Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 464fbb2bad | |||
| 167b736aad | |||
| d88cc59627 | |||
| eaf0c8f93b | |||
| 6dd755234a |
@@ -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`
|
||||
|
||||
@@ -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>
|
||||
@@ -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)"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+2
@@ -32,6 +32,7 @@
|
||||
<attribute name="id" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
|
||||
<attribute name="name" attributeType="String" defaultValueString=""/>
|
||||
<attribute name="sortOrder" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/>
|
||||
<relationship name="goals" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Goal" inverseName="category" inverseEntity="Goal"/>
|
||||
<relationship name="sources" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="InvestmentSource" inverseName="category" inverseEntity="InvestmentSource"/>
|
||||
</entity>
|
||||
<entity name="InvestmentSource" representedClassName="InvestmentSource" syncable="YES">
|
||||
@@ -76,6 +77,7 @@
|
||||
<attribute name="targetAmount" optional="YES" attributeType="Decimal"/>
|
||||
<attribute name="targetDate" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||
<relationship name="account" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Account" inverseName="goals" inverseEntity="Account"/>
|
||||
<relationship name="category" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Category" inverseName="goals" inverseEntity="Category"/>
|
||||
</entity>
|
||||
<entity name="PredictionCache" representedClassName="PredictionCache" syncable="NO">
|
||||
<attribute name="algorithm" attributeType="String" defaultValueString="linear"/>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 June–August 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))!
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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() }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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() }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 %@";
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user