initial version
This commit is contained in:
@@ -0,0 +1,41 @@
|
||||
import Foundation
|
||||
|
||||
enum AllocationTargetStore {
|
||||
private static let targetsKey = "allocationTargets"
|
||||
|
||||
static func target(for categoryId: UUID) -> Double? {
|
||||
loadTargets()[categoryId.uuidString]
|
||||
}
|
||||
|
||||
static func setTarget(_ value: Double?, for categoryId: UUID) {
|
||||
var targets = loadTargets()
|
||||
let key = categoryId.uuidString
|
||||
if let value, value > 0 {
|
||||
targets[key] = value
|
||||
} else {
|
||||
targets.removeValue(forKey: key)
|
||||
}
|
||||
saveTargets(targets)
|
||||
}
|
||||
|
||||
static func totalTargetPercentage(for categoryIds: [UUID]) -> Double {
|
||||
let targets = loadTargets()
|
||||
return categoryIds.reduce(0) { total, id in
|
||||
total + (targets[id.uuidString] ?? 0)
|
||||
}
|
||||
}
|
||||
|
||||
private static func loadTargets() -> [String: Double] {
|
||||
guard let data = UserDefaults.standard.data(forKey: targetsKey),
|
||||
let decoded = try? JSONDecoder().decode([String: Double].self, from: data) else {
|
||||
return [:]
|
||||
}
|
||||
return decoded
|
||||
}
|
||||
|
||||
private static func saveTargets(_ targets: [String: Double]) {
|
||||
if let data = try? JSONEncoder().encode(targets) {
|
||||
UserDefaults.standard.set(data, forKey: targetsKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import Foundation
|
||||
import LocalAuthentication
|
||||
|
||||
enum AppLockService {
|
||||
static func canUseBiometrics() -> Bool {
|
||||
let context = LAContext()
|
||||
var error: NSError?
|
||||
return context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error)
|
||||
}
|
||||
|
||||
static func authenticate(reason: String, completion: @escaping (Bool) -> Void) {
|
||||
let context = LAContext()
|
||||
context.evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, localizedReason: reason) { success, _ in
|
||||
DispatchQueue.main.async {
|
||||
completion(success)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import Foundation
|
||||
|
||||
enum AppConstants {
|
||||
// MARK: - App Info
|
||||
|
||||
static let appName = "Portfolio Journal"
|
||||
static let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0"
|
||||
static let buildNumber = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "1"
|
||||
|
||||
// MARK: - Bundle Identifiers
|
||||
|
||||
static let bundleIdentifier = "com.alexandrevazquez.portfoliojournal"
|
||||
static let appGroupIdentifier = "group.com.alexandrevazquez.portfoliojournal"
|
||||
static let cloudKitContainerIdentifier = "iCloud.com.alexandrevazquez.portfoliojournal"
|
||||
|
||||
// MARK: - StoreKit
|
||||
|
||||
static let premiumProductID = "com.portfoliojournal.premium"
|
||||
static let premiumPrice = "€4.69"
|
||||
|
||||
// MARK: - AdMob
|
||||
|
||||
#if DEBUG
|
||||
static let adMobAppID = "ca-app-pub-3940256099942544~1458002511" // Test App ID
|
||||
static let bannerAdUnitID = "ca-app-pub-3940256099942544/2934735716" // Test Banner
|
||||
#else
|
||||
static let adMobAppID = "ca-app-pub-XXXXXXXXXXXXXXXX~YYYYYYYYYY" // Replace with real App ID
|
||||
static let bannerAdUnitID = "ca-app-pub-XXXXXXXXXXXXXXXX/YYYYYYYYYY" // Replace with real Ad Unit ID
|
||||
#endif
|
||||
|
||||
// MARK: - Currency
|
||||
|
||||
static let defaultCurrency = "EUR"
|
||||
static let currencySymbol = "€"
|
||||
|
||||
// MARK: - Freemium Limits
|
||||
|
||||
static let maxFreeSources = 5
|
||||
static let maxFreeHistoricalMonths = 12
|
||||
|
||||
// MARK: - UI Constants
|
||||
|
||||
enum UI {
|
||||
static let cornerRadius: CGFloat = 12
|
||||
static let smallCornerRadius: CGFloat = 8
|
||||
static let largeCornerRadius: CGFloat = 16
|
||||
|
||||
static let padding: CGFloat = 16
|
||||
static let smallPadding: CGFloat = 8
|
||||
static let largePadding: CGFloat = 24
|
||||
|
||||
static let iconSize: CGFloat = 24
|
||||
static let smallIconSize: CGFloat = 16
|
||||
static let largeIconSize: CGFloat = 32
|
||||
|
||||
static let bannerAdHeight: CGFloat = 50
|
||||
static let tabBarHeight: CGFloat = 49
|
||||
|
||||
static let cardShadowRadius: CGFloat = 4
|
||||
static let cardShadowOpacity: CGFloat = 0.1
|
||||
}
|
||||
|
||||
// MARK: - Animation
|
||||
|
||||
enum Animation {
|
||||
static let defaultDuration: Double = 0.3
|
||||
static let shortDuration: Double = 0.15
|
||||
static let longDuration: Double = 0.5
|
||||
}
|
||||
|
||||
// MARK: - Charts
|
||||
|
||||
enum Charts {
|
||||
static let defaultMonthsToShow = 12
|
||||
static let predictionMonths = 12
|
||||
static let minDataPointsForPrediction = 3
|
||||
static let confidenceIntervalPercentage = 0.15
|
||||
}
|
||||
|
||||
// MARK: - Notifications
|
||||
|
||||
enum Notifications {
|
||||
static let defaultHour = 9
|
||||
static let defaultMinute = 0
|
||||
static let categoryIdentifier = "INVESTMENT_REMINDER"
|
||||
}
|
||||
|
||||
// MARK: - Storage Keys
|
||||
|
||||
enum StorageKeys {
|
||||
static let onboardingCompleted = "onboardingCompleted"
|
||||
static let adConsentObtained = "adConsentObtained"
|
||||
static let lastSyncDate = "lastSyncDate"
|
||||
static let selectedCategoryFilter = "selectedCategoryFilter"
|
||||
static let preferredChartType = "preferredChartType"
|
||||
}
|
||||
|
||||
// MARK: - Deep Links
|
||||
|
||||
enum DeepLinks {
|
||||
static let scheme = "portfoliojournal"
|
||||
static let sourceDetail = "source"
|
||||
static let addSnapshot = "addSnapshot"
|
||||
static let premium = "premium"
|
||||
}
|
||||
|
||||
// MARK: - URLs
|
||||
|
||||
enum URLs {
|
||||
static let privacyPolicy = "https://portfoliojournal.app/privacy.html"
|
||||
static let termsOfService = "https://portfoliojournal.app/terms.html"
|
||||
static let support = "https://portfoliojournal.app/support.html"
|
||||
static let appStore = "https://apps.apple.com/app/idXXXXXXXXXX"
|
||||
}
|
||||
|
||||
// MARK: - Feature Flags
|
||||
|
||||
enum Features {
|
||||
static let enablePredictions = true
|
||||
static let enableExport = true
|
||||
static let enableWidgets = true
|
||||
static let enableNotifications = true
|
||||
static let enableAnalytics = true
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - SF Symbols
|
||||
|
||||
enum SFSymbol {
|
||||
// Navigation
|
||||
static let dashboard = "chart.pie.fill"
|
||||
static let sources = "list.bullet"
|
||||
static let charts = "chart.xyaxis.line"
|
||||
static let settings = "gearshape.fill"
|
||||
|
||||
// Actions
|
||||
static let add = "plus"
|
||||
static let edit = "pencil"
|
||||
static let delete = "trash"
|
||||
static let share = "square.and.arrow.up"
|
||||
static let export = "arrow.up.doc"
|
||||
|
||||
// Status
|
||||
static let checkmark = "checkmark.circle.fill"
|
||||
static let warning = "exclamationmark.triangle.fill"
|
||||
static let error = "xmark.circle.fill"
|
||||
static let info = "info.circle.fill"
|
||||
|
||||
// Financial
|
||||
static let trendUp = "arrow.up.right"
|
||||
static let trendDown = "arrow.down.right"
|
||||
static let money = "eurosign.circle.fill"
|
||||
static let chart = "chart.line.uptrend.xyaxis"
|
||||
|
||||
// Categories
|
||||
static let stocks = "chart.line.uptrend.xyaxis"
|
||||
static let bonds = "building.columns.fill"
|
||||
static let realEstate = "house.fill"
|
||||
static let crypto = "bitcoinsign.circle.fill"
|
||||
static let cash = "banknote.fill"
|
||||
static let etf = "chart.bar.fill"
|
||||
static let retirement = "person.fill"
|
||||
static let other = "ellipsis.circle.fill"
|
||||
|
||||
// Premium
|
||||
static let premium = "crown.fill"
|
||||
static let lock = "lock.fill"
|
||||
static let unlock = "lock.open.fill"
|
||||
|
||||
// Misc
|
||||
static let calendar = "calendar"
|
||||
static let notification = "bell.fill"
|
||||
static let refresh = "arrow.clockwise"
|
||||
static let close = "xmark"
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import Foundation
|
||||
|
||||
enum CurrencyFormatter {
|
||||
static func currentCurrencyCode() -> String {
|
||||
let context = CoreDataStack.shared.viewContext
|
||||
return AppSettings.getOrCreate(in: context).currency
|
||||
}
|
||||
|
||||
static func format(_ decimal: Decimal, style: NumberFormatter.Style = .currency, maximumFractionDigits: Int = 2) -> String {
|
||||
let formatter = NumberFormatter()
|
||||
formatter.numberStyle = style
|
||||
formatter.currencyCode = currentCurrencyCode()
|
||||
formatter.maximumFractionDigits = maximumFractionDigits
|
||||
return formatter.string(from: decimal as NSDecimalNumber) ?? "\(decimal)"
|
||||
}
|
||||
|
||||
static func symbol(for code: String) -> String {
|
||||
let formatter = NumberFormatter()
|
||||
formatter.numberStyle = .currency
|
||||
formatter.currencyCode = code
|
||||
return formatter.currencySymbol ?? code
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import Foundation
|
||||
|
||||
enum CurrencyPicker {
|
||||
static let commonCodes: [String] = [
|
||||
"EUR", "USD", "GBP", "CHF", "JPY",
|
||||
"CAD", "AUD", "SEK", "NOK", "DKK"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import Foundation
|
||||
|
||||
struct DashboardSectionConfig: Identifiable, Codable, Hashable {
|
||||
let id: String
|
||||
var isVisible: Bool
|
||||
var isCollapsed: Bool
|
||||
}
|
||||
|
||||
enum DashboardSection: String, CaseIterable, Identifiable {
|
||||
case totalValue
|
||||
case monthlyCheckIn
|
||||
case momentumStreaks
|
||||
case monthlySummary
|
||||
case evolution
|
||||
case categoryBreakdown
|
||||
case goals
|
||||
case pendingUpdates
|
||||
case periodReturns
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .totalValue:
|
||||
return "Total Portfolio Value"
|
||||
case .monthlyCheckIn:
|
||||
return "Monthly Check-in"
|
||||
case .momentumStreaks:
|
||||
return "Momentum & Streaks"
|
||||
case .monthlySummary:
|
||||
return "Cashflow vs Growth"
|
||||
case .evolution:
|
||||
return "Portfolio Evolution"
|
||||
case .categoryBreakdown:
|
||||
return "By Category"
|
||||
case .goals:
|
||||
return "Goals"
|
||||
case .pendingUpdates:
|
||||
return "Pending Updates"
|
||||
case .periodReturns:
|
||||
return "Returns"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum DashboardLayoutStore {
|
||||
private static let storageKey = "dashboardLayoutConfig"
|
||||
|
||||
static func load() -> [DashboardSectionConfig] {
|
||||
let defaults = defaultConfigs()
|
||||
guard let data = UserDefaults.standard.data(forKey: storageKey),
|
||||
let decoded = try? JSONDecoder().decode([DashboardSectionConfig].self, from: data) else {
|
||||
return defaults
|
||||
}
|
||||
|
||||
var merged: [DashboardSectionConfig] = []
|
||||
for config in decoded {
|
||||
if let section = DashboardSection(rawValue: config.id) {
|
||||
merged.append(config)
|
||||
} else {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
for section in DashboardSection.allCases {
|
||||
if !merged.contains(where: { $0.id == section.id }) {
|
||||
merged.append(defaults.first(where: { $0.id == section.id }) ?? DashboardSectionConfig(
|
||||
id: section.id,
|
||||
isVisible: true,
|
||||
isCollapsed: false
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
return merged
|
||||
}
|
||||
|
||||
static func save(_ configs: [DashboardSectionConfig]) {
|
||||
guard let data = try? JSONEncoder().encode(configs) else { return }
|
||||
UserDefaults.standard.set(data, forKey: storageKey)
|
||||
}
|
||||
|
||||
static func reset() {
|
||||
UserDefaults.standard.removeObject(forKey: storageKey)
|
||||
}
|
||||
|
||||
private static func defaultConfigs() -> [DashboardSectionConfig] {
|
||||
DashboardSection.allCases.map { section in
|
||||
DashboardSectionConfig(
|
||||
id: section.id,
|
||||
isVisible: true,
|
||||
isCollapsed: false
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import SwiftUI
|
||||
|
||||
extension Color {
|
||||
// MARK: - Hex Initialization
|
||||
|
||||
init?(hex: String) {
|
||||
var hexSanitized = hex.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
hexSanitized = hexSanitized.replacingOccurrences(of: "#", with: "")
|
||||
|
||||
var rgb: UInt64 = 0
|
||||
|
||||
guard Scanner(string: hexSanitized).scanHexInt64(&rgb) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let length = hexSanitized.count
|
||||
|
||||
switch length {
|
||||
case 6:
|
||||
self.init(
|
||||
red: Double((rgb & 0xFF0000) >> 16) / 255.0,
|
||||
green: Double((rgb & 0x00FF00) >> 8) / 255.0,
|
||||
blue: Double(rgb & 0x0000FF) / 255.0
|
||||
)
|
||||
case 8:
|
||||
self.init(
|
||||
red: Double((rgb & 0xFF000000) >> 24) / 255.0,
|
||||
green: Double((rgb & 0x00FF0000) >> 16) / 255.0,
|
||||
blue: Double((rgb & 0x0000FF00) >> 8) / 255.0,
|
||||
opacity: Double(rgb & 0x000000FF) / 255.0
|
||||
)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Hex String Output
|
||||
|
||||
var hexString: String {
|
||||
guard let components = UIColor(self).cgColor.components else {
|
||||
return "#000000"
|
||||
}
|
||||
|
||||
let r = Int(components[0] * 255.0)
|
||||
let g = Int(components[1] * 255.0)
|
||||
let b = Int(components[2] * 255.0)
|
||||
|
||||
return String(format: "#%02X%02X%02X", r, g, b)
|
||||
}
|
||||
|
||||
// MARK: - App Colors
|
||||
|
||||
static let appPrimary = Color(hex: "#3B82F6") ?? .blue
|
||||
static let appSecondary = Color(hex: "#10B981") ?? .green
|
||||
static let appAccent = Color(hex: "#F59E0B") ?? .orange
|
||||
static let appError = Color(hex: "#EF4444") ?? .red
|
||||
static let appSuccess = Color(hex: "#10B981") ?? .green
|
||||
static let appWarning = Color(hex: "#F59E0B") ?? .orange
|
||||
|
||||
// MARK: - Financial Colors
|
||||
|
||||
static let positiveGreen = Color(hex: "#10B981") ?? .green
|
||||
static let negativeRed = Color(hex: "#EF4444") ?? .red
|
||||
static let neutralGray = Color(hex: "#6B7280") ?? .gray
|
||||
|
||||
static func financialColor(for value: Decimal) -> Color {
|
||||
if value > 0 {
|
||||
return .positiveGreen
|
||||
} else if value < 0 {
|
||||
return .negativeRed
|
||||
} else {
|
||||
return .neutralGray
|
||||
}
|
||||
}
|
||||
|
||||
static func financialColor(for value: Double) -> Color {
|
||||
if value > 0 {
|
||||
return .positiveGreen
|
||||
} else if value < 0 {
|
||||
return .negativeRed
|
||||
} else {
|
||||
return .neutralGray
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Category Colors
|
||||
|
||||
static let categoryColors: [String] = [
|
||||
"#3B82F6", // Blue
|
||||
"#10B981", // Green
|
||||
"#F59E0B", // Amber
|
||||
"#EF4444", // Red
|
||||
"#8B5CF6", // Purple
|
||||
"#EC4899", // Pink
|
||||
"#14B8A6", // Teal
|
||||
"#F97316", // Orange
|
||||
"#6366F1", // Indigo
|
||||
"#84CC16", // Lime
|
||||
"#06B6D4", // Cyan
|
||||
"#A855F7" // Violet
|
||||
]
|
||||
|
||||
static func categoryColor(at index: Int) -> Color {
|
||||
let hex = categoryColors[index % categoryColors.count]
|
||||
return Color(hex: hex) ?? .blue
|
||||
}
|
||||
|
||||
// MARK: - Chart Colors
|
||||
|
||||
static let chartColors: [Color] = categoryColors.compactMap { Color(hex: $0) }
|
||||
|
||||
// MARK: - Adjustments
|
||||
|
||||
func lighter(by percentage: Double = 0.2) -> Color {
|
||||
adjustBrightness(by: abs(percentage))
|
||||
}
|
||||
|
||||
func darker(by percentage: Double = 0.2) -> Color {
|
||||
adjustBrightness(by: -abs(percentage))
|
||||
}
|
||||
|
||||
private func adjustBrightness(by percentage: Double) -> Color {
|
||||
var hue: CGFloat = 0
|
||||
var saturation: CGFloat = 0
|
||||
var brightness: CGFloat = 0
|
||||
var alpha: CGFloat = 0
|
||||
|
||||
UIColor(self).getHue(&hue, saturation: &saturation, brightness: &brightness, alpha: &alpha)
|
||||
|
||||
let newBrightness = max(0, min(1, brightness + CGFloat(percentage)))
|
||||
|
||||
return Color(UIColor(hue: hue, saturation: saturation, brightness: newBrightness, alpha: alpha))
|
||||
}
|
||||
|
||||
// MARK: - Opacity Variants
|
||||
|
||||
var soft: Color {
|
||||
self.opacity(0.1)
|
||||
}
|
||||
|
||||
var medium: Color {
|
||||
self.opacity(0.5)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Gradient Extensions
|
||||
|
||||
extension LinearGradient {
|
||||
static let appPrimaryGradient = LinearGradient(
|
||||
colors: [Color.appPrimary, Color.appPrimary.lighter()],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
|
||||
static let positiveGradient = LinearGradient(
|
||||
colors: [Color.positiveGreen.lighter(), Color.positiveGreen],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
|
||||
static let negativeGradient = LinearGradient(
|
||||
colors: [Color.negativeRed.lighter(), Color.negativeRed],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
import Foundation
|
||||
|
||||
extension Date {
|
||||
// MARK: - Formatting
|
||||
|
||||
var shortDateString: String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateStyle = .short
|
||||
formatter.timeStyle = .none
|
||||
return formatter.string(from: self)
|
||||
}
|
||||
|
||||
var mediumDateString: String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateStyle = .medium
|
||||
formatter.timeStyle = .none
|
||||
return formatter.string(from: self)
|
||||
}
|
||||
|
||||
var longDateString: String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateStyle = .long
|
||||
formatter.timeStyle = .none
|
||||
return formatter.string(from: self)
|
||||
}
|
||||
|
||||
var monthYearString: String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "MMM yyyy"
|
||||
return formatter.string(from: self)
|
||||
}
|
||||
|
||||
var yearString: String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "yyyy"
|
||||
return formatter.string(from: self)
|
||||
}
|
||||
|
||||
var iso8601String: String {
|
||||
ISO8601DateFormatter().string(from: self)
|
||||
}
|
||||
|
||||
// MARK: - Components
|
||||
|
||||
var startOfDay: Date {
|
||||
Calendar.current.startOfDay(for: self)
|
||||
}
|
||||
|
||||
var startOfMonth: Date {
|
||||
let components = Calendar.current.dateComponents([.year, .month], from: self)
|
||||
return Calendar.current.date(from: components) ?? self
|
||||
}
|
||||
|
||||
var startOfYear: Date {
|
||||
let components = Calendar.current.dateComponents([.year], from: self)
|
||||
return Calendar.current.date(from: components) ?? self
|
||||
}
|
||||
|
||||
var endOfMonth: Date {
|
||||
let startOfNextMonth = Calendar.current.date(
|
||||
byAdding: .month,
|
||||
value: 1,
|
||||
to: startOfMonth
|
||||
) ?? self
|
||||
return Calendar.current.date(byAdding: .day, value: -1, to: startOfNextMonth) ?? self
|
||||
}
|
||||
|
||||
// MARK: - Comparisons
|
||||
|
||||
func isSameDay(as other: Date) -> Bool {
|
||||
Calendar.current.isDate(self, inSameDayAs: other)
|
||||
}
|
||||
|
||||
func isSameMonth(as other: Date) -> Bool {
|
||||
let selfComponents = Calendar.current.dateComponents([.year, .month], from: self)
|
||||
let otherComponents = Calendar.current.dateComponents([.year, .month], from: other)
|
||||
return selfComponents.year == otherComponents.year &&
|
||||
selfComponents.month == otherComponents.month
|
||||
}
|
||||
|
||||
func isSameYear(as other: Date) -> Bool {
|
||||
Calendar.current.component(.year, from: self) ==
|
||||
Calendar.current.component(.year, from: other)
|
||||
}
|
||||
|
||||
var isToday: Bool {
|
||||
Calendar.current.isDateInToday(self)
|
||||
}
|
||||
|
||||
var isYesterday: Bool {
|
||||
Calendar.current.isDateInYesterday(self)
|
||||
}
|
||||
|
||||
var isThisMonth: Bool {
|
||||
isSameMonth(as: Date())
|
||||
}
|
||||
|
||||
var isThisYear: Bool {
|
||||
isSameYear(as: Date())
|
||||
}
|
||||
|
||||
// MARK: - Calculations
|
||||
|
||||
func adding(days: Int) -> Date {
|
||||
Calendar.current.date(byAdding: .day, value: days, to: self) ?? self
|
||||
}
|
||||
|
||||
func adding(months: Int) -> Date {
|
||||
Calendar.current.date(byAdding: .month, value: months, to: self) ?? self
|
||||
}
|
||||
|
||||
func adding(years: Int) -> Date {
|
||||
Calendar.current.date(byAdding: .year, value: years, to: self) ?? self
|
||||
}
|
||||
|
||||
func monthsBetween(_ other: Date) -> Int {
|
||||
let components = Calendar.current.dateComponents([.month], from: self, to: other)
|
||||
return components.month ?? 0
|
||||
}
|
||||
|
||||
func daysBetween(_ other: Date) -> Int {
|
||||
let components = Calendar.current.dateComponents([.day], from: self, to: other)
|
||||
return components.day ?? 0
|
||||
}
|
||||
|
||||
func yearsBetween(_ other: Date) -> Double {
|
||||
let days = Double(daysBetween(other))
|
||||
return days / 365.25
|
||||
}
|
||||
|
||||
// MARK: - Relative Description
|
||||
|
||||
var relativeDescription: String {
|
||||
let formatter = RelativeDateTimeFormatter()
|
||||
formatter.unitsStyle = .abbreviated
|
||||
return formatter.localizedString(for: self, relativeTo: Date())
|
||||
}
|
||||
|
||||
var friendlyDescription: String {
|
||||
if isToday {
|
||||
return String(localized: "date_today")
|
||||
} else if isYesterday {
|
||||
return String(localized: "date_yesterday")
|
||||
} else if isThisMonth {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "EEEE, d"
|
||||
return formatter.string(from: self)
|
||||
} else if isThisYear {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "MMM d"
|
||||
return formatter.string(from: self)
|
||||
} else {
|
||||
return mediumDateString
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Date Range
|
||||
|
||||
struct DateRange {
|
||||
let start: Date
|
||||
let end: Date
|
||||
|
||||
static var thisMonth: DateRange {
|
||||
let now = Date()
|
||||
return DateRange(start: now.startOfMonth, end: now)
|
||||
}
|
||||
|
||||
static var lastMonth: DateRange {
|
||||
let now = Date()
|
||||
let lastMonth = now.adding(months: -1)
|
||||
return DateRange(start: lastMonth.startOfMonth, end: lastMonth.endOfMonth)
|
||||
}
|
||||
|
||||
static var thisYear: DateRange {
|
||||
let now = Date()
|
||||
return DateRange(start: now.startOfYear, end: now)
|
||||
}
|
||||
|
||||
static var lastYear: DateRange {
|
||||
let now = Date()
|
||||
let lastYear = now.adding(years: -1)
|
||||
return DateRange(start: lastYear.startOfYear, end: now.startOfYear.adding(days: -1))
|
||||
}
|
||||
|
||||
static func month(containing date: Date) -> DateRange {
|
||||
DateRange(start: date.startOfMonth, end: date.endOfMonth)
|
||||
}
|
||||
|
||||
static func last(months: Int) -> DateRange {
|
||||
let now = Date()
|
||||
let start = now.adding(months: -months)
|
||||
return DateRange(start: start, end: now)
|
||||
}
|
||||
|
||||
func contains(_ date: Date) -> Bool {
|
||||
date >= start && date <= end
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import Foundation
|
||||
|
||||
extension Decimal {
|
||||
// MARK: - Performance: Shared formatters (avoid creating on every call)
|
||||
private static let percentFormatter: NumberFormatter = {
|
||||
let formatter = NumberFormatter()
|
||||
formatter.numberStyle = .percent
|
||||
formatter.maximumFractionDigits = 2
|
||||
formatter.multiplier = 1
|
||||
return formatter
|
||||
}()
|
||||
|
||||
private static let decimalFormatter: NumberFormatter = {
|
||||
let formatter = NumberFormatter()
|
||||
formatter.numberStyle = .decimal
|
||||
formatter.maximumFractionDigits = 2
|
||||
return formatter
|
||||
}()
|
||||
|
||||
// MARK: - Performance: Cached currency symbol
|
||||
private static var _cachedCurrencySymbol: String?
|
||||
private static var currencySymbolCacheTime: Date?
|
||||
|
||||
private static var cachedCurrencySymbol: String {
|
||||
// Refresh cache every 60 seconds to pick up settings changes
|
||||
let now = Date()
|
||||
if let cached = _cachedCurrencySymbol,
|
||||
let cacheTime = currencySymbolCacheTime,
|
||||
now.timeIntervalSince(cacheTime) < 60 {
|
||||
return cached
|
||||
}
|
||||
let symbol = AppSettings.getOrCreate(in: CoreDataStack.shared.viewContext).currencySymbol
|
||||
_cachedCurrencySymbol = symbol
|
||||
currencySymbolCacheTime = now
|
||||
return symbol
|
||||
}
|
||||
|
||||
/// Call this when currency settings change to invalidate the cache
|
||||
static func invalidateCurrencyCache() {
|
||||
_cachedCurrencySymbol = nil
|
||||
currencySymbolCacheTime = nil
|
||||
}
|
||||
|
||||
// MARK: - Formatting
|
||||
|
||||
var currencyString: String {
|
||||
CurrencyFormatter.format(self, style: .currency, maximumFractionDigits: 2)
|
||||
}
|
||||
|
||||
var compactCurrencyString: String {
|
||||
CurrencyFormatter.format(self, style: .currency, maximumFractionDigits: 0)
|
||||
}
|
||||
|
||||
var shortCurrencyString: String {
|
||||
let value = NSDecimalNumber(decimal: self).doubleValue
|
||||
let symbol = Self.cachedCurrencySymbol
|
||||
|
||||
switch Swift.abs(value) {
|
||||
case 1_000_000...:
|
||||
return String(format: "%@%.1fM", symbol, value / 1_000_000)
|
||||
case 1_000...:
|
||||
return String(format: "%@%.1fK", symbol, value / 1_000)
|
||||
default:
|
||||
return compactCurrencyString
|
||||
}
|
||||
}
|
||||
|
||||
var percentageString: String {
|
||||
Self.percentFormatter.string(from: self as NSDecimalNumber) ?? "0%"
|
||||
}
|
||||
|
||||
var signedPercentageString: String {
|
||||
let prefix = self >= 0 ? "+" : ""
|
||||
return prefix + percentageString
|
||||
}
|
||||
|
||||
var decimalString: String {
|
||||
Self.decimalFormatter.string(from: self as NSDecimalNumber) ?? "0"
|
||||
}
|
||||
|
||||
// MARK: - Conversions
|
||||
|
||||
var doubleValue: Double {
|
||||
NSDecimalNumber(decimal: self).doubleValue
|
||||
}
|
||||
|
||||
var intValue: Int {
|
||||
NSDecimalNumber(decimal: self).intValue
|
||||
}
|
||||
|
||||
// MARK: - Math Operations
|
||||
|
||||
var abs: Decimal {
|
||||
self < 0 ? -self : self
|
||||
}
|
||||
|
||||
func rounded(scale: Int = 2) -> Decimal {
|
||||
var result = Decimal()
|
||||
var mutableSelf = self
|
||||
NSDecimalRound(&result, &mutableSelf, scale, .plain)
|
||||
return result
|
||||
}
|
||||
|
||||
// MARK: - Comparisons
|
||||
|
||||
var isPositive: Bool {
|
||||
self > 0
|
||||
}
|
||||
|
||||
var isNegative: Bool {
|
||||
self < 0
|
||||
}
|
||||
|
||||
var isZero: Bool {
|
||||
self == 0
|
||||
}
|
||||
|
||||
// MARK: - Static Helpers
|
||||
|
||||
static func from(_ double: Double) -> Decimal {
|
||||
Decimal(double)
|
||||
}
|
||||
|
||||
static func from(_ string: String) -> Decimal? {
|
||||
let formatter = NumberFormatter()
|
||||
formatter.numberStyle = .decimal
|
||||
return formatter.number(from: string)?.decimalValue
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - NSDecimalNumber Extension
|
||||
|
||||
extension NSDecimalNumber {
|
||||
var currencyString: String {
|
||||
decimalValue.currencyString
|
||||
}
|
||||
|
||||
var compactCurrencyString: String {
|
||||
decimalValue.compactCurrencyString
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Optional Decimal
|
||||
|
||||
extension Optional where Wrapped == Decimal {
|
||||
var orZero: Decimal {
|
||||
self ?? Decimal.zero
|
||||
}
|
||||
|
||||
var currencyString: String {
|
||||
(self ?? Decimal.zero).currencyString
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
import Foundation
|
||||
import Combine
|
||||
|
||||
enum FreemiumLimits {
|
||||
static let maxSources = 5
|
||||
static let maxHistoricalMonths = 12
|
||||
}
|
||||
|
||||
class FreemiumValidator: ObservableObject {
|
||||
private let iapService: IAPService
|
||||
|
||||
init(iapService: IAPService) {
|
||||
self.iapService = iapService
|
||||
}
|
||||
|
||||
// MARK: - Source Limits
|
||||
|
||||
var isPremium: Bool {
|
||||
iapService.isPremium
|
||||
}
|
||||
|
||||
func canAddSource(currentCount: Int) -> Bool {
|
||||
if iapService.isPremium { return true }
|
||||
return currentCount < FreemiumLimits.maxSources
|
||||
}
|
||||
|
||||
func remainingSources(currentCount: Int) -> Int {
|
||||
if iapService.isPremium { return Int.max }
|
||||
return max(0, FreemiumLimits.maxSources - currentCount)
|
||||
}
|
||||
|
||||
var sourceLimit: Int {
|
||||
iapService.isPremium ? Int.max : FreemiumLimits.maxSources
|
||||
}
|
||||
|
||||
var sourceLimitDescription: String {
|
||||
if iapService.isPremium {
|
||||
return "Unlimited"
|
||||
}
|
||||
return "\(FreemiumLimits.maxSources) sources"
|
||||
}
|
||||
|
||||
// MARK: - Historical Data Limits
|
||||
|
||||
func filterSnapshots(_ snapshots: [Snapshot]) -> [Snapshot] {
|
||||
if iapService.isPremium { return snapshots }
|
||||
|
||||
let cutoffDate = Calendar.current.date(
|
||||
byAdding: .month,
|
||||
value: -FreemiumLimits.maxHistoricalMonths,
|
||||
to: Date()
|
||||
) ?? Date()
|
||||
|
||||
return snapshots.filter { $0.date >= cutoffDate }
|
||||
}
|
||||
|
||||
func isSnapshotAccessible(_ snapshot: Snapshot) -> Bool {
|
||||
if iapService.isPremium { return true }
|
||||
|
||||
let cutoffDate = Calendar.current.date(
|
||||
byAdding: .month,
|
||||
value: -FreemiumLimits.maxHistoricalMonths,
|
||||
to: Date()
|
||||
) ?? Date()
|
||||
|
||||
return snapshot.date >= cutoffDate
|
||||
}
|
||||
|
||||
var historicalLimit: Int {
|
||||
iapService.isPremium ? Int.max : FreemiumLimits.maxHistoricalMonths
|
||||
}
|
||||
|
||||
var historicalLimitDescription: String {
|
||||
if iapService.isPremium {
|
||||
return "Full history"
|
||||
}
|
||||
return "Last \(FreemiumLimits.maxHistoricalMonths) months"
|
||||
}
|
||||
|
||||
// MARK: - Feature Access
|
||||
|
||||
func canExport() -> Bool {
|
||||
return iapService.isPremium
|
||||
}
|
||||
|
||||
func canViewPredictions() -> Bool {
|
||||
return iapService.isPremium
|
||||
}
|
||||
|
||||
func canViewAdvancedCharts() -> Bool {
|
||||
return iapService.isPremium
|
||||
}
|
||||
|
||||
func canAccessFeature(_ feature: PremiumFeature) -> Bool {
|
||||
if iapService.isPremium { return true }
|
||||
|
||||
switch feature {
|
||||
case .multipleAccounts:
|
||||
return false
|
||||
case .unlimitedSources:
|
||||
return false
|
||||
case .fullHistory:
|
||||
return false
|
||||
case .advancedCharts:
|
||||
return false
|
||||
case .predictions:
|
||||
return false
|
||||
case .export:
|
||||
return false
|
||||
case .noAds:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Premium Features Enum
|
||||
|
||||
enum PremiumFeature: String, CaseIterable, Identifiable {
|
||||
case multipleAccounts = "multiple_accounts"
|
||||
case unlimitedSources = "unlimited_sources"
|
||||
case fullHistory = "full_history"
|
||||
case advancedCharts = "advanced_charts"
|
||||
case predictions = "predictions"
|
||||
case export = "export"
|
||||
case noAds = "no_ads"
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .multipleAccounts: return "Multiple Accounts"
|
||||
case .unlimitedSources: return "Unlimited Sources"
|
||||
case .fullHistory: return "Full History"
|
||||
case .advancedCharts: return "Advanced Charts"
|
||||
case .predictions: return "Predictions"
|
||||
case .export: return "Export Data"
|
||||
case .noAds: return "No Ads"
|
||||
}
|
||||
}
|
||||
|
||||
var icon: String {
|
||||
switch self {
|
||||
case .multipleAccounts: return "person.2"
|
||||
case .unlimitedSources: return "infinity"
|
||||
case .fullHistory: return "clock.arrow.circlepath"
|
||||
case .advancedCharts: return "chart.bar.xaxis"
|
||||
case .predictions: return "wand.and.stars"
|
||||
case .export: return "square.and.arrow.up"
|
||||
case .noAds: return "xmark.circle"
|
||||
}
|
||||
}
|
||||
|
||||
var description: String {
|
||||
switch self {
|
||||
case .multipleAccounts:
|
||||
return "Track separate portfolios for family or business"
|
||||
case .unlimitedSources:
|
||||
return "Track as many investment sources as you want"
|
||||
case .fullHistory:
|
||||
return "Access your complete investment history"
|
||||
case .advancedCharts:
|
||||
return "5 types of detailed analytics charts"
|
||||
case .predictions:
|
||||
return "AI-powered 12-month forecasts"
|
||||
case .export:
|
||||
return "Export to CSV and JSON formats"
|
||||
case .noAds:
|
||||
return "Ad-free experience forever"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Paywall Triggers
|
||||
|
||||
enum PaywallTrigger: String {
|
||||
case sourceLimit = "source_limit"
|
||||
case historyLimit = "history_limit"
|
||||
case advancedCharts = "advanced_charts"
|
||||
case predictions = "predictions"
|
||||
case export = "export"
|
||||
case settingsUpgrade = "settings_upgrade"
|
||||
case manualTap = "manual_tap"
|
||||
}
|
||||
|
||||
func shouldShowPaywall(for trigger: PaywallTrigger) -> Bool {
|
||||
guard !iapService.isPremium else { return false }
|
||||
|
||||
switch trigger {
|
||||
case .sourceLimit, .historyLimit, .advancedCharts, .predictions, .export:
|
||||
return true
|
||||
case .settingsUpgrade, .manualTap:
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import Foundation
|
||||
import Security
|
||||
|
||||
enum KeychainService {
|
||||
private static let service = "PortfolioJournal"
|
||||
private static let pinKey = "appLockPin"
|
||||
|
||||
static func savePin(_ pin: String) -> Bool {
|
||||
guard let data = pin.data(using: .utf8) else { return false }
|
||||
deletePin()
|
||||
let query: [CFString: Any] = [
|
||||
kSecClass: kSecClassGenericPassword,
|
||||
kSecAttrService: service,
|
||||
kSecAttrAccount: pinKey,
|
||||
kSecValueData: data
|
||||
]
|
||||
return SecItemAdd(query as CFDictionary, nil) == errSecSuccess
|
||||
}
|
||||
|
||||
static func readPin() -> String? {
|
||||
let query: [CFString: Any] = [
|
||||
kSecClass: kSecClassGenericPassword,
|
||||
kSecAttrService: service,
|
||||
kSecAttrAccount: pinKey,
|
||||
kSecReturnData: true,
|
||||
kSecMatchLimit: kSecMatchLimitOne
|
||||
]
|
||||
|
||||
var result: AnyObject?
|
||||
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
||||
guard status == errSecSuccess,
|
||||
let data = result as? Data,
|
||||
let pin = String(data: data, encoding: .utf8) else {
|
||||
return nil
|
||||
}
|
||||
return pin
|
||||
}
|
||||
|
||||
static func deletePin() {
|
||||
let query: [CFString: Any] = [
|
||||
kSecClass: kSecClassGenericPassword,
|
||||
kSecAttrService: service,
|
||||
kSecAttrAccount: pinKey
|
||||
]
|
||||
SecItemDelete(query as CFDictionary)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,455 @@
|
||||
import Foundation
|
||||
|
||||
enum MonthlyCheckInStore {
|
||||
private static let notesKey = "monthlyCheckInNotes"
|
||||
private static let completionsKey = "monthlyCheckInCompletions"
|
||||
private static let legacyLastCheckInKey = "lastCheckInDate"
|
||||
private static let entriesKey = "monthlyCheckInEntries"
|
||||
|
||||
// MARK: - Public Accessors
|
||||
|
||||
static func note(for date: Date) -> String {
|
||||
entry(for: date)?.note ?? ""
|
||||
}
|
||||
|
||||
static func setNote(_ note: String, for date: Date) {
|
||||
updateEntry(for: date) { entry in
|
||||
let trimmed = note.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
entry.note = trimmed.isEmpty ? nil : note
|
||||
}
|
||||
}
|
||||
|
||||
static func rating(for date: Date) -> Int? {
|
||||
entry(for: date)?.rating
|
||||
}
|
||||
|
||||
static func setRating(_ rating: Int?, for date: Date) {
|
||||
updateEntry(for: date) { entry in
|
||||
if let rating, rating > 0 {
|
||||
entry.rating = min(max(1, rating), 5)
|
||||
} else {
|
||||
entry.rating = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static func mood(for date: Date) -> MonthlyCheckInMood? {
|
||||
entry(for: date)?.mood
|
||||
}
|
||||
|
||||
static func setMood(_ mood: MonthlyCheckInMood?, for date: Date) {
|
||||
updateEntry(for: date) { entry in
|
||||
entry.mood = mood
|
||||
}
|
||||
}
|
||||
|
||||
static func monthKey(for date: Date) -> String {
|
||||
Self.monthFormatter.string(from: date)
|
||||
}
|
||||
|
||||
static func allNotes() -> [(date: Date, note: String)] {
|
||||
loadEntries()
|
||||
.compactMap { key, entry in
|
||||
guard let date = Self.monthFormatter.date(from: key) else { return nil }
|
||||
return (date: date, note: entry.note ?? "")
|
||||
}
|
||||
.sorted { $0.date > $1.date }
|
||||
}
|
||||
|
||||
static func entry(for date: Date) -> MonthlyCheckInEntry? {
|
||||
loadEntries()[monthKey(for: date)]
|
||||
}
|
||||
|
||||
static func allEntries() -> [(date: Date, entry: MonthlyCheckInEntry)] {
|
||||
loadEntries()
|
||||
.compactMap { key, entry in
|
||||
guard let date = Self.monthFormatter.date(from: key) else { return nil }
|
||||
return (date: date, entry: entry)
|
||||
}
|
||||
.sorted { $0.date > $1.date }
|
||||
}
|
||||
|
||||
static func completionDate(for date: Date) -> Date? {
|
||||
entry(for: date)?.completionDate
|
||||
}
|
||||
|
||||
static func setCompletionDate(_ completionDate: Date, for month: Date) {
|
||||
updateEntry(for: month) { entry in
|
||||
entry.completionTime = completionDate.timeIntervalSince1970
|
||||
}
|
||||
}
|
||||
|
||||
static func latestCompletionDate() -> Date? {
|
||||
let latestEntryDate = loadEntries().values
|
||||
.compactMap { $0.completionDate }
|
||||
.max()
|
||||
|
||||
if let latestEntryDate {
|
||||
return latestEntryDate
|
||||
}
|
||||
|
||||
let legacy = UserDefaults.standard.double(forKey: legacyLastCheckInKey)
|
||||
guard legacy > 0 else { return nil }
|
||||
return Date(timeIntervalSince1970: legacy)
|
||||
}
|
||||
|
||||
static func stats(referenceDate: Date = Date()) -> MonthlyCheckInStats {
|
||||
let cutoff = referenceDate.endOfMonth
|
||||
let entries = allEntries().filter { $0.date <= cutoff }
|
||||
let completions: [(month: Date, completion: Date, mood: MonthlyCheckInMood?)] = entries.compactMap { entry in
|
||||
guard let completion = entry.entry.completionDate else { return nil }
|
||||
return (month: entry.date.startOfMonth, completion: completion, mood: entry.entry.mood)
|
||||
}
|
||||
|
||||
guard !completions.isEmpty else { return .empty }
|
||||
|
||||
let deadlineDiffs = completions.map { item -> Double in
|
||||
let deadline = item.month.endOfMonth
|
||||
return deadline.timeIntervalSince(item.completion) / 86_400
|
||||
}
|
||||
|
||||
let onTimeCompletions = completions.filter { item in
|
||||
item.completion <= item.month.endOfMonth
|
||||
}
|
||||
let onTimeMonths = Set(onTimeCompletions.map { $0.month })
|
||||
let totalCheckIns = completions.count
|
||||
let onTimeCount = onTimeMonths.count
|
||||
|
||||
// Current streak counts consecutive on-time months up to the reference month.
|
||||
var currentStreak = 0
|
||||
var cursor = referenceDate.startOfMonth
|
||||
while onTimeMonths.contains(cursor) {
|
||||
currentStreak += 1
|
||||
cursor = cursor.adding(months: -1).startOfMonth
|
||||
}
|
||||
|
||||
// Best streak across history.
|
||||
let sortedMonths = onTimeMonths.sorted()
|
||||
var bestStreak = 0
|
||||
var running = 0
|
||||
var previousMonth: Date?
|
||||
for month in sortedMonths {
|
||||
if let previousMonth, month == previousMonth.adding(months: 1).startOfMonth {
|
||||
running += 1
|
||||
} else {
|
||||
running = 1
|
||||
}
|
||||
bestStreak = max(bestStreak, running)
|
||||
previousMonth = month
|
||||
}
|
||||
|
||||
let averageDaysBeforeDeadline = onTimeCount > 0
|
||||
? deadlineDiffs
|
||||
.filter { $0 >= 0 }
|
||||
.average()
|
||||
: nil
|
||||
let closestCutoffDays = onTimeCount > 0
|
||||
? deadlineDiffs.filter { $0 >= 0 }.min()
|
||||
: nil
|
||||
|
||||
let recentMood = completions.sorted { $0.month > $1.month }.first?.mood
|
||||
let achievements = buildAchievements(
|
||||
currentStreak: currentStreak,
|
||||
bestStreak: bestStreak,
|
||||
onTimeCount: onTimeCount,
|
||||
totalCheckIns: totalCheckIns,
|
||||
closestCutoffDays: closestCutoffDays,
|
||||
averageDaysBeforeDeadline: averageDaysBeforeDeadline
|
||||
)
|
||||
|
||||
return MonthlyCheckInStats(
|
||||
currentStreak: currentStreak,
|
||||
bestStreak: bestStreak,
|
||||
onTimeCount: onTimeCount,
|
||||
totalCheckIns: totalCheckIns,
|
||||
averageDaysBeforeDeadline: averageDaysBeforeDeadline,
|
||||
closestCutoffDays: closestCutoffDays,
|
||||
recentMood: recentMood,
|
||||
achievements: achievements
|
||||
)
|
||||
}
|
||||
|
||||
static func achievementStatuses(referenceDate: Date = Date()) -> [MonthlyCheckInAchievementStatus] {
|
||||
let stats = stats(referenceDate: referenceDate)
|
||||
return achievementStatuses(for: stats)
|
||||
}
|
||||
|
||||
// MARK: - Private Helpers
|
||||
|
||||
private static func updateEntry(for date: Date, mutate: (inout MonthlyCheckInEntry) -> Void) {
|
||||
let key = monthKey(for: date)
|
||||
var entries = loadEntries()
|
||||
var entry = entries[key] ?? MonthlyCheckInEntry(
|
||||
note: nil,
|
||||
rating: nil,
|
||||
mood: nil,
|
||||
completionTime: legacyCompletion(for: key),
|
||||
createdAt: Date().timeIntervalSince1970
|
||||
)
|
||||
|
||||
mutate(&entry)
|
||||
|
||||
if entry.note?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == true {
|
||||
entry.note = nil
|
||||
}
|
||||
|
||||
let isEmpty = entry.note == nil && entry.rating == nil && entry.mood == nil && entry.completionTime == nil
|
||||
if isEmpty {
|
||||
entries.removeValue(forKey: key)
|
||||
} else {
|
||||
entries[key] = entry
|
||||
}
|
||||
|
||||
saveEntries(entries)
|
||||
persistLegacyMirrors(entries)
|
||||
}
|
||||
|
||||
private static func loadEntries() -> [String: MonthlyCheckInEntry] {
|
||||
guard let data = UserDefaults.standard.data(forKey: entriesKey),
|
||||
let decoded = try? JSONDecoder().decode([String: MonthlyCheckInEntry].self, from: data) else {
|
||||
return migrateLegacyData()
|
||||
}
|
||||
|
||||
// Ensure legacy data is merged if it existed before this release.
|
||||
return mergeLegacy(into: decoded)
|
||||
}
|
||||
|
||||
private static func saveEntries(_ entries: [String: MonthlyCheckInEntry]) {
|
||||
guard let data = try? JSONEncoder().encode(entries) else { return }
|
||||
UserDefaults.standard.set(data, forKey: entriesKey)
|
||||
}
|
||||
|
||||
private static func migrateLegacyData() -> [String: MonthlyCheckInEntry] {
|
||||
let notes = loadNotes()
|
||||
let completions = loadCompletions()
|
||||
guard !notes.isEmpty || !completions.isEmpty else { return [:] }
|
||||
|
||||
var entries: [String: MonthlyCheckInEntry] = [:]
|
||||
let now = Date().timeIntervalSince1970
|
||||
|
||||
for (key, note) in notes {
|
||||
entries[key] = MonthlyCheckInEntry(
|
||||
note: note,
|
||||
rating: nil,
|
||||
mood: nil,
|
||||
completionTime: completions[key],
|
||||
createdAt: now
|
||||
)
|
||||
}
|
||||
|
||||
for (key, completion) in completions where entries[key] == nil {
|
||||
entries[key] = MonthlyCheckInEntry(
|
||||
note: nil,
|
||||
rating: nil,
|
||||
mood: nil,
|
||||
completionTime: completion,
|
||||
createdAt: completion
|
||||
)
|
||||
}
|
||||
|
||||
saveEntries(entries)
|
||||
return entries
|
||||
}
|
||||
|
||||
private static func mergeLegacy(into entries: [String: MonthlyCheckInEntry]) -> [String: MonthlyCheckInEntry] {
|
||||
var merged = entries
|
||||
let notes = loadNotes()
|
||||
let completions = loadCompletions()
|
||||
var shouldSave = false
|
||||
|
||||
for (key, note) in notes where merged[key]?.note == nil {
|
||||
var entry = merged[key] ?? MonthlyCheckInEntry(
|
||||
note: nil,
|
||||
rating: nil,
|
||||
mood: nil,
|
||||
completionTime: completions[key],
|
||||
createdAt: Date().timeIntervalSince1970
|
||||
)
|
||||
entry.note = note
|
||||
merged[key] = entry
|
||||
shouldSave = true
|
||||
}
|
||||
|
||||
for (key, completion) in completions where merged[key]?.completionTime == nil {
|
||||
var entry = merged[key] ?? MonthlyCheckInEntry(
|
||||
note: nil,
|
||||
rating: nil,
|
||||
mood: nil,
|
||||
completionTime: nil,
|
||||
createdAt: completion
|
||||
)
|
||||
entry.completionTime = completion
|
||||
merged[key] = entry
|
||||
shouldSave = true
|
||||
}
|
||||
|
||||
if shouldSave {
|
||||
saveEntries(merged)
|
||||
}
|
||||
|
||||
return merged
|
||||
}
|
||||
|
||||
private static func persistLegacyMirrors(_ entries: [String: MonthlyCheckInEntry]) {
|
||||
var notes: [String: String] = [:]
|
||||
var completions: [String: Double] = [:]
|
||||
|
||||
for (key, entry) in entries {
|
||||
if let note = entry.note {
|
||||
notes[key] = note
|
||||
}
|
||||
if let completion = entry.completionTime {
|
||||
completions[key] = completion
|
||||
}
|
||||
}
|
||||
|
||||
saveNotes(notes)
|
||||
saveCompletions(completions)
|
||||
}
|
||||
|
||||
private static func loadNotes() -> [String: String] {
|
||||
guard let data = UserDefaults.standard.data(forKey: notesKey),
|
||||
let decoded = try? JSONDecoder().decode([String: String].self, from: data) else {
|
||||
return [:]
|
||||
}
|
||||
return decoded
|
||||
}
|
||||
|
||||
private static func saveNotes(_ notes: [String: String]) {
|
||||
guard let data = try? JSONEncoder().encode(notes) else { return }
|
||||
UserDefaults.standard.set(data, forKey: notesKey)
|
||||
}
|
||||
|
||||
private static func loadCompletions() -> [String: Double] {
|
||||
guard let data = UserDefaults.standard.data(forKey: completionsKey),
|
||||
let decoded = try? JSONDecoder().decode([String: Double].self, from: data) else {
|
||||
return [:]
|
||||
}
|
||||
return decoded
|
||||
}
|
||||
|
||||
private static func saveCompletions(_ completions: [String: Double]) {
|
||||
guard let data = try? JSONEncoder().encode(completions) else { return }
|
||||
UserDefaults.standard.set(data, forKey: completionsKey)
|
||||
}
|
||||
|
||||
private static func legacyCompletion(for key: String) -> Double? {
|
||||
loadCompletions()[key]
|
||||
}
|
||||
|
||||
private struct MonthlyCheckInAchievementRule {
|
||||
let achievement: MonthlyCheckInAchievement
|
||||
let isUnlocked: (Int, Int, Int, Int, Double?, Double?) -> Bool
|
||||
}
|
||||
|
||||
private static let achievementRules: [MonthlyCheckInAchievementRule] = [
|
||||
MonthlyCheckInAchievementRule(
|
||||
achievement: MonthlyCheckInAchievement(
|
||||
key: "streak_3",
|
||||
title: String(localized: "achievement_streak_3_title"),
|
||||
detail: String(localized: "achievement_streak_3_detail"),
|
||||
icon: "flame.fill"
|
||||
),
|
||||
isUnlocked: { currentStreak, _, _, _, _, _ in currentStreak >= 3 }
|
||||
),
|
||||
MonthlyCheckInAchievementRule(
|
||||
achievement: MonthlyCheckInAchievement(
|
||||
key: "streak_6",
|
||||
title: String(localized: "achievement_streak_6_title"),
|
||||
detail: String(localized: "achievement_streak_6_detail"),
|
||||
icon: "bolt.heart.fill"
|
||||
),
|
||||
isUnlocked: { currentStreak, _, _, _, _, _ in currentStreak >= 6 }
|
||||
),
|
||||
MonthlyCheckInAchievementRule(
|
||||
achievement: MonthlyCheckInAchievement(
|
||||
key: "streak_12",
|
||||
title: String(localized: "achievement_streak_12_title"),
|
||||
detail: String(localized: "achievement_streak_12_detail"),
|
||||
icon: "calendar.circle.fill"
|
||||
),
|
||||
isUnlocked: { _, bestStreak, _, _, _, _ in bestStreak >= 12 }
|
||||
),
|
||||
MonthlyCheckInAchievementRule(
|
||||
achievement: MonthlyCheckInAchievement(
|
||||
key: "perfect_on_time",
|
||||
title: String(localized: "achievement_perfect_on_time_title"),
|
||||
detail: String(localized: "achievement_perfect_on_time_detail"),
|
||||
icon: "checkmark.seal.fill"
|
||||
),
|
||||
isUnlocked: { _, _, onTimeCount, totalCheckIns, _, _ in
|
||||
onTimeCount == totalCheckIns && totalCheckIns >= 3
|
||||
}
|
||||
),
|
||||
MonthlyCheckInAchievementRule(
|
||||
achievement: MonthlyCheckInAchievement(
|
||||
key: "clutch_finish",
|
||||
title: String(localized: "achievement_clutch_finish_title"),
|
||||
detail: String(localized: "achievement_clutch_finish_detail"),
|
||||
icon: "hourglass"
|
||||
),
|
||||
isUnlocked: { _, _, _, _, closestCutoffDays, _ in
|
||||
if let closestCutoffDays {
|
||||
return closestCutoffDays <= 2
|
||||
}
|
||||
return false
|
||||
}
|
||||
),
|
||||
MonthlyCheckInAchievementRule(
|
||||
achievement: MonthlyCheckInAchievement(
|
||||
key: "early_bird",
|
||||
title: String(localized: "achievement_early_bird_title"),
|
||||
detail: String(localized: "achievement_early_bird_detail"),
|
||||
icon: "sun.max.fill"
|
||||
),
|
||||
isUnlocked: { _, _, _, totalCheckIns, _, averageDaysBeforeDeadline in
|
||||
if let averageDaysBeforeDeadline {
|
||||
return averageDaysBeforeDeadline >= 10 && totalCheckIns >= 3
|
||||
}
|
||||
return false
|
||||
}
|
||||
)
|
||||
]
|
||||
|
||||
private static func achievementStatuses(for stats: MonthlyCheckInStats) -> [MonthlyCheckInAchievementStatus] {
|
||||
achievementRules.map { rule in
|
||||
MonthlyCheckInAchievementStatus(
|
||||
achievement: rule.achievement,
|
||||
isUnlocked: rule.isUnlocked(
|
||||
stats.currentStreak,
|
||||
stats.bestStreak,
|
||||
stats.onTimeCount,
|
||||
stats.totalCheckIns,
|
||||
stats.closestCutoffDays,
|
||||
stats.averageDaysBeforeDeadline
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private static func buildAchievements(
|
||||
currentStreak: Int,
|
||||
bestStreak: Int,
|
||||
onTimeCount: Int,
|
||||
totalCheckIns: Int,
|
||||
closestCutoffDays: Double?,
|
||||
averageDaysBeforeDeadline: Double?
|
||||
) -> [MonthlyCheckInAchievement] {
|
||||
achievementRules.compactMap { rule in
|
||||
rule.isUnlocked(
|
||||
currentStreak,
|
||||
bestStreak,
|
||||
onTimeCount,
|
||||
totalCheckIns,
|
||||
closestCutoffDays,
|
||||
averageDaysBeforeDeadline
|
||||
) ? rule.achievement : nil
|
||||
}
|
||||
}
|
||||
|
||||
private static var monthFormatter: DateFormatter {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "yyyy-MM"
|
||||
return formatter
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import Foundation
|
||||
import Combine
|
||||
|
||||
@MainActor
|
||||
final class TabSelectionStore: ObservableObject {
|
||||
@Published var selectedTab = 0
|
||||
}
|
||||
Reference in New Issue
Block a user