71 Commits

Author SHA1 Message Date
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
alexandrev-tibco fa7df50400 Review prompt tras check-in: ≥2 meses de histórico, una vez por versión (build 85) #41
- ReviewRequestService: gate por meses distintos con snapshots + versión
- QuickUpdateView.saveAll y AddSnapshotView.performSave (solo alta, no edición) piden reseña via \.requestReview tras cerrar el sheet
- Motivo: 0 ratings visibles en US, 1 en ES — la ficha no convierte

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VXtwQP6nYnbLFVqsHiTK5G
2026-09-01 11:23:31 +02:00
alexandrev-tibco 66dc854461 Fix instrumentación: snapshot_added también con valor inicial en AddSourceView (build 85) #39
El snapshot creado desde el valor inicial no registraba snapshot_added en Firebase — el funnel de activación quedaba infracontado justo tras el cambio de la 1.6.1 que empuja a ese camino.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VXtwQP6nYnbLFVqsHiTK5G
2026-08-26 14:29:04 +02:00
alexandrev-tibco f2f54a5ca3 Activación: valor inicial como primer check-in + CTA en Quick Update vacío (build 84) #37
- AddSourceView: sección de valor reencuadrada como 'Valor de hoy' (primer check-in); si se omite, abre AddSnapshotView automáticamente en vez de dejar al usuario en el dashboard vacío
- QuickUpdateView: estado sin fuentes ahora tiene CTA 'Añade tu primera fuente' → .openAddFirstSource
- 3 claves nuevas localizadas en los 7 idiomas
- Bump a 1.6.1 (84)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VXtwQP6nYnbLFVqsHiTK5G
2026-08-23 12:37:58 +02:00
alexandrev-tibco 3b1f62cc26 Share cards compartibles: brand header grande, fecha, Dark/Light (build 83) #35
- Header de marca arriba, grande (logo BrandMark 48px + nombre title2 rounded),
  nunca se corta en Story (9:16 real 360x640, Post 4:5 360x450).
- Héroe = retorno % (privacy/branding-friendly), valor como apoyo.
- Fecha del último check-in bajo el nombre (share_as_of) para dar contexto a
  quien no conoce la app; DashboardViewModel.shareAsOfDate.
- Tema Dark + Light (ShareCardTheme) con selector en el sheet + preview vivo.
- Sparkline real (viewModel.evolutionData), ya se pasaba.
- CTA de descarga + QR con ct=snapshot_share (atribución App Analytics).
- L10n x7 (as_of, theme_dark/light, cta, hero, metrics).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L38583J7AYWCVPevkivscj
2026-08-07 09:45:10 +02:00
alexandrev-tibco 8084bc6a82 Share cards 2.0: dark panorama + privacidad + Story + atribución (build 82) #34
Rediseño de las cards de compartir con el sistema visual de las screenshots
ASO: fondo navy con glows y grid, sparkline de evolución con gradiente
azul→cian y punto final, chip de delta, footer con marca + QR. Aplica a
Portfolio Snapshot, Monthly Check-in y Monthly Summary.

Potencia referral: sheet de opciones previo con preview en vivo
(PortfolioShareOptionsView) — toggle «Solo porcentajes» (sin importes
absolutos, la fricción #1 para compartir) y formato Post/Story 9:16. QR y
link con ?ct=snapshot_share para atribución en App Analytics. Analytics:
share type portfolio_value_percent. L10n ×7.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L38583J7AYWCVPevkivscj
2026-08-06 23:09:59 +02:00
alexandrev-tibco 6e6f4c5489 Dashboard: quitar títulos duplicados en cards de gráficas (build 81) #33
showsTitle (default true) en HorizontalPerformanceChart, DrawdownChart,
VolatilityChartView, PredictionChartView y YearOverYearChartView; el
pageContent del Dashboard pasa false porque el header de la card ya nombra
la página. La sección Charts no cambia.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L38583J7AYWCVPevkivscj
2026-08-06 22:55:27 +02:00
alexandrev-tibco cf984dbf09 Quitar swipe entre charts + ventana del brush deslizable (build 80) #31 #32
Feedback TF build 79: fuera el swipe entre gráficas (navegación = chip bar;
el gesto chocaba con scrub/pan/brush). Brush con gesto unificado decidido en
touch-down: cerca de un asa (18pt) redimensiona; en cualquier otro punto
(dentro de la ventana o sobre el historial atenuado) desliza la ventana entera
manteniendo el ancho — seleccionar ene–mar y arrastrar hasta jun–ago. Con
ventanas estrechas las asas solo agarran desde fuera, el interior siempre mueve.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L38583J7AYWCVPevkivscj
2026-08-06 21:46:07 +02:00
alexandrev-tibco f1aeafccf6 Brush global de rango: filtro de datos para todos los charts + fix swipe (build 79)
Feedback TestFlight build 78: arrastrar las asas del minimapa disparaba el
swipe entre charts (el brush vivía dentro de chartContent, bajo el
simultaneousGesture del swipe). El brush sale del chart y pasa a la barra de
periodo, fuera del área con gesto.

Y de paso, el cambio de diseño pedido: la ventana elegida ya no es zoom visual
por-chart sino ChartsViewModel.customRange — filtra los DATOS de todas las
gráficas (evolution, contributions, rolling, drawdown, volatility, cashflow,
comparison, YoY, prediction). Sparkline del brush desde fullHistorySeries
(agregación mensual extraída de calculateEvolutionData como monthlySeries).
Presets limpian el rango custom; selección completa = sin filtro. El pinch
por-chart se mantiene como zoom visual.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L38583J7AYWCVPevkivscj
2026-08-06 20:33:58 +02:00
alexandrev-tibco d941ecf27b Fix ITMS-90626: quitar 'Siri' de descripciones de App Intents + build 78
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L38583J7AYWCVPevkivscj
2026-08-06 20:11:58 +02:00
alexandrev-tibco 9c2a67853d Bump build 77 (TestFlight: charts workable)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L38583J7AYWCVPevkivscj
2026-08-06 18:07:23 +02:00
alexandrev-tibco d38c6943e1 Charts iPhone workable: chip bar, ventana+brush, header vivo, drill-down, overlay #29 #30
Fix look&feel (#29): chip bar selector visible (sustituye page dots + title menu
como navegación primaria), sin bubbles permanentes en compact (solo endpoint),
Performance a barras horizontales con >6 categorías, línea con gradiente y
estética de widget, pinch anclado al centro + doble-tap reset + haptics.

Workable (#30): modelo de ventana (inicio,fin) único para pinch/pan/brush;
minimapa bajo demanda con asas snap-a-mes y ventana arrastrable; label de rango
con pickers mes/año precisos; header vivo (valor+delta del rango, scrub con
haptic); drill-down por tap en Allocation/Performance → Evolution filtrada
(premium, chip Filtrado ✕ para volver); overlay aportaciones acumuladas
(premium); insight contextual del rango visible (premium). L10n ×7.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L38583J7AYWCVPevkivscj
2026-08-06 18:03:50 +02:00
alexandrev-tibco 525911f67a Eliminar propiedad muerta sourcesByCategory (force-unwrap latente) #28
categories.first! petaría con una fuente sin categoría y categories vacío
(plausible en sync CloudKit). La propiedad no la consumía ninguna vista.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L38583J7AYWCVPevkivscj
2026-08-02 15:21:36 +02:00
alexandrev-tibco 326be6db04 1.6.0 #10 ocultar saldos + Face ID · #11 widget interactivo + Siri
#10 Ocultar saldos: BalancePrivacyManager (balancesHidden + requireBiometricToReveal),
modifier .hiddenBalance() (blur + máscara de bullets, ancho estable), botón ojo en Dashboard,
revelar con Face ID vía AppLockService, sección Privacidad en Settings. Aplicado a total
portfolio, forecast, sources, goals, monthly summary.

#11 Siri/App Intents: NetWorthIntent ('¿cuál es mi patrimonio?' → 'Tu patrimonio es X'),
StreakIntent, 5 AppShortcuts con frases. Widget interactivo iOS 17: botón Refresh
(Button(intent:) → reloadAllTimelines, in-process) en medium/large, gated #available(iOS 17).
Reader read-only del AppGroup. Dialogs Siri refactorizados a String(localized:).

Localización: strings de #10 y de Siri en los 7 idiomas.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L2p3gUZRNWW388rWFRjiU7
2026-07-25 17:52:09 +02:00
alexandrev-tibco 648c2bd805 1.6.0 #9: auto-backup programado a iCloud Drive/Archivos
El BackupService (export JSON v3 lossless a local + iCloud ubiquity, retención,
restore, premium-gated) ya existía de 1.1.0 pero solo era manual. Añadido lo automático:
- BackupService.runScheduledBackupIfNeeded() (config vía UserDefaults, gate por
  backupsEnabled+premium, respeta frecuencia).
- Disparo en foreground (scenePhase .active) en background queue.
- Frecuencia Off/Diario/Semanal/Mensual + fecha del último backup en Settings
  (SettingsViewModel.autoBackupFrequencyDays + updateAutoBackupFrequency).
- Strings en 7 idiomas.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L2p3gUZRNWW388rWFRjiU7
2026-07-25 17:31:53 +02:00
alexandrev-tibco 6887d05b01 1.6.0 #6+#7: proyección/ETA de objetivos + insights inteligentes
Goal ETA (#6): GoalProjection engine (CAGR mensual robusto de la evolución +
aportaciones, proyecta mes a mes → fecha estimada, estado ahead/on-track/behind/
unreachable, sparkline). GoalsView muestra 'Projected: <mes>', chip de ritmo con color,
fallback 'Add contributions to reach this'. GoalsViewModel.projection(for:) cacheado.

Insights (#7): InsightsEngine rule-based on-device (sin red/AI). Reglas: allocation vs
target, riesgo de concentración, mejor/peor categoría, diversificación, racha, YTD,
ganancias, forecast, proximidad a milestone. Priorizados por tono, top 4. PortfolioInsight
gana detail + tone. Surface en el Dashboard.

Localización: 19 claves nuevas en los 7 idiomas.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L2p3gUZRNWW388rWFRjiU7
2026-07-25 12:22:12 +02:00
alexandrev-tibco fd1094b4fc 1.6.0 #3: limpieza CloudKit-safe de duplicados de defaults (merge + Nullify)
Reglas de borrado Account.sources/goals y Category.sources: Cascade -> Nullify.
Así un borrado de padre (local o propagado por CloudKit al importar) NUNCA arrasa
hijos — elimina de raíz el mecanismo del desastre del build 68. Además es mejor UX
(borrar una categoría ya no borra todas sus sources+snapshots).

CoreDataStack.mergeDefaultDuplicates(): fusiona los Account 'Default' duplicados y
las categorías duplicadas por nombre en un ganador DETERMINISTA (el registro con el
UUID estable si existe, si no earliest createdAt / menor UUID → todos los dispositivos
convergen). Reasigna sources/goals al ganador y borra los perdedores (seguro por Nullify).
Llamado en la carga inicial y tras cada import remoto.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L2p3gUZRNWW388rWFRjiU7
2026-07-25 12:18:42 +02:00
alexandrev-tibco 922710c53f 1.6.0: auto-rellenar huecos (snapshots estimados) + widgets/lock screen enriquecidos
Auto-relleno (#5): SnapshotRepository.autoFillGap crea snapshots interpolados
linealmente entre los dos snapshots que bordean el hueco, marcados isEstimated.
deleteEstimatedSnapshots para borrado en bloque. Botón 'Autocompletar N meses'
(wand.and.stars) en DataGapsSheet + 'Añadir manualmente'. Idempotente. Strings 7 idiomas.

Widgets/Lock Screen (#8, vía agente): familias enriquecidas — systemSmall/Medium/Large
con net worth + cambio desde check-in + racha + próximo check-in + progreso de objetivo;
lock screen accessoryCircular (gauge objetivo/milestone), accessoryRectangular, accessoryInline.
Deep link quickupdate preservado. Solo InvestmentWidget.swift.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L2p3gUZRNWW388rWFRjiU7
2026-07-25 12:13:51 +02:00
alexandrev-tibco 1a3fce0df4 1.6.0 foundation: dedup prevención completa + atributo isEstimated
Dedup prevención: UUIDs estables (StableID v5) asignados al default Account y a las
categorías por defecto en createDefaultAccountIfNeeded, ensureDefaultCategoriesExist,
createDefaultCategories y el reset de SettingsViewModel → CloudKit fusiona nativamente,
no más acumulación de duplicados.

isEstimated: nuevo atributo Bool opcional (default NO, syncable) en Snapshot para marcar
snapshots interpolados del auto-relleno. Requiere deploy de schema CloudKit antes del
release. Compila verde (verificado antes de que el agente de widgets empezara a editar).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L2p3gUZRNWW388rWFRjiU7
2026-07-25 12:09:59 +02:00
alexandrev-tibco eee0910680 Rama 1.6.0: dedup CloudKit-safe (WIP: StableID + UUIDs estables en defaults)
Funde 1.5.1 en 1.6.0. Parte 1 dedup (prevención): StableID (UUIDv5 namespace+nombre),
Account.defaultAccountStableID, Category.stableID(for:) + asignación en
createDefaultCategories. Pendiente: asignar en repos + limpieza de duplicados existentes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L2p3gUZRNWW388rWFRjiU7
2026-07-25 12:04:04 +02:00
alexandrev-tibco e4a93a5d4a Bump 1.5.1 (build 76)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L2p3gUZRNWW388rWFRjiU7
2026-07-25 11:47:53 +02:00
alexandrev-tibco 20a73ea957 ASO: renderers dark-panorama (iPhone + iPad) para screenshots 1.5.0
frame_panorama_codex.py / _ipad.py: panorámica premium oscura (Impact, marco real,
+22.7% verde, línea continua) con copy localizado nativo en 7 idiomas. Usado para
las screenshots publicadas de la 1.5.0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L2p3gUZRNWW388rWFRjiU7
2026-07-25 11:47:52 +02:00
alexandrev-tibco 8d5f309f21 Bump build 75
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L2p3gUZRNWW388rWFRjiU7
2026-07-24 09:36:51 +02:00
alexandrev-tibco 84d9240ee6 1.5.0: aviso de huecos + resumen mensual compartible + import con vista previa
Feature 1 — Aviso de huecos: SnapshotGapDetector detecta huecos internos por
source (meses sin datos entre dos snapshots). Banner desechable en Dashboard +
DataGapsSheet para rellenar (abre AddSnapshotView con el mes que falta). Complementa
la interpolación de gráficas: la línea se ve suave pero el usuario sabe que faltan
datos reales.

Feature 2 — Resumen mensual compartible: MonthlySummaryShareView (tarjeta con marca:
valor, variación desde el check-in, racha, mood/rating) renderizada con ImageRenderer
y compartida vía ShareService. Botón en la fila "check-in done" del Dashboard.

Feature 3 — Import con vista previa: ImportPreview + previewImportAsync (dry-run
READ-ONLY, no muta nada) cuenta qué se creará/actualizará usando los mismos checks
de existencia que applyImport. ImportDataView muestra la confirmación antes de
importar. El path real de import (applyImport/importDataAsync) NO se toca.

Localización en los 7 idiomas.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L2p3gUZRNWW388rWFRjiU7
2026-07-24 09:36:50 +02:00
alexandrev-tibco 8d7cfc098b Bump build 74
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L2p3gUZRNWW388rWFRjiU7
2026-07-23 20:13:22 +02:00
alexandrev-tibco df9309895a Export/import JSON lossless (v3): goals, monthly contributions, journal, ratings
El JSON v2 perdía Goals, source.monthlyContribution, category.allocationTarget y
todo el Journal (nota/mood/rating/completionTime). Nuevo formato v3:
- source: + monthlyContribution, customFrequencyMonths
- category: + allocationTarget
- top-level: + goals[] (name/targetAmount/targetDate/isActive/account)
- top-level: + journalEntries[] (monthKey/note/mood/rating/completionTime/createdAt)

Import parsea y aplica todo, creando Goals y JournalEntry DIRECTAMENTE en el
contexto de background (GoalRepository es @MainActor → no usable desde el import).
Dedup: goals por nombre+cuenta, journal por monthKey → reimport idempotente.
Retrocompatible con JSON v2. CSV sin cambios.

Guard: si el import trae journal, se salta el marcado de completions derivado de
snapshots (evita JournalEntry duplicados por el desfase de merge viewContext).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L2p3gUZRNWW388rWFRjiU7
2026-07-23 20:13:21 +02:00
alexandrev-tibco b60e3041cb Bump build 73
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L2p3gUZRNWW388rWFRjiU7
2026-07-23 19:51:27 +02:00
alexandrev-tibco cbcb1da0ac Feature: rellenar huecos entre snapshots en las gráficas (interpolación lineal)
Nueva utilidad ChartGapFill: para cada source, rellena los meses sin snapshot
entre dos conocidos con interpolación lineal (Feb=100k, Jun=130k → Mar/Abr/May
~110/120/130k), arrastra el último valor tras el último snapshot y no aporta
antes del primero. Solo afecta al pintado de gráficas — NO crea ni modifica
snapshots (cero riesgo para la sync iCloud). Aplicado a las evolution del
Dashboard (total + por categoría) y de Charts. Toggle en Settings "Smooth Gaps
in Charts" (ON por defecto), localizado en los 7 idiomas.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L2p3gUZRNWW388rWFRjiU7
2026-07-23 19:51:26 +02:00
alexandrev-tibco fc05dfe2c6 Bump build 72
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L2p3gUZRNWW388rWFRjiU7
2026-07-23 18:59:37 +02:00
alexandrev-tibco 047060f126 Fix check-ins no marcados al importar (setCompletionDate en hilo equivocado)
El import corre en un contexto de background, pero MonthlyCheckInStore muta
viewContext (main). Llamar setCompletionDate desde el background era una
violación de concurrencia de Core Data → los JournalEntry.completionTime de los
meses importados no persistían, dejando el "siguiente check-in" anclado a una
entrada vieja (p.ej. May 2025). Envuelto el marcado en viewContext.performAndWait
para ejecutarlo en la cola correcta.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L2p3gUZRNWW388rWFRjiU7
2026-07-23 18:59:36 +02:00
alexandrev-tibco 2ea32b5494 Bump build 71
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L2p3gUZRNWW388rWFRjiU7
2026-07-23 11:38:33 +02:00
alexandrev-tibco d7149aad67 Fix ANR al terminar el import CSV (save + widget refresh por cada fila)
createSnapshot hacía save() por cada snapshot, y save() dispara refreshWidgetData
→ checkpointWAL (performAndWait en el viewContext main) + reloadAllTimelines. Con
213 snapshots eran 213 saves + 213 checkpoints en el main → Application Not
Responding. Añadido modo batch a createSnapshot (no guarda por fila; applyImport
ya guarda el contexto una vez al final) y flag suppressWidgetRefresh en
CoreDataStack para neutralizar el refresh por fila durante el import, con un único
refreshWidgetData al terminar.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L2p3gUZRNWW388rWFRjiU7
2026-07-23 11:38:32 +02:00
alexandrev-tibco 57280ca3ca Bump build 70
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L2p3gUZRNWW388rWFRjiU7
2026-07-23 11:02:57 +02:00
alexandrev-tibco 9539803d74 Fix crash al abrir Import Data en Mac Catalyst (@EnvironmentObject no heredado en sheets)
ImportDataView usa @EnvironmentObject (iapService/accountStore/tabSelection).
En Mac (Designed for iPad) las sheets no heredan el environment vía
PresentationHostingController → EnvironmentObject.error() / SIGTRAP al abrir
Import Data (ImportDataView.swift:247, shouldShowAccountSelection). Reinyectados
los 3 objects en las sheets de Settings, Dashboard y Onboarding.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L2p3gUZRNWW388rWFRjiU7
2026-07-23 11:02:56 +02:00
alexandrev-tibco d50926df76 Bump build 69 + cambios pendientes (scheme, AppShortcuts)
Build 69: primer build sin el auto-dedup destructivo. Base limpia para
recuperar los dispositivos vía TestFlight tras el incidente de build 68.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L2p3gUZRNWW388rWFRjiU7
2026-07-22 19:58:24 +02:00
alexandrev-tibco 99dc855467 Desactiva auto-dedup (pérdida de datos cross-device) + forward-fill en gráficas de evolution
CRÍTICO: el auto-dedup de build 68 borraba entidades padre con regla Cascade
(Account/Category). Al propagarse el delete por CloudKit, otros dispositivos
aplicaban la cascada al importar ANTES de la reasignación de hijos → wipe de
sources/snapshots. Vació iPhone y iPad. Quitadas las llamadas automáticas a
cleanupLogicalDuplicates() en processRemoteChanges y en la carga; el UUID-dedup
seguro se mantiene. Métodos quedan definidos para un rediseño CloudKit-safe.

Gráficas: forward-fill por source en evolution/allocation/period para que el
total de cada mes refleje el último valor de CADA source (no solo las
actualizadas ese mes) y el último punto coincida con el total del portfolio.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L2p3gUZRNWW388rWFRjiU7
2026-07-22 19:58:13 +02:00
alexandrev-tibco 20b8a32ea3 Fix duplicados CloudKit cross-device: dedup por identidad lógica + bump build 68
cleanupLogicalDuplicates() colapsa registros con misma clave de negocio pero
distinto UUID (defaults sembrados por-dispositivo antes del primer import):
Account/Category por name, InvestmentSource por account+name, Snapshot por
source+día+valor, Goal por name+target, JournalEntry por monthKey. Ganador
determinista (createdAt asc, tiebreak UUID) → todos los dispositivos eligen el
mismo superviviente y los deletes convergen sin race. Reasigna hijos al ganador
antes de borrar. Snapshot dedup produce la UNIÓN → sin pérdida de historial.
Cableado en processRemoteChanges (tras import) + carga inicial (limpia lo ya
existente). El dedup previo por UUID no veía estos casos.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L2p3gUZRNWW388rWFRjiU7
2026-07-22 15:56:41 +02:00
alexandrev-tibco d87d9d3dd5 Debug helper: initializeCloudKitSchema (opt-in por launch arg -InitializeCloudKitSchema)
Materializa el modelo completo en el esquema de Development de CloudKit para poder desplegar
a Production los campos que nunca se exportaron (monthlyContribution, allocationTarget). Sin ellos,
Production rechaza los registros y el export falla en bloque (CKErrorPartialFailure).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LZFmGhbWzibhApev3C4554
2026-07-22 09:56:33 +02:00
alexandrev-tibco f34460d97a Diagnóstico iCloud: dump completo del árbol CKError (códigos hoja + record types) + sonda de integridad local
deepErrorReport() sigue NSUnderlyingError y CKPartialErrorsByItemIDKey y extrae el recordType
del CKRecord embebido → dice qué entidad rechaza CloudKit. integrityReport() lista counts por
entidad + registros con id/createdAt nil u orphan snapshots. Ambos en el diagnóstico copiable.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LZFmGhbWzibhApev3C4554
2026-07-21 18:00:22 +02:00
alexandrev-tibco e916218271 Bump build 66
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LZFmGhbWzibhApev3C4554
2026-07-21 15:23:43 +02:00
alexandrev-tibco 7eb7cb81d4 Fix sync iCloud del Goal: accessor nil-tolerante en createdAt (Goal/Account/InvestmentSource)
El modelo declara createdAt optional=YES pero las clases lo tenían @NSManaged var createdAt: Date
(no-opcional). Al materializar un registro importado por CloudKit con createdAt transitoriamente
nil, el acceso revienta el merge → contribuye al CKErrorPartialFailure y a que el Goal no sincronice.
Mismo patrón safeValue/setManagedValue ya aplicado a Snapshot (hotfix b76b8f8) que no cubrió estas 3.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LZFmGhbWzibhApev3C4554
2026-07-21 15:22:42 +02:00
alexandrev-tibco 137931b37c Fix upload script: limpieza .p8 vía TRAPEXIT global (bug zsh: trap EXIT en función se dispara al retornar, borraba la key antes del archive)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LZFmGhbWzibhApev3C4554
2026-07-21 13:58:20 +02:00
alexandrev-tibco 32e1fb1c9c Bump build 65
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LZFmGhbWzibhApev3C4554
2026-07-21 13:56:39 +02:00
alexandrev-tibco 95c689d2b8 Home: carrusel de charts con gating premium (candado) + aviso de próximo check-in
- EvolutionChartCard: swipe entre 7 charts (evolution, allocation libres; performance,
  drawdown, volatility, prediction, year-over-year premium). No premium ve las premium
  con candado + CTA de unlock; premium las ve reales. Reutiliza ChartsViewModel (computa
  solo la página visible) y las vistas del feature Charts.
- MonthlyCheckInCard: aviso de próximo check-in siempre visible (fecha + badge de
  días restantes/atrasado), con fallback cuando no hay check-in previo.
- Localización de nuevas cadenas en los 7 idiomas.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LZFmGhbWzibhApev3C4554
2026-07-21 09:39:26 +02:00
alexandrev-tibco 2a5ffca48f Fix script upload App Store: .p8 en ruta fija (mktemp roto) + ExportOptions firma manual con 3 perfiles
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LZFmGhbWzibhApev3C4554
2026-07-20 21:20:43 +02:00
alexandrev-tibco 1e6e3ef361 Re-engagement con KPIs reales (goal ETA, YoY, mes, milestone, racha) + rotación (build 64)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LZFmGhbWzibhApev3C4554
2026-07-20 19:29:27 +02:00
claude-code 8f14d4cd06 Add Crashlytics BigQuery report script
Consulta el export de Crashlytics en BigQuery (dataset firebase_crashlytics,
proyecto portfoliojournal-ef2d7) con el service account de pass. Top crashes por
versión/build. Setup documentado en el docstring.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WoScpmHdVj1aUf4rAp6hbe
2026-07-13 00:54:07 +02:00
alexandrev-tibco 41e2a22a64 Swipe fiable en card del Home y Charts: simultaneousGesture sobre la gráfica (build 63)
El swipe entre páginas del Home (build 62) y entre charts (build 60) no
funcionaba encima de la gráfica: su DragGesture de scrub (minimumDistance 0)
consumía el gesto. Cambiado a .simultaneousGesture — ambos se reconocen; la
dominancia horizontal en onEnded evita romper el scroll vertical. Ahora el swipe
funciona en toda la card, también sobre el chart.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WoScpmHdVj1aUf4rAp6hbe
2026-07-11 10:50:52 +02:00
alexandrev-tibco d8bd625327 Swipe entre mini-charts en la card del Home (build 62)
El feedback #4 original apuntaba a la card 'Portfolio Evolution' del HOME, no
solo a la pestaña Charts (donde ya se hizo en build 60). Ahora la card del Home
se desliza entre páginas:
- Evolution (con su toggle Total/By Category intacto)
- Allocation (semicírculo + leyenda con %), datos de viewModel.categoryMetrics
Puntos indicadores, transición direccional, título dinámico. 'By Category' no
es página aparte porque ya es un toggle dentro de Evolution.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WoScpmHdVj1aUf4rAp6hbe
2026-07-10 22:13:00 +02:00
alexandrev-tibco 7c6a29c821 OCR desde imagen del portapapeles + botón copiar diagnóstico iCloud (build 61)
- Quick Update: checkClipboard ahora detecta también una IMAGEN copiada
  (pb.hasImages) y hace OCR automático — te ofrece pegar el importe detectado
  sin pulsar 'Escanear'. Gated por changeCount. El 'Pegar €X' del teclado
  también usa el importe OCR'd de la imagen.
- Settings iCloud: botón 'Copiar diagnóstico de iCloud' SIEMPRE visible (el
  desplegable de detalle solo salía con un error activo, difícil de encontrar).
  Copia versión/build, contadores locales, últimas fechas import/export,
  criticidad, hint y el error completo con códigos internos. Strings ×7.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WoScpmHdVj1aUf4rAp6hbe
2026-07-10 21:52:53 +02:00
alexandrev-tibco 6bdf2740c5 Feedback #3 y #4: pegar/OCR por campo en Quick Update + swipe entre charts (build 60)
#3 Quick Update paste/scan por campo:
- Barra de teclado con 'Pegar €X' (si hay importe en portapapeles) y 'Escanear'
  (PhotosPicker → OCR on-device). Actúan sobre el campo enfocado.
- ImageAmountScanner nuevo (app target, Vision) espejo del scanner del Share
  Extension: candidatos rankeados por prominencia, filtra % y años.
- 1 candidato → rellena directo; varios → confirmationDialog para elegir.
- Strings ×7.

#4 Swipe entre charts (iPhone):
- Gesto horizontal en el área del chart avanza al chart anterior/siguiente en
  el orden del menú de título (respeta showForecast). Indicador de página con
  puntos. Transición direccional. selectChart ya no bloquea premium (muestra
  teaser), así que deslizar a uno premium funciona.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WoScpmHdVj1aUf4rAp6hbe
2026-07-10 21:37:21 +02:00
alexandrev-tibco 86c95a9e73 Feedback TestFlight build 58 (build 59): labels, needs-update, period selector, Home masonry
De 7 comentarios de TestFlight:
- Labels de línea ilegibles (puntos 5+7): ChartLabels.showsLabel thin-out
  (máx 5 iPhone / 8 iPad, siempre primero y último). Charts multi-serie
  (Compare, Period, Year vs Year) etiquetan solo el ENDPOINT de cada serie.
  Prediction etiqueta solo el forecast final. Aplicado a Evolution, Rolling,
  Drawdown, Volatility, Compare, Period, YoY, Prediction.
- 'Needs update' en meses pasados (punto 2): MonthlyCheckInView.snapshotForViewedMonth
  — el estado es por el MES QUE SE VE (effective month de referenceDate), no
  contra la última completion global. Diff usa el snapshot de ese mes.
- Selector de periodo Performance (punto 6): slider 1..60 → picker segmentado
  consistente con el resto, opciones capadas a los meses de datos disponibles
  (availableHistoryMonths). Ya no deja pedir ventanas sin datos.
- Home iPad/Mac (feedback previo): masonry con nº de columnas según ancho
  (2 iPad / 3 Mac ancho >=1250), reparto por peso, propio ScrollView.

Pendiente de este lote: Goals no sincronizan (verificado modelo Goal CloudKit-OK
→ es el subset del partial-failure, no filtro ni esquema; necesita códigos
internos del error), paste/OCR por campo (punto 3) y swipe entre charts (punto 4).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WoScpmHdVj1aUf4rAp6hbe
2026-07-10 20:36:15 +02:00
alexandrev-tibco 1abffdb7bb Feedback iPad: allocation grande + %, Home 2 columnas, YoY KPIs, achievements fix, iCloud calmado (build 58)
Allocation:
- Semicírculo responsive que llena su mitad de la fila (grande en iPad, no
  minúsculo en una esquina) + porcentaje dentro de cada segmento >=7%.
  SemicircleArc acepta center explícito; labels en el ángulo medio del arco.

Home iPad:
- Ya no columna única estrecha a 720. Hero + check-in a ancho completo, resto
  de cards en DOS columnas masonry independientes (sin acoplar alturas → sin
  gaps del grid). Cap 1100pt.

Year vs Year KPIs:
- Retorno del año actual + diferencia (pp) vs año anterior + mejor/peor año
  histórico. Usa forecastEndValue para el año en curso.

Fixes:
- 'View all achievements' en Journal no hacía nada: NavigationLink inline
  (frágil según contexto de navegación) → sheet fiable en cualquier sitio.
- iCloud: CKErrorPartialFailure tras una sync que funciona ya NO se pinta en
  rojo alarmante — nota calmada 'Sincronizando con iCloud' + detalle técnico
  en disclosure. Rojo solo si nunca ha sincronizado y es fallo total.
  syncErrorIsCritical + strings ×7.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WoScpmHdVj1aUf4rAp6hbe
2026-07-10 14:48:06 +02:00
alexandrev-tibco 18f2609d87 Fixes UI iPad + diagnóstico iCloud (build 57)
Feedback del usuario en iPad:
- Allocation: gráfica de tarta → semicírculo estilo hemiciclo (parlamento).
  SemicircleAllocation con arcos dibujados a mano (SemicircleArc Shape),
  segmentos por cuota en 180°, total/selección en el centro, tap por segmento.
- Charts de Analyze (Compare, Period vs Period, Year vs Year) ahora SÍ muestran
  KPIs en la cabecera de iPad como el resto: Compare (líder/rezagado por modo),
  Period (retorno final por periodo), YoY (retorno final por año seleccionado).
- Momentum & Streaks en el sidebar del Journal (iPad, 320pt): variante compact
  — tiles en fila sin subtítulos que rompían a 3 líneas, sin bloque de logros.

iCloud:
- CKErrorPartialFailure (CKErrorDomain code 2) mostraba un mensaje opaco.
  CoreDataStack.hint(for:) recorre CKPartialErrorsByItemIDKey y clasifica el
  código dominante → hint accionable (esquema/quota/red). Settings muestra el
  hint legible sobre el detalle técnico. lastSyncErrorHint publicado. Strings ×7.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WoScpmHdVj1aUf4rAp6hbe
2026-07-10 14:16:24 +02:00
alexandrev-tibco b76b8f81f0 Hotfix: accessors nil-tolerantes en Core Data — crashes de bridging con iCloud sync (build 56)
Dos crashes de producción con la misma causa raíz: atributos declarados
no-opcionales en Swift (@NSManaged id: UUID, date: Date) que llegan nil en
runtime — NSPersistentCloudKitContainer materializa registros de otros
dispositivos por fases y los objetos borrados pueden seguir referenciados por
vistas vivas. El force-bridge de nil revienta:

1. UUID._unconditionallyBridgeFromObjectiveC en ForEach (keypath Identifiable.id)
2. Date._unconditionallyBridgeFromObjectiveC en ChartsViewModel.monthlyTotals

Fix sistémico (no por call-site): NSManagedObject.safeValue(forKey:fallback:healing:)
lee el primitive y cae a un valor seguro; id se auto-cura (UUID nuevo escrito al
primitive sin ensuciar el objeto). Aplicado a:
- Snapshot: id (heal), date, createdAt (fallback distantPast)
- InvestmentSource / Goal / Account: id (heal), name (fallback "")
- Category: id (heal), name, colorHex, icon (fallbacks neutros)

Los accessors son @objc → NSSortDescriptor(keyPath:) y KVC siguen funcionando.
awakeFromInsert/awakeFromFetch se mantienen. Smoke test UITest en verde.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WoScpmHdVj1aUf4rAp6hbe
2026-07-10 13:28:59 +02:00
alexandrev-tibco 4c0d2af564 Calm 2.0 Fase 4: TipKit para descubrimiento + aha post-onboarding (build 55)
TipKit (Tips.configure con frecuencia diaria):
- QuickUpdateTip: popover en el '+' del Home — descubre Quick Update
- ScreenshotOCRTip: inline en Quick Update — descubre el OCR por captura
- SiriTip: en el cierre del check-in guiado — descubre el intent de voz
- ChartShareTip: popover en el compartir de Charts
- Se invalidan al ejecutar la acción; máx 1-2 impresiones cada uno
- Strings ×7 idiomas

Onboarding:
- Al completar el onboarding sin datos, se abre Add Source directamente
  (notificación .openAddFirstSource) — el usuario nuevo no aterriza en un
  dashboard vacío

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WoScpmHdVj1aUf4rAp6hbe
2026-07-10 09:31:03 +02:00
alexandrev-tibco 08094483ed What's New versionado y acumulativo (build 54)
El modal estaba congelado en las features de 1.4.0 (hardcodeadas en la vista) y
el título decía 'What's New in 1.4' fijo. Rediseñado como catálogo por versión:

- WhatsNewCatalog: una entrada por release (1.4.0/1.4.1/1.4.2/1.5.0) con sus
  features; AÑADIR ENTRADA CON CADA RELEASE (comentario en el código).
- Diff ACUMULATIVO desde lastSeenWhatsNewVersion: saltar 1.3.x → 1.4.2 muestra
  1.4.0 + 1.4.1 + 1.4.2 agrupadas con badge de versión (headers solo si hay
  más de una release pendiente). Comparación numérica de versiones (1.10 > 1.9).
- lastSeen vacío + onboarding ya completado (usuarios pre-1.4.0) = mostrar todo
  el catálogo; instalación nueva = lastSeen sella a la versión actual al
  completar onboarding (un usuario recién onboardeado no ve 'novedades').
- Sin entrada en el catálogo para el salto → no se muestra sheet.
- Título dinámico (whats_new_title_format) y contenido scrollable.
- Strings de 4 releases + título ×7 idiomas.

Verificado en simulador con -lastSeenWhatsNewVersion 1.4.0: pending
[1.5.0, 1.4.2, 1.4.1] y sheet agrupada correcta.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WoScpmHdVj1aUf4rAp6hbe
2026-07-10 08:06:49 +02:00
alexandrev-tibco ed737bc290 Calm 2.0 Fase 3: Home iPad columna única, Goals con anillos, Journal timeline, Sources polish (build 53)
Home iPad/Mac (feedback del usuario: 'me gusta más el iPhone'):
- Fuera el grid de 2 columnas (acoplaba alturas de fila y dejaba huecos entre
  cards de tamaños distintos) → columna única centrada a 720pt, patrón
  Things/Day One. Eliminado el drag-drop del grid, columnSpan y el overlay
  full-screen (~120 líneas); el orden/visibilidad sigue en Customize.

Goals:
- ProgressRing nuevo (anillo estilo Fitness con porcentaje centrado y color
  semántico: verde=logrado, ámbar=retrasado, acento=en camino) en GoalRowView
  y en la card de Goals del Home. GoalProgressBar queda solo en la share card.

Journal:
- Momentum & Streaks vive ahora en la cabecera del Journal (la historia del
  hábito es narrativa, no dashboard).
- Filas timeline: círculo de mood como ancla visual (tint semántico por mood),
  mes + estrellas + preview de nota.

Sources:
- Badge 'Needs update' como chip ámbar visible; valor en tipografía rounded.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WoScpmHdVj1aUf4rAp6hbe
2026-07-09 11:11:04 +02:00
alexandrev-tibco 7044cfd441 Calm 2.0 Fase 2: check-in guiado full-screen (build 52)
El ritual mensual como flujo paso a paso en vez de formulario:
- Una fuente por pantalla: valor anterior a la vista, entrada grande (40pt
  rounded), delta en vivo (chip verde/rojo con importe y %), sugerencia de
  portapapeles, 'Igual que el mes pasado' y 'Omitir'
- Barra de progreso del ritual; cancelable solo al inicio (interactiveDismiss
  bloqueado a mitad para no perder valores)
- Reflexión: mood (5 estados existentes) + nota de una línea
- Cierre: total nuevo, delta del mes, streak, compartir y haptic de éxito
- Persistencia por los MISMOS caminos que Quick Update (Snapshots) y el journal
  (MonthlyCheckInStore → JournalEntry): coherente con el resto de entradas
- Entrada: CTA del check-in card en Home (fullScreenCover); la fila de
  completado sigue navegando al journal
- Fix: referenceDate congelada en @State al presentar — el card la recalcula al
  guardar y SwiftUI re-evaluaba el contenido del cover cambiando el mes a mitad
  de flujo
- --pending-checkin en ScreenshotMode (revierte el seed a 'mes pendiente') +
  UITest testGuidedCheckInFlow end-to-end (valor → skips → mood → cierre) ✓
- Strings ×7 idiomas

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WoScpmHdVj1aUf4rAp6hbe
2026-07-09 10:51:11 +02:00
alexandrev-tibco be7119f4b9 Calm 2.0 Fase 1: sistema de diseño limpio + Home en 3 zonas (1.5.0 build 51)
Sistema:
- AppBackground: fuera gradiente + formas decorativas → systemGroupedBackground
  plano en toda la app. La profundidad la da la jerarquía, no la decoración.

Home (3 zonas: estado / acción / contexto):
- TotalValueCard: hero tipográfico — número 44pt rounded sobre card limpia,
  chip de delta semántico (verde/rojo), sparkline de evolución 56pt estilo
  Stocks, fila secundaria YoY / inception / forecast. Fuera el gradiente azul.
- MonthlyCheckInCard state-driven: pendiente → protagonista con progreso real
  de fuentes (N de M) y un CTA; completado → fila discreta con checkmark,
  próximo check-in y streak integrado. En iPad va a ancho completo bajo el
  hero (zona de acción), fuera del grid.
- InsightsRow: de carrusel de chips a un insight único rotatorio (tap para
  ciclar, dots de posición).
- Eliminados del top: streak badge (vive en el check-in) y banner de pending
  updates (el progreso del check-in lo comunica).
- Defaults nuevos: momentumStreaks/pendingUpdates/periodReturns/monthlySummary
  ocultos por defecto (siguen disponibles en Customize; configs guardadas de
  usuarios existentes se respetan).
- Strings nuevas en 7 idiomas (checkin_done_*, checkin_sources_progress).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WoScpmHdVj1aUf4rAp6hbe
2026-07-08 23:58:06 +02:00
160 changed files with 14661 additions and 2586 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
+20
View File
@@ -51,8 +51,28 @@ PortfolioJournal/
└── Resources/ # Info.plist, GoogleService-Info.plist, assets
PortfolioJournalWidget/ # iOS Home Screen Widget (WidgetKit)
PortfolioJournalWatch/ # watchOS app (read-only portfolio view)
PortfolioJournalWatchWidget/# watchOS complications (WidgetKit)
Shared/ # Types shared between iOS app and watch targets
```
### Apple Watch
The watch app and its complications are read-only; every edit still happens on
the iPhone. App Groups are not shared between iOS and watchOS, so the watch
cannot read the Core Data store the iOS widget reads. Instead:
- `WatchSyncService` (iOS) builds a `WatchPortfolioSnapshot` and pushes it as the
WatchConnectivity application context. It is triggered from
`CoreDataStack.refreshWidgetData()`, the same hook that reloads the iOS widget.
- `WatchDataStore` (watchOS) receives it, caches it in the watch App Group via
`WatchSnapshotCache`, and reloads the complication timelines.
- The complication extension only reads that cache — it never talks to
WatchConnectivity itself.
Watch targets keep their own `*.lproj/Localizable.strings` with just their keys,
like the other extensions.
### Core Data Model
Key entities: `Account`, `InvestmentSource`, `Snapshot`, `Category`, `Goal`, `Asset`, `Transaction`, `AppSettings`, `PremiumStatus`
+103
View File
@@ -0,0 +1,103 @@
# iPhone Duo — estado de la adaptación (Portfolio Journal 1.7.0)
Fase 1, hecha con Xcode 27.0 / SDK iOS 27.0 (sin las APIs específicas de Duo de
Xcode 27.1). Todo lo que se describe aquí funciona con size classes estándar:
| Pantalla Duo | Size class | Cómo se comporta la app |
|---|---|---|
| Exterior 5.4" (vertical) | compact × regular | Igual que un iPhone: tab bar, stacks |
| Exterior (horizontal) | compact × compact | Igual que un iPhone en horizontal |
| Interior 7.6" (ambas orientaciones) | regular × regular | Layouts de dos columnas, sidebar/tab bar superior, inspector |
## Qué se ha adaptado
### Navegación (ContentView)
- Un único `TabView` para todas las size classes. En iOS 18+ usa
`.tabViewStyle(.sidebarAdaptable)`: tab bar en compacto, tab bar superior /
sidebar en regular. Antes había dos jerarquías (`TabView` iPhone /
`NavigationSplitView` iPad) y el cambio de size class las intercambiaba,
perdiendo el estado; ahora abrir/cerrar el Duo conserva cada pestaña.
- `@SceneStorage("selectedTab")` restaura la pestaña por escena.
- Ningún `userInterfaceIdiom`, `UIScreen.main`, ni orientación: todo por
`@Environment(\.horizontalSizeClass)`.
### Escenarios regular×regular (pantalla interior)
1. **Fuentes**: `NavigationSplitView` — lista de posiciones a la izquierda y
detalle (rendimiento, gráfico, snapshots) a la derecha, a la vez. En
compacto colapsa a stack; la selección es el único estado de navegación, así
sobrevive al plegado.
2. **Diario**: `NavigationSplitView` — lista de meses + check-in mensual inline.
3. **Dashboard + Quick Update**: el check-in rápido se abre como `.inspector`
(panel lateral) en ancho regular, de modo que los gráficos del dashboard
siguen visibles mientras se teclean los valores; en compacto es un sheet.
El dashboard usa masonry de 2/3 columnas según el ancho disponible
(`GeometryReader`), que se recalcula al abrir/cerrar el inspector.
4. **Gráficos**: sidebar de tipos de gráfico (248 pt) + área de gráfico a todo
el ancho, cabecera KPI y selector de periodo segmentado. Nuevo
`chartHeightScale` (+35 % de alto en regular) para que el ancho extra se
traduzca en más área de trazado; las etiquetas de eje ya eran más densas en
regular.
5. **Bloqueo / privacidad**: `LAContext.biometryType` → "Desbloquear con
Face ID / Touch ID / Optic ID"; icono y textos genéricos en 7 idiomas.
### Reglas de Apple aplicadas
- Layout por size classes (regla 1), sin `UIScreen.main` (2), sin tamaños de
pantalla fijos (3): las columnas usan `navigationSplitViewColumnWidth(min:ideal:max:)`
e `inspectorColumnWidth`, y el contenido `maxWidth: .infinity`.
- Navegación nativa: `TabView` sidebarAdaptable, `NavigationSplitView`,
`.inspector`, sheets/popovers/alerts del sistema (4, 11).
- Safe areas: se eliminó el `ignoresSafeArea(edges: .bottom)` del contenido
interactivo de Gráficos; solo fondos decorativos (`AppBackground`) ignoran el
safe area (5).
- Orientaciones: iPhone vertical + horizontal, iPad las 4; sin
`UIRequiresFullScreen`; `UIApplicationSupportsMultipleScenes = YES` (6).
- Biometría por `biometryType` (7).
- Estado persistente ante redimensionado: sin intercambio de jerarquías,
`@SceneStorage`, ViewModels en `@StateObject` que sobreviven al cambio de
size class (9).
- Liquid Glass: barras del sistema automáticas al compilar con SDK 27;
`glassEffect` en las etiquetas flotantes del Diario (iOS 26+, fallback
material).
### Esquinas concéntricas (regla 8)
No aplicada: ninguna tarjeta llega a sangre hasta la esquina de pantalla (todo
va con padding dentro de `ScrollView`/`List`). Revisar en fase 2 si el
edge-to-edge de 27.1 cambia esto.
## Qué queda para la fase 2 (Xcode 27.1)
- `ArrangementView` / `ReservedRegion`: llegar al borde de la pantalla interior y
respetar la región reservada (botones del sistema en Split View).
- Probar en el simulador Duo (Device Hub) el plegado en caliente: comprobar que
el inspector de Quick Update y las columnas de Fuentes/Diario se recomponen
sin perder el texto tecleado.
- Skill "App Resizability" y release notes con el edge-to-edge.
- Traducir `fastlane/metadata/{de-DE,fr-*,it,ja,pt-*}/release_notes.txt`
(solo en-* y es-* están actualizados).
- Widgets: verificar tamaños en la pantalla interior cuando exista simulador.
## Verificado (16 sep 2026, Xcode 27.0)
- `xcodebuild build` iOS (app + widget + extensión Quick Update) y watchOS
(app + complicaciones): sin errores ni warnings de compilador.
- 108/108 tests unitarios en iPhone 17 Pro.
- `DuoLayoutUITests` en iPhone 17 Pro (compacto) y iPad mini A17 Pro
(regular×regular): tab bar / tab bar superior, Fuentes y Diario en dos
columnas, inspector de Quick Update (abre y cierra), gráficos con sidebar,
vertical y horizontal.
## Cómo probarlo
```bash
# Build (un simulador cada vez)
xcodebuild -project PortfolioJournal.xcodeproj -scheme PortfolioJournal \
-destination 'platform=iOS Simulator,name=iPad mini (A17 Pro)' build
# Recorrido con capturas (compacto vs regular×regular)
xcodebuild test -project PortfolioJournal.xcodeproj -scheme PortfolioJournal \
-destination 'platform=iOS Simulator,name=iPhone 17 Pro' \
-only-testing:PortfolioJournalUITests/DuoLayoutUITests -resultBundlePath /tmp/duo-iphone.xcresult
xcodebuild test ... -destination 'platform=iOS Simulator,name=iPad mini (A17 Pro)' \
-only-testing:PortfolioJournalUITests/DuoLayoutUITests -resultBundlePath /tmp/duo-ipad.xcresult
xcrun xcresulttool export attachments --path /tmp/duo-ipad.xcresult --output-path /tmp/duo-ipad-png
```
La app arranca con datos de ejemplo y sin onboarding con el argumento
`--screenshots`. iPad mini (A17 Pro) es el simulador más parecido a la pantalla
interior del Duo (regular×regular en ambas orientaciones).
+383 -20
View File
@@ -16,6 +16,8 @@
0E5375312F0FD12E00F31390 /* GoogleMobileAds in Frameworks */ = {isa = PBXBuildFile; productRef = 0E5375302F0FD12E00F31390 /* GoogleMobileAds */; };
0E5375352F0FD14000F31390 /* FirebaseCrashlytics in Frameworks */ = {isa = PBXBuildFile; productRef = 0E5375342F0FD14000F31390 /* FirebaseCrashlytics */; };
0EQUPD102F40000000000001 /* PortfolioJournalQuickUpdate.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 0EQUPD012F40000000000001 /* PortfolioJournalQuickUpdate.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
0EWTCH190000000000000000 /* PortfolioJournalWatchWidget.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 0EWTCH040000000000000000 /* PortfolioJournalWatchWidget.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
0EWTCH250000000000000000 /* PortfolioJournalWatch.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = 0EWTCH030000000000000000 /* PortfolioJournalWatch.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@@ -47,6 +49,20 @@
remoteGlobalIDString = 0EQUPD022F40000000000001;
remoteInfo = PortfolioJournalQuickUpdate;
};
0EWTCH200000000000000000 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 0E241E312F0DA93A00283E2F /* Project object */;
proxyType = 1;
remoteGlobalIDString = 0EWTCH020000000000000000;
remoteInfo = PortfolioJournalWatchWidget;
};
0EWTCH220000000000000000 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 0E241E312F0DA93A00283E2F /* Project object */;
proxyType = 1;
remoteGlobalIDString = 0EWTCH010000000000000000;
remoteInfo = PortfolioJournalWatch;
};
/* End PBXContainerItemProxy section */
/* Begin PBXCopyFilesBuildPhase section */
@@ -62,6 +78,28 @@
name = "Embed Foundation Extensions";
runOnlyForDeploymentPostprocessing = 0;
};
0EWTCH150000000000000000 /* Embed Foundation Extensions */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "";
dstSubfolderSpec = 13;
files = (
0EWTCH190000000000000000 /* PortfolioJournalWatchWidget.appex in Embed Foundation Extensions */,
);
name = "Embed Foundation Extensions";
runOnlyForDeploymentPostprocessing = 0;
};
0EWTCH240000000000000000 /* Embed Watch Content */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "$(CONTENTS_FOLDER_PATH)/Watch";
dstSubfolderSpec = 16;
files = (
0EWTCH250000000000000000 /* PortfolioJournalWatch.app in Embed Watch Content */,
);
name = "Embed Watch Content";
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
@@ -75,6 +113,8 @@
0EUITEST002F31000000001 /* PortfolioJournalUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = PortfolioJournalUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
0EQUPD012F40000000000001 /* PortfolioJournalQuickUpdate.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = PortfolioJournalQuickUpdate.appex; sourceTree = BUILT_PRODUCTS_DIR; };
0EQUPD142F40000000000001 /* PortfolioJournalQuickUpdateExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = PortfolioJournalQuickUpdateExtension.entitlements; sourceTree = "<group>"; };
0EWTCH030000000000000000 /* PortfolioJournalWatch.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = PortfolioJournalWatch.app; sourceTree = BUILT_PRODUCTS_DIR; };
0EWTCH040000000000000000 /* PortfolioJournalWatchWidget.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = PortfolioJournalWatchWidget.appex; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
@@ -106,6 +146,36 @@
);
target = 0EQUPD022F40000000000001 /* PortfolioJournalQuickUpdate */;
};
0EWTCH080000000000000000 /* Exceptions for "PortfolioJournalWatch" folder in "PortfolioJournalWatch" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
Info.plist,
);
target = 0EWTCH010000000000000000 /* PortfolioJournalWatch */;
};
0EWTCH090000000000000000 /* Exceptions for "PortfolioJournalWatchWidget" folder in "PortfolioJournalWatchWidget" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
Info.plist,
);
target = 0EWTCH020000000000000000 /* PortfolioJournalWatchWidget */;
};
0EWTCH100000000000000000 /* Exceptions for "Shared" folder in "PortfolioJournal" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
WatchPortfolioSnapshot.swift,
);
target = 0E241E382F0DA93A00283E2F /* PortfolioJournal */;
};
0EWTCH110000000000000000 /* Exceptions for "Shared" folder in "PortfolioJournalWatchWidget" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
WatchColorHex.swift,
WatchPortfolioSnapshot.swift,
WatchSnapshotCache.swift,
);
target = 0EWTCH020000000000000000 /* PortfolioJournalWatchWidget */;
};
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
@@ -144,6 +214,31 @@
path = PortfolioJournalQuickUpdate;
sourceTree = "<group>";
};
0EWTCH050000000000000000 /* PortfolioJournalWatch */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
0EWTCH080000000000000000 /* Exceptions for "PortfolioJournalWatch" folder in "PortfolioJournalWatch" target */,
);
path = PortfolioJournalWatch;
sourceTree = "<group>";
};
0EWTCH060000000000000000 /* PortfolioJournalWatchWidget */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
0EWTCH090000000000000000 /* Exceptions for "PortfolioJournalWatchWidget" folder in "PortfolioJournalWatchWidget" target */,
);
path = PortfolioJournalWatchWidget;
sourceTree = "<group>";
};
0EWTCH070000000000000000 /* Shared */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
0EWTCH100000000000000000 /* Exceptions for "Shared" folder in "PortfolioJournal" target */,
0EWTCH110000000000000000 /* Exceptions for "Shared" folder in "PortfolioJournalWatchWidget" target */,
);
path = Shared;
sourceTree = "<group>";
};
/* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */
@@ -189,6 +284,20 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
0EWTCH130000000000000000 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
0EWTCH170000000000000000 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
@@ -204,6 +313,9 @@
0EUITEST032F31000000001 /* PortfolioJournalUITests */,
0E241ECD2F0DAA3C00283E2F /* Frameworks */,
0E241E3A2F0DA93A00283E2F /* Products */,
0EWTCH070000000000000000 /* Shared */,
0EWTCH050000000000000000 /* PortfolioJournalWatch */,
0EWTCH060000000000000000 /* PortfolioJournalWatchWidget */,
);
sourceTree = "<group>";
};
@@ -215,6 +327,8 @@
0EQUPD012F40000000000001 /* PortfolioJournalQuickUpdate.appex */,
0ETEST0002F31000000000001 /* PortfolioJournalTests.xctest */,
0EUITEST002F31000000001 /* PortfolioJournalUITests.xctest */,
0EWTCH030000000000000000 /* PortfolioJournalWatch.app */,
0EWTCH040000000000000000 /* PortfolioJournalWatchWidget.appex */,
);
name = Products;
sourceTree = "<group>";
@@ -240,6 +354,7 @@
0E241E362F0DA93A00283E2F /* Frameworks */,
0E241EE72F0DAA3E00283E2F /* Embed Foundation Extensions */,
0E8318932F0DB2FB0030C2F9 /* Resources */,
0EWTCH240000000000000000 /* Embed Watch Content */,
0E5375362F0FD14000F31390 /* Upload dSYMs to Crashlytics */,
);
buildRules = (
@@ -247,6 +362,7 @@
dependencies = (
0E241EE12F0DAA3E00283E2F /* PBXTargetDependency */,
0EQUPD122F40000000000001 /* PBXTargetDependency */,
0EWTCH230000000000000000 /* PBXTargetDependency */,
);
fileSystemSynchronizedGroups = (
0E241E3B2F0DA93A00283E2F /* PortfolioJournal */,
@@ -352,6 +468,53 @@
productReference = 0EQUPD012F40000000000001 /* PortfolioJournalQuickUpdate.appex */;
productType = "com.apple.product-type.app-extension";
};
0EWTCH010000000000000000 /* PortfolioJournalWatch */ = {
isa = PBXNativeTarget;
buildConfigurationList = 0EWTCH280000000000000000 /* Build configuration list for PBXNativeTarget "PortfolioJournalWatch" */;
buildPhases = (
0EWTCH120000000000000000 /* Sources */,
0EWTCH130000000000000000 /* Frameworks */,
0EWTCH140000000000000000 /* Resources */,
0EWTCH150000000000000000 /* Embed Foundation Extensions */,
);
buildRules = (
);
dependencies = (
0EWTCH210000000000000000 /* PBXTargetDependency */,
);
fileSystemSynchronizedGroups = (
0EWTCH050000000000000000 /* PortfolioJournalWatch */,
0EWTCH070000000000000000 /* Shared */,
);
name = PortfolioJournalWatch;
packageProductDependencies = (
);
productName = PortfolioJournalWatch;
productReference = 0EWTCH030000000000000000 /* PortfolioJournalWatch.app */;
productType = "com.apple.product-type.application";
};
0EWTCH020000000000000000 /* PortfolioJournalWatchWidget */ = {
isa = PBXNativeTarget;
buildConfigurationList = 0EWTCH310000000000000000 /* Build configuration list for PBXNativeTarget "PortfolioJournalWatchWidget" */;
buildPhases = (
0EWTCH160000000000000000 /* Sources */,
0EWTCH170000000000000000 /* Frameworks */,
0EWTCH180000000000000000 /* Resources */,
);
buildRules = (
);
dependencies = (
);
fileSystemSynchronizedGroups = (
0EWTCH060000000000000000 /* PortfolioJournalWatchWidget */,
);
name = PortfolioJournalWatchWidget;
packageProductDependencies = (
);
productName = PortfolioJournalWatchWidget;
productReference = 0EWTCH040000000000000000 /* PortfolioJournalWatchWidget.appex */;
productType = "com.apple.product-type.app-extension";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
@@ -406,6 +569,8 @@
0EQUPD022F40000000000001 /* PortfolioJournalQuickUpdate */,
0ETEST0012F31000000000001 /* PortfolioJournalTests */,
0EUITEST012F31000000001 /* PortfolioJournalUITests */,
0EWTCH010000000000000000 /* PortfolioJournalWatch */,
0EWTCH020000000000000000 /* PortfolioJournalWatchWidget */,
);
};
/* End PBXProject section */
@@ -446,6 +611,20 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
0EWTCH140000000000000000 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
0EWTCH180000000000000000 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
@@ -484,6 +663,20 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
0EWTCH120000000000000000 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
0EWTCH160000000000000000 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
@@ -530,6 +723,16 @@
target = 0EQUPD022F40000000000001 /* PortfolioJournalQuickUpdate */;
targetProxy = 0EQUPD112F40000000000001 /* PBXContainerItemProxy */;
};
0EWTCH210000000000000000 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 0EWTCH020000000000000000 /* PortfolioJournalWatchWidget */;
targetProxy = 0EWTCH200000000000000000 /* PBXContainerItemProxy */;
};
0EWTCH230000000000000000 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 0EWTCH010000000000000000 /* PortfolioJournalWatch */;
targetProxy = 0EWTCH220000000000000000 /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
/* Begin XCBuildConfiguration section */
@@ -541,7 +744,7 @@
CODE_SIGN_ENTITLEMENTS = PortfolioJournal/PortfolioJournalDebug.entitlements;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 50;
CURRENT_PROJECT_VERSION = 89;
DEVELOPMENT_ASSET_PATHS = PortfolioJournal/Assets.xcassets;
DEVELOPMENT_TEAM = 2825Q76T7H;
ENABLE_PREVIEWS = YES;
@@ -559,12 +762,13 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.4.2;
MARKETING_VERSION = 1.7.0;
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournal;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
SWIFT_UPCOMING_FEATURE_ISOLATED_DEFAULT_VALUES = YES;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
@@ -582,7 +786,7 @@
CODE_SIGN_IDENTITY = "Apple Distribution";
CODE_SIGN_STYLE = Manual;
PROVISIONING_PROFILE_SPECIFIER = "porfoliojournal";
CURRENT_PROJECT_VERSION = 50;
CURRENT_PROJECT_VERSION = 89;
DEVELOPMENT_ASSET_PATHS = PortfolioJournal/Assets.xcassets;
DEVELOPMENT_TEAM = 2825Q76T7H;
ENABLE_PREVIEWS = YES;
@@ -600,12 +804,13 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.4.2;
MARKETING_VERSION = 1.7.0;
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournal;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
SWIFT_UPCOMING_FEATURE_ISOLATED_DEFAULT_VALUES = YES;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
@@ -739,7 +944,7 @@
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
CODE_SIGN_ENTITLEMENTS = PortfolioJournalWidgetExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 50;
CURRENT_PROJECT_VERSION = 89;
DEVELOPMENT_ASSET_PATHS = PortfolioJournalWidget/Assets.xcassets;
DEVELOPMENT_TEAM = 2825Q76T7H;
GENERATE_INFOPLIST_FILE = NO;
@@ -752,7 +957,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.4.2;
MARKETING_VERSION = 1.7.0;
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournal.PortfolioJournalWidget;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
@@ -774,7 +979,7 @@
CODE_SIGN_IDENTITY = "Apple Distribution";
CODE_SIGN_STYLE = Manual;
PROVISIONING_PROFILE_SPECIFIER = "Portfolio Journalwidget";
CURRENT_PROJECT_VERSION = 50;
CURRENT_PROJECT_VERSION = 89;
DEVELOPMENT_ASSET_PATHS = PortfolioJournalWidget/Assets.xcassets;
DEVELOPMENT_TEAM = 2825Q76T7H;
GENERATE_INFOPLIST_FILE = NO;
@@ -787,7 +992,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.4.2;
MARKETING_VERSION = 1.7.0;
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournal.PortfolioJournalWidget;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
@@ -805,7 +1010,7 @@
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 50;
CURRENT_PROJECT_VERSION = 89;
DEVELOPMENT_TEAM = 2825Q76T7H;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
@@ -814,7 +1019,7 @@
"@executable_path/Frameworks",
"@loader_path/Frameworks",
);
MARKETING_VERSION = 1.4.2;
MARKETING_VERSION = 1.7.0;
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournalTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = NO;
@@ -829,7 +1034,7 @@
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 50;
CURRENT_PROJECT_VERSION = 89;
DEVELOPMENT_TEAM = 2825Q76T7H;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
@@ -838,7 +1043,7 @@
"@executable_path/Frameworks",
"@loader_path/Frameworks",
);
MARKETING_VERSION = 1.4.2;
MARKETING_VERSION = 1.7.0;
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournalTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = NO;
@@ -852,7 +1057,7 @@
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 50;
CURRENT_PROJECT_VERSION = 89;
DEVELOPMENT_TEAM = 2825Q76T7H;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
@@ -861,7 +1066,7 @@
"@executable_path/Frameworks",
"@loader_path/Frameworks",
);
MARKETING_VERSION = 1.4.2;
MARKETING_VERSION = 1.7.0;
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournalUITests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = NO;
@@ -875,7 +1080,7 @@
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 50;
CURRENT_PROJECT_VERSION = 89;
DEVELOPMENT_TEAM = 2825Q76T7H;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
@@ -884,7 +1089,7 @@
"@executable_path/Frameworks",
"@loader_path/Frameworks",
);
MARKETING_VERSION = 1.4.2;
MARKETING_VERSION = 1.7.0;
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournalUITests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = NO;
@@ -899,7 +1104,7 @@
buildSettings = {
CODE_SIGN_ENTITLEMENTS = PortfolioJournalQuickUpdateExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 50;
CURRENT_PROJECT_VERSION = 89;
DEVELOPMENT_TEAM = 2825Q76T7H;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = PortfolioJournalQuickUpdate/Info.plist;
@@ -911,7 +1116,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.4.2;
MARKETING_VERSION = 1.7.0;
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournal.QuickUpdate;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
@@ -927,7 +1132,7 @@
CODE_SIGN_ENTITLEMENTS = PortfolioJournalQuickUpdateExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Distribution";
CODE_SIGN_STYLE = Manual;
CURRENT_PROJECT_VERSION = 50;
CURRENT_PROJECT_VERSION = 89;
DEVELOPMENT_TEAM = 2825Q76T7H;
PROVISIONING_PROFILE_SPECIFIER = "PortfolioJournal QuickUpdate AppStore";
GENERATE_INFOPLIST_FILE = NO;
@@ -940,7 +1145,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.4.2;
MARKETING_VERSION = 1.7.0;
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournal.QuickUpdate;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
@@ -950,6 +1155,146 @@
};
name = Release;
};
0EWTCH260000000000000000 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = PortfolioJournalWatch.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 89;
DEVELOPMENT_TEAM = 2825Q76T7H;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = PortfolioJournalWatch/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "Portfolio Journal";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.7.0;
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournal.watchkitapp;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = watchos;
SKIP_INSTALL = YES;
SUPPORTED_PLATFORMS = "watchsimulator watchos";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 4;
WATCHOS_DEPLOYMENT_TARGET = 10.0;
DEBUG_INFORMATION_FORMAT = dwarf;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
ONLY_ACTIVE_ARCH = YES;
};
name = Debug;
};
0EWTCH270000000000000000 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = PortfolioJournalWatch.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 89;
DEVELOPMENT_TEAM = 2825Q76T7H;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = PortfolioJournalWatch/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "Portfolio Journal";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.7.0;
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournal.watchkitapp;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = watchos;
SKIP_INSTALL = YES;
SUPPORTED_PLATFORMS = "watchsimulator watchos";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 4;
WATCHOS_DEPLOYMENT_TARGET = 10.0;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
SWIFT_OPTIMIZATION_LEVEL = "-O";
SWIFT_COMPILATION_MODE = wholemodule;
};
name = Release;
};
0EWTCH290000000000000000 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = PortfolioJournalWatchWidget.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 89;
DEVELOPMENT_TEAM = 2825Q76T7H;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = PortfolioJournalWatchWidget/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "Portfolio Complications";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.7.0;
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournal.watchkitapp.widget;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = watchos;
SKIP_INSTALL = YES;
SUPPORTED_PLATFORMS = "watchsimulator watchos";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 4;
WATCHOS_DEPLOYMENT_TARGET = 10.0;
DEBUG_INFORMATION_FORMAT = dwarf;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
ONLY_ACTIVE_ARCH = YES;
};
name = Debug;
};
0EWTCH300000000000000000 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = PortfolioJournalWatchWidget.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 89;
DEVELOPMENT_TEAM = 2825Q76T7H;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = PortfolioJournalWatchWidget/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "Portfolio Complications";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.7.0;
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournal.watchkitapp.widget;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = watchos;
SKIP_INSTALL = YES;
SUPPORTED_PLATFORMS = "watchsimulator watchos";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 4;
WATCHOS_DEPLOYMENT_TARGET = 10.0;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
SWIFT_OPTIMIZATION_LEVEL = "-O";
SWIFT_COMPILATION_MODE = wholemodule;
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
@@ -1007,6 +1352,24 @@
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
0EWTCH280000000000000000 /* Build configuration list for PBXNativeTarget "PortfolioJournalWatch" */ = {
isa = XCConfigurationList;
buildConfigurations = (
0EWTCH260000000000000000 /* Debug */,
0EWTCH270000000000000000 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
0EWTCH310000000000000000 /* Build configuration list for PBXNativeTarget "PortfolioJournalWatchWidget" */ = {
isa = XCConfigurationList;
buildConfigurations = (
0EWTCH290000000000000000 /* Debug */,
0EWTCH300000000000000000 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
/* Begin XCRemoteSwiftPackageReference section */
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "2620"
version = "1.7">
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
@@ -34,7 +34,7 @@
parallelizable = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "0ETEST001000000000000000"
BlueprintIdentifier = "0ETEST0012F31000000000001"
BuildableName = "PortfolioJournalTests.xctest"
BlueprintName = "PortfolioJournalTests"
ReferencedContainer = "container:PortfolioJournal.xcodeproj">
@@ -73,6 +73,20 @@
ReferencedContainer = "container:PortfolioJournal.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
<CommandLineArguments>
<CommandLineArgument
argument = "-com.apple.CoreData.Logging.stderr 1"
isEnabled = "YES">
</CommandLineArgument>
<CommandLineArgument
argument = "-InitializeCloudKitSchema"
isEnabled = "YES">
</CommandLineArgument>
<CommandLineArgument
argument = "-com.apple.CoreData.CloudKitDebug 3 "
isEnabled = "YES">
</CommandLineArgument>
</CommandLineArguments>
<StoreKitConfigurationFileReference
identifier = "../../PortfolioJournal/Configuration.storekit">
</StoreKitConfigurationFileReference>
@@ -0,0 +1,78 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "2620"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
buildArchitectures = "Automatic">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "0EWTCH010000000000000000"
BuildableName = "PortfolioJournalWatch.app"
BlueprintName = "PortfolioJournalWatch"
ReferencedContainer = "container:PortfolioJournal.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "0EWTCH010000000000000000"
BuildableName = "PortfolioJournalWatch.app"
BlueprintName = "PortfolioJournalWatch"
ReferencedContainer = "container:PortfolioJournal.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "0EWTCH010000000000000000"
BuildableName = "PortfolioJournalWatch.app"
BlueprintName = "PortfolioJournalWatch"
ReferencedContainer = "container:PortfolioJournal.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>
@@ -9,6 +9,11 @@
<key>orderHint</key>
<integer>1</integer>
</dict>
<key>PortfolioJournalQuickUpdate.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>2</integer>
</dict>
<key>PortfolioJournalWidgetExtension.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
+6
View File
@@ -23,6 +23,12 @@ class AppDelegate: NSObject, UIApplicationDelegate {
// Request notification permissions
requestNotificationPermissions()
// Apple Watch bridge: activating early means the first application
// context lands before the user raises their wrist.
Task { @MainActor in
WatchSyncService.shared.activate()
}
return true
}
+69 -71
View File
@@ -41,13 +41,14 @@ struct ContentView: View {
@AppStorage("lockOnBackground") private var lockOnBackground = false
@AppStorage("lastSeenWhatsNewVersion") private var lastSeenWhatsNewVersion = ""
@Environment(\.scenePhase) private var scenePhase
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
@State private var isUnlocked = false
@State private var resolvedOnboardingCompleted: Bool?
@State private var iCloudCheckDone = false
@State private var loadingMessageKey: LocalizedStringKey = "loading_data"
@State private var sidebarSelection: AppTab? = .dashboard
@State private var showingWhatsNew = false
/// Per-scene tab restoration: the interface is re-laid out when iPhone Duo
/// opens/closes or a window is resized the selected tab must survive it.
@SceneStorage("selectedTab") private var restoredTab: Int = -1
private var currentVersion: String {
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? ""
@@ -75,8 +76,6 @@ struct ContentView: View {
OnboardingICloudCheckView(onSkip: { iCloudCheckDone = true })
} else if resolvedOnboardingCompleted == false {
OnboardingView(onboardingCompleted: $onboardingCompleted)
} else if horizontalSizeClass == .regular {
iPadMainContent
} else {
mainContent
}
@@ -92,6 +91,12 @@ struct ContentView: View {
} else {
isUnlocked = !lockOnLaunch
}
if restoredTab >= 0, AppTab(rawValue: restoredTab) != nil {
tabSelection.selectedTab = restoredTab
}
}
.onChange(of: tabSelection.selectedTab) { _, tab in
restoredTab = tab
}
.onChange(of: lockEnabled) { _, enabled in
if !enabled {
@@ -113,33 +118,46 @@ struct ContentView: View {
}
.task {
await waitForDataAndResolveOnboarding()
if (resolvedOnboardingCompleted == true) && currentVersion != lastSeenWhatsNewVersion {
// Cumulative What's New: everything between the version the user
// comes from and the one they just installed. Empty catalog diff
// (e.g. patch release without entry) no sheet.
if (resolvedOnboardingCompleted == true) && currentVersion != lastSeenWhatsNewVersion,
!WhatsNewCatalog.pendingReleases(since: lastSeenWhatsNewVersion, current: currentVersion).isEmpty {
// Small delay to let the UI settle before showing sheet
try? await Task.sleep(nanoseconds: 300_000_000)
try? await Task.sleep(for: .milliseconds(300))
showingWhatsNew = true
}
}
.onChange(of: onboardingCompleted) { _, completed in
resolvedOnboardingCompleted = completed
// Fresh install: nothing is "new" for a user who just onboarded.
if completed && lastSeenWhatsNewVersion.isEmpty {
lastSeenWhatsNewVersion = currentVersion
// Straight to the aha moment: open Add Source instead of
// dropping the new user on an empty dashboard.
DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) {
NotificationCenter.default.post(name: .openAddFirstSource, object: nil)
}
}
}
.sheet(isPresented: $showingWhatsNew, onDismiss: {
lastSeenWhatsNewVersion = currentVersion
}) {
WhatsNewView()
WhatsNewView(releases: WhatsNewCatalog.pendingReleases(
since: lastSeenWhatsNewVersion,
current: currentVersion
))
}
.onReceive(NotificationCenter.default.publisher(for: .openBatchUpdate)) { _ in
tabSelection.selectedTab = 1
sidebarSelection = .sources
tabSelection.selectedTab = AppTab.sources.rawValue
NotificationCenter.default.post(name: .openQuickUpdate, object: nil)
}
.onReceive(NotificationCenter.default.publisher(for: .openDashboard)) { _ in
tabSelection.selectedTab = 0
sidebarSelection = .dashboard
tabSelection.selectedTab = AppTab.dashboard.rawValue
}
.onOpenURL { url in
if url.host == "quickupdate" {
tabSelection.selectedTab = 0
sidebarSelection = .dashboard
tabSelection.selectedTab = AppTab.dashboard.rawValue
NotificationCenter.default.post(name: .openQuickUpdate, object: nil)
}
}
@@ -152,7 +170,7 @@ struct ContentView: View {
private func waitForDataAndResolveOnboarding() async {
// Wait for Core Data to be loaded
while !coreDataStack.isLoaded {
try? await Task.sleep(nanoseconds: 50_000_000) // 50ms
try? await Task.sleep(for: .milliseconds(50)) // 50ms
}
// If CloudKit is enabled and no local data yet, wait briefly for the
@@ -176,7 +194,7 @@ struct ContentView: View {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
if hasExistingData() { return }
try? await Task.sleep(nanoseconds: 300_000_000) // poll every 300ms
try? await Task.sleep(for: .milliseconds(300)) // poll every 300ms
}
}
@@ -224,33 +242,50 @@ struct ContentView: View {
return ((try? context.count(for: accountRequest)) ?? 0) > 0
}
// MARK: - iPad Layout
// MARK: - Adaptive navigation
//
// One TabView for every size class. iOS 18+ uses the sidebar-adaptable
// style: a tab bar in compact width (iPhone, iPhone Duo outer screen) and a
// top tab bar / sidebar in regular width (iPad, iPhone Duo inner screen).
// Because the hierarchy never changes, opening or closing the Duo keeps
// every tab's navigation and scroll state. Each tab that has a list+detail
// structure (Sources, Journal) owns a NavigationSplitView that collapses
// to a stack in compact width and shows both columns in regular width.
private var iPadMainContent: some View {
NavigationSplitView(columnVisibility: .constant(.all)) {
List(AppTab.allCases, id: \.self, selection: $sidebarSelection) { tab in
Label(tab.title, systemImage: tab.icon)
}
.navigationTitle("Portfolio Journal")
.navigationSplitViewColumnWidth(min: 200, ideal: 240, max: 280)
} detail: {
iPadDetailView(for: sidebarSelection ?? .dashboard)
@ViewBuilder
private var mainContent: some View {
if #available(iOS 18.0, *) {
adaptiveTabs
} else {
legacyTabs
}
.onChange(of: sidebarSelection) { _, tab in
guard let tab else { return }
if tabSelection.selectedTab != tab.rawValue {
tabSelection.selectedTab = tab.rawValue
}
@available(iOS 18.0, *)
private var adaptiveTabs: some View {
TabView(selection: $tabSelection.selectedTab) {
ForEach(AppTab.allCases, id: \.self) { tab in
Tab(tab.title, systemImage: tab.icon, value: tab.rawValue) {
tabContent(for: tab)
}
}
}
.onChange(of: tabSelection.selectedTab) { _, value in
if let tab = AppTab(rawValue: value), sidebarSelection != tab {
sidebarSelection = tab
.tabViewStyle(.sidebarAdaptable)
}
/// iOS 17 fallback: classic tab bar in every size class.
private var legacyTabs: some View {
TabView(selection: $tabSelection.selectedTab) {
ForEach(AppTab.allCases, id: \.self) { tab in
tabContent(for: tab)
.tabItem { Label(tab.title, systemImage: tab.icon) }
.tag(tab.rawValue)
}
}
}
@ViewBuilder
private func iPadDetailView(for tab: AppTab) -> some View {
private func tabContent(for tab: AppTab) -> some View {
switch tab {
case .dashboard:
bannerInsetView(DashboardView())
@@ -265,44 +300,6 @@ struct ContentView: View {
}
}
// MARK: - iPhone Layout
private var mainContent: some View {
ZStack {
TabView(selection: $tabSelection.selectedTab) {
bannerInsetView(DashboardView())
.tabItem {
Label("Home", systemImage: "house.fill")
}
.tag(0)
bannerInsetView(SourceListView(iapService: iapService))
.tabItem {
Label("Sources", systemImage: "list.bullet")
}
.tag(1)
bannerInsetView(ChartsContainerView(iapService: iapService))
.tabItem {
Label("Charts", systemImage: "chart.xyaxis.line")
}
.tag(2)
bannerInsetView(JournalView())
.tabItem {
Label("Journal", systemImage: "book.closed")
}
.tag(3)
bannerInsetView(SettingsView(iapService: iapService))
.tabItem {
Label("Settings", systemImage: "gearshape.fill")
}
.tag(4)
}
}
}
private func bannerInsetView<Content: View>(_ content: Content) -> some View {
content.safeAreaInset(edge: .bottom, spacing: 0) {
if !iapService.isPremium && adMobService.canShowAds {
@@ -322,4 +319,5 @@ struct ContentView: View {
.environmentObject(AdMobService())
.environmentObject(AccountStore(iapService: IAPService()))
.environmentObject(TabSelectionStore())
.environmentObject(BalancePrivacyManager.shared)
}
@@ -1,4 +1,6 @@
import SwiftUI
import CoreData
import TipKit
/// Support for automated App Store screenshot capture. Activated by launching the app
/// with the `--screenshots` argument (used by the UI-test capture flow). Only mutates
@@ -22,6 +24,9 @@ enum ScreenshotMode {
// the premium charts without a StoreKit purchase. `--no-premium` keeps it
// off to test the free-tier experience (locked charts, teasers).
d.set(!CommandLine.arguments.contains("--no-premium"), forKey: "debugPremiumOverride")
// TipKit popovers cover toolbar buttons and swallow the first tap in
// automated walkthroughs keep them out of captures.
Tips.hideAllTipsForTesting()
}
/// Seed demo data (no-op if the store already has sources). Called once Core Data
@@ -29,6 +34,27 @@ enum ScreenshotMode {
static func seedIfNeeded() {
guard isActive else { return }
SampleDataService.shared.seedSampleData()
makeCheckInPendingIfNeeded()
}
/// `--pending-checkin`: strip the seeded data back to a "month not done yet"
/// state (recent snapshots + this month's journal completion removed) so the
/// guided check-in flow can be exercised/captured.
private static func makeCheckInPendingIfNeeded() {
guard CommandLine.arguments.contains("--pending-checkin") else { return }
let context = CoreDataStack.shared.viewContext
let cutoff = Calendar.current.date(byAdding: .day, value: -40, to: Date()) ?? Date()
let snapshotRequest = Snapshot.fetchRequest()
snapshotRequest.predicate = NSPredicate(format: "date >= %@", cutoff as NSDate)
(try? context.fetch(snapshotRequest))?.forEach(context.delete)
let journalRequest = JournalEntry.fetchRequest()
journalRequest.predicate = NSPredicate(format: "completionTime >= %@", cutoff as NSDate)
(try? context.fetch(journalRequest))?.forEach(context.delete)
try? context.save()
SharedQuickUpdateSync.refreshMirror()
}
}
@@ -39,6 +65,7 @@ struct PortfolioJournalApp: App {
@StateObject private var adMobService: AdMobService
@StateObject private var accountStore: AccountStore
@StateObject private var tabSelection = TabSelectionStore()
@StateObject private var balancePrivacy = BalancePrivacyManager.shared
@Environment(\.scenePhase) private var scenePhase
let coreDataStack = CoreDataStack.shared
@@ -51,6 +78,9 @@ struct PortfolioJournalApp: App {
// Clean up any duplicate objects from previous bugs before initializing stores
CoreDataStack.shared.cleanupDuplicateObjects()
// Feature-discovery tips (Quick Update, OCR, Siri, chart sharing)
try? Tips.configure([.displayFrequency(.daily)])
let iap = IAPService()
_iapService = StateObject(wrappedValue: iap)
_adMobService = StateObject(wrappedValue: AdMobService())
@@ -66,14 +96,25 @@ struct PortfolioJournalApp: App {
.environmentObject(adMobService)
.environmentObject(accountStore)
.environmentObject(tabSelection)
.environmentObject(balancePrivacy)
}
.onChange(of: scenePhase) { _, newPhase in
if newPhase == .background {
// Safety net for any save path that doesn't refresh the widget
// itself: guarantee the widget is fresh whenever the app leaves
// the foreground (WAL checkpoint + timeline reload).
coreDataStack.refreshWidgetData()
}
if newPhase == .active {
coreDataStack.refreshWidgetData()
// Re-read all Core Data objects from the persistent store so that
// iCloud changes made on other devices while this device was inactive
// are reflected immediately without waiting for a remote-change notification.
coreDataStack.refreshFromCloudKit()
// Auto-backup: writes a lossless JSON snapshot on the configured cadence.
DispatchQueue.global(qos: .utility).async {
BackupService.shared.runScheduledBackupIfNeeded()
}
NotificationService.shared.scheduleReEngagementNotification()
NotificationService.shared.scheduleMonthlyCheckIn()
NotificationService.shared.scheduleStreakProtectionReminder()
@@ -7,9 +7,21 @@ public class Account: NSManagedObject, Identifiable {
return NSFetchRequest<Account>(entityName: "Account")
}
@NSManaged public var id: UUID
@NSManaged public var name: String
@NSManaged public var createdAt: Date
@objc public var id: UUID {
get { safeValue(forKey: "id", fallback: UUID(), healing: true) }
set { setManagedValue(newValue, forKey: "id") }
}
@objc public var name: String {
get { safeValue(forKey: "name", fallback: "") }
set { setManagedValue(newValue, forKey: "name") }
}
// createdAt is optional in the model; a non-optional @NSManaged Date crashes the
// CloudKit merge when an imported record materialises with a transient nil.
// Same nil-tolerant accessor pattern already applied to Snapshot.
@objc public var createdAt: Date {
get { safeValue(forKey: "createdAt", fallback: .distantPast) }
set { setManagedValue(newValue, forKey: "createdAt") }
}
@NSManaged public var currency: String?
@NSManaged public var inputMode: String
@NSManaged public var notificationFrequency: String
@@ -34,6 +46,11 @@ public class Account: NSManagedObject, Identifiable {
extension Account {
static let defaultAccountName = "Default"
/// Deterministic UUID for the auto-created "Default" account, so every device
/// creates it as the SAME CloudKit record instead of a per-device duplicate.
/// See StableID.
static let defaultAccountStableID = StableID.v5("account:default")
}
// MARK: - Computed Properties
@@ -8,14 +8,27 @@ public class Category: NSManagedObject, Identifiable {
return NSFetchRequest<Category>(entityName: "Category")
}
@NSManaged public var id: UUID
@NSManaged public var name: String
@NSManaged public var colorHex: String
@NSManaged public var icon: String
@objc public var id: UUID {
get { safeValue(forKey: "id", fallback: UUID(), healing: true) }
set { setManagedValue(newValue, forKey: "id") }
}
@objc public var name: String {
get { safeValue(forKey: "name", fallback: "") }
set { setManagedValue(newValue, forKey: "name") }
}
@objc public var colorHex: String {
get { safeValue(forKey: "colorHex", fallback: "#6B7280") }
set { setManagedValue(newValue, forKey: "colorHex") }
}
@objc public var icon: String {
get { safeValue(forKey: "icon", fallback: "questionmark") }
set { setManagedValue(newValue, forKey: "icon") }
}
@NSManaged public var sortOrder: Int16
@NSManaged public var createdAt: Date
@NSManaged public var allocationTarget: NSNumber?
@NSManaged public var sources: NSSet?
@NSManaged public var goals: NSSet?
public override func awakeFromInsert() {
super.awakeFromInsert()
@@ -31,6 +44,13 @@ public class Category: NSManagedObject, Identifiable {
// MARK: - Computed Properties
extension Category {
/// Nil-safe identifier that never heals a deleted object, mirroring
/// `Account.safeId`. Use it when comparing identities across fetches.
var safeId: UUID? {
guard !isDeleted else { return nil }
return value(forKey: "id") as? UUID
}
var color: Color {
Color(hex: colorHex) ?? .blue
}
@@ -80,9 +100,17 @@ extension Category {
("ETFs", "#EC4899", "chart.bar.fill")
]
/// Deterministic UUID for a default category, so every device creates each
/// default category as the SAME CloudKit record instead of a per-device
/// duplicate. Only meaningful for the built-in default names. See StableID.
static func stableID(for name: String) -> UUID {
StableID.v5("category:\(name.lowercased())")
}
static func createDefaultCategories(in context: NSManagedObjectContext) {
for (index, categoryData) in defaultCategories.enumerated() {
let category = Category(context: context)
category.id = stableID(for: categoryData.name)
category.name = categoryData.name
category.colorHex = categoryData.colorHex
category.icon = categoryData.icon
@@ -7,13 +7,26 @@ public class Goal: NSManagedObject, Identifiable {
return NSFetchRequest<Goal>(entityName: "Goal")
}
@NSManaged public var id: UUID
@NSManaged public var name: String
@objc public var id: UUID {
get { safeValue(forKey: "id", fallback: UUID(), healing: true) }
set { setManagedValue(newValue, forKey: "id") }
}
@objc public var name: String {
get { safeValue(forKey: "name", fallback: "") }
set { setManagedValue(newValue, forKey: "name") }
}
@NSManaged public var targetAmount: NSDecimalNumber?
@NSManaged public var targetDate: Date?
@NSManaged public var isActive: Bool
@NSManaged public var createdAt: Date
// createdAt is optional in the model; a non-optional @NSManaged Date crashes the
// CloudKit merge when an imported record materialises with a transient nil.
// Same nil-tolerant accessor pattern already applied to Snapshot.
@objc public var createdAt: Date {
get { safeValue(forKey: "createdAt", fallback: .distantPast) }
set { setManagedValue(newValue, forKey: "createdAt") }
}
@NSManaged public var account: Account?
@NSManaged public var category: Category?
public override func awakeFromInsert() {
super.awakeFromInsert()
@@ -30,5 +43,32 @@ extension Goal {
var targetDecimal: Decimal {
targetAmount?.decimalValue ?? Decimal.zero
}
/// A goal may be scoped to an account, to a source category (e.g. "100k in
/// Cash"), to both, or to nothing at all (the whole portfolio). `account`
/// and `category` act as AND filters over the sources that count towards it.
func includes(_ source: InvestmentSource) -> Bool {
if let accountId = account?.safeId, source.account?.id != accountId {
return false
}
if let categoryId = category?.safeId, source.category?.id != categoryId {
return false
}
return true
}
/// Sources whose value counts towards this goal.
func relevantSources(from sources: [InvestmentSource]) -> [InvestmentSource] {
guard account != nil || category != nil else { return sources }
return sources.filter { includes($0) }
}
/// Stable key identifying the scope (account + category) of a goal, so
/// goals sharing a scope can share cached evolution data.
var scopeKey: String {
let accountPart = account?.safeId?.uuidString ?? "all"
let categoryPart = category?.safeId?.uuidString ?? "all"
return "\(accountPart)|\(categoryPart)"
}
}
@@ -7,13 +7,25 @@ public class InvestmentSource: NSManagedObject, Identifiable {
return NSFetchRequest<InvestmentSource>(entityName: "InvestmentSource")
}
@NSManaged public var id: UUID
@NSManaged public var name: String
@objc public var id: UUID {
get { safeValue(forKey: "id", fallback: UUID(), healing: true) }
set { setManagedValue(newValue, forKey: "id") }
}
@objc public var name: String {
get { safeValue(forKey: "name", fallback: "") }
set { setManagedValue(newValue, forKey: "name") }
}
@NSManaged public var notificationFrequency: String
@NSManaged public var customFrequencyMonths: Int16
@NSManaged public var isActive: Bool
@NSManaged public var monthlyContribution: NSDecimalNumber?
@NSManaged public var createdAt: Date
// createdAt is optional in the model; a non-optional @NSManaged Date crashes the
// CloudKit merge when an imported record materialises with a transient nil.
// Same nil-tolerant accessor pattern already applied to Snapshot.
@objc public var createdAt: Date {
get { safeValue(forKey: "createdAt", fallback: .distantPast) }
set { setManagedValue(newValue, forKey: "createdAt") }
}
@NSManaged public var category: Category?
@NSManaged public var account: Account?
@NSManaged public var snapshots: NSSet?
@@ -0,0 +1,34 @@
import CoreData
// MARK: - Nil-tolerant accessors for CloudKit-synced attributes
//
// Attributes declared non-optional in Swift (`id: UUID`, `date: Date`, ) CAN
// be nil at runtime: NSPersistentCloudKitContainer merges records from other
// devices in stages, and deleted objects may still be referenced by live views.
// Reading them through `@NSManaged` force-bridges nil and crashes
// (`UUID/Date._unconditionallyBridgeFromObjectiveC`) seen in production in
// SwiftUI ForEach identity (nil id) and ChartsViewModel.monthlyTotals (nil
// date). These helpers read the primitive value and fall back safely.
extension NSManagedObject {
/// Reads a primitive attribute, returning `fallback` when nil. With
/// `healing: true` the fallback is also written back to the primitive
/// (without dirtying the object) so identity stays stable for the session.
func safeValue<T>(forKey key: String, fallback: @autoclosure () -> T, healing: Bool = false) -> T {
willAccessValue(forKey: key)
defer { didAccessValue(forKey: key) }
if let value = primitiveValue(forKey: key) as? T {
return value
}
let healed = fallback()
if healing {
setPrimitiveValue(healed, forKey: key)
}
return healed
}
func setManagedValue<T>(_ value: T, forKey key: String) {
willChangeValue(forKey: key)
setPrimitiveValue(value, forKey: key)
didChangeValue(forKey: key)
}
}
@@ -21,8 +21,8 @@
<attribute name="name" attributeType="String" defaultValueString=""/>
<attribute name="notificationFrequency" attributeType="String" defaultValueString="monthly"/>
<attribute name="sortOrder" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/>
<relationship name="goals" optional="YES" toMany="YES" deletionRule="Cascade" destinationEntity="Goal" inverseName="account" inverseEntity="Goal"/>
<relationship name="sources" optional="YES" toMany="YES" deletionRule="Cascade" destinationEntity="InvestmentSource" inverseName="account" inverseEntity="InvestmentSource"/>
<relationship name="goals" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Goal" inverseName="account" inverseEntity="Goal"/>
<relationship name="sources" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="InvestmentSource" inverseName="account" inverseEntity="InvestmentSource"/>
</entity>
<entity name="Category" representedClassName="Category" syncable="YES">
<attribute name="allocationTarget" optional="YES" attributeType="Double" usesScalarValueType="NO"/>
@@ -32,7 +32,8 @@
<attribute name="id" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
<attribute name="name" attributeType="String" defaultValueString=""/>
<attribute name="sortOrder" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/>
<relationship name="sources" optional="YES" toMany="YES" deletionRule="Cascade" destinationEntity="InvestmentSource" inverseName="category" inverseEntity="InvestmentSource"/>
<relationship name="goals" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Goal" inverseName="category" inverseEntity="Goal"/>
<relationship name="sources" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="InvestmentSource" inverseName="category" inverseEntity="InvestmentSource"/>
</entity>
<entity name="InvestmentSource" representedClassName="InvestmentSource" syncable="YES">
<attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
@@ -76,6 +77,7 @@
<attribute name="targetAmount" optional="YES" attributeType="Decimal"/>
<attribute name="targetDate" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<relationship name="account" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Account" inverseName="goals" inverseEntity="Account"/>
<relationship name="category" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Category" inverseName="goals" inverseEntity="Category"/>
</entity>
<entity name="PredictionCache" representedClassName="PredictionCache" syncable="NO">
<attribute name="algorithm" attributeType="String" defaultValueString="linear"/>
@@ -100,6 +102,7 @@
<attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="date" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="id" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
<attribute name="isEstimated" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="notes" optional="YES" attributeType="String"/>
<attribute name="value" optional="YES" attributeType="Decimal"/>
<relationship name="source" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="InvestmentSource" inverseName="snapshots" inverseEntity="InvestmentSource"/>
@@ -7,12 +7,25 @@ public class Snapshot: NSManagedObject, Identifiable {
return NSFetchRequest<Snapshot>(entityName: "Snapshot")
}
@NSManaged public var id: UUID
@NSManaged public var date: Date
@objc public var id: UUID {
get { safeValue(forKey: "id", fallback: UUID(), healing: true) }
set { setManagedValue(newValue, forKey: "id") }
}
@objc public var date: Date {
get { safeValue(forKey: "date", fallback: .distantPast) }
set { setManagedValue(newValue, forKey: "date") }
}
@NSManaged public var value: NSDecimalNumber?
@NSManaged public var contribution: NSDecimalNumber?
@NSManaged public var notes: String?
@NSManaged public var createdAt: Date
/// True for auto-generated (interpolated) snapshots that fill a gap between two
/// real snapshots. Estimated snapshots are shown with a distinct marker and can
/// be removed in bulk. Defaults to false (model default) for all real snapshots.
@NSManaged public var isEstimated: Bool
@objc public var createdAt: Date {
get { safeValue(forKey: "createdAt", fallback: .distantPast) }
set { setManagedValue(newValue, forKey: "createdAt") }
}
@NSManaged public var source: InvestmentSource?
public override func awakeFromInsert() {
+435 -1
View File
@@ -123,6 +123,15 @@ class CoreDataStack: ObservableObject {
@Published private(set) var lastExportDate: Date?
@Published private(set) var isSyncing = false
@Published private(set) var lastSyncError: String?
/// Full recursive dump of the last sync error tree (leaf CKError codes, record
/// types, retry-after, raw userInfo keys). Surfaced in the copyable diagnostics
/// so we can see WHICH record CloudKit rejects, not just the opaque code 2.
@Published private(set) var lastSyncErrorDetail: String?
/// Human-readable, actionable explanation of the last sync error (nil if none).
@Published private(set) var lastSyncErrorHint: String?
/// True only when sync is genuinely broken (never synced + total failure).
/// Partial failures after a working sync are informational, not alarming.
@Published private(set) var syncErrorIsCritical = false
var lastSyncDate: Date? { lastImportDate }
@@ -183,9 +192,32 @@ class CoreDataStack: ObservableObject {
print("Core Data failed to load: \(error), \(error.userInfo)")
} else {
print("Core Data loaded successfully at: \(description.url?.path ?? "unknown")")
#if DEBUG
// Dev-only, opt-in: push the FULL model schema to the Development CloudKit
// environment so optional fields that were never exported by a Debug run
// (e.g. monthlyContribution on InvestmentSource, allocationTarget on Category)
// get materialised otherwise they can't be deployed to Production and every
// Production export rejects those records (bare CKErrorPartialFailure).
// Run ONCE from Xcode with launch arg -InitializeCloudKitSchema, watch for the
// log, then deploy to Production in the CloudKit Console and remove the arg.
if ProcessInfo.processInfo.arguments.contains("-InitializeCloudKitSchema"),
let ckContainer = container as? NSPersistentCloudKitContainer {
do {
try ckContainer.initializeCloudKitSchema(options: [])
print("✅ CloudKit schema initialized in Development. Now open the CloudKit Console → Deploy Schema Changes → Production.")
} catch {
print("❌ initializeCloudKitSchema failed: \(error)")
}
}
#endif
}
DispatchQueue.main.async {
self?.isLoaded = true
// Merge the pre-stable-UUID default duplicates. Safe now: the merge
// reassigns children then deletes losers, and the relevant relationships
// use the Nullify delete rule so a propagated parent-delete can never
// cascade-wipe children (unlike the build-68 Cascade disaster).
self?.mergeDefaultDuplicates()
MonthlyCheckInStore.migrateIfNeeded()
// Migrate device-local UserDefaults data into Core Data so it syncs via iCloud.
MonthlyContributionStore.migrateIfNeeded(context: container.viewContext)
@@ -276,6 +308,243 @@ class CoreDataStack: ObservableObject {
return objectsToDelete.count
}
// MARK: - Logical Duplicate Cleanup (cross-device / cross-environment)
/// Removes *logical* duplicates: records that represent the same real-world
/// entity but carry DIFFERENT UUIDs. These appear when the same data is
/// created independently on two devices or in the Development vs Production
/// CloudKit environments and then merged by CloudKit, which keys on the
/// record name (our UUID) and therefore never recognises them as the same,
/// keeping both. The classic trigger here: `createDefaultAccountIfNeeded` and
/// `ensureDefaultCategoriesExist` seed defaults on every device at launch
/// *before* the first import arrives, so each device contributes its own
/// "Default" account + 8 categories they pile up (8 accounts, 23 categories).
///
/// The UUID-based `cleanupDuplicateObjects()` cannot catch these because the
/// UUIDs differ. Here we group by a stable *business key* and keep a single
/// deterministic winner (earliest `createdAt`, tie-broken by lowest UUID
/// both synced fields, so every device independently picks the SAME survivor
/// and the deletes converge instead of racing each other into data loss),
/// reassigning children onto the survivor before deleting the losers.
@discardableResult
func cleanupLogicalDuplicates() -> Int {
var removed = 0
let context = viewContext
context.performAndWait {
// Parents first: re-point children onto the surviving parent so the
// child keys (which embed the parent's id) stay stable for later passes.
removed += mergeLogicalDuplicates(
entityName: "Account",
childRelationships: ["sources", "goals"],
context: context,
key: { obj in
let name = (obj.value(forKey: "name") as? String ?? "")
.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
return name.isEmpty ? "" : "account|\(name)"
})
removed += mergeLogicalDuplicates(
entityName: "Category",
childRelationships: ["sources"],
context: context,
key: { obj in
let name = (obj.value(forKey: "name") as? String ?? "")
.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
return name.isEmpty ? "" : "category|\(name)"
})
removed += mergeLogicalDuplicates(
entityName: "InvestmentSource",
childRelationships: ["snapshots", "transactions"],
context: context,
key: { obj in
let name = (obj.value(forKey: "name") as? String ?? "")
.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
guard !name.isEmpty else { return "" }
let acc = (obj.value(forKey: "account") as? NSManagedObject)?
.value(forKey: "id") as? UUID
return "source|\(acc?.uuidString ?? "none")|\(name)"
})
removed += mergeLogicalDuplicates(
entityName: "Snapshot",
childRelationships: [],
context: context,
key: { obj in
// Only dedup snapshots that share the same source, same day AND
// same value a true copy. Orphans (nil source) are left alone.
guard let src = (obj.value(forKey: "source") as? NSManagedObject)?
.value(forKey: "id") as? UUID,
let date = obj.value(forKey: "date") as? Date else { return "" }
let day = (date.timeIntervalSinceReferenceDate / 86_400).rounded(.down)
let value = (obj.value(forKey: "value") as? NSDecimalNumber)?
.stringValue ?? "nil"
return "snapshot|\(src.uuidString)|\(day)|\(value)"
})
removed += mergeLogicalDuplicates(
entityName: "Goal",
childRelationships: [],
context: context,
key: { obj in
let name = (obj.value(forKey: "name") as? String ?? "")
.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
guard !name.isEmpty else { return "" }
let target = (obj.value(forKey: "targetAmount") as? NSDecimalNumber)?
.stringValue ?? "nil"
return "goal|\(name)|\(target)"
})
removed += mergeLogicalDuplicates(
entityName: "JournalEntry",
childRelationships: [],
context: context,
key: { obj in
let monthKey = (obj.value(forKey: "monthKey") as? String ?? "")
.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
return monthKey.isEmpty ? "" : "journal|\(monthKey)"
})
if context.hasChanges {
try? context.save()
}
}
return removed
}
/// Groups objects of `entityName` by `key`, keeps one deterministic winner
/// per group, moves every child in `childRelationships` from the losers onto
/// the winner, then deletes the losers. An empty key means "never dedup this
/// object" (degenerate identity e.g. no name, orphan snapshot).
@discardableResult
private func mergeLogicalDuplicates(
entityName: String,
childRelationships: [String],
context: NSManagedObjectContext,
key: (NSManagedObject) -> String
) -> Int {
let request = NSFetchRequest<NSManagedObject>(entityName: entityName)
guard let objects = try? context.fetch(request), !objects.isEmpty else { return 0 }
var groups: [String: [NSManagedObject]] = [:]
for obj in objects {
let k = key(obj)
guard !k.isEmpty else { continue }
groups[k, default: []].append(obj)
}
var removed = 0
for (_, group) in groups where group.count > 1 {
// Deterministic ordering same winner on every device.
let sorted = group.sorted { a, b in
let ca = (a.value(forKey: "createdAt") as? Date) ?? .distantPast
let cb = (b.value(forKey: "createdAt") as? Date) ?? .distantPast
if ca != cb { return ca < cb }
let ia = (a.value(forKey: "id") as? UUID)?.uuidString ?? ""
let ib = (b.value(forKey: "id") as? UUID)?.uuidString ?? ""
return ia < ib
}
let winner = sorted[0]
for loser in sorted.dropFirst() {
for rel in childRelationships {
let children = loser.mutableSetValue(forKey: rel)
guard children.count > 0 else { continue }
let winnerChildren = winner.mutableSetValue(forKey: rel)
// Snapshot children first so the winner is a superset before delete.
for child in children.allObjects {
winnerChildren.add(child) // reassigns the modeled inverse
}
}
context.delete(loser)
removed += 1
}
}
if removed > 0 {
print("[LogicalDedup] \(entityName): removed \(removed) duplicate(s)")
}
return removed
}
// MARK: - Merge Default Duplicates (CloudKit-safe)
/// Merges the duplicate default Accounts and Categories that piled up before
/// the stable-UUID fix (per-device "Default" accounts + default categories with
/// random UUIDs). CloudKit-safe now because Account.sources/goals and
/// Category.sources use the Nullify delete rule so a merged-away parent's
/// delete can never cascade-wipe children on any device (the build-68 disaster
/// was Cascade + cross-device import ordering). Children are reassigned to a
/// DETERMINISTIC winner (the stable-UUID record if present, else earliest
/// createdAt / lowest UUID) so every device converges on the same survivor.
@discardableResult
func mergeDefaultDuplicates() -> Int {
var merged = 0
let context = viewContext
context.performAndWait {
merged += mergeDefaultAccounts(in: context)
merged += mergeDuplicateCategories(in: context)
if context.hasChanges { try? context.save() }
}
return merged
}
private func deterministicWinner(_ objects: [NSManagedObject], stableID: UUID?) -> NSManagedObject? {
if let stableID, let canonical = objects.first(where: { ($0.value(forKey: "id") as? UUID) == stableID }) {
return canonical
}
return objects.sorted {
let a = ($0.value(forKey: "createdAt") as? Date) ?? .distantPast
let b = ($1.value(forKey: "createdAt") as? Date) ?? .distantPast
if a != b { return a < b }
let ida = ($0.value(forKey: "id") as? UUID)?.uuidString ?? ""
let idb = ($1.value(forKey: "id") as? UUID)?.uuidString ?? ""
return ida < idb
}.first
}
private func reassign(_ children: Any?, key: String, to winner: NSManagedObject) {
guard let set = children as? Set<NSManagedObject> else { return }
for child in Array(set) { child.setValue(winner, forKey: key) }
}
private func mergeDefaultAccounts(in context: NSManagedObjectContext) -> Int {
let request = NSFetchRequest<NSManagedObject>(entityName: "Account")
request.predicate = NSPredicate(format: "name == %@", Account.defaultAccountName)
guard let accounts = try? context.fetch(request), accounts.count > 1,
let winner = deterministicWinner(accounts, stableID: Account.defaultAccountStableID) else { return 0 }
var removed = 0
for loser in accounts where loser !== winner {
reassign(loser.value(forKey: "sources"), key: "account", to: winner)
reassign(loser.value(forKey: "goals"), key: "account", to: winner)
context.delete(loser)
removed += 1
}
if removed > 0 { print("[MergeDefaults] accounts: removed \(removed)") }
return removed
}
private func mergeDuplicateCategories(in context: NSManagedObjectContext) -> Int {
let request = NSFetchRequest<NSManagedObject>(entityName: "Category")
guard let categories = try? context.fetch(request), !categories.isEmpty else { return 0 }
var byName: [String: [NSManagedObject]] = [:]
for c in categories {
let name = (c.value(forKey: "name") as? String ?? "").trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
guard !name.isEmpty else { continue }
byName[name, default: []].append(c)
}
var removed = 0
for (name, group) in byName where group.count > 1 {
let stable = Category.stableID(for: name)
guard let winner = deterministicWinner(group, stableID: stable) else { continue }
for loser in group where loser !== winner {
reassign(loser.value(forKey: "sources"), key: "category", to: winner)
context.delete(loser)
removed += 1
}
}
if removed > 0 { print("[MergeDefaults] categories: removed \(removed)") }
return removed
}
// MARK: - Save Context
func save() {
@@ -317,6 +586,12 @@ class CoreDataStack: ObservableObject {
if removed > 0 {
print("[RemoteChanges] Removed \(removed) duplicate objects after CloudKit import")
}
// Merge default duplicates after each import (CloudKit-safe via Nullify
// rules + deterministic winner devices converge, no cascade wipes).
let mergedDefaults = self.mergeDefaultDuplicates()
if mergedDefaults > 0 {
print("[RemoteChanges] Merged \(mergedDefaults) default duplicates after CloudKit import")
}
// Notify repositories to re-fetch unconditionally.
NotificationCenter.default.post(name: .cloudKitForceReload, object: nil)
self.objectWillChange.send()
@@ -343,6 +618,15 @@ class CoreDataStack: ObservableObject {
} else if let error = event.error {
let typeLabel = event.type == .import ? "import" : event.type == .export ? "export" : "setup"
self.lastSyncError = "\(typeLabel): \(Self.describeError(error))"
self.lastSyncErrorDetail = "\(typeLabel)\n\(Self.deepErrorReport(error))"
self.lastSyncErrorHint = Self.hint(for: error)
// Severity: a partial failure AFTER sync has worked at least
// once is background noise (a subset of records CloudKit
// retries), NOT a broken-sync alarm. Only a total failure with
// no data ever synced is critical.
let hasSyncedBefore = self.lastImportDate != nil || self.lastExportDate != nil
let isPartial = (error as NSError).code == 2 // CKErrorPartialFailure
self.syncErrorIsCritical = !(hasSyncedBefore || isPartial)
print("CloudKit \(typeLabel) full error:\n\(error)\nuserInfo: \((error as NSError).userInfo)")
}
}
@@ -376,6 +660,144 @@ class CoreDataStack: ObservableObject {
return parts.joined(separator: "\n")
}
/// Turns an opaque CloudKit error into a human-readable, actionable hint by
/// digging into CKPartialErrorsByItemIDKey the per-record failures hidden
/// inside a CKErrorPartialFailure (code 2). The dominant underlying code
/// tells us the real problem: schema not deployed (unknownItem/invalidArguments)
/// vs. quota vs. transient network.
static func hint(for error: Error) -> String? {
let ns = error as NSError
// Collect all leaf CKError codes across a possible partial-failure tree.
var codes: [Int] = []
func collect(_ e: NSError) {
if e.domain == "CKErrorDomain", e.code != 2 { codes.append(e.code) }
if let partial = e.userInfo["CKPartialErrorsByItemIDKey"] as? [AnyHashable: Any] {
for case let sub as NSError in partial.values { collect(sub) }
}
for value in e.userInfo.values {
if let sub = value as? NSError, sub !== e { collect(sub) }
}
}
collect(ns)
// CKError codes: 14 = unknownItem, 12 = invalidArguments, 6 = partialFailure,
// 25 = quotaExceeded, 4 = networkFailure/unavailable, 3 = networkUnavailable,
// 15 = serverRejectedRequest, 26 = operationCancelled.
if codes.contains(14) || codes.contains(12) || codes.contains(15) {
return String(localized: "icloud_hint_schema")
}
if codes.contains(25) {
return String(localized: "icloud_hint_quota")
}
if codes.contains(3) || codes.contains(4) {
return String(localized: "icloud_hint_network")
}
if ns.code == 2 {
return String(localized: "icloud_hint_partial_generic")
}
return nil
}
/// Full recursive walk of a CloudKit/Core Data error tree. Unlike `describeError`
/// (a compact one-liner), this follows NSUnderlyingError chains and
/// CKPartialErrorsByItemIDKey, and pulls the failing record TYPE out of any
/// embedded CKRecord which is what tells us WHICH entity CloudKit rejects.
static func deepErrorReport(_ error: Error) -> String {
var lines: [String] = []
var leafCodes = Set<Int>()
var recordTypes = Set<String>()
var visited = 0
func walk(_ err: Error, indent: Int) {
guard visited < 300 else { return }
visited += 1
let ns = err as NSError
let pad = String(repeating: " ", count: indent)
lines.append("\(pad)\(ns.domain)(\(ns.code))")
if ns.domain == "CKErrorDomain", ns.code != 2 { leafCodes.insert(ns.code) }
let info = ns.userInfo
if let reason = info[NSLocalizedFailureReasonErrorKey] as? String {
lines.append("\(pad) reason: \(reason)")
}
if let desc = info[NSLocalizedDescriptionKey] as? String {
lines.append("\(pad) desc: \(desc)")
}
if let retry = info["CKErrorRetryAfterKey"] {
lines.append("\(pad) retryAfter: \(retry)")
}
for key in ["CKRecordChangedErrorServerRecordKey",
"CKRecordChangedErrorClientRecordKey",
"CKRecordChangedErrorAncestorRecordKey"] {
if let rec = info[key] as? CKRecord {
recordTypes.insert(rec.recordType)
lines.append("\(pad) record: \(rec.recordType) [\(rec.recordID.recordName)]")
}
}
if let partial = info["CKPartialErrorsByItemIDKey"] as? [AnyHashable: Any] {
lines.append("\(pad) partialErrors: \(partial.count)")
for (rid, value) in partial.prefix(25) {
if let name = (rid as? CKRecord.ID)?.recordName {
lines.append("\(pad) item: \(name)")
}
if let sub = value as? Error { walk(sub, indent: indent + 3) }
}
}
if let underlying = info[NSUnderlyingErrorKey] as? Error {
lines.append("\(pad) underlying:")
walk(underlying, indent: indent + 2)
}
for (k, v) in info {
let ks = "\(k)"
if ks == NSUnderlyingErrorKey || ks == "CKPartialErrorsByItemIDKey" { continue }
if let sub = v as? Error, (sub as NSError) !== ns {
lines.append("\(pad) [\(ks)]:")
walk(sub, indent: indent + 2)
}
}
let keys = info.keys.map { "\($0)" }.sorted()
if !keys.isEmpty { lines.append("\(pad) keys: \(keys.joined(separator: ", "))") }
}
walk(error, indent: 0)
var summary = ""
if !leafCodes.isEmpty {
summary += "leafCKCodes: [\(leafCodes.sorted().map(String.init).joined(separator: ", "))]\n"
}
if !recordTypes.isEmpty {
summary += "recordTypes: [\(recordTypes.sorted().joined(separator: ", "))]\n"
}
return summary + lines.joined(separator: "\n")
}
/// Local Core Data integrity probe: per-entity counts + records with nil id /
/// nil createdAt / orphan relationships that can silently block a CloudKit export.
func integrityReport() -> String {
let ctx = viewContext
var lines: [String] = []
let entities = ["Account", "Category", "InvestmentSource", "Snapshot", "Goal", "JournalEntry"]
for name in entities {
let count = (try? ctx.count(for: NSFetchRequest<NSManagedObject>(entityName: name))) ?? -1
lines.append("\(name): \(count)")
}
func flag(_ label: String, _ n: Int) { if n > 0 { lines.append("⚠︎ \(label): \(n)") } }
func fetch(_ name: String) -> [NSManagedObject] {
(try? ctx.fetch(NSFetchRequest<NSManagedObject>(entityName: name))) ?? []
}
let sources = fetch("InvestmentSource")
flag("sources nil id", sources.filter { $0.value(forKey: "id") == nil }.count)
flag("sources nil createdAt", sources.filter { $0.value(forKey: "createdAt") == nil }.count)
let snaps = fetch("Snapshot")
flag("snapshots nil id", snaps.filter { $0.value(forKey: "id") == nil }.count)
flag("snapshots nil createdAt", snaps.filter { $0.value(forKey: "createdAt") == nil }.count)
flag("orphan snapshots (nil source)", snaps.filter { $0.value(forKey: "source") == nil }.count)
let goals = fetch("Goal")
flag("goals nil id", goals.filter { $0.value(forKey: "id") == nil }.count)
flag("goals nil createdAt", goals.filter { $0.value(forKey: "createdAt") == nil }.count)
return lines.joined(separator: "\n")
}
func forceReload() {
viewContext.perform { [weak self] in
self?.viewContext.refreshAllObjects()
@@ -398,7 +820,7 @@ class CoreDataStack: ObservableObject {
guard Self.cloudKitEnabled else { completion(0); return }
let entities = ["Account", "Category", "InvestmentSource", "Snapshot", "Goal"]
let context = newBackgroundContext()
context.perform { [weak self] in
context.perform {
var totalTouched = 0
for entityName in entities {
let request = NSFetchRequest<NSManagedObject>(entityName: entityName)
@@ -432,13 +854,25 @@ class CoreDataStack: ObservableObject {
// MARK: - Widget Data Refresh
/// When true, `refreshWidgetData()` becomes a no-op. Set during batch operations
/// (e.g. CSV import of hundreds of snapshots) so we don't run a synchronous WAL
/// checkpoint + widget reload per row that hammered the main thread into an ANR.
/// Call `refreshWidgetData()` once when the batch finishes.
var suppressWidgetRefresh = false
func refreshWidgetData() {
if suppressWidgetRefresh { return }
if #available(iOS 14.0, *) {
if Self.appGroupEnabled {
checkpointWAL()
WidgetCenter.shared.reloadAllTimelines()
}
}
// The paired watch reads the same data changes; every repository that
// refreshes the widget wants the watch refreshed too.
Task { @MainActor in
WatchSyncService.shared.scheduleSync()
}
}
/// Forces SQLite to checkpoint the WAL file, ensuring all changes are written to the main database file.
@@ -1,9 +1,47 @@
import SwiftUI
/// Tone / severity of an insight, used to pick an accent color and to
/// prioritize which insights are surfaced first.
enum InsightTone {
case positive
case neutral
case attention
/// Higher priority insights are shown first when capping the list.
var priority: Int {
switch self {
case .attention: return 2
case .positive: return 1
case .neutral: return 0
}
}
}
struct PortfolioInsight: Identifiable {
let id: String
let systemImage: String
let title: String
let value: String
let accentColor: Color
/// Optional supporting line shown under the value in the richer card.
let detail: String?
let tone: InsightTone
init(
id: String,
systemImage: String,
title: String,
value: String,
accentColor: Color,
detail: String? = nil,
tone: InsightTone = .neutral
) {
self.id = id
self.systemImage = systemImage
self.title = title
self.value = value
self.accentColor = accentColor
self.detail = detail
self.tone = tone
}
}
@@ -96,9 +96,12 @@ class AccountRepository: ObservableObject {
return existing
}
// No accounts exist, create Default account
// No accounts exist, create Default account.
// Use a DETERMINISTIC id so every device creates the same CloudKit record
// instead of a per-device duplicate (root-cause fix for the dedup pile-up).
let defaultCurrency = AppSettings.getOrCreate(in: context).currency
let account = Account(context: context)
account.id = Account.defaultAccountStableID
account.name = Account.defaultAccountName
account.currency = defaultCurrency
account.inputMode = InputMode.simple.rawValue
@@ -109,6 +109,8 @@ class CategoryRepository: ObservableObject {
}
let category = Category(context: context)
// Deterministic id same CloudKit record on every device (no duplicates).
category.id = Category.stableID(for: categoryData.name)
category.name = categoryData.name
category.colorHex = categoryData.colorHex
category.icon = categoryData.icon
@@ -53,25 +53,21 @@ class GoalRepository: ObservableObject {
// MARK: - Create
/// Check if a goal with the given name already exists in the account (case-insensitive)
func goalExists(name: String, in account: Account?) -> Bool {
let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines)
let normalized = trimmed.lowercased()
return goals.contains { goal in
let nameMatches = goal.name.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == normalized
let accountMatches = goal.account?.id == account?.id
return nameMatches && accountMatches
}
/// Check if a goal with the given name already exists in the same scope
/// (account + category), case-insensitive
func goalExists(name: String, in account: Account?, category: Category? = nil) -> Bool {
findGoal(byName: name, in: account, category: category) != nil
}
/// Find an existing goal by name (case-insensitive) within the same account
func findGoal(byName name: String, in account: Account?) -> Goal? {
/// Find an existing goal by name (case-insensitive) within the same scope
func findGoal(byName name: String, in account: Account?, category: Category? = nil) -> Goal? {
let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines)
let normalized = trimmed.lowercased()
return goals.first { goal in
let nameMatches = goal.name.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == normalized
let accountMatches = goal.account?.id == account?.id
return nameMatches && accountMatches
let categoryMatches = goal.category?.id == category?.id
return nameMatches && accountMatches && categoryMatches
}
}
@@ -80,10 +76,11 @@ class GoalRepository: ObservableObject {
name: String,
targetAmount: Decimal,
targetDate: Date? = nil,
account: Account?
account: Account?,
category: Category? = nil
) -> Goal {
// Check if goal with same name already exists in this account
if let existing = findGoal(byName: name, in: account) {
// Check if goal with same name already exists in this scope
if let existing = findGoal(byName: name, in: account, category: category) {
return existing
}
@@ -92,6 +89,7 @@ class GoalRepository: ObservableObject {
goal.targetAmount = NSDecimalNumber(decimal: targetAmount)
goal.targetDate = targetDate
goal.account = account
goal.category = category
save()
return goal
}
@@ -104,7 +102,9 @@ class GoalRepository: ObservableObject {
targetAmount: Decimal? = nil,
targetDate: Date? = nil,
clearTargetDate: Bool = false,
isActive: Bool? = nil
isActive: Bool? = nil,
category: Category? = nil,
clearCategory: Bool = false
) {
if let name = name {
goal.name = name
@@ -120,6 +120,11 @@ class GoalRepository: ObservableObject {
if let isActive = isActive {
goal.isActive = isActive
}
if clearCategory {
goal.category = nil
} else if let category = category {
goal.category = category
}
save()
}
@@ -102,7 +102,8 @@ class SnapshotRepository: ObservableObject {
date: Date = Date(),
value: Decimal,
contribution: Decimal? = nil,
notes: String? = nil
notes: String? = nil,
batch: Bool = false
) -> Snapshot {
let snapshot = Snapshot(context: context)
snapshot.source = source
@@ -113,10 +114,65 @@ class SnapshotRepository: ObservableObject {
}
snapshot.notes = notes
save()
// In batch mode (CSV import), skip the per-row save + widget refresh the
// caller saves the whole context once at the end. Saving per row triggered a
// main-thread WAL checkpoint each time ANR on large imports.
if !batch {
save()
}
return snapshot
}
// MARK: - Gap Auto-Fill (estimated snapshots)
/// Creates ESTIMATED snapshots for the missing months of an internal gap,
/// linearly interpolated between the source's value at `from` and at `to`.
/// They are marked `isEstimated` so they read as clearly distinct from real
/// data and can be removed in bulk. Idempotent (skips months that already have
/// a snapshot). Returns how many were created.
@discardableResult
func autoFillGap(source: InvestmentSource, from: Date, to: Date, missingMonthDates: [Date]) -> Int {
let snaps = source.snapshotsArray
func value(atMonth month: Date) -> Decimal? {
snaps.filter { $0.date.startOfMonth == month.startOfMonth }
.max(by: { $0.date < $1.date })?.decimalValue
}
guard let v0 = value(atMonth: from),
let v1 = value(atMonth: to),
!missingMonthDates.isEmpty else { return 0 }
let steps = missingMonthDates.count + 1 // segments between v0 and v1
var created = 0
for (i, month) in missingMonthDates.enumerated() {
let monthStart = month.startOfMonth
if snaps.contains(where: { $0.date.startOfMonth == monthStart }) { continue }
let fraction = Decimal(i + 1) / Decimal(steps)
let interpolated = v0 + (v1 - v0) * fraction
let snapshot = Snapshot(context: context)
snapshot.source = source
snapshot.date = monthStart
snapshot.value = NSDecimalNumber(decimal: interpolated)
snapshot.isEstimated = true
snapshot.notes = String(localized: "gaps_estimated_note")
created += 1
}
if created > 0 { save() }
return created
}
/// Deletes all estimated (auto-filled) snapshots, optionally scoped to one
/// source. Returns how many were removed.
@discardableResult
func deleteEstimatedSnapshots(for source: InvestmentSource? = nil) -> Int {
let request = NSFetchRequest<Snapshot>(entityName: "Snapshot")
request.predicate = NSPredicate(format: "isEstimated == YES")
guard let estimated = try? context.fetch(request) else { return 0 }
let targets = source == nil ? estimated : estimated.filter { $0.source?.id == source?.id }
for snapshot in targets { context.delete(snapshot) }
if !targets.isEmpty { save() }
return targets.count
}
// MARK: - Update
func updateSnapshot(
+249 -185
View File
@@ -1,286 +1,350 @@
{
"sourceLanguage": "en",
"strings": {
"Add a snapshot in ${applicationName}": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Füge einen Eintrag in ${applicationName} hinzu"
"sourceLanguage" : "en",
"strings" : {
"Add a snapshot in ${applicationName}" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Füge einen Eintrag in ${applicationName} hinzu"
}
},
"es-ES": {
"stringUnit": {
"state": "translated",
"value": "Añade un registro en ${applicationName}"
"es-ES" : {
"stringUnit" : {
"state" : "translated",
"value" : "Añade un registro en ${applicationName}"
}
},
"fr": {
"stringUnit": {
"state": "translated",
"value": "Ajoute un relevé dans ${applicationName}"
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Ajoute un relevé dans ${applicationName}"
}
},
"it": {
"stringUnit": {
"state": "translated",
"value": "Aggiungi una rilevazione in ${applicationName}"
"it" : {
"stringUnit" : {
"state" : "translated",
"value" : "Aggiungi una rilevazione in ${applicationName}"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "${applicationName}でスナップショットを追加"
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "${applicationName}でスナップショットを追加"
}
},
"pt-BR": {
"stringUnit": {
"state": "translated",
"value": "Adicione um registro no ${applicationName}"
"pt-BR" : {
"stringUnit" : {
"state" : "translated",
"value" : "Adicione um registro no ${applicationName}"
}
}
}
},
"Check my portfolio in ${applicationName}": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Zeige mein Portfolio in ${applicationName}"
"Check my portfolio in ${applicationName}" : {
"extractionState" : "extracted_with_value",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "Zeige mein Portfolio in ${applicationName}"
}
},
"es-ES": {
"stringUnit": {
"state": "translated",
"value": "Consulta mi cartera en ${applicationName}"
"en" : {
"stringSet" : {
"state" : "new",
"values" : [
"Check my portfolio in ${applicationName}",
"Show my net worth in ${applicationName}"
]
}
},
"fr": {
"stringUnit": {
"state": "translated",
"value": "Consulte mon portefeuille dans ${applicationName}"
"es-ES" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "Consulta mi cartera en ${applicationName}"
}
},
"it": {
"stringUnit": {
"state": "translated",
"value": "Controlla il mio portafoglio in ${applicationName}"
"fr" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "Consulte mon portefeuille dans ${applicationName}"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "${applicationName}でポートフォリオを確認"
"it" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "Controlla il mio portafoglio in ${applicationName}"
}
},
"pt-BR": {
"stringUnit": {
"state": "translated",
"value": "Verifique minha carteira no ${applicationName}"
"ja" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "${applicationName}でポートフォリオを確認"
}
},
"pt-BR" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "Verifique minha carteira no ${applicationName}"
}
}
}
},
"Log a value in ${applicationName}": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Erfasse einen Wert in ${applicationName}"
"Log a value in ${applicationName}" : {
"extractionState" : "extracted_with_value",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "Erfasse einen Wert in ${applicationName}"
}
},
"es-ES": {
"stringUnit": {
"state": "translated",
"value": "Registra un valor en ${applicationName}"
"en" : {
"stringSet" : {
"state" : "new",
"values" : [
"Log a value in ${applicationName}",
"Log my ${source} balance in ${applicationName}",
"Update ${source} in ${applicationName}"
]
}
},
"fr": {
"stringUnit": {
"state": "translated",
"value": "Enregistre une valeur dans ${applicationName}"
"es-ES" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "Registra un valor en ${applicationName}"
}
},
"it": {
"stringUnit": {
"state": "translated",
"value": "Registra un valore in ${applicationName}"
"fr" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "Enregistre une valeur dans ${applicationName}"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "${applicationName}で金額を記録"
"it" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "Registra un valore in ${applicationName}"
}
},
"pt-BR": {
"stringUnit": {
"state": "translated",
"value": "Registre um valor no ${applicationName}"
"ja" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "${applicationName}で金額を記録"
}
},
"pt-BR" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "Registre um valor no ${applicationName}"
}
}
}
},
"Log my ${source} balance in ${applicationName}": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Erfasse den Saldo von ${source} in ${applicationName}"
"Log my ${source} balance in ${applicationName}" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Erfasse den Saldo von ${source} in ${applicationName}"
}
},
"es-ES": {
"stringUnit": {
"state": "translated",
"value": "Registra el saldo de ${source} en ${applicationName}"
"es-ES" : {
"stringUnit" : {
"state" : "translated",
"value" : "Registra el saldo de ${source} en ${applicationName}"
}
},
"fr": {
"stringUnit": {
"state": "translated",
"value": "Enregistre le solde de ${source} dans ${applicationName}"
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Enregistre le solde de ${source} dans ${applicationName}"
}
},
"it": {
"stringUnit": {
"state": "translated",
"value": "Registra il saldo di ${source} in ${applicationName}"
"it" : {
"stringUnit" : {
"state" : "translated",
"value" : "Registra il saldo di ${source} in ${applicationName}"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "${applicationName}で${source}の残高を記録"
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "${applicationName}で${source}の残高を記録"
}
},
"pt-BR": {
"stringUnit": {
"state": "translated",
"value": "Registre o saldo de ${source} no ${applicationName}"
"pt-BR" : {
"stringUnit" : {
"state" : "translated",
"value" : "Registre o saldo de ${source} no ${applicationName}"
}
}
}
},
"Show my net worth in ${applicationName}": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Zeige mein Vermögen in ${applicationName}"
"Show my net worth in ${applicationName}" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Zeige mein Vermögen in ${applicationName}"
}
},
"es-ES": {
"stringUnit": {
"state": "translated",
"value": "Muestra mi patrimonio en ${applicationName}"
"es-ES" : {
"stringUnit" : {
"state" : "translated",
"value" : "Muestra mi patrimonio en ${applicationName}"
}
},
"fr": {
"stringUnit": {
"state": "translated",
"value": "Affiche mon patrimoine dans ${applicationName}"
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Affiche mon patrimoine dans ${applicationName}"
}
},
"it": {
"stringUnit": {
"state": "translated",
"value": "Mostra il mio patrimonio in ${applicationName}"
"it" : {
"stringUnit" : {
"state" : "translated",
"value" : "Mostra il mio patrimonio in ${applicationName}"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "${applicationName}で純資産を表示"
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "${applicationName}で純資産を表示"
}
},
"pt-BR": {
"stringUnit": {
"state": "translated",
"value": "Mostre meu patrimônio no ${applicationName}"
"pt-BR" : {
"stringUnit" : {
"state" : "translated",
"value" : "Mostre meu patrimônio no ${applicationName}"
}
}
}
},
"Update ${source} in ${applicationName}": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Aktualisiere ${source} in ${applicationName}"
"Update ${source} in ${applicationName}" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Aktualisiere ${source} in ${applicationName}"
}
},
"es-ES": {
"stringUnit": {
"state": "translated",
"value": "Actualiza ${source} en ${applicationName}"
"es-ES" : {
"stringUnit" : {
"state" : "translated",
"value" : "Actualiza ${source} en ${applicationName}"
}
},
"fr": {
"stringUnit": {
"state": "translated",
"value": "Mets à jour ${source} dans ${applicationName}"
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Mets à jour ${source} dans ${applicationName}"
}
},
"it": {
"stringUnit": {
"state": "translated",
"value": "Aggiorna ${source} in ${applicationName}"
"it" : {
"stringUnit" : {
"state" : "translated",
"value" : "Aggiorna ${source} in ${applicationName}"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "${applicationName}で${source}を更新"
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "${applicationName}で${source}を更新"
}
},
"pt-BR": {
"stringUnit": {
"state": "translated",
"value": "Atualize ${source} no ${applicationName}"
"pt-BR" : {
"stringUnit" : {
"state" : "translated",
"value" : "Atualize ${source} no ${applicationName}"
}
}
}
},
"Update my portfolio in ${applicationName}": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Aktualisiere mein Portfolio in ${applicationName}"
"What's my net worth in ${applicationName}" : {
"extractionState" : "extracted_with_value",
"localizations" : {
"en" : {
"stringSet" : {
"state" : "new",
"values" : [
"What's my net worth in ${applicationName}",
"How much is my portfolio worth in ${applicationName}",
"Get my net worth from ${applicationName}"
]
}
}
}
},
"What's my streak in ${applicationName}" : {
"extractionState" : "extracted_with_value",
"localizations" : {
"en" : {
"stringSet" : {
"state" : "new",
"values" : [
"What's my streak in ${applicationName}",
"Check my check-in streak in ${applicationName}"
]
}
}
}
},
"Update my portfolio in ${applicationName}" : {
"extractionState" : "extracted_with_value",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "Aktualisiere mein Portfolio in ${applicationName}"
}
},
"es-ES": {
"stringUnit": {
"state": "translated",
"value": "Actualiza mi cartera en ${applicationName}"
"en" : {
"stringSet" : {
"state" : "new",
"values" : [
"Update my portfolio in ${applicationName}",
"Add a snapshot in ${applicationName}"
]
}
},
"fr": {
"stringUnit": {
"state": "translated",
"value": "Mets à jour mon portefeuille dans ${applicationName}"
"es-ES" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "Actualiza mi cartera en ${applicationName}"
}
},
"it": {
"stringUnit": {
"state": "translated",
"value": "Aggiorna il mio portafoglio in ${applicationName}"
"fr" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "Mets à jour mon portefeuille dans ${applicationName}"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "${applicationName}でポートフォリオを更新"
"it" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "Aggiorna il mio portafoglio in ${applicationName}"
}
},
"pt-BR": {
"stringUnit": {
"state": "translated",
"value": "Atualize minha carteira no ${applicationName}"
"ja" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "${applicationName}でポートフォリオを更新"
}
},
"pt-BR" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "Atualize minha carteira no ${applicationName}"
}
}
}
}
},
"version": "1.0"
"version" : "1.0"
}
@@ -353,6 +353,18 @@
"onboarding_quickstart_subtitle" = "Füge deine erste Investitionsquelle hinzu, um dein Portfolio zu verfolgen.";
"onboarding_quickstart_title" = "Fast fertig";
"reengagement_body" = "Ein paar Minuten reichen, um deine Investitionen im Blick zu behalten.";
"reengagement_goal_title" = "Du bist auf Kurs 📈";
"reengagement_goal_body" = "In diesem Tempo erreichst du \"%@\" bis %@. Sieh, wie nah du bist.";
"reengagement_yoy_title" = "Sieh dir dieses Wachstum an 👀";
"reengagement_yoy_body" = "Du liegst im letzten Jahr %@ im Plus. Sieh dir den Trend an.";
"reengagement_month_title" = "Guter Monat 📈";
"reengagement_month_body" = "Dein Portfolio ist diesen Monat um %@ gewachsen. Wirf einen Blick darauf.";
"reengagement_milestone_title" = "Fast geschafft 🎯";
"reengagement_milestone_body" = "Du bist fast bei %@. Wie nah bist du heute?";
"reengagement_streak_title" = "Halte die Serie am Leben 🔥";
"reengagement_streak_body" = "Du hast %d Monate in Folge erfasst. Hör jetzt nicht auf.";
"reengagement_neutral_title" = "Gibt's was Neues? 👀";
"reengagement_neutral_body" = "In deinem Portfolio hat sich etwas getan. Sieh, was sich geändert hat.";
"reengagement_title" = "Dein Portfolio wartet";
"snapshot_duplicate_add" = "Trotzdem hinzufügen";
"snapshot_contribution_propagate_title" = "Beitrag anwenden";
@@ -412,6 +424,25 @@
"insight_streak_title" = "Tracking-Serie";
"insight_streak_value" = "%d Monate in Folge";
"insight_forecast_title" = "Auf dem Weg zu";
"insight_allocation_title" = "Allokationsabweichung";
"insight_allocation_over_value" = "%1$@ liegt bei %2$.0f%% — über deinem Ziel von %3$.0f%%";
"insight_allocation_under_value" = "%1$@ liegt bei %2$.0f%% — unter deinem Ziel von %3$.0f%%";
"insight_allocation_detail" = "Zum Plan zurück ausbalancieren";
"insight_concentration_title" = "Klumpenrisiko";
"insight_concentration_value" = "%1$@ macht %2$.0f%% deines Portfolios aus";
"insight_concentration_detail" = "Erwäge eine breitere Streuung";
"insight_best_performer_title" = "Bester Wert";
"insight_worst_performer_title" = "Braucht Aufmerksamkeit";
"insight_performer_value" = "%1$@ · %2$+.1f%%";
"insight_diversification_title" = "Diversifikation";
"insight_diversification_single_value" = "Alles in einer Quelle — erwäge eine weitere";
"insight_diversification_good_value" = "Auf %d Kategorien verteilt";
"goal_projection_projected" = "Prognose: %@";
"goal_projection_add_contributions" = "Beiträge hinzufügen, um es zu erreichen";
"goal_chip_ahead" = "Voraus";
"goal_chip_on_track" = "Im Plan";
"goal_chip_behind" = "Im Rückstand";
"goal_chip_at_this_pace" = "In diesem Tempo…";
"notification_milestone_title" = "Portfolio-Meilenstein! 🎉";
"notification_milestone_body" = "Dein Portfolio hat gerade %@ überschritten. Herzlichen Glückwunsch!";
@@ -468,3 +499,211 @@
"chart_share_scan" = "Zum Laden scannen";
"chart_locked_title" = "%@ ist ein Premium-Diagramm";
"chart_locked_cta" = "Premium freischalten";
// Calm 2.0 Home (1.5.0)
"checkin_done_title" = "Check-in %@ erledigt";
"checkin_done_next" = "Nächster: %@";
"checkin_next_due" = "Nächster Check-in: %@";
"checkin_days_left" = "noch %d Tage";
"checkin_due_today" = "Heute fällig";
"checkin_overdue" = "Überfällig";
"dash_chart_yoy" = "Jahr für Jahr";
"home_chart_locked_sub" = "Mit Premium freischalten, um dieses Diagramm zu sehen.";
"checkin_sources_progress" = "%1$d von %2$d Quellen aktualisiert";
// Guided check-in (1.5.0 Fase 2)
"guided_previous_value" = "Letzter Monat";
"guided_paste_amount" = "%@ einfügen";
"guided_next" = "Weiter";
"guided_same_as_last" = "Wie letzten Monat";
"guided_skip" = "Überspringen";
"guided_reflection_title" = "Wie fühlt sich dieser Monat an?";
"guided_reflection_subtitle" = "Eine kurze Notiz für dein zukünftiges Ich.";
"guided_note_placeholder" = "Eine ehrliche Zeile zu diesem Monat…";
"guided_finish" = "Check-in abschließen";
"guided_done_title" = "Check-in %@ erledigt";
"guided_streak" = "%d Monate in Folge";
"guided_share_month" = "Diesen Monat teilen";
"guided_done_cta" = "Fertig";
"journal_no_note" = "Noch keine Notiz.";
// What's New catalog (1.4.11.5.0)
"whats_new_version_header" = "Version %@";
"whats_new_150_redesign_title" = "Ein ruhigeres, klareres Design";
"whats_new_150_redesign_body" = "Neu gestaltetes Home mit klarem Fokus, deinem Check-in im Mittelpunkt und weniger Ablenkung.";
"whats_new_150_guided_title" = "Geführter Monats-Check-in";
"whats_new_150_guided_body" = "Eine Quelle pro Schritt, Live-Deltas, kurze Reflexion — dein 5-Minuten-Ritual, neu gedacht.";
"whats_new_150_rings_title" = "Ziel-Ringe & Journal-Timeline";
"whats_new_150_rings_body" = "Fortschrittsringe im Fitness-Stil für Ziele und eine Stimmungs-Timeline für deine Investment-Geschichte.";
"whats_new_142_ocr_title" = "Update per Screenshot";
"whats_new_142_ocr_body" = "Teile einen Screenshot deiner Bank-App — der Saldo wird auf dem Gerät erkannt, nichts verlässt dein iPhone.";
"whats_new_142_siri_title" = "Siri auf Deutsch";
"whats_new_142_siri_body" = "\"Erfasse den Saldo von Indexa\" — Werte per Sprache oder Kurzbefehle erfassen, ohne die App zu öffnen.";
"whats_new_142_charts_title" = "Diagramme, neu gebaut";
"whats_new_142_charts_body" = "Native Seitenleiste auf dem iPad, passende KPIs pro Diagramm und Teilen als schönes Bild.";
"whats_new_141_extension_title" = "Aktualisieren ohne App-Wechsel";
"whats_new_141_extension_body" = "Teile einen Wert aus deiner Bank-App direkt in Portfolio Journal mit der neuen Erweiterung.";
"whats_new_141_ipad_title" = "Besser auf dem iPad";
"whats_new_141_ipad_body" = "Neu gestaltete Diagramme für den großen Bildschirm, mit Zoom und Wertbeschriftungen.";
"whats_new_141_icloud_title" = "Mehr Sync";
"whats_new_141_icloud_body" = "Monatliche Beiträge und Allokationsziele werden jetzt über iCloud synchronisiert.";
"whats_new_title_format" = "Neu in %@";
// TipKit (1.5.0 Fase 4)
"tip_quick_update_title" = "Alles in Sekunden aktualisieren";
"tip_quick_update_body" = "Quick Update zeigt alle Quellen auf einem Bildschirm — Werte aus der Zwischenablage einfügen, fertig.";
"tip_ocr_title" = "Oder teile einfach einen Screenshot";
"tip_ocr_body" = "Aus deiner Bank-App: Screenshot → Teilen → Portfolio Journal. Der Saldo wird auf dem Gerät gelesen.";
"tip_siri_title" = "Probier es nächstes Mal per Sprache";
"tip_siri_body" = "Sag Siri \"Erfasse den Saldo meines Kontos in Portfolio Journal\" — ohne die App zu öffnen.";
"tip_chart_share_title" = "Teile dieses Diagramm";
"tip_chart_share_body" = "Erzeugt ein schönes Bild mit deinen Daten, bereit für X oder WhatsApp.";
"allocation_total" = "Gesamt";
// iCloud error hints (1.5.0)
"icloud_hint_schema" = "iCloud hat einige Datensätze abgelehnt. Meist wird das iCloud-Schema noch eingerichtet — löst sich oft innerhalb weniger Stunden oder nach dem nächsten App-Update.";
"icloud_hint_quota" = "Dein iCloud-Speicher ist voll. Gib Speicher frei und versuche es erneut.";
"icloud_hint_network" = "iCloud nicht erreichbar. Prüfe deine Verbindung und versuche es erneut.";
"icloud_hint_partial_generic" = "Einige Datensätze konnten diesmal nicht synchronisiert werden. Die App versucht es automatisch erneut.";
"icloud_status_error" = "iCloud-Synchronisierungsproblem";
"icloud_status_syncing" = "Synchronisierung mit iCloud";
"icloud_show_details" = "Technische Details anzeigen";
"yoy_vs_prev" = "vs %@";
"yoy_best_year" = "Bestes Jahr";
"yoy_worst_year" = "Schlechtestes Jahr";
// Quick Update paste/scan (1.5.0)
"quick_update_paste_value" = "%@ einfügen";
"quick_update_scan" = "Scannen";
"quick_update_done_kbd" = "Fertig";
"quick_update_scanning" = "Bild wird gelesen…";
"quick_update_pick_amount" = "Welcher Betrag?";
"quick_update_no_amount_found" = "Kein Betrag im Bild erkannt.";
"icloud_copy_diagnostics" = "iCloud-Diagnose kopieren";
"icloud_diagnostics_copied" = "Diagnose in Zwischenablage kopiert";
"Smooth Gaps in Charts" = "Lücken in Diagrammen glätten";
// MARK: - Data Gaps (1.5.0)
"gaps_banner_title" = "Für %d Monate fehlen Daten";
"gaps_missing_count" = "%d fehlen";
"gaps_range_between" = "Zwischen %@ und %@";
"Fill in missing data" = "Fehlende Daten ergänzen";
"Missing Data" = "Fehlende Daten";
"Fill Gap" = "Lücke füllen";
"The chart estimates these months. Add real snapshots to keep your history accurate." = "Das Diagramm schätzt diese Monate. Füge echte Werte hinzu, damit dein Verlauf genau bleibt.";
"Dismiss" = "Ausblenden";
// MARK: - Shareable Monthly Summary (1.5.0)
"share_summary_streak_badge" = "%d Mon.";
"Monthly Summary" = "Monatsübersicht";
"Portfolio Value" = "Portfoliowert";
"Tracked with" = "Erfasst mit";
"Share monthly summary" = "Monatsübersicht teilen";
"since last check-in" = "seit dem letzten Check-in";
// MARK: - Import Preview (1.5.0)
"import_preview_nothing" = "Nichts Neues zu importieren. Deine Daten sind bereits aktuell.";
"import_preview_sources" = "%d Quellen erstellen";
"import_preview_snapshots_new" = "%d Einträge erstellen";
"import_preview_snapshots_update" = "%d Einträge aktualisieren";
"import_preview_categories" = "%d Kategorien erstellen";
"import_preview_goals" = "%d Ziele erstellen";
"import_preview_journal_new" = "%d Journaleinträge erstellen";
"import_preview_journal_update" = "%d Journaleinträge aktualisieren";
"Review Import" = "Import prüfen";
"Reviewing import…" = "Import wird geprüft…";
"Import" = "Importieren";
/* 1.6.0 gap auto-fill */
"gaps_estimated_note" = "Geschätzt";
"gaps_autofill_cta" = "%d Monate automatisch füllen";
"Add manually" = "Manuell hinzufügen";
"Auto-fill estimates the missing months between two snapshots (marked as estimated). Or add the real values yourself." = "Das automatische Füllen schätzt die fehlenden Monate zwischen zwei Snapshots (als geschätzt markiert). Oder trage die echten Werte selbst ein.";
/* 1.6.0 auto-backup */
"Auto Backup" = "Automatisches Backup";
"Off" = "Aus";
"Daily" = "Täglich";
"Weekly" = "Wöchentlich";
"Monthly" = "Monatlich";
"Last auto backup" = "Letztes Auto-Backup";
/* 1.6.0 hide balances */
"Hide balances" = "Guthaben ausblenden";
"Show balances" = "Guthaben anzeigen";
"Balance hidden" = "Guthaben ausgeblendet";
"Reveal your balances" = "Guthaben anzeigen";
"Privacy" = "Datenschutz";
"Hide Balances" = "Guthaben ausblenden";
"Require Face ID to Reveal" = "Face ID zum Anzeigen erforderlich";
"Hide all monetary amounts behind a blur. Great for using the app in public or taking screenshots. Optionally require Face ID to reveal them." = "Blendet alle Beträge hinter einer Unschärfe aus. Ideal für die App in der Öffentlichkeit oder für Screenshots. Optional Face ID zum Anzeigen erforderlich.";
/* 1.6.0 Siri intents */
"intent_networth_empty" = "Du hast noch keinen Portfoliowert erfasst.";
"intent_networth_dialog" = "Dein Vermögen beträgt %@.";
"intent_streak" = "Du hast eine Serie von %d Monaten.";
"intent_streak_with_date" = "Du hast eine Serie von %d Monaten. Dein letzter Check-in war am %@.";
"intent_streak_none" = "Du hast noch keine aktive Serie. Erfasse einen Wert, um zu starten.";
// Charts workable (1.6.0)
"chart_range_button" = "Benutzerdefinierter Zeitraum";
"chart_range_from" = "Von";
"chart_range_to" = "Bis";
"chart_filtered_prefix" = "Gefiltert: %@";
"chart_overlay_contributions" = "vs. Einzahlungen";
"chart_insight_best_month" = "Bester Monat im Zeitraum: %1$@ (%2$@)";
// Share cards 2.0 (1.6.0)
"share_options_title" = "Teile deinen Fortschritt";
"share_percent_only" = "Nur Prozente";
"share_format_post" = "Post";
"share_format_story" = "Story";
"share_card_cta" = "Teilen";
// Share cards 2.1 — brand header + download CTA (1.6.0)
"share_hero_eyebrow" = "Gesamtrendite";
"share_metric_yoy" = "Dieses Jahr";
"share_metric_since_inception" = "Seit Beginn";
"share_cta_headline" = "Verfolge deine — kostenlos";
"share_cta_sub" = "Scannen für Portfolio Journal";
// Share cards 2.2 — as-of date + theme (1.6.0)
"share_as_of" = "Stand %@";
"share_theme_dark" = "Dunkel";
"share_theme_light" = "Hell";
// Activation: first source → first snapshot (1.6.1)
"add_source_value_header" = "Heutiger Wert";
"add_source_value_footer" = "Wird als dein erster Check-in gespeichert dein Fortschritt beginnt heute.";
"quick_update_add_first_source" = "Erste Quelle hinzufügen";
/* MARK: - Goal scope (category) */
"goal_scope_section" = "Bereich";
"goal_scope_category" = "Kategorie";
"goal_scope_all_categories" = "Alle Kategorien";
"goal_scope_footer_all" = "Alle Quellen deines Portfolios zählen für dieses Ziel.";
"goal_scope_footer_category" = "Nur Quellen dieser Kategorie zählen für dieses Ziel.";
// MARK: - Biometrics (1.7.0 — Face ID / Touch ID / Optic ID aware)
"biometric_generic_name" = "Face ID oder Touch ID";
"biometric_unlock_with" = "Mit %@ entsperren";
"biometric_failed" = "%@ fehlgeschlagen. Bitte erneut versuchen.";
"biometric_enable" = "%@ aktivieren";
"biometric_unavailable_title" = "%@ nicht verfügbar";
"biometric_unavailable_body" = "%@ ist auf diesem Gerät nicht verfügbar.";
"biometric_pin_required" = "Richte zuerst eine PIN ein, bevor du %@ aktivierst.";
"biometric_disable_first_title" = "Zuerst %@ deaktivieren";
"biometric_disable_first_body" = "Deaktiviere %@, bevor du deine PIN deaktivierst.";
"biometric_pin_to_disable" = "PIN eingeben, um %@ zu deaktivieren";
"biometric_require_to_reveal" = "%@ zum Anzeigen erforderlich";
"biometric_lock_footer" = "Schütze deine Daten mit %@ oder einer 4-stelligen PIN.";
"privacy_footer_biometric" = "Blendet alle Beträge hinter einer Unschärfe aus. Ideal für die App in der Öffentlichkeit oder für Screenshots. Optional %@ zum Anzeigen erforderlich.";
// MARK: - What's New 1.7.0
"whats_new_170_duo_title" = "Bereit für iPhone Duo";
"whats_new_170_duo_body" = "Auf dem Innendisplay zeigen Quellen und Tagebuch Liste und Detail nebeneinander, Diagramme erhalten eine Seitenleiste und mehr Höhe, und deine Position bleibt beim Auf- und Zuklappen erhalten.";
"whats_new_170_inspector_title" = "Schnellupdate neben deinen Diagrammen";
"whats_new_170_inspector_body" = "Auf breiten Bildschirmen öffnet sich das monatliche Schnellupdate in einem Seitenbereich das Dashboard bleibt sichtbar, während du Werte eingibst.";
"whats_new_170_biometrics_title" = "Face ID, Touch ID oder Optic ID";
"whats_new_170_biometrics_body" = "App-Sperre und Saldo-Privatsphäre nutzen jetzt die Biometrie deines Geräts.";
@@ -284,6 +284,18 @@
// MARK: - Re-engagement & Monthly Notifications (1.2.1)
"reengagement_title" = "Your portfolio is waiting";
"reengagement_body" = "A few minutes is all it takes to stay on top of your investments.";
"reengagement_goal_title" = "You're on track 📈";
"reengagement_goal_body" = "At this pace you'll reach \"%@\" by %@. See how close you are.";
"reengagement_yoy_title" = "Look at that growth 👀";
"reengagement_yoy_body" = "You're up %@ over the last year. Check the trend.";
"reengagement_month_title" = "Nice month 📈";
"reengagement_month_body" = "Your portfolio grew %@ this month. Take a look.";
"reengagement_milestone_title" = "So close 🎯";
"reengagement_milestone_body" = "You're almost at %@. How close are you today?";
"reengagement_streak_title" = "Keep the streak alive 🔥";
"reengagement_streak_body" = "You've logged %d months in a row. Don't stop now.";
"reengagement_neutral_title" = "Anything new? 👀";
"reengagement_neutral_body" = "There's been movement in your portfolio. See what changed.";
"monthly_checkin_notification_title" = "Time for your monthly update";
"monthly_checkin_notification_body" = "Log this month's values and track your portfolio growth.";
@@ -420,6 +432,25 @@
"insight_streak_title" = "Tracking streak";
"insight_streak_value" = "%d months in a row";
"insight_forecast_title" = "On track for";
"insight_allocation_title" = "Allocation drift";
"insight_allocation_over_value" = "%1$@ is %2$.0f%% — above your %3$.0f%% target";
"insight_allocation_under_value" = "%1$@ is %2$.0f%% — below your %3$.0f%% target";
"insight_allocation_detail" = "Rebalance to stay on plan";
"insight_concentration_title" = "Concentration risk";
"insight_concentration_value" = "%1$@ is %2$.0f%% of your portfolio";
"insight_concentration_detail" = "Consider spreading it out";
"insight_best_performer_title" = "Best performer";
"insight_worst_performer_title" = "Needs attention";
"insight_performer_value" = "%1$@ · %2$+.1f%%";
"insight_diversification_title" = "Diversification";
"insight_diversification_single_value" = "All in one source — consider adding another";
"insight_diversification_good_value" = "Spread across %d categories";
"goal_projection_projected" = "Projected: %@";
"goal_projection_add_contributions" = "Add contributions to reach this";
"goal_chip_ahead" = "Ahead of schedule";
"goal_chip_on_track" = "On track";
"goal_chip_behind" = "Behind";
"goal_chip_at_this_pace" = "At this pace…";
"notification_milestone_title" = "Portfolio Milestone! 🎉";
"notification_milestone_body" = "Your portfolio just passed %@. Congrats!";
@@ -469,3 +500,211 @@
"chart_share_scan" = "Scan to download";
"chart_locked_title" = "%@ is a Premium chart";
"chart_locked_cta" = "Unlock Premium";
// Calm 2.0 Home (1.5.0)
"checkin_done_title" = "%@ check-in done";
"checkin_done_next" = "Next one: %@";
"checkin_next_due" = "Next check-in: %@";
"checkin_days_left" = "%d days left";
"checkin_due_today" = "Due today";
"checkin_overdue" = "Overdue";
"dash_chart_yoy" = "Year vs Year";
"home_chart_locked_sub" = "Unlock with Premium to explore this chart.";
"checkin_sources_progress" = "%1$d of %2$d sources updated";
// Guided check-in (1.5.0 Fase 2)
"guided_previous_value" = "Last month";
"guided_paste_amount" = "Paste %@";
"guided_next" = "Next";
"guided_same_as_last" = "Same as last month";
"guided_skip" = "Skip";
"guided_reflection_title" = "How does this month feel?";
"guided_reflection_subtitle" = "A quick note for your future self.";
"guided_note_placeholder" = "One honest line about this month…";
"guided_finish" = "Finish check-in";
"guided_done_title" = "%@ check-in done";
"guided_streak" = "%d months in a row";
"guided_share_month" = "Share this month";
"guided_done_cta" = "Done";
"journal_no_note" = "No note yet.";
// What's New catalog (1.4.11.5.0)
"whats_new_version_header" = "Version %@";
"whats_new_150_redesign_title" = "A calmer, cleaner look";
"whats_new_150_redesign_body" = "Redesigned Home with a clear hero, your check-in front and center, and less noise everywhere.";
"whats_new_150_guided_title" = "Guided monthly check-in";
"whats_new_150_guided_body" = "One source per step, live deltas, a quick reflection — your 5-minute ritual, reimagined.";
"whats_new_150_rings_title" = "Goal rings & journal timeline";
"whats_new_150_rings_body" = "Fitness-style progress rings for goals and a mood timeline for your investing story.";
"whats_new_142_ocr_title" = "Update from a screenshot";
"whats_new_142_ocr_body" = "Share a screenshot of your bank app — the balance is recognized on-device, nothing leaves your phone.";
"whats_new_142_siri_title" = "Siri in your language";
"whats_new_142_siri_body" = "\"Log my Indexa balance\" — record values by voice or from Shortcuts, no app needed.";
"whats_new_142_charts_title" = "Charts, rebuilt";
"whats_new_142_charts_body" = "Native sidebar on iPad, smart KPIs per chart, and a share button with beautiful branded images.";
"whats_new_141_extension_title" = "Update without switching apps";
"whats_new_141_extension_body" = "Share a value from your bank app straight into Portfolio Journal with the new share extension.";
"whats_new_141_ipad_title" = "Better on iPad";
"whats_new_141_ipad_body" = "Redesigned charts for the big screen, with pinch-to-zoom and value labels.";
"whats_new_141_icloud_title" = "More in sync";
"whats_new_141_icloud_body" = "Monthly contributions and allocation targets now sync across your devices via iCloud.";
"whats_new_title_format" = "What's New in %@";
// TipKit (1.5.0 Fase 4)
"tip_quick_update_title" = "Update everything in seconds";
"tip_quick_update_body" = "Quick Update lists all your sources in one screen — paste values from your clipboard and go.";
"tip_ocr_title" = "Or just share a screenshot";
"tip_ocr_body" = "From your bank app: screenshot → Share → Portfolio Journal. The balance is read on-device.";
"tip_siri_title" = "Next time, try your voice";
"tip_siri_body" = "Say \"Log my account balance in Portfolio Journal\" to Siri — no need to open the app.";
"tip_chart_share_title" = "Share this chart";
"tip_chart_share_body" = "Renders a beautiful image with your data, ready for X or WhatsApp.";
"allocation_total" = "Total";
// iCloud error hints (1.5.0)
"icloud_hint_schema" = "Some records were rejected by iCloud. This usually means the app's iCloud schema hasn't finished setting up — it often resolves within a few hours of first use, or after the next app update.";
"icloud_hint_quota" = "Your iCloud storage is full. Free up space in iCloud settings and try again.";
"icloud_hint_network" = "Couldn't reach iCloud. Check your connection and try again.";
"icloud_hint_partial_generic" = "Some records couldn't sync this time. The app will retry automatically.";
"icloud_status_error" = "iCloud sync problem";
"icloud_status_syncing" = "Syncing with iCloud";
"icloud_show_details" = "Show technical details";
"yoy_vs_prev" = "vs %@";
"yoy_best_year" = "Best year";
"yoy_worst_year" = "Worst year";
// Quick Update paste/scan (1.5.0)
"quick_update_paste_value" = "Paste %@";
"quick_update_scan" = "Scan";
"quick_update_done_kbd" = "Done";
"quick_update_scanning" = "Reading image…";
"quick_update_pick_amount" = "Which amount?";
"quick_update_no_amount_found" = "No amount recognized in that image.";
"icloud_copy_diagnostics" = "Copy iCloud diagnostics";
"icloud_diagnostics_copied" = "Diagnostics copied to clipboard";
"Smooth Gaps in Charts" = "Smooth Gaps in Charts";
// MARK: - Data Gaps (1.5.0)
"gaps_banner_title" = "%d months are missing data";
"gaps_missing_count" = "%d missing";
"gaps_range_between" = "Between %@ and %@";
"Fill in missing data" = "Fill in missing data";
"Missing Data" = "Missing Data";
"Fill Gap" = "Fill Gap";
"The chart estimates these months. Add real snapshots to keep your history accurate." = "The chart estimates these months. Add real snapshots to keep your history accurate.";
"Dismiss" = "Dismiss";
// MARK: - Shareable Monthly Summary (1.5.0)
"share_summary_streak_badge" = "%d mo";
"Monthly Summary" = "Monthly Summary";
"Portfolio Value" = "Portfolio Value";
"Tracked with" = "Tracked with";
"Share monthly summary" = "Share monthly summary";
"since last check-in" = "since last check-in";
// MARK: - Import Preview (1.5.0)
"import_preview_nothing" = "Nothing new to import. Your data is already up to date.";
"import_preview_sources" = "Create %d sources";
"import_preview_snapshots_new" = "Create %d snapshots";
"import_preview_snapshots_update" = "Update %d snapshots";
"import_preview_categories" = "Create %d categories";
"import_preview_goals" = "Create %d goals";
"import_preview_journal_new" = "Create %d journal entries";
"import_preview_journal_update" = "Update %d journal entries";
"Review Import" = "Review Import";
"Reviewing import…" = "Reviewing import…";
"Import" = "Import";
/* 1.6.0 gap auto-fill */
"gaps_estimated_note" = "Estimated";
"gaps_autofill_cta" = "Auto-fill %d months";
"Add manually" = "Add manually";
"Auto-fill estimates the missing months between two snapshots (marked as estimated). Or add the real values yourself." = "Auto-fill estimates the missing months between two snapshots (marked as estimated). Or add the real values yourself.";
/* 1.6.0 auto-backup */
"Auto Backup" = "Auto Backup";
"Off" = "Off";
"Daily" = "Daily";
"Weekly" = "Weekly";
"Monthly" = "Monthly";
"Last auto backup" = "Last auto backup";
/* 1.6.0 hide balances */
"Hide balances" = "Hide balances";
"Show balances" = "Show balances";
"Balance hidden" = "Balance hidden";
"Reveal your balances" = "Reveal your balances";
"Privacy" = "Privacy";
"Hide Balances" = "Hide Balances";
"Require Face ID to Reveal" = "Require Face ID to Reveal";
"Hide all monetary amounts behind a blur. Great for using the app in public or taking screenshots. Optionally require Face ID to reveal them." = "Hide all monetary amounts behind a blur. Great for using the app in public or taking screenshots. Optionally require Face ID to reveal them.";
/* 1.6.0 Siri intents */
"intent_networth_empty" = "You don't have any portfolio value recorded yet.";
"intent_networth_dialog" = "Your net worth is %@.";
"intent_streak" = "You're on a %d-month streak.";
"intent_streak_with_date" = "You're on a %d-month streak. Your last check-in was %@.";
"intent_streak_none" = "You don't have an active streak yet. Log a value to start one.";
// Charts workable (1.6.0)
"chart_range_button" = "Custom range";
"chart_range_from" = "From";
"chart_range_to" = "To";
"chart_filtered_prefix" = "Filtered: %@";
"chart_overlay_contributions" = "vs Contributions";
"chart_insight_best_month" = "Best month in range: %1$@ (%2$@)";
// Share cards 2.0 (1.6.0)
"share_options_title" = "Share your progress";
"share_percent_only" = "Only percentages";
"share_format_post" = "Post";
"share_format_story" = "Story";
"share_card_cta" = "Share";
// Share cards 2.1 — brand header + download CTA (1.6.0)
"share_hero_eyebrow" = "Total return";
"share_metric_yoy" = "This year";
"share_metric_since_inception" = "Since inception";
"share_cta_headline" = "Track yours — free";
"share_cta_sub" = "Scan to get Portfolio Journal";
// Share cards 2.2 — as-of date + theme (1.6.0)
"share_as_of" = "As of %@";
"share_theme_dark" = "Dark";
"share_theme_light" = "Light";
// Activation: first source → first snapshot (1.6.1)
"add_source_value_header" = "Today's Value";
"add_source_value_footer" = "Saved as your first check-in — your progress starts today.";
"quick_update_add_first_source" = "Add Your First Source";
/* MARK: - Goal scope (category) */
"goal_scope_section" = "Scope";
"goal_scope_category" = "Category";
"goal_scope_all_categories" = "All categories";
"goal_scope_footer_all" = "Every source in your portfolio counts towards this goal.";
"goal_scope_footer_category" = "Only sources in this category count towards this goal.";
// MARK: - Biometrics (1.7.0 — Face ID / Touch ID / Optic ID aware)
"biometric_generic_name" = "Face ID or Touch ID";
"biometric_unlock_with" = "Unlock with %@";
"biometric_failed" = "%@ failed. Please try again.";
"biometric_enable" = "Enable %@";
"biometric_unavailable_title" = "%@ Unavailable";
"biometric_unavailable_body" = "%@ isn't available on this device.";
"biometric_pin_required" = "Enable a PIN before turning on %@.";
"biometric_disable_first_title" = "Disable %@ First";
"biometric_disable_first_body" = "Turn off %@ before disabling your PIN.";
"biometric_pin_to_disable" = "Enter PIN to Disable %@";
"biometric_require_to_reveal" = "Require %@ to Reveal";
"biometric_lock_footer" = "Use %@ or a 4-digit PIN to protect your data.";
"privacy_footer_biometric" = "Hide all monetary amounts behind a blur. Great for using the app in public or taking screenshots. Optionally require %@ to reveal them.";
// MARK: - What's New 1.7.0
"whats_new_170_duo_title" = "Ready for iPhone Duo";
"whats_new_170_duo_body" = "On the inner screen, Sources and Journal show list and detail side by side, Charts get a sidebar and taller plots, and your place is kept when you open or close the phone.";
"whats_new_170_inspector_title" = "Quick Update beside your charts";
"whats_new_170_inspector_body" = "On wide screens the monthly Quick Update opens in a side panel, so the dashboard stays visible while you type values.";
"whats_new_170_biometrics_title" = "Face ID, Touch ID or Optic ID";
"whats_new_170_biometrics_body" = "App Lock and balance privacy now use whichever biometrics your device has.";
@@ -213,6 +213,18 @@
// MARK: - Re-engagement & Monthly Notifications (1.2.1)
"reengagement_title" = "Tu portfolio te espera";
"reengagement_body" = "Unos minutos son suficientes para mantener el control de tus inversiones.";
"reengagement_goal_title" = "Vas por buen camino 📈";
"reengagement_goal_body" = "A este ritmo alcanzarás \"%@\" en %@. Mira lo cerca que estás.";
"reengagement_yoy_title" = "Menudo crecimiento 👀";
"reengagement_yoy_body" = "Llevas un %@ en el último año. Mira la tendencia.";
"reengagement_month_title" = "Buen mes 📈";
"reengagement_month_body" = "Tu cartera creció %@ este mes. Échale un ojo.";
"reengagement_milestone_title" = "Muy cerca 🎯";
"reengagement_milestone_body" = "Estás a punto de llegar a %@. ¿Cuánto te falta hoy?";
"reengagement_streak_title" = "No pierdas la racha 🔥";
"reengagement_streak_body" = "Llevas %d meses seguidos registrando. No pares ahora.";
"reengagement_neutral_title" = "¿Algo nuevo? 👀";
"reengagement_neutral_body" = "Ha habido movimiento en tu cartera. Mira qué ha cambiado.";
"monthly_checkin_notification_title" = "Ya puedes actualizar tu portfolio";
"monthly_checkin_notification_body" = "Registra los valores de este mes y sigue la evolución de tu cartera.";
@@ -364,6 +376,25 @@
"insight_streak_title" = "Racha de seguimiento";
"insight_streak_value" = "%d meses consecutivos";
"insight_forecast_title" = "En camino hacia";
"insight_allocation_title" = "Desvío de asignación";
"insight_allocation_over_value" = "%1$@ está al %2$.0f%% — por encima de tu objetivo del %3$.0f%%";
"insight_allocation_under_value" = "%1$@ está al %2$.0f%% — por debajo de tu objetivo del %3$.0f%%";
"insight_allocation_detail" = "Reequilibra para seguir tu plan";
"insight_concentration_title" = "Riesgo de concentración";
"insight_concentration_value" = "%1$@ es el %2$.0f%% de tu cartera";
"insight_concentration_detail" = "Plantéate diversificar";
"insight_best_performer_title" = "Mejor rendimiento";
"insight_worst_performer_title" = "Requiere atención";
"insight_performer_value" = "%1$@ · %2$+.1f%%";
"insight_diversification_title" = "Diversificación";
"insight_diversification_single_value" = "Todo en una fuente — considera añadir otra";
"insight_diversification_good_value" = "Repartido en %d categorías";
"goal_projection_projected" = "Previsión: %@";
"goal_projection_add_contributions" = "Añade aportaciones para alcanzarlo";
"goal_chip_ahead" = "Por delante";
"goal_chip_on_track" = "En camino";
"goal_chip_behind" = "Con retraso";
"goal_chip_at_this_pace" = "A este ritmo…";
"notification_milestone_title" = "¡Hito de cartera! 🎉";
"notification_milestone_body" = "Tu cartera acaba de superar %@. ¡Enhorabuena!";
@@ -420,3 +451,211 @@
"chart_share_scan" = "Escanea para descargar";
"chart_locked_title" = "%@ es una gráfica Premium";
"chart_locked_cta" = "Desbloquear Premium";
// Calm 2.0 Home (1.5.0)
"checkin_done_title" = "Check-in de %@ completado";
"checkin_done_next" = "Próximo: %@";
"checkin_next_due" = "Próximo check-in: %@";
"checkin_days_left" = "faltan %d días";
"checkin_due_today" = "Hoy";
"checkin_overdue" = "Atrasado";
"dash_chart_yoy" = "Año vs Año";
"home_chart_locked_sub" = "Desbloquéalo con Premium para ver este gráfico.";
"checkin_sources_progress" = "%1$d de %2$d fuentes actualizadas";
// Guided check-in (1.5.0 Fase 2)
"guided_previous_value" = "Mes anterior";
"guided_paste_amount" = "Pegar %@";
"guided_next" = "Siguiente";
"guided_same_as_last" = "Igual que el mes pasado";
"guided_skip" = "Omitir";
"guided_reflection_title" = "¿Cómo sientes este mes?";
"guided_reflection_subtitle" = "Una nota rápida para tu yo del futuro.";
"guided_note_placeholder" = "Una línea honesta sobre este mes…";
"guided_finish" = "Terminar check-in";
"guided_done_title" = "Check-in de %@ completado";
"guided_streak" = "%d meses seguidos";
"guided_share_month" = "Compartir este mes";
"guided_done_cta" = "Hecho";
"journal_no_note" = "Sin nota todavía.";
// What's New catalog (1.4.11.5.0)
"whats_new_version_header" = "Versión %@";
"whats_new_150_redesign_title" = "Un look más limpio y tranquilo";
"whats_new_150_redesign_body" = "Home rediseñado con tu valor total protagonista, el check-in en el centro y menos ruido en todas partes.";
"whats_new_150_guided_title" = "Check-in mensual guiado";
"whats_new_150_guided_body" = "Una fuente por paso, deltas en vivo y una reflexión rápida — tu ritual de 5 minutos, reimaginado.";
"whats_new_150_rings_title" = "Anillos de metas y timeline";
"whats_new_150_rings_body" = "Anillos de progreso estilo Fitness para tus metas y un timeline con tu historia como inversor.";
"whats_new_142_ocr_title" = "Actualiza desde un pantallazo";
"whats_new_142_ocr_body" = "Comparte una captura de tu banco — el saldo se reconoce en el dispositivo, nada sale de tu iPhone.";
"whats_new_142_siri_title" = "Siri en tu idioma";
"whats_new_142_siri_body" = "\"Registra el saldo de Indexa\" — apunta valores por voz o desde Atajos, sin abrir la app.";
"whats_new_142_charts_title" = "Gráficas renovadas";
"whats_new_142_charts_body" = "Sidebar nativa en iPad, KPIs inteligentes por gráfica y botón de compartir con imágenes con estilo.";
"whats_new_141_extension_title" = "Actualiza sin cambiar de app";
"whats_new_141_extension_body" = "Comparte un valor desde la app de tu banco directo a Portfolio Journal con la nueva extensión.";
"whats_new_141_ipad_title" = "Mejor en iPad";
"whats_new_141_ipad_body" = "Gráficas rediseñadas para pantalla grande, con zoom y etiquetas de valor.";
"whats_new_141_icloud_title" = "Más sincronizado";
"whats_new_141_icloud_body" = "Las aportaciones mensuales y los objetivos de asignación ahora se sincronizan vía iCloud.";
"whats_new_title_format" = "Novedades de la %@";
// TipKit (1.5.0 Fase 4)
"tip_quick_update_title" = "Actualiza todo en segundos";
"tip_quick_update_body" = "Quick Update lista todas tus fuentes en una pantalla — pega valores del portapapeles y listo.";
"tip_ocr_title" = "O comparte un pantallazo";
"tip_ocr_body" = "Desde tu app del banco: captura → Compartir → Portfolio Journal. El saldo se lee en el dispositivo.";
"tip_siri_title" = "La próxima vez, prueba con la voz";
"tip_siri_body" = "Dile a Siri \"Registra el saldo de mi cuenta en Portfolio Journal\" — sin abrir la app.";
"tip_chart_share_title" = "Comparte esta gráfica";
"tip_chart_share_body" = "Genera una imagen bonita con tus datos, lista para X o WhatsApp.";
"allocation_total" = "Total";
// iCloud error hints (1.5.0)
"icloud_hint_schema" = "iCloud rechazó algunos registros. Suele significar que el esquema de iCloud aún se está configurando — normalmente se resuelve en unas horas de uso, o tras la próxima actualización de la app.";
"icloud_hint_quota" = "Tu almacenamiento de iCloud está lleno. Libera espacio en ajustes de iCloud e inténtalo de nuevo.";
"icloud_hint_network" = "No se pudo conectar con iCloud. Revisa tu conexión e inténtalo de nuevo.";
"icloud_hint_partial_generic" = "Algunos registros no se sincronizaron esta vez. La app lo reintentará automáticamente.";
"icloud_status_error" = "Problema de sincronización con iCloud";
"icloud_status_syncing" = "Sincronizando con iCloud";
"icloud_show_details" = "Ver detalles técnicos";
"yoy_vs_prev" = "vs %@";
"yoy_best_year" = "Mejor año";
"yoy_worst_year" = "Peor año";
// Quick Update paste/scan (1.5.0)
"quick_update_paste_value" = "Pegar %@";
"quick_update_scan" = "Escanear";
"quick_update_done_kbd" = "Listo";
"quick_update_scanning" = "Leyendo imagen…";
"quick_update_pick_amount" = "¿Qué importe?";
"quick_update_no_amount_found" = "No se reconoció ningún importe en esa imagen.";
"icloud_copy_diagnostics" = "Copiar diagnóstico de iCloud";
"icloud_diagnostics_copied" = "Diagnóstico copiado al portapapeles";
"Smooth Gaps in Charts" = "Suavizar huecos en gráficas";
// MARK: - Data Gaps (1.5.0)
"gaps_banner_title" = "Faltan datos de %d meses";
"gaps_missing_count" = "%d sin datos";
"gaps_range_between" = "Entre %@ y %@";
"Fill in missing data" = "Completa los datos que faltan";
"Missing Data" = "Datos faltantes";
"Fill Gap" = "Rellenar hueco";
"The chart estimates these months. Add real snapshots to keep your history accurate." = "La gráfica estima estos meses. Añade valores reales para mantener tu historial preciso.";
"Dismiss" = "Descartar";
// MARK: - Shareable Monthly Summary (1.5.0)
"share_summary_streak_badge" = "%d m";
"Monthly Summary" = "Resumen mensual";
"Portfolio Value" = "Valor de la cartera";
"Tracked with" = "Registrado con";
"Share monthly summary" = "Compartir resumen mensual";
"since last check-in" = "desde el último chequeo";
// MARK: - Import Preview (1.5.0)
"import_preview_nothing" = "No hay nada nuevo que importar. Tus datos ya están actualizados.";
"import_preview_sources" = "Crear %d fuentes";
"import_preview_snapshots_new" = "Crear %d registros";
"import_preview_snapshots_update" = "Actualizar %d registros";
"import_preview_categories" = "Crear %d categorías";
"import_preview_goals" = "Crear %d objetivos";
"import_preview_journal_new" = "Crear %d entradas del diario";
"import_preview_journal_update" = "Actualizar %d entradas del diario";
"Review Import" = "Revisar importación";
"Reviewing import…" = "Revisando importación…";
"Import" = "Importar";
/* 1.6.0 gap auto-fill */
"gaps_estimated_note" = "Estimado";
"gaps_autofill_cta" = "Autocompletar %d meses";
"Add manually" = "Añadir manualmente";
"Auto-fill estimates the missing months between two snapshots (marked as estimated). Or add the real values yourself." = "El autocompletado estima los meses que faltan entre dos snapshots (marcados como estimados). O añade los valores reales tú mismo.";
/* 1.6.0 auto-backup */
"Auto Backup" = "Copia automática";
"Off" = "Desactivado";
"Daily" = "Diario";
"Weekly" = "Semanal";
"Monthly" = "Mensual";
"Last auto backup" = "Última copia automática";
/* 1.6.0 hide balances */
"Hide balances" = "Ocultar saldos";
"Show balances" = "Mostrar saldos";
"Balance hidden" = "Saldo oculto";
"Reveal your balances" = "Mostrar tus saldos";
"Privacy" = "Privacidad";
"Hide Balances" = "Ocultar saldos";
"Require Face ID to Reveal" = "Requerir Face ID para mostrar";
"Hide all monetary amounts behind a blur. Great for using the app in public or taking screenshots. Optionally require Face ID to reveal them." = "Oculta todas las cifras tras un difuminado. Ideal para usar la app en público o hacer capturas. Opcionalmente, requiere Face ID para mostrarlas.";
/* 1.6.0 Siri intents */
"intent_networth_empty" = "Aún no tienes ningún valor de cartera registrado.";
"intent_networth_dialog" = "Tu patrimonio es %@.";
"intent_streak" = "Llevas una racha de %d meses.";
"intent_streak_with_date" = "Llevas una racha de %d meses. Tu último check-in fue el %@.";
"intent_streak_none" = "Aún no tienes una racha activa. Registra un valor para empezar una.";
// Charts workable (1.6.0)
"chart_range_button" = "Rango personalizado";
"chart_range_from" = "Desde";
"chart_range_to" = "Hasta";
"chart_filtered_prefix" = "Filtrado: %@";
"chart_overlay_contributions" = "vs Aportaciones";
"chart_insight_best_month" = "Mejor mes del rango: %1$@ (%2$@)";
// Share cards 2.0 (1.6.0)
"share_options_title" = "Comparte tu progreso";
"share_percent_only" = "Solo porcentajes";
"share_format_post" = "Post";
"share_format_story" = "Historia";
"share_card_cta" = "Compartir";
// Share cards 2.1 — brand header + download CTA (1.6.0)
"share_hero_eyebrow" = "Rentabilidad total";
"share_metric_yoy" = "Este año";
"share_metric_since_inception" = "Desde el inicio";
"share_cta_headline" = "Controla la tuya — gratis";
"share_cta_sub" = "Escanea para descargar Portfolio Journal";
// Share cards 2.2 — as-of date + theme (1.6.0)
"share_as_of" = "A %@";
"share_theme_dark" = "Oscuro";
"share_theme_light" = "Claro";
// Activation: first source → first snapshot (1.6.1)
"add_source_value_header" = "Valor de hoy";
"add_source_value_footer" = "Se guarda como tu primer check-in: tu progreso empieza hoy.";
"quick_update_add_first_source" = "Añade tu primera fuente";
/* MARK: - Goal scope (category) */
"goal_scope_section" = "Ámbito";
"goal_scope_category" = "Categoría";
"goal_scope_all_categories" = "Todas las categorías";
"goal_scope_footer_all" = "Todas las fuentes de tu cartera cuentan para este objetivo.";
"goal_scope_footer_category" = "Solo las fuentes de esta categoría cuentan para este objetivo.";
// MARK: - Biometrics (1.7.0 — Face ID / Touch ID / Optic ID aware)
"biometric_generic_name" = "Face ID o Touch ID";
"biometric_unlock_with" = "Desbloquear con %@";
"biometric_failed" = "%@ ha fallado. Inténtalo de nuevo.";
"biometric_enable" = "Activar %@";
"biometric_unavailable_title" = "%@ no disponible";
"biometric_unavailable_body" = "%@ no está disponible en este dispositivo.";
"biometric_pin_required" = "Activa un PIN antes de activar %@.";
"biometric_disable_first_title" = "Desactiva %@ primero";
"biometric_disable_first_body" = "Desactiva %@ antes de desactivar tu PIN.";
"biometric_pin_to_disable" = "Introduce el PIN para desactivar %@";
"biometric_require_to_reveal" = "Requerir %@ para mostrar";
"biometric_lock_footer" = "Usa %@ o un PIN de 4 dígitos para proteger tus datos.";
"privacy_footer_biometric" = "Oculta todas las cifras tras un difuminado. Ideal para usar la app en público o hacer capturas. Opcionalmente, requiere %@ para mostrarlas.";
// MARK: - What's New 1.7.0
"whats_new_170_duo_title" = "Preparada para iPhone Duo";
"whats_new_170_duo_body" = "En la pantalla interior, Fuentes y Diario muestran lista y detalle a la vez, Gráficos gana una barra lateral y más altura, y no pierdes el sitio al abrir o cerrar el teléfono.";
"whats_new_170_inspector_title" = "Actualización rápida junto a tus gráficos";
"whats_new_170_inspector_body" = "En pantallas anchas la actualización rápida mensual se abre en un panel lateral: el panel principal sigue visible mientras escribes valores.";
"whats_new_170_biometrics_title" = "Face ID, Touch ID u Optic ID";
"whats_new_170_biometrics_body" = "El bloqueo de la app y la privacidad de saldos usan la biometría que tenga tu dispositivo.";
@@ -353,6 +353,18 @@
"onboarding_quickstart_subtitle" = "Ajoutez votre première source d'investissement pour commencer à suivre votre portefeuille.";
"onboarding_quickstart_title" = "Presque prêt";
"reengagement_body" = "Quelques minutes suffisent pour rester au top de vos investissements.";
"reengagement_goal_title" = "Vous êtes sur la bonne voie 📈";
"reengagement_goal_body" = "À ce rythme, vous atteindrez \"%@\" d'ici %@. Voyez où vous en êtes.";
"reengagement_yoy_title" = "Regardez cette progression 👀";
"reengagement_yoy_body" = "Vous êtes en hausse de %@ sur un an. Consultez la tendance.";
"reengagement_month_title" = "Beau mois 📈";
"reengagement_month_body" = "Votre portefeuille a gagné %@ ce mois-ci. Jetez-y un œil.";
"reengagement_milestone_title" = "Si près 🎯";
"reengagement_milestone_body" = "Vous approchez de %@. À combien êtes-vous aujourd'hui ?";
"reengagement_streak_title" = "Gardez votre série 🔥";
"reengagement_streak_body" = "Vous avez enregistré %d mois d'affilée. Ne vous arrêtez pas.";
"reengagement_neutral_title" = "Du nouveau ? 👀";
"reengagement_neutral_body" = "Il y a eu du mouvement dans votre portefeuille. Voyez ce qui a changé.";
"reengagement_title" = "Votre portefeuille vous attend";
"snapshot_duplicate_add" = "Ajouter quand même";
"snapshot_contribution_propagate_title" = "Appliquer la contribution";
@@ -412,6 +424,25 @@
"insight_streak_title" = "Série de suivi";
"insight_streak_value" = "%d mois consécutifs";
"insight_forecast_title" = "En bonne voie pour";
"insight_allocation_title" = "Écart d'allocation";
"insight_allocation_over_value" = "%1$@ est à %2$.0f%% — au-dessus de votre cible de %3$.0f%%";
"insight_allocation_under_value" = "%1$@ est à %2$.0f%% — en dessous de votre cible de %3$.0f%%";
"insight_allocation_detail" = "Rééquilibrez pour rester dans le plan";
"insight_concentration_title" = "Risque de concentration";
"insight_concentration_value" = "%1$@ représente %2$.0f%% de votre portefeuille";
"insight_concentration_detail" = "Envisagez de mieux répartir";
"insight_best_performer_title" = "Meilleure performance";
"insight_worst_performer_title" = "À surveiller";
"insight_performer_value" = "%1$@ · %2$+.1f%%";
"insight_diversification_title" = "Diversification";
"insight_diversification_single_value" = "Tout sur une seule source — pensez à en ajouter";
"insight_diversification_good_value" = "Réparti sur %d catégories";
"goal_projection_projected" = "Prévu : %@";
"goal_projection_add_contributions" = "Ajoutez des versements pour l'atteindre";
"goal_chip_ahead" = "En avance";
"goal_chip_on_track" = "Dans les temps";
"goal_chip_behind" = "En retard";
"goal_chip_at_this_pace" = "À ce rythme…";
"notification_milestone_title" = "Jalon de portefeuille ! 🎉";
"notification_milestone_body" = "Votre portefeuille vient de dépasser %@. Félicitations !";
@@ -468,3 +499,211 @@
"chart_share_scan" = "Scannez pour télécharger";
"chart_locked_title" = "%@ est un graphique Premium";
"chart_locked_cta" = "Débloquer Premium";
// Calm 2.0 Home (1.5.0)
"checkin_done_title" = "Check-in de %@ terminé";
"checkin_done_next" = "Prochain : %@";
"checkin_next_due" = "Prochain point : %@";
"checkin_days_left" = "%d jours restants";
"checkin_due_today" = "Aujourd'hui";
"checkin_overdue" = "En retard";
"dash_chart_yoy" = "Année vs Année";
"home_chart_locked_sub" = "Débloquez avec Premium pour voir ce graphique.";
"checkin_sources_progress" = "%1$d sur %2$d sources mises à jour";
// Guided check-in (1.5.0 Fase 2)
"guided_previous_value" = "Mois dernier";
"guided_paste_amount" = "Coller %@";
"guided_next" = "Suivant";
"guided_same_as_last" = "Comme le mois dernier";
"guided_skip" = "Passer";
"guided_reflection_title" = "Comment se sent ce mois-ci ?";
"guided_reflection_subtitle" = "Une note rapide pour votre futur vous.";
"guided_note_placeholder" = "Une ligne honnête sur ce mois…";
"guided_finish" = "Terminer le check-in";
"guided_done_title" = "Check-in de %@ terminé";
"guided_streak" = "%d mois d'affilée";
"guided_share_month" = "Partager ce mois";
"guided_done_cta" = "Terminé";
"journal_no_note" = "Pas encore de note.";
// What's New catalog (1.4.11.5.0)
"whats_new_version_header" = "Version %@";
"whats_new_150_redesign_title" = "Un design plus calme et épuré";
"whats_new_150_redesign_body" = "Accueil repensé avec votre valeur totale en vedette, le check-in au centre et moins de bruit partout.";
"whats_new_150_guided_title" = "Check-in mensuel guidé";
"whats_new_150_guided_body" = "Une source par étape, des deltas en direct, une réflexion rapide — votre rituel de 5 minutes, réinventé.";
"whats_new_150_rings_title" = "Anneaux d'objectifs & timeline";
"whats_new_150_rings_body" = "Des anneaux de progression style Fitness pour vos objectifs et une timeline de votre parcours d'investisseur.";
"whats_new_142_ocr_title" = "Mise à jour par capture d'écran";
"whats_new_142_ocr_body" = "Partagez une capture de votre banque — le solde est reconnu sur l'appareil, rien ne quitte votre iPhone.";
"whats_new_142_siri_title" = "Siri dans votre langue";
"whats_new_142_siri_body" = "« Enregistre le solde de Indexa » — notez des valeurs à la voix ou depuis Raccourcis, sans ouvrir l'app.";
"whats_new_142_charts_title" = "Graphiques repensés";
"whats_new_142_charts_body" = "Barre latérale native sur iPad, KPIs adaptés à chaque graphique et partage en belles images.";
"whats_new_141_extension_title" = "Mettre à jour sans changer d'app";
"whats_new_141_extension_body" = "Partagez une valeur depuis votre app bancaire directement vers Portfolio Journal avec la nouvelle extension.";
"whats_new_141_ipad_title" = "Meilleur sur iPad";
"whats_new_141_ipad_body" = "Graphiques repensés pour le grand écran, avec zoom et étiquettes de valeur.";
"whats_new_141_icloud_title" = "Plus synchronisé";
"whats_new_141_icloud_body" = "Les contributions mensuelles et les objectifs d'allocation se synchronisent désormais via iCloud.";
"whats_new_title_format" = "Nouveautés de la %@";
// TipKit (1.5.0 Fase 4)
"tip_quick_update_title" = "Tout mettre à jour en quelques secondes";
"tip_quick_update_body" = "Quick Update liste toutes vos sources sur un écran — collez les valeurs du presse-papiers et c'est fait.";
"tip_ocr_title" = "Ou partagez une capture d'écran";
"tip_ocr_body" = "Depuis votre app bancaire : capture → Partager → Portfolio Journal. Le solde est lu sur l'appareil.";
"tip_siri_title" = "La prochaine fois, essayez la voix";
"tip_siri_body" = "Dites à Siri « Enregistre le solde de mon compte dans Portfolio Journal » — sans ouvrir l'app.";
"tip_chart_share_title" = "Partagez ce graphique";
"tip_chart_share_body" = "Génère une belle image avec vos données, prête pour X ou WhatsApp.";
"allocation_total" = "Total";
// iCloud error hints (1.5.0)
"icloud_hint_schema" = "iCloud a refusé certains enregistrements. Cela signifie souvent que le schéma iCloud est encore en cours de configuration — se résout généralement en quelques heures ou après la prochaine mise à jour.";
"icloud_hint_quota" = "Votre stockage iCloud est plein. Libérez de l'espace et réessayez.";
"icloud_hint_network" = "Impossible de joindre iCloud. Vérifiez votre connexion et réessayez.";
"icloud_hint_partial_generic" = "Certains enregistrements n'ont pas pu être synchronisés cette fois. L'app réessaiera automatiquement.";
"icloud_status_error" = "Problème de synchronisation iCloud";
"icloud_status_syncing" = "Synchronisation avec iCloud";
"icloud_show_details" = "Afficher les détails techniques";
"yoy_vs_prev" = "vs %@";
"yoy_best_year" = "Meilleure année";
"yoy_worst_year" = "Pire année";
// Quick Update paste/scan (1.5.0)
"quick_update_paste_value" = "Coller %@";
"quick_update_scan" = "Scanner";
"quick_update_done_kbd" = "OK";
"quick_update_scanning" = "Lecture de limage…";
"quick_update_pick_amount" = "Quel montant ?";
"quick_update_no_amount_found" = "Aucun montant reconnu dans limage.";
"icloud_copy_diagnostics" = "Copier le diagnostic iCloud";
"icloud_diagnostics_copied" = "Diagnostic copié dans le presse-papiers";
"Smooth Gaps in Charts" = "Lisser les écarts dans les graphiques";
// MARK: - Data Gaps (1.5.0)
"gaps_banner_title" = "Données manquantes pour %d mois";
"gaps_missing_count" = "%d manquants";
"gaps_range_between" = "Entre %@ et %@";
"Fill in missing data" = "Compléter les données manquantes";
"Missing Data" = "Données manquantes";
"Fill Gap" = "Combler le vide";
"The chart estimates these months. Add real snapshots to keep your history accurate." = "Le graphique estime ces mois. Ajoutez des valeurs réelles pour garder un historique précis.";
"Dismiss" = "Ignorer";
// MARK: - Shareable Monthly Summary (1.5.0)
"share_summary_streak_badge" = "%d mois";
"Monthly Summary" = "Résumé mensuel";
"Portfolio Value" = "Valeur du portefeuille";
"Tracked with" = "Suivi avec";
"Share monthly summary" = "Partager le résumé mensuel";
"since last check-in" = "depuis le dernier point";
// MARK: - Import Preview (1.5.0)
"import_preview_nothing" = "Rien de nouveau à importer. Vos données sont déjà à jour.";
"import_preview_sources" = "Créer %d sources";
"import_preview_snapshots_new" = "Créer %d relevés";
"import_preview_snapshots_update" = "Mettre à jour %d relevés";
"import_preview_categories" = "Créer %d catégories";
"import_preview_goals" = "Créer %d objectifs";
"import_preview_journal_new" = "Créer %d entrées de journal";
"import_preview_journal_update" = "Mettre à jour %d entrées de journal";
"Review Import" = "Vérifier limport";
"Reviewing import…" = "Vérification de limport…";
"Import" = "Importer";
/* 1.6.0 gap auto-fill */
"gaps_estimated_note" = "Estimé";
"gaps_autofill_cta" = "Remplir %d mois";
"Add manually" = "Ajouter manuellement";
"Auto-fill estimates the missing months between two snapshots (marked as estimated). Or add the real values yourself." = "Le remplissage auto estime les mois manquants entre deux relevés (marqués comme estimés). Ou saisis les vraies valeurs toi-même.";
/* 1.6.0 auto-backup */
"Auto Backup" = "Sauvegarde auto";
"Off" = "Désactivé";
"Daily" = "Quotidien";
"Weekly" = "Hebdomadaire";
"Monthly" = "Mensuel";
"Last auto backup" = "Dernière sauvegarde auto";
/* 1.6.0 hide balances */
"Hide balances" = "Masquer les soldes";
"Show balances" = "Afficher les soldes";
"Balance hidden" = "Solde masqué";
"Reveal your balances" = "Afficher vos soldes";
"Privacy" = "Confidentialité";
"Hide Balances" = "Masquer les soldes";
"Require Face ID to Reveal" = "Exiger Face ID pour afficher";
"Hide all monetary amounts behind a blur. Great for using the app in public or taking screenshots. Optionally require Face ID to reveal them." = "Masque tous les montants derrière un flou. Idéal pour utiliser l'app en public ou faire des captures. Exigez Face ID pour les afficher, en option.";
/* 1.6.0 Siri intents */
"intent_networth_empty" = "Tu n'as encore enregistré aucune valeur de portefeuille.";
"intent_networth_dialog" = "Ton patrimoine est de %@.";
"intent_streak" = "Tu es sur une série de %d mois.";
"intent_streak_with_date" = "Tu es sur une série de %d mois. Ton dernier relevé date du %@.";
"intent_streak_none" = "Tu n'as pas encore de série active. Enregistre une valeur pour en commencer une.";
// Charts workable (1.6.0)
"chart_range_button" = "Période personnalisée";
"chart_range_from" = "De";
"chart_range_to" = "À";
"chart_filtered_prefix" = "Filtré : %@";
"chart_overlay_contributions" = "vs Versements";
"chart_insight_best_month" = "Meilleur mois de la période : %1$@ (%2$@)";
// Share cards 2.0 (1.6.0)
"share_options_title" = "Partage tes progrès";
"share_percent_only" = "Pourcentages uniquement";
"share_format_post" = "Post";
"share_format_story" = "Story";
"share_card_cta" = "Partager";
// Share cards 2.1 — brand header + download CTA (1.6.0)
"share_hero_eyebrow" = "Rendement total";
"share_metric_yoy" = "Cette année";
"share_metric_since_inception" = "Depuis le début";
"share_cta_headline" = "Suis le tien — gratuit";
"share_cta_sub" = "Scanne pour Portfolio Journal";
// Share cards 2.2 — as-of date + theme (1.6.0)
"share_as_of" = "Au %@";
"share_theme_dark" = "Sombre";
"share_theme_light" = "Clair";
// Activation: first source → first snapshot (1.6.1)
"add_source_value_header" = "Valeur du jour";
"add_source_value_footer" = "Enregistrée comme votre premier check-in : votre progression commence aujourd'hui.";
"quick_update_add_first_source" = "Ajouter votre première source";
/* MARK: - Goal scope (category) */
"goal_scope_section" = "Périmètre";
"goal_scope_category" = "Catégorie";
"goal_scope_all_categories" = "Toutes les catégories";
"goal_scope_footer_all" = "Toutes les sources de votre portefeuille comptent pour cet objectif.";
"goal_scope_footer_category" = "Seules les sources de cette catégorie comptent pour cet objectif.";
// MARK: - Biometrics (1.7.0 — Face ID / Touch ID / Optic ID aware)
"biometric_generic_name" = "Face ID ou Touch ID";
"biometric_unlock_with" = "Déverrouiller avec %@";
"biometric_failed" = "Échec de %@. Veuillez réessayer.";
"biometric_enable" = "Activer %@";
"biometric_unavailable_title" = "%@ indisponible";
"biometric_unavailable_body" = "%@ n'est pas disponible sur cet appareil.";
"biometric_pin_required" = "Définissez un code PIN avant d'activer %@.";
"biometric_disable_first_title" = "Désactivez d'abord %@";
"biometric_disable_first_body" = "Désactivez %@ avant de désactiver votre code PIN.";
"biometric_pin_to_disable" = "Saisissez le code PIN pour désactiver %@";
"biometric_require_to_reveal" = "Exiger %@ pour afficher";
"biometric_lock_footer" = "Protégez vos données avec %@ ou un code PIN à 4 chiffres.";
"privacy_footer_biometric" = "Masque tous les montants derrière un flou. Idéal pour utiliser l'app en public ou faire des captures. Exigez %@ pour les afficher, en option.";
// MARK: - What's New 1.7.0
"whats_new_170_duo_title" = "Prête pour iPhone Duo";
"whats_new_170_duo_body" = "Sur l'écran intérieur, Sources et Journal affichent liste et détail côte à côte, Graphiques gagne une barre latérale et plus de hauteur, et vous gardez votre position en ouvrant ou fermant le téléphone.";
"whats_new_170_inspector_title" = "Mise à jour rapide à côté de vos graphiques";
"whats_new_170_inspector_body" = "Sur les grands écrans, la mise à jour rapide mensuelle s'ouvre dans un panneau latéral : le tableau de bord reste visible pendant la saisie.";
"whats_new_170_biometrics_title" = "Face ID, Touch ID ou Optic ID";
"whats_new_170_biometrics_body" = "Le verrouillage de l'app et la confidentialité des soldes utilisent la biométrie de votre appareil.";
@@ -353,6 +353,18 @@
"onboarding_quickstart_subtitle" = "Aggiungi la tua prima fonte di investimento per iniziare a monitorare il portafoglio.";
"onboarding_quickstart_title" = "Quasi pronto";
"reengagement_body" = "Pochi minuti bastano per tenere sotto controllo i tuoi investimenti.";
"reengagement_goal_title" = "Sei sulla buona strada 📈";
"reengagement_goal_body" = "A questo ritmo raggiungerai \"%@\" entro %@. Guarda quanto manca.";
"reengagement_yoy_title" = "Guarda che crescita 👀";
"reengagement_yoy_body" = "Sei in rialzo del %@ nell'ultimo anno. Controlla l'andamento.";
"reengagement_month_title" = "Bel mese 📈";
"reengagement_month_body" = "Il tuo portafoglio è cresciuto di %@ questo mese. Dai un'occhiata.";
"reengagement_milestone_title" = "Ci sei quasi 🎯";
"reengagement_milestone_body" = "Sei quasi a %@. Quanto manca oggi?";
"reengagement_streak_title" = "Non perdere la serie 🔥";
"reengagement_streak_body" = "Hai registrato %d mesi di fila. Non fermarti ora.";
"reengagement_neutral_title" = "Novità? 👀";
"reengagement_neutral_body" = "C'è stato movimento nel tuo portafoglio. Guarda cosa è cambiato.";
"reengagement_title" = "Il tuo portafoglio ti aspetta";
"snapshot_duplicate_add" = "Aggiungi comunque";
"snapshot_contribution_propagate_title" = "Applica contributo";
@@ -412,6 +424,25 @@
"insight_streak_title" = "Serie di aggiornamenti";
"insight_streak_value" = "%d mesi consecutivi";
"insight_forecast_title" = "In direzione di";
"insight_allocation_title" = "Scostamento allocazione";
"insight_allocation_over_value" = "%1$@ è al %2$.0f%% — sopra il tuo obiettivo del %3$.0f%%";
"insight_allocation_under_value" = "%1$@ è al %2$.0f%% — sotto il tuo obiettivo del %3$.0f%%";
"insight_allocation_detail" = "Riequilibra per restare nel piano";
"insight_concentration_title" = "Rischio di concentrazione";
"insight_concentration_value" = "%1$@ è il %2$.0f%% del tuo portafoglio";
"insight_concentration_detail" = "Valuta di diversificare";
"insight_best_performer_title" = "Miglior rendimento";
"insight_worst_performer_title" = "Richiede attenzione";
"insight_performer_value" = "%1$@ · %2$+.1f%%";
"insight_diversification_title" = "Diversificazione";
"insight_diversification_single_value" = "Tutto in un'unica fonte — valuta di aggiungerne un'altra";
"insight_diversification_good_value" = "Distribuito su %d categorie";
"goal_projection_projected" = "Previsione: %@";
"goal_projection_add_contributions" = "Aggiungi versamenti per raggiungerlo";
"goal_chip_ahead" = "In anticipo";
"goal_chip_on_track" = "In linea";
"goal_chip_behind" = "In ritardo";
"goal_chip_at_this_pace" = "A questo ritmo…";
"notification_milestone_title" = "Traguardo raggiunto! 🎉";
"notification_milestone_body" = "Il tuo portafoglio ha appena superato %@. Complimenti!";
@@ -468,3 +499,211 @@
"chart_share_scan" = "Scansiona per scaricare";
"chart_locked_title" = "%@ è un grafico Premium";
"chart_locked_cta" = "Sblocca Premium";
// Calm 2.0 Home (1.5.0)
"checkin_done_title" = "Check-in di %@ completato";
"checkin_done_next" = "Prossimo: %@";
"checkin_next_due" = "Prossimo check-in: %@";
"checkin_days_left" = "%d giorni rimasti";
"checkin_due_today" = "Oggi";
"checkin_overdue" = "In ritardo";
"dash_chart_yoy" = "Anno su anno";
"home_chart_locked_sub" = "Sblocca con Premium per vedere questo grafico.";
"checkin_sources_progress" = "%1$d di %2$d fonti aggiornate";
// Guided check-in (1.5.0 Fase 2)
"guided_previous_value" = "Mese scorso";
"guided_paste_amount" = "Incolla %@";
"guided_next" = "Avanti";
"guided_same_as_last" = "Come il mese scorso";
"guided_skip" = "Salta";
"guided_reflection_title" = "Come senti questo mese?";
"guided_reflection_subtitle" = "Una nota veloce per il te del futuro.";
"guided_note_placeholder" = "Una riga sincera su questo mese…";
"guided_finish" = "Concludi il check-in";
"guided_done_title" = "Check-in di %@ completato";
"guided_streak" = "%d mesi di fila";
"guided_share_month" = "Condividi questo mese";
"guided_done_cta" = "Fatto";
"journal_no_note" = "Ancora nessuna nota.";
// What's New catalog (1.4.11.5.0)
"whats_new_version_header" = "Versione %@";
"whats_new_150_redesign_title" = "Un look più calmo e pulito";
"whats_new_150_redesign_body" = "Home ridisegnata con il tuo valore totale in primo piano, il check-in al centro e meno rumore ovunque.";
"whats_new_150_guided_title" = "Check-in mensile guidato";
"whats_new_150_guided_body" = "Una fonte per passo, delta in tempo reale, una riflessione veloce — il tuo rituale di 5 minuti, ripensato.";
"whats_new_150_rings_title" = "Anelli obiettivi & timeline";
"whats_new_150_rings_body" = "Anelli di progresso stile Fitness per gli obiettivi e una timeline della tua storia da investitore.";
"whats_new_142_ocr_title" = "Aggiorna da uno screenshot";
"whats_new_142_ocr_body" = "Condividi uno screenshot della tua banca — il saldo viene riconosciuto sul dispositivo, nulla lascia il tuo iPhone.";
"whats_new_142_siri_title" = "Siri nella tua lingua";
"whats_new_142_siri_body" = "\"Registra il saldo di Indexa\" — registra valori con la voce o dai Comandi rapidi, senza aprire l'app.";
"whats_new_142_charts_title" = "Grafici rinnovati";
"whats_new_142_charts_body" = "Barra laterale nativa su iPad, KPI su misura per ogni grafico e condivisione come belle immagini.";
"whats_new_141_extension_title" = "Aggiorna senza cambiare app";
"whats_new_141_extension_body" = "Condividi un valore dall'app della tua banca direttamente in Portfolio Journal con la nuova estensione.";
"whats_new_141_ipad_title" = "Meglio su iPad";
"whats_new_141_ipad_body" = "Grafici ridisegnati per lo schermo grande, con zoom ed etichette dei valori.";
"whats_new_141_icloud_title" = "Più sincronizzato";
"whats_new_141_icloud_body" = "I contributi mensili e gli obiettivi di allocazione ora si sincronizzano via iCloud.";
"whats_new_title_format" = "Novità della %@";
// TipKit (1.5.0 Fase 4)
"tip_quick_update_title" = "Aggiorna tutto in pochi secondi";
"tip_quick_update_body" = "Quick Update elenca tutte le tue fonti in una schermata — incolla i valori dagli appunti e via.";
"tip_ocr_title" = "Oppure condividi uno screenshot";
"tip_ocr_body" = "Dall'app della tua banca: screenshot → Condividi → Portfolio Journal. Il saldo viene letto sul dispositivo.";
"tip_siri_title" = "La prossima volta prova con la voce";
"tip_siri_body" = "Di' a Siri \"Registra il saldo del mio conto in Portfolio Journal\" — senza aprire l'app.";
"tip_chart_share_title" = "Condividi questo grafico";
"tip_chart_share_body" = "Genera una bella immagine con i tuoi dati, pronta per X o WhatsApp.";
"allocation_total" = "Totale";
// iCloud error hints (1.5.0)
"icloud_hint_schema" = "iCloud ha rifiutato alcuni record. Di solito significa che lo schema iCloud è ancora in configurazione — spesso si risolve in poche ore o dopo il prossimo aggiornamento.";
"icloud_hint_quota" = "Il tuo spazio iCloud è pieno. Libera spazio e riprova.";
"icloud_hint_network" = "Impossibile raggiungere iCloud. Controlla la connessione e riprova.";
"icloud_hint_partial_generic" = "Alcuni record non si sono sincronizzati stavolta. L'app riproverà automaticamente.";
"icloud_status_error" = "Problema di sincronizzazione iCloud";
"icloud_status_syncing" = "Sincronizzazione con iCloud";
"icloud_show_details" = "Mostra dettagli tecnici";
"yoy_vs_prev" = "vs %@";
"yoy_best_year" = "Anno migliore";
"yoy_worst_year" = "Anno peggiore";
// Quick Update paste/scan (1.5.0)
"quick_update_paste_value" = "Incolla %@";
"quick_update_scan" = "Scansiona";
"quick_update_done_kbd" = "Fine";
"quick_update_scanning" = "Lettura immagine…";
"quick_update_pick_amount" = "Quale importo?";
"quick_update_no_amount_found" = "Nessun importo riconosciuto nellimmagine.";
"icloud_copy_diagnostics" = "Copia diagnostica iCloud";
"icloud_diagnostics_copied" = "Diagnostica copiata negli appunti";
"Smooth Gaps in Charts" = "Uniforma i vuoti nei grafici";
// MARK: - Data Gaps (1.5.0)
"gaps_banner_title" = "Mancano i dati di %d mesi";
"gaps_missing_count" = "%d mancanti";
"gaps_range_between" = "Tra %@ e %@";
"Fill in missing data" = "Completa i dati mancanti";
"Missing Data" = "Dati mancanti";
"Fill Gap" = "Colma il vuoto";
"The chart estimates these months. Add real snapshots to keep your history accurate." = "Il grafico stima questi mesi. Aggiungi valori reali per mantenere accurata la cronologia.";
"Dismiss" = "Ignora";
// MARK: - Shareable Monthly Summary (1.5.0)
"share_summary_streak_badge" = "%d mesi";
"Monthly Summary" = "Riepilogo mensile";
"Portfolio Value" = "Valore del portafoglio";
"Tracked with" = "Registrato con";
"Share monthly summary" = "Condividi il riepilogo mensile";
"since last check-in" = "dallultimo check-in";
// MARK: - Import Preview (1.5.0)
"import_preview_nothing" = "Niente di nuovo da importare. I tuoi dati sono già aggiornati.";
"import_preview_sources" = "Crea %d fonti";
"import_preview_snapshots_new" = "Crea %d rilevazioni";
"import_preview_snapshots_update" = "Aggiorna %d rilevazioni";
"import_preview_categories" = "Crea %d categorie";
"import_preview_goals" = "Crea %d obiettivi";
"import_preview_journal_new" = "Crea %d voci del diario";
"import_preview_journal_update" = "Aggiorna %d voci del diario";
"Review Import" = "Rivedi importazione";
"Reviewing import…" = "Revisione importazione…";
"Import" = "Importa";
/* 1.6.0 gap auto-fill */
"gaps_estimated_note" = "Stimato";
"gaps_autofill_cta" = "Riempi %d mesi";
"Add manually" = "Aggiungi manualmente";
"Auto-fill estimates the missing months between two snapshots (marked as estimated). Or add the real values yourself." = "Il riempimento automatico stima i mesi mancanti tra due snapshot (contrassegnati come stimati). Oppure inserisci tu i valori reali.";
/* 1.6.0 auto-backup */
"Auto Backup" = "Backup automatico";
"Off" = "Disattivato";
"Daily" = "Giornaliero";
"Weekly" = "Settimanale";
"Monthly" = "Mensile";
"Last auto backup" = "Ultimo backup automatico";
/* 1.6.0 hide balances */
"Hide balances" = "Nascondi saldi";
"Show balances" = "Mostra saldi";
"Balance hidden" = "Saldo nascosto";
"Reveal your balances" = "Mostra i tuoi saldi";
"Privacy" = "Privacy";
"Hide Balances" = "Nascondi saldi";
"Require Face ID to Reveal" = "Richiedi Face ID per mostrare";
"Hide all monetary amounts behind a blur. Great for using the app in public or taking screenshots. Optionally require Face ID to reveal them." = "Nasconde tutti gli importi dietro una sfocatura. Ideale per usare l'app in pubblico o fare screenshot. Facoltativamente richiedi Face ID per mostrarli.";
/* 1.6.0 Siri intents */
"intent_networth_empty" = "Non hai ancora registrato alcun valore di portafoglio.";
"intent_networth_dialog" = "Il tuo patrimonio è %@.";
"intent_streak" = "Sei a una serie di %d mesi.";
"intent_streak_with_date" = "Sei a una serie di %d mesi. Il tuo ultimo check-in è stato il %@.";
"intent_streak_none" = "Non hai ancora una serie attiva. Registra un valore per iniziarne una.";
// Charts workable (1.6.0)
"chart_range_button" = "Intervallo personalizzato";
"chart_range_from" = "Da";
"chart_range_to" = "A";
"chart_filtered_prefix" = "Filtrato: %@";
"chart_overlay_contributions" = "vs Versamenti";
"chart_insight_best_month" = "Miglior mese del periodo: %1$@ (%2$@)";
// Share cards 2.0 (1.6.0)
"share_options_title" = "Condividi i tuoi progressi";
"share_percent_only" = "Solo percentuali";
"share_format_post" = "Post";
"share_format_story" = "Storia";
"share_card_cta" = "Condividi";
// Share cards 2.1 — brand header + download CTA (1.6.0)
"share_hero_eyebrow" = "Rendimento totale";
"share_metric_yoy" = "Quest'anno";
"share_metric_since_inception" = "Dall'inizio";
"share_cta_headline" = "Monitora il tuo — gratis";
"share_cta_sub" = "Scansiona per Portfolio Journal";
// Share cards 2.2 — as-of date + theme (1.6.0)
"share_as_of" = "Al %@";
"share_theme_dark" = "Scuro";
"share_theme_light" = "Chiaro";
// Activation: first source → first snapshot (1.6.1)
"add_source_value_header" = "Valore di oggi";
"add_source_value_footer" = "Salvato come primo check-in: i tuoi progressi iniziano oggi.";
"quick_update_add_first_source" = "Aggiungi la tua prima fonte";
/* MARK: - Goal scope (category) */
"goal_scope_section" = "Ambito";
"goal_scope_category" = "Categoria";
"goal_scope_all_categories" = "Tutte le categorie";
"goal_scope_footer_all" = "Tutte le fonti del tuo portafoglio contano per questo obiettivo.";
"goal_scope_footer_category" = "Solo le fonti di questa categoria contano per questo obiettivo.";
// MARK: - Biometrics (1.7.0 — Face ID / Touch ID / Optic ID aware)
"biometric_generic_name" = "Face ID o Touch ID";
"biometric_unlock_with" = "Sblocca con %@";
"biometric_failed" = "%@ non riuscito. Riprova.";
"biometric_enable" = "Attiva %@";
"biometric_unavailable_title" = "%@ non disponibile";
"biometric_unavailable_body" = "%@ non è disponibile su questo dispositivo.";
"biometric_pin_required" = "Imposta un PIN prima di attivare %@.";
"biometric_disable_first_title" = "Disattiva prima %@";
"biometric_disable_first_body" = "Disattiva %@ prima di disattivare il PIN.";
"biometric_pin_to_disable" = "Inserisci il PIN per disattivare %@";
"biometric_require_to_reveal" = "Richiedi %@ per mostrare";
"biometric_lock_footer" = "Proteggi i tuoi dati con %@ o un PIN a 4 cifre.";
"privacy_footer_biometric" = "Nasconde tutti gli importi dietro una sfocatura. Ideale per usare l'app in pubblico o fare screenshot. Facoltativamente richiedi %@ per mostrarli.";
// MARK: - What's New 1.7.0
"whats_new_170_duo_title" = "Pronta per iPhone Duo";
"whats_new_170_duo_body" = "Sullo schermo interno, Fonti e Diario mostrano elenco e dettaglio affiancati, Grafici ottiene una barra laterale e più altezza, e non perdi la posizione aprendo o chiudendo il telefono.";
"whats_new_170_inspector_title" = "Aggiornamento rapido accanto ai grafici";
"whats_new_170_inspector_body" = "Sugli schermi ampi l'aggiornamento rapido mensile si apre in un pannello laterale: la dashboard resta visibile mentre inserisci i valori.";
"whats_new_170_biometrics_title" = "Face ID, Touch ID o Optic ID";
"whats_new_170_biometrics_body" = "Blocco app e privacy dei saldi usano la biometria disponibile sul tuo dispositivo.";
@@ -353,6 +353,18 @@
"onboarding_quickstart_subtitle" = "最初の投資ソースを追加して、ポートフォリオの追跡を始めましょう。";
"onboarding_quickstart_title" = "もうすぐ完了";
"reengagement_body" = "数分で投資の状況を把握できます。";
"reengagement_goal_title" = "順調です 📈";
"reengagement_goal_body" = "このペースなら%2$@までに「%1$@」に到達します。あとどれくらいか見てみましょう。";
"reengagement_yoy_title" = "この成長を見て 👀";
"reengagement_yoy_body" = "この1年で%@上昇しています。トレンドをチェックしましょう。";
"reengagement_month_title" = "好調な月 📈";
"reengagement_month_body" = "今月、ポートフォリオが%@増えました。ちょっと見てみましょう。";
"reengagement_milestone_title" = "あと少し 🎯";
"reengagement_milestone_body" = "もうすぐ%@です。今日はどこまで来たでしょう?";
"reengagement_streak_title" = "連続記録を継続 🔥";
"reengagement_streak_body" = "%dか月連続で記録しています。ここでやめないで。";
"reengagement_neutral_title" = "何か変化は? 👀";
"reengagement_neutral_body" = "ポートフォリオに動きがありました。何が変わったか見てみましょう。";
"reengagement_title" = "ポートフォリオが待っています";
"snapshot_duplicate_add" = "とにかく追加";
"snapshot_contribution_propagate_title" = "拠出を適用";
@@ -412,6 +424,25 @@
"insight_streak_title" = "追跡連続記録";
"insight_streak_value" = "%dヶ月連続";
"insight_forecast_title" = "目標に向けて順調";
"insight_allocation_title" = "配分のずれ";
"insight_allocation_over_value" = "%1$@は%2$.0f%% — 目標の%3$.0f%%を上回っています";
"insight_allocation_under_value" = "%1$@は%2$.0f%% — 目標の%3$.0f%%を下回っています";
"insight_allocation_detail" = "計画に合わせてリバランスしましょう";
"insight_concentration_title" = "集中リスク";
"insight_concentration_value" = "%1$@がポートフォリオの%2$.0f%%を占めています";
"insight_concentration_detail" = "分散を検討しましょう";
"insight_best_performer_title" = "最も好調";
"insight_worst_performer_title" = "要注意";
"insight_performer_value" = "%1$@ · %2$+.1f%%";
"insight_diversification_title" = "分散";
"insight_diversification_single_value" = "1つの資産に集中 — 追加を検討しましょう";
"insight_diversification_good_value" = "%d個のカテゴリに分散";
"goal_projection_projected" = "予測: %@";
"goal_projection_add_contributions" = "積立を追加して達成しましょう";
"goal_chip_ahead" = "前倒し";
"goal_chip_on_track" = "順調";
"goal_chip_behind" = "遅れ気味";
"goal_chip_at_this_pace" = "このペースだと…";
"notification_milestone_title" = "ポートフォリオ達成!🎉";
"notification_milestone_body" = "ポートフォリオが%@を突破しました。おめでとうございます!";
@@ -468,3 +499,211 @@
"chart_share_scan" = "スキャンしてダウンロード";
"chart_locked_title" = "%@はプレミアムチャートです";
"chart_locked_cta" = "プレミアムを解除";
// Calm 2.0 Home (1.5.0)
"checkin_done_title" = "%@のチェックイン完了";
"checkin_done_next" = "次回:%@";
"checkin_next_due" = "次回のチェックイン:%@";
"checkin_days_left" = "あと%d日";
"checkin_due_today" = "本日期限";
"checkin_overdue" = "期限切れ";
"dash_chart_yoy" = "年別比較";
"home_chart_locked_sub" = "Premium で解除してこのチャートを表示できます。";
"checkin_sources_progress" = "%2$d件中%1$d件のソースを更新済み";
// Guided check-in (1.5.0 Fase 2)
"guided_previous_value" = "先月";
"guided_paste_amount" = "%@を貼り付け";
"guided_next" = "次へ";
"guided_same_as_last" = "先月と同じ";
"guided_skip" = "スキップ";
"guided_reflection_title" = "今月はどんな気分ですか?";
"guided_reflection_subtitle" = "未来の自分への簡単なメモ。";
"guided_note_placeholder" = "今月について正直な一言…";
"guided_finish" = "チェックインを完了";
"guided_done_title" = "%@のチェックイン完了";
"guided_streak" = "%dか月連続";
"guided_share_month" = "今月をシェア";
"guided_done_cta" = "完了";
"journal_no_note" = "まだメモがありません。";
// What's New catalog (1.4.11.5.0)
"whats_new_version_header" = "バージョン%@";
"whats_new_150_redesign_title" = "より穏やかで洗練されたデザイン";
"whats_new_150_redesign_body" = "ホームを刷新。総資産を主役に、チェックインを中心に、あらゆる場所のノイズを削減しました。";
"whats_new_150_guided_title" = "ガイド付き月次チェックイン";
"whats_new_150_guided_body" = "1画面1ソース、リアルタイム差分、簡単な振り返り — 月5分の習慣を再設計しました。";
"whats_new_150_rings_title" = "目標リングとジャーナルタイムライン";
"whats_new_150_rings_body" = "フィットネス風の進捗リングと、投資の歩みを綴るムードタイムライン。";
"whats_new_142_ocr_title" = "スクリーンショットから更新";
"whats_new_142_ocr_body" = "銀行アプリのスクリーンショットを共有するだけ。残高はデバイス上で認識され、外部には送信されません。";
"whats_new_142_siri_title" = "Siriに対応";
"whats_new_142_siri_body" = "「Indexaの残高を記録」— アプリを開かずに音声やショートカットで記録できます。";
"whats_new_142_charts_title" = "チャートを一新";
"whats_new_142_charts_body" = "iPadのネイティブサイドバー、チャートごとの最適なKPI、美しい画像での共有ボタン。";
"whats_new_141_extension_title" = "アプリを切り替えずに更新";
"whats_new_141_extension_body" = "新しい共有拡張機能で、銀行アプリから直接Portfolio Journalへ値を送れます。";
"whats_new_141_ipad_title" = "iPadでより快適に";
"whats_new_141_ipad_body" = "大画面向けにチャートを再設計。ピンチズームと値ラベルに対応。";
"whats_new_141_icloud_title" = "さらに同期";
"whats_new_141_icloud_body" = "毎月の積立額と配分目標がiCloudでデバイス間同期されるようになりました。";
"whats_new_title_format" = "バージョン%@の新機能";
// TipKit (1.5.0 Fase 4)
"tip_quick_update_title" = "数秒ですべて更新";
"tip_quick_update_body" = "クイック更新なら全ソースを1画面で。クリップボードから値を貼り付けるだけ。";
"tip_ocr_title" = "スクリーンショットの共有でもOK";
"tip_ocr_body" = "銀行アプリでスクショ → 共有 → Portfolio Journal。残高はデバイス上で読み取られます。";
"tip_siri_title" = "次は声で試してみて";
"tip_siri_body" = "Siriに「Portfolio Journalで口座の残高を記録」と話しかけるだけ。アプリを開く必要はありません。";
"tip_chart_share_title" = "このチャートを共有";
"tip_chart_share_body" = "あなたのデータで美しい画像を生成。XやWhatsAppにそのまま共有できます。";
"allocation_total" = "合計";
// iCloud error hints (1.5.0)
"icloud_hint_schema" = "一部のレコードがiCloudに拒否されました。多くの場合iCloudのスキーマ設定が完了していないためで、数時間の使用後または次回のアップデートで解消されます。";
"icloud_hint_quota" = "iCloudの容量がいっぱいです。空き容量を確保して再試行してください。";
"icloud_hint_network" = "iCloudに接続できませんでした。接続を確認して再試行してください。";
"icloud_hint_partial_generic" = "今回一部のレコードを同期できませんでした。アプリが自動的に再試行します。";
"icloud_status_error" = "iCloud同期の問題";
"icloud_status_syncing" = "iCloudと同期中";
"icloud_show_details" = "技術的な詳細を表示";
"yoy_vs_prev" = "%@比";
"yoy_best_year" = "最高の年";
"yoy_worst_year" = "最悪の年";
// Quick Update paste/scan (1.5.0)
"quick_update_paste_value" = "%@を貼り付け";
"quick_update_scan" = "スキャン";
"quick_update_done_kbd" = "完了";
"quick_update_scanning" = "画像を読み取り中…";
"quick_update_pick_amount" = "どの金額?";
"quick_update_no_amount_found" = "画像から金額を認識できませんでした。";
"icloud_copy_diagnostics" = "iCloud診断をコピー";
"icloud_diagnostics_copied" = "診断をクリップボードにコピーしました";
"Smooth Gaps in Charts" = "グラフの空白を補間";
// MARK: - Data Gaps (1.5.0)
"gaps_banner_title" = "%dか月分のデータがありません";
"gaps_missing_count" = "%d件不足";
"gaps_range_between" = "%@〜%@の間";
"Fill in missing data" = "不足しているデータを入力";
"Missing Data" = "不足データ";
"Fill Gap" = "空白を埋める";
"The chart estimates these months. Add real snapshots to keep your history accurate." = "グラフはこれらの月を推定しています。正確な履歴を保つため実際の値を追加してください。";
"Dismiss" = "閉じる";
// MARK: - Shareable Monthly Summary (1.5.0)
"share_summary_streak_badge" = "%dか月";
"Monthly Summary" = "月間サマリー";
"Portfolio Value" = "ポートフォリオの価値";
"Tracked with" = "記録に使用";
"Share monthly summary" = "月間サマリーを共有";
"since last check-in" = "前回のチェックイン以降";
// MARK: - Import Preview (1.5.0)
"import_preview_nothing" = "新しくインポートするものはありません。データはすでに最新です。";
"import_preview_sources" = "%d件のソースを作成";
"import_preview_snapshots_new" = "%d件のスナップショットを作成";
"import_preview_snapshots_update" = "%d件のスナップショットを更新";
"import_preview_categories" = "%d件のカテゴリーを作成";
"import_preview_goals" = "%d件の目標を作成";
"import_preview_journal_new" = "%d件のジャーナルを作成";
"import_preview_journal_update" = "%d件のジャーナルを更新";
"Review Import" = "インポートの確認";
"Reviewing import…" = "インポートを確認中…";
"Import" = "インポート";
/* 1.6.0 gap auto-fill */
"gaps_estimated_note" = "推定";
"gaps_autofill_cta" = "%dか月を自動入力";
"Add manually" = "手動で追加";
"Auto-fill estimates the missing months between two snapshots (marked as estimated). Or add the real values yourself." = "自動入力は2つの記録の間の欠けている月を推定します(推定として表示)。または実際の値を自分で入力できます。";
/* 1.6.0 auto-backup */
"Auto Backup" = "自動バックアップ";
"Off" = "オフ";
"Daily" = "毎日";
"Weekly" = "毎週";
"Monthly" = "毎月";
"Last auto backup" = "最後の自動バックアップ";
/* 1.6.0 hide balances */
"Hide balances" = "残高を隠す";
"Show balances" = "残高を表示";
"Balance hidden" = "残高は非表示";
"Reveal your balances" = "残高を表示";
"Privacy" = "プライバシー";
"Hide Balances" = "残高を隠す";
"Require Face ID to Reveal" = "表示にFace IDを要求";
"Hide all monetary amounts behind a blur. Great for using the app in public or taking screenshots. Optionally require Face ID to reveal them." = "すべての金額をぼかして隠します。人前での利用やスクリーンショットに最適。表示にFace IDを要求することもできます。";
/* 1.6.0 Siri intents */
"intent_networth_empty" = "まだポートフォリオの金額が記録されていません。";
"intent_networth_dialog" = "あなたの純資産は%@です。";
"intent_streak" = "%dか月連続で記録中です。";
"intent_streak_with_date" = "%dか月連続で記録中です。最後のチェックインは%@でした。";
"intent_streak_none" = "まだ連続記録がありません。値を記録して始めましょう。";
// Charts workable (1.6.0)
"chart_range_button" = "カスタム期間";
"chart_range_from" = "開始";
"chart_range_to" = "終了";
"chart_filtered_prefix" = "フィルター: %@";
"chart_overlay_contributions" = "拠出額と比較";
"chart_insight_best_month" = "期間内のベスト月: %1$@%2$@";
// Share cards 2.0 (1.6.0)
"share_options_title" = "成果をシェア";
"share_percent_only" = "パーセントのみ";
"share_format_post" = "投稿";
"share_format_story" = "ストーリー";
"share_card_cta" = "シェア";
// Share cards 2.1 — brand header + download CTA (1.6.0)
"share_hero_eyebrow" = "トータルリターン";
"share_metric_yoy" = "今年";
"share_metric_since_inception" = "開始来";
"share_cta_headline" = "あなたも無料で記録";
"share_cta_sub" = "スキャンでPortfolio Journalを入手";
// Share cards 2.2 — as-of date + theme (1.6.0)
"share_as_of" = "%@時点";
"share_theme_dark" = "ダーク";
"share_theme_light" = "ライト";
// Activation: first source → first snapshot (1.6.1)
"add_source_value_header" = "今日の評価額";
"add_source_value_footer" = "最初のチェックインとして保存され、今日から進捗の記録が始まります。";
"quick_update_add_first_source" = "最初の投資先を追加";
/* MARK: - Goal scope (category) */
"goal_scope_section" = "対象範囲";
"goal_scope_category" = "カテゴリ";
"goal_scope_all_categories" = "すべてのカテゴリ";
"goal_scope_footer_all" = "ポートフォリオのすべての投資先がこの目標に加算されます。";
"goal_scope_footer_category" = "このカテゴリの投資先のみがこの目標に加算されます。";
// MARK: - Biometrics (1.7.0 — Face ID / Touch ID / Optic ID aware)
"biometric_generic_name" = "Face IDまたはTouch ID";
"biometric_unlock_with" = "%@でロック解除";
"biometric_failed" = "%@に失敗しました。もう一度お試しください。";
"biometric_enable" = "%@を有効にする";
"biometric_unavailable_title" = "%@は利用できません";
"biometric_unavailable_body" = "このデバイスでは%@を利用できません。";
"biometric_pin_required" = "%@を有効にする前にPINを設定してください。";
"biometric_disable_first_title" = "先に%@を無効にしてください";
"biometric_disable_first_body" = "PINを無効にする前に%@を無効にしてください。";
"biometric_pin_to_disable" = "%@を無効にするにはPINを入力";
"biometric_require_to_reveal" = "表示に%@を要求";
"biometric_lock_footer" = "%@または4桁のPINでデータを保護します。";
"privacy_footer_biometric" = "すべての金額をぼかして隠します。人前での利用やスクリーンショットに最適。表示に%@を要求することもできます。";
// MARK: - What's New 1.7.0
"whats_new_170_duo_title" = "iPhone Duoに対応";
"whats_new_170_duo_body" = "内側の画面では、投資先とジャーナルがリストと詳細を並べて表示し、グラフにはサイドバーと高さが加わり、本体を開閉しても表示位置が保たれます。";
"whats_new_170_inspector_title" = "グラフの横でクイック更新";
"whats_new_170_inspector_body" = "広い画面では月次のクイック更新がサイドパネルで開き、値を入力している間もダッシュボードが見えたままです。";
"whats_new_170_biometrics_title" = "Face ID、Touch ID、Optic ID";
"whats_new_170_biometrics_body" = "アプリロックと残高のプライバシーが、デバイスの生体認証の種類に合わせて動作します。";
@@ -353,6 +353,18 @@
"onboarding_quickstart_subtitle" = "Adicione sua primeira fonte de investimento para começar a acompanhar seu portfólio.";
"onboarding_quickstart_title" = "Quase lá";
"reengagement_body" = "Alguns minutos são suficientes para manter o controle dos seus investimentos.";
"reengagement_goal_title" = "Você está no caminho certo 📈";
"reengagement_goal_body" = "Nesse ritmo você vai alcançar \"%@\" até %@. Veja o quanto falta.";
"reengagement_yoy_title" = "Olha esse crescimento 👀";
"reengagement_yoy_body" = "Você está %@ acima no último ano. Confira a tendência.";
"reengagement_month_title" = "Bom mês 📈";
"reengagement_month_body" = "Sua carteira cresceu %@ este mês. Dê uma olhada.";
"reengagement_milestone_title" = "Quase lá 🎯";
"reengagement_milestone_body" = "Você está quase em %@. Quão perto está hoje?";
"reengagement_streak_title" = "Mantenha a sequência 🔥";
"reengagement_streak_body" = "Você registrou %d meses seguidos. Não pare agora.";
"reengagement_neutral_title" = "Alguma novidade? 👀";
"reengagement_neutral_body" = "Houve movimento na sua carteira. Veja o que mudou.";
"reengagement_title" = "Seu portfólio está esperando";
"snapshot_duplicate_add" = "Adicionar mesmo assim";
"snapshot_contribution_propagate_title" = "Aplicar contribuição";
@@ -412,6 +424,25 @@
"insight_streak_title" = "Sequência de atualizações";
"insight_streak_value" = "%d meses consecutivos";
"insight_forecast_title" = "Caminhando para";
"insight_allocation_title" = "Desvio de alocação";
"insight_allocation_over_value" = "%1$@ está em %2$.0f%% — acima da sua meta de %3$.0f%%";
"insight_allocation_under_value" = "%1$@ está em %2$.0f%% — abaixo da sua meta de %3$.0f%%";
"insight_allocation_detail" = "Rebalanceie para seguir o plano";
"insight_concentration_title" = "Risco de concentração";
"insight_concentration_value" = "%1$@ representa %2$.0f%% da sua carteira";
"insight_concentration_detail" = "Considere diversificar";
"insight_best_performer_title" = "Melhor desempenho";
"insight_worst_performer_title" = "Requer atenção";
"insight_performer_value" = "%1$@ · %2$+.1f%%";
"insight_diversification_title" = "Diversificação";
"insight_diversification_single_value" = "Tudo em uma fonte — considere adicionar outra";
"insight_diversification_good_value" = "Distribuído em %d categorias";
"goal_projection_projected" = "Previsão: %@";
"goal_projection_add_contributions" = "Adicione aportes para alcançar";
"goal_chip_ahead" = "Adiantado";
"goal_chip_on_track" = "No caminho";
"goal_chip_behind" = "Atrasado";
"goal_chip_at_this_pace" = "Neste ritmo…";
"notification_milestone_title" = "Meta de portfólio! 🎉";
"notification_milestone_body" = "Seu portfólio acabou de ultrapassar %@. Parabéns!";
@@ -468,3 +499,211 @@
"chart_share_scan" = "Escaneie para baixar";
"chart_locked_title" = "%@ é um gráfico Premium";
"chart_locked_cta" = "Desbloquear Premium";
// Calm 2.0 Home (1.5.0)
"checkin_done_title" = "Check-in de %@ concluído";
"checkin_done_next" = "Próximo: %@";
"checkin_next_due" = "Próximo check-in: %@";
"checkin_days_left" = "faltam %d dias";
"checkin_due_today" = "Hoje";
"checkin_overdue" = "Atrasado";
"dash_chart_yoy" = "Ano vs Ano";
"home_chart_locked_sub" = "Desbloqueie com Premium para ver este gráfico.";
"checkin_sources_progress" = "%1$d de %2$d fontes atualizadas";
// Guided check-in (1.5.0 Fase 2)
"guided_previous_value" = "Mês passado";
"guided_paste_amount" = "Colar %@";
"guided_next" = "Avançar";
"guided_same_as_last" = "Igual ao mês passado";
"guided_skip" = "Pular";
"guided_reflection_title" = "Como está sendo este mês?";
"guided_reflection_subtitle" = "Uma nota rápida para o seu eu do futuro.";
"guided_note_placeholder" = "Uma linha sincera sobre este mês…";
"guided_finish" = "Concluir check-in";
"guided_done_title" = "Check-in de %@ concluído";
"guided_streak" = "%d meses seguidos";
"guided_share_month" = "Compartilhar este mês";
"guided_done_cta" = "Concluído";
"journal_no_note" = "Ainda sem nota.";
// What's New catalog (1.4.11.5.0)
"whats_new_version_header" = "Versão %@";
"whats_new_150_redesign_title" = "Um visual mais calmo e limpo";
"whats_new_150_redesign_body" = "Início redesenhado com seu valor total em destaque, o check-in no centro e menos ruído em tudo.";
"whats_new_150_guided_title" = "Check-in mensal guiado";
"whats_new_150_guided_body" = "Uma fonte por etapa, deltas ao vivo e uma reflexão rápida — seu ritual de 5 minutos, reinventado.";
"whats_new_150_rings_title" = "Anéis de metas & timeline";
"whats_new_150_rings_body" = "Anéis de progresso estilo Fitness para metas e uma timeline da sua história como investidor.";
"whats_new_142_ocr_title" = "Atualize com uma captura de tela";
"whats_new_142_ocr_body" = "Compartilhe uma captura do seu banco — o saldo é reconhecido no aparelho, nada sai do seu iPhone.";
"whats_new_142_siri_title" = "Siri no seu idioma";
"whats_new_142_siri_body" = "\"Registre o saldo da Indexa\" — anote valores por voz ou pelos Atalhos, sem abrir o app.";
"whats_new_142_charts_title" = "Gráficos renovados";
"whats_new_142_charts_body" = "Barra lateral nativa no iPad, KPIs sob medida por gráfico e compartilhamento com belas imagens.";
"whats_new_141_extension_title" = "Atualize sem trocar de app";
"whats_new_141_extension_body" = "Compartilhe um valor do app do seu banco direto no Portfolio Journal com a nova extensão.";
"whats_new_141_ipad_title" = "Melhor no iPad";
"whats_new_141_ipad_body" = "Gráficos redesenhados para a tela grande, com zoom e rótulos de valor.";
"whats_new_141_icloud_title" = "Mais sincronizado";
"whats_new_141_icloud_body" = "Aportes mensais e metas de alocação agora sincronizam via iCloud.";
"whats_new_title_format" = "Novidades da %@";
// TipKit (1.5.0 Fase 4)
"tip_quick_update_title" = "Atualize tudo em segundos";
"tip_quick_update_body" = "O Quick Update lista todas as suas fontes em uma tela — cole valores da área de transferência e pronto.";
"tip_ocr_title" = "Ou compartilhe uma captura de tela";
"tip_ocr_body" = "Do app do seu banco: captura → Compartilhar → Portfolio Journal. O saldo é lido no aparelho.";
"tip_siri_title" = "Na próxima, experimente por voz";
"tip_siri_body" = "Diga à Siri \"Registre o saldo da minha conta no Portfolio Journal\" — sem abrir o app.";
"tip_chart_share_title" = "Compartilhe este gráfico";
"tip_chart_share_body" = "Gera uma bela imagem com seus dados, pronta para o X ou WhatsApp.";
"allocation_total" = "Total";
// iCloud error hints (1.5.0)
"icloud_hint_schema" = "O iCloud rejeitou alguns registros. Geralmente significa que o esquema do iCloud ainda está sendo configurado — costuma resolver em algumas horas ou após a próxima atualização do app.";
"icloud_hint_quota" = "Seu armazenamento do iCloud está cheio. Libere espaço e tente novamente.";
"icloud_hint_network" = "Não foi possível acessar o iCloud. Verifique sua conexão e tente novamente.";
"icloud_hint_partial_generic" = "Alguns registros não sincronizaram desta vez. O app tentará novamente automaticamente.";
"icloud_status_error" = "Problema de sincronização com o iCloud";
"icloud_status_syncing" = "Sincronizando com o iCloud";
"icloud_show_details" = "Mostrar detalhes técnicos";
"yoy_vs_prev" = "vs %@";
"yoy_best_year" = "Melhor ano";
"yoy_worst_year" = "Pior ano";
// Quick Update paste/scan (1.5.0)
"quick_update_paste_value" = "Colar %@";
"quick_update_scan" = "Escanear";
"quick_update_done_kbd" = "Concluir";
"quick_update_scanning" = "Lendo imagem…";
"quick_update_pick_amount" = "Qual valor?";
"quick_update_no_amount_found" = "Nenhum valor reconhecido na imagem.";
"icloud_copy_diagnostics" = "Copiar diagnóstico do iCloud";
"icloud_diagnostics_copied" = "Diagnóstico copiado para a área de transferência";
"Smooth Gaps in Charts" = "Suavizar lacunas nos gráficos";
// MARK: - Data Gaps (1.5.0)
"gaps_banner_title" = "Faltam dados de %d meses";
"gaps_missing_count" = "%d faltando";
"gaps_range_between" = "Entre %@ e %@";
"Fill in missing data" = "Preencher dados faltantes";
"Missing Data" = "Dados faltantes";
"Fill Gap" = "Preencher lacuna";
"The chart estimates these months. Add real snapshots to keep your history accurate." = "O gráfico estima esses meses. Adicione valores reais para manter seu histórico preciso.";
"Dismiss" = "Dispensar";
// MARK: - Shareable Monthly Summary (1.5.0)
"share_summary_streak_badge" = "%d m";
"Monthly Summary" = "Resumo mensal";
"Portfolio Value" = "Valor da carteira";
"Tracked with" = "Registrado com";
"Share monthly summary" = "Compartilhar resumo mensal";
"since last check-in" = "desde o último check-in";
// MARK: - Import Preview (1.5.0)
"import_preview_nothing" = "Nada novo para importar. Seus dados já estão atualizados.";
"import_preview_sources" = "Criar %d fontes";
"import_preview_snapshots_new" = "Criar %d registros";
"import_preview_snapshots_update" = "Atualizar %d registros";
"import_preview_categories" = "Criar %d categorias";
"import_preview_goals" = "Criar %d metas";
"import_preview_journal_new" = "Criar %d entradas do diário";
"import_preview_journal_update" = "Atualizar %d entradas do diário";
"Review Import" = "Revisar importação";
"Reviewing import…" = "Revisando importação…";
"Import" = "Importar";
/* 1.6.0 gap auto-fill */
"gaps_estimated_note" = "Estimado";
"gaps_autofill_cta" = "Preencher %d meses";
"Add manually" = "Adicionar manualmente";
"Auto-fill estimates the missing months between two snapshots (marked as estimated). Or add the real values yourself." = "O preenchimento automático estima os meses faltantes entre dois registros (marcados como estimados). Ou insira os valores reais você mesmo.";
/* 1.6.0 auto-backup */
"Auto Backup" = "Backup automático";
"Off" = "Desativado";
"Daily" = "Diário";
"Weekly" = "Semanal";
"Monthly" = "Mensal";
"Last auto backup" = "Último backup automático";
/* 1.6.0 hide balances */
"Hide balances" = "Ocultar saldos";
"Show balances" = "Mostrar saldos";
"Balance hidden" = "Saldo oculto";
"Reveal your balances" = "Mostrar seus saldos";
"Privacy" = "Privacidade";
"Hide Balances" = "Ocultar saldos";
"Require Face ID to Reveal" = "Exigir Face ID para mostrar";
"Hide all monetary amounts behind a blur. Great for using the app in public or taking screenshots. Optionally require Face ID to reveal them." = "Oculta todos os valores atrás de um desfoque. Ótimo para usar o app em público ou tirar prints. Opcionalmente, exija Face ID para revelá-los.";
/* 1.6.0 Siri intents */
"intent_networth_empty" = "Você ainda não registrou nenhum valor de carteira.";
"intent_networth_dialog" = "Seu patrimônio é %@.";
"intent_streak" = "Você está em uma sequência de %d meses.";
"intent_streak_with_date" = "Você está em uma sequência de %d meses. Seu último check-in foi em %@.";
"intent_streak_none" = "Você ainda não tem uma sequência ativa. Registre um valor para começar uma.";
// Charts workable (1.6.0)
"chart_range_button" = "Período personalizado";
"chart_range_from" = "De";
"chart_range_to" = "Até";
"chart_filtered_prefix" = "Filtrado: %@";
"chart_overlay_contributions" = "vs Aportes";
"chart_insight_best_month" = "Melhor mês do período: %1$@ (%2$@)";
// Share cards 2.0 (1.6.0)
"share_options_title" = "Compartilhe seu progresso";
"share_percent_only" = "Apenas porcentagens";
"share_format_post" = "Post";
"share_format_story" = "Story";
"share_card_cta" = "Compartilhar";
// Share cards 2.1 — brand header + download CTA (1.6.0)
"share_hero_eyebrow" = "Retorno total";
"share_metric_yoy" = "Este ano";
"share_metric_since_inception" = "Desde o início";
"share_cta_headline" = "Acompanhe o seu — grátis";
"share_cta_sub" = "Escaneie para obter o Portfolio Journal";
// Share cards 2.2 — as-of date + theme (1.6.0)
"share_as_of" = "Em %@";
"share_theme_dark" = "Escuro";
"share_theme_light" = "Claro";
// Activation: first source → first snapshot (1.6.1)
"add_source_value_header" = "Valor de hoje";
"add_source_value_footer" = "Salvo como seu primeiro check-in — seu progresso começa hoje.";
"quick_update_add_first_source" = "Adicionar sua primeira fonte";
/* MARK: - Goal scope (category) */
"goal_scope_section" = "Escopo";
"goal_scope_category" = "Categoria";
"goal_scope_all_categories" = "Todas as categorias";
"goal_scope_footer_all" = "Todas as fontes da sua carteira contam para esta meta.";
"goal_scope_footer_category" = "Apenas as fontes desta categoria contam para esta meta.";
// MARK: - Biometrics (1.7.0 — Face ID / Touch ID / Optic ID aware)
"biometric_generic_name" = "Face ID ou Touch ID";
"biometric_unlock_with" = "Desbloquear com %@";
"biometric_failed" = "%@ falhou. Tente novamente.";
"biometric_enable" = "Ativar %@";
"biometric_unavailable_title" = "%@ indisponível";
"biometric_unavailable_body" = "%@ não está disponível neste dispositivo.";
"biometric_pin_required" = "Defina um PIN antes de ativar o %@.";
"biometric_disable_first_title" = "Desative o %@ primeiro";
"biometric_disable_first_body" = "Desative o %@ antes de desativar seu PIN.";
"biometric_pin_to_disable" = "Digite o PIN para desativar o %@";
"biometric_require_to_reveal" = "Exigir %@ para mostrar";
"biometric_lock_footer" = "Use %@ ou um PIN de 4 dígitos para proteger seus dados.";
"privacy_footer_biometric" = "Oculta todos os valores atrás de um desfoque. Ótimo para usar o app em público ou tirar prints. Opcionalmente, exija %@ para revelá-los.";
// MARK: - What's New 1.7.0
"whats_new_170_duo_title" = "Pronto para iPhone Duo";
"whats_new_170_duo_body" = "Na tela interna, Fontes e Diário mostram lista e detalhe lado a lado, Gráficos ganha uma barra lateral e mais altura, e você não perde o lugar ao abrir ou fechar o aparelho.";
"whats_new_170_inspector_title" = "Atualização rápida ao lado dos gráficos";
"whats_new_170_inspector_body" = "Em telas largas a atualização rápida mensal abre em um painel lateral: o painel principal continua visível enquanto você digita valores.";
"whats_new_170_biometrics_title" = "Face ID, Touch ID ou Optic ID";
"whats_new_170_biometrics_body" = "O bloqueio do app e a privacidade de saldos usam a biometria disponível no seu aparelho.";
@@ -1,5 +1,6 @@
import AppIntents
import Foundation
import CoreData
// MARK: - App Intents (Siri / Shortcuts / Spotlight)
//
@@ -59,14 +60,14 @@ struct SourceEntity: AppEntity {
struct SourceEntityQuery: EntityQuery {
func entities(for identifiers: [UUID]) async throws -> [SourceEntity] {
SharedQuickUpdateStore.readMirror()
await MainActor.run { SharedQuickUpdateStore.readMirror() }
.filter { identifiers.contains($0.id) }
.map(SourceEntity.init)
}
func suggestedEntities() async throws -> [SourceEntity] {
// Pending-first, mirroring the Share Extension's ordering.
SharedQuickUpdateStore.readMirror()
await MainActor.run { SharedQuickUpdateStore.readMirror() }
.sorted { a, b in
if a.updatedThisMonth != b.updatedThisMonth { return !a.updatedThisMonth }
return a.name.localizedCaseInsensitiveCompare(b.name) == .orderedAscending
@@ -117,6 +118,173 @@ struct LogSnapshotIntent: AppIntent {
}
}
// MARK: - Net worth / streak (read-only, from the shared App Group store)
/// Reads aggregate portfolio facts from the App Group Core Data store the same
/// way the widget does: a read-only container over the shared SQLite file. This
/// lets the intents answer instantly without touching the app's CloudKit stack.
enum SharedPortfolioReader {
private static let storeFileName = "PortfolioJournal.sqlite"
struct Snapshot {
let totalValue: Decimal
let currencyCode: String
let streak: Int
let lastCheckIn: Date?
}
private static func sharedStoreURL() -> URL? {
FileManager.default
.containerURL(forSecurityApplicationGroupIdentifier: AppConstants.appGroupIdentifier)?
.appendingPathComponent(storeFileName)
}
private static func makeContainer() -> NSPersistentContainer? {
guard let modelURL = Bundle.main.url(forResource: "PortfolioJournal", withExtension: "momd"),
let model = NSManagedObjectModel(contentsOf: modelURL),
let storeURL = sharedStoreURL(),
FileManager.default.fileExists(atPath: storeURL.path) else {
return nil
}
let container = NSPersistentContainer(name: "PortfolioJournal", managedObjectModel: model)
let description = NSPersistentStoreDescription(url: storeURL)
description.isReadOnly = true
description.shouldMigrateStoreAutomatically = true
description.shouldInferMappingModelAutomatically = true
container.persistentStoreDescriptions = [description]
var loadError: Error?
container.loadPersistentStores { _, error in loadError = error }
return loadError == nil ? container : nil
}
static func read() -> Snapshot {
guard let container = makeContainer() else {
return Snapshot(totalValue: 0, currencyCode: "EUR", streak: 0, lastCheckIn: nil)
}
let context = container.viewContext
let calendar = Calendar.current
// Currency
var currency = "EUR"
let settingsRequest = NSFetchRequest<NSManagedObject>(entityName: "AppSettings")
settingsRequest.fetchLimit = 1
if let settings = try? context.fetch(settingsRequest).first,
let code = settings.value(forKey: "currency") as? String, !code.isEmpty {
currency = code
}
// Latest snapshot value per source total net worth.
// Also collect the set of (year, month) that have snapshot data for streak.
let snapRequest = NSFetchRequest<NSManagedObject>(entityName: "Snapshot")
snapRequest.sortDescriptors = [NSSortDescriptor(key: "date", ascending: true)]
let snapshots = (try? context.fetch(snapRequest)) ?? []
var latestBySource: [NSManagedObjectID: (date: Date, value: Decimal)] = [:]
var monthComponents = Set<DateComponents>()
for snap in snapshots {
guard let source = snap.value(forKey: "source") as? NSManagedObject else { continue }
let date = (snap.value(forKey: "date") as? Date) ?? .distantPast
let value = (snap.value(forKey: "value") as? NSDecimalNumber)?.decimalValue ?? .zero
let key = source.objectID
if let existing = latestBySource[key] {
if date >= existing.date { latestBySource[key] = (date, value) }
} else {
latestBySource[key] = (date, value)
}
let c = calendar.dateComponents([.year, .month], from: date)
monthComponents.insert(DateComponents(year: c.year, month: c.month))
}
let totalValue = latestBySource.values.reduce(Decimal.zero) { $0 + $1.value }
// Streak: consecutive months with data counting back from most recent.
let sortedMonthsDesc = monthComponents
.compactMap { calendar.date(from: $0) }
.sorted(by: >)
var streak = 0
if let mostRecent = sortedMonthsDesc.first {
streak = 1
var current = mostRecent
for i in 1..<max(sortedMonthsDesc.count, 1) {
guard i < sortedMonthsDesc.count,
let expected = calendar.date(byAdding: .month, value: -1, to: current) else { break }
let prev = sortedMonthsDesc[i]
let pc = calendar.dateComponents([.year, .month], from: prev)
let ec = calendar.dateComponents([.year, .month], from: expected)
if pc.year == ec.year && pc.month == ec.month {
streak += 1
current = prev
} else { break }
}
}
// Last check-in from the most recent completed JournalEntry.
let journalRequest = NSFetchRequest<NSManagedObject>(entityName: "JournalEntry")
journalRequest.predicate = NSPredicate(format: "completionTime != nil")
journalRequest.sortDescriptors = [NSSortDescriptor(key: "completionTime", ascending: false)]
journalRequest.fetchLimit = 1
let lastCheckIn = (try? context.fetch(journalRequest))?.first?
.value(forKey: "completionTime") as? Date
return Snapshot(totalValue: totalValue, currencyCode: currency, streak: streak, lastCheckIn: lastCheckIn)
}
}
/// "What's my net worth in Portfolio Journal" reads the total portfolio value
/// from the shared store and speaks it back. Runs headless (no app launch).
struct NetWorthIntent: AppIntent {
static let title: LocalizedStringResource = "Check Net Worth"
// ITMS-90626: intent descriptions must not contain the word "Siri".
static let description = IntentDescription("Get your total portfolio value.")
static let openAppWhenRun = false
@MainActor
func perform() async throws -> some IntentResult & ProvidesDialog {
let snapshot = SharedPortfolioReader.read()
let formatter = NumberFormatter()
formatter.numberStyle = .currency
formatter.currencyCode = snapshot.currencyCode
let formatted = formatter.string(from: NSDecimalNumber(decimal: snapshot.totalValue))
?? "\(snapshot.totalValue)"
let text: String
if snapshot.totalValue == 0 {
text = String(localized: "intent_networth_empty")
} else {
text = String(format: String(localized: "intent_networth_dialog"), formatted)
}
return .result(dialog: IntentDialog(stringLiteral: text))
}
}
/// "What's my streak in Portfolio Journal" reports the current check-in streak
/// and when you last logged. Nice-to-have engagement surface. Runs headless.
struct StreakIntent: AppIntent {
static let title: LocalizedStringResource = "Check Streak"
// ITMS-90626: intent descriptions must not contain the word "Siri".
static let description = IntentDescription("Get your current check-in streak.")
static let openAppWhenRun = false
@MainActor
func perform() async throws -> some IntentResult & ProvidesDialog {
let snapshot = SharedPortfolioReader.read()
let text: String
if snapshot.streak >= 1 {
if let last = snapshot.lastCheckIn {
let df = DateFormatter()
df.dateStyle = .medium
df.timeStyle = .none
text = String(format: String(localized: "intent_streak_with_date"), snapshot.streak, df.string(from: last))
} else {
text = String(format: String(localized: "intent_streak"), snapshot.streak)
}
} else {
text = String(localized: "intent_streak_none")
}
return .result(dialog: IntentDialog(stringLiteral: text))
}
}
struct PortfolioJournalShortcuts: AppShortcutsProvider {
static var appShortcuts: [AppShortcut] {
AppShortcut(
@@ -147,5 +315,24 @@ struct PortfolioJournalShortcuts: AppShortcutsProvider {
shortTitle: "Log Value",
systemImageName: "square.and.pencil"
)
AppShortcut(
intent: NetWorthIntent(),
phrases: [
"What's my net worth in \(.applicationName)",
"How much is my portfolio worth in \(.applicationName)",
"Get my net worth from \(.applicationName)"
],
shortTitle: "Net Worth",
systemImageName: "eurosign.circle.fill"
)
AppShortcut(
intent: StreakIntent(),
phrases: [
"What's my streak in \(.applicationName)",
"Check my check-in streak in \(.applicationName)"
],
shortTitle: "Streak",
systemImageName: "flame.fill"
)
}
}
+37
View File
@@ -0,0 +1,37 @@
import SwiftUI
import TipKit
// MARK: - Feature discovery (TipKit, Calm 2.0 Fase 4)
//
// The app's most differentiating features (quick update, screenshot OCR, Siri,
// chart sharing) were invisible unless you stumbled on them. One contextual tip
// each, shown at most once a day (see Tips.configure in PortfolioJournalApp),
// auto-invalidated after display or when the action is performed.
struct QuickUpdateTip: Tip {
var title: Text { Text(String(localized: "tip_quick_update_title")) }
var message: Text? { Text(String(localized: "tip_quick_update_body")) }
var image: Image? { Image(systemName: "bolt.circle.fill") }
var options: [any TipOption] { [MaxDisplayCount(2)] }
}
struct ScreenshotOCRTip: Tip {
var title: Text { Text(String(localized: "tip_ocr_title")) }
var message: Text? { Text(String(localized: "tip_ocr_body")) }
var image: Image? { Image(systemName: "text.viewfinder") }
var options: [any TipOption] { [MaxDisplayCount(2)] }
}
struct SiriTip: Tip {
var title: Text { Text(String(localized: "tip_siri_title")) }
var message: Text? { Text(String(localized: "tip_siri_body")) }
var image: Image? { Image(systemName: "mic.fill") }
var options: [any TipOption] { [MaxDisplayCount(1)] }
}
struct ChartShareTip: Tip {
var title: Text { Text(String(localized: "tip_chart_share_title")) }
var message: Text? { Text(String(localized: "tip_chart_share_body")) }
var image: Image? { Image(systemName: "square.and.arrow.up") }
var options: [any TipOption] { [MaxDisplayCount(2)] }
}
@@ -85,6 +85,30 @@ class BackupService {
return records.sorted { $0.date > $1.date }
}
/// Runs an automatic backup if auto-backup is enabled and the configured
/// interval has elapsed since the last one. Call on app foreground. Reads its
/// config from UserDefaults so it needs no coupling to the Settings VM. The
/// backup content is the lossless JSON v3 export (goals, contributions, journal).
@discardableResult
func runScheduledBackupIfNeeded() -> Bool {
let d = UserDefaults.standard
// `backupsEnabled` is only settable by Premium users, so it also gates this.
guard d.bool(forKey: "backupsEnabled") else { return false }
let frequencyDays = d.integer(forKey: "autoBackupFrequencyDays") // 0 = off
guard frequencyDays > 0 else { return false }
if let last = d.object(forKey: "lastAutoBackupDate") as? Date,
dateProvider().timeIntervalSince(last) < Double(frequencyDays) * 86_400 {
return false
}
let retention = (d.object(forKey: "backupRetentionCount") as? Int) ?? 10
let includeICloud = d.bool(forKey: "cloudSyncEnabled")
_ = createBackup(retentionCount: retention, includeICloud: includeICloud)
d.set(dateProvider(), forKey: "lastAutoBackupDate")
return true
}
func listAllBackups(includeICloud: Bool) -> [BackupRecord] {
var records: [BackupRecord] = []
if let localDir = backupDirectory() {
@@ -124,7 +124,7 @@ struct ChartShareCardView<Content: View>: View {
.font(.headline)
Text("\(title) · \(Self.dateLabel)")
.font(.subheadline)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
Spacer()
}
@@ -138,15 +138,15 @@ struct ChartShareCardView<Content: View>: View {
VStack(alignment: .leading, spacing: 3) {
Text(stats[i].label)
.font(.caption2)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
Text(stats[i].value)
.font(.subheadline.weight(.semibold))
.foregroundColor(stats[i].color)
.foregroundStyle(stats[i].color)
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(Color(.systemGray6))
.cornerRadius(8)
.clipShape(RoundedRectangle(cornerRadius: 8))
}
Spacer()
}
@@ -161,7 +161,7 @@ struct ChartShareCardView<Content: View>: View {
.font(.footnote.weight(.medium))
Text(verbatim: "portfoliojournal.app")
.font(.footnote)
.foregroundColor(.appPrimary)
.foregroundStyle(Color.appPrimary)
}
Spacer()
if let qrCodeImage {
@@ -173,7 +173,7 @@ struct ChartShareCardView<Content: View>: View {
.frame(width: 64, height: 64)
Text(String(localized: "chart_share_scan"))
.font(.caption2)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
}
}
+61 -2
View File
@@ -1,5 +1,6 @@
import Foundation
import UIKit
import CoreData
class ExportService {
static let shared = ExportService()
@@ -63,7 +64,7 @@ class ExportService {
) -> String {
var exportData: [String: Any] = [:]
exportData["exportDate"] = ISO8601DateFormatter().string(from: Date())
exportData["version"] = 2
exportData["version"] = 3
exportData["currency"] = AppSettings.getOrCreate(in: CoreDataStack.shared.viewContext).currency
let accounts = Dictionary(grouping: sources) { $0.account?.id.uuidString ?? "default" }
@@ -92,14 +93,23 @@ class ExportService {
"icon": category?.icon ?? "chart.pie.fill"
]
if let target = category?.allocationTarget?.doubleValue, target > 0 {
categoryDict["allocationTarget"] = target
}
var sourcesArray: [[String: Any]] = []
for source in categorySources {
var sourceDict: [String: Any] = [
"name": source.name,
"isActive": source.isActive,
"notificationFrequency": source.notificationFrequency
"notificationFrequency": source.notificationFrequency,
"customFrequencyMonths": Int(source.customFrequencyMonths)
]
if let contribution = source.monthlyContribution {
sourceDict["monthlyContribution"] = contribution.doubleValue
}
var snapshotsArray: [[String: Any]] = []
for snapshot in source.snapshotsArray {
var snapshotDict: [String: Any] = [
@@ -134,6 +144,55 @@ class ExportService {
exportData["accounts"] = accountsArray
// Goals (all accounts)
let isoFormatter = ISO8601DateFormatter()
let goalContext = CoreDataStack.shared.viewContext
let goalRequest: NSFetchRequest<Goal> = Goal.fetchRequest()
let allGoals = (try? goalContext.fetch(goalRequest)) ?? []
var goalsArray: [[String: Any]] = []
for goal in allGoals {
var goalDict: [String: Any] = [
"name": goal.name,
"isActive": goal.isActive,
"account": goal.account?.name as Any,
"category": goal.category?.name as Any
]
if let amount = goal.targetAmount {
goalDict["targetAmount"] = amount.doubleValue
}
if let date = goal.targetDate {
goalDict["targetDate"] = isoFormatter.string(from: date)
}
goalsArray.append(goalDict)
}
exportData["goals"] = goalsArray
// Journal entries (all)
let journalRequest: NSFetchRequest<JournalEntry> = JournalEntry.fetchRequest()
let allJournalEntries = (try? goalContext.fetch(journalRequest)) ?? []
var journalArray: [[String: Any]] = []
for entry in allJournalEntries {
guard let monthKey = entry.monthKey, !monthKey.isEmpty else { continue }
var entryDict: [String: Any] = ["monthKey": monthKey]
if let note = entry.note, !note.isEmpty {
entryDict["note"] = note
}
if let mood = entry.moodRaw, !mood.isEmpty {
entryDict["mood"] = mood
}
if entry.rating > 0 {
entryDict["rating"] = Int(entry.rating)
}
if let completionTime = entry.completionTime {
entryDict["completionTime"] = isoFormatter.string(from: completionTime)
}
if let createdAt = entry.createdAt {
entryDict["createdAt"] = isoFormatter.string(from: createdAt)
}
journalArray.append(entryDict)
}
exportData["journalEntries"] = journalArray
// Add summary
let totalValue = sources.reduce(Decimal.zero) { $0 + $1.latestValue }
exportData["summary"] = [
+497 -14
View File
@@ -31,6 +31,25 @@ class ImportService {
case json
}
/// Read-only summary of what an import WOULD do, computed against existing
/// data without mutating anything. Presented to the user for confirmation
/// before the real import runs.
struct ImportPreview {
var sourcesToCreate: Int = 0
var snapshotsToCreate: Int = 0
var snapshotsToUpdate: Int = 0
var categoriesToCreate: Int = 0
var goalsToCreate: Int = 0
var journalToCreate: Int = 0
var journalToUpdate: Int = 0
var hasAnything: Bool {
sourcesToCreate > 0 || snapshotsToCreate > 0 || snapshotsToUpdate > 0 ||
categoriesToCreate > 0 || goalsToCreate > 0 ||
journalToCreate > 0 || journalToUpdate > 0
}
}
struct ImportedAccount {
let name: String
let currency: String?
@@ -44,11 +63,14 @@ class ImportService {
let name: String
let colorHex: String?
let icon: String?
let allocationTarget: Double?
let sources: [ImportedSource]
}
struct ImportedSource {
let name: String
let monthlyContribution: Decimal?
let customFrequencyMonths: Int?
let snapshots: [ImportedSnapshot]
}
@@ -59,6 +81,24 @@ class ImportService {
let notes: String?
}
struct ImportedGoal {
let name: String
let targetAmount: Decimal?
let targetDate: Date?
let isActive: Bool
let accountName: String?
let categoryName: String?
}
struct ImportedJournalEntry {
let monthKey: String
let note: String?
let mood: String?
let rating: Int?
let completionTime: Date?
let createdAt: Date?
}
func importData(
content: String,
format: ImportFormat,
@@ -79,7 +119,14 @@ class ImportService {
allowMultipleAccounts: allowMultipleAccounts,
defaultAccountName: defaultAccountName
)
return applyImport(parsed, context: CoreDataStack.shared.viewContext)
let goals = parseGoals(content)
let journal = parseJournal(content)
return applyImport(
parsed,
context: CoreDataStack.shared.viewContext,
goals: goals,
journal: journal
)
}
}
@@ -93,6 +140,8 @@ class ImportService {
await withCheckedContinuation { continuation in
CoreDataStack.shared.performBackgroundTask { context in
let parsed: [ImportedAccount]
var goals: [ImportedGoal] = []
var journal: [ImportedJournalEntry] = []
switch format {
case .csv:
parsed = self.parseCSV(
@@ -106,6 +155,8 @@ class ImportService {
allowMultipleAccounts: allowMultipleAccounts,
defaultAccountName: defaultAccountName
)
goals = self.parseGoals(content)
journal = self.parseJournal(content)
}
let totalSnapshots = parsed.reduce(0) { total, account in
@@ -120,7 +171,12 @@ class ImportService {
progress(ImportProgress(completed: 0, total: totalSnapshots, message: "Importing data"))
}
let result = self.applyImport(parsed, context: context) { completed in
let result = self.applyImport(
parsed,
context: context,
goals: goals,
journal: journal
) { completed in
DispatchQueue.main.async {
progress(ImportProgress(
completed: completed,
@@ -147,7 +203,7 @@ Personal,Real Estate,Rental Property,2024-01-01,82000,80000,Estimated value
static func sampleJSON() -> String {
return """
{
"version": 2,
"version": 3,
"currency": "EUR",
"accounts": [{
"name": "Personal",
@@ -171,6 +227,211 @@ Personal,Real Estate,Rental Property,2024-01-01,82000,80000,Estimated value
"""
}
// MARK: - Dry-Run Preview (read-only)
/// Parses the content and counts what an import WOULD do against existing
/// data, WITHOUT mutating anything. Uses the same parse functions and the
/// same existence checks as `applyImport`, but only reads.
func previewImportAsync(
content: String,
format: ImportFormat,
allowMultipleAccounts: Bool,
defaultAccountName: String? = nil
) async -> ImportPreview {
await withCheckedContinuation { continuation in
CoreDataStack.shared.performBackgroundTask { context in
let parsed: [ImportedAccount]
var goals: [ImportedGoal] = []
var journal: [ImportedJournalEntry] = []
switch format {
case .csv:
parsed = self.parseCSV(
content,
allowMultipleAccounts: allowMultipleAccounts,
defaultAccountName: defaultAccountName
)
case .json:
parsed = self.parseJSON(
content,
allowMultipleAccounts: allowMultipleAccounts,
defaultAccountName: defaultAccountName
)
goals = self.parseGoals(content)
journal = self.parseJournal(content)
}
let preview = self.computePreview(
accounts: parsed,
goals: goals,
journal: journal,
context: context
)
continuation.resume(returning: preview)
}
}
}
/// Dry-run for the CSV column-mapping flow.
func previewCSVWithMappingAsync(
content: String,
mapping: CSVMappingConfig,
defaultAccountName: String?
) async -> ImportPreview {
await withCheckedContinuation { continuation in
CoreDataStack.shared.performBackgroundTask { context in
let preview = self.previewCSV(content)
let accounts = self.parseCSVWithMapping(
preview: preview,
mapping: mapping,
defaultAccountName: defaultAccountName
)
let result = self.computePreview(
accounts: accounts,
goals: [],
journal: [],
context: context
)
continuation.resume(returning: result)
}
}
}
/// Read-only counting pass. Mirrors the existence checks in `applyImport`
/// (same source name+account, same-day snapshot, category name, goal
/// name+account, journal monthKey) but never writes. Because it never
/// saves, it may not perfectly disambiguate multiple new sources/categories
/// that collapse to the same name within one import; it errs toward the
/// user-facing counts `applyImport` would produce for typical files.
private func computePreview(
accounts: [ImportedAccount],
goals: [ImportedGoal],
journal: [ImportedJournalEntry],
context: NSManagedObjectContext
) -> ImportPreview {
var preview = ImportPreview()
let existingCategories = fetchCategories(in: context)
let categoryLookup = buildCategoryLookup(from: existingCategories)
let existingAccountsLookup = fetchAccountsLookup(in: context)
// Track names we would create so duplicates within the same import file
// aren't double-counted.
var plannedCategoryKeys = Set<String>()
var plannedSourceKeys = Set<String>() // "accountName|sourceName"
var accountsByName: [String: Account] = existingAccountsLookup
// "Other" is created on demand by applyImport when a source has no
// resolvable category; only count it if we actually need it.
var otherCategoryNeeded = false
let otherExists = resolveExistingCategory(named: "Other", lookup: categoryLookup) != nil
for importedAccount in accounts {
let account = existingAccountsLookup[importedAccount.name]
accountsByName[importedAccount.name] = account
for importedCategory in importedAccount.categories {
let existingCategory = resolveExistingCategory(
named: importedCategory.name,
lookup: categoryLookup
)
let shouldUseOther = existingCategory == nil &&
importedCategory.colorHex == nil &&
importedCategory.icon == nil
if existingCategory == nil {
if shouldUseOther {
otherCategoryNeeded = otherCategoryNeeded || !otherExists
} else {
let key = normalizedCategoryName(
canonicalCategoryName(for: importedCategory.name) ?? importedCategory.name
)
if !plannedCategoryKeys.contains(key) {
plannedCategoryKeys.insert(key)
preview.categoriesToCreate += 1
}
}
}
for importedSource in importedCategory.sources {
let accountId = account?.safeId
let existingSource = fetchSource(
named: importedSource.name,
accountId: accountId,
in: context
)
let sourceKey = "\(importedAccount.name)|\(importedSource.name)"
if existingSource == nil, !plannedSourceKeys.contains(sourceKey) {
plannedSourceKeys.insert(sourceKey)
preview.sourcesToCreate += 1
}
for snapshot in importedSource.snapshots {
// Only an EXISTING source can have a same-day snapshot to update.
if let existing = existingSource,
fetchSnapshot(for: existing, date: snapshot.date, in: context) != nil {
preview.snapshotsToUpdate += 1
} else {
preview.snapshotsToCreate += 1
}
}
}
}
}
if otherCategoryNeeded {
preview.categoriesToCreate += 1
}
// Goals dedup by name within account (case-insensitive), same as applyImport.
var plannedGoalKeys = Set<String>()
for importedGoal in goals {
let trimmedName = importedGoal.name.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedName.isEmpty, importedGoal.targetAmount != nil else { continue }
let resolvedAccount = importedGoal.accountName.flatMap { accountsByName[$0] }
let resolvedCategory = importedGoal.categoryName.flatMap {
resolveExistingCategory(named: $0, lookup: categoryLookup)
}
if fetchGoal(
named: trimmedName,
account: resolvedAccount,
category: resolvedCategory,
in: context
) == nil {
let key = "\(trimmedName.lowercased())|\(resolvedAccount?.name ?? "")|\(resolvedCategory?.name ?? "")"
if !plannedGoalKeys.contains(key) {
plannedGoalKeys.insert(key)
preview.goalsToCreate += 1
}
}
}
// Journal dedup by monthKey, same as applyImport.
var plannedJournalKeys = Set<String>()
for importedEntry in journal {
guard !plannedJournalKeys.contains(importedEntry.monthKey) else { continue }
plannedJournalKeys.insert(importedEntry.monthKey)
if fetchJournalEntry(monthKey: importedEntry.monthKey, in: context) != nil {
preview.journalToUpdate += 1
} else {
preview.journalToCreate += 1
}
}
return preview
}
/// Read-only fetch of an existing source by name within an account (or with
/// no account). Mirrors applyImport's `name && account?.id` match.
private func fetchSource(
named name: String,
accountId: UUID?,
in context: NSManagedObjectContext
) -> InvestmentSource? {
let request: NSFetchRequest<InvestmentSource> = InvestmentSource.fetchRequest()
request.predicate = NSPredicate(format: "name == %@", name)
let matches = (try? context.fetch(request)) ?? []
return matches.first { $0.account?.id == accountId }
}
// MARK: - Parsing
private func parseCSV(
@@ -230,9 +491,20 @@ Personal,Real Estate,Rental Property,2024-01-01,82000,80000,Estimated value
return grouped.map { accountName, categories in
let importedCategories = categories.map { categoryName, sources in
let importedSources = sources.map { sourceName, snapshots in
ImportedSource(name: sourceName, snapshots: snapshots)
ImportedSource(
name: sourceName,
monthlyContribution: nil,
customFrequencyMonths: nil,
snapshots: snapshots
)
}
return ImportedCategory(name: categoryName, colorHex: nil, icon: nil, sources: importedSources)
return ImportedCategory(
name: categoryName,
colorHex: nil,
icon: nil,
allocationTarget: nil,
sources: importedSources
)
}
return ImportedAccount(
@@ -274,9 +546,12 @@ Personal,Real Estate,Rental Property,2024-01-01,82000,80000,Estimated value
let categoryName = categoryDict["name"] as? String ?? "Uncategorized"
let colorHex = categoryDict["color"] as? String
let icon = categoryDict["icon"] as? String
let allocationTarget = categoryDict["allocationTarget"] as? Double
let sourcesArray = categoryDict["sources"] as? [[String: Any]] ?? []
let sources = sourcesArray.map { sourceDict in
let sourceName = sourceDict["name"] as? String ?? "Source"
let monthlyContribution = (sourceDict["monthlyContribution"] as? Double).map { Decimal($0) }
let customFrequencyMonths = sourceDict["customFrequencyMonths"] as? Int
let snapshotsArray = sourceDict["snapshots"] as? [[String: Any]] ?? []
let snapshots = snapshotsArray.compactMap { snapshotDict -> ImportedSnapshot? in
guard let dateString = snapshotDict["date"] as? String,
@@ -295,13 +570,19 @@ Personal,Real Estate,Rental Property,2024-01-01,82000,80000,Estimated value
)
}
return ImportedSource(name: sourceName, snapshots: snapshots)
return ImportedSource(
name: sourceName,
monthlyContribution: monthlyContribution,
customFrequencyMonths: customFrequencyMonths,
snapshots: snapshots
)
}
return ImportedCategory(
name: categoryName,
colorHex: colorHex,
icon: icon,
allocationTarget: allocationTarget,
sources: sources
)
}
@@ -344,13 +625,19 @@ Personal,Real Estate,Rental Property,2024-01-01,82000,80000,Estimated value
)
}
return ImportedSource(name: sourceName, snapshots: snapshots)
return ImportedSource(
name: sourceName,
monthlyContribution: nil,
customFrequencyMonths: nil,
snapshots: snapshots
)
}
return ImportedCategory(
name: categoryName,
colorHex: colorHex,
icon: icon,
allocationTarget: nil,
sources: sources
)
}
@@ -371,11 +658,72 @@ Personal,Real Estate,Rental Property,2024-01-01,82000,80000,Estimated value
return []
}
private func parseGoals(_ content: String) -> [ImportedGoal] {
guard let data = content.data(using: .utf8),
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let goalsArray = json["goals"] as? [[String: Any]] else {
return []
}
return goalsArray.compactMap { goalDict -> ImportedGoal? in
guard let name = goalDict["name"] as? String,
!name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
return nil
}
let targetAmount = (goalDict["targetAmount"] as? Double).map { Decimal($0) }
let targetDate = (goalDict["targetDate"] as? String)
.flatMap { ISO8601DateFormatter().date(from: $0) }
let isActive = goalDict["isActive"] as? Bool ?? true
let accountName = goalDict["account"] as? String
let categoryName = goalDict["category"] as? String
return ImportedGoal(
name: name,
targetAmount: targetAmount,
targetDate: targetDate,
isActive: isActive,
accountName: accountName,
categoryName: categoryName
)
}
}
private func parseJournal(_ content: String) -> [ImportedJournalEntry] {
guard let data = content.data(using: .utf8),
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let journalArray = json["journalEntries"] as? [[String: Any]] else {
return []
}
return journalArray.compactMap { entryDict -> ImportedJournalEntry? in
guard let monthKey = entryDict["monthKey"] as? String,
!monthKey.isEmpty else {
return nil
}
let note = entryDict["note"] as? String
let mood = entryDict["mood"] as? String
let rating = entryDict["rating"] as? Int
let completionTime = (entryDict["completionTime"] as? String)
.flatMap { ISO8601DateFormatter().date(from: $0) }
let createdAt = (entryDict["createdAt"] as? String)
.flatMap { ISO8601DateFormatter().date(from: $0) }
return ImportedJournalEntry(
monthKey: monthKey,
note: note,
mood: mood,
rating: rating,
completionTime: completionTime,
createdAt: createdAt
)
}
}
// MARK: - Apply Import
private func applyImport(
_ accounts: [ImportedAccount],
context: NSManagedObjectContext,
goals: [ImportedGoal] = [],
journal: [ImportedJournalEntry] = [],
snapshotProgress: ((Int) -> Void)? = nil
) -> ImportResult {
let accountRepository = AccountRepository(context: context)
@@ -383,6 +731,15 @@ Personal,Real Estate,Rental Property,2024-01-01,82000,80000,Estimated value
let sourceRepository = InvestmentSourceRepository(context: context)
let snapshotRepository = SnapshotRepository(context: context)
// Batch import: suppress the per-row widget refresh (each does a synchronous
// WAL checkpoint on the main context) to avoid hanging the main thread on
// large imports. Refresh once when everything is done.
CoreDataStack.shared.suppressWidgetRefresh = true
defer {
CoreDataStack.shared.suppressWidgetRefresh = false
CoreDataStack.shared.refreshWidgetData()
}
var accountsCreated = 0
var sourcesCreated = 0
var snapshotsCreated = 0
@@ -404,6 +761,8 @@ Personal,Real Estate,Rental Property,2024-01-01,82000,80000,Estimated value
// Build lookup of existing accounts by fetching directly from database
let existingAccountsLookup = fetchAccountsLookup(in: context)
var accountsByName: [String: Account] = existingAccountsLookup
for importedAccount in accounts {
let existingAccount = existingAccountsLookup[importedAccount.name]
let account: Account
@@ -419,6 +778,7 @@ Personal,Real Estate,Rental Property,2024-01-01,82000,80000,Estimated value
)
accountsCreated += 1
}
accountsByName[importedAccount.name] = account
for importedCategory in importedAccount.categories {
let existingCategory = resolveExistingCategory(
@@ -438,6 +798,10 @@ Personal,Real Estate,Rental Property,2024-01-01,82000,80000,Estimated value
))
categoryLookup[normalizedCategoryName(category.name)] = category
if let allocationTarget = importedCategory.allocationTarget {
category.allocationTarget = NSNumber(value: allocationTarget)
}
for importedSource in importedCategory.sources {
let accountId = account.safeId
let existingSource = sourceRepository.sources.first(where: {
@@ -454,6 +818,13 @@ Personal,Real Estate,Rental Property,2024-01-01,82000,80000,Estimated value
sourcesCreated += 1
}
if let monthlyContribution = importedSource.monthlyContribution {
source.monthlyContribution = NSDecimalNumber(decimal: monthlyContribution)
}
if let customFrequencyMonths = importedSource.customFrequencyMonths {
source.customFrequencyMonths = Int16(customFrequencyMonths)
}
for snapshot in importedSource.snapshots {
// Check if a snapshot with the same date already exists for this source
let existingSnapshot = fetchSnapshot(for: source, date: snapshot.date, in: context)
@@ -475,7 +846,8 @@ Personal,Real Estate,Rental Property,2024-01-01,82000,80000,Estimated value
date: snapshot.date,
value: snapshot.value,
contribution: snapshot.contribution,
notes: snapshot.notes
notes: snapshot.notes,
batch: true
)
snapshotsCreated += 1
}
@@ -494,6 +866,56 @@ Personal,Real Estate,Rental Property,2024-01-01,82000,80000,Estimated value
}
}
// Goals: create directly on the background context. Dedup by name within
// the same account (case-insensitive), mirroring GoalRepository behaviour.
for importedGoal in goals {
let trimmedName = importedGoal.name.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedName.isEmpty else { continue }
guard let targetAmount = importedGoal.targetAmount else { continue }
let resolvedAccount = importedGoal.accountName.flatMap { accountsByName[$0] }
let resolvedCategory = importedGoal.categoryName.flatMap {
resolveExistingCategory(named: $0, lookup: categoryLookup)
}
if fetchGoal(
named: trimmedName,
account: resolvedAccount,
category: resolvedCategory,
in: context
) == nil {
let goal = Goal(context: context)
goal.name = trimmedName
goal.targetAmount = NSDecimalNumber(decimal: targetAmount)
goal.targetDate = importedGoal.targetDate
goal.account = resolvedAccount
goal.category = resolvedCategory
goal.isActive = importedGoal.isActive
}
}
// Journal entries: create directly on the background context. Dedup by
// monthKey; update fields when an entry already exists.
for importedEntry in journal {
let entry = fetchJournalEntry(monthKey: importedEntry.monthKey, in: context)
?? JournalEntry(context: context)
entry.monthKey = importedEntry.monthKey
if let note = importedEntry.note, !note.isEmpty {
entry.note = note
}
if let mood = importedEntry.mood, !mood.isEmpty {
entry.moodRaw = mood
}
if let rating = importedEntry.rating {
entry.rating = Int16(rating)
}
if let completionTime = importedEntry.completionTime {
entry.completionTime = completionTime
}
if let createdAt = importedEntry.createdAt {
entry.createdAt = createdAt
}
}
if context.hasChanges {
do {
try context.save()
@@ -502,12 +924,26 @@ Personal,Real Estate,Rental Property,2024-01-01,82000,80000,Estimated value
}
}
if !completionDatesByMonth.isEmpty {
for (monthKey, completionDate) in completionDatesByMonth {
// Snapshot-derived completion marking is a FALLBACK for imports that carry no
// journal (CSV, or JSON without journalEntries): it marks months that have
// snapshots as "checked in" so the Home card looks right. When the import DOES
// bring journal entries, they already carry their real completionTime and were
// created on the background context above running setCompletionDate here (on
// viewContext, which hasn't merged those yet) would create DUPLICATE
// JournalEntry rows for the same month. So skip it when journal was imported.
if journal.isEmpty, !completionDatesByMonth.isEmpty {
// MonthlyCheckInStore mutates CoreDataStack.shared.viewContext (main queue).
// applyImport runs on a BACKGROUND context, so calling setCompletionDate
// here directly was a Core Data concurrency violation the imported months'
// JournalEntry.completionTime never persisted, leaving the "next check-in"
// stuck on an old entry. Run it on the viewContext's own queue.
CoreDataStack.shared.viewContext.performAndWait {
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM"
if let monthDate = formatter.date(from: monthKey) {
MonthlyCheckInStore.setCompletionDate(completionDate, for: monthDate)
for (monthKey, completionDate) in completionDatesByMonth {
if let monthDate = formatter.date(from: monthKey) {
MonthlyCheckInStore.setCompletionDate(completionDate, for: monthDate)
}
}
}
}
@@ -586,6 +1022,42 @@ Personal,Real Estate,Rental Property,2024-01-01,82000,80000,Estimated value
return try? context.fetch(request).first
}
/// Fetch an existing goal by name (case-insensitive) within the same scope
/// (account + category).
private func fetchGoal(
named name: String,
account: Account?,
category: Category?,
in context: NSManagedObjectContext
) -> Goal? {
let request: NSFetchRequest<Goal> = Goal.fetchRequest()
var predicates: [NSPredicate] = [NSPredicate(format: "name ==[c] %@", name)]
if let account = account {
predicates.append(NSPredicate(format: "account == %@", account))
} else {
predicates.append(NSPredicate(format: "account == nil"))
}
if let category = category {
predicates.append(NSPredicate(format: "category == %@", category))
} else {
predicates.append(NSPredicate(format: "category == nil"))
}
request.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: predicates)
request.fetchLimit = 1
return try? context.fetch(request).first
}
/// Fetch an existing journal entry by monthKey.
private func fetchJournalEntry(
monthKey: String,
in context: NSManagedObjectContext
) -> JournalEntry? {
let request: NSFetchRequest<JournalEntry> = JournalEntry.fetchRequest()
request.predicate = NSPredicate(format: "monthKey == %@", monthKey)
request.fetchLimit = 1
return try? context.fetch(request).first
}
private func canonicalCategoryName(for rawName: String) -> String? {
let normalized = normalizedCategoryName(rawName)
for mapping in categoryAliasMappings {
@@ -818,9 +1290,20 @@ Personal,Real Estate,Rental Property,2024-01-01,82000,80000,Estimated value
return grouped.map { accountName, categories in
let importedCategories = categories.map { categoryName, sources in
let importedSources = sources.map { sourceName, snapshots in
ImportedSource(name: sourceName, snapshots: snapshots)
ImportedSource(
name: sourceName,
monthlyContribution: nil,
customFrequencyMonths: nil,
snapshots: snapshots
)
}
return ImportedCategory(name: categoryName, colorHex: nil, icon: nil, sources: importedSources)
return ImportedCategory(
name: categoryName,
colorHex: nil,
icon: nil,
allocationTarget: nil,
sources: importedSources
)
}
return ImportedAccount(
name: accountName,
@@ -0,0 +1,241 @@
import SwiftUI
/// On-device, privacy-friendly rule-based insights engine.
///
/// Produces a prioritized list of `PortfolioInsight`s purely from local data
/// no network, no AI calls. The `DashboardViewModel` gathers the inputs from
/// Core Data and passes them in; this keeps the rules pure and testable.
enum InsightsEngine {
/// One category's allocation snapshot for the allocation-vs-target rule.
struct CategorySlice {
let name: String
let percentageOfPortfolio: Double
/// User's target allocation percentage, if set.
let targetPercentage: Double?
/// Trailing return percentage over the analysed window, if computable.
let returnPercentage: Double?
}
/// One source's weight for the concentration-risk rule.
struct SourceSlice {
let name: String
let percentageOfPortfolio: Double
}
struct Input {
let totalValue: Decimal
let categories: [CategorySlice]
let sources: [SourceSlice]
/// Number of categories that actually hold value.
let fundedCategoryCount: Int
/// Number of sources that actually hold value.
let fundedSourceCount: Int
let yearChangePercentage: Double
let allTimeReturn: Decimal
let updateStreak: Int
let forecast: PortfolioForecast?
}
// Thresholds
private static let concentrationThreshold = 0.40 // one source > 40% of portfolio
private static let allocationDriftThreshold = 5.0 // > 5 percentage points off target
private static let milestoneNearThreshold = 0.90 // within 10% of next milestone
static func makeInsights(_ input: Input, limit: Int = 4) -> [PortfolioInsight] {
guard input.totalValue > 0 else { return [] }
var result: [PortfolioInsight] = []
result.append(contentsOf: milestoneInsight(input))
result.append(contentsOf: allocationDriftInsight(input))
result.append(contentsOf: concentrationInsight(input))
result.append(contentsOf: performerInsights(input))
result.append(contentsOf: diversificationInsight(input))
result.append(contentsOf: streakInsight(input))
result.append(contentsOf: ytdInsight(input))
result.append(contentsOf: marketGainsInsight(input))
result.append(contentsOf: forecastInsight(input))
// Prioritize: attention first, then positive, then neutral; stable within tone.
let sorted = result.enumerated().sorted { lhs, rhs in
if lhs.element.tone.priority != rhs.element.tone.priority {
return lhs.element.tone.priority > rhs.element.tone.priority
}
return lhs.offset < rhs.offset
}.map(\.element)
return Array(sorted.prefix(limit))
}
// MARK: - Rules
private static func milestoneInsight(_ input: Input) -> [PortfolioInsight] {
let milestones: [Decimal] = [1000, 2500, 5000, 10000, 25000, 50000,
100000, 250000, 500000, 1_000_000,
2_500_000, 5_000_000, 10_000_000]
guard let next = milestones.first(where: { $0 > input.totalValue }) else { return [] }
let pct = NSDecimalNumber(decimal: input.totalValue / next).doubleValue
guard pct >= milestoneNearThreshold else { return [] }
let gap = next - input.totalValue
let gapStr = CurrencyFormatter.format(gap, style: .currency, maximumFractionDigits: 0)
let msStr = CurrencyFormatter.format(next, style: .currency, maximumFractionDigits: 0)
return [PortfolioInsight(
id: "milestone",
systemImage: "flag.checkered",
title: String(localized: "insight_milestone_title"),
value: String(format: String(localized: "insight_milestone_value"), gapStr, msStr),
accentColor: .orange,
tone: .positive
)]
}
private static func allocationDriftInsight(_ input: Input) -> [PortfolioInsight] {
// Pick the category most off its target (over or under).
let drifts = input.categories.compactMap { cat -> (CategorySlice, Double)? in
guard let target = cat.targetPercentage, target > 0 else { return nil }
return (cat, cat.percentageOfPortfolio - target)
}
guard let (cat, drift) = drifts.max(by: { abs($0.1) < abs($1.1) }),
abs(drift) >= allocationDriftThreshold,
let target = cat.targetPercentage else { return [] }
let over = drift > 0
let value = String(
format: String(localized: over ? "insight_allocation_over_value" : "insight_allocation_under_value"),
cat.name,
cat.percentageOfPortfolio,
target
)
return [PortfolioInsight(
id: "allocation_\(cat.name)",
systemImage: over ? "arrow.up.forward.circle.fill" : "arrow.down.forward.circle.fill",
title: String(localized: "insight_allocation_title"),
value: value,
accentColor: .appWarning,
detail: String(localized: "insight_allocation_detail"),
tone: .attention
)]
}
private static func concentrationInsight(_ input: Input) -> [PortfolioInsight] {
guard let top = input.sources.max(by: { $0.percentageOfPortfolio < $1.percentageOfPortfolio }),
top.percentageOfPortfolio >= concentrationThreshold * 100,
input.fundedSourceCount >= 2 else { return [] }
return [PortfolioInsight(
id: "concentration",
systemImage: "exclamationmark.triangle.fill",
title: String(localized: "insight_concentration_title"),
value: String(format: String(localized: "insight_concentration_value"), top.name, top.percentageOfPortfolio),
accentColor: .appWarning,
detail: String(localized: "insight_concentration_detail"),
tone: .attention
)]
}
private static func performerInsights(_ input: Input) -> [PortfolioInsight] {
let ranked = input.categories.compactMap { cat -> (CategorySlice, Double)? in
guard let ret = cat.returnPercentage else { return nil }
return (cat, ret)
}
guard ranked.count >= 2 else { return [] }
var out: [PortfolioInsight] = []
if let best = ranked.max(by: { $0.1 < $1.1 }), best.1 > 0 {
out.append(PortfolioInsight(
id: "best_performer",
systemImage: "trophy.fill",
title: String(localized: "insight_best_performer_title"),
value: String(format: String(localized: "insight_performer_value"), best.0.name, best.1),
accentColor: .positiveGreen,
tone: .positive
))
}
if let worst = ranked.min(by: { $0.1 < $1.1 }), worst.1 < 0 {
out.append(PortfolioInsight(
id: "worst_performer",
systemImage: "chart.line.downtrend.xyaxis",
title: String(localized: "insight_worst_performer_title"),
value: String(format: String(localized: "insight_performer_value"), worst.0.name, worst.1),
accentColor: .negativeRed,
tone: .attention
))
}
return out
}
private static func diversificationInsight(_ input: Input) -> [PortfolioInsight] {
// Encourage diversification only when the portfolio is thin.
if input.fundedSourceCount == 1 {
return [PortfolioInsight(
id: "diversification",
systemImage: "square.grid.2x2",
title: String(localized: "insight_diversification_title"),
value: String(localized: "insight_diversification_single_value"),
accentColor: .appPrimary,
tone: .neutral
)]
}
if input.fundedCategoryCount >= 4 {
return [PortfolioInsight(
id: "diversification",
systemImage: "square.grid.2x2.fill",
title: String(localized: "insight_diversification_title"),
value: String(format: String(localized: "insight_diversification_good_value"), input.fundedCategoryCount),
accentColor: .positiveGreen,
tone: .positive
)]
}
return []
}
private static func streakInsight(_ input: Input) -> [PortfolioInsight] {
guard input.updateStreak >= 3 else { return [] }
return [PortfolioInsight(
id: "streak",
systemImage: "flame.fill",
title: String(localized: "insight_streak_title"),
value: String(format: String(localized: "insight_streak_value"), input.updateStreak),
accentColor: .orange,
tone: .positive
)]
}
private static func ytdInsight(_ input: Input) -> [PortfolioInsight] {
let ytd = input.yearChangePercentage
guard abs(ytd) >= 0.1 else { return [] }
let icon = ytd >= 0 ? "arrow.up.right.circle.fill" : "arrow.down.right.circle.fill"
return [PortfolioInsight(
id: "ytd",
systemImage: icon,
title: String(localized: "insight_ytd_title"),
value: String(format: "%+.1f%%", ytd),
accentColor: ytd >= 0 ? .positiveGreen : .negativeRed,
tone: ytd >= 0 ? .positive : .neutral
)]
}
private static func marketGainsInsight(_ input: Input) -> [PortfolioInsight] {
guard input.allTimeReturn > 0 else { return [] }
let gainStr = CurrencyFormatter.format(input.allTimeReturn, style: .currency, maximumFractionDigits: 0)
return [PortfolioInsight(
id: "market_gains",
systemImage: "chart.line.uptrend.xyaxis",
title: String(localized: "insight_market_gains_title"),
value: String(format: String(localized: "insight_market_gains_value"), gainStr),
accentColor: .appPrimary,
tone: .positive
)]
}
private static func forecastInsight(_ input: Input) -> [PortfolioInsight] {
guard let forecast = input.forecast, forecast.forecastValue > input.totalValue else { return [] }
return [PortfolioInsight(
id: "forecast",
systemImage: "wand.and.stars",
title: String(localized: "insight_forecast_title"),
value: "\(forecast.formattedForecastValue) · \(forecast.formattedForecastDate)",
accentColor: .purple,
tone: .neutral
)]
}
}
@@ -1,5 +1,6 @@
import Foundation
import Combine
import CoreData
import UserNotifications
import UIKit
@@ -192,6 +193,7 @@ extension Notification.Name {
static let openBatchUpdate = Notification.Name("openBatchUpdate")
static let openDashboard = Notification.Name("openDashboard")
static let openQuickUpdate = Notification.Name("openQuickUpdate")
static let openAddFirstSource = Notification.Name("openAddFirstSource")
}
// MARK: - Re-engagement Notifications
@@ -199,15 +201,28 @@ extension Notification.Name {
extension NotificationService {
/// Schedules a re-engagement notification 7 days from now.
/// Call this every time the app becomes active to reset the timer.
///
/// Instead of a fixed generic message, the body is built from the user's real
/// KPIs (goal ETA, YoY, monthly gain, milestone proximity, update streak) to
/// spark curiosity. Among the hooks that currently apply we rotate one per call
/// so the copy stays fresh instead of repeating the same line every week.
func scheduleReEngagementNotification() {
guard isAuthorized else { return }
center.removePendingNotificationRequests(withIdentifiers: ["re_engagement"])
let content = UNMutableNotificationContent()
content.title = String(localized: "reengagement_title")
content.body = String(localized: "reengagement_body")
let hooks = buildReEngagementHooks()
if let hook = hooks.isEmpty ? nil : hooks[nextReEngagementRotationIndex(count: hooks.count)] {
content.title = hook.title
content.body = hook.body
} else {
// No data yet (brand-new / empty portfolio) generic fallback.
content.title = String(localized: "reengagement_title")
content.body = String(localized: "reengagement_body")
}
content.sound = .default
content.userInfo = ["action": "openDashboard"]
let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 7 * 24 * 3600, repeats: false)
let request = UNNotificationRequest(identifier: "re_engagement", content: content, trigger: trigger)
@@ -219,6 +234,16 @@ extension NotificationService {
}
}
/// Advances and returns the rotation index over the eligible hooks so a different
/// one surfaces each time we (re)schedule.
private func nextReEngagementRotationIndex(count: Int) -> Int {
guard count > 0 else { return 0 }
let key = "reengagementRotationIndex"
let stored = UserDefaults.standard.integer(forKey: key)
UserDefaults.standard.set(stored &+ 1, forKey: key)
return ((stored % count) + count) % count
}
/// Schedules a non-repeating monthly check-in notification for the 1st of the next month
/// that doesn't have a completed check-in. Safe to call on every app activation.
func scheduleMonthlyCheckIn() {
@@ -385,6 +410,186 @@ extension NotificationService {
}
}
// MARK: - Re-engagement KPI copy
private extension NotificationService {
struct ReEngagementHook {
let title: String
let body: String
}
static let milestones: [Decimal] = [1000, 2500, 5000, 10000, 25000, 50000,
100000, 250000, 500000, 1_000_000]
static let monthYearFormatter: DateFormatter = {
let f = DateFormatter()
f.setLocalizedDateFormatFromTemplate("MMMM yyyy")
return f
}()
static let percentFormatter: NumberFormatter = {
let f = NumberFormatter()
f.numberStyle = .decimal
f.minimumFractionDigits = 0
f.maximumFractionDigits = 1
return f
}()
/// Builds the list of KPI-driven hooks that currently apply. A neutral
/// curiosity hook is always appended (when the user has data) so the message
/// never has to fall back to the bland generic copy and never states a loss.
func buildReEngagementHooks() -> [ReEngagementHook] {
let ctx = CoreDataStack.shared.viewContext
let sourceReq: NSFetchRequest<InvestmentSource> = InvestmentSource.fetchRequest()
sourceReq.predicate = NSPredicate(format: "isActive == YES")
let sources = (try? ctx.fetch(sourceReq)) ?? []
guard !sources.isEmpty else { return [] }
let summary = CalculationService.shared.calculatePortfolioSummary(from: sources, snapshots: [])
let evolution = monthlyEvolution(from: sources)
var hooks: [ReEngagementHook] = []
// 1. Goal ETA "at this pace you'll reach '<goal>' by <month year>"
if let goalHook = goalEtaHook(ctx: ctx, sources: sources) {
hooks.append(goalHook)
}
// 2. Year-over-year growth
if summary.yearChangePercentage >= 5,
let pct = Self.percentFormatter.string(from: NSNumber(value: summary.yearChangePercentage)) {
hooks.append(ReEngagementHook(
title: String(localized: "reengagement_yoy_title"),
body: String(format: String(localized: "reengagement_yoy_body"), "\(pct)%")))
}
// 3. Positive monthly change
if summary.monthChange > 0 {
let amount = CurrencyFormatter.format(summary.monthChange, style: .currency, maximumFractionDigits: 0)
hooks.append(ReEngagementHook(
title: String(localized: "reengagement_month_title"),
body: String(format: String(localized: "reengagement_month_body"), amount)))
}
// 4. Close to the next round milestone (within 10% below it)
if let msHook = milestoneProximityHook(totalValue: summary.totalValue) {
hooks.append(msHook)
}
// 5. Update streak of 3+ consecutive months
if let streak = monthlyStreak(evolution: evolution), streak >= 3 {
hooks.append(ReEngagementHook(
title: String(localized: "reengagement_streak_title"),
body: String(format: String(localized: "reengagement_streak_body"), streak)))
}
// 6. Neutral curiosity always available, never states a loss.
hooks.append(ReEngagementHook(
title: String(localized: "reengagement_neutral_title"),
body: String(localized: "reengagement_neutral_body")))
return hooks
}
/// Portfolio total at the end of each month that has any snapshot, ascending.
func monthlyEvolution(from sources: [InvestmentSource]) -> [(date: Date, value: Decimal)] {
let cal = Calendar.current
var monthStarts = Set<Date>()
for source in sources {
for snap in source.sortedSnapshotsByDateAscending {
if let m = cal.date(from: cal.dateComponents([.year, .month], from: snap.date)) {
monthStarts.insert(m)
}
}
}
return monthStarts.sorted().map { monthStart in
let monthEnd = cal.date(byAdding: DateComponents(month: 1, day: -1), to: monthStart) ?? monthStart
var total = Decimal.zero
for source in sources {
if let snap = source.sortedSnapshotsByDateAscending.last(where: { $0.date <= monthEnd }) {
total += snap.decimalValue
}
}
return (monthStart, total)
}
}
/// Projects the first active goal reachable on the current 6-month trend and
/// returns a hook naming its ETA. Mirrors GoalsViewModel.estimateCompletionDate.
func goalEtaHook(ctx: NSManagedObjectContext, sources: [InvestmentSource]) -> ReEngagementHook? {
let req: NSFetchRequest<Goal> = Goal.fetchRequest()
req.predicate = NSPredicate(format: "isActive == YES")
let goals = (try? ctx.fetch(req)) ?? []
guard !goals.isEmpty else { return nil }
let cal = Calendar.current
for goal in goals {
let goalSources = goal.relevantSources(from: sources)
let evolution = monthlyEvolution(from: goalSources)
guard evolution.count >= 3,
let first = evolution.suffix(6).first,
let last = evolution.suffix(6).last else { continue }
let monthsBetween = max(1, first.date.monthsBetween(last.date))
let delta = last.value - first.value
guard delta > 0 else { continue }
let monthlyGain = delta / Decimal(monthsBetween)
guard monthlyGain > 0 else { continue }
let remaining = goal.targetDecimal - last.value
guard remaining > 0 else { continue } // already reached skip
let monthsToGo = Int(ceil(NSDecimalNumber(decimal: remaining / monthlyGain).doubleValue))
guard monthsToGo > 0, monthsToGo <= 600,
let eta = cal.date(byAdding: .month, value: monthsToGo, to: last.date) else { continue }
let etaStr = Self.monthYearFormatter.string(from: eta)
return ReEngagementHook(
title: String(localized: "reengagement_goal_title"),
body: String(format: String(localized: "reengagement_goal_body"), goal.name, etaStr))
}
return nil
}
/// Hook when the portfolio is within 10% below the next round milestone.
func milestoneProximityHook(totalValue: Decimal) -> ReEngagementHook? {
let current = NSDecimalNumber(decimal: totalValue).doubleValue
guard current > 0 else { return nil }
for milestone in Self.milestones {
let target = NSDecimalNumber(decimal: milestone).doubleValue
if current < target {
guard current >= target * 0.9 else { return nil } // next one up is too far
let msStr = CurrencyFormatter.format(milestone, style: .currency, maximumFractionDigits: 0)
return ReEngagementHook(
title: String(localized: "reengagement_milestone_title"),
body: String(format: String(localized: "reengagement_milestone_body"), msStr))
}
}
return nil
}
/// Number of consecutive months (ending at the most recent one with data) that
/// have a snapshot. `evolution` already holds one entry per month.
func monthlyStreak(evolution: [(date: Date, value: Decimal)]) -> Int? {
guard !evolution.isEmpty else { return nil }
let cal = Calendar.current
let months = Set(evolution.map { $0.date })
guard var cursor = months.max() else { return nil }
var count = 0
while months.contains(cursor) {
count += 1
guard let prev = cal.date(byAdding: .month, value: -1, to: cursor) else { break }
cursor = prev
}
return count
}
}
// MARK: - Background Refresh
extension NotificationService {
@@ -15,18 +15,18 @@ final class ReviewPromptService {
private let minDaysBetweenPrompts = 30
private let userDefaults: UserDefaults
private let dateProvider: () -> Date
private let reviewRequestHandler: () -> Void
private let reviewRequestHandler: @MainActor () -> Void
private init() {
userDefaults = .standard
dateProvider = Date.init
reviewRequestHandler = ReviewPromptService.defaultReviewRequestHandler
reviewRequestHandler = { ReviewPromptService.defaultReviewRequestHandler() }
}
init(
userDefaults: UserDefaults,
dateProvider: @escaping () -> Date,
reviewRequestHandler: @escaping () -> Void
reviewRequestHandler: @escaping @MainActor () -> Void
) {
self.userDefaults = userDefaults
self.dateProvider = dateProvider
@@ -93,6 +93,7 @@ final class ReviewPromptService {
userDefaults.set(0, forKey: checkInCountKey)
}
@MainActor
private static func defaultReviewRequestHandler() {
guard let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene else { return }
if #available(iOS 18.0, *) {
+134 -11
View File
@@ -6,8 +6,60 @@ import LinkPresentation
class ShareService {
static let shared = ShareService()
/// Attributed App Store link for shared cards (ct= shows up in App
/// Analytics as a campaign, so referral installs become measurable).
static let snapshotShareURL = URL(string: "https://apps.apple.com/app/portfolio-journal-tracker/id6757678318?ct=snapshot_share")!
private init() {}
/// Extracts the "(±X.XX%)" part of a combined "±1.234 (±X.XX%)" string.
static func percentPart(_ s: String?) -> String? {
guard let s else { return nil }
guard let open = s.lastIndex(of: "("), let close = s.lastIndex(of: ")"), open < close else {
return s
}
return String(s[s.index(after: open)..<close])
}
struct PortfolioCardStrings {
/// The big number (an all-time/return %) the hero of the card.
let heroPercent: String?
/// Supporting absolute value (empty in percent-only mode).
let totalValue: String
let changeText: String
let yearChange: String?
let sinceInceptionChange: String?
}
/// The all-time return is always the hero (branding + shareable). With
/// `percentOnly` every absolute amount is dropped nobody posts their net
/// worth in euros; everybody posts +42%.
static func portfolioCardStrings(
totalValue: String,
changeText: String,
yearChange: String?,
sinceInceptionChange: String?,
percentOnly: Bool
) -> PortfolioCardStrings {
let hero = percentPart(sinceInceptionChange) ?? percentPart(changeText)
if percentOnly {
return PortfolioCardStrings(
heroPercent: hero ?? totalValue,
totalValue: "",
changeText: percentPart(changeText) ?? changeText,
yearChange: percentPart(yearChange),
sinceInceptionChange: percentPart(sinceInceptionChange)
)
}
return PortfolioCardStrings(
heroPercent: hero,
totalValue: totalValue,
changeText: changeText,
yearChange: yearChange,
sinceInceptionChange: sinceInceptionChange
)
}
static func buildMonthlyCheckInShareText(summary: MonthlySummary, appName: String) -> String {
"""
\(summary.periodLabel) Check-in
@@ -62,37 +114,108 @@ class ShareService {
}
}
struct MonthlySummaryShareData {
let monthLabel: String
let totalValue: String
let changeAmount: String
let changePercentage: String
let isPositive: Bool
let streak: Int
let mood: MonthlyCheckInMood?
let rating: Int?
}
@MainActor
func shareMonthlySummary(_ data: MonthlySummaryShareData) {
FirebaseService.shared.logShare(type: "monthly_summary")
let appName = Self.appDisplayName
var lines = [
"\(data.monthLabel) — Monthly Summary",
"Portfolio Value: \(data.totalValue)",
"\(data.changeAmount) (\(data.changePercentage)) since last check-in"
]
if data.streak >= 2 {
lines.append("Streak: \(data.streak) months")
}
if let mood = data.mood {
lines.append("Mood: \(mood.title)")
}
lines.append("")
lines.append("Tracked with \(appName)")
let fallbackText = lines.joined(separator: "\n")
shareCard(
cardTitle: "\(data.monthLabel) — Monthly Summary",
fallbackText: fallbackText
) {
MonthlySummaryShareView(
monthLabel: data.monthLabel,
totalValue: data.totalValue,
changeAmount: data.changeAmount,
changePercentage: data.changePercentage,
isPositive: data.isPositive,
streak: data.streak,
mood: data.mood,
rating: data.rating,
appName: appName,
qrCodeImage: GoalShareService.generateQRCode(for: GoalShareService.appStoreURL, size: 200)
)
}
}
@MainActor
func sharePortfolioValue(
totalValue: String,
changeText: String,
changeLabel: String,
yearChange: String?,
sinceInceptionChange: String?
sinceInceptionChange: String?,
sparkline: [Double] = [],
isPositive: Bool = true,
asOf: String? = nil,
theme: ShareCardTheme = .dark,
percentOnly: Bool = false,
storyFormat: Bool = false
) {
FirebaseService.shared.logShare(type: "portfolio_value")
FirebaseService.shared.logShare(type: percentOnly ? "portfolio_value_percent" : "portfolio_value")
let appName = Self.appDisplayName
let text = Self.buildPortfolioValueShareText(
let display = Self.portfolioCardStrings(
totalValue: totalValue,
changeText: changeText,
changeLabel: changeLabel,
yearChange: yearChange,
sinceInceptionChange: sinceInceptionChange,
percentOnly: percentOnly
)
// Fallback text honors the privacy choice too.
let text = Self.buildPortfolioValueShareText(
totalValue: display.totalValue,
changeText: display.changeText,
changeLabel: changeLabel,
yearChange: display.yearChange,
sinceInceptionChange: display.sinceInceptionChange,
appName: appName
)
shareCard(
cardTitle: "Portfolio Snapshot",
fallbackText: text
fallbackText: text,
shareURL: Self.snapshotShareURL
) {
PortfolioValueShareCardView(
totalValue: totalValue,
changeText: changeText,
totalValue: display.totalValue,
changeText: display.changeText,
changeLabel: changeLabel,
yearChange: yearChange,
sinceInceptionChange: sinceInceptionChange,
yearChange: display.yearChange,
sinceInceptionChange: display.sinceInceptionChange,
appName: appName,
qrCodeImage: GoalShareService.generateQRCode(for: GoalShareService.appStoreURL, size: 200)
qrCodeImage: GoalShareService.generateQRCode(for: Self.snapshotShareURL, size: 200),
sparkline: sparkline,
isPositive: isPositive,
heroPercent: display.heroPercent,
asOf: asOf,
theme: theme,
storyFormat: storyFormat
)
}
}
@@ -124,10 +247,10 @@ class ShareService {
private func shareCard<Content: View>(
cardTitle: String,
fallbackText: String,
shareURL: URL = GoalShareService.appStoreURL,
@ViewBuilder card: () -> Content
) {
guard let viewController = ShareService.topViewController() else { return }
let shareURL = GoalShareService.appStoreURL
if #available(iOS 16.0, *) {
let renderer = ImageRenderer(content: card())
@@ -0,0 +1,196 @@
import Foundation
import CoreData
import WatchConnectivity
/// Ships a `WatchPortfolioSnapshot` from the iPhone to the paired Apple Watch.
///
/// The watch app and its complications are read-only, so a single application
/// context always the latest state, coalesced by the system is enough. The
/// watch may also ask for a fresh copy (`WatchSyncMessage.requestSnapshot`) when it opens
/// while the phone is reachable.
@MainActor
final class WatchSyncService: NSObject {
static let shared = WatchSyncService()
/// Largest holdings sent to the watch. A watch screen shows a handful at
/// most, and the payload must stay small.
private let maxSources = 6
private let maxGoals = 5
private var session: WCSession? {
WCSession.isSupported() ? WCSession.default : nil
}
/// Last payload actually handed to WatchConnectivity, so repeated data
/// changes that produce an identical snapshot don't queue redundant work.
private var lastSentSnapshot: WatchPortfolioSnapshot?
private var pendingSync = false
private override init() {
super.init()
}
// MARK: - Lifecycle
func activate() {
guard let session else { return }
session.delegate = self
if session.activationState != .activated {
session.activate()
}
}
/// Coalesces the bursts of Core Data changes a single user action produces
/// (save snapshot repository refresh widget refresh) into one send.
func scheduleSync() {
guard session != nil, !pendingSync else { return }
pendingSync = true
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
self?.pendingSync = false
self?.syncNow()
}
}
func syncNow() {
guard let session, session.activationState == .activated else { return }
let snapshot = buildSnapshot()
guard snapshot != lastSentSnapshot else { return }
guard let context = snapshot.applicationContext() else { return }
do {
try session.updateApplicationContext(context)
lastSentSnapshot = snapshot
} catch {
// Not fatal: the watch keeps its cached copy and can pull a fresh
// one next time it opens with the phone reachable.
print("Watch sync failed: \(error.localizedDescription)")
}
}
// MARK: - Snapshot building
func buildSnapshot(referenceDate: Date = Date()) -> WatchPortfolioSnapshot {
let context = CoreDataStack.shared.viewContext
let sourceRequest: NSFetchRequest<InvestmentSource> = InvestmentSource.fetchRequest()
sourceRequest.predicate = NSPredicate(format: "isActive == YES")
let sources = (try? context.fetch(sourceRequest)) ?? []
let totalValue = sources.reduce(Decimal.zero) { $0 + $1.latestValue }
// Value at the close of the previous month, per source, so the change
// shown on the watch matches the dashboard's month change.
let previousMonthEnd = referenceDate.startOfMonth.adding(months: -1).endOfMonth
let previousTotal = sources.reduce(Decimal.zero) { partial, source in
let snapshot = source.sortedSnapshotsByDateAscending.last { $0.date <= previousMonthEnd }
return partial + (snapshot?.value?.decimalValue ?? .zero)
}
let monthChange = totalValue - previousTotal
let monthChangePercent: Double = previousTotal > 0
? NSDecimalNumber(decimal: monthChange / previousTotal).doubleValue
: 0
let topSources = sources
.sorted { $0.latestValue > $1.latestValue }
.prefix(maxSources)
.map { source in
WatchPortfolioSnapshot.Source(
id: source.id,
name: source.name,
categoryName: source.category?.name,
categoryColorHex: source.category?.colorHex,
value: NSDecimalNumber(decimal: source.latestValue).doubleValue
)
}
let goalRequest: NSFetchRequest<Goal> = Goal.fetchRequest()
goalRequest.predicate = NSPredicate(format: "isActive == YES")
let goals = (try? context.fetch(goalRequest)) ?? []
let watchGoals = goals
.map { goal -> WatchPortfolioSnapshot.Goal in
let current = goal.relevantSources(from: sources)
.reduce(Decimal.zero) { $0 + $1.latestValue }
return WatchPortfolioSnapshot.Goal(
id: goal.id,
name: goal.name,
categoryName: goal.category?.name,
categoryIcon: goal.category?.icon,
categoryColorHex: goal.category?.colorHex,
currentValue: NSDecimalNumber(decimal: current).doubleValue,
targetValue: NSDecimalNumber(decimal: goal.targetDecimal).doubleValue
)
}
.sorted { $0.progress > $1.progress }
.prefix(maxGoals)
let stats = MonthlyCheckInStore.stats(referenceDate: referenceDate)
let dueMonth = MonthlyCheckInStore.effectiveMonth(
for: referenceDate,
relativeTo: referenceDate,
graceDays: MonthlyCheckInStore.graceDays
)
let checkInDone = MonthlyCheckInStore.completionDate(for: dueMonth) != nil
return WatchPortfolioSnapshot(
generatedAt: referenceDate,
currencyCode: AppSettings.getOrCreate(in: context).currency,
totalValue: NSDecimalNumber(decimal: totalValue).doubleValue,
monthChange: NSDecimalNumber(decimal: monthChange).doubleValue,
monthChangePercent: monthChangePercent,
sources: Array(topSources),
goals: Array(watchGoals),
currentStreak: stats.currentStreak,
bestStreak: stats.bestStreak,
checkInDone: checkInDone,
checkInMonthLabel: dueMonth.monthYearString
)
}
}
// MARK: - WCSessionDelegate
extension WatchSyncService: WCSessionDelegate {
nonisolated func session(
_ session: WCSession,
activationDidCompleteWith activationState: WCSessionActivationState,
error: Error?
) {
guard activationState == .activated else { return }
Task { @MainActor in
self.syncNow()
}
}
nonisolated func sessionDidBecomeInactive(_ session: WCSession) {}
/// The user switched to a different watch: reactivate so the new one gets
/// its own context.
nonisolated func sessionDidDeactivate(_ session: WCSession) {
session.activate()
}
nonisolated func sessionWatchStateDidChange(_ session: WCSession) {
Task { @MainActor in
self.lastSentSnapshot = nil
self.syncNow()
}
}
nonisolated func session(
_ session: WCSession,
didReceiveMessage message: [String: Any],
replyHandler: @escaping ([String: Any]) -> Void
) {
guard message[WatchSyncMessage.requestSnapshot] != nil else {
replyHandler([:])
return
}
Task { @MainActor in
let snapshot = self.buildSnapshot()
self.lastSentSnapshot = snapshot
replyHandler(snapshot.applicationContext() ?? [:])
}
}
}
@@ -1,6 +1,36 @@
import Foundation
import LocalAuthentication
/// Biometric hardware available on this device. Never assume Face ID: iPad and
/// iPhone Duo can ship with Touch ID, Vision Pro with Optic ID.
enum BiometryKind {
case faceID
case touchID
case opticID
case none
/// Marketing name shown in UI ("Face ID", "Touch ID", "Optic ID"). Falls back
/// to a generic label when no biometry is available (e.g. not enrolled).
var displayName: String {
switch self {
case .faceID: return "Face ID"
case .touchID: return "Touch ID"
case .opticID: return "Optic ID"
case .none: return String(localized: "biometric_generic_name")
}
}
/// SF Symbol matching the hardware.
var systemImage: String {
switch self {
case .faceID: return "faceid"
case .touchID: return "touchid"
case .opticID: return "opticid"
case .none: return "lock.shield"
}
}
}
enum AppLockService {
static func canUseBiometrics() -> Bool {
let context = LAContext()
@@ -8,6 +38,24 @@ enum AppLockService {
return context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error)
}
/// The biometry type reported by the system. `biometryType` is only populated
/// after `canEvaluatePolicy` runs, and stays set even when not enrolled.
static var biometryKind: BiometryKind {
let context = LAContext()
var error: NSError?
_ = context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error)
switch context.biometryType {
case .faceID: return .faceID
case .touchID: return .touchID
case .opticID: return .opticID
case .none: return .none
@unknown default: return .none
}
}
/// Localized biometry name for user-facing text.
static var biometryName: String { biometryKind.displayName }
static func authenticate(reason: String, completion: @escaping (Bool) -> Void) {
let context = LAContext()
context.evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, localizedReason: reason) { success, _ in
@@ -0,0 +1,66 @@
import SwiftUI
import Combine
/// Central store for the "Hide balances" privacy feature.
///
/// When `balancesHidden` is true, every monetary amount in the app renders
/// redacted (a blurred bullet mask) instead of the real value. This lets the
/// user open the app in public or take screenshots without exposing net worth.
///
/// If `requireBiometricToReveal` is enabled, un-hiding balances must first pass
/// Face ID / biometric auth (reusing `AppLockService`). On failure, balances
/// stay hidden.
///
/// State is persisted in `UserDefaults` so it survives relaunches, and mirrored
/// into `@Published` properties so SwiftUI views observing this object update
/// instantly when the user taps the quick toggle.
@MainActor
final class BalancePrivacyManager: ObservableObject {
static let shared = BalancePrivacyManager()
private enum Keys {
static let balancesHidden = "balancesHidden"
static let requireBiometricToReveal = "requireBiometricToReveal"
}
/// When true, all monetary amounts render redacted.
@Published var balancesHidden: Bool {
didSet { UserDefaults.standard.set(balancesHidden, forKey: Keys.balancesHidden) }
}
/// When true, revealing hidden balances requires biometric auth.
@Published var requireBiometricToReveal: Bool {
didSet { UserDefaults.standard.set(requireBiometricToReveal, forKey: Keys.requireBiometricToReveal) }
}
init() {
self.balancesHidden = UserDefaults.standard.bool(forKey: Keys.balancesHidden)
self.requireBiometricToReveal = UserDefaults.standard.bool(forKey: Keys.requireBiometricToReveal)
}
/// Toggle balance visibility. Hiding is always immediate. Revealing, when
/// `requireBiometricToReveal` is on and biometrics are available, runs a
/// Face ID prompt first and only reveals on success.
func toggleHidden() {
if balancesHidden {
reveal()
} else {
balancesHidden = true
}
}
/// Attempt to reveal balances, honoring the biometric requirement.
func reveal() {
guard balancesHidden else { return }
if requireBiometricToReveal && AppLockService.canUseBiometrics() {
AppLockService.authenticate(reason: "Reveal your balances") { [weak self] success in
if success {
self?.balancesHidden = false
}
// On failure, stay hidden do nothing.
}
} else {
balancesHidden = false
}
}
}
@@ -0,0 +1,65 @@
import Foundation
/// Fills gaps between snapshots for chart display only it never creates or
/// mutates Core Data objects. A source that has a snapshot in February and the
/// next in June leaves March/April/May empty; without filling, per-source and
/// category lines dip or break. This computes a value for every month in the
/// grid so the graphs read continuously.
///
/// - Internal gaps (between two known snapshots) are linearly interpolated when
/// `interpolate` is true, or carried forward (stepped) when false.
/// - After a source's last snapshot the value is carried forward (there is no
/// future point to interpolate towards) matches the portfolio total.
/// - Before a source's first snapshot the value is `nil` (the source did not
/// exist yet, so it must not contribute 0 and drag the total down).
enum ChartGapFill {
/// The user-facing toggle (Settings). Defaults to ON so charts look right out
/// of the box; reading via `object(forKey:)` so an unset key means enabled.
static let defaultsKey = "smoothChartGaps"
static var isEnabled: Bool {
(UserDefaults.standard.object(forKey: defaultsKey) as? Bool) ?? true
}
/// For a single source: `known` maps a month index (position in the ordered
/// month grid) to that month's value. Returns a value per month index, with
/// internal gaps filled and a trailing carry-forward. `nil` means "absent".
static func denseValues(
known: [Int: Decimal],
monthCount: Int,
interpolate: Bool
) -> [Decimal?] {
var result = [Decimal?](repeating: nil, count: monthCount)
let indices = known.keys.sorted()
guard let first = indices.first, let last = indices.last else { return result }
for i in indices { result[i] = known[i] }
if interpolate {
// Linear interpolation across each internal gap.
for k in 0..<(indices.count - 1) {
let a = indices[k]
let b = indices[k + 1]
guard b - a > 1, let va = known[a], let vb = known[b] else { continue }
for i in (a + 1)..<b {
let frac = Decimal(i - a) / Decimal(b - a)
result[i] = va + (vb - va) * frac
}
}
} else {
// Stepped carry-forward within internal gaps.
var lastVal = known[first] ?? .zero
for i in first...last {
if let v = result[i] { lastVal = v } else { result[i] = lastVal }
}
}
// Carry the last known value forward past the final snapshot.
if let lastVal = known[last], last + 1 < monthCount {
for i in (last + 1)..<monthCount { result[i] = lastVal }
}
return result
}
}
@@ -74,7 +74,7 @@ enum DashboardLayoutStore {
var merged: [DashboardSectionConfig] = []
for config in decoded {
if let section = DashboardSection(rawValue: config.id) {
if DashboardSection(rawValue: config.id) != nil {
merged.append(config)
} else {
continue
@@ -103,12 +103,23 @@ enum DashboardLayoutStore {
UserDefaults.standard.removeObject(forKey: storageKey)
}
/// Calm 2.0 defaults: three zones state (total value), action (check-in),
/// context. Redundant cards start hidden: pending updates and streaks now
/// live inside the check-in card, period returns inside the hero. All remain
/// available via Customize.
private static let hiddenByDefault: Set<DashboardSection> = [
.momentumStreaks, .pendingUpdates, .periodReturns, .monthlySummary
]
private static func defaultConfigs() -> [DashboardSectionConfig] {
DashboardSection.allCases.map { section in
DashboardSectionConfig(
id: section.id,
isVisible: true,
isCollapsed: false
isVisible: !hiddenByDefault.contains(section),
isCollapsed: false,
// The check-in row is slim full width on the iPad grid so it
// reads as the "action" zone instead of leaving a gap cell.
columnSpan: section == .monthlyCheckIn ? 2 : 1
)
}
}
@@ -167,6 +167,7 @@ extension Color {
// MARK: - Gradient Extensions
@MainActor
extension LinearGradient {
static let appPrimaryGradient = LinearGradient(
colors: [Color.appPrimary, Color.appPrimary.lighter()],
@@ -0,0 +1,167 @@
import Foundation
/// Pure, dependency-free projection helper for goals.
///
/// Given the current portfolio value relevant to a goal (its account's sources,
/// or the whole portfolio when the goal has no account), a monthly growth rate
/// derived from evolution history, and the total monthly contributions of the
/// relevant sources, it projects value forward month by month and estimates the
/// month the goal amount is reached plus an on-track status vs the target date.
///
/// Kept free of Core Data / SwiftUI so it is trivially unit-testable and reusable.
enum GoalProjection {
/// On-track status of a goal relative to its target date.
enum Status: Equatable {
/// Reached before the target date.
case aheadOfSchedule
/// Reached at (or within a small window of) the target date.
case onTrack
/// Reached after the target date.
case behind
/// Never reached at the current trajectory (no growth, no contributions).
case unreachable
/// No target date set only a projected date is meaningful.
case noTargetDate
}
struct Result {
/// Projected month the goal amount is reached, or nil if unreachable.
let projectedDate: Date?
/// Number of months from `asOf` until the goal is reached, or nil if unreachable.
let monthsToReach: Int?
/// On-track status vs the target date.
let status: Status
/// Monthly growth rate used (fraction, e.g. 0.008 == +0.8%/mo). Zero if flat/negative.
let monthlyGrowthRate: Double
/// A short projection curve (value per month) for a sparkline, starting at `currentValue`.
/// Empty when the goal is already reached or unreachable.
let sparkline: [Decimal]
var isReachable: Bool { projectedDate != nil }
}
/// Hard cap on how many months we project before declaring a goal unreachable
/// at the current pace (50 years).
private static let maxProjectionMonths = 600
/// Derives a robust monthly growth rate (as a fraction) from evolution history.
///
/// Uses the trailing window (default 6 points) and computes a monthly CAGR from
/// first to last value. Contributions inflate raw value growth, so this rate is a
/// blended "how fast the pot grows" figure rather than pure market return which
/// is the honest thing to extrapolate for an ETA. Returns 0 for flat/negative
/// trends or insufficient data.
static func monthlyGrowthRate(
from evolution: [(date: Date, value: Decimal)],
window: Int = 6
) -> Double {
guard evolution.count >= 3 else { return 0 }
let recent = Array(evolution.suffix(max(2, window)))
guard let first = recent.first, let last = recent.last else { return 0 }
let firstValue = NSDecimalNumber(decimal: first.value).doubleValue
let lastValue = NSDecimalNumber(decimal: last.value).doubleValue
guard firstValue > 0, lastValue > firstValue else { return 0 }
let months = max(1, first.date.monthsBetween(last.date))
// Monthly CAGR: (last/first)^(1/months) - 1
let ratio = lastValue / firstValue
let rate = pow(ratio, 1.0 / Double(months)) - 1.0
guard rate.isFinite, rate > 0 else { return 0 }
// Clamp to a sane band to avoid absurd extrapolations from noisy data.
return min(rate, 0.5)
}
/// Projects when `targetAmount` is reached.
///
/// - Parameters:
/// - currentValue: current portfolio value relevant to the goal.
/// - targetAmount: the goal amount.
/// - monthlyGrowthRate: fraction per month (from `monthlyGrowthRate(from:)`).
/// - monthlyContribution: total monthly contributions of relevant sources.
/// - targetDate: optional deadline to compare against.
/// - asOf: base date to project from (defaults to now).
static func project(
currentValue: Decimal,
targetAmount: Decimal,
monthlyGrowthRate: Double,
monthlyContribution: Decimal,
targetDate: Date?,
asOf: Date = Date()
) -> Result {
// Already reached.
if currentValue >= targetAmount, targetAmount > 0 {
return Result(
projectedDate: asOf,
monthsToReach: 0,
status: targetDate == nil ? .noTargetDate : .onTrack,
monthlyGrowthRate: max(0, monthlyGrowthRate),
sparkline: []
)
}
let rate = max(0, monthlyGrowthRate)
let contribution = NSDecimalNumber(decimal: max(0, monthlyContribution)).doubleValue
var value = NSDecimalNumber(decimal: currentValue).doubleValue
let target = NSDecimalNumber(decimal: targetAmount).doubleValue
// No trajectory at all unreachable.
if rate <= 0 && contribution <= 0 {
return Result(
projectedDate: nil,
monthsToReach: nil,
status: .unreachable,
monthlyGrowthRate: 0,
sparkline: []
)
}
var sparkline: [Decimal] = [Decimal(value)]
var months = 0
while value < target && months < maxProjectionMonths {
value = value * (1 + rate) + contribution
months += 1
// Sample the sparkline sparsely (at most ~12 points).
if months % max(1, months / 12) == 0 || value >= target {
sparkline.append(Decimal(value))
}
}
guard value >= target else {
return Result(
projectedDate: nil,
monthsToReach: nil,
status: .unreachable,
monthlyGrowthRate: rate,
sparkline: []
)
}
let projectedDate = Calendar.current.date(byAdding: .month, value: months, to: asOf)
let status: Status
if let targetDate {
let projectedDay = (projectedDate ?? asOf).startOfDay
let targetDay = targetDate.startOfDay
let deltaDays = targetDay.daysBetween(projectedDay)
if deltaDays <= -15 {
status = .aheadOfSchedule
} else if deltaDays >= 15 {
status = .behind
} else {
status = .onTrack
}
} else {
status = .noTargetDate
}
return Result(
projectedDate: projectedDate,
monthsToReach: months,
status: status,
monthlyGrowthRate: rate,
sparkline: sparkline
)
}
}
@@ -0,0 +1,95 @@
import Foundation
import UIKit
import Vision
// MARK: - On-device amount OCR (app target)
//
// Recognizes the monetary amounts in a shared/picked image entirely on-device
// (Vision), ranked by visual prominence the balance is almost always the
// biggest number on a bank screen. Mirrors the Share Extension's scanner so the
// two entry points behave identically.
struct ScannedAmount: Identifiable, Equatable {
let id = UUID()
let display: String
let value: Decimal
let prominence: CGFloat
static func == (lhs: Self, rhs: Self) -> Bool { lhs.id == rhs.id }
}
enum ImageAmountScanner {
static let maxCandidates = 6
static func scan(_ image: UIImage, completion: @escaping ([ScannedAmount]) -> Void) {
guard let cgImage = image.cgImage else { completion([]); return }
let request = VNRecognizeTextRequest { request, _ in
let obs = (request.results as? [VNRecognizedTextObservation]) ?? []
completion(candidates(from: obs))
}
request.recognitionLevel = .accurate
request.usesLanguageCorrection = false
DispatchQueue.global(qos: .userInitiated).async {
let handler = VNImageRequestHandler(cgImage: cgImage, orientation: cgOrientation(from: image.imageOrientation))
try? handler.perform([request])
}
}
private static let numberPattern: NSRegularExpression = {
let pattern = "\\d{1,3}(?:[.,\u{00A0} ]\\d{3})+(?:[.,]\\d{1,2})?|\\d+[.,]\\d{1,2}|\\d+"
return try! NSRegularExpression(pattern: pattern)
}()
private static func candidates(from observations: [VNRecognizedTextObservation]) -> [ScannedAmount] {
var found: [ScannedAmount] = []
for observation in observations {
guard let recognized = observation.topCandidates(1).first else { continue }
let line = recognized.string
let range = NSRange(line.startIndex..., in: line)
for match in numberPattern.matches(in: line, range: range) {
guard let r = Range(match.range, in: line) else { continue }
let display = String(line[r])
guard !isPercentage(display, in: line, at: r),
!looksLikeYear(display),
let value = CurrencyFormatter.parseUserInput(display), value > 0 else { continue }
found.append(ScannedAmount(
display: display.replacingOccurrences(of: "\u{00A0}", with: " "),
value: value,
prominence: observation.boundingBox.height))
}
}
var byValue: [Decimal: ScannedAmount] = [:]
for c in found {
if let e = byValue[c.value], e.prominence >= c.prominence { continue }
byValue[c.value] = c
}
return byValue.values.sorted { $0.prominence > $1.prominence }.prefix(maxCandidates).map { $0 }
}
private static func isPercentage(_ display: String, in line: String, at range: Range<String.Index>) -> Bool {
let after = line[range.upperBound...].drop(while: { $0 == " " || $0 == "\u{00A0}" })
if after.first == "%" { return true }
let before = line[..<range.lowerBound].reversed().drop(while: { $0 == " " || $0 == "\u{00A0}" })
return before.first == "%"
}
private static func looksLikeYear(_ display: String) -> Bool {
guard display.count == 4, display.allSatisfy(\.isNumber), let v = Int(display) else { return false }
return (1990...2099).contains(v)
}
private static func cgOrientation(from o: UIImage.Orientation) -> CGImagePropertyOrientation {
switch o {
case .up: return .up
case .down: return .down
case .left: return .left
case .right: return .right
case .upMirrored: return .upMirrored
case .downMirrored: return .downMirrored
case .leftMirrored: return .leftMirrored
case .rightMirrored: return .rightMirrored
@unknown default: return .up
}
}
}
@@ -66,7 +66,7 @@ enum MonthlyCheckInStore {
}
static func entry(for date: Date) -> MonthlyCheckInEntry? {
fetchEntry(for: monthKey(for: date)).map(makeCheckInEntry)
fetchEntry(for: monthKey(for: date)).map { makeCheckInEntry($0) }
}
static func allEntries() -> [(date: Date, entry: MonthlyCheckInEntry)] {
@@ -183,8 +183,17 @@ enum MonthlyCheckInStore {
let totalCheckIns = completions.count
let onTimeCount = onTimeMonths.count
// The current month's check-in is still within its deadline (end of the
// month), so having it pending must NOT read as a broken streak that
// made the card show 0x for most of every month. Start counting at the
// current month when it is already done, otherwise at the previous one.
// Any earlier month that is missing has passed its deadline and does
// break the streak, so the walk backwards stays honest.
var currentStreak = 0
var cursor = referenceDate.startOfMonth
if !onTimeMonths.contains(cursor) {
cursor = cursor.adding(months: -1).startOfMonth
}
while onTimeMonths.contains(cursor) {
currentStreak += 1
cursor = cursor.adding(months: -1).startOfMonth
@@ -0,0 +1,38 @@
import Foundation
import CoreData
/// Decides when to ask for an App Store rating: after a "positive moment"
/// (a saved check-in), only once the habit is established (2 distinct months
/// of snapshots) and at most once per app version. Apple additionally caps
/// the system prompt at 3 displays per year, so over-calling is safe.
///
/// Call sites own the actual prompt via SwiftUI's `\.requestReview` this
/// service only answers "should we ask now?" and records that we did.
@MainActor
enum ReviewRequestService {
private static let lastVersionKey = "reviewRequest.lastVersion"
/// Returns true at most once per app version, and only when the user has
/// snapshots in 2+ distinct months (they've seen the app deliver value).
/// Marks the version as asked when returning true.
static func shouldRequestAfterCheckIn() -> Bool {
guard distinctSnapshotMonths() >= 2 else { return false }
let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "unknown"
let defaults = UserDefaults.standard
guard defaults.string(forKey: lastVersionKey) != version else { return false }
defaults.set(version, forKey: lastVersionKey)
return true
}
private static func distinctSnapshotMonths() -> Int {
let request = NSFetchRequest<Snapshot>(entityName: "Snapshot")
guard let snapshots = try? CoreDataStack.shared.viewContext.fetch(request) else { return 0 }
let calendar = Calendar.current
let months = Set(snapshots.map { snapshot in
calendar.dateComponents([.year, .month], from: snapshot.date)
})
return months.count
}
}
@@ -0,0 +1,86 @@
import Foundation
import CoreData
/// Detects INTERNAL gaps in a source's snapshot history: two consecutive
/// snapshots more than one calendar month apart, i.e. one or more calendar
/// months between them have no data at all.
///
/// This complements `ChartGapFill`, which only smooths the *display* by
/// interpolating/carrying values across those empty months. The chart looks
/// continuous, but real data is missing this surfaces that so the user can
/// fill it in. Read-only: it never mutates Core Data.
enum SnapshotGapDetector {
/// A single internal gap for one source.
struct Gap: Identifiable, Equatable {
let sourceId: UUID
let sourceName: String
/// The month of the snapshot before the gap (start of that month).
let fromMonth: Date
/// The month of the snapshot after the gap (start of that month).
let toMonth: Date
/// Number of calendar months with no data between `fromMonth` and `toMonth`.
let missingMonths: Int
/// The individual missing months (start-of-month dates), oldest first.
let missingMonthDates: [Date]
var id: String { "\(sourceId.uuidString)-\(fromMonth.timeIntervalSince1970)" }
}
/// Computes all internal gaps across the provided sources' snapshots.
/// Only months strictly between two known snapshots count a source's
/// trailing "not updated in a while" is handled elsewhere (pending updates).
static func detectGaps(sources: [InvestmentSource], snapshots: [Snapshot]) -> [Gap] {
let snapshotsBySource = Dictionary(grouping: snapshots) { $0.source?.id }
var gaps: [Gap] = []
for source in sources {
let sourceId = source.id
guard let sourceSnapshots = snapshotsBySource[sourceId], sourceSnapshots.count >= 2 else {
continue
}
// One value per calendar month (keep the latest in each), sorted ascending.
var monthStarts: [Date] = sourceSnapshots
.map { $0.date.startOfMonth }
monthStarts = Array(Set(monthStarts)).sorted()
guard monthStarts.count >= 2 else { continue }
for i in 0..<(monthStarts.count - 1) {
let from = monthStarts[i]
let to = monthStarts[i + 1]
let monthsApart = from.monthsBetween(to)
guard monthsApart > 1 else { continue }
var missingDates: [Date] = []
var cursor = from.adding(months: 1).startOfMonth
while cursor < to {
missingDates.append(cursor)
cursor = cursor.adding(months: 1).startOfMonth
}
guard !missingDates.isEmpty else { continue }
gaps.append(Gap(
sourceId: sourceId,
sourceName: source.name,
fromMonth: from,
toMonth: to,
missingMonths: missingDates.count,
missingMonthDates: missingDates
))
}
}
// Most recent gaps first (by the month after the gap), then by source name.
return gaps.sorted {
if $0.toMonth != $1.toMonth { return $0.toMonth > $1.toMonth }
return $0.sourceName < $1.sourceName
}
}
/// Total count of missing months across all gaps.
static func totalMissingMonths(_ gaps: [Gap]) -> Int {
gaps.reduce(0) { $0 + $1.missingMonths }
}
}
+32
View File
@@ -0,0 +1,32 @@
import Foundation
import CryptoKit
/// Deterministic UUIDs (RFC 4122 v5) for records that every device/install must
/// create identically the default Account and the default Categories.
///
/// Why: `awakeFromInsert()` assigns a random UUID, so each device seeded its own
/// "Default" account + default categories with *different* UUIDs before the first
/// CloudKit import. CloudKit keys on the UUID (record name), so those identical-in-
/// meaning records piled up as distinct records (8 accounts, 23 categories in the
/// wild). Giving the defaults a STABLE UUID derived from a fixed namespace + name
/// means every device produces the SAME record name CloudKit natively merges
/// them into one. No dedup, no deletes, no cascade the root-cause fix.
enum StableID {
/// Fixed app namespace (generated once, constant forever never change it).
static let namespace = UUID(uuidString: "F3A1C2D4-5E6F-4A7B-8C9D-0E1F2A3B4C5D")!
/// RFC 4122 v5 UUID: SHA-1(namespace bytes name) with version/variant bits set.
static func v5(_ name: String) -> UUID {
var hasher = Insecure.SHA1()
withUnsafeBytes(of: namespace.uuid) { hasher.update(bufferPointer: $0) }
hasher.update(data: Data(name.utf8))
let digest = Array(hasher.finalize()) // 20 bytes; take first 16
var b = Array(digest.prefix(16))
b[6] = (b[6] & 0x0F) | 0x50 // version 5
b[8] = (b[8] & 0x3F) | 0x80 // RFC 4122 variant
return UUID(uuid: (b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7],
b[8], b[9], b[10], b[11], b[12], b[13], b[14], b[15]))
}
}
+121 -40
View File
@@ -94,6 +94,12 @@ class ChartsViewModel: ObservableObject {
@Published var selectedSource: InvestmentSource?
@Published var selectedSourceIds: Set<UUID> = []
@Published var selectedTimeRange: TimeRange = .year
/// Global custom data window (range brush): when set, it REPLACES the
/// preset time range as the data filter for EVERY chart. Month-granular.
@Published var customRange: ClosedRange<Date>?
/// Full-history monthly totals for the current source/account universe
/// the sparkline behind the range brush. Rebuilt with the snapshot cache.
@Published var fullHistorySeries: [(date: Date, value: Decimal)] = []
@Published var selectedAccount: Account?
@Published var showAllAccounts = true
@Published var selectedBreakdown: BreakdownMode = .category
@@ -328,7 +334,9 @@ class ChartsViewModel: ObservableObject {
snapshots = self.freemiumValidator.filterSnapshots(snapshots)
}
var filtered = snapshots
if let cutoff = self.selectedTimeRange.startDate() {
if let custom = self.customRange {
filtered = snapshots.filter { custom.contains($0.date) }
} else if let cutoff = self.selectedTimeRange.startDate() {
filtered = snapshots.filter { $0.date >= cutoff }
}
self.calculateComparisonData(sources: allSources, allSnapshots: filtered)
@@ -354,6 +362,28 @@ class ChartsViewModel: ObservableObject {
}
.store(in: &cancellables)
// Global custom range (brush): refilter the selected chart's data.
// Calls updateChartData directly (like performancePeriodMonths) because
// the main pipeline's skip-check doesn't track customRange.
$customRange
.dropFirst()
.debounce(for: .milliseconds(150), scheduler: DispatchQueue.main)
.sink { [weak self] _ in
guard let self else { return }
self.updateChartData(chartType: self.selectedChartType, category: self.selectedCategory, timeRange: self.selectedTimeRange)
}
.store(in: &cancellables)
// Picking a preset range clears the custom brush window.
$selectedTimeRange
.removeDuplicates()
.dropFirst()
.sink { [weak self] _ in
guard let self, self.customRange != nil else { return }
self.customRange = nil
}
.store(in: &cancellables)
// Period comparison: react to date picker changes
Publishers.CombineLatest4($periodAStart, $periodAEnd, $periodBStart, $periodBEnd)
.debounce(for: .milliseconds(300), scheduler: DispatchQueue.main)
@@ -489,9 +519,13 @@ class ChartsViewModel: ObservableObject {
hiddenHistoryMonths = freemiumValidator.hiddenHistoryMonths(in: snapshots)
snapshots = freemiumValidator.filterSnapshots(snapshots)
cachedSnapshots = snapshots
fullHistorySeries = monthlySeries(from: snapshots)
}
if let cutoffDate = timeRange.startDate() {
// The brush's custom window replaces the preset range as data filter.
if let custom = customRange {
snapshots = snapshots.filter { custom.contains($0.date) }
} else if let cutoffDate = timeRange.startDate() {
snapshots = snapshots.filter { $0.date >= cutoffDate }
}
@@ -512,6 +546,8 @@ class ChartsViewModel: ObservableObject {
selectedCategory: selectedCategory
)
calculateCategoryEvolutionData(from: completedSnapshots, categories: categoriesForChart)
// Also needed by the Evolution "vs Contributions" overlay (premium).
calculateContributionsData(from: completedSnapshots)
case .allocation:
calculateAllocationData(for: sources, breakdown: selectedBreakdown)
calculateAllocationEvolutionData(from: completedSnapshots)
@@ -564,7 +600,9 @@ class ChartsViewModel: ObservableObject {
allSnapshots = freemiumValidator.filterSnapshots(allSnapshots)
}
var filteredForComparison = allSnapshots
if let cutoff = timeRange.startDate() {
if let custom = customRange {
filteredForComparison = allSnapshots.filter { custom.contains($0.date) }
} else if let cutoff = timeRange.startDate() {
filteredForComparison = allSnapshots.filter { $0.date >= cutoff }
}
calculateComparisonData(sources: allSources, allSnapshots: filteredForComparison)
@@ -734,36 +772,65 @@ class ChartsViewModel: ObservableObject {
// MARK: - Chart Calculations
/// Sets the custom window from two month-granular dates (inclusive months).
func setCustomRange(fromMonth: Date, toMonth: Date) {
let cal = Calendar.current
let start = cal.date(from: cal.dateComponents([.year, .month], from: fromMonth)) ?? fromMonth
let endMonthStart = cal.date(from: cal.dateComponents([.year, .month], from: toMonth)) ?? toMonth
let end = cal.date(byAdding: DateComponents(month: 1, second: -1), to: endMonthStart) ?? toMonth
customRange = start...max(start, end)
}
private func calculateEvolutionData(from snapshots: [Snapshot]) {
let sortedSnapshots = snapshots.sorted { $0.date < $1.date }
let groupedByMonth = Dictionary(grouping: sortedSnapshots) { snapshot -> DateComponents in
chartMonth(for: snapshot.date)
evolutionData = downsampleSeries(monthlySeries(from: snapshots), maxPoints: maxChartPoints)
}
/// Pure monthly aggregation (grid + gap fill + per-month totals) shared by
/// the evolution chart and the range-brush sparkline.
private func monthlySeries(from snapshots: [Snapshot]) -> [(date: Date, value: Decimal)] {
// Ordered month grid + each month's index.
let sortedMonthKeys = Set(snapshots.map { chartMonth(for: $0.date) }).sorted {
(Calendar.current.date(from: $0) ?? .distantPast) < (Calendar.current.date(from: $1) ?? .distantPast)
}
let monthCount = sortedMonthKeys.count
guard monthCount > 0 else { return [] }
var indexByKey: [DateComponents: Int] = [:]
for (i, key) in sortedMonthKeys.enumerated() { indexByKey[key] = i }
// Per source: latest value at each month index.
var knownBySource: [UUID: [Int: (value: Decimal, date: Date)]] = [:]
for snapshot in snapshots {
guard let sourceId = snapshot.source?.id,
let idx = indexByKey[chartMonth(for: snapshot.date)] else { continue }
let existing = knownBySource[sourceId]?[idx]
if existing == nil || snapshot.date > existing!.date {
knownBySource[sourceId, default: [:]][idx] = (snapshot.decimalValue, snapshot.date)
}
}
// Fill each source's gaps (interpolated when enabled, else stepped
// carry-forward), then sum per month. Display only see ChartGapFill.
let interpolate = ChartGapFill.isEnabled
var monthTotals = [Decimal](repeating: 0, count: monthCount)
for (_, known) in knownBySource {
let dense = ChartGapFill.denseValues(
known: known.mapValues { $0.value },
monthCount: monthCount,
interpolate: interpolate
)
for i in 0..<monthCount where dense[i] != nil {
monthTotals[i] += dense[i]!
}
}
var series: [(date: Date, value: Decimal)] = []
series.reserveCapacity(groupedByMonth.count)
for (key, monthSnapshots) in groupedByMonth {
var latestBySource: [UUID: Snapshot] = [:]
for snapshot in monthSnapshots {
guard let sourceId = snapshot.source?.id else { continue }
if let existing = latestBySource[sourceId] {
if snapshot.date > existing.date {
latestBySource[sourceId] = snapshot
}
} else {
latestBySource[sourceId] = snapshot
}
}
let total = latestBySource.values.reduce(Decimal.zero) { $0 + $1.decimalValue }
let date = Calendar.current.date(from: key) ?? Date()
series.append((date: date, value: total))
series.reserveCapacity(monthCount)
for i in 0..<monthCount {
let date = Calendar.current.date(from: sortedMonthKeys[i]) ?? Date()
series.append((date: date, value: monthTotals[i]))
}
series.sort { $0.date < $1.date }
evolutionData = downsampleSeries(series, maxPoints: maxChartPoints)
return series
}
private func calculateCategoryEvolutionData(from snapshots: [Snapshot], categories: [Category]) {
@@ -852,12 +919,16 @@ class ChartsViewModel: ObservableObject {
var globalCategoryTotals: [String: (total: Decimal, color: String)] = [:]
var monthlyData: [(date: Date, categories: [String: (value: Decimal, color: String)])] = []
// Forward-filled across months: a source keeps its last known value in months
// where it wasn't updated, so category allocations don't distort when a single
// source is missing that month.
var sourceLatest: [UUID: Snapshot] = [:]
for monthKey in sortedMonths {
guard let monthSnapshots = groupedByMonth[monthKey],
let monthDate = Calendar.current.date(from: monthKey) else { continue }
var categoryTotals: [String: (value: Decimal, color: String)] = [:]
var sourceLatest: [UUID: Snapshot] = [:]
for snapshot in monthSnapshots {
guard let sourceId = snapshot.source?.id else { continue }
@@ -1541,10 +1612,15 @@ class ChartsViewModel: ObservableObject {
let grouped = Dictionary(grouping: periodSnapshots) { snap -> DateComponents in
Calendar.current.dateComponents([.year, .month], from: snap.date)
}
let sortedKeys = grouped.keys.sorted {
(Calendar.current.date(from: $0) ?? .distantPast) < (Calendar.current.date(from: $1) ?? .distantPast)
}
var monthlyTotalsArr: [(date: Date, value: Double)] = []
for (key, snaps) in grouped {
var latestBySource: [UUID: Snapshot] = [:]
for snap in snaps {
// Forward-fill per source across the period so an un-updated source keeps
// its last value instead of dropping the month's total.
var latestBySource: [UUID: Snapshot] = [:]
for key in sortedKeys {
for snap in grouped[key] ?? [] {
guard let sourceId = snap.source?.id else { continue }
if let existing = latestBySource[sourceId] {
if snap.date > existing.date { latestBySource[sourceId] = snap }
@@ -1556,7 +1632,6 @@ class ChartsViewModel: ObservableObject {
let date = Calendar.current.date(from: key) ?? Date()
monthlyTotalsArr.append((date: date, value: total))
}
monthlyTotalsArr.sort { $0.date < $1.date }
guard let firstValue = monthlyTotalsArr.first?.value, firstValue > 0 else { return nil }
let points = monthlyTotalsArr.enumerated().map { index, item in
@@ -1669,17 +1744,23 @@ class ChartsViewModel: ObservableObject {
}
private func monthlyTotalsByMonthYear(from snapshots: [Snapshot]) -> [(date: Date, totalValue: Decimal)] {
let sortedSnapshots = snapshots.sorted { $0.date < $1.date }
let groupedByMonth = Dictionary(grouping: sortedSnapshots) { snapshot -> DateComponents in
let groupedByMonth = Dictionary(grouping: snapshots) { snapshot -> DateComponents in
chartMonth(for: snapshot.date)
}
var totals: [(date: Date, totalValue: Decimal)] = []
totals.reserveCapacity(groupedByMonth.count)
let sortedMonthKeys = groupedByMonth.keys.sorted {
(Calendar.current.date(from: $0) ?? .distantPast) < (Calendar.current.date(from: $1) ?? .distantPast)
}
for (key, monthSnapshots) in groupedByMonth {
var latestBySource: [UUID: Snapshot] = [:]
for snapshot in monthSnapshots {
var totals: [(date: Date, totalValue: Decimal)] = []
totals.reserveCapacity(sortedMonthKeys.count)
// Forward-fill per source so each month's total reflects every source's last
// known value (matches monthlyTotals and the portfolio total).
var latestBySource: [UUID: Snapshot] = [:]
for key in sortedMonthKeys {
for snapshot in groupedByMonth[key] ?? [] {
guard let sourceId = snapshot.source?.id else { continue }
if let existing = latestBySource[sourceId] {
if snapshot.date > existing.date {
@@ -1695,7 +1776,7 @@ class ChartsViewModel: ObservableObject {
totals.append((date: date, totalValue: total))
}
return totals.sorted { $0.date < $1.date }
return totals
}
private func monthlyReturnSeries(
@@ -25,6 +25,13 @@ class DashboardViewModel: ObservableObject {
@Published var evolutionData: [(date: Date, value: Decimal)] = []
@Published var updateStreak: Int = 0
// MARK: - Data Gaps
/// Internal gaps in per-source snapshot history (missing months between two
/// known snapshots). The chart smooths these over; this exposes them so the
/// user knows real data is missing and can fill it. See SnapshotGapDetector.
@Published var dataGaps: [SnapshotGapDetector.Gap] = []
// MARK: - Portfolio Forecast
@Published var portfolioForecast: PortfolioForecast?
@@ -209,8 +216,13 @@ class DashboardViewModel: ObservableObject {
// Compute update streak
updateStreak = computeStreak(from: evolutionData)
// Log screen view
FirebaseService.shared.logScreenView(screenName: "Dashboard")
// Detect internal data gaps (missing months between known snapshots)
dataGaps = SnapshotGapDetector.detectGaps(sources: sources, snapshots: allSnapshots)
// NOTE: no screen_view logging here refreshData() fires on every
// relevant Core Data change (incl. iCloud imports), so logging from it
// inflated GA4 with thousands of phantom Dashboard views from devices
// left open. The view logs once per appearance in DashboardView.onAppear.
}
private func filteredSources() -> [InvestmentSource] {
@@ -283,51 +295,75 @@ class DashboardViewModel: ObservableObject {
return EvolutionSummary(evolutionData: [], categorySeries: [], categoryTotals: [:])
}
let sortedSnapshots = snapshots.sorted { $0.date < $1.date }
let groupedByMonth = Dictionary(grouping: sortedSnapshots) { snapshot -> DateComponents in
let components = Calendar.current.dateComponents([.year, .month], from: snapshot.date)
return DateComponents(year: components.year, month: components.month)
let calendar = Calendar.current
func monthKey(_ date: Date) -> DateComponents {
let c = calendar.dateComponents([.year, .month], from: date)
return DateComponents(year: c.year, month: c.month)
}
// Ordered grid of every month that has data, and each month's index in it.
let sortedMonthKeys = Set(snapshots.map { monthKey($0.date) }).sorted {
(calendar.date(from: $0) ?? .distantPast) < (calendar.date(from: $1) ?? .distantPast)
}
let monthCount = sortedMonthKeys.count
guard monthCount > 0 else {
return EvolutionSummary(evolutionData: [], categorySeries: [], categoryTotals: [:])
}
var indexByKey: [DateComponents: Int] = [:]
for (i, key) in sortedMonthKeys.enumerated() { indexByKey[key] = i }
// Per source: the latest snapshot value at each month index + its category.
var knownBySource: [UUID: [Int: (value: Decimal, date: Date)]] = [:]
var categoryBySource: [UUID: UUID] = [:]
for snapshot in snapshots {
guard let sourceId = snapshot.source?.id,
let idx = indexByKey[monthKey(snapshot.date)] else { continue }
let existing = knownBySource[sourceId]?[idx]
if existing == nil || snapshot.date > existing!.date {
knownBySource[sourceId, default: [:]][idx] = (snapshot.decimalValue, snapshot.date)
}
if let categoryId = snapshot.source?.category?.id {
categoryBySource[sourceId] = categoryId
}
}
// Fill each source's gaps (linear interpolation when enabled, else stepped
// carry-forward), then sum per month. Interpolating per source means a
// source last seen in Feb and next in Jun contributes a smooth ramp for
// Mar/Apr/May instead of a flat line + jump. Chart display only no data
// is created (see ChartGapFill).
let interpolate = ChartGapFill.isEnabled
var monthTotals = [Decimal](repeating: 0, count: monthCount)
var monthCategory = [[UUID: Decimal]](repeating: [:], count: monthCount)
var categoryTotals: [UUID: Decimal] = [:]
for (sourceId, known) in knownBySource {
let dense = ChartGapFill.denseValues(
known: known.mapValues { $0.value },
monthCount: monthCount,
interpolate: interpolate
)
let categoryId = categoryBySource[sourceId]
for i in 0..<monthCount {
guard let v = dense[i] else { continue }
monthTotals[i] += v
if let categoryId {
monthCategory[i][categoryId, default: 0] += v
categoryTotals[categoryId, default: 0] += v
}
}
}
var evolution: [(date: Date, value: Decimal)] = []
evolution.reserveCapacity(groupedByMonth.count)
evolution.reserveCapacity(monthCount)
var series: [(date: Date, valuesByCategory: [UUID: Decimal])] = []
series.reserveCapacity(groupedByMonth.count)
var categoryTotals: [UUID: Decimal] = [:]
for (key, monthSnapshots) in groupedByMonth {
var latestBySource: [UUID: Snapshot] = [:]
for snapshot in monthSnapshots {
guard let sourceId = snapshot.source?.id else { continue }
if let existing = latestBySource[sourceId] {
if snapshot.date > existing.date {
latestBySource[sourceId] = snapshot
}
} else {
latestBySource[sourceId] = snapshot
}
}
var total: Decimal = 0
var valuesByCategory: [UUID: Decimal] = [:]
for snapshot in latestBySource.values {
let value = snapshot.decimalValue
total += value
if let categoryId = snapshot.source?.category?.id {
valuesByCategory[categoryId, default: 0] += value
categoryTotals[categoryId, default: 0] += value
}
}
let date = Calendar.current.date(from: key) ?? Date()
evolution.append((date: date, value: total))
series.append((date: date, valuesByCategory: valuesByCategory))
series.reserveCapacity(monthCount)
for i in 0..<monthCount {
let date = calendar.date(from: sortedMonthKeys[i]) ?? Date()
evolution.append((date: date, value: monthTotals[i]))
series.append((date: date, valuesByCategory: monthCategory[i]))
}
evolution.sort { $0.date < $1.date }
series.sort { $0.date < $1.date }
return EvolutionSummary(
evolutionData: evolution,
categorySeries: series,
@@ -599,6 +635,29 @@ class DashboardViewModel: ObservableObject {
let value: Decimal
}
/// ETA text for a goal scoped to an account and/or a category. The dashboard
/// evolution covers the whole (selected) portfolio, so a scoped goal e.g.
/// "100k in Cash" must be projected over its own sources instead; the
/// caller passes that projection in.
func goalEtaText(for goal: Goal, currentValue: Decimal, projection: GoalProjection.Result) -> String? {
if currentValue >= goal.targetDecimal {
return "Goal reached. Keep it steady."
}
guard let months = projection.monthsToReach else {
return "Keep going. Consistency pays off."
}
if months == 0 {
return "Almost there. One more check-in."
}
if months >= 72 {
return "Long journey, strong discipline. You're building momentum."
}
if let projectedDate = projection.projectedDate {
return "Estimated: \(projectedDate.monthYearString)"
}
return "Estimated: \(months) months"
}
func goalEtaText(for goal: Goal, currentValue: Decimal) -> String? {
let target = goal.targetDecimal
let lastValue = evolutionData.last?.value ?? currentValue
@@ -661,80 +720,81 @@ class DashboardViewModel: ObservableObject {
sourcesNeedingUpdate.count
}
/// Total number of missing months across all detected internal gaps.
var totalMissingMonths: Int {
SnapshotGapDetector.totalMissingMonths(dataGaps)
}
/// Resolves an InvestmentSource for a detected gap so the fill flow can open
/// the add-snapshot sheet for it.
func source(withId id: UUID) -> InvestmentSource? {
sourceRepository.sources.first { $0.id == id }
}
/// Builds the data for the shareable monthly summary card from already-computed
/// dashboard state. Mood/rating come from the latest completed check-in entry.
func monthlySummaryShareData() -> ShareService.MonthlySummaryShareData {
let monthLabel: String = {
let formatter = DateFormatter()
formatter.dateFormat = "MMMM yyyy"
let date = MonthlyCheckInStore.latestCompletionDate() ?? Date()
return formatter.string(from: date)
}()
let latestEntry = MonthlyCheckInStore.latestCompletionDate()
.flatMap { MonthlyCheckInStore.entry(for: $0) }
return ShareService.MonthlySummaryShareData(
monthLabel: monthLabel,
totalValue: portfolioSummary.formattedTotalValue,
changeAmount: latestPortfolioChange.formattedAbsolute,
changePercentage: latestPortfolioChange.formattedPercentage,
isPositive: latestPortfolioChange.absolute >= 0,
streak: updateStreak,
mood: latestEntry?.mood,
rating: latestEntry?.rating
)
}
/// Prioritized, on-device insights. Gathers local inputs and delegates the
/// rules to `InsightsEngine` (no network / no AI).
var insights: [PortfolioInsight] {
var result: [PortfolioInsight] = []
guard portfolioSummary.totalValue > 0 else { return result }
let total = portfolioSummary.totalValue
guard total > 0 else { return [] }
// 1. Milestone approaching (within 10% of next round milestone)
let milestones: [Decimal] = [1000, 2500, 5000, 10000, 25000, 50000,
100000, 250000, 500000, 1_000_000,
2_500_000, 5_000_000, 10_000_000]
if let next = milestones.first(where: { $0 > total }) {
let pct = NSDecimalNumber(decimal: total / next).doubleValue
if pct >= 0.90 {
let gap = next - total
let gapStr = CurrencyFormatter.format(gap, style: .currency, maximumFractionDigits: 0)
let msStr = CurrencyFormatter.format(next, style: .currency, maximumFractionDigits: 0)
result.append(PortfolioInsight(
id: "milestone",
systemImage: "flag.checkered",
title: String(localized: "insight_milestone_title"),
value: String(format: String(localized: "insight_milestone_value"), gapStr, msStr),
accentColor: .orange
))
}
let categorySlices: [InsightsEngine.CategorySlice] = categoryMetrics.map { cat in
InsightsEngine.CategorySlice(
name: cat.categoryName,
percentageOfPortfolio: cat.percentageOfPortfolio,
targetPercentage: AllocationTargetStore.target(for: cat.id),
returnPercentage: NSDecimalNumber(decimal: cat.metrics.percentageReturn).doubleValue
)
}
// 2. Year-to-date performance
let ytdPct = portfolioSummary.yearChangePercentage
if abs(ytdPct) >= 0.1 {
let icon = ytdPct >= 0 ? "arrow.up.right.circle.fill" : "arrow.down.right.circle.fill"
result.append(PortfolioInsight(
id: "ytd",
systemImage: icon,
title: String(localized: "insight_ytd_title"),
value: String(format: "%+.1f%%", ytdPct),
accentColor: ytdPct >= 0 ? .positiveGreen : .negativeRed
))
let sources = filteredSources()
let totalDouble = NSDecimalNumber(decimal: total).doubleValue
let sourceSlices: [InsightsEngine.SourceSlice] = sources.compactMap { source in
let value = NSDecimalNumber(decimal: source.latestValue).doubleValue
guard value > 0, totalDouble > 0 else { return nil }
return InsightsEngine.SourceSlice(
name: source.name,
percentageOfPortfolio: value / totalDouble * 100
)
}
// 3. All-time market gains
let gains = portfolioSummary.allTimeReturn
if gains > 0 {
let gainStr = CurrencyFormatter.format(gains, style: .currency, maximumFractionDigits: 0)
result.append(PortfolioInsight(
id: "market_gains",
systemImage: "chart.line.uptrend.xyaxis",
title: String(localized: "insight_market_gains_title"),
value: String(format: String(localized: "insight_market_gains_value"), gainStr),
accentColor: .appPrimary
))
}
let input = InsightsEngine.Input(
totalValue: total,
categories: categorySlices,
sources: sourceSlices,
fundedCategoryCount: categoryMetrics.filter { $0.totalValue > 0 }.count,
fundedSourceCount: sourceSlices.count,
yearChangePercentage: portfolioSummary.yearChangePercentage,
allTimeReturn: portfolioSummary.allTimeReturn,
updateStreak: updateStreak,
forecast: portfolioForecast
)
// 4. Tracking streak (>= 3 months)
if updateStreak >= 3 {
result.append(PortfolioInsight(
id: "streak",
systemImage: "flame.fill",
title: String(localized: "insight_streak_title"),
value: String(format: String(localized: "insight_streak_value"), updateStreak),
accentColor: .orange
))
}
// 5. Forecast
if let forecast = portfolioForecast, forecast.forecastValue > total {
result.append(PortfolioInsight(
id: "forecast",
systemImage: "wand.and.stars",
title: String(localized: "insight_forecast_title"),
value: "\(forecast.formattedForecastValue) · \(forecast.formattedForecastDate)",
accentColor: .purple
))
}
return result
return InsightsEngine.makeInsights(input)
}
var topCategories: [CategoryMetrics] {
@@ -774,4 +834,15 @@ class DashboardViewModel: ObservableObject {
var formattedLastUpdate: String {
portfolioSummary.lastUpdated?.friendlyDescription ?? "Not yet"
}
/// Absolute last-check-in date (e.g. "7 Aug 2026") for the share card an
/// outsider needs a concrete date, not "2 days ago".
var shareAsOfDate: String? {
guard let date = portfolioSummary.lastUpdated else { return nil }
let f = DateFormatter()
f.locale = .autoupdatingCurrent
f.dateStyle = .medium
f.timeStyle = .none
return f.string(from: date)
}
}
@@ -16,7 +16,7 @@ class GoalsViewModel: ObservableObject {
private var cancellables = Set<AnyCancellable>()
// MARK: - Performance: Caching
private var cachedEvolutionData: [UUID: [(date: Date, value: Decimal)]] = [:]
private var cachedEvolutionData: [String: [(date: Date, value: Decimal)]] = [:]
private var cachedCompletionDates: [UUID: Date?] = [:]
private var lastSourcesHash: Int = 0
@@ -94,12 +94,9 @@ class GoalsViewModel: ObservableObject {
}
func totalValue(for goal: Goal) -> Decimal {
if let accountId = goal.account?.safeId {
return sourceRepository.sources
.filter { $0.account?.id == accountId }
.reduce(Decimal.zero) { $0 + $1.latestValue }
}
return totalValue
guard goal.account != nil || goal.category != nil else { return totalValue }
return relevantSources(for: goal)
.reduce(Decimal.zero) { $0 + $1.latestValue }
}
func paceStatus(for goal: Goal) -> GoalPaceStatus? {
@@ -166,70 +163,59 @@ class GoalsViewModel: ObservableObject {
}
func estimateCompletionDate(for goal: Goal) -> Date? {
// Performance: Use cached completion date if available
if let cached = cachedCompletionDates[goal.id] {
projection(for: goal).projectedDate
}
/// Full forward projection for a goal, blending recent growth trend with the
/// monthly contributions of the relevant sources. Cached per goal.
func projection(for goal: Goal) -> GoalProjection.Result {
let sources = relevantSources(for: goal)
let currentValue = totalValue(for: goal)
guard !sources.isEmpty else {
let cached = GoalProjection.Result(
projectedDate: nil, monthsToReach: nil,
status: goal.targetDate == nil ? .noTargetDate : .unreachable,
monthlyGrowthRate: 0, sparkline: []
)
cachedCompletionDates[goal.id] = nil
return cached
}
let sources: [InvestmentSource]
if let accountId = goal.account?.safeId {
sources = sourceRepository.sources.filter { $0.account?.id == accountId }
} else {
sources = sourceRepository.sources
let evolutionData = evolutionData(for: goal, sources: sources)
let rate = GoalProjection.monthlyGrowthRate(from: evolutionData)
let contribution = sources.reduce(Decimal.zero) {
$0 + ($1.monthlyContribution?.decimalValue ?? .zero)
}
let result = GoalProjection.project(
currentValue: currentValue,
targetAmount: goal.targetDecimal,
monthlyGrowthRate: rate,
monthlyContribution: contribution,
targetDate: goal.targetDate
)
cachedCompletionDates[goal.id] = result.projectedDate
return result
}
private func relevantSources(for goal: Goal) -> [InvestmentSource] {
goal.relevantSources(from: sourceRepository.sources)
}
private func evolutionData(
for goal: Goal,
sources: [InvestmentSource]
) -> [(date: Date, value: Decimal)] {
let cacheKey = goal.scopeKey
if let cached = cachedEvolutionData[cacheKey] {
return cached
}
let sourceIds = sources.compactMap { $0.id }
guard !sourceIds.isEmpty else {
cachedCompletionDates[goal.id] = nil
return nil
}
// Performance: Use cached evolution data if available
let cacheKey = goal.account?.safeId ?? UUID(uuidString: "00000000-0000-0000-0000-000000000000")!
let evolutionData: [(date: Date, value: Decimal)]
if let cached = cachedEvolutionData[cacheKey] {
evolutionData = cached
} else {
let snapshots = snapshotRepository.fetchSnapshots(
for: sourceIds,
months: maxHistoryMonths
)
evolutionData = calculateEvolutionData(from: snapshots)
cachedEvolutionData[cacheKey] = evolutionData
}
guard evolutionData.count >= 3,
let first = evolutionData.suffix(6).first,
let last = evolutionData.suffix(6).last else {
cachedCompletionDates[goal.id] = nil
return nil
}
let monthsBetween = max(1, first.date.monthsBetween(last.date))
let delta = last.value - first.value
guard delta > 0 else {
cachedCompletionDates[goal.id] = nil
return nil
}
let monthlyGain = delta / Decimal(monthsBetween)
guard monthlyGain > 0 else {
cachedCompletionDates[goal.id] = nil
return nil
}
let currentValue = totalValue(for: goal)
let remaining = goal.targetDecimal - currentValue
guard remaining > 0 else {
let result = Date()
cachedCompletionDates[goal.id] = result
return result
}
let months = NSDecimalNumber(decimal: remaining / monthlyGain).doubleValue
let monthsRounded = Int(ceil(months))
let result = Calendar.current.date(byAdding: .month, value: monthsRounded, to: last.date)
cachedCompletionDates[goal.id] = result
return result
let snapshots = snapshotRepository.fetchSnapshots(for: sourceIds, months: maxHistoryMonths)
let data = calculateEvolutionData(from: snapshots)
cachedEvolutionData[cacheKey] = data
return data
}
private func calculateEvolutionData(from snapshots: [Snapshot]) -> [(date: Date, value: Decimal)] {
@@ -36,6 +36,8 @@ class SettingsViewModel: ObservableObject {
@Published var backupRetentionCount = 10
@Published var backups: [BackupRecord] = []
@Published var backupsEnabled = false
/// Auto-backup cadence in days: 0 = off, 1 = daily, 7 = weekly, 30 = monthly.
@Published var autoBackupFrequencyDays = 0
// MARK: - Dependencies
@@ -96,6 +98,7 @@ class SettingsViewModel: ObservableObject {
inputMode = InputMode(rawValue: settings.inputMode) ?? .simple
backupRetentionCount = loadBackupRetention()
backupsEnabled = loadBackupsEnabled()
autoBackupFrequencyDays = UserDefaults.standard.integer(forKey: "autoBackupFrequencyDays")
if backupsEnabled {
refreshBackups()
} else {
@@ -236,8 +239,8 @@ class SettingsViewModel: ObservableObject {
exportStatus = "Export complete"
isExporting = false
showingExportOptions = false
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in
self?.shareItem = ShareItem(url: tempURL)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
self.shareItem = ShareItem(url: tempURL)
}
FirebaseService.shared.logExportAttempt(format: format.rawValue, success: true)
}
@@ -353,6 +356,7 @@ class SettingsViewModel: ObservableObject {
if accountCount == 0 {
let defaultCurrency = AppSettings.getOrCreate(in: context).currency
let account = Account(context: context)
account.id = Account.defaultAccountStableID
account.name = Account.defaultAccountName
account.currency = defaultCurrency
account.inputMode = InputMode.simple.rawValue
@@ -394,6 +398,19 @@ class SettingsViewModel: ObservableObject {
backups = BackupService.shared.listAllBackups(includeICloud: includeICloud)
}
/// Auto-backup cadence (0 = off). Running one immediately when enabled gives
/// instant feedback that it works.
func updateAutoBackupFrequency(_ days: Int) {
guard backupsEnabled, isPremium else { return }
autoBackupFrequencyDays = days
UserDefaults.standard.set(days, forKey: "autoBackupFrequencyDays")
if days > 0 { createBackupNow() }
}
var lastAutoBackupDate: Date? {
UserDefaults.standard.object(forKey: "lastAutoBackupDate") as? Date
}
func createBackupNow() {
guard backupsEnabled, isPremium else { return }
guard !isBackupInProgress else { return }
@@ -41,9 +41,14 @@ class SnapshotFormViewModel: ObservableObject {
// MARK: - Initialization
init(source: InvestmentSource, mode: Mode = .add) {
init(source: InvestmentSource, mode: Mode = .add, initialDate: Date? = nil) {
self.source = source
self.mode = mode
if case .add = mode, let initialDate {
// Prefill for filling a specific missing month (gap fill flow).
// Clamp to today so the date picker's `...Date()` bound holds.
date = min(initialDate, Date())
}
let settings = AppSettings.getOrCreate(in: CoreDataStack.shared.viewContext)
if let accountCurrency = source.account?.currency, !accountCurrency.isEmpty {
currencyCode = accountCurrency
@@ -198,10 +198,6 @@ class SourceListViewModel: ObservableObject {
totalValue.currencyString
}
var sourcesByCategory: [Category: [InvestmentSource]] {
Dictionary(grouping: sources) { $0.category ?? categories.first! }
}
var isEmpty: Bool {
sources.isEmpty && searchText.isEmpty && selectedCategoryIds.isEmpty
}
@@ -21,11 +21,11 @@ struct AccountEditorView: View {
if isEditingDefaultAccount {
HStack {
Text(Account.defaultAccountName)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
Spacer()
Image(systemName: "lock.fill")
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
} else {
TextField("Account name", text: $name)
@@ -40,7 +40,7 @@ struct AccountEditorView: View {
} footer: {
if let errorMessage {
Text(errorMessage)
.foregroundColor(.negativeRed)
.foregroundStyle(Color.negativeRed)
} else if isEditingDefaultAccount {
Text("The Default account name cannot be changed.")
}
@@ -62,10 +62,10 @@ struct AccountEditorView: View {
.navigationTitle(account == nil ? "New Account" : "Edit Account")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
ToolbarItem(placement: .topBarLeading) {
Button("Cancel") { dismiss() }
}
ToolbarItem(placement: .navigationBarTrailing) {
ToolbarItem(placement: .topBarTrailing) {
Button("Save") { saveAccount() }
.disabled(!isValid)
}
@@ -1,4 +1,5 @@
import SwiftUI
import CoreData
struct AccountsView: View {
@EnvironmentObject private var iapService: IAPService
@@ -33,17 +34,17 @@ struct AccountsView: View {
if account.isDefaultAccount {
Image(systemName: "star.fill")
.font(.caption2)
.foregroundColor(.appWarning)
.foregroundStyle(Color.appWarning)
}
}
Text(account.currencyCode ?? AppSettings.getOrCreate(in: CoreDataStack.shared.viewContext).currency)
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
Spacer()
if accountStore.selectedAccount?.safeId == account.safeId && !accountStore.showAllAccounts {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.appPrimary)
.foregroundStyle(Color.appPrimary)
}
}
}
@@ -92,7 +93,7 @@ struct AccountsView: View {
Text("The Default account cannot be deleted. You must keep at least one account.")
}
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
ToolbarItem(placement: .topBarTrailing) {
Button {
if accountStore.canAddAccount() {
showingAddAccount = true
@@ -5,8 +5,16 @@ struct AllocationPieChart: View {
let data: [(category: String, value: Decimal, color: String)]
var title: String = "Asset Allocation"
var showsTargetsComparison: Bool = true
/// Drill-down: tapping the chevron on a legend row jumps to Evolution
/// filtered to that category/source.
var onDrillDown: ((String) -> Void)? = nil
@State private var selectedSlice: String?
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
private var chartHeight: CGFloat {
horizontalSizeClass == .regular ? 190 : 130
}
var total: Decimal {
data.reduce(Decimal.zero) { $0 + $1.value }
@@ -18,83 +26,62 @@ struct AllocationPieChart: View {
.font(.headline)
if !data.isEmpty {
HStack(alignment: .top, spacing: 20) {
// Pie Chart
Chart(data, id: \.category) { item in
SectorMark(
angle: .value("Value", NSDecimalNumber(decimal: item.value).doubleValue),
innerRadius: .ratio(0.6),
angularInset: 1.5
)
.foregroundStyle(Color(hex: item.color) ?? .gray)
.cornerRadius(4)
.opacity(selectedSlice == nil || selectedSlice == item.category ? 1 : 0.5)
}
.chartLegend(.hidden)
.frame(width: 180, height: 180)
.overlay {
// Center content
VStack(spacing: 2) {
if let selected = selectedSlice,
let item = data.first(where: { $0.category == selected }) {
Text(selected)
.font(.caption)
.foregroundColor(.secondary)
Text(item.value.compactCurrencyString)
.font(.headline)
let percentage = total > 0
? NSDecimalNumber(decimal: item.value / total).doubleValue * 100
: 0
Text(String(format: "%.1f%%", percentage))
.font(.caption)
.foregroundColor(.secondary)
} else {
Text("Total")
.font(.caption)
.foregroundColor(.secondary)
Text(total.compactCurrencyString)
.font(.headline)
}
}
}
.gesture(
DragGesture(minimumDistance: 0)
.onChanged { value in
// Simple tap detection
}
HStack(alignment: .center, spacing: 24) {
// Hemicycle (parliament-style) allocation gauge fills its
// half of the row so it scales up on iPad instead of sitting
// tiny in a corner.
SemicircleAllocation(
data: data,
total: total,
selectedSlice: $selectedSlice
)
.frame(maxWidth: .infinity)
.frame(height: chartHeight)
// Legend
VStack(alignment: .leading, spacing: 8) {
VStack(alignment: .leading, spacing: 10) {
ForEach(data, id: \.category) { item in
Button {
if selectedSlice == item.category {
selectedSlice = nil
} else {
selectedSlice = item.category
}
} label: {
HStack(spacing: 8) {
Circle()
.fill(Color(hex: item.color) ?? .gray)
.frame(width: 10, height: 10)
VStack(alignment: .leading, spacing: 0) {
Text(item.category)
.font(.caption)
.foregroundColor(.primary)
let percentage = total > 0
? NSDecimalNumber(decimal: item.value / total).doubleValue * 100
: 0
Text(String(format: "%.1f%%", percentage))
.font(.caption2)
.foregroundColor(.secondary)
HStack(spacing: 6) {
Button {
if selectedSlice == item.category {
selectedSlice = nil
} else {
selectedSlice = item.category
}
} label: {
HStack(spacing: 8) {
Circle()
.fill(Color(hex: item.color) ?? .gray)
.frame(width: 11, height: 11)
VStack(alignment: .leading, spacing: 0) {
Text(item.category)
.font(.subheadline)
.foregroundStyle(.primary)
let percentage = total > 0
? NSDecimalNumber(decimal: item.value / total).doubleValue * 100
: 0
Text(String(format: "%.1f%%", percentage))
.font(.caption)
.foregroundStyle(.secondary)
}
}
.opacity(selectedSlice == nil || selectedSlice == item.category ? 1 : 0.5)
}
.buttonStyle(.plain)
if let onDrillDown {
Button {
onDrillDown(item.category)
} label: {
Image(systemName: "chevron.right.circle.fill")
.font(.footnote)
.foregroundStyle(.secondary.opacity(0.6))
}
.buttonStyle(.plain)
}
.opacity(selectedSlice == nil || selectedSlice == item.category ? 1 : 0.5)
}
.buttonStyle(.plain)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
@@ -105,18 +92,128 @@ struct AllocationPieChart: View {
}
} else {
Text("No allocation data available")
.foregroundColor(.secondary)
.frame(height: 200)
.foregroundStyle(.secondary)
.chartFrame(height: 200)
.frame(maxWidth: .infinity)
}
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
}
}
// MARK: - Semicircle (hemicycle) allocation gauge
/// Parliament-style half-donut: segments sweep the top 180°, sized by share.
struct SemicircleAllocation: View {
let data: [(category: String, value: Decimal, color: String)]
let total: Decimal
@Binding var selectedSlice: String?
/// Cumulative start/end fractions [0,1] for each segment across the 180° arc.
private var segments: [(category: String, start: Double, end: Double, color: Color)] {
guard total > 0 else { return [] }
var acc = 0.0
return data.map { item in
let frac = NSDecimalNumber(decimal: item.value / total).doubleValue
let start = acc
acc += frac
return (item.category, start, min(acc, 1), Color(hex: item.color) ?? .gray)
}
}
private var centerItem: (label: String, value: String, sub: String?)? {
if let selected = selectedSlice, let item = data.first(where: { $0.category == selected }) {
let pct = total > 0 ? NSDecimalNumber(decimal: item.value / total).doubleValue * 100 : 0
return (selected, item.value.compactCurrencyString, String(format: "%.1f%%", pct))
}
return (String(localized: "allocation_total"), total.compactCurrencyString, nil)
}
var body: some View {
GeometryReader { geo in
// The semicircle is 2:1 (width:height). Fit it to whichever
// dimension binds so it scales up on iPad without clipping.
let w = min(geo.size.width, geo.size.height * 2)
let lineWidth = w * 0.17
let radius = (w - lineWidth) / 2
let center = CGPoint(x: geo.size.width / 2, y: (geo.size.height + w / 2) / 2)
ZStack {
// Track
SemicircleArc(startFraction: 0, endFraction: 1, radius: radius, center: center)
.stroke(Color(.systemGray5), style: StrokeStyle(lineWidth: lineWidth, lineCap: .butt))
ForEach(segments, id: \.category) { seg in
SemicircleArc(startFraction: seg.start, endFraction: seg.end, radius: radius, center: center, inset: 0.006)
.stroke(seg.color, style: StrokeStyle(lineWidth: lineWidth, lineCap: .butt))
.opacity(selectedSlice == nil || selectedSlice == seg.category ? 1 : 0.4)
.contentShape(SemicircleArc(startFraction: seg.start, endFraction: seg.end, radius: radius, center: center).stroke(style: StrokeStyle(lineWidth: lineWidth)))
.onTapGesture {
selectedSlice = (selectedSlice == seg.category) ? nil : seg.category
}
}
// Percentage labels on the arc band (only where the slice is wide
// enough to fit the text).
ForEach(segments, id: \.category) { seg in
let frac = seg.end - seg.start
if frac >= 0.07 {
let mid = (seg.start + seg.end) / 2
let angle = (180 + mid * 180) * .pi / 180
let pos = CGPoint(x: center.x + radius * CoreGraphics.cos(angle),
y: center.y + radius * CoreGraphics.sin(angle))
Text("\(Int((frac * 100).rounded()))%")
.font(.system(size: lineWidth * 0.42, weight: .bold))
.foregroundStyle(.white)
.position(pos)
.opacity(selectedSlice == nil || selectedSlice == seg.category ? 1 : 0.4)
}
}
// Center readout, sitting just under the arc
if let c = centerItem {
VStack(spacing: 1) {
Text(c.label)
.font(.caption2)
.foregroundStyle(.secondary)
.lineLimit(1)
Text(c.value)
.font(.headline)
if let sub = c.sub {
Text(sub)
.font(.caption2)
.foregroundStyle(.secondary)
}
}
.position(x: center.x, y: center.y - lineWidth * 0.25)
}
}
}
}
}
/// An arc along the TOP semicircle, parameterized by fraction [0,1] of the 180°
/// sweep (0 = left/9 o'clock, 1 = right/3 o'clock, passing through the top).
struct SemicircleArc: Shape {
let startFraction: Double
let endFraction: Double
let radius: CGFloat
var center: CGPoint? = nil
var inset: Double = 0
func path(in rect: CGRect) -> Path {
let c = center ?? CGPoint(x: rect.width / 2, y: rect.width / 2)
let start = Angle.degrees(180 + (startFraction + inset) * 180)
let end = Angle.degrees(180 + (endFraction - inset) * 180)
var p = Path()
p.addArc(center: c, radius: radius, startAngle: start, endAngle: end, clockwise: false)
return p
}
}
// MARK: - Allocation Targets Comparison
struct AllocationTargetsComparisonChart: View {
@@ -146,7 +243,7 @@ struct AllocationTargetsComparisonChart: View {
if targetData.allSatisfy({ $0.target == 0 }) {
Text("Set allocation targets to compare your portfolio against your plan.")
.font(.subheadline)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
} else {
Chart {
ForEach(targetData, id: \.category) { item in
@@ -178,7 +275,7 @@ struct AllocationTargetsComparisonChart: View {
AxisValueLabel()
}
}
.frame(height: 220)
.chartFrame(height: 220)
ForEach(targetData, id: \.category) { item in
let drift = item.actual - item.target
@@ -192,20 +289,20 @@ struct AllocationTargetsComparisonChart: View {
Spacer()
Text("Actual \(String(format: "%.1f%%", item.actual))")
.font(.caption2)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
Text("Target \(String(format: "%.0f%%", item.target))")
.font(.caption2)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
Text("\(prefix)\(String(format: "%.1f%%", drift))")
.font(.caption2.weight(.semibold))
.foregroundColor(drift >= 0 ? .positiveGreen : .negativeRed)
.foregroundStyle(drift >= 0 ? Color.positiveGreen : Color.negativeRed)
}
}
}
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
}
@@ -254,7 +351,7 @@ struct AllocationListView: View {
Text(item.value.compactCurrencyString)
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
.frame(width: 70, alignment: .trailing)
}
@@ -268,12 +365,12 @@ struct AllocationListView: View {
Rectangle()
.fill(Color.gray.opacity(0.1))
.frame(height: 6)
.cornerRadius(3)
.clipShape(RoundedRectangle(cornerRadius: 3))
Rectangle()
.fill(Color(hex: item.color) ?? .gray)
.frame(width: geometry.size.width * percentage, height: 6)
.cornerRadius(3)
.clipShape(RoundedRectangle(cornerRadius: 3))
}
}
.frame(height: 6)
@@ -282,7 +379,7 @@ struct AllocationListView: View {
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
}
}
@@ -19,7 +19,7 @@ struct AllocationSimulatorView: View {
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
}
@@ -29,13 +29,13 @@ struct AllocationSimulatorView: View {
VStack(spacing: 12) {
Image(systemName: "slider.horizontal.3")
.font(.system(size: 36))
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
Text("No sources available")
.font(.subheadline)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity)
.frame(height: 300)
.chartFrame(height: 300)
}
// MARK: - Allocation Card
@@ -56,7 +56,7 @@ struct AllocationSimulatorView: View {
}
.padding(12)
.background(Color(.systemGray6))
.cornerRadius(10)
.clipShape(RoundedRectangle(cornerRadius: 10))
}
private var totalAllocationBadge: some View {
@@ -67,8 +67,8 @@ struct AllocationSimulatorView: View {
.padding(.horizontal, 8)
.padding(.vertical, 3)
.background(isNear100 ? Color.green.opacity(0.15) : Color.orange.opacity(0.15))
.foregroundColor(isNear100 ? .green : .orange)
.cornerRadius(8)
.foregroundStyle(isNear100 ? .green : .orange)
.clipShape(RoundedRectangle(cornerRadius: 8))
}
private var resetButton: some View {
@@ -87,7 +87,7 @@ struct AllocationSimulatorView: View {
} label: {
Image(systemName: "arrow.counterclockwise")
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
}
@@ -104,11 +104,11 @@ struct AllocationSimulatorView: View {
Spacer()
Text(String(format: "%.0f%%", source.simulatedPct))
.font(.caption.weight(.semibold))
.foregroundColor(.primary)
.foregroundStyle(.primary)
.frame(width: 36, alignment: .trailing)
Text(String(format: "(%.0f%%)", source.currentPct))
.font(.caption2)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
Slider(
@@ -142,8 +142,8 @@ struct AllocationSimulatorView: View {
if viewModel.simulatorActualData.isEmpty && viewModel.simulatorData.isEmpty {
Text("Not enough data to simulate.")
.font(.subheadline)
.foregroundColor(.secondary)
.frame(height: 220)
.foregroundStyle(.secondary)
.chartFrame(height: 220)
} else {
simulationChart
chartLegend
@@ -194,7 +194,7 @@ struct AllocationSimulatorView: View {
.foregroundStyle(Color.secondary.opacity(0.15))
}
}
.frame(height: 220)
.chartFrame(height: 220)
.chartDrawingGroup(disabledForExport: chartImageExport)
}
@@ -224,7 +224,7 @@ struct AllocationSimulatorView: View {
}
Text(label)
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
}
@@ -24,16 +24,16 @@ struct ChartStatsRow: View {
VStack(alignment: .center, spacing: 3) {
Text(stats[i].label)
.font(.caption2)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
Text(stats[i].value)
.font(.subheadline.weight(.semibold))
.foregroundColor(stats[i].color)
.foregroundStyle(stats[i].color)
}
.frame(minWidth: 60)
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(Color(.systemGray6))
.cornerRadius(8)
.clipShape(RoundedRectangle(cornerRadius: 8))
}
}
}
@@ -84,28 +84,28 @@ struct ChartDataTable: View {
Image(systemName: isExpanded ? "chevron.up" : "chevron.down")
.font(.caption2)
}
.foregroundColor(.appPrimary)
.foregroundStyle(Color.appPrimary)
.frame(maxWidth: .infinity)
.padding(.vertical, 8)
}
}
}
.background(Color(.systemGray6))
.cornerRadius(8)
.clipShape(RoundedRectangle(cornerRadius: 8))
}
private var tableHeader: some View {
HStack(spacing: 0) {
Text("Date").font(.caption2.weight(.semibold)).foregroundColor(.secondary)
Text("Date").font(.caption2.weight(.semibold)).foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
Text(valueHeader).font(.caption2.weight(.semibold)).foregroundColor(.secondary)
Text(valueHeader).font(.caption2.weight(.semibold)).foregroundStyle(.secondary)
.frame(width: 72, alignment: .trailing)
if let h = deltaPrevHeader {
Text(h).font(.caption2.weight(.semibold)).foregroundColor(.secondary)
Text(h).font(.caption2.weight(.semibold)).foregroundStyle(.secondary)
.frame(width: 62, alignment: .trailing)
}
if let h = deltaFirstHeader {
Text(h).font(.caption2.weight(.semibold)).foregroundColor(.secondary)
Text(h).font(.caption2.weight(.semibold)).foregroundStyle(.secondary)
.frame(width: 62, alignment: .trailing)
}
}
@@ -115,18 +115,18 @@ struct ChartDataTable: View {
private func tableDataRow(_ row: ChartDataTableRow) -> some View {
HStack(spacing: 0) {
Text(row.label).font(.caption2).foregroundColor(.primary)
Text(row.label).font(.caption2).foregroundStyle(.primary)
.frame(maxWidth: .infinity, alignment: .leading)
Text(row.value).font(.caption2.weight(.medium))
.frame(width: 72, alignment: .trailing)
if let dp = row.deltaPrev, deltaPrevHeader != nil {
Text(dp).font(.caption2.weight(.medium))
.foregroundColor(row.isPrevPositive ? .positiveGreen : .negativeRed)
.foregroundStyle(row.isPrevPositive ? Color.positiveGreen : Color.negativeRed)
.frame(width: 62, alignment: .trailing)
}
if let df = row.deltaFirst, deltaFirstHeader != nil {
Text(df).font(.caption2.weight(.medium))
.foregroundColor(row.isFirstPositive ? .positiveGreen : .negativeRed)
.foregroundStyle(row.isFirstPositive ? Color.positiveGreen : Color.negativeRed)
.frame(width: 62, alignment: .trailing)
}
}
@@ -11,6 +11,34 @@ enum ChartLabels {
/// A series with at most this many points shows its value labels permanently.
static let alwaysShowThreshold = 12
/// Max permanent labels to render before thinning fewer on the narrow
/// iPhone screen where crammed labels overlap and become unreadable.
static func maxLabels(compact: Bool) -> Int { compact ? 5 : 8 }
/// Stride between labelled points so at most `maxLabels` show.
static func stride(count: Int, compact: Bool) -> Int {
let maxN = maxLabels(compact: compact)
guard count > maxN else { return 1 }
return Int(ceil(Double(count) / Double(maxN)))
}
/// Whether point `index` should carry a permanent label: always the first
/// and last (the endpoints matter most), plus every `stride`-th in between.
/// On iPhone (compact) permanent labels collide with the axis on the narrow
/// screen, so only the endpoint keeps its label exact values live in the
/// scrub header instead.
static func showsLabel(index: Int, count: Int, compact: Bool) -> Bool {
guard count > 0 else { return false }
if compact {
return count <= 6 ? true : index == count - 1
}
if index == 0 || index == count - 1 { return true }
let s = stride(count: count, compact: compact)
// Avoid a label landing right next to the forced last one.
if index >= count - 1 - (s / 2) { return false }
return index % s == 0
}
static func compactCurrency(_ value: Decimal) -> String {
value.compactCurrencyString
}
@@ -29,7 +57,7 @@ struct ChartValueBubble: View {
var body: some View {
Text(text)
.font(prominent ? .caption.weight(.bold) : .caption2.weight(.semibold))
.foregroundColor(prominent ? .white : color)
.foregroundStyle(prominent ? .white : color)
.padding(.horizontal, prominent ? 8 : 5)
.padding(.vertical, prominent ? 4 : 2)
.background(
@@ -48,16 +76,16 @@ struct ChartSelectionCard: View {
VStack(alignment: .leading, spacing: 3) {
Text(title)
.font(.caption2.weight(.semibold))
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
ForEach(Array(rows.enumerated()), id: \.offset) { _, row in
HStack(spacing: 5) {
Circle().fill(row.color).frame(width: 6, height: 6)
Text(row.label)
.font(.caption2)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
Text(row.value)
.font(.caption2.weight(.semibold))
.foregroundColor(.primary)
.foregroundStyle(.primary)
}
}
}
+376 -103
View File
@@ -1,149 +1,422 @@
import SwiftUI
import Charts
import UIKit
// MARK: - Time-series chart zoom
// MARK: - Time-series chart zoom (window model)
//
// Shared zoom behavior for every chart with a Date X axis. Default state is
// "fit all" (no behavior change); zooming in narrows the visible X domain and
// enables horizontal scrolling so the user can pan through history. Zoom is
// driven by pinch (magnification) and by explicit +/- buttons for
// discoverability and accessibility (HIG: never make gestures the only way).
// Shared VISUAL zoom for every chart with a Date X axis: pinch anchored on the
// window center, native horizontal pan once zoomed, double-tap to reset.
//
// The DATA range is a separate, global concern: the range brush
// (`ChartRangeBrush`) lives in the period bar and drives
// `ChartsViewModel.customRange`, filtering every chart at once. Keeping it out
// of the chart content also keeps it out of the chart-swipe gesture's reach.
/// View-side zoom state for a time-series chart. `visibleSpan == nil` means fit-all.
/// View-side window state for a time-series chart. `spanSeconds == nil` means fit-all.
struct ChartZoomModel {
/// Currently visible X-domain length in seconds; nil = show everything.
var visibleSpan: TimeInterval?
/// Span captured when a pinch gesture begins.
var pinchAnchor: TimeInterval?
init() {
visibleSpan = nil
pinchAnchor = nil
}
/// Visible X-domain length in seconds; nil = show everything.
var spanSeconds: TimeInterval?
/// Start of the visible window (chart scroll position).
var startX: Date = .distantPast
/// (span, center) captured when a pinch begins.
var pinchAnchor: (span: TimeInterval, center: Date)?
static let minSpan: TimeInterval = 60 * 60 * 24 * 45 // ~1.5 months
private static let zoomStep = 0.6 // per button tap
/// Padding after the last point so it isn't clipped at the window edge.
static let endPadding: TimeInterval = 60 * 60 * 24 * 15 // half month
init() {}
var isZoomed: Bool { spanSeconds != nil }
/// The currently visible window, given the full data domain.
func visibleWindow(dates: [Date]) -> ClosedRange<Date>? {
guard let first = dates.first, let last = dates.last else { return nil }
guard let span = spanSeconds else { return first...last }
let start = max(first, min(startX, last))
let end = min(last, start.addingTimeInterval(span))
return start <= end ? start...end : first...last
}
func clamped(_ span: TimeInterval, fullSpan: TimeInterval) -> TimeInterval? {
let upper = max(fullSpan, Self.minSpan)
let upper = max(fullSpan + Self.endPadding, Self.minSpan)
let clamped = min(max(span, Self.minSpan), upper)
// Snap back to fit-all when zoomed (almost) fully out.
return clamped >= upper * 0.98 ? nil : clamped
}
mutating func zoomIn(fullSpan: TimeInterval) {
let current = visibleSpan ?? fullSpan
visibleSpan = clamped(current * Self.zoomStep, fullSpan: fullSpan)
}
mutating func zoomOut(fullSpan: TimeInterval) {
guard let current = visibleSpan else { return }
visibleSpan = clamped(current / Self.zoomStep, fullSpan: fullSpan)
}
mutating func reset() {
visibleSpan = nil
spanSeconds = nil
pinchAnchor = nil
}
}
// MARK: - Haptics
enum ChartHaptics {
private static let selection = UISelectionFeedbackGenerator()
private static let impact = UIImpactFeedbackGenerator(style: .light)
static func tick() { selection.selectionChanged() }
static func bump() { impact.impactOccurred() }
}
extension View {
/// Applies zoomable behavior to a Chart with a Date X axis.
/// - Parameters:
/// - dates: the full set of X values (used to compute the total span)
/// - zoom: binding to the chart's zoom state
/// Applies zoomable window behavior to a Chart with a Date X axis.
@ViewBuilder
func zoomableTimeSeries(dates: [Date], zoom: Binding<ChartZoomModel>) -> some View {
let fullSpan = ChartZoomHelper.fullSpan(of: dates)
self
.modifier(ZoomDomainModifier(zoom: zoom, fullSpan: fullSpan))
.simultaneousGesture(
MagnifyGesture()
.onChanged { value in
var model = zoom.wrappedValue
if model.pinchAnchor == nil {
model.pinchAnchor = model.visibleSpan ?? fullSpan
}
if let anchor = model.pinchAnchor, value.magnification > 0 {
model.visibleSpan = model.clamped(anchor / value.magnification, fullSpan: fullSpan)
}
zoom.wrappedValue = model
}
.onEnded { _ in
zoom.wrappedValue.pinchAnchor = nil
}
)
.overlay(alignment: .topTrailing) {
ChartZoomControls(zoom: zoom, fullSpan: fullSpan)
}
modifier(ZoomableTimeSeriesModifier(dates: dates, zoom: zoom))
}
}
enum ChartZoomHelper {
static func fullSpan(of dates: [Date]) -> TimeInterval {
private struct ZoomableTimeSeriesModifier: ViewModifier {
let dates: [Date]
@Binding var zoom: ChartZoomModel
private var fullSpan: TimeInterval {
guard let min = dates.min(), let max = dates.max(), max > min else {
return ChartZoomModel.minSpan
}
return max.timeIntervalSince(min)
}
}
private struct ZoomDomainModifier: ViewModifier {
@Binding var zoom: ChartZoomModel
let fullSpan: TimeInterval
func body(content: Content) -> some View {
if let span = zoom.visibleSpan {
content
.chartScrollableAxes(.horizontal)
.chartXVisibleDomain(length: span)
} else {
content
Group {
if let span = zoom.spanSeconds {
content
.chartScrollableAxes(.horizontal)
.chartXVisibleDomain(length: span)
.chartScrollPosition(x: $zoom.startX)
} else {
content
}
}
.simultaneousGesture(pinchGesture)
.simultaneousGesture(doubleTapGesture)
}
// Pinch anchored on the window center: the point you're looking at stays put.
private var pinchGesture: some Gesture {
MagnifyGesture()
.onChanged { value in
var model = zoom
if model.pinchAnchor == nil {
let window = model.visibleWindow(dates: dates)
let span = model.spanSeconds ?? (fullSpan + ChartZoomModel.endPadding)
let center = window.map {
$0.lowerBound.addingTimeInterval($0.upperBound.timeIntervalSince($0.lowerBound) / 2)
} ?? Date()
model.pinchAnchor = (span, center)
}
guard let anchor = model.pinchAnchor, value.magnification > 0 else { return }
let newSpan = model.clamped(anchor.span / value.magnification, fullSpan: fullSpan)
model.spanSeconds = newSpan
if let newSpan, let first = dates.first, let last = dates.last {
let start = anchor.center.addingTimeInterval(-newSpan / 2)
let maxStart = last.addingTimeInterval(-newSpan + ChartZoomModel.endPadding)
model.startX = max(first, min(start, maxStart))
}
zoom = model
}
.onEnded { _ in
zoom.pinchAnchor = nil
}
}
private var doubleTapGesture: some Gesture {
TapGesture(count: 2)
.onEnded {
guard zoom.isZoomed else { return }
withAnimation(.snappy) { zoom.reset() }
ChartHaptics.bump()
}
}
}
/// Compact +/- (and reset when zoomed) control cluster shown on top of the chart.
struct ChartZoomControls: View {
@Binding var zoom: ChartZoomModel
let fullSpan: TimeInterval
// MARK: - Range brush (full-history sparkline + month-snapping handles)
//
// Index-based over the months that actually have data. Bound to a
// ClosedRange<Int> selection owned by the caller (the charts container maps it
// to ChartsViewModel.customRange). Selecting everything = "no filter".
struct ChartRangeBrush: View {
let dates: [Date]
let values: [Double]
@Binding var selection: ClosedRange<Int>
@State private var dragStartIndices: (lo: Int, hi: Int)?
@State private var dragTarget: DragTarget?
@State private var lastTickedIndex: Int = -1
@State private var showingPickers = false
/// What a drag manipulates, decided ONCE at touch-down by where it starts.
/// One unified gesture instead of per-element gestures: with a narrow
/// window (e.g. 2 months) the handles' hit areas used to swallow the whole
/// window and moving it was impossible.
private enum DragTarget { case lowerHandle, upperHandle, moveWindow }
private static let labelFormatter: DateFormatter = {
let f = DateFormatter()
f.locale = .autoupdatingCurrent
f.setLocalizedDateFormatFromTemplate("MMM yyyy")
return f
}()
private var clampedSelection: (lo: Int, hi: Int) {
guard !dates.isEmpty else { return (0, 0) }
let lo = max(0, min(selection.lowerBound, dates.count - 1))
let hi = max(lo, min(selection.upperBound, dates.count - 1))
return (lo, hi)
}
var body: some View {
HStack(spacing: 0) {
controlButton(systemName: "minus.magnifyingglass", enabled: zoom.visibleSpan != nil) {
withAnimation(.easeOut(duration: 0.15)) { zoom.zoomOut(fullSpan: fullSpan) }
}
.accessibilityLabel(String(localized: "chart_zoom_out"))
Divider().frame(height: 16)
controlButton(systemName: "plus.magnifyingglass",
enabled: (zoom.visibleSpan ?? fullSpan) > ChartZoomModel.minSpan) {
withAnimation(.easeOut(duration: 0.15)) { zoom.zoomIn(fullSpan: fullSpan) }
}
.accessibilityLabel(String(localized: "chart_zoom_in"))
if zoom.visibleSpan != nil {
Divider().frame(height: 16)
controlButton(systemName: "arrow.counterclockwise", enabled: true) {
withAnimation(.easeOut(duration: 0.15)) { zoom.reset() }
}
.accessibilityLabel(String(localized: "chart_zoom_reset"))
VStack(spacing: 6) {
rangeLabel
GeometryReader { geo in
brushBody(width: geo.size.width)
}
.frame(height: 44)
}
.sheet(isPresented: $showingPickers) {
ChartRangePickerSheet(dates: dates, selection: $selection)
.presentationDetents([.height(320)])
}
.background(.ultraThinMaterial, in: Capsule())
.overlay(Capsule().stroke(Color.gray.opacity(0.2), lineWidth: 0.5))
.padding(6)
}
private func controlButton(systemName: String, enabled: Bool, action: @escaping () -> Void) -> some View {
Button(action: action) {
Image(systemName: systemName)
.font(.system(size: 13, weight: .medium))
.foregroundColor(enabled ? .appPrimary : .secondary.opacity(0.4))
.frame(width: 32, height: 28)
.contentShape(Rectangle())
private var rangeLabel: some View {
let (lo, hi) = clampedSelection
return Button {
showingPickers = true
} label: {
HStack(spacing: 5) {
Text("\(Self.labelFormatter.string(from: dates[lo])) \(Self.labelFormatter.string(from: dates[hi]))")
.font(.caption.weight(.semibold))
.foregroundStyle(Color.appPrimary)
Image(systemName: "chevron.up.chevron.down")
.font(.system(size: 8, weight: .semibold))
.foregroundStyle(.secondary)
}
}
.buttonStyle(.plain)
.disabled(!enabled)
.accessibilityLabel(String(localized: "chart_range_button"))
}
@ViewBuilder
private func brushBody(width: CGFloat) -> some View {
let (lo, hi) = clampedSelection
let xLo = xPosition(index: lo, width: width)
let xHi = xPosition(index: hi, width: width)
ZStack(alignment: .leading) {
// Full-history sparkline (widget-style: gradient line, no axes)
SparklinePath(values: values)
.stroke(
LinearGradient(colors: [.appPrimary, .cyan],
startPoint: .leading, endPoint: .trailing),
style: StrokeStyle(lineWidth: 1.8, lineCap: .round, lineJoin: .round)
)
.opacity(0.85)
// Dimming outside the window
Rectangle()
.fill(Color(.systemBackground).opacity(0.72))
.frame(width: max(0, xLo))
Rectangle()
.fill(Color(.systemBackground).opacity(0.72))
.frame(width: max(0, width - xHi))
.offset(x: xHi)
// Window frame
RoundedRectangle(cornerRadius: 6)
.stroke(Color.appPrimary.opacity(0.7), lineWidth: 1.4)
.background(
RoundedRectangle(cornerRadius: 6).fill(Color.appPrimary.opacity(0.06))
)
.frame(width: max(10, xHi - xLo))
.offset(x: xLo)
// Handles (visual only hit-testing lives in the unified gesture)
handle(at: xLo)
handle(at: xHi)
}
.contentShape(Rectangle())
.gesture(unifiedGesture(width: width))
}
private func handle(at x: CGFloat) -> some View {
Capsule()
.fill(Color.appPrimary)
.frame(width: 5, height: 26)
.overlay(
Capsule().stroke(Color(.systemBackground), lineWidth: 1.5)
)
.frame(height: 44)
.offset(x: x - 2.5)
.allowsHitTesting(false)
}
// MARK: Geometry helpers
private func xPosition(index: Int, width: CGFloat) -> CGFloat {
guard dates.count > 1 else { return 0 }
return width * CGFloat(index) / CGFloat(dates.count - 1)
}
private func index(atX x: CGFloat, width: CGFloat) -> Int {
guard dates.count > 1, width > 0 else { return 0 }
let frac = max(0, min(1, x / width))
return Int((frac * CGFloat(dates.count - 1)).rounded())
}
// MARK: Unified gesture
//
// Decided at touch-down: grabbing near a handle resizes that edge; grabbing
// anywhere else (inside the window OR on the dimmed history) slides the
// whole window keeping its width select JanMar, then swipe to land on
// JunAug. Narrow windows stay movable because the edge grip only wins in
// its 18pt zone (from outside when the window is too narrow to share).
private func unifiedGesture(width: CGFloat) -> some Gesture {
DragGesture(minimumDistance: 1)
.onChanged { value in
let (lo, hi) = dragStartIndices ?? clampedSelection
if dragStartIndices == nil {
dragStartIndices = (lo, hi)
dragTarget = target(
forX: value.startLocation.x,
xLo: xPosition(index: lo, width: width),
xHi: xPosition(index: hi, width: width)
)
}
guard let target = dragTarget else { return }
var newLo = lo
var newHi = hi
switch target {
case .lowerHandle:
newLo = min(index(atX: value.location.x, width: width), hi)
case .upperHandle:
newHi = max(index(atX: value.location.x, width: width), lo)
case .moveWindow:
let span = hi - lo
let deltaIdx = Int((value.translation.width / max(width, 1) * CGFloat(dates.count - 1)).rounded())
newLo = max(0, min(lo + deltaIdx, dates.count - 1 - span))
newHi = newLo + span
}
let ticked = (target == .upperHandle) ? newHi : newLo
if ticked != lastTickedIndex {
lastTickedIndex = ticked
ChartHaptics.tick()
}
selection = newLo...newHi
}
.onEnded { _ in
dragStartIndices = nil
dragTarget = nil
lastTickedIndex = -1
}
}
private func target(forX x: CGFloat, xLo: CGFloat, xHi: CGFloat) -> DragTarget {
let grip: CGFloat = 18
let windowWidth = xHi - xLo
if windowWidth >= grip * 3 {
// Wide window: edges grab from either side of the handle.
if abs(x - xLo) <= grip { return .lowerHandle }
if abs(x - xHi) <= grip { return .upperHandle }
} else {
// Narrow window: the inside belongs to MOVE; edges only from outside.
if x < xLo && xLo - x <= grip { return .lowerHandle }
if x > xHi && x - xHi <= grip { return .upperHandle }
}
return .moveWindow
}
}
/// Minimal normalized line path for the brush background.
private struct SparklinePath: Shape {
let values: [Double]
func path(in rect: CGRect) -> Path {
var path = Path()
guard values.count > 1 else { return path }
let minV = values.min() ?? 0
let maxV = values.max() ?? 1
let range = maxV - minV
let inset: CGFloat = 4
func point(_ i: Int) -> CGPoint {
let x = rect.width * CGFloat(i) / CGFloat(values.count - 1)
let norm = range > 0 ? (values[i] - minV) / range : 0.5
let y = inset + (rect.height - inset * 2) * (1 - CGFloat(norm))
return CGPoint(x: x, y: y)
}
path.move(to: point(0))
for i in 1..<values.count {
path.addLine(to: point(i))
}
return path
}
}
// MARK: - Precise month/year pickers
/// Two wheel pickers over the months that actually have data the precise,
/// non-gesture way to land exactly on e.g. FebMar 2025.
struct ChartRangePickerSheet: View {
let dates: [Date]
@Binding var selection: ClosedRange<Int>
@Environment(\.dismiss) private var dismiss
@State private var fromIndex = 0
@State private var toIndex = 0
private static let optionFormatter: DateFormatter = {
let f = DateFormatter()
f.locale = .autoupdatingCurrent
f.setLocalizedDateFormatFromTemplate("MMMM yyyy")
return f
}()
var body: some View {
NavigationStack {
HStack(spacing: 0) {
picker(title: String(localized: "chart_range_from"), selection: $fromIndex)
picker(title: String(localized: "chart_range_to"), selection: $toIndex)
}
.navigationTitle(String(localized: "chart_range_button"))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("OK") {
selection = min(fromIndex, toIndex)...max(fromIndex, toIndex)
ChartHaptics.bump()
dismiss()
}
}
}
}
.onAppear {
fromIndex = max(0, min(selection.lowerBound, dates.count - 1))
toIndex = max(fromIndex, min(selection.upperBound, dates.count - 1))
}
.onChange(of: fromIndex) { _, new in
if new > toIndex { toIndex = new }
}
.onChange(of: toIndex) { _, new in
if new < fromIndex { fromIndex = new }
}
}
private func picker(title: String, selection: Binding<Int>) -> some View {
VStack(spacing: 2) {
Text(title)
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
Picker(title, selection: selection) {
ForEach(dates.indices, id: \.self) { i in
Text(Self.optionFormatter.string(from: dates[i])).tag(i)
}
}
.pickerStyle(.wheel)
}
}
}
File diff suppressed because it is too large Load Diff
@@ -32,7 +32,7 @@ struct ComparisonChartView: View {
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
}
@@ -66,7 +66,7 @@ struct ComparisonChartView: View {
RoundedRectangle(cornerRadius: 16)
.stroke(isSelected ? chipColor : Color.clear, lineWidth: 1.5)
)
.cornerRadius(16)
.clipShape(RoundedRectangle(cornerRadius: 16))
}
.buttonStyle(.plain)
}
@@ -92,30 +92,28 @@ struct ComparisonChartView: View {
VStack(spacing: 12) {
Image(systemName: "chart.xyaxis.line")
.font(.system(size: 36))
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
Text("Select 2+ sources to compare")
.font(.subheadline)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity)
.frame(height: 260)
.chartFrame(height: 260)
}
private var noDataView: some View {
Text("No data available for selected sources in this period.")
.font(.subheadline)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.frame(maxWidth: .infinity)
.frame(height: 260)
.chartFrame(height: 260)
}
/// Permanent labels only when the chart stays readable (few points AND few series).
private var showAllLabels: Bool {
let maxPoints = viewModel.comparisonData.map { $0.points.count }.max() ?? 0
return maxPoints <= ChartLabels.alwaysShowThreshold && viewModel.comparisonData.count <= 3
}
/// Multi-series comparison: labelling every point of every series overlaps
/// badly. Instead we label only each series' END point (where it landed),
/// which is the value the comparison is actually about.
private func selectionRows(for date: Date) -> [(label: String, value: String, color: Color)] {
viewModel.comparisonData.compactMap { series in
@@ -131,6 +129,7 @@ struct ComparisonChartView: View {
Chart {
ForEach(viewModel.comparisonData, id: \.id) { series in
let seriesColor = Color(hex: series.colorHex) ?? .appPrimary
let lastDate = series.points.last?.date
ForEach(series.points, id: \.date) { point in
LineMark(
x: .value("Date", point.date),
@@ -145,9 +144,9 @@ struct ComparisonChartView: View {
y: .value("Value", point.value)
)
.foregroundStyle(seriesColor)
.symbolSize(showAllLabels ? 32 : 16)
.annotation(position: .top, spacing: 2) {
if showAllLabels {
.symbolSize(point.date == lastDate ? 30 : 14)
.annotation(position: .trailing, spacing: 2) {
if point.date == lastDate {
ChartValueBubble(text: yAxisLabel(for: point.value), color: seriesColor)
}
}
@@ -187,7 +186,7 @@ struct ComparisonChartView: View {
.foregroundStyle(Color.secondary.opacity(0.15))
}
}
.frame(height: 260)
.chartFrame(height: 260)
.chartDrawingGroup(disabledForExport: chartImageExport)
legend
@@ -204,7 +203,7 @@ struct ComparisonChartView: View {
.frame(width: 20, height: 3)
Text(series.name)
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
}
}
@@ -3,10 +3,13 @@ import Charts
struct DrawdownChart: View {
let data: [(date: Date, drawdown: Double)]
/// Hidden when the host (e.g. a dashboard card) already shows a title.
var showsTitle: Bool = true
@State private var zoom = ChartZoomModel()
@State private var selectedDate: Date?
@Environment(\.horizontalSizeClass) private var labelsSizeClass
private var showAllLabels: Bool { data.count <= ChartLabels.alwaysShowThreshold }
private var labelsCompact: Bool { labelsSizeClass != .regular }
private var selectedPoint: (date: Date, drawdown: Double)? {
guard let selectedDate else { return nil }
return data.min(by: {
@@ -25,28 +28,31 @@ struct DrawdownChart: View {
var body: some View {
VStack(alignment: .leading, spacing: 16) {
HStack {
Text("Drawdown Analysis")
.font(.headline)
if showsTitle {
Text("Drawdown Analysis")
.font(.headline)
}
Spacer()
VStack(alignment: .trailing, spacing: 2) {
Text("Max Drawdown")
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
Text(String(format: "%.1f%%", maxDrawdown))
.font(.subheadline.weight(.semibold))
.foregroundColor(.negativeRed)
.foregroundStyle(Color.negativeRed)
}
}
Text("Shows percentage decline from peak values")
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
if data.count >= 2 {
Chart {
ForEach(data, id: \.date) { item in
ForEach(Array(data.enumerated()), id: \.element.date) { pair in
let item = pair.element
AreaMark(
x: .value("Date", item.date),
y: .value("Drawdown", item.drawdown)
@@ -72,9 +78,9 @@ struct DrawdownChart: View {
y: .value("Drawdown", item.drawdown)
)
.foregroundStyle(Color.negativeRed)
.symbolSize(showAllLabels ? 36 : 20)
.symbolSize(20)
.annotation(position: .bottom, spacing: 3) {
if showAllLabels {
if ChartLabels.showsLabel(index: pair.offset, count: data.count, compact: labelsCompact) {
ChartValueBubble(text: ChartLabels.percent(item.drawdown), color: .negativeRed)
}
}
@@ -113,7 +119,7 @@ struct DrawdownChart: View {
}
}
.chartYScale(domain: (data.map { $0.drawdown }.min() ?? -50)...0)
.frame(height: 250)
.chartFrame(height: 250)
.zoomableTimeSeries(dates: data.map(\.date), zoom: $zoom)
// Statistics
@@ -146,14 +152,14 @@ struct DrawdownChart: View {
.padding(.top, 4)
} else {
Text("Not enough data for drawdown analysis")
.foregroundColor(.secondary)
.frame(height: 250)
.foregroundStyle(.secondary)
.chartFrame(height: 250)
.frame(maxWidth: .infinity)
}
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
}
@@ -191,11 +197,11 @@ struct DrawdownStatView: View {
VStack(spacing: 4) {
Text(title)
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
Text(value)
.font(.subheadline.weight(.semibold))
.foregroundColor(isHighlighted ? .negativeRed : .primary)
.foregroundStyle(isHighlighted ? Color.negativeRed : .primary)
}
.frame(maxWidth: .infinity)
}
@@ -205,10 +211,13 @@ struct DrawdownStatView: View {
struct VolatilityChartView: View {
let data: [(date: Date, volatility: Double)]
/// Hidden when the host (e.g. a dashboard card) already shows a title.
var showsTitle: Bool = true
@State private var zoom = ChartZoomModel()
@State private var selectedDate: Date?
@Environment(\.horizontalSizeClass) private var labelsSizeClass
private var showAllLabels: Bool { data.count <= ChartLabels.alwaysShowThreshold }
private var labelsCompact: Bool { labelsSizeClass != .regular }
private var selectedPoint: (date: Date, volatility: Double)? {
guard let selectedDate else { return nil }
return data.min(by: {
@@ -227,20 +236,23 @@ struct VolatilityChartView: View {
var body: some View {
VStack(alignment: .leading, spacing: 16) {
HStack {
Text("Volatility")
.font(.headline)
if showsTitle {
HStack {
Text("Volatility")
.font(.headline)
Spacer()
Spacer()
}
}
Text("Measures price variability over time")
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
if data.count >= 2 {
Chart {
ForEach(data, id: \.date) { item in
ForEach(Array(data.enumerated()), id: \.element.date) { pair in
let item = pair.element
LineMark(
x: .value("Date", item.date),
y: .value("Volatility", item.volatility)
@@ -266,9 +278,9 @@ struct VolatilityChartView: View {
y: .value("Volatility", item.volatility)
)
.foregroundStyle(Color.appPrimary)
.symbolSize(showAllLabels ? 36 : 20)
.symbolSize(20)
.annotation(position: .top, spacing: 3) {
if showAllLabels {
if ChartLabels.showsLabel(index: pair.offset, count: data.count, compact: labelsCompact) {
ChartValueBubble(text: String(format: "%.1f%%", item.volatility))
}
}
@@ -281,7 +293,7 @@ struct VolatilityChartView: View {
.annotation(position: .top, alignment: .leading) {
Text("Avg: \(String(format: "%.1f%%", averageVolatility))")
.font(.caption2)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
if let sel = selectedPoint {
@@ -316,7 +328,7 @@ struct VolatilityChartView: View {
}
}
}
.frame(height: 250)
.chartFrame(height: 250)
.zoomableTimeSeries(dates: data.map(\.date), zoom: $zoom)
ChartStatsRow(stats: [
@@ -334,14 +346,14 @@ struct VolatilityChartView: View {
)
} else {
Text("Not enough data for volatility analysis")
.foregroundColor(.secondary)
.frame(height: 250)
.foregroundStyle(.secondary)
.chartFrame(height: 250)
.frame(maxWidth: .infinity)
}
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
}
@@ -388,7 +400,7 @@ struct VolatilityLevelView: View {
.font(.caption2)
Text(range)
.font(.caption2)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
}
}
@@ -4,6 +4,16 @@ import Charts
struct PerformanceBarChart: View {
let data: [(category: String, cagr: Double, color: String)]
var title: String = "Performance by Category"
/// Drill-down: tapping a row jumps to Evolution filtered to it.
var onDrillDown: ((String) -> Void)? = nil
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
/// On iPhone, vertical bars collide once there are more than a handful of
/// categories (labels + annotations fight for width). Horizontal bars give
/// every name a full line.
private var useHorizontalBars: Bool {
horizontalSizeClass != .regular && data.count > 6
}
var body: some View {
VStack(alignment: .leading, spacing: 16) {
@@ -12,83 +22,151 @@ struct PerformanceBarChart: View {
Text("Compound Annual Growth Rate (CAGR)")
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
if !data.isEmpty {
ChartStatsRow(stats: perfStats)
}
if !data.isEmpty {
Chart(data, id: \.category) { item in
BarMark(
x: .value("Category", item.category),
y: .value("CAGR", item.cagr)
)
.foregroundStyle(Color(hex: item.color) ?? .gray)
.cornerRadius(4)
.annotation(position: item.cagr >= 0 ? .top : .bottom) {
Text(String(format: "%.1f%%", item.cagr))
.font(.caption2)
.foregroundColor(item.cagr >= 0 ? .positiveGreen : .negativeRed)
}
if useHorizontalBars {
horizontalChart
} else {
verticalChart
}
.chartXAxis {
AxisMarks { value in
AxisValueLabel {
if let category = value.as(String.self) {
Text(category)
.font(.caption)
.lineLimit(1)
}
}
}
}
.chartYAxis {
AxisMarks { value in
AxisGridLine()
AxisValueLabel {
if let doubleValue = value.as(Double.self) {
Text(String(format: "%.0f%%", doubleValue))
.font(.caption)
}
}
}
}
.frame(height: 250)
// Legend / Details
// Legend / Details rows drill down when the owner wires it up.
VStack(spacing: 8) {
ForEach(data.sorted(by: { $0.cagr > $1.cagr }), id: \.category) { item in
HStack {
Circle()
.fill(Color(hex: item.color) ?? .gray)
.frame(width: 10, height: 10)
Text(item.category)
.font(.subheadline)
Spacer()
Text(String(format: "%.2f%%", item.cagr))
.font(.subheadline.weight(.semibold))
.foregroundColor(item.cagr >= 0 ? .positiveGreen : .negativeRed)
}
legendRow(item)
}
}
.padding(.top, 8)
} else {
Text("No performance data available")
.foregroundColor(.secondary)
.frame(height: 250)
.foregroundStyle(.secondary)
.chartFrame(height: 250)
.frame(maxWidth: .infinity)
}
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
}
private var verticalChart: some View {
Chart(data, id: \.category) { item in
BarMark(
x: .value("Category", item.category),
y: .value("CAGR", item.cagr)
)
.foregroundStyle(Color(hex: item.color) ?? .gray)
.clipShape(RoundedRectangle(cornerRadius: 4))
.annotation(position: item.cagr >= 0 ? .top : .bottom) {
Text(String(format: "%.1f%%", item.cagr))
.font(.caption2)
.foregroundStyle(item.cagr >= 0 ? Color.positiveGreen : Color.negativeRed)
}
}
.chartXAxis {
AxisMarks { value in
AxisValueLabel {
if let category = value.as(String.self) {
Text(category)
.font(.caption)
.lineLimit(1)
}
}
}
}
.chartYAxis {
AxisMarks { value in
AxisGridLine()
AxisValueLabel {
if let doubleValue = value.as(Double.self) {
Text(String(format: "%.0f%%", doubleValue))
.font(.caption)
}
}
}
}
.chartFrame(height: 250)
}
private var horizontalChart: some View {
Chart(data.sorted(by: { $0.cagr > $1.cagr }), id: \.category) { item in
BarMark(
x: .value("CAGR", item.cagr),
y: .value("Category", item.category)
)
.foregroundStyle(Color(hex: item.color) ?? .gray)
.clipShape(RoundedRectangle(cornerRadius: 4))
.annotation(position: item.cagr >= 0 ? .trailing : .leading) {
Text(String(format: "%.1f%%", item.cagr))
.font(.caption2)
.foregroundStyle(item.cagr >= 0 ? Color.positiveGreen : Color.negativeRed)
}
}
.chartXAxis {
AxisMarks { value in
AxisGridLine()
AxisValueLabel {
if let doubleValue = value.as(Double.self) {
Text(String(format: "%.0f%%", doubleValue))
.font(.caption)
}
}
}
}
.chartYAxis {
AxisMarks { value in
AxisValueLabel {
if let category = value.as(String.self) {
Text(category)
.font(.caption)
.lineLimit(1)
}
}
}
}
.frame(height: CGFloat(data.count) * 34 + 40)
}
@ViewBuilder
private func legendRow(_ item: (category: String, cagr: Double, color: String)) -> some View {
let row = HStack {
Circle()
.fill(Color(hex: item.color) ?? .gray)
.frame(width: 10, height: 10)
Text(item.category)
.font(.subheadline)
Spacer()
Text(String(format: "%.2f%%", item.cagr))
.font(.subheadline.weight(.semibold))
.foregroundStyle(item.cagr >= 0 ? Color.positiveGreen : Color.negativeRed)
if onDrillDown != nil {
Image(systemName: "chevron.right")
.font(.caption2.weight(.semibold))
.foregroundStyle(.secondary)
}
}
if let onDrillDown {
Button {
onDrillDown(item.category)
} label: {
row.contentShape(Rectangle())
}
.buttonStyle(.plain)
} else {
row
}
}
private var perfStats: [ChartStat] {
guard !data.isEmpty else { return [] }
let values = data.map { $0.cagr }
@@ -107,6 +185,8 @@ struct PerformanceBarChart: View {
struct HorizontalPerformanceChart: View {
let data: [(category: String, cagr: Double, color: String)]
/// Hidden when the host (e.g. a dashboard card) already shows a title.
var showsTitle: Bool = true
var sortedData: [(category: String, cagr: Double, color: String)] {
data.sorted { $0.cagr > $1.cagr }
@@ -118,8 +198,10 @@ struct HorizontalPerformanceChart: View {
var body: some View {
VStack(alignment: .leading, spacing: 16) {
Text("Performance by Category")
.font(.headline)
if showsTitle {
Text("Performance by Category")
.font(.headline)
}
ForEach(sortedData, id: \.category) { item in
VStack(alignment: .leading, spacing: 4) {
@@ -131,7 +213,7 @@ struct HorizontalPerformanceChart: View {
Text(String(format: "%.2f%%", item.cagr))
.font(.subheadline.weight(.semibold))
.foregroundColor(item.cagr >= 0 ? .positiveGreen : .negativeRed)
.foregroundStyle(item.cagr >= 0 ? Color.positiveGreen : Color.negativeRed)
}
GeometryReader { geometry in
@@ -142,12 +224,12 @@ struct HorizontalPerformanceChart: View {
Rectangle()
.fill(Color.gray.opacity(0.1))
.frame(height: 8)
.cornerRadius(4)
.clipShape(RoundedRectangle(cornerRadius: 4))
Rectangle()
.fill(item.cagr >= 0 ? Color.positiveGreen : Color.negativeRed)
.frame(width: barWidth, height: 8)
.cornerRadius(4)
.clipShape(RoundedRectangle(cornerRadius: 4))
}
}
.frame(height: 8)
@@ -156,7 +238,7 @@ struct HorizontalPerformanceChart: View {
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
}
}
@@ -6,10 +6,6 @@ struct PeriodComparisonChartView: View {
@State private var selectedMonth: Int?
/// Period charts are short by design (a handful of months) labels always help.
private var showAllLabels: Bool {
(viewModel.periodComparisonData.map { $0.points.count }.max() ?? 0) <= ChartLabels.alwaysShowThreshold
}
private func selectionRows(for month: Int) -> [(label: String, value: String, color: Color)] {
viewModel.periodComparisonData.compactMap { series in
guard let point = series.points.first(where: { $0.monthOffset == month }) else { return nil }
@@ -32,7 +28,7 @@ struct PeriodComparisonChartView: View {
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
}
@@ -53,7 +49,7 @@ struct PeriodComparisonChartView: View {
endPoint: .bottomTrailing
)
)
.cornerRadius(12)
.clipShape(RoundedRectangle(cornerRadius: 12))
.overlay(
RoundedRectangle(cornerRadius: 12)
.stroke(Color.secondary.opacity(0.1), lineWidth: 1)
@@ -68,7 +64,7 @@ struct PeriodComparisonChartView: View {
Text(label)
.font(.caption.weight(.bold))
.foregroundColor(color)
.foregroundStyle(color)
.frame(width: 60, alignment: .leading)
DatePicker(
@@ -85,7 +81,7 @@ struct PeriodComparisonChartView: View {
Text("")
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
DatePicker(
"",
@@ -136,28 +132,28 @@ struct PeriodComparisonChartView: View {
.frame(width: 6, height: 6)
Text(series.label)
.font(.caption2.weight(.medium))
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
.lineLimit(1)
}
Text(String(format: "%+.2f%%", finalReturn))
.font(.title2.weight(.bold))
.foregroundColor(finalReturn >= 0 ? .positiveGreen : .negativeRed)
.foregroundStyle(finalReturn >= 0 ? Color.positiveGreen : Color.negativeRed)
HStack(spacing: 10) {
HStack(spacing: 3) {
Image(systemName: "arrow.up")
.font(.caption2.weight(.semibold))
.foregroundColor(.positiveGreen)
.foregroundStyle(Color.positiveGreen)
Text(String(format: "%.1f%%", maxReturn))
.font(.caption2)
.foregroundColor(.positiveGreen)
.foregroundStyle(Color.positiveGreen)
}
HStack(spacing: 3) {
Image(systemName: "arrow.down")
.font(.caption2.weight(.semibold))
.foregroundColor(.negativeRed)
.foregroundStyle(Color.negativeRed)
Text(String(format: "%.1f%%", minReturn))
.font(.caption2)
.foregroundColor(.negativeRed)
.foregroundStyle(Color.negativeRed)
}
}
}
@@ -179,14 +175,14 @@ struct PeriodComparisonChartView: View {
VStack(spacing: 12) {
Image(systemName: "calendar.badge.clock")
.font(.system(size: 36))
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
Text("No data available for the selected periods.")
.font(.subheadline)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity)
.frame(height: 260)
.chartFrame(height: 260)
}
// MARK: - Flat data for Chart
@@ -217,8 +213,18 @@ struct PeriodComparisonChartView: View {
return result
}
/// Last month offset per series used to label only each series' endpoint.
private var lastOffsetBySeries: [String: Int] {
var m: [String: Int] = [:]
for s in viewModel.periodComparisonData {
m[s.id] = s.points.map(\.monthOffset).max()
}
return m
}
private var periodChart: some View {
let points = flatPoints
let endpoints = lastOffsetBySeries
let seriesIds = viewModel.periodComparisonData.map { $0.id }
let seriesColors: [Color] = viewModel.periodComparisonData.map {
Color(hex: $0.colorHex) ?? .gray
@@ -240,10 +246,10 @@ struct PeriodComparisonChartView: View {
y: .value("Return", point.returnPct)
)
.foregroundStyle(by: .value("Period", point.seriesLabel))
.symbolSize(showAllLabels ? 40 : 30)
.symbolSize(point.monthOffset == endpoints[point.seriesId] ? 38 : 22)
.annotation(position: point.seriesLabel == viewModel.periodComparisonData.first?.label ? .top : .bottom,
spacing: 3) {
if showAllLabels {
if point.monthOffset == endpoints[point.seriesId] {
ChartValueBubble(
text: ChartLabels.percent(point.returnPct),
color: Color(hex: point.colorHex) ?? .gray
@@ -297,7 +303,7 @@ struct PeriodComparisonChartView: View {
.foregroundStyle(Color.secondary.opacity(0.15))
}
}
.frame(height: 260)
.chartFrame(height: 260)
.chartDrawingGroup(disabledForExport: chartImageExport)
.onChange(of: seriesIds) { _, _ in }
}
@@ -311,7 +317,7 @@ struct PeriodComparisonChartView: View {
.frame(width: 20, height: 3)
Text(series.label)
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
}
Spacer()
@@ -4,11 +4,12 @@ import Charts
struct PredictionChartView: View {
let predictions: [Prediction]
let historicalData: [(date: Date, value: Decimal)]
/// Hidden when the host (e.g. a dashboard card) already shows a title.
var showsTitle: Bool = true
@State private var selectedDate: Date?
@Environment(\.horizontalSizeClass) private var labelsSizeClass
private var showAllLabels: Bool {
historicalData.count + predictions.count <= ChartLabels.alwaysShowThreshold
}
private var labelsCompact: Bool { labelsSizeClass != .regular }
/// Nearest point (historical or predicted) to the selected date.
private var selectedPoint: (date: Date, value: Decimal, isPrediction: Bool)? {
@@ -24,8 +25,10 @@ struct PredictionChartView: View {
var body: some View {
VStack(alignment: .leading, spacing: 16) {
HStack {
Text(predictions.isEmpty ? "Prediction" : "\(predictions.count)-Month Prediction")
.font(.headline)
if showsTitle {
Text(predictions.isEmpty ? "Prediction" : "\(predictions.count)-Month Prediction")
.font(.headline)
}
Spacer()
@@ -33,10 +36,10 @@ struct PredictionChartView: View {
VStack(alignment: .trailing, spacing: 2) {
Text("Forecast")
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
Text(lastPrediction.formattedValue)
.font(.subheadline.weight(.semibold))
.foregroundColor(.appPrimary)
.foregroundStyle(Color.appPrimary)
}
}
}
@@ -44,13 +47,14 @@ struct PredictionChartView: View {
if let algorithm = predictions.first?.algorithm {
Text("Algorithm: \(algorithm.displayName)")
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
if !predictions.isEmpty && historicalData.count >= 2 {
Chart {
// Historical data
ForEach(historicalData, id: \.date) { item in
// Historical data thin labels to at most a handful.
ForEach(Array(historicalData.enumerated()), id: \.element.date) { pair in
let item = pair.element
LineMark(
x: .value("Date", item.date),
y: .value("Value", NSDecimalNumber(decimal: item.value).doubleValue)
@@ -63,9 +67,9 @@ struct PredictionChartView: View {
y: .value("Value", NSDecimalNumber(decimal: item.value).doubleValue)
)
.foregroundStyle(Color.appPrimary)
.symbolSize(showAllLabels ? 36 : 24)
.symbolSize(24)
.annotation(position: .top, spacing: 3) {
if showAllLabels {
if ChartLabels.showsLabel(index: pair.offset, count: historicalData.count, compact: labelsCompact) {
ChartValueBubble(text: item.value.compactCurrencyString)
}
}
@@ -95,9 +99,10 @@ struct PredictionChartView: View {
y: .value("Predicted", NSDecimalNumber(decimal: prediction.predictedValue).doubleValue)
)
.foregroundStyle(Color.appSecondary)
.symbolSize(showAllLabels ? 36 : 24)
.symbolSize(prediction.id == predictions.last?.id ? 34 : 24)
.annotation(position: .top, spacing: 3) {
if showAllLabels {
// Only the final forecast point (the KPIs cover the rest).
if prediction.id == predictions.last?.id {
ChartValueBubble(text: prediction.predictedValue.compactCurrencyString, color: .appSecondary)
}
}
@@ -180,7 +185,7 @@ struct PredictionChartView: View {
}
}
}
.frame(height: 280)
.chartFrame(height: 280)
// Stats row
if let currentValue = historicalData.last?.value,
@@ -201,7 +206,7 @@ struct PredictionChartView: View {
.frame(width: 20, height: 3)
Text("Historical")
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
HStack(spacing: 6) {
@@ -218,7 +223,7 @@ struct PredictionChartView: View {
)
Text("Prediction")
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
HStack(spacing: 6) {
@@ -227,7 +232,7 @@ struct PredictionChartView: View {
.frame(width: 20, height: 10)
Text("Confidence")
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
HStack(spacing: 6) {
@@ -236,7 +241,7 @@ struct PredictionChartView: View {
.frame(width: 20, height: 2)
Text("Bull")
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
HStack(spacing: 6) {
@@ -245,7 +250,7 @@ struct PredictionChartView: View {
.frame(width: 20, height: 2)
Text("Bear")
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
}
@@ -269,7 +274,7 @@ struct PredictionChartView: View {
Spacer()
Text(lastPrediction.formattedConfidenceRange)
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
if let currentValue = historicalData.last?.value {
@@ -288,7 +293,7 @@ struct PredictionChartView: View {
Text(String(format: "%+.1f%%", changePercent))
.font(.subheadline.weight(.medium))
}
.foregroundColor(change >= 0 ? .positiveGreen : .negativeRed)
.foregroundStyle(change >= 0 ? Color.positiveGreen : Color.negativeRed)
}
}
}
@@ -305,24 +310,24 @@ struct PredictionChartView: View {
VStack(spacing: 12) {
Image(systemName: "wand.and.stars")
.font(.system(size: 40))
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
Text("Not enough data for predictions")
.font(.subheadline)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
Text("Add at least 3 snapshots to generate predictions")
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
.frame(height: 280)
.chartFrame(height: 280)
.frame(maxWidth: .infinity)
}
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
}
@@ -3,6 +3,8 @@ import Charts
struct YearOverYearChartView: View {
@ObservedObject var viewModel: ChartsViewModel
/// Hidden when the host (e.g. a dashboard card) already shows a title.
var showsTitle: Bool = true
@State private var selectedMonthIdx: Int?
private let monthAbbreviations = ["Jan", "Feb", "Mar", "Apr", "May", "Jun",
@@ -20,15 +22,17 @@ struct YearOverYearChartView: View {
var body: some View {
VStack(alignment: .leading, spacing: 16) {
Text(String(localized: "chart_yoy_title"))
.font(.headline)
if showsTitle {
Text(String(localized: "chart_yoy_title"))
.font(.headline)
}
if allYears.isEmpty {
Text(String(localized: "chart_yoy_empty"))
.font(.subheadline)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .center)
.frame(height: 200)
.chartFrame(height: 200)
} else {
yearSelector
if !selectedSeries.isEmpty {
@@ -40,7 +44,7 @@ struct YearOverYearChartView: View {
if hasEstimate {
Text(String(localized: "chart_yoy_estimated_note"))
.font(.caption2)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
// Detalle debajo (#147)
chartBody
@@ -53,7 +57,7 @@ struct YearOverYearChartView: View {
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
}
@@ -78,12 +82,12 @@ struct YearOverYearChartView: View {
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(isSelected ? color.opacity(0.15) : Color.gray.opacity(0.1))
.foregroundColor(isSelected ? color : .secondary)
.foregroundStyle(isSelected ? color : .secondary)
.overlay(
RoundedRectangle(cornerRadius: 16)
.stroke(isSelected ? color : Color.clear, lineWidth: 1.5)
)
.cornerRadius(16)
.clipShape(RoundedRectangle(cornerRadius: 16))
}
.buttonStyle(.plain)
}
@@ -93,8 +97,11 @@ struct YearOverYearChartView: View {
// MARK: - Chart
/// Two selected years × 12 months stays readable with permanent labels.
private var showAllLabels: Bool { selectedSeries.count <= 2 }
/// Last month index that has data, per series id label only that endpoint
/// (the year's cumulative return) to avoid 24 overlapping labels.
private func lastValidMonth(_ series: ChartsViewModel.YearSeries) -> Int? {
(0..<12).last { !series.values[$0].isNaN }
}
private func selectionRows(for month: Int) -> [(label: String, value: String, color: Color)] {
selectedSeries.compactMap { series in
@@ -113,6 +120,7 @@ struct YearOverYearChartView: View {
let idx = allYears.firstIndex(where: { $0.id == yearSeries.id }) ?? 0
let color = lineColors[idx % lineColors.count]
let seriesPosition = selectedSeries.firstIndex(where: { $0.id == yearSeries.id }) ?? 0
let endMonth = lastValidMonth(yearSeries)
ForEach(0..<12, id: \.self) { monthIdx in
let value = yearSeries.values[monthIdx]
if !value.isNaN {
@@ -129,9 +137,9 @@ struct YearOverYearChartView: View {
y: .value("Return", value)
)
.foregroundStyle(color)
.symbolSize(showAllLabels ? 32 : 18)
.symbolSize(monthIdx == endMonth ? 34 : 18)
.annotation(position: seriesPosition == 0 ? .top : .bottom, spacing: 2) {
if showAllLabels {
if monthIdx == endMonth {
ChartValueBubble(text: ChartLabels.percent(value), color: color)
}
}
@@ -173,11 +181,11 @@ struct YearOverYearChartView: View {
AxisGridLine()
}
}
.frame(height: 220)
.chartFrame(height: 220)
} else {
Text("iOS 16+ required for chart")
.foregroundColor(.secondary)
.frame(height: 220)
.foregroundStyle(.secondary)
.chartFrame(height: 220)
}
}
@@ -194,7 +202,7 @@ struct YearOverYearChartView: View {
.frame(width: 20, height: 3)
Text(String(yearSeries.year))
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
}
}
@@ -213,16 +221,16 @@ struct YearOverYearChartView: View {
VStack(alignment: .center, spacing: 3) {
Text(String(yearSeries.year))
.font(.caption2)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
Text(String(format: "%+.1f%%", end.value) + (end.estimated ? "*" : ""))
.font(.subheadline.weight(.semibold))
.foregroundColor(end.value >= 0 ? color : .negativeRed)
.foregroundStyle(end.value >= 0 ? color : Color.negativeRed)
}
.frame(minWidth: 60)
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(Color(.systemGray6))
.cornerRadius(8)
.clipShape(RoundedRectangle(cornerRadius: 8))
}
}
}
@@ -0,0 +1,39 @@
import SwiftUI
// MARK: - Chart height scaling
//
// Charts declare a base height for compact width. Containers with a wider
// canvas (regular width: iPad, iPhone Duo inner screen, Mac windows) raise the
// scale so plots gain vertical room instead of just stretching horizontally.
extension EnvironmentValues {
@Entry var chartHeightScale: CGFloat = 1
}
private struct ChartFrameModifier: ViewModifier {
@Environment(\.chartHeightScale) private var scale
let base: CGFloat
func body(content: Content) -> some View {
content.frame(height: base * scale)
}
}
extension View {
/// Fixed chart height that follows `chartHeightScale` from the environment.
func chartFrame(height: CGFloat) -> some View {
modifier(ChartFrameModifier(base: height))
}
/// Floating label background: Liquid Glass on iOS 26+, material fallback.
@ViewBuilder
func floatingPillBackground() -> some View {
if #available(iOS 26.0, *) {
self.glassEffect(.regular, in: .capsule)
} else {
self
.background(.regularMaterial, in: Capsule())
.shadow(color: .black.opacity(0.12), radius: 6, y: 2)
}
}
}
@@ -1,37 +1,13 @@
import SwiftUI
// Calm 2.0: plain system grouped background depth comes from content
// hierarchy, not decoration (the old gradient + overflowing shapes also
// intercepted touches on sibling views; see 1.4.2 build 50).
struct AppBackground: View {
var body: some View {
GeometryReader { proxy in
ZStack {
LinearGradient(
colors: [
Color.appPrimary.opacity(0.15),
Color.appSecondary.opacity(0.12),
Color.appAccent.opacity(0.1)
],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
Circle()
.fill(Color.appPrimary.opacity(0.18))
.frame(width: proxy.size.width * 0.7)
.offset(x: -proxy.size.width * 0.35, y: -proxy.size.height * 0.35)
RoundedRectangle(cornerRadius: 120, style: .continuous)
.fill(Color.appAccent.opacity(0.12))
.frame(width: proxy.size.width * 0.8, height: proxy.size.height * 0.35)
.rotationEffect(.degrees(-12))
.offset(x: proxy.size.width * 0.2, y: proxy.size.height * 0.45)
}
Color(.systemGroupedBackground)
.ignoresSafeArea()
}
// Purely decorative must never intercept touches. The circle overflows
// its panel's bounds (offset -35% width), and in side-by-side layouts it
// floats OVER sibling views (e.g. the Charts sidebar on iPad), silently
// swallowing every tap underneath.
.allowsHitTesting(false)
.allowsHitTesting(false)
}
}
@@ -0,0 +1,78 @@
import SwiftUI
/// A view modifier that redacts monetary amounts when the "Hide balances"
/// privacy feature is active.
///
/// When `hidden` is true the underlying content is blurred and its value masked
/// with a bullet string, while keeping the original layout footprint so the UI
/// doesn't jump. When false, the real content shows through untouched.
///
/// Usage:
/// ```swift
/// Text(viewModel.formattedTotalValue)
/// .hiddenBalance() // observes BalancePrivacyManager.shared
/// // or explicitly:
/// Text(amount).hiddenBalance(isHidden)
/// ```
struct HiddenBalanceModifier: ViewModifier {
let hidden: Bool
func body(content: Content) -> some View {
content
.opacity(hidden ? 0 : 1)
.overlay {
if hidden {
// Bullet mask sized to the underlying content so layout width
// stays roughly stable and the amount is fully obscured.
GeometryReader { proxy in
Text("••••••")
.redacted(reason: .placeholder)
.blur(radius: 6)
.frame(width: proxy.size.width, height: proxy.size.height)
.allowsHitTesting(false)
}
}
}
.accessibilityLabel(hidden ? Text("Balance hidden") : Text(""))
}
}
/// A self-observing variant of `HiddenBalanceModifier` that subscribes to the
/// shared `BalancePrivacyManager` so any monetary label updates live when the
/// user toggles visibility without threading the flag through view params.
struct ObservingHiddenBalanceModifier: ViewModifier {
@ObservedObject private var privacy = BalancePrivacyManager.shared
func body(content: Content) -> some View {
content.modifier(HiddenBalanceModifier(hidden: privacy.balancesHidden))
}
}
extension View {
/// Redact this monetary label with an explicit hidden flag. Use inside views
/// that already observe `BalancePrivacyManager`.
func hiddenBalance(_ hidden: Bool) -> some View {
modifier(HiddenBalanceModifier(hidden: hidden))
}
/// Redact this monetary label, self-observing the shared privacy manager.
/// Use anywhere without threading the flag through view parameters.
func hiddenBalance() -> some View {
modifier(ObservingHiddenBalanceModifier())
}
}
/// A tap-to-reveal wrapper for the quick eye toggle. Renders an eye / eye.slash
/// button bound to the shared `BalancePrivacyManager`.
struct BalanceVisibilityToggle: View {
@ObservedObject var privacy = BalancePrivacyManager.shared
var body: some View {
Button {
privacy.toggleHidden()
} label: {
Image(systemName: privacy.balancesHidden ? "eye.slash" : "eye")
}
.accessibilityLabel(privacy.balancesHidden ? Text("Show balances") : Text("Hide balances"))
}
}
@@ -10,7 +10,7 @@ struct LoadingView: View {
Text(message)
.font(.subheadline)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color(.systemBackground))
@@ -30,14 +30,14 @@ struct AppLaunchLoadingView: View {
.frame(width: 140, height: 140)
.padding(16)
.background(Color.appPrimary.opacity(0.08))
.cornerRadius(28)
.clipShape(RoundedRectangle(cornerRadius: 28))
ProgressView()
.scaleEffect(1.2)
Text(messageKey)
.font(.subheadline)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
Spacer()
}
@@ -82,27 +82,27 @@ struct SkeletonCardView: View {
VStack(alignment: .leading, spacing: 12) {
SkeletonView()
.frame(width: 100, height: 16)
.cornerRadius(4)
.clipShape(RoundedRectangle(cornerRadius: 4))
SkeletonView()
.frame(height: 32)
.cornerRadius(4)
.clipShape(RoundedRectangle(cornerRadius: 4))
HStack {
SkeletonView()
.frame(width: 80, height: 14)
.cornerRadius(4)
.clipShape(RoundedRectangle(cornerRadius: 4))
Spacer()
SkeletonView()
.frame(width: 60, height: 14)
.cornerRadius(4)
.clipShape(RoundedRectangle(cornerRadius: 4))
}
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
}
}
@@ -119,14 +119,14 @@ struct EmptyStateView: View {
VStack(spacing: 20) {
Image(systemName: icon)
.font(.system(size: 60))
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
Text(title)
.font(.title2.weight(.semibold))
Text(message)
.font(.subheadline)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal, 40)
@@ -134,11 +134,11 @@ struct EmptyStateView: View {
Button(action: action) {
Text(actionTitle)
.font(.headline)
.foregroundColor(.white)
.foregroundStyle(.white)
.padding()
.frame(maxWidth: 200)
.background(Color.appPrimary)
.cornerRadius(AppConstants.UI.cornerRadius)
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
}
}
}
@@ -156,14 +156,14 @@ struct ErrorView: View {
VStack(spacing: 16) {
Image(systemName: "exclamationmark.triangle.fill")
.font(.system(size: 48))
.foregroundColor(.appWarning)
.foregroundStyle(Color.appWarning)
Text("Something went wrong")
.font(.headline)
Text(message)
.font(.subheadline)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal, 40)
@@ -174,8 +174,8 @@ struct ErrorView: View {
.padding(.horizontal, 20)
.padding(.vertical, 10)
.background(Color.appPrimary)
.foregroundColor(.white)
.cornerRadius(20)
.foregroundStyle(.white)
.clipShape(RoundedRectangle(cornerRadius: 20))
}
}
}
@@ -199,7 +199,7 @@ struct SuccessView: View {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 60))
.foregroundColor(.positiveGreen)
.foregroundStyle(Color.positiveGreen)
}
Text(title)
@@ -207,7 +207,7 @@ struct SuccessView: View {
Text(message)
.font(.subheadline)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal, 40)
@@ -215,11 +215,11 @@ struct SuccessView: View {
Button(action: action) {
Text("Continue")
.font(.headline)
.foregroundColor(.white)
.foregroundStyle(.white)
.padding()
.frame(maxWidth: 200)
.background(Color.appPrimary)
.cornerRadius(AppConstants.UI.cornerRadius)
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
}
}
}
@@ -255,14 +255,14 @@ struct ToastView: View {
var body: some View {
HStack(spacing: 12) {
Image(systemName: type.icon)
.foregroundColor(type.color)
.foregroundStyle(type.color)
Text(message)
.font(.subheadline)
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
.shadow(color: .black.opacity(0.1), radius: 10, y: 5)
}
}
@@ -0,0 +1,30 @@
import SwiftUI
/// Fitness-style progress ring: track + rounded-cap arc + centered percentage.
/// Color is semantic (pace/achievement), passed by the caller.
struct ProgressRing: View {
let progress: Double
var tint: Color = .appPrimary
var size: CGFloat = 56
var lineWidth: CGFloat = 6
private var clamped: Double { min(max(progress, 0), 1) }
var body: some View {
ZStack {
Circle()
.stroke(tint.opacity(0.15), lineWidth: lineWidth)
Circle()
.trim(from: 0, to: clamped)
.stroke(tint, style: StrokeStyle(lineWidth: lineWidth, lineCap: .round))
.rotationEffect(.degrees(-90))
.animation(.easeOut(duration: 0.6), value: clamped)
Text("\(Int((clamped * 100).rounded()))%")
.font(.system(size: size * 0.26, weight: .bold, design: .rounded))
.foregroundStyle(tint)
.minimumScaleFactor(0.7)
}
.frame(width: size, height: size)
.accessibilityLabel(Text("\(Int((clamped * 100).rounded()))%"))
}
}
@@ -17,7 +17,7 @@ struct CategoryBreakdownCard: View {
} label: {
Text("See All")
.font(.subheadline)
.foregroundColor(.appPrimary)
.foregroundStyle(Color.appPrimary)
}
}
@@ -27,7 +27,7 @@ struct CategoryBreakdownCard: View {
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
}
}
@@ -55,7 +55,7 @@ struct CategoryRowView: View {
Image(systemName: category.icon)
.font(.system(size: 14))
.foregroundColor(Color(hex: category.colorHex) ?? .gray)
.foregroundStyle(Color(hex: category.colorHex) ?? .gray)
}
// Name and percentage
@@ -65,12 +65,12 @@ struct CategoryRowView: View {
Text(category.formattedPercentage)
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
if let target = targetPercentage {
Text("Target \(String(format: "%.0f%%", target)) | Drift \(driftText ?? "")")
.font(.caption2)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
}
@@ -84,10 +84,10 @@ struct CategoryRowView: View {
HStack(spacing: 4) {
Text("CAGR")
.font(.caption2)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
Text(category.metrics.formattedCAGR)
.font(.caption.weight(.semibold))
.foregroundColor(category.metrics.cagr >= 0 ? .positiveGreen : .negativeRed)
.foregroundStyle(category.metrics.cagr >= 0 ? Color.positiveGreen : Color.negativeRed)
}
}
}
@@ -122,7 +122,7 @@ struct CategoryProgressBar: View {
Text(category.formattedPercentage)
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
GeometryReader { geometry in
@@ -130,12 +130,12 @@ struct CategoryProgressBar: View {
Rectangle()
.fill(Color.gray.opacity(0.1))
.frame(height: 6)
.cornerRadius(3)
.clipShape(RoundedRectangle(cornerRadius: 3))
Rectangle()
.fill(Color(hex: category.colorHex) ?? .gray)
.frame(width: geometry.size.width * progress, height: 6)
.cornerRadius(3)
.clipShape(RoundedRectangle(cornerRadius: 3))
}
}
.frame(height: 6)
@@ -166,7 +166,7 @@ struct SimpleCategoryList: View {
Text("(\(category.formattedPercentage))")
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,165 @@
import SwiftUI
/// Dismissible banner shown on the Dashboard when internal snapshot gaps exist.
/// The evolution chart smooths these over, so without this the user would never
/// know real data is missing. Tapping opens `DataGapsSheet` to fill them.
struct DataGapsBanner: View {
let totalMissingMonths: Int
let onTap: () -> Void
let onDismiss: () -> Void
var body: some View {
Button(action: onTap) {
HStack(spacing: 12) {
Image(systemName: "exclamationmark.triangle.fill")
.font(.title3)
.foregroundStyle(.orange)
VStack(alignment: .leading, spacing: 2) {
Text(String(format: String(localized: "gaps_banner_title"), totalMissingMonths))
.font(.subheadline.weight(.semibold))
.foregroundStyle(.primary)
.multilineTextAlignment(.leading)
Text("Fill in missing data")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Image(systemName: "chevron.right")
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
}
.padding(14)
.background(Color(.secondarySystemGroupedBackground))
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
.overlay(
RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius, style: .continuous)
.stroke(Color.orange.opacity(0.35), lineWidth: 1)
)
}
.buttonStyle(.plain)
.overlay(alignment: .topTrailing) {
Button(action: onDismiss) {
Image(systemName: "xmark.circle.fill")
.font(.body)
.foregroundStyle(.secondary)
.padding(6)
}
.buttonStyle(.plain)
.accessibilityLabel(Text("Dismiss"))
}
}
}
/// Lists each detected gap and offers to fill it. Filling opens the standard
/// add-snapshot flow for that source, prefilled to the missing month's date.
struct DataGapsSheet: View {
let gaps: [SnapshotGapDetector.Gap]
/// Resolves a source id to a live InvestmentSource for the add-snapshot flow.
let sourceProvider: (UUID) -> InvestmentSource?
@Environment(\.dismiss) private var dismiss
@State private var fillTarget: FillTarget?
@State private var filledGapIds: Set<String> = []
private let snapshotRepository = SnapshotRepository()
private struct FillTarget: Identifiable {
let source: InvestmentSource
let date: Date
var id: String { "\(source.id.uuidString)-\(date.timeIntervalSince1970)" }
}
private var visibleGaps: [SnapshotGapDetector.Gap] {
gaps.filter { !filledGapIds.contains($0.id) }
}
var body: some View {
NavigationStack {
List {
Section {
ForEach(visibleGaps) { gap in
gapRow(gap)
}
} footer: {
Text("Auto-fill estimates the missing months between two snapshots (marked as estimated). Or add the real values yourself.")
}
}
.navigationTitle("Missing Data")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("Done") { dismiss() }
}
}
.sheet(item: $fillTarget) { target in
AddSnapshotView(source: target.source, initialDate: target.date)
.environment(\.managedObjectContext, CoreDataStack.shared.viewContext)
}
}
}
@ViewBuilder
private func gapRow(_ gap: SnapshotGapDetector.Gap) -> some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
Text(gap.sourceName)
.font(.headline)
Spacer()
Text(String(format: String(localized: "gaps_missing_count"), gap.missingMonths))
.font(.caption.weight(.semibold))
.foregroundStyle(.orange)
.padding(.horizontal, 8)
.padding(.vertical, 3)
.background(Color.orange.opacity(0.12))
.clipShape(Capsule())
}
Text(rangeLabel(gap))
.font(.caption)
.foregroundStyle(.secondary)
if let source = sourceProvider(gap.sourceId),
let firstMissing = gap.missingMonthDates.first {
HStack(spacing: 16) {
Button {
let n = snapshotRepository.autoFillGap(
source: source,
from: gap.fromMonth,
to: gap.toMonth,
missingMonthDates: gap.missingMonthDates
)
if n > 0 { filledGapIds.insert(gap.id) }
} label: {
Label(
String(format: String(localized: "gaps_autofill_cta"), gap.missingMonths),
systemImage: "wand.and.stars"
)
.font(.subheadline.weight(.semibold))
}
.buttonStyle(.borderless)
.tint(.appPrimary)
Button {
fillTarget = FillTarget(source: source, date: firstMissing)
} label: {
Label("Add manually", systemImage: "plus.circle")
.font(.subheadline)
}
.buttonStyle(.borderless)
.tint(.secondary)
}
}
}
.padding(.vertical, 4)
}
private func rangeLabel(_ gap: SnapshotGapDetector.Gap) -> String {
let formatter = DateFormatter()
formatter.dateFormat = "MMM yyyy"
let from = formatter.string(from: gap.fromMonth)
let to = formatter.string(from: gap.toMonth)
return String(format: String(localized: "gaps_range_between"), from, to)
}
}
@@ -1,15 +1,107 @@
import SwiftUI
import Charts
/// Swipeable pages for the Home evolution card.
enum DashboardChartPage: Hashable {
case evolution, allocation, performance, drawdown, volatility, prediction, yearOverYear
var title: String {
switch self {
case .evolution: return String(localized: "Portfolio Evolution")
case .allocation: return String(localized: "Asset Allocation")
case .performance: return String(localized: "performance")
case .drawdown: return String(localized: "drawdown")
case .volatility: return String(localized: "volatility")
case .prediction: return String(localized: "prediction")
case .yearOverYear: return String(localized: "dash_chart_yoy")
}
}
/// Pages that stay unlocked on the Home carousel for everyone. The rest show a
/// lock teaser for non-premium users (they still see the page to drive upgrade).
var isFreeOnHome: Bool {
self == .evolution || self == .allocation
}
/// Maps to the full Charts feature type so we can reuse its computed data + views.
var chartsType: ChartsViewModel.ChartType {
switch self {
case .evolution: return .evolution
case .allocation: return .allocation
case .performance: return .performance
case .drawdown: return .drawdown
case .volatility: return .volatility
case .prediction: return .prediction
case .yearOverYear: return .yearOverYear
}
}
}
struct EvolutionChartCard: View {
let data: [(date: Date, value: Decimal)]
let categoryData: [CategoryEvolutionPoint]
let goals: [Goal]
/// Current allocation per category (name, value, colorHex) for the swipeable
/// Allocation page. Empty the Allocation page is skipped.
var allocation: [(category: String, value: Decimal, color: String)] = []
let iapService: IAPService
/// Called when a non-premium user taps "unlock" on a locked chart page.
var onRequestUpgrade: () -> Void = {}
/// Reused to render the premium analytics pages (performance/drawdown/): it
/// already computes their data and the Charts feature already ships the views.
/// Only the currently-visible premium page is computed (see syncChartsVM).
@StateObject private var chartsVM: ChartsViewModel
init(
data: [(date: Date, value: Decimal)],
categoryData: [CategoryEvolutionPoint],
goals: [Goal],
allocation: [(category: String, value: Decimal, color: String)] = [],
iapService: IAPService,
onRequestUpgrade: @escaping () -> Void = {}
) {
self.data = data
self.categoryData = categoryData
self.goals = goals
self.allocation = allocation
self.iapService = iapService
self.onRequestUpgrade = onRequestUpgrade
_chartsVM = StateObject(wrappedValue: ChartsViewModel(iapService: iapService))
}
@State private var selectedDataPoint: (date: Date, value: Decimal)?
@State private var chartMode: ChartMode = .total
@State private var showGoalLines = true
@State private var chartWidth: CGFloat = 300
// Feedback #4: swipe the Home card between a curated set of glanceable charts.
@State private var page: DashboardChartPage = .evolution
@State private var pageInsertionEdge: Edge = .trailing
@State private var allocationSelection: String?
/// Pages available given the data on hand. "By Category" already lives as a
/// toggle inside the Evolution page, so it isn't a separate swipe page.
private var pages: [DashboardChartPage] {
var p: [DashboardChartPage] = [.evolution]
if !allocation.isEmpty { p.append(.allocation) }
// Premium analytics pages are shown to everyone premium users see the
// real chart, free users see a lock teaser (drives the upgrade).
p.append(contentsOf: [.performance, .drawdown, .volatility, .prediction, .yearOverYear])
return p
}
private func isLocked(_ page: DashboardChartPage) -> Bool {
!page.isFreeOnHome && !iapService.isPremium
}
/// Compute data for the visible premium page on demand (the VM computes only its
/// selected chart). No-op for free/locked pages so nothing is calculated in vain.
private func syncChartsVM(for page: DashboardChartPage) {
guard !isLocked(page), !page.isFreeOnHome else { return }
if chartsVM.selectedChartType != page.chartsType {
chartsVM.selectChart(page.chartsType)
}
}
enum ChartMode: String, CaseIterable, Identifiable {
case total = "Total"
@@ -45,37 +137,173 @@ struct EvolutionChartCard: View {
var body: some View {
VStack(alignment: .leading, spacing: 12) {
headerView
modePicker
chartSection
pageContent
.id(page)
.transition(.asymmetric(
insertion: .move(edge: pageInsertionEdge).combined(with: .opacity),
removal: .opacity
))
if pages.count > 1 { pageIndicator }
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
.contentShape(Rectangle())
// simultaneousGesture so the swipe is recognized even over the chart,
// whose own scrub DragGesture would otherwise consume it. Horizontal
// dominance is enforced in onEnded so vertical scroll still works; a
// single page is a harmless no-op (bounds prevent movement).
.simultaneousGesture(pageSwipeGesture)
.onAppear { syncChartsVM(for: page) }
.onChange(of: page) { _, newPage in syncChartsVM(for: newPage) }
}
@ViewBuilder
private var pageContent: some View {
if isLocked(page) {
lockedPageView(page)
} else {
switch page {
case .evolution:
modePicker
chartSection
case .allocation:
SemicircleAllocation(
data: allocation,
total: allocation.reduce(Decimal.zero) { $0 + $1.value },
selectedSlice: $allocationSelection
)
.frame(height: 150)
.padding(.vertical, 8)
allocationLegend
// showsTitle: false the card header already names the page;
// the embedded charts' own titles duplicated it.
case .performance:
HorizontalPerformanceChart(data: chartsVM.performanceData, showsTitle: false)
case .drawdown:
DrawdownChart(data: chartsVM.drawdownData, showsTitle: false)
case .volatility:
VolatilityChartView(data: chartsVM.volatilityData, showsTitle: false)
case .prediction:
PredictionChartView(
predictions: chartsVM.predictionData,
historicalData: chartsVM.evolutionData,
showsTitle: false
)
case .yearOverYear:
YearOverYearChartView(viewModel: chartsVM, showsTitle: false)
}
}
}
/// Compact lock teaser reused from the Charts feature's premium treatment.
private func lockedPageView(_ page: DashboardChartPage) -> some View {
VStack(spacing: 12) {
ZStack {
Circle()
.fill(Color.appPrimary.opacity(0.12))
.frame(width: 72, height: 72)
Image(systemName: page.chartsType.icon)
.font(.system(size: 28))
.foregroundStyle(Color.appPrimary)
Image(systemName: "lock.circle.fill")
.font(.system(size: 22))
.foregroundStyle(Color.appWarning)
.background(Circle().fill(Color(.systemBackground)))
.offset(x: 26, y: 24)
}
Text(String(localized: "home_chart_locked_sub"))
.font(.subheadline)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
Button {
FirebaseService.shared.logPaywallShown(trigger: "home_charts")
onRequestUpgrade()
} label: {
Label(String(localized: "chart_locked_cta"), systemImage: "lock.open.fill")
.font(.subheadline.weight(.semibold))
.padding(.horizontal, 20)
.padding(.vertical, 10)
.background(Color.appPrimary)
.foregroundStyle(.white)
.clipShape(Capsule())
}
}
.frame(maxWidth: .infinity, minHeight: 240)
.padding(.vertical, 12)
}
private var headerView: some View {
HStack {
Text("Portfolio Evolution")
Text(page.title)
.font(.headline)
Spacer()
Button {
showGoalLines.toggle()
} label: {
Image(systemName: showGoalLines ? "target" : "slash.circle")
.foregroundColor(.secondary)
}
.accessibilityLabel(showGoalLines ? "Hide goals" : "Show goals")
if page == .evolution {
Button {
showGoalLines.toggle()
} label: {
Image(systemName: showGoalLines ? "target" : "slash.circle")
.foregroundStyle(.secondary)
}
.accessibilityLabel(showGoalLines ? "Hide goals" : "Show goals")
if let selected = selectedDataPoint, chartMode == .total {
VStack(alignment: .trailing) {
Text(selected.value.compactCurrencyString)
.font(.subheadline.weight(.semibold))
Text(selected.date.monthYearString)
.font(.caption)
.foregroundColor(.secondary)
if let selected = selectedDataPoint, chartMode == .total {
VStack(alignment: .trailing) {
Text(selected.value.compactCurrencyString)
.font(.subheadline.weight(.semibold))
Text(selected.date.monthYearString)
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
}
}
// MARK: - Swipe between pages
private var pageSwipeGesture: some Gesture {
DragGesture(minimumDistance: 30)
.onEnded { value in
guard abs(value.translation.width) > abs(value.translation.height) * 1.5,
let idx = pages.firstIndex(of: page) else { return }
if value.translation.width < 0, idx < pages.count - 1 {
pageInsertionEdge = .trailing
withAnimation(.snappy) { page = pages[idx + 1] }
} else if value.translation.width > 0, idx > 0 {
pageInsertionEdge = .leading
withAnimation(.snappy) { page = pages[idx - 1] }
}
}
}
private var pageIndicator: some View {
let current = pages.firstIndex(of: page) ?? 0
return HStack(spacing: 5) {
ForEach(pages.indices, id: \.self) { i in
Circle()
.fill(i == current ? Color.appPrimary : Color.secondary.opacity(0.25))
.frame(width: i == current ? 7 : 5, height: i == current ? 7 : 5)
}
}
.frame(maxWidth: .infinity)
.animation(.snappy, value: current)
}
private var allocationLegend: some View {
VStack(spacing: 6) {
ForEach(allocation.prefix(4), id: \.category) { item in
let total = allocation.reduce(Decimal.zero) { $0 + $1.value }
let pct = total > 0 ? NSDecimalNumber(decimal: item.value / total).doubleValue * 100 : 0
HStack(spacing: 8) {
Circle().fill(Color(hex: item.color) ?? .gray).frame(width: 9, height: 9)
Text(item.category).font(.caption)
Spacer()
Text(String(format: "%.1f%%", pct)).font(.caption.weight(.medium))
Text(item.value.compactCurrencyString).font(.caption).foregroundStyle(.secondary)
}
}
}
@@ -97,7 +325,7 @@ struct EvolutionChartCard: View {
} else {
Text("Not enough data to display chart")
.font(.subheadline)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
.frame(height: 200)
.frame(maxWidth: .infinity)
}
@@ -221,7 +449,7 @@ struct EvolutionChartCard: View {
.annotation(position: .topTrailing) {
Text(goal.name)
.font(.caption2)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
}
}
@@ -335,6 +563,6 @@ struct SparklineView: View {
(Date(), 12000)
]
return EvolutionChartCard(data: sampleData, categoryData: [], goals: [])
return EvolutionChartCard(data: sampleData, categoryData: [], goals: [], iapService: IAPService())
.padding()
}
@@ -0,0 +1,482 @@
import SwiftUI
import CoreData
import TipKit
import UIKit
// MARK: - Guided monthly check-in (Calm 2.0, Fase 2)
//
// The monthly ritual as a full-screen, one-source-per-step flow instead of a
// form: previous value in sight, big keypad entry, live delta, clipboard
// hand-off then a short reflection (mood + one line) and a closing summary
// with the streak. Persistence reuses the exact same paths as Quick Update
// (Snapshots) and the journal (MonthlyCheckInStore JournalEntry), so data
// stays consistent with every other entry point.
struct GuidedCheckInView: View {
/// Frozen at presentation: the launching card recomputes its reference date
/// as soon as the check-in saves, and SwiftUI re-evaluates the cover content
/// without freezing, the month would shift mid-flow on the done screen.
@State private var referenceDate: Date
init(referenceDate: Date) {
_referenceDate = State(initialValue: referenceDate)
}
@Environment(\.dismiss) private var dismiss
@Environment(\.managedObjectContext) private var context
@Environment(\.scenePhase) private var scenePhase
@FetchRequest(
sortDescriptors: [NSSortDescriptor(keyPath: \InvestmentSource.name, ascending: true)],
predicate: NSPredicate(format: "isActive == YES"),
animation: .none
) private var sources: FetchedResults<InvestmentSource>
// Step 0..<count one source each; count reflection; count+1 done.
@State private var step = 0
@State private var values: [NSManagedObjectID: String] = [:]
@State private var mood: MonthlyCheckInMood?
@State private var note = ""
@State private var saveError: String?
@State private var savedTotals: (previous: Decimal, current: Decimal)?
@State private var clipboardAmount: Decimal?
@State private var lastSuggestedRaw: String?
@FocusState private var amountFocused: Bool
@FocusState private var noteFocused: Bool
private var reflectionStep: Int { sources.count }
private var doneStep: Int { sources.count + 1 }
private var currentSource: InvestmentSource? {
step < sources.count ? sources[step] : nil
}
private var monthLabel: String {
let formatter = DateFormatter()
formatter.dateFormat = "LLLL yyyy"
return formatter.string(from: MonthlyCheckInStore.effectiveMonth(for: referenceDate, relativeTo: referenceDate))
}
var body: some View {
NavigationStack {
ZStack {
AppBackground()
VStack(spacing: 0) {
if step < doneStep {
ProgressView(value: Double(step + 1), total: Double(doneStep))
.tint(.appPrimary)
.padding(.horizontal)
.padding(.top, 8)
}
Group {
if let source = currentSource {
sourceStep(source)
.id(source.objectID)
} else if step == reflectionStep {
reflectionView
} else {
doneView
}
}
.frame(maxWidth: 560)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.transition(.asymmetric(
insertion: .move(edge: .trailing).combined(with: .opacity),
removal: .move(edge: .leading).combined(with: .opacity)
))
}
}
.navigationTitle(step == doneStep ? "" : monthLabel)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
if step < doneStep {
ToolbarItem(placement: .cancellationAction) {
Button(String(localized: "cancel")) { dismiss() }
}
}
}
.alert("Error", isPresented: Binding(
get: { saveError != nil },
set: { if !$0 { saveError = nil } }
)) {
Button("OK", role: .cancel) { saveError = nil }
} message: {
Text(saveError ?? "")
}
.onAppear {
amountFocused = true
checkClipboard()
}
.onChange(of: scenePhase) { _, phase in
if phase == .active { checkClipboard() }
}
}
.interactiveDismissDisabled(step > 0 && step < doneStep)
}
// MARK: - Source step
private func sourceStep(_ source: InvestmentSource) -> some View {
VStack(spacing: 24) {
Spacer()
VStack(spacing: 6) {
Text(source.name)
.font(.title2.weight(.bold))
if let category = source.category?.name {
Text(category)
.font(.subheadline)
.foregroundStyle(.secondary)
}
}
if source.latestValue != .zero {
VStack(spacing: 2) {
Text(String(localized: "guided_previous_value"))
.font(.caption)
.foregroundStyle(.secondary)
Text(source.latestValue.currencyString)
.font(.title3.weight(.semibold))
.foregroundStyle(.secondary)
}
}
TextField("0", text: valueBinding(for: source))
.keyboardType(.decimalPad)
.multilineTextAlignment(.center)
.font(.system(size: 40, weight: .bold, design: .rounded))
.focused($amountFocused)
.accessibilityIdentifier("guided_value_field")
.padding(.vertical, 12)
.padding(.horizontal, 24)
.background(Color(.secondarySystemGroupedBackground))
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
.padding(.horizontal, 32)
liveDelta(for: source)
if let amount = clipboardAmount {
Button {
values[source.objectID] = String(format: "%.2f", NSDecimalNumber(decimal: amount).doubleValue)
clipboardAmount = nil
} label: {
Label(
String(format: String(localized: "guided_paste_amount"), amount.currencyString),
systemImage: "doc.on.clipboard.fill"
)
.font(.subheadline.weight(.semibold))
}
.buttonStyle(.bordered)
.tint(.appPrimary)
}
Spacer()
VStack(spacing: 10) {
Button {
advance()
} label: {
Text(String(localized: "guided_next"))
.font(.headline.weight(.semibold))
.frame(maxWidth: .infinity)
.padding(.vertical, 14)
}
.buttonStyle(.borderedProminent)
.tint(.appPrimary)
.disabled(parsedValue(for: source) == nil)
.accessibilityIdentifier("guided_next_btn")
HStack(spacing: 12) {
if source.latestValue != .zero {
Button {
values[source.objectID] = String(format: "%.2f", NSDecimalNumber(decimal: source.latestValue).doubleValue)
advance()
} label: {
Text(String(localized: "guided_same_as_last"))
.font(.subheadline.weight(.medium))
}
}
Button {
values[source.objectID] = ""
advance()
} label: {
Text(String(localized: "guided_skip"))
.font(.subheadline.weight(.medium))
.foregroundStyle(.secondary)
}
.accessibilityIdentifier("guided_skip_btn")
}
}
.padding(.horizontal, 32)
.padding(.bottom, 24)
}
}
@ViewBuilder
private func liveDelta(for source: InvestmentSource) -> some View {
if let value = parsedValue(for: source), source.latestValue > 0 {
let delta = value - source.latestValue
let pct = NSDecimalNumber(decimal: delta / source.latestValue).doubleValue * 100
let positive = delta >= 0
HStack(spacing: 4) {
Image(systemName: positive ? "arrow.up.right" : "arrow.down.right")
.font(.caption.weight(.semibold))
Text("\(delta.currencyString) (\(String(format: "%+.1f%%", pct)))")
.font(.subheadline.weight(.semibold))
}
.foregroundStyle(positive ? Color.positiveGreen : Color.negativeRed)
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background((positive ? Color.positiveGreen : Color.negativeRed).opacity(0.12))
.clipShape(Capsule())
.transition(.opacity)
} else {
// Reserve the space so the layout doesn't jump while typing.
Color.clear.frame(height: 30)
}
}
// MARK: - Reflection step
private var reflectionView: some View {
VStack(spacing: 24) {
Spacer()
VStack(spacing: 6) {
Text(String(localized: "guided_reflection_title"))
.font(.title2.weight(.bold))
Text(String(localized: "guided_reflection_subtitle"))
.font(.subheadline)
.foregroundStyle(.secondary)
}
HStack(spacing: 10) {
ForEach(MonthlyCheckInMood.allCases) { candidate in
Button {
withAnimation(.snappy) {
mood = (mood == candidate) ? nil : candidate
}
} label: {
VStack(spacing: 6) {
Image(systemName: candidate.iconName)
.font(.title3)
Text(candidate.title)
.font(.caption2)
.lineLimit(1)
.minimumScaleFactor(0.7)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
.background(
mood == candidate
? Color.appPrimary.opacity(0.15)
: Color(.secondarySystemGroupedBackground)
)
.foregroundStyle(mood == candidate ? Color.appPrimary : .primary)
.clipShape(RoundedRectangle(cornerRadius: 12))
.overlay(
RoundedRectangle(cornerRadius: 12)
.strokeBorder(mood == candidate ? Color.appPrimary : .clear, lineWidth: 1.5)
)
}
.buttonStyle(.plain)
.accessibilityIdentifier("guided_mood_\(candidate.rawValue)")
}
}
.padding(.horizontal, 24)
TextField(String(localized: "guided_note_placeholder"), text: $note, axis: .vertical)
.lineLimit(2...4)
.focused($noteFocused)
.padding(14)
.background(Color(.secondarySystemGroupedBackground))
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
.padding(.horizontal, 24)
Spacer()
Button {
finish()
} label: {
Text(String(localized: "guided_finish"))
.font(.headline.weight(.semibold))
.frame(maxWidth: .infinity)
.padding(.vertical, 14)
}
.buttonStyle(.borderedProminent)
.tint(.appPrimary)
.accessibilityIdentifier("guided_finish_btn")
.padding(.horizontal, 32)
.padding(.bottom, 24)
}
}
// MARK: - Done step
private var doneView: some View {
VStack(spacing: 20) {
Spacer()
Image(systemName: "checkmark.seal.fill")
.font(.system(size: 64))
.foregroundStyle(Color.positiveGreen)
.symbolEffect(.bounce, value: step)
Text(String(format: String(localized: "guided_done_title"), monthLabel))
.font(.title2.weight(.bold))
.multilineTextAlignment(.center)
if let totals = savedTotals {
VStack(spacing: 4) {
Text(totals.current.currencyString)
.font(.system(size: 40, weight: .bold, design: .rounded))
if totals.previous > 0 {
let delta = totals.current - totals.previous
let pct = NSDecimalNumber(decimal: delta / totals.previous).doubleValue * 100
Text("\(delta.currencyString) (\(String(format: "%+.1f%%", pct)))")
.font(.subheadline.weight(.semibold))
.foregroundStyle(delta >= 0 ? Color.positiveGreen : Color.negativeRed)
}
}
}
let streak = MonthlyCheckInStore.stats(referenceDate: referenceDate).currentStreak
if streak >= 2 {
HStack(spacing: 6) {
Image(systemName: "flame.fill")
Text(String(format: String(localized: "guided_streak"), streak))
}
.font(.subheadline.weight(.semibold))
.foregroundStyle(.orange)
.padding(.horizontal, 14)
.padding(.vertical, 8)
.background(Color.orange.opacity(0.12))
.clipShape(Capsule())
}
Spacer()
TipView(SiriTip())
.padding(.horizontal, 32)
VStack(spacing: 10) {
if let totals = savedTotals {
Button {
let delta = totals.current - totals.previous
let pct = totals.previous > 0
? NSDecimalNumber(decimal: delta / totals.previous).doubleValue * 100 : 0
ShareService.shared.sharePortfolioValue(
totalValue: totals.current.currencyString,
changeText: "\(delta.currencyString) (\(String(format: "%+.1f%%", pct)))",
changeLabel: monthLabel,
yearChange: nil,
sinceInceptionChange: nil
)
} label: {
Label(String(localized: "guided_share_month"), systemImage: "square.and.arrow.up")
.font(.subheadline.weight(.semibold))
}
.buttonStyle(.bordered)
.tint(.appPrimary)
}
Button {
dismiss()
} label: {
Text(String(localized: "guided_done_cta"))
.font(.headline.weight(.semibold))
.frame(maxWidth: .infinity)
.padding(.vertical, 14)
}
.buttonStyle(.borderedProminent)
.tint(.appPrimary)
}
.padding(.horizontal, 32)
.padding(.bottom, 24)
}
}
// MARK: - Flow
private func advance() {
withAnimation(.snappy) {
step += 1
}
if step < sources.count {
amountFocused = true
checkClipboard()
} else if step == reflectionStep {
amountFocused = false
}
}
private func finish() {
let previousTotal = sources.reduce(Decimal.zero) { $0 + $1.latestValue }
let now = Date()
var updatedSources: [InvestmentSource] = []
for source in sources {
guard let value = parsedValue(for: source) else { continue }
let snapshot = Snapshot(context: context)
snapshot.id = UUID()
snapshot.value = NSDecimalNumber(decimal: value)
snapshot.date = now
snapshot.source = source
if let contribution = MonthlyContributionStore.contribution(for: source.id) {
snapshot.contribution = NSDecimalNumber(decimal: contribution)
}
updatedSources.append(source)
}
do {
try context.save()
} catch {
saveError = error.localizedDescription
return
}
// New total = entered values + untouched sources at their previous value.
let currentTotal = sources.reduce(Decimal.zero) { total, source in
total + (parsedValue(for: source) ?? source.latestValue)
}
savedTotals = (previous: previousTotal, current: currentTotal)
if let mood { MonthlyCheckInStore.setMood(mood, for: referenceDate) }
let trimmedNote = note.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmedNote.isEmpty { MonthlyCheckInStore.setNote(trimmedNote, for: referenceDate) }
MonthlyCheckInStore.setCompletionDate(now, for: referenceDate)
for source in updatedSources {
NotificationService.shared.scheduleReminder(for: source)
}
SharedQuickUpdateSync.refreshMirror()
UINotificationFeedbackGenerator().notificationOccurred(.success)
withAnimation(.snappy) {
step = doneStep
}
}
// MARK: - Helpers
private func valueBinding(for source: InvestmentSource) -> Binding<String> {
Binding(
get: { values[source.objectID] ?? "" },
set: { values[source.objectID] = $0 }
)
}
private func parsedValue(for source: InvestmentSource) -> Decimal? {
guard let raw = values[source.objectID],
!raw.trimmingCharacters(in: .whitespaces).isEmpty,
let value = CurrencyFormatter.parseUserInput(raw), value > 0 else { return nil }
return value
}
private func checkClipboard() {
guard let raw = UIPasteboard.general.string, raw != lastSuggestedRaw,
let parsed = CurrencyFormatter.parseUserInput(raw), parsed > 0 else { return }
lastSuggestedRaw = raw
clipboardAmount = parsed
}
}
@@ -1,49 +1,66 @@
import SwiftUI
// Calm 2.0: one insight at a time instead of a chip carousel a quiet,
// glanceable line under the check-in zone. Tap to cycle through the rest.
struct InsightsRow: View {
let insights: [PortfolioInsight]
@State private var index = 0
private var current: PortfolioInsight? {
guard !insights.isEmpty else { return nil }
return insights[index % insights.count]
}
var body: some View {
VStack(alignment: .leading, spacing: 10) {
Text(String(localized: "insights_section_title"))
.font(.headline)
ScrollView(.horizontal, showsIndicators: false) {
if let insight = current {
Button {
withAnimation(.snappy) {
index = (index + 1) % insights.count
}
} label: {
HStack(spacing: 12) {
ForEach(insights) { insight in
insightChip(insight)
Image(systemName: insight.systemImage)
.font(.subheadline.weight(.semibold))
.foregroundStyle(insight.accentColor)
.frame(width: 30, height: 30)
.background(insight.accentColor.opacity(0.12))
.clipShape(Circle())
VStack(alignment: .leading, spacing: 1) {
Text(insight.title)
.font(.caption)
.foregroundStyle(.secondary)
Text(insight.value)
.font(.subheadline.weight(.semibold))
.foregroundStyle(.primary)
.lineLimit(2)
if let detail = insight.detail {
Text(detail)
.font(.caption2)
.foregroundStyle(.secondary)
.lineLimit(1)
}
}
Spacer()
if insights.count > 1 {
HStack(spacing: 4) {
ForEach(insights.indices, id: \.self) { i in
Circle()
.fill(i == index % insights.count ? Color.secondary : Color.secondary.opacity(0.25))
.frame(width: 5, height: 5)
}
}
}
}
.padding(14)
.background(Color(.secondarySystemGroupedBackground))
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
}
.buttonStyle(.plain)
.id(insight.id)
.transition(.opacity)
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
}
private func insightChip(_ insight: PortfolioInsight) -> some View {
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 5) {
Image(systemName: insight.systemImage)
.font(.caption.weight(.semibold))
.foregroundColor(insight.accentColor)
Text(insight.title)
.font(.caption2)
.foregroundColor(.secondary)
}
Text(insight.value)
.font(.subheadline.weight(.semibold))
.foregroundColor(.primary)
.lineLimit(1)
}
.padding(.horizontal, 14)
.padding(.vertical, 10)
.background(insight.accentColor.opacity(0.08))
.cornerRadius(12)
.overlay(
RoundedRectangle(cornerRadius: 12)
.stroke(insight.accentColor.opacity(0.2), lineWidth: 1)
)
}
}
@@ -116,7 +116,7 @@ struct MonthlyCheckInView: View {
} label: {
Image(systemName: "chevron.left")
.font(.subheadline.weight(.semibold))
.foregroundColor(.appPrimary)
.foregroundStyle(Color.appPrimary)
}
Text(monthLabel)
@@ -127,13 +127,13 @@ struct MonthlyCheckInView: View {
} label: {
Image(systemName: "chevron.right")
.font(.subheadline.weight(.semibold))
.foregroundColor(canGoToNextMonth ? .appPrimary : .secondary.opacity(0.3))
.foregroundStyle(canGoToNextMonth ? Color.appPrimary : .secondary.opacity(0.3))
}
.disabled(!canGoToNextMonth)
}
}
ToolbarItem(placement: .navigationBarTrailing) {
ToolbarItem(placement: .topBarTrailing) {
Button {
shareMonthlyCheckIn()
} label: {
@@ -230,11 +230,11 @@ struct MonthlyCheckInView: View {
)
)
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
} else {
Text("Start your first check-in anytime.")
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
}
}
@@ -242,10 +242,10 @@ struct MonthlyCheckInView: View {
if let completed = MonthlyCheckInStore.completionDate(for: referenceDate) {
HStack(spacing: 6) {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.positiveGreen)
.foregroundStyle(Color.positiveGreen)
Text("Completed \(completed.friendlyDescription)")
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
}
@@ -272,12 +272,12 @@ struct MonthlyCheckInView: View {
.frame(maxWidth: .infinity)
.padding(.vertical, 10)
.background(isCompleted ? Color.appSecondary.opacity(0.1) : Color.appPrimary.opacity(0.1))
.cornerRadius(AppConstants.UI.cornerRadius)
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
}
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
}
@@ -310,7 +310,7 @@ struct MonthlyCheckInView: View {
Spacer()
Text("Optional")
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
VStack(alignment: .leading, spacing: 8) {
@@ -326,7 +326,7 @@ struct MonthlyCheckInView: View {
} label: {
Image(systemName: value <= starRating ? "star.fill" : "star")
.font(.title3)
.foregroundColor(value <= starRating ? .appSecondary : .secondary)
.foregroundStyle(value <= starRating ? Color.appSecondary : .secondary)
.padding(8)
.background(
Circle()
@@ -346,7 +346,7 @@ struct MonthlyCheckInView: View {
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(Color.gray.opacity(0.12))
.cornerRadius(AppConstants.UI.smallCornerRadius)
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.smallCornerRadius))
}
}
}
@@ -373,7 +373,7 @@ struct MonthlyCheckInView: View {
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
}
@@ -386,7 +386,7 @@ struct MonthlyCheckInView: View {
VStack(alignment: .leading, spacing: 4) {
Text("Starting")
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
Text(viewModel.monthlySummary.formattedStartingValue)
.font(.subheadline.weight(.semibold))
}
@@ -394,7 +394,7 @@ struct MonthlyCheckInView: View {
VStack(alignment: .trailing, spacing: 4) {
Text("Ending")
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
Text(viewModel.monthlySummary.formattedEndingValue)
.font(.subheadline.weight(.semibold))
}
@@ -404,7 +404,7 @@ struct MonthlyCheckInView: View {
VStack(alignment: .leading, spacing: 4) {
Text("Contributions")
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
Text(viewModel.monthlySummary.formattedContributions)
.font(.subheadline.weight(.semibold))
}
@@ -412,16 +412,16 @@ struct MonthlyCheckInView: View {
VStack(alignment: .trailing, spacing: 4) {
Text("Net Performance")
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
Text("\(viewModel.monthlySummary.formattedNetPerformance) (\(viewModel.monthlySummary.formattedNetPerformancePercentage))")
.font(.subheadline.weight(.semibold))
.foregroundColor(viewModel.monthlySummary.netPerformance >= 0 ? .positiveGreen : .negativeRed)
.foregroundStyle(viewModel.monthlySummary.netPerformance >= 0 ? Color.positiveGreen : Color.negativeRed)
}
}
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
}
@@ -489,7 +489,7 @@ struct MonthlyCheckInView: View {
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
}
}
@@ -497,11 +497,11 @@ struct MonthlyCheckInView: View {
private func highlightRow(icon: String, iconColor: Color, label: String, name: String, percentage: Double, diff: Decimal, valueColor: Color) -> some View {
HStack {
Image(systemName: icon)
.foregroundColor(iconColor)
.foregroundStyle(iconColor)
VStack(alignment: .leading, spacing: 2) {
Text(label)
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
Text(name)
.font(.subheadline.weight(.semibold))
}
@@ -509,10 +509,10 @@ struct MonthlyCheckInView: View {
VStack(alignment: .trailing, spacing: 2) {
Text(String(format: "%+.1f%%", percentage))
.font(.subheadline.weight(.bold))
.foregroundColor(valueColor)
.foregroundStyle(valueColor)
Text("(\(diff.compactCurrencyString))")
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
}
}
@@ -529,26 +529,30 @@ struct MonthlyCheckInView: View {
} label: {
Label("Batch Update", systemImage: "square.and.pencil")
.font(.caption.weight(.semibold))
.foregroundColor(.appPrimary)
.foregroundStyle(Color.appPrimary)
}
}
Text("\(viewModel.sources.count)")
.font(.subheadline)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
if viewModel.sources.isEmpty {
Text("Add sources to start your monthly check-in.")
.font(.subheadline)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
} else {
ForEach(viewModel.sources, id: \.objectID) { source in
let latestSnapshot = source.latestSnapshot
let updatedThisCycle = isSnapshotInCurrentCycle(latestSnapshot)
// Month-aware: "updated" means updated FOR THE VIEWED month.
let monthSnapshot = snapshotForViewedMonth(source)
let updatedThisCycle = monthSnapshot != nil
let latestSnapshot = monthSnapshot ?? source.latestSnapshot
let snapshots = source.sortedSnapshotsByDateAscending
let previousSnapshot: Snapshot? = {
guard snapshots.count >= 2 else { return nil }
return snapshots[snapshots.count - 2]
guard let ref = latestSnapshot,
let idx = snapshots.firstIndex(where: { $0.objectID == ref.objectID }),
idx > 0 else { return nil }
return snapshots[idx - 1]
}()
let valueDiff: Decimal? = {
guard let latest = latestSnapshot, let previous = previousSnapshot else { return nil }
@@ -571,7 +575,7 @@ struct MonthlyCheckInView: View {
.font(.subheadline.weight(.medium))
Text(updatedThisCycle ? "Updated this cycle" : "Needs update")
.font(.caption2)
.foregroundColor(updatedThisCycle ? .positiveGreen : .secondary)
.foregroundStyle(updatedThisCycle ? Color.positiveGreen : .secondary)
}
Spacer()
@@ -579,16 +583,16 @@ struct MonthlyCheckInView: View {
if let diff = valueDiff, updatedThisCycle {
Text(diff >= 0 ? "+\(diff.compactCurrencyString)" : diff.compactCurrencyString)
.font(.caption.weight(.semibold))
.foregroundColor(diff >= 0 ? .positiveGreen : .negativeRed)
.foregroundStyle(diff >= 0 ? Color.positiveGreen : Color.negativeRed)
}
Text(latestSnapshot?.date.relativeDayDescription ?? String(localized: "date_never"))
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
Image(systemName: "chevron.right")
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
}
.buttonStyle(.plain)
@@ -597,7 +601,7 @@ struct MonthlyCheckInView: View {
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
}
@@ -610,7 +614,7 @@ struct MonthlyCheckInView: View {
.frame(minHeight: 120)
.padding(8)
.background(Color.gray.opacity(0.08))
.cornerRadius(12)
.clipShape(RoundedRectangle(cornerRadius: 12))
.focused($noteFocused)
.onChange(of: monthlyNote) { _, newValue in
MonthlyCheckInStore.setNote(newValue, for: referenceDate)
@@ -625,7 +629,7 @@ struct MonthlyCheckInView: View {
.frame(maxWidth: .infinity)
.padding(.vertical, 8)
.background(Color.appSecondary.opacity(0.12))
.cornerRadius(AppConstants.UI.cornerRadius)
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
}
.buttonStyle(.plain)
@@ -638,13 +642,13 @@ struct MonthlyCheckInView: View {
.frame(maxWidth: .infinity)
.padding(.vertical, 8)
.background(Color.appPrimary.opacity(0.12))
.cornerRadius(AppConstants.UI.cornerRadius)
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
}
}
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
}
@@ -656,7 +660,7 @@ struct MonthlyCheckInView: View {
if viewModel.recentNotes.isEmpty {
Text("No snapshot notes for this month.")
.font(.subheadline)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
} else {
ForEach(viewModel.recentNotes, id: \.objectID) { snapshot in
VStack(alignment: .leading, spacing: 4) {
@@ -664,10 +668,10 @@ struct MonthlyCheckInView: View {
.font(.subheadline.weight(.semibold))
Text(snapshot.notes ?? "")
.font(.subheadline)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
Text(snapshot.date.friendlyDescription)
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
if snapshot.id != viewModel.recentNotes.last?.id {
@@ -678,7 +682,7 @@ struct MonthlyCheckInView: View {
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
}
@@ -760,11 +764,11 @@ struct AchievementsView: View {
)
)
.font(.subheadline)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
}
@@ -781,7 +785,7 @@ struct AchievementsView: View {
if let subtitle {
Text(subtitle)
.font(.subheadline)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
if achievements.isEmpty {
@@ -794,7 +798,7 @@ struct AchievementsView: View {
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
}
@@ -806,7 +810,7 @@ struct AchievementsView: View {
.frame(width: 42, height: 42)
Image(systemName: status.achievement.icon)
.font(.headline)
.foregroundColor(isLocked ? .secondary : .appSecondary)
.foregroundStyle(isLocked ? .secondary : Color.appSecondary)
}
VStack(alignment: .leading, spacing: 2) {
@@ -814,7 +818,7 @@ struct AchievementsView: View {
.font(.subheadline.weight(.semibold))
Text(status.achievement.detail)
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
Spacer()
@@ -822,12 +826,12 @@ struct AchievementsView: View {
if isLocked {
Image(systemName: "lock.fill")
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
}
.padding(10)
.background(isLocked ? Color.gray.opacity(0.08) : Color.appSecondary.opacity(0.12))
.cornerRadius(AppConstants.UI.smallCornerRadius)
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.smallCornerRadius))
}
}
@@ -837,19 +841,37 @@ private extension MonthlyCheckInView {
return snapshot.date >= Calendar.current.startOfDay(for: last)
}
/// The effective (year, month) of the check-in month currently being viewed.
private var viewedMonthComponents: DateComponents {
let effective = MonthlyCheckInStore.effectiveMonth(for: referenceDate, relativeTo: referenceDate)
return Calendar.current.dateComponents([.year, .month], from: effective)
}
/// The source's snapshot that belongs to the VIEWED month (not the global
/// latest) so a past month shows "Updated" for the sources it recorded,
/// instead of "Needs update" for everything.
func snapshotForViewedMonth(_ source: InvestmentSource) -> Snapshot? {
let target = viewedMonthComponents
return source.sortedSnapshotsByDateAscending.last { snapshot in
let eff = MonthlyCheckInStore.effectiveMonth(for: snapshot.date, relativeTo: snapshot.date)
let c = Calendar.current.dateComponents([.year, .month], from: eff)
return c.year == target.year && c.month == target.month
}
}
@ViewBuilder
func moodPill(for mood: MonthlyCheckInMood) -> some View {
let isSelected = selectedMood == mood
HStack(alignment: .center, spacing: 8) {
Image(systemName: mood.iconName)
.font(.body)
.foregroundColor(isSelected ? moodColor(for: mood) : .secondary)
.foregroundStyle(isSelected ? moodColor(for: mood) : .secondary)
VStack(alignment: .leading, spacing: 2) {
Text(mood.title)
.font(.subheadline.weight(.semibold))
Text(mood.detail)
.font(.caption2)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
}
.padding(10)
@@ -858,7 +880,7 @@ private extension MonthlyCheckInView {
RoundedRectangle(cornerRadius: AppConstants.UI.smallCornerRadius)
.stroke(isSelected ? moodColor(for: mood) : Color.clear, lineWidth: 1)
)
.cornerRadius(AppConstants.UI.smallCornerRadius)
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.smallCornerRadius))
}
func moodColor(for mood: MonthlyCheckInMood) -> Color {
@@ -918,7 +940,7 @@ struct AchievementMilestoneBar: View {
if status.isUnlocked {
Image(systemName: "checkmark")
.font(.system(size: 8, weight: .bold))
.foregroundColor(.white)
.foregroundStyle(.white)
}
}
.position(x: x, y: barY)
@@ -967,10 +989,10 @@ struct BatchUpdateView: View {
.navigationTitle("Batch Update")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
ToolbarItem(placement: .topBarLeading) {
Button("Cancel") { dismiss() }
}
ToolbarItem(placement: .navigationBarTrailing) {
ToolbarItem(placement: .topBarTrailing) {
Button("Save") { saveAll() }
.disabled(filledCount == 0)
.fontWeight(.semibold)
@@ -1008,33 +1030,33 @@ struct BatchUpdateView: View {
if let prev = previousValue {
Text(prev.currencyString)
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
}
HStack {
Text(symbol)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
TextField("Current value", text: valueBinding)
.keyboardType(.decimalPad)
}
.padding(8)
.background(Color.gray.opacity(0.08))
.cornerRadius(8)
.clipShape(RoundedRectangle(cornerRadius: 8))
if isDetailed {
HStack {
Image(systemName: "plus.circle")
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
TextField("Contribution this period (optional)", text: contributionBinding)
.keyboardType(.decimalPad)
.font(.subheadline)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
.padding(8)
.background(Color.appSecondary.opacity(0.06))
.cornerRadius(8)
.clipShape(RoundedRectangle(cornerRadius: 8))
}
}
.padding(.vertical, 2)
@@ -0,0 +1,145 @@
import SwiftUI
/// A polished, branded card summarising the month designed to be rendered to
/// an image and shared (screenshot-worthy, marketing-facing). Pulls only from
/// data the Dashboard already computes.
struct MonthlySummaryShareView: View {
let monthLabel: String
let totalValue: String
let changeAmount: String
let changePercentage: String
let isPositive: Bool
let streak: Int
let mood: MonthlyCheckInMood?
let rating: Int?
let appName: String
var qrCodeImage: UIImage? = nil
var body: some View {
VStack(alignment: .leading, spacing: 0) {
// Header
HStack {
Text(monthLabel)
.font(.caption.weight(.semibold))
.foregroundStyle(.white.opacity(0.85))
.textCase(.uppercase)
Spacer()
if streak >= 2 {
HStack(spacing: 4) {
Image(systemName: "flame.fill")
.font(.caption2)
Text(String(format: String(localized: "share_summary_streak_badge"), streak))
.font(.caption2.weight(.bold))
}
.foregroundStyle(.white)
.padding(.horizontal, 10)
.padding(.vertical, 5)
.background(Color.white.opacity(0.18))
.clipShape(Capsule())
}
}
Text("Monthly Summary")
.font(.title.weight(.bold))
.foregroundStyle(.white)
.padding(.top, 6)
// Hero value
VStack(alignment: .leading, spacing: 6) {
Text("Portfolio Value")
.font(.subheadline.weight(.semibold))
.foregroundStyle(.white.opacity(0.85))
Text(totalValue)
.font(.system(size: 40, weight: .bold, design: .rounded))
.foregroundStyle(.white)
.minimumScaleFactor(0.6)
.lineLimit(1)
}
.padding(.top, 22)
// Change since last check-in
HStack(spacing: 8) {
Image(systemName: isPositive ? "arrow.up.right" : "arrow.down.right")
.font(.subheadline.weight(.bold))
Text("\(changeAmount) (\(changePercentage))")
.font(.headline.weight(.bold))
Spacer()
Text("since last check-in")
.font(.caption)
.foregroundStyle(.white.opacity(0.75))
}
.foregroundStyle(.white)
.padding(.horizontal, 14)
.padding(.vertical, 10)
.background(Color.white.opacity(0.15))
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
.padding(.top, 18)
// Mood / rating
if mood != nil || rating != nil {
HStack(spacing: 14) {
if let mood {
HStack(spacing: 6) {
Image(systemName: mood.iconName)
.font(.subheadline)
Text(mood.title)
.font(.subheadline.weight(.semibold))
}
.foregroundStyle(.white)
}
if let rating {
HStack(spacing: 2) {
ForEach(1...5, id: \.self) { i in
Image(systemName: i <= rating ? "star.fill" : "star")
.font(.caption)
.foregroundStyle(.white.opacity(i <= rating ? 1 : 0.4))
}
}
}
Spacer()
}
.padding(.top, 16)
}
Spacer(minLength: 0)
// Footer / branding
HStack(spacing: 12) {
Image("BrandMark")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 40, height: 40)
.clipShape(RoundedRectangle(cornerRadius: 9, style: .continuous))
VStack(alignment: .leading, spacing: 2) {
Text("Tracked with")
.font(.caption2.weight(.medium))
.foregroundStyle(.white.opacity(0.7))
Text(appName)
.font(.subheadline.weight(.bold))
.foregroundStyle(.white)
}
Spacer()
if let qrCodeImage {
Image(uiImage: qrCodeImage)
.interpolation(.none)
.resizable()
.frame(width: 52, height: 52)
.background(Color.white)
.clipShape(RoundedRectangle(cornerRadius: 7, style: .continuous))
}
}
.padding(.top, 20)
}
.padding(24)
.frame(width: 340, height: 440)
.background(ShareCardBackground())
.clipShape(RoundedRectangle(cornerRadius: 28, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 28, style: .continuous)
.stroke(Color.white.opacity(0.12), lineWidth: 1)
)
}
}
@@ -1,9 +1,13 @@
import SwiftUI
import CoreData
import TipKit
import UIKit
import PhotosUI
import StoreKit
struct QuickUpdateView: View {
@Environment(\.dismiss) private var dismiss
@Environment(\.requestReview) private var requestReview
@Environment(\.managedObjectContext) private var context
@Environment(\.scenePhase) private var scenePhase
@FetchRequest(
@@ -22,6 +26,15 @@ struct QuickUpdateView: View {
@FocusState private var focusedSource: NSManagedObjectID?
@State private var clipboardAmount: Decimal?
@State private var lastSuggestedRaw: String?
@State private var lastPasteboardChangeCount = -1
// Per-field paste/scan (feedback #3): keyboard toolbar acts on the focused
// field paste a clipboard amount or OCR a number from a screenshot.
@State private var photoItem: PhotosPickerItem?
@State private var showingPhotoPicker = false
@State private var scanCandidates: [ScannedAmount] = []
@State private var isScanning = false
@State private var scanTargetSource: NSManagedObjectID?
/// First source (list order) still without a value the "active" one.
private var nextEmptySource: InvestmentSource? {
@@ -36,13 +49,32 @@ struct QuickUpdateView: View {
AppBackground()
if sources.isEmpty {
ContentUnavailableView(
String(localized: "quick_update_no_sources"),
systemImage: "list.bullet",
description: Text(String(localized: "quick_update_no_sources_body"))
)
ContentUnavailableView {
Label(String(localized: "quick_update_no_sources"), systemImage: "list.bullet")
} description: {
Text(String(localized: "quick_update_no_sources_body"))
} actions: {
Button {
dismiss()
// Let this sheet finish dismissing before Dashboard
// presents AddSourceView (same pattern as ContentView).
DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) {
NotificationCenter.default.post(name: .openAddFirstSource, object: nil)
}
} label: {
Text(String(localized: "quick_update_add_first_source"))
}
.buttonStyle(.borderedProminent)
}
} else {
List {
Section {
TipView(ScreenshotOCRTip())
.listRowInsets(EdgeInsets())
.listRowBackground(Color.clear)
}
.listSectionSeparator(.hidden)
if let amount = clipboardAmount, let target = nextEmptySource {
Section {
Button {
@@ -59,7 +91,7 @@ struct QuickUpdateView: View {
Image(systemName: "arrow.down.circle.fill")
}
.font(.subheadline.weight(.semibold))
.foregroundColor(.white)
.foregroundStyle(.white)
.padding(.vertical, 2)
}
.listRowBackground(Color.appPrimary)
@@ -92,6 +124,51 @@ struct QuickUpdateView: View {
.disabled(isSaving || filledValues.isEmpty)
.fontWeight(.semibold)
}
// Keyboard accessory: paste / scan for the focused field.
ToolbarItemGroup(placement: .keyboard) {
if let clip = clipboardFieldAmount {
Button {
if let target = focusedSource { values[target] = decimalInputString(clip) }
} label: {
Label(String(format: String(localized: "quick_update_paste_value"), clip.currencyString),
systemImage: "doc.on.clipboard")
}
}
Button {
scanTargetSource = focusedSource
showingPhotoPicker = true
} label: {
Label(String(localized: "quick_update_scan"), systemImage: "text.viewfinder")
}
Spacer()
Button(String(localized: "quick_update_done_kbd")) { focusedSource = nil }
.fontWeight(.semibold)
}
}
.photosPicker(isPresented: $showingPhotoPicker, selection: $photoItem, matching: .images)
.onChange(of: photoItem) { _, item in
guard let item else { return }
Task { await scanPickedPhoto(item) }
}
.overlay {
if isScanning {
ZStack {
Color.black.opacity(0.2).ignoresSafeArea()
ProgressView(String(localized: "quick_update_scanning"))
.padding(20)
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12))
}
}
}
.confirmationDialog(
String(localized: "quick_update_pick_amount"),
isPresented: Binding(get: { scanCandidates.count > 1 }, set: { if !$0 { scanCandidates = [] } }),
titleVisibility: .visible
) {
ForEach(scanCandidates) { c in
Button(c.value.currencyString) { applyScanned(c.value) }
}
Button(String(localized: "cancel"), role: .cancel) { scanCandidates = [] }
}
.alert("Error", isPresented: Binding(
get: { saveError != nil },
@@ -111,6 +188,47 @@ struct QuickUpdateView: View {
}
}
/// Clipboard amount for the keyboard toolbar a live text amount, or the
/// amount OCR'd from a copied image (held in clipboardAmount).
private var clipboardFieldAmount: Decimal? {
if let raw = UIPasteboard.general.string,
let parsed = CurrencyFormatter.parseUserInput(raw), parsed > 0 {
return parsed
}
return clipboardAmount
}
private func decimalInputString(_ value: Decimal) -> String {
String(format: "%.2f", NSDecimalNumber(decimal: value).doubleValue)
}
private func applyScanned(_ value: Decimal) {
if let target = scanTargetSource ?? focusedSource {
values[target] = decimalInputString(value)
}
scanCandidates = []
}
@MainActor
private func scanPickedPhoto(_ item: PhotosPickerItem) async {
isScanning = true
defer { photoItem = nil }
guard let data = try? await item.loadTransferable(type: Data.self),
let image = UIImage(data: data) else { isScanning = false; return }
ImageAmountScanner.scan(image) { candidates in
DispatchQueue.main.async {
isScanning = false
if candidates.count == 1 {
applyScanned(candidates[0].value)
} else if candidates.count > 1 {
scanCandidates = candidates
} else {
saveError = String(localized: "quick_update_no_amount_found")
}
}
}
}
@ViewBuilder
private func sourceRow(_ source: InvestmentSource) -> some View {
VStack(spacing: 6) {
@@ -125,14 +243,14 @@ struct QuickUpdateView: View {
.padding(.horizontal, 6)
.padding(.vertical, 1)
.background(Color.appSecondary.opacity(0.15))
.foregroundColor(.appSecondary)
.foregroundStyle(Color.appSecondary)
.clipShape(Capsule())
}
}
if source.latestValue != .zero {
Text(source.latestValue.currencyString)
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
}
Spacer()
@@ -151,7 +269,7 @@ struct QuickUpdateView: View {
HStack {
Text(String(localized: "quick_update_contribution_label"))
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
Spacer()
TextField(
String(localized: "quick_update_contribution_placeholder"),
@@ -161,7 +279,7 @@ struct QuickUpdateView: View {
.multilineTextAlignment(.trailing)
.frame(width: 120)
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
}
}
@@ -185,17 +303,30 @@ struct QuickUpdateView: View {
)
}
/// Reads the pasteboard and offers the parsed amount for the next pending source.
/// Skips values already suggested (or already typed) so returning to the app
/// with the same clipboard doesn't nag.
/// Reads the pasteboard and offers a parsed amount for the next pending source.
/// Handles BOTH a copied text value and a copied image (screenshot) the
/// image is OCR'd on-device and its most prominent number offered. Gated by
/// the pasteboard change count so returning with the same clipboard doesn't nag.
private func checkClipboard() {
guard let raw = UIPasteboard.general.string, raw != lastSuggestedRaw,
let parsed = CurrencyFormatter.parseUserInput(raw), parsed > 0,
nextEmptySource != nil else {
let pb = UIPasteboard.general
guard pb.changeCount != lastPasteboardChangeCount, nextEmptySource != nil else { return }
lastPasteboardChangeCount = pb.changeCount
// 1. Text amount (cheap, synchronous).
if let raw = pb.string, let parsed = CurrencyFormatter.parseUserInput(raw), parsed > 0 {
clipboardAmount = parsed
return
}
lastSuggestedRaw = raw
clipboardAmount = parsed
// 2. Copied image OCR the amount automatically.
if pb.hasImages, let image = pb.image {
isScanning = true
ImageAmountScanner.scan(image) { candidates in
DispatchQueue.main.async {
isScanning = false
if let best = candidates.first { clipboardAmount = best.value }
}
}
}
}
private func applyClipboard(_ amount: Decimal, to source: InvestmentSource) {
@@ -246,6 +377,14 @@ struct QuickUpdateView: View {
}
// Keep the share-extension mirror in sync with what was just saved
SharedQuickUpdateSync.refreshMirror()
// Direct context.save() bypasses SnapshotRepository, so refresh the
// widget explicitly otherwise it stays stale until the next app open.
CoreDataStack.shared.refreshWidgetData()
// Positive moment: check-in saved. Ask for a rating once the sheet
// is gone (gated to 2 months of history, once per version).
if ReviewRequestService.shouldRequestAfterCheckIn() {
DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) { requestReview() }
}
dismiss()
} catch {
saveError = error.localizedDescription
+479 -113
View File
@@ -1,41 +1,264 @@
import SwiftUI
struct MonthlyCheckInShareCardView: View {
let summary: MonthlySummary
let appName: String
var qrCodeImage: UIImage? = nil
// MARK: - Share card design system ("dark panorama")
//
// Same visual language as the App Store screenshots the app's strongest
// brand asset: near-black navy base, blue/green atmospheric glows, a subtle
// technical grid and the gradient evolution sparkline. These cards are the
// main referral surface, so they are designed to look premium in a chat or a
// story, and to carry an attributed QR/link back to the App Store.
enum ShareCardStyle {
static let navyTop = Color(red: 10 / 255, green: 21 / 255, blue: 42 / 255)
static let navyBottom = Color(red: 3 / 255, green: 9 / 255, blue: 22 / 255)
static let blue = Color(red: 48 / 255, green: 132 / 255, blue: 255 / 255)
static let green = Color(red: 16 / 255, green: 185 / 255, blue: 129 / 255)
static let gridLine = Color(red: 93 / 255, green: 132 / 255, blue: 181 / 255)
static let muted = Color(red: 155 / 255, green: 169 / 255, blue: 191 / 255)
// Light theme
static let lightTop = Color(red: 248 / 255, green: 250 / 255, blue: 252 / 255)
static let lightBottom = Color(red: 226 / 255, green: 232 / 255, blue: 240 / 255)
static let ink = Color(red: 15 / 255, green: 27 / 255, blue: 45 / 255)
static let lightMuted = Color(red: 100 / 255, green: 116 / 255, blue: 139 / 255)
}
/// Light/dark variants of the card. Blue/green accents are shared; everything
/// else (surface, text, grid, panels) flips per theme.
enum ShareCardTheme: String, CaseIterable, Identifiable {
case dark, light
var id: String { rawValue }
var ink: Color { self == .dark ? .white : ShareCardStyle.ink }
var mutedInk: Color { self == .dark ? ShareCardStyle.muted : ShareCardStyle.lightMuted }
var panelFill: Color { self == .dark ? Color.white.opacity(0.06) : ShareCardStyle.ink.opacity(0.05) }
var strokeColor: Color { self == .dark ? Color.white.opacity(0.12) : ShareCardStyle.ink.opacity(0.10) }
var footerFill: Color { self == .dark ? Color.white.opacity(0.08) : ShareCardStyle.ink.opacity(0.04) }
var gridOpacity: Double { self == .dark ? 0.10 : 0.06 }
var glowOpacityMul: Double { self == .dark ? 1.0 : 0.7 }
}
struct ShareCardBackground: View {
var theme: ShareCardTheme = .dark
var body: some View {
VStack(alignment: .leading, spacing: 14) {
Text(summary.formattedMonthYear)
.font(.caption.weight(.semibold))
.foregroundColor(.white.opacity(0.85))
ZStack {
LinearGradient(
colors: theme == .dark
? [ShareCardStyle.navyTop, ShareCardStyle.navyBottom]
: [ShareCardStyle.lightTop, ShareCardStyle.lightBottom],
startPoint: .top, endPoint: .bottom
)
Text("Monthly Check-in")
.font(.title2.weight(.bold))
.foregroundColor(.white)
// Atmospheric glows (blue top-left, green bottom-right)
Circle()
.fill(ShareCardStyle.blue.opacity(0.30 * theme.glowOpacityMul))
.frame(width: 280, height: 280)
.blur(radius: 70)
.offset(x: -100, y: -120)
Circle()
.fill(ShareCardStyle.green.opacity(0.22 * theme.glowOpacityMul))
.frame(width: 260, height: 260)
.blur(radius: 80)
.offset(x: 130, y: 150)
metricRow("Starting", summary.formattedStartingValue)
metricRow("Ending", summary.formattedEndingValue)
if summary.contributions != 0 {
metricRow("Contributions", summary.formattedContributions)
}
metricRow("Net performance", "\(summary.formattedNetPerformance) (\(summary.formattedNetPerformancePercentage))")
Spacer(minLength: 0)
shareFooter(appName: appName, qrCodeImage: qrCodeImage)
// Subtle technical grid
ShareCardGrid()
.stroke(ShareCardStyle.gridLine.opacity(theme.gridOpacity), lineWidth: 1)
}
.padding(20)
.frame(width: 320, height: 300)
.background(LinearGradient.appPrimaryGradient)
.clipShape(RoundedRectangle(cornerRadius: 24, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 24, style: .continuous)
.stroke(Color.white.opacity(0.2), lineWidth: 1)
)
}
}
private struct ShareCardGrid: Shape {
func path(in rect: CGRect) -> Path {
var p = Path()
let step: CGFloat = 44
var x: CGFloat = 0
while x <= rect.width {
p.move(to: CGPoint(x: x, y: 0))
p.addLine(to: CGPoint(x: x, y: rect.height))
x += step
}
var y: CGFloat = 0
while y <= rect.height {
p.move(to: CGPoint(x: 0, y: y))
p.addLine(to: CGPoint(x: rect.width, y: y))
y += step
}
return p
}
}
/// Gradient evolution sparkline (line + soft area + endpoint dot).
struct ShareSparkline: View {
let values: [Double]
var body: some View {
GeometryReader { geo in
let pts = points(in: geo.size)
if pts.count > 1 {
ZStack {
// Area fill
Path { p in
p.move(to: CGPoint(x: pts[0].x, y: geo.size.height))
pts.forEach { p.addLine(to: $0) }
p.addLine(to: CGPoint(x: pts[pts.count - 1].x, y: geo.size.height))
p.closeSubpath()
}
.fill(
LinearGradient(colors: [ShareCardStyle.blue.opacity(0.30), .clear],
startPoint: .top, endPoint: .bottom)
)
// Gradient line
Path { p in
p.move(to: pts[0])
pts.dropFirst().forEach { p.addLine(to: $0) }
}
.stroke(
LinearGradient(colors: [ShareCardStyle.blue, .cyan],
startPoint: .leading, endPoint: .trailing),
style: StrokeStyle(lineWidth: 2.4, lineCap: .round, lineJoin: .round)
)
// Endpoint dot ("now")
if let last = pts.last {
Circle()
.fill(Color.cyan)
.frame(width: 7, height: 7)
.position(last)
Circle()
.fill(Color.cyan.opacity(0.3))
.frame(width: 16, height: 16)
.position(last)
}
}
}
}
}
private func points(in size: CGSize) -> [CGPoint] {
guard values.count > 1 else { return [] }
let minV = values.min() ?? 0
let maxV = values.max() ?? 1
let range = maxV - minV
let inset: CGFloat = 6
return values.indices.map { i in
let x = size.width * CGFloat(i) / CGFloat(values.count - 1)
let norm = range > 0 ? (values[i] - minV) / range : 0.5
let y = inset + (size.height - inset * 2) * (1 - CGFloat(norm))
return CGPoint(x: x, y: y)
}
}
}
// MARK: - Shared pieces
private func eyebrow(_ text: String) -> some View {
Text(text.uppercased())
.font(.caption2.weight(.bold))
.tracking(2.2)
.foregroundStyle(ShareCardStyle.green)
}
private func deltaChip(_ text: String, positive: Bool, large: Bool = false) -> some View {
HStack(spacing: 4) {
Image(systemName: positive ? "arrow.up.right" : "arrow.down.right")
.font(.system(size: large ? 13 : 10, weight: .bold))
Text(text)
.font((large ? Font.headline : Font.caption).weight(.bold))
}
.foregroundStyle(positive ? ShareCardStyle.green : Color(red: 1, green: 0.42, blue: 0.42))
.padding(.horizontal, large ? 12 : 9)
.padding(.vertical, large ? 6 : 4)
.background(
Capsule().fill((positive ? ShareCardStyle.green : Color.red).opacity(0.14))
)
}
private func metricRow(_ title: String, _ value: String, theme: ShareCardTheme) -> some View {
HStack {
Text(title)
.font(.caption.weight(.semibold))
.foregroundStyle(theme.mutedInk)
Spacer()
Text(value)
.font(.subheadline.weight(.semibold))
.foregroundStyle(theme.ink)
.multilineTextAlignment(.trailing)
.minimumScaleFactor(0.7)
.lineLimit(1)
}
}
/// Brand lockup pinned at the TOP of every card big and unmissable so the
/// name does the branding work (and never clips like the old footer mark did).
/// Optional `asOf` line gives an outsider the context ("what date is this?").
private func brandHeader(theme: ShareCardTheme, asOf: String?) -> some View {
HStack(spacing: 12) {
Image("BrandMark")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 48, height: 48)
.clipShape(RoundedRectangle(cornerRadius: 11, style: .continuous))
VStack(alignment: .leading, spacing: 1) {
Text("Portfolio Journal")
.font(.system(.title2, design: .rounded).weight(.heavy))
.foregroundStyle(theme.ink)
.minimumScaleFactor(0.7)
.lineLimit(1)
if let asOf {
Text(String(format: String(localized: "share_as_of"), asOf))
.font(.caption2.weight(.medium))
.foregroundStyle(theme.mutedInk)
}
}
Spacer(minLength: 0)
}
}
/// Download-focused footer: an explicit "get the app" CTA plus a scannable QR.
/// This is the conversion surface every shared card should sell the install.
private func downloadFooter(qrCodeImage: UIImage?, theme: ShareCardTheme) -> some View {
HStack(spacing: 12) {
VStack(alignment: .leading, spacing: 3) {
Text(String(localized: "share_cta_headline"))
.font(.subheadline.weight(.bold))
.foregroundStyle(theme.ink)
.fixedSize(horizontal: false, vertical: true)
Text(String(localized: "share_cta_sub"))
.font(.caption2.weight(.medium))
.foregroundStyle(theme.mutedInk)
}
Spacer(minLength: 8)
if let qrCodeImage {
Image(uiImage: qrCodeImage)
.interpolation(.none)
.resizable()
.frame(width: 54, height: 54)
.padding(4)
.background(Color.white)
.clipShape(RoundedRectangle(cornerRadius: 9, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 9, style: .continuous)
.stroke(ShareCardStyle.ink.opacity(theme == .light ? 0.10 : 0), lineWidth: 1)
)
}
}
.padding(12)
.background(
RoundedRectangle(cornerRadius: 14, style: .continuous)
.fill(theme.footerFill)
)
.overlay(
RoundedRectangle(cornerRadius: 14, style: .continuous)
.stroke(ShareCardStyle.green.opacity(0.35), lineWidth: 1)
)
}
// MARK: - Portfolio value card
struct PortfolioValueShareCardView: View {
let totalValue: String
let changeText: String
@@ -44,102 +267,245 @@ struct PortfolioValueShareCardView: View {
let sinceInceptionChange: String?
let appName: String
var qrCodeImage: UIImage? = nil
var sparkline: [Double] = []
var isPositive: Bool = true
/// The all-time (or best available) return %, shown as the hero this is
/// the number people actually want to post. Falls back to nil uses value.
var heroPercent: String? = nil
/// Date of the latest check-in, shown under the brand so an outsider knows
/// what point in time the numbers describe.
var asOf: String? = nil
var theme: ShareCardTheme = .dark
/// Story = 9:16 (Instagram/WhatsApp status); otherwise 4:5 feed post.
var storyFormat: Bool = false
// Exact aspect ratios so nothing clips: 9:16 story, 4:5 post.
private var cardWidth: CGFloat { storyFormat ? 360 : 360 }
private var cardHeight: CGFloat { storyFormat ? 640 : 450 }
var body: some View {
VStack(alignment: .leading, spacing: 14) {
Text("Portfolio Snapshot")
.font(.caption.weight(.semibold))
.foregroundColor(.white.opacity(0.85))
VStack(alignment: .leading, spacing: 0) {
brandHeader(theme: theme, asOf: asOf)
Text("Total Portfolio Value")
.font(.headline.weight(.semibold))
.foregroundColor(.white.opacity(0.9))
Spacer(minLength: storyFormat ? 24 : 14)
Text(totalValue)
.font(.system(size: 34, weight: .bold, design: .rounded))
.foregroundColor(.white)
Text("\(changeText) \(changeLabel)")
.font(.subheadline.weight(.semibold))
.foregroundColor(.white.opacity(0.9))
if let yearChange {
metricRow("YoY", yearChange)
// Hero: big return % (branding-friendly, privacy-friendly), with the
// portfolio value as the supporting line.
VStack(alignment: .leading, spacing: 8) {
eyebrow(String(localized: "share_hero_eyebrow"))
Text(heroPercent ?? totalValue)
.font(.system(size: storyFormat ? 52 : 44, weight: .heavy, design: .rounded))
.foregroundStyle(heroIsPercent ? (isPositive ? ShareCardStyle.green : Color(red: 1, green: 0.42, blue: 0.42)) : theme.ink)
.minimumScaleFactor(0.5)
.lineLimit(1)
if heroIsPercent && !totalValue.isEmpty {
Text(totalValue)
.font(.title3.weight(.bold))
.foregroundStyle(theme.ink)
.minimumScaleFactor(0.5)
.lineLimit(1)
}
deltaChip("\(changeText) \(changeLabel)", positive: isPositive, large: true)
}
if let sinceInceptionChange {
metricRow("Since inception", sinceInceptionChange)
if sparkline.count > 1 {
ShareSparkline(values: sparkline)
.frame(height: storyFormat ? 150 : 96)
.padding(.top, storyFormat ? 24 : 16)
}
Spacer(minLength: 0)
shareFooter(appName: appName, qrCodeImage: qrCodeImage)
}
.padding(20)
.frame(width: 320, height: 290)
.background(
LinearGradient(
colors: [Color.appSecondary, Color.appPrimary],
startPoint: .topLeading,
endPoint: .bottomTrailing
VStack(spacing: 9) {
if let yearChange {
metricRow(String(localized: "share_metric_yoy"), yearChange, theme: theme)
}
if let sinceInceptionChange {
metricRow(String(localized: "share_metric_since_inception"), sinceInceptionChange, theme: theme)
}
}
.padding(13)
.background(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(theme.panelFill)
)
)
.clipShape(RoundedRectangle(cornerRadius: 24, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 24, style: .continuous)
.stroke(Color.white.opacity(0.2), lineWidth: 1)
)
}
}
.padding(.top, storyFormat ? 24 : 14)
private func metricRow(_ title: String, _ value: String) -> some View {
HStack {
Text(title)
.font(.caption.weight(.semibold))
.foregroundColor(.white.opacity(0.8))
Spacer()
Text(value)
.font(.subheadline.weight(.semibold))
.foregroundColor(.white)
.multilineTextAlignment(.trailing)
}
}
Spacer(minLength: 14)
private func shareFooter(appName: String, qrCodeImage: UIImage?) -> some View {
VStack(spacing: 10) {
Rectangle()
.fill(Color.white.opacity(0.2))
.frame(height: 1)
HStack(spacing: 12) {
Image("BrandMark")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 36, height: 36)
.clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
VStack(alignment: .leading, spacing: 2) {
Text("Powered by")
.font(.caption2.weight(.medium))
.foregroundColor(.white.opacity(0.7))
Text(appName)
.font(.subheadline.weight(.bold))
.foregroundColor(.white)
}
Spacer()
if let qrCodeImage {
Image(uiImage: qrCodeImage)
.interpolation(.none)
.resizable()
.frame(width: 48, height: 48)
.background(Color.white)
.clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous))
} else {
Image(systemName: "qrcode")
.font(.title3)
.foregroundColor(.white.opacity(0.9))
}
downloadFooter(qrCodeImage: qrCodeImage, theme: theme)
}
.padding(storyFormat ? 28 : 22)
.frame(width: cardWidth, height: cardHeight)
.background(ShareCardBackground(theme: theme))
.clipShape(RoundedRectangle(cornerRadius: 26, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 26, style: .continuous)
.stroke(theme.strokeColor, lineWidth: 1)
)
}
private var heroIsPercent: Bool { heroPercent != nil }
}
// MARK: - Monthly check-in card
struct MonthlyCheckInShareCardView: View {
let summary: MonthlySummary
let appName: String
var qrCodeImage: UIImage? = nil
var theme: ShareCardTheme = .dark
var body: some View {
VStack(alignment: .leading, spacing: 0) {
brandHeader(theme: theme, asOf: summary.formattedMonthYear)
Spacer(minLength: 16)
eyebrow(summary.formattedMonthYear)
Text("Monthly Check-in")
.font(.title.weight(.bold))
.foregroundStyle(theme.ink)
.padding(.top, 4)
VStack(spacing: 9) {
metricRow("Starting", summary.formattedStartingValue, theme: theme)
metricRow("Ending", summary.formattedEndingValue, theme: theme)
if summary.contributions != 0 {
metricRow("Contributions", summary.formattedContributions, theme: theme)
}
metricRow("Net performance", "\(summary.formattedNetPerformance) (\(summary.formattedNetPerformancePercentage))", theme: theme)
}
.padding(13)
.background(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(theme.panelFill)
)
.padding(.top, 16)
Spacer(minLength: 14)
downloadFooter(qrCodeImage: qrCodeImage, theme: theme)
}
.padding(22)
.frame(width: 360, height: 420)
.background(ShareCardBackground(theme: theme))
.clipShape(RoundedRectangle(cornerRadius: 26, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 26, style: .continuous)
.stroke(theme.strokeColor, lineWidth: 1)
)
}
}
// MARK: - Pre-share options (privacy + format) with live preview
/// Sheet shown before sharing the portfolio card. The privacy toggle ("only
/// percentages") is the referral unlock: nobody shares absolute net worth,
/// everybody shares +42%.
struct PortfolioShareOptionsView: View {
let totalValue: String
let changeText: String
let changeLabel: String
let yearChange: String?
let sinceInceptionChange: String?
let sparkline: [Double]
let isPositive: Bool
var asOf: String? = nil
@State private var percentOnly = false
@State private var storyFormat = false
@State private var theme: ShareCardTheme = .dark
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationStack {
VStack(spacing: 16) {
Spacer(minLength: 0)
// Live preview scaled to fit
previewCard
.scaleEffect(storyFormat ? 0.62 : 0.78)
.frame(height: storyFormat ? 310 : 275)
.animation(.snappy, value: percentOnly)
.animation(.snappy, value: storyFormat)
.animation(.snappy, value: theme)
Spacer(minLength: 0)
VStack(spacing: 12) {
Toggle(isOn: $percentOnly.animation(.snappy)) {
Label(String(localized: "share_percent_only"), systemImage: "eye.slash")
.font(.subheadline)
}
.tint(.appPrimary)
Picker("Theme", selection: $theme.animation(.snappy)) {
Text(String(localized: "share_theme_dark")).tag(ShareCardTheme.dark)
Text(String(localized: "share_theme_light")).tag(ShareCardTheme.light)
}
.pickerStyle(.segmented)
Picker("Format", selection: $storyFormat.animation(.snappy)) {
Text(String(localized: "share_format_post")).tag(false)
Text(String(localized: "share_format_story")).tag(true)
}
.pickerStyle(.segmented)
}
.padding(.horizontal)
Button {
ShareService.shared.sharePortfolioValue(
totalValue: totalValue,
changeText: changeText,
changeLabel: changeLabel,
yearChange: yearChange,
sinceInceptionChange: sinceInceptionChange,
sparkline: sparkline,
isPositive: isPositive,
asOf: asOf,
theme: theme,
percentOnly: percentOnly,
storyFormat: storyFormat
)
dismiss()
} label: {
Text(String(localized: "share_card_cta"))
.font(.headline)
.frame(maxWidth: .infinity)
.padding(.vertical, 14)
.background(Color.appPrimary)
.foregroundStyle(.white)
.clipShape(RoundedRectangle(cornerRadius: 14))
}
.padding(.horizontal)
.padding(.bottom, 8)
}
.navigationTitle(String(localized: "share_options_title"))
.navigationBarTitleDisplayMode(.inline)
}
.presentationDetents([.large])
}
private var previewCard: some View {
let display = ShareService.portfolioCardStrings(
totalValue: totalValue,
changeText: changeText,
yearChange: yearChange,
sinceInceptionChange: sinceInceptionChange,
percentOnly: percentOnly
)
return PortfolioValueShareCardView(
totalValue: display.totalValue,
changeText: display.changeText,
changeLabel: changeLabel,
yearChange: display.yearChange,
sinceInceptionChange: display.sinceInceptionChange,
appName: "Portfolio Journal",
qrCodeImage: GoalShareService.generateQRCode(for: ShareService.snapshotShareURL, size: 200),
sparkline: sparkline,
isPositive: isPositive,
heroPercent: display.heroPercent,
asOf: asOf,
theme: theme,
storyFormat: storyFormat
)
}
}
@@ -7,6 +7,9 @@ struct GoalEditorView: View {
@State private var targetDate = Date()
@State private var includeTargetDate = false
@State private var didLoadGoal = false
@State private var selectedCategoryId: UUID?
@StateObject private var categoryRepository = CategoryRepository()
let account: Account?
let goal: Goal?
@@ -38,7 +41,7 @@ struct GoalEditorView: View {
TextField("Goal name", text: $name)
HStack {
Text(currencySymbol)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
TextField("Target amount", text: $targetAmount)
.keyboardType(.decimalPad)
}
@@ -51,13 +54,29 @@ struct GoalEditorView: View {
} header: {
Text("Goal Details")
}
Section {
Picker(String(localized: "goal_scope_category"), selection: $selectedCategoryId) {
Text("goal_scope_all_categories").tag(UUID?.none)
ForEach(categoryRepository.categories) { category in
Label(category.name, systemImage: category.icon)
.tag(UUID?.some(category.id))
}
}
} header: {
Text("goal_scope_section")
} footer: {
Text(selectedCategoryId == nil
? String(localized: "goal_scope_footer_all")
: String(localized: "goal_scope_footer_category"))
}
}
.navigationTitle(goal == nil ? "New Goal" : "Edit Goal")
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
ToolbarItem(placement: .topBarLeading) {
Button("Cancel") { dismiss() }
}
ToolbarItem(placement: .navigationBarTrailing) {
ToolbarItem(placement: .topBarTrailing) {
Button("Save") { saveGoal() }
.disabled(!isValid)
}
@@ -67,7 +86,7 @@ struct GoalEditorView: View {
.presentationDragIndicator(.visible)
.onAppear {
guard let goal, !didLoadGoal else { return }
name = goal.name ?? ""
name = goal.name
if let amount = goal.targetAmount?.decimalValue {
targetAmount = formatDecimalForInput(amount)
}
@@ -75,6 +94,7 @@ struct GoalEditorView: View {
includeTargetDate = true
targetDate = target
}
selectedCategoryId = goal.category?.safeId
didLoadGoal = true
}
}
@@ -83,22 +103,31 @@ struct GoalEditorView: View {
!name.trimmingCharacters(in: .whitespaces).isEmpty && parseDecimal(targetAmount) != nil
}
private var selectedCategory: Category? {
guard let selectedCategoryId else { return nil }
return categoryRepository.categories.first { $0.safeId == selectedCategoryId }
}
private func saveGoal() {
guard let value = parseDecimal(targetAmount) else { return }
let category = selectedCategory
if let goal {
goalRepository.updateGoal(
goal,
name: name,
targetAmount: value,
targetDate: includeTargetDate ? targetDate : nil,
clearTargetDate: !includeTargetDate
clearTargetDate: !includeTargetDate,
category: category,
clearCategory: category == nil
)
} else {
goalRepository.createGoal(
name: name,
targetAmount: value,
targetDate: includeTargetDate ? targetDate : nil,
account: account
account: account,
category: category
)
}
dismiss()
@@ -26,7 +26,7 @@ struct GoalProgressBar: View {
.overlay(alignment: .leading) {
Image(systemName: iconName)
.font(.caption2)
.foregroundColor(iconColor)
.foregroundStyle(iconColor)
.offset(x: iconOffset)
}
}
@@ -34,10 +34,10 @@ struct GoalShareCardView: View {
VStack(alignment: .leading, spacing: 4) {
Text("Goal Progress")
.font(.caption.weight(.semibold))
.foregroundColor(.white.opacity(0.8))
.foregroundStyle(.white.opacity(0.8))
Text(name)
.font(.title2.weight(.bold))
.foregroundColor(.white)
.foregroundStyle(.white)
}
GoalProgressBar(
@@ -51,27 +51,27 @@ struct GoalShareCardView: View {
Text("\(progressText) complete")
.font(.caption.weight(.semibold))
.foregroundColor(.white.opacity(0.85))
.foregroundStyle(.white.opacity(0.85))
if privacyMode {
HStack {
Text("Progress")
.font(.subheadline.weight(.semibold))
.foregroundColor(.white.opacity(0.9))
.foregroundStyle(.white.opacity(0.9))
Spacer()
Text(progressText)
.font(.headline.weight(.bold))
.foregroundColor(.white)
.foregroundStyle(.white)
}
} else {
HStack {
Text(displayCurrentValue)
.font(.headline)
.foregroundColor(.white)
.foregroundStyle(.white)
Spacer()
Text("of \(targetValue.currencyString)")
.font(.subheadline.weight(.medium))
.foregroundColor(.white.opacity(0.8))
.foregroundStyle(.white.opacity(0.8))
}
}
@@ -82,7 +82,7 @@ struct GoalShareCardView: View {
Text("Target: \(targetDate.mediumDateString)")
.font(.caption.weight(.medium))
}
.foregroundColor(.white.opacity(0.85))
.foregroundStyle(.white.opacity(0.85))
}
if let estimatedCompletionDate {
@@ -92,7 +92,7 @@ struct GoalShareCardView: View {
Text("Est. completion: \(estimatedCompletionDate.mediumDateString)")
.font(.caption.weight(.medium))
}
.foregroundColor(.white.opacity(0.85))
.foregroundStyle(.white.opacity(0.85))
}
if privacyMode {
@@ -101,7 +101,7 @@ struct GoalShareCardView: View {
Text("Privacy mode enabled")
}
.font(.caption.weight(.semibold))
.foregroundColor(.white.opacity(0.7))
.foregroundStyle(.white.opacity(0.7))
}
Spacer(minLength: 0)
@@ -124,10 +124,10 @@ struct GoalShareCardView: View {
VStack(alignment: .leading, spacing: 2) {
Text("Powered by")
.font(.caption2.weight(.medium))
.foregroundColor(.white.opacity(0.7))
.foregroundStyle(.white.opacity(0.7))
Text("Portfolio Journal")
.font(.subheadline.weight(.bold))
.foregroundColor(.white)
.foregroundStyle(.white)
}
Spacer()
@@ -144,7 +144,7 @@ struct GoalShareCardView: View {
Text("Scan to download")
.font(.system(size: 7, weight: .medium))
.foregroundColor(.white.opacity(0.8))
.foregroundStyle(.white.opacity(0.8))
}
} else {
// Fallback if QR code generation fails
@@ -154,7 +154,7 @@ struct GoalShareCardView: View {
Text("App Store")
.font(.caption2.weight(.semibold))
}
.foregroundColor(.white.opacity(0.9))
.foregroundStyle(.white.opacity(0.9))
}
}
}
+176 -65
View File
@@ -36,7 +36,7 @@ struct GoalsView: View {
progress: viewModel.progress(for: goal),
totalValue: viewModel.totalValue(for: goal),
paceStatus: viewModel.paceStatus(for: goal),
estimatedCompletionDate: viewModel.estimateCompletionDate(for: goal),
projection: viewModel.projection(for: goal),
onEdit: { editingGoal = goal }
)
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
@@ -72,7 +72,7 @@ struct GoalsView: View {
}
.navigationTitle("Goals")
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
ToolbarItem(placement: .topBarLeading) {
Picker(String(localized: "goals_filter_active"), selection: $goalFilter) {
ForEach(GoalFilter.allCases) { filter in
Text(filter.label).tag(filter)
@@ -80,7 +80,7 @@ struct GoalsView: View {
}
.pickerStyle(.menu)
}
ToolbarItem(placement: .navigationBarTrailing) {
ToolbarItem(placement: .topBarTrailing) {
Button {
showingAddGoal = true
} label: {
@@ -145,19 +145,19 @@ struct GoalsView: View {
VStack(spacing: 16) {
Image(systemName: "target")
.font(.system(size: 48))
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
Text("Set your first goal")
.font(.headline)
Text("Track progress toward milestones like \(AppSettings.getOrCreate(in: CoreDataStack.shared.viewContext).currencySymbol)1M and share your wins.")
.font(.subheadline)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
Button {
showingAddGoal = true
} label: {
Text(String(localized: "goals_empty_add_cta"))
.font(.subheadline.weight(.semibold))
.foregroundColor(.white)
.foregroundStyle(.white)
.padding(.horizontal, 20)
.padding(.vertical, 10)
.background(Color.appPrimary)
@@ -170,13 +170,13 @@ struct GoalsView: View {
VStack(spacing: 12) {
Image(systemName: "archivebox")
.font(.system(size: 40))
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
Text(String(localized: "goals_all_active_achieved"))
.font(.headline)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
Text("Switch to \"Archived\" or \"All\" to see other goals.")
.font(.subheadline)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity)
@@ -186,10 +186,10 @@ struct GoalsView: View {
VStack(spacing: 12) {
Image(systemName: "archivebox")
.font(.system(size: 40))
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
Text(String(localized: "goals_empty_archived"))
.font(.headline)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 32)
@@ -197,12 +197,12 @@ struct GoalsView: View {
VStack(spacing: 16) {
Image(systemName: "target")
.font(.system(size: 48))
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
Text("Set your first goal")
.font(.headline)
Text("Track progress toward milestones like \(AppSettings.getOrCreate(in: CoreDataStack.shared.viewContext).currencySymbol)1M and share your wins.")
.font(.subheadline)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity)
@@ -216,7 +216,7 @@ struct GoalRowView: View {
let progress: Double
let totalValue: Decimal
let paceStatus: GoalPaceStatus?
let estimatedCompletionDate: Date?
let projection: GoalProjection.Result
let onEdit: () -> Void
@State private var showingShareOptions = false
@@ -244,62 +244,81 @@ struct GoalRowView: View {
}
}
/// Semantic ring color: green when achieved or ahead, orange when behind,
/// accent while simply on the way.
private var ringTint: Color {
if isAchieved { return .appSuccess }
if paceStatus?.isBehind == true { return .appWarning }
return .appPrimary
}
var body: some View {
ZStack(alignment: .topTrailing) {
Button(action: onEdit) {
VStack(alignment: .leading, spacing: 12) {
HStack {
Text(goal.name)
.font(.headline)
.foregroundColor(isAchieved ? .appSecondary : .primary)
if isAchieved {
Text("Achieved")
.font(.caption2.weight(.bold))
.foregroundColor(.white)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(Color.appSecondary)
.clipShape(Capsule())
HStack(spacing: 16) {
ProgressRing(progress: progress, tint: ringTint)
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 8) {
Text(goal.name)
.font(.headline)
if isAchieved {
Text("Achieved")
.font(.caption2.weight(.bold))
.foregroundStyle(.white)
.padding(.horizontal, 8)
.padding(.vertical, 3)
.background(Color.appSuccess)
.clipShape(Capsule())
}
}
if let category = goal.category {
HStack(spacing: 4) {
Image(systemName: category.icon)
.font(.caption2)
Text(category.name)
.font(.caption2.weight(.semibold))
}
.foregroundStyle(category.color)
.padding(.horizontal, 8)
.padding(.vertical, 3)
.background(category.color.opacity(0.15))
.clipShape(Capsule())
}
HStack(spacing: 4) {
Text(totalValue.currencyString)
.font(.subheadline.weight(.semibold))
.hiddenBalance()
Text("of \(goal.targetDecimal.currencyString)")
.font(.subheadline)
.foregroundStyle(.secondary)
.hiddenBalance()
}
if let targetDate = goal.targetDate {
Text("Target date: \(targetDate.mediumDateString)")
.font(.caption)
.foregroundStyle(targetDateColor)
}
if let paceStatus {
Text(paceStatus.statusText)
.font(.caption.weight(.semibold))
.foregroundStyle(
isAchieved ? Color.appSuccess : (paceStatus.isBehind ? Color.appWarning : Color.positiveGreen)
)
}
if !isAchieved {
projectionRow
}
}
GoalProgressBar(
progress: progress,
tint: isAchieved ? .appSuccess : .appSecondary,
iconColor: isAchieved ? .appSuccess : .appSecondary
)
HStack {
Text(totalValue.currencyString)
.font(.subheadline.weight(.semibold))
Spacer()
Text("of \(goal.targetDecimal.currencyString)")
.font(.subheadline)
.foregroundColor(.secondary)
}
if let targetDate = goal.targetDate {
Text("Target date: \(targetDate.mediumDateString)")
.font(.caption)
.foregroundColor(targetDateColor)
}
if let paceStatus {
Text(paceStatus.statusText)
.font(.caption.weight(.semibold))
.foregroundColor(
isAchieved ? .appSuccess : (paceStatus.isBehind ? .negativeRed : .positiveGreen)
)
}
Spacer(minLength: 0)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(12)
.background(isAchieved ? Color.appSuccess.opacity(0.10) : Color.clear)
.overlay(
RoundedRectangle(cornerRadius: 12)
.stroke(isAchieved ? Color.appSuccess.opacity(0.35) : Color.clear, lineWidth: 1)
)
.cornerRadius(12)
.padding(.vertical, 8)
}
.buttonStyle(.plain)
@@ -307,7 +326,7 @@ struct GoalRowView: View {
showingShareOptions = true
} label: {
Image(systemName: "square.and.arrow.up")
.foregroundColor(.appPrimary)
.foregroundStyle(Color.appPrimary)
.padding(.top, 2)
}
.buttonStyle(.borderless)
@@ -333,10 +352,102 @@ struct GoalRowView: View {
currentValue: totalValue,
targetValue: goal.targetDecimal,
targetDate: goal.targetDate,
estimatedCompletionDate: estimatedCompletionDate,
estimatedCompletionDate: projection.projectedDate,
privacyMode: privacyMode
)
}
// MARK: - Projection UI
/// Projected completion date, an on-track chip, and a tiny projection sparkline.
@ViewBuilder
private var projectionRow: some View {
switch projection.status {
case .unreachable:
Label(String(localized: "goal_projection_add_contributions"), systemImage: "plus.circle")
.font(.caption)
.foregroundStyle(.secondary)
default:
HStack(spacing: 8) {
if let date = projection.projectedDate {
Text(String(format: String(localized: "goal_projection_projected"), date.monthYearString))
.font(.caption)
.foregroundStyle(.secondary)
}
onTrackChip
Spacer(minLength: 0)
if projection.sparkline.count >= 2 {
ProjectionSparkline(values: projection.sparkline, tint: chipColor)
.frame(width: 48, height: 16)
}
}
}
}
private var chipColor: Color {
switch projection.status {
case .aheadOfSchedule: return .appSuccess
case .onTrack: return .positiveGreen
case .behind: return .appWarning
case .noTargetDate: return .secondary
case .unreachable: return .secondary
}
}
private var chipText: String {
switch projection.status {
case .aheadOfSchedule: return String(localized: "goal_chip_ahead")
case .onTrack: return String(localized: "goal_chip_on_track")
case .behind: return String(localized: "goal_chip_behind")
case .noTargetDate: return String(localized: "goal_chip_at_this_pace")
case .unreachable: return ""
}
}
@ViewBuilder
private var onTrackChip: some View {
if projection.status != .unreachable {
Text(chipText)
.font(.caption2.weight(.semibold))
.foregroundStyle(chipColor)
.padding(.horizontal, 8)
.padding(.vertical, 3)
.background(chipColor.opacity(0.15))
.clipShape(Capsule())
}
}
}
/// Minimal filled sparkline for the goal value projection curve.
private struct ProjectionSparkline: View {
let values: [Decimal]
let tint: Color
var body: some View {
GeometryReader { geo in
let points = normalizedPoints(in: geo.size)
ZStack {
if points.count >= 2 {
Path { path in
path.move(to: points[0])
for p in points.dropFirst() { path.addLine(to: p) }
}
.stroke(tint, style: StrokeStyle(lineWidth: 1.5, lineCap: .round, lineJoin: .round))
}
}
}
}
private func normalizedPoints(in size: CGSize) -> [CGPoint] {
let doubles = values.map { NSDecimalNumber(decimal: $0).doubleValue }
guard let minV = doubles.min(), let maxV = doubles.max(), doubles.count >= 2 else { return [] }
let range = max(maxV - minV, 0.0001)
return doubles.enumerated().map { index, value in
let x = size.width * CGFloat(index) / CGFloat(doubles.count - 1)
let y = size.height * (1 - CGFloat((value - minV) / range))
return CGPoint(x: x, y: y)
}
}
}
#Preview {
@@ -10,71 +10,63 @@ struct JournalView: View {
@State private var selectedDate: Date?
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
/// Month list + inline check-in: two columns in regular width (iPad, iPhone
/// Duo inner screen), a collapsing stack in compact width. The selected
/// month is the only navigation state, so it survives the Duo opening/closing.
@State private var columnVisibility: NavigationSplitViewVisibility = .all
var body: some View {
if horizontalSizeClass == .regular {
iPadJournalLayout
} else {
iPhoneJournalLayout
}
}
// MARK: - iPad Layout (list + inline detail)
private var iPadJournalLayout: some View {
HStack(spacing: 0) {
// Left: month list
NavigationStack {
journalListContent(isPad: true)
.navigationTitle("Journal")
.searchable(text: $searchText, prompt: "Search monthly notes")
.onAppear { viewModel.refresh() }
}
.frame(width: 320)
Divider()
// Right: monthly check-in detail
NavigationSplitView(columnVisibility: $columnVisibility) {
journalListContent
.navigationTitle("Journal")
.searchable(text: $searchText, prompt: "Search monthly notes")
.onAppear { viewModel.refresh() }
.navigationSplitViewColumnWidth(min: 300, ideal: 340, max: 420)
} detail: {
NavigationStack {
if let date = selectedDate {
MonthlyCheckInView(referenceDate: date)
.id(date)
} else {
noMonthSelectedView
}
}
}
}
// MARK: - iPhone Layout (push navigation)
private var iPhoneJournalLayout: some View {
NavigationStack {
journalListContent(isPad: false)
.navigationTitle("Journal")
.searchable(text: $searchText, prompt: "Search monthly notes")
.onAppear { viewModel.refresh() }
}
.navigationSplitViewStyle(.balanced)
}
// MARK: - Shared List Content
private func journalListContent(isPad: Bool) -> some View {
private var journalListContent: some View {
ScrollViewReader { proxy in
ZStack {
AppBackground()
List {
List(selection: $selectedDate) {
// Calm 2.0: the habit story (streaks, achievements) lives in
// the Journal this is the emotional home of the ritual.
if !viewModel.monthlyNotes.isEmpty && searchText.isEmpty {
Section {
// Narrow sidebar column in regular width compact stat tiles.
MomentumStreaksCard(compact: horizontalSizeClass == .regular)
}
.listRowInsets(EdgeInsets())
.listRowBackground(Color.clear)
.listSectionSeparator(.hidden)
}
Section("Monthly Check-ins") {
if filteredMonthlyNotes.isEmpty {
if searchText.isEmpty {
VStack(spacing: 12) {
Image(systemName: "book.closed")
.font(.system(size: 36))
.foregroundColor(.appSecondary)
.foregroundStyle(Color.appSecondary)
Text(String(localized: "journal_empty_title"))
.font(.headline)
Text(String(localized: "journal_empty_body"))
.font(.subheadline)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
.padding(.vertical, 16)
@@ -82,34 +74,16 @@ struct JournalView: View {
} else {
Text("No matching notes.")
.font(.subheadline)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
} else {
ForEach(filteredMonthlyNotes) { entry in
if isPad {
Button {
selectedDate = entry.date
} label: {
monthlyNoteRow(entry)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.listRowBackground(
selectedDate.map { $0.isSameMonth(as: entry.date) } == true
? Color.appPrimary.opacity(0.08)
: Color.clear
)
.id(entry.date)
// ForEach identity (MonthlyNoteItem.id == date) already serves
// ScrollViewReader; an extra .id() would wrap the .tag trait
// and break List selection.
monthlyNoteRow(entry)
.tag(entry.date)
.onAppear { currentVisibleMonth = entry.date }
} else {
NavigationLink {
MonthlyCheckInView(referenceDate: entry.date)
} label: {
monthlyNoteRow(entry)
}
.id(entry.date)
.onAppear { currentVisibleMonth = entry.date }
}
}
}
}
@@ -145,7 +119,7 @@ struct JournalView: View {
Image(systemName: "book.closed.fill")
.font(.system(size: 50, weight: .light))
.foregroundColor(.white)
.foregroundStyle(.white)
}
VStack(spacing: 10) {
@@ -153,14 +127,15 @@ struct JournalView: View {
.font(.title2.weight(.semibold))
Text("Choose a month from the list on the\nleft to view your check-in notes.")
.frame(maxWidth: 360)
.font(.subheadline)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
Label("Select from the list", systemImage: "arrow.left")
.font(.subheadline.weight(.semibold))
.foregroundColor(.appSecondary)
.foregroundStyle(Color.appSecondary)
.padding(.horizontal, 22)
.padding(.vertical, 11)
.background(Color.appSecondary.opacity(0.1))
@@ -183,36 +158,42 @@ struct JournalView: View {
}
}
/// Timeline-style row: the mood is the visual anchor of each month.
private func monthlyNoteRow(_ entry: MonthlyNoteItem) -> some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
Text(entry.date.monthYearString)
.font(.subheadline.weight(.semibold))
Spacer()
HStack(alignment: .top, spacing: 12) {
ZStack {
Circle()
.fill((entry.mood?.tint ?? Color.secondary).opacity(0.14))
.frame(width: 40, height: 40)
Image(systemName: entry.mood?.iconName ?? "book.closed")
.font(.system(size: 16))
.foregroundStyle(entry.mood?.tint ?? .secondary)
}
VStack(alignment: .leading, spacing: 4) {
HStack {
Text(entry.date.monthYearString)
.font(.subheadline.weight(.semibold))
Spacer()
if entry.rating != nil {
starRow(rating: entry.rating)
.font(.caption2)
}
}
if let mood = entry.mood {
moodPill(mood)
} else {
Text("Mood not set")
.font(.caption)
.foregroundColor(.secondary)
Text(mood.title)
.font(.caption.weight(.medium))
.foregroundStyle(mood.tint)
}
}
HStack(spacing: 6) {
starRow(rating: entry.rating)
if entry.rating == nil {
Text("No rating")
.font(.caption)
.foregroundColor(.secondary)
}
Text(entry.note.isEmpty ? String(localized: "journal_no_note") : entry.note)
.font(.subheadline)
.foregroundStyle(.secondary)
.lineLimit(2)
}
Text(entry.note.isEmpty ? "No note yet." : entry.note)
.font(.subheadline)
.foregroundColor(.secondary)
.lineLimit(2)
}
.padding(.vertical, 4)
.padding(.vertical, 6)
}
private func starRow(rating: Int?) -> some View {
@@ -220,7 +201,7 @@ struct JournalView: View {
return HStack(spacing: 4) {
ForEach(1...5, id: \.self) { index in
Image(systemName: index <= value ? "star.fill" : "star")
.foregroundColor(index <= value ? .yellow : .secondary)
.foregroundStyle(index <= value ? .yellow : .secondary)
}
}
.accessibilityLabel(
@@ -234,7 +215,7 @@ struct JournalView: View {
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(Color.gray.opacity(0.15))
.foregroundColor(.primary)
.foregroundStyle(.primary)
.clipShape(Capsule())
}
@@ -252,9 +233,7 @@ struct JournalView: View {
.font(.caption.weight(.semibold))
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(Color(.systemBackground).opacity(0.95))
.cornerRadius(12)
.shadow(color: .black.opacity(0.12), radius: 6, y: 2)
.floatingPillBackground()
.offset(x: -16)
}
@@ -263,9 +242,7 @@ struct JournalView: View {
.font(.caption.weight(.semibold))
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(Color(.systemBackground).opacity(0.95))
.cornerRadius(12)
.shadow(color: .black.opacity(0.12), radius: 6, y: 2)
.floatingPillBackground()
.offset(x: -16, y: scrubberOffset - height / 2)
}
@@ -322,13 +299,13 @@ struct MonthlyNoteEditorView: View {
.frame(minHeight: 200)
.padding(8)
.background(Color.gray.opacity(0.08))
.cornerRadius(12)
.clipShape(RoundedRectangle(cornerRadius: 12))
}
.padding()
.navigationTitle("Monthly Note")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
ToolbarItem(placement: .topBarTrailing) {
Button("Save") {
MonthlyCheckInStore.setNote(note, for: date)
dismiss()
@@ -339,6 +316,19 @@ struct MonthlyNoteEditorView: View {
}
}
extension MonthlyCheckInMood {
/// Semantic tint per mood the timeline's color language.
var tint: Color {
switch self {
case .energized: return .orange
case .confident: return .positiveGreen
case .balanced: return .teal
case .cautious: return .yellow
case .stressed: return .negativeRed
}
}
}
#Preview {
NavigationStack {
JournalView()
@@ -33,7 +33,7 @@ struct OnboardingICloudCheckView: View {
.frame(width: 100, height: 100)
Image(systemName: "icloud.fill")
.font(.system(size: 50))
.foregroundColor(.appPrimary)
.foregroundStyle(Color.appPrimary)
}
VStack(spacing: 14) {
@@ -43,7 +43,7 @@ struct OnboardingICloudCheckView: View {
Text(String(localized: "icloud_check_description"))
.font(.body)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal, 40)
}
@@ -59,11 +59,11 @@ struct OnboardingICloudCheckView: View {
} label: {
Label("Restore from iCloud", systemImage: "icloud.and.arrow.down")
.font(.headline)
.foregroundColor(.white)
.foregroundStyle(.white)
.frame(maxWidth: .infinity)
.padding()
.background(Color.appPrimary)
.cornerRadius(AppConstants.UI.cornerRadius)
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
}
Button {
@@ -71,7 +71,7 @@ struct OnboardingICloudCheckView: View {
} label: {
Text("Start Fresh")
.font(.subheadline)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
}
.padding(.horizontal, 24)
@@ -96,7 +96,7 @@ struct OnboardingICloudCheckView: View {
.frame(width: 100, height: 100)
Image(systemName: "checkmark.icloud.fill")
.font(.system(size: 50))
.foregroundColor(.positiveGreen)
.foregroundStyle(Color.positiveGreen)
}
VStack(spacing: 14) {
@@ -106,7 +106,7 @@ struct OnboardingICloudCheckView: View {
Text(String(localized: "icloud_enabled_description"))
.font(.body)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal, 40)
}
@@ -123,11 +123,11 @@ struct OnboardingICloudCheckView: View {
} label: {
Text("Got it")
.font(.headline)
.foregroundColor(.white)
.foregroundStyle(.white)
.frame(maxWidth: .infinity)
.padding()
.background(Color.positiveGreen)
.cornerRadius(AppConstants.UI.cornerRadius)
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
}
.padding(.horizontal, 24)
.padding(.bottom, 40)
@@ -13,6 +13,12 @@ struct OnboardingView: View {
@State private var showingImportSheet = false
@State private var showingAddSource = false
// Forwarded into the ImportDataView sheet sheets don't inherit
// @EnvironmentObject on Mac Catalyst, which crashed the import screen.
@EnvironmentObject private var iapService: IAPService
@EnvironmentObject private var accountStore: AccountStore
@EnvironmentObject private var tabSelection: TabSelectionStore
private let pages: [OnboardingPage] = [
OnboardingPage(
icon: "chart.line.uptrend.xyaxis",
@@ -82,11 +88,11 @@ struct OnboardingView: View {
} label: {
Text("Continue")
.font(.headline)
.foregroundColor(.white)
.foregroundStyle(.white)
.frame(maxWidth: .infinity)
.padding()
.background(Color.appPrimary)
.cornerRadius(AppConstants.UI.cornerRadius)
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
}
Button {
@@ -95,7 +101,7 @@ struct OnboardingView: View {
} label: {
Text("Skip")
.font(.subheadline)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
} else {
Button {
@@ -103,11 +109,11 @@ struct OnboardingView: View {
} label: {
Text("Get Started")
.font(.headline)
.foregroundColor(.white)
.foregroundStyle(.white)
.frame(maxWidth: .infinity)
.padding()
.background(Color.appPrimary)
.cornerRadius(AppConstants.UI.cornerRadius)
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
}
}
}
@@ -123,6 +129,9 @@ struct OnboardingView: View {
}
.sheet(isPresented: $showingImportSheet) {
ImportDataView(importContext: .onboarding)
.environmentObject(iapService)
.environmentObject(accountStore)
.environmentObject(tabSelection)
}
.sheet(isPresented: $showingAddSource) {
AddSourceView()
@@ -135,7 +144,7 @@ struct OnboardingView: View {
categoryRepository.createDefaultCategoriesIfNeeded()
// Create default account if needed
AccountRepository().createDefaultAccountIfNeeded()
_ = AccountRepository().createDefaultAccountIfNeeded()
// Mark onboarding as complete
let context = CoreDataStack.shared.viewContext
@@ -192,7 +201,7 @@ struct OnboardingPageView: View {
Image(systemName: page.icon)
.font(.system(size: 50))
.foregroundColor(page.color)
.foregroundStyle(page.color)
}
// Content
@@ -203,7 +212,7 @@ struct OnboardingPageView: View {
Text(page.description)
.font(.body)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal, 40)
}
@@ -246,7 +255,7 @@ struct OnboardingQuickStartView: View {
Text(String(localized: "onboarding_quickstart_subtitle"))
.font(.subheadline)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal, 30)
}
@@ -259,8 +268,8 @@ struct OnboardingQuickStartView: View {
.frame(maxWidth: .infinity)
.padding()
.background(Color.appPrimary)
.foregroundColor(.white)
.cornerRadius(AppConstants.UI.cornerRadius)
.foregroundStyle(.white)
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
}
Button(action: onImport) {
@@ -269,8 +278,8 @@ struct OnboardingQuickStartView: View {
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
.background(Color.appPrimary.opacity(0.1))
.foregroundColor(.appPrimary)
.cornerRadius(AppConstants.UI.cornerRadius)
.foregroundStyle(Color.appPrimary)
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
}
}
@@ -288,13 +297,13 @@ struct OnboardingQuickStartView: View {
}
.padding()
.background(Color.gray.opacity(0.1))
.cornerRadius(12)
.clipShape(RoundedRectangle(cornerRadius: 12))
Toggle("Sync with iCloud (optional)", isOn: $cloudSyncEnabled)
.padding()
.background(Color.gray.opacity(0.1))
.cornerRadius(12)
.clipShape(RoundedRectangle(cornerRadius: 12))
}
Spacer().frame(height: 8)
@@ -317,7 +326,7 @@ struct WelcomeView: View {
Image(systemName: "hand.wave.fill")
.font(.system(size: 60))
.foregroundColor(.appPrimary)
.foregroundStyle(Color.appPrimary)
VStack(spacing: 8) {
if let name = userName {
@@ -332,7 +341,7 @@ struct WelcomeView: View {
? "Your investment data has been synced from iCloud."
: "Your investment data stays on this device.")
.font(.subheadline)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
@@ -345,11 +354,11 @@ struct WelcomeView: View {
} label: {
Text("Continue")
.font(.headline)
.foregroundColor(.white)
.foregroundStyle(.white)
.frame(maxWidth: .infinity)
.padding()
.background(Color.appPrimary)
.cornerRadius(AppConstants.UI.cornerRadius)
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
}
.padding(.horizontal, 24)
.padding(.bottom, 40)
@@ -7,86 +7,223 @@ struct WhatsNewFeature {
let description: String
}
struct WhatsNewRelease: Identifiable {
let version: String
let features: [WhatsNewFeature]
var id: String { version }
}
// MARK: - Versioned catalog
//
// One entry per released version, newest first. The sheet shows the CUMULATIVE
// diff between the version the user is coming from (lastSeenWhatsNewVersion)
// and the one they just installed jumping 1.3.x 1.4.2 shows 1.4.0 + 1.4.1
// + 1.4.2, not just the last step. ADD AN ENTRY HERE WITH EVERY RELEASE.
enum WhatsNewCatalog {
static var releases: [WhatsNewRelease] {
[
WhatsNewRelease(version: "1.7.0", features: [
WhatsNewFeature(
icon: "iphone.gen3.and.iphone.gen3",
color: .appPrimary,
title: String(localized: "whats_new_170_duo_title"),
description: String(localized: "whats_new_170_duo_body")
),
WhatsNewFeature(
icon: "rectangle.trailinghalf.inset.filled",
color: .appSecondary,
title: String(localized: "whats_new_170_inspector_title"),
description: String(localized: "whats_new_170_inspector_body")
),
WhatsNewFeature(
icon: "touchid",
color: .positiveGreen,
title: String(localized: "whats_new_170_biometrics_title"),
description: String(localized: "whats_new_170_biometrics_body")
)
]),
WhatsNewRelease(version: "1.5.0", features: [
WhatsNewFeature(
icon: "sparkles",
color: .appPrimary,
title: String(localized: "whats_new_150_redesign_title"),
description: String(localized: "whats_new_150_redesign_body")
),
WhatsNewFeature(
icon: "figure.mind.and.body",
color: .positiveGreen,
title: String(localized: "whats_new_150_guided_title"),
description: String(localized: "whats_new_150_guided_body")
),
WhatsNewFeature(
icon: "circle.dashed.inset.filled",
color: .orange,
title: String(localized: "whats_new_150_rings_title"),
description: String(localized: "whats_new_150_rings_body")
)
]),
WhatsNewRelease(version: "1.4.2", features: [
WhatsNewFeature(
icon: "text.viewfinder",
color: .appPrimary,
title: String(localized: "whats_new_142_ocr_title"),
description: String(localized: "whats_new_142_ocr_body")
),
WhatsNewFeature(
icon: "mic.fill",
color: .purple,
title: String(localized: "whats_new_142_siri_title"),
description: String(localized: "whats_new_142_siri_body")
),
WhatsNewFeature(
icon: "chart.xyaxis.line",
color: .appSecondary,
title: String(localized: "whats_new_142_charts_title"),
description: String(localized: "whats_new_142_charts_body")
)
]),
WhatsNewRelease(version: "1.4.1", features: [
WhatsNewFeature(
icon: "square.and.arrow.up.circle.fill",
color: .appPrimary,
title: String(localized: "whats_new_141_extension_title"),
description: String(localized: "whats_new_141_extension_body")
),
WhatsNewFeature(
icon: "ipad.landscape",
color: .appSecondary,
title: String(localized: "whats_new_141_ipad_title"),
description: String(localized: "whats_new_141_ipad_body")
),
WhatsNewFeature(
icon: "icloud.fill",
color: .cyan,
title: String(localized: "whats_new_141_icloud_title"),
description: String(localized: "whats_new_141_icloud_body")
)
]),
WhatsNewRelease(version: "1.4.0", features: [
WhatsNewFeature(
icon: "bolt.circle.fill",
color: .appPrimary,
title: String(localized: "whats_new_quick_update_title"),
description: String(localized: "whats_new_quick_update_body")
),
WhatsNewFeature(
icon: "flame.fill",
color: .orange,
title: String(localized: "whats_new_streak_title"),
description: String(localized: "whats_new_streak_body")
),
WhatsNewFeature(
icon: "target",
color: .appSecondary,
title: String(localized: "whats_new_goals_title"),
description: String(localized: "whats_new_goals_body")
)
])
]
}
/// Releases the user hasn't seen: newer than `since`, no newer than `current`.
/// Newest first. `since` empty (pre-1.4.0 installs, before the key existed)
/// is treated as "show everything in the catalog".
static func pendingReleases(since: String, current: String) -> [WhatsNewRelease] {
releases.filter { release in
(since.isEmpty || compare(release.version, isNewerThan: since))
&& !compare(release.version, isNewerThan: current)
}
}
/// Numeric semver-style comparison ("1.10.0" > "1.9.0").
static func compare(_ a: String, isNewerThan b: String) -> Bool {
let aParts = a.split(separator: ".").map { Int($0) ?? 0 }
let bParts = b.split(separator: ".").map { Int($0) ?? 0 }
for i in 0..<max(aParts.count, bParts.count) {
let x = i < aParts.count ? aParts[i] : 0
let y = i < bParts.count ? bParts[i] : 0
if x != y { return x > y }
}
return false
}
}
// MARK: - Sheet
struct WhatsNewView: View {
let releases: [WhatsNewRelease]
@Environment(\.dismiss) private var dismiss
private let features: [WhatsNewFeature] = [
WhatsNewFeature(
icon: "bolt.circle.fill",
color: .appPrimary,
title: String(localized: "whats_new_quick_update_title"),
description: String(localized: "whats_new_quick_update_body")
),
WhatsNewFeature(
icon: "flame.fill",
color: .orange,
title: String(localized: "whats_new_streak_title"),
description: String(localized: "whats_new_streak_body")
),
WhatsNewFeature(
icon: "target",
color: .appSecondary,
title: String(localized: "whats_new_goals_title"),
description: String(localized: "whats_new_goals_body")
)
]
/// Version headers only add value when the user skipped several releases.
private var showsVersionHeaders: Bool { releases.count > 1 }
var body: some View {
ZStack {
AppBackground()
VStack(spacing: 0) {
Spacer()
VStack(spacing: 8) {
Text("🎉")
.font(.system(size: 48))
Text(String(format: String(localized: "whats_new_title_format"), releases.first?.version ?? ""))
.font(.title.weight(.bold))
Text(String(localized: "whats_new_subtitle"))
.font(.subheadline)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
.padding(.top, 32)
.padding(.horizontal)
VStack(spacing: 32) {
VStack(spacing: 8) {
Text("🎉")
.font(.system(size: 56))
Text(String(localized: "whats_new_title"))
.font(.title.weight(.bold))
Text(String(localized: "whats_new_subtitle"))
.font(.subheadline)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
}
VStack(spacing: 20) {
ForEach(features, id: \.title) { feature in
HStack(spacing: 16) {
Image(systemName: feature.icon)
.font(.title2)
.foregroundColor(feature.color)
.frame(width: 40)
VStack(alignment: .leading, spacing: 2) {
Text(feature.title)
.font(.subheadline.weight(.semibold))
Text(feature.description)
.font(.caption)
.foregroundColor(.secondary)
ScrollView {
VStack(spacing: 16) {
ForEach(releases) { release in
VStack(alignment: .leading, spacing: 14) {
if showsVersionHeaders {
Text(String(format: String(localized: "whats_new_version_header"), release.version))
.font(.caption.weight(.bold))
.foregroundStyle(Color.appPrimary)
.padding(.horizontal, 10)
.padding(.vertical, 4)
.background(Color.appPrimary.opacity(0.12))
.clipShape(Capsule())
}
ForEach(release.features, id: \.title) { feature in
HStack(spacing: 16) {
Image(systemName: feature.icon)
.font(.title2)
.foregroundStyle(feature.color)
.frame(width: 40)
VStack(alignment: .leading, spacing: 2) {
Text(feature.title)
.font(.subheadline.weight(.semibold))
Text(feature.description)
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
}
}
Spacer()
}
.padding()
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color(.secondarySystemGroupedBackground))
.clipShape(RoundedRectangle(cornerRadius: 16))
}
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(16)
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
}
.padding()
Spacer()
Button {
dismiss()
} label: {
Text(String(localized: "whats_new_continue"))
.font(.headline)
.foregroundColor(.white)
.foregroundStyle(.white)
.frame(maxWidth: .infinity)
.padding()
.background(Color.appPrimary)
.cornerRadius(14)
.clipShape(RoundedRectangle(cornerRadius: 14))
}
.padding()
}
@@ -16,11 +16,11 @@ private struct PremiumChartPreview: View {
HStack(alignment: .firstTextBaseline) {
Text("€24,750")
.font(.system(size: 18, weight: .bold, design: .rounded))
.foregroundColor(.primary)
.foregroundStyle(.primary)
Spacer()
Text("+34.2%")
.font(.caption.weight(.semibold))
.foregroundColor(.positiveGreen)
.foregroundStyle(Color.positiveGreen)
.padding(.horizontal, 7)
.padding(.vertical, 3)
.background(Color.positiveGreen.opacity(0.12))
@@ -141,7 +141,7 @@ struct PaywallView: View {
} label: {
Image(systemName: "xmark")
.font(.system(size: 14, weight: .semibold))
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
.padding(10)
.background(Color(.secondarySystemBackground))
.clipShape(Circle())
@@ -174,7 +174,7 @@ struct PaywallView: View {
Text("One payment. Every feature. Forever.")
.font(.subheadline)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
}
@@ -189,7 +189,7 @@ struct PaywallView: View {
}
.padding(20)
.background(Color(.systemBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
}
@@ -199,11 +199,11 @@ struct PaywallView: View {
HStack(spacing: 4) {
Text(iapService.formattedPrice)
.font(.system(size: 28, weight: .bold, design: .rounded))
.foregroundColor(.appPrimary)
.foregroundStyle(Color.appPrimary)
Text("· one-time · Family Sharing")
.font(.footnote)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
}
@@ -242,24 +242,24 @@ struct PaywallView: View {
HStack(spacing: 6) {
Text(title)
.font(.subheadline.weight(.semibold))
.foregroundColor(.primary)
.foregroundStyle(.primary)
if let badge, !badge.isEmpty {
Text(badge)
.font(.caption2.weight(.bold))
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Color.appSecondary.opacity(0.15))
.foregroundColor(.appSecondary)
.foregroundStyle(Color.appSecondary)
.clipShape(Capsule())
}
}
Text(price)
.font(.footnote)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
Spacer()
Image(systemName: selectedPlan == plan ? "checkmark.circle.fill" : "circle")
.foregroundColor(selectedPlan == plan ? .appPrimary : .secondary)
.foregroundStyle(selectedPlan == plan ? Color.appPrimary : .secondary)
}
.padding(12)
.background(Color(.systemBackground))
@@ -268,7 +268,7 @@ struct PaywallView: View {
.stroke(selectedPlan == plan ? Color.appPrimary : Color.gray.opacity(0.25),
lineWidth: selectedPlan == plan ? 2 : 1)
)
.cornerRadius(AppConstants.UI.smallCornerRadius)
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.smallCornerRadius))
}
.buttonStyle(.plain)
}
@@ -291,8 +291,8 @@ struct PaywallView: View {
.frame(maxWidth: .infinity)
.padding()
.background(Color.appPrimary)
.foregroundColor(.white)
.cornerRadius(AppConstants.UI.cornerRadius)
.foregroundStyle(.white)
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
}
.disabled(isPurchasing)
}
@@ -305,7 +305,7 @@ struct PaywallView: View {
} label: {
Text("Restore Purchases")
.font(.subheadline)
.foregroundColor(.appPrimary)
.foregroundStyle(Color.appPrimary)
}
.disabled(isPurchasing)
}
@@ -316,17 +316,17 @@ struct PaywallView: View {
VStack(spacing: 4) {
Text("Payment charged to your Apple ID account.")
.font(.caption2)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
HStack(spacing: 12) {
Link("Terms", destination: URL(string: AppConstants.URLs.termsOfService)!)
.font(.caption2)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
Link("Privacy", destination: URL(string: AppConstants.URLs.privacyPolicy)!)
.font(.caption2)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
}
}
@@ -381,7 +381,7 @@ struct BenefitRow: View {
HStack(spacing: 14) {
Image(systemName: icon)
.font(.system(size: 20))
.foregroundColor(.appPrimary)
.foregroundStyle(Color.appPrimary)
.frame(width: 28)
VStack(alignment: .leading, spacing: 1) {
@@ -390,13 +390,13 @@ struct BenefitRow: View {
Text(subtitle)
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
Spacer()
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.positiveGreen)
.foregroundStyle(Color.positiveGreen)
.font(.system(size: 18))
}
.padding(.vertical, 2)
@@ -439,7 +439,7 @@ struct CompactPaywallBanner: View {
Text("Unlimited sources, advanced charts & more")
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
Spacer()
@@ -452,13 +452,13 @@ struct CompactPaywallBanner: View {
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(Color.appPrimary)
.foregroundColor(.white)
.cornerRadius(16)
.foregroundStyle(.white)
.clipShape(RoundedRectangle(cornerRadius: 16))
}
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
}
}
@@ -493,8 +493,8 @@ struct PremiumLockOverlay: View {
.padding(.horizontal, 20)
.padding(.vertical, 10)
.background(Color.appPrimary)
.foregroundColor(.white)
.cornerRadius(20)
.foregroundStyle(.white)
.clipShape(RoundedRectangle(cornerRadius: 20))
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
@@ -9,6 +9,7 @@ struct AppLockView: View {
@State private var errorMessage: String?
@State private var didAttemptBiometrics = false
@FocusState private var pinFocused: Bool
private let biometry = AppLockService.biometryKind
var body: some View {
ZStack {
@@ -28,7 +29,7 @@ struct AppLockView: View {
Text("Unlock Portfolio Journal to view your data.")
.font(.subheadline)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal, 30)
@@ -36,13 +37,14 @@ struct AppLockView: View {
Button {
authenticateWithBiometrics()
} label: {
Label("Unlock with Face ID", systemImage: "faceid")
Label(String(format: String(localized: "biometric_unlock_with"), biometry.displayName),
systemImage: biometry.systemImage)
.font(.headline)
.frame(maxWidth: .infinity)
.padding()
.background(Color.appPrimary)
.foregroundColor(.white)
.cornerRadius(AppConstants.UI.cornerRadius)
.foregroundStyle(.white)
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
}
.padding(.horizontal, 24)
}
@@ -56,7 +58,7 @@ struct AppLockView: View {
.font(.title3.weight(.semibold))
.padding()
.background(Color.gray.opacity(0.1))
.cornerRadius(12)
.clipShape(RoundedRectangle(cornerRadius: 12))
.focused($pinFocused)
.onChange(of: pin) { _, newValue in
pin = String(newValue.filter(\.isNumber).prefix(4))
@@ -78,7 +80,7 @@ struct AppLockView: View {
if let errorMessage {
Text(errorMessage)
.font(.caption)
.foregroundColor(.negativeRed)
.foregroundStyle(Color.negativeRed)
}
Spacer()
@@ -101,7 +103,7 @@ struct AppLockView: View {
} else if pinEnabled {
pinFocused = true
} else {
errorMessage = "Face ID failed. Please try again."
errorMessage = String(format: String(localized: "biometric_failed"), biometry.displayName)
}
}
}
@@ -11,13 +11,13 @@ struct AllocationTargetsView: View {
Text("Total Targets")
Spacer()
Text(String(format: "%.0f%%", totalTargets))
.foregroundColor(totalTargets == 100 ? .positiveGreen : .secondary)
.foregroundStyle(totalTargets == 100 ? Color.positiveGreen : .secondary)
}
if totalTargets != 100 {
Text("Targets don't need to be perfect, but aiming for 100% keeps the drift view accurate.")
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
}
@@ -40,7 +40,7 @@ struct AllocationTargetsView: View {
.frame(width: 60)
Text("%")
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
}
} header: {
@@ -29,7 +29,7 @@ struct CSVMappingView: View {
VStack(alignment: .leading, spacing: 2) {
Text(pair.0)
.font(.caption2.weight(.semibold))
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
Text(pair.1.isEmpty ? "" : pair.1)
.font(.caption)
.lineLimit(2)
@@ -117,10 +117,10 @@ struct CSVMappingView: View {
.navigationTitle("Map Columns")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
ToolbarItem(placement: .topBarLeading) {
Button("Cancel") { dismiss() }
}
ToolbarItem(placement: .navigationBarTrailing) {
ToolbarItem(placement: .topBarTrailing) {
Button("Import") {
onComplete(config)
dismiss()
@@ -261,10 +261,10 @@ private struct MappingRow: View {
HStack(spacing: 4) {
Text(selectionLabel)
.font(.subheadline)
.foregroundColor(index == ImportService.CSVMappingConfig.notMapped ? .secondary : .appPrimary)
.foregroundStyle(index == ImportService.CSVMappingConfig.notMapped ? .secondary : Color.appPrimary)
Image(systemName: "chevron.up.chevron.down")
.font(.caption2)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
}
}
@@ -332,10 +332,10 @@ private struct MappingRowWithConstant: View {
HStack(spacing: 4) {
Text(selectionLabel)
.font(.subheadline)
.foregroundColor(index == ImportService.CSVMappingConfig.notMapped ? .secondary : .appPrimary)
.foregroundStyle(index == ImportService.CSVMappingConfig.notMapped ? .secondary : Color.appPrimary)
Image(systemName: "chevron.up.chevron.down")
.font(.caption2)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
}
}

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