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 {