diff --git a/CLAUDE.md b/CLAUDE.md index c1e746a..5ab41d7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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` diff --git a/PortfolioJournal.xcodeproj/project.pbxproj b/PortfolioJournal.xcodeproj/project.pbxproj index 7418bfd..f2e1ad3 100644 --- a/PortfolioJournal.xcodeproj/project.pbxproj +++ b/PortfolioJournal.xcodeproj/project.pbxproj @@ -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 = ""; }; + 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 = ""; }; + 0EWTCH050000000000000000 /* PortfolioJournalWatch */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + 0EWTCH080000000000000000 /* Exceptions for "PortfolioJournalWatch" folder in "PortfolioJournalWatch" target */, + ); + path = PortfolioJournalWatch; + sourceTree = ""; + }; + 0EWTCH060000000000000000 /* PortfolioJournalWatchWidget */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + 0EWTCH090000000000000000 /* Exceptions for "PortfolioJournalWatchWidget" folder in "PortfolioJournalWatchWidget" target */, + ); + path = PortfolioJournalWatchWidget; + sourceTree = ""; + }; + 0EWTCH070000000000000000 /* Shared */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + 0EWTCH100000000000000000 /* Exceptions for "Shared" folder in "PortfolioJournal" target */, + 0EWTCH110000000000000000 /* Exceptions for "Shared" folder in "PortfolioJournalWatchWidget" target */, + ); + path = Shared; + sourceTree = ""; + }; /* 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 = ""; }; @@ -215,6 +327,8 @@ 0EQUPD012F40000000000001 /* PortfolioJournalQuickUpdate.appex */, 0ETEST0002F31000000000001 /* PortfolioJournalTests.xctest */, 0EUITEST002F31000000001 /* PortfolioJournalUITests.xctest */, + 0EWTCH030000000000000000 /* PortfolioJournalWatch.app */, + 0EWTCH040000000000000000 /* PortfolioJournalWatchWidget.appex */, ); name = Products; sourceTree = ""; @@ -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 */ @@ -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 */ diff --git a/PortfolioJournal.xcodeproj/xcshareddata/xcschemes/PortfolioJournalWatch.xcscheme b/PortfolioJournal.xcodeproj/xcshareddata/xcschemes/PortfolioJournalWatch.xcscheme new file mode 100644 index 0000000..156cd57 --- /dev/null +++ b/PortfolioJournal.xcodeproj/xcshareddata/xcschemes/PortfolioJournalWatch.xcscheme @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/PortfolioJournal/App/AppDelegate.swift b/PortfolioJournal/App/AppDelegate.swift index 57cd2b3..9984f0d 100644 --- a/PortfolioJournal/App/AppDelegate.swift +++ b/PortfolioJournal/App/AppDelegate.swift @@ -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 } diff --git a/PortfolioJournal/Models/CoreDataStack.swift b/PortfolioJournal/Models/CoreDataStack.swift index a2f3a26..a8f3bc3 100644 --- a/PortfolioJournal/Models/CoreDataStack.swift +++ b/PortfolioJournal/Models/CoreDataStack.swift @@ -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. diff --git a/PortfolioJournal/Services/WatchSyncService.swift b/PortfolioJournal/Services/WatchSyncService.swift new file mode 100644 index 0000000..39457d9 --- /dev/null +++ b/PortfolioJournal/Services/WatchSyncService.swift @@ -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.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.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() ?? [:]) + } + } +} diff --git a/PortfolioJournalTests/Services/WatchPortfolioSnapshotTests.swift b/PortfolioJournalTests/Services/WatchPortfolioSnapshotTests.swift new file mode 100644 index 0000000..4bda7c9 --- /dev/null +++ b/PortfolioJournalTests/Services/WatchPortfolioSnapshotTests.swift @@ -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%") + } +} diff --git a/PortfolioJournalWatch.entitlements b/PortfolioJournalWatch.entitlements new file mode 100644 index 0000000..c2a86f9 --- /dev/null +++ b/PortfolioJournalWatch.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.com.alexandrevazquez.portfoliojournal + + + diff --git a/PortfolioJournalWatch/Assets.xcassets/AccentColor.colorset/Contents.json b/PortfolioJournalWatch/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/PortfolioJournalWatch/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/PortfolioJournalWatch/Assets.xcassets/AppIcon.appiconset/AppIcon.png b/PortfolioJournalWatch/Assets.xcassets/AppIcon.appiconset/AppIcon.png new file mode 100644 index 0000000..f51c7e5 Binary files /dev/null and b/PortfolioJournalWatch/Assets.xcassets/AppIcon.appiconset/AppIcon.png differ diff --git a/PortfolioJournalWatch/Assets.xcassets/AppIcon.appiconset/Contents.json b/PortfolioJournalWatch/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..74cc725 --- /dev/null +++ b/PortfolioJournalWatch/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images" : [ + { + "filename" : "AppIcon.png", + "idiom" : "universal", + "platform" : "watchos", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/PortfolioJournalWatch/Assets.xcassets/Contents.json b/PortfolioJournalWatch/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/PortfolioJournalWatch/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/PortfolioJournalWatch/Info.plist b/PortfolioJournalWatch/Info.plist new file mode 100644 index 0000000..cf14fa3 --- /dev/null +++ b/PortfolioJournalWatch/Info.plist @@ -0,0 +1,30 @@ + + + + + CFBundleDisplayName + Portfolio Journal + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + ITSAppUsesNonExemptEncryption + + WKApplication + + WKCompanionAppBundleIdentifier + com.alexandrevazquez.PortfolioJournal + WKRunsIndependentlyOfCompanionApp + + + diff --git a/PortfolioJournalWatch/PortfolioJournalWatchApp.swift b/PortfolioJournalWatch/PortfolioJournalWatchApp.swift new file mode 100644 index 0000000..3f23fa8 --- /dev/null +++ b/PortfolioJournalWatch/PortfolioJournalWatchApp.swift @@ -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() } + } + } +} diff --git a/PortfolioJournalWatch/WatchDataStore.swift b/PortfolioJournalWatch/WatchDataStore.swift new file mode 100644 index 0000000..effbd7e --- /dev/null +++ b/PortfolioJournalWatch/WatchDataStore.swift @@ -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() } + } + } +} diff --git a/PortfolioJournalWatch/WatchRootView.swift b/PortfolioJournalWatch/WatchRootView.swift new file mode 100644 index 0000000..d08d680 --- /dev/null +++ b/PortfolioJournalWatch/WatchRootView.swift @@ -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) + } +} diff --git a/PortfolioJournalWatch/de.lproj/Localizable.strings b/PortfolioJournalWatch/de.lproj/Localizable.strings new file mode 100644 index 0000000..bd3d9e9 --- /dev/null +++ b/PortfolioJournalWatch/de.lproj/Localizable.strings @@ -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 %@"; diff --git a/PortfolioJournalWatch/en.lproj/Localizable.strings b/PortfolioJournalWatch/en.lproj/Localizable.strings new file mode 100644 index 0000000..0fdcfbf --- /dev/null +++ b/PortfolioJournalWatch/en.lproj/Localizable.strings @@ -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 %@"; diff --git a/PortfolioJournalWatch/es-ES.lproj/Localizable.strings b/PortfolioJournalWatch/es-ES.lproj/Localizable.strings new file mode 100644 index 0000000..20256bb --- /dev/null +++ b/PortfolioJournalWatch/es-ES.lproj/Localizable.strings @@ -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 %@"; diff --git a/PortfolioJournalWatch/fr.lproj/Localizable.strings b/PortfolioJournalWatch/fr.lproj/Localizable.strings new file mode 100644 index 0000000..5b7c2ae --- /dev/null +++ b/PortfolioJournalWatch/fr.lproj/Localizable.strings @@ -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 %@"; diff --git a/PortfolioJournalWatch/it.lproj/Localizable.strings b/PortfolioJournalWatch/it.lproj/Localizable.strings new file mode 100644 index 0000000..53c72c2 --- /dev/null +++ b/PortfolioJournalWatch/it.lproj/Localizable.strings @@ -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 %@"; diff --git a/PortfolioJournalWatch/ja.lproj/Localizable.strings b/PortfolioJournalWatch/ja.lproj/Localizable.strings new file mode 100644 index 0000000..fc95323 --- /dev/null +++ b/PortfolioJournalWatch/ja.lproj/Localizable.strings @@ -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" = "/ %@"; diff --git a/PortfolioJournalWatch/pt-BR.lproj/Localizable.strings b/PortfolioJournalWatch/pt-BR.lproj/Localizable.strings new file mode 100644 index 0000000..e94510d --- /dev/null +++ b/PortfolioJournalWatch/pt-BR.lproj/Localizable.strings @@ -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 %@"; diff --git a/PortfolioJournalWatchWidget.entitlements b/PortfolioJournalWatchWidget.entitlements new file mode 100644 index 0000000..c2a86f9 --- /dev/null +++ b/PortfolioJournalWatchWidget.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.com.alexandrevazquez.portfoliojournal + + + diff --git a/PortfolioJournalWatchWidget/Assets.xcassets/AccentColor.colorset/Contents.json b/PortfolioJournalWatchWidget/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/PortfolioJournalWatchWidget/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/PortfolioJournalWatchWidget/Assets.xcassets/Contents.json b/PortfolioJournalWatchWidget/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/PortfolioJournalWatchWidget/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/PortfolioJournalWatchWidget/Info.plist b/PortfolioJournalWatchWidget/Info.plist new file mode 100644 index 0000000..2c79871 --- /dev/null +++ b/PortfolioJournalWatchWidget/Info.plist @@ -0,0 +1,29 @@ + + + + + CFBundleDisplayName + Portfolio Complications + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + ITSAppUsesNonExemptEncryption + + NSExtension + + NSExtensionPointIdentifier + com.apple.widgetkit-extension + + + diff --git a/PortfolioJournalWatchWidget/WatchComplications.swift b/PortfolioJournalWatchWidget/WatchComplications.swift new file mode 100644 index 0000000..f177e19 --- /dev/null +++ b/PortfolioJournalWatchWidget/WatchComplications.swift @@ -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) -> 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" + ) + } +} diff --git a/PortfolioJournalWatchWidget/de.lproj/Localizable.strings b/PortfolioJournalWatchWidget/de.lproj/Localizable.strings new file mode 100644 index 0000000..2211c79 --- /dev/null +++ b/PortfolioJournalWatchWidget/de.lproj/Localizable.strings @@ -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"; diff --git a/PortfolioJournalWatchWidget/en.lproj/Localizable.strings b/PortfolioJournalWatchWidget/en.lproj/Localizable.strings new file mode 100644 index 0000000..550ba2c --- /dev/null +++ b/PortfolioJournalWatchWidget/en.lproj/Localizable.strings @@ -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"; diff --git a/PortfolioJournalWatchWidget/es-ES.lproj/Localizable.strings b/PortfolioJournalWatchWidget/es-ES.lproj/Localizable.strings new file mode 100644 index 0000000..cb9f63a --- /dev/null +++ b/PortfolioJournalWatchWidget/es-ES.lproj/Localizable.strings @@ -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"; diff --git a/PortfolioJournalWatchWidget/fr.lproj/Localizable.strings b/PortfolioJournalWatchWidget/fr.lproj/Localizable.strings new file mode 100644 index 0000000..874e953 --- /dev/null +++ b/PortfolioJournalWatchWidget/fr.lproj/Localizable.strings @@ -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"; diff --git a/PortfolioJournalWatchWidget/it.lproj/Localizable.strings b/PortfolioJournalWatchWidget/it.lproj/Localizable.strings new file mode 100644 index 0000000..979752c --- /dev/null +++ b/PortfolioJournalWatchWidget/it.lproj/Localizable.strings @@ -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"; diff --git a/PortfolioJournalWatchWidget/ja.lproj/Localizable.strings b/PortfolioJournalWatchWidget/ja.lproj/Localizable.strings new file mode 100644 index 0000000..7d1a15d --- /dev/null +++ b/PortfolioJournalWatchWidget/ja.lproj/Localizable.strings @@ -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" = "チェックイン未完了"; diff --git a/PortfolioJournalWatchWidget/pt-BR.lproj/Localizable.strings b/PortfolioJournalWatchWidget/pt-BR.lproj/Localizable.strings new file mode 100644 index 0000000..6c9cc0e --- /dev/null +++ b/PortfolioJournalWatchWidget/pt-BR.lproj/Localizable.strings @@ -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"; diff --git a/Shared/WatchColorHex.swift b/Shared/WatchColorHex.swift new file mode 100644 index 0000000..facb9b9 --- /dev/null +++ b/Shared/WatchColorHex.swift @@ -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 + ) + } +} diff --git a/Shared/WatchPortfolioSnapshot.swift b/Shared/WatchPortfolioSnapshot.swift new file mode 100644 index 0000000..7746701 --- /dev/null +++ b/Shared/WatchPortfolioSnapshot.swift @@ -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) + } + } +} diff --git a/Shared/WatchSnapshotCache.swift b/Shared/WatchSnapshotCache.swift new file mode 100644 index 0000000..aa7aede --- /dev/null +++ b/Shared/WatchSnapshotCache.swift @@ -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) + } +}