18 Commits

Author SHA1 Message Date
alexandrev-tibco 4b8873eb84 Fase 2 Duo: verificado con SDK 27.1 en el simulador real y biometria por hardware
Compila con Xcode 27.1 beta (SDK iOS 27.1) sin errores ni warnings nuevos, y
`DuoLayoutUITests` pasa en el simulador de iPhone Duo: la pantalla exterior mide
466 x 678 pt y es compact x regular, iOS 27 pone la barra de pestanas y la
toolbar en vertical en el borde derecho, el `.inspector` de Quick Update cae a
hoja como debe, Fuentes y Diario colapsan a una columna y no queda hueco al pie
donde estaba el banner de AdMob.

- DuoLayoutUITests registra el tamano de ventana en puntos y baja hasta "App
  Lock" para comparar el texto del toggle con `LAContext.biometryType`: en el
  Duo dice "Touch ID" (verificado en captura).
- WhatsNewView deja de llevar el icono `touchid` fijo: sale de
  `AppLockService.biometryKind`, igual que los textos.
- Release notes sin emoji (TestFlight devuelve 409) en RELEASE_NOTES.md y en
  fastlane/metadata/{en-US,en-GB,en-AU,en-CA,es-ES,es-MX}.
- DUO_STATUS documenta las medidas reales, por que se descartan ReservedRegion,
  ArrangementView y ConcentricRectangle, y que la pantalla interior (669 x 951
  pt) no se puede ejercitar: el simulador arranca plegado y no hay forma de
  cambiar la pose desde linea de comandos.

No se sube version ni build: las builds de App Store no pueden ir firmadas con
un SDK beta.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F1u4K16xy7eQVtgsYNZ9Vn
2026-09-19 00:57:25 +02:00
alexandrev-tibco 8daa80bb42 Firma manual de los targets del Apple Watch para App Store
Los dos targets de watchOS seguían en firma automática y el export no tenía
sus perfiles, así que la build 88 nunca llegó a subirse. Ahora:

- PortfolioJournalWatch y PortfolioJournalWatchWidget en Release con
  CODE_SIGN_STYLE = Manual y sus perfiles App Store recién creados en el
  portal ("Portfolio Journal Watch App Store" / "... Watch Widget App Store").
- CODE_SIGN_IDENTITY con el nombre completo "Apple Distribution: Alexandre
  Vazquez" en los cinco targets embebidos, en vez del genérico.
- ExportOptions.plist y el script de release con los CINCO bundle ids en
  provisioningProfiles (faltaban los dos del reloj).
- El script valida con `altool --validate-app` antes de subir, para no gastar
  un número de build en un IPA que la App Store va a rechazar.

Con la firma totalmente manual y los perfiles instalados en local, el archive
ya no necesita -allowProvisioningUpdates ni la API key, así que esquiva el bug
del bearer token de xcodebuild.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F1u4K16xy7eQVtgsYNZ9Vn
2026-09-18 23:02:04 +02:00
alexandrev-tibco 12386673ff textos de privacidad honestos + PrivacyInfo.xcprivacy
La ficha decía "Sin analíticas, sin rastreo, sin venta de datos — nunca" y
"la sincronización con iCloud … está cifrada de extremo a extremo". Lo
primero ya es verdad tras quitar los SDKs; lo segundo sigue sin serlo: es
la base privada de CloudKit, cifrado gestionado por Apple, no cifrado
nuestro de extremo a extremo.

fastlane/metadata — bloque de privacidad reescrito en las 13
localizaciones (en-US/GB/AU/CA, es-ES/MX, de-DE, fr-FR/CA, it, ja,
pt-BR/PT): sin cuentas, sin anuncios ni analítica de terceros, sin
servidores nuestros, datos en el dispositivo, iCloud opcional cifrado por
Apple. No se sube a ASC: los ficheros quedan en el repo.

Copias de seguridad — se elige decir la verdad en vez de cifrarlas: son
JSON legible que el usuario controla y puede abrir con cualquier editor.
Cifrarlas obligaría a gestionar una clave (Keychain o frase del usuario)
y convertiría una copia recuperable a mano en una que se pierde con la
clave; el pie de Ajustes ahora avisa de que no están cifradas.

PrivacyInfo.xcprivacy — Apple lo exige y la app no tenía ninguno. Declara
NSPrivacyTracking false, sin dominios de rastreo, sin tipos de datos
recogidos, y solo las API de motivo requerido que se usan de verdad:
FileTimestamp (CoreData, exportaciones, backups, diagnósticos) y
UserDefaults (preferencias y App Group).

Closes #52

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F1u4K16xy7eQVtgsYNZ9Vn
2026-09-18 16:10:17 +02:00
alexandrev-tibco 2380d37a9e sustituir Crashlytics por un recolector MetricKit que no sale del dispositivo
FirebaseCrashlytics se llevaba los informes de fallo a Google. Los crashes
ya llegan a Xcode Organizer por la vía de Apple, así que el sustituto no
tiene que enviar nada: DiagnosticsCollector se suscribe a MXMetricManager,
guarda los payloads de crash, cuelgue y escritura en disco como JSON en
Application Support/Diagnostics y ahí se quedan.

- Sin código de red. Retención de 5 informes, los ficheros se marcan como
  excluidos de backup (son ayuda de depuración, no datos del usuario).
- Arranque: AppDelegate llama a DiagnosticsCollector.shared.start() en
  lugar de FirebaseApp.configure() + MobileAds.shared.start().
- Ajustes → Acerca de: "Compartir diagnósticos", visible solo cuando hay
  algo recogido. Compartir es la única forma de que un informe salga del
  iPhone, y la decide el usuario. Cadenas nuevas en los 7 idiomas.

Un recolector que guarda y no enseña sería código muerto, y uno que sube
sería el problema de antes con otro nombre; por eso guarda en local y deja
el envío como acción explícita.

Closes #51

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F1u4K16xy7eQVtgsYNZ9Vn
2026-09-18 16:09:45 +02:00
alexandrev-tibco 7d2f605c16 quitar AdMob, Firebase Analytics y ATT de la app
"Portfolio Journal sin AdMob ni Google: es la única forma de que 'sin
rastreo' sea verdad, y el ingreso por anuncios es despreciable" — la ficha
de App Store prometía "sin analíticas, sin rastreo, sin venta de datos"
mientras la app enlazaba GoogleMobileAds y FirebaseAnalytics, pedía permiso
de rastreo al arrancar y mandaba el importe del saldo a GA4 en el evento
snapshot_added.

SDKs fuera del proyecto (project.pbxproj): paquetes firebase-ios-sdk y
swift-package-manager-google-mobile-ads, productos FirebaseCore,
FirebaseAnalytics, GoogleMobileAds y FirebaseCrashlytics, más la fase de
build "Upload dSYMs to Crashlytics". Package.resolved se queda sin nada que
resolver. Con la fase de script fuera, ENABLE_USER_SCRIPT_SANDBOXING vuelve
a YES en el target app (estaba en NO solo por Crashlytics).

Código borrado:
- Services/AdMobService.swift entero — con él se van el flujo UMP,
  BannerAdView, BannerAdCoordinator y la llamada a
  ATTrackingManager.requestTrackingAuthorization().
- Services/FirebaseService.swift entero y sus 40 llamadas. Se borra en vez
  de dejarse como capa vacía: una clase llamada FirebaseService en una app
  que presume de no llevar Firebase es exactamente la clase de detalle que
  vuelve a colarse en una auditoría dentro de un año.
- El banner y su safeAreaInset en ContentView (bannerInsetView): las cinco
  pestañas ya no reservan 50 pt al pie.
- La entrada "Manage Ad Consent" de Ajustes y el @EnvironmentObject
  adMobService de SettingsView, ContentView y PortfolioJournalApp.
- AppConstants: bloque AdMob, bannerAdHeight, adConsentObtained,
  Features.enableAnalytics.
- SettingsViewModel.toggleAnalytics y analyticsEnabled — el flag no estaba
  conectado a nada ni tenía control en la interfaz. El atributo
  AppSettings.enableAnalytics se queda en CoreData con un comentario: no
  vale la pena una migración y un deploy de esquema CloudKit por borrarlo.
- Premium ya no promete "sin anuncios": fuera PremiumFeature.noAds, la
  entrada de IAPService.premiumFeatures y paywallBenefits, y las claves
  feature_no_ads / paywall_benefit_noads de los 7 idiomas. No se toca ni el
  precio ni el producto.

Info.plist: fuera NSUserTrackingUsageDescription, GADApplicationIdentifier,
GADDelayAppMeasurementInit y los 65 SKAdNetworkItems. Borrados también
GoogleService-Info.plist y los scripts Scripts/analyze_ga4.py y
Scripts/analyze_crashlytics.py, que ya no tienen de dónde leer.

Closes #50

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F1u4K16xy7eQVtgsYNZ9Vn
2026-09-18 16:09:04 +02:00
alexandrev-tibco 319ab9589e Columna lateral de 300 pt para que iOS 27 no la esconda en iPad mini
En iOS 27, NavigationSplitView(.balanced) con la barra lateral en ideal 340 pt
deja la columna fuera de pantalla en regular estrecho (iPad mini vertical,
744 pt): la app abre solo el detalle ("Select a Month" / "Select a Source")
y el recorrido DuoLayoutUITests fallaba al no encontrar el mes. En 26.5 se
mostraban las dos columnas. Con min 280 / ideal 300 el sistema vuelve a
acoplar la lista junto al detalle.

Verificado en iOS 27.0: DuoLayoutUITests pasa en iPad mini (27) y en
iPhone 17 Pro (27).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F1u4K16xy7eQVtgsYNZ9Vn
2026-09-17 13:48:36 +02:00
alexandrev-tibco e9e3e5da66 DUO_STATUS: registrar la verificación en simulador
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F1u4K16xy7eQVtgsYNZ9Vn
2026-09-16 15:30:47 +02:00
alexandrev-tibco 15a6d7395e Fix selección de mes en Diario y dashboard a 1 columna cuando hay panel lateral
- JournalView: el .id() extra sobre las filas envolvía el .tag y la selección
  de la List no navegaba (ni en compacto ni en regular). ForEach ya aporta la
  identidad que usa el ScrollViewReader.
- DashboardView: masonry pasa a 1 columna por debajo de 600 pt (inspector de
  Quick Update abierto, Split View) en vez de apretar dos tarjetas.
- DuoLayoutUITests: selecciona el mes anterior por etiqueta y comprueba que se
  abre el check-in.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F1u4K16xy7eQVtgsYNZ9Vn
2026-09-16 15:30:10 +02:00
alexandrev-tibco 8828a2aa0f Versión 1.7.0 (build 89): release notes iPhone Duo y DUO_STATUS
- MARKETING_VERSION 1.7.0 / CURRENT_PROJECT_VERSION 89 en todos los targets.
- Release notes en-US/en-GB/en-AU/en-CA y es-ES/es-MX con iPhone Duo como
  primer bullet; RELEASE_NOTES.md y CHANGELOG.md.
- DUO_STATUS.md: qué se adaptó, escenarios regular×regular, pendiente fase 2
  (Xcode 27.1) y cómo probarlo.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F1u4K16xy7eQVtgsYNZ9Vn
2026-09-16 15:13:10 +02:00
alexandrev-tibco 171023a411 Tests: corrige 3 tests desfasados y añade recorrido UI del layout adaptativo
- ReviewPromptServiceTests: la regla vigente pide 2 check-ins, no 3.
- MonthlyCheckInViewTests: el día 1 cae en el periodo de gracia (20 días);
  usar el día 25 para etiquetar febrero.
- SettingsViewModelTests: isPremium llega por receive(on: main); dar una
  vuelta de run loop antes de actuar.
- DuoLayoutUITests: recorre tabs, split views e inspector con capturas
  (ejecutar en iPhone 17 Pro y iPad mini). ScreenshotMode oculta los tips de
  TipKit; identificador de accesibilidad en el botón de Quick Update.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F1u4K16xy7eQVtgsYNZ9Vn
2026-09-16 15:13:10 +02:00
alexandrev-tibco 6774d76146 Biometría genérica (Face ID / Touch ID / Optic ID) y What's New 1.7.0
- AppLockService.biometryKind lee LAContext.biometryType; textos e icono del
  bloqueo y de la privacidad de saldos ya no asumen Face ID (Duo puede llevar
  Touch ID). Nuevas claves biometric_* en los 7 idiomas.
- Entrada 1.7.0 en el catálogo What's New (Duo, inspector, biometría) con
  strings en los 7 idiomas.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F1u4K16xy7eQVtgsYNZ9Vn
2026-09-16 11:44:50 +02:00
alexandrev-tibco 680255f69d iPhone Duo (fase 1): navegación adaptativa por size class y layouts regular×regular
- ContentView: un único TabView (sidebarAdaptable en iOS 18+) para todas las
  size classes; desaparece el cambio de jerarquía iPhone/iPad, así abrir o
  cerrar el Duo conserva el estado de cada pestaña. @SceneStorage de la tab.
- Fuentes y Diario: NavigationSplitView (lista + detalle a la vez en ancho
  regular, stack colapsable en compacto) con selección nativa de List.
- Dashboard: Quick Update como .inspector (panel lateral junto a los gráficos
  en regular, sheet en compacto).
- Gráficos: chartHeightScale (+35% de alto en regular) vía chartFrame(height:),
  sin ignoresSafeArea en contenido interactivo.
- Liquid Glass (glassEffect) en las etiquetas flotantes del Diario en iOS 26+.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F1u4K16xy7eQVtgsYNZ9Vn
2026-09-16 11:44:50 +02:00
alexandrev-tibco a8a9ad64f3 Xcode 27: compila sin warnings y migra API deprecadas de SwiftUI
- Imports de CoreData que faltaban (MemberImportVisibility) en AccountsView,
  ImportDataView, ChartsContainerView y AddSourceView.
- Aislamiento MainActor: IsolatedDefaultValues en el target de la app,
  LinearGradient @MainActor, WatchSyncMessage nonisolated, closures de
  ReviewPromptService/MonthlyCheckInStore/AppIntents.
- Warnings de valores sin usar (SnapshotGapDetector, DashboardLayoutStore,
  OnboardingView, GoalEditorView, CoreDataStack, SettingsViewModel).
- foregroundColor→foregroundStyle, cornerRadius→clipShape,
  navigationBarLeading/Trailing→topBarLeading/Trailing,
  Task.sleep(nanoseconds:)→sleep(for:), NavigationLink(isActive:)→
  navigationDestination(isPresented:).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F1u4K16xy7eQVtgsYNZ9Vn
2026-09-16 11:44:49 +02:00
alexandrev-tibco 464fbb2bad App de Apple Watch + 3 complicaciones (build 88) #48
App de watchOS de solo consulta: resumen de cartera (total, cambio del
mes y mayores posiciones), objetivos con su progreso y estado del
check-in con la racha. Toda la edición sigue en el iPhone.

Complicaciones (accessoryCircular / Rectangular / Inline / Corner):
valor total de la cartera, progreso del objetivo más cercano a cumplirse
y racha + estado del check-in.

Datos: los App Groups no se comparten entre iOS y watchOS, así que el
reloj no puede leer el store de CoreData que lee el widget de iOS. En su
lugar, WatchSyncService construye un WatchPortfolioSnapshot ligero y lo
envía como application context de WatchConnectivity, enganchado a
CoreDataStack.refreshWidgetData() — el mismo punto que refresca el widget
de iOS, así que cualquier repositorio que toque datos refresca el reloj.
WatchDataStore lo cachea en el App Group del watch (WatchSnapshotCache) y
recarga las timelines; la extensión de complicaciones solo lee ese caché.

Proyecto: dos targets nuevos (watchOS app + widget extension) con grupos
sincronizados de Xcode 16; Shared/ pertenece a la app de watch y se
comparte con la app de iOS y el widget mediante exception sets, el mismo
mecanismo que ya usaba el widget de iOS para el modelo de CoreData.
Scheme compartido PortfolioJournalWatch. Localización propia por target
en los 7 idiomas.

Verificado: compilan los tres targets, la app de watch queda embebida en
Watch/ con la extensión en PlugIns/, y en el par de simuladores
iPhone 17 Pro Max + Apple Watch Series 11 el reloj recibe y muestra el
snapshot enviado por el iPhone. 10 tests nuevos del payload.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YcDn5ccuRFV83q7xWWokBT
2026-09-13 22:05:31 +02:00
alexandrev-tibco 167b736aad Fix streak: el mes en curso pendiente ya no rompe la racha (build 88) #47
La tarjeta Momentum & Streaks mostraba "Streak 0x" casi todo el mes
aunque la racha estuviera intacta: stats() empezaba a contar en el mes
en curso y cortaba en el primer mes sin check-in on-time. El check-in
del mes en curso está dentro de plazo hasta fin de mes (y con
graceDays = 20 se hace normalmente ya entrado el mes siguiente), así
que tenerlo pendiente no debe leerse como racha rota.

Ahora el recuento arranca en el mes en curso si ya está hecho y, si no,
en el anterior. Un mes anterior sin check-in sí rompe la racha, porque
su plazo ya venció.

Tests: 3 casos nuevos en MonthlyCheckInStoreTests (mes en curso
pendiente, mes en curso completado, mes anterior perdido).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YcDn5ccuRFV83q7xWWokBT
2026-09-12 16:19:46 +02:00
alexandrev-tibco d88cc59627 Goals por categoría: ámbito de categoría en objetivos (build 88) #46
Un Goal ya podía acotarse por cuenta; ahora también por categoría de
fuente, de modo que se puede fijar "100.000 € en Cash" y que el progreso
cuente solo las fuentes de esa categoría.

- CoreData: relación Goal.category ↔ Category.goals (opcional, Nullify)
- Goal.includes(_:)/relevantSources(from:)/scopeKey centralizan el filtro
  de ámbito (cuenta Y categoría) que antes estaba duplicado en
  GoalsViewModel, AddSourceView y NotificationService
- GoalEditorView: sección "Ámbito" con selector de categoría
- Chip de categoría en la lista de Goals y en la tarjeta del Dashboard
- ETA del Dashboard para goals con ámbito: usa la proyección del propio
  goal en vez de la evolución global de la cartera (que daría "objetivo
  alcanzado" en cuanto la cartera total superara el importe)
- GoalRepository e ImportService deduplican por (nombre, cuenta, categoría)
- Export/Import de goals incluye "category"
- Nuevas claves localizadas en los 7 idiomas

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YcDn5ccuRFV83q7xWWokBT
2026-09-12 09:32:02 +02:00
alexandrev-tibco eaf0c8f93b Fix GA4: screen_view de Dashboard fuera de refreshData() (build 87) #44
logScreenView('Dashboard') estaba dentro de refreshData(), que se re-ejecuta con cada cambio de CoreData (incl. imports de iCloud, debounce 500ms) — 2 iPads con la app abierta generaban 1.400-2.100 views fantasma/día. Movido a DashboardView.onAppear (una vez por aparición), como el resto de pantallas.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VXtwQP6nYnbLFVqsHiTK5G
2026-09-10 06:34:21 +02:00
alexandrev-tibco 6dd755234a Fix widget: refrescar timeline al guardar Quick Update y al pasar a background (build 86) #42
- QuickUpdateView.saveAll hacía context.save() directo sin pasar por SnapshotRepository → nunca llamaba a refreshWidgetData() y el widget quedaba obsoleto hasta reabrir la app
- Red de seguridad: refreshWidgetData() también en scenePhase .background para cualquier otro camino de guardado directo

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VXtwQP6nYnbLFVqsHiTK5G
2026-09-01 16:19:52 +02:00
159 changed files with 4283 additions and 2706 deletions
+16
View File
@@ -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
+29 -7
View File
@@ -48,11 +48,31 @@ PortfolioJournal/
│ ├── Security/ # Face ID/PIN lock (AppLockView)
│ └── Components/ # Shared UI components
├── Utilities/ # Helpers: KeychainService, FreemiumValidator, formatters, extensions
└── Resources/ # Info.plist, GoogleService-Info.plist, assets
└── Resources/ # Info.plist, PrivacyInfo.xcprivacy, assets
PortfolioJournalWidget/ # iOS Home Screen Widget (WidgetKit)
PortfolioJournalWatch/ # watchOS app (read-only portfolio view)
PortfolioJournalWatchWidget/# watchOS complications (WidgetKit)
Shared/ # Types shared between iOS app and watch targets
```
### Apple Watch
The watch app and its complications are read-only; every edit still happens on
the iPhone. App Groups are not shared between iOS and watchOS, so the watch
cannot read the Core Data store the iOS widget reads. Instead:
- `WatchSyncService` (iOS) builds a `WatchPortfolioSnapshot` and pushes it as the
WatchConnectivity application context. It is triggered from
`CoreDataStack.refreshWidgetData()`, the same hook that reloads the iOS widget.
- `WatchDataStore` (watchOS) receives it, caches it in the watch App Group via
`WatchSnapshotCache`, and reloads the complication timelines.
- The complication extension only reads that cache — it never talks to
WatchConnectivity itself.
Watch targets keep their own `*.lproj/Localizable.strings` with just their keys,
like the other extensions.
### Core Data Model
Key entities: `Account`, `InvestmentSource`, `Snapshot`, `Category`, `Goal`, `Asset`, `Transaction`, `AppSettings`, `PremiumStatus`
@@ -64,23 +84,25 @@ Data flows through `CoreDataStack` singleton which manages CloudKit sync and App
- **CalculationService**: Portfolio metrics, returns calculation, allocation analysis
- **PredictionEngine**: Investment forecasting algorithms with caching
- **IAPService**: StoreKit 2 in-app purchases
- **AdMobService**: Google Mobile Ads integration
- **DiagnosticsCollector**: on-device MetricKit crash/hang reports (nothing is uploaded)
- **ImportService/ExportService**: CSV data import/export
- **AppLockService**: Biometric/PIN security via Keychain
## Dependencies
Managed via Swift Package Manager:
- Firebase iOS SDK (v12.7.0+) - Analytics
- Google Mobile Ads SDK
**No third-party packages.** The app deliberately links nothing outside Apple's SDKs:
AdMob, Firebase Analytics and Crashlytics were removed in 1.7.0 so that the
"no tracking, no analytics" claim on the App Store listing is literally true.
Do not reintroduce an advertising, analytics or crash-reporting SDK.
Native frameworks: SwiftUI, Combine, CoreData, CloudKit, WidgetKit, StoreKit 2, LocalAuthentication
Native frameworks: SwiftUI, Combine, CoreData, CloudKit, WidgetKit, StoreKit 2,
LocalAuthentication, MetricKit
## App Initialization Flow
```
PortfolioJournalApp (@main)
└── AppDelegate (Firebase, AdMob, Notifications init)
└── AppDelegate (MetricKit diagnostics, Notifications init)
└── ContentView
├── OnboardingView (first launch)
├── AppLockView (if security enabled)
+150
View File
@@ -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
View File
@@ -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>
+384 -110
View File
@@ -10,12 +10,10 @@
0E241ECF2F0DAA3C00283E2F /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0E241ECE2F0DAA3C00283E2F /* WidgetKit.framework */; };
0E241ED12F0DAA3C00283E2F /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0E241ED02F0DAA3C00283E2F /* SwiftUI.framework */; };
0E241EE22F0DAA3E00283E2F /* PortfolioJournalWidgetExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 0E241ECC2F0DAA3C00283E2F /* PortfolioJournalWidgetExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
0E53752B2F0FD08100F31390 /* FirebaseCore in Frameworks */ = {isa = PBXBuildFile; productRef = 0E53752A2F0FD08100F31390 /* FirebaseCore */; };
0E53752D2F0FD08600F31390 /* FirebaseAnalytics in Frameworks */ = {isa = PBXBuildFile; productRef = 0E53752C2F0FD08600F31390 /* FirebaseAnalytics */; };
0E53752F2F0FD09F00F31390 /* CoreData.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0E53752E2F0FD09F00F31390 /* CoreData.framework */; };
0E5375312F0FD12E00F31390 /* GoogleMobileAds in Frameworks */ = {isa = PBXBuildFile; productRef = 0E5375302F0FD12E00F31390 /* GoogleMobileAds */; };
0E5375352F0FD14000F31390 /* FirebaseCrashlytics in Frameworks */ = {isa = PBXBuildFile; productRef = 0E5375342F0FD14000F31390 /* FirebaseCrashlytics */; };
0EQUPD102F40000000000001 /* PortfolioJournalQuickUpdate.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 0EQUPD012F40000000000001 /* PortfolioJournalQuickUpdate.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
0EWTCH190000000000000000 /* PortfolioJournalWatchWidget.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 0EWTCH040000000000000000 /* PortfolioJournalWatchWidget.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
0EWTCH250000000000000000 /* PortfolioJournalWatch.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = 0EWTCH030000000000000000 /* PortfolioJournalWatch.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@@ -47,6 +45,20 @@
remoteGlobalIDString = 0EQUPD022F40000000000001;
remoteInfo = PortfolioJournalQuickUpdate;
};
0EWTCH200000000000000000 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 0E241E312F0DA93A00283E2F /* Project object */;
proxyType = 1;
remoteGlobalIDString = 0EWTCH020000000000000000;
remoteInfo = PortfolioJournalWatchWidget;
};
0EWTCH220000000000000000 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 0E241E312F0DA93A00283E2F /* Project object */;
proxyType = 1;
remoteGlobalIDString = 0EWTCH010000000000000000;
remoteInfo = PortfolioJournalWatch;
};
/* End PBXContainerItemProxy section */
/* Begin PBXCopyFilesBuildPhase section */
@@ -62,6 +74,28 @@
name = "Embed Foundation Extensions";
runOnlyForDeploymentPostprocessing = 0;
};
0EWTCH150000000000000000 /* Embed Foundation Extensions */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "";
dstSubfolderSpec = 13;
files = (
0EWTCH190000000000000000 /* PortfolioJournalWatchWidget.appex in Embed Foundation Extensions */,
);
name = "Embed Foundation Extensions";
runOnlyForDeploymentPostprocessing = 0;
};
0EWTCH240000000000000000 /* Embed Watch Content */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "$(CONTENTS_FOLDER_PATH)/Watch";
dstSubfolderSpec = 16;
files = (
0EWTCH250000000000000000 /* PortfolioJournalWatch.app in Embed Watch Content */,
);
name = "Embed Watch Content";
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
@@ -75,6 +109,8 @@
0EUITEST002F31000000001 /* PortfolioJournalUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = PortfolioJournalUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
0EQUPD012F40000000000001 /* PortfolioJournalQuickUpdate.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = PortfolioJournalQuickUpdate.appex; sourceTree = BUILT_PRODUCTS_DIR; };
0EQUPD142F40000000000001 /* PortfolioJournalQuickUpdateExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = PortfolioJournalQuickUpdateExtension.entitlements; sourceTree = "<group>"; };
0EWTCH030000000000000000 /* PortfolioJournalWatch.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = PortfolioJournalWatch.app; sourceTree = BUILT_PRODUCTS_DIR; };
0EWTCH040000000000000000 /* PortfolioJournalWatchWidget.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = PortfolioJournalWatchWidget.appex; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
@@ -106,6 +142,36 @@
);
target = 0EQUPD022F40000000000001 /* PortfolioJournalQuickUpdate */;
};
0EWTCH080000000000000000 /* Exceptions for "PortfolioJournalWatch" folder in "PortfolioJournalWatch" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
Info.plist,
);
target = 0EWTCH010000000000000000 /* PortfolioJournalWatch */;
};
0EWTCH090000000000000000 /* Exceptions for "PortfolioJournalWatchWidget" folder in "PortfolioJournalWatchWidget" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
Info.plist,
);
target = 0EWTCH020000000000000000 /* PortfolioJournalWatchWidget */;
};
0EWTCH100000000000000000 /* Exceptions for "Shared" folder in "PortfolioJournal" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
WatchPortfolioSnapshot.swift,
);
target = 0E241E382F0DA93A00283E2F /* PortfolioJournal */;
};
0EWTCH110000000000000000 /* Exceptions for "Shared" folder in "PortfolioJournalWatchWidget" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
WatchColorHex.swift,
WatchPortfolioSnapshot.swift,
WatchSnapshotCache.swift,
);
target = 0EWTCH020000000000000000 /* PortfolioJournalWatchWidget */;
};
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
@@ -144,6 +210,31 @@
path = PortfolioJournalQuickUpdate;
sourceTree = "<group>";
};
0EWTCH050000000000000000 /* PortfolioJournalWatch */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
0EWTCH080000000000000000 /* Exceptions for "PortfolioJournalWatch" folder in "PortfolioJournalWatch" target */,
);
path = PortfolioJournalWatch;
sourceTree = "<group>";
};
0EWTCH060000000000000000 /* PortfolioJournalWatchWidget */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
0EWTCH090000000000000000 /* Exceptions for "PortfolioJournalWatchWidget" folder in "PortfolioJournalWatchWidget" target */,
);
path = PortfolioJournalWatchWidget;
sourceTree = "<group>";
};
0EWTCH070000000000000000 /* Shared */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
0EWTCH100000000000000000 /* Exceptions for "Shared" folder in "PortfolioJournal" target */,
0EWTCH110000000000000000 /* Exceptions for "Shared" folder in "PortfolioJournalWatchWidget" target */,
);
path = Shared;
sourceTree = "<group>";
};
/* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */
@@ -151,11 +242,7 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
0E5375312F0FD12E00F31390 /* GoogleMobileAds in Frameworks */,
0E53752D2F0FD08600F31390 /* FirebaseAnalytics in Frameworks */,
0E53752F2F0FD09F00F31390 /* CoreData.framework in Frameworks */,
0E53752B2F0FD08100F31390 /* FirebaseCore in Frameworks */,
0E5375352F0FD14000F31390 /* FirebaseCrashlytics in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -189,6 +276,20 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
0EWTCH130000000000000000 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
0EWTCH170000000000000000 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
@@ -204,6 +305,9 @@
0EUITEST032F31000000001 /* PortfolioJournalUITests */,
0E241ECD2F0DAA3C00283E2F /* Frameworks */,
0E241E3A2F0DA93A00283E2F /* Products */,
0EWTCH070000000000000000 /* Shared */,
0EWTCH050000000000000000 /* PortfolioJournalWatch */,
0EWTCH060000000000000000 /* PortfolioJournalWatchWidget */,
);
sourceTree = "<group>";
};
@@ -215,6 +319,8 @@
0EQUPD012F40000000000001 /* PortfolioJournalQuickUpdate.appex */,
0ETEST0002F31000000000001 /* PortfolioJournalTests.xctest */,
0EUITEST002F31000000001 /* PortfolioJournalUITests.xctest */,
0EWTCH030000000000000000 /* PortfolioJournalWatch.app */,
0EWTCH040000000000000000 /* PortfolioJournalWatchWidget.appex */,
);
name = Products;
sourceTree = "<group>";
@@ -240,24 +346,19 @@
0E241E362F0DA93A00283E2F /* Frameworks */,
0E241EE72F0DAA3E00283E2F /* Embed Foundation Extensions */,
0E8318932F0DB2FB0030C2F9 /* Resources */,
0E5375362F0FD14000F31390 /* Upload dSYMs to Crashlytics */,
0EWTCH240000000000000000 /* Embed Watch Content */,
);
buildRules = (
);
dependencies = (
0E241EE12F0DAA3E00283E2F /* PBXTargetDependency */,
0EQUPD122F40000000000001 /* PBXTargetDependency */,
0EWTCH230000000000000000 /* PBXTargetDependency */,
);
fileSystemSynchronizedGroups = (
0E241E3B2F0DA93A00283E2F /* PortfolioJournal */,
);
name = PortfolioJournal;
packageProductDependencies = (
0E53752A2F0FD08100F31390 /* FirebaseCore */,
0E53752C2F0FD08600F31390 /* FirebaseAnalytics */,
0E5375302F0FD12E00F31390 /* GoogleMobileAds */,
0E5375342F0FD14000F31390 /* FirebaseCrashlytics */,
);
productName = PortfolioJournal;
productReference = 0E241E392F0DA93A00283E2F /* PortfolioJournal.app */;
productType = "com.apple.product-type.application";
@@ -278,8 +379,6 @@
0E241ED22F0DAA3C00283E2F /* PortfolioJournalWidget */,
);
name = PortfolioJournalWidgetExtension;
packageProductDependencies = (
);
productName = PortfolioJournalWidgetExtension;
productReference = 0E241ECC2F0DAA3C00283E2F /* PortfolioJournalWidgetExtension.appex */;
productType = "com.apple.product-type.app-extension";
@@ -301,8 +400,6 @@
0ETEST0032F31000000000001 /* PortfolioJournalTests */,
);
name = PortfolioJournalTests;
packageProductDependencies = (
);
productName = PortfolioJournalTests;
productReference = 0ETEST0002F31000000000001 /* PortfolioJournalTests.xctest */;
productType = "com.apple.product-type.bundle.unit-test";
@@ -324,8 +421,6 @@
0EUITEST032F31000000001 /* PortfolioJournalUITests */,
);
name = PortfolioJournalUITests;
packageProductDependencies = (
);
productName = PortfolioJournalUITests;
productReference = 0EUITEST002F31000000001 /* PortfolioJournalUITests.xctest */;
productType = "com.apple.product-type.bundle.ui-testing";
@@ -346,12 +441,53 @@
0EQUPD032F40000000000001 /* PortfolioJournalQuickUpdate */,
);
name = PortfolioJournalQuickUpdate;
packageProductDependencies = (
);
productName = PortfolioJournalQuickUpdate;
productReference = 0EQUPD012F40000000000001 /* PortfolioJournalQuickUpdate.appex */;
productType = "com.apple.product-type.app-extension";
};
0EWTCH010000000000000000 /* PortfolioJournalWatch */ = {
isa = PBXNativeTarget;
buildConfigurationList = 0EWTCH280000000000000000 /* Build configuration list for PBXNativeTarget "PortfolioJournalWatch" */;
buildPhases = (
0EWTCH120000000000000000 /* Sources */,
0EWTCH130000000000000000 /* Frameworks */,
0EWTCH140000000000000000 /* Resources */,
0EWTCH150000000000000000 /* Embed Foundation Extensions */,
);
buildRules = (
);
dependencies = (
0EWTCH210000000000000000 /* PBXTargetDependency */,
);
fileSystemSynchronizedGroups = (
0EWTCH050000000000000000 /* PortfolioJournalWatch */,
0EWTCH070000000000000000 /* Shared */,
);
name = PortfolioJournalWatch;
productName = PortfolioJournalWatch;
productReference = 0EWTCH030000000000000000 /* PortfolioJournalWatch.app */;
productType = "com.apple.product-type.application";
};
0EWTCH020000000000000000 /* PortfolioJournalWatchWidget */ = {
isa = PBXNativeTarget;
buildConfigurationList = 0EWTCH310000000000000000 /* Build configuration list for PBXNativeTarget "PortfolioJournalWatchWidget" */;
buildPhases = (
0EWTCH160000000000000000 /* Sources */,
0EWTCH170000000000000000 /* Frameworks */,
0EWTCH180000000000000000 /* Resources */,
);
buildRules = (
);
dependencies = (
);
fileSystemSynchronizedGroups = (
0EWTCH060000000000000000 /* PortfolioJournalWatchWidget */,
);
name = PortfolioJournalWatchWidget;
productName = PortfolioJournalWatchWidget;
productReference = 0EWTCH040000000000000000 /* PortfolioJournalWatchWidget.appex */;
productType = "com.apple.product-type.app-extension";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
@@ -392,10 +528,6 @@
);
mainGroup = 0E241E302F0DA93A00283E2F;
minimizedProjectReferenceProxies = 1;
packageReferences = (
0E241EEB2F0DABEC00283E2F /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */,
0E241EEC2F0DAC2D00283E2F /* XCRemoteSwiftPackageReference "swift-package-manager-google-mobile-ads" */,
);
preferredProjectObjectVersion = 77;
productRefGroup = 0E241E3A2F0DA93A00283E2F /* Products */;
projectDirPath = "";
@@ -406,6 +538,8 @@
0EQUPD022F40000000000001 /* PortfolioJournalQuickUpdate */,
0ETEST0012F31000000000001 /* PortfolioJournalTests */,
0EUITEST012F31000000001 /* PortfolioJournalUITests */,
0EWTCH010000000000000000 /* PortfolioJournalWatch */,
0EWTCH020000000000000000 /* PortfolioJournalWatchWidget */,
);
};
/* End PBXProject section */
@@ -446,6 +580,20 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
0EWTCH140000000000000000 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
0EWTCH180000000000000000 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
@@ -484,30 +632,22 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
0E5375362F0FD14000F31390 /* Upload dSYMs to Crashlytics */ = {
isa = PBXShellScriptBuildPhase;
0EWTCH120000000000000000 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
"${DWARF_DSYM_FOLDER_PATH}/${DWARF_DSYM_FILE_NAME}/Contents/Resources/DWARF/${TARGET_NAME}",
"$(BUILT_PRODUCTS_DIR)/$(INFOPLIST_PATH)",
);
name = "Upload dSYMs to Crashlytics";
outputFileListPaths = (
);
outputPaths = (
runOnlyForDeploymentPostprocessing = 0;
};
0EWTCH160000000000000000 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${BUILD_DIR%/Build/*}/SourcePackages/checkouts/firebase-ios-sdk/Crashlytics/run\"\n";
};
/* End PBXShellScriptBuildPhase section */
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
0E241EE12F0DAA3E00283E2F /* PBXTargetDependency */ = {
@@ -530,6 +670,16 @@
target = 0EQUPD022F40000000000001 /* PortfolioJournalQuickUpdate */;
targetProxy = 0EQUPD112F40000000000001 /* PBXContainerItemProxy */;
};
0EWTCH210000000000000000 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 0EWTCH020000000000000000 /* PortfolioJournalWatchWidget */;
targetProxy = 0EWTCH200000000000000000 /* PBXContainerItemProxy */;
};
0EWTCH230000000000000000 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 0EWTCH010000000000000000 /* PortfolioJournalWatch */;
targetProxy = 0EWTCH220000000000000000 /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
/* Begin XCBuildConfiguration section */
@@ -539,9 +689,9 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = PortfolioJournal/PortfolioJournalDebug.entitlements;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 85;
CURRENT_PROJECT_VERSION = 89;
DEVELOPMENT_ASSET_PATHS = PortfolioJournal/Assets.xcassets;
DEVELOPMENT_TEAM = 2825Q76T7H;
ENABLE_PREVIEWS = YES;
@@ -559,12 +709,13 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.6.2;
MARKETING_VERSION = 1.7.0;
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournal;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
SWIFT_UPCOMING_FEATURE_ISOLATED_DEFAULT_VALUES = YES;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
@@ -578,11 +729,11 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = PortfolioJournal/PortfolioJournal.entitlements;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
CODE_SIGN_IDENTITY = "Apple Distribution";
ENABLE_USER_SCRIPT_SANDBOXING = YES;
CODE_SIGN_IDENTITY = "Apple Distribution: Alexandre Vazquez";
CODE_SIGN_STYLE = Manual;
PROVISIONING_PROFILE_SPECIFIER = "porfoliojournal";
CURRENT_PROJECT_VERSION = 85;
CURRENT_PROJECT_VERSION = 89;
DEVELOPMENT_ASSET_PATHS = PortfolioJournal/Assets.xcassets;
DEVELOPMENT_TEAM = 2825Q76T7H;
ENABLE_PREVIEWS = YES;
@@ -600,12 +751,13 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.6.2;
MARKETING_VERSION = 1.7.0;
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournal;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
SWIFT_UPCOMING_FEATURE_ISOLATED_DEFAULT_VALUES = YES;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
@@ -739,7 +891,7 @@
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
CODE_SIGN_ENTITLEMENTS = PortfolioJournalWidgetExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 85;
CURRENT_PROJECT_VERSION = 89;
DEVELOPMENT_ASSET_PATHS = PortfolioJournalWidget/Assets.xcassets;
DEVELOPMENT_TEAM = 2825Q76T7H;
GENERATE_INFOPLIST_FILE = NO;
@@ -752,7 +904,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.6.2;
MARKETING_VERSION = 1.7.0;
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournal.PortfolioJournalWidget;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
@@ -771,10 +923,10 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
CODE_SIGN_ENTITLEMENTS = PortfolioJournalWidgetExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Distribution";
CODE_SIGN_IDENTITY = "Apple Distribution: Alexandre Vazquez";
CODE_SIGN_STYLE = Manual;
PROVISIONING_PROFILE_SPECIFIER = "Portfolio Journalwidget";
CURRENT_PROJECT_VERSION = 85;
CURRENT_PROJECT_VERSION = 89;
DEVELOPMENT_ASSET_PATHS = PortfolioJournalWidget/Assets.xcassets;
DEVELOPMENT_TEAM = 2825Q76T7H;
GENERATE_INFOPLIST_FILE = NO;
@@ -787,7 +939,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.6.2;
MARKETING_VERSION = 1.7.0;
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournal.PortfolioJournalWidget;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
@@ -805,7 +957,7 @@
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 85;
CURRENT_PROJECT_VERSION = 89;
DEVELOPMENT_TEAM = 2825Q76T7H;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
@@ -814,7 +966,7 @@
"@executable_path/Frameworks",
"@loader_path/Frameworks",
);
MARKETING_VERSION = 1.6.2;
MARKETING_VERSION = 1.7.0;
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournalTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = NO;
@@ -829,7 +981,7 @@
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 85;
CURRENT_PROJECT_VERSION = 89;
DEVELOPMENT_TEAM = 2825Q76T7H;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
@@ -838,7 +990,7 @@
"@executable_path/Frameworks",
"@loader_path/Frameworks",
);
MARKETING_VERSION = 1.6.2;
MARKETING_VERSION = 1.7.0;
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournalTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = NO;
@@ -852,7 +1004,7 @@
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 85;
CURRENT_PROJECT_VERSION = 89;
DEVELOPMENT_TEAM = 2825Q76T7H;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
@@ -861,7 +1013,7 @@
"@executable_path/Frameworks",
"@loader_path/Frameworks",
);
MARKETING_VERSION = 1.6.2;
MARKETING_VERSION = 1.7.0;
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournalUITests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = NO;
@@ -875,7 +1027,7 @@
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 85;
CURRENT_PROJECT_VERSION = 89;
DEVELOPMENT_TEAM = 2825Q76T7H;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
@@ -884,7 +1036,7 @@
"@executable_path/Frameworks",
"@loader_path/Frameworks",
);
MARKETING_VERSION = 1.6.2;
MARKETING_VERSION = 1.7.0;
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournalUITests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = NO;
@@ -899,7 +1051,7 @@
buildSettings = {
CODE_SIGN_ENTITLEMENTS = PortfolioJournalQuickUpdateExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 85;
CURRENT_PROJECT_VERSION = 89;
DEVELOPMENT_TEAM = 2825Q76T7H;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = PortfolioJournalQuickUpdate/Info.plist;
@@ -911,7 +1063,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.6.2;
MARKETING_VERSION = 1.7.0;
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournal.QuickUpdate;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
@@ -925,9 +1077,9 @@
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_ENTITLEMENTS = PortfolioJournalQuickUpdateExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Distribution";
CODE_SIGN_IDENTITY = "Apple Distribution: Alexandre Vazquez";
CODE_SIGN_STYLE = Manual;
CURRENT_PROJECT_VERSION = 85;
CURRENT_PROJECT_VERSION = 89;
DEVELOPMENT_TEAM = 2825Q76T7H;
PROVISIONING_PROFILE_SPECIFIER = "PortfolioJournal QuickUpdate AppStore";
GENERATE_INFOPLIST_FILE = NO;
@@ -940,7 +1092,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.6.2;
MARKETING_VERSION = 1.7.0;
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournal.QuickUpdate;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
@@ -950,6 +1102,150 @@
};
name = Release;
};
0EWTCH260000000000000000 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = PortfolioJournalWatch.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 89;
DEVELOPMENT_TEAM = 2825Q76T7H;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = PortfolioJournalWatch/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "Portfolio Journal";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.7.0;
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournal.watchkitapp;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = watchos;
SKIP_INSTALL = YES;
SUPPORTED_PLATFORMS = "watchsimulator watchos";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 4;
WATCHOS_DEPLOYMENT_TARGET = 10.0;
DEBUG_INFORMATION_FORMAT = dwarf;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
ONLY_ACTIVE_ARCH = YES;
};
name = Debug;
};
0EWTCH270000000000000000 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = PortfolioJournalWatch.entitlements;
CODE_SIGN_IDENTITY = "Apple Distribution: Alexandre Vazquez";
CODE_SIGN_STYLE = Manual;
PROVISIONING_PROFILE_SPECIFIER = "Portfolio Journal Watch App Store";
CURRENT_PROJECT_VERSION = 89;
DEVELOPMENT_TEAM = 2825Q76T7H;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = PortfolioJournalWatch/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "Portfolio Journal";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.7.0;
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournal.watchkitapp;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = watchos;
SKIP_INSTALL = YES;
SUPPORTED_PLATFORMS = "watchsimulator watchos";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 4;
WATCHOS_DEPLOYMENT_TARGET = 10.0;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
SWIFT_OPTIMIZATION_LEVEL = "-O";
SWIFT_COMPILATION_MODE = wholemodule;
};
name = Release;
};
0EWTCH290000000000000000 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = PortfolioJournalWatchWidget.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 89;
DEVELOPMENT_TEAM = 2825Q76T7H;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = PortfolioJournalWatchWidget/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "Portfolio Complications";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.7.0;
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournal.watchkitapp.widget;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = watchos;
SKIP_INSTALL = YES;
SUPPORTED_PLATFORMS = "watchsimulator watchos";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 4;
WATCHOS_DEPLOYMENT_TARGET = 10.0;
DEBUG_INFORMATION_FORMAT = dwarf;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
ONLY_ACTIVE_ARCH = YES;
};
name = Debug;
};
0EWTCH300000000000000000 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = PortfolioJournalWatchWidget.entitlements;
CODE_SIGN_IDENTITY = "Apple Distribution: Alexandre Vazquez";
CODE_SIGN_STYLE = Manual;
PROVISIONING_PROFILE_SPECIFIER = "Portfolio Journal Watch Widget App Store";
CURRENT_PROJECT_VERSION = 89;
DEVELOPMENT_TEAM = 2825Q76T7H;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = PortfolioJournalWatchWidget/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "Portfolio Complications";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.7.0;
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournal.watchkitapp.widget;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = watchos;
SKIP_INSTALL = YES;
SUPPORTED_PLATFORMS = "watchsimulator watchos";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 4;
WATCHOS_DEPLOYMENT_TARGET = 10.0;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
SWIFT_OPTIMIZATION_LEVEL = "-O";
SWIFT_COMPILATION_MODE = wholemodule;
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
@@ -1007,49 +1303,27 @@
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
0EWTCH280000000000000000 /* Build configuration list for PBXNativeTarget "PortfolioJournalWatch" */ = {
isa = XCConfigurationList;
buildConfigurations = (
0EWTCH260000000000000000 /* Debug */,
0EWTCH270000000000000000 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
0EWTCH310000000000000000 /* Build configuration list for PBXNativeTarget "PortfolioJournalWatchWidget" */ = {
isa = XCConfigurationList;
buildConfigurations = (
0EWTCH290000000000000000 /* Debug */,
0EWTCH300000000000000000 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
/* Begin XCRemoteSwiftPackageReference section */
0E241EEB2F0DABEC00283E2F /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/firebase/firebase-ios-sdk";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 12.7.0;
};
};
0E241EEC2F0DAC2D00283E2F /* XCRemoteSwiftPackageReference "swift-package-manager-google-mobile-ads" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/googleads/swift-package-manager-google-mobile-ads";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 12.14.0;
};
};
/* End XCRemoteSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
0E53752A2F0FD08100F31390 /* FirebaseCore */ = {
isa = XCSwiftPackageProductDependency;
package = 0E241EEB2F0DABEC00283E2F /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */;
productName = FirebaseCore;
};
0E53752C2F0FD08600F31390 /* FirebaseAnalytics */ = {
isa = XCSwiftPackageProductDependency;
package = 0E241EEB2F0DABEC00283E2F /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */;
productName = FirebaseAnalytics;
};
0E5375302F0FD12E00F31390 /* GoogleMobileAds */ = {
isa = XCSwiftPackageProductDependency;
package = 0E241EEC2F0DAC2D00283E2F /* XCRemoteSwiftPackageReference "swift-package-manager-google-mobile-ads" */;
productName = GoogleMobileAds;
};
0E5375342F0FD14000F31390 /* FirebaseCrashlytics */ = {
isa = XCSwiftPackageProductDependency;
package = 0E241EEB2F0DABEC00283E2F /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */;
productName = FirebaseCrashlytics;
};
/* End XCSwiftPackageProductDependency section */
};
rootObject = 0E241E312F0DA93A00283E2F /* Project object */;
}
@@ -1,150 +0,0 @@
{
"originHash" : "2d3f06aab1ccdfff7b100ca10208a834bfb664b3c50890610f1e3c349eb3fbf3",
"pins" : [
{
"identity" : "abseil-cpp-binary",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/abseil-cpp-binary.git",
"state" : {
"revision" : "bbe8b69694d7873315fd3a4ad41efe043e1c07c5",
"version" : "1.2024072200.0"
}
},
{
"identity" : "app-check",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/app-check.git",
"state" : {
"revision" : "61b85103a1aeed8218f17c794687781505fbbef5",
"version" : "11.2.0"
}
},
{
"identity" : "firebase-ios-sdk",
"kind" : "remoteSourceControl",
"location" : "https://github.com/firebase/firebase-ios-sdk",
"state" : {
"revision" : "45210bd1ea695779e6de016ab00fea8c0b7eb2ef",
"version" : "12.7.0"
}
},
{
"identity" : "google-ads-on-device-conversion-ios-sdk",
"kind" : "remoteSourceControl",
"location" : "https://github.com/googleads/google-ads-on-device-conversion-ios-sdk",
"state" : {
"revision" : "35b601a60fbbea2de3ea461f604deaaa4d8bbd0c",
"version" : "3.2.0"
}
},
{
"identity" : "googleappmeasurement",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/GoogleAppMeasurement.git",
"state" : {
"revision" : "c2d59acf17a8ba7ed80a763593c67c9c7c006ad1",
"version" : "12.5.0"
}
},
{
"identity" : "googledatatransport",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/GoogleDataTransport.git",
"state" : {
"revision" : "617af071af9aa1d6a091d59a202910ac482128f9",
"version" : "10.1.0"
}
},
{
"identity" : "googleutilities",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/GoogleUtilities.git",
"state" : {
"revision" : "60da361632d0de02786f709bdc0c4df340f7613e",
"version" : "8.1.0"
}
},
{
"identity" : "grpc-binary",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/grpc-binary.git",
"state" : {
"revision" : "75b31c842f664a0f46a2e590a570e370249fd8f6",
"version" : "1.69.1"
}
},
{
"identity" : "gtm-session-fetcher",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/gtm-session-fetcher.git",
"state" : {
"revision" : "fb7f2740b1570d2f7599c6bb9531bf4fad6974b7",
"version" : "5.0.0"
}
},
{
"identity" : "interop-ios-for-google-sdks",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/interop-ios-for-google-sdks.git",
"state" : {
"revision" : "040d087ac2267d2ddd4cca36c757d1c6a05fdbfe",
"version" : "101.0.0"
}
},
{
"identity" : "leveldb",
"kind" : "remoteSourceControl",
"location" : "https://github.com/firebase/leveldb.git",
"state" : {
"revision" : "a0bc79961d7be727d258d33d5a6b2f1023270ba1",
"version" : "1.22.5"
}
},
{
"identity" : "nanopb",
"kind" : "remoteSourceControl",
"location" : "https://github.com/firebase/nanopb.git",
"state" : {
"revision" : "b7e1104502eca3a213b46303391ca4d3bc8ddec1",
"version" : "2.30910.0"
}
},
{
"identity" : "promises",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/promises.git",
"state" : {
"revision" : "540318ecedd63d883069ae7f1ed811a2df00b6ac",
"version" : "2.4.0"
}
},
{
"identity" : "swift-package-manager-google-mobile-ads",
"kind" : "remoteSourceControl",
"location" : "https://github.com/googleads/swift-package-manager-google-mobile-ads",
"state" : {
"revision" : "1239c23464503053c17d37ee5daa70de3455e9c8",
"version" : "12.14.0"
}
},
{
"identity" : "swift-package-manager-google-user-messaging-platform",
"kind" : "remoteSourceControl",
"location" : "https://github.com/googleads/swift-package-manager-google-user-messaging-platform.git",
"state" : {
"revision" : "13b248eaa73b7826f0efb1bcf455e251d65ecb1b",
"version" : "3.1.0"
}
},
{
"identity" : "swift-protobuf",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-protobuf.git",
"state" : {
"revision" : "c169a5744230951031770e27e475ff6eefe51f9d",
"version" : "1.33.3"
}
}
],
"version" : 3
}
@@ -0,0 +1,78 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "2620"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
buildArchitectures = "Automatic">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "0EWTCH010000000000000000"
BuildableName = "PortfolioJournalWatch.app"
BlueprintName = "PortfolioJournalWatch"
ReferencedContainer = "container:PortfolioJournal.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "0EWTCH010000000000000000"
BuildableName = "PortfolioJournalWatch.app"
BlueprintName = "PortfolioJournalWatch"
ReferencedContainer = "container:PortfolioJournal.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "0EWTCH010000000000000000"
BuildableName = "PortfolioJournalWatch.app"
BlueprintName = "PortfolioJournalWatch"
ReferencedContainer = "container:PortfolioJournal.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>
+10 -13
View File
@@ -1,28 +1,25 @@
import UIKit
import UserNotifications
import FirebaseCore
import FirebaseAnalytics
import FirebaseCrashlytics
import GoogleMobileAds
class AppDelegate: NSObject, UIApplicationDelegate {
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
) -> Bool {
// Initialize Firebase (only if GoogleService-Info.plist exists)
if Bundle.main.path(forResource: "GoogleService-Info", ofType: "plist") != nil {
FirebaseApp.configure()
} else {
print("Warning: GoogleService-Info.plist not found. Firebase disabled.")
}
// Initialize Google Mobile Ads
MobileAds.shared.start()
// On-device crash/hang diagnostics. Nothing is uploaded anywhere: reports
// stay in the app container and Apple's own pipeline is what feeds the
// Xcode Organizer. See DiagnosticsCollector.
DiagnosticsCollector.shared.start()
// Request notification permissions
requestNotificationPermissions()
// Apple Watch bridge: activating early means the first application
// context lands before the user raises their wrist.
Task { @MainActor in
WatchSyncService.shared.activate()
}
return true
}
+55 -87
View File
@@ -31,7 +31,6 @@ enum AppTab: Int, Hashable, CaseIterable {
struct ContentView: View {
@EnvironmentObject var iapService: IAPService
@EnvironmentObject var adMobService: AdMobService
@EnvironmentObject var tabSelection: TabSelectionStore
@EnvironmentObject var coreDataStack: CoreDataStack
@AppStorage("onboardingCompleted") private var onboardingCompleted = false
@@ -41,13 +40,14 @@ struct ContentView: View {
@AppStorage("lockOnBackground") private var lockOnBackground = false
@AppStorage("lastSeenWhatsNewVersion") private var lastSeenWhatsNewVersion = ""
@Environment(\.scenePhase) private var scenePhase
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
@State private var isUnlocked = false
@State private var resolvedOnboardingCompleted: Bool?
@State private var iCloudCheckDone = false
@State private var loadingMessageKey: LocalizedStringKey = "loading_data"
@State private var sidebarSelection: AppTab? = .dashboard
@State private var showingWhatsNew = false
/// Per-scene tab restoration: the interface is re-laid out when iPhone Duo
/// opens/closes or a window is resized the selected tab must survive it.
@SceneStorage("selectedTab") private var restoredTab: Int = -1
private var currentVersion: String {
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? ""
@@ -75,8 +75,6 @@ struct ContentView: View {
OnboardingICloudCheckView(onSkip: { iCloudCheckDone = true })
} else if resolvedOnboardingCompleted == false {
OnboardingView(onboardingCompleted: $onboardingCompleted)
} else if horizontalSizeClass == .regular {
iPadMainContent
} else {
mainContent
}
@@ -92,6 +90,12 @@ struct ContentView: View {
} else {
isUnlocked = !lockOnLaunch
}
if restoredTab >= 0, AppTab(rawValue: restoredTab) != nil {
tabSelection.selectedTab = restoredTab
}
}
.onChange(of: tabSelection.selectedTab) { _, tab in
restoredTab = tab
}
.onChange(of: lockEnabled) { _, enabled in
if !enabled {
@@ -119,7 +123,7 @@ struct ContentView: View {
if (resolvedOnboardingCompleted == true) && currentVersion != lastSeenWhatsNewVersion,
!WhatsNewCatalog.pendingReleases(since: lastSeenWhatsNewVersion, current: currentVersion).isEmpty {
// Small delay to let the UI settle before showing sheet
try? await Task.sleep(nanoseconds: 300_000_000)
try? await Task.sleep(for: .milliseconds(300))
showingWhatsNew = true
}
}
@@ -144,18 +148,15 @@ struct ContentView: View {
))
}
.onReceive(NotificationCenter.default.publisher(for: .openBatchUpdate)) { _ in
tabSelection.selectedTab = 1
sidebarSelection = .sources
tabSelection.selectedTab = AppTab.sources.rawValue
NotificationCenter.default.post(name: .openQuickUpdate, object: nil)
}
.onReceive(NotificationCenter.default.publisher(for: .openDashboard)) { _ in
tabSelection.selectedTab = 0
sidebarSelection = .dashboard
tabSelection.selectedTab = AppTab.dashboard.rawValue
}
.onOpenURL { url in
if url.host == "quickupdate" {
tabSelection.selectedTab = 0
sidebarSelection = .dashboard
tabSelection.selectedTab = AppTab.dashboard.rawValue
NotificationCenter.default.post(name: .openQuickUpdate, object: nil)
}
}
@@ -168,7 +169,7 @@ struct ContentView: View {
private func waitForDataAndResolveOnboarding() async {
// Wait for Core Data to be loaded
while !coreDataStack.isLoaded {
try? await Task.sleep(nanoseconds: 50_000_000) // 50ms
try? await Task.sleep(for: .milliseconds(50)) // 50ms
}
// If CloudKit is enabled and no local data yet, wait briefly for the
@@ -192,7 +193,7 @@ struct ContentView: View {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
if hasExistingData() { return }
try? await Task.sleep(nanoseconds: 300_000_000) // poll every 300ms
try? await Task.sleep(for: .milliseconds(300)) // poll every 300ms
}
}
@@ -240,93 +241,61 @@ struct ContentView: View {
return ((try? context.count(for: accountRequest)) ?? 0) > 0
}
// MARK: - iPad Layout
// MARK: - Adaptive navigation
//
// One TabView for every size class. iOS 18+ uses the sidebar-adaptable
// style: a tab bar in compact width (iPhone, iPhone Duo outer screen) and a
// top tab bar / sidebar in regular width (iPad, iPhone Duo inner screen).
// Because the hierarchy never changes, opening or closing the Duo keeps
// every tab's navigation and scroll state. Each tab that has a list+detail
// structure (Sources, Journal) owns a NavigationSplitView that collapses
// to a stack in compact width and shows both columns in regular width.
private var iPadMainContent: some View {
NavigationSplitView(columnVisibility: .constant(.all)) {
List(AppTab.allCases, id: \.self, selection: $sidebarSelection) { tab in
Label(tab.title, systemImage: tab.icon)
}
.navigationTitle("Portfolio Journal")
.navigationSplitViewColumnWidth(min: 200, ideal: 240, max: 280)
} detail: {
iPadDetailView(for: sidebarSelection ?? .dashboard)
@ViewBuilder
private var mainContent: some View {
if #available(iOS 18.0, *) {
adaptiveTabs
} else {
legacyTabs
}
.onChange(of: sidebarSelection) { _, tab in
guard let tab else { return }
if tabSelection.selectedTab != tab.rawValue {
tabSelection.selectedTab = tab.rawValue
}
@available(iOS 18.0, *)
private var adaptiveTabs: some View {
TabView(selection: $tabSelection.selectedTab) {
ForEach(AppTab.allCases, id: \.self) { tab in
Tab(tab.title, systemImage: tab.icon, value: tab.rawValue) {
tabContent(for: tab)
}
}
}
.onChange(of: tabSelection.selectedTab) { _, value in
if let tab = AppTab(rawValue: value), sidebarSelection != tab {
sidebarSelection = tab
.tabViewStyle(.sidebarAdaptable)
}
/// iOS 17 fallback: classic tab bar in every size class.
private var legacyTabs: some View {
TabView(selection: $tabSelection.selectedTab) {
ForEach(AppTab.allCases, id: \.self) { tab in
tabContent(for: tab)
.tabItem { Label(tab.title, systemImage: tab.icon) }
.tag(tab.rawValue)
}
}
}
@ViewBuilder
private func iPadDetailView(for tab: AppTab) -> some View {
private func tabContent(for tab: AppTab) -> some View {
switch tab {
case .dashboard:
bannerInsetView(DashboardView())
DashboardView()
case .sources:
bannerInsetView(SourceListView(iapService: iapService))
SourceListView(iapService: iapService)
case .charts:
bannerInsetView(ChartsContainerView(iapService: iapService))
ChartsContainerView(iapService: iapService)
case .journal:
bannerInsetView(JournalView())
JournalView()
case .settings:
bannerInsetView(SettingsView(iapService: iapService))
}
}
// MARK: - iPhone Layout
private var mainContent: some View {
ZStack {
TabView(selection: $tabSelection.selectedTab) {
bannerInsetView(DashboardView())
.tabItem {
Label("Home", systemImage: "house.fill")
}
.tag(0)
bannerInsetView(SourceListView(iapService: iapService))
.tabItem {
Label("Sources", systemImage: "list.bullet")
}
.tag(1)
bannerInsetView(ChartsContainerView(iapService: iapService))
.tabItem {
Label("Charts", systemImage: "chart.xyaxis.line")
}
.tag(2)
bannerInsetView(JournalView())
.tabItem {
Label("Journal", systemImage: "book.closed")
}
.tag(3)
bannerInsetView(SettingsView(iapService: iapService))
.tabItem {
Label("Settings", systemImage: "gearshape.fill")
}
.tag(4)
}
}
}
private func bannerInsetView<Content: View>(_ content: Content) -> some View {
content.safeAreaInset(edge: .bottom, spacing: 0) {
if !iapService.isPremium && adMobService.canShowAds {
BannerAdView()
.frame(height: AppConstants.UI.bannerAdHeight)
.frame(maxWidth: .infinity)
.background(Color(.systemBackground))
}
SettingsView(iapService: iapService)
}
}
}
@@ -335,7 +304,6 @@ struct ContentView: View {
ContentView()
.environmentObject(CoreDataStack.shared)
.environmentObject(IAPService())
.environmentObject(AdMobService())
.environmentObject(AccountStore(iapService: IAPService()))
.environmentObject(TabSelectionStore())
.environmentObject(BalancePrivacyManager.shared)
@@ -24,6 +24,9 @@ enum ScreenshotMode {
// the premium charts without a StoreKit purchase. `--no-premium` keeps it
// off to test the free-tier experience (locked charts, teasers).
d.set(!CommandLine.arguments.contains("--no-premium"), forKey: "debugPremiumOverride")
// TipKit popovers cover toolbar buttons and swallow the first tap in
// automated walkthroughs keep them out of captures.
Tips.hideAllTipsForTesting()
}
/// Seed demo data (no-op if the store already has sources). Called once Core Data
@@ -59,7 +62,6 @@ enum ScreenshotMode {
struct PortfolioJournalApp: App {
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
@StateObject private var iapService: IAPService
@StateObject private var adMobService: AdMobService
@StateObject private var accountStore: AccountStore
@StateObject private var tabSelection = TabSelectionStore()
@StateObject private var balancePrivacy = BalancePrivacyManager.shared
@@ -80,7 +82,6 @@ struct PortfolioJournalApp: App {
let iap = IAPService()
_iapService = StateObject(wrappedValue: iap)
_adMobService = StateObject(wrappedValue: AdMobService())
_accountStore = StateObject(wrappedValue: AccountStore(iapService: iap))
}
@@ -90,12 +91,17 @@ struct PortfolioJournalApp: App {
.environment(\.managedObjectContext, coreDataStack.viewContext)
.environmentObject(coreDataStack)
.environmentObject(iapService)
.environmentObject(adMobService)
.environmentObject(accountStore)
.environmentObject(tabSelection)
.environmentObject(balancePrivacy)
}
.onChange(of: scenePhase) { _, newPhase in
if newPhase == .background {
// Safety net for any save path that doesn't refresh the widget
// itself: guarantee the widget is fresh whenever the app leaves
// the foreground (WAL checkpoint + timeline reload).
coreDataStack.refreshWidgetData()
}
if newPhase == .active {
coreDataStack.refreshWidgetData()
// Re-read all Core Data objects from the persistent store so that
@@ -10,6 +10,9 @@ public class AppSettings: NSManagedObject, Identifiable {
@NSManaged public var id: UUID
@NSManaged public var currency: String
@NSManaged public var defaultNotificationTime: Date?
/// Unused since 1.7.0: there is no analytics of any kind to enable or
/// disable. The attribute stays only because dropping it would mean a Core
/// Data migration plus a CloudKit schema deploy. Do not wire it to anything.
@NSManaged public var enableAnalytics: Bool
@NSManaged public var onboardingCompleted: Bool
@NSManaged public var lastSyncDate: Date?
@@ -28,6 +28,7 @@ public class Category: NSManagedObject, Identifiable {
@NSManaged public var createdAt: Date
@NSManaged public var allocationTarget: NSNumber?
@NSManaged public var sources: NSSet?
@NSManaged public var goals: NSSet?
public override func awakeFromInsert() {
super.awakeFromInsert()
@@ -43,6 +44,13 @@ public class Category: NSManagedObject, Identifiable {
// MARK: - Computed Properties
extension Category {
/// Nil-safe identifier that never heals a deleted object, mirroring
/// `Account.safeId`. Use it when comparing identities across fetches.
var safeId: UUID? {
guard !isDeleted else { return nil }
return value(forKey: "id") as? UUID
}
var color: Color {
Color(hex: colorHex) ?? .blue
}
@@ -26,6 +26,7 @@ public class Goal: NSManagedObject, Identifiable {
set { setManagedValue(newValue, forKey: "createdAt") }
}
@NSManaged public var account: Account?
@NSManaged public var category: Category?
public override func awakeFromInsert() {
super.awakeFromInsert()
@@ -42,5 +43,32 @@ extension Goal {
var targetDecimal: Decimal {
targetAmount?.decimalValue ?? Decimal.zero
}
/// A goal may be scoped to an account, to a source category (e.g. "100k in
/// Cash"), to both, or to nothing at all (the whole portfolio). `account`
/// and `category` act as AND filters over the sources that count towards it.
func includes(_ source: InvestmentSource) -> Bool {
if let accountId = account?.safeId, source.account?.id != accountId {
return false
}
if let categoryId = category?.safeId, source.category?.id != categoryId {
return false
}
return true
}
/// Sources whose value counts towards this goal.
func relevantSources(from sources: [InvestmentSource]) -> [InvestmentSource] {
guard account != nil || category != nil else { return sources }
return sources.filter { includes($0) }
}
/// Stable key identifying the scope (account + category) of a goal, so
/// goals sharing a scope can share cached evolution data.
var scopeKey: String {
let accountPart = account?.safeId?.uuidString ?? "all"
let categoryPart = category?.safeId?.uuidString ?? "all"
return "\(accountPart)|\(categoryPart)"
}
}
@@ -32,6 +32,7 @@
<attribute name="id" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
<attribute name="name" attributeType="String" defaultValueString=""/>
<attribute name="sortOrder" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/>
<relationship name="goals" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Goal" inverseName="category" inverseEntity="Goal"/>
<relationship name="sources" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="InvestmentSource" inverseName="category" inverseEntity="InvestmentSource"/>
</entity>
<entity name="InvestmentSource" representedClassName="InvestmentSource" syncable="YES">
@@ -76,6 +77,7 @@
<attribute name="targetAmount" optional="YES" attributeType="Decimal"/>
<attribute name="targetDate" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<relationship name="account" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Account" inverseName="goals" inverseEntity="Account"/>
<relationship name="category" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Category" inverseName="goals" inverseEntity="Category"/>
</entity>
<entity name="PredictionCache" representedClassName="PredictionCache" syncable="NO">
<attribute name="algorithm" attributeType="String" defaultValueString="linear"/>
+6 -1
View File
@@ -820,7 +820,7 @@ class CoreDataStack: ObservableObject {
guard Self.cloudKitEnabled else { completion(0); return }
let entities = ["Account", "Category", "InvestmentSource", "Snapshot", "Goal"]
let context = newBackgroundContext()
context.perform { [weak self] in
context.perform {
var totalTouched = 0
for entityName in entities {
let request = NSFetchRequest<NSManagedObject>(entityName: entityName)
@@ -868,6 +868,11 @@ class CoreDataStack: ObservableObject {
WidgetCenter.shared.reloadAllTimelines()
}
}
// The paired watch reads the same data changes; every repository that
// refreshes the widget wants the watch refreshed too.
Task { @MainActor in
WatchSyncService.shared.scheduleSync()
}
}
/// Forces SQLite to checkpoint the WAL file, ensuring all changes are written to the main database file.
@@ -53,25 +53,21 @@ class GoalRepository: ObservableObject {
// MARK: - Create
/// Check if a goal with the given name already exists in the account (case-insensitive)
func goalExists(name: String, in account: Account?) -> Bool {
let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines)
let normalized = trimmed.lowercased()
return goals.contains { goal in
let nameMatches = goal.name.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == normalized
let accountMatches = goal.account?.id == account?.id
return nameMatches && accountMatches
}
/// Check if a goal with the given name already exists in the same scope
/// (account + category), case-insensitive
func goalExists(name: String, in account: Account?, category: Category? = nil) -> Bool {
findGoal(byName: name, in: account, category: category) != nil
}
/// Find an existing goal by name (case-insensitive) within the same account
func findGoal(byName name: String, in account: Account?) -> Goal? {
/// Find an existing goal by name (case-insensitive) within the same scope
func findGoal(byName name: String, in account: Account?, category: Category? = nil) -> Goal? {
let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines)
let normalized = trimmed.lowercased()
return goals.first { goal in
let nameMatches = goal.name.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == normalized
let accountMatches = goal.account?.id == account?.id
return nameMatches && accountMatches
let categoryMatches = goal.category?.id == category?.id
return nameMatches && accountMatches && categoryMatches
}
}
@@ -80,10 +76,11 @@ class GoalRepository: ObservableObject {
name: String,
targetAmount: Decimal,
targetDate: Date? = nil,
account: Account?
account: Account?,
category: Category? = nil
) -> Goal {
// Check if goal with same name already exists in this account
if let existing = findGoal(byName: name, in: account) {
// Check if goal with same name already exists in this scope
if let existing = findGoal(byName: name, in: account, category: category) {
return existing
}
@@ -92,6 +89,7 @@ class GoalRepository: ObservableObject {
goal.targetAmount = NSDecimalNumber(decimal: targetAmount)
goal.targetDate = targetDate
goal.account = account
goal.category = category
save()
return goal
}
@@ -104,7 +102,9 @@ class GoalRepository: ObservableObject {
targetAmount: Decimal? = nil,
targetDate: Date? = nil,
clearTargetDate: Bool = false,
isActive: Bool? = nil
isActive: Bool? = nil,
category: Category? = nil,
clearCategory: Bool = false
) {
if let name = name {
goal.name = name
@@ -120,6 +120,11 @@ class GoalRepository: ObservableObject {
if let isActive = isActive {
goal.isActive = isActive
}
if clearCategory {
goal.category = nil
} else if let category = category {
goal.category = category
}
save()
}
@@ -1,30 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>API_KEY</key>
<string>AIzaSyAregLaXQ-WqRQTltTRyjQx3-lfxLl4Cng</string>
<key>GCM_SENDER_ID</key>
<string>334225114072</string>
<key>PLIST_VERSION</key>
<string>1</string>
<key>BUNDLE_ID</key>
<string>com.alexandrevazquez.PortfolioJournal</string>
<key>PROJECT_ID</key>
<string>portfoliojournal-ef2d7</string>
<key>STORAGE_BUCKET</key>
<string>portfoliojournal-ef2d7.firebasestorage.app</string>
<key>IS_ADS_ENABLED</key>
<false></false>
<key>IS_ANALYTICS_ENABLED</key>
<false></false>
<key>IS_APPINVITE_ENABLED</key>
<true></true>
<key>IS_GCM_ENABLED</key>
<true></true>
<key>IS_SIGNIN_ENABLED</key>
<true></true>
<key>GOOGLE_APP_ID</key>
<string>1:334225114072:ios:a0a50b5835ac042e3d28ad</string>
</dict>
</plist>
-269
View File
@@ -31,10 +31,6 @@
</array>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>GADApplicationIdentifier</key>
<string>ca-app-pub-1549720748100858~9632507420</string>
<key>GADDelayAppMeasurementInit</key>
<true/>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>LSRequiresIPhoneOS</key>
@@ -54,271 +50,6 @@
<string>Use Face ID to unlock your portfolio data.</string>
<key>NSUbiquitousContainerIsDocumentScopePublic</key>
<true/>
<key>NSUserTrackingUsageDescription</key>
<string>This app uses tracking to provide personalized ads and improve your experience. Your data is not sold to third parties.</string>
<key>SKAdNetworkItems</key>
<array>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>cstr6suwn9.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>4fzdc2evr5.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>4pfyvq9l8r.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>2fnua5tdw4.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>ydx93a7ass.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>5a6flpkh64.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>p78axxw29g.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>v72qych5uu.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>ludvb6z3bs.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>cp8zw746q7.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>3sh42y64q3.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>c6k4g5qg8m.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>s39g8k73mm.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>3qy4746246.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>f38h382jlk.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>hs6bdukanm.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>v4nxqhlyqp.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>wzmmz9fp6w.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>yclnxrl5pm.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>t38b2kh725.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>7ug5zh24hu.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>gta9lk7p23.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>vutu7akeur.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>y5ghdn5j9k.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>n6fk4nfna4.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>v9wttpbfk9.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>n38lu8286q.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>47vhws6wlr.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>kbd757ber7.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>9t245vhmpl.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>eh6m2bh4zr.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>a2p9lx4jpn.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>22mmun2rn5.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>4468km3ulz.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>2u9pt9hc89.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>8s468mfl3y.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>klf5c3l5u5.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>ppxm28t8ap.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>ecpz2srf59.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>uw77j35x4d.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>pwa73g5rt2.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>mlmmfzh3r3.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>578prtvx9j.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>4dzt52r2t5.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>e5fvkxwrpn.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>8c4e2ghe7u.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>zq492l623r.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>3rd42ekr43.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>3qcr597p9d.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>su67r6k2v3.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>kbd757ywx3.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>44n7hlldy6.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>k674qkevps.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>2rq3zucswp.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>5tjdwbrq8w.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>97r2b46745.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>b9bk5wbcq9.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>44jx6755aq.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>kbmxgpxpgc.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>tl55sbb4fm.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>9g2aggbj52.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>g2y4y55b64.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>c3frkrj4fj.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>r26jy69rpl.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>275upjj5gd.skadnetwork</string>
</dict>
</array>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
@@ -0,0 +1,41 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!--
Portfolio Journal collects nothing. There is no account, no server of
ours, no analytics SDK and no advertising SDK. Everything the user types
stays in the app container and, if they opt in, in their own private
CloudKit database.
-->
<key>NSPrivacyTracking</key>
<false/>
<key>NSPrivacyTrackingDomains</key>
<array/>
<key>NSPrivacyCollectedDataTypes</key>
<array/>
<key>NSPrivacyAccessedAPITypes</key>
<array>
<dict>
<!-- Reading/writing the Core Data store, exports, backups and the
on-device diagnostic reports. -->
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryFileTimestamp</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>C617.1</string>
</array>
</dict>
<dict>
<!-- App preferences (chart selection, dashboard layout, reminder
settings) stored in UserDefaults and the App Group. -->
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryUserDefaults</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>CA92.1</string>
</array>
</dict>
</array>
</dict>
</plist>
@@ -85,16 +85,12 @@
"feature_predictions_desc" = "KI-gestützte 12-Monats-Prognosen";
"feature_export" = "Daten exportieren";
"feature_export_desc" = "Export nach CSV und JSON";
"feature_no_ads" = "Keine Werbung";
"feature_no_ads_desc" = "Dauerhaft werbefrei";
"paywall_benefit_history_title" = "Deine vollständige Historie";
"paywall_benefit_history_subtitle" = "Jeder Snapshot, Beitrag und Gewinn seit Tag eins";
"paywall_benefit_charts_title" = "Charts, die Muster zeigen";
"paywall_benefit_charts_subtitle" = "Allokation, Drawdown, Performance, alles an einem Ort";
"paywall_benefit_forecasts_title" = "12-Monats-Prognosen";
"paywall_benefit_forecasts_subtitle" = "Sieh, wohin sich dein Portfolio wahrscheinlich entwickelt";
"paywall_benefit_noads_title" = "Keine Werbung, jemals";
"paywall_benefit_noads_subtitle" = "Klares, fokussiertes Erlebnis ohne Ablenkung";
"settings_title" = "Einstellungen";
"subscription" = "Abo";
"notifications" = "Benachrichtigungen";
@@ -677,3 +673,38 @@
"add_source_value_header" = "Heutiger Wert";
"add_source_value_footer" = "Wird als dein erster Check-in gespeichert dein Fortschritt beginnt heute.";
"quick_update_add_first_source" = "Erste Quelle hinzufügen";
/* MARK: - Goal scope (category) */
"goal_scope_section" = "Bereich";
"goal_scope_category" = "Kategorie";
"goal_scope_all_categories" = "Alle Kategorien";
"goal_scope_footer_all" = "Alle Quellen deines Portfolios zählen für dieses Ziel.";
"goal_scope_footer_category" = "Nur Quellen dieser Kategorie zählen für dieses Ziel.";
// MARK: - Biometrics (1.7.0 — Face ID / Touch ID / Optic ID aware)
"biometric_generic_name" = "Face ID oder Touch ID";
"biometric_unlock_with" = "Mit %@ entsperren";
"biometric_failed" = "%@ fehlgeschlagen. Bitte erneut versuchen.";
"biometric_enable" = "%@ aktivieren";
"biometric_unavailable_title" = "%@ nicht verfügbar";
"biometric_unavailable_body" = "%@ ist auf diesem Gerät nicht verfügbar.";
"biometric_pin_required" = "Richte zuerst eine PIN ein, bevor du %@ aktivierst.";
"biometric_disable_first_title" = "Zuerst %@ deaktivieren";
"biometric_disable_first_body" = "Deaktiviere %@, bevor du deine PIN deaktivierst.";
"biometric_pin_to_disable" = "PIN eingeben, um %@ zu deaktivieren";
"biometric_require_to_reveal" = "%@ zum Anzeigen erforderlich";
"biometric_lock_footer" = "Schütze deine Daten mit %@ oder einer 4-stelligen PIN.";
"privacy_footer_biometric" = "Blendet alle Beträge hinter einer Unschärfe aus. Ideal für die App in der Öffentlichkeit oder für Screenshots. Optional %@ zum Anzeigen erforderlich.";
// MARK: - What's New 1.7.0
"whats_new_170_duo_title" = "Bereit für iPhone Duo";
"whats_new_170_duo_body" = "Auf dem Innendisplay zeigen Quellen und Tagebuch Liste und Detail nebeneinander, Diagramme erhalten eine Seitenleiste und mehr Höhe, und deine Position bleibt beim Auf- und Zuklappen erhalten.";
"whats_new_170_inspector_title" = "Schnellupdate neben deinen Diagrammen";
"whats_new_170_inspector_body" = "Auf breiten Bildschirmen öffnet sich das monatliche Schnellupdate in einem Seitenbereich das Dashboard bleibt sichtbar, während du Werte eingibst.";
"whats_new_170_biometrics_title" = "Face ID, Touch ID oder Optic ID";
"whats_new_170_biometrics_body" = "App-Sperre und Saldo-Privatsphäre nutzen jetzt die Biometrie deines Geräts.";
/* 1.7.0 privacy cleanup: on-device diagnostics + honest backup wording */
"Share Diagnostics" = "Diagnose teilen";
"diagnostics_footer" = "Absturz- und Hänger-Berichte, die iOS auf diesem Gerät gesammelt hat. Sie werden nirgendwohin übertragen — nur durch Teilen verlässt ein Bericht dein iPhone.";
"backups_footer" = "Backups sind lesbare JSON-Dateien auf diesem Gerät und, wenn aktiviert, in deiner iCloud Drive. Die App verschlüsselt sie nicht: Wer die Datei öffnen kann, sieht deine Kontostände.";
@@ -109,8 +109,6 @@
"feature_predictions_desc" = "AI-powered 12-month forecasts";
"feature_export" = "Export Data";
"feature_export_desc" = "Export to CSV and JSON formats";
"feature_no_ads" = "No Ads";
"feature_no_ads_desc" = "Ad-free experience forever";
// Paywall benefits (1.2.0)
"paywall_benefit_history_title" = "Your complete history";
@@ -119,8 +117,6 @@
"paywall_benefit_charts_subtitle" = "Allocation, drawdown, performance — all in one place";
"paywall_benefit_forecasts_title" = "12-month forecasts";
"paywall_benefit_forecasts_subtitle" = "See where your portfolio is likely heading";
"paywall_benefit_noads_title" = "No ads, ever";
"paywall_benefit_noads_subtitle" = "Clean, focused experience with no distractions";
// MARK: - Settings
"settings_title" = "Settings";
@@ -678,3 +674,38 @@
"add_source_value_header" = "Today's Value";
"add_source_value_footer" = "Saved as your first check-in — your progress starts today.";
"quick_update_add_first_source" = "Add Your First Source";
/* MARK: - Goal scope (category) */
"goal_scope_section" = "Scope";
"goal_scope_category" = "Category";
"goal_scope_all_categories" = "All categories";
"goal_scope_footer_all" = "Every source in your portfolio counts towards this goal.";
"goal_scope_footer_category" = "Only sources in this category count towards this goal.";
// MARK: - Biometrics (1.7.0 — Face ID / Touch ID / Optic ID aware)
"biometric_generic_name" = "Face ID or Touch ID";
"biometric_unlock_with" = "Unlock with %@";
"biometric_failed" = "%@ failed. Please try again.";
"biometric_enable" = "Enable %@";
"biometric_unavailable_title" = "%@ Unavailable";
"biometric_unavailable_body" = "%@ isn't available on this device.";
"biometric_pin_required" = "Enable a PIN before turning on %@.";
"biometric_disable_first_title" = "Disable %@ First";
"biometric_disable_first_body" = "Turn off %@ before disabling your PIN.";
"biometric_pin_to_disable" = "Enter PIN to Disable %@";
"biometric_require_to_reveal" = "Require %@ to Reveal";
"biometric_lock_footer" = "Use %@ or a 4-digit PIN to protect your data.";
"privacy_footer_biometric" = "Hide all monetary amounts behind a blur. Great for using the app in public or taking screenshots. Optionally require %@ to reveal them.";
// MARK: - What's New 1.7.0
"whats_new_170_duo_title" = "Ready for iPhone Duo";
"whats_new_170_duo_body" = "On the inner screen, Sources and Journal show list and detail side by side, Charts get a sidebar and taller plots, and your place is kept when you open or close the phone.";
"whats_new_170_inspector_title" = "Quick Update beside your charts";
"whats_new_170_inspector_body" = "On wide screens the monthly Quick Update opens in a side panel, so the dashboard stays visible while you type values.";
"whats_new_170_biometrics_title" = "Face ID, Touch ID or Optic ID";
"whats_new_170_biometrics_body" = "App Lock and balance privacy now use whichever biometrics your device has.";
/* 1.7.0 privacy cleanup: on-device diagnostics + honest backup wording */
"Share Diagnostics" = "Share Diagnostics";
"diagnostics_footer" = "Crash and hang reports collected by iOS on this device. They are never uploaded anywhere — sharing one is the only way it leaves your iPhone.";
"backups_footer" = "Backups are readable JSON files kept on this device, and in your iCloud Drive when enabled. They are not encrypted by the app: anyone who can open the file can read your balances.";
@@ -154,8 +154,6 @@
"paywall_benefit_charts_subtitle" = "Asignación, drawdown, rendimiento — todo en un lugar";
"paywall_benefit_forecasts_title" = "Previsiones a 12 meses";
"paywall_benefit_forecasts_subtitle" = "Ve a dónde se dirige tu cartera";
"paywall_benefit_noads_title" = "Sin anuncios, nunca";
"paywall_benefit_noads_subtitle" = "Una experiencia limpia y sin distracciones";
// MARK: - Onboarding pages (1.2.0)
"onboarding_clarity_title" = "Sabe exactamente dónde estás";
@@ -629,3 +627,38 @@
"add_source_value_header" = "Valor de hoy";
"add_source_value_footer" = "Se guarda como tu primer check-in: tu progreso empieza hoy.";
"quick_update_add_first_source" = "Añade tu primera fuente";
/* MARK: - Goal scope (category) */
"goal_scope_section" = "Ámbito";
"goal_scope_category" = "Categoría";
"goal_scope_all_categories" = "Todas las categorías";
"goal_scope_footer_all" = "Todas las fuentes de tu cartera cuentan para este objetivo.";
"goal_scope_footer_category" = "Solo las fuentes de esta categoría cuentan para este objetivo.";
// MARK: - Biometrics (1.7.0 — Face ID / Touch ID / Optic ID aware)
"biometric_generic_name" = "Face ID o Touch ID";
"biometric_unlock_with" = "Desbloquear con %@";
"biometric_failed" = "%@ ha fallado. Inténtalo de nuevo.";
"biometric_enable" = "Activar %@";
"biometric_unavailable_title" = "%@ no disponible";
"biometric_unavailable_body" = "%@ no está disponible en este dispositivo.";
"biometric_pin_required" = "Activa un PIN antes de activar %@.";
"biometric_disable_first_title" = "Desactiva %@ primero";
"biometric_disable_first_body" = "Desactiva %@ antes de desactivar tu PIN.";
"biometric_pin_to_disable" = "Introduce el PIN para desactivar %@";
"biometric_require_to_reveal" = "Requerir %@ para mostrar";
"biometric_lock_footer" = "Usa %@ o un PIN de 4 dígitos para proteger tus datos.";
"privacy_footer_biometric" = "Oculta todas las cifras tras un difuminado. Ideal para usar la app en público o hacer capturas. Opcionalmente, requiere %@ para mostrarlas.";
// MARK: - What's New 1.7.0
"whats_new_170_duo_title" = "Preparada para iPhone Duo";
"whats_new_170_duo_body" = "En la pantalla interior, Fuentes y Diario muestran lista y detalle a la vez, Gráficos gana una barra lateral y más altura, y no pierdes el sitio al abrir o cerrar el teléfono.";
"whats_new_170_inspector_title" = "Actualización rápida junto a tus gráficos";
"whats_new_170_inspector_body" = "En pantallas anchas la actualización rápida mensual se abre en un panel lateral: el panel principal sigue visible mientras escribes valores.";
"whats_new_170_biometrics_title" = "Face ID, Touch ID u Optic ID";
"whats_new_170_biometrics_body" = "El bloqueo de la app y la privacidad de saldos usan la biometría que tenga tu dispositivo.";
/* 1.7.0 privacy cleanup: on-device diagnostics + honest backup wording */
"Share Diagnostics" = "Compartir diagnósticos";
"diagnostics_footer" = "Informes de fallos y bloqueos que iOS ha recogido en este dispositivo. Nunca se envían a ningún sitio: compartir uno es la única forma de que salga de tu iPhone.";
"backups_footer" = "Las copias de seguridad son ficheros JSON legibles guardados en este dispositivo, y en tu iCloud Drive si lo activas. La app no las cifra: cualquiera que pueda abrir el fichero puede leer tus saldos.";
@@ -85,16 +85,12 @@
"feature_predictions_desc" = "Prévisions IA sur 12 mois";
"feature_export" = "Exporter les données";
"feature_export_desc" = "Export vers CSV et JSON";
"feature_no_ads" = "Sans publicité";
"feature_no_ads_desc" = "Expérience sans publicité à vie";
"paywall_benefit_history_title" = "Votre historique complet";
"paywall_benefit_history_subtitle" = "Chaque snapshot, contribution et gain depuis le premier jour";
"paywall_benefit_charts_title" = "Des graphiques qui révèlent des tendances";
"paywall_benefit_charts_subtitle" = "Allocation, drawdown, performance, le tout au même endroit";
"paywall_benefit_forecasts_title" = "Prévisions à 12 mois";
"paywall_benefit_forecasts_subtitle" = "Voyez où votre portefeuille pourrait aller";
"paywall_benefit_noads_title" = "Aucune pub, jamais";
"paywall_benefit_noads_subtitle" = "Une expérience propre et centrée, sans distractions";
"settings_title" = "Réglages";
"subscription" = "Abonnement";
"notifications" = "Notifications";
@@ -677,3 +673,38 @@
"add_source_value_header" = "Valeur du jour";
"add_source_value_footer" = "Enregistrée comme votre premier check-in : votre progression commence aujourd'hui.";
"quick_update_add_first_source" = "Ajouter votre première source";
/* MARK: - Goal scope (category) */
"goal_scope_section" = "Périmètre";
"goal_scope_category" = "Catégorie";
"goal_scope_all_categories" = "Toutes les catégories";
"goal_scope_footer_all" = "Toutes les sources de votre portefeuille comptent pour cet objectif.";
"goal_scope_footer_category" = "Seules les sources de cette catégorie comptent pour cet objectif.";
// MARK: - Biometrics (1.7.0 — Face ID / Touch ID / Optic ID aware)
"biometric_generic_name" = "Face ID ou Touch ID";
"biometric_unlock_with" = "Déverrouiller avec %@";
"biometric_failed" = "Échec de %@. Veuillez réessayer.";
"biometric_enable" = "Activer %@";
"biometric_unavailable_title" = "%@ indisponible";
"biometric_unavailable_body" = "%@ n'est pas disponible sur cet appareil.";
"biometric_pin_required" = "Définissez un code PIN avant d'activer %@.";
"biometric_disable_first_title" = "Désactivez d'abord %@";
"biometric_disable_first_body" = "Désactivez %@ avant de désactiver votre code PIN.";
"biometric_pin_to_disable" = "Saisissez le code PIN pour désactiver %@";
"biometric_require_to_reveal" = "Exiger %@ pour afficher";
"biometric_lock_footer" = "Protégez vos données avec %@ ou un code PIN à 4 chiffres.";
"privacy_footer_biometric" = "Masque tous les montants derrière un flou. Idéal pour utiliser l'app en public ou faire des captures. Exigez %@ pour les afficher, en option.";
// MARK: - What's New 1.7.0
"whats_new_170_duo_title" = "Prête pour iPhone Duo";
"whats_new_170_duo_body" = "Sur l'écran intérieur, Sources et Journal affichent liste et détail côte à côte, Graphiques gagne une barre latérale et plus de hauteur, et vous gardez votre position en ouvrant ou fermant le téléphone.";
"whats_new_170_inspector_title" = "Mise à jour rapide à côté de vos graphiques";
"whats_new_170_inspector_body" = "Sur les grands écrans, la mise à jour rapide mensuelle s'ouvre dans un panneau latéral : le tableau de bord reste visible pendant la saisie.";
"whats_new_170_biometrics_title" = "Face ID, Touch ID ou Optic ID";
"whats_new_170_biometrics_body" = "Le verrouillage de l'app et la confidentialité des soldes utilisent la biométrie de votre appareil.";
/* 1.7.0 privacy cleanup: on-device diagnostics + honest backup wording */
"Share Diagnostics" = "Partager les diagnostics";
"diagnostics_footer" = "Rapports de plantage et de blocage collectés par iOS sur cet appareil. Ils ne sont jamais envoyés nulle part : les partager est le seul moyen qu'ils quittent votre iPhone.";
"backups_footer" = "Les sauvegardes sont des fichiers JSON lisibles conservés sur cet appareil, et dans votre iCloud Drive si vous l'activez. L'app ne les chiffre pas : quiconque peut ouvrir le fichier peut lire vos soldes.";
@@ -85,16 +85,12 @@
"feature_predictions_desc" = "Previsioni a 12 mesi basate su IA";
"feature_export" = "Esporta dati";
"feature_export_desc" = "Esporta in CSV e JSON";
"feature_no_ads" = "Niente pubblicità";
"feature_no_ads_desc" = "Esperienza senza pubblicità per sempre";
"paywall_benefit_history_title" = "La tua cronologia completa";
"paywall_benefit_history_subtitle" = "Ogni snapshot, contributo e guadagno dal primo giorno";
"paywall_benefit_charts_title" = "Grafici che rivelano schemi";
"paywall_benefit_charts_subtitle" = "Allocazione, drawdown, performance: tutto in un unico posto";
"paywall_benefit_forecasts_title" = "Previsioni a 12 mesi";
"paywall_benefit_forecasts_subtitle" = "Guarda dove probabilmente andrà il tuo portafoglio";
"paywall_benefit_noads_title" = "Niente pubblicità, mai";
"paywall_benefit_noads_subtitle" = "Esperienza pulita e focalizzata, senza distrazioni";
"settings_title" = "Impostazioni";
"subscription" = "Abbonamento";
"notifications" = "Notifiche";
@@ -677,3 +673,38 @@
"add_source_value_header" = "Valore di oggi";
"add_source_value_footer" = "Salvato come primo check-in: i tuoi progressi iniziano oggi.";
"quick_update_add_first_source" = "Aggiungi la tua prima fonte";
/* MARK: - Goal scope (category) */
"goal_scope_section" = "Ambito";
"goal_scope_category" = "Categoria";
"goal_scope_all_categories" = "Tutte le categorie";
"goal_scope_footer_all" = "Tutte le fonti del tuo portafoglio contano per questo obiettivo.";
"goal_scope_footer_category" = "Solo le fonti di questa categoria contano per questo obiettivo.";
// MARK: - Biometrics (1.7.0 — Face ID / Touch ID / Optic ID aware)
"biometric_generic_name" = "Face ID o Touch ID";
"biometric_unlock_with" = "Sblocca con %@";
"biometric_failed" = "%@ non riuscito. Riprova.";
"biometric_enable" = "Attiva %@";
"biometric_unavailable_title" = "%@ non disponibile";
"biometric_unavailable_body" = "%@ non è disponibile su questo dispositivo.";
"biometric_pin_required" = "Imposta un PIN prima di attivare %@.";
"biometric_disable_first_title" = "Disattiva prima %@";
"biometric_disable_first_body" = "Disattiva %@ prima di disattivare il PIN.";
"biometric_pin_to_disable" = "Inserisci il PIN per disattivare %@";
"biometric_require_to_reveal" = "Richiedi %@ per mostrare";
"biometric_lock_footer" = "Proteggi i tuoi dati con %@ o un PIN a 4 cifre.";
"privacy_footer_biometric" = "Nasconde tutti gli importi dietro una sfocatura. Ideale per usare l'app in pubblico o fare screenshot. Facoltativamente richiedi %@ per mostrarli.";
// MARK: - What's New 1.7.0
"whats_new_170_duo_title" = "Pronta per iPhone Duo";
"whats_new_170_duo_body" = "Sullo schermo interno, Fonti e Diario mostrano elenco e dettaglio affiancati, Grafici ottiene una barra laterale e più altezza, e non perdi la posizione aprendo o chiudendo il telefono.";
"whats_new_170_inspector_title" = "Aggiornamento rapido accanto ai grafici";
"whats_new_170_inspector_body" = "Sugli schermi ampi l'aggiornamento rapido mensile si apre in un pannello laterale: la dashboard resta visibile mentre inserisci i valori.";
"whats_new_170_biometrics_title" = "Face ID, Touch ID o Optic ID";
"whats_new_170_biometrics_body" = "Blocco app e privacy dei saldi usano la biometria disponibile sul tuo dispositivo.";
/* 1.7.0 privacy cleanup: on-device diagnostics + honest backup wording */
"Share Diagnostics" = "Condividi diagnostica";
"diagnostics_footer" = "Rapporti di crash e blocchi raccolti da iOS su questo dispositivo. Non vengono mai inviati da nessuna parte: condividerne uno è l'unico modo per farlo uscire dal tuo iPhone.";
"backups_footer" = "I backup sono file JSON leggibili conservati su questo dispositivo, e nel tuo iCloud Drive se lo attivi. L'app non li cifra: chiunque possa aprire il file può leggere i tuoi saldi.";
@@ -85,16 +85,12 @@
"feature_predictions_desc" = "AI による12か月予測";
"feature_export" = "データを書き出し";
"feature_export_desc" = "CSV と JSON に書き出し";
"feature_no_ads" = "広告なし";
"feature_no_ads_desc" = "ずっと広告なしで使えます";
"paywall_benefit_history_title" = "完全な履歴";
"paywall_benefit_history_subtitle" = "初日からのすべての snapshot、追加資金、利益を確認";
"paywall_benefit_charts_title" = "傾向が見えるチャート";
"paywall_benefit_charts_subtitle" = "配分、ドローダウン、パフォーマンスを一か所で確認";
"paywall_benefit_forecasts_title" = "12か月予測";
"paywall_benefit_forecasts_subtitle" = "ポートフォリオの行き先を見通せます";
"paywall_benefit_noads_title" = "広告は一切なし";
"paywall_benefit_noads_subtitle" = "気が散らない、集中できる体験";
"settings_title" = "設定";
"subscription" = "サブスクリプション";
"notifications" = "通知";
@@ -677,3 +673,38 @@
"add_source_value_header" = "今日の評価額";
"add_source_value_footer" = "最初のチェックインとして保存され、今日から進捗の記録が始まります。";
"quick_update_add_first_source" = "最初の投資先を追加";
/* MARK: - Goal scope (category) */
"goal_scope_section" = "対象範囲";
"goal_scope_category" = "カテゴリ";
"goal_scope_all_categories" = "すべてのカテゴリ";
"goal_scope_footer_all" = "ポートフォリオのすべての投資先がこの目標に加算されます。";
"goal_scope_footer_category" = "このカテゴリの投資先のみがこの目標に加算されます。";
// MARK: - Biometrics (1.7.0 — Face ID / Touch ID / Optic ID aware)
"biometric_generic_name" = "Face IDまたはTouch ID";
"biometric_unlock_with" = "%@でロック解除";
"biometric_failed" = "%@に失敗しました。もう一度お試しください。";
"biometric_enable" = "%@を有効にする";
"biometric_unavailable_title" = "%@は利用できません";
"biometric_unavailable_body" = "このデバイスでは%@を利用できません。";
"biometric_pin_required" = "%@を有効にする前にPINを設定してください。";
"biometric_disable_first_title" = "先に%@を無効にしてください";
"biometric_disable_first_body" = "PINを無効にする前に%@を無効にしてください。";
"biometric_pin_to_disable" = "%@を無効にするにはPINを入力";
"biometric_require_to_reveal" = "表示に%@を要求";
"biometric_lock_footer" = "%@または4桁のPINでデータを保護します。";
"privacy_footer_biometric" = "すべての金額をぼかして隠します。人前での利用やスクリーンショットに最適。表示に%@を要求することもできます。";
// MARK: - What's New 1.7.0
"whats_new_170_duo_title" = "iPhone Duoに対応";
"whats_new_170_duo_body" = "内側の画面では、投資先とジャーナルがリストと詳細を並べて表示し、グラフにはサイドバーと高さが加わり、本体を開閉しても表示位置が保たれます。";
"whats_new_170_inspector_title" = "グラフの横でクイック更新";
"whats_new_170_inspector_body" = "広い画面では月次のクイック更新がサイドパネルで開き、値を入力している間もダッシュボードが見えたままです。";
"whats_new_170_biometrics_title" = "Face ID、Touch ID、Optic ID";
"whats_new_170_biometrics_body" = "アプリロックと残高のプライバシーが、デバイスの生体認証の種類に合わせて動作します。";
/* 1.7.0 privacy cleanup: on-device diagnostics + honest backup wording */
"Share Diagnostics" = "診断情報を共有";
"diagnostics_footer" = "iOS がこの端末で収集したクラッシュとハングのレポートです。どこにも送信されません。共有した場合だけ iPhone の外に出ます。";
"backups_footer" = "バックアップはこの端末に保存される読み取り可能な JSON ファイルです(有効にすると iCloud Drive にも保存)。アプリは暗号化しません。ファイルを開ける人は残高を読めます。";
@@ -85,16 +85,12 @@
"feature_predictions_desc" = "Previsões de 12 meses com IA";
"feature_export" = "Exportar dados";
"feature_export_desc" = "Exporte para CSV e JSON";
"feature_no_ads" = "Sem anúncios";
"feature_no_ads_desc" = "Experiência sem anúncios para sempre";
"paywall_benefit_history_title" = "Seu histórico completo";
"paywall_benefit_history_subtitle" = "Cada snapshot, contribuição e ganho desde o primeiro dia";
"paywall_benefit_charts_title" = "Gráficos que revelam padrões";
"paywall_benefit_charts_subtitle" = "Alocação, drawdown, desempenho: tudo em um só lugar";
"paywall_benefit_forecasts_title" = "Previsões de 12 meses";
"paywall_benefit_forecasts_subtitle" = "Veja para onde sua carteira provavelmente está indo";
"paywall_benefit_noads_title" = "Sem anúncios, nunca";
"paywall_benefit_noads_subtitle" = "Experiência limpa e focada, sem distrações";
"settings_title" = "Ajustes";
"subscription" = "Assinatura";
"notifications" = "Notificações";
@@ -677,3 +673,38 @@
"add_source_value_header" = "Valor de hoje";
"add_source_value_footer" = "Salvo como seu primeiro check-in — seu progresso começa hoje.";
"quick_update_add_first_source" = "Adicionar sua primeira fonte";
/* MARK: - Goal scope (category) */
"goal_scope_section" = "Escopo";
"goal_scope_category" = "Categoria";
"goal_scope_all_categories" = "Todas as categorias";
"goal_scope_footer_all" = "Todas as fontes da sua carteira contam para esta meta.";
"goal_scope_footer_category" = "Apenas as fontes desta categoria contam para esta meta.";
// MARK: - Biometrics (1.7.0 — Face ID / Touch ID / Optic ID aware)
"biometric_generic_name" = "Face ID ou Touch ID";
"biometric_unlock_with" = "Desbloquear com %@";
"biometric_failed" = "%@ falhou. Tente novamente.";
"biometric_enable" = "Ativar %@";
"biometric_unavailable_title" = "%@ indisponível";
"biometric_unavailable_body" = "%@ não está disponível neste dispositivo.";
"biometric_pin_required" = "Defina um PIN antes de ativar o %@.";
"biometric_disable_first_title" = "Desative o %@ primeiro";
"biometric_disable_first_body" = "Desative o %@ antes de desativar seu PIN.";
"biometric_pin_to_disable" = "Digite o PIN para desativar o %@";
"biometric_require_to_reveal" = "Exigir %@ para mostrar";
"biometric_lock_footer" = "Use %@ ou um PIN de 4 dígitos para proteger seus dados.";
"privacy_footer_biometric" = "Oculta todos os valores atrás de um desfoque. Ótimo para usar o app em público ou tirar prints. Opcionalmente, exija %@ para revelá-los.";
// MARK: - What's New 1.7.0
"whats_new_170_duo_title" = "Pronto para iPhone Duo";
"whats_new_170_duo_body" = "Na tela interna, Fontes e Diário mostram lista e detalhe lado a lado, Gráficos ganha uma barra lateral e mais altura, e você não perde o lugar ao abrir ou fechar o aparelho.";
"whats_new_170_inspector_title" = "Atualização rápida ao lado dos gráficos";
"whats_new_170_inspector_body" = "Em telas largas a atualização rápida mensal abre em um painel lateral: o painel principal continua visível enquanto você digita valores.";
"whats_new_170_biometrics_title" = "Face ID, Touch ID ou Optic ID";
"whats_new_170_biometrics_body" = "O bloqueio do app e a privacidade de saldos usam a biometria disponível no seu aparelho.";
/* 1.7.0 privacy cleanup: on-device diagnostics + honest backup wording */
"Share Diagnostics" = "Compartilhar diagnósticos";
"diagnostics_footer" = "Relatórios de falhas e travamentos coletados pelo iOS neste dispositivo. Nunca são enviados para lugar nenhum: compartilhar um é a única forma de ele sair do seu iPhone.";
"backups_footer" = "Os backups são arquivos JSON legíveis guardados neste dispositivo, e no seu iCloud Drive se você ativar. O app não os criptografa: quem conseguir abrir o arquivo lê os seus saldos.";
@@ -1,215 +0,0 @@
import Foundation
import SwiftUI
import Combine
import GoogleMobileAds
import UserMessagingPlatform
import AppTrackingTransparency
import AdSupport
@MainActor
class AdMobService: ObservableObject {
// MARK: - Published Properties
@Published var isConsentObtained = false
@Published var canShowAds = false
@Published var isLoading = false
@Published var shouldRequestNonPersonalizedAds = false
// MARK: - Ad Unit IDs
// Ad Unit IDs
#if DEBUG
static let bannerAdUnitID = "ca-app-pub-3940256099942544/2934735716" // Test ad unit for development
#else
static let bannerAdUnitID = "ca-app-pub-1549720748100858/3342431241" // Production ad unit
#endif
// MARK: - Initialization
init() {
setupResetObserver()
Task {
await configureConsentAndRequestAdsIfNeeded()
}
}
// MARK: - Consent Management
func requestConsent() async {
await configureConsentAndRequestAdsIfNeeded()
}
func presentPrivacyOptions() async {
guard let root = topViewController() else { return }
await withCheckedContinuation { continuation in
ConsentForm.presentPrivacyOptionsForm(from: root) { _ in
continuation.resume()
}
}
updateConsentState()
}
func resetConsent() {
ConsentInformation.shared.reset()
UserDefaults.standard.removeObject(forKey: AppConstants.StorageKeys.adConsentObtained)
isConsentObtained = false
canShowAds = false
shouldRequestNonPersonalizedAds = false
}
private func setupResetObserver() {
NotificationCenter.default.addObserver(
forName: .didResetData,
object: nil,
queue: .main
) { [weak self] _ in
guard let self else { return }
Task { @MainActor in
self.resetConsent()
await self.configureConsentAndRequestAdsIfNeeded()
}
}
}
private func configureConsentAndRequestAdsIfNeeded() async {
isLoading = true
let parameters = RequestParameters()
await withCheckedContinuation { continuation in
ConsentInformation.shared.requestConsentInfoUpdate(with: parameters) { _ in
continuation.resume()
}
}
if let root = topViewController() {
await withCheckedContinuation { continuation in
ConsentForm.loadAndPresentIfRequired(from: root) { _ in
continuation.resume()
}
}
}
updateConsentState()
if canShowAds {
await requestTrackingIfNeeded()
updateConsentState()
}
isLoading = false
}
private func updateConsentState() {
let consentInfo = ConsentInformation.shared
canShowAds = consentInfo.canRequestAds
isConsentObtained = consentInfo.consentStatus == .obtained || consentInfo.consentStatus == .notRequired
let trackingDenied: Bool
if #available(iOS 14.5, *) {
trackingDenied = ATTrackingManager.trackingAuthorizationStatus != .authorized
} else {
trackingDenied = false
}
shouldRequestNonPersonalizedAds = !isConsentObtained || trackingDenied
UserDefaults.standard.set(isConsentObtained, forKey: AppConstants.StorageKeys.adConsentObtained)
}
private func requestTrackingIfNeeded() async {
if #available(iOS 14.5, *) {
_ = await ATTrackingManager.requestTrackingAuthorization()
}
}
private func topViewController() -> UIViewController? {
guard let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let root = scene.windows.first?.rootViewController else {
return nil
}
var current = root
while let presented = current.presentedViewController {
current = presented
}
return current
}
// MARK: - Analytics
func logAdImpression() {
FirebaseService.shared.logAdImpression(adType: "banner")
}
func logAdClick() {
FirebaseService.shared.logAdClick(adType: "banner")
}
}
// MARK: - Banner Ad Coordinator
class BannerAdCoordinator: NSObject, BannerViewDelegate {
weak var adMobService: AdMobService?
func bannerViewDidReceiveAd(_ bannerView: BannerView) {
print("Banner ad received")
adMobService?.logAdImpression()
}
func bannerView(_ bannerView: BannerView, didFailToReceiveAdWithError error: Error) {
print("Banner ad failed to load: \(error.localizedDescription)")
}
func bannerViewDidRecordImpression(_ bannerView: BannerView) {
// Impression recorded
}
func bannerViewDidRecordClick(_ bannerView: BannerView) {
adMobService?.logAdClick()
}
func bannerViewWillPresentScreen(_ bannerView: BannerView) {
// Ad will present full screen
}
func bannerViewWillDismissScreen(_ bannerView: BannerView) {
// Ad will dismiss
}
func bannerViewDidDismissScreen(_ bannerView: BannerView) {
// Ad dismissed
}
}
// MARK: - UIKit Banner View Wrapper
struct BannerAdView: UIViewRepresentable {
@EnvironmentObject var adMobService: AdMobService
func makeUIView(context: Context) -> BannerView {
let bannerView = BannerView(adSize: AdSizeBanner)
bannerView.adUnitID = AdMobService.bannerAdUnitID
// Get root view controller
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let rootViewController = windowScene.windows.first?.rootViewController {
bannerView.rootViewController = rootViewController
}
bannerView.delegate = context.coordinator
let request = Request()
if adMobService.shouldRequestNonPersonalizedAds {
let extras = Extras()
extras.additionalParameters = ["npa": "1"]
request.register(extras)
}
bannerView.load(request)
return bannerView
}
func updateUIView(_ uiView: BannerView, context: Context) {
// Banner updates automatically
}
func makeCoordinator() -> BannerAdCoordinator {
let coordinator = BannerAdCoordinator()
coordinator.adMobService = adMobService
return coordinator
}
}
@@ -60,14 +60,14 @@ struct SourceEntity: AppEntity {
struct SourceEntityQuery: EntityQuery {
func entities(for identifiers: [UUID]) async throws -> [SourceEntity] {
SharedQuickUpdateStore.readMirror()
await MainActor.run { SharedQuickUpdateStore.readMirror() }
.filter { identifiers.contains($0.id) }
.map(SourceEntity.init)
}
func suggestedEntities() async throws -> [SourceEntity] {
// Pending-first, mirroring the Share Extension's ordering.
SharedQuickUpdateStore.readMirror()
await MainActor.run { SharedQuickUpdateStore.readMirror() }
.sorted { a, b in
if a.updatedThisMonth != b.updatedThisMonth { return !a.updatedThisMonth }
return a.name.localizedCaseInsensitiveCompare(b.name) == .orderedAscending
@@ -45,7 +45,6 @@ final class ChartShareService {
static let appStoreShareURL = URL(string: "https://apps.apple.com/app/portfolio-journal-tracker/id6757678318?ct=chart_share")!
func share<Content: View>(title: String, subtitle: String, stats: [ChartStat], @ViewBuilder chart: () -> Content) {
FirebaseService.shared.logShare(type: "chart")
guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let viewController = windowScene.windows.first?.rootViewController else {
return
@@ -124,7 +123,7 @@ struct ChartShareCardView<Content: View>: View {
.font(.headline)
Text("\(title) · \(Self.dateLabel)")
.font(.subheadline)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
Spacer()
}
@@ -138,15 +137,15 @@ struct ChartShareCardView<Content: View>: View {
VStack(alignment: .leading, spacing: 3) {
Text(stats[i].label)
.font(.caption2)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
Text(stats[i].value)
.font(.subheadline.weight(.semibold))
.foregroundColor(stats[i].color)
.foregroundStyle(stats[i].color)
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(Color(.systemGray6))
.cornerRadius(8)
.clipShape(RoundedRectangle(cornerRadius: 8))
}
Spacer()
}
@@ -161,7 +160,7 @@ struct ChartShareCardView<Content: View>: View {
.font(.footnote.weight(.medium))
Text(verbatim: "portfoliojournal.app")
.font(.footnote)
.foregroundColor(.appPrimary)
.foregroundStyle(Color.appPrimary)
}
Spacer()
if let qrCodeImage {
@@ -173,7 +172,7 @@ struct ChartShareCardView<Content: View>: View {
.frame(width: 64, height: 64)
Text(String(localized: "chart_share_scan"))
.font(.caption2)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
}
}
@@ -0,0 +1,121 @@
import Foundation
import MetricKit
import os
/// On-device replacement for Crashlytics.
///
/// `MXMetricManager` hands the app a daily payload with crash, hang and
/// disk-write diagnostics that Apple already collected for it. This collector
/// writes those payloads to the app container as JSON and **never sends them
/// anywhere** no network code, no third-party SDK, no identifiers. Crash
/// reports still reach Xcode Organizer through Apple's own pipeline, which is
/// the channel that was actually useful about Crashlytics.
///
/// The user can share the stored reports from Settings; that is the only way
/// one ever leaves the device, and it is an explicit action.
final class DiagnosticsCollector: NSObject {
static let shared = DiagnosticsCollector()
/// Reports older than this are pruned on every new payload.
private static let retentionCount = 5
private let logger = Logger(subsystem: AppConstants.bundleIdentifier, category: "diagnostics")
private let fileManager = FileManager.default
private let dateFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateFormat = "yyyyMMdd-HHmmss"
formatter.locale = Locale(identifier: "en_US_POSIX")
return formatter
}()
private override init() {
super.init()
}
/// Subscribes to MetricKit. Safe to call more than once.
func start() {
MXMetricManager.shared.add(self)
}
// MARK: - Storage
/// `Application Support/Diagnostics`, created on demand.
private var directory: URL? {
guard let base = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else {
return nil
}
let url = base.appendingPathComponent("Diagnostics", isDirectory: true)
if !fileManager.fileExists(atPath: url.path) {
try? fileManager.createDirectory(at: url, withIntermediateDirectories: true)
}
return url
}
/// Stored reports, newest first. Used by Settings to offer sharing them.
func storedReports() -> [URL] {
guard let directory else { return [] }
let contents = (try? fileManager.contentsOfDirectory(
at: directory,
includingPropertiesForKeys: [.contentModificationDateKey]
)) ?? []
return contents
.filter { $0.pathExtension == "json" }
.sorted { lhs, rhs in
let l = (try? lhs.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? .distantPast
let r = (try? rhs.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? .distantPast
return l > r
}
}
func deleteStoredReports() {
for url in storedReports() {
try? fileManager.removeItem(at: url)
}
}
private func write(_ data: Data, prefix: String) {
guard let directory else { return }
let name = "\(prefix)-\(dateFormatter.string(from: Date())).json"
let url = directory.appendingPathComponent(name)
do {
try data.write(to: url, options: .atomic)
// Diagnostics are debugging aids, not user data: keep them out of
// iCloud/iTunes backups.
var resourceValues = URLResourceValues()
resourceValues.isExcludedFromBackup = true
var mutableURL = url
try? mutableURL.setResourceValues(resourceValues)
logger.info("Stored diagnostic report \(name, privacy: .public)")
} catch {
logger.error("Could not store diagnostic report: \(error.localizedDescription, privacy: .public)")
}
prune()
}
private func prune() {
let reports = storedReports()
guard reports.count > Self.retentionCount else { return }
for url in reports.dropFirst(Self.retentionCount) {
try? fileManager.removeItem(at: url)
}
}
}
// MARK: - MXMetricManagerSubscriber
extension DiagnosticsCollector: MXMetricManagerSubscriber {
func didReceive(_ payloads: [MXMetricPayload]) {
for payload in payloads {
write(payload.jsonRepresentation(), prefix: "metrics")
}
}
func didReceive(_ payloads: [MXDiagnosticPayload]) {
for payload in payloads {
let crashes = payload.crashDiagnostics?.count ?? 0
let hangs = payload.hangDiagnostics?.count ?? 0
logger.info("Diagnostic payload: \(crashes, privacy: .public) crash(es), \(hangs, privacy: .public) hang(s)")
write(payload.jsonRepresentation(), prefix: "diagnostics")
}
}
}
+3 -11
View File
@@ -154,7 +154,8 @@ class ExportService {
var goalDict: [String: Any] = [
"name": goal.name,
"isActive": goal.isActive,
"account": goal.account?.name as Any
"account": goal.account?.name as Any,
"category": goal.category?.name as Any
]
if let amount = goal.targetAmount {
goalDict["targetAmount"] = amount.doubleValue
@@ -257,18 +258,9 @@ class ExportService {
)
}
viewController.present(activityVC, animated: true) {
FirebaseService.shared.logExportAttempt(
format: format.rawValue,
success: true
)
}
viewController.present(activityVC, animated: true)
} catch {
print("Export error: \(error)")
FirebaseService.shared.logExportAttempt(
format: format.rawValue,
success: false
)
}
}
@@ -1,226 +0,0 @@
import Foundation
import FirebaseAnalytics
import FirebaseCore
class FirebaseService {
static let shared = FirebaseService()
private var isConfigured: Bool {
FirebaseApp.app() != nil
}
private init() {}
// MARK: - User Properties
func setUserTier(_ tier: UserTier) {
guard isConfigured else { return }
Analytics.setUserProperty(tier.rawValue, forName: "user_tier")
}
enum UserTier: String {
case free = "free"
case premium = "premium"
}
// MARK: - Screen Tracking
func logScreenView(screenName: String, screenClass: String? = nil) {
guard isConfigured else { return }
Analytics.logEvent(AnalyticsEventScreenView, parameters: [
AnalyticsParameterScreenName: screenName,
AnalyticsParameterScreenClass: screenClass ?? screenName
])
}
// MARK: - Investment Events
func logSourceAdded(categoryName: String, sourceCount: Int) {
guard isConfigured else { return }
Analytics.logEvent("source_added", parameters: [
"category_name": categoryName,
"total_sources": sourceCount
])
}
func logSourceDeleted(categoryName: String) {
guard isConfigured else { return }
Analytics.logEvent("source_deleted", parameters: [
"category_name": categoryName
])
}
func logSnapshotAdded(sourceName: String, value: Decimal) {
guard isConfigured else { return }
Analytics.logEvent("snapshot_added", parameters: [
"source_name": sourceName,
"value": NSDecimalNumber(decimal: value).doubleValue
])
}
func logCategoryCreated(name: String) {
guard isConfigured else { return }
Analytics.logEvent("category_created", parameters: [
"category_name": name
])
}
// MARK: - Purchase Events
func logPaywallShown(trigger: String) {
guard isConfigured else { return }
Analytics.logEvent("paywall_shown", parameters: [
"paywall_trigger": trigger
])
}
func logPurchaseAttempt(productId: String) {
guard isConfigured else { return }
Analytics.logEvent("purchase_attempt", parameters: [
"product_id": productId
])
}
func logPurchaseSuccess(productId: String, price: Decimal, isFamilyShared: Bool) {
guard isConfigured else { return }
Analytics.logEvent(AnalyticsEventPurchase, parameters: [
AnalyticsParameterItemID: productId,
AnalyticsParameterPrice: NSDecimalNumber(decimal: price).doubleValue,
AnalyticsParameterCurrency: "EUR",
"is_family_shared": isFamilyShared
])
}
func logPurchaseFailure(productId: String, error: String) {
guard isConfigured else { return }
Analytics.logEvent("purchase_failed", parameters: [
"product_id": productId,
"error": error
])
}
func logRestorePurchases(success: Bool) {
guard isConfigured else { return }
Analytics.logEvent("restore_purchases", parameters: [
"success": success
])
}
// MARK: - Feature Usage Events
func logChartViewed(chartType: String, isPremium: Bool) {
guard isConfigured else { return }
Analytics.logEvent("chart_viewed", parameters: [
"chart_type": chartType,
"is_premium_chart": isPremium
])
}
func logPredictionViewed(algorithm: String) {
guard isConfigured else { return }
Analytics.logEvent("prediction_viewed", parameters: [
"algorithm": algorithm
])
}
func logExportAttempt(format: String, success: Bool) {
guard isConfigured else { return }
Analytics.logEvent("export_attempt", parameters: [
"format": format,
"success": success
])
}
func logNotificationScheduled(frequency: String) {
guard isConfigured else { return }
Analytics.logEvent("notification_scheduled", parameters: [
"frequency": frequency
])
}
// MARK: - Ad Events
func logAdImpression(adType: String) {
guard isConfigured else { return }
Analytics.logEvent("ad_impression", parameters: [
"ad_type": adType
])
}
func logAdClick(adType: String) {
guard isConfigured else { return }
Analytics.logEvent("ad_click", parameters: [
"ad_type": adType
])
}
// MARK: - Engagement Events
func logOnboardingCompleted(stepCount: Int) {
guard isConfigured else { return }
Analytics.logEvent("onboarding_completed", parameters: [
"steps_completed": stepCount
])
}
/// Per-step onboarding funnel event lets us see exactly where users drop off
/// inside onboarding (only the final `onboarding_completed` existed before).
func logOnboardingStep(step: Int) {
guard isConfigured else { return }
Analytics.logEvent("onboarding_step", parameters: [
"step": step
])
}
func logOnboardingSkipped(atStep step: Int) {
guard isConfigured else { return }
Analytics.logEvent("onboarding_skipped", parameters: [
"step": step
])
}
/// Viral loop measurement: fired whenever the user opens a share sheet with
/// app content (portfolio image, check-in, goal). `type` identifies the surface.
func logShare(type: String) {
guard isConfigured else { return }
Analytics.logEvent("content_shared", parameters: [
"share_type": type
])
}
func logWidgetUsed(widgetType: String) {
guard isConfigured else { return }
Analytics.logEvent("widget_used", parameters: [
"widget_type": widgetType
])
}
func logAppOpened(fromWidget: Bool) {
guard isConfigured else { return }
Analytics.logEvent("app_opened", parameters: [
"from_widget": fromWidget
])
}
// MARK: - Portfolio Events
func logPortfolioMilestone(totalValue: Decimal, milestone: String) {
guard isConfigured else { return }
Analytics.logEvent("portfolio_milestone", parameters: [
"total_value": NSDecimalNumber(decimal: totalValue).doubleValue,
"milestone": milestone
])
}
// MARK: - Error Events
func logError(type: String, message: String, context: String? = nil) {
guard isConfigured else { return }
Analytics.logEvent("app_error", parameters: [
"error_type": type,
"error_message": message,
"context": context ?? ""
])
}
}
@@ -60,7 +60,6 @@ class GoalShareService {
estimatedCompletionDate: Date? = nil,
privacyMode: Bool = false
) {
FirebaseService.shared.logShare(type: "goal")
guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let viewController = windowScene.windows.first?.rootViewController else {
return
+1 -16
View File
@@ -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"))
]}
}
+36 -11
View File
@@ -87,6 +87,7 @@ class ImportService {
let targetDate: Date?
let isActive: Bool
let accountName: String?
let categoryName: String?
}
struct ImportedJournalEntry {
@@ -386,8 +387,16 @@ Personal,Real Estate,Rental Property,2024-01-01,82000,80000,Estimated value
let trimmedName = importedGoal.name.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedName.isEmpty, importedGoal.targetAmount != nil else { continue }
let resolvedAccount = importedGoal.accountName.flatMap { accountsByName[$0] }
if fetchGoal(named: trimmedName, account: resolvedAccount, in: context) == nil {
let key = "\(trimmedName.lowercased())|\(resolvedAccount?.name ?? "")"
let resolvedCategory = importedGoal.categoryName.flatMap {
resolveExistingCategory(named: $0, lookup: categoryLookup)
}
if fetchGoal(
named: trimmedName,
account: resolvedAccount,
category: resolvedCategory,
in: context
) == nil {
let key = "\(trimmedName.lowercased())|\(resolvedAccount?.name ?? "")|\(resolvedCategory?.name ?? "")"
if !plannedGoalKeys.contains(key) {
plannedGoalKeys.insert(key)
preview.goalsToCreate += 1
@@ -666,12 +675,14 @@ Personal,Real Estate,Rental Property,2024-01-01,82000,80000,Estimated value
.flatMap { ISO8601DateFormatter().date(from: $0) }
let isActive = goalDict["isActive"] as? Bool ?? true
let accountName = goalDict["account"] as? String
let categoryName = goalDict["category"] as? String
return ImportedGoal(
name: name,
targetAmount: targetAmount,
targetDate: targetDate,
isActive: isActive,
accountName: accountName
accountName: accountName,
categoryName: categoryName
)
}
}
@@ -863,12 +874,21 @@ Personal,Real Estate,Rental Property,2024-01-01,82000,80000,Estimated value
guard let targetAmount = importedGoal.targetAmount else { continue }
let resolvedAccount = importedGoal.accountName.flatMap { accountsByName[$0] }
if fetchGoal(named: trimmedName, account: resolvedAccount, in: context) == nil {
let resolvedCategory = importedGoal.categoryName.flatMap {
resolveExistingCategory(named: $0, lookup: categoryLookup)
}
if fetchGoal(
named: trimmedName,
account: resolvedAccount,
category: resolvedCategory,
in: context
) == nil {
let goal = Goal(context: context)
goal.name = trimmedName
goal.targetAmount = NSDecimalNumber(decimal: targetAmount)
goal.targetDate = importedGoal.targetDate
goal.account = resolvedAccount
goal.category = resolvedCategory
goal.isActive = importedGoal.isActive
}
}
@@ -1002,22 +1022,27 @@ Personal,Real Estate,Rental Property,2024-01-01,82000,80000,Estimated value
return try? context.fetch(request).first
}
/// Fetch an existing goal by name (case-insensitive) within the same account.
/// Fetch an existing goal by name (case-insensitive) within the same scope
/// (account + category).
private func fetchGoal(
named name: String,
account: Account?,
category: Category?,
in context: NSManagedObjectContext
) -> Goal? {
let request: NSFetchRequest<Goal> = Goal.fetchRequest()
var predicates: [NSPredicate] = [NSPredicate(format: "name ==[c] %@", name)]
if let account = account {
request.predicate = NSPredicate(
format: "name ==[c] %@ AND account == %@", name, account
)
predicates.append(NSPredicate(format: "account == %@", account))
} else {
request.predicate = NSPredicate(
format: "name ==[c] %@ AND account == nil", name
)
predicates.append(NSPredicate(format: "account == nil"))
}
if let category = category {
predicates.append(NSPredicate(format: "category == %@", category))
} else {
predicates.append(NSPredicate(format: "category == nil"))
}
request.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: predicates)
request.fetchLimit = 1
return try? context.fetch(request).first
}
@@ -95,7 +95,6 @@ class NotificationService: ObservableObject {
print("Failed to schedule notification: \(error)")
} else {
print("Scheduled reminder for \(source.name) on \(triggerDate)")
FirebaseService.shared.logNotificationScheduled(frequency: source.notificationFrequency)
}
}
}
@@ -527,12 +526,7 @@ private extension NotificationService {
let cal = Calendar.current
for goal in goals {
let goalSources: [InvestmentSource]
if let accId = goal.account?.id {
goalSources = sources.filter { $0.account?.id == accId }
} else {
goalSources = sources
}
let goalSources = goal.relevantSources(from: sources)
let evolution = monthlyEvolution(from: goalSources)
guard evolution.count >= 3,
@@ -15,18 +15,18 @@ final class ReviewPromptService {
private let minDaysBetweenPrompts = 30
private let userDefaults: UserDefaults
private let dateProvider: () -> Date
private let reviewRequestHandler: () -> Void
private let reviewRequestHandler: @MainActor () -> Void
private init() {
userDefaults = .standard
dateProvider = Date.init
reviewRequestHandler = ReviewPromptService.defaultReviewRequestHandler
reviewRequestHandler = { ReviewPromptService.defaultReviewRequestHandler() }
}
init(
userDefaults: UserDefaults,
dateProvider: @escaping () -> Date,
reviewRequestHandler: @escaping () -> Void
reviewRequestHandler: @escaping @MainActor () -> Void
) {
self.userDefaults = userDefaults
self.dateProvider = dateProvider
@@ -93,6 +93,7 @@ final class ReviewPromptService {
userDefaults.set(0, forKey: checkInCountKey)
}
@MainActor
private static func defaultReviewRequestHandler() {
guard let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene else { return }
if #available(iOS 18.0, *) {
@@ -100,7 +100,6 @@ class ShareService {
@MainActor
func shareMonthlyCheckIn(summary: MonthlySummary, appName: String) {
FirebaseService.shared.logShare(type: "monthly_checkin")
let text = Self.buildMonthlyCheckInShareText(summary: summary, appName: appName)
shareCard(
cardTitle: "\(summary.periodLabel) Check-in",
@@ -127,7 +126,6 @@ class ShareService {
@MainActor
func shareMonthlySummary(_ data: MonthlySummaryShareData) {
FirebaseService.shared.logShare(type: "monthly_summary")
let appName = Self.appDisplayName
var lines = [
@@ -178,7 +176,6 @@ class ShareService {
percentOnly: Bool = false,
storyFormat: Bool = false
) {
FirebaseService.shared.logShare(type: percentOnly ? "portfolio_value_percent" : "portfolio_value")
let appName = Self.appDisplayName
let display = Self.portfolioCardStrings(
totalValue: totalValue,
@@ -0,0 +1,196 @@
import Foundation
import CoreData
import WatchConnectivity
/// Ships a `WatchPortfolioSnapshot` from the iPhone to the paired Apple Watch.
///
/// The watch app and its complications are read-only, so a single application
/// context always the latest state, coalesced by the system is enough. The
/// watch may also ask for a fresh copy (`WatchSyncMessage.requestSnapshot`) when it opens
/// while the phone is reachable.
@MainActor
final class WatchSyncService: NSObject {
static let shared = WatchSyncService()
/// Largest holdings sent to the watch. A watch screen shows a handful at
/// most, and the payload must stay small.
private let maxSources = 6
private let maxGoals = 5
private var session: WCSession? {
WCSession.isSupported() ? WCSession.default : nil
}
/// Last payload actually handed to WatchConnectivity, so repeated data
/// changes that produce an identical snapshot don't queue redundant work.
private var lastSentSnapshot: WatchPortfolioSnapshot?
private var pendingSync = false
private override init() {
super.init()
}
// MARK: - Lifecycle
func activate() {
guard let session else { return }
session.delegate = self
if session.activationState != .activated {
session.activate()
}
}
/// Coalesces the bursts of Core Data changes a single user action produces
/// (save snapshot repository refresh widget refresh) into one send.
func scheduleSync() {
guard session != nil, !pendingSync else { return }
pendingSync = true
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
self?.pendingSync = false
self?.syncNow()
}
}
func syncNow() {
guard let session, session.activationState == .activated else { return }
let snapshot = buildSnapshot()
guard snapshot != lastSentSnapshot else { return }
guard let context = snapshot.applicationContext() else { return }
do {
try session.updateApplicationContext(context)
lastSentSnapshot = snapshot
} catch {
// Not fatal: the watch keeps its cached copy and can pull a fresh
// one next time it opens with the phone reachable.
print("Watch sync failed: \(error.localizedDescription)")
}
}
// MARK: - Snapshot building
func buildSnapshot(referenceDate: Date = Date()) -> WatchPortfolioSnapshot {
let context = CoreDataStack.shared.viewContext
let sourceRequest: NSFetchRequest<InvestmentSource> = InvestmentSource.fetchRequest()
sourceRequest.predicate = NSPredicate(format: "isActive == YES")
let sources = (try? context.fetch(sourceRequest)) ?? []
let totalValue = sources.reduce(Decimal.zero) { $0 + $1.latestValue }
// Value at the close of the previous month, per source, so the change
// shown on the watch matches the dashboard's month change.
let previousMonthEnd = referenceDate.startOfMonth.adding(months: -1).endOfMonth
let previousTotal = sources.reduce(Decimal.zero) { partial, source in
let snapshot = source.sortedSnapshotsByDateAscending.last { $0.date <= previousMonthEnd }
return partial + (snapshot?.value?.decimalValue ?? .zero)
}
let monthChange = totalValue - previousTotal
let monthChangePercent: Double = previousTotal > 0
? NSDecimalNumber(decimal: monthChange / previousTotal).doubleValue
: 0
let topSources = sources
.sorted { $0.latestValue > $1.latestValue }
.prefix(maxSources)
.map { source in
WatchPortfolioSnapshot.Source(
id: source.id,
name: source.name,
categoryName: source.category?.name,
categoryColorHex: source.category?.colorHex,
value: NSDecimalNumber(decimal: source.latestValue).doubleValue
)
}
let goalRequest: NSFetchRequest<Goal> = Goal.fetchRequest()
goalRequest.predicate = NSPredicate(format: "isActive == YES")
let goals = (try? context.fetch(goalRequest)) ?? []
let watchGoals = goals
.map { goal -> WatchPortfolioSnapshot.Goal in
let current = goal.relevantSources(from: sources)
.reduce(Decimal.zero) { $0 + $1.latestValue }
return WatchPortfolioSnapshot.Goal(
id: goal.id,
name: goal.name,
categoryName: goal.category?.name,
categoryIcon: goal.category?.icon,
categoryColorHex: goal.category?.colorHex,
currentValue: NSDecimalNumber(decimal: current).doubleValue,
targetValue: NSDecimalNumber(decimal: goal.targetDecimal).doubleValue
)
}
.sorted { $0.progress > $1.progress }
.prefix(maxGoals)
let stats = MonthlyCheckInStore.stats(referenceDate: referenceDate)
let dueMonth = MonthlyCheckInStore.effectiveMonth(
for: referenceDate,
relativeTo: referenceDate,
graceDays: MonthlyCheckInStore.graceDays
)
let checkInDone = MonthlyCheckInStore.completionDate(for: dueMonth) != nil
return WatchPortfolioSnapshot(
generatedAt: referenceDate,
currencyCode: AppSettings.getOrCreate(in: context).currency,
totalValue: NSDecimalNumber(decimal: totalValue).doubleValue,
monthChange: NSDecimalNumber(decimal: monthChange).doubleValue,
monthChangePercent: monthChangePercent,
sources: Array(topSources),
goals: Array(watchGoals),
currentStreak: stats.currentStreak,
bestStreak: stats.bestStreak,
checkInDone: checkInDone,
checkInMonthLabel: dueMonth.monthYearString
)
}
}
// MARK: - WCSessionDelegate
extension WatchSyncService: WCSessionDelegate {
nonisolated func session(
_ session: WCSession,
activationDidCompleteWith activationState: WCSessionActivationState,
error: Error?
) {
guard activationState == .activated else { return }
Task { @MainActor in
self.syncNow()
}
}
nonisolated func sessionDidBecomeInactive(_ session: WCSession) {}
/// The user switched to a different watch: reactivate so the new one gets
/// its own context.
nonisolated func sessionDidDeactivate(_ session: WCSession) {
session.activate()
}
nonisolated func sessionWatchStateDidChange(_ session: WCSession) {
Task { @MainActor in
self.lastSentSnapshot = nil
self.syncNow()
}
}
nonisolated func session(
_ session: WCSession,
didReceiveMessage message: [String: Any],
replyHandler: @escaping ([String: Any]) -> Void
) {
guard message[WatchSyncMessage.requestSnapshot] != nil else {
replyHandler([:])
return
}
Task { @MainActor in
let snapshot = self.buildSnapshot()
self.lastSentSnapshot = snapshot
replyHandler(snapshot.applicationContext() ?? [:])
}
}
}
@@ -1,6 +1,36 @@
import Foundation
import LocalAuthentication
/// Biometric hardware available on this device. Never assume Face ID: iPad and
/// iPhone Duo can ship with Touch ID, Vision Pro with Optic ID.
enum BiometryKind {
case faceID
case touchID
case opticID
case none
/// Marketing name shown in UI ("Face ID", "Touch ID", "Optic ID"). Falls back
/// to a generic label when no biometry is available (e.g. not enrolled).
var displayName: String {
switch self {
case .faceID: return "Face ID"
case .touchID: return "Touch ID"
case .opticID: return "Optic ID"
case .none: return String(localized: "biometric_generic_name")
}
}
/// SF Symbol matching the hardware.
var systemImage: String {
switch self {
case .faceID: return "faceid"
case .touchID: return "touchid"
case .opticID: return "opticid"
case .none: return "lock.shield"
}
}
}
enum AppLockService {
static func canUseBiometrics() -> Bool {
let context = LAContext()
@@ -8,6 +38,24 @@ enum AppLockService {
return context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error)
}
/// The biometry type reported by the system. `biometryType` is only populated
/// after `canEvaluatePolicy` runs, and stays set even when not enrolled.
static var biometryKind: BiometryKind {
let context = LAContext()
var error: NSError?
_ = context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error)
switch context.biometryType {
case .faceID: return .faceID
case .touchID: return .touchID
case .opticID: return .opticID
case .none: return .none
@unknown default: return .none
}
}
/// Localized biometry name for user-facing text.
static var biometryName: String { biometryKind.displayName }
static func authenticate(reason: String, completion: @escaping (Bool) -> Void) {
let context = LAContext()
context.evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, localizedReason: reason) { success, _ in
@@ -17,16 +17,6 @@ enum AppConstants {
static let premiumProductID = "com.portfoliojournal.premium"
// MARK: - AdMob
#if DEBUG
static let adMobAppID = "ca-app-pub-3940256099942544~1458002511" // Test App ID
static let bannerAdUnitID = "ca-app-pub-3940256099942544/2934735716" // Test Banner
#else
static let adMobAppID = "ca-app-pub-XXXXXXXXXXXXXXXX~YYYYYYYYYY" // Replace with real App ID
static let bannerAdUnitID = "ca-app-pub-XXXXXXXXXXXXXXXX/YYYYYYYYYY" // Replace with real Ad Unit ID
#endif
// MARK: - Currency
static let defaultCurrency = "EUR"
@@ -52,7 +42,6 @@ enum AppConstants {
static let smallIconSize: CGFloat = 16
static let largeIconSize: CGFloat = 32
static let bannerAdHeight: CGFloat = 50
static let tabBarHeight: CGFloat = 49
static let cardShadowRadius: CGFloat = 4
@@ -88,7 +77,6 @@ enum AppConstants {
enum StorageKeys {
static let onboardingCompleted = "onboardingCompleted"
static let adConsentObtained = "adConsentObtained"
static let lastSyncDate = "lastSyncDate"
static let selectedCategoryFilter = "selectedCategoryFilter"
static let preferredChartType = "preferredChartType"
@@ -119,7 +107,6 @@ enum AppConstants {
static let enableExport = true
static let enableWidgets = true
static let enableNotifications = true
static let enableAnalytics = true
}
}
@@ -74,7 +74,7 @@ enum DashboardLayoutStore {
var merged: [DashboardSectionConfig] = []
for config in decoded {
if let section = DashboardSection(rawValue: config.id) {
if DashboardSection(rawValue: config.id) != nil {
merged.append(config)
} else {
continue
@@ -167,6 +167,7 @@ extension Color {
// MARK: - Gradient Extensions
@MainActor
extension LinearGradient {
static let appPrimaryGradient = LinearGradient(
colors: [Color.appPrimary, Color.appPrimary.lighter()],
@@ -128,8 +128,6 @@ class FreemiumValidator: ObservableObject {
return false
case .export:
return false
case .noAds:
return false
}
}
@@ -142,7 +140,6 @@ class FreemiumValidator: ObservableObject {
case advancedCharts = "advanced_charts"
case predictions = "predictions"
case export = "export"
case noAds = "no_ads"
var id: String { rawValue }
@@ -154,7 +151,6 @@ class FreemiumValidator: ObservableObject {
case .advancedCharts: return "Advanced Charts"
case .predictions: return "Predictions"
case .export: return "Export Data"
case .noAds: return "No Ads"
}
}
@@ -166,7 +162,6 @@ class FreemiumValidator: ObservableObject {
case .advancedCharts: return "chart.bar.xaxis"
case .predictions: return "wand.and.stars"
case .export: return "square.and.arrow.up"
case .noAds: return "xmark.circle"
}
}
@@ -184,8 +179,6 @@ class FreemiumValidator: ObservableObject {
return "AI-powered 12-month forecasts"
case .export:
return "Export to CSV and JSON formats"
case .noAds:
return "Ad-free experience forever"
}
}
}
@@ -66,7 +66,7 @@ enum MonthlyCheckInStore {
}
static func entry(for date: Date) -> MonthlyCheckInEntry? {
fetchEntry(for: monthKey(for: date)).map(makeCheckInEntry)
fetchEntry(for: monthKey(for: date)).map { makeCheckInEntry($0) }
}
static func allEntries() -> [(date: Date, entry: MonthlyCheckInEntry)] {
@@ -183,8 +183,17 @@ enum MonthlyCheckInStore {
let totalCheckIns = completions.count
let onTimeCount = onTimeMonths.count
// The current month's check-in is still within its deadline (end of the
// month), so having it pending must NOT read as a broken streak that
// made the card show 0x for most of every month. Start counting at the
// current month when it is already done, otherwise at the previous one.
// Any earlier month that is missing has passed its deadline and does
// break the streak, so the walk backwards stays honest.
var currentStreak = 0
var cursor = referenceDate.startOfMonth
if !onTimeMonths.contains(cursor) {
cursor = cursor.adding(months: -1).startOfMonth
}
while onTimeMonths.contains(cursor) {
currentStreak += 1
cursor = cursor.adding(months: -1).startOfMonth
@@ -31,7 +31,6 @@ enum SnapshotGapDetector {
/// Only months strictly between two known snapshots count a source's
/// trailing "not updated in a while" is handled elsewhere (pending updates).
static func detectGaps(sources: [InvestmentSource], snapshots: [Snapshot]) -> [Gap] {
let calendar = Calendar.current
let snapshotsBySource = Dictionary(grouping: snapshots) { $0.source?.id }
var gaps: [Gap] = []
@@ -405,8 +405,6 @@ class ChartsViewModel: ObservableObject {
category: selectedCategory,
timeRange: selectedTimeRange
)
FirebaseService.shared.logScreenView(screenName: "Charts")
}
/// Selection is NEVER blocked premium charts select normally and the chart
@@ -423,10 +421,6 @@ class ChartsViewModel: ObservableObject {
// observer: makes selection deterministic and lets re-tapping a tile
// act as a retry if a previous update was dropped.
updateChartData(chartType: chartType, category: selectedCategory, timeRange: selectedTimeRange)
FirebaseService.shared.logChartViewed(
chartType: chartType.rawValue,
isPremium: chartType.isPremium
)
}
func availableTimeRanges(for chartType: ChartType) -> [TimeRange] {
@@ -219,8 +219,10 @@ class DashboardViewModel: ObservableObject {
// Detect internal data gaps (missing months between known snapshots)
dataGaps = SnapshotGapDetector.detectGaps(sources: sources, snapshots: allSnapshots)
// Log screen view
FirebaseService.shared.logScreenView(screenName: "Dashboard")
// NOTE: no screen_view logging here refreshData() fires on every
// relevant Core Data change (incl. iCloud imports), so logging from it
// inflated GA4 with thousands of phantom Dashboard views from devices
// left open. The view logs once per appearance in DashboardView.onAppear.
}
private func filteredSources() -> [InvestmentSource] {
@@ -633,6 +635,29 @@ class DashboardViewModel: ObservableObject {
let value: Decimal
}
/// ETA text for a goal scoped to an account and/or a category. The dashboard
/// evolution covers the whole (selected) portfolio, so a scoped goal e.g.
/// "100k in Cash" must be projected over its own sources instead; the
/// caller passes that projection in.
func goalEtaText(for goal: Goal, currentValue: Decimal, projection: GoalProjection.Result) -> String? {
if currentValue >= goal.targetDecimal {
return "Goal reached. Keep it steady."
}
guard let months = projection.monthsToReach else {
return "Keep going. Consistency pays off."
}
if months == 0 {
return "Almost there. One more check-in."
}
if months >= 72 {
return "Long journey, strong discipline. You're building momentum."
}
if let projectedDate = projection.projectedDate {
return "Estimated: \(projectedDate.monthYearString)"
}
return "Estimated: \(months) months"
}
func goalEtaText(for goal: Goal, currentValue: Decimal) -> String? {
let target = goal.targetDecimal
let lastValue = evolutionData.last?.value ?? currentValue
@@ -16,7 +16,7 @@ class GoalsViewModel: ObservableObject {
private var cancellables = Set<AnyCancellable>()
// MARK: - Performance: Caching
private var cachedEvolutionData: [UUID: [(date: Date, value: Decimal)]] = [:]
private var cachedEvolutionData: [String: [(date: Date, value: Decimal)]] = [:]
private var cachedCompletionDates: [UUID: Date?] = [:]
private var lastSourcesHash: Int = 0
@@ -94,12 +94,9 @@ class GoalsViewModel: ObservableObject {
}
func totalValue(for goal: Goal) -> Decimal {
if let accountId = goal.account?.safeId {
return sourceRepository.sources
.filter { $0.account?.id == accountId }
.reduce(Decimal.zero) { $0 + $1.latestValue }
}
return totalValue
guard goal.account != nil || goal.category != nil else { return totalValue }
return relevantSources(for: goal)
.reduce(Decimal.zero) { $0 + $1.latestValue }
}
func paceStatus(for goal: Goal) -> GoalPaceStatus? {
@@ -203,17 +200,14 @@ class GoalsViewModel: ObservableObject {
}
private func relevantSources(for goal: Goal) -> [InvestmentSource] {
if let accountId = goal.account?.safeId {
return sourceRepository.sources.filter { $0.account?.id == accountId }
}
return sourceRepository.sources
goal.relevantSources(from: sourceRepository.sources)
}
private func evolutionData(
for goal: Goal,
sources: [InvestmentSource]
) -> [(date: Date, value: Decimal)] {
let cacheKey = goal.account?.safeId ?? UUID(uuidString: "00000000-0000-0000-0000-000000000000")!
let cacheKey = goal.scopeKey
if let cached = cachedEvolutionData[cacheKey] {
return cached
}
@@ -11,7 +11,6 @@ class SettingsViewModel: ObservableObject {
@Published var isFamilyShared = false
@Published var notificationsEnabled = false
@Published var defaultNotificationTime = Date()
@Published var analyticsEnabled = true
@Published var currencyCode = AppSettings.getOrCreate(in: CoreDataStack.shared.viewContext).currency
@Published var inputMode: InputMode = .simple
@@ -93,7 +92,6 @@ class SettingsViewModel: ObservableObject {
let settings = AppSettings.getOrCreate(in: context)
defaultNotificationTime = settings.defaultNotificationTime ?? Date()
analyticsEnabled = settings.enableAnalytics
currencyCode = settings.currency
inputMode = InputMode(rawValue: settings.inputMode) ?? .simple
backupRetentionCount = loadBackupRetention()
@@ -107,8 +105,6 @@ class SettingsViewModel: ObservableObject {
// Load statistics directly from database to avoid async race conditions
loadStatistics()
FirebaseService.shared.logScreenView(screenName: "Settings")
}
private func loadStatistics() {
@@ -131,7 +127,6 @@ class SettingsViewModel: ObservableObject {
func upgradeToPremium() {
showingPaywall = true
FirebaseService.shared.logPaywallShown(trigger: "settings_upgrade")
}
func restorePurchases() async {
@@ -141,10 +136,8 @@ class SettingsViewModel: ObservableObject {
if isPremium {
successMessage = "Purchases restored successfully!"
FirebaseService.shared.logRestorePurchases(success: true)
} else {
errorMessage = "No purchases to restore"
FirebaseService.shared.logRestorePurchases(success: false)
}
}
@@ -191,7 +184,6 @@ class SettingsViewModel: ObservableObject {
func exportData(format: ExportService.ExportFormat) {
guard freemiumValidator.canExport() else {
showingPaywall = true
FirebaseService.shared.logPaywallShown(trigger: "export")
return
}
@@ -239,16 +231,14 @@ class SettingsViewModel: ObservableObject {
exportStatus = "Export complete"
isExporting = false
showingExportOptions = false
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in
self?.shareItem = ShareItem(url: tempURL)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
self.shareItem = ShareItem(url: tempURL)
}
FirebaseService.shared.logExportAttempt(format: format.rawValue, success: true)
}
} catch {
await MainActor.run {
isExporting = false
errorMessage = "Export failed: \(error.localizedDescription)"
FirebaseService.shared.logExportAttempt(format: format.rawValue, success: false)
}
}
}
@@ -272,19 +262,6 @@ class SettingsViewModel: ObservableObject {
freemiumValidator.canExport()
}
// MARK: - Analytics
func toggleAnalytics(_ enabled: Bool) {
analyticsEnabled = enabled
let context = CoreDataStack.shared.viewContext
let settings = AppSettings.getOrCreate(in: context)
settings.enableAnalytics = enabled
CoreDataStack.shared.save()
// Note: In production, you'd also update Firebase Analytics consent
}
// MARK: - Currency
func updateCurrency(_ code: String) {
@@ -555,7 +532,6 @@ class SettingsViewModel: ObservableObject {
guard isPremium else {
backupsEnabled = false
UserDefaults.standard.set(false, forKey: backupsEnabledKey)
FirebaseService.shared.logPaywallShown(trigger: "backups")
showingPaywall = true
return
}
@@ -87,8 +87,6 @@ class SourceDetailViewModel: ObservableObject {
isLoading = true
refreshData()
isLoading = false
FirebaseService.shared.logScreenView(screenName: "SourceDetail")
}
func refreshData() {
@@ -167,9 +165,6 @@ class SourceDetailViewModel: ObservableObject {
// Reschedule notification
NotificationService.shared.scheduleReminder(for: source)
// Log analytics
FirebaseService.shared.logSnapshotAdded(sourceName: source.name, value: value)
showingAddSnapshot = false
refreshData()
}
@@ -216,9 +211,6 @@ class SourceDetailViewModel: ObservableObject {
// Cancel any pending notifications for this source
NotificationService.shared.cancelReminder(for: source)
// Log analytics before deletion
FirebaseService.shared.logSourceDeleted(categoryName: source.category?.name ?? "Uncategorized")
// Delete the source using the existing repository
sourceRepository.deleteSource(source)
}
@@ -226,14 +218,9 @@ class SourceDetailViewModel: ObservableObject {
// MARK: - Predictions
func showPredictions() {
if freemiumValidator.canViewPredictions() {
// Already loaded, just navigate
FirebaseService.shared.logPredictionViewed(
algorithm: predictionResult?.algorithm.rawValue ?? "unknown"
)
} else {
// Predictions are already loaded; free users get the paywall instead.
if !freemiumValidator.canViewPredictions() {
showingPaywall = true
FirebaseService.shared.logPaywallShown(trigger: "predictions")
}
}
@@ -76,8 +76,6 @@ class SourceListViewModel: ObservableObject {
sourceRepository.fetchSources()
categoryRepository.fetchCategories()
isLoading = false
FirebaseService.shared.logScreenView(screenName: "SourceList")
}
private func filterAndSortSources(_ allSources: [InvestmentSource]) {
@@ -115,7 +113,6 @@ class SourceListViewModel: ObservableObject {
showingAddSource = true
} else {
showingPaywall = true
FirebaseService.shared.logPaywallShown(trigger: "source_limit")
}
}
@@ -137,26 +134,15 @@ class SourceListViewModel: ObservableObject {
// Schedule notification
NotificationService.shared.scheduleReminder(for: source)
// Log analytics
FirebaseService.shared.logSourceAdded(
categoryName: category.name,
sourceCount: sourceRepository.sourceCount
)
showingAddSource = false
}
func deleteSource(_ source: InvestmentSource) {
let categoryName = source.category?.name ?? "Unknown"
// Cancel notifications
NotificationService.shared.cancelReminder(for: source)
// Delete source
sourceRepository.deleteSource(source)
// Log analytics
FirebaseService.shared.logSourceDeleted(categoryName: categoryName)
}
func deleteSource(at offsets: IndexSet) {
@@ -21,11 +21,11 @@ struct AccountEditorView: View {
if isEditingDefaultAccount {
HStack {
Text(Account.defaultAccountName)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
Spacer()
Image(systemName: "lock.fill")
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
} else {
TextField("Account name", text: $name)
@@ -40,7 +40,7 @@ struct AccountEditorView: View {
} footer: {
if let errorMessage {
Text(errorMessage)
.foregroundColor(.negativeRed)
.foregroundStyle(Color.negativeRed)
} else if isEditingDefaultAccount {
Text("The Default account name cannot be changed.")
}
@@ -62,10 +62,10 @@ struct AccountEditorView: View {
.navigationTitle(account == nil ? "New Account" : "Edit Account")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
ToolbarItem(placement: .topBarLeading) {
Button("Cancel") { dismiss() }
}
ToolbarItem(placement: .navigationBarTrailing) {
ToolbarItem(placement: .topBarTrailing) {
Button("Save") { saveAccount() }
.disabled(!isValid)
}
@@ -1,4 +1,5 @@
import SwiftUI
import CoreData
struct AccountsView: View {
@EnvironmentObject private var iapService: IAPService
@@ -33,17 +34,17 @@ struct AccountsView: View {
if account.isDefaultAccount {
Image(systemName: "star.fill")
.font(.caption2)
.foregroundColor(.appWarning)
.foregroundStyle(Color.appWarning)
}
}
Text(account.currencyCode ?? AppSettings.getOrCreate(in: CoreDataStack.shared.viewContext).currency)
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
Spacer()
if accountStore.selectedAccount?.safeId == account.safeId && !accountStore.showAllAccounts {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.appPrimary)
.foregroundStyle(Color.appPrimary)
}
}
}
@@ -92,7 +93,7 @@ struct AccountsView: View {
Text("The Default account cannot be deleted. You must keep at least one account.")
}
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
ToolbarItem(placement: .topBarTrailing) {
Button {
if accountStore.canAddAccount() {
showingAddAccount = true
@@ -57,14 +57,14 @@ struct AllocationPieChart: View {
VStack(alignment: .leading, spacing: 0) {
Text(item.category)
.font(.subheadline)
.foregroundColor(.primary)
.foregroundStyle(.primary)
let percentage = total > 0
? NSDecimalNumber(decimal: item.value / total).doubleValue * 100
: 0
Text(String(format: "%.1f%%", percentage))
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
}
.opacity(selectedSlice == nil || selectedSlice == item.category ? 1 : 0.5)
@@ -77,7 +77,7 @@ struct AllocationPieChart: View {
} label: {
Image(systemName: "chevron.right.circle.fill")
.font(.footnote)
.foregroundColor(.secondary.opacity(0.6))
.foregroundStyle(.secondary.opacity(0.6))
}
.buttonStyle(.plain)
}
@@ -92,14 +92,14 @@ struct AllocationPieChart: View {
}
} else {
Text("No allocation data available")
.foregroundColor(.secondary)
.frame(height: 200)
.foregroundStyle(.secondary)
.chartFrame(height: 200)
.frame(maxWidth: .infinity)
}
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
}
}
@@ -167,7 +167,7 @@ struct SemicircleAllocation: View {
y: center.y + radius * CoreGraphics.sin(angle))
Text("\(Int((frac * 100).rounded()))%")
.font(.system(size: lineWidth * 0.42, weight: .bold))
.foregroundColor(.white)
.foregroundStyle(.white)
.position(pos)
.opacity(selectedSlice == nil || selectedSlice == seg.category ? 1 : 0.4)
}
@@ -178,14 +178,14 @@ struct SemicircleAllocation: View {
VStack(spacing: 1) {
Text(c.label)
.font(.caption2)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
.lineLimit(1)
Text(c.value)
.font(.headline)
if let sub = c.sub {
Text(sub)
.font(.caption2)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
}
.position(x: center.x, y: center.y - lineWidth * 0.25)
@@ -243,7 +243,7 @@ struct AllocationTargetsComparisonChart: View {
if targetData.allSatisfy({ $0.target == 0 }) {
Text("Set allocation targets to compare your portfolio against your plan.")
.font(.subheadline)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
} else {
Chart {
ForEach(targetData, id: \.category) { item in
@@ -275,7 +275,7 @@ struct AllocationTargetsComparisonChart: View {
AxisValueLabel()
}
}
.frame(height: 220)
.chartFrame(height: 220)
ForEach(targetData, id: \.category) { item in
let drift = item.actual - item.target
@@ -289,20 +289,20 @@ struct AllocationTargetsComparisonChart: View {
Spacer()
Text("Actual \(String(format: "%.1f%%", item.actual))")
.font(.caption2)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
Text("Target \(String(format: "%.0f%%", item.target))")
.font(.caption2)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
Text("\(prefix)\(String(format: "%.1f%%", drift))")
.font(.caption2.weight(.semibold))
.foregroundColor(drift >= 0 ? .positiveGreen : .negativeRed)
.foregroundStyle(drift >= 0 ? Color.positiveGreen : Color.negativeRed)
}
}
}
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
}
@@ -351,7 +351,7 @@ struct AllocationListView: View {
Text(item.value.compactCurrencyString)
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
.frame(width: 70, alignment: .trailing)
}
@@ -365,12 +365,12 @@ struct AllocationListView: View {
Rectangle()
.fill(Color.gray.opacity(0.1))
.frame(height: 6)
.cornerRadius(3)
.clipShape(RoundedRectangle(cornerRadius: 3))
Rectangle()
.fill(Color(hex: item.color) ?? .gray)
.frame(width: geometry.size.width * percentage, height: 6)
.cornerRadius(3)
.clipShape(RoundedRectangle(cornerRadius: 3))
}
}
.frame(height: 6)
@@ -379,7 +379,7 @@ struct AllocationListView: View {
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
}
}
@@ -19,7 +19,7 @@ struct AllocationSimulatorView: View {
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
}
@@ -29,13 +29,13 @@ struct AllocationSimulatorView: View {
VStack(spacing: 12) {
Image(systemName: "slider.horizontal.3")
.font(.system(size: 36))
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
Text("No sources available")
.font(.subheadline)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity)
.frame(height: 300)
.chartFrame(height: 300)
}
// MARK: - Allocation Card
@@ -56,7 +56,7 @@ struct AllocationSimulatorView: View {
}
.padding(12)
.background(Color(.systemGray6))
.cornerRadius(10)
.clipShape(RoundedRectangle(cornerRadius: 10))
}
private var totalAllocationBadge: some View {
@@ -67,8 +67,8 @@ struct AllocationSimulatorView: View {
.padding(.horizontal, 8)
.padding(.vertical, 3)
.background(isNear100 ? Color.green.opacity(0.15) : Color.orange.opacity(0.15))
.foregroundColor(isNear100 ? .green : .orange)
.cornerRadius(8)
.foregroundStyle(isNear100 ? .green : .orange)
.clipShape(RoundedRectangle(cornerRadius: 8))
}
private var resetButton: some View {
@@ -87,7 +87,7 @@ struct AllocationSimulatorView: View {
} label: {
Image(systemName: "arrow.counterclockwise")
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
}
@@ -104,11 +104,11 @@ struct AllocationSimulatorView: View {
Spacer()
Text(String(format: "%.0f%%", source.simulatedPct))
.font(.caption.weight(.semibold))
.foregroundColor(.primary)
.foregroundStyle(.primary)
.frame(width: 36, alignment: .trailing)
Text(String(format: "(%.0f%%)", source.currentPct))
.font(.caption2)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
Slider(
@@ -142,8 +142,8 @@ struct AllocationSimulatorView: View {
if viewModel.simulatorActualData.isEmpty && viewModel.simulatorData.isEmpty {
Text("Not enough data to simulate.")
.font(.subheadline)
.foregroundColor(.secondary)
.frame(height: 220)
.foregroundStyle(.secondary)
.chartFrame(height: 220)
} else {
simulationChart
chartLegend
@@ -194,7 +194,7 @@ struct AllocationSimulatorView: View {
.foregroundStyle(Color.secondary.opacity(0.15))
}
}
.frame(height: 220)
.chartFrame(height: 220)
.chartDrawingGroup(disabledForExport: chartImageExport)
}
@@ -224,7 +224,7 @@ struct AllocationSimulatorView: View {
}
Text(label)
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
}
@@ -24,16 +24,16 @@ struct ChartStatsRow: View {
VStack(alignment: .center, spacing: 3) {
Text(stats[i].label)
.font(.caption2)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
Text(stats[i].value)
.font(.subheadline.weight(.semibold))
.foregroundColor(stats[i].color)
.foregroundStyle(stats[i].color)
}
.frame(minWidth: 60)
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(Color(.systemGray6))
.cornerRadius(8)
.clipShape(RoundedRectangle(cornerRadius: 8))
}
}
}
@@ -84,28 +84,28 @@ struct ChartDataTable: View {
Image(systemName: isExpanded ? "chevron.up" : "chevron.down")
.font(.caption2)
}
.foregroundColor(.appPrimary)
.foregroundStyle(Color.appPrimary)
.frame(maxWidth: .infinity)
.padding(.vertical, 8)
}
}
}
.background(Color(.systemGray6))
.cornerRadius(8)
.clipShape(RoundedRectangle(cornerRadius: 8))
}
private var tableHeader: some View {
HStack(spacing: 0) {
Text("Date").font(.caption2.weight(.semibold)).foregroundColor(.secondary)
Text("Date").font(.caption2.weight(.semibold)).foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
Text(valueHeader).font(.caption2.weight(.semibold)).foregroundColor(.secondary)
Text(valueHeader).font(.caption2.weight(.semibold)).foregroundStyle(.secondary)
.frame(width: 72, alignment: .trailing)
if let h = deltaPrevHeader {
Text(h).font(.caption2.weight(.semibold)).foregroundColor(.secondary)
Text(h).font(.caption2.weight(.semibold)).foregroundStyle(.secondary)
.frame(width: 62, alignment: .trailing)
}
if let h = deltaFirstHeader {
Text(h).font(.caption2.weight(.semibold)).foregroundColor(.secondary)
Text(h).font(.caption2.weight(.semibold)).foregroundStyle(.secondary)
.frame(width: 62, alignment: .trailing)
}
}
@@ -115,18 +115,18 @@ struct ChartDataTable: View {
private func tableDataRow(_ row: ChartDataTableRow) -> some View {
HStack(spacing: 0) {
Text(row.label).font(.caption2).foregroundColor(.primary)
Text(row.label).font(.caption2).foregroundStyle(.primary)
.frame(maxWidth: .infinity, alignment: .leading)
Text(row.value).font(.caption2.weight(.medium))
.frame(width: 72, alignment: .trailing)
if let dp = row.deltaPrev, deltaPrevHeader != nil {
Text(dp).font(.caption2.weight(.medium))
.foregroundColor(row.isPrevPositive ? .positiveGreen : .negativeRed)
.foregroundStyle(row.isPrevPositive ? Color.positiveGreen : Color.negativeRed)
.frame(width: 62, alignment: .trailing)
}
if let df = row.deltaFirst, deltaFirstHeader != nil {
Text(df).font(.caption2.weight(.medium))
.foregroundColor(row.isFirstPositive ? .positiveGreen : .negativeRed)
.foregroundStyle(row.isFirstPositive ? Color.positiveGreen : Color.negativeRed)
.frame(width: 62, alignment: .trailing)
}
}
@@ -57,7 +57,7 @@ struct ChartValueBubble: View {
var body: some View {
Text(text)
.font(prominent ? .caption.weight(.bold) : .caption2.weight(.semibold))
.foregroundColor(prominent ? .white : color)
.foregroundStyle(prominent ? .white : color)
.padding(.horizontal, prominent ? 8 : 5)
.padding(.vertical, prominent ? 4 : 2)
.background(
@@ -76,16 +76,16 @@ struct ChartSelectionCard: View {
VStack(alignment: .leading, spacing: 3) {
Text(title)
.font(.caption2.weight(.semibold))
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
ForEach(Array(rows.enumerated()), id: \.offset) { _, row in
HStack(spacing: 5) {
Circle().fill(row.color).frame(width: 6, height: 6)
Text(row.label)
.font(.caption2)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
Text(row.value)
.font(.caption2.weight(.semibold))
.foregroundColor(.primary)
.foregroundStyle(.primary)
}
}
}
@@ -191,10 +191,10 @@ struct ChartRangeBrush: View {
HStack(spacing: 5) {
Text("\(Self.labelFormatter.string(from: dates[lo])) \(Self.labelFormatter.string(from: dates[hi]))")
.font(.caption.weight(.semibold))
.foregroundColor(.appPrimary)
.foregroundStyle(Color.appPrimary)
Image(systemName: "chevron.up.chevron.down")
.font(.system(size: 8, weight: .semibold))
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
}
.buttonStyle(.plain)
@@ -410,7 +410,7 @@ struct ChartRangePickerSheet: View {
VStack(spacing: 2) {
Text(title)
.font(.caption.weight(.semibold))
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
Picker(title, selection: selection) {
ForEach(dates.indices, id: \.self) { i in
Text(Self.optionFormatter.string(from: dates[i])).tag(i)
@@ -1,5 +1,6 @@
import SwiftUI
import Charts
import CoreData
import TipKit
struct ChartsContainerView: View {
@@ -28,10 +29,10 @@ struct ChartsContainerView: View {
// pattern) replaces the 14-chip horizontal carousel.
.toolbarTitleMenu { chartTypePicker }
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) { accountFilterMenu }
ToolbarItem(placement: .navigationBarTrailing) { filterMenu }
ToolbarItem(placement: .topBarTrailing) { accountFilterMenu }
ToolbarItem(placement: .topBarTrailing) { filterMenu }
if horizontalSizeClass != .regular {
ToolbarItem(placement: .navigationBarTrailing) { shareButton }
ToolbarItem(placement: .topBarTrailing) { shareButton }
}
}
.onAppear { syncState() }
@@ -95,9 +96,6 @@ struct ChartsContainerView: View {
if !viewModel.isPremium {
CompactPaywallBanner(showingPaywall: $viewModel.showingPaywall)
.onAppear {
FirebaseService.shared.logPaywallShown(trigger: "charts_banner")
}
}
chartContent
if viewModel.hiddenHistoryMonths > 0 {
@@ -105,11 +103,14 @@ struct ChartsContainerView: View {
}
}
.padding()
// Regular width has a wide canvas next to the sidebar: give
// every plot ~35% more height so the extra area shows data,
// not just a stretched iPhone chart.
.environment(\.chartHeightScale, 1.35)
}
}
.clipped()
}
.ignoresSafeArea(edges: .bottom)
// Attached here (not on the layout-switching Group): sheets presented from
// a view that gets replaced when the size class changes were unreliable in
// NavigationSplitView premium tiles looked like they "did nothing".
@@ -144,7 +145,7 @@ struct ChartsContainerView: View {
if chartType.isPremium && !viewModel.isPremium {
Image(systemName: "lock.fill")
.font(.caption2)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
}
}
@@ -465,19 +466,19 @@ struct ChartsContainerView: View {
VStack(alignment: .leading, spacing: 4) {
Text(label)
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
.lineLimit(1)
.minimumScaleFactor(0.8)
Text(value)
.font(.system(.title3, design: .rounded).weight(.semibold))
.foregroundColor(color)
.foregroundStyle(color)
.lineLimit(1)
.minimumScaleFactor(0.6)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(12)
.background(Color(.systemBackground))
.cornerRadius(AppConstants.UI.smallCornerRadius)
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.smallCornerRadius))
.shadow(color: .black.opacity(0.05), radius: 4, y: 1)
}
@@ -492,7 +493,7 @@ struct ChartsContainerView: View {
.font(.headline)
Text(viewModel.selectedChartType.description)
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
.lineLimit(1)
}
Spacer()
@@ -577,7 +578,6 @@ struct ChartsContainerView: View {
/// 12-month window; tell them exactly how much is locked instead of hiding it.
private var lockedHistoryTeaser: some View {
Button {
FirebaseService.shared.logPaywallShown(trigger: "history_teaser")
viewModel.showingPaywall = true
} label: {
HStack(spacing: 8) {
@@ -589,10 +589,10 @@ struct ChartsContainerView: View {
.font(.caption)
}
.font(.footnote.weight(.medium))
.foregroundColor(.appPrimary)
.foregroundStyle(Color.appPrimary)
.padding(12)
.background(Color.appPrimary.opacity(0.08))
.cornerRadius(AppConstants.UI.smallCornerRadius)
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.smallCornerRadius))
}
.buttonStyle(.plain)
}
@@ -607,9 +607,6 @@ struct ChartsContainerView: View {
chartTypeChipBar
if !viewModel.isPremium {
CompactPaywallBanner(showingPaywall: $viewModel.showingPaywall)
.onAppear {
FirebaseService.shared.logPaywallShown(trigger: "charts_banner")
}
}
iPhonePeriodControl
if hasActiveFilters {
@@ -673,7 +670,7 @@ struct ChartsContainerView: View {
}
.padding(.horizontal, 12)
.padding(.vertical, 7)
.foregroundColor(isSelected ? .white : (locked ? .secondary : .primary))
.foregroundStyle(isSelected ? .white : (locked ? .secondary : .primary))
.background(
Capsule().fill(isSelected ? Color.appPrimary : Color(.systemBackground))
)
@@ -706,7 +703,7 @@ struct ChartsContainerView: View {
Image(systemName: "xmark.circle.fill")
.font(.footnote)
}
.foregroundColor(.appPrimary)
.foregroundStyle(Color.appPrimary)
.padding(.horizontal, 12)
.padding(.vertical, 7)
.background(Capsule().fill(Color.appPrimary.opacity(0.12)))
@@ -777,7 +774,7 @@ struct ChartsContainerView: View {
} label: {
Image(systemName: "calendar.badge.clock")
.font(.system(size: 14, weight: .medium))
.foregroundColor(showRangeBrush ? .white : .appPrimary)
.foregroundStyle(showRangeBrush ? .white : Color.appPrimary)
.frame(width: 38, height: 30)
.background(
RoundedRectangle(cornerRadius: 8)
@@ -858,7 +855,6 @@ struct ChartsContainerView: View {
/// back.
private func drillDown(to name: String) {
guard viewModel.isPremium else {
FirebaseService.shared.logPaywallShown(trigger: "chart_workable")
viewModel.showingPaywall = true
return
}
@@ -899,7 +895,7 @@ struct ChartsContainerView: View {
premiumLockedView
} else if viewModel.isLoading {
ProgressView()
.frame(height: 300)
.chartFrame(height: 300)
} else if !viewModel.hasData {
emptyStateView
} else {
@@ -912,7 +908,6 @@ struct ChartsContainerView: View {
contributions: viewModel.contributionsData,
isPremium: viewModel.isPremium,
onPremiumNeeded: {
FirebaseService.shared.logPaywallShown(trigger: "chart_workable")
viewModel.showingPaywall = true
}
)
@@ -975,10 +970,10 @@ struct ChartsContainerView: View {
.frame(width: 88, height: 88)
Image(systemName: viewModel.selectedChartType.icon)
.font(.system(size: 34))
.foregroundColor(.appPrimary)
.foregroundStyle(Color.appPrimary)
Image(systemName: "lock.circle.fill")
.font(.system(size: 26))
.foregroundColor(.appWarning)
.foregroundStyle(Color.appWarning)
.background(Circle().fill(Color(.systemBackground)))
.offset(x: 32, y: 30)
}
@@ -988,11 +983,10 @@ struct ChartsContainerView: View {
Text(viewModel.selectedChartType.description)
.font(.subheadline)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
Button {
FirebaseService.shared.logPaywallShown(trigger: "advanced_charts")
viewModel.showingPaywall = true
} label: {
Text(String(localized: "chart_locked_cta"))
@@ -1000,14 +994,14 @@ struct ChartsContainerView: View {
.padding(.horizontal, 24)
.padding(.vertical, 12)
.background(Color.appPrimary)
.foregroundColor(.white)
.cornerRadius(24)
.foregroundStyle(.white)
.clipShape(RoundedRectangle(cornerRadius: 24))
}
}
.padding(32)
.frame(maxWidth: .infinity, minHeight: 320)
.background(Color(.systemBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
}
@@ -1015,17 +1009,17 @@ struct ChartsContainerView: View {
VStack(spacing: 16) {
Image(systemName: "chart.bar.xaxis")
.font(.system(size: 48))
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
Text("No Data Available")
.font(.headline)
Text("Add some investment sources and snapshots to see charts.")
.font(.subheadline)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
.frame(height: 300)
.chartFrame(height: 300)
}
private var accountFilterMenu: some View {
@@ -1167,10 +1161,10 @@ struct EvolutionChartView: View {
HStack(spacing: 6) {
Image(systemName: "lightbulb.fill")
.font(.caption2)
.foregroundColor(.appWarning)
.foregroundStyle(Color.appWarning)
Text(insight)
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
}
if !data.isEmpty && chartMode == .total {
@@ -1179,7 +1173,7 @@ struct EvolutionChartView: View {
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
}
@@ -1228,7 +1222,7 @@ struct EvolutionChartView: View {
}
.padding(.horizontal, 10)
.padding(.vertical, 5)
.foregroundColor(showContributions ? .appSecondary : .secondary)
.foregroundStyle(showContributions ? Color.appSecondary : .secondary)
.background(
Capsule().fill(showContributions ? Color.appSecondary.opacity(0.16) : Color(.systemGray6))
)
@@ -1307,7 +1301,7 @@ struct EvolutionChartView: View {
if series.count > 1 {
Text(String(format: "%+.1f%%", deltaPct))
.font(.caption.weight(.bold))
.foregroundColor(deltaPct >= 0 ? .positiveGreen : .negativeRed)
.foregroundStyle(deltaPct >= 0 ? Color.positiveGreen : Color.negativeRed)
.padding(.horizontal, 7)
.padding(.vertical, 3)
.background(
@@ -1317,7 +1311,7 @@ struct EvolutionChartView: View {
}
Text(subtitle)
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
Spacer()
@@ -1326,7 +1320,7 @@ struct EvolutionChartView: View {
showGoalLines.toggle()
} label: {
Image(systemName: showGoalLines ? "target" : "slash.circle")
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
.disabled(goals.isEmpty)
.accessibilityLabel(showGoalLines ? "Hide goals" : "Show goals")
@@ -1349,8 +1343,8 @@ struct EvolutionChartView: View {
chartView
} else {
Text("Not enough data")
.foregroundColor(.secondary)
.frame(height: 300)
.foregroundStyle(.secondary)
.chartFrame(height: 300)
}
}
@@ -1414,7 +1408,7 @@ struct EvolutionChartView: View {
}
}
}
.frame(height: 300)
.chartFrame(height: 300)
.zoomableTimeSeries(dates: data.map(\.date), zoom: $zoom)
// Performance: GPU rendering for smoother scrolling on older devices
.chartDrawingGroup(disabledForExport: chartImageExport)
@@ -1435,7 +1429,7 @@ struct EvolutionChartView: View {
series: .value("Series", "total")
)
.foregroundStyle(
LinearGradient(colors: [.appPrimary, .cyan],
LinearGradient(colors: [Color.appPrimary, .cyan],
startPoint: .leading, endPoint: .trailing)
)
.lineStyle(StrokeStyle(lineWidth: 2.2, lineCap: .round, lineJoin: .round))
@@ -1571,8 +1565,8 @@ struct ContributionsChartView: View {
if data.isEmpty {
Text("No contributions yet.")
.foregroundColor(.secondary)
.frame(height: 260)
.foregroundStyle(.secondary)
.chartFrame(height: 260)
} else {
Chart {
ForEach(data, id: \.date) { item in
@@ -1581,7 +1575,7 @@ struct ContributionsChartView: View {
y: .value("Amount", NSDecimalNumber(decimal: item.amount).doubleValue)
)
.foregroundStyle(Color.appSecondary)
.cornerRadius(6)
.clipShape(RoundedRectangle(cornerRadius: 6))
}
}
.chartXAxis {
@@ -1599,14 +1593,14 @@ struct ContributionsChartView: View {
}
}
}
.frame(height: 260)
.chartFrame(height: 260)
.zoomableTimeSeries(dates: data.map(\.date), zoom: $zoom)
ChartStatsRow(stats: contributionStats).padding(.top, 4)
}
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
}
@@ -1649,8 +1643,8 @@ struct RollingReturnChartView: View {
if data.isEmpty {
Text("Not enough data for rolling returns.")
.foregroundColor(.secondary)
.frame(height: 260)
.foregroundStyle(.secondary)
.chartFrame(height: 260)
} else {
Chart {
ForEach(Array(data.enumerated()), id: \.element.date) { pair in
@@ -1706,7 +1700,7 @@ struct RollingReturnChartView: View {
}
}
}
.frame(height: 260)
.chartFrame(height: 260)
.zoomableTimeSeries(dates: data.map(\.date), zoom: $zoom)
ChartStatsRow(stats: rollingStats).padding(.top, 4)
ChartDataTable(
@@ -1719,7 +1713,7 @@ struct RollingReturnChartView: View {
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
}
@@ -1771,8 +1765,8 @@ struct RiskReturnChartView: View {
if data.isEmpty {
Text("Not enough data to compare categories.")
.foregroundColor(.secondary)
.frame(height: 260)
.foregroundStyle(.secondary)
.chartFrame(height: 260)
} else {
Chart {
ForEach(data, id: \.category) { item in
@@ -1785,7 +1779,7 @@ struct RiskReturnChartView: View {
.annotation(position: .top) {
Text(item.category)
.font(.caption2)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
}
}
@@ -1809,12 +1803,12 @@ struct RiskReturnChartView: View {
}
}
}
.frame(height: 260)
.chartFrame(height: 260)
}
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
}
}
@@ -1832,8 +1826,8 @@ struct CashflowStackedChartView: View {
if data.isEmpty {
Text("Not enough data to compare cashflow.")
.foregroundColor(.secondary)
.frame(height: 260)
.foregroundStyle(.secondary)
.chartFrame(height: 260)
} else {
Chart {
ForEach(data, id: \.date) { item in
@@ -1871,14 +1865,14 @@ struct CashflowStackedChartView: View {
}
}
}
.frame(height: 260)
.chartFrame(height: 260)
.zoomableTimeSeries(dates: data.map(\.date), zoom: $zoom)
ChartStatsRow(stats: cashflowStats).padding(.top, 4)
}
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
}
@@ -1934,14 +1928,14 @@ struct AllocationEvolutionChart: View {
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
}
private var emptyView: some View {
Text("Not enough data to show allocation evolution.")
.foregroundColor(.secondary)
.frame(height: 260)
.foregroundStyle(.secondary)
.chartFrame(height: 260)
}
/// Categories in stable order (preserving the ViewModel's sort: largest overall first)
@@ -1989,7 +1983,7 @@ struct AllocationEvolutionChart: View {
}
}
}
.frame(height: 260)
.chartFrame(height: 260)
.chartDrawingGroup(disabledForExport: chartImageExport)
}
}
@@ -32,7 +32,7 @@ struct ComparisonChartView: View {
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
}
@@ -66,7 +66,7 @@ struct ComparisonChartView: View {
RoundedRectangle(cornerRadius: 16)
.stroke(isSelected ? chipColor : Color.clear, lineWidth: 1.5)
)
.cornerRadius(16)
.clipShape(RoundedRectangle(cornerRadius: 16))
}
.buttonStyle(.plain)
}
@@ -92,23 +92,23 @@ struct ComparisonChartView: View {
VStack(spacing: 12) {
Image(systemName: "chart.xyaxis.line")
.font(.system(size: 36))
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
Text("Select 2+ sources to compare")
.font(.subheadline)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity)
.frame(height: 260)
.chartFrame(height: 260)
}
private var noDataView: some View {
Text("No data available for selected sources in this period.")
.font(.subheadline)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.frame(maxWidth: .infinity)
.frame(height: 260)
.chartFrame(height: 260)
}
/// Multi-series comparison: labelling every point of every series overlaps
@@ -186,7 +186,7 @@ struct ComparisonChartView: View {
.foregroundStyle(Color.secondary.opacity(0.15))
}
}
.frame(height: 260)
.chartFrame(height: 260)
.chartDrawingGroup(disabledForExport: chartImageExport)
legend
@@ -203,7 +203,7 @@ struct ComparisonChartView: View {
.frame(width: 20, height: 3)
Text(series.name)
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
}
}
@@ -38,16 +38,16 @@ struct DrawdownChart: View {
VStack(alignment: .trailing, spacing: 2) {
Text("Max Drawdown")
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
Text(String(format: "%.1f%%", maxDrawdown))
.font(.subheadline.weight(.semibold))
.foregroundColor(.negativeRed)
.foregroundStyle(Color.negativeRed)
}
}
Text("Shows percentage decline from peak values")
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
if data.count >= 2 {
Chart {
@@ -119,7 +119,7 @@ struct DrawdownChart: View {
}
}
.chartYScale(domain: (data.map { $0.drawdown }.min() ?? -50)...0)
.frame(height: 250)
.chartFrame(height: 250)
.zoomableTimeSeries(dates: data.map(\.date), zoom: $zoom)
// Statistics
@@ -152,14 +152,14 @@ struct DrawdownChart: View {
.padding(.top, 4)
} else {
Text("Not enough data for drawdown analysis")
.foregroundColor(.secondary)
.frame(height: 250)
.foregroundStyle(.secondary)
.chartFrame(height: 250)
.frame(maxWidth: .infinity)
}
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
}
@@ -197,11 +197,11 @@ struct DrawdownStatView: View {
VStack(spacing: 4) {
Text(title)
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
Text(value)
.font(.subheadline.weight(.semibold))
.foregroundColor(isHighlighted ? .negativeRed : .primary)
.foregroundStyle(isHighlighted ? Color.negativeRed : .primary)
}
.frame(maxWidth: .infinity)
}
@@ -247,7 +247,7 @@ struct VolatilityChartView: View {
Text("Measures price variability over time")
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
if data.count >= 2 {
Chart {
@@ -293,7 +293,7 @@ struct VolatilityChartView: View {
.annotation(position: .top, alignment: .leading) {
Text("Avg: \(String(format: "%.1f%%", averageVolatility))")
.font(.caption2)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
if let sel = selectedPoint {
@@ -328,7 +328,7 @@ struct VolatilityChartView: View {
}
}
}
.frame(height: 250)
.chartFrame(height: 250)
.zoomableTimeSeries(dates: data.map(\.date), zoom: $zoom)
ChartStatsRow(stats: [
@@ -346,14 +346,14 @@ struct VolatilityChartView: View {
)
} else {
Text("Not enough data for volatility analysis")
.foregroundColor(.secondary)
.frame(height: 250)
.foregroundStyle(.secondary)
.chartFrame(height: 250)
.frame(maxWidth: .infinity)
}
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
}
@@ -400,7 +400,7 @@ struct VolatilityLevelView: View {
.font(.caption2)
Text(range)
.font(.caption2)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
}
}
@@ -22,7 +22,7 @@ struct PerformanceBarChart: View {
Text("Compound Annual Growth Rate (CAGR)")
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
if !data.isEmpty {
ChartStatsRow(stats: perfStats)
@@ -44,14 +44,14 @@ struct PerformanceBarChart: View {
.padding(.top, 8)
} else {
Text("No performance data available")
.foregroundColor(.secondary)
.frame(height: 250)
.foregroundStyle(.secondary)
.chartFrame(height: 250)
.frame(maxWidth: .infinity)
}
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
}
@@ -62,11 +62,11 @@ struct PerformanceBarChart: View {
y: .value("CAGR", item.cagr)
)
.foregroundStyle(Color(hex: item.color) ?? .gray)
.cornerRadius(4)
.clipShape(RoundedRectangle(cornerRadius: 4))
.annotation(position: item.cagr >= 0 ? .top : .bottom) {
Text(String(format: "%.1f%%", item.cagr))
.font(.caption2)
.foregroundColor(item.cagr >= 0 ? .positiveGreen : .negativeRed)
.foregroundStyle(item.cagr >= 0 ? Color.positiveGreen : Color.negativeRed)
}
}
.chartXAxis {
@@ -91,7 +91,7 @@ struct PerformanceBarChart: View {
}
}
}
.frame(height: 250)
.chartFrame(height: 250)
}
private var horizontalChart: some View {
@@ -101,11 +101,11 @@ struct PerformanceBarChart: View {
y: .value("Category", item.category)
)
.foregroundStyle(Color(hex: item.color) ?? .gray)
.cornerRadius(4)
.clipShape(RoundedRectangle(cornerRadius: 4))
.annotation(position: item.cagr >= 0 ? .trailing : .leading) {
Text(String(format: "%.1f%%", item.cagr))
.font(.caption2)
.foregroundColor(item.cagr >= 0 ? .positiveGreen : .negativeRed)
.foregroundStyle(item.cagr >= 0 ? Color.positiveGreen : Color.negativeRed)
}
}
.chartXAxis {
@@ -147,12 +147,12 @@ struct PerformanceBarChart: View {
Text(String(format: "%.2f%%", item.cagr))
.font(.subheadline.weight(.semibold))
.foregroundColor(item.cagr >= 0 ? .positiveGreen : .negativeRed)
.foregroundStyle(item.cagr >= 0 ? Color.positiveGreen : Color.negativeRed)
if onDrillDown != nil {
Image(systemName: "chevron.right")
.font(.caption2.weight(.semibold))
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
}
if let onDrillDown {
@@ -213,7 +213,7 @@ struct HorizontalPerformanceChart: View {
Text(String(format: "%.2f%%", item.cagr))
.font(.subheadline.weight(.semibold))
.foregroundColor(item.cagr >= 0 ? .positiveGreen : .negativeRed)
.foregroundStyle(item.cagr >= 0 ? Color.positiveGreen : Color.negativeRed)
}
GeometryReader { geometry in
@@ -224,12 +224,12 @@ struct HorizontalPerformanceChart: View {
Rectangle()
.fill(Color.gray.opacity(0.1))
.frame(height: 8)
.cornerRadius(4)
.clipShape(RoundedRectangle(cornerRadius: 4))
Rectangle()
.fill(item.cagr >= 0 ? Color.positiveGreen : Color.negativeRed)
.frame(width: barWidth, height: 8)
.cornerRadius(4)
.clipShape(RoundedRectangle(cornerRadius: 4))
}
}
.frame(height: 8)
@@ -238,7 +238,7 @@ struct HorizontalPerformanceChart: View {
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
}
}
@@ -28,7 +28,7 @@ struct PeriodComparisonChartView: View {
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
}
@@ -49,7 +49,7 @@ struct PeriodComparisonChartView: View {
endPoint: .bottomTrailing
)
)
.cornerRadius(12)
.clipShape(RoundedRectangle(cornerRadius: 12))
.overlay(
RoundedRectangle(cornerRadius: 12)
.stroke(Color.secondary.opacity(0.1), lineWidth: 1)
@@ -64,7 +64,7 @@ struct PeriodComparisonChartView: View {
Text(label)
.font(.caption.weight(.bold))
.foregroundColor(color)
.foregroundStyle(color)
.frame(width: 60, alignment: .leading)
DatePicker(
@@ -81,7 +81,7 @@ struct PeriodComparisonChartView: View {
Text("")
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
DatePicker(
"",
@@ -132,28 +132,28 @@ struct PeriodComparisonChartView: View {
.frame(width: 6, height: 6)
Text(series.label)
.font(.caption2.weight(.medium))
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
.lineLimit(1)
}
Text(String(format: "%+.2f%%", finalReturn))
.font(.title2.weight(.bold))
.foregroundColor(finalReturn >= 0 ? .positiveGreen : .negativeRed)
.foregroundStyle(finalReturn >= 0 ? Color.positiveGreen : Color.negativeRed)
HStack(spacing: 10) {
HStack(spacing: 3) {
Image(systemName: "arrow.up")
.font(.caption2.weight(.semibold))
.foregroundColor(.positiveGreen)
.foregroundStyle(Color.positiveGreen)
Text(String(format: "%.1f%%", maxReturn))
.font(.caption2)
.foregroundColor(.positiveGreen)
.foregroundStyle(Color.positiveGreen)
}
HStack(spacing: 3) {
Image(systemName: "arrow.down")
.font(.caption2.weight(.semibold))
.foregroundColor(.negativeRed)
.foregroundStyle(Color.negativeRed)
Text(String(format: "%.1f%%", minReturn))
.font(.caption2)
.foregroundColor(.negativeRed)
.foregroundStyle(Color.negativeRed)
}
}
}
@@ -175,14 +175,14 @@ struct PeriodComparisonChartView: View {
VStack(spacing: 12) {
Image(systemName: "calendar.badge.clock")
.font(.system(size: 36))
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
Text("No data available for the selected periods.")
.font(.subheadline)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity)
.frame(height: 260)
.chartFrame(height: 260)
}
// MARK: - Flat data for Chart
@@ -303,7 +303,7 @@ struct PeriodComparisonChartView: View {
.foregroundStyle(Color.secondary.opacity(0.15))
}
}
.frame(height: 260)
.chartFrame(height: 260)
.chartDrawingGroup(disabledForExport: chartImageExport)
.onChange(of: seriesIds) { _, _ in }
}
@@ -317,7 +317,7 @@ struct PeriodComparisonChartView: View {
.frame(width: 20, height: 3)
Text(series.label)
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
}
Spacer()
@@ -36,10 +36,10 @@ struct PredictionChartView: View {
VStack(alignment: .trailing, spacing: 2) {
Text("Forecast")
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
Text(lastPrediction.formattedValue)
.font(.subheadline.weight(.semibold))
.foregroundColor(.appPrimary)
.foregroundStyle(Color.appPrimary)
}
}
}
@@ -47,7 +47,7 @@ struct PredictionChartView: View {
if let algorithm = predictions.first?.algorithm {
Text("Algorithm: \(algorithm.displayName)")
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
if !predictions.isEmpty && historicalData.count >= 2 {
@@ -185,7 +185,7 @@ struct PredictionChartView: View {
}
}
}
.frame(height: 280)
.chartFrame(height: 280)
// Stats row
if let currentValue = historicalData.last?.value,
@@ -206,7 +206,7 @@ struct PredictionChartView: View {
.frame(width: 20, height: 3)
Text("Historical")
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
HStack(spacing: 6) {
@@ -223,7 +223,7 @@ struct PredictionChartView: View {
)
Text("Prediction")
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
HStack(spacing: 6) {
@@ -232,7 +232,7 @@ struct PredictionChartView: View {
.frame(width: 20, height: 10)
Text("Confidence")
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
HStack(spacing: 6) {
@@ -241,7 +241,7 @@ struct PredictionChartView: View {
.frame(width: 20, height: 2)
Text("Bull")
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
HStack(spacing: 6) {
@@ -250,7 +250,7 @@ struct PredictionChartView: View {
.frame(width: 20, height: 2)
Text("Bear")
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
}
@@ -274,7 +274,7 @@ struct PredictionChartView: View {
Spacer()
Text(lastPrediction.formattedConfidenceRange)
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
if let currentValue = historicalData.last?.value {
@@ -293,7 +293,7 @@ struct PredictionChartView: View {
Text(String(format: "%+.1f%%", changePercent))
.font(.subheadline.weight(.medium))
}
.foregroundColor(change >= 0 ? .positiveGreen : .negativeRed)
.foregroundStyle(change >= 0 ? Color.positiveGreen : Color.negativeRed)
}
}
}
@@ -310,24 +310,24 @@ struct PredictionChartView: View {
VStack(spacing: 12) {
Image(systemName: "wand.and.stars")
.font(.system(size: 40))
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
Text("Not enough data for predictions")
.font(.subheadline)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
Text("Add at least 3 snapshots to generate predictions")
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
.frame(height: 280)
.chartFrame(height: 280)
.frame(maxWidth: .infinity)
}
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
}
@@ -30,9 +30,9 @@ struct YearOverYearChartView: View {
if allYears.isEmpty {
Text(String(localized: "chart_yoy_empty"))
.font(.subheadline)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .center)
.frame(height: 200)
.chartFrame(height: 200)
} else {
yearSelector
if !selectedSeries.isEmpty {
@@ -44,7 +44,7 @@ struct YearOverYearChartView: View {
if hasEstimate {
Text(String(localized: "chart_yoy_estimated_note"))
.font(.caption2)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
// Detalle debajo (#147)
chartBody
@@ -57,7 +57,7 @@ struct YearOverYearChartView: View {
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
}
@@ -82,12 +82,12 @@ struct YearOverYearChartView: View {
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(isSelected ? color.opacity(0.15) : Color.gray.opacity(0.1))
.foregroundColor(isSelected ? color : .secondary)
.foregroundStyle(isSelected ? color : .secondary)
.overlay(
RoundedRectangle(cornerRadius: 16)
.stroke(isSelected ? color : Color.clear, lineWidth: 1.5)
)
.cornerRadius(16)
.clipShape(RoundedRectangle(cornerRadius: 16))
}
.buttonStyle(.plain)
}
@@ -181,11 +181,11 @@ struct YearOverYearChartView: View {
AxisGridLine()
}
}
.frame(height: 220)
.chartFrame(height: 220)
} else {
Text("iOS 16+ required for chart")
.foregroundColor(.secondary)
.frame(height: 220)
.foregroundStyle(.secondary)
.chartFrame(height: 220)
}
}
@@ -202,7 +202,7 @@ struct YearOverYearChartView: View {
.frame(width: 20, height: 3)
Text(String(yearSeries.year))
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
}
}
@@ -221,16 +221,16 @@ struct YearOverYearChartView: View {
VStack(alignment: .center, spacing: 3) {
Text(String(yearSeries.year))
.font(.caption2)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
Text(String(format: "%+.1f%%", end.value) + (end.estimated ? "*" : ""))
.font(.subheadline.weight(.semibold))
.foregroundColor(end.value >= 0 ? color : .negativeRed)
.foregroundStyle(end.value >= 0 ? color : Color.negativeRed)
}
.frame(minWidth: 60)
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(Color(.systemGray6))
.cornerRadius(8)
.clipShape(RoundedRectangle(cornerRadius: 8))
}
}
}
@@ -0,0 +1,39 @@
import SwiftUI
// MARK: - Chart height scaling
//
// Charts declare a base height for compact width. Containers with a wider
// canvas (regular width: iPad, iPhone Duo inner screen, Mac windows) raise the
// scale so plots gain vertical room instead of just stretching horizontally.
extension EnvironmentValues {
@Entry var chartHeightScale: CGFloat = 1
}
private struct ChartFrameModifier: ViewModifier {
@Environment(\.chartHeightScale) private var scale
let base: CGFloat
func body(content: Content) -> some View {
content.frame(height: base * scale)
}
}
extension View {
/// Fixed chart height that follows `chartHeightScale` from the environment.
func chartFrame(height: CGFloat) -> some View {
modifier(ChartFrameModifier(base: height))
}
/// Floating label background: Liquid Glass on iOS 26+, material fallback.
@ViewBuilder
func floatingPillBackground() -> some View {
if #available(iOS 26.0, *) {
self.glassEffect(.regular, in: .capsule)
} else {
self
.background(.regularMaterial, in: Capsule())
.shadow(color: .black.opacity(0.12), radius: 6, y: 2)
}
}
}
@@ -10,7 +10,7 @@ struct LoadingView: View {
Text(message)
.font(.subheadline)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color(.systemBackground))
@@ -30,14 +30,14 @@ struct AppLaunchLoadingView: View {
.frame(width: 140, height: 140)
.padding(16)
.background(Color.appPrimary.opacity(0.08))
.cornerRadius(28)
.clipShape(RoundedRectangle(cornerRadius: 28))
ProgressView()
.scaleEffect(1.2)
Text(messageKey)
.font(.subheadline)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
Spacer()
}
@@ -82,27 +82,27 @@ struct SkeletonCardView: View {
VStack(alignment: .leading, spacing: 12) {
SkeletonView()
.frame(width: 100, height: 16)
.cornerRadius(4)
.clipShape(RoundedRectangle(cornerRadius: 4))
SkeletonView()
.frame(height: 32)
.cornerRadius(4)
.clipShape(RoundedRectangle(cornerRadius: 4))
HStack {
SkeletonView()
.frame(width: 80, height: 14)
.cornerRadius(4)
.clipShape(RoundedRectangle(cornerRadius: 4))
Spacer()
SkeletonView()
.frame(width: 60, height: 14)
.cornerRadius(4)
.clipShape(RoundedRectangle(cornerRadius: 4))
}
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
}
}
@@ -119,14 +119,14 @@ struct EmptyStateView: View {
VStack(spacing: 20) {
Image(systemName: icon)
.font(.system(size: 60))
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
Text(title)
.font(.title2.weight(.semibold))
Text(message)
.font(.subheadline)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal, 40)
@@ -134,11 +134,11 @@ struct EmptyStateView: View {
Button(action: action) {
Text(actionTitle)
.font(.headline)
.foregroundColor(.white)
.foregroundStyle(.white)
.padding()
.frame(maxWidth: 200)
.background(Color.appPrimary)
.cornerRadius(AppConstants.UI.cornerRadius)
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
}
}
}
@@ -156,14 +156,14 @@ struct ErrorView: View {
VStack(spacing: 16) {
Image(systemName: "exclamationmark.triangle.fill")
.font(.system(size: 48))
.foregroundColor(.appWarning)
.foregroundStyle(Color.appWarning)
Text("Something went wrong")
.font(.headline)
Text(message)
.font(.subheadline)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal, 40)
@@ -174,8 +174,8 @@ struct ErrorView: View {
.padding(.horizontal, 20)
.padding(.vertical, 10)
.background(Color.appPrimary)
.foregroundColor(.white)
.cornerRadius(20)
.foregroundStyle(.white)
.clipShape(RoundedRectangle(cornerRadius: 20))
}
}
}
@@ -199,7 +199,7 @@ struct SuccessView: View {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 60))
.foregroundColor(.positiveGreen)
.foregroundStyle(Color.positiveGreen)
}
Text(title)
@@ -207,7 +207,7 @@ struct SuccessView: View {
Text(message)
.font(.subheadline)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal, 40)
@@ -215,11 +215,11 @@ struct SuccessView: View {
Button(action: action) {
Text("Continue")
.font(.headline)
.foregroundColor(.white)
.foregroundStyle(.white)
.padding()
.frame(maxWidth: 200)
.background(Color.appPrimary)
.cornerRadius(AppConstants.UI.cornerRadius)
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
}
}
}
@@ -255,14 +255,14 @@ struct ToastView: View {
var body: some View {
HStack(spacing: 12) {
Image(systemName: type.icon)
.foregroundColor(type.color)
.foregroundStyle(type.color)
Text(message)
.font(.subheadline)
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
.shadow(color: .black.opacity(0.1), radius: 10, y: 5)
}
}
@@ -21,7 +21,7 @@ struct ProgressRing: View {
.animation(.easeOut(duration: 0.6), value: clamped)
Text("\(Int((clamped * 100).rounded()))%")
.font(.system(size: size * 0.26, weight: .bold, design: .rounded))
.foregroundColor(tint)
.foregroundStyle(tint)
.minimumScaleFactor(0.7)
}
.frame(width: size, height: size)
@@ -17,7 +17,7 @@ struct CategoryBreakdownCard: View {
} label: {
Text("See All")
.font(.subheadline)
.foregroundColor(.appPrimary)
.foregroundStyle(Color.appPrimary)
}
}
@@ -27,7 +27,7 @@ struct CategoryBreakdownCard: View {
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
}
}
@@ -55,7 +55,7 @@ struct CategoryRowView: View {
Image(systemName: category.icon)
.font(.system(size: 14))
.foregroundColor(Color(hex: category.colorHex) ?? .gray)
.foregroundStyle(Color(hex: category.colorHex) ?? .gray)
}
// Name and percentage
@@ -65,12 +65,12 @@ struct CategoryRowView: View {
Text(category.formattedPercentage)
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
if let target = targetPercentage {
Text("Target \(String(format: "%.0f%%", target)) | Drift \(driftText ?? "")")
.font(.caption2)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
}
@@ -84,10 +84,10 @@ struct CategoryRowView: View {
HStack(spacing: 4) {
Text("CAGR")
.font(.caption2)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
Text(category.metrics.formattedCAGR)
.font(.caption.weight(.semibold))
.foregroundColor(category.metrics.cagr >= 0 ? .positiveGreen : .negativeRed)
.foregroundStyle(category.metrics.cagr >= 0 ? Color.positiveGreen : Color.negativeRed)
}
}
}
@@ -122,7 +122,7 @@ struct CategoryProgressBar: View {
Text(category.formattedPercentage)
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
GeometryReader { geometry in
@@ -130,12 +130,12 @@ struct CategoryProgressBar: View {
Rectangle()
.fill(Color.gray.opacity(0.1))
.frame(height: 6)
.cornerRadius(3)
.clipShape(RoundedRectangle(cornerRadius: 3))
Rectangle()
.fill(Color(hex: category.colorHex) ?? .gray)
.frame(width: geometry.size.width * progress, height: 6)
.cornerRadius(3)
.clipShape(RoundedRectangle(cornerRadius: 3))
}
}
.frame(height: 6)
@@ -166,7 +166,7 @@ struct SimpleCategoryList: View {
Text("(\(category.formattedPercentage))")
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
}
}
@@ -77,16 +77,17 @@ struct DashboardView: View {
}
}
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
ToolbarItem(placement: .topBarTrailing) {
Button {
QuickUpdateTip().invalidate(reason: .actionPerformed)
showingQuickUpdate = true
} label: {
Image(systemName: "plus.circle.fill")
}
.accessibilityIdentifier("dashboard.quickUpdate")
.popoverTip(QuickUpdateTip())
}
ToolbarItem(placement: .navigationBarTrailing) {
ToolbarItem(placement: .topBarTrailing) {
Button {
balancePrivacy.toggleHidden()
} label: {
@@ -94,10 +95,10 @@ struct DashboardView: View {
}
.accessibilityLabel(balancePrivacy.balancesHidden ? Text("Show balances") : Text("Hide balances"))
}
ToolbarItem(placement: .navigationBarTrailing) {
ToolbarItem(placement: .topBarTrailing) {
accountFilterMenu
}
ToolbarItem(placement: .navigationBarLeading) {
ToolbarItem(placement: .topBarLeading) {
Button {
showingCustomize = true
} label: {
@@ -140,10 +141,6 @@ struct DashboardView: View {
.sheet(isPresented: $showingCustomize) {
DashboardCustomizeView(configs: $sectionConfigs)
}
.sheet(isPresented: $showingQuickUpdate) {
QuickUpdateView()
.environment(\.managedObjectContext, CoreDataStack.shared.viewContext)
}
.sheet(isPresented: $viewModel.showingPaywall) {
PaywallView()
}
@@ -172,6 +169,18 @@ struct DashboardView: View {
if !viewModel.hasData { showingAddSource = true }
}
}
// Quick Update lives in an inspector: a trailing panel in regular width
// (iPad, iPhone Duo inner screen) so the dashboard charts stay visible
// while values are typed; the system presents it as a sheet in compact.
.inspector(isPresented: $showingQuickUpdate) {
QuickUpdateView()
.environment(\.managedObjectContext, CoreDataStack.shared.viewContext)
.environmentObject(iapService)
.environmentObject(accountStore)
.environmentObject(tabSelection)
.environmentObject(balancePrivacy)
.inspectorColumnWidth(min: 340, ideal: 400, max: 480)
}
}
private var accountFilterMenu: some View {
@@ -275,7 +284,10 @@ struct DashboardView: View {
GeometryReader { geo in
// Scale column count with available width so a wide Mac window fills
// (3 columns) while an iPad stays at 2. Lead cards span full width.
let columnCount = geo.size.width >= 1250 ? 3 : 2
// Continuous sizing: 3 columns on a wide Mac window, 2 on an iPad /
// Duo inner screen, 1 when a side panel (Quick Update inspector,
// Split View) leaves too little room for two readable cards.
let columnCount = geo.size.width >= 1250 ? 3 : (geo.size.width >= 600 ? 2 : 1)
let columns = iPadContextColumns(count: columnCount)
ScrollView {
VStack(spacing: 16) {
@@ -400,7 +412,14 @@ struct DashboardView: View {
paceStatusProvider: goalsViewModel.paceStatus(for:),
etaProvider: { goal in
let currentValue = goalsViewModel.totalValue(for: goal)
return viewModel.goalEtaText(for: goal, currentValue: currentValue)
guard goal.account != nil || goal.category != nil else {
return viewModel.goalEtaText(for: goal, currentValue: currentValue)
}
return viewModel.goalEtaText(
for: goal,
currentValue: currentValue,
projection: goalsViewModel.projection(for: goal)
)
}
)
}
@@ -468,20 +487,20 @@ struct TotalValueCard: View {
HStack {
Text("Total Portfolio Value")
.font(.subheadline.weight(.medium))
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
Spacer()
if let onShareTap {
Button(action: onShareTap) {
Image(systemName: "square.and.arrow.up")
.font(.subheadline.weight(.semibold))
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
}
}
Text(totalValue)
.font(.system(size: 44, weight: .bold, design: .rounded))
.foregroundColor(.primary)
.foregroundStyle(.primary)
.minimumScaleFactor(0.6)
.lineLimit(1)
.hiddenBalance(balancesHidden)
@@ -493,7 +512,7 @@ struct TotalValueCard: View {
Text(changeText)
.font(.subheadline.weight(.semibold))
}
.foregroundColor(isPositive ? .positiveGreen : .negativeRed)
.foregroundStyle(isPositive ? Color.positiveGreen : Color.negativeRed)
.padding(.horizontal, 10)
.padding(.vertical, 5)
.background((isPositive ? Color.positiveGreen : Color.negativeRed).opacity(0.12))
@@ -501,7 +520,7 @@ struct TotalValueCard: View {
Text(changeLabel)
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
if sparklineData.count >= 2 {
@@ -534,10 +553,10 @@ struct TotalValueCard: View {
Image(systemName: "lock.fill").font(.caption2)
Text("12m forecast").font(.caption2)
}
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
Text("Unlock")
.font(.caption.weight(.semibold))
.foregroundColor(.appPrimary)
.foregroundStyle(Color.appPrimary)
}
.frame(maxWidth: .infinity)
}
@@ -549,17 +568,17 @@ struct TotalValueCard: View {
.padding(20)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color(.secondarySystemGroupedBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
}
private func heroMetric(label: String, value: String, positive: Bool) -> some View {
VStack(spacing: 2) {
Text(label)
.font(.caption2)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
Text(value)
.font(.caption.weight(.semibold))
.foregroundColor(positive ? .positiveGreen : .negativeRed)
.foregroundStyle(positive ? Color.positiveGreen : Color.negativeRed)
}
.frame(maxWidth: .infinity)
}
@@ -750,15 +769,10 @@ struct MonthlyCheckInCard: View {
}
.padding(isMonthComplete ? 14 : 20)
.background(Color(.secondarySystemGroupedBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.background(
NavigationLink(isActive: $startDestinationActive) {
MonthlyCheckInView(referenceDate: navigationReferenceDate)
} label: {
EmptyView()
}
.opacity(0)
)
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
.navigationDestination(isPresented: $startDestinationActive) {
MonthlyCheckInView(referenceDate: navigationReferenceDate)
}
// Calm 2.0 Fase 2: the ritual runs as a guided full-screen flow one
// source per step, reflection, closing summary.
.fullScreenCover(isPresented: $showingGuidedFlow) {
@@ -772,14 +786,14 @@ struct MonthlyCheckInCard: View {
HStack(spacing: 10) {
Image(systemName: "checkmark.seal.fill")
.font(.title3)
.foregroundColor(.positiveGreen)
.foregroundStyle(Color.positiveGreen)
VStack(alignment: .leading, spacing: 2) {
Text(String(format: String(localized: "checkin_done_title"), currentMonthLabel))
.font(.subheadline.weight(.semibold))
if let nextDate = nextCheckInDate {
Text(String(format: String(localized: "checkin_done_next"), nextDate.mediumDateString))
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
}
Spacer()
@@ -790,7 +804,7 @@ struct MonthlyCheckInCard: View {
Text("\(streak)")
.font(.caption.weight(.bold))
}
.foregroundColor(.orange)
.foregroundStyle(.orange)
.padding(.horizontal, 10)
.padding(.vertical, 5)
.background(Color.orange.opacity(0.12))
@@ -800,7 +814,7 @@ struct MonthlyCheckInCard: View {
Button(action: onShareSummary) {
Image(systemName: "square.and.arrow.up")
.font(.caption.weight(.semibold))
.foregroundColor(.appPrimary)
.foregroundStyle(Color.appPrimary)
}
.buttonStyle(.plain)
.accessibilityLabel(Text("Share monthly summary"))
@@ -810,7 +824,7 @@ struct MonthlyCheckInCard: View {
} label: {
Image(systemName: "chevron.right")
.font(.caption.weight(.semibold))
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
}
.contentShape(Rectangle())
@@ -826,7 +840,7 @@ struct MonthlyCheckInCard: View {
.font(.headline)
Text(currentMonthLabel)
.font(.subheadline)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
Spacer()
if streak >= 2 {
@@ -834,7 +848,7 @@ struct MonthlyCheckInCard: View {
Image(systemName: "flame.fill").font(.caption)
Text("\(streak)").font(.caption.weight(.bold))
}
.foregroundColor(.orange)
.foregroundStyle(.orange)
.padding(.horizontal, 10)
.padding(.vertical, 5)
.background(Color.orange.opacity(0.12))
@@ -857,7 +871,7 @@ struct MonthlyCheckInCard: View {
)
} label: {
Image(systemName: "calendar.badge.plus")
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
.font(.subheadline.weight(.semibold))
}
@@ -869,7 +883,7 @@ struct MonthlyCheckInCard: View {
.tint(progressBarTint)
Text(String(format: String(localized: "checkin_sources_progress"), updatedCount, totalSources))
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
} else {
ProgressView(value: checkInProgress)
@@ -880,15 +894,15 @@ struct MonthlyCheckInCard: View {
HStack(spacing: 6) {
Image(systemName: "calendar")
.font(.caption)
.foregroundColor(isOverdue ? .red : .secondary)
.foregroundStyle(isOverdue ? .red : .secondary)
Text(String(format: String(localized: "checkin_next_due"), nextDate.mediumDateString))
.font(.caption)
.foregroundColor(isOverdue ? .red : .secondary)
.foregroundStyle(isOverdue ? .red : .secondary)
Spacer()
if !deadlineBadgeText.isEmpty {
Text(deadlineBadgeText)
.font(.caption2.weight(.semibold))
.foregroundColor(progressBarTint)
.foregroundStyle(progressBarTint)
.padding(.horizontal, 8)
.padding(.vertical, 3)
.background(progressBarTint.opacity(0.12))
@@ -898,7 +912,7 @@ struct MonthlyCheckInCard: View {
.padding(.horizontal, 10)
.padding(.vertical, 8)
.background(Color(.tertiarySystemFill))
.cornerRadius(10)
.clipShape(RoundedRectangle(cornerRadius: 10))
}
Button {
@@ -909,8 +923,8 @@ struct MonthlyCheckInCard: View {
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
.background(Color.appPrimary)
.foregroundColor(.white)
.cornerRadius(AppConstants.UI.cornerRadius)
.foregroundStyle(.white)
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
}
.accessibilityIdentifier("checkin_start_cta")
}
@@ -952,11 +966,11 @@ struct MomentumStreaksCard: View {
if stats.totalCheckIns > 0 {
Text(String(format: NSLocalizedString("on_time_rate", comment: ""), onTimeRateText))
.font(.subheadline.weight(.semibold))
.foregroundColor(.appSecondary)
.foregroundStyle(Color.appSecondary)
} else {
Text("Log a check-in to start a streak")
.font(.subheadline)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
}
@@ -992,7 +1006,7 @@ struct MomentumStreaksCard: View {
HStack {
Text("On-time score")
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
Spacer()
Text(
String(
@@ -1002,7 +1016,7 @@ struct MomentumStreaksCard: View {
)
)
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
if let closest = stats.closestCutoffDays {
@@ -1013,7 +1027,7 @@ struct MomentumStreaksCard: View {
)
)
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
if !stats.achievements.isEmpty && !compact {
@@ -1043,7 +1057,7 @@ struct MomentumStreaksCard: View {
Spacer()
Image(systemName: "chevron.right")
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
.padding(.vertical, 8)
.contentShape(Rectangle())
@@ -1052,7 +1066,7 @@ struct MomentumStreaksCard: View {
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
.onAppear(perform: refreshStats)
.onReceive(NotificationCenter.default.publisher(for: .NSManagedObjectContextObjectsDidChange)) { _ in
@@ -1087,14 +1101,14 @@ struct MomentumStreaksCard: View {
.lineLimit(1)
Text(title)
.font(.caption2)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
.lineLimit(1)
.minimumScaleFactor(0.8)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 10)
.background(Color.gray.opacity(0.08))
.cornerRadius(AppConstants.UI.smallCornerRadius)
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.smallCornerRadius))
}
@ViewBuilder
@@ -1102,17 +1116,17 @@ struct MomentumStreaksCard: View {
VStack(alignment: .leading, spacing: 4) {
Text(title)
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
Text(value)
.font(.title3.weight(.semibold))
Text(subtitle)
.font(.caption2)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
.padding()
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color.gray.opacity(0.08))
.cornerRadius(AppConstants.UI.smallCornerRadius)
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.smallCornerRadius))
}
@ViewBuilder
@@ -1120,18 +1134,18 @@ struct MomentumStreaksCard: View {
HStack(alignment: .top, spacing: 8) {
Image(systemName: achievement.icon)
.font(.headline)
.foregroundColor(.appSecondary)
.foregroundStyle(Color.appSecondary)
VStack(alignment: .leading, spacing: 2) {
Text(achievement.title)
.font(.caption.weight(.semibold))
Text(achievement.detail)
.font(.caption2)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
}
.padding(10)
.background(Color.appSecondary.opacity(0.12))
.cornerRadius(AppConstants.UI.smallCornerRadius)
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.smallCornerRadius))
}
private func formattedDaysText(for days: Double?) -> String {
@@ -1181,7 +1195,7 @@ struct MonthlySummaryCard: View {
VStack(alignment: .leading, spacing: 4) {
Text("Contributions")
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
Text(summary.formattedContributions)
.font(.subheadline.weight(.semibold))
.hiddenBalance()
@@ -1192,17 +1206,17 @@ struct MonthlySummaryCard: View {
VStack(alignment: .trailing, spacing: 4) {
Text("Net Performance")
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
Text("\(summary.formattedNetPerformance) (\(summary.formattedNetPerformancePercentage))")
.font(.subheadline.weight(.semibold))
.foregroundColor(summary.netPerformance >= 0 ? .positiveGreen : .negativeRed)
.foregroundStyle(summary.netPerformance >= 0 ? Color.positiveGreen : Color.negativeRed)
.hiddenBalance()
}
}
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
}
}
@@ -1223,7 +1237,7 @@ struct DashboardCustomizeView: View {
.font(.subheadline.weight(.semibold))
Text(config.isCollapsed ? "Compact" : "Expanded")
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
Spacer()
@@ -1246,10 +1260,10 @@ struct DashboardCustomizeView: View {
}
.navigationTitle("Customize Dashboard")
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
ToolbarItem(placement: .topBarLeading) {
EditButton()
}
ToolbarItem(placement: .navigationBarTrailing) {
ToolbarItem(placement: .topBarTrailing) {
Button("Done") {
DashboardLayoutStore.save(configs)
dismiss()
@@ -1276,18 +1290,18 @@ struct CompactCard: View {
if subtitleIsBalance {
Text(subtitle)
.font(.subheadline)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
.hiddenBalance()
} else {
Text(subtitle)
.font(.subheadline)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
}
.padding()
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color(.systemBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
}
}
@@ -1302,7 +1316,7 @@ struct EvolutionCompactCard: View {
if let last = data.last {
Text(last.value.compactCurrencyString)
.font(.subheadline.weight(.semibold))
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
SparklineView(data: data, color: .appPrimary)
.frame(height: 40)
@@ -1310,7 +1324,7 @@ struct EvolutionCompactCard: View {
.padding()
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color(.systemBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
}
}
@@ -1357,7 +1371,7 @@ struct PeriodReturnsCard: View {
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
}
}
@@ -1371,11 +1385,11 @@ struct ReturnPeriodView: View {
VStack(spacing: 4) {
Text(period)
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
Text(change)
.font(.subheadline.weight(.semibold))
.foregroundColor(isPositive ? .positiveGreen : .negativeRed)
.foregroundStyle(isPositive ? Color.positiveGreen : Color.negativeRed)
.lineLimit(1)
.minimumScaleFactor(0.8)
}
@@ -1394,14 +1408,14 @@ struct EmptyDashboardView: View {
VStack(spacing: 20) {
Image(systemName: "chart.pie")
.font(.system(size: 60))
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
Text("Welcome to Portfolio Journal")
.font(.custom("Avenir Next", size: 24).weight(.semibold))
Text("Start by adding your first investment source to track your portfolio.")
.font(.subheadline)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
Button {
@@ -1409,11 +1423,11 @@ struct EmptyDashboardView: View {
} label: {
Label("Add Investment Source", systemImage: "plus")
.font(.headline)
.foregroundColor(.white)
.foregroundStyle(.white)
.padding()
.frame(maxWidth: .infinity)
.background(Color.appPrimary)
.cornerRadius(AppConstants.UI.cornerRadius)
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
}
HStack(spacing: 12) {
@@ -1425,8 +1439,8 @@ struct EmptyDashboardView: View {
.frame(maxWidth: .infinity)
.padding()
.background(Color.appPrimary.opacity(0.1))
.foregroundColor(.appPrimary)
.cornerRadius(AppConstants.UI.cornerRadius)
.foregroundStyle(Color.appPrimary)
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
}
Button {
@@ -1437,8 +1451,8 @@ struct EmptyDashboardView: View {
.frame(maxWidth: .infinity)
.padding()
.background(Color.appSecondary.opacity(0.1))
.foregroundColor(.appSecondary)
.cornerRadius(AppConstants.UI.cornerRadius)
.foregroundStyle(Color.appSecondary)
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
}
}
}
@@ -1455,24 +1469,24 @@ struct PendingUpdatesAlertBanner: View {
var body: some View {
HStack(spacing: 10) {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundColor(.white)
.foregroundStyle(.white)
Text("\(count) source\(count == 1 ? "" : "s") pending update")
.font(.subheadline.weight(.semibold))
.foregroundColor(.white)
.foregroundStyle(.white)
Spacer()
Button(action: onDismiss) {
Image(systemName: "xmark")
.font(.caption.weight(.bold))
.foregroundColor(.white.opacity(0.8))
.foregroundStyle(.white.opacity(0.8))
}
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
.background(Color.appWarning)
.cornerRadius(AppConstants.UI.cornerRadius)
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
}
}
@@ -1486,13 +1500,13 @@ struct PendingUpdatesCard: View {
VStack(alignment: .leading, spacing: 12) {
HStack {
Image(systemName: "bell.badge.fill")
.foregroundColor(.appWarning)
.foregroundStyle(Color.appWarning)
Text("Pending Updates")
.font(.headline)
Spacer()
Text("\(sources.count)")
.font(.subheadline)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
ForEach(sources.prefix(3), id: \.objectID) { source in
@@ -1509,11 +1523,11 @@ struct PendingUpdatesCard: View {
Text(source.latestSnapshot?.date.relativeDescription ?? "Never")
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
Image(systemName: "chevron.right")
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
}
.buttonStyle(.plain)
@@ -1522,12 +1536,12 @@ struct PendingUpdatesCard: View {
if sources.count > 3 {
Text("+ \(sources.count - 3) more")
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
}
}
@@ -1573,29 +1587,40 @@ struct GoalsSummaryCard: View {
lineWidth: 5
)
VStack(alignment: .leading, spacing: 3) {
Text(goal.name)
.font(.subheadline.weight(.semibold))
HStack(spacing: 6) {
Text(goal.name)
.font(.subheadline.weight(.semibold))
if let category = goal.category {
HStack(spacing: 3) {
Image(systemName: category.icon)
.font(.system(size: 9))
Text(category.name)
.font(.caption2.weight(.semibold))
}
.foregroundStyle(category.color)
}
}
HStack(spacing: 4) {
Text(currentValue.compactCurrencyString)
.font(.caption.weight(.semibold))
Text("of \(goal.targetDecimal.compactCurrencyString)")
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
if let targetDate = goal.targetDate {
Text("Target: \(targetDate.mediumDateString)")
.font(.caption2.weight(.semibold))
.foregroundColor(targetUrgency == .critical ? .negativeRed : (targetUrgency == .warning ? .appWarning : .secondary))
.foregroundStyle(targetUrgency == .critical ? Color.negativeRed : (targetUrgency == .warning ? Color.appWarning : .secondary))
}
if let etaText = etaProvider(goal) {
Text(etaText)
.font(.caption2)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
if let paceStatus {
Text(paceStatus.statusText)
.font(.caption2.weight(.semibold))
.foregroundColor(paceStatus.isBehind ? .appWarning : .positiveGreen)
.foregroundStyle(paceStatus.isBehind ? Color.appWarning : Color.positiveGreen)
}
}
Spacer()
@@ -1609,14 +1634,14 @@ struct GoalsSummaryCard: View {
} label: {
Image(systemName: "square.and.arrow.up")
.font(.caption)
.foregroundColor(.appPrimary)
.foregroundStyle(Color.appPrimary)
}
}
}
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
}
}
@@ -1636,11 +1661,11 @@ struct EmptyGoalsCard: View {
}
Text("Add a milestone like 1M and track your progress each month.")
.font(.subheadline)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
}
}
@@ -1685,7 +1710,7 @@ struct ContributionsVsReturnsCard: View {
Circle().fill(Color.appSecondary).frame(width: 10, height: 10)
VStack(alignment: .leading, spacing: 2) {
Text(String(localized: "contributions_vs_returns_invested"))
.font(.caption).foregroundColor(.secondary)
.font(.caption).foregroundStyle(.secondary)
Text(totalContributions.currencyString)
.font(.subheadline.weight(.semibold))
}
@@ -1695,18 +1720,18 @@ struct ContributionsVsReturnsCard: View {
Circle().fill(isReturnPositive ? Color.positiveGreen : Color.negativeRed).frame(width: 10, height: 10)
VStack(alignment: .trailing, spacing: 2) {
Text(String(localized: "contributions_vs_returns_returns"))
.font(.caption).foregroundColor(.secondary)
.font(.caption).foregroundStyle(.secondary)
let prefix = totalReturns >= 0 ? "+" : ""
Text("\(prefix)\(totalReturns.currencyString)")
.font(.subheadline.weight(.semibold))
.foregroundColor(isReturnPositive ? .positiveGreen : .negativeRed)
.foregroundStyle(isReturnPositive ? Color.positiveGreen : Color.negativeRed)
}
}
}
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
}
}
@@ -13,27 +13,27 @@ struct DataGapsBanner: View {
HStack(spacing: 12) {
Image(systemName: "exclamationmark.triangle.fill")
.font(.title3)
.foregroundColor(.orange)
.foregroundStyle(.orange)
VStack(alignment: .leading, spacing: 2) {
Text(String(format: String(localized: "gaps_banner_title"), totalMissingMonths))
.font(.subheadline.weight(.semibold))
.foregroundColor(.primary)
.foregroundStyle(.primary)
.multilineTextAlignment(.leading)
Text("Fill in missing data")
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
Spacer()
Image(systemName: "chevron.right")
.font(.caption.weight(.semibold))
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
.padding(14)
.background(Color(.secondarySystemGroupedBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
.overlay(
RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius, style: .continuous)
.stroke(Color.orange.opacity(0.35), lineWidth: 1)
@@ -44,7 +44,7 @@ struct DataGapsBanner: View {
Button(action: onDismiss) {
Image(systemName: "xmark.circle.fill")
.font(.body)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
.padding(6)
}
.buttonStyle(.plain)
@@ -89,7 +89,7 @@ struct DataGapsSheet: View {
.navigationTitle("Missing Data")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
ToolbarItem(placement: .topBarTrailing) {
Button("Done") { dismiss() }
}
}
@@ -109,7 +109,7 @@ struct DataGapsSheet: View {
Spacer()
Text(String(format: String(localized: "gaps_missing_count"), gap.missingMonths))
.font(.caption.weight(.semibold))
.foregroundColor(.orange)
.foregroundStyle(.orange)
.padding(.horizontal, 8)
.padding(.vertical, 3)
.background(Color.orange.opacity(0.12))
@@ -118,7 +118,7 @@ struct DataGapsSheet: View {
Text(rangeLabel(gap))
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
if let source = sourceProvider(gap.sourceId),
let firstMissing = gap.missingMonthDates.first {
@@ -147,7 +147,7 @@ struct EvolutionChartCard: View {
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
.contentShape(Rectangle())
// simultaneousGesture so the swipe is recognized even over the chart,
@@ -206,19 +206,18 @@ struct EvolutionChartCard: View {
.frame(width: 72, height: 72)
Image(systemName: page.chartsType.icon)
.font(.system(size: 28))
.foregroundColor(.appPrimary)
.foregroundStyle(Color.appPrimary)
Image(systemName: "lock.circle.fill")
.font(.system(size: 22))
.foregroundColor(.appWarning)
.foregroundStyle(Color.appWarning)
.background(Circle().fill(Color(.systemBackground)))
.offset(x: 26, y: 24)
}
Text(String(localized: "home_chart_locked_sub"))
.font(.subheadline)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
Button {
FirebaseService.shared.logPaywallShown(trigger: "home_charts")
onRequestUpgrade()
} label: {
Label(String(localized: "chart_locked_cta"), systemImage: "lock.open.fill")
@@ -226,7 +225,7 @@ struct EvolutionChartCard: View {
.padding(.horizontal, 20)
.padding(.vertical, 10)
.background(Color.appPrimary)
.foregroundColor(.white)
.foregroundStyle(.white)
.clipShape(Capsule())
}
}
@@ -246,7 +245,7 @@ struct EvolutionChartCard: View {
showGoalLines.toggle()
} label: {
Image(systemName: showGoalLines ? "target" : "slash.circle")
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
.accessibilityLabel(showGoalLines ? "Hide goals" : "Show goals")
@@ -256,7 +255,7 @@ struct EvolutionChartCard: View {
.font(.subheadline.weight(.semibold))
Text(selected.date.monthYearString)
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
}
}
@@ -303,7 +302,7 @@ struct EvolutionChartCard: View {
Text(item.category).font(.caption)
Spacer()
Text(String(format: "%.1f%%", pct)).font(.caption.weight(.medium))
Text(item.value.compactCurrencyString).font(.caption).foregroundColor(.secondary)
Text(item.value.compactCurrencyString).font(.caption).foregroundStyle(.secondary)
}
}
}
@@ -325,7 +324,7 @@ struct EvolutionChartCard: View {
} else {
Text("Not enough data to display chart")
.font(.subheadline)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
.frame(height: 200)
.frame(maxWidth: .infinity)
}
@@ -449,7 +448,7 @@ struct EvolutionChartCard: View {
.annotation(position: .topTrailing) {
Text(goal.name)
.font(.caption2)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
}
}
@@ -126,7 +126,7 @@ struct GuidedCheckInView: View {
if let category = source.category?.name {
Text(category)
.font(.subheadline)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
}
@@ -134,10 +134,10 @@ struct GuidedCheckInView: View {
VStack(spacing: 2) {
Text(String(localized: "guided_previous_value"))
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
Text(source.latestValue.currencyString)
.font(.title3.weight(.semibold))
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
}
@@ -150,7 +150,7 @@ struct GuidedCheckInView: View {
.padding(.vertical, 12)
.padding(.horizontal, 24)
.background(Color(.secondarySystemGroupedBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
.padding(.horizontal, 32)
liveDelta(for: source)
@@ -202,7 +202,7 @@ struct GuidedCheckInView: View {
} label: {
Text(String(localized: "guided_skip"))
.font(.subheadline.weight(.medium))
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
.accessibilityIdentifier("guided_skip_btn")
}
@@ -224,7 +224,7 @@ struct GuidedCheckInView: View {
Text("\(delta.currencyString) (\(String(format: "%+.1f%%", pct)))")
.font(.subheadline.weight(.semibold))
}
.foregroundColor(positive ? .positiveGreen : .negativeRed)
.foregroundStyle(positive ? Color.positiveGreen : Color.negativeRed)
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background((positive ? Color.positiveGreen : Color.negativeRed).opacity(0.12))
@@ -247,7 +247,7 @@ struct GuidedCheckInView: View {
.font(.title2.weight(.bold))
Text(String(localized: "guided_reflection_subtitle"))
.font(.subheadline)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
HStack(spacing: 10) {
@@ -272,8 +272,8 @@ struct GuidedCheckInView: View {
? Color.appPrimary.opacity(0.15)
: Color(.secondarySystemGroupedBackground)
)
.foregroundColor(mood == candidate ? .appPrimary : .primary)
.cornerRadius(12)
.foregroundStyle(mood == candidate ? Color.appPrimary : .primary)
.clipShape(RoundedRectangle(cornerRadius: 12))
.overlay(
RoundedRectangle(cornerRadius: 12)
.strokeBorder(mood == candidate ? Color.appPrimary : .clear, lineWidth: 1.5)
@@ -290,7 +290,7 @@ struct GuidedCheckInView: View {
.focused($noteFocused)
.padding(14)
.background(Color(.secondarySystemGroupedBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
.padding(.horizontal, 24)
Spacer()
@@ -319,7 +319,7 @@ struct GuidedCheckInView: View {
Image(systemName: "checkmark.seal.fill")
.font(.system(size: 64))
.foregroundColor(.positiveGreen)
.foregroundStyle(Color.positiveGreen)
.symbolEffect(.bounce, value: step)
Text(String(format: String(localized: "guided_done_title"), monthLabel))
@@ -335,7 +335,7 @@ struct GuidedCheckInView: View {
let pct = NSDecimalNumber(decimal: delta / totals.previous).doubleValue * 100
Text("\(delta.currencyString) (\(String(format: "%+.1f%%", pct)))")
.font(.subheadline.weight(.semibold))
.foregroundColor(delta >= 0 ? .positiveGreen : .negativeRed)
.foregroundStyle(delta >= 0 ? Color.positiveGreen : Color.negativeRed)
}
}
}
@@ -347,7 +347,7 @@ struct GuidedCheckInView: View {
Text(String(format: String(localized: "guided_streak"), streak))
}
.font(.subheadline.weight(.semibold))
.foregroundColor(.orange)
.foregroundStyle(.orange)
.padding(.horizontal, 14)
.padding(.vertical, 8)
.background(Color.orange.opacity(0.12))
@@ -21,7 +21,7 @@ struct InsightsRow: View {
HStack(spacing: 12) {
Image(systemName: insight.systemImage)
.font(.subheadline.weight(.semibold))
.foregroundColor(insight.accentColor)
.foregroundStyle(insight.accentColor)
.frame(width: 30, height: 30)
.background(insight.accentColor.opacity(0.12))
.clipShape(Circle())
@@ -29,15 +29,15 @@ struct InsightsRow: View {
VStack(alignment: .leading, spacing: 1) {
Text(insight.title)
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
Text(insight.value)
.font(.subheadline.weight(.semibold))
.foregroundColor(.primary)
.foregroundStyle(.primary)
.lineLimit(2)
if let detail = insight.detail {
Text(detail)
.font(.caption2)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
.lineLimit(1)
}
}
@@ -56,7 +56,7 @@ struct InsightsRow: View {
}
.padding(14)
.background(Color(.secondarySystemGroupedBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
}
.buttonStyle(.plain)
.id(insight.id)
@@ -116,7 +116,7 @@ struct MonthlyCheckInView: View {
} label: {
Image(systemName: "chevron.left")
.font(.subheadline.weight(.semibold))
.foregroundColor(.appPrimary)
.foregroundStyle(Color.appPrimary)
}
Text(monthLabel)
@@ -127,13 +127,13 @@ struct MonthlyCheckInView: View {
} label: {
Image(systemName: "chevron.right")
.font(.subheadline.weight(.semibold))
.foregroundColor(canGoToNextMonth ? .appPrimary : .secondary.opacity(0.3))
.foregroundStyle(canGoToNextMonth ? Color.appPrimary : .secondary.opacity(0.3))
}
.disabled(!canGoToNextMonth)
}
}
ToolbarItem(placement: .navigationBarTrailing) {
ToolbarItem(placement: .topBarTrailing) {
Button {
shareMonthlyCheckIn()
} label: {
@@ -230,11 +230,11 @@ struct MonthlyCheckInView: View {
)
)
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
} else {
Text("Start your first check-in anytime.")
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
}
}
@@ -242,10 +242,10 @@ struct MonthlyCheckInView: View {
if let completed = MonthlyCheckInStore.completionDate(for: referenceDate) {
HStack(spacing: 6) {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.positiveGreen)
.foregroundStyle(Color.positiveGreen)
Text("Completed \(completed.friendlyDescription)")
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
}
@@ -272,12 +272,12 @@ struct MonthlyCheckInView: View {
.frame(maxWidth: .infinity)
.padding(.vertical, 10)
.background(isCompleted ? Color.appSecondary.opacity(0.1) : Color.appPrimary.opacity(0.1))
.cornerRadius(AppConstants.UI.cornerRadius)
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
}
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
}
@@ -310,7 +310,7 @@ struct MonthlyCheckInView: View {
Spacer()
Text("Optional")
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
VStack(alignment: .leading, spacing: 8) {
@@ -326,7 +326,7 @@ struct MonthlyCheckInView: View {
} label: {
Image(systemName: value <= starRating ? "star.fill" : "star")
.font(.title3)
.foregroundColor(value <= starRating ? .appSecondary : .secondary)
.foregroundStyle(value <= starRating ? Color.appSecondary : .secondary)
.padding(8)
.background(
Circle()
@@ -346,7 +346,7 @@ struct MonthlyCheckInView: View {
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(Color.gray.opacity(0.12))
.cornerRadius(AppConstants.UI.smallCornerRadius)
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.smallCornerRadius))
}
}
}
@@ -373,7 +373,7 @@ struct MonthlyCheckInView: View {
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
}
@@ -386,7 +386,7 @@ struct MonthlyCheckInView: View {
VStack(alignment: .leading, spacing: 4) {
Text("Starting")
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
Text(viewModel.monthlySummary.formattedStartingValue)
.font(.subheadline.weight(.semibold))
}
@@ -394,7 +394,7 @@ struct MonthlyCheckInView: View {
VStack(alignment: .trailing, spacing: 4) {
Text("Ending")
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
Text(viewModel.monthlySummary.formattedEndingValue)
.font(.subheadline.weight(.semibold))
}
@@ -404,7 +404,7 @@ struct MonthlyCheckInView: View {
VStack(alignment: .leading, spacing: 4) {
Text("Contributions")
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
Text(viewModel.monthlySummary.formattedContributions)
.font(.subheadline.weight(.semibold))
}
@@ -412,16 +412,16 @@ struct MonthlyCheckInView: View {
VStack(alignment: .trailing, spacing: 4) {
Text("Net Performance")
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
Text("\(viewModel.monthlySummary.formattedNetPerformance) (\(viewModel.monthlySummary.formattedNetPerformancePercentage))")
.font(.subheadline.weight(.semibold))
.foregroundColor(viewModel.monthlySummary.netPerformance >= 0 ? .positiveGreen : .negativeRed)
.foregroundStyle(viewModel.monthlySummary.netPerformance >= 0 ? Color.positiveGreen : Color.negativeRed)
}
}
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
}
@@ -489,7 +489,7 @@ struct MonthlyCheckInView: View {
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
}
}
@@ -497,11 +497,11 @@ struct MonthlyCheckInView: View {
private func highlightRow(icon: String, iconColor: Color, label: String, name: String, percentage: Double, diff: Decimal, valueColor: Color) -> some View {
HStack {
Image(systemName: icon)
.foregroundColor(iconColor)
.foregroundStyle(iconColor)
VStack(alignment: .leading, spacing: 2) {
Text(label)
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
Text(name)
.font(.subheadline.weight(.semibold))
}
@@ -509,10 +509,10 @@ struct MonthlyCheckInView: View {
VStack(alignment: .trailing, spacing: 2) {
Text(String(format: "%+.1f%%", percentage))
.font(.subheadline.weight(.bold))
.foregroundColor(valueColor)
.foregroundStyle(valueColor)
Text("(\(diff.compactCurrencyString))")
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
}
}
@@ -529,18 +529,18 @@ struct MonthlyCheckInView: View {
} label: {
Label("Batch Update", systemImage: "square.and.pencil")
.font(.caption.weight(.semibold))
.foregroundColor(.appPrimary)
.foregroundStyle(Color.appPrimary)
}
}
Text("\(viewModel.sources.count)")
.font(.subheadline)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
if viewModel.sources.isEmpty {
Text("Add sources to start your monthly check-in.")
.font(.subheadline)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
} else {
ForEach(viewModel.sources, id: \.objectID) { source in
// Month-aware: "updated" means updated FOR THE VIEWED month.
@@ -575,7 +575,7 @@ struct MonthlyCheckInView: View {
.font(.subheadline.weight(.medium))
Text(updatedThisCycle ? "Updated this cycle" : "Needs update")
.font(.caption2)
.foregroundColor(updatedThisCycle ? .positiveGreen : .secondary)
.foregroundStyle(updatedThisCycle ? Color.positiveGreen : .secondary)
}
Spacer()
@@ -583,16 +583,16 @@ struct MonthlyCheckInView: View {
if let diff = valueDiff, updatedThisCycle {
Text(diff >= 0 ? "+\(diff.compactCurrencyString)" : diff.compactCurrencyString)
.font(.caption.weight(.semibold))
.foregroundColor(diff >= 0 ? .positiveGreen : .negativeRed)
.foregroundStyle(diff >= 0 ? Color.positiveGreen : Color.negativeRed)
}
Text(latestSnapshot?.date.relativeDayDescription ?? String(localized: "date_never"))
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
Image(systemName: "chevron.right")
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
}
.buttonStyle(.plain)
@@ -601,7 +601,7 @@ struct MonthlyCheckInView: View {
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
}
@@ -614,7 +614,7 @@ struct MonthlyCheckInView: View {
.frame(minHeight: 120)
.padding(8)
.background(Color.gray.opacity(0.08))
.cornerRadius(12)
.clipShape(RoundedRectangle(cornerRadius: 12))
.focused($noteFocused)
.onChange(of: monthlyNote) { _, newValue in
MonthlyCheckInStore.setNote(newValue, for: referenceDate)
@@ -629,7 +629,7 @@ struct MonthlyCheckInView: View {
.frame(maxWidth: .infinity)
.padding(.vertical, 8)
.background(Color.appSecondary.opacity(0.12))
.cornerRadius(AppConstants.UI.cornerRadius)
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
}
.buttonStyle(.plain)
@@ -642,13 +642,13 @@ struct MonthlyCheckInView: View {
.frame(maxWidth: .infinity)
.padding(.vertical, 8)
.background(Color.appPrimary.opacity(0.12))
.cornerRadius(AppConstants.UI.cornerRadius)
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
}
}
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
}
@@ -660,7 +660,7 @@ struct MonthlyCheckInView: View {
if viewModel.recentNotes.isEmpty {
Text("No snapshot notes for this month.")
.font(.subheadline)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
} else {
ForEach(viewModel.recentNotes, id: \.objectID) { snapshot in
VStack(alignment: .leading, spacing: 4) {
@@ -668,10 +668,10 @@ struct MonthlyCheckInView: View {
.font(.subheadline.weight(.semibold))
Text(snapshot.notes ?? "")
.font(.subheadline)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
Text(snapshot.date.friendlyDescription)
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
if snapshot.id != viewModel.recentNotes.last?.id {
@@ -682,7 +682,7 @@ struct MonthlyCheckInView: View {
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
}
@@ -764,11 +764,11 @@ struct AchievementsView: View {
)
)
.font(.subheadline)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
}
@@ -785,7 +785,7 @@ struct AchievementsView: View {
if let subtitle {
Text(subtitle)
.font(.subheadline)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
if achievements.isEmpty {
@@ -798,7 +798,7 @@ struct AchievementsView: View {
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
}
@@ -810,7 +810,7 @@ struct AchievementsView: View {
.frame(width: 42, height: 42)
Image(systemName: status.achievement.icon)
.font(.headline)
.foregroundColor(isLocked ? .secondary : .appSecondary)
.foregroundStyle(isLocked ? .secondary : Color.appSecondary)
}
VStack(alignment: .leading, spacing: 2) {
@@ -818,7 +818,7 @@ struct AchievementsView: View {
.font(.subheadline.weight(.semibold))
Text(status.achievement.detail)
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
Spacer()
@@ -826,12 +826,12 @@ struct AchievementsView: View {
if isLocked {
Image(systemName: "lock.fill")
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
}
.padding(10)
.background(isLocked ? Color.gray.opacity(0.08) : Color.appSecondary.opacity(0.12))
.cornerRadius(AppConstants.UI.smallCornerRadius)
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.smallCornerRadius))
}
}
@@ -865,13 +865,13 @@ private extension MonthlyCheckInView {
HStack(alignment: .center, spacing: 8) {
Image(systemName: mood.iconName)
.font(.body)
.foregroundColor(isSelected ? moodColor(for: mood) : .secondary)
.foregroundStyle(isSelected ? moodColor(for: mood) : .secondary)
VStack(alignment: .leading, spacing: 2) {
Text(mood.title)
.font(.subheadline.weight(.semibold))
Text(mood.detail)
.font(.caption2)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
}
.padding(10)
@@ -880,7 +880,7 @@ private extension MonthlyCheckInView {
RoundedRectangle(cornerRadius: AppConstants.UI.smallCornerRadius)
.stroke(isSelected ? moodColor(for: mood) : Color.clear, lineWidth: 1)
)
.cornerRadius(AppConstants.UI.smallCornerRadius)
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.smallCornerRadius))
}
func moodColor(for mood: MonthlyCheckInMood) -> Color {
@@ -940,7 +940,7 @@ struct AchievementMilestoneBar: View {
if status.isUnlocked {
Image(systemName: "checkmark")
.font(.system(size: 8, weight: .bold))
.foregroundColor(.white)
.foregroundStyle(.white)
}
}
.position(x: x, y: barY)
@@ -989,10 +989,10 @@ struct BatchUpdateView: View {
.navigationTitle("Batch Update")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
ToolbarItem(placement: .topBarLeading) {
Button("Cancel") { dismiss() }
}
ToolbarItem(placement: .navigationBarTrailing) {
ToolbarItem(placement: .topBarTrailing) {
Button("Save") { saveAll() }
.disabled(filledCount == 0)
.fontWeight(.semibold)
@@ -1030,33 +1030,33 @@ struct BatchUpdateView: View {
if let prev = previousValue {
Text(prev.currencyString)
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
}
HStack {
Text(symbol)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
TextField("Current value", text: valueBinding)
.keyboardType(.decimalPad)
}
.padding(8)
.background(Color.gray.opacity(0.08))
.cornerRadius(8)
.clipShape(RoundedRectangle(cornerRadius: 8))
if isDetailed {
HStack {
Image(systemName: "plus.circle")
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
TextField("Contribution this period (optional)", text: contributionBinding)
.keyboardType(.decimalPad)
.font(.subheadline)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
.padding(8)
.background(Color.appSecondary.opacity(0.06))
.cornerRadius(8)
.clipShape(RoundedRectangle(cornerRadius: 8))
}
}
.padding(.vertical, 2)
@@ -21,7 +21,7 @@ struct MonthlySummaryShareView: View {
HStack {
Text(monthLabel)
.font(.caption.weight(.semibold))
.foregroundColor(.white.opacity(0.85))
.foregroundStyle(.white.opacity(0.85))
.textCase(.uppercase)
Spacer()
if streak >= 2 {
@@ -31,7 +31,7 @@ struct MonthlySummaryShareView: View {
Text(String(format: String(localized: "share_summary_streak_badge"), streak))
.font(.caption2.weight(.bold))
}
.foregroundColor(.white)
.foregroundStyle(.white)
.padding(.horizontal, 10)
.padding(.vertical, 5)
.background(Color.white.opacity(0.18))
@@ -41,17 +41,17 @@ struct MonthlySummaryShareView: View {
Text("Monthly Summary")
.font(.title.weight(.bold))
.foregroundColor(.white)
.foregroundStyle(.white)
.padding(.top, 6)
// Hero value
VStack(alignment: .leading, spacing: 6) {
Text("Portfolio Value")
.font(.subheadline.weight(.semibold))
.foregroundColor(.white.opacity(0.85))
.foregroundStyle(.white.opacity(0.85))
Text(totalValue)
.font(.system(size: 40, weight: .bold, design: .rounded))
.foregroundColor(.white)
.foregroundStyle(.white)
.minimumScaleFactor(0.6)
.lineLimit(1)
}
@@ -66,9 +66,9 @@ struct MonthlySummaryShareView: View {
Spacer()
Text("since last check-in")
.font(.caption)
.foregroundColor(.white.opacity(0.75))
.foregroundStyle(.white.opacity(0.75))
}
.foregroundColor(.white)
.foregroundStyle(.white)
.padding(.horizontal, 14)
.padding(.vertical, 10)
.background(Color.white.opacity(0.15))
@@ -85,14 +85,14 @@ struct MonthlySummaryShareView: View {
Text(mood.title)
.font(.subheadline.weight(.semibold))
}
.foregroundColor(.white)
.foregroundStyle(.white)
}
if let rating {
HStack(spacing: 2) {
ForEach(1...5, id: \.self) { i in
Image(systemName: i <= rating ? "star.fill" : "star")
.font(.caption)
.foregroundColor(.white.opacity(i <= rating ? 1 : 0.4))
.foregroundStyle(.white.opacity(i <= rating ? 1 : 0.4))
}
}
}
@@ -114,10 +114,10 @@ struct MonthlySummaryShareView: View {
VStack(alignment: .leading, spacing: 2) {
Text("Tracked with")
.font(.caption2.weight(.medium))
.foregroundColor(.white.opacity(0.7))
.foregroundStyle(.white.opacity(0.7))
Text(appName)
.font(.subheadline.weight(.bold))
.foregroundColor(.white)
.foregroundStyle(.white)
}
Spacer()
@@ -91,7 +91,7 @@ struct QuickUpdateView: View {
Image(systemName: "arrow.down.circle.fill")
}
.font(.subheadline.weight(.semibold))
.foregroundColor(.white)
.foregroundStyle(.white)
.padding(.vertical, 2)
}
.listRowBackground(Color.appPrimary)
@@ -243,14 +243,14 @@ struct QuickUpdateView: View {
.padding(.horizontal, 6)
.padding(.vertical, 1)
.background(Color.appSecondary.opacity(0.15))
.foregroundColor(.appSecondary)
.foregroundStyle(Color.appSecondary)
.clipShape(Capsule())
}
}
if source.latestValue != .zero {
Text(source.latestValue.currencyString)
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
}
Spacer()
@@ -269,7 +269,7 @@ struct QuickUpdateView: View {
HStack {
Text(String(localized: "quick_update_contribution_label"))
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
Spacer()
TextField(
String(localized: "quick_update_contribution_placeholder"),
@@ -279,7 +279,7 @@ struct QuickUpdateView: View {
.multilineTextAlignment(.trailing)
.frame(width: 120)
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
}
}
@@ -377,6 +377,9 @@ struct QuickUpdateView: View {
}
// Keep the share-extension mirror in sync with what was just saved
SharedQuickUpdateSync.refreshMirror()
// Direct context.save() bypasses SnapshotRepository, so refresh the
// widget explicitly otherwise it stays stale until the next app open.
CoreDataStack.shared.refreshWidgetData()
// Positive moment: check-in saved. Ask for a rating once the sheet
// is gone (gated to 2 months of history, once per version).
if ReviewRequestService.shouldRequestAfterCheckIn() {
@@ -157,7 +157,7 @@ private func eyebrow(_ text: String) -> some View {
Text(text.uppercased())
.font(.caption2.weight(.bold))
.tracking(2.2)
.foregroundColor(ShareCardStyle.green)
.foregroundStyle(ShareCardStyle.green)
}
private func deltaChip(_ text: String, positive: Bool, large: Bool = false) -> some View {
@@ -167,7 +167,7 @@ private func deltaChip(_ text: String, positive: Bool, large: Bool = false) -> s
Text(text)
.font((large ? Font.headline : Font.caption).weight(.bold))
}
.foregroundColor(positive ? ShareCardStyle.green : Color(red: 1, green: 0.42, blue: 0.42))
.foregroundStyle(positive ? ShareCardStyle.green : Color(red: 1, green: 0.42, blue: 0.42))
.padding(.horizontal, large ? 12 : 9)
.padding(.vertical, large ? 6 : 4)
.background(
@@ -179,11 +179,11 @@ private func metricRow(_ title: String, _ value: String, theme: ShareCardTheme)
HStack {
Text(title)
.font(.caption.weight(.semibold))
.foregroundColor(theme.mutedInk)
.foregroundStyle(theme.mutedInk)
Spacer()
Text(value)
.font(.subheadline.weight(.semibold))
.foregroundColor(theme.ink)
.foregroundStyle(theme.ink)
.multilineTextAlignment(.trailing)
.minimumScaleFactor(0.7)
.lineLimit(1)
@@ -203,13 +203,13 @@ private func brandHeader(theme: ShareCardTheme, asOf: String?) -> some View {
VStack(alignment: .leading, spacing: 1) {
Text("Portfolio Journal")
.font(.system(.title2, design: .rounded).weight(.heavy))
.foregroundColor(theme.ink)
.foregroundStyle(theme.ink)
.minimumScaleFactor(0.7)
.lineLimit(1)
if let asOf {
Text(String(format: String(localized: "share_as_of"), asOf))
.font(.caption2.weight(.medium))
.foregroundColor(theme.mutedInk)
.foregroundStyle(theme.mutedInk)
}
}
Spacer(minLength: 0)
@@ -223,11 +223,11 @@ private func downloadFooter(qrCodeImage: UIImage?, theme: ShareCardTheme) -> som
VStack(alignment: .leading, spacing: 3) {
Text(String(localized: "share_cta_headline"))
.font(.subheadline.weight(.bold))
.foregroundColor(theme.ink)
.foregroundStyle(theme.ink)
.fixedSize(horizontal: false, vertical: true)
Text(String(localized: "share_cta_sub"))
.font(.caption2.weight(.medium))
.foregroundColor(theme.mutedInk)
.foregroundStyle(theme.mutedInk)
}
Spacer(minLength: 8)
@@ -295,13 +295,13 @@ struct PortfolioValueShareCardView: View {
eyebrow(String(localized: "share_hero_eyebrow"))
Text(heroPercent ?? totalValue)
.font(.system(size: storyFormat ? 52 : 44, weight: .heavy, design: .rounded))
.foregroundColor(heroIsPercent ? (isPositive ? ShareCardStyle.green : Color(red: 1, green: 0.42, blue: 0.42)) : theme.ink)
.foregroundStyle(heroIsPercent ? (isPositive ? ShareCardStyle.green : Color(red: 1, green: 0.42, blue: 0.42)) : theme.ink)
.minimumScaleFactor(0.5)
.lineLimit(1)
if heroIsPercent && !totalValue.isEmpty {
Text(totalValue)
.font(.title3.weight(.bold))
.foregroundColor(theme.ink)
.foregroundStyle(theme.ink)
.minimumScaleFactor(0.5)
.lineLimit(1)
}
@@ -363,7 +363,7 @@ struct MonthlyCheckInShareCardView: View {
eyebrow(summary.formattedMonthYear)
Text("Monthly Check-in")
.font(.title.weight(.bold))
.foregroundColor(theme.ink)
.foregroundStyle(theme.ink)
.padding(.top, 4)
VStack(spacing: 9) {
@@ -472,8 +472,8 @@ struct PortfolioShareOptionsView: View {
.frame(maxWidth: .infinity)
.padding(.vertical, 14)
.background(Color.appPrimary)
.foregroundColor(.white)
.cornerRadius(14)
.foregroundStyle(.white)
.clipShape(RoundedRectangle(cornerRadius: 14))
}
.padding(.horizontal)
.padding(.bottom, 8)
@@ -7,6 +7,9 @@ struct GoalEditorView: View {
@State private var targetDate = Date()
@State private var includeTargetDate = false
@State private var didLoadGoal = false
@State private var selectedCategoryId: UUID?
@StateObject private var categoryRepository = CategoryRepository()
let account: Account?
let goal: Goal?
@@ -38,7 +41,7 @@ struct GoalEditorView: View {
TextField("Goal name", text: $name)
HStack {
Text(currencySymbol)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
TextField("Target amount", text: $targetAmount)
.keyboardType(.decimalPad)
}
@@ -51,13 +54,29 @@ struct GoalEditorView: View {
} header: {
Text("Goal Details")
}
Section {
Picker(String(localized: "goal_scope_category"), selection: $selectedCategoryId) {
Text("goal_scope_all_categories").tag(UUID?.none)
ForEach(categoryRepository.categories) { category in
Label(category.name, systemImage: category.icon)
.tag(UUID?.some(category.id))
}
}
} header: {
Text("goal_scope_section")
} footer: {
Text(selectedCategoryId == nil
? String(localized: "goal_scope_footer_all")
: String(localized: "goal_scope_footer_category"))
}
}
.navigationTitle(goal == nil ? "New Goal" : "Edit Goal")
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
ToolbarItem(placement: .topBarLeading) {
Button("Cancel") { dismiss() }
}
ToolbarItem(placement: .navigationBarTrailing) {
ToolbarItem(placement: .topBarTrailing) {
Button("Save") { saveGoal() }
.disabled(!isValid)
}
@@ -67,7 +86,7 @@ struct GoalEditorView: View {
.presentationDragIndicator(.visible)
.onAppear {
guard let goal, !didLoadGoal else { return }
name = goal.name ?? ""
name = goal.name
if let amount = goal.targetAmount?.decimalValue {
targetAmount = formatDecimalForInput(amount)
}
@@ -75,6 +94,7 @@ struct GoalEditorView: View {
includeTargetDate = true
targetDate = target
}
selectedCategoryId = goal.category?.safeId
didLoadGoal = true
}
}
@@ -83,22 +103,31 @@ struct GoalEditorView: View {
!name.trimmingCharacters(in: .whitespaces).isEmpty && parseDecimal(targetAmount) != nil
}
private var selectedCategory: Category? {
guard let selectedCategoryId else { return nil }
return categoryRepository.categories.first { $0.safeId == selectedCategoryId }
}
private func saveGoal() {
guard let value = parseDecimal(targetAmount) else { return }
let category = selectedCategory
if let goal {
goalRepository.updateGoal(
goal,
name: name,
targetAmount: value,
targetDate: includeTargetDate ? targetDate : nil,
clearTargetDate: !includeTargetDate
clearTargetDate: !includeTargetDate,
category: category,
clearCategory: category == nil
)
} else {
goalRepository.createGoal(
name: name,
targetAmount: value,
targetDate: includeTargetDate ? targetDate : nil,
account: account
account: account,
category: category
)
}
dismiss()
@@ -26,7 +26,7 @@ struct GoalProgressBar: View {
.overlay(alignment: .leading) {
Image(systemName: iconName)
.font(.caption2)
.foregroundColor(iconColor)
.foregroundStyle(iconColor)
.offset(x: iconOffset)
}
}
@@ -34,10 +34,10 @@ struct GoalShareCardView: View {
VStack(alignment: .leading, spacing: 4) {
Text("Goal Progress")
.font(.caption.weight(.semibold))
.foregroundColor(.white.opacity(0.8))
.foregroundStyle(.white.opacity(0.8))
Text(name)
.font(.title2.weight(.bold))
.foregroundColor(.white)
.foregroundStyle(.white)
}
GoalProgressBar(
@@ -51,27 +51,27 @@ struct GoalShareCardView: View {
Text("\(progressText) complete")
.font(.caption.weight(.semibold))
.foregroundColor(.white.opacity(0.85))
.foregroundStyle(.white.opacity(0.85))
if privacyMode {
HStack {
Text("Progress")
.font(.subheadline.weight(.semibold))
.foregroundColor(.white.opacity(0.9))
.foregroundStyle(.white.opacity(0.9))
Spacer()
Text(progressText)
.font(.headline.weight(.bold))
.foregroundColor(.white)
.foregroundStyle(.white)
}
} else {
HStack {
Text(displayCurrentValue)
.font(.headline)
.foregroundColor(.white)
.foregroundStyle(.white)
Spacer()
Text("of \(targetValue.currencyString)")
.font(.subheadline.weight(.medium))
.foregroundColor(.white.opacity(0.8))
.foregroundStyle(.white.opacity(0.8))
}
}
@@ -82,7 +82,7 @@ struct GoalShareCardView: View {
Text("Target: \(targetDate.mediumDateString)")
.font(.caption.weight(.medium))
}
.foregroundColor(.white.opacity(0.85))
.foregroundStyle(.white.opacity(0.85))
}
if let estimatedCompletionDate {
@@ -92,7 +92,7 @@ struct GoalShareCardView: View {
Text("Est. completion: \(estimatedCompletionDate.mediumDateString)")
.font(.caption.weight(.medium))
}
.foregroundColor(.white.opacity(0.85))
.foregroundStyle(.white.opacity(0.85))
}
if privacyMode {
@@ -101,7 +101,7 @@ struct GoalShareCardView: View {
Text("Privacy mode enabled")
}
.font(.caption.weight(.semibold))
.foregroundColor(.white.opacity(0.7))
.foregroundStyle(.white.opacity(0.7))
}
Spacer(minLength: 0)
@@ -124,10 +124,10 @@ struct GoalShareCardView: View {
VStack(alignment: .leading, spacing: 2) {
Text("Powered by")
.font(.caption2.weight(.medium))
.foregroundColor(.white.opacity(0.7))
.foregroundStyle(.white.opacity(0.7))
Text("Portfolio Journal")
.font(.subheadline.weight(.bold))
.foregroundColor(.white)
.foregroundStyle(.white)
}
Spacer()
@@ -144,7 +144,7 @@ struct GoalShareCardView: View {
Text("Scan to download")
.font(.system(size: 7, weight: .medium))
.foregroundColor(.white.opacity(0.8))
.foregroundStyle(.white.opacity(0.8))
}
} else {
// Fallback if QR code generation fails
@@ -154,7 +154,7 @@ struct GoalShareCardView: View {
Text("App Store")
.font(.caption2.weight(.semibold))
}
.foregroundColor(.white.opacity(0.9))
.foregroundStyle(.white.opacity(0.9))
}
}
}
+35 -21
View File
@@ -72,7 +72,7 @@ struct GoalsView: View {
}
.navigationTitle("Goals")
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
ToolbarItem(placement: .topBarLeading) {
Picker(String(localized: "goals_filter_active"), selection: $goalFilter) {
ForEach(GoalFilter.allCases) { filter in
Text(filter.label).tag(filter)
@@ -80,7 +80,7 @@ struct GoalsView: View {
}
.pickerStyle(.menu)
}
ToolbarItem(placement: .navigationBarTrailing) {
ToolbarItem(placement: .topBarTrailing) {
Button {
showingAddGoal = true
} label: {
@@ -145,19 +145,19 @@ struct GoalsView: View {
VStack(spacing: 16) {
Image(systemName: "target")
.font(.system(size: 48))
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
Text("Set your first goal")
.font(.headline)
Text("Track progress toward milestones like \(AppSettings.getOrCreate(in: CoreDataStack.shared.viewContext).currencySymbol)1M and share your wins.")
.font(.subheadline)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
Button {
showingAddGoal = true
} label: {
Text(String(localized: "goals_empty_add_cta"))
.font(.subheadline.weight(.semibold))
.foregroundColor(.white)
.foregroundStyle(.white)
.padding(.horizontal, 20)
.padding(.vertical, 10)
.background(Color.appPrimary)
@@ -170,13 +170,13 @@ struct GoalsView: View {
VStack(spacing: 12) {
Image(systemName: "archivebox")
.font(.system(size: 40))
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
Text(String(localized: "goals_all_active_achieved"))
.font(.headline)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
Text("Switch to \"Archived\" or \"All\" to see other goals.")
.font(.subheadline)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity)
@@ -186,10 +186,10 @@ struct GoalsView: View {
VStack(spacing: 12) {
Image(systemName: "archivebox")
.font(.system(size: 40))
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
Text(String(localized: "goals_empty_archived"))
.font(.headline)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 32)
@@ -197,12 +197,12 @@ struct GoalsView: View {
VStack(spacing: 16) {
Image(systemName: "target")
.font(.system(size: 48))
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
Text("Set your first goal")
.font(.headline)
Text("Track progress toward milestones like \(AppSettings.getOrCreate(in: CoreDataStack.shared.viewContext).currencySymbol)1M and share your wins.")
.font(.subheadline)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity)
@@ -265,7 +265,7 @@ struct GoalRowView: View {
if isAchieved {
Text("Achieved")
.font(.caption2.weight(.bold))
.foregroundColor(.white)
.foregroundStyle(.white)
.padding(.horizontal, 8)
.padding(.vertical, 3)
.background(Color.appSuccess)
@@ -273,27 +273,41 @@ struct GoalRowView: View {
}
}
if let category = goal.category {
HStack(spacing: 4) {
Image(systemName: category.icon)
.font(.caption2)
Text(category.name)
.font(.caption2.weight(.semibold))
}
.foregroundStyle(category.color)
.padding(.horizontal, 8)
.padding(.vertical, 3)
.background(category.color.opacity(0.15))
.clipShape(Capsule())
}
HStack(spacing: 4) {
Text(totalValue.currencyString)
.font(.subheadline.weight(.semibold))
.hiddenBalance()
Text("of \(goal.targetDecimal.currencyString)")
.font(.subheadline)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
.hiddenBalance()
}
if let targetDate = goal.targetDate {
Text("Target date: \(targetDate.mediumDateString)")
.font(.caption)
.foregroundColor(targetDateColor)
.foregroundStyle(targetDateColor)
}
if let paceStatus {
Text(paceStatus.statusText)
.font(.caption.weight(.semibold))
.foregroundColor(
isAchieved ? .appSuccess : (paceStatus.isBehind ? .appWarning : .positiveGreen)
.foregroundStyle(
isAchieved ? Color.appSuccess : (paceStatus.isBehind ? Color.appWarning : Color.positiveGreen)
)
}
@@ -312,7 +326,7 @@ struct GoalRowView: View {
showingShareOptions = true
} label: {
Image(systemName: "square.and.arrow.up")
.foregroundColor(.appPrimary)
.foregroundStyle(Color.appPrimary)
.padding(.top, 2)
}
.buttonStyle(.borderless)
@@ -352,13 +366,13 @@ struct GoalRowView: View {
case .unreachable:
Label(String(localized: "goal_projection_add_contributions"), systemImage: "plus.circle")
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
default:
HStack(spacing: 8) {
if let date = projection.projectedDate {
Text(String(format: String(localized: "goal_projection_projected"), date.monthYearString))
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
onTrackChip
Spacer(minLength: 0)
@@ -395,7 +409,7 @@ struct GoalRowView: View {
if projection.status != .unreachable {
Text(chipText)
.font(.caption2.weight(.semibold))
.foregroundColor(chipColor)
.foregroundStyle(chipColor)
.padding(.horizontal, 8)
.padding(.vertical, 3)
.background(chipColor.opacity(0.15))
@@ -10,64 +10,45 @@ struct JournalView: View {
@State private var selectedDate: Date?
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
/// Month list + inline check-in: two columns in regular width (iPad, iPhone
/// Duo inner screen), a collapsing stack in compact width. The selected
/// month is the only navigation state, so it survives the Duo opening/closing.
@State private var columnVisibility: NavigationSplitViewVisibility = .all
var body: some View {
if horizontalSizeClass == .regular {
iPadJournalLayout
} else {
iPhoneJournalLayout
}
}
// MARK: - iPad Layout (list + inline detail)
private var iPadJournalLayout: some View {
HStack(spacing: 0) {
// Left: month list
NavigationStack {
journalListContent(isPad: true)
.navigationTitle("Journal")
.searchable(text: $searchText, prompt: "Search monthly notes")
.onAppear { viewModel.refresh() }
}
.frame(width: 320)
Divider()
// Right: monthly check-in detail
NavigationSplitView(columnVisibility: $columnVisibility) {
journalListContent
.navigationTitle("Journal")
.searchable(text: $searchText, prompt: "Search monthly notes")
.onAppear { viewModel.refresh() }
.navigationSplitViewColumnWidth(min: 280, ideal: 300, max: 420)
} detail: {
NavigationStack {
if let date = selectedDate {
MonthlyCheckInView(referenceDate: date)
.id(date)
} else {
noMonthSelectedView
}
}
}
}
// MARK: - iPhone Layout (push navigation)
private var iPhoneJournalLayout: some View {
NavigationStack {
journalListContent(isPad: false)
.navigationTitle("Journal")
.searchable(text: $searchText, prompt: "Search monthly notes")
.onAppear { viewModel.refresh() }
}
.navigationSplitViewStyle(.balanced)
}
// MARK: - Shared List Content
private func journalListContent(isPad: Bool) -> some View {
private var journalListContent: some View {
ScrollViewReader { proxy in
ZStack {
AppBackground()
List {
List(selection: $selectedDate) {
// Calm 2.0: the habit story (streaks, achievements) lives in
// the Journal this is the emotional home of the ritual.
if !viewModel.monthlyNotes.isEmpty && searchText.isEmpty {
Section {
MomentumStreaksCard(compact: isPad)
// Narrow sidebar column in regular width compact stat tiles.
MomentumStreaksCard(compact: horizontalSizeClass == .regular)
}
.listRowInsets(EdgeInsets())
.listRowBackground(Color.clear)
@@ -80,12 +61,12 @@ struct JournalView: View {
VStack(spacing: 12) {
Image(systemName: "book.closed")
.font(.system(size: 36))
.foregroundColor(.appSecondary)
.foregroundStyle(Color.appSecondary)
Text(String(localized: "journal_empty_title"))
.font(.headline)
Text(String(localized: "journal_empty_body"))
.font(.subheadline)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
.padding(.vertical, 16)
@@ -93,34 +74,16 @@ struct JournalView: View {
} else {
Text("No matching notes.")
.font(.subheadline)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
} else {
ForEach(filteredMonthlyNotes) { entry in
if isPad {
Button {
selectedDate = entry.date
} label: {
monthlyNoteRow(entry)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.listRowBackground(
selectedDate.map { $0.isSameMonth(as: entry.date) } == true
? Color.appPrimary.opacity(0.08)
: Color.clear
)
.id(entry.date)
// ForEach identity (MonthlyNoteItem.id == date) already serves
// ScrollViewReader; an extra .id() would wrap the .tag trait
// and break List selection.
monthlyNoteRow(entry)
.tag(entry.date)
.onAppear { currentVisibleMonth = entry.date }
} else {
NavigationLink {
MonthlyCheckInView(referenceDate: entry.date)
} label: {
monthlyNoteRow(entry)
}
.id(entry.date)
.onAppear { currentVisibleMonth = entry.date }
}
}
}
}
@@ -156,7 +119,7 @@ struct JournalView: View {
Image(systemName: "book.closed.fill")
.font(.system(size: 50, weight: .light))
.foregroundColor(.white)
.foregroundStyle(.white)
}
VStack(spacing: 10) {
@@ -164,14 +127,15 @@ struct JournalView: View {
.font(.title2.weight(.semibold))
Text("Choose a month from the list on the\nleft to view your check-in notes.")
.frame(maxWidth: 360)
.font(.subheadline)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
Label("Select from the list", systemImage: "arrow.left")
.font(.subheadline.weight(.semibold))
.foregroundColor(.appSecondary)
.foregroundStyle(Color.appSecondary)
.padding(.horizontal, 22)
.padding(.vertical, 11)
.background(Color.appSecondary.opacity(0.1))
@@ -203,7 +167,7 @@ struct JournalView: View {
.frame(width: 40, height: 40)
Image(systemName: entry.mood?.iconName ?? "book.closed")
.font(.system(size: 16))
.foregroundColor(entry.mood?.tint ?? .secondary)
.foregroundStyle(entry.mood?.tint ?? .secondary)
}
VStack(alignment: .leading, spacing: 4) {
@@ -220,12 +184,12 @@ struct JournalView: View {
if let mood = entry.mood {
Text(mood.title)
.font(.caption.weight(.medium))
.foregroundColor(mood.tint)
.foregroundStyle(mood.tint)
}
Text(entry.note.isEmpty ? String(localized: "journal_no_note") : entry.note)
.font(.subheadline)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
.lineLimit(2)
}
}
@@ -237,7 +201,7 @@ struct JournalView: View {
return HStack(spacing: 4) {
ForEach(1...5, id: \.self) { index in
Image(systemName: index <= value ? "star.fill" : "star")
.foregroundColor(index <= value ? .yellow : .secondary)
.foregroundStyle(index <= value ? .yellow : .secondary)
}
}
.accessibilityLabel(
@@ -251,7 +215,7 @@ struct JournalView: View {
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(Color.gray.opacity(0.15))
.foregroundColor(.primary)
.foregroundStyle(.primary)
.clipShape(Capsule())
}
@@ -269,9 +233,7 @@ struct JournalView: View {
.font(.caption.weight(.semibold))
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(Color(.systemBackground).opacity(0.95))
.cornerRadius(12)
.shadow(color: .black.opacity(0.12), radius: 6, y: 2)
.floatingPillBackground()
.offset(x: -16)
}
@@ -280,9 +242,7 @@ struct JournalView: View {
.font(.caption.weight(.semibold))
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(Color(.systemBackground).opacity(0.95))
.cornerRadius(12)
.shadow(color: .black.opacity(0.12), radius: 6, y: 2)
.floatingPillBackground()
.offset(x: -16, y: scrubberOffset - height / 2)
}
@@ -339,13 +299,13 @@ struct MonthlyNoteEditorView: View {
.frame(minHeight: 200)
.padding(8)
.background(Color.gray.opacity(0.08))
.cornerRadius(12)
.clipShape(RoundedRectangle(cornerRadius: 12))
}
.padding()
.navigationTitle("Monthly Note")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
ToolbarItem(placement: .topBarTrailing) {
Button("Save") {
MonthlyCheckInStore.setNote(note, for: date)
dismiss()
@@ -33,7 +33,7 @@ struct OnboardingICloudCheckView: View {
.frame(width: 100, height: 100)
Image(systemName: "icloud.fill")
.font(.system(size: 50))
.foregroundColor(.appPrimary)
.foregroundStyle(Color.appPrimary)
}
VStack(spacing: 14) {
@@ -43,7 +43,7 @@ struct OnboardingICloudCheckView: View {
Text(String(localized: "icloud_check_description"))
.font(.body)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal, 40)
}
@@ -59,11 +59,11 @@ struct OnboardingICloudCheckView: View {
} label: {
Label("Restore from iCloud", systemImage: "icloud.and.arrow.down")
.font(.headline)
.foregroundColor(.white)
.foregroundStyle(.white)
.frame(maxWidth: .infinity)
.padding()
.background(Color.appPrimary)
.cornerRadius(AppConstants.UI.cornerRadius)
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
}
Button {
@@ -71,7 +71,7 @@ struct OnboardingICloudCheckView: View {
} label: {
Text("Start Fresh")
.font(.subheadline)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
}
.padding(.horizontal, 24)
@@ -96,7 +96,7 @@ struct OnboardingICloudCheckView: View {
.frame(width: 100, height: 100)
Image(systemName: "checkmark.icloud.fill")
.font(.system(size: 50))
.foregroundColor(.positiveGreen)
.foregroundStyle(Color.positiveGreen)
}
VStack(spacing: 14) {
@@ -106,7 +106,7 @@ struct OnboardingICloudCheckView: View {
Text(String(localized: "icloud_enabled_description"))
.font(.body)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal, 40)
}
@@ -123,11 +123,11 @@ struct OnboardingICloudCheckView: View {
} label: {
Text("Got it")
.font(.headline)
.foregroundColor(.white)
.foregroundStyle(.white)
.frame(maxWidth: .infinity)
.padding()
.background(Color.positiveGreen)
.cornerRadius(AppConstants.UI.cornerRadius)
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
}
.padding(.horizontal, 24)
.padding(.bottom, 40)
@@ -88,20 +88,19 @@ struct OnboardingView: View {
} label: {
Text("Continue")
.font(.headline)
.foregroundColor(.white)
.foregroundStyle(.white)
.frame(maxWidth: .infinity)
.padding()
.background(Color.appPrimary)
.cornerRadius(AppConstants.UI.cornerRadius)
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
}
Button {
FirebaseService.shared.logOnboardingSkipped(atStep: currentPage)
completeOnboarding()
} label: {
Text("Skip")
.font(.subheadline)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
} else {
Button {
@@ -109,11 +108,11 @@ struct OnboardingView: View {
} label: {
Text("Get Started")
.font(.headline)
.foregroundColor(.white)
.foregroundStyle(.white)
.frame(maxWidth: .infinity)
.padding()
.background(Color.appPrimary)
.cornerRadius(AppConstants.UI.cornerRadius)
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
}
}
}
@@ -124,9 +123,6 @@ struct OnboardingView: View {
.onAppear {
selectedCurrency = AppSettings.getOrCreate(in: CoreDataStack.shared.viewContext).currency
}
.onChange(of: currentPage) { _, newPage in
FirebaseService.shared.logOnboardingStep(step: newPage)
}
.sheet(isPresented: $showingImportSheet) {
ImportDataView(importContext: .onboarding)
.environmentObject(iapService)
@@ -144,7 +140,7 @@ struct OnboardingView: View {
categoryRepository.createDefaultCategoriesIfNeeded()
// Create default account if needed
AccountRepository().createDefaultAccountIfNeeded()
_ = AccountRepository().createDefaultAccountIfNeeded()
// Mark onboarding as complete
let context = CoreDataStack.shared.viewContext
@@ -157,9 +153,6 @@ struct OnboardingView: View {
SampleDataService.shared.seedSampleData()
}
// Log analytics
FirebaseService.shared.logOnboardingCompleted(stepCount: pages.count + 1)
// Request notification permission
Task {
await NotificationService.shared.requestAuthorization()
@@ -201,7 +194,7 @@ struct OnboardingPageView: View {
Image(systemName: page.icon)
.font(.system(size: 50))
.foregroundColor(page.color)
.foregroundStyle(page.color)
}
// Content
@@ -212,7 +205,7 @@ struct OnboardingPageView: View {
Text(page.description)
.font(.body)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal, 40)
}
@@ -255,7 +248,7 @@ struct OnboardingQuickStartView: View {
Text(String(localized: "onboarding_quickstart_subtitle"))
.font(.subheadline)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal, 30)
}
@@ -268,8 +261,8 @@ struct OnboardingQuickStartView: View {
.frame(maxWidth: .infinity)
.padding()
.background(Color.appPrimary)
.foregroundColor(.white)
.cornerRadius(AppConstants.UI.cornerRadius)
.foregroundStyle(.white)
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
}
Button(action: onImport) {
@@ -278,8 +271,8 @@ struct OnboardingQuickStartView: View {
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
.background(Color.appPrimary.opacity(0.1))
.foregroundColor(.appPrimary)
.cornerRadius(AppConstants.UI.cornerRadius)
.foregroundStyle(Color.appPrimary)
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
}
}
@@ -297,13 +290,13 @@ struct OnboardingQuickStartView: View {
}
.padding()
.background(Color.gray.opacity(0.1))
.cornerRadius(12)
.clipShape(RoundedRectangle(cornerRadius: 12))
Toggle("Sync with iCloud (optional)", isOn: $cloudSyncEnabled)
.padding()
.background(Color.gray.opacity(0.1))
.cornerRadius(12)
.clipShape(RoundedRectangle(cornerRadius: 12))
}
Spacer().frame(height: 8)
@@ -326,7 +319,7 @@ struct WelcomeView: View {
Image(systemName: "hand.wave.fill")
.font(.system(size: 60))
.foregroundColor(.appPrimary)
.foregroundStyle(Color.appPrimary)
VStack(spacing: 8) {
if let name = userName {
@@ -341,7 +334,7 @@ struct WelcomeView: View {
? "Your investment data has been synced from iCloud."
: "Your investment data stays on this device.")
.font(.subheadline)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
@@ -354,11 +347,11 @@ struct WelcomeView: View {
} label: {
Text("Continue")
.font(.headline)
.foregroundColor(.white)
.foregroundStyle(.white)
.frame(maxWidth: .infinity)
.padding()
.background(Color.appPrimary)
.cornerRadius(AppConstants.UI.cornerRadius)
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
}
.padding(.horizontal, 24)
.padding(.bottom, 40)
@@ -22,6 +22,29 @@ struct WhatsNewRelease: Identifiable {
enum WhatsNewCatalog {
static var releases: [WhatsNewRelease] {
[
WhatsNewRelease(version: "1.7.0", features: [
WhatsNewFeature(
icon: "iphone.gen3.and.iphone.gen3",
color: .appPrimary,
title: String(localized: "whats_new_170_duo_title"),
description: String(localized: "whats_new_170_duo_body")
),
WhatsNewFeature(
icon: "rectangle.trailinghalf.inset.filled",
color: .appSecondary,
title: String(localized: "whats_new_170_inspector_title"),
description: String(localized: "whats_new_170_inspector_body")
),
WhatsNewFeature(
// The feature is "we use whatever biometry you have", so the
// icon comes from the hardware too touchid on an iPhone Duo,
// faceid on a Face ID phone instead of being hardcoded.
icon: AppLockService.biometryKind.systemImage,
color: .positiveGreen,
title: String(localized: "whats_new_170_biometrics_title"),
description: String(localized: "whats_new_170_biometrics_body")
)
]),
WhatsNewRelease(version: "1.5.0", features: [
WhatsNewFeature(
icon: "sparkles",
@@ -149,7 +172,7 @@ struct WhatsNewView: View {
.font(.title.weight(.bold))
Text(String(localized: "whats_new_subtitle"))
.font(.subheadline)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
.padding(.top, 32)
@@ -162,7 +185,7 @@ struct WhatsNewView: View {
if showsVersionHeaders {
Text(String(format: String(localized: "whats_new_version_header"), release.version))
.font(.caption.weight(.bold))
.foregroundColor(.appPrimary)
.foregroundStyle(Color.appPrimary)
.padding(.horizontal, 10)
.padding(.vertical, 4)
.background(Color.appPrimary.opacity(0.12))
@@ -172,14 +195,14 @@ struct WhatsNewView: View {
HStack(spacing: 16) {
Image(systemName: feature.icon)
.font(.title2)
.foregroundColor(feature.color)
.foregroundStyle(feature.color)
.frame(width: 40)
VStack(alignment: .leading, spacing: 2) {
Text(feature.title)
.font(.subheadline.weight(.semibold))
Text(feature.description)
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
Spacer()
}
@@ -188,7 +211,7 @@ struct WhatsNewView: View {
.padding()
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color(.secondarySystemGroupedBackground))
.cornerRadius(16)
.clipShape(RoundedRectangle(cornerRadius: 16))
}
}
.padding()
@@ -199,11 +222,11 @@ struct WhatsNewView: View {
} label: {
Text(String(localized: "whats_new_continue"))
.font(.headline)
.foregroundColor(.white)
.foregroundStyle(.white)
.frame(maxWidth: .infinity)
.padding()
.background(Color.appPrimary)
.cornerRadius(14)
.clipShape(RoundedRectangle(cornerRadius: 14))
}
.padding()
}
@@ -16,11 +16,11 @@ private struct PremiumChartPreview: View {
HStack(alignment: .firstTextBaseline) {
Text("€24,750")
.font(.system(size: 18, weight: .bold, design: .rounded))
.foregroundColor(.primary)
.foregroundStyle(.primary)
Spacer()
Text("+34.2%")
.font(.caption.weight(.semibold))
.foregroundColor(.positiveGreen)
.foregroundStyle(Color.positiveGreen)
.padding(.horizontal, 7)
.padding(.vertical, 3)
.background(Color.positiveGreen.opacity(0.12))
@@ -141,7 +141,7 @@ struct PaywallView: View {
} label: {
Image(systemName: "xmark")
.font(.system(size: 14, weight: .semibold))
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
.padding(10)
.background(Color(.secondarySystemBackground))
.clipShape(Circle())
@@ -174,7 +174,7 @@ struct PaywallView: View {
Text("One payment. Every feature. Forever.")
.font(.subheadline)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
}
@@ -189,7 +189,7 @@ struct PaywallView: View {
}
.padding(20)
.background(Color(.systemBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
}
@@ -199,11 +199,11 @@ struct PaywallView: View {
HStack(spacing: 4) {
Text(iapService.formattedPrice)
.font(.system(size: 28, weight: .bold, design: .rounded))
.foregroundColor(.appPrimary)
.foregroundStyle(Color.appPrimary)
Text("· one-time · Family Sharing")
.font(.footnote)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
}
@@ -242,24 +242,24 @@ struct PaywallView: View {
HStack(spacing: 6) {
Text(title)
.font(.subheadline.weight(.semibold))
.foregroundColor(.primary)
.foregroundStyle(.primary)
if let badge, !badge.isEmpty {
Text(badge)
.font(.caption2.weight(.bold))
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Color.appSecondary.opacity(0.15))
.foregroundColor(.appSecondary)
.foregroundStyle(Color.appSecondary)
.clipShape(Capsule())
}
}
Text(price)
.font(.footnote)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
Spacer()
Image(systemName: selectedPlan == plan ? "checkmark.circle.fill" : "circle")
.foregroundColor(selectedPlan == plan ? .appPrimary : .secondary)
.foregroundStyle(selectedPlan == plan ? Color.appPrimary : .secondary)
}
.padding(12)
.background(Color(.systemBackground))
@@ -268,7 +268,7 @@ struct PaywallView: View {
.stroke(selectedPlan == plan ? Color.appPrimary : Color.gray.opacity(0.25),
lineWidth: selectedPlan == plan ? 2 : 1)
)
.cornerRadius(AppConstants.UI.smallCornerRadius)
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.smallCornerRadius))
}
.buttonStyle(.plain)
}
@@ -291,8 +291,8 @@ struct PaywallView: View {
.frame(maxWidth: .infinity)
.padding()
.background(Color.appPrimary)
.foregroundColor(.white)
.cornerRadius(AppConstants.UI.cornerRadius)
.foregroundStyle(.white)
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
}
.disabled(isPurchasing)
}
@@ -305,7 +305,7 @@ struct PaywallView: View {
} label: {
Text("Restore Purchases")
.font(.subheadline)
.foregroundColor(.appPrimary)
.foregroundStyle(Color.appPrimary)
}
.disabled(isPurchasing)
}
@@ -316,17 +316,17 @@ struct PaywallView: View {
VStack(spacing: 4) {
Text("Payment charged to your Apple ID account.")
.font(.caption2)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
HStack(spacing: 12) {
Link("Terms", destination: URL(string: AppConstants.URLs.termsOfService)!)
.font(.caption2)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
Link("Privacy", destination: URL(string: AppConstants.URLs.privacyPolicy)!)
.font(.caption2)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
}
}
@@ -336,9 +336,6 @@ struct PaywallView: View {
private func purchase() {
isPurchasing = true
let product = (selectedPlan == .annual ? iapService.annualProduct : iapService.premiumProduct)
FirebaseService.shared.logPurchaseAttempt(
productId: product?.id ?? IAPService.premiumProductID
)
Task {
do {
@@ -381,7 +378,7 @@ struct BenefitRow: View {
HStack(spacing: 14) {
Image(systemName: icon)
.font(.system(size: 20))
.foregroundColor(.appPrimary)
.foregroundStyle(Color.appPrimary)
.frame(width: 28)
VStack(alignment: .leading, spacing: 1) {
@@ -390,13 +387,13 @@ struct BenefitRow: View {
Text(subtitle)
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
Spacer()
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.positiveGreen)
.foregroundStyle(Color.positiveGreen)
.font(.system(size: 18))
}
.padding(.vertical, 2)
@@ -439,7 +436,7 @@ struct CompactPaywallBanner: View {
Text("Unlimited sources, advanced charts & more")
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
Spacer()
@@ -452,13 +449,13 @@ struct CompactPaywallBanner: View {
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(Color.appPrimary)
.foregroundColor(.white)
.cornerRadius(16)
.foregroundStyle(.white)
.clipShape(RoundedRectangle(cornerRadius: 16))
}
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
}
}
@@ -493,8 +490,8 @@ struct PremiumLockOverlay: View {
.padding(.horizontal, 20)
.padding(.vertical, 10)
.background(Color.appPrimary)
.foregroundColor(.white)
.cornerRadius(20)
.foregroundStyle(.white)
.clipShape(RoundedRectangle(cornerRadius: 20))
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
@@ -9,6 +9,7 @@ struct AppLockView: View {
@State private var errorMessage: String?
@State private var didAttemptBiometrics = false
@FocusState private var pinFocused: Bool
private let biometry = AppLockService.biometryKind
var body: some View {
ZStack {
@@ -28,7 +29,7 @@ struct AppLockView: View {
Text("Unlock Portfolio Journal to view your data.")
.font(.subheadline)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal, 30)
@@ -36,13 +37,14 @@ struct AppLockView: View {
Button {
authenticateWithBiometrics()
} label: {
Label("Unlock with Face ID", systemImage: "faceid")
Label(String(format: String(localized: "biometric_unlock_with"), biometry.displayName),
systemImage: biometry.systemImage)
.font(.headline)
.frame(maxWidth: .infinity)
.padding()
.background(Color.appPrimary)
.foregroundColor(.white)
.cornerRadius(AppConstants.UI.cornerRadius)
.foregroundStyle(.white)
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
}
.padding(.horizontal, 24)
}
@@ -56,7 +58,7 @@ struct AppLockView: View {
.font(.title3.weight(.semibold))
.padding()
.background(Color.gray.opacity(0.1))
.cornerRadius(12)
.clipShape(RoundedRectangle(cornerRadius: 12))
.focused($pinFocused)
.onChange(of: pin) { _, newValue in
pin = String(newValue.filter(\.isNumber).prefix(4))
@@ -78,7 +80,7 @@ struct AppLockView: View {
if let errorMessage {
Text(errorMessage)
.font(.caption)
.foregroundColor(.negativeRed)
.foregroundStyle(Color.negativeRed)
}
Spacer()
@@ -101,7 +103,7 @@ struct AppLockView: View {
} else if pinEnabled {
pinFocused = true
} else {
errorMessage = "Face ID failed. Please try again."
errorMessage = String(format: String(localized: "biometric_failed"), biometry.displayName)
}
}
}
@@ -11,13 +11,13 @@ struct AllocationTargetsView: View {
Text("Total Targets")
Spacer()
Text(String(format: "%.0f%%", totalTargets))
.foregroundColor(totalTargets == 100 ? .positiveGreen : .secondary)
.foregroundStyle(totalTargets == 100 ? Color.positiveGreen : .secondary)
}
if totalTargets != 100 {
Text("Targets don't need to be perfect, but aiming for 100% keeps the drift view accurate.")
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
}
@@ -40,7 +40,7 @@ struct AllocationTargetsView: View {
.frame(width: 60)
Text("%")
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
}
} header: {
@@ -29,7 +29,7 @@ struct CSVMappingView: View {
VStack(alignment: .leading, spacing: 2) {
Text(pair.0)
.font(.caption2.weight(.semibold))
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
Text(pair.1.isEmpty ? "" : pair.1)
.font(.caption)
.lineLimit(2)
@@ -117,10 +117,10 @@ struct CSVMappingView: View {
.navigationTitle("Map Columns")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
ToolbarItem(placement: .topBarLeading) {
Button("Cancel") { dismiss() }
}
ToolbarItem(placement: .navigationBarTrailing) {
ToolbarItem(placement: .topBarTrailing) {
Button("Import") {
onComplete(config)
dismiss()
@@ -261,10 +261,10 @@ private struct MappingRow: View {
HStack(spacing: 4) {
Text(selectionLabel)
.font(.subheadline)
.foregroundColor(index == ImportService.CSVMappingConfig.notMapped ? .secondary : .appPrimary)
.foregroundStyle(index == ImportService.CSVMappingConfig.notMapped ? .secondary : Color.appPrimary)
Image(systemName: "chevron.up.chevron.down")
.font(.caption2)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
}
}
@@ -332,10 +332,10 @@ private struct MappingRowWithConstant: View {
HStack(spacing: 4) {
Text(selectionLabel)
.font(.subheadline)
.foregroundColor(index == ImportService.CSVMappingConfig.notMapped ? .secondary : .appPrimary)
.foregroundStyle(index == ImportService.CSVMappingConfig.notMapped ? .secondary : Color.appPrimary)
Image(systemName: "chevron.up.chevron.down")
.font(.caption2)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
}
}
@@ -13,7 +13,7 @@ struct CategoriesView: View {
List {
if categoryRepository.categories.isEmpty {
Text(String(localized: "categories_empty"))
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .center)
.padding(.vertical, 32)
} else {
@@ -54,14 +54,14 @@ struct CategoriesView: View {
.navigationTitle("Categories")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
ToolbarItem(placement: .topBarTrailing) {
Button {
showingAddCategory = true
} label: {
Image(systemName: "plus")
}
}
ToolbarItem(placement: .navigationBarLeading) {
ToolbarItem(placement: .topBarLeading) {
EditButton()
}
}
@@ -99,7 +99,7 @@ private struct CategoryRow: View {
.frame(width: 40, height: 40)
Image(systemName: category.icon)
.font(.system(size: 16))
.foregroundColor(category.color)
.foregroundStyle(category.color)
}
VStack(alignment: .leading, spacing: 2) {
@@ -107,7 +107,7 @@ private struct CategoryRow: View {
.font(.body)
Text(category.sourceCount == 1 ? "1 source" : "\(category.sourceCount) sources")
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
Spacer()
@@ -186,20 +186,20 @@ struct CategoryEditorView: View {
.frame(width: 44, height: 44)
Image(systemName: selectedIcon)
.font(.system(size: 18))
.foregroundColor(Color(hex: selectedColor) ?? .blue)
.foregroundStyle(Color(hex: selectedColor) ?? .blue)
}
Text(name.isEmpty ? String(localized: "category_name_placeholder") : name)
.foregroundColor(name.isEmpty ? .secondary : .primary)
.foregroundStyle(name.isEmpty ? .secondary : .primary)
}
}
}
.navigationTitle(category == nil ? "Add Category" : "Edit Category")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
ToolbarItem(placement: .topBarLeading) {
Button("Cancel") { dismiss() }
}
ToolbarItem(placement: .navigationBarTrailing) {
ToolbarItem(placement: .topBarTrailing) {
Button("Save") { save() }
.fontWeight(.semibold)
.disabled(name.trimmingCharacters(in: .whitespaces).isEmpty)
@@ -259,7 +259,7 @@ private struct IconSwatch: View {
.frame(width: 44, height: 44)
Image(systemName: icon)
.font(.system(size: 18))
.foregroundColor(isSelected ? color : .secondary)
.foregroundStyle(isSelected ? color : .secondary)
}
}
}
@@ -1,4 +1,5 @@
import SwiftUI
import CoreData
import UniformTypeIdentifiers
import UIKit
@@ -88,7 +89,7 @@ struct ImportDataView: View {
ProgressView()
Text("Reviewing import…")
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
.padding(.top, 8)
}
@@ -98,7 +99,7 @@ struct ImportDataView: View {
ProgressView(value: importProgress)
Text(importStatus)
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
.padding(.top, 8)
}
@@ -133,7 +134,7 @@ struct ImportDataView: View {
.navigationTitle("Import Data")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
ToolbarItem(placement: .topBarTrailing) {
Button("Done") { dismiss() }
.disabled(isImporting)
}
@@ -244,7 +245,7 @@ Personal,Stocks,Index Fund,2024-01-01,15000,12000,Long-term
.font(.caption.monospaced())
Text("Account, Contribution, and Notes are optional. Dates accept / or - and 24h/12h time.")
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
}
@@ -325,7 +326,7 @@ Personal,Stocks,Index Fund,2024-01-01,15000,12000,Long-term
Text("Account")
Spacer()
Text(selectedAccountName ?? "Personal")
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
}
} header: {
@@ -333,7 +334,7 @@ Personal,Stocks,Index Fund,2024-01-01,15000,12000,Long-term
} footer: {
if let accountErrorMessage {
Text(accountErrorMessage)
.foregroundColor(.negativeRed)
.foregroundStyle(Color.negativeRed)
}
}
}
@@ -5,7 +5,6 @@ import UIKit
struct SettingsView: View {
private let iapService: IAPService
@StateObject private var viewModel: SettingsViewModel
@EnvironmentObject private var adMobService: AdMobService
@EnvironmentObject private var accountStore: AccountStore
@EnvironmentObject private var tabSelection: TabSelectionStore
@AppStorage("showForecast") private var showForecast = true
@@ -149,20 +148,20 @@ struct SettingsView: View {
} message: {
Text("Restart the app to apply iCloud sync changes.")
}
.alert("Face ID Unavailable", isPresented: $showingBiometricAlert) {
.alert(Text(String(format: String(localized: "biometric_unavailable_title"), biometryName)), isPresented: $showingBiometricAlert) {
Button("OK") {}
} message: {
Text("Face ID isn't available on this device.")
Text(String(format: String(localized: "biometric_unavailable_body"), biometryName))
}
.alert("PIN Required", isPresented: $showingPinRequiredAlert) {
Button("OK") {}
} message: {
Text("Enable a PIN before turning on Face ID.")
Text(String(format: String(localized: "biometric_pin_required"), biometryName))
}
.alert("Disable Face ID First", isPresented: $showingPinDisableAlert) {
.alert(Text(String(format: String(localized: "biometric_disable_first_title"), biometryName)), isPresented: $showingPinDisableAlert) {
Button("OK") {}
} message: {
Text("Turn off Face ID before disabling your PIN.")
Text(String(format: String(localized: "biometric_disable_first_body"), biometryName))
}
.sheet(isPresented: $showingPinSetup) {
PinSetupView(title: "Set PIN") { pin in
@@ -184,7 +183,7 @@ struct SettingsView: View {
}
}
.sheet(isPresented: $showingPinVerifyForFaceId) {
PinVerifyView(title: "Enter PIN to Disable Face ID") { success in
PinVerifyView(title: String(format: String(localized: "biometric_pin_to_disable"), biometryName)) { success in
if success {
faceIdEnabled = false
}
@@ -207,19 +206,19 @@ struct SettingsView: View {
Link(destination: GoalShareService.appStoreURL) {
HStack {
Image(systemName: "arrow.down.circle.fill")
.foregroundColor(.appPrimary)
.foregroundStyle(Color.appPrimary)
VStack(alignment: .leading, spacing: 2) {
Text(String(localized: "update_available_title"))
.font(.subheadline.weight(.semibold))
if let v = updateService.latestVersion {
Text(String(format: String(localized: "update_available_body"), v))
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
}
Spacer()
Image(systemName: "chevron.right")
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
.font(.caption)
}
}
@@ -237,14 +236,14 @@ struct SettingsView: View {
.frame(width: 36, height: 36)
.padding(6)
.background(Color.appPrimary.opacity(0.08))
.cornerRadius(10)
.clipShape(RoundedRectangle(cornerRadius: 10))
VStack(alignment: .leading, spacing: 2) {
Text(appDisplayName)
.font(.headline)
Text("Long-term portfolio tracker")
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
Spacer()
@@ -280,14 +279,14 @@ struct SettingsView: View {
if viewModel.isFamilyShared {
Text("Family Sharing")
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
}
Spacer()
Image(systemName: "checkmark.seal.fill")
.foregroundColor(.positiveGreen)
.foregroundStyle(Color.positiveGreen)
}
} else {
Button {
@@ -300,23 +299,23 @@ struct SettingsView: View {
.frame(width: 44, height: 44)
Image(systemName: "crown.fill")
.foregroundColor(.appPrimary)
.foregroundStyle(Color.appPrimary)
}
VStack(alignment: .leading, spacing: 2) {
Text("Upgrade to Premium")
.font(.headline)
.foregroundColor(.primary)
.foregroundStyle(.primary)
Text("Unlock all features for \(iapService.formattedPrice)")
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
Spacer()
Image(systemName: "chevron.right")
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
}
@@ -345,7 +344,7 @@ struct SettingsView: View {
Text("Notifications")
Spacer()
Text(viewModel.notificationsEnabled ? "Enabled" : "Disabled")
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
.contentShape(Rectangle())
.onTapGesture {
@@ -396,7 +395,7 @@ struct SettingsView: View {
systemImage: "internaldrive"
)
.font(.subheadline)
.foregroundColor(.primary)
.foregroundStyle(.primary)
Spacer()
@@ -411,27 +410,27 @@ struct SettingsView: View {
if cloudStack.isSyncing {
Label("Syncing with iCloud...", systemImage: "arrow.triangle.2.circlepath.icloud")
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
} else {
HStack(spacing: 12) {
if let date = cloudStack.lastImportDate {
Label("\(date.formatted(.relative(presentation: .named, unitsStyle: .abbreviated)))", systemImage: "icloud.and.arrow.down")
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
} else {
Label("No import yet", systemImage: "icloud.and.arrow.down")
.font(.caption)
.foregroundColor(.orange)
.foregroundStyle(.orange)
}
if let date = cloudStack.lastExportDate {
Label("\(date.formatted(.relative(presentation: .named, unitsStyle: .abbreviated)))", systemImage: "icloud.and.arrow.up")
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
} else {
Label("No export yet", systemImage: "icloud.and.arrow.up")
.font(.caption)
.foregroundColor(.orange)
.foregroundStyle(.orange)
}
}
}
@@ -449,12 +448,12 @@ struct SettingsView: View {
systemImage: critical ? "exclamationmark.icloud" : "arrow.triangle.2.circlepath.icloud"
)
.font(.caption.weight(.semibold))
.foregroundColor(critical ? .red : .secondary)
.foregroundStyle(critical ? .red : .secondary)
if critical, let hint = cloudStack.lastSyncErrorHint {
Text(hint)
.font(.caption)
.foregroundColor(.primary)
.foregroundStyle(.primary)
}
// Technical detail is opt-in behind a disclosure, so it
@@ -463,11 +462,11 @@ struct SettingsView: View {
if let hint = cloudStack.lastSyncErrorHint, !critical {
Text(hint)
.font(.caption2)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
Text(error)
.font(.caption2)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
.textSelection(.enabled)
}
.font(.caption2)
@@ -484,7 +483,7 @@ struct SettingsView: View {
} label: {
Label(String(localized: "icloud_copy_diagnostics"), systemImage: "doc.on.doc")
.font(.caption)
.foregroundColor(.appPrimary)
.foregroundStyle(Color.appPrimary)
}
// Force upload use when data exists locally but hasn't reached iCloud
@@ -492,7 +491,7 @@ struct SettingsView: View {
if let result = forceUploadResult {
Label(result, systemImage: "checkmark.icloud")
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
} else {
Button {
isForceUploading = true
@@ -514,7 +513,7 @@ struct SettingsView: View {
}
}
.font(.caption)
.foregroundColor(.appPrimary)
.foregroundStyle(Color.appPrimary)
.disabled(isForceUploading)
}
}
@@ -533,7 +532,7 @@ struct SettingsView: View {
if !viewModel.isPremium {
Text("Premium")
.font(.caption.weight(.semibold))
.foregroundColor(.appWarning)
.foregroundStyle(Color.appWarning)
}
}
}
@@ -553,7 +552,7 @@ struct SettingsView: View {
Text("Last auto backup")
Spacer()
Text(last, style: .date)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
.font(.caption)
}
@@ -580,7 +579,7 @@ struct SettingsView: View {
if !viewModel.canExport {
Image(systemName: "lock.fill")
.font(.caption)
.foregroundColor(.appWarning)
.foregroundStyle(Color.appWarning)
}
}
}
@@ -598,21 +597,21 @@ struct SettingsView: View {
Text("Total Sources")
Spacer()
Text("\(viewModel.totalSources)")
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
HStack {
Text("Total Snapshots")
Spacer()
Text("\(viewModel.totalSnapshots)")
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
HStack {
Text("Storage Used")
Spacer()
Text(viewModel.storageUsedText)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
} header: {
Text("Data")
@@ -648,7 +647,7 @@ struct SettingsView: View {
if viewModel.backups.isEmpty {
Text("No backups yet.")
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
} else {
ForEach(viewModel.backups) { backup in
HStack {
@@ -657,7 +656,7 @@ struct SettingsView: View {
.font(.subheadline)
Text("\(backup.location.rawValue) · \(formatBytes(backup.size))")
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
Spacer()
Button("Restore") {
@@ -675,7 +674,7 @@ struct SettingsView: View {
} header: {
Text("Backups")
} footer: {
Text("Backups are stored locally and in iCloud (when enabled).")
Text("backups_footer")
}
}
@@ -685,7 +684,7 @@ struct SettingsView: View {
Section {
Toggle("Hide Balances", isOn: $balancePrivacy.balancesHidden)
Toggle("Require Face ID to Reveal", isOn: $balancePrivacy.requireBiometricToReveal)
Toggle(String(format: String(localized: "biometric_require_to_reveal"), biometryName), isOn: $balancePrivacy.requireBiometricToReveal)
.onChange(of: balancePrivacy.requireBiometricToReveal) { _, enabled in
if enabled && !AppLockService.canUseBiometrics() {
balancePrivacy.requireBiometricToReveal = false
@@ -695,10 +694,13 @@ struct SettingsView: View {
} header: {
Text("Privacy")
} footer: {
Text("Hide all monetary amounts behind a blur. Great for using the app in public or taking screenshots. Optionally require Face ID to reveal them.")
Text(String(format: String(localized: "privacy_footer_biometric"), biometryName))
}
}
/// Face ID / Touch ID / Optic ID resolved from the hardware, never assumed.
private var biometryName: String { AppLockService.biometryName }
private var securitySection: some View {
Section {
Toggle("Require PIN", isOn: $pinEnabled)
@@ -717,7 +719,7 @@ struct SettingsView: View {
}
}
Toggle("Enable Face ID", isOn: $faceIdEnabled)
Toggle(String(format: String(localized: "biometric_enable"), biometryName), isOn: $faceIdEnabled)
.onChange(of: faceIdEnabled) { _, enabled in
if enabled {
guard AppLockService.canUseBiometrics() else {
@@ -748,7 +750,7 @@ struct SettingsView: View {
} header: {
Text("App Lock")
} footer: {
Text("Use Face ID or a 4-digit PIN to protect your data.")
Text(String(format: String(localized: "biometric_lock_footer"), biometryName))
}
}
@@ -773,14 +775,6 @@ struct SettingsView: View {
.onChange(of: viewModel.inputMode) { _, newValue in
viewModel.updateInputMode(newValue)
}
if !viewModel.isPremium {
Button("Manage Ad Consent") {
Task {
await adMobService.presentPrivacyOptions()
}
}
}
} header: {
Text("Preferences")
} footer: {
@@ -802,7 +796,7 @@ struct SettingsView: View {
Label("Allocation Targets", systemImage: "target")
Spacer()
Image(systemName: "chevron.right")
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
}
} header: {
@@ -825,7 +819,7 @@ struct SettingsView: View {
if !viewModel.isPremium {
Text("Premium")
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
}
}
@@ -844,7 +838,7 @@ struct SettingsView: View {
Text("Version")
Spacer()
Text(viewModel.appVersion)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
Link(destination: URL(string: AppConstants.URLs.privacyPolicy)!) {
@@ -853,7 +847,7 @@ struct SettingsView: View {
Spacer()
Image(systemName: "arrow.up.right")
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
}
@@ -863,7 +857,7 @@ struct SettingsView: View {
Spacer()
Image(systemName: "arrow.up.right")
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
}
@@ -873,7 +867,7 @@ struct SettingsView: View {
Spacer()
Image(systemName: "arrow.up.right")
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
}
@@ -884,14 +878,38 @@ struct SettingsView: View {
Text("Rate App")
Spacer()
Image(systemName: "star.fill")
.foregroundColor(.yellow)
.foregroundStyle(.yellow)
}
}
// Only shown when MetricKit has actually left something behind.
// Sharing is the only way a report leaves the device.
if let latestReport = diagnosticReports.first {
Button {
viewModel.shareItem = SettingsViewModel.ShareItem(url: latestReport)
} label: {
HStack {
Text("Share Diagnostics")
Spacer()
Image(systemName: "square.and.arrow.up")
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
} header: {
Text("About")
} footer: {
if !diagnosticReports.isEmpty {
Text("diagnostics_footer")
}
}
}
/// Crash/hang reports MetricKit left in the app container. They never leave
/// the device unless the user shares one from here.
private var diagnosticReports: [URL] { DiagnosticsCollector.shared.storedReports() }
// MARK: - Danger Zone Section
private var dangerZoneSection: some View {
@@ -992,7 +1010,7 @@ struct ExportOptionsSheet: View {
Text(viewModel.exportStatus)
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
.padding(.vertical, 8)
} header: {
@@ -1005,7 +1023,7 @@ struct ExportOptionsSheet: View {
} label: {
HStack {
Image(systemName: "tablecells")
.foregroundColor(.positiveGreen)
.foregroundStyle(Color.positiveGreen)
.frame(width: 30)
VStack(alignment: .leading) {
@@ -1013,7 +1031,7 @@ struct ExportOptionsSheet: View {
.font(.headline)
Text("Compatible with Excel, Google Sheets")
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
}
}
@@ -1023,7 +1041,7 @@ struct ExportOptionsSheet: View {
} label: {
HStack {
Image(systemName: "doc.text")
.foregroundColor(.appPrimary)
.foregroundStyle(Color.appPrimary)
.frame(width: 30)
VStack(alignment: .leading) {
@@ -1031,7 +1049,7 @@ struct ExportOptionsSheet: View {
.font(.headline)
Text("Full data structure for backup")
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
}
}
@@ -1043,7 +1061,7 @@ struct ExportOptionsSheet: View {
.navigationTitle(viewModel.isExporting ? "Exporting" : "Export Data")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
ToolbarItem(placement: .topBarTrailing) {
Button("Cancel") {
dismiss()
}
@@ -1089,16 +1107,16 @@ struct PinSetupView: View {
if let errorMessage {
Text(errorMessage)
.font(.caption)
.foregroundColor(.negativeRed)
.foregroundStyle(Color.negativeRed)
}
}
.navigationTitle(title)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
ToolbarItem(placement: .topBarLeading) {
Button("Cancel") { dismiss() }
}
ToolbarItem(placement: .navigationBarTrailing) {
ToolbarItem(placement: .topBarTrailing) {
Button("Save") { savePin() }
.disabled(pin.count < 4 || confirmPin.count < 4)
}
@@ -1142,19 +1160,19 @@ struct PinVerifyView: View {
if let errorMessage {
Text(errorMessage)
.font(.caption)
.foregroundColor(.negativeRed)
.foregroundStyle(Color.negativeRed)
}
}
.navigationTitle(title)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
ToolbarItem(placement: .topBarLeading) {
Button("Cancel") {
onResult(false)
dismiss()
}
}
ToolbarItem(placement: .navigationBarTrailing) {
ToolbarItem(placement: .topBarTrailing) {
Button("Verify") { verifyPin() }
.disabled(pin.count < 4)
}
@@ -1,4 +1,5 @@
import SwiftUI
import CoreData
import StoreKit
struct AddSourceView: View {
@@ -54,25 +55,25 @@ struct AddSourceView: View {
} label: {
HStack {
Text("Category")
.foregroundColor(.primary)
.foregroundStyle(.primary)
Spacer()
if let category = selectedCategory {
HStack(spacing: 6) {
Image(systemName: category.icon)
.foregroundColor(category.color)
.foregroundStyle(category.color)
Text(category.name)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
} else {
Text("Select")
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
Image(systemName: "chevron.right")
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
}
} header: {
@@ -80,10 +81,10 @@ struct AddSourceView: View {
} footer: {
if let error = duplicateError {
Text(error)
.foregroundColor(.negativeRed)
.foregroundStyle(Color.negativeRed)
} else {
Text(String(localized: "add_source_name_footer"))
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
}
@@ -92,7 +93,7 @@ struct AddSourceView: View {
Section {
HStack {
Text(currencySymbol)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
TextField("0.00", text: $initialValue)
.keyboardType(.decimalPad)
@@ -106,13 +107,13 @@ struct AddSourceView: View {
.navigationTitle("Add Source")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
ToolbarItem(placement: .topBarLeading) {
Button("Cancel") {
dismiss()
}
}
ToolbarItem(placement: .navigationBarTrailing) {
ToolbarItem(placement: .topBarTrailing) {
Button("Add") {
saveSource()
}
@@ -182,12 +183,6 @@ struct AddSourceView: View {
date: Date(),
value: value
)
// Instrument like AddSnapshotView does without this, snapshots
// created via the initial value never count in the activation funnel.
FirebaseService.shared.logSnapshotAdded(
sourceName: name.trimmingCharacters(in: .whitespaces),
value: value
)
hasInitialSnapshot = true
} else {
hasInitialSnapshot = false
@@ -202,12 +197,6 @@ struct AddSourceView: View {
let total = allSources.reduce(Decimal.zero) { $0 + $1.latestValue }
NotificationService.shared.checkAndScheduleMilestoneNotification(portfolioValue: total)
// Log analytics
FirebaseService.shared.logSourceAdded(
categoryName: category.name,
sourceCount: repository.sourceCount
)
if hasInitialSnapshot {
dismiss()
} else {
@@ -262,17 +251,17 @@ struct CategoryPickerView: View {
.frame(width: 40, height: 40)
Image(systemName: category.icon)
.foregroundColor(category.color)
.foregroundStyle(category.color)
}
Text(category.name)
.foregroundColor(.primary)
.foregroundStyle(.primary)
Spacer()
if selectedCategory?.id == category.id {
Image(systemName: "checkmark")
.foregroundColor(.appPrimary)
.foregroundStyle(Color.appPrimary)
}
}
}
@@ -280,7 +269,7 @@ struct CategoryPickerView: View {
.navigationTitle("Select Category")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
ToolbarItem(placement: .topBarTrailing) {
Button("Done") {
dismiss()
}
@@ -334,22 +323,22 @@ struct EditSourceView: View {
} label: {
HStack {
Text("Category")
.foregroundColor(.primary)
.foregroundStyle(.primary)
Spacer()
if let category = selectedCategory {
HStack(spacing: 6) {
Image(systemName: category.icon)
.foregroundColor(category.color)
.foregroundStyle(category.color)
Text(category.name)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
}
Image(systemName: "chevron.right")
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
}
}
@@ -357,13 +346,13 @@ struct EditSourceView: View {
.navigationTitle("Edit Source")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
ToolbarItem(placement: .topBarLeading) {
Button("Cancel") {
dismiss()
}
}
ToolbarItem(placement: .navigationBarTrailing) {
ToolbarItem(placement: .topBarTrailing) {
Button("Save") {
saveChanges()
}
@@ -456,7 +445,7 @@ struct AddSnapshotView: View {
HStack {
Text(viewModel.currencySymbol)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
TextField("Value", text: $viewModel.valueString)
.keyboardType(.decimalPad)
@@ -474,7 +463,7 @@ struct AddSnapshotView: View {
if viewModel.previousValue != nil {
Text(viewModel.previousValueString)
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
} header: {
Text("Snapshot Details")
@@ -488,10 +477,10 @@ struct AddSnapshotView: View {
Text("Change from previous")
Spacer()
Text("\(change) (\(percentage))")
.foregroundColor(
.foregroundStyle(
(viewModel.changeFromPrevious ?? 0) >= 0
? .positiveGreen
: .negativeRed
? Color.positiveGreen
: Color.negativeRed
)
}
}
@@ -503,7 +492,7 @@ struct AddSnapshotView: View {
if viewModel.includeContribution {
HStack {
Text(viewModel.currencySymbol)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
TextField("New capital added", text: $viewModel.contributionString)
.keyboardType(.decimalPad)
@@ -525,13 +514,13 @@ struct AddSnapshotView: View {
.navigationTitle(viewModel.title)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
ToolbarItem(placement: .topBarLeading) {
Button("Cancel") {
dismiss()
}
}
ToolbarItem(placement: .navigationBarTrailing) {
ToolbarItem(placement: .topBarTrailing) {
Button(viewModel.buttonTitle) {
saveSnapshot()
}
@@ -640,9 +629,6 @@ struct AddSnapshotView: View {
// Reschedule notification
NotificationService.shared.scheduleReminder(for: source)
// Log analytics
FirebaseService.shared.logSnapshotAdded(sourceName: source.name, value: value)
// When editing, offer to propagate a changed contribution to other snapshots.
if isEdit, let contribution = viewModel.contribution, viewModel.contributionChanged {
pendingContribution = contribution
@@ -670,14 +656,8 @@ struct AddSnapshotView: View {
let goals = goalRepo.goals.filter { $0.isActive }
var achieved = Set<UUID>()
for goal in goals {
let currentValue: Decimal
if let accountId = goal.account?.safeId {
currentValue = sourceRepo.sources
.filter { $0.account?.id == accountId }
.reduce(Decimal.zero) { $0 + $1.latestValue }
} else {
currentValue = sourceRepo.sources.reduce(Decimal.zero) { $0 + $1.latestValue }
}
let currentValue = goal.relevantSources(from: sourceRepo.sources)
.reduce(Decimal.zero) { $0 + $1.latestValue }
let target = goal.targetDecimal
if target > 0 && currentValue / target >= 1 {
achieved.insert(goal.id)
@@ -691,14 +671,8 @@ struct AddSnapshotView: View {
let goals = goalRepo.goals.filter { $0.isActive }
for goal in goals {
guard !prevAchievedIds.contains(goal.id) else { continue }
let currentValue: Decimal
if let accountId = goal.account?.safeId {
currentValue = sourceRepo.sources
.filter { $0.account?.id == accountId }
.reduce(Decimal.zero) { $0 + $1.latestValue }
} else {
currentValue = sourceRepo.sources.reduce(Decimal.zero) { $0 + $1.latestValue }
}
let currentValue = goal.relevantSources(from: sourceRepo.sources)
.reduce(Decimal.zero) { $0 + $1.latestValue }
let target = goal.targetDecimal
if target > 0 && currentValue / target >= 1 {
NotificationService.shared.scheduleGoalAchievedNotification(goalName: goal.name)
@@ -66,7 +66,7 @@ struct SourceDetailView: View {
.navigationTitle(viewModel.safeSourceName)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
ToolbarItem(placement: .topBarTrailing) {
Menu {
Button {
viewModel.showingEditSource = true
@@ -129,11 +129,11 @@ struct SourceDetailView: View {
Text(viewModel.categoryName)
}
.font(.caption)
.foregroundColor(Color(hex: viewModel.categoryColor) ?? .gray)
.foregroundStyle(Color(hex: viewModel.categoryColor) ?? .gray)
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background((Color(hex: viewModel.categoryColor) ?? .gray).opacity(0.1))
.cornerRadius(20)
.clipShape(RoundedRectangle(cornerRadius: 20))
// Current value
Text(viewModel.formattedCurrentValue)
@@ -147,17 +147,17 @@ struct SourceDetailView: View {
Text("(\(viewModel.formattedPercentageReturn))")
}
.font(.subheadline.weight(.medium))
.foregroundColor(viewModel.isPositiveReturn ? .positiveGreen : .negativeRed)
.foregroundStyle(viewModel.isPositiveReturn ? Color.positiveGreen : Color.negativeRed)
// Last updated
Text("Last updated: \(viewModel.lastUpdated)")
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity)
.padding()
.background(Color(.systemBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
}
@@ -172,8 +172,8 @@ struct SourceDetailView: View {
.frame(maxWidth: .infinity)
.padding()
.background(Color.appPrimary)
.foregroundColor(.white)
.cornerRadius(AppConstants.UI.cornerRadius)
.foregroundStyle(.white)
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
}
}
@@ -186,7 +186,7 @@ struct SourceDetailView: View {
HStack {
Label(String(localized: "source_monthly_contribution_title"), systemImage: "arrow.down.circle.fill")
.font(.subheadline.weight(.semibold))
.foregroundColor(.primary)
.foregroundStyle(.primary)
Spacer()
Button {
if editingContribution {
@@ -204,7 +204,7 @@ struct SourceDetailView: View {
} label: {
Text(editingContribution ? String(localized: "done") : String(localized: "edit"))
.font(.caption.weight(.semibold))
.foregroundColor(.appPrimary)
.foregroundStyle(Color.appPrimary)
}
}
@@ -213,20 +213,20 @@ struct SourceDetailView: View {
.keyboardType(.decimalPad)
.padding(8)
.background(Color(.systemGray6))
.cornerRadius(8)
.clipShape(RoundedRectangle(cornerRadius: 8))
} else {
Text(current.map { $0.currencyString } ?? String(localized: "source_monthly_contribution_not_set"))
.font(.subheadline)
.foregroundColor(current != nil ? .primary : .secondary)
.foregroundStyle(current != nil ? .primary : .secondary)
}
Text(String(localized: "source_monthly_contribution_hint"))
.font(.caption2)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
.confirmationDialog(
String(localized: "source_monthly_contribution_apply_title"),
@@ -316,7 +316,7 @@ struct SourceDetailView: View {
.padding(.horizontal, 6)
.padding(.vertical, 4)
.background(Color.appSecondary.opacity(0.15))
.cornerRadius(8)
.clipShape(RoundedRectangle(cornerRadius: 8))
}
}
}
@@ -347,18 +347,18 @@ struct SourceDetailView: View {
Text("Unlock predictions on the chart")
}
.font(.caption.weight(.semibold))
.foregroundColor(.appPrimary)
.foregroundStyle(Color.appPrimary)
.padding(.vertical, 6)
.padding(.horizontal, 10)
.background(Color.appPrimary.opacity(0.1))
.cornerRadius(10)
.clipShape(RoundedRectangle(cornerRadius: 10))
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
}
@@ -383,7 +383,7 @@ struct SourceDetailView: View {
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
}
@@ -399,13 +399,13 @@ struct SourceDetailView: View {
Text("\(viewModel.snapshotCount)")
.font(.subheadline)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
if viewModel.isHistoryLimited {
HStack {
Image(systemName: "info.circle")
.foregroundColor(.appWarning)
.foregroundStyle(Color.appWarning)
Text("\(viewModel.hiddenSnapshotCount) older snapshots hidden. Upgrade for full history.")
.font(.caption)
@@ -418,7 +418,7 @@ struct SourceDetailView: View {
}
.padding(8)
.background(Color.appWarning.opacity(0.1))
.cornerRadius(AppConstants.UI.smallCornerRadius)
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.smallCornerRadius))
}
// Use LazyVStack for better performance with many snapshots
@@ -464,12 +464,12 @@ struct SourceDetailView: View {
if viewModel.isHistoryLimited && viewModel.hiddenSnapshotCount > 0 {
Text("+ \(viewModel.hiddenSnapshotCount) older snapshots hidden")
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
}
}
@@ -484,7 +484,7 @@ struct MetricCard: View {
VStack(alignment: .leading, spacing: 4) {
Text(title)
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
Text(value)
.font(.subheadline.weight(.semibold))
@@ -508,7 +508,7 @@ struct SnapshotRowView: View {
if let notes = snapshot.notes, !notes.isEmpty {
Text(notes)
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
.lineLimit(1)
}
}
@@ -523,7 +523,7 @@ struct SnapshotRowView: View {
if snapshot.contribution != nil && snapshot.decimalContribution > 0 {
Text("+ \(snapshot.decimalContribution.currencyString)")
.font(.caption)
.foregroundColor(.appPrimary)
.foregroundStyle(Color.appPrimary)
.hiddenBalance()
}
}
@@ -532,10 +532,10 @@ struct SnapshotRowView: View {
Button(action: onEdit) {
Image(systemName: "pencil")
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
.padding(6)
.background(Color.gray.opacity(0.1))
.cornerRadius(8)
.clipShape(RoundedRectangle(cornerRadius: 8))
}
.buttonStyle(.plain)
}
@@ -552,7 +552,7 @@ struct MetricChip: View {
VStack(alignment: .leading, spacing: 4) {
Text(title)
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
Text(value)
.font(.caption.weight(.semibold))
@@ -561,7 +561,7 @@ struct MetricChip: View {
.padding(.vertical, 10)
.padding(.horizontal, 12)
.background(Color.gray.opacity(0.1))
.cornerRadius(10)
.clipShape(RoundedRectangle(cornerRadius: 10))
}
}
@@ -7,70 +7,21 @@ struct SourceListView: View {
@Environment(\.managedObjectContext) private var context
@StateObject private var viewModel: SourceListViewModel
@State private var sourceToDelete: InvestmentSource?
@State private var navigationPath = NavigationPath()
@State private var showingSearch = false
@State private var selectedSourceID: NSManagedObjectID?
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
init(iapService: IAPService) {
_viewModel = StateObject(wrappedValue: SourceListViewModel(iapService: iapService))
}
/// Sidebar/detail split: two columns in regular width (iPad, iPhone Duo
/// inner screen), a single collapsing stack in compact width. Selection is
/// the only navigation state, so it survives the size-class change when the
/// Duo opens or closes.
@State private var columnVisibility: NavigationSplitViewVisibility = .all
var body: some View {
if horizontalSizeClass == .regular {
iPadSourcesLayout
} else {
iPhoneSourcesLayout
}
}
// MARK: - iPad Layout (master-detail)
private var iPadSourcesLayout: some View {
HStack(spacing: 0) {
// Left: source list panel
NavigationStack {
ZStack {
AppBackground()
if viewModel.isEmpty { emptyStateView } else { sourcesList }
}
.navigationTitle("Sources")
.toolbar { sourcesToolbar }
.sheet(isPresented: $viewModel.showingAddSource) { AddSourceView() }
.sheet(isPresented: $viewModel.showingPaywall) { PaywallView() }
.onAppear { syncAccountState() }
.onReceive(accountStore.$selectedAccount) { viewModel.selectedAccount = $0 }
.onReceive(accountStore.$showAllAccounts) { viewModel.showAllAccounts = $0 }
.confirmationDialog(
"Delete Source",
isPresented: Binding(get: { sourceToDelete != nil }, set: { if !$0 { sourceToDelete = nil } }),
titleVisibility: .visible
) { deleteSourceDialogButtons } message: { deleteSourceDialogMessage }
}
.frame(width: 340)
Divider()
// Right: detail panel
NavigationStack {
Group {
if let id = selectedSourceID,
let source = try? context.existingObject(with: id) as? InvestmentSource,
!source.isDeleted {
SourceDetailView(source: source, iapService: iapService)
.id(id)
} else {
noSourceSelectedView
}
}
}
}
}
// MARK: - iPhone Layout (push navigation)
private var iPhoneSourcesLayout: some View {
NavigationStack(path: $navigationPath) {
NavigationSplitView(columnVisibility: $columnVisibility) {
ZStack {
AppBackground()
if viewModel.isEmpty { emptyStateView } else { sourcesList }
@@ -82,19 +33,32 @@ struct SourceListView: View {
.onAppear { syncAccountState() }
.onReceive(accountStore.$selectedAccount) { viewModel.selectedAccount = $0 }
.onReceive(accountStore.$showAllAccounts) { viewModel.showAllAccounts = $0 }
.navigationDestination(for: NSManagedObjectID.self) { objectID in
if let source = try? context.existingObject(with: objectID) as? InvestmentSource,
!source.isDeleted {
SourceDetailView(source: source, iapService: iapService)
} else {
MissingSourceView()
}
}
.confirmationDialog(
"Delete Source",
isPresented: Binding(get: { sourceToDelete != nil }, set: { if !$0 { sourceToDelete = nil } }),
titleVisibility: .visible
) { deleteSourceDialogButtons } message: { deleteSourceDialogMessage }
.navigationSplitViewColumnWidth(min: 280, ideal: 300, max: 420)
} detail: {
NavigationStack {
detailContent
}
}
.navigationSplitViewStyle(.balanced)
}
@ViewBuilder
private var detailContent: some View {
if let id = selectedSourceID {
if let source = try? context.existingObject(with: id) as? InvestmentSource,
!source.isDeleted {
SourceDetailView(source: source, iapService: iapService)
.id(id)
} else {
MissingSourceView()
}
} else {
noSourceSelectedView
}
}
@@ -107,21 +71,21 @@ struct SourceListView: View {
@ToolbarContentBuilder
private var sourcesToolbar: some ToolbarContent {
ToolbarItem(placement: .navigationBarTrailing) {
ToolbarItem(placement: .topBarTrailing) {
Button { viewModel.addSourceTapped() } label: {
Image(systemName: "plus")
}
}
ToolbarItem(placement: .navigationBarTrailing) {
ToolbarItem(placement: .topBarTrailing) {
Button {
withAnimation(.easeInOut(duration: 0.2)) { showingSearch.toggle() }
if !showingSearch { viewModel.searchText = "" }
} label: {
Image(systemName: showingSearch ? "xmark.circle.fill" : "magnifyingglass")
.foregroundColor(showingSearch ? .secondary : .primary)
.foregroundStyle(showingSearch ? .secondary : .primary)
}
}
ToolbarItem(placement: .navigationBarLeading) {
ToolbarItem(placement: .topBarLeading) {
accountFilterMenu
}
}
@@ -163,7 +127,7 @@ struct SourceListView: View {
Image(systemName: "chart.line.uptrend.xyaxis")
.font(.system(size: 50, weight: .light))
.foregroundColor(.white)
.foregroundStyle(.white)
}
VStack(spacing: 10) {
@@ -171,15 +135,16 @@ struct SourceListView: View {
.font(.title2.weight(.semibold))
Text("Pick an investment source from the\npanel on the left to see its details.")
.frame(maxWidth: 360)
.font(.subheadline)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
// Call-to-action pill
Label("Select from the list", systemImage: "arrow.left")
.font(.subheadline.weight(.semibold))
.foregroundColor(.appPrimary)
.foregroundStyle(Color.appPrimary)
.padding(.horizontal, 22)
.padding(.vertical, 11)
.background(Color.appPrimary.opacity(0.1))
@@ -194,7 +159,7 @@ struct SourceListView: View {
// MARK: - Sources List
private var sourcesList: some View {
List {
List(selection: $selectedSourceID) {
// Filter Bar
Section {
filterBarContent
@@ -210,7 +175,7 @@ struct SourceListView: View {
VStack(alignment: .leading) {
Text("Total Value")
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
Text(viewModel.formattedTotalValue)
.font(.title2.weight(.bold))
.hiddenBalance()
@@ -221,7 +186,7 @@ struct SourceListView: View {
VStack(alignment: .trailing) {
Text("Sources")
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
Text("\(viewModel.sources.count)")
.font(.title2.weight(.bold))
}
@@ -235,7 +200,7 @@ struct SourceListView: View {
Section {
HStack {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundColor(.appWarning)
.foregroundStyle(Color.appWarning)
Text("Source limit reached. Upgrade to Premium for unlimited sources.")
.font(.subheadline)
@@ -246,7 +211,7 @@ struct SourceListView: View {
viewModel.showingPaywall = true
}
.font(.subheadline.weight(.semibold))
.foregroundColor(.appPrimary)
.foregroundStyle(Color.appPrimary)
}
}
}
@@ -255,19 +220,7 @@ struct SourceListView: View {
Section {
ForEach(viewModel.sources, id: \.objectID) { source in
SourceRowView(source: source)
.contentShape(Rectangle())
.background(
horizontalSizeClass == .regular && selectedSourceID == source.objectID
? Color.appPrimary.opacity(0.08)
: Color.clear
)
.onTapGesture {
if horizontalSizeClass == .regular {
selectedSourceID = source.objectID
} else {
navigationPath.append(source.objectID)
}
}
.tag(source.objectID)
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
Button(role: .destructive) {
sourceToDelete = source
@@ -302,14 +255,14 @@ struct SourceListView: View {
VStack(spacing: 20) {
Image(systemName: "tray")
.font(.system(size: 60))
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
Text("No Investment Sources")
.font(.custom("Avenir Next", size: 24).weight(.semibold))
Text("Add your first investment source to start tracking your portfolio.")
.font(.subheadline)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal, 40)
@@ -318,11 +271,11 @@ struct SourceListView: View {
} label: {
Label("Add Source", systemImage: "plus")
.font(.headline)
.foregroundColor(.white)
.foregroundStyle(.white)
.padding()
.frame(maxWidth: 200)
.background(Color.appPrimary)
.cornerRadius(AppConstants.UI.cornerRadius)
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
}
}
}
@@ -358,7 +311,7 @@ struct SourceListView: View {
if showingSearch {
HStack(spacing: 8) {
Image(systemName: "magnifyingglass")
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
TextField("Search sources", text: $viewModel.searchText)
.textFieldStyle(.plain)
@@ -369,14 +322,14 @@ struct SourceListView: View {
viewModel.searchText = ""
} label: {
Image(systemName: "xmark.circle.fill")
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
}
}
.padding(.horizontal, 12)
.padding(.vertical, 9)
.background(Color(.systemGray6))
.cornerRadius(10)
.clipShape(RoundedRectangle(cornerRadius: 10))
.padding(.horizontal, 16)
.padding(.bottom, 10)
.transition(.move(edge: .top).combined(with: .opacity))
@@ -452,8 +405,8 @@ private struct CategoryChip: View {
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(isSelected ? Color.appPrimary : Color(.systemGray5))
.foregroundColor(isSelected ? .white : .primary)
.cornerRadius(20)
.foregroundStyle(isSelected ? .white : .primary)
.clipShape(RoundedRectangle(cornerRadius: 20))
}
.buttonStyle(.plain)
}
@@ -464,12 +417,12 @@ private struct MissingSourceView: View {
VStack(spacing: 12) {
Image(systemName: "exclamationmark.triangle")
.font(.system(size: 42))
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
Text("Source not available")
.font(.headline)
Text("This source was deleted.")
.font(.subheadline)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
.padding()
}
@@ -490,7 +443,7 @@ struct SourceRowView: View {
Image(systemName: source.category?.icon ?? "questionmark")
.font(.system(size: 18))
.foregroundColor(source.category?.color ?? .gray)
.foregroundStyle(source.category?.color ?? .gray)
}
// Source info
@@ -501,12 +454,12 @@ struct SourceRowView: View {
HStack(spacing: 8) {
Text(source.category?.name ?? "Uncategorized")
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
if source.needsUpdate {
Label("Needs update", systemImage: "clock.fill")
.font(.caption2.weight(.semibold))
.foregroundColor(.appWarning)
.foregroundStyle(Color.appWarning)
.padding(.horizontal, 8)
.padding(.vertical, 3)
.background(Color.appWarning.opacity(0.12))
@@ -529,7 +482,7 @@ struct SourceRowView: View {
Text(String(format: "%.1f%%", NSDecimalNumber(decimal: source.totalReturn).doubleValue))
.font(.caption)
}
.foregroundColor(source.totalReturn >= 0 ? .positiveGreen : .negativeRed)
.foregroundStyle(source.totalReturn >= 0 ? Color.positiveGreen : Color.negativeRed)
}
}
.padding(.vertical, 4)
@@ -147,7 +147,7 @@ struct QuickUpdateShareView: View {
ProgressView()
Text(String(localized: "ext_scanning_image"))
.font(.subheadline)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
} else {
TextField(String(localized: "ext_amount_placeholder"), text: $amountText)
@@ -157,7 +157,7 @@ struct QuickUpdateShareView: View {
if scanFoundNothing {
Text(String(localized: "ext_ocr_no_amounts"))
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
}
}
@@ -187,7 +187,7 @@ struct QuickUpdateShareView: View {
lineWidth: 1
)
)
.foregroundColor(.primary)
.foregroundStyle(.primary)
}
.buttonStyle(.plain)
}
@@ -205,18 +205,18 @@ struct QuickUpdateShareView: View {
HStack {
VStack(alignment: .leading, spacing: 2) {
Text(source.name)
.foregroundColor(.primary)
.foregroundStyle(.primary)
.font(.subheadline.weight(.medium))
if source.updatedThisMonth {
Text(String(localized: "ext_already_updated"))
.font(.caption2)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
}
Spacer()
if selectedSourceId == source.id {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.blue)
.foregroundStyle(.blue)
}
}
}
@@ -229,12 +229,12 @@ struct QuickUpdateShareView: View {
VStack(spacing: 14) {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 52))
.foregroundColor(.green)
.foregroundStyle(.green)
Text(String(localized: "ext_saved_title"))
.font(.headline)
Text(String(localized: "ext_saved_body"))
.font(.subheadline)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
.padding(32)
@@ -26,7 +26,7 @@ final class ReviewPromptServiceTests: XCTestCase {
reviewRequestHandler: { didRequest = true }
)
service.recordMonthlyCheckInCompleted()
// Rating velocity rule (1.6.x): 2 check-ins are enough, not 3.
service.recordMonthlyCheckInCompleted()
XCTAssertFalse(didRequest)
@@ -0,0 +1,126 @@
import XCTest
@testable import PortfolioJournal
/// The watch payload travels between processes and devices, so its encoding and
/// the formatting the complications rely on are worth pinning down.
final class WatchPortfolioSnapshotTests: XCTestCase {
private func makeSnapshot(
goals: [WatchPortfolioSnapshot.Goal] = [],
totalValue: Double = 1_000
) -> WatchPortfolioSnapshot {
WatchPortfolioSnapshot(
generatedAt: Date(timeIntervalSince1970: 1_770_000_000),
currencyCode: "EUR",
totalValue: totalValue,
monthChange: 250,
monthChangePercent: 0.025,
sources: [],
goals: goals,
currentStreak: 3,
bestStreak: 9,
checkInDone: true,
checkInMonthLabel: "August 2026"
)
}
private func makeGoal(name: String, current: Double, target: Double) -> WatchPortfolioSnapshot.Goal {
WatchPortfolioSnapshot.Goal(
id: UUID(),
name: name,
categoryName: "Cash",
categoryIcon: "banknote.fill",
categoryColorHex: "#6B7280",
currentValue: current,
targetValue: target
)
}
// MARK: - Transport
func testApplicationContextRoundTrip() throws {
let snapshot = makeSnapshot(goals: [makeGoal(name: "Cash", current: 40_000, target: 100_000)])
let context = try XCTUnwrap(snapshot.applicationContext())
let decoded = try XCTUnwrap(WatchPortfolioSnapshot.from(applicationContext: context))
XCTAssertEqual(decoded, snapshot)
}
func testApplicationContextIgnoresUnrelatedPayload() {
XCTAssertNil(WatchPortfolioSnapshot.from(applicationContext: ["somethingElse": 1]))
}
func testEmptySnapshotReportsNoData() {
XCTAssertFalse(WatchPortfolioSnapshot.empty.hasData)
XCTAssertTrue(makeSnapshot().hasData)
}
// MARK: - Goals
func testProgressIsClampedBetweenZeroAndOne() {
XCTAssertEqual(makeGoal(name: "Over", current: 150, target: 100).progress, 1)
XCTAssertEqual(makeGoal(name: "Negative", current: -50, target: 100).progress, 0)
XCTAssertEqual(makeGoal(name: "Half", current: 50, target: 100).progress, 0.5)
}
func testProgressWithoutTargetIsZero() {
XCTAssertEqual(makeGoal(name: "No target", current: 50, target: 0).progress, 0)
}
/// The single-goal complication must skip goals already reached.
func testFeaturedGoalSkipsAchievedGoals() {
let achieved = makeGoal(name: "Done", current: 100, target: 100)
let open = makeGoal(name: "Cash buffer", current: 40, target: 100)
let snapshot = makeSnapshot(goals: [achieved, open])
XCTAssertEqual(snapshot.featuredGoal?.name, "Cash buffer")
}
func testFeaturedGoalFallsBackToFirstWhenAllAchieved() {
let first = makeGoal(name: "First", current: 100, target: 100)
let second = makeGoal(name: "Second", current: 200, target: 200)
XCTAssertEqual(makeSnapshot(goals: [first, second]).featuredGoal?.name, "First")
}
func testFeaturedGoalIsNilWithoutGoals() {
XCTAssertNil(makeSnapshot().featuredGoal)
}
// MARK: - Formatting
func testCompactFormatShortensLargeAmounts() {
func compact(_ value: Double) -> String {
WatchPortfolioSnapshot.format(value, currencyCode: "EUR", compact: true)
}
XCTAssertTrue(compact(750).contains("750"))
XCTAssertTrue(compact(1_500).contains("1.5K"))
XCTAssertTrue(compact(25_000).contains("25K"))
XCTAssertTrue(compact(2_000_000).contains("2.0M"))
XCTAssertTrue(compact(-1_500).hasPrefix("-"))
}
func testMonthChangeCarriesExplicitSign() {
let positive = makeSnapshot()
XCTAssertTrue(positive.formattedMonthChange().hasPrefix("+"))
XCTAssertEqual(positive.formattedMonthChangePercent, "+2.5%")
let negative = WatchPortfolioSnapshot(
generatedAt: Date(),
currencyCode: "EUR",
totalValue: 1_000,
monthChange: -250,
monthChangePercent: -0.025,
sources: [],
goals: [],
currentStreak: 0,
bestStreak: 0,
checkInDone: false,
checkInMonthLabel: ""
)
XCTAssertTrue(negative.formattedMonthChange().hasPrefix("-"))
XCTAssertEqual(negative.formattedMonthChangePercent, "-2.5%")
}
}

Some files were not shown because too many files have changed in this diff Show More