Compare commits
57 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f2f54a5ca3 | |||
| 3b1f62cc26 | |||
| 8084bc6a82 | |||
| 6e6f4c5489 | |||
| cf984dbf09 | |||
| f1aeafccf6 | |||
| d941ecf27b | |||
| 9c2a67853d | |||
| d38c6943e1 | |||
| 525911f67a | |||
| 326be6db04 | |||
| 648c2bd805 | |||
| 6887d05b01 | |||
| fd1094b4fc | |||
| 922710c53f | |||
| 1a3fce0df4 | |||
| eee0910680 | |||
| e4a93a5d4a | |||
| 20a73ea957 | |||
| 8d5f309f21 | |||
| 84d9240ee6 | |||
| 8d7cfc098b | |||
| df9309895a | |||
| b60e3041cb | |||
| cbcb1da0ac | |||
| fc05dfe2c6 | |||
| 047060f126 | |||
| 2ea32b5494 | |||
| d7149aad67 | |||
| 57280ca3ca | |||
| 9539803d74 | |||
| d50926df76 | |||
| 99dc855467 | |||
| 20b8a32ea3 | |||
| d87d9d3dd5 | |||
| f34460d97a | |||
| e916218271 | |||
| 7eb7cb81d4 | |||
| 137931b37c | |||
| 32e1fb1c9c | |||
| 95c689d2b8 | |||
| 2a5ffca48f | |||
| 1e6e3ef361 | |||
| 8f14d4cd06 | |||
| 41e2a22a64 | |||
| d8bd625327 | |||
| 7c6a29c821 | |||
| 6bdf2740c5 | |||
| 86c95a9e73 | |||
| 1abffdb7bb | |||
| 18f2609d87 | |||
| b76b8f81f0 | |||
| 4c0d2af564 | |||
| 08094483ed | |||
| ed737bc290 | |||
| 7044cfd441 | |||
| be7119f4b9 |
@@ -541,7 +541,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = PortfolioJournal/PortfolioJournalDebug.entitlements;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 50;
|
||||
CURRENT_PROJECT_VERSION = 84;
|
||||
DEVELOPMENT_ASSET_PATHS = PortfolioJournal/Assets.xcassets;
|
||||
DEVELOPMENT_TEAM = 2825Q76T7H;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
@@ -559,7 +559,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.4.2;
|
||||
MARKETING_VERSION = 1.6.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournal;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
@@ -582,7 +582,7 @@
|
||||
CODE_SIGN_IDENTITY = "Apple Distribution";
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
PROVISIONING_PROFILE_SPECIFIER = "porfoliojournal";
|
||||
CURRENT_PROJECT_VERSION = 50;
|
||||
CURRENT_PROJECT_VERSION = 84;
|
||||
DEVELOPMENT_ASSET_PATHS = PortfolioJournal/Assets.xcassets;
|
||||
DEVELOPMENT_TEAM = 2825Q76T7H;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
@@ -600,7 +600,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.4.2;
|
||||
MARKETING_VERSION = 1.6.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournal;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
@@ -739,7 +739,7 @@
|
||||
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
|
||||
CODE_SIGN_ENTITLEMENTS = PortfolioJournalWidgetExtension.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 50;
|
||||
CURRENT_PROJECT_VERSION = 84;
|
||||
DEVELOPMENT_ASSET_PATHS = PortfolioJournalWidget/Assets.xcassets;
|
||||
DEVELOPMENT_TEAM = 2825Q76T7H;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
@@ -752,7 +752,7 @@
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.4.2;
|
||||
MARKETING_VERSION = 1.6.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournal.PortfolioJournalWidget;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SKIP_INSTALL = YES;
|
||||
@@ -774,7 +774,7 @@
|
||||
CODE_SIGN_IDENTITY = "Apple Distribution";
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
PROVISIONING_PROFILE_SPECIFIER = "Portfolio Journalwidget";
|
||||
CURRENT_PROJECT_VERSION = 50;
|
||||
CURRENT_PROJECT_VERSION = 84;
|
||||
DEVELOPMENT_ASSET_PATHS = PortfolioJournalWidget/Assets.xcassets;
|
||||
DEVELOPMENT_TEAM = 2825Q76T7H;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
@@ -787,7 +787,7 @@
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.4.2;
|
||||
MARKETING_VERSION = 1.6.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournal.PortfolioJournalWidget;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SKIP_INSTALL = YES;
|
||||
@@ -805,7 +805,7 @@
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 50;
|
||||
CURRENT_PROJECT_VERSION = 84;
|
||||
DEVELOPMENT_TEAM = 2825Q76T7H;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
|
||||
@@ -814,7 +814,7 @@
|
||||
"@executable_path/Frameworks",
|
||||
"@loader_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.4.2;
|
||||
MARKETING_VERSION = 1.6.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournalTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||
@@ -829,7 +829,7 @@
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 50;
|
||||
CURRENT_PROJECT_VERSION = 84;
|
||||
DEVELOPMENT_TEAM = 2825Q76T7H;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
|
||||
@@ -838,7 +838,7 @@
|
||||
"@executable_path/Frameworks",
|
||||
"@loader_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.4.2;
|
||||
MARKETING_VERSION = 1.6.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournalTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||
@@ -852,7 +852,7 @@
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 50;
|
||||
CURRENT_PROJECT_VERSION = 84;
|
||||
DEVELOPMENT_TEAM = 2825Q76T7H;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
|
||||
@@ -861,7 +861,7 @@
|
||||
"@executable_path/Frameworks",
|
||||
"@loader_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.4.2;
|
||||
MARKETING_VERSION = 1.6.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournalUITests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||
@@ -875,7 +875,7 @@
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 50;
|
||||
CURRENT_PROJECT_VERSION = 84;
|
||||
DEVELOPMENT_TEAM = 2825Q76T7H;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
|
||||
@@ -884,7 +884,7 @@
|
||||
"@executable_path/Frameworks",
|
||||
"@loader_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.4.2;
|
||||
MARKETING_VERSION = 1.6.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournalUITests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||
@@ -899,7 +899,7 @@
|
||||
buildSettings = {
|
||||
CODE_SIGN_ENTITLEMENTS = PortfolioJournalQuickUpdateExtension.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 50;
|
||||
CURRENT_PROJECT_VERSION = 84;
|
||||
DEVELOPMENT_TEAM = 2825Q76T7H;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = PortfolioJournalQuickUpdate/Info.plist;
|
||||
@@ -911,7 +911,7 @@
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.4.2;
|
||||
MARKETING_VERSION = 1.6.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournal.QuickUpdate;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SKIP_INSTALL = YES;
|
||||
@@ -927,7 +927,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = PortfolioJournalQuickUpdateExtension.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Distribution";
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
CURRENT_PROJECT_VERSION = 50;
|
||||
CURRENT_PROJECT_VERSION = 84;
|
||||
DEVELOPMENT_TEAM = 2825Q76T7H;
|
||||
PROVISIONING_PROFILE_SPECIFIER = "PortfolioJournal QuickUpdate AppStore";
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
@@ -940,7 +940,7 @@
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.4.2;
|
||||
MARKETING_VERSION = 1.6.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournal.QuickUpdate;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SKIP_INSTALL = YES;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "2620"
|
||||
version = "1.7">
|
||||
version = "1.3">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES"
|
||||
@@ -34,7 +34,7 @@
|
||||
parallelizable = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "0ETEST001000000000000000"
|
||||
BlueprintIdentifier = "0ETEST0012F31000000000001"
|
||||
BuildableName = "PortfolioJournalTests.xctest"
|
||||
BlueprintName = "PortfolioJournalTests"
|
||||
ReferencedContainer = "container:PortfolioJournal.xcodeproj">
|
||||
@@ -73,6 +73,20 @@
|
||||
ReferencedContainer = "container:PortfolioJournal.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
<CommandLineArguments>
|
||||
<CommandLineArgument
|
||||
argument = "-com.apple.CoreData.Logging.stderr 1"
|
||||
isEnabled = "YES">
|
||||
</CommandLineArgument>
|
||||
<CommandLineArgument
|
||||
argument = "-InitializeCloudKitSchema"
|
||||
isEnabled = "YES">
|
||||
</CommandLineArgument>
|
||||
<CommandLineArgument
|
||||
argument = "-com.apple.CoreData.CloudKitDebug 3 "
|
||||
isEnabled = "YES">
|
||||
</CommandLineArgument>
|
||||
</CommandLineArguments>
|
||||
<StoreKitConfigurationFileReference
|
||||
identifier = "../../PortfolioJournal/Configuration.storekit">
|
||||
</StoreKitConfigurationFileReference>
|
||||
|
||||
+5
@@ -9,6 +9,11 @@
|
||||
<key>orderHint</key>
|
||||
<integer>1</integer>
|
||||
</dict>
|
||||
<key>PortfolioJournalQuickUpdate.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
<key>orderHint</key>
|
||||
<integer>2</integer>
|
||||
</dict>
|
||||
<key>PortfolioJournalWidgetExtension.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
<key>orderHint</key>
|
||||
|
||||
@@ -113,7 +113,11 @@ struct ContentView: View {
|
||||
}
|
||||
.task {
|
||||
await waitForDataAndResolveOnboarding()
|
||||
if (resolvedOnboardingCompleted == true) && currentVersion != lastSeenWhatsNewVersion {
|
||||
// Cumulative What's New: everything between the version the user
|
||||
// comes from and the one they just installed. Empty catalog diff
|
||||
// (e.g. patch release without entry) → no sheet.
|
||||
if (resolvedOnboardingCompleted == true) && currentVersion != lastSeenWhatsNewVersion,
|
||||
!WhatsNewCatalog.pendingReleases(since: lastSeenWhatsNewVersion, current: currentVersion).isEmpty {
|
||||
// Small delay to let the UI settle before showing sheet
|
||||
try? await Task.sleep(nanoseconds: 300_000_000)
|
||||
showingWhatsNew = true
|
||||
@@ -121,11 +125,23 @@ struct ContentView: View {
|
||||
}
|
||||
.onChange(of: onboardingCompleted) { _, completed in
|
||||
resolvedOnboardingCompleted = completed
|
||||
// Fresh install: nothing is "new" for a user who just onboarded.
|
||||
if completed && lastSeenWhatsNewVersion.isEmpty {
|
||||
lastSeenWhatsNewVersion = currentVersion
|
||||
// Straight to the aha moment: open Add Source instead of
|
||||
// dropping the new user on an empty dashboard.
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) {
|
||||
NotificationCenter.default.post(name: .openAddFirstSource, object: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showingWhatsNew, onDismiss: {
|
||||
lastSeenWhatsNewVersion = currentVersion
|
||||
}) {
|
||||
WhatsNewView()
|
||||
WhatsNewView(releases: WhatsNewCatalog.pendingReleases(
|
||||
since: lastSeenWhatsNewVersion,
|
||||
current: currentVersion
|
||||
))
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .openBatchUpdate)) { _ in
|
||||
tabSelection.selectedTab = 1
|
||||
@@ -322,4 +338,5 @@ struct ContentView: View {
|
||||
.environmentObject(AdMobService())
|
||||
.environmentObject(AccountStore(iapService: IAPService()))
|
||||
.environmentObject(TabSelectionStore())
|
||||
.environmentObject(BalancePrivacyManager.shared)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import SwiftUI
|
||||
import CoreData
|
||||
import TipKit
|
||||
|
||||
/// Support for automated App Store screenshot capture. Activated by launching the app
|
||||
/// with the `--screenshots` argument (used by the UI-test capture flow). Only mutates
|
||||
@@ -29,6 +31,27 @@ enum ScreenshotMode {
|
||||
static func seedIfNeeded() {
|
||||
guard isActive else { return }
|
||||
SampleDataService.shared.seedSampleData()
|
||||
makeCheckInPendingIfNeeded()
|
||||
}
|
||||
|
||||
/// `--pending-checkin`: strip the seeded data back to a "month not done yet"
|
||||
/// state (recent snapshots + this month's journal completion removed) so the
|
||||
/// guided check-in flow can be exercised/captured.
|
||||
private static func makeCheckInPendingIfNeeded() {
|
||||
guard CommandLine.arguments.contains("--pending-checkin") else { return }
|
||||
let context = CoreDataStack.shared.viewContext
|
||||
let cutoff = Calendar.current.date(byAdding: .day, value: -40, to: Date()) ?? Date()
|
||||
|
||||
let snapshotRequest = Snapshot.fetchRequest()
|
||||
snapshotRequest.predicate = NSPredicate(format: "date >= %@", cutoff as NSDate)
|
||||
(try? context.fetch(snapshotRequest))?.forEach(context.delete)
|
||||
|
||||
let journalRequest = JournalEntry.fetchRequest()
|
||||
journalRequest.predicate = NSPredicate(format: "completionTime >= %@", cutoff as NSDate)
|
||||
(try? context.fetch(journalRequest))?.forEach(context.delete)
|
||||
|
||||
try? context.save()
|
||||
SharedQuickUpdateSync.refreshMirror()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,6 +62,7 @@ struct PortfolioJournalApp: App {
|
||||
@StateObject private var adMobService: AdMobService
|
||||
@StateObject private var accountStore: AccountStore
|
||||
@StateObject private var tabSelection = TabSelectionStore()
|
||||
@StateObject private var balancePrivacy = BalancePrivacyManager.shared
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
|
||||
let coreDataStack = CoreDataStack.shared
|
||||
@@ -51,6 +75,9 @@ struct PortfolioJournalApp: App {
|
||||
// Clean up any duplicate objects from previous bugs before initializing stores
|
||||
CoreDataStack.shared.cleanupDuplicateObjects()
|
||||
|
||||
// Feature-discovery tips (Quick Update, OCR, Siri, chart sharing)
|
||||
try? Tips.configure([.displayFrequency(.daily)])
|
||||
|
||||
let iap = IAPService()
|
||||
_iapService = StateObject(wrappedValue: iap)
|
||||
_adMobService = StateObject(wrappedValue: AdMobService())
|
||||
@@ -66,6 +93,7 @@ struct PortfolioJournalApp: App {
|
||||
.environmentObject(adMobService)
|
||||
.environmentObject(accountStore)
|
||||
.environmentObject(tabSelection)
|
||||
.environmentObject(balancePrivacy)
|
||||
}
|
||||
.onChange(of: scenePhase) { _, newPhase in
|
||||
if newPhase == .active {
|
||||
@@ -74,6 +102,10 @@ struct PortfolioJournalApp: App {
|
||||
// iCloud changes made on other devices while this device was inactive
|
||||
// are reflected immediately without waiting for a remote-change notification.
|
||||
coreDataStack.refreshFromCloudKit()
|
||||
// Auto-backup: writes a lossless JSON snapshot on the configured cadence.
|
||||
DispatchQueue.global(qos: .utility).async {
|
||||
BackupService.shared.runScheduledBackupIfNeeded()
|
||||
}
|
||||
NotificationService.shared.scheduleReEngagementNotification()
|
||||
NotificationService.shared.scheduleMonthlyCheckIn()
|
||||
NotificationService.shared.scheduleStreakProtectionReminder()
|
||||
|
||||
@@ -7,9 +7,21 @@ public class Account: NSManagedObject, Identifiable {
|
||||
return NSFetchRequest<Account>(entityName: "Account")
|
||||
}
|
||||
|
||||
@NSManaged public var id: UUID
|
||||
@NSManaged public var name: String
|
||||
@NSManaged public var createdAt: Date
|
||||
@objc public var id: UUID {
|
||||
get { safeValue(forKey: "id", fallback: UUID(), healing: true) }
|
||||
set { setManagedValue(newValue, forKey: "id") }
|
||||
}
|
||||
@objc public var name: String {
|
||||
get { safeValue(forKey: "name", fallback: "") }
|
||||
set { setManagedValue(newValue, forKey: "name") }
|
||||
}
|
||||
// createdAt is optional in the model; a non-optional @NSManaged Date crashes the
|
||||
// CloudKit merge when an imported record materialises with a transient nil.
|
||||
// Same nil-tolerant accessor pattern already applied to Snapshot.
|
||||
@objc public var createdAt: Date {
|
||||
get { safeValue(forKey: "createdAt", fallback: .distantPast) }
|
||||
set { setManagedValue(newValue, forKey: "createdAt") }
|
||||
}
|
||||
@NSManaged public var currency: String?
|
||||
@NSManaged public var inputMode: String
|
||||
@NSManaged public var notificationFrequency: String
|
||||
@@ -34,6 +46,11 @@ public class Account: NSManagedObject, Identifiable {
|
||||
|
||||
extension Account {
|
||||
static let defaultAccountName = "Default"
|
||||
|
||||
/// Deterministic UUID for the auto-created "Default" account, so every device
|
||||
/// creates it as the SAME CloudKit record instead of a per-device duplicate.
|
||||
/// See StableID.
|
||||
static let defaultAccountStableID = StableID.v5("account:default")
|
||||
}
|
||||
|
||||
// MARK: - Computed Properties
|
||||
|
||||
@@ -8,10 +8,22 @@ public class Category: NSManagedObject, Identifiable {
|
||||
return NSFetchRequest<Category>(entityName: "Category")
|
||||
}
|
||||
|
||||
@NSManaged public var id: UUID
|
||||
@NSManaged public var name: String
|
||||
@NSManaged public var colorHex: String
|
||||
@NSManaged public var icon: String
|
||||
@objc public var id: UUID {
|
||||
get { safeValue(forKey: "id", fallback: UUID(), healing: true) }
|
||||
set { setManagedValue(newValue, forKey: "id") }
|
||||
}
|
||||
@objc public var name: String {
|
||||
get { safeValue(forKey: "name", fallback: "") }
|
||||
set { setManagedValue(newValue, forKey: "name") }
|
||||
}
|
||||
@objc public var colorHex: String {
|
||||
get { safeValue(forKey: "colorHex", fallback: "#6B7280") }
|
||||
set { setManagedValue(newValue, forKey: "colorHex") }
|
||||
}
|
||||
@objc public var icon: String {
|
||||
get { safeValue(forKey: "icon", fallback: "questionmark") }
|
||||
set { setManagedValue(newValue, forKey: "icon") }
|
||||
}
|
||||
@NSManaged public var sortOrder: Int16
|
||||
@NSManaged public var createdAt: Date
|
||||
@NSManaged public var allocationTarget: NSNumber?
|
||||
@@ -80,9 +92,17 @@ extension Category {
|
||||
("ETFs", "#EC4899", "chart.bar.fill")
|
||||
]
|
||||
|
||||
/// Deterministic UUID for a default category, so every device creates each
|
||||
/// default category as the SAME CloudKit record instead of a per-device
|
||||
/// duplicate. Only meaningful for the built-in default names. See StableID.
|
||||
static func stableID(for name: String) -> UUID {
|
||||
StableID.v5("category:\(name.lowercased())")
|
||||
}
|
||||
|
||||
static func createDefaultCategories(in context: NSManagedObjectContext) {
|
||||
for (index, categoryData) in defaultCategories.enumerated() {
|
||||
let category = Category(context: context)
|
||||
category.id = stableID(for: categoryData.name)
|
||||
category.name = categoryData.name
|
||||
category.colorHex = categoryData.colorHex
|
||||
category.icon = categoryData.icon
|
||||
|
||||
@@ -7,12 +7,24 @@ public class Goal: NSManagedObject, Identifiable {
|
||||
return NSFetchRequest<Goal>(entityName: "Goal")
|
||||
}
|
||||
|
||||
@NSManaged public var id: UUID
|
||||
@NSManaged public var name: String
|
||||
@objc public var id: UUID {
|
||||
get { safeValue(forKey: "id", fallback: UUID(), healing: true) }
|
||||
set { setManagedValue(newValue, forKey: "id") }
|
||||
}
|
||||
@objc public var name: String {
|
||||
get { safeValue(forKey: "name", fallback: "") }
|
||||
set { setManagedValue(newValue, forKey: "name") }
|
||||
}
|
||||
@NSManaged public var targetAmount: NSDecimalNumber?
|
||||
@NSManaged public var targetDate: Date?
|
||||
@NSManaged public var isActive: Bool
|
||||
@NSManaged public var createdAt: Date
|
||||
// createdAt is optional in the model; a non-optional @NSManaged Date crashes the
|
||||
// CloudKit merge when an imported record materialises with a transient nil.
|
||||
// Same nil-tolerant accessor pattern already applied to Snapshot.
|
||||
@objc public var createdAt: Date {
|
||||
get { safeValue(forKey: "createdAt", fallback: .distantPast) }
|
||||
set { setManagedValue(newValue, forKey: "createdAt") }
|
||||
}
|
||||
@NSManaged public var account: Account?
|
||||
|
||||
public override func awakeFromInsert() {
|
||||
|
||||
@@ -7,13 +7,25 @@ public class InvestmentSource: NSManagedObject, Identifiable {
|
||||
return NSFetchRequest<InvestmentSource>(entityName: "InvestmentSource")
|
||||
}
|
||||
|
||||
@NSManaged public var id: UUID
|
||||
@NSManaged public var name: String
|
||||
@objc public var id: UUID {
|
||||
get { safeValue(forKey: "id", fallback: UUID(), healing: true) }
|
||||
set { setManagedValue(newValue, forKey: "id") }
|
||||
}
|
||||
@objc public var name: String {
|
||||
get { safeValue(forKey: "name", fallback: "") }
|
||||
set { setManagedValue(newValue, forKey: "name") }
|
||||
}
|
||||
@NSManaged public var notificationFrequency: String
|
||||
@NSManaged public var customFrequencyMonths: Int16
|
||||
@NSManaged public var isActive: Bool
|
||||
@NSManaged public var monthlyContribution: NSDecimalNumber?
|
||||
@NSManaged public var createdAt: Date
|
||||
// createdAt is optional in the model; a non-optional @NSManaged Date crashes the
|
||||
// CloudKit merge when an imported record materialises with a transient nil.
|
||||
// Same nil-tolerant accessor pattern already applied to Snapshot.
|
||||
@objc public var createdAt: Date {
|
||||
get { safeValue(forKey: "createdAt", fallback: .distantPast) }
|
||||
set { setManagedValue(newValue, forKey: "createdAt") }
|
||||
}
|
||||
@NSManaged public var category: Category?
|
||||
@NSManaged public var account: Account?
|
||||
@NSManaged public var snapshots: NSSet?
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import CoreData
|
||||
|
||||
// MARK: - Nil-tolerant accessors for CloudKit-synced attributes
|
||||
//
|
||||
// Attributes declared non-optional in Swift (`id: UUID`, `date: Date`, …) CAN
|
||||
// be nil at runtime: NSPersistentCloudKitContainer merges records from other
|
||||
// devices in stages, and deleted objects may still be referenced by live views.
|
||||
// Reading them through `@NSManaged` force-bridges nil and crashes
|
||||
// (`UUID/Date._unconditionallyBridgeFromObjectiveC`) — seen in production in
|
||||
// SwiftUI ForEach identity (nil id) and ChartsViewModel.monthlyTotals (nil
|
||||
// date). These helpers read the primitive value and fall back safely.
|
||||
extension NSManagedObject {
|
||||
/// Reads a primitive attribute, returning `fallback` when nil. With
|
||||
/// `healing: true` the fallback is also written back to the primitive
|
||||
/// (without dirtying the object) so identity stays stable for the session.
|
||||
func safeValue<T>(forKey key: String, fallback: @autoclosure () -> T, healing: Bool = false) -> T {
|
||||
willAccessValue(forKey: key)
|
||||
defer { didAccessValue(forKey: key) }
|
||||
if let value = primitiveValue(forKey: key) as? T {
|
||||
return value
|
||||
}
|
||||
let healed = fallback()
|
||||
if healing {
|
||||
setPrimitiveValue(healed, forKey: key)
|
||||
}
|
||||
return healed
|
||||
}
|
||||
|
||||
func setManagedValue<T>(_ value: T, forKey key: String) {
|
||||
willChangeValue(forKey: key)
|
||||
setPrimitiveValue(value, forKey: key)
|
||||
didChangeValue(forKey: key)
|
||||
}
|
||||
}
|
||||
+4
-3
@@ -21,8 +21,8 @@
|
||||
<attribute name="name" attributeType="String" defaultValueString=""/>
|
||||
<attribute name="notificationFrequency" attributeType="String" defaultValueString="monthly"/>
|
||||
<attribute name="sortOrder" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/>
|
||||
<relationship name="goals" optional="YES" toMany="YES" deletionRule="Cascade" destinationEntity="Goal" inverseName="account" inverseEntity="Goal"/>
|
||||
<relationship name="sources" optional="YES" toMany="YES" deletionRule="Cascade" destinationEntity="InvestmentSource" inverseName="account" inverseEntity="InvestmentSource"/>
|
||||
<relationship name="goals" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Goal" inverseName="account" inverseEntity="Goal"/>
|
||||
<relationship name="sources" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="InvestmentSource" inverseName="account" inverseEntity="InvestmentSource"/>
|
||||
</entity>
|
||||
<entity name="Category" representedClassName="Category" syncable="YES">
|
||||
<attribute name="allocationTarget" optional="YES" attributeType="Double" usesScalarValueType="NO"/>
|
||||
@@ -32,7 +32,7 @@
|
||||
<attribute name="id" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
|
||||
<attribute name="name" attributeType="String" defaultValueString=""/>
|
||||
<attribute name="sortOrder" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/>
|
||||
<relationship name="sources" optional="YES" toMany="YES" deletionRule="Cascade" destinationEntity="InvestmentSource" inverseName="category" inverseEntity="InvestmentSource"/>
|
||||
<relationship name="sources" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="InvestmentSource" inverseName="category" inverseEntity="InvestmentSource"/>
|
||||
</entity>
|
||||
<entity name="InvestmentSource" representedClassName="InvestmentSource" syncable="YES">
|
||||
<attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||
@@ -100,6 +100,7 @@
|
||||
<attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||
<attribute name="date" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||
<attribute name="id" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
|
||||
<attribute name="isEstimated" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
|
||||
<attribute name="notes" optional="YES" attributeType="String"/>
|
||||
<attribute name="value" optional="YES" attributeType="Decimal"/>
|
||||
<relationship name="source" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="InvestmentSource" inverseName="snapshots" inverseEntity="InvestmentSource"/>
|
||||
|
||||
@@ -7,12 +7,25 @@ public class Snapshot: NSManagedObject, Identifiable {
|
||||
return NSFetchRequest<Snapshot>(entityName: "Snapshot")
|
||||
}
|
||||
|
||||
@NSManaged public var id: UUID
|
||||
@NSManaged public var date: Date
|
||||
@objc public var id: UUID {
|
||||
get { safeValue(forKey: "id", fallback: UUID(), healing: true) }
|
||||
set { setManagedValue(newValue, forKey: "id") }
|
||||
}
|
||||
@objc public var date: Date {
|
||||
get { safeValue(forKey: "date", fallback: .distantPast) }
|
||||
set { setManagedValue(newValue, forKey: "date") }
|
||||
}
|
||||
@NSManaged public var value: NSDecimalNumber?
|
||||
@NSManaged public var contribution: NSDecimalNumber?
|
||||
@NSManaged public var notes: String?
|
||||
@NSManaged public var createdAt: Date
|
||||
/// True for auto-generated (interpolated) snapshots that fill a gap between two
|
||||
/// real snapshots. Estimated snapshots are shown with a distinct marker and can
|
||||
/// be removed in bulk. Defaults to false (model default) for all real snapshots.
|
||||
@NSManaged public var isEstimated: Bool
|
||||
@objc public var createdAt: Date {
|
||||
get { safeValue(forKey: "createdAt", fallback: .distantPast) }
|
||||
set { setManagedValue(newValue, forKey: "createdAt") }
|
||||
}
|
||||
@NSManaged public var source: InvestmentSource?
|
||||
|
||||
public override func awakeFromInsert() {
|
||||
|
||||
@@ -123,6 +123,15 @@ class CoreDataStack: ObservableObject {
|
||||
@Published private(set) var lastExportDate: Date?
|
||||
@Published private(set) var isSyncing = false
|
||||
@Published private(set) var lastSyncError: String?
|
||||
/// Full recursive dump of the last sync error tree (leaf CKError codes, record
|
||||
/// types, retry-after, raw userInfo keys). Surfaced in the copyable diagnostics
|
||||
/// so we can see WHICH record CloudKit rejects, not just the opaque code 2.
|
||||
@Published private(set) var lastSyncErrorDetail: String?
|
||||
/// Human-readable, actionable explanation of the last sync error (nil if none).
|
||||
@Published private(set) var lastSyncErrorHint: String?
|
||||
/// True only when sync is genuinely broken (never synced + total failure).
|
||||
/// Partial failures after a working sync are informational, not alarming.
|
||||
@Published private(set) var syncErrorIsCritical = false
|
||||
|
||||
var lastSyncDate: Date? { lastImportDate }
|
||||
|
||||
@@ -183,9 +192,32 @@ class CoreDataStack: ObservableObject {
|
||||
print("Core Data failed to load: \(error), \(error.userInfo)")
|
||||
} else {
|
||||
print("Core Data loaded successfully at: \(description.url?.path ?? "unknown")")
|
||||
#if DEBUG
|
||||
// Dev-only, opt-in: push the FULL model schema to the Development CloudKit
|
||||
// environment so optional fields that were never exported by a Debug run
|
||||
// (e.g. monthlyContribution on InvestmentSource, allocationTarget on Category)
|
||||
// get materialised — otherwise they can't be deployed to Production and every
|
||||
// Production export rejects those records (bare CKErrorPartialFailure).
|
||||
// Run ONCE from Xcode with launch arg -InitializeCloudKitSchema, watch for the
|
||||
// ✅ log, then deploy to Production in the CloudKit Console and remove the arg.
|
||||
if ProcessInfo.processInfo.arguments.contains("-InitializeCloudKitSchema"),
|
||||
let ckContainer = container as? NSPersistentCloudKitContainer {
|
||||
do {
|
||||
try ckContainer.initializeCloudKitSchema(options: [])
|
||||
print("✅ CloudKit schema initialized in Development. Now open the CloudKit Console → Deploy Schema Changes → Production.")
|
||||
} catch {
|
||||
print("❌ initializeCloudKitSchema failed: \(error)")
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
DispatchQueue.main.async {
|
||||
self?.isLoaded = true
|
||||
// Merge the pre-stable-UUID default duplicates. Safe now: the merge
|
||||
// reassigns children then deletes losers, and the relevant relationships
|
||||
// use the Nullify delete rule so a propagated parent-delete can never
|
||||
// cascade-wipe children (unlike the build-68 Cascade disaster).
|
||||
self?.mergeDefaultDuplicates()
|
||||
MonthlyCheckInStore.migrateIfNeeded()
|
||||
// Migrate device-local UserDefaults data into Core Data so it syncs via iCloud.
|
||||
MonthlyContributionStore.migrateIfNeeded(context: container.viewContext)
|
||||
@@ -276,6 +308,243 @@ class CoreDataStack: ObservableObject {
|
||||
return objectsToDelete.count
|
||||
}
|
||||
|
||||
// MARK: - Logical Duplicate Cleanup (cross-device / cross-environment)
|
||||
|
||||
/// Removes *logical* duplicates: records that represent the same real-world
|
||||
/// entity but carry DIFFERENT UUIDs. These appear when the same data is
|
||||
/// created independently on two devices — or in the Development vs Production
|
||||
/// CloudKit environments — and then merged by CloudKit, which keys on the
|
||||
/// record name (our UUID) and therefore never recognises them as the same,
|
||||
/// keeping both. The classic trigger here: `createDefaultAccountIfNeeded` and
|
||||
/// `ensureDefaultCategoriesExist` seed defaults on every device at launch
|
||||
/// *before* the first import arrives, so each device contributes its own
|
||||
/// "Default" account + 8 categories → they pile up (8 accounts, 23 categories).
|
||||
///
|
||||
/// The UUID-based `cleanupDuplicateObjects()` cannot catch these because the
|
||||
/// UUIDs differ. Here we group by a stable *business key* and keep a single
|
||||
/// deterministic winner (earliest `createdAt`, tie-broken by lowest UUID —
|
||||
/// both synced fields, so every device independently picks the SAME survivor
|
||||
/// and the deletes converge instead of racing each other into data loss),
|
||||
/// reassigning children onto the survivor before deleting the losers.
|
||||
@discardableResult
|
||||
func cleanupLogicalDuplicates() -> Int {
|
||||
var removed = 0
|
||||
let context = viewContext
|
||||
context.performAndWait {
|
||||
// Parents first: re-point children onto the surviving parent so the
|
||||
// child keys (which embed the parent's id) stay stable for later passes.
|
||||
removed += mergeLogicalDuplicates(
|
||||
entityName: "Account",
|
||||
childRelationships: ["sources", "goals"],
|
||||
context: context,
|
||||
key: { obj in
|
||||
let name = (obj.value(forKey: "name") as? String ?? "")
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
return name.isEmpty ? "" : "account|\(name)"
|
||||
})
|
||||
|
||||
removed += mergeLogicalDuplicates(
|
||||
entityName: "Category",
|
||||
childRelationships: ["sources"],
|
||||
context: context,
|
||||
key: { obj in
|
||||
let name = (obj.value(forKey: "name") as? String ?? "")
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
return name.isEmpty ? "" : "category|\(name)"
|
||||
})
|
||||
|
||||
removed += mergeLogicalDuplicates(
|
||||
entityName: "InvestmentSource",
|
||||
childRelationships: ["snapshots", "transactions"],
|
||||
context: context,
|
||||
key: { obj in
|
||||
let name = (obj.value(forKey: "name") as? String ?? "")
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
guard !name.isEmpty else { return "" }
|
||||
let acc = (obj.value(forKey: "account") as? NSManagedObject)?
|
||||
.value(forKey: "id") as? UUID
|
||||
return "source|\(acc?.uuidString ?? "none")|\(name)"
|
||||
})
|
||||
|
||||
removed += mergeLogicalDuplicates(
|
||||
entityName: "Snapshot",
|
||||
childRelationships: [],
|
||||
context: context,
|
||||
key: { obj in
|
||||
// Only dedup snapshots that share the same source, same day AND
|
||||
// same value — a true copy. Orphans (nil source) are left alone.
|
||||
guard let src = (obj.value(forKey: "source") as? NSManagedObject)?
|
||||
.value(forKey: "id") as? UUID,
|
||||
let date = obj.value(forKey: "date") as? Date else { return "" }
|
||||
let day = (date.timeIntervalSinceReferenceDate / 86_400).rounded(.down)
|
||||
let value = (obj.value(forKey: "value") as? NSDecimalNumber)?
|
||||
.stringValue ?? "nil"
|
||||
return "snapshot|\(src.uuidString)|\(day)|\(value)"
|
||||
})
|
||||
|
||||
removed += mergeLogicalDuplicates(
|
||||
entityName: "Goal",
|
||||
childRelationships: [],
|
||||
context: context,
|
||||
key: { obj in
|
||||
let name = (obj.value(forKey: "name") as? String ?? "")
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
guard !name.isEmpty else { return "" }
|
||||
let target = (obj.value(forKey: "targetAmount") as? NSDecimalNumber)?
|
||||
.stringValue ?? "nil"
|
||||
return "goal|\(name)|\(target)"
|
||||
})
|
||||
|
||||
removed += mergeLogicalDuplicates(
|
||||
entityName: "JournalEntry",
|
||||
childRelationships: [],
|
||||
context: context,
|
||||
key: { obj in
|
||||
let monthKey = (obj.value(forKey: "monthKey") as? String ?? "")
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
return monthKey.isEmpty ? "" : "journal|\(monthKey)"
|
||||
})
|
||||
|
||||
if context.hasChanges {
|
||||
try? context.save()
|
||||
}
|
||||
}
|
||||
return removed
|
||||
}
|
||||
|
||||
/// Groups objects of `entityName` by `key`, keeps one deterministic winner
|
||||
/// per group, moves every child in `childRelationships` from the losers onto
|
||||
/// the winner, then deletes the losers. An empty key means "never dedup this
|
||||
/// object" (degenerate identity — e.g. no name, orphan snapshot).
|
||||
@discardableResult
|
||||
private func mergeLogicalDuplicates(
|
||||
entityName: String,
|
||||
childRelationships: [String],
|
||||
context: NSManagedObjectContext,
|
||||
key: (NSManagedObject) -> String
|
||||
) -> Int {
|
||||
let request = NSFetchRequest<NSManagedObject>(entityName: entityName)
|
||||
guard let objects = try? context.fetch(request), !objects.isEmpty else { return 0 }
|
||||
|
||||
var groups: [String: [NSManagedObject]] = [:]
|
||||
for obj in objects {
|
||||
let k = key(obj)
|
||||
guard !k.isEmpty else { continue }
|
||||
groups[k, default: []].append(obj)
|
||||
}
|
||||
|
||||
var removed = 0
|
||||
for (_, group) in groups where group.count > 1 {
|
||||
// Deterministic ordering → same winner on every device.
|
||||
let sorted = group.sorted { a, b in
|
||||
let ca = (a.value(forKey: "createdAt") as? Date) ?? .distantPast
|
||||
let cb = (b.value(forKey: "createdAt") as? Date) ?? .distantPast
|
||||
if ca != cb { return ca < cb }
|
||||
let ia = (a.value(forKey: "id") as? UUID)?.uuidString ?? ""
|
||||
let ib = (b.value(forKey: "id") as? UUID)?.uuidString ?? ""
|
||||
return ia < ib
|
||||
}
|
||||
let winner = sorted[0]
|
||||
for loser in sorted.dropFirst() {
|
||||
for rel in childRelationships {
|
||||
let children = loser.mutableSetValue(forKey: rel)
|
||||
guard children.count > 0 else { continue }
|
||||
let winnerChildren = winner.mutableSetValue(forKey: rel)
|
||||
// Snapshot children first so the winner is a superset before delete.
|
||||
for child in children.allObjects {
|
||||
winnerChildren.add(child) // reassigns the modeled inverse
|
||||
}
|
||||
}
|
||||
context.delete(loser)
|
||||
removed += 1
|
||||
}
|
||||
}
|
||||
if removed > 0 {
|
||||
print("[LogicalDedup] \(entityName): removed \(removed) duplicate(s)")
|
||||
}
|
||||
return removed
|
||||
}
|
||||
|
||||
// MARK: - Merge Default Duplicates (CloudKit-safe)
|
||||
|
||||
/// Merges the duplicate default Accounts and Categories that piled up before
|
||||
/// the stable-UUID fix (per-device "Default" accounts + default categories with
|
||||
/// random UUIDs). CloudKit-safe now because Account.sources/goals and
|
||||
/// Category.sources use the Nullify delete rule — so a merged-away parent's
|
||||
/// delete can never cascade-wipe children on any device (the build-68 disaster
|
||||
/// was Cascade + cross-device import ordering). Children are reassigned to a
|
||||
/// DETERMINISTIC winner (the stable-UUID record if present, else earliest
|
||||
/// createdAt / lowest UUID) so every device converges on the same survivor.
|
||||
@discardableResult
|
||||
func mergeDefaultDuplicates() -> Int {
|
||||
var merged = 0
|
||||
let context = viewContext
|
||||
context.performAndWait {
|
||||
merged += mergeDefaultAccounts(in: context)
|
||||
merged += mergeDuplicateCategories(in: context)
|
||||
if context.hasChanges { try? context.save() }
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
private func deterministicWinner(_ objects: [NSManagedObject], stableID: UUID?) -> NSManagedObject? {
|
||||
if let stableID, let canonical = objects.first(where: { ($0.value(forKey: "id") as? UUID) == stableID }) {
|
||||
return canonical
|
||||
}
|
||||
return objects.sorted {
|
||||
let a = ($0.value(forKey: "createdAt") as? Date) ?? .distantPast
|
||||
let b = ($1.value(forKey: "createdAt") as? Date) ?? .distantPast
|
||||
if a != b { return a < b }
|
||||
let ida = ($0.value(forKey: "id") as? UUID)?.uuidString ?? ""
|
||||
let idb = ($1.value(forKey: "id") as? UUID)?.uuidString ?? ""
|
||||
return ida < idb
|
||||
}.first
|
||||
}
|
||||
|
||||
private func reassign(_ children: Any?, key: String, to winner: NSManagedObject) {
|
||||
guard let set = children as? Set<NSManagedObject> else { return }
|
||||
for child in Array(set) { child.setValue(winner, forKey: key) }
|
||||
}
|
||||
|
||||
private func mergeDefaultAccounts(in context: NSManagedObjectContext) -> Int {
|
||||
let request = NSFetchRequest<NSManagedObject>(entityName: "Account")
|
||||
request.predicate = NSPredicate(format: "name == %@", Account.defaultAccountName)
|
||||
guard let accounts = try? context.fetch(request), accounts.count > 1,
|
||||
let winner = deterministicWinner(accounts, stableID: Account.defaultAccountStableID) else { return 0 }
|
||||
var removed = 0
|
||||
for loser in accounts where loser !== winner {
|
||||
reassign(loser.value(forKey: "sources"), key: "account", to: winner)
|
||||
reassign(loser.value(forKey: "goals"), key: "account", to: winner)
|
||||
context.delete(loser)
|
||||
removed += 1
|
||||
}
|
||||
if removed > 0 { print("[MergeDefaults] accounts: removed \(removed)") }
|
||||
return removed
|
||||
}
|
||||
|
||||
private func mergeDuplicateCategories(in context: NSManagedObjectContext) -> Int {
|
||||
let request = NSFetchRequest<NSManagedObject>(entityName: "Category")
|
||||
guard let categories = try? context.fetch(request), !categories.isEmpty else { return 0 }
|
||||
var byName: [String: [NSManagedObject]] = [:]
|
||||
for c in categories {
|
||||
let name = (c.value(forKey: "name") as? String ?? "").trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
guard !name.isEmpty else { continue }
|
||||
byName[name, default: []].append(c)
|
||||
}
|
||||
var removed = 0
|
||||
for (name, group) in byName where group.count > 1 {
|
||||
let stable = Category.stableID(for: name)
|
||||
guard let winner = deterministicWinner(group, stableID: stable) else { continue }
|
||||
for loser in group where loser !== winner {
|
||||
reassign(loser.value(forKey: "sources"), key: "category", to: winner)
|
||||
context.delete(loser)
|
||||
removed += 1
|
||||
}
|
||||
}
|
||||
if removed > 0 { print("[MergeDefaults] categories: removed \(removed)") }
|
||||
return removed
|
||||
}
|
||||
|
||||
// MARK: - Save Context
|
||||
|
||||
func save() {
|
||||
@@ -317,6 +586,12 @@ class CoreDataStack: ObservableObject {
|
||||
if removed > 0 {
|
||||
print("[RemoteChanges] Removed \(removed) duplicate objects after CloudKit import")
|
||||
}
|
||||
// Merge default duplicates after each import (CloudKit-safe via Nullify
|
||||
// rules + deterministic winner → devices converge, no cascade wipes).
|
||||
let mergedDefaults = self.mergeDefaultDuplicates()
|
||||
if mergedDefaults > 0 {
|
||||
print("[RemoteChanges] Merged \(mergedDefaults) default duplicates after CloudKit import")
|
||||
}
|
||||
// Notify repositories to re-fetch unconditionally.
|
||||
NotificationCenter.default.post(name: .cloudKitForceReload, object: nil)
|
||||
self.objectWillChange.send()
|
||||
@@ -343,6 +618,15 @@ class CoreDataStack: ObservableObject {
|
||||
} else if let error = event.error {
|
||||
let typeLabel = event.type == .import ? "import" : event.type == .export ? "export" : "setup"
|
||||
self.lastSyncError = "\(typeLabel): \(Self.describeError(error))"
|
||||
self.lastSyncErrorDetail = "\(typeLabel)\n\(Self.deepErrorReport(error))"
|
||||
self.lastSyncErrorHint = Self.hint(for: error)
|
||||
// Severity: a partial failure AFTER sync has worked at least
|
||||
// once is background noise (a subset of records CloudKit
|
||||
// retries), NOT a broken-sync alarm. Only a total failure with
|
||||
// no data ever synced is critical.
|
||||
let hasSyncedBefore = self.lastImportDate != nil || self.lastExportDate != nil
|
||||
let isPartial = (error as NSError).code == 2 // CKErrorPartialFailure
|
||||
self.syncErrorIsCritical = !(hasSyncedBefore || isPartial)
|
||||
print("CloudKit \(typeLabel) full error:\n\(error)\nuserInfo: \((error as NSError).userInfo)")
|
||||
}
|
||||
}
|
||||
@@ -376,6 +660,144 @@ class CoreDataStack: ObservableObject {
|
||||
return parts.joined(separator: "\n")
|
||||
}
|
||||
|
||||
/// Turns an opaque CloudKit error into a human-readable, actionable hint by
|
||||
/// digging into CKPartialErrorsByItemIDKey — the per-record failures hidden
|
||||
/// inside a CKErrorPartialFailure (code 2). The dominant underlying code
|
||||
/// tells us the real problem: schema not deployed (unknownItem/invalidArguments)
|
||||
/// vs. quota vs. transient network.
|
||||
static func hint(for error: Error) -> String? {
|
||||
let ns = error as NSError
|
||||
// Collect all leaf CKError codes across a possible partial-failure tree.
|
||||
var codes: [Int] = []
|
||||
func collect(_ e: NSError) {
|
||||
if e.domain == "CKErrorDomain", e.code != 2 { codes.append(e.code) }
|
||||
if let partial = e.userInfo["CKPartialErrorsByItemIDKey"] as? [AnyHashable: Any] {
|
||||
for case let sub as NSError in partial.values { collect(sub) }
|
||||
}
|
||||
for value in e.userInfo.values {
|
||||
if let sub = value as? NSError, sub !== e { collect(sub) }
|
||||
}
|
||||
}
|
||||
collect(ns)
|
||||
|
||||
// CKError codes: 14 = unknownItem, 12 = invalidArguments, 6 = partialFailure,
|
||||
// 25 = quotaExceeded, 4 = networkFailure/unavailable, 3 = networkUnavailable,
|
||||
// 15 = serverRejectedRequest, 26 = operationCancelled.
|
||||
if codes.contains(14) || codes.contains(12) || codes.contains(15) {
|
||||
return String(localized: "icloud_hint_schema")
|
||||
}
|
||||
if codes.contains(25) {
|
||||
return String(localized: "icloud_hint_quota")
|
||||
}
|
||||
if codes.contains(3) || codes.contains(4) {
|
||||
return String(localized: "icloud_hint_network")
|
||||
}
|
||||
if ns.code == 2 {
|
||||
return String(localized: "icloud_hint_partial_generic")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Full recursive walk of a CloudKit/Core Data error tree. Unlike `describeError`
|
||||
/// (a compact one-liner), this follows NSUnderlyingError chains and
|
||||
/// CKPartialErrorsByItemIDKey, and pulls the failing record TYPE out of any
|
||||
/// embedded CKRecord — which is what tells us WHICH entity CloudKit rejects.
|
||||
static func deepErrorReport(_ error: Error) -> String {
|
||||
var lines: [String] = []
|
||||
var leafCodes = Set<Int>()
|
||||
var recordTypes = Set<String>()
|
||||
var visited = 0
|
||||
|
||||
func walk(_ err: Error, indent: Int) {
|
||||
guard visited < 300 else { return }
|
||||
visited += 1
|
||||
let ns = err as NSError
|
||||
let pad = String(repeating: " ", count: indent)
|
||||
lines.append("\(pad)\(ns.domain)(\(ns.code))")
|
||||
if ns.domain == "CKErrorDomain", ns.code != 2 { leafCodes.insert(ns.code) }
|
||||
|
||||
let info = ns.userInfo
|
||||
if let reason = info[NSLocalizedFailureReasonErrorKey] as? String {
|
||||
lines.append("\(pad) reason: \(reason)")
|
||||
}
|
||||
if let desc = info[NSLocalizedDescriptionKey] as? String {
|
||||
lines.append("\(pad) desc: \(desc)")
|
||||
}
|
||||
if let retry = info["CKErrorRetryAfterKey"] {
|
||||
lines.append("\(pad) retryAfter: \(retry)")
|
||||
}
|
||||
for key in ["CKRecordChangedErrorServerRecordKey",
|
||||
"CKRecordChangedErrorClientRecordKey",
|
||||
"CKRecordChangedErrorAncestorRecordKey"] {
|
||||
if let rec = info[key] as? CKRecord {
|
||||
recordTypes.insert(rec.recordType)
|
||||
lines.append("\(pad) record: \(rec.recordType) [\(rec.recordID.recordName)]")
|
||||
}
|
||||
}
|
||||
if let partial = info["CKPartialErrorsByItemIDKey"] as? [AnyHashable: Any] {
|
||||
lines.append("\(pad) partialErrors: \(partial.count)")
|
||||
for (rid, value) in partial.prefix(25) {
|
||||
if let name = (rid as? CKRecord.ID)?.recordName {
|
||||
lines.append("\(pad) item: \(name)")
|
||||
}
|
||||
if let sub = value as? Error { walk(sub, indent: indent + 3) }
|
||||
}
|
||||
}
|
||||
if let underlying = info[NSUnderlyingErrorKey] as? Error {
|
||||
lines.append("\(pad) underlying:")
|
||||
walk(underlying, indent: indent + 2)
|
||||
}
|
||||
for (k, v) in info {
|
||||
let ks = "\(k)"
|
||||
if ks == NSUnderlyingErrorKey || ks == "CKPartialErrorsByItemIDKey" { continue }
|
||||
if let sub = v as? Error, (sub as NSError) !== ns {
|
||||
lines.append("\(pad) [\(ks)]:")
|
||||
walk(sub, indent: indent + 2)
|
||||
}
|
||||
}
|
||||
let keys = info.keys.map { "\($0)" }.sorted()
|
||||
if !keys.isEmpty { lines.append("\(pad) keys: \(keys.joined(separator: ", "))") }
|
||||
}
|
||||
|
||||
walk(error, indent: 0)
|
||||
|
||||
var summary = ""
|
||||
if !leafCodes.isEmpty {
|
||||
summary += "leafCKCodes: [\(leafCodes.sorted().map(String.init).joined(separator: ", "))]\n"
|
||||
}
|
||||
if !recordTypes.isEmpty {
|
||||
summary += "recordTypes: [\(recordTypes.sorted().joined(separator: ", "))]\n"
|
||||
}
|
||||
return summary + lines.joined(separator: "\n")
|
||||
}
|
||||
|
||||
/// Local Core Data integrity probe: per-entity counts + records with nil id /
|
||||
/// nil createdAt / orphan relationships that can silently block a CloudKit export.
|
||||
func integrityReport() -> String {
|
||||
let ctx = viewContext
|
||||
var lines: [String] = []
|
||||
let entities = ["Account", "Category", "InvestmentSource", "Snapshot", "Goal", "JournalEntry"]
|
||||
for name in entities {
|
||||
let count = (try? ctx.count(for: NSFetchRequest<NSManagedObject>(entityName: name))) ?? -1
|
||||
lines.append("\(name): \(count)")
|
||||
}
|
||||
func flag(_ label: String, _ n: Int) { if n > 0 { lines.append("⚠︎ \(label): \(n)") } }
|
||||
func fetch(_ name: String) -> [NSManagedObject] {
|
||||
(try? ctx.fetch(NSFetchRequest<NSManagedObject>(entityName: name))) ?? []
|
||||
}
|
||||
let sources = fetch("InvestmentSource")
|
||||
flag("sources nil id", sources.filter { $0.value(forKey: "id") == nil }.count)
|
||||
flag("sources nil createdAt", sources.filter { $0.value(forKey: "createdAt") == nil }.count)
|
||||
let snaps = fetch("Snapshot")
|
||||
flag("snapshots nil id", snaps.filter { $0.value(forKey: "id") == nil }.count)
|
||||
flag("snapshots nil createdAt", snaps.filter { $0.value(forKey: "createdAt") == nil }.count)
|
||||
flag("orphan snapshots (nil source)", snaps.filter { $0.value(forKey: "source") == nil }.count)
|
||||
let goals = fetch("Goal")
|
||||
flag("goals nil id", goals.filter { $0.value(forKey: "id") == nil }.count)
|
||||
flag("goals nil createdAt", goals.filter { $0.value(forKey: "createdAt") == nil }.count)
|
||||
return lines.joined(separator: "\n")
|
||||
}
|
||||
|
||||
func forceReload() {
|
||||
viewContext.perform { [weak self] in
|
||||
self?.viewContext.refreshAllObjects()
|
||||
@@ -432,7 +854,14 @@ class CoreDataStack: ObservableObject {
|
||||
|
||||
// MARK: - Widget Data Refresh
|
||||
|
||||
/// When true, `refreshWidgetData()` becomes a no-op. Set during batch operations
|
||||
/// (e.g. CSV import of hundreds of snapshots) so we don't run a synchronous WAL
|
||||
/// checkpoint + widget reload per row — that hammered the main thread into an ANR.
|
||||
/// Call `refreshWidgetData()` once when the batch finishes.
|
||||
var suppressWidgetRefresh = false
|
||||
|
||||
func refreshWidgetData() {
|
||||
if suppressWidgetRefresh { return }
|
||||
if #available(iOS 14.0, *) {
|
||||
if Self.appGroupEnabled {
|
||||
checkpointWAL()
|
||||
|
||||
@@ -1,9 +1,47 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Tone / severity of an insight, used to pick an accent color and to
|
||||
/// prioritize which insights are surfaced first.
|
||||
enum InsightTone {
|
||||
case positive
|
||||
case neutral
|
||||
case attention
|
||||
|
||||
/// Higher priority insights are shown first when capping the list.
|
||||
var priority: Int {
|
||||
switch self {
|
||||
case .attention: return 2
|
||||
case .positive: return 1
|
||||
case .neutral: return 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct PortfolioInsight: Identifiable {
|
||||
let id: String
|
||||
let systemImage: String
|
||||
let title: String
|
||||
let value: String
|
||||
let accentColor: Color
|
||||
/// Optional supporting line shown under the value in the richer card.
|
||||
let detail: String?
|
||||
let tone: InsightTone
|
||||
|
||||
init(
|
||||
id: String,
|
||||
systemImage: String,
|
||||
title: String,
|
||||
value: String,
|
||||
accentColor: Color,
|
||||
detail: String? = nil,
|
||||
tone: InsightTone = .neutral
|
||||
) {
|
||||
self.id = id
|
||||
self.systemImage = systemImage
|
||||
self.title = title
|
||||
self.value = value
|
||||
self.accentColor = accentColor
|
||||
self.detail = detail
|
||||
self.tone = tone
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,9 +96,12 @@ class AccountRepository: ObservableObject {
|
||||
return existing
|
||||
}
|
||||
|
||||
// No accounts exist, create Default account
|
||||
// No accounts exist, create Default account.
|
||||
// Use a DETERMINISTIC id so every device creates the same CloudKit record
|
||||
// instead of a per-device duplicate (root-cause fix for the dedup pile-up).
|
||||
let defaultCurrency = AppSettings.getOrCreate(in: context).currency
|
||||
let account = Account(context: context)
|
||||
account.id = Account.defaultAccountStableID
|
||||
account.name = Account.defaultAccountName
|
||||
account.currency = defaultCurrency
|
||||
account.inputMode = InputMode.simple.rawValue
|
||||
|
||||
@@ -109,6 +109,8 @@ class CategoryRepository: ObservableObject {
|
||||
}
|
||||
|
||||
let category = Category(context: context)
|
||||
// Deterministic id → same CloudKit record on every device (no duplicates).
|
||||
category.id = Category.stableID(for: categoryData.name)
|
||||
category.name = categoryData.name
|
||||
category.colorHex = categoryData.colorHex
|
||||
category.icon = categoryData.icon
|
||||
|
||||
@@ -102,7 +102,8 @@ class SnapshotRepository: ObservableObject {
|
||||
date: Date = Date(),
|
||||
value: Decimal,
|
||||
contribution: Decimal? = nil,
|
||||
notes: String? = nil
|
||||
notes: String? = nil,
|
||||
batch: Bool = false
|
||||
) -> Snapshot {
|
||||
let snapshot = Snapshot(context: context)
|
||||
snapshot.source = source
|
||||
@@ -113,10 +114,65 @@ class SnapshotRepository: ObservableObject {
|
||||
}
|
||||
snapshot.notes = notes
|
||||
|
||||
save()
|
||||
// In batch mode (CSV import), skip the per-row save + widget refresh — the
|
||||
// caller saves the whole context once at the end. Saving per row triggered a
|
||||
// main-thread WAL checkpoint each time → ANR on large imports.
|
||||
if !batch {
|
||||
save()
|
||||
}
|
||||
return snapshot
|
||||
}
|
||||
|
||||
// MARK: - Gap Auto-Fill (estimated snapshots)
|
||||
|
||||
/// Creates ESTIMATED snapshots for the missing months of an internal gap,
|
||||
/// linearly interpolated between the source's value at `from` and at `to`.
|
||||
/// They are marked `isEstimated` so they read as clearly distinct from real
|
||||
/// data and can be removed in bulk. Idempotent (skips months that already have
|
||||
/// a snapshot). Returns how many were created.
|
||||
@discardableResult
|
||||
func autoFillGap(source: InvestmentSource, from: Date, to: Date, missingMonthDates: [Date]) -> Int {
|
||||
let snaps = source.snapshotsArray
|
||||
func value(atMonth month: Date) -> Decimal? {
|
||||
snaps.filter { $0.date.startOfMonth == month.startOfMonth }
|
||||
.max(by: { $0.date < $1.date })?.decimalValue
|
||||
}
|
||||
guard let v0 = value(atMonth: from),
|
||||
let v1 = value(atMonth: to),
|
||||
!missingMonthDates.isEmpty else { return 0 }
|
||||
|
||||
let steps = missingMonthDates.count + 1 // segments between v0 and v1
|
||||
var created = 0
|
||||
for (i, month) in missingMonthDates.enumerated() {
|
||||
let monthStart = month.startOfMonth
|
||||
if snaps.contains(where: { $0.date.startOfMonth == monthStart }) { continue }
|
||||
let fraction = Decimal(i + 1) / Decimal(steps)
|
||||
let interpolated = v0 + (v1 - v0) * fraction
|
||||
let snapshot = Snapshot(context: context)
|
||||
snapshot.source = source
|
||||
snapshot.date = monthStart
|
||||
snapshot.value = NSDecimalNumber(decimal: interpolated)
|
||||
snapshot.isEstimated = true
|
||||
snapshot.notes = String(localized: "gaps_estimated_note")
|
||||
created += 1
|
||||
}
|
||||
if created > 0 { save() }
|
||||
return created
|
||||
}
|
||||
|
||||
/// Deletes all estimated (auto-filled) snapshots, optionally scoped to one
|
||||
/// source. Returns how many were removed.
|
||||
@discardableResult
|
||||
func deleteEstimatedSnapshots(for source: InvestmentSource? = nil) -> Int {
|
||||
let request = NSFetchRequest<Snapshot>(entityName: "Snapshot")
|
||||
request.predicate = NSPredicate(format: "isEstimated == YES")
|
||||
guard let estimated = try? context.fetch(request) else { return 0 }
|
||||
let targets = source == nil ? estimated : estimated.filter { $0.source?.id == source?.id }
|
||||
for snapshot in targets { context.delete(snapshot) }
|
||||
if !targets.isEmpty { save() }
|
||||
return targets.count
|
||||
}
|
||||
|
||||
// MARK: - Update
|
||||
|
||||
func updateSnapshot(
|
||||
|
||||
@@ -1,286 +1,350 @@
|
||||
{
|
||||
"sourceLanguage": "en",
|
||||
"strings": {
|
||||
"Add a snapshot in ${applicationName}": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Füge einen Eintrag in ${applicationName} hinzu"
|
||||
"sourceLanguage" : "en",
|
||||
"strings" : {
|
||||
"Add a snapshot in ${applicationName}" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Füge einen Eintrag in ${applicationName} hinzu"
|
||||
}
|
||||
},
|
||||
"es-ES": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Añade un registro en ${applicationName}"
|
||||
"es-ES" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Añade un registro en ${applicationName}"
|
||||
}
|
||||
},
|
||||
"fr": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Ajoute un relevé dans ${applicationName}"
|
||||
"fr" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Ajoute un relevé dans ${applicationName}"
|
||||
}
|
||||
},
|
||||
"it": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Aggiungi una rilevazione in ${applicationName}"
|
||||
"it" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Aggiungi una rilevazione in ${applicationName}"
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "${applicationName}でスナップショットを追加"
|
||||
"ja" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "${applicationName}でスナップショットを追加"
|
||||
}
|
||||
},
|
||||
"pt-BR": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Adicione um registro no ${applicationName}"
|
||||
"pt-BR" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Adicione um registro no ${applicationName}"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Check my portfolio in ${applicationName}": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Zeige mein Portfolio in ${applicationName}"
|
||||
"Check my portfolio in ${applicationName}" : {
|
||||
"extractionState" : "extracted_with_value",
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
"state" : "needs_review",
|
||||
"value" : "Zeige mein Portfolio in ${applicationName}"
|
||||
}
|
||||
},
|
||||
"es-ES": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Consulta mi cartera en ${applicationName}"
|
||||
"en" : {
|
||||
"stringSet" : {
|
||||
"state" : "new",
|
||||
"values" : [
|
||||
"Check my portfolio in ${applicationName}",
|
||||
"Show my net worth in ${applicationName}"
|
||||
]
|
||||
}
|
||||
},
|
||||
"fr": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Consulte mon portefeuille dans ${applicationName}"
|
||||
"es-ES" : {
|
||||
"stringUnit" : {
|
||||
"state" : "needs_review",
|
||||
"value" : "Consulta mi cartera en ${applicationName}"
|
||||
}
|
||||
},
|
||||
"it": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Controlla il mio portafoglio in ${applicationName}"
|
||||
"fr" : {
|
||||
"stringUnit" : {
|
||||
"state" : "needs_review",
|
||||
"value" : "Consulte mon portefeuille dans ${applicationName}"
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "${applicationName}でポートフォリオを確認"
|
||||
"it" : {
|
||||
"stringUnit" : {
|
||||
"state" : "needs_review",
|
||||
"value" : "Controlla il mio portafoglio in ${applicationName}"
|
||||
}
|
||||
},
|
||||
"pt-BR": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Verifique minha carteira no ${applicationName}"
|
||||
"ja" : {
|
||||
"stringUnit" : {
|
||||
"state" : "needs_review",
|
||||
"value" : "${applicationName}でポートフォリオを確認"
|
||||
}
|
||||
},
|
||||
"pt-BR" : {
|
||||
"stringUnit" : {
|
||||
"state" : "needs_review",
|
||||
"value" : "Verifique minha carteira no ${applicationName}"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Log a value in ${applicationName}": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Erfasse einen Wert in ${applicationName}"
|
||||
"Log a value in ${applicationName}" : {
|
||||
"extractionState" : "extracted_with_value",
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
"state" : "needs_review",
|
||||
"value" : "Erfasse einen Wert in ${applicationName}"
|
||||
}
|
||||
},
|
||||
"es-ES": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Registra un valor en ${applicationName}"
|
||||
"en" : {
|
||||
"stringSet" : {
|
||||
"state" : "new",
|
||||
"values" : [
|
||||
"Log a value in ${applicationName}",
|
||||
"Log my ${source} balance in ${applicationName}",
|
||||
"Update ${source} in ${applicationName}"
|
||||
]
|
||||
}
|
||||
},
|
||||
"fr": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Enregistre une valeur dans ${applicationName}"
|
||||
"es-ES" : {
|
||||
"stringUnit" : {
|
||||
"state" : "needs_review",
|
||||
"value" : "Registra un valor en ${applicationName}"
|
||||
}
|
||||
},
|
||||
"it": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Registra un valore in ${applicationName}"
|
||||
"fr" : {
|
||||
"stringUnit" : {
|
||||
"state" : "needs_review",
|
||||
"value" : "Enregistre une valeur dans ${applicationName}"
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "${applicationName}で金額を記録"
|
||||
"it" : {
|
||||
"stringUnit" : {
|
||||
"state" : "needs_review",
|
||||
"value" : "Registra un valore in ${applicationName}"
|
||||
}
|
||||
},
|
||||
"pt-BR": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Registre um valor no ${applicationName}"
|
||||
"ja" : {
|
||||
"stringUnit" : {
|
||||
"state" : "needs_review",
|
||||
"value" : "${applicationName}で金額を記録"
|
||||
}
|
||||
},
|
||||
"pt-BR" : {
|
||||
"stringUnit" : {
|
||||
"state" : "needs_review",
|
||||
"value" : "Registre um valor no ${applicationName}"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Log my ${source} balance in ${applicationName}": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Erfasse den Saldo von ${source} in ${applicationName}"
|
||||
"Log my ${source} balance in ${applicationName}" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Erfasse den Saldo von ${source} in ${applicationName}"
|
||||
}
|
||||
},
|
||||
"es-ES": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Registra el saldo de ${source} en ${applicationName}"
|
||||
"es-ES" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Registra el saldo de ${source} en ${applicationName}"
|
||||
}
|
||||
},
|
||||
"fr": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Enregistre le solde de ${source} dans ${applicationName}"
|
||||
"fr" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Enregistre le solde de ${source} dans ${applicationName}"
|
||||
}
|
||||
},
|
||||
"it": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Registra il saldo di ${source} in ${applicationName}"
|
||||
"it" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Registra il saldo di ${source} in ${applicationName}"
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "${applicationName}で${source}の残高を記録"
|
||||
"ja" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "${applicationName}で${source}の残高を記録"
|
||||
}
|
||||
},
|
||||
"pt-BR": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Registre o saldo de ${source} no ${applicationName}"
|
||||
"pt-BR" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Registre o saldo de ${source} no ${applicationName}"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Show my net worth in ${applicationName}": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Zeige mein Vermögen in ${applicationName}"
|
||||
"Show my net worth in ${applicationName}" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Zeige mein Vermögen in ${applicationName}"
|
||||
}
|
||||
},
|
||||
"es-ES": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Muestra mi patrimonio en ${applicationName}"
|
||||
"es-ES" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Muestra mi patrimonio en ${applicationName}"
|
||||
}
|
||||
},
|
||||
"fr": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Affiche mon patrimoine dans ${applicationName}"
|
||||
"fr" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Affiche mon patrimoine dans ${applicationName}"
|
||||
}
|
||||
},
|
||||
"it": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Mostra il mio patrimonio in ${applicationName}"
|
||||
"it" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Mostra il mio patrimonio in ${applicationName}"
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "${applicationName}で純資産を表示"
|
||||
"ja" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "${applicationName}で純資産を表示"
|
||||
}
|
||||
},
|
||||
"pt-BR": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Mostre meu patrimônio no ${applicationName}"
|
||||
"pt-BR" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Mostre meu patrimônio no ${applicationName}"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Update ${source} in ${applicationName}": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Aktualisiere ${source} in ${applicationName}"
|
||||
"Update ${source} in ${applicationName}" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Aktualisiere ${source} in ${applicationName}"
|
||||
}
|
||||
},
|
||||
"es-ES": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Actualiza ${source} en ${applicationName}"
|
||||
"es-ES" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Actualiza ${source} en ${applicationName}"
|
||||
}
|
||||
},
|
||||
"fr": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Mets à jour ${source} dans ${applicationName}"
|
||||
"fr" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Mets à jour ${source} dans ${applicationName}"
|
||||
}
|
||||
},
|
||||
"it": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Aggiorna ${source} in ${applicationName}"
|
||||
"it" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Aggiorna ${source} in ${applicationName}"
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "${applicationName}で${source}を更新"
|
||||
"ja" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "${applicationName}で${source}を更新"
|
||||
}
|
||||
},
|
||||
"pt-BR": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Atualize ${source} no ${applicationName}"
|
||||
"pt-BR" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Atualize ${source} no ${applicationName}"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Update my portfolio in ${applicationName}": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Aktualisiere mein Portfolio in ${applicationName}"
|
||||
"What's my net worth in ${applicationName}" : {
|
||||
"extractionState" : "extracted_with_value",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringSet" : {
|
||||
"state" : "new",
|
||||
"values" : [
|
||||
"What's my net worth in ${applicationName}",
|
||||
"How much is my portfolio worth in ${applicationName}",
|
||||
"Get my net worth from ${applicationName}"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"What's my streak in ${applicationName}" : {
|
||||
"extractionState" : "extracted_with_value",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringSet" : {
|
||||
"state" : "new",
|
||||
"values" : [
|
||||
"What's my streak in ${applicationName}",
|
||||
"Check my check-in streak in ${applicationName}"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Update my portfolio in ${applicationName}" : {
|
||||
"extractionState" : "extracted_with_value",
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
"state" : "needs_review",
|
||||
"value" : "Aktualisiere mein Portfolio in ${applicationName}"
|
||||
}
|
||||
},
|
||||
"es-ES": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Actualiza mi cartera en ${applicationName}"
|
||||
"en" : {
|
||||
"stringSet" : {
|
||||
"state" : "new",
|
||||
"values" : [
|
||||
"Update my portfolio in ${applicationName}",
|
||||
"Add a snapshot in ${applicationName}"
|
||||
]
|
||||
}
|
||||
},
|
||||
"fr": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Mets à jour mon portefeuille dans ${applicationName}"
|
||||
"es-ES" : {
|
||||
"stringUnit" : {
|
||||
"state" : "needs_review",
|
||||
"value" : "Actualiza mi cartera en ${applicationName}"
|
||||
}
|
||||
},
|
||||
"it": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Aggiorna il mio portafoglio in ${applicationName}"
|
||||
"fr" : {
|
||||
"stringUnit" : {
|
||||
"state" : "needs_review",
|
||||
"value" : "Mets à jour mon portefeuille dans ${applicationName}"
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "${applicationName}でポートフォリオを更新"
|
||||
"it" : {
|
||||
"stringUnit" : {
|
||||
"state" : "needs_review",
|
||||
"value" : "Aggiorna il mio portafoglio in ${applicationName}"
|
||||
}
|
||||
},
|
||||
"pt-BR": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Atualize minha carteira no ${applicationName}"
|
||||
"ja" : {
|
||||
"stringUnit" : {
|
||||
"state" : "needs_review",
|
||||
"value" : "${applicationName}でポートフォリオを更新"
|
||||
}
|
||||
},
|
||||
"pt-BR" : {
|
||||
"stringUnit" : {
|
||||
"state" : "needs_review",
|
||||
"value" : "Atualize minha carteira no ${applicationName}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"version": "1.0"
|
||||
"version" : "1.0"
|
||||
}
|
||||
@@ -353,6 +353,18 @@
|
||||
"onboarding_quickstart_subtitle" = "Füge deine erste Investitionsquelle hinzu, um dein Portfolio zu verfolgen.";
|
||||
"onboarding_quickstart_title" = "Fast fertig";
|
||||
"reengagement_body" = "Ein paar Minuten reichen, um deine Investitionen im Blick zu behalten.";
|
||||
"reengagement_goal_title" = "Du bist auf Kurs 📈";
|
||||
"reengagement_goal_body" = "In diesem Tempo erreichst du \"%@\" bis %@. Sieh, wie nah du bist.";
|
||||
"reengagement_yoy_title" = "Sieh dir dieses Wachstum an 👀";
|
||||
"reengagement_yoy_body" = "Du liegst im letzten Jahr %@ im Plus. Sieh dir den Trend an.";
|
||||
"reengagement_month_title" = "Guter Monat 📈";
|
||||
"reengagement_month_body" = "Dein Portfolio ist diesen Monat um %@ gewachsen. Wirf einen Blick darauf.";
|
||||
"reengagement_milestone_title" = "Fast geschafft 🎯";
|
||||
"reengagement_milestone_body" = "Du bist fast bei %@. Wie nah bist du heute?";
|
||||
"reengagement_streak_title" = "Halte die Serie am Leben 🔥";
|
||||
"reengagement_streak_body" = "Du hast %d Monate in Folge erfasst. Hör jetzt nicht auf.";
|
||||
"reengagement_neutral_title" = "Gibt's was Neues? 👀";
|
||||
"reengagement_neutral_body" = "In deinem Portfolio hat sich etwas getan. Sieh, was sich geändert hat.";
|
||||
"reengagement_title" = "Dein Portfolio wartet";
|
||||
"snapshot_duplicate_add" = "Trotzdem hinzufügen";
|
||||
"snapshot_contribution_propagate_title" = "Beitrag anwenden";
|
||||
@@ -412,6 +424,25 @@
|
||||
"insight_streak_title" = "Tracking-Serie";
|
||||
"insight_streak_value" = "%d Monate in Folge";
|
||||
"insight_forecast_title" = "Auf dem Weg zu";
|
||||
"insight_allocation_title" = "Allokationsabweichung";
|
||||
"insight_allocation_over_value" = "%1$@ liegt bei %2$.0f%% — über deinem Ziel von %3$.0f%%";
|
||||
"insight_allocation_under_value" = "%1$@ liegt bei %2$.0f%% — unter deinem Ziel von %3$.0f%%";
|
||||
"insight_allocation_detail" = "Zum Plan zurück ausbalancieren";
|
||||
"insight_concentration_title" = "Klumpenrisiko";
|
||||
"insight_concentration_value" = "%1$@ macht %2$.0f%% deines Portfolios aus";
|
||||
"insight_concentration_detail" = "Erwäge eine breitere Streuung";
|
||||
"insight_best_performer_title" = "Bester Wert";
|
||||
"insight_worst_performer_title" = "Braucht Aufmerksamkeit";
|
||||
"insight_performer_value" = "%1$@ · %2$+.1f%%";
|
||||
"insight_diversification_title" = "Diversifikation";
|
||||
"insight_diversification_single_value" = "Alles in einer Quelle — erwäge eine weitere";
|
||||
"insight_diversification_good_value" = "Auf %d Kategorien verteilt";
|
||||
"goal_projection_projected" = "Prognose: %@";
|
||||
"goal_projection_add_contributions" = "Beiträge hinzufügen, um es zu erreichen";
|
||||
"goal_chip_ahead" = "Voraus";
|
||||
"goal_chip_on_track" = "Im Plan";
|
||||
"goal_chip_behind" = "Im Rückstand";
|
||||
"goal_chip_at_this_pace" = "In diesem Tempo…";
|
||||
"notification_milestone_title" = "Portfolio-Meilenstein! 🎉";
|
||||
"notification_milestone_body" = "Dein Portfolio hat gerade %@ überschritten. Herzlichen Glückwunsch!";
|
||||
|
||||
@@ -468,3 +499,181 @@
|
||||
"chart_share_scan" = "Zum Laden scannen";
|
||||
"chart_locked_title" = "%@ ist ein Premium-Diagramm";
|
||||
"chart_locked_cta" = "Premium freischalten";
|
||||
|
||||
// Calm 2.0 Home (1.5.0)
|
||||
"checkin_done_title" = "Check-in %@ erledigt";
|
||||
"checkin_done_next" = "Nächster: %@";
|
||||
"checkin_next_due" = "Nächster Check-in: %@";
|
||||
"checkin_days_left" = "noch %d Tage";
|
||||
"checkin_due_today" = "Heute fällig";
|
||||
"checkin_overdue" = "Überfällig";
|
||||
"dash_chart_yoy" = "Jahr für Jahr";
|
||||
"home_chart_locked_sub" = "Mit Premium freischalten, um dieses Diagramm zu sehen.";
|
||||
"checkin_sources_progress" = "%1$d von %2$d Quellen aktualisiert";
|
||||
|
||||
// Guided check-in (1.5.0 Fase 2)
|
||||
"guided_previous_value" = "Letzter Monat";
|
||||
"guided_paste_amount" = "%@ einfügen";
|
||||
"guided_next" = "Weiter";
|
||||
"guided_same_as_last" = "Wie letzten Monat";
|
||||
"guided_skip" = "Überspringen";
|
||||
"guided_reflection_title" = "Wie fühlt sich dieser Monat an?";
|
||||
"guided_reflection_subtitle" = "Eine kurze Notiz für dein zukünftiges Ich.";
|
||||
"guided_note_placeholder" = "Eine ehrliche Zeile zu diesem Monat…";
|
||||
"guided_finish" = "Check-in abschließen";
|
||||
"guided_done_title" = "Check-in %@ erledigt";
|
||||
"guided_streak" = "%d Monate in Folge";
|
||||
"guided_share_month" = "Diesen Monat teilen";
|
||||
"guided_done_cta" = "Fertig";
|
||||
"journal_no_note" = "Noch keine Notiz.";
|
||||
|
||||
// What's New catalog (1.4.1–1.5.0)
|
||||
"whats_new_version_header" = "Version %@";
|
||||
"whats_new_150_redesign_title" = "Ein ruhigeres, klareres Design";
|
||||
"whats_new_150_redesign_body" = "Neu gestaltetes Home mit klarem Fokus, deinem Check-in im Mittelpunkt und weniger Ablenkung.";
|
||||
"whats_new_150_guided_title" = "Geführter Monats-Check-in";
|
||||
"whats_new_150_guided_body" = "Eine Quelle pro Schritt, Live-Deltas, kurze Reflexion — dein 5-Minuten-Ritual, neu gedacht.";
|
||||
"whats_new_150_rings_title" = "Ziel-Ringe & Journal-Timeline";
|
||||
"whats_new_150_rings_body" = "Fortschrittsringe im Fitness-Stil für Ziele und eine Stimmungs-Timeline für deine Investment-Geschichte.";
|
||||
"whats_new_142_ocr_title" = "Update per Screenshot";
|
||||
"whats_new_142_ocr_body" = "Teile einen Screenshot deiner Bank-App — der Saldo wird auf dem Gerät erkannt, nichts verlässt dein iPhone.";
|
||||
"whats_new_142_siri_title" = "Siri auf Deutsch";
|
||||
"whats_new_142_siri_body" = "\"Erfasse den Saldo von Indexa\" — Werte per Sprache oder Kurzbefehle erfassen, ohne die App zu öffnen.";
|
||||
"whats_new_142_charts_title" = "Diagramme, neu gebaut";
|
||||
"whats_new_142_charts_body" = "Native Seitenleiste auf dem iPad, passende KPIs pro Diagramm und Teilen als schönes Bild.";
|
||||
"whats_new_141_extension_title" = "Aktualisieren ohne App-Wechsel";
|
||||
"whats_new_141_extension_body" = "Teile einen Wert aus deiner Bank-App direkt in Portfolio Journal mit der neuen Erweiterung.";
|
||||
"whats_new_141_ipad_title" = "Besser auf dem iPad";
|
||||
"whats_new_141_ipad_body" = "Neu gestaltete Diagramme für den großen Bildschirm, mit Zoom und Wertbeschriftungen.";
|
||||
"whats_new_141_icloud_title" = "Mehr Sync";
|
||||
"whats_new_141_icloud_body" = "Monatliche Beiträge und Allokationsziele werden jetzt über iCloud synchronisiert.";
|
||||
"whats_new_title_format" = "Neu in %@";
|
||||
|
||||
// TipKit (1.5.0 Fase 4)
|
||||
"tip_quick_update_title" = "Alles in Sekunden aktualisieren";
|
||||
"tip_quick_update_body" = "Quick Update zeigt alle Quellen auf einem Bildschirm — Werte aus der Zwischenablage einfügen, fertig.";
|
||||
"tip_ocr_title" = "Oder teile einfach einen Screenshot";
|
||||
"tip_ocr_body" = "Aus deiner Bank-App: Screenshot → Teilen → Portfolio Journal. Der Saldo wird auf dem Gerät gelesen.";
|
||||
"tip_siri_title" = "Probier es nächstes Mal per Sprache";
|
||||
"tip_siri_body" = "Sag Siri \"Erfasse den Saldo meines Kontos in Portfolio Journal\" — ohne die App zu öffnen.";
|
||||
"tip_chart_share_title" = "Teile dieses Diagramm";
|
||||
"tip_chart_share_body" = "Erzeugt ein schönes Bild mit deinen Daten, bereit für X oder WhatsApp.";
|
||||
"allocation_total" = "Gesamt";
|
||||
|
||||
// iCloud error hints (1.5.0)
|
||||
"icloud_hint_schema" = "iCloud hat einige Datensätze abgelehnt. Meist wird das iCloud-Schema noch eingerichtet — löst sich oft innerhalb weniger Stunden oder nach dem nächsten App-Update.";
|
||||
"icloud_hint_quota" = "Dein iCloud-Speicher ist voll. Gib Speicher frei und versuche es erneut.";
|
||||
"icloud_hint_network" = "iCloud nicht erreichbar. Prüfe deine Verbindung und versuche es erneut.";
|
||||
"icloud_hint_partial_generic" = "Einige Datensätze konnten diesmal nicht synchronisiert werden. Die App versucht es automatisch erneut.";
|
||||
"icloud_status_error" = "iCloud-Synchronisierungsproblem";
|
||||
"icloud_status_syncing" = "Synchronisierung mit iCloud";
|
||||
"icloud_show_details" = "Technische Details anzeigen";
|
||||
"yoy_vs_prev" = "vs %@";
|
||||
"yoy_best_year" = "Bestes Jahr";
|
||||
"yoy_worst_year" = "Schlechtestes Jahr";
|
||||
|
||||
// Quick Update paste/scan (1.5.0)
|
||||
"quick_update_paste_value" = "%@ einfügen";
|
||||
"quick_update_scan" = "Scannen";
|
||||
"quick_update_done_kbd" = "Fertig";
|
||||
"quick_update_scanning" = "Bild wird gelesen…";
|
||||
"quick_update_pick_amount" = "Welcher Betrag?";
|
||||
"quick_update_no_amount_found" = "Kein Betrag im Bild erkannt.";
|
||||
"icloud_copy_diagnostics" = "iCloud-Diagnose kopieren";
|
||||
"icloud_diagnostics_copied" = "Diagnose in Zwischenablage kopiert";
|
||||
|
||||
"Smooth Gaps in Charts" = "Lücken in Diagrammen glätten";
|
||||
|
||||
// MARK: - Data Gaps (1.5.0)
|
||||
"gaps_banner_title" = "Für %d Monate fehlen Daten";
|
||||
"gaps_missing_count" = "%d fehlen";
|
||||
"gaps_range_between" = "Zwischen %@ und %@";
|
||||
"Fill in missing data" = "Fehlende Daten ergänzen";
|
||||
"Missing Data" = "Fehlende Daten";
|
||||
"Fill Gap" = "Lücke füllen";
|
||||
"The chart estimates these months. Add real snapshots to keep your history accurate." = "Das Diagramm schätzt diese Monate. Füge echte Werte hinzu, damit dein Verlauf genau bleibt.";
|
||||
"Dismiss" = "Ausblenden";
|
||||
|
||||
// MARK: - Shareable Monthly Summary (1.5.0)
|
||||
"share_summary_streak_badge" = "%d Mon.";
|
||||
"Monthly Summary" = "Monatsübersicht";
|
||||
"Portfolio Value" = "Portfoliowert";
|
||||
"Tracked with" = "Erfasst mit";
|
||||
"Share monthly summary" = "Monatsübersicht teilen";
|
||||
"since last check-in" = "seit dem letzten Check-in";
|
||||
|
||||
// MARK: - Import Preview (1.5.0)
|
||||
"import_preview_nothing" = "Nichts Neues zu importieren. Deine Daten sind bereits aktuell.";
|
||||
"import_preview_sources" = "%d Quellen erstellen";
|
||||
"import_preview_snapshots_new" = "%d Einträge erstellen";
|
||||
"import_preview_snapshots_update" = "%d Einträge aktualisieren";
|
||||
"import_preview_categories" = "%d Kategorien erstellen";
|
||||
"import_preview_goals" = "%d Ziele erstellen";
|
||||
"import_preview_journal_new" = "%d Journaleinträge erstellen";
|
||||
"import_preview_journal_update" = "%d Journaleinträge aktualisieren";
|
||||
"Review Import" = "Import prüfen";
|
||||
"Reviewing import…" = "Import wird geprüft…";
|
||||
"Import" = "Importieren";
|
||||
|
||||
/* 1.6.0 gap auto-fill */
|
||||
"gaps_estimated_note" = "Geschätzt";
|
||||
"gaps_autofill_cta" = "%d Monate automatisch füllen";
|
||||
"Add manually" = "Manuell hinzufügen";
|
||||
"Auto-fill estimates the missing months between two snapshots (marked as estimated). Or add the real values yourself." = "Das automatische Füllen schätzt die fehlenden Monate zwischen zwei Snapshots (als geschätzt markiert). Oder trage die echten Werte selbst ein.";
|
||||
|
||||
/* 1.6.0 auto-backup */
|
||||
"Auto Backup" = "Automatisches Backup";
|
||||
"Off" = "Aus";
|
||||
"Daily" = "Täglich";
|
||||
"Weekly" = "Wöchentlich";
|
||||
"Monthly" = "Monatlich";
|
||||
"Last auto backup" = "Letztes Auto-Backup";
|
||||
|
||||
/* 1.6.0 hide balances */
|
||||
"Hide balances" = "Guthaben ausblenden";
|
||||
"Show balances" = "Guthaben anzeigen";
|
||||
"Balance hidden" = "Guthaben ausgeblendet";
|
||||
"Reveal your balances" = "Guthaben anzeigen";
|
||||
"Privacy" = "Datenschutz";
|
||||
"Hide Balances" = "Guthaben ausblenden";
|
||||
"Require Face ID to Reveal" = "Face ID zum Anzeigen erforderlich";
|
||||
"Hide all monetary amounts behind a blur. Great for using the app in public or taking screenshots. Optionally require Face ID to reveal them." = "Blendet alle Beträge hinter einer Unschärfe aus. Ideal für die App in der Öffentlichkeit oder für Screenshots. Optional Face ID zum Anzeigen erforderlich.";
|
||||
|
||||
/* 1.6.0 Siri intents */
|
||||
"intent_networth_empty" = "Du hast noch keinen Portfoliowert erfasst.";
|
||||
"intent_networth_dialog" = "Dein Vermögen beträgt %@.";
|
||||
"intent_streak" = "Du hast eine Serie von %d Monaten.";
|
||||
"intent_streak_with_date" = "Du hast eine Serie von %d Monaten. Dein letzter Check-in war am %@.";
|
||||
"intent_streak_none" = "Du hast noch keine aktive Serie. Erfasse einen Wert, um zu starten.";
|
||||
|
||||
// Charts workable (1.6.0)
|
||||
"chart_range_button" = "Benutzerdefinierter Zeitraum";
|
||||
"chart_range_from" = "Von";
|
||||
"chart_range_to" = "Bis";
|
||||
"chart_filtered_prefix" = "Gefiltert: %@";
|
||||
"chart_overlay_contributions" = "vs. Einzahlungen";
|
||||
"chart_insight_best_month" = "Bester Monat im Zeitraum: %1$@ (%2$@)";
|
||||
|
||||
// Share cards 2.0 (1.6.0)
|
||||
"share_options_title" = "Teile deinen Fortschritt";
|
||||
"share_percent_only" = "Nur Prozente";
|
||||
"share_format_post" = "Post";
|
||||
"share_format_story" = "Story";
|
||||
"share_card_cta" = "Teilen";
|
||||
|
||||
// Share cards 2.1 — brand header + download CTA (1.6.0)
|
||||
"share_hero_eyebrow" = "Gesamtrendite";
|
||||
"share_metric_yoy" = "Dieses Jahr";
|
||||
"share_metric_since_inception" = "Seit Beginn";
|
||||
"share_cta_headline" = "Verfolge deine — kostenlos";
|
||||
"share_cta_sub" = "Scannen für Portfolio Journal";
|
||||
|
||||
// Share cards 2.2 — as-of date + theme (1.6.0)
|
||||
"share_as_of" = "Stand %@";
|
||||
"share_theme_dark" = "Dunkel";
|
||||
"share_theme_light" = "Hell";
|
||||
|
||||
// Activation: first source → first snapshot (1.6.1)
|
||||
"add_source_value_header" = "Heutiger Wert";
|
||||
"add_source_value_footer" = "Wird als dein erster Check-in gespeichert – dein Fortschritt beginnt heute.";
|
||||
"quick_update_add_first_source" = "Erste Quelle hinzufügen";
|
||||
|
||||
@@ -284,6 +284,18 @@
|
||||
// MARK: - Re-engagement & Monthly Notifications (1.2.1)
|
||||
"reengagement_title" = "Your portfolio is waiting";
|
||||
"reengagement_body" = "A few minutes is all it takes to stay on top of your investments.";
|
||||
"reengagement_goal_title" = "You're on track 📈";
|
||||
"reengagement_goal_body" = "At this pace you'll reach \"%@\" by %@. See how close you are.";
|
||||
"reengagement_yoy_title" = "Look at that growth 👀";
|
||||
"reengagement_yoy_body" = "You're up %@ over the last year. Check the trend.";
|
||||
"reengagement_month_title" = "Nice month 📈";
|
||||
"reengagement_month_body" = "Your portfolio grew %@ this month. Take a look.";
|
||||
"reengagement_milestone_title" = "So close 🎯";
|
||||
"reengagement_milestone_body" = "You're almost at %@. How close are you today?";
|
||||
"reengagement_streak_title" = "Keep the streak alive 🔥";
|
||||
"reengagement_streak_body" = "You've logged %d months in a row. Don't stop now.";
|
||||
"reengagement_neutral_title" = "Anything new? 👀";
|
||||
"reengagement_neutral_body" = "There's been movement in your portfolio. See what changed.";
|
||||
"monthly_checkin_notification_title" = "Time for your monthly update";
|
||||
"monthly_checkin_notification_body" = "Log this month's values and track your portfolio growth.";
|
||||
|
||||
@@ -420,6 +432,25 @@
|
||||
"insight_streak_title" = "Tracking streak";
|
||||
"insight_streak_value" = "%d months in a row";
|
||||
"insight_forecast_title" = "On track for";
|
||||
"insight_allocation_title" = "Allocation drift";
|
||||
"insight_allocation_over_value" = "%1$@ is %2$.0f%% — above your %3$.0f%% target";
|
||||
"insight_allocation_under_value" = "%1$@ is %2$.0f%% — below your %3$.0f%% target";
|
||||
"insight_allocation_detail" = "Rebalance to stay on plan";
|
||||
"insight_concentration_title" = "Concentration risk";
|
||||
"insight_concentration_value" = "%1$@ is %2$.0f%% of your portfolio";
|
||||
"insight_concentration_detail" = "Consider spreading it out";
|
||||
"insight_best_performer_title" = "Best performer";
|
||||
"insight_worst_performer_title" = "Needs attention";
|
||||
"insight_performer_value" = "%1$@ · %2$+.1f%%";
|
||||
"insight_diversification_title" = "Diversification";
|
||||
"insight_diversification_single_value" = "All in one source — consider adding another";
|
||||
"insight_diversification_good_value" = "Spread across %d categories";
|
||||
"goal_projection_projected" = "Projected: %@";
|
||||
"goal_projection_add_contributions" = "Add contributions to reach this";
|
||||
"goal_chip_ahead" = "Ahead of schedule";
|
||||
"goal_chip_on_track" = "On track";
|
||||
"goal_chip_behind" = "Behind";
|
||||
"goal_chip_at_this_pace" = "At this pace…";
|
||||
"notification_milestone_title" = "Portfolio Milestone! 🎉";
|
||||
"notification_milestone_body" = "Your portfolio just passed %@. Congrats!";
|
||||
|
||||
@@ -469,3 +500,181 @@
|
||||
"chart_share_scan" = "Scan to download";
|
||||
"chart_locked_title" = "%@ is a Premium chart";
|
||||
"chart_locked_cta" = "Unlock Premium";
|
||||
|
||||
// Calm 2.0 Home (1.5.0)
|
||||
"checkin_done_title" = "%@ check-in done";
|
||||
"checkin_done_next" = "Next one: %@";
|
||||
"checkin_next_due" = "Next check-in: %@";
|
||||
"checkin_days_left" = "%d days left";
|
||||
"checkin_due_today" = "Due today";
|
||||
"checkin_overdue" = "Overdue";
|
||||
"dash_chart_yoy" = "Year vs Year";
|
||||
"home_chart_locked_sub" = "Unlock with Premium to explore this chart.";
|
||||
"checkin_sources_progress" = "%1$d of %2$d sources updated";
|
||||
|
||||
// Guided check-in (1.5.0 Fase 2)
|
||||
"guided_previous_value" = "Last month";
|
||||
"guided_paste_amount" = "Paste %@";
|
||||
"guided_next" = "Next";
|
||||
"guided_same_as_last" = "Same as last month";
|
||||
"guided_skip" = "Skip";
|
||||
"guided_reflection_title" = "How does this month feel?";
|
||||
"guided_reflection_subtitle" = "A quick note for your future self.";
|
||||
"guided_note_placeholder" = "One honest line about this month…";
|
||||
"guided_finish" = "Finish check-in";
|
||||
"guided_done_title" = "%@ check-in done";
|
||||
"guided_streak" = "%d months in a row";
|
||||
"guided_share_month" = "Share this month";
|
||||
"guided_done_cta" = "Done";
|
||||
"journal_no_note" = "No note yet.";
|
||||
|
||||
// What's New catalog (1.4.1–1.5.0)
|
||||
"whats_new_version_header" = "Version %@";
|
||||
"whats_new_150_redesign_title" = "A calmer, cleaner look";
|
||||
"whats_new_150_redesign_body" = "Redesigned Home with a clear hero, your check-in front and center, and less noise everywhere.";
|
||||
"whats_new_150_guided_title" = "Guided monthly check-in";
|
||||
"whats_new_150_guided_body" = "One source per step, live deltas, a quick reflection — your 5-minute ritual, reimagined.";
|
||||
"whats_new_150_rings_title" = "Goal rings & journal timeline";
|
||||
"whats_new_150_rings_body" = "Fitness-style progress rings for goals and a mood timeline for your investing story.";
|
||||
"whats_new_142_ocr_title" = "Update from a screenshot";
|
||||
"whats_new_142_ocr_body" = "Share a screenshot of your bank app — the balance is recognized on-device, nothing leaves your phone.";
|
||||
"whats_new_142_siri_title" = "Siri in your language";
|
||||
"whats_new_142_siri_body" = "\"Log my Indexa balance\" — record values by voice or from Shortcuts, no app needed.";
|
||||
"whats_new_142_charts_title" = "Charts, rebuilt";
|
||||
"whats_new_142_charts_body" = "Native sidebar on iPad, smart KPIs per chart, and a share button with beautiful branded images.";
|
||||
"whats_new_141_extension_title" = "Update without switching apps";
|
||||
"whats_new_141_extension_body" = "Share a value from your bank app straight into Portfolio Journal with the new share extension.";
|
||||
"whats_new_141_ipad_title" = "Better on iPad";
|
||||
"whats_new_141_ipad_body" = "Redesigned charts for the big screen, with pinch-to-zoom and value labels.";
|
||||
"whats_new_141_icloud_title" = "More in sync";
|
||||
"whats_new_141_icloud_body" = "Monthly contributions and allocation targets now sync across your devices via iCloud.";
|
||||
"whats_new_title_format" = "What's New in %@";
|
||||
|
||||
// TipKit (1.5.0 Fase 4)
|
||||
"tip_quick_update_title" = "Update everything in seconds";
|
||||
"tip_quick_update_body" = "Quick Update lists all your sources in one screen — paste values from your clipboard and go.";
|
||||
"tip_ocr_title" = "Or just share a screenshot";
|
||||
"tip_ocr_body" = "From your bank app: screenshot → Share → Portfolio Journal. The balance is read on-device.";
|
||||
"tip_siri_title" = "Next time, try your voice";
|
||||
"tip_siri_body" = "Say \"Log my account balance in Portfolio Journal\" to Siri — no need to open the app.";
|
||||
"tip_chart_share_title" = "Share this chart";
|
||||
"tip_chart_share_body" = "Renders a beautiful image with your data, ready for X or WhatsApp.";
|
||||
"allocation_total" = "Total";
|
||||
|
||||
// iCloud error hints (1.5.0)
|
||||
"icloud_hint_schema" = "Some records were rejected by iCloud. This usually means the app's iCloud schema hasn't finished setting up — it often resolves within a few hours of first use, or after the next app update.";
|
||||
"icloud_hint_quota" = "Your iCloud storage is full. Free up space in iCloud settings and try again.";
|
||||
"icloud_hint_network" = "Couldn't reach iCloud. Check your connection and try again.";
|
||||
"icloud_hint_partial_generic" = "Some records couldn't sync this time. The app will retry automatically.";
|
||||
"icloud_status_error" = "iCloud sync problem";
|
||||
"icloud_status_syncing" = "Syncing with iCloud";
|
||||
"icloud_show_details" = "Show technical details";
|
||||
"yoy_vs_prev" = "vs %@";
|
||||
"yoy_best_year" = "Best year";
|
||||
"yoy_worst_year" = "Worst year";
|
||||
|
||||
// Quick Update paste/scan (1.5.0)
|
||||
"quick_update_paste_value" = "Paste %@";
|
||||
"quick_update_scan" = "Scan";
|
||||
"quick_update_done_kbd" = "Done";
|
||||
"quick_update_scanning" = "Reading image…";
|
||||
"quick_update_pick_amount" = "Which amount?";
|
||||
"quick_update_no_amount_found" = "No amount recognized in that image.";
|
||||
"icloud_copy_diagnostics" = "Copy iCloud diagnostics";
|
||||
"icloud_diagnostics_copied" = "Diagnostics copied to clipboard";
|
||||
|
||||
"Smooth Gaps in Charts" = "Smooth Gaps in Charts";
|
||||
|
||||
// MARK: - Data Gaps (1.5.0)
|
||||
"gaps_banner_title" = "%d months are missing data";
|
||||
"gaps_missing_count" = "%d missing";
|
||||
"gaps_range_between" = "Between %@ and %@";
|
||||
"Fill in missing data" = "Fill in missing data";
|
||||
"Missing Data" = "Missing Data";
|
||||
"Fill Gap" = "Fill Gap";
|
||||
"The chart estimates these months. Add real snapshots to keep your history accurate." = "The chart estimates these months. Add real snapshots to keep your history accurate.";
|
||||
"Dismiss" = "Dismiss";
|
||||
|
||||
// MARK: - Shareable Monthly Summary (1.5.0)
|
||||
"share_summary_streak_badge" = "%d mo";
|
||||
"Monthly Summary" = "Monthly Summary";
|
||||
"Portfolio Value" = "Portfolio Value";
|
||||
"Tracked with" = "Tracked with";
|
||||
"Share monthly summary" = "Share monthly summary";
|
||||
"since last check-in" = "since last check-in";
|
||||
|
||||
// MARK: - Import Preview (1.5.0)
|
||||
"import_preview_nothing" = "Nothing new to import. Your data is already up to date.";
|
||||
"import_preview_sources" = "Create %d sources";
|
||||
"import_preview_snapshots_new" = "Create %d snapshots";
|
||||
"import_preview_snapshots_update" = "Update %d snapshots";
|
||||
"import_preview_categories" = "Create %d categories";
|
||||
"import_preview_goals" = "Create %d goals";
|
||||
"import_preview_journal_new" = "Create %d journal entries";
|
||||
"import_preview_journal_update" = "Update %d journal entries";
|
||||
"Review Import" = "Review Import";
|
||||
"Reviewing import…" = "Reviewing import…";
|
||||
"Import" = "Import";
|
||||
|
||||
/* 1.6.0 gap auto-fill */
|
||||
"gaps_estimated_note" = "Estimated";
|
||||
"gaps_autofill_cta" = "Auto-fill %d months";
|
||||
"Add manually" = "Add manually";
|
||||
"Auto-fill estimates the missing months between two snapshots (marked as estimated). Or add the real values yourself." = "Auto-fill estimates the missing months between two snapshots (marked as estimated). Or add the real values yourself.";
|
||||
|
||||
/* 1.6.0 auto-backup */
|
||||
"Auto Backup" = "Auto Backup";
|
||||
"Off" = "Off";
|
||||
"Daily" = "Daily";
|
||||
"Weekly" = "Weekly";
|
||||
"Monthly" = "Monthly";
|
||||
"Last auto backup" = "Last auto backup";
|
||||
|
||||
/* 1.6.0 hide balances */
|
||||
"Hide balances" = "Hide balances";
|
||||
"Show balances" = "Show balances";
|
||||
"Balance hidden" = "Balance hidden";
|
||||
"Reveal your balances" = "Reveal your balances";
|
||||
"Privacy" = "Privacy";
|
||||
"Hide Balances" = "Hide Balances";
|
||||
"Require Face ID to Reveal" = "Require Face ID to Reveal";
|
||||
"Hide all monetary amounts behind a blur. Great for using the app in public or taking screenshots. Optionally require Face ID to reveal them." = "Hide all monetary amounts behind a blur. Great for using the app in public or taking screenshots. Optionally require Face ID to reveal them.";
|
||||
|
||||
/* 1.6.0 Siri intents */
|
||||
"intent_networth_empty" = "You don't have any portfolio value recorded yet.";
|
||||
"intent_networth_dialog" = "Your net worth is %@.";
|
||||
"intent_streak" = "You're on a %d-month streak.";
|
||||
"intent_streak_with_date" = "You're on a %d-month streak. Your last check-in was %@.";
|
||||
"intent_streak_none" = "You don't have an active streak yet. Log a value to start one.";
|
||||
|
||||
// Charts workable (1.6.0)
|
||||
"chart_range_button" = "Custom range";
|
||||
"chart_range_from" = "From";
|
||||
"chart_range_to" = "To";
|
||||
"chart_filtered_prefix" = "Filtered: %@";
|
||||
"chart_overlay_contributions" = "vs Contributions";
|
||||
"chart_insight_best_month" = "Best month in range: %1$@ (%2$@)";
|
||||
|
||||
// Share cards 2.0 (1.6.0)
|
||||
"share_options_title" = "Share your progress";
|
||||
"share_percent_only" = "Only percentages";
|
||||
"share_format_post" = "Post";
|
||||
"share_format_story" = "Story";
|
||||
"share_card_cta" = "Share";
|
||||
|
||||
// Share cards 2.1 — brand header + download CTA (1.6.0)
|
||||
"share_hero_eyebrow" = "Total return";
|
||||
"share_metric_yoy" = "This year";
|
||||
"share_metric_since_inception" = "Since inception";
|
||||
"share_cta_headline" = "Track yours — free";
|
||||
"share_cta_sub" = "Scan to get Portfolio Journal";
|
||||
|
||||
// Share cards 2.2 — as-of date + theme (1.6.0)
|
||||
"share_as_of" = "As of %@";
|
||||
"share_theme_dark" = "Dark";
|
||||
"share_theme_light" = "Light";
|
||||
|
||||
// Activation: first source → first snapshot (1.6.1)
|
||||
"add_source_value_header" = "Today's Value";
|
||||
"add_source_value_footer" = "Saved as your first check-in — your progress starts today.";
|
||||
"quick_update_add_first_source" = "Add Your First Source";
|
||||
|
||||
@@ -213,6 +213,18 @@
|
||||
// MARK: - Re-engagement & Monthly Notifications (1.2.1)
|
||||
"reengagement_title" = "Tu portfolio te espera";
|
||||
"reengagement_body" = "Unos minutos son suficientes para mantener el control de tus inversiones.";
|
||||
"reengagement_goal_title" = "Vas por buen camino 📈";
|
||||
"reengagement_goal_body" = "A este ritmo alcanzarás \"%@\" en %@. Mira lo cerca que estás.";
|
||||
"reengagement_yoy_title" = "Menudo crecimiento 👀";
|
||||
"reengagement_yoy_body" = "Llevas un %@ en el último año. Mira la tendencia.";
|
||||
"reengagement_month_title" = "Buen mes 📈";
|
||||
"reengagement_month_body" = "Tu cartera creció %@ este mes. Échale un ojo.";
|
||||
"reengagement_milestone_title" = "Muy cerca 🎯";
|
||||
"reengagement_milestone_body" = "Estás a punto de llegar a %@. ¿Cuánto te falta hoy?";
|
||||
"reengagement_streak_title" = "No pierdas la racha 🔥";
|
||||
"reengagement_streak_body" = "Llevas %d meses seguidos registrando. No pares ahora.";
|
||||
"reengagement_neutral_title" = "¿Algo nuevo? 👀";
|
||||
"reengagement_neutral_body" = "Ha habido movimiento en tu cartera. Mira qué ha cambiado.";
|
||||
"monthly_checkin_notification_title" = "Ya puedes actualizar tu portfolio";
|
||||
"monthly_checkin_notification_body" = "Registra los valores de este mes y sigue la evolución de tu cartera.";
|
||||
|
||||
@@ -364,6 +376,25 @@
|
||||
"insight_streak_title" = "Racha de seguimiento";
|
||||
"insight_streak_value" = "%d meses consecutivos";
|
||||
"insight_forecast_title" = "En camino hacia";
|
||||
"insight_allocation_title" = "Desvío de asignación";
|
||||
"insight_allocation_over_value" = "%1$@ está al %2$.0f%% — por encima de tu objetivo del %3$.0f%%";
|
||||
"insight_allocation_under_value" = "%1$@ está al %2$.0f%% — por debajo de tu objetivo del %3$.0f%%";
|
||||
"insight_allocation_detail" = "Reequilibra para seguir tu plan";
|
||||
"insight_concentration_title" = "Riesgo de concentración";
|
||||
"insight_concentration_value" = "%1$@ es el %2$.0f%% de tu cartera";
|
||||
"insight_concentration_detail" = "Plantéate diversificar";
|
||||
"insight_best_performer_title" = "Mejor rendimiento";
|
||||
"insight_worst_performer_title" = "Requiere atención";
|
||||
"insight_performer_value" = "%1$@ · %2$+.1f%%";
|
||||
"insight_diversification_title" = "Diversificación";
|
||||
"insight_diversification_single_value" = "Todo en una fuente — considera añadir otra";
|
||||
"insight_diversification_good_value" = "Repartido en %d categorías";
|
||||
"goal_projection_projected" = "Previsión: %@";
|
||||
"goal_projection_add_contributions" = "Añade aportaciones para alcanzarlo";
|
||||
"goal_chip_ahead" = "Por delante";
|
||||
"goal_chip_on_track" = "En camino";
|
||||
"goal_chip_behind" = "Con retraso";
|
||||
"goal_chip_at_this_pace" = "A este ritmo…";
|
||||
"notification_milestone_title" = "¡Hito de cartera! 🎉";
|
||||
"notification_milestone_body" = "Tu cartera acaba de superar %@. ¡Enhorabuena!";
|
||||
|
||||
@@ -420,3 +451,181 @@
|
||||
"chart_share_scan" = "Escanea para descargar";
|
||||
"chart_locked_title" = "%@ es una gráfica Premium";
|
||||
"chart_locked_cta" = "Desbloquear Premium";
|
||||
|
||||
// Calm 2.0 Home (1.5.0)
|
||||
"checkin_done_title" = "Check-in de %@ completado";
|
||||
"checkin_done_next" = "Próximo: %@";
|
||||
"checkin_next_due" = "Próximo check-in: %@";
|
||||
"checkin_days_left" = "faltan %d días";
|
||||
"checkin_due_today" = "Hoy";
|
||||
"checkin_overdue" = "Atrasado";
|
||||
"dash_chart_yoy" = "Año vs Año";
|
||||
"home_chart_locked_sub" = "Desbloquéalo con Premium para ver este gráfico.";
|
||||
"checkin_sources_progress" = "%1$d de %2$d fuentes actualizadas";
|
||||
|
||||
// Guided check-in (1.5.0 Fase 2)
|
||||
"guided_previous_value" = "Mes anterior";
|
||||
"guided_paste_amount" = "Pegar %@";
|
||||
"guided_next" = "Siguiente";
|
||||
"guided_same_as_last" = "Igual que el mes pasado";
|
||||
"guided_skip" = "Omitir";
|
||||
"guided_reflection_title" = "¿Cómo sientes este mes?";
|
||||
"guided_reflection_subtitle" = "Una nota rápida para tu yo del futuro.";
|
||||
"guided_note_placeholder" = "Una línea honesta sobre este mes…";
|
||||
"guided_finish" = "Terminar check-in";
|
||||
"guided_done_title" = "Check-in de %@ completado";
|
||||
"guided_streak" = "%d meses seguidos";
|
||||
"guided_share_month" = "Compartir este mes";
|
||||
"guided_done_cta" = "Hecho";
|
||||
"journal_no_note" = "Sin nota todavía.";
|
||||
|
||||
// What's New catalog (1.4.1–1.5.0)
|
||||
"whats_new_version_header" = "Versión %@";
|
||||
"whats_new_150_redesign_title" = "Un look más limpio y tranquilo";
|
||||
"whats_new_150_redesign_body" = "Home rediseñado con tu valor total protagonista, el check-in en el centro y menos ruido en todas partes.";
|
||||
"whats_new_150_guided_title" = "Check-in mensual guiado";
|
||||
"whats_new_150_guided_body" = "Una fuente por paso, deltas en vivo y una reflexión rápida — tu ritual de 5 minutos, reimaginado.";
|
||||
"whats_new_150_rings_title" = "Anillos de metas y timeline";
|
||||
"whats_new_150_rings_body" = "Anillos de progreso estilo Fitness para tus metas y un timeline con tu historia como inversor.";
|
||||
"whats_new_142_ocr_title" = "Actualiza desde un pantallazo";
|
||||
"whats_new_142_ocr_body" = "Comparte una captura de tu banco — el saldo se reconoce en el dispositivo, nada sale de tu iPhone.";
|
||||
"whats_new_142_siri_title" = "Siri en tu idioma";
|
||||
"whats_new_142_siri_body" = "\"Registra el saldo de Indexa\" — apunta valores por voz o desde Atajos, sin abrir la app.";
|
||||
"whats_new_142_charts_title" = "Gráficas renovadas";
|
||||
"whats_new_142_charts_body" = "Sidebar nativa en iPad, KPIs inteligentes por gráfica y botón de compartir con imágenes con estilo.";
|
||||
"whats_new_141_extension_title" = "Actualiza sin cambiar de app";
|
||||
"whats_new_141_extension_body" = "Comparte un valor desde la app de tu banco directo a Portfolio Journal con la nueva extensión.";
|
||||
"whats_new_141_ipad_title" = "Mejor en iPad";
|
||||
"whats_new_141_ipad_body" = "Gráficas rediseñadas para pantalla grande, con zoom y etiquetas de valor.";
|
||||
"whats_new_141_icloud_title" = "Más sincronizado";
|
||||
"whats_new_141_icloud_body" = "Las aportaciones mensuales y los objetivos de asignación ahora se sincronizan vía iCloud.";
|
||||
"whats_new_title_format" = "Novedades de la %@";
|
||||
|
||||
// TipKit (1.5.0 Fase 4)
|
||||
"tip_quick_update_title" = "Actualiza todo en segundos";
|
||||
"tip_quick_update_body" = "Quick Update lista todas tus fuentes en una pantalla — pega valores del portapapeles y listo.";
|
||||
"tip_ocr_title" = "O comparte un pantallazo";
|
||||
"tip_ocr_body" = "Desde tu app del banco: captura → Compartir → Portfolio Journal. El saldo se lee en el dispositivo.";
|
||||
"tip_siri_title" = "La próxima vez, prueba con la voz";
|
||||
"tip_siri_body" = "Dile a Siri \"Registra el saldo de mi cuenta en Portfolio Journal\" — sin abrir la app.";
|
||||
"tip_chart_share_title" = "Comparte esta gráfica";
|
||||
"tip_chart_share_body" = "Genera una imagen bonita con tus datos, lista para X o WhatsApp.";
|
||||
"allocation_total" = "Total";
|
||||
|
||||
// iCloud error hints (1.5.0)
|
||||
"icloud_hint_schema" = "iCloud rechazó algunos registros. Suele significar que el esquema de iCloud aún se está configurando — normalmente se resuelve en unas horas de uso, o tras la próxima actualización de la app.";
|
||||
"icloud_hint_quota" = "Tu almacenamiento de iCloud está lleno. Libera espacio en ajustes de iCloud e inténtalo de nuevo.";
|
||||
"icloud_hint_network" = "No se pudo conectar con iCloud. Revisa tu conexión e inténtalo de nuevo.";
|
||||
"icloud_hint_partial_generic" = "Algunos registros no se sincronizaron esta vez. La app lo reintentará automáticamente.";
|
||||
"icloud_status_error" = "Problema de sincronización con iCloud";
|
||||
"icloud_status_syncing" = "Sincronizando con iCloud";
|
||||
"icloud_show_details" = "Ver detalles técnicos";
|
||||
"yoy_vs_prev" = "vs %@";
|
||||
"yoy_best_year" = "Mejor año";
|
||||
"yoy_worst_year" = "Peor año";
|
||||
|
||||
// Quick Update paste/scan (1.5.0)
|
||||
"quick_update_paste_value" = "Pegar %@";
|
||||
"quick_update_scan" = "Escanear";
|
||||
"quick_update_done_kbd" = "Listo";
|
||||
"quick_update_scanning" = "Leyendo imagen…";
|
||||
"quick_update_pick_amount" = "¿Qué importe?";
|
||||
"quick_update_no_amount_found" = "No se reconoció ningún importe en esa imagen.";
|
||||
"icloud_copy_diagnostics" = "Copiar diagnóstico de iCloud";
|
||||
"icloud_diagnostics_copied" = "Diagnóstico copiado al portapapeles";
|
||||
|
||||
"Smooth Gaps in Charts" = "Suavizar huecos en gráficas";
|
||||
|
||||
// MARK: - Data Gaps (1.5.0)
|
||||
"gaps_banner_title" = "Faltan datos de %d meses";
|
||||
"gaps_missing_count" = "%d sin datos";
|
||||
"gaps_range_between" = "Entre %@ y %@";
|
||||
"Fill in missing data" = "Completa los datos que faltan";
|
||||
"Missing Data" = "Datos faltantes";
|
||||
"Fill Gap" = "Rellenar hueco";
|
||||
"The chart estimates these months. Add real snapshots to keep your history accurate." = "La gráfica estima estos meses. Añade valores reales para mantener tu historial preciso.";
|
||||
"Dismiss" = "Descartar";
|
||||
|
||||
// MARK: - Shareable Monthly Summary (1.5.0)
|
||||
"share_summary_streak_badge" = "%d m";
|
||||
"Monthly Summary" = "Resumen mensual";
|
||||
"Portfolio Value" = "Valor de la cartera";
|
||||
"Tracked with" = "Registrado con";
|
||||
"Share monthly summary" = "Compartir resumen mensual";
|
||||
"since last check-in" = "desde el último chequeo";
|
||||
|
||||
// MARK: - Import Preview (1.5.0)
|
||||
"import_preview_nothing" = "No hay nada nuevo que importar. Tus datos ya están actualizados.";
|
||||
"import_preview_sources" = "Crear %d fuentes";
|
||||
"import_preview_snapshots_new" = "Crear %d registros";
|
||||
"import_preview_snapshots_update" = "Actualizar %d registros";
|
||||
"import_preview_categories" = "Crear %d categorías";
|
||||
"import_preview_goals" = "Crear %d objetivos";
|
||||
"import_preview_journal_new" = "Crear %d entradas del diario";
|
||||
"import_preview_journal_update" = "Actualizar %d entradas del diario";
|
||||
"Review Import" = "Revisar importación";
|
||||
"Reviewing import…" = "Revisando importación…";
|
||||
"Import" = "Importar";
|
||||
|
||||
/* 1.6.0 gap auto-fill */
|
||||
"gaps_estimated_note" = "Estimado";
|
||||
"gaps_autofill_cta" = "Autocompletar %d meses";
|
||||
"Add manually" = "Añadir manualmente";
|
||||
"Auto-fill estimates the missing months between two snapshots (marked as estimated). Or add the real values yourself." = "El autocompletado estima los meses que faltan entre dos snapshots (marcados como estimados). O añade los valores reales tú mismo.";
|
||||
|
||||
/* 1.6.0 auto-backup */
|
||||
"Auto Backup" = "Copia automática";
|
||||
"Off" = "Desactivado";
|
||||
"Daily" = "Diario";
|
||||
"Weekly" = "Semanal";
|
||||
"Monthly" = "Mensual";
|
||||
"Last auto backup" = "Última copia automática";
|
||||
|
||||
/* 1.6.0 hide balances */
|
||||
"Hide balances" = "Ocultar saldos";
|
||||
"Show balances" = "Mostrar saldos";
|
||||
"Balance hidden" = "Saldo oculto";
|
||||
"Reveal your balances" = "Mostrar tus saldos";
|
||||
"Privacy" = "Privacidad";
|
||||
"Hide Balances" = "Ocultar saldos";
|
||||
"Require Face ID to Reveal" = "Requerir Face ID para mostrar";
|
||||
"Hide all monetary amounts behind a blur. Great for using the app in public or taking screenshots. Optionally require Face ID to reveal them." = "Oculta todas las cifras tras un difuminado. Ideal para usar la app en público o hacer capturas. Opcionalmente, requiere Face ID para mostrarlas.";
|
||||
|
||||
/* 1.6.0 Siri intents */
|
||||
"intent_networth_empty" = "Aún no tienes ningún valor de cartera registrado.";
|
||||
"intent_networth_dialog" = "Tu patrimonio es %@.";
|
||||
"intent_streak" = "Llevas una racha de %d meses.";
|
||||
"intent_streak_with_date" = "Llevas una racha de %d meses. Tu último check-in fue el %@.";
|
||||
"intent_streak_none" = "Aún no tienes una racha activa. Registra un valor para empezar una.";
|
||||
|
||||
// Charts workable (1.6.0)
|
||||
"chart_range_button" = "Rango personalizado";
|
||||
"chart_range_from" = "Desde";
|
||||
"chart_range_to" = "Hasta";
|
||||
"chart_filtered_prefix" = "Filtrado: %@";
|
||||
"chart_overlay_contributions" = "vs Aportaciones";
|
||||
"chart_insight_best_month" = "Mejor mes del rango: %1$@ (%2$@)";
|
||||
|
||||
// Share cards 2.0 (1.6.0)
|
||||
"share_options_title" = "Comparte tu progreso";
|
||||
"share_percent_only" = "Solo porcentajes";
|
||||
"share_format_post" = "Post";
|
||||
"share_format_story" = "Historia";
|
||||
"share_card_cta" = "Compartir";
|
||||
|
||||
// Share cards 2.1 — brand header + download CTA (1.6.0)
|
||||
"share_hero_eyebrow" = "Rentabilidad total";
|
||||
"share_metric_yoy" = "Este año";
|
||||
"share_metric_since_inception" = "Desde el inicio";
|
||||
"share_cta_headline" = "Controla la tuya — gratis";
|
||||
"share_cta_sub" = "Escanea para descargar Portfolio Journal";
|
||||
|
||||
// Share cards 2.2 — as-of date + theme (1.6.0)
|
||||
"share_as_of" = "A %@";
|
||||
"share_theme_dark" = "Oscuro";
|
||||
"share_theme_light" = "Claro";
|
||||
|
||||
// Activation: first source → first snapshot (1.6.1)
|
||||
"add_source_value_header" = "Valor de hoy";
|
||||
"add_source_value_footer" = "Se guarda como tu primer check-in: tu progreso empieza hoy.";
|
||||
"quick_update_add_first_source" = "Añade tu primera fuente";
|
||||
|
||||
@@ -353,6 +353,18 @@
|
||||
"onboarding_quickstart_subtitle" = "Ajoutez votre première source d'investissement pour commencer à suivre votre portefeuille.";
|
||||
"onboarding_quickstart_title" = "Presque prêt";
|
||||
"reengagement_body" = "Quelques minutes suffisent pour rester au top de vos investissements.";
|
||||
"reengagement_goal_title" = "Vous êtes sur la bonne voie 📈";
|
||||
"reengagement_goal_body" = "À ce rythme, vous atteindrez \"%@\" d'ici %@. Voyez où vous en êtes.";
|
||||
"reengagement_yoy_title" = "Regardez cette progression 👀";
|
||||
"reengagement_yoy_body" = "Vous êtes en hausse de %@ sur un an. Consultez la tendance.";
|
||||
"reengagement_month_title" = "Beau mois 📈";
|
||||
"reengagement_month_body" = "Votre portefeuille a gagné %@ ce mois-ci. Jetez-y un œil.";
|
||||
"reengagement_milestone_title" = "Si près 🎯";
|
||||
"reengagement_milestone_body" = "Vous approchez de %@. À combien êtes-vous aujourd'hui ?";
|
||||
"reengagement_streak_title" = "Gardez votre série 🔥";
|
||||
"reengagement_streak_body" = "Vous avez enregistré %d mois d'affilée. Ne vous arrêtez pas.";
|
||||
"reengagement_neutral_title" = "Du nouveau ? 👀";
|
||||
"reengagement_neutral_body" = "Il y a eu du mouvement dans votre portefeuille. Voyez ce qui a changé.";
|
||||
"reengagement_title" = "Votre portefeuille vous attend";
|
||||
"snapshot_duplicate_add" = "Ajouter quand même";
|
||||
"snapshot_contribution_propagate_title" = "Appliquer la contribution";
|
||||
@@ -412,6 +424,25 @@
|
||||
"insight_streak_title" = "Série de suivi";
|
||||
"insight_streak_value" = "%d mois consécutifs";
|
||||
"insight_forecast_title" = "En bonne voie pour";
|
||||
"insight_allocation_title" = "Écart d'allocation";
|
||||
"insight_allocation_over_value" = "%1$@ est à %2$.0f%% — au-dessus de votre cible de %3$.0f%%";
|
||||
"insight_allocation_under_value" = "%1$@ est à %2$.0f%% — en dessous de votre cible de %3$.0f%%";
|
||||
"insight_allocation_detail" = "Rééquilibrez pour rester dans le plan";
|
||||
"insight_concentration_title" = "Risque de concentration";
|
||||
"insight_concentration_value" = "%1$@ représente %2$.0f%% de votre portefeuille";
|
||||
"insight_concentration_detail" = "Envisagez de mieux répartir";
|
||||
"insight_best_performer_title" = "Meilleure performance";
|
||||
"insight_worst_performer_title" = "À surveiller";
|
||||
"insight_performer_value" = "%1$@ · %2$+.1f%%";
|
||||
"insight_diversification_title" = "Diversification";
|
||||
"insight_diversification_single_value" = "Tout sur une seule source — pensez à en ajouter";
|
||||
"insight_diversification_good_value" = "Réparti sur %d catégories";
|
||||
"goal_projection_projected" = "Prévu : %@";
|
||||
"goal_projection_add_contributions" = "Ajoutez des versements pour l'atteindre";
|
||||
"goal_chip_ahead" = "En avance";
|
||||
"goal_chip_on_track" = "Dans les temps";
|
||||
"goal_chip_behind" = "En retard";
|
||||
"goal_chip_at_this_pace" = "À ce rythme…";
|
||||
"notification_milestone_title" = "Jalon de portefeuille ! 🎉";
|
||||
"notification_milestone_body" = "Votre portefeuille vient de dépasser %@. Félicitations !";
|
||||
|
||||
@@ -468,3 +499,181 @@
|
||||
"chart_share_scan" = "Scannez pour télécharger";
|
||||
"chart_locked_title" = "%@ est un graphique Premium";
|
||||
"chart_locked_cta" = "Débloquer Premium";
|
||||
|
||||
// Calm 2.0 Home (1.5.0)
|
||||
"checkin_done_title" = "Check-in de %@ terminé";
|
||||
"checkin_done_next" = "Prochain : %@";
|
||||
"checkin_next_due" = "Prochain point : %@";
|
||||
"checkin_days_left" = "%d jours restants";
|
||||
"checkin_due_today" = "Aujourd'hui";
|
||||
"checkin_overdue" = "En retard";
|
||||
"dash_chart_yoy" = "Année vs Année";
|
||||
"home_chart_locked_sub" = "Débloquez avec Premium pour voir ce graphique.";
|
||||
"checkin_sources_progress" = "%1$d sur %2$d sources mises à jour";
|
||||
|
||||
// Guided check-in (1.5.0 Fase 2)
|
||||
"guided_previous_value" = "Mois dernier";
|
||||
"guided_paste_amount" = "Coller %@";
|
||||
"guided_next" = "Suivant";
|
||||
"guided_same_as_last" = "Comme le mois dernier";
|
||||
"guided_skip" = "Passer";
|
||||
"guided_reflection_title" = "Comment se sent ce mois-ci ?";
|
||||
"guided_reflection_subtitle" = "Une note rapide pour votre futur vous.";
|
||||
"guided_note_placeholder" = "Une ligne honnête sur ce mois…";
|
||||
"guided_finish" = "Terminer le check-in";
|
||||
"guided_done_title" = "Check-in de %@ terminé";
|
||||
"guided_streak" = "%d mois d'affilée";
|
||||
"guided_share_month" = "Partager ce mois";
|
||||
"guided_done_cta" = "Terminé";
|
||||
"journal_no_note" = "Pas encore de note.";
|
||||
|
||||
// What's New catalog (1.4.1–1.5.0)
|
||||
"whats_new_version_header" = "Version %@";
|
||||
"whats_new_150_redesign_title" = "Un design plus calme et épuré";
|
||||
"whats_new_150_redesign_body" = "Accueil repensé avec votre valeur totale en vedette, le check-in au centre et moins de bruit partout.";
|
||||
"whats_new_150_guided_title" = "Check-in mensuel guidé";
|
||||
"whats_new_150_guided_body" = "Une source par étape, des deltas en direct, une réflexion rapide — votre rituel de 5 minutes, réinventé.";
|
||||
"whats_new_150_rings_title" = "Anneaux d'objectifs & timeline";
|
||||
"whats_new_150_rings_body" = "Des anneaux de progression style Fitness pour vos objectifs et une timeline de votre parcours d'investisseur.";
|
||||
"whats_new_142_ocr_title" = "Mise à jour par capture d'écran";
|
||||
"whats_new_142_ocr_body" = "Partagez une capture de votre banque — le solde est reconnu sur l'appareil, rien ne quitte votre iPhone.";
|
||||
"whats_new_142_siri_title" = "Siri dans votre langue";
|
||||
"whats_new_142_siri_body" = "« Enregistre le solde de Indexa » — notez des valeurs à la voix ou depuis Raccourcis, sans ouvrir l'app.";
|
||||
"whats_new_142_charts_title" = "Graphiques repensés";
|
||||
"whats_new_142_charts_body" = "Barre latérale native sur iPad, KPIs adaptés à chaque graphique et partage en belles images.";
|
||||
"whats_new_141_extension_title" = "Mettre à jour sans changer d'app";
|
||||
"whats_new_141_extension_body" = "Partagez une valeur depuis votre app bancaire directement vers Portfolio Journal avec la nouvelle extension.";
|
||||
"whats_new_141_ipad_title" = "Meilleur sur iPad";
|
||||
"whats_new_141_ipad_body" = "Graphiques repensés pour le grand écran, avec zoom et étiquettes de valeur.";
|
||||
"whats_new_141_icloud_title" = "Plus synchronisé";
|
||||
"whats_new_141_icloud_body" = "Les contributions mensuelles et les objectifs d'allocation se synchronisent désormais via iCloud.";
|
||||
"whats_new_title_format" = "Nouveautés de la %@";
|
||||
|
||||
// TipKit (1.5.0 Fase 4)
|
||||
"tip_quick_update_title" = "Tout mettre à jour en quelques secondes";
|
||||
"tip_quick_update_body" = "Quick Update liste toutes vos sources sur un écran — collez les valeurs du presse-papiers et c'est fait.";
|
||||
"tip_ocr_title" = "Ou partagez une capture d'écran";
|
||||
"tip_ocr_body" = "Depuis votre app bancaire : capture → Partager → Portfolio Journal. Le solde est lu sur l'appareil.";
|
||||
"tip_siri_title" = "La prochaine fois, essayez la voix";
|
||||
"tip_siri_body" = "Dites à Siri « Enregistre le solde de mon compte dans Portfolio Journal » — sans ouvrir l'app.";
|
||||
"tip_chart_share_title" = "Partagez ce graphique";
|
||||
"tip_chart_share_body" = "Génère une belle image avec vos données, prête pour X ou WhatsApp.";
|
||||
"allocation_total" = "Total";
|
||||
|
||||
// iCloud error hints (1.5.0)
|
||||
"icloud_hint_schema" = "iCloud a refusé certains enregistrements. Cela signifie souvent que le schéma iCloud est encore en cours de configuration — se résout généralement en quelques heures ou après la prochaine mise à jour.";
|
||||
"icloud_hint_quota" = "Votre stockage iCloud est plein. Libérez de l'espace et réessayez.";
|
||||
"icloud_hint_network" = "Impossible de joindre iCloud. Vérifiez votre connexion et réessayez.";
|
||||
"icloud_hint_partial_generic" = "Certains enregistrements n'ont pas pu être synchronisés cette fois. L'app réessaiera automatiquement.";
|
||||
"icloud_status_error" = "Problème de synchronisation iCloud";
|
||||
"icloud_status_syncing" = "Synchronisation avec iCloud";
|
||||
"icloud_show_details" = "Afficher les détails techniques";
|
||||
"yoy_vs_prev" = "vs %@";
|
||||
"yoy_best_year" = "Meilleure année";
|
||||
"yoy_worst_year" = "Pire année";
|
||||
|
||||
// Quick Update paste/scan (1.5.0)
|
||||
"quick_update_paste_value" = "Coller %@";
|
||||
"quick_update_scan" = "Scanner";
|
||||
"quick_update_done_kbd" = "OK";
|
||||
"quick_update_scanning" = "Lecture de l’image…";
|
||||
"quick_update_pick_amount" = "Quel montant ?";
|
||||
"quick_update_no_amount_found" = "Aucun montant reconnu dans l’image.";
|
||||
"icloud_copy_diagnostics" = "Copier le diagnostic iCloud";
|
||||
"icloud_diagnostics_copied" = "Diagnostic copié dans le presse-papiers";
|
||||
|
||||
"Smooth Gaps in Charts" = "Lisser les écarts dans les graphiques";
|
||||
|
||||
// MARK: - Data Gaps (1.5.0)
|
||||
"gaps_banner_title" = "Données manquantes pour %d mois";
|
||||
"gaps_missing_count" = "%d manquants";
|
||||
"gaps_range_between" = "Entre %@ et %@";
|
||||
"Fill in missing data" = "Compléter les données manquantes";
|
||||
"Missing Data" = "Données manquantes";
|
||||
"Fill Gap" = "Combler le vide";
|
||||
"The chart estimates these months. Add real snapshots to keep your history accurate." = "Le graphique estime ces mois. Ajoutez des valeurs réelles pour garder un historique précis.";
|
||||
"Dismiss" = "Ignorer";
|
||||
|
||||
// MARK: - Shareable Monthly Summary (1.5.0)
|
||||
"share_summary_streak_badge" = "%d mois";
|
||||
"Monthly Summary" = "Résumé mensuel";
|
||||
"Portfolio Value" = "Valeur du portefeuille";
|
||||
"Tracked with" = "Suivi avec";
|
||||
"Share monthly summary" = "Partager le résumé mensuel";
|
||||
"since last check-in" = "depuis le dernier point";
|
||||
|
||||
// MARK: - Import Preview (1.5.0)
|
||||
"import_preview_nothing" = "Rien de nouveau à importer. Vos données sont déjà à jour.";
|
||||
"import_preview_sources" = "Créer %d sources";
|
||||
"import_preview_snapshots_new" = "Créer %d relevés";
|
||||
"import_preview_snapshots_update" = "Mettre à jour %d relevés";
|
||||
"import_preview_categories" = "Créer %d catégories";
|
||||
"import_preview_goals" = "Créer %d objectifs";
|
||||
"import_preview_journal_new" = "Créer %d entrées de journal";
|
||||
"import_preview_journal_update" = "Mettre à jour %d entrées de journal";
|
||||
"Review Import" = "Vérifier l’import";
|
||||
"Reviewing import…" = "Vérification de l’import…";
|
||||
"Import" = "Importer";
|
||||
|
||||
/* 1.6.0 gap auto-fill */
|
||||
"gaps_estimated_note" = "Estimé";
|
||||
"gaps_autofill_cta" = "Remplir %d mois";
|
||||
"Add manually" = "Ajouter manuellement";
|
||||
"Auto-fill estimates the missing months between two snapshots (marked as estimated). Or add the real values yourself." = "Le remplissage auto estime les mois manquants entre deux relevés (marqués comme estimés). Ou saisis les vraies valeurs toi-même.";
|
||||
|
||||
/* 1.6.0 auto-backup */
|
||||
"Auto Backup" = "Sauvegarde auto";
|
||||
"Off" = "Désactivé";
|
||||
"Daily" = "Quotidien";
|
||||
"Weekly" = "Hebdomadaire";
|
||||
"Monthly" = "Mensuel";
|
||||
"Last auto backup" = "Dernière sauvegarde auto";
|
||||
|
||||
/* 1.6.0 hide balances */
|
||||
"Hide balances" = "Masquer les soldes";
|
||||
"Show balances" = "Afficher les soldes";
|
||||
"Balance hidden" = "Solde masqué";
|
||||
"Reveal your balances" = "Afficher vos soldes";
|
||||
"Privacy" = "Confidentialité";
|
||||
"Hide Balances" = "Masquer les soldes";
|
||||
"Require Face ID to Reveal" = "Exiger Face ID pour afficher";
|
||||
"Hide all monetary amounts behind a blur. Great for using the app in public or taking screenshots. Optionally require Face ID to reveal them." = "Masque tous les montants derrière un flou. Idéal pour utiliser l'app en public ou faire des captures. Exigez Face ID pour les afficher, en option.";
|
||||
|
||||
/* 1.6.0 Siri intents */
|
||||
"intent_networth_empty" = "Tu n'as encore enregistré aucune valeur de portefeuille.";
|
||||
"intent_networth_dialog" = "Ton patrimoine est de %@.";
|
||||
"intent_streak" = "Tu es sur une série de %d mois.";
|
||||
"intent_streak_with_date" = "Tu es sur une série de %d mois. Ton dernier relevé date du %@.";
|
||||
"intent_streak_none" = "Tu n'as pas encore de série active. Enregistre une valeur pour en commencer une.";
|
||||
|
||||
// Charts workable (1.6.0)
|
||||
"chart_range_button" = "Période personnalisée";
|
||||
"chart_range_from" = "De";
|
||||
"chart_range_to" = "À";
|
||||
"chart_filtered_prefix" = "Filtré : %@";
|
||||
"chart_overlay_contributions" = "vs Versements";
|
||||
"chart_insight_best_month" = "Meilleur mois de la période : %1$@ (%2$@)";
|
||||
|
||||
// Share cards 2.0 (1.6.0)
|
||||
"share_options_title" = "Partage tes progrès";
|
||||
"share_percent_only" = "Pourcentages uniquement";
|
||||
"share_format_post" = "Post";
|
||||
"share_format_story" = "Story";
|
||||
"share_card_cta" = "Partager";
|
||||
|
||||
// Share cards 2.1 — brand header + download CTA (1.6.0)
|
||||
"share_hero_eyebrow" = "Rendement total";
|
||||
"share_metric_yoy" = "Cette année";
|
||||
"share_metric_since_inception" = "Depuis le début";
|
||||
"share_cta_headline" = "Suis le tien — gratuit";
|
||||
"share_cta_sub" = "Scanne pour Portfolio Journal";
|
||||
|
||||
// Share cards 2.2 — as-of date + theme (1.6.0)
|
||||
"share_as_of" = "Au %@";
|
||||
"share_theme_dark" = "Sombre";
|
||||
"share_theme_light" = "Clair";
|
||||
|
||||
// Activation: first source → first snapshot (1.6.1)
|
||||
"add_source_value_header" = "Valeur du jour";
|
||||
"add_source_value_footer" = "Enregistrée comme votre premier check-in : votre progression commence aujourd'hui.";
|
||||
"quick_update_add_first_source" = "Ajouter votre première source";
|
||||
|
||||
@@ -353,6 +353,18 @@
|
||||
"onboarding_quickstart_subtitle" = "Aggiungi la tua prima fonte di investimento per iniziare a monitorare il portafoglio.";
|
||||
"onboarding_quickstart_title" = "Quasi pronto";
|
||||
"reengagement_body" = "Pochi minuti bastano per tenere sotto controllo i tuoi investimenti.";
|
||||
"reengagement_goal_title" = "Sei sulla buona strada 📈";
|
||||
"reengagement_goal_body" = "A questo ritmo raggiungerai \"%@\" entro %@. Guarda quanto manca.";
|
||||
"reengagement_yoy_title" = "Guarda che crescita 👀";
|
||||
"reengagement_yoy_body" = "Sei in rialzo del %@ nell'ultimo anno. Controlla l'andamento.";
|
||||
"reengagement_month_title" = "Bel mese 📈";
|
||||
"reengagement_month_body" = "Il tuo portafoglio è cresciuto di %@ questo mese. Dai un'occhiata.";
|
||||
"reengagement_milestone_title" = "Ci sei quasi 🎯";
|
||||
"reengagement_milestone_body" = "Sei quasi a %@. Quanto manca oggi?";
|
||||
"reengagement_streak_title" = "Non perdere la serie 🔥";
|
||||
"reengagement_streak_body" = "Hai registrato %d mesi di fila. Non fermarti ora.";
|
||||
"reengagement_neutral_title" = "Novità? 👀";
|
||||
"reengagement_neutral_body" = "C'è stato movimento nel tuo portafoglio. Guarda cosa è cambiato.";
|
||||
"reengagement_title" = "Il tuo portafoglio ti aspetta";
|
||||
"snapshot_duplicate_add" = "Aggiungi comunque";
|
||||
"snapshot_contribution_propagate_title" = "Applica contributo";
|
||||
@@ -412,6 +424,25 @@
|
||||
"insight_streak_title" = "Serie di aggiornamenti";
|
||||
"insight_streak_value" = "%d mesi consecutivi";
|
||||
"insight_forecast_title" = "In direzione di";
|
||||
"insight_allocation_title" = "Scostamento allocazione";
|
||||
"insight_allocation_over_value" = "%1$@ è al %2$.0f%% — sopra il tuo obiettivo del %3$.0f%%";
|
||||
"insight_allocation_under_value" = "%1$@ è al %2$.0f%% — sotto il tuo obiettivo del %3$.0f%%";
|
||||
"insight_allocation_detail" = "Riequilibra per restare nel piano";
|
||||
"insight_concentration_title" = "Rischio di concentrazione";
|
||||
"insight_concentration_value" = "%1$@ è il %2$.0f%% del tuo portafoglio";
|
||||
"insight_concentration_detail" = "Valuta di diversificare";
|
||||
"insight_best_performer_title" = "Miglior rendimento";
|
||||
"insight_worst_performer_title" = "Richiede attenzione";
|
||||
"insight_performer_value" = "%1$@ · %2$+.1f%%";
|
||||
"insight_diversification_title" = "Diversificazione";
|
||||
"insight_diversification_single_value" = "Tutto in un'unica fonte — valuta di aggiungerne un'altra";
|
||||
"insight_diversification_good_value" = "Distribuito su %d categorie";
|
||||
"goal_projection_projected" = "Previsione: %@";
|
||||
"goal_projection_add_contributions" = "Aggiungi versamenti per raggiungerlo";
|
||||
"goal_chip_ahead" = "In anticipo";
|
||||
"goal_chip_on_track" = "In linea";
|
||||
"goal_chip_behind" = "In ritardo";
|
||||
"goal_chip_at_this_pace" = "A questo ritmo…";
|
||||
"notification_milestone_title" = "Traguardo raggiunto! 🎉";
|
||||
"notification_milestone_body" = "Il tuo portafoglio ha appena superato %@. Complimenti!";
|
||||
|
||||
@@ -468,3 +499,181 @@
|
||||
"chart_share_scan" = "Scansiona per scaricare";
|
||||
"chart_locked_title" = "%@ è un grafico Premium";
|
||||
"chart_locked_cta" = "Sblocca Premium";
|
||||
|
||||
// Calm 2.0 Home (1.5.0)
|
||||
"checkin_done_title" = "Check-in di %@ completato";
|
||||
"checkin_done_next" = "Prossimo: %@";
|
||||
"checkin_next_due" = "Prossimo check-in: %@";
|
||||
"checkin_days_left" = "%d giorni rimasti";
|
||||
"checkin_due_today" = "Oggi";
|
||||
"checkin_overdue" = "In ritardo";
|
||||
"dash_chart_yoy" = "Anno su anno";
|
||||
"home_chart_locked_sub" = "Sblocca con Premium per vedere questo grafico.";
|
||||
"checkin_sources_progress" = "%1$d di %2$d fonti aggiornate";
|
||||
|
||||
// Guided check-in (1.5.0 Fase 2)
|
||||
"guided_previous_value" = "Mese scorso";
|
||||
"guided_paste_amount" = "Incolla %@";
|
||||
"guided_next" = "Avanti";
|
||||
"guided_same_as_last" = "Come il mese scorso";
|
||||
"guided_skip" = "Salta";
|
||||
"guided_reflection_title" = "Come senti questo mese?";
|
||||
"guided_reflection_subtitle" = "Una nota veloce per il te del futuro.";
|
||||
"guided_note_placeholder" = "Una riga sincera su questo mese…";
|
||||
"guided_finish" = "Concludi il check-in";
|
||||
"guided_done_title" = "Check-in di %@ completato";
|
||||
"guided_streak" = "%d mesi di fila";
|
||||
"guided_share_month" = "Condividi questo mese";
|
||||
"guided_done_cta" = "Fatto";
|
||||
"journal_no_note" = "Ancora nessuna nota.";
|
||||
|
||||
// What's New catalog (1.4.1–1.5.0)
|
||||
"whats_new_version_header" = "Versione %@";
|
||||
"whats_new_150_redesign_title" = "Un look più calmo e pulito";
|
||||
"whats_new_150_redesign_body" = "Home ridisegnata con il tuo valore totale in primo piano, il check-in al centro e meno rumore ovunque.";
|
||||
"whats_new_150_guided_title" = "Check-in mensile guidato";
|
||||
"whats_new_150_guided_body" = "Una fonte per passo, delta in tempo reale, una riflessione veloce — il tuo rituale di 5 minuti, ripensato.";
|
||||
"whats_new_150_rings_title" = "Anelli obiettivi & timeline";
|
||||
"whats_new_150_rings_body" = "Anelli di progresso stile Fitness per gli obiettivi e una timeline della tua storia da investitore.";
|
||||
"whats_new_142_ocr_title" = "Aggiorna da uno screenshot";
|
||||
"whats_new_142_ocr_body" = "Condividi uno screenshot della tua banca — il saldo viene riconosciuto sul dispositivo, nulla lascia il tuo iPhone.";
|
||||
"whats_new_142_siri_title" = "Siri nella tua lingua";
|
||||
"whats_new_142_siri_body" = "\"Registra il saldo di Indexa\" — registra valori con la voce o dai Comandi rapidi, senza aprire l'app.";
|
||||
"whats_new_142_charts_title" = "Grafici rinnovati";
|
||||
"whats_new_142_charts_body" = "Barra laterale nativa su iPad, KPI su misura per ogni grafico e condivisione come belle immagini.";
|
||||
"whats_new_141_extension_title" = "Aggiorna senza cambiare app";
|
||||
"whats_new_141_extension_body" = "Condividi un valore dall'app della tua banca direttamente in Portfolio Journal con la nuova estensione.";
|
||||
"whats_new_141_ipad_title" = "Meglio su iPad";
|
||||
"whats_new_141_ipad_body" = "Grafici ridisegnati per lo schermo grande, con zoom ed etichette dei valori.";
|
||||
"whats_new_141_icloud_title" = "Più sincronizzato";
|
||||
"whats_new_141_icloud_body" = "I contributi mensili e gli obiettivi di allocazione ora si sincronizzano via iCloud.";
|
||||
"whats_new_title_format" = "Novità della %@";
|
||||
|
||||
// TipKit (1.5.0 Fase 4)
|
||||
"tip_quick_update_title" = "Aggiorna tutto in pochi secondi";
|
||||
"tip_quick_update_body" = "Quick Update elenca tutte le tue fonti in una schermata — incolla i valori dagli appunti e via.";
|
||||
"tip_ocr_title" = "Oppure condividi uno screenshot";
|
||||
"tip_ocr_body" = "Dall'app della tua banca: screenshot → Condividi → Portfolio Journal. Il saldo viene letto sul dispositivo.";
|
||||
"tip_siri_title" = "La prossima volta prova con la voce";
|
||||
"tip_siri_body" = "Di' a Siri \"Registra il saldo del mio conto in Portfolio Journal\" — senza aprire l'app.";
|
||||
"tip_chart_share_title" = "Condividi questo grafico";
|
||||
"tip_chart_share_body" = "Genera una bella immagine con i tuoi dati, pronta per X o WhatsApp.";
|
||||
"allocation_total" = "Totale";
|
||||
|
||||
// iCloud error hints (1.5.0)
|
||||
"icloud_hint_schema" = "iCloud ha rifiutato alcuni record. Di solito significa che lo schema iCloud è ancora in configurazione — spesso si risolve in poche ore o dopo il prossimo aggiornamento.";
|
||||
"icloud_hint_quota" = "Il tuo spazio iCloud è pieno. Libera spazio e riprova.";
|
||||
"icloud_hint_network" = "Impossibile raggiungere iCloud. Controlla la connessione e riprova.";
|
||||
"icloud_hint_partial_generic" = "Alcuni record non si sono sincronizzati stavolta. L'app riproverà automaticamente.";
|
||||
"icloud_status_error" = "Problema di sincronizzazione iCloud";
|
||||
"icloud_status_syncing" = "Sincronizzazione con iCloud";
|
||||
"icloud_show_details" = "Mostra dettagli tecnici";
|
||||
"yoy_vs_prev" = "vs %@";
|
||||
"yoy_best_year" = "Anno migliore";
|
||||
"yoy_worst_year" = "Anno peggiore";
|
||||
|
||||
// Quick Update paste/scan (1.5.0)
|
||||
"quick_update_paste_value" = "Incolla %@";
|
||||
"quick_update_scan" = "Scansiona";
|
||||
"quick_update_done_kbd" = "Fine";
|
||||
"quick_update_scanning" = "Lettura immagine…";
|
||||
"quick_update_pick_amount" = "Quale importo?";
|
||||
"quick_update_no_amount_found" = "Nessun importo riconosciuto nell’immagine.";
|
||||
"icloud_copy_diagnostics" = "Copia diagnostica iCloud";
|
||||
"icloud_diagnostics_copied" = "Diagnostica copiata negli appunti";
|
||||
|
||||
"Smooth Gaps in Charts" = "Uniforma i vuoti nei grafici";
|
||||
|
||||
// MARK: - Data Gaps (1.5.0)
|
||||
"gaps_banner_title" = "Mancano i dati di %d mesi";
|
||||
"gaps_missing_count" = "%d mancanti";
|
||||
"gaps_range_between" = "Tra %@ e %@";
|
||||
"Fill in missing data" = "Completa i dati mancanti";
|
||||
"Missing Data" = "Dati mancanti";
|
||||
"Fill Gap" = "Colma il vuoto";
|
||||
"The chart estimates these months. Add real snapshots to keep your history accurate." = "Il grafico stima questi mesi. Aggiungi valori reali per mantenere accurata la cronologia.";
|
||||
"Dismiss" = "Ignora";
|
||||
|
||||
// MARK: - Shareable Monthly Summary (1.5.0)
|
||||
"share_summary_streak_badge" = "%d mesi";
|
||||
"Monthly Summary" = "Riepilogo mensile";
|
||||
"Portfolio Value" = "Valore del portafoglio";
|
||||
"Tracked with" = "Registrato con";
|
||||
"Share monthly summary" = "Condividi il riepilogo mensile";
|
||||
"since last check-in" = "dall’ultimo check-in";
|
||||
|
||||
// MARK: - Import Preview (1.5.0)
|
||||
"import_preview_nothing" = "Niente di nuovo da importare. I tuoi dati sono già aggiornati.";
|
||||
"import_preview_sources" = "Crea %d fonti";
|
||||
"import_preview_snapshots_new" = "Crea %d rilevazioni";
|
||||
"import_preview_snapshots_update" = "Aggiorna %d rilevazioni";
|
||||
"import_preview_categories" = "Crea %d categorie";
|
||||
"import_preview_goals" = "Crea %d obiettivi";
|
||||
"import_preview_journal_new" = "Crea %d voci del diario";
|
||||
"import_preview_journal_update" = "Aggiorna %d voci del diario";
|
||||
"Review Import" = "Rivedi importazione";
|
||||
"Reviewing import…" = "Revisione importazione…";
|
||||
"Import" = "Importa";
|
||||
|
||||
/* 1.6.0 gap auto-fill */
|
||||
"gaps_estimated_note" = "Stimato";
|
||||
"gaps_autofill_cta" = "Riempi %d mesi";
|
||||
"Add manually" = "Aggiungi manualmente";
|
||||
"Auto-fill estimates the missing months between two snapshots (marked as estimated). Or add the real values yourself." = "Il riempimento automatico stima i mesi mancanti tra due snapshot (contrassegnati come stimati). Oppure inserisci tu i valori reali.";
|
||||
|
||||
/* 1.6.0 auto-backup */
|
||||
"Auto Backup" = "Backup automatico";
|
||||
"Off" = "Disattivato";
|
||||
"Daily" = "Giornaliero";
|
||||
"Weekly" = "Settimanale";
|
||||
"Monthly" = "Mensile";
|
||||
"Last auto backup" = "Ultimo backup automatico";
|
||||
|
||||
/* 1.6.0 hide balances */
|
||||
"Hide balances" = "Nascondi saldi";
|
||||
"Show balances" = "Mostra saldi";
|
||||
"Balance hidden" = "Saldo nascosto";
|
||||
"Reveal your balances" = "Mostra i tuoi saldi";
|
||||
"Privacy" = "Privacy";
|
||||
"Hide Balances" = "Nascondi saldi";
|
||||
"Require Face ID to Reveal" = "Richiedi Face ID per mostrare";
|
||||
"Hide all monetary amounts behind a blur. Great for using the app in public or taking screenshots. Optionally require Face ID to reveal them." = "Nasconde tutti gli importi dietro una sfocatura. Ideale per usare l'app in pubblico o fare screenshot. Facoltativamente richiedi Face ID per mostrarli.";
|
||||
|
||||
/* 1.6.0 Siri intents */
|
||||
"intent_networth_empty" = "Non hai ancora registrato alcun valore di portafoglio.";
|
||||
"intent_networth_dialog" = "Il tuo patrimonio è %@.";
|
||||
"intent_streak" = "Sei a una serie di %d mesi.";
|
||||
"intent_streak_with_date" = "Sei a una serie di %d mesi. Il tuo ultimo check-in è stato il %@.";
|
||||
"intent_streak_none" = "Non hai ancora una serie attiva. Registra un valore per iniziarne una.";
|
||||
|
||||
// Charts workable (1.6.0)
|
||||
"chart_range_button" = "Intervallo personalizzato";
|
||||
"chart_range_from" = "Da";
|
||||
"chart_range_to" = "A";
|
||||
"chart_filtered_prefix" = "Filtrato: %@";
|
||||
"chart_overlay_contributions" = "vs Versamenti";
|
||||
"chart_insight_best_month" = "Miglior mese del periodo: %1$@ (%2$@)";
|
||||
|
||||
// Share cards 2.0 (1.6.0)
|
||||
"share_options_title" = "Condividi i tuoi progressi";
|
||||
"share_percent_only" = "Solo percentuali";
|
||||
"share_format_post" = "Post";
|
||||
"share_format_story" = "Storia";
|
||||
"share_card_cta" = "Condividi";
|
||||
|
||||
// Share cards 2.1 — brand header + download CTA (1.6.0)
|
||||
"share_hero_eyebrow" = "Rendimento totale";
|
||||
"share_metric_yoy" = "Quest'anno";
|
||||
"share_metric_since_inception" = "Dall'inizio";
|
||||
"share_cta_headline" = "Monitora il tuo — gratis";
|
||||
"share_cta_sub" = "Scansiona per Portfolio Journal";
|
||||
|
||||
// Share cards 2.2 — as-of date + theme (1.6.0)
|
||||
"share_as_of" = "Al %@";
|
||||
"share_theme_dark" = "Scuro";
|
||||
"share_theme_light" = "Chiaro";
|
||||
|
||||
// Activation: first source → first snapshot (1.6.1)
|
||||
"add_source_value_header" = "Valore di oggi";
|
||||
"add_source_value_footer" = "Salvato come primo check-in: i tuoi progressi iniziano oggi.";
|
||||
"quick_update_add_first_source" = "Aggiungi la tua prima fonte";
|
||||
|
||||
@@ -353,6 +353,18 @@
|
||||
"onboarding_quickstart_subtitle" = "最初の投資ソースを追加して、ポートフォリオの追跡を始めましょう。";
|
||||
"onboarding_quickstart_title" = "もうすぐ完了";
|
||||
"reengagement_body" = "数分で投資の状況を把握できます。";
|
||||
"reengagement_goal_title" = "順調です 📈";
|
||||
"reengagement_goal_body" = "このペースなら%2$@までに「%1$@」に到達します。あとどれくらいか見てみましょう。";
|
||||
"reengagement_yoy_title" = "この成長を見て 👀";
|
||||
"reengagement_yoy_body" = "この1年で%@上昇しています。トレンドをチェックしましょう。";
|
||||
"reengagement_month_title" = "好調な月 📈";
|
||||
"reengagement_month_body" = "今月、ポートフォリオが%@増えました。ちょっと見てみましょう。";
|
||||
"reengagement_milestone_title" = "あと少し 🎯";
|
||||
"reengagement_milestone_body" = "もうすぐ%@です。今日はどこまで来たでしょう?";
|
||||
"reengagement_streak_title" = "連続記録を継続 🔥";
|
||||
"reengagement_streak_body" = "%dか月連続で記録しています。ここでやめないで。";
|
||||
"reengagement_neutral_title" = "何か変化は? 👀";
|
||||
"reengagement_neutral_body" = "ポートフォリオに動きがありました。何が変わったか見てみましょう。";
|
||||
"reengagement_title" = "ポートフォリオが待っています";
|
||||
"snapshot_duplicate_add" = "とにかく追加";
|
||||
"snapshot_contribution_propagate_title" = "拠出を適用";
|
||||
@@ -412,6 +424,25 @@
|
||||
"insight_streak_title" = "追跡連続記録";
|
||||
"insight_streak_value" = "%dヶ月連続";
|
||||
"insight_forecast_title" = "目標に向けて順調";
|
||||
"insight_allocation_title" = "配分のずれ";
|
||||
"insight_allocation_over_value" = "%1$@は%2$.0f%% — 目標の%3$.0f%%を上回っています";
|
||||
"insight_allocation_under_value" = "%1$@は%2$.0f%% — 目標の%3$.0f%%を下回っています";
|
||||
"insight_allocation_detail" = "計画に合わせてリバランスしましょう";
|
||||
"insight_concentration_title" = "集中リスク";
|
||||
"insight_concentration_value" = "%1$@がポートフォリオの%2$.0f%%を占めています";
|
||||
"insight_concentration_detail" = "分散を検討しましょう";
|
||||
"insight_best_performer_title" = "最も好調";
|
||||
"insight_worst_performer_title" = "要注意";
|
||||
"insight_performer_value" = "%1$@ · %2$+.1f%%";
|
||||
"insight_diversification_title" = "分散";
|
||||
"insight_diversification_single_value" = "1つの資産に集中 — 追加を検討しましょう";
|
||||
"insight_diversification_good_value" = "%d個のカテゴリに分散";
|
||||
"goal_projection_projected" = "予測: %@";
|
||||
"goal_projection_add_contributions" = "積立を追加して達成しましょう";
|
||||
"goal_chip_ahead" = "前倒し";
|
||||
"goal_chip_on_track" = "順調";
|
||||
"goal_chip_behind" = "遅れ気味";
|
||||
"goal_chip_at_this_pace" = "このペースだと…";
|
||||
"notification_milestone_title" = "ポートフォリオ達成!🎉";
|
||||
"notification_milestone_body" = "ポートフォリオが%@を突破しました。おめでとうございます!";
|
||||
|
||||
@@ -468,3 +499,181 @@
|
||||
"chart_share_scan" = "スキャンしてダウンロード";
|
||||
"chart_locked_title" = "%@はプレミアムチャートです";
|
||||
"chart_locked_cta" = "プレミアムを解除";
|
||||
|
||||
// Calm 2.0 Home (1.5.0)
|
||||
"checkin_done_title" = "%@のチェックイン完了";
|
||||
"checkin_done_next" = "次回:%@";
|
||||
"checkin_next_due" = "次回のチェックイン:%@";
|
||||
"checkin_days_left" = "あと%d日";
|
||||
"checkin_due_today" = "本日期限";
|
||||
"checkin_overdue" = "期限切れ";
|
||||
"dash_chart_yoy" = "年別比較";
|
||||
"home_chart_locked_sub" = "Premium で解除してこのチャートを表示できます。";
|
||||
"checkin_sources_progress" = "%2$d件中%1$d件のソースを更新済み";
|
||||
|
||||
// Guided check-in (1.5.0 Fase 2)
|
||||
"guided_previous_value" = "先月";
|
||||
"guided_paste_amount" = "%@を貼り付け";
|
||||
"guided_next" = "次へ";
|
||||
"guided_same_as_last" = "先月と同じ";
|
||||
"guided_skip" = "スキップ";
|
||||
"guided_reflection_title" = "今月はどんな気分ですか?";
|
||||
"guided_reflection_subtitle" = "未来の自分への簡単なメモ。";
|
||||
"guided_note_placeholder" = "今月について正直な一言…";
|
||||
"guided_finish" = "チェックインを完了";
|
||||
"guided_done_title" = "%@のチェックイン完了";
|
||||
"guided_streak" = "%dか月連続";
|
||||
"guided_share_month" = "今月をシェア";
|
||||
"guided_done_cta" = "完了";
|
||||
"journal_no_note" = "まだメモがありません。";
|
||||
|
||||
// What's New catalog (1.4.1–1.5.0)
|
||||
"whats_new_version_header" = "バージョン%@";
|
||||
"whats_new_150_redesign_title" = "より穏やかで洗練されたデザイン";
|
||||
"whats_new_150_redesign_body" = "ホームを刷新。総資産を主役に、チェックインを中心に、あらゆる場所のノイズを削減しました。";
|
||||
"whats_new_150_guided_title" = "ガイド付き月次チェックイン";
|
||||
"whats_new_150_guided_body" = "1画面1ソース、リアルタイム差分、簡単な振り返り — 月5分の習慣を再設計しました。";
|
||||
"whats_new_150_rings_title" = "目標リングとジャーナルタイムライン";
|
||||
"whats_new_150_rings_body" = "フィットネス風の進捗リングと、投資の歩みを綴るムードタイムライン。";
|
||||
"whats_new_142_ocr_title" = "スクリーンショットから更新";
|
||||
"whats_new_142_ocr_body" = "銀行アプリのスクリーンショットを共有するだけ。残高はデバイス上で認識され、外部には送信されません。";
|
||||
"whats_new_142_siri_title" = "Siriに対応";
|
||||
"whats_new_142_siri_body" = "「Indexaの残高を記録」— アプリを開かずに音声やショートカットで記録できます。";
|
||||
"whats_new_142_charts_title" = "チャートを一新";
|
||||
"whats_new_142_charts_body" = "iPadのネイティブサイドバー、チャートごとの最適なKPI、美しい画像での共有ボタン。";
|
||||
"whats_new_141_extension_title" = "アプリを切り替えずに更新";
|
||||
"whats_new_141_extension_body" = "新しい共有拡張機能で、銀行アプリから直接Portfolio Journalへ値を送れます。";
|
||||
"whats_new_141_ipad_title" = "iPadでより快適に";
|
||||
"whats_new_141_ipad_body" = "大画面向けにチャートを再設計。ピンチズームと値ラベルに対応。";
|
||||
"whats_new_141_icloud_title" = "さらに同期";
|
||||
"whats_new_141_icloud_body" = "毎月の積立額と配分目標がiCloudでデバイス間同期されるようになりました。";
|
||||
"whats_new_title_format" = "バージョン%@の新機能";
|
||||
|
||||
// TipKit (1.5.0 Fase 4)
|
||||
"tip_quick_update_title" = "数秒ですべて更新";
|
||||
"tip_quick_update_body" = "クイック更新なら全ソースを1画面で。クリップボードから値を貼り付けるだけ。";
|
||||
"tip_ocr_title" = "スクリーンショットの共有でもOK";
|
||||
"tip_ocr_body" = "銀行アプリでスクショ → 共有 → Portfolio Journal。残高はデバイス上で読み取られます。";
|
||||
"tip_siri_title" = "次は声で試してみて";
|
||||
"tip_siri_body" = "Siriに「Portfolio Journalで口座の残高を記録」と話しかけるだけ。アプリを開く必要はありません。";
|
||||
"tip_chart_share_title" = "このチャートを共有";
|
||||
"tip_chart_share_body" = "あなたのデータで美しい画像を生成。XやWhatsAppにそのまま共有できます。";
|
||||
"allocation_total" = "合計";
|
||||
|
||||
// iCloud error hints (1.5.0)
|
||||
"icloud_hint_schema" = "一部のレコードがiCloudに拒否されました。多くの場合iCloudのスキーマ設定が完了していないためで、数時間の使用後または次回のアップデートで解消されます。";
|
||||
"icloud_hint_quota" = "iCloudの容量がいっぱいです。空き容量を確保して再試行してください。";
|
||||
"icloud_hint_network" = "iCloudに接続できませんでした。接続を確認して再試行してください。";
|
||||
"icloud_hint_partial_generic" = "今回一部のレコードを同期できませんでした。アプリが自動的に再試行します。";
|
||||
"icloud_status_error" = "iCloud同期の問題";
|
||||
"icloud_status_syncing" = "iCloudと同期中";
|
||||
"icloud_show_details" = "技術的な詳細を表示";
|
||||
"yoy_vs_prev" = "%@比";
|
||||
"yoy_best_year" = "最高の年";
|
||||
"yoy_worst_year" = "最悪の年";
|
||||
|
||||
// Quick Update paste/scan (1.5.0)
|
||||
"quick_update_paste_value" = "%@を貼り付け";
|
||||
"quick_update_scan" = "スキャン";
|
||||
"quick_update_done_kbd" = "完了";
|
||||
"quick_update_scanning" = "画像を読み取り中…";
|
||||
"quick_update_pick_amount" = "どの金額?";
|
||||
"quick_update_no_amount_found" = "画像から金額を認識できませんでした。";
|
||||
"icloud_copy_diagnostics" = "iCloud診断をコピー";
|
||||
"icloud_diagnostics_copied" = "診断をクリップボードにコピーしました";
|
||||
|
||||
"Smooth Gaps in Charts" = "グラフの空白を補間";
|
||||
|
||||
// MARK: - Data Gaps (1.5.0)
|
||||
"gaps_banner_title" = "%dか月分のデータがありません";
|
||||
"gaps_missing_count" = "%d件不足";
|
||||
"gaps_range_between" = "%@〜%@の間";
|
||||
"Fill in missing data" = "不足しているデータを入力";
|
||||
"Missing Data" = "不足データ";
|
||||
"Fill Gap" = "空白を埋める";
|
||||
"The chart estimates these months. Add real snapshots to keep your history accurate." = "グラフはこれらの月を推定しています。正確な履歴を保つため実際の値を追加してください。";
|
||||
"Dismiss" = "閉じる";
|
||||
|
||||
// MARK: - Shareable Monthly Summary (1.5.0)
|
||||
"share_summary_streak_badge" = "%dか月";
|
||||
"Monthly Summary" = "月間サマリー";
|
||||
"Portfolio Value" = "ポートフォリオの価値";
|
||||
"Tracked with" = "記録に使用";
|
||||
"Share monthly summary" = "月間サマリーを共有";
|
||||
"since last check-in" = "前回のチェックイン以降";
|
||||
|
||||
// MARK: - Import Preview (1.5.0)
|
||||
"import_preview_nothing" = "新しくインポートするものはありません。データはすでに最新です。";
|
||||
"import_preview_sources" = "%d件のソースを作成";
|
||||
"import_preview_snapshots_new" = "%d件のスナップショットを作成";
|
||||
"import_preview_snapshots_update" = "%d件のスナップショットを更新";
|
||||
"import_preview_categories" = "%d件のカテゴリーを作成";
|
||||
"import_preview_goals" = "%d件の目標を作成";
|
||||
"import_preview_journal_new" = "%d件のジャーナルを作成";
|
||||
"import_preview_journal_update" = "%d件のジャーナルを更新";
|
||||
"Review Import" = "インポートの確認";
|
||||
"Reviewing import…" = "インポートを確認中…";
|
||||
"Import" = "インポート";
|
||||
|
||||
/* 1.6.0 gap auto-fill */
|
||||
"gaps_estimated_note" = "推定";
|
||||
"gaps_autofill_cta" = "%dか月を自動入力";
|
||||
"Add manually" = "手動で追加";
|
||||
"Auto-fill estimates the missing months between two snapshots (marked as estimated). Or add the real values yourself." = "自動入力は2つの記録の間の欠けている月を推定します(推定として表示)。または実際の値を自分で入力できます。";
|
||||
|
||||
/* 1.6.0 auto-backup */
|
||||
"Auto Backup" = "自動バックアップ";
|
||||
"Off" = "オフ";
|
||||
"Daily" = "毎日";
|
||||
"Weekly" = "毎週";
|
||||
"Monthly" = "毎月";
|
||||
"Last auto backup" = "最後の自動バックアップ";
|
||||
|
||||
/* 1.6.0 hide balances */
|
||||
"Hide balances" = "残高を隠す";
|
||||
"Show balances" = "残高を表示";
|
||||
"Balance hidden" = "残高は非表示";
|
||||
"Reveal your balances" = "残高を表示";
|
||||
"Privacy" = "プライバシー";
|
||||
"Hide Balances" = "残高を隠す";
|
||||
"Require Face ID to Reveal" = "表示にFace IDを要求";
|
||||
"Hide all monetary amounts behind a blur. Great for using the app in public or taking screenshots. Optionally require Face ID to reveal them." = "すべての金額をぼかして隠します。人前での利用やスクリーンショットに最適。表示にFace IDを要求することもできます。";
|
||||
|
||||
/* 1.6.0 Siri intents */
|
||||
"intent_networth_empty" = "まだポートフォリオの金額が記録されていません。";
|
||||
"intent_networth_dialog" = "あなたの純資産は%@です。";
|
||||
"intent_streak" = "%dか月連続で記録中です。";
|
||||
"intent_streak_with_date" = "%dか月連続で記録中です。最後のチェックインは%@でした。";
|
||||
"intent_streak_none" = "まだ連続記録がありません。値を記録して始めましょう。";
|
||||
|
||||
// Charts workable (1.6.0)
|
||||
"chart_range_button" = "カスタム期間";
|
||||
"chart_range_from" = "開始";
|
||||
"chart_range_to" = "終了";
|
||||
"chart_filtered_prefix" = "フィルター: %@";
|
||||
"chart_overlay_contributions" = "拠出額と比較";
|
||||
"chart_insight_best_month" = "期間内のベスト月: %1$@(%2$@)";
|
||||
|
||||
// Share cards 2.0 (1.6.0)
|
||||
"share_options_title" = "成果をシェア";
|
||||
"share_percent_only" = "パーセントのみ";
|
||||
"share_format_post" = "投稿";
|
||||
"share_format_story" = "ストーリー";
|
||||
"share_card_cta" = "シェア";
|
||||
|
||||
// Share cards 2.1 — brand header + download CTA (1.6.0)
|
||||
"share_hero_eyebrow" = "トータルリターン";
|
||||
"share_metric_yoy" = "今年";
|
||||
"share_metric_since_inception" = "開始来";
|
||||
"share_cta_headline" = "あなたも無料で記録";
|
||||
"share_cta_sub" = "スキャンでPortfolio Journalを入手";
|
||||
|
||||
// Share cards 2.2 — as-of date + theme (1.6.0)
|
||||
"share_as_of" = "%@時点";
|
||||
"share_theme_dark" = "ダーク";
|
||||
"share_theme_light" = "ライト";
|
||||
|
||||
// Activation: first source → first snapshot (1.6.1)
|
||||
"add_source_value_header" = "今日の評価額";
|
||||
"add_source_value_footer" = "最初のチェックインとして保存され、今日から進捗の記録が始まります。";
|
||||
"quick_update_add_first_source" = "最初の投資先を追加";
|
||||
|
||||
@@ -353,6 +353,18 @@
|
||||
"onboarding_quickstart_subtitle" = "Adicione sua primeira fonte de investimento para começar a acompanhar seu portfólio.";
|
||||
"onboarding_quickstart_title" = "Quase lá";
|
||||
"reengagement_body" = "Alguns minutos são suficientes para manter o controle dos seus investimentos.";
|
||||
"reengagement_goal_title" = "Você está no caminho certo 📈";
|
||||
"reengagement_goal_body" = "Nesse ritmo você vai alcançar \"%@\" até %@. Veja o quanto falta.";
|
||||
"reengagement_yoy_title" = "Olha esse crescimento 👀";
|
||||
"reengagement_yoy_body" = "Você está %@ acima no último ano. Confira a tendência.";
|
||||
"reengagement_month_title" = "Bom mês 📈";
|
||||
"reengagement_month_body" = "Sua carteira cresceu %@ este mês. Dê uma olhada.";
|
||||
"reengagement_milestone_title" = "Quase lá 🎯";
|
||||
"reengagement_milestone_body" = "Você está quase em %@. Quão perto está hoje?";
|
||||
"reengagement_streak_title" = "Mantenha a sequência 🔥";
|
||||
"reengagement_streak_body" = "Você registrou %d meses seguidos. Não pare agora.";
|
||||
"reengagement_neutral_title" = "Alguma novidade? 👀";
|
||||
"reengagement_neutral_body" = "Houve movimento na sua carteira. Veja o que mudou.";
|
||||
"reengagement_title" = "Seu portfólio está esperando";
|
||||
"snapshot_duplicate_add" = "Adicionar mesmo assim";
|
||||
"snapshot_contribution_propagate_title" = "Aplicar contribuição";
|
||||
@@ -412,6 +424,25 @@
|
||||
"insight_streak_title" = "Sequência de atualizações";
|
||||
"insight_streak_value" = "%d meses consecutivos";
|
||||
"insight_forecast_title" = "Caminhando para";
|
||||
"insight_allocation_title" = "Desvio de alocação";
|
||||
"insight_allocation_over_value" = "%1$@ está em %2$.0f%% — acima da sua meta de %3$.0f%%";
|
||||
"insight_allocation_under_value" = "%1$@ está em %2$.0f%% — abaixo da sua meta de %3$.0f%%";
|
||||
"insight_allocation_detail" = "Rebalanceie para seguir o plano";
|
||||
"insight_concentration_title" = "Risco de concentração";
|
||||
"insight_concentration_value" = "%1$@ representa %2$.0f%% da sua carteira";
|
||||
"insight_concentration_detail" = "Considere diversificar";
|
||||
"insight_best_performer_title" = "Melhor desempenho";
|
||||
"insight_worst_performer_title" = "Requer atenção";
|
||||
"insight_performer_value" = "%1$@ · %2$+.1f%%";
|
||||
"insight_diversification_title" = "Diversificação";
|
||||
"insight_diversification_single_value" = "Tudo em uma fonte — considere adicionar outra";
|
||||
"insight_diversification_good_value" = "Distribuído em %d categorias";
|
||||
"goal_projection_projected" = "Previsão: %@";
|
||||
"goal_projection_add_contributions" = "Adicione aportes para alcançar";
|
||||
"goal_chip_ahead" = "Adiantado";
|
||||
"goal_chip_on_track" = "No caminho";
|
||||
"goal_chip_behind" = "Atrasado";
|
||||
"goal_chip_at_this_pace" = "Neste ritmo…";
|
||||
"notification_milestone_title" = "Meta de portfólio! 🎉";
|
||||
"notification_milestone_body" = "Seu portfólio acabou de ultrapassar %@. Parabéns!";
|
||||
|
||||
@@ -468,3 +499,181 @@
|
||||
"chart_share_scan" = "Escaneie para baixar";
|
||||
"chart_locked_title" = "%@ é um gráfico Premium";
|
||||
"chart_locked_cta" = "Desbloquear Premium";
|
||||
|
||||
// Calm 2.0 Home (1.5.0)
|
||||
"checkin_done_title" = "Check-in de %@ concluído";
|
||||
"checkin_done_next" = "Próximo: %@";
|
||||
"checkin_next_due" = "Próximo check-in: %@";
|
||||
"checkin_days_left" = "faltam %d dias";
|
||||
"checkin_due_today" = "Hoje";
|
||||
"checkin_overdue" = "Atrasado";
|
||||
"dash_chart_yoy" = "Ano vs Ano";
|
||||
"home_chart_locked_sub" = "Desbloqueie com Premium para ver este gráfico.";
|
||||
"checkin_sources_progress" = "%1$d de %2$d fontes atualizadas";
|
||||
|
||||
// Guided check-in (1.5.0 Fase 2)
|
||||
"guided_previous_value" = "Mês passado";
|
||||
"guided_paste_amount" = "Colar %@";
|
||||
"guided_next" = "Avançar";
|
||||
"guided_same_as_last" = "Igual ao mês passado";
|
||||
"guided_skip" = "Pular";
|
||||
"guided_reflection_title" = "Como está sendo este mês?";
|
||||
"guided_reflection_subtitle" = "Uma nota rápida para o seu eu do futuro.";
|
||||
"guided_note_placeholder" = "Uma linha sincera sobre este mês…";
|
||||
"guided_finish" = "Concluir check-in";
|
||||
"guided_done_title" = "Check-in de %@ concluído";
|
||||
"guided_streak" = "%d meses seguidos";
|
||||
"guided_share_month" = "Compartilhar este mês";
|
||||
"guided_done_cta" = "Concluído";
|
||||
"journal_no_note" = "Ainda sem nota.";
|
||||
|
||||
// What's New catalog (1.4.1–1.5.0)
|
||||
"whats_new_version_header" = "Versão %@";
|
||||
"whats_new_150_redesign_title" = "Um visual mais calmo e limpo";
|
||||
"whats_new_150_redesign_body" = "Início redesenhado com seu valor total em destaque, o check-in no centro e menos ruído em tudo.";
|
||||
"whats_new_150_guided_title" = "Check-in mensal guiado";
|
||||
"whats_new_150_guided_body" = "Uma fonte por etapa, deltas ao vivo e uma reflexão rápida — seu ritual de 5 minutos, reinventado.";
|
||||
"whats_new_150_rings_title" = "Anéis de metas & timeline";
|
||||
"whats_new_150_rings_body" = "Anéis de progresso estilo Fitness para metas e uma timeline da sua história como investidor.";
|
||||
"whats_new_142_ocr_title" = "Atualize com uma captura de tela";
|
||||
"whats_new_142_ocr_body" = "Compartilhe uma captura do seu banco — o saldo é reconhecido no aparelho, nada sai do seu iPhone.";
|
||||
"whats_new_142_siri_title" = "Siri no seu idioma";
|
||||
"whats_new_142_siri_body" = "\"Registre o saldo da Indexa\" — anote valores por voz ou pelos Atalhos, sem abrir o app.";
|
||||
"whats_new_142_charts_title" = "Gráficos renovados";
|
||||
"whats_new_142_charts_body" = "Barra lateral nativa no iPad, KPIs sob medida por gráfico e compartilhamento com belas imagens.";
|
||||
"whats_new_141_extension_title" = "Atualize sem trocar de app";
|
||||
"whats_new_141_extension_body" = "Compartilhe um valor do app do seu banco direto no Portfolio Journal com a nova extensão.";
|
||||
"whats_new_141_ipad_title" = "Melhor no iPad";
|
||||
"whats_new_141_ipad_body" = "Gráficos redesenhados para a tela grande, com zoom e rótulos de valor.";
|
||||
"whats_new_141_icloud_title" = "Mais sincronizado";
|
||||
"whats_new_141_icloud_body" = "Aportes mensais e metas de alocação agora sincronizam via iCloud.";
|
||||
"whats_new_title_format" = "Novidades da %@";
|
||||
|
||||
// TipKit (1.5.0 Fase 4)
|
||||
"tip_quick_update_title" = "Atualize tudo em segundos";
|
||||
"tip_quick_update_body" = "O Quick Update lista todas as suas fontes em uma tela — cole valores da área de transferência e pronto.";
|
||||
"tip_ocr_title" = "Ou compartilhe uma captura de tela";
|
||||
"tip_ocr_body" = "Do app do seu banco: captura → Compartilhar → Portfolio Journal. O saldo é lido no aparelho.";
|
||||
"tip_siri_title" = "Na próxima, experimente por voz";
|
||||
"tip_siri_body" = "Diga à Siri \"Registre o saldo da minha conta no Portfolio Journal\" — sem abrir o app.";
|
||||
"tip_chart_share_title" = "Compartilhe este gráfico";
|
||||
"tip_chart_share_body" = "Gera uma bela imagem com seus dados, pronta para o X ou WhatsApp.";
|
||||
"allocation_total" = "Total";
|
||||
|
||||
// iCloud error hints (1.5.0)
|
||||
"icloud_hint_schema" = "O iCloud rejeitou alguns registros. Geralmente significa que o esquema do iCloud ainda está sendo configurado — costuma resolver em algumas horas ou após a próxima atualização do app.";
|
||||
"icloud_hint_quota" = "Seu armazenamento do iCloud está cheio. Libere espaço e tente novamente.";
|
||||
"icloud_hint_network" = "Não foi possível acessar o iCloud. Verifique sua conexão e tente novamente.";
|
||||
"icloud_hint_partial_generic" = "Alguns registros não sincronizaram desta vez. O app tentará novamente automaticamente.";
|
||||
"icloud_status_error" = "Problema de sincronização com o iCloud";
|
||||
"icloud_status_syncing" = "Sincronizando com o iCloud";
|
||||
"icloud_show_details" = "Mostrar detalhes técnicos";
|
||||
"yoy_vs_prev" = "vs %@";
|
||||
"yoy_best_year" = "Melhor ano";
|
||||
"yoy_worst_year" = "Pior ano";
|
||||
|
||||
// Quick Update paste/scan (1.5.0)
|
||||
"quick_update_paste_value" = "Colar %@";
|
||||
"quick_update_scan" = "Escanear";
|
||||
"quick_update_done_kbd" = "Concluir";
|
||||
"quick_update_scanning" = "Lendo imagem…";
|
||||
"quick_update_pick_amount" = "Qual valor?";
|
||||
"quick_update_no_amount_found" = "Nenhum valor reconhecido na imagem.";
|
||||
"icloud_copy_diagnostics" = "Copiar diagnóstico do iCloud";
|
||||
"icloud_diagnostics_copied" = "Diagnóstico copiado para a área de transferência";
|
||||
|
||||
"Smooth Gaps in Charts" = "Suavizar lacunas nos gráficos";
|
||||
|
||||
// MARK: - Data Gaps (1.5.0)
|
||||
"gaps_banner_title" = "Faltam dados de %d meses";
|
||||
"gaps_missing_count" = "%d faltando";
|
||||
"gaps_range_between" = "Entre %@ e %@";
|
||||
"Fill in missing data" = "Preencher dados faltantes";
|
||||
"Missing Data" = "Dados faltantes";
|
||||
"Fill Gap" = "Preencher lacuna";
|
||||
"The chart estimates these months. Add real snapshots to keep your history accurate." = "O gráfico estima esses meses. Adicione valores reais para manter seu histórico preciso.";
|
||||
"Dismiss" = "Dispensar";
|
||||
|
||||
// MARK: - Shareable Monthly Summary (1.5.0)
|
||||
"share_summary_streak_badge" = "%d m";
|
||||
"Monthly Summary" = "Resumo mensal";
|
||||
"Portfolio Value" = "Valor da carteira";
|
||||
"Tracked with" = "Registrado com";
|
||||
"Share monthly summary" = "Compartilhar resumo mensal";
|
||||
"since last check-in" = "desde o último check-in";
|
||||
|
||||
// MARK: - Import Preview (1.5.0)
|
||||
"import_preview_nothing" = "Nada novo para importar. Seus dados já estão atualizados.";
|
||||
"import_preview_sources" = "Criar %d fontes";
|
||||
"import_preview_snapshots_new" = "Criar %d registros";
|
||||
"import_preview_snapshots_update" = "Atualizar %d registros";
|
||||
"import_preview_categories" = "Criar %d categorias";
|
||||
"import_preview_goals" = "Criar %d metas";
|
||||
"import_preview_journal_new" = "Criar %d entradas do diário";
|
||||
"import_preview_journal_update" = "Atualizar %d entradas do diário";
|
||||
"Review Import" = "Revisar importação";
|
||||
"Reviewing import…" = "Revisando importação…";
|
||||
"Import" = "Importar";
|
||||
|
||||
/* 1.6.0 gap auto-fill */
|
||||
"gaps_estimated_note" = "Estimado";
|
||||
"gaps_autofill_cta" = "Preencher %d meses";
|
||||
"Add manually" = "Adicionar manualmente";
|
||||
"Auto-fill estimates the missing months between two snapshots (marked as estimated). Or add the real values yourself." = "O preenchimento automático estima os meses faltantes entre dois registros (marcados como estimados). Ou insira os valores reais você mesmo.";
|
||||
|
||||
/* 1.6.0 auto-backup */
|
||||
"Auto Backup" = "Backup automático";
|
||||
"Off" = "Desativado";
|
||||
"Daily" = "Diário";
|
||||
"Weekly" = "Semanal";
|
||||
"Monthly" = "Mensal";
|
||||
"Last auto backup" = "Último backup automático";
|
||||
|
||||
/* 1.6.0 hide balances */
|
||||
"Hide balances" = "Ocultar saldos";
|
||||
"Show balances" = "Mostrar saldos";
|
||||
"Balance hidden" = "Saldo oculto";
|
||||
"Reveal your balances" = "Mostrar seus saldos";
|
||||
"Privacy" = "Privacidade";
|
||||
"Hide Balances" = "Ocultar saldos";
|
||||
"Require Face ID to Reveal" = "Exigir Face ID para mostrar";
|
||||
"Hide all monetary amounts behind a blur. Great for using the app in public or taking screenshots. Optionally require Face ID to reveal them." = "Oculta todos os valores atrás de um desfoque. Ótimo para usar o app em público ou tirar prints. Opcionalmente, exija Face ID para revelá-los.";
|
||||
|
||||
/* 1.6.0 Siri intents */
|
||||
"intent_networth_empty" = "Você ainda não registrou nenhum valor de carteira.";
|
||||
"intent_networth_dialog" = "Seu patrimônio é %@.";
|
||||
"intent_streak" = "Você está em uma sequência de %d meses.";
|
||||
"intent_streak_with_date" = "Você está em uma sequência de %d meses. Seu último check-in foi em %@.";
|
||||
"intent_streak_none" = "Você ainda não tem uma sequência ativa. Registre um valor para começar uma.";
|
||||
|
||||
// Charts workable (1.6.0)
|
||||
"chart_range_button" = "Período personalizado";
|
||||
"chart_range_from" = "De";
|
||||
"chart_range_to" = "Até";
|
||||
"chart_filtered_prefix" = "Filtrado: %@";
|
||||
"chart_overlay_contributions" = "vs Aportes";
|
||||
"chart_insight_best_month" = "Melhor mês do período: %1$@ (%2$@)";
|
||||
|
||||
// Share cards 2.0 (1.6.0)
|
||||
"share_options_title" = "Compartilhe seu progresso";
|
||||
"share_percent_only" = "Apenas porcentagens";
|
||||
"share_format_post" = "Post";
|
||||
"share_format_story" = "Story";
|
||||
"share_card_cta" = "Compartilhar";
|
||||
|
||||
// Share cards 2.1 — brand header + download CTA (1.6.0)
|
||||
"share_hero_eyebrow" = "Retorno total";
|
||||
"share_metric_yoy" = "Este ano";
|
||||
"share_metric_since_inception" = "Desde o início";
|
||||
"share_cta_headline" = "Acompanhe o seu — grátis";
|
||||
"share_cta_sub" = "Escaneie para obter o Portfolio Journal";
|
||||
|
||||
// Share cards 2.2 — as-of date + theme (1.6.0)
|
||||
"share_as_of" = "Em %@";
|
||||
"share_theme_dark" = "Escuro";
|
||||
"share_theme_light" = "Claro";
|
||||
|
||||
// Activation: first source → first snapshot (1.6.1)
|
||||
"add_source_value_header" = "Valor de hoje";
|
||||
"add_source_value_footer" = "Salvo como seu primeiro check-in — seu progresso começa hoje.";
|
||||
"quick_update_add_first_source" = "Adicionar sua primeira fonte";
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import AppIntents
|
||||
import Foundation
|
||||
import CoreData
|
||||
|
||||
// MARK: - App Intents (Siri / Shortcuts / Spotlight)
|
||||
//
|
||||
@@ -117,6 +118,173 @@ struct LogSnapshotIntent: AppIntent {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Net worth / streak (read-only, from the shared App Group store)
|
||||
|
||||
/// Reads aggregate portfolio facts from the App Group Core Data store the same
|
||||
/// way the widget does: a read-only container over the shared SQLite file. This
|
||||
/// lets the intents answer instantly without touching the app's CloudKit stack.
|
||||
enum SharedPortfolioReader {
|
||||
private static let storeFileName = "PortfolioJournal.sqlite"
|
||||
|
||||
struct Snapshot {
|
||||
let totalValue: Decimal
|
||||
let currencyCode: String
|
||||
let streak: Int
|
||||
let lastCheckIn: Date?
|
||||
}
|
||||
|
||||
private static func sharedStoreURL() -> URL? {
|
||||
FileManager.default
|
||||
.containerURL(forSecurityApplicationGroupIdentifier: AppConstants.appGroupIdentifier)?
|
||||
.appendingPathComponent(storeFileName)
|
||||
}
|
||||
|
||||
private static func makeContainer() -> NSPersistentContainer? {
|
||||
guard let modelURL = Bundle.main.url(forResource: "PortfolioJournal", withExtension: "momd"),
|
||||
let model = NSManagedObjectModel(contentsOf: modelURL),
|
||||
let storeURL = sharedStoreURL(),
|
||||
FileManager.default.fileExists(atPath: storeURL.path) else {
|
||||
return nil
|
||||
}
|
||||
let container = NSPersistentContainer(name: "PortfolioJournal", managedObjectModel: model)
|
||||
let description = NSPersistentStoreDescription(url: storeURL)
|
||||
description.isReadOnly = true
|
||||
description.shouldMigrateStoreAutomatically = true
|
||||
description.shouldInferMappingModelAutomatically = true
|
||||
container.persistentStoreDescriptions = [description]
|
||||
|
||||
var loadError: Error?
|
||||
container.loadPersistentStores { _, error in loadError = error }
|
||||
return loadError == nil ? container : nil
|
||||
}
|
||||
|
||||
static func read() -> Snapshot {
|
||||
guard let container = makeContainer() else {
|
||||
return Snapshot(totalValue: 0, currencyCode: "EUR", streak: 0, lastCheckIn: nil)
|
||||
}
|
||||
let context = container.viewContext
|
||||
let calendar = Calendar.current
|
||||
|
||||
// Currency
|
||||
var currency = "EUR"
|
||||
let settingsRequest = NSFetchRequest<NSManagedObject>(entityName: "AppSettings")
|
||||
settingsRequest.fetchLimit = 1
|
||||
if let settings = try? context.fetch(settingsRequest).first,
|
||||
let code = settings.value(forKey: "currency") as? String, !code.isEmpty {
|
||||
currency = code
|
||||
}
|
||||
|
||||
// Latest snapshot value per source → total net worth.
|
||||
// Also collect the set of (year, month) that have snapshot data for streak.
|
||||
let snapRequest = NSFetchRequest<NSManagedObject>(entityName: "Snapshot")
|
||||
snapRequest.sortDescriptors = [NSSortDescriptor(key: "date", ascending: true)]
|
||||
let snapshots = (try? context.fetch(snapRequest)) ?? []
|
||||
|
||||
var latestBySource: [NSManagedObjectID: (date: Date, value: Decimal)] = [:]
|
||||
var monthComponents = Set<DateComponents>()
|
||||
for snap in snapshots {
|
||||
guard let source = snap.value(forKey: "source") as? NSManagedObject else { continue }
|
||||
let date = (snap.value(forKey: "date") as? Date) ?? .distantPast
|
||||
let value = (snap.value(forKey: "value") as? NSDecimalNumber)?.decimalValue ?? .zero
|
||||
let key = source.objectID
|
||||
if let existing = latestBySource[key] {
|
||||
if date >= existing.date { latestBySource[key] = (date, value) }
|
||||
} else {
|
||||
latestBySource[key] = (date, value)
|
||||
}
|
||||
let c = calendar.dateComponents([.year, .month], from: date)
|
||||
monthComponents.insert(DateComponents(year: c.year, month: c.month))
|
||||
}
|
||||
let totalValue = latestBySource.values.reduce(Decimal.zero) { $0 + $1.value }
|
||||
|
||||
// Streak: consecutive months with data counting back from most recent.
|
||||
let sortedMonthsDesc = monthComponents
|
||||
.compactMap { calendar.date(from: $0) }
|
||||
.sorted(by: >)
|
||||
var streak = 0
|
||||
if let mostRecent = sortedMonthsDesc.first {
|
||||
streak = 1
|
||||
var current = mostRecent
|
||||
for i in 1..<max(sortedMonthsDesc.count, 1) {
|
||||
guard i < sortedMonthsDesc.count,
|
||||
let expected = calendar.date(byAdding: .month, value: -1, to: current) else { break }
|
||||
let prev = sortedMonthsDesc[i]
|
||||
let pc = calendar.dateComponents([.year, .month], from: prev)
|
||||
let ec = calendar.dateComponents([.year, .month], from: expected)
|
||||
if pc.year == ec.year && pc.month == ec.month {
|
||||
streak += 1
|
||||
current = prev
|
||||
} else { break }
|
||||
}
|
||||
}
|
||||
|
||||
// Last check-in from the most recent completed JournalEntry.
|
||||
let journalRequest = NSFetchRequest<NSManagedObject>(entityName: "JournalEntry")
|
||||
journalRequest.predicate = NSPredicate(format: "completionTime != nil")
|
||||
journalRequest.sortDescriptors = [NSSortDescriptor(key: "completionTime", ascending: false)]
|
||||
journalRequest.fetchLimit = 1
|
||||
let lastCheckIn = (try? context.fetch(journalRequest))?.first?
|
||||
.value(forKey: "completionTime") as? Date
|
||||
|
||||
return Snapshot(totalValue: totalValue, currencyCode: currency, streak: streak, lastCheckIn: lastCheckIn)
|
||||
}
|
||||
}
|
||||
|
||||
/// "What's my net worth in Portfolio Journal" — reads the total portfolio value
|
||||
/// from the shared store and speaks it back. Runs headless (no app launch).
|
||||
struct NetWorthIntent: AppIntent {
|
||||
static let title: LocalizedStringResource = "Check Net Worth"
|
||||
// ITMS-90626: intent descriptions must not contain the word "Siri".
|
||||
static let description = IntentDescription("Get your total portfolio value.")
|
||||
static let openAppWhenRun = false
|
||||
|
||||
@MainActor
|
||||
func perform() async throws -> some IntentResult & ProvidesDialog {
|
||||
let snapshot = SharedPortfolioReader.read()
|
||||
let formatter = NumberFormatter()
|
||||
formatter.numberStyle = .currency
|
||||
formatter.currencyCode = snapshot.currencyCode
|
||||
let formatted = formatter.string(from: NSDecimalNumber(decimal: snapshot.totalValue))
|
||||
?? "\(snapshot.totalValue)"
|
||||
|
||||
let text: String
|
||||
if snapshot.totalValue == 0 {
|
||||
text = String(localized: "intent_networth_empty")
|
||||
} else {
|
||||
text = String(format: String(localized: "intent_networth_dialog"), formatted)
|
||||
}
|
||||
return .result(dialog: IntentDialog(stringLiteral: text))
|
||||
}
|
||||
}
|
||||
|
||||
/// "What's my streak in Portfolio Journal" — reports the current check-in streak
|
||||
/// and when you last logged. Nice-to-have engagement surface. Runs headless.
|
||||
struct StreakIntent: AppIntent {
|
||||
static let title: LocalizedStringResource = "Check Streak"
|
||||
// ITMS-90626: intent descriptions must not contain the word "Siri".
|
||||
static let description = IntentDescription("Get your current check-in streak.")
|
||||
static let openAppWhenRun = false
|
||||
|
||||
@MainActor
|
||||
func perform() async throws -> some IntentResult & ProvidesDialog {
|
||||
let snapshot = SharedPortfolioReader.read()
|
||||
let text: String
|
||||
if snapshot.streak >= 1 {
|
||||
if let last = snapshot.lastCheckIn {
|
||||
let df = DateFormatter()
|
||||
df.dateStyle = .medium
|
||||
df.timeStyle = .none
|
||||
text = String(format: String(localized: "intent_streak_with_date"), snapshot.streak, df.string(from: last))
|
||||
} else {
|
||||
text = String(format: String(localized: "intent_streak"), snapshot.streak)
|
||||
}
|
||||
} else {
|
||||
text = String(localized: "intent_streak_none")
|
||||
}
|
||||
return .result(dialog: IntentDialog(stringLiteral: text))
|
||||
}
|
||||
}
|
||||
|
||||
struct PortfolioJournalShortcuts: AppShortcutsProvider {
|
||||
static var appShortcuts: [AppShortcut] {
|
||||
AppShortcut(
|
||||
@@ -147,5 +315,24 @@ struct PortfolioJournalShortcuts: AppShortcutsProvider {
|
||||
shortTitle: "Log Value",
|
||||
systemImageName: "square.and.pencil"
|
||||
)
|
||||
AppShortcut(
|
||||
intent: NetWorthIntent(),
|
||||
phrases: [
|
||||
"What's my net worth in \(.applicationName)",
|
||||
"How much is my portfolio worth in \(.applicationName)",
|
||||
"Get my net worth from \(.applicationName)"
|
||||
],
|
||||
shortTitle: "Net Worth",
|
||||
systemImageName: "eurosign.circle.fill"
|
||||
)
|
||||
AppShortcut(
|
||||
intent: StreakIntent(),
|
||||
phrases: [
|
||||
"What's my streak in \(.applicationName)",
|
||||
"Check my check-in streak in \(.applicationName)"
|
||||
],
|
||||
shortTitle: "Streak",
|
||||
systemImageName: "flame.fill"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import SwiftUI
|
||||
import TipKit
|
||||
|
||||
// MARK: - Feature discovery (TipKit, Calm 2.0 Fase 4)
|
||||
//
|
||||
// The app's most differentiating features (quick update, screenshot OCR, Siri,
|
||||
// chart sharing) were invisible unless you stumbled on them. One contextual tip
|
||||
// each, shown at most once a day (see Tips.configure in PortfolioJournalApp),
|
||||
// auto-invalidated after display or when the action is performed.
|
||||
|
||||
struct QuickUpdateTip: Tip {
|
||||
var title: Text { Text(String(localized: "tip_quick_update_title")) }
|
||||
var message: Text? { Text(String(localized: "tip_quick_update_body")) }
|
||||
var image: Image? { Image(systemName: "bolt.circle.fill") }
|
||||
var options: [any TipOption] { [MaxDisplayCount(2)] }
|
||||
}
|
||||
|
||||
struct ScreenshotOCRTip: Tip {
|
||||
var title: Text { Text(String(localized: "tip_ocr_title")) }
|
||||
var message: Text? { Text(String(localized: "tip_ocr_body")) }
|
||||
var image: Image? { Image(systemName: "text.viewfinder") }
|
||||
var options: [any TipOption] { [MaxDisplayCount(2)] }
|
||||
}
|
||||
|
||||
struct SiriTip: Tip {
|
||||
var title: Text { Text(String(localized: "tip_siri_title")) }
|
||||
var message: Text? { Text(String(localized: "tip_siri_body")) }
|
||||
var image: Image? { Image(systemName: "mic.fill") }
|
||||
var options: [any TipOption] { [MaxDisplayCount(1)] }
|
||||
}
|
||||
|
||||
struct ChartShareTip: Tip {
|
||||
var title: Text { Text(String(localized: "tip_chart_share_title")) }
|
||||
var message: Text? { Text(String(localized: "tip_chart_share_body")) }
|
||||
var image: Image? { Image(systemName: "square.and.arrow.up") }
|
||||
var options: [any TipOption] { [MaxDisplayCount(2)] }
|
||||
}
|
||||
@@ -85,6 +85,30 @@ class BackupService {
|
||||
return records.sorted { $0.date > $1.date }
|
||||
}
|
||||
|
||||
/// Runs an automatic backup if auto-backup is enabled and the configured
|
||||
/// interval has elapsed since the last one. Call on app foreground. Reads its
|
||||
/// config from UserDefaults so it needs no coupling to the Settings VM. The
|
||||
/// backup content is the lossless JSON v3 export (goals, contributions, journal).
|
||||
@discardableResult
|
||||
func runScheduledBackupIfNeeded() -> Bool {
|
||||
let d = UserDefaults.standard
|
||||
// `backupsEnabled` is only settable by Premium users, so it also gates this.
|
||||
guard d.bool(forKey: "backupsEnabled") else { return false }
|
||||
let frequencyDays = d.integer(forKey: "autoBackupFrequencyDays") // 0 = off
|
||||
guard frequencyDays > 0 else { return false }
|
||||
|
||||
if let last = d.object(forKey: "lastAutoBackupDate") as? Date,
|
||||
dateProvider().timeIntervalSince(last) < Double(frequencyDays) * 86_400 {
|
||||
return false
|
||||
}
|
||||
|
||||
let retention = (d.object(forKey: "backupRetentionCount") as? Int) ?? 10
|
||||
let includeICloud = d.bool(forKey: "cloudSyncEnabled")
|
||||
_ = createBackup(retentionCount: retention, includeICloud: includeICloud)
|
||||
d.set(dateProvider(), forKey: "lastAutoBackupDate")
|
||||
return true
|
||||
}
|
||||
|
||||
func listAllBackups(includeICloud: Bool) -> [BackupRecord] {
|
||||
var records: [BackupRecord] = []
|
||||
if let localDir = backupDirectory() {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import Foundation
|
||||
import UIKit
|
||||
import CoreData
|
||||
|
||||
class ExportService {
|
||||
static let shared = ExportService()
|
||||
@@ -63,7 +64,7 @@ class ExportService {
|
||||
) -> String {
|
||||
var exportData: [String: Any] = [:]
|
||||
exportData["exportDate"] = ISO8601DateFormatter().string(from: Date())
|
||||
exportData["version"] = 2
|
||||
exportData["version"] = 3
|
||||
exportData["currency"] = AppSettings.getOrCreate(in: CoreDataStack.shared.viewContext).currency
|
||||
|
||||
let accounts = Dictionary(grouping: sources) { $0.account?.id.uuidString ?? "default" }
|
||||
@@ -92,14 +93,23 @@ class ExportService {
|
||||
"icon": category?.icon ?? "chart.pie.fill"
|
||||
]
|
||||
|
||||
if let target = category?.allocationTarget?.doubleValue, target > 0 {
|
||||
categoryDict["allocationTarget"] = target
|
||||
}
|
||||
|
||||
var sourcesArray: [[String: Any]] = []
|
||||
for source in categorySources {
|
||||
var sourceDict: [String: Any] = [
|
||||
"name": source.name,
|
||||
"isActive": source.isActive,
|
||||
"notificationFrequency": source.notificationFrequency
|
||||
"notificationFrequency": source.notificationFrequency,
|
||||
"customFrequencyMonths": Int(source.customFrequencyMonths)
|
||||
]
|
||||
|
||||
if let contribution = source.monthlyContribution {
|
||||
sourceDict["monthlyContribution"] = contribution.doubleValue
|
||||
}
|
||||
|
||||
var snapshotsArray: [[String: Any]] = []
|
||||
for snapshot in source.snapshotsArray {
|
||||
var snapshotDict: [String: Any] = [
|
||||
@@ -134,6 +144,54 @@ class ExportService {
|
||||
|
||||
exportData["accounts"] = accountsArray
|
||||
|
||||
// Goals (all accounts)
|
||||
let isoFormatter = ISO8601DateFormatter()
|
||||
let goalContext = CoreDataStack.shared.viewContext
|
||||
let goalRequest: NSFetchRequest<Goal> = Goal.fetchRequest()
|
||||
let allGoals = (try? goalContext.fetch(goalRequest)) ?? []
|
||||
var goalsArray: [[String: Any]] = []
|
||||
for goal in allGoals {
|
||||
var goalDict: [String: Any] = [
|
||||
"name": goal.name,
|
||||
"isActive": goal.isActive,
|
||||
"account": goal.account?.name as Any
|
||||
]
|
||||
if let amount = goal.targetAmount {
|
||||
goalDict["targetAmount"] = amount.doubleValue
|
||||
}
|
||||
if let date = goal.targetDate {
|
||||
goalDict["targetDate"] = isoFormatter.string(from: date)
|
||||
}
|
||||
goalsArray.append(goalDict)
|
||||
}
|
||||
exportData["goals"] = goalsArray
|
||||
|
||||
// Journal entries (all)
|
||||
let journalRequest: NSFetchRequest<JournalEntry> = JournalEntry.fetchRequest()
|
||||
let allJournalEntries = (try? goalContext.fetch(journalRequest)) ?? []
|
||||
var journalArray: [[String: Any]] = []
|
||||
for entry in allJournalEntries {
|
||||
guard let monthKey = entry.monthKey, !monthKey.isEmpty else { continue }
|
||||
var entryDict: [String: Any] = ["monthKey": monthKey]
|
||||
if let note = entry.note, !note.isEmpty {
|
||||
entryDict["note"] = note
|
||||
}
|
||||
if let mood = entry.moodRaw, !mood.isEmpty {
|
||||
entryDict["mood"] = mood
|
||||
}
|
||||
if entry.rating > 0 {
|
||||
entryDict["rating"] = Int(entry.rating)
|
||||
}
|
||||
if let completionTime = entry.completionTime {
|
||||
entryDict["completionTime"] = isoFormatter.string(from: completionTime)
|
||||
}
|
||||
if let createdAt = entry.createdAt {
|
||||
entryDict["createdAt"] = isoFormatter.string(from: createdAt)
|
||||
}
|
||||
journalArray.append(entryDict)
|
||||
}
|
||||
exportData["journalEntries"] = journalArray
|
||||
|
||||
// Add summary
|
||||
let totalValue = sources.reduce(Decimal.zero) { $0 + $1.latestValue }
|
||||
exportData["summary"] = [
|
||||
|
||||
@@ -31,6 +31,25 @@ class ImportService {
|
||||
case json
|
||||
}
|
||||
|
||||
/// Read-only summary of what an import WOULD do, computed against existing
|
||||
/// data without mutating anything. Presented to the user for confirmation
|
||||
/// before the real import runs.
|
||||
struct ImportPreview {
|
||||
var sourcesToCreate: Int = 0
|
||||
var snapshotsToCreate: Int = 0
|
||||
var snapshotsToUpdate: Int = 0
|
||||
var categoriesToCreate: Int = 0
|
||||
var goalsToCreate: Int = 0
|
||||
var journalToCreate: Int = 0
|
||||
var journalToUpdate: Int = 0
|
||||
|
||||
var hasAnything: Bool {
|
||||
sourcesToCreate > 0 || snapshotsToCreate > 0 || snapshotsToUpdate > 0 ||
|
||||
categoriesToCreate > 0 || goalsToCreate > 0 ||
|
||||
journalToCreate > 0 || journalToUpdate > 0
|
||||
}
|
||||
}
|
||||
|
||||
struct ImportedAccount {
|
||||
let name: String
|
||||
let currency: String?
|
||||
@@ -44,11 +63,14 @@ class ImportService {
|
||||
let name: String
|
||||
let colorHex: String?
|
||||
let icon: String?
|
||||
let allocationTarget: Double?
|
||||
let sources: [ImportedSource]
|
||||
}
|
||||
|
||||
struct ImportedSource {
|
||||
let name: String
|
||||
let monthlyContribution: Decimal?
|
||||
let customFrequencyMonths: Int?
|
||||
let snapshots: [ImportedSnapshot]
|
||||
}
|
||||
|
||||
@@ -59,6 +81,23 @@ class ImportService {
|
||||
let notes: String?
|
||||
}
|
||||
|
||||
struct ImportedGoal {
|
||||
let name: String
|
||||
let targetAmount: Decimal?
|
||||
let targetDate: Date?
|
||||
let isActive: Bool
|
||||
let accountName: String?
|
||||
}
|
||||
|
||||
struct ImportedJournalEntry {
|
||||
let monthKey: String
|
||||
let note: String?
|
||||
let mood: String?
|
||||
let rating: Int?
|
||||
let completionTime: Date?
|
||||
let createdAt: Date?
|
||||
}
|
||||
|
||||
func importData(
|
||||
content: String,
|
||||
format: ImportFormat,
|
||||
@@ -79,7 +118,14 @@ class ImportService {
|
||||
allowMultipleAccounts: allowMultipleAccounts,
|
||||
defaultAccountName: defaultAccountName
|
||||
)
|
||||
return applyImport(parsed, context: CoreDataStack.shared.viewContext)
|
||||
let goals = parseGoals(content)
|
||||
let journal = parseJournal(content)
|
||||
return applyImport(
|
||||
parsed,
|
||||
context: CoreDataStack.shared.viewContext,
|
||||
goals: goals,
|
||||
journal: journal
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,6 +139,8 @@ class ImportService {
|
||||
await withCheckedContinuation { continuation in
|
||||
CoreDataStack.shared.performBackgroundTask { context in
|
||||
let parsed: [ImportedAccount]
|
||||
var goals: [ImportedGoal] = []
|
||||
var journal: [ImportedJournalEntry] = []
|
||||
switch format {
|
||||
case .csv:
|
||||
parsed = self.parseCSV(
|
||||
@@ -106,6 +154,8 @@ class ImportService {
|
||||
allowMultipleAccounts: allowMultipleAccounts,
|
||||
defaultAccountName: defaultAccountName
|
||||
)
|
||||
goals = self.parseGoals(content)
|
||||
journal = self.parseJournal(content)
|
||||
}
|
||||
|
||||
let totalSnapshots = parsed.reduce(0) { total, account in
|
||||
@@ -120,7 +170,12 @@ class ImportService {
|
||||
progress(ImportProgress(completed: 0, total: totalSnapshots, message: "Importing data"))
|
||||
}
|
||||
|
||||
let result = self.applyImport(parsed, context: context) { completed in
|
||||
let result = self.applyImport(
|
||||
parsed,
|
||||
context: context,
|
||||
goals: goals,
|
||||
journal: journal
|
||||
) { completed in
|
||||
DispatchQueue.main.async {
|
||||
progress(ImportProgress(
|
||||
completed: completed,
|
||||
@@ -147,7 +202,7 @@ Personal,Real Estate,Rental Property,2024-01-01,82000,80000,Estimated value
|
||||
static func sampleJSON() -> String {
|
||||
return """
|
||||
{
|
||||
"version": 2,
|
||||
"version": 3,
|
||||
"currency": "EUR",
|
||||
"accounts": [{
|
||||
"name": "Personal",
|
||||
@@ -171,6 +226,203 @@ Personal,Real Estate,Rental Property,2024-01-01,82000,80000,Estimated value
|
||||
"""
|
||||
}
|
||||
|
||||
// MARK: - Dry-Run Preview (read-only)
|
||||
|
||||
/// Parses the content and counts what an import WOULD do against existing
|
||||
/// data, WITHOUT mutating anything. Uses the same parse functions and the
|
||||
/// same existence checks as `applyImport`, but only reads.
|
||||
func previewImportAsync(
|
||||
content: String,
|
||||
format: ImportFormat,
|
||||
allowMultipleAccounts: Bool,
|
||||
defaultAccountName: String? = nil
|
||||
) async -> ImportPreview {
|
||||
await withCheckedContinuation { continuation in
|
||||
CoreDataStack.shared.performBackgroundTask { context in
|
||||
let parsed: [ImportedAccount]
|
||||
var goals: [ImportedGoal] = []
|
||||
var journal: [ImportedJournalEntry] = []
|
||||
switch format {
|
||||
case .csv:
|
||||
parsed = self.parseCSV(
|
||||
content,
|
||||
allowMultipleAccounts: allowMultipleAccounts,
|
||||
defaultAccountName: defaultAccountName
|
||||
)
|
||||
case .json:
|
||||
parsed = self.parseJSON(
|
||||
content,
|
||||
allowMultipleAccounts: allowMultipleAccounts,
|
||||
defaultAccountName: defaultAccountName
|
||||
)
|
||||
goals = self.parseGoals(content)
|
||||
journal = self.parseJournal(content)
|
||||
}
|
||||
let preview = self.computePreview(
|
||||
accounts: parsed,
|
||||
goals: goals,
|
||||
journal: journal,
|
||||
context: context
|
||||
)
|
||||
continuation.resume(returning: preview)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Dry-run for the CSV column-mapping flow.
|
||||
func previewCSVWithMappingAsync(
|
||||
content: String,
|
||||
mapping: CSVMappingConfig,
|
||||
defaultAccountName: String?
|
||||
) async -> ImportPreview {
|
||||
await withCheckedContinuation { continuation in
|
||||
CoreDataStack.shared.performBackgroundTask { context in
|
||||
let preview = self.previewCSV(content)
|
||||
let accounts = self.parseCSVWithMapping(
|
||||
preview: preview,
|
||||
mapping: mapping,
|
||||
defaultAccountName: defaultAccountName
|
||||
)
|
||||
let result = self.computePreview(
|
||||
accounts: accounts,
|
||||
goals: [],
|
||||
journal: [],
|
||||
context: context
|
||||
)
|
||||
continuation.resume(returning: result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Read-only counting pass. Mirrors the existence checks in `applyImport`
|
||||
/// (same source name+account, same-day snapshot, category name, goal
|
||||
/// name+account, journal monthKey) but never writes. Because it never
|
||||
/// saves, it may not perfectly disambiguate multiple new sources/categories
|
||||
/// that collapse to the same name within one import; it errs toward the
|
||||
/// user-facing counts `applyImport` would produce for typical files.
|
||||
private func computePreview(
|
||||
accounts: [ImportedAccount],
|
||||
goals: [ImportedGoal],
|
||||
journal: [ImportedJournalEntry],
|
||||
context: NSManagedObjectContext
|
||||
) -> ImportPreview {
|
||||
var preview = ImportPreview()
|
||||
|
||||
let existingCategories = fetchCategories(in: context)
|
||||
let categoryLookup = buildCategoryLookup(from: existingCategories)
|
||||
let existingAccountsLookup = fetchAccountsLookup(in: context)
|
||||
|
||||
// Track names we would create so duplicates within the same import file
|
||||
// aren't double-counted.
|
||||
var plannedCategoryKeys = Set<String>()
|
||||
var plannedSourceKeys = Set<String>() // "accountName|sourceName"
|
||||
var accountsByName: [String: Account] = existingAccountsLookup
|
||||
|
||||
// "Other" is created on demand by applyImport when a source has no
|
||||
// resolvable category; only count it if we actually need it.
|
||||
var otherCategoryNeeded = false
|
||||
let otherExists = resolveExistingCategory(named: "Other", lookup: categoryLookup) != nil
|
||||
|
||||
for importedAccount in accounts {
|
||||
let account = existingAccountsLookup[importedAccount.name]
|
||||
accountsByName[importedAccount.name] = account
|
||||
|
||||
for importedCategory in importedAccount.categories {
|
||||
let existingCategory = resolveExistingCategory(
|
||||
named: importedCategory.name,
|
||||
lookup: categoryLookup
|
||||
)
|
||||
let shouldUseOther = existingCategory == nil &&
|
||||
importedCategory.colorHex == nil &&
|
||||
importedCategory.icon == nil
|
||||
|
||||
if existingCategory == nil {
|
||||
if shouldUseOther {
|
||||
otherCategoryNeeded = otherCategoryNeeded || !otherExists
|
||||
} else {
|
||||
let key = normalizedCategoryName(
|
||||
canonicalCategoryName(for: importedCategory.name) ?? importedCategory.name
|
||||
)
|
||||
if !plannedCategoryKeys.contains(key) {
|
||||
plannedCategoryKeys.insert(key)
|
||||
preview.categoriesToCreate += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for importedSource in importedCategory.sources {
|
||||
let accountId = account?.safeId
|
||||
let existingSource = fetchSource(
|
||||
named: importedSource.name,
|
||||
accountId: accountId,
|
||||
in: context
|
||||
)
|
||||
let sourceKey = "\(importedAccount.name)|\(importedSource.name)"
|
||||
if existingSource == nil, !plannedSourceKeys.contains(sourceKey) {
|
||||
plannedSourceKeys.insert(sourceKey)
|
||||
preview.sourcesToCreate += 1
|
||||
}
|
||||
|
||||
for snapshot in importedSource.snapshots {
|
||||
// Only an EXISTING source can have a same-day snapshot to update.
|
||||
if let existing = existingSource,
|
||||
fetchSnapshot(for: existing, date: snapshot.date, in: context) != nil {
|
||||
preview.snapshotsToUpdate += 1
|
||||
} else {
|
||||
preview.snapshotsToCreate += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if otherCategoryNeeded {
|
||||
preview.categoriesToCreate += 1
|
||||
}
|
||||
|
||||
// Goals — dedup by name within account (case-insensitive), same as applyImport.
|
||||
var plannedGoalKeys = Set<String>()
|
||||
for importedGoal in goals {
|
||||
let trimmedName = importedGoal.name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmedName.isEmpty, importedGoal.targetAmount != nil else { continue }
|
||||
let resolvedAccount = importedGoal.accountName.flatMap { accountsByName[$0] }
|
||||
if fetchGoal(named: trimmedName, account: resolvedAccount, in: context) == nil {
|
||||
let key = "\(trimmedName.lowercased())|\(resolvedAccount?.name ?? "")"
|
||||
if !plannedGoalKeys.contains(key) {
|
||||
plannedGoalKeys.insert(key)
|
||||
preview.goalsToCreate += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Journal — dedup by monthKey, same as applyImport.
|
||||
var plannedJournalKeys = Set<String>()
|
||||
for importedEntry in journal {
|
||||
guard !plannedJournalKeys.contains(importedEntry.monthKey) else { continue }
|
||||
plannedJournalKeys.insert(importedEntry.monthKey)
|
||||
if fetchJournalEntry(monthKey: importedEntry.monthKey, in: context) != nil {
|
||||
preview.journalToUpdate += 1
|
||||
} else {
|
||||
preview.journalToCreate += 1
|
||||
}
|
||||
}
|
||||
|
||||
return preview
|
||||
}
|
||||
|
||||
/// Read-only fetch of an existing source by name within an account (or with
|
||||
/// no account). Mirrors applyImport's `name && account?.id` match.
|
||||
private func fetchSource(
|
||||
named name: String,
|
||||
accountId: UUID?,
|
||||
in context: NSManagedObjectContext
|
||||
) -> InvestmentSource? {
|
||||
let request: NSFetchRequest<InvestmentSource> = InvestmentSource.fetchRequest()
|
||||
request.predicate = NSPredicate(format: "name == %@", name)
|
||||
let matches = (try? context.fetch(request)) ?? []
|
||||
return matches.first { $0.account?.id == accountId }
|
||||
}
|
||||
|
||||
// MARK: - Parsing
|
||||
|
||||
private func parseCSV(
|
||||
@@ -230,9 +482,20 @@ Personal,Real Estate,Rental Property,2024-01-01,82000,80000,Estimated value
|
||||
return grouped.map { accountName, categories in
|
||||
let importedCategories = categories.map { categoryName, sources in
|
||||
let importedSources = sources.map { sourceName, snapshots in
|
||||
ImportedSource(name: sourceName, snapshots: snapshots)
|
||||
ImportedSource(
|
||||
name: sourceName,
|
||||
monthlyContribution: nil,
|
||||
customFrequencyMonths: nil,
|
||||
snapshots: snapshots
|
||||
)
|
||||
}
|
||||
return ImportedCategory(name: categoryName, colorHex: nil, icon: nil, sources: importedSources)
|
||||
return ImportedCategory(
|
||||
name: categoryName,
|
||||
colorHex: nil,
|
||||
icon: nil,
|
||||
allocationTarget: nil,
|
||||
sources: importedSources
|
||||
)
|
||||
}
|
||||
|
||||
return ImportedAccount(
|
||||
@@ -274,9 +537,12 @@ Personal,Real Estate,Rental Property,2024-01-01,82000,80000,Estimated value
|
||||
let categoryName = categoryDict["name"] as? String ?? "Uncategorized"
|
||||
let colorHex = categoryDict["color"] as? String
|
||||
let icon = categoryDict["icon"] as? String
|
||||
let allocationTarget = categoryDict["allocationTarget"] as? Double
|
||||
let sourcesArray = categoryDict["sources"] as? [[String: Any]] ?? []
|
||||
let sources = sourcesArray.map { sourceDict in
|
||||
let sourceName = sourceDict["name"] as? String ?? "Source"
|
||||
let monthlyContribution = (sourceDict["monthlyContribution"] as? Double).map { Decimal($0) }
|
||||
let customFrequencyMonths = sourceDict["customFrequencyMonths"] as? Int
|
||||
let snapshotsArray = sourceDict["snapshots"] as? [[String: Any]] ?? []
|
||||
let snapshots = snapshotsArray.compactMap { snapshotDict -> ImportedSnapshot? in
|
||||
guard let dateString = snapshotDict["date"] as? String,
|
||||
@@ -295,13 +561,19 @@ Personal,Real Estate,Rental Property,2024-01-01,82000,80000,Estimated value
|
||||
)
|
||||
}
|
||||
|
||||
return ImportedSource(name: sourceName, snapshots: snapshots)
|
||||
return ImportedSource(
|
||||
name: sourceName,
|
||||
monthlyContribution: monthlyContribution,
|
||||
customFrequencyMonths: customFrequencyMonths,
|
||||
snapshots: snapshots
|
||||
)
|
||||
}
|
||||
|
||||
return ImportedCategory(
|
||||
name: categoryName,
|
||||
colorHex: colorHex,
|
||||
icon: icon,
|
||||
allocationTarget: allocationTarget,
|
||||
sources: sources
|
||||
)
|
||||
}
|
||||
@@ -344,13 +616,19 @@ Personal,Real Estate,Rental Property,2024-01-01,82000,80000,Estimated value
|
||||
)
|
||||
}
|
||||
|
||||
return ImportedSource(name: sourceName, snapshots: snapshots)
|
||||
return ImportedSource(
|
||||
name: sourceName,
|
||||
monthlyContribution: nil,
|
||||
customFrequencyMonths: nil,
|
||||
snapshots: snapshots
|
||||
)
|
||||
}
|
||||
|
||||
return ImportedCategory(
|
||||
name: categoryName,
|
||||
colorHex: colorHex,
|
||||
icon: icon,
|
||||
allocationTarget: nil,
|
||||
sources: sources
|
||||
)
|
||||
}
|
||||
@@ -371,11 +649,70 @@ Personal,Real Estate,Rental Property,2024-01-01,82000,80000,Estimated value
|
||||
return []
|
||||
}
|
||||
|
||||
private func parseGoals(_ content: String) -> [ImportedGoal] {
|
||||
guard let data = content.data(using: .utf8),
|
||||
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let goalsArray = json["goals"] as? [[String: Any]] else {
|
||||
return []
|
||||
}
|
||||
|
||||
return goalsArray.compactMap { goalDict -> ImportedGoal? in
|
||||
guard let name = goalDict["name"] as? String,
|
||||
!name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
||||
return nil
|
||||
}
|
||||
let targetAmount = (goalDict["targetAmount"] as? Double).map { Decimal($0) }
|
||||
let targetDate = (goalDict["targetDate"] as? String)
|
||||
.flatMap { ISO8601DateFormatter().date(from: $0) }
|
||||
let isActive = goalDict["isActive"] as? Bool ?? true
|
||||
let accountName = goalDict["account"] as? String
|
||||
return ImportedGoal(
|
||||
name: name,
|
||||
targetAmount: targetAmount,
|
||||
targetDate: targetDate,
|
||||
isActive: isActive,
|
||||
accountName: accountName
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func parseJournal(_ content: String) -> [ImportedJournalEntry] {
|
||||
guard let data = content.data(using: .utf8),
|
||||
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let journalArray = json["journalEntries"] as? [[String: Any]] else {
|
||||
return []
|
||||
}
|
||||
|
||||
return journalArray.compactMap { entryDict -> ImportedJournalEntry? in
|
||||
guard let monthKey = entryDict["monthKey"] as? String,
|
||||
!monthKey.isEmpty else {
|
||||
return nil
|
||||
}
|
||||
let note = entryDict["note"] as? String
|
||||
let mood = entryDict["mood"] as? String
|
||||
let rating = entryDict["rating"] as? Int
|
||||
let completionTime = (entryDict["completionTime"] as? String)
|
||||
.flatMap { ISO8601DateFormatter().date(from: $0) }
|
||||
let createdAt = (entryDict["createdAt"] as? String)
|
||||
.flatMap { ISO8601DateFormatter().date(from: $0) }
|
||||
return ImportedJournalEntry(
|
||||
monthKey: monthKey,
|
||||
note: note,
|
||||
mood: mood,
|
||||
rating: rating,
|
||||
completionTime: completionTime,
|
||||
createdAt: createdAt
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Apply Import
|
||||
|
||||
private func applyImport(
|
||||
_ accounts: [ImportedAccount],
|
||||
context: NSManagedObjectContext,
|
||||
goals: [ImportedGoal] = [],
|
||||
journal: [ImportedJournalEntry] = [],
|
||||
snapshotProgress: ((Int) -> Void)? = nil
|
||||
) -> ImportResult {
|
||||
let accountRepository = AccountRepository(context: context)
|
||||
@@ -383,6 +720,15 @@ Personal,Real Estate,Rental Property,2024-01-01,82000,80000,Estimated value
|
||||
let sourceRepository = InvestmentSourceRepository(context: context)
|
||||
let snapshotRepository = SnapshotRepository(context: context)
|
||||
|
||||
// Batch import: suppress the per-row widget refresh (each does a synchronous
|
||||
// WAL checkpoint on the main context) to avoid hanging the main thread on
|
||||
// large imports. Refresh once when everything is done.
|
||||
CoreDataStack.shared.suppressWidgetRefresh = true
|
||||
defer {
|
||||
CoreDataStack.shared.suppressWidgetRefresh = false
|
||||
CoreDataStack.shared.refreshWidgetData()
|
||||
}
|
||||
|
||||
var accountsCreated = 0
|
||||
var sourcesCreated = 0
|
||||
var snapshotsCreated = 0
|
||||
@@ -404,6 +750,8 @@ Personal,Real Estate,Rental Property,2024-01-01,82000,80000,Estimated value
|
||||
// Build lookup of existing accounts by fetching directly from database
|
||||
let existingAccountsLookup = fetchAccountsLookup(in: context)
|
||||
|
||||
var accountsByName: [String: Account] = existingAccountsLookup
|
||||
|
||||
for importedAccount in accounts {
|
||||
let existingAccount = existingAccountsLookup[importedAccount.name]
|
||||
let account: Account
|
||||
@@ -419,6 +767,7 @@ Personal,Real Estate,Rental Property,2024-01-01,82000,80000,Estimated value
|
||||
)
|
||||
accountsCreated += 1
|
||||
}
|
||||
accountsByName[importedAccount.name] = account
|
||||
|
||||
for importedCategory in importedAccount.categories {
|
||||
let existingCategory = resolveExistingCategory(
|
||||
@@ -438,6 +787,10 @@ Personal,Real Estate,Rental Property,2024-01-01,82000,80000,Estimated value
|
||||
))
|
||||
categoryLookup[normalizedCategoryName(category.name)] = category
|
||||
|
||||
if let allocationTarget = importedCategory.allocationTarget {
|
||||
category.allocationTarget = NSNumber(value: allocationTarget)
|
||||
}
|
||||
|
||||
for importedSource in importedCategory.sources {
|
||||
let accountId = account.safeId
|
||||
let existingSource = sourceRepository.sources.first(where: {
|
||||
@@ -454,6 +807,13 @@ Personal,Real Estate,Rental Property,2024-01-01,82000,80000,Estimated value
|
||||
sourcesCreated += 1
|
||||
}
|
||||
|
||||
if let monthlyContribution = importedSource.monthlyContribution {
|
||||
source.monthlyContribution = NSDecimalNumber(decimal: monthlyContribution)
|
||||
}
|
||||
if let customFrequencyMonths = importedSource.customFrequencyMonths {
|
||||
source.customFrequencyMonths = Int16(customFrequencyMonths)
|
||||
}
|
||||
|
||||
for snapshot in importedSource.snapshots {
|
||||
// Check if a snapshot with the same date already exists for this source
|
||||
let existingSnapshot = fetchSnapshot(for: source, date: snapshot.date, in: context)
|
||||
@@ -475,7 +835,8 @@ Personal,Real Estate,Rental Property,2024-01-01,82000,80000,Estimated value
|
||||
date: snapshot.date,
|
||||
value: snapshot.value,
|
||||
contribution: snapshot.contribution,
|
||||
notes: snapshot.notes
|
||||
notes: snapshot.notes,
|
||||
batch: true
|
||||
)
|
||||
snapshotsCreated += 1
|
||||
}
|
||||
@@ -494,6 +855,47 @@ Personal,Real Estate,Rental Property,2024-01-01,82000,80000,Estimated value
|
||||
}
|
||||
}
|
||||
|
||||
// Goals: create directly on the background context. Dedup by name within
|
||||
// the same account (case-insensitive), mirroring GoalRepository behaviour.
|
||||
for importedGoal in goals {
|
||||
let trimmedName = importedGoal.name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmedName.isEmpty else { continue }
|
||||
guard let targetAmount = importedGoal.targetAmount else { continue }
|
||||
|
||||
let resolvedAccount = importedGoal.accountName.flatMap { accountsByName[$0] }
|
||||
if fetchGoal(named: trimmedName, account: resolvedAccount, in: context) == nil {
|
||||
let goal = Goal(context: context)
|
||||
goal.name = trimmedName
|
||||
goal.targetAmount = NSDecimalNumber(decimal: targetAmount)
|
||||
goal.targetDate = importedGoal.targetDate
|
||||
goal.account = resolvedAccount
|
||||
goal.isActive = importedGoal.isActive
|
||||
}
|
||||
}
|
||||
|
||||
// Journal entries: create directly on the background context. Dedup by
|
||||
// monthKey; update fields when an entry already exists.
|
||||
for importedEntry in journal {
|
||||
let entry = fetchJournalEntry(monthKey: importedEntry.monthKey, in: context)
|
||||
?? JournalEntry(context: context)
|
||||
entry.monthKey = importedEntry.monthKey
|
||||
if let note = importedEntry.note, !note.isEmpty {
|
||||
entry.note = note
|
||||
}
|
||||
if let mood = importedEntry.mood, !mood.isEmpty {
|
||||
entry.moodRaw = mood
|
||||
}
|
||||
if let rating = importedEntry.rating {
|
||||
entry.rating = Int16(rating)
|
||||
}
|
||||
if let completionTime = importedEntry.completionTime {
|
||||
entry.completionTime = completionTime
|
||||
}
|
||||
if let createdAt = importedEntry.createdAt {
|
||||
entry.createdAt = createdAt
|
||||
}
|
||||
}
|
||||
|
||||
if context.hasChanges {
|
||||
do {
|
||||
try context.save()
|
||||
@@ -502,12 +904,26 @@ Personal,Real Estate,Rental Property,2024-01-01,82000,80000,Estimated value
|
||||
}
|
||||
}
|
||||
|
||||
if !completionDatesByMonth.isEmpty {
|
||||
for (monthKey, completionDate) in completionDatesByMonth {
|
||||
// Snapshot-derived completion marking is a FALLBACK for imports that carry no
|
||||
// journal (CSV, or JSON without journalEntries): it marks months that have
|
||||
// snapshots as "checked in" so the Home card looks right. When the import DOES
|
||||
// bring journal entries, they already carry their real completionTime and were
|
||||
// created on the background context above — running setCompletionDate here (on
|
||||
// viewContext, which hasn't merged those yet) would create DUPLICATE
|
||||
// JournalEntry rows for the same month. So skip it when journal was imported.
|
||||
if journal.isEmpty, !completionDatesByMonth.isEmpty {
|
||||
// MonthlyCheckInStore mutates CoreDataStack.shared.viewContext (main queue).
|
||||
// applyImport runs on a BACKGROUND context, so calling setCompletionDate
|
||||
// here directly was a Core Data concurrency violation → the imported months'
|
||||
// JournalEntry.completionTime never persisted, leaving the "next check-in"
|
||||
// stuck on an old entry. Run it on the viewContext's own queue.
|
||||
CoreDataStack.shared.viewContext.performAndWait {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "yyyy-MM"
|
||||
if let monthDate = formatter.date(from: monthKey) {
|
||||
MonthlyCheckInStore.setCompletionDate(completionDate, for: monthDate)
|
||||
for (monthKey, completionDate) in completionDatesByMonth {
|
||||
if let monthDate = formatter.date(from: monthKey) {
|
||||
MonthlyCheckInStore.setCompletionDate(completionDate, for: monthDate)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -586,6 +1002,37 @@ Personal,Real Estate,Rental Property,2024-01-01,82000,80000,Estimated value
|
||||
return try? context.fetch(request).first
|
||||
}
|
||||
|
||||
/// Fetch an existing goal by name (case-insensitive) within the same account.
|
||||
private func fetchGoal(
|
||||
named name: String,
|
||||
account: Account?,
|
||||
in context: NSManagedObjectContext
|
||||
) -> Goal? {
|
||||
let request: NSFetchRequest<Goal> = Goal.fetchRequest()
|
||||
if let account = account {
|
||||
request.predicate = NSPredicate(
|
||||
format: "name ==[c] %@ AND account == %@", name, account
|
||||
)
|
||||
} else {
|
||||
request.predicate = NSPredicate(
|
||||
format: "name ==[c] %@ AND account == nil", name
|
||||
)
|
||||
}
|
||||
request.fetchLimit = 1
|
||||
return try? context.fetch(request).first
|
||||
}
|
||||
|
||||
/// Fetch an existing journal entry by monthKey.
|
||||
private func fetchJournalEntry(
|
||||
monthKey: String,
|
||||
in context: NSManagedObjectContext
|
||||
) -> JournalEntry? {
|
||||
let request: NSFetchRequest<JournalEntry> = JournalEntry.fetchRequest()
|
||||
request.predicate = NSPredicate(format: "monthKey == %@", monthKey)
|
||||
request.fetchLimit = 1
|
||||
return try? context.fetch(request).first
|
||||
}
|
||||
|
||||
private func canonicalCategoryName(for rawName: String) -> String? {
|
||||
let normalized = normalizedCategoryName(rawName)
|
||||
for mapping in categoryAliasMappings {
|
||||
@@ -818,9 +1265,20 @@ Personal,Real Estate,Rental Property,2024-01-01,82000,80000,Estimated value
|
||||
return grouped.map { accountName, categories in
|
||||
let importedCategories = categories.map { categoryName, sources in
|
||||
let importedSources = sources.map { sourceName, snapshots in
|
||||
ImportedSource(name: sourceName, snapshots: snapshots)
|
||||
ImportedSource(
|
||||
name: sourceName,
|
||||
monthlyContribution: nil,
|
||||
customFrequencyMonths: nil,
|
||||
snapshots: snapshots
|
||||
)
|
||||
}
|
||||
return ImportedCategory(name: categoryName, colorHex: nil, icon: nil, sources: importedSources)
|
||||
return ImportedCategory(
|
||||
name: categoryName,
|
||||
colorHex: nil,
|
||||
icon: nil,
|
||||
allocationTarget: nil,
|
||||
sources: importedSources
|
||||
)
|
||||
}
|
||||
return ImportedAccount(
|
||||
name: accountName,
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
import SwiftUI
|
||||
|
||||
/// On-device, privacy-friendly rule-based insights engine.
|
||||
///
|
||||
/// Produces a prioritized list of `PortfolioInsight`s purely from local data —
|
||||
/// no network, no AI calls. The `DashboardViewModel` gathers the inputs from
|
||||
/// Core Data and passes them in; this keeps the rules pure and testable.
|
||||
enum InsightsEngine {
|
||||
|
||||
/// One category's allocation snapshot for the allocation-vs-target rule.
|
||||
struct CategorySlice {
|
||||
let name: String
|
||||
let percentageOfPortfolio: Double
|
||||
/// User's target allocation percentage, if set.
|
||||
let targetPercentage: Double?
|
||||
/// Trailing return percentage over the analysed window, if computable.
|
||||
let returnPercentage: Double?
|
||||
}
|
||||
|
||||
/// One source's weight for the concentration-risk rule.
|
||||
struct SourceSlice {
|
||||
let name: String
|
||||
let percentageOfPortfolio: Double
|
||||
}
|
||||
|
||||
struct Input {
|
||||
let totalValue: Decimal
|
||||
let categories: [CategorySlice]
|
||||
let sources: [SourceSlice]
|
||||
/// Number of categories that actually hold value.
|
||||
let fundedCategoryCount: Int
|
||||
/// Number of sources that actually hold value.
|
||||
let fundedSourceCount: Int
|
||||
let yearChangePercentage: Double
|
||||
let allTimeReturn: Decimal
|
||||
let updateStreak: Int
|
||||
let forecast: PortfolioForecast?
|
||||
}
|
||||
|
||||
// Thresholds
|
||||
private static let concentrationThreshold = 0.40 // one source > 40% of portfolio
|
||||
private static let allocationDriftThreshold = 5.0 // > 5 percentage points off target
|
||||
private static let milestoneNearThreshold = 0.90 // within 10% of next milestone
|
||||
|
||||
static func makeInsights(_ input: Input, limit: Int = 4) -> [PortfolioInsight] {
|
||||
guard input.totalValue > 0 else { return [] }
|
||||
var result: [PortfolioInsight] = []
|
||||
|
||||
result.append(contentsOf: milestoneInsight(input))
|
||||
result.append(contentsOf: allocationDriftInsight(input))
|
||||
result.append(contentsOf: concentrationInsight(input))
|
||||
result.append(contentsOf: performerInsights(input))
|
||||
result.append(contentsOf: diversificationInsight(input))
|
||||
result.append(contentsOf: streakInsight(input))
|
||||
result.append(contentsOf: ytdInsight(input))
|
||||
result.append(contentsOf: marketGainsInsight(input))
|
||||
result.append(contentsOf: forecastInsight(input))
|
||||
|
||||
// Prioritize: attention first, then positive, then neutral; stable within tone.
|
||||
let sorted = result.enumerated().sorted { lhs, rhs in
|
||||
if lhs.element.tone.priority != rhs.element.tone.priority {
|
||||
return lhs.element.tone.priority > rhs.element.tone.priority
|
||||
}
|
||||
return lhs.offset < rhs.offset
|
||||
}.map(\.element)
|
||||
|
||||
return Array(sorted.prefix(limit))
|
||||
}
|
||||
|
||||
// MARK: - Rules
|
||||
|
||||
private static func milestoneInsight(_ input: Input) -> [PortfolioInsight] {
|
||||
let milestones: [Decimal] = [1000, 2500, 5000, 10000, 25000, 50000,
|
||||
100000, 250000, 500000, 1_000_000,
|
||||
2_500_000, 5_000_000, 10_000_000]
|
||||
guard let next = milestones.first(where: { $0 > input.totalValue }) else { return [] }
|
||||
let pct = NSDecimalNumber(decimal: input.totalValue / next).doubleValue
|
||||
guard pct >= milestoneNearThreshold else { return [] }
|
||||
let gap = next - input.totalValue
|
||||
let gapStr = CurrencyFormatter.format(gap, style: .currency, maximumFractionDigits: 0)
|
||||
let msStr = CurrencyFormatter.format(next, style: .currency, maximumFractionDigits: 0)
|
||||
return [PortfolioInsight(
|
||||
id: "milestone",
|
||||
systemImage: "flag.checkered",
|
||||
title: String(localized: "insight_milestone_title"),
|
||||
value: String(format: String(localized: "insight_milestone_value"), gapStr, msStr),
|
||||
accentColor: .orange,
|
||||
tone: .positive
|
||||
)]
|
||||
}
|
||||
|
||||
private static func allocationDriftInsight(_ input: Input) -> [PortfolioInsight] {
|
||||
// Pick the category most off its target (over or under).
|
||||
let drifts = input.categories.compactMap { cat -> (CategorySlice, Double)? in
|
||||
guard let target = cat.targetPercentage, target > 0 else { return nil }
|
||||
return (cat, cat.percentageOfPortfolio - target)
|
||||
}
|
||||
guard let (cat, drift) = drifts.max(by: { abs($0.1) < abs($1.1) }),
|
||||
abs(drift) >= allocationDriftThreshold,
|
||||
let target = cat.targetPercentage else { return [] }
|
||||
|
||||
let over = drift > 0
|
||||
let value = String(
|
||||
format: String(localized: over ? "insight_allocation_over_value" : "insight_allocation_under_value"),
|
||||
cat.name,
|
||||
cat.percentageOfPortfolio,
|
||||
target
|
||||
)
|
||||
return [PortfolioInsight(
|
||||
id: "allocation_\(cat.name)",
|
||||
systemImage: over ? "arrow.up.forward.circle.fill" : "arrow.down.forward.circle.fill",
|
||||
title: String(localized: "insight_allocation_title"),
|
||||
value: value,
|
||||
accentColor: .appWarning,
|
||||
detail: String(localized: "insight_allocation_detail"),
|
||||
tone: .attention
|
||||
)]
|
||||
}
|
||||
|
||||
private static func concentrationInsight(_ input: Input) -> [PortfolioInsight] {
|
||||
guard let top = input.sources.max(by: { $0.percentageOfPortfolio < $1.percentageOfPortfolio }),
|
||||
top.percentageOfPortfolio >= concentrationThreshold * 100,
|
||||
input.fundedSourceCount >= 2 else { return [] }
|
||||
return [PortfolioInsight(
|
||||
id: "concentration",
|
||||
systemImage: "exclamationmark.triangle.fill",
|
||||
title: String(localized: "insight_concentration_title"),
|
||||
value: String(format: String(localized: "insight_concentration_value"), top.name, top.percentageOfPortfolio),
|
||||
accentColor: .appWarning,
|
||||
detail: String(localized: "insight_concentration_detail"),
|
||||
tone: .attention
|
||||
)]
|
||||
}
|
||||
|
||||
private static func performerInsights(_ input: Input) -> [PortfolioInsight] {
|
||||
let ranked = input.categories.compactMap { cat -> (CategorySlice, Double)? in
|
||||
guard let ret = cat.returnPercentage else { return nil }
|
||||
return (cat, ret)
|
||||
}
|
||||
guard ranked.count >= 2 else { return [] }
|
||||
var out: [PortfolioInsight] = []
|
||||
|
||||
if let best = ranked.max(by: { $0.1 < $1.1 }), best.1 > 0 {
|
||||
out.append(PortfolioInsight(
|
||||
id: "best_performer",
|
||||
systemImage: "trophy.fill",
|
||||
title: String(localized: "insight_best_performer_title"),
|
||||
value: String(format: String(localized: "insight_performer_value"), best.0.name, best.1),
|
||||
accentColor: .positiveGreen,
|
||||
tone: .positive
|
||||
))
|
||||
}
|
||||
if let worst = ranked.min(by: { $0.1 < $1.1 }), worst.1 < 0 {
|
||||
out.append(PortfolioInsight(
|
||||
id: "worst_performer",
|
||||
systemImage: "chart.line.downtrend.xyaxis",
|
||||
title: String(localized: "insight_worst_performer_title"),
|
||||
value: String(format: String(localized: "insight_performer_value"), worst.0.name, worst.1),
|
||||
accentColor: .negativeRed,
|
||||
tone: .attention
|
||||
))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
private static func diversificationInsight(_ input: Input) -> [PortfolioInsight] {
|
||||
// Encourage diversification only when the portfolio is thin.
|
||||
if input.fundedSourceCount == 1 {
|
||||
return [PortfolioInsight(
|
||||
id: "diversification",
|
||||
systemImage: "square.grid.2x2",
|
||||
title: String(localized: "insight_diversification_title"),
|
||||
value: String(localized: "insight_diversification_single_value"),
|
||||
accentColor: .appPrimary,
|
||||
tone: .neutral
|
||||
)]
|
||||
}
|
||||
if input.fundedCategoryCount >= 4 {
|
||||
return [PortfolioInsight(
|
||||
id: "diversification",
|
||||
systemImage: "square.grid.2x2.fill",
|
||||
title: String(localized: "insight_diversification_title"),
|
||||
value: String(format: String(localized: "insight_diversification_good_value"), input.fundedCategoryCount),
|
||||
accentColor: .positiveGreen,
|
||||
tone: .positive
|
||||
)]
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
private static func streakInsight(_ input: Input) -> [PortfolioInsight] {
|
||||
guard input.updateStreak >= 3 else { return [] }
|
||||
return [PortfolioInsight(
|
||||
id: "streak",
|
||||
systemImage: "flame.fill",
|
||||
title: String(localized: "insight_streak_title"),
|
||||
value: String(format: String(localized: "insight_streak_value"), input.updateStreak),
|
||||
accentColor: .orange,
|
||||
tone: .positive
|
||||
)]
|
||||
}
|
||||
|
||||
private static func ytdInsight(_ input: Input) -> [PortfolioInsight] {
|
||||
let ytd = input.yearChangePercentage
|
||||
guard abs(ytd) >= 0.1 else { return [] }
|
||||
let icon = ytd >= 0 ? "arrow.up.right.circle.fill" : "arrow.down.right.circle.fill"
|
||||
return [PortfolioInsight(
|
||||
id: "ytd",
|
||||
systemImage: icon,
|
||||
title: String(localized: "insight_ytd_title"),
|
||||
value: String(format: "%+.1f%%", ytd),
|
||||
accentColor: ytd >= 0 ? .positiveGreen : .negativeRed,
|
||||
tone: ytd >= 0 ? .positive : .neutral
|
||||
)]
|
||||
}
|
||||
|
||||
private static func marketGainsInsight(_ input: Input) -> [PortfolioInsight] {
|
||||
guard input.allTimeReturn > 0 else { return [] }
|
||||
let gainStr = CurrencyFormatter.format(input.allTimeReturn, style: .currency, maximumFractionDigits: 0)
|
||||
return [PortfolioInsight(
|
||||
id: "market_gains",
|
||||
systemImage: "chart.line.uptrend.xyaxis",
|
||||
title: String(localized: "insight_market_gains_title"),
|
||||
value: String(format: String(localized: "insight_market_gains_value"), gainStr),
|
||||
accentColor: .appPrimary,
|
||||
tone: .positive
|
||||
)]
|
||||
}
|
||||
|
||||
private static func forecastInsight(_ input: Input) -> [PortfolioInsight] {
|
||||
guard let forecast = input.forecast, forecast.forecastValue > input.totalValue else { return [] }
|
||||
return [PortfolioInsight(
|
||||
id: "forecast",
|
||||
systemImage: "wand.and.stars",
|
||||
title: String(localized: "insight_forecast_title"),
|
||||
value: "\(forecast.formattedForecastValue) · \(forecast.formattedForecastDate)",
|
||||
accentColor: .purple,
|
||||
tone: .neutral
|
||||
)]
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import Foundation
|
||||
import Combine
|
||||
import CoreData
|
||||
import UserNotifications
|
||||
import UIKit
|
||||
|
||||
@@ -192,6 +193,7 @@ extension Notification.Name {
|
||||
static let openBatchUpdate = Notification.Name("openBatchUpdate")
|
||||
static let openDashboard = Notification.Name("openDashboard")
|
||||
static let openQuickUpdate = Notification.Name("openQuickUpdate")
|
||||
static let openAddFirstSource = Notification.Name("openAddFirstSource")
|
||||
}
|
||||
|
||||
// MARK: - Re-engagement Notifications
|
||||
@@ -199,15 +201,28 @@ extension Notification.Name {
|
||||
extension NotificationService {
|
||||
/// Schedules a re-engagement notification 7 days from now.
|
||||
/// Call this every time the app becomes active to reset the timer.
|
||||
///
|
||||
/// Instead of a fixed generic message, the body is built from the user's real
|
||||
/// KPIs (goal ETA, YoY, monthly gain, milestone proximity, update streak) to
|
||||
/// spark curiosity. Among the hooks that currently apply we rotate one per call
|
||||
/// so the copy stays fresh instead of repeating the same line every week.
|
||||
func scheduleReEngagementNotification() {
|
||||
guard isAuthorized else { return }
|
||||
|
||||
center.removePendingNotificationRequests(withIdentifiers: ["re_engagement"])
|
||||
|
||||
let content = UNMutableNotificationContent()
|
||||
content.title = String(localized: "reengagement_title")
|
||||
content.body = String(localized: "reengagement_body")
|
||||
let hooks = buildReEngagementHooks()
|
||||
if let hook = hooks.isEmpty ? nil : hooks[nextReEngagementRotationIndex(count: hooks.count)] {
|
||||
content.title = hook.title
|
||||
content.body = hook.body
|
||||
} else {
|
||||
// No data yet (brand-new / empty portfolio) → generic fallback.
|
||||
content.title = String(localized: "reengagement_title")
|
||||
content.body = String(localized: "reengagement_body")
|
||||
}
|
||||
content.sound = .default
|
||||
content.userInfo = ["action": "openDashboard"]
|
||||
|
||||
let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 7 * 24 * 3600, repeats: false)
|
||||
let request = UNNotificationRequest(identifier: "re_engagement", content: content, trigger: trigger)
|
||||
@@ -219,6 +234,16 @@ extension NotificationService {
|
||||
}
|
||||
}
|
||||
|
||||
/// Advances and returns the rotation index over the eligible hooks so a different
|
||||
/// one surfaces each time we (re)schedule.
|
||||
private func nextReEngagementRotationIndex(count: Int) -> Int {
|
||||
guard count > 0 else { return 0 }
|
||||
let key = "reengagementRotationIndex"
|
||||
let stored = UserDefaults.standard.integer(forKey: key)
|
||||
UserDefaults.standard.set(stored &+ 1, forKey: key)
|
||||
return ((stored % count) + count) % count
|
||||
}
|
||||
|
||||
/// Schedules a non-repeating monthly check-in notification for the 1st of the next month
|
||||
/// that doesn't have a completed check-in. Safe to call on every app activation.
|
||||
func scheduleMonthlyCheckIn() {
|
||||
@@ -385,6 +410,191 @@ extension NotificationService {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Re-engagement KPI copy
|
||||
|
||||
private extension NotificationService {
|
||||
|
||||
struct ReEngagementHook {
|
||||
let title: String
|
||||
let body: String
|
||||
}
|
||||
|
||||
static let milestones: [Decimal] = [1000, 2500, 5000, 10000, 25000, 50000,
|
||||
100000, 250000, 500000, 1_000_000]
|
||||
|
||||
static let monthYearFormatter: DateFormatter = {
|
||||
let f = DateFormatter()
|
||||
f.setLocalizedDateFormatFromTemplate("MMMM yyyy")
|
||||
return f
|
||||
}()
|
||||
|
||||
static let percentFormatter: NumberFormatter = {
|
||||
let f = NumberFormatter()
|
||||
f.numberStyle = .decimal
|
||||
f.minimumFractionDigits = 0
|
||||
f.maximumFractionDigits = 1
|
||||
return f
|
||||
}()
|
||||
|
||||
/// Builds the list of KPI-driven hooks that currently apply. A neutral
|
||||
/// curiosity hook is always appended (when the user has data) so the message
|
||||
/// never has to fall back to the bland generic copy and never states a loss.
|
||||
func buildReEngagementHooks() -> [ReEngagementHook] {
|
||||
let ctx = CoreDataStack.shared.viewContext
|
||||
|
||||
let sourceReq: NSFetchRequest<InvestmentSource> = InvestmentSource.fetchRequest()
|
||||
sourceReq.predicate = NSPredicate(format: "isActive == YES")
|
||||
let sources = (try? ctx.fetch(sourceReq)) ?? []
|
||||
guard !sources.isEmpty else { return [] }
|
||||
|
||||
let summary = CalculationService.shared.calculatePortfolioSummary(from: sources, snapshots: [])
|
||||
let evolution = monthlyEvolution(from: sources)
|
||||
|
||||
var hooks: [ReEngagementHook] = []
|
||||
|
||||
// 1. Goal ETA — "at this pace you'll reach '<goal>' by <month year>"
|
||||
if let goalHook = goalEtaHook(ctx: ctx, sources: sources) {
|
||||
hooks.append(goalHook)
|
||||
}
|
||||
|
||||
// 2. Year-over-year growth
|
||||
if summary.yearChangePercentage >= 5,
|
||||
let pct = Self.percentFormatter.string(from: NSNumber(value: summary.yearChangePercentage)) {
|
||||
hooks.append(ReEngagementHook(
|
||||
title: String(localized: "reengagement_yoy_title"),
|
||||
body: String(format: String(localized: "reengagement_yoy_body"), "\(pct)%")))
|
||||
}
|
||||
|
||||
// 3. Positive monthly change
|
||||
if summary.monthChange > 0 {
|
||||
let amount = CurrencyFormatter.format(summary.monthChange, style: .currency, maximumFractionDigits: 0)
|
||||
hooks.append(ReEngagementHook(
|
||||
title: String(localized: "reengagement_month_title"),
|
||||
body: String(format: String(localized: "reengagement_month_body"), amount)))
|
||||
}
|
||||
|
||||
// 4. Close to the next round milestone (within 10% below it)
|
||||
if let msHook = milestoneProximityHook(totalValue: summary.totalValue) {
|
||||
hooks.append(msHook)
|
||||
}
|
||||
|
||||
// 5. Update streak of 3+ consecutive months
|
||||
if let streak = monthlyStreak(evolution: evolution), streak >= 3 {
|
||||
hooks.append(ReEngagementHook(
|
||||
title: String(localized: "reengagement_streak_title"),
|
||||
body: String(format: String(localized: "reengagement_streak_body"), streak)))
|
||||
}
|
||||
|
||||
// 6. Neutral curiosity — always available, never states a loss.
|
||||
hooks.append(ReEngagementHook(
|
||||
title: String(localized: "reengagement_neutral_title"),
|
||||
body: String(localized: "reengagement_neutral_body")))
|
||||
|
||||
return hooks
|
||||
}
|
||||
|
||||
/// Portfolio total at the end of each month that has any snapshot, ascending.
|
||||
func monthlyEvolution(from sources: [InvestmentSource]) -> [(date: Date, value: Decimal)] {
|
||||
let cal = Calendar.current
|
||||
var monthStarts = Set<Date>()
|
||||
for source in sources {
|
||||
for snap in source.sortedSnapshotsByDateAscending {
|
||||
if let m = cal.date(from: cal.dateComponents([.year, .month], from: snap.date)) {
|
||||
monthStarts.insert(m)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return monthStarts.sorted().map { monthStart in
|
||||
let monthEnd = cal.date(byAdding: DateComponents(month: 1, day: -1), to: monthStart) ?? monthStart
|
||||
var total = Decimal.zero
|
||||
for source in sources {
|
||||
if let snap = source.sortedSnapshotsByDateAscending.last(where: { $0.date <= monthEnd }) {
|
||||
total += snap.decimalValue
|
||||
}
|
||||
}
|
||||
return (monthStart, total)
|
||||
}
|
||||
}
|
||||
|
||||
/// Projects the first active goal reachable on the current 6-month trend and
|
||||
/// returns a hook naming its ETA. Mirrors GoalsViewModel.estimateCompletionDate.
|
||||
func goalEtaHook(ctx: NSManagedObjectContext, sources: [InvestmentSource]) -> ReEngagementHook? {
|
||||
let req: NSFetchRequest<Goal> = Goal.fetchRequest()
|
||||
req.predicate = NSPredicate(format: "isActive == YES")
|
||||
let goals = (try? ctx.fetch(req)) ?? []
|
||||
guard !goals.isEmpty else { return nil }
|
||||
|
||||
let cal = Calendar.current
|
||||
for goal in goals {
|
||||
let goalSources: [InvestmentSource]
|
||||
if let accId = goal.account?.id {
|
||||
goalSources = sources.filter { $0.account?.id == accId }
|
||||
} else {
|
||||
goalSources = sources
|
||||
}
|
||||
|
||||
let evolution = monthlyEvolution(from: goalSources)
|
||||
guard evolution.count >= 3,
|
||||
let first = evolution.suffix(6).first,
|
||||
let last = evolution.suffix(6).last else { continue }
|
||||
|
||||
let monthsBetween = max(1, first.date.monthsBetween(last.date))
|
||||
let delta = last.value - first.value
|
||||
guard delta > 0 else { continue }
|
||||
let monthlyGain = delta / Decimal(monthsBetween)
|
||||
guard monthlyGain > 0 else { continue }
|
||||
|
||||
let remaining = goal.targetDecimal - last.value
|
||||
guard remaining > 0 else { continue } // already reached → skip
|
||||
let monthsToGo = Int(ceil(NSDecimalNumber(decimal: remaining / monthlyGain).doubleValue))
|
||||
guard monthsToGo > 0, monthsToGo <= 600,
|
||||
let eta = cal.date(byAdding: .month, value: monthsToGo, to: last.date) else { continue }
|
||||
|
||||
let etaStr = Self.monthYearFormatter.string(from: eta)
|
||||
return ReEngagementHook(
|
||||
title: String(localized: "reengagement_goal_title"),
|
||||
body: String(format: String(localized: "reengagement_goal_body"), goal.name, etaStr))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Hook when the portfolio is within 10% below the next round milestone.
|
||||
func milestoneProximityHook(totalValue: Decimal) -> ReEngagementHook? {
|
||||
let current = NSDecimalNumber(decimal: totalValue).doubleValue
|
||||
guard current > 0 else { return nil }
|
||||
|
||||
for milestone in Self.milestones {
|
||||
let target = NSDecimalNumber(decimal: milestone).doubleValue
|
||||
if current < target {
|
||||
guard current >= target * 0.9 else { return nil } // next one up is too far
|
||||
let msStr = CurrencyFormatter.format(milestone, style: .currency, maximumFractionDigits: 0)
|
||||
return ReEngagementHook(
|
||||
title: String(localized: "reengagement_milestone_title"),
|
||||
body: String(format: String(localized: "reengagement_milestone_body"), msStr))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Number of consecutive months (ending at the most recent one with data) that
|
||||
/// have a snapshot. `evolution` already holds one entry per month.
|
||||
func monthlyStreak(evolution: [(date: Date, value: Decimal)]) -> Int? {
|
||||
guard !evolution.isEmpty else { return nil }
|
||||
let cal = Calendar.current
|
||||
let months = Set(evolution.map { $0.date })
|
||||
guard var cursor = months.max() else { return nil }
|
||||
|
||||
var count = 0
|
||||
while months.contains(cursor) {
|
||||
count += 1
|
||||
guard let prev = cal.date(byAdding: .month, value: -1, to: cursor) else { break }
|
||||
cursor = prev
|
||||
}
|
||||
return count
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Background Refresh
|
||||
|
||||
extension NotificationService {
|
||||
|
||||
@@ -6,8 +6,60 @@ import LinkPresentation
|
||||
class ShareService {
|
||||
static let shared = ShareService()
|
||||
|
||||
/// Attributed App Store link for shared cards (ct= shows up in App
|
||||
/// Analytics as a campaign, so referral installs become measurable).
|
||||
static let snapshotShareURL = URL(string: "https://apps.apple.com/app/portfolio-journal-tracker/id6757678318?ct=snapshot_share")!
|
||||
|
||||
private init() {}
|
||||
|
||||
/// Extracts the "(±X.XX%)" part of a combined "±1.234 € (±X.XX%)" string.
|
||||
static func percentPart(_ s: String?) -> String? {
|
||||
guard let s else { return nil }
|
||||
guard let open = s.lastIndex(of: "("), let close = s.lastIndex(of: ")"), open < close else {
|
||||
return s
|
||||
}
|
||||
return String(s[s.index(after: open)..<close])
|
||||
}
|
||||
|
||||
struct PortfolioCardStrings {
|
||||
/// The big number (an all-time/return %) — the hero of the card.
|
||||
let heroPercent: String?
|
||||
/// Supporting absolute value (empty in percent-only mode).
|
||||
let totalValue: String
|
||||
let changeText: String
|
||||
let yearChange: String?
|
||||
let sinceInceptionChange: String?
|
||||
}
|
||||
|
||||
/// The all-time return is always the hero (branding + shareable). With
|
||||
/// `percentOnly` every absolute amount is dropped — nobody posts their net
|
||||
/// worth in euros; everybody posts +42%.
|
||||
static func portfolioCardStrings(
|
||||
totalValue: String,
|
||||
changeText: String,
|
||||
yearChange: String?,
|
||||
sinceInceptionChange: String?,
|
||||
percentOnly: Bool
|
||||
) -> PortfolioCardStrings {
|
||||
let hero = percentPart(sinceInceptionChange) ?? percentPart(changeText)
|
||||
if percentOnly {
|
||||
return PortfolioCardStrings(
|
||||
heroPercent: hero ?? totalValue,
|
||||
totalValue: "",
|
||||
changeText: percentPart(changeText) ?? changeText,
|
||||
yearChange: percentPart(yearChange),
|
||||
sinceInceptionChange: percentPart(sinceInceptionChange)
|
||||
)
|
||||
}
|
||||
return PortfolioCardStrings(
|
||||
heroPercent: hero,
|
||||
totalValue: totalValue,
|
||||
changeText: changeText,
|
||||
yearChange: yearChange,
|
||||
sinceInceptionChange: sinceInceptionChange
|
||||
)
|
||||
}
|
||||
|
||||
static func buildMonthlyCheckInShareText(summary: MonthlySummary, appName: String) -> String {
|
||||
"""
|
||||
\(summary.periodLabel) Check-in
|
||||
@@ -62,37 +114,108 @@ class ShareService {
|
||||
}
|
||||
}
|
||||
|
||||
struct MonthlySummaryShareData {
|
||||
let monthLabel: String
|
||||
let totalValue: String
|
||||
let changeAmount: String
|
||||
let changePercentage: String
|
||||
let isPositive: Bool
|
||||
let streak: Int
|
||||
let mood: MonthlyCheckInMood?
|
||||
let rating: Int?
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func shareMonthlySummary(_ data: MonthlySummaryShareData) {
|
||||
FirebaseService.shared.logShare(type: "monthly_summary")
|
||||
let appName = Self.appDisplayName
|
||||
|
||||
var lines = [
|
||||
"\(data.monthLabel) — Monthly Summary",
|
||||
"Portfolio Value: \(data.totalValue)",
|
||||
"\(data.changeAmount) (\(data.changePercentage)) since last check-in"
|
||||
]
|
||||
if data.streak >= 2 {
|
||||
lines.append("Streak: \(data.streak) months")
|
||||
}
|
||||
if let mood = data.mood {
|
||||
lines.append("Mood: \(mood.title)")
|
||||
}
|
||||
lines.append("")
|
||||
lines.append("Tracked with \(appName)")
|
||||
let fallbackText = lines.joined(separator: "\n")
|
||||
|
||||
shareCard(
|
||||
cardTitle: "\(data.monthLabel) — Monthly Summary",
|
||||
fallbackText: fallbackText
|
||||
) {
|
||||
MonthlySummaryShareView(
|
||||
monthLabel: data.monthLabel,
|
||||
totalValue: data.totalValue,
|
||||
changeAmount: data.changeAmount,
|
||||
changePercentage: data.changePercentage,
|
||||
isPositive: data.isPositive,
|
||||
streak: data.streak,
|
||||
mood: data.mood,
|
||||
rating: data.rating,
|
||||
appName: appName,
|
||||
qrCodeImage: GoalShareService.generateQRCode(for: GoalShareService.appStoreURL, size: 200)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func sharePortfolioValue(
|
||||
totalValue: String,
|
||||
changeText: String,
|
||||
changeLabel: String,
|
||||
yearChange: String?,
|
||||
sinceInceptionChange: String?
|
||||
sinceInceptionChange: String?,
|
||||
sparkline: [Double] = [],
|
||||
isPositive: Bool = true,
|
||||
asOf: String? = nil,
|
||||
theme: ShareCardTheme = .dark,
|
||||
percentOnly: Bool = false,
|
||||
storyFormat: Bool = false
|
||||
) {
|
||||
FirebaseService.shared.logShare(type: "portfolio_value")
|
||||
FirebaseService.shared.logShare(type: percentOnly ? "portfolio_value_percent" : "portfolio_value")
|
||||
let appName = Self.appDisplayName
|
||||
let text = Self.buildPortfolioValueShareText(
|
||||
let display = Self.portfolioCardStrings(
|
||||
totalValue: totalValue,
|
||||
changeText: changeText,
|
||||
changeLabel: changeLabel,
|
||||
yearChange: yearChange,
|
||||
sinceInceptionChange: sinceInceptionChange,
|
||||
percentOnly: percentOnly
|
||||
)
|
||||
// Fallback text honors the privacy choice too.
|
||||
let text = Self.buildPortfolioValueShareText(
|
||||
totalValue: display.totalValue,
|
||||
changeText: display.changeText,
|
||||
changeLabel: changeLabel,
|
||||
yearChange: display.yearChange,
|
||||
sinceInceptionChange: display.sinceInceptionChange,
|
||||
appName: appName
|
||||
)
|
||||
|
||||
shareCard(
|
||||
cardTitle: "Portfolio Snapshot",
|
||||
fallbackText: text
|
||||
fallbackText: text,
|
||||
shareURL: Self.snapshotShareURL
|
||||
) {
|
||||
PortfolioValueShareCardView(
|
||||
totalValue: totalValue,
|
||||
changeText: changeText,
|
||||
totalValue: display.totalValue,
|
||||
changeText: display.changeText,
|
||||
changeLabel: changeLabel,
|
||||
yearChange: yearChange,
|
||||
sinceInceptionChange: sinceInceptionChange,
|
||||
yearChange: display.yearChange,
|
||||
sinceInceptionChange: display.sinceInceptionChange,
|
||||
appName: appName,
|
||||
qrCodeImage: GoalShareService.generateQRCode(for: GoalShareService.appStoreURL, size: 200)
|
||||
qrCodeImage: GoalShareService.generateQRCode(for: Self.snapshotShareURL, size: 200),
|
||||
sparkline: sparkline,
|
||||
isPositive: isPositive,
|
||||
heroPercent: display.heroPercent,
|
||||
asOf: asOf,
|
||||
theme: theme,
|
||||
storyFormat: storyFormat
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -124,10 +247,10 @@ class ShareService {
|
||||
private func shareCard<Content: View>(
|
||||
cardTitle: String,
|
||||
fallbackText: String,
|
||||
shareURL: URL = GoalShareService.appStoreURL,
|
||||
@ViewBuilder card: () -> Content
|
||||
) {
|
||||
guard let viewController = ShareService.topViewController() else { return }
|
||||
let shareURL = GoalShareService.appStoreURL
|
||||
|
||||
if #available(iOS 16.0, *) {
|
||||
let renderer = ImageRenderer(content: card())
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import SwiftUI
|
||||
import Combine
|
||||
|
||||
/// Central store for the "Hide balances" privacy feature.
|
||||
///
|
||||
/// When `balancesHidden` is true, every monetary amount in the app renders
|
||||
/// redacted (a blurred bullet mask) instead of the real value. This lets the
|
||||
/// user open the app in public or take screenshots without exposing net worth.
|
||||
///
|
||||
/// If `requireBiometricToReveal` is enabled, un-hiding balances must first pass
|
||||
/// Face ID / biometric auth (reusing `AppLockService`). On failure, balances
|
||||
/// stay hidden.
|
||||
///
|
||||
/// State is persisted in `UserDefaults` so it survives relaunches, and mirrored
|
||||
/// into `@Published` properties so SwiftUI views observing this object update
|
||||
/// instantly when the user taps the quick toggle.
|
||||
@MainActor
|
||||
final class BalancePrivacyManager: ObservableObject {
|
||||
static let shared = BalancePrivacyManager()
|
||||
|
||||
private enum Keys {
|
||||
static let balancesHidden = "balancesHidden"
|
||||
static let requireBiometricToReveal = "requireBiometricToReveal"
|
||||
}
|
||||
|
||||
/// When true, all monetary amounts render redacted.
|
||||
@Published var balancesHidden: Bool {
|
||||
didSet { UserDefaults.standard.set(balancesHidden, forKey: Keys.balancesHidden) }
|
||||
}
|
||||
|
||||
/// When true, revealing hidden balances requires biometric auth.
|
||||
@Published var requireBiometricToReveal: Bool {
|
||||
didSet { UserDefaults.standard.set(requireBiometricToReveal, forKey: Keys.requireBiometricToReveal) }
|
||||
}
|
||||
|
||||
init() {
|
||||
self.balancesHidden = UserDefaults.standard.bool(forKey: Keys.balancesHidden)
|
||||
self.requireBiometricToReveal = UserDefaults.standard.bool(forKey: Keys.requireBiometricToReveal)
|
||||
}
|
||||
|
||||
/// Toggle balance visibility. Hiding is always immediate. Revealing, when
|
||||
/// `requireBiometricToReveal` is on and biometrics are available, runs a
|
||||
/// Face ID prompt first and only reveals on success.
|
||||
func toggleHidden() {
|
||||
if balancesHidden {
|
||||
reveal()
|
||||
} else {
|
||||
balancesHidden = true
|
||||
}
|
||||
}
|
||||
|
||||
/// Attempt to reveal balances, honoring the biometric requirement.
|
||||
func reveal() {
|
||||
guard balancesHidden else { return }
|
||||
if requireBiometricToReveal && AppLockService.canUseBiometrics() {
|
||||
AppLockService.authenticate(reason: "Reveal your balances") { [weak self] success in
|
||||
if success {
|
||||
self?.balancesHidden = false
|
||||
}
|
||||
// On failure, stay hidden — do nothing.
|
||||
}
|
||||
} else {
|
||||
balancesHidden = false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import Foundation
|
||||
|
||||
/// Fills gaps between snapshots for chart display only — it never creates or
|
||||
/// mutates Core Data objects. A source that has a snapshot in February and the
|
||||
/// next in June leaves March/April/May empty; without filling, per-source and
|
||||
/// category lines dip or break. This computes a value for every month in the
|
||||
/// grid so the graphs read continuously.
|
||||
///
|
||||
/// - Internal gaps (between two known snapshots) are linearly interpolated when
|
||||
/// `interpolate` is true, or carried forward (stepped) when false.
|
||||
/// - After a source's last snapshot the value is carried forward (there is no
|
||||
/// future point to interpolate towards) — matches the portfolio total.
|
||||
/// - Before a source's first snapshot the value is `nil` (the source did not
|
||||
/// exist yet, so it must not contribute 0 and drag the total down).
|
||||
enum ChartGapFill {
|
||||
|
||||
/// The user-facing toggle (Settings). Defaults to ON so charts look right out
|
||||
/// of the box; reading via `object(forKey:)` so an unset key means enabled.
|
||||
static let defaultsKey = "smoothChartGaps"
|
||||
|
||||
static var isEnabled: Bool {
|
||||
(UserDefaults.standard.object(forKey: defaultsKey) as? Bool) ?? true
|
||||
}
|
||||
|
||||
/// For a single source: `known` maps a month index (position in the ordered
|
||||
/// month grid) to that month's value. Returns a value per month index, with
|
||||
/// internal gaps filled and a trailing carry-forward. `nil` means "absent".
|
||||
static func denseValues(
|
||||
known: [Int: Decimal],
|
||||
monthCount: Int,
|
||||
interpolate: Bool
|
||||
) -> [Decimal?] {
|
||||
var result = [Decimal?](repeating: nil, count: monthCount)
|
||||
let indices = known.keys.sorted()
|
||||
guard let first = indices.first, let last = indices.last else { return result }
|
||||
|
||||
for i in indices { result[i] = known[i] }
|
||||
|
||||
if interpolate {
|
||||
// Linear interpolation across each internal gap.
|
||||
for k in 0..<(indices.count - 1) {
|
||||
let a = indices[k]
|
||||
let b = indices[k + 1]
|
||||
guard b - a > 1, let va = known[a], let vb = known[b] else { continue }
|
||||
for i in (a + 1)..<b {
|
||||
let frac = Decimal(i - a) / Decimal(b - a)
|
||||
result[i] = va + (vb - va) * frac
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Stepped carry-forward within internal gaps.
|
||||
var lastVal = known[first] ?? .zero
|
||||
for i in first...last {
|
||||
if let v = result[i] { lastVal = v } else { result[i] = lastVal }
|
||||
}
|
||||
}
|
||||
|
||||
// Carry the last known value forward past the final snapshot.
|
||||
if let lastVal = known[last], last + 1 < monthCount {
|
||||
for i in (last + 1)..<monthCount { result[i] = lastVal }
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
}
|
||||
@@ -103,12 +103,23 @@ enum DashboardLayoutStore {
|
||||
UserDefaults.standard.removeObject(forKey: storageKey)
|
||||
}
|
||||
|
||||
/// Calm 2.0 defaults: three zones — state (total value), action (check-in),
|
||||
/// context. Redundant cards start hidden: pending updates and streaks now
|
||||
/// live inside the check-in card, period returns inside the hero. All remain
|
||||
/// available via Customize.
|
||||
private static let hiddenByDefault: Set<DashboardSection> = [
|
||||
.momentumStreaks, .pendingUpdates, .periodReturns, .monthlySummary
|
||||
]
|
||||
|
||||
private static func defaultConfigs() -> [DashboardSectionConfig] {
|
||||
DashboardSection.allCases.map { section in
|
||||
DashboardSectionConfig(
|
||||
id: section.id,
|
||||
isVisible: true,
|
||||
isCollapsed: false
|
||||
isVisible: !hiddenByDefault.contains(section),
|
||||
isCollapsed: false,
|
||||
// The check-in row is slim — full width on the iPad grid so it
|
||||
// reads as the "action" zone instead of leaving a gap cell.
|
||||
columnSpan: section == .monthlyCheckIn ? 2 : 1
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
import Foundation
|
||||
|
||||
/// Pure, dependency-free projection helper for goals.
|
||||
///
|
||||
/// Given the current portfolio value relevant to a goal (its account's sources,
|
||||
/// or the whole portfolio when the goal has no account), a monthly growth rate
|
||||
/// derived from evolution history, and the total monthly contributions of the
|
||||
/// relevant sources, it projects value forward month by month and estimates the
|
||||
/// month the goal amount is reached — plus an on-track status vs the target date.
|
||||
///
|
||||
/// Kept free of Core Data / SwiftUI so it is trivially unit-testable and reusable.
|
||||
enum GoalProjection {
|
||||
|
||||
/// On-track status of a goal relative to its target date.
|
||||
enum Status: Equatable {
|
||||
/// Reached before the target date.
|
||||
case aheadOfSchedule
|
||||
/// Reached at (or within a small window of) the target date.
|
||||
case onTrack
|
||||
/// Reached after the target date.
|
||||
case behind
|
||||
/// Never reached at the current trajectory (no growth, no contributions).
|
||||
case unreachable
|
||||
/// No target date set — only a projected date is meaningful.
|
||||
case noTargetDate
|
||||
}
|
||||
|
||||
struct Result {
|
||||
/// Projected month the goal amount is reached, or nil if unreachable.
|
||||
let projectedDate: Date?
|
||||
/// Number of months from `asOf` until the goal is reached, or nil if unreachable.
|
||||
let monthsToReach: Int?
|
||||
/// On-track status vs the target date.
|
||||
let status: Status
|
||||
/// Monthly growth rate used (fraction, e.g. 0.008 == +0.8%/mo). Zero if flat/negative.
|
||||
let monthlyGrowthRate: Double
|
||||
/// A short projection curve (value per month) for a sparkline, starting at `currentValue`.
|
||||
/// Empty when the goal is already reached or unreachable.
|
||||
let sparkline: [Decimal]
|
||||
|
||||
var isReachable: Bool { projectedDate != nil }
|
||||
}
|
||||
|
||||
/// Hard cap on how many months we project before declaring a goal unreachable
|
||||
/// at the current pace (50 years).
|
||||
private static let maxProjectionMonths = 600
|
||||
|
||||
/// Derives a robust monthly growth rate (as a fraction) from evolution history.
|
||||
///
|
||||
/// Uses the trailing window (default 6 points) and computes a monthly CAGR from
|
||||
/// first to last value. Contributions inflate raw value growth, so this rate is a
|
||||
/// blended "how fast the pot grows" figure rather than pure market return — which
|
||||
/// is the honest thing to extrapolate for an ETA. Returns 0 for flat/negative
|
||||
/// trends or insufficient data.
|
||||
static func monthlyGrowthRate(
|
||||
from evolution: [(date: Date, value: Decimal)],
|
||||
window: Int = 6
|
||||
) -> Double {
|
||||
guard evolution.count >= 3 else { return 0 }
|
||||
let recent = Array(evolution.suffix(max(2, window)))
|
||||
guard let first = recent.first, let last = recent.last else { return 0 }
|
||||
|
||||
let firstValue = NSDecimalNumber(decimal: first.value).doubleValue
|
||||
let lastValue = NSDecimalNumber(decimal: last.value).doubleValue
|
||||
guard firstValue > 0, lastValue > firstValue else { return 0 }
|
||||
|
||||
let months = max(1, first.date.monthsBetween(last.date))
|
||||
// Monthly CAGR: (last/first)^(1/months) - 1
|
||||
let ratio = lastValue / firstValue
|
||||
let rate = pow(ratio, 1.0 / Double(months)) - 1.0
|
||||
guard rate.isFinite, rate > 0 else { return 0 }
|
||||
// Clamp to a sane band to avoid absurd extrapolations from noisy data.
|
||||
return min(rate, 0.5)
|
||||
}
|
||||
|
||||
/// Projects when `targetAmount` is reached.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - currentValue: current portfolio value relevant to the goal.
|
||||
/// - targetAmount: the goal amount.
|
||||
/// - monthlyGrowthRate: fraction per month (from `monthlyGrowthRate(from:)`).
|
||||
/// - monthlyContribution: total monthly contributions of relevant sources.
|
||||
/// - targetDate: optional deadline to compare against.
|
||||
/// - asOf: base date to project from (defaults to now).
|
||||
static func project(
|
||||
currentValue: Decimal,
|
||||
targetAmount: Decimal,
|
||||
monthlyGrowthRate: Double,
|
||||
monthlyContribution: Decimal,
|
||||
targetDate: Date?,
|
||||
asOf: Date = Date()
|
||||
) -> Result {
|
||||
// Already reached.
|
||||
if currentValue >= targetAmount, targetAmount > 0 {
|
||||
return Result(
|
||||
projectedDate: asOf,
|
||||
monthsToReach: 0,
|
||||
status: targetDate == nil ? .noTargetDate : .onTrack,
|
||||
monthlyGrowthRate: max(0, monthlyGrowthRate),
|
||||
sparkline: []
|
||||
)
|
||||
}
|
||||
|
||||
let rate = max(0, monthlyGrowthRate)
|
||||
let contribution = NSDecimalNumber(decimal: max(0, monthlyContribution)).doubleValue
|
||||
var value = NSDecimalNumber(decimal: currentValue).doubleValue
|
||||
let target = NSDecimalNumber(decimal: targetAmount).doubleValue
|
||||
|
||||
// No trajectory at all → unreachable.
|
||||
if rate <= 0 && contribution <= 0 {
|
||||
return Result(
|
||||
projectedDate: nil,
|
||||
monthsToReach: nil,
|
||||
status: .unreachable,
|
||||
monthlyGrowthRate: 0,
|
||||
sparkline: []
|
||||
)
|
||||
}
|
||||
|
||||
var sparkline: [Decimal] = [Decimal(value)]
|
||||
var months = 0
|
||||
while value < target && months < maxProjectionMonths {
|
||||
value = value * (1 + rate) + contribution
|
||||
months += 1
|
||||
// Sample the sparkline sparsely (at most ~12 points).
|
||||
if months % max(1, months / 12) == 0 || value >= target {
|
||||
sparkline.append(Decimal(value))
|
||||
}
|
||||
}
|
||||
|
||||
guard value >= target else {
|
||||
return Result(
|
||||
projectedDate: nil,
|
||||
monthsToReach: nil,
|
||||
status: .unreachable,
|
||||
monthlyGrowthRate: rate,
|
||||
sparkline: []
|
||||
)
|
||||
}
|
||||
|
||||
let projectedDate = Calendar.current.date(byAdding: .month, value: months, to: asOf)
|
||||
|
||||
let status: Status
|
||||
if let targetDate {
|
||||
let projectedDay = (projectedDate ?? asOf).startOfDay
|
||||
let targetDay = targetDate.startOfDay
|
||||
let deltaDays = targetDay.daysBetween(projectedDay)
|
||||
if deltaDays <= -15 {
|
||||
status = .aheadOfSchedule
|
||||
} else if deltaDays >= 15 {
|
||||
status = .behind
|
||||
} else {
|
||||
status = .onTrack
|
||||
}
|
||||
} else {
|
||||
status = .noTargetDate
|
||||
}
|
||||
|
||||
return Result(
|
||||
projectedDate: projectedDate,
|
||||
monthsToReach: months,
|
||||
status: status,
|
||||
monthlyGrowthRate: rate,
|
||||
sparkline: sparkline
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import Foundation
|
||||
import UIKit
|
||||
import Vision
|
||||
|
||||
// MARK: - On-device amount OCR (app target)
|
||||
//
|
||||
// Recognizes the monetary amounts in a shared/picked image entirely on-device
|
||||
// (Vision), ranked by visual prominence — the balance is almost always the
|
||||
// biggest number on a bank screen. Mirrors the Share Extension's scanner so the
|
||||
// two entry points behave identically.
|
||||
|
||||
struct ScannedAmount: Identifiable, Equatable {
|
||||
let id = UUID()
|
||||
let display: String
|
||||
let value: Decimal
|
||||
let prominence: CGFloat
|
||||
|
||||
static func == (lhs: Self, rhs: Self) -> Bool { lhs.id == rhs.id }
|
||||
}
|
||||
|
||||
enum ImageAmountScanner {
|
||||
static let maxCandidates = 6
|
||||
|
||||
static func scan(_ image: UIImage, completion: @escaping ([ScannedAmount]) -> Void) {
|
||||
guard let cgImage = image.cgImage else { completion([]); return }
|
||||
let request = VNRecognizeTextRequest { request, _ in
|
||||
let obs = (request.results as? [VNRecognizedTextObservation]) ?? []
|
||||
completion(candidates(from: obs))
|
||||
}
|
||||
request.recognitionLevel = .accurate
|
||||
request.usesLanguageCorrection = false
|
||||
|
||||
DispatchQueue.global(qos: .userInitiated).async {
|
||||
let handler = VNImageRequestHandler(cgImage: cgImage, orientation: cgOrientation(from: image.imageOrientation))
|
||||
try? handler.perform([request])
|
||||
}
|
||||
}
|
||||
|
||||
private static let numberPattern: NSRegularExpression = {
|
||||
let pattern = "\\d{1,3}(?:[.,\u{00A0} ]\\d{3})+(?:[.,]\\d{1,2})?|\\d+[.,]\\d{1,2}|\\d+"
|
||||
return try! NSRegularExpression(pattern: pattern)
|
||||
}()
|
||||
|
||||
private static func candidates(from observations: [VNRecognizedTextObservation]) -> [ScannedAmount] {
|
||||
var found: [ScannedAmount] = []
|
||||
for observation in observations {
|
||||
guard let recognized = observation.topCandidates(1).first else { continue }
|
||||
let line = recognized.string
|
||||
let range = NSRange(line.startIndex..., in: line)
|
||||
for match in numberPattern.matches(in: line, range: range) {
|
||||
guard let r = Range(match.range, in: line) else { continue }
|
||||
let display = String(line[r])
|
||||
guard !isPercentage(display, in: line, at: r),
|
||||
!looksLikeYear(display),
|
||||
let value = CurrencyFormatter.parseUserInput(display), value > 0 else { continue }
|
||||
found.append(ScannedAmount(
|
||||
display: display.replacingOccurrences(of: "\u{00A0}", with: " "),
|
||||
value: value,
|
||||
prominence: observation.boundingBox.height))
|
||||
}
|
||||
}
|
||||
var byValue: [Decimal: ScannedAmount] = [:]
|
||||
for c in found {
|
||||
if let e = byValue[c.value], e.prominence >= c.prominence { continue }
|
||||
byValue[c.value] = c
|
||||
}
|
||||
return byValue.values.sorted { $0.prominence > $1.prominence }.prefix(maxCandidates).map { $0 }
|
||||
}
|
||||
|
||||
private static func isPercentage(_ display: String, in line: String, at range: Range<String.Index>) -> Bool {
|
||||
let after = line[range.upperBound...].drop(while: { $0 == " " || $0 == "\u{00A0}" })
|
||||
if after.first == "%" { return true }
|
||||
let before = line[..<range.lowerBound].reversed().drop(while: { $0 == " " || $0 == "\u{00A0}" })
|
||||
return before.first == "%"
|
||||
}
|
||||
|
||||
private static func looksLikeYear(_ display: String) -> Bool {
|
||||
guard display.count == 4, display.allSatisfy(\.isNumber), let v = Int(display) else { return false }
|
||||
return (1990...2099).contains(v)
|
||||
}
|
||||
|
||||
private static func cgOrientation(from o: UIImage.Orientation) -> CGImagePropertyOrientation {
|
||||
switch o {
|
||||
case .up: return .up
|
||||
case .down: return .down
|
||||
case .left: return .left
|
||||
case .right: return .right
|
||||
case .upMirrored: return .upMirrored
|
||||
case .downMirrored: return .downMirrored
|
||||
case .leftMirrored: return .leftMirrored
|
||||
case .rightMirrored: return .rightMirrored
|
||||
@unknown default: return .up
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import Foundation
|
||||
import CoreData
|
||||
|
||||
/// Detects INTERNAL gaps in a source's snapshot history: two consecutive
|
||||
/// snapshots more than one calendar month apart, i.e. one or more calendar
|
||||
/// months between them have no data at all.
|
||||
///
|
||||
/// This complements `ChartGapFill`, which only smooths the *display* by
|
||||
/// interpolating/carrying values across those empty months. The chart looks
|
||||
/// continuous, but real data is missing — this surfaces that so the user can
|
||||
/// fill it in. Read-only: it never mutates Core Data.
|
||||
enum SnapshotGapDetector {
|
||||
|
||||
/// A single internal gap for one source.
|
||||
struct Gap: Identifiable, Equatable {
|
||||
let sourceId: UUID
|
||||
let sourceName: String
|
||||
/// The month of the snapshot before the gap (start of that month).
|
||||
let fromMonth: Date
|
||||
/// The month of the snapshot after the gap (start of that month).
|
||||
let toMonth: Date
|
||||
/// Number of calendar months with no data between `fromMonth` and `toMonth`.
|
||||
let missingMonths: Int
|
||||
/// The individual missing months (start-of-month dates), oldest first.
|
||||
let missingMonthDates: [Date]
|
||||
|
||||
var id: String { "\(sourceId.uuidString)-\(fromMonth.timeIntervalSince1970)" }
|
||||
}
|
||||
|
||||
/// Computes all internal gaps across the provided sources' snapshots.
|
||||
/// Only months strictly between two known snapshots count — a source's
|
||||
/// trailing "not updated in a while" is handled elsewhere (pending updates).
|
||||
static func detectGaps(sources: [InvestmentSource], snapshots: [Snapshot]) -> [Gap] {
|
||||
let calendar = Calendar.current
|
||||
let snapshotsBySource = Dictionary(grouping: snapshots) { $0.source?.id }
|
||||
var gaps: [Gap] = []
|
||||
|
||||
for source in sources {
|
||||
let sourceId = source.id
|
||||
guard let sourceSnapshots = snapshotsBySource[sourceId], sourceSnapshots.count >= 2 else {
|
||||
continue
|
||||
}
|
||||
|
||||
// One value per calendar month (keep the latest in each), sorted ascending.
|
||||
var monthStarts: [Date] = sourceSnapshots
|
||||
.map { $0.date.startOfMonth }
|
||||
monthStarts = Array(Set(monthStarts)).sorted()
|
||||
|
||||
guard monthStarts.count >= 2 else { continue }
|
||||
|
||||
for i in 0..<(monthStarts.count - 1) {
|
||||
let from = monthStarts[i]
|
||||
let to = monthStarts[i + 1]
|
||||
let monthsApart = from.monthsBetween(to)
|
||||
guard monthsApart > 1 else { continue }
|
||||
|
||||
var missingDates: [Date] = []
|
||||
var cursor = from.adding(months: 1).startOfMonth
|
||||
while cursor < to {
|
||||
missingDates.append(cursor)
|
||||
cursor = cursor.adding(months: 1).startOfMonth
|
||||
}
|
||||
guard !missingDates.isEmpty else { continue }
|
||||
|
||||
gaps.append(Gap(
|
||||
sourceId: sourceId,
|
||||
sourceName: source.name,
|
||||
fromMonth: from,
|
||||
toMonth: to,
|
||||
missingMonths: missingDates.count,
|
||||
missingMonthDates: missingDates
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
// Most recent gaps first (by the month after the gap), then by source name.
|
||||
return gaps.sorted {
|
||||
if $0.toMonth != $1.toMonth { return $0.toMonth > $1.toMonth }
|
||||
return $0.sourceName < $1.sourceName
|
||||
}
|
||||
}
|
||||
|
||||
/// Total count of missing months across all gaps.
|
||||
static func totalMissingMonths(_ gaps: [Gap]) -> Int {
|
||||
gaps.reduce(0) { $0 + $1.missingMonths }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
|
||||
/// Deterministic UUIDs (RFC 4122 v5) for records that every device/install must
|
||||
/// create identically — the default Account and the default Categories.
|
||||
///
|
||||
/// Why: `awakeFromInsert()` assigns a random UUID, so each device seeded its own
|
||||
/// "Default" account + default categories with *different* UUIDs before the first
|
||||
/// CloudKit import. CloudKit keys on the UUID (record name), so those identical-in-
|
||||
/// meaning records piled up as distinct records (8 accounts, 23 categories in the
|
||||
/// wild). Giving the defaults a STABLE UUID derived from a fixed namespace + name
|
||||
/// means every device produces the SAME record name → CloudKit natively merges
|
||||
/// them into one. No dedup, no deletes, no cascade — the root-cause fix.
|
||||
enum StableID {
|
||||
/// Fixed app namespace (generated once, constant forever — never change it).
|
||||
static let namespace = UUID(uuidString: "F3A1C2D4-5E6F-4A7B-8C9D-0E1F2A3B4C5D")!
|
||||
|
||||
/// RFC 4122 v5 UUID: SHA-1(namespace bytes ‖ name) with version/variant bits set.
|
||||
static func v5(_ name: String) -> UUID {
|
||||
var hasher = Insecure.SHA1()
|
||||
withUnsafeBytes(of: namespace.uuid) { hasher.update(bufferPointer: $0) }
|
||||
hasher.update(data: Data(name.utf8))
|
||||
let digest = Array(hasher.finalize()) // 20 bytes; take first 16
|
||||
|
||||
var b = Array(digest.prefix(16))
|
||||
b[6] = (b[6] & 0x0F) | 0x50 // version 5
|
||||
b[8] = (b[8] & 0x3F) | 0x80 // RFC 4122 variant
|
||||
|
||||
return UUID(uuid: (b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7],
|
||||
b[8], b[9], b[10], b[11], b[12], b[13], b[14], b[15]))
|
||||
}
|
||||
}
|
||||
@@ -94,6 +94,12 @@ class ChartsViewModel: ObservableObject {
|
||||
@Published var selectedSource: InvestmentSource?
|
||||
@Published var selectedSourceIds: Set<UUID> = []
|
||||
@Published var selectedTimeRange: TimeRange = .year
|
||||
/// Global custom data window (range brush): when set, it REPLACES the
|
||||
/// preset time range as the data filter for EVERY chart. Month-granular.
|
||||
@Published var customRange: ClosedRange<Date>?
|
||||
/// Full-history monthly totals for the current source/account universe —
|
||||
/// the sparkline behind the range brush. Rebuilt with the snapshot cache.
|
||||
@Published var fullHistorySeries: [(date: Date, value: Decimal)] = []
|
||||
@Published var selectedAccount: Account?
|
||||
@Published var showAllAccounts = true
|
||||
@Published var selectedBreakdown: BreakdownMode = .category
|
||||
@@ -328,7 +334,9 @@ class ChartsViewModel: ObservableObject {
|
||||
snapshots = self.freemiumValidator.filterSnapshots(snapshots)
|
||||
}
|
||||
var filtered = snapshots
|
||||
if let cutoff = self.selectedTimeRange.startDate() {
|
||||
if let custom = self.customRange {
|
||||
filtered = snapshots.filter { custom.contains($0.date) }
|
||||
} else if let cutoff = self.selectedTimeRange.startDate() {
|
||||
filtered = snapshots.filter { $0.date >= cutoff }
|
||||
}
|
||||
self.calculateComparisonData(sources: allSources, allSnapshots: filtered)
|
||||
@@ -354,6 +362,28 @@ class ChartsViewModel: ObservableObject {
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
// Global custom range (brush): refilter the selected chart's data.
|
||||
// Calls updateChartData directly (like performancePeriodMonths) because
|
||||
// the main pipeline's skip-check doesn't track customRange.
|
||||
$customRange
|
||||
.dropFirst()
|
||||
.debounce(for: .milliseconds(150), scheduler: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
guard let self else { return }
|
||||
self.updateChartData(chartType: self.selectedChartType, category: self.selectedCategory, timeRange: self.selectedTimeRange)
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
// Picking a preset range clears the custom brush window.
|
||||
$selectedTimeRange
|
||||
.removeDuplicates()
|
||||
.dropFirst()
|
||||
.sink { [weak self] _ in
|
||||
guard let self, self.customRange != nil else { return }
|
||||
self.customRange = nil
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
// Period comparison: react to date picker changes
|
||||
Publishers.CombineLatest4($periodAStart, $periodAEnd, $periodBStart, $periodBEnd)
|
||||
.debounce(for: .milliseconds(300), scheduler: DispatchQueue.main)
|
||||
@@ -489,9 +519,13 @@ class ChartsViewModel: ObservableObject {
|
||||
hiddenHistoryMonths = freemiumValidator.hiddenHistoryMonths(in: snapshots)
|
||||
snapshots = freemiumValidator.filterSnapshots(snapshots)
|
||||
cachedSnapshots = snapshots
|
||||
fullHistorySeries = monthlySeries(from: snapshots)
|
||||
}
|
||||
|
||||
if let cutoffDate = timeRange.startDate() {
|
||||
// The brush's custom window replaces the preset range as data filter.
|
||||
if let custom = customRange {
|
||||
snapshots = snapshots.filter { custom.contains($0.date) }
|
||||
} else if let cutoffDate = timeRange.startDate() {
|
||||
snapshots = snapshots.filter { $0.date >= cutoffDate }
|
||||
}
|
||||
|
||||
@@ -512,6 +546,8 @@ class ChartsViewModel: ObservableObject {
|
||||
selectedCategory: selectedCategory
|
||||
)
|
||||
calculateCategoryEvolutionData(from: completedSnapshots, categories: categoriesForChart)
|
||||
// Also needed by the Evolution "vs Contributions" overlay (premium).
|
||||
calculateContributionsData(from: completedSnapshots)
|
||||
case .allocation:
|
||||
calculateAllocationData(for: sources, breakdown: selectedBreakdown)
|
||||
calculateAllocationEvolutionData(from: completedSnapshots)
|
||||
@@ -564,7 +600,9 @@ class ChartsViewModel: ObservableObject {
|
||||
allSnapshots = freemiumValidator.filterSnapshots(allSnapshots)
|
||||
}
|
||||
var filteredForComparison = allSnapshots
|
||||
if let cutoff = timeRange.startDate() {
|
||||
if let custom = customRange {
|
||||
filteredForComparison = allSnapshots.filter { custom.contains($0.date) }
|
||||
} else if let cutoff = timeRange.startDate() {
|
||||
filteredForComparison = allSnapshots.filter { $0.date >= cutoff }
|
||||
}
|
||||
calculateComparisonData(sources: allSources, allSnapshots: filteredForComparison)
|
||||
@@ -734,36 +772,65 @@ class ChartsViewModel: ObservableObject {
|
||||
|
||||
// MARK: - Chart Calculations
|
||||
|
||||
/// Sets the custom window from two month-granular dates (inclusive months).
|
||||
func setCustomRange(fromMonth: Date, toMonth: Date) {
|
||||
let cal = Calendar.current
|
||||
let start = cal.date(from: cal.dateComponents([.year, .month], from: fromMonth)) ?? fromMonth
|
||||
let endMonthStart = cal.date(from: cal.dateComponents([.year, .month], from: toMonth)) ?? toMonth
|
||||
let end = cal.date(byAdding: DateComponents(month: 1, second: -1), to: endMonthStart) ?? toMonth
|
||||
customRange = start...max(start, end)
|
||||
}
|
||||
|
||||
private func calculateEvolutionData(from snapshots: [Snapshot]) {
|
||||
let sortedSnapshots = snapshots.sorted { $0.date < $1.date }
|
||||
let groupedByMonth = Dictionary(grouping: sortedSnapshots) { snapshot -> DateComponents in
|
||||
chartMonth(for: snapshot.date)
|
||||
evolutionData = downsampleSeries(monthlySeries(from: snapshots), maxPoints: maxChartPoints)
|
||||
}
|
||||
|
||||
/// Pure monthly aggregation (grid + gap fill + per-month totals) — shared by
|
||||
/// the evolution chart and the range-brush sparkline.
|
||||
private func monthlySeries(from snapshots: [Snapshot]) -> [(date: Date, value: Decimal)] {
|
||||
// Ordered month grid + each month's index.
|
||||
let sortedMonthKeys = Set(snapshots.map { chartMonth(for: $0.date) }).sorted {
|
||||
(Calendar.current.date(from: $0) ?? .distantPast) < (Calendar.current.date(from: $1) ?? .distantPast)
|
||||
}
|
||||
let monthCount = sortedMonthKeys.count
|
||||
guard monthCount > 0 else { return [] }
|
||||
var indexByKey: [DateComponents: Int] = [:]
|
||||
for (i, key) in sortedMonthKeys.enumerated() { indexByKey[key] = i }
|
||||
|
||||
// Per source: latest value at each month index.
|
||||
var knownBySource: [UUID: [Int: (value: Decimal, date: Date)]] = [:]
|
||||
for snapshot in snapshots {
|
||||
guard let sourceId = snapshot.source?.id,
|
||||
let idx = indexByKey[chartMonth(for: snapshot.date)] else { continue }
|
||||
let existing = knownBySource[sourceId]?[idx]
|
||||
if existing == nil || snapshot.date > existing!.date {
|
||||
knownBySource[sourceId, default: [:]][idx] = (snapshot.decimalValue, snapshot.date)
|
||||
}
|
||||
}
|
||||
|
||||
// Fill each source's gaps (interpolated when enabled, else stepped
|
||||
// carry-forward), then sum per month. Display only — see ChartGapFill.
|
||||
let interpolate = ChartGapFill.isEnabled
|
||||
var monthTotals = [Decimal](repeating: 0, count: monthCount)
|
||||
for (_, known) in knownBySource {
|
||||
let dense = ChartGapFill.denseValues(
|
||||
known: known.mapValues { $0.value },
|
||||
monthCount: monthCount,
|
||||
interpolate: interpolate
|
||||
)
|
||||
for i in 0..<monthCount where dense[i] != nil {
|
||||
monthTotals[i] += dense[i]!
|
||||
}
|
||||
}
|
||||
|
||||
var series: [(date: Date, value: Decimal)] = []
|
||||
series.reserveCapacity(groupedByMonth.count)
|
||||
|
||||
for (key, monthSnapshots) in groupedByMonth {
|
||||
var latestBySource: [UUID: Snapshot] = [:]
|
||||
for snapshot in monthSnapshots {
|
||||
guard let sourceId = snapshot.source?.id else { continue }
|
||||
if let existing = latestBySource[sourceId] {
|
||||
if snapshot.date > existing.date {
|
||||
latestBySource[sourceId] = snapshot
|
||||
}
|
||||
} else {
|
||||
latestBySource[sourceId] = snapshot
|
||||
}
|
||||
}
|
||||
|
||||
let total = latestBySource.values.reduce(Decimal.zero) { $0 + $1.decimalValue }
|
||||
let date = Calendar.current.date(from: key) ?? Date()
|
||||
series.append((date: date, value: total))
|
||||
series.reserveCapacity(monthCount)
|
||||
for i in 0..<monthCount {
|
||||
let date = Calendar.current.date(from: sortedMonthKeys[i]) ?? Date()
|
||||
series.append((date: date, value: monthTotals[i]))
|
||||
}
|
||||
|
||||
series.sort { $0.date < $1.date }
|
||||
|
||||
evolutionData = downsampleSeries(series, maxPoints: maxChartPoints)
|
||||
return series
|
||||
}
|
||||
|
||||
private func calculateCategoryEvolutionData(from snapshots: [Snapshot], categories: [Category]) {
|
||||
@@ -852,12 +919,16 @@ class ChartsViewModel: ObservableObject {
|
||||
var globalCategoryTotals: [String: (total: Decimal, color: String)] = [:]
|
||||
var monthlyData: [(date: Date, categories: [String: (value: Decimal, color: String)])] = []
|
||||
|
||||
// Forward-filled across months: a source keeps its last known value in months
|
||||
// where it wasn't updated, so category allocations don't distort when a single
|
||||
// source is missing that month.
|
||||
var sourceLatest: [UUID: Snapshot] = [:]
|
||||
|
||||
for monthKey in sortedMonths {
|
||||
guard let monthSnapshots = groupedByMonth[monthKey],
|
||||
let monthDate = Calendar.current.date(from: monthKey) else { continue }
|
||||
|
||||
var categoryTotals: [String: (value: Decimal, color: String)] = [:]
|
||||
var sourceLatest: [UUID: Snapshot] = [:]
|
||||
|
||||
for snapshot in monthSnapshots {
|
||||
guard let sourceId = snapshot.source?.id else { continue }
|
||||
@@ -1541,10 +1612,15 @@ class ChartsViewModel: ObservableObject {
|
||||
let grouped = Dictionary(grouping: periodSnapshots) { snap -> DateComponents in
|
||||
Calendar.current.dateComponents([.year, .month], from: snap.date)
|
||||
}
|
||||
let sortedKeys = grouped.keys.sorted {
|
||||
(Calendar.current.date(from: $0) ?? .distantPast) < (Calendar.current.date(from: $1) ?? .distantPast)
|
||||
}
|
||||
var monthlyTotalsArr: [(date: Date, value: Double)] = []
|
||||
for (key, snaps) in grouped {
|
||||
var latestBySource: [UUID: Snapshot] = [:]
|
||||
for snap in snaps {
|
||||
// Forward-fill per source across the period so an un-updated source keeps
|
||||
// its last value instead of dropping the month's total.
|
||||
var latestBySource: [UUID: Snapshot] = [:]
|
||||
for key in sortedKeys {
|
||||
for snap in grouped[key] ?? [] {
|
||||
guard let sourceId = snap.source?.id else { continue }
|
||||
if let existing = latestBySource[sourceId] {
|
||||
if snap.date > existing.date { latestBySource[sourceId] = snap }
|
||||
@@ -1556,7 +1632,6 @@ class ChartsViewModel: ObservableObject {
|
||||
let date = Calendar.current.date(from: key) ?? Date()
|
||||
monthlyTotalsArr.append((date: date, value: total))
|
||||
}
|
||||
monthlyTotalsArr.sort { $0.date < $1.date }
|
||||
guard let firstValue = monthlyTotalsArr.first?.value, firstValue > 0 else { return nil }
|
||||
|
||||
let points = monthlyTotalsArr.enumerated().map { index, item in
|
||||
@@ -1669,17 +1744,23 @@ class ChartsViewModel: ObservableObject {
|
||||
}
|
||||
|
||||
private func monthlyTotalsByMonthYear(from snapshots: [Snapshot]) -> [(date: Date, totalValue: Decimal)] {
|
||||
let sortedSnapshots = snapshots.sorted { $0.date < $1.date }
|
||||
let groupedByMonth = Dictionary(grouping: sortedSnapshots) { snapshot -> DateComponents in
|
||||
let groupedByMonth = Dictionary(grouping: snapshots) { snapshot -> DateComponents in
|
||||
chartMonth(for: snapshot.date)
|
||||
}
|
||||
|
||||
var totals: [(date: Date, totalValue: Decimal)] = []
|
||||
totals.reserveCapacity(groupedByMonth.count)
|
||||
let sortedMonthKeys = groupedByMonth.keys.sorted {
|
||||
(Calendar.current.date(from: $0) ?? .distantPast) < (Calendar.current.date(from: $1) ?? .distantPast)
|
||||
}
|
||||
|
||||
for (key, monthSnapshots) in groupedByMonth {
|
||||
var latestBySource: [UUID: Snapshot] = [:]
|
||||
for snapshot in monthSnapshots {
|
||||
var totals: [(date: Date, totalValue: Decimal)] = []
|
||||
totals.reserveCapacity(sortedMonthKeys.count)
|
||||
|
||||
// Forward-fill per source so each month's total reflects every source's last
|
||||
// known value (matches monthlyTotals and the portfolio total).
|
||||
var latestBySource: [UUID: Snapshot] = [:]
|
||||
|
||||
for key in sortedMonthKeys {
|
||||
for snapshot in groupedByMonth[key] ?? [] {
|
||||
guard let sourceId = snapshot.source?.id else { continue }
|
||||
if let existing = latestBySource[sourceId] {
|
||||
if snapshot.date > existing.date {
|
||||
@@ -1695,7 +1776,7 @@ class ChartsViewModel: ObservableObject {
|
||||
totals.append((date: date, totalValue: total))
|
||||
}
|
||||
|
||||
return totals.sorted { $0.date < $1.date }
|
||||
return totals
|
||||
}
|
||||
|
||||
private func monthlyReturnSeries(
|
||||
|
||||
@@ -25,6 +25,13 @@ class DashboardViewModel: ObservableObject {
|
||||
@Published var evolutionData: [(date: Date, value: Decimal)] = []
|
||||
@Published var updateStreak: Int = 0
|
||||
|
||||
// MARK: - Data Gaps
|
||||
|
||||
/// Internal gaps in per-source snapshot history (missing months between two
|
||||
/// known snapshots). The chart smooths these over; this exposes them so the
|
||||
/// user knows real data is missing and can fill it. See SnapshotGapDetector.
|
||||
@Published var dataGaps: [SnapshotGapDetector.Gap] = []
|
||||
|
||||
// MARK: - Portfolio Forecast
|
||||
|
||||
@Published var portfolioForecast: PortfolioForecast?
|
||||
@@ -209,6 +216,9 @@ class DashboardViewModel: ObservableObject {
|
||||
// Compute update streak
|
||||
updateStreak = computeStreak(from: evolutionData)
|
||||
|
||||
// Detect internal data gaps (missing months between known snapshots)
|
||||
dataGaps = SnapshotGapDetector.detectGaps(sources: sources, snapshots: allSnapshots)
|
||||
|
||||
// Log screen view
|
||||
FirebaseService.shared.logScreenView(screenName: "Dashboard")
|
||||
}
|
||||
@@ -283,51 +293,75 @@ class DashboardViewModel: ObservableObject {
|
||||
return EvolutionSummary(evolutionData: [], categorySeries: [], categoryTotals: [:])
|
||||
}
|
||||
|
||||
let sortedSnapshots = snapshots.sorted { $0.date < $1.date }
|
||||
let groupedByMonth = Dictionary(grouping: sortedSnapshots) { snapshot -> DateComponents in
|
||||
let components = Calendar.current.dateComponents([.year, .month], from: snapshot.date)
|
||||
return DateComponents(year: components.year, month: components.month)
|
||||
let calendar = Calendar.current
|
||||
func monthKey(_ date: Date) -> DateComponents {
|
||||
let c = calendar.dateComponents([.year, .month], from: date)
|
||||
return DateComponents(year: c.year, month: c.month)
|
||||
}
|
||||
|
||||
// Ordered grid of every month that has data, and each month's index in it.
|
||||
let sortedMonthKeys = Set(snapshots.map { monthKey($0.date) }).sorted {
|
||||
(calendar.date(from: $0) ?? .distantPast) < (calendar.date(from: $1) ?? .distantPast)
|
||||
}
|
||||
let monthCount = sortedMonthKeys.count
|
||||
guard monthCount > 0 else {
|
||||
return EvolutionSummary(evolutionData: [], categorySeries: [], categoryTotals: [:])
|
||||
}
|
||||
var indexByKey: [DateComponents: Int] = [:]
|
||||
for (i, key) in sortedMonthKeys.enumerated() { indexByKey[key] = i }
|
||||
|
||||
// Per source: the latest snapshot value at each month index + its category.
|
||||
var knownBySource: [UUID: [Int: (value: Decimal, date: Date)]] = [:]
|
||||
var categoryBySource: [UUID: UUID] = [:]
|
||||
for snapshot in snapshots {
|
||||
guard let sourceId = snapshot.source?.id,
|
||||
let idx = indexByKey[monthKey(snapshot.date)] else { continue }
|
||||
let existing = knownBySource[sourceId]?[idx]
|
||||
if existing == nil || snapshot.date > existing!.date {
|
||||
knownBySource[sourceId, default: [:]][idx] = (snapshot.decimalValue, snapshot.date)
|
||||
}
|
||||
if let categoryId = snapshot.source?.category?.id {
|
||||
categoryBySource[sourceId] = categoryId
|
||||
}
|
||||
}
|
||||
|
||||
// Fill each source's gaps (linear interpolation when enabled, else stepped
|
||||
// carry-forward), then sum per month. Interpolating per source means a
|
||||
// source last seen in Feb and next in Jun contributes a smooth ramp for
|
||||
// Mar/Apr/May instead of a flat line + jump. Chart display only — no data
|
||||
// is created (see ChartGapFill).
|
||||
let interpolate = ChartGapFill.isEnabled
|
||||
var monthTotals = [Decimal](repeating: 0, count: monthCount)
|
||||
var monthCategory = [[UUID: Decimal]](repeating: [:], count: monthCount)
|
||||
var categoryTotals: [UUID: Decimal] = [:]
|
||||
|
||||
for (sourceId, known) in knownBySource {
|
||||
let dense = ChartGapFill.denseValues(
|
||||
known: known.mapValues { $0.value },
|
||||
monthCount: monthCount,
|
||||
interpolate: interpolate
|
||||
)
|
||||
let categoryId = categoryBySource[sourceId]
|
||||
for i in 0..<monthCount {
|
||||
guard let v = dense[i] else { continue }
|
||||
monthTotals[i] += v
|
||||
if let categoryId {
|
||||
monthCategory[i][categoryId, default: 0] += v
|
||||
categoryTotals[categoryId, default: 0] += v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var evolution: [(date: Date, value: Decimal)] = []
|
||||
evolution.reserveCapacity(groupedByMonth.count)
|
||||
evolution.reserveCapacity(monthCount)
|
||||
var series: [(date: Date, valuesByCategory: [UUID: Decimal])] = []
|
||||
series.reserveCapacity(groupedByMonth.count)
|
||||
var categoryTotals: [UUID: Decimal] = [:]
|
||||
|
||||
for (key, monthSnapshots) in groupedByMonth {
|
||||
var latestBySource: [UUID: Snapshot] = [:]
|
||||
for snapshot in monthSnapshots {
|
||||
guard let sourceId = snapshot.source?.id else { continue }
|
||||
if let existing = latestBySource[sourceId] {
|
||||
if snapshot.date > existing.date {
|
||||
latestBySource[sourceId] = snapshot
|
||||
}
|
||||
} else {
|
||||
latestBySource[sourceId] = snapshot
|
||||
}
|
||||
}
|
||||
|
||||
var total: Decimal = 0
|
||||
var valuesByCategory: [UUID: Decimal] = [:]
|
||||
|
||||
for snapshot in latestBySource.values {
|
||||
let value = snapshot.decimalValue
|
||||
total += value
|
||||
if let categoryId = snapshot.source?.category?.id {
|
||||
valuesByCategory[categoryId, default: 0] += value
|
||||
categoryTotals[categoryId, default: 0] += value
|
||||
}
|
||||
}
|
||||
|
||||
let date = Calendar.current.date(from: key) ?? Date()
|
||||
evolution.append((date: date, value: total))
|
||||
series.append((date: date, valuesByCategory: valuesByCategory))
|
||||
series.reserveCapacity(monthCount)
|
||||
for i in 0..<monthCount {
|
||||
let date = calendar.date(from: sortedMonthKeys[i]) ?? Date()
|
||||
evolution.append((date: date, value: monthTotals[i]))
|
||||
series.append((date: date, valuesByCategory: monthCategory[i]))
|
||||
}
|
||||
|
||||
evolution.sort { $0.date < $1.date }
|
||||
series.sort { $0.date < $1.date }
|
||||
|
||||
return EvolutionSummary(
|
||||
evolutionData: evolution,
|
||||
categorySeries: series,
|
||||
@@ -661,80 +695,81 @@ class DashboardViewModel: ObservableObject {
|
||||
sourcesNeedingUpdate.count
|
||||
}
|
||||
|
||||
/// Total number of missing months across all detected internal gaps.
|
||||
var totalMissingMonths: Int {
|
||||
SnapshotGapDetector.totalMissingMonths(dataGaps)
|
||||
}
|
||||
|
||||
/// Resolves an InvestmentSource for a detected gap so the fill flow can open
|
||||
/// the add-snapshot sheet for it.
|
||||
func source(withId id: UUID) -> InvestmentSource? {
|
||||
sourceRepository.sources.first { $0.id == id }
|
||||
}
|
||||
|
||||
/// Builds the data for the shareable monthly summary card from already-computed
|
||||
/// dashboard state. Mood/rating come from the latest completed check-in entry.
|
||||
func monthlySummaryShareData() -> ShareService.MonthlySummaryShareData {
|
||||
let monthLabel: String = {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "MMMM yyyy"
|
||||
let date = MonthlyCheckInStore.latestCompletionDate() ?? Date()
|
||||
return formatter.string(from: date)
|
||||
}()
|
||||
|
||||
let latestEntry = MonthlyCheckInStore.latestCompletionDate()
|
||||
.flatMap { MonthlyCheckInStore.entry(for: $0) }
|
||||
|
||||
return ShareService.MonthlySummaryShareData(
|
||||
monthLabel: monthLabel,
|
||||
totalValue: portfolioSummary.formattedTotalValue,
|
||||
changeAmount: latestPortfolioChange.formattedAbsolute,
|
||||
changePercentage: latestPortfolioChange.formattedPercentage,
|
||||
isPositive: latestPortfolioChange.absolute >= 0,
|
||||
streak: updateStreak,
|
||||
mood: latestEntry?.mood,
|
||||
rating: latestEntry?.rating
|
||||
)
|
||||
}
|
||||
|
||||
/// Prioritized, on-device insights. Gathers local inputs and delegates the
|
||||
/// rules to `InsightsEngine` (no network / no AI).
|
||||
var insights: [PortfolioInsight] {
|
||||
var result: [PortfolioInsight] = []
|
||||
guard portfolioSummary.totalValue > 0 else { return result }
|
||||
let total = portfolioSummary.totalValue
|
||||
guard total > 0 else { return [] }
|
||||
|
||||
// 1. Milestone approaching (within 10% of next round milestone)
|
||||
let milestones: [Decimal] = [1000, 2500, 5000, 10000, 25000, 50000,
|
||||
100000, 250000, 500000, 1_000_000,
|
||||
2_500_000, 5_000_000, 10_000_000]
|
||||
if let next = milestones.first(where: { $0 > total }) {
|
||||
let pct = NSDecimalNumber(decimal: total / next).doubleValue
|
||||
if pct >= 0.90 {
|
||||
let gap = next - total
|
||||
let gapStr = CurrencyFormatter.format(gap, style: .currency, maximumFractionDigits: 0)
|
||||
let msStr = CurrencyFormatter.format(next, style: .currency, maximumFractionDigits: 0)
|
||||
result.append(PortfolioInsight(
|
||||
id: "milestone",
|
||||
systemImage: "flag.checkered",
|
||||
title: String(localized: "insight_milestone_title"),
|
||||
value: String(format: String(localized: "insight_milestone_value"), gapStr, msStr),
|
||||
accentColor: .orange
|
||||
))
|
||||
}
|
||||
let categorySlices: [InsightsEngine.CategorySlice] = categoryMetrics.map { cat in
|
||||
InsightsEngine.CategorySlice(
|
||||
name: cat.categoryName,
|
||||
percentageOfPortfolio: cat.percentageOfPortfolio,
|
||||
targetPercentage: AllocationTargetStore.target(for: cat.id),
|
||||
returnPercentage: NSDecimalNumber(decimal: cat.metrics.percentageReturn).doubleValue
|
||||
)
|
||||
}
|
||||
|
||||
// 2. Year-to-date performance
|
||||
let ytdPct = portfolioSummary.yearChangePercentage
|
||||
if abs(ytdPct) >= 0.1 {
|
||||
let icon = ytdPct >= 0 ? "arrow.up.right.circle.fill" : "arrow.down.right.circle.fill"
|
||||
result.append(PortfolioInsight(
|
||||
id: "ytd",
|
||||
systemImage: icon,
|
||||
title: String(localized: "insight_ytd_title"),
|
||||
value: String(format: "%+.1f%%", ytdPct),
|
||||
accentColor: ytdPct >= 0 ? .positiveGreen : .negativeRed
|
||||
))
|
||||
let sources = filteredSources()
|
||||
let totalDouble = NSDecimalNumber(decimal: total).doubleValue
|
||||
let sourceSlices: [InsightsEngine.SourceSlice] = sources.compactMap { source in
|
||||
let value = NSDecimalNumber(decimal: source.latestValue).doubleValue
|
||||
guard value > 0, totalDouble > 0 else { return nil }
|
||||
return InsightsEngine.SourceSlice(
|
||||
name: source.name,
|
||||
percentageOfPortfolio: value / totalDouble * 100
|
||||
)
|
||||
}
|
||||
|
||||
// 3. All-time market gains
|
||||
let gains = portfolioSummary.allTimeReturn
|
||||
if gains > 0 {
|
||||
let gainStr = CurrencyFormatter.format(gains, style: .currency, maximumFractionDigits: 0)
|
||||
result.append(PortfolioInsight(
|
||||
id: "market_gains",
|
||||
systemImage: "chart.line.uptrend.xyaxis",
|
||||
title: String(localized: "insight_market_gains_title"),
|
||||
value: String(format: String(localized: "insight_market_gains_value"), gainStr),
|
||||
accentColor: .appPrimary
|
||||
))
|
||||
}
|
||||
let input = InsightsEngine.Input(
|
||||
totalValue: total,
|
||||
categories: categorySlices,
|
||||
sources: sourceSlices,
|
||||
fundedCategoryCount: categoryMetrics.filter { $0.totalValue > 0 }.count,
|
||||
fundedSourceCount: sourceSlices.count,
|
||||
yearChangePercentage: portfolioSummary.yearChangePercentage,
|
||||
allTimeReturn: portfolioSummary.allTimeReturn,
|
||||
updateStreak: updateStreak,
|
||||
forecast: portfolioForecast
|
||||
)
|
||||
|
||||
// 4. Tracking streak (>= 3 months)
|
||||
if updateStreak >= 3 {
|
||||
result.append(PortfolioInsight(
|
||||
id: "streak",
|
||||
systemImage: "flame.fill",
|
||||
title: String(localized: "insight_streak_title"),
|
||||
value: String(format: String(localized: "insight_streak_value"), updateStreak),
|
||||
accentColor: .orange
|
||||
))
|
||||
}
|
||||
|
||||
// 5. Forecast
|
||||
if let forecast = portfolioForecast, forecast.forecastValue > total {
|
||||
result.append(PortfolioInsight(
|
||||
id: "forecast",
|
||||
systemImage: "wand.and.stars",
|
||||
title: String(localized: "insight_forecast_title"),
|
||||
value: "\(forecast.formattedForecastValue) · \(forecast.formattedForecastDate)",
|
||||
accentColor: .purple
|
||||
))
|
||||
}
|
||||
|
||||
return result
|
||||
return InsightsEngine.makeInsights(input)
|
||||
}
|
||||
|
||||
var topCategories: [CategoryMetrics] {
|
||||
@@ -774,4 +809,15 @@ class DashboardViewModel: ObservableObject {
|
||||
var formattedLastUpdate: String {
|
||||
portfolioSummary.lastUpdated?.friendlyDescription ?? "Not yet"
|
||||
}
|
||||
|
||||
/// Absolute last-check-in date (e.g. "7 Aug 2026") for the share card — an
|
||||
/// outsider needs a concrete date, not "2 days ago".
|
||||
var shareAsOfDate: String? {
|
||||
guard let date = portfolioSummary.lastUpdated else { return nil }
|
||||
let f = DateFormatter()
|
||||
f.locale = .autoupdatingCurrent
|
||||
f.dateStyle = .medium
|
||||
f.timeStyle = .none
|
||||
return f.string(from: date)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,70 +166,62 @@ class GoalsViewModel: ObservableObject {
|
||||
}
|
||||
|
||||
func estimateCompletionDate(for goal: Goal) -> Date? {
|
||||
// Performance: Use cached completion date if available
|
||||
if let cached = cachedCompletionDates[goal.id] {
|
||||
projection(for: goal).projectedDate
|
||||
}
|
||||
|
||||
/// Full forward projection for a goal, blending recent growth trend with the
|
||||
/// monthly contributions of the relevant sources. Cached per goal.
|
||||
func projection(for goal: Goal) -> GoalProjection.Result {
|
||||
let sources = relevantSources(for: goal)
|
||||
let currentValue = totalValue(for: goal)
|
||||
|
||||
guard !sources.isEmpty else {
|
||||
let cached = GoalProjection.Result(
|
||||
projectedDate: nil, monthsToReach: nil,
|
||||
status: goal.targetDate == nil ? .noTargetDate : .unreachable,
|
||||
monthlyGrowthRate: 0, sparkline: []
|
||||
)
|
||||
cachedCompletionDates[goal.id] = nil
|
||||
return cached
|
||||
}
|
||||
|
||||
let sources: [InvestmentSource]
|
||||
let evolutionData = evolutionData(for: goal, sources: sources)
|
||||
let rate = GoalProjection.monthlyGrowthRate(from: evolutionData)
|
||||
let contribution = sources.reduce(Decimal.zero) {
|
||||
$0 + ($1.monthlyContribution?.decimalValue ?? .zero)
|
||||
}
|
||||
|
||||
let result = GoalProjection.project(
|
||||
currentValue: currentValue,
|
||||
targetAmount: goal.targetDecimal,
|
||||
monthlyGrowthRate: rate,
|
||||
monthlyContribution: contribution,
|
||||
targetDate: goal.targetDate
|
||||
)
|
||||
cachedCompletionDates[goal.id] = result.projectedDate
|
||||
return result
|
||||
}
|
||||
|
||||
private func relevantSources(for goal: Goal) -> [InvestmentSource] {
|
||||
if let accountId = goal.account?.safeId {
|
||||
sources = sourceRepository.sources.filter { $0.account?.id == accountId }
|
||||
} else {
|
||||
sources = sourceRepository.sources
|
||||
return sourceRepository.sources.filter { $0.account?.id == accountId }
|
||||
}
|
||||
return sourceRepository.sources
|
||||
}
|
||||
|
||||
private func evolutionData(
|
||||
for goal: Goal,
|
||||
sources: [InvestmentSource]
|
||||
) -> [(date: Date, value: Decimal)] {
|
||||
let cacheKey = goal.account?.safeId ?? UUID(uuidString: "00000000-0000-0000-0000-000000000000")!
|
||||
if let cached = cachedEvolutionData[cacheKey] {
|
||||
return cached
|
||||
}
|
||||
let sourceIds = sources.compactMap { $0.id }
|
||||
guard !sourceIds.isEmpty else {
|
||||
cachedCompletionDates[goal.id] = nil
|
||||
return nil
|
||||
}
|
||||
|
||||
// Performance: Use cached evolution data if available
|
||||
let cacheKey = goal.account?.safeId ?? UUID(uuidString: "00000000-0000-0000-0000-000000000000")!
|
||||
let evolutionData: [(date: Date, value: Decimal)]
|
||||
if let cached = cachedEvolutionData[cacheKey] {
|
||||
evolutionData = cached
|
||||
} else {
|
||||
let snapshots = snapshotRepository.fetchSnapshots(
|
||||
for: sourceIds,
|
||||
months: maxHistoryMonths
|
||||
)
|
||||
evolutionData = calculateEvolutionData(from: snapshots)
|
||||
cachedEvolutionData[cacheKey] = evolutionData
|
||||
}
|
||||
|
||||
guard evolutionData.count >= 3,
|
||||
let first = evolutionData.suffix(6).first,
|
||||
let last = evolutionData.suffix(6).last else {
|
||||
cachedCompletionDates[goal.id] = nil
|
||||
return nil
|
||||
}
|
||||
|
||||
let monthsBetween = max(1, first.date.monthsBetween(last.date))
|
||||
let delta = last.value - first.value
|
||||
guard delta > 0 else {
|
||||
cachedCompletionDates[goal.id] = nil
|
||||
return nil
|
||||
}
|
||||
|
||||
let monthlyGain = delta / Decimal(monthsBetween)
|
||||
guard monthlyGain > 0 else {
|
||||
cachedCompletionDates[goal.id] = nil
|
||||
return nil
|
||||
}
|
||||
|
||||
let currentValue = totalValue(for: goal)
|
||||
let remaining = goal.targetDecimal - currentValue
|
||||
guard remaining > 0 else {
|
||||
let result = Date()
|
||||
cachedCompletionDates[goal.id] = result
|
||||
return result
|
||||
}
|
||||
|
||||
let months = NSDecimalNumber(decimal: remaining / monthlyGain).doubleValue
|
||||
let monthsRounded = Int(ceil(months))
|
||||
let result = Calendar.current.date(byAdding: .month, value: monthsRounded, to: last.date)
|
||||
cachedCompletionDates[goal.id] = result
|
||||
return result
|
||||
let snapshots = snapshotRepository.fetchSnapshots(for: sourceIds, months: maxHistoryMonths)
|
||||
let data = calculateEvolutionData(from: snapshots)
|
||||
cachedEvolutionData[cacheKey] = data
|
||||
return data
|
||||
}
|
||||
|
||||
private func calculateEvolutionData(from snapshots: [Snapshot]) -> [(date: Date, value: Decimal)] {
|
||||
|
||||
@@ -36,6 +36,8 @@ class SettingsViewModel: ObservableObject {
|
||||
@Published var backupRetentionCount = 10
|
||||
@Published var backups: [BackupRecord] = []
|
||||
@Published var backupsEnabled = false
|
||||
/// Auto-backup cadence in days: 0 = off, 1 = daily, 7 = weekly, 30 = monthly.
|
||||
@Published var autoBackupFrequencyDays = 0
|
||||
|
||||
// MARK: - Dependencies
|
||||
|
||||
@@ -96,6 +98,7 @@ class SettingsViewModel: ObservableObject {
|
||||
inputMode = InputMode(rawValue: settings.inputMode) ?? .simple
|
||||
backupRetentionCount = loadBackupRetention()
|
||||
backupsEnabled = loadBackupsEnabled()
|
||||
autoBackupFrequencyDays = UserDefaults.standard.integer(forKey: "autoBackupFrequencyDays")
|
||||
if backupsEnabled {
|
||||
refreshBackups()
|
||||
} else {
|
||||
@@ -353,6 +356,7 @@ class SettingsViewModel: ObservableObject {
|
||||
if accountCount == 0 {
|
||||
let defaultCurrency = AppSettings.getOrCreate(in: context).currency
|
||||
let account = Account(context: context)
|
||||
account.id = Account.defaultAccountStableID
|
||||
account.name = Account.defaultAccountName
|
||||
account.currency = defaultCurrency
|
||||
account.inputMode = InputMode.simple.rawValue
|
||||
@@ -394,6 +398,19 @@ class SettingsViewModel: ObservableObject {
|
||||
backups = BackupService.shared.listAllBackups(includeICloud: includeICloud)
|
||||
}
|
||||
|
||||
/// Auto-backup cadence (0 = off). Running one immediately when enabled gives
|
||||
/// instant feedback that it works.
|
||||
func updateAutoBackupFrequency(_ days: Int) {
|
||||
guard backupsEnabled, isPremium else { return }
|
||||
autoBackupFrequencyDays = days
|
||||
UserDefaults.standard.set(days, forKey: "autoBackupFrequencyDays")
|
||||
if days > 0 { createBackupNow() }
|
||||
}
|
||||
|
||||
var lastAutoBackupDate: Date? {
|
||||
UserDefaults.standard.object(forKey: "lastAutoBackupDate") as? Date
|
||||
}
|
||||
|
||||
func createBackupNow() {
|
||||
guard backupsEnabled, isPremium else { return }
|
||||
guard !isBackupInProgress else { return }
|
||||
|
||||
@@ -41,9 +41,14 @@ class SnapshotFormViewModel: ObservableObject {
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
init(source: InvestmentSource, mode: Mode = .add) {
|
||||
init(source: InvestmentSource, mode: Mode = .add, initialDate: Date? = nil) {
|
||||
self.source = source
|
||||
self.mode = mode
|
||||
if case .add = mode, let initialDate {
|
||||
// Prefill for filling a specific missing month (gap fill flow).
|
||||
// Clamp to today so the date picker's `...Date()` bound holds.
|
||||
date = min(initialDate, Date())
|
||||
}
|
||||
let settings = AppSettings.getOrCreate(in: CoreDataStack.shared.viewContext)
|
||||
if let accountCurrency = source.account?.currency, !accountCurrency.isEmpty {
|
||||
currencyCode = accountCurrency
|
||||
|
||||
@@ -198,10 +198,6 @@ class SourceListViewModel: ObservableObject {
|
||||
totalValue.currencyString
|
||||
}
|
||||
|
||||
var sourcesByCategory: [Category: [InvestmentSource]] {
|
||||
Dictionary(grouping: sources) { $0.category ?? categories.first! }
|
||||
}
|
||||
|
||||
var isEmpty: Bool {
|
||||
sources.isEmpty && searchText.isEmpty && selectedCategoryIds.isEmpty
|
||||
}
|
||||
|
||||
@@ -5,8 +5,16 @@ struct AllocationPieChart: View {
|
||||
let data: [(category: String, value: Decimal, color: String)]
|
||||
var title: String = "Asset Allocation"
|
||||
var showsTargetsComparison: Bool = true
|
||||
/// Drill-down: tapping the chevron on a legend row jumps to Evolution
|
||||
/// filtered to that category/source.
|
||||
var onDrillDown: ((String) -> Void)? = nil
|
||||
|
||||
@State private var selectedSlice: String?
|
||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||
|
||||
private var chartHeight: CGFloat {
|
||||
horizontalSizeClass == .regular ? 190 : 130
|
||||
}
|
||||
|
||||
var total: Decimal {
|
||||
data.reduce(Decimal.zero) { $0 + $1.value }
|
||||
@@ -18,83 +26,62 @@ struct AllocationPieChart: View {
|
||||
.font(.headline)
|
||||
|
||||
if !data.isEmpty {
|
||||
HStack(alignment: .top, spacing: 20) {
|
||||
// Pie Chart
|
||||
Chart(data, id: \.category) { item in
|
||||
SectorMark(
|
||||
angle: .value("Value", NSDecimalNumber(decimal: item.value).doubleValue),
|
||||
innerRadius: .ratio(0.6),
|
||||
angularInset: 1.5
|
||||
)
|
||||
.foregroundStyle(Color(hex: item.color) ?? .gray)
|
||||
.cornerRadius(4)
|
||||
.opacity(selectedSlice == nil || selectedSlice == item.category ? 1 : 0.5)
|
||||
}
|
||||
.chartLegend(.hidden)
|
||||
.frame(width: 180, height: 180)
|
||||
.overlay {
|
||||
// Center content
|
||||
VStack(spacing: 2) {
|
||||
if let selected = selectedSlice,
|
||||
let item = data.first(where: { $0.category == selected }) {
|
||||
Text(selected)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
Text(item.value.compactCurrencyString)
|
||||
.font(.headline)
|
||||
let percentage = total > 0
|
||||
? NSDecimalNumber(decimal: item.value / total).doubleValue * 100
|
||||
: 0
|
||||
Text(String(format: "%.1f%%", percentage))
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
} else {
|
||||
Text("Total")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
Text(total.compactCurrencyString)
|
||||
.font(.headline)
|
||||
}
|
||||
}
|
||||
}
|
||||
.gesture(
|
||||
DragGesture(minimumDistance: 0)
|
||||
.onChanged { value in
|
||||
// Simple tap detection
|
||||
}
|
||||
HStack(alignment: .center, spacing: 24) {
|
||||
// Hemicycle (parliament-style) allocation gauge — fills its
|
||||
// half of the row so it scales up on iPad instead of sitting
|
||||
// tiny in a corner.
|
||||
SemicircleAllocation(
|
||||
data: data,
|
||||
total: total,
|
||||
selectedSlice: $selectedSlice
|
||||
)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: chartHeight)
|
||||
|
||||
// Legend
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
ForEach(data, id: \.category) { item in
|
||||
Button {
|
||||
if selectedSlice == item.category {
|
||||
selectedSlice = nil
|
||||
} else {
|
||||
selectedSlice = item.category
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: 8) {
|
||||
Circle()
|
||||
.fill(Color(hex: item.color) ?? .gray)
|
||||
.frame(width: 10, height: 10)
|
||||
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text(item.category)
|
||||
.font(.caption)
|
||||
.foregroundColor(.primary)
|
||||
|
||||
let percentage = total > 0
|
||||
? NSDecimalNumber(decimal: item.value / total).doubleValue * 100
|
||||
: 0
|
||||
Text(String(format: "%.1f%%", percentage))
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
HStack(spacing: 6) {
|
||||
Button {
|
||||
if selectedSlice == item.category {
|
||||
selectedSlice = nil
|
||||
} else {
|
||||
selectedSlice = item.category
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: 8) {
|
||||
Circle()
|
||||
.fill(Color(hex: item.color) ?? .gray)
|
||||
.frame(width: 11, height: 11)
|
||||
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text(item.category)
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.primary)
|
||||
|
||||
let percentage = total > 0
|
||||
? NSDecimalNumber(decimal: item.value / total).doubleValue * 100
|
||||
: 0
|
||||
Text(String(format: "%.1f%%", percentage))
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
.opacity(selectedSlice == nil || selectedSlice == item.category ? 1 : 0.5)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
if let onDrillDown {
|
||||
Button {
|
||||
onDrillDown(item.category)
|
||||
} label: {
|
||||
Image(systemName: "chevron.right.circle.fill")
|
||||
.font(.footnote)
|
||||
.foregroundColor(.secondary.opacity(0.6))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.opacity(selectedSlice == nil || selectedSlice == item.category ? 1 : 0.5)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
@@ -117,6 +104,116 @@ struct AllocationPieChart: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Semicircle (hemicycle) allocation gauge
|
||||
|
||||
/// Parliament-style half-donut: segments sweep the top 180°, sized by share.
|
||||
struct SemicircleAllocation: View {
|
||||
let data: [(category: String, value: Decimal, color: String)]
|
||||
let total: Decimal
|
||||
@Binding var selectedSlice: String?
|
||||
|
||||
/// Cumulative start/end fractions [0,1] for each segment across the 180° arc.
|
||||
private var segments: [(category: String, start: Double, end: Double, color: Color)] {
|
||||
guard total > 0 else { return [] }
|
||||
var acc = 0.0
|
||||
return data.map { item in
|
||||
let frac = NSDecimalNumber(decimal: item.value / total).doubleValue
|
||||
let start = acc
|
||||
acc += frac
|
||||
return (item.category, start, min(acc, 1), Color(hex: item.color) ?? .gray)
|
||||
}
|
||||
}
|
||||
|
||||
private var centerItem: (label: String, value: String, sub: String?)? {
|
||||
if let selected = selectedSlice, let item = data.first(where: { $0.category == selected }) {
|
||||
let pct = total > 0 ? NSDecimalNumber(decimal: item.value / total).doubleValue * 100 : 0
|
||||
return (selected, item.value.compactCurrencyString, String(format: "%.1f%%", pct))
|
||||
}
|
||||
return (String(localized: "allocation_total"), total.compactCurrencyString, nil)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geo in
|
||||
// The semicircle is 2:1 (width:height). Fit it to whichever
|
||||
// dimension binds so it scales up on iPad without clipping.
|
||||
let w = min(geo.size.width, geo.size.height * 2)
|
||||
let lineWidth = w * 0.17
|
||||
let radius = (w - lineWidth) / 2
|
||||
let center = CGPoint(x: geo.size.width / 2, y: (geo.size.height + w / 2) / 2)
|
||||
|
||||
ZStack {
|
||||
// Track
|
||||
SemicircleArc(startFraction: 0, endFraction: 1, radius: radius, center: center)
|
||||
.stroke(Color(.systemGray5), style: StrokeStyle(lineWidth: lineWidth, lineCap: .butt))
|
||||
|
||||
ForEach(segments, id: \.category) { seg in
|
||||
SemicircleArc(startFraction: seg.start, endFraction: seg.end, radius: radius, center: center, inset: 0.006)
|
||||
.stroke(seg.color, style: StrokeStyle(lineWidth: lineWidth, lineCap: .butt))
|
||||
.opacity(selectedSlice == nil || selectedSlice == seg.category ? 1 : 0.4)
|
||||
.contentShape(SemicircleArc(startFraction: seg.start, endFraction: seg.end, radius: radius, center: center).stroke(style: StrokeStyle(lineWidth: lineWidth)))
|
||||
.onTapGesture {
|
||||
selectedSlice = (selectedSlice == seg.category) ? nil : seg.category
|
||||
}
|
||||
}
|
||||
|
||||
// Percentage labels on the arc band (only where the slice is wide
|
||||
// enough to fit the text).
|
||||
ForEach(segments, id: \.category) { seg in
|
||||
let frac = seg.end - seg.start
|
||||
if frac >= 0.07 {
|
||||
let mid = (seg.start + seg.end) / 2
|
||||
let angle = (180 + mid * 180) * .pi / 180
|
||||
let pos = CGPoint(x: center.x + radius * CoreGraphics.cos(angle),
|
||||
y: center.y + radius * CoreGraphics.sin(angle))
|
||||
Text("\(Int((frac * 100).rounded()))%")
|
||||
.font(.system(size: lineWidth * 0.42, weight: .bold))
|
||||
.foregroundColor(.white)
|
||||
.position(pos)
|
||||
.opacity(selectedSlice == nil || selectedSlice == seg.category ? 1 : 0.4)
|
||||
}
|
||||
}
|
||||
|
||||
// Center readout, sitting just under the arc
|
||||
if let c = centerItem {
|
||||
VStack(spacing: 1) {
|
||||
Text(c.label)
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
.lineLimit(1)
|
||||
Text(c.value)
|
||||
.font(.headline)
|
||||
if let sub = c.sub {
|
||||
Text(sub)
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
.position(x: center.x, y: center.y - lineWidth * 0.25)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An arc along the TOP semicircle, parameterized by fraction [0,1] of the 180°
|
||||
/// sweep (0 = left/9 o'clock, 1 = right/3 o'clock, passing through the top).
|
||||
struct SemicircleArc: Shape {
|
||||
let startFraction: Double
|
||||
let endFraction: Double
|
||||
let radius: CGFloat
|
||||
var center: CGPoint? = nil
|
||||
var inset: Double = 0
|
||||
|
||||
func path(in rect: CGRect) -> Path {
|
||||
let c = center ?? CGPoint(x: rect.width / 2, y: rect.width / 2)
|
||||
let start = Angle.degrees(180 + (startFraction + inset) * 180)
|
||||
let end = Angle.degrees(180 + (endFraction - inset) * 180)
|
||||
var p = Path()
|
||||
p.addArc(center: c, radius: radius, startAngle: start, endAngle: end, clockwise: false)
|
||||
return p
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Allocation Targets Comparison
|
||||
|
||||
struct AllocationTargetsComparisonChart: View {
|
||||
|
||||
@@ -11,6 +11,34 @@ enum ChartLabels {
|
||||
/// A series with at most this many points shows its value labels permanently.
|
||||
static let alwaysShowThreshold = 12
|
||||
|
||||
/// Max permanent labels to render before thinning — fewer on the narrow
|
||||
/// iPhone screen where crammed labels overlap and become unreadable.
|
||||
static func maxLabels(compact: Bool) -> Int { compact ? 5 : 8 }
|
||||
|
||||
/// Stride between labelled points so at most `maxLabels` show.
|
||||
static func stride(count: Int, compact: Bool) -> Int {
|
||||
let maxN = maxLabels(compact: compact)
|
||||
guard count > maxN else { return 1 }
|
||||
return Int(ceil(Double(count) / Double(maxN)))
|
||||
}
|
||||
|
||||
/// Whether point `index` should carry a permanent label: always the first
|
||||
/// and last (the endpoints matter most), plus every `stride`-th in between.
|
||||
/// On iPhone (compact) permanent labels collide with the axis on the narrow
|
||||
/// screen, so only the endpoint keeps its label — exact values live in the
|
||||
/// scrub header instead.
|
||||
static func showsLabel(index: Int, count: Int, compact: Bool) -> Bool {
|
||||
guard count > 0 else { return false }
|
||||
if compact {
|
||||
return count <= 6 ? true : index == count - 1
|
||||
}
|
||||
if index == 0 || index == count - 1 { return true }
|
||||
let s = stride(count: count, compact: compact)
|
||||
// Avoid a label landing right next to the forced last one.
|
||||
if index >= count - 1 - (s / 2) { return false }
|
||||
return index % s == 0
|
||||
}
|
||||
|
||||
static func compactCurrency(_ value: Decimal) -> String {
|
||||
value.compactCurrencyString
|
||||
}
|
||||
|
||||
@@ -1,149 +1,422 @@
|
||||
import SwiftUI
|
||||
import Charts
|
||||
import UIKit
|
||||
|
||||
// MARK: - Time-series chart zoom
|
||||
// MARK: - Time-series chart zoom (window model)
|
||||
//
|
||||
// Shared zoom behavior for every chart with a Date X axis. Default state is
|
||||
// "fit all" (no behavior change); zooming in narrows the visible X domain and
|
||||
// enables horizontal scrolling so the user can pan through history. Zoom is
|
||||
// driven by pinch (magnification) and by explicit +/- buttons for
|
||||
// discoverability and accessibility (HIG: never make gestures the only way).
|
||||
// Shared VISUAL zoom for every chart with a Date X axis: pinch anchored on the
|
||||
// window center, native horizontal pan once zoomed, double-tap to reset.
|
||||
//
|
||||
// The DATA range is a separate, global concern: the range brush
|
||||
// (`ChartRangeBrush`) lives in the period bar and drives
|
||||
// `ChartsViewModel.customRange`, filtering every chart at once. Keeping it out
|
||||
// of the chart content also keeps it out of the chart-swipe gesture's reach.
|
||||
|
||||
/// View-side zoom state for a time-series chart. `visibleSpan == nil` means fit-all.
|
||||
/// View-side window state for a time-series chart. `spanSeconds == nil` means fit-all.
|
||||
struct ChartZoomModel {
|
||||
/// Currently visible X-domain length in seconds; nil = show everything.
|
||||
var visibleSpan: TimeInterval?
|
||||
/// Span captured when a pinch gesture begins.
|
||||
var pinchAnchor: TimeInterval?
|
||||
|
||||
init() {
|
||||
visibleSpan = nil
|
||||
pinchAnchor = nil
|
||||
}
|
||||
/// Visible X-domain length in seconds; nil = show everything.
|
||||
var spanSeconds: TimeInterval?
|
||||
/// Start of the visible window (chart scroll position).
|
||||
var startX: Date = .distantPast
|
||||
/// (span, center) captured when a pinch begins.
|
||||
var pinchAnchor: (span: TimeInterval, center: Date)?
|
||||
|
||||
static let minSpan: TimeInterval = 60 * 60 * 24 * 45 // ~1.5 months
|
||||
private static let zoomStep = 0.6 // per button tap
|
||||
/// Padding after the last point so it isn't clipped at the window edge.
|
||||
static let endPadding: TimeInterval = 60 * 60 * 24 * 15 // half month
|
||||
|
||||
init() {}
|
||||
|
||||
var isZoomed: Bool { spanSeconds != nil }
|
||||
|
||||
/// The currently visible window, given the full data domain.
|
||||
func visibleWindow(dates: [Date]) -> ClosedRange<Date>? {
|
||||
guard let first = dates.first, let last = dates.last else { return nil }
|
||||
guard let span = spanSeconds else { return first...last }
|
||||
let start = max(first, min(startX, last))
|
||||
let end = min(last, start.addingTimeInterval(span))
|
||||
return start <= end ? start...end : first...last
|
||||
}
|
||||
|
||||
func clamped(_ span: TimeInterval, fullSpan: TimeInterval) -> TimeInterval? {
|
||||
let upper = max(fullSpan, Self.minSpan)
|
||||
let upper = max(fullSpan + Self.endPadding, Self.minSpan)
|
||||
let clamped = min(max(span, Self.minSpan), upper)
|
||||
// Snap back to fit-all when zoomed (almost) fully out.
|
||||
return clamped >= upper * 0.98 ? nil : clamped
|
||||
}
|
||||
|
||||
mutating func zoomIn(fullSpan: TimeInterval) {
|
||||
let current = visibleSpan ?? fullSpan
|
||||
visibleSpan = clamped(current * Self.zoomStep, fullSpan: fullSpan)
|
||||
}
|
||||
|
||||
mutating func zoomOut(fullSpan: TimeInterval) {
|
||||
guard let current = visibleSpan else { return }
|
||||
visibleSpan = clamped(current / Self.zoomStep, fullSpan: fullSpan)
|
||||
}
|
||||
|
||||
mutating func reset() {
|
||||
visibleSpan = nil
|
||||
spanSeconds = nil
|
||||
pinchAnchor = nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Haptics
|
||||
|
||||
enum ChartHaptics {
|
||||
private static let selection = UISelectionFeedbackGenerator()
|
||||
private static let impact = UIImpactFeedbackGenerator(style: .light)
|
||||
|
||||
static func tick() { selection.selectionChanged() }
|
||||
static func bump() { impact.impactOccurred() }
|
||||
}
|
||||
|
||||
extension View {
|
||||
/// Applies zoomable behavior to a Chart with a Date X axis.
|
||||
/// - Parameters:
|
||||
/// - dates: the full set of X values (used to compute the total span)
|
||||
/// - zoom: binding to the chart's zoom state
|
||||
/// Applies zoomable window behavior to a Chart with a Date X axis.
|
||||
@ViewBuilder
|
||||
func zoomableTimeSeries(dates: [Date], zoom: Binding<ChartZoomModel>) -> some View {
|
||||
let fullSpan = ChartZoomHelper.fullSpan(of: dates)
|
||||
self
|
||||
.modifier(ZoomDomainModifier(zoom: zoom, fullSpan: fullSpan))
|
||||
.simultaneousGesture(
|
||||
MagnifyGesture()
|
||||
.onChanged { value in
|
||||
var model = zoom.wrappedValue
|
||||
if model.pinchAnchor == nil {
|
||||
model.pinchAnchor = model.visibleSpan ?? fullSpan
|
||||
}
|
||||
if let anchor = model.pinchAnchor, value.magnification > 0 {
|
||||
model.visibleSpan = model.clamped(anchor / value.magnification, fullSpan: fullSpan)
|
||||
}
|
||||
zoom.wrappedValue = model
|
||||
}
|
||||
.onEnded { _ in
|
||||
zoom.wrappedValue.pinchAnchor = nil
|
||||
}
|
||||
)
|
||||
.overlay(alignment: .topTrailing) {
|
||||
ChartZoomControls(zoom: zoom, fullSpan: fullSpan)
|
||||
}
|
||||
modifier(ZoomableTimeSeriesModifier(dates: dates, zoom: zoom))
|
||||
}
|
||||
}
|
||||
|
||||
enum ChartZoomHelper {
|
||||
static func fullSpan(of dates: [Date]) -> TimeInterval {
|
||||
private struct ZoomableTimeSeriesModifier: ViewModifier {
|
||||
let dates: [Date]
|
||||
@Binding var zoom: ChartZoomModel
|
||||
|
||||
private var fullSpan: TimeInterval {
|
||||
guard let min = dates.min(), let max = dates.max(), max > min else {
|
||||
return ChartZoomModel.minSpan
|
||||
}
|
||||
return max.timeIntervalSince(min)
|
||||
}
|
||||
}
|
||||
|
||||
private struct ZoomDomainModifier: ViewModifier {
|
||||
@Binding var zoom: ChartZoomModel
|
||||
let fullSpan: TimeInterval
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
if let span = zoom.visibleSpan {
|
||||
content
|
||||
.chartScrollableAxes(.horizontal)
|
||||
.chartXVisibleDomain(length: span)
|
||||
} else {
|
||||
content
|
||||
Group {
|
||||
if let span = zoom.spanSeconds {
|
||||
content
|
||||
.chartScrollableAxes(.horizontal)
|
||||
.chartXVisibleDomain(length: span)
|
||||
.chartScrollPosition(x: $zoom.startX)
|
||||
} else {
|
||||
content
|
||||
}
|
||||
}
|
||||
.simultaneousGesture(pinchGesture)
|
||||
.simultaneousGesture(doubleTapGesture)
|
||||
}
|
||||
|
||||
// Pinch anchored on the window center: the point you're looking at stays put.
|
||||
private var pinchGesture: some Gesture {
|
||||
MagnifyGesture()
|
||||
.onChanged { value in
|
||||
var model = zoom
|
||||
if model.pinchAnchor == nil {
|
||||
let window = model.visibleWindow(dates: dates)
|
||||
let span = model.spanSeconds ?? (fullSpan + ChartZoomModel.endPadding)
|
||||
let center = window.map {
|
||||
$0.lowerBound.addingTimeInterval($0.upperBound.timeIntervalSince($0.lowerBound) / 2)
|
||||
} ?? Date()
|
||||
model.pinchAnchor = (span, center)
|
||||
}
|
||||
guard let anchor = model.pinchAnchor, value.magnification > 0 else { return }
|
||||
let newSpan = model.clamped(anchor.span / value.magnification, fullSpan: fullSpan)
|
||||
model.spanSeconds = newSpan
|
||||
if let newSpan, let first = dates.first, let last = dates.last {
|
||||
let start = anchor.center.addingTimeInterval(-newSpan / 2)
|
||||
let maxStart = last.addingTimeInterval(-newSpan + ChartZoomModel.endPadding)
|
||||
model.startX = max(first, min(start, maxStart))
|
||||
}
|
||||
zoom = model
|
||||
}
|
||||
.onEnded { _ in
|
||||
zoom.pinchAnchor = nil
|
||||
}
|
||||
}
|
||||
|
||||
private var doubleTapGesture: some Gesture {
|
||||
TapGesture(count: 2)
|
||||
.onEnded {
|
||||
guard zoom.isZoomed else { return }
|
||||
withAnimation(.snappy) { zoom.reset() }
|
||||
ChartHaptics.bump()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Compact +/- (and reset when zoomed) control cluster shown on top of the chart.
|
||||
struct ChartZoomControls: View {
|
||||
@Binding var zoom: ChartZoomModel
|
||||
let fullSpan: TimeInterval
|
||||
// MARK: - Range brush (full-history sparkline + month-snapping handles)
|
||||
//
|
||||
// Index-based over the months that actually have data. Bound to a
|
||||
// ClosedRange<Int> selection owned by the caller (the charts container maps it
|
||||
// to ChartsViewModel.customRange). Selecting everything = "no filter".
|
||||
|
||||
struct ChartRangeBrush: View {
|
||||
let dates: [Date]
|
||||
let values: [Double]
|
||||
@Binding var selection: ClosedRange<Int>
|
||||
|
||||
@State private var dragStartIndices: (lo: Int, hi: Int)?
|
||||
@State private var dragTarget: DragTarget?
|
||||
@State private var lastTickedIndex: Int = -1
|
||||
@State private var showingPickers = false
|
||||
|
||||
/// What a drag manipulates, decided ONCE at touch-down by where it starts.
|
||||
/// One unified gesture instead of per-element gestures: with a narrow
|
||||
/// window (e.g. 2 months) the handles' hit areas used to swallow the whole
|
||||
/// window and moving it was impossible.
|
||||
private enum DragTarget { case lowerHandle, upperHandle, moveWindow }
|
||||
|
||||
private static let labelFormatter: DateFormatter = {
|
||||
let f = DateFormatter()
|
||||
f.locale = .autoupdatingCurrent
|
||||
f.setLocalizedDateFormatFromTemplate("MMM yyyy")
|
||||
return f
|
||||
}()
|
||||
|
||||
private var clampedSelection: (lo: Int, hi: Int) {
|
||||
guard !dates.isEmpty else { return (0, 0) }
|
||||
let lo = max(0, min(selection.lowerBound, dates.count - 1))
|
||||
let hi = max(lo, min(selection.upperBound, dates.count - 1))
|
||||
return (lo, hi)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 0) {
|
||||
controlButton(systemName: "minus.magnifyingglass", enabled: zoom.visibleSpan != nil) {
|
||||
withAnimation(.easeOut(duration: 0.15)) { zoom.zoomOut(fullSpan: fullSpan) }
|
||||
}
|
||||
.accessibilityLabel(String(localized: "chart_zoom_out"))
|
||||
|
||||
Divider().frame(height: 16)
|
||||
|
||||
controlButton(systemName: "plus.magnifyingglass",
|
||||
enabled: (zoom.visibleSpan ?? fullSpan) > ChartZoomModel.minSpan) {
|
||||
withAnimation(.easeOut(duration: 0.15)) { zoom.zoomIn(fullSpan: fullSpan) }
|
||||
}
|
||||
.accessibilityLabel(String(localized: "chart_zoom_in"))
|
||||
|
||||
if zoom.visibleSpan != nil {
|
||||
Divider().frame(height: 16)
|
||||
controlButton(systemName: "arrow.counterclockwise", enabled: true) {
|
||||
withAnimation(.easeOut(duration: 0.15)) { zoom.reset() }
|
||||
}
|
||||
.accessibilityLabel(String(localized: "chart_zoom_reset"))
|
||||
VStack(spacing: 6) {
|
||||
rangeLabel
|
||||
GeometryReader { geo in
|
||||
brushBody(width: geo.size.width)
|
||||
}
|
||||
.frame(height: 44)
|
||||
}
|
||||
.sheet(isPresented: $showingPickers) {
|
||||
ChartRangePickerSheet(dates: dates, selection: $selection)
|
||||
.presentationDetents([.height(320)])
|
||||
}
|
||||
.background(.ultraThinMaterial, in: Capsule())
|
||||
.overlay(Capsule().stroke(Color.gray.opacity(0.2), lineWidth: 0.5))
|
||||
.padding(6)
|
||||
}
|
||||
|
||||
private func controlButton(systemName: String, enabled: Bool, action: @escaping () -> Void) -> some View {
|
||||
Button(action: action) {
|
||||
Image(systemName: systemName)
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.foregroundColor(enabled ? .appPrimary : .secondary.opacity(0.4))
|
||||
.frame(width: 32, height: 28)
|
||||
.contentShape(Rectangle())
|
||||
private var rangeLabel: some View {
|
||||
let (lo, hi) = clampedSelection
|
||||
return Button {
|
||||
showingPickers = true
|
||||
} label: {
|
||||
HStack(spacing: 5) {
|
||||
Text("\(Self.labelFormatter.string(from: dates[lo])) – \(Self.labelFormatter.string(from: dates[hi]))")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundColor(.appPrimary)
|
||||
Image(systemName: "chevron.up.chevron.down")
|
||||
.font(.system(size: 8, weight: .semibold))
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(!enabled)
|
||||
.accessibilityLabel(String(localized: "chart_range_button"))
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func brushBody(width: CGFloat) -> some View {
|
||||
let (lo, hi) = clampedSelection
|
||||
let xLo = xPosition(index: lo, width: width)
|
||||
let xHi = xPosition(index: hi, width: width)
|
||||
|
||||
ZStack(alignment: .leading) {
|
||||
// Full-history sparkline (widget-style: gradient line, no axes)
|
||||
SparklinePath(values: values)
|
||||
.stroke(
|
||||
LinearGradient(colors: [.appPrimary, .cyan],
|
||||
startPoint: .leading, endPoint: .trailing),
|
||||
style: StrokeStyle(lineWidth: 1.8, lineCap: .round, lineJoin: .round)
|
||||
)
|
||||
.opacity(0.85)
|
||||
|
||||
// Dimming outside the window
|
||||
Rectangle()
|
||||
.fill(Color(.systemBackground).opacity(0.72))
|
||||
.frame(width: max(0, xLo))
|
||||
Rectangle()
|
||||
.fill(Color(.systemBackground).opacity(0.72))
|
||||
.frame(width: max(0, width - xHi))
|
||||
.offset(x: xHi)
|
||||
|
||||
// Window frame
|
||||
RoundedRectangle(cornerRadius: 6)
|
||||
.stroke(Color.appPrimary.opacity(0.7), lineWidth: 1.4)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 6).fill(Color.appPrimary.opacity(0.06))
|
||||
)
|
||||
.frame(width: max(10, xHi - xLo))
|
||||
.offset(x: xLo)
|
||||
|
||||
// Handles (visual only — hit-testing lives in the unified gesture)
|
||||
handle(at: xLo)
|
||||
handle(at: xHi)
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.gesture(unifiedGesture(width: width))
|
||||
}
|
||||
|
||||
private func handle(at x: CGFloat) -> some View {
|
||||
Capsule()
|
||||
.fill(Color.appPrimary)
|
||||
.frame(width: 5, height: 26)
|
||||
.overlay(
|
||||
Capsule().stroke(Color(.systemBackground), lineWidth: 1.5)
|
||||
)
|
||||
.frame(height: 44)
|
||||
.offset(x: x - 2.5)
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
|
||||
// MARK: Geometry helpers
|
||||
|
||||
private func xPosition(index: Int, width: CGFloat) -> CGFloat {
|
||||
guard dates.count > 1 else { return 0 }
|
||||
return width * CGFloat(index) / CGFloat(dates.count - 1)
|
||||
}
|
||||
|
||||
private func index(atX x: CGFloat, width: CGFloat) -> Int {
|
||||
guard dates.count > 1, width > 0 else { return 0 }
|
||||
let frac = max(0, min(1, x / width))
|
||||
return Int((frac * CGFloat(dates.count - 1)).rounded())
|
||||
}
|
||||
|
||||
// MARK: Unified gesture
|
||||
//
|
||||
// Decided at touch-down: grabbing near a handle resizes that edge; grabbing
|
||||
// anywhere else (inside the window OR on the dimmed history) slides the
|
||||
// whole window keeping its width — select Jan–Mar, then swipe to land on
|
||||
// Jun–Aug. Narrow windows stay movable because the edge grip only wins in
|
||||
// its 18pt zone (from outside when the window is too narrow to share).
|
||||
|
||||
private func unifiedGesture(width: CGFloat) -> some Gesture {
|
||||
DragGesture(minimumDistance: 1)
|
||||
.onChanged { value in
|
||||
let (lo, hi) = dragStartIndices ?? clampedSelection
|
||||
if dragStartIndices == nil {
|
||||
dragStartIndices = (lo, hi)
|
||||
dragTarget = target(
|
||||
forX: value.startLocation.x,
|
||||
xLo: xPosition(index: lo, width: width),
|
||||
xHi: xPosition(index: hi, width: width)
|
||||
)
|
||||
}
|
||||
guard let target = dragTarget else { return }
|
||||
var newLo = lo
|
||||
var newHi = hi
|
||||
switch target {
|
||||
case .lowerHandle:
|
||||
newLo = min(index(atX: value.location.x, width: width), hi)
|
||||
case .upperHandle:
|
||||
newHi = max(index(atX: value.location.x, width: width), lo)
|
||||
case .moveWindow:
|
||||
let span = hi - lo
|
||||
let deltaIdx = Int((value.translation.width / max(width, 1) * CGFloat(dates.count - 1)).rounded())
|
||||
newLo = max(0, min(lo + deltaIdx, dates.count - 1 - span))
|
||||
newHi = newLo + span
|
||||
}
|
||||
let ticked = (target == .upperHandle) ? newHi : newLo
|
||||
if ticked != lastTickedIndex {
|
||||
lastTickedIndex = ticked
|
||||
ChartHaptics.tick()
|
||||
}
|
||||
selection = newLo...newHi
|
||||
}
|
||||
.onEnded { _ in
|
||||
dragStartIndices = nil
|
||||
dragTarget = nil
|
||||
lastTickedIndex = -1
|
||||
}
|
||||
}
|
||||
|
||||
private func target(forX x: CGFloat, xLo: CGFloat, xHi: CGFloat) -> DragTarget {
|
||||
let grip: CGFloat = 18
|
||||
let windowWidth = xHi - xLo
|
||||
if windowWidth >= grip * 3 {
|
||||
// Wide window: edges grab from either side of the handle.
|
||||
if abs(x - xLo) <= grip { return .lowerHandle }
|
||||
if abs(x - xHi) <= grip { return .upperHandle }
|
||||
} else {
|
||||
// Narrow window: the inside belongs to MOVE; edges only from outside.
|
||||
if x < xLo && xLo - x <= grip { return .lowerHandle }
|
||||
if x > xHi && x - xHi <= grip { return .upperHandle }
|
||||
}
|
||||
return .moveWindow
|
||||
}
|
||||
}
|
||||
|
||||
/// Minimal normalized line path for the brush background.
|
||||
private struct SparklinePath: Shape {
|
||||
let values: [Double]
|
||||
|
||||
func path(in rect: CGRect) -> Path {
|
||||
var path = Path()
|
||||
guard values.count > 1 else { return path }
|
||||
let minV = values.min() ?? 0
|
||||
let maxV = values.max() ?? 1
|
||||
let range = maxV - minV
|
||||
let inset: CGFloat = 4
|
||||
func point(_ i: Int) -> CGPoint {
|
||||
let x = rect.width * CGFloat(i) / CGFloat(values.count - 1)
|
||||
let norm = range > 0 ? (values[i] - minV) / range : 0.5
|
||||
let y = inset + (rect.height - inset * 2) * (1 - CGFloat(norm))
|
||||
return CGPoint(x: x, y: y)
|
||||
}
|
||||
path.move(to: point(0))
|
||||
for i in 1..<values.count {
|
||||
path.addLine(to: point(i))
|
||||
}
|
||||
return path
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Precise month/year pickers
|
||||
|
||||
/// Two wheel pickers over the months that actually have data — the precise,
|
||||
/// non-gesture way to land exactly on e.g. Feb–Mar 2025.
|
||||
struct ChartRangePickerSheet: View {
|
||||
let dates: [Date]
|
||||
@Binding var selection: ClosedRange<Int>
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var fromIndex = 0
|
||||
@State private var toIndex = 0
|
||||
|
||||
private static let optionFormatter: DateFormatter = {
|
||||
let f = DateFormatter()
|
||||
f.locale = .autoupdatingCurrent
|
||||
f.setLocalizedDateFormatFromTemplate("MMMM yyyy")
|
||||
return f
|
||||
}()
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
HStack(spacing: 0) {
|
||||
picker(title: String(localized: "chart_range_from"), selection: $fromIndex)
|
||||
picker(title: String(localized: "chart_range_to"), selection: $toIndex)
|
||||
}
|
||||
.navigationTitle(String(localized: "chart_range_button"))
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("OK") {
|
||||
selection = min(fromIndex, toIndex)...max(fromIndex, toIndex)
|
||||
ChartHaptics.bump()
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
fromIndex = max(0, min(selection.lowerBound, dates.count - 1))
|
||||
toIndex = max(fromIndex, min(selection.upperBound, dates.count - 1))
|
||||
}
|
||||
.onChange(of: fromIndex) { _, new in
|
||||
if new > toIndex { toIndex = new }
|
||||
}
|
||||
.onChange(of: toIndex) { _, new in
|
||||
if new < fromIndex { fromIndex = new }
|
||||
}
|
||||
}
|
||||
|
||||
private func picker(title: String, selection: Binding<Int>) -> some View {
|
||||
VStack(spacing: 2) {
|
||||
Text(title)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundColor(.secondary)
|
||||
Picker(title, selection: selection) {
|
||||
ForEach(dates.indices, id: \.self) { i in
|
||||
Text(Self.optionFormatter.string(from: dates[i])).tag(i)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.wheel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import SwiftUI
|
||||
import Charts
|
||||
import TipKit
|
||||
|
||||
struct ChartsContainerView: View {
|
||||
@EnvironmentObject var accountStore: AccountStore
|
||||
@@ -87,6 +88,11 @@ struct ChartsContainerView: View {
|
||||
|
||||
chartToolbar
|
||||
|
||||
if showRangeBrush && supportsGlobalRange {
|
||||
globalRangeBrush
|
||||
.transition(.opacity.combined(with: .move(edge: .top)))
|
||||
}
|
||||
|
||||
if !viewModel.isPremium {
|
||||
CompactPaywallBanner(showingPaywall: $viewModel.showingPaywall)
|
||||
.onAppear {
|
||||
@@ -388,8 +394,69 @@ struct ChartsContainerView: View {
|
||||
ChartStat(label: "Bull case", value: lastPred.confidenceInterval.upper.compactCurrencyString, color: .positiveGreen),
|
||||
ChartStat(label: "Bear case", value: lastPred.confidenceInterval.lower.compactCurrencyString, color: .negativeRed),
|
||||
]
|
||||
case .yearOverYear, .comparison, .simulator, .periodComparison:
|
||||
// These charts carry their own comparison summaries inside the card.
|
||||
case .comparison:
|
||||
let series = viewModel.comparisonData
|
||||
guard !series.isEmpty else { return [] }
|
||||
// Rank sources by their latest value in the current display mode.
|
||||
let ranked = series.compactMap { s -> (name: String, val: Double)? in
|
||||
guard let last = s.points.last?.value else { return nil }
|
||||
return (s.name, last)
|
||||
}.sorted { $0.val > $1.val }
|
||||
let unit = viewModel.comparisonDisplayMode == .absolute ? "" : "%"
|
||||
func fmt(_ v: Double) -> String { unit.isEmpty ? Decimal(v).compactCurrencyString : String(format: "%+.1f%%", v) }
|
||||
var stats: [ChartStat] = [ChartStat(label: "Series", value: "\(series.count)", color: .secondary)]
|
||||
if let best = ranked.first {
|
||||
stats.append(ChartStat(label: "Leader", value: "\(best.name) \(fmt(best.val))", color: .positiveGreen))
|
||||
}
|
||||
if ranked.count > 1, let worst = ranked.last {
|
||||
stats.append(ChartStat(label: "Laggard", value: "\(worst.name) \(fmt(worst.val))", color: .negativeRed))
|
||||
}
|
||||
return stats
|
||||
case .periodComparison:
|
||||
let series = viewModel.periodComparisonData
|
||||
guard !series.isEmpty else { return [] }
|
||||
return series.map { s in
|
||||
let end = s.points.last?.returnPct ?? 0
|
||||
return ChartStat(label: s.label, value: String(format: "%+.1f%%", end),
|
||||
color: end >= 0 ? .positiveGreen : .negativeRed)
|
||||
}
|
||||
case .yearOverYear:
|
||||
let all = viewModel.yearOverYearData.sorted { $0.year < $1.year }
|
||||
guard let latest = all.last else { return [] }
|
||||
func endReturn(_ ys: ChartsViewModel.YearSeries) -> Double {
|
||||
ys.forecastEndValue ?? (ys.values.compactMap { $0.isNaN ? nil : $0 }.last ?? 0)
|
||||
}
|
||||
let latestEnd = endReturn(latest)
|
||||
var stats: [ChartStat] = [
|
||||
ChartStat(label: "\(latest.year)", value: String(format: "%+.1f%%", latestEnd),
|
||||
color: latestEnd >= 0 ? .positiveGreen : .negativeRed)
|
||||
]
|
||||
// Current year vs previous year (end-of-year return difference).
|
||||
if all.count >= 2 {
|
||||
let prev = all[all.count - 2]
|
||||
let diff = latestEnd - endReturn(prev)
|
||||
stats.append(ChartStat(
|
||||
label: String(format: String(localized: "yoy_vs_prev"), "\(prev.year)"),
|
||||
value: String(format: "%+.1f pp", diff),
|
||||
color: diff >= 0 ? .positiveGreen : .negativeRed))
|
||||
}
|
||||
// Best and worst full year on record.
|
||||
if all.count >= 2 {
|
||||
let ranked = all.map { ($0.year, endReturn($0)) }.sorted { $0.1 > $1.1 }
|
||||
if let best = ranked.first {
|
||||
stats.append(ChartStat(label: String(localized: "yoy_best_year"),
|
||||
value: "\(best.0) \(String(format: "%+.1f%%", best.1))",
|
||||
color: .positiveGreen))
|
||||
}
|
||||
if let worst = ranked.last, worst.0 != ranked.first?.0 {
|
||||
stats.append(ChartStat(label: String(localized: "yoy_worst_year"),
|
||||
value: "\(worst.0) \(String(format: "%+.1f%%", worst.1))",
|
||||
color: .negativeRed))
|
||||
}
|
||||
}
|
||||
return stats
|
||||
case .simulator:
|
||||
// The simulator carries its own live sliders and comparison inline.
|
||||
return []
|
||||
}
|
||||
}
|
||||
@@ -430,7 +497,7 @@ struct ChartsContainerView: View {
|
||||
}
|
||||
Spacer()
|
||||
if viewModel.selectedChartType == .performance {
|
||||
performanceSlider
|
||||
performancePeriodPicker
|
||||
.frame(maxWidth: 360)
|
||||
} else if !ranges.isEmpty {
|
||||
Picker("Period", selection: $viewModel.selectedTimeRange) {
|
||||
@@ -441,35 +508,55 @@ struct ChartsContainerView: View {
|
||||
.pickerStyle(.segmented)
|
||||
.frame(maxWidth: 360)
|
||||
}
|
||||
if supportsGlobalRange {
|
||||
rangeToggleButton
|
||||
}
|
||||
shareButton
|
||||
}
|
||||
.padding(.horizontal, 4)
|
||||
}
|
||||
|
||||
private var performanceSlider: some View {
|
||||
HStack(spacing: 10) {
|
||||
Text("Period: \(periodLabel(viewModel.performancePeriodMonths))")
|
||||
.font(.caption.weight(.medium))
|
||||
.foregroundColor(.secondary)
|
||||
.frame(width: 92, alignment: .leading)
|
||||
Slider(
|
||||
value: Binding(
|
||||
get: { Double(viewModel.performancePeriodMonths) },
|
||||
set: { viewModel.performancePeriodMonths = Int($0) }
|
||||
),
|
||||
in: 1...60,
|
||||
step: 1
|
||||
)
|
||||
.tint(.appPrimary)
|
||||
/// Months of history actually available (distinct aggregated months). The
|
||||
/// Performance period options never exceed this, so the control can't ask
|
||||
/// for a window with no data.
|
||||
private var availableHistoryMonths: Int {
|
||||
max(1, viewModel.evolutionData.count)
|
||||
}
|
||||
|
||||
/// Discrete period options (in months) capped to available data — mirrors
|
||||
/// the segmented period control used by every other chart for consistency.
|
||||
private var performancePeriodOptions: [(label: String, months: Int)] {
|
||||
let candidates = [(3, "3M"), (6, "6M"), (12, "12M"), (24, "2Y")]
|
||||
var opts = candidates.filter { $0.0 < availableHistoryMonths }.map { ($0.1, $0.0) }
|
||||
opts.append(("All", availableHistoryMonths)) // full history
|
||||
return opts.map { (label: $0.0, months: $0.1) }
|
||||
}
|
||||
|
||||
private var performancePeriodPicker: some View {
|
||||
Picker("Period", selection: Binding(
|
||||
get: {
|
||||
// Snap to the closest available option.
|
||||
let m = viewModel.performancePeriodMonths
|
||||
return performancePeriodOptions.min(by: { abs($0.months - m) < abs($1.months - m) })?.months
|
||||
?? availableHistoryMonths
|
||||
},
|
||||
set: { viewModel.performancePeriodMonths = min($0, availableHistoryMonths) }
|
||||
)) {
|
||||
ForEach(performancePeriodOptions, id: \.months) { opt in
|
||||
Text(opt.label).tag(opt.months)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
}
|
||||
|
||||
private var shareButton: some View {
|
||||
Button {
|
||||
ChartShareTip().invalidate(reason: .actionPerformed)
|
||||
shareCurrentChart()
|
||||
} label: {
|
||||
Image(systemName: "square.and.arrow.up")
|
||||
}
|
||||
.popoverTip(ChartShareTip())
|
||||
.disabled(!viewModel.hasData || viewModel.isLoading || selectedChartLocked)
|
||||
.accessibilityLabel(String(localized: "chart_share_button"))
|
||||
}
|
||||
@@ -516,7 +603,8 @@ struct ChartsContainerView: View {
|
||||
ZStack {
|
||||
AppBackground()
|
||||
ScrollView {
|
||||
VStack(spacing: 20) {
|
||||
VStack(spacing: 16) {
|
||||
chartTypeChipBar
|
||||
if !viewModel.isPremium {
|
||||
CompactPaywallBanner(showingPaywall: $viewModel.showingPaywall)
|
||||
.onAppear {
|
||||
@@ -524,7 +612,14 @@ struct ChartsContainerView: View {
|
||||
}
|
||||
}
|
||||
iPhonePeriodControl
|
||||
if hasActiveFilters {
|
||||
activeFilterChip
|
||||
}
|
||||
// No swipe-between-charts: it collided with every horizontal
|
||||
// interaction (scrub, pan, brush). The chip bar navigates.
|
||||
chartContent
|
||||
.id(viewModel.selectedChartType)
|
||||
.transition(.opacity)
|
||||
if viewModel.hiddenHistoryMonths > 0 {
|
||||
lockedHistoryTeaser
|
||||
}
|
||||
@@ -535,26 +630,217 @@ struct ChartsContainerView: View {
|
||||
.sheet(isPresented: $viewModel.showingPaywall) { PaywallView() }
|
||||
}
|
||||
|
||||
// MARK: - iPhone chart type chip bar
|
||||
//
|
||||
// Always-visible horizontal switcher (Health-app pattern) — replaces the
|
||||
// hidden title menu as primary navigation and the broken page-dot row.
|
||||
// Swipe still works and stays in sync via the auto-scroll.
|
||||
|
||||
private var chartTypeChipBar: some View {
|
||||
ScrollViewReader { proxy in
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 8) {
|
||||
ForEach(orderedChartTypes) { chartType in
|
||||
chartTypeChip(chartType)
|
||||
.id(chartType)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 2)
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
.onChange(of: viewModel.selectedChartType) { _, newValue in
|
||||
withAnimation(.snappy) { proxy.scrollTo(newValue, anchor: .center) }
|
||||
}
|
||||
.onAppear {
|
||||
proxy.scrollTo(viewModel.selectedChartType, anchor: .center)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func chartTypeChip(_ chartType: ChartsViewModel.ChartType) -> some View {
|
||||
let isSelected = viewModel.selectedChartType == chartType
|
||||
let locked = chartType.isPremium && !viewModel.isPremium
|
||||
return Button {
|
||||
guard !isSelected else { return }
|
||||
ChartHaptics.tick()
|
||||
withAnimation(.snappy) { viewModel.selectChart(chartType) }
|
||||
} label: {
|
||||
HStack(spacing: 5) {
|
||||
Image(systemName: locked ? "lock.fill" : chartType.icon)
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
Text(chartType.rawValue)
|
||||
.font(.footnote.weight(isSelected ? .semibold : .regular))
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 7)
|
||||
.foregroundColor(isSelected ? .white : (locked ? .secondary : .primary))
|
||||
.background(
|
||||
Capsule().fill(isSelected ? Color.appPrimary : Color(.systemBackground))
|
||||
)
|
||||
.overlay(
|
||||
Capsule().stroke(isSelected ? Color.clear : Color.gray.opacity(0.25), lineWidth: 0.8)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
/// Visible affordance for an active drill-down/filter — tap to clear.
|
||||
private var activeFilterChip: some View {
|
||||
let name = viewModel.selectedCategory?.name
|
||||
?? viewModel.availableSources(for: viewModel.selectedChartType)
|
||||
.first(where: { viewModel.selectedSourceIds.contains($0.id) })?.name
|
||||
?? ""
|
||||
return HStack {
|
||||
Button {
|
||||
withAnimation(.snappy) {
|
||||
viewModel.selectedCategory = nil
|
||||
viewModel.selectedSource = nil
|
||||
viewModel.selectedSourceIds.removeAll()
|
||||
viewModel.selectedBreakdown = .category
|
||||
}
|
||||
ChartHaptics.bump()
|
||||
} label: {
|
||||
HStack(spacing: 6) {
|
||||
Text(String(format: String(localized: "chart_filtered_prefix"), name))
|
||||
.font(.footnote.weight(.medium))
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.font(.footnote)
|
||||
}
|
||||
.foregroundColor(.appPrimary)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 7)
|
||||
.background(Capsule().fill(Color.appPrimary.opacity(0.12)))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
|
||||
/// Chart types in the same grouped order as the title switcher menu.
|
||||
private var orderedChartTypes: [ChartsViewModel.ChartType] {
|
||||
Self.chartGroups.flatMap { $0.types }.filter { showForecast || $0 != .prediction }
|
||||
}
|
||||
|
||||
/// Stocks-style segmented period control above the chart (the chart switcher
|
||||
/// lives in the title menu, filters in the toolbar).
|
||||
/// lives in the title menu, filters in the toolbar), plus the global range
|
||||
/// brush toggle. The brush lives HERE — outside chartContent — so its drags
|
||||
/// can't collide with the chart-swipe gesture, and its window filters the
|
||||
/// data of EVERY chart (it replaces the preset as data range).
|
||||
@ViewBuilder
|
||||
private var iPhonePeriodControl: some View {
|
||||
let ranges = viewModel.availableTimeRanges(for: viewModel.selectedChartType)
|
||||
if viewModel.selectedChartType == .performance {
|
||||
performanceSlider
|
||||
.padding(12)
|
||||
.background(Color(.systemBackground))
|
||||
.cornerRadius(AppConstants.UI.smallCornerRadius)
|
||||
} else if !ranges.isEmpty {
|
||||
Picker("Period", selection: $viewModel.selectedTimeRange) {
|
||||
ForEach(ranges) { range in
|
||||
Text(range.rawValue).tag(range)
|
||||
VStack(spacing: 10) {
|
||||
HStack(spacing: 8) {
|
||||
if viewModel.selectedChartType == .performance {
|
||||
performancePeriodPicker
|
||||
} else if !ranges.isEmpty {
|
||||
Picker("Period", selection: $viewModel.selectedTimeRange) {
|
||||
ForEach(ranges) { range in
|
||||
Text(range.rawValue).tag(range)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
}
|
||||
if supportsGlobalRange {
|
||||
rangeToggleButton
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
if showRangeBrush && supportsGlobalRange {
|
||||
globalRangeBrush
|
||||
.transition(.opacity.combined(with: .move(edge: .top)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Global range brush (shared data window for all charts)
|
||||
|
||||
@State private var showRangeBrush = false
|
||||
|
||||
/// Charts whose data honors customRange (Performance/Simulator/Period vs
|
||||
/// Period fetch their own windows).
|
||||
private var supportsGlobalRange: Bool {
|
||||
!viewModel.availableTimeRanges(for: viewModel.selectedChartType).isEmpty
|
||||
&& viewModel.selectedChartType != .performance
|
||||
&& viewModel.fullHistorySeries.count >= 2
|
||||
}
|
||||
|
||||
private var rangeToggleButton: some View {
|
||||
Button {
|
||||
withAnimation(.snappy) { showRangeBrush.toggle() }
|
||||
ChartHaptics.bump()
|
||||
if showRangeBrush {
|
||||
// Load the full history so any window can be selected.
|
||||
if viewModel.selectedTimeRange != .all { viewModel.selectedTimeRange = .all }
|
||||
} else {
|
||||
viewModel.customRange = nil
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "calendar.badge.clock")
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.foregroundColor(showRangeBrush ? .white : .appPrimary)
|
||||
.frame(width: 38, height: 30)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.fill(showRangeBrush ? Color.appPrimary : Color(.systemBackground))
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.stroke(Color.gray.opacity(showRangeBrush ? 0 : 0.3), lineWidth: 0.8)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(String(localized: "chart_range_button"))
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var globalRangeBrush: some View {
|
||||
let series = viewModel.fullHistorySeries
|
||||
if series.count >= 2 {
|
||||
ChartRangeBrush(
|
||||
dates: series.map(\.date),
|
||||
values: series.map { NSDecimalNumber(decimal: $0.value).doubleValue },
|
||||
selection: rangeSelection
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Maps the brush's month indices to the view model's date window.
|
||||
/// Full selection = no filter (nil).
|
||||
private var rangeSelection: Binding<ClosedRange<Int>> {
|
||||
Binding(
|
||||
get: {
|
||||
let dates = viewModel.fullHistorySeries.map(\.date)
|
||||
guard let last = dates.indices.last else { return 0...0 }
|
||||
guard let range = viewModel.customRange else { return 0...last }
|
||||
let lo = nearestIndex(in: dates, to: range.lowerBound)
|
||||
let hi = nearestIndex(in: dates, to: range.upperBound)
|
||||
return min(lo, hi)...max(lo, hi)
|
||||
},
|
||||
set: { sel in
|
||||
let dates = viewModel.fullHistorySeries.map(\.date)
|
||||
guard let last = dates.indices.last else { return }
|
||||
if sel.lowerBound <= 0 && sel.upperBound >= last {
|
||||
viewModel.customRange = nil
|
||||
} else {
|
||||
viewModel.setCustomRange(
|
||||
fromMonth: dates[max(0, sel.lowerBound)],
|
||||
toMonth: dates[min(last, sel.upperBound)]
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private func nearestIndex(in dates: [Date], to date: Date) -> Int {
|
||||
var best = 0
|
||||
var bestDist = TimeInterval.greatestFiniteMagnitude
|
||||
for (i, d) in dates.enumerated() {
|
||||
let dist = abs(d.timeIntervalSince(date))
|
||||
if dist < bestDist { best = i; bestDist = dist }
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
// MARK: - Shared
|
||||
|
||||
private func syncState() {
|
||||
@@ -567,6 +853,34 @@ struct ChartsContainerView: View {
|
||||
viewModel.updatePredictionTargetDate(goalsViewModel.goals)
|
||||
}
|
||||
|
||||
/// Drill-down: tapping a category/source in Allocation or Performance jumps
|
||||
/// to Evolution filtered to it (premium). The active-filter chip is the way
|
||||
/// back.
|
||||
private func drillDown(to name: String) {
|
||||
guard viewModel.isPremium else {
|
||||
FirebaseService.shared.logPaywallShown(trigger: "chart_workable")
|
||||
viewModel.showingPaywall = true
|
||||
return
|
||||
}
|
||||
ChartHaptics.bump()
|
||||
withAnimation(.snappy) {
|
||||
if viewModel.selectedBreakdown == .source {
|
||||
if let source = viewModel.availableSources(for: viewModel.selectedChartType)
|
||||
.first(where: { $0.name == name }) {
|
||||
viewModel.selectedCategory = nil
|
||||
viewModel.selectedSource = nil
|
||||
viewModel.selectedSourceIds = [source.id]
|
||||
}
|
||||
} else if let category = viewModel.availableCategories(for: viewModel.selectedChartType)
|
||||
.first(where: { $0.name == name }) {
|
||||
viewModel.selectedSource = nil
|
||||
viewModel.selectedSourceIds.removeAll()
|
||||
viewModel.selectedCategory = category
|
||||
}
|
||||
viewModel.selectChart(.evolution)
|
||||
}
|
||||
}
|
||||
|
||||
private func periodLabel(_ months: Int) -> String {
|
||||
if months < 12 {
|
||||
return "\(months)M"
|
||||
@@ -594,14 +908,21 @@ struct ChartsContainerView: View {
|
||||
EvolutionChartView(
|
||||
data: viewModel.evolutionData,
|
||||
categoryData: viewModel.categoryEvolutionData,
|
||||
goals: goalsViewModel.goals
|
||||
goals: goalsViewModel.goals,
|
||||
contributions: viewModel.contributionsData,
|
||||
isPremium: viewModel.isPremium,
|
||||
onPremiumNeeded: {
|
||||
FirebaseService.shared.logPaywallShown(trigger: "chart_workable")
|
||||
viewModel.showingPaywall = true
|
||||
}
|
||||
)
|
||||
case .allocation:
|
||||
VStack(spacing: 20) {
|
||||
AllocationPieChart(
|
||||
data: viewModel.allocationData,
|
||||
title: viewModel.selectedBreakdown == .source ? "Allocation by Source" : "Asset Allocation",
|
||||
showsTargetsComparison: ChartsViewModel.supportsAllocationTargets(for: viewModel.selectedBreakdown)
|
||||
showsTargetsComparison: ChartsViewModel.supportsAllocationTargets(for: viewModel.selectedBreakdown),
|
||||
onDrillDown: { name in drillDown(to: name) }
|
||||
)
|
||||
|
||||
if !viewModel.allocationEvolutionData.isEmpty {
|
||||
@@ -611,7 +932,8 @@ struct ChartsContainerView: View {
|
||||
case .performance:
|
||||
PerformanceBarChart(
|
||||
data: viewModel.performanceData,
|
||||
title: viewModel.selectedBreakdown == .source ? "Performance by Source" : "Performance by Category"
|
||||
title: viewModel.selectedBreakdown == .source ? "Performance by Source" : "Performance by Category",
|
||||
onDrillDown: { name in drillDown(to: name) }
|
||||
)
|
||||
case .contributions:
|
||||
ContributionsChartView(data: viewModel.contributionsData)
|
||||
@@ -750,11 +1072,18 @@ struct EvolutionChartView: View {
|
||||
@State private var zoom = ChartZoomModel()
|
||||
let categoryData: [CategoryEvolutionPoint]
|
||||
let goals: [Goal]
|
||||
var contributions: [(date: Date, amount: Decimal)] = []
|
||||
var isPremium: Bool = true
|
||||
var onPremiumNeeded: (() -> Void)? = nil
|
||||
@Environment(\.chartImageExport) private var chartImageExport
|
||||
@Environment(\.horizontalSizeClass) private var labelsSizeClass
|
||||
|
||||
private var labelsCompact: Bool { labelsSizeClass != .regular }
|
||||
|
||||
@State private var selectedDataPoint: (date: Date, value: Decimal)?
|
||||
@State private var chartMode: ChartMode = .total
|
||||
@State private var showGoalLines = false
|
||||
@State private var showContributions = false
|
||||
|
||||
enum ChartMode: String, CaseIterable, Identifiable {
|
||||
case total = "Total"
|
||||
@@ -827,7 +1156,23 @@ struct EvolutionChartView: View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
headerView
|
||||
modePicker
|
||||
if chartMode == .total && !contributions.isEmpty {
|
||||
HStack {
|
||||
contributionsToggle
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
chartSection
|
||||
if chartMode == .total, isPremium, let insight = rangeInsight {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "lightbulb.fill")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.appWarning)
|
||||
Text(insight)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
if !data.isEmpty && chartMode == .total {
|
||||
ChartStatsRow(stats: evolutionStats)
|
||||
}
|
||||
@@ -838,6 +1183,69 @@ struct EvolutionChartView: View {
|
||||
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
|
||||
}
|
||||
|
||||
/// One contextual takeaway recomputed for the visible window (premium).
|
||||
private var rangeInsight: String? {
|
||||
let series = visibleData
|
||||
guard series.count >= 3 else { return nil }
|
||||
var bestDate: Date?
|
||||
var bestPct = -Double.infinity
|
||||
for i in 1..<series.count {
|
||||
let prev = NSDecimalNumber(decimal: series[i - 1].value).doubleValue
|
||||
let cur = NSDecimalNumber(decimal: series[i].value).doubleValue
|
||||
guard prev > 0 else { continue }
|
||||
let pct = (cur - prev) / prev * 100
|
||||
if pct > bestPct {
|
||||
bestPct = pct
|
||||
bestDate = series[i].date
|
||||
}
|
||||
}
|
||||
guard let bestDate, bestPct.isFinite else { return nil }
|
||||
return String(
|
||||
format: String(localized: "chart_insight_best_month"),
|
||||
Self.headerRangeFormatter.string(from: bestDate),
|
||||
String(format: "%+.1f%%", bestPct)
|
||||
)
|
||||
}
|
||||
|
||||
/// Premium overlay: cumulative contributed capital vs market value — shows
|
||||
/// at a glance what is *your money* and what is growth.
|
||||
private var contributionsToggle: some View {
|
||||
Button {
|
||||
if isPremium {
|
||||
withAnimation(.snappy) { showContributions.toggle() }
|
||||
ChartHaptics.tick()
|
||||
} else {
|
||||
onPremiumNeeded?()
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: 4) {
|
||||
if !isPremium {
|
||||
Image(systemName: "lock.fill")
|
||||
.font(.system(size: 9, weight: .semibold))
|
||||
}
|
||||
Text(String(localized: "chart_overlay_contributions"))
|
||||
.font(.caption2.weight(.medium))
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 5)
|
||||
.foregroundColor(showContributions ? .appSecondary : .secondary)
|
||||
.background(
|
||||
Capsule().fill(showContributions ? Color.appSecondary.opacity(0.16) : Color(.systemGray6))
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
private var cumulativeContributions: [(date: Date, value: Double)] {
|
||||
var running = 0.0
|
||||
return contributions
|
||||
.sorted { $0.date < $1.date }
|
||||
.map { item in
|
||||
running += NSDecimalNumber(decimal: item.amount).doubleValue
|
||||
return (item.date, running)
|
||||
}
|
||||
}
|
||||
|
||||
private var evolutionStats: [ChartStat] {
|
||||
guard !data.isEmpty else { return [] }
|
||||
let values = data.map { NSDecimalNumber(decimal: $0.value).doubleValue }
|
||||
@@ -855,23 +1263,65 @@ struct EvolutionChartView: View {
|
||||
]
|
||||
}
|
||||
|
||||
// MARK: Live header
|
||||
//
|
||||
// The numbers live HERE, not on the chart: while scrubbing it shows the
|
||||
// exact point (value, date, delta vs window start); at rest it summarizes
|
||||
// the visible window. This is what lets the chart itself stay clean.
|
||||
|
||||
/// Data restricted to the currently visible window (zoom/brush).
|
||||
private var visibleData: [(date: Date, value: Decimal)] {
|
||||
guard let window = zoom.visibleWindow(dates: data.map(\.date)) else { return data }
|
||||
let filtered = data.filter { window.contains($0.date) }
|
||||
return filtered.isEmpty ? data : filtered
|
||||
}
|
||||
|
||||
private static let headerRangeFormatter: DateFormatter = {
|
||||
let f = DateFormatter()
|
||||
f.locale = .autoupdatingCurrent
|
||||
f.setLocalizedDateFormatFromTemplate("MMM yyyy")
|
||||
return f
|
||||
}()
|
||||
|
||||
private var headerView: some View {
|
||||
HStack {
|
||||
Text("Portfolio Evolution")
|
||||
.font(.headline)
|
||||
let series = visibleData
|
||||
let current = (chartMode == .total ? selectedDataPoint : nil)
|
||||
?? series.last.map { (date: $0.date, value: $0.value) }
|
||||
let firstValue = series.first.map { NSDecimalNumber(decimal: $0.value).doubleValue } ?? 0
|
||||
let currentValue = current.map { NSDecimalNumber(decimal: $0.value).doubleValue } ?? 0
|
||||
let deltaPct = firstValue > 0 ? (currentValue - firstValue) / firstValue * 100 : 0
|
||||
let subtitle: String = {
|
||||
if let selected = selectedDataPoint, chartMode == .total {
|
||||
return selected.date.monthYearString
|
||||
}
|
||||
guard let first = series.first?.date, let last = series.last?.date else { return "" }
|
||||
return "\(Self.headerRangeFormatter.string(from: first)) – \(Self.headerRangeFormatter.string(from: last))"
|
||||
}()
|
||||
|
||||
return HStack(alignment: .top) {
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 8) {
|
||||
Text((current?.value ?? 0).compactCurrencyString)
|
||||
.font(.system(.title2, design: .rounded).weight(.bold))
|
||||
.contentTransition(.numericText())
|
||||
if series.count > 1 {
|
||||
Text(String(format: "%+.1f%%", deltaPct))
|
||||
.font(.caption.weight(.bold))
|
||||
.foregroundColor(deltaPct >= 0 ? .positiveGreen : .negativeRed)
|
||||
.padding(.horizontal, 7)
|
||||
.padding(.vertical, 3)
|
||||
.background(
|
||||
Capsule().fill((deltaPct >= 0 ? Color.positiveGreen : Color.negativeRed).opacity(0.12))
|
||||
)
|
||||
}
|
||||
}
|
||||
Text(subtitle)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if let selected = selectedDataPoint, chartMode == .total {
|
||||
VStack(alignment: .trailing, spacing: 2) {
|
||||
Text(selected.value.compactCurrencyString)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Text(selected.date.monthYearString)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Button {
|
||||
showGoalLines.toggle()
|
||||
} label: {
|
||||
@@ -881,6 +1331,7 @@ struct EvolutionChartView: View {
|
||||
.disabled(goals.isEmpty)
|
||||
.accessibilityLabel(showGoalLines ? "Hide goals" : "Show goals")
|
||||
}
|
||||
.animation(.snappy, value: selectedDataPoint?.date)
|
||||
}
|
||||
|
||||
private var modePicker: some View {
|
||||
@@ -949,6 +1400,10 @@ struct EvolutionChartView: View {
|
||||
if let closest = data.min(by: {
|
||||
abs($0.date.timeIntervalSince(date)) < abs($1.date.timeIntervalSince(date))
|
||||
}) {
|
||||
// Haptic tick when the scrub snaps to a new point.
|
||||
if selectedDataPoint?.date != closest.date {
|
||||
ChartHaptics.tick()
|
||||
}
|
||||
selectedDataPoint = closest
|
||||
}
|
||||
}
|
||||
@@ -969,23 +1424,34 @@ struct EvolutionChartView: View {
|
||||
private var chartMarks: some ChartContent {
|
||||
switch chartMode {
|
||||
case .total:
|
||||
ForEach(data, id: \.date) { item in
|
||||
// Widget-parity styling: gradient stroke, soft area fill, and NO
|
||||
// permanent dots/bubbles on iPhone — the endpoint dot marks "now"
|
||||
// and exact values live in the live header while scrubbing.
|
||||
ForEach(Array(data.enumerated()), id: \.element.date) { pair in
|
||||
let item = pair.element
|
||||
LineMark(
|
||||
x: .value("Date", item.date),
|
||||
y: .value("Value", NSDecimalNumber(decimal: item.value).doubleValue)
|
||||
y: .value("Value", NSDecimalNumber(decimal: item.value).doubleValue),
|
||||
series: .value("Series", "total")
|
||||
)
|
||||
.foregroundStyle(Color.appPrimary)
|
||||
.foregroundStyle(
|
||||
LinearGradient(colors: [.appPrimary, .cyan],
|
||||
startPoint: .leading, endPoint: .trailing)
|
||||
)
|
||||
.lineStyle(StrokeStyle(lineWidth: 2.2, lineCap: .round, lineJoin: .round))
|
||||
.interpolationMethod(.catmullRom)
|
||||
|
||||
PointMark(
|
||||
x: .value("Date", item.date),
|
||||
y: .value("Value", NSDecimalNumber(decimal: item.value).doubleValue)
|
||||
)
|
||||
.foregroundStyle(Color.appPrimary)
|
||||
.symbolSize(data.count <= ChartLabels.alwaysShowThreshold ? 40 : 30)
|
||||
.annotation(position: .top, spacing: 3) {
|
||||
if data.count <= ChartLabels.alwaysShowThreshold {
|
||||
ChartValueBubble(text: item.value.compactCurrencyString)
|
||||
if !labelsCompact {
|
||||
PointMark(
|
||||
x: .value("Date", item.date),
|
||||
y: .value("Value", NSDecimalNumber(decimal: item.value).doubleValue)
|
||||
)
|
||||
.foregroundStyle(Color.appPrimary)
|
||||
.symbolSize(30)
|
||||
.annotation(position: .top, spacing: 3) {
|
||||
if ChartLabels.showsLabel(index: pair.offset, count: data.count, compact: labelsCompact) {
|
||||
ChartValueBubble(text: item.value.compactCurrencyString)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1002,6 +1468,28 @@ struct EvolutionChartView: View {
|
||||
)
|
||||
.interpolationMethod(.catmullRom)
|
||||
}
|
||||
|
||||
if labelsCompact, let last = data.last {
|
||||
PointMark(
|
||||
x: .value("Date", last.date),
|
||||
y: .value("Value", NSDecimalNumber(decimal: last.value).doubleValue)
|
||||
)
|
||||
.foregroundStyle(Color.cyan)
|
||||
.symbolSize(55)
|
||||
}
|
||||
|
||||
if showContributions {
|
||||
ForEach(cumulativeContributions, id: \.date) { item in
|
||||
LineMark(
|
||||
x: .value("Date", item.date),
|
||||
y: .value("Contributed", item.value),
|
||||
series: .value("Series", "contributions")
|
||||
)
|
||||
.foregroundStyle(Color.appSecondary)
|
||||
.lineStyle(StrokeStyle(lineWidth: 1.6, dash: [5, 4]))
|
||||
.interpolationMethod(.monotone)
|
||||
}
|
||||
}
|
||||
case .byCategory:
|
||||
ForEach(categoryData) { item in
|
||||
AreaMark(
|
||||
@@ -1144,8 +1632,9 @@ struct RollingReturnChartView: View {
|
||||
let data: [(date: Date, value: Double)]
|
||||
@State private var zoom = ChartZoomModel()
|
||||
@State private var selectedDate: Date?
|
||||
@Environment(\.horizontalSizeClass) private var labelsSizeClass
|
||||
|
||||
private var showAllLabels: Bool { data.count <= ChartLabels.alwaysShowThreshold }
|
||||
private var labelsCompact: Bool { labelsSizeClass != .regular }
|
||||
private var selectedPoint: (date: Date, value: Double)? {
|
||||
guard let selectedDate else { return nil }
|
||||
return data.min(by: {
|
||||
@@ -1164,7 +1653,8 @@ struct RollingReturnChartView: View {
|
||||
.frame(height: 260)
|
||||
} else {
|
||||
Chart {
|
||||
ForEach(data, id: \.date) { item in
|
||||
ForEach(Array(data.enumerated()), id: \.element.date) { pair in
|
||||
let item = pair.element
|
||||
LineMark(
|
||||
x: .value("Month", item.date),
|
||||
y: .value("Return", item.value)
|
||||
@@ -1177,9 +1667,9 @@ struct RollingReturnChartView: View {
|
||||
y: .value("Return", item.value)
|
||||
)
|
||||
.foregroundStyle(Color.appPrimary)
|
||||
.symbolSize(showAllLabels ? 36 : 24)
|
||||
.symbolSize(24)
|
||||
.annotation(position: item.value >= 0 ? .top : .bottom, spacing: 3) {
|
||||
if showAllLabels {
|
||||
if ChartLabels.showsLabel(index: pair.offset, count: data.count, compact: labelsCompact) {
|
||||
ChartValueBubble(text: ChartLabels.percent(item.value))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,11 +111,9 @@ struct ComparisonChartView: View {
|
||||
.frame(height: 260)
|
||||
}
|
||||
|
||||
/// Permanent labels only when the chart stays readable (few points AND few series).
|
||||
private var showAllLabels: Bool {
|
||||
let maxPoints = viewModel.comparisonData.map { $0.points.count }.max() ?? 0
|
||||
return maxPoints <= ChartLabels.alwaysShowThreshold && viewModel.comparisonData.count <= 3
|
||||
}
|
||||
/// Multi-series comparison: labelling every point of every series overlaps
|
||||
/// badly. Instead we label only each series' END point (where it landed),
|
||||
/// which is the value the comparison is actually about.
|
||||
|
||||
private func selectionRows(for date: Date) -> [(label: String, value: String, color: Color)] {
|
||||
viewModel.comparisonData.compactMap { series in
|
||||
@@ -131,6 +129,7 @@ struct ComparisonChartView: View {
|
||||
Chart {
|
||||
ForEach(viewModel.comparisonData, id: \.id) { series in
|
||||
let seriesColor = Color(hex: series.colorHex) ?? .appPrimary
|
||||
let lastDate = series.points.last?.date
|
||||
ForEach(series.points, id: \.date) { point in
|
||||
LineMark(
|
||||
x: .value("Date", point.date),
|
||||
@@ -145,9 +144,9 @@ struct ComparisonChartView: View {
|
||||
y: .value("Value", point.value)
|
||||
)
|
||||
.foregroundStyle(seriesColor)
|
||||
.symbolSize(showAllLabels ? 32 : 16)
|
||||
.annotation(position: .top, spacing: 2) {
|
||||
if showAllLabels {
|
||||
.symbolSize(point.date == lastDate ? 30 : 14)
|
||||
.annotation(position: .trailing, spacing: 2) {
|
||||
if point.date == lastDate {
|
||||
ChartValueBubble(text: yAxisLabel(for: point.value), color: seriesColor)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,10 +3,13 @@ import Charts
|
||||
|
||||
struct DrawdownChart: View {
|
||||
let data: [(date: Date, drawdown: Double)]
|
||||
/// Hidden when the host (e.g. a dashboard card) already shows a title.
|
||||
var showsTitle: Bool = true
|
||||
@State private var zoom = ChartZoomModel()
|
||||
@State private var selectedDate: Date?
|
||||
@Environment(\.horizontalSizeClass) private var labelsSizeClass
|
||||
|
||||
private var showAllLabels: Bool { data.count <= ChartLabels.alwaysShowThreshold }
|
||||
private var labelsCompact: Bool { labelsSizeClass != .regular }
|
||||
private var selectedPoint: (date: Date, drawdown: Double)? {
|
||||
guard let selectedDate else { return nil }
|
||||
return data.min(by: {
|
||||
@@ -25,8 +28,10 @@ struct DrawdownChart: View {
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
HStack {
|
||||
Text("Drawdown Analysis")
|
||||
.font(.headline)
|
||||
if showsTitle {
|
||||
Text("Drawdown Analysis")
|
||||
.font(.headline)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
@@ -46,7 +51,8 @@ struct DrawdownChart: View {
|
||||
|
||||
if data.count >= 2 {
|
||||
Chart {
|
||||
ForEach(data, id: \.date) { item in
|
||||
ForEach(Array(data.enumerated()), id: \.element.date) { pair in
|
||||
let item = pair.element
|
||||
AreaMark(
|
||||
x: .value("Date", item.date),
|
||||
y: .value("Drawdown", item.drawdown)
|
||||
@@ -72,9 +78,9 @@ struct DrawdownChart: View {
|
||||
y: .value("Drawdown", item.drawdown)
|
||||
)
|
||||
.foregroundStyle(Color.negativeRed)
|
||||
.symbolSize(showAllLabels ? 36 : 20)
|
||||
.symbolSize(20)
|
||||
.annotation(position: .bottom, spacing: 3) {
|
||||
if showAllLabels {
|
||||
if ChartLabels.showsLabel(index: pair.offset, count: data.count, compact: labelsCompact) {
|
||||
ChartValueBubble(text: ChartLabels.percent(item.drawdown), color: .negativeRed)
|
||||
}
|
||||
}
|
||||
@@ -205,10 +211,13 @@ struct DrawdownStatView: View {
|
||||
|
||||
struct VolatilityChartView: View {
|
||||
let data: [(date: Date, volatility: Double)]
|
||||
/// Hidden when the host (e.g. a dashboard card) already shows a title.
|
||||
var showsTitle: Bool = true
|
||||
@State private var zoom = ChartZoomModel()
|
||||
@State private var selectedDate: Date?
|
||||
@Environment(\.horizontalSizeClass) private var labelsSizeClass
|
||||
|
||||
private var showAllLabels: Bool { data.count <= ChartLabels.alwaysShowThreshold }
|
||||
private var labelsCompact: Bool { labelsSizeClass != .regular }
|
||||
private var selectedPoint: (date: Date, volatility: Double)? {
|
||||
guard let selectedDate else { return nil }
|
||||
return data.min(by: {
|
||||
@@ -227,11 +236,13 @@ struct VolatilityChartView: View {
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
HStack {
|
||||
Text("Volatility")
|
||||
.font(.headline)
|
||||
if showsTitle {
|
||||
HStack {
|
||||
Text("Volatility")
|
||||
.font(.headline)
|
||||
|
||||
Spacer()
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
|
||||
Text("Measures price variability over time")
|
||||
@@ -240,7 +251,8 @@ struct VolatilityChartView: View {
|
||||
|
||||
if data.count >= 2 {
|
||||
Chart {
|
||||
ForEach(data, id: \.date) { item in
|
||||
ForEach(Array(data.enumerated()), id: \.element.date) { pair in
|
||||
let item = pair.element
|
||||
LineMark(
|
||||
x: .value("Date", item.date),
|
||||
y: .value("Volatility", item.volatility)
|
||||
@@ -266,9 +278,9 @@ struct VolatilityChartView: View {
|
||||
y: .value("Volatility", item.volatility)
|
||||
)
|
||||
.foregroundStyle(Color.appPrimary)
|
||||
.symbolSize(showAllLabels ? 36 : 20)
|
||||
.symbolSize(20)
|
||||
.annotation(position: .top, spacing: 3) {
|
||||
if showAllLabels {
|
||||
if ChartLabels.showsLabel(index: pair.offset, count: data.count, compact: labelsCompact) {
|
||||
ChartValueBubble(text: String(format: "%.1f%%", item.volatility))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,16 @@ import Charts
|
||||
struct PerformanceBarChart: View {
|
||||
let data: [(category: String, cagr: Double, color: String)]
|
||||
var title: String = "Performance by Category"
|
||||
/// Drill-down: tapping a row jumps to Evolution filtered to it.
|
||||
var onDrillDown: ((String) -> Void)? = nil
|
||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||
|
||||
/// On iPhone, vertical bars collide once there are more than a handful of
|
||||
/// categories (labels + annotations fight for width). Horizontal bars give
|
||||
/// every name a full line.
|
||||
private var useHorizontalBars: Bool {
|
||||
horizontalSizeClass != .regular && data.count > 6
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
@@ -19,60 +29,16 @@ struct PerformanceBarChart: View {
|
||||
}
|
||||
|
||||
if !data.isEmpty {
|
||||
Chart(data, id: \.category) { item in
|
||||
BarMark(
|
||||
x: .value("Category", item.category),
|
||||
y: .value("CAGR", item.cagr)
|
||||
)
|
||||
.foregroundStyle(Color(hex: item.color) ?? .gray)
|
||||
.cornerRadius(4)
|
||||
.annotation(position: item.cagr >= 0 ? .top : .bottom) {
|
||||
Text(String(format: "%.1f%%", item.cagr))
|
||||
.font(.caption2)
|
||||
.foregroundColor(item.cagr >= 0 ? .positiveGreen : .negativeRed)
|
||||
}
|
||||
if useHorizontalBars {
|
||||
horizontalChart
|
||||
} else {
|
||||
verticalChart
|
||||
}
|
||||
.chartXAxis {
|
||||
AxisMarks { value in
|
||||
AxisValueLabel {
|
||||
if let category = value.as(String.self) {
|
||||
Text(category)
|
||||
.font(.caption)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.chartYAxis {
|
||||
AxisMarks { value in
|
||||
AxisGridLine()
|
||||
AxisValueLabel {
|
||||
if let doubleValue = value.as(Double.self) {
|
||||
Text(String(format: "%.0f%%", doubleValue))
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(height: 250)
|
||||
|
||||
// Legend / Details
|
||||
// Legend / Details — rows drill down when the owner wires it up.
|
||||
VStack(spacing: 8) {
|
||||
ForEach(data.sorted(by: { $0.cagr > $1.cagr }), id: \.category) { item in
|
||||
HStack {
|
||||
Circle()
|
||||
.fill(Color(hex: item.color) ?? .gray)
|
||||
.frame(width: 10, height: 10)
|
||||
|
||||
Text(item.category)
|
||||
.font(.subheadline)
|
||||
|
||||
Spacer()
|
||||
|
||||
Text(String(format: "%.2f%%", item.cagr))
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundColor(item.cagr >= 0 ? .positiveGreen : .negativeRed)
|
||||
}
|
||||
legendRow(item)
|
||||
}
|
||||
}
|
||||
.padding(.top, 8)
|
||||
@@ -89,6 +55,118 @@ struct PerformanceBarChart: View {
|
||||
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
|
||||
}
|
||||
|
||||
private var verticalChart: some View {
|
||||
Chart(data, id: \.category) { item in
|
||||
BarMark(
|
||||
x: .value("Category", item.category),
|
||||
y: .value("CAGR", item.cagr)
|
||||
)
|
||||
.foregroundStyle(Color(hex: item.color) ?? .gray)
|
||||
.cornerRadius(4)
|
||||
.annotation(position: item.cagr >= 0 ? .top : .bottom) {
|
||||
Text(String(format: "%.1f%%", item.cagr))
|
||||
.font(.caption2)
|
||||
.foregroundColor(item.cagr >= 0 ? .positiveGreen : .negativeRed)
|
||||
}
|
||||
}
|
||||
.chartXAxis {
|
||||
AxisMarks { value in
|
||||
AxisValueLabel {
|
||||
if let category = value.as(String.self) {
|
||||
Text(category)
|
||||
.font(.caption)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.chartYAxis {
|
||||
AxisMarks { value in
|
||||
AxisGridLine()
|
||||
AxisValueLabel {
|
||||
if let doubleValue = value.as(Double.self) {
|
||||
Text(String(format: "%.0f%%", doubleValue))
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(height: 250)
|
||||
}
|
||||
|
||||
private var horizontalChart: some View {
|
||||
Chart(data.sorted(by: { $0.cagr > $1.cagr }), id: \.category) { item in
|
||||
BarMark(
|
||||
x: .value("CAGR", item.cagr),
|
||||
y: .value("Category", item.category)
|
||||
)
|
||||
.foregroundStyle(Color(hex: item.color) ?? .gray)
|
||||
.cornerRadius(4)
|
||||
.annotation(position: item.cagr >= 0 ? .trailing : .leading) {
|
||||
Text(String(format: "%.1f%%", item.cagr))
|
||||
.font(.caption2)
|
||||
.foregroundColor(item.cagr >= 0 ? .positiveGreen : .negativeRed)
|
||||
}
|
||||
}
|
||||
.chartXAxis {
|
||||
AxisMarks { value in
|
||||
AxisGridLine()
|
||||
AxisValueLabel {
|
||||
if let doubleValue = value.as(Double.self) {
|
||||
Text(String(format: "%.0f%%", doubleValue))
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.chartYAxis {
|
||||
AxisMarks { value in
|
||||
AxisValueLabel {
|
||||
if let category = value.as(String.self) {
|
||||
Text(category)
|
||||
.font(.caption)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(height: CGFloat(data.count) * 34 + 40)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func legendRow(_ item: (category: String, cagr: Double, color: String)) -> some View {
|
||||
let row = HStack {
|
||||
Circle()
|
||||
.fill(Color(hex: item.color) ?? .gray)
|
||||
.frame(width: 10, height: 10)
|
||||
|
||||
Text(item.category)
|
||||
.font(.subheadline)
|
||||
|
||||
Spacer()
|
||||
|
||||
Text(String(format: "%.2f%%", item.cagr))
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundColor(item.cagr >= 0 ? .positiveGreen : .negativeRed)
|
||||
|
||||
if onDrillDown != nil {
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption2.weight(.semibold))
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
if let onDrillDown {
|
||||
Button {
|
||||
onDrillDown(item.category)
|
||||
} label: {
|
||||
row.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
} else {
|
||||
row
|
||||
}
|
||||
}
|
||||
|
||||
private var perfStats: [ChartStat] {
|
||||
guard !data.isEmpty else { return [] }
|
||||
let values = data.map { $0.cagr }
|
||||
@@ -107,6 +185,8 @@ struct PerformanceBarChart: View {
|
||||
|
||||
struct HorizontalPerformanceChart: View {
|
||||
let data: [(category: String, cagr: Double, color: String)]
|
||||
/// Hidden when the host (e.g. a dashboard card) already shows a title.
|
||||
var showsTitle: Bool = true
|
||||
|
||||
var sortedData: [(category: String, cagr: Double, color: String)] {
|
||||
data.sorted { $0.cagr > $1.cagr }
|
||||
@@ -118,8 +198,10 @@ struct HorizontalPerformanceChart: View {
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text("Performance by Category")
|
||||
.font(.headline)
|
||||
if showsTitle {
|
||||
Text("Performance by Category")
|
||||
.font(.headline)
|
||||
}
|
||||
|
||||
ForEach(sortedData, id: \.category) { item in
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
|
||||
@@ -6,10 +6,6 @@ struct PeriodComparisonChartView: View {
|
||||
@State private var selectedMonth: Int?
|
||||
|
||||
/// Period charts are short by design (a handful of months) — labels always help.
|
||||
private var showAllLabels: Bool {
|
||||
(viewModel.periodComparisonData.map { $0.points.count }.max() ?? 0) <= ChartLabels.alwaysShowThreshold
|
||||
}
|
||||
|
||||
private func selectionRows(for month: Int) -> [(label: String, value: String, color: Color)] {
|
||||
viewModel.periodComparisonData.compactMap { series in
|
||||
guard let point = series.points.first(where: { $0.monthOffset == month }) else { return nil }
|
||||
@@ -217,8 +213,18 @@ struct PeriodComparisonChartView: View {
|
||||
return result
|
||||
}
|
||||
|
||||
/// Last month offset per series — used to label only each series' endpoint.
|
||||
private var lastOffsetBySeries: [String: Int] {
|
||||
var m: [String: Int] = [:]
|
||||
for s in viewModel.periodComparisonData {
|
||||
m[s.id] = s.points.map(\.monthOffset).max()
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
private var periodChart: some View {
|
||||
let points = flatPoints
|
||||
let endpoints = lastOffsetBySeries
|
||||
let seriesIds = viewModel.periodComparisonData.map { $0.id }
|
||||
let seriesColors: [Color] = viewModel.periodComparisonData.map {
|
||||
Color(hex: $0.colorHex) ?? .gray
|
||||
@@ -240,10 +246,10 @@ struct PeriodComparisonChartView: View {
|
||||
y: .value("Return", point.returnPct)
|
||||
)
|
||||
.foregroundStyle(by: .value("Period", point.seriesLabel))
|
||||
.symbolSize(showAllLabels ? 40 : 30)
|
||||
.symbolSize(point.monthOffset == endpoints[point.seriesId] ? 38 : 22)
|
||||
.annotation(position: point.seriesLabel == viewModel.periodComparisonData.first?.label ? .top : .bottom,
|
||||
spacing: 3) {
|
||||
if showAllLabels {
|
||||
if point.monthOffset == endpoints[point.seriesId] {
|
||||
ChartValueBubble(
|
||||
text: ChartLabels.percent(point.returnPct),
|
||||
color: Color(hex: point.colorHex) ?? .gray
|
||||
|
||||
@@ -4,11 +4,12 @@ import Charts
|
||||
struct PredictionChartView: View {
|
||||
let predictions: [Prediction]
|
||||
let historicalData: [(date: Date, value: Decimal)]
|
||||
/// Hidden when the host (e.g. a dashboard card) already shows a title.
|
||||
var showsTitle: Bool = true
|
||||
@State private var selectedDate: Date?
|
||||
@Environment(\.horizontalSizeClass) private var labelsSizeClass
|
||||
|
||||
private var showAllLabels: Bool {
|
||||
historicalData.count + predictions.count <= ChartLabels.alwaysShowThreshold
|
||||
}
|
||||
private var labelsCompact: Bool { labelsSizeClass != .regular }
|
||||
|
||||
/// Nearest point (historical or predicted) to the selected date.
|
||||
private var selectedPoint: (date: Date, value: Decimal, isPrediction: Bool)? {
|
||||
@@ -24,8 +25,10 @@ struct PredictionChartView: View {
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
HStack {
|
||||
Text(predictions.isEmpty ? "Prediction" : "\(predictions.count)-Month Prediction")
|
||||
.font(.headline)
|
||||
if showsTitle {
|
||||
Text(predictions.isEmpty ? "Prediction" : "\(predictions.count)-Month Prediction")
|
||||
.font(.headline)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
@@ -49,8 +52,9 @@ struct PredictionChartView: View {
|
||||
|
||||
if !predictions.isEmpty && historicalData.count >= 2 {
|
||||
Chart {
|
||||
// Historical data
|
||||
ForEach(historicalData, id: \.date) { item in
|
||||
// Historical data — thin labels to at most a handful.
|
||||
ForEach(Array(historicalData.enumerated()), id: \.element.date) { pair in
|
||||
let item = pair.element
|
||||
LineMark(
|
||||
x: .value("Date", item.date),
|
||||
y: .value("Value", NSDecimalNumber(decimal: item.value).doubleValue)
|
||||
@@ -63,9 +67,9 @@ struct PredictionChartView: View {
|
||||
y: .value("Value", NSDecimalNumber(decimal: item.value).doubleValue)
|
||||
)
|
||||
.foregroundStyle(Color.appPrimary)
|
||||
.symbolSize(showAllLabels ? 36 : 24)
|
||||
.symbolSize(24)
|
||||
.annotation(position: .top, spacing: 3) {
|
||||
if showAllLabels {
|
||||
if ChartLabels.showsLabel(index: pair.offset, count: historicalData.count, compact: labelsCompact) {
|
||||
ChartValueBubble(text: item.value.compactCurrencyString)
|
||||
}
|
||||
}
|
||||
@@ -95,9 +99,10 @@ struct PredictionChartView: View {
|
||||
y: .value("Predicted", NSDecimalNumber(decimal: prediction.predictedValue).doubleValue)
|
||||
)
|
||||
.foregroundStyle(Color.appSecondary)
|
||||
.symbolSize(showAllLabels ? 36 : 24)
|
||||
.symbolSize(prediction.id == predictions.last?.id ? 34 : 24)
|
||||
.annotation(position: .top, spacing: 3) {
|
||||
if showAllLabels {
|
||||
// Only the final forecast point (the KPIs cover the rest).
|
||||
if prediction.id == predictions.last?.id {
|
||||
ChartValueBubble(text: prediction.predictedValue.compactCurrencyString, color: .appSecondary)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ import Charts
|
||||
|
||||
struct YearOverYearChartView: View {
|
||||
@ObservedObject var viewModel: ChartsViewModel
|
||||
/// Hidden when the host (e.g. a dashboard card) already shows a title.
|
||||
var showsTitle: Bool = true
|
||||
@State private var selectedMonthIdx: Int?
|
||||
|
||||
private let monthAbbreviations = ["Jan", "Feb", "Mar", "Apr", "May", "Jun",
|
||||
@@ -20,8 +22,10 @@ struct YearOverYearChartView: View {
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text(String(localized: "chart_yoy_title"))
|
||||
.font(.headline)
|
||||
if showsTitle {
|
||||
Text(String(localized: "chart_yoy_title"))
|
||||
.font(.headline)
|
||||
}
|
||||
|
||||
if allYears.isEmpty {
|
||||
Text(String(localized: "chart_yoy_empty"))
|
||||
@@ -93,8 +97,11 @@ struct YearOverYearChartView: View {
|
||||
|
||||
// MARK: - Chart
|
||||
|
||||
/// Two selected years × 12 months stays readable with permanent labels.
|
||||
private var showAllLabels: Bool { selectedSeries.count <= 2 }
|
||||
/// Last month index that has data, per series id — label only that endpoint
|
||||
/// (the year's cumulative return) to avoid 24 overlapping labels.
|
||||
private func lastValidMonth(_ series: ChartsViewModel.YearSeries) -> Int? {
|
||||
(0..<12).last { !series.values[$0].isNaN }
|
||||
}
|
||||
|
||||
private func selectionRows(for month: Int) -> [(label: String, value: String, color: Color)] {
|
||||
selectedSeries.compactMap { series in
|
||||
@@ -113,6 +120,7 @@ struct YearOverYearChartView: View {
|
||||
let idx = allYears.firstIndex(where: { $0.id == yearSeries.id }) ?? 0
|
||||
let color = lineColors[idx % lineColors.count]
|
||||
let seriesPosition = selectedSeries.firstIndex(where: { $0.id == yearSeries.id }) ?? 0
|
||||
let endMonth = lastValidMonth(yearSeries)
|
||||
ForEach(0..<12, id: \.self) { monthIdx in
|
||||
let value = yearSeries.values[monthIdx]
|
||||
if !value.isNaN {
|
||||
@@ -129,9 +137,9 @@ struct YearOverYearChartView: View {
|
||||
y: .value("Return", value)
|
||||
)
|
||||
.foregroundStyle(color)
|
||||
.symbolSize(showAllLabels ? 32 : 18)
|
||||
.symbolSize(monthIdx == endMonth ? 34 : 18)
|
||||
.annotation(position: seriesPosition == 0 ? .top : .bottom, spacing: 2) {
|
||||
if showAllLabels {
|
||||
if monthIdx == endMonth {
|
||||
ChartValueBubble(text: ChartLabels.percent(value), color: color)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,37 +1,13 @@
|
||||
import SwiftUI
|
||||
|
||||
// Calm 2.0: plain system grouped background — depth comes from content
|
||||
// hierarchy, not decoration (the old gradient + overflowing shapes also
|
||||
// intercepted touches on sibling views; see 1.4.2 build 50).
|
||||
struct AppBackground: View {
|
||||
var body: some View {
|
||||
GeometryReader { proxy in
|
||||
ZStack {
|
||||
LinearGradient(
|
||||
colors: [
|
||||
Color.appPrimary.opacity(0.15),
|
||||
Color.appSecondary.opacity(0.12),
|
||||
Color.appAccent.opacity(0.1)
|
||||
],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
|
||||
Circle()
|
||||
.fill(Color.appPrimary.opacity(0.18))
|
||||
.frame(width: proxy.size.width * 0.7)
|
||||
.offset(x: -proxy.size.width * 0.35, y: -proxy.size.height * 0.35)
|
||||
|
||||
RoundedRectangle(cornerRadius: 120, style: .continuous)
|
||||
.fill(Color.appAccent.opacity(0.12))
|
||||
.frame(width: proxy.size.width * 0.8, height: proxy.size.height * 0.35)
|
||||
.rotationEffect(.degrees(-12))
|
||||
.offset(x: proxy.size.width * 0.2, y: proxy.size.height * 0.45)
|
||||
}
|
||||
Color(.systemGroupedBackground)
|
||||
.ignoresSafeArea()
|
||||
}
|
||||
// Purely decorative — must never intercept touches. The circle overflows
|
||||
// its panel's bounds (offset -35% width), and in side-by-side layouts it
|
||||
// floats OVER sibling views (e.g. the Charts sidebar on iPad), silently
|
||||
// swallowing every tap underneath.
|
||||
.allowsHitTesting(false)
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import SwiftUI
|
||||
|
||||
/// A view modifier that redacts monetary amounts when the "Hide balances"
|
||||
/// privacy feature is active.
|
||||
///
|
||||
/// When `hidden` is true the underlying content is blurred and its value masked
|
||||
/// with a bullet string, while keeping the original layout footprint so the UI
|
||||
/// doesn't jump. When false, the real content shows through untouched.
|
||||
///
|
||||
/// Usage:
|
||||
/// ```swift
|
||||
/// Text(viewModel.formattedTotalValue)
|
||||
/// .hiddenBalance() // observes BalancePrivacyManager.shared
|
||||
/// // or explicitly:
|
||||
/// Text(amount).hiddenBalance(isHidden)
|
||||
/// ```
|
||||
struct HiddenBalanceModifier: ViewModifier {
|
||||
let hidden: Bool
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.opacity(hidden ? 0 : 1)
|
||||
.overlay {
|
||||
if hidden {
|
||||
// Bullet mask sized to the underlying content so layout width
|
||||
// stays roughly stable and the amount is fully obscured.
|
||||
GeometryReader { proxy in
|
||||
Text("••••••")
|
||||
.redacted(reason: .placeholder)
|
||||
.blur(radius: 6)
|
||||
.frame(width: proxy.size.width, height: proxy.size.height)
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
.accessibilityLabel(hidden ? Text("Balance hidden") : Text(""))
|
||||
}
|
||||
}
|
||||
|
||||
/// A self-observing variant of `HiddenBalanceModifier` that subscribes to the
|
||||
/// shared `BalancePrivacyManager` so any monetary label updates live when the
|
||||
/// user toggles visibility — without threading the flag through view params.
|
||||
struct ObservingHiddenBalanceModifier: ViewModifier {
|
||||
@ObservedObject private var privacy = BalancePrivacyManager.shared
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content.modifier(HiddenBalanceModifier(hidden: privacy.balancesHidden))
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
/// Redact this monetary label with an explicit hidden flag. Use inside views
|
||||
/// that already observe `BalancePrivacyManager`.
|
||||
func hiddenBalance(_ hidden: Bool) -> some View {
|
||||
modifier(HiddenBalanceModifier(hidden: hidden))
|
||||
}
|
||||
|
||||
/// Redact this monetary label, self-observing the shared privacy manager.
|
||||
/// Use anywhere without threading the flag through view parameters.
|
||||
func hiddenBalance() -> some View {
|
||||
modifier(ObservingHiddenBalanceModifier())
|
||||
}
|
||||
}
|
||||
|
||||
/// A tap-to-reveal wrapper for the quick eye toggle. Renders an eye / eye.slash
|
||||
/// button bound to the shared `BalancePrivacyManager`.
|
||||
struct BalanceVisibilityToggle: View {
|
||||
@ObservedObject var privacy = BalancePrivacyManager.shared
|
||||
|
||||
var body: some View {
|
||||
Button {
|
||||
privacy.toggleHidden()
|
||||
} label: {
|
||||
Image(systemName: privacy.balancesHidden ? "eye.slash" : "eye")
|
||||
}
|
||||
.accessibilityLabel(privacy.balancesHidden ? Text("Show balances") : Text("Hide balances"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Fitness-style progress ring: track + rounded-cap arc + centered percentage.
|
||||
/// Color is semantic (pace/achievement), passed by the caller.
|
||||
struct ProgressRing: View {
|
||||
let progress: Double
|
||||
var tint: Color = .appPrimary
|
||||
var size: CGFloat = 56
|
||||
var lineWidth: CGFloat = 6
|
||||
|
||||
private var clamped: Double { min(max(progress, 0), 1) }
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Circle()
|
||||
.stroke(tint.opacity(0.15), lineWidth: lineWidth)
|
||||
Circle()
|
||||
.trim(from: 0, to: clamped)
|
||||
.stroke(tint, style: StrokeStyle(lineWidth: lineWidth, lineCap: .round))
|
||||
.rotationEffect(.degrees(-90))
|
||||
.animation(.easeOut(duration: 0.6), value: clamped)
|
||||
Text("\(Int((clamped * 100).rounded()))%")
|
||||
.font(.system(size: size * 0.26, weight: .bold, design: .rounded))
|
||||
.foregroundColor(tint)
|
||||
.minimumScaleFactor(0.7)
|
||||
}
|
||||
.frame(width: size, height: size)
|
||||
.accessibilityLabel(Text("\(Int((clamped * 100).rounded()))%"))
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,165 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Dismissible banner shown on the Dashboard when internal snapshot gaps exist.
|
||||
/// The evolution chart smooths these over, so without this the user would never
|
||||
/// know real data is missing. Tapping opens `DataGapsSheet` to fill them.
|
||||
struct DataGapsBanner: View {
|
||||
let totalMissingMonths: Int
|
||||
let onTap: () -> Void
|
||||
let onDismiss: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: onTap) {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.font(.title3)
|
||||
.foregroundColor(.orange)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(String(format: String(localized: "gaps_banner_title"), totalMissingMonths))
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundColor(.primary)
|
||||
.multilineTextAlignment(.leading)
|
||||
Text("Fill in missing data")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.padding(14)
|
||||
.background(Color(.secondarySystemGroupedBackground))
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius, style: .continuous)
|
||||
.stroke(Color.orange.opacity(0.35), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.overlay(alignment: .topTrailing) {
|
||||
Button(action: onDismiss) {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.font(.body)
|
||||
.foregroundColor(.secondary)
|
||||
.padding(6)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(Text("Dismiss"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Lists each detected gap and offers to fill it. Filling opens the standard
|
||||
/// add-snapshot flow for that source, prefilled to the missing month's date.
|
||||
struct DataGapsSheet: View {
|
||||
let gaps: [SnapshotGapDetector.Gap]
|
||||
/// Resolves a source id to a live InvestmentSource for the add-snapshot flow.
|
||||
let sourceProvider: (UUID) -> InvestmentSource?
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var fillTarget: FillTarget?
|
||||
@State private var filledGapIds: Set<String> = []
|
||||
private let snapshotRepository = SnapshotRepository()
|
||||
|
||||
private struct FillTarget: Identifiable {
|
||||
let source: InvestmentSource
|
||||
let date: Date
|
||||
var id: String { "\(source.id.uuidString)-\(date.timeIntervalSince1970)" }
|
||||
}
|
||||
|
||||
private var visibleGaps: [SnapshotGapDetector.Gap] {
|
||||
gaps.filter { !filledGapIds.contains($0.id) }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
Section {
|
||||
ForEach(visibleGaps) { gap in
|
||||
gapRow(gap)
|
||||
}
|
||||
} footer: {
|
||||
Text("Auto-fill estimates the missing months between two snapshots (marked as estimated). Or add the real values yourself.")
|
||||
}
|
||||
}
|
||||
.navigationTitle("Missing Data")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button("Done") { dismiss() }
|
||||
}
|
||||
}
|
||||
.sheet(item: $fillTarget) { target in
|
||||
AddSnapshotView(source: target.source, initialDate: target.date)
|
||||
.environment(\.managedObjectContext, CoreDataStack.shared.viewContext)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func gapRow(_ gap: SnapshotGapDetector.Gap) -> some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack {
|
||||
Text(gap.sourceName)
|
||||
.font(.headline)
|
||||
Spacer()
|
||||
Text(String(format: String(localized: "gaps_missing_count"), gap.missingMonths))
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundColor(.orange)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 3)
|
||||
.background(Color.orange.opacity(0.12))
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
|
||||
Text(rangeLabel(gap))
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
if let source = sourceProvider(gap.sourceId),
|
||||
let firstMissing = gap.missingMonthDates.first {
|
||||
HStack(spacing: 16) {
|
||||
Button {
|
||||
let n = snapshotRepository.autoFillGap(
|
||||
source: source,
|
||||
from: gap.fromMonth,
|
||||
to: gap.toMonth,
|
||||
missingMonthDates: gap.missingMonthDates
|
||||
)
|
||||
if n > 0 { filledGapIds.insert(gap.id) }
|
||||
} label: {
|
||||
Label(
|
||||
String(format: String(localized: "gaps_autofill_cta"), gap.missingMonths),
|
||||
systemImage: "wand.and.stars"
|
||||
)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.tint(.appPrimary)
|
||||
|
||||
Button {
|
||||
fillTarget = FillTarget(source: source, date: firstMissing)
|
||||
} label: {
|
||||
Label("Add manually", systemImage: "plus.circle")
|
||||
.font(.subheadline)
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.tint(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
|
||||
private func rangeLabel(_ gap: SnapshotGapDetector.Gap) -> String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "MMM yyyy"
|
||||
let from = formatter.string(from: gap.fromMonth)
|
||||
let to = formatter.string(from: gap.toMonth)
|
||||
return String(format: String(localized: "gaps_range_between"), from, to)
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,107 @@
|
||||
import SwiftUI
|
||||
import Charts
|
||||
|
||||
/// Swipeable pages for the Home evolution card.
|
||||
enum DashboardChartPage: Hashable {
|
||||
case evolution, allocation, performance, drawdown, volatility, prediction, yearOverYear
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .evolution: return String(localized: "Portfolio Evolution")
|
||||
case .allocation: return String(localized: "Asset Allocation")
|
||||
case .performance: return String(localized: "performance")
|
||||
case .drawdown: return String(localized: "drawdown")
|
||||
case .volatility: return String(localized: "volatility")
|
||||
case .prediction: return String(localized: "prediction")
|
||||
case .yearOverYear: return String(localized: "dash_chart_yoy")
|
||||
}
|
||||
}
|
||||
|
||||
/// Pages that stay unlocked on the Home carousel for everyone. The rest show a
|
||||
/// lock teaser for non-premium users (they still see the page to drive upgrade).
|
||||
var isFreeOnHome: Bool {
|
||||
self == .evolution || self == .allocation
|
||||
}
|
||||
|
||||
/// Maps to the full Charts feature type so we can reuse its computed data + views.
|
||||
var chartsType: ChartsViewModel.ChartType {
|
||||
switch self {
|
||||
case .evolution: return .evolution
|
||||
case .allocation: return .allocation
|
||||
case .performance: return .performance
|
||||
case .drawdown: return .drawdown
|
||||
case .volatility: return .volatility
|
||||
case .prediction: return .prediction
|
||||
case .yearOverYear: return .yearOverYear
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct EvolutionChartCard: View {
|
||||
let data: [(date: Date, value: Decimal)]
|
||||
let categoryData: [CategoryEvolutionPoint]
|
||||
let goals: [Goal]
|
||||
/// Current allocation per category (name, value, colorHex) for the swipeable
|
||||
/// Allocation page. Empty → the Allocation page is skipped.
|
||||
var allocation: [(category: String, value: Decimal, color: String)] = []
|
||||
let iapService: IAPService
|
||||
/// Called when a non-premium user taps "unlock" on a locked chart page.
|
||||
var onRequestUpgrade: () -> Void = {}
|
||||
|
||||
/// Reused to render the premium analytics pages (performance/drawdown/…): it
|
||||
/// already computes their data and the Charts feature already ships the views.
|
||||
/// Only the currently-visible premium page is computed (see syncChartsVM).
|
||||
@StateObject private var chartsVM: ChartsViewModel
|
||||
|
||||
init(
|
||||
data: [(date: Date, value: Decimal)],
|
||||
categoryData: [CategoryEvolutionPoint],
|
||||
goals: [Goal],
|
||||
allocation: [(category: String, value: Decimal, color: String)] = [],
|
||||
iapService: IAPService,
|
||||
onRequestUpgrade: @escaping () -> Void = {}
|
||||
) {
|
||||
self.data = data
|
||||
self.categoryData = categoryData
|
||||
self.goals = goals
|
||||
self.allocation = allocation
|
||||
self.iapService = iapService
|
||||
self.onRequestUpgrade = onRequestUpgrade
|
||||
_chartsVM = StateObject(wrappedValue: ChartsViewModel(iapService: iapService))
|
||||
}
|
||||
|
||||
@State private var selectedDataPoint: (date: Date, value: Decimal)?
|
||||
@State private var chartMode: ChartMode = .total
|
||||
@State private var showGoalLines = true
|
||||
@State private var chartWidth: CGFloat = 300
|
||||
// Feedback #4: swipe the Home card between a curated set of glanceable charts.
|
||||
@State private var page: DashboardChartPage = .evolution
|
||||
@State private var pageInsertionEdge: Edge = .trailing
|
||||
@State private var allocationSelection: String?
|
||||
|
||||
/// Pages available given the data on hand. "By Category" already lives as a
|
||||
/// toggle inside the Evolution page, so it isn't a separate swipe page.
|
||||
private var pages: [DashboardChartPage] {
|
||||
var p: [DashboardChartPage] = [.evolution]
|
||||
if !allocation.isEmpty { p.append(.allocation) }
|
||||
// Premium analytics pages are shown to everyone — premium users see the
|
||||
// real chart, free users see a lock teaser (drives the upgrade).
|
||||
p.append(contentsOf: [.performance, .drawdown, .volatility, .prediction, .yearOverYear])
|
||||
return p
|
||||
}
|
||||
|
||||
private func isLocked(_ page: DashboardChartPage) -> Bool {
|
||||
!page.isFreeOnHome && !iapService.isPremium
|
||||
}
|
||||
|
||||
/// Compute data for the visible premium page on demand (the VM computes only its
|
||||
/// selected chart). No-op for free/locked pages so nothing is calculated in vain.
|
||||
private func syncChartsVM(for page: DashboardChartPage) {
|
||||
guard !isLocked(page), !page.isFreeOnHome else { return }
|
||||
if chartsVM.selectedChartType != page.chartsType {
|
||||
chartsVM.selectChart(page.chartsType)
|
||||
}
|
||||
}
|
||||
|
||||
enum ChartMode: String, CaseIterable, Identifiable {
|
||||
case total = "Total"
|
||||
@@ -45,38 +137,174 @@ struct EvolutionChartCard: View {
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
headerView
|
||||
modePicker
|
||||
chartSection
|
||||
pageContent
|
||||
.id(page)
|
||||
.transition(.asymmetric(
|
||||
insertion: .move(edge: pageInsertionEdge).combined(with: .opacity),
|
||||
removal: .opacity
|
||||
))
|
||||
if pages.count > 1 { pageIndicator }
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.systemBackground))
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
|
||||
.contentShape(Rectangle())
|
||||
// simultaneousGesture so the swipe is recognized even over the chart,
|
||||
// whose own scrub DragGesture would otherwise consume it. Horizontal
|
||||
// dominance is enforced in onEnded so vertical scroll still works; a
|
||||
// single page is a harmless no-op (bounds prevent movement).
|
||||
.simultaneousGesture(pageSwipeGesture)
|
||||
.onAppear { syncChartsVM(for: page) }
|
||||
.onChange(of: page) { _, newPage in syncChartsVM(for: newPage) }
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var pageContent: some View {
|
||||
if isLocked(page) {
|
||||
lockedPageView(page)
|
||||
} else {
|
||||
switch page {
|
||||
case .evolution:
|
||||
modePicker
|
||||
chartSection
|
||||
case .allocation:
|
||||
SemicircleAllocation(
|
||||
data: allocation,
|
||||
total: allocation.reduce(Decimal.zero) { $0 + $1.value },
|
||||
selectedSlice: $allocationSelection
|
||||
)
|
||||
.frame(height: 150)
|
||||
.padding(.vertical, 8)
|
||||
allocationLegend
|
||||
// showsTitle: false — the card header already names the page;
|
||||
// the embedded charts' own titles duplicated it.
|
||||
case .performance:
|
||||
HorizontalPerformanceChart(data: chartsVM.performanceData, showsTitle: false)
|
||||
case .drawdown:
|
||||
DrawdownChart(data: chartsVM.drawdownData, showsTitle: false)
|
||||
case .volatility:
|
||||
VolatilityChartView(data: chartsVM.volatilityData, showsTitle: false)
|
||||
case .prediction:
|
||||
PredictionChartView(
|
||||
predictions: chartsVM.predictionData,
|
||||
historicalData: chartsVM.evolutionData,
|
||||
showsTitle: false
|
||||
)
|
||||
case .yearOverYear:
|
||||
YearOverYearChartView(viewModel: chartsVM, showsTitle: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Compact lock teaser reused from the Charts feature's premium treatment.
|
||||
private func lockedPageView(_ page: DashboardChartPage) -> some View {
|
||||
VStack(spacing: 12) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(Color.appPrimary.opacity(0.12))
|
||||
.frame(width: 72, height: 72)
|
||||
Image(systemName: page.chartsType.icon)
|
||||
.font(.system(size: 28))
|
||||
.foregroundColor(.appPrimary)
|
||||
Image(systemName: "lock.circle.fill")
|
||||
.font(.system(size: 22))
|
||||
.foregroundColor(.appWarning)
|
||||
.background(Circle().fill(Color(.systemBackground)))
|
||||
.offset(x: 26, y: 24)
|
||||
}
|
||||
Text(String(localized: "home_chart_locked_sub"))
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
Button {
|
||||
FirebaseService.shared.logPaywallShown(trigger: "home_charts")
|
||||
onRequestUpgrade()
|
||||
} label: {
|
||||
Label(String(localized: "chart_locked_cta"), systemImage: "lock.open.fill")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.vertical, 10)
|
||||
.background(Color.appPrimary)
|
||||
.foregroundColor(.white)
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, minHeight: 240)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
|
||||
private var headerView: some View {
|
||||
HStack {
|
||||
Text("Portfolio Evolution")
|
||||
Text(page.title)
|
||||
.font(.headline)
|
||||
|
||||
Spacer()
|
||||
|
||||
Button {
|
||||
showGoalLines.toggle()
|
||||
} label: {
|
||||
Image(systemName: showGoalLines ? "target" : "slash.circle")
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.accessibilityLabel(showGoalLines ? "Hide goals" : "Show goals")
|
||||
|
||||
if let selected = selectedDataPoint, chartMode == .total {
|
||||
VStack(alignment: .trailing) {
|
||||
Text(selected.value.compactCurrencyString)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Text(selected.date.monthYearString)
|
||||
.font(.caption)
|
||||
if page == .evolution {
|
||||
Button {
|
||||
showGoalLines.toggle()
|
||||
} label: {
|
||||
Image(systemName: showGoalLines ? "target" : "slash.circle")
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.accessibilityLabel(showGoalLines ? "Hide goals" : "Show goals")
|
||||
|
||||
if let selected = selectedDataPoint, chartMode == .total {
|
||||
VStack(alignment: .trailing) {
|
||||
Text(selected.value.compactCurrencyString)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Text(selected.date.monthYearString)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Swipe between pages
|
||||
|
||||
private var pageSwipeGesture: some Gesture {
|
||||
DragGesture(minimumDistance: 30)
|
||||
.onEnded { value in
|
||||
guard abs(value.translation.width) > abs(value.translation.height) * 1.5,
|
||||
let idx = pages.firstIndex(of: page) else { return }
|
||||
if value.translation.width < 0, idx < pages.count - 1 {
|
||||
pageInsertionEdge = .trailing
|
||||
withAnimation(.snappy) { page = pages[idx + 1] }
|
||||
} else if value.translation.width > 0, idx > 0 {
|
||||
pageInsertionEdge = .leading
|
||||
withAnimation(.snappy) { page = pages[idx - 1] }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var pageIndicator: some View {
|
||||
let current = pages.firstIndex(of: page) ?? 0
|
||||
return HStack(spacing: 5) {
|
||||
ForEach(pages.indices, id: \.self) { i in
|
||||
Circle()
|
||||
.fill(i == current ? Color.appPrimary : Color.secondary.opacity(0.25))
|
||||
.frame(width: i == current ? 7 : 5, height: i == current ? 7 : 5)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.animation(.snappy, value: current)
|
||||
}
|
||||
|
||||
private var allocationLegend: some View {
|
||||
VStack(spacing: 6) {
|
||||
ForEach(allocation.prefix(4), id: \.category) { item in
|
||||
let total = allocation.reduce(Decimal.zero) { $0 + $1.value }
|
||||
let pct = total > 0 ? NSDecimalNumber(decimal: item.value / total).doubleValue * 100 : 0
|
||||
HStack(spacing: 8) {
|
||||
Circle().fill(Color(hex: item.color) ?? .gray).frame(width: 9, height: 9)
|
||||
Text(item.category).font(.caption)
|
||||
Spacer()
|
||||
Text(String(format: "%.1f%%", pct)).font(.caption.weight(.medium))
|
||||
Text(item.value.compactCurrencyString).font(.caption).foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -335,6 +563,6 @@ struct SparklineView: View {
|
||||
(Date(), 12000)
|
||||
]
|
||||
|
||||
return EvolutionChartCard(data: sampleData, categoryData: [], goals: [])
|
||||
return EvolutionChartCard(data: sampleData, categoryData: [], goals: [], iapService: IAPService())
|
||||
.padding()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,482 @@
|
||||
import SwiftUI
|
||||
import CoreData
|
||||
import TipKit
|
||||
import UIKit
|
||||
|
||||
// MARK: - Guided monthly check-in (Calm 2.0, Fase 2)
|
||||
//
|
||||
// The monthly ritual as a full-screen, one-source-per-step flow instead of a
|
||||
// form: previous value in sight, big keypad entry, live delta, clipboard
|
||||
// hand-off — then a short reflection (mood + one line) and a closing summary
|
||||
// with the streak. Persistence reuses the exact same paths as Quick Update
|
||||
// (Snapshots) and the journal (MonthlyCheckInStore → JournalEntry), so data
|
||||
// stays consistent with every other entry point.
|
||||
|
||||
struct GuidedCheckInView: View {
|
||||
/// Frozen at presentation: the launching card recomputes its reference date
|
||||
/// as soon as the check-in saves, and SwiftUI re-evaluates the cover content
|
||||
/// — without freezing, the month would shift mid-flow on the done screen.
|
||||
@State private var referenceDate: Date
|
||||
|
||||
init(referenceDate: Date) {
|
||||
_referenceDate = State(initialValue: referenceDate)
|
||||
}
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(\.managedObjectContext) private var context
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
|
||||
@FetchRequest(
|
||||
sortDescriptors: [NSSortDescriptor(keyPath: \InvestmentSource.name, ascending: true)],
|
||||
predicate: NSPredicate(format: "isActive == YES"),
|
||||
animation: .none
|
||||
) private var sources: FetchedResults<InvestmentSource>
|
||||
|
||||
// Step 0..<count → one source each; count → reflection; count+1 → done.
|
||||
@State private var step = 0
|
||||
@State private var values: [NSManagedObjectID: String] = [:]
|
||||
@State private var mood: MonthlyCheckInMood?
|
||||
@State private var note = ""
|
||||
@State private var saveError: String?
|
||||
@State private var savedTotals: (previous: Decimal, current: Decimal)?
|
||||
@State private var clipboardAmount: Decimal?
|
||||
@State private var lastSuggestedRaw: String?
|
||||
@FocusState private var amountFocused: Bool
|
||||
@FocusState private var noteFocused: Bool
|
||||
|
||||
private var reflectionStep: Int { sources.count }
|
||||
private var doneStep: Int { sources.count + 1 }
|
||||
private var currentSource: InvestmentSource? {
|
||||
step < sources.count ? sources[step] : nil
|
||||
}
|
||||
|
||||
private var monthLabel: String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "LLLL yyyy"
|
||||
return formatter.string(from: MonthlyCheckInStore.effectiveMonth(for: referenceDate, relativeTo: referenceDate))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ZStack {
|
||||
AppBackground()
|
||||
VStack(spacing: 0) {
|
||||
if step < doneStep {
|
||||
ProgressView(value: Double(step + 1), total: Double(doneStep))
|
||||
.tint(.appPrimary)
|
||||
.padding(.horizontal)
|
||||
.padding(.top, 8)
|
||||
}
|
||||
|
||||
Group {
|
||||
if let source = currentSource {
|
||||
sourceStep(source)
|
||||
.id(source.objectID)
|
||||
} else if step == reflectionStep {
|
||||
reflectionView
|
||||
} else {
|
||||
doneView
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: 560)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.transition(.asymmetric(
|
||||
insertion: .move(edge: .trailing).combined(with: .opacity),
|
||||
removal: .move(edge: .leading).combined(with: .opacity)
|
||||
))
|
||||
}
|
||||
}
|
||||
.navigationTitle(step == doneStep ? "" : monthLabel)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
if step < doneStep {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button(String(localized: "cancel")) { dismiss() }
|
||||
}
|
||||
}
|
||||
}
|
||||
.alert("Error", isPresented: Binding(
|
||||
get: { saveError != nil },
|
||||
set: { if !$0 { saveError = nil } }
|
||||
)) {
|
||||
Button("OK", role: .cancel) { saveError = nil }
|
||||
} message: {
|
||||
Text(saveError ?? "")
|
||||
}
|
||||
.onAppear {
|
||||
amountFocused = true
|
||||
checkClipboard()
|
||||
}
|
||||
.onChange(of: scenePhase) { _, phase in
|
||||
if phase == .active { checkClipboard() }
|
||||
}
|
||||
}
|
||||
.interactiveDismissDisabled(step > 0 && step < doneStep)
|
||||
}
|
||||
|
||||
// MARK: - Source step
|
||||
|
||||
private func sourceStep(_ source: InvestmentSource) -> some View {
|
||||
VStack(spacing: 24) {
|
||||
Spacer()
|
||||
|
||||
VStack(spacing: 6) {
|
||||
Text(source.name)
|
||||
.font(.title2.weight(.bold))
|
||||
if let category = source.category?.name {
|
||||
Text(category)
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
if source.latestValue != .zero {
|
||||
VStack(spacing: 2) {
|
||||
Text(String(localized: "guided_previous_value"))
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
Text(source.latestValue.currencyString)
|
||||
.font(.title3.weight(.semibold))
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
TextField("0", text: valueBinding(for: source))
|
||||
.keyboardType(.decimalPad)
|
||||
.multilineTextAlignment(.center)
|
||||
.font(.system(size: 40, weight: .bold, design: .rounded))
|
||||
.focused($amountFocused)
|
||||
.accessibilityIdentifier("guided_value_field")
|
||||
.padding(.vertical, 12)
|
||||
.padding(.horizontal, 24)
|
||||
.background(Color(.secondarySystemGroupedBackground))
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
.padding(.horizontal, 32)
|
||||
|
||||
liveDelta(for: source)
|
||||
|
||||
if let amount = clipboardAmount {
|
||||
Button {
|
||||
values[source.objectID] = String(format: "%.2f", NSDecimalNumber(decimal: amount).doubleValue)
|
||||
clipboardAmount = nil
|
||||
} label: {
|
||||
Label(
|
||||
String(format: String(localized: "guided_paste_amount"), amount.currencyString),
|
||||
systemImage: "doc.on.clipboard.fill"
|
||||
)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.tint(.appPrimary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
VStack(spacing: 10) {
|
||||
Button {
|
||||
advance()
|
||||
} label: {
|
||||
Text(String(localized: "guided_next"))
|
||||
.font(.headline.weight(.semibold))
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 14)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(.appPrimary)
|
||||
.disabled(parsedValue(for: source) == nil)
|
||||
.accessibilityIdentifier("guided_next_btn")
|
||||
|
||||
HStack(spacing: 12) {
|
||||
if source.latestValue != .zero {
|
||||
Button {
|
||||
values[source.objectID] = String(format: "%.2f", NSDecimalNumber(decimal: source.latestValue).doubleValue)
|
||||
advance()
|
||||
} label: {
|
||||
Text(String(localized: "guided_same_as_last"))
|
||||
.font(.subheadline.weight(.medium))
|
||||
}
|
||||
}
|
||||
Button {
|
||||
values[source.objectID] = ""
|
||||
advance()
|
||||
} label: {
|
||||
Text(String(localized: "guided_skip"))
|
||||
.font(.subheadline.weight(.medium))
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.accessibilityIdentifier("guided_skip_btn")
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 32)
|
||||
.padding(.bottom, 24)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func liveDelta(for source: InvestmentSource) -> some View {
|
||||
if let value = parsedValue(for: source), source.latestValue > 0 {
|
||||
let delta = value - source.latestValue
|
||||
let pct = NSDecimalNumber(decimal: delta / source.latestValue).doubleValue * 100
|
||||
let positive = delta >= 0
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: positive ? "arrow.up.right" : "arrow.down.right")
|
||||
.font(.caption.weight(.semibold))
|
||||
Text("\(delta.currencyString) (\(String(format: "%+.1f%%", pct)))")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
}
|
||||
.foregroundColor(positive ? .positiveGreen : .negativeRed)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background((positive ? Color.positiveGreen : Color.negativeRed).opacity(0.12))
|
||||
.clipShape(Capsule())
|
||||
.transition(.opacity)
|
||||
} else {
|
||||
// Reserve the space so the layout doesn't jump while typing.
|
||||
Color.clear.frame(height: 30)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Reflection step
|
||||
|
||||
private var reflectionView: some View {
|
||||
VStack(spacing: 24) {
|
||||
Spacer()
|
||||
|
||||
VStack(spacing: 6) {
|
||||
Text(String(localized: "guided_reflection_title"))
|
||||
.font(.title2.weight(.bold))
|
||||
Text(String(localized: "guided_reflection_subtitle"))
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
HStack(spacing: 10) {
|
||||
ForEach(MonthlyCheckInMood.allCases) { candidate in
|
||||
Button {
|
||||
withAnimation(.snappy) {
|
||||
mood = (mood == candidate) ? nil : candidate
|
||||
}
|
||||
} label: {
|
||||
VStack(spacing: 6) {
|
||||
Image(systemName: candidate.iconName)
|
||||
.font(.title3)
|
||||
Text(candidate.title)
|
||||
.font(.caption2)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.7)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 12)
|
||||
.background(
|
||||
mood == candidate
|
||||
? Color.appPrimary.opacity(0.15)
|
||||
: Color(.secondarySystemGroupedBackground)
|
||||
)
|
||||
.foregroundColor(mood == candidate ? .appPrimary : .primary)
|
||||
.cornerRadius(12)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.strokeBorder(mood == candidate ? Color.appPrimary : .clear, lineWidth: 1.5)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityIdentifier("guided_mood_\(candidate.rawValue)")
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
|
||||
TextField(String(localized: "guided_note_placeholder"), text: $note, axis: .vertical)
|
||||
.lineLimit(2...4)
|
||||
.focused($noteFocused)
|
||||
.padding(14)
|
||||
.background(Color(.secondarySystemGroupedBackground))
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
.padding(.horizontal, 24)
|
||||
|
||||
Spacer()
|
||||
|
||||
Button {
|
||||
finish()
|
||||
} label: {
|
||||
Text(String(localized: "guided_finish"))
|
||||
.font(.headline.weight(.semibold))
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 14)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(.appPrimary)
|
||||
.accessibilityIdentifier("guided_finish_btn")
|
||||
.padding(.horizontal, 32)
|
||||
.padding(.bottom, 24)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Done step
|
||||
|
||||
private var doneView: some View {
|
||||
VStack(spacing: 20) {
|
||||
Spacer()
|
||||
|
||||
Image(systemName: "checkmark.seal.fill")
|
||||
.font(.system(size: 64))
|
||||
.foregroundColor(.positiveGreen)
|
||||
.symbolEffect(.bounce, value: step)
|
||||
|
||||
Text(String(format: String(localized: "guided_done_title"), monthLabel))
|
||||
.font(.title2.weight(.bold))
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
if let totals = savedTotals {
|
||||
VStack(spacing: 4) {
|
||||
Text(totals.current.currencyString)
|
||||
.font(.system(size: 40, weight: .bold, design: .rounded))
|
||||
if totals.previous > 0 {
|
||||
let delta = totals.current - totals.previous
|
||||
let pct = NSDecimalNumber(decimal: delta / totals.previous).doubleValue * 100
|
||||
Text("\(delta.currencyString) (\(String(format: "%+.1f%%", pct)))")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundColor(delta >= 0 ? .positiveGreen : .negativeRed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let streak = MonthlyCheckInStore.stats(referenceDate: referenceDate).currentStreak
|
||||
if streak >= 2 {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "flame.fill")
|
||||
Text(String(format: String(localized: "guided_streak"), streak))
|
||||
}
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundColor(.orange)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 8)
|
||||
.background(Color.orange.opacity(0.12))
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
TipView(SiriTip())
|
||||
.padding(.horizontal, 32)
|
||||
|
||||
VStack(spacing: 10) {
|
||||
if let totals = savedTotals {
|
||||
Button {
|
||||
let delta = totals.current - totals.previous
|
||||
let pct = totals.previous > 0
|
||||
? NSDecimalNumber(decimal: delta / totals.previous).doubleValue * 100 : 0
|
||||
ShareService.shared.sharePortfolioValue(
|
||||
totalValue: totals.current.currencyString,
|
||||
changeText: "\(delta.currencyString) (\(String(format: "%+.1f%%", pct)))",
|
||||
changeLabel: monthLabel,
|
||||
yearChange: nil,
|
||||
sinceInceptionChange: nil
|
||||
)
|
||||
} label: {
|
||||
Label(String(localized: "guided_share_month"), systemImage: "square.and.arrow.up")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.tint(.appPrimary)
|
||||
}
|
||||
|
||||
Button {
|
||||
dismiss()
|
||||
} label: {
|
||||
Text(String(localized: "guided_done_cta"))
|
||||
.font(.headline.weight(.semibold))
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 14)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(.appPrimary)
|
||||
}
|
||||
.padding(.horizontal, 32)
|
||||
.padding(.bottom, 24)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Flow
|
||||
|
||||
private func advance() {
|
||||
withAnimation(.snappy) {
|
||||
step += 1
|
||||
}
|
||||
if step < sources.count {
|
||||
amountFocused = true
|
||||
checkClipboard()
|
||||
} else if step == reflectionStep {
|
||||
amountFocused = false
|
||||
}
|
||||
}
|
||||
|
||||
private func finish() {
|
||||
let previousTotal = sources.reduce(Decimal.zero) { $0 + $1.latestValue }
|
||||
let now = Date()
|
||||
var updatedSources: [InvestmentSource] = []
|
||||
|
||||
for source in sources {
|
||||
guard let value = parsedValue(for: source) else { continue }
|
||||
let snapshot = Snapshot(context: context)
|
||||
snapshot.id = UUID()
|
||||
snapshot.value = NSDecimalNumber(decimal: value)
|
||||
snapshot.date = now
|
||||
snapshot.source = source
|
||||
if let contribution = MonthlyContributionStore.contribution(for: source.id) {
|
||||
snapshot.contribution = NSDecimalNumber(decimal: contribution)
|
||||
}
|
||||
updatedSources.append(source)
|
||||
}
|
||||
|
||||
do {
|
||||
try context.save()
|
||||
} catch {
|
||||
saveError = error.localizedDescription
|
||||
return
|
||||
}
|
||||
|
||||
// New total = entered values + untouched sources at their previous value.
|
||||
let currentTotal = sources.reduce(Decimal.zero) { total, source in
|
||||
total + (parsedValue(for: source) ?? source.latestValue)
|
||||
}
|
||||
savedTotals = (previous: previousTotal, current: currentTotal)
|
||||
|
||||
if let mood { MonthlyCheckInStore.setMood(mood, for: referenceDate) }
|
||||
let trimmedNote = note.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !trimmedNote.isEmpty { MonthlyCheckInStore.setNote(trimmedNote, for: referenceDate) }
|
||||
MonthlyCheckInStore.setCompletionDate(now, for: referenceDate)
|
||||
|
||||
for source in updatedSources {
|
||||
NotificationService.shared.scheduleReminder(for: source)
|
||||
}
|
||||
SharedQuickUpdateSync.refreshMirror()
|
||||
UINotificationFeedbackGenerator().notificationOccurred(.success)
|
||||
|
||||
withAnimation(.snappy) {
|
||||
step = doneStep
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func valueBinding(for source: InvestmentSource) -> Binding<String> {
|
||||
Binding(
|
||||
get: { values[source.objectID] ?? "" },
|
||||
set: { values[source.objectID] = $0 }
|
||||
)
|
||||
}
|
||||
|
||||
private func parsedValue(for source: InvestmentSource) -> Decimal? {
|
||||
guard let raw = values[source.objectID],
|
||||
!raw.trimmingCharacters(in: .whitespaces).isEmpty,
|
||||
let value = CurrencyFormatter.parseUserInput(raw), value > 0 else { return nil }
|
||||
return value
|
||||
}
|
||||
|
||||
private func checkClipboard() {
|
||||
guard let raw = UIPasteboard.general.string, raw != lastSuggestedRaw,
|
||||
let parsed = CurrencyFormatter.parseUserInput(raw), parsed > 0 else { return }
|
||||
lastSuggestedRaw = raw
|
||||
clipboardAmount = parsed
|
||||
}
|
||||
}
|
||||
@@ -1,49 +1,66 @@
|
||||
import SwiftUI
|
||||
|
||||
// Calm 2.0: one insight at a time instead of a chip carousel — a quiet,
|
||||
// glanceable line under the check-in zone. Tap to cycle through the rest.
|
||||
struct InsightsRow: View {
|
||||
let insights: [PortfolioInsight]
|
||||
@State private var index = 0
|
||||
|
||||
private var current: PortfolioInsight? {
|
||||
guard !insights.isEmpty else { return nil }
|
||||
return insights[index % insights.count]
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text(String(localized: "insights_section_title"))
|
||||
.font(.headline)
|
||||
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
if let insight = current {
|
||||
Button {
|
||||
withAnimation(.snappy) {
|
||||
index = (index + 1) % insights.count
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: 12) {
|
||||
ForEach(insights) { insight in
|
||||
insightChip(insight)
|
||||
Image(systemName: insight.systemImage)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundColor(insight.accentColor)
|
||||
.frame(width: 30, height: 30)
|
||||
.background(insight.accentColor.opacity(0.12))
|
||||
.clipShape(Circle())
|
||||
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(insight.title)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
Text(insight.value)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundColor(.primary)
|
||||
.lineLimit(2)
|
||||
if let detail = insight.detail {
|
||||
Text(detail)
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if insights.count > 1 {
|
||||
HStack(spacing: 4) {
|
||||
ForEach(insights.indices, id: \.self) { i in
|
||||
Circle()
|
||||
.fill(i == index % insights.count ? Color.secondary : Color.secondary.opacity(0.25))
|
||||
.frame(width: 5, height: 5)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(14)
|
||||
.background(Color(.secondarySystemGroupedBackground))
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.id(insight.id)
|
||||
.transition(.opacity)
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.systemBackground))
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
|
||||
}
|
||||
|
||||
private func insightChip(_ insight: PortfolioInsight) -> some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack(spacing: 5) {
|
||||
Image(systemName: insight.systemImage)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundColor(insight.accentColor)
|
||||
Text(insight.title)
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
Text(insight.value)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundColor(.primary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 10)
|
||||
.background(insight.accentColor.opacity(0.08))
|
||||
.cornerRadius(12)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.stroke(insight.accentColor.opacity(0.2), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -543,12 +543,16 @@ struct MonthlyCheckInView: View {
|
||||
.foregroundColor(.secondary)
|
||||
} else {
|
||||
ForEach(viewModel.sources, id: \.objectID) { source in
|
||||
let latestSnapshot = source.latestSnapshot
|
||||
let updatedThisCycle = isSnapshotInCurrentCycle(latestSnapshot)
|
||||
// Month-aware: "updated" means updated FOR THE VIEWED month.
|
||||
let monthSnapshot = snapshotForViewedMonth(source)
|
||||
let updatedThisCycle = monthSnapshot != nil
|
||||
let latestSnapshot = monthSnapshot ?? source.latestSnapshot
|
||||
let snapshots = source.sortedSnapshotsByDateAscending
|
||||
let previousSnapshot: Snapshot? = {
|
||||
guard snapshots.count >= 2 else { return nil }
|
||||
return snapshots[snapshots.count - 2]
|
||||
guard let ref = latestSnapshot,
|
||||
let idx = snapshots.firstIndex(where: { $0.objectID == ref.objectID }),
|
||||
idx > 0 else { return nil }
|
||||
return snapshots[idx - 1]
|
||||
}()
|
||||
let valueDiff: Decimal? = {
|
||||
guard let latest = latestSnapshot, let previous = previousSnapshot else { return nil }
|
||||
@@ -837,6 +841,24 @@ private extension MonthlyCheckInView {
|
||||
return snapshot.date >= Calendar.current.startOfDay(for: last)
|
||||
}
|
||||
|
||||
/// The effective (year, month) of the check-in month currently being viewed.
|
||||
private var viewedMonthComponents: DateComponents {
|
||||
let effective = MonthlyCheckInStore.effectiveMonth(for: referenceDate, relativeTo: referenceDate)
|
||||
return Calendar.current.dateComponents([.year, .month], from: effective)
|
||||
}
|
||||
|
||||
/// The source's snapshot that belongs to the VIEWED month (not the global
|
||||
/// latest) — so a past month shows "Updated" for the sources it recorded,
|
||||
/// instead of "Needs update" for everything.
|
||||
func snapshotForViewedMonth(_ source: InvestmentSource) -> Snapshot? {
|
||||
let target = viewedMonthComponents
|
||||
return source.sortedSnapshotsByDateAscending.last { snapshot in
|
||||
let eff = MonthlyCheckInStore.effectiveMonth(for: snapshot.date, relativeTo: snapshot.date)
|
||||
let c = Calendar.current.dateComponents([.year, .month], from: eff)
|
||||
return c.year == target.year && c.month == target.month
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func moodPill(for mood: MonthlyCheckInMood) -> some View {
|
||||
let isSelected = selectedMood == mood
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import SwiftUI
|
||||
|
||||
/// A polished, branded card summarising the month — designed to be rendered to
|
||||
/// an image and shared (screenshot-worthy, marketing-facing). Pulls only from
|
||||
/// data the Dashboard already computes.
|
||||
struct MonthlySummaryShareView: View {
|
||||
let monthLabel: String
|
||||
let totalValue: String
|
||||
let changeAmount: String
|
||||
let changePercentage: String
|
||||
let isPositive: Bool
|
||||
let streak: Int
|
||||
let mood: MonthlyCheckInMood?
|
||||
let rating: Int?
|
||||
let appName: String
|
||||
var qrCodeImage: UIImage? = nil
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
// Header
|
||||
HStack {
|
||||
Text(monthLabel)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundColor(.white.opacity(0.85))
|
||||
.textCase(.uppercase)
|
||||
Spacer()
|
||||
if streak >= 2 {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "flame.fill")
|
||||
.font(.caption2)
|
||||
Text(String(format: String(localized: "share_summary_streak_badge"), streak))
|
||||
.font(.caption2.weight(.bold))
|
||||
}
|
||||
.foregroundColor(.white)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 5)
|
||||
.background(Color.white.opacity(0.18))
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
}
|
||||
|
||||
Text("Monthly Summary")
|
||||
.font(.title.weight(.bold))
|
||||
.foregroundColor(.white)
|
||||
.padding(.top, 6)
|
||||
|
||||
// Hero value
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Portfolio Value")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundColor(.white.opacity(0.85))
|
||||
Text(totalValue)
|
||||
.font(.system(size: 40, weight: .bold, design: .rounded))
|
||||
.foregroundColor(.white)
|
||||
.minimumScaleFactor(0.6)
|
||||
.lineLimit(1)
|
||||
}
|
||||
.padding(.top, 22)
|
||||
|
||||
// Change since last check-in
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: isPositive ? "arrow.up.right" : "arrow.down.right")
|
||||
.font(.subheadline.weight(.bold))
|
||||
Text("\(changeAmount) (\(changePercentage))")
|
||||
.font(.headline.weight(.bold))
|
||||
Spacer()
|
||||
Text("since last check-in")
|
||||
.font(.caption)
|
||||
.foregroundColor(.white.opacity(0.75))
|
||||
}
|
||||
.foregroundColor(.white)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 10)
|
||||
.background(Color.white.opacity(0.15))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
|
||||
.padding(.top, 18)
|
||||
|
||||
// Mood / rating
|
||||
if mood != nil || rating != nil {
|
||||
HStack(spacing: 14) {
|
||||
if let mood {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: mood.iconName)
|
||||
.font(.subheadline)
|
||||
Text(mood.title)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
}
|
||||
.foregroundColor(.white)
|
||||
}
|
||||
if let rating {
|
||||
HStack(spacing: 2) {
|
||||
ForEach(1...5, id: \.self) { i in
|
||||
Image(systemName: i <= rating ? "star.fill" : "star")
|
||||
.font(.caption)
|
||||
.foregroundColor(.white.opacity(i <= rating ? 1 : 0.4))
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding(.top, 16)
|
||||
}
|
||||
|
||||
Spacer(minLength: 0)
|
||||
|
||||
// Footer / branding
|
||||
HStack(spacing: 12) {
|
||||
Image("BrandMark")
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: 40, height: 40)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 9, style: .continuous))
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Tracked with")
|
||||
.font(.caption2.weight(.medium))
|
||||
.foregroundColor(.white.opacity(0.7))
|
||||
Text(appName)
|
||||
.font(.subheadline.weight(.bold))
|
||||
.foregroundColor(.white)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if let qrCodeImage {
|
||||
Image(uiImage: qrCodeImage)
|
||||
.interpolation(.none)
|
||||
.resizable()
|
||||
.frame(width: 52, height: 52)
|
||||
.background(Color.white)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 7, style: .continuous))
|
||||
}
|
||||
}
|
||||
.padding(.top, 20)
|
||||
}
|
||||
.padding(24)
|
||||
.frame(width: 340, height: 440)
|
||||
.background(ShareCardBackground())
|
||||
.clipShape(RoundedRectangle(cornerRadius: 28, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 28, style: .continuous)
|
||||
.stroke(Color.white.opacity(0.12), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import SwiftUI
|
||||
import CoreData
|
||||
import TipKit
|
||||
import UIKit
|
||||
import PhotosUI
|
||||
|
||||
struct QuickUpdateView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@@ -22,6 +24,15 @@ struct QuickUpdateView: View {
|
||||
@FocusState private var focusedSource: NSManagedObjectID?
|
||||
@State private var clipboardAmount: Decimal?
|
||||
@State private var lastSuggestedRaw: String?
|
||||
@State private var lastPasteboardChangeCount = -1
|
||||
|
||||
// Per-field paste/scan (feedback #3): keyboard toolbar acts on the focused
|
||||
// field — paste a clipboard amount or OCR a number from a screenshot.
|
||||
@State private var photoItem: PhotosPickerItem?
|
||||
@State private var showingPhotoPicker = false
|
||||
@State private var scanCandidates: [ScannedAmount] = []
|
||||
@State private var isScanning = false
|
||||
@State private var scanTargetSource: NSManagedObjectID?
|
||||
|
||||
/// First source (list order) still without a value — the "active" one.
|
||||
private var nextEmptySource: InvestmentSource? {
|
||||
@@ -36,13 +47,32 @@ struct QuickUpdateView: View {
|
||||
AppBackground()
|
||||
|
||||
if sources.isEmpty {
|
||||
ContentUnavailableView(
|
||||
String(localized: "quick_update_no_sources"),
|
||||
systemImage: "list.bullet",
|
||||
description: Text(String(localized: "quick_update_no_sources_body"))
|
||||
)
|
||||
ContentUnavailableView {
|
||||
Label(String(localized: "quick_update_no_sources"), systemImage: "list.bullet")
|
||||
} description: {
|
||||
Text(String(localized: "quick_update_no_sources_body"))
|
||||
} actions: {
|
||||
Button {
|
||||
dismiss()
|
||||
// Let this sheet finish dismissing before Dashboard
|
||||
// presents AddSourceView (same pattern as ContentView).
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) {
|
||||
NotificationCenter.default.post(name: .openAddFirstSource, object: nil)
|
||||
}
|
||||
} label: {
|
||||
Text(String(localized: "quick_update_add_first_source"))
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
} else {
|
||||
List {
|
||||
Section {
|
||||
TipView(ScreenshotOCRTip())
|
||||
.listRowInsets(EdgeInsets())
|
||||
.listRowBackground(Color.clear)
|
||||
}
|
||||
.listSectionSeparator(.hidden)
|
||||
|
||||
if let amount = clipboardAmount, let target = nextEmptySource {
|
||||
Section {
|
||||
Button {
|
||||
@@ -92,6 +122,51 @@ struct QuickUpdateView: View {
|
||||
.disabled(isSaving || filledValues.isEmpty)
|
||||
.fontWeight(.semibold)
|
||||
}
|
||||
// Keyboard accessory: paste / scan for the focused field.
|
||||
ToolbarItemGroup(placement: .keyboard) {
|
||||
if let clip = clipboardFieldAmount {
|
||||
Button {
|
||||
if let target = focusedSource { values[target] = decimalInputString(clip) }
|
||||
} label: {
|
||||
Label(String(format: String(localized: "quick_update_paste_value"), clip.currencyString),
|
||||
systemImage: "doc.on.clipboard")
|
||||
}
|
||||
}
|
||||
Button {
|
||||
scanTargetSource = focusedSource
|
||||
showingPhotoPicker = true
|
||||
} label: {
|
||||
Label(String(localized: "quick_update_scan"), systemImage: "text.viewfinder")
|
||||
}
|
||||
Spacer()
|
||||
Button(String(localized: "quick_update_done_kbd")) { focusedSource = nil }
|
||||
.fontWeight(.semibold)
|
||||
}
|
||||
}
|
||||
.photosPicker(isPresented: $showingPhotoPicker, selection: $photoItem, matching: .images)
|
||||
.onChange(of: photoItem) { _, item in
|
||||
guard let item else { return }
|
||||
Task { await scanPickedPhoto(item) }
|
||||
}
|
||||
.overlay {
|
||||
if isScanning {
|
||||
ZStack {
|
||||
Color.black.opacity(0.2).ignoresSafeArea()
|
||||
ProgressView(String(localized: "quick_update_scanning"))
|
||||
.padding(20)
|
||||
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
}
|
||||
}
|
||||
.confirmationDialog(
|
||||
String(localized: "quick_update_pick_amount"),
|
||||
isPresented: Binding(get: { scanCandidates.count > 1 }, set: { if !$0 { scanCandidates = [] } }),
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
ForEach(scanCandidates) { c in
|
||||
Button(c.value.currencyString) { applyScanned(c.value) }
|
||||
}
|
||||
Button(String(localized: "cancel"), role: .cancel) { scanCandidates = [] }
|
||||
}
|
||||
.alert("Error", isPresented: Binding(
|
||||
get: { saveError != nil },
|
||||
@@ -111,6 +186,47 @@ struct QuickUpdateView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// Clipboard amount for the keyboard toolbar — a live text amount, or the
|
||||
/// amount OCR'd from a copied image (held in clipboardAmount).
|
||||
private var clipboardFieldAmount: Decimal? {
|
||||
if let raw = UIPasteboard.general.string,
|
||||
let parsed = CurrencyFormatter.parseUserInput(raw), parsed > 0 {
|
||||
return parsed
|
||||
}
|
||||
return clipboardAmount
|
||||
}
|
||||
|
||||
private func decimalInputString(_ value: Decimal) -> String {
|
||||
String(format: "%.2f", NSDecimalNumber(decimal: value).doubleValue)
|
||||
}
|
||||
|
||||
private func applyScanned(_ value: Decimal) {
|
||||
if let target = scanTargetSource ?? focusedSource {
|
||||
values[target] = decimalInputString(value)
|
||||
}
|
||||
scanCandidates = []
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func scanPickedPhoto(_ item: PhotosPickerItem) async {
|
||||
isScanning = true
|
||||
defer { photoItem = nil }
|
||||
guard let data = try? await item.loadTransferable(type: Data.self),
|
||||
let image = UIImage(data: data) else { isScanning = false; return }
|
||||
ImageAmountScanner.scan(image) { candidates in
|
||||
DispatchQueue.main.async {
|
||||
isScanning = false
|
||||
if candidates.count == 1 {
|
||||
applyScanned(candidates[0].value)
|
||||
} else if candidates.count > 1 {
|
||||
scanCandidates = candidates
|
||||
} else {
|
||||
saveError = String(localized: "quick_update_no_amount_found")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func sourceRow(_ source: InvestmentSource) -> some View {
|
||||
VStack(spacing: 6) {
|
||||
@@ -185,17 +301,30 @@ struct QuickUpdateView: View {
|
||||
)
|
||||
}
|
||||
|
||||
/// Reads the pasteboard and offers the parsed amount for the next pending source.
|
||||
/// Skips values already suggested (or already typed) so returning to the app
|
||||
/// with the same clipboard doesn't nag.
|
||||
/// Reads the pasteboard and offers a parsed amount for the next pending source.
|
||||
/// Handles BOTH a copied text value and a copied image (screenshot) — the
|
||||
/// image is OCR'd on-device and its most prominent number offered. Gated by
|
||||
/// the pasteboard change count so returning with the same clipboard doesn't nag.
|
||||
private func checkClipboard() {
|
||||
guard let raw = UIPasteboard.general.string, raw != lastSuggestedRaw,
|
||||
let parsed = CurrencyFormatter.parseUserInput(raw), parsed > 0,
|
||||
nextEmptySource != nil else {
|
||||
let pb = UIPasteboard.general
|
||||
guard pb.changeCount != lastPasteboardChangeCount, nextEmptySource != nil else { return }
|
||||
lastPasteboardChangeCount = pb.changeCount
|
||||
|
||||
// 1. Text amount (cheap, synchronous).
|
||||
if let raw = pb.string, let parsed = CurrencyFormatter.parseUserInput(raw), parsed > 0 {
|
||||
clipboardAmount = parsed
|
||||
return
|
||||
}
|
||||
lastSuggestedRaw = raw
|
||||
clipboardAmount = parsed
|
||||
// 2. Copied image → OCR the amount automatically.
|
||||
if pb.hasImages, let image = pb.image {
|
||||
isScanning = true
|
||||
ImageAmountScanner.scan(image) { candidates in
|
||||
DispatchQueue.main.async {
|
||||
isScanning = false
|
||||
if let best = candidates.first { clipboardAmount = best.value }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func applyClipboard(_ amount: Decimal, to source: InvestmentSource) {
|
||||
|
||||
@@ -1,41 +1,264 @@
|
||||
import SwiftUI
|
||||
|
||||
struct MonthlyCheckInShareCardView: View {
|
||||
let summary: MonthlySummary
|
||||
let appName: String
|
||||
var qrCodeImage: UIImage? = nil
|
||||
// MARK: - Share card design system ("dark panorama")
|
||||
//
|
||||
// Same visual language as the App Store screenshots — the app's strongest
|
||||
// brand asset: near-black navy base, blue/green atmospheric glows, a subtle
|
||||
// technical grid and the gradient evolution sparkline. These cards are the
|
||||
// main referral surface, so they are designed to look premium in a chat or a
|
||||
// story, and to carry an attributed QR/link back to the App Store.
|
||||
|
||||
enum ShareCardStyle {
|
||||
static let navyTop = Color(red: 10 / 255, green: 21 / 255, blue: 42 / 255)
|
||||
static let navyBottom = Color(red: 3 / 255, green: 9 / 255, blue: 22 / 255)
|
||||
static let blue = Color(red: 48 / 255, green: 132 / 255, blue: 255 / 255)
|
||||
static let green = Color(red: 16 / 255, green: 185 / 255, blue: 129 / 255)
|
||||
static let gridLine = Color(red: 93 / 255, green: 132 / 255, blue: 181 / 255)
|
||||
static let muted = Color(red: 155 / 255, green: 169 / 255, blue: 191 / 255)
|
||||
// Light theme
|
||||
static let lightTop = Color(red: 248 / 255, green: 250 / 255, blue: 252 / 255)
|
||||
static let lightBottom = Color(red: 226 / 255, green: 232 / 255, blue: 240 / 255)
|
||||
static let ink = Color(red: 15 / 255, green: 27 / 255, blue: 45 / 255)
|
||||
static let lightMuted = Color(red: 100 / 255, green: 116 / 255, blue: 139 / 255)
|
||||
}
|
||||
|
||||
/// Light/dark variants of the card. Blue/green accents are shared; everything
|
||||
/// else (surface, text, grid, panels) flips per theme.
|
||||
enum ShareCardTheme: String, CaseIterable, Identifiable {
|
||||
case dark, light
|
||||
var id: String { rawValue }
|
||||
|
||||
var ink: Color { self == .dark ? .white : ShareCardStyle.ink }
|
||||
var mutedInk: Color { self == .dark ? ShareCardStyle.muted : ShareCardStyle.lightMuted }
|
||||
var panelFill: Color { self == .dark ? Color.white.opacity(0.06) : ShareCardStyle.ink.opacity(0.05) }
|
||||
var strokeColor: Color { self == .dark ? Color.white.opacity(0.12) : ShareCardStyle.ink.opacity(0.10) }
|
||||
var footerFill: Color { self == .dark ? Color.white.opacity(0.08) : ShareCardStyle.ink.opacity(0.04) }
|
||||
var gridOpacity: Double { self == .dark ? 0.10 : 0.06 }
|
||||
var glowOpacityMul: Double { self == .dark ? 1.0 : 0.7 }
|
||||
}
|
||||
|
||||
struct ShareCardBackground: View {
|
||||
var theme: ShareCardTheme = .dark
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
Text(summary.formattedMonthYear)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundColor(.white.opacity(0.85))
|
||||
ZStack {
|
||||
LinearGradient(
|
||||
colors: theme == .dark
|
||||
? [ShareCardStyle.navyTop, ShareCardStyle.navyBottom]
|
||||
: [ShareCardStyle.lightTop, ShareCardStyle.lightBottom],
|
||||
startPoint: .top, endPoint: .bottom
|
||||
)
|
||||
|
||||
Text("Monthly Check-in")
|
||||
.font(.title2.weight(.bold))
|
||||
.foregroundColor(.white)
|
||||
// Atmospheric glows (blue top-left, green bottom-right)
|
||||
Circle()
|
||||
.fill(ShareCardStyle.blue.opacity(0.30 * theme.glowOpacityMul))
|
||||
.frame(width: 280, height: 280)
|
||||
.blur(radius: 70)
|
||||
.offset(x: -100, y: -120)
|
||||
Circle()
|
||||
.fill(ShareCardStyle.green.opacity(0.22 * theme.glowOpacityMul))
|
||||
.frame(width: 260, height: 260)
|
||||
.blur(radius: 80)
|
||||
.offset(x: 130, y: 150)
|
||||
|
||||
metricRow("Starting", summary.formattedStartingValue)
|
||||
metricRow("Ending", summary.formattedEndingValue)
|
||||
if summary.contributions != 0 {
|
||||
metricRow("Contributions", summary.formattedContributions)
|
||||
}
|
||||
metricRow("Net performance", "\(summary.formattedNetPerformance) (\(summary.formattedNetPerformancePercentage))")
|
||||
|
||||
Spacer(minLength: 0)
|
||||
shareFooter(appName: appName, qrCodeImage: qrCodeImage)
|
||||
// Subtle technical grid
|
||||
ShareCardGrid()
|
||||
.stroke(ShareCardStyle.gridLine.opacity(theme.gridOpacity), lineWidth: 1)
|
||||
}
|
||||
.padding(20)
|
||||
.frame(width: 320, height: 300)
|
||||
.background(LinearGradient.appPrimaryGradient)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 24, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 24, style: .continuous)
|
||||
.stroke(Color.white.opacity(0.2), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private struct ShareCardGrid: Shape {
|
||||
func path(in rect: CGRect) -> Path {
|
||||
var p = Path()
|
||||
let step: CGFloat = 44
|
||||
var x: CGFloat = 0
|
||||
while x <= rect.width {
|
||||
p.move(to: CGPoint(x: x, y: 0))
|
||||
p.addLine(to: CGPoint(x: x, y: rect.height))
|
||||
x += step
|
||||
}
|
||||
var y: CGFloat = 0
|
||||
while y <= rect.height {
|
||||
p.move(to: CGPoint(x: 0, y: y))
|
||||
p.addLine(to: CGPoint(x: rect.width, y: y))
|
||||
y += step
|
||||
}
|
||||
return p
|
||||
}
|
||||
}
|
||||
|
||||
/// Gradient evolution sparkline (line + soft area + endpoint dot).
|
||||
struct ShareSparkline: View {
|
||||
let values: [Double]
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geo in
|
||||
let pts = points(in: geo.size)
|
||||
if pts.count > 1 {
|
||||
ZStack {
|
||||
// Area fill
|
||||
Path { p in
|
||||
p.move(to: CGPoint(x: pts[0].x, y: geo.size.height))
|
||||
pts.forEach { p.addLine(to: $0) }
|
||||
p.addLine(to: CGPoint(x: pts[pts.count - 1].x, y: geo.size.height))
|
||||
p.closeSubpath()
|
||||
}
|
||||
.fill(
|
||||
LinearGradient(colors: [ShareCardStyle.blue.opacity(0.30), .clear],
|
||||
startPoint: .top, endPoint: .bottom)
|
||||
)
|
||||
|
||||
// Gradient line
|
||||
Path { p in
|
||||
p.move(to: pts[0])
|
||||
pts.dropFirst().forEach { p.addLine(to: $0) }
|
||||
}
|
||||
.stroke(
|
||||
LinearGradient(colors: [ShareCardStyle.blue, .cyan],
|
||||
startPoint: .leading, endPoint: .trailing),
|
||||
style: StrokeStyle(lineWidth: 2.4, lineCap: .round, lineJoin: .round)
|
||||
)
|
||||
|
||||
// Endpoint dot ("now")
|
||||
if let last = pts.last {
|
||||
Circle()
|
||||
.fill(Color.cyan)
|
||||
.frame(width: 7, height: 7)
|
||||
.position(last)
|
||||
Circle()
|
||||
.fill(Color.cyan.opacity(0.3))
|
||||
.frame(width: 16, height: 16)
|
||||
.position(last)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func points(in size: CGSize) -> [CGPoint] {
|
||||
guard values.count > 1 else { return [] }
|
||||
let minV = values.min() ?? 0
|
||||
let maxV = values.max() ?? 1
|
||||
let range = maxV - minV
|
||||
let inset: CGFloat = 6
|
||||
return values.indices.map { i in
|
||||
let x = size.width * CGFloat(i) / CGFloat(values.count - 1)
|
||||
let norm = range > 0 ? (values[i] - minV) / range : 0.5
|
||||
let y = inset + (size.height - inset * 2) * (1 - CGFloat(norm))
|
||||
return CGPoint(x: x, y: y)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Shared pieces
|
||||
|
||||
private func eyebrow(_ text: String) -> some View {
|
||||
Text(text.uppercased())
|
||||
.font(.caption2.weight(.bold))
|
||||
.tracking(2.2)
|
||||
.foregroundColor(ShareCardStyle.green)
|
||||
}
|
||||
|
||||
private func deltaChip(_ text: String, positive: Bool, large: Bool = false) -> some View {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: positive ? "arrow.up.right" : "arrow.down.right")
|
||||
.font(.system(size: large ? 13 : 10, weight: .bold))
|
||||
Text(text)
|
||||
.font((large ? Font.headline : Font.caption).weight(.bold))
|
||||
}
|
||||
.foregroundColor(positive ? ShareCardStyle.green : Color(red: 1, green: 0.42, blue: 0.42))
|
||||
.padding(.horizontal, large ? 12 : 9)
|
||||
.padding(.vertical, large ? 6 : 4)
|
||||
.background(
|
||||
Capsule().fill((positive ? ShareCardStyle.green : Color.red).opacity(0.14))
|
||||
)
|
||||
}
|
||||
|
||||
private func metricRow(_ title: String, _ value: String, theme: ShareCardTheme) -> some View {
|
||||
HStack {
|
||||
Text(title)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundColor(theme.mutedInk)
|
||||
Spacer()
|
||||
Text(value)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundColor(theme.ink)
|
||||
.multilineTextAlignment(.trailing)
|
||||
.minimumScaleFactor(0.7)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
|
||||
/// Brand lockup pinned at the TOP of every card — big and unmissable so the
|
||||
/// name does the branding work (and never clips like the old footer mark did).
|
||||
/// Optional `asOf` line gives an outsider the context ("what date is this?").
|
||||
private func brandHeader(theme: ShareCardTheme, asOf: String?) -> some View {
|
||||
HStack(spacing: 12) {
|
||||
Image("BrandMark")
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: 48, height: 48)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 11, style: .continuous))
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text("Portfolio Journal")
|
||||
.font(.system(.title2, design: .rounded).weight(.heavy))
|
||||
.foregroundColor(theme.ink)
|
||||
.minimumScaleFactor(0.7)
|
||||
.lineLimit(1)
|
||||
if let asOf {
|
||||
Text(String(format: String(localized: "share_as_of"), asOf))
|
||||
.font(.caption2.weight(.medium))
|
||||
.foregroundColor(theme.mutedInk)
|
||||
}
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Download-focused footer: an explicit "get the app" CTA plus a scannable QR.
|
||||
/// This is the conversion surface — every shared card should sell the install.
|
||||
private func downloadFooter(qrCodeImage: UIImage?, theme: ShareCardTheme) -> some View {
|
||||
HStack(spacing: 12) {
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(String(localized: "share_cta_headline"))
|
||||
.font(.subheadline.weight(.bold))
|
||||
.foregroundColor(theme.ink)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
Text(String(localized: "share_cta_sub"))
|
||||
.font(.caption2.weight(.medium))
|
||||
.foregroundColor(theme.mutedInk)
|
||||
}
|
||||
|
||||
Spacer(minLength: 8)
|
||||
|
||||
if let qrCodeImage {
|
||||
Image(uiImage: qrCodeImage)
|
||||
.interpolation(.none)
|
||||
.resizable()
|
||||
.frame(width: 54, height: 54)
|
||||
.padding(4)
|
||||
.background(Color.white)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 9, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 9, style: .continuous)
|
||||
.stroke(ShareCardStyle.ink.opacity(theme == .light ? 0.10 : 0), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||
.fill(theme.footerFill)
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||
.stroke(ShareCardStyle.green.opacity(0.35), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Portfolio value card
|
||||
|
||||
struct PortfolioValueShareCardView: View {
|
||||
let totalValue: String
|
||||
let changeText: String
|
||||
@@ -44,102 +267,245 @@ struct PortfolioValueShareCardView: View {
|
||||
let sinceInceptionChange: String?
|
||||
let appName: String
|
||||
var qrCodeImage: UIImage? = nil
|
||||
var sparkline: [Double] = []
|
||||
var isPositive: Bool = true
|
||||
/// The all-time (or best available) return %, shown as the hero — this is
|
||||
/// the number people actually want to post. Falls back to nil → uses value.
|
||||
var heroPercent: String? = nil
|
||||
/// Date of the latest check-in, shown under the brand so an outsider knows
|
||||
/// what point in time the numbers describe.
|
||||
var asOf: String? = nil
|
||||
var theme: ShareCardTheme = .dark
|
||||
/// Story = 9:16 (Instagram/WhatsApp status); otherwise 4:5 feed post.
|
||||
var storyFormat: Bool = false
|
||||
|
||||
// Exact aspect ratios so nothing clips: 9:16 story, 4:5 post.
|
||||
private var cardWidth: CGFloat { storyFormat ? 360 : 360 }
|
||||
private var cardHeight: CGFloat { storyFormat ? 640 : 450 }
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
Text("Portfolio Snapshot")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundColor(.white.opacity(0.85))
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
brandHeader(theme: theme, asOf: asOf)
|
||||
|
||||
Text("Total Portfolio Value")
|
||||
.font(.headline.weight(.semibold))
|
||||
.foregroundColor(.white.opacity(0.9))
|
||||
Spacer(minLength: storyFormat ? 24 : 14)
|
||||
|
||||
Text(totalValue)
|
||||
.font(.system(size: 34, weight: .bold, design: .rounded))
|
||||
.foregroundColor(.white)
|
||||
|
||||
Text("\(changeText) \(changeLabel)")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundColor(.white.opacity(0.9))
|
||||
|
||||
if let yearChange {
|
||||
metricRow("YoY", yearChange)
|
||||
// Hero: big return % (branding-friendly, privacy-friendly), with the
|
||||
// portfolio value as the supporting line.
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
eyebrow(String(localized: "share_hero_eyebrow"))
|
||||
Text(heroPercent ?? totalValue)
|
||||
.font(.system(size: storyFormat ? 52 : 44, weight: .heavy, design: .rounded))
|
||||
.foregroundColor(heroIsPercent ? (isPositive ? ShareCardStyle.green : Color(red: 1, green: 0.42, blue: 0.42)) : theme.ink)
|
||||
.minimumScaleFactor(0.5)
|
||||
.lineLimit(1)
|
||||
if heroIsPercent && !totalValue.isEmpty {
|
||||
Text(totalValue)
|
||||
.font(.title3.weight(.bold))
|
||||
.foregroundColor(theme.ink)
|
||||
.minimumScaleFactor(0.5)
|
||||
.lineLimit(1)
|
||||
}
|
||||
deltaChip("\(changeText) \(changeLabel)", positive: isPositive, large: true)
|
||||
}
|
||||
|
||||
if let sinceInceptionChange {
|
||||
metricRow("Since inception", sinceInceptionChange)
|
||||
if sparkline.count > 1 {
|
||||
ShareSparkline(values: sparkline)
|
||||
.frame(height: storyFormat ? 150 : 96)
|
||||
.padding(.top, storyFormat ? 24 : 16)
|
||||
}
|
||||
|
||||
Spacer(minLength: 0)
|
||||
shareFooter(appName: appName, qrCodeImage: qrCodeImage)
|
||||
}
|
||||
.padding(20)
|
||||
.frame(width: 320, height: 290)
|
||||
.background(
|
||||
LinearGradient(
|
||||
colors: [Color.appSecondary, Color.appPrimary],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
VStack(spacing: 9) {
|
||||
if let yearChange {
|
||||
metricRow(String(localized: "share_metric_yoy"), yearChange, theme: theme)
|
||||
}
|
||||
if let sinceInceptionChange {
|
||||
metricRow(String(localized: "share_metric_since_inception"), sinceInceptionChange, theme: theme)
|
||||
}
|
||||
}
|
||||
.padding(13)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
.fill(theme.panelFill)
|
||||
)
|
||||
)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 24, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 24, style: .continuous)
|
||||
.stroke(Color.white.opacity(0.2), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
}
|
||||
.padding(.top, storyFormat ? 24 : 14)
|
||||
|
||||
private func metricRow(_ title: String, _ value: String) -> some View {
|
||||
HStack {
|
||||
Text(title)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundColor(.white.opacity(0.8))
|
||||
Spacer()
|
||||
Text(value)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundColor(.white)
|
||||
.multilineTextAlignment(.trailing)
|
||||
}
|
||||
}
|
||||
Spacer(minLength: 14)
|
||||
|
||||
private func shareFooter(appName: String, qrCodeImage: UIImage?) -> some View {
|
||||
VStack(spacing: 10) {
|
||||
Rectangle()
|
||||
.fill(Color.white.opacity(0.2))
|
||||
.frame(height: 1)
|
||||
|
||||
HStack(spacing: 12) {
|
||||
Image("BrandMark")
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: 36, height: 36)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Powered by")
|
||||
.font(.caption2.weight(.medium))
|
||||
.foregroundColor(.white.opacity(0.7))
|
||||
Text(appName)
|
||||
.font(.subheadline.weight(.bold))
|
||||
.foregroundColor(.white)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if let qrCodeImage {
|
||||
Image(uiImage: qrCodeImage)
|
||||
.interpolation(.none)
|
||||
.resizable()
|
||||
.frame(width: 48, height: 48)
|
||||
.background(Color.white)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous))
|
||||
} else {
|
||||
Image(systemName: "qrcode")
|
||||
.font(.title3)
|
||||
.foregroundColor(.white.opacity(0.9))
|
||||
}
|
||||
downloadFooter(qrCodeImage: qrCodeImage, theme: theme)
|
||||
}
|
||||
.padding(storyFormat ? 28 : 22)
|
||||
.frame(width: cardWidth, height: cardHeight)
|
||||
.background(ShareCardBackground(theme: theme))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 26, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 26, style: .continuous)
|
||||
.stroke(theme.strokeColor, lineWidth: 1)
|
||||
)
|
||||
}
|
||||
|
||||
private var heroIsPercent: Bool { heroPercent != nil }
|
||||
}
|
||||
|
||||
// MARK: - Monthly check-in card
|
||||
|
||||
struct MonthlyCheckInShareCardView: View {
|
||||
let summary: MonthlySummary
|
||||
let appName: String
|
||||
var qrCodeImage: UIImage? = nil
|
||||
var theme: ShareCardTheme = .dark
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
brandHeader(theme: theme, asOf: summary.formattedMonthYear)
|
||||
|
||||
Spacer(minLength: 16)
|
||||
|
||||
eyebrow(summary.formattedMonthYear)
|
||||
Text("Monthly Check-in")
|
||||
.font(.title.weight(.bold))
|
||||
.foregroundColor(theme.ink)
|
||||
.padding(.top, 4)
|
||||
|
||||
VStack(spacing: 9) {
|
||||
metricRow("Starting", summary.formattedStartingValue, theme: theme)
|
||||
metricRow("Ending", summary.formattedEndingValue, theme: theme)
|
||||
if summary.contributions != 0 {
|
||||
metricRow("Contributions", summary.formattedContributions, theme: theme)
|
||||
}
|
||||
metricRow("Net performance", "\(summary.formattedNetPerformance) (\(summary.formattedNetPerformancePercentage))", theme: theme)
|
||||
}
|
||||
.padding(13)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
.fill(theme.panelFill)
|
||||
)
|
||||
.padding(.top, 16)
|
||||
|
||||
Spacer(minLength: 14)
|
||||
downloadFooter(qrCodeImage: qrCodeImage, theme: theme)
|
||||
}
|
||||
.padding(22)
|
||||
.frame(width: 360, height: 420)
|
||||
.background(ShareCardBackground(theme: theme))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 26, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 26, style: .continuous)
|
||||
.stroke(theme.strokeColor, lineWidth: 1)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Pre-share options (privacy + format) with live preview
|
||||
|
||||
/// Sheet shown before sharing the portfolio card. The privacy toggle ("only
|
||||
/// percentages") is the referral unlock: nobody shares absolute net worth,
|
||||
/// everybody shares +42%.
|
||||
struct PortfolioShareOptionsView: View {
|
||||
let totalValue: String
|
||||
let changeText: String
|
||||
let changeLabel: String
|
||||
let yearChange: String?
|
||||
let sinceInceptionChange: String?
|
||||
let sparkline: [Double]
|
||||
let isPositive: Bool
|
||||
var asOf: String? = nil
|
||||
|
||||
@State private var percentOnly = false
|
||||
@State private var storyFormat = false
|
||||
@State private var theme: ShareCardTheme = .dark
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
VStack(spacing: 16) {
|
||||
Spacer(minLength: 0)
|
||||
|
||||
// Live preview — scaled to fit
|
||||
previewCard
|
||||
.scaleEffect(storyFormat ? 0.62 : 0.78)
|
||||
.frame(height: storyFormat ? 310 : 275)
|
||||
.animation(.snappy, value: percentOnly)
|
||||
.animation(.snappy, value: storyFormat)
|
||||
.animation(.snappy, value: theme)
|
||||
|
||||
Spacer(minLength: 0)
|
||||
|
||||
VStack(spacing: 12) {
|
||||
Toggle(isOn: $percentOnly.animation(.snappy)) {
|
||||
Label(String(localized: "share_percent_only"), systemImage: "eye.slash")
|
||||
.font(.subheadline)
|
||||
}
|
||||
.tint(.appPrimary)
|
||||
|
||||
Picker("Theme", selection: $theme.animation(.snappy)) {
|
||||
Text(String(localized: "share_theme_dark")).tag(ShareCardTheme.dark)
|
||||
Text(String(localized: "share_theme_light")).tag(ShareCardTheme.light)
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
|
||||
Picker("Format", selection: $storyFormat.animation(.snappy)) {
|
||||
Text(String(localized: "share_format_post")).tag(false)
|
||||
Text(String(localized: "share_format_story")).tag(true)
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
|
||||
Button {
|
||||
ShareService.shared.sharePortfolioValue(
|
||||
totalValue: totalValue,
|
||||
changeText: changeText,
|
||||
changeLabel: changeLabel,
|
||||
yearChange: yearChange,
|
||||
sinceInceptionChange: sinceInceptionChange,
|
||||
sparkline: sparkline,
|
||||
isPositive: isPositive,
|
||||
asOf: asOf,
|
||||
theme: theme,
|
||||
percentOnly: percentOnly,
|
||||
storyFormat: storyFormat
|
||||
)
|
||||
dismiss()
|
||||
} label: {
|
||||
Text(String(localized: "share_card_cta"))
|
||||
.font(.headline)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 14)
|
||||
.background(Color.appPrimary)
|
||||
.foregroundColor(.white)
|
||||
.cornerRadius(14)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
.navigationTitle(String(localized: "share_options_title"))
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
.presentationDetents([.large])
|
||||
}
|
||||
|
||||
private var previewCard: some View {
|
||||
let display = ShareService.portfolioCardStrings(
|
||||
totalValue: totalValue,
|
||||
changeText: changeText,
|
||||
yearChange: yearChange,
|
||||
sinceInceptionChange: sinceInceptionChange,
|
||||
percentOnly: percentOnly
|
||||
)
|
||||
return PortfolioValueShareCardView(
|
||||
totalValue: display.totalValue,
|
||||
changeText: display.changeText,
|
||||
changeLabel: changeLabel,
|
||||
yearChange: display.yearChange,
|
||||
sinceInceptionChange: display.sinceInceptionChange,
|
||||
appName: "Portfolio Journal",
|
||||
qrCodeImage: GoalShareService.generateQRCode(for: ShareService.snapshotShareURL, size: 200),
|
||||
sparkline: sparkline,
|
||||
isPositive: isPositive,
|
||||
heroPercent: display.heroPercent,
|
||||
asOf: asOf,
|
||||
theme: theme,
|
||||
storyFormat: storyFormat
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ struct GoalsView: View {
|
||||
progress: viewModel.progress(for: goal),
|
||||
totalValue: viewModel.totalValue(for: goal),
|
||||
paceStatus: viewModel.paceStatus(for: goal),
|
||||
estimatedCompletionDate: viewModel.estimateCompletionDate(for: goal),
|
||||
projection: viewModel.projection(for: goal),
|
||||
onEdit: { editingGoal = goal }
|
||||
)
|
||||
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
|
||||
@@ -216,7 +216,7 @@ struct GoalRowView: View {
|
||||
let progress: Double
|
||||
let totalValue: Decimal
|
||||
let paceStatus: GoalPaceStatus?
|
||||
let estimatedCompletionDate: Date?
|
||||
let projection: GoalProjection.Result
|
||||
let onEdit: () -> Void
|
||||
|
||||
@State private var showingShareOptions = false
|
||||
@@ -244,62 +244,67 @@ struct GoalRowView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// Semantic ring color: green when achieved or ahead, orange when behind,
|
||||
/// accent while simply on the way.
|
||||
private var ringTint: Color {
|
||||
if isAchieved { return .appSuccess }
|
||||
if paceStatus?.isBehind == true { return .appWarning }
|
||||
return .appPrimary
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack(alignment: .topTrailing) {
|
||||
Button(action: onEdit) {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack {
|
||||
Text(goal.name)
|
||||
.font(.headline)
|
||||
.foregroundColor(isAchieved ? .appSecondary : .primary)
|
||||
if isAchieved {
|
||||
Text("Achieved")
|
||||
.font(.caption2.weight(.bold))
|
||||
.foregroundColor(.white)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.background(Color.appSecondary)
|
||||
.clipShape(Capsule())
|
||||
HStack(spacing: 16) {
|
||||
ProgressRing(progress: progress, tint: ringTint)
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack(spacing: 8) {
|
||||
Text(goal.name)
|
||||
.font(.headline)
|
||||
if isAchieved {
|
||||
Text("Achieved")
|
||||
.font(.caption2.weight(.bold))
|
||||
.foregroundColor(.white)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 3)
|
||||
.background(Color.appSuccess)
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
}
|
||||
|
||||
HStack(spacing: 4) {
|
||||
Text(totalValue.currencyString)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.hiddenBalance()
|
||||
Text("of \(goal.targetDecimal.currencyString)")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
.hiddenBalance()
|
||||
}
|
||||
|
||||
if let targetDate = goal.targetDate {
|
||||
Text("Target date: \(targetDate.mediumDateString)")
|
||||
.font(.caption)
|
||||
.foregroundColor(targetDateColor)
|
||||
}
|
||||
|
||||
if let paceStatus {
|
||||
Text(paceStatus.statusText)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundColor(
|
||||
isAchieved ? .appSuccess : (paceStatus.isBehind ? .appWarning : .positiveGreen)
|
||||
)
|
||||
}
|
||||
|
||||
if !isAchieved {
|
||||
projectionRow
|
||||
}
|
||||
}
|
||||
|
||||
GoalProgressBar(
|
||||
progress: progress,
|
||||
tint: isAchieved ? .appSuccess : .appSecondary,
|
||||
iconColor: isAchieved ? .appSuccess : .appSecondary
|
||||
)
|
||||
|
||||
HStack {
|
||||
Text(totalValue.currencyString)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Spacer()
|
||||
Text("of \(goal.targetDecimal.currencyString)")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
if let targetDate = goal.targetDate {
|
||||
Text("Target date: \(targetDate.mediumDateString)")
|
||||
.font(.caption)
|
||||
.foregroundColor(targetDateColor)
|
||||
}
|
||||
|
||||
if let paceStatus {
|
||||
Text(paceStatus.statusText)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundColor(
|
||||
isAchieved ? .appSuccess : (paceStatus.isBehind ? .negativeRed : .positiveGreen)
|
||||
)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(12)
|
||||
.background(isAchieved ? Color.appSuccess.opacity(0.10) : Color.clear)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.stroke(isAchieved ? Color.appSuccess.opacity(0.35) : Color.clear, lineWidth: 1)
|
||||
)
|
||||
.cornerRadius(12)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
@@ -333,10 +338,102 @@ struct GoalRowView: View {
|
||||
currentValue: totalValue,
|
||||
targetValue: goal.targetDecimal,
|
||||
targetDate: goal.targetDate,
|
||||
estimatedCompletionDate: estimatedCompletionDate,
|
||||
estimatedCompletionDate: projection.projectedDate,
|
||||
privacyMode: privacyMode
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Projection UI
|
||||
|
||||
/// Projected completion date, an on-track chip, and a tiny projection sparkline.
|
||||
@ViewBuilder
|
||||
private var projectionRow: some View {
|
||||
switch projection.status {
|
||||
case .unreachable:
|
||||
Label(String(localized: "goal_projection_add_contributions"), systemImage: "plus.circle")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
default:
|
||||
HStack(spacing: 8) {
|
||||
if let date = projection.projectedDate {
|
||||
Text(String(format: String(localized: "goal_projection_projected"), date.monthYearString))
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
onTrackChip
|
||||
Spacer(minLength: 0)
|
||||
if projection.sparkline.count >= 2 {
|
||||
ProjectionSparkline(values: projection.sparkline, tint: chipColor)
|
||||
.frame(width: 48, height: 16)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var chipColor: Color {
|
||||
switch projection.status {
|
||||
case .aheadOfSchedule: return .appSuccess
|
||||
case .onTrack: return .positiveGreen
|
||||
case .behind: return .appWarning
|
||||
case .noTargetDate: return .secondary
|
||||
case .unreachable: return .secondary
|
||||
}
|
||||
}
|
||||
|
||||
private var chipText: String {
|
||||
switch projection.status {
|
||||
case .aheadOfSchedule: return String(localized: "goal_chip_ahead")
|
||||
case .onTrack: return String(localized: "goal_chip_on_track")
|
||||
case .behind: return String(localized: "goal_chip_behind")
|
||||
case .noTargetDate: return String(localized: "goal_chip_at_this_pace")
|
||||
case .unreachable: return ""
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var onTrackChip: some View {
|
||||
if projection.status != .unreachable {
|
||||
Text(chipText)
|
||||
.font(.caption2.weight(.semibold))
|
||||
.foregroundColor(chipColor)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 3)
|
||||
.background(chipColor.opacity(0.15))
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Minimal filled sparkline for the goal value projection curve.
|
||||
private struct ProjectionSparkline: View {
|
||||
let values: [Decimal]
|
||||
let tint: Color
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geo in
|
||||
let points = normalizedPoints(in: geo.size)
|
||||
ZStack {
|
||||
if points.count >= 2 {
|
||||
Path { path in
|
||||
path.move(to: points[0])
|
||||
for p in points.dropFirst() { path.addLine(to: p) }
|
||||
}
|
||||
.stroke(tint, style: StrokeStyle(lineWidth: 1.5, lineCap: .round, lineJoin: .round))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func normalizedPoints(in size: CGSize) -> [CGPoint] {
|
||||
let doubles = values.map { NSDecimalNumber(decimal: $0).doubleValue }
|
||||
guard let minV = doubles.min(), let maxV = doubles.max(), doubles.count >= 2 else { return [] }
|
||||
let range = max(maxV - minV, 0.0001)
|
||||
return doubles.enumerated().map { index, value in
|
||||
let x = size.width * CGFloat(index) / CGFloat(doubles.count - 1)
|
||||
let y = size.height * (1 - CGFloat((value - minV) / range))
|
||||
return CGPoint(x: x, y: y)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
|
||||
@@ -63,6 +63,17 @@ struct JournalView: View {
|
||||
AppBackground()
|
||||
|
||||
List {
|
||||
// Calm 2.0: the habit story (streaks, achievements) lives in
|
||||
// the Journal — this is the emotional home of the ritual.
|
||||
if !viewModel.monthlyNotes.isEmpty && searchText.isEmpty {
|
||||
Section {
|
||||
MomentumStreaksCard(compact: isPad)
|
||||
}
|
||||
.listRowInsets(EdgeInsets())
|
||||
.listRowBackground(Color.clear)
|
||||
.listSectionSeparator(.hidden)
|
||||
}
|
||||
|
||||
Section("Monthly Check-ins") {
|
||||
if filteredMonthlyNotes.isEmpty {
|
||||
if searchText.isEmpty {
|
||||
@@ -183,36 +194,42 @@ struct JournalView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// Timeline-style row: the mood is the visual anchor of each month.
|
||||
private func monthlyNoteRow(_ entry: MonthlyNoteItem) -> some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack {
|
||||
Text(entry.date.monthYearString)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Spacer()
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill((entry.mood?.tint ?? Color.secondary).opacity(0.14))
|
||||
.frame(width: 40, height: 40)
|
||||
Image(systemName: entry.mood?.iconName ?? "book.closed")
|
||||
.font(.system(size: 16))
|
||||
.foregroundColor(entry.mood?.tint ?? .secondary)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack {
|
||||
Text(entry.date.monthYearString)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Spacer()
|
||||
if entry.rating != nil {
|
||||
starRow(rating: entry.rating)
|
||||
.font(.caption2)
|
||||
}
|
||||
}
|
||||
|
||||
if let mood = entry.mood {
|
||||
moodPill(mood)
|
||||
} else {
|
||||
Text("Mood not set")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
Text(mood.title)
|
||||
.font(.caption.weight(.medium))
|
||||
.foregroundColor(mood.tint)
|
||||
}
|
||||
}
|
||||
|
||||
HStack(spacing: 6) {
|
||||
starRow(rating: entry.rating)
|
||||
if entry.rating == nil {
|
||||
Text("No rating")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
Text(entry.note.isEmpty ? String(localized: "journal_no_note") : entry.note)
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
.lineLimit(2)
|
||||
}
|
||||
|
||||
Text(entry.note.isEmpty ? "No note yet." : entry.note)
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
.lineLimit(2)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
.padding(.vertical, 6)
|
||||
}
|
||||
|
||||
private func starRow(rating: Int?) -> some View {
|
||||
@@ -339,6 +356,19 @@ struct MonthlyNoteEditorView: View {
|
||||
}
|
||||
}
|
||||
|
||||
extension MonthlyCheckInMood {
|
||||
/// Semantic tint per mood — the timeline's color language.
|
||||
var tint: Color {
|
||||
switch self {
|
||||
case .energized: return .orange
|
||||
case .confident: return .positiveGreen
|
||||
case .balanced: return .teal
|
||||
case .cautious: return .yellow
|
||||
case .stressed: return .negativeRed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
NavigationStack {
|
||||
JournalView()
|
||||
|
||||
@@ -13,6 +13,12 @@ struct OnboardingView: View {
|
||||
@State private var showingImportSheet = false
|
||||
@State private var showingAddSource = false
|
||||
|
||||
// Forwarded into the ImportDataView sheet — sheets don't inherit
|
||||
// @EnvironmentObject on Mac Catalyst, which crashed the import screen.
|
||||
@EnvironmentObject private var iapService: IAPService
|
||||
@EnvironmentObject private var accountStore: AccountStore
|
||||
@EnvironmentObject private var tabSelection: TabSelectionStore
|
||||
|
||||
private let pages: [OnboardingPage] = [
|
||||
OnboardingPage(
|
||||
icon: "chart.line.uptrend.xyaxis",
|
||||
@@ -123,6 +129,9 @@ struct OnboardingView: View {
|
||||
}
|
||||
.sheet(isPresented: $showingImportSheet) {
|
||||
ImportDataView(importContext: .onboarding)
|
||||
.environmentObject(iapService)
|
||||
.environmentObject(accountStore)
|
||||
.environmentObject(tabSelection)
|
||||
}
|
||||
.sheet(isPresented: $showingAddSource) {
|
||||
AddSourceView()
|
||||
|
||||
@@ -7,75 +7,192 @@ struct WhatsNewFeature {
|
||||
let description: String
|
||||
}
|
||||
|
||||
struct WhatsNewRelease: Identifiable {
|
||||
let version: String
|
||||
let features: [WhatsNewFeature]
|
||||
var id: String { version }
|
||||
}
|
||||
|
||||
// MARK: - Versioned catalog
|
||||
//
|
||||
// One entry per released version, newest first. The sheet shows the CUMULATIVE
|
||||
// diff between the version the user is coming from (lastSeenWhatsNewVersion)
|
||||
// and the one they just installed — jumping 1.3.x → 1.4.2 shows 1.4.0 + 1.4.1
|
||||
// + 1.4.2, not just the last step. ADD AN ENTRY HERE WITH EVERY RELEASE.
|
||||
enum WhatsNewCatalog {
|
||||
static var releases: [WhatsNewRelease] {
|
||||
[
|
||||
WhatsNewRelease(version: "1.5.0", features: [
|
||||
WhatsNewFeature(
|
||||
icon: "sparkles",
|
||||
color: .appPrimary,
|
||||
title: String(localized: "whats_new_150_redesign_title"),
|
||||
description: String(localized: "whats_new_150_redesign_body")
|
||||
),
|
||||
WhatsNewFeature(
|
||||
icon: "figure.mind.and.body",
|
||||
color: .positiveGreen,
|
||||
title: String(localized: "whats_new_150_guided_title"),
|
||||
description: String(localized: "whats_new_150_guided_body")
|
||||
),
|
||||
WhatsNewFeature(
|
||||
icon: "circle.dashed.inset.filled",
|
||||
color: .orange,
|
||||
title: String(localized: "whats_new_150_rings_title"),
|
||||
description: String(localized: "whats_new_150_rings_body")
|
||||
)
|
||||
]),
|
||||
WhatsNewRelease(version: "1.4.2", features: [
|
||||
WhatsNewFeature(
|
||||
icon: "text.viewfinder",
|
||||
color: .appPrimary,
|
||||
title: String(localized: "whats_new_142_ocr_title"),
|
||||
description: String(localized: "whats_new_142_ocr_body")
|
||||
),
|
||||
WhatsNewFeature(
|
||||
icon: "mic.fill",
|
||||
color: .purple,
|
||||
title: String(localized: "whats_new_142_siri_title"),
|
||||
description: String(localized: "whats_new_142_siri_body")
|
||||
),
|
||||
WhatsNewFeature(
|
||||
icon: "chart.xyaxis.line",
|
||||
color: .appSecondary,
|
||||
title: String(localized: "whats_new_142_charts_title"),
|
||||
description: String(localized: "whats_new_142_charts_body")
|
||||
)
|
||||
]),
|
||||
WhatsNewRelease(version: "1.4.1", features: [
|
||||
WhatsNewFeature(
|
||||
icon: "square.and.arrow.up.circle.fill",
|
||||
color: .appPrimary,
|
||||
title: String(localized: "whats_new_141_extension_title"),
|
||||
description: String(localized: "whats_new_141_extension_body")
|
||||
),
|
||||
WhatsNewFeature(
|
||||
icon: "ipad.landscape",
|
||||
color: .appSecondary,
|
||||
title: String(localized: "whats_new_141_ipad_title"),
|
||||
description: String(localized: "whats_new_141_ipad_body")
|
||||
),
|
||||
WhatsNewFeature(
|
||||
icon: "icloud.fill",
|
||||
color: .cyan,
|
||||
title: String(localized: "whats_new_141_icloud_title"),
|
||||
description: String(localized: "whats_new_141_icloud_body")
|
||||
)
|
||||
]),
|
||||
WhatsNewRelease(version: "1.4.0", features: [
|
||||
WhatsNewFeature(
|
||||
icon: "bolt.circle.fill",
|
||||
color: .appPrimary,
|
||||
title: String(localized: "whats_new_quick_update_title"),
|
||||
description: String(localized: "whats_new_quick_update_body")
|
||||
),
|
||||
WhatsNewFeature(
|
||||
icon: "flame.fill",
|
||||
color: .orange,
|
||||
title: String(localized: "whats_new_streak_title"),
|
||||
description: String(localized: "whats_new_streak_body")
|
||||
),
|
||||
WhatsNewFeature(
|
||||
icon: "target",
|
||||
color: .appSecondary,
|
||||
title: String(localized: "whats_new_goals_title"),
|
||||
description: String(localized: "whats_new_goals_body")
|
||||
)
|
||||
])
|
||||
]
|
||||
}
|
||||
|
||||
/// Releases the user hasn't seen: newer than `since`, no newer than `current`.
|
||||
/// Newest first. `since` empty (pre-1.4.0 installs, before the key existed)
|
||||
/// is treated as "show everything in the catalog".
|
||||
static func pendingReleases(since: String, current: String) -> [WhatsNewRelease] {
|
||||
releases.filter { release in
|
||||
(since.isEmpty || compare(release.version, isNewerThan: since))
|
||||
&& !compare(release.version, isNewerThan: current)
|
||||
}
|
||||
}
|
||||
|
||||
/// Numeric semver-style comparison ("1.10.0" > "1.9.0").
|
||||
static func compare(_ a: String, isNewerThan b: String) -> Bool {
|
||||
let aParts = a.split(separator: ".").map { Int($0) ?? 0 }
|
||||
let bParts = b.split(separator: ".").map { Int($0) ?? 0 }
|
||||
for i in 0..<max(aParts.count, bParts.count) {
|
||||
let x = i < aParts.count ? aParts[i] : 0
|
||||
let y = i < bParts.count ? bParts[i] : 0
|
||||
if x != y { return x > y }
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Sheet
|
||||
|
||||
struct WhatsNewView: View {
|
||||
let releases: [WhatsNewRelease]
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
private let features: [WhatsNewFeature] = [
|
||||
WhatsNewFeature(
|
||||
icon: "bolt.circle.fill",
|
||||
color: .appPrimary,
|
||||
title: String(localized: "whats_new_quick_update_title"),
|
||||
description: String(localized: "whats_new_quick_update_body")
|
||||
),
|
||||
WhatsNewFeature(
|
||||
icon: "flame.fill",
|
||||
color: .orange,
|
||||
title: String(localized: "whats_new_streak_title"),
|
||||
description: String(localized: "whats_new_streak_body")
|
||||
),
|
||||
WhatsNewFeature(
|
||||
icon: "target",
|
||||
color: .appSecondary,
|
||||
title: String(localized: "whats_new_goals_title"),
|
||||
description: String(localized: "whats_new_goals_body")
|
||||
)
|
||||
]
|
||||
/// Version headers only add value when the user skipped several releases.
|
||||
private var showsVersionHeaders: Bool { releases.count > 1 }
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
AppBackground()
|
||||
|
||||
VStack(spacing: 0) {
|
||||
Spacer()
|
||||
VStack(spacing: 8) {
|
||||
Text("🎉")
|
||||
.font(.system(size: 48))
|
||||
Text(String(format: String(localized: "whats_new_title_format"), releases.first?.version ?? ""))
|
||||
.font(.title.weight(.bold))
|
||||
Text(String(localized: "whats_new_subtitle"))
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
.padding(.top, 32)
|
||||
.padding(.horizontal)
|
||||
|
||||
VStack(spacing: 32) {
|
||||
VStack(spacing: 8) {
|
||||
Text("🎉")
|
||||
.font(.system(size: 56))
|
||||
Text(String(localized: "whats_new_title"))
|
||||
.font(.title.weight(.bold))
|
||||
Text(String(localized: "whats_new_subtitle"))
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
|
||||
VStack(spacing: 20) {
|
||||
ForEach(features, id: \.title) { feature in
|
||||
HStack(spacing: 16) {
|
||||
Image(systemName: feature.icon)
|
||||
.font(.title2)
|
||||
.foregroundColor(feature.color)
|
||||
.frame(width: 40)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(feature.title)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Text(feature.description)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
ScrollView {
|
||||
VStack(spacing: 16) {
|
||||
ForEach(releases) { release in
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
if showsVersionHeaders {
|
||||
Text(String(format: String(localized: "whats_new_version_header"), release.version))
|
||||
.font(.caption.weight(.bold))
|
||||
.foregroundColor(.appPrimary)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 4)
|
||||
.background(Color.appPrimary.opacity(0.12))
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
ForEach(release.features, id: \.title) { feature in
|
||||
HStack(spacing: 16) {
|
||||
Image(systemName: feature.icon)
|
||||
.font(.title2)
|
||||
.foregroundColor(feature.color)
|
||||
.frame(width: 40)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(feature.title)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Text(feature.description)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(Color(.secondarySystemGroupedBackground))
|
||||
.cornerRadius(16)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.systemBackground))
|
||||
.cornerRadius(16)
|
||||
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
|
||||
}
|
||||
.padding()
|
||||
|
||||
Spacer()
|
||||
|
||||
Button {
|
||||
dismiss()
|
||||
|
||||
@@ -38,6 +38,12 @@ struct ImportDataView: View {
|
||||
@State private var pendingCSVContent: String?
|
||||
@State private var showingCSVMapping = false
|
||||
|
||||
// Import preview (dry-run) confirmation flow
|
||||
@State private var previewSummary: ImportService.ImportPreview?
|
||||
@State private var isPreviewing = false
|
||||
/// Closure that runs the actual (unchanged) import once the user confirms.
|
||||
@State private var confirmedImportAction: (() -> Void)?
|
||||
|
||||
private let accountRepository = AccountRepository()
|
||||
|
||||
private var availableAccounts: [Account] {
|
||||
@@ -68,14 +74,24 @@ struct ImportDataView: View {
|
||||
} label: {
|
||||
Label("Choose File", systemImage: "doc")
|
||||
}
|
||||
.disabled(isImporting)
|
||||
.disabled(isImporting || isPreviewing)
|
||||
|
||||
Button {
|
||||
importFromClipboard()
|
||||
} label: {
|
||||
Label("Paste from Clipboard", systemImage: "doc.on.clipboard")
|
||||
}
|
||||
.disabled(isImporting)
|
||||
.disabled(isImporting || isPreviewing)
|
||||
|
||||
if isPreviewing {
|
||||
HStack(spacing: 8) {
|
||||
ProgressView()
|
||||
Text("Reviewing import…")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.padding(.top, 8)
|
||||
}
|
||||
|
||||
if isImporting {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
@@ -160,6 +176,33 @@ struct ImportDataView: View {
|
||||
} message: {
|
||||
Text(errorMessage ?? "")
|
||||
}
|
||||
.alert(
|
||||
"Review Import",
|
||||
isPresented: Binding(
|
||||
get: { previewSummary != nil },
|
||||
set: { if !$0 { previewSummary = nil; confirmedImportAction = nil } }
|
||||
)
|
||||
) {
|
||||
if previewSummary?.hasAnything == true {
|
||||
Button("Import") {
|
||||
let action = confirmedImportAction
|
||||
previewSummary = nil
|
||||
confirmedImportAction = nil
|
||||
action?()
|
||||
}
|
||||
Button("Cancel", role: .cancel) {
|
||||
previewSummary = nil
|
||||
confirmedImportAction = nil
|
||||
}
|
||||
} else {
|
||||
Button("OK") {
|
||||
previewSummary = nil
|
||||
confirmedImportAction = nil
|
||||
}
|
||||
}
|
||||
} message: {
|
||||
Text(previewSummary.map(previewMessage) ?? "")
|
||||
}
|
||||
.onAppear {
|
||||
// Ensure selectedAccountId is valid and exists in availableAccounts
|
||||
let validIds = Set(availableAccounts.compactMap { $0.safeId })
|
||||
@@ -420,12 +463,45 @@ Personal,Stocks,Index Fund,2024-01-01,15000,12000,Long-term
|
||||
return
|
||||
}
|
||||
|
||||
// JSON → import directly
|
||||
runImport(content: content, format: .json, defaultAccountName: resolvedAccountName())
|
||||
// JSON → dry-run preview first, then import on confirm.
|
||||
let accountName = resolvedAccountName()
|
||||
previewThenConfirm(
|
||||
dryRun: {
|
||||
await ImportService.shared.previewImportAsync(
|
||||
content: content,
|
||||
format: .json,
|
||||
allowMultipleAccounts: false,
|
||||
defaultAccountName: accountName
|
||||
)
|
||||
},
|
||||
onConfirm: {
|
||||
self.runImport(content: content, format: .json, defaultAccountName: accountName)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private func performMappedCSVImport(content: String, mapping: ImportService.CSVMappingConfig) {
|
||||
let defaultAccountName = resolvedAccountName()
|
||||
// Dry-run preview first, then run the actual (unchanged) import on confirm.
|
||||
previewThenConfirm(
|
||||
dryRun: {
|
||||
await ImportService.shared.previewCSVWithMappingAsync(
|
||||
content: content,
|
||||
mapping: mapping,
|
||||
defaultAccountName: defaultAccountName
|
||||
)
|
||||
},
|
||||
onConfirm: {
|
||||
self.runMappedCSVImport(content: content, mapping: mapping, defaultAccountName: defaultAccountName)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private func runMappedCSVImport(
|
||||
content: String,
|
||||
mapping: ImportService.CSVMappingConfig,
|
||||
defaultAccountName: String
|
||||
) {
|
||||
isImporting = true
|
||||
importProgress = 0
|
||||
importStatus = "Parsing file"
|
||||
@@ -443,6 +519,51 @@ Personal,Stocks,Index Fund,2024-01-01,15000,12000,Long-term
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs a read-only dry-run, then presents a confirmation with the counts.
|
||||
/// Confirming triggers `onConfirm` (the existing, unchanged import path);
|
||||
/// cancelling does nothing.
|
||||
private func previewThenConfirm(
|
||||
dryRun: @escaping () async -> ImportService.ImportPreview,
|
||||
onConfirm: @escaping () -> Void
|
||||
) {
|
||||
isPreviewing = true
|
||||
Task {
|
||||
let preview = await dryRun()
|
||||
isPreviewing = false
|
||||
confirmedImportAction = onConfirm
|
||||
previewSummary = preview
|
||||
}
|
||||
}
|
||||
|
||||
private func previewMessage(_ preview: ImportService.ImportPreview) -> String {
|
||||
guard preview.hasAnything else {
|
||||
return String(localized: "import_preview_nothing")
|
||||
}
|
||||
var parts: [String] = []
|
||||
if preview.sourcesToCreate > 0 {
|
||||
parts.append(String(format: String(localized: "import_preview_sources"), preview.sourcesToCreate))
|
||||
}
|
||||
if preview.snapshotsToCreate > 0 {
|
||||
parts.append(String(format: String(localized: "import_preview_snapshots_new"), preview.snapshotsToCreate))
|
||||
}
|
||||
if preview.snapshotsToUpdate > 0 {
|
||||
parts.append(String(format: String(localized: "import_preview_snapshots_update"), preview.snapshotsToUpdate))
|
||||
}
|
||||
if preview.categoriesToCreate > 0 {
|
||||
parts.append(String(format: String(localized: "import_preview_categories"), preview.categoriesToCreate))
|
||||
}
|
||||
if preview.goalsToCreate > 0 {
|
||||
parts.append(String(format: String(localized: "import_preview_goals"), preview.goalsToCreate))
|
||||
}
|
||||
if preview.journalToCreate > 0 {
|
||||
parts.append(String(format: String(localized: "import_preview_journal_new"), preview.journalToCreate))
|
||||
}
|
||||
if preview.journalToUpdate > 0 {
|
||||
parts.append(String(format: String(localized: "import_preview_journal_update"), preview.journalToUpdate))
|
||||
}
|
||||
return parts.joined(separator: "\n")
|
||||
}
|
||||
|
||||
private func runImport(content: String, format: ImportService.ImportFormat, defaultAccountName: String) {
|
||||
isImporting = true
|
||||
importProgress = 0
|
||||
|
||||
@@ -6,7 +6,10 @@ struct SettingsView: View {
|
||||
private let iapService: IAPService
|
||||
@StateObject private var viewModel: SettingsViewModel
|
||||
@EnvironmentObject private var adMobService: AdMobService
|
||||
@EnvironmentObject private var accountStore: AccountStore
|
||||
@EnvironmentObject private var tabSelection: TabSelectionStore
|
||||
@AppStorage("showForecast") private var showForecast = true
|
||||
@AppStorage("smoothChartGaps") private var smoothChartGaps = true
|
||||
@AppStorage("cloudSyncEnabled") private var cloudSyncEnabled = false
|
||||
@AppStorage("faceIdEnabled") private var faceIdEnabled = false
|
||||
@AppStorage("pinEnabled") private var pinEnabled = false
|
||||
@@ -15,6 +18,7 @@ struct SettingsView: View {
|
||||
|
||||
@ObservedObject private var cloudStack = CoreDataStack.shared
|
||||
@ObservedObject private var updateService = AppUpdateService.shared
|
||||
@ObservedObject private var balancePrivacy = BalancePrivacyManager.shared
|
||||
|
||||
@State private var showingPinSetup = false
|
||||
@State private var showingPinChange = false
|
||||
@@ -55,6 +59,9 @@ struct SettingsView: View {
|
||||
backupsSection
|
||||
}
|
||||
|
||||
// Privacy Section
|
||||
privacySection
|
||||
|
||||
// Security Section
|
||||
securitySection
|
||||
|
||||
@@ -86,7 +93,11 @@ struct SettingsView: View {
|
||||
ActivityView(activityItems: [shareItem.url])
|
||||
}
|
||||
.sheet(isPresented: $viewModel.showingImportSheet) {
|
||||
// Sheets don't inherit @EnvironmentObject on Mac Catalyst → re-inject.
|
||||
ImportDataView()
|
||||
.environmentObject(iapService)
|
||||
.environmentObject(accountStore)
|
||||
.environmentObject(tabSelection)
|
||||
}
|
||||
.confirmationDialog(
|
||||
"Reset All Data",
|
||||
@@ -425,20 +436,57 @@ struct SettingsView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// CloudKit error (if any)
|
||||
// CloudKit status: only alarm (red) when sync is genuinely
|
||||
// broken. A partial failure while data is syncing is shown as
|
||||
// a calm "still syncing" note, not a scary error.
|
||||
if let error = cloudStack.lastSyncError {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Label("CloudKit error", systemImage: "exclamationmark.icloud")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundColor(.red)
|
||||
Text(error)
|
||||
.font(.caption2)
|
||||
.foregroundColor(.red)
|
||||
.lineLimit(6)
|
||||
.textSelection(.enabled)
|
||||
let critical = cloudStack.syncErrorIsCritical
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Label(
|
||||
critical
|
||||
? String(localized: "icloud_status_error")
|
||||
: String(localized: "icloud_status_syncing"),
|
||||
systemImage: critical ? "exclamationmark.icloud" : "arrow.triangle.2.circlepath.icloud"
|
||||
)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundColor(critical ? .red : .secondary)
|
||||
|
||||
if critical, let hint = cloudStack.lastSyncErrorHint {
|
||||
Text(hint)
|
||||
.font(.caption)
|
||||
.foregroundColor(.primary)
|
||||
}
|
||||
|
||||
// Technical detail is opt-in behind a disclosure, so it
|
||||
// never dominates the screen for a benign partial sync.
|
||||
DisclosureGroup(String(localized: "icloud_show_details")) {
|
||||
if let hint = cloudStack.lastSyncErrorHint, !critical {
|
||||
Text(hint)
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
Text(error)
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
.font(.caption2)
|
||||
.tint(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
// Always-available diagnostics: copies status + last error (with
|
||||
// the inner CloudKit codes) so it can be shared for support.
|
||||
Button {
|
||||
UIPasteboard.general.string = iCloudDiagnostics()
|
||||
forceUploadResult = String(localized: "icloud_diagnostics_copied")
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 4) { forceUploadResult = nil }
|
||||
} label: {
|
||||
Label(String(localized: "icloud_copy_diagnostics"), systemImage: "doc.on.doc")
|
||||
.font(.caption)
|
||||
.foregroundColor(.appPrimary)
|
||||
}
|
||||
|
||||
// Force upload — use when data exists locally but hasn't reached iCloud
|
||||
if cloudStack.localSourceCount > 0 {
|
||||
if let result = forceUploadResult {
|
||||
@@ -490,6 +538,27 @@ struct SettingsView: View {
|
||||
}
|
||||
}
|
||||
|
||||
if viewModel.backupsEnabled {
|
||||
Picker("Auto Backup", selection: Binding(
|
||||
get: { viewModel.autoBackupFrequencyDays },
|
||||
set: { viewModel.updateAutoBackupFrequency($0) }
|
||||
)) {
|
||||
Text("Off").tag(0)
|
||||
Text("Daily").tag(1)
|
||||
Text("Weekly").tag(7)
|
||||
Text("Monthly").tag(30)
|
||||
}
|
||||
if let last = viewModel.lastAutoBackupDate {
|
||||
HStack {
|
||||
Text("Last auto backup")
|
||||
Spacer()
|
||||
Text(last, style: .date)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
|
||||
NavigationLink {
|
||||
CategoriesView()
|
||||
} label: {
|
||||
@@ -612,6 +681,24 @@ struct SettingsView: View {
|
||||
|
||||
// MARK: - Security Section
|
||||
|
||||
private var privacySection: some View {
|
||||
Section {
|
||||
Toggle("Hide Balances", isOn: $balancePrivacy.balancesHidden)
|
||||
|
||||
Toggle("Require Face ID to Reveal", isOn: $balancePrivacy.requireBiometricToReveal)
|
||||
.onChange(of: balancePrivacy.requireBiometricToReveal) { _, enabled in
|
||||
if enabled && !AppLockService.canUseBiometrics() {
|
||||
balancePrivacy.requireBiometricToReveal = false
|
||||
showingBiometricAlert = true
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text("Privacy")
|
||||
} footer: {
|
||||
Text("Hide all monetary amounts behind a blur. Great for using the app in public or taking screenshots. Optionally require Face ID to reveal them.")
|
||||
}
|
||||
}
|
||||
|
||||
private var securitySection: some View {
|
||||
Section {
|
||||
Toggle("Require PIN", isOn: $pinEnabled)
|
||||
@@ -706,6 +793,7 @@ struct SettingsView: View {
|
||||
private var longTermSection: some View {
|
||||
Section {
|
||||
Toggle("Show Forecast", isOn: $showForecast)
|
||||
Toggle("Smooth Gaps in Charts", isOn: $smoothChartGaps)
|
||||
|
||||
NavigationLink {
|
||||
AllocationTargetsView()
|
||||
@@ -849,6 +937,26 @@ struct SettingsView: View {
|
||||
formatter.countStyle = .file
|
||||
return formatter.string(fromByteCount: bytes)
|
||||
}
|
||||
|
||||
/// Copyable iCloud diagnostics snapshot for support/debugging.
|
||||
private func iCloudDiagnostics() -> String {
|
||||
let df = ISO8601DateFormatter()
|
||||
var lines: [String] = ["Portfolio Journal — iCloud diagnostics"]
|
||||
lines.append("app: \(Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "?") (\(Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "?"))")
|
||||
lines.append("local: \(cloudStack.localSourceCount) sources · \(cloudStack.localSnapshotCount) snapshots")
|
||||
lines.append("lastImport: \(cloudStack.lastImportDate.map { df.string(from: $0) } ?? "never")")
|
||||
lines.append("lastExport: \(cloudStack.lastExportDate.map { df.string(from: $0) } ?? "never")")
|
||||
lines.append("critical: \(cloudStack.syncErrorIsCritical)")
|
||||
lines.append("hint: \(cloudStack.lastSyncErrorHint ?? "—")")
|
||||
lines.append("error: \(cloudStack.lastSyncError ?? "none this session")")
|
||||
lines.append("")
|
||||
lines.append("— integrity —")
|
||||
lines.append(cloudStack.integrityReport())
|
||||
lines.append("")
|
||||
lines.append("— error detail —")
|
||||
lines.append(cloudStack.lastSyncErrorDetail ?? "none this session")
|
||||
return lines.joined(separator: "\n")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Activity View
|
||||
|
||||
@@ -11,6 +11,9 @@ struct AddSourceView: View {
|
||||
@State private var showingCategoryPicker = false
|
||||
@State private var selectedAccountId: UUID?
|
||||
@State private var duplicateError: String?
|
||||
// Set when the source was saved without a value — routes the user straight
|
||||
// into their first check-in instead of dropping them on an empty dashboard.
|
||||
@State private var pendingSnapshotSource: InvestmentSource?
|
||||
|
||||
@StateObject private var categoryRepository = CategoryRepository()
|
||||
@StateObject private var sourceRepository = InvestmentSourceRepository()
|
||||
@@ -83,7 +86,8 @@ struct AddSourceView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// Initial Value (Optional)
|
||||
// Today's value — becomes the first snapshot. Framed as the default
|
||||
// (not "optional"): most users who skipped it never recorded a check-in.
|
||||
Section {
|
||||
HStack {
|
||||
Text(currencySymbol)
|
||||
@@ -93,9 +97,9 @@ struct AddSourceView: View {
|
||||
.keyboardType(.decimalPad)
|
||||
}
|
||||
} header: {
|
||||
Text("Initial Value (Optional)")
|
||||
Text(String(localized: "add_source_value_header"))
|
||||
} footer: {
|
||||
Text("You can add snapshots later if you prefer.")
|
||||
Text(String(localized: "add_source_value_footer"))
|
||||
}
|
||||
}
|
||||
.navigationTitle("Add Source")
|
||||
@@ -121,6 +125,9 @@ struct AddSourceView: View {
|
||||
categories: categoryRepository.categories
|
||||
)
|
||||
}
|
||||
.sheet(item: $pendingSnapshotSource, onDismiss: { dismiss() }) { source in
|
||||
AddSnapshotView(source: source)
|
||||
}
|
||||
.onAppear {
|
||||
categoryRepository.createDefaultCategoriesIfNeeded()
|
||||
if selectedCategory == nil {
|
||||
@@ -166,6 +173,7 @@ struct AddSourceView: View {
|
||||
)
|
||||
|
||||
// Add initial snapshot if provided
|
||||
let hasInitialSnapshot: Bool
|
||||
if let value = parseDecimal(initialValue), value > 0 {
|
||||
let snapshotRepository = SnapshotRepository()
|
||||
snapshotRepository.createSnapshot(
|
||||
@@ -173,6 +181,9 @@ struct AddSourceView: View {
|
||||
date: Date(),
|
||||
value: value
|
||||
)
|
||||
hasInitialSnapshot = true
|
||||
} else {
|
||||
hasInitialSnapshot = false
|
||||
}
|
||||
|
||||
// Schedule notification
|
||||
@@ -190,7 +201,13 @@ struct AddSourceView: View {
|
||||
sourceCount: repository.sourceCount
|
||||
)
|
||||
|
||||
dismiss()
|
||||
if hasInitialSnapshot {
|
||||
dismiss()
|
||||
} else {
|
||||
// No value entered: open the first check-in right away so the user
|
||||
// reaches the moment of value instead of an empty dashboard.
|
||||
pendingSnapshotSource = source
|
||||
}
|
||||
}
|
||||
|
||||
private func parseDecimal(_ string: String) -> Decimal? {
|
||||
@@ -400,13 +417,13 @@ struct AddSnapshotView: View {
|
||||
@State private var showingPropagationDialog = false
|
||||
@State private var pendingContribution: Decimal?
|
||||
|
||||
init(source: InvestmentSource, snapshot: Snapshot? = nil) {
|
||||
init(source: InvestmentSource, snapshot: Snapshot? = nil, initialDate: Date? = nil) {
|
||||
self.source = source
|
||||
self.snapshot = snapshot
|
||||
if let snapshot = snapshot {
|
||||
_viewModel = StateObject(wrappedValue: SnapshotFormViewModel(source: source, mode: .edit(snapshot)))
|
||||
} else {
|
||||
_viewModel = StateObject(wrappedValue: SnapshotFormViewModel(source: source))
|
||||
_viewModel = StateObject(wrappedValue: SnapshotFormViewModel(source: source, initialDate: initialDate))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -138,6 +138,7 @@ struct SourceDetailView: View {
|
||||
// Current value
|
||||
Text(viewModel.formattedCurrentValue)
|
||||
.font(.system(size: 36, weight: .bold, design: .rounded))
|
||||
.hiddenBalance()
|
||||
|
||||
// Return
|
||||
HStack(spacing: 4) {
|
||||
@@ -517,11 +518,13 @@ struct SnapshotRowView: View {
|
||||
VStack(alignment: .trailing, spacing: 2) {
|
||||
Text(snapshot.formattedValue)
|
||||
.font(.subheadline.weight(.medium))
|
||||
.hiddenBalance()
|
||||
|
||||
if snapshot.contribution != nil && snapshot.decimalContribution > 0 {
|
||||
Text("+ \(snapshot.decimalContribution.currencyString)")
|
||||
.font(.caption)
|
||||
.foregroundColor(.appPrimary)
|
||||
.hiddenBalance()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -213,6 +213,7 @@ struct SourceListView: View {
|
||||
.foregroundColor(.secondary)
|
||||
Text(viewModel.formattedTotalValue)
|
||||
.font(.title2.weight(.bold))
|
||||
.hiddenBalance()
|
||||
}
|
||||
|
||||
Spacer()
|
||||
@@ -503,9 +504,13 @@ struct SourceRowView: View {
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
if source.needsUpdate {
|
||||
Label("Needs update", systemImage: "bell.badge.fill")
|
||||
.font(.caption2)
|
||||
Label("Needs update", systemImage: "clock.fill")
|
||||
.font(.caption2.weight(.semibold))
|
||||
.foregroundColor(.appWarning)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 3)
|
||||
.background(Color.appWarning.opacity(0.12))
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -515,7 +520,8 @@ struct SourceRowView: View {
|
||||
// Value
|
||||
VStack(alignment: .trailing, spacing: 4) {
|
||||
Text(source.latestValue.compactCurrencyString)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.font(.system(.subheadline, design: .rounded).weight(.semibold))
|
||||
.hiddenBalance()
|
||||
|
||||
HStack(spacing: 2) {
|
||||
Image(systemName: source.totalReturn >= 0 ? "arrow.up.right" : "arrow.down.right")
|
||||
|
||||
@@ -309,6 +309,83 @@ final class PortfolioJournalUITests: XCTestCase {
|
||||
add(paywallShot)
|
||||
}
|
||||
|
||||
/// Calm 2.0 Fase 2: walks the guided check-in end to end — enter a value on
|
||||
/// the first source (live delta), skip the rest, pick a mood, finish, and
|
||||
/// verify the closing summary appears. `--pending-checkin` puts the seeded
|
||||
/// data back into a "month not done" state.
|
||||
func testGuidedCheckInFlow() throws {
|
||||
XCUIDevice.shared.orientation = .portrait
|
||||
let capture = XCUIApplication()
|
||||
capture.launchArguments = ["--screenshots", "--pending-checkin"]
|
||||
capture.launch()
|
||||
Thread.sleep(forTimeInterval: 4.0)
|
||||
|
||||
let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard")
|
||||
let denyButton = springboard.alerts.buttons["Don't Allow"]
|
||||
if denyButton.waitForExistence(timeout: 3) {
|
||||
denyButton.tap()
|
||||
Thread.sleep(forTimeInterval: 1.0)
|
||||
}
|
||||
|
||||
func snap(_ name: String) {
|
||||
let shot = XCTAttachment(screenshot: capture.screenshot())
|
||||
shot.name = name
|
||||
shot.lifetime = .keepAlways
|
||||
add(shot)
|
||||
}
|
||||
|
||||
snap("guided_00_home_pending")
|
||||
|
||||
let cta = capture.buttons["checkin_start_cta"].firstMatch
|
||||
XCTAssertTrue(cta.waitForExistence(timeout: 5), "Pending check-in CTA not found on Home")
|
||||
cta.tap()
|
||||
Thread.sleep(forTimeInterval: 1.5)
|
||||
|
||||
// First source: type a value, verify Next enables, advance
|
||||
let valueField = capture.textFields["guided_value_field"].firstMatch
|
||||
XCTAssertTrue(valueField.waitForExistence(timeout: 5), "Guided flow value field not found")
|
||||
valueField.tap()
|
||||
valueField.typeText("12345")
|
||||
Thread.sleep(forTimeInterval: 0.5)
|
||||
snap("guided_01_source_value")
|
||||
|
||||
let next = capture.buttons["guided_next_btn"].firstMatch
|
||||
XCTAssertTrue(next.isEnabled, "Next should enable after typing a value")
|
||||
next.tap()
|
||||
Thread.sleep(forTimeInterval: 0.8)
|
||||
|
||||
// Skip the remaining sources until the reflection step appears
|
||||
let finish = capture.buttons["guided_finish_btn"].firstMatch
|
||||
var hops = 0
|
||||
while !finish.exists && hops < 20 {
|
||||
let skip = capture.buttons["guided_skip_btn"].firstMatch
|
||||
guard skip.waitForExistence(timeout: 2) else { break }
|
||||
skip.tap()
|
||||
Thread.sleep(forTimeInterval: 0.6)
|
||||
hops += 1
|
||||
}
|
||||
XCTAssertTrue(finish.waitForExistence(timeout: 5), "Reflection step not reached")
|
||||
snap("guided_02_reflection")
|
||||
|
||||
// Pick a mood and finish
|
||||
let mood = capture.otherElements["guided_mood_balanced"].firstMatch
|
||||
if mood.exists {
|
||||
mood.tap()
|
||||
} else if capture.buttons["guided_mood_balanced"].firstMatch.exists {
|
||||
capture.buttons["guided_mood_balanced"].firstMatch.tap()
|
||||
}
|
||||
Thread.sleep(forTimeInterval: 0.5)
|
||||
finish.tap()
|
||||
Thread.sleep(forTimeInterval: 2.0)
|
||||
snap("guided_03_done")
|
||||
|
||||
XCTAssertTrue(
|
||||
capture.staticTexts.matching(NSPredicate(format: "label CONTAINS[c] 'check-in done'")).firstMatch
|
||||
.waitForExistence(timeout: 5),
|
||||
"Closing summary did not appear after finishing the check-in"
|
||||
)
|
||||
}
|
||||
|
||||
/// Captures full-screen screenshots of the main tabs with demo data for App Store
|
||||
/// marketing. Launches the app in `--screenshots` mode (onboarding/lock skipped,
|
||||
/// SampleDataService seeded). Language can be driven via the SCREENSHOT_LANG env var
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import WidgetKit
|
||||
import SwiftUI
|
||||
import CoreData
|
||||
import AppIntents
|
||||
|
||||
private let appGroupIdentifier = "group.com.alexandrevazquez.portfoliojournal"
|
||||
private let storeFileName = "PortfolioJournal.sqlite"
|
||||
@@ -76,6 +77,26 @@ struct InvestmentWidgetEntry: TimelineEntry {
|
||||
let currencyCode: String
|
||||
let insightTitle: String
|
||||
let insightValue: String
|
||||
|
||||
// Engagement + progress metrics (v1.6.0)
|
||||
let streak: Int
|
||||
let nextCheckInDate: Date?
|
||||
/// Nearest active goal (soonest target date, else smallest target) if any.
|
||||
let nearestGoal: GoalSummary?
|
||||
/// Progress [0...1] of totalValue toward the nearest goal's target.
|
||||
let nearestGoalProgress: Double
|
||||
/// Fraction [0...1] toward the next net-worth milestone (fallback gauge when no goal).
|
||||
let milestoneProgress: Double
|
||||
/// The next milestone amount used for the fallback gauge (0 when none).
|
||||
let nextMilestone: Decimal
|
||||
|
||||
/// True when the store has no usable portfolio data yet.
|
||||
var hasData: Bool { totalValue != 0 || !trendPoints.isEmpty || !topSources.isEmpty }
|
||||
|
||||
/// A gauge value [0...1]: nearest-goal progress if a goal exists, else milestone progress.
|
||||
var primaryGaugeProgress: Double {
|
||||
nearestGoal != nil ? nearestGoalProgress : milestoneProgress
|
||||
}
|
||||
}
|
||||
|
||||
struct CategorySeries: Identifiable {
|
||||
@@ -143,7 +164,13 @@ struct InvestmentWidgetProvider: TimelineProvider {
|
||||
],
|
||||
currencyCode: "EUR",
|
||||
insightTitle: "Milestone ahead",
|
||||
insightValue: "€5K from €50K"
|
||||
insightValue: "€5K from €50K",
|
||||
streak: 4,
|
||||
nextCheckInDate: Calendar.current.date(byAdding: .day, value: 6, to: Date()),
|
||||
nearestGoal: GoalSummary(name: "Target", targetAmount: 75000, targetDate: nil),
|
||||
nearestGoalProgress: 0.66,
|
||||
milestoneProgress: 0.5,
|
||||
nextMilestone: 100000
|
||||
)
|
||||
}
|
||||
|
||||
@@ -182,7 +209,13 @@ struct InvestmentWidgetProvider: TimelineProvider {
|
||||
goals: [],
|
||||
currencyCode: currencyCode,
|
||||
insightTitle: "",
|
||||
insightValue: ""
|
||||
insightValue: "",
|
||||
streak: 0,
|
||||
nextCheckInDate: nil,
|
||||
nearestGoal: nil,
|
||||
nearestGoalProgress: 0,
|
||||
milestoneProgress: 0,
|
||||
nextMilestone: 0
|
||||
)
|
||||
}
|
||||
|
||||
@@ -368,6 +401,84 @@ struct InvestmentWidgetProvider: TimelineProvider {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Streak — consecutive months (year+month) that have snapshot data,
|
||||
// counting back from the most recent. Mirrors DashboardViewModel.computeStreak.
|
||||
let streak: Int = {
|
||||
let monthDates = Set(sortedTotals.map { pair -> DateComponents in
|
||||
let c = calendar.dateComponents([.year, .month], from: pair.0)
|
||||
return DateComponents(year: c.year, month: c.month)
|
||||
})
|
||||
let sortedMonthsDesc = monthDates
|
||||
.compactMap { calendar.date(from: $0) }
|
||||
.sorted(by: >)
|
||||
guard let mostRecent = sortedMonthsDesc.first else { return 0 }
|
||||
var count = 1
|
||||
var current = mostRecent
|
||||
for i in 1..<max(sortedMonthsDesc.count, 1) {
|
||||
guard i < sortedMonthsDesc.count,
|
||||
let expected = calendar.date(byAdding: .month, value: -1, to: current) else { break }
|
||||
let prev = sortedMonthsDesc[i]
|
||||
let pc = calendar.dateComponents([.year, .month], from: prev)
|
||||
let ec = calendar.dateComponents([.year, .month], from: expected)
|
||||
if pc.year == ec.year && pc.month == ec.month {
|
||||
count += 1
|
||||
current = prev
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
return count
|
||||
}()
|
||||
|
||||
// MARK: Next check-in date — from the most recent completed JournalEntry.
|
||||
// Mirrors DashboardView.nextCheckInDate (common case, without grace-period edge).
|
||||
let nextCheckInDate: Date? = {
|
||||
let journalRequest = NSFetchRequest<NSManagedObject>(entityName: "JournalEntry")
|
||||
journalRequest.predicate = NSPredicate(format: "completionTime != nil")
|
||||
journalRequest.sortDescriptors = [NSSortDescriptor(key: "completionTime", ascending: false)]
|
||||
journalRequest.fetchLimit = 1
|
||||
let lastCompletion = (try? context.fetch(journalRequest))?.first?
|
||||
.value(forKey: "completionTime") as? Date
|
||||
let base = lastCompletion ?? Date()
|
||||
let startOfMonth = calendar.date(from: calendar.dateComponents([.year, .month], from: base)) ?? base
|
||||
// For a prior check-in, next is the end of the following month; otherwise
|
||||
// the end of the current month.
|
||||
let targetMonthStart: Date
|
||||
if lastCompletion != nil {
|
||||
targetMonthStart = calendar.date(byAdding: .month, value: 1, to: startOfMonth) ?? startOfMonth
|
||||
} else {
|
||||
targetMonthStart = startOfMonth
|
||||
}
|
||||
guard let nextMonthStart = calendar.date(byAdding: .month, value: 1, to: targetMonthStart) else {
|
||||
return targetMonthStart
|
||||
}
|
||||
return calendar.date(byAdding: .day, value: -1, to: nextMonthStart)
|
||||
}()
|
||||
|
||||
// MARK: Nearest goal + progress toward target from current total value.
|
||||
let nearestGoal = goals.first
|
||||
var nearestGoalProgress = 0.0
|
||||
if let goal = nearestGoal {
|
||||
let target = NSDecimalNumber(decimal: goal.targetAmount).doubleValue
|
||||
if target > 0 {
|
||||
nearestGoalProgress = min(max(NSDecimalNumber(decimal: totalValue).doubleValue / target, 0), 1)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Milestone gauge (fallback when there's no goal).
|
||||
let milestoneLadder: [Double] = [1000, 2500, 5000, 10000, 25000, 50000, 100000, 250000, 500000, 1_000_000, 2_500_000, 5_000_000]
|
||||
let totalForMilestone = NSDecimalNumber(decimal: totalValue).doubleValue
|
||||
var milestoneProgress = 0.0
|
||||
var nextMilestone: Decimal = 0
|
||||
if let nextMs = milestoneLadder.first(where: { $0 > totalForMilestone }) {
|
||||
let prevMs = milestoneLadder.last(where: { $0 <= totalForMilestone }) ?? 0
|
||||
let span = nextMs - prevMs
|
||||
milestoneProgress = span > 0 ? min(max((totalForMilestone - prevMs) / span, 0), 1) : 0
|
||||
nextMilestone = Decimal(nextMs)
|
||||
} else if totalForMilestone > 0 {
|
||||
milestoneProgress = 1
|
||||
}
|
||||
|
||||
// Compute top insight
|
||||
var insightTitle = ""
|
||||
var insightValue = ""
|
||||
@@ -417,77 +528,251 @@ struct InvestmentWidgetProvider: TimelineProvider {
|
||||
goals: goals,
|
||||
currencyCode: currencyCode,
|
||||
insightTitle: insightTitle,
|
||||
insightValue: insightValue
|
||||
insightValue: insightValue,
|
||||
streak: streak,
|
||||
nextCheckInDate: nextCheckInDate,
|
||||
nearestGoal: nearestGoal,
|
||||
nearestGoalProgress: nearestGoalProgress,
|
||||
milestoneProgress: milestoneProgress,
|
||||
nextMilestone: nextMilestone
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Shared Formatting Helpers
|
||||
|
||||
private func checkInText(for date: Date?) -> String {
|
||||
guard let date = date else { return "—" }
|
||||
let cal = Calendar.current
|
||||
let days = cal.dateComponents([.day], from: Date().startOfWidgetDay, to: date.startOfWidgetDay).day ?? 0
|
||||
if days < 0 { return "Overdue" }
|
||||
if days == 0 { return "Due today" }
|
||||
if days == 1 { return "Due tomorrow" }
|
||||
if days <= 21 { return "In \(days)d" }
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "MMM d"
|
||||
return formatter.string(from: date)
|
||||
}
|
||||
|
||||
private func checkInIsUrgent(_ date: Date?) -> Bool {
|
||||
guard let date = date else { return false }
|
||||
let cal = Calendar.current
|
||||
let days = cal.dateComponents([.day], from: Date().startOfWidgetDay, to: date.startOfWidgetDay).day ?? 0
|
||||
return days <= 3
|
||||
}
|
||||
|
||||
private extension Date {
|
||||
var startOfWidgetDay: Date { Calendar.current.startOfDay(for: self) }
|
||||
}
|
||||
|
||||
// MARK: - Change Line (shared)
|
||||
|
||||
struct ChangeLine: View {
|
||||
let entry: InvestmentWidgetEntry
|
||||
var compact: Bool = false
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: entry.dayChange >= 0 ? "arrow.up.right" : "arrow.down.right")
|
||||
.font(.caption2)
|
||||
Text(entry.dayChange.compactCurrencyString(currencyCode: entry.currencyCode))
|
||||
.font(.caption.weight(.medium))
|
||||
Text("(\(String(format: "%.1f%%", entry.dayChangePercentage)))")
|
||||
.font(.caption2)
|
||||
.foregroundColor(compact ? (entry.dayChange >= 0 ? .green : .red) : .secondary)
|
||||
}
|
||||
.foregroundColor(entry.dayChange >= 0 ? .green : .red)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.8)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Metric Pills (shared)
|
||||
|
||||
struct StreakPill: View {
|
||||
let streak: Int
|
||||
var body: some View {
|
||||
HStack(spacing: 3) {
|
||||
Image(systemName: "flame.fill")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.orange)
|
||||
Text("\(streak) mo")
|
||||
.font(.caption2.weight(.semibold))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct CheckInPill: View {
|
||||
let date: Date?
|
||||
var body: some View {
|
||||
HStack(spacing: 3) {
|
||||
Image(systemName: "calendar.badge.clock")
|
||||
.font(.caption2)
|
||||
.foregroundColor(checkInIsUrgent(date) ? .orange : .secondary)
|
||||
Text(checkInText(for: date))
|
||||
.font(.caption2.weight(.medium))
|
||||
.foregroundColor(checkInIsUrgent(date) ? .orange : .secondary)
|
||||
}
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Goal Progress Row (shared)
|
||||
|
||||
struct GoalProgressRow: View {
|
||||
let entry: InvestmentWidgetEntry
|
||||
|
||||
var body: some View {
|
||||
if let goal = entry.nearestGoal {
|
||||
let pct = entry.nearestGoalProgress
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack {
|
||||
Image(systemName: "target")
|
||||
.font(.caption2)
|
||||
.foregroundColor(widgetSecondaryColor)
|
||||
Text(goal.name)
|
||||
.font(.caption.weight(.semibold))
|
||||
.lineLimit(1)
|
||||
Spacer()
|
||||
Text("\(Int((pct * 100).rounded()))%")
|
||||
.font(.caption.weight(.bold))
|
||||
.foregroundColor(widgetSecondaryColor)
|
||||
}
|
||||
ProgressView(value: pct)
|
||||
.tint(widgetSecondaryColor)
|
||||
HStack {
|
||||
Text(entry.totalValue.compactCurrencyString(currencyCode: entry.currencyCode))
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
Spacer()
|
||||
Text(goal.targetAmount.compactCurrencyString(currencyCode: entry.currencyCode))
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
} else if entry.nextMilestone > 0 {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack {
|
||||
Image(systemName: "flag.checkered")
|
||||
.font(.caption2)
|
||||
.foregroundColor(widgetSecondaryColor)
|
||||
Text("Next milestone")
|
||||
.font(.caption.weight(.semibold))
|
||||
Spacer()
|
||||
Text("\(Int((entry.milestoneProgress * 100).rounded()))%")
|
||||
.font(.caption.weight(.bold))
|
||||
.foregroundColor(widgetSecondaryColor)
|
||||
}
|
||||
ProgressView(value: entry.milestoneProgress)
|
||||
.tint(widgetSecondaryColor)
|
||||
HStack {
|
||||
Spacer()
|
||||
Text(entry.nextMilestone.compactCurrencyString(currencyCode: entry.currencyCode))
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Small Widget View
|
||||
|
||||
struct SmallWidgetView: View {
|
||||
let entry: InvestmentWidgetEntry
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Total Value")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
Group {
|
||||
if entry.hasData {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Total Value")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
Text(entry.totalValue.compactCurrencyString(currencyCode: entry.currencyCode))
|
||||
.font(.title2.weight(.bold))
|
||||
.minimumScaleFactor(0.7)
|
||||
.lineLimit(1)
|
||||
Text(entry.totalValue.compactCurrencyString(currencyCode: entry.currencyCode))
|
||||
.font(.title2.weight(.bold))
|
||||
.minimumScaleFactor(0.7)
|
||||
.lineLimit(1)
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Image(systemName: entry.dayChange >= 0 ? "arrow.up.right" : "arrow.down.right")
|
||||
.font(.caption2)
|
||||
ChangeLine(entry: entry, compact: true)
|
||||
Text("since last check-in")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
Text(entry.dayChange.compactCurrencyString(currencyCode: entry.currencyCode))
|
||||
.font(.caption.weight(.medium))
|
||||
Spacer(minLength: 0)
|
||||
|
||||
Text(String(format: "%.1f%% since last", entry.dayChangePercentage))
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
HStack {
|
||||
if entry.streak >= 1 { StreakPill(streak: entry.streak) }
|
||||
Spacer()
|
||||
CheckInPill(date: entry.nextCheckInDate)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
EmptyStateView()
|
||||
}
|
||||
.foregroundColor(entry.dayChange >= 0 ? .green : .red)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding()
|
||||
.containerBackground(.background, for: .widget)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Empty State
|
||||
|
||||
struct EmptyStateView: View {
|
||||
var body: some View {
|
||||
VStack(spacing: 8) {
|
||||
Image(systemName: "chart.line.uptrend.xyaxis")
|
||||
.font(.title)
|
||||
.foregroundColor(widgetPrimaryColor)
|
||||
Text("No data yet")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Text("Add a snapshot to start tracking.")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Medium Widget View
|
||||
|
||||
struct MediumWidgetView: View {
|
||||
let entry: InvestmentWidgetEntry
|
||||
|
||||
var body: some View {
|
||||
if !entry.hasData {
|
||||
EmptyStateView()
|
||||
.padding()
|
||||
.containerBackground(.background, for: .widget)
|
||||
} else {
|
||||
content
|
||||
}
|
||||
}
|
||||
|
||||
private var content: some View {
|
||||
HStack(spacing: 16) {
|
||||
// Left side - Total value
|
||||
// Left side - Total value + engagement metrics
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Portfolio")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
HStack(spacing: 6) {
|
||||
Text("Portfolio")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
Spacer(minLength: 0)
|
||||
WidgetRefreshButton()
|
||||
}
|
||||
|
||||
Text(entry.totalValue.compactCurrencyString(currencyCode: entry.currencyCode))
|
||||
.font(.title.weight(.bold))
|
||||
.minimumScaleFactor(0.7)
|
||||
.lineLimit(1)
|
||||
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: entry.dayChange >= 0 ? "arrow.up.right" : "arrow.down.right")
|
||||
.font(.caption2)
|
||||
ChangeLine(entry: entry)
|
||||
|
||||
Text(entry.dayChange.compactCurrencyString(currencyCode: entry.currencyCode))
|
||||
.font(.caption.weight(.medium))
|
||||
Spacer(minLength: 4)
|
||||
|
||||
Text("(\(String(format: "%.1f%%", entry.dayChangePercentage)))")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.foregroundColor(entry.dayChange >= 0 ? .green : .red)
|
||||
if entry.streak >= 1 { StreakPill(streak: entry.streak) }
|
||||
CheckInPill(date: entry.nextCheckInDate)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
@@ -574,7 +859,17 @@ struct LargeWidgetView: View {
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
if !entry.hasData {
|
||||
EmptyStateView()
|
||||
.padding()
|
||||
.containerBackground(.background, for: .widget)
|
||||
} else {
|
||||
content
|
||||
}
|
||||
}
|
||||
|
||||
private var content: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack(alignment: .top) {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Portfolio")
|
||||
@@ -586,27 +881,20 @@ struct LargeWidgetView: View {
|
||||
.minimumScaleFactor(0.7)
|
||||
.lineLimit(1)
|
||||
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: entry.dayChange >= 0 ? "arrow.up.right" : "arrow.down.right")
|
||||
.font(.caption2)
|
||||
|
||||
Text(entry.dayChange.compactCurrencyString(currencyCode: entry.currencyCode))
|
||||
.font(.caption.weight(.medium))
|
||||
|
||||
Text("(\(String(format: "%.1f%%", entry.dayChangePercentage)))")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.foregroundColor(entry.dayChange >= 0 ? .green : .red)
|
||||
ChangeLine(entry: entry)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Text("Category Evolution")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundColor(.secondary)
|
||||
VStack(alignment: .trailing, spacing: 6) {
|
||||
if entry.streak >= 1 { StreakPill(streak: entry.streak) }
|
||||
CheckInPill(date: entry.nextCheckInDate)
|
||||
WidgetRefreshButton()
|
||||
}
|
||||
}
|
||||
|
||||
GoalProgressRow(entry: entry)
|
||||
|
||||
if entry.isPremium {
|
||||
if hasCategoryTrend {
|
||||
CombinedCategoryChartView(
|
||||
@@ -615,10 +903,10 @@ struct LargeWidgetView: View {
|
||||
goal: entry.goals.first,
|
||||
currencyCode: entry.currencyCode
|
||||
)
|
||||
.frame(height: 98)
|
||||
.frame(height: 84)
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
ForEach(entry.categoryTotals.prefix(4), id: \.name) { category in
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
ForEach(entry.categoryTotals.prefix(3), id: \.name) { category in
|
||||
HStack {
|
||||
RoundedRectangle(cornerRadius: 3)
|
||||
.fill(Color(hex: category.color) ?? .gray)
|
||||
@@ -667,21 +955,49 @@ struct AccessoryCircularView: View {
|
||||
let entry: InvestmentWidgetEntry
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
AccessoryWidgetBackground()
|
||||
|
||||
VStack(spacing: 2) {
|
||||
Image(systemName: entry.dayChange >= 0 ? "arrow.up.right" : "arrow.down.right")
|
||||
.font(.caption)
|
||||
|
||||
Text(String(format: "%.1f%%", entry.dayChangePercentage))
|
||||
.font(.caption2.weight(.semibold))
|
||||
Group {
|
||||
if entry.hasData && (entry.nearestGoal != nil || entry.nextMilestone > 0) {
|
||||
// Gauge toward nearest goal (or next milestone as fallback).
|
||||
Gauge(value: entry.primaryGaugeProgress) {
|
||||
Image(systemName: entry.nearestGoal != nil ? "target" : "flag.checkered")
|
||||
} currentValueLabel: {
|
||||
Text("\(Int((entry.primaryGaugeProgress * 100).rounded()))%")
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
}
|
||||
.gaugeStyle(.accessoryCircularCapacity)
|
||||
} else {
|
||||
// Fallback: change since last check-in.
|
||||
ZStack {
|
||||
AccessoryWidgetBackground()
|
||||
VStack(spacing: 2) {
|
||||
Image(systemName: entry.dayChange >= 0 ? "arrow.up.right" : "arrow.down.right")
|
||||
.font(.caption)
|
||||
Text(String(format: "%.1f%%", entry.dayChangePercentage))
|
||||
.font(.caption2.weight(.semibold))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.containerBackground(.background, for: .widget)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Accessory Inline View (Lock Screen)
|
||||
|
||||
struct AccessoryInlineView: View {
|
||||
let entry: InvestmentWidgetEntry
|
||||
|
||||
var body: some View {
|
||||
// e.g. "€50K · ▲ +0.5%"
|
||||
let arrow = entry.dayChange >= 0 ? "arrow.up" : "arrow.down"
|
||||
Label {
|
||||
Text("\(entry.totalValue.compactCurrencyString(currencyCode: entry.currencyCode)) · \(String(format: "%+.1f%%", entry.dayChangePercentage))")
|
||||
} icon: {
|
||||
Image(systemName: arrow)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Accessory Rectangular View (Lock Screen)
|
||||
|
||||
struct AccessoryRectangularView: View {
|
||||
@@ -913,6 +1229,26 @@ struct CombinedCategoryChartView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Interactive Refresh Button (iOS 17+)
|
||||
|
||||
/// A small refresh control that reloads the widget timeline in-process via an
|
||||
/// AppIntent. Only rendered on iOS 17+, where `Button(intent:)` is available;
|
||||
/// on iOS 16 it collapses to nothing so the widget still builds and works with
|
||||
/// the existing deep links.
|
||||
struct WidgetRefreshButton: View {
|
||||
var body: some View {
|
||||
if #available(iOS 17.0, *) {
|
||||
Button(intent: RefreshWidgetIntent()) {
|
||||
Image(systemName: "arrow.clockwise")
|
||||
.font(.caption2.weight(.semibold))
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.invalidatableContent()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Widget Configuration
|
||||
|
||||
struct InvestmentWidget: Widget {
|
||||
@@ -929,7 +1265,8 @@ struct InvestmentWidget: Widget {
|
||||
.systemMedium,
|
||||
.systemLarge,
|
||||
.accessoryCircular,
|
||||
.accessoryRectangular
|
||||
.accessoryRectangular,
|
||||
.accessoryInline
|
||||
])
|
||||
}
|
||||
}
|
||||
@@ -957,6 +1294,9 @@ struct InvestmentWidgetEntryView: View {
|
||||
case .accessoryRectangular:
|
||||
AccessoryRectangularView(entry: entry)
|
||||
.widgetURL(URL(string: "portfoliojournal://quickupdate")!)
|
||||
case .accessoryInline:
|
||||
AccessoryInlineView(entry: entry)
|
||||
.widgetURL(URL(string: "portfoliojournal://quickupdate")!)
|
||||
default:
|
||||
SmallWidgetView(entry: entry)
|
||||
.widgetURL(URL(string: "portfoliojournal://quickupdate")!)
|
||||
@@ -992,7 +1332,13 @@ struct PortfolioJournalWidgetBundle: WidgetBundle {
|
||||
goals: [],
|
||||
currencyCode: "EUR",
|
||||
insightTitle: "",
|
||||
insightValue: ""
|
||||
insightValue: "",
|
||||
streak: 5,
|
||||
nextCheckInDate: Calendar.current.date(byAdding: .day, value: 8, to: Date()),
|
||||
nearestGoal: nil,
|
||||
nearestGoalProgress: 0,
|
||||
milestoneProgress: 0.5,
|
||||
nextMilestone: 100000
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1017,7 +1363,13 @@ struct PortfolioJournalWidgetBundle: WidgetBundle {
|
||||
goals: [],
|
||||
currencyCode: "EUR",
|
||||
insightTitle: "Year to date",
|
||||
insightValue: "+12.4%"
|
||||
insightValue: "+12.4%",
|
||||
streak: 7,
|
||||
nextCheckInDate: Calendar.current.date(byAdding: .day, value: 2, to: Date()),
|
||||
nearestGoal: nil,
|
||||
nearestGoalProgress: 0,
|
||||
milestoneProgress: 0.5,
|
||||
nextMilestone: 100000
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1066,11 +1418,17 @@ struct PortfolioJournalWidgetBundle: WidgetBundle {
|
||||
("Real Estate", 18000, "#F59E0B")
|
||||
],
|
||||
goals: [
|
||||
GoalSummary(name: "Target", targetAmount: 120000, targetDate: nil)
|
||||
GoalSummary(name: "House deposit", targetAmount: 120000, targetDate: nil)
|
||||
],
|
||||
currencyCode: "EUR",
|
||||
insightTitle: "Milestone ahead",
|
||||
insightValue: "€5K from €100K"
|
||||
insightValue: "€5K from €100K",
|
||||
streak: 9,
|
||||
nextCheckInDate: Calendar.current.date(byAdding: .day, value: 4, to: Date()),
|
||||
nearestGoal: GoalSummary(name: "House deposit", targetAmount: 120000, targetDate: nil),
|
||||
nearestGoalProgress: 0.79,
|
||||
milestoneProgress: 0.6,
|
||||
nextMilestone: 100000
|
||||
)
|
||||
}
|
||||
extension Decimal {
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import AppIntents
|
||||
import WidgetKit
|
||||
|
||||
// MARK: - Interactive widget intents (iOS 17+)
|
||||
//
|
||||
// These run in-process from the widget via `Button(intent:)` — no app launch.
|
||||
// The intent type compiles on iOS 16, but the interactive Button that invokes it
|
||||
// is gated to iOS 17 in the widget views, so iOS 16 still builds and works with
|
||||
// the existing `.widgetURL` deep links.
|
||||
|
||||
/// Reloads all widget timelines so the numbers refresh without opening the app.
|
||||
struct RefreshWidgetIntent: AppIntent {
|
||||
static let title: LocalizedStringResource = "Refresh Portfolio Widget"
|
||||
static let description = IntentDescription("Reload the portfolio widget with the latest values.")
|
||||
static let isDiscoverable = false
|
||||
|
||||
func perform() async throws -> some IntentResult {
|
||||
WidgetCenter.shared.reloadAllTimelines()
|
||||
return .result()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Crashlytics report for Portfolio Journal via the BigQuery export.
|
||||
|
||||
Setup (one-time, already done 2026-07-13):
|
||||
- Firebase console → project portfoliojournal-ef2d7 → Integrations → BigQuery:
|
||||
enable the Crashlytics export (requires Blaze plan).
|
||||
- Grant the service account sirius@sirius-vazquez.iam BigQuery Data Viewer +
|
||||
Job User on project portfoliojournal-ef2d7.
|
||||
|
||||
Creds come from `pass show google/sirius-service-account`. The export table is
|
||||
created on the first crash after enabling, named like
|
||||
`firebase_crashlytics.com_alexandrevazquez_PortfolioJournal_IOS`.
|
||||
|
||||
Usage: python3 Scripts/analyze_crashlytics.py [days] # default 30
|
||||
"""
|
||||
import subprocess, json, sys, urllib.request, urllib.error
|
||||
|
||||
PROJECT = "portfoliojournal-ef2d7"
|
||||
DATASET = "firebase_crashlytics"
|
||||
DAYS = int(sys.argv[1]) if len(sys.argv) > 1 else 30
|
||||
|
||||
|
||||
def creds_token():
|
||||
raw = subprocess.run(["pass", "show", "google/sirius-service-account"],
|
||||
capture_output=True, text=True).stdout
|
||||
try:
|
||||
info = json.loads(raw)
|
||||
except Exception:
|
||||
out, ins, esc = [], False, False
|
||||
for c in raw:
|
||||
if esc: out.append(c); esc = False
|
||||
elif c == "\\": out.append(c); esc = True
|
||||
elif c == '"': ins = not ins; out.append(c)
|
||||
elif ins and c == "\n": out.append("\\n")
|
||||
elif ins and c == "\r": pass
|
||||
else: out.append(c)
|
||||
info = json.loads("".join(out))
|
||||
from google.oauth2 import service_account
|
||||
import google.auth.transport.requests
|
||||
c = service_account.Credentials.from_service_account_info(
|
||||
info, scopes=["https://www.googleapis.com/auth/cloud-platform"])
|
||||
c.refresh(google.auth.transport.requests.Request())
|
||||
return c.token
|
||||
|
||||
|
||||
def api(token, path):
|
||||
req = urllib.request.Request(
|
||||
f"https://bigquery.googleapis.com/bigquery/v2/projects/{PROJECT}{path}",
|
||||
headers={"Authorization": f"Bearer {token}"})
|
||||
return json.load(urllib.request.urlopen(req))
|
||||
|
||||
|
||||
def query(token, sql):
|
||||
body = json.dumps({"query": sql, "useLegacySql": False, "timeoutMs": 60000}).encode()
|
||||
req = urllib.request.Request(
|
||||
f"https://bigquery.googleapis.com/bigquery/v2/projects/{PROJECT}/queries",
|
||||
data=body, headers={"Authorization": f"Bearer {token}",
|
||||
"Content-Type": "application/json"})
|
||||
d = json.load(urllib.request.urlopen(req))
|
||||
cols = [f["name"] for f in d.get("schema", {}).get("fields", [])]
|
||||
rows = [[c.get("v") for c in r["f"]] for r in d.get("rows", [])]
|
||||
return cols, rows
|
||||
|
||||
|
||||
def main():
|
||||
token = creds_token()
|
||||
try:
|
||||
td = api(token, f"/datasets/{DATASET}/tables?maxResults=100")
|
||||
except urllib.error.HTTPError as e:
|
||||
print(f"No access to {DATASET} (HTTP {e.code}).")
|
||||
return
|
||||
tables = [t["tableReference"]["tableId"] for t in td.get("tables", [])]
|
||||
crash_tables = [t for t in tables if "IOS" in t or "ios" in t.lower()]
|
||||
if not crash_tables:
|
||||
print("No Crashlytics export tables yet — no crashes exported since the "
|
||||
"export was enabled. (Good news.)")
|
||||
return
|
||||
table = crash_tables[0]
|
||||
print(f"== Crashlytics {table} — últimos {DAYS}d ==\n")
|
||||
|
||||
sql = f"""
|
||||
SELECT
|
||||
issue_title, issue_subtitle,
|
||||
application.display_version AS version,
|
||||
COUNT(*) AS events,
|
||||
COUNT(DISTINCT installation_uuid) AS users,
|
||||
COUNTIF(error_type = 'FATAL') AS fatal
|
||||
FROM `{PROJECT}.{DATASET}.{table}`
|
||||
WHERE event_timestamp >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL {DAYS} DAY)
|
||||
GROUP BY issue_title, issue_subtitle, version
|
||||
ORDER BY events DESC
|
||||
LIMIT 25
|
||||
"""
|
||||
cols, rows = query(token, sql)
|
||||
if not rows:
|
||||
print("Sin crashes en el periodo.")
|
||||
return
|
||||
for r in rows:
|
||||
title, sub, ver, events, users, fatal = r
|
||||
print(f"[{ver}] {title} — {sub}")
|
||||
print(f" {events} eventos · {users} usuarios · {fatal} fatales\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -27,6 +27,13 @@ ISSUER_ID="$(_pass_or_env "${APPSTORE_ISSUER_ID:-}" appstore/issuer-id)"
|
||||
TEAM_ID="$(_pass_or_env "${DEVELOPMENT_TEAM_ID:-}" appstore/team-id)"
|
||||
P8_PATH="${APPSTORE_P8_PATH:-}"
|
||||
P8_DIR=""
|
||||
# Fichero .p8 temporal a limpiar al salir. OJO zsh: un `trap ... EXIT` puesto DENTRO
|
||||
# de una función se dispara al RETORNAR la función (no al salir el script, como bash),
|
||||
# lo que borraba la clave antes del archive. Por eso la limpieza va en TRAPEXIT global.
|
||||
P8_TEMP=""
|
||||
TRAPEXIT() {
|
||||
[[ -n "$P8_TEMP" ]] && rm -f "$P8_TEMP"
|
||||
}
|
||||
|
||||
fail() {
|
||||
echo "Error: $*" >&2
|
||||
@@ -47,12 +54,15 @@ find_p8_file() {
|
||||
|
||||
# 2. Extraer de pass y escribir en fichero temporal (limpieza al salir del script)
|
||||
if command -v pass >/dev/null 2>&1 && pass show appstore/api-key-p8 &>/dev/null; then
|
||||
local tmp_p8
|
||||
tmp_p8="$(mktemp /tmp/AuthKey_XXXXXX.p8)"
|
||||
# OJO: macOS `mktemp` solo sustituye las X si están al FINAL del patrón; con
|
||||
# `AuthKey_XXXXXX.p8` deja el nombre literal y xcodebuild no encuentra el fichero.
|
||||
# Además `altool` exige que la key se llame AuthKey_<KEY_ID>.p8 dentro de
|
||||
# API_PRIVATE_KEYS_DIR, así que usamos ese nombre fijo directamente.
|
||||
local tmp_p8="/tmp/AuthKey_${KEY_ID}.p8"
|
||||
pass show appstore/api-key-p8 > "$tmp_p8"
|
||||
chmod 600 "$tmp_p8"
|
||||
# Registrar para limpieza al salir del script (trap en el shell principal).
|
||||
trap "rm -f '$tmp_p8'" EXIT
|
||||
# Limpieza al salir del script vía TRAPEXIT global (ver arriba por qué no un trap local).
|
||||
P8_TEMP="$tmp_p8"
|
||||
P8_PATH="$tmp_p8"
|
||||
return
|
||||
fi
|
||||
@@ -91,8 +101,22 @@ create_export_options() {
|
||||
<false/>
|
||||
<key>method</key>
|
||||
<string>app-store-connect</string>
|
||||
<!-- Firma MANUAL obligatoria: el cloud signing con API key falla al exportar la
|
||||
extensión QuickUpdate ("No profiles ... were found"). Los 3 targets se firman
|
||||
con "Apple Distribution" + sus perfiles App Store instalados localmente. -->
|
||||
<key>signingStyle</key>
|
||||
<string>automatic</string>
|
||||
<string>manual</string>
|
||||
<key>signingCertificate</key>
|
||||
<string>Apple Distribution</string>
|
||||
<key>provisioningProfiles</key>
|
||||
<dict>
|
||||
<key>com.alexandrevazquez.PortfolioJournal</key>
|
||||
<string>porfoliojournal</string>
|
||||
<key>com.alexandrevazquez.PortfolioJournal.PortfolioJournalWidget</key>
|
||||
<string>Portfolio Journalwidget</string>
|
||||
<key>com.alexandrevazquez.PortfolioJournal.QuickUpdate</key>
|
||||
<string>PortfolioJournal QuickUpdate AppStore</string>
|
||||
</dict>
|
||||
<key>stripSwiftSymbols</key>
|
||||
<true/>
|
||||
<key>teamID</key>
|
||||
|
||||
@@ -92,7 +92,8 @@ def with_shadow(rgba: Image.Image, blur: int, alpha: float, off=(0, 40)) -> Imag
|
||||
canvas = Image.new("RGBA", (rgba.width + pad * 2, rgba.height + pad * 2), (0, 0, 0, 0))
|
||||
sh = Image.new("RGBA", rgba.size, (0, 0, 0, 0))
|
||||
sh.putalpha(rgba.split()[3].point(lambda p: int(p * alpha)))
|
||||
dark = Image.new("RGBA", rgba.size, (10, 20, 40, 255))
|
||||
# cool light glow (not a dark drop-shadow) so the device lifts off the dark bg
|
||||
dark = Image.new("RGBA", rgba.size, (78, 110, 180, 255))
|
||||
dark.putalpha(sh.split()[3])
|
||||
canvas.alpha_composite(dark, (pad + off[0], pad + off[1]))
|
||||
canvas = canvas.filter(ImageFilter.GaussianBlur(blur))
|
||||
@@ -107,9 +108,9 @@ def _blob(W, H, cx, cy, r, a):
|
||||
|
||||
|
||||
def make_bg(W, H, accent, accent2, n=1):
|
||||
"""Light, premium background: near-white vertical wash + large soft brand-color
|
||||
blobs (blue + green) for depth. `n` = number of panels (for even blob spread)."""
|
||||
top, bottom = (251, 252, 254), (238, 242, 248)
|
||||
"""Dark premium background: near-black vertical wash (slate-900 -> near black)
|
||||
+ large soft brand-color glow blobs for subtle depth. `n` = number of panels."""
|
||||
top, bottom = (15, 23, 42), (2, 6, 23) # #0F172A -> #020617
|
||||
# fast vertical gradient: build a 1px-wide column then stretch to full width
|
||||
col = Image.new("RGB", (1, H))
|
||||
cp = col.load()
|
||||
@@ -120,11 +121,12 @@ def make_bg(W, H, accent, accent2, n=1):
|
||||
for i in range(n):
|
||||
col = accent if i % 2 == 0 else accent2
|
||||
cx = pw * (i + 0.5)
|
||||
# low-alpha glows so the dark stays premium, not garish
|
||||
base = Image.composite(Image.new("RGBA", (W, H), col + (255,)), base,
|
||||
_blob(W, H, cx, H * 0.20, pw * 0.42, 60).point(lambda p: int(p * 0.55)))
|
||||
_blob(W, H, cx, H * 0.16, pw * 0.5, 60).point(lambda p: int(p * 0.26)))
|
||||
col2 = accent2 if i % 2 == 0 else accent
|
||||
base = Image.composite(Image.new("RGBA", (W, H), col2 + (255,)), base,
|
||||
_blob(W, H, cx, H * 0.86, pw * 0.5, 45).point(lambda p: int(p * 0.5)))
|
||||
_blob(W, H, cx, H * 0.90, pw * 0.55, 45).point(lambda p: int(p * 0.22)))
|
||||
return base
|
||||
|
||||
|
||||
@@ -146,16 +148,23 @@ def draw_headline(canvas, text, cx, top_y, panel_w, color, max_lines=2):
|
||||
`max_lines` lines within the panel margins (never clips). Returns the y
|
||||
coordinate just below the headline block."""
|
||||
d = ImageDraw.Draw(canvas)
|
||||
maxw = int(panel_w * 0.82)
|
||||
size = int(panel_w * 0.074)
|
||||
# keep the block well inside the panel so nothing spills into the neighbour
|
||||
maxw = int(panel_w * 0.78)
|
||||
size = int(panel_w * 0.078)
|
||||
fnt = font(size, text)
|
||||
lines = wrap(d, text, fnt, maxw)
|
||||
while size > int(panel_w * 0.042):
|
||||
floor = int(panel_w * 0.034)
|
||||
while size > floor:
|
||||
fnt = font(size, text)
|
||||
lines = wrap(d, text, fnt, maxw)
|
||||
if len(lines) <= max_lines and max(d.textlength(ln, font=fnt) for ln in lines) <= maxw:
|
||||
break
|
||||
size -= 3
|
||||
# hard safety: shrink until every line truly fits, even below the floor
|
||||
while size > 14 and max(d.textlength(ln, font=fnt) for ln in lines) > maxw:
|
||||
size -= 2
|
||||
fnt = font(size, text)
|
||||
lines = wrap(d, text, fnt, maxw)
|
||||
asc, desc = fnt.getmetrics()
|
||||
lh = int((asc + desc) * 1.06)
|
||||
for i, ln in enumerate(lines):
|
||||
@@ -189,8 +198,8 @@ def main():
|
||||
accent, accent2 = hexrgb(a.accent), hexrgb(a.accent2)
|
||||
canvas = make_bg(W * n, H, accent, accent2, n=n)
|
||||
|
||||
text_color = (13, 22, 38)
|
||||
sub_color = (90, 102, 120)
|
||||
text_color = (248, 250, 252) # near-white on dark
|
||||
sub_color = (148, 163, 184) # slate-400
|
||||
dev_w = int(W * a.device_scale)
|
||||
angles = [-6, 5, -5, 6, -5, 5]
|
||||
y_off = [0.0, 0.05, 0.0, 0.05, 0.0, 0.05]
|
||||
@@ -205,13 +214,15 @@ def main():
|
||||
lines = wrap(d, sub, sf, int(W * 0.78))
|
||||
asc, desc = sf.getmetrics(); lh = int((asc + desc) * 1.05)
|
||||
sy = hy + int(H * 0.012)
|
||||
# positive/stat sublines (▲ ↑ +) pop in green; the rest stay muted
|
||||
sub_fill = accent2 if sub.strip()[:1] in "▲↑+" else sub_color
|
||||
for j, ln in enumerate(lines):
|
||||
w = d.textlength(ln, font=sf)
|
||||
d.text((panel_cx - w / 2, sy + j * lh), ln, font=sf, fill=sub_color)
|
||||
d.text((panel_cx - w / 2, sy + j * lh), ln, font=sf, fill=sub_fill)
|
||||
|
||||
shot = Image.open(path).convert("RGB")
|
||||
dev = build_device(shot, dev_w)
|
||||
dev = with_shadow(dev, blur=int(W * 0.04), alpha=0.34, off=(0, 46))
|
||||
dev = with_shadow(dev, blur=int(W * 0.05), alpha=0.30, off=(0, 18))
|
||||
ang = angles[i % len(angles)]
|
||||
dev = dev.rotate(ang, expand=True, resample=Image.BICUBIC)
|
||||
# nudge horizontally so adjacent devices overlap across the panel border
|
||||
|
||||
@@ -0,0 +1,522 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Render the high-impact Portfolio Journal App Store panorama.
|
||||
|
||||
The output is one continuous 5 x 1320 scene sliced into App Store panels.
|
||||
Run with Scripts/aso/.venv/bin/python.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFilter, ImageFont
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
RAW = ROOT / "build/screenshots/src_raw"
|
||||
|
||||
PANEL_W = 1320
|
||||
HEIGHT = 2868
|
||||
PANELS = 5
|
||||
WIDTH = PANEL_W * PANELS
|
||||
|
||||
INK = (244, 248, 255)
|
||||
MUTED = (155, 169, 191)
|
||||
GREEN = (16, 185, 129)
|
||||
BLUE = (48, 132, 255)
|
||||
NAVY = (3, 9, 22)
|
||||
|
||||
DISPLAY_FONT = "/System/Library/Fonts/Supplemental/Impact.ttf"
|
||||
TEXT_FONT = "/System/Library/Fonts/Avenir Next.ttc"
|
||||
CONDENSED_FONT = "/System/Library/Fonts/Avenir Next Condensed.ttc"
|
||||
JAPANESE_FONT_CANDIDATES = (
|
||||
"/System/Library/Fonts/ヒラギノ角ゴシック W8.ttc",
|
||||
"/System/Library/Fonts/ヒラギノ角ゴシック W9.ttc",
|
||||
"/System/Library/Fonts/Hiragino Sans GB.ttc",
|
||||
)
|
||||
|
||||
LOCALIZED_SPECS = {
|
||||
"es-ES": [
|
||||
{
|
||||
"eyebrow": "TU PATRIMONIO",
|
||||
"headline": ("CONOCE TU", "PATRIMONIO."),
|
||||
"body": "Todo lo que tienes, de un vistazo.",
|
||||
},
|
||||
{
|
||||
"eyebrow": "CRECIMIENTO REAL",
|
||||
"headline": ("MÍRALO", "AUMENTAR."),
|
||||
"stat": "+22.7%",
|
||||
"body": "Desde el inicio. Y sigue subiendo.",
|
||||
},
|
||||
{
|
||||
"eyebrow": "TODA TU CARTERA",
|
||||
"headline": ("TODO LO", "QUE TIENES."),
|
||||
"body": "Acciones, inmuebles, cripto y efectivo.",
|
||||
},
|
||||
{
|
||||
"eyebrow": "DECIDE CON DATOS",
|
||||
"headline": ("DESCUBRE TUS", "GANADORES."),
|
||||
"body": "Rentabilidad real. Sin suposiciones.",
|
||||
},
|
||||
{
|
||||
"eyebrow": "PRIVADO DE SERIE",
|
||||
"headline": ("SIN CLAVES", "BANCARIAS."),
|
||||
"body": "Tus datos nunca salen de tu iPhone.",
|
||||
},
|
||||
],
|
||||
"de": [
|
||||
{
|
||||
"eyebrow": "DEIN VERMÖGEN",
|
||||
"headline": ("KENN DEIN", "VERMÖGEN."),
|
||||
"body": "Alles, was dir gehört — auf einen Blick.",
|
||||
},
|
||||
{
|
||||
"eyebrow": "ECHTES VERMÖGEN. ECHTER SCHWUNG.",
|
||||
"headline": ("SIEH ES", "WACHSEN."),
|
||||
"stat": "+22.7%",
|
||||
"body": "Seit Beginn — und weiter aufwärts.",
|
||||
},
|
||||
{
|
||||
"eyebrow": "DAS GANZE PORTFOLIO",
|
||||
"headline": ("ALLES, WAS", "DIR GEHÖRT."),
|
||||
"body": "Aktien · Immobilien · Krypto · Cash",
|
||||
},
|
||||
{
|
||||
"eyebrow": "KLARHEIT SCHAFFT ÜBERZEUGUNG",
|
||||
"headline": ("FINDE DEINE", "GEWINNER."),
|
||||
"body": "Echter CAGR. Kein Rätselraten.",
|
||||
},
|
||||
{
|
||||
"eyebrow": "PRIVAT. VON GRUND AUF.",
|
||||
"headline": ("KEINE BANK-", "LOGINS. NIE."),
|
||||
"body": "Deine Zahlen bleiben auf deinem iPhone.",
|
||||
},
|
||||
],
|
||||
"fr": [
|
||||
{
|
||||
"eyebrow": "VOTRE PATRIMOINE",
|
||||
"headline": ("CONNAIS TON", "PATRIMOINE."),
|
||||
"body": "Tout ce que tu possèdes, d’un coup d’œil.",
|
||||
},
|
||||
{
|
||||
"eyebrow": "VRAIE RICHESSE. VRAI ÉLAN.",
|
||||
"headline": ("REGARDE-LE", "FRUCTIFIER."),
|
||||
"stat": "+22.7%",
|
||||
"body": "Depuis le début — et ça continue.",
|
||||
},
|
||||
{
|
||||
"eyebrow": "TOUT LE PORTEFEUILLE",
|
||||
"headline": ("TOUT CE QUE", "TU POSSÈDES."),
|
||||
"body": "Actions · immobilier · crypto · liquidités",
|
||||
},
|
||||
{
|
||||
"eyebrow": "LA CLARTÉ CRÉE LA CONVICTION",
|
||||
"headline": ("REPÈRE TES", "GAGNANTS."),
|
||||
"body": "CAGR réel. Zéro approximation.",
|
||||
},
|
||||
{
|
||||
"eyebrow": "CONFIDENTIEL PAR CONCEPTION",
|
||||
"headline": ("AUCUN ACCÈS", "BANCAIRE. JAMAIS."),
|
||||
"body": "Tes chiffres ne quittent jamais ton iPhone.",
|
||||
},
|
||||
],
|
||||
"it": [
|
||||
{
|
||||
"eyebrow": "IL TUO PATRIMONIO",
|
||||
"headline": ("CONOSCI IL TUO", "PATRIMONIO."),
|
||||
"body": "Tutto ciò che hai, in un colpo d’occhio.",
|
||||
},
|
||||
{
|
||||
"eyebrow": "PATRIMONIO REALE. SLANCIO REALE.",
|
||||
"headline": ("GUARDALO", "CRESCERE."),
|
||||
"stat": "+22.7%",
|
||||
"body": "Dall’inizio — e continua a salire.",
|
||||
},
|
||||
{
|
||||
"eyebrow": "TUTTO IL PORTAFOGLIO",
|
||||
"headline": ("TUTTO CIÒ", "CHE POSSIEDI."),
|
||||
"body": "Azioni · immobili · crypto · liquidità",
|
||||
},
|
||||
{
|
||||
"eyebrow": "LA CHIAREZZA CREA CONVINZIONE",
|
||||
"headline": ("SCOPRI I TUOI", "VINCENTI."),
|
||||
"body": "CAGR reale. Zero ipotesi.",
|
||||
},
|
||||
{
|
||||
"eyebrow": "PRIVATO PER NATURA",
|
||||
"headline": ("NESSUN LOGIN", "BANCARIO. MAI."),
|
||||
"body": "I tuoi numeri restano sul tuo iPhone.",
|
||||
},
|
||||
],
|
||||
"ja": [
|
||||
{
|
||||
"eyebrow": "資産管理",
|
||||
"headline": ("純資産を", "ひと目で。"),
|
||||
"body": "持っている資産のすべてを。",
|
||||
},
|
||||
{
|
||||
"eyebrow": "着実な成長",
|
||||
"headline": ("資産が", "増えていく。"),
|
||||
"stat": "+22.7%",
|
||||
"body": "運用開始から、今も上昇中。",
|
||||
},
|
||||
{
|
||||
"eyebrow": "ポートフォリオのすべて",
|
||||
"headline": ("所有資産を", "すべて。"),
|
||||
"body": "株式・不動産・暗号資産・現金",
|
||||
},
|
||||
{
|
||||
"eyebrow": "明確さが確信を生む",
|
||||
"headline": ("勝ち筋を", "見極める。"),
|
||||
"body": "本当のCAGR。勘に頼らない。",
|
||||
},
|
||||
{
|
||||
"eyebrow": "プライバシーを最優先",
|
||||
"headline": ("銀行ログインは", "一切不要。"),
|
||||
"body": "あなたの数字はiPhoneの外に出ません。",
|
||||
},
|
||||
],
|
||||
"pt-BR": [
|
||||
{
|
||||
"eyebrow": "SEU PATRIMÔNIO",
|
||||
"headline": ("CONHEÇA SEU", "PATRIMÔNIO."),
|
||||
"body": "Tudo o que é seu, num relance.",
|
||||
},
|
||||
{
|
||||
"eyebrow": "PATRIMÔNIO REAL. IMPULSO REAL.",
|
||||
"headline": ("VEJA-O", "CRESCER."),
|
||||
"stat": "+22.7%",
|
||||
"body": "Desde o início — e ainda subindo.",
|
||||
},
|
||||
{
|
||||
"eyebrow": "A CARTEIRA COMPLETA",
|
||||
"headline": ("TUDO QUE", "É SEU."),
|
||||
"body": "Ações · imóveis · cripto · dinheiro",
|
||||
},
|
||||
{
|
||||
"eyebrow": "CLAREZA GERA CONVICÇÃO",
|
||||
"headline": ("ACHE SEUS", "VENCEDORES."),
|
||||
"body": "CAGR real. Zero achismo.",
|
||||
},
|
||||
{
|
||||
"eyebrow": "PRIVADO POR NATUREZA",
|
||||
"headline": ("SEM LOGIN", "BANCÁRIO. NUNCA."),
|
||||
"body": "Seus números nunca saem do seu iPhone.",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def font(path: str, size: int, index: int = 0) -> ImageFont.FreeTypeFont:
|
||||
return ImageFont.truetype(path, size=size, index=index)
|
||||
|
||||
|
||||
def contains_cjk(text: str) -> bool:
|
||||
return any(
|
||||
"\u3040" <= char <= "\u30ff" or "\u3400" <= char <= "\u9fff"
|
||||
for char in text
|
||||
)
|
||||
|
||||
|
||||
def japanese_font_path() -> str:
|
||||
for path in JAPANESE_FONT_CANDIDATES:
|
||||
try:
|
||||
font(path, 20)
|
||||
return path
|
||||
except OSError:
|
||||
continue
|
||||
raise RuntimeError("No supported heavy Japanese font could be loaded")
|
||||
|
||||
|
||||
def face_for(text: str, latin_path: str, size: int, index: int = 0) -> ImageFont.FreeTypeFont:
|
||||
if contains_cjk(text):
|
||||
return font(japanese_font_path(), size)
|
||||
return font(latin_path, size, index)
|
||||
|
||||
|
||||
def gradient_background() -> Image.Image:
|
||||
"""Near-black navy base with a continuous blue/green atmospheric wash."""
|
||||
column = Image.new("RGB", (1, HEIGHT))
|
||||
px = column.load()
|
||||
for y in range(HEIGHT):
|
||||
t = y / (HEIGHT - 1)
|
||||
px[0, y] = (
|
||||
int(10 - 7 * t),
|
||||
int(21 - 12 * t),
|
||||
int(42 - 20 * t),
|
||||
)
|
||||
bg = column.resize((WIDTH, HEIGHT)).convert("RGBA")
|
||||
|
||||
glows = [
|
||||
(460, 610, 920, BLUE, 58),
|
||||
(1670, 1150, 870, GREEN, 42),
|
||||
(2890, 520, 760, BLUE, 40),
|
||||
(4130, 1240, 950, GREEN, 36),
|
||||
(5720, 600, 940, BLUE, 44),
|
||||
(6280, 2350, 980, GREEN, 28),
|
||||
]
|
||||
for cx, cy, radius, color, alpha in glows:
|
||||
mask = Image.new("L", (WIDTH, HEIGHT), 0)
|
||||
md = ImageDraw.Draw(mask)
|
||||
md.ellipse((cx - radius, cy - radius, cx + radius, cy + radius), fill=alpha)
|
||||
mask = mask.filter(ImageFilter.GaussianBlur(radius // 2))
|
||||
wash = Image.new("RGBA", bg.size, color + (255,))
|
||||
bg = Image.composite(wash, bg, mask)
|
||||
|
||||
# Subtle technical grid keeps the background dimensional without looking busy.
|
||||
grid = Image.new("RGBA", bg.size, (0, 0, 0, 0))
|
||||
gd = ImageDraw.Draw(grid)
|
||||
for x in range(0, WIDTH, 132):
|
||||
gd.line((x, 0, x, HEIGHT), fill=(93, 132, 181, 11), width=1)
|
||||
for y in range(0, HEIGHT, 132):
|
||||
gd.line((0, y, WIDTH, y), fill=(93, 132, 181, 9), width=1)
|
||||
bg.alpha_composite(grid)
|
||||
return bg
|
||||
|
||||
|
||||
def rounded_mask(size: tuple[int, int], radius: int) -> Image.Image:
|
||||
mask = Image.new("L", size, 0)
|
||||
ImageDraw.Draw(mask).rounded_rectangle(
|
||||
(0, 0, size[0] - 1, size[1] - 1), radius=radius, fill=255
|
||||
)
|
||||
return mask
|
||||
|
||||
|
||||
def build_iphone(shot: Image.Image, target_w: int) -> Image.Image:
|
||||
"""Realistic titanium iPhone frame with bezel, screen and Dynamic Island."""
|
||||
outer = max(18, int(target_w * 0.032))
|
||||
inner = max(6, int(target_w * 0.009))
|
||||
screen_w = target_w - 2 * (outer + inner)
|
||||
screen_h = round(screen_w * shot.height / shot.width)
|
||||
total_h = screen_h + 2 * (outer + inner)
|
||||
corner = int(target_w * 0.126)
|
||||
|
||||
phone = Image.new("RGBA", (target_w, total_h), (0, 0, 0, 0))
|
||||
d = ImageDraw.Draw(phone)
|
||||
d.rounded_rectangle(
|
||||
(0, 0, target_w - 1, total_h - 1),
|
||||
radius=corner + outer,
|
||||
fill=(26, 29, 35, 255),
|
||||
outline=(147, 154, 166, 255),
|
||||
width=max(4, target_w // 180),
|
||||
)
|
||||
d.rounded_rectangle(
|
||||
(outer, outer, target_w - outer - 1, total_h - outer - 1),
|
||||
radius=corner,
|
||||
fill=(2, 3, 5, 255),
|
||||
outline=(79, 84, 94, 255),
|
||||
width=max(3, target_w // 240),
|
||||
)
|
||||
|
||||
screen = shot.convert("RGB").resize((screen_w, screen_h), Image.Resampling.LANCZOS)
|
||||
screen_radius = int(screen_w * 0.105)
|
||||
inset = outer + inner
|
||||
phone.paste(screen, (inset, inset), rounded_mask(screen.size, screen_radius))
|
||||
|
||||
# The source shots already contain a Dynamic Island when captured on-device.
|
||||
# Repainting it ensures the one older settings capture matches the same hardware.
|
||||
island_w = int(screen_w * 0.315)
|
||||
island_h = int(screen_w * 0.084)
|
||||
island_x = inset + (screen_w - island_w) // 2
|
||||
island_y = inset + int(screen_w * 0.022)
|
||||
d.rounded_rectangle(
|
||||
(island_x, island_y, island_x + island_w, island_y + island_h),
|
||||
radius=island_h // 2,
|
||||
fill=(0, 0, 0, 255),
|
||||
)
|
||||
|
||||
# Hardware controls sell the silhouette as a physical iPhone.
|
||||
button = (70, 74, 82, 255)
|
||||
d.rounded_rectangle((-4, total_h * 0.22, 7, total_h * 0.31), radius=5, fill=button)
|
||||
d.rounded_rectangle((-4, total_h * 0.34, 7, total_h * 0.43), radius=5, fill=button)
|
||||
d.rounded_rectangle(
|
||||
(target_w - 7, total_h * 0.29, target_w + 4, total_h * 0.43),
|
||||
radius=5,
|
||||
fill=button,
|
||||
)
|
||||
return phone
|
||||
|
||||
|
||||
def elevated(phone: Image.Image, angle: float, glow: tuple[int, int, int]) -> Image.Image:
|
||||
rotated = phone.rotate(
|
||||
angle, expand=True, resample=Image.Resampling.BICUBIC
|
||||
)
|
||||
pad = 160
|
||||
stage = Image.new(
|
||||
"RGBA", (rotated.width + pad * 2, rotated.height + pad * 2), (0, 0, 0, 0)
|
||||
)
|
||||
alpha = rotated.getchannel("A")
|
||||
|
||||
shadow = Image.new("RGBA", rotated.size, (0, 0, 0, 0))
|
||||
shadow.putalpha(alpha.point(lambda a: int(a * 0.68)))
|
||||
black = Image.new("RGBA", rotated.size, (0, 0, 0, 255))
|
||||
black.putalpha(shadow.getchannel("A"))
|
||||
stage.alpha_composite(black, (pad + 18, pad + 74))
|
||||
stage = stage.filter(ImageFilter.GaussianBlur(70))
|
||||
|
||||
halo = Image.new("RGBA", rotated.size, glow + (255,))
|
||||
halo.putalpha(alpha.point(lambda a: int(a * 0.24)))
|
||||
stage.alpha_composite(halo, (pad - 8, pad + 16))
|
||||
stage.alpha_composite(rotated, (pad, pad))
|
||||
return stage
|
||||
|
||||
|
||||
def text_width(draw: ImageDraw.ImageDraw, text: str, face: ImageFont.FreeTypeFont) -> float:
|
||||
return draw.textlength(text, font=face)
|
||||
|
||||
|
||||
def draw_tracking_text(
|
||||
draw: ImageDraw.ImageDraw,
|
||||
xy: tuple[int, int],
|
||||
text: str,
|
||||
face: ImageFont.FreeTypeFont,
|
||||
fill: tuple[int, int, int],
|
||||
tracking: int,
|
||||
) -> None:
|
||||
x, y = xy
|
||||
for char in text:
|
||||
draw.text((x, y), char, font=face, fill=fill)
|
||||
x += text_width(draw, char, face) + tracking
|
||||
|
||||
|
||||
def fit_display(draw: ImageDraw.ImageDraw, lines: tuple[str, ...], max_width: int) -> ImageFont.FreeTypeFont:
|
||||
size = 206
|
||||
display_path = japanese_font_path() if any(contains_cjk(line) for line in lines) else DISPLAY_FONT
|
||||
while size > 110:
|
||||
face = font(display_path, size)
|
||||
if max(text_width(draw, line, face) for line in lines) <= max_width:
|
||||
return face
|
||||
size -= 4
|
||||
return font(display_path, size)
|
||||
|
||||
|
||||
def fit_body(draw: ImageDraw.ImageDraw, text: str, max_width: int) -> ImageFont.FreeTypeFont:
|
||||
size = 39
|
||||
while size > 29:
|
||||
face = face_for(text, TEXT_FONT, size)
|
||||
if text_width(draw, text, face) <= max_width:
|
||||
return face
|
||||
size -= 1
|
||||
return face_for(text, TEXT_FONT, size)
|
||||
|
||||
|
||||
def draw_campaign_copy(canvas: Image.Image, panel: int, spec: dict) -> None:
|
||||
d = ImageDraw.Draw(canvas)
|
||||
left = panel * PANEL_W + 92
|
||||
right = (panel + 1) * PANEL_W - 92
|
||||
top = 78
|
||||
|
||||
eyebrow = face_for(spec["eyebrow"], CONDENSED_FONT, 33, 0)
|
||||
draw_tracking_text(d, (left, top), spec["eyebrow"], eyebrow, GREEN, 5)
|
||||
|
||||
index = font(CONDENSED_FONT, 31, 0)
|
||||
index_text = f"0{panel + 1} / 05"
|
||||
d.text(
|
||||
(right - text_width(d, index_text, index), top),
|
||||
index_text,
|
||||
font=index,
|
||||
fill=(100, 118, 145),
|
||||
)
|
||||
d.rounded_rectangle((left, 139, right, 147), radius=4, fill=(38, 57, 82))
|
||||
d.rounded_rectangle((left, 139, left + 120, 147), radius=4, fill=GREEN)
|
||||
|
||||
lines = tuple(spec["headline"])
|
||||
display = fit_display(d, lines, PANEL_W - 184)
|
||||
line_h = int(display.size * (1.02 if any(contains_cjk(line) for line in lines) else 0.88))
|
||||
y = 190
|
||||
for line in lines:
|
||||
# A restrained blue offset gives the letterforms extra physical punch.
|
||||
d.text((left + 6, y + 9), line, font=display, fill=(23, 68, 127))
|
||||
d.text((left, y), line, font=display, fill=INK)
|
||||
y += line_h
|
||||
y += 34
|
||||
|
||||
if spec.get("stat"):
|
||||
stat = font(DISPLAY_FONT, 110)
|
||||
d.text((left, y + 8), spec["stat"], font=stat, fill=GREEN)
|
||||
y += 139
|
||||
|
||||
body = fit_body(d, spec["body"], PANEL_W - 184)
|
||||
d.text((left, y + 18), spec["body"], font=body, fill=MUTED)
|
||||
|
||||
|
||||
def draw_growth_thread(canvas: Image.Image) -> None:
|
||||
"""Continuous electric growth curve tying all five gallery panels together."""
|
||||
overlay = Image.new("RGBA", canvas.size, (0, 0, 0, 0))
|
||||
points = [
|
||||
(0, 1370),
|
||||
(600, 1330),
|
||||
(1210, 1380),
|
||||
(1770, 1160),
|
||||
(2380, 1215),
|
||||
(2970, 1030),
|
||||
(3590, 1080),
|
||||
(4210, 890),
|
||||
(4820, 960),
|
||||
(5420, 750),
|
||||
(6040, 820),
|
||||
(6600, 650),
|
||||
]
|
||||
glow = Image.new("RGBA", canvas.size, (0, 0, 0, 0))
|
||||
gd = ImageDraw.Draw(glow)
|
||||
gd.line(points, fill=BLUE + (80,), width=38, joint="curve")
|
||||
glow = glow.filter(ImageFilter.GaussianBlur(30))
|
||||
overlay.alpha_composite(glow)
|
||||
od = ImageDraw.Draw(overlay)
|
||||
od.line(points, fill=BLUE + (135,), width=5, joint="curve")
|
||||
for x, y in points[1:-1]:
|
||||
od.ellipse((x - 9, y - 9, x + 9, y + 9), fill=GREEN + (190,))
|
||||
canvas.alpha_composite(overlay)
|
||||
|
||||
|
||||
def render(specs: list[dict], output_dir: Path) -> list[Path]:
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
canvas = gradient_background()
|
||||
draw_growth_thread(canvas)
|
||||
|
||||
for i, spec in enumerate(specs):
|
||||
draw_campaign_copy(canvas, i, spec)
|
||||
|
||||
# One FULL iPhone per panel — sized + centered so nothing is clipped at the
|
||||
# panel borders even after the tilt + halo padding. Panel centers are at
|
||||
# 660, 1980, 3300, 4620, 5940 (PANEL_W = 1320). Widths kept small enough that
|
||||
# rotated_width + halo stays inside the panel; top_y leaves the device fully
|
||||
# visible (only a small, tasteful bottom bleed).
|
||||
placements = [
|
||||
# source, width, angle, center x, top y, halo color
|
||||
("01_home.png", 860, -4.5, 660, 900, BLUE),
|
||||
("02_evolution.png", 860, 4.5, 1980, 960, GREEN),
|
||||
("03_allocation.png", 860, -3.6, 3300, 910, BLUE),
|
||||
("04_performance.png", 860, 4.0, 4620, 950, GREEN),
|
||||
("05_settings.png", 860, -4.0, 5940, 895, BLUE),
|
||||
]
|
||||
for source, width, angle, center_x, top_y, halo in placements:
|
||||
shot = Image.open(RAW / source)
|
||||
phone = elevated(build_iphone(shot, width), angle, halo)
|
||||
canvas.alpha_composite(phone, (center_x - phone.width // 2, top_y))
|
||||
|
||||
rgb = canvas.convert("RGB")
|
||||
outputs: list[Path] = []
|
||||
for i in range(PANELS):
|
||||
path = output_dir / f"portfolio_journal_{i + 1:02d}.png"
|
||||
panel = rgb.crop((i * PANEL_W, 0, (i + 1) * PANEL_W, HEIGHT))
|
||||
panel.save(path, "PNG", optimize=True)
|
||||
outputs.append(path)
|
||||
|
||||
preview = output_dir / "_preview.png"
|
||||
preview_w = 1980
|
||||
preview_h = round(HEIGHT * preview_w / WIDTH)
|
||||
rgb.resize((preview_w, preview_h), Image.Resampling.LANCZOS).save(
|
||||
preview, "PNG", optimize=True
|
||||
)
|
||||
outputs.append(preview)
|
||||
return outputs
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
for locale, localized_specs in LOCALIZED_SPECS.items():
|
||||
output_dir = ROOT / "build/screenshots" / f"pano_codex_{locale}"
|
||||
rendered = render(localized_specs, output_dir)
|
||||
for output in rendered:
|
||||
with Image.open(output) as image:
|
||||
print(f"{output} {image.width}x{image.height}")
|
||||
@@ -0,0 +1,431 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Render the approved Portfolio Journal panorama for 12.9-inch iPad.
|
||||
|
||||
Each App Store panel is exactly 2732 x 2048 (APP_IPAD_PRO_3GEN_129).
|
||||
Run with Scripts/aso/.venv/bin/python.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFilter, ImageFont
|
||||
|
||||
import frame_panorama_codex as iphone_panorama
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
RAW = ROOT / "build/screenshots/src_raw_ipad"
|
||||
|
||||
PANEL_W = 2732
|
||||
HEIGHT = 2048
|
||||
PANELS = 5
|
||||
WIDTH = PANEL_W * PANELS
|
||||
|
||||
INK = iphone_panorama.INK
|
||||
MUTED = iphone_panorama.MUTED
|
||||
GREEN = iphone_panorama.GREEN
|
||||
BLUE = iphone_panorama.BLUE
|
||||
NAVY = iphone_panorama.NAVY
|
||||
|
||||
DISPLAY_FONT = iphone_panorama.DISPLAY_FONT
|
||||
TEXT_FONT = iphone_panorama.TEXT_FONT
|
||||
CONDENSED_FONT = iphone_panorama.CONDENSED_FONT
|
||||
LOCALIZED_SPECS = iphone_panorama.LOCALIZED_SPECS
|
||||
|
||||
# The final iPhone module currently exposes the localized specs but not its
|
||||
# English list as a named constant. These are the matching source strings from
|
||||
# which that localization set was written. If the shared renderer later exports
|
||||
# its English specs, this script automatically uses them instead.
|
||||
_ENGLISH_FALLBACK = [
|
||||
{
|
||||
"eyebrow": "YOUR NET WORTH",
|
||||
"headline": ("KNOW YOUR", "NET WORTH."),
|
||||
"body": "Everything you own, at a glance.",
|
||||
},
|
||||
{
|
||||
"eyebrow": "REAL WEALTH. REAL MOMENTUM.",
|
||||
"headline": ("WATCH IT", "GROW."),
|
||||
"stat": "+22.7%",
|
||||
"body": "Since day one — and still climbing.",
|
||||
},
|
||||
{
|
||||
"eyebrow": "THE WHOLE PORTFOLIO",
|
||||
"headline": ("EVERYTHING", "YOU OWN."),
|
||||
"body": "Stocks · property · crypto · cash",
|
||||
},
|
||||
{
|
||||
"eyebrow": "CLARITY BUILDS CONVICTION",
|
||||
"headline": ("FIND YOUR", "WINNERS."),
|
||||
"body": "Real CAGR. Zero guesswork.",
|
||||
},
|
||||
{
|
||||
"eyebrow": "PRIVATE BY DESIGN",
|
||||
"headline": ("NO BANK", "LOGINS. EVER."),
|
||||
"body": "Your numbers never leave your iPhone.",
|
||||
},
|
||||
]
|
||||
DEFAULT_ENGLISH_SPECS = getattr(
|
||||
iphone_panorama,
|
||||
"DEFAULT_ENGLISH_SPECS",
|
||||
getattr(iphone_panorama, "ENGLISH_SPECS", _ENGLISH_FALLBACK),
|
||||
)
|
||||
|
||||
|
||||
def font(path: str, size: int, index: int = 0) -> ImageFont.FreeTypeFont:
|
||||
return ImageFont.truetype(path, size=size, index=index)
|
||||
|
||||
|
||||
def contains_cjk(text: str) -> bool:
|
||||
return iphone_panorama.contains_cjk(text)
|
||||
|
||||
|
||||
def japanese_font_path() -> str:
|
||||
return iphone_panorama.japanese_font_path()
|
||||
|
||||
|
||||
def face_for(
|
||||
text: str, latin_path: str, size: int, index: int = 0
|
||||
) -> ImageFont.FreeTypeFont:
|
||||
if contains_cjk(text):
|
||||
return font(japanese_font_path(), size)
|
||||
return font(latin_path, size, index)
|
||||
|
||||
|
||||
def rounded_mask(size: tuple[int, int], radius: int) -> Image.Image:
|
||||
mask = Image.new("L", size, 0)
|
||||
ImageDraw.Draw(mask).rounded_rectangle(
|
||||
(0, 0, size[0] - 1, size[1] - 1), radius=radius, fill=255
|
||||
)
|
||||
return mask
|
||||
|
||||
|
||||
def gradient_background() -> Image.Image:
|
||||
"""Near-black navy canvas with continuous atmospheric blue/green light."""
|
||||
column = Image.new("RGB", (1, HEIGHT))
|
||||
pixels = column.load()
|
||||
for y in range(HEIGHT):
|
||||
t = y / (HEIGHT - 1)
|
||||
pixels[0, y] = (
|
||||
round(10 - 7 * t),
|
||||
round(21 - 12 * t),
|
||||
round(42 - 20 * t),
|
||||
)
|
||||
background = column.resize((WIDTH, HEIGHT)).convert("RGBA")
|
||||
|
||||
glow_layer = Image.new("RGBA", background.size, (0, 0, 0, 0))
|
||||
glow_draw = ImageDraw.Draw(glow_layer)
|
||||
glows = [
|
||||
(680, 570, 940, BLUE, 56),
|
||||
(2950, 1390, 930, GREEN, 38),
|
||||
(5350, 510, 880, BLUE, 42),
|
||||
(7770, 1350, 980, GREEN, 34),
|
||||
(10350, 500, 920, BLUE, 43),
|
||||
(12900, 1370, 980, GREEN, 32),
|
||||
]
|
||||
for cx, cy, radius, color, alpha in glows:
|
||||
glow_draw.ellipse(
|
||||
(cx - radius, cy - radius, cx + radius, cy + radius),
|
||||
fill=color + (alpha,),
|
||||
)
|
||||
glow_layer = glow_layer.filter(ImageFilter.GaussianBlur(330))
|
||||
background.alpha_composite(glow_layer)
|
||||
|
||||
grid = Image.new("RGBA", background.size, (0, 0, 0, 0))
|
||||
grid_draw = ImageDraw.Draw(grid)
|
||||
for x in range(0, WIDTH, 160):
|
||||
grid_draw.line((x, 0, x, HEIGHT), fill=(93, 132, 181, 11), width=1)
|
||||
for y in range(0, HEIGHT, 160):
|
||||
grid_draw.line((0, y, WIDTH, y), fill=(93, 132, 181, 9), width=1)
|
||||
background.alpha_composite(grid)
|
||||
return background
|
||||
|
||||
|
||||
def draw_growth_thread(canvas: Image.Image) -> None:
|
||||
"""Run one electric rising line continuously through all five panels."""
|
||||
points: list[tuple[int, int]] = []
|
||||
anchors = [
|
||||
1460,
|
||||
1420,
|
||||
1470,
|
||||
1310,
|
||||
1360,
|
||||
1180,
|
||||
1240,
|
||||
1030,
|
||||
1090,
|
||||
880,
|
||||
940,
|
||||
750,
|
||||
820,
|
||||
650,
|
||||
710,
|
||||
560,
|
||||
]
|
||||
for i, y in enumerate(anchors):
|
||||
points.append((round(i * WIDTH / (len(anchors) - 1)), y))
|
||||
|
||||
glow = Image.new("RGBA", canvas.size, (0, 0, 0, 0))
|
||||
gd = ImageDraw.Draw(glow)
|
||||
gd.line(points, fill=BLUE + (78,), width=42, joint="curve")
|
||||
glow = glow.filter(ImageFilter.GaussianBlur(34))
|
||||
canvas.alpha_composite(glow)
|
||||
|
||||
line = Image.new("RGBA", canvas.size, (0, 0, 0, 0))
|
||||
ld = ImageDraw.Draw(line)
|
||||
ld.line(points, fill=BLUE + (138,), width=6, joint="curve")
|
||||
for x, y in points[1:-1]:
|
||||
ld.ellipse((x - 9, y - 9, x + 9, y + 9), fill=GREEN + (188,))
|
||||
canvas.alpha_composite(line)
|
||||
|
||||
|
||||
def text_width(
|
||||
draw: ImageDraw.ImageDraw, text: str, face: ImageFont.FreeTypeFont
|
||||
) -> float:
|
||||
return draw.textlength(text, font=face)
|
||||
|
||||
|
||||
def draw_tracking_text(
|
||||
draw: ImageDraw.ImageDraw,
|
||||
xy: tuple[int, int],
|
||||
text: str,
|
||||
face: ImageFont.FreeTypeFont,
|
||||
fill: tuple[int, int, int],
|
||||
tracking: int,
|
||||
) -> None:
|
||||
x, y = xy
|
||||
for char in text:
|
||||
draw.text((x, y), char, font=face, fill=fill)
|
||||
x += text_width(draw, char, face) + tracking
|
||||
|
||||
|
||||
def fit_display(
|
||||
draw: ImageDraw.ImageDraw, lines: tuple[str, ...], max_width: int
|
||||
) -> ImageFont.FreeTypeFont:
|
||||
is_cjk = any(contains_cjk(line) for line in lines)
|
||||
display_path = japanese_font_path() if is_cjk else DISPLAY_FONT
|
||||
size = 166 if is_cjk else 184
|
||||
while size > 126:
|
||||
face = font(display_path, size)
|
||||
if max(text_width(draw, line, face) for line in lines) <= max_width:
|
||||
return face
|
||||
size -= 4
|
||||
return font(display_path, size)
|
||||
|
||||
|
||||
def fit_body(
|
||||
draw: ImageDraw.ImageDraw, text: str, max_width: int
|
||||
) -> ImageFont.FreeTypeFont:
|
||||
size = 40
|
||||
while size > 30:
|
||||
face = face_for(text, TEXT_FONT, size)
|
||||
if text_width(draw, text, face) <= max_width:
|
||||
return face
|
||||
size -= 1
|
||||
return face_for(text, TEXT_FONT, size)
|
||||
|
||||
|
||||
def draw_campaign_copy(canvas: Image.Image, panel: int, spec: dict) -> None:
|
||||
draw = ImageDraw.Draw(canvas)
|
||||
left = panel * PANEL_W + 150
|
||||
right = (panel + 1) * PANEL_W - 150
|
||||
top = 76
|
||||
|
||||
eyebrow = face_for(spec["eyebrow"], CONDENSED_FONT, 34)
|
||||
draw_tracking_text(draw, (left, top), spec["eyebrow"], eyebrow, GREEN, 5)
|
||||
|
||||
index = font(CONDENSED_FONT, 32)
|
||||
index_text = f"0{panel + 1} / 05"
|
||||
draw.text(
|
||||
(right - text_width(draw, index_text, index), top),
|
||||
index_text,
|
||||
font=index,
|
||||
fill=(100, 118, 145),
|
||||
)
|
||||
draw.rounded_rectangle((left, 139, right, 147), radius=4, fill=(38, 57, 82))
|
||||
draw.rounded_rectangle((left, 139, left + 165, 147), radius=4, fill=GREEN)
|
||||
|
||||
lines = tuple(spec["headline"])
|
||||
display = fit_display(draw, lines, 1640)
|
||||
line_h = round(
|
||||
display.size * (1.04 if any(contains_cjk(line) for line in lines) else 0.86)
|
||||
)
|
||||
y = 171
|
||||
headline_bottom = y
|
||||
for line in lines:
|
||||
draw.text((left + 6, y + 9), line, font=display, fill=(23, 68, 127))
|
||||
draw.text((left, y), line, font=display, fill=INK)
|
||||
headline_bottom = max(
|
||||
headline_bottom,
|
||||
draw.textbbox((left, y), line, font=display)[3],
|
||||
)
|
||||
y += line_h
|
||||
|
||||
body = fit_body(draw, spec["body"], 1060)
|
||||
body_y = headline_bottom + 18
|
||||
draw.text((left, body_y), spec["body"], font=body, fill=MUTED)
|
||||
|
||||
if spec.get("stat"):
|
||||
stat = font(DISPLAY_FONT, 112)
|
||||
stat_x = right - text_width(draw, spec["stat"], stat)
|
||||
draw.text((stat_x, 457), spec["stat"], font=stat, fill=GREEN)
|
||||
|
||||
|
||||
def build_ipad(shot: Image.Image, target_w: int) -> Image.Image:
|
||||
"""Build a slim, uniform titanium landscape iPad frame."""
|
||||
outer = max(22, round(target_w * 0.016))
|
||||
bezel = max(20, round(target_w * 0.014))
|
||||
screen_w = target_w - 2 * (outer + bezel)
|
||||
screen_h = round(screen_w * shot.height / shot.width)
|
||||
total_h = screen_h + 2 * (outer + bezel)
|
||||
outer_radius = round(target_w * 0.036)
|
||||
|
||||
ipad = Image.new("RGBA", (target_w, total_h), (0, 0, 0, 0))
|
||||
draw = ImageDraw.Draw(ipad)
|
||||
draw.rounded_rectangle(
|
||||
(0, 0, target_w - 1, total_h - 1),
|
||||
radius=outer_radius,
|
||||
fill=(43, 46, 52, 255),
|
||||
outline=(173, 179, 189, 255),
|
||||
width=max(3, target_w // 420),
|
||||
)
|
||||
draw.rounded_rectangle(
|
||||
(outer, outer, target_w - outer - 1, total_h - outer - 1),
|
||||
radius=outer_radius - outer // 3,
|
||||
fill=(3, 4, 7, 255),
|
||||
outline=(79, 84, 94, 255),
|
||||
width=max(2, target_w // 600),
|
||||
)
|
||||
|
||||
screen = shot.convert("RGB").resize((screen_w, screen_h), Image.Resampling.LANCZOS)
|
||||
inset = outer + bezel
|
||||
screen_radius = round(target_w * 0.020)
|
||||
ipad.paste(screen, (inset, inset), rounded_mask(screen.size, screen_radius))
|
||||
|
||||
# A landscape iPad has a small camera on the center of the long top edge,
|
||||
# never an iPhone-style Dynamic Island.
|
||||
camera_r = max(5, round(target_w * 0.0042))
|
||||
camera_x = target_w // 2
|
||||
camera_y = outer + bezel // 2
|
||||
draw.ellipse(
|
||||
(
|
||||
camera_x - camera_r,
|
||||
camera_y - camera_r,
|
||||
camera_x + camera_r,
|
||||
camera_y + camera_r,
|
||||
),
|
||||
fill=(5, 8, 13, 255),
|
||||
outline=(32, 52, 75, 255),
|
||||
width=2,
|
||||
)
|
||||
draw.ellipse(
|
||||
(
|
||||
camera_x - camera_r // 3,
|
||||
camera_y - camera_r // 3,
|
||||
camera_x + camera_r // 3,
|
||||
camera_y + camera_r // 3,
|
||||
),
|
||||
fill=(31, 60, 83, 255),
|
||||
)
|
||||
return ipad
|
||||
|
||||
|
||||
def elevated(
|
||||
ipad: Image.Image, angle: float, glow_color: tuple[int, int, int]
|
||||
) -> Image.Image:
|
||||
rotated = ipad.rotate(angle, expand=True, resample=Image.Resampling.BICUBIC)
|
||||
pad = 68
|
||||
stage = Image.new(
|
||||
"RGBA",
|
||||
(rotated.width + pad * 2, rotated.height + pad * 2),
|
||||
(0, 0, 0, 0),
|
||||
)
|
||||
alpha = rotated.getchannel("A")
|
||||
|
||||
shadow = Image.new("RGBA", rotated.size, (0, 0, 0, 255))
|
||||
shadow.putalpha(alpha.point(lambda value: round(value * 0.66)))
|
||||
stage.alpha_composite(shadow, (pad + 12, pad + 36))
|
||||
stage = stage.filter(ImageFilter.GaussianBlur(34))
|
||||
|
||||
halo = Image.new("RGBA", rotated.size, glow_color + (255,))
|
||||
halo.putalpha(alpha.point(lambda value: round(value * 0.20)))
|
||||
stage.alpha_composite(halo, (pad - 4, pad + 8))
|
||||
stage.alpha_composite(rotated, (pad, pad))
|
||||
return stage
|
||||
|
||||
|
||||
PLACEMENTS = [
|
||||
("01_home.png", 1690, -2.2, BLUE),
|
||||
("02_evolution.png", 1690, 2.2, GREEN),
|
||||
("03_allocation.png", 1690, -1.8, BLUE),
|
||||
("04_performance.png", 1690, 2.0, GREEN),
|
||||
("05_settings.png", 1690, -2.0, BLUE),
|
||||
]
|
||||
|
||||
|
||||
def render(
|
||||
specs: list[dict], output_dir: Path, base_scene: Image.Image
|
||||
) -> list[Path]:
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
canvas = base_scene.copy()
|
||||
|
||||
for panel, spec in enumerate(specs):
|
||||
draw_campaign_copy(canvas, panel, spec)
|
||||
|
||||
for panel, (source, target_w, angle, halo) in enumerate(PLACEMENTS):
|
||||
with Image.open(RAW / source) as shot:
|
||||
staged_ipad = elevated(build_ipad(shot, target_w), angle, halo)
|
||||
panel_left = panel * PANEL_W
|
||||
x = panel_left + (PANEL_W - staged_ipad.width) // 2
|
||||
y = HEIGHT - staged_ipad.height - 14
|
||||
if x < panel_left or x + staged_ipad.width > panel_left + PANEL_W or y < 0:
|
||||
raise RuntimeError(f"iPad placement escapes panel {panel + 1}")
|
||||
canvas.alpha_composite(staged_ipad, (x, y))
|
||||
|
||||
rgb = canvas.convert("RGB")
|
||||
outputs: list[Path] = []
|
||||
for panel in range(PANELS):
|
||||
path = output_dir / f"portfolio_journal_{panel + 1:02d}.png"
|
||||
image = rgb.crop(
|
||||
(panel * PANEL_W, 0, (panel + 1) * PANEL_W, HEIGHT)
|
||||
)
|
||||
image.save(path, "PNG", optimize=True)
|
||||
outputs.append(path)
|
||||
|
||||
preview = output_dir / "_preview.png"
|
||||
preview_w = PANEL_W
|
||||
preview_h = round(HEIGHT * preview_w / WIDTH)
|
||||
rgb.resize((preview_w, preview_h), Image.Resampling.LANCZOS).save(
|
||||
preview, "PNG", optimize=True
|
||||
)
|
||||
outputs.append(preview)
|
||||
return outputs
|
||||
|
||||
|
||||
def verify(outputs: list[Path]) -> None:
|
||||
for path in outputs:
|
||||
with Image.open(path) as image:
|
||||
image.load()
|
||||
expected = (
|
||||
(PANEL_W, HEIGHT)
|
||||
if path.name != "_preview.png"
|
||||
else (PANEL_W, round(HEIGHT * PANEL_W / WIDTH))
|
||||
)
|
||||
if image.size != expected:
|
||||
raise RuntimeError(f"{path}: expected {expected}, got {image.size}")
|
||||
print(f"{path} {image.width}x{image.height}")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
base_scene = gradient_background()
|
||||
draw_growth_thread(base_scene)
|
||||
|
||||
locale_specs = [("en", DEFAULT_ENGLISH_SPECS), *LOCALIZED_SPECS.items()]
|
||||
for locale, specs in locale_specs:
|
||||
suffix = "" if locale == "en" else f"_{locale}"
|
||||
output_dir = ROOT / "build/screenshots" / f"pano_codex_ipad{suffix}"
|
||||
verify(render(specs, output_dir, base_scene))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+246
-13
@@ -23,6 +23,13 @@ DEFAULT_GRAD_TOP = "#0B5FFF"
|
||||
DEFAULT_GRAD_BOTTOM = "#00C2A8"
|
||||
HEX_RE = re.compile(r"#[0-9A-Fa-f]{6}\b")
|
||||
|
||||
# --- Dark premium / fintech theme -----------------------------------------
|
||||
DARK_GRAD_TOP = "#0F172A" # slate-900
|
||||
DARK_GRAD_BOTTOM = "#020617" # near black
|
||||
HEADLINE_COLOR = (248, 250, 252) # #F8FAFC near-white
|
||||
ACCENT_COLOR = (52, 211, 153) # #34D399 vibrant green
|
||||
MUTED_COLOR = (148, 163, 184) # #94A3B8 muted slate
|
||||
|
||||
|
||||
FONT_CANDIDATES = (
|
||||
# CJK-capable macOS fonts first.
|
||||
@@ -38,6 +45,24 @@ FONT_CANDIDATES = (
|
||||
"/System/Library/Fonts/SFNS.ttf",
|
||||
)
|
||||
|
||||
# Bold-weight candidates for headline / stat callout.
|
||||
BOLD_FONT_CANDIDATES = (
|
||||
"/System/Library/Fonts/ヒラギノ角ゴシック W7.ttc",
|
||||
"/System/Library/Fonts/ヒラギノ角ゴシック W6.ttc",
|
||||
"/System/Library/Fonts/Supplemental/Arial Bold.ttf",
|
||||
"/System/Library/Fonts/SFNS.ttf",
|
||||
"/System/Library/Fonts/Supplemental/Arial.ttf",
|
||||
)
|
||||
|
||||
# Regular-weight candidates for secondary / substat text.
|
||||
REGULAR_FONT_CANDIDATES = (
|
||||
"/System/Library/Fonts/ヒラギノ角ゴシック W4.ttc",
|
||||
"/System/Library/Fonts/ヒラギノ角ゴシック W5.ttc",
|
||||
"/System/Library/Fonts/Supplemental/Arial.ttf",
|
||||
"/System/Library/Fonts/SFNS.ttf",
|
||||
"/System/Library/Fonts/Supplemental/Arial Unicode.ttf",
|
||||
)
|
||||
|
||||
|
||||
def parse_hex_color(value: str) -> tuple[int, int, int]:
|
||||
"""Return an RGB tuple from #RRGGBB."""
|
||||
@@ -140,8 +165,11 @@ def make_vertical_gradient(size: tuple[int, int], top: str, bottom: str) -> Imag
|
||||
return gradient.convert("RGBA")
|
||||
|
||||
|
||||
def load_font(size: int) -> ImageFont.ImageFont:
|
||||
for font_path in FONT_CANDIDATES:
|
||||
def load_font(
|
||||
size: int,
|
||||
candidates: Sequence[str] = FONT_CANDIDATES,
|
||||
) -> ImageFont.ImageFont:
|
||||
for font_path in candidates:
|
||||
path = Path(font_path)
|
||||
if not path.exists():
|
||||
continue
|
||||
@@ -158,6 +186,14 @@ def load_font(size: int) -> ImageFont.ImageFont:
|
||||
return ImageFont.load_default()
|
||||
|
||||
|
||||
def load_bold_font(size: int) -> ImageFont.ImageFont:
|
||||
return load_font(size, BOLD_FONT_CANDIDATES)
|
||||
|
||||
|
||||
def load_regular_font(size: int) -> ImageFont.ImageFont:
|
||||
return load_font(size, REGULAR_FONT_CANDIDATES)
|
||||
|
||||
|
||||
def text_bbox(draw: ImageDraw.ImageDraw, text: str, font: ImageFont.ImageFont) -> tuple[int, int]:
|
||||
if not text:
|
||||
return 0, 0
|
||||
@@ -244,19 +280,21 @@ def fit_headline(
|
||||
max_width: int,
|
||||
max_height: int,
|
||||
draw: ImageDraw.ImageDraw,
|
||||
max_scale: float = 0.052,
|
||||
font_loader=load_font,
|
||||
) -> tuple[str, ImageFont.ImageFont, int, int]:
|
||||
_, height = canvas_size
|
||||
max_size = max(34, round(height * 0.052))
|
||||
max_size = max(34, round(height * max_scale))
|
||||
min_size = 28
|
||||
|
||||
for size in range(max_size, min_size - 1, -2):
|
||||
font = load_font(size)
|
||||
font = font_loader(size)
|
||||
wrapped = wrap_headline(headline, font, max_width, draw)
|
||||
text_width, text_height = text_bbox(draw, wrapped, font)
|
||||
if text_width <= max_width and text_height <= max_height:
|
||||
return wrapped, font, text_width, text_height
|
||||
|
||||
font = load_font(min_size)
|
||||
font = font_loader(min_size)
|
||||
wrapped = wrap_headline(headline, font, max_width, draw)
|
||||
text_width, text_height = text_bbox(draw, wrapped, font)
|
||||
return wrapped, font, text_width, text_height
|
||||
@@ -276,12 +314,32 @@ def paste_with_shadow(
|
||||
screenshot: Image.Image,
|
||||
position: tuple[int, int],
|
||||
radius: int,
|
||||
glow: bool = False,
|
||||
) -> None:
|
||||
x, y = position
|
||||
shadow_offset = max(10, round(canvas.width * 0.014))
|
||||
shadow_blur = max(18, round(canvas.width * 0.025))
|
||||
shadow_opacity = 105
|
||||
|
||||
if glow:
|
||||
# Soft light halo so the device lifts off the dark background.
|
||||
halo_blur = max(28, round(canvas.width * 0.05))
|
||||
halo_pad = max(24, round(canvas.width * 0.03))
|
||||
halo_size = (screenshot.width + halo_pad * 2, screenshot.height + halo_pad * 2)
|
||||
halo = Image.new("RGBA", halo_size, (0, 0, 0, 0))
|
||||
halo_mask = Image.new("L", halo_size, 0)
|
||||
ImageDraw.Draw(halo_mask).rounded_rectangle(
|
||||
(halo_pad, halo_pad, halo_pad + screenshot.width, halo_pad + screenshot.height),
|
||||
radius=radius,
|
||||
fill=70,
|
||||
)
|
||||
halo.putalpha(halo_mask)
|
||||
# Tint the halo toward a cool light so it reads as premium elevation.
|
||||
halo_rgb = Image.new("RGBA", halo_size, (148, 197, 255, 0))
|
||||
halo_rgb.putalpha(halo.getchannel("A"))
|
||||
halo_rgb = halo_rgb.filter(ImageFilter.GaussianBlur(halo_blur))
|
||||
canvas.alpha_composite(halo_rgb, (x - halo_pad, y - halo_pad))
|
||||
|
||||
shadow = Image.new("RGBA", screenshot.size, (0, 0, 0, 0))
|
||||
mask = Image.new("L", screenshot.size, 0)
|
||||
ImageDraw.Draw(mask).rounded_rectangle((0, 0, screenshot.width, screenshot.height), radius=radius, fill=shadow_opacity)
|
||||
@@ -292,9 +350,17 @@ def paste_with_shadow(
|
||||
canvas.alpha_composite(rounded_image(screenshot, radius), position)
|
||||
|
||||
|
||||
def resize_screenshot(image: Image.Image, canvas_width: int, canvas_height: int) -> Image.Image:
|
||||
target_width = round(canvas_width * 0.78)
|
||||
def resize_screenshot(
|
||||
image: Image.Image,
|
||||
canvas_width: int,
|
||||
canvas_height: int,
|
||||
width_frac: float = 0.78,
|
||||
max_height_px: int | None = None,
|
||||
) -> Image.Image:
|
||||
target_width = round(canvas_width * width_frac)
|
||||
target_height = round(canvas_height * 0.72)
|
||||
if max_height_px is not None:
|
||||
target_height = min(target_height, max_height_px)
|
||||
scale = min(target_width / image.width, target_height / image.height, 1.0)
|
||||
new_size = (max(1, round(image.width * scale)), max(1, round(image.height * scale)))
|
||||
if new_size == image.size:
|
||||
@@ -311,8 +377,18 @@ def frame_screenshot(
|
||||
grad_top: str,
|
||||
grad_bottom: str,
|
||||
device: str | None = None,
|
||||
theme: str = "light",
|
||||
stat: str | None = None,
|
||||
substat: str | None = None,
|
||||
) -> None:
|
||||
del device # Reserved for future per-device tuning.
|
||||
if theme == "dark":
|
||||
frame_screenshot_dark(
|
||||
input_path, output_path, width, height,
|
||||
headline, grad_top, grad_bottom, stat, substat,
|
||||
)
|
||||
return
|
||||
|
||||
canvas = make_vertical_gradient((width, height), grad_top, grad_bottom)
|
||||
draw = ImageDraw.Draw(canvas)
|
||||
|
||||
@@ -353,6 +429,133 @@ def frame_screenshot(
|
||||
canvas.convert("RGB").save(output_path, "PNG")
|
||||
|
||||
|
||||
def frame_screenshot_dark(
|
||||
input_path: Path,
|
||||
output_path: Path,
|
||||
width: int,
|
||||
height: int,
|
||||
headline: str,
|
||||
grad_top: str,
|
||||
grad_bottom: str,
|
||||
stat: str | None,
|
||||
substat: str | None,
|
||||
) -> None:
|
||||
"""Dark premium / fintech framing: near-black gradient, near-white bold
|
||||
headline, optional big green stat callout, and a device shot lifted off the
|
||||
background with a subtle light glow."""
|
||||
canvas = make_vertical_gradient((width, height), grad_top, grad_bottom)
|
||||
draw = ImageDraw.Draw(canvas)
|
||||
|
||||
horizontal_margin = round(width * 0.08)
|
||||
top_margin = round(height * 0.055)
|
||||
max_text_width = width - horizontal_margin * 2
|
||||
line_spacing = max(8, round(height * 0.006))
|
||||
|
||||
# --- Headline: bold, near-white, slightly tighter/smaller than the light
|
||||
# theme so it reads premium rather than shouty.
|
||||
headline_area_height = round(height * 0.11)
|
||||
wrapped, headline_font, _, headline_h = fit_headline(
|
||||
headline,
|
||||
(width, height),
|
||||
max_text_width,
|
||||
headline_area_height,
|
||||
draw,
|
||||
max_scale=0.044,
|
||||
font_loader=load_bold_font,
|
||||
)
|
||||
|
||||
cursor_y = top_margin
|
||||
draw.multiline_text(
|
||||
(width // 2, cursor_y),
|
||||
wrapped,
|
||||
font=headline_font,
|
||||
fill=HEADLINE_COLOR + (255,),
|
||||
anchor="ma",
|
||||
align="center",
|
||||
spacing=line_spacing,
|
||||
)
|
||||
cursor_y += headline_h
|
||||
|
||||
# --- Optional stat callout: big vibrant-green number + muted secondary.
|
||||
if stat:
|
||||
cursor_y += round(height * 0.022)
|
||||
stat_size = max(48, round(height * 0.062))
|
||||
stat_font = load_bold_font(stat_size)
|
||||
# Shrink to fit width if the number is long.
|
||||
while stat_size > 40:
|
||||
if text_bbox(draw, stat, stat_font)[0] <= max_text_width:
|
||||
break
|
||||
stat_size -= 2
|
||||
stat_font = load_bold_font(stat_size)
|
||||
_, stat_h = text_bbox(draw, stat, stat_font)
|
||||
draw.text(
|
||||
(width // 2, cursor_y),
|
||||
stat,
|
||||
font=stat_font,
|
||||
fill=ACCENT_COLOR + (255,),
|
||||
anchor="ma",
|
||||
)
|
||||
cursor_y += stat_h
|
||||
|
||||
if substat:
|
||||
cursor_y += round(height * 0.010)
|
||||
sub_size = max(26, round(height * 0.026))
|
||||
sub_font = load_regular_font(sub_size)
|
||||
sub_wrapped = wrap_headline(substat, sub_font, max_text_width, draw)
|
||||
_, sub_h = text_bbox(draw, sub_wrapped, sub_font)
|
||||
# A leading "▲" (or up-arrow) is a positive signal -> green.
|
||||
sub_color = ACCENT_COLOR if substat.strip().startswith(("▲", "↑", "+")) else MUTED_COLOR
|
||||
draw.multiline_text(
|
||||
(width // 2, cursor_y),
|
||||
sub_wrapped,
|
||||
font=sub_font,
|
||||
fill=sub_color + (255,),
|
||||
anchor="ma",
|
||||
align="center",
|
||||
spacing=line_spacing,
|
||||
)
|
||||
cursor_y += sub_h
|
||||
elif substat:
|
||||
# substat without a big stat -> supporting line. Positive-signal
|
||||
# prefixes (▲ ↑ +) get the green accent; otherwise muted slate.
|
||||
cursor_y += round(height * 0.014)
|
||||
sub_size = max(26, round(height * 0.030))
|
||||
sub_font = load_bold_font(sub_size)
|
||||
sub_wrapped = wrap_headline(substat, sub_font, max_text_width, draw)
|
||||
_, sub_h = text_bbox(draw, sub_wrapped, sub_font)
|
||||
sub_color = ACCENT_COLOR if substat.strip().startswith(("▲", "↑", "+")) else MUTED_COLOR
|
||||
draw.multiline_text(
|
||||
(width // 2, cursor_y),
|
||||
sub_wrapped,
|
||||
font=sub_font,
|
||||
fill=sub_color + (255,),
|
||||
anchor="ma",
|
||||
align="center",
|
||||
spacing=line_spacing,
|
||||
)
|
||||
cursor_y += sub_h
|
||||
|
||||
# --- Device shot below the text block, with glow + shadow.
|
||||
# The device top is pinned just under the text block; it is scaled down so
|
||||
# it always fits the remaining space and never overlaps the copy.
|
||||
screenshot_y = cursor_y + round(height * 0.05)
|
||||
available_bottom = height - round(height * 0.045)
|
||||
max_device_height = available_bottom - screenshot_y
|
||||
screenshot = resize_screenshot(
|
||||
Image.open(input_path), width, height, max_height_px=max_device_height
|
||||
)
|
||||
screenshot_x = (width - screenshot.width) // 2
|
||||
# Center the device within the space left below the text for balance.
|
||||
slack = max(0, (available_bottom - screenshot_y) - screenshot.height)
|
||||
screenshot_y += slack // 2
|
||||
|
||||
radius = max(24, round(width * 0.03))
|
||||
paste_with_shadow(canvas, screenshot, (screenshot_x, screenshot_y), radius, glow=True)
|
||||
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
canvas.convert("RGB").save(output_path, "PNG")
|
||||
|
||||
|
||||
def positive_int(value: str) -> int:
|
||||
number = int(value)
|
||||
if number <= 0:
|
||||
@@ -378,8 +581,28 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
parser.add_argument("--width", required=True, type=positive_int, help="Final canvas width.")
|
||||
parser.add_argument("--height", required=True, type=positive_int, help="Final canvas height.")
|
||||
parser.add_argument("--headline", required=True, help="Localized headline text.")
|
||||
parser.add_argument("--grad-top", required=True, help="Top gradient color as #RRGGBB.")
|
||||
parser.add_argument("--grad-bottom", required=True, help="Bottom gradient color as #RRGGBB.")
|
||||
parser.add_argument(
|
||||
"--grad-top",
|
||||
help="Top gradient color as #RRGGBB (defaults per theme).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--grad-bottom",
|
||||
help="Bottom gradient color as #RRGGBB (defaults per theme).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--theme",
|
||||
choices=("light", "dark"),
|
||||
default="light",
|
||||
help="Framing theme. 'dark' = dark premium / fintech look.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--stat",
|
||||
help="Optional big accent stat callout, e.g. '€717,382' (dark theme).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--substat",
|
||||
help="Optional smaller secondary line, e.g. '▲ +9.4% since last check-in'.",
|
||||
)
|
||||
parser.add_argument("--device", choices=("iphone", "ipad"), help="Optional device family hint.")
|
||||
return parser
|
||||
|
||||
@@ -388,8 +611,15 @@ def main(argv: Sequence[str] | None = None) -> int:
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
parse_hex_color(args.grad_top)
|
||||
parse_hex_color(args.grad_bottom)
|
||||
if args.theme == "dark":
|
||||
grad_top = args.grad_top or DARK_GRAD_TOP
|
||||
grad_bottom = args.grad_bottom or DARK_GRAD_BOTTOM
|
||||
else:
|
||||
grad_top = args.grad_top or DEFAULT_GRAD_TOP
|
||||
grad_bottom = args.grad_bottom or DEFAULT_GRAD_BOTTOM
|
||||
|
||||
parse_hex_color(grad_top)
|
||||
parse_hex_color(grad_bottom)
|
||||
print_brand_colors(find_repo_root(Path(__file__).resolve()))
|
||||
|
||||
frame_screenshot(
|
||||
@@ -398,9 +628,12 @@ def main(argv: Sequence[str] | None = None) -> int:
|
||||
width=args.width,
|
||||
height=args.height,
|
||||
headline=args.headline,
|
||||
grad_top=args.grad_top,
|
||||
grad_bottom=args.grad_bottom,
|
||||
grad_top=grad_top,
|
||||
grad_bottom=grad_bottom,
|
||||
device=args.device,
|
||||
theme=args.theme,
|
||||
stat=args.stat,
|
||||
substat=args.substat,
|
||||
)
|
||||
print(f"Wrote {args.output} ({args.width}x{args.height})")
|
||||
return 0
|
||||
|
||||
Reference in New Issue
Block a user