Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4b8873eb84 | |||
| 8daa80bb42 | |||
| 12386673ff | |||
| 2380d37a9e | |||
| 7d2f605c16 | |||
| 319ab9589e | |||
| e9e3e5da66 | |||
| 15a6d7395e | |||
| 8828a2aa0f | |||
| 171023a411 | |||
| 6774d76146 | |||
| 680255f69d | |||
| a8a9ad64f3 | |||
| 464fbb2bad | |||
| 167b736aad | |||
| d88cc59627 | |||
| eaf0c8f93b | |||
| 6dd755234a |
@@ -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
|
||||
|
||||
@@ -48,11 +48,31 @@ PortfolioJournal/
|
||||
│ ├── Security/ # Face ID/PIN lock (AppLockView)
|
||||
│ └── Components/ # Shared UI components
|
||||
├── Utilities/ # Helpers: KeychainService, FreemiumValidator, formatters, extensions
|
||||
└── Resources/ # Info.plist, GoogleService-Info.plist, assets
|
||||
└── Resources/ # Info.plist, PrivacyInfo.xcprivacy, 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`
|
||||
@@ -64,23 +84,25 @@ Data flows through `CoreDataStack` singleton which manages CloudKit sync and App
|
||||
- **CalculationService**: Portfolio metrics, returns calculation, allocation analysis
|
||||
- **PredictionEngine**: Investment forecasting algorithms with caching
|
||||
- **IAPService**: StoreKit 2 in-app purchases
|
||||
- **AdMobService**: Google Mobile Ads integration
|
||||
- **DiagnosticsCollector**: on-device MetricKit crash/hang reports (nothing is uploaded)
|
||||
- **ImportService/ExportService**: CSV data import/export
|
||||
- **AppLockService**: Biometric/PIN security via Keychain
|
||||
|
||||
## Dependencies
|
||||
|
||||
Managed via Swift Package Manager:
|
||||
- Firebase iOS SDK (v12.7.0+) - Analytics
|
||||
- Google Mobile Ads SDK
|
||||
**No third-party packages.** The app deliberately links nothing outside Apple's SDKs:
|
||||
AdMob, Firebase Analytics and Crashlytics were removed in 1.7.0 so that the
|
||||
"no tracking, no analytics" claim on the App Store listing is literally true.
|
||||
Do not reintroduce an advertising, analytics or crash-reporting SDK.
|
||||
|
||||
Native frameworks: SwiftUI, Combine, CoreData, CloudKit, WidgetKit, StoreKit 2, LocalAuthentication
|
||||
Native frameworks: SwiftUI, Combine, CoreData, CloudKit, WidgetKit, StoreKit 2,
|
||||
LocalAuthentication, MetricKit
|
||||
|
||||
## App Initialization Flow
|
||||
|
||||
```
|
||||
PortfolioJournalApp (@main)
|
||||
└── AppDelegate (Firebase, AdMob, Notifications init)
|
||||
└── AppDelegate (MetricKit diagnostics, Notifications init)
|
||||
└── ContentView
|
||||
├── OnboardingView (first launch)
|
||||
├── AppLockView (if security enabled)
|
||||
|
||||
+150
@@ -0,0 +1,150 @@
|
||||
# iPhone Duo — estado de la adaptación (Portfolio Journal 1.7.0)
|
||||
|
||||
Fase 1 hecha con Xcode 27.0 / SDK 27.0. **Fase 2 hecha con Xcode 27.1 beta
|
||||
(27A9269, SDK iOS 27.1) y el simulador real de iPhone Duo** — lo que se pudo
|
||||
verificar y lo que no está dicho con nombre y apellidos más abajo.
|
||||
|
||||
## El dispositivo, medido (no supuesto)
|
||||
|
||||
| Dato | Valor medido |
|
||||
|---|---|
|
||||
| Modelo | `iPhone19,4`, Touch ID (`com.apple.touch-id: true` en el perfil) |
|
||||
| Pantalla exterior | 1398 × 2034 px @3x = **466 × 678 pt** (`screenID` 1, "LCD") |
|
||||
| Pantalla interior | 2007 × 2853 px @3x = **669 × 951 pt** (`screenID` 3, "LCD-1") |
|
||||
| Size class exterior | **compact × regular** (medido: el split view colapsa y el `.inspector` sale como hoja) |
|
||||
| Pose al arrancar | **plegado**: `SIMULATOR_MAINSCREEN_*` = 1398×2034, la interior no pinta nada |
|
||||
|
||||
La pantalla interior (669 pt) es **más estrecha que cualquier iPad** (el iPad mini
|
||||
son 744 pt en vertical), así que no hay ningún simulador que la imite.
|
||||
|
||||
## Qué se ha cerrado en la fase 2
|
||||
|
||||
- **Compila con el SDK 27.1 sin un solo error ni warning nuevo** (app, widget,
|
||||
extensión Quick Update y app + complicaciones de watchOS).
|
||||
- **Verificado en el simulador de iPhone Duo de verdad**, pantalla exterior
|
||||
(466 × 678 pt), con capturas leídas una a una:
|
||||
- iOS 27 coloca la **barra de pestañas y la toolbar en vertical, pegadas al
|
||||
borde derecho**, con la barra de estado (reloj, Wi-Fi, cámara) encima en esa
|
||||
misma franja. Al contenido de la app le quedan ~360 pt de ancho. Nada se
|
||||
corta y nada se solapa con la franja del sistema: el safe area ya la aparta.
|
||||
- `.inspector` de Quick Update: en compacto se presenta como **hoja**, como
|
||||
debe. El dashboard sigue debajo.
|
||||
- Fuentes y Diario colapsan a una sola columna. La selección se conserva.
|
||||
- Gráficos usa los 360 pt completos, con el selector de periodo y el gráfico
|
||||
a lo alto.
|
||||
- **No queda hueco al pie donde estaba el banner de AdMob**: el contenido
|
||||
llega al borde inferior. No hay ni una referencia a AdMob, Firebase ni ATT
|
||||
en el proyecto (verificado por grep sobre `.swift`, `.plist` y `.pbxproj`).
|
||||
- **Biometría**: `DuoLayoutUITests` ahora baja hasta "App Lock" en Ajustes y
|
||||
**compara el texto del toggle con `LAContext.biometryType` del propio
|
||||
dispositivo**; en el Duo tiene que decir "Touch ID". La tarjeta de novedades
|
||||
ya no lleva el icono `touchid` fijo: sale de `AppLockService.biometryKind`.
|
||||
- **Release notes sin emoji** (TestFlight devuelve 409 con ellos) en
|
||||
`RELEASE_NOTES.md` y en `fastlane/metadata/{en-US,en-GB,en-AU,en-CA,es-ES,es-MX}`.
|
||||
|
||||
## APIs nuevas de 27.1: qué se adopta y qué no
|
||||
|
||||
Las APIs existen y se han comprobado contra el SDK (compilan):
|
||||
|
||||
- SwiftUI: `ReservedRegion` (`.occlusion` / `.division`, con `frame`, `margins`,
|
||||
`isActive`) vía `GeometryProxy.reservedRegions(kind:options:layoutDirectionBehavior:)`;
|
||||
`ArrangementView(primary:secondary:)` con `.arrangementViewStyle(.automatic/.split/.overlay)`;
|
||||
`View.onHingeChange(isEnabled:_:)` con `DeviceHinge.status` (`.closed`,
|
||||
`.partiallyOpen`, `.fullyOpen`) y `DeviceHinge.angle`.
|
||||
- UIKit: `UIView.reservedRegions(kind:options:)`, `UIArrangementViewController`,
|
||||
`UIHingeInteraction` / `UIHinge`.
|
||||
|
||||
**`ReservedRegion` — no se adopta.** No hay en la app ni una vista a sangre que
|
||||
llegue a una esquina o a un borde: todo va con padding dentro de `ScrollView` /
|
||||
`List`, y lo único que ignora el safe area es el fondo decorativo
|
||||
(`AppBackground`). En la pantalla exterior se ha comprobado en captura que el
|
||||
safe area ya aparta el contenido de la franja del sistema, sin tocar nada. La
|
||||
única región que aportaría algo es la `.division` del pliegue en la pantalla
|
||||
interior, y esa **no se puede ejercitar** (ver limitaciones): meter un layout
|
||||
consciente del pliegue que no se puede probar en ningún dispositivo alcanzable
|
||||
es código muerto.
|
||||
|
||||
**`ArrangementView` — no se adopta.** Es un contenedor de dos vistas
|
||||
(primaria/secundaria) que elige split u overlay. `NavigationSplitView` ya
|
||||
resuelve Fuentes y Diario **y además** aporta lo que `ArrangementView` no tiene:
|
||||
selección de lista, anchos de columna (`navigationSplitViewColumnWidth`),
|
||||
colapso a pila en compacto y barra de navegación. Cambiarlo sería perder función
|
||||
a cambio de nada. Igual en Gráficos, donde el `HStack` barra lateral + panel no
|
||||
necesita decidir entre split y overlay.
|
||||
|
||||
**`ConcentricRectangle` — no se adopta**, por lo mismo que en fase 1: no hay
|
||||
contenido a sangre que deba seguir la esquina.
|
||||
|
||||
## Qué NO se ha podido verificar (y por qué)
|
||||
|
||||
1. **La pantalla interior, entera.** El simulador del Duo arranca plegado y no
|
||||
hay forma de cambiar la pose desde la línea de comandos:
|
||||
- `simctl ui` solo ofrece `appearance`, `content_size` e `increase_contrast`
|
||||
(no hay `pose`, `fold`, `posture` ni `arrangement`).
|
||||
- No aparece nada de pose/hinge en `CoreSimulator`, en `SimulatorKit` ni en
|
||||
los frameworks de dispositivos de Xcode.
|
||||
- `simctl io … screenConfig --display=3 power on` responde "ok" pero la
|
||||
pantalla interior sigue capturando negro; `geometry 2007x2853@3` sobre la
|
||||
exterior se rechaza ("No mode found").
|
||||
Las poses parecen estar solo en el Device Hub (GUI) de Xcode.
|
||||
2. **Por tanto, tampoco**: si la interior es realmente regular × regular a 669 pt,
|
||||
ni si el `.inspector` de Quick Update se convierte ahí en overlay con scrim
|
||||
(la regresión de iOS 27 medida en Quipu aparece por debajo de ~744 pt, y 669
|
||||
está por debajo). **Queda como el riesgo número uno.**
|
||||
3. **Compacto en un iPhone con iOS 27.1**: imposible. El runtime iOS 27.1
|
||||
instalado **solo soporta el device type iPhone Duo** (`supportedDeviceTypes`
|
||||
tiene un único elemento). El iPhone 17 Pro solo se puede probar con iOS 27.0.
|
||||
4. **Rotación**: `XCUIDevice.shared.orientation = .landscapeLeft` no tiene efecto
|
||||
en el simulador del Duo — las capturas "landscape" salen idénticas a las
|
||||
verticales. No se ha podido ejercitar el horizontal en este dispositivo.
|
||||
|
||||
## Qué queda para la fase 3
|
||||
|
||||
- Con pose disponible (Device Hub, dispositivo real o un `simctl` que la exponga):
|
||||
medir la size class y el ancho reales de la pantalla interior y comprobar el
|
||||
`.inspector`. **Si sale overlay con scrim, la solución ya probada en Quipu es
|
||||
sustituirlo por una columna propia** (`HStack` dashboard + panel, con el ancho
|
||||
del panel entre 340 y 480 pt) solo cuando el ancho regular sea < 744 pt.
|
||||
- Comprobar el plegado en caliente: que el texto tecleado en Quick Update y la
|
||||
selección de Fuentes/Diario sobreviven al cambio de pantalla.
|
||||
- Widgets en la pantalla interior.
|
||||
- Traducir `fastlane/metadata/{de-DE,fr-*,it,ja,pt-*}/release_notes.txt`.
|
||||
- **Subir versión y build cuando 27.1 sea GM**: las builds de App Store no pueden
|
||||
ir firmadas con un SDK beta, así que esto es solo preparación.
|
||||
|
||||
## Cómo probarlo
|
||||
|
||||
```bash
|
||||
export DEVELOPER_DIR=/Applications/Xcode-27.1.app/Contents/Developer
|
||||
SIM=$(xcrun simctl create "Duo-PJ" com.apple.CoreSimulator.SimDeviceType.iPhone-Duo \
|
||||
com.apple.CoreSimulator.SimRuntime.iOS-27-1)
|
||||
xcrun simctl boot $SIM && xcrun simctl bootstatus $SIM -b # tarda ~70 s
|
||||
|
||||
# Build y test en dos pasos: el runner de UI tests se queda sin memoria si
|
||||
# xcodebuild compila y ejecuta a la vez con varios simuladores arrancados.
|
||||
xcodebuild build-for-testing -project PortfolioJournal.xcodeproj \
|
||||
-scheme PortfolioJournal -destination "platform=iOS Simulator,id=$SIM" \
|
||||
-derivedDataPath /tmp/dd-pj
|
||||
xcodebuild test-without-building \
|
||||
-xctestrun /tmp/dd-pj/Build/Products/PortfolioJournal_iphonesimulator27.1-arm64.xctestrun \
|
||||
-destination "platform=iOS Simulator,id=$SIM" -parallel-testing-enabled NO \
|
||||
-only-testing:PortfolioJournalUITests/DuoLayoutUITests \
|
||||
-resultBundlePath /tmp/duo-pj.xcresult
|
||||
xcrun xcresulttool export attachments --path /tmp/duo-pj.xcresult --output-path /tmp/duo-pj-png
|
||||
|
||||
# Capturas sueltas, eligiendo pantalla (1 = exterior, 3 = interior):
|
||||
xcrun simctl io $SIM screenshot --display=1 /tmp/duo.png
|
||||
xcrun simctl delete $SIM
|
||||
```
|
||||
|
||||
`-parallel-testing-enabled NO` es obligatorio: Xcode clona el simulador para
|
||||
paralelizar y el clon del Duo no arranca en los 60 s que espera.
|
||||
|
||||
## Verificado
|
||||
|
||||
- 18-19 sep 2026, Xcode 27.1 beta, simulador iPhone Duo (iOS 27.1):
|
||||
`xcodebuild build` sin errores ni warnings; `DuoLayoutUITests` en verde
|
||||
(466 × 678 pt, pantalla exterior) con 12 capturas revisadas.
|
||||
- 16 sep 2026, Xcode 27.0: 108/108 tests unitarios en iPhone 17 Pro;
|
||||
`DuoLayoutUITests` en iPhone 17 Pro (compacto) y iPad mini A17 Pro
|
||||
(regular × regular, 744 pt).
|
||||
+26
-2
@@ -2,13 +2,37 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>destination</key>
|
||||
<string>export</string>
|
||||
<key>manageAppVersionAndBuildNumber</key>
|
||||
<false/>
|
||||
<key>method</key>
|
||||
<string>app-store-connect</string>
|
||||
<!-- Firma MANUAL obligatoria: el cloud signing con API key falla al exportar las
|
||||
extensiones ("No profiles ... were found"). Los 5 targets embebidos se firman
|
||||
con "Apple Distribution" + sus perfiles App Store instalados localmente. -->
|
||||
<key>signingStyle</key>
|
||||
<string>manual</string>
|
||||
<key>signingCertificate</key>
|
||||
<string>Apple Distribution</string>
|
||||
<key>provisioningProfiles</key>
|
||||
<dict>
|
||||
<key>com.alexandrevazquez.PortfolioJournal</key>
|
||||
<string>porfoliojournal</string>
|
||||
<key>com.alexandrevazquez.PortfolioJournal.PortfolioJournalWidget</key>
|
||||
<string>Portfolio Journalwidget</string>
|
||||
<key>com.alexandrevazquez.PortfolioJournal.QuickUpdate</key>
|
||||
<string>PortfolioJournal QuickUpdate AppStore</string>
|
||||
<key>com.alexandrevazquez.PortfolioJournal.watchkitapp</key>
|
||||
<string>Portfolio Journal Watch App Store</string>
|
||||
<key>com.alexandrevazquez.PortfolioJournal.watchkitapp.widget</key>
|
||||
<string>Portfolio Journal Watch Widget App Store</string>
|
||||
</dict>
|
||||
<key>stripSwiftSymbols</key>
|
||||
<true/>
|
||||
<key>teamID</key>
|
||||
<string>2825Q76T7H</string>
|
||||
<key>uploadSymbols</key>
|
||||
<true/>
|
||||
<key>signingStyle</key>
|
||||
<string>automatic</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -10,12 +10,10 @@
|
||||
0E241ECF2F0DAA3C00283E2F /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0E241ECE2F0DAA3C00283E2F /* WidgetKit.framework */; };
|
||||
0E241ED12F0DAA3C00283E2F /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0E241ED02F0DAA3C00283E2F /* SwiftUI.framework */; };
|
||||
0E241EE22F0DAA3E00283E2F /* PortfolioJournalWidgetExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 0E241ECC2F0DAA3C00283E2F /* PortfolioJournalWidgetExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
0E53752B2F0FD08100F31390 /* FirebaseCore in Frameworks */ = {isa = PBXBuildFile; productRef = 0E53752A2F0FD08100F31390 /* FirebaseCore */; };
|
||||
0E53752D2F0FD08600F31390 /* FirebaseAnalytics in Frameworks */ = {isa = PBXBuildFile; productRef = 0E53752C2F0FD08600F31390 /* FirebaseAnalytics */; };
|
||||
0E53752F2F0FD09F00F31390 /* CoreData.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0E53752E2F0FD09F00F31390 /* CoreData.framework */; };
|
||||
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 +45,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 +74,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 +109,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 +142,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 +210,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 */
|
||||
@@ -151,11 +242,7 @@
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
0E5375312F0FD12E00F31390 /* GoogleMobileAds in Frameworks */,
|
||||
0E53752D2F0FD08600F31390 /* FirebaseAnalytics in Frameworks */,
|
||||
0E53752F2F0FD09F00F31390 /* CoreData.framework in Frameworks */,
|
||||
0E53752B2F0FD08100F31390 /* FirebaseCore in Frameworks */,
|
||||
0E5375352F0FD14000F31390 /* FirebaseCrashlytics in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -189,6 +276,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 +305,9 @@
|
||||
0EUITEST032F31000000001 /* PortfolioJournalUITests */,
|
||||
0E241ECD2F0DAA3C00283E2F /* Frameworks */,
|
||||
0E241E3A2F0DA93A00283E2F /* Products */,
|
||||
0EWTCH070000000000000000 /* Shared */,
|
||||
0EWTCH050000000000000000 /* PortfolioJournalWatch */,
|
||||
0EWTCH060000000000000000 /* PortfolioJournalWatchWidget */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
@@ -215,6 +319,8 @@
|
||||
0EQUPD012F40000000000001 /* PortfolioJournalQuickUpdate.appex */,
|
||||
0ETEST0002F31000000000001 /* PortfolioJournalTests.xctest */,
|
||||
0EUITEST002F31000000001 /* PortfolioJournalUITests.xctest */,
|
||||
0EWTCH030000000000000000 /* PortfolioJournalWatch.app */,
|
||||
0EWTCH040000000000000000 /* PortfolioJournalWatchWidget.appex */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
@@ -240,24 +346,19 @@
|
||||
0E241E362F0DA93A00283E2F /* Frameworks */,
|
||||
0E241EE72F0DAA3E00283E2F /* Embed Foundation Extensions */,
|
||||
0E8318932F0DB2FB0030C2F9 /* Resources */,
|
||||
0E5375362F0FD14000F31390 /* Upload dSYMs to Crashlytics */,
|
||||
0EWTCH240000000000000000 /* Embed Watch Content */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
0E241EE12F0DAA3E00283E2F /* PBXTargetDependency */,
|
||||
0EQUPD122F40000000000001 /* PBXTargetDependency */,
|
||||
0EWTCH230000000000000000 /* PBXTargetDependency */,
|
||||
);
|
||||
fileSystemSynchronizedGroups = (
|
||||
0E241E3B2F0DA93A00283E2F /* PortfolioJournal */,
|
||||
);
|
||||
name = PortfolioJournal;
|
||||
packageProductDependencies = (
|
||||
0E53752A2F0FD08100F31390 /* FirebaseCore */,
|
||||
0E53752C2F0FD08600F31390 /* FirebaseAnalytics */,
|
||||
0E5375302F0FD12E00F31390 /* GoogleMobileAds */,
|
||||
0E5375342F0FD14000F31390 /* FirebaseCrashlytics */,
|
||||
);
|
||||
productName = PortfolioJournal;
|
||||
productReference = 0E241E392F0DA93A00283E2F /* PortfolioJournal.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
@@ -278,8 +379,6 @@
|
||||
0E241ED22F0DAA3C00283E2F /* PortfolioJournalWidget */,
|
||||
);
|
||||
name = PortfolioJournalWidgetExtension;
|
||||
packageProductDependencies = (
|
||||
);
|
||||
productName = PortfolioJournalWidgetExtension;
|
||||
productReference = 0E241ECC2F0DAA3C00283E2F /* PortfolioJournalWidgetExtension.appex */;
|
||||
productType = "com.apple.product-type.app-extension";
|
||||
@@ -301,8 +400,6 @@
|
||||
0ETEST0032F31000000000001 /* PortfolioJournalTests */,
|
||||
);
|
||||
name = PortfolioJournalTests;
|
||||
packageProductDependencies = (
|
||||
);
|
||||
productName = PortfolioJournalTests;
|
||||
productReference = 0ETEST0002F31000000000001 /* PortfolioJournalTests.xctest */;
|
||||
productType = "com.apple.product-type.bundle.unit-test";
|
||||
@@ -324,8 +421,6 @@
|
||||
0EUITEST032F31000000001 /* PortfolioJournalUITests */,
|
||||
);
|
||||
name = PortfolioJournalUITests;
|
||||
packageProductDependencies = (
|
||||
);
|
||||
productName = PortfolioJournalUITests;
|
||||
productReference = 0EUITEST002F31000000001 /* PortfolioJournalUITests.xctest */;
|
||||
productType = "com.apple.product-type.bundle.ui-testing";
|
||||
@@ -346,12 +441,53 @@
|
||||
0EQUPD032F40000000000001 /* PortfolioJournalQuickUpdate */,
|
||||
);
|
||||
name = PortfolioJournalQuickUpdate;
|
||||
packageProductDependencies = (
|
||||
);
|
||||
productName = PortfolioJournalQuickUpdate;
|
||||
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;
|
||||
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;
|
||||
productName = PortfolioJournalWatchWidget;
|
||||
productReference = 0EWTCH040000000000000000 /* PortfolioJournalWatchWidget.appex */;
|
||||
productType = "com.apple.product-type.app-extension";
|
||||
};
|
||||
/* End PBXNativeTarget section */
|
||||
|
||||
/* Begin PBXProject section */
|
||||
@@ -392,10 +528,6 @@
|
||||
);
|
||||
mainGroup = 0E241E302F0DA93A00283E2F;
|
||||
minimizedProjectReferenceProxies = 1;
|
||||
packageReferences = (
|
||||
0E241EEB2F0DABEC00283E2F /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */,
|
||||
0E241EEC2F0DAC2D00283E2F /* XCRemoteSwiftPackageReference "swift-package-manager-google-mobile-ads" */,
|
||||
);
|
||||
preferredProjectObjectVersion = 77;
|
||||
productRefGroup = 0E241E3A2F0DA93A00283E2F /* Products */;
|
||||
projectDirPath = "";
|
||||
@@ -406,6 +538,8 @@
|
||||
0EQUPD022F40000000000001 /* PortfolioJournalQuickUpdate */,
|
||||
0ETEST0012F31000000000001 /* PortfolioJournalTests */,
|
||||
0EUITEST012F31000000001 /* PortfolioJournalUITests */,
|
||||
0EWTCH010000000000000000 /* PortfolioJournalWatch */,
|
||||
0EWTCH020000000000000000 /* PortfolioJournalWatchWidget */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
@@ -446,6 +580,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,30 +632,22 @@
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXShellScriptBuildPhase section */
|
||||
0E5375362F0FD14000F31390 /* Upload dSYMs to Crashlytics */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
0EWTCH120000000000000000 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputFileListPaths = (
|
||||
);
|
||||
inputPaths = (
|
||||
"${DWARF_DSYM_FOLDER_PATH}/${DWARF_DSYM_FILE_NAME}/Contents/Resources/DWARF/${TARGET_NAME}",
|
||||
"$(BUILT_PRODUCTS_DIR)/$(INFOPLIST_PATH)",
|
||||
);
|
||||
name = "Upload dSYMs to Crashlytics";
|
||||
outputFileListPaths = (
|
||||
);
|
||||
outputPaths = (
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
0EWTCH160000000000000000 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "\"${BUILD_DIR%/Build/*}/SourcePackages/checkouts/firebase-ios-sdk/Crashlytics/run\"\n";
|
||||
};
|
||||
/* End PBXShellScriptBuildPhase section */
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
|
||||
/* Begin PBXTargetDependency section */
|
||||
0E241EE12F0DAA3E00283E2F /* PBXTargetDependency */ = {
|
||||
@@ -530,6 +670,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 */
|
||||
@@ -539,9 +689,9 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_ENTITLEMENTS = PortfolioJournal/PortfolioJournalDebug.entitlements;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 85;
|
||||
CURRENT_PROJECT_VERSION = 89;
|
||||
DEVELOPMENT_ASSET_PATHS = PortfolioJournal/Assets.xcassets;
|
||||
DEVELOPMENT_TEAM = 2825Q76T7H;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
@@ -559,12 +709,13 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.6.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;
|
||||
@@ -578,11 +729,11 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_ENTITLEMENTS = PortfolioJournal/PortfolioJournal.entitlements;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
CODE_SIGN_IDENTITY = "Apple Distribution";
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
CODE_SIGN_IDENTITY = "Apple Distribution: Alexandre Vazquez";
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
PROVISIONING_PROFILE_SPECIFIER = "porfoliojournal";
|
||||
CURRENT_PROJECT_VERSION = 85;
|
||||
CURRENT_PROJECT_VERSION = 89;
|
||||
DEVELOPMENT_ASSET_PATHS = PortfolioJournal/Assets.xcassets;
|
||||
DEVELOPMENT_TEAM = 2825Q76T7H;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
@@ -600,12 +751,13 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.6.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 +891,7 @@
|
||||
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
|
||||
CODE_SIGN_ENTITLEMENTS = PortfolioJournalWidgetExtension.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 85;
|
||||
CURRENT_PROJECT_VERSION = 89;
|
||||
DEVELOPMENT_ASSET_PATHS = PortfolioJournalWidget/Assets.xcassets;
|
||||
DEVELOPMENT_TEAM = 2825Q76T7H;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
@@ -752,7 +904,7 @@
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.6.2;
|
||||
MARKETING_VERSION = 1.7.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournal.PortfolioJournalWidget;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SKIP_INSTALL = YES;
|
||||
@@ -771,10 +923,10 @@
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
|
||||
CODE_SIGN_ENTITLEMENTS = PortfolioJournalWidgetExtension.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Distribution";
|
||||
CODE_SIGN_IDENTITY = "Apple Distribution: Alexandre Vazquez";
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
PROVISIONING_PROFILE_SPECIFIER = "Portfolio Journalwidget";
|
||||
CURRENT_PROJECT_VERSION = 85;
|
||||
CURRENT_PROJECT_VERSION = 89;
|
||||
DEVELOPMENT_ASSET_PATHS = PortfolioJournalWidget/Assets.xcassets;
|
||||
DEVELOPMENT_TEAM = 2825Q76T7H;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
@@ -787,7 +939,7 @@
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.6.2;
|
||||
MARKETING_VERSION = 1.7.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournal.PortfolioJournalWidget;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SKIP_INSTALL = YES;
|
||||
@@ -805,7 +957,7 @@
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 85;
|
||||
CURRENT_PROJECT_VERSION = 89;
|
||||
DEVELOPMENT_TEAM = 2825Q76T7H;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
|
||||
@@ -814,7 +966,7 @@
|
||||
"@executable_path/Frameworks",
|
||||
"@loader_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.6.2;
|
||||
MARKETING_VERSION = 1.7.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournalTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||
@@ -829,7 +981,7 @@
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 85;
|
||||
CURRENT_PROJECT_VERSION = 89;
|
||||
DEVELOPMENT_TEAM = 2825Q76T7H;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
|
||||
@@ -838,7 +990,7 @@
|
||||
"@executable_path/Frameworks",
|
||||
"@loader_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.6.2;
|
||||
MARKETING_VERSION = 1.7.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournalTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||
@@ -852,7 +1004,7 @@
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 85;
|
||||
CURRENT_PROJECT_VERSION = 89;
|
||||
DEVELOPMENT_TEAM = 2825Q76T7H;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
|
||||
@@ -861,7 +1013,7 @@
|
||||
"@executable_path/Frameworks",
|
||||
"@loader_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.6.2;
|
||||
MARKETING_VERSION = 1.7.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournalUITests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||
@@ -875,7 +1027,7 @@
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 85;
|
||||
CURRENT_PROJECT_VERSION = 89;
|
||||
DEVELOPMENT_TEAM = 2825Q76T7H;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
|
||||
@@ -884,7 +1036,7 @@
|
||||
"@executable_path/Frameworks",
|
||||
"@loader_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.6.2;
|
||||
MARKETING_VERSION = 1.7.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournalUITests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||
@@ -899,7 +1051,7 @@
|
||||
buildSettings = {
|
||||
CODE_SIGN_ENTITLEMENTS = PortfolioJournalQuickUpdateExtension.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 85;
|
||||
CURRENT_PROJECT_VERSION = 89;
|
||||
DEVELOPMENT_TEAM = 2825Q76T7H;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = PortfolioJournalQuickUpdate/Info.plist;
|
||||
@@ -911,7 +1063,7 @@
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.6.2;
|
||||
MARKETING_VERSION = 1.7.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournal.QuickUpdate;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SKIP_INSTALL = YES;
|
||||
@@ -925,9 +1077,9 @@
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_ENTITLEMENTS = PortfolioJournalQuickUpdateExtension.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Distribution";
|
||||
CODE_SIGN_IDENTITY = "Apple Distribution: Alexandre Vazquez";
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
CURRENT_PROJECT_VERSION = 85;
|
||||
CURRENT_PROJECT_VERSION = 89;
|
||||
DEVELOPMENT_TEAM = 2825Q76T7H;
|
||||
PROVISIONING_PROFILE_SPECIFIER = "PortfolioJournal QuickUpdate AppStore";
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
@@ -940,7 +1092,7 @@
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.6.2;
|
||||
MARKETING_VERSION = 1.7.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournal.QuickUpdate;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SKIP_INSTALL = YES;
|
||||
@@ -950,6 +1102,150 @@
|
||||
};
|
||||
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_IDENTITY = "Apple Distribution: Alexandre Vazquez";
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
PROVISIONING_PROFILE_SPECIFIER = "Portfolio Journal Watch App Store";
|
||||
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_IDENTITY = "Apple Distribution: Alexandre Vazquez";
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
PROVISIONING_PROFILE_SPECIFIER = "Portfolio Journal Watch Widget App Store";
|
||||
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,49 +1303,27 @@
|
||||
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 */
|
||||
0E241EEB2F0DABEC00283E2F /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */ = {
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/firebase/firebase-ios-sdk";
|
||||
requirement = {
|
||||
kind = upToNextMajorVersion;
|
||||
minimumVersion = 12.7.0;
|
||||
};
|
||||
};
|
||||
0E241EEC2F0DAC2D00283E2F /* XCRemoteSwiftPackageReference "swift-package-manager-google-mobile-ads" */ = {
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/googleads/swift-package-manager-google-mobile-ads";
|
||||
requirement = {
|
||||
kind = upToNextMajorVersion;
|
||||
minimumVersion = 12.14.0;
|
||||
};
|
||||
};
|
||||
/* End XCRemoteSwiftPackageReference section */
|
||||
|
||||
/* Begin XCSwiftPackageProductDependency section */
|
||||
0E53752A2F0FD08100F31390 /* FirebaseCore */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = 0E241EEB2F0DABEC00283E2F /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */;
|
||||
productName = FirebaseCore;
|
||||
};
|
||||
0E53752C2F0FD08600F31390 /* FirebaseAnalytics */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = 0E241EEB2F0DABEC00283E2F /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */;
|
||||
productName = FirebaseAnalytics;
|
||||
};
|
||||
0E5375302F0FD12E00F31390 /* GoogleMobileAds */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = 0E241EEC2F0DAC2D00283E2F /* XCRemoteSwiftPackageReference "swift-package-manager-google-mobile-ads" */;
|
||||
productName = GoogleMobileAds;
|
||||
};
|
||||
0E5375342F0FD14000F31390 /* FirebaseCrashlytics */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = 0E241EEB2F0DABEC00283E2F /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */;
|
||||
productName = FirebaseCrashlytics;
|
||||
};
|
||||
/* End XCSwiftPackageProductDependency section */
|
||||
};
|
||||
rootObject = 0E241E312F0DA93A00283E2F /* Project object */;
|
||||
}
|
||||
|
||||
@@ -1,150 +0,0 @@
|
||||
{
|
||||
"originHash" : "2d3f06aab1ccdfff7b100ca10208a834bfb664b3c50890610f1e3c349eb3fbf3",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "abseil-cpp-binary",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/google/abseil-cpp-binary.git",
|
||||
"state" : {
|
||||
"revision" : "bbe8b69694d7873315fd3a4ad41efe043e1c07c5",
|
||||
"version" : "1.2024072200.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "app-check",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/google/app-check.git",
|
||||
"state" : {
|
||||
"revision" : "61b85103a1aeed8218f17c794687781505fbbef5",
|
||||
"version" : "11.2.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "firebase-ios-sdk",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/firebase/firebase-ios-sdk",
|
||||
"state" : {
|
||||
"revision" : "45210bd1ea695779e6de016ab00fea8c0b7eb2ef",
|
||||
"version" : "12.7.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "google-ads-on-device-conversion-ios-sdk",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/googleads/google-ads-on-device-conversion-ios-sdk",
|
||||
"state" : {
|
||||
"revision" : "35b601a60fbbea2de3ea461f604deaaa4d8bbd0c",
|
||||
"version" : "3.2.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "googleappmeasurement",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/google/GoogleAppMeasurement.git",
|
||||
"state" : {
|
||||
"revision" : "c2d59acf17a8ba7ed80a763593c67c9c7c006ad1",
|
||||
"version" : "12.5.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "googledatatransport",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/google/GoogleDataTransport.git",
|
||||
"state" : {
|
||||
"revision" : "617af071af9aa1d6a091d59a202910ac482128f9",
|
||||
"version" : "10.1.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "googleutilities",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/google/GoogleUtilities.git",
|
||||
"state" : {
|
||||
"revision" : "60da361632d0de02786f709bdc0c4df340f7613e",
|
||||
"version" : "8.1.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "grpc-binary",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/google/grpc-binary.git",
|
||||
"state" : {
|
||||
"revision" : "75b31c842f664a0f46a2e590a570e370249fd8f6",
|
||||
"version" : "1.69.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "gtm-session-fetcher",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/google/gtm-session-fetcher.git",
|
||||
"state" : {
|
||||
"revision" : "fb7f2740b1570d2f7599c6bb9531bf4fad6974b7",
|
||||
"version" : "5.0.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "interop-ios-for-google-sdks",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/google/interop-ios-for-google-sdks.git",
|
||||
"state" : {
|
||||
"revision" : "040d087ac2267d2ddd4cca36c757d1c6a05fdbfe",
|
||||
"version" : "101.0.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "leveldb",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/firebase/leveldb.git",
|
||||
"state" : {
|
||||
"revision" : "a0bc79961d7be727d258d33d5a6b2f1023270ba1",
|
||||
"version" : "1.22.5"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "nanopb",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/firebase/nanopb.git",
|
||||
"state" : {
|
||||
"revision" : "b7e1104502eca3a213b46303391ca4d3bc8ddec1",
|
||||
"version" : "2.30910.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "promises",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/google/promises.git",
|
||||
"state" : {
|
||||
"revision" : "540318ecedd63d883069ae7f1ed811a2df00b6ac",
|
||||
"version" : "2.4.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-package-manager-google-mobile-ads",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/googleads/swift-package-manager-google-mobile-ads",
|
||||
"state" : {
|
||||
"revision" : "1239c23464503053c17d37ee5daa70de3455e9c8",
|
||||
"version" : "12.14.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-package-manager-google-user-messaging-platform",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/googleads/swift-package-manager-google-user-messaging-platform.git",
|
||||
"state" : {
|
||||
"revision" : "13b248eaa73b7826f0efb1bcf455e251d65ecb1b",
|
||||
"version" : "3.1.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-protobuf",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-protobuf.git",
|
||||
"state" : {
|
||||
"revision" : "c169a5744230951031770e27e475ff6eefe51f9d",
|
||||
"version" : "1.33.3"
|
||||
}
|
||||
}
|
||||
],
|
||||
"version" : 3
|
||||
}
|
||||
@@ -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>
|
||||
@@ -1,28 +1,25 @@
|
||||
import UIKit
|
||||
import UserNotifications
|
||||
import FirebaseCore
|
||||
import FirebaseAnalytics
|
||||
import FirebaseCrashlytics
|
||||
import GoogleMobileAds
|
||||
|
||||
class AppDelegate: NSObject, UIApplicationDelegate {
|
||||
func application(
|
||||
_ application: UIApplication,
|
||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
|
||||
) -> Bool {
|
||||
// Initialize Firebase (only if GoogleService-Info.plist exists)
|
||||
if Bundle.main.path(forResource: "GoogleService-Info", ofType: "plist") != nil {
|
||||
FirebaseApp.configure()
|
||||
} else {
|
||||
print("Warning: GoogleService-Info.plist not found. Firebase disabled.")
|
||||
}
|
||||
|
||||
// Initialize Google Mobile Ads
|
||||
MobileAds.shared.start()
|
||||
// On-device crash/hang diagnostics. Nothing is uploaded anywhere: reports
|
||||
// stay in the app container and Apple's own pipeline is what feeds the
|
||||
// Xcode Organizer. See DiagnosticsCollector.
|
||||
DiagnosticsCollector.shared.start()
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
|
||||
@@ -31,7 +31,6 @@ enum AppTab: Int, Hashable, CaseIterable {
|
||||
|
||||
struct ContentView: View {
|
||||
@EnvironmentObject var iapService: IAPService
|
||||
@EnvironmentObject var adMobService: AdMobService
|
||||
@EnvironmentObject var tabSelection: TabSelectionStore
|
||||
@EnvironmentObject var coreDataStack: CoreDataStack
|
||||
@AppStorage("onboardingCompleted") private var onboardingCompleted = false
|
||||
@@ -41,13 +40,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 +75,6 @@ struct ContentView: View {
|
||||
OnboardingICloudCheckView(onSkip: { iCloudCheckDone = true })
|
||||
} else if resolvedOnboardingCompleted == false {
|
||||
OnboardingView(onboardingCompleted: $onboardingCompleted)
|
||||
} else if horizontalSizeClass == .regular {
|
||||
iPadMainContent
|
||||
} else {
|
||||
mainContent
|
||||
}
|
||||
@@ -92,6 +90,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 {
|
||||
@@ -119,7 +123,7 @@ struct ContentView: View {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -144,18 +148,15 @@ struct ContentView: View {
|
||||
))
|
||||
}
|
||||
.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)
|
||||
}
|
||||
}
|
||||
@@ -168,7 +169,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
|
||||
@@ -192,7 +193,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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -240,93 +241,61 @@ 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())
|
||||
DashboardView()
|
||||
case .sources:
|
||||
bannerInsetView(SourceListView(iapService: iapService))
|
||||
SourceListView(iapService: iapService)
|
||||
case .charts:
|
||||
bannerInsetView(ChartsContainerView(iapService: iapService))
|
||||
ChartsContainerView(iapService: iapService)
|
||||
case .journal:
|
||||
bannerInsetView(JournalView())
|
||||
JournalView()
|
||||
case .settings:
|
||||
bannerInsetView(SettingsView(iapService: iapService))
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
BannerAdView()
|
||||
.frame(height: AppConstants.UI.bannerAdHeight)
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(Color(.systemBackground))
|
||||
}
|
||||
SettingsView(iapService: iapService)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -335,7 +304,6 @@ struct ContentView: View {
|
||||
ContentView()
|
||||
.environmentObject(CoreDataStack.shared)
|
||||
.environmentObject(IAPService())
|
||||
.environmentObject(AdMobService())
|
||||
.environmentObject(AccountStore(iapService: IAPService()))
|
||||
.environmentObject(TabSelectionStore())
|
||||
.environmentObject(BalancePrivacyManager.shared)
|
||||
|
||||
@@ -24,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
|
||||
@@ -59,7 +62,6 @@ enum ScreenshotMode {
|
||||
struct PortfolioJournalApp: App {
|
||||
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
|
||||
@StateObject private var iapService: IAPService
|
||||
@StateObject private var adMobService: AdMobService
|
||||
@StateObject private var accountStore: AccountStore
|
||||
@StateObject private var tabSelection = TabSelectionStore()
|
||||
@StateObject private var balancePrivacy = BalancePrivacyManager.shared
|
||||
@@ -80,7 +82,6 @@ struct PortfolioJournalApp: App {
|
||||
|
||||
let iap = IAPService()
|
||||
_iapService = StateObject(wrappedValue: iap)
|
||||
_adMobService = StateObject(wrappedValue: AdMobService())
|
||||
_accountStore = StateObject(wrappedValue: AccountStore(iapService: iap))
|
||||
}
|
||||
|
||||
@@ -90,12 +91,17 @@ struct PortfolioJournalApp: App {
|
||||
.environment(\.managedObjectContext, coreDataStack.viewContext)
|
||||
.environmentObject(coreDataStack)
|
||||
.environmentObject(iapService)
|
||||
.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
|
||||
|
||||
@@ -10,6 +10,9 @@ public class AppSettings: NSManagedObject, Identifiable {
|
||||
@NSManaged public var id: UUID
|
||||
@NSManaged public var currency: String
|
||||
@NSManaged public var defaultNotificationTime: Date?
|
||||
/// Unused since 1.7.0: there is no analytics of any kind to enable or
|
||||
/// disable. The attribute stays only because dropping it would mean a Core
|
||||
/// Data migration plus a CloudKit schema deploy. Do not wire it to anything.
|
||||
@NSManaged public var enableAnalytics: Bool
|
||||
@NSManaged public var onboardingCompleted: Bool
|
||||
@NSManaged public var lastSyncDate: Date?
|
||||
|
||||
@@ -28,6 +28,7 @@ public class Category: NSManagedObject, Identifiable {
|
||||
@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()
|
||||
@@ -43,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
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ public class Goal: NSManagedObject, Identifiable {
|
||||
set { setManagedValue(newValue, forKey: "createdAt") }
|
||||
}
|
||||
@NSManaged public var account: Account?
|
||||
@NSManaged public var category: Category?
|
||||
|
||||
public override func awakeFromInsert() {
|
||||
super.awakeFromInsert()
|
||||
@@ -42,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)"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+2
@@ -32,6 +32,7 @@
|
||||
<attribute name="id" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
|
||||
<attribute name="name" attributeType="String" defaultValueString=""/>
|
||||
<attribute name="sortOrder" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/>
|
||||
<relationship name="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">
|
||||
@@ -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"/>
|
||||
|
||||
@@ -820,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)
|
||||
@@ -868,6 +868,11 @@ class CoreDataStack: ObservableObject {
|
||||
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.
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>API_KEY</key>
|
||||
<string>AIzaSyAregLaXQ-WqRQTltTRyjQx3-lfxLl4Cng</string>
|
||||
<key>GCM_SENDER_ID</key>
|
||||
<string>334225114072</string>
|
||||
<key>PLIST_VERSION</key>
|
||||
<string>1</string>
|
||||
<key>BUNDLE_ID</key>
|
||||
<string>com.alexandrevazquez.PortfolioJournal</string>
|
||||
<key>PROJECT_ID</key>
|
||||
<string>portfoliojournal-ef2d7</string>
|
||||
<key>STORAGE_BUCKET</key>
|
||||
<string>portfoliojournal-ef2d7.firebasestorage.app</string>
|
||||
<key>IS_ADS_ENABLED</key>
|
||||
<false></false>
|
||||
<key>IS_ANALYTICS_ENABLED</key>
|
||||
<false></false>
|
||||
<key>IS_APPINVITE_ENABLED</key>
|
||||
<true></true>
|
||||
<key>IS_GCM_ENABLED</key>
|
||||
<true></true>
|
||||
<key>IS_SIGNIN_ENABLED</key>
|
||||
<true></true>
|
||||
<key>GOOGLE_APP_ID</key>
|
||||
<string>1:334225114072:ios:a0a50b5835ac042e3d28ad</string>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -31,10 +31,6 @@
|
||||
</array>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
<key>GADApplicationIdentifier</key>
|
||||
<string>ca-app-pub-1549720748100858~9632507420</string>
|
||||
<key>GADDelayAppMeasurementInit</key>
|
||||
<true/>
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
<false/>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
@@ -54,271 +50,6 @@
|
||||
<string>Use Face ID to unlock your portfolio data.</string>
|
||||
<key>NSUbiquitousContainerIsDocumentScopePublic</key>
|
||||
<true/>
|
||||
<key>NSUserTrackingUsageDescription</key>
|
||||
<string>This app uses tracking to provide personalized ads and improve your experience. Your data is not sold to third parties.</string>
|
||||
<key>SKAdNetworkItems</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>SKAdNetworkIdentifier</key>
|
||||
<string>cstr6suwn9.skadnetwork</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>SKAdNetworkIdentifier</key>
|
||||
<string>4fzdc2evr5.skadnetwork</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>SKAdNetworkIdentifier</key>
|
||||
<string>4pfyvq9l8r.skadnetwork</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>SKAdNetworkIdentifier</key>
|
||||
<string>2fnua5tdw4.skadnetwork</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>SKAdNetworkIdentifier</key>
|
||||
<string>ydx93a7ass.skadnetwork</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>SKAdNetworkIdentifier</key>
|
||||
<string>5a6flpkh64.skadnetwork</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>SKAdNetworkIdentifier</key>
|
||||
<string>p78axxw29g.skadnetwork</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>SKAdNetworkIdentifier</key>
|
||||
<string>v72qych5uu.skadnetwork</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>SKAdNetworkIdentifier</key>
|
||||
<string>ludvb6z3bs.skadnetwork</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>SKAdNetworkIdentifier</key>
|
||||
<string>cp8zw746q7.skadnetwork</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>SKAdNetworkIdentifier</key>
|
||||
<string>3sh42y64q3.skadnetwork</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>SKAdNetworkIdentifier</key>
|
||||
<string>c6k4g5qg8m.skadnetwork</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>SKAdNetworkIdentifier</key>
|
||||
<string>s39g8k73mm.skadnetwork</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>SKAdNetworkIdentifier</key>
|
||||
<string>3qy4746246.skadnetwork</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>SKAdNetworkIdentifier</key>
|
||||
<string>f38h382jlk.skadnetwork</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>SKAdNetworkIdentifier</key>
|
||||
<string>hs6bdukanm.skadnetwork</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>SKAdNetworkIdentifier</key>
|
||||
<string>v4nxqhlyqp.skadnetwork</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>SKAdNetworkIdentifier</key>
|
||||
<string>wzmmz9fp6w.skadnetwork</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>SKAdNetworkIdentifier</key>
|
||||
<string>yclnxrl5pm.skadnetwork</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>SKAdNetworkIdentifier</key>
|
||||
<string>t38b2kh725.skadnetwork</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>SKAdNetworkIdentifier</key>
|
||||
<string>7ug5zh24hu.skadnetwork</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>SKAdNetworkIdentifier</key>
|
||||
<string>gta9lk7p23.skadnetwork</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>SKAdNetworkIdentifier</key>
|
||||
<string>vutu7akeur.skadnetwork</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>SKAdNetworkIdentifier</key>
|
||||
<string>y5ghdn5j9k.skadnetwork</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>SKAdNetworkIdentifier</key>
|
||||
<string>n6fk4nfna4.skadnetwork</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>SKAdNetworkIdentifier</key>
|
||||
<string>v9wttpbfk9.skadnetwork</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>SKAdNetworkIdentifier</key>
|
||||
<string>n38lu8286q.skadnetwork</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>SKAdNetworkIdentifier</key>
|
||||
<string>47vhws6wlr.skadnetwork</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>SKAdNetworkIdentifier</key>
|
||||
<string>kbd757ber7.skadnetwork</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>SKAdNetworkIdentifier</key>
|
||||
<string>9t245vhmpl.skadnetwork</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>SKAdNetworkIdentifier</key>
|
||||
<string>eh6m2bh4zr.skadnetwork</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>SKAdNetworkIdentifier</key>
|
||||
<string>a2p9lx4jpn.skadnetwork</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>SKAdNetworkIdentifier</key>
|
||||
<string>22mmun2rn5.skadnetwork</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>SKAdNetworkIdentifier</key>
|
||||
<string>4468km3ulz.skadnetwork</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>SKAdNetworkIdentifier</key>
|
||||
<string>2u9pt9hc89.skadnetwork</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>SKAdNetworkIdentifier</key>
|
||||
<string>8s468mfl3y.skadnetwork</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>SKAdNetworkIdentifier</key>
|
||||
<string>klf5c3l5u5.skadnetwork</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>SKAdNetworkIdentifier</key>
|
||||
<string>ppxm28t8ap.skadnetwork</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>SKAdNetworkIdentifier</key>
|
||||
<string>ecpz2srf59.skadnetwork</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>SKAdNetworkIdentifier</key>
|
||||
<string>uw77j35x4d.skadnetwork</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>SKAdNetworkIdentifier</key>
|
||||
<string>pwa73g5rt2.skadnetwork</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>SKAdNetworkIdentifier</key>
|
||||
<string>mlmmfzh3r3.skadnetwork</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>SKAdNetworkIdentifier</key>
|
||||
<string>578prtvx9j.skadnetwork</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>SKAdNetworkIdentifier</key>
|
||||
<string>4dzt52r2t5.skadnetwork</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>SKAdNetworkIdentifier</key>
|
||||
<string>e5fvkxwrpn.skadnetwork</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>SKAdNetworkIdentifier</key>
|
||||
<string>8c4e2ghe7u.skadnetwork</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>SKAdNetworkIdentifier</key>
|
||||
<string>zq492l623r.skadnetwork</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>SKAdNetworkIdentifier</key>
|
||||
<string>3rd42ekr43.skadnetwork</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>SKAdNetworkIdentifier</key>
|
||||
<string>3qcr597p9d.skadnetwork</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>SKAdNetworkIdentifier</key>
|
||||
<string>su67r6k2v3.skadnetwork</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>SKAdNetworkIdentifier</key>
|
||||
<string>kbd757ywx3.skadnetwork</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>SKAdNetworkIdentifier</key>
|
||||
<string>44n7hlldy6.skadnetwork</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>SKAdNetworkIdentifier</key>
|
||||
<string>k674qkevps.skadnetwork</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>SKAdNetworkIdentifier</key>
|
||||
<string>2rq3zucswp.skadnetwork</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>SKAdNetworkIdentifier</key>
|
||||
<string>5tjdwbrq8w.skadnetwork</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>SKAdNetworkIdentifier</key>
|
||||
<string>97r2b46745.skadnetwork</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>SKAdNetworkIdentifier</key>
|
||||
<string>b9bk5wbcq9.skadnetwork</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>SKAdNetworkIdentifier</key>
|
||||
<string>44jx6755aq.skadnetwork</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>SKAdNetworkIdentifier</key>
|
||||
<string>kbmxgpxpgc.skadnetwork</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>SKAdNetworkIdentifier</key>
|
||||
<string>tl55sbb4fm.skadnetwork</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>SKAdNetworkIdentifier</key>
|
||||
<string>9g2aggbj52.skadnetwork</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>SKAdNetworkIdentifier</key>
|
||||
<string>g2y4y55b64.skadnetwork</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>SKAdNetworkIdentifier</key>
|
||||
<string>c3frkrj4fj.skadnetwork</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>SKAdNetworkIdentifier</key>
|
||||
<string>r26jy69rpl.skadnetwork</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>SKAdNetworkIdentifier</key>
|
||||
<string>275upjj5gd.skadnetwork</string>
|
||||
</dict>
|
||||
</array>
|
||||
<key>UIApplicationSceneManifest</key>
|
||||
<dict>
|
||||
<key>UIApplicationSupportsMultipleScenes</key>
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<!--
|
||||
Portfolio Journal collects nothing. There is no account, no server of
|
||||
ours, no analytics SDK and no advertising SDK. Everything the user types
|
||||
stays in the app container and, if they opt in, in their own private
|
||||
CloudKit database.
|
||||
-->
|
||||
<key>NSPrivacyTracking</key>
|
||||
<false/>
|
||||
<key>NSPrivacyTrackingDomains</key>
|
||||
<array/>
|
||||
<key>NSPrivacyCollectedDataTypes</key>
|
||||
<array/>
|
||||
<key>NSPrivacyAccessedAPITypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<!-- Reading/writing the Core Data store, exports, backups and the
|
||||
on-device diagnostic reports. -->
|
||||
<key>NSPrivacyAccessedAPIType</key>
|
||||
<string>NSPrivacyAccessedAPICategoryFileTimestamp</string>
|
||||
<key>NSPrivacyAccessedAPITypeReasons</key>
|
||||
<array>
|
||||
<string>C617.1</string>
|
||||
</array>
|
||||
</dict>
|
||||
<dict>
|
||||
<!-- App preferences (chart selection, dashboard layout, reminder
|
||||
settings) stored in UserDefaults and the App Group. -->
|
||||
<key>NSPrivacyAccessedAPIType</key>
|
||||
<string>NSPrivacyAccessedAPICategoryUserDefaults</string>
|
||||
<key>NSPrivacyAccessedAPITypeReasons</key>
|
||||
<array>
|
||||
<string>CA92.1</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -85,16 +85,12 @@
|
||||
"feature_predictions_desc" = "KI-gestützte 12-Monats-Prognosen";
|
||||
"feature_export" = "Daten exportieren";
|
||||
"feature_export_desc" = "Export nach CSV und JSON";
|
||||
"feature_no_ads" = "Keine Werbung";
|
||||
"feature_no_ads_desc" = "Dauerhaft werbefrei";
|
||||
"paywall_benefit_history_title" = "Deine vollständige Historie";
|
||||
"paywall_benefit_history_subtitle" = "Jeder Snapshot, Beitrag und Gewinn seit Tag eins";
|
||||
"paywall_benefit_charts_title" = "Charts, die Muster zeigen";
|
||||
"paywall_benefit_charts_subtitle" = "Allokation, Drawdown, Performance, alles an einem Ort";
|
||||
"paywall_benefit_forecasts_title" = "12-Monats-Prognosen";
|
||||
"paywall_benefit_forecasts_subtitle" = "Sieh, wohin sich dein Portfolio wahrscheinlich entwickelt";
|
||||
"paywall_benefit_noads_title" = "Keine Werbung, jemals";
|
||||
"paywall_benefit_noads_subtitle" = "Klares, fokussiertes Erlebnis ohne Ablenkung";
|
||||
"settings_title" = "Einstellungen";
|
||||
"subscription" = "Abo";
|
||||
"notifications" = "Benachrichtigungen";
|
||||
@@ -677,3 +673,38 @@
|
||||
"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.";
|
||||
|
||||
/* 1.7.0 privacy cleanup: on-device diagnostics + honest backup wording */
|
||||
"Share Diagnostics" = "Diagnose teilen";
|
||||
"diagnostics_footer" = "Absturz- und Hänger-Berichte, die iOS auf diesem Gerät gesammelt hat. Sie werden nirgendwohin übertragen — nur durch Teilen verlässt ein Bericht dein iPhone.";
|
||||
"backups_footer" = "Backups sind lesbare JSON-Dateien auf diesem Gerät und, wenn aktiviert, in deiner iCloud Drive. Die App verschlüsselt sie nicht: Wer die Datei öffnen kann, sieht deine Kontostände.";
|
||||
|
||||
@@ -109,8 +109,6 @@
|
||||
"feature_predictions_desc" = "AI-powered 12-month forecasts";
|
||||
"feature_export" = "Export Data";
|
||||
"feature_export_desc" = "Export to CSV and JSON formats";
|
||||
"feature_no_ads" = "No Ads";
|
||||
"feature_no_ads_desc" = "Ad-free experience forever";
|
||||
|
||||
// Paywall benefits (1.2.0)
|
||||
"paywall_benefit_history_title" = "Your complete history";
|
||||
@@ -119,8 +117,6 @@
|
||||
"paywall_benefit_charts_subtitle" = "Allocation, drawdown, performance — all in one place";
|
||||
"paywall_benefit_forecasts_title" = "12-month forecasts";
|
||||
"paywall_benefit_forecasts_subtitle" = "See where your portfolio is likely heading";
|
||||
"paywall_benefit_noads_title" = "No ads, ever";
|
||||
"paywall_benefit_noads_subtitle" = "Clean, focused experience with no distractions";
|
||||
|
||||
// MARK: - Settings
|
||||
"settings_title" = "Settings";
|
||||
@@ -678,3 +674,38 @@
|
||||
"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.";
|
||||
|
||||
/* 1.7.0 privacy cleanup: on-device diagnostics + honest backup wording */
|
||||
"Share Diagnostics" = "Share Diagnostics";
|
||||
"diagnostics_footer" = "Crash and hang reports collected by iOS on this device. They are never uploaded anywhere — sharing one is the only way it leaves your iPhone.";
|
||||
"backups_footer" = "Backups are readable JSON files kept on this device, and in your iCloud Drive when enabled. They are not encrypted by the app: anyone who can open the file can read your balances.";
|
||||
|
||||
@@ -154,8 +154,6 @@
|
||||
"paywall_benefit_charts_subtitle" = "Asignación, drawdown, rendimiento — todo en un lugar";
|
||||
"paywall_benefit_forecasts_title" = "Previsiones a 12 meses";
|
||||
"paywall_benefit_forecasts_subtitle" = "Ve a dónde se dirige tu cartera";
|
||||
"paywall_benefit_noads_title" = "Sin anuncios, nunca";
|
||||
"paywall_benefit_noads_subtitle" = "Una experiencia limpia y sin distracciones";
|
||||
|
||||
// MARK: - Onboarding pages (1.2.0)
|
||||
"onboarding_clarity_title" = "Sabe exactamente dónde estás";
|
||||
@@ -629,3 +627,38 @@
|
||||
"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.";
|
||||
|
||||
/* 1.7.0 privacy cleanup: on-device diagnostics + honest backup wording */
|
||||
"Share Diagnostics" = "Compartir diagnósticos";
|
||||
"diagnostics_footer" = "Informes de fallos y bloqueos que iOS ha recogido en este dispositivo. Nunca se envían a ningún sitio: compartir uno es la única forma de que salga de tu iPhone.";
|
||||
"backups_footer" = "Las copias de seguridad son ficheros JSON legibles guardados en este dispositivo, y en tu iCloud Drive si lo activas. La app no las cifra: cualquiera que pueda abrir el fichero puede leer tus saldos.";
|
||||
|
||||
@@ -85,16 +85,12 @@
|
||||
"feature_predictions_desc" = "Prévisions IA sur 12 mois";
|
||||
"feature_export" = "Exporter les données";
|
||||
"feature_export_desc" = "Export vers CSV et JSON";
|
||||
"feature_no_ads" = "Sans publicité";
|
||||
"feature_no_ads_desc" = "Expérience sans publicité à vie";
|
||||
"paywall_benefit_history_title" = "Votre historique complet";
|
||||
"paywall_benefit_history_subtitle" = "Chaque snapshot, contribution et gain depuis le premier jour";
|
||||
"paywall_benefit_charts_title" = "Des graphiques qui révèlent des tendances";
|
||||
"paywall_benefit_charts_subtitle" = "Allocation, drawdown, performance, le tout au même endroit";
|
||||
"paywall_benefit_forecasts_title" = "Prévisions à 12 mois";
|
||||
"paywall_benefit_forecasts_subtitle" = "Voyez où votre portefeuille pourrait aller";
|
||||
"paywall_benefit_noads_title" = "Aucune pub, jamais";
|
||||
"paywall_benefit_noads_subtitle" = "Une expérience propre et centrée, sans distractions";
|
||||
"settings_title" = "Réglages";
|
||||
"subscription" = "Abonnement";
|
||||
"notifications" = "Notifications";
|
||||
@@ -677,3 +673,38 @@
|
||||
"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.";
|
||||
|
||||
/* 1.7.0 privacy cleanup: on-device diagnostics + honest backup wording */
|
||||
"Share Diagnostics" = "Partager les diagnostics";
|
||||
"diagnostics_footer" = "Rapports de plantage et de blocage collectés par iOS sur cet appareil. Ils ne sont jamais envoyés nulle part : les partager est le seul moyen qu'ils quittent votre iPhone.";
|
||||
"backups_footer" = "Les sauvegardes sont des fichiers JSON lisibles conservés sur cet appareil, et dans votre iCloud Drive si vous l'activez. L'app ne les chiffre pas : quiconque peut ouvrir le fichier peut lire vos soldes.";
|
||||
|
||||
@@ -85,16 +85,12 @@
|
||||
"feature_predictions_desc" = "Previsioni a 12 mesi basate su IA";
|
||||
"feature_export" = "Esporta dati";
|
||||
"feature_export_desc" = "Esporta in CSV e JSON";
|
||||
"feature_no_ads" = "Niente pubblicità";
|
||||
"feature_no_ads_desc" = "Esperienza senza pubblicità per sempre";
|
||||
"paywall_benefit_history_title" = "La tua cronologia completa";
|
||||
"paywall_benefit_history_subtitle" = "Ogni snapshot, contributo e guadagno dal primo giorno";
|
||||
"paywall_benefit_charts_title" = "Grafici che rivelano schemi";
|
||||
"paywall_benefit_charts_subtitle" = "Allocazione, drawdown, performance: tutto in un unico posto";
|
||||
"paywall_benefit_forecasts_title" = "Previsioni a 12 mesi";
|
||||
"paywall_benefit_forecasts_subtitle" = "Guarda dove probabilmente andrà il tuo portafoglio";
|
||||
"paywall_benefit_noads_title" = "Niente pubblicità, mai";
|
||||
"paywall_benefit_noads_subtitle" = "Esperienza pulita e focalizzata, senza distrazioni";
|
||||
"settings_title" = "Impostazioni";
|
||||
"subscription" = "Abbonamento";
|
||||
"notifications" = "Notifiche";
|
||||
@@ -677,3 +673,38 @@
|
||||
"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.";
|
||||
|
||||
/* 1.7.0 privacy cleanup: on-device diagnostics + honest backup wording */
|
||||
"Share Diagnostics" = "Condividi diagnostica";
|
||||
"diagnostics_footer" = "Rapporti di crash e blocchi raccolti da iOS su questo dispositivo. Non vengono mai inviati da nessuna parte: condividerne uno è l'unico modo per farlo uscire dal tuo iPhone.";
|
||||
"backups_footer" = "I backup sono file JSON leggibili conservati su questo dispositivo, e nel tuo iCloud Drive se lo attivi. L'app non li cifra: chiunque possa aprire il file può leggere i tuoi saldi.";
|
||||
|
||||
@@ -85,16 +85,12 @@
|
||||
"feature_predictions_desc" = "AI による12か月予測";
|
||||
"feature_export" = "データを書き出し";
|
||||
"feature_export_desc" = "CSV と JSON に書き出し";
|
||||
"feature_no_ads" = "広告なし";
|
||||
"feature_no_ads_desc" = "ずっと広告なしで使えます";
|
||||
"paywall_benefit_history_title" = "完全な履歴";
|
||||
"paywall_benefit_history_subtitle" = "初日からのすべての snapshot、追加資金、利益を確認";
|
||||
"paywall_benefit_charts_title" = "傾向が見えるチャート";
|
||||
"paywall_benefit_charts_subtitle" = "配分、ドローダウン、パフォーマンスを一か所で確認";
|
||||
"paywall_benefit_forecasts_title" = "12か月予測";
|
||||
"paywall_benefit_forecasts_subtitle" = "ポートフォリオの行き先を見通せます";
|
||||
"paywall_benefit_noads_title" = "広告は一切なし";
|
||||
"paywall_benefit_noads_subtitle" = "気が散らない、集中できる体験";
|
||||
"settings_title" = "設定";
|
||||
"subscription" = "サブスクリプション";
|
||||
"notifications" = "通知";
|
||||
@@ -677,3 +673,38 @@
|
||||
"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" = "アプリロックと残高のプライバシーが、デバイスの生体認証の種類に合わせて動作します。";
|
||||
|
||||
/* 1.7.0 privacy cleanup: on-device diagnostics + honest backup wording */
|
||||
"Share Diagnostics" = "診断情報を共有";
|
||||
"diagnostics_footer" = "iOS がこの端末で収集したクラッシュとハングのレポートです。どこにも送信されません。共有した場合だけ iPhone の外に出ます。";
|
||||
"backups_footer" = "バックアップはこの端末に保存される読み取り可能な JSON ファイルです(有効にすると iCloud Drive にも保存)。アプリは暗号化しません。ファイルを開ける人は残高を読めます。";
|
||||
|
||||
@@ -85,16 +85,12 @@
|
||||
"feature_predictions_desc" = "Previsões de 12 meses com IA";
|
||||
"feature_export" = "Exportar dados";
|
||||
"feature_export_desc" = "Exporte para CSV e JSON";
|
||||
"feature_no_ads" = "Sem anúncios";
|
||||
"feature_no_ads_desc" = "Experiência sem anúncios para sempre";
|
||||
"paywall_benefit_history_title" = "Seu histórico completo";
|
||||
"paywall_benefit_history_subtitle" = "Cada snapshot, contribuição e ganho desde o primeiro dia";
|
||||
"paywall_benefit_charts_title" = "Gráficos que revelam padrões";
|
||||
"paywall_benefit_charts_subtitle" = "Alocação, drawdown, desempenho: tudo em um só lugar";
|
||||
"paywall_benefit_forecasts_title" = "Previsões de 12 meses";
|
||||
"paywall_benefit_forecasts_subtitle" = "Veja para onde sua carteira provavelmente está indo";
|
||||
"paywall_benefit_noads_title" = "Sem anúncios, nunca";
|
||||
"paywall_benefit_noads_subtitle" = "Experiência limpa e focada, sem distrações";
|
||||
"settings_title" = "Ajustes";
|
||||
"subscription" = "Assinatura";
|
||||
"notifications" = "Notificações";
|
||||
@@ -677,3 +673,38 @@
|
||||
"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.7.0 privacy cleanup: on-device diagnostics + honest backup wording */
|
||||
"Share Diagnostics" = "Compartilhar diagnósticos";
|
||||
"diagnostics_footer" = "Relatórios de falhas e travamentos coletados pelo iOS neste dispositivo. Nunca são enviados para lugar nenhum: compartilhar um é a única forma de ele sair do seu iPhone.";
|
||||
"backups_footer" = "Os backups são arquivos JSON legíveis guardados neste dispositivo, e no seu iCloud Drive se você ativar. O app não os criptografa: quem conseguir abrir o arquivo lê os seus saldos.";
|
||||
|
||||
@@ -1,215 +0,0 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
import Combine
|
||||
import GoogleMobileAds
|
||||
import UserMessagingPlatform
|
||||
import AppTrackingTransparency
|
||||
import AdSupport
|
||||
|
||||
@MainActor
|
||||
class AdMobService: ObservableObject {
|
||||
// MARK: - Published Properties
|
||||
|
||||
@Published var isConsentObtained = false
|
||||
@Published var canShowAds = false
|
||||
@Published var isLoading = false
|
||||
@Published var shouldRequestNonPersonalizedAds = false
|
||||
|
||||
// MARK: - Ad Unit IDs
|
||||
|
||||
// Ad Unit IDs
|
||||
#if DEBUG
|
||||
static let bannerAdUnitID = "ca-app-pub-3940256099942544/2934735716" // Test ad unit for development
|
||||
#else
|
||||
static let bannerAdUnitID = "ca-app-pub-1549720748100858/3342431241" // Production ad unit
|
||||
#endif
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
init() {
|
||||
setupResetObserver()
|
||||
Task {
|
||||
await configureConsentAndRequestAdsIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Consent Management
|
||||
|
||||
func requestConsent() async {
|
||||
await configureConsentAndRequestAdsIfNeeded()
|
||||
}
|
||||
|
||||
func presentPrivacyOptions() async {
|
||||
guard let root = topViewController() else { return }
|
||||
await withCheckedContinuation { continuation in
|
||||
ConsentForm.presentPrivacyOptionsForm(from: root) { _ in
|
||||
continuation.resume()
|
||||
}
|
||||
}
|
||||
updateConsentState()
|
||||
}
|
||||
|
||||
func resetConsent() {
|
||||
ConsentInformation.shared.reset()
|
||||
UserDefaults.standard.removeObject(forKey: AppConstants.StorageKeys.adConsentObtained)
|
||||
isConsentObtained = false
|
||||
canShowAds = false
|
||||
shouldRequestNonPersonalizedAds = false
|
||||
}
|
||||
|
||||
private func setupResetObserver() {
|
||||
NotificationCenter.default.addObserver(
|
||||
forName: .didResetData,
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
guard let self else { return }
|
||||
Task { @MainActor in
|
||||
self.resetConsent()
|
||||
await self.configureConsentAndRequestAdsIfNeeded()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func configureConsentAndRequestAdsIfNeeded() async {
|
||||
isLoading = true
|
||||
let parameters = RequestParameters()
|
||||
await withCheckedContinuation { continuation in
|
||||
ConsentInformation.shared.requestConsentInfoUpdate(with: parameters) { _ in
|
||||
continuation.resume()
|
||||
}
|
||||
}
|
||||
|
||||
if let root = topViewController() {
|
||||
await withCheckedContinuation { continuation in
|
||||
ConsentForm.loadAndPresentIfRequired(from: root) { _ in
|
||||
continuation.resume()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updateConsentState()
|
||||
if canShowAds {
|
||||
await requestTrackingIfNeeded()
|
||||
updateConsentState()
|
||||
}
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
private func updateConsentState() {
|
||||
let consentInfo = ConsentInformation.shared
|
||||
canShowAds = consentInfo.canRequestAds
|
||||
isConsentObtained = consentInfo.consentStatus == .obtained || consentInfo.consentStatus == .notRequired
|
||||
|
||||
let trackingDenied: Bool
|
||||
if #available(iOS 14.5, *) {
|
||||
trackingDenied = ATTrackingManager.trackingAuthorizationStatus != .authorized
|
||||
} else {
|
||||
trackingDenied = false
|
||||
}
|
||||
|
||||
shouldRequestNonPersonalizedAds = !isConsentObtained || trackingDenied
|
||||
UserDefaults.standard.set(isConsentObtained, forKey: AppConstants.StorageKeys.adConsentObtained)
|
||||
}
|
||||
|
||||
private func requestTrackingIfNeeded() async {
|
||||
if #available(iOS 14.5, *) {
|
||||
_ = await ATTrackingManager.requestTrackingAuthorization()
|
||||
}
|
||||
}
|
||||
|
||||
private func topViewController() -> UIViewController? {
|
||||
guard let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
|
||||
let root = scene.windows.first?.rootViewController else {
|
||||
return nil
|
||||
}
|
||||
var current = root
|
||||
while let presented = current.presentedViewController {
|
||||
current = presented
|
||||
}
|
||||
return current
|
||||
}
|
||||
|
||||
// MARK: - Analytics
|
||||
|
||||
func logAdImpression() {
|
||||
FirebaseService.shared.logAdImpression(adType: "banner")
|
||||
}
|
||||
|
||||
func logAdClick() {
|
||||
FirebaseService.shared.logAdClick(adType: "banner")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Banner Ad Coordinator
|
||||
|
||||
class BannerAdCoordinator: NSObject, BannerViewDelegate {
|
||||
weak var adMobService: AdMobService?
|
||||
|
||||
func bannerViewDidReceiveAd(_ bannerView: BannerView) {
|
||||
print("Banner ad received")
|
||||
adMobService?.logAdImpression()
|
||||
}
|
||||
|
||||
func bannerView(_ bannerView: BannerView, didFailToReceiveAdWithError error: Error) {
|
||||
print("Banner ad failed to load: \(error.localizedDescription)")
|
||||
}
|
||||
|
||||
func bannerViewDidRecordImpression(_ bannerView: BannerView) {
|
||||
// Impression recorded
|
||||
}
|
||||
|
||||
func bannerViewDidRecordClick(_ bannerView: BannerView) {
|
||||
adMobService?.logAdClick()
|
||||
}
|
||||
|
||||
func bannerViewWillPresentScreen(_ bannerView: BannerView) {
|
||||
// Ad will present full screen
|
||||
}
|
||||
|
||||
func bannerViewWillDismissScreen(_ bannerView: BannerView) {
|
||||
// Ad will dismiss
|
||||
}
|
||||
|
||||
func bannerViewDidDismissScreen(_ bannerView: BannerView) {
|
||||
// Ad dismissed
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - UIKit Banner View Wrapper
|
||||
|
||||
struct BannerAdView: UIViewRepresentable {
|
||||
@EnvironmentObject var adMobService: AdMobService
|
||||
|
||||
func makeUIView(context: Context) -> BannerView {
|
||||
let bannerView = BannerView(adSize: AdSizeBanner)
|
||||
bannerView.adUnitID = AdMobService.bannerAdUnitID
|
||||
|
||||
// Get root view controller
|
||||
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
|
||||
let rootViewController = windowScene.windows.first?.rootViewController {
|
||||
bannerView.rootViewController = rootViewController
|
||||
}
|
||||
|
||||
bannerView.delegate = context.coordinator
|
||||
let request = Request()
|
||||
if adMobService.shouldRequestNonPersonalizedAds {
|
||||
let extras = Extras()
|
||||
extras.additionalParameters = ["npa": "1"]
|
||||
request.register(extras)
|
||||
}
|
||||
bannerView.load(request)
|
||||
|
||||
return bannerView
|
||||
}
|
||||
|
||||
func updateUIView(_ uiView: BannerView, context: Context) {
|
||||
// Banner updates automatically
|
||||
}
|
||||
|
||||
func makeCoordinator() -> BannerAdCoordinator {
|
||||
let coordinator = BannerAdCoordinator()
|
||||
coordinator.adMobService = adMobService
|
||||
return coordinator
|
||||
}
|
||||
}
|
||||
@@ -60,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
|
||||
|
||||
@@ -45,7 +45,6 @@ final class ChartShareService {
|
||||
static let appStoreShareURL = URL(string: "https://apps.apple.com/app/portfolio-journal-tracker/id6757678318?ct=chart_share")!
|
||||
|
||||
func share<Content: View>(title: String, subtitle: String, stats: [ChartStat], @ViewBuilder chart: () -> Content) {
|
||||
FirebaseService.shared.logShare(type: "chart")
|
||||
guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
|
||||
let viewController = windowScene.windows.first?.rootViewController else {
|
||||
return
|
||||
@@ -124,7 +123,7 @@ struct ChartShareCardView<Content: View>: View {
|
||||
.font(.headline)
|
||||
Text("\(title) · \(Self.dateLabel)")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
@@ -138,15 +137,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 +160,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 +172,7 @@ struct ChartShareCardView<Content: View>: View {
|
||||
.frame(width: 64, height: 64)
|
||||
Text(String(localized: "chart_share_scan"))
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import Foundation
|
||||
import MetricKit
|
||||
import os
|
||||
|
||||
/// On-device replacement for Crashlytics.
|
||||
///
|
||||
/// `MXMetricManager` hands the app a daily payload with crash, hang and
|
||||
/// disk-write diagnostics that Apple already collected for it. This collector
|
||||
/// writes those payloads to the app container as JSON and **never sends them
|
||||
/// anywhere** — no network code, no third-party SDK, no identifiers. Crash
|
||||
/// reports still reach Xcode Organizer through Apple's own pipeline, which is
|
||||
/// the channel that was actually useful about Crashlytics.
|
||||
///
|
||||
/// The user can share the stored reports from Settings; that is the only way
|
||||
/// one ever leaves the device, and it is an explicit action.
|
||||
final class DiagnosticsCollector: NSObject {
|
||||
static let shared = DiagnosticsCollector()
|
||||
|
||||
/// Reports older than this are pruned on every new payload.
|
||||
private static let retentionCount = 5
|
||||
|
||||
private let logger = Logger(subsystem: AppConstants.bundleIdentifier, category: "diagnostics")
|
||||
private let fileManager = FileManager.default
|
||||
private let dateFormatter: DateFormatter = {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "yyyyMMdd-HHmmss"
|
||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||
return formatter
|
||||
}()
|
||||
|
||||
private override init() {
|
||||
super.init()
|
||||
}
|
||||
|
||||
/// Subscribes to MetricKit. Safe to call more than once.
|
||||
func start() {
|
||||
MXMetricManager.shared.add(self)
|
||||
}
|
||||
|
||||
// MARK: - Storage
|
||||
|
||||
/// `Application Support/Diagnostics`, created on demand.
|
||||
private var directory: URL? {
|
||||
guard let base = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else {
|
||||
return nil
|
||||
}
|
||||
let url = base.appendingPathComponent("Diagnostics", isDirectory: true)
|
||||
if !fileManager.fileExists(atPath: url.path) {
|
||||
try? fileManager.createDirectory(at: url, withIntermediateDirectories: true)
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
/// Stored reports, newest first. Used by Settings to offer sharing them.
|
||||
func storedReports() -> [URL] {
|
||||
guard let directory else { return [] }
|
||||
let contents = (try? fileManager.contentsOfDirectory(
|
||||
at: directory,
|
||||
includingPropertiesForKeys: [.contentModificationDateKey]
|
||||
)) ?? []
|
||||
return contents
|
||||
.filter { $0.pathExtension == "json" }
|
||||
.sorted { lhs, rhs in
|
||||
let l = (try? lhs.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? .distantPast
|
||||
let r = (try? rhs.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? .distantPast
|
||||
return l > r
|
||||
}
|
||||
}
|
||||
|
||||
func deleteStoredReports() {
|
||||
for url in storedReports() {
|
||||
try? fileManager.removeItem(at: url)
|
||||
}
|
||||
}
|
||||
|
||||
private func write(_ data: Data, prefix: String) {
|
||||
guard let directory else { return }
|
||||
let name = "\(prefix)-\(dateFormatter.string(from: Date())).json"
|
||||
let url = directory.appendingPathComponent(name)
|
||||
do {
|
||||
try data.write(to: url, options: .atomic)
|
||||
// Diagnostics are debugging aids, not user data: keep them out of
|
||||
// iCloud/iTunes backups.
|
||||
var resourceValues = URLResourceValues()
|
||||
resourceValues.isExcludedFromBackup = true
|
||||
var mutableURL = url
|
||||
try? mutableURL.setResourceValues(resourceValues)
|
||||
logger.info("Stored diagnostic report \(name, privacy: .public)")
|
||||
} catch {
|
||||
logger.error("Could not store diagnostic report: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
prune()
|
||||
}
|
||||
|
||||
private func prune() {
|
||||
let reports = storedReports()
|
||||
guard reports.count > Self.retentionCount else { return }
|
||||
for url in reports.dropFirst(Self.retentionCount) {
|
||||
try? fileManager.removeItem(at: url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - MXMetricManagerSubscriber
|
||||
|
||||
extension DiagnosticsCollector: MXMetricManagerSubscriber {
|
||||
func didReceive(_ payloads: [MXMetricPayload]) {
|
||||
for payload in payloads {
|
||||
write(payload.jsonRepresentation(), prefix: "metrics")
|
||||
}
|
||||
}
|
||||
|
||||
func didReceive(_ payloads: [MXDiagnosticPayload]) {
|
||||
for payload in payloads {
|
||||
let crashes = payload.crashDiagnostics?.count ?? 0
|
||||
let hangs = payload.hangDiagnostics?.count ?? 0
|
||||
logger.info("Diagnostic payload: \(crashes, privacy: .public) crash(es), \(hangs, privacy: .public) hang(s)")
|
||||
write(payload.jsonRepresentation(), prefix: "diagnostics")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -154,7 +154,8 @@ class ExportService {
|
||||
var goalDict: [String: Any] = [
|
||||
"name": goal.name,
|
||||
"isActive": goal.isActive,
|
||||
"account": goal.account?.name as Any
|
||||
"account": goal.account?.name as Any,
|
||||
"category": goal.category?.name as Any
|
||||
]
|
||||
if let amount = goal.targetAmount {
|
||||
goalDict["targetAmount"] = amount.doubleValue
|
||||
@@ -257,18 +258,9 @@ class ExportService {
|
||||
)
|
||||
}
|
||||
|
||||
viewController.present(activityVC, animated: true) {
|
||||
FirebaseService.shared.logExportAttempt(
|
||||
format: format.rawValue,
|
||||
success: true
|
||||
)
|
||||
}
|
||||
viewController.present(activityVC, animated: true)
|
||||
} catch {
|
||||
print("Export error: \(error)")
|
||||
FirebaseService.shared.logExportAttempt(
|
||||
format: format.rawValue,
|
||||
success: false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,226 +0,0 @@
|
||||
import Foundation
|
||||
import FirebaseAnalytics
|
||||
import FirebaseCore
|
||||
|
||||
class FirebaseService {
|
||||
static let shared = FirebaseService()
|
||||
|
||||
private var isConfigured: Bool {
|
||||
FirebaseApp.app() != nil
|
||||
}
|
||||
|
||||
private init() {}
|
||||
|
||||
// MARK: - User Properties
|
||||
|
||||
func setUserTier(_ tier: UserTier) {
|
||||
guard isConfigured else { return }
|
||||
Analytics.setUserProperty(tier.rawValue, forName: "user_tier")
|
||||
}
|
||||
|
||||
enum UserTier: String {
|
||||
case free = "free"
|
||||
case premium = "premium"
|
||||
}
|
||||
|
||||
// MARK: - Screen Tracking
|
||||
|
||||
func logScreenView(screenName: String, screenClass: String? = nil) {
|
||||
guard isConfigured else { return }
|
||||
Analytics.logEvent(AnalyticsEventScreenView, parameters: [
|
||||
AnalyticsParameterScreenName: screenName,
|
||||
AnalyticsParameterScreenClass: screenClass ?? screenName
|
||||
])
|
||||
}
|
||||
|
||||
// MARK: - Investment Events
|
||||
|
||||
func logSourceAdded(categoryName: String, sourceCount: Int) {
|
||||
guard isConfigured else { return }
|
||||
Analytics.logEvent("source_added", parameters: [
|
||||
"category_name": categoryName,
|
||||
"total_sources": sourceCount
|
||||
])
|
||||
}
|
||||
|
||||
func logSourceDeleted(categoryName: String) {
|
||||
guard isConfigured else { return }
|
||||
Analytics.logEvent("source_deleted", parameters: [
|
||||
"category_name": categoryName
|
||||
])
|
||||
}
|
||||
|
||||
func logSnapshotAdded(sourceName: String, value: Decimal) {
|
||||
guard isConfigured else { return }
|
||||
Analytics.logEvent("snapshot_added", parameters: [
|
||||
"source_name": sourceName,
|
||||
"value": NSDecimalNumber(decimal: value).doubleValue
|
||||
])
|
||||
}
|
||||
|
||||
func logCategoryCreated(name: String) {
|
||||
guard isConfigured else { return }
|
||||
Analytics.logEvent("category_created", parameters: [
|
||||
"category_name": name
|
||||
])
|
||||
}
|
||||
|
||||
// MARK: - Purchase Events
|
||||
|
||||
func logPaywallShown(trigger: String) {
|
||||
guard isConfigured else { return }
|
||||
Analytics.logEvent("paywall_shown", parameters: [
|
||||
"paywall_trigger": trigger
|
||||
])
|
||||
}
|
||||
|
||||
func logPurchaseAttempt(productId: String) {
|
||||
guard isConfigured else { return }
|
||||
Analytics.logEvent("purchase_attempt", parameters: [
|
||||
"product_id": productId
|
||||
])
|
||||
}
|
||||
|
||||
func logPurchaseSuccess(productId: String, price: Decimal, isFamilyShared: Bool) {
|
||||
guard isConfigured else { return }
|
||||
Analytics.logEvent(AnalyticsEventPurchase, parameters: [
|
||||
AnalyticsParameterItemID: productId,
|
||||
AnalyticsParameterPrice: NSDecimalNumber(decimal: price).doubleValue,
|
||||
AnalyticsParameterCurrency: "EUR",
|
||||
"is_family_shared": isFamilyShared
|
||||
])
|
||||
}
|
||||
|
||||
func logPurchaseFailure(productId: String, error: String) {
|
||||
guard isConfigured else { return }
|
||||
Analytics.logEvent("purchase_failed", parameters: [
|
||||
"product_id": productId,
|
||||
"error": error
|
||||
])
|
||||
}
|
||||
|
||||
func logRestorePurchases(success: Bool) {
|
||||
guard isConfigured else { return }
|
||||
Analytics.logEvent("restore_purchases", parameters: [
|
||||
"success": success
|
||||
])
|
||||
}
|
||||
|
||||
// MARK: - Feature Usage Events
|
||||
|
||||
func logChartViewed(chartType: String, isPremium: Bool) {
|
||||
guard isConfigured else { return }
|
||||
Analytics.logEvent("chart_viewed", parameters: [
|
||||
"chart_type": chartType,
|
||||
"is_premium_chart": isPremium
|
||||
])
|
||||
}
|
||||
|
||||
func logPredictionViewed(algorithm: String) {
|
||||
guard isConfigured else { return }
|
||||
Analytics.logEvent("prediction_viewed", parameters: [
|
||||
"algorithm": algorithm
|
||||
])
|
||||
}
|
||||
|
||||
func logExportAttempt(format: String, success: Bool) {
|
||||
guard isConfigured else { return }
|
||||
Analytics.logEvent("export_attempt", parameters: [
|
||||
"format": format,
|
||||
"success": success
|
||||
])
|
||||
}
|
||||
|
||||
func logNotificationScheduled(frequency: String) {
|
||||
guard isConfigured else { return }
|
||||
Analytics.logEvent("notification_scheduled", parameters: [
|
||||
"frequency": frequency
|
||||
])
|
||||
}
|
||||
|
||||
// MARK: - Ad Events
|
||||
|
||||
func logAdImpression(adType: String) {
|
||||
guard isConfigured else { return }
|
||||
Analytics.logEvent("ad_impression", parameters: [
|
||||
"ad_type": adType
|
||||
])
|
||||
}
|
||||
|
||||
func logAdClick(adType: String) {
|
||||
guard isConfigured else { return }
|
||||
Analytics.logEvent("ad_click", parameters: [
|
||||
"ad_type": adType
|
||||
])
|
||||
}
|
||||
|
||||
// MARK: - Engagement Events
|
||||
|
||||
func logOnboardingCompleted(stepCount: Int) {
|
||||
guard isConfigured else { return }
|
||||
Analytics.logEvent("onboarding_completed", parameters: [
|
||||
"steps_completed": stepCount
|
||||
])
|
||||
}
|
||||
|
||||
/// Per-step onboarding funnel event — lets us see exactly where users drop off
|
||||
/// inside onboarding (only the final `onboarding_completed` existed before).
|
||||
func logOnboardingStep(step: Int) {
|
||||
guard isConfigured else { return }
|
||||
Analytics.logEvent("onboarding_step", parameters: [
|
||||
"step": step
|
||||
])
|
||||
}
|
||||
|
||||
func logOnboardingSkipped(atStep step: Int) {
|
||||
guard isConfigured else { return }
|
||||
Analytics.logEvent("onboarding_skipped", parameters: [
|
||||
"step": step
|
||||
])
|
||||
}
|
||||
|
||||
/// Viral loop measurement: fired whenever the user opens a share sheet with
|
||||
/// app content (portfolio image, check-in, goal). `type` identifies the surface.
|
||||
func logShare(type: String) {
|
||||
guard isConfigured else { return }
|
||||
Analytics.logEvent("content_shared", parameters: [
|
||||
"share_type": type
|
||||
])
|
||||
}
|
||||
|
||||
func logWidgetUsed(widgetType: String) {
|
||||
guard isConfigured else { return }
|
||||
Analytics.logEvent("widget_used", parameters: [
|
||||
"widget_type": widgetType
|
||||
])
|
||||
}
|
||||
|
||||
func logAppOpened(fromWidget: Bool) {
|
||||
guard isConfigured else { return }
|
||||
Analytics.logEvent("app_opened", parameters: [
|
||||
"from_widget": fromWidget
|
||||
])
|
||||
}
|
||||
|
||||
// MARK: - Portfolio Events
|
||||
|
||||
func logPortfolioMilestone(totalValue: Decimal, milestone: String) {
|
||||
guard isConfigured else { return }
|
||||
Analytics.logEvent("portfolio_milestone", parameters: [
|
||||
"total_value": NSDecimalNumber(decimal: totalValue).doubleValue,
|
||||
"milestone": milestone
|
||||
])
|
||||
}
|
||||
|
||||
// MARK: - Error Events
|
||||
|
||||
func logError(type: String, message: String, context: String? = nil) {
|
||||
guard isConfigured else { return }
|
||||
Analytics.logEvent("app_error", parameters: [
|
||||
"error_type": type,
|
||||
"error_message": message,
|
||||
"context": context ?? ""
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,7 +60,6 @@ class GoalShareService {
|
||||
estimatedCompletionDate: Date? = nil,
|
||||
privacyMode: Bool = false
|
||||
) {
|
||||
FirebaseService.shared.logShare(type: "goal")
|
||||
guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
|
||||
let viewController = windowScene.windows.first?.rootViewController else {
|
||||
return
|
||||
|
||||
@@ -107,13 +107,6 @@ class IAPService: ObservableObject {
|
||||
|
||||
purchaseState = .purchased
|
||||
|
||||
// Track analytics
|
||||
FirebaseService.shared.logPurchaseSuccess(
|
||||
productId: product.id,
|
||||
price: product.price,
|
||||
isFamilyShared: isFamilyShared
|
||||
)
|
||||
|
||||
case .userCancelled:
|
||||
purchaseState = .idle
|
||||
|
||||
@@ -125,10 +118,6 @@ class IAPService: ObservableObject {
|
||||
}
|
||||
} catch {
|
||||
purchaseState = .failed(error.localizedDescription)
|
||||
FirebaseService.shared.logPurchaseFailure(
|
||||
productId: product.id,
|
||||
error: error.localizedDescription
|
||||
)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -312,7 +301,6 @@ extension IAPService {
|
||||
("chart.bar.xaxis", "Advanced Charts", "5 types of detailed analytics charts"),
|
||||
("wand.and.stars", "Predictions", "AI-powered 12-month forecasts"),
|
||||
("square.and.arrow.up", "Export Data", "Export to CSV and JSON formats"),
|
||||
("xmark.circle", "No Ads", "Ad-free experience forever"),
|
||||
("person.2", "Family Sharing", "Share with up to 5 family members")
|
||||
]
|
||||
|
||||
@@ -333,9 +321,6 @@ extension IAPService {
|
||||
String(localized: "paywall_benefit_accounts_subtitle")),
|
||||
("person.3",
|
||||
String(localized: "paywall_benefit_family_title"),
|
||||
String(localized: "paywall_benefit_family_subtitle")),
|
||||
("xmark.circle",
|
||||
String(localized: "paywall_benefit_noads_title"),
|
||||
String(localized: "paywall_benefit_noads_subtitle"))
|
||||
String(localized: "paywall_benefit_family_subtitle"))
|
||||
]}
|
||||
}
|
||||
|
||||
@@ -87,6 +87,7 @@ class ImportService {
|
||||
let targetDate: Date?
|
||||
let isActive: Bool
|
||||
let accountName: String?
|
||||
let categoryName: String?
|
||||
}
|
||||
|
||||
struct ImportedJournalEntry {
|
||||
@@ -386,8 +387,16 @@ Personal,Real Estate,Rental Property,2024-01-01,82000,80000,Estimated value
|
||||
let trimmedName = importedGoal.name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmedName.isEmpty, importedGoal.targetAmount != nil else { continue }
|
||||
let resolvedAccount = importedGoal.accountName.flatMap { accountsByName[$0] }
|
||||
if fetchGoal(named: trimmedName, account: resolvedAccount, in: context) == nil {
|
||||
let key = "\(trimmedName.lowercased())|\(resolvedAccount?.name ?? "")"
|
||||
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
|
||||
@@ -666,12 +675,14 @@ Personal,Real Estate,Rental Property,2024-01-01,82000,80000,Estimated value
|
||||
.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
|
||||
accountName: accountName,
|
||||
categoryName: categoryName
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -863,12 +874,21 @@ Personal,Real Estate,Rental Property,2024-01-01,82000,80000,Estimated value
|
||||
guard let targetAmount = importedGoal.targetAmount else { continue }
|
||||
|
||||
let resolvedAccount = importedGoal.accountName.flatMap { accountsByName[$0] }
|
||||
if fetchGoal(named: trimmedName, account: resolvedAccount, in: context) == nil {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -1002,22 +1022,27 @@ Personal,Real Estate,Rental Property,2024-01-01,82000,80000,Estimated value
|
||||
return try? context.fetch(request).first
|
||||
}
|
||||
|
||||
/// Fetch an existing goal by name (case-insensitive) within the same account.
|
||||
/// 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 {
|
||||
request.predicate = NSPredicate(
|
||||
format: "name ==[c] %@ AND account == %@", name, account
|
||||
)
|
||||
predicates.append(NSPredicate(format: "account == %@", account))
|
||||
} else {
|
||||
request.predicate = NSPredicate(
|
||||
format: "name ==[c] %@ AND account == nil", name
|
||||
)
|
||||
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
|
||||
}
|
||||
|
||||
@@ -95,7 +95,6 @@ class NotificationService: ObservableObject {
|
||||
print("Failed to schedule notification: \(error)")
|
||||
} else {
|
||||
print("Scheduled reminder for \(source.name) on \(triggerDate)")
|
||||
FirebaseService.shared.logNotificationScheduled(frequency: source.notificationFrequency)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -527,12 +526,7 @@ private extension NotificationService {
|
||||
|
||||
let cal = Calendar.current
|
||||
for goal in goals {
|
||||
let goalSources: [InvestmentSource]
|
||||
if let accId = goal.account?.id {
|
||||
goalSources = sources.filter { $0.account?.id == accId }
|
||||
} else {
|
||||
goalSources = sources
|
||||
}
|
||||
let goalSources = goal.relevantSources(from: sources)
|
||||
|
||||
let evolution = monthlyEvolution(from: goalSources)
|
||||
guard evolution.count >= 3,
|
||||
|
||||
@@ -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, *) {
|
||||
|
||||
@@ -100,7 +100,6 @@ class ShareService {
|
||||
|
||||
@MainActor
|
||||
func shareMonthlyCheckIn(summary: MonthlySummary, appName: String) {
|
||||
FirebaseService.shared.logShare(type: "monthly_checkin")
|
||||
let text = Self.buildMonthlyCheckInShareText(summary: summary, appName: appName)
|
||||
shareCard(
|
||||
cardTitle: "\(summary.periodLabel) Check-in",
|
||||
@@ -127,7 +126,6 @@ class ShareService {
|
||||
|
||||
@MainActor
|
||||
func shareMonthlySummary(_ data: MonthlySummaryShareData) {
|
||||
FirebaseService.shared.logShare(type: "monthly_summary")
|
||||
let appName = Self.appDisplayName
|
||||
|
||||
var lines = [
|
||||
@@ -178,7 +176,6 @@ class ShareService {
|
||||
percentOnly: Bool = false,
|
||||
storyFormat: Bool = false
|
||||
) {
|
||||
FirebaseService.shared.logShare(type: percentOnly ? "portfolio_value_percent" : "portfolio_value")
|
||||
let appName = Self.appDisplayName
|
||||
let display = Self.portfolioCardStrings(
|
||||
totalValue: totalValue,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -17,16 +17,6 @@ enum AppConstants {
|
||||
|
||||
static let premiumProductID = "com.portfoliojournal.premium"
|
||||
|
||||
// MARK: - AdMob
|
||||
|
||||
#if DEBUG
|
||||
static let adMobAppID = "ca-app-pub-3940256099942544~1458002511" // Test App ID
|
||||
static let bannerAdUnitID = "ca-app-pub-3940256099942544/2934735716" // Test Banner
|
||||
#else
|
||||
static let adMobAppID = "ca-app-pub-XXXXXXXXXXXXXXXX~YYYYYYYYYY" // Replace with real App ID
|
||||
static let bannerAdUnitID = "ca-app-pub-XXXXXXXXXXXXXXXX/YYYYYYYYYY" // Replace with real Ad Unit ID
|
||||
#endif
|
||||
|
||||
// MARK: - Currency
|
||||
|
||||
static let defaultCurrency = "EUR"
|
||||
@@ -52,7 +42,6 @@ enum AppConstants {
|
||||
static let smallIconSize: CGFloat = 16
|
||||
static let largeIconSize: CGFloat = 32
|
||||
|
||||
static let bannerAdHeight: CGFloat = 50
|
||||
static let tabBarHeight: CGFloat = 49
|
||||
|
||||
static let cardShadowRadius: CGFloat = 4
|
||||
@@ -88,7 +77,6 @@ enum AppConstants {
|
||||
|
||||
enum StorageKeys {
|
||||
static let onboardingCompleted = "onboardingCompleted"
|
||||
static let adConsentObtained = "adConsentObtained"
|
||||
static let lastSyncDate = "lastSyncDate"
|
||||
static let selectedCategoryFilter = "selectedCategoryFilter"
|
||||
static let preferredChartType = "preferredChartType"
|
||||
@@ -119,7 +107,6 @@ enum AppConstants {
|
||||
static let enableExport = true
|
||||
static let enableWidgets = true
|
||||
static let enableNotifications = true
|
||||
static let enableAnalytics = true
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -167,6 +167,7 @@ extension Color {
|
||||
|
||||
// MARK: - Gradient Extensions
|
||||
|
||||
@MainActor
|
||||
extension LinearGradient {
|
||||
static let appPrimaryGradient = LinearGradient(
|
||||
colors: [Color.appPrimary, Color.appPrimary.lighter()],
|
||||
|
||||
@@ -128,8 +128,6 @@ class FreemiumValidator: ObservableObject {
|
||||
return false
|
||||
case .export:
|
||||
return false
|
||||
case .noAds:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,7 +140,6 @@ class FreemiumValidator: ObservableObject {
|
||||
case advancedCharts = "advanced_charts"
|
||||
case predictions = "predictions"
|
||||
case export = "export"
|
||||
case noAds = "no_ads"
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
@@ -154,7 +151,6 @@ class FreemiumValidator: ObservableObject {
|
||||
case .advancedCharts: return "Advanced Charts"
|
||||
case .predictions: return "Predictions"
|
||||
case .export: return "Export Data"
|
||||
case .noAds: return "No Ads"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,7 +162,6 @@ class FreemiumValidator: ObservableObject {
|
||||
case .advancedCharts: return "chart.bar.xaxis"
|
||||
case .predictions: return "wand.and.stars"
|
||||
case .export: return "square.and.arrow.up"
|
||||
case .noAds: return "xmark.circle"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -184,8 +179,6 @@ class FreemiumValidator: ObservableObject {
|
||||
return "AI-powered 12-month forecasts"
|
||||
case .export:
|
||||
return "Export to CSV and JSON formats"
|
||||
case .noAds:
|
||||
return "Ad-free experience forever"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -31,7 +31,6 @@ enum SnapshotGapDetector {
|
||||
/// Only months strictly between two known snapshots count — a source's
|
||||
/// trailing "not updated in a while" is handled elsewhere (pending updates).
|
||||
static func detectGaps(sources: [InvestmentSource], snapshots: [Snapshot]) -> [Gap] {
|
||||
let calendar = Calendar.current
|
||||
let snapshotsBySource = Dictionary(grouping: snapshots) { $0.source?.id }
|
||||
var gaps: [Gap] = []
|
||||
|
||||
|
||||
@@ -405,8 +405,6 @@ class ChartsViewModel: ObservableObject {
|
||||
category: selectedCategory,
|
||||
timeRange: selectedTimeRange
|
||||
)
|
||||
|
||||
FirebaseService.shared.logScreenView(screenName: "Charts")
|
||||
}
|
||||
|
||||
/// Selection is NEVER blocked — premium charts select normally and the chart
|
||||
@@ -423,10 +421,6 @@ class ChartsViewModel: ObservableObject {
|
||||
// observer: makes selection deterministic and lets re-tapping a tile
|
||||
// act as a retry if a previous update was dropped.
|
||||
updateChartData(chartType: chartType, category: selectedCategory, timeRange: selectedTimeRange)
|
||||
FirebaseService.shared.logChartViewed(
|
||||
chartType: chartType.rawValue,
|
||||
isPremium: chartType.isPremium
|
||||
)
|
||||
}
|
||||
|
||||
func availableTimeRanges(for chartType: ChartType) -> [TimeRange] {
|
||||
|
||||
@@ -219,8 +219,10 @@ class DashboardViewModel: ObservableObject {
|
||||
// Detect internal data gaps (missing months between known snapshots)
|
||||
dataGaps = SnapshotGapDetector.detectGaps(sources: sources, snapshots: allSnapshots)
|
||||
|
||||
// Log screen view
|
||||
FirebaseService.shared.logScreenView(screenName: "Dashboard")
|
||||
// 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] {
|
||||
@@ -633,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
|
||||
|
||||
@@ -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? {
|
||||
@@ -203,17 +200,14 @@ class GoalsViewModel: ObservableObject {
|
||||
}
|
||||
|
||||
private func relevantSources(for goal: Goal) -> [InvestmentSource] {
|
||||
if let accountId = goal.account?.safeId {
|
||||
return sourceRepository.sources.filter { $0.account?.id == accountId }
|
||||
}
|
||||
return sourceRepository.sources
|
||||
goal.relevantSources(from: sourceRepository.sources)
|
||||
}
|
||||
|
||||
private func evolutionData(
|
||||
for goal: Goal,
|
||||
sources: [InvestmentSource]
|
||||
) -> [(date: Date, value: Decimal)] {
|
||||
let cacheKey = goal.account?.safeId ?? UUID(uuidString: "00000000-0000-0000-0000-000000000000")!
|
||||
let cacheKey = goal.scopeKey
|
||||
if let cached = cachedEvolutionData[cacheKey] {
|
||||
return cached
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ class SettingsViewModel: ObservableObject {
|
||||
@Published var isFamilyShared = false
|
||||
@Published var notificationsEnabled = false
|
||||
@Published var defaultNotificationTime = Date()
|
||||
@Published var analyticsEnabled = true
|
||||
@Published var currencyCode = AppSettings.getOrCreate(in: CoreDataStack.shared.viewContext).currency
|
||||
@Published var inputMode: InputMode = .simple
|
||||
|
||||
@@ -93,7 +92,6 @@ class SettingsViewModel: ObservableObject {
|
||||
let settings = AppSettings.getOrCreate(in: context)
|
||||
|
||||
defaultNotificationTime = settings.defaultNotificationTime ?? Date()
|
||||
analyticsEnabled = settings.enableAnalytics
|
||||
currencyCode = settings.currency
|
||||
inputMode = InputMode(rawValue: settings.inputMode) ?? .simple
|
||||
backupRetentionCount = loadBackupRetention()
|
||||
@@ -107,8 +105,6 @@ class SettingsViewModel: ObservableObject {
|
||||
|
||||
// Load statistics directly from database to avoid async race conditions
|
||||
loadStatistics()
|
||||
|
||||
FirebaseService.shared.logScreenView(screenName: "Settings")
|
||||
}
|
||||
|
||||
private func loadStatistics() {
|
||||
@@ -131,7 +127,6 @@ class SettingsViewModel: ObservableObject {
|
||||
|
||||
func upgradeToPremium() {
|
||||
showingPaywall = true
|
||||
FirebaseService.shared.logPaywallShown(trigger: "settings_upgrade")
|
||||
}
|
||||
|
||||
func restorePurchases() async {
|
||||
@@ -141,10 +136,8 @@ class SettingsViewModel: ObservableObject {
|
||||
|
||||
if isPremium {
|
||||
successMessage = "Purchases restored successfully!"
|
||||
FirebaseService.shared.logRestorePurchases(success: true)
|
||||
} else {
|
||||
errorMessage = "No purchases to restore"
|
||||
FirebaseService.shared.logRestorePurchases(success: false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -191,7 +184,6 @@ class SettingsViewModel: ObservableObject {
|
||||
func exportData(format: ExportService.ExportFormat) {
|
||||
guard freemiumValidator.canExport() else {
|
||||
showingPaywall = true
|
||||
FirebaseService.shared.logPaywallShown(trigger: "export")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -239,16 +231,14 @@ 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)
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
isExporting = false
|
||||
errorMessage = "Export failed: \(error.localizedDescription)"
|
||||
FirebaseService.shared.logExportAttempt(format: format.rawValue, success: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -272,19 +262,6 @@ class SettingsViewModel: ObservableObject {
|
||||
freemiumValidator.canExport()
|
||||
}
|
||||
|
||||
// MARK: - Analytics
|
||||
|
||||
func toggleAnalytics(_ enabled: Bool) {
|
||||
analyticsEnabled = enabled
|
||||
|
||||
let context = CoreDataStack.shared.viewContext
|
||||
let settings = AppSettings.getOrCreate(in: context)
|
||||
settings.enableAnalytics = enabled
|
||||
CoreDataStack.shared.save()
|
||||
|
||||
// Note: In production, you'd also update Firebase Analytics consent
|
||||
}
|
||||
|
||||
// MARK: - Currency
|
||||
|
||||
func updateCurrency(_ code: String) {
|
||||
@@ -555,7 +532,6 @@ class SettingsViewModel: ObservableObject {
|
||||
guard isPremium else {
|
||||
backupsEnabled = false
|
||||
UserDefaults.standard.set(false, forKey: backupsEnabledKey)
|
||||
FirebaseService.shared.logPaywallShown(trigger: "backups")
|
||||
showingPaywall = true
|
||||
return
|
||||
}
|
||||
|
||||
@@ -87,8 +87,6 @@ class SourceDetailViewModel: ObservableObject {
|
||||
isLoading = true
|
||||
refreshData()
|
||||
isLoading = false
|
||||
|
||||
FirebaseService.shared.logScreenView(screenName: "SourceDetail")
|
||||
}
|
||||
|
||||
func refreshData() {
|
||||
@@ -167,9 +165,6 @@ class SourceDetailViewModel: ObservableObject {
|
||||
// Reschedule notification
|
||||
NotificationService.shared.scheduleReminder(for: source)
|
||||
|
||||
// Log analytics
|
||||
FirebaseService.shared.logSnapshotAdded(sourceName: source.name, value: value)
|
||||
|
||||
showingAddSnapshot = false
|
||||
refreshData()
|
||||
}
|
||||
@@ -216,9 +211,6 @@ class SourceDetailViewModel: ObservableObject {
|
||||
// Cancel any pending notifications for this source
|
||||
NotificationService.shared.cancelReminder(for: source)
|
||||
|
||||
// Log analytics before deletion
|
||||
FirebaseService.shared.logSourceDeleted(categoryName: source.category?.name ?? "Uncategorized")
|
||||
|
||||
// Delete the source using the existing repository
|
||||
sourceRepository.deleteSource(source)
|
||||
}
|
||||
@@ -226,14 +218,9 @@ class SourceDetailViewModel: ObservableObject {
|
||||
// MARK: - Predictions
|
||||
|
||||
func showPredictions() {
|
||||
if freemiumValidator.canViewPredictions() {
|
||||
// Already loaded, just navigate
|
||||
FirebaseService.shared.logPredictionViewed(
|
||||
algorithm: predictionResult?.algorithm.rawValue ?? "unknown"
|
||||
)
|
||||
} else {
|
||||
// Predictions are already loaded; free users get the paywall instead.
|
||||
if !freemiumValidator.canViewPredictions() {
|
||||
showingPaywall = true
|
||||
FirebaseService.shared.logPaywallShown(trigger: "predictions")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -76,8 +76,6 @@ class SourceListViewModel: ObservableObject {
|
||||
sourceRepository.fetchSources()
|
||||
categoryRepository.fetchCategories()
|
||||
isLoading = false
|
||||
|
||||
FirebaseService.shared.logScreenView(screenName: "SourceList")
|
||||
}
|
||||
|
||||
private func filterAndSortSources(_ allSources: [InvestmentSource]) {
|
||||
@@ -115,7 +113,6 @@ class SourceListViewModel: ObservableObject {
|
||||
showingAddSource = true
|
||||
} else {
|
||||
showingPaywall = true
|
||||
FirebaseService.shared.logPaywallShown(trigger: "source_limit")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,26 +134,15 @@ class SourceListViewModel: ObservableObject {
|
||||
// Schedule notification
|
||||
NotificationService.shared.scheduleReminder(for: source)
|
||||
|
||||
// Log analytics
|
||||
FirebaseService.shared.logSourceAdded(
|
||||
categoryName: category.name,
|
||||
sourceCount: sourceRepository.sourceCount
|
||||
)
|
||||
|
||||
showingAddSource = false
|
||||
}
|
||||
|
||||
func deleteSource(_ source: InvestmentSource) {
|
||||
let categoryName = source.category?.name ?? "Unknown"
|
||||
|
||||
// Cancel notifications
|
||||
NotificationService.shared.cancelReminder(for: source)
|
||||
|
||||
// Delete source
|
||||
sourceRepository.deleteSource(source)
|
||||
|
||||
// Log analytics
|
||||
FirebaseService.shared.logSourceDeleted(categoryName: categoryName)
|
||||
}
|
||||
|
||||
func deleteSource(at offsets: IndexSet) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -57,14 +57,14 @@ struct AllocationPieChart: View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text(item.category)
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.primary)
|
||||
.foregroundStyle(.primary)
|
||||
|
||||
let percentage = total > 0
|
||||
? NSDecimalNumber(decimal: item.value / total).doubleValue * 100
|
||||
: 0
|
||||
Text(String(format: "%.1f%%", percentage))
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.opacity(selectedSlice == nil || selectedSlice == item.category ? 1 : 0.5)
|
||||
@@ -77,7 +77,7 @@ struct AllocationPieChart: View {
|
||||
} label: {
|
||||
Image(systemName: "chevron.right.circle.fill")
|
||||
.font(.footnote)
|
||||
.foregroundColor(.secondary.opacity(0.6))
|
||||
.foregroundStyle(.secondary.opacity(0.6))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
@@ -92,14 +92,14 @@ 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)
|
||||
}
|
||||
}
|
||||
@@ -167,7 +167,7 @@ struct SemicircleAllocation: View {
|
||||
y: center.y + radius * CoreGraphics.sin(angle))
|
||||
Text("\(Int((frac * 100).rounded()))%")
|
||||
.font(.system(size: lineWidth * 0.42, weight: .bold))
|
||||
.foregroundColor(.white)
|
||||
.foregroundStyle(.white)
|
||||
.position(pos)
|
||||
.opacity(selectedSlice == nil || selectedSlice == seg.category ? 1 : 0.4)
|
||||
}
|
||||
@@ -178,14 +178,14 @@ struct SemicircleAllocation: View {
|
||||
VStack(spacing: 1) {
|
||||
Text(c.label)
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
Text(c.value)
|
||||
.font(.headline)
|
||||
if let sub = c.sub {
|
||||
Text(sub)
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.position(x: center.x, y: center.y - lineWidth * 0.25)
|
||||
@@ -243,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
|
||||
@@ -275,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
|
||||
@@ -289,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)
|
||||
}
|
||||
|
||||
@@ -351,7 +351,7 @@ struct AllocationListView: View {
|
||||
|
||||
Text(item.value.compactCurrencyString)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 70, alignment: .trailing)
|
||||
}
|
||||
|
||||
@@ -365,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)
|
||||
@@ -379,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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,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(
|
||||
@@ -76,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -191,10 +191,10 @@ struct ChartRangeBrush: View {
|
||||
HStack(spacing: 5) {
|
||||
Text("\(Self.labelFormatter.string(from: dates[lo])) – \(Self.labelFormatter.string(from: dates[hi]))")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundColor(.appPrimary)
|
||||
.foregroundStyle(Color.appPrimary)
|
||||
Image(systemName: "chevron.up.chevron.down")
|
||||
.font(.system(size: 8, weight: .semibold))
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
@@ -410,7 +410,7 @@ struct ChartRangePickerSheet: View {
|
||||
VStack(spacing: 2) {
|
||||
Text(title)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
Picker(title, selection: selection) {
|
||||
ForEach(dates.indices, id: \.self) { i in
|
||||
Text(Self.optionFormatter.string(from: dates[i])).tag(i)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import SwiftUI
|
||||
import Charts
|
||||
import CoreData
|
||||
import TipKit
|
||||
|
||||
struct ChartsContainerView: View {
|
||||
@@ -28,10 +29,10 @@ struct ChartsContainerView: View {
|
||||
// pattern) — replaces the 14-chip horizontal carousel.
|
||||
.toolbarTitleMenu { chartTypePicker }
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) { accountFilterMenu }
|
||||
ToolbarItem(placement: .navigationBarTrailing) { filterMenu }
|
||||
ToolbarItem(placement: .topBarTrailing) { accountFilterMenu }
|
||||
ToolbarItem(placement: .topBarTrailing) { filterMenu }
|
||||
if horizontalSizeClass != .regular {
|
||||
ToolbarItem(placement: .navigationBarTrailing) { shareButton }
|
||||
ToolbarItem(placement: .topBarTrailing) { shareButton }
|
||||
}
|
||||
}
|
||||
.onAppear { syncState() }
|
||||
@@ -95,9 +96,6 @@ struct ChartsContainerView: View {
|
||||
|
||||
if !viewModel.isPremium {
|
||||
CompactPaywallBanner(showingPaywall: $viewModel.showingPaywall)
|
||||
.onAppear {
|
||||
FirebaseService.shared.logPaywallShown(trigger: "charts_banner")
|
||||
}
|
||||
}
|
||||
chartContent
|
||||
if viewModel.hiddenHistoryMonths > 0 {
|
||||
@@ -105,11 +103,14 @@ struct ChartsContainerView: View {
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
// Regular width has a wide canvas next to the sidebar: give
|
||||
// every plot ~35% more height so the extra area shows data,
|
||||
// not just a stretched iPhone chart.
|
||||
.environment(\.chartHeightScale, 1.35)
|
||||
}
|
||||
}
|
||||
.clipped()
|
||||
}
|
||||
.ignoresSafeArea(edges: .bottom)
|
||||
// Attached here (not on the layout-switching Group): sheets presented from
|
||||
// a view that gets replaced when the size class changes were unreliable in
|
||||
// NavigationSplitView — premium tiles looked like they "did nothing".
|
||||
@@ -144,7 +145,7 @@ struct ChartsContainerView: View {
|
||||
if chartType.isPremium && !viewModel.isPremium {
|
||||
Image(systemName: "lock.fill")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -465,19 +466,19 @@ struct ChartsContainerView: View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(label)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.8)
|
||||
Text(value)
|
||||
.font(.system(.title3, design: .rounded).weight(.semibold))
|
||||
.foregroundColor(color)
|
||||
.foregroundStyle(color)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.6)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(12)
|
||||
.background(Color(.systemBackground))
|
||||
.cornerRadius(AppConstants.UI.smallCornerRadius)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.smallCornerRadius))
|
||||
.shadow(color: .black.opacity(0.05), radius: 4, y: 1)
|
||||
}
|
||||
|
||||
@@ -492,7 +493,7 @@ struct ChartsContainerView: View {
|
||||
.font(.headline)
|
||||
Text(viewModel.selectedChartType.description)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
Spacer()
|
||||
@@ -577,7 +578,6 @@ struct ChartsContainerView: View {
|
||||
/// 12-month window; tell them exactly how much is locked instead of hiding it.
|
||||
private var lockedHistoryTeaser: some View {
|
||||
Button {
|
||||
FirebaseService.shared.logPaywallShown(trigger: "history_teaser")
|
||||
viewModel.showingPaywall = true
|
||||
} label: {
|
||||
HStack(spacing: 8) {
|
||||
@@ -589,10 +589,10 @@ struct ChartsContainerView: View {
|
||||
.font(.caption)
|
||||
}
|
||||
.font(.footnote.weight(.medium))
|
||||
.foregroundColor(.appPrimary)
|
||||
.foregroundStyle(Color.appPrimary)
|
||||
.padding(12)
|
||||
.background(Color.appPrimary.opacity(0.08))
|
||||
.cornerRadius(AppConstants.UI.smallCornerRadius)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.smallCornerRadius))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
@@ -607,9 +607,6 @@ struct ChartsContainerView: View {
|
||||
chartTypeChipBar
|
||||
if !viewModel.isPremium {
|
||||
CompactPaywallBanner(showingPaywall: $viewModel.showingPaywall)
|
||||
.onAppear {
|
||||
FirebaseService.shared.logPaywallShown(trigger: "charts_banner")
|
||||
}
|
||||
}
|
||||
iPhonePeriodControl
|
||||
if hasActiveFilters {
|
||||
@@ -673,7 +670,7 @@ struct ChartsContainerView: View {
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 7)
|
||||
.foregroundColor(isSelected ? .white : (locked ? .secondary : .primary))
|
||||
.foregroundStyle(isSelected ? .white : (locked ? .secondary : .primary))
|
||||
.background(
|
||||
Capsule().fill(isSelected ? Color.appPrimary : Color(.systemBackground))
|
||||
)
|
||||
@@ -706,7 +703,7 @@ struct ChartsContainerView: View {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.font(.footnote)
|
||||
}
|
||||
.foregroundColor(.appPrimary)
|
||||
.foregroundStyle(Color.appPrimary)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 7)
|
||||
.background(Capsule().fill(Color.appPrimary.opacity(0.12)))
|
||||
@@ -777,7 +774,7 @@ struct ChartsContainerView: View {
|
||||
} label: {
|
||||
Image(systemName: "calendar.badge.clock")
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.foregroundColor(showRangeBrush ? .white : .appPrimary)
|
||||
.foregroundStyle(showRangeBrush ? .white : Color.appPrimary)
|
||||
.frame(width: 38, height: 30)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
@@ -858,7 +855,6 @@ struct ChartsContainerView: View {
|
||||
/// back.
|
||||
private func drillDown(to name: String) {
|
||||
guard viewModel.isPremium else {
|
||||
FirebaseService.shared.logPaywallShown(trigger: "chart_workable")
|
||||
viewModel.showingPaywall = true
|
||||
return
|
||||
}
|
||||
@@ -899,7 +895,7 @@ struct ChartsContainerView: View {
|
||||
premiumLockedView
|
||||
} else if viewModel.isLoading {
|
||||
ProgressView()
|
||||
.frame(height: 300)
|
||||
.chartFrame(height: 300)
|
||||
} else if !viewModel.hasData {
|
||||
emptyStateView
|
||||
} else {
|
||||
@@ -912,7 +908,6 @@ struct ChartsContainerView: View {
|
||||
contributions: viewModel.contributionsData,
|
||||
isPremium: viewModel.isPremium,
|
||||
onPremiumNeeded: {
|
||||
FirebaseService.shared.logPaywallShown(trigger: "chart_workable")
|
||||
viewModel.showingPaywall = true
|
||||
}
|
||||
)
|
||||
@@ -975,10 +970,10 @@ struct ChartsContainerView: View {
|
||||
.frame(width: 88, height: 88)
|
||||
Image(systemName: viewModel.selectedChartType.icon)
|
||||
.font(.system(size: 34))
|
||||
.foregroundColor(.appPrimary)
|
||||
.foregroundStyle(Color.appPrimary)
|
||||
Image(systemName: "lock.circle.fill")
|
||||
.font(.system(size: 26))
|
||||
.foregroundColor(.appWarning)
|
||||
.foregroundStyle(Color.appWarning)
|
||||
.background(Circle().fill(Color(.systemBackground)))
|
||||
.offset(x: 32, y: 30)
|
||||
}
|
||||
@@ -988,11 +983,10 @@ struct ChartsContainerView: View {
|
||||
|
||||
Text(viewModel.selectedChartType.description)
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
Button {
|
||||
FirebaseService.shared.logPaywallShown(trigger: "advanced_charts")
|
||||
viewModel.showingPaywall = true
|
||||
} label: {
|
||||
Text(String(localized: "chart_locked_cta"))
|
||||
@@ -1000,14 +994,14 @@ struct ChartsContainerView: View {
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.vertical, 12)
|
||||
.background(Color.appPrimary)
|
||||
.foregroundColor(.white)
|
||||
.cornerRadius(24)
|
||||
.foregroundStyle(.white)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 24))
|
||||
}
|
||||
}
|
||||
.padding(32)
|
||||
.frame(maxWidth: .infinity, minHeight: 320)
|
||||
.background(Color(.systemBackground))
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
|
||||
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
|
||||
}
|
||||
|
||||
@@ -1015,17 +1009,17 @@ struct ChartsContainerView: View {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "chart.bar.xaxis")
|
||||
.font(.system(size: 48))
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Text("No Data Available")
|
||||
.font(.headline)
|
||||
|
||||
Text("Add some investment sources and snapshots to see charts.")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
.frame(height: 300)
|
||||
.chartFrame(height: 300)
|
||||
}
|
||||
|
||||
private var accountFilterMenu: some View {
|
||||
@@ -1167,10 +1161,10 @@ struct EvolutionChartView: View {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "lightbulb.fill")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.appWarning)
|
||||
.foregroundStyle(Color.appWarning)
|
||||
Text(insight)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
if !data.isEmpty && chartMode == .total {
|
||||
@@ -1179,7 +1173,7 @@ struct EvolutionChartView: 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)
|
||||
}
|
||||
|
||||
@@ -1228,7 +1222,7 @@ struct EvolutionChartView: View {
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 5)
|
||||
.foregroundColor(showContributions ? .appSecondary : .secondary)
|
||||
.foregroundStyle(showContributions ? Color.appSecondary : .secondary)
|
||||
.background(
|
||||
Capsule().fill(showContributions ? Color.appSecondary.opacity(0.16) : Color(.systemGray6))
|
||||
)
|
||||
@@ -1307,7 +1301,7 @@ struct EvolutionChartView: View {
|
||||
if series.count > 1 {
|
||||
Text(String(format: "%+.1f%%", deltaPct))
|
||||
.font(.caption.weight(.bold))
|
||||
.foregroundColor(deltaPct >= 0 ? .positiveGreen : .negativeRed)
|
||||
.foregroundStyle(deltaPct >= 0 ? Color.positiveGreen : Color.negativeRed)
|
||||
.padding(.horizontal, 7)
|
||||
.padding(.vertical, 3)
|
||||
.background(
|
||||
@@ -1317,7 +1311,7 @@ struct EvolutionChartView: View {
|
||||
}
|
||||
Text(subtitle)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
@@ -1326,7 +1320,7 @@ struct EvolutionChartView: View {
|
||||
showGoalLines.toggle()
|
||||
} label: {
|
||||
Image(systemName: showGoalLines ? "target" : "slash.circle")
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.disabled(goals.isEmpty)
|
||||
.accessibilityLabel(showGoalLines ? "Hide goals" : "Show goals")
|
||||
@@ -1349,8 +1343,8 @@ struct EvolutionChartView: View {
|
||||
chartView
|
||||
} else {
|
||||
Text("Not enough data")
|
||||
.foregroundColor(.secondary)
|
||||
.frame(height: 300)
|
||||
.foregroundStyle(.secondary)
|
||||
.chartFrame(height: 300)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1414,7 +1408,7 @@ struct EvolutionChartView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(height: 300)
|
||||
.chartFrame(height: 300)
|
||||
.zoomableTimeSeries(dates: data.map(\.date), zoom: $zoom)
|
||||
// Performance: GPU rendering for smoother scrolling on older devices
|
||||
.chartDrawingGroup(disabledForExport: chartImageExport)
|
||||
@@ -1435,7 +1429,7 @@ struct EvolutionChartView: View {
|
||||
series: .value("Series", "total")
|
||||
)
|
||||
.foregroundStyle(
|
||||
LinearGradient(colors: [.appPrimary, .cyan],
|
||||
LinearGradient(colors: [Color.appPrimary, .cyan],
|
||||
startPoint: .leading, endPoint: .trailing)
|
||||
)
|
||||
.lineStyle(StrokeStyle(lineWidth: 2.2, lineCap: .round, lineJoin: .round))
|
||||
@@ -1571,8 +1565,8 @@ struct ContributionsChartView: View {
|
||||
|
||||
if data.isEmpty {
|
||||
Text("No contributions yet.")
|
||||
.foregroundColor(.secondary)
|
||||
.frame(height: 260)
|
||||
.foregroundStyle(.secondary)
|
||||
.chartFrame(height: 260)
|
||||
} else {
|
||||
Chart {
|
||||
ForEach(data, id: \.date) { item in
|
||||
@@ -1581,7 +1575,7 @@ struct ContributionsChartView: View {
|
||||
y: .value("Amount", NSDecimalNumber(decimal: item.amount).doubleValue)
|
||||
)
|
||||
.foregroundStyle(Color.appSecondary)
|
||||
.cornerRadius(6)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
}
|
||||
}
|
||||
.chartXAxis {
|
||||
@@ -1599,14 +1593,14 @@ struct ContributionsChartView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(height: 260)
|
||||
.chartFrame(height: 260)
|
||||
.zoomableTimeSeries(dates: data.map(\.date), zoom: $zoom)
|
||||
ChartStatsRow(stats: contributionStats).padding(.top, 4)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.systemBackground))
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
|
||||
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
|
||||
}
|
||||
|
||||
@@ -1649,8 +1643,8 @@ struct RollingReturnChartView: View {
|
||||
|
||||
if data.isEmpty {
|
||||
Text("Not enough data for rolling returns.")
|
||||
.foregroundColor(.secondary)
|
||||
.frame(height: 260)
|
||||
.foregroundStyle(.secondary)
|
||||
.chartFrame(height: 260)
|
||||
} else {
|
||||
Chart {
|
||||
ForEach(Array(data.enumerated()), id: \.element.date) { pair in
|
||||
@@ -1706,7 +1700,7 @@ struct RollingReturnChartView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(height: 260)
|
||||
.chartFrame(height: 260)
|
||||
.zoomableTimeSeries(dates: data.map(\.date), zoom: $zoom)
|
||||
ChartStatsRow(stats: rollingStats).padding(.top, 4)
|
||||
ChartDataTable(
|
||||
@@ -1719,7 +1713,7 @@ struct RollingReturnChartView: 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)
|
||||
}
|
||||
|
||||
@@ -1771,8 +1765,8 @@ struct RiskReturnChartView: View {
|
||||
|
||||
if data.isEmpty {
|
||||
Text("Not enough data to compare categories.")
|
||||
.foregroundColor(.secondary)
|
||||
.frame(height: 260)
|
||||
.foregroundStyle(.secondary)
|
||||
.chartFrame(height: 260)
|
||||
} else {
|
||||
Chart {
|
||||
ForEach(data, id: \.category) { item in
|
||||
@@ -1785,7 +1779,7 @@ struct RiskReturnChartView: View {
|
||||
.annotation(position: .top) {
|
||||
Text(item.category)
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1809,12 +1803,12 @@ struct RiskReturnChartView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(height: 260)
|
||||
.chartFrame(height: 260)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.systemBackground))
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
|
||||
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
|
||||
}
|
||||
}
|
||||
@@ -1832,8 +1826,8 @@ struct CashflowStackedChartView: View {
|
||||
|
||||
if data.isEmpty {
|
||||
Text("Not enough data to compare cashflow.")
|
||||
.foregroundColor(.secondary)
|
||||
.frame(height: 260)
|
||||
.foregroundStyle(.secondary)
|
||||
.chartFrame(height: 260)
|
||||
} else {
|
||||
Chart {
|
||||
ForEach(data, id: \.date) { item in
|
||||
@@ -1871,14 +1865,14 @@ struct CashflowStackedChartView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(height: 260)
|
||||
.chartFrame(height: 260)
|
||||
.zoomableTimeSeries(dates: data.map(\.date), zoom: $zoom)
|
||||
ChartStatsRow(stats: cashflowStats).padding(.top, 4)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.systemBackground))
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
|
||||
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
|
||||
}
|
||||
|
||||
@@ -1934,14 +1928,14 @@ struct AllocationEvolutionChart: 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)
|
||||
}
|
||||
|
||||
private var emptyView: some View {
|
||||
Text("Not enough data to show allocation evolution.")
|
||||
.foregroundColor(.secondary)
|
||||
.frame(height: 260)
|
||||
.foregroundStyle(.secondary)
|
||||
.chartFrame(height: 260)
|
||||
}
|
||||
|
||||
/// Categories in stable order (preserving the ViewModel's sort: largest overall first)
|
||||
@@ -1989,7 +1983,7 @@ struct AllocationEvolutionChart: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(height: 260)
|
||||
.chartFrame(height: 260)
|
||||
.chartDrawingGroup(disabledForExport: chartImageExport)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,23 +92,23 @@ 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)
|
||||
}
|
||||
|
||||
/// Multi-series comparison: labelling every point of every series overlaps
|
||||
@@ -186,7 +186,7 @@ struct ComparisonChartView: View {
|
||||
.foregroundStyle(Color.secondary.opacity(0.15))
|
||||
}
|
||||
}
|
||||
.frame(height: 260)
|
||||
.chartFrame(height: 260)
|
||||
.chartDrawingGroup(disabledForExport: chartImageExport)
|
||||
|
||||
legend
|
||||
@@ -203,7 +203,7 @@ struct ComparisonChartView: View {
|
||||
.frame(width: 20, height: 3)
|
||||
Text(series.name)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,16 +38,16 @@ struct DrawdownChart: View {
|
||||
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 {
|
||||
@@ -119,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
|
||||
@@ -152,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)
|
||||
}
|
||||
|
||||
@@ -197,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)
|
||||
}
|
||||
@@ -247,7 +247,7 @@ struct VolatilityChartView: View {
|
||||
|
||||
Text("Measures price variability over time")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
if data.count >= 2 {
|
||||
Chart {
|
||||
@@ -293,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 {
|
||||
@@ -328,7 +328,7 @@ struct VolatilityChartView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(height: 250)
|
||||
.chartFrame(height: 250)
|
||||
.zoomableTimeSeries(dates: data.map(\.date), zoom: $zoom)
|
||||
|
||||
ChartStatsRow(stats: [
|
||||
@@ -346,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)
|
||||
}
|
||||
|
||||
@@ -400,7 +400,7 @@ struct VolatilityLevelView: View {
|
||||
.font(.caption2)
|
||||
Text(range)
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ struct PerformanceBarChart: View {
|
||||
|
||||
Text("Compound Annual Growth Rate (CAGR)")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
if !data.isEmpty {
|
||||
ChartStatsRow(stats: perfStats)
|
||||
@@ -44,14 +44,14 @@ struct PerformanceBarChart: View {
|
||||
.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)
|
||||
}
|
||||
|
||||
@@ -62,11 +62,11 @@ struct PerformanceBarChart: View {
|
||||
y: .value("CAGR", item.cagr)
|
||||
)
|
||||
.foregroundStyle(Color(hex: item.color) ?? .gray)
|
||||
.cornerRadius(4)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 4))
|
||||
.annotation(position: item.cagr >= 0 ? .top : .bottom) {
|
||||
Text(String(format: "%.1f%%", item.cagr))
|
||||
.font(.caption2)
|
||||
.foregroundColor(item.cagr >= 0 ? .positiveGreen : .negativeRed)
|
||||
.foregroundStyle(item.cagr >= 0 ? Color.positiveGreen : Color.negativeRed)
|
||||
}
|
||||
}
|
||||
.chartXAxis {
|
||||
@@ -91,7 +91,7 @@ struct PerformanceBarChart: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(height: 250)
|
||||
.chartFrame(height: 250)
|
||||
}
|
||||
|
||||
private var horizontalChart: some View {
|
||||
@@ -101,11 +101,11 @@ struct PerformanceBarChart: View {
|
||||
y: .value("Category", item.category)
|
||||
)
|
||||
.foregroundStyle(Color(hex: item.color) ?? .gray)
|
||||
.cornerRadius(4)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 4))
|
||||
.annotation(position: item.cagr >= 0 ? .trailing : .leading) {
|
||||
Text(String(format: "%.1f%%", item.cagr))
|
||||
.font(.caption2)
|
||||
.foregroundColor(item.cagr >= 0 ? .positiveGreen : .negativeRed)
|
||||
.foregroundStyle(item.cagr >= 0 ? Color.positiveGreen : Color.negativeRed)
|
||||
}
|
||||
}
|
||||
.chartXAxis {
|
||||
@@ -147,12 +147,12 @@ struct PerformanceBarChart: 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)
|
||||
|
||||
if onDrillDown != nil {
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption2.weight(.semibold))
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
if let onDrillDown {
|
||||
@@ -213,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
|
||||
@@ -224,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)
|
||||
@@ -238,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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,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)
|
||||
}
|
||||
|
||||
@@ -49,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)
|
||||
@@ -64,7 +64,7 @@ struct PeriodComparisonChartView: View {
|
||||
|
||||
Text(label)
|
||||
.font(.caption.weight(.bold))
|
||||
.foregroundColor(color)
|
||||
.foregroundStyle(color)
|
||||
.frame(width: 60, alignment: .leading)
|
||||
|
||||
DatePicker(
|
||||
@@ -81,7 +81,7 @@ struct PeriodComparisonChartView: View {
|
||||
|
||||
Text("→")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
DatePicker(
|
||||
"",
|
||||
@@ -132,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -175,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
|
||||
@@ -303,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 }
|
||||
}
|
||||
@@ -317,7 +317,7 @@ struct PeriodComparisonChartView: View {
|
||||
.frame(width: 20, height: 3)
|
||||
Text(series.label)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
|
||||
@@ -36,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -47,7 +47,7 @@ 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 {
|
||||
@@ -185,7 +185,7 @@ struct PredictionChartView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(height: 280)
|
||||
.chartFrame(height: 280)
|
||||
|
||||
// Stats row
|
||||
if let currentValue = historicalData.last?.value,
|
||||
@@ -206,7 +206,7 @@ struct PredictionChartView: View {
|
||||
.frame(width: 20, height: 3)
|
||||
Text("Historical")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
HStack(spacing: 6) {
|
||||
@@ -223,7 +223,7 @@ struct PredictionChartView: View {
|
||||
)
|
||||
Text("Prediction")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
HStack(spacing: 6) {
|
||||
@@ -232,7 +232,7 @@ struct PredictionChartView: View {
|
||||
.frame(width: 20, height: 10)
|
||||
Text("Confidence")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
HStack(spacing: 6) {
|
||||
@@ -241,7 +241,7 @@ struct PredictionChartView: View {
|
||||
.frame(width: 20, height: 2)
|
||||
Text("Bull")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
HStack(spacing: 6) {
|
||||
@@ -250,7 +250,7 @@ struct PredictionChartView: View {
|
||||
.frame(width: 20, height: 2)
|
||||
Text("Bear")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -274,7 +274,7 @@ struct PredictionChartView: View {
|
||||
Spacer()
|
||||
Text(lastPrediction.formattedConfidenceRange)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
if let currentValue = historicalData.last?.value {
|
||||
@@ -293,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -310,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)
|
||||
}
|
||||
|
||||
|
||||
@@ -30,9 +30,9 @@ struct YearOverYearChartView: View {
|
||||
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 {
|
||||
@@ -44,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
|
||||
@@ -57,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)
|
||||
}
|
||||
|
||||
@@ -82,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)
|
||||
}
|
||||
@@ -181,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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -202,7 +202,7 @@ struct YearOverYearChartView: View {
|
||||
.frame(width: 20, height: 3)
|
||||
Text(String(yearSeries.year))
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -221,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ struct ProgressRing: View {
|
||||
.animation(.easeOut(duration: 0.6), value: clamped)
|
||||
Text("\(Int((clamped * 100).rounded()))%")
|
||||
.font(.system(size: size * 0.26, weight: .bold, design: .rounded))
|
||||
.foregroundColor(tint)
|
||||
.foregroundStyle(tint)
|
||||
.minimumScaleFactor(0.7)
|
||||
}
|
||||
.frame(width: size, height: size)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,16 +77,17 @@ struct DashboardView: View {
|
||||
}
|
||||
}
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button {
|
||||
QuickUpdateTip().invalidate(reason: .actionPerformed)
|
||||
showingQuickUpdate = true
|
||||
} label: {
|
||||
Image(systemName: "plus.circle.fill")
|
||||
}
|
||||
.accessibilityIdentifier("dashboard.quickUpdate")
|
||||
.popoverTip(QuickUpdateTip())
|
||||
}
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button {
|
||||
balancePrivacy.toggleHidden()
|
||||
} label: {
|
||||
@@ -94,10 +95,10 @@ struct DashboardView: View {
|
||||
}
|
||||
.accessibilityLabel(balancePrivacy.balancesHidden ? Text("Show balances") : Text("Hide balances"))
|
||||
}
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
accountFilterMenu
|
||||
}
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
Button {
|
||||
showingCustomize = true
|
||||
} label: {
|
||||
@@ -140,10 +141,6 @@ struct DashboardView: View {
|
||||
.sheet(isPresented: $showingCustomize) {
|
||||
DashboardCustomizeView(configs: $sectionConfigs)
|
||||
}
|
||||
.sheet(isPresented: $showingQuickUpdate) {
|
||||
QuickUpdateView()
|
||||
.environment(\.managedObjectContext, CoreDataStack.shared.viewContext)
|
||||
}
|
||||
.sheet(isPresented: $viewModel.showingPaywall) {
|
||||
PaywallView()
|
||||
}
|
||||
@@ -172,6 +169,18 @@ struct DashboardView: View {
|
||||
if !viewModel.hasData { showingAddSource = true }
|
||||
}
|
||||
}
|
||||
// Quick Update lives in an inspector: a trailing panel in regular width
|
||||
// (iPad, iPhone Duo inner screen) so the dashboard charts stay visible
|
||||
// while values are typed; the system presents it as a sheet in compact.
|
||||
.inspector(isPresented: $showingQuickUpdate) {
|
||||
QuickUpdateView()
|
||||
.environment(\.managedObjectContext, CoreDataStack.shared.viewContext)
|
||||
.environmentObject(iapService)
|
||||
.environmentObject(accountStore)
|
||||
.environmentObject(tabSelection)
|
||||
.environmentObject(balancePrivacy)
|
||||
.inspectorColumnWidth(min: 340, ideal: 400, max: 480)
|
||||
}
|
||||
}
|
||||
|
||||
private var accountFilterMenu: some View {
|
||||
@@ -275,7 +284,10 @@ struct DashboardView: View {
|
||||
GeometryReader { geo in
|
||||
// Scale column count with available width so a wide Mac window fills
|
||||
// (3 columns) while an iPad stays at 2. Lead cards span full width.
|
||||
let columnCount = geo.size.width >= 1250 ? 3 : 2
|
||||
// Continuous sizing: 3 columns on a wide Mac window, 2 on an iPad /
|
||||
// Duo inner screen, 1 when a side panel (Quick Update inspector,
|
||||
// Split View) leaves too little room for two readable cards.
|
||||
let columnCount = geo.size.width >= 1250 ? 3 : (geo.size.width >= 600 ? 2 : 1)
|
||||
let columns = iPadContextColumns(count: columnCount)
|
||||
ScrollView {
|
||||
VStack(spacing: 16) {
|
||||
@@ -400,7 +412,14 @@ struct DashboardView: View {
|
||||
paceStatusProvider: goalsViewModel.paceStatus(for:),
|
||||
etaProvider: { goal in
|
||||
let currentValue = goalsViewModel.totalValue(for: goal)
|
||||
return viewModel.goalEtaText(for: goal, currentValue: currentValue)
|
||||
guard goal.account != nil || goal.category != nil else {
|
||||
return viewModel.goalEtaText(for: goal, currentValue: currentValue)
|
||||
}
|
||||
return viewModel.goalEtaText(
|
||||
for: goal,
|
||||
currentValue: currentValue,
|
||||
projection: goalsViewModel.projection(for: goal)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -468,20 +487,20 @@ struct TotalValueCard: View {
|
||||
HStack {
|
||||
Text("Total Portfolio Value")
|
||||
.font(.subheadline.weight(.medium))
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
if let onShareTap {
|
||||
Button(action: onShareTap) {
|
||||
Image(systemName: "square.and.arrow.up")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text(totalValue)
|
||||
.font(.system(size: 44, weight: .bold, design: .rounded))
|
||||
.foregroundColor(.primary)
|
||||
.foregroundStyle(.primary)
|
||||
.minimumScaleFactor(0.6)
|
||||
.lineLimit(1)
|
||||
.hiddenBalance(balancesHidden)
|
||||
@@ -493,7 +512,7 @@ struct TotalValueCard: View {
|
||||
Text(changeText)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
}
|
||||
.foregroundColor(isPositive ? .positiveGreen : .negativeRed)
|
||||
.foregroundStyle(isPositive ? Color.positiveGreen : Color.negativeRed)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 5)
|
||||
.background((isPositive ? Color.positiveGreen : Color.negativeRed).opacity(0.12))
|
||||
@@ -501,7 +520,7 @@ struct TotalValueCard: View {
|
||||
|
||||
Text(changeLabel)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
if sparklineData.count >= 2 {
|
||||
@@ -534,10 +553,10 @@ struct TotalValueCard: View {
|
||||
Image(systemName: "lock.fill").font(.caption2)
|
||||
Text("12m forecast").font(.caption2)
|
||||
}
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
Text("Unlock")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundColor(.appPrimary)
|
||||
.foregroundStyle(Color.appPrimary)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
@@ -549,17 +568,17 @@ struct TotalValueCard: View {
|
||||
.padding(20)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(Color(.secondarySystemGroupedBackground))
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
|
||||
}
|
||||
|
||||
private func heroMetric(label: String, value: String, positive: Bool) -> some View {
|
||||
VStack(spacing: 2) {
|
||||
Text(label)
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(value)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundColor(positive ? .positiveGreen : .negativeRed)
|
||||
.foregroundStyle(positive ? Color.positiveGreen : Color.negativeRed)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
@@ -750,15 +769,10 @@ struct MonthlyCheckInCard: View {
|
||||
}
|
||||
.padding(isMonthComplete ? 14 : 20)
|
||||
.background(Color(.secondarySystemGroupedBackground))
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
.background(
|
||||
NavigationLink(isActive: $startDestinationActive) {
|
||||
MonthlyCheckInView(referenceDate: navigationReferenceDate)
|
||||
} label: {
|
||||
EmptyView()
|
||||
}
|
||||
.opacity(0)
|
||||
)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
|
||||
.navigationDestination(isPresented: $startDestinationActive) {
|
||||
MonthlyCheckInView(referenceDate: navigationReferenceDate)
|
||||
}
|
||||
// Calm 2.0 Fase 2: the ritual runs as a guided full-screen flow — one
|
||||
// source per step, reflection, closing summary.
|
||||
.fullScreenCover(isPresented: $showingGuidedFlow) {
|
||||
@@ -772,14 +786,14 @@ struct MonthlyCheckInCard: View {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: "checkmark.seal.fill")
|
||||
.font(.title3)
|
||||
.foregroundColor(.positiveGreen)
|
||||
.foregroundStyle(Color.positiveGreen)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(String(format: String(localized: "checkin_done_title"), currentMonthLabel))
|
||||
.font(.subheadline.weight(.semibold))
|
||||
if let nextDate = nextCheckInDate {
|
||||
Text(String(format: String(localized: "checkin_done_next"), nextDate.mediumDateString))
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
@@ -790,7 +804,7 @@ struct MonthlyCheckInCard: View {
|
||||
Text("\(streak)")
|
||||
.font(.caption.weight(.bold))
|
||||
}
|
||||
.foregroundColor(.orange)
|
||||
.foregroundStyle(.orange)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 5)
|
||||
.background(Color.orange.opacity(0.12))
|
||||
@@ -800,7 +814,7 @@ struct MonthlyCheckInCard: View {
|
||||
Button(action: onShareSummary) {
|
||||
Image(systemName: "square.and.arrow.up")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundColor(.appPrimary)
|
||||
.foregroundStyle(Color.appPrimary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(Text("Share monthly summary"))
|
||||
@@ -810,7 +824,7 @@ struct MonthlyCheckInCard: View {
|
||||
} label: {
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
@@ -826,7 +840,7 @@ struct MonthlyCheckInCard: View {
|
||||
.font(.headline)
|
||||
Text(currentMonthLabel)
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
if streak >= 2 {
|
||||
@@ -834,7 +848,7 @@ struct MonthlyCheckInCard: View {
|
||||
Image(systemName: "flame.fill").font(.caption)
|
||||
Text("\(streak)").font(.caption.weight(.bold))
|
||||
}
|
||||
.foregroundColor(.orange)
|
||||
.foregroundStyle(.orange)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 5)
|
||||
.background(Color.orange.opacity(0.12))
|
||||
@@ -857,7 +871,7 @@ struct MonthlyCheckInCard: View {
|
||||
)
|
||||
} label: {
|
||||
Image(systemName: "calendar.badge.plus")
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.font(.subheadline.weight(.semibold))
|
||||
}
|
||||
@@ -869,7 +883,7 @@ struct MonthlyCheckInCard: View {
|
||||
.tint(progressBarTint)
|
||||
Text(String(format: String(localized: "checkin_sources_progress"), updatedCount, totalSources))
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
} else {
|
||||
ProgressView(value: checkInProgress)
|
||||
@@ -880,15 +894,15 @@ struct MonthlyCheckInCard: View {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "calendar")
|
||||
.font(.caption)
|
||||
.foregroundColor(isOverdue ? .red : .secondary)
|
||||
.foregroundStyle(isOverdue ? .red : .secondary)
|
||||
Text(String(format: String(localized: "checkin_next_due"), nextDate.mediumDateString))
|
||||
.font(.caption)
|
||||
.foregroundColor(isOverdue ? .red : .secondary)
|
||||
.foregroundStyle(isOverdue ? .red : .secondary)
|
||||
Spacer()
|
||||
if !deadlineBadgeText.isEmpty {
|
||||
Text(deadlineBadgeText)
|
||||
.font(.caption2.weight(.semibold))
|
||||
.foregroundColor(progressBarTint)
|
||||
.foregroundStyle(progressBarTint)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 3)
|
||||
.background(progressBarTint.opacity(0.12))
|
||||
@@ -898,7 +912,7 @@ struct MonthlyCheckInCard: View {
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 8)
|
||||
.background(Color(.tertiarySystemFill))
|
||||
.cornerRadius(10)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
}
|
||||
|
||||
Button {
|
||||
@@ -909,8 +923,8 @@ struct MonthlyCheckInCard: View {
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 12)
|
||||
.background(Color.appPrimary)
|
||||
.foregroundColor(.white)
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
.foregroundStyle(.white)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
|
||||
}
|
||||
.accessibilityIdentifier("checkin_start_cta")
|
||||
}
|
||||
@@ -952,11 +966,11 @@ struct MomentumStreaksCard: View {
|
||||
if stats.totalCheckIns > 0 {
|
||||
Text(String(format: NSLocalizedString("on_time_rate", comment: ""), onTimeRateText))
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundColor(.appSecondary)
|
||||
.foregroundStyle(Color.appSecondary)
|
||||
} else {
|
||||
Text("Log a check-in to start a streak")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -992,7 +1006,7 @@ struct MomentumStreaksCard: View {
|
||||
HStack {
|
||||
Text("On-time score")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
Text(
|
||||
String(
|
||||
@@ -1002,7 +1016,7 @@ struct MomentumStreaksCard: View {
|
||||
)
|
||||
)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
if let closest = stats.closestCutoffDays {
|
||||
@@ -1013,7 +1027,7 @@ struct MomentumStreaksCard: View {
|
||||
)
|
||||
)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
if !stats.achievements.isEmpty && !compact {
|
||||
@@ -1043,7 +1057,7 @@ struct MomentumStreaksCard: View {
|
||||
Spacer()
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
.contentShape(Rectangle())
|
||||
@@ -1052,7 +1066,7 @@ struct MomentumStreaksCard: 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)
|
||||
.onAppear(perform: refreshStats)
|
||||
.onReceive(NotificationCenter.default.publisher(for: .NSManagedObjectContextObjectsDidChange)) { _ in
|
||||
@@ -1087,14 +1101,14 @@ struct MomentumStreaksCard: View {
|
||||
.lineLimit(1)
|
||||
Text(title)
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.8)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 10)
|
||||
.background(Color.gray.opacity(0.08))
|
||||
.cornerRadius(AppConstants.UI.smallCornerRadius)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.smallCornerRadius))
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
@@ -1102,17 +1116,17 @@ struct MomentumStreaksCard: View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(title)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(value)
|
||||
.font(.title3.weight(.semibold))
|
||||
Text(subtitle)
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(Color.gray.opacity(0.08))
|
||||
.cornerRadius(AppConstants.UI.smallCornerRadius)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.smallCornerRadius))
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
@@ -1120,18 +1134,18 @@ struct MomentumStreaksCard: View {
|
||||
HStack(alignment: .top, spacing: 8) {
|
||||
Image(systemName: achievement.icon)
|
||||
.font(.headline)
|
||||
.foregroundColor(.appSecondary)
|
||||
.foregroundStyle(Color.appSecondary)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(achievement.title)
|
||||
.font(.caption.weight(.semibold))
|
||||
Text(achievement.detail)
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(10)
|
||||
.background(Color.appSecondary.opacity(0.12))
|
||||
.cornerRadius(AppConstants.UI.smallCornerRadius)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.smallCornerRadius))
|
||||
}
|
||||
|
||||
private func formattedDaysText(for days: Double?) -> String {
|
||||
@@ -1181,7 +1195,7 @@ struct MonthlySummaryCard: View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Contributions")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(summary.formattedContributions)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.hiddenBalance()
|
||||
@@ -1192,17 +1206,17 @@ struct MonthlySummaryCard: View {
|
||||
VStack(alignment: .trailing, spacing: 4) {
|
||||
Text("Net Performance")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
Text("\(summary.formattedNetPerformance) (\(summary.formattedNetPerformancePercentage))")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundColor(summary.netPerformance >= 0 ? .positiveGreen : .negativeRed)
|
||||
.foregroundStyle(summary.netPerformance >= 0 ? Color.positiveGreen : Color.negativeRed)
|
||||
.hiddenBalance()
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.systemBackground))
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
|
||||
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
|
||||
}
|
||||
}
|
||||
@@ -1223,7 +1237,7 @@ struct DashboardCustomizeView: View {
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Text(config.isCollapsed ? "Compact" : "Expanded")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
@@ -1246,10 +1260,10 @@ struct DashboardCustomizeView: View {
|
||||
}
|
||||
.navigationTitle("Customize Dashboard")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
EditButton()
|
||||
}
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button("Done") {
|
||||
DashboardLayoutStore.save(configs)
|
||||
dismiss()
|
||||
@@ -1276,18 +1290,18 @@ struct CompactCard: View {
|
||||
if subtitleIsBalance {
|
||||
Text(subtitle)
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
.hiddenBalance()
|
||||
} else {
|
||||
Text(subtitle)
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(Color(.systemBackground))
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
|
||||
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
|
||||
}
|
||||
}
|
||||
@@ -1302,7 +1316,7 @@ struct EvolutionCompactCard: View {
|
||||
if let last = data.last {
|
||||
Text(last.value.compactCurrencyString)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
SparklineView(data: data, color: .appPrimary)
|
||||
.frame(height: 40)
|
||||
@@ -1310,7 +1324,7 @@ struct EvolutionCompactCard: View {
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(Color(.systemBackground))
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
|
||||
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
|
||||
}
|
||||
}
|
||||
@@ -1357,7 +1371,7 @@ struct PeriodReturnsCard: 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)
|
||||
}
|
||||
}
|
||||
@@ -1371,11 +1385,11 @@ struct ReturnPeriodView: View {
|
||||
VStack(spacing: 4) {
|
||||
Text(period)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Text(change)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundColor(isPositive ? .positiveGreen : .negativeRed)
|
||||
.foregroundStyle(isPositive ? Color.positiveGreen : Color.negativeRed)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.8)
|
||||
}
|
||||
@@ -1394,14 +1408,14 @@ struct EmptyDashboardView: View {
|
||||
VStack(spacing: 20) {
|
||||
Image(systemName: "chart.pie")
|
||||
.font(.system(size: 60))
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Text("Welcome to Portfolio Journal")
|
||||
.font(.custom("Avenir Next", size: 24).weight(.semibold))
|
||||
|
||||
Text("Start by adding your first investment source to track your portfolio.")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
Button {
|
||||
@@ -1409,11 +1423,11 @@ struct EmptyDashboardView: View {
|
||||
} label: {
|
||||
Label("Add Investment Source", systemImage: "plus")
|
||||
.font(.headline)
|
||||
.foregroundColor(.white)
|
||||
.foregroundStyle(.white)
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(Color.appPrimary)
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
|
||||
}
|
||||
|
||||
HStack(spacing: 12) {
|
||||
@@ -1425,8 +1439,8 @@ struct EmptyDashboardView: View {
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding()
|
||||
.background(Color.appPrimary.opacity(0.1))
|
||||
.foregroundColor(.appPrimary)
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
.foregroundStyle(Color.appPrimary)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
|
||||
}
|
||||
|
||||
Button {
|
||||
@@ -1437,8 +1451,8 @@ struct EmptyDashboardView: View {
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding()
|
||||
.background(Color.appSecondary.opacity(0.1))
|
||||
.foregroundColor(.appSecondary)
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
.foregroundStyle(Color.appSecondary)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1455,24 +1469,24 @@ struct PendingUpdatesAlertBanner: View {
|
||||
var body: some View {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.foregroundColor(.white)
|
||||
.foregroundStyle(.white)
|
||||
|
||||
Text("\(count) source\(count == 1 ? "" : "s") pending update")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundColor(.white)
|
||||
.foregroundStyle(.white)
|
||||
|
||||
Spacer()
|
||||
|
||||
Button(action: onDismiss) {
|
||||
Image(systemName: "xmark")
|
||||
.font(.caption.weight(.bold))
|
||||
.foregroundColor(.white.opacity(0.8))
|
||||
.foregroundStyle(.white.opacity(0.8))
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
.background(Color.appWarning)
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1486,13 +1500,13 @@ struct PendingUpdatesCard: View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack {
|
||||
Image(systemName: "bell.badge.fill")
|
||||
.foregroundColor(.appWarning)
|
||||
.foregroundStyle(Color.appWarning)
|
||||
Text("Pending Updates")
|
||||
.font(.headline)
|
||||
Spacer()
|
||||
Text("\(sources.count)")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
ForEach(sources.prefix(3), id: \.objectID) { source in
|
||||
@@ -1509,11 +1523,11 @@ struct PendingUpdatesCard: View {
|
||||
|
||||
Text(source.latestSnapshot?.date.relativeDescription ?? "Never")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
@@ -1522,12 +1536,12 @@ struct PendingUpdatesCard: View {
|
||||
if sources.count > 3 {
|
||||
Text("+ \(sources.count - 3) more")
|
||||
.font(.caption)
|
||||
.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)
|
||||
}
|
||||
}
|
||||
@@ -1573,29 +1587,40 @@ struct GoalsSummaryCard: View {
|
||||
lineWidth: 5
|
||||
)
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(goal.name)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
HStack(spacing: 6) {
|
||||
Text(goal.name)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
if let category = goal.category {
|
||||
HStack(spacing: 3) {
|
||||
Image(systemName: category.icon)
|
||||
.font(.system(size: 9))
|
||||
Text(category.name)
|
||||
.font(.caption2.weight(.semibold))
|
||||
}
|
||||
.foregroundStyle(category.color)
|
||||
}
|
||||
}
|
||||
HStack(spacing: 4) {
|
||||
Text(currentValue.compactCurrencyString)
|
||||
.font(.caption.weight(.semibold))
|
||||
Text("of \(goal.targetDecimal.compactCurrencyString)")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
if let targetDate = goal.targetDate {
|
||||
Text("Target: \(targetDate.mediumDateString)")
|
||||
.font(.caption2.weight(.semibold))
|
||||
.foregroundColor(targetUrgency == .critical ? .negativeRed : (targetUrgency == .warning ? .appWarning : .secondary))
|
||||
.foregroundStyle(targetUrgency == .critical ? Color.negativeRed : (targetUrgency == .warning ? Color.appWarning : .secondary))
|
||||
}
|
||||
if let etaText = etaProvider(goal) {
|
||||
Text(etaText)
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
if let paceStatus {
|
||||
Text(paceStatus.statusText)
|
||||
.font(.caption2.weight(.semibold))
|
||||
.foregroundColor(paceStatus.isBehind ? .appWarning : .positiveGreen)
|
||||
.foregroundStyle(paceStatus.isBehind ? Color.appWarning : Color.positiveGreen)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
@@ -1609,14 +1634,14 @@ struct GoalsSummaryCard: View {
|
||||
} label: {
|
||||
Image(systemName: "square.and.arrow.up")
|
||||
.font(.caption)
|
||||
.foregroundColor(.appPrimary)
|
||||
.foregroundStyle(Color.appPrimary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.systemBackground))
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
|
||||
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
|
||||
}
|
||||
}
|
||||
@@ -1636,11 +1661,11 @@ struct EmptyGoalsCard: View {
|
||||
}
|
||||
Text("Add a milestone like 1M and track your progress each month.")
|
||||
.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)
|
||||
}
|
||||
}
|
||||
@@ -1685,7 +1710,7 @@ struct ContributionsVsReturnsCard: View {
|
||||
Circle().fill(Color.appSecondary).frame(width: 10, height: 10)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(String(localized: "contributions_vs_returns_invested"))
|
||||
.font(.caption).foregroundColor(.secondary)
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
Text(totalContributions.currencyString)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
}
|
||||
@@ -1695,18 +1720,18 @@ struct ContributionsVsReturnsCard: View {
|
||||
Circle().fill(isReturnPositive ? Color.positiveGreen : Color.negativeRed).frame(width: 10, height: 10)
|
||||
VStack(alignment: .trailing, spacing: 2) {
|
||||
Text(String(localized: "contributions_vs_returns_returns"))
|
||||
.font(.caption).foregroundColor(.secondary)
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
let prefix = totalReturns >= 0 ? "+" : ""
|
||||
Text("\(prefix)\(totalReturns.currencyString)")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundColor(isReturnPositive ? .positiveGreen : .negativeRed)
|
||||
.foregroundStyle(isReturnPositive ? 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,27 +13,27 @@ struct DataGapsBanner: View {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.font(.title3)
|
||||
.foregroundColor(.orange)
|
||||
.foregroundStyle(.orange)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(String(format: String(localized: "gaps_banner_title"), totalMissingMonths))
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundColor(.primary)
|
||||
.foregroundStyle(.primary)
|
||||
.multilineTextAlignment(.leading)
|
||||
Text("Fill in missing data")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(14)
|
||||
.background(Color(.secondarySystemGroupedBackground))
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius, style: .continuous)
|
||||
.stroke(Color.orange.opacity(0.35), lineWidth: 1)
|
||||
@@ -44,7 +44,7 @@ struct DataGapsBanner: View {
|
||||
Button(action: onDismiss) {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.font(.body)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(6)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
@@ -89,7 +89,7 @@ struct DataGapsSheet: View {
|
||||
.navigationTitle("Missing Data")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button("Done") { dismiss() }
|
||||
}
|
||||
}
|
||||
@@ -109,7 +109,7 @@ struct DataGapsSheet: View {
|
||||
Spacer()
|
||||
Text(String(format: String(localized: "gaps_missing_count"), gap.missingMonths))
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundColor(.orange)
|
||||
.foregroundStyle(.orange)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 3)
|
||||
.background(Color.orange.opacity(0.12))
|
||||
@@ -118,7 +118,7 @@ struct DataGapsSheet: View {
|
||||
|
||||
Text(rangeLabel(gap))
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
if let source = sourceProvider(gap.sourceId),
|
||||
let firstMissing = gap.missingMonthDates.first {
|
||||
|
||||
@@ -147,7 +147,7 @@ struct EvolutionChartCard: 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)
|
||||
.contentShape(Rectangle())
|
||||
// simultaneousGesture so the swipe is recognized even over the chart,
|
||||
@@ -206,19 +206,18 @@ struct EvolutionChartCard: View {
|
||||
.frame(width: 72, height: 72)
|
||||
Image(systemName: page.chartsType.icon)
|
||||
.font(.system(size: 28))
|
||||
.foregroundColor(.appPrimary)
|
||||
.foregroundStyle(Color.appPrimary)
|
||||
Image(systemName: "lock.circle.fill")
|
||||
.font(.system(size: 22))
|
||||
.foregroundColor(.appWarning)
|
||||
.foregroundStyle(Color.appWarning)
|
||||
.background(Circle().fill(Color(.systemBackground)))
|
||||
.offset(x: 26, y: 24)
|
||||
}
|
||||
Text(String(localized: "home_chart_locked_sub"))
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
Button {
|
||||
FirebaseService.shared.logPaywallShown(trigger: "home_charts")
|
||||
onRequestUpgrade()
|
||||
} label: {
|
||||
Label(String(localized: "chart_locked_cta"), systemImage: "lock.open.fill")
|
||||
@@ -226,7 +225,7 @@ struct EvolutionChartCard: View {
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.vertical, 10)
|
||||
.background(Color.appPrimary)
|
||||
.foregroundColor(.white)
|
||||
.foregroundStyle(.white)
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
}
|
||||
@@ -246,7 +245,7 @@ struct EvolutionChartCard: View {
|
||||
showGoalLines.toggle()
|
||||
} label: {
|
||||
Image(systemName: showGoalLines ? "target" : "slash.circle")
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.accessibilityLabel(showGoalLines ? "Hide goals" : "Show goals")
|
||||
|
||||
@@ -256,7 +255,7 @@ struct EvolutionChartCard: View {
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Text(selected.date.monthYearString)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -303,7 +302,7 @@ struct EvolutionChartCard: View {
|
||||
Text(item.category).font(.caption)
|
||||
Spacer()
|
||||
Text(String(format: "%.1f%%", pct)).font(.caption.weight(.medium))
|
||||
Text(item.value.compactCurrencyString).font(.caption).foregroundColor(.secondary)
|
||||
Text(item.value.compactCurrencyString).font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -325,7 +324,7 @@ struct EvolutionChartCard: View {
|
||||
} else {
|
||||
Text("Not enough data to display chart")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(height: 200)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
@@ -449,7 +448,7 @@ struct EvolutionChartCard: View {
|
||||
.annotation(position: .topTrailing) {
|
||||
Text(goal.name)
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,7 +126,7 @@ struct GuidedCheckInView: View {
|
||||
if let category = source.category?.name {
|
||||
Text(category)
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,10 +134,10 @@ struct GuidedCheckInView: View {
|
||||
VStack(spacing: 2) {
|
||||
Text(String(localized: "guided_previous_value"))
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(source.latestValue.currencyString)
|
||||
.font(.title3.weight(.semibold))
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,7 +150,7 @@ struct GuidedCheckInView: View {
|
||||
.padding(.vertical, 12)
|
||||
.padding(.horizontal, 24)
|
||||
.background(Color(.secondarySystemGroupedBackground))
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
|
||||
.padding(.horizontal, 32)
|
||||
|
||||
liveDelta(for: source)
|
||||
@@ -202,7 +202,7 @@ struct GuidedCheckInView: View {
|
||||
} label: {
|
||||
Text(String(localized: "guided_skip"))
|
||||
.font(.subheadline.weight(.medium))
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.accessibilityIdentifier("guided_skip_btn")
|
||||
}
|
||||
@@ -224,7 +224,7 @@ struct GuidedCheckInView: View {
|
||||
Text("\(delta.currencyString) (\(String(format: "%+.1f%%", pct)))")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
}
|
||||
.foregroundColor(positive ? .positiveGreen : .negativeRed)
|
||||
.foregroundStyle(positive ? Color.positiveGreen : Color.negativeRed)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background((positive ? Color.positiveGreen : Color.negativeRed).opacity(0.12))
|
||||
@@ -247,7 +247,7 @@ struct GuidedCheckInView: View {
|
||||
.font(.title2.weight(.bold))
|
||||
Text(String(localized: "guided_reflection_subtitle"))
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
HStack(spacing: 10) {
|
||||
@@ -272,8 +272,8 @@ struct GuidedCheckInView: View {
|
||||
? Color.appPrimary.opacity(0.15)
|
||||
: Color(.secondarySystemGroupedBackground)
|
||||
)
|
||||
.foregroundColor(mood == candidate ? .appPrimary : .primary)
|
||||
.cornerRadius(12)
|
||||
.foregroundStyle(mood == candidate ? Color.appPrimary : .primary)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.strokeBorder(mood == candidate ? Color.appPrimary : .clear, lineWidth: 1.5)
|
||||
@@ -290,7 +290,7 @@ struct GuidedCheckInView: View {
|
||||
.focused($noteFocused)
|
||||
.padding(14)
|
||||
.background(Color(.secondarySystemGroupedBackground))
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
|
||||
.padding(.horizontal, 24)
|
||||
|
||||
Spacer()
|
||||
@@ -319,7 +319,7 @@ struct GuidedCheckInView: View {
|
||||
|
||||
Image(systemName: "checkmark.seal.fill")
|
||||
.font(.system(size: 64))
|
||||
.foregroundColor(.positiveGreen)
|
||||
.foregroundStyle(Color.positiveGreen)
|
||||
.symbolEffect(.bounce, value: step)
|
||||
|
||||
Text(String(format: String(localized: "guided_done_title"), monthLabel))
|
||||
@@ -335,7 +335,7 @@ struct GuidedCheckInView: View {
|
||||
let pct = NSDecimalNumber(decimal: delta / totals.previous).doubleValue * 100
|
||||
Text("\(delta.currencyString) (\(String(format: "%+.1f%%", pct)))")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundColor(delta >= 0 ? .positiveGreen : .negativeRed)
|
||||
.foregroundStyle(delta >= 0 ? Color.positiveGreen : Color.negativeRed)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -347,7 +347,7 @@ struct GuidedCheckInView: View {
|
||||
Text(String(format: String(localized: "guided_streak"), streak))
|
||||
}
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundColor(.orange)
|
||||
.foregroundStyle(.orange)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 8)
|
||||
.background(Color.orange.opacity(0.12))
|
||||
|
||||
@@ -21,7 +21,7 @@ struct InsightsRow: View {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: insight.systemImage)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundColor(insight.accentColor)
|
||||
.foregroundStyle(insight.accentColor)
|
||||
.frame(width: 30, height: 30)
|
||||
.background(insight.accentColor.opacity(0.12))
|
||||
.clipShape(Circle())
|
||||
@@ -29,15 +29,15 @@ struct InsightsRow: View {
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(insight.title)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(insight.value)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundColor(.primary)
|
||||
.foregroundStyle(.primary)
|
||||
.lineLimit(2)
|
||||
if let detail = insight.detail {
|
||||
Text(detail)
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
@@ -56,7 +56,7 @@ struct InsightsRow: View {
|
||||
}
|
||||
.padding(14)
|
||||
.background(Color(.secondarySystemGroupedBackground))
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.id(insight.id)
|
||||
|
||||
@@ -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,18 +529,18 @@ 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
|
||||
// Month-aware: "updated" means updated FOR THE VIEWED month.
|
||||
@@ -575,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()
|
||||
@@ -583,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)
|
||||
@@ -601,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)
|
||||
}
|
||||
|
||||
@@ -614,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)
|
||||
@@ -629,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)
|
||||
|
||||
@@ -642,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)
|
||||
}
|
||||
|
||||
@@ -660,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) {
|
||||
@@ -668,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 {
|
||||
@@ -682,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)
|
||||
}
|
||||
|
||||
@@ -764,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)
|
||||
}
|
||||
|
||||
@@ -785,7 +785,7 @@ struct AchievementsView: View {
|
||||
if let subtitle {
|
||||
Text(subtitle)
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
if achievements.isEmpty {
|
||||
@@ -798,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)
|
||||
}
|
||||
|
||||
@@ -810,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) {
|
||||
@@ -818,7 +818,7 @@ struct AchievementsView: View {
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Text(status.achievement.detail)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
@@ -826,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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -865,13 +865,13 @@ private extension MonthlyCheckInView {
|
||||
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)
|
||||
@@ -880,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 {
|
||||
@@ -940,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)
|
||||
@@ -989,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)
|
||||
@@ -1030,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)
|
||||
|
||||
@@ -21,7 +21,7 @@ struct MonthlySummaryShareView: View {
|
||||
HStack {
|
||||
Text(monthLabel)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundColor(.white.opacity(0.85))
|
||||
.foregroundStyle(.white.opacity(0.85))
|
||||
.textCase(.uppercase)
|
||||
Spacer()
|
||||
if streak >= 2 {
|
||||
@@ -31,7 +31,7 @@ struct MonthlySummaryShareView: View {
|
||||
Text(String(format: String(localized: "share_summary_streak_badge"), streak))
|
||||
.font(.caption2.weight(.bold))
|
||||
}
|
||||
.foregroundColor(.white)
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 5)
|
||||
.background(Color.white.opacity(0.18))
|
||||
@@ -41,17 +41,17 @@ struct MonthlySummaryShareView: View {
|
||||
|
||||
Text("Monthly Summary")
|
||||
.font(.title.weight(.bold))
|
||||
.foregroundColor(.white)
|
||||
.foregroundStyle(.white)
|
||||
.padding(.top, 6)
|
||||
|
||||
// Hero value
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Portfolio Value")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundColor(.white.opacity(0.85))
|
||||
.foregroundStyle(.white.opacity(0.85))
|
||||
Text(totalValue)
|
||||
.font(.system(size: 40, weight: .bold, design: .rounded))
|
||||
.foregroundColor(.white)
|
||||
.foregroundStyle(.white)
|
||||
.minimumScaleFactor(0.6)
|
||||
.lineLimit(1)
|
||||
}
|
||||
@@ -66,9 +66,9 @@ struct MonthlySummaryShareView: View {
|
||||
Spacer()
|
||||
Text("since last check-in")
|
||||
.font(.caption)
|
||||
.foregroundColor(.white.opacity(0.75))
|
||||
.foregroundStyle(.white.opacity(0.75))
|
||||
}
|
||||
.foregroundColor(.white)
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 10)
|
||||
.background(Color.white.opacity(0.15))
|
||||
@@ -85,14 +85,14 @@ struct MonthlySummaryShareView: View {
|
||||
Text(mood.title)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
}
|
||||
.foregroundColor(.white)
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
if let rating {
|
||||
HStack(spacing: 2) {
|
||||
ForEach(1...5, id: \.self) { i in
|
||||
Image(systemName: i <= rating ? "star.fill" : "star")
|
||||
.font(.caption)
|
||||
.foregroundColor(.white.opacity(i <= rating ? 1 : 0.4))
|
||||
.foregroundStyle(.white.opacity(i <= rating ? 1 : 0.4))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -114,10 +114,10 @@ struct MonthlySummaryShareView: View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Tracked with")
|
||||
.font(.caption2.weight(.medium))
|
||||
.foregroundColor(.white.opacity(0.7))
|
||||
.foregroundStyle(.white.opacity(0.7))
|
||||
Text(appName)
|
||||
.font(.subheadline.weight(.bold))
|
||||
.foregroundColor(.white)
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
@@ -91,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)
|
||||
@@ -243,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()
|
||||
@@ -269,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"),
|
||||
@@ -279,7 +279,7 @@ struct QuickUpdateView: View {
|
||||
.multilineTextAlignment(.trailing)
|
||||
.frame(width: 120)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -377,6 +377,9 @@ 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() {
|
||||
|
||||
@@ -157,7 +157,7 @@ private func eyebrow(_ text: String) -> some View {
|
||||
Text(text.uppercased())
|
||||
.font(.caption2.weight(.bold))
|
||||
.tracking(2.2)
|
||||
.foregroundColor(ShareCardStyle.green)
|
||||
.foregroundStyle(ShareCardStyle.green)
|
||||
}
|
||||
|
||||
private func deltaChip(_ text: String, positive: Bool, large: Bool = false) -> some View {
|
||||
@@ -167,7 +167,7 @@ private func deltaChip(_ text: String, positive: Bool, large: Bool = false) -> s
|
||||
Text(text)
|
||||
.font((large ? Font.headline : Font.caption).weight(.bold))
|
||||
}
|
||||
.foregroundColor(positive ? ShareCardStyle.green : Color(red: 1, green: 0.42, blue: 0.42))
|
||||
.foregroundStyle(positive ? ShareCardStyle.green : Color(red: 1, green: 0.42, blue: 0.42))
|
||||
.padding(.horizontal, large ? 12 : 9)
|
||||
.padding(.vertical, large ? 6 : 4)
|
||||
.background(
|
||||
@@ -179,11 +179,11 @@ private func metricRow(_ title: String, _ value: String, theme: ShareCardTheme)
|
||||
HStack {
|
||||
Text(title)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundColor(theme.mutedInk)
|
||||
.foregroundStyle(theme.mutedInk)
|
||||
Spacer()
|
||||
Text(value)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundColor(theme.ink)
|
||||
.foregroundStyle(theme.ink)
|
||||
.multilineTextAlignment(.trailing)
|
||||
.minimumScaleFactor(0.7)
|
||||
.lineLimit(1)
|
||||
@@ -203,13 +203,13 @@ private func brandHeader(theme: ShareCardTheme, asOf: String?) -> some View {
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text("Portfolio Journal")
|
||||
.font(.system(.title2, design: .rounded).weight(.heavy))
|
||||
.foregroundColor(theme.ink)
|
||||
.foregroundStyle(theme.ink)
|
||||
.minimumScaleFactor(0.7)
|
||||
.lineLimit(1)
|
||||
if let asOf {
|
||||
Text(String(format: String(localized: "share_as_of"), asOf))
|
||||
.font(.caption2.weight(.medium))
|
||||
.foregroundColor(theme.mutedInk)
|
||||
.foregroundStyle(theme.mutedInk)
|
||||
}
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
@@ -223,11 +223,11 @@ private func downloadFooter(qrCodeImage: UIImage?, theme: ShareCardTheme) -> som
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(String(localized: "share_cta_headline"))
|
||||
.font(.subheadline.weight(.bold))
|
||||
.foregroundColor(theme.ink)
|
||||
.foregroundStyle(theme.ink)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
Text(String(localized: "share_cta_sub"))
|
||||
.font(.caption2.weight(.medium))
|
||||
.foregroundColor(theme.mutedInk)
|
||||
.foregroundStyle(theme.mutedInk)
|
||||
}
|
||||
|
||||
Spacer(minLength: 8)
|
||||
@@ -295,13 +295,13 @@ struct PortfolioValueShareCardView: View {
|
||||
eyebrow(String(localized: "share_hero_eyebrow"))
|
||||
Text(heroPercent ?? totalValue)
|
||||
.font(.system(size: storyFormat ? 52 : 44, weight: .heavy, design: .rounded))
|
||||
.foregroundColor(heroIsPercent ? (isPositive ? ShareCardStyle.green : Color(red: 1, green: 0.42, blue: 0.42)) : theme.ink)
|
||||
.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))
|
||||
.foregroundColor(theme.ink)
|
||||
.foregroundStyle(theme.ink)
|
||||
.minimumScaleFactor(0.5)
|
||||
.lineLimit(1)
|
||||
}
|
||||
@@ -363,7 +363,7 @@ struct MonthlyCheckInShareCardView: View {
|
||||
eyebrow(summary.formattedMonthYear)
|
||||
Text("Monthly Check-in")
|
||||
.font(.title.weight(.bold))
|
||||
.foregroundColor(theme.ink)
|
||||
.foregroundStyle(theme.ink)
|
||||
.padding(.top, 4)
|
||||
|
||||
VStack(spacing: 9) {
|
||||
@@ -472,8 +472,8 @@ struct PortfolioShareOptionsView: View {
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 14)
|
||||
.background(Color.appPrimary)
|
||||
.foregroundColor(.white)
|
||||
.cornerRadius(14)
|
||||
.foregroundStyle(.white)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.bottom, 8)
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
@@ -265,7 +265,7 @@ struct GoalRowView: View {
|
||||
if isAchieved {
|
||||
Text("Achieved")
|
||||
.font(.caption2.weight(.bold))
|
||||
.foregroundColor(.white)
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 3)
|
||||
.background(Color.appSuccess)
|
||||
@@ -273,27 +273,41 @@ struct GoalRowView: View {
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
.hiddenBalance()
|
||||
}
|
||||
|
||||
if let targetDate = goal.targetDate {
|
||||
Text("Target date: \(targetDate.mediumDateString)")
|
||||
.font(.caption)
|
||||
.foregroundColor(targetDateColor)
|
||||
.foregroundStyle(targetDateColor)
|
||||
}
|
||||
|
||||
if let paceStatus {
|
||||
Text(paceStatus.statusText)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundColor(
|
||||
isAchieved ? .appSuccess : (paceStatus.isBehind ? .appWarning : .positiveGreen)
|
||||
.foregroundStyle(
|
||||
isAchieved ? Color.appSuccess : (paceStatus.isBehind ? Color.appWarning : Color.positiveGreen)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -312,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)
|
||||
@@ -352,13 +366,13 @@ struct GoalRowView: View {
|
||||
case .unreachable:
|
||||
Label(String(localized: "goal_projection_add_contributions"), systemImage: "plus.circle")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
default:
|
||||
HStack(spacing: 8) {
|
||||
if let date = projection.projectedDate {
|
||||
Text(String(format: String(localized: "goal_projection_projected"), date.monthYearString))
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
onTrackChip
|
||||
Spacer(minLength: 0)
|
||||
@@ -395,7 +409,7 @@ struct GoalRowView: View {
|
||||
if projection.status != .unreachable {
|
||||
Text(chipText)
|
||||
.font(.caption2.weight(.semibold))
|
||||
.foregroundColor(chipColor)
|
||||
.foregroundStyle(chipColor)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 3)
|
||||
.background(chipColor.opacity(0.15))
|
||||
|
||||
@@ -10,64 +10,45 @@ 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: 280, ideal: 300, 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 {
|
||||
MomentumStreaksCard(compact: isPad)
|
||||
// Narrow sidebar column in regular width → compact stat tiles.
|
||||
MomentumStreaksCard(compact: horizontalSizeClass == .regular)
|
||||
}
|
||||
.listRowInsets(EdgeInsets())
|
||||
.listRowBackground(Color.clear)
|
||||
@@ -80,12 +61,12 @@ struct JournalView: View {
|
||||
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)
|
||||
@@ -93,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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -156,7 +119,7 @@ struct JournalView: View {
|
||||
|
||||
Image(systemName: "book.closed.fill")
|
||||
.font(.system(size: 50, weight: .light))
|
||||
.foregroundColor(.white)
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
|
||||
VStack(spacing: 10) {
|
||||
@@ -164,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))
|
||||
@@ -203,7 +167,7 @@ struct JournalView: View {
|
||||
.frame(width: 40, height: 40)
|
||||
Image(systemName: entry.mood?.iconName ?? "book.closed")
|
||||
.font(.system(size: 16))
|
||||
.foregroundColor(entry.mood?.tint ?? .secondary)
|
||||
.foregroundStyle(entry.mood?.tint ?? .secondary)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
@@ -220,12 +184,12 @@ struct JournalView: View {
|
||||
if let mood = entry.mood {
|
||||
Text(mood.title)
|
||||
.font(.caption.weight(.medium))
|
||||
.foregroundColor(mood.tint)
|
||||
.foregroundStyle(mood.tint)
|
||||
}
|
||||
|
||||
Text(entry.note.isEmpty ? String(localized: "journal_no_note") : entry.note)
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
}
|
||||
}
|
||||
@@ -237,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(
|
||||
@@ -251,7 +215,7 @@ struct JournalView: View {
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 6)
|
||||
.background(Color.gray.opacity(0.15))
|
||||
.foregroundColor(.primary)
|
||||
.foregroundStyle(.primary)
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
|
||||
@@ -269,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)
|
||||
}
|
||||
|
||||
@@ -280,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)
|
||||
}
|
||||
|
||||
@@ -339,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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -88,20 +88,19 @@ 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 {
|
||||
FirebaseService.shared.logOnboardingSkipped(atStep: currentPage)
|
||||
completeOnboarding()
|
||||
} label: {
|
||||
Text("Skip")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
} else {
|
||||
Button {
|
||||
@@ -109,11 +108,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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -124,9 +123,6 @@ struct OnboardingView: View {
|
||||
.onAppear {
|
||||
selectedCurrency = AppSettings.getOrCreate(in: CoreDataStack.shared.viewContext).currency
|
||||
}
|
||||
.onChange(of: currentPage) { _, newPage in
|
||||
FirebaseService.shared.logOnboardingStep(step: newPage)
|
||||
}
|
||||
.sheet(isPresented: $showingImportSheet) {
|
||||
ImportDataView(importContext: .onboarding)
|
||||
.environmentObject(iapService)
|
||||
@@ -144,7 +140,7 @@ struct OnboardingView: View {
|
||||
categoryRepository.createDefaultCategoriesIfNeeded()
|
||||
|
||||
// Create default account if needed
|
||||
AccountRepository().createDefaultAccountIfNeeded()
|
||||
_ = AccountRepository().createDefaultAccountIfNeeded()
|
||||
|
||||
// Mark onboarding as complete
|
||||
let context = CoreDataStack.shared.viewContext
|
||||
@@ -157,9 +153,6 @@ struct OnboardingView: View {
|
||||
SampleDataService.shared.seedSampleData()
|
||||
}
|
||||
|
||||
// Log analytics
|
||||
FirebaseService.shared.logOnboardingCompleted(stepCount: pages.count + 1)
|
||||
|
||||
// Request notification permission
|
||||
Task {
|
||||
await NotificationService.shared.requestAuthorization()
|
||||
@@ -201,7 +194,7 @@ struct OnboardingPageView: View {
|
||||
|
||||
Image(systemName: page.icon)
|
||||
.font(.system(size: 50))
|
||||
.foregroundColor(page.color)
|
||||
.foregroundStyle(page.color)
|
||||
}
|
||||
|
||||
// Content
|
||||
@@ -212,7 +205,7 @@ struct OnboardingPageView: View {
|
||||
|
||||
Text(page.description)
|
||||
.font(.body)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, 40)
|
||||
}
|
||||
@@ -255,7 +248,7 @@ struct OnboardingQuickStartView: View {
|
||||
|
||||
Text(String(localized: "onboarding_quickstart_subtitle"))
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, 30)
|
||||
}
|
||||
@@ -268,8 +261,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) {
|
||||
@@ -278,8 +271,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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -297,13 +290,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)
|
||||
@@ -326,7 +319,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 {
|
||||
@@ -341,7 +334,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)
|
||||
}
|
||||
|
||||
@@ -354,11 +347,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)
|
||||
|
||||
@@ -22,6 +22,29 @@ struct WhatsNewRelease: Identifiable {
|
||||
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(
|
||||
// The feature is "we use whatever biometry you have", so the
|
||||
// icon comes from the hardware too — touchid on an iPhone Duo,
|
||||
// faceid on a Face ID phone — instead of being hardcoded.
|
||||
icon: AppLockService.biometryKind.systemImage,
|
||||
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",
|
||||
@@ -149,7 +172,7 @@ struct WhatsNewView: View {
|
||||
.font(.title.weight(.bold))
|
||||
Text(String(localized: "whats_new_subtitle"))
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
.padding(.top, 32)
|
||||
@@ -162,7 +185,7 @@ struct WhatsNewView: View {
|
||||
if showsVersionHeaders {
|
||||
Text(String(format: String(localized: "whats_new_version_header"), release.version))
|
||||
.font(.caption.weight(.bold))
|
||||
.foregroundColor(.appPrimary)
|
||||
.foregroundStyle(Color.appPrimary)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 4)
|
||||
.background(Color.appPrimary.opacity(0.12))
|
||||
@@ -172,14 +195,14 @@ struct WhatsNewView: View {
|
||||
HStack(spacing: 16) {
|
||||
Image(systemName: feature.icon)
|
||||
.font(.title2)
|
||||
.foregroundColor(feature.color)
|
||||
.foregroundStyle(feature.color)
|
||||
.frame(width: 40)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(feature.title)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Text(feature.description)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
@@ -188,7 +211,7 @@ struct WhatsNewView: View {
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(Color(.secondarySystemGroupedBackground))
|
||||
.cornerRadius(16)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
@@ -199,11 +222,11 @@ struct WhatsNewView: View {
|
||||
} 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -336,9 +336,6 @@ struct PaywallView: View {
|
||||
private func purchase() {
|
||||
isPurchasing = true
|
||||
let product = (selectedPlan == .annual ? iapService.annualProduct : iapService.premiumProduct)
|
||||
FirebaseService.shared.logPurchaseAttempt(
|
||||
productId: product?.id ?? IAPService.premiumProductID
|
||||
)
|
||||
|
||||
Task {
|
||||
do {
|
||||
@@ -381,7 +378,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 +387,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 +436,7 @@ struct CompactPaywallBanner: View {
|
||||
|
||||
Text("Unlimited sources, advanced charts & more")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
@@ -452,13 +449,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 +490,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ struct CategoriesView: View {
|
||||
List {
|
||||
if categoryRepository.categories.isEmpty {
|
||||
Text(String(localized: "categories_empty"))
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
.padding(.vertical, 32)
|
||||
} else {
|
||||
@@ -54,14 +54,14 @@ struct CategoriesView: View {
|
||||
.navigationTitle("Categories")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button {
|
||||
showingAddCategory = true
|
||||
} label: {
|
||||
Image(systemName: "plus")
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
EditButton()
|
||||
}
|
||||
}
|
||||
@@ -99,7 +99,7 @@ private struct CategoryRow: View {
|
||||
.frame(width: 40, height: 40)
|
||||
Image(systemName: category.icon)
|
||||
.font(.system(size: 16))
|
||||
.foregroundColor(category.color)
|
||||
.foregroundStyle(category.color)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
@@ -107,7 +107,7 @@ private struct CategoryRow: View {
|
||||
.font(.body)
|
||||
Text(category.sourceCount == 1 ? "1 source" : "\(category.sourceCount) sources")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
@@ -186,20 +186,20 @@ struct CategoryEditorView: View {
|
||||
.frame(width: 44, height: 44)
|
||||
Image(systemName: selectedIcon)
|
||||
.font(.system(size: 18))
|
||||
.foregroundColor(Color(hex: selectedColor) ?? .blue)
|
||||
.foregroundStyle(Color(hex: selectedColor) ?? .blue)
|
||||
}
|
||||
Text(name.isEmpty ? String(localized: "category_name_placeholder") : name)
|
||||
.foregroundColor(name.isEmpty ? .secondary : .primary)
|
||||
.foregroundStyle(name.isEmpty ? .secondary : .primary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle(category == nil ? "Add Category" : "Edit Category")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
Button("Cancel") { dismiss() }
|
||||
}
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button("Save") { save() }
|
||||
.fontWeight(.semibold)
|
||||
.disabled(name.trimmingCharacters(in: .whitespaces).isEmpty)
|
||||
@@ -259,7 +259,7 @@ private struct IconSwatch: View {
|
||||
.frame(width: 44, height: 44)
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 18))
|
||||
.foregroundColor(isSelected ? color : .secondary)
|
||||
.foregroundStyle(isSelected ? color : .secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import SwiftUI
|
||||
import CoreData
|
||||
import UniformTypeIdentifiers
|
||||
import UIKit
|
||||
|
||||
@@ -88,7 +89,7 @@ struct ImportDataView: View {
|
||||
ProgressView()
|
||||
Text("Reviewing import…")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.top, 8)
|
||||
}
|
||||
@@ -98,7 +99,7 @@ struct ImportDataView: View {
|
||||
ProgressView(value: importProgress)
|
||||
Text(importStatus)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.top, 8)
|
||||
}
|
||||
@@ -133,7 +134,7 @@ struct ImportDataView: View {
|
||||
.navigationTitle("Import Data")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button("Done") { dismiss() }
|
||||
.disabled(isImporting)
|
||||
}
|
||||
@@ -244,7 +245,7 @@ Personal,Stocks,Index Fund,2024-01-01,15000,12000,Long-term
|
||||
.font(.caption.monospaced())
|
||||
Text("Account, Contribution, and Notes are optional. Dates accept / or - and 24h/12h time.")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -325,7 +326,7 @@ Personal,Stocks,Index Fund,2024-01-01,15000,12000,Long-term
|
||||
Text("Account")
|
||||
Spacer()
|
||||
Text(selectedAccountName ?? "Personal")
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
@@ -333,7 +334,7 @@ Personal,Stocks,Index Fund,2024-01-01,15000,12000,Long-term
|
||||
} footer: {
|
||||
if let accountErrorMessage {
|
||||
Text(accountErrorMessage)
|
||||
.foregroundColor(.negativeRed)
|
||||
.foregroundStyle(Color.negativeRed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import UIKit
|
||||
struct SettingsView: View {
|
||||
private let iapService: IAPService
|
||||
@StateObject private var viewModel: SettingsViewModel
|
||||
@EnvironmentObject private var adMobService: AdMobService
|
||||
@EnvironmentObject private var accountStore: AccountStore
|
||||
@EnvironmentObject private var tabSelection: TabSelectionStore
|
||||
@AppStorage("showForecast") private var showForecast = true
|
||||
@@ -149,20 +148,20 @@ struct SettingsView: View {
|
||||
} message: {
|
||||
Text("Restart the app to apply iCloud sync changes.")
|
||||
}
|
||||
.alert("Face ID Unavailable", isPresented: $showingBiometricAlert) {
|
||||
.alert(Text(String(format: String(localized: "biometric_unavailable_title"), biometryName)), isPresented: $showingBiometricAlert) {
|
||||
Button("OK") {}
|
||||
} message: {
|
||||
Text("Face ID isn't available on this device.")
|
||||
Text(String(format: String(localized: "biometric_unavailable_body"), biometryName))
|
||||
}
|
||||
.alert("PIN Required", isPresented: $showingPinRequiredAlert) {
|
||||
Button("OK") {}
|
||||
} message: {
|
||||
Text("Enable a PIN before turning on Face ID.")
|
||||
Text(String(format: String(localized: "biometric_pin_required"), biometryName))
|
||||
}
|
||||
.alert("Disable Face ID First", isPresented: $showingPinDisableAlert) {
|
||||
.alert(Text(String(format: String(localized: "biometric_disable_first_title"), biometryName)), isPresented: $showingPinDisableAlert) {
|
||||
Button("OK") {}
|
||||
} message: {
|
||||
Text("Turn off Face ID before disabling your PIN.")
|
||||
Text(String(format: String(localized: "biometric_disable_first_body"), biometryName))
|
||||
}
|
||||
.sheet(isPresented: $showingPinSetup) {
|
||||
PinSetupView(title: "Set PIN") { pin in
|
||||
@@ -184,7 +183,7 @@ struct SettingsView: View {
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showingPinVerifyForFaceId) {
|
||||
PinVerifyView(title: "Enter PIN to Disable Face ID") { success in
|
||||
PinVerifyView(title: String(format: String(localized: "biometric_pin_to_disable"), biometryName)) { success in
|
||||
if success {
|
||||
faceIdEnabled = false
|
||||
}
|
||||
@@ -207,19 +206,19 @@ struct SettingsView: View {
|
||||
Link(destination: GoalShareService.appStoreURL) {
|
||||
HStack {
|
||||
Image(systemName: "arrow.down.circle.fill")
|
||||
.foregroundColor(.appPrimary)
|
||||
.foregroundStyle(Color.appPrimary)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(String(localized: "update_available_title"))
|
||||
.font(.subheadline.weight(.semibold))
|
||||
if let v = updateService.latestVersion {
|
||||
Text(String(format: String(localized: "update_available_body"), v))
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
Image(systemName: "chevron.right")
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
@@ -237,14 +236,14 @@ struct SettingsView: View {
|
||||
.frame(width: 36, height: 36)
|
||||
.padding(6)
|
||||
.background(Color.appPrimary.opacity(0.08))
|
||||
.cornerRadius(10)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(appDisplayName)
|
||||
.font(.headline)
|
||||
Text("Long-term portfolio tracker")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
@@ -280,14 +279,14 @@ struct SettingsView: View {
|
||||
if viewModel.isFamilyShared {
|
||||
Text("Family Sharing")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Image(systemName: "checkmark.seal.fill")
|
||||
.foregroundColor(.positiveGreen)
|
||||
.foregroundStyle(Color.positiveGreen)
|
||||
}
|
||||
} else {
|
||||
Button {
|
||||
@@ -300,23 +299,23 @@ struct SettingsView: View {
|
||||
.frame(width: 44, height: 44)
|
||||
|
||||
Image(systemName: "crown.fill")
|
||||
.foregroundColor(.appPrimary)
|
||||
.foregroundStyle(Color.appPrimary)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Upgrade to Premium")
|
||||
.font(.headline)
|
||||
.foregroundColor(.primary)
|
||||
.foregroundStyle(.primary)
|
||||
|
||||
Text("Unlock all features for \(iapService.formattedPrice)")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -345,7 +344,7 @@ struct SettingsView: View {
|
||||
Text("Notifications")
|
||||
Spacer()
|
||||
Text(viewModel.notificationsEnabled ? "Enabled" : "Disabled")
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
@@ -396,7 +395,7 @@ struct SettingsView: View {
|
||||
systemImage: "internaldrive"
|
||||
)
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.primary)
|
||||
.foregroundStyle(.primary)
|
||||
|
||||
Spacer()
|
||||
|
||||
@@ -411,27 +410,27 @@ struct SettingsView: View {
|
||||
if cloudStack.isSyncing {
|
||||
Label("Syncing with iCloud...", systemImage: "arrow.triangle.2.circlepath.icloud")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
HStack(spacing: 12) {
|
||||
if let date = cloudStack.lastImportDate {
|
||||
Label("↓ \(date.formatted(.relative(presentation: .named, unitsStyle: .abbreviated)))", systemImage: "icloud.and.arrow.down")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
Label("No import yet", systemImage: "icloud.and.arrow.down")
|
||||
.font(.caption)
|
||||
.foregroundColor(.orange)
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
|
||||
if let date = cloudStack.lastExportDate {
|
||||
Label("↑ \(date.formatted(.relative(presentation: .named, unitsStyle: .abbreviated)))", systemImage: "icloud.and.arrow.up")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
Label("No export yet", systemImage: "icloud.and.arrow.up")
|
||||
.font(.caption)
|
||||
.foregroundColor(.orange)
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -449,12 +448,12 @@ struct SettingsView: View {
|
||||
systemImage: critical ? "exclamationmark.icloud" : "arrow.triangle.2.circlepath.icloud"
|
||||
)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundColor(critical ? .red : .secondary)
|
||||
.foregroundStyle(critical ? .red : .secondary)
|
||||
|
||||
if critical, let hint = cloudStack.lastSyncErrorHint {
|
||||
Text(hint)
|
||||
.font(.caption)
|
||||
.foregroundColor(.primary)
|
||||
.foregroundStyle(.primary)
|
||||
}
|
||||
|
||||
// Technical detail is opt-in behind a disclosure, so it
|
||||
@@ -463,11 +462,11 @@ struct SettingsView: View {
|
||||
if let hint = cloudStack.lastSyncErrorHint, !critical {
|
||||
Text(hint)
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Text(error)
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
.font(.caption2)
|
||||
@@ -484,7 +483,7 @@ struct SettingsView: View {
|
||||
} label: {
|
||||
Label(String(localized: "icloud_copy_diagnostics"), systemImage: "doc.on.doc")
|
||||
.font(.caption)
|
||||
.foregroundColor(.appPrimary)
|
||||
.foregroundStyle(Color.appPrimary)
|
||||
}
|
||||
|
||||
// Force upload — use when data exists locally but hasn't reached iCloud
|
||||
@@ -492,7 +491,7 @@ struct SettingsView: View {
|
||||
if let result = forceUploadResult {
|
||||
Label(result, systemImage: "checkmark.icloud")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
Button {
|
||||
isForceUploading = true
|
||||
@@ -514,7 +513,7 @@ struct SettingsView: View {
|
||||
}
|
||||
}
|
||||
.font(.caption)
|
||||
.foregroundColor(.appPrimary)
|
||||
.foregroundStyle(Color.appPrimary)
|
||||
.disabled(isForceUploading)
|
||||
}
|
||||
}
|
||||
@@ -533,7 +532,7 @@ struct SettingsView: View {
|
||||
if !viewModel.isPremium {
|
||||
Text("Premium")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundColor(.appWarning)
|
||||
.foregroundStyle(Color.appWarning)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -553,7 +552,7 @@ struct SettingsView: View {
|
||||
Text("Last auto backup")
|
||||
Spacer()
|
||||
Text(last, style: .date)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.font(.caption)
|
||||
}
|
||||
@@ -580,7 +579,7 @@ struct SettingsView: View {
|
||||
if !viewModel.canExport {
|
||||
Image(systemName: "lock.fill")
|
||||
.font(.caption)
|
||||
.foregroundColor(.appWarning)
|
||||
.foregroundStyle(Color.appWarning)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -598,21 +597,21 @@ struct SettingsView: View {
|
||||
Text("Total Sources")
|
||||
Spacer()
|
||||
Text("\(viewModel.totalSources)")
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
HStack {
|
||||
Text("Total Snapshots")
|
||||
Spacer()
|
||||
Text("\(viewModel.totalSnapshots)")
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
HStack {
|
||||
Text("Storage Used")
|
||||
Spacer()
|
||||
Text(viewModel.storageUsedText)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
} header: {
|
||||
Text("Data")
|
||||
@@ -648,7 +647,7 @@ struct SettingsView: View {
|
||||
if viewModel.backups.isEmpty {
|
||||
Text("No backups yet.")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
ForEach(viewModel.backups) { backup in
|
||||
HStack {
|
||||
@@ -657,7 +656,7 @@ struct SettingsView: View {
|
||||
.font(.subheadline)
|
||||
Text("\(backup.location.rawValue) · \(formatBytes(backup.size))")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
Button("Restore") {
|
||||
@@ -675,7 +674,7 @@ struct SettingsView: View {
|
||||
} header: {
|
||||
Text("Backups")
|
||||
} footer: {
|
||||
Text("Backups are stored locally and in iCloud (when enabled).")
|
||||
Text("backups_footer")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -685,7 +684,7 @@ struct SettingsView: View {
|
||||
Section {
|
||||
Toggle("Hide Balances", isOn: $balancePrivacy.balancesHidden)
|
||||
|
||||
Toggle("Require Face ID to Reveal", isOn: $balancePrivacy.requireBiometricToReveal)
|
||||
Toggle(String(format: String(localized: "biometric_require_to_reveal"), biometryName), isOn: $balancePrivacy.requireBiometricToReveal)
|
||||
.onChange(of: balancePrivacy.requireBiometricToReveal) { _, enabled in
|
||||
if enabled && !AppLockService.canUseBiometrics() {
|
||||
balancePrivacy.requireBiometricToReveal = false
|
||||
@@ -695,10 +694,13 @@ struct SettingsView: View {
|
||||
} header: {
|
||||
Text("Privacy")
|
||||
} footer: {
|
||||
Text("Hide all monetary amounts behind a blur. Great for using the app in public or taking screenshots. Optionally require Face ID to reveal them.")
|
||||
Text(String(format: String(localized: "privacy_footer_biometric"), biometryName))
|
||||
}
|
||||
}
|
||||
|
||||
/// Face ID / Touch ID / Optic ID — resolved from the hardware, never assumed.
|
||||
private var biometryName: String { AppLockService.biometryName }
|
||||
|
||||
private var securitySection: some View {
|
||||
Section {
|
||||
Toggle("Require PIN", isOn: $pinEnabled)
|
||||
@@ -717,7 +719,7 @@ struct SettingsView: View {
|
||||
}
|
||||
}
|
||||
|
||||
Toggle("Enable Face ID", isOn: $faceIdEnabled)
|
||||
Toggle(String(format: String(localized: "biometric_enable"), biometryName), isOn: $faceIdEnabled)
|
||||
.onChange(of: faceIdEnabled) { _, enabled in
|
||||
if enabled {
|
||||
guard AppLockService.canUseBiometrics() else {
|
||||
@@ -748,7 +750,7 @@ struct SettingsView: View {
|
||||
} header: {
|
||||
Text("App Lock")
|
||||
} footer: {
|
||||
Text("Use Face ID or a 4-digit PIN to protect your data.")
|
||||
Text(String(format: String(localized: "biometric_lock_footer"), biometryName))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -773,14 +775,6 @@ struct SettingsView: View {
|
||||
.onChange(of: viewModel.inputMode) { _, newValue in
|
||||
viewModel.updateInputMode(newValue)
|
||||
}
|
||||
|
||||
if !viewModel.isPremium {
|
||||
Button("Manage Ad Consent") {
|
||||
Task {
|
||||
await adMobService.presentPrivacyOptions()
|
||||
}
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text("Preferences")
|
||||
} footer: {
|
||||
@@ -802,7 +796,7 @@ struct SettingsView: View {
|
||||
Label("Allocation Targets", systemImage: "target")
|
||||
Spacer()
|
||||
Image(systemName: "chevron.right")
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
@@ -825,7 +819,7 @@ struct SettingsView: View {
|
||||
if !viewModel.isPremium {
|
||||
Text("Premium")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -844,7 +838,7 @@ struct SettingsView: View {
|
||||
Text("Version")
|
||||
Spacer()
|
||||
Text(viewModel.appVersion)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Link(destination: URL(string: AppConstants.URLs.privacyPolicy)!) {
|
||||
@@ -853,7 +847,7 @@ struct SettingsView: View {
|
||||
Spacer()
|
||||
Image(systemName: "arrow.up.right")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -863,7 +857,7 @@ struct SettingsView: View {
|
||||
Spacer()
|
||||
Image(systemName: "arrow.up.right")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -873,7 +867,7 @@ struct SettingsView: View {
|
||||
Spacer()
|
||||
Image(systemName: "arrow.up.right")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -884,14 +878,38 @@ struct SettingsView: View {
|
||||
Text("Rate App")
|
||||
Spacer()
|
||||
Image(systemName: "star.fill")
|
||||
.foregroundColor(.yellow)
|
||||
.foregroundStyle(.yellow)
|
||||
}
|
||||
}
|
||||
|
||||
// Only shown when MetricKit has actually left something behind.
|
||||
// Sharing is the only way a report leaves the device.
|
||||
if let latestReport = diagnosticReports.first {
|
||||
Button {
|
||||
viewModel.shareItem = SettingsViewModel.ShareItem(url: latestReport)
|
||||
} label: {
|
||||
HStack {
|
||||
Text("Share Diagnostics")
|
||||
Spacer()
|
||||
Image(systemName: "square.and.arrow.up")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text("About")
|
||||
} footer: {
|
||||
if !diagnosticReports.isEmpty {
|
||||
Text("diagnostics_footer")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Crash/hang reports MetricKit left in the app container. They never leave
|
||||
/// the device unless the user shares one from here.
|
||||
private var diagnosticReports: [URL] { DiagnosticsCollector.shared.storedReports() }
|
||||
|
||||
// MARK: - Danger Zone Section
|
||||
|
||||
private var dangerZoneSection: some View {
|
||||
@@ -992,7 +1010,7 @@ struct ExportOptionsSheet: View {
|
||||
|
||||
Text(viewModel.exportStatus)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
} header: {
|
||||
@@ -1005,7 +1023,7 @@ struct ExportOptionsSheet: View {
|
||||
} label: {
|
||||
HStack {
|
||||
Image(systemName: "tablecells")
|
||||
.foregroundColor(.positiveGreen)
|
||||
.foregroundStyle(Color.positiveGreen)
|
||||
.frame(width: 30)
|
||||
|
||||
VStack(alignment: .leading) {
|
||||
@@ -1013,7 +1031,7 @@ struct ExportOptionsSheet: View {
|
||||
.font(.headline)
|
||||
Text("Compatible with Excel, Google Sheets")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1023,7 +1041,7 @@ struct ExportOptionsSheet: View {
|
||||
} label: {
|
||||
HStack {
|
||||
Image(systemName: "doc.text")
|
||||
.foregroundColor(.appPrimary)
|
||||
.foregroundStyle(Color.appPrimary)
|
||||
.frame(width: 30)
|
||||
|
||||
VStack(alignment: .leading) {
|
||||
@@ -1031,7 +1049,7 @@ struct ExportOptionsSheet: View {
|
||||
.font(.headline)
|
||||
Text("Full data structure for backup")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1043,7 +1061,7 @@ struct ExportOptionsSheet: View {
|
||||
.navigationTitle(viewModel.isExporting ? "Exporting" : "Export Data")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button("Cancel") {
|
||||
dismiss()
|
||||
}
|
||||
@@ -1089,16 +1107,16 @@ struct PinSetupView: View {
|
||||
if let errorMessage {
|
||||
Text(errorMessage)
|
||||
.font(.caption)
|
||||
.foregroundColor(.negativeRed)
|
||||
.foregroundStyle(Color.negativeRed)
|
||||
}
|
||||
}
|
||||
.navigationTitle(title)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
Button("Cancel") { dismiss() }
|
||||
}
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button("Save") { savePin() }
|
||||
.disabled(pin.count < 4 || confirmPin.count < 4)
|
||||
}
|
||||
@@ -1142,19 +1160,19 @@ struct PinVerifyView: View {
|
||||
if let errorMessage {
|
||||
Text(errorMessage)
|
||||
.font(.caption)
|
||||
.foregroundColor(.negativeRed)
|
||||
.foregroundStyle(Color.negativeRed)
|
||||
}
|
||||
}
|
||||
.navigationTitle(title)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
Button("Cancel") {
|
||||
onResult(false)
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button("Verify") { verifyPin() }
|
||||
.disabled(pin.count < 4)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import SwiftUI
|
||||
import CoreData
|
||||
import StoreKit
|
||||
|
||||
struct AddSourceView: View {
|
||||
@@ -54,25 +55,25 @@ struct AddSourceView: View {
|
||||
} label: {
|
||||
HStack {
|
||||
Text("Category")
|
||||
.foregroundColor(.primary)
|
||||
.foregroundStyle(.primary)
|
||||
|
||||
Spacer()
|
||||
|
||||
if let category = selectedCategory {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: category.icon)
|
||||
.foregroundColor(category.color)
|
||||
.foregroundStyle(category.color)
|
||||
Text(category.name)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
} else {
|
||||
Text("Select")
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
@@ -80,10 +81,10 @@ struct AddSourceView: View {
|
||||
} footer: {
|
||||
if let error = duplicateError {
|
||||
Text(error)
|
||||
.foregroundColor(.negativeRed)
|
||||
.foregroundStyle(Color.negativeRed)
|
||||
} else {
|
||||
Text(String(localized: "add_source_name_footer"))
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,7 +93,7 @@ struct AddSourceView: View {
|
||||
Section {
|
||||
HStack {
|
||||
Text(currencySymbol)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
TextField("0.00", text: $initialValue)
|
||||
.keyboardType(.decimalPad)
|
||||
@@ -106,13 +107,13 @@ struct AddSourceView: View {
|
||||
.navigationTitle("Add Source")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
Button("Cancel") {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button("Add") {
|
||||
saveSource()
|
||||
}
|
||||
@@ -182,12 +183,6 @@ struct AddSourceView: View {
|
||||
date: Date(),
|
||||
value: value
|
||||
)
|
||||
// Instrument like AddSnapshotView does — without this, snapshots
|
||||
// created via the initial value never count in the activation funnel.
|
||||
FirebaseService.shared.logSnapshotAdded(
|
||||
sourceName: name.trimmingCharacters(in: .whitespaces),
|
||||
value: value
|
||||
)
|
||||
hasInitialSnapshot = true
|
||||
} else {
|
||||
hasInitialSnapshot = false
|
||||
@@ -202,12 +197,6 @@ struct AddSourceView: View {
|
||||
let total = allSources.reduce(Decimal.zero) { $0 + $1.latestValue }
|
||||
NotificationService.shared.checkAndScheduleMilestoneNotification(portfolioValue: total)
|
||||
|
||||
// Log analytics
|
||||
FirebaseService.shared.logSourceAdded(
|
||||
categoryName: category.name,
|
||||
sourceCount: repository.sourceCount
|
||||
)
|
||||
|
||||
if hasInitialSnapshot {
|
||||
dismiss()
|
||||
} else {
|
||||
@@ -262,17 +251,17 @@ struct CategoryPickerView: View {
|
||||
.frame(width: 40, height: 40)
|
||||
|
||||
Image(systemName: category.icon)
|
||||
.foregroundColor(category.color)
|
||||
.foregroundStyle(category.color)
|
||||
}
|
||||
|
||||
Text(category.name)
|
||||
.foregroundColor(.primary)
|
||||
.foregroundStyle(.primary)
|
||||
|
||||
Spacer()
|
||||
|
||||
if selectedCategory?.id == category.id {
|
||||
Image(systemName: "checkmark")
|
||||
.foregroundColor(.appPrimary)
|
||||
.foregroundStyle(Color.appPrimary)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -280,7 +269,7 @@ struct CategoryPickerView: View {
|
||||
.navigationTitle("Select Category")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button("Done") {
|
||||
dismiss()
|
||||
}
|
||||
@@ -334,22 +323,22 @@ struct EditSourceView: View {
|
||||
} label: {
|
||||
HStack {
|
||||
Text("Category")
|
||||
.foregroundColor(.primary)
|
||||
.foregroundStyle(.primary)
|
||||
|
||||
Spacer()
|
||||
|
||||
if let category = selectedCategory {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: category.icon)
|
||||
.foregroundColor(category.color)
|
||||
.foregroundStyle(category.color)
|
||||
Text(category.name)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -357,13 +346,13 @@ struct EditSourceView: View {
|
||||
.navigationTitle("Edit Source")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
Button("Cancel") {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button("Save") {
|
||||
saveChanges()
|
||||
}
|
||||
@@ -456,7 +445,7 @@ struct AddSnapshotView: View {
|
||||
|
||||
HStack {
|
||||
Text(viewModel.currencySymbol)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
TextField("Value", text: $viewModel.valueString)
|
||||
.keyboardType(.decimalPad)
|
||||
@@ -474,7 +463,7 @@ struct AddSnapshotView: View {
|
||||
if viewModel.previousValue != nil {
|
||||
Text(viewModel.previousValueString)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
} header: {
|
||||
Text("Snapshot Details")
|
||||
@@ -488,10 +477,10 @@ struct AddSnapshotView: View {
|
||||
Text("Change from previous")
|
||||
Spacer()
|
||||
Text("\(change) (\(percentage))")
|
||||
.foregroundColor(
|
||||
.foregroundStyle(
|
||||
(viewModel.changeFromPrevious ?? 0) >= 0
|
||||
? .positiveGreen
|
||||
: .negativeRed
|
||||
? Color.positiveGreen
|
||||
: Color.negativeRed
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -503,7 +492,7 @@ struct AddSnapshotView: View {
|
||||
if viewModel.includeContribution {
|
||||
HStack {
|
||||
Text(viewModel.currencySymbol)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
TextField("New capital added", text: $viewModel.contributionString)
|
||||
.keyboardType(.decimalPad)
|
||||
@@ -525,13 +514,13 @@ struct AddSnapshotView: View {
|
||||
.navigationTitle(viewModel.title)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
Button("Cancel") {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button(viewModel.buttonTitle) {
|
||||
saveSnapshot()
|
||||
}
|
||||
@@ -640,9 +629,6 @@ struct AddSnapshotView: View {
|
||||
// Reschedule notification
|
||||
NotificationService.shared.scheduleReminder(for: source)
|
||||
|
||||
// Log analytics
|
||||
FirebaseService.shared.logSnapshotAdded(sourceName: source.name, value: value)
|
||||
|
||||
// When editing, offer to propagate a changed contribution to other snapshots.
|
||||
if isEdit, let contribution = viewModel.contribution, viewModel.contributionChanged {
|
||||
pendingContribution = contribution
|
||||
@@ -670,14 +656,8 @@ struct AddSnapshotView: View {
|
||||
let goals = goalRepo.goals.filter { $0.isActive }
|
||||
var achieved = Set<UUID>()
|
||||
for goal in goals {
|
||||
let currentValue: Decimal
|
||||
if let accountId = goal.account?.safeId {
|
||||
currentValue = sourceRepo.sources
|
||||
.filter { $0.account?.id == accountId }
|
||||
.reduce(Decimal.zero) { $0 + $1.latestValue }
|
||||
} else {
|
||||
currentValue = sourceRepo.sources.reduce(Decimal.zero) { $0 + $1.latestValue }
|
||||
}
|
||||
let currentValue = goal.relevantSources(from: sourceRepo.sources)
|
||||
.reduce(Decimal.zero) { $0 + $1.latestValue }
|
||||
let target = goal.targetDecimal
|
||||
if target > 0 && currentValue / target >= 1 {
|
||||
achieved.insert(goal.id)
|
||||
@@ -691,14 +671,8 @@ struct AddSnapshotView: View {
|
||||
let goals = goalRepo.goals.filter { $0.isActive }
|
||||
for goal in goals {
|
||||
guard !prevAchievedIds.contains(goal.id) else { continue }
|
||||
let currentValue: Decimal
|
||||
if let accountId = goal.account?.safeId {
|
||||
currentValue = sourceRepo.sources
|
||||
.filter { $0.account?.id == accountId }
|
||||
.reduce(Decimal.zero) { $0 + $1.latestValue }
|
||||
} else {
|
||||
currentValue = sourceRepo.sources.reduce(Decimal.zero) { $0 + $1.latestValue }
|
||||
}
|
||||
let currentValue = goal.relevantSources(from: sourceRepo.sources)
|
||||
.reduce(Decimal.zero) { $0 + $1.latestValue }
|
||||
let target = goal.targetDecimal
|
||||
if target > 0 && currentValue / target >= 1 {
|
||||
NotificationService.shared.scheduleGoalAchievedNotification(goalName: goal.name)
|
||||
|
||||
@@ -66,7 +66,7 @@ struct SourceDetailView: View {
|
||||
.navigationTitle(viewModel.safeSourceName)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Menu {
|
||||
Button {
|
||||
viewModel.showingEditSource = true
|
||||
@@ -129,11 +129,11 @@ struct SourceDetailView: View {
|
||||
Text(viewModel.categoryName)
|
||||
}
|
||||
.font(.caption)
|
||||
.foregroundColor(Color(hex: viewModel.categoryColor) ?? .gray)
|
||||
.foregroundStyle(Color(hex: viewModel.categoryColor) ?? .gray)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background((Color(hex: viewModel.categoryColor) ?? .gray).opacity(0.1))
|
||||
.cornerRadius(20)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 20))
|
||||
|
||||
// Current value
|
||||
Text(viewModel.formattedCurrentValue)
|
||||
@@ -147,17 +147,17 @@ struct SourceDetailView: View {
|
||||
Text("(\(viewModel.formattedPercentageReturn))")
|
||||
}
|
||||
.font(.subheadline.weight(.medium))
|
||||
.foregroundColor(viewModel.isPositiveReturn ? .positiveGreen : .negativeRed)
|
||||
.foregroundStyle(viewModel.isPositiveReturn ? Color.positiveGreen : Color.negativeRed)
|
||||
|
||||
// Last updated
|
||||
Text("Last updated: \(viewModel.lastUpdated)")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.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)
|
||||
}
|
||||
|
||||
@@ -172,8 +172,8 @@ struct SourceDetailView: View {
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding()
|
||||
.background(Color.appPrimary)
|
||||
.foregroundColor(.white)
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
.foregroundStyle(.white)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -186,7 +186,7 @@ struct SourceDetailView: View {
|
||||
HStack {
|
||||
Label(String(localized: "source_monthly_contribution_title"), systemImage: "arrow.down.circle.fill")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundColor(.primary)
|
||||
.foregroundStyle(.primary)
|
||||
Spacer()
|
||||
Button {
|
||||
if editingContribution {
|
||||
@@ -204,7 +204,7 @@ struct SourceDetailView: View {
|
||||
} label: {
|
||||
Text(editingContribution ? String(localized: "done") : String(localized: "edit"))
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundColor(.appPrimary)
|
||||
.foregroundStyle(Color.appPrimary)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -213,20 +213,20 @@ struct SourceDetailView: View {
|
||||
.keyboardType(.decimalPad)
|
||||
.padding(8)
|
||||
.background(Color(.systemGray6))
|
||||
.cornerRadius(8)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
} else {
|
||||
Text(current.map { $0.currencyString } ?? String(localized: "source_monthly_contribution_not_set"))
|
||||
.font(.subheadline)
|
||||
.foregroundColor(current != nil ? .primary : .secondary)
|
||||
.foregroundStyle(current != nil ? .primary : .secondary)
|
||||
}
|
||||
|
||||
Text(String(localized: "source_monthly_contribution_hint"))
|
||||
.font(.caption2)
|
||||
.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)
|
||||
.confirmationDialog(
|
||||
String(localized: "source_monthly_contribution_apply_title"),
|
||||
@@ -316,7 +316,7 @@ struct SourceDetailView: View {
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 4)
|
||||
.background(Color.appSecondary.opacity(0.15))
|
||||
.cornerRadius(8)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -347,18 +347,18 @@ struct SourceDetailView: View {
|
||||
Text("Unlock predictions on the chart")
|
||||
}
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundColor(.appPrimary)
|
||||
.foregroundStyle(Color.appPrimary)
|
||||
.padding(.vertical, 6)
|
||||
.padding(.horizontal, 10)
|
||||
.background(Color.appPrimary.opacity(0.1))
|
||||
.cornerRadius(10)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.systemBackground))
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
|
||||
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
|
||||
}
|
||||
|
||||
@@ -383,7 +383,7 @@ struct SourceDetailView: 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)
|
||||
}
|
||||
|
||||
@@ -399,13 +399,13 @@ struct SourceDetailView: View {
|
||||
|
||||
Text("\(viewModel.snapshotCount)")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
if viewModel.isHistoryLimited {
|
||||
HStack {
|
||||
Image(systemName: "info.circle")
|
||||
.foregroundColor(.appWarning)
|
||||
.foregroundStyle(Color.appWarning)
|
||||
Text("\(viewModel.hiddenSnapshotCount) older snapshots hidden. Upgrade for full history.")
|
||||
.font(.caption)
|
||||
|
||||
@@ -418,7 +418,7 @@ struct SourceDetailView: View {
|
||||
}
|
||||
.padding(8)
|
||||
.background(Color.appWarning.opacity(0.1))
|
||||
.cornerRadius(AppConstants.UI.smallCornerRadius)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.smallCornerRadius))
|
||||
}
|
||||
|
||||
// Use LazyVStack for better performance with many snapshots
|
||||
@@ -464,12 +464,12 @@ struct SourceDetailView: View {
|
||||
if viewModel.isHistoryLimited && viewModel.hiddenSnapshotCount > 0 {
|
||||
Text("+ \(viewModel.hiddenSnapshotCount) older snapshots hidden")
|
||||
.font(.caption)
|
||||
.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)
|
||||
}
|
||||
}
|
||||
@@ -484,7 +484,7 @@ struct MetricCard: View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(title)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Text(value)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
@@ -508,7 +508,7 @@ struct SnapshotRowView: View {
|
||||
if let notes = snapshot.notes, !notes.isEmpty {
|
||||
Text(notes)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
@@ -523,7 +523,7 @@ struct SnapshotRowView: View {
|
||||
if snapshot.contribution != nil && snapshot.decimalContribution > 0 {
|
||||
Text("+ \(snapshot.decimalContribution.currencyString)")
|
||||
.font(.caption)
|
||||
.foregroundColor(.appPrimary)
|
||||
.foregroundStyle(Color.appPrimary)
|
||||
.hiddenBalance()
|
||||
}
|
||||
}
|
||||
@@ -532,10 +532,10 @@ struct SnapshotRowView: View {
|
||||
Button(action: onEdit) {
|
||||
Image(systemName: "pencil")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(6)
|
||||
.background(Color.gray.opacity(0.1))
|
||||
.cornerRadius(8)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
@@ -552,7 +552,7 @@ struct MetricChip: View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(title)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Text(value)
|
||||
.font(.caption.weight(.semibold))
|
||||
@@ -561,7 +561,7 @@ struct MetricChip: View {
|
||||
.padding(.vertical, 10)
|
||||
.padding(.horizontal, 12)
|
||||
.background(Color.gray.opacity(0.1))
|
||||
.cornerRadius(10)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,70 +7,21 @@ struct SourceListView: View {
|
||||
@Environment(\.managedObjectContext) private var context
|
||||
@StateObject private var viewModel: SourceListViewModel
|
||||
@State private var sourceToDelete: InvestmentSource?
|
||||
@State private var navigationPath = NavigationPath()
|
||||
@State private var showingSearch = false
|
||||
@State private var selectedSourceID: NSManagedObjectID?
|
||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||
|
||||
init(iapService: IAPService) {
|
||||
_viewModel = StateObject(wrappedValue: SourceListViewModel(iapService: iapService))
|
||||
}
|
||||
|
||||
/// Sidebar/detail split: two columns in regular width (iPad, iPhone Duo
|
||||
/// inner screen), a single collapsing stack in compact width. Selection is
|
||||
/// the only navigation state, so it survives the size-class change when the
|
||||
/// Duo opens or closes.
|
||||
@State private var columnVisibility: NavigationSplitViewVisibility = .all
|
||||
|
||||
var body: some View {
|
||||
if horizontalSizeClass == .regular {
|
||||
iPadSourcesLayout
|
||||
} else {
|
||||
iPhoneSourcesLayout
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - iPad Layout (master-detail)
|
||||
|
||||
private var iPadSourcesLayout: some View {
|
||||
HStack(spacing: 0) {
|
||||
// Left: source list panel
|
||||
NavigationStack {
|
||||
ZStack {
|
||||
AppBackground()
|
||||
if viewModel.isEmpty { emptyStateView } else { sourcesList }
|
||||
}
|
||||
.navigationTitle("Sources")
|
||||
.toolbar { sourcesToolbar }
|
||||
.sheet(isPresented: $viewModel.showingAddSource) { AddSourceView() }
|
||||
.sheet(isPresented: $viewModel.showingPaywall) { PaywallView() }
|
||||
.onAppear { syncAccountState() }
|
||||
.onReceive(accountStore.$selectedAccount) { viewModel.selectedAccount = $0 }
|
||||
.onReceive(accountStore.$showAllAccounts) { viewModel.showAllAccounts = $0 }
|
||||
.confirmationDialog(
|
||||
"Delete Source",
|
||||
isPresented: Binding(get: { sourceToDelete != nil }, set: { if !$0 { sourceToDelete = nil } }),
|
||||
titleVisibility: .visible
|
||||
) { deleteSourceDialogButtons } message: { deleteSourceDialogMessage }
|
||||
}
|
||||
.frame(width: 340)
|
||||
|
||||
Divider()
|
||||
|
||||
// Right: detail panel
|
||||
NavigationStack {
|
||||
Group {
|
||||
if let id = selectedSourceID,
|
||||
let source = try? context.existingObject(with: id) as? InvestmentSource,
|
||||
!source.isDeleted {
|
||||
SourceDetailView(source: source, iapService: iapService)
|
||||
.id(id)
|
||||
} else {
|
||||
noSourceSelectedView
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - iPhone Layout (push navigation)
|
||||
|
||||
private var iPhoneSourcesLayout: some View {
|
||||
NavigationStack(path: $navigationPath) {
|
||||
NavigationSplitView(columnVisibility: $columnVisibility) {
|
||||
ZStack {
|
||||
AppBackground()
|
||||
if viewModel.isEmpty { emptyStateView } else { sourcesList }
|
||||
@@ -82,19 +33,32 @@ struct SourceListView: View {
|
||||
.onAppear { syncAccountState() }
|
||||
.onReceive(accountStore.$selectedAccount) { viewModel.selectedAccount = $0 }
|
||||
.onReceive(accountStore.$showAllAccounts) { viewModel.showAllAccounts = $0 }
|
||||
.navigationDestination(for: NSManagedObjectID.self) { objectID in
|
||||
if let source = try? context.existingObject(with: objectID) as? InvestmentSource,
|
||||
!source.isDeleted {
|
||||
SourceDetailView(source: source, iapService: iapService)
|
||||
} else {
|
||||
MissingSourceView()
|
||||
}
|
||||
}
|
||||
.confirmationDialog(
|
||||
"Delete Source",
|
||||
isPresented: Binding(get: { sourceToDelete != nil }, set: { if !$0 { sourceToDelete = nil } }),
|
||||
titleVisibility: .visible
|
||||
) { deleteSourceDialogButtons } message: { deleteSourceDialogMessage }
|
||||
.navigationSplitViewColumnWidth(min: 280, ideal: 300, max: 420)
|
||||
} detail: {
|
||||
NavigationStack {
|
||||
detailContent
|
||||
}
|
||||
}
|
||||
.navigationSplitViewStyle(.balanced)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var detailContent: some View {
|
||||
if let id = selectedSourceID {
|
||||
if let source = try? context.existingObject(with: id) as? InvestmentSource,
|
||||
!source.isDeleted {
|
||||
SourceDetailView(source: source, iapService: iapService)
|
||||
.id(id)
|
||||
} else {
|
||||
MissingSourceView()
|
||||
}
|
||||
} else {
|
||||
noSourceSelectedView
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,21 +71,21 @@ struct SourceListView: View {
|
||||
|
||||
@ToolbarContentBuilder
|
||||
private var sourcesToolbar: some ToolbarContent {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button { viewModel.addSourceTapped() } label: {
|
||||
Image(systemName: "plus")
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button {
|
||||
withAnimation(.easeInOut(duration: 0.2)) { showingSearch.toggle() }
|
||||
if !showingSearch { viewModel.searchText = "" }
|
||||
} label: {
|
||||
Image(systemName: showingSearch ? "xmark.circle.fill" : "magnifyingglass")
|
||||
.foregroundColor(showingSearch ? .secondary : .primary)
|
||||
.foregroundStyle(showingSearch ? .secondary : .primary)
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
accountFilterMenu
|
||||
}
|
||||
}
|
||||
@@ -163,7 +127,7 @@ struct SourceListView: View {
|
||||
|
||||
Image(systemName: "chart.line.uptrend.xyaxis")
|
||||
.font(.system(size: 50, weight: .light))
|
||||
.foregroundColor(.white)
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
|
||||
VStack(spacing: 10) {
|
||||
@@ -171,15 +135,16 @@ struct SourceListView: View {
|
||||
.font(.title2.weight(.semibold))
|
||||
|
||||
Text("Pick an investment source from the\npanel on the left to see its details.")
|
||||
.frame(maxWidth: 360)
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
|
||||
// Call-to-action pill
|
||||
Label("Select from the list", systemImage: "arrow.left")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundColor(.appPrimary)
|
||||
.foregroundStyle(Color.appPrimary)
|
||||
.padding(.horizontal, 22)
|
||||
.padding(.vertical, 11)
|
||||
.background(Color.appPrimary.opacity(0.1))
|
||||
@@ -194,7 +159,7 @@ struct SourceListView: View {
|
||||
// MARK: - Sources List
|
||||
|
||||
private var sourcesList: some View {
|
||||
List {
|
||||
List(selection: $selectedSourceID) {
|
||||
// Filter Bar
|
||||
Section {
|
||||
filterBarContent
|
||||
@@ -210,7 +175,7 @@ struct SourceListView: View {
|
||||
VStack(alignment: .leading) {
|
||||
Text("Total Value")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(viewModel.formattedTotalValue)
|
||||
.font(.title2.weight(.bold))
|
||||
.hiddenBalance()
|
||||
@@ -221,7 +186,7 @@ struct SourceListView: View {
|
||||
VStack(alignment: .trailing) {
|
||||
Text("Sources")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
Text("\(viewModel.sources.count)")
|
||||
.font(.title2.weight(.bold))
|
||||
}
|
||||
@@ -235,7 +200,7 @@ struct SourceListView: View {
|
||||
Section {
|
||||
HStack {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.foregroundColor(.appWarning)
|
||||
.foregroundStyle(Color.appWarning)
|
||||
|
||||
Text("Source limit reached. Upgrade to Premium for unlimited sources.")
|
||||
.font(.subheadline)
|
||||
@@ -246,7 +211,7 @@ struct SourceListView: View {
|
||||
viewModel.showingPaywall = true
|
||||
}
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundColor(.appPrimary)
|
||||
.foregroundStyle(Color.appPrimary)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -255,19 +220,7 @@ struct SourceListView: View {
|
||||
Section {
|
||||
ForEach(viewModel.sources, id: \.objectID) { source in
|
||||
SourceRowView(source: source)
|
||||
.contentShape(Rectangle())
|
||||
.background(
|
||||
horizontalSizeClass == .regular && selectedSourceID == source.objectID
|
||||
? Color.appPrimary.opacity(0.08)
|
||||
: Color.clear
|
||||
)
|
||||
.onTapGesture {
|
||||
if horizontalSizeClass == .regular {
|
||||
selectedSourceID = source.objectID
|
||||
} else {
|
||||
navigationPath.append(source.objectID)
|
||||
}
|
||||
}
|
||||
.tag(source.objectID)
|
||||
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
|
||||
Button(role: .destructive) {
|
||||
sourceToDelete = source
|
||||
@@ -302,14 +255,14 @@ struct SourceListView: View {
|
||||
VStack(spacing: 20) {
|
||||
Image(systemName: "tray")
|
||||
.font(.system(size: 60))
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Text("No Investment Sources")
|
||||
.font(.custom("Avenir Next", size: 24).weight(.semibold))
|
||||
|
||||
Text("Add your first investment source to start tracking your portfolio.")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, 40)
|
||||
|
||||
@@ -318,11 +271,11 @@ struct SourceListView: View {
|
||||
} label: {
|
||||
Label("Add Source", systemImage: "plus")
|
||||
.font(.headline)
|
||||
.foregroundColor(.white)
|
||||
.foregroundStyle(.white)
|
||||
.padding()
|
||||
.frame(maxWidth: 200)
|
||||
.background(Color.appPrimary)
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -358,7 +311,7 @@ struct SourceListView: View {
|
||||
if showingSearch {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "magnifyingglass")
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
TextField("Search sources", text: $viewModel.searchText)
|
||||
.textFieldStyle(.plain)
|
||||
@@ -369,14 +322,14 @@ struct SourceListView: View {
|
||||
viewModel.searchText = ""
|
||||
} label: {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 9)
|
||||
.background(Color(.systemGray6))
|
||||
.cornerRadius(10)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.bottom, 10)
|
||||
.transition(.move(edge: .top).combined(with: .opacity))
|
||||
@@ -452,8 +405,8 @@ private struct CategoryChip: View {
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(isSelected ? Color.appPrimary : Color(.systemGray5))
|
||||
.foregroundColor(isSelected ? .white : .primary)
|
||||
.cornerRadius(20)
|
||||
.foregroundStyle(isSelected ? .white : .primary)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 20))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
@@ -464,12 +417,12 @@ private struct MissingSourceView: View {
|
||||
VStack(spacing: 12) {
|
||||
Image(systemName: "exclamationmark.triangle")
|
||||
.font(.system(size: 42))
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
Text("Source not available")
|
||||
.font(.headline)
|
||||
Text("This source was deleted.")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
@@ -490,7 +443,7 @@ struct SourceRowView: View {
|
||||
|
||||
Image(systemName: source.category?.icon ?? "questionmark")
|
||||
.font(.system(size: 18))
|
||||
.foregroundColor(source.category?.color ?? .gray)
|
||||
.foregroundStyle(source.category?.color ?? .gray)
|
||||
}
|
||||
|
||||
// Source info
|
||||
@@ -501,12 +454,12 @@ struct SourceRowView: View {
|
||||
HStack(spacing: 8) {
|
||||
Text(source.category?.name ?? "Uncategorized")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
if source.needsUpdate {
|
||||
Label("Needs update", systemImage: "clock.fill")
|
||||
.font(.caption2.weight(.semibold))
|
||||
.foregroundColor(.appWarning)
|
||||
.foregroundStyle(Color.appWarning)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 3)
|
||||
.background(Color.appWarning.opacity(0.12))
|
||||
@@ -529,7 +482,7 @@ struct SourceRowView: View {
|
||||
Text(String(format: "%.1f%%", NSDecimalNumber(decimal: source.totalReturn).doubleValue))
|
||||
.font(.caption)
|
||||
}
|
||||
.foregroundColor(source.totalReturn >= 0 ? .positiveGreen : .negativeRed)
|
||||
.foregroundStyle(source.totalReturn >= 0 ? Color.positiveGreen : Color.negativeRed)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
|
||||
@@ -147,7 +147,7 @@ struct QuickUpdateShareView: View {
|
||||
ProgressView()
|
||||
Text(String(localized: "ext_scanning_image"))
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
} else {
|
||||
TextField(String(localized: "ext_amount_placeholder"), text: $amountText)
|
||||
@@ -157,7 +157,7 @@ struct QuickUpdateShareView: View {
|
||||
if scanFoundNothing {
|
||||
Text(String(localized: "ext_ocr_no_amounts"))
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -187,7 +187,7 @@ struct QuickUpdateShareView: View {
|
||||
lineWidth: 1
|
||||
)
|
||||
)
|
||||
.foregroundColor(.primary)
|
||||
.foregroundStyle(.primary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
@@ -205,18 +205,18 @@ struct QuickUpdateShareView: View {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(source.name)
|
||||
.foregroundColor(.primary)
|
||||
.foregroundStyle(.primary)
|
||||
.font(.subheadline.weight(.medium))
|
||||
if source.updatedThisMonth {
|
||||
Text(String(localized: "ext_already_updated"))
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
if selectedSourceId == source.id {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundColor(.blue)
|
||||
.foregroundStyle(.blue)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -229,12 +229,12 @@ struct QuickUpdateShareView: View {
|
||||
VStack(spacing: 14) {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.font(.system(size: 52))
|
||||
.foregroundColor(.green)
|
||||
.foregroundStyle(.green)
|
||||
Text(String(localized: "ext_saved_title"))
|
||||
.font(.headline)
|
||||
Text(String(localized: "ext_saved_body"))
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
.padding(32)
|
||||
|
||||
@@ -26,7 +26,7 @@ final class ReviewPromptServiceTests: XCTestCase {
|
||||
reviewRequestHandler: { didRequest = true }
|
||||
)
|
||||
|
||||
service.recordMonthlyCheckInCompleted()
|
||||
// Rating velocity rule (1.6.x): 2 check-ins are enough, not 3.
|
||||
service.recordMonthlyCheckInCompleted()
|
||||
XCTAssertFalse(didRequest)
|
||||
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import XCTest
|
||||
@testable import PortfolioJournal
|
||||
|
||||
/// The watch payload travels between processes and devices, so its encoding and
|
||||
/// the formatting the complications rely on are worth pinning down.
|
||||
final class WatchPortfolioSnapshotTests: XCTestCase {
|
||||
|
||||
private func makeSnapshot(
|
||||
goals: [WatchPortfolioSnapshot.Goal] = [],
|
||||
totalValue: Double = 1_000
|
||||
) -> WatchPortfolioSnapshot {
|
||||
WatchPortfolioSnapshot(
|
||||
generatedAt: Date(timeIntervalSince1970: 1_770_000_000),
|
||||
currencyCode: "EUR",
|
||||
totalValue: totalValue,
|
||||
monthChange: 250,
|
||||
monthChangePercent: 0.025,
|
||||
sources: [],
|
||||
goals: goals,
|
||||
currentStreak: 3,
|
||||
bestStreak: 9,
|
||||
checkInDone: true,
|
||||
checkInMonthLabel: "August 2026"
|
||||
)
|
||||
}
|
||||
|
||||
private func makeGoal(name: String, current: Double, target: Double) -> WatchPortfolioSnapshot.Goal {
|
||||
WatchPortfolioSnapshot.Goal(
|
||||
id: UUID(),
|
||||
name: name,
|
||||
categoryName: "Cash",
|
||||
categoryIcon: "banknote.fill",
|
||||
categoryColorHex: "#6B7280",
|
||||
currentValue: current,
|
||||
targetValue: target
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Transport
|
||||
|
||||
func testApplicationContextRoundTrip() throws {
|
||||
let snapshot = makeSnapshot(goals: [makeGoal(name: "Cash", current: 40_000, target: 100_000)])
|
||||
|
||||
let context = try XCTUnwrap(snapshot.applicationContext())
|
||||
let decoded = try XCTUnwrap(WatchPortfolioSnapshot.from(applicationContext: context))
|
||||
|
||||
XCTAssertEqual(decoded, snapshot)
|
||||
}
|
||||
|
||||
func testApplicationContextIgnoresUnrelatedPayload() {
|
||||
XCTAssertNil(WatchPortfolioSnapshot.from(applicationContext: ["somethingElse": 1]))
|
||||
}
|
||||
|
||||
func testEmptySnapshotReportsNoData() {
|
||||
XCTAssertFalse(WatchPortfolioSnapshot.empty.hasData)
|
||||
XCTAssertTrue(makeSnapshot().hasData)
|
||||
}
|
||||
|
||||
// MARK: - Goals
|
||||
|
||||
func testProgressIsClampedBetweenZeroAndOne() {
|
||||
XCTAssertEqual(makeGoal(name: "Over", current: 150, target: 100).progress, 1)
|
||||
XCTAssertEqual(makeGoal(name: "Negative", current: -50, target: 100).progress, 0)
|
||||
XCTAssertEqual(makeGoal(name: "Half", current: 50, target: 100).progress, 0.5)
|
||||
}
|
||||
|
||||
func testProgressWithoutTargetIsZero() {
|
||||
XCTAssertEqual(makeGoal(name: "No target", current: 50, target: 0).progress, 0)
|
||||
}
|
||||
|
||||
/// The single-goal complication must skip goals already reached.
|
||||
func testFeaturedGoalSkipsAchievedGoals() {
|
||||
let achieved = makeGoal(name: "Done", current: 100, target: 100)
|
||||
let open = makeGoal(name: "Cash buffer", current: 40, target: 100)
|
||||
let snapshot = makeSnapshot(goals: [achieved, open])
|
||||
|
||||
XCTAssertEqual(snapshot.featuredGoal?.name, "Cash buffer")
|
||||
}
|
||||
|
||||
func testFeaturedGoalFallsBackToFirstWhenAllAchieved() {
|
||||
let first = makeGoal(name: "First", current: 100, target: 100)
|
||||
let second = makeGoal(name: "Second", current: 200, target: 200)
|
||||
|
||||
XCTAssertEqual(makeSnapshot(goals: [first, second]).featuredGoal?.name, "First")
|
||||
}
|
||||
|
||||
func testFeaturedGoalIsNilWithoutGoals() {
|
||||
XCTAssertNil(makeSnapshot().featuredGoal)
|
||||
}
|
||||
|
||||
// MARK: - Formatting
|
||||
|
||||
func testCompactFormatShortensLargeAmounts() {
|
||||
func compact(_ value: Double) -> String {
|
||||
WatchPortfolioSnapshot.format(value, currencyCode: "EUR", compact: true)
|
||||
}
|
||||
|
||||
XCTAssertTrue(compact(750).contains("750"))
|
||||
XCTAssertTrue(compact(1_500).contains("1.5K"))
|
||||
XCTAssertTrue(compact(25_000).contains("25K"))
|
||||
XCTAssertTrue(compact(2_000_000).contains("2.0M"))
|
||||
XCTAssertTrue(compact(-1_500).hasPrefix("-"))
|
||||
}
|
||||
|
||||
func testMonthChangeCarriesExplicitSign() {
|
||||
let positive = makeSnapshot()
|
||||
XCTAssertTrue(positive.formattedMonthChange().hasPrefix("+"))
|
||||
XCTAssertEqual(positive.formattedMonthChangePercent, "+2.5%")
|
||||
|
||||
let negative = WatchPortfolioSnapshot(
|
||||
generatedAt: Date(),
|
||||
currencyCode: "EUR",
|
||||
totalValue: 1_000,
|
||||
monthChange: -250,
|
||||
monthChangePercent: -0.025,
|
||||
sources: [],
|
||||
goals: [],
|
||||
currentStreak: 0,
|
||||
bestStreak: 0,
|
||||
checkInDone: false,
|
||||
checkInMonthLabel: ""
|
||||
)
|
||||
XCTAssertTrue(negative.formattedMonthChange().hasPrefix("-"))
|
||||
XCTAssertEqual(negative.formattedMonthChangePercent, "-2.5%")
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user