Update version

main
Alexandre 2026-01-16 11:28:26 +01:00
parent 7988257399
commit c6be398e5a
No known key found for this signature in database
GPG Key ID: 205DAC70EF7BDFD9
28 changed files with 1061 additions and 193 deletions

BIN
.DS_Store vendored

Binary file not shown.

View File

@ -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",

View File

@ -50,6 +50,9 @@
ReferencedContainer = "container:PortfolioJournal.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
<StoreKitConfigurationFileReference
identifier = "../../PortfolioJournal/Configuration.storekit">
</StoreKitConfigurationFileReference>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"

View File

@ -1,9 +1,11 @@
import SwiftUI
import CoreData
struct ContentView: View {
@EnvironmentObject var iapService: IAPService
@EnvironmentObject var adMobService: AdMobService
@EnvironmentObject var tabSelection: TabSelectionStore
@EnvironmentObject var coreDataStack: CoreDataStack
@AppStorage("onboardingCompleted") private var onboardingCompleted = false
@AppStorage("faceIdEnabled") private var faceIdEnabled = false
@AppStorage("pinEnabled") private var pinEnabled = false
@ -11,6 +13,7 @@ struct ContentView: View {
@AppStorage("lockOnBackground") private var lockOnBackground = false
@Environment(\.scenePhase) private var scenePhase
@State private var isUnlocked = false
@State private var resolvedOnboardingCompleted: Bool?
private var lockEnabled: Bool {
faceIdEnabled || pinEnabled
@ -19,7 +22,9 @@ struct ContentView: View {
var body: some View {
ZStack {
Group {
if !onboardingCompleted {
if !isReadyForContent {
AppLaunchLoadingView(messageKey: "loading_data")
} else if resolvedOnboardingCompleted == false {
OnboardingView(onboardingCompleted: $onboardingCompleted)
} else {
mainContent
@ -55,6 +60,62 @@ struct ContentView: View {
isUnlocked = false
}
}
.onAppear {
if coreDataStack.isLoaded {
syncOnboardingState()
}
}
.onChange(of: coreDataStack.isLoaded) { _, loaded in
if loaded {
syncOnboardingState()
}
}
.onChange(of: onboardingCompleted) { _, completed in
resolvedOnboardingCompleted = completed
}
}
private var isReadyForContent: Bool {
coreDataStack.isLoaded && resolvedOnboardingCompleted != nil
}
private func syncOnboardingState() {
let settings = AppSettings.getOrCreate(in: coreDataStack.viewContext)
var resolved = settings.onboardingCompleted || onboardingCompleted
if !resolved && hasExistingData() {
resolved = true
}
if settings.onboardingCompleted != resolved {
settings.onboardingCompleted = resolved
CoreDataStack.shared.save()
}
if onboardingCompleted != resolved {
onboardingCompleted = resolved
}
resolvedOnboardingCompleted = resolved
}
private func hasExistingData() -> Bool {
let context = coreDataStack.viewContext
let sourceRequest: NSFetchRequest<InvestmentSource> = InvestmentSource.fetchRequest()
sourceRequest.fetchLimit = 1
sourceRequest.resultType = .countResultType
if (try? context.count(for: sourceRequest)) ?? 0 > 0 {
return true
}
let snapshotRequest: NSFetchRequest<Snapshot> = Snapshot.fetchRequest()
snapshotRequest.fetchLimit = 1
snapshotRequest.resultType = .countResultType
if (try? context.count(for: snapshotRequest)) ?? 0 > 0 {
return true
}
let accountRequest: NSFetchRequest<Account> = 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()))

View File

@ -22,6 +22,7 @@ struct PortfolioJournalApp: App {
WindowGroup {
ContentView()
.environment(\.managedObjectContext, coreDataStack.viewContext)
.environmentObject(coreDataStack)
.environmentObject(iapService)
.environmentObject(adMobService)
.environmentObject(accountStore)

View File

@ -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
}
}

View File

@ -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

View File

@ -3,7 +3,7 @@
<plist version="1.0">
<dict>
<key>aps-environment</key>
<string>development</string>
<string>production</string>
<key>com.apple.developer.icloud-container-identifiers</key>
<array>
<string>iCloud.com.alexandrevazquez.portfoliojournal</string>

View File

@ -39,13 +39,18 @@ 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)")
DispatchQueue.main.async {
self.categories = []
}
}
}
}
func fetchCategory(by id: UUID) -> Category? {
let request: NSFetchRequest<Category> = Category.fetchRequest()

View File

@ -40,13 +40,18 @@ 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)")
DispatchQueue.main.async {
self.goals = []
}
}
}
}
// MARK: - Create

View File

@ -41,13 +41,18 @@ 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)")
DispatchQueue.main.async {
self.sources = []
}
}
}
}
func fetchSource(by id: UUID) -> InvestmentSource? {
let request: NSFetchRequest<InvestmentSource> = InvestmentSource.fetchRequest()

View File

@ -9,7 +9,7 @@
<key>PLIST_VERSION</key>
<string>1</string>
<key>BUNDLE_ID</key>
<string>com.alexandrevazquez.portfoliojournal</string>
<string>com.alexandrevazquez.PortfolioJournal</string>
<key>PROJECT_ID</key>
<string>portfoliojournal-ef2d7</string>
<key>STORAGE_BUCKET</key>
@ -25,6 +25,6 @@
<key>IS_SIGNIN_ENABLED</key>
<true></true>
<key>GOOGLE_APP_ID</key>
<string>1:334225114072:ios:81bad412ffe1c6df3d28ad</string>
<string>1:334225114072:ios:a0a50b5835ac042e3d28ad</string>
</dict>
</plist>

View File

@ -35,6 +35,269 @@
<string>ca-app-pub-1549720748100858~9632507420</string>
<key>GADDelayAppMeasurementInit</key>
<true/>
<key>SKAdNetworkItems</key>
<array>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>cstr6suwn9.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>4fzdc2evr5.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>4pfyvq9l8r.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>2fnua5tdw4.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>ydx93a7ass.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>5a6flpkh64.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>p78axxw29g.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>v72qych5uu.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>ludvb6z3bs.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>cp8zw746q7.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>3sh42y64q3.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>c6k4g5qg8m.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>s39g8k73mm.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>3qy4746246.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>f38h382jlk.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>hs6bdukanm.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>v4nxqhlyqp.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>wzmmz9fp6w.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>yclnxrl5pm.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>t38b2kh725.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>7ug5zh24hu.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>gta9lk7p23.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>vutu7akeur.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>y5ghdn5j9k.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>n6fk4nfna4.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>v9wttpbfk9.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>n38lu8286q.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>47vhws6wlr.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>kbd757ber7.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>9t245vhmpl.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>eh6m2bh4zr.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>a2p9lx4jpn.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>22mmun2rn5.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>4468km3ulz.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>2u9pt9hc89.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>8s468mfl3y.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>klf5c3l5u5.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>ppxm28t8ap.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>ecpz2srf59.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>uw77j35x4d.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>pwa73g5rt2.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>mlmmfzh3r3.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>578prtvx9j.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>4dzt52r2t5.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>e5fvkxwrpn.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>8c4e2ghe7u.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>zq492l623r.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>3rd42ekr43.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>3qcr597p9d.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>su67r6k2v3.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>kbd757ywx3.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>44n7hlldy6.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>k674qkevps.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>2rq3zucswp.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>5tjdwbrq8w.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>97r2b46745.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>b9bk5wbcq9.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>44jx6755aq.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>kbmxgpxpgc.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>tl55sbb4fm.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>9g2aggbj52.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>g2y4y55b64.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>c3frkrj4fj.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>r26jy69rpl.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>275upjj5gd.skadnetwork</string>
</dict>
</array>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSAppTransportSecurity</key>

View File

@ -20,6 +20,7 @@
"error" = "Error";
"success" = "Success";
"loading" = "Loading...";
"loading_data" = "Loading your data...";
// MARK: - Tab Bar
"tab_dashboard" = "Home";

View File

@ -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";

View File

@ -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,

View File

@ -16,7 +16,6 @@ enum AppConstants {
// MARK: - StoreKit
static let premiumProductID = "com.portfoliojournal.premium"
static let premiumPrice = "€4.69"
// MARK: - AdMob

View File

@ -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<AnyCancellable>()
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

View File

@ -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
)
}

View File

@ -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 {

View File

@ -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 {

View File

@ -43,7 +43,7 @@ struct DashboardView: View {
viewModel.refreshData()
}
.overlay {
if viewModel.isLoading {
if viewModel.isLoading && !viewModel.hasData {
ProgressView()
}
}

View File

@ -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()

View File

@ -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")
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)

View File

@ -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"

View File

@ -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()))
}

View File

@ -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<NSManagedObject>(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,31 +617,55 @@ 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 }
private var values: [Double] {
points.map { NSDecimalNumber(decimal: $0).doubleValue }
}
return doubles.map { CGFloat(($0 - minV) / (maxV - minV)) }
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 {
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)
}
VStack(spacing: 4) {
GeometryReader { geo in
let width = geo.size.width
let height = geo.size.height
let step = data.count > 1 ? width / CGFloat(data.count - 1) : width
let step = points.count > 1 ? width / CGFloat(points.count - 1) : width
Path { path in
guard !points.isEmpty else { return }
for (index, value) in points.enumerated() {
guard !values.isEmpty else { return }
for (index, value) in values.enumerated() {
let x = CGFloat(index) * step
let y = height - (CGFloat(value) * height)
let y = height - (normalized(value) * height)
if index == 0 {
path.move(to: CGPoint(x: x, y: y))
} else {
@ -580,48 +676,143 @@ struct SparklineView: View {
.stroke(
LinearGradient(
colors: [
(isPositive ? Color.green : Color.red).opacity(0.9),
(isPositive ? Color.green : Color.red).opacity(0.6)
widgetPrimaryColor.opacity(0.9),
widgetPrimaryColor.opacity(0.6)
],
startPoint: .leading,
endPoint: .trailing
),
style: StrokeStyle(lineWidth: 2, lineJoin: .round)
)
.shadow(color: Color.black.opacity(0.08), radius: 2, y: 1)
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)
}
}
}
}
}
}
// 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
}
private var totals: [Decimal] {
guard pointsCount > 0 else { return [] }
return (0..<pointsCount).map { index in
series.reduce(Decimal.zero) { $0 + $1.points[index] }
}
}
private var maxValue: Double {
let seriesMax = totals.map { NSDecimalNumber(decimal: $0).doubleValue }.max() ?? 0
let goalValue = goal.map { NSDecimalNumber(decimal: $0.targetAmount).doubleValue } ?? 0
return max(seriesMax, goalValue)
}
private func normalized(_ value: Double) -> 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..<pointsCount, id: \.self) { index in
let dayTotal = series.reduce(Decimal.zero) { $0 + $1.points[index] }
let total = totals.indices.contains(index) ? totals[index] : .zero
let totalValue = NSDecimalNumber(decimal: total).doubleValue
let columnHeight = height * normalized(totalValue)
VStack(spacing: 1) {
ForEach(series.indices, id: \.self) { catIndex in
let value = series[catIndex].points[index]
let ratio = dayTotal == 0 ? 0 : NSDecimalNumber(decimal: value / dayTotal).doubleValue
let segmentHeight = value == 0 ? 0 : max(1, height * CGFloat(ratio))
let valueDouble = NSDecimalNumber(decimal: value).doubleValue
let segmentHeight = totalValue == 0 ? 0 : height * CGFloat(valueDouble / maxValue)
RoundedRectangle(cornerRadius: 2)
.fill(Color(hex: series[catIndex].color) ?? .gray)
.frame(height: segmentHeight)
}
}
.frame(width: columnWidth, height: height, alignment: .bottom)
.frame(width: columnWidth, height: columnHeight, alignment: .bottom)
}
}
Path { path in
guard totals.count > 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]))
}
}
}
HStack(spacing: 4) {
ForEach(labels.indices, id: \.self) { index in
Text(labels[index])
.font(.caption2)
.foregroundColor(.secondary)
.frame(maxWidth: .infinity)
}
}
}
}
@ -694,9 +885,11 @@ struct PortfolioJournalWidgetBundle: WidgetBundle {
dayChange: 250,
dayChangePercentage: 0.5,
topSources: [],
sparklineData: [],
trendPoints: [45000, 46000, 47000, 48000, 49000, 50000],
trendLabels: ["Aug", "Sep", "Oct", "Nov", "Dec", "Jan"],
categoryEvolution: [],
categoryTotals: []
categoryTotals: [],
goals: []
)
}
@ -714,9 +907,11 @@ struct PortfolioJournalWidgetBundle: WidgetBundle {
("Bonds", 15000, "#3B82F6"),
("Real Estate", 5000, "#F59E0B")
],
sparklineData: [],
trendPoints: [45000, 46000, 47000, 48000, 49000, 50000],
trendLabels: ["Aug", "Sep", "Oct", "Nov", "Dec", "Jan"],
categoryEvolution: [],
categoryTotals: []
categoryTotals: [],
goals: []
)
}
@ -734,7 +929,8 @@ struct PortfolioJournalWidgetBundle: WidgetBundle {
("Bonds", 26000, "#3B82F6"),
("Real Estate", 18000, "#F59E0B")
],
sparklineData: [88000, 89000, 90000, 91500, 93000, 94000, 95000],
trendPoints: [88000, 89000, 90000, 91500, 93000, 94000, 95000],
trendLabels: ["Jul", "Aug", "Sep", "Oct", "Nov", "Dec", "Jan"],
categoryEvolution: [
CategorySeries(
id: "stocks",
@ -762,6 +958,9 @@ struct PortfolioJournalWidgetBundle: WidgetBundle {
("Stocks", 36000, "#10B981"),
("Bonds", 26000, "#3B82F6"),
("Real Estate", 18000, "#F59E0B")
],
goals: [
GoalSummary(name: "Target", targetAmount: 120000, targetDate: nil)
]
)
}