Compare commits
71 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e9e3e5da66 | |||
| 15a6d7395e | |||
| 8828a2aa0f | |||
| 171023a411 | |||
| 6774d76146 | |||
| 680255f69d | |||
| a8a9ad64f3 | |||
| 464fbb2bad | |||
| 167b736aad | |||
| d88cc59627 | |||
| eaf0c8f93b | |||
| 6dd755234a | |||
| fa7df50400 | |||
| 66dc854461 | |||
| 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 |
@@ -2,6 +2,22 @@
|
||||
|
||||
All notable changes to Portfolio Journal will be documented in this file.
|
||||
|
||||
## [1.7.0] — iOS 27 + iPhone Duo (phase 1)
|
||||
|
||||
### Added
|
||||
- **iPhone Duo / regular width**: single adaptive `TabView` (sidebar-adaptable on iOS 18+) — no more layout swap between iPhone and iPad hierarchies, so navigation state survives opening/closing the Duo.
|
||||
- **Sources** and **Journal** use `NavigationSplitView` (list + detail side by side in regular width, collapsing stack in compact).
|
||||
- **Quick Update** opens as an inspector panel next to the dashboard in regular width (sheet in compact).
|
||||
- **Charts**: plots get 35% more height in regular width via `chartHeightScale`; bottom safe area respected.
|
||||
- **Biometrics**: Face ID / Touch ID / Optic ID resolved from `LAContext.biometryType`; all lock/privacy strings are generic (7 languages).
|
||||
- `@SceneStorage` restore of the selected tab.
|
||||
- What's New entry for 1.7.0.
|
||||
|
||||
### Changed
|
||||
- Compiles cleanly with Xcode 27 / SDK 27: missing `CoreData` imports (MemberImportVisibility), main-actor isolation warnings, `IsolatedDefaultValues` upcoming feature.
|
||||
- Migrated soft-deprecated SwiftUI API: `foregroundColor` → `foregroundStyle`, `cornerRadius` → `clipShape`, `navigationBarLeading/Trailing` → `topBarLeading/Trailing`, `Task.sleep(nanoseconds:)` → `sleep(for:)`, `NavigationLink(isActive:)` → `navigationDestination(isPresented:)`.
|
||||
- Liquid Glass (`glassEffect`) for the Journal floating month labels on iOS 26+, material fallback below.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -51,8 +51,28 @@ PortfolioJournal/
|
||||
└── Resources/ # Info.plist, GoogleService-Info.plist, assets
|
||||
|
||||
PortfolioJournalWidget/ # iOS Home Screen Widget (WidgetKit)
|
||||
PortfolioJournalWatch/ # watchOS app (read-only portfolio view)
|
||||
PortfolioJournalWatchWidget/# watchOS complications (WidgetKit)
|
||||
Shared/ # Types shared between iOS app and watch targets
|
||||
```
|
||||
|
||||
### Apple Watch
|
||||
|
||||
The watch app and its complications are read-only; every edit still happens on
|
||||
the iPhone. App Groups are not shared between iOS and watchOS, so the watch
|
||||
cannot read the Core Data store the iOS widget reads. Instead:
|
||||
|
||||
- `WatchSyncService` (iOS) builds a `WatchPortfolioSnapshot` and pushes it as the
|
||||
WatchConnectivity application context. It is triggered from
|
||||
`CoreDataStack.refreshWidgetData()`, the same hook that reloads the iOS widget.
|
||||
- `WatchDataStore` (watchOS) receives it, caches it in the watch App Group via
|
||||
`WatchSnapshotCache`, and reloads the complication timelines.
|
||||
- The complication extension only reads that cache — it never talks to
|
||||
WatchConnectivity itself.
|
||||
|
||||
Watch targets keep their own `*.lproj/Localizable.strings` with just their keys,
|
||||
like the other extensions.
|
||||
|
||||
### Core Data Model
|
||||
|
||||
Key entities: `Account`, `InvestmentSource`, `Snapshot`, `Category`, `Goal`, `Asset`, `Transaction`, `AppSettings`, `PremiumStatus`
|
||||
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
# iPhone Duo — estado de la adaptación (Portfolio Journal 1.7.0)
|
||||
|
||||
Fase 1, hecha con Xcode 27.0 / SDK iOS 27.0 (sin las APIs específicas de Duo de
|
||||
Xcode 27.1). Todo lo que se describe aquí funciona con size classes estándar:
|
||||
|
||||
| Pantalla Duo | Size class | Cómo se comporta la app |
|
||||
|---|---|---|
|
||||
| Exterior 5.4" (vertical) | compact × regular | Igual que un iPhone: tab bar, stacks |
|
||||
| Exterior (horizontal) | compact × compact | Igual que un iPhone en horizontal |
|
||||
| Interior 7.6" (ambas orientaciones) | regular × regular | Layouts de dos columnas, sidebar/tab bar superior, inspector |
|
||||
|
||||
## Qué se ha adaptado
|
||||
|
||||
### Navegación (ContentView)
|
||||
- Un único `TabView` para todas las size classes. En iOS 18+ usa
|
||||
`.tabViewStyle(.sidebarAdaptable)`: tab bar en compacto, tab bar superior /
|
||||
sidebar en regular. Antes había dos jerarquías (`TabView` iPhone /
|
||||
`NavigationSplitView` iPad) y el cambio de size class las intercambiaba,
|
||||
perdiendo el estado; ahora abrir/cerrar el Duo conserva cada pestaña.
|
||||
- `@SceneStorage("selectedTab")` restaura la pestaña por escena.
|
||||
- Ningún `userInterfaceIdiom`, `UIScreen.main`, ni orientación: todo por
|
||||
`@Environment(\.horizontalSizeClass)`.
|
||||
|
||||
### Escenarios regular×regular (pantalla interior)
|
||||
1. **Fuentes**: `NavigationSplitView` — lista de posiciones a la izquierda y
|
||||
detalle (rendimiento, gráfico, snapshots) a la derecha, a la vez. En
|
||||
compacto colapsa a stack; la selección es el único estado de navegación, así
|
||||
sobrevive al plegado.
|
||||
2. **Diario**: `NavigationSplitView` — lista de meses + check-in mensual inline.
|
||||
3. **Dashboard + Quick Update**: el check-in rápido se abre como `.inspector`
|
||||
(panel lateral) en ancho regular, de modo que los gráficos del dashboard
|
||||
siguen visibles mientras se teclean los valores; en compacto es un sheet.
|
||||
El dashboard usa masonry de 2/3 columnas según el ancho disponible
|
||||
(`GeometryReader`), que se recalcula al abrir/cerrar el inspector.
|
||||
4. **Gráficos**: sidebar de tipos de gráfico (248 pt) + área de gráfico a todo
|
||||
el ancho, cabecera KPI y selector de periodo segmentado. Nuevo
|
||||
`chartHeightScale` (+35 % de alto en regular) para que el ancho extra se
|
||||
traduzca en más área de trazado; las etiquetas de eje ya eran más densas en
|
||||
regular.
|
||||
5. **Bloqueo / privacidad**: `LAContext.biometryType` → "Desbloquear con
|
||||
Face ID / Touch ID / Optic ID"; icono y textos genéricos en 7 idiomas.
|
||||
|
||||
### Reglas de Apple aplicadas
|
||||
- Layout por size classes (regla 1), sin `UIScreen.main` (2), sin tamaños de
|
||||
pantalla fijos (3): las columnas usan `navigationSplitViewColumnWidth(min:ideal:max:)`
|
||||
e `inspectorColumnWidth`, y el contenido `maxWidth: .infinity`.
|
||||
- Navegación nativa: `TabView` sidebarAdaptable, `NavigationSplitView`,
|
||||
`.inspector`, sheets/popovers/alerts del sistema (4, 11).
|
||||
- Safe areas: se eliminó el `ignoresSafeArea(edges: .bottom)` del contenido
|
||||
interactivo de Gráficos; solo fondos decorativos (`AppBackground`) ignoran el
|
||||
safe area (5).
|
||||
- Orientaciones: iPhone vertical + horizontal, iPad las 4; sin
|
||||
`UIRequiresFullScreen`; `UIApplicationSupportsMultipleScenes = YES` (6).
|
||||
- Biometría por `biometryType` (7).
|
||||
- Estado persistente ante redimensionado: sin intercambio de jerarquías,
|
||||
`@SceneStorage`, ViewModels en `@StateObject` que sobreviven al cambio de
|
||||
size class (9).
|
||||
- Liquid Glass: barras del sistema automáticas al compilar con SDK 27;
|
||||
`glassEffect` en las etiquetas flotantes del Diario (iOS 26+, fallback
|
||||
material).
|
||||
|
||||
### Esquinas concéntricas (regla 8)
|
||||
No aplicada: ninguna tarjeta llega a sangre hasta la esquina de pantalla (todo
|
||||
va con padding dentro de `ScrollView`/`List`). Revisar en fase 2 si el
|
||||
edge-to-edge de 27.1 cambia esto.
|
||||
|
||||
## Qué queda para la fase 2 (Xcode 27.1)
|
||||
- `ArrangementView` / `ReservedRegion`: llegar al borde de la pantalla interior y
|
||||
respetar la región reservada (botones del sistema en Split View).
|
||||
- Probar en el simulador Duo (Device Hub) el plegado en caliente: comprobar que
|
||||
el inspector de Quick Update y las columnas de Fuentes/Diario se recomponen
|
||||
sin perder el texto tecleado.
|
||||
- Skill "App Resizability" y release notes con el edge-to-edge.
|
||||
- Traducir `fastlane/metadata/{de-DE,fr-*,it,ja,pt-*}/release_notes.txt`
|
||||
(solo en-* y es-* están actualizados).
|
||||
- Widgets: verificar tamaños en la pantalla interior cuando exista simulador.
|
||||
|
||||
## Verificado (16 sep 2026, Xcode 27.0)
|
||||
- `xcodebuild build` iOS (app + widget + extensión Quick Update) y watchOS
|
||||
(app + complicaciones): sin errores ni warnings de compilador.
|
||||
- 108/108 tests unitarios en iPhone 17 Pro.
|
||||
- `DuoLayoutUITests` en iPhone 17 Pro (compacto) y iPad mini A17 Pro
|
||||
(regular×regular): tab bar / tab bar superior, Fuentes y Diario en dos
|
||||
columnas, inspector de Quick Update (abre y cierra), gráficos con sidebar,
|
||||
vertical y horizontal.
|
||||
|
||||
## Cómo probarlo
|
||||
```bash
|
||||
# Build (un simulador cada vez)
|
||||
xcodebuild -project PortfolioJournal.xcodeproj -scheme PortfolioJournal \
|
||||
-destination 'platform=iOS Simulator,name=iPad mini (A17 Pro)' build
|
||||
|
||||
# Recorrido con capturas (compacto vs regular×regular)
|
||||
xcodebuild test -project PortfolioJournal.xcodeproj -scheme PortfolioJournal \
|
||||
-destination 'platform=iOS Simulator,name=iPhone 17 Pro' \
|
||||
-only-testing:PortfolioJournalUITests/DuoLayoutUITests -resultBundlePath /tmp/duo-iphone.xcresult
|
||||
xcodebuild test ... -destination 'platform=iOS Simulator,name=iPad mini (A17 Pro)' \
|
||||
-only-testing:PortfolioJournalUITests/DuoLayoutUITests -resultBundlePath /tmp/duo-ipad.xcresult
|
||||
xcrun xcresulttool export attachments --path /tmp/duo-ipad.xcresult --output-path /tmp/duo-ipad-png
|
||||
```
|
||||
La app arranca con datos de ejemplo y sin onboarding con el argumento
|
||||
`--screenshots`. iPad mini (A17 Pro) es el simulador más parecido a la pantalla
|
||||
interior del Duo (regular×regular en ambas orientaciones).
|
||||
@@ -16,6 +16,8 @@
|
||||
0E5375312F0FD12E00F31390 /* GoogleMobileAds in Frameworks */ = {isa = PBXBuildFile; productRef = 0E5375302F0FD12E00F31390 /* GoogleMobileAds */; };
|
||||
0E5375352F0FD14000F31390 /* FirebaseCrashlytics in Frameworks */ = {isa = PBXBuildFile; productRef = 0E5375342F0FD14000F31390 /* FirebaseCrashlytics */; };
|
||||
0EQUPD102F40000000000001 /* PortfolioJournalQuickUpdate.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 0EQUPD012F40000000000001 /* PortfolioJournalQuickUpdate.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
0EWTCH190000000000000000 /* PortfolioJournalWatchWidget.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 0EWTCH040000000000000000 /* PortfolioJournalWatchWidget.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
0EWTCH250000000000000000 /* PortfolioJournalWatch.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = 0EWTCH030000000000000000 /* PortfolioJournalWatch.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
@@ -47,6 +49,20 @@
|
||||
remoteGlobalIDString = 0EQUPD022F40000000000001;
|
||||
remoteInfo = PortfolioJournalQuickUpdate;
|
||||
};
|
||||
0EWTCH200000000000000000 /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = 0E241E312F0DA93A00283E2F /* Project object */;
|
||||
proxyType = 1;
|
||||
remoteGlobalIDString = 0EWTCH020000000000000000;
|
||||
remoteInfo = PortfolioJournalWatchWidget;
|
||||
};
|
||||
0EWTCH220000000000000000 /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = 0E241E312F0DA93A00283E2F /* Project object */;
|
||||
proxyType = 1;
|
||||
remoteGlobalIDString = 0EWTCH010000000000000000;
|
||||
remoteInfo = PortfolioJournalWatch;
|
||||
};
|
||||
/* End PBXContainerItemProxy section */
|
||||
|
||||
/* Begin PBXCopyFilesBuildPhase section */
|
||||
@@ -62,6 +78,28 @@
|
||||
name = "Embed Foundation Extensions";
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
0EWTCH150000000000000000 /* Embed Foundation Extensions */ = {
|
||||
isa = PBXCopyFilesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
dstPath = "";
|
||||
dstSubfolderSpec = 13;
|
||||
files = (
|
||||
0EWTCH190000000000000000 /* PortfolioJournalWatchWidget.appex in Embed Foundation Extensions */,
|
||||
);
|
||||
name = "Embed Foundation Extensions";
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
0EWTCH240000000000000000 /* Embed Watch Content */ = {
|
||||
isa = PBXCopyFilesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
dstPath = "$(CONTENTS_FOLDER_PATH)/Watch";
|
||||
dstSubfolderSpec = 16;
|
||||
files = (
|
||||
0EWTCH250000000000000000 /* PortfolioJournalWatch.app in Embed Watch Content */,
|
||||
);
|
||||
name = "Embed Watch Content";
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXCopyFilesBuildPhase section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
@@ -75,6 +113,8 @@
|
||||
0EUITEST002F31000000001 /* PortfolioJournalUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = PortfolioJournalUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
0EQUPD012F40000000000001 /* PortfolioJournalQuickUpdate.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = PortfolioJournalQuickUpdate.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
0EQUPD142F40000000000001 /* PortfolioJournalQuickUpdateExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = PortfolioJournalQuickUpdateExtension.entitlements; sourceTree = "<group>"; };
|
||||
0EWTCH030000000000000000 /* PortfolioJournalWatch.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = PortfolioJournalWatch.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
0EWTCH040000000000000000 /* PortfolioJournalWatchWidget.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = PortfolioJournalWatchWidget.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
@@ -106,6 +146,36 @@
|
||||
);
|
||||
target = 0EQUPD022F40000000000001 /* PortfolioJournalQuickUpdate */;
|
||||
};
|
||||
0EWTCH080000000000000000 /* Exceptions for "PortfolioJournalWatch" folder in "PortfolioJournalWatch" target */ = {
|
||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||
membershipExceptions = (
|
||||
Info.plist,
|
||||
);
|
||||
target = 0EWTCH010000000000000000 /* PortfolioJournalWatch */;
|
||||
};
|
||||
0EWTCH090000000000000000 /* Exceptions for "PortfolioJournalWatchWidget" folder in "PortfolioJournalWatchWidget" target */ = {
|
||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||
membershipExceptions = (
|
||||
Info.plist,
|
||||
);
|
||||
target = 0EWTCH020000000000000000 /* PortfolioJournalWatchWidget */;
|
||||
};
|
||||
0EWTCH100000000000000000 /* Exceptions for "Shared" folder in "PortfolioJournal" target */ = {
|
||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||
membershipExceptions = (
|
||||
WatchPortfolioSnapshot.swift,
|
||||
);
|
||||
target = 0E241E382F0DA93A00283E2F /* PortfolioJournal */;
|
||||
};
|
||||
0EWTCH110000000000000000 /* Exceptions for "Shared" folder in "PortfolioJournalWatchWidget" target */ = {
|
||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||
membershipExceptions = (
|
||||
WatchColorHex.swift,
|
||||
WatchPortfolioSnapshot.swift,
|
||||
WatchSnapshotCache.swift,
|
||||
);
|
||||
target = 0EWTCH020000000000000000 /* PortfolioJournalWatchWidget */;
|
||||
};
|
||||
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||
@@ -144,6 +214,31 @@
|
||||
path = PortfolioJournalQuickUpdate;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
0EWTCH050000000000000000 /* PortfolioJournalWatch */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
exceptions = (
|
||||
0EWTCH080000000000000000 /* Exceptions for "PortfolioJournalWatch" folder in "PortfolioJournalWatch" target */,
|
||||
);
|
||||
path = PortfolioJournalWatch;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
0EWTCH060000000000000000 /* PortfolioJournalWatchWidget */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
exceptions = (
|
||||
0EWTCH090000000000000000 /* Exceptions for "PortfolioJournalWatchWidget" folder in "PortfolioJournalWatchWidget" target */,
|
||||
);
|
||||
path = PortfolioJournalWatchWidget;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
0EWTCH070000000000000000 /* Shared */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
exceptions = (
|
||||
0EWTCH100000000000000000 /* Exceptions for "Shared" folder in "PortfolioJournal" target */,
|
||||
0EWTCH110000000000000000 /* Exceptions for "Shared" folder in "PortfolioJournalWatchWidget" target */,
|
||||
);
|
||||
path = Shared;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXFileSystemSynchronizedRootGroup section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
@@ -189,6 +284,20 @@
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
0EWTCH130000000000000000 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
0EWTCH170000000000000000 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
@@ -204,6 +313,9 @@
|
||||
0EUITEST032F31000000001 /* PortfolioJournalUITests */,
|
||||
0E241ECD2F0DAA3C00283E2F /* Frameworks */,
|
||||
0E241E3A2F0DA93A00283E2F /* Products */,
|
||||
0EWTCH070000000000000000 /* Shared */,
|
||||
0EWTCH050000000000000000 /* PortfolioJournalWatch */,
|
||||
0EWTCH060000000000000000 /* PortfolioJournalWatchWidget */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
@@ -215,6 +327,8 @@
|
||||
0EQUPD012F40000000000001 /* PortfolioJournalQuickUpdate.appex */,
|
||||
0ETEST0002F31000000000001 /* PortfolioJournalTests.xctest */,
|
||||
0EUITEST002F31000000001 /* PortfolioJournalUITests.xctest */,
|
||||
0EWTCH030000000000000000 /* PortfolioJournalWatch.app */,
|
||||
0EWTCH040000000000000000 /* PortfolioJournalWatchWidget.appex */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
@@ -240,6 +354,7 @@
|
||||
0E241E362F0DA93A00283E2F /* Frameworks */,
|
||||
0E241EE72F0DAA3E00283E2F /* Embed Foundation Extensions */,
|
||||
0E8318932F0DB2FB0030C2F9 /* Resources */,
|
||||
0EWTCH240000000000000000 /* Embed Watch Content */,
|
||||
0E5375362F0FD14000F31390 /* Upload dSYMs to Crashlytics */,
|
||||
);
|
||||
buildRules = (
|
||||
@@ -247,6 +362,7 @@
|
||||
dependencies = (
|
||||
0E241EE12F0DAA3E00283E2F /* PBXTargetDependency */,
|
||||
0EQUPD122F40000000000001 /* PBXTargetDependency */,
|
||||
0EWTCH230000000000000000 /* PBXTargetDependency */,
|
||||
);
|
||||
fileSystemSynchronizedGroups = (
|
||||
0E241E3B2F0DA93A00283E2F /* PortfolioJournal */,
|
||||
@@ -352,6 +468,53 @@
|
||||
productReference = 0EQUPD012F40000000000001 /* PortfolioJournalQuickUpdate.appex */;
|
||||
productType = "com.apple.product-type.app-extension";
|
||||
};
|
||||
0EWTCH010000000000000000 /* PortfolioJournalWatch */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 0EWTCH280000000000000000 /* Build configuration list for PBXNativeTarget "PortfolioJournalWatch" */;
|
||||
buildPhases = (
|
||||
0EWTCH120000000000000000 /* Sources */,
|
||||
0EWTCH130000000000000000 /* Frameworks */,
|
||||
0EWTCH140000000000000000 /* Resources */,
|
||||
0EWTCH150000000000000000 /* Embed Foundation Extensions */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
0EWTCH210000000000000000 /* PBXTargetDependency */,
|
||||
);
|
||||
fileSystemSynchronizedGroups = (
|
||||
0EWTCH050000000000000000 /* PortfolioJournalWatch */,
|
||||
0EWTCH070000000000000000 /* Shared */,
|
||||
);
|
||||
name = PortfolioJournalWatch;
|
||||
packageProductDependencies = (
|
||||
);
|
||||
productName = PortfolioJournalWatch;
|
||||
productReference = 0EWTCH030000000000000000 /* PortfolioJournalWatch.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
0EWTCH020000000000000000 /* PortfolioJournalWatchWidget */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 0EWTCH310000000000000000 /* Build configuration list for PBXNativeTarget "PortfolioJournalWatchWidget" */;
|
||||
buildPhases = (
|
||||
0EWTCH160000000000000000 /* Sources */,
|
||||
0EWTCH170000000000000000 /* Frameworks */,
|
||||
0EWTCH180000000000000000 /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
fileSystemSynchronizedGroups = (
|
||||
0EWTCH060000000000000000 /* PortfolioJournalWatchWidget */,
|
||||
);
|
||||
name = PortfolioJournalWatchWidget;
|
||||
packageProductDependencies = (
|
||||
);
|
||||
productName = PortfolioJournalWatchWidget;
|
||||
productReference = 0EWTCH040000000000000000 /* PortfolioJournalWatchWidget.appex */;
|
||||
productType = "com.apple.product-type.app-extension";
|
||||
};
|
||||
/* End PBXNativeTarget section */
|
||||
|
||||
/* Begin PBXProject section */
|
||||
@@ -406,6 +569,8 @@
|
||||
0EQUPD022F40000000000001 /* PortfolioJournalQuickUpdate */,
|
||||
0ETEST0012F31000000000001 /* PortfolioJournalTests */,
|
||||
0EUITEST012F31000000001 /* PortfolioJournalUITests */,
|
||||
0EWTCH010000000000000000 /* PortfolioJournalWatch */,
|
||||
0EWTCH020000000000000000 /* PortfolioJournalWatchWidget */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
@@ -446,6 +611,20 @@
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
0EWTCH140000000000000000 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
0EWTCH180000000000000000 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
@@ -484,6 +663,20 @@
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
0EWTCH120000000000000000 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
0EWTCH160000000000000000 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXShellScriptBuildPhase section */
|
||||
@@ -530,6 +723,16 @@
|
||||
target = 0EQUPD022F40000000000001 /* PortfolioJournalQuickUpdate */;
|
||||
targetProxy = 0EQUPD112F40000000000001 /* PBXContainerItemProxy */;
|
||||
};
|
||||
0EWTCH210000000000000000 /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = 0EWTCH020000000000000000 /* PortfolioJournalWatchWidget */;
|
||||
targetProxy = 0EWTCH200000000000000000 /* PBXContainerItemProxy */;
|
||||
};
|
||||
0EWTCH230000000000000000 /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = 0EWTCH010000000000000000 /* PortfolioJournalWatch */;
|
||||
targetProxy = 0EWTCH220000000000000000 /* PBXContainerItemProxy */;
|
||||
};
|
||||
/* End PBXTargetDependency section */
|
||||
|
||||
/* Begin XCBuildConfiguration section */
|
||||
@@ -541,7 +744,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = PortfolioJournal/PortfolioJournalDebug.entitlements;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 50;
|
||||
CURRENT_PROJECT_VERSION = 89;
|
||||
DEVELOPMENT_ASSET_PATHS = PortfolioJournal/Assets.xcassets;
|
||||
DEVELOPMENT_TEAM = 2825Q76T7H;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
@@ -559,12 +762,13 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.4.2;
|
||||
MARKETING_VERSION = 1.7.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournal;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
|
||||
SWIFT_UPCOMING_FEATURE_ISOLATED_DEFAULT_VALUES = YES;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
@@ -582,7 +786,7 @@
|
||||
CODE_SIGN_IDENTITY = "Apple Distribution";
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
PROVISIONING_PROFILE_SPECIFIER = "porfoliojournal";
|
||||
CURRENT_PROJECT_VERSION = 50;
|
||||
CURRENT_PROJECT_VERSION = 89;
|
||||
DEVELOPMENT_ASSET_PATHS = PortfolioJournal/Assets.xcassets;
|
||||
DEVELOPMENT_TEAM = 2825Q76T7H;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
@@ -600,12 +804,13 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.4.2;
|
||||
MARKETING_VERSION = 1.7.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournal;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
|
||||
SWIFT_UPCOMING_FEATURE_ISOLATED_DEFAULT_VALUES = YES;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
@@ -739,7 +944,7 @@
|
||||
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
|
||||
CODE_SIGN_ENTITLEMENTS = PortfolioJournalWidgetExtension.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 50;
|
||||
CURRENT_PROJECT_VERSION = 89;
|
||||
DEVELOPMENT_ASSET_PATHS = PortfolioJournalWidget/Assets.xcassets;
|
||||
DEVELOPMENT_TEAM = 2825Q76T7H;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
@@ -752,7 +957,7 @@
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.4.2;
|
||||
MARKETING_VERSION = 1.7.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournal.PortfolioJournalWidget;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SKIP_INSTALL = YES;
|
||||
@@ -774,7 +979,7 @@
|
||||
CODE_SIGN_IDENTITY = "Apple Distribution";
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
PROVISIONING_PROFILE_SPECIFIER = "Portfolio Journalwidget";
|
||||
CURRENT_PROJECT_VERSION = 50;
|
||||
CURRENT_PROJECT_VERSION = 89;
|
||||
DEVELOPMENT_ASSET_PATHS = PortfolioJournalWidget/Assets.xcassets;
|
||||
DEVELOPMENT_TEAM = 2825Q76T7H;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
@@ -787,7 +992,7 @@
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.4.2;
|
||||
MARKETING_VERSION = 1.7.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournal.PortfolioJournalWidget;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SKIP_INSTALL = YES;
|
||||
@@ -805,7 +1010,7 @@
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 50;
|
||||
CURRENT_PROJECT_VERSION = 89;
|
||||
DEVELOPMENT_TEAM = 2825Q76T7H;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
|
||||
@@ -814,7 +1019,7 @@
|
||||
"@executable_path/Frameworks",
|
||||
"@loader_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.4.2;
|
||||
MARKETING_VERSION = 1.7.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournalTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||
@@ -829,7 +1034,7 @@
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 50;
|
||||
CURRENT_PROJECT_VERSION = 89;
|
||||
DEVELOPMENT_TEAM = 2825Q76T7H;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
|
||||
@@ -838,7 +1043,7 @@
|
||||
"@executable_path/Frameworks",
|
||||
"@loader_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.4.2;
|
||||
MARKETING_VERSION = 1.7.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournalTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||
@@ -852,7 +1057,7 @@
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 50;
|
||||
CURRENT_PROJECT_VERSION = 89;
|
||||
DEVELOPMENT_TEAM = 2825Q76T7H;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
|
||||
@@ -861,7 +1066,7 @@
|
||||
"@executable_path/Frameworks",
|
||||
"@loader_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.4.2;
|
||||
MARKETING_VERSION = 1.7.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournalUITests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||
@@ -875,7 +1080,7 @@
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 50;
|
||||
CURRENT_PROJECT_VERSION = 89;
|
||||
DEVELOPMENT_TEAM = 2825Q76T7H;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
|
||||
@@ -884,7 +1089,7 @@
|
||||
"@executable_path/Frameworks",
|
||||
"@loader_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.4.2;
|
||||
MARKETING_VERSION = 1.7.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournalUITests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||
@@ -899,7 +1104,7 @@
|
||||
buildSettings = {
|
||||
CODE_SIGN_ENTITLEMENTS = PortfolioJournalQuickUpdateExtension.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 50;
|
||||
CURRENT_PROJECT_VERSION = 89;
|
||||
DEVELOPMENT_TEAM = 2825Q76T7H;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = PortfolioJournalQuickUpdate/Info.plist;
|
||||
@@ -911,7 +1116,7 @@
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.4.2;
|
||||
MARKETING_VERSION = 1.7.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournal.QuickUpdate;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SKIP_INSTALL = YES;
|
||||
@@ -927,7 +1132,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = PortfolioJournalQuickUpdateExtension.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Distribution";
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
CURRENT_PROJECT_VERSION = 50;
|
||||
CURRENT_PROJECT_VERSION = 89;
|
||||
DEVELOPMENT_TEAM = 2825Q76T7H;
|
||||
PROVISIONING_PROFILE_SPECIFIER = "PortfolioJournal QuickUpdate AppStore";
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
@@ -940,7 +1145,7 @@
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.4.2;
|
||||
MARKETING_VERSION = 1.7.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournal.QuickUpdate;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SKIP_INSTALL = YES;
|
||||
@@ -950,6 +1155,146 @@
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
0EWTCH260000000000000000 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = PortfolioJournalWatch.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 89;
|
||||
DEVELOPMENT_TEAM = 2825Q76T7H;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = PortfolioJournalWatch/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = "Portfolio Journal";
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.7.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournal.watchkitapp;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = watchos;
|
||||
SKIP_INSTALL = YES;
|
||||
SUPPORTED_PLATFORMS = "watchsimulator watchos";
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = 4;
|
||||
WATCHOS_DEPLOYMENT_TARGET = 10.0;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
0EWTCH270000000000000000 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = PortfolioJournalWatch.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 89;
|
||||
DEVELOPMENT_TEAM = 2825Q76T7H;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = PortfolioJournalWatch/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = "Portfolio Journal";
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.7.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournal.watchkitapp;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = watchos;
|
||||
SKIP_INSTALL = YES;
|
||||
SUPPORTED_PLATFORMS = "watchsimulator watchos";
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = 4;
|
||||
WATCHOS_DEPLOYMENT_TARGET = 10.0;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-O";
|
||||
SWIFT_COMPILATION_MODE = wholemodule;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
0EWTCH290000000000000000 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = PortfolioJournalWatchWidget.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 89;
|
||||
DEVELOPMENT_TEAM = 2825Q76T7H;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = PortfolioJournalWatchWidget/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = "Portfolio Complications";
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.7.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournal.watchkitapp.widget;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = watchos;
|
||||
SKIP_INSTALL = YES;
|
||||
SUPPORTED_PLATFORMS = "watchsimulator watchos";
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = 4;
|
||||
WATCHOS_DEPLOYMENT_TARGET = 10.0;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
0EWTCH300000000000000000 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = PortfolioJournalWatchWidget.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 89;
|
||||
DEVELOPMENT_TEAM = 2825Q76T7H;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = PortfolioJournalWatchWidget/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = "Portfolio Complications";
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.7.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournal.watchkitapp.widget;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = watchos;
|
||||
SKIP_INSTALL = YES;
|
||||
SUPPORTED_PLATFORMS = "watchsimulator watchos";
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = 4;
|
||||
WATCHOS_DEPLOYMENT_TARGET = 10.0;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-O";
|
||||
SWIFT_COMPILATION_MODE = wholemodule;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
@@ -1007,6 +1352,24 @@
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
0EWTCH280000000000000000 /* Build configuration list for PBXNativeTarget "PortfolioJournalWatch" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
0EWTCH260000000000000000 /* Debug */,
|
||||
0EWTCH270000000000000000 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
0EWTCH310000000000000000 /* Build configuration list for PBXNativeTarget "PortfolioJournalWatchWidget" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
0EWTCH290000000000000000 /* Debug */,
|
||||
0EWTCH300000000000000000 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
|
||||
/* Begin XCRemoteSwiftPackageReference section */
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "2620"
|
||||
version = "1.7">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES"
|
||||
buildArchitectures = "Automatic">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "0EWTCH010000000000000000"
|
||||
BuildableName = "PortfolioJournalWatch.app"
|
||||
BlueprintName = "PortfolioJournalWatch"
|
||||
ReferencedContainer = "container:PortfolioJournal.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
shouldAutocreateTestPlan = "YES">
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
launchStyle = "0"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
allowLocationSimulation = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "0EWTCH010000000000000000"
|
||||
BuildableName = "PortfolioJournalWatch.app"
|
||||
BlueprintName = "PortfolioJournalWatch"
|
||||
ReferencedContainer = "container:PortfolioJournal.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "0EWTCH010000000000000000"
|
||||
BuildableName = "PortfolioJournalWatch.app"
|
||||
BlueprintName = "PortfolioJournalWatch"
|
||||
ReferencedContainer = "container:PortfolioJournal.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
||||
+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>
|
||||
|
||||
@@ -23,6 +23,12 @@ class AppDelegate: NSObject, UIApplicationDelegate {
|
||||
// Request notification permissions
|
||||
requestNotificationPermissions()
|
||||
|
||||
// Apple Watch bridge: activating early means the first application
|
||||
// context lands before the user raises their wrist.
|
||||
Task { @MainActor in
|
||||
WatchSyncService.shared.activate()
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
@@ -41,13 +41,14 @@ struct ContentView: View {
|
||||
@AppStorage("lockOnBackground") private var lockOnBackground = false
|
||||
@AppStorage("lastSeenWhatsNewVersion") private var lastSeenWhatsNewVersion = ""
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||
@State private var isUnlocked = false
|
||||
@State private var resolvedOnboardingCompleted: Bool?
|
||||
@State private var iCloudCheckDone = false
|
||||
@State private var loadingMessageKey: LocalizedStringKey = "loading_data"
|
||||
@State private var sidebarSelection: AppTab? = .dashboard
|
||||
@State private var showingWhatsNew = false
|
||||
/// Per-scene tab restoration: the interface is re-laid out when iPhone Duo
|
||||
/// opens/closes or a window is resized — the selected tab must survive it.
|
||||
@SceneStorage("selectedTab") private var restoredTab: Int = -1
|
||||
|
||||
private var currentVersion: String {
|
||||
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? ""
|
||||
@@ -75,8 +76,6 @@ struct ContentView: View {
|
||||
OnboardingICloudCheckView(onSkip: { iCloudCheckDone = true })
|
||||
} else if resolvedOnboardingCompleted == false {
|
||||
OnboardingView(onboardingCompleted: $onboardingCompleted)
|
||||
} else if horizontalSizeClass == .regular {
|
||||
iPadMainContent
|
||||
} else {
|
||||
mainContent
|
||||
}
|
||||
@@ -92,6 +91,12 @@ struct ContentView: View {
|
||||
} else {
|
||||
isUnlocked = !lockOnLaunch
|
||||
}
|
||||
if restoredTab >= 0, AppTab(rawValue: restoredTab) != nil {
|
||||
tabSelection.selectedTab = restoredTab
|
||||
}
|
||||
}
|
||||
.onChange(of: tabSelection.selectedTab) { _, tab in
|
||||
restoredTab = tab
|
||||
}
|
||||
.onChange(of: lockEnabled) { _, enabled in
|
||||
if !enabled {
|
||||
@@ -113,33 +118,46 @@ 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)
|
||||
try? await Task.sleep(for: .milliseconds(300))
|
||||
showingWhatsNew = true
|
||||
}
|
||||
}
|
||||
.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
|
||||
sidebarSelection = .sources
|
||||
tabSelection.selectedTab = AppTab.sources.rawValue
|
||||
NotificationCenter.default.post(name: .openQuickUpdate, object: nil)
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .openDashboard)) { _ in
|
||||
tabSelection.selectedTab = 0
|
||||
sidebarSelection = .dashboard
|
||||
tabSelection.selectedTab = AppTab.dashboard.rawValue
|
||||
}
|
||||
.onOpenURL { url in
|
||||
if url.host == "quickupdate" {
|
||||
tabSelection.selectedTab = 0
|
||||
sidebarSelection = .dashboard
|
||||
tabSelection.selectedTab = AppTab.dashboard.rawValue
|
||||
NotificationCenter.default.post(name: .openQuickUpdate, object: nil)
|
||||
}
|
||||
}
|
||||
@@ -152,7 +170,7 @@ struct ContentView: View {
|
||||
private func waitForDataAndResolveOnboarding() async {
|
||||
// Wait for Core Data to be loaded
|
||||
while !coreDataStack.isLoaded {
|
||||
try? await Task.sleep(nanoseconds: 50_000_000) // 50ms
|
||||
try? await Task.sleep(for: .milliseconds(50)) // 50ms
|
||||
}
|
||||
|
||||
// If CloudKit is enabled and no local data yet, wait briefly for the
|
||||
@@ -176,7 +194,7 @@ struct ContentView: View {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
while Date() < deadline {
|
||||
if hasExistingData() { return }
|
||||
try? await Task.sleep(nanoseconds: 300_000_000) // poll every 300ms
|
||||
try? await Task.sleep(for: .milliseconds(300)) // poll every 300ms
|
||||
}
|
||||
}
|
||||
|
||||
@@ -224,33 +242,50 @@ struct ContentView: View {
|
||||
return ((try? context.count(for: accountRequest)) ?? 0) > 0
|
||||
}
|
||||
|
||||
// MARK: - iPad Layout
|
||||
// MARK: - Adaptive navigation
|
||||
//
|
||||
// One TabView for every size class. iOS 18+ uses the sidebar-adaptable
|
||||
// style: a tab bar in compact width (iPhone, iPhone Duo outer screen) and a
|
||||
// top tab bar / sidebar in regular width (iPad, iPhone Duo inner screen).
|
||||
// Because the hierarchy never changes, opening or closing the Duo keeps
|
||||
// every tab's navigation and scroll state. Each tab that has a list+detail
|
||||
// structure (Sources, Journal) owns a NavigationSplitView that collapses
|
||||
// to a stack in compact width and shows both columns in regular width.
|
||||
|
||||
private var iPadMainContent: some View {
|
||||
NavigationSplitView(columnVisibility: .constant(.all)) {
|
||||
List(AppTab.allCases, id: \.self, selection: $sidebarSelection) { tab in
|
||||
Label(tab.title, systemImage: tab.icon)
|
||||
}
|
||||
.navigationTitle("Portfolio Journal")
|
||||
.navigationSplitViewColumnWidth(min: 200, ideal: 240, max: 280)
|
||||
} detail: {
|
||||
iPadDetailView(for: sidebarSelection ?? .dashboard)
|
||||
@ViewBuilder
|
||||
private var mainContent: some View {
|
||||
if #available(iOS 18.0, *) {
|
||||
adaptiveTabs
|
||||
} else {
|
||||
legacyTabs
|
||||
}
|
||||
.onChange(of: sidebarSelection) { _, tab in
|
||||
guard let tab else { return }
|
||||
if tabSelection.selectedTab != tab.rawValue {
|
||||
tabSelection.selectedTab = tab.rawValue
|
||||
}
|
||||
|
||||
@available(iOS 18.0, *)
|
||||
private var adaptiveTabs: some View {
|
||||
TabView(selection: $tabSelection.selectedTab) {
|
||||
ForEach(AppTab.allCases, id: \.self) { tab in
|
||||
Tab(tab.title, systemImage: tab.icon, value: tab.rawValue) {
|
||||
tabContent(for: tab)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onChange(of: tabSelection.selectedTab) { _, value in
|
||||
if let tab = AppTab(rawValue: value), sidebarSelection != tab {
|
||||
sidebarSelection = tab
|
||||
.tabViewStyle(.sidebarAdaptable)
|
||||
}
|
||||
|
||||
/// iOS 17 fallback: classic tab bar in every size class.
|
||||
private var legacyTabs: some View {
|
||||
TabView(selection: $tabSelection.selectedTab) {
|
||||
ForEach(AppTab.allCases, id: \.self) { tab in
|
||||
tabContent(for: tab)
|
||||
.tabItem { Label(tab.title, systemImage: tab.icon) }
|
||||
.tag(tab.rawValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func iPadDetailView(for tab: AppTab) -> some View {
|
||||
private func tabContent(for tab: AppTab) -> some View {
|
||||
switch tab {
|
||||
case .dashboard:
|
||||
bannerInsetView(DashboardView())
|
||||
@@ -265,44 +300,6 @@ struct ContentView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - iPhone Layout
|
||||
|
||||
private var mainContent: some View {
|
||||
ZStack {
|
||||
TabView(selection: $tabSelection.selectedTab) {
|
||||
bannerInsetView(DashboardView())
|
||||
.tabItem {
|
||||
Label("Home", systemImage: "house.fill")
|
||||
}
|
||||
.tag(0)
|
||||
|
||||
bannerInsetView(SourceListView(iapService: iapService))
|
||||
.tabItem {
|
||||
Label("Sources", systemImage: "list.bullet")
|
||||
}
|
||||
.tag(1)
|
||||
|
||||
bannerInsetView(ChartsContainerView(iapService: iapService))
|
||||
.tabItem {
|
||||
Label("Charts", systemImage: "chart.xyaxis.line")
|
||||
}
|
||||
.tag(2)
|
||||
|
||||
bannerInsetView(JournalView())
|
||||
.tabItem {
|
||||
Label("Journal", systemImage: "book.closed")
|
||||
}
|
||||
.tag(3)
|
||||
|
||||
bannerInsetView(SettingsView(iapService: iapService))
|
||||
.tabItem {
|
||||
Label("Settings", systemImage: "gearshape.fill")
|
||||
}
|
||||
.tag(4)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func bannerInsetView<Content: View>(_ content: Content) -> some View {
|
||||
content.safeAreaInset(edge: .bottom, spacing: 0) {
|
||||
if !iapService.isPremium && adMobService.canShowAds {
|
||||
@@ -322,4 +319,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
|
||||
@@ -22,6 +24,9 @@ enum ScreenshotMode {
|
||||
// the premium charts without a StoreKit purchase. `--no-premium` keeps it
|
||||
// off to test the free-tier experience (locked charts, teasers).
|
||||
d.set(!CommandLine.arguments.contains("--no-premium"), forKey: "debugPremiumOverride")
|
||||
// TipKit popovers cover toolbar buttons and swallow the first tap in
|
||||
// automated walkthroughs — keep them out of captures.
|
||||
Tips.hideAllTipsForTesting()
|
||||
}
|
||||
|
||||
/// Seed demo data (no-op if the store already has sources). Called once Core Data
|
||||
@@ -29,6 +34,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 +65,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 +78,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,14 +96,25 @@ struct PortfolioJournalApp: App {
|
||||
.environmentObject(adMobService)
|
||||
.environmentObject(accountStore)
|
||||
.environmentObject(tabSelection)
|
||||
.environmentObject(balancePrivacy)
|
||||
}
|
||||
.onChange(of: scenePhase) { _, newPhase in
|
||||
if newPhase == .background {
|
||||
// Safety net for any save path that doesn't refresh the widget
|
||||
// itself: guarantee the widget is fresh whenever the app leaves
|
||||
// the foreground (WAL checkpoint + timeline reload).
|
||||
coreDataStack.refreshWidgetData()
|
||||
}
|
||||
if newPhase == .active {
|
||||
coreDataStack.refreshWidgetData()
|
||||
// Re-read all Core Data objects from the persistent store so that
|
||||
// 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,14 +8,27 @@ 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?
|
||||
@NSManaged public var sources: NSSet?
|
||||
@NSManaged public var goals: NSSet?
|
||||
|
||||
public override func awakeFromInsert() {
|
||||
super.awakeFromInsert()
|
||||
@@ -31,6 +44,13 @@ public class Category: NSManagedObject, Identifiable {
|
||||
// MARK: - Computed Properties
|
||||
|
||||
extension Category {
|
||||
/// Nil-safe identifier that never heals a deleted object, mirroring
|
||||
/// `Account.safeId`. Use it when comparing identities across fetches.
|
||||
var safeId: UUID? {
|
||||
guard !isDeleted else { return nil }
|
||||
return value(forKey: "id") as? UUID
|
||||
}
|
||||
|
||||
var color: Color {
|
||||
Color(hex: colorHex) ?? .blue
|
||||
}
|
||||
@@ -80,9 +100,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,13 +7,26 @@ 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?
|
||||
@NSManaged public var category: Category?
|
||||
|
||||
public override func awakeFromInsert() {
|
||||
super.awakeFromInsert()
|
||||
@@ -30,5 +43,32 @@ extension Goal {
|
||||
var targetDecimal: Decimal {
|
||||
targetAmount?.decimalValue ?? Decimal.zero
|
||||
}
|
||||
|
||||
/// A goal may be scoped to an account, to a source category (e.g. "100k in
|
||||
/// Cash"), to both, or to nothing at all (the whole portfolio). `account`
|
||||
/// and `category` act as AND filters over the sources that count towards it.
|
||||
func includes(_ source: InvestmentSource) -> Bool {
|
||||
if let accountId = account?.safeId, source.account?.id != accountId {
|
||||
return false
|
||||
}
|
||||
if let categoryId = category?.safeId, source.category?.id != categoryId {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/// Sources whose value counts towards this goal.
|
||||
func relevantSources(from sources: [InvestmentSource]) -> [InvestmentSource] {
|
||||
guard account != nil || category != nil else { return sources }
|
||||
return sources.filter { includes($0) }
|
||||
}
|
||||
|
||||
/// Stable key identifying the scope (account + category) of a goal, so
|
||||
/// goals sharing a scope can share cached evolution data.
|
||||
var scopeKey: String {
|
||||
let accountPart = account?.safeId?.uuidString ?? "all"
|
||||
let categoryPart = category?.safeId?.uuidString ?? "all"
|
||||
return "\(accountPart)|\(categoryPart)"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+6
-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,8 @@
|
||||
<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="goals" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Goal" inverseName="category" inverseEntity="Goal"/>
|
||||
<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"/>
|
||||
@@ -76,6 +77,7 @@
|
||||
<attribute name="targetAmount" optional="YES" attributeType="Decimal"/>
|
||||
<attribute name="targetDate" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||
<relationship name="account" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Account" inverseName="goals" inverseEntity="Account"/>
|
||||
<relationship name="category" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Category" inverseName="goals" inverseEntity="Category"/>
|
||||
</entity>
|
||||
<entity name="PredictionCache" representedClassName="PredictionCache" syncable="NO">
|
||||
<attribute name="algorithm" attributeType="String" defaultValueString="linear"/>
|
||||
@@ -100,6 +102,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()
|
||||
@@ -398,7 +820,7 @@ class CoreDataStack: ObservableObject {
|
||||
guard Self.cloudKitEnabled else { completion(0); return }
|
||||
let entities = ["Account", "Category", "InvestmentSource", "Snapshot", "Goal"]
|
||||
let context = newBackgroundContext()
|
||||
context.perform { [weak self] in
|
||||
context.perform {
|
||||
var totalTouched = 0
|
||||
for entityName in entities {
|
||||
let request = NSFetchRequest<NSManagedObject>(entityName: entityName)
|
||||
@@ -432,13 +854,25 @@ 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()
|
||||
WidgetCenter.shared.reloadAllTimelines()
|
||||
}
|
||||
}
|
||||
// The paired watch reads the same data changes; every repository that
|
||||
// refreshes the widget wants the watch refreshed too.
|
||||
Task { @MainActor in
|
||||
WatchSyncService.shared.scheduleSync()
|
||||
}
|
||||
}
|
||||
|
||||
/// Forces SQLite to checkpoint the WAL file, ensuring all changes are written to the main database file.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -53,25 +53,21 @@ class GoalRepository: ObservableObject {
|
||||
|
||||
// MARK: - Create
|
||||
|
||||
/// Check if a goal with the given name already exists in the account (case-insensitive)
|
||||
func goalExists(name: String, in account: Account?) -> Bool {
|
||||
let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let normalized = trimmed.lowercased()
|
||||
return goals.contains { goal in
|
||||
let nameMatches = goal.name.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == normalized
|
||||
let accountMatches = goal.account?.id == account?.id
|
||||
return nameMatches && accountMatches
|
||||
}
|
||||
/// Check if a goal with the given name already exists in the same scope
|
||||
/// (account + category), case-insensitive
|
||||
func goalExists(name: String, in account: Account?, category: Category? = nil) -> Bool {
|
||||
findGoal(byName: name, in: account, category: category) != nil
|
||||
}
|
||||
|
||||
/// Find an existing goal by name (case-insensitive) within the same account
|
||||
func findGoal(byName name: String, in account: Account?) -> Goal? {
|
||||
/// Find an existing goal by name (case-insensitive) within the same scope
|
||||
func findGoal(byName name: String, in account: Account?, category: Category? = nil) -> Goal? {
|
||||
let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let normalized = trimmed.lowercased()
|
||||
return goals.first { goal in
|
||||
let nameMatches = goal.name.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == normalized
|
||||
let accountMatches = goal.account?.id == account?.id
|
||||
return nameMatches && accountMatches
|
||||
let categoryMatches = goal.category?.id == category?.id
|
||||
return nameMatches && accountMatches && categoryMatches
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,10 +76,11 @@ class GoalRepository: ObservableObject {
|
||||
name: String,
|
||||
targetAmount: Decimal,
|
||||
targetDate: Date? = nil,
|
||||
account: Account?
|
||||
account: Account?,
|
||||
category: Category? = nil
|
||||
) -> Goal {
|
||||
// Check if goal with same name already exists in this account
|
||||
if let existing = findGoal(byName: name, in: account) {
|
||||
// Check if goal with same name already exists in this scope
|
||||
if let existing = findGoal(byName: name, in: account, category: category) {
|
||||
return existing
|
||||
}
|
||||
|
||||
@@ -92,6 +89,7 @@ class GoalRepository: ObservableObject {
|
||||
goal.targetAmount = NSDecimalNumber(decimal: targetAmount)
|
||||
goal.targetDate = targetDate
|
||||
goal.account = account
|
||||
goal.category = category
|
||||
save()
|
||||
return goal
|
||||
}
|
||||
@@ -104,7 +102,9 @@ class GoalRepository: ObservableObject {
|
||||
targetAmount: Decimal? = nil,
|
||||
targetDate: Date? = nil,
|
||||
clearTargetDate: Bool = false,
|
||||
isActive: Bool? = nil
|
||||
isActive: Bool? = nil,
|
||||
category: Category? = nil,
|
||||
clearCategory: Bool = false
|
||||
) {
|
||||
if let name = name {
|
||||
goal.name = name
|
||||
@@ -120,6 +120,11 @@ class GoalRepository: ObservableObject {
|
||||
if let isActive = isActive {
|
||||
goal.isActive = isActive
|
||||
}
|
||||
if clearCategory {
|
||||
goal.category = nil
|
||||
} else if let category = category {
|
||||
goal.category = category
|
||||
}
|
||||
save()
|
||||
}
|
||||
|
||||
|
||||
@@ -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,211 @@
|
||||
"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";
|
||||
|
||||
/* MARK: - Goal scope (category) */
|
||||
"goal_scope_section" = "Bereich";
|
||||
"goal_scope_category" = "Kategorie";
|
||||
"goal_scope_all_categories" = "Alle Kategorien";
|
||||
"goal_scope_footer_all" = "Alle Quellen deines Portfolios zählen für dieses Ziel.";
|
||||
"goal_scope_footer_category" = "Nur Quellen dieser Kategorie zählen für dieses Ziel.";
|
||||
|
||||
// MARK: - Biometrics (1.7.0 — Face ID / Touch ID / Optic ID aware)
|
||||
"biometric_generic_name" = "Face ID oder Touch ID";
|
||||
"biometric_unlock_with" = "Mit %@ entsperren";
|
||||
"biometric_failed" = "%@ fehlgeschlagen. Bitte erneut versuchen.";
|
||||
"biometric_enable" = "%@ aktivieren";
|
||||
"biometric_unavailable_title" = "%@ nicht verfügbar";
|
||||
"biometric_unavailable_body" = "%@ ist auf diesem Gerät nicht verfügbar.";
|
||||
"biometric_pin_required" = "Richte zuerst eine PIN ein, bevor du %@ aktivierst.";
|
||||
"biometric_disable_first_title" = "Zuerst %@ deaktivieren";
|
||||
"biometric_disable_first_body" = "Deaktiviere %@, bevor du deine PIN deaktivierst.";
|
||||
"biometric_pin_to_disable" = "PIN eingeben, um %@ zu deaktivieren";
|
||||
"biometric_require_to_reveal" = "%@ zum Anzeigen erforderlich";
|
||||
"biometric_lock_footer" = "Schütze deine Daten mit %@ oder einer 4-stelligen PIN.";
|
||||
"privacy_footer_biometric" = "Blendet alle Beträge hinter einer Unschärfe aus. Ideal für die App in der Öffentlichkeit oder für Screenshots. Optional %@ zum Anzeigen erforderlich.";
|
||||
|
||||
// MARK: - What's New 1.7.0
|
||||
"whats_new_170_duo_title" = "Bereit für iPhone Duo";
|
||||
"whats_new_170_duo_body" = "Auf dem Innendisplay zeigen Quellen und Tagebuch Liste und Detail nebeneinander, Diagramme erhalten eine Seitenleiste und mehr Höhe, und deine Position bleibt beim Auf- und Zuklappen erhalten.";
|
||||
"whats_new_170_inspector_title" = "Schnellupdate neben deinen Diagrammen";
|
||||
"whats_new_170_inspector_body" = "Auf breiten Bildschirmen öffnet sich das monatliche Schnellupdate in einem Seitenbereich – das Dashboard bleibt sichtbar, während du Werte eingibst.";
|
||||
"whats_new_170_biometrics_title" = "Face ID, Touch ID oder Optic ID";
|
||||
"whats_new_170_biometrics_body" = "App-Sperre und Saldo-Privatsphäre nutzen jetzt die Biometrie deines Geräts.";
|
||||
|
||||
@@ -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,211 @@
|
||||
"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";
|
||||
|
||||
/* MARK: - Goal scope (category) */
|
||||
"goal_scope_section" = "Scope";
|
||||
"goal_scope_category" = "Category";
|
||||
"goal_scope_all_categories" = "All categories";
|
||||
"goal_scope_footer_all" = "Every source in your portfolio counts towards this goal.";
|
||||
"goal_scope_footer_category" = "Only sources in this category count towards this goal.";
|
||||
|
||||
// MARK: - Biometrics (1.7.0 — Face ID / Touch ID / Optic ID aware)
|
||||
"biometric_generic_name" = "Face ID or Touch ID";
|
||||
"biometric_unlock_with" = "Unlock with %@";
|
||||
"biometric_failed" = "%@ failed. Please try again.";
|
||||
"biometric_enable" = "Enable %@";
|
||||
"biometric_unavailable_title" = "%@ Unavailable";
|
||||
"biometric_unavailable_body" = "%@ isn't available on this device.";
|
||||
"biometric_pin_required" = "Enable a PIN before turning on %@.";
|
||||
"biometric_disable_first_title" = "Disable %@ First";
|
||||
"biometric_disable_first_body" = "Turn off %@ before disabling your PIN.";
|
||||
"biometric_pin_to_disable" = "Enter PIN to Disable %@";
|
||||
"biometric_require_to_reveal" = "Require %@ to Reveal";
|
||||
"biometric_lock_footer" = "Use %@ or a 4-digit PIN to protect your data.";
|
||||
"privacy_footer_biometric" = "Hide all monetary amounts behind a blur. Great for using the app in public or taking screenshots. Optionally require %@ to reveal them.";
|
||||
|
||||
// MARK: - What's New 1.7.0
|
||||
"whats_new_170_duo_title" = "Ready for iPhone Duo";
|
||||
"whats_new_170_duo_body" = "On the inner screen, Sources and Journal show list and detail side by side, Charts get a sidebar and taller plots, and your place is kept when you open or close the phone.";
|
||||
"whats_new_170_inspector_title" = "Quick Update beside your charts";
|
||||
"whats_new_170_inspector_body" = "On wide screens the monthly Quick Update opens in a side panel, so the dashboard stays visible while you type values.";
|
||||
"whats_new_170_biometrics_title" = "Face ID, Touch ID or Optic ID";
|
||||
"whats_new_170_biometrics_body" = "App Lock and balance privacy now use whichever biometrics your device has.";
|
||||
|
||||
@@ -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,211 @@
|
||||
"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";
|
||||
|
||||
/* MARK: - Goal scope (category) */
|
||||
"goal_scope_section" = "Ámbito";
|
||||
"goal_scope_category" = "Categoría";
|
||||
"goal_scope_all_categories" = "Todas las categorías";
|
||||
"goal_scope_footer_all" = "Todas las fuentes de tu cartera cuentan para este objetivo.";
|
||||
"goal_scope_footer_category" = "Solo las fuentes de esta categoría cuentan para este objetivo.";
|
||||
|
||||
// MARK: - Biometrics (1.7.0 — Face ID / Touch ID / Optic ID aware)
|
||||
"biometric_generic_name" = "Face ID o Touch ID";
|
||||
"biometric_unlock_with" = "Desbloquear con %@";
|
||||
"biometric_failed" = "%@ ha fallado. Inténtalo de nuevo.";
|
||||
"biometric_enable" = "Activar %@";
|
||||
"biometric_unavailable_title" = "%@ no disponible";
|
||||
"biometric_unavailable_body" = "%@ no está disponible en este dispositivo.";
|
||||
"biometric_pin_required" = "Activa un PIN antes de activar %@.";
|
||||
"biometric_disable_first_title" = "Desactiva %@ primero";
|
||||
"biometric_disable_first_body" = "Desactiva %@ antes de desactivar tu PIN.";
|
||||
"biometric_pin_to_disable" = "Introduce el PIN para desactivar %@";
|
||||
"biometric_require_to_reveal" = "Requerir %@ para mostrar";
|
||||
"biometric_lock_footer" = "Usa %@ o un PIN de 4 dígitos para proteger tus datos.";
|
||||
"privacy_footer_biometric" = "Oculta todas las cifras tras un difuminado. Ideal para usar la app en público o hacer capturas. Opcionalmente, requiere %@ para mostrarlas.";
|
||||
|
||||
// MARK: - What's New 1.7.0
|
||||
"whats_new_170_duo_title" = "Preparada para iPhone Duo";
|
||||
"whats_new_170_duo_body" = "En la pantalla interior, Fuentes y Diario muestran lista y detalle a la vez, Gráficos gana una barra lateral y más altura, y no pierdes el sitio al abrir o cerrar el teléfono.";
|
||||
"whats_new_170_inspector_title" = "Actualización rápida junto a tus gráficos";
|
||||
"whats_new_170_inspector_body" = "En pantallas anchas la actualización rápida mensual se abre en un panel lateral: el panel principal sigue visible mientras escribes valores.";
|
||||
"whats_new_170_biometrics_title" = "Face ID, Touch ID u Optic ID";
|
||||
"whats_new_170_biometrics_body" = "El bloqueo de la app y la privacidad de saldos usan la biometría que tenga tu dispositivo.";
|
||||
|
||||
@@ -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,211 @@
|
||||
"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";
|
||||
|
||||
/* MARK: - Goal scope (category) */
|
||||
"goal_scope_section" = "Périmètre";
|
||||
"goal_scope_category" = "Catégorie";
|
||||
"goal_scope_all_categories" = "Toutes les catégories";
|
||||
"goal_scope_footer_all" = "Toutes les sources de votre portefeuille comptent pour cet objectif.";
|
||||
"goal_scope_footer_category" = "Seules les sources de cette catégorie comptent pour cet objectif.";
|
||||
|
||||
// MARK: - Biometrics (1.7.0 — Face ID / Touch ID / Optic ID aware)
|
||||
"biometric_generic_name" = "Face ID ou Touch ID";
|
||||
"biometric_unlock_with" = "Déverrouiller avec %@";
|
||||
"biometric_failed" = "Échec de %@. Veuillez réessayer.";
|
||||
"biometric_enable" = "Activer %@";
|
||||
"biometric_unavailable_title" = "%@ indisponible";
|
||||
"biometric_unavailable_body" = "%@ n'est pas disponible sur cet appareil.";
|
||||
"biometric_pin_required" = "Définissez un code PIN avant d'activer %@.";
|
||||
"biometric_disable_first_title" = "Désactivez d'abord %@";
|
||||
"biometric_disable_first_body" = "Désactivez %@ avant de désactiver votre code PIN.";
|
||||
"biometric_pin_to_disable" = "Saisissez le code PIN pour désactiver %@";
|
||||
"biometric_require_to_reveal" = "Exiger %@ pour afficher";
|
||||
"biometric_lock_footer" = "Protégez vos données avec %@ ou un code PIN à 4 chiffres.";
|
||||
"privacy_footer_biometric" = "Masque tous les montants derrière un flou. Idéal pour utiliser l'app en public ou faire des captures. Exigez %@ pour les afficher, en option.";
|
||||
|
||||
// MARK: - What's New 1.7.0
|
||||
"whats_new_170_duo_title" = "Prête pour iPhone Duo";
|
||||
"whats_new_170_duo_body" = "Sur l'écran intérieur, Sources et Journal affichent liste et détail côte à côte, Graphiques gagne une barre latérale et plus de hauteur, et vous gardez votre position en ouvrant ou fermant le téléphone.";
|
||||
"whats_new_170_inspector_title" = "Mise à jour rapide à côté de vos graphiques";
|
||||
"whats_new_170_inspector_body" = "Sur les grands écrans, la mise à jour rapide mensuelle s'ouvre dans un panneau latéral : le tableau de bord reste visible pendant la saisie.";
|
||||
"whats_new_170_biometrics_title" = "Face ID, Touch ID ou Optic ID";
|
||||
"whats_new_170_biometrics_body" = "Le verrouillage de l'app et la confidentialité des soldes utilisent la biométrie de votre appareil.";
|
||||
|
||||
@@ -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,211 @@
|
||||
"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";
|
||||
|
||||
/* MARK: - Goal scope (category) */
|
||||
"goal_scope_section" = "Ambito";
|
||||
"goal_scope_category" = "Categoria";
|
||||
"goal_scope_all_categories" = "Tutte le categorie";
|
||||
"goal_scope_footer_all" = "Tutte le fonti del tuo portafoglio contano per questo obiettivo.";
|
||||
"goal_scope_footer_category" = "Solo le fonti di questa categoria contano per questo obiettivo.";
|
||||
|
||||
// MARK: - Biometrics (1.7.0 — Face ID / Touch ID / Optic ID aware)
|
||||
"biometric_generic_name" = "Face ID o Touch ID";
|
||||
"biometric_unlock_with" = "Sblocca con %@";
|
||||
"biometric_failed" = "%@ non riuscito. Riprova.";
|
||||
"biometric_enable" = "Attiva %@";
|
||||
"biometric_unavailable_title" = "%@ non disponibile";
|
||||
"biometric_unavailable_body" = "%@ non è disponibile su questo dispositivo.";
|
||||
"biometric_pin_required" = "Imposta un PIN prima di attivare %@.";
|
||||
"biometric_disable_first_title" = "Disattiva prima %@";
|
||||
"biometric_disable_first_body" = "Disattiva %@ prima di disattivare il PIN.";
|
||||
"biometric_pin_to_disable" = "Inserisci il PIN per disattivare %@";
|
||||
"biometric_require_to_reveal" = "Richiedi %@ per mostrare";
|
||||
"biometric_lock_footer" = "Proteggi i tuoi dati con %@ o un PIN a 4 cifre.";
|
||||
"privacy_footer_biometric" = "Nasconde tutti gli importi dietro una sfocatura. Ideale per usare l'app in pubblico o fare screenshot. Facoltativamente richiedi %@ per mostrarli.";
|
||||
|
||||
// MARK: - What's New 1.7.0
|
||||
"whats_new_170_duo_title" = "Pronta per iPhone Duo";
|
||||
"whats_new_170_duo_body" = "Sullo schermo interno, Fonti e Diario mostrano elenco e dettaglio affiancati, Grafici ottiene una barra laterale e più altezza, e non perdi la posizione aprendo o chiudendo il telefono.";
|
||||
"whats_new_170_inspector_title" = "Aggiornamento rapido accanto ai grafici";
|
||||
"whats_new_170_inspector_body" = "Sugli schermi ampi l'aggiornamento rapido mensile si apre in un pannello laterale: la dashboard resta visibile mentre inserisci i valori.";
|
||||
"whats_new_170_biometrics_title" = "Face ID, Touch ID o Optic ID";
|
||||
"whats_new_170_biometrics_body" = "Blocco app e privacy dei saldi usano la biometria disponibile sul tuo dispositivo.";
|
||||
|
||||
@@ -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,211 @@
|
||||
"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" = "最初の投資先を追加";
|
||||
|
||||
/* MARK: - Goal scope (category) */
|
||||
"goal_scope_section" = "対象範囲";
|
||||
"goal_scope_category" = "カテゴリ";
|
||||
"goal_scope_all_categories" = "すべてのカテゴリ";
|
||||
"goal_scope_footer_all" = "ポートフォリオのすべての投資先がこの目標に加算されます。";
|
||||
"goal_scope_footer_category" = "このカテゴリの投資先のみがこの目標に加算されます。";
|
||||
|
||||
// MARK: - Biometrics (1.7.0 — Face ID / Touch ID / Optic ID aware)
|
||||
"biometric_generic_name" = "Face IDまたはTouch ID";
|
||||
"biometric_unlock_with" = "%@でロック解除";
|
||||
"biometric_failed" = "%@に失敗しました。もう一度お試しください。";
|
||||
"biometric_enable" = "%@を有効にする";
|
||||
"biometric_unavailable_title" = "%@は利用できません";
|
||||
"biometric_unavailable_body" = "このデバイスでは%@を利用できません。";
|
||||
"biometric_pin_required" = "%@を有効にする前にPINを設定してください。";
|
||||
"biometric_disable_first_title" = "先に%@を無効にしてください";
|
||||
"biometric_disable_first_body" = "PINを無効にする前に%@を無効にしてください。";
|
||||
"biometric_pin_to_disable" = "%@を無効にするにはPINを入力";
|
||||
"biometric_require_to_reveal" = "表示に%@を要求";
|
||||
"biometric_lock_footer" = "%@または4桁のPINでデータを保護します。";
|
||||
"privacy_footer_biometric" = "すべての金額をぼかして隠します。人前での利用やスクリーンショットに最適。表示に%@を要求することもできます。";
|
||||
|
||||
// MARK: - What's New 1.7.0
|
||||
"whats_new_170_duo_title" = "iPhone Duoに対応";
|
||||
"whats_new_170_duo_body" = "内側の画面では、投資先とジャーナルがリストと詳細を並べて表示し、グラフにはサイドバーと高さが加わり、本体を開閉しても表示位置が保たれます。";
|
||||
"whats_new_170_inspector_title" = "グラフの横でクイック更新";
|
||||
"whats_new_170_inspector_body" = "広い画面では月次のクイック更新がサイドパネルで開き、値を入力している間もダッシュボードが見えたままです。";
|
||||
"whats_new_170_biometrics_title" = "Face ID、Touch ID、Optic ID";
|
||||
"whats_new_170_biometrics_body" = "アプリロックと残高のプライバシーが、デバイスの生体認証の種類に合わせて動作します。";
|
||||
|
||||
@@ -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,211 @@
|
||||
"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";
|
||||
|
||||
/* MARK: - Goal scope (category) */
|
||||
"goal_scope_section" = "Escopo";
|
||||
"goal_scope_category" = "Categoria";
|
||||
"goal_scope_all_categories" = "Todas as categorias";
|
||||
"goal_scope_footer_all" = "Todas as fontes da sua carteira contam para esta meta.";
|
||||
"goal_scope_footer_category" = "Apenas as fontes desta categoria contam para esta meta.";
|
||||
|
||||
// MARK: - Biometrics (1.7.0 — Face ID / Touch ID / Optic ID aware)
|
||||
"biometric_generic_name" = "Face ID ou Touch ID";
|
||||
"biometric_unlock_with" = "Desbloquear com %@";
|
||||
"biometric_failed" = "%@ falhou. Tente novamente.";
|
||||
"biometric_enable" = "Ativar %@";
|
||||
"biometric_unavailable_title" = "%@ indisponível";
|
||||
"biometric_unavailable_body" = "%@ não está disponível neste dispositivo.";
|
||||
"biometric_pin_required" = "Defina um PIN antes de ativar o %@.";
|
||||
"biometric_disable_first_title" = "Desative o %@ primeiro";
|
||||
"biometric_disable_first_body" = "Desative o %@ antes de desativar seu PIN.";
|
||||
"biometric_pin_to_disable" = "Digite o PIN para desativar o %@";
|
||||
"biometric_require_to_reveal" = "Exigir %@ para mostrar";
|
||||
"biometric_lock_footer" = "Use %@ ou um PIN de 4 dígitos para proteger seus dados.";
|
||||
"privacy_footer_biometric" = "Oculta todos os valores atrás de um desfoque. Ótimo para usar o app em público ou tirar prints. Opcionalmente, exija %@ para revelá-los.";
|
||||
|
||||
// MARK: - What's New 1.7.0
|
||||
"whats_new_170_duo_title" = "Pronto para iPhone Duo";
|
||||
"whats_new_170_duo_body" = "Na tela interna, Fontes e Diário mostram lista e detalhe lado a lado, Gráficos ganha uma barra lateral e mais altura, e você não perde o lugar ao abrir ou fechar o aparelho.";
|
||||
"whats_new_170_inspector_title" = "Atualização rápida ao lado dos gráficos";
|
||||
"whats_new_170_inspector_body" = "Em telas largas a atualização rápida mensal abre em um painel lateral: o painel principal continua visível enquanto você digita valores.";
|
||||
"whats_new_170_biometrics_title" = "Face ID, Touch ID ou Optic ID";
|
||||
"whats_new_170_biometrics_body" = "O bloqueio do app e a privacidade de saldos usam a biometria disponível no seu aparelho.";
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import AppIntents
|
||||
import Foundation
|
||||
import CoreData
|
||||
|
||||
// MARK: - App Intents (Siri / Shortcuts / Spotlight)
|
||||
//
|
||||
@@ -59,14 +60,14 @@ struct SourceEntity: AppEntity {
|
||||
|
||||
struct SourceEntityQuery: EntityQuery {
|
||||
func entities(for identifiers: [UUID]) async throws -> [SourceEntity] {
|
||||
SharedQuickUpdateStore.readMirror()
|
||||
await MainActor.run { SharedQuickUpdateStore.readMirror() }
|
||||
.filter { identifiers.contains($0.id) }
|
||||
.map(SourceEntity.init)
|
||||
}
|
||||
|
||||
func suggestedEntities() async throws -> [SourceEntity] {
|
||||
// Pending-first, mirroring the Share Extension's ordering.
|
||||
SharedQuickUpdateStore.readMirror()
|
||||
await MainActor.run { SharedQuickUpdateStore.readMirror() }
|
||||
.sorted { a, b in
|
||||
if a.updatedThisMonth != b.updatedThisMonth { return !a.updatedThisMonth }
|
||||
return a.name.localizedCaseInsensitiveCompare(b.name) == .orderedAscending
|
||||
@@ -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() {
|
||||
|
||||
@@ -124,7 +124,7 @@ struct ChartShareCardView<Content: View>: View {
|
||||
.font(.headline)
|
||||
Text("\(title) · \(Self.dateLabel)")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
@@ -138,15 +138,15 @@ struct ChartShareCardView<Content: View>: View {
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(stats[i].label)
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(stats[i].value)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundColor(stats[i].color)
|
||||
.foregroundStyle(stats[i].color)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
.background(Color(.systemGray6))
|
||||
.cornerRadius(8)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
@@ -161,7 +161,7 @@ struct ChartShareCardView<Content: View>: View {
|
||||
.font(.footnote.weight(.medium))
|
||||
Text(verbatim: "portfoliojournal.app")
|
||||
.font(.footnote)
|
||||
.foregroundColor(.appPrimary)
|
||||
.foregroundStyle(Color.appPrimary)
|
||||
}
|
||||
Spacer()
|
||||
if let qrCodeImage {
|
||||
@@ -173,7 +173,7 @@ struct ChartShareCardView<Content: View>: View {
|
||||
.frame(width: 64, height: 64)
|
||||
Text(String(localized: "chart_share_scan"))
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,55 @@ 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,
|
||||
"category": goal.category?.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,24 @@ class ImportService {
|
||||
let notes: String?
|
||||
}
|
||||
|
||||
struct ImportedGoal {
|
||||
let name: String
|
||||
let targetAmount: Decimal?
|
||||
let targetDate: Date?
|
||||
let isActive: Bool
|
||||
let accountName: String?
|
||||
let categoryName: 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 +119,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 +140,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 +155,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 +171,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 +203,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 +227,211 @@ 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] }
|
||||
let resolvedCategory = importedGoal.categoryName.flatMap {
|
||||
resolveExistingCategory(named: $0, lookup: categoryLookup)
|
||||
}
|
||||
if fetchGoal(
|
||||
named: trimmedName,
|
||||
account: resolvedAccount,
|
||||
category: resolvedCategory,
|
||||
in: context
|
||||
) == nil {
|
||||
let key = "\(trimmedName.lowercased())|\(resolvedAccount?.name ?? "")|\(resolvedCategory?.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 +491,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 +546,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 +570,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 +625,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 +658,72 @@ 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
|
||||
let categoryName = goalDict["category"] as? String
|
||||
return ImportedGoal(
|
||||
name: name,
|
||||
targetAmount: targetAmount,
|
||||
targetDate: targetDate,
|
||||
isActive: isActive,
|
||||
accountName: accountName,
|
||||
categoryName: categoryName
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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 +731,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 +761,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 +778,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 +798,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 +818,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 +846,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 +866,56 @@ 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] }
|
||||
let resolvedCategory = importedGoal.categoryName.flatMap {
|
||||
resolveExistingCategory(named: $0, lookup: categoryLookup)
|
||||
}
|
||||
if fetchGoal(
|
||||
named: trimmedName,
|
||||
account: resolvedAccount,
|
||||
category: resolvedCategory,
|
||||
in: context
|
||||
) == nil {
|
||||
let goal = Goal(context: context)
|
||||
goal.name = trimmedName
|
||||
goal.targetAmount = NSDecimalNumber(decimal: targetAmount)
|
||||
goal.targetDate = importedGoal.targetDate
|
||||
goal.account = resolvedAccount
|
||||
goal.category = resolvedCategory
|
||||
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 +924,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 +1022,42 @@ 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 scope
|
||||
/// (account + category).
|
||||
private func fetchGoal(
|
||||
named name: String,
|
||||
account: Account?,
|
||||
category: Category?,
|
||||
in context: NSManagedObjectContext
|
||||
) -> Goal? {
|
||||
let request: NSFetchRequest<Goal> = Goal.fetchRequest()
|
||||
var predicates: [NSPredicate] = [NSPredicate(format: "name ==[c] %@", name)]
|
||||
if let account = account {
|
||||
predicates.append(NSPredicate(format: "account == %@", account))
|
||||
} else {
|
||||
predicates.append(NSPredicate(format: "account == nil"))
|
||||
}
|
||||
if let category = category {
|
||||
predicates.append(NSPredicate(format: "category == %@", category))
|
||||
} else {
|
||||
predicates.append(NSPredicate(format: "category == nil"))
|
||||
}
|
||||
request.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: predicates)
|
||||
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 +1290,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,186 @@ 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 = goal.relevantSources(from: 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 {
|
||||
|
||||
@@ -15,18 +15,18 @@ final class ReviewPromptService {
|
||||
private let minDaysBetweenPrompts = 30
|
||||
private let userDefaults: UserDefaults
|
||||
private let dateProvider: () -> Date
|
||||
private let reviewRequestHandler: () -> Void
|
||||
private let reviewRequestHandler: @MainActor () -> Void
|
||||
|
||||
private init() {
|
||||
userDefaults = .standard
|
||||
dateProvider = Date.init
|
||||
reviewRequestHandler = ReviewPromptService.defaultReviewRequestHandler
|
||||
reviewRequestHandler = { ReviewPromptService.defaultReviewRequestHandler() }
|
||||
}
|
||||
|
||||
init(
|
||||
userDefaults: UserDefaults,
|
||||
dateProvider: @escaping () -> Date,
|
||||
reviewRequestHandler: @escaping () -> Void
|
||||
reviewRequestHandler: @escaping @MainActor () -> Void
|
||||
) {
|
||||
self.userDefaults = userDefaults
|
||||
self.dateProvider = dateProvider
|
||||
@@ -93,6 +93,7 @@ final class ReviewPromptService {
|
||||
userDefaults.set(0, forKey: checkInCountKey)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private static func defaultReviewRequestHandler() {
|
||||
guard let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene else { return }
|
||||
if #available(iOS 18.0, *) {
|
||||
|
||||
@@ -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,196 @@
|
||||
import Foundation
|
||||
import CoreData
|
||||
import WatchConnectivity
|
||||
|
||||
/// Ships a `WatchPortfolioSnapshot` from the iPhone to the paired Apple Watch.
|
||||
///
|
||||
/// The watch app and its complications are read-only, so a single application
|
||||
/// context — always the latest state, coalesced by the system — is enough. The
|
||||
/// watch may also ask for a fresh copy (`WatchSyncMessage.requestSnapshot`) when it opens
|
||||
/// while the phone is reachable.
|
||||
@MainActor
|
||||
final class WatchSyncService: NSObject {
|
||||
|
||||
static let shared = WatchSyncService()
|
||||
|
||||
/// Largest holdings sent to the watch. A watch screen shows a handful at
|
||||
/// most, and the payload must stay small.
|
||||
private let maxSources = 6
|
||||
private let maxGoals = 5
|
||||
|
||||
private var session: WCSession? {
|
||||
WCSession.isSupported() ? WCSession.default : nil
|
||||
}
|
||||
|
||||
/// Last payload actually handed to WatchConnectivity, so repeated data
|
||||
/// changes that produce an identical snapshot don't queue redundant work.
|
||||
private var lastSentSnapshot: WatchPortfolioSnapshot?
|
||||
private var pendingSync = false
|
||||
|
||||
private override init() {
|
||||
super.init()
|
||||
}
|
||||
|
||||
// MARK: - Lifecycle
|
||||
|
||||
func activate() {
|
||||
guard let session else { return }
|
||||
session.delegate = self
|
||||
if session.activationState != .activated {
|
||||
session.activate()
|
||||
}
|
||||
}
|
||||
|
||||
/// Coalesces the bursts of Core Data changes a single user action produces
|
||||
/// (save snapshot → repository refresh → widget refresh) into one send.
|
||||
func scheduleSync() {
|
||||
guard session != nil, !pendingSync else { return }
|
||||
pendingSync = true
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
|
||||
self?.pendingSync = false
|
||||
self?.syncNow()
|
||||
}
|
||||
}
|
||||
|
||||
func syncNow() {
|
||||
guard let session, session.activationState == .activated else { return }
|
||||
let snapshot = buildSnapshot()
|
||||
guard snapshot != lastSentSnapshot else { return }
|
||||
guard let context = snapshot.applicationContext() else { return }
|
||||
|
||||
do {
|
||||
try session.updateApplicationContext(context)
|
||||
lastSentSnapshot = snapshot
|
||||
} catch {
|
||||
// Not fatal: the watch keeps its cached copy and can pull a fresh
|
||||
// one next time it opens with the phone reachable.
|
||||
print("Watch sync failed: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Snapshot building
|
||||
|
||||
func buildSnapshot(referenceDate: Date = Date()) -> WatchPortfolioSnapshot {
|
||||
let context = CoreDataStack.shared.viewContext
|
||||
|
||||
let sourceRequest: NSFetchRequest<InvestmentSource> = InvestmentSource.fetchRequest()
|
||||
sourceRequest.predicate = NSPredicate(format: "isActive == YES")
|
||||
let sources = (try? context.fetch(sourceRequest)) ?? []
|
||||
|
||||
let totalValue = sources.reduce(Decimal.zero) { $0 + $1.latestValue }
|
||||
|
||||
// Value at the close of the previous month, per source, so the change
|
||||
// shown on the watch matches the dashboard's month change.
|
||||
let previousMonthEnd = referenceDate.startOfMonth.adding(months: -1).endOfMonth
|
||||
let previousTotal = sources.reduce(Decimal.zero) { partial, source in
|
||||
let snapshot = source.sortedSnapshotsByDateAscending.last { $0.date <= previousMonthEnd }
|
||||
return partial + (snapshot?.value?.decimalValue ?? .zero)
|
||||
}
|
||||
let monthChange = totalValue - previousTotal
|
||||
let monthChangePercent: Double = previousTotal > 0
|
||||
? NSDecimalNumber(decimal: monthChange / previousTotal).doubleValue
|
||||
: 0
|
||||
|
||||
let topSources = sources
|
||||
.sorted { $0.latestValue > $1.latestValue }
|
||||
.prefix(maxSources)
|
||||
.map { source in
|
||||
WatchPortfolioSnapshot.Source(
|
||||
id: source.id,
|
||||
name: source.name,
|
||||
categoryName: source.category?.name,
|
||||
categoryColorHex: source.category?.colorHex,
|
||||
value: NSDecimalNumber(decimal: source.latestValue).doubleValue
|
||||
)
|
||||
}
|
||||
|
||||
let goalRequest: NSFetchRequest<Goal> = Goal.fetchRequest()
|
||||
goalRequest.predicate = NSPredicate(format: "isActive == YES")
|
||||
let goals = (try? context.fetch(goalRequest)) ?? []
|
||||
let watchGoals = goals
|
||||
.map { goal -> WatchPortfolioSnapshot.Goal in
|
||||
let current = goal.relevantSources(from: sources)
|
||||
.reduce(Decimal.zero) { $0 + $1.latestValue }
|
||||
return WatchPortfolioSnapshot.Goal(
|
||||
id: goal.id,
|
||||
name: goal.name,
|
||||
categoryName: goal.category?.name,
|
||||
categoryIcon: goal.category?.icon,
|
||||
categoryColorHex: goal.category?.colorHex,
|
||||
currentValue: NSDecimalNumber(decimal: current).doubleValue,
|
||||
targetValue: NSDecimalNumber(decimal: goal.targetDecimal).doubleValue
|
||||
)
|
||||
}
|
||||
.sorted { $0.progress > $1.progress }
|
||||
.prefix(maxGoals)
|
||||
|
||||
let stats = MonthlyCheckInStore.stats(referenceDate: referenceDate)
|
||||
let dueMonth = MonthlyCheckInStore.effectiveMonth(
|
||||
for: referenceDate,
|
||||
relativeTo: referenceDate,
|
||||
graceDays: MonthlyCheckInStore.graceDays
|
||||
)
|
||||
let checkInDone = MonthlyCheckInStore.completionDate(for: dueMonth) != nil
|
||||
|
||||
return WatchPortfolioSnapshot(
|
||||
generatedAt: referenceDate,
|
||||
currencyCode: AppSettings.getOrCreate(in: context).currency,
|
||||
totalValue: NSDecimalNumber(decimal: totalValue).doubleValue,
|
||||
monthChange: NSDecimalNumber(decimal: monthChange).doubleValue,
|
||||
monthChangePercent: monthChangePercent,
|
||||
sources: Array(topSources),
|
||||
goals: Array(watchGoals),
|
||||
currentStreak: stats.currentStreak,
|
||||
bestStreak: stats.bestStreak,
|
||||
checkInDone: checkInDone,
|
||||
checkInMonthLabel: dueMonth.monthYearString
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - WCSessionDelegate
|
||||
|
||||
extension WatchSyncService: WCSessionDelegate {
|
||||
|
||||
nonisolated func session(
|
||||
_ session: WCSession,
|
||||
activationDidCompleteWith activationState: WCSessionActivationState,
|
||||
error: Error?
|
||||
) {
|
||||
guard activationState == .activated else { return }
|
||||
Task { @MainActor in
|
||||
self.syncNow()
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated func sessionDidBecomeInactive(_ session: WCSession) {}
|
||||
|
||||
/// The user switched to a different watch: reactivate so the new one gets
|
||||
/// its own context.
|
||||
nonisolated func sessionDidDeactivate(_ session: WCSession) {
|
||||
session.activate()
|
||||
}
|
||||
|
||||
nonisolated func sessionWatchStateDidChange(_ session: WCSession) {
|
||||
Task { @MainActor in
|
||||
self.lastSentSnapshot = nil
|
||||
self.syncNow()
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated func session(
|
||||
_ session: WCSession,
|
||||
didReceiveMessage message: [String: Any],
|
||||
replyHandler: @escaping ([String: Any]) -> Void
|
||||
) {
|
||||
guard message[WatchSyncMessage.requestSnapshot] != nil else {
|
||||
replyHandler([:])
|
||||
return
|
||||
}
|
||||
Task { @MainActor in
|
||||
let snapshot = self.buildSnapshot()
|
||||
self.lastSentSnapshot = snapshot
|
||||
replyHandler(snapshot.applicationContext() ?? [:])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,36 @@
|
||||
import Foundation
|
||||
import LocalAuthentication
|
||||
|
||||
/// Biometric hardware available on this device. Never assume Face ID: iPad and
|
||||
/// iPhone Duo can ship with Touch ID, Vision Pro with Optic ID.
|
||||
enum BiometryKind {
|
||||
case faceID
|
||||
case touchID
|
||||
case opticID
|
||||
case none
|
||||
|
||||
/// Marketing name shown in UI ("Face ID", "Touch ID", "Optic ID"). Falls back
|
||||
/// to a generic label when no biometry is available (e.g. not enrolled).
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .faceID: return "Face ID"
|
||||
case .touchID: return "Touch ID"
|
||||
case .opticID: return "Optic ID"
|
||||
case .none: return String(localized: "biometric_generic_name")
|
||||
}
|
||||
}
|
||||
|
||||
/// SF Symbol matching the hardware.
|
||||
var systemImage: String {
|
||||
switch self {
|
||||
case .faceID: return "faceid"
|
||||
case .touchID: return "touchid"
|
||||
case .opticID: return "opticid"
|
||||
case .none: return "lock.shield"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum AppLockService {
|
||||
static func canUseBiometrics() -> Bool {
|
||||
let context = LAContext()
|
||||
@@ -8,6 +38,24 @@ enum AppLockService {
|
||||
return context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error)
|
||||
}
|
||||
|
||||
/// The biometry type reported by the system. `biometryType` is only populated
|
||||
/// after `canEvaluatePolicy` runs, and stays set even when not enrolled.
|
||||
static var biometryKind: BiometryKind {
|
||||
let context = LAContext()
|
||||
var error: NSError?
|
||||
_ = context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error)
|
||||
switch context.biometryType {
|
||||
case .faceID: return .faceID
|
||||
case .touchID: return .touchID
|
||||
case .opticID: return .opticID
|
||||
case .none: return .none
|
||||
@unknown default: return .none
|
||||
}
|
||||
}
|
||||
|
||||
/// Localized biometry name for user-facing text.
|
||||
static var biometryName: String { biometryKind.displayName }
|
||||
|
||||
static func authenticate(reason: String, completion: @escaping (Bool) -> Void) {
|
||||
let context = LAContext()
|
||||
context.evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, localizedReason: reason) { success, _ in
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -74,7 +74,7 @@ enum DashboardLayoutStore {
|
||||
|
||||
var merged: [DashboardSectionConfig] = []
|
||||
for config in decoded {
|
||||
if let section = DashboardSection(rawValue: config.id) {
|
||||
if DashboardSection(rawValue: config.id) != nil {
|
||||
merged.append(config)
|
||||
} else {
|
||||
continue
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -167,6 +167,7 @@ extension Color {
|
||||
|
||||
// MARK: - Gradient Extensions
|
||||
|
||||
@MainActor
|
||||
extension LinearGradient {
|
||||
static let appPrimaryGradient = LinearGradient(
|
||||
colors: [Color.appPrimary, Color.appPrimary.lighter()],
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -66,7 +66,7 @@ enum MonthlyCheckInStore {
|
||||
}
|
||||
|
||||
static func entry(for date: Date) -> MonthlyCheckInEntry? {
|
||||
fetchEntry(for: monthKey(for: date)).map(makeCheckInEntry)
|
||||
fetchEntry(for: monthKey(for: date)).map { makeCheckInEntry($0) }
|
||||
}
|
||||
|
||||
static func allEntries() -> [(date: Date, entry: MonthlyCheckInEntry)] {
|
||||
@@ -183,8 +183,17 @@ enum MonthlyCheckInStore {
|
||||
let totalCheckIns = completions.count
|
||||
let onTimeCount = onTimeMonths.count
|
||||
|
||||
// The current month's check-in is still within its deadline (end of the
|
||||
// month), so having it pending must NOT read as a broken streak — that
|
||||
// made the card show 0x for most of every month. Start counting at the
|
||||
// current month when it is already done, otherwise at the previous one.
|
||||
// Any earlier month that is missing has passed its deadline and does
|
||||
// break the streak, so the walk backwards stays honest.
|
||||
var currentStreak = 0
|
||||
var cursor = referenceDate.startOfMonth
|
||||
if !onTimeMonths.contains(cursor) {
|
||||
cursor = cursor.adding(months: -1).startOfMonth
|
||||
}
|
||||
while onTimeMonths.contains(cursor) {
|
||||
currentStreak += 1
|
||||
cursor = cursor.adding(months: -1).startOfMonth
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import Foundation
|
||||
import CoreData
|
||||
|
||||
/// Decides when to ask for an App Store rating: after a "positive moment"
|
||||
/// (a saved check-in), only once the habit is established (≥2 distinct months
|
||||
/// of snapshots) and at most once per app version. Apple additionally caps
|
||||
/// the system prompt at 3 displays per year, so over-calling is safe.
|
||||
///
|
||||
/// Call sites own the actual prompt via SwiftUI's `\.requestReview` — this
|
||||
/// service only answers "should we ask now?" and records that we did.
|
||||
@MainActor
|
||||
enum ReviewRequestService {
|
||||
private static let lastVersionKey = "reviewRequest.lastVersion"
|
||||
|
||||
/// Returns true at most once per app version, and only when the user has
|
||||
/// snapshots in 2+ distinct months (they've seen the app deliver value).
|
||||
/// Marks the version as asked when returning true.
|
||||
static func shouldRequestAfterCheckIn() -> Bool {
|
||||
guard distinctSnapshotMonths() >= 2 else { return false }
|
||||
|
||||
let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "unknown"
|
||||
let defaults = UserDefaults.standard
|
||||
guard defaults.string(forKey: lastVersionKey) != version else { return false }
|
||||
|
||||
defaults.set(version, forKey: lastVersionKey)
|
||||
return true
|
||||
}
|
||||
|
||||
private static func distinctSnapshotMonths() -> Int {
|
||||
let request = NSFetchRequest<Snapshot>(entityName: "Snapshot")
|
||||
guard let snapshots = try? CoreDataStack.shared.viewContext.fetch(request) else { return 0 }
|
||||
let calendar = Calendar.current
|
||||
let months = Set(snapshots.map { snapshot in
|
||||
calendar.dateComponents([.year, .month], from: snapshot.date)
|
||||
})
|
||||
return months.count
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
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 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,8 +216,13 @@ class DashboardViewModel: ObservableObject {
|
||||
// Compute update streak
|
||||
updateStreak = computeStreak(from: evolutionData)
|
||||
|
||||
// Log screen view
|
||||
FirebaseService.shared.logScreenView(screenName: "Dashboard")
|
||||
// Detect internal data gaps (missing months between known snapshots)
|
||||
dataGaps = SnapshotGapDetector.detectGaps(sources: sources, snapshots: allSnapshots)
|
||||
|
||||
// NOTE: no screen_view logging here — refreshData() fires on every
|
||||
// relevant Core Data change (incl. iCloud imports), so logging from it
|
||||
// inflated GA4 with thousands of phantom Dashboard views from devices
|
||||
// left open. The view logs once per appearance in DashboardView.onAppear.
|
||||
}
|
||||
|
||||
private func filteredSources() -> [InvestmentSource] {
|
||||
@@ -283,51 +295,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,
|
||||
@@ -599,6 +635,29 @@ class DashboardViewModel: ObservableObject {
|
||||
let value: Decimal
|
||||
}
|
||||
|
||||
/// ETA text for a goal scoped to an account and/or a category. The dashboard
|
||||
/// evolution covers the whole (selected) portfolio, so a scoped goal — e.g.
|
||||
/// "100k in Cash" — must be projected over its own sources instead; the
|
||||
/// caller passes that projection in.
|
||||
func goalEtaText(for goal: Goal, currentValue: Decimal, projection: GoalProjection.Result) -> String? {
|
||||
if currentValue >= goal.targetDecimal {
|
||||
return "Goal reached. Keep it steady."
|
||||
}
|
||||
guard let months = projection.monthsToReach else {
|
||||
return "Keep going. Consistency pays off."
|
||||
}
|
||||
if months == 0 {
|
||||
return "Almost there. One more check-in."
|
||||
}
|
||||
if months >= 72 {
|
||||
return "Long journey, strong discipline. You're building momentum."
|
||||
}
|
||||
if let projectedDate = projection.projectedDate {
|
||||
return "Estimated: \(projectedDate.monthYearString)"
|
||||
}
|
||||
return "Estimated: \(months) months"
|
||||
}
|
||||
|
||||
func goalEtaText(for goal: Goal, currentValue: Decimal) -> String? {
|
||||
let target = goal.targetDecimal
|
||||
let lastValue = evolutionData.last?.value ?? currentValue
|
||||
@@ -661,80 +720,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 +834,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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ class GoalsViewModel: ObservableObject {
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
// MARK: - Performance: Caching
|
||||
private var cachedEvolutionData: [UUID: [(date: Date, value: Decimal)]] = [:]
|
||||
private var cachedEvolutionData: [String: [(date: Date, value: Decimal)]] = [:]
|
||||
private var cachedCompletionDates: [UUID: Date?] = [:]
|
||||
private var lastSourcesHash: Int = 0
|
||||
|
||||
@@ -94,12 +94,9 @@ class GoalsViewModel: ObservableObject {
|
||||
}
|
||||
|
||||
func totalValue(for goal: Goal) -> Decimal {
|
||||
if let accountId = goal.account?.safeId {
|
||||
return sourceRepository.sources
|
||||
.filter { $0.account?.id == accountId }
|
||||
.reduce(Decimal.zero) { $0 + $1.latestValue }
|
||||
}
|
||||
return totalValue
|
||||
guard goal.account != nil || goal.category != nil else { return totalValue }
|
||||
return relevantSources(for: goal)
|
||||
.reduce(Decimal.zero) { $0 + $1.latestValue }
|
||||
}
|
||||
|
||||
func paceStatus(for goal: Goal) -> GoalPaceStatus? {
|
||||
@@ -166,70 +163,59 @@ 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]
|
||||
if let accountId = goal.account?.safeId {
|
||||
sources = sourceRepository.sources.filter { $0.account?.id == accountId }
|
||||
} else {
|
||||
sources = sourceRepository.sources
|
||||
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] {
|
||||
goal.relevantSources(from: sourceRepository.sources)
|
||||
}
|
||||
|
||||
private func evolutionData(
|
||||
for goal: Goal,
|
||||
sources: [InvestmentSource]
|
||||
) -> [(date: Date, value: Decimal)] {
|
||||
let cacheKey = goal.scopeKey
|
||||
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 {
|
||||
@@ -236,8 +239,8 @@ class SettingsViewModel: ObservableObject {
|
||||
exportStatus = "Export complete"
|
||||
isExporting = false
|
||||
showingExportOptions = false
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in
|
||||
self?.shareItem = ShareItem(url: tempURL)
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
|
||||
self.shareItem = ShareItem(url: tempURL)
|
||||
}
|
||||
FirebaseService.shared.logExportAttempt(format: format.rawValue, success: true)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -21,11 +21,11 @@ struct AccountEditorView: View {
|
||||
if isEditingDefaultAccount {
|
||||
HStack {
|
||||
Text(Account.defaultAccountName)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
Image(systemName: "lock.fill")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
} else {
|
||||
TextField("Account name", text: $name)
|
||||
@@ -40,7 +40,7 @@ struct AccountEditorView: View {
|
||||
} footer: {
|
||||
if let errorMessage {
|
||||
Text(errorMessage)
|
||||
.foregroundColor(.negativeRed)
|
||||
.foregroundStyle(Color.negativeRed)
|
||||
} else if isEditingDefaultAccount {
|
||||
Text("The Default account name cannot be changed.")
|
||||
}
|
||||
@@ -62,10 +62,10 @@ struct AccountEditorView: View {
|
||||
.navigationTitle(account == nil ? "New Account" : "Edit Account")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
Button("Cancel") { dismiss() }
|
||||
}
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button("Save") { saveAccount() }
|
||||
.disabled(!isValid)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import SwiftUI
|
||||
import CoreData
|
||||
|
||||
struct AccountsView: View {
|
||||
@EnvironmentObject private var iapService: IAPService
|
||||
@@ -33,17 +34,17 @@ struct AccountsView: View {
|
||||
if account.isDefaultAccount {
|
||||
Image(systemName: "star.fill")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.appWarning)
|
||||
.foregroundStyle(Color.appWarning)
|
||||
}
|
||||
}
|
||||
Text(account.currencyCode ?? AppSettings.getOrCreate(in: CoreDataStack.shared.viewContext).currency)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
if accountStore.selectedAccount?.safeId == account.safeId && !accountStore.showAllAccounts {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundColor(.appPrimary)
|
||||
.foregroundStyle(Color.appPrimary)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -92,7 +93,7 @@ struct AccountsView: View {
|
||||
Text("The Default account cannot be deleted. You must keep at least one account.")
|
||||
}
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button {
|
||||
if accountStore.canAddAccount() {
|
||||
showingAddAccount = true
|
||||
|
||||
@@ -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)
|
||||
.foregroundStyle(.primary)
|
||||
|
||||
let percentage = total > 0
|
||||
? NSDecimalNumber(decimal: item.value / total).doubleValue * 100
|
||||
: 0
|
||||
Text(String(format: "%.1f%%", percentage))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.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)
|
||||
.foregroundStyle(.secondary.opacity(0.6))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.opacity(selectedSlice == nil || selectedSlice == item.category ? 1 : 0.5)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
@@ -105,18 +92,128 @@ struct AllocationPieChart: View {
|
||||
}
|
||||
} else {
|
||||
Text("No allocation data available")
|
||||
.foregroundColor(.secondary)
|
||||
.frame(height: 200)
|
||||
.foregroundStyle(.secondary)
|
||||
.chartFrame(height: 200)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.systemBackground))
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
|
||||
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
|
||||
}
|
||||
}
|
||||
|
||||
// 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))
|
||||
.foregroundStyle(.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)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
Text(c.value)
|
||||
.font(.headline)
|
||||
if let sub = c.sub {
|
||||
Text(sub)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.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 {
|
||||
@@ -146,7 +243,7 @@ struct AllocationTargetsComparisonChart: View {
|
||||
if targetData.allSatisfy({ $0.target == 0 }) {
|
||||
Text("Set allocation targets to compare your portfolio against your plan.")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
Chart {
|
||||
ForEach(targetData, id: \.category) { item in
|
||||
@@ -178,7 +275,7 @@ struct AllocationTargetsComparisonChart: View {
|
||||
AxisValueLabel()
|
||||
}
|
||||
}
|
||||
.frame(height: 220)
|
||||
.chartFrame(height: 220)
|
||||
|
||||
ForEach(targetData, id: \.category) { item in
|
||||
let drift = item.actual - item.target
|
||||
@@ -192,20 +289,20 @@ struct AllocationTargetsComparisonChart: View {
|
||||
Spacer()
|
||||
Text("Actual \(String(format: "%.1f%%", item.actual))")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
Text("Target \(String(format: "%.0f%%", item.target))")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
Text("\(prefix)\(String(format: "%.1f%%", drift))")
|
||||
.font(.caption2.weight(.semibold))
|
||||
.foregroundColor(drift >= 0 ? .positiveGreen : .negativeRed)
|
||||
.foregroundStyle(drift >= 0 ? Color.positiveGreen : Color.negativeRed)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.systemBackground))
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
|
||||
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
|
||||
}
|
||||
|
||||
@@ -254,7 +351,7 @@ struct AllocationListView: View {
|
||||
|
||||
Text(item.value.compactCurrencyString)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 70, alignment: .trailing)
|
||||
}
|
||||
|
||||
@@ -268,12 +365,12 @@ struct AllocationListView: View {
|
||||
Rectangle()
|
||||
.fill(Color.gray.opacity(0.1))
|
||||
.frame(height: 6)
|
||||
.cornerRadius(3)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 3))
|
||||
|
||||
Rectangle()
|
||||
.fill(Color(hex: item.color) ?? .gray)
|
||||
.frame(width: geometry.size.width * percentage, height: 6)
|
||||
.cornerRadius(3)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 3))
|
||||
}
|
||||
}
|
||||
.frame(height: 6)
|
||||
@@ -282,7 +379,7 @@ struct AllocationListView: View {
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.systemBackground))
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
|
||||
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ struct AllocationSimulatorView: View {
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.systemBackground))
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
|
||||
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
|
||||
}
|
||||
|
||||
@@ -29,13 +29,13 @@ struct AllocationSimulatorView: View {
|
||||
VStack(spacing: 12) {
|
||||
Image(systemName: "slider.horizontal.3")
|
||||
.font(.system(size: 36))
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
Text("No sources available")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 300)
|
||||
.chartFrame(height: 300)
|
||||
}
|
||||
|
||||
// MARK: - Allocation Card
|
||||
@@ -56,7 +56,7 @@ struct AllocationSimulatorView: View {
|
||||
}
|
||||
.padding(12)
|
||||
.background(Color(.systemGray6))
|
||||
.cornerRadius(10)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
}
|
||||
|
||||
private var totalAllocationBadge: some View {
|
||||
@@ -67,8 +67,8 @@ struct AllocationSimulatorView: View {
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 3)
|
||||
.background(isNear100 ? Color.green.opacity(0.15) : Color.orange.opacity(0.15))
|
||||
.foregroundColor(isNear100 ? .green : .orange)
|
||||
.cornerRadius(8)
|
||||
.foregroundStyle(isNear100 ? .green : .orange)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
|
||||
private var resetButton: some View {
|
||||
@@ -87,7 +87,7 @@ struct AllocationSimulatorView: View {
|
||||
} label: {
|
||||
Image(systemName: "arrow.counterclockwise")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
@@ -104,11 +104,11 @@ struct AllocationSimulatorView: View {
|
||||
Spacer()
|
||||
Text(String(format: "%.0f%%", source.simulatedPct))
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundColor(.primary)
|
||||
.foregroundStyle(.primary)
|
||||
.frame(width: 36, alignment: .trailing)
|
||||
Text(String(format: "(%.0f%%)", source.currentPct))
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Slider(
|
||||
@@ -142,8 +142,8 @@ struct AllocationSimulatorView: View {
|
||||
if viewModel.simulatorActualData.isEmpty && viewModel.simulatorData.isEmpty {
|
||||
Text("Not enough data to simulate.")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
.frame(height: 220)
|
||||
.foregroundStyle(.secondary)
|
||||
.chartFrame(height: 220)
|
||||
} else {
|
||||
simulationChart
|
||||
chartLegend
|
||||
@@ -194,7 +194,7 @@ struct AllocationSimulatorView: View {
|
||||
.foregroundStyle(Color.secondary.opacity(0.15))
|
||||
}
|
||||
}
|
||||
.frame(height: 220)
|
||||
.chartFrame(height: 220)
|
||||
.chartDrawingGroup(disabledForExport: chartImageExport)
|
||||
}
|
||||
|
||||
@@ -224,7 +224,7 @@ struct AllocationSimulatorView: View {
|
||||
}
|
||||
Text(label)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -24,16 +24,16 @@ struct ChartStatsRow: View {
|
||||
VStack(alignment: .center, spacing: 3) {
|
||||
Text(stats[i].label)
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(stats[i].value)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundColor(stats[i].color)
|
||||
.foregroundStyle(stats[i].color)
|
||||
}
|
||||
.frame(minWidth: 60)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
.background(Color(.systemGray6))
|
||||
.cornerRadius(8)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -84,28 +84,28 @@ struct ChartDataTable: View {
|
||||
Image(systemName: isExpanded ? "chevron.up" : "chevron.down")
|
||||
.font(.caption2)
|
||||
}
|
||||
.foregroundColor(.appPrimary)
|
||||
.foregroundStyle(Color.appPrimary)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
}
|
||||
}
|
||||
.background(Color(.systemGray6))
|
||||
.cornerRadius(8)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
|
||||
private var tableHeader: some View {
|
||||
HStack(spacing: 0) {
|
||||
Text("Date").font(.caption2.weight(.semibold)).foregroundColor(.secondary)
|
||||
Text("Date").font(.caption2.weight(.semibold)).foregroundStyle(.secondary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
Text(valueHeader).font(.caption2.weight(.semibold)).foregroundColor(.secondary)
|
||||
Text(valueHeader).font(.caption2.weight(.semibold)).foregroundStyle(.secondary)
|
||||
.frame(width: 72, alignment: .trailing)
|
||||
if let h = deltaPrevHeader {
|
||||
Text(h).font(.caption2.weight(.semibold)).foregroundColor(.secondary)
|
||||
Text(h).font(.caption2.weight(.semibold)).foregroundStyle(.secondary)
|
||||
.frame(width: 62, alignment: .trailing)
|
||||
}
|
||||
if let h = deltaFirstHeader {
|
||||
Text(h).font(.caption2.weight(.semibold)).foregroundColor(.secondary)
|
||||
Text(h).font(.caption2.weight(.semibold)).foregroundStyle(.secondary)
|
||||
.frame(width: 62, alignment: .trailing)
|
||||
}
|
||||
}
|
||||
@@ -115,18 +115,18 @@ struct ChartDataTable: View {
|
||||
|
||||
private func tableDataRow(_ row: ChartDataTableRow) -> some View {
|
||||
HStack(spacing: 0) {
|
||||
Text(row.label).font(.caption2).foregroundColor(.primary)
|
||||
Text(row.label).font(.caption2).foregroundStyle(.primary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
Text(row.value).font(.caption2.weight(.medium))
|
||||
.frame(width: 72, alignment: .trailing)
|
||||
if let dp = row.deltaPrev, deltaPrevHeader != nil {
|
||||
Text(dp).font(.caption2.weight(.medium))
|
||||
.foregroundColor(row.isPrevPositive ? .positiveGreen : .negativeRed)
|
||||
.foregroundStyle(row.isPrevPositive ? Color.positiveGreen : Color.negativeRed)
|
||||
.frame(width: 62, alignment: .trailing)
|
||||
}
|
||||
if let df = row.deltaFirst, deltaFirstHeader != nil {
|
||||
Text(df).font(.caption2.weight(.medium))
|
||||
.foregroundColor(row.isFirstPositive ? .positiveGreen : .negativeRed)
|
||||
.foregroundStyle(row.isFirstPositive ? Color.positiveGreen : Color.negativeRed)
|
||||
.frame(width: 62, alignment: .trailing)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -29,7 +57,7 @@ struct ChartValueBubble: View {
|
||||
var body: some View {
|
||||
Text(text)
|
||||
.font(prominent ? .caption.weight(.bold) : .caption2.weight(.semibold))
|
||||
.foregroundColor(prominent ? .white : color)
|
||||
.foregroundStyle(prominent ? .white : color)
|
||||
.padding(.horizontal, prominent ? 8 : 5)
|
||||
.padding(.vertical, prominent ? 4 : 2)
|
||||
.background(
|
||||
@@ -48,16 +76,16 @@ struct ChartSelectionCard: View {
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(title)
|
||||
.font(.caption2.weight(.semibold))
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
ForEach(Array(rows.enumerated()), id: \.offset) { _, row in
|
||||
HStack(spacing: 5) {
|
||||
Circle().fill(row.color).frame(width: 6, height: 6)
|
||||
Text(row.label)
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(row.value)
|
||||
.font(.caption2.weight(.semibold))
|
||||
.foregroundColor(.primary)
|
||||
.foregroundStyle(.primary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
.foregroundStyle(Color.appPrimary)
|
||||
Image(systemName: "chevron.up.chevron.down")
|
||||
.font(.system(size: 8, weight: .semibold))
|
||||
.foregroundStyle(.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))
|
||||
.foregroundStyle(.secondary)
|
||||
Picker(title, selection: selection) {
|
||||
ForEach(dates.indices, id: \.self) { i in
|
||||
Text(Self.optionFormatter.string(from: dates[i])).tag(i)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.wheel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -32,7 +32,7 @@ struct ComparisonChartView: View {
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.systemBackground))
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
|
||||
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
|
||||
}
|
||||
|
||||
@@ -66,7 +66,7 @@ struct ComparisonChartView: View {
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.stroke(isSelected ? chipColor : Color.clear, lineWidth: 1.5)
|
||||
)
|
||||
.cornerRadius(16)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
@@ -92,30 +92,28 @@ struct ComparisonChartView: View {
|
||||
VStack(spacing: 12) {
|
||||
Image(systemName: "chart.xyaxis.line")
|
||||
.font(.system(size: 36))
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
Text("Select 2+ sources to compare")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 260)
|
||||
.chartFrame(height: 260)
|
||||
}
|
||||
|
||||
private var noDataView: some View {
|
||||
Text("No data available for selected sources in this period.")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 260)
|
||||
.chartFrame(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)
|
||||
}
|
||||
}
|
||||
@@ -187,7 +186,7 @@ struct ComparisonChartView: View {
|
||||
.foregroundStyle(Color.secondary.opacity(0.15))
|
||||
}
|
||||
}
|
||||
.frame(height: 260)
|
||||
.chartFrame(height: 260)
|
||||
.chartDrawingGroup(disabledForExport: chartImageExport)
|
||||
|
||||
legend
|
||||
@@ -204,7 +203,7 @@ struct ComparisonChartView: View {
|
||||
.frame(width: 20, height: 3)
|
||||
Text(series.name)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,28 +28,31 @@ 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()
|
||||
|
||||
VStack(alignment: .trailing, spacing: 2) {
|
||||
Text("Max Drawdown")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(String(format: "%.1f%%", maxDrawdown))
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundColor(.negativeRed)
|
||||
.foregroundStyle(Color.negativeRed)
|
||||
}
|
||||
}
|
||||
|
||||
Text("Shows percentage decline from peak values")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -113,7 +119,7 @@ struct DrawdownChart: View {
|
||||
}
|
||||
}
|
||||
.chartYScale(domain: (data.map { $0.drawdown }.min() ?? -50)...0)
|
||||
.frame(height: 250)
|
||||
.chartFrame(height: 250)
|
||||
.zoomableTimeSeries(dates: data.map(\.date), zoom: $zoom)
|
||||
|
||||
// Statistics
|
||||
@@ -146,14 +152,14 @@ struct DrawdownChart: View {
|
||||
.padding(.top, 4)
|
||||
} else {
|
||||
Text("Not enough data for drawdown analysis")
|
||||
.foregroundColor(.secondary)
|
||||
.frame(height: 250)
|
||||
.foregroundStyle(.secondary)
|
||||
.chartFrame(height: 250)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.systemBackground))
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
|
||||
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
|
||||
}
|
||||
|
||||
@@ -191,11 +197,11 @@ struct DrawdownStatView: View {
|
||||
VStack(spacing: 4) {
|
||||
Text(title)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Text(value)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundColor(isHighlighted ? .negativeRed : .primary)
|
||||
.foregroundStyle(isHighlighted ? Color.negativeRed : .primary)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
@@ -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,20 +236,23 @@ 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")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
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))
|
||||
}
|
||||
}
|
||||
@@ -281,7 +293,7 @@ struct VolatilityChartView: View {
|
||||
.annotation(position: .top, alignment: .leading) {
|
||||
Text("Avg: \(String(format: "%.1f%%", averageVolatility))")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
if let sel = selectedPoint {
|
||||
@@ -316,7 +328,7 @@ struct VolatilityChartView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(height: 250)
|
||||
.chartFrame(height: 250)
|
||||
.zoomableTimeSeries(dates: data.map(\.date), zoom: $zoom)
|
||||
|
||||
ChartStatsRow(stats: [
|
||||
@@ -334,14 +346,14 @@ struct VolatilityChartView: View {
|
||||
)
|
||||
} else {
|
||||
Text("Not enough data for volatility analysis")
|
||||
.foregroundColor(.secondary)
|
||||
.frame(height: 250)
|
||||
.foregroundStyle(.secondary)
|
||||
.chartFrame(height: 250)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.systemBackground))
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
|
||||
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
|
||||
}
|
||||
|
||||
@@ -388,7 +400,7 @@ struct VolatilityLevelView: View {
|
||||
.font(.caption2)
|
||||
Text(range)
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
@@ -12,83 +22,151 @@ struct PerformanceBarChart: View {
|
||||
|
||||
Text("Compound Annual Growth Rate (CAGR)")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
if !data.isEmpty {
|
||||
ChartStatsRow(stats: perfStats)
|
||||
}
|
||||
|
||||
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)
|
||||
} else {
|
||||
Text("No performance data available")
|
||||
.foregroundColor(.secondary)
|
||||
.frame(height: 250)
|
||||
.foregroundStyle(.secondary)
|
||||
.chartFrame(height: 250)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.systemBackground))
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
|
||||
.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)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 4))
|
||||
.annotation(position: item.cagr >= 0 ? .top : .bottom) {
|
||||
Text(String(format: "%.1f%%", item.cagr))
|
||||
.font(.caption2)
|
||||
.foregroundStyle(item.cagr >= 0 ? Color.positiveGreen : Color.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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.chartFrame(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)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 4))
|
||||
.annotation(position: item.cagr >= 0 ? .trailing : .leading) {
|
||||
Text(String(format: "%.1f%%", item.cagr))
|
||||
.font(.caption2)
|
||||
.foregroundStyle(item.cagr >= 0 ? Color.positiveGreen : Color.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))
|
||||
.foregroundStyle(item.cagr >= 0 ? Color.positiveGreen : Color.negativeRed)
|
||||
|
||||
if onDrillDown != nil {
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption2.weight(.semibold))
|
||||
.foregroundStyle(.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) {
|
||||
@@ -131,7 +213,7 @@ struct HorizontalPerformanceChart: View {
|
||||
|
||||
Text(String(format: "%.2f%%", item.cagr))
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundColor(item.cagr >= 0 ? .positiveGreen : .negativeRed)
|
||||
.foregroundStyle(item.cagr >= 0 ? Color.positiveGreen : Color.negativeRed)
|
||||
}
|
||||
|
||||
GeometryReader { geometry in
|
||||
@@ -142,12 +224,12 @@ struct HorizontalPerformanceChart: View {
|
||||
Rectangle()
|
||||
.fill(Color.gray.opacity(0.1))
|
||||
.frame(height: 8)
|
||||
.cornerRadius(4)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 4))
|
||||
|
||||
Rectangle()
|
||||
.fill(item.cagr >= 0 ? Color.positiveGreen : Color.negativeRed)
|
||||
.frame(width: barWidth, height: 8)
|
||||
.cornerRadius(4)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 4))
|
||||
}
|
||||
}
|
||||
.frame(height: 8)
|
||||
@@ -156,7 +238,7 @@ struct HorizontalPerformanceChart: View {
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.systemBackground))
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
|
||||
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
@@ -32,7 +28,7 @@ struct PeriodComparisonChartView: View {
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.systemBackground))
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
|
||||
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
|
||||
}
|
||||
|
||||
@@ -53,7 +49,7 @@ struct PeriodComparisonChartView: View {
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
)
|
||||
.cornerRadius(12)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.stroke(Color.secondary.opacity(0.1), lineWidth: 1)
|
||||
@@ -68,7 +64,7 @@ struct PeriodComparisonChartView: View {
|
||||
|
||||
Text(label)
|
||||
.font(.caption.weight(.bold))
|
||||
.foregroundColor(color)
|
||||
.foregroundStyle(color)
|
||||
.frame(width: 60, alignment: .leading)
|
||||
|
||||
DatePicker(
|
||||
@@ -85,7 +81,7 @@ struct PeriodComparisonChartView: View {
|
||||
|
||||
Text("→")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
DatePicker(
|
||||
"",
|
||||
@@ -136,28 +132,28 @@ struct PeriodComparisonChartView: View {
|
||||
.frame(width: 6, height: 6)
|
||||
Text(series.label)
|
||||
.font(.caption2.weight(.medium))
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
Text(String(format: "%+.2f%%", finalReturn))
|
||||
.font(.title2.weight(.bold))
|
||||
.foregroundColor(finalReturn >= 0 ? .positiveGreen : .negativeRed)
|
||||
.foregroundStyle(finalReturn >= 0 ? Color.positiveGreen : Color.negativeRed)
|
||||
HStack(spacing: 10) {
|
||||
HStack(spacing: 3) {
|
||||
Image(systemName: "arrow.up")
|
||||
.font(.caption2.weight(.semibold))
|
||||
.foregroundColor(.positiveGreen)
|
||||
.foregroundStyle(Color.positiveGreen)
|
||||
Text(String(format: "%.1f%%", maxReturn))
|
||||
.font(.caption2)
|
||||
.foregroundColor(.positiveGreen)
|
||||
.foregroundStyle(Color.positiveGreen)
|
||||
}
|
||||
HStack(spacing: 3) {
|
||||
Image(systemName: "arrow.down")
|
||||
.font(.caption2.weight(.semibold))
|
||||
.foregroundColor(.negativeRed)
|
||||
.foregroundStyle(Color.negativeRed)
|
||||
Text(String(format: "%.1f%%", minReturn))
|
||||
.font(.caption2)
|
||||
.foregroundColor(.negativeRed)
|
||||
.foregroundStyle(Color.negativeRed)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -179,14 +175,14 @@ struct PeriodComparisonChartView: View {
|
||||
VStack(spacing: 12) {
|
||||
Image(systemName: "calendar.badge.clock")
|
||||
.font(.system(size: 36))
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
Text("No data available for the selected periods.")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 260)
|
||||
.chartFrame(height: 260)
|
||||
}
|
||||
|
||||
// MARK: - Flat data for Chart
|
||||
@@ -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
|
||||
@@ -297,7 +303,7 @@ struct PeriodComparisonChartView: View {
|
||||
.foregroundStyle(Color.secondary.opacity(0.15))
|
||||
}
|
||||
}
|
||||
.frame(height: 260)
|
||||
.chartFrame(height: 260)
|
||||
.chartDrawingGroup(disabledForExport: chartImageExport)
|
||||
.onChange(of: seriesIds) { _, _ in }
|
||||
}
|
||||
@@ -311,7 +317,7 @@ struct PeriodComparisonChartView: View {
|
||||
.frame(width: 20, height: 3)
|
||||
Text(series.label)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -33,10 +36,10 @@ struct PredictionChartView: View {
|
||||
VStack(alignment: .trailing, spacing: 2) {
|
||||
Text("Forecast")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(lastPrediction.formattedValue)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundColor(.appPrimary)
|
||||
.foregroundStyle(Color.appPrimary)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -44,13 +47,14 @@ struct PredictionChartView: View {
|
||||
if let algorithm = predictions.first?.algorithm {
|
||||
Text("Algorithm: \(algorithm.displayName)")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -180,7 +185,7 @@ struct PredictionChartView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(height: 280)
|
||||
.chartFrame(height: 280)
|
||||
|
||||
// Stats row
|
||||
if let currentValue = historicalData.last?.value,
|
||||
@@ -201,7 +206,7 @@ struct PredictionChartView: View {
|
||||
.frame(width: 20, height: 3)
|
||||
Text("Historical")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
HStack(spacing: 6) {
|
||||
@@ -218,7 +223,7 @@ struct PredictionChartView: View {
|
||||
)
|
||||
Text("Prediction")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
HStack(spacing: 6) {
|
||||
@@ -227,7 +232,7 @@ struct PredictionChartView: View {
|
||||
.frame(width: 20, height: 10)
|
||||
Text("Confidence")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
HStack(spacing: 6) {
|
||||
@@ -236,7 +241,7 @@ struct PredictionChartView: View {
|
||||
.frame(width: 20, height: 2)
|
||||
Text("Bull")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
HStack(spacing: 6) {
|
||||
@@ -245,7 +250,7 @@ struct PredictionChartView: View {
|
||||
.frame(width: 20, height: 2)
|
||||
Text("Bear")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -269,7 +274,7 @@ struct PredictionChartView: View {
|
||||
Spacer()
|
||||
Text(lastPrediction.formattedConfidenceRange)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
if let currentValue = historicalData.last?.value {
|
||||
@@ -288,7 +293,7 @@ struct PredictionChartView: View {
|
||||
Text(String(format: "%+.1f%%", changePercent))
|
||||
.font(.subheadline.weight(.medium))
|
||||
}
|
||||
.foregroundColor(change >= 0 ? .positiveGreen : .negativeRed)
|
||||
.foregroundStyle(change >= 0 ? Color.positiveGreen : Color.negativeRed)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -305,24 +310,24 @@ struct PredictionChartView: View {
|
||||
VStack(spacing: 12) {
|
||||
Image(systemName: "wand.and.stars")
|
||||
.font(.system(size: 40))
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Text("Not enough data for predictions")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Text("Add at least 3 snapshots to generate predictions")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
.frame(height: 280)
|
||||
.chartFrame(height: 280)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.systemBackground))
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
|
||||
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
|
||||
}
|
||||
|
||||
|
||||
@@ -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,15 +22,17 @@ 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"))
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
.frame(height: 200)
|
||||
.chartFrame(height: 200)
|
||||
} else {
|
||||
yearSelector
|
||||
if !selectedSeries.isEmpty {
|
||||
@@ -40,7 +44,7 @@ struct YearOverYearChartView: View {
|
||||
if hasEstimate {
|
||||
Text(String(localized: "chart_yoy_estimated_note"))
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
// Detalle debajo (#147)
|
||||
chartBody
|
||||
@@ -53,7 +57,7 @@ struct YearOverYearChartView: View {
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.systemBackground))
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
|
||||
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
|
||||
}
|
||||
|
||||
@@ -78,12 +82,12 @@ struct YearOverYearChartView: View {
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(isSelected ? color.opacity(0.15) : Color.gray.opacity(0.1))
|
||||
.foregroundColor(isSelected ? color : .secondary)
|
||||
.foregroundStyle(isSelected ? color : .secondary)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.stroke(isSelected ? color : Color.clear, lineWidth: 1.5)
|
||||
)
|
||||
.cornerRadius(16)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -173,11 +181,11 @@ struct YearOverYearChartView: View {
|
||||
AxisGridLine()
|
||||
}
|
||||
}
|
||||
.frame(height: 220)
|
||||
.chartFrame(height: 220)
|
||||
} else {
|
||||
Text("iOS 16+ required for chart")
|
||||
.foregroundColor(.secondary)
|
||||
.frame(height: 220)
|
||||
.foregroundStyle(.secondary)
|
||||
.chartFrame(height: 220)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -194,7 +202,7 @@ struct YearOverYearChartView: View {
|
||||
.frame(width: 20, height: 3)
|
||||
Text(String(yearSeries.year))
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -213,16 +221,16 @@ struct YearOverYearChartView: View {
|
||||
VStack(alignment: .center, spacing: 3) {
|
||||
Text(String(yearSeries.year))
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(String(format: "%+.1f%%", end.value) + (end.estimated ? "*" : ""))
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundColor(end.value >= 0 ? color : .negativeRed)
|
||||
.foregroundStyle(end.value >= 0 ? color : Color.negativeRed)
|
||||
}
|
||||
.frame(minWidth: 60)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
.background(Color(.systemGray6))
|
||||
.cornerRadius(8)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - Chart height scaling
|
||||
//
|
||||
// Charts declare a base height for compact width. Containers with a wider
|
||||
// canvas (regular width: iPad, iPhone Duo inner screen, Mac windows) raise the
|
||||
// scale so plots gain vertical room instead of just stretching horizontally.
|
||||
|
||||
extension EnvironmentValues {
|
||||
@Entry var chartHeightScale: CGFloat = 1
|
||||
}
|
||||
|
||||
private struct ChartFrameModifier: ViewModifier {
|
||||
@Environment(\.chartHeightScale) private var scale
|
||||
let base: CGFloat
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content.frame(height: base * scale)
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
/// Fixed chart height that follows `chartHeightScale` from the environment.
|
||||
func chartFrame(height: CGFloat) -> some View {
|
||||
modifier(ChartFrameModifier(base: height))
|
||||
}
|
||||
|
||||
/// Floating label background: Liquid Glass on iOS 26+, material fallback.
|
||||
@ViewBuilder
|
||||
func floatingPillBackground() -> some View {
|
||||
if #available(iOS 26.0, *) {
|
||||
self.glassEffect(.regular, in: .capsule)
|
||||
} else {
|
||||
self
|
||||
.background(.regularMaterial, in: Capsule())
|
||||
.shadow(color: .black.opacity(0.12), radius: 6, y: 2)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@ struct LoadingView: View {
|
||||
|
||||
Text(message)
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(Color(.systemBackground))
|
||||
@@ -30,14 +30,14 @@ struct AppLaunchLoadingView: View {
|
||||
.frame(width: 140, height: 140)
|
||||
.padding(16)
|
||||
.background(Color.appPrimary.opacity(0.08))
|
||||
.cornerRadius(28)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 28))
|
||||
|
||||
ProgressView()
|
||||
.scaleEffect(1.2)
|
||||
|
||||
Text(messageKey)
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
@@ -82,27 +82,27 @@ struct SkeletonCardView: View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
SkeletonView()
|
||||
.frame(width: 100, height: 16)
|
||||
.cornerRadius(4)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 4))
|
||||
|
||||
SkeletonView()
|
||||
.frame(height: 32)
|
||||
.cornerRadius(4)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 4))
|
||||
|
||||
HStack {
|
||||
SkeletonView()
|
||||
.frame(width: 80, height: 14)
|
||||
.cornerRadius(4)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 4))
|
||||
|
||||
Spacer()
|
||||
|
||||
SkeletonView()
|
||||
.frame(width: 60, height: 14)
|
||||
.cornerRadius(4)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 4))
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.systemBackground))
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,14 +119,14 @@ struct EmptyStateView: View {
|
||||
VStack(spacing: 20) {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 60))
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Text(title)
|
||||
.font(.title2.weight(.semibold))
|
||||
|
||||
Text(message)
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, 40)
|
||||
|
||||
@@ -134,11 +134,11 @@ struct EmptyStateView: View {
|
||||
Button(action: action) {
|
||||
Text(actionTitle)
|
||||
.font(.headline)
|
||||
.foregroundColor(.white)
|
||||
.foregroundStyle(.white)
|
||||
.padding()
|
||||
.frame(maxWidth: 200)
|
||||
.background(Color.appPrimary)
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -156,14 +156,14 @@ struct ErrorView: View {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.font(.system(size: 48))
|
||||
.foregroundColor(.appWarning)
|
||||
.foregroundStyle(Color.appWarning)
|
||||
|
||||
Text("Something went wrong")
|
||||
.font(.headline)
|
||||
|
||||
Text(message)
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, 40)
|
||||
|
||||
@@ -174,8 +174,8 @@ struct ErrorView: View {
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.vertical, 10)
|
||||
.background(Color.appPrimary)
|
||||
.foregroundColor(.white)
|
||||
.cornerRadius(20)
|
||||
.foregroundStyle(.white)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 20))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -199,7 +199,7 @@ struct SuccessView: View {
|
||||
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.font(.system(size: 60))
|
||||
.foregroundColor(.positiveGreen)
|
||||
.foregroundStyle(Color.positiveGreen)
|
||||
}
|
||||
|
||||
Text(title)
|
||||
@@ -207,7 +207,7 @@ struct SuccessView: View {
|
||||
|
||||
Text(message)
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, 40)
|
||||
|
||||
@@ -215,11 +215,11 @@ struct SuccessView: View {
|
||||
Button(action: action) {
|
||||
Text("Continue")
|
||||
.font(.headline)
|
||||
.foregroundColor(.white)
|
||||
.foregroundStyle(.white)
|
||||
.padding()
|
||||
.frame(maxWidth: 200)
|
||||
.background(Color.appPrimary)
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -255,14 +255,14 @@ struct ToastView: View {
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: type.icon)
|
||||
.foregroundColor(type.color)
|
||||
.foregroundStyle(type.color)
|
||||
|
||||
Text(message)
|
||||
.font(.subheadline)
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.systemBackground))
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
|
||||
.shadow(color: .black.opacity(0.1), radius: 10, y: 5)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
.foregroundStyle(tint)
|
||||
.minimumScaleFactor(0.7)
|
||||
}
|
||||
.frame(width: size, height: size)
|
||||
.accessibilityLabel(Text("\(Int((clamped * 100).rounded()))%"))
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,7 @@ struct CategoryBreakdownCard: View {
|
||||
} label: {
|
||||
Text("See All")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.appPrimary)
|
||||
.foregroundStyle(Color.appPrimary)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ struct CategoryBreakdownCard: View {
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.systemBackground))
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
|
||||
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
|
||||
}
|
||||
}
|
||||
@@ -55,7 +55,7 @@ struct CategoryRowView: View {
|
||||
|
||||
Image(systemName: category.icon)
|
||||
.font(.system(size: 14))
|
||||
.foregroundColor(Color(hex: category.colorHex) ?? .gray)
|
||||
.foregroundStyle(Color(hex: category.colorHex) ?? .gray)
|
||||
}
|
||||
|
||||
// Name and percentage
|
||||
@@ -65,12 +65,12 @@ struct CategoryRowView: View {
|
||||
|
||||
Text(category.formattedPercentage)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
if let target = targetPercentage {
|
||||
Text("Target \(String(format: "%.0f%%", target)) | Drift \(driftText ?? "")")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,10 +84,10 @@ struct CategoryRowView: View {
|
||||
HStack(spacing: 4) {
|
||||
Text("CAGR")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(category.metrics.formattedCAGR)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundColor(category.metrics.cagr >= 0 ? .positiveGreen : .negativeRed)
|
||||
.foregroundStyle(category.metrics.cagr >= 0 ? Color.positiveGreen : Color.negativeRed)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -122,7 +122,7 @@ struct CategoryProgressBar: View {
|
||||
|
||||
Text(category.formattedPercentage)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
GeometryReader { geometry in
|
||||
@@ -130,12 +130,12 @@ struct CategoryProgressBar: View {
|
||||
Rectangle()
|
||||
.fill(Color.gray.opacity(0.1))
|
||||
.frame(height: 6)
|
||||
.cornerRadius(3)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 3))
|
||||
|
||||
Rectangle()
|
||||
.fill(Color(hex: category.colorHex) ?? .gray)
|
||||
.frame(width: geometry.size.width * progress, height: 6)
|
||||
.cornerRadius(3)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 3))
|
||||
}
|
||||
}
|
||||
.frame(height: 6)
|
||||
@@ -166,7 +166,7 @@ struct SimpleCategoryList: View {
|
||||
|
||||
Text("(\(category.formattedPercentage))")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
.foregroundStyle(.orange)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(String(format: String(localized: "gaps_banner_title"), totalMissingMonths))
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(.primary)
|
||||
.multilineTextAlignment(.leading)
|
||||
Text("Fill in missing data")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(14)
|
||||
.background(Color(.secondarySystemGroupedBackground))
|
||||
.clipShape(RoundedRectangle(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)
|
||||
.foregroundStyle(.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: .topBarTrailing) {
|
||||
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))
|
||||
.foregroundStyle(.orange)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 3)
|
||||
.background(Color.orange.opacity(0.12))
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
|
||||
Text(rangeLabel(gap))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.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,37 +137,173 @@ 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)
|
||||
.clipShape(RoundedRectangle(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))
|
||||
.foregroundStyle(Color.appPrimary)
|
||||
Image(systemName: "lock.circle.fill")
|
||||
.font(.system(size: 22))
|
||||
.foregroundStyle(Color.appWarning)
|
||||
.background(Circle().fill(Color(.systemBackground)))
|
||||
.offset(x: 26, y: 24)
|
||||
}
|
||||
Text(String(localized: "home_chart_locked_sub"))
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.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)
|
||||
.foregroundStyle(.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 page == .evolution {
|
||||
Button {
|
||||
showGoalLines.toggle()
|
||||
} label: {
|
||||
Image(systemName: showGoalLines ? "target" : "slash.circle")
|
||||
.foregroundStyle(.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)
|
||||
if let selected = selectedDataPoint, chartMode == .total {
|
||||
VStack(alignment: .trailing) {
|
||||
Text(selected.value.compactCurrencyString)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Text(selected.date.monthYearString)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.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).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -97,7 +325,7 @@ struct EvolutionChartCard: View {
|
||||
} else {
|
||||
Text("Not enough data to display chart")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(height: 200)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
@@ -221,7 +449,7 @@ struct EvolutionChartCard: View {
|
||||
.annotation(position: .topTrailing) {
|
||||
Text(goal.name)
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.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)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
if source.latestValue != .zero {
|
||||
VStack(spacing: 2) {
|
||||
Text(String(localized: "guided_previous_value"))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(source.latestValue.currencyString)
|
||||
.font(.title3.weight(.semibold))
|
||||
.foregroundStyle(.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))
|
||||
.clipShape(RoundedRectangle(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))
|
||||
.foregroundStyle(.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))
|
||||
}
|
||||
.foregroundStyle(positive ? Color.positiveGreen : Color.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)
|
||||
.foregroundStyle(.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)
|
||||
)
|
||||
.foregroundStyle(mood == candidate ? Color.appPrimary : .primary)
|
||||
.clipShape(RoundedRectangle(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))
|
||||
.clipShape(RoundedRectangle(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))
|
||||
.foregroundStyle(Color.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))
|
||||
.foregroundStyle(delta >= 0 ? Color.positiveGreen : Color.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))
|
||||
.foregroundStyle(.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))
|
||||
.foregroundStyle(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)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(insight.value)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(.primary)
|
||||
.lineLimit(2)
|
||||
if let detail = insight.detail {
|
||||
Text(detail)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.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))
|
||||
.clipShape(RoundedRectangle(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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,7 +116,7 @@ struct MonthlyCheckInView: View {
|
||||
} label: {
|
||||
Image(systemName: "chevron.left")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundColor(.appPrimary)
|
||||
.foregroundStyle(Color.appPrimary)
|
||||
}
|
||||
|
||||
Text(monthLabel)
|
||||
@@ -127,13 +127,13 @@ struct MonthlyCheckInView: View {
|
||||
} label: {
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundColor(canGoToNextMonth ? .appPrimary : .secondary.opacity(0.3))
|
||||
.foregroundStyle(canGoToNextMonth ? Color.appPrimary : .secondary.opacity(0.3))
|
||||
}
|
||||
.disabled(!canGoToNextMonth)
|
||||
}
|
||||
}
|
||||
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button {
|
||||
shareMonthlyCheckIn()
|
||||
} label: {
|
||||
@@ -230,11 +230,11 @@ struct MonthlyCheckInView: View {
|
||||
)
|
||||
)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
Text("Start your first check-in anytime.")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -242,10 +242,10 @@ struct MonthlyCheckInView: View {
|
||||
if let completed = MonthlyCheckInStore.completionDate(for: referenceDate) {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundColor(.positiveGreen)
|
||||
.foregroundStyle(Color.positiveGreen)
|
||||
Text("Completed \(completed.friendlyDescription)")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -272,12 +272,12 @@ struct MonthlyCheckInView: View {
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 10)
|
||||
.background(isCompleted ? Color.appSecondary.opacity(0.1) : Color.appPrimary.opacity(0.1))
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.systemBackground))
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
|
||||
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
|
||||
}
|
||||
|
||||
@@ -310,7 +310,7 @@ struct MonthlyCheckInView: View {
|
||||
Spacer()
|
||||
Text("Optional")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
@@ -326,7 +326,7 @@ struct MonthlyCheckInView: View {
|
||||
} label: {
|
||||
Image(systemName: value <= starRating ? "star.fill" : "star")
|
||||
.font(.title3)
|
||||
.foregroundColor(value <= starRating ? .appSecondary : .secondary)
|
||||
.foregroundStyle(value <= starRating ? Color.appSecondary : .secondary)
|
||||
.padding(8)
|
||||
.background(
|
||||
Circle()
|
||||
@@ -346,7 +346,7 @@ struct MonthlyCheckInView: View {
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 6)
|
||||
.background(Color.gray.opacity(0.12))
|
||||
.cornerRadius(AppConstants.UI.smallCornerRadius)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.smallCornerRadius))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -373,7 +373,7 @@ struct MonthlyCheckInView: View {
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.systemBackground))
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
|
||||
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
|
||||
}
|
||||
|
||||
@@ -386,7 +386,7 @@ struct MonthlyCheckInView: View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Starting")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(viewModel.monthlySummary.formattedStartingValue)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
}
|
||||
@@ -394,7 +394,7 @@ struct MonthlyCheckInView: View {
|
||||
VStack(alignment: .trailing, spacing: 4) {
|
||||
Text("Ending")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(viewModel.monthlySummary.formattedEndingValue)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
}
|
||||
@@ -404,7 +404,7 @@ struct MonthlyCheckInView: View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Contributions")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(viewModel.monthlySummary.formattedContributions)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
}
|
||||
@@ -412,16 +412,16 @@ struct MonthlyCheckInView: View {
|
||||
VStack(alignment: .trailing, spacing: 4) {
|
||||
Text("Net Performance")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
Text("\(viewModel.monthlySummary.formattedNetPerformance) (\(viewModel.monthlySummary.formattedNetPerformancePercentage))")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundColor(viewModel.monthlySummary.netPerformance >= 0 ? .positiveGreen : .negativeRed)
|
||||
.foregroundStyle(viewModel.monthlySummary.netPerformance >= 0 ? Color.positiveGreen : Color.negativeRed)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.systemBackground))
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
|
||||
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
|
||||
}
|
||||
|
||||
@@ -489,7 +489,7 @@ struct MonthlyCheckInView: View {
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.systemBackground))
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
|
||||
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
|
||||
}
|
||||
}
|
||||
@@ -497,11 +497,11 @@ struct MonthlyCheckInView: View {
|
||||
private func highlightRow(icon: String, iconColor: Color, label: String, name: String, percentage: Double, diff: Decimal, valueColor: Color) -> some View {
|
||||
HStack {
|
||||
Image(systemName: icon)
|
||||
.foregroundColor(iconColor)
|
||||
.foregroundStyle(iconColor)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(label)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(name)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
}
|
||||
@@ -509,10 +509,10 @@ struct MonthlyCheckInView: View {
|
||||
VStack(alignment: .trailing, spacing: 2) {
|
||||
Text(String(format: "%+.1f%%", percentage))
|
||||
.font(.subheadline.weight(.bold))
|
||||
.foregroundColor(valueColor)
|
||||
.foregroundStyle(valueColor)
|
||||
Text("(\(diff.compactCurrencyString))")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -529,26 +529,30 @@ struct MonthlyCheckInView: View {
|
||||
} label: {
|
||||
Label("Batch Update", systemImage: "square.and.pencil")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundColor(.appPrimary)
|
||||
.foregroundStyle(Color.appPrimary)
|
||||
}
|
||||
}
|
||||
Text("\(viewModel.sources.count)")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
if viewModel.sources.isEmpty {
|
||||
Text("Add sources to start your monthly check-in.")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.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 }
|
||||
@@ -571,7 +575,7 @@ struct MonthlyCheckInView: View {
|
||||
.font(.subheadline.weight(.medium))
|
||||
Text(updatedThisCycle ? "Updated this cycle" : "Needs update")
|
||||
.font(.caption2)
|
||||
.foregroundColor(updatedThisCycle ? .positiveGreen : .secondary)
|
||||
.foregroundStyle(updatedThisCycle ? Color.positiveGreen : .secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
@@ -579,16 +583,16 @@ struct MonthlyCheckInView: View {
|
||||
if let diff = valueDiff, updatedThisCycle {
|
||||
Text(diff >= 0 ? "+\(diff.compactCurrencyString)" : diff.compactCurrencyString)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundColor(diff >= 0 ? .positiveGreen : .negativeRed)
|
||||
.foregroundStyle(diff >= 0 ? Color.positiveGreen : Color.negativeRed)
|
||||
}
|
||||
|
||||
Text(latestSnapshot?.date.relativeDayDescription ?? String(localized: "date_never"))
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
@@ -597,7 +601,7 @@ struct MonthlyCheckInView: View {
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.systemBackground))
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
|
||||
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
|
||||
}
|
||||
|
||||
@@ -610,7 +614,7 @@ struct MonthlyCheckInView: View {
|
||||
.frame(minHeight: 120)
|
||||
.padding(8)
|
||||
.background(Color.gray.opacity(0.08))
|
||||
.cornerRadius(12)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
.focused($noteFocused)
|
||||
.onChange(of: monthlyNote) { _, newValue in
|
||||
MonthlyCheckInStore.setNote(newValue, for: referenceDate)
|
||||
@@ -625,7 +629,7 @@ struct MonthlyCheckInView: View {
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 8)
|
||||
.background(Color.appSecondary.opacity(0.12))
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
@@ -638,13 +642,13 @@ struct MonthlyCheckInView: View {
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 8)
|
||||
.background(Color.appPrimary.opacity(0.12))
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.systemBackground))
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
|
||||
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
|
||||
}
|
||||
|
||||
@@ -656,7 +660,7 @@ struct MonthlyCheckInView: View {
|
||||
if viewModel.recentNotes.isEmpty {
|
||||
Text("No snapshot notes for this month.")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
ForEach(viewModel.recentNotes, id: \.objectID) { snapshot in
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
@@ -664,10 +668,10 @@ struct MonthlyCheckInView: View {
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Text(snapshot.notes ?? "")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(snapshot.date.friendlyDescription)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
if snapshot.id != viewModel.recentNotes.last?.id {
|
||||
@@ -678,7 +682,7 @@ struct MonthlyCheckInView: View {
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.systemBackground))
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
|
||||
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
|
||||
}
|
||||
|
||||
@@ -760,11 +764,11 @@ struct AchievementsView: View {
|
||||
)
|
||||
)
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.systemBackground))
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
|
||||
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
|
||||
}
|
||||
|
||||
@@ -781,7 +785,7 @@ struct AchievementsView: View {
|
||||
if let subtitle {
|
||||
Text(subtitle)
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
if achievements.isEmpty {
|
||||
@@ -794,7 +798,7 @@ struct AchievementsView: View {
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.systemBackground))
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
|
||||
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
|
||||
}
|
||||
|
||||
@@ -806,7 +810,7 @@ struct AchievementsView: View {
|
||||
.frame(width: 42, height: 42)
|
||||
Image(systemName: status.achievement.icon)
|
||||
.font(.headline)
|
||||
.foregroundColor(isLocked ? .secondary : .appSecondary)
|
||||
.foregroundStyle(isLocked ? .secondary : Color.appSecondary)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
@@ -814,7 +818,7 @@ struct AchievementsView: View {
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Text(status.achievement.detail)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
@@ -822,12 +826,12 @@ struct AchievementsView: View {
|
||||
if isLocked {
|
||||
Image(systemName: "lock.fill")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(10)
|
||||
.background(isLocked ? Color.gray.opacity(0.08) : Color.appSecondary.opacity(0.12))
|
||||
.cornerRadius(AppConstants.UI.smallCornerRadius)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.smallCornerRadius))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -837,19 +841,37 @@ 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
|
||||
HStack(alignment: .center, spacing: 8) {
|
||||
Image(systemName: mood.iconName)
|
||||
.font(.body)
|
||||
.foregroundColor(isSelected ? moodColor(for: mood) : .secondary)
|
||||
.foregroundStyle(isSelected ? moodColor(for: mood) : .secondary)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(mood.title)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Text(mood.detail)
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(10)
|
||||
@@ -858,7 +880,7 @@ private extension MonthlyCheckInView {
|
||||
RoundedRectangle(cornerRadius: AppConstants.UI.smallCornerRadius)
|
||||
.stroke(isSelected ? moodColor(for: mood) : Color.clear, lineWidth: 1)
|
||||
)
|
||||
.cornerRadius(AppConstants.UI.smallCornerRadius)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.smallCornerRadius))
|
||||
}
|
||||
|
||||
func moodColor(for mood: MonthlyCheckInMood) -> Color {
|
||||
@@ -918,7 +940,7 @@ struct AchievementMilestoneBar: View {
|
||||
if status.isUnlocked {
|
||||
Image(systemName: "checkmark")
|
||||
.font(.system(size: 8, weight: .bold))
|
||||
.foregroundColor(.white)
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
}
|
||||
.position(x: x, y: barY)
|
||||
@@ -967,10 +989,10 @@ struct BatchUpdateView: View {
|
||||
.navigationTitle("Batch Update")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
Button("Cancel") { dismiss() }
|
||||
}
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button("Save") { saveAll() }
|
||||
.disabled(filledCount == 0)
|
||||
.fontWeight(.semibold)
|
||||
@@ -1008,33 +1030,33 @@ struct BatchUpdateView: View {
|
||||
if let prev = previousValue {
|
||||
Text(prev.currencyString)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
HStack {
|
||||
Text(symbol)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
TextField("Current value", text: valueBinding)
|
||||
.keyboardType(.decimalPad)
|
||||
}
|
||||
.padding(8)
|
||||
.background(Color.gray.opacity(0.08))
|
||||
.cornerRadius(8)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
|
||||
if isDetailed {
|
||||
HStack {
|
||||
Image(systemName: "plus.circle")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
TextField("Contribution this period (optional)", text: contributionBinding)
|
||||
.keyboardType(.decimalPad)
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(8)
|
||||
.background(Color.appSecondary.opacity(0.06))
|
||||
.cornerRadius(8)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
|
||||
@@ -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))
|
||||
.foregroundStyle(.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))
|
||||
}
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 5)
|
||||
.background(Color.white.opacity(0.18))
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
}
|
||||
|
||||
Text("Monthly Summary")
|
||||
.font(.title.weight(.bold))
|
||||
.foregroundStyle(.white)
|
||||
.padding(.top, 6)
|
||||
|
||||
// Hero value
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Portfolio Value")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(.white.opacity(0.85))
|
||||
Text(totalValue)
|
||||
.font(.system(size: 40, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(.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)
|
||||
.foregroundStyle(.white.opacity(0.75))
|
||||
}
|
||||
.foregroundStyle(.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))
|
||||
}
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
if let rating {
|
||||
HStack(spacing: 2) {
|
||||
ForEach(1...5, id: \.self) { i in
|
||||
Image(systemName: i <= rating ? "star.fill" : "star")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.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))
|
||||
.foregroundStyle(.white.opacity(0.7))
|
||||
Text(appName)
|
||||
.font(.subheadline.weight(.bold))
|
||||
.foregroundStyle(.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,9 +1,13 @@
|
||||
import SwiftUI
|
||||
import CoreData
|
||||
import TipKit
|
||||
import UIKit
|
||||
import PhotosUI
|
||||
import StoreKit
|
||||
|
||||
struct QuickUpdateView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(\.requestReview) private var requestReview
|
||||
@Environment(\.managedObjectContext) private var context
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
@FetchRequest(
|
||||
@@ -22,6 +26,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 +49,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 {
|
||||
@@ -59,7 +91,7 @@ struct QuickUpdateView: View {
|
||||
Image(systemName: "arrow.down.circle.fill")
|
||||
}
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundColor(.white)
|
||||
.foregroundStyle(.white)
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
.listRowBackground(Color.appPrimary)
|
||||
@@ -92,6 +124,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 +188,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) {
|
||||
@@ -125,14 +243,14 @@ struct QuickUpdateView: View {
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 1)
|
||||
.background(Color.appSecondary.opacity(0.15))
|
||||
.foregroundColor(.appSecondary)
|
||||
.foregroundStyle(Color.appSecondary)
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
}
|
||||
if source.latestValue != .zero {
|
||||
Text(source.latestValue.currencyString)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
@@ -151,7 +269,7 @@ struct QuickUpdateView: View {
|
||||
HStack {
|
||||
Text(String(localized: "quick_update_contribution_label"))
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
TextField(
|
||||
String(localized: "quick_update_contribution_placeholder"),
|
||||
@@ -161,7 +279,7 @@ struct QuickUpdateView: View {
|
||||
.multilineTextAlignment(.trailing)
|
||||
.frame(width: 120)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -185,17 +303,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) {
|
||||
@@ -246,6 +377,14 @@ struct QuickUpdateView: View {
|
||||
}
|
||||
// Keep the share-extension mirror in sync with what was just saved
|
||||
SharedQuickUpdateSync.refreshMirror()
|
||||
// Direct context.save() bypasses SnapshotRepository, so refresh the
|
||||
// widget explicitly — otherwise it stays stale until the next app open.
|
||||
CoreDataStack.shared.refreshWidgetData()
|
||||
// Positive moment: check-in saved. Ask for a rating once the sheet
|
||||
// is gone (gated to ≥2 months of history, once per version).
|
||||
if ReviewRequestService.shouldRequestAfterCheckIn() {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) { requestReview() }
|
||||
}
|
||||
dismiss()
|
||||
} catch {
|
||||
saveError = error.localizedDescription
|
||||
|
||||
@@ -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)
|
||||
.foregroundStyle(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))
|
||||
}
|
||||
.foregroundStyle(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))
|
||||
.foregroundStyle(theme.mutedInk)
|
||||
Spacer()
|
||||
Text(value)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(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))
|
||||
.foregroundStyle(theme.ink)
|
||||
.minimumScaleFactor(0.7)
|
||||
.lineLimit(1)
|
||||
if let asOf {
|
||||
Text(String(format: String(localized: "share_as_of"), asOf))
|
||||
.font(.caption2.weight(.medium))
|
||||
.foregroundStyle(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))
|
||||
.foregroundStyle(theme.ink)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
Text(String(localized: "share_cta_sub"))
|
||||
.font(.caption2.weight(.medium))
|
||||
.foregroundStyle(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))
|
||||
.foregroundStyle(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))
|
||||
.foregroundStyle(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))
|
||||
.foregroundStyle(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)
|
||||
.foregroundStyle(.white)
|
||||
.clipShape(RoundedRectangle(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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,9 @@ struct GoalEditorView: View {
|
||||
@State private var targetDate = Date()
|
||||
@State private var includeTargetDate = false
|
||||
@State private var didLoadGoal = false
|
||||
@State private var selectedCategoryId: UUID?
|
||||
|
||||
@StateObject private var categoryRepository = CategoryRepository()
|
||||
|
||||
let account: Account?
|
||||
let goal: Goal?
|
||||
@@ -38,7 +41,7 @@ struct GoalEditorView: View {
|
||||
TextField("Goal name", text: $name)
|
||||
HStack {
|
||||
Text(currencySymbol)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
TextField("Target amount", text: $targetAmount)
|
||||
.keyboardType(.decimalPad)
|
||||
}
|
||||
@@ -51,13 +54,29 @@ struct GoalEditorView: View {
|
||||
} header: {
|
||||
Text("Goal Details")
|
||||
}
|
||||
|
||||
Section {
|
||||
Picker(String(localized: "goal_scope_category"), selection: $selectedCategoryId) {
|
||||
Text("goal_scope_all_categories").tag(UUID?.none)
|
||||
ForEach(categoryRepository.categories) { category in
|
||||
Label(category.name, systemImage: category.icon)
|
||||
.tag(UUID?.some(category.id))
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text("goal_scope_section")
|
||||
} footer: {
|
||||
Text(selectedCategoryId == nil
|
||||
? String(localized: "goal_scope_footer_all")
|
||||
: String(localized: "goal_scope_footer_category"))
|
||||
}
|
||||
}
|
||||
.navigationTitle(goal == nil ? "New Goal" : "Edit Goal")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
Button("Cancel") { dismiss() }
|
||||
}
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button("Save") { saveGoal() }
|
||||
.disabled(!isValid)
|
||||
}
|
||||
@@ -67,7 +86,7 @@ struct GoalEditorView: View {
|
||||
.presentationDragIndicator(.visible)
|
||||
.onAppear {
|
||||
guard let goal, !didLoadGoal else { return }
|
||||
name = goal.name ?? ""
|
||||
name = goal.name
|
||||
if let amount = goal.targetAmount?.decimalValue {
|
||||
targetAmount = formatDecimalForInput(amount)
|
||||
}
|
||||
@@ -75,6 +94,7 @@ struct GoalEditorView: View {
|
||||
includeTargetDate = true
|
||||
targetDate = target
|
||||
}
|
||||
selectedCategoryId = goal.category?.safeId
|
||||
didLoadGoal = true
|
||||
}
|
||||
}
|
||||
@@ -83,22 +103,31 @@ struct GoalEditorView: View {
|
||||
!name.trimmingCharacters(in: .whitespaces).isEmpty && parseDecimal(targetAmount) != nil
|
||||
}
|
||||
|
||||
private var selectedCategory: Category? {
|
||||
guard let selectedCategoryId else { return nil }
|
||||
return categoryRepository.categories.first { $0.safeId == selectedCategoryId }
|
||||
}
|
||||
|
||||
private func saveGoal() {
|
||||
guard let value = parseDecimal(targetAmount) else { return }
|
||||
let category = selectedCategory
|
||||
if let goal {
|
||||
goalRepository.updateGoal(
|
||||
goal,
|
||||
name: name,
|
||||
targetAmount: value,
|
||||
targetDate: includeTargetDate ? targetDate : nil,
|
||||
clearTargetDate: !includeTargetDate
|
||||
clearTargetDate: !includeTargetDate,
|
||||
category: category,
|
||||
clearCategory: category == nil
|
||||
)
|
||||
} else {
|
||||
goalRepository.createGoal(
|
||||
name: name,
|
||||
targetAmount: value,
|
||||
targetDate: includeTargetDate ? targetDate : nil,
|
||||
account: account
|
||||
account: account,
|
||||
category: category
|
||||
)
|
||||
}
|
||||
dismiss()
|
||||
|
||||
@@ -26,7 +26,7 @@ struct GoalProgressBar: View {
|
||||
.overlay(alignment: .leading) {
|
||||
Image(systemName: iconName)
|
||||
.font(.caption2)
|
||||
.foregroundColor(iconColor)
|
||||
.foregroundStyle(iconColor)
|
||||
.offset(x: iconOffset)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,10 +34,10 @@ struct GoalShareCardView: View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Goal Progress")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundColor(.white.opacity(0.8))
|
||||
.foregroundStyle(.white.opacity(0.8))
|
||||
Text(name)
|
||||
.font(.title2.weight(.bold))
|
||||
.foregroundColor(.white)
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
|
||||
GoalProgressBar(
|
||||
@@ -51,27 +51,27 @@ struct GoalShareCardView: View {
|
||||
|
||||
Text("\(progressText) complete")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundColor(.white.opacity(0.85))
|
||||
.foregroundStyle(.white.opacity(0.85))
|
||||
|
||||
if privacyMode {
|
||||
HStack {
|
||||
Text("Progress")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundColor(.white.opacity(0.9))
|
||||
.foregroundStyle(.white.opacity(0.9))
|
||||
Spacer()
|
||||
Text(progressText)
|
||||
.font(.headline.weight(.bold))
|
||||
.foregroundColor(.white)
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
} else {
|
||||
HStack {
|
||||
Text(displayCurrentValue)
|
||||
.font(.headline)
|
||||
.foregroundColor(.white)
|
||||
.foregroundStyle(.white)
|
||||
Spacer()
|
||||
Text("of \(targetValue.currencyString)")
|
||||
.font(.subheadline.weight(.medium))
|
||||
.foregroundColor(.white.opacity(0.8))
|
||||
.foregroundStyle(.white.opacity(0.8))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,7 +82,7 @@ struct GoalShareCardView: View {
|
||||
Text("Target: \(targetDate.mediumDateString)")
|
||||
.font(.caption.weight(.medium))
|
||||
}
|
||||
.foregroundColor(.white.opacity(0.85))
|
||||
.foregroundStyle(.white.opacity(0.85))
|
||||
}
|
||||
|
||||
if let estimatedCompletionDate {
|
||||
@@ -92,7 +92,7 @@ struct GoalShareCardView: View {
|
||||
Text("Est. completion: \(estimatedCompletionDate.mediumDateString)")
|
||||
.font(.caption.weight(.medium))
|
||||
}
|
||||
.foregroundColor(.white.opacity(0.85))
|
||||
.foregroundStyle(.white.opacity(0.85))
|
||||
}
|
||||
|
||||
if privacyMode {
|
||||
@@ -101,7 +101,7 @@ struct GoalShareCardView: View {
|
||||
Text("Privacy mode enabled")
|
||||
}
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundColor(.white.opacity(0.7))
|
||||
.foregroundStyle(.white.opacity(0.7))
|
||||
}
|
||||
|
||||
Spacer(minLength: 0)
|
||||
@@ -124,10 +124,10 @@ struct GoalShareCardView: View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Powered by")
|
||||
.font(.caption2.weight(.medium))
|
||||
.foregroundColor(.white.opacity(0.7))
|
||||
.foregroundStyle(.white.opacity(0.7))
|
||||
Text("Portfolio Journal")
|
||||
.font(.subheadline.weight(.bold))
|
||||
.foregroundColor(.white)
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
@@ -144,7 +144,7 @@ struct GoalShareCardView: View {
|
||||
|
||||
Text("Scan to download")
|
||||
.font(.system(size: 7, weight: .medium))
|
||||
.foregroundColor(.white.opacity(0.8))
|
||||
.foregroundStyle(.white.opacity(0.8))
|
||||
}
|
||||
} else {
|
||||
// Fallback if QR code generation fails
|
||||
@@ -154,7 +154,7 @@ struct GoalShareCardView: View {
|
||||
Text("App Store")
|
||||
.font(.caption2.weight(.semibold))
|
||||
}
|
||||
.foregroundColor(.white.opacity(0.9))
|
||||
.foregroundStyle(.white.opacity(0.9))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
@@ -72,7 +72,7 @@ struct GoalsView: View {
|
||||
}
|
||||
.navigationTitle("Goals")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
Picker(String(localized: "goals_filter_active"), selection: $goalFilter) {
|
||||
ForEach(GoalFilter.allCases) { filter in
|
||||
Text(filter.label).tag(filter)
|
||||
@@ -80,7 +80,7 @@ struct GoalsView: View {
|
||||
}
|
||||
.pickerStyle(.menu)
|
||||
}
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button {
|
||||
showingAddGoal = true
|
||||
} label: {
|
||||
@@ -145,19 +145,19 @@ struct GoalsView: View {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "target")
|
||||
.font(.system(size: 48))
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
Text("Set your first goal")
|
||||
.font(.headline)
|
||||
Text("Track progress toward milestones like \(AppSettings.getOrCreate(in: CoreDataStack.shared.viewContext).currencySymbol)1M and share your wins.")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
Button {
|
||||
showingAddGoal = true
|
||||
} label: {
|
||||
Text(String(localized: "goals_empty_add_cta"))
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundColor(.white)
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.vertical, 10)
|
||||
.background(Color.appPrimary)
|
||||
@@ -170,13 +170,13 @@ struct GoalsView: View {
|
||||
VStack(spacing: 12) {
|
||||
Image(systemName: "archivebox")
|
||||
.font(.system(size: 40))
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(String(localized: "goals_all_active_achieved"))
|
||||
.font(.headline)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
Text("Switch to \"Archived\" or \"All\" to see other goals.")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
@@ -186,10 +186,10 @@ struct GoalsView: View {
|
||||
VStack(spacing: 12) {
|
||||
Image(systemName: "archivebox")
|
||||
.font(.system(size: 40))
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(String(localized: "goals_empty_archived"))
|
||||
.font(.headline)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 32)
|
||||
@@ -197,12 +197,12 @@ struct GoalsView: View {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "target")
|
||||
.font(.system(size: 48))
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
Text("Set your first goal")
|
||||
.font(.headline)
|
||||
Text("Track progress toward milestones like \(AppSettings.getOrCreate(in: CoreDataStack.shared.viewContext).currencySymbol)1M and share your wins.")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
@@ -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,81 @@ 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))
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 3)
|
||||
.background(Color.appSuccess)
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
}
|
||||
|
||||
if let category = goal.category {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: category.icon)
|
||||
.font(.caption2)
|
||||
Text(category.name)
|
||||
.font(.caption2.weight(.semibold))
|
||||
}
|
||||
.foregroundStyle(category.color)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 3)
|
||||
.background(category.color.opacity(0.15))
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
|
||||
HStack(spacing: 4) {
|
||||
Text(totalValue.currencyString)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.hiddenBalance()
|
||||
Text("of \(goal.targetDecimal.currencyString)")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.hiddenBalance()
|
||||
}
|
||||
|
||||
if let targetDate = goal.targetDate {
|
||||
Text("Target date: \(targetDate.mediumDateString)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(targetDateColor)
|
||||
}
|
||||
|
||||
if let paceStatus {
|
||||
Text(paceStatus.statusText)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(
|
||||
isAchieved ? Color.appSuccess : (paceStatus.isBehind ? Color.appWarning : Color.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)
|
||||
|
||||
@@ -307,7 +326,7 @@ struct GoalRowView: View {
|
||||
showingShareOptions = true
|
||||
} label: {
|
||||
Image(systemName: "square.and.arrow.up")
|
||||
.foregroundColor(.appPrimary)
|
||||
.foregroundStyle(Color.appPrimary)
|
||||
.padding(.top, 2)
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
@@ -333,10 +352,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)
|
||||
.foregroundStyle(.secondary)
|
||||
default:
|
||||
HStack(spacing: 8) {
|
||||
if let date = projection.projectedDate {
|
||||
Text(String(format: String(localized: "goal_projection_projected"), date.monthYearString))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.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))
|
||||
.foregroundStyle(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 {
|
||||
|
||||
@@ -10,71 +10,63 @@ struct JournalView: View {
|
||||
@State private var selectedDate: Date?
|
||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||
|
||||
/// Month list + inline check-in: two columns in regular width (iPad, iPhone
|
||||
/// Duo inner screen), a collapsing stack in compact width. The selected
|
||||
/// month is the only navigation state, so it survives the Duo opening/closing.
|
||||
@State private var columnVisibility: NavigationSplitViewVisibility = .all
|
||||
|
||||
var body: some View {
|
||||
if horizontalSizeClass == .regular {
|
||||
iPadJournalLayout
|
||||
} else {
|
||||
iPhoneJournalLayout
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - iPad Layout (list + inline detail)
|
||||
|
||||
private var iPadJournalLayout: some View {
|
||||
HStack(spacing: 0) {
|
||||
// Left: month list
|
||||
NavigationStack {
|
||||
journalListContent(isPad: true)
|
||||
.navigationTitle("Journal")
|
||||
.searchable(text: $searchText, prompt: "Search monthly notes")
|
||||
.onAppear { viewModel.refresh() }
|
||||
}
|
||||
.frame(width: 320)
|
||||
|
||||
Divider()
|
||||
|
||||
// Right: monthly check-in detail
|
||||
NavigationSplitView(columnVisibility: $columnVisibility) {
|
||||
journalListContent
|
||||
.navigationTitle("Journal")
|
||||
.searchable(text: $searchText, prompt: "Search monthly notes")
|
||||
.onAppear { viewModel.refresh() }
|
||||
.navigationSplitViewColumnWidth(min: 300, ideal: 340, max: 420)
|
||||
} detail: {
|
||||
NavigationStack {
|
||||
if let date = selectedDate {
|
||||
MonthlyCheckInView(referenceDate: date)
|
||||
.id(date)
|
||||
} else {
|
||||
noMonthSelectedView
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - iPhone Layout (push navigation)
|
||||
|
||||
private var iPhoneJournalLayout: some View {
|
||||
NavigationStack {
|
||||
journalListContent(isPad: false)
|
||||
.navigationTitle("Journal")
|
||||
.searchable(text: $searchText, prompt: "Search monthly notes")
|
||||
.onAppear { viewModel.refresh() }
|
||||
}
|
||||
.navigationSplitViewStyle(.balanced)
|
||||
}
|
||||
|
||||
// MARK: - Shared List Content
|
||||
|
||||
private func journalListContent(isPad: Bool) -> some View {
|
||||
private var journalListContent: some View {
|
||||
ScrollViewReader { proxy in
|
||||
ZStack {
|
||||
AppBackground()
|
||||
|
||||
List {
|
||||
List(selection: $selectedDate) {
|
||||
// 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 {
|
||||
// Narrow sidebar column in regular width → compact stat tiles.
|
||||
MomentumStreaksCard(compact: horizontalSizeClass == .regular)
|
||||
}
|
||||
.listRowInsets(EdgeInsets())
|
||||
.listRowBackground(Color.clear)
|
||||
.listSectionSeparator(.hidden)
|
||||
}
|
||||
|
||||
Section("Monthly Check-ins") {
|
||||
if filteredMonthlyNotes.isEmpty {
|
||||
if searchText.isEmpty {
|
||||
VStack(spacing: 12) {
|
||||
Image(systemName: "book.closed")
|
||||
.font(.system(size: 36))
|
||||
.foregroundColor(.appSecondary)
|
||||
.foregroundStyle(Color.appSecondary)
|
||||
Text(String(localized: "journal_empty_title"))
|
||||
.font(.headline)
|
||||
Text(String(localized: "journal_empty_body"))
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
.padding(.vertical, 16)
|
||||
@@ -82,34 +74,16 @@ struct JournalView: View {
|
||||
} else {
|
||||
Text("No matching notes.")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
} else {
|
||||
ForEach(filteredMonthlyNotes) { entry in
|
||||
if isPad {
|
||||
Button {
|
||||
selectedDate = entry.date
|
||||
} label: {
|
||||
monthlyNoteRow(entry)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.listRowBackground(
|
||||
selectedDate.map { $0.isSameMonth(as: entry.date) } == true
|
||||
? Color.appPrimary.opacity(0.08)
|
||||
: Color.clear
|
||||
)
|
||||
.id(entry.date)
|
||||
// ForEach identity (MonthlyNoteItem.id == date) already serves
|
||||
// ScrollViewReader; an extra .id() would wrap the .tag trait
|
||||
// and break List selection.
|
||||
monthlyNoteRow(entry)
|
||||
.tag(entry.date)
|
||||
.onAppear { currentVisibleMonth = entry.date }
|
||||
} else {
|
||||
NavigationLink {
|
||||
MonthlyCheckInView(referenceDate: entry.date)
|
||||
} label: {
|
||||
monthlyNoteRow(entry)
|
||||
}
|
||||
.id(entry.date)
|
||||
.onAppear { currentVisibleMonth = entry.date }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -145,7 +119,7 @@ struct JournalView: View {
|
||||
|
||||
Image(systemName: "book.closed.fill")
|
||||
.font(.system(size: 50, weight: .light))
|
||||
.foregroundColor(.white)
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
|
||||
VStack(spacing: 10) {
|
||||
@@ -153,14 +127,15 @@ struct JournalView: View {
|
||||
.font(.title2.weight(.semibold))
|
||||
|
||||
Text("Choose a month from the list on the\nleft to view your check-in notes.")
|
||||
.frame(maxWidth: 360)
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
|
||||
Label("Select from the list", systemImage: "arrow.left")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundColor(.appSecondary)
|
||||
.foregroundStyle(Color.appSecondary)
|
||||
.padding(.horizontal, 22)
|
||||
.padding(.vertical, 11)
|
||||
.background(Color.appSecondary.opacity(0.1))
|
||||
@@ -183,36 +158,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))
|
||||
.foregroundStyle(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))
|
||||
.foregroundStyle(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)
|
||||
.foregroundStyle(.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 {
|
||||
@@ -220,7 +201,7 @@ struct JournalView: View {
|
||||
return HStack(spacing: 4) {
|
||||
ForEach(1...5, id: \.self) { index in
|
||||
Image(systemName: index <= value ? "star.fill" : "star")
|
||||
.foregroundColor(index <= value ? .yellow : .secondary)
|
||||
.foregroundStyle(index <= value ? .yellow : .secondary)
|
||||
}
|
||||
}
|
||||
.accessibilityLabel(
|
||||
@@ -234,7 +215,7 @@ struct JournalView: View {
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 6)
|
||||
.background(Color.gray.opacity(0.15))
|
||||
.foregroundColor(.primary)
|
||||
.foregroundStyle(.primary)
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
|
||||
@@ -252,9 +233,7 @@ struct JournalView: View {
|
||||
.font(.caption.weight(.semibold))
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 6)
|
||||
.background(Color(.systemBackground).opacity(0.95))
|
||||
.cornerRadius(12)
|
||||
.shadow(color: .black.opacity(0.12), radius: 6, y: 2)
|
||||
.floatingPillBackground()
|
||||
.offset(x: -16)
|
||||
}
|
||||
|
||||
@@ -263,9 +242,7 @@ struct JournalView: View {
|
||||
.font(.caption.weight(.semibold))
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 6)
|
||||
.background(Color(.systemBackground).opacity(0.95))
|
||||
.cornerRadius(12)
|
||||
.shadow(color: .black.opacity(0.12), radius: 6, y: 2)
|
||||
.floatingPillBackground()
|
||||
.offset(x: -16, y: scrubberOffset - height / 2)
|
||||
}
|
||||
|
||||
@@ -322,13 +299,13 @@ struct MonthlyNoteEditorView: View {
|
||||
.frame(minHeight: 200)
|
||||
.padding(8)
|
||||
.background(Color.gray.opacity(0.08))
|
||||
.cornerRadius(12)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
.padding()
|
||||
.navigationTitle("Monthly Note")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button("Save") {
|
||||
MonthlyCheckInStore.setNote(note, for: date)
|
||||
dismiss()
|
||||
@@ -339,6 +316,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()
|
||||
|
||||
@@ -33,7 +33,7 @@ struct OnboardingICloudCheckView: View {
|
||||
.frame(width: 100, height: 100)
|
||||
Image(systemName: "icloud.fill")
|
||||
.font(.system(size: 50))
|
||||
.foregroundColor(.appPrimary)
|
||||
.foregroundStyle(Color.appPrimary)
|
||||
}
|
||||
|
||||
VStack(spacing: 14) {
|
||||
@@ -43,7 +43,7 @@ struct OnboardingICloudCheckView: View {
|
||||
|
||||
Text(String(localized: "icloud_check_description"))
|
||||
.font(.body)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, 40)
|
||||
}
|
||||
@@ -59,11 +59,11 @@ struct OnboardingICloudCheckView: View {
|
||||
} label: {
|
||||
Label("Restore from iCloud", systemImage: "icloud.and.arrow.down")
|
||||
.font(.headline)
|
||||
.foregroundColor(.white)
|
||||
.foregroundStyle(.white)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding()
|
||||
.background(Color.appPrimary)
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
|
||||
}
|
||||
|
||||
Button {
|
||||
@@ -71,7 +71,7 @@ struct OnboardingICloudCheckView: View {
|
||||
} label: {
|
||||
Text("Start Fresh")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
@@ -96,7 +96,7 @@ struct OnboardingICloudCheckView: View {
|
||||
.frame(width: 100, height: 100)
|
||||
Image(systemName: "checkmark.icloud.fill")
|
||||
.font(.system(size: 50))
|
||||
.foregroundColor(.positiveGreen)
|
||||
.foregroundStyle(Color.positiveGreen)
|
||||
}
|
||||
|
||||
VStack(spacing: 14) {
|
||||
@@ -106,7 +106,7 @@ struct OnboardingICloudCheckView: View {
|
||||
|
||||
Text(String(localized: "icloud_enabled_description"))
|
||||
.font(.body)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, 40)
|
||||
}
|
||||
@@ -123,11 +123,11 @@ struct OnboardingICloudCheckView: View {
|
||||
} label: {
|
||||
Text("Got it")
|
||||
.font(.headline)
|
||||
.foregroundColor(.white)
|
||||
.foregroundStyle(.white)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding()
|
||||
.background(Color.positiveGreen)
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.bottom, 40)
|
||||
|
||||
@@ -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",
|
||||
@@ -82,11 +88,11 @@ struct OnboardingView: View {
|
||||
} label: {
|
||||
Text("Continue")
|
||||
.font(.headline)
|
||||
.foregroundColor(.white)
|
||||
.foregroundStyle(.white)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding()
|
||||
.background(Color.appPrimary)
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
|
||||
}
|
||||
|
||||
Button {
|
||||
@@ -95,7 +101,7 @@ struct OnboardingView: View {
|
||||
} label: {
|
||||
Text("Skip")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
} else {
|
||||
Button {
|
||||
@@ -103,11 +109,11 @@ struct OnboardingView: View {
|
||||
} label: {
|
||||
Text("Get Started")
|
||||
.font(.headline)
|
||||
.foregroundColor(.white)
|
||||
.foregroundStyle(.white)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding()
|
||||
.background(Color.appPrimary)
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -123,6 +129,9 @@ struct OnboardingView: View {
|
||||
}
|
||||
.sheet(isPresented: $showingImportSheet) {
|
||||
ImportDataView(importContext: .onboarding)
|
||||
.environmentObject(iapService)
|
||||
.environmentObject(accountStore)
|
||||
.environmentObject(tabSelection)
|
||||
}
|
||||
.sheet(isPresented: $showingAddSource) {
|
||||
AddSourceView()
|
||||
@@ -135,7 +144,7 @@ struct OnboardingView: View {
|
||||
categoryRepository.createDefaultCategoriesIfNeeded()
|
||||
|
||||
// Create default account if needed
|
||||
AccountRepository().createDefaultAccountIfNeeded()
|
||||
_ = AccountRepository().createDefaultAccountIfNeeded()
|
||||
|
||||
// Mark onboarding as complete
|
||||
let context = CoreDataStack.shared.viewContext
|
||||
@@ -192,7 +201,7 @@ struct OnboardingPageView: View {
|
||||
|
||||
Image(systemName: page.icon)
|
||||
.font(.system(size: 50))
|
||||
.foregroundColor(page.color)
|
||||
.foregroundStyle(page.color)
|
||||
}
|
||||
|
||||
// Content
|
||||
@@ -203,7 +212,7 @@ struct OnboardingPageView: View {
|
||||
|
||||
Text(page.description)
|
||||
.font(.body)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, 40)
|
||||
}
|
||||
@@ -246,7 +255,7 @@ struct OnboardingQuickStartView: View {
|
||||
|
||||
Text(String(localized: "onboarding_quickstart_subtitle"))
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, 30)
|
||||
}
|
||||
@@ -259,8 +268,8 @@ struct OnboardingQuickStartView: View {
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding()
|
||||
.background(Color.appPrimary)
|
||||
.foregroundColor(.white)
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
.foregroundStyle(.white)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
|
||||
}
|
||||
|
||||
Button(action: onImport) {
|
||||
@@ -269,8 +278,8 @@ struct OnboardingQuickStartView: View {
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 12)
|
||||
.background(Color.appPrimary.opacity(0.1))
|
||||
.foregroundColor(.appPrimary)
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
.foregroundStyle(Color.appPrimary)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -288,13 +297,13 @@ struct OnboardingQuickStartView: View {
|
||||
}
|
||||
.padding()
|
||||
.background(Color.gray.opacity(0.1))
|
||||
.cornerRadius(12)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
|
||||
|
||||
Toggle("Sync with iCloud (optional)", isOn: $cloudSyncEnabled)
|
||||
.padding()
|
||||
.background(Color.gray.opacity(0.1))
|
||||
.cornerRadius(12)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
|
||||
Spacer().frame(height: 8)
|
||||
@@ -317,7 +326,7 @@ struct WelcomeView: View {
|
||||
|
||||
Image(systemName: "hand.wave.fill")
|
||||
.font(.system(size: 60))
|
||||
.foregroundColor(.appPrimary)
|
||||
.foregroundStyle(Color.appPrimary)
|
||||
|
||||
VStack(spacing: 8) {
|
||||
if let name = userName {
|
||||
@@ -332,7 +341,7 @@ struct WelcomeView: View {
|
||||
? "Your investment data has been synced from iCloud."
|
||||
: "Your investment data stays on this device.")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
|
||||
@@ -345,11 +354,11 @@ struct WelcomeView: View {
|
||||
} label: {
|
||||
Text("Continue")
|
||||
.font(.headline)
|
||||
.foregroundColor(.white)
|
||||
.foregroundStyle(.white)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding()
|
||||
.background(Color.appPrimary)
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.bottom, 40)
|
||||
|
||||
@@ -7,86 +7,223 @@ 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.7.0", features: [
|
||||
WhatsNewFeature(
|
||||
icon: "iphone.gen3.and.iphone.gen3",
|
||||
color: .appPrimary,
|
||||
title: String(localized: "whats_new_170_duo_title"),
|
||||
description: String(localized: "whats_new_170_duo_body")
|
||||
),
|
||||
WhatsNewFeature(
|
||||
icon: "rectangle.trailinghalf.inset.filled",
|
||||
color: .appSecondary,
|
||||
title: String(localized: "whats_new_170_inspector_title"),
|
||||
description: String(localized: "whats_new_170_inspector_body")
|
||||
),
|
||||
WhatsNewFeature(
|
||||
icon: "touchid",
|
||||
color: .positiveGreen,
|
||||
title: String(localized: "whats_new_170_biometrics_title"),
|
||||
description: String(localized: "whats_new_170_biometrics_body")
|
||||
)
|
||||
]),
|
||||
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)
|
||||
.foregroundStyle(.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))
|
||||
.foregroundStyle(Color.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)
|
||||
.foregroundStyle(feature.color)
|
||||
.frame(width: 40)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(feature.title)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Text(feature.description)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(Color(.secondarySystemGroupedBackground))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.systemBackground))
|
||||
.cornerRadius(16)
|
||||
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
|
||||
}
|
||||
.padding()
|
||||
|
||||
Spacer()
|
||||
|
||||
Button {
|
||||
dismiss()
|
||||
} label: {
|
||||
Text(String(localized: "whats_new_continue"))
|
||||
.font(.headline)
|
||||
.foregroundColor(.white)
|
||||
.foregroundStyle(.white)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding()
|
||||
.background(Color.appPrimary)
|
||||
.cornerRadius(14)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
|
||||
@@ -16,11 +16,11 @@ private struct PremiumChartPreview: View {
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
Text("€24,750")
|
||||
.font(.system(size: 18, weight: .bold, design: .rounded))
|
||||
.foregroundColor(.primary)
|
||||
.foregroundStyle(.primary)
|
||||
Spacer()
|
||||
Text("+34.2%")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundColor(.positiveGreen)
|
||||
.foregroundStyle(Color.positiveGreen)
|
||||
.padding(.horizontal, 7)
|
||||
.padding(.vertical, 3)
|
||||
.background(Color.positiveGreen.opacity(0.12))
|
||||
@@ -141,7 +141,7 @@ struct PaywallView: View {
|
||||
} label: {
|
||||
Image(systemName: "xmark")
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(10)
|
||||
.background(Color(.secondarySystemBackground))
|
||||
.clipShape(Circle())
|
||||
@@ -174,7 +174,7 @@ struct PaywallView: View {
|
||||
|
||||
Text("One payment. Every feature. Forever.")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
}
|
||||
@@ -189,7 +189,7 @@ struct PaywallView: View {
|
||||
}
|
||||
.padding(20)
|
||||
.background(Color(.systemBackground))
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
|
||||
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
|
||||
}
|
||||
|
||||
@@ -199,11 +199,11 @@ struct PaywallView: View {
|
||||
HStack(spacing: 4) {
|
||||
Text(iapService.formattedPrice)
|
||||
.font(.system(size: 28, weight: .bold, design: .rounded))
|
||||
.foregroundColor(.appPrimary)
|
||||
.foregroundStyle(Color.appPrimary)
|
||||
|
||||
Text("· one-time · Family Sharing")
|
||||
.font(.footnote)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -242,24 +242,24 @@ struct PaywallView: View {
|
||||
HStack(spacing: 6) {
|
||||
Text(title)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundColor(.primary)
|
||||
.foregroundStyle(.primary)
|
||||
if let badge, !badge.isEmpty {
|
||||
Text(badge)
|
||||
.font(.caption2.weight(.bold))
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 2)
|
||||
.background(Color.appSecondary.opacity(0.15))
|
||||
.foregroundColor(.appSecondary)
|
||||
.foregroundStyle(Color.appSecondary)
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
}
|
||||
Text(price)
|
||||
.font(.footnote)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
Image(systemName: selectedPlan == plan ? "checkmark.circle.fill" : "circle")
|
||||
.foregroundColor(selectedPlan == plan ? .appPrimary : .secondary)
|
||||
.foregroundStyle(selectedPlan == plan ? Color.appPrimary : .secondary)
|
||||
}
|
||||
.padding(12)
|
||||
.background(Color(.systemBackground))
|
||||
@@ -268,7 +268,7 @@ struct PaywallView: View {
|
||||
.stroke(selectedPlan == plan ? Color.appPrimary : Color.gray.opacity(0.25),
|
||||
lineWidth: selectedPlan == plan ? 2 : 1)
|
||||
)
|
||||
.cornerRadius(AppConstants.UI.smallCornerRadius)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.smallCornerRadius))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
@@ -291,8 +291,8 @@ struct PaywallView: View {
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding()
|
||||
.background(Color.appPrimary)
|
||||
.foregroundColor(.white)
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
.foregroundStyle(.white)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
|
||||
}
|
||||
.disabled(isPurchasing)
|
||||
}
|
||||
@@ -305,7 +305,7 @@ struct PaywallView: View {
|
||||
} label: {
|
||||
Text("Restore Purchases")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.appPrimary)
|
||||
.foregroundStyle(Color.appPrimary)
|
||||
}
|
||||
.disabled(isPurchasing)
|
||||
}
|
||||
@@ -316,17 +316,17 @@ struct PaywallView: View {
|
||||
VStack(spacing: 4) {
|
||||
Text("Payment charged to your Apple ID account.")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
HStack(spacing: 12) {
|
||||
Link("Terms", destination: URL(string: AppConstants.URLs.termsOfService)!)
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Link("Privacy", destination: URL(string: AppConstants.URLs.privacyPolicy)!)
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -381,7 +381,7 @@ struct BenefitRow: View {
|
||||
HStack(spacing: 14) {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 20))
|
||||
.foregroundColor(.appPrimary)
|
||||
.foregroundStyle(Color.appPrimary)
|
||||
.frame(width: 28)
|
||||
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
@@ -390,13 +390,13 @@ struct BenefitRow: View {
|
||||
|
||||
Text(subtitle)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundColor(.positiveGreen)
|
||||
.foregroundStyle(Color.positiveGreen)
|
||||
.font(.system(size: 18))
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
@@ -439,7 +439,7 @@ struct CompactPaywallBanner: View {
|
||||
|
||||
Text("Unlimited sources, advanced charts & more")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
@@ -452,13 +452,13 @@ struct CompactPaywallBanner: View {
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(Color.appPrimary)
|
||||
.foregroundColor(.white)
|
||||
.cornerRadius(16)
|
||||
.foregroundStyle(.white)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.systemBackground))
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
|
||||
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
|
||||
}
|
||||
}
|
||||
@@ -493,8 +493,8 @@ struct PremiumLockOverlay: View {
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.vertical, 10)
|
||||
.background(Color.appPrimary)
|
||||
.foregroundColor(.white)
|
||||
.cornerRadius(20)
|
||||
.foregroundStyle(.white)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 20))
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
|
||||
@@ -9,6 +9,7 @@ struct AppLockView: View {
|
||||
@State private var errorMessage: String?
|
||||
@State private var didAttemptBiometrics = false
|
||||
@FocusState private var pinFocused: Bool
|
||||
private let biometry = AppLockService.biometryKind
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
@@ -28,7 +29,7 @@ struct AppLockView: View {
|
||||
|
||||
Text("Unlock Portfolio Journal to view your data.")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, 30)
|
||||
|
||||
@@ -36,13 +37,14 @@ struct AppLockView: View {
|
||||
Button {
|
||||
authenticateWithBiometrics()
|
||||
} label: {
|
||||
Label("Unlock with Face ID", systemImage: "faceid")
|
||||
Label(String(format: String(localized: "biometric_unlock_with"), biometry.displayName),
|
||||
systemImage: biometry.systemImage)
|
||||
.font(.headline)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding()
|
||||
.background(Color.appPrimary)
|
||||
.foregroundColor(.white)
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
.foregroundStyle(.white)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
}
|
||||
@@ -56,7 +58,7 @@ struct AppLockView: View {
|
||||
.font(.title3.weight(.semibold))
|
||||
.padding()
|
||||
.background(Color.gray.opacity(0.1))
|
||||
.cornerRadius(12)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
.focused($pinFocused)
|
||||
.onChange(of: pin) { _, newValue in
|
||||
pin = String(newValue.filter(\.isNumber).prefix(4))
|
||||
@@ -78,7 +80,7 @@ struct AppLockView: View {
|
||||
if let errorMessage {
|
||||
Text(errorMessage)
|
||||
.font(.caption)
|
||||
.foregroundColor(.negativeRed)
|
||||
.foregroundStyle(Color.negativeRed)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
@@ -101,7 +103,7 @@ struct AppLockView: View {
|
||||
} else if pinEnabled {
|
||||
pinFocused = true
|
||||
} else {
|
||||
errorMessage = "Face ID failed. Please try again."
|
||||
errorMessage = String(format: String(localized: "biometric_failed"), biometry.displayName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,13 +11,13 @@ struct AllocationTargetsView: View {
|
||||
Text("Total Targets")
|
||||
Spacer()
|
||||
Text(String(format: "%.0f%%", totalTargets))
|
||||
.foregroundColor(totalTargets == 100 ? .positiveGreen : .secondary)
|
||||
.foregroundStyle(totalTargets == 100 ? Color.positiveGreen : .secondary)
|
||||
}
|
||||
|
||||
if totalTargets != 100 {
|
||||
Text("Targets don't need to be perfect, but aiming for 100% keeps the drift view accurate.")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ struct AllocationTargetsView: View {
|
||||
.frame(width: 60)
|
||||
|
||||
Text("%")
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
|
||||
@@ -29,7 +29,7 @@ struct CSVMappingView: View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(pair.0)
|
||||
.font(.caption2.weight(.semibold))
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(pair.1.isEmpty ? "—" : pair.1)
|
||||
.font(.caption)
|
||||
.lineLimit(2)
|
||||
@@ -117,10 +117,10 @@ struct CSVMappingView: View {
|
||||
.navigationTitle("Map Columns")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
Button("Cancel") { dismiss() }
|
||||
}
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button("Import") {
|
||||
onComplete(config)
|
||||
dismiss()
|
||||
@@ -261,10 +261,10 @@ private struct MappingRow: View {
|
||||
HStack(spacing: 4) {
|
||||
Text(selectionLabel)
|
||||
.font(.subheadline)
|
||||
.foregroundColor(index == ImportService.CSVMappingConfig.notMapped ? .secondary : .appPrimary)
|
||||
.foregroundStyle(index == ImportService.CSVMappingConfig.notMapped ? .secondary : Color.appPrimary)
|
||||
Image(systemName: "chevron.up.chevron.down")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -332,10 +332,10 @@ private struct MappingRowWithConstant: View {
|
||||
HStack(spacing: 4) {
|
||||
Text(selectionLabel)
|
||||
.font(.subheadline)
|
||||
.foregroundColor(index == ImportService.CSVMappingConfig.notMapped ? .secondary : .appPrimary)
|
||||
.foregroundStyle(index == ImportService.CSVMappingConfig.notMapped ? .secondary : Color.appPrimary)
|
||||
Image(systemName: "chevron.up.chevron.down")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user