Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4b8873eb84 | |||
| 8daa80bb42 | |||
| 12386673ff | |||
| 2380d37a9e | |||
| 7d2f605c16 | |||
| 319ab9589e | |||
| e9e3e5da66 | |||
| 15a6d7395e | |||
| 8828a2aa0f | |||
| 171023a411 | |||
| 6774d76146 | |||
| 680255f69d | |||
| a8a9ad64f3 |
@@ -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,7 +48,7 @@ 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)
|
||||
@@ -84,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,11 +10,7 @@
|
||||
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, ); }; };
|
||||
@@ -246,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;
|
||||
};
|
||||
@@ -355,7 +347,6 @@
|
||||
0E241EE72F0DAA3E00283E2F /* Embed Foundation Extensions */,
|
||||
0E8318932F0DB2FB0030C2F9 /* Resources */,
|
||||
0EWTCH240000000000000000 /* Embed Watch Content */,
|
||||
0E5375362F0FD14000F31390 /* Upload dSYMs to Crashlytics */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
@@ -368,12 +359,6 @@
|
||||
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";
|
||||
@@ -394,8 +379,6 @@
|
||||
0E241ED22F0DAA3C00283E2F /* PortfolioJournalWidget */,
|
||||
);
|
||||
name = PortfolioJournalWidgetExtension;
|
||||
packageProductDependencies = (
|
||||
);
|
||||
productName = PortfolioJournalWidgetExtension;
|
||||
productReference = 0E241ECC2F0DAA3C00283E2F /* PortfolioJournalWidgetExtension.appex */;
|
||||
productType = "com.apple.product-type.app-extension";
|
||||
@@ -417,8 +400,6 @@
|
||||
0ETEST0032F31000000000001 /* PortfolioJournalTests */,
|
||||
);
|
||||
name = PortfolioJournalTests;
|
||||
packageProductDependencies = (
|
||||
);
|
||||
productName = PortfolioJournalTests;
|
||||
productReference = 0ETEST0002F31000000000001 /* PortfolioJournalTests.xctest */;
|
||||
productType = "com.apple.product-type.bundle.unit-test";
|
||||
@@ -440,8 +421,6 @@
|
||||
0EUITEST032F31000000001 /* PortfolioJournalUITests */,
|
||||
);
|
||||
name = PortfolioJournalUITests;
|
||||
packageProductDependencies = (
|
||||
);
|
||||
productName = PortfolioJournalUITests;
|
||||
productReference = 0EUITEST002F31000000001 /* PortfolioJournalUITests.xctest */;
|
||||
productType = "com.apple.product-type.bundle.ui-testing";
|
||||
@@ -462,8 +441,6 @@
|
||||
0EQUPD032F40000000000001 /* PortfolioJournalQuickUpdate */,
|
||||
);
|
||||
name = PortfolioJournalQuickUpdate;
|
||||
packageProductDependencies = (
|
||||
);
|
||||
productName = PortfolioJournalQuickUpdate;
|
||||
productReference = 0EQUPD012F40000000000001 /* PortfolioJournalQuickUpdate.appex */;
|
||||
productType = "com.apple.product-type.app-extension";
|
||||
@@ -487,8 +464,6 @@
|
||||
0EWTCH070000000000000000 /* Shared */,
|
||||
);
|
||||
name = PortfolioJournalWatch;
|
||||
packageProductDependencies = (
|
||||
);
|
||||
productName = PortfolioJournalWatch;
|
||||
productReference = 0EWTCH030000000000000000 /* PortfolioJournalWatch.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
@@ -509,8 +484,6 @@
|
||||
0EWTCH060000000000000000 /* PortfolioJournalWatchWidget */,
|
||||
);
|
||||
name = PortfolioJournalWatchWidget;
|
||||
packageProductDependencies = (
|
||||
);
|
||||
productName = PortfolioJournalWatchWidget;
|
||||
productReference = 0EWTCH040000000000000000 /* PortfolioJournalWatchWidget.appex */;
|
||||
productType = "com.apple.product-type.app-extension";
|
||||
@@ -555,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 = "";
|
||||
@@ -679,28 +648,6 @@
|
||||
};
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXShellScriptBuildPhase section */
|
||||
0E5375362F0FD14000F31390 /* Upload dSYMs to Crashlytics */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
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;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "\"${BUILD_DIR%/Build/*}/SourcePackages/checkouts/firebase-ios-sdk/Crashlytics/run\"\n";
|
||||
};
|
||||
/* End PBXShellScriptBuildPhase section */
|
||||
|
||||
/* Begin PBXTargetDependency section */
|
||||
0E241EE12F0DAA3E00283E2F /* PBXTargetDependency */ = {
|
||||
@@ -742,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 = 88;
|
||||
CURRENT_PROJECT_VERSION = 89;
|
||||
DEVELOPMENT_ASSET_PATHS = PortfolioJournal/Assets.xcassets;
|
||||
DEVELOPMENT_TEAM = 2825Q76T7H;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
@@ -762,12 +709,13 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.6.3;
|
||||
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;
|
||||
@@ -781,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 = 88;
|
||||
CURRENT_PROJECT_VERSION = 89;
|
||||
DEVELOPMENT_ASSET_PATHS = PortfolioJournal/Assets.xcassets;
|
||||
DEVELOPMENT_TEAM = 2825Q76T7H;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
@@ -803,12 +751,13 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.6.3;
|
||||
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;
|
||||
@@ -942,7 +891,7 @@
|
||||
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
|
||||
CODE_SIGN_ENTITLEMENTS = PortfolioJournalWidgetExtension.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 88;
|
||||
CURRENT_PROJECT_VERSION = 89;
|
||||
DEVELOPMENT_ASSET_PATHS = PortfolioJournalWidget/Assets.xcassets;
|
||||
DEVELOPMENT_TEAM = 2825Q76T7H;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
@@ -955,7 +904,7 @@
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.6.3;
|
||||
MARKETING_VERSION = 1.7.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournal.PortfolioJournalWidget;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SKIP_INSTALL = YES;
|
||||
@@ -974,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 = 88;
|
||||
CURRENT_PROJECT_VERSION = 89;
|
||||
DEVELOPMENT_ASSET_PATHS = PortfolioJournalWidget/Assets.xcassets;
|
||||
DEVELOPMENT_TEAM = 2825Q76T7H;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
@@ -990,7 +939,7 @@
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.6.3;
|
||||
MARKETING_VERSION = 1.7.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournal.PortfolioJournalWidget;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SKIP_INSTALL = YES;
|
||||
@@ -1008,7 +957,7 @@
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 88;
|
||||
CURRENT_PROJECT_VERSION = 89;
|
||||
DEVELOPMENT_TEAM = 2825Q76T7H;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
|
||||
@@ -1017,7 +966,7 @@
|
||||
"@executable_path/Frameworks",
|
||||
"@loader_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.6.3;
|
||||
MARKETING_VERSION = 1.7.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournalTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||
@@ -1032,7 +981,7 @@
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 88;
|
||||
CURRENT_PROJECT_VERSION = 89;
|
||||
DEVELOPMENT_TEAM = 2825Q76T7H;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
|
||||
@@ -1041,7 +990,7 @@
|
||||
"@executable_path/Frameworks",
|
||||
"@loader_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.6.3;
|
||||
MARKETING_VERSION = 1.7.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournalTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||
@@ -1055,7 +1004,7 @@
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 88;
|
||||
CURRENT_PROJECT_VERSION = 89;
|
||||
DEVELOPMENT_TEAM = 2825Q76T7H;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
|
||||
@@ -1064,7 +1013,7 @@
|
||||
"@executable_path/Frameworks",
|
||||
"@loader_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.6.3;
|
||||
MARKETING_VERSION = 1.7.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournalUITests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||
@@ -1078,7 +1027,7 @@
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 88;
|
||||
CURRENT_PROJECT_VERSION = 89;
|
||||
DEVELOPMENT_TEAM = 2825Q76T7H;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
|
||||
@@ -1087,7 +1036,7 @@
|
||||
"@executable_path/Frameworks",
|
||||
"@loader_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.6.3;
|
||||
MARKETING_VERSION = 1.7.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournalUITests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||
@@ -1102,7 +1051,7 @@
|
||||
buildSettings = {
|
||||
CODE_SIGN_ENTITLEMENTS = PortfolioJournalQuickUpdateExtension.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 88;
|
||||
CURRENT_PROJECT_VERSION = 89;
|
||||
DEVELOPMENT_TEAM = 2825Q76T7H;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = PortfolioJournalQuickUpdate/Info.plist;
|
||||
@@ -1114,7 +1063,7 @@
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.6.3;
|
||||
MARKETING_VERSION = 1.7.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournal.QuickUpdate;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SKIP_INSTALL = YES;
|
||||
@@ -1128,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 = 88;
|
||||
CURRENT_PROJECT_VERSION = 89;
|
||||
DEVELOPMENT_TEAM = 2825Q76T7H;
|
||||
PROVISIONING_PROFILE_SPECIFIER = "PortfolioJournal QuickUpdate AppStore";
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
@@ -1143,7 +1092,7 @@
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.6.3;
|
||||
MARKETING_VERSION = 1.7.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournal.QuickUpdate;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SKIP_INSTALL = YES;
|
||||
@@ -1162,7 +1111,7 @@
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = PortfolioJournalWatch.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 88;
|
||||
CURRENT_PROJECT_VERSION = 89;
|
||||
DEVELOPMENT_TEAM = 2825Q76T7H;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
@@ -1172,7 +1121,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.6.3;
|
||||
MARKETING_VERSION = 1.7.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournal.watchkitapp;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = watchos;
|
||||
@@ -1197,8 +1146,10 @@
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = PortfolioJournalWatch.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 88;
|
||||
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;
|
||||
@@ -1208,7 +1159,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.6.3;
|
||||
MARKETING_VERSION = 1.7.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournal.watchkitapp;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = watchos;
|
||||
@@ -1232,7 +1183,7 @@
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = PortfolioJournalWatchWidget.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 88;
|
||||
CURRENT_PROJECT_VERSION = 89;
|
||||
DEVELOPMENT_TEAM = 2825Q76T7H;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
@@ -1242,7 +1193,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.6.3;
|
||||
MARKETING_VERSION = 1.7.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournal.watchkitapp.widget;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = watchos;
|
||||
@@ -1266,8 +1217,10 @@
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = PortfolioJournalWatchWidget.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 88;
|
||||
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;
|
||||
@@ -1277,7 +1230,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.6.3;
|
||||
MARKETING_VERSION = 1.7.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournal.watchkitapp.widget;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = watchos;
|
||||
@@ -1370,47 +1323,7 @@
|
||||
};
|
||||
/* 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
|
||||
}
|
||||
@@ -1,24 +1,15 @@
|
||||
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()
|
||||
|
||||
@@ -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,7 +91,6 @@ struct PortfolioJournalApp: App {
|
||||
.environment(\.managedObjectContext, coreDataStack.viewContext)
|
||||
.environmentObject(coreDataStack)
|
||||
.environmentObject(iapService)
|
||||
.environmentObject(adMobService)
|
||||
.environmentObject(accountStore)
|
||||
.environmentObject(tabSelection)
|
||||
.environmentObject(balancePrivacy)
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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";
|
||||
@@ -684,3 +680,31 @@
|
||||
"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";
|
||||
@@ -685,3 +681,31 @@
|
||||
"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";
|
||||
@@ -636,3 +634,31 @@
|
||||
"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";
|
||||
@@ -684,3 +680,31 @@
|
||||
"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";
|
||||
@@ -684,3 +680,31 @@
|
||||
"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" = "通知";
|
||||
@@ -684,3 +680,31 @@
|
||||
"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";
|
||||
@@ -684,3 +680,31 @@
|
||||
"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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -258,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"))
|
||||
]}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)] {
|
||||
|
||||
@@ -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] {
|
||||
|
||||
@@ -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: {
|
||||
@@ -112,9 +113,6 @@ struct DashboardView: View {
|
||||
goalsViewModel.selectedAccount = accountStore.selectedAccount
|
||||
goalsViewModel.showAllAccounts = accountStore.showAllAccounts
|
||||
goalsViewModel.refresh()
|
||||
// Screen view logged here (once per appearance) — NOT inside
|
||||
// refreshData(), which re-runs on every Core Data change.
|
||||
FirebaseService.shared.logScreenView(screenName: "Dashboard")
|
||||
}
|
||||
// Performance: Combine account selection changes into a single handler
|
||||
.onChange(of: accountStore.selectedAccount) { _, newAccount in
|
||||
@@ -143,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()
|
||||
}
|
||||
@@ -175,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 {
|
||||
@@ -278,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) {
|
||||
@@ -478,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)
|
||||
@@ -503,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))
|
||||
@@ -511,7 +520,7 @@ struct TotalValueCard: View {
|
||||
|
||||
Text(changeLabel)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
if sparklineData.count >= 2 {
|
||||
@@ -544,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)
|
||||
}
|
||||
@@ -559,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)
|
||||
}
|
||||
@@ -760,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) {
|
||||
@@ -782,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()
|
||||
@@ -800,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))
|
||||
@@ -810,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"))
|
||||
@@ -820,7 +824,7 @@ struct MonthlyCheckInCard: View {
|
||||
} label: {
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
@@ -836,7 +840,7 @@ struct MonthlyCheckInCard: View {
|
||||
.font(.headline)
|
||||
Text(currentMonthLabel)
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
if streak >= 2 {
|
||||
@@ -844,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))
|
||||
@@ -867,7 +871,7 @@ struct MonthlyCheckInCard: View {
|
||||
)
|
||||
} label: {
|
||||
Image(systemName: "calendar.badge.plus")
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.font(.subheadline.weight(.semibold))
|
||||
}
|
||||
@@ -879,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)
|
||||
@@ -890,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))
|
||||
@@ -908,7 +912,7 @@ struct MonthlyCheckInCard: View {
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 8)
|
||||
.background(Color(.tertiarySystemFill))
|
||||
.cornerRadius(10)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
}
|
||||
|
||||
Button {
|
||||
@@ -919,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")
|
||||
}
|
||||
@@ -962,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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1002,7 +1006,7 @@ struct MomentumStreaksCard: View {
|
||||
HStack {
|
||||
Text("On-time score")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
Text(
|
||||
String(
|
||||
@@ -1012,7 +1016,7 @@ struct MomentumStreaksCard: View {
|
||||
)
|
||||
)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
if let closest = stats.closestCutoffDays {
|
||||
@@ -1023,7 +1027,7 @@ struct MomentumStreaksCard: View {
|
||||
)
|
||||
)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
if !stats.achievements.isEmpty && !compact {
|
||||
@@ -1053,7 +1057,7 @@ struct MomentumStreaksCard: View {
|
||||
Spacer()
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
.contentShape(Rectangle())
|
||||
@@ -1062,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
|
||||
@@ -1097,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
|
||||
@@ -1112,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
|
||||
@@ -1130,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 {
|
||||
@@ -1191,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()
|
||||
@@ -1202,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)
|
||||
}
|
||||
}
|
||||
@@ -1233,7 +1237,7 @@ struct DashboardCustomizeView: View {
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Text(config.isCollapsed ? "Compact" : "Expanded")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
@@ -1256,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()
|
||||
@@ -1286,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)
|
||||
}
|
||||
}
|
||||
@@ -1312,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)
|
||||
@@ -1320,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)
|
||||
}
|
||||
}
|
||||
@@ -1367,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)
|
||||
}
|
||||
}
|
||||
@@ -1381,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)
|
||||
}
|
||||
@@ -1404,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 {
|
||||
@@ -1419,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) {
|
||||
@@ -1435,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 {
|
||||
@@ -1447,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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1465,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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1496,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
|
||||
@@ -1519,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)
|
||||
@@ -1532,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)
|
||||
}
|
||||
}
|
||||
@@ -1593,7 +1597,7 @@ struct GoalsSummaryCard: View {
|
||||
Text(category.name)
|
||||
.font(.caption2.weight(.semibold))
|
||||
}
|
||||
.foregroundColor(category.color)
|
||||
.foregroundStyle(category.color)
|
||||
}
|
||||
}
|
||||
HStack(spacing: 4) {
|
||||
@@ -1601,22 +1605,22 @@ struct GoalsSummaryCard: View {
|
||||
.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()
|
||||
@@ -1630,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)
|
||||
}
|
||||
}
|
||||
@@ -1657,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)
|
||||
}
|
||||
}
|
||||
@@ -1706,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))
|
||||
}
|
||||
@@ -1716,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -41,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)
|
||||
}
|
||||
@@ -73,10 +73,10 @@ struct GoalEditorView: View {
|
||||
}
|
||||
.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)
|
||||
}
|
||||
@@ -86,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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
@@ -280,7 +280,7 @@ struct GoalRowView: View {
|
||||
Text(category.name)
|
||||
.font(.caption2.weight(.semibold))
|
||||
}
|
||||
.foregroundColor(category.color)
|
||||
.foregroundStyle(category.color)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 3)
|
||||
.background(category.color.opacity(0.15))
|
||||
@@ -293,21 +293,21 @@ struct GoalRowView: View {
|
||||
.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)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -326,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)
|
||||
@@ -366,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)
|
||||
@@ -409,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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -32,6 +32,10 @@ final class SettingsViewModelTests: XCTestCase {
|
||||
iap.setDebugPremiumOverride(true)
|
||||
#endif
|
||||
let viewModel = SettingsViewModel(iapService: iap)
|
||||
// isPremium is mirrored via `receive(on: DispatchQueue.main)`; give the
|
||||
// publisher a run-loop turn before acting on it.
|
||||
RunLoop.main.run(until: Date().addingTimeInterval(0.2))
|
||||
XCTAssertTrue(viewModel.isPremium)
|
||||
|
||||
viewModel.setBackupsEnabled(true)
|
||||
|
||||
|
||||
@@ -6,7 +6,9 @@ final class MonthlyCheckInViewTests: XCTestCase {
|
||||
var components = DateComponents()
|
||||
components.year = 2026
|
||||
components.month = 2
|
||||
components.day = 1
|
||||
// Past the 20-day grace period (MonthlyCheckInStore.graceDays), so the
|
||||
// effective month is February itself.
|
||||
components.day = 25
|
||||
let date = Calendar(identifier: .gregorian).date(from: components)!
|
||||
|
||||
let label = MonthlyCheckInView.monthLabel(
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
import XCTest
|
||||
import LocalAuthentication
|
||||
|
||||
/// Walks the adaptive layout (tab bar / sidebar, split views, inspector) and
|
||||
/// attaches a screenshot per step. Run on a compact device (iPhone) and on a
|
||||
/// regular×regular one (iPad mini ≈ iPhone Duo inner screen) to compare.
|
||||
final class DuoLayoutUITests: XCTestCase {
|
||||
|
||||
override func setUpWithError() throws {
|
||||
continueAfterFailure = true
|
||||
}
|
||||
|
||||
private func snap(_ app: XCUIApplication, _ name: String) {
|
||||
let shot = XCTAttachment(screenshot: app.screenshot())
|
||||
shot.name = name
|
||||
shot.lifetime = .keepAlways
|
||||
add(shot)
|
||||
}
|
||||
|
||||
/// Records the window geometry in points so the run log says which canvas
|
||||
/// the layout was actually exercised on (Duo cover screen, inner screen,
|
||||
/// iPhone, iPad…) instead of leaving it to the reader to guess.
|
||||
private func recordGeometry(_ app: XCUIApplication, _ name: String) {
|
||||
let frame = app.windows.firstMatch.frame
|
||||
let text = "\(name): window \(Int(frame.width))x\(Int(frame.height)) pt"
|
||||
let note = XCTAttachment(string: text)
|
||||
note.name = name
|
||||
note.lifetime = .keepAlways
|
||||
add(note)
|
||||
print("DUO-GEOMETRY \(text)")
|
||||
}
|
||||
|
||||
/// Tab bar button on iPhone, top tab bar / sidebar row on iPad.
|
||||
private func openTab(_ app: XCUIApplication, _ title: String) {
|
||||
let tabBar = app.tabBars.firstMatch
|
||||
if tabBar.exists && tabBar.buttons[title].exists && tabBar.buttons[title].isHittable {
|
||||
tabBar.buttons[title].tap()
|
||||
} else if app.buttons[title].firstMatch.waitForExistence(timeout: 3) {
|
||||
app.buttons[title].firstMatch.tap()
|
||||
} else if app.staticTexts[title].firstMatch.waitForExistence(timeout: 3) {
|
||||
app.staticTexts[title].firstMatch.tap()
|
||||
}
|
||||
Thread.sleep(forTimeInterval: 1.5)
|
||||
}
|
||||
|
||||
/// The App Lock toggle must name the biometry the hardware actually has —
|
||||
/// the iPhone Duo ships with Touch ID, so a hardcoded "Face ID" would be
|
||||
/// wrong there. Scrolls Settings down to App Lock, attaches the screenshot
|
||||
/// and compares the label against `LAContext.biometryType` on this device.
|
||||
private func assertBiometryLabelMatchesHardware(_ app: XCUIApplication) {
|
||||
let context = LAContext()
|
||||
_ = context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: nil)
|
||||
let expected: String
|
||||
switch context.biometryType {
|
||||
case .faceID: expected = "Face ID"
|
||||
case .touchID: expected = "Touch ID"
|
||||
case .opticID: expected = "Optic ID"
|
||||
default: expected = ""
|
||||
}
|
||||
guard !expected.isEmpty else {
|
||||
// No biometric hardware: the generic label is correct, nothing to check.
|
||||
return
|
||||
}
|
||||
|
||||
let toggle = app.switches.containing(
|
||||
NSPredicate(format: "label CONTAINS[c] %@", expected)
|
||||
).firstMatch
|
||||
for _ in 0..<8 where !toggle.exists {
|
||||
app.swipeUp()
|
||||
Thread.sleep(forTimeInterval: 0.6)
|
||||
}
|
||||
Thread.sleep(forTimeInterval: 0.8)
|
||||
snap(app, "09b_settings_app_lock")
|
||||
XCTAssertTrue(
|
||||
toggle.exists,
|
||||
"App Lock toggle should name \(expected) on this device"
|
||||
)
|
||||
print("DUO-BIOMETRY expected \(expected), toggle found: \(toggle.exists)")
|
||||
}
|
||||
|
||||
func testAdaptiveLayoutWalkthrough() throws {
|
||||
let app = XCUIApplication()
|
||||
app.launchArguments = ["--screenshots"]
|
||||
app.launch()
|
||||
Thread.sleep(forTimeInterval: 4.0)
|
||||
|
||||
let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard")
|
||||
let denyButton = springboard.alerts.buttons["Don't Allow"]
|
||||
if denyButton.waitForExistence(timeout: 8) {
|
||||
denyButton.tap()
|
||||
Thread.sleep(forTimeInterval: 1.0)
|
||||
}
|
||||
|
||||
recordGeometry(app, "00_window")
|
||||
snap(app, "01_dashboard")
|
||||
|
||||
// Quick Update: inspector column in regular width, sheet in compact.
|
||||
let quickUpdate = app.buttons["dashboard.quickUpdate"].firstMatch
|
||||
if quickUpdate.waitForExistence(timeout: 3) {
|
||||
quickUpdate.tap()
|
||||
Thread.sleep(forTimeInterval: 2.0)
|
||||
snap(app, "02_quick_update_inspector")
|
||||
let cancel = app.buttons["Cancel"].firstMatch
|
||||
XCTAssertTrue(cancel.waitForExistence(timeout: 3), "Quick Update cancel button missing")
|
||||
cancel.tap()
|
||||
Thread.sleep(forTimeInterval: 1.5)
|
||||
XCTAssertFalse(app.buttons["Cancel"].firstMatch.exists, "Quick Update did not dismiss")
|
||||
snap(app, "03_dashboard_after_cancel")
|
||||
} else {
|
||||
XCTFail("Quick Update button not found")
|
||||
}
|
||||
|
||||
openTab(app, "Sources")
|
||||
snap(app, "04_sources")
|
||||
let sourceCells = app.cells
|
||||
if sourceCells.count > 2 {
|
||||
sourceCells.element(boundBy: 2).tap()
|
||||
Thread.sleep(forTimeInterval: 2.0)
|
||||
snap(app, "05_source_detail")
|
||||
}
|
||||
|
||||
openTab(app, "Charts")
|
||||
snap(app, "06_charts")
|
||||
|
||||
openTab(app, "Journal")
|
||||
snap(app, "07_journal")
|
||||
// Seeded data always has last month's check-in: select it by label.
|
||||
let formatter = DateFormatter()
|
||||
formatter.locale = Locale(identifier: "en_US")
|
||||
formatter.dateFormat = "MMM yyyy"
|
||||
let lastMonth = Calendar.current.date(byAdding: .month, value: -1, to: Date())!
|
||||
let monthRow = app.staticTexts[formatter.string(from: lastMonth)].firstMatch
|
||||
if monthRow.waitForExistence(timeout: 3) {
|
||||
monthRow.tap()
|
||||
} else if app.cells.count > 1 {
|
||||
app.cells.element(boundBy: 1).tap()
|
||||
}
|
||||
Thread.sleep(forTimeInterval: 2.0)
|
||||
XCTAssertFalse(app.staticTexts["Select a Month"].exists, "Journal month selection did not open the check-in")
|
||||
snap(app, "08_journal_detail")
|
||||
|
||||
openTab(app, "Settings")
|
||||
snap(app, "09_settings")
|
||||
assertBiometryLabelMatchesHardware(app)
|
||||
|
||||
// Landscape: the Duo inner screen is regular×regular in both orientations.
|
||||
XCUIDevice.shared.orientation = .landscapeLeft
|
||||
Thread.sleep(forTimeInterval: 2.0)
|
||||
openTab(app, "Home")
|
||||
snap(app, "10_dashboard_landscape")
|
||||
openTab(app, "Charts")
|
||||
snap(app, "11_charts_landscape")
|
||||
XCUIDevice.shared.orientation = .portrait
|
||||
}
|
||||
}
|
||||
@@ -579,9 +579,9 @@ struct ChangeLine: View {
|
||||
.font(.caption.weight(.medium))
|
||||
Text("(\(String(format: "%.1f%%", entry.dayChangePercentage)))")
|
||||
.font(.caption2)
|
||||
.foregroundColor(compact ? (entry.dayChange >= 0 ? .green : .red) : .secondary)
|
||||
.foregroundStyle(compact ? (entry.dayChange >= 0 ? .green : .red) : .secondary)
|
||||
}
|
||||
.foregroundColor(entry.dayChange >= 0 ? .green : .red)
|
||||
.foregroundStyle(entry.dayChange >= 0 ? .green : .red)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.8)
|
||||
}
|
||||
@@ -595,7 +595,7 @@ struct StreakPill: View {
|
||||
HStack(spacing: 3) {
|
||||
Image(systemName: "flame.fill")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.orange)
|
||||
.foregroundStyle(.orange)
|
||||
Text("\(streak) mo")
|
||||
.font(.caption2.weight(.semibold))
|
||||
}
|
||||
@@ -608,10 +608,10 @@ struct CheckInPill: View {
|
||||
HStack(spacing: 3) {
|
||||
Image(systemName: "calendar.badge.clock")
|
||||
.font(.caption2)
|
||||
.foregroundColor(checkInIsUrgent(date) ? .orange : .secondary)
|
||||
.foregroundStyle(checkInIsUrgent(date) ? .orange : .secondary)
|
||||
Text(checkInText(for: date))
|
||||
.font(.caption2.weight(.medium))
|
||||
.foregroundColor(checkInIsUrgent(date) ? .orange : .secondary)
|
||||
.foregroundStyle(checkInIsUrgent(date) ? .orange : .secondary)
|
||||
}
|
||||
.lineLimit(1)
|
||||
}
|
||||
@@ -629,25 +629,25 @@ struct GoalProgressRow: View {
|
||||
HStack {
|
||||
Image(systemName: "target")
|
||||
.font(.caption2)
|
||||
.foregroundColor(widgetSecondaryColor)
|
||||
.foregroundStyle(widgetSecondaryColor)
|
||||
Text(goal.name)
|
||||
.font(.caption.weight(.semibold))
|
||||
.lineLimit(1)
|
||||
Spacer()
|
||||
Text("\(Int((pct * 100).rounded()))%")
|
||||
.font(.caption.weight(.bold))
|
||||
.foregroundColor(widgetSecondaryColor)
|
||||
.foregroundStyle(widgetSecondaryColor)
|
||||
}
|
||||
ProgressView(value: pct)
|
||||
.tint(widgetSecondaryColor)
|
||||
HStack {
|
||||
Text(entry.totalValue.compactCurrencyString(currencyCode: entry.currencyCode))
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
Text(goal.targetAmount.compactCurrencyString(currencyCode: entry.currencyCode))
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
} else if entry.nextMilestone > 0 {
|
||||
@@ -655,13 +655,13 @@ struct GoalProgressRow: View {
|
||||
HStack {
|
||||
Image(systemName: "flag.checkered")
|
||||
.font(.caption2)
|
||||
.foregroundColor(widgetSecondaryColor)
|
||||
.foregroundStyle(widgetSecondaryColor)
|
||||
Text("Next milestone")
|
||||
.font(.caption.weight(.semibold))
|
||||
Spacer()
|
||||
Text("\(Int((entry.milestoneProgress * 100).rounded()))%")
|
||||
.font(.caption.weight(.bold))
|
||||
.foregroundColor(widgetSecondaryColor)
|
||||
.foregroundStyle(widgetSecondaryColor)
|
||||
}
|
||||
ProgressView(value: entry.milestoneProgress)
|
||||
.tint(widgetSecondaryColor)
|
||||
@@ -669,7 +669,7 @@ struct GoalProgressRow: View {
|
||||
Spacer()
|
||||
Text(entry.nextMilestone.compactCurrencyString(currencyCode: entry.currencyCode))
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -687,7 +687,7 @@ struct SmallWidgetView: View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Total Value")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Text(entry.totalValue.compactCurrencyString(currencyCode: entry.currencyCode))
|
||||
.font(.title2.weight(.bold))
|
||||
@@ -697,7 +697,7 @@ struct SmallWidgetView: View {
|
||||
ChangeLine(entry: entry, compact: true)
|
||||
Text("since last check-in")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Spacer(minLength: 0)
|
||||
|
||||
@@ -723,12 +723,12 @@ struct EmptyStateView: View {
|
||||
VStack(spacing: 8) {
|
||||
Image(systemName: "chart.line.uptrend.xyaxis")
|
||||
.font(.title)
|
||||
.foregroundColor(widgetPrimaryColor)
|
||||
.foregroundStyle(widgetPrimaryColor)
|
||||
Text("No data yet")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Text("Add a snapshot to start tracking.")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
@@ -757,7 +757,7 @@ struct MediumWidgetView: View {
|
||||
HStack(spacing: 6) {
|
||||
Text("Portfolio")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer(minLength: 0)
|
||||
WidgetRefreshButton()
|
||||
}
|
||||
@@ -791,20 +791,20 @@ struct MediumWidgetView: View {
|
||||
VStack(alignment: .trailing, spacing: 4) {
|
||||
Text("Add snapshots")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
Text("to see trend")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
VStack(alignment: .trailing, spacing: 4) {
|
||||
Text("Sparkline")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
Text("Premium")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -818,7 +818,7 @@ struct MediumWidgetView: View {
|
||||
|
||||
Text(source.name)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
|
||||
Text(source.value.shortCurrencyString(currencyCode: entry.currencyCode))
|
||||
@@ -831,14 +831,14 @@ struct MediumWidgetView: View {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "lightbulb.fill")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.orange)
|
||||
.foregroundStyle(.orange)
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text(entry.insightTitle)
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(entry.insightValue)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundColor(.primary)
|
||||
.foregroundStyle(.primary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
@@ -874,7 +874,7 @@ struct LargeWidgetView: View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Portfolio")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Text(entry.totalValue.compactCurrencyString(currencyCode: entry.currencyCode))
|
||||
.font(.title2.weight(.bold))
|
||||
@@ -914,7 +914,7 @@ struct LargeWidgetView: View {
|
||||
|
||||
Text(category.name)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Spacer()
|
||||
|
||||
@@ -929,7 +929,7 @@ struct LargeWidgetView: View {
|
||||
.font(.caption.weight(.semibold))
|
||||
Text("Category evolution appears after updates.")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
|
||||
}
|
||||
@@ -939,7 +939,7 @@ struct LargeWidgetView: View {
|
||||
.font(.caption.weight(.semibold))
|
||||
Text("Premium shows evolution by category.")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
|
||||
}
|
||||
@@ -1007,7 +1007,7 @@ struct AccessoryRectangularView: View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Portfolio")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Text(entry.totalValue.compactCurrencyString(currencyCode: entry.currencyCode))
|
||||
.font(.headline)
|
||||
@@ -1019,7 +1019,7 @@ struct AccessoryRectangularView: View {
|
||||
Text(String(format: "%.1f%%", entry.dayChangePercentage))
|
||||
.font(.caption2)
|
||||
}
|
||||
.foregroundColor(entry.dayChange >= 0 ? .green : .red)
|
||||
.foregroundStyle(entry.dayChange >= 0 ? .green : .red)
|
||||
}
|
||||
.containerBackground(.background, for: .widget)
|
||||
}
|
||||
@@ -1057,11 +1057,11 @@ struct TrendLineChartView: View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(Decimal(maxValue).shortCurrencyString(currencyCode: currencyCode))
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
Text(Decimal(minValue).shortCurrencyString(currencyCode: currencyCode))
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
VStack(spacing: 4) {
|
||||
@@ -1109,7 +1109,7 @@ struct TrendLineChartView: View {
|
||||
ForEach(labels.indices, id: \.self) { index in
|
||||
Text(labels[index])
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
@@ -1154,11 +1154,11 @@ struct CombinedCategoryChartView: View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(Decimal(maxValue).shortCurrencyString(currencyCode: currencyCode))
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
Text(Decimal(0).shortCurrencyString(currencyCode: currencyCode))
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
VStack(spacing: 4) {
|
||||
@@ -1220,7 +1220,7 @@ struct CombinedCategoryChartView: View {
|
||||
ForEach(labels.indices, id: \.self) { index in
|
||||
Text(labels[index])
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
@@ -1241,7 +1241,7 @@ struct WidgetRefreshButton: View {
|
||||
Button(intent: RefreshWidgetIntent()) {
|
||||
Image(systemName: "arrow.clockwise")
|
||||
.font(.caption2.weight(.semibold))
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.invalidatableContent()
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
# Release notes — 1.7.0 (build 89)
|
||||
|
||||
Sin emojis: App Store Connect / TestFlight rechazan las notas con emoji (HTTP 409).
|
||||
|
||||
## en-US
|
||||
|
||||
Ready for iPhone Duo. On the outer screen the app keeps the phone layout with the
|
||||
new iOS 27 side tab bar; on the inner screen Sources and Journal show the list and
|
||||
the detail side by side, Charts gain a sidebar with taller, wider plots, and your
|
||||
place is kept when you open or close the phone. Quick Update opens in a side panel
|
||||
next to your dashboard where there is room, and as a sheet when there is not.
|
||||
• Built for iOS 27 with the new Liquid Glass tab bar and toolbars.
|
||||
• Face ID, Touch ID or Optic ID: App Lock and balance privacy use whichever
|
||||
biometrics your device has, with the matching name and icon.
|
||||
• No ads, no analytics, no tracking SDKs: they are gone from the app.
|
||||
• Layout adapts to any window size, Split View and Stage Manager.
|
||||
• Bug fixes and performance improvements.
|
||||
|
||||
## es-ES
|
||||
|
||||
Preparada para iPhone Duo. En la pantalla exterior mantiene el layout de teléfono
|
||||
con la nueva barra de pestañas lateral de iOS 27; en la interior, Fuentes y Diario
|
||||
muestran lista y detalle a la vez, Gráficos gana una barra lateral con gráficos más
|
||||
altos y anchos, y no pierdes el sitio al abrir o cerrar el teléfono. La actualización
|
||||
rápida se abre en un panel lateral junto al panel principal cuando hay sitio, y como
|
||||
hoja cuando no lo hay.
|
||||
• Compilada para iOS 27 con la nueva barra de pestañas y toolbars Liquid Glass.
|
||||
• Face ID, Touch ID u Optic ID: el bloqueo de la app y la privacidad de saldos usan
|
||||
la biometría que tenga tu dispositivo, con su nombre y su icono.
|
||||
• Sin anuncios, sin analíticas y sin SDK de rastreo: se han eliminado de la app.
|
||||
• La interfaz se adapta a cualquier tamaño de ventana, Split View y Stage Manager.
|
||||
• Correcciones de errores y mejoras de rendimiento.
|
||||
|
||||
Los ficheros que sube fastlane están en `fastlane/metadata/<locale>/release_notes.txt`
|
||||
(en-US, en-GB, en-AU, en-CA, es-ES, es-MX actualizados; de-DE, fr-*, it, ja, pt-*
|
||||
pendientes de traducir).
|
||||
@@ -1,106 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Crashlytics report for Portfolio Journal via the BigQuery export.
|
||||
|
||||
Setup (one-time, already done 2026-07-13):
|
||||
- Firebase console → project portfoliojournal-ef2d7 → Integrations → BigQuery:
|
||||
enable the Crashlytics export (requires Blaze plan).
|
||||
- Grant the service account sirius@sirius-vazquez.iam BigQuery Data Viewer +
|
||||
Job User on project portfoliojournal-ef2d7.
|
||||
|
||||
Creds come from `pass show google/sirius-service-account`. The export table is
|
||||
created on the first crash after enabling, named like
|
||||
`firebase_crashlytics.com_alexandrevazquez_PortfolioJournal_IOS`.
|
||||
|
||||
Usage: python3 Scripts/analyze_crashlytics.py [days] # default 30
|
||||
"""
|
||||
import subprocess, json, sys, urllib.request, urllib.error
|
||||
|
||||
PROJECT = "portfoliojournal-ef2d7"
|
||||
DATASET = "firebase_crashlytics"
|
||||
DAYS = int(sys.argv[1]) if len(sys.argv) > 1 else 30
|
||||
|
||||
|
||||
def creds_token():
|
||||
raw = subprocess.run(["pass", "show", "google/sirius-service-account"],
|
||||
capture_output=True, text=True).stdout
|
||||
try:
|
||||
info = json.loads(raw)
|
||||
except Exception:
|
||||
out, ins, esc = [], False, False
|
||||
for c in raw:
|
||||
if esc: out.append(c); esc = False
|
||||
elif c == "\\": out.append(c); esc = True
|
||||
elif c == '"': ins = not ins; out.append(c)
|
||||
elif ins and c == "\n": out.append("\\n")
|
||||
elif ins and c == "\r": pass
|
||||
else: out.append(c)
|
||||
info = json.loads("".join(out))
|
||||
from google.oauth2 import service_account
|
||||
import google.auth.transport.requests
|
||||
c = service_account.Credentials.from_service_account_info(
|
||||
info, scopes=["https://www.googleapis.com/auth/cloud-platform"])
|
||||
c.refresh(google.auth.transport.requests.Request())
|
||||
return c.token
|
||||
|
||||
|
||||
def api(token, path):
|
||||
req = urllib.request.Request(
|
||||
f"https://bigquery.googleapis.com/bigquery/v2/projects/{PROJECT}{path}",
|
||||
headers={"Authorization": f"Bearer {token}"})
|
||||
return json.load(urllib.request.urlopen(req))
|
||||
|
||||
|
||||
def query(token, sql):
|
||||
body = json.dumps({"query": sql, "useLegacySql": False, "timeoutMs": 60000}).encode()
|
||||
req = urllib.request.Request(
|
||||
f"https://bigquery.googleapis.com/bigquery/v2/projects/{PROJECT}/queries",
|
||||
data=body, headers={"Authorization": f"Bearer {token}",
|
||||
"Content-Type": "application/json"})
|
||||
d = json.load(urllib.request.urlopen(req))
|
||||
cols = [f["name"] for f in d.get("schema", {}).get("fields", [])]
|
||||
rows = [[c.get("v") for c in r["f"]] for r in d.get("rows", [])]
|
||||
return cols, rows
|
||||
|
||||
|
||||
def main():
|
||||
token = creds_token()
|
||||
try:
|
||||
td = api(token, f"/datasets/{DATASET}/tables?maxResults=100")
|
||||
except urllib.error.HTTPError as e:
|
||||
print(f"No access to {DATASET} (HTTP {e.code}).")
|
||||
return
|
||||
tables = [t["tableReference"]["tableId"] for t in td.get("tables", [])]
|
||||
crash_tables = [t for t in tables if "IOS" in t or "ios" in t.lower()]
|
||||
if not crash_tables:
|
||||
print("No Crashlytics export tables yet — no crashes exported since the "
|
||||
"export was enabled. (Good news.)")
|
||||
return
|
||||
table = crash_tables[0]
|
||||
print(f"== Crashlytics {table} — últimos {DAYS}d ==\n")
|
||||
|
||||
sql = f"""
|
||||
SELECT
|
||||
issue_title, issue_subtitle,
|
||||
application.display_version AS version,
|
||||
COUNT(*) AS events,
|
||||
COUNT(DISTINCT installation_uuid) AS users,
|
||||
COUNTIF(error_type = 'FATAL') AS fatal
|
||||
FROM `{PROJECT}.{DATASET}.{table}`
|
||||
WHERE event_timestamp >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL {DAYS} DAY)
|
||||
GROUP BY issue_title, issue_subtitle, version
|
||||
ORDER BY events DESC
|
||||
LIMIT 25
|
||||
"""
|
||||
cols, rows = query(token, sql)
|
||||
if not rows:
|
||||
print("Sin crashes en el periodo.")
|
||||
return
|
||||
for r in rows:
|
||||
title, sub, ver, events, users, fatal = r
|
||||
print(f"[{ver}] {title} — {sub}")
|
||||
print(f" {events} eventos · {users} usuarios · {fatal} fatales\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,185 +0,0 @@
|
||||
"""
|
||||
GA4 Analytics report for Portfolio Journal
|
||||
Property: 300195945
|
||||
"""
|
||||
|
||||
import os
|
||||
from datetime import date, timedelta
|
||||
from google.analytics.data_v1beta import BetaAnalyticsDataClient
|
||||
from google.analytics.data_v1beta.types import (
|
||||
RunReportRequest, Dimension, Metric, DateRange, OrderBy, FilterExpression, Filter
|
||||
)
|
||||
|
||||
PROPERTY_ID = "519342587"
|
||||
|
||||
import subprocess, json, tempfile
|
||||
|
||||
def _fix_multiline_json(raw):
|
||||
result, in_string, escape_next = [], False, False
|
||||
for c in raw:
|
||||
if escape_next:
|
||||
result.append(c); escape_next = False
|
||||
elif c == '\\':
|
||||
result.append(c); escape_next = True
|
||||
elif c == '"':
|
||||
in_string = not in_string; result.append(c)
|
||||
elif in_string and c == '\n':
|
||||
result.append('\\n')
|
||||
elif in_string and c == '\r':
|
||||
pass
|
||||
else:
|
||||
result.append(c)
|
||||
return ''.join(result)
|
||||
|
||||
key_raw = subprocess.check_output(["pass", "show", "google/sirius-service-account"]).decode()
|
||||
key_json = json.dumps(json.loads(_fix_multiline_json(key_raw)))
|
||||
_tmp = tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False)
|
||||
_tmp.write(key_json); _tmp.flush()
|
||||
os.environ["GOOGLE_APPLICATION_CREDENTIALS"] = _tmp.name
|
||||
client = BetaAnalyticsDataClient()
|
||||
|
||||
TODAY = date.today().isoformat()
|
||||
D7 = (date.today() - timedelta(days=7)).isoformat()
|
||||
D28 = (date.today() - timedelta(days=28)).isoformat()
|
||||
D90 = (date.today() - timedelta(days=90)).isoformat()
|
||||
|
||||
def run(dimensions, metrics, date_from, date_to=TODAY, order_by=None, limit=20):
|
||||
req = RunReportRequest(
|
||||
property=f"properties/{PROPERTY_ID}",
|
||||
dimensions=[Dimension(name=d) for d in dimensions],
|
||||
metrics=[Metric(name=m) for m in metrics],
|
||||
date_ranges=[DateRange(start_date=date_from, end_date=date_to)],
|
||||
limit=limit,
|
||||
)
|
||||
if order_by:
|
||||
req.order_bys = order_by
|
||||
return client.run_report(req)
|
||||
|
||||
def run_no_dim(metrics, date_from, date_to=TODAY):
|
||||
req = RunReportRequest(
|
||||
property=f"properties/{PROPERTY_ID}",
|
||||
metrics=[Metric(name=m) for m in metrics],
|
||||
date_ranges=[DateRange(start_date=date_from, end_date=date_to)],
|
||||
)
|
||||
return client.run_report(req)
|
||||
|
||||
def header(title):
|
||||
print(f"\n{'='*60}")
|
||||
print(f" {title}")
|
||||
print('='*60)
|
||||
|
||||
def fmt_row(row, widths):
|
||||
parts = [str(v.value).ljust(w) for v, w in zip(list(row.dimension_values) + list(row.metric_values), widths)]
|
||||
print(" " + " ".join(parts))
|
||||
|
||||
# ── 1. Overview ────────────────────────────────────────────────
|
||||
header("OVERVIEW — últimos 28 días vs 7 días")
|
||||
|
||||
r28 = run_no_dim(["activeUsers", "sessions", "screenPageViews", "averageSessionDuration"], D28)
|
||||
r7 = run_no_dim(["activeUsers", "sessions", "screenPageViews", "averageSessionDuration"], D7)
|
||||
|
||||
def ovw(r):
|
||||
row = r.rows[0]
|
||||
vals = [v.value for v in row.metric_values]
|
||||
return vals
|
||||
|
||||
v28 = ovw(r28)
|
||||
v7 = ovw(r7)
|
||||
labels = ["Usuarios activos", "Sesiones", "Pantallas vistas", "Duración media (s)"]
|
||||
print(f"\n {'Métrica':<30} {'28d':>10} {'7d':>10}")
|
||||
print(" " + "-"*52)
|
||||
for l, a, b in zip(labels, v28, v7):
|
||||
print(f" {l:<30} {a:>10} {b:>10}")
|
||||
|
||||
# ── 2. Pantallas más visitadas ──────────────────────────────────
|
||||
header("PANTALLAS MÁS VISITADAS — últimos 28d")
|
||||
r = run(["unifiedScreenName"], ["screenPageViews", "activeUsers"], D28,
|
||||
order_by=[OrderBy(metric=OrderBy.MetricOrderBy(metric_name="screenPageViews"), desc=True)])
|
||||
print(f"\n {'Pantalla':<40} {'Vistas':>8} {'Usuarios':>10}")
|
||||
print(" " + "-"*60)
|
||||
for row in r.rows:
|
||||
fmt_row(row, [40, 8, 10])
|
||||
|
||||
# ── 3. Eventos clave ────────────────────────────────────────────
|
||||
header("EVENTOS CLAVE — últimos 28d")
|
||||
r = run(["eventName"], ["eventCount", "totalUsers"], D28,
|
||||
order_by=[OrderBy(metric=OrderBy.MetricOrderBy(metric_name="eventCount"), desc=True)],
|
||||
limit=30)
|
||||
print(f"\n {'Evento':<45} {'Count':>8} {'Usuarios':>10}")
|
||||
print(" " + "-"*65)
|
||||
for row in r.rows:
|
||||
fmt_row(row, [45, 8, 10])
|
||||
|
||||
# ── 4. Funnel paywall ───────────────────────────────────────────
|
||||
header("FUNNEL PAYWALL — últimos 90d")
|
||||
paywall_events = [
|
||||
"paywall_shown", "purchase_attempt", "purchase_success", "purchase_failure",
|
||||
"restore_purchases"
|
||||
]
|
||||
r = run(["eventName"], ["eventCount", "totalUsers"], D90,
|
||||
order_by=[OrderBy(metric=OrderBy.MetricOrderBy(metric_name="eventCount"), desc=True)],
|
||||
limit=50)
|
||||
print(f"\n {'Evento paywall':<40} {'Count':>8} {'Usuarios':>10}")
|
||||
print(" " + "-"*60)
|
||||
for row in r.rows:
|
||||
name = row.dimension_values[0].value
|
||||
if any(p in name for p in ["paywall", "purchase", "restore", "premium"]):
|
||||
fmt_row(row, [40, 8, 10])
|
||||
|
||||
# ── 5. Triggers del paywall ─────────────────────────────────────
|
||||
header("TRIGGERS DEL PAYWALL — últimos 90d (qué feature lo dispara)")
|
||||
try:
|
||||
r = run(["eventName", "customEvent:paywall_trigger"], ["eventCount"], D90,
|
||||
order_by=[OrderBy(metric=OrderBy.MetricOrderBy(metric_name="eventCount"), desc=True)],
|
||||
limit=30)
|
||||
rows = [row for row in r.rows if "paywall" in row.dimension_values[0].value]
|
||||
if rows:
|
||||
print(f"\n {'Evento':<30} {'Trigger':<25} {'Count':>8}")
|
||||
print(" " + "-"*65)
|
||||
for row in rows:
|
||||
fmt_row(row, [30, 25, 8])
|
||||
else:
|
||||
print("\n (sin eventos paywall con trigger en este periodo — los datos aparecerán desde hoy)")
|
||||
except Exception as e:
|
||||
print(f"\n Error: {e}")
|
||||
|
||||
# ── 6. Retención — nuevos vs recurrentes ───────────────────────
|
||||
header("NUEVOS vs RECURRENTES — últimos 28d")
|
||||
r = run(["newVsReturning"], ["activeUsers", "sessions", "screenPageViews"], D28,
|
||||
order_by=[OrderBy(metric=OrderBy.MetricOrderBy(metric_name="activeUsers"), desc=True)])
|
||||
print(f"\n {'Tipo':<20} {'Usuarios':>10} {'Sesiones':>10} {'Pantallas':>10}")
|
||||
print(" " + "-"*54)
|
||||
for row in r.rows:
|
||||
fmt_row(row, [20, 10, 10, 10])
|
||||
|
||||
# ── 7. Versiones de app ─────────────────────────────────────────
|
||||
header("VERSIONES EN USO — últimos 28d")
|
||||
r = run(["appVersion"], ["activeUsers", "sessions"], D28,
|
||||
order_by=[OrderBy(metric=OrderBy.MetricOrderBy(metric_name="activeUsers"), desc=True)],
|
||||
limit=10)
|
||||
print(f"\n {'Versión':<20} {'Usuarios':>10} {'Sesiones':>10}")
|
||||
print(" " + "-"*44)
|
||||
for row in r.rows:
|
||||
fmt_row(row, [20, 10, 10])
|
||||
|
||||
# ── 8. Dispositivos ─────────────────────────────────────────────
|
||||
header("DISPOSITIVOS — últimos 28d")
|
||||
r = run(["deviceModel"], ["activeUsers"], D28,
|
||||
order_by=[OrderBy(metric=OrderBy.MetricOrderBy(metric_name="activeUsers"), desc=True)],
|
||||
limit=10)
|
||||
print(f"\n {'Dispositivo':<30} {'Usuarios':>10}")
|
||||
print(" " + "-"*44)
|
||||
for row in r.rows:
|
||||
fmt_row(row, [30, 10])
|
||||
|
||||
# ── 9. País / región ────────────────────────────────────────────
|
||||
header("PAÍSES — últimos 28d")
|
||||
r = run(["country"], ["activeUsers"], D28,
|
||||
order_by=[OrderBy(metric=OrderBy.MetricOrderBy(metric_name="activeUsers"), desc=True)],
|
||||
limit=10)
|
||||
print(f"\n {'País':<30} {'Usuarios':>10}")
|
||||
print(" " + "-"*44)
|
||||
for row in r.rows:
|
||||
fmt_row(row, [30, 10])
|
||||
|
||||
print("\n")
|
||||
@@ -101,8 +101,8 @@ create_export_options() {
|
||||
<false/>
|
||||
<key>method</key>
|
||||
<string>app-store-connect</string>
|
||||
<!-- Firma MANUAL obligatoria: el cloud signing con API key falla al exportar la
|
||||
extensión QuickUpdate ("No profiles ... were found"). Los 3 targets se firman
|
||||
<!-- 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>
|
||||
@@ -116,6 +116,10 @@ create_export_options() {
|
||||
<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/>
|
||||
@@ -158,6 +162,23 @@ export_ipa() {
|
||||
-authenticationKeyIssuerID "$ISSUER_ID"
|
||||
}
|
||||
|
||||
validate_ipa() {
|
||||
local ipa
|
||||
ipa="$(find "$EXPORT_PATH" -maxdepth 1 -name '*.ipa' -print -quit)"
|
||||
[[ -n "$ipa" ]] || fail "No se encontro ningun .ipa en $EXPORT_PATH"
|
||||
|
||||
# Validar SIEMPRE antes de subir: pilla iconos ausentes, orientaciones de iPad
|
||||
# incompletas e Info.plist mal formados sin gastar un numero de build.
|
||||
echo "==> Validando $(basename "$ipa")"
|
||||
API_PRIVATE_KEYS_DIR="$P8_DIR" \
|
||||
xcrun altool \
|
||||
--validate-app \
|
||||
--type ios \
|
||||
--file "$ipa" \
|
||||
--apiKey "$KEY_ID" \
|
||||
--apiIssuer "$ISSUER_ID"
|
||||
}
|
||||
|
||||
upload_ipa() {
|
||||
local ipa
|
||||
ipa="$(find "$EXPORT_PATH" -maxdepth 1 -name '*.ipa' -print -quit)"
|
||||
@@ -189,6 +210,7 @@ main() {
|
||||
create_export_options
|
||||
archive_app
|
||||
export_ipa
|
||||
validate_ipa
|
||||
upload_ipa
|
||||
|
||||
echo "==> Proceso completado"
|
||||
|
||||
@@ -86,7 +86,7 @@ struct WatchPortfolioSnapshot: Codable, Equatable {
|
||||
/// Message keys exchanged over WatchConnectivity.
|
||||
enum WatchSyncMessage {
|
||||
/// The watch asking the phone for a freshly built snapshot.
|
||||
static let requestSnapshot = "requestSnapshot"
|
||||
nonisolated static let requestSnapshot = "requestSnapshot"
|
||||
}
|
||||
|
||||
// MARK: - Transport
|
||||
|
||||
@@ -28,8 +28,9 @@ EIN INVESTITIONSTAGEBUCH FÜHREN
|
||||
|
||||
DATENSCHUTZ BY DESIGN
|
||||
• Kein Konto, kein Login, keine Registrierung erforderlich.
|
||||
• Keine Analyse, kein Tracking, kein Datenverkauf — niemals.
|
||||
• iCloud-Sync ist optional: Du bestimmst, und alles ist Ende-zu-Ende-verschlüsselt.
|
||||
• Keine Werbung und keine Analyse durch Dritte: Die App enthält kein Werbe- oder Tracking-SDK.
|
||||
• Keine Server von uns. Deine Daten bleiben auf deinem Gerät.
|
||||
• iCloud-Sync ist optional: Er nutzt deine eigene private iCloud-Datenbank, von Apple verschlüsselt.
|
||||
• Funktioniert 100% offline.
|
||||
|
||||
FÜR PASSIVE ANLEGER ENTWICKELT
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user