diff --git a/.DS_Store b/.DS_Store index 18741fe..fca6afb 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/PortfolioJournal.xcodeproj/project.pbxproj b/PortfolioJournal.xcodeproj/project.pbxproj index 0d15600..785356e 100644 --- a/PortfolioJournal.xcodeproj/project.pbxproj +++ b/PortfolioJournal.xcodeproj/project.pbxproj @@ -306,6 +306,7 @@ INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 17.6; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -343,6 +344,7 @@ INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 17.6; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -412,7 +414,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 26.2; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; @@ -469,7 +471,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 26.2; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; @@ -493,6 +495,7 @@ INFOPLIST_FILE = PortfolioJournalWidget/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = "Portfolio Journal Widget"; INFOPLIST_KEY_NSHumanReadableCopyright = ""; + IPHONEOS_DEPLOYMENT_TARGET = 17.6; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -525,6 +528,7 @@ INFOPLIST_FILE = PortfolioJournalWidget/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = "Portfolio Journal Widget"; INFOPLIST_KEY_NSHumanReadableCopyright = ""; + IPHONEOS_DEPLOYMENT_TARGET = 17.6; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", diff --git a/PortfolioJournal.xcodeproj/project.xcworkspace/xcuserdata/alexandrev.xcuserdatad/UserInterfaceState.xcuserstate b/PortfolioJournal.xcodeproj/project.xcworkspace/xcuserdata/alexandrev.xcuserdatad/UserInterfaceState.xcuserstate index 117fd35..ed29357 100644 Binary files a/PortfolioJournal.xcodeproj/project.xcworkspace/xcuserdata/alexandrev.xcuserdatad/UserInterfaceState.xcuserstate and b/PortfolioJournal.xcodeproj/project.xcworkspace/xcuserdata/alexandrev.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/PortfolioJournal.xcodeproj/xcshareddata/xcschemes/PortfolioJournal.xcscheme b/PortfolioJournal.xcodeproj/xcshareddata/xcschemes/PortfolioJournal.xcscheme index ae4f789..734e45b 100644 --- a/PortfolioJournal.xcodeproj/xcshareddata/xcschemes/PortfolioJournal.xcscheme +++ b/PortfolioJournal.xcodeproj/xcshareddata/xcschemes/PortfolioJournal.xcscheme @@ -50,6 +50,9 @@ ReferencedContainer = "container:PortfolioJournal.xcodeproj"> + + Bool { + let context = coreDataStack.viewContext + + let sourceRequest: NSFetchRequest = InvestmentSource.fetchRequest() + sourceRequest.fetchLimit = 1 + sourceRequest.resultType = .countResultType + if (try? context.count(for: sourceRequest)) ?? 0 > 0 { + return true + } + + let snapshotRequest: NSFetchRequest = Snapshot.fetchRequest() + snapshotRequest.fetchLimit = 1 + snapshotRequest.resultType = .countResultType + if (try? context.count(for: snapshotRequest)) ?? 0 > 0 { + return true + } + + let accountRequest: NSFetchRequest = Account.fetchRequest() + accountRequest.fetchLimit = 1 + accountRequest.resultType = .countResultType + return ((try? context.count(for: accountRequest)) ?? 0) > 0 } private var mainContent: some View { @@ -84,7 +145,7 @@ struct ContentView: View { } .tag(3) - SettingsView() + SettingsView(iapService: iapService) .tabItem { Label("Settings", systemImage: "gearshape.fill") } @@ -105,6 +166,7 @@ struct ContentView: View { #Preview { ContentView() + .environmentObject(CoreDataStack.shared) .environmentObject(IAPService()) .environmentObject(AdMobService()) .environmentObject(AccountStore(iapService: IAPService())) diff --git a/PortfolioJournal/App/PortfolioJournalApp.swift b/PortfolioJournal/App/PortfolioJournalApp.swift index eddd04b..68db5c6 100644 --- a/PortfolioJournal/App/PortfolioJournalApp.swift +++ b/PortfolioJournal/App/PortfolioJournalApp.swift @@ -22,6 +22,7 @@ struct PortfolioJournalApp: App { WindowGroup { ContentView() .environment(\.managedObjectContext, coreDataStack.viewContext) + .environmentObject(coreDataStack) .environmentObject(iapService) .environmentObject(adMobService) .environmentObject(accountStore) diff --git a/PortfolioJournal/Configuration.storekit b/PortfolioJournal/Configuration.storekit new file mode 100644 index 0000000..d6401a7 --- /dev/null +++ b/PortfolioJournal/Configuration.storekit @@ -0,0 +1,90 @@ +{ + "identifier" : "A1B2C3D4-E5F6-7890-ABCD-EF1234567890", + "nonRenewingSubscriptions" : [ + + ], + "products" : [ + { + "displayPrice" : "4.69", + "familyShareable" : true, + "internalID" : "premium_001", + "localizations" : [ + { + "description" : "Unlock all premium features forever", + "displayName" : "Portfolio Journal Premium", + "locale" : "en_US" + }, + { + "description" : "Desbloquea todas las funciones premium para siempre", + "displayName" : "Portfolio Journal Premium", + "locale" : "es_ES" + } + ], + "productID" : "com.portfoliojournal.premium", + "referenceName" : "Premium Lifetime", + "type" : "NonConsumable" + } + ], + "settings" : { + "_applicationInternalID" : "1234567890", + "_developerTeamID" : "XXXXXXXXXX", + "_failTransactionsEnabled" : false, + "_lastSynchronizedDate" : 758307388.12345, + "_locale" : "en_US", + "_storefront" : "USA", + "_storeKitErrors" : [ + { + "current" : null, + "enabled" : false, + "name" : "Load Products" + }, + { + "current" : null, + "enabled" : false, + "name" : "Purchase" + }, + { + "current" : null, + "enabled" : false, + "name" : "Verification" + }, + { + "current" : null, + "enabled" : false, + "name" : "App Store Sync" + }, + { + "current" : null, + "enabled" : false, + "name" : "Subscription Status" + }, + { + "current" : null, + "enabled" : false, + "name" : "App Transaction" + }, + { + "current" : null, + "enabled" : false, + "name" : "Manage Subscriptions Sheet" + }, + { + "current" : null, + "enabled" : false, + "name" : "Refund Request Sheet" + }, + { + "current" : null, + "enabled" : false, + "name" : "Offer Code Redeem Sheet" + } + ] + }, + "subscriptionGroups" : [ + + ], + "version" : { + "major" : 3, + "minor" : 0 + } +} diff --git a/PortfolioJournal/Models/CoreDataStack.swift b/PortfolioJournal/Models/CoreDataStack.swift index 010ca65..d77cd84 100644 --- a/PortfolioJournal/Models/CoreDataStack.swift +++ b/PortfolioJournal/Models/CoreDataStack.swift @@ -114,6 +114,8 @@ class CoreDataStack: ObservableObject { return appGroupURL } + @Published private(set) var isLoaded = false + lazy var persistentContainer: NSPersistentContainer = { let container: NSPersistentContainer if Self.cloudKitEnabled { @@ -139,13 +141,16 @@ class CoreDataStack: ObservableObject { container.persistentStoreDescriptions = [description] - container.loadPersistentStores { description, error in + container.loadPersistentStores { [weak self] description, error in if let error = error as NSError? { // In production, handle this error appropriately print("Core Data failed to load: \(error), \(error.userInfo)") } else { print("Core Data loaded successfully at: \(description.url?.path ?? "unknown")") } + DispatchQueue.main.async { + self?.isLoaded = true + } } // Merge policy - remote changes win diff --git a/PortfolioJournal/PortfolioJournal.entitlements b/PortfolioJournal/PortfolioJournal.entitlements index 72e48d1..a1a2b88 100644 --- a/PortfolioJournal/PortfolioJournal.entitlements +++ b/PortfolioJournal/PortfolioJournal.entitlements @@ -3,7 +3,7 @@ aps-environment - development + production com.apple.developer.icloud-container-identifiers iCloud.com.alexandrevazquez.portfoliojournal diff --git a/PortfolioJournal/Repositories/CategoryRepository.swift b/PortfolioJournal/Repositories/CategoryRepository.swift index 8af6679..a0554ef 100644 --- a/PortfolioJournal/Repositories/CategoryRepository.swift +++ b/PortfolioJournal/Repositories/CategoryRepository.swift @@ -39,10 +39,15 @@ class CategoryRepository: ObservableObject { ] do { - self.categories = try self.context.fetch(request) + let fetched = try self.context.fetch(request) + DispatchQueue.main.async { + self.categories = fetched + } } catch { print("Failed to fetch categories: \(error)") - self.categories = [] + DispatchQueue.main.async { + self.categories = [] + } } } } diff --git a/PortfolioJournal/Repositories/GoalRepository.swift b/PortfolioJournal/Repositories/GoalRepository.swift index 3493cb7..a6da414 100644 --- a/PortfolioJournal/Repositories/GoalRepository.swift +++ b/PortfolioJournal/Repositories/GoalRepository.swift @@ -40,10 +40,15 @@ class GoalRepository: ObservableObject { ] do { - self.goals = try self.context.fetch(request) + let fetched = try self.context.fetch(request) + DispatchQueue.main.async { + self.goals = fetched + } } catch { print("Failed to fetch goals: \(error)") - self.goals = [] + DispatchQueue.main.async { + self.goals = [] + } } } } diff --git a/PortfolioJournal/Repositories/InvestmentSourceRepository.swift b/PortfolioJournal/Repositories/InvestmentSourceRepository.swift index e79ea59..7a1eef1 100644 --- a/PortfolioJournal/Repositories/InvestmentSourceRepository.swift +++ b/PortfolioJournal/Repositories/InvestmentSourceRepository.swift @@ -41,10 +41,15 @@ class InvestmentSourceRepository: ObservableObject { ] do { - self.sources = try self.context.fetch(request) + let fetched = try self.context.fetch(request) + DispatchQueue.main.async { + self.sources = fetched + } } catch { print("Failed to fetch sources: \(error)") - self.sources = [] + DispatchQueue.main.async { + self.sources = [] + } } } } diff --git a/PortfolioJournal/Resources/GoogleService-Info.plist b/PortfolioJournal/Resources/GoogleService-Info.plist index 127b24a..45950bc 100644 --- a/PortfolioJournal/Resources/GoogleService-Info.plist +++ b/PortfolioJournal/Resources/GoogleService-Info.plist @@ -9,7 +9,7 @@ PLIST_VERSION 1 BUNDLE_ID - com.alexandrevazquez.portfoliojournal + com.alexandrevazquez.PortfolioJournal PROJECT_ID portfoliojournal-ef2d7 STORAGE_BUCKET @@ -25,6 +25,6 @@ IS_SIGNIN_ENABLED GOOGLE_APP_ID - 1:334225114072:ios:81bad412ffe1c6df3d28ad + 1:334225114072:ios:a0a50b5835ac042e3d28ad \ No newline at end of file diff --git a/PortfolioJournal/Resources/Info.plist b/PortfolioJournal/Resources/Info.plist index 74c86e0..7a0d636 100644 --- a/PortfolioJournal/Resources/Info.plist +++ b/PortfolioJournal/Resources/Info.plist @@ -35,6 +35,269 @@ ca-app-pub-1549720748100858~9632507420 GADDelayAppMeasurementInit + SKAdNetworkItems + + + SKAdNetworkIdentifier + cstr6suwn9.skadnetwork + + + SKAdNetworkIdentifier + 4fzdc2evr5.skadnetwork + + + SKAdNetworkIdentifier + 4pfyvq9l8r.skadnetwork + + + SKAdNetworkIdentifier + 2fnua5tdw4.skadnetwork + + + SKAdNetworkIdentifier + ydx93a7ass.skadnetwork + + + SKAdNetworkIdentifier + 5a6flpkh64.skadnetwork + + + SKAdNetworkIdentifier + p78axxw29g.skadnetwork + + + SKAdNetworkIdentifier + v72qych5uu.skadnetwork + + + SKAdNetworkIdentifier + ludvb6z3bs.skadnetwork + + + SKAdNetworkIdentifier + cp8zw746q7.skadnetwork + + + SKAdNetworkIdentifier + 3sh42y64q3.skadnetwork + + + SKAdNetworkIdentifier + c6k4g5qg8m.skadnetwork + + + SKAdNetworkIdentifier + s39g8k73mm.skadnetwork + + + SKAdNetworkIdentifier + 3qy4746246.skadnetwork + + + SKAdNetworkIdentifier + f38h382jlk.skadnetwork + + + SKAdNetworkIdentifier + hs6bdukanm.skadnetwork + + + SKAdNetworkIdentifier + v4nxqhlyqp.skadnetwork + + + SKAdNetworkIdentifier + wzmmz9fp6w.skadnetwork + + + SKAdNetworkIdentifier + yclnxrl5pm.skadnetwork + + + SKAdNetworkIdentifier + t38b2kh725.skadnetwork + + + SKAdNetworkIdentifier + 7ug5zh24hu.skadnetwork + + + SKAdNetworkIdentifier + gta9lk7p23.skadnetwork + + + SKAdNetworkIdentifier + vutu7akeur.skadnetwork + + + SKAdNetworkIdentifier + y5ghdn5j9k.skadnetwork + + + SKAdNetworkIdentifier + n6fk4nfna4.skadnetwork + + + SKAdNetworkIdentifier + v9wttpbfk9.skadnetwork + + + SKAdNetworkIdentifier + n38lu8286q.skadnetwork + + + SKAdNetworkIdentifier + 47vhws6wlr.skadnetwork + + + SKAdNetworkIdentifier + kbd757ber7.skadnetwork + + + SKAdNetworkIdentifier + 9t245vhmpl.skadnetwork + + + SKAdNetworkIdentifier + eh6m2bh4zr.skadnetwork + + + SKAdNetworkIdentifier + a2p9lx4jpn.skadnetwork + + + SKAdNetworkIdentifier + 22mmun2rn5.skadnetwork + + + SKAdNetworkIdentifier + 4468km3ulz.skadnetwork + + + SKAdNetworkIdentifier + 2u9pt9hc89.skadnetwork + + + SKAdNetworkIdentifier + 8s468mfl3y.skadnetwork + + + SKAdNetworkIdentifier + klf5c3l5u5.skadnetwork + + + SKAdNetworkIdentifier + ppxm28t8ap.skadnetwork + + + SKAdNetworkIdentifier + ecpz2srf59.skadnetwork + + + SKAdNetworkIdentifier + uw77j35x4d.skadnetwork + + + SKAdNetworkIdentifier + pwa73g5rt2.skadnetwork + + + SKAdNetworkIdentifier + mlmmfzh3r3.skadnetwork + + + SKAdNetworkIdentifier + 578prtvx9j.skadnetwork + + + SKAdNetworkIdentifier + 4dzt52r2t5.skadnetwork + + + SKAdNetworkIdentifier + e5fvkxwrpn.skadnetwork + + + SKAdNetworkIdentifier + 8c4e2ghe7u.skadnetwork + + + SKAdNetworkIdentifier + zq492l623r.skadnetwork + + + SKAdNetworkIdentifier + 3rd42ekr43.skadnetwork + + + SKAdNetworkIdentifier + 3qcr597p9d.skadnetwork + + + SKAdNetworkIdentifier + su67r6k2v3.skadnetwork + + + SKAdNetworkIdentifier + kbd757ywx3.skadnetwork + + + SKAdNetworkIdentifier + 44n7hlldy6.skadnetwork + + + SKAdNetworkIdentifier + k674qkevps.skadnetwork + + + SKAdNetworkIdentifier + 2rq3zucswp.skadnetwork + + + SKAdNetworkIdentifier + 5tjdwbrq8w.skadnetwork + + + SKAdNetworkIdentifier + 97r2b46745.skadnetwork + + + SKAdNetworkIdentifier + b9bk5wbcq9.skadnetwork + + + SKAdNetworkIdentifier + 44jx6755aq.skadnetwork + + + SKAdNetworkIdentifier + kbmxgpxpgc.skadnetwork + + + SKAdNetworkIdentifier + tl55sbb4fm.skadnetwork + + + SKAdNetworkIdentifier + 9g2aggbj52.skadnetwork + + + SKAdNetworkIdentifier + g2y4y55b64.skadnetwork + + + SKAdNetworkIdentifier + c3frkrj4fj.skadnetwork + + + SKAdNetworkIdentifier + r26jy69rpl.skadnetwork + + + SKAdNetworkIdentifier + 275upjj5gd.skadnetwork + + LSRequiresIPhoneOS NSAppTransportSecurity diff --git a/PortfolioJournal/Resources/en.lproj/Localizable.strings b/PortfolioJournal/Resources/en.lproj/Localizable.strings index 31a85b6..8dbac06 100644 --- a/PortfolioJournal/Resources/en.lproj/Localizable.strings +++ b/PortfolioJournal/Resources/en.lproj/Localizable.strings @@ -20,6 +20,7 @@ "error" = "Error"; "success" = "Success"; "loading" = "Loading..."; +"loading_data" = "Loading your data..."; // MARK: - Tab Bar "tab_dashboard" = "Home"; diff --git a/PortfolioJournal/Resources/es-ES.lproj/Localizable.strings b/PortfolioJournal/Resources/es-ES.lproj/Localizable.strings index 5209cc2..f685faa 100644 --- a/PortfolioJournal/Resources/es-ES.lproj/Localizable.strings +++ b/PortfolioJournal/Resources/es-ES.lproj/Localizable.strings @@ -24,6 +24,8 @@ "Open Full Note" = "Ver nota completa"; "Duplicate Previous" = "Duplicar anterior"; "Save" = "Guardar"; +"loading" = "Cargando..."; +"loading_data" = "Cargando tus datos..."; // MARK: - Monthly Check-in "Monthly Check-in" = "Chequeo mensual"; diff --git a/PortfolioJournal/Services/ImportService.swift b/PortfolioJournal/Services/ImportService.swift index 28b458c..075e77d 100644 --- a/PortfolioJournal/Services/ImportService.swift +++ b/PortfolioJournal/Services/ImportService.swift @@ -73,7 +73,11 @@ class ImportService { ) return applyImport(parsed, context: CoreDataStack.shared.viewContext) case .json: - let parsed = parseJSON(content, allowMultipleAccounts: allowMultipleAccounts) + let parsed = parseJSON( + content, + allowMultipleAccounts: allowMultipleAccounts, + defaultAccountName: defaultAccountName + ) return applyImport(parsed, context: CoreDataStack.shared.viewContext) } } @@ -96,7 +100,11 @@ class ImportService { defaultAccountName: defaultAccountName ) case .json: - parsed = self.parseJSON(content, allowMultipleAccounts: allowMultipleAccounts) + parsed = self.parseJSON( + content, + allowMultipleAccounts: allowMultipleAccounts, + defaultAccountName: defaultAccountName + ) } let totalSnapshots = parsed.reduce(0) { total, account in @@ -189,7 +197,7 @@ Personal,Real Estate,Rental Property,2024-01-01,82000,80000,Estimated value let normalizedAccount = (providedAccount ?? "") .trimmingCharacters(in: .whitespacesAndNewlines) let rawAccountName = normalizedAccount.isEmpty ? fallbackAccount : normalizedAccount - let accountName = allowMultipleAccounts ? rawAccountName : "Personal" + let accountName = allowMultipleAccounts ? rawAccountName : fallbackAccount guard let categoryName = indexOfCategory.flatMap({ row.safeValue(at: $0) }), !categoryName.isEmpty, let sourceName = indexOfSource.flatMap({ row.safeValue(at: $0) }), !sourceName.isEmpty, @@ -237,7 +245,11 @@ Personal,Real Estate,Rental Property,2024-01-01,82000,80000,Estimated value } } - private func parseJSON(_ content: String, allowMultipleAccounts: Bool) -> [ImportedAccount] { + private func parseJSON( + _ content: String, + allowMultipleAccounts: Bool, + defaultAccountName: String? + ) -> [ImportedAccount] { guard let data = content.data(using: .utf8), let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return [] @@ -246,7 +258,8 @@ Personal,Real Estate,Rental Property,2024-01-01,82000,80000,Estimated value if let accountsArray = json["accounts"] as? [[String: Any]] { return accountsArray.compactMap { accountDict in let rawName = accountDict["name"] as? String ?? "Personal" - let name = allowMultipleAccounts ? rawName : "Personal" + let fallbackName = defaultAccountName ?? "Personal" + let name = allowMultipleAccounts ? rawName : fallbackName let currency = accountDict["currency"] as? String let inputMode = InputMode(rawValue: accountDict["inputMode"] as? String ?? "") ?? .simple @@ -341,9 +354,10 @@ Personal,Real Estate,Rental Property,2024-01-01,82000,80000,Estimated value ) } + let fallbackName = allowMultipleAccounts ? "Personal" : (defaultAccountName ?? "Personal") return [ ImportedAccount( - name: "Personal", + name: fallbackName, currency: json["currency"] as? String, inputMode: .simple, notificationFrequency: .monthly, diff --git a/PortfolioJournal/Utilities/Constants/AppConstants.swift b/PortfolioJournal/Utilities/Constants/AppConstants.swift index 14d6c05..7eb1478 100644 --- a/PortfolioJournal/Utilities/Constants/AppConstants.swift +++ b/PortfolioJournal/Utilities/Constants/AppConstants.swift @@ -16,7 +16,6 @@ enum AppConstants { // MARK: - StoreKit static let premiumProductID = "com.portfoliojournal.premium" - static let premiumPrice = "€4.69" // MARK: - AdMob diff --git a/PortfolioJournal/ViewModels/SourceDetailViewModel.swift b/PortfolioJournal/ViewModels/SourceDetailViewModel.swift index 3a9e6f8..61adac6 100644 --- a/PortfolioJournal/ViewModels/SourceDetailViewModel.swift +++ b/PortfolioJournal/ViewModels/SourceDetailViewModel.swift @@ -32,6 +32,7 @@ class SourceDetailViewModel: ObservableObject { private let calculationService: CalculationService private let predictionEngine: PredictionEngine private let freemiumValidator: FreemiumValidator + private let iapService: IAPService private var cancellables = Set() private var isRefreshing = false private var refreshQueued = false @@ -54,6 +55,7 @@ class SourceDetailViewModel: ObservableObject { self.calculationService = calculationService ?? .shared self.predictionEngine = predictionEngine ?? .shared self.freemiumValidator = FreemiumValidator(iapService: iapService) + self.iapService = iapService loadData() setupObservers() @@ -70,6 +72,14 @@ class SourceDetailViewModel: ObservableObject { self.refreshData() } .store(in: &cancellables) + + iapService.$isPremium + .removeDuplicates() + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.refreshData() + } + .store(in: &cancellables) } // MARK: - Data Loading diff --git a/PortfolioJournal/Views/Accounts/AccountEditorView.swift b/PortfolioJournal/Views/Accounts/AccountEditorView.swift index 5c5de48..f7b1298 100644 --- a/PortfolioJournal/Views/Accounts/AccountEditorView.swift +++ b/PortfolioJournal/Views/Accounts/AccountEditorView.swift @@ -10,6 +10,7 @@ struct AccountEditorView: View { @State private var inputMode: InputMode = .simple @State private var notificationFrequency: NotificationFrequency = .monthly @State private var customFrequencyMonths = 1 + @State private var errorMessage: String? private let accountRepository = AccountRepository() @@ -25,6 +26,11 @@ struct AccountEditorView: View { } } header: { Text("Account") + } footer: { + if let errorMessage { + Text(errorMessage) + .foregroundColor(.negativeRed) + } } Section { @@ -39,25 +45,6 @@ struct AccountEditorView: View { Text(inputMode.description) } - Section { - Picker("Reminder Frequency", selection: $notificationFrequency) { - ForEach(NotificationFrequency.allCases) { frequency in - Text(frequency.displayName).tag(frequency) - } - } - - if notificationFrequency == .custom { - Stepper( - "Every \(customFrequencyMonths) month\(customFrequencyMonths > 1 ? "s" : "")", - value: $customFrequencyMonths, - in: 1...24 - ) - } - } header: { - Text("Account Reminders") - } footer: { - Text("Reminders apply to the whole account.") - } } .navigationTitle(account == nil ? "New Account" : "Edit Account") .navigationBarTitleDisplayMode(.inline) @@ -73,12 +60,35 @@ struct AccountEditorView: View { .onAppear { loadAccount() } + .onChange(of: name) { _, _ in + validateName() + } } .presentationDetents([.large]) } private var isValid: Bool { - !name.trimmingCharacters(in: .whitespaces).isEmpty + let trimmed = name.trimmingCharacters(in: .whitespaces) + return !trimmed.isEmpty && !isDuplicateName(trimmed) + } + + private func validateName() { + let trimmed = name.trimmingCharacters(in: .whitespaces) + if trimmed.isEmpty { + errorMessage = nil + return + } + errorMessage = isDuplicateName(trimmed) ? "An account with this name already exists." : nil + } + + private func isDuplicateName(_ trimmed: String) -> Bool { + let normalized = trimmed.lowercased() + return accountRepository.accounts.contains { existing in + if let account, existing.id == account.id { + return false + } + return (existing.name).trimmingCharacters(in: .whitespaces).lowercased() == normalized + } } private func loadAccount() { @@ -86,27 +96,33 @@ struct AccountEditorView: View { name = account.name currencyCode = account.currencyCode ?? currencyCode inputMode = InputMode(rawValue: account.inputMode) ?? .simple - notificationFrequency = account.frequency - customFrequencyMonths = Int(account.customFrequencyMonths) + notificationFrequency = .monthly + customFrequencyMonths = 1 + validateName() } private func saveAccount() { + validateName() + guard errorMessage == nil else { return } + let trimmedName = name.trimmingCharacters(in: .whitespaces) + let enforcedFrequency: NotificationFrequency = .monthly + let enforcedCustomMonths = 1 if let account { accountRepository.updateAccount( account, - name: name.trimmingCharacters(in: .whitespaces), + name: trimmedName, currency: currencyCode, inputMode: inputMode, - notificationFrequency: notificationFrequency, - customFrequencyMonths: customFrequencyMonths + notificationFrequency: enforcedFrequency, + customFrequencyMonths: enforcedCustomMonths ) } else { _ = accountRepository.createAccount( - name: name.trimmingCharacters(in: .whitespaces), + name: trimmedName, currency: currencyCode, inputMode: inputMode, - notificationFrequency: notificationFrequency, - customFrequencyMonths: customFrequencyMonths + notificationFrequency: enforcedFrequency, + customFrequencyMonths: enforcedCustomMonths ) } diff --git a/PortfolioJournal/Views/Accounts/AccountsView.swift b/PortfolioJournal/Views/Accounts/AccountsView.swift index ec0a3cc..3efbcb5 100644 --- a/PortfolioJournal/Views/Accounts/AccountsView.swift +++ b/PortfolioJournal/Views/Accounts/AccountsView.swift @@ -7,6 +7,7 @@ struct AccountsView: View { @State private var showingAddAccount = false @State private var selectedAccount: Account? @State private var showingPaywall = false + @State private var accountToDelete: Account? var body: some View { ZStack { @@ -33,6 +34,13 @@ struct AccountsView: View { } } } + .swipeActions(edge: .trailing) { + Button(role: .destructive) { + accountToDelete = account + } label: { + Label("Delete", systemImage: "trash") + } + } } } header: { Text("Accounts") @@ -43,6 +51,25 @@ struct AccountsView: View { .scrollContentBackground(.hidden) } .navigationTitle("Accounts") + .confirmationDialog( + "Delete Account", + isPresented: Binding( + get: { accountToDelete != nil }, + set: { if !$0 { accountToDelete = nil } } + ), + titleVisibility: .visible + ) { + Button("Delete", role: .destructive) { + guard let accountToDelete else { return } + if accountStore.selectedAccount?.id == accountToDelete.id { + accountStore.selectAllAccounts() + } + accountRepository.deleteAccount(accountToDelete) + self.accountToDelete = nil + } + } message: { + Text("This will remove the account and unlink its sources.") + } .toolbar { ToolbarItem(placement: .navigationBarTrailing) { Button { diff --git a/PortfolioJournal/Views/Components/LoadingView.swift b/PortfolioJournal/Views/Components/LoadingView.swift index b1a7cfc..54a581a 100644 --- a/PortfolioJournal/Views/Components/LoadingView.swift +++ b/PortfolioJournal/Views/Components/LoadingView.swift @@ -17,6 +17,35 @@ struct LoadingView: View { } } +struct AppLaunchLoadingView: View { + var messageKey: LocalizedStringKey = "loading" + + var body: some View { + VStack(spacing: 20) { + Spacer() + + Image("BrandMark") + .resizable() + .scaledToFit() + .frame(width: 140, height: 140) + .padding(16) + .background(Color.appPrimary.opacity(0.08)) + .cornerRadius(28) + + ProgressView() + .scaleEffect(1.2) + + Text(messageKey) + .font(.subheadline) + .foregroundColor(.secondary) + + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(AppBackground()) + } +} + // MARK: - Skeleton Loading struct SkeletonView: View { diff --git a/PortfolioJournal/Views/Dashboard/DashboardView.swift b/PortfolioJournal/Views/Dashboard/DashboardView.swift index e6a25eb..92918cb 100644 --- a/PortfolioJournal/Views/Dashboard/DashboardView.swift +++ b/PortfolioJournal/Views/Dashboard/DashboardView.swift @@ -43,7 +43,7 @@ struct DashboardView: View { viewModel.refreshData() } .overlay { - if viewModel.isLoading { + if viewModel.isLoading && !viewModel.hasData { ProgressView() } } diff --git a/PortfolioJournal/Views/Onboarding/OnboardingView.swift b/PortfolioJournal/Views/Onboarding/OnboardingView.swift index efb8420..5535013 100644 --- a/PortfolioJournal/Views/Onboarding/OnboardingView.swift +++ b/PortfolioJournal/Views/Onboarding/OnboardingView.swift @@ -117,7 +117,7 @@ struct OnboardingView: View { selectedCurrency = AppSettings.getOrCreate(in: CoreDataStack.shared.viewContext).currency } .sheet(isPresented: $showingImportSheet) { - ImportDataView() + ImportDataView(importContext: .onboarding) } .sheet(isPresented: $showingAddSource) { AddSourceView() diff --git a/PortfolioJournal/Views/Premium/PaywallView.swift b/PortfolioJournal/Views/Premium/PaywallView.swift index c2d1e91..83bf699 100644 --- a/PortfolioJournal/Views/Premium/PaywallView.swift +++ b/PortfolioJournal/Views/Premium/PaywallView.swift @@ -120,15 +120,9 @@ struct PaywallView: View { private var priceCard: some View { VStack(spacing: 8) { - HStack(alignment: .top, spacing: 4) { - Text("€") - .font(.title2.weight(.semibold)) - .foregroundColor(.appPrimary) - - Text("4.69") - .font(.system(size: 48, weight: .bold, design: .rounded)) - .foregroundColor(.appPrimary) - } + Text(iapService.formattedPrice) + .font(.system(size: 48, weight: .bold, design: .rounded)) + .foregroundColor(.appPrimary) Text("One-time purchase") .font(.subheadline) @@ -283,6 +277,7 @@ struct FeatureRow: View { // MARK: - Compact Paywall (for inline use) struct CompactPaywallBanner: View { + @EnvironmentObject var iapService: IAPService @Binding var showingPaywall: Bool var body: some View { @@ -311,7 +306,7 @@ struct CompactPaywallBanner: View { Button { showingPaywall = true } label: { - Text("€4.69") + Text(iapService.formattedPrice) .font(.caption.weight(.semibold)) .padding(.horizontal, 12) .padding(.vertical, 6) diff --git a/PortfolioJournal/Views/Settings/ImportDataView.swift b/PortfolioJournal/Views/Settings/ImportDataView.swift index b9cd1da..e8b094f 100644 --- a/PortfolioJournal/Views/Settings/ImportDataView.swift +++ b/PortfolioJournal/Views/Settings/ImportDataView.swift @@ -3,6 +3,20 @@ import UniformTypeIdentifiers import UIKit struct ImportDataView: View { + enum ImportContext { + case settings + case onboarding + } + + enum AccountSelection: String, CaseIterable, Identifiable { + case existing + case new + + var id: String { rawValue } + } + + let importContext: ImportContext + @EnvironmentObject private var iapService: IAPService @EnvironmentObject private var accountStore: AccountStore @EnvironmentObject private var tabSelection: TabSelectionStore @@ -15,10 +29,24 @@ struct ImportDataView: View { @State private var isImporting = false @State private var importProgress: Double = 0 @State private var importStatus = "Preparing import" + @State private var accountSelection: AccountSelection = .existing + @State private var selectedAccountId: UUID? + @State private var newAccountName = "" + @State private var accountErrorMessage: String? + + private let accountRepository = AccountRepository() + + init(importContext: ImportContext = .settings) { + self.importContext = importContext + } var body: some View { NavigationStack { List { + if shouldShowAccountSelection { + accountSection + } + Section { Picker("Format", selection: $selectedFormat) { Text("CSV").tag(ImportService.ImportFormat.csv) @@ -65,7 +93,7 @@ struct ImportDataView: View { } header: { Text("Format Guide") } footer: { - Text(iapService.isPremium ? "Accounts are imported as provided." : "Free users import into the Personal account.") + Text(importFooterText) } Section { @@ -117,6 +145,18 @@ struct ImportDataView: View { } message: { Text(errorMessage ?? "") } + .onAppear { + if selectedAccountId == nil { + selectedAccountId = accountStore.selectedAccount?.id ?? accountStore.accounts.first?.id + } + } + .onChange(of: accountSelection) { _, _ in + accountErrorMessage = nil + } + .onChange(of: newAccountName) { _, _ in + guard accountSelection == .new else { return } + accountErrorMessage = validateNewAccountName() + } } .presentationDetents([.large]) } @@ -178,6 +218,78 @@ Personal,Stocks,Index Fund,2024-01-01,15000,12000,Long-term } } + private var shouldShowAccountSelection: Bool { + importContext == .onboarding + } + + private var importFooterText: String { + if importContext == .onboarding { + return iapService.isPremium + ? "Import will be added to the selected account." + : "Free users import into the existing account." + } + return iapService.isPremium + ? "Accounts are imported as provided." + : "Free users import into the selected account." + } + + private var accountSection: some View { + Section { + if iapService.isPremium { + Picker("Account", selection: $accountSelection) { + Text("Existing").tag(AccountSelection.existing) + Text("New").tag(AccountSelection.new) + } + .pickerStyle(.segmented) + .disabled(isImporting) + + if accountSelection == .existing { + Picker("Import into", selection: $selectedAccountId) { + ForEach(accountStore.accounts) { account in + Text(account.name).tag(Optional(account.id)) + } + } + .disabled(isImporting) + } else { + TextField("New account name", text: $newAccountName) + .disabled(isImporting) + } + } else { + HStack { + Text("Account") + Spacer() + Text(selectedAccountName ?? "Personal") + .foregroundColor(.secondary) + } + } + } header: { + Text("Account") + } footer: { + if let accountErrorMessage { + Text(accountErrorMessage) + .foregroundColor(.negativeRed) + } + } + } + + private var selectedAccountName: String? { + accountStore.accounts.first { $0.id == selectedAccountId }?.name + ?? accountStore.selectedAccount?.name + ?? accountStore.accounts.first?.name + } + + private func validateNewAccountName() -> String? { + let trimmed = newAccountName.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + return "Enter a name for the new account." + } + let normalized = trimmed.lowercased() + let exists = accountStore.accounts.contains { + $0.name.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == normalized + } + return exists ? "An account with this name already exists." : nil + } + private func handleImport(_ result: Result<[URL], Error>) { do { let urls = try result.get() @@ -243,9 +355,30 @@ Personal,Stocks,Index Fund,2024-01-01,15000,12000,Long-term } private func handleImportContent(_ content: String) { - let allowMultipleAccounts = iapService.isPremium - let defaultAccountName = accountStore.selectedAccount?.name + let shouldForceSingleAccount = importContext == .onboarding + let allowMultipleAccounts = iapService.isPremium && !shouldForceSingleAccount + var defaultAccountName = accountStore.selectedAccount?.name ?? accountStore.accounts.first?.name + + if shouldForceSingleAccount && iapService.isPremium { + if accountSelection == .new { + accountErrorMessage = validateNewAccountName() + guard accountErrorMessage == nil else { return } + let trimmed = newAccountName.trimmingCharacters(in: .whitespacesAndNewlines) + let currency = AppSettings.getOrCreate(in: CoreDataStack.shared.viewContext).currency + let account = accountRepository.createAccount( + name: trimmed, + currency: currency, + inputMode: .simple, + notificationFrequency: .monthly, + customFrequencyMonths: 1 + ) + defaultAccountName = account.name + } else { + defaultAccountName = selectedAccountName + } + } + isImporting = true importProgress = 0 importStatus = "Parsing file" diff --git a/PortfolioJournal/Views/Settings/SettingsView.swift b/PortfolioJournal/Views/Settings/SettingsView.swift index d82117b..48c5bf1 100644 --- a/PortfolioJournal/Views/Settings/SettingsView.swift +++ b/PortfolioJournal/Views/Settings/SettingsView.swift @@ -2,7 +2,7 @@ import SwiftUI import StoreKit struct SettingsView: View { - @EnvironmentObject var iapService: IAPService + private let iapService: IAPService @StateObject private var viewModel: SettingsViewModel @AppStorage("calmModeEnabled") private var calmModeEnabled = true @AppStorage("cloudSyncEnabled") private var cloudSyncEnabled = false @@ -19,8 +19,9 @@ struct SettingsView: View { @State private var showingRestartAlert = false @State private var didLoadCloudSync = false - init() { - _viewModel = StateObject(wrappedValue: SettingsViewModel(iapService: IAPService())) + init(iapService: IAPService) { + self.iapService = iapService + _viewModel = StateObject(wrappedValue: SettingsViewModel(iapService: iapService)) } var body: some View { @@ -221,7 +222,7 @@ struct SettingsView: View { .font(.headline) .foregroundColor(.primary) - Text("Unlock all features for €4.69") + Text("Unlock all features for \(iapService.formattedPrice)") .font(.caption) .foregroundColor(.secondary) } @@ -705,7 +706,6 @@ struct PinSetupView: View { } #Preview { - SettingsView() - .environmentObject(IAPService()) + SettingsView(iapService: IAPService()) .environmentObject(AccountStore(iapService: IAPService())) } diff --git a/PortfolioJournalWidget/InvestmentWidget.swift b/PortfolioJournalWidget/InvestmentWidget.swift index bfc38cc..1902daa 100644 --- a/PortfolioJournalWidget/InvestmentWidget.swift +++ b/PortfolioJournalWidget/InvestmentWidget.swift @@ -5,6 +5,8 @@ import CoreData private let appGroupIdentifier = "group.com.alexandrevazquez.portfoliojournal" private let storeFileName = "PortfolioJournal.sqlite" private let sharedPremiumKey = "premiumUnlocked" +private let widgetPrimaryColor = Color(hex: "#3B82F6") ?? .blue +private let widgetSecondaryColor = Color(hex: "#10B981") ?? .green private func sharedStoreURL() -> URL? { return FileManager.default @@ -53,9 +55,11 @@ struct InvestmentWidgetEntry: TimelineEntry { let dayChange: Decimal let dayChangePercentage: Double let topSources: [(name: String, value: Decimal, color: String)] - let sparklineData: [Decimal] + let trendPoints: [Decimal] + let trendLabels: [String] let categoryEvolution: [CategorySeries] let categoryTotals: [(name: String, value: Decimal, color: String)] + let goals: [GoalSummary] } struct CategorySeries: Identifiable { @@ -66,6 +70,13 @@ struct CategorySeries: Identifiable { let latestValue: Decimal } +struct GoalSummary: Identifiable { + let id = UUID() + let name: String + let targetAmount: Decimal + let targetDate: Date? +} + // MARK: - Widget Provider struct InvestmentWidgetProvider: TimelineProvider { @@ -81,7 +92,8 @@ struct InvestmentWidgetProvider: TimelineProvider { ("Bonds", 15000, "#3B82F6"), ("Real Estate", 5000, "#F59E0B") ], - sparklineData: [45000, 46000, 47000, 48000, 49000, 50000], + trendPoints: [45000, 46000, 47000, 48000, 49000, 50000], + trendLabels: ["Aug", "Sep", "Oct", "Nov", "Dec", "Jan"], categoryEvolution: [ CategorySeries( id: "stocks", @@ -109,6 +121,9 @@ struct InvestmentWidgetProvider: TimelineProvider { ("Stocks", 26000, "#10B981"), ("Bonds", 14500, "#3B82F6"), ("Real Estate", 6800, "#F59E0B") + ], + goals: [ + GoalSummary(name: "Target", targetAmount: 75000, targetDate: nil) ] ) } @@ -139,9 +154,11 @@ struct InvestmentWidgetProvider: TimelineProvider { dayChange: 0, dayChangePercentage: 0, topSources: [], - sparklineData: [], + trendPoints: [], + trendLabels: [], categoryEvolution: [], - categoryTotals: [] + categoryTotals: [], + goals: [] ) } @@ -166,15 +183,18 @@ struct InvestmentWidgetProvider: TimelineProvider { let snapshots = (try? context.fetch(snapshotRequest)) ?? [] var dailyTotals: [Date: Decimal] = [:] + var monthlyTotals: [Date: Decimal] = [:] var latestBySource: [NSManagedObjectID: (date: Date, value: Decimal, source: NSManagedObject)] = [:] - var categoryDailyTotals: [String: [Date: Decimal]] = [:] + var categoryMonthlyTotals: [String: [Date: Decimal]] = [:] var categoryMeta: [String: (name: String, color: String)] = [:] let calendar = Calendar.current for snapshot in snapshots { guard let rawDate = snapshot.value(forKey: "date") as? Date else { continue } let day = calendar.startOfDay(for: rawDate) + let month = calendar.date(from: calendar.dateComponents([.year, .month], from: rawDate)) ?? day let value = decimalValue(from: snapshot, key: "value") dailyTotals[day, default: .zero] += value + monthlyTotals[month, default: .zero] += value if let source = snapshot.value(forKey: "source") as? NSManagedObject { let sourceId = source.objectID @@ -198,9 +218,9 @@ struct InvestmentWidgetProvider: TimelineProvider { } categoryMeta[categoryId] = (categoryName, categoryColor) - var dayTotals = categoryDailyTotals[categoryId, default: [:]] - dayTotals[day, default: .zero] += value - categoryDailyTotals[categoryId] = dayTotals + var monthTotals = categoryMonthlyTotals[categoryId, default: [:]] + monthTotals[month, default: .zero] += value + categoryMonthlyTotals[categoryId] = monthTotals } } @@ -208,7 +228,27 @@ struct InvestmentWidgetProvider: TimelineProvider { .map { ($0.key, $0.value) } .sorted { $0.0 < $1.0 } - let sparklineData = Array(sortedTotals.suffix(7).map { $0.1 }) + let sortedMonths = monthlyTotals + .map { ($0.key, $0.value) } + .sorted { $0.0 < $1.0 } + let months: [Date] + let trendPoints: [Decimal] + let trendLabels: [String] + if sortedMonths.isEmpty { + months = [] + trendPoints = [] + trendLabels = [] + } else { + let latestMonth = sortedMonths.last?.0 ?? + (calendar.date(from: calendar.dateComponents([.year, .month], from: Date())) ?? Date()) + months = (0..<6).reversed().compactMap { offset in + calendar.date(byAdding: .month, value: -offset, to: latestMonth) + } + let monthFormatter = DateFormatter() + monthFormatter.dateFormat = "MMM" + trendPoints = months.map { monthlyTotals[$0] ?? .zero } + trendLabels = months.map { monthFormatter.string(from: $0) } + } let totalValue = latestBySource.values.reduce(Decimal.zero) { $0 + $1.value } @@ -247,10 +287,9 @@ struct InvestmentWidgetProvider: TimelineProvider { } .sorted { $0.value > $1.value } - let evolutionDays = Array(sortedTotals.suffix(7).map { $0.0 }) let categoryEvolution: [CategorySeries] = categoryTotalsData.prefix(4).map { category in - let dayMap = categoryDailyTotals[category.id] ?? [:] - let points = evolutionDays.map { dayMap[$0] ?? .zero } + let monthMap = categoryMonthlyTotals[category.id] ?? [:] + let points = months.map { monthMap[$0] ?? .zero } return CategorySeries( id: category.id, name: category.name, @@ -271,6 +310,29 @@ struct InvestmentWidgetProvider: TimelineProvider { } } + let goalsRequest = NSFetchRequest(entityName: "Goal") + goalsRequest.predicate = NSPredicate(format: "isActive == YES") + let goalObjects = (try? context.fetch(goalsRequest)) ?? [] + let goalSummaries = goalObjects.compactMap { goal -> GoalSummary? in + let amount = decimalValue(from: goal, key: "targetAmount") + guard amount > 0 else { return nil } + let name = (goal.value(forKey: "name") as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) + let targetDate = goal.value(forKey: "targetDate") as? Date + return GoalSummary(name: (name?.isEmpty == false ? name! : "Goal"), targetAmount: amount, targetDate: targetDate) + } + let goals = goalSummaries.sorted { lhs, rhs in + switch (lhs.targetDate, rhs.targetDate) { + case let (lDate?, rDate?): + return lDate < rDate + case (_?, nil): + return true + case (nil, _?): + return false + default: + return lhs.targetAmount < rhs.targetAmount + } + } + return InvestmentWidgetEntry( date: Date(), isPremium: isPremium, @@ -278,9 +340,11 @@ struct InvestmentWidgetProvider: TimelineProvider { dayChange: dayChange, dayChangePercentage: dayChangePercentage, topSources: Array(topSources), - sparklineData: sparklineData, + trendPoints: trendPoints, + trendLabels: trendLabels, categoryEvolution: categoryEvolution, - categoryTotals: categoryTotalsData.map { (name: $0.name, value: $0.value, color: $0.color) } + categoryTotals: categoryTotalsData.map { (name: $0.name, value: $0.value, color: $0.color) }, + goals: goals ) } } @@ -357,9 +421,13 @@ struct MediumWidgetView: View { VStack(alignment: .trailing, spacing: 8) { if entry.isPremium { - if entry.sparklineData.count >= 2 { - SparklineView(data: entry.sparklineData, isPositive: entry.dayChange >= 0) - .frame(height: 48) + if entry.trendPoints.count >= 2 { + TrendLineChartView( + points: entry.trendPoints, + labels: entry.trendLabels, + goal: entry.goals.first + ) + .frame(height: 70) } else { VStack(alignment: .trailing, spacing: 4) { Text("Add snapshots") @@ -450,8 +518,12 @@ struct LargeWidgetView: View { if entry.isPremium { if hasCategoryTrend { - CategoryEvolutionView(series: entry.categoryEvolution) - .frame(height: 86) + CombinedCategoryChartView( + series: entry.categoryEvolution, + labels: entry.trendLabels, + goal: entry.goals.first + ) + .frame(height: 98) VStack(alignment: .leading, spacing: 8) { ForEach(entry.categoryTotals.prefix(4), id: \.name) { category in @@ -545,83 +617,202 @@ struct AccessoryRectangularView: View { } } -// MARK: - Sparkline +// MARK: - Trend Line Chart -struct SparklineView: View { - let data: [Decimal] - let isPositive: Bool +struct TrendLineChartView: View { + let points: [Decimal] + let labels: [String] + let goal: GoalSummary? - private var points: [CGFloat] { - let doubles = data.map { NSDecimalNumber(decimal: $0).doubleValue } - guard let minV = doubles.min(), let maxV = doubles.max(), minV != maxV else { - return doubles.map { _ in 0.5 } - } - return doubles.map { CGFloat(($0 - minV) / (maxV - minV)) } + private var values: [Double] { + points.map { NSDecimalNumber(decimal: $0).doubleValue } + } + + private var minValue: Double { + values.min() ?? 0 + } + + private var maxValue: Double { + values.max() ?? 0 + } + + private func normalized(_ value: Double) -> CGFloat { + let minV = minValue + let maxV = maxValue + if minV == maxV { return 0.5 } + return CGFloat((value - minV) / (maxV - minV)) } var body: some View { - GeometryReader { geo in - let width = geo.size.width - let height = geo.size.height - let step = data.count > 1 ? width / CGFloat(data.count - 1) : width + HStack(alignment: .center, spacing: 6) { + VStack(alignment: .leading, spacing: 2) { + Text(Decimal(maxValue).shortCurrencyString) + .font(.caption2) + .foregroundColor(.secondary) + Spacer() + Text(Decimal(minValue).shortCurrencyString) + .font(.caption2) + .foregroundColor(.secondary) + } - Path { path in - guard !points.isEmpty else { return } - for (index, value) in points.enumerated() { - let x = CGFloat(index) * step - let y = height - (CGFloat(value) * height) - if index == 0 { - path.move(to: CGPoint(x: x, y: y)) - } else { - path.addLine(to: CGPoint(x: x, y: y)) + VStack(spacing: 4) { + GeometryReader { geo in + let width = geo.size.width + let height = geo.size.height + let step = points.count > 1 ? width / CGFloat(points.count - 1) : width + + Path { path in + guard !values.isEmpty else { return } + for (index, value) in values.enumerated() { + let x = CGFloat(index) * step + let y = height - (normalized(value) * height) + if index == 0 { + path.move(to: CGPoint(x: x, y: y)) + } else { + path.addLine(to: CGPoint(x: x, y: y)) + } + } + } + .stroke( + LinearGradient( + colors: [ + widgetPrimaryColor.opacity(0.9), + widgetPrimaryColor.opacity(0.6) + ], + startPoint: .leading, + endPoint: .trailing + ), + style: StrokeStyle(lineWidth: 2, lineJoin: .round) + ) + + if let goal, maxValue > 0 { + let goalValue = NSDecimalNumber(decimal: goal.targetAmount).doubleValue + let goalY = height - (normalized(goalValue) * height) + Path { path in + path.move(to: CGPoint(x: 0, y: goalY)) + path.addLine(to: CGPoint(x: width, y: goalY)) + } + .stroke(widgetSecondaryColor.opacity(0.6), style: StrokeStyle(lineWidth: 1, dash: [4, 3])) + } + } + + HStack(spacing: 4) { + ForEach(labels.indices, id: \.self) { index in + Text(labels[index]) + .font(.caption2) + .foregroundColor(.secondary) + .frame(maxWidth: .infinity) } } } - .stroke( - LinearGradient( - colors: [ - (isPositive ? Color.green : Color.red).opacity(0.9), - (isPositive ? Color.green : Color.red).opacity(0.6) - ], - startPoint: .leading, - endPoint: .trailing - ), - style: StrokeStyle(lineWidth: 2, lineJoin: .round) - ) - .shadow(color: Color.black.opacity(0.08), radius: 2, y: 1) } } } -// MARK: - Category Evolution Chart +// MARK: - Combined Category Chart -struct CategoryEvolutionView: View { +struct CombinedCategoryChartView: View { let series: [CategorySeries] + let labels: [String] + let goal: GoalSummary? private var pointsCount: Int { series.first?.points.count ?? 0 } - var body: some View { - GeometryReader { geo in - let height = geo.size.height - let width = geo.size.width - let columnWidth = pointsCount > 0 ? (width / CGFloat(pointsCount)) : width + private var totals: [Decimal] { + guard pointsCount > 0 else { return [] } + return (0.. CGFloat { + let maxV = maxValue + if maxV == 0 { return 0 } + return CGFloat(value / maxV) + } + + var body: some View { + HStack(alignment: .center, spacing: 6) { + VStack(alignment: .leading, spacing: 2) { + Text(Decimal(maxValue).shortCurrencyString) + .font(.caption2) + .foregroundColor(.secondary) + Spacer() + Text(Decimal(0).shortCurrencyString) + .font(.caption2) + .foregroundColor(.secondary) + } + + VStack(spacing: 4) { + GeometryReader { geo in + let height = geo.size.height + let width = geo.size.width + let columnWidth = pointsCount > 0 ? (width / CGFloat(pointsCount)) : width + + ZStack { + HStack(alignment: .bottom, spacing: 4) { + ForEach(0.. 1 else { return } + let step = pointsCount > 1 ? width / CGFloat(pointsCount - 1) : width + for (index, total) in totals.enumerated() { + let value = NSDecimalNumber(decimal: total).doubleValue + let x = CGFloat(index) * step + let y = height - (normalized(value) * height) + if index == 0 { + path.move(to: CGPoint(x: x, y: y)) + } else { + path.addLine(to: CGPoint(x: x, y: y)) + } + } + } + .stroke(widgetPrimaryColor.opacity(0.85), style: StrokeStyle(lineWidth: 1.6)) + + if let goal, maxValue > 0 { + let goalValue = NSDecimalNumber(decimal: goal.targetAmount).doubleValue + let goalY = height - (normalized(goalValue) * height) + Path { path in + path.move(to: CGPoint(x: 0, y: goalY)) + path.addLine(to: CGPoint(x: width, y: goalY)) + } + .stroke(widgetSecondaryColor.opacity(0.6), style: StrokeStyle(lineWidth: 1, dash: [4, 3])) } } - .frame(width: columnWidth, height: height, alignment: .bottom) + } + + HStack(spacing: 4) { + ForEach(labels.indices, id: \.self) { index in + Text(labels[index]) + .font(.caption2) + .foregroundColor(.secondary) + .frame(maxWidth: .infinity) + } } } } @@ -687,59 +878,64 @@ struct PortfolioJournalWidgetBundle: WidgetBundle { #Preview("Small", as: .systemSmall) { InvestmentWidget() } timeline: { - InvestmentWidgetEntry( - date: Date(), - isPremium: true, - totalValue: 50000, - dayChange: 250, - dayChangePercentage: 0.5, - topSources: [], - sparklineData: [], - categoryEvolution: [], - categoryTotals: [] - ) + InvestmentWidgetEntry( + date: Date(), + isPremium: true, + totalValue: 50000, + dayChange: 250, + dayChangePercentage: 0.5, + topSources: [], + trendPoints: [45000, 46000, 47000, 48000, 49000, 50000], + trendLabels: ["Aug", "Sep", "Oct", "Nov", "Dec", "Jan"], + categoryEvolution: [], + categoryTotals: [], + goals: [] + ) } #Preview("Medium", as: .systemMedium) { InvestmentWidget() } timeline: { - InvestmentWidgetEntry( - date: Date(), - isPremium: true, - totalValue: 50000, - dayChange: 250, - dayChangePercentage: 0.5, - topSources: [ - ("Stocks", 30000, "#10B981"), - ("Bonds", 15000, "#3B82F6"), - ("Real Estate", 5000, "#F59E0B") - ], - sparklineData: [], - categoryEvolution: [], - categoryTotals: [] - ) + InvestmentWidgetEntry( + date: Date(), + isPremium: true, + totalValue: 50000, + dayChange: 250, + dayChangePercentage: 0.5, + topSources: [ + ("Stocks", 30000, "#10B981"), + ("Bonds", 15000, "#3B82F6"), + ("Real Estate", 5000, "#F59E0B") + ], + trendPoints: [45000, 46000, 47000, 48000, 49000, 50000], + trendLabels: ["Aug", "Sep", "Oct", "Nov", "Dec", "Jan"], + categoryEvolution: [], + categoryTotals: [], + goals: [] + ) } #Preview("Large", as: .systemLarge) { InvestmentWidget() } timeline: { - InvestmentWidgetEntry( - date: Date(), - isPremium: true, - totalValue: 95000, - dayChange: 850, - dayChangePercentage: 0.9, - topSources: [ - ("Vanguard", 42000, "#10B981"), - ("Bonds", 26000, "#3B82F6"), - ("Real Estate", 18000, "#F59E0B") - ], - sparklineData: [88000, 89000, 90000, 91500, 93000, 94000, 95000], - categoryEvolution: [ - CategorySeries( - id: "stocks", - name: "Stocks", - color: "#10B981", + InvestmentWidgetEntry( + date: Date(), + isPremium: true, + totalValue: 95000, + dayChange: 850, + dayChangePercentage: 0.9, + topSources: [ + ("Vanguard", 42000, "#10B981"), + ("Bonds", 26000, "#3B82F6"), + ("Real Estate", 18000, "#F59E0B") + ], + trendPoints: [88000, 89000, 90000, 91500, 93000, 94000, 95000], + trendLabels: ["Jul", "Aug", "Sep", "Oct", "Nov", "Dec", "Jan"], + categoryEvolution: [ + CategorySeries( + id: "stocks", + name: "Stocks", + color: "#10B981", points: [30000, 31000, 32000, 33000, 34000, 35000, 36000], latestValue: 36000 ), @@ -757,13 +953,16 @@ struct PortfolioJournalWidgetBundle: WidgetBundle { points: [15000, 15500, 16000, 16500, 17000, 17500, 18000], latestValue: 18000 ) - ], - categoryTotals: [ - ("Stocks", 36000, "#10B981"), - ("Bonds", 26000, "#3B82F6"), - ("Real Estate", 18000, "#F59E0B") - ] - ) + ], + categoryTotals: [ + ("Stocks", 36000, "#10B981"), + ("Bonds", 26000, "#3B82F6"), + ("Real Estate", 18000, "#F59E0B") + ], + goals: [ + GoalSummary(name: "Target", targetAmount: 120000, targetDate: nil) + ] + ) } extension Decimal { var compactCurrencyString: String {