5 Commits

Author SHA1 Message Date
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
62 changed files with 2322 additions and 89 deletions
+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`
+381 -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 = 85;
CURRENT_PROJECT_VERSION = 88;
DEVELOPMENT_ASSET_PATHS = PortfolioJournal/Assets.xcassets;
DEVELOPMENT_TEAM = 2825Q76T7H;
ENABLE_PREVIEWS = YES;
@@ -559,7 +762,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.6.2;
MARKETING_VERSION = 1.6.3;
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournal;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -582,7 +785,7 @@
CODE_SIGN_IDENTITY = "Apple Distribution";
CODE_SIGN_STYLE = Manual;
PROVISIONING_PROFILE_SPECIFIER = "porfoliojournal";
CURRENT_PROJECT_VERSION = 85;
CURRENT_PROJECT_VERSION = 88;
DEVELOPMENT_ASSET_PATHS = PortfolioJournal/Assets.xcassets;
DEVELOPMENT_TEAM = 2825Q76T7H;
ENABLE_PREVIEWS = YES;
@@ -600,7 +803,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.6.2;
MARKETING_VERSION = 1.6.3;
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournal;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -739,7 +942,7 @@
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
CODE_SIGN_ENTITLEMENTS = PortfolioJournalWidgetExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 85;
CURRENT_PROJECT_VERSION = 88;
DEVELOPMENT_ASSET_PATHS = PortfolioJournalWidget/Assets.xcassets;
DEVELOPMENT_TEAM = 2825Q76T7H;
GENERATE_INFOPLIST_FILE = NO;
@@ -752,7 +955,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.6.2;
MARKETING_VERSION = 1.6.3;
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournal.PortfolioJournalWidget;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
@@ -774,7 +977,7 @@
CODE_SIGN_IDENTITY = "Apple Distribution";
CODE_SIGN_STYLE = Manual;
PROVISIONING_PROFILE_SPECIFIER = "Portfolio Journalwidget";
CURRENT_PROJECT_VERSION = 85;
CURRENT_PROJECT_VERSION = 88;
DEVELOPMENT_ASSET_PATHS = PortfolioJournalWidget/Assets.xcassets;
DEVELOPMENT_TEAM = 2825Q76T7H;
GENERATE_INFOPLIST_FILE = NO;
@@ -787,7 +990,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.6.2;
MARKETING_VERSION = 1.6.3;
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournal.PortfolioJournalWidget;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
@@ -805,7 +1008,7 @@
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 85;
CURRENT_PROJECT_VERSION = 88;
DEVELOPMENT_TEAM = 2825Q76T7H;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
@@ -814,7 +1017,7 @@
"@executable_path/Frameworks",
"@loader_path/Frameworks",
);
MARKETING_VERSION = 1.6.2;
MARKETING_VERSION = 1.6.3;
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournalTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = NO;
@@ -829,7 +1032,7 @@
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 85;
CURRENT_PROJECT_VERSION = 88;
DEVELOPMENT_TEAM = 2825Q76T7H;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
@@ -838,7 +1041,7 @@
"@executable_path/Frameworks",
"@loader_path/Frameworks",
);
MARKETING_VERSION = 1.6.2;
MARKETING_VERSION = 1.6.3;
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournalTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = NO;
@@ -852,7 +1055,7 @@
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 85;
CURRENT_PROJECT_VERSION = 88;
DEVELOPMENT_TEAM = 2825Q76T7H;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
@@ -861,7 +1064,7 @@
"@executable_path/Frameworks",
"@loader_path/Frameworks",
);
MARKETING_VERSION = 1.6.2;
MARKETING_VERSION = 1.6.3;
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournalUITests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = NO;
@@ -875,7 +1078,7 @@
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 85;
CURRENT_PROJECT_VERSION = 88;
DEVELOPMENT_TEAM = 2825Q76T7H;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
@@ -884,7 +1087,7 @@
"@executable_path/Frameworks",
"@loader_path/Frameworks",
);
MARKETING_VERSION = 1.6.2;
MARKETING_VERSION = 1.6.3;
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournalUITests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = NO;
@@ -899,7 +1102,7 @@
buildSettings = {
CODE_SIGN_ENTITLEMENTS = PortfolioJournalQuickUpdateExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 85;
CURRENT_PROJECT_VERSION = 88;
DEVELOPMENT_TEAM = 2825Q76T7H;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = PortfolioJournalQuickUpdate/Info.plist;
@@ -911,7 +1114,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.6.2;
MARKETING_VERSION = 1.6.3;
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournal.QuickUpdate;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
@@ -927,7 +1130,7 @@
CODE_SIGN_ENTITLEMENTS = PortfolioJournalQuickUpdateExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Distribution";
CODE_SIGN_STYLE = Manual;
CURRENT_PROJECT_VERSION = 85;
CURRENT_PROJECT_VERSION = 88;
DEVELOPMENT_TEAM = 2825Q76T7H;
PROVISIONING_PROFILE_SPECIFIER = "PortfolioJournal QuickUpdate AppStore";
GENERATE_INFOPLIST_FILE = NO;
@@ -940,7 +1143,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.6.2;
MARKETING_VERSION = 1.6.3;
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournal.QuickUpdate;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
@@ -950,6 +1153,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 = 88;
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.6.3;
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 = 88;
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.6.3;
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 = 88;
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.6.3;
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 = 88;
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.6.3;
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 +1350,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 */
@@ -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>
+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
}
@@ -96,6 +96,12 @@ struct PortfolioJournalApp: App {
.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
@@ -28,6 +28,7 @@ public class Category: NSManagedObject, Identifiable {
@NSManaged public var createdAt: Date
@NSManaged public var allocationTarget: NSNumber?
@NSManaged public var sources: NSSet?
@NSManaged public var goals: NSSet?
public override func awakeFromInsert() {
super.awakeFromInsert()
@@ -43,6 +44,13 @@ public class Category: NSManagedObject, Identifiable {
// MARK: - Computed Properties
extension Category {
/// Nil-safe identifier that never heals a deleted object, mirroring
/// `Account.safeId`. Use it when comparing identities across fetches.
var safeId: UUID? {
guard !isDeleted else { return nil }
return value(forKey: "id") as? UUID
}
var color: Color {
Color(hex: colorHex) ?? .blue
}
@@ -26,6 +26,7 @@ public class Goal: NSManagedObject, Identifiable {
set { setManagedValue(newValue, forKey: "createdAt") }
}
@NSManaged public var account: Account?
@NSManaged public var category: Category?
public override func awakeFromInsert() {
super.awakeFromInsert()
@@ -42,5 +43,32 @@ extension Goal {
var targetDecimal: Decimal {
targetAmount?.decimalValue ?? Decimal.zero
}
/// A goal may be scoped to an account, to a source category (e.g. "100k in
/// Cash"), to both, or to nothing at all (the whole portfolio). `account`
/// and `category` act as AND filters over the sources that count towards it.
func includes(_ source: InvestmentSource) -> Bool {
if let accountId = account?.safeId, source.account?.id != accountId {
return false
}
if let categoryId = category?.safeId, source.category?.id != categoryId {
return false
}
return true
}
/// Sources whose value counts towards this goal.
func relevantSources(from sources: [InvestmentSource]) -> [InvestmentSource] {
guard account != nil || category != nil else { return sources }
return sources.filter { includes($0) }
}
/// Stable key identifying the scope (account + category) of a goal, so
/// goals sharing a scope can share cached evolution data.
var scopeKey: String {
let accountPart = account?.safeId?.uuidString ?? "all"
let categoryPart = category?.safeId?.uuidString ?? "all"
return "\(accountPart)|\(categoryPart)"
}
}
@@ -32,6 +32,7 @@
<attribute name="id" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
<attribute name="name" attributeType="String" defaultValueString=""/>
<attribute name="sortOrder" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/>
<relationship name="goals" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Goal" inverseName="category" inverseEntity="Goal"/>
<relationship name="sources" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="InvestmentSource" inverseName="category" inverseEntity="InvestmentSource"/>
</entity>
<entity name="InvestmentSource" representedClassName="InvestmentSource" syncable="YES">
@@ -76,6 +77,7 @@
<attribute name="targetAmount" optional="YES" attributeType="Decimal"/>
<attribute name="targetDate" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<relationship name="account" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Account" inverseName="goals" inverseEntity="Account"/>
<relationship name="category" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Category" inverseName="goals" inverseEntity="Category"/>
</entity>
<entity name="PredictionCache" representedClassName="PredictionCache" syncable="NO">
<attribute name="algorithm" attributeType="String" defaultValueString="linear"/>
@@ -868,6 +868,11 @@ class CoreDataStack: ObservableObject {
WidgetCenter.shared.reloadAllTimelines()
}
}
// The paired watch reads the same data changes; every repository that
// refreshes the widget wants the watch refreshed too.
Task { @MainActor in
WatchSyncService.shared.scheduleSync()
}
}
/// Forces SQLite to checkpoint the WAL file, ensuring all changes are written to the main database file.
@@ -53,25 +53,21 @@ class GoalRepository: ObservableObject {
// MARK: - Create
/// Check if a goal with the given name already exists in the account (case-insensitive)
func goalExists(name: String, in account: Account?) -> Bool {
let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines)
let normalized = trimmed.lowercased()
return goals.contains { goal in
let nameMatches = goal.name.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == normalized
let accountMatches = goal.account?.id == account?.id
return nameMatches && accountMatches
}
/// Check if a goal with the given name already exists in the same scope
/// (account + category), case-insensitive
func goalExists(name: String, in account: Account?, category: Category? = nil) -> Bool {
findGoal(byName: name, in: account, category: category) != nil
}
/// Find an existing goal by name (case-insensitive) within the same account
func findGoal(byName name: String, in account: Account?) -> Goal? {
/// Find an existing goal by name (case-insensitive) within the same scope
func findGoal(byName name: String, in account: Account?, category: Category? = nil) -> Goal? {
let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines)
let normalized = trimmed.lowercased()
return goals.first { goal in
let nameMatches = goal.name.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == normalized
let accountMatches = goal.account?.id == account?.id
return nameMatches && accountMatches
let categoryMatches = goal.category?.id == category?.id
return nameMatches && accountMatches && categoryMatches
}
}
@@ -80,10 +76,11 @@ class GoalRepository: ObservableObject {
name: String,
targetAmount: Decimal,
targetDate: Date? = nil,
account: Account?
account: Account?,
category: Category? = nil
) -> Goal {
// Check if goal with same name already exists in this account
if let existing = findGoal(byName: name, in: account) {
// Check if goal with same name already exists in this scope
if let existing = findGoal(byName: name, in: account, category: category) {
return existing
}
@@ -92,6 +89,7 @@ class GoalRepository: ObservableObject {
goal.targetAmount = NSDecimalNumber(decimal: targetAmount)
goal.targetDate = targetDate
goal.account = account
goal.category = category
save()
return goal
}
@@ -104,7 +102,9 @@ class GoalRepository: ObservableObject {
targetAmount: Decimal? = nil,
targetDate: Date? = nil,
clearTargetDate: Bool = false,
isActive: Bool? = nil
isActive: Bool? = nil,
category: Category? = nil,
clearCategory: Bool = false
) {
if let name = name {
goal.name = name
@@ -120,6 +120,11 @@ class GoalRepository: ObservableObject {
if let isActive = isActive {
goal.isActive = isActive
}
if clearCategory {
goal.category = nil
} else if let category = category {
goal.category = category
}
save()
}
@@ -677,3 +677,10 @@
"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.";
@@ -678,3 +678,10 @@
"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.";
@@ -629,3 +629,10 @@
"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.";
@@ -677,3 +677,10 @@
"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.";
@@ -677,3 +677,10 @@
"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.";
@@ -677,3 +677,10 @@
"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" = "このカテゴリの投資先のみがこの目標に加算されます。";
@@ -677,3 +677,10 @@
"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.";
@@ -154,7 +154,8 @@ class ExportService {
var goalDict: [String: Any] = [
"name": goal.name,
"isActive": goal.isActive,
"account": goal.account?.name as Any
"account": goal.account?.name as Any,
"category": goal.category?.name as Any
]
if let amount = goal.targetAmount {
goalDict["targetAmount"] = amount.doubleValue
+36 -11
View File
@@ -87,6 +87,7 @@ class ImportService {
let targetDate: Date?
let isActive: Bool
let accountName: String?
let categoryName: String?
}
struct ImportedJournalEntry {
@@ -386,8 +387,16 @@ Personal,Real Estate,Rental Property,2024-01-01,82000,80000,Estimated value
let trimmedName = importedGoal.name.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedName.isEmpty, importedGoal.targetAmount != nil else { continue }
let resolvedAccount = importedGoal.accountName.flatMap { accountsByName[$0] }
if fetchGoal(named: trimmedName, account: resolvedAccount, in: context) == nil {
let key = "\(trimmedName.lowercased())|\(resolvedAccount?.name ?? "")"
let resolvedCategory = importedGoal.categoryName.flatMap {
resolveExistingCategory(named: $0, lookup: categoryLookup)
}
if fetchGoal(
named: trimmedName,
account: resolvedAccount,
category: resolvedCategory,
in: context
) == nil {
let key = "\(trimmedName.lowercased())|\(resolvedAccount?.name ?? "")|\(resolvedCategory?.name ?? "")"
if !plannedGoalKeys.contains(key) {
plannedGoalKeys.insert(key)
preview.goalsToCreate += 1
@@ -666,12 +675,14 @@ Personal,Real Estate,Rental Property,2024-01-01,82000,80000,Estimated value
.flatMap { ISO8601DateFormatter().date(from: $0) }
let isActive = goalDict["isActive"] as? Bool ?? true
let accountName = goalDict["account"] as? String
let categoryName = goalDict["category"] as? String
return ImportedGoal(
name: name,
targetAmount: targetAmount,
targetDate: targetDate,
isActive: isActive,
accountName: accountName
accountName: accountName,
categoryName: categoryName
)
}
}
@@ -863,12 +874,21 @@ Personal,Real Estate,Rental Property,2024-01-01,82000,80000,Estimated value
guard let targetAmount = importedGoal.targetAmount else { continue }
let resolvedAccount = importedGoal.accountName.flatMap { accountsByName[$0] }
if fetchGoal(named: trimmedName, account: resolvedAccount, in: context) == nil {
let resolvedCategory = importedGoal.categoryName.flatMap {
resolveExistingCategory(named: $0, lookup: categoryLookup)
}
if fetchGoal(
named: trimmedName,
account: resolvedAccount,
category: resolvedCategory,
in: context
) == nil {
let goal = Goal(context: context)
goal.name = trimmedName
goal.targetAmount = NSDecimalNumber(decimal: targetAmount)
goal.targetDate = importedGoal.targetDate
goal.account = resolvedAccount
goal.category = resolvedCategory
goal.isActive = importedGoal.isActive
}
}
@@ -1002,22 +1022,27 @@ Personal,Real Estate,Rental Property,2024-01-01,82000,80000,Estimated value
return try? context.fetch(request).first
}
/// Fetch an existing goal by name (case-insensitive) within the same account.
/// Fetch an existing goal by name (case-insensitive) within the same scope
/// (account + category).
private func fetchGoal(
named name: String,
account: Account?,
category: Category?,
in context: NSManagedObjectContext
) -> Goal? {
let request: NSFetchRequest<Goal> = Goal.fetchRequest()
var predicates: [NSPredicate] = [NSPredicate(format: "name ==[c] %@", name)]
if let account = account {
request.predicate = NSPredicate(
format: "name ==[c] %@ AND account == %@", name, account
)
predicates.append(NSPredicate(format: "account == %@", account))
} else {
request.predicate = NSPredicate(
format: "name ==[c] %@ AND account == nil", name
)
predicates.append(NSPredicate(format: "account == nil"))
}
if let category = category {
predicates.append(NSPredicate(format: "category == %@", category))
} else {
predicates.append(NSPredicate(format: "category == nil"))
}
request.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: predicates)
request.fetchLimit = 1
return try? context.fetch(request).first
}
@@ -527,12 +527,7 @@ private extension NotificationService {
let cal = Calendar.current
for goal in goals {
let goalSources: [InvestmentSource]
if let accId = goal.account?.id {
goalSources = sources.filter { $0.account?.id == accId }
} else {
goalSources = sources
}
let goalSources = goal.relevantSources(from: sources)
let evolution = monthlyEvolution(from: goalSources)
guard evolution.count >= 3,
@@ -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() ?? [:])
}
}
}
@@ -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
@@ -219,8 +219,10 @@ class DashboardViewModel: ObservableObject {
// Detect internal data gaps (missing months between known snapshots)
dataGaps = SnapshotGapDetector.detectGaps(sources: sources, snapshots: allSnapshots)
// Log screen view
FirebaseService.shared.logScreenView(screenName: "Dashboard")
// NOTE: no screen_view logging here refreshData() fires on every
// relevant Core Data change (incl. iCloud imports), so logging from it
// inflated GA4 with thousands of phantom Dashboard views from devices
// left open. The view logs once per appearance in DashboardView.onAppear.
}
private func filteredSources() -> [InvestmentSource] {
@@ -633,6 +635,29 @@ class DashboardViewModel: ObservableObject {
let value: Decimal
}
/// ETA text for a goal scoped to an account and/or a category. The dashboard
/// evolution covers the whole (selected) portfolio, so a scoped goal e.g.
/// "100k in Cash" must be projected over its own sources instead; the
/// caller passes that projection in.
func goalEtaText(for goal: Goal, currentValue: Decimal, projection: GoalProjection.Result) -> String? {
if currentValue >= goal.targetDecimal {
return "Goal reached. Keep it steady."
}
guard let months = projection.monthsToReach else {
return "Keep going. Consistency pays off."
}
if months == 0 {
return "Almost there. One more check-in."
}
if months >= 72 {
return "Long journey, strong discipline. You're building momentum."
}
if let projectedDate = projection.projectedDate {
return "Estimated: \(projectedDate.monthYearString)"
}
return "Estimated: \(months) months"
}
func goalEtaText(for goal: Goal, currentValue: Decimal) -> String? {
let target = goal.targetDecimal
let lastValue = evolutionData.last?.value ?? currentValue
@@ -16,7 +16,7 @@ class GoalsViewModel: ObservableObject {
private var cancellables = Set<AnyCancellable>()
// MARK: - Performance: Caching
private var cachedEvolutionData: [UUID: [(date: Date, value: Decimal)]] = [:]
private var cachedEvolutionData: [String: [(date: Date, value: Decimal)]] = [:]
private var cachedCompletionDates: [UUID: Date?] = [:]
private var lastSourcesHash: Int = 0
@@ -94,12 +94,9 @@ class GoalsViewModel: ObservableObject {
}
func totalValue(for goal: Goal) -> Decimal {
if let accountId = goal.account?.safeId {
return sourceRepository.sources
.filter { $0.account?.id == accountId }
.reduce(Decimal.zero) { $0 + $1.latestValue }
}
return totalValue
guard goal.account != nil || goal.category != nil else { return totalValue }
return relevantSources(for: goal)
.reduce(Decimal.zero) { $0 + $1.latestValue }
}
func paceStatus(for goal: Goal) -> GoalPaceStatus? {
@@ -203,17 +200,14 @@ class GoalsViewModel: ObservableObject {
}
private func relevantSources(for goal: Goal) -> [InvestmentSource] {
if let accountId = goal.account?.safeId {
return sourceRepository.sources.filter { $0.account?.id == accountId }
}
return sourceRepository.sources
goal.relevantSources(from: sourceRepository.sources)
}
private func evolutionData(
for goal: Goal,
sources: [InvestmentSource]
) -> [(date: Date, value: Decimal)] {
let cacheKey = goal.account?.safeId ?? UUID(uuidString: "00000000-0000-0000-0000-000000000000")!
let cacheKey = goal.scopeKey
if let cached = cachedEvolutionData[cacheKey] {
return cached
}
@@ -112,6 +112,9 @@ struct DashboardView: View {
goalsViewModel.selectedAccount = accountStore.selectedAccount
goalsViewModel.showAllAccounts = accountStore.showAllAccounts
goalsViewModel.refresh()
// Screen view logged here (once per appearance) NOT inside
// refreshData(), which re-runs on every Core Data change.
FirebaseService.shared.logScreenView(screenName: "Dashboard")
}
// Performance: Combine account selection changes into a single handler
.onChange(of: accountStore.selectedAccount) { _, newAccount in
@@ -400,7 +403,14 @@ struct DashboardView: View {
paceStatusProvider: goalsViewModel.paceStatus(for:),
etaProvider: { goal in
let currentValue = goalsViewModel.totalValue(for: goal)
return viewModel.goalEtaText(for: goal, currentValue: currentValue)
guard goal.account != nil || goal.category != nil else {
return viewModel.goalEtaText(for: goal, currentValue: currentValue)
}
return viewModel.goalEtaText(
for: goal,
currentValue: currentValue,
projection: goalsViewModel.projection(for: goal)
)
}
)
}
@@ -1573,8 +1583,19 @@ struct GoalsSummaryCard: View {
lineWidth: 5
)
VStack(alignment: .leading, spacing: 3) {
Text(goal.name)
.font(.subheadline.weight(.semibold))
HStack(spacing: 6) {
Text(goal.name)
.font(.subheadline.weight(.semibold))
if let category = goal.category {
HStack(spacing: 3) {
Image(systemName: category.icon)
.font(.system(size: 9))
Text(category.name)
.font(.caption2.weight(.semibold))
}
.foregroundColor(category.color)
}
}
HStack(spacing: 4) {
Text(currentValue.compactCurrencyString)
.font(.caption.weight(.semibold))
@@ -377,6 +377,9 @@ struct QuickUpdateView: View {
}
// Keep the share-extension mirror in sync with what was just saved
SharedQuickUpdateSync.refreshMirror()
// Direct context.save() bypasses SnapshotRepository, so refresh the
// widget explicitly otherwise it stays stale until the next app open.
CoreDataStack.shared.refreshWidgetData()
// Positive moment: check-in saved. Ask for a rating once the sheet
// is gone (gated to 2 months of history, once per version).
if ReviewRequestService.shouldRequestAfterCheckIn() {
@@ -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?
@@ -51,6 +54,22 @@ 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 {
@@ -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()
@@ -273,6 +273,20 @@ struct GoalRowView: View {
}
}
if let category = goal.category {
HStack(spacing: 4) {
Image(systemName: category.icon)
.font(.caption2)
Text(category.name)
.font(.caption2.weight(.semibold))
}
.foregroundColor(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))
@@ -670,14 +670,8 @@ struct AddSnapshotView: View {
let goals = goalRepo.goals.filter { $0.isActive }
var achieved = Set<UUID>()
for goal in goals {
let currentValue: Decimal
if let accountId = goal.account?.safeId {
currentValue = sourceRepo.sources
.filter { $0.account?.id == accountId }
.reduce(Decimal.zero) { $0 + $1.latestValue }
} else {
currentValue = sourceRepo.sources.reduce(Decimal.zero) { $0 + $1.latestValue }
}
let currentValue = goal.relevantSources(from: sourceRepo.sources)
.reduce(Decimal.zero) { $0 + $1.latestValue }
let target = goal.targetDecimal
if target > 0 && currentValue / target >= 1 {
achieved.insert(goal.id)
@@ -691,14 +685,8 @@ struct AddSnapshotView: View {
let goals = goalRepo.goals.filter { $0.isActive }
for goal in goals {
guard !prevAchievedIds.contains(goal.id) else { continue }
let currentValue: Decimal
if let accountId = goal.account?.safeId {
currentValue = sourceRepo.sources
.filter { $0.account?.id == accountId }
.reduce(Decimal.zero) { $0 + $1.latestValue }
} else {
currentValue = sourceRepo.sources.reduce(Decimal.zero) { $0 + $1.latestValue }
}
let currentValue = goal.relevantSources(from: sourceRepo.sources)
.reduce(Decimal.zero) { $0 + $1.latestValue }
let target = goal.targetDecimal
if target > 0 && currentValue / target >= 1 {
NotificationService.shared.scheduleGoalAchievedNotification(goalName: goal.name)
@@ -0,0 +1,126 @@
import XCTest
@testable import PortfolioJournal
/// The watch payload travels between processes and devices, so its encoding and
/// the formatting the complications rely on are worth pinning down.
final class WatchPortfolioSnapshotTests: XCTestCase {
private func makeSnapshot(
goals: [WatchPortfolioSnapshot.Goal] = [],
totalValue: Double = 1_000
) -> WatchPortfolioSnapshot {
WatchPortfolioSnapshot(
generatedAt: Date(timeIntervalSince1970: 1_770_000_000),
currencyCode: "EUR",
totalValue: totalValue,
monthChange: 250,
monthChangePercent: 0.025,
sources: [],
goals: goals,
currentStreak: 3,
bestStreak: 9,
checkInDone: true,
checkInMonthLabel: "August 2026"
)
}
private func makeGoal(name: String, current: Double, target: Double) -> WatchPortfolioSnapshot.Goal {
WatchPortfolioSnapshot.Goal(
id: UUID(),
name: name,
categoryName: "Cash",
categoryIcon: "banknote.fill",
categoryColorHex: "#6B7280",
currentValue: current,
targetValue: target
)
}
// MARK: - Transport
func testApplicationContextRoundTrip() throws {
let snapshot = makeSnapshot(goals: [makeGoal(name: "Cash", current: 40_000, target: 100_000)])
let context = try XCTUnwrap(snapshot.applicationContext())
let decoded = try XCTUnwrap(WatchPortfolioSnapshot.from(applicationContext: context))
XCTAssertEqual(decoded, snapshot)
}
func testApplicationContextIgnoresUnrelatedPayload() {
XCTAssertNil(WatchPortfolioSnapshot.from(applicationContext: ["somethingElse": 1]))
}
func testEmptySnapshotReportsNoData() {
XCTAssertFalse(WatchPortfolioSnapshot.empty.hasData)
XCTAssertTrue(makeSnapshot().hasData)
}
// MARK: - Goals
func testProgressIsClampedBetweenZeroAndOne() {
XCTAssertEqual(makeGoal(name: "Over", current: 150, target: 100).progress, 1)
XCTAssertEqual(makeGoal(name: "Negative", current: -50, target: 100).progress, 0)
XCTAssertEqual(makeGoal(name: "Half", current: 50, target: 100).progress, 0.5)
}
func testProgressWithoutTargetIsZero() {
XCTAssertEqual(makeGoal(name: "No target", current: 50, target: 0).progress, 0)
}
/// The single-goal complication must skip goals already reached.
func testFeaturedGoalSkipsAchievedGoals() {
let achieved = makeGoal(name: "Done", current: 100, target: 100)
let open = makeGoal(name: "Cash buffer", current: 40, target: 100)
let snapshot = makeSnapshot(goals: [achieved, open])
XCTAssertEqual(snapshot.featuredGoal?.name, "Cash buffer")
}
func testFeaturedGoalFallsBackToFirstWhenAllAchieved() {
let first = makeGoal(name: "First", current: 100, target: 100)
let second = makeGoal(name: "Second", current: 200, target: 200)
XCTAssertEqual(makeSnapshot(goals: [first, second]).featuredGoal?.name, "First")
}
func testFeaturedGoalIsNilWithoutGoals() {
XCTAssertNil(makeSnapshot().featuredGoal)
}
// MARK: - Formatting
func testCompactFormatShortensLargeAmounts() {
func compact(_ value: Double) -> String {
WatchPortfolioSnapshot.format(value, currencyCode: "EUR", compact: true)
}
XCTAssertTrue(compact(750).contains("750"))
XCTAssertTrue(compact(1_500).contains("1.5K"))
XCTAssertTrue(compact(25_000).contains("25K"))
XCTAssertTrue(compact(2_000_000).contains("2.0M"))
XCTAssertTrue(compact(-1_500).hasPrefix("-"))
}
func testMonthChangeCarriesExplicitSign() {
let positive = makeSnapshot()
XCTAssertTrue(positive.formattedMonthChange().hasPrefix("+"))
XCTAssertEqual(positive.formattedMonthChangePercent, "+2.5%")
let negative = WatchPortfolioSnapshot(
generatedAt: Date(),
currencyCode: "EUR",
totalValue: 1_000,
monthChange: -250,
monthChangePercent: -0.025,
sources: [],
goals: [],
currentStreak: 0,
bestStreak: 0,
checkInDone: false,
checkInMonthLabel: ""
)
XCTAssertTrue(negative.formattedMonthChange().hasPrefix("-"))
XCTAssertEqual(negative.formattedMonthChangePercent, "-2.5%")
}
}
@@ -50,6 +50,56 @@ final class MonthlyCheckInStoreTests: XCTestCase {
XCTAssertNotNil(januaryEntry?.completionDate)
}
// MARK: - Current streak
/// Completing JuneAugust and looking at the card on 12 September (the
/// September check-in is still within its deadline) must show a live streak,
/// not 0x.
func testCurrentStreakCountsWhenCurrentMonthIsStillPending() {
let calendar = Calendar(identifier: .gregorian)
for month in 6...8 {
let monthDate = calendar.date(from: DateComponents(year: 2026, month: month, day: 15))!
let completion = calendar.date(from: DateComponents(year: 2026, month: month, day: 28))!
MonthlyCheckInStore.setCompletionDate(completion, for: monthDate)
}
let referenceDate = calendar.date(from: DateComponents(year: 2026, month: 9, day: 12))!
let stats = MonthlyCheckInStore.stats(referenceDate: referenceDate)
XCTAssertEqual(stats.currentStreak, 3)
}
/// The current month being pending is forgiven; an earlier month whose
/// deadline already passed is not.
func testCurrentStreakBreaksWhenPreviousMonthWasMissed() {
let calendar = Calendar(identifier: .gregorian)
for month in 6...8 {
let monthDate = calendar.date(from: DateComponents(year: 2026, month: month, day: 15))!
let completion = calendar.date(from: DateComponents(year: 2026, month: month, day: 28))!
MonthlyCheckInStore.setCompletionDate(completion, for: monthDate)
}
// September was never checked in and October is under way.
let referenceDate = calendar.date(from: DateComponents(year: 2026, month: 10, day: 12))!
let stats = MonthlyCheckInStore.stats(referenceDate: referenceDate)
XCTAssertEqual(stats.currentStreak, 0)
}
func testCurrentStreakCountsCurrentMonthWhenAlreadyCompleted() {
let calendar = Calendar(identifier: .gregorian)
for month in 7...9 {
let monthDate = calendar.date(from: DateComponents(year: 2026, month: month, day: 15))!
let completion = calendar.date(from: DateComponents(year: 2026, month: month, day: 28))!
MonthlyCheckInStore.setCompletionDate(completion, for: monthDate)
}
let referenceDate = calendar.date(from: DateComponents(year: 2026, month: 9, day: 29))!
let stats = MonthlyCheckInStore.stats(referenceDate: referenceDate)
XCTAssertEqual(stats.currentStreak, 3)
}
func testSetCompletionDateForVeryOldMonthStillCompletesEntry() {
let calendar = Calendar(identifier: .gregorian)
let oldMonthDate = calendar.date(from: DateComponents(year: 2024, month: 1, day: 10))!
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>group.com.alexandrevazquez.portfoliojournal</string>
</array>
</dict>
</plist>
@@ -0,0 +1,11 @@
{
"colors" : [
{
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

@@ -0,0 +1,14 @@
{
"images" : [
{
"filename" : "AppIcon.png",
"idiom" : "universal",
"platform" : "watchos",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
+30
View File
@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDisplayName</key>
<string>Portfolio Journal</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>WKApplication</key>
<true/>
<key>WKCompanionAppBundleIdentifier</key>
<string>com.alexandrevazquez.PortfolioJournal</string>
<key>WKRunsIndependentlyOfCompanionApp</key>
<false/>
</dict>
</plist>
@@ -0,0 +1,18 @@
import SwiftUI
@main
struct PortfolioJournalWatchApp: App {
@StateObject private var store = WatchDataStore.shared
@Environment(\.scenePhase) private var scenePhase
var body: some Scene {
WindowGroup {
WatchRootView()
.environmentObject(store)
.onAppear { store.activate() }
}
.onChange(of: scenePhase) { _, phase in
if phase == .active { store.refresh() }
}
}
}
+103
View File
@@ -0,0 +1,103 @@
import Foundation
import WatchConnectivity
import WidgetKit
/// Receives portfolio snapshots from the iPhone and keeps them available to the
/// watch app and its complications.
///
/// Two paths feed it: the application context the phone pushes whenever data
/// changes (delivered even if the watch app is not running), and an explicit
/// request sent when the user opens the app while the phone is reachable.
@MainActor
final class WatchDataStore: NSObject, ObservableObject {
static let shared = WatchDataStore()
@Published private(set) var snapshot: WatchPortfolioSnapshot
@Published private(set) var isRefreshing = false
/// True once we know the phone can be asked for fresh data.
@Published private(set) var isPhoneReachable = false
private var session: WCSession? {
WCSession.isSupported() ? WCSession.default : nil
}
private override init() {
snapshot = WatchSnapshotCache.load() ?? .empty
super.init()
}
func activate() {
guard let session else { return }
session.delegate = self
if session.activationState != .activated {
session.activate()
} else {
isPhoneReachable = session.isReachable
}
}
/// Asks the phone for a fresh snapshot. Silently does nothing when the
/// phone is out of reach the cached snapshot stays on screen.
func refresh() {
guard let session, session.activationState == .activated, session.isReachable else { return }
isRefreshing = true
session.sendMessage(
[WatchSyncMessage.requestSnapshot: true],
replyHandler: { [weak self] reply in
Task { @MainActor in
self?.isRefreshing = false
self?.apply(context: reply)
}
},
errorHandler: { [weak self] _ in
Task { @MainActor in
self?.isRefreshing = false
}
}
)
}
private func apply(context: [String: Any]) {
guard let received = WatchPortfolioSnapshot.from(applicationContext: context) else { return }
// Application contexts can arrive out of order after a reconnect.
guard received.generatedAt >= snapshot.generatedAt else { return }
snapshot = received
WatchSnapshotCache.save(received)
WidgetCenter.shared.reloadAllTimelines()
}
}
// MARK: - WCSessionDelegate
extension WatchDataStore: WCSessionDelegate {
nonisolated func session(
_ session: WCSession,
activationDidCompleteWith activationState: WCSessionActivationState,
error: Error?
) {
guard activationState == .activated else { return }
let context = session.receivedApplicationContext
let reachable = session.isReachable
Task { @MainActor in
self.isPhoneReachable = reachable
self.apply(context: context)
self.refresh()
}
}
nonisolated func session(_ session: WCSession, didReceiveApplicationContext applicationContext: [String: Any]) {
Task { @MainActor in
self.apply(context: applicationContext)
}
}
nonisolated func sessionReachabilityDidChange(_ session: WCSession) {
let reachable = session.isReachable
Task { @MainActor in
self.isPhoneReachable = reachable
if reachable { self.refresh() }
}
}
}
+258
View File
@@ -0,0 +1,258 @@
import SwiftUI
/// Three vertical pages: portfolio summary, goals, and check-in status. The
/// watch app is read-only every edit still happens on the iPhone.
struct WatchRootView: View {
@EnvironmentObject private var store: WatchDataStore
var body: some View {
TabView {
WatchSummaryView(snapshot: store.snapshot)
WatchGoalsView(snapshot: store.snapshot)
WatchCheckInView(snapshot: store.snapshot)
}
.tabViewStyle(.verticalPage)
}
}
// MARK: - Summary
struct WatchSummaryView: View {
let snapshot: WatchPortfolioSnapshot
@EnvironmentObject private var store: WatchDataStore
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 10) {
if !snapshot.hasData {
WatchEmptyStateView()
} else {
VStack(alignment: .leading, spacing: 2) {
Text("watch_total_value")
.font(.caption2)
.foregroundStyle(.secondary)
Text(snapshot.formattedTotal())
.font(.title3.bold())
.minimumScaleFactor(0.6)
.lineLimit(1)
}
HStack(spacing: 6) {
Image(systemName: snapshot.monthChange >= 0
? "arrow.up.right"
: "arrow.down.right")
Text(snapshot.formattedMonthChange())
Text(snapshot.formattedMonthChangePercent)
.foregroundStyle(.secondary)
}
.font(.caption)
.foregroundStyle(snapshot.monthChange >= 0 ? Color.green : Color.red)
if !snapshot.sources.isEmpty {
Divider()
ForEach(snapshot.sources) { source in
HStack(spacing: 6) {
Circle()
.fill(Color(watchHex: source.categoryColorHex) ?? .gray)
.frame(width: 6, height: 6)
Text(source.name)
.font(.caption2)
.lineLimit(1)
Spacer(minLength: 4)
Text(WatchPortfolioSnapshot.format(
source.value,
currencyCode: snapshot.currencyCode,
compact: true
))
.font(.caption2.weight(.semibold))
.foregroundStyle(.secondary)
}
}
}
WatchFooterView(snapshot: snapshot)
}
}
.padding(.horizontal, 2)
}
.navigationTitle("watch_tab_summary")
}
}
// MARK: - Goals
struct WatchGoalsView: View {
let snapshot: WatchPortfolioSnapshot
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 12) {
Text("watch_goals_title")
.font(.headline)
if snapshot.goals.isEmpty {
Text("watch_no_goals")
.font(.caption2)
.foregroundStyle(.secondary)
} else {
ForEach(snapshot.goals) { goal in
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 4) {
if let icon = goal.categoryIcon {
Image(systemName: icon)
.font(.caption2)
.foregroundStyle(Color(watchHex: goal.categoryColorHex) ?? .accentColor)
}
Text(goal.name)
.font(.caption.weight(.semibold))
.lineLimit(1)
}
ProgressView(value: goal.progress)
.tint(goal.isAchieved
? .green
: (Color(watchHex: goal.categoryColorHex) ?? .accentColor))
HStack {
Text(WatchPortfolioSnapshot.format(
goal.currentValue,
currencyCode: snapshot.currencyCode,
compact: true
))
Text(String(
format: String(localized: "watch_of_target"),
WatchPortfolioSnapshot.format(
goal.targetValue,
currencyCode: snapshot.currencyCode,
compact: true
)
))
.foregroundStyle(.secondary)
Spacer(minLength: 2)
Text("\(Int(goal.progress * 100))%")
.foregroundStyle(.secondary)
}
.font(.caption2)
}
}
}
}
.padding(.horizontal, 2)
}
}
}
// MARK: - Check-in
struct WatchCheckInView: View {
let snapshot: WatchPortfolioSnapshot
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 12) {
Text("watch_checkin_title")
.font(.headline)
HStack(spacing: 8) {
Image(systemName: snapshot.checkInDone
? "checkmark.circle.fill"
: "exclamationmark.circle.fill")
.foregroundStyle(snapshot.checkInDone ? Color.green : Color.orange)
VStack(alignment: .leading, spacing: 2) {
Text(snapshot.checkInDone ? "watch_checkin_done" : "watch_checkin_pending")
.font(.caption.weight(.semibold))
if !snapshot.checkInMonthLabel.isEmpty {
Text(snapshot.checkInMonthLabel)
.font(.caption2)
.foregroundStyle(.secondary)
}
}
}
HStack(spacing: 12) {
WatchStatTile(
title: String(localized: "watch_streak"),
value: "\(snapshot.currentStreak)x"
)
WatchStatTile(
title: String(localized: "watch_best_streak"),
value: "\(snapshot.bestStreak)x"
)
}
}
.padding(.horizontal, 2)
}
}
}
// MARK: - Shared pieces
struct WatchStatTile: View {
let title: String
let value: String
var body: some View {
VStack(alignment: .leading, spacing: 2) {
Text(title)
.font(.caption2)
.foregroundStyle(.secondary)
Text(value)
.font(.title3.bold())
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(8)
.background(.quaternary, in: RoundedRectangle(cornerRadius: 10))
}
}
struct WatchEmptyStateView: View {
var body: some View {
VStack(spacing: 8) {
Image(systemName: "iphone.and.arrow.forward")
.font(.title2)
.foregroundStyle(.secondary)
Text("watch_no_data_title")
.font(.caption.weight(.semibold))
Text("watch_no_data_body")
.font(.caption2)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 8)
}
}
/// Freshness line plus a manual pull, for when the user wants to be sure the
/// number on the wrist is the one on the phone.
struct WatchFooterView: View {
let snapshot: WatchPortfolioSnapshot
@EnvironmentObject private var store: WatchDataStore
var body: some View {
VStack(spacing: 6) {
Text(String(
format: String(localized: "watch_updated"),
snapshot.generatedAt.formatted(date: .omitted, time: .shortened)
))
.font(.caption2)
.foregroundStyle(.secondary)
if store.isPhoneReachable {
Button {
store.refresh()
} label: {
if store.isRefreshing {
ProgressView()
} else {
Label("watch_refresh", systemImage: "arrow.clockwise")
.font(.caption2)
}
}
.buttonStyle(.bordered)
}
}
.frame(maxWidth: .infinity)
.padding(.top, 4)
}
}
@@ -0,0 +1,14 @@
"watch_tab_summary" = "Übersicht";
"watch_total_value" = "Portfolio";
"watch_goals_title" = "Ziele";
"watch_no_goals" = "Keine aktiven Ziele";
"watch_checkin_title" = "Check-in";
"watch_checkin_done" = "Check-in erledigt";
"watch_checkin_pending" = "Check-in ausstehend";
"watch_streak" = "Serie";
"watch_best_streak" = "Bestwert";
"watch_no_data_title" = "Noch keine Daten";
"watch_no_data_body" = "Öffne Portfolio Journal auf deinem iPhone zum Synchronisieren.";
"watch_updated" = "Aktualisiert %@";
"watch_refresh" = "Aktualisieren";
"watch_of_target" = "von %@";
@@ -0,0 +1,14 @@
"watch_tab_summary" = "Summary";
"watch_total_value" = "Portfolio";
"watch_goals_title" = "Goals";
"watch_no_goals" = "No active goals";
"watch_checkin_title" = "Check-in";
"watch_checkin_done" = "Check-in done";
"watch_checkin_pending" = "Check-in pending";
"watch_streak" = "Streak";
"watch_best_streak" = "Best";
"watch_no_data_title" = "No data yet";
"watch_no_data_body" = "Open Portfolio Journal on your iPhone to sync.";
"watch_updated" = "Updated %@";
"watch_refresh" = "Refresh";
"watch_of_target" = "of %@";
@@ -0,0 +1,14 @@
"watch_tab_summary" = "Resumen";
"watch_total_value" = "Cartera";
"watch_goals_title" = "Objetivos";
"watch_no_goals" = "Sin objetivos activos";
"watch_checkin_title" = "Check-in";
"watch_checkin_done" = "Check-in hecho";
"watch_checkin_pending" = "Check-in pendiente";
"watch_streak" = "Racha";
"watch_best_streak" = "Mejor";
"watch_no_data_title" = "Todavía no hay datos";
"watch_no_data_body" = "Abre Portfolio Journal en tu iPhone para sincronizar.";
"watch_updated" = "Actualizado %@";
"watch_refresh" = "Actualizar";
"watch_of_target" = "de %@";
@@ -0,0 +1,14 @@
"watch_tab_summary" = "Résumé";
"watch_total_value" = "Portefeuille";
"watch_goals_title" = "Objectifs";
"watch_no_goals" = "Aucun objectif actif";
"watch_checkin_title" = "Point mensuel";
"watch_checkin_done" = "Point mensuel fait";
"watch_checkin_pending" = "Point mensuel en attente";
"watch_streak" = "Série";
"watch_best_streak" = "Record";
"watch_no_data_title" = "Pas encore de données";
"watch_no_data_body" = "Ouvrez Portfolio Journal sur votre iPhone pour synchroniser.";
"watch_updated" = "Mis à jour %@";
"watch_refresh" = "Actualiser";
"watch_of_target" = "sur %@";
@@ -0,0 +1,14 @@
"watch_tab_summary" = "Riepilogo";
"watch_total_value" = "Portafoglio";
"watch_goals_title" = "Obiettivi";
"watch_no_goals" = "Nessun obiettivo attivo";
"watch_checkin_title" = "Check-in";
"watch_checkin_done" = "Check-in completato";
"watch_checkin_pending" = "Check-in in sospeso";
"watch_streak" = "Serie";
"watch_best_streak" = "Record";
"watch_no_data_title" = "Ancora nessun dato";
"watch_no_data_body" = "Apri Portfolio Journal sul tuo iPhone per sincronizzare.";
"watch_updated" = "Aggiornato %@";
"watch_refresh" = "Aggiorna";
"watch_of_target" = "di %@";
@@ -0,0 +1,14 @@
"watch_tab_summary" = "サマリー";
"watch_total_value" = "ポートフォリオ";
"watch_goals_title" = "目標";
"watch_no_goals" = "アクティブな目標はありません";
"watch_checkin_title" = "チェックイン";
"watch_checkin_done" = "チェックイン済み";
"watch_checkin_pending" = "チェックイン未完了";
"watch_streak" = "連続記録";
"watch_best_streak" = "最高";
"watch_no_data_title" = "データがまだありません";
"watch_no_data_body" = "iPhoneでPortfolio Journalを開いて同期してください。";
"watch_updated" = "更新 %@";
"watch_refresh" = "更新";
"watch_of_target" = "/ %@";
@@ -0,0 +1,14 @@
"watch_tab_summary" = "Resumo";
"watch_total_value" = "Carteira";
"watch_goals_title" = "Metas";
"watch_no_goals" = "Nenhuma meta ativa";
"watch_checkin_title" = "Check-in";
"watch_checkin_done" = "Check-in feito";
"watch_checkin_pending" = "Check-in pendente";
"watch_streak" = "Sequência";
"watch_best_streak" = "Recorde";
"watch_no_data_title" = "Ainda sem dados";
"watch_no_data_body" = "Abra o Portfolio Journal no seu iPhone para sincronizar.";
"watch_updated" = "Atualizado %@";
"watch_refresh" = "Atualizar";
"watch_of_target" = "de %@";
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>group.com.alexandrevazquez.portfoliojournal</string>
</array>
</dict>
</plist>
@@ -0,0 +1,11 @@
{
"colors" : [
{
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
+29
View File
@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDisplayName</key>
<string>Portfolio Complications</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.widgetkit-extension</string>
</dict>
</dict>
</plist>
@@ -0,0 +1,295 @@
import WidgetKit
import SwiftUI
/// Complications for the Apple Watch face. They read the snapshot the watch app
/// cached from the iPhone (see `WatchSnapshotCache`) the extension never talks
/// to WatchConnectivity itself, the app reloads the timelines when new data
/// arrives.
struct WatchComplicationEntry: TimelineEntry {
let date: Date
let snapshot: WatchPortfolioSnapshot
}
struct WatchComplicationProvider: TimelineProvider {
func placeholder(in context: Context) -> WatchComplicationEntry {
WatchComplicationEntry(date: Date(), snapshot: .preview)
}
func getSnapshot(in context: Context, completion: @escaping (WatchComplicationEntry) -> Void) {
let snapshot = context.isPreview ? .preview : (WatchSnapshotCache.load() ?? .preview)
completion(WatchComplicationEntry(date: Date(), snapshot: snapshot))
}
func getTimeline(in context: Context, completion: @escaping (Timeline<WatchComplicationEntry>) -> Void) {
let entry = WatchComplicationEntry(
date: Date(),
snapshot: WatchSnapshotCache.load() ?? .empty
)
// The watch app reloads timelines as soon as the phone sends new data;
// this cadence is just a safety net for a watch that has been away from
// its phone.
let nextUpdate = Calendar.current.date(byAdding: .minute, value: 30, to: Date()) ?? Date()
completion(Timeline(entries: [entry], policy: .after(nextUpdate)))
}
}
// MARK: - Total value
struct WatchTotalValueComplication: Widget {
var body: some WidgetConfiguration {
StaticConfiguration(kind: "WatchTotalValue", provider: WatchComplicationProvider()) { entry in
WatchTotalValueView(snapshot: entry.snapshot)
.containerBackground(.fill.tertiary, for: .widget)
}
.configurationDisplayName("complication_total_name")
.description("complication_total_description")
.supportedFamilies([
.accessoryCircular,
.accessoryRectangular,
.accessoryInline,
.accessoryCorner
])
}
}
struct WatchTotalValueView: View {
@Environment(\.widgetFamily) private var family
let snapshot: WatchPortfolioSnapshot
private var compactTotal: String {
WatchPortfolioSnapshot.format(
snapshot.totalValue,
currencyCode: snapshot.currencyCode,
compact: true
)
}
var body: some View {
switch family {
case .accessoryInline:
Text("\(compactTotal) \(snapshot.formattedMonthChangePercent)")
case .accessoryCorner:
Text(compactTotal)
.widgetCurvesContent()
.widgetLabel(snapshot.formattedMonthChangePercent)
case .accessoryRectangular:
VStack(alignment: .leading, spacing: 1) {
Text("complication_total_name")
.font(.caption2)
.foregroundStyle(.secondary)
Text(compactTotal)
.font(.title3.bold())
.minimumScaleFactor(0.6)
.lineLimit(1)
Text("\(snapshot.formattedMonthChange()) \(snapshot.formattedMonthChangePercent)")
.font(.caption2)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, alignment: .leading)
default:
VStack(spacing: 0) {
Image(systemName: "chart.pie.fill")
.font(.caption2)
Text(compactTotal)
.font(.caption.bold())
.minimumScaleFactor(0.5)
.lineLimit(1)
}
}
}
}
// MARK: - Goal progress
struct WatchGoalProgressComplication: Widget {
var body: some WidgetConfiguration {
StaticConfiguration(kind: "WatchGoalProgress", provider: WatchComplicationProvider()) { entry in
WatchGoalProgressView(snapshot: entry.snapshot)
.containerBackground(.fill.tertiary, for: .widget)
}
.configurationDisplayName("complication_goal_name")
.description("complication_goal_description")
.supportedFamilies([
.accessoryCircular,
.accessoryRectangular,
.accessoryInline,
.accessoryCorner
])
}
}
struct WatchGoalProgressView: View {
@Environment(\.widgetFamily) private var family
let snapshot: WatchPortfolioSnapshot
/// Most advanced goal still open the one the user is about to hit.
private var goal: WatchPortfolioSnapshot.Goal? { snapshot.featuredGoal }
private var percentText: String {
guard let goal else { return "" }
return "\(Int(goal.progress * 100))%"
}
var body: some View {
switch family {
case .accessoryInline:
if let goal {
Text("\(goal.name) \(percentText)")
} else {
Text("complication_goal_empty")
}
case .accessoryCorner:
Gauge(value: goal?.progress ?? 0) {
Text(percentText)
}
.gaugeStyle(.accessoryCircular)
.widgetLabel(goal?.name ?? String(localized: "complication_goal_empty"))
case .accessoryRectangular:
VStack(alignment: .leading, spacing: 2) {
Text(goal?.name ?? String(localized: "complication_goal_empty"))
.font(.caption.weight(.semibold))
.lineLimit(1)
ProgressView(value: goal?.progress ?? 0)
.tint(.green)
if let goal {
Text("\(WatchPortfolioSnapshot.format(goal.currentValue, currencyCode: snapshot.currencyCode, compact: true)) · \(percentText)")
.font(.caption2)
.foregroundStyle(.secondary)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
default:
Gauge(value: goal?.progress ?? 0) {
Image(systemName: "target")
} currentValueLabel: {
Text(percentText)
.minimumScaleFactor(0.5)
}
.gaugeStyle(.accessoryCircularCapacity)
}
}
}
// MARK: - Streak / check-in
struct WatchStreakComplication: Widget {
var body: some WidgetConfiguration {
StaticConfiguration(kind: "WatchStreak", provider: WatchComplicationProvider()) { entry in
WatchStreakView(snapshot: entry.snapshot)
.containerBackground(.fill.tertiary, for: .widget)
}
.configurationDisplayName("complication_streak_name")
.description("complication_streak_description")
.supportedFamilies([
.accessoryCircular,
.accessoryRectangular,
.accessoryInline,
.accessoryCorner
])
}
}
struct WatchStreakView: View {
@Environment(\.widgetFamily) private var family
let snapshot: WatchPortfolioSnapshot
private var statusSymbol: String {
snapshot.checkInDone ? "checkmark.circle.fill" : "exclamationmark.circle.fill"
}
private var streakText: String { "\(snapshot.currentStreak)x" }
var body: some View {
switch family {
case .accessoryInline:
Text("\(Image(systemName: "flame.fill")) \(streakText)")
case .accessoryCorner:
Text(streakText)
.widgetCurvesContent()
.widgetLabel(
snapshot.checkInDone
? String(localized: "complication_streak_done")
: String(localized: "complication_streak_pending")
)
case .accessoryRectangular:
VStack(alignment: .leading, spacing: 1) {
Label(streakText, systemImage: "flame.fill")
.font(.caption.weight(.semibold))
Text(snapshot.checkInDone
? "complication_streak_done"
: "complication_streak_pending")
.font(.caption2)
.foregroundStyle(.secondary)
if !snapshot.checkInMonthLabel.isEmpty {
Text(snapshot.checkInMonthLabel)
.font(.caption2)
.foregroundStyle(.secondary)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
default:
VStack(spacing: 0) {
Image(systemName: statusSymbol)
.font(.caption2)
.foregroundStyle(snapshot.checkInDone ? Color.green : Color.orange)
Text(streakText)
.font(.caption.bold())
}
}
}
}
// MARK: - Bundle
@main
struct PortfolioJournalWatchWidgetBundle: WidgetBundle {
var body: some Widget {
WatchTotalValueComplication()
WatchGoalProgressComplication()
WatchStreakComplication()
}
}
// MARK: - Previews
extension WatchPortfolioSnapshot {
/// Sample data for widget galleries and Xcode previews.
static var preview: WatchPortfolioSnapshot {
WatchPortfolioSnapshot(
generatedAt: Date(),
currencyCode: "EUR",
totalValue: 124_500,
monthChange: 2_340,
monthChangePercent: 0.019,
sources: [
Source(id: UUID(), name: "Indexa", categoryName: "ETFs", categoryColorHex: "#EC4899", value: 68_000),
Source(id: UUID(), name: "Cuenta ahorro", categoryName: "Cash", categoryColorHex: "#6B7280", value: 32_500)
],
goals: [
Goal(
id: UUID(),
name: "Cash buffer",
categoryName: "Cash",
categoryIcon: "banknote.fill",
categoryColorHex: "#6B7280",
currentValue: 32_500,
targetValue: 100_000
)
],
currentStreak: 7,
bestStreak: 15,
checkInDone: true,
checkInMonthLabel: "August 2026"
)
}
}
@@ -0,0 +1,9 @@
"complication_total_name" = "Portfoliowert";
"complication_total_description" = "Dein gesamter Portfoliowert und die Veränderung in diesem Monat.";
"complication_goal_name" = "Zielfortschritt";
"complication_goal_description" = "Fortschritt des Ziels, das du am ehesten erreichst.";
"complication_goal_empty" = "Keine Ziele";
"complication_streak_name" = "Check-in-Serie";
"complication_streak_description" = "Monate mit pünktlichem Check-in und ob dieser Monat erledigt ist.";
"complication_streak_done" = "Diesen Monat erledigt";
"complication_streak_pending" = "Check-in ausstehend";
@@ -0,0 +1,9 @@
"complication_total_name" = "Portfolio value";
"complication_total_description" = "Your total portfolio value and this month's change.";
"complication_goal_name" = "Goal progress";
"complication_goal_description" = "Progress of the goal you are closest to reaching.";
"complication_goal_empty" = "No goals";
"complication_streak_name" = "Check-in streak";
"complication_streak_description" = "Months checked in on time and whether this month is done.";
"complication_streak_done" = "Done this month";
"complication_streak_pending" = "Check-in pending";
@@ -0,0 +1,9 @@
"complication_total_name" = "Valor de la cartera";
"complication_total_description" = "El valor total de tu cartera y el cambio de este mes.";
"complication_goal_name" = "Progreso del objetivo";
"complication_goal_description" = "Progreso del objetivo que tienes más cerca de alcanzar.";
"complication_goal_empty" = "Sin objetivos";
"complication_streak_name" = "Racha de check-ins";
"complication_streak_description" = "Meses con check-in a tiempo y si el de este mes está hecho.";
"complication_streak_done" = "Hecho este mes";
"complication_streak_pending" = "Check-in pendiente";
@@ -0,0 +1,9 @@
"complication_total_name" = "Valeur du portefeuille";
"complication_total_description" = "La valeur totale de votre portefeuille et la variation du mois.";
"complication_goal_name" = "Progression de l'objectif";
"complication_goal_description" = "Progression de l'objectif le plus proche d'être atteint.";
"complication_goal_empty" = "Aucun objectif";
"complication_streak_name" = "Série de points mensuels";
"complication_streak_description" = "Mois pointés à temps et si celui-ci est fait.";
"complication_streak_done" = "Fait ce mois-ci";
"complication_streak_pending" = "Point mensuel en attente";
@@ -0,0 +1,9 @@
"complication_total_name" = "Valore del portafoglio";
"complication_total_description" = "Il valore totale del portafoglio e la variazione del mese.";
"complication_goal_name" = "Progresso obiettivo";
"complication_goal_description" = "Progresso dell'obiettivo più vicino al traguardo.";
"complication_goal_empty" = "Nessun obiettivo";
"complication_streak_name" = "Serie di check-in";
"complication_streak_description" = "Mesi con check-in puntuale e se quello di questo mese è fatto.";
"complication_streak_done" = "Fatto questo mese";
"complication_streak_pending" = "Check-in in sospeso";
@@ -0,0 +1,9 @@
"complication_total_name" = "ポートフォリオの評価額";
"complication_total_description" = "ポートフォリオの合計額と今月の増減。";
"complication_goal_name" = "目標の進捗";
"complication_goal_description" = "達成に最も近い目標の進捗。";
"complication_goal_empty" = "目標なし";
"complication_streak_name" = "チェックイン連続記録";
"complication_streak_description" = "期限内にチェックインした月数と今月の状況。";
"complication_streak_done" = "今月は完了";
"complication_streak_pending" = "チェックイン未完了";
@@ -0,0 +1,9 @@
"complication_total_name" = "Valor da carteira";
"complication_total_description" = "O valor total da sua carteira e a variação deste mês.";
"complication_goal_name" = "Progresso da meta";
"complication_goal_description" = "Progresso da meta mais perto de ser alcançada.";
"complication_goal_empty" = "Sem metas";
"complication_streak_name" = "Sequência de check-ins";
"complication_streak_description" = "Meses com check-in em dia e se o deste mês está feito.";
"complication_streak_done" = "Feito este mês";
"complication_streak_pending" = "Check-in pendente";
+18
View File
@@ -0,0 +1,18 @@
import SwiftUI
extension Color {
/// Parses the `#RRGGBB` strings categories are stored with. Kept separate
/// from the iOS `Color(hex:)` helper so the watch targets stay free of the
/// rest of the app's utilities.
init?(watchHex hex: String?) {
guard let hex else { return nil }
var value = hex.trimmingCharacters(in: .whitespacesAndNewlines)
if value.hasPrefix("#") { value.removeFirst() }
guard value.count == 6, let rgb = UInt32(value, radix: 16) else { return nil }
self.init(
red: Double((rgb & 0xFF0000) >> 16) / 255,
green: Double((rgb & 0x00FF00) >> 8) / 255,
blue: Double(rgb & 0x0000FF) / 255
)
}
}
+181
View File
@@ -0,0 +1,181 @@
import Foundation
/// Lightweight portfolio payload shipped from the iPhone to the Apple Watch.
///
/// App Groups are not shared between iOS and watchOS, so the watch cannot read
/// the Core Data store the iOS widget reads. Instead the phone builds this
/// snapshot and sends it over WatchConnectivity; the watch caches it in its own
/// App Group container so the watch app and its complications read the same
/// data. Values are plain `Double`s (not `Decimal`) to keep the JSON small and
/// the decoding trivial on the watch side.
struct WatchPortfolioSnapshot: Codable, Equatable {
struct Goal: Codable, Equatable, Identifiable {
let id: UUID
let name: String
/// Category the goal is scoped to, when any ("100k in Cash").
let categoryName: String?
let categoryIcon: String?
let categoryColorHex: String?
let currentValue: Double
let targetValue: Double
var progress: Double {
guard targetValue > 0 else { return 0 }
return min(max(currentValue / targetValue, 0), 1)
}
var isAchieved: Bool { progress >= 0.999 }
}
struct Source: Codable, Equatable, Identifiable {
let id: UUID
let name: String
let categoryName: String?
let categoryColorHex: String?
let value: Double
}
/// When the phone built this snapshot.
let generatedAt: Date
let currencyCode: String
let totalValue: Double
/// Absolute change since the end of the previous month.
let monthChange: Double
/// Same change as a fraction (0.042 == +4.2%).
let monthChangePercent: Double
/// Largest holdings first, capped by the sender.
let sources: [Source]
/// Active goals, most advanced first.
let goals: [Goal]
let currentStreak: Int
let bestStreak: Int
/// Whether the check-in that is currently due has been completed.
let checkInDone: Bool
/// Human label of the month that check-in belongs to, e.g. "August 2026".
let checkInMonthLabel: String
static var empty: WatchPortfolioSnapshot {
WatchPortfolioSnapshot(
generatedAt: .distantPast,
currencyCode: "EUR",
totalValue: 0,
monthChange: 0,
monthChangePercent: 0,
sources: [],
goals: [],
currentStreak: 0,
bestStreak: 0,
checkInDone: false,
checkInMonthLabel: ""
)
}
var hasData: Bool { generatedAt != .distantPast }
/// Goal to surface when only one fits: the most advanced one still open,
/// falling back to the most advanced overall.
var featuredGoal: Goal? {
goals.first { !$0.isAchieved } ?? goals.first
}
}
/// Message keys exchanged over WatchConnectivity.
enum WatchSyncMessage {
/// The watch asking the phone for a freshly built snapshot.
static let requestSnapshot = "requestSnapshot"
}
// MARK: - Transport
extension WatchPortfolioSnapshot {
/// App Group shared by the watch app and its complication extension.
/// Same identifier string as on iOS, but a separate watchOS container.
static let appGroupIdentifier = "group.com.alexandrevazquez.portfoliojournal"
/// Key used both inside the WatchConnectivity payload and in the cache.
static let payloadKey = "watchPortfolioSnapshot"
static var encoder: JSONEncoder {
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
return encoder
}
static var decoder: JSONDecoder {
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
return decoder
}
func encoded() -> Data? {
try? Self.encoder.encode(self)
}
static func decoded(from data: Data) -> WatchPortfolioSnapshot? {
try? decoder.decode(WatchPortfolioSnapshot.self, from: data)
}
/// WatchConnectivity dictionaries must hold property-list types, so the
/// snapshot travels as a single `Data` value under `payloadKey`.
func applicationContext() -> [String: Any]? {
guard let data = encoded() else { return nil }
return [Self.payloadKey: data]
}
static func from(applicationContext context: [String: Any]) -> WatchPortfolioSnapshot? {
guard let data = context[payloadKey] as? Data else { return nil }
return decoded(from: data)
}
}
// MARK: - Formatting helpers shared by watch app and complications
extension WatchPortfolioSnapshot {
func formattedTotal(compact: Bool = false) -> String {
Self.format(totalValue, currencyCode: currencyCode, compact: compact)
}
func formattedMonthChange(compact: Bool = true) -> String {
let sign = monthChange >= 0 ? "+" : "-"
return sign + Self.format(abs(monthChange), currencyCode: currencyCode, compact: compact)
}
var formattedMonthChangePercent: String {
let sign = monthChangePercent >= 0 ? "+" : ""
return String(format: "%@%.1f%%", sign, monthChangePercent * 100)
}
/// Currency string; `compact` shortens thousands/millions so it fits a
/// complication (e.g. "124.5K").
static func format(_ value: Double, currencyCode: String, compact: Bool) -> String {
let formatter = NumberFormatter()
formatter.numberStyle = .currency
formatter.currencyCode = currencyCode
formatter.maximumFractionDigits = 0
guard compact else {
return formatter.string(from: NSNumber(value: value)) ?? "\(Int(value))"
}
let symbol = formatter.currencySymbol ?? ""
let magnitude = abs(value)
let sign = value < 0 ? "-" : ""
switch magnitude {
case 1_000_000...:
return String(format: "%@%@%.1fM", sign, symbol, magnitude / 1_000_000)
case 10_000...:
return String(format: "%@%@%.0fK", sign, symbol, magnitude / 1_000)
case 1_000...:
return String(format: "%@%@%.1fK", sign, symbol, magnitude / 1_000)
default:
return String(format: "%@%@%.0f", sign, symbol, magnitude)
}
}
}
+28
View File
@@ -0,0 +1,28 @@
import Foundation
/// On-watch cache of the last snapshot received from the iPhone.
///
/// The watch app writes it; the complication extension reads it. They are
/// separate processes, so the App Group container is what lets them share
/// with a fallback to the process defaults so the app still shows data even if
/// the App Group entitlement is not in place yet.
enum WatchSnapshotCache {
private static var sharedDefaults: UserDefaults? {
UserDefaults(suiteName: WatchPortfolioSnapshot.appGroupIdentifier)
}
static func save(_ snapshot: WatchPortfolioSnapshot) {
guard let data = snapshot.encoded() else { return }
let key = WatchPortfolioSnapshot.payloadKey
sharedDefaults?.set(data, forKey: key)
UserDefaults.standard.set(data, forKey: key)
}
static func load() -> WatchPortfolioSnapshot? {
let key = WatchPortfolioSnapshot.payloadKey
let data = sharedDefaults?.data(forKey: key) ?? UserDefaults.standard.data(forKey: key)
guard let data else { return nil }
return WatchPortfolioSnapshot.decoded(from: data)
}
}