From cf27620552c5380c262d2c62cf9945494372daa2 Mon Sep 17 00:00:00 2001 From: MtlPhil <76601115+MtlPhil@users.noreply.github.com> Date: Sat, 7 Mar 2026 17:05:10 -0500 Subject: [PATCH 01/82] =?UTF-8?q?feat:=20Live=20Activity=20=E2=80=94=20Pha?= =?UTF-8?q?se=201=20(lock=20screen=20+=20Dynamic=20Island,=20APNs=20self-p?= =?UTF-8?q?ush)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements a lock screen and Dynamic Island Live Activity for LoopFollow displaying real-time glucose data updated via APNs self-push. ## What's included - Lock screen card: glucose + trend arrow, delta, IOB, COB, projected, last update time, threshold-driven background color (green/orange/red) - Dynamic Island: compact, expanded, and minimal presentations - Not Looping overlay: red banner when Loop hasn't reported in 15+ min - APNs self-push: app sends push to itself for reliable background updates without interference from background audio session - Single source of truth: all data flows from Storage/Observable - Source-agnostic: IOB/COB/projected are optional, safe for Dexcom-only users - Dynamic App Group ID: derived from bundle identifier, no hardcoded team IDs - APNs key injected via xcconfig/Info.plist — never bundled, never committed ## Files added - LoopFollow/LiveActivity/: APNSClient, APNSJWTGenerator, AppGroupID, GlucoseLiveActivityAttributes, GlucoseSnapshot, GlucoseSnapshotBuilder, GlucoseSnapshotStore, GlucoseUnitConversion, LAAppGroupSettings, LAThresholdSync, LiveActivityManager, PreferredGlucoseUnit, StorageCurrentGlucoseStateProvider - LoopFollowLAExtension/: LoopFollowLiveActivity, LoopFollowLABundle - docs/LiveActivity.md (architecture + APNs setup guide) ## Files modified - Storage: added lastBgReadingTimeSeconds, lastDeltaMgdl, lastTrendCode, lastIOB, lastCOB, projectedBgMgdl - Observable: added isNotLooping - BGData, DeviceStatusLoop, DeviceStatusOpenAPS: write canonical values to Storage - DeviceStatus: write isNotLooping to Observable - BackgroundTaskAudio: cleanup - MainViewController: wired LiveActivityManager.refreshFromCurrentState() - Info.plist: added APNSKeyID, APNSTeamID, APNSKeyContent build settings - fastlane/Fastfile: added extension App ID and provisioning profile - build_LoopFollow.yml: inject APNs key from GitHub secret --- .github/workflows/build_LoopFollow.yml | 10 + LoopFollow.xcodeproj/project.pbxproj | 308 +++++++++++- .../Controllers/Nightscout/BGData.swift | 12 + .../Controllers/Nightscout/DeviceStatus.swift | 2 + .../Nightscout/DeviceStatusLoop.swift | 10 + .../Nightscout/DeviceStatusOpenAPS.swift | 5 + LoopFollow/Helpers/BackgroundTaskAudio.swift | 21 +- LoopFollow/Info.plist | 12 +- LoopFollow/LiveActivity/APNSClient.swift | 111 +++++ .../LiveActivity/APNSJWTGenerator.swift | 116 +++++ LoopFollow/LiveActivity/AppGroupID.swift | 66 +++ .../GlucoseLiveActivityAttributes.swift | 29 ++ LoopFollow/LiveActivity/GlucoseSnapshot.swift | 113 +++++ .../LiveActivity/GlucoseSnapshotBuilder.swift | 117 +++++ .../LiveActivity/GlucoseSnapshotStore.swift | 80 ++++ .../LiveActivity/GlucoseUnitConversion.swift | 28 ++ .../LiveActivity/LAAppGroupSettings.swift | 39 ++ LoopFollow/LiveActivity/LAThresholdSync.swift | 22 + .../LiveActivity/LiveActivityManager.swift | 254 ++++++++++ .../LiveActivity/PreferredGlucoseUnit.swift | 34 ++ .../StorageCurrentGlucoseStateProvider.swift | 49 ++ LoopFollow/Loop Follow.entitlements | 4 + LoopFollow/Storage/Observable.swift | 2 + LoopFollow/Storage/Storage.swift | 8 + .../ViewControllers/MainViewController.swift | 2 +- LoopFollowLAExtension/ExtensionInfo.plist | 27 ++ .../LoopFollowLABundle.swift | 24 + .../LoopFollowLiveActivity.swift | 440 ++++++++++++++++++ LoopFollowLAExtensionExtension.entitlements | 10 + docs/LiveActivity.md | 166 +++++++ fastlane/Fastfile | 21 +- 31 files changed, 2115 insertions(+), 27 deletions(-) create mode 100644 LoopFollow/LiveActivity/APNSClient.swift create mode 100644 LoopFollow/LiveActivity/APNSJWTGenerator.swift create mode 100644 LoopFollow/LiveActivity/AppGroupID.swift create mode 100644 LoopFollow/LiveActivity/GlucoseLiveActivityAttributes.swift create mode 100644 LoopFollow/LiveActivity/GlucoseSnapshot.swift create mode 100644 LoopFollow/LiveActivity/GlucoseSnapshotBuilder.swift create mode 100644 LoopFollow/LiveActivity/GlucoseSnapshotStore.swift create mode 100644 LoopFollow/LiveActivity/GlucoseUnitConversion.swift create mode 100644 LoopFollow/LiveActivity/LAAppGroupSettings.swift create mode 100644 LoopFollow/LiveActivity/LAThresholdSync.swift create mode 100644 LoopFollow/LiveActivity/LiveActivityManager.swift create mode 100644 LoopFollow/LiveActivity/PreferredGlucoseUnit.swift create mode 100644 LoopFollow/LiveActivity/StorageCurrentGlucoseStateProvider.swift create mode 100644 LoopFollowLAExtension/ExtensionInfo.plist create mode 100644 LoopFollowLAExtension/LoopFollowLABundle.swift create mode 100644 LoopFollowLAExtension/LoopFollowLiveActivity.swift create mode 100644 LoopFollowLAExtensionExtension.entitlements create mode 100644 docs/LiveActivity.md diff --git a/.github/workflows/build_LoopFollow.yml b/.github/workflows/build_LoopFollow.yml index 0ad0e814a..361dcacdc 100644 --- a/.github/workflows/build_LoopFollow.yml +++ b/.github/workflows/build_LoopFollow.yml @@ -203,6 +203,16 @@ jobs: - name: Sync clock run: sudo sntp -sS time.windows.com + - name: Inject APNs Key Content + env: + APNS_KEY: ${{ secrets.APNS_KEY }} + APNS_KEY_ID: ${{ secrets.APNS_KEY_ID }} + run: | + # Strip PEM headers, footers, and newlines — xcconfig requires single line + APNS_KEY_CONTENT=$(echo "$APNS_KEY" | grep -v "BEGIN\|END" | tr -d '\n\r ') + echo "APNS_KEY_ID = ${APNS_KEY_ID}" >> "${GITHUB_WORKSPACE}/LoopFollowConfigOverride.xcconfig" + echo "APNS_KEY_CONTENT = ${APNS_KEY_CONTENT}" >> "${GITHUB_WORKSPACE}/LoopFollowConfigOverride.xcconfig" + # Build signed LoopFollow IPA file - name: Fastlane Build & Archive run: bundle exec fastlane build_LoopFollow diff --git a/LoopFollow.xcodeproj/project.pbxproj b/LoopFollow.xcodeproj/project.pbxproj index e5cee0dc2..f85491fc1 100644 --- a/LoopFollow.xcodeproj/project.pbxproj +++ b/LoopFollow.xcodeproj/project.pbxproj @@ -7,6 +7,27 @@ objects = { /* Begin PBXBuildFile section */ + 374A77972F5BD78700E96858 /* APNSJWTGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A77962F5BD77500E96858 /* APNSJWTGenerator.swift */; }; + 374A77992F5BD8B200E96858 /* APNSClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A77982F5BD8AB00E96858 /* APNSClient.swift */; }; + 374A77A42F5BE17000E96858 /* GlucoseUnitConversion.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A77A22F5BE17000E96858 /* GlucoseUnitConversion.swift */; }; + 374A77A52F5BE17000E96858 /* AppGroupID.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A779F2F5BE17000E96858 /* AppGroupID.swift */; }; + 374A77A62F5BE17000E96858 /* GlucoseSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A77A12F5BE17000E96858 /* GlucoseSnapshot.swift */; }; + 374A77A72F5BE17000E96858 /* LAAppGroupSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A77A32F5BE17000E96858 /* LAAppGroupSettings.swift */; }; + 374A77A82F5BE17000E96858 /* GlucoseLiveActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A77A02F5BE17000E96858 /* GlucoseLiveActivityAttributes.swift */; }; + 374A77A92F5BE17000E96858 /* GlucoseUnitConversion.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A77A22F5BE17000E96858 /* GlucoseUnitConversion.swift */; }; + 374A77AA2F5BE17000E96858 /* AppGroupID.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A779F2F5BE17000E96858 /* AppGroupID.swift */; }; + 374A77AB2F5BE17000E96858 /* GlucoseSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A77A12F5BE17000E96858 /* GlucoseSnapshot.swift */; }; + 374A77AC2F5BE17000E96858 /* LAAppGroupSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A77A32F5BE17000E96858 /* LAAppGroupSettings.swift */; }; + 374A77AD2F5BE17000E96858 /* GlucoseLiveActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A77A02F5BE17000E96858 /* GlucoseLiveActivityAttributes.swift */; }; + 374A77B42F5BE1AC00E96858 /* StorageCurrentGlucoseStateProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A77B32F5BE1AC00E96858 /* StorageCurrentGlucoseStateProvider.swift */; }; + 374A77B52F5BE1AC00E96858 /* GlucoseSnapshotBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A77AE2F5BE1AC00E96858 /* GlucoseSnapshotBuilder.swift */; }; + 374A77B62F5BE1AC00E96858 /* LAThresholdSync.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A77B02F5BE1AC00E96858 /* LAThresholdSync.swift */; }; + 374A77B72F5BE1AC00E96858 /* LiveActivityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A77B12F5BE1AC00E96858 /* LiveActivityManager.swift */; }; + 374A77B82F5BE1AC00E96858 /* GlucoseSnapshotStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A77AF2F5BE1AC00E96858 /* GlucoseSnapshotStore.swift */; }; + 374A77B92F5BE1AC00E96858 /* PreferredGlucoseUnit.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A77B22F5BE1AC00E96858 /* PreferredGlucoseUnit.swift */; }; + 37A4BDDB2F5B6B4A00EEB289 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 37A4BDDA2F5B6B4A00EEB289 /* WidgetKit.framework */; }; + 37A4BDDD2F5B6B4A00EEB289 /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 37A4BDDC2F5B6B4A00EEB289 /* SwiftUI.framework */; }; + 37A4BDE82F5B6B4C00EEB289 /* LoopFollowLAExtensionExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 37A4BDD92F5B6B4A00EEB289 /* LoopFollowLAExtensionExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 3F1335F351590E573D8E6962 /* Pods_LoopFollow.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A7D55B42A22051DAD69E89D0 /* Pods_LoopFollow.framework */; }; 654132E72E19EA7E00BDBE08 /* SimpleQRCodeScannerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 654132E62E19EA7E00BDBE08 /* SimpleQRCodeScannerView.swift */; }; 654132EA2E19F24800BDBE08 /* TOTPGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 654132E92E19F24800BDBE08 /* TOTPGenerator.swift */; }; @@ -198,14 +219,14 @@ DDC7E5472DBD8A1600EB1127 /* AlarmEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDC7E5402DBD8A1600EB1127 /* AlarmEditor.swift */; }; DDC7E5CF2DC77C2000EB1127 /* SnoozerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDC7E5CE2DC77C2000EB1127 /* SnoozerViewModel.swift */; }; DDCC3A4B2DDBB5E4006F1C10 /* BatteryCondition.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCC3A4A2DDBB5E4006F1C10 /* BatteryCondition.swift */; }; - DDCC3A502DDED000006F1C10 /* PumpBatteryCondition.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCC3A512DDED000006F1C10 /* PumpBatteryCondition.swift */; }; DDCC3A4D2DDBB77C006F1C10 /* PhoneBatteryAlarmEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCC3A4C2DDBB77C006F1C10 /* PhoneBatteryAlarmEditor.swift */; }; - DDCC3A5B2DDE2000006F1C10 /* PumpBatteryAlarmEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCC3A5C2DDE2000006F1C10 /* PumpBatteryAlarmEditor.swift */; }; DDCC3A4F2DDC5B54006F1C10 /* BatteryDropCondition.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCC3A4E2DDC5B54006F1C10 /* BatteryDropCondition.swift */; }; + DDCC3A502DDED000006F1C10 /* PumpBatteryCondition.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCC3A512DDED000006F1C10 /* PumpBatteryCondition.swift */; }; DDCC3A542DDC5D62006F1C10 /* BatteryDropAlarmEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCC3A532DDC5D62006F1C10 /* BatteryDropAlarmEditor.swift */; }; DDCC3A562DDC9617006F1C10 /* MissedBolusCondition.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCC3A552DDC9617006F1C10 /* MissedBolusCondition.swift */; }; DDCC3A582DDC9655006F1C10 /* MissedBolusAlarmEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCC3A572DDC9655006F1C10 /* MissedBolusAlarmEditor.swift */; }; DDCC3A5A2DDC988F006F1C10 /* CarbSample.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCC3A592DDC988F006F1C10 /* CarbSample.swift */; }; + DDCC3A5B2DDE2000006F1C10 /* PumpBatteryAlarmEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCC3A5C2DDE2000006F1C10 /* PumpBatteryAlarmEditor.swift */; }; DDCF979424C0D380002C9752 /* UIViewExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCF979324C0D380002C9752 /* UIViewExtension.swift */; }; DDCF9A802D85FD0B004DF4DD /* Alarm.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCF9A7F2D85FD09004DF4DD /* Alarm.swift */; }; DDCF9A822D85FD15004DF4DD /* AlarmType.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCF9A812D85FD14004DF4DD /* AlarmType.swift */; }; @@ -400,6 +421,13 @@ /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ + 37A4BDE62F5B6B4C00EEB289 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = FC97880C2485969B00A7906C /* Project object */; + proxyType = 1; + remoteGlobalIDString = 37A4BDD82F5B6B4A00EEB289; + remoteInfo = LoopFollowLAExtensionExtension; + }; DDCC3ADA2DDE1790006F1C10 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = FC97880C2485969B00A7906C /* Project object */; @@ -409,8 +437,39 @@ }; /* End PBXContainerItemProxy section */ +/* Begin PBXCopyFilesBuildPhase section */ + 37A4BDED2F5B6B4C00EEB289 /* Embed Foundation Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 13; + files = ( + 37A4BDE82F5B6B4C00EEB289 /* LoopFollowLAExtensionExtension.appex in Embed Foundation Extensions */, + ); + name = "Embed Foundation Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + /* Begin PBXFileReference section */ 059B0FA59AABFE72FE13DDDA /* Pods-LoopFollow.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-LoopFollow.release.xcconfig"; path = "Target Support Files/Pods-LoopFollow/Pods-LoopFollow.release.xcconfig"; sourceTree = ""; }; + 374A77962F5BD77500E96858 /* APNSJWTGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APNSJWTGenerator.swift; sourceTree = ""; }; + 374A77982F5BD8AB00E96858 /* APNSClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APNSClient.swift; sourceTree = ""; }; + 374A779F2F5BE17000E96858 /* AppGroupID.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppGroupID.swift; sourceTree = ""; }; + 374A77A02F5BE17000E96858 /* GlucoseLiveActivityAttributes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseLiveActivityAttributes.swift; sourceTree = ""; }; + 374A77A12F5BE17000E96858 /* GlucoseSnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseSnapshot.swift; sourceTree = ""; }; + 374A77A22F5BE17000E96858 /* GlucoseUnitConversion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseUnitConversion.swift; sourceTree = ""; }; + 374A77A32F5BE17000E96858 /* LAAppGroupSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LAAppGroupSettings.swift; sourceTree = ""; }; + 374A77AE2F5BE1AC00E96858 /* GlucoseSnapshotBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseSnapshotBuilder.swift; sourceTree = ""; }; + 374A77AF2F5BE1AC00E96858 /* GlucoseSnapshotStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseSnapshotStore.swift; sourceTree = ""; }; + 374A77B02F5BE1AC00E96858 /* LAThresholdSync.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LAThresholdSync.swift; sourceTree = ""; }; + 374A77B12F5BE1AC00E96858 /* LiveActivityManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActivityManager.swift; sourceTree = ""; }; + 374A77B22F5BE1AC00E96858 /* PreferredGlucoseUnit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferredGlucoseUnit.swift; sourceTree = ""; }; + 374A77B32F5BE1AC00E96858 /* StorageCurrentGlucoseStateProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StorageCurrentGlucoseStateProvider.swift; sourceTree = ""; }; + 374DAACA2F5B924B00BB663B /* LoopFollowLAExtensionExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = LoopFollowLAExtensionExtension.entitlements; sourceTree = ""; }; + 37A4BDD92F5B6B4A00EEB289 /* LoopFollowLAExtensionExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = LoopFollowLAExtensionExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + 37A4BDDA2F5B6B4A00EEB289 /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = /System/Library/Frameworks/WidgetKit.framework; sourceTree = ""; }; + 37A4BDDC2F5B6B4A00EEB289 /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = /System/Library/Frameworks/SwiftUI.framework; sourceTree = ""; }; 654132E62E19EA7E00BDBE08 /* SimpleQRCodeScannerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimpleQRCodeScannerView.swift; sourceTree = ""; }; 654132E92E19F24800BDBE08 /* TOTPGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TOTPGenerator.swift; sourceTree = ""; }; 654134172E1DC09700BDBE08 /* OverridePresetsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverridePresetsView.swift; sourceTree = ""; }; @@ -601,14 +660,14 @@ DDC7E5402DBD8A1600EB1127 /* AlarmEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlarmEditor.swift; sourceTree = ""; }; DDC7E5CE2DC77C2000EB1127 /* SnoozerViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SnoozerViewModel.swift; sourceTree = ""; }; DDCC3A4A2DDBB5E4006F1C10 /* BatteryCondition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatteryCondition.swift; sourceTree = ""; }; - DDCC3A512DDED000006F1C10 /* PumpBatteryCondition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PumpBatteryCondition.swift; sourceTree = ""; }; DDCC3A4C2DDBB77C006F1C10 /* PhoneBatteryAlarmEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhoneBatteryAlarmEditor.swift; sourceTree = ""; }; - DDCC3A5C2DDE2000006F1C10 /* PumpBatteryAlarmEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PumpBatteryAlarmEditor.swift; sourceTree = ""; }; DDCC3A4E2DDC5B54006F1C10 /* BatteryDropCondition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatteryDropCondition.swift; sourceTree = ""; }; + DDCC3A512DDED000006F1C10 /* PumpBatteryCondition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PumpBatteryCondition.swift; sourceTree = ""; }; DDCC3A532DDC5D62006F1C10 /* BatteryDropAlarmEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatteryDropAlarmEditor.swift; sourceTree = ""; }; DDCC3A552DDC9617006F1C10 /* MissedBolusCondition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MissedBolusCondition.swift; sourceTree = ""; }; DDCC3A572DDC9655006F1C10 /* MissedBolusAlarmEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MissedBolusAlarmEditor.swift; sourceTree = ""; }; DDCC3A592DDC988F006F1C10 /* CarbSample.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarbSample.swift; sourceTree = ""; }; + DDCC3A5C2DDE2000006F1C10 /* PumpBatteryAlarmEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PumpBatteryAlarmEditor.swift; sourceTree = ""; }; DDCC3ABF2DDE10B0006F1C10 /* Testing.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Testing.framework; path = Platforms/iPhoneOS.platform/Developer/Library/Frameworks/Testing.framework; sourceTree = DEVELOPER_DIR; }; DDCC3AD62DDE1790006F1C10 /* Tests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = Tests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; DDCF979324C0D380002C9752 /* UIViewExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIViewExtension.swift; sourceTree = ""; }; @@ -810,10 +869,20 @@ /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ + 37A4BDDE2F5B6B4A00EEB289 /* LoopFollowLAExtension */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = LoopFollowLAExtension; sourceTree = ""; }; DDCC3AD72DDE1790006F1C10 /* Tests */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = Tests; sourceTree = ""; }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ + 37A4BDD62F5B6B4A00EEB289 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 37A4BDDD2F5B6B4A00EEB289 /* SwiftUI.framework in Frameworks */, + 37A4BDDB2F5B6B4A00EEB289 /* WidgetKit.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; DDCC3AD32DDE1790006F1C10 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -835,6 +904,26 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 376310762F5CD65100656488 /* LiveActivity */ = { + isa = PBXGroup; + children = ( + 374A77AE2F5BE1AC00E96858 /* GlucoseSnapshotBuilder.swift */, + 374A77AF2F5BE1AC00E96858 /* GlucoseSnapshotStore.swift */, + 374A77B02F5BE1AC00E96858 /* LAThresholdSync.swift */, + 374A77B12F5BE1AC00E96858 /* LiveActivityManager.swift */, + 374A77B22F5BE1AC00E96858 /* PreferredGlucoseUnit.swift */, + 374A77B32F5BE1AC00E96858 /* StorageCurrentGlucoseStateProvider.swift */, + 374A779F2F5BE17000E96858 /* AppGroupID.swift */, + 374A77A02F5BE17000E96858 /* GlucoseLiveActivityAttributes.swift */, + 374A77A12F5BE17000E96858 /* GlucoseSnapshot.swift */, + 374A77A22F5BE17000E96858 /* GlucoseUnitConversion.swift */, + 374A77A32F5BE17000E96858 /* LAAppGroupSettings.swift */, + 374A77982F5BD8AB00E96858 /* APNSClient.swift */, + 374A77962F5BD77500E96858 /* APNSJWTGenerator.swift */, + ); + path = LiveActivity; + sourceTree = ""; + }; 6589CC552E9E7D1600BB18FE /* ImportExport */ = { isa = PBXGroup; children = ( @@ -873,6 +962,8 @@ FCFEEC9D2486E68E00402A7F /* WebKit.framework */, A7D55B42A22051DAD69E89D0 /* Pods_LoopFollow.framework */, FCE537C2249AAB2600F80BF8 /* NotificationCenter.framework */, + 37A4BDDA2F5B6B4A00EEB289 /* WidgetKit.framework */, + 37A4BDDC2F5B6B4A00EEB289 /* SwiftUI.framework */, ); name = Frameworks; sourceTree = ""; @@ -1476,6 +1567,7 @@ FC8DEEE32485D1680075863F /* LoopFollow */ = { isa = PBXGroup; children = ( + 376310762F5CD65100656488 /* LiveActivity */, 6589CC612E9E7D1600BB18FE /* Settings */, DDCF9A7E2D85FCE6004DF4DD /* Alarm */, FC16A97624995FEE003D6245 /* Application */, @@ -1504,6 +1596,7 @@ FC97880B2485969B00A7906C = { isa = PBXGroup; children = ( + 374DAACA2F5B924B00BB663B /* LoopFollowLAExtensionExtension.entitlements */, DDF2C0132BEFD468007A20E6 /* blacklisted-versions.json */, DDB0AF542BB1B24A00AFA48B /* BuildDetails.plist */, DDB0AF4F2BB1A81F00AFA48B /* Scripts */, @@ -1512,6 +1605,7 @@ FC5A5C3C2497B229009C550E /* Config.xcconfig */, FC8DEEE32485D1680075863F /* LoopFollow */, DDCC3AD72DDE1790006F1C10 /* Tests */, + 37A4BDDE2F5B6B4A00EEB289 /* LoopFollowLAExtension */, FC9788152485969B00A7906C /* Products */, 8E32230C453C93FDCE59C2B9 /* Pods */, 6A5880E0B811AF443B05AB02 /* Frameworks */, @@ -1523,6 +1617,7 @@ children = ( FC9788142485969B00A7906C /* Loop Follow.app */, DDCC3AD62DDE1790006F1C10 /* Tests.xctest */, + 37A4BDD92F5B6B4A00EEB289 /* LoopFollowLAExtensionExtension.appex */, ); name = Products; sourceTree = ""; @@ -1594,6 +1689,28 @@ /* End PBXGroup section */ /* Begin PBXNativeTarget section */ + 37A4BDD82F5B6B4A00EEB289 /* LoopFollowLAExtensionExtension */ = { + isa = PBXNativeTarget; + buildConfigurationList = 37A4BDEA2F5B6B4C00EEB289 /* Build configuration list for PBXNativeTarget "LoopFollowLAExtensionExtension" */; + buildPhases = ( + 37A4BDD52F5B6B4A00EEB289 /* Sources */, + 37A4BDD62F5B6B4A00EEB289 /* Frameworks */, + 37A4BDD72F5B6B4A00EEB289 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + 37A4BDDE2F5B6B4A00EEB289 /* LoopFollowLAExtension */, + ); + name = LoopFollowLAExtensionExtension; + packageProductDependencies = ( + ); + productName = LoopFollowLAExtensionExtension; + productReference = 37A4BDD92F5B6B4A00EEB289 /* LoopFollowLAExtensionExtension.appex */; + productType = "com.apple.product-type.app-extension"; + }; DDCC3AD52DDE1790006F1C10 /* Tests */ = { isa = PBXNativeTarget; buildConfigurationList = DDCC3ADC2DDE1790006F1C10 /* Build configuration list for PBXNativeTarget "Tests" */; @@ -1628,10 +1745,12 @@ FC9788122485969B00A7906C /* Resources */, 04DA71CCA0280FA5FA2DF7A6 /* [CP] Embed Pods Frameworks */, DDB0AF532BB1AA0900AFA48B /* Capture Build Details */, + 37A4BDED2F5B6B4C00EEB289 /* Embed Foundation Extensions */, ); buildRules = ( ); dependencies = ( + 37A4BDE72F5B6B4C00EEB289 /* PBXTargetDependency */, ); name = LoopFollow; packageProductDependencies = ( @@ -1648,10 +1767,13 @@ FC97880C2485969B00A7906C /* Project object */ = { isa = PBXProject; attributes = { - LastSwiftUpdateCheck = 1630; + LastSwiftUpdateCheck = 2620; LastUpgradeCheck = 1140; ORGANIZATIONNAME = "Jon Fawcett"; TargetAttributes = { + 37A4BDD82F5B6B4A00EEB289 = { + CreatedOnToolsVersion = 26.2; + }; DDCC3AD52DDE1790006F1C10 = { CreatedOnToolsVersion = 16.3; TestTargetID = FC9788132485969B00A7906C; @@ -1681,11 +1803,19 @@ targets = ( FC9788132485969B00A7906C /* LoopFollow */, DDCC3AD52DDE1790006F1C10 /* Tests */, + 37A4BDD82F5B6B4A00EEB289 /* LoopFollowLAExtensionExtension */, ); }; /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ + 37A4BDD72F5B6B4A00EEB289 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; DDCC3AD42DDE1790006F1C10 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -1909,6 +2039,18 @@ /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ + 37A4BDD52F5B6B4A00EEB289 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 374A77A92F5BE17000E96858 /* GlucoseUnitConversion.swift in Sources */, + 374A77AA2F5BE17000E96858 /* AppGroupID.swift in Sources */, + 374A77AB2F5BE17000E96858 /* GlucoseSnapshot.swift in Sources */, + 374A77AC2F5BE17000E96858 /* LAAppGroupSettings.swift in Sources */, + 374A77AD2F5BE17000E96858 /* GlucoseLiveActivityAttributes.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; DDCC3AD22DDE1790006F1C10 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -1927,11 +2069,18 @@ DD7F4B9F2DD1F92700D449E9 /* AlarmActiveSection.swift in Sources */, DD4AFB672DB68C5500BB593F /* UUID+Identifiable.swift in Sources */, DD9ED0CA2D355257000D2A63 /* LogView.swift in Sources */, + 374A77972F5BD78700E96858 /* APNSJWTGenerator.swift in Sources */, DD9ACA082D32F68B00415D8A /* BGTask.swift in Sources */, DD9ACA102D34129200415D8A /* Task.swift in Sources */, DD4AFB492DB576C200BB593F /* AlarmSettingsView.swift in Sources */, DD7F4C032DD4B92E00D449E9 /* NotLoopingAlarmEditor.swift in Sources */, DD7F4C1B2DD6501D00D449E9 /* COBCondition.swift in Sources */, + 374A77B42F5BE1AC00E96858 /* StorageCurrentGlucoseStateProvider.swift in Sources */, + 374A77B52F5BE1AC00E96858 /* GlucoseSnapshotBuilder.swift in Sources */, + 374A77B62F5BE1AC00E96858 /* LAThresholdSync.swift in Sources */, + 374A77B72F5BE1AC00E96858 /* LiveActivityManager.swift in Sources */, + 374A77B82F5BE1AC00E96858 /* GlucoseSnapshotStore.swift in Sources */, + 374A77B92F5BE1AC00E96858 /* PreferredGlucoseUnit.swift in Sources */, DDE75D232DE5E505007C1FC1 /* Glyph.swift in Sources */, DD4878202C7DAF890048F05C /* PushMessage.swift in Sources */, DD0247712DB4337700FCADF6 /* BuildExpireCondition.swift in Sources */, @@ -2012,6 +2161,7 @@ DDEF503A2D31615000999A5D /* LogManager.swift in Sources */, DD4878172C7B75350048F05C /* BolusView.swift in Sources */, DD026E592EA2C8A200A39CB5 /* InsulinPrecisionManager.swift in Sources */, + 374A77992F5BD8B200E96858 /* APNSClient.swift in Sources */, DD493AE72ACF23CF009A6922 /* DeviceStatus.swift in Sources */, DDC7E5162DBCFA7F00EB1127 /* SnoozerView.swift in Sources */, FCFEECA2248857A600402A7F /* SettingsViewController.swift in Sources */, @@ -2137,6 +2287,11 @@ DD7F4C212DD66BB200D449E9 /* FastRiseAlarmEditor.swift in Sources */, FC97881A2485969B00A7906C /* SceneDelegate.swift in Sources */, DD0C0C662C46E54C00DBADDF /* InfoDataSeparator.swift in Sources */, + 374A77A42F5BE17000E96858 /* GlucoseUnitConversion.swift in Sources */, + 374A77A52F5BE17000E96858 /* AppGroupID.swift in Sources */, + 374A77A62F5BE17000E96858 /* GlucoseSnapshot.swift in Sources */, + 374A77A72F5BE17000E96858 /* LAAppGroupSettings.swift in Sources */, + 374A77A82F5BE17000E96858 /* GlucoseLiveActivityAttributes.swift in Sources */, DD58171C2D299F940041FB98 /* BluetoothDevice.swift in Sources */, DD7E198A2ACDA62600DBD158 /* SensorStart.swift in Sources */, DD026E5B2EA2C9C300A39CB5 /* InsulinFormatter.swift in Sources */, @@ -2189,6 +2344,11 @@ /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ + 37A4BDE72F5B6B4C00EEB289 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 37A4BDD82F5B6B4A00EEB289 /* LoopFollowLAExtensionExtension */; + targetProxy = 37A4BDE62F5B6B4C00EEB289 /* PBXContainerItemProxy */; + }; DDCC3ADB2DDE1790006F1C10 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = FC9788132485969B00A7906C /* LoopFollow */; @@ -2216,6 +2376,109 @@ /* End PBXVariantGroup section */ /* Begin XCBuildConfiguration section */ + 37A4BDEB2F5B6B4C00EEB289 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CODE_SIGN_ENTITLEMENTS = LoopFollowLAExtensionExtension.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 2HEY366Q6J; + ENABLE_APP_SANDBOX = NO; + ENABLE_HARDENED_RUNTIME = NO; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = LoopFollowLAExtension/ExtensionInfo.plist; + INFOPLIST_KEY_CFBundleDisplayName = LoopFollowLAExtension; + INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2026 Jon Fawcett. All rights reserved."; + IPHONEOS_DEPLOYMENT_TARGET = 16.6; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = ( + "$(inherited)", + "@executable_path/../Frameworks", + "@executable_path/../../../../Frameworks", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MACOSX_DEPLOYMENT_TARGET = 26.2; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.2HEY366Q6J.LoopFollow.LoopFollowLAExtension; + PRODUCT_MODULE_NAME = LoopFollowLAExtension; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = auto; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 1; + XROS_DEPLOYMENT_TARGET = 26.2; + }; + name = Debug; + }; + 37A4BDEC2F5B6B4C00EEB289 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CODE_SIGN_ENTITLEMENTS = LoopFollowLAExtensionExtension.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 2HEY366Q6J; + ENABLE_APP_SANDBOX = NO; + ENABLE_HARDENED_RUNTIME = NO; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = LoopFollowLAExtension/ExtensionInfo.plist; + INFOPLIST_KEY_CFBundleDisplayName = LoopFollowLAExtension; + INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2026 Jon Fawcett. All rights reserved."; + IPHONEOS_DEPLOYMENT_TARGET = 16.6; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = ( + "$(inherited)", + "@executable_path/../Frameworks", + "@executable_path/../../../../Frameworks", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MACOSX_DEPLOYMENT_TARGET = 26.2; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.2HEY366Q6J.LoopFollow.LoopFollowLAExtension; + PRODUCT_MODULE_NAME = LoopFollowLAExtension; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = auto; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 1; + XROS_DEPLOYMENT_TARGET = 26.2; + }; + name = Release; + }; DDCC3ADD2DDE1790006F1C10 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -2225,7 +2488,7 @@ CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = "$(LF_DEVELOPMENT_TEAM)"; + DEVELOPMENT_TEAM = "$(LF_DEVELOPMENT_TEAM)"; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; GENERATE_INFOPLIST_FILE = YES; @@ -2252,7 +2515,7 @@ CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = "$(LF_DEVELOPMENT_TEAM)"; + DEVELOPMENT_TEAM = "$(LF_DEVELOPMENT_TEAM)"; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; GENERATE_INFOPLIST_FILE = YES; @@ -2397,18 +2660,21 @@ CODE_SIGN_ENTITLEMENTS = "LoopFollow/Loop Follow.entitlements"; CODE_SIGN_STYLE = Automatic; DEFINES_MODULE = YES; - DEVELOPMENT_TEAM = "$(LF_DEVELOPMENT_TEAM)"; + DEVELOPMENT_TEAM = 2HEY366Q6J; INFOPLIST_FILE = LoopFollow/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 16.6; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = "com.$(unique_id).LoopFollow$(app_suffix)"; + PRODUCT_BUNDLE_IDENTIFIER = com.2HEY366Q6J.LoopFollow; PRODUCT_NAME = "Loop Follow"; - SUPPORTS_MACCATALYST = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; + TARGETED_DEVICE_FAMILY = 1; }; name = Debug; }; @@ -2420,24 +2686,36 @@ CODE_SIGN_ENTITLEMENTS = "LoopFollow/Loop Follow.entitlements"; CODE_SIGN_STYLE = Automatic; DEFINES_MODULE = YES; - DEVELOPMENT_TEAM = "$(LF_DEVELOPMENT_TEAM)"; + DEVELOPMENT_TEAM = 2HEY366Q6J; INFOPLIST_FILE = LoopFollow/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 16.6; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = "com.$(unique_id).LoopFollow$(app_suffix)"; + PRODUCT_BUNDLE_IDENTIFIER = com.2HEY366Q6J.LoopFollow; PRODUCT_NAME = "Loop Follow"; - SUPPORTS_MACCATALYST = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; + TARGETED_DEVICE_FAMILY = 1; }; name = Release; }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ + 37A4BDEA2F5B6B4C00EEB289 /* Build configuration list for PBXNativeTarget "LoopFollowLAExtensionExtension" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 37A4BDEB2F5B6B4C00EEB289 /* Debug */, + 37A4BDEC2F5B6B4C00EEB289 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; DDCC3ADC2DDE1790006F1C10 /* Build configuration list for PBXNativeTarget "Tests" */ = { isa = XCConfigurationList; buildConfigurations = ( diff --git a/LoopFollow/Controllers/Nightscout/BGData.swift b/LoopFollow/Controllers/Nightscout/BGData.swift index c07da66d5..2f0311053 100644 --- a/LoopFollow/Controllers/Nightscout/BGData.swift +++ b/LoopFollow/Controllers/Nightscout/BGData.swift @@ -260,8 +260,19 @@ extension MainViewController { Observable.shared.deltaText.value = "+" + Localizer.toDisplayUnits(String(deltaBG)) } + // Live Activity storage + Storage.shared.lastBgReadingTimeSeconds.value = lastBGTime + Storage.shared.lastDeltaMgdl.value = Double(deltaBG) + Storage.shared.lastTrendCode.value = entries[latestEntryIndex].direction + // Mark BG data as loaded for initial loading state self.markDataLoaded("bg") + + // Live Activity update + if #available(iOS 16.1, *) { + LiveActivityManager.shared.refreshFromCurrentState(reason: "bg") + } + // Update contact if Storage.shared.contactEnabled.value { @@ -274,6 +285,7 @@ extension MainViewController { ) } Storage.shared.lastBGChecked.value = Date() + } } } diff --git a/LoopFollow/Controllers/Nightscout/DeviceStatus.swift b/LoopFollow/Controllers/Nightscout/DeviceStatus.swift index f8bc8f867..a287548c6 100644 --- a/LoopFollow/Controllers/Nightscout/DeviceStatus.swift +++ b/LoopFollow/Controllers/Nightscout/DeviceStatus.swift @@ -46,6 +46,7 @@ extension MainViewController { if IsNightscoutEnabled(), (now - lastLoopTime) >= nonLoopingTimeThreshold, lastLoopTime > 0 { IsNotLooping = true + Observable.shared.isNotLooping.value = true statusStackView.distribution = .fill PredictionLabel.isHidden = true @@ -58,6 +59,7 @@ extension MainViewController { } else { IsNotLooping = false + Observable.shared.isNotLooping.value = false statusStackView.distribution = .fillEqually PredictionLabel.isHidden = false diff --git a/LoopFollow/Controllers/Nightscout/DeviceStatusLoop.swift b/LoopFollow/Controllers/Nightscout/DeviceStatusLoop.swift index fe10b62b9..d4f851ba5 100644 --- a/LoopFollow/Controllers/Nightscout/DeviceStatusLoop.swift +++ b/LoopFollow/Controllers/Nightscout/DeviceStatusLoop.swift @@ -119,6 +119,16 @@ extension MainViewController { LoopStatusLabel.text = "↻" latestLoopStatusString = "↻" } + + // Live Activity storage + Storage.shared.lastIOB.value = latestIOB?.value + Storage.shared.lastCOB.value = latestCOB?.value + if let predictdata = lastLoopRecord["predicted"] as? [String: AnyObject], + let values = predictdata["values"] as? [Double] { + Storage.shared.projectedBgMgdl.value = values.last + } else { + Storage.shared.projectedBgMgdl.value = nil + } } } } diff --git a/LoopFollow/Controllers/Nightscout/DeviceStatusOpenAPS.swift b/LoopFollow/Controllers/Nightscout/DeviceStatusOpenAPS.swift index 57a940695..fc3b3c5b5 100644 --- a/LoopFollow/Controllers/Nightscout/DeviceStatusOpenAPS.swift +++ b/LoopFollow/Controllers/Nightscout/DeviceStatusOpenAPS.swift @@ -224,6 +224,11 @@ extension MainViewController { LoopStatusLabel.text = "↻" latestLoopStatusString = "↻" } + + // Live Activity storage + Storage.shared.lastIOB.value = latestIOB?.value + Storage.shared.lastCOB.value = latestCOB?.value + Storage.shared.projectedBgMgdl.value = nil } } } diff --git a/LoopFollow/Helpers/BackgroundTaskAudio.swift b/LoopFollow/Helpers/BackgroundTaskAudio.swift index 91504ab5d..b7bbd26a8 100755 --- a/LoopFollow/Helpers/BackgroundTaskAudio.swift +++ b/LoopFollow/Helpers/BackgroundTaskAudio.swift @@ -1,16 +1,18 @@ -// LoopFollow // BackgroundTaskAudio.swift +// LoopFollow +// Philippe Achkar +// 2026-03-07 import AVFoundation class BackgroundTask { - // MARK: - Vars + // MARK: - Vars + static let shared = BackgroundTask() var player = AVAudioPlayer() var timer = Timer() // MARK: - Methods - func startBackgroundTask() { NotificationCenter.default.addObserver(self, selector: #selector(interruptedAudio), name: AVAudioSession.interruptionNotification, object: AVAudioSession.sharedInstance()) playAudio() @@ -19,10 +21,15 @@ class BackgroundTask { func stopBackgroundTask() { NotificationCenter.default.removeObserver(self, name: AVAudioSession.interruptionNotification, object: nil) player.stop() - LogManager.shared.log(category: .general, message: "Silent audio stopped", isDebug: true) + do { + try AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation) + LogManager.shared.log(category: .general, message: "Silent audio stopped", isDebug: true) + } catch { + LogManager.shared.log(category: .general, message: "Silent audio stop failed: \(error)", isDebug: true) + } } - @objc fileprivate func interruptedAudio(_ notification: Notification) { + @objc private func interruptedAudio(_ notification: Notification) { LogManager.shared.log(category: .general, message: "Silent audio interrupted") if notification.name == AVAudioSession.interruptionNotification, notification.userInfo != nil { var info = notification.userInfo! @@ -32,15 +39,13 @@ class BackgroundTask { } } - fileprivate func playAudio() { + private func playAudio() { do { let bundle = Bundle.main.path(forResource: "blank", ofType: "wav") let alertSound = URL(fileURLWithPath: bundle!) - // try AVAudioSession.sharedInstance().setCategory(AVAudioSession.Category.playback) try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default, options: .mixWithOthers) try AVAudioSession.sharedInstance().setActive(true) try player = AVAudioPlayer(contentsOf: alertSound) - // Play audio forever by setting num of loops to -1 player.numberOfLoops = -1 player.volume = 0.01 player.prepareToPlay() diff --git a/LoopFollow/Info.plist b/LoopFollow/Info.plist index e76068f9a..94351928e 100644 --- a/LoopFollow/Info.plist +++ b/LoopFollow/Info.plist @@ -2,6 +2,12 @@ + APNSKeyContent + $(APNS_KEY_CONTENT) + APNSKeyID + $(APNS_KEY_ID) + APNSTeamID + $(DEVELOPMENT_TEAM) AppGroupIdentifier group.com.$(unique_id).LoopFollow$(app_suffix) BGTaskSchedulerPermittedIdentifiers @@ -16,8 +22,10 @@ $(EXECUTABLE_NAME) CFBundleGetInfoString + CFBundleIconFile + Activities CFBundleIdentifier - com.$(unique_id).LoopFollow$(app_suffix) + $(PRODUCT_BUNDLE_IDENTIFIER) CFBundleInfoDictionaryVersion 6.0 CFBundleName @@ -61,6 +69,8 @@ This app requires Face ID for secure authentication. NSHumanReadableCopyright + NSSupportsLiveActivities + UIApplicationSceneManifest UIApplicationSupportsMultipleScenes diff --git a/LoopFollow/LiveActivity/APNSClient.swift b/LoopFollow/LiveActivity/APNSClient.swift new file mode 100644 index 000000000..8a46babef --- /dev/null +++ b/LoopFollow/LiveActivity/APNSClient.swift @@ -0,0 +1,111 @@ +// APNSClient.swift +// Philippe Achkar +// 2026-03-07 + +import Foundation + +class APNSClient { + + static let shared = APNSClient() + private init() {} + + // MARK: - Configuration + + private let bundleID = Bundle.main.bundleIdentifier ?? "com.apple.unknown" + private let apnsHost = "https://api.push.apple.com" + + // MARK: - JWT Cache + + private var cachedToken: String? + private var tokenGeneratedAt: Date? + private let tokenTTL: TimeInterval = 55 * 60 + + private func validToken() throws -> String { + let now = Date() + if let token = cachedToken, + let generatedAt = tokenGeneratedAt, + now.timeIntervalSince(generatedAt) < tokenTTL { + return token + } + let newToken = try APNSJWTGenerator.generateToken() + cachedToken = newToken + tokenGeneratedAt = now + LogManager.shared.log(category: .general, message: "APNs JWT refreshed", isDebug: true) + return newToken + } + + // MARK: - Send Live Activity Update + + func sendLiveActivityUpdate( + pushToken: String, + state: GlucoseLiveActivityAttributes.ContentState + ) async { + do { + let jwt = try validToken() + let payload = buildPayload(state: state) + + guard let url = URL(string: "\(apnsHost)/3/device/\(pushToken)") else { + LogManager.shared.log(category: .general, message: "APNs invalid URL", isDebug: true) + return + } + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("bearer \(jwt)", forHTTPHeaderField: "authorization") + request.setValue("application/json", forHTTPHeaderField: "content-type") + request.setValue("\(bundleID).push-type.liveactivity", forHTTPHeaderField: "apns-topic") + request.setValue("liveactivity", forHTTPHeaderField: "apns-push-type") + request.setValue("10", forHTTPHeaderField: "apns-priority") + request.httpBody = payload + + let (data, response) = try await URLSession.shared.data(for: request) + + if let httpResponse = response as? HTTPURLResponse { + if httpResponse.statusCode == 200 { + LogManager.shared.log(category: .general, message: "APNs push sent successfully", isDebug: true) + } else { + let responseBody = String(data: data, encoding: .utf8) ?? "empty" + LogManager.shared.log(category: .general, message: "APNs push failed status=\(httpResponse.statusCode) body=\(responseBody)") + } + } + + } catch { + LogManager.shared.log(category: .general, message: "APNs error: \(error.localizedDescription)") + } + } + + // MARK: - Payload Builder + + private func buildPayload(state: GlucoseLiveActivityAttributes.ContentState) -> Data? { + let snapshot = state.snapshot + + var snapshotDict: [String: Any] = [ + "glucose": snapshot.glucose, + "delta": snapshot.delta, + "trend": snapshot.trend.rawValue, + "updatedAt": snapshot.updatedAt.timeIntervalSince1970, + "unit": snapshot.unit.rawValue + ] + + if let iob = snapshot.iob { snapshotDict["iob"] = iob } + if let cob = snapshot.cob { snapshotDict["cob"] = cob } + if let projected = snapshot.projected { snapshotDict["projected"] = projected } + + let contentState: [String: Any] = [ + "snapshot": snapshotDict, + "seq": state.seq, + "reason": state.reason, + "producedAt": state.producedAt.timeIntervalSince1970 + ] + + let payload: [String: Any] = [ + "aps": [ + "timestamp": Int(Date().timeIntervalSince1970), + "event": "update", + "content-state": contentState + ] + ] + + return try? JSONSerialization.data(withJSONObject: payload) + } +} diff --git a/LoopFollow/LiveActivity/APNSJWTGenerator.swift b/LoopFollow/LiveActivity/APNSJWTGenerator.swift new file mode 100644 index 000000000..381000ed1 --- /dev/null +++ b/LoopFollow/LiveActivity/APNSJWTGenerator.swift @@ -0,0 +1,116 @@ +// APNSJWTGenerator.swift +// Philippe Achkar +// 2026-03-07 + +import Foundation +import CryptoKit + +struct APNSJWTGenerator { + + // MARK: - Configuration (read from Info.plist — never hardcoded) + + static var keyID: String { + Bundle.main.infoDictionary?["APNSKeyID"] as? String ?? "" + } + + static var teamID: String { + Bundle.main.infoDictionary?["APNSTeamID"] as? String ?? "" + } + + static var keyContent: String { + Bundle.main.infoDictionary?["APNSKeyContent"] as? String ?? "" + } + + // MARK: - JWT Generation + + /// Generates a signed ES256 JWT for APNs authentication. + /// Valid for 60 minutes per Apple's requirements. + static func generateToken() throws -> String { + let privateKey = try loadPrivateKey() + let header = try encodeHeader() + let payload = try encodePayload() + let signingInput = "\(header).\(payload)" + + guard let signingData = signingInput.data(using: .utf8) else { + throw APNSJWTError.encodingFailed + } + + let signature = try privateKey.signature(for: signingData) + let signatureBase64 = base64URLEncode(signature.rawRepresentation) + return "\(signingInput).\(signatureBase64)" + } + + // MARK: - Private Helpers + + private static func loadPrivateKey() throws -> P256.Signing.PrivateKey { + guard !keyID.isEmpty else { + throw APNSJWTError.keyIDNotConfigured + } + + guard !keyContent.isEmpty else { + throw APNSJWTError.keyContentNotConfigured + } + + // Strip PEM headers/footers and whitespace if present + let cleaned = keyContent + .replacingOccurrences(of: "-----BEGIN PRIVATE KEY-----", with: "") + .replacingOccurrences(of: "-----END PRIVATE KEY-----", with: "") + .replacingOccurrences(of: "\n", with: "") + .replacingOccurrences(of: "\r", with: "") + .trimmingCharacters(in: .whitespaces) + + guard let keyData = Data(base64Encoded: cleaned) else { + throw APNSJWTError.keyDecodingFailed + } + + return try P256.Signing.PrivateKey(derRepresentation: keyData) + } + + private static func encodeHeader() throws -> String { + let header: [String: String] = [ + "alg": "ES256", + "kid": keyID + ] + let data = try JSONSerialization.data(withJSONObject: header) + return base64URLEncode(data) + } + + private static func encodePayload() throws -> String { + let now = Int(Date().timeIntervalSince1970) + let payload: [String: Any] = [ + "iss": teamID, + "iat": now + ] + let data = try JSONSerialization.data(withJSONObject: payload) + return base64URLEncode(data) + } + + private static func base64URLEncode(_ data: Data) -> String { + return data.base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } +} + +// MARK: - Errors + +enum APNSJWTError: Error, LocalizedError { + case keyIDNotConfigured + case keyContentNotConfigured + case keyDecodingFailed + case encodingFailed + + var errorDescription: String? { + switch self { + case .keyIDNotConfigured: + return "APNSKeyID not set in Info.plist or LoopFollowConfigOverride.xcconfig." + case .keyContentNotConfigured: + return "APNSKeyContent not set. Add APNS_KEY_CONTENT to LoopFollowConfigOverride.xcconfig or GitHub Secrets." + case .keyDecodingFailed: + return "Failed to decode APNs p8 key content. Ensure it is valid base64 with no line breaks." + case .encodingFailed: + return "Failed to encode JWT signing input." + } + } +} diff --git a/LoopFollow/LiveActivity/AppGroupID.swift b/LoopFollow/LiveActivity/AppGroupID.swift new file mode 100644 index 000000000..7b02acb94 --- /dev/null +++ b/LoopFollow/LiveActivity/AppGroupID.swift @@ -0,0 +1,66 @@ +// +// AppGroupID.swift +// LoopFollow +// +// Created by Philippe Achkar on 2026-02-24. +// + +import Foundation + +/// Resolves the App Group identifier in a PR-safe way. +/// +/// Preferred contract: +/// - App Group = "group." +/// - No team-specific hardcoding +/// +/// Important nuance: +/// - Extensions often have a *different* bundle identifier than the main app. +/// - To keep app + extensions aligned, we: +/// 1) Prefer an explicit base bundle id if provided via Info.plist key. +/// 2) Otherwise, apply a conservative suffix-stripping heuristic. +/// 3) Fall back to the current bundle identifier. +enum AppGroupID { + + /// Optional Info.plist key you can set in *both* app + extension targets + /// to force a shared base bundle id (recommended for reliability). + private static let baseBundleIDPlistKey = "LFAppGroupBaseBundleID" + + static func current() -> String { + if let base = Bundle.main.object(forInfoDictionaryKey: baseBundleIDPlistKey) as? String, + !base.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return "group.\(base)" + } + + let bundleID = Bundle.main.bundleIdentifier ?? "unknown" + + // Heuristic: strip common extension suffixes so the extension can land on the main app’s group id. + let base = stripLikelyExtensionSuffixes(from: bundleID) + + return "group.\(base)" + } + + private static func stripLikelyExtensionSuffixes(from bundleID: String) -> String { + let knownSuffixes = [ + ".LiveActivity", + ".LiveActivityExtension", + ".Widget", + ".WidgetExtension", + ".Widgets", + ".WidgetsExtension", + ".Watch", + ".WatchExtension", + ".CarPlay", + ".CarPlayExtension", + ".Intents", + ".IntentsExtension" + ] + + for suffix in knownSuffixes { + if bundleID.hasSuffix(suffix) { + return String(bundleID.dropLast(suffix.count)) + } + } + + return bundleID + } +} \ No newline at end of file diff --git a/LoopFollow/LiveActivity/GlucoseLiveActivityAttributes.swift b/LoopFollow/LiveActivity/GlucoseLiveActivityAttributes.swift new file mode 100644 index 000000000..1052ee0ec --- /dev/null +++ b/LoopFollow/LiveActivity/GlucoseLiveActivityAttributes.swift @@ -0,0 +1,29 @@ +// +// GlucoseLiveActivityAttributes.swift +// LoopFollow +// +// Created by Philippe Achkar on 2026-02-24. +// + +import ActivityKit +import Foundation + +struct GlucoseLiveActivityAttributes: ActivityAttributes { + + public struct ContentState: Codable, Hashable { + /// The latest snapshot, already converted into the user’s preferred unit. + let snapshot: GlucoseSnapshot + + /// Monotonic sequence for “did we update?” debugging and hung detection. + let seq: Int + + /// Reason the app refreshed (e.g., "bg", "deviceStatus"). + let reason: String + + /// When the activity state was produced. + let producedAt: Date + } + + /// Reserved for future metadata. Keep minimal for stability. + let title: String +} \ No newline at end of file diff --git a/LoopFollow/LiveActivity/GlucoseSnapshot.swift b/LoopFollow/LiveActivity/GlucoseSnapshot.swift new file mode 100644 index 000000000..563be34d5 --- /dev/null +++ b/LoopFollow/LiveActivity/GlucoseSnapshot.swift @@ -0,0 +1,113 @@ +// +// GlucoseSnapshot.swift +// LoopFollow +// +// Created by Philippe Achkar on 2026-02-24. +// + +import Foundation + +/// Canonical, source-agnostic glucose state used by +/// Live Activity, future Watch complication, and CarPlay. +/// +struct GlucoseSnapshot: Codable, Equatable, Hashable { + + // MARK: - Units + + enum Unit: String, Codable, Hashable { + case mgdl + case mmol + } + + // MARK: - Core Glucose + + /// Raw glucose value in the user-selected unit. + let glucose: Double + + /// Raw delta in the user-selected unit. May be 0.0 if unchanged. + let delta: Double + + /// Trend direction (mapped from LoopFollow state). + let trend: Trend + + /// Timestamp of reading. + let updatedAt: Date + + // MARK: - Secondary Metrics + + /// Insulin On Board + let iob: Double? + + /// Carbs On Board + let cob: Double? + + /// Projected glucose (if available) + let projected: Double? + + // MARK: - Unit Context + + /// Unit selected by the user in LoopFollow settings. + let unit: Unit + + // MARK: - Loop Status + /// True when LoopFollow detects the loop has not reported in 15+ minutes (Nightscout only). + let isNotLooping: Bool + + init( + glucose: Double, + delta: Double, + trend: Trend, + updatedAt: Date, + iob: Double?, + cob: Double?, + projected: Double?, + unit: Unit, + isNotLooping: Bool + ) { + self.glucose = glucose + self.delta = delta + self.trend = trend + self.updatedAt = updatedAt + self.iob = iob + self.cob = cob + self.projected = projected + self.unit = unit + self.isNotLooping = isNotLooping + } + + // MARK: - Codable + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + glucose = try container.decode(Double.self, forKey: .glucose) + delta = try container.decode(Double.self, forKey: .delta) + trend = try container.decode(Trend.self, forKey: .trend) + updatedAt = try container.decode(Date.self, forKey: .updatedAt) + iob = try container.decodeIfPresent(Double.self, forKey: .iob) + cob = try container.decodeIfPresent(Double.self, forKey: .cob) + projected = try container.decodeIfPresent(Double.self, forKey: .projected) + unit = try container.decode(Unit.self, forKey: .unit) + isNotLooping = try container.decodeIfPresent(Bool.self, forKey: .isNotLooping) ?? false + } + + // MARK: - Derived Convenience + + /// Age of reading in seconds. + var age: TimeInterval { + Date().timeIntervalSince(updatedAt) + } +} + + +// MARK: - Trend + +extension GlucoseSnapshot { + + enum Trend: String, Codable, Hashable { + case up + case upFast + case flat + case down + case downFast + case unknown + } +} diff --git a/LoopFollow/LiveActivity/GlucoseSnapshotBuilder.swift b/LoopFollow/LiveActivity/GlucoseSnapshotBuilder.swift new file mode 100644 index 000000000..a61774ead --- /dev/null +++ b/LoopFollow/LiveActivity/GlucoseSnapshotBuilder.swift @@ -0,0 +1,117 @@ +// +// GlucoseSnapshotBuilder.swift +// LoopFollow +// +// Created by Philippe Achkar on 2026-02-28. +// + +import Foundation + +/// Provides the *latest* glucose-relevant values from LoopFollow’s single source of truth. +/// This is intentionally provider-agnostic (Nightscout vs Dexcom doesn’t matter). +protocol CurrentGlucoseStateProviding { + /// Canonical glucose value in mg/dL (recommended internal canonical form). + var glucoseMgdl: Double? { get } + + /// Canonical delta in mg/dL. + var deltaMgdl: Double? { get } + + /// Canonical projected glucose in mg/dL. + var projectedMgdl: Double? { get } + + /// Timestamp of the last reading/update. + var updatedAt: Date? { get } + + /// Trend string / code from LoopFollow (we map to GlucoseSnapshot.Trend). + var trendCode: String? { get } + + /// Secondary metrics (typically already unitless) + var iob: Double? { get } + var cob: Double? { get } +} + +/// Builds a GlucoseSnapshot in the user’s preferred unit, without embedding provider logic. +enum GlucoseSnapshotBuilder { + + static func build(from provider: CurrentGlucoseStateProviding) -> GlucoseSnapshot? { + guard + let glucoseMgdl = provider.glucoseMgdl, + glucoseMgdl > 0, + let updatedAt = provider.updatedAt + else { + // Debug-only signal: we’re missing core state. + // (If you prefer no logs here, remove this line.) + LogManager.shared.log( + category: .general, + message: "GlucoseSnapshotBuilder: missing/invalid core values glucoseMgdl=\(provider.glucoseMgdl?.description ?? "nil") updatedAt=\(provider.updatedAt?.description ?? "nil")", + isDebug: true + ) + return nil + } + + let preferredUnit = PreferredGlucoseUnit.snapshotUnit() + + let glucose = GlucoseUnitConversion.convertGlucose(glucoseMgdl, from: .mgdl, to: preferredUnit) + + let deltaMgdl = provider.deltaMgdl ?? 0.0 + let delta = GlucoseUnitConversion.convertGlucose(deltaMgdl, from: .mgdl, to: preferredUnit) + + let projected: Double? + if let projMgdl = provider.projectedMgdl { + projected = GlucoseUnitConversion.convertGlucose(projMgdl, from: .mgdl, to: preferredUnit) + } else { + projected = nil + } + + let trend = mapTrend(provider.trendCode) + + // Not Looping — read from Observable, set by evaluateNotLooping() in DeviceStatus.swift + let isNotLooping = Observable.shared.isNotLooping.value + + return GlucoseSnapshot( + glucose: glucose, + delta: delta, + trend: trend, + updatedAt: updatedAt, + iob: provider.iob, + cob: provider.cob, + projected: projected, + unit: preferredUnit, + isNotLooping: isNotLooping + ) + } + + private static func mapTrend(_ code: String?) -> GlucoseSnapshot.Trend { + guard + let raw = code? + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased(), + !raw.isEmpty + else { return .unknown } + + // Common Nightscout strings: + // "Flat", "FortyFiveUp", "SingleUp", "DoubleUp", "FortyFiveDown", "SingleDown", "DoubleDown" + // Common variants: + // "rising", "falling", "rapidRise", "rapidFall" + + if raw.contains("doubleup") || raw.contains("rapidrise") || raw == "up2" || raw == "upfast" { + return .upFast + } + if raw.contains("singleup") || raw.contains("fortyfiveup") || raw == "up" || raw == "up1" || raw == "rising" { + return .up + } + + if raw.contains("flat") || raw == "steady" || raw == "none" { + return .flat + } + + if raw.contains("doubledown") || raw.contains("rapidfall") || raw == "down2" || raw == "downfast" { + return .downFast + } + if raw.contains("singledown") || raw.contains("fortyfivedown") || raw == "down" || raw == "down1" || raw == "falling" { + return .down + } + + return .unknown + } +} diff --git a/LoopFollow/LiveActivity/GlucoseSnapshotStore.swift b/LoopFollow/LiveActivity/GlucoseSnapshotStore.swift new file mode 100644 index 000000000..b906742ce --- /dev/null +++ b/LoopFollow/LiveActivity/GlucoseSnapshotStore.swift @@ -0,0 +1,80 @@ +// +// GlucoseSnapshotStore.swift +// LoopFollow +// +// Created by Philippe Achkar on 2026-02-24. +// + +import Foundation + +/// Persists the latest GlucoseSnapshot into the App Group container so that: +/// - the Live Activity extension can read it +/// - future Watch + CarPlay surfaces can reuse it +/// +/// Uses an atomic JSON file write to avoid partial/corrupt reads across processes. +final class GlucoseSnapshotStore { + + static let shared = GlucoseSnapshotStore() + private init() {} + + private let fileName = "glucose_snapshot.json" + private let queue = DispatchQueue(label: "com.loopfollow.glucoseSnapshotStore", qos: .utility) + + // MARK: - Public API + + func save(_ snapshot: GlucoseSnapshot) { + queue.async { + do { + let url = try self.fileURL() + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + let data = try encoder.encode(snapshot) + try data.write(to: url, options: [.atomic]) + } catch { + // Intentionally silent (extension-safe, no dependencies). + } + } + } + + func load() -> GlucoseSnapshot? { + do { + let url = try fileURL() + guard FileManager.default.fileExists(atPath: url.path) else { return nil } + + let data = try Data(contentsOf: url) + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + return try decoder.decode(GlucoseSnapshot.self, from: data) + } catch { + // Intentionally silent (extension-safe, no dependencies). + return nil + } + } + + func delete() { + queue.async { + do { + let url = try self.fileURL() + if FileManager.default.fileExists(atPath: url.path) { + try FileManager.default.removeItem(at: url) + } + } catch { + // Intentionally silent (extension-safe, no dependencies). + } + } + } + + // MARK: - Helpers + + private func fileURL() throws -> URL { + let groupID = AppGroupID.current() + guard let containerURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: groupID) else { + throw NSError( + domain: "GlucoseSnapshotStore", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "App Group containerURL is nil for id=\(groupID)"] + ) + } + return containerURL.appendingPathComponent(fileName, isDirectory: false) + } +} diff --git a/LoopFollow/LiveActivity/GlucoseUnitConversion.swift b/LoopFollow/LiveActivity/GlucoseUnitConversion.swift new file mode 100644 index 000000000..3d81620b5 --- /dev/null +++ b/LoopFollow/LiveActivity/GlucoseUnitConversion.swift @@ -0,0 +1,28 @@ +// +// GlucoseUnitConversion.swift +// LoopFollow +// +// Created by Philippe Achkar on 2026-02-24. +// + +import Foundation + +enum GlucoseUnitConversion { + + // 1 mmol/L glucose ≈ 18.0182 mg/dL (commonly rounded to 18) + // Using 18.0182 is standard for glucose conversions. + private static let mgdlPerMmol: Double = 18.0182 + + static func convertGlucose(_ value: Double, from: GlucoseSnapshot.Unit, to: GlucoseSnapshot.Unit) -> Double { + guard from != to else { return value } + + switch (from, to) { + case (.mgdl, .mmol): + return value / mgdlPerMmol + case (.mmol, .mgdl): + return value * mgdlPerMmol + default: + return value + } + } +} \ No newline at end of file diff --git a/LoopFollow/LiveActivity/LAAppGroupSettings.swift b/LoopFollow/LiveActivity/LAAppGroupSettings.swift new file mode 100644 index 000000000..091497f1e --- /dev/null +++ b/LoopFollow/LiveActivity/LAAppGroupSettings.swift @@ -0,0 +1,39 @@ +// +// LAAppGroupSettings.swift +// LoopFollow +// +// Created by Philippe Achkar on 2026-02-24. +// + +import Foundation + +/// Minimal App Group settings needed by the Live Activity UI. +/// +/// We keep this separate from Storage.shared to avoid target-coupling and +/// ensure the widget extension reads the same values as the app. +enum LAAppGroupSettings { + + private enum Keys { + static let lowLineMgdl = "la.lowLine.mgdl" + static let highLineMgdl = "la.highLine.mgdl" + } + + private static var defaults: UserDefaults? { + UserDefaults(suiteName: AppGroupID.current()) + } + + // MARK: - Write (App) + + static func setThresholds(lowMgdl: Double, highMgdl: Double) { + defaults?.set(lowMgdl, forKey: Keys.lowLineMgdl) + defaults?.set(highMgdl, forKey: Keys.highLineMgdl) + } + + // MARK: - Read (Extension) + + static func thresholdsMgdl(fallbackLow: Double = 70, fallbackHigh: Double = 180) -> (low: Double, high: Double) { + let low = defaults?.object(forKey: Keys.lowLineMgdl) as? Double ?? fallbackLow + let high = defaults?.object(forKey: Keys.highLineMgdl) as? Double ?? fallbackHigh + return (low, high) + } +} \ No newline at end of file diff --git a/LoopFollow/LiveActivity/LAThresholdSync.swift b/LoopFollow/LiveActivity/LAThresholdSync.swift new file mode 100644 index 000000000..03a5a95a4 --- /dev/null +++ b/LoopFollow/LiveActivity/LAThresholdSync.swift @@ -0,0 +1,22 @@ +// +// LAThresholdSync.swift +// LoopFollow +// +// Created by Philippe Achkar on 2026-02-25. +// + +import Foundation + +/// Bridges LoopFollow's internal threshold settings +/// into the App Group for extension consumption. +/// +/// This file belongs ONLY to the main app target. +enum LAThresholdSync { + + static func syncToAppGroup() { + LAAppGroupSettings.setThresholds( + lowMgdl: Storage.shared.lowLine.value, + highMgdl: Storage.shared.highLine.value + ) + } +} \ No newline at end of file diff --git a/LoopFollow/LiveActivity/LiveActivityManager.swift b/LoopFollow/LiveActivity/LiveActivityManager.swift new file mode 100644 index 000000000..e41ce5b39 --- /dev/null +++ b/LoopFollow/LiveActivity/LiveActivityManager.swift @@ -0,0 +1,254 @@ +// LiveActivityManager.swift +// Philippe Achkar +// 2026-03-07 + +import Foundation +@preconcurrency import ActivityKit +import UIKit +import os + +/// Live Activity manager for LoopFollow. + +@available(iOS 16.1, *) +final class LiveActivityManager { + + static let shared = LiveActivityManager() + private init() {} + + private(set) var current: Activity? + private var stateObserverTask: Task? + private var updateTask: Task? + private var seq: Int = 0 + private var lastUpdateTime: Date? + private var pushToken: String? + private var tokenObservationTask: Task? + + // MARK: - Public API + + func startIfNeeded() { + guard ActivityAuthorizationInfo().areActivitiesEnabled else { + LogManager.shared.log(category: .general, message: "Live Activity not authorized") + return + } + + if let existing = Activity.activities.first { + bind(to: existing, logReason: "reuse") + return + } + + do { + let attributes = GlucoseLiveActivityAttributes(title: "LoopFollow") + + let seedSnapshot = GlucoseSnapshotStore.shared.load() ?? GlucoseSnapshot( + glucose: 0, + delta: 0, + trend: .unknown, + updatedAt: Date(), + iob: nil, + cob: nil, + projected: nil, + unit: .mgdl, + isNotLooping: false + ) + + let initialState = GlucoseLiveActivityAttributes.ContentState( + snapshot: seedSnapshot, + seq: 0, + reason: "start", + producedAt: Date() + ) + + let content = ActivityContent(state: initialState, staleDate: Date().addingTimeInterval(15 * 60)) + let activity = try Activity.request(attributes: attributes, content: content, pushType: .token) + + bind(to: activity, logReason: "start-new") + LogManager.shared.log(category: .general, message: "Live Activity started id=\(activity.id)") + } catch { + LogManager.shared.log(category: .general, message: "Live Activity failed to start: \(error)") + } + } + + func end(dismissalPolicy: ActivityUIDismissalPolicy = .default) { + updateTask?.cancel() + updateTask = nil + + guard let activity = current else { return } + + Task { + let finalState = GlucoseLiveActivityAttributes.ContentState( + snapshot: (GlucoseSnapshotStore.shared.load() ?? GlucoseSnapshot( + glucose: 0, + delta: 0, + trend: .unknown, + updatedAt: Date(), + iob: nil, + cob: nil, + projected: nil, + unit: .mgdl, + isNotLooping: false + )), + seq: seq, + reason: "end", + producedAt: Date() + ) + + let content = ActivityContent(state: finalState, staleDate: nil) + await activity.end(content, dismissalPolicy: dismissalPolicy) + + LogManager.shared.log(category: .general, message: "Live Activity ended id=\(activity.id)", isDebug: true) + + if current?.id == activity.id { + current = nil + } + } + } + + func refreshFromCurrentState(reason: String) { + let provider = StorageCurrentGlucoseStateProvider() + + guard let snapshot = GlucoseSnapshotBuilder.build(from: provider) else { + return + } + + LogManager.shared.log(category: .general, message: "[LA] refresh g=\(snapshot.glucose) reason=\(reason)", isDebug: true) + + let fingerprint = + "g=\(snapshot.glucose) d=\(snapshot.delta) t=\(snapshot.trend.rawValue) " + + "at=\(snapshot.updatedAt.timeIntervalSince1970) iob=\(snapshot.iob?.description ?? "nil") " + + "cob=\(snapshot.cob?.description ?? "nil") proj=\(snapshot.projected?.description ?? "nil") u=\(snapshot.unit.rawValue)" + + LogManager.shared.log(category: .general, message: "[LA] snapshot \(fingerprint) reason=\(reason)", isDebug: true) + + let now = Date() + let timeSinceLastUpdate = now.timeIntervalSince(lastUpdateTime ?? .distantPast) + let forceRefreshNeeded = timeSinceLastUpdate >= 5 * 60 + + if let previous = GlucoseSnapshotStore.shared.load(), previous == snapshot, !forceRefreshNeeded { + return + } + + LAAppGroupSettings.setThresholds( + lowMgdl: Storage.shared.lowLine.value, + highMgdl: Storage.shared.highLine.value + ) + + GlucoseSnapshotStore.shared.save(snapshot) + + guard ActivityAuthorizationInfo().areActivitiesEnabled else { + return + } + + if current == nil, let existing = Activity.activities.first { + bind(to: existing, logReason: "bind-existing") + } + + if let _ = current { + update(snapshot: snapshot, reason: reason) + return + } + + if isAppVisibleForLiveActivityStart() { + startIfNeeded() + if current != nil { + update(snapshot: snapshot, reason: reason) + } + } else { + LogManager.shared.log(category: .general, message: "LA start suppressed (not visible) reason=\(reason)", isDebug: true) + } + } + + private func isAppVisibleForLiveActivityStart() -> Bool { + let scenes = UIApplication.shared.connectedScenes + return scenes.contains { $0.activationState == .foregroundActive } + } + + func update(snapshot: GlucoseSnapshot, reason: String) { + if current == nil, let existing = Activity.activities.first { + bind(to: existing, logReason: "bind-existing") + } + + guard let activity = current else { return } + + updateTask?.cancel() + + seq += 1 + let nextSeq = seq + let activityID = activity.id + + let state = GlucoseLiveActivityAttributes.ContentState( + snapshot: snapshot, + seq: nextSeq, + reason: reason, + producedAt: Date() + ) + + updateTask = Task { [weak self] in + guard let self else { return } + + if activity.activityState == .ended || activity.activityState == .dismissed { + if self.current?.id == activityID { self.current = nil } + return + } + + let content = ActivityContent( + state: state, + staleDate: Date().addingTimeInterval(15 * 60), + relevanceScore: 100.0 + ) + + if Task.isCancelled { return } + + await activity.update(content) + + if Task.isCancelled { return } + + guard self.current?.id == activityID else { + LogManager.shared.log(category: .general, message: "Live Activity update — activity ID mismatch, discarding") + return + } + + self.lastUpdateTime = Date() + LogManager.shared.log(category: .general, message: "[LA] updated id=\(activityID) seq=\(nextSeq) reason=\(reason)", isDebug: true) + + if let token = self.pushToken { + await APNSClient.shared.sendLiveActivityUpdate(pushToken: token, state: state) + } + } + } + + // MARK: - Binding / Lifecycle + + private func bind(to activity: Activity, logReason: String) { + if current?.id == activity.id { return } + current = activity + attachStateObserver(to: activity) + LogManager.shared.log(category: .general, message: "Live Activity bound id=\(activity.id) (\(logReason))", isDebug: true) + observePushToken(for: activity) + } + + private func observePushToken(for activity: Activity) { + tokenObservationTask?.cancel() + tokenObservationTask = Task { + for await tokenData in activity.pushTokenUpdates { + let token = tokenData.map { String(format: "%02x", $0) }.joined() + self.pushToken = token + LogManager.shared.log(category: .general, message: "Live Activity push token received", isDebug: true) + } + } + } + + private func attachStateObserver(to activity: Activity) { + stateObserverTask?.cancel() + stateObserverTask = Task { + for await state in activity.activityStateUpdates { + LogManager.shared.log(category: .general, message: "Live Activity state id=\(activity.id) -> \(state)", isDebug: true) + if state == .ended || state == .dismissed { + if current?.id == activity.id { + current = nil + LogManager.shared.log(category: .general, message: "Live Activity cleared id=\(activity.id)", isDebug: true) + } + } + } + } + } +} diff --git a/LoopFollow/LiveActivity/PreferredGlucoseUnit.swift b/LoopFollow/LiveActivity/PreferredGlucoseUnit.swift new file mode 100644 index 000000000..b4c5dadfb --- /dev/null +++ b/LoopFollow/LiveActivity/PreferredGlucoseUnit.swift @@ -0,0 +1,34 @@ +// +// PreferredGlucoseUnit.swift +// LoopFollow +// +// Created by Philippe Achkar on 2026-02-24. +// + +import Foundation +import HealthKit + +enum PreferredGlucoseUnit { + + /// LoopFollow’s existing source of truth for unit selection. + /// NOTE: Do not duplicate the string constant elsewhere—keep it here. + static func hkUnit() -> HKUnit { + let unitString = Storage.shared.units.value + switch unitString { + case "mmol/L": + return .millimolesPerLiter + default: + return .milligramsPerDeciliter + } + } + + /// Maps HKUnit -> GlucoseSnapshot.Unit (our cross-platform enum). + static func snapshotUnit() -> GlucoseSnapshot.Unit { + switch hkUnit() { + case .millimolesPerLiter: + return .mmol + default: + return .mgdl + } + } +} \ No newline at end of file diff --git a/LoopFollow/LiveActivity/StorageCurrentGlucoseStateProvider.swift b/LoopFollow/LiveActivity/StorageCurrentGlucoseStateProvider.swift new file mode 100644 index 000000000..5e50a3e0d --- /dev/null +++ b/LoopFollow/LiveActivity/StorageCurrentGlucoseStateProvider.swift @@ -0,0 +1,49 @@ +// +// StorageCurrentGlucoseStateProvider.swift +// LoopFollow +// +// Created by Philippe Achkar on 2026-02-24. +// + +import Foundation + +/// Reads the latest glucose state from LoopFollow’s existing single source of truth. +/// Provider remains source-agnostic (Nightscout vs Dexcom). +struct StorageCurrentGlucoseStateProvider: CurrentGlucoseStateProviding { + + var glucoseMgdl: Double? { + guard + let bg = Observable.shared.bg.value, + bg > 0 + else { + return nil + } + + return Double(bg) + } + + var deltaMgdl: Double? { + Storage.shared.lastDeltaMgdl.value + } + + var projectedMgdl: Double? { + Storage.shared.projectedBgMgdl.value + } + + var updatedAt: Date? { + guard let t = Storage.shared.lastBgReadingTimeSeconds.value else { return nil } + return Date(timeIntervalSince1970: t) + } + + var trendCode: String? { + Storage.shared.lastTrendCode.value + } + + var iob: Double? { + Storage.shared.lastIOB.value + } + + var cob: Double? { + Storage.shared.lastCOB.value + } +} diff --git a/LoopFollow/Loop Follow.entitlements b/LoopFollow/Loop Follow.entitlements index ec1156a01..4ec33220c 100644 --- a/LoopFollow/Loop Follow.entitlements +++ b/LoopFollow/Loop Follow.entitlements @@ -8,6 +8,10 @@ development com.apple.security.app-sandbox + com.apple.security.application-groups + + group.com.2HEY366Q6J.LoopFollow + com.apple.security.device.bluetooth com.apple.security.network.client diff --git a/LoopFollow/Storage/Observable.swift b/LoopFollow/Storage/Observable.swift index fd4494342..4caef7b94 100644 --- a/LoopFollow/Storage/Observable.swift +++ b/LoopFollow/Storage/Observable.swift @@ -42,6 +42,8 @@ class Observable { var lastSentTOTP = ObservableValue(default: nil) var loopFollowDeviceToken = ObservableValue(default: "") + + var isNotLooping = ObservableValue(default: false) private init() {} } diff --git a/LoopFollow/Storage/Storage.swift b/LoopFollow/Storage/Storage.swift index e5e2a7ffc..0cb7d32f3 100644 --- a/LoopFollow/Storage/Storage.swift +++ b/LoopFollow/Storage/Storage.swift @@ -80,6 +80,14 @@ class Storage { var speakLanguage = StorageValue(key: "speakLanguage", defaultValue: "en") // General Settings [END] + // Live Activity glucose state + var lastBgReadingTimeSeconds = StorageValue(key: "lastBgReadingTimeSeconds", defaultValue: nil) + var lastDeltaMgdl = StorageValue(key: "lastDeltaMgdl", defaultValue: nil) + var lastTrendCode = StorageValue(key: "lastTrendCode", defaultValue: nil) + var lastIOB = StorageValue(key: "lastIOB", defaultValue: nil) + var lastCOB = StorageValue(key: "lastCOB", defaultValue: nil) + var projectedBgMgdl = StorageValue(key: "projectedBgMgdl", defaultValue: nil) + // Graph Settings [BEGIN] var showDots = StorageValue(key: "showDots", defaultValue: true) var showLines = StorageValue(key: "showLines", defaultValue: true) diff --git a/LoopFollow/ViewControllers/MainViewController.swift b/LoopFollow/ViewControllers/MainViewController.swift index ab1df5766..453f67097 100644 --- a/LoopFollow/ViewControllers/MainViewController.swift +++ b/LoopFollow/ViewControllers/MainViewController.swift @@ -59,7 +59,7 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele var currentCage: cageData? var currentIage: iageData? - var backgroundTask = BackgroundTask() + var backgroundTask = BackgroundTask.shared var graphNowTimer = Timer() diff --git a/LoopFollowLAExtension/ExtensionInfo.plist b/LoopFollowLAExtension/ExtensionInfo.plist new file mode 100644 index 000000000..cf08ba141 --- /dev/null +++ b/LoopFollowLAExtension/ExtensionInfo.plist @@ -0,0 +1,27 @@ + + + + + CFBundleDisplayName + LoopFollowLAExtension + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + XPC! + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + NSExtension + + NSExtensionPointIdentifier + com.apple.widgetkit-extension + + + diff --git a/LoopFollowLAExtension/LoopFollowLABundle.swift b/LoopFollowLAExtension/LoopFollowLABundle.swift new file mode 100644 index 000000000..13b01574b --- /dev/null +++ b/LoopFollowLAExtension/LoopFollowLABundle.swift @@ -0,0 +1,24 @@ +// +// LoopFollowLABundle.swift +// LoopFollow +// +// Created by Philippe Achkar on 2026-03-07. +// Copyright © 2026 Jon Fawcett. All rights reserved. +// + + +// LoopFollowLABundle.swift +// Philippe Achkar +// 2026-03-07 + +import WidgetKit +import SwiftUI + +@main +struct LoopFollowLABundle: WidgetBundle { + var body: some Widget { + if #available(iOS 16.1, *) { + LoopFollowLiveActivityWidget() + } + } +} \ No newline at end of file diff --git a/LoopFollowLAExtension/LoopFollowLiveActivity.swift b/LoopFollowLAExtension/LoopFollowLiveActivity.swift new file mode 100644 index 000000000..2b0948679 --- /dev/null +++ b/LoopFollowLAExtension/LoopFollowLiveActivity.swift @@ -0,0 +1,440 @@ +// +// LoopFollowLiveActivity.swift +// LoopFollow +// +// Created by Philippe Achkar on 2026-02-24. +// + +import ActivityKit +import SwiftUI +import WidgetKit + +@available(iOS 16.1, *) +struct LoopFollowLiveActivityWidget: Widget { + + var body: some WidgetConfiguration { + ActivityConfiguration(for: GlucoseLiveActivityAttributes.self) { context in + // LOCK SCREEN / BANNER UI + LockScreenLiveActivityView(state: context.state/*, activityID: context.activityID*/) + .id(context.state.seq) // force SwiftUI to re-render on every update + .activitySystemActionForegroundColor(.white) + .activityBackgroundTint(LAColors.backgroundTint(for: context.state.snapshot)) + .applyActivityContentMarginsFixIfAvailable() + } dynamicIsland: { context in + // DYNAMIC ISLAND UI + DynamicIsland { + DynamicIslandExpandedRegion(.leading) { + DynamicIslandLeadingView(snapshot: context.state.snapshot) + .id(context.state.seq) + } + DynamicIslandExpandedRegion(.trailing) { + DynamicIslandTrailingView(snapshot: context.state.snapshot) + .id(context.state.seq) + } + DynamicIslandExpandedRegion(.bottom) { + DynamicIslandBottomView(snapshot: context.state.snapshot) + .id(context.state.seq) + } + } compactLeading: { + DynamicIslandCompactLeadingView(snapshot: context.state.snapshot) + .id(context.state.seq) + } compactTrailing: { + DynamicIslandCompactTrailingView(snapshot: context.state.snapshot) + .id(context.state.seq) + } minimal: { + DynamicIslandMinimalView(snapshot: context.state.snapshot) + .id(context.state.seq) + } + .keylineTint(LAColors.keyline(for: context.state.snapshot).opacity(0.75)) + } + } +} + +// MARK: - Live Activity content margins helper + +private extension View { + @ViewBuilder + func applyActivityContentMarginsFixIfAvailable() -> some View { + if #available(iOS 17.0, *) { + // Use the generic SwiftUI API available in iOS 17+ (no placement enum) + self.contentMargins(Edge.Set.all, 0) + } else { + self + } + } +} + +// MARK: - Lock Screen Contract View +@available(iOS 16.1, *) +private struct LockScreenLiveActivityView: View { + let state: GlucoseLiveActivityAttributes.ContentState + /*let activityID: String*/ + + var body: some View { + let s = state.snapshot + + HStack(spacing: 12) { + + // LEFT: Glucose + trend, update time below + VStack(alignment: .leading, spacing: 4) { + HStack(alignment: .firstTextBaseline, spacing: 8) { + Text(LAFormat.glucose(s)) + .font(.system(size: 46, weight: .bold, design: .rounded)) + .monospacedDigit() + .foregroundStyle(.white) + + Text(LAFormat.trendArrow(s)) + .font(.system(size: 46, weight: .bold, design: .rounded)) + .foregroundStyle(.white.opacity(0.95)) + } + + Text("Last Update: \(LAFormat.updated(s))") + .font(.system(size: 13, weight: .regular, design: .rounded)) + .foregroundStyle(.white.opacity(0.75)) + } + .frame(width: 168, alignment: .leading) + .layoutPriority(2) + + // Divider + Rectangle() + .fill(Color.white.opacity(0.20)) + .frame(width: 1) + .padding(.vertical, 8) + + // RIGHT: 2x2 grid — delta/proj | iob/cob + VStack(spacing: 10) { + HStack(spacing: 16) { + MetricBlock(label: "Delta", value: LAFormat.delta(s)) + MetricBlock(label: "IOB", value: LAFormat.iob(s)) + } + HStack(spacing: 16) { + MetricBlock(label: "Proj", value: LAFormat.projected(s)) + MetricBlock(label: "COB", value: LAFormat.cob(s)) + } + } + .frame(maxWidth: .infinity, alignment: .trailing) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 14) + .padding(.vertical, 12) + .overlay( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .stroke(Color.white.opacity(0.20), lineWidth: 1) + ) + .overlay( + Group { + if state.snapshot.isNotLooping { + ZStack { + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill(Color(uiColor: UIColor.systemRed).opacity(0.85)) + Text("Not Looping") + .font(.system(size: 20, weight: .heavy, design: .rounded)) + .foregroundStyle(.white) + .tracking(1.5) + } + } + } + ) + } +} + +private struct MetricBlock: View { + let label: String + let value: String + + var body: some View { + VStack(alignment: .leading, spacing: 2) { + Text(label) + .font(.system(size: 12, weight: .semibold, design: .rounded)) + .foregroundStyle(.white.opacity(0.78)) + + Text(value) + .font(.system(size: 16, weight: .bold, design: .rounded)) + .monospacedDigit() + .foregroundStyle(.white) + .lineLimit(1) + .minimumScaleFactor(0.85) + } + .frame(width: 64, alignment: .leading) // consistent 2×2 columns + } +} + +// MARK: - Dynamic Island + +@available(iOS 16.1, *) +private struct DynamicIslandLeadingView: View { + let snapshot: GlucoseSnapshot + var body: some View { + if snapshot.isNotLooping { + VStack(alignment: .leading, spacing: 2) { + Text("⚠️ Not Looping") + .font(.system(size: 20, weight: .heavy, design: .rounded)) + .foregroundStyle(.white) + .tracking(1.0) + .lineLimit(1) + .minimumScaleFactor(0.7) + } + } else { + VStack(alignment: .leading, spacing: 2) { + HStack(alignment: .firstTextBaseline, spacing: 6) { + Text(LAFormat.glucose(snapshot)) + .font(.system(size: 28, weight: .bold, design: .rounded)) + .monospacedDigit() + .foregroundStyle(.white) + Text(LAFormat.trendArrow(snapshot)) + .font(.system(size: 16, weight: .semibold, design: .rounded)) + .foregroundStyle(.white.opacity(0.95)) + .padding(.top, 2) + } + Text(LAFormat.delta(snapshot)) + .font(.system(size: 14, weight: .semibold, design: .rounded)) + .monospacedDigit() + .foregroundStyle(.white.opacity(0.9)) + } + } + } +} + +@available(iOS 16.1, *) +private struct DynamicIslandTrailingView: View { + let snapshot: GlucoseSnapshot + var body: some View { + if snapshot.isNotLooping { + EmptyView() + } else { + VStack(alignment: .trailing, spacing: 3) { + Text("Upd \(LAFormat.updated(snapshot))") + .font(.system(size: 12, weight: .semibold, design: .rounded)) + .foregroundStyle(.white.opacity(0.85)) + Text("Proj \(LAFormat.projected(snapshot))") + .font(.system(size: 13, weight: .bold, design: .rounded)) + .monospacedDigit() + .foregroundStyle(.white.opacity(0.95)) + } + } + } +} + +@available(iOS 16.1, *) +private struct DynamicIslandBottomView: View { + let snapshot: GlucoseSnapshot + var body: some View { + if snapshot.isNotLooping { + Text("Loop has not reported in 15+ minutes") + .font(.system(size: 12, weight: .semibold, design: .rounded)) + .foregroundStyle(.white.opacity(0.92)) + .lineLimit(1) + .minimumScaleFactor(0.75) + } else { + HStack(spacing: 14) { + Text("IOB \(LAFormat.iob(snapshot))") + Text("COB \(LAFormat.cob(snapshot))") + } + .font(.system(size: 13, weight: .semibold, design: .rounded)) + .foregroundStyle(.white.opacity(0.92)) + .lineLimit(1) + .minimumScaleFactor(0.85) + } + } +} + +@available(iOS 16.1, *) +private struct DynamicIslandCompactTrailingView: View { + let snapshot: GlucoseSnapshot + var body: some View { + if snapshot.isNotLooping { + Text("Not Looping") + .font(.system(size: 11, weight: .heavy, design: .rounded)) + .foregroundStyle(.white) + .lineLimit(1) + .minimumScaleFactor(0.7) + } else { + Text(LAFormat.trendArrow(snapshot)) + .font(.system(size: 14, weight: .semibold, design: .rounded)) + .foregroundStyle(.white.opacity(0.95)) + } + } +} + +@available(iOS 16.1, *) +private struct DynamicIslandCompactLeadingView: View { + let snapshot: GlucoseSnapshot + var body: some View { + if snapshot.isNotLooping { + Text("⚠️") + .font(.system(size: 14)) + } else { + Text(LAFormat.glucose(snapshot)) + .font(.system(size: 16, weight: .bold, design: .rounded)) + .monospacedDigit() + .foregroundStyle(.white) + } + } +} + +@available(iOS 16.1, *) +private struct DynamicIslandMinimalView: View { + let snapshot: GlucoseSnapshot + var body: some View { + if snapshot.isNotLooping { + Text("⚠️") + .font(.system(size: 12)) + } else { + Text(LAFormat.glucose(snapshot)) + .font(.system(size: 14, weight: .bold, design: .rounded)) + .monospacedDigit() + .foregroundStyle(.white) + } + } +} + +// MARK: - Formatting + +private enum LAFormat { + + // MARK: Glucose + + static func glucose(_ s: GlucoseSnapshot) -> String { + switch s.unit { + case .mgdl: + return String(Int(round(s.glucose))) + case .mmol: + // 1 decimal always (contract: clinical, consistent) + return String(format: "%.1f", s.glucose) + } + } + + static func delta(_ s: GlucoseSnapshot) -> String { + switch s.unit { + case .mgdl: + let v = Int(round(s.delta)) + if v == 0 { return "0" } + return v > 0 ? "+\(v)" : "\(v)" + + case .mmol: + // Treat tiny fluctuations as 0.0 to avoid “+0.0” noise + let d = (abs(s.delta) < 0.05) ? 0.0 : s.delta + if d == 0 { return "0.0" } + return d > 0 ? String(format: "+%.1f", d) : String(format: "%.1f", d) + } + } + + // MARK: Trend + + static func trendArrow(_ s: GlucoseSnapshot) -> String { + // Map to the common clinical arrows; keep unknown as a neutral dash. + switch s.trend { + case .upFast: return "↑↑" + case .up: return "↑" + case .flat: return "→" + case .down: return "↓" + case .downFast: return "↓↓" + case .unknown: return "–" + } + } + + // MARK: Secondary + + static func iob(_ s: GlucoseSnapshot) -> String { + guard let v = s.iob else { return "—" } + // Contract-friendly: one decimal, no unit suffix + return String(format: "%.1f", v) + } + + static func cob(_ s: GlucoseSnapshot) -> String { + guard let v = s.cob else { return "—" } + // Contract-friendly: whole grams + return String(Int(round(v))) + } + + static func projected(_ s: GlucoseSnapshot) -> String { + guard let v = s.projected else { return "—" } + switch s.unit { + case .mgdl: + return String(Int(round(v))) + case .mmol: + return String(format: "%.1f", v) + } + } + + // MARK: Update time + + private static let hhmmFormatter: DateFormatter = { + let df = DateFormatter() + df.locale = .current + df.timeZone = .current + df.dateFormat = "HH:mm" // 24h format + return df + }() + + private static let hhmmssFormatter: DateFormatter = { + let df = DateFormatter() + df.locale = .current + df.timeZone = .current + df.dateFormat = "HH:mm:ss" + return df + }() + + static func hhmmss(_ date: Date) -> String { + hhmmssFormatter.string(from: date) + } + + static func updated(_ s: GlucoseSnapshot) -> String { + hhmmFormatter.string(from: s.updatedAt) + } +} + +// MARK: - Threshold-driven colors (Option A, App Group-backed) + +private enum LAColors { + + static func backgroundTint(for snapshot: GlucoseSnapshot) -> Color { + let mgdl = toMgdl(snapshot) + + let t = LAAppGroupSettings.thresholdsMgdl() + let low = t.low + let high = t.high + + if mgdl < low { + let raw = 0.48 + (0.85 - 0.48) * ((low - mgdl) / (low - 54.0)) + let opacity = min(max(raw, 0.48), 0.85) + return Color(uiColor: UIColor.systemRed).opacity(opacity) + + } else if mgdl > high { + let raw = 0.44 + (0.85 - 0.44) * ((mgdl - high) / (324.0 - high)) + let opacity = min(max(raw, 0.44), 0.85) + return Color(uiColor: UIColor.systemOrange).opacity(opacity) + + } else { + // In range: fixed at your existing value + return Color(uiColor: UIColor.systemGreen).opacity(0.36) + } + } + + + static func keyline(for snapshot: GlucoseSnapshot) -> Color { + let mgdl = toMgdl(snapshot) + + let t = LAAppGroupSettings.thresholdsMgdl() + let low = t.low + let high = t.high + + if mgdl < low { + return Color(uiColor: UIColor.systemRed) + } else if mgdl > high { + return Color(uiColor: UIColor.systemOrange) + } else { + return Color(uiColor: UIColor.systemGreen) + } + } + + private static func toMgdl(_ snapshot: GlucoseSnapshot) -> Double { + switch snapshot.unit { + case .mgdl: + return snapshot.glucose + case .mmol: + // Convert mmol/L → mg/dL for threshold comparison + return GlucoseUnitConversion.convertGlucose(snapshot.glucose, from: .mmol, to: .mgdl) + } + } +} diff --git a/LoopFollowLAExtensionExtension.entitlements b/LoopFollowLAExtensionExtension.entitlements new file mode 100644 index 000000000..c2a1aa523 --- /dev/null +++ b/LoopFollowLAExtensionExtension.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.com.2HEY366Q6J.LoopFollow + + + diff --git a/docs/LiveActivity.md b/docs/LiveActivity.md new file mode 100644 index 000000000..651b80086 --- /dev/null +++ b/docs/LiveActivity.md @@ -0,0 +1,166 @@ +# LoopFollow Live Activity — Architecture & Design Decisions + +**Author:** Philippe Achkar (supported by Claude) +**Date:** 2026-03-07 + +--- + +## What Is the Live Activity? + +The Live Activity displays real-time glucose data on the iPhone lock screen and in the Dynamic Island. It shows: + +- Current glucose value (mg/dL or mmol/L) +- Trend arrow and delta +- IOB, COB, and projected glucose (when available) +- Threshold-driven background color (red (low) / green (in-range) / orange (high)) with user-set thresholds +- A "Not Looping" overlay when Loop has not reported in 15+ minutes + +It updates every 5 minutes, driven by LoopFollow's existing refresh engine. No separate data pipeline exists — the Live Activity is a rendering surface only. + +--- + +## Core Principles + +### 1. Single Source of Truth + +The Live Activity never fetches data directly from Nightscout or Dexcom. It reads exclusively from LoopFollow's internal storage layer (`Storage.shared`, `Observable.shared`). All glucose values, thresholds, IOB, COB, and loop status flow through the same path as the rest of the app. + +This means: +- No duplicated business logic +- No risk of the Live Activity showing different data than the app +- The architecture is reusable for Apple Watch and CarPlay in future phases + +### 2. Source-Agnostic Design + +LoopFollow supports both Nightscout and Dexcom. IOB, COB, or predicted glucose are modeled as optional (`Double?`) in `GlucoseSnapshot` and the UI renders a dash (—) when they are absent. The Live Activity never assumes these values exist. + +### 3. No Hardcoded Identifiers + +The App Group ID is derived dynamically at runtime: group.. No team-specific bundle IDs or App Group IDs are hardcoded anywhere. This ensures the project is safe to fork, clone, and submit as a pull request by any contributor. + +--- + +## Update Architecture — Why APNs Self-Push? + +This is the most important architectural decision in Phase 1. Understanding it will help you maintain and extend this feature correctly. + +### What We Tried First — Direct ``activity.update()`` + +The obvious approach to updating a Live Activity is to call ``activity.update()`` directly from the app. This works reliably when the app is in the foreground. + +The problem appears when the app is in the background. LoopFollow uses a background audio session (`.playback` category, silent WAV file) to stay alive in the background and continue fetching glucose data. We discovered that _liveactivitiesd_ (the iOS system daemon responsible for rendering Live Activities) refuses to process ``activity.update()`` calls from processes that hold an active background audio session. The update call either hangs indefinitely or is silently dropped. The Live Activity freezes on the lock screen while the app continues running normally. + +We attempted several workarounds; none of these approaches were reliable or production-safe: +- Call ``activity.update()`` while audio is playing | Updates hang or are dropped +- Pause the audio player before updating | Insufficient — iOS checks the process-level audio assertion, not just the player state +- Call `AVAudioSession.setActive(false)` before updating | Intermittently worked, but introduced a race condition and broke the audio session unpredictably +- Add a fixed 3-second wait after deactivation | Fragile, caused background task timeout warnings, and still failed intermittently + +### The Solution — APNs Self-Push + +Our solution is for LoopFollow to send an APNs (Apple Push Notification service) push notification to itself. + +Here is how it works: + +1. When a Live Activity is started, ActivityKit provides a **push token** — a unique identifier for that specific Live Activity instance. +2. LoopFollow captures this token via `activity.pushTokenUpdates`. +3. After each BG refresh, LoopFollow generates a signed JWT using its APNs authentication key and posts an HTTP/2 request directly to Apple's APNs servers. +4. Apple's APNs infrastructure delivers the push to `liveactivitiesd` on the device. +5. `liveactivitiesd` updates the Live Activity directly — the app process is **never involved in the rendering path**. + +Because `liveactivitiesd` receives the update via APNs rather than via an inter-process call from LoopFollow, it does not care that LoopFollow holds a background audio session. The update is processed reliably every time. + +### Why This Is Safe and Appropriate + +- This is an officially supported ActivityKit feature. Apple documents push-token-based Live Activity updates as the **recommended** update mechanism. +- The push is sent from the app itself, to itself. No external server or provider infrastructure is required. +- The APNs authentication key is injected at build time via xcconfig and Info.plist. It is never stored in the repository. +- The JWT is generated on-device using CryptoKit (`P256.Signing`) and cached for 55 minutes (APNs tokens are valid for 60 minutes). + +--- + +## File Map + +### Main App Target + +| File | Responsibility | +|---|---| +| `LiveActivityManager.swift` | Orchestration — start, update, end, bind, observe lifecycle | +| `GlucoseSnapshotBuilder.swift` | Pure data transformation — builds `GlucoseSnapshot` from storage | +| `StorageCurrentGlucoseStateProvider.swift` | Thin abstraction over `Storage.shared` and `Observable.shared` | +| `GlucoseSnapshotStore.swift` | App Group persistence — saves/loads latest snapshot | +| `LAThresholdSync.swift` | Reads threshold config from Storage for widget color | +| `PreferredGlucoseUnit.swift` | Reads user unit preference, converts mg/dL ↔ mmol/L | +| `APNSClient.swift` | Sends APNs self-push with Live Activity content state | +| `APNSJWTGenerator.swift` | Generates ES256-signed JWT for APNs authentication | + +### Shared (App + Extension) + +| File | Responsibility | +|---|---| +| `GlucoseLiveActivityAttributes.swift` | ActivityKit attributes and content state definition | +| `GlucoseSnapshot.swift` | Canonical cross-platform glucose data struct | +| `GlucoseUnitConversion.swift` | Unit conversion helpers | +| `LAAppGroupSettings.swift` | App Group UserDefaults access | +| `AppGroupID.swift` | Derives App Group ID dynamically from bundle identifier | + +### Extension Target + +| File | Responsibility | +|---|---| +| `LoopFollowLiveActivity.swift` | SwiftUI rendering — lock screen card and Dynamic Island | +| `LoopFollowLABundle.swift` | WidgetBundle entry point | + +--- + +## Update Flow + +``` +LoopFollow BG refresh completes + → Storage.shared updated (glucose, delta, trend, IOB, COB, projected) + → Observable.shared updated (isNotLooping) + → BGData calls LiveActivityManager.refreshFromCurrentState(reason: "bg") + → GlucoseSnapshotBuilder.build() reads from StorageCurrentGlucoseStateProvider + → GlucoseSnapshot constructed (unit-converted, threshold-classified) + → GlucoseSnapshotStore persists snapshot to App Group + → activity.update(content) called (direct update for foreground reliability) + → APNSClient.sendLiveActivityUpdate() sends self-push via APNs + → liveactivitiesd receives push + → Lock screen re-renders +``` + +--- + +## APNs Setup — Required for Contributors + +To build and run the Live Activity locally or via CI, you need an APNs authentication key. The key content is injected at build time via `LoopFollowConfigOverride.xcconfig` and is **never stored in the repository**. + +### What you need + +- An Apple Developer account +- An APNs Auth Key (`.p8` file) with the **Apple Push Notifications service (APNs)** capability enabled +- The 10-character Key ID associated with that key + +### Local Build Setup + +1. Generate or download your `.p8` key from [developer.apple.com](https://developer.apple.com) → Certificates, Identifiers & Profiles → Keys. +2. Open the key file in a text editor. Copy the base64 content between the header and footer lines — **exclude** `-----BEGIN PRIVATE KEY-----` and `-----END PRIVATE KEY-----`. Join all lines into a single unbroken string with no spaces or line breaks. +3. Create or edit `LoopFollowConfigOverride.xcconfig` in the project root (this file is gitignored): + +``` +APNS_KEY_ID = +APNS_KEY_CONTENT = +``` + +4. Build and run. The key is read at runtime from `Info.plist` which resolves `$(APNS_KEY_CONTENT)` from the xcconfig. + +### CI / GitHub Actions Setup + +Add two repository secrets under **Settings → Secrets and variables → Actions**: + +| Secret Name | Value | +|---|---| +| `APNS_KEY_ID` | Your 10-character key ID | +| `APNS_KEY` | Full contents of your `.p8` file including PEM headers | + +The build workflow strips the PEM headers automatically and injects the content into `LoopFollowConfigOverride.xcconfig` before building. diff --git a/fastlane/Fastfile b/fastlane/Fastfile index d81e60e5d..0871e7414 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -55,7 +55,8 @@ platform :ios do type: "appstore", git_basic_authorization: Base64.strict_encode64("#{GITHUB_REPOSITORY_OWNER}:#{GH_PAT}"), app_identifier: [ - "com.#{TEAMID}.LoopFollow" + "com.#{TEAMID}.LoopFollow", + "com.#{TEAMID}.LoopFollow.LoopFollowLAExtension" ] ) @@ -70,13 +71,26 @@ platform :ios do targets: ["LoopFollow"] ) + update_code_signing_settings( + path: "#{GITHUB_WORKSPACE}/LoopFollow.xcodeproj", + profile_name: mapping["com.#{TEAMID}.LoopFollow.LoopFollowLAExtension"], + code_sign_identity: "iPhone Distribution", + targets: ["LoopFollowLAExtensionExtension"] + ) + gym( export_method: "app-store", scheme: "LoopFollow", output_name: "LoopFollow.ipa", configuration: "Release", destination: 'generic/platform=iOS', - buildlog_path: 'buildlog' + buildlog_path: 'buildlog', + export_options: { + provisioningProfiles: { + "com.#{TEAMID}.LoopFollow" => mapping["com.#{TEAMID}.LoopFollow"], + "com.#{TEAMID}.LoopFollow.LoopFollowLAExtension" => mapping["com.#{TEAMID}.LoopFollow.LoopFollowLAExtension"] + } + } ) copy_artifacts( @@ -128,6 +142,8 @@ platform :ios do Spaceship::ConnectAPI::BundleIdCapability::Type::PUSH_NOTIFICATIONS ]) + configure_bundle_id("LoopFollow Live Activity Extension", "com.#{TEAMID}.LoopFollow.LoopFollowLAExtension", []) + end desc "Provision Certificates" @@ -148,6 +164,7 @@ platform :ios do git_basic_authorization: Base64.strict_encode64("#{GITHUB_REPOSITORY_OWNER}:#{GH_PAT}"), app_identifier: [ "com.#{TEAMID}.LoopFollow", + "com.#{TEAMID}.LoopFollow.LoopFollowLAExtension" ] ) end From 59f5d2b2a6d5bb84eef7843f9df35b8dafb7e4dd Mon Sep 17 00:00:00 2001 From: MtlPhil <76601115+MtlPhil@users.noreply.github.com> Date: Sun, 8 Mar 2026 09:43:48 -0400 Subject: [PATCH 02/82] fix: trigger Live Activity refresh on not-looping state change; handle APNs error codes; fix DST timezone --- LoopFollow/Controllers/Nightscout/DeviceStatus.swift | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/LoopFollow/Controllers/Nightscout/DeviceStatus.swift b/LoopFollow/Controllers/Nightscout/DeviceStatus.swift index a287548c6..fc1739c4c 100644 --- a/LoopFollow/Controllers/Nightscout/DeviceStatus.swift +++ b/LoopFollow/Controllers/Nightscout/DeviceStatus.swift @@ -56,6 +56,9 @@ extension MainViewController { LoopStatusLabel.text = "⚠️ Not Looping!" LoopStatusLabel.textColor = UIColor.systemYellow LoopStatusLabel.font = UIFont.boldSystemFont(ofSize: 18) + if #available(iOS 16.1, *) { + LiveActivityManager.shared.refreshFromCurrentState(reason: "notLooping") + } } else { IsNotLooping = false @@ -74,6 +77,9 @@ extension MainViewController { case .system: LoopStatusLabel.textColor = UIColor.label } + if #available(iOS 16.1, *) { + LiveActivityManager.shared.refreshFromCurrentState(reason: "loopingResumed") + } } } From c53e17f2438bc74a5782e8e1ae65d499db86dc1b Mon Sep 17 00:00:00 2001 From: MtlPhil <76601115+MtlPhil@users.noreply.github.com> Date: Sun, 8 Mar 2026 20:36:01 -0400 Subject: [PATCH 03/82] Fix PR issues + DST fix and better APNs error checking --- LoopFollow.xcodeproj/project.pbxproj | 24 ++++---- LoopFollow/Helpers/BackgroundTaskAudio.swift | 21 +++---- LoopFollow/Info.plist | 2 +- LoopFollow/LiveActivity/APNSClient.swift | 29 +++++++++- LoopFollow/LiveActivity/AppGroupID.swift | 3 +- .../GlucoseLiveActivityAttributes.swift | 29 +++++++--- LoopFollow/LiveActivity/GlucoseSnapshot.swift | 19 ++++++- .../LiveActivity/GlucoseSnapshotBuilder.swift | 33 ++++++----- .../LiveActivity/LiveActivityManager.swift | 55 ++++++++++++++----- LoopFollow/Loop Follow.entitlements | 2 +- .../ViewControllers/MainViewController.swift | 5 +- LoopFollowLAExtensionExtension.entitlements | 2 +- 12 files changed, 154 insertions(+), 70 deletions(-) diff --git a/LoopFollow.xcodeproj/project.pbxproj b/LoopFollow.xcodeproj/project.pbxproj index f85491fc1..bacc966b5 100644 --- a/LoopFollow.xcodeproj/project.pbxproj +++ b/LoopFollow.xcodeproj/project.pbxproj @@ -2410,7 +2410,7 @@ LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MACOSX_DEPLOYMENT_TARGET = 26.2; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.2HEY366Q6J.LoopFollow.LoopFollowLAExtension; + PRODUCT_BUNDLE_IDENTIFIER = "com.$(unique_id).LoopFollow$(app_suffix).LoopFollowLAExtension"; PRODUCT_MODULE_NAME = LoopFollowLAExtension; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = auto; @@ -2423,7 +2423,7 @@ SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = 1; + TARGETED_DEVICE_FAMILY = "1,2"; XROS_DEPLOYMENT_TARGET = 26.2; }; name = Debug; @@ -2462,7 +2462,7 @@ LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MACOSX_DEPLOYMENT_TARGET = 26.2; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.2HEY366Q6J.LoopFollow.LoopFollowLAExtension; + PRODUCT_BUNDLE_IDENTIFIER = "com.$(unique_id).LoopFollow$(app_suffix).LoopFollowLAExtension"; PRODUCT_MODULE_NAME = LoopFollowLAExtension; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = auto; @@ -2474,7 +2474,7 @@ SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = 1; + TARGETED_DEVICE_FAMILY = "1,2"; XROS_DEPLOYMENT_TARGET = 26.2; }; name = Release; @@ -2667,14 +2667,12 @@ "$(inherited)", "@executable_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = com.2HEY366Q6J.LoopFollow; + PRODUCT_BUNDLE_IDENTIFIER = "com.$(unique_id).LoopFollow$(app_suffix)"; PRODUCT_NAME = "Loop Follow"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; - SUPPORTS_MACCATALYST = NO; - SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; - SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_MACCATALYST = YES; SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = 1; + TARGETED_DEVICE_FAMILY = "1,2"; }; name = Debug; }; @@ -2693,14 +2691,12 @@ "$(inherited)", "@executable_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = com.2HEY366Q6J.LoopFollow; + PRODUCT_BUNDLE_IDENTIFIER = "com.$(unique_id).LoopFollow$(app_suffix)"; PRODUCT_NAME = "Loop Follow"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; - SUPPORTS_MACCATALYST = NO; - SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; - SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_MACCATALYST = YES; SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = 1; + TARGETED_DEVICE_FAMILY = "1,2"; }; name = Release; }; diff --git a/LoopFollow/Helpers/BackgroundTaskAudio.swift b/LoopFollow/Helpers/BackgroundTaskAudio.swift index b7bbd26a8..91504ab5d 100755 --- a/LoopFollow/Helpers/BackgroundTaskAudio.swift +++ b/LoopFollow/Helpers/BackgroundTaskAudio.swift @@ -1,18 +1,16 @@ -// BackgroundTaskAudio.swift // LoopFollow -// Philippe Achkar -// 2026-03-07 +// BackgroundTaskAudio.swift import AVFoundation class BackgroundTask { - // MARK: - Vars - static let shared = BackgroundTask() + var player = AVAudioPlayer() var timer = Timer() // MARK: - Methods + func startBackgroundTask() { NotificationCenter.default.addObserver(self, selector: #selector(interruptedAudio), name: AVAudioSession.interruptionNotification, object: AVAudioSession.sharedInstance()) playAudio() @@ -21,15 +19,10 @@ class BackgroundTask { func stopBackgroundTask() { NotificationCenter.default.removeObserver(self, name: AVAudioSession.interruptionNotification, object: nil) player.stop() - do { - try AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation) - LogManager.shared.log(category: .general, message: "Silent audio stopped", isDebug: true) - } catch { - LogManager.shared.log(category: .general, message: "Silent audio stop failed: \(error)", isDebug: true) - } + LogManager.shared.log(category: .general, message: "Silent audio stopped", isDebug: true) } - @objc private func interruptedAudio(_ notification: Notification) { + @objc fileprivate func interruptedAudio(_ notification: Notification) { LogManager.shared.log(category: .general, message: "Silent audio interrupted") if notification.name == AVAudioSession.interruptionNotification, notification.userInfo != nil { var info = notification.userInfo! @@ -39,13 +32,15 @@ class BackgroundTask { } } - private func playAudio() { + fileprivate func playAudio() { do { let bundle = Bundle.main.path(forResource: "blank", ofType: "wav") let alertSound = URL(fileURLWithPath: bundle!) + // try AVAudioSession.sharedInstance().setCategory(AVAudioSession.Category.playback) try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default, options: .mixWithOthers) try AVAudioSession.sharedInstance().setActive(true) try player = AVAudioPlayer(contentsOf: alertSound) + // Play audio forever by setting num of loops to -1 player.numberOfLoops = -1 player.volume = 0.01 player.prepareToPlay() diff --git a/LoopFollow/Info.plist b/LoopFollow/Info.plist index 94351928e..6f329c1a6 100644 --- a/LoopFollow/Info.plist +++ b/LoopFollow/Info.plist @@ -25,7 +25,7 @@ CFBundleIconFile Activities CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) + com.$(unique_id).LoopFollow$(app_suffix) CFBundleInfoDictionaryVersion 6.0 CFBundleName diff --git a/LoopFollow/LiveActivity/APNSClient.swift b/LoopFollow/LiveActivity/APNSClient.swift index 8a46babef..7e98103dd 100644 --- a/LoopFollow/LiveActivity/APNSClient.swift +++ b/LoopFollow/LiveActivity/APNSClient.swift @@ -61,9 +61,34 @@ class APNSClient { let (data, response) = try await URLSession.shared.data(for: request) if let httpResponse = response as? HTTPURLResponse { - if httpResponse.statusCode == 200 { + switch httpResponse.statusCode { + case 200: LogManager.shared.log(category: .general, message: "APNs push sent successfully", isDebug: true) - } else { + + case 400: + let responseBody = String(data: data, encoding: .utf8) ?? "empty" + LogManager.shared.log(category: .general, message: "APNs bad request (400) — malformed payload: \(responseBody)") + + case 403: + // JWT rejected — force regenerate on next push + cachedToken = nil + tokenGeneratedAt = nil + LogManager.shared.log(category: .general, message: "APNs JWT rejected (403) — token cache cleared, will regenerate") + + case 404, 410: + // Activity token not found or expired — end and restart on next refresh + let reason = httpResponse.statusCode == 410 ? "expired (410)" : "not found (404)" + LogManager.shared.log(category: .general, message: "APNs token \(reason) — restarting Live Activity") + LiveActivityManager.shared.handleExpiredToken() + + case 429: + LogManager.shared.log(category: .general, message: "APNs rate limited (429) — will retry on next refresh") + + case 500...599: + let responseBody = String(data: data, encoding: .utf8) ?? "empty" + LogManager.shared.log(category: .general, message: "APNs server error (\(httpResponse.statusCode)) — will retry on next refresh: \(responseBody)") + + default: let responseBody = String(data: data, encoding: .utf8) ?? "empty" LogManager.shared.log(category: .general, message: "APNs push failed status=\(httpResponse.statusCode) body=\(responseBody)") } diff --git a/LoopFollow/LiveActivity/AppGroupID.swift b/LoopFollow/LiveActivity/AppGroupID.swift index 7b02acb94..c0887c2be 100644 --- a/LoopFollow/LiveActivity/AppGroupID.swift +++ b/LoopFollow/LiveActivity/AppGroupID.swift @@ -43,6 +43,7 @@ enum AppGroupID { let knownSuffixes = [ ".LiveActivity", ".LiveActivityExtension", + ".LoopFollowLAExtension", ".Widget", ".WidgetExtension", ".Widgets", @@ -63,4 +64,4 @@ enum AppGroupID { return bundleID } -} \ No newline at end of file +} diff --git a/LoopFollow/LiveActivity/GlucoseLiveActivityAttributes.swift b/LoopFollow/LiveActivity/GlucoseLiveActivityAttributes.swift index 1052ee0ec..981be7a05 100644 --- a/LoopFollow/LiveActivity/GlucoseLiveActivityAttributes.swift +++ b/LoopFollow/LiveActivity/GlucoseLiveActivityAttributes.swift @@ -11,19 +11,32 @@ import Foundation struct GlucoseLiveActivityAttributes: ActivityAttributes { public struct ContentState: Codable, Hashable { - /// The latest snapshot, already converted into the user’s preferred unit. let snapshot: GlucoseSnapshot - - /// Monotonic sequence for “did we update?” debugging and hung detection. let seq: Int - - /// Reason the app refreshed (e.g., "bg", "deviceStatus"). let reason: String - - /// When the activity state was produced. let producedAt: Date + + init(snapshot: GlucoseSnapshot, seq: Int, reason: String, producedAt: Date) { + self.snapshot = snapshot + self.seq = seq + self.reason = reason + self.producedAt = producedAt + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + snapshot = try container.decode(GlucoseSnapshot.self, forKey: .snapshot) + seq = try container.decode(Int.self, forKey: .seq) + reason = try container.decode(String.self, forKey: .reason) + let producedAtInterval = try container.decode(Double.self, forKey: .producedAt) + producedAt = Date(timeIntervalSince1970: producedAtInterval) + } + + private enum CodingKeys: String, CodingKey { + case snapshot, seq, reason, producedAt + } } /// Reserved for future metadata. Keep minimal for stability. let title: String -} \ No newline at end of file +} diff --git a/LoopFollow/LiveActivity/GlucoseSnapshot.swift b/LoopFollow/LiveActivity/GlucoseSnapshot.swift index 563be34d5..db3f50ef3 100644 --- a/LoopFollow/LiveActivity/GlucoseSnapshot.swift +++ b/LoopFollow/LiveActivity/GlucoseSnapshot.swift @@ -75,13 +75,30 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable { self.isNotLooping = isNotLooping } + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(glucose, forKey: .glucose) + try container.encode(delta, forKey: .delta) + try container.encode(trend, forKey: .trend) + try container.encode(updatedAt.timeIntervalSince1970, forKey: .updatedAt) + try container.encodeIfPresent(iob, forKey: .iob) + try container.encodeIfPresent(cob, forKey: .cob) + try container.encodeIfPresent(projected, forKey: .projected) + try container.encode(unit, forKey: .unit) + try container.encode(isNotLooping, forKey: .isNotLooping) + } + + private enum CodingKeys: String, CodingKey { + case glucose, delta, trend, updatedAt, iob, cob, projected, unit, isNotLooping + } + // MARK: - Codable init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) glucose = try container.decode(Double.self, forKey: .glucose) delta = try container.decode(Double.self, forKey: .delta) trend = try container.decode(Trend.self, forKey: .trend) - updatedAt = try container.decode(Date.self, forKey: .updatedAt) + updatedAt = Date(timeIntervalSince1970: try container.decode(Double.self, forKey: .updatedAt)) iob = try container.decodeIfPresent(Double.self, forKey: .iob) cob = try container.decodeIfPresent(Double.self, forKey: .cob) projected = try container.decodeIfPresent(Double.self, forKey: .projected) diff --git a/LoopFollow/LiveActivity/GlucoseSnapshotBuilder.swift b/LoopFollow/LiveActivity/GlucoseSnapshotBuilder.swift index a61774ead..31628fedf 100644 --- a/LoopFollow/LiveActivity/GlucoseSnapshotBuilder.swift +++ b/LoopFollow/LiveActivity/GlucoseSnapshotBuilder.swift @@ -66,19 +66,26 @@ enum GlucoseSnapshotBuilder { let trend = mapTrend(provider.trendCode) // Not Looping — read from Observable, set by evaluateNotLooping() in DeviceStatus.swift - let isNotLooping = Observable.shared.isNotLooping.value - - return GlucoseSnapshot( - glucose: glucose, - delta: delta, - trend: trend, - updatedAt: updatedAt, - iob: provider.iob, - cob: provider.cob, - projected: projected, - unit: preferredUnit, - isNotLooping: isNotLooping - ) + let isNotLooping = Observable.shared.isNotLooping.value + + + LogManager.shared.log( + category: .general, + message: "LA snapshot built: updatedAt=\(updatedAt) interval=\(updatedAt.timeIntervalSince1970)", + isDebug: true + ) + + return GlucoseSnapshot( + glucose: glucose, + delta: delta, + trend: trend, + updatedAt: updatedAt, + iob: provider.iob, + cob: provider.cob, + projected: projected, + unit: preferredUnit, + isNotLooping: isNotLooping + ) } private static func mapTrend(_ code: String?) -> GlucoseSnapshot.Trend { diff --git a/LoopFollow/LiveActivity/LiveActivityManager.swift b/LoopFollow/LiveActivity/LiveActivityManager.swift index e41ce5b39..0c2e20537 100644 --- a/LoopFollow/LiveActivity/LiveActivityManager.swift +++ b/LoopFollow/LiveActivity/LiveActivityManager.swift @@ -22,6 +22,7 @@ final class LiveActivityManager { private var lastUpdateTime: Date? private var pushToken: String? private var tokenObservationTask: Task? + private var refreshWorkItem: DispatchWorkItem? // MARK: - Public API @@ -103,50 +104,59 @@ final class LiveActivityManager { } } + func startFromCurrentState() { + let provider = StorageCurrentGlucoseStateProvider() + if let snapshot = GlucoseSnapshotBuilder.build(from: provider) { + LAAppGroupSettings.setThresholds( + lowMgdl: Storage.shared.lowLine.value, + highMgdl: Storage.shared.highLine.value + ) + GlucoseSnapshotStore.shared.save(snapshot) + } + startIfNeeded() + } + func refreshFromCurrentState(reason: String) { + refreshWorkItem?.cancel() + let workItem = DispatchWorkItem { [weak self] in + self?.performRefresh(reason: reason) + } + refreshWorkItem = workItem + DispatchQueue.main.asyncAfter(deadline: .now() + 5.0, execute: workItem) + } + + private func performRefresh(reason: String) { let provider = StorageCurrentGlucoseStateProvider() - guard let snapshot = GlucoseSnapshotBuilder.build(from: provider) else { return } - LogManager.shared.log(category: .general, message: "[LA] refresh g=\(snapshot.glucose) reason=\(reason)", isDebug: true) - let fingerprint = "g=\(snapshot.glucose) d=\(snapshot.delta) t=\(snapshot.trend.rawValue) " + "at=\(snapshot.updatedAt.timeIntervalSince1970) iob=\(snapshot.iob?.description ?? "nil") " + "cob=\(snapshot.cob?.description ?? "nil") proj=\(snapshot.projected?.description ?? "nil") u=\(snapshot.unit.rawValue)" - LogManager.shared.log(category: .general, message: "[LA] snapshot \(fingerprint) reason=\(reason)", isDebug: true) - let now = Date() let timeSinceLastUpdate = now.timeIntervalSince(lastUpdateTime ?? .distantPast) let forceRefreshNeeded = timeSinceLastUpdate >= 5 * 60 - if let previous = GlucoseSnapshotStore.shared.load(), previous == snapshot, !forceRefreshNeeded { return } - LAAppGroupSettings.setThresholds( lowMgdl: Storage.shared.lowLine.value, highMgdl: Storage.shared.highLine.value ) - GlucoseSnapshotStore.shared.save(snapshot) - guard ActivityAuthorizationInfo().areActivitiesEnabled else { return } - if current == nil, let existing = Activity.activities.first { bind(to: existing, logReason: "bind-existing") } - if let _ = current { update(snapshot: snapshot, reason: reason) return } - if isAppVisibleForLiveActivityStart() { startIfNeeded() if current != nil { @@ -156,7 +166,7 @@ final class LiveActivityManager { LogManager.shared.log(category: .general, message: "LA start suppressed (not visible) reason=\(reason)", isDebug: true) } } - + private func isAppVisibleForLiveActivityStart() -> Bool { let scenes = UIApplication.shared.connectedScenes return scenes.contains { $0.activationState == .foregroundActive } @@ -198,7 +208,19 @@ final class LiveActivityManager { if Task.isCancelled { return } - await activity.update(content) + // Dual-path update strategy: + // - Foreground: direct ActivityKit update works reliably. + // - Background: direct update silently fails due to the audio session + // limitation. APNs self-push is the only reliable delivery path. + // Both paths are attempted when applicable; APNs is the authoritative + // background mechanism. + let isForeground = await MainActor.run { + UIApplication.shared.applicationState == .active + } + + if isForeground { + await activity.update(content) + } if Task.isCancelled { return } @@ -237,6 +259,11 @@ final class LiveActivityManager { } } + func handleExpiredToken() { + end() + // Activity will restart on next BG refresh via refreshFromCurrentState() + } + private func attachStateObserver(to activity: Activity) { stateObserverTask?.cancel() stateObserverTask = Task { diff --git a/LoopFollow/Loop Follow.entitlements b/LoopFollow/Loop Follow.entitlements index 4ec33220c..69ade1013 100644 --- a/LoopFollow/Loop Follow.entitlements +++ b/LoopFollow/Loop Follow.entitlements @@ -10,7 +10,7 @@ com.apple.security.application-groups - group.com.2HEY366Q6J.LoopFollow + group.com.$(unique_id).LoopFollow$(app_suffix) com.apple.security.device.bluetooth diff --git a/LoopFollow/ViewControllers/MainViewController.swift b/LoopFollow/ViewControllers/MainViewController.swift index 453f67097..b5e37222a 100644 --- a/LoopFollow/ViewControllers/MainViewController.swift +++ b/LoopFollow/ViewControllers/MainViewController.swift @@ -59,7 +59,7 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele var currentCage: cageData? var currentIage: iageData? - var backgroundTask = BackgroundTask.shared + var backgroundTask = BackgroundTask() var graphNowTimer = Timer() @@ -827,6 +827,9 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele @objc override func viewDidAppear(_: Bool) { showHideNSDetails() + if #available(iOS 16.1, *) { + LiveActivityManager.shared.startFromCurrentState() + } } func stringFromTimeInterval(interval: TimeInterval) -> String { diff --git a/LoopFollowLAExtensionExtension.entitlements b/LoopFollowLAExtensionExtension.entitlements index c2a1aa523..5b963cc90 100644 --- a/LoopFollowLAExtensionExtension.entitlements +++ b/LoopFollowLAExtensionExtension.entitlements @@ -4,7 +4,7 @@ com.apple.security.application-groups - group.com.2HEY366Q6J.LoopFollow + group.com.$(unique_id).LoopFollow$(app_suffix) From b833ad972dd214d84fad1a02a0e0e369630bd615 Mon Sep 17 00:00:00 2001 From: MtlPhil <76601115+MtlPhil@users.noreply.github.com> Date: Mon, 9 Mar 2026 23:30:35 -0400 Subject: [PATCH 04/82] fix: address remaining hardcoded bundleID --- .gitignore | 2 +- LoopFollow.xcodeproj/project.pbxproj | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.gitignore b/.gitignore index f176e2f72..178842387 100644 --- a/.gitignore +++ b/.gitignore @@ -80,4 +80,4 @@ fastlane/test_output fastlane/FastlaneRunner LoopFollowConfigOverride.xcconfig -.history \ No newline at end of file +.history*.xcuserstate diff --git a/LoopFollow.xcodeproj/project.pbxproj b/LoopFollow.xcodeproj/project.pbxproj index 1e68023a2..5fe9e6dd3 100644 --- a/LoopFollow.xcodeproj/project.pbxproj +++ b/LoopFollow.xcodeproj/project.pbxproj @@ -2401,7 +2401,7 @@ CODE_SIGN_ENTITLEMENTS = LoopFollowLAExtensionExtension.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = 2HEY366Q6J; + DEVELOPMENT_TEAM = "$(LF_DEVELOPMENT_TEAM)"; ENABLE_APP_SANDBOX = NO; ENABLE_HARDENED_RUNTIME = NO; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -2453,7 +2453,7 @@ CODE_SIGN_ENTITLEMENTS = LoopFollowLAExtensionExtension.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = 2HEY366Q6J; + DEVELOPMENT_TEAM = "$(LF_DEVELOPMENT_TEAM)"; ENABLE_APP_SANDBOX = NO; ENABLE_HARDENED_RUNTIME = NO; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -2675,7 +2675,7 @@ CODE_SIGN_ENTITLEMENTS = "LoopFollow/Loop Follow.entitlements"; CODE_SIGN_STYLE = Automatic; DEFINES_MODULE = YES; - DEVELOPMENT_TEAM = 2HEY366Q6J; + DEVELOPMENT_TEAM = "$(LF_DEVELOPMENT_TEAM)"; INFOPLIST_FILE = LoopFollow/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 16.6; LD_RUNPATH_SEARCH_PATHS = ( @@ -2700,7 +2700,7 @@ CODE_SIGN_ENTITLEMENTS = "LoopFollow/Loop Follow.entitlements"; CODE_SIGN_STYLE = Automatic; DEFINES_MODULE = YES; - DEVELOPMENT_TEAM = 2HEY366Q6J; + DEVELOPMENT_TEAM = "$(LF_DEVELOPMENT_TEAM)"; INFOPLIST_FILE = LoopFollow/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 16.6; LD_RUNPATH_SEARCH_PATHS = ( From 524b3bb86959b662c436cf37c60b5e0a51b3bdef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Wed, 11 Mar 2026 19:41:30 +0100 Subject: [PATCH 05/82] Replace SwiftJWT with CryptoKit and separate APNs credentials - Consolidate JWT generation into JWTManager using CryptoKit with multi-slot in-memory cache, removing SwiftJWT and swift-crypto SPM dependencies - Separate APNs keys for LoopFollow (lf) vs remote commands, with automatic team-ID routing and a migration step for legacy keys - Add dedicated APN settings page for LoopFollow's own APNs keys - Remove hardcoded APNs credentials from CI workflow and Info.plist in favor of user-configured keys - Apply swiftformat to Live Activity files --- .github/workflows/build_LoopFollow.yml | 14 +-- LoopFollow.xcodeproj/project.pbxproj | 38 +----- .../xcshareddata/swiftpm/Package.resolved | 16 --- .../Controllers/Nightscout/BGData.swift | 6 +- .../Nightscout/DeviceStatusLoop.swift | 3 +- LoopFollow/Helpers/JWTManager.swift | 108 ++++++++++++---- LoopFollow/Info.plist | 6 - LoopFollow/LiveActivity/APNSClient.swift | 79 ++++++------ .../LiveActivity/APNSJWTGenerator.swift | 116 ------------------ LoopFollow/LiveActivity/AppGroupID.swift | 14 +-- .../GlucoseLiveActivityAttributes.swift | 11 +- LoopFollow/LiveActivity/GlucoseSnapshot.swift | 21 ++-- .../LiveActivity/GlucoseSnapshotBuilder.swift | 16 +-- .../LiveActivity/GlucoseSnapshotStore.swift | 9 +- .../LiveActivity/GlucoseUnitConversion.swift | 11 +- .../LiveActivity/LAAppGroupSettings.swift | 11 +- LoopFollow/LiveActivity/LAThresholdSync.swift | 11 +- .../LiveActivity/LiveActivityManager.swift | 20 ++- .../LiveActivity/PreferredGlucoseUnit.swift | 11 +- .../StorageCurrentGlucoseStateProvider.swift | 9 +- .../Remote/LoopAPNS/LoopAPNSService.swift | 86 ++++++------- .../Settings/RemoteCommandSettings.swift | 24 ++-- .../Remote/Settings/RemoteSettingsView.swift | 91 ++++++-------- .../Settings/RemoteSettingsViewModel.swift | 56 +++------ .../Remote/TRC/PushNotificationManager.swift | 39 +++--- LoopFollow/Settings/APNSettingsView.swift | 44 +++++++ .../ImportExport/ExportableSettings.swift | 24 ++-- LoopFollow/Settings/SettingsMenuView.swift | 8 ++ LoopFollow/Storage/Observable.swift | 2 +- LoopFollow/Storage/Storage+Migrate.swift | 39 ++++++ LoopFollow/Storage/Storage.swift | 15 +-- .../ViewControllers/MainViewController.swift | 38 +++--- .../LoopFollowLABundle.swift | 14 +-- .../LoopFollowLiveActivity.swift | 30 ++--- 34 files changed, 447 insertions(+), 593 deletions(-) delete mode 100644 LoopFollow.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved delete mode 100644 LoopFollow/LiveActivity/APNSJWTGenerator.swift create mode 100644 LoopFollow/Settings/APNSettingsView.swift diff --git a/.github/workflows/build_LoopFollow.yml b/.github/workflows/build_LoopFollow.yml index 361dcacdc..2e8c0be54 100644 --- a/.github/workflows/build_LoopFollow.yml +++ b/.github/workflows/build_LoopFollow.yml @@ -165,7 +165,7 @@ jobs: build: name: Build needs: [check_certs, check_status] - runs-on: macos-15 + runs-on: macos-26 permissions: contents: write if: @@ -175,7 +175,7 @@ jobs: (vars.SCHEDULED_SYNC != 'false' && needs.check_status.outputs.NEW_COMMITS == 'true' ) steps: - name: Select Xcode version - run: "sudo xcode-select --switch /Applications/Xcode_16.4.app/Contents/Developer" + run: "sudo xcode-select --switch /Applications/Xcode_26.2.app/Contents/Developer" - name: Checkout Repo for building uses: actions/checkout@v4 @@ -203,16 +203,6 @@ jobs: - name: Sync clock run: sudo sntp -sS time.windows.com - - name: Inject APNs Key Content - env: - APNS_KEY: ${{ secrets.APNS_KEY }} - APNS_KEY_ID: ${{ secrets.APNS_KEY_ID }} - run: | - # Strip PEM headers, footers, and newlines — xcconfig requires single line - APNS_KEY_CONTENT=$(echo "$APNS_KEY" | grep -v "BEGIN\|END" | tr -d '\n\r ') - echo "APNS_KEY_ID = ${APNS_KEY_ID}" >> "${GITHUB_WORKSPACE}/LoopFollowConfigOverride.xcconfig" - echo "APNS_KEY_CONTENT = ${APNS_KEY_CONTENT}" >> "${GITHUB_WORKSPACE}/LoopFollowConfigOverride.xcconfig" - # Build signed LoopFollow IPA file - name: Fastlane Build & Archive run: bundle exec fastlane build_LoopFollow diff --git a/LoopFollow.xcodeproj/project.pbxproj b/LoopFollow.xcodeproj/project.pbxproj index 5fe9e6dd3..83592c9ad 100644 --- a/LoopFollow.xcodeproj/project.pbxproj +++ b/LoopFollow.xcodeproj/project.pbxproj @@ -7,7 +7,6 @@ objects = { /* Begin PBXBuildFile section */ - 374A77972F5BD78700E96858 /* APNSJWTGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A77962F5BD77500E96858 /* APNSJWTGenerator.swift */; }; 374A77992F5BD8B200E96858 /* APNSClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A77982F5BD8AB00E96858 /* APNSClient.swift */; }; 374A77A42F5BE17000E96858 /* GlucoseUnitConversion.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A77A22F5BE17000E96858 /* GlucoseUnitConversion.swift */; }; 374A77A52F5BE17000E96858 /* AppGroupID.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A779F2F5BE17000E96858 /* AppGroupID.swift */; }; @@ -42,6 +41,7 @@ 6584B1012E4A263900135D4D /* TOTPService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6584B1002E4A263900135D4D /* TOTPService.swift */; }; 6589CC622E9E7D1600BB18FE /* ImportExportSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6589CC532E9E7D1600BB18FE /* ImportExportSettingsView.swift */; }; 6589CC632E9E7D1600BB18FE /* GeneralSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6589CC5D2E9E7D1600BB18FE /* GeneralSettingsView.swift */; }; + 5544D8C363FB5D3B9BF8CE4A /* APNSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7C2676561D686C6459CAA2D /* APNSettingsView.swift */; }; 6589CC642E9E7D1600BB18FE /* ContactSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6589CC592E9E7D1600BB18FE /* ContactSettingsView.swift */; }; 6589CC652E9E7D1600BB18FE /* DexcomSettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6589CC5C2E9E7D1600BB18FE /* DexcomSettingsViewModel.swift */; }; 6589CC662E9E7D1600BB18FE /* AdvancedSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6589CC562E9E7D1600BB18FE /* AdvancedSettingsView.swift */; }; @@ -109,7 +109,6 @@ DD4878152C7B75230048F05C /* MealView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD4878142C7B75230048F05C /* MealView.swift */; }; DD4878172C7B75350048F05C /* BolusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD4878162C7B75350048F05C /* BolusView.swift */; }; DD4878192C7C56D60048F05C /* TrioNightscoutRemoteController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD4878182C7C56D60048F05C /* TrioNightscoutRemoteController.swift */; }; - DD48781C2C7DAF140048F05C /* SwiftJWT in Frameworks */ = {isa = PBXBuildFile; productRef = DD48781B2C7DAF140048F05C /* SwiftJWT */; }; DD48781E2C7DAF2F0048F05C /* PushNotificationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD48781D2C7DAF2F0048F05C /* PushNotificationManager.swift */; }; DD4878202C7DAF890048F05C /* PushMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD48781F2C7DAF890048F05C /* PushMessage.swift */; }; DD493AD52ACF2109009A6922 /* ResumePump.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD493AD42ACF2109009A6922 /* ResumePump.swift */; }; @@ -455,7 +454,6 @@ /* Begin PBXFileReference section */ 059B0FA59AABFE72FE13DDDA /* Pods-LoopFollow.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-LoopFollow.release.xcconfig"; path = "Target Support Files/Pods-LoopFollow/Pods-LoopFollow.release.xcconfig"; sourceTree = ""; }; - 374A77962F5BD77500E96858 /* APNSJWTGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APNSJWTGenerator.swift; sourceTree = ""; }; 374A77982F5BD8AB00E96858 /* APNSClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APNSClient.swift; sourceTree = ""; }; 374A779F2F5BE17000E96858 /* AppGroupID.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppGroupID.swift; sourceTree = ""; }; 374A77A02F5BE17000E96858 /* GlucoseLiveActivityAttributes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseLiveActivityAttributes.swift; sourceTree = ""; }; @@ -494,6 +492,7 @@ 6589CC5B2E9E7D1600BB18FE /* DexcomSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DexcomSettingsView.swift; sourceTree = ""; }; 6589CC5C2E9E7D1600BB18FE /* DexcomSettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DexcomSettingsViewModel.swift; sourceTree = ""; }; 6589CC5D2E9E7D1600BB18FE /* GeneralSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeneralSettingsView.swift; sourceTree = ""; }; + E7C2676561D686C6459CAA2D /* APNSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APNSettingsView.swift; sourceTree = ""; }; 6589CC5E2E9E7D1600BB18FE /* GraphSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GraphSettingsView.swift; sourceTree = ""; }; 6589CC5F2E9E7D1600BB18FE /* SettingsMenuView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsMenuView.swift; sourceTree = ""; }; 6589CC602E9E7D1600BB18FE /* TabCustomizationModal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabCustomizationModal.swift; sourceTree = ""; }; @@ -903,7 +902,6 @@ FCFEEC9E2486E68E00402A7F /* WebKit.framework in Frameworks */, DD485F162E46631000CE8CBF /* CryptoSwift in Frameworks */, 3F1335F351590E573D8E6962 /* Pods_LoopFollow.framework in Frameworks */, - DD48781C2C7DAF140048F05C /* SwiftJWT in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -925,7 +923,6 @@ 374A77A22F5BE17000E96858 /* GlucoseUnitConversion.swift */, 374A77A32F5BE17000E96858 /* LAAppGroupSettings.swift */, 374A77982F5BD8AB00E96858 /* APNSClient.swift */, - 374A77962F5BD77500E96858 /* APNSJWTGenerator.swift */, ); path = LiveActivity; sourceTree = ""; @@ -947,6 +944,7 @@ children = ( 6589CC552E9E7D1600BB18FE /* ImportExport */, 6589CC562E9E7D1600BB18FE /* AdvancedSettingsView.swift */, + E7C2676561D686C6459CAA2D /* APNSettingsView.swift */, 6589CC572E9E7D1600BB18FE /* AdvancedSettingsViewModel.swift */, 6589CC582E9E7D1600BB18FE /* CalendarSettingsView.swift */, 6589CC592E9E7D1600BB18FE /* ContactSettingsView.swift */, @@ -1767,7 +1765,6 @@ ); name = LoopFollow; packageProductDependencies = ( - DD48781B2C7DAF140048F05C /* SwiftJWT */, DD485F152E46631000CE8CBF /* CryptoSwift */, ); productName = LoopFollow; @@ -1806,8 +1803,6 @@ ); mainGroup = FC97880B2485969B00A7906C; packageReferences = ( - DD48781A2C7DAF140048F05C /* XCRemoteSwiftPackageReference "Swift-JWT" */, - 654132E82E19F0B800BDBE08 /* XCRemoteSwiftPackageReference "swift-crypto" */, DD485F0B2E4547C800CE8CBF /* XCRemoteSwiftPackageReference "CryptoSwift" */, ); productRefGroup = FC9788152485969B00A7906C /* Products */; @@ -2082,7 +2077,6 @@ DD7F4B9F2DD1F92700D449E9 /* AlarmActiveSection.swift in Sources */, DD4AFB672DB68C5500BB593F /* UUID+Identifiable.swift in Sources */, DD9ED0CA2D355257000D2A63 /* LogView.swift in Sources */, - 374A77972F5BD78700E96858 /* APNSJWTGenerator.swift in Sources */, DD9ACA082D32F68B00415D8A /* BGTask.swift in Sources */, DD9ACA102D34129200415D8A /* Task.swift in Sources */, DD4AFB492DB576C200BB593F /* AlarmSettingsView.swift in Sources */, @@ -2216,6 +2210,7 @@ DD493AE12ACF22FE009A6922 /* Profile.swift in Sources */, 6589CC622E9E7D1600BB18FE /* ImportExportSettingsView.swift in Sources */, 6589CC632E9E7D1600BB18FE /* GeneralSettingsView.swift in Sources */, + 5544D8C363FB5D3B9BF8CE4A /* APNSettingsView.swift in Sources */, 6589CC642E9E7D1600BB18FE /* ContactSettingsView.swift in Sources */, 6589CC652E9E7D1600BB18FE /* DexcomSettingsViewModel.swift in Sources */, 6589CC662E9E7D1600BB18FE /* AdvancedSettingsView.swift in Sources */, @@ -2758,14 +2753,6 @@ /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ - 654132E82E19F0B800BDBE08 /* XCRemoteSwiftPackageReference "swift-crypto" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/apple/swift-crypto.git"; - requirement = { - kind = upToNextMajorVersion; - minimumVersion = 3.12.3; - }; - }; DD485F0B2E4547C800CE8CBF /* XCRemoteSwiftPackageReference "CryptoSwift" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/krzyzanowskim/CryptoSwift.git"; @@ -2774,27 +2761,14 @@ minimumVersion = 1.9.0; }; }; - DD48781A2C7DAF140048F05C /* XCRemoteSwiftPackageReference "Swift-JWT" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/Kitura/Swift-JWT.git"; - requirement = { - kind = exactVersion; - version = 4.0.1; - }; - }; -/* End XCRemoteSwiftPackageReference section */ + /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ DD485F152E46631000CE8CBF /* CryptoSwift */ = { isa = XCSwiftPackageProductDependency; productName = CryptoSwift; }; - DD48781B2C7DAF140048F05C /* SwiftJWT */ = { - isa = XCSwiftPackageProductDependency; - package = DD48781A2C7DAF140048F05C /* XCRemoteSwiftPackageReference "Swift-JWT" */; - productName = SwiftJWT; - }; -/* End XCSwiftPackageProductDependency section */ + /* End XCSwiftPackageProductDependency section */ /* Begin XCVersionGroup section */ FC3AE7B3249E8E0E00AAE1E0 /* LoopFollow.xcdatamodeld */ = { diff --git a/LoopFollow.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/LoopFollow.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved deleted file mode 100644 index f2d75025c..000000000 --- a/LoopFollow.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ /dev/null @@ -1,16 +0,0 @@ -{ - "object": { - "pins": [ - { - "package": "Oxygen", - "repositoryURL": "https://github.com/mpangburn/Oxygen.git", - "state": { - "branch": "master", - "revision": "b3c7a6ead1400e4799b16755d23c9905040d4acc", - "version": null - } - } - ] - }, - "version": 1 -} diff --git a/LoopFollow/Controllers/Nightscout/BGData.swift b/LoopFollow/Controllers/Nightscout/BGData.swift index 2f0311053..c870abe57 100644 --- a/LoopFollow/Controllers/Nightscout/BGData.swift +++ b/LoopFollow/Controllers/Nightscout/BGData.swift @@ -264,16 +264,15 @@ extension MainViewController { Storage.shared.lastBgReadingTimeSeconds.value = lastBGTime Storage.shared.lastDeltaMgdl.value = Double(deltaBG) Storage.shared.lastTrendCode.value = entries[latestEntryIndex].direction - + // Mark BG data as loaded for initial loading state self.markDataLoaded("bg") - + // Live Activity update if #available(iOS 16.1, *) { LiveActivityManager.shared.refreshFromCurrentState(reason: "bg") } - // Update contact if Storage.shared.contactEnabled.value { self.contactImageUpdater @@ -285,7 +284,6 @@ extension MainViewController { ) } Storage.shared.lastBGChecked.value = Date() - } } } diff --git a/LoopFollow/Controllers/Nightscout/DeviceStatusLoop.swift b/LoopFollow/Controllers/Nightscout/DeviceStatusLoop.swift index d4f851ba5..650092237 100644 --- a/LoopFollow/Controllers/Nightscout/DeviceStatusLoop.swift +++ b/LoopFollow/Controllers/Nightscout/DeviceStatusLoop.swift @@ -124,7 +124,8 @@ extension MainViewController { Storage.shared.lastIOB.value = latestIOB?.value Storage.shared.lastCOB.value = latestCOB?.value if let predictdata = lastLoopRecord["predicted"] as? [String: AnyObject], - let values = predictdata["values"] as? [Double] { + let values = predictdata["values"] as? [Double] + { Storage.shared.projectedBgMgdl.value = values.last } else { Storage.shared.projectedBgMgdl.value = nil diff --git a/LoopFollow/Helpers/JWTManager.swift b/LoopFollow/Helpers/JWTManager.swift index 621b2186d..06f4a5583 100644 --- a/LoopFollow/Helpers/JWTManager.swift +++ b/LoopFollow/Helpers/JWTManager.swift @@ -1,42 +1,46 @@ // LoopFollow // JWTManager.swift +import CryptoKit import Foundation -import SwiftJWT - -struct JWTClaims: Claims { - let iss: String - let iat: Date -} class JWTManager { static let shared = JWTManager() + private struct CachedToken { + let jwt: String + let expiresAt: Date + } + + /// Cache keyed by "keyId:teamId", 55 min TTL + private var cache: [String: CachedToken] = [:] + private let ttl: TimeInterval = 55 * 60 + private init() {} func getOrGenerateJWT(keyId: String, teamId: String, apnsKey: String) -> String? { - // 1. Check for a valid, non-expired JWT directly from Storage.shared - if let jwt = Storage.shared.cachedJWT.value, - let expiration = Storage.shared.jwtExpirationDate.value, - Date() < expiration - { - return jwt - } + let cacheKey = "\(keyId):\(teamId)" - // 2. If no valid JWT is found, generate a new one - let header = Header(kid: keyId) - let claims = JWTClaims(iss: teamId, iat: Date()) - var jwt = JWT(header: header, claims: claims) + if let cached = cache[cacheKey], Date() < cached.expiresAt { + return cached.jwt + } do { - let privateKey = Data(apnsKey.utf8) - let jwtSigner = JWTSigner.es256(privateKey: privateKey) - let signedJWT = try jwt.sign(using: jwtSigner) + let privateKey = try loadPrivateKey(from: apnsKey) + let header = try encodeHeader(keyId: keyId) + let payload = try encodePayload(teamId: teamId) + let signingInput = "\(header).\(payload)" + + guard let signingData = signingInput.data(using: .utf8) else { + LogManager.shared.log(category: .apns, message: "Failed to encode JWT signing input") + return nil + } - // 3. Save the new JWT and its expiration date directly to Storage.shared - Storage.shared.cachedJWT.value = signedJWT - Storage.shared.jwtExpirationDate.value = Date().addingTimeInterval(3600) // Expires in 1 hour + let signature = try privateKey.signature(for: signingData) + let signatureBase64 = base64URLEncode(signature.rawRepresentation) + let signedJWT = "\(signingInput).\(signatureBase64)" + cache[cacheKey] = CachedToken(jwt: signedJWT, expiresAt: Date().addingTimeInterval(ttl)) return signedJWT } catch { LogManager.shared.log(category: .apns, message: "Failed to sign JWT: \(error.localizedDescription)") @@ -44,9 +48,61 @@ class JWTManager { } } - // Invalidate the cache by clearing values in Storage.shared func invalidateCache() { - Storage.shared.cachedJWT.value = nil - Storage.shared.jwtExpirationDate.value = nil + cache.removeAll() + } + + // MARK: - Private Helpers + + private func loadPrivateKey(from apnsKey: String) throws -> P256.Signing.PrivateKey { + let cleaned = apnsKey + .replacingOccurrences(of: "-----BEGIN PRIVATE KEY-----", with: "") + .replacingOccurrences(of: "-----END PRIVATE KEY-----", with: "") + .replacingOccurrences(of: "\n", with: "") + .replacingOccurrences(of: "\r", with: "") + .trimmingCharacters(in: .whitespaces) + + guard let keyData = Data(base64Encoded: cleaned) else { + throw JWTError.keyDecodingFailed + } + + return try P256.Signing.PrivateKey(derRepresentation: keyData) + } + + private func encodeHeader(keyId: String) throws -> String { + let header: [String: String] = [ + "alg": "ES256", + "kid": keyId, + ] + let data = try JSONSerialization.data(withJSONObject: header) + return base64URLEncode(data) + } + + private func encodePayload(teamId: String) throws -> String { + let now = Int(Date().timeIntervalSince1970) + let payload: [String: Any] = [ + "iss": teamId, + "iat": now, + ] + let data = try JSONSerialization.data(withJSONObject: payload) + return base64URLEncode(data) + } + + private func base64URLEncode(_ data: Data) -> String { + data.base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } + + private enum JWTError: Error, LocalizedError { + case keyDecodingFailed + + var errorDescription: String? { + switch self { + case .keyDecodingFailed: + return "Failed to decode APNs p8 key content. Ensure it is valid base64." + } + } } } diff --git a/LoopFollow/Info.plist b/LoopFollow/Info.plist index 6f329c1a6..28385ac6e 100644 --- a/LoopFollow/Info.plist +++ b/LoopFollow/Info.plist @@ -2,12 +2,6 @@ - APNSKeyContent - $(APNS_KEY_CONTENT) - APNSKeyID - $(APNS_KEY_ID) - APNSTeamID - $(DEVELOPMENT_TEAM) AppGroupIdentifier group.com.$(unique_id).LoopFollow$(app_suffix) BGTaskSchedulerPermittedIdentifiers diff --git a/LoopFollow/LiveActivity/APNSClient.swift b/LoopFollow/LiveActivity/APNSClient.swift index 7e98103dd..a8f079d05 100644 --- a/LoopFollow/LiveActivity/APNSClient.swift +++ b/LoopFollow/LiveActivity/APNSClient.swift @@ -1,63 +1,55 @@ +// LoopFollow // APNSClient.swift -// Philippe Achkar -// 2026-03-07 import Foundation class APNSClient { - static let shared = APNSClient() private init() {} // MARK: - Configuration private let bundleID = Bundle.main.bundleIdentifier ?? "com.apple.unknown" - private let apnsHost = "https://api.push.apple.com" - - // MARK: - JWT Cache - - private var cachedToken: String? - private var tokenGeneratedAt: Date? - private let tokenTTL: TimeInterval = 55 * 60 - private func validToken() throws -> String { - let now = Date() - if let token = cachedToken, - let generatedAt = tokenGeneratedAt, - now.timeIntervalSince(generatedAt) < tokenTTL { - return token - } - let newToken = try APNSJWTGenerator.generateToken() - cachedToken = newToken - tokenGeneratedAt = now - LogManager.shared.log(category: .general, message: "APNs JWT refreshed", isDebug: true) - return newToken + private var apnsHost: String { + let isProduction = BuildDetails.default.isTestFlightBuild() + return isProduction + ? "https://api.push.apple.com" + : "https://api.sandbox.push.apple.com" } + private var lfKeyId: String { Storage.shared.lfKeyId.value } + private var lfTeamId: String { BuildDetails.default.teamID ?? "" } + private var lfApnsKey: String { Storage.shared.lfApnsKey.value } + // MARK: - Send Live Activity Update func sendLiveActivityUpdate( pushToken: String, state: GlucoseLiveActivityAttributes.ContentState ) async { - do { - let jwt = try validToken() - let payload = buildPayload(state: state) + guard let jwt = JWTManager.shared.getOrGenerateJWT(keyId: lfKeyId, teamId: lfTeamId, apnsKey: lfApnsKey) else { + LogManager.shared.log(category: .general, message: "APNs failed to generate JWT for Live Activity push") + return + } - guard let url = URL(string: "\(apnsHost)/3/device/\(pushToken)") else { - LogManager.shared.log(category: .general, message: "APNs invalid URL", isDebug: true) - return - } + let payload = buildPayload(state: state) - var request = URLRequest(url: url) - request.httpMethod = "POST" - request.setValue("bearer \(jwt)", forHTTPHeaderField: "authorization") - request.setValue("application/json", forHTTPHeaderField: "content-type") - request.setValue("\(bundleID).push-type.liveactivity", forHTTPHeaderField: "apns-topic") - request.setValue("liveactivity", forHTTPHeaderField: "apns-push-type") - request.setValue("10", forHTTPHeaderField: "apns-priority") - request.httpBody = payload + guard let url = URL(string: "\(apnsHost)/3/device/\(pushToken)") else { + LogManager.shared.log(category: .general, message: "APNs invalid URL", isDebug: true) + return + } + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("bearer \(jwt)", forHTTPHeaderField: "authorization") + request.setValue("application/json", forHTTPHeaderField: "content-type") + request.setValue("\(bundleID).push-type.liveactivity", forHTTPHeaderField: "apns-topic") + request.setValue("liveactivity", forHTTPHeaderField: "apns-push-type") + request.setValue("10", forHTTPHeaderField: "apns-priority") + request.httpBody = payload + + do { let (data, response) = try await URLSession.shared.data(for: request) if let httpResponse = response as? HTTPURLResponse { @@ -71,8 +63,7 @@ class APNSClient { case 403: // JWT rejected — force regenerate on next push - cachedToken = nil - tokenGeneratedAt = nil + JWTManager.shared.invalidateCache() LogManager.shared.log(category: .general, message: "APNs JWT rejected (403) — token cache cleared, will regenerate") case 404, 410: @@ -84,7 +75,7 @@ class APNSClient { case 429: LogManager.shared.log(category: .general, message: "APNs rate limited (429) — will retry on next refresh") - case 500...599: + case 500 ... 599: let responseBody = String(data: data, encoding: .utf8) ?? "empty" LogManager.shared.log(category: .general, message: "APNs server error (\(httpResponse.statusCode)) — will retry on next refresh: \(responseBody)") @@ -109,7 +100,7 @@ class APNSClient { "delta": snapshot.delta, "trend": snapshot.trend.rawValue, "updatedAt": snapshot.updatedAt.timeIntervalSince1970, - "unit": snapshot.unit.rawValue + "unit": snapshot.unit.rawValue, ] if let iob = snapshot.iob { snapshotDict["iob"] = iob } @@ -120,15 +111,15 @@ class APNSClient { "snapshot": snapshotDict, "seq": state.seq, "reason": state.reason, - "producedAt": state.producedAt.timeIntervalSince1970 + "producedAt": state.producedAt.timeIntervalSince1970, ] let payload: [String: Any] = [ "aps": [ "timestamp": Int(Date().timeIntervalSince1970), "event": "update", - "content-state": contentState - ] + "content-state": contentState, + ], ] return try? JSONSerialization.data(withJSONObject: payload) diff --git a/LoopFollow/LiveActivity/APNSJWTGenerator.swift b/LoopFollow/LiveActivity/APNSJWTGenerator.swift deleted file mode 100644 index 381000ed1..000000000 --- a/LoopFollow/LiveActivity/APNSJWTGenerator.swift +++ /dev/null @@ -1,116 +0,0 @@ -// APNSJWTGenerator.swift -// Philippe Achkar -// 2026-03-07 - -import Foundation -import CryptoKit - -struct APNSJWTGenerator { - - // MARK: - Configuration (read from Info.plist — never hardcoded) - - static var keyID: String { - Bundle.main.infoDictionary?["APNSKeyID"] as? String ?? "" - } - - static var teamID: String { - Bundle.main.infoDictionary?["APNSTeamID"] as? String ?? "" - } - - static var keyContent: String { - Bundle.main.infoDictionary?["APNSKeyContent"] as? String ?? "" - } - - // MARK: - JWT Generation - - /// Generates a signed ES256 JWT for APNs authentication. - /// Valid for 60 minutes per Apple's requirements. - static func generateToken() throws -> String { - let privateKey = try loadPrivateKey() - let header = try encodeHeader() - let payload = try encodePayload() - let signingInput = "\(header).\(payload)" - - guard let signingData = signingInput.data(using: .utf8) else { - throw APNSJWTError.encodingFailed - } - - let signature = try privateKey.signature(for: signingData) - let signatureBase64 = base64URLEncode(signature.rawRepresentation) - return "\(signingInput).\(signatureBase64)" - } - - // MARK: - Private Helpers - - private static func loadPrivateKey() throws -> P256.Signing.PrivateKey { - guard !keyID.isEmpty else { - throw APNSJWTError.keyIDNotConfigured - } - - guard !keyContent.isEmpty else { - throw APNSJWTError.keyContentNotConfigured - } - - // Strip PEM headers/footers and whitespace if present - let cleaned = keyContent - .replacingOccurrences(of: "-----BEGIN PRIVATE KEY-----", with: "") - .replacingOccurrences(of: "-----END PRIVATE KEY-----", with: "") - .replacingOccurrences(of: "\n", with: "") - .replacingOccurrences(of: "\r", with: "") - .trimmingCharacters(in: .whitespaces) - - guard let keyData = Data(base64Encoded: cleaned) else { - throw APNSJWTError.keyDecodingFailed - } - - return try P256.Signing.PrivateKey(derRepresentation: keyData) - } - - private static func encodeHeader() throws -> String { - let header: [String: String] = [ - "alg": "ES256", - "kid": keyID - ] - let data = try JSONSerialization.data(withJSONObject: header) - return base64URLEncode(data) - } - - private static func encodePayload() throws -> String { - let now = Int(Date().timeIntervalSince1970) - let payload: [String: Any] = [ - "iss": teamID, - "iat": now - ] - let data = try JSONSerialization.data(withJSONObject: payload) - return base64URLEncode(data) - } - - private static func base64URLEncode(_ data: Data) -> String { - return data.base64EncodedString() - .replacingOccurrences(of: "+", with: "-") - .replacingOccurrences(of: "/", with: "_") - .replacingOccurrences(of: "=", with: "") - } -} - -// MARK: - Errors - -enum APNSJWTError: Error, LocalizedError { - case keyIDNotConfigured - case keyContentNotConfigured - case keyDecodingFailed - case encodingFailed - - var errorDescription: String? { - switch self { - case .keyIDNotConfigured: - return "APNSKeyID not set in Info.plist or LoopFollowConfigOverride.xcconfig." - case .keyContentNotConfigured: - return "APNSKeyContent not set. Add APNS_KEY_CONTENT to LoopFollowConfigOverride.xcconfig or GitHub Secrets." - case .keyDecodingFailed: - return "Failed to decode APNs p8 key content. Ensure it is valid base64 with no line breaks." - case .encodingFailed: - return "Failed to encode JWT signing input." - } - } -} diff --git a/LoopFollow/LiveActivity/AppGroupID.swift b/LoopFollow/LiveActivity/AppGroupID.swift index c0887c2be..6fc2bb9a6 100644 --- a/LoopFollow/LiveActivity/AppGroupID.swift +++ b/LoopFollow/LiveActivity/AppGroupID.swift @@ -1,9 +1,5 @@ -// -// AppGroupID.swift -// LoopFollow -// -// Created by Philippe Achkar on 2026-02-24. -// +// LoopFollow +// AppGroupID.swift import Foundation @@ -20,14 +16,14 @@ import Foundation /// 2) Otherwise, apply a conservative suffix-stripping heuristic. /// 3) Fall back to the current bundle identifier. enum AppGroupID { - /// Optional Info.plist key you can set in *both* app + extension targets /// to force a shared base bundle id (recommended for reliability). private static let baseBundleIDPlistKey = "LFAppGroupBaseBundleID" static func current() -> String { if let base = Bundle.main.object(forInfoDictionaryKey: baseBundleIDPlistKey) as? String, - !base.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + !base.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + { return "group.\(base)" } @@ -53,7 +49,7 @@ enum AppGroupID { ".CarPlay", ".CarPlayExtension", ".Intents", - ".IntentsExtension" + ".IntentsExtension", ] for suffix in knownSuffixes { diff --git a/LoopFollow/LiveActivity/GlucoseLiveActivityAttributes.swift b/LoopFollow/LiveActivity/GlucoseLiveActivityAttributes.swift index 981be7a05..9d6811e56 100644 --- a/LoopFollow/LiveActivity/GlucoseLiveActivityAttributes.swift +++ b/LoopFollow/LiveActivity/GlucoseLiveActivityAttributes.swift @@ -1,15 +1,10 @@ -// -// GlucoseLiveActivityAttributes.swift -// LoopFollow -// -// Created by Philippe Achkar on 2026-02-24. -// +// LoopFollow +// GlucoseLiveActivityAttributes.swift import ActivityKit import Foundation struct GlucoseLiveActivityAttributes: ActivityAttributes { - public struct ContentState: Codable, Hashable { let snapshot: GlucoseSnapshot let seq: Int @@ -31,7 +26,7 @@ struct GlucoseLiveActivityAttributes: ActivityAttributes { let producedAtInterval = try container.decode(Double.self, forKey: .producedAt) producedAt = Date(timeIntervalSince1970: producedAtInterval) } - + private enum CodingKeys: String, CodingKey { case snapshot, seq, reason, producedAt } diff --git a/LoopFollow/LiveActivity/GlucoseSnapshot.swift b/LoopFollow/LiveActivity/GlucoseSnapshot.swift index db3f50ef3..2f0aac9dc 100644 --- a/LoopFollow/LiveActivity/GlucoseSnapshot.swift +++ b/LoopFollow/LiveActivity/GlucoseSnapshot.swift @@ -1,9 +1,5 @@ -// -// GlucoseSnapshot.swift -// LoopFollow -// -// Created by Philippe Achkar on 2026-02-24. -// +// LoopFollow +// GlucoseSnapshot.swift import Foundation @@ -11,7 +7,6 @@ import Foundation /// Live Activity, future Watch complication, and CarPlay. /// struct GlucoseSnapshot: Codable, Equatable, Hashable { - // MARK: - Units enum Unit: String, Codable, Hashable { @@ -50,6 +45,7 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable { let unit: Unit // MARK: - Loop Status + /// True when LoopFollow detects the loop has not reported in 15+ minutes (Nightscout only). let isNotLooping: Bool @@ -74,7 +70,7 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable { self.unit = unit self.isNotLooping = isNotLooping } - + func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) try container.encode(glucose, forKey: .glucose) @@ -91,21 +87,22 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable { private enum CodingKeys: String, CodingKey { case glucose, delta, trend, updatedAt, iob, cob, projected, unit, isNotLooping } - + // MARK: - Codable + init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) glucose = try container.decode(Double.self, forKey: .glucose) delta = try container.decode(Double.self, forKey: .delta) trend = try container.decode(Trend.self, forKey: .trend) - updatedAt = Date(timeIntervalSince1970: try container.decode(Double.self, forKey: .updatedAt)) + updatedAt = try Date(timeIntervalSince1970: container.decode(Double.self, forKey: .updatedAt)) iob = try container.decodeIfPresent(Double.self, forKey: .iob) cob = try container.decodeIfPresent(Double.self, forKey: .cob) projected = try container.decodeIfPresent(Double.self, forKey: .projected) unit = try container.decode(Unit.self, forKey: .unit) isNotLooping = try container.decodeIfPresent(Bool.self, forKey: .isNotLooping) ?? false } - + // MARK: - Derived Convenience /// Age of reading in seconds. @@ -114,11 +111,9 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable { } } - // MARK: - Trend extension GlucoseSnapshot { - enum Trend: String, Codable, Hashable { case up case upFast diff --git a/LoopFollow/LiveActivity/GlucoseSnapshotBuilder.swift b/LoopFollow/LiveActivity/GlucoseSnapshotBuilder.swift index 31628fedf..2f0fcaa13 100644 --- a/LoopFollow/LiveActivity/GlucoseSnapshotBuilder.swift +++ b/LoopFollow/LiveActivity/GlucoseSnapshotBuilder.swift @@ -1,9 +1,5 @@ -// -// GlucoseSnapshotBuilder.swift -// LoopFollow -// -// Created by Philippe Achkar on 2026-02-28. -// +// LoopFollow +// GlucoseSnapshotBuilder.swift import Foundation @@ -32,7 +28,6 @@ protocol CurrentGlucoseStateProviding { /// Builds a GlucoseSnapshot in the user’s preferred unit, without embedding provider logic. enum GlucoseSnapshotBuilder { - static func build(from provider: CurrentGlucoseStateProviding) -> GlucoseSnapshot? { guard let glucoseMgdl = provider.glucoseMgdl, @@ -68,13 +63,12 @@ enum GlucoseSnapshotBuilder { // Not Looping — read from Observable, set by evaluateNotLooping() in DeviceStatus.swift let isNotLooping = Observable.shared.isNotLooping.value - LogManager.shared.log( category: .general, message: "LA snapshot built: updatedAt=\(updatedAt) interval=\(updatedAt.timeIntervalSince1970)", isDebug: true ) - + return GlucoseSnapshot( glucose: glucose, delta: delta, @@ -91,8 +85,8 @@ enum GlucoseSnapshotBuilder { private static func mapTrend(_ code: String?) -> GlucoseSnapshot.Trend { guard let raw = code? - .trimmingCharacters(in: .whitespacesAndNewlines) - .lowercased(), + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased(), !raw.isEmpty else { return .unknown } diff --git a/LoopFollow/LiveActivity/GlucoseSnapshotStore.swift b/LoopFollow/LiveActivity/GlucoseSnapshotStore.swift index b906742ce..b45a7a0b9 100644 --- a/LoopFollow/LiveActivity/GlucoseSnapshotStore.swift +++ b/LoopFollow/LiveActivity/GlucoseSnapshotStore.swift @@ -1,9 +1,5 @@ -// -// GlucoseSnapshotStore.swift -// LoopFollow -// -// Created by Philippe Achkar on 2026-02-24. -// +// LoopFollow +// GlucoseSnapshotStore.swift import Foundation @@ -13,7 +9,6 @@ import Foundation /// /// Uses an atomic JSON file write to avoid partial/corrupt reads across processes. final class GlucoseSnapshotStore { - static let shared = GlucoseSnapshotStore() private init() {} diff --git a/LoopFollow/LiveActivity/GlucoseUnitConversion.swift b/LoopFollow/LiveActivity/GlucoseUnitConversion.swift index 3d81620b5..cf39988d2 100644 --- a/LoopFollow/LiveActivity/GlucoseUnitConversion.swift +++ b/LoopFollow/LiveActivity/GlucoseUnitConversion.swift @@ -1,14 +1,9 @@ -// -// GlucoseUnitConversion.swift -// LoopFollow -// -// Created by Philippe Achkar on 2026-02-24. -// +// LoopFollow +// GlucoseUnitConversion.swift import Foundation enum GlucoseUnitConversion { - // 1 mmol/L glucose ≈ 18.0182 mg/dL (commonly rounded to 18) // Using 18.0182 is standard for glucose conversions. private static let mgdlPerMmol: Double = 18.0182 @@ -25,4 +20,4 @@ enum GlucoseUnitConversion { return value } } -} \ No newline at end of file +} diff --git a/LoopFollow/LiveActivity/LAAppGroupSettings.swift b/LoopFollow/LiveActivity/LAAppGroupSettings.swift index 091497f1e..7615b2cf7 100644 --- a/LoopFollow/LiveActivity/LAAppGroupSettings.swift +++ b/LoopFollow/LiveActivity/LAAppGroupSettings.swift @@ -1,9 +1,5 @@ -// -// LAAppGroupSettings.swift -// LoopFollow -// -// Created by Philippe Achkar on 2026-02-24. -// +// LoopFollow +// LAAppGroupSettings.swift import Foundation @@ -12,7 +8,6 @@ import Foundation /// We keep this separate from Storage.shared to avoid target-coupling and /// ensure the widget extension reads the same values as the app. enum LAAppGroupSettings { - private enum Keys { static let lowLineMgdl = "la.lowLine.mgdl" static let highLineMgdl = "la.highLine.mgdl" @@ -36,4 +31,4 @@ enum LAAppGroupSettings { let high = defaults?.object(forKey: Keys.highLineMgdl) as? Double ?? fallbackHigh return (low, high) } -} \ No newline at end of file +} diff --git a/LoopFollow/LiveActivity/LAThresholdSync.swift b/LoopFollow/LiveActivity/LAThresholdSync.swift index 03a5a95a4..0c6c48e51 100644 --- a/LoopFollow/LiveActivity/LAThresholdSync.swift +++ b/LoopFollow/LiveActivity/LAThresholdSync.swift @@ -1,9 +1,5 @@ -// -// LAThresholdSync.swift -// LoopFollow -// -// Created by Philippe Achkar on 2026-02-25. -// +// LoopFollow +// LAThresholdSync.swift import Foundation @@ -12,11 +8,10 @@ import Foundation /// /// This file belongs ONLY to the main app target. enum LAThresholdSync { - static func syncToAppGroup() { LAAppGroupSettings.setThresholds( lowMgdl: Storage.shared.lowLine.value, highMgdl: Storage.shared.highLine.value ) } -} \ No newline at end of file +} diff --git a/LoopFollow/LiveActivity/LiveActivityManager.swift b/LoopFollow/LiveActivity/LiveActivityManager.swift index 0c2e20537..b342711a7 100644 --- a/LoopFollow/LiveActivity/LiveActivityManager.swift +++ b/LoopFollow/LiveActivity/LiveActivityManager.swift @@ -1,17 +1,15 @@ +// LoopFollow // LiveActivityManager.swift -// Philippe Achkar -// 2026-03-07 -import Foundation @preconcurrency import ActivityKit -import UIKit +import Foundation import os +import UIKit /// Live Activity manager for LoopFollow. @available(iOS 16.1, *) final class LiveActivityManager { - static let shared = LiveActivityManager() private init() {} @@ -77,7 +75,7 @@ final class LiveActivityManager { Task { let finalState = GlucoseLiveActivityAttributes.ContentState( - snapshot: (GlucoseSnapshotStore.shared.load() ?? GlucoseSnapshot( + snapshot: GlucoseSnapshotStore.shared.load() ?? GlucoseSnapshot( glucose: 0, delta: 0, trend: .unknown, @@ -87,7 +85,7 @@ final class LiveActivityManager { projected: nil, unit: .mgdl, isNotLooping: false - )), + ), seq: seq, reason: "end", producedAt: Date() @@ -115,7 +113,7 @@ final class LiveActivityManager { } startIfNeeded() } - + func refreshFromCurrentState(reason: String) { refreshWorkItem?.cancel() let workItem = DispatchWorkItem { [weak self] in @@ -124,7 +122,7 @@ final class LiveActivityManager { refreshWorkItem = workItem DispatchQueue.main.asyncAfter(deadline: .now() + 5.0, execute: workItem) } - + private func performRefresh(reason: String) { let provider = StorageCurrentGlucoseStateProvider() guard let snapshot = GlucoseSnapshotBuilder.build(from: provider) else { @@ -166,7 +164,7 @@ final class LiveActivityManager { LogManager.shared.log(category: .general, message: "LA start suppressed (not visible) reason=\(reason)", isDebug: true) } } - + private func isAppVisibleForLiveActivityStart() -> Bool { let scenes = UIApplication.shared.connectedScenes return scenes.contains { $0.activationState == .foregroundActive } @@ -263,7 +261,7 @@ final class LiveActivityManager { end() // Activity will restart on next BG refresh via refreshFromCurrentState() } - + private func attachStateObserver(to activity: Activity) { stateObserverTask?.cancel() stateObserverTask = Task { diff --git a/LoopFollow/LiveActivity/PreferredGlucoseUnit.swift b/LoopFollow/LiveActivity/PreferredGlucoseUnit.swift index b4c5dadfb..f9e40468e 100644 --- a/LoopFollow/LiveActivity/PreferredGlucoseUnit.swift +++ b/LoopFollow/LiveActivity/PreferredGlucoseUnit.swift @@ -1,15 +1,10 @@ -// -// PreferredGlucoseUnit.swift -// LoopFollow -// -// Created by Philippe Achkar on 2026-02-24. -// +// LoopFollow +// PreferredGlucoseUnit.swift import Foundation import HealthKit enum PreferredGlucoseUnit { - /// LoopFollow’s existing source of truth for unit selection. /// NOTE: Do not duplicate the string constant elsewhere—keep it here. static func hkUnit() -> HKUnit { @@ -31,4 +26,4 @@ enum PreferredGlucoseUnit { return .mgdl } } -} \ No newline at end of file +} diff --git a/LoopFollow/LiveActivity/StorageCurrentGlucoseStateProvider.swift b/LoopFollow/LiveActivity/StorageCurrentGlucoseStateProvider.swift index 5e50a3e0d..b5a5cf7ea 100644 --- a/LoopFollow/LiveActivity/StorageCurrentGlucoseStateProvider.swift +++ b/LoopFollow/LiveActivity/StorageCurrentGlucoseStateProvider.swift @@ -1,16 +1,11 @@ -// -// StorageCurrentGlucoseStateProvider.swift -// LoopFollow -// -// Created by Philippe Achkar on 2026-02-24. -// +// LoopFollow +// StorageCurrentGlucoseStateProvider.swift import Foundation /// Reads the latest glucose state from LoopFollow’s existing single source of truth. /// Provider remains source-agnostic (Nightscout vs Dexcom). struct StorageCurrentGlucoseStateProvider: CurrentGlucoseStateProviding { - var glucoseMgdl: Double? { guard let bg = Observable.shared.bg.value, diff --git a/LoopFollow/Remote/LoopAPNS/LoopAPNSService.swift b/LoopFollow/Remote/LoopAPNS/LoopAPNSService.swift index efcb15862..61aaa6ef4 100644 --- a/LoopFollow/Remote/LoopAPNS/LoopAPNSService.swift +++ b/LoopFollow/Remote/LoopAPNS/LoopAPNSService.swift @@ -1,14 +1,26 @@ // LoopFollow // LoopAPNSService.swift -import CryptoKit import Foundation import HealthKit -import SwiftJWT class LoopAPNSService { private let storage = Storage.shared + /// Returns the effective APNs credentials for sending commands to the remote app. + /// Same team → use LoopFollow's own key. Different team → use remote-specific key. + private func effectiveCredentials() -> (apnsKey: String, keyId: String, teamId: String) { + let lfTeamId = BuildDetails.default.teamID ?? "" + let remoteTeamId = storage.teamId.value ?? "" + let sameTeam = !lfTeamId.isEmpty && !remoteTeamId.isEmpty && lfTeamId == remoteTeamId + + if sameTeam || remoteTeamId.isEmpty { + return (storage.lfApnsKey.value, storage.lfKeyId.value, lfTeamId) + } else { + return (storage.remoteApnsKey.value, storage.remoteKeyId.value, remoteTeamId) + } + } + enum LoopAPNSError: Error, LocalizedError { case invalidConfiguration case jwtError @@ -57,26 +69,11 @@ class LoopAPNSService { return nil } - // Get the target Loop app's Team ID from storage. - let targetTeamId = storage.teamId.value ?? "" - let teamIdsAreDifferent = loopFollowTeamID != targetTeamId - - let keyIdForReturn: String - let apnsKeyForReturn: String - - if teamIdsAreDifferent { - // Team IDs differ, use the separate return credentials. - keyIdForReturn = storage.returnKeyId.value - apnsKeyForReturn = storage.returnApnsKey.value - } else { - // Team IDs are the same, use the primary credentials. - keyIdForReturn = storage.keyId.value - apnsKeyForReturn = storage.apnsKey.value - } + let lfKeyId = storage.lfKeyId.value + let lfApnsKey = storage.lfApnsKey.value - // Ensure we have the necessary credentials. - guard !keyIdForReturn.isEmpty, !apnsKeyForReturn.isEmpty else { - LogManager.shared.log(category: .apns, message: "Missing required return APNS credentials. Check Remote Settings.") + guard !lfKeyId.isEmpty, !lfApnsKey.isEmpty else { + LogManager.shared.log(category: .apns, message: "Missing LoopFollow APNS credentials. Configure them in App Settings → APN.") return nil } @@ -85,8 +82,8 @@ class LoopAPNSService { deviceToken: loopFollowDeviceToken, bundleId: Bundle.main.bundleIdentifier ?? "", teamId: loopFollowTeamID, - keyId: keyIdForReturn, - apnsKey: apnsKeyForReturn + keyId: lfKeyId, + apnsKey: lfApnsKey ) } @@ -108,8 +105,9 @@ class LoopAPNSService { /// Validates the Loop APNS setup by checking all required fields /// - Returns: True if setup is valid, false otherwise func validateSetup() -> Bool { - let hasKeyId = !storage.keyId.value.isEmpty - let hasAPNSKey = !storage.apnsKey.value.isEmpty + let creds = effectiveCredentials() + let hasKeyId = !creds.keyId.isEmpty + let hasAPNSKey = !creds.apnsKey.isEmpty let hasQrCode = !storage.loopAPNSQrCodeURL.value.isEmpty let hasDeviceToken = !Storage.shared.deviceToken.value.isEmpty let hasBundleIdentifier = !Storage.shared.bundleId.value.isEmpty @@ -138,8 +136,7 @@ class LoopAPNSService { let deviceToken = Storage.shared.deviceToken.value let bundleIdentifier = Storage.shared.bundleId.value - let keyId = storage.keyId.value - let apnsKey = storage.apnsKey.value + let creds = effectiveCredentials() // Create APNS notification payload (matching Loop's expected format) let now = Date() @@ -186,8 +183,9 @@ class LoopAPNSService { sendAPNSNotification( deviceToken: deviceToken, bundleIdentifier: bundleIdentifier, - keyId: keyId, - apnsKey: apnsKey, + keyId: creds.keyId, + apnsKey: creds.apnsKey, + teamId: creds.teamId, payload: finalPayload, completion: completion ) @@ -207,8 +205,7 @@ class LoopAPNSService { let deviceToken = Storage.shared.deviceToken.value let bundleIdentifier = Storage.shared.bundleId.value - let keyId = storage.keyId.value - let apnsKey = storage.apnsKey.value + let creds = effectiveCredentials() // Create APNS notification payload (matching Loop's expected format) let now = Date() @@ -250,8 +247,9 @@ class LoopAPNSService { sendAPNSNotification( deviceToken: deviceToken, bundleIdentifier: bundleIdentifier, - keyId: keyId, - apnsKey: apnsKey, + keyId: creds.keyId, + apnsKey: creds.apnsKey, + teamId: creds.teamId, payload: finalPayload, completion: completion ) @@ -262,9 +260,10 @@ class LoopAPNSService { private func validateCredentials() -> [String]? { var errors = [String]() - let keyId = storage.keyId.value - let teamId = Storage.shared.teamId.value ?? "" - let apnsKey = storage.apnsKey.value + let creds = effectiveCredentials() + let keyId = creds.keyId + let teamId = creds.teamId + let apnsKey = creds.apnsKey // Validate keyId (should be 10 alphanumeric characters) let keyIdPattern = "^[A-Z0-9]{10}$" @@ -328,6 +327,7 @@ class LoopAPNSService { bundleIdentifier: String, keyId: String, apnsKey: String, + teamId: String, payload: [String: Any], completion: @escaping (Bool, String?) -> Void ) { @@ -340,7 +340,7 @@ class LoopAPNSService { } // Create JWT token for APNS authentication - guard let jwt = JWTManager.shared.getOrGenerateJWT(keyId: keyId, teamId: Storage.shared.teamId.value ?? "", apnsKey: apnsKey) else { + guard let jwt = JWTManager.shared.getOrGenerateJWT(keyId: keyId, teamId: teamId, apnsKey: apnsKey) else { let errorMessage = "Failed to generate JWT, please check that the APNS Key ID, APNS Key and Team ID are correct." LogManager.shared.log(category: .apns, message: errorMessage) completion(false, errorMessage) @@ -699,11 +699,13 @@ class LoopAPNSService { } // Send the notification using the existing APNS infrastructure + let creds = effectiveCredentials() sendAPNSNotification( deviceToken: deviceToken, bundleIdentifier: bundleIdentifier, - keyId: storage.keyId.value, - apnsKey: storage.apnsKey.value, + keyId: creds.keyId, + apnsKey: creds.apnsKey, + teamId: creds.teamId, payload: payload, completion: completion ) @@ -753,11 +755,13 @@ class LoopAPNSService { } // Send the notification using the existing APNS infrastructure + let creds = effectiveCredentials() sendAPNSNotification( deviceToken: deviceToken, bundleIdentifier: bundleIdentifier, - keyId: storage.keyId.value, - apnsKey: storage.apnsKey.value, + keyId: creds.keyId, + apnsKey: creds.apnsKey, + teamId: creds.teamId, payload: payload, completion: completion ) diff --git a/LoopFollow/Remote/Settings/RemoteCommandSettings.swift b/LoopFollow/Remote/Settings/RemoteCommandSettings.swift index bdc270dc6..56c5686fb 100644 --- a/LoopFollow/Remote/Settings/RemoteCommandSettings.swift +++ b/LoopFollow/Remote/Settings/RemoteCommandSettings.swift @@ -8,8 +8,8 @@ struct RemoteCommandSettings: Codable { let remoteType: RemoteType let user: String let sharedSecret: String - let apnsKey: String - let keyId: String + let remoteApnsKey: String + let remoteKeyId: String let teamId: String? let maxBolus: Double let maxCarbs: Double @@ -27,8 +27,8 @@ struct RemoteCommandSettings: Codable { remoteType: RemoteType, user: String, sharedSecret: String, - apnsKey: String, - keyId: String, + remoteApnsKey: String, + remoteKeyId: String, teamId: String?, maxBolus: Double, maxCarbs: Double, @@ -44,8 +44,8 @@ struct RemoteCommandSettings: Codable { self.remoteType = remoteType self.user = user self.sharedSecret = sharedSecret - self.apnsKey = apnsKey - self.keyId = keyId + self.remoteApnsKey = remoteApnsKey + self.remoteKeyId = remoteKeyId self.teamId = teamId self.maxBolus = maxBolus self.maxCarbs = maxCarbs @@ -68,8 +68,8 @@ struct RemoteCommandSettings: Codable { remoteType: storage.remoteType.value, user: storage.user.value, sharedSecret: storage.sharedSecret.value, - apnsKey: storage.apnsKey.value, - keyId: storage.keyId.value, + remoteApnsKey: storage.remoteApnsKey.value, + remoteKeyId: storage.remoteKeyId.value, teamId: storage.teamId.value, maxBolus: storage.maxBolus.value.doubleValue(for: .internationalUnit()), maxCarbs: storage.maxCarbs.value.doubleValue(for: .gram()), @@ -91,8 +91,8 @@ struct RemoteCommandSettings: Codable { storage.remoteType.value = remoteType storage.user.value = user storage.sharedSecret.value = sharedSecret - storage.apnsKey.value = apnsKey - storage.keyId.value = keyId + storage.remoteApnsKey.value = remoteApnsKey + storage.remoteKeyId.value = remoteKeyId storage.teamId.value = teamId storage.maxBolus.value = HKQuantity(unit: .internationalUnit(), doubleValue: maxBolus) storage.maxCarbs.value = HKQuantity(unit: .gram(), doubleValue: maxCarbs) @@ -152,9 +152,9 @@ struct RemoteCommandSettings: Codable { case .nightscout: return !user.isEmpty case .trc: - return !user.isEmpty && !sharedSecret.isEmpty && !apnsKey.isEmpty && !keyId.isEmpty + return !user.isEmpty && !sharedSecret.isEmpty && !remoteApnsKey.isEmpty && !remoteKeyId.isEmpty case .loopAPNS: - return !keyId.isEmpty && !apnsKey.isEmpty && teamId != nil && !loopAPNSQrCodeURL.isEmpty + return !remoteKeyId.isEmpty && !remoteApnsKey.isEmpty && teamId != nil && !loopAPNSQrCodeURL.isEmpty } } diff --git a/LoopFollow/Remote/Settings/RemoteSettingsView.swift b/LoopFollow/Remote/Settings/RemoteSettingsView.swift index 3df7acf2d..c9d1878ba 100644 --- a/LoopFollow/Remote/Settings/RemoteSettingsView.swift +++ b/LoopFollow/Remote/Settings/RemoteSettingsView.swift @@ -148,23 +148,25 @@ struct RemoteSettingsView: View { ) } - HStack { - Text("APNS Key ID") - TogglableSecureInput( - placeholder: "Enter APNS Key ID", - text: $viewModel.keyId, - style: .singleLine - ) - } + if viewModel.areTeamIdsDifferent { + HStack { + Text("APNS Key ID") + TogglableSecureInput( + placeholder: "Enter APNS Key ID", + text: $viewModel.remoteKeyId, + style: .singleLine + ) + } - VStack(alignment: .leading) { - Text("APNS Key") - TogglableSecureInput( - placeholder: "Paste APNS Key", - text: $viewModel.apnsKey, - style: .multiLine - ) - .frame(minHeight: 110) + VStack(alignment: .leading) { + Text("APNS Key") + TogglableSecureInput( + placeholder: "Paste APNS Key", + text: $viewModel.remoteApnsKey, + style: .multiLine + ) + .frame(minHeight: 110) + } } } @@ -194,23 +196,25 @@ struct RemoteSettingsView: View { ) } - HStack { - Text("APNS Key ID") - TogglableSecureInput( - placeholder: "Enter APNS Key ID", - text: $viewModel.keyId, - style: .singleLine - ) - } + if viewModel.areTeamIdsDifferent { + HStack { + Text("APNS Key ID") + TogglableSecureInput( + placeholder: "Enter APNS Key ID", + text: $viewModel.remoteKeyId, + style: .singleLine + ) + } - VStack(alignment: .leading) { - Text("APNS Key") - TogglableSecureInput( - placeholder: "Paste APNS Key", - text: $viewModel.apnsKey, - style: .multiLine - ) - .frame(minHeight: 110) + VStack(alignment: .leading) { + Text("APNS Key") + TogglableSecureInput( + placeholder: "Paste APNS Key", + text: $viewModel.remoteApnsKey, + style: .multiLine + ) + .frame(minHeight: 110) + } } HStack { @@ -279,29 +283,6 @@ struct RemoteSettingsView: View { Text("Bolus Increment: \(Storage.shared.bolusIncrement.value.doubleValue(for: .internationalUnit()), specifier: "%.3f") U") } } - - if viewModel.areTeamIdsDifferent { - Section(header: Text("Return Notification Settings"), footer: Text("Because LoopFollow and the target app were built with different Team IDs, you must provide the APNS credentials for LoopFollow below.").font(.caption)) { - HStack { - Text("Return APNS Key ID") - TogglableSecureInput( - placeholder: "Enter Key ID for LoopFollow", - text: $viewModel.returnKeyId, - style: .singleLine - ) - } - - VStack(alignment: .leading) { - Text("Return APNS Key") - TogglableSecureInput( - placeholder: "Paste APNS Key for LoopFollow", - text: $viewModel.returnApnsKey, - style: .multiLine - ) - .frame(minHeight: 110) - } - } - } } } .alert(isPresented: $showAlert) { diff --git a/LoopFollow/Remote/Settings/RemoteSettingsViewModel.swift b/LoopFollow/Remote/Settings/RemoteSettingsViewModel.swift index bcf5a9952..d5ea2fc4e 100644 --- a/LoopFollow/Remote/Settings/RemoteSettingsViewModel.swift +++ b/LoopFollow/Remote/Settings/RemoteSettingsViewModel.swift @@ -9,8 +9,8 @@ class RemoteSettingsViewModel: ObservableObject { @Published var remoteType: RemoteType @Published var user: String @Published var sharedSecret: String - @Published var apnsKey: String - @Published var keyId: String + @Published var remoteApnsKey: String + @Published var remoteKeyId: String @Published var maxBolus: HKQuantity @Published var maxCarbs: HKQuantity @@ -21,11 +21,6 @@ class RemoteSettingsViewModel: ObservableObject { @Published var isTrioDevice: Bool = (Storage.shared.device.value == "Trio") @Published var isLoopDevice: Bool = (Storage.shared.device.value == "Loop") - // MARK: - Return Notification Properties - - @Published var returnApnsKey: String - @Published var returnKeyId: String - // MARK: - Loop APNS Setup Properties @Published var loopDeveloperTeamId: String @@ -56,16 +51,13 @@ class RemoteSettingsViewModel: ObservableObject { // Determine if a comparison is needed and perform it. switch remoteType { - case .trc: - // If the target ID is empty, there's nothing to compare. + case .trc, .loopAPNS: guard !targetTeamId.isEmpty else { return false } - // Return true if the IDs are different. return loopFollowTeamID != targetTeamId - case .loopAPNS, .none, .nightscout: - // For other remote types, this check is not applicable. + case .none, .nightscout: return false } } @@ -73,8 +65,13 @@ class RemoteSettingsViewModel: ObservableObject { // MARK: - Computed property for Loop APNS Setup validation var loopAPNSSetup: Bool { - !keyId.isEmpty && - !apnsKey.isEmpty && + let hasCredentials: Bool + if areTeamIdsDifferent { + hasCredentials = !remoteKeyId.isEmpty && !remoteApnsKey.isEmpty + } else { + hasCredentials = !Storage.shared.lfKeyId.value.isEmpty && !Storage.shared.lfApnsKey.value.isEmpty + } + return hasCredentials && !loopDeveloperTeamId.isEmpty && !loopAPNSQrCodeURL.isEmpty && !Storage.shared.deviceToken.value.isEmpty && @@ -89,8 +86,8 @@ class RemoteSettingsViewModel: ObservableObject { remoteType = storage.remoteType.value user = storage.user.value sharedSecret = storage.sharedSecret.value - apnsKey = storage.apnsKey.value - keyId = storage.keyId.value + remoteApnsKey = storage.remoteApnsKey.value + remoteKeyId = storage.remoteKeyId.value maxBolus = storage.maxBolus.value maxCarbs = storage.maxCarbs.value maxProtein = storage.maxProtein.value @@ -102,9 +99,6 @@ class RemoteSettingsViewModel: ObservableObject { loopAPNSQrCodeURL = storage.loopAPNSQrCodeURL.value productionEnvironment = storage.productionEnvironment.value - returnApnsKey = storage.returnApnsKey.value - returnKeyId = storage.returnKeyId.value - setupBindings() } @@ -125,19 +119,18 @@ class RemoteSettingsViewModel: ObservableObject { .sink { [weak self] in self?.storage.sharedSecret.value = $0 } .store(in: &cancellables) - $apnsKey + $remoteApnsKey .dropFirst() .sink { [weak self] newValue in - // Validate and fix the APNS key format using the service let apnsService = LoopAPNSService() let fixedKey = apnsService.validateAndFixAPNSKey(newValue) - self?.storage.apnsKey.value = fixedKey + self?.storage.remoteApnsKey.value = fixedKey } .store(in: &cancellables) - $keyId + $remoteKeyId .dropFirst() - .sink { [weak self] in self?.storage.keyId.value = $0 } + .sink { [weak self] in self?.storage.remoteKeyId.value = $0 } .store(in: &cancellables) $maxBolus @@ -194,17 +187,6 @@ class RemoteSettingsViewModel: ObservableObject { .dropFirst() .sink { [weak self] in self?.storage.productionEnvironment.value = $0 } .store(in: &cancellables) - - // Return notification bindings - $returnApnsKey - .dropFirst() - .sink { [weak self] in self?.storage.returnApnsKey.value = $0 } - .store(in: &cancellables) - - $returnKeyId - .dropFirst() - .sink { [weak self] in self?.storage.returnKeyId.value = $0 } - .store(in: &cancellables) } func handleLoopAPNSQRCodeScanResult(_ result: Result) { @@ -235,8 +217,8 @@ class RemoteSettingsViewModel: ObservableObject { remoteType = storage.remoteType.value user = storage.user.value sharedSecret = storage.sharedSecret.value - apnsKey = storage.apnsKey.value - keyId = storage.keyId.value + remoteApnsKey = storage.remoteApnsKey.value + remoteKeyId = storage.remoteKeyId.value maxBolus = storage.maxBolus.value maxCarbs = storage.maxCarbs.value maxProtein = storage.maxProtein.value diff --git a/LoopFollow/Remote/TRC/PushNotificationManager.swift b/LoopFollow/Remote/TRC/PushNotificationManager.swift index 1cef2ff1a..e0c70d746 100644 --- a/LoopFollow/Remote/TRC/PushNotificationManager.swift +++ b/LoopFollow/Remote/TRC/PushNotificationManager.swift @@ -3,7 +3,6 @@ import Foundation import HealthKit -import SwiftJWT class PushNotificationManager { private var deviceToken: String @@ -19,11 +18,22 @@ class PushNotificationManager { deviceToken = Storage.shared.deviceToken.value sharedSecret = Storage.shared.sharedSecret.value productionEnvironment = Storage.shared.productionEnvironment.value - apnsKey = Storage.shared.apnsKey.value - teamId = Storage.shared.teamId.value ?? "" - keyId = Storage.shared.keyId.value user = Storage.shared.user.value bundleId = Storage.shared.bundleId.value + + let lfTeamId = BuildDetails.default.teamID ?? "" + let remoteTeamId = Storage.shared.teamId.value ?? "" + let sameTeam = !lfTeamId.isEmpty && !remoteTeamId.isEmpty && lfTeamId == remoteTeamId + + if sameTeam || remoteTeamId.isEmpty { + apnsKey = Storage.shared.lfApnsKey.value + keyId = Storage.shared.lfKeyId.value + teamId = lfTeamId + } else { + apnsKey = Storage.shared.remoteApnsKey.value + keyId = Storage.shared.remoteKeyId.value + teamId = remoteTeamId + } } private func createReturnNotificationInfo() -> CommandPayload.ReturnNotificationInfo? { @@ -38,20 +48,11 @@ class PushNotificationManager { return nil } - let teamIdsAreDifferent = loopFollowTeamID != teamId - let keyIdForReturn: String - let apnsKeyForReturn: String - - if teamIdsAreDifferent { - keyIdForReturn = Storage.shared.returnKeyId.value - apnsKeyForReturn = Storage.shared.returnApnsKey.value - } else { - keyIdForReturn = keyId - apnsKeyForReturn = apnsKey - } + let lfKeyId = Storage.shared.lfKeyId.value + let lfApnsKey = Storage.shared.lfApnsKey.value - guard !keyIdForReturn.isEmpty, !apnsKeyForReturn.isEmpty else { - LogManager.shared.log(category: .apns, message: "Missing required return APNS credentials. Check Remote Settings.") + guard !lfKeyId.isEmpty, !lfApnsKey.isEmpty else { + LogManager.shared.log(category: .apns, message: "Missing LoopFollow APNS credentials. Configure them in App Settings → APN.") return nil } @@ -60,8 +61,8 @@ class PushNotificationManager { deviceToken: loopFollowDeviceToken, bundleId: Bundle.main.bundleIdentifier ?? "", teamId: loopFollowTeamID, - keyId: keyIdForReturn, - apnsKey: apnsKeyForReturn + keyId: lfKeyId, + apnsKey: lfApnsKey ) } diff --git a/LoopFollow/Settings/APNSettingsView.swift b/LoopFollow/Settings/APNSettingsView.swift new file mode 100644 index 000000000..79b07e7cd --- /dev/null +++ b/LoopFollow/Settings/APNSettingsView.swift @@ -0,0 +1,44 @@ +// LoopFollow +// APNSettingsView.swift + +import SwiftUI + +struct APNSettingsView: View { + @State private var keyId: String = Storage.shared.lfKeyId.value + @State private var apnsKey: String = Storage.shared.lfApnsKey.value + + var body: some View { + Form { + Section(header: Text("LoopFollow APNs Credentials")) { + HStack { + Text("APNS Key ID") + TogglableSecureInput( + placeholder: "Enter APNS Key ID", + text: $keyId, + style: .singleLine + ) + } + + VStack(alignment: .leading) { + Text("APNS Key") + TogglableSecureInput( + placeholder: "Paste APNS Key", + text: $apnsKey, + style: .multiLine + ) + .frame(minHeight: 110) + } + } + } + .onChange(of: keyId) { newValue in + Storage.shared.lfKeyId.value = newValue + } + .onChange(of: apnsKey) { newValue in + let apnsService = LoopAPNSService() + Storage.shared.lfApnsKey.value = apnsService.validateAndFixAPNSKey(newValue) + } + .preferredColorScheme(Storage.shared.appearanceMode.value.colorScheme) + .navigationTitle("APN") + .navigationBarTitleDisplayMode(.inline) + } +} diff --git a/LoopFollow/Settings/ImportExport/ExportableSettings.swift b/LoopFollow/Settings/ImportExport/ExportableSettings.swift index 77c905806..0425528c9 100644 --- a/LoopFollow/Settings/ImportExport/ExportableSettings.swift +++ b/LoopFollow/Settings/ImportExport/ExportableSettings.swift @@ -148,8 +148,8 @@ struct RemoteSettingsExport: Codable { let remoteType: RemoteType let user: String let sharedSecret: String - let apnsKey: String - let keyId: String + let remoteApnsKey: String + let remoteKeyId: String let teamId: String? let maxBolus: Double let maxCarbs: Double @@ -168,8 +168,8 @@ struct RemoteSettingsExport: Codable { remoteType: storage.remoteType.value, user: storage.user.value, sharedSecret: storage.sharedSecret.value, - apnsKey: storage.apnsKey.value, - keyId: storage.keyId.value, + remoteApnsKey: storage.remoteApnsKey.value, + remoteKeyId: storage.remoteKeyId.value, teamId: storage.teamId.value, maxBolus: storage.maxBolus.value.doubleValue(for: .internationalUnit()), maxCarbs: storage.maxCarbs.value.doubleValue(for: .gram()), @@ -189,8 +189,8 @@ struct RemoteSettingsExport: Codable { storage.remoteType.value = remoteType storage.user.value = user storage.sharedSecret.value = sharedSecret - storage.apnsKey.value = apnsKey - storage.keyId.value = keyId + storage.remoteApnsKey.value = remoteApnsKey + storage.remoteKeyId.value = remoteKeyId storage.teamId.value = teamId storage.maxBolus.value = HKQuantity(unit: .internationalUnit(), doubleValue: maxBolus) storage.maxCarbs.value = HKQuantity(unit: .gram(), doubleValue: maxCarbs) @@ -237,9 +237,9 @@ struct RemoteSettingsExport: Codable { case .nightscout: return !user.isEmpty case .trc: - return !user.isEmpty && !sharedSecret.isEmpty && !apnsKey.isEmpty && !keyId.isEmpty + return !user.isEmpty && !sharedSecret.isEmpty && !remoteApnsKey.isEmpty && !remoteKeyId.isEmpty case .loopAPNS: - return !keyId.isEmpty && !apnsKey.isEmpty && teamId != nil && !loopAPNSQrCodeURL.isEmpty + return !remoteKeyId.isEmpty && !remoteApnsKey.isEmpty && teamId != nil && !loopAPNSQrCodeURL.isEmpty } } @@ -266,14 +266,14 @@ struct RemoteSettingsExport: Codable { // For TRC and LoopAPNS, check if key details are changing if remoteType == .trc || remoteType == .loopAPNS { - let currentKeyId = storage.keyId.value - let currentApnsKey = storage.apnsKey.value + let currentKeyId = storage.remoteKeyId.value + let currentApnsKey = storage.remoteApnsKey.value - if !currentKeyId.isEmpty, currentKeyId != keyId { + if !currentKeyId.isEmpty, currentKeyId != remoteKeyId { message += "APNS Key ID is changing. This may affect your remote commands.\n" } - if !currentApnsKey.isEmpty, currentApnsKey != apnsKey { + if !currentApnsKey.isEmpty, currentApnsKey != remoteApnsKey { message += "APNS Key is changing. This may affect your remote commands.\n" } } diff --git a/LoopFollow/Settings/SettingsMenuView.swift b/LoopFollow/Settings/SettingsMenuView.swift index 4ec770943..1ddcffc77 100644 --- a/LoopFollow/Settings/SettingsMenuView.swift +++ b/LoopFollow/Settings/SettingsMenuView.swift @@ -60,6 +60,12 @@ struct SettingsMenuView: View { settingsPath.value.append(Sheet.importExport) } + NavigationRow(title: "APN", + icon: "bell.and.waves.left.and.right") + { + settingsPath.value.append(Sheet.apn) + } + if !nightscoutURL.value.isEmpty { NavigationRow(title: "Information Display Settings", icon: "info.circle") @@ -238,6 +244,7 @@ private enum Sheet: Hashable, Identifiable { case general, graph case infoDisplay case alarmSettings + case apn case remote case importExport case calendar, contact @@ -257,6 +264,7 @@ private enum Sheet: Hashable, Identifiable { case .graph: GraphSettingsView() case .infoDisplay: InfoDisplaySettingsView(viewModel: .init()) case .alarmSettings: AlarmSettingsView() + case .apn: APNSettingsView() case .remote: RemoteSettingsView(viewModel: .init()) case .importExport: ImportExportSettingsView() case .calendar: CalendarSettingsView() diff --git a/LoopFollow/Storage/Observable.swift b/LoopFollow/Storage/Observable.swift index 4caef7b94..f5e9b1606 100644 --- a/LoopFollow/Storage/Observable.swift +++ b/LoopFollow/Storage/Observable.swift @@ -42,7 +42,7 @@ class Observable { var lastSentTOTP = ObservableValue(default: nil) var loopFollowDeviceToken = ObservableValue(default: "") - + var isNotLooping = ObservableValue(default: false) private init() {} diff --git a/LoopFollow/Storage/Storage+Migrate.swift b/LoopFollow/Storage/Storage+Migrate.swift index b913d9b42..d3efbe16e 100644 --- a/LoopFollow/Storage/Storage+Migrate.swift +++ b/LoopFollow/Storage/Storage+Migrate.swift @@ -4,6 +4,45 @@ import Foundation extension Storage { + func migrateStep5() { + LogManager.shared.log(category: .general, message: "Running migrateStep5 — APNs credential separation") + + let legacyReturnApnsKey = StorageValue(key: "returnApnsKey", defaultValue: "") + let legacyReturnKeyId = StorageValue(key: "returnKeyId", defaultValue: "") + let legacyApnsKey = StorageValue(key: "apnsKey", defaultValue: "") + let legacyKeyId = StorageValue(key: "keyId", defaultValue: "") + + // 1. If returnApnsKey had a value, that was LoopFollow's own key (different team scenario) + if legacyReturnApnsKey.exists, !legacyReturnApnsKey.value.isEmpty { + lfApnsKey.value = legacyReturnApnsKey.value + lfKeyId.value = legacyReturnKeyId.value + } + + // 2. If lfApnsKey is still empty and the old primary key exists, + // check if same team — if so, the primary key was used for everything + if lfApnsKey.value.isEmpty, legacyApnsKey.exists, !legacyApnsKey.value.isEmpty { + let lfTeamId = BuildDetails.default.teamID ?? "" + let remoteTeamId = teamId.value ?? "" + let sameTeam = !lfTeamId.isEmpty && (remoteTeamId.isEmpty || lfTeamId == remoteTeamId) + if sameTeam { + lfApnsKey.value = legacyApnsKey.value + lfKeyId.value = legacyKeyId.value + } + } + + // 3. Move old primary credentials to remoteApnsKey/remoteKeyId + if legacyApnsKey.exists, !legacyApnsKey.value.isEmpty { + remoteApnsKey.value = legacyApnsKey.value + remoteKeyId.value = legacyKeyId.value + } + + // 4. Clean up old keys + legacyReturnApnsKey.remove() + legacyReturnKeyId.remove() + legacyApnsKey.remove() + legacyKeyId.remove() + } + func migrateStep3() { LogManager.shared.log(category: .general, message: "Running migrateStep3 - this should only happen once!") let legacyForceDarkMode = StorageValue(key: "forceDarkMode", defaultValue: true) diff --git a/LoopFollow/Storage/Storage.swift b/LoopFollow/Storage/Storage.swift index 093599bcb..dc0c8a282 100644 --- a/LoopFollow/Storage/Storage.swift +++ b/LoopFollow/Storage/Storage.swift @@ -15,9 +15,12 @@ class Storage { var expirationDate = StorageValue(key: "expirationDate", defaultValue: nil) var sharedSecret = StorageValue(key: "sharedSecret", defaultValue: "") var productionEnvironment = StorageValue(key: "productionEnvironment", defaultValue: false) - var apnsKey = StorageValue(key: "apnsKey", defaultValue: "") + var remoteApnsKey = StorageValue(key: "remoteApnsKey", defaultValue: "") var teamId = StorageValue(key: "teamId", defaultValue: nil) - var keyId = StorageValue(key: "keyId", defaultValue: "") + var remoteKeyId = StorageValue(key: "remoteKeyId", defaultValue: "") + + var lfApnsKey = StorageValue(key: "lfApnsKey", defaultValue: "") + var lfKeyId = StorageValue(key: "lfKeyId", defaultValue: "") var bundleId = StorageValue(key: "bundleId", defaultValue: "") var user = StorageValue(key: "user", defaultValue: "") @@ -32,9 +35,6 @@ class Storage { // TODO: This flag can be deleted in March 2027. Check the commit for other places to cleanup. var hasSeenFatProteinOrderChange = StorageValue(key: "hasSeenFatProteinOrderChange", defaultValue: false) - var cachedJWT = StorageValue(key: "cachedJWT", defaultValue: nil) - var jwtExpirationDate = StorageValue(key: "jwtExpirationDate", defaultValue: nil) - var backgroundRefreshType = StorageValue(key: "backgroundRefreshType", defaultValue: .silentTune) var selectedBLEDevice = StorageValue(key: "selectedBLEDevice", defaultValue: nil) @@ -90,7 +90,7 @@ class Storage { var lastIOB = StorageValue(key: "lastIOB", defaultValue: nil) var lastCOB = StorageValue(key: "lastCOB", defaultValue: nil) var projectedBgMgdl = StorageValue(key: "projectedBgMgdl", defaultValue: nil) - + // Graph Settings [BEGIN] var showDots = StorageValue(key: "showDots", defaultValue: true) var showLines = StorageValue(key: "showLines", defaultValue: true) @@ -186,9 +186,6 @@ class Storage { var loopAPNSQrCodeURL = StorageValue(key: "loopAPNSQrCodeURL", defaultValue: "") - var returnApnsKey = StorageValue(key: "returnApnsKey", defaultValue: "") - var returnKeyId = StorageValue(key: "returnKeyId", defaultValue: "") - var bolusIncrement = SecureStorageValue(key: "bolusIncrement", defaultValue: HKQuantity(unit: .internationalUnit(), doubleValue: 0.05)) var bolusIncrementDetected = StorageValue(key: "bolusIncrementDetected", defaultValue: false) // Statistics display preferences diff --git a/LoopFollow/ViewControllers/MainViewController.swift b/LoopFollow/ViewControllers/MainViewController.swift index eb87fc800..9173bec9f 100644 --- a/LoopFollow/ViewControllers/MainViewController.swift +++ b/LoopFollow/ViewControllers/MainViewController.swift @@ -168,6 +168,11 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele Storage.shared.migrationStep.value = 4 } + if Storage.shared.migrationStep.value < 5 { + Storage.shared.migrateStep5() + Storage.shared.migrationStep.value = 5 + } + // Synchronize info types to ensure arrays are the correct size synchronizeInfoTypes() @@ -385,29 +390,16 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele } .store(in: &cancellables) - Storage.shared.apnsKey.$value - .receive(on: DispatchQueue.main) - .removeDuplicates() - .sink { _ in - JWTManager.shared.invalidateCache() - } - .store(in: &cancellables) - - Storage.shared.teamId.$value - .receive(on: DispatchQueue.main) - .removeDuplicates() - .sink { _ in - JWTManager.shared.invalidateCache() - } - .store(in: &cancellables) - - Storage.shared.keyId.$value - .receive(on: DispatchQueue.main) - .removeDuplicates() - .sink { _ in - JWTManager.shared.invalidateCache() - } - .store(in: &cancellables) + Publishers.MergeMany( + Storage.shared.remoteApnsKey.$value.map { _ in () }.eraseToAnyPublisher(), + Storage.shared.teamId.$value.map { _ in () }.eraseToAnyPublisher(), + Storage.shared.remoteKeyId.$value.map { _ in () }.eraseToAnyPublisher(), + Storage.shared.lfApnsKey.$value.map { _ in () }.eraseToAnyPublisher(), + Storage.shared.lfKeyId.$value.map { _ in () }.eraseToAnyPublisher() + ) + .receive(on: DispatchQueue.main) + .sink { _ in JWTManager.shared.invalidateCache() } + .store(in: &cancellables) Storage.shared.device.$value .receive(on: DispatchQueue.main) diff --git a/LoopFollowLAExtension/LoopFollowLABundle.swift b/LoopFollowLAExtension/LoopFollowLABundle.swift index 13b01574b..e3a043783 100644 --- a/LoopFollowLAExtension/LoopFollowLABundle.swift +++ b/LoopFollowLAExtension/LoopFollowLABundle.swift @@ -1,18 +1,12 @@ -// -// LoopFollowLABundle.swift -// LoopFollow -// -// Created by Philippe Achkar on 2026-03-07. -// Copyright © 2026 Jon Fawcett. All rights reserved. -// - +// LoopFollow +// LoopFollowLABundle.swift // LoopFollowLABundle.swift // Philippe Achkar // 2026-03-07 -import WidgetKit import SwiftUI +import WidgetKit @main struct LoopFollowLABundle: WidgetBundle { @@ -21,4 +15,4 @@ struct LoopFollowLABundle: WidgetBundle { LoopFollowLiveActivityWidget() } } -} \ No newline at end of file +} diff --git a/LoopFollowLAExtension/LoopFollowLiveActivity.swift b/LoopFollowLAExtension/LoopFollowLiveActivity.swift index 2b0948679..55342bc22 100644 --- a/LoopFollowLAExtension/LoopFollowLiveActivity.swift +++ b/LoopFollowLAExtension/LoopFollowLiveActivity.swift @@ -1,9 +1,5 @@ -// -// LoopFollowLiveActivity.swift -// LoopFollow -// -// Created by Philippe Achkar on 2026-02-24. -// +// LoopFollow +// LoopFollowLiveActivity.swift import ActivityKit import SwiftUI @@ -11,16 +7,15 @@ import WidgetKit @available(iOS 16.1, *) struct LoopFollowLiveActivityWidget: Widget { - var body: some WidgetConfiguration { ActivityConfiguration(for: GlucoseLiveActivityAttributes.self) { context in // LOCK SCREEN / BANNER UI - LockScreenLiveActivityView(state: context.state/*, activityID: context.activityID*/) + LockScreenLiveActivityView(state: context.state /* , activityID: context.activityID */ ) .id(context.state.seq) // force SwiftUI to re-render on every update .activitySystemActionForegroundColor(.white) .activityBackgroundTint(LAColors.backgroundTint(for: context.state.snapshot)) .applyActivityContentMarginsFixIfAvailable() - } dynamicIsland: { context in + } dynamicIsland: { context in // DYNAMIC ISLAND UI DynamicIsland { DynamicIslandExpandedRegion(.leading) { @@ -65,16 +60,16 @@ private extension View { } // MARK: - Lock Screen Contract View + @available(iOS 16.1, *) private struct LockScreenLiveActivityView: View { let state: GlucoseLiveActivityAttributes.ContentState - /*let activityID: String*/ - + /* let activityID: String */ + var body: some View { let s = state.snapshot HStack(spacing: 12) { - // LEFT: Glucose + trend, update time below VStack(alignment: .leading, spacing: 4) { HStack(alignment: .firstTextBaseline, spacing: 8) { @@ -291,7 +286,6 @@ private struct DynamicIslandMinimalView: View { // MARK: - Formatting private enum LAFormat { - // MARK: Glucose static func glucose(_ s: GlucoseSnapshot) -> String { @@ -378,7 +372,7 @@ private enum LAFormat { static func hhmmss(_ date: Date) -> String { hhmmssFormatter.string(from: date) } - + static func updated(_ s: GlucoseSnapshot) -> String { hhmmFormatter.string(from: s.updatedAt) } @@ -387,14 +381,13 @@ private enum LAFormat { // MARK: - Threshold-driven colors (Option A, App Group-backed) private enum LAColors { - static func backgroundTint(for snapshot: GlucoseSnapshot) -> Color { let mgdl = toMgdl(snapshot) - + let t = LAAppGroupSettings.thresholdsMgdl() let low = t.low let high = t.high - + if mgdl < low { let raw = 0.48 + (0.85 - 0.48) * ((low - mgdl) / (low - 54.0)) let opacity = min(max(raw, 0.48), 0.85) @@ -404,14 +397,13 @@ private enum LAColors { let raw = 0.44 + (0.85 - 0.44) * ((mgdl - high) / (324.0 - high)) let opacity = min(max(raw, 0.44), 0.85) return Color(uiColor: UIColor.systemOrange).opacity(opacity) - + } else { // In range: fixed at your existing value return Color(uiColor: UIColor.systemGreen).opacity(0.36) } } - static func keyline(for snapshot: GlucoseSnapshot) -> Color { let mgdl = toMgdl(snapshot) From 4dcbd6982791ec204f6d502e36af47244f472ef2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Thu, 12 Mar 2026 22:16:51 +0100 Subject: [PATCH 06/82] Localization refactoring --- LoopFollow.xcodeproj/project.pbxproj | 12 +--- LoopFollow/Helpers/GlucoseConversion.swift | 6 +- LoopFollow/LiveActivity/APNSClient.swift | 1 + LoopFollow/LiveActivity/GlucoseSnapshot.swift | 17 +++-- .../LiveActivity/GlucoseSnapshotBuilder.swift | 26 +++---- .../LiveActivity/GlucoseUnitConversion.swift | 23 ------ LoopFollow/LiveActivity/LAThresholdSync.swift | 17 ----- .../LiveActivity/PreferredGlucoseUnit.swift | 9 +-- .../LoopFollowLiveActivity.swift | 71 ++++++++++--------- docs/LiveActivity.md | 3 +- 10 files changed, 73 insertions(+), 112 deletions(-) delete mode 100644 LoopFollow/LiveActivity/GlucoseUnitConversion.swift delete mode 100644 LoopFollow/LiveActivity/LAThresholdSync.swift diff --git a/LoopFollow.xcodeproj/project.pbxproj b/LoopFollow.xcodeproj/project.pbxproj index 83592c9ad..d7ed09428 100644 --- a/LoopFollow.xcodeproj/project.pbxproj +++ b/LoopFollow.xcodeproj/project.pbxproj @@ -8,19 +8,17 @@ /* Begin PBXBuildFile section */ 374A77992F5BD8B200E96858 /* APNSClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A77982F5BD8AB00E96858 /* APNSClient.swift */; }; - 374A77A42F5BE17000E96858 /* GlucoseUnitConversion.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A77A22F5BE17000E96858 /* GlucoseUnitConversion.swift */; }; 374A77A52F5BE17000E96858 /* AppGroupID.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A779F2F5BE17000E96858 /* AppGroupID.swift */; }; 374A77A62F5BE17000E96858 /* GlucoseSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A77A12F5BE17000E96858 /* GlucoseSnapshot.swift */; }; 374A77A72F5BE17000E96858 /* LAAppGroupSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A77A32F5BE17000E96858 /* LAAppGroupSettings.swift */; }; 374A77A82F5BE17000E96858 /* GlucoseLiveActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A77A02F5BE17000E96858 /* GlucoseLiveActivityAttributes.swift */; }; - 374A77A92F5BE17000E96858 /* GlucoseUnitConversion.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A77A22F5BE17000E96858 /* GlucoseUnitConversion.swift */; }; + DD91E4DE2BDEC3F8002D9E97 /* GlucoseConversion.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD91E4DC2BDEC3F8002D9E97 /* GlucoseConversion.swift */; }; 374A77AA2F5BE17000E96858 /* AppGroupID.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A779F2F5BE17000E96858 /* AppGroupID.swift */; }; 374A77AB2F5BE17000E96858 /* GlucoseSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A77A12F5BE17000E96858 /* GlucoseSnapshot.swift */; }; 374A77AC2F5BE17000E96858 /* LAAppGroupSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A77A32F5BE17000E96858 /* LAAppGroupSettings.swift */; }; 374A77AD2F5BE17000E96858 /* GlucoseLiveActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A77A02F5BE17000E96858 /* GlucoseLiveActivityAttributes.swift */; }; 374A77B42F5BE1AC00E96858 /* StorageCurrentGlucoseStateProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A77B32F5BE1AC00E96858 /* StorageCurrentGlucoseStateProvider.swift */; }; 374A77B52F5BE1AC00E96858 /* GlucoseSnapshotBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A77AE2F5BE1AC00E96858 /* GlucoseSnapshotBuilder.swift */; }; - 374A77B62F5BE1AC00E96858 /* LAThresholdSync.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A77B02F5BE1AC00E96858 /* LAThresholdSync.swift */; }; 374A77B72F5BE1AC00E96858 /* LiveActivityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A77B12F5BE1AC00E96858 /* LiveActivityManager.swift */; }; 374A77B82F5BE1AC00E96858 /* GlucoseSnapshotStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A77AF2F5BE1AC00E96858 /* GlucoseSnapshotStore.swift */; }; 374A77B92F5BE1AC00E96858 /* PreferredGlucoseUnit.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A77B22F5BE1AC00E96858 /* PreferredGlucoseUnit.swift */; }; @@ -458,11 +456,9 @@ 374A779F2F5BE17000E96858 /* AppGroupID.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppGroupID.swift; sourceTree = ""; }; 374A77A02F5BE17000E96858 /* GlucoseLiveActivityAttributes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseLiveActivityAttributes.swift; sourceTree = ""; }; 374A77A12F5BE17000E96858 /* GlucoseSnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseSnapshot.swift; sourceTree = ""; }; - 374A77A22F5BE17000E96858 /* GlucoseUnitConversion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseUnitConversion.swift; sourceTree = ""; }; 374A77A32F5BE17000E96858 /* LAAppGroupSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LAAppGroupSettings.swift; sourceTree = ""; }; 374A77AE2F5BE1AC00E96858 /* GlucoseSnapshotBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseSnapshotBuilder.swift; sourceTree = ""; }; 374A77AF2F5BE1AC00E96858 /* GlucoseSnapshotStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseSnapshotStore.swift; sourceTree = ""; }; - 374A77B02F5BE1AC00E96858 /* LAThresholdSync.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LAThresholdSync.swift; sourceTree = ""; }; 374A77B12F5BE1AC00E96858 /* LiveActivityManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActivityManager.swift; sourceTree = ""; }; 374A77B22F5BE1AC00E96858 /* PreferredGlucoseUnit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferredGlucoseUnit.swift; sourceTree = ""; }; 374A77B32F5BE1AC00E96858 /* StorageCurrentGlucoseStateProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StorageCurrentGlucoseStateProvider.swift; sourceTree = ""; }; @@ -913,14 +909,12 @@ children = ( 374A77AE2F5BE1AC00E96858 /* GlucoseSnapshotBuilder.swift */, 374A77AF2F5BE1AC00E96858 /* GlucoseSnapshotStore.swift */, - 374A77B02F5BE1AC00E96858 /* LAThresholdSync.swift */, 374A77B12F5BE1AC00E96858 /* LiveActivityManager.swift */, 374A77B22F5BE1AC00E96858 /* PreferredGlucoseUnit.swift */, 374A77B32F5BE1AC00E96858 /* StorageCurrentGlucoseStateProvider.swift */, 374A779F2F5BE17000E96858 /* AppGroupID.swift */, 374A77A02F5BE17000E96858 /* GlucoseLiveActivityAttributes.swift */, 374A77A12F5BE17000E96858 /* GlucoseSnapshot.swift */, - 374A77A22F5BE17000E96858 /* GlucoseUnitConversion.swift */, 374A77A32F5BE17000E96858 /* LAAppGroupSettings.swift */, 374A77982F5BD8AB00E96858 /* APNSClient.swift */, ); @@ -2051,7 +2045,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 374A77A92F5BE17000E96858 /* GlucoseUnitConversion.swift in Sources */, + DD91E4DE2BDEC3F8002D9E97 /* GlucoseConversion.swift in Sources */, 374A77AA2F5BE17000E96858 /* AppGroupID.swift in Sources */, 374A77AB2F5BE17000E96858 /* GlucoseSnapshot.swift in Sources */, 374A77AC2F5BE17000E96858 /* LAAppGroupSettings.swift in Sources */, @@ -2084,7 +2078,6 @@ DD7F4C1B2DD6501D00D449E9 /* COBCondition.swift in Sources */, 374A77B42F5BE1AC00E96858 /* StorageCurrentGlucoseStateProvider.swift in Sources */, 374A77B52F5BE1AC00E96858 /* GlucoseSnapshotBuilder.swift in Sources */, - 374A77B62F5BE1AC00E96858 /* LAThresholdSync.swift in Sources */, 374A77B72F5BE1AC00E96858 /* LiveActivityManager.swift in Sources */, 374A77B82F5BE1AC00E96858 /* GlucoseSnapshotStore.swift in Sources */, 374A77B92F5BE1AC00E96858 /* PreferredGlucoseUnit.swift in Sources */, @@ -2296,7 +2289,6 @@ DD7F4C212DD66BB200D449E9 /* FastRiseAlarmEditor.swift in Sources */, FC97881A2485969B00A7906C /* SceneDelegate.swift in Sources */, DD0C0C662C46E54C00DBADDF /* InfoDataSeparator.swift in Sources */, - 374A77A42F5BE17000E96858 /* GlucoseUnitConversion.swift in Sources */, 374A77A52F5BE17000E96858 /* AppGroupID.swift in Sources */, 374A77A62F5BE17000E96858 /* GlucoseSnapshot.swift in Sources */, 374A77A72F5BE17000E96858 /* LAAppGroupSettings.swift in Sources */, diff --git a/LoopFollow/Helpers/GlucoseConversion.swift b/LoopFollow/Helpers/GlucoseConversion.swift index bee265965..dab205bfa 100644 --- a/LoopFollow/Helpers/GlucoseConversion.swift +++ b/LoopFollow/Helpers/GlucoseConversion.swift @@ -4,6 +4,10 @@ import Foundation enum GlucoseConversion { - static let mgDlToMmolL: Double = 0.0555 static let mmolToMgDl: Double = 18.01559 + static let mgDlToMmolL: Double = 1.0 / mmolToMgDl + + static func toMmol(_ mgdl: Double) -> Double { + mgdl * mgDlToMmolL + } } diff --git a/LoopFollow/LiveActivity/APNSClient.swift b/LoopFollow/LiveActivity/APNSClient.swift index a8f079d05..ac2dfc782 100644 --- a/LoopFollow/LiveActivity/APNSClient.swift +++ b/LoopFollow/LiveActivity/APNSClient.swift @@ -103,6 +103,7 @@ class APNSClient { "unit": snapshot.unit.rawValue, ] + snapshotDict["isNotLooping"] = snapshot.isNotLooping if let iob = snapshot.iob { snapshotDict["iob"] = iob } if let cob = snapshot.cob { snapshotDict["cob"] = cob } if let projected = snapshot.projected { snapshotDict["projected"] = projected } diff --git a/LoopFollow/LiveActivity/GlucoseSnapshot.swift b/LoopFollow/LiveActivity/GlucoseSnapshot.swift index 2f0aac9dc..934f44eac 100644 --- a/LoopFollow/LiveActivity/GlucoseSnapshot.swift +++ b/LoopFollow/LiveActivity/GlucoseSnapshot.swift @@ -16,10 +16,10 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable { // MARK: - Core Glucose - /// Raw glucose value in the user-selected unit. + /// Glucose value in mg/dL (canonical internal unit). let glucose: Double - /// Raw delta in the user-selected unit. May be 0.0 if unchanged. + /// Delta in mg/dL. May be 0.0 if unchanged. let delta: Double /// Trend direction (mapped from LoopFollow state). @@ -36,12 +36,13 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable { /// Carbs On Board let cob: Double? - /// Projected glucose (if available) + /// Projected glucose in mg/dL (if available) let projected: Double? // MARK: - Unit Context - /// Unit selected by the user in LoopFollow settings. + /// User's preferred display unit. Values are always stored in mg/dL; + /// this tells the display layer which unit to render. let unit: Unit // MARK: - Loop Status @@ -116,10 +117,18 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable { extension GlucoseSnapshot { enum Trend: String, Codable, Hashable { case up + case upSlight case upFast case flat case down + case downSlight case downFast case unknown + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + let raw = try container.decode(String.self) + self = Trend(rawValue: raw) ?? .unknown + } } } diff --git a/LoopFollow/LiveActivity/GlucoseSnapshotBuilder.swift b/LoopFollow/LiveActivity/GlucoseSnapshotBuilder.swift index 2f0fcaa13..ad5b93dae 100644 --- a/LoopFollow/LiveActivity/GlucoseSnapshotBuilder.swift +++ b/LoopFollow/LiveActivity/GlucoseSnapshotBuilder.swift @@ -46,17 +46,7 @@ enum GlucoseSnapshotBuilder { let preferredUnit = PreferredGlucoseUnit.snapshotUnit() - let glucose = GlucoseUnitConversion.convertGlucose(glucoseMgdl, from: .mgdl, to: preferredUnit) - let deltaMgdl = provider.deltaMgdl ?? 0.0 - let delta = GlucoseUnitConversion.convertGlucose(deltaMgdl, from: .mgdl, to: preferredUnit) - - let projected: Double? - if let projMgdl = provider.projectedMgdl { - projected = GlucoseUnitConversion.convertGlucose(projMgdl, from: .mgdl, to: preferredUnit) - } else { - projected = nil - } let trend = mapTrend(provider.trendCode) @@ -70,13 +60,13 @@ enum GlucoseSnapshotBuilder { ) return GlucoseSnapshot( - glucose: glucose, - delta: delta, + glucose: glucoseMgdl, + delta: deltaMgdl, trend: trend, updatedAt: updatedAt, iob: provider.iob, cob: provider.cob, - projected: projected, + projected: provider.projectedMgdl, unit: preferredUnit, isNotLooping: isNotLooping ) @@ -98,7 +88,10 @@ enum GlucoseSnapshotBuilder { if raw.contains("doubleup") || raw.contains("rapidrise") || raw == "up2" || raw == "upfast" { return .upFast } - if raw.contains("singleup") || raw.contains("fortyfiveup") || raw == "up" || raw == "up1" || raw == "rising" { + if raw.contains("fortyfiveup") { + return .upSlight + } + if raw.contains("singleup") || raw == "up" || raw == "up1" || raw == "rising" { return .up } @@ -109,7 +102,10 @@ enum GlucoseSnapshotBuilder { if raw.contains("doubledown") || raw.contains("rapidfall") || raw == "down2" || raw == "downfast" { return .downFast } - if raw.contains("singledown") || raw.contains("fortyfivedown") || raw == "down" || raw == "down1" || raw == "falling" { + if raw.contains("fortyfivedown") { + return .downSlight + } + if raw.contains("singledown") || raw == "down" || raw == "down1" || raw == "falling" { return .down } diff --git a/LoopFollow/LiveActivity/GlucoseUnitConversion.swift b/LoopFollow/LiveActivity/GlucoseUnitConversion.swift deleted file mode 100644 index cf39988d2..000000000 --- a/LoopFollow/LiveActivity/GlucoseUnitConversion.swift +++ /dev/null @@ -1,23 +0,0 @@ -// LoopFollow -// GlucoseUnitConversion.swift - -import Foundation - -enum GlucoseUnitConversion { - // 1 mmol/L glucose ≈ 18.0182 mg/dL (commonly rounded to 18) - // Using 18.0182 is standard for glucose conversions. - private static let mgdlPerMmol: Double = 18.0182 - - static func convertGlucose(_ value: Double, from: GlucoseSnapshot.Unit, to: GlucoseSnapshot.Unit) -> Double { - guard from != to else { return value } - - switch (from, to) { - case (.mgdl, .mmol): - return value / mgdlPerMmol - case (.mmol, .mgdl): - return value * mgdlPerMmol - default: - return value - } - } -} diff --git a/LoopFollow/LiveActivity/LAThresholdSync.swift b/LoopFollow/LiveActivity/LAThresholdSync.swift deleted file mode 100644 index 0c6c48e51..000000000 --- a/LoopFollow/LiveActivity/LAThresholdSync.swift +++ /dev/null @@ -1,17 +0,0 @@ -// LoopFollow -// LAThresholdSync.swift - -import Foundation - -/// Bridges LoopFollow's internal threshold settings -/// into the App Group for extension consumption. -/// -/// This file belongs ONLY to the main app target. -enum LAThresholdSync { - static func syncToAppGroup() { - LAAppGroupSettings.setThresholds( - lowMgdl: Storage.shared.lowLine.value, - highMgdl: Storage.shared.highLine.value - ) - } -} diff --git a/LoopFollow/LiveActivity/PreferredGlucoseUnit.swift b/LoopFollow/LiveActivity/PreferredGlucoseUnit.swift index f9e40468e..eb26b9b54 100644 --- a/LoopFollow/LiveActivity/PreferredGlucoseUnit.swift +++ b/LoopFollow/LiveActivity/PreferredGlucoseUnit.swift @@ -6,15 +6,8 @@ import HealthKit enum PreferredGlucoseUnit { /// LoopFollow’s existing source of truth for unit selection. - /// NOTE: Do not duplicate the string constant elsewhere—keep it here. static func hkUnit() -> HKUnit { - let unitString = Storage.shared.units.value - switch unitString { - case "mmol/L": - return .millimolesPerLiter - default: - return .milligramsPerDeciliter - } + Localizer.getPreferredUnit() } /// Maps HKUnit -> GlucoseSnapshot.Unit (our cross-platform enum). diff --git a/LoopFollowLAExtension/LoopFollowLiveActivity.swift b/LoopFollowLAExtension/LoopFollowLiveActivity.swift index 55342bc22..cca77be83 100644 --- a/LoopFollowLAExtension/LoopFollowLiveActivity.swift +++ b/LoopFollowLAExtension/LoopFollowLiveActivity.swift @@ -286,18 +286,41 @@ private struct DynamicIslandMinimalView: View { // MARK: - Formatting private enum LAFormat { - // MARK: Glucose + // MARK: - NumberFormatters (locale-aware) + + private static let mgdlFormatter: NumberFormatter = { + let nf = NumberFormatter() + nf.numberStyle = .decimal + nf.maximumFractionDigits = 0 + nf.locale = .current + return nf + }() - static func glucose(_ s: GlucoseSnapshot) -> String { - switch s.unit { + private static let mmolFormatter: NumberFormatter = { + let nf = NumberFormatter() + nf.numberStyle = .decimal + nf.minimumFractionDigits = 1 + nf.maximumFractionDigits = 1 + nf.locale = .current + return nf + }() + + private static func formatGlucoseValue(_ mgdl: Double, unit: GlucoseSnapshot.Unit) -> String { + switch unit { case .mgdl: - return String(Int(round(s.glucose))) + return mgdlFormatter.string(from: NSNumber(value: round(mgdl))) ?? "\(Int(round(mgdl)))" case .mmol: - // 1 decimal always (contract: clinical, consistent) - return String(format: "%.1f", s.glucose) + let mmol = GlucoseConversion.toMmol(mgdl) + return mmolFormatter.string(from: NSNumber(value: mmol)) ?? String(format: "%.1f", mmol) } } + // MARK: Glucose + + static func glucose(_ s: GlucoseSnapshot) -> String { + formatGlucoseValue(s.glucose, unit: s.unit) + } + static func delta(_ s: GlucoseSnapshot) -> String { switch s.unit { case .mgdl: @@ -306,21 +329,23 @@ private enum LAFormat { return v > 0 ? "+\(v)" : "\(v)" case .mmol: - // Treat tiny fluctuations as 0.0 to avoid “+0.0” noise - let d = (abs(s.delta) < 0.05) ? 0.0 : s.delta - if d == 0 { return "0.0" } - return d > 0 ? String(format: "+%.1f", d) : String(format: "%.1f", d) + let mmol = GlucoseConversion.toMmol(s.delta) + let d = (abs(mmol) < 0.05) ? 0.0 : mmol + if d == 0 { return mmolFormatter.string(from: 0) ?? "0.0" } + let formatted = mmolFormatter.string(from: NSNumber(value: abs(d))) ?? String(format: "%.1f", abs(d)) + return d > 0 ? "+\(formatted)" : "-\(formatted)" } } // MARK: Trend static func trendArrow(_ s: GlucoseSnapshot) -> String { - // Map to the common clinical arrows; keep unknown as a neutral dash. switch s.trend { case .upFast: return "↑↑" case .up: return "↑" + case .upSlight: return "↗" case .flat: return "→" + case .downSlight: return "↘︎" case .down: return "↓" case .downFast: return "↓↓" case .unknown: return "–" @@ -331,24 +356,17 @@ private enum LAFormat { static func iob(_ s: GlucoseSnapshot) -> String { guard let v = s.iob else { return "—" } - // Contract-friendly: one decimal, no unit suffix return String(format: "%.1f", v) } static func cob(_ s: GlucoseSnapshot) -> String { guard let v = s.cob else { return "—" } - // Contract-friendly: whole grams return String(Int(round(v))) } static func projected(_ s: GlucoseSnapshot) -> String { guard let v = s.projected else { return "—" } - switch s.unit { - case .mgdl: - return String(Int(round(v))) - case .mmol: - return String(format: "%.1f", v) - } + return formatGlucoseValue(v, unit: s.unit) } // MARK: Update time @@ -382,7 +400,7 @@ private enum LAFormat { private enum LAColors { static func backgroundTint(for snapshot: GlucoseSnapshot) -> Color { - let mgdl = toMgdl(snapshot) + let mgdl = snapshot.glucose let t = LAAppGroupSettings.thresholdsMgdl() let low = t.low @@ -399,13 +417,12 @@ private enum LAColors { return Color(uiColor: UIColor.systemOrange).opacity(opacity) } else { - // In range: fixed at your existing value return Color(uiColor: UIColor.systemGreen).opacity(0.36) } } static func keyline(for snapshot: GlucoseSnapshot) -> Color { - let mgdl = toMgdl(snapshot) + let mgdl = snapshot.glucose let t = LAAppGroupSettings.thresholdsMgdl() let low = t.low @@ -419,14 +436,4 @@ private enum LAColors { return Color(uiColor: UIColor.systemGreen) } } - - private static func toMgdl(_ snapshot: GlucoseSnapshot) -> Double { - switch snapshot.unit { - case .mgdl: - return snapshot.glucose - case .mmol: - // Convert mmol/L → mg/dL for threshold comparison - return GlucoseUnitConversion.convertGlucose(snapshot.glucose, from: .mmol, to: .mgdl) - } - } } diff --git a/docs/LiveActivity.md b/docs/LiveActivity.md index 651b80086..979213a96 100644 --- a/docs/LiveActivity.md +++ b/docs/LiveActivity.md @@ -89,7 +89,6 @@ Because `liveactivitiesd` receives the update via APNs rather than via an inter- | `GlucoseSnapshotBuilder.swift` | Pure data transformation — builds `GlucoseSnapshot` from storage | | `StorageCurrentGlucoseStateProvider.swift` | Thin abstraction over `Storage.shared` and `Observable.shared` | | `GlucoseSnapshotStore.swift` | App Group persistence — saves/loads latest snapshot | -| `LAThresholdSync.swift` | Reads threshold config from Storage for widget color | | `PreferredGlucoseUnit.swift` | Reads user unit preference, converts mg/dL ↔ mmol/L | | `APNSClient.swift` | Sends APNs self-push with Live Activity content state | | `APNSJWTGenerator.swift` | Generates ES256-signed JWT for APNs authentication | @@ -100,7 +99,7 @@ Because `liveactivitiesd` receives the update via APNs rather than via an inter- |---|---| | `GlucoseLiveActivityAttributes.swift` | ActivityKit attributes and content state definition | | `GlucoseSnapshot.swift` | Canonical cross-platform glucose data struct | -| `GlucoseUnitConversion.swift` | Unit conversion helpers | +| `GlucoseConversion.swift` | Single source of truth for mg/dL ↔ mmol/L conversion | | `LAAppGroupSettings.swift` | App Group UserDefaults access | | `AppGroupID.swift` | Derives App Group ID dynamically from bundle identifier | From 63326d87c7380cf930e895ed9e7ad6a166478a87 Mon Sep 17 00:00:00 2001 From: MtlPhil <76601115+MtlPhil@users.noreply.github.com> Date: Thu, 12 Mar 2026 21:40:01 -0400 Subject: [PATCH 07/82] feat: Live Activity auto-renewal to work around 8-hour system limit Co-Authored-By: Claude Sonnet 4.6 --- .../LiveActivity/LiveActivityManager.swift | 56 +++++++++++++++++++ LoopFollow/Storage/Storage.swift | 3 + 2 files changed, 59 insertions(+) diff --git a/LoopFollow/LiveActivity/LiveActivityManager.swift b/LoopFollow/LiveActivity/LiveActivityManager.swift index b342711a7..c79468845 100644 --- a/LoopFollow/LiveActivity/LiveActivityManager.swift +++ b/LoopFollow/LiveActivity/LiveActivityManager.swift @@ -13,6 +13,8 @@ final class LiveActivityManager { static let shared = LiveActivityManager() private init() {} + private static let renewalThreshold: TimeInterval = 7.5 * 3600 + private(set) var current: Activity? private var stateObserverTask: Task? private var updateTask: Task? @@ -61,6 +63,7 @@ final class LiveActivityManager { let activity = try Activity.request(attributes: attributes, content: content, pushType: .token) bind(to: activity, logReason: "start-new") + Storage.shared.laRenewBy.value = Date().timeIntervalSince1970 + LiveActivityManager.renewalThreshold LogManager.shared.log(category: .general, message: "Live Activity started id=\(activity.id)") } catch { LogManager.shared.log(category: .general, message: "Live Activity failed to start: \(error)") @@ -98,11 +101,13 @@ final class LiveActivityManager { if current?.id == activity.id { current = nil + Storage.shared.laRenewBy.value = 0 } } } func startFromCurrentState() { + endOrphanedActivities() let provider = StorageCurrentGlucoseStateProvider() if let snapshot = GlucoseSnapshotBuilder.build(from: provider) { LAAppGroupSettings.setThresholds( @@ -123,6 +128,40 @@ final class LiveActivityManager { DispatchQueue.main.asyncAfter(deadline: .now() + 5.0, execute: workItem) } + // MARK: - Renewal + + /// Ends the current Live Activity immediately and re-requests a fresh one, + /// working around Apple's 8-hour maximum LA lifetime. + /// Returns true if renewal was performed (caller should return early). + private func renewIfNeeded(snapshot: GlucoseSnapshot) -> Bool { + guard let activity = current else { return false } + + let renewBy = Storage.shared.laRenewBy.value + guard renewBy > 0, Date().timeIntervalSince1970 >= renewBy else { return false } + + LogManager.shared.log(category: .general, message: "[LA] renewal deadline passed, renewing") + + // Clear our reference before re-requesting so startIfNeeded() creates a fresh one + current = nil + updateTask?.cancel() + updateTask = nil + tokenObservationTask?.cancel() + tokenObservationTask = nil + stateObserverTask?.cancel() + stateObserverTask = nil + pushToken = nil + + Task { + // .immediate clears the stale Lock Screen card before the new one appears + await activity.end(nil, dismissalPolicy: .immediate) + await MainActor.run { + self.startFromCurrentState() + } + } + + return true + } + private func performRefresh(reason: String) { let provider = StorageCurrentGlucoseStateProvider() guard let snapshot = GlucoseSnapshotBuilder.build(from: provider) else { @@ -134,6 +173,10 @@ final class LiveActivityManager { "at=\(snapshot.updatedAt.timeIntervalSince1970) iob=\(snapshot.iob?.description ?? "nil") " + "cob=\(snapshot.cob?.description ?? "nil") proj=\(snapshot.projected?.description ?? "nil") u=\(snapshot.unit.rawValue)" LogManager.shared.log(category: .general, message: "[LA] snapshot \(fingerprint) reason=\(reason)", isDebug: true) + + // Check if the Live Activity is approaching Apple's 8-hour limit and renew if so. + if renewIfNeeded(snapshot: snapshot) { return } + let now = Date() let timeSinceLastUpdate = now.timeIntervalSince(lastUpdateTime ?? .distantPast) let forceRefreshNeeded = timeSinceLastUpdate >= 5 * 60 @@ -238,6 +281,19 @@ final class LiveActivityManager { // MARK: - Binding / Lifecycle + /// Ends any Live Activities of this type that are not the one currently tracked. + /// Called on app launch to clean up cards left behind by a previous crash. + private func endOrphanedActivities() { + for activity in Activity.activities { + guard activity.id != current?.id else { continue } + let orphanID = activity.id + Task { + await activity.end(nil, dismissalPolicy: .immediate) + LogManager.shared.log(category: .general, message: "Ended orphaned Live Activity id=\(orphanID)") + } + } + } + private func bind(to activity: Activity, logReason: String) { if current?.id == activity.id { return } current = activity diff --git a/LoopFollow/Storage/Storage.swift b/LoopFollow/Storage/Storage.swift index dc0c8a282..cfc0249ec 100644 --- a/LoopFollow/Storage/Storage.swift +++ b/LoopFollow/Storage/Storage.swift @@ -91,6 +91,9 @@ class Storage { var lastCOB = StorageValue(key: "lastCOB", defaultValue: nil) var projectedBgMgdl = StorageValue(key: "projectedBgMgdl", defaultValue: nil) + // Live Activity renewal + var laRenewBy = StorageValue(key: "laRenewBy", defaultValue: 0) + // Graph Settings [BEGIN] var showDots = StorageValue(key: "showDots", defaultValue: true) var showLines = StorageValue(key: "showLines", defaultValue: true) From a020c8f08ccd336fbbb3a41b439d97d8e534096c Mon Sep 17 00:00:00 2001 From: MtlPhil <76601115+MtlPhil@users.noreply.github.com> Date: Thu, 12 Mar 2026 21:55:48 -0400 Subject: [PATCH 08/82] test: reduce LA renewal threshold to 20 min for testing Co-Authored-By: Claude Sonnet 4.6 --- LoopFollow/LiveActivity/LiveActivityManager.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/LoopFollow/LiveActivity/LiveActivityManager.swift b/LoopFollow/LiveActivity/LiveActivityManager.swift index c79468845..feba7cfb1 100644 --- a/LoopFollow/LiveActivity/LiveActivityManager.swift +++ b/LoopFollow/LiveActivity/LiveActivityManager.swift @@ -13,7 +13,7 @@ final class LiveActivityManager { static let shared = LiveActivityManager() private init() {} - private static let renewalThreshold: TimeInterval = 7.5 * 3600 + private static let renewalThreshold: TimeInterval = 20 * 60 private(set) var current: Activity? private var stateObserverTask: Task? @@ -156,6 +156,7 @@ final class LiveActivityManager { await activity.end(nil, dismissalPolicy: .immediate) await MainActor.run { self.startFromCurrentState() + LogManager.shared.log(category: .general, message: "[LA] Live Activity renewed successfully") } } From bae228d6da6c07b292f720704c24c40694b1cf58 Mon Sep 17 00:00:00 2001 From: MtlPhil <76601115+MtlPhil@users.noreply.github.com> Date: Thu, 12 Mar 2026 22:42:36 -0400 Subject: [PATCH 09/82] feat: improve LA renewal robustness and stale indicator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - staleDate on every ActivityContent now tracks the renewal deadline, so the system shows Apple's built-in stale indicator if renewal fails - Add laRenewalFailed StorageValue; set on Activity.request() failure, cleared on any successful LA start - Observe willEnterForegroundNotification: retry startIfNeeded() if a previous renewal attempt failed - New-first renewal order: request the replacement LA before ending the old one — if the request throws the existing LA stays alive so the user keeps live data until the system kills it at the 8-hour mark Co-Authored-By: Claude Sonnet 4.6 --- .../LiveActivity/LiveActivityManager.swift | 84 +++++++++++++------ LoopFollow/Storage/Storage.swift | 1 + 2 files changed, 60 insertions(+), 25 deletions(-) diff --git a/LoopFollow/LiveActivity/LiveActivityManager.swift b/LoopFollow/LiveActivity/LiveActivityManager.swift index feba7cfb1..f7aee54b8 100644 --- a/LoopFollow/LiveActivity/LiveActivityManager.swift +++ b/LoopFollow/LiveActivity/LiveActivityManager.swift @@ -11,7 +11,20 @@ import UIKit @available(iOS 16.1, *) final class LiveActivityManager { static let shared = LiveActivityManager() - private init() {} + private init() { + NotificationCenter.default.addObserver( + self, + selector: #selector(handleForeground), + name: UIApplication.willEnterForegroundNotification, + object: nil + ) + } + + @objc private func handleForeground() { + guard Storage.shared.laRenewalFailed.value else { return } + LogManager.shared.log(category: .general, message: "[LA] retrying Live Activity start after previous renewal failure") + startIfNeeded() + } private static let renewalThreshold: TimeInterval = 20 * 60 @@ -34,6 +47,7 @@ final class LiveActivityManager { if let existing = Activity.activities.first { bind(to: existing, logReason: "reuse") + Storage.shared.laRenewalFailed.value = false return } @@ -59,11 +73,13 @@ final class LiveActivityManager { producedAt: Date() ) - let content = ActivityContent(state: initialState, staleDate: Date().addingTimeInterval(15 * 60)) + let renewDeadline = Date().addingTimeInterval(LiveActivityManager.renewalThreshold) + let content = ActivityContent(state: initialState, staleDate: renewDeadline) let activity = try Activity.request(attributes: attributes, content: content, pushType: .token) bind(to: activity, logReason: "start-new") - Storage.shared.laRenewBy.value = Date().timeIntervalSince1970 + LiveActivityManager.renewalThreshold + Storage.shared.laRenewBy.value = renewDeadline.timeIntervalSince1970 + Storage.shared.laRenewalFailed.value = false LogManager.shared.log(category: .general, message: "Live Activity started id=\(activity.id)") } catch { LogManager.shared.log(category: .general, message: "Live Activity failed to start: \(error)") @@ -130,37 +146,55 @@ final class LiveActivityManager { // MARK: - Renewal - /// Ends the current Live Activity immediately and re-requests a fresh one, - /// working around Apple's 8-hour maximum LA lifetime. + /// Requests a fresh Live Activity to replace the current one when the renewal + /// deadline has passed, working around Apple's 8-hour maximum LA lifetime. + /// The new LA is requested FIRST — the old one is only ended if that succeeds, + /// so the user keeps live data if Activity.request() throws. /// Returns true if renewal was performed (caller should return early). private func renewIfNeeded(snapshot: GlucoseSnapshot) -> Bool { - guard let activity = current else { return false } + guard let oldActivity = current else { return false } let renewBy = Storage.shared.laRenewBy.value guard renewBy > 0, Date().timeIntervalSince1970 >= renewBy else { return false } - LogManager.shared.log(category: .general, message: "[LA] renewal deadline passed, renewing") + LogManager.shared.log(category: .general, message: "[LA] renewal deadline passed, requesting new LA") - // Clear our reference before re-requesting so startIfNeeded() creates a fresh one - current = nil - updateTask?.cancel() - updateTask = nil - tokenObservationTask?.cancel() - tokenObservationTask = nil - stateObserverTask?.cancel() - stateObserverTask = nil - pushToken = nil + let renewDeadline = Date().addingTimeInterval(LiveActivityManager.renewalThreshold) + let attributes = GlucoseLiveActivityAttributes(title: "LoopFollow") + let state = GlucoseLiveActivityAttributes.ContentState( + snapshot: snapshot, + seq: seq, + reason: "renew", + producedAt: Date() + ) + let content = ActivityContent(state: state, staleDate: renewDeadline) - Task { - // .immediate clears the stale Lock Screen card before the new one appears - await activity.end(nil, dismissalPolicy: .immediate) - await MainActor.run { - self.startFromCurrentState() - LogManager.shared.log(category: .general, message: "[LA] Live Activity renewed successfully") + do { + let newActivity = try Activity.request(attributes: attributes, content: content, pushType: .token) + + // New LA is live — now it's safe to remove the old card. + Task { + await oldActivity.end(nil, dismissalPolicy: .immediate) } - } - return true + updateTask?.cancel() + updateTask = nil + tokenObservationTask?.cancel() + tokenObservationTask = nil + stateObserverTask?.cancel() + stateObserverTask = nil + pushToken = nil + + bind(to: newActivity, logReason: "renew") + Storage.shared.laRenewBy.value = renewDeadline.timeIntervalSince1970 + Storage.shared.laRenewalFailed.value = false + LogManager.shared.log(category: .general, message: "[LA] Live Activity renewed successfully id=\(newActivity.id)") + return true + } catch { + Storage.shared.laRenewalFailed.value = true + LogManager.shared.log(category: .general, message: "[LA] renewal failed, keeping existing LA: \(error)") + return false + } } private func performRefresh(reason: String) { @@ -244,7 +278,7 @@ final class LiveActivityManager { let content = ActivityContent( state: state, - staleDate: Date().addingTimeInterval(15 * 60), + staleDate: Date(timeIntervalSince1970: Storage.shared.laRenewBy.value), relevanceScore: 100.0 ) diff --git a/LoopFollow/Storage/Storage.swift b/LoopFollow/Storage/Storage.swift index cfc0249ec..7de9ac7e7 100644 --- a/LoopFollow/Storage/Storage.swift +++ b/LoopFollow/Storage/Storage.swift @@ -93,6 +93,7 @@ class Storage { // Live Activity renewal var laRenewBy = StorageValue(key: "laRenewBy", defaultValue: 0) + var laRenewalFailed = StorageValue(key: "laRenewalFailed", defaultValue: false) // Graph Settings [BEGIN] var showDots = StorageValue(key: "showDots", defaultValue: true) From 2785502867d7450dda3f98755b2b4119bb74a9ab Mon Sep 17 00:00:00 2001 From: MtlPhil <76601115+MtlPhil@users.noreply.github.com> Date: Thu, 12 Mar 2026 23:42:47 -0400 Subject: [PATCH 10/82] feat: renewal warning overlay + restore 7.5h threshold - Restore renewalThreshold to 7.5 * 3600 (testing complete) - Add showRenewalOverlay: Bool to GlucoseSnapshot (Codable, default false) - GlucoseSnapshotBuilder sets it true when now >= laRenewBy - 1800 (30 minutes before the renewal deadline) - Lock screen: 60% gray overlay with "Tap to update" centered in white, layered above the existing isNotLooping overlay - DI expanded: RenewalOverlayView applied to leading/trailing/bottom regions; "Tap to update" text shown on the bottom region only - showRenewalOverlay resets to false automatically on renewal since laRenewBy is updated and the next snapshot rebuild clears the flag Co-Authored-By: Claude Sonnet 4.6 --- LoopFollow/LiveActivity/GlucoseSnapshot.swift | 14 ++++++-- .../LiveActivity/GlucoseSnapshotBuilder.swift | 8 ++++- .../LiveActivity/LiveActivityManager.swift | 2 +- .../LoopFollowLiveActivity.swift | 36 +++++++++++++++++++ 4 files changed, 56 insertions(+), 4 deletions(-) diff --git a/LoopFollow/LiveActivity/GlucoseSnapshot.swift b/LoopFollow/LiveActivity/GlucoseSnapshot.swift index 934f44eac..1e573cba6 100644 --- a/LoopFollow/LiveActivity/GlucoseSnapshot.swift +++ b/LoopFollow/LiveActivity/GlucoseSnapshot.swift @@ -50,6 +50,12 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable { /// True when LoopFollow detects the loop has not reported in 15+ minutes (Nightscout only). let isNotLooping: Bool + // MARK: - Renewal + + /// True when the Live Activity is within 30 minutes of its renewal deadline. + /// The extension renders a "Tap to update" overlay so the user knows renewal is imminent. + let showRenewalOverlay: Bool + init( glucose: Double, delta: Double, @@ -59,7 +65,8 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable { cob: Double?, projected: Double?, unit: Unit, - isNotLooping: Bool + isNotLooping: Bool, + showRenewalOverlay: Bool = false ) { self.glucose = glucose self.delta = delta @@ -70,6 +77,7 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable { self.projected = projected self.unit = unit self.isNotLooping = isNotLooping + self.showRenewalOverlay = showRenewalOverlay } func encode(to encoder: Encoder) throws { @@ -83,10 +91,11 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable { try container.encodeIfPresent(projected, forKey: .projected) try container.encode(unit, forKey: .unit) try container.encode(isNotLooping, forKey: .isNotLooping) + try container.encode(showRenewalOverlay, forKey: .showRenewalOverlay) } private enum CodingKeys: String, CodingKey { - case glucose, delta, trend, updatedAt, iob, cob, projected, unit, isNotLooping + case glucose, delta, trend, updatedAt, iob, cob, projected, unit, isNotLooping, showRenewalOverlay } // MARK: - Codable @@ -102,6 +111,7 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable { projected = try container.decodeIfPresent(Double.self, forKey: .projected) unit = try container.decode(Unit.self, forKey: .unit) isNotLooping = try container.decodeIfPresent(Bool.self, forKey: .isNotLooping) ?? false + showRenewalOverlay = try container.decodeIfPresent(Bool.self, forKey: .showRenewalOverlay) ?? false } // MARK: - Derived Convenience diff --git a/LoopFollow/LiveActivity/GlucoseSnapshotBuilder.swift b/LoopFollow/LiveActivity/GlucoseSnapshotBuilder.swift index ad5b93dae..862d465b4 100644 --- a/LoopFollow/LiveActivity/GlucoseSnapshotBuilder.swift +++ b/LoopFollow/LiveActivity/GlucoseSnapshotBuilder.swift @@ -53,6 +53,11 @@ enum GlucoseSnapshotBuilder { // Not Looping — read from Observable, set by evaluateNotLooping() in DeviceStatus.swift let isNotLooping = Observable.shared.isNotLooping.value + // Renewal overlay — show 30 minutes before the renewal deadline so the user + // knows the LA is about to be replaced. + let renewBy = Storage.shared.laRenewBy.value + let showRenewalOverlay = renewBy > 0 && Date().timeIntervalSince1970 >= renewBy - 1800 + LogManager.shared.log( category: .general, message: "LA snapshot built: updatedAt=\(updatedAt) interval=\(updatedAt.timeIntervalSince1970)", @@ -68,7 +73,8 @@ enum GlucoseSnapshotBuilder { cob: provider.cob, projected: provider.projectedMgdl, unit: preferredUnit, - isNotLooping: isNotLooping + isNotLooping: isNotLooping, + showRenewalOverlay: showRenewalOverlay ) } diff --git a/LoopFollow/LiveActivity/LiveActivityManager.swift b/LoopFollow/LiveActivity/LiveActivityManager.swift index f7aee54b8..400d4f58c 100644 --- a/LoopFollow/LiveActivity/LiveActivityManager.swift +++ b/LoopFollow/LiveActivity/LiveActivityManager.swift @@ -26,7 +26,7 @@ final class LiveActivityManager { startIfNeeded() } - private static let renewalThreshold: TimeInterval = 20 * 60 + private static let renewalThreshold: TimeInterval = 7.5 * 3600 private(set) var current: Activity? private var stateObserverTask: Task? diff --git a/LoopFollowLAExtension/LoopFollowLiveActivity.swift b/LoopFollowLAExtension/LoopFollowLiveActivity.swift index cca77be83..cba942ea2 100644 --- a/LoopFollowLAExtension/LoopFollowLiveActivity.swift +++ b/LoopFollowLAExtension/LoopFollowLiveActivity.swift @@ -21,14 +21,17 @@ struct LoopFollowLiveActivityWidget: Widget { DynamicIslandExpandedRegion(.leading) { DynamicIslandLeadingView(snapshot: context.state.snapshot) .id(context.state.seq) + .overlay(RenewalOverlayView(show: context.state.snapshot.showRenewalOverlay)) } DynamicIslandExpandedRegion(.trailing) { DynamicIslandTrailingView(snapshot: context.state.snapshot) .id(context.state.seq) + .overlay(RenewalOverlayView(show: context.state.snapshot.showRenewalOverlay)) } DynamicIslandExpandedRegion(.bottom) { DynamicIslandBottomView(snapshot: context.state.snapshot) .id(context.state.seq) + .overlay(RenewalOverlayView(show: context.state.snapshot.showRenewalOverlay, showText: true)) } } compactLeading: { DynamicIslandCompactLeadingView(snapshot: context.state.snapshot) @@ -130,6 +133,39 @@ private struct LockScreenLiveActivityView: View { } } ) + .overlay( + Group { + if state.snapshot.showRenewalOverlay { + ZStack { + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill(Color.gray.opacity(0.6)) + Text("Tap to update") + .font(.system(size: 20, weight: .semibold)) + .foregroundStyle(.white) + } + } + } + ) + } +} + +/// Full-size gray overlay shown 30 minutes before the LA renewal deadline. +/// Applied to both the lock screen view and each expanded Dynamic Island region. +private struct RenewalOverlayView: View { + let show: Bool + var showText: Bool = false + + var body: some View { + if show { + ZStack { + Color.gray.opacity(0.6) + if showText { + Text("Tap to update") + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(.white) + } + } + } } } From 0250633f4f949f1ca2446de8f7454cf643684f6d Mon Sep 17 00:00:00 2001 From: MtlPhil <76601115+MtlPhil@users.noreply.github.com> Date: Fri, 13 Mar 2026 08:17:08 -0400 Subject: [PATCH 11/82] fix: overlay not appearing + foreground restart not working MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Overlay rendering: - Replace Group{if condition{ZStack{RoundedRectangle...}}} with a permanently-present ZStack toggled via .opacity(). The Group/if pattern causes SwiftUI sizing ambiguity when the condition transitions from false→true inside an .overlay(), producing a zero-size result. The .opacity() approach keeps a stable view hierarchy. - Same fix applied to RenewalOverlayView used on DI expanded regions. Foreground restart: - handleForeground() was calling startIfNeeded(), which finds the still-alive (failed-to-renew) LA in Activity.activities and reuses it, doing nothing useful. Fixed to manually nil out current, cancel all tasks, await activity.end(.immediate), then startFromCurrentState(). Overlay timing: - Changed warning window from 30 min (1800s) to 20 min (1200s) before the renewal deadline, matching the intended test cadence. Logging: - handleForeground: log on every foreground event with laRenewalFailed value - renewIfNeeded: log how many seconds past the deadline when firing - GlucoseSnapshotBuilder: log when overlay activates with seconds to deadline - performRefresh: log when sending an update with the overlay visible Co-Authored-By: Claude Sonnet 4.6 --- .../LiveActivity/GlucoseSnapshotBuilder.swift | 10 ++++-- .../LiveActivity/LiveActivityManager.swift | 34 +++++++++++++++++-- .../LoopFollowLiveActivity.swift | 32 ++++++++--------- 3 files changed, 53 insertions(+), 23 deletions(-) diff --git a/LoopFollow/LiveActivity/GlucoseSnapshotBuilder.swift b/LoopFollow/LiveActivity/GlucoseSnapshotBuilder.swift index 862d465b4..db0945d30 100644 --- a/LoopFollow/LiveActivity/GlucoseSnapshotBuilder.swift +++ b/LoopFollow/LiveActivity/GlucoseSnapshotBuilder.swift @@ -53,10 +53,16 @@ enum GlucoseSnapshotBuilder { // Not Looping — read from Observable, set by evaluateNotLooping() in DeviceStatus.swift let isNotLooping = Observable.shared.isNotLooping.value - // Renewal overlay — show 30 minutes before the renewal deadline so the user + // Renewal overlay — show 20 minutes before the renewal deadline so the user // knows the LA is about to be replaced. let renewBy = Storage.shared.laRenewBy.value - let showRenewalOverlay = renewBy > 0 && Date().timeIntervalSince1970 >= renewBy - 1800 + let now = Date().timeIntervalSince1970 + let showRenewalOverlay = renewBy > 0 && now >= renewBy - 1200 + + if showRenewalOverlay { + let timeLeft = max(renewBy - now, 0) + LogManager.shared.log(category: .general, message: "[LA] renewal overlay ON — \(Int(timeLeft))s until deadline") + } LogManager.shared.log( category: .general, diff --git a/LoopFollow/LiveActivity/LiveActivityManager.swift b/LoopFollow/LiveActivity/LiveActivityManager.swift index 400d4f58c..6049f1016 100644 --- a/LoopFollow/LiveActivity/LiveActivityManager.swift +++ b/LoopFollow/LiveActivity/LiveActivityManager.swift @@ -21,9 +21,32 @@ final class LiveActivityManager { } @objc private func handleForeground() { + LogManager.shared.log(category: .general, message: "[LA] foreground notification received, laRenewalFailed=\(Storage.shared.laRenewalFailed.value)") guard Storage.shared.laRenewalFailed.value else { return } - LogManager.shared.log(category: .general, message: "[LA] retrying Live Activity start after previous renewal failure") - startIfNeeded() + + // Renewal previously failed — end the stale LA and start a fresh one. + // We cannot call startIfNeeded() here: it finds the existing activity in + // Activity.activities and reuses it rather than replacing it. + LogManager.shared.log(category: .general, message: "[LA] ending stale LA and restarting after renewal failure") + if let activity = current { + current = nil + updateTask?.cancel() + updateTask = nil + tokenObservationTask?.cancel() + tokenObservationTask = nil + stateObserverTask?.cancel() + stateObserverTask = nil + pushToken = nil + Task { + await activity.end(nil, dismissalPolicy: .immediate) + await MainActor.run { + self.startFromCurrentState() + LogManager.shared.log(category: .general, message: "[LA] Live Activity restarted after foreground retry") + } + } + } else { + startFromCurrentState() + } } private static let renewalThreshold: TimeInterval = 7.5 * 3600 @@ -157,7 +180,8 @@ final class LiveActivityManager { let renewBy = Storage.shared.laRenewBy.value guard renewBy > 0, Date().timeIntervalSince1970 >= renewBy else { return false } - LogManager.shared.log(category: .general, message: "[LA] renewal deadline passed, requesting new LA") + let overdueBy = Date().timeIntervalSince1970 - renewBy + LogManager.shared.log(category: .general, message: "[LA] renewal deadline passed by \(Int(overdueBy))s, requesting new LA") let renewDeadline = Date().addingTimeInterval(LiveActivityManager.renewalThreshold) let attributes = GlucoseLiveActivityAttributes(title: "LoopFollow") @@ -212,6 +236,10 @@ final class LiveActivityManager { // Check if the Live Activity is approaching Apple's 8-hour limit and renew if so. if renewIfNeeded(snapshot: snapshot) { return } + if snapshot.showRenewalOverlay { + LogManager.shared.log(category: .general, message: "[LA] sending update with renewal overlay visible") + } + let now = Date() let timeSinceLastUpdate = now.timeIntervalSince(lastUpdateTime ?? .distantPast) let forceRefreshNeeded = timeSinceLastUpdate >= 5 * 60 diff --git a/LoopFollowLAExtension/LoopFollowLiveActivity.swift b/LoopFollowLAExtension/LoopFollowLiveActivity.swift index cba942ea2..2ef72f6fe 100644 --- a/LoopFollowLAExtension/LoopFollowLiveActivity.swift +++ b/LoopFollowLAExtension/LoopFollowLiveActivity.swift @@ -134,17 +134,14 @@ private struct LockScreenLiveActivityView: View { } ) .overlay( - Group { - if state.snapshot.showRenewalOverlay { - ZStack { - RoundedRectangle(cornerRadius: 16, style: .continuous) - .fill(Color.gray.opacity(0.6)) - Text("Tap to update") - .font(.system(size: 20, weight: .semibold)) - .foregroundStyle(.white) - } - } + ZStack { + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill(Color.gray.opacity(0.6)) + Text("Tap to update") + .font(.system(size: 20, weight: .semibold)) + .foregroundStyle(.white) } + .opacity(state.snapshot.showRenewalOverlay ? 1 : 0) ) } } @@ -156,16 +153,15 @@ private struct RenewalOverlayView: View { var showText: Bool = false var body: some View { - if show { - ZStack { - Color.gray.opacity(0.6) - if showText { - Text("Tap to update") - .font(.system(size: 14, weight: .semibold)) - .foregroundStyle(.white) - } + ZStack { + Color.gray.opacity(0.6) + if showText { + Text("Tap to update") + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(.white) } } + .opacity(show ? 1 : 0) } } From 4e48c45108f49976232945fa8a1f013fd1cd156b Mon Sep 17 00:00:00 2001 From: MtlPhil <76601115+MtlPhil@users.noreply.github.com> Date: Fri, 13 Mar 2026 08:18:57 -0400 Subject: [PATCH 12/82] test: set renewalThreshold to 20 min for testing Co-Authored-By: Claude Sonnet 4.6 --- LoopFollow/LiveActivity/LiveActivityManager.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/LoopFollow/LiveActivity/LiveActivityManager.swift b/LoopFollow/LiveActivity/LiveActivityManager.swift index 6049f1016..d4695f096 100644 --- a/LoopFollow/LiveActivity/LiveActivityManager.swift +++ b/LoopFollow/LiveActivity/LiveActivityManager.swift @@ -49,7 +49,7 @@ final class LiveActivityManager { } } - private static let renewalThreshold: TimeInterval = 7.5 * 3600 + private static let renewalThreshold: TimeInterval = 20 * 60 private(set) var current: Activity? private var stateObserverTask: Task? From 136dba040bb35aecea1412aa021413a8b000c450 Mon Sep 17 00:00:00 2001 From: MtlPhil <76601115+MtlPhil@users.noreply.github.com> Date: Fri, 13 Mar 2026 08:59:21 -0400 Subject: [PATCH 13/82] fix: renewal overlay not clearing after LA is refreshed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Normal renewal path (renewIfNeeded success): - Build a fresh snapshot with showRenewalOverlay: false for the new LA's initial content — it has a new deadline so the overlay should never be visible from the first frame. - Save that fresh snapshot to GlucoseSnapshotStore so the next duplicate check has the correct baseline and doesn't suppress the first real BG update. Foreground restart path (handleForeground): - Zero laRenewBy before calling startFromCurrentState() so GlucoseSnapshotBuilder computes showRenewalOverlay = false for the seed snapshot. startIfNeeded() then sets the new deadline after the request succeeds. Co-Authored-By: Claude Sonnet 4.6 --- .../LiveActivity/LiveActivityManager.swift | 22 ++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/LoopFollow/LiveActivity/LiveActivityManager.swift b/LoopFollow/LiveActivity/LiveActivityManager.swift index d4695f096..e86cb678b 100644 --- a/LoopFollow/LiveActivity/LiveActivityManager.swift +++ b/LoopFollow/LiveActivity/LiveActivityManager.swift @@ -40,6 +40,9 @@ final class LiveActivityManager { Task { await activity.end(nil, dismissalPolicy: .immediate) await MainActor.run { + // Clear the expired deadline before rebuilding the snapshot so + // GlucoseSnapshotBuilder computes showRenewalOverlay = false. + Storage.shared.laRenewBy.value = 0 self.startFromCurrentState() LogManager.shared.log(category: .general, message: "[LA] Live Activity restarted after foreground retry") } @@ -185,8 +188,23 @@ final class LiveActivityManager { let renewDeadline = Date().addingTimeInterval(LiveActivityManager.renewalThreshold) let attributes = GlucoseLiveActivityAttributes(title: "LoopFollow") + + // Strip the overlay flag — the new LA has a fresh deadline so it should + // open clean, without the warning visible from the first frame. + let freshSnapshot = GlucoseSnapshot( + glucose: snapshot.glucose, + delta: snapshot.delta, + trend: snapshot.trend, + updatedAt: snapshot.updatedAt, + iob: snapshot.iob, + cob: snapshot.cob, + projected: snapshot.projected, + unit: snapshot.unit, + isNotLooping: snapshot.isNotLooping, + showRenewalOverlay: false + ) let state = GlucoseLiveActivityAttributes.ContentState( - snapshot: snapshot, + snapshot: freshSnapshot, seq: seq, reason: "renew", producedAt: Date() @@ -212,6 +230,8 @@ final class LiveActivityManager { bind(to: newActivity, logReason: "renew") Storage.shared.laRenewBy.value = renewDeadline.timeIntervalSince1970 Storage.shared.laRenewalFailed.value = false + // Update the store so the next duplicate check has the correct baseline. + GlucoseSnapshotStore.shared.save(freshSnapshot) LogManager.shared.log(category: .general, message: "[LA] Live Activity renewed successfully id=\(newActivity.id)") return true } catch { From 32a6dd0b542c9f7dd7dbb141dbb6154d0006abcd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Fri, 13 Mar 2026 14:19:55 +0100 Subject: [PATCH 14/82] Fix Mac Catalyst build: guard ActivityKit code and exclude widget extension - Wrap ActivityKit-dependent files (GlucoseLiveActivityAttributes, LiveActivityManager, APNSClient) in #if !targetEnvironment(macCatalyst) - Guard LiveActivityManager call sites in MainViewController, BGData, and DeviceStatus with the same compile-time check - Remove unnecessary @available(iOS 16.1, *) checks (deployment target is already 16.6) - Add platformFilter = ios to the widget extension embed phase and target dependency so it is excluded from Mac Catalyst builds --- LoopFollow.xcodeproj/project.pbxproj | 3 ++- LoopFollow/Controllers/Nightscout/BGData.swift | 4 ++-- LoopFollow/Controllers/Nightscout/DeviceStatus.swift | 8 ++++---- LoopFollow/LiveActivity/APNSClient.swift | 5 +++++ .../LiveActivity/GlucoseLiveActivityAttributes.swift | 5 +++++ LoopFollow/LiveActivity/LiveActivityManager.swift | 6 +++++- LoopFollow/ViewControllers/MainViewController.swift | 4 ++-- 7 files changed, 25 insertions(+), 10 deletions(-) diff --git a/LoopFollow.xcodeproj/project.pbxproj b/LoopFollow.xcodeproj/project.pbxproj index d7ed09428..fb04f561a 100644 --- a/LoopFollow.xcodeproj/project.pbxproj +++ b/LoopFollow.xcodeproj/project.pbxproj @@ -24,7 +24,7 @@ 374A77B92F5BE1AC00E96858 /* PreferredGlucoseUnit.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A77B22F5BE1AC00E96858 /* PreferredGlucoseUnit.swift */; }; 37A4BDDB2F5B6B4A00EEB289 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 37A4BDDA2F5B6B4A00EEB289 /* WidgetKit.framework */; }; 37A4BDDD2F5B6B4A00EEB289 /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 37A4BDDC2F5B6B4A00EEB289 /* SwiftUI.framework */; }; - 37A4BDE82F5B6B4C00EEB289 /* LoopFollowLAExtensionExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 37A4BDD92F5B6B4A00EEB289 /* LoopFollowLAExtensionExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + 37A4BDE82F5B6B4C00EEB289 /* LoopFollowLAExtensionExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 37A4BDD92F5B6B4A00EEB289 /* LoopFollowLAExtensionExtension.appex */; platformFilter = ios; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 3F1335F351590E573D8E6962 /* Pods_LoopFollow.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A7D55B42A22051DAD69E89D0 /* Pods_LoopFollow.framework */; }; 654132E72E19EA7E00BDBE08 /* SimpleQRCodeScannerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 654132E62E19EA7E00BDBE08 /* SimpleQRCodeScannerView.swift */; }; 654132EA2E19F24800BDBE08 /* TOTPGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 654132E92E19F24800BDBE08 /* TOTPGenerator.swift */; }; @@ -2347,6 +2347,7 @@ /* Begin PBXTargetDependency section */ 37A4BDE72F5B6B4C00EEB289 /* PBXTargetDependency */ = { isa = PBXTargetDependency; + platformFilter = ios; target = 37A4BDD82F5B6B4A00EEB289 /* LoopFollowLAExtensionExtension */; targetProxy = 37A4BDE62F5B6B4C00EEB289 /* PBXContainerItemProxy */; }; diff --git a/LoopFollow/Controllers/Nightscout/BGData.swift b/LoopFollow/Controllers/Nightscout/BGData.swift index c870abe57..c0721b8a4 100644 --- a/LoopFollow/Controllers/Nightscout/BGData.swift +++ b/LoopFollow/Controllers/Nightscout/BGData.swift @@ -269,9 +269,9 @@ extension MainViewController { self.markDataLoaded("bg") // Live Activity update - if #available(iOS 16.1, *) { + #if !targetEnvironment(macCatalyst) LiveActivityManager.shared.refreshFromCurrentState(reason: "bg") - } + #endif // Update contact if Storage.shared.contactEnabled.value { diff --git a/LoopFollow/Controllers/Nightscout/DeviceStatus.swift b/LoopFollow/Controllers/Nightscout/DeviceStatus.swift index fc1739c4c..b7f88634e 100644 --- a/LoopFollow/Controllers/Nightscout/DeviceStatus.swift +++ b/LoopFollow/Controllers/Nightscout/DeviceStatus.swift @@ -56,9 +56,9 @@ extension MainViewController { LoopStatusLabel.text = "⚠️ Not Looping!" LoopStatusLabel.textColor = UIColor.systemYellow LoopStatusLabel.font = UIFont.boldSystemFont(ofSize: 18) - if #available(iOS 16.1, *) { + #if !targetEnvironment(macCatalyst) LiveActivityManager.shared.refreshFromCurrentState(reason: "notLooping") - } + #endif } else { IsNotLooping = false @@ -77,9 +77,9 @@ extension MainViewController { case .system: LoopStatusLabel.textColor = UIColor.label } - if #available(iOS 16.1, *) { + #if !targetEnvironment(macCatalyst) LiveActivityManager.shared.refreshFromCurrentState(reason: "loopingResumed") - } + #endif } } diff --git a/LoopFollow/LiveActivity/APNSClient.swift b/LoopFollow/LiveActivity/APNSClient.swift index ac2dfc782..4679989f9 100644 --- a/LoopFollow/LiveActivity/APNSClient.swift +++ b/LoopFollow/LiveActivity/APNSClient.swift @@ -1,6 +1,9 @@ // LoopFollow // APNSClient.swift +// swiftformat:disable indent +#if !targetEnvironment(macCatalyst) + import Foundation class APNSClient { @@ -126,3 +129,5 @@ class APNSClient { return try? JSONSerialization.data(withJSONObject: payload) } } + +#endif diff --git a/LoopFollow/LiveActivity/GlucoseLiveActivityAttributes.swift b/LoopFollow/LiveActivity/GlucoseLiveActivityAttributes.swift index 9d6811e56..b04768fab 100644 --- a/LoopFollow/LiveActivity/GlucoseLiveActivityAttributes.swift +++ b/LoopFollow/LiveActivity/GlucoseLiveActivityAttributes.swift @@ -1,6 +1,9 @@ // LoopFollow // GlucoseLiveActivityAttributes.swift +// swiftformat:disable indent +#if !targetEnvironment(macCatalyst) + import ActivityKit import Foundation @@ -35,3 +38,5 @@ struct GlucoseLiveActivityAttributes: ActivityAttributes { /// Reserved for future metadata. Keep minimal for stability. let title: String } + +#endif diff --git a/LoopFollow/LiveActivity/LiveActivityManager.swift b/LoopFollow/LiveActivity/LiveActivityManager.swift index b342711a7..bec4b4d4d 100644 --- a/LoopFollow/LiveActivity/LiveActivityManager.swift +++ b/LoopFollow/LiveActivity/LiveActivityManager.swift @@ -1,6 +1,9 @@ // LoopFollow // LiveActivityManager.swift +// swiftformat:disable indent +#if !targetEnvironment(macCatalyst) + @preconcurrency import ActivityKit import Foundation import os @@ -8,7 +11,6 @@ import UIKit /// Live Activity manager for LoopFollow. -@available(iOS 16.1, *) final class LiveActivityManager { static let shared = LiveActivityManager() private init() {} @@ -277,3 +279,5 @@ final class LiveActivityManager { } } } + +#endif diff --git a/LoopFollow/ViewControllers/MainViewController.swift b/LoopFollow/ViewControllers/MainViewController.swift index 9173bec9f..270b9be87 100644 --- a/LoopFollow/ViewControllers/MainViewController.swift +++ b/LoopFollow/ViewControllers/MainViewController.swift @@ -992,9 +992,9 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele @objc override func viewDidAppear(_: Bool) { showHideNSDetails() - if #available(iOS 16.1, *) { + #if !targetEnvironment(macCatalyst) LiveActivityManager.shared.startFromCurrentState() - } + #endif } func stringFromTimeInterval(interval: TimeInterval) -> String { From 921a96615068022f2230a90a2dc20856977a5822 Mon Sep 17 00:00:00 2001 From: MtlPhil <76601115+MtlPhil@users.noreply.github.com> Date: Fri, 13 Mar 2026 09:54:09 -0400 Subject: [PATCH 15/82] fix: overlay permanently active when warning window equals threshold With renewalThreshold=20min and the hardcoded 1200s warning window, renewBy-1200 = start, so showRenewalOverlay is always true from the moment the LA begins. Extract a named renewalWarning constant (5 min for testing) so the warning window is always less than the threshold. The builder now reads LiveActivityManager.renewalWarning instead of a hardcoded literal. Production values to restore before merging: renewalThreshold = 7.5 * 3600 renewalWarning = 20 * 60 Co-Authored-By: Claude Sonnet 4.6 --- LoopFollow/LiveActivity/GlucoseSnapshotBuilder.swift | 6 +++--- LoopFollow/LiveActivity/LiveActivityManager.swift | 6 +++++- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/LoopFollow/LiveActivity/GlucoseSnapshotBuilder.swift b/LoopFollow/LiveActivity/GlucoseSnapshotBuilder.swift index db0945d30..f6a1d7208 100644 --- a/LoopFollow/LiveActivity/GlucoseSnapshotBuilder.swift +++ b/LoopFollow/LiveActivity/GlucoseSnapshotBuilder.swift @@ -53,11 +53,11 @@ enum GlucoseSnapshotBuilder { // Not Looping — read from Observable, set by evaluateNotLooping() in DeviceStatus.swift let isNotLooping = Observable.shared.isNotLooping.value - // Renewal overlay — show 20 minutes before the renewal deadline so the user - // knows the LA is about to be replaced. + // Renewal overlay — show renewalWarning seconds before the renewal deadline + // so the user knows the LA is about to be replaced. let renewBy = Storage.shared.laRenewBy.value let now = Date().timeIntervalSince1970 - let showRenewalOverlay = renewBy > 0 && now >= renewBy - 1200 + let showRenewalOverlay = renewBy > 0 && now >= renewBy - LiveActivityManager.renewalWarning if showRenewalOverlay { let timeLeft = max(renewBy - now, 0) diff --git a/LoopFollow/LiveActivity/LiveActivityManager.swift b/LoopFollow/LiveActivity/LiveActivityManager.swift index e86cb678b..21590e7f7 100644 --- a/LoopFollow/LiveActivity/LiveActivityManager.swift +++ b/LoopFollow/LiveActivity/LiveActivityManager.swift @@ -52,7 +52,11 @@ final class LiveActivityManager { } } - private static let renewalThreshold: TimeInterval = 20 * 60 + // TEST VALUES — restore both to production before merging: + // renewalThreshold = 7.5 * 3600 + // renewalWarning = 20 * 60 + static let renewalThreshold: TimeInterval = 20 * 60 + static let renewalWarning: TimeInterval = 5 * 60 private(set) var current: Activity? private var stateObserverTask: Task? From 8989103f5095f560db0b2f32bf6b7a16f2c652cb Mon Sep 17 00:00:00 2001 From: MtlPhil <76601115+MtlPhil@users.noreply.github.com> Date: Fri, 13 Mar 2026 10:43:01 -0400 Subject: [PATCH 16/82] fix: include showRenewalOverlay in APNs payload and clear laRenewBy synchronously MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit APNSClient was missing showRenewalOverlay from the push payload, so background APNs updates never delivered the overlay flag to the extension — only foreground direct ActivityKit updates did. In handleForeground, laRenewBy is now zeroed synchronously before spawning the async end/restart Task. This means any snapshot built between the foreground notification and the new LA start (e.g. from viewDidAppear's startFromCurrentState) computes showRenewalOverlay = false rather than reading the stale expired deadline. Co-Authored-By: Claude Sonnet 4.6 --- LoopFollow/LiveActivity/APNSClient.swift | 1 + LoopFollow/LiveActivity/LiveActivityManager.swift | 6 +++--- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/LoopFollow/LiveActivity/APNSClient.swift b/LoopFollow/LiveActivity/APNSClient.swift index ac2dfc782..de721fd58 100644 --- a/LoopFollow/LiveActivity/APNSClient.swift +++ b/LoopFollow/LiveActivity/APNSClient.swift @@ -104,6 +104,7 @@ class APNSClient { ] snapshotDict["isNotLooping"] = snapshot.isNotLooping + snapshotDict["showRenewalOverlay"] = snapshot.showRenewalOverlay if let iob = snapshot.iob { snapshotDict["iob"] = iob } if let cob = snapshot.cob { snapshotDict["cob"] = cob } if let projected = snapshot.projected { snapshotDict["projected"] = projected } diff --git a/LoopFollow/LiveActivity/LiveActivityManager.swift b/LoopFollow/LiveActivity/LiveActivityManager.swift index 21590e7f7..4c67531e4 100644 --- a/LoopFollow/LiveActivity/LiveActivityManager.swift +++ b/LoopFollow/LiveActivity/LiveActivityManager.swift @@ -28,6 +28,9 @@ final class LiveActivityManager { // We cannot call startIfNeeded() here: it finds the existing activity in // Activity.activities and reuses it rather than replacing it. LogManager.shared.log(category: .general, message: "[LA] ending stale LA and restarting after renewal failure") + // Clear the expired deadline synchronously so any snapshot built between now + // and when the new LA is started computes showRenewalOverlay = false. + Storage.shared.laRenewBy.value = 0 if let activity = current { current = nil updateTask?.cancel() @@ -40,9 +43,6 @@ final class LiveActivityManager { Task { await activity.end(nil, dismissalPolicy: .immediate) await MainActor.run { - // Clear the expired deadline before rebuilding the snapshot so - // GlucoseSnapshotBuilder computes showRenewalOverlay = false. - Storage.shared.laRenewBy.value = 0 self.startFromCurrentState() LogManager.shared.log(category: .general, message: "[LA] Live Activity restarted after foreground retry") } From 1ab3930b0f70095bc750ce335e27f5b54af81e33 Mon Sep 17 00:00:00 2001 From: MtlPhil <76601115+MtlPhil@users.noreply.github.com> Date: Fri, 13 Mar 2026 11:34:46 -0400 Subject: [PATCH 17/82] fix: await LA end before restarting on foreground retry to avoid reuse path Reset laRenewBy and laRenewalFailed synchronously before tearing down the failed LA, then await activity.end() before calling startFromCurrentState(). This guarantees Activity.activities is clear when startIfNeeded() runs, so it takes the fresh-request path and writes a new laRenewBy. startFromCurrentState rebuilds the snapshot with showRenewalOverlay=false (laRenewBy=0), saves it to the store, then startIfNeeded uses that clean snapshot as the seed. Co-Authored-By: Claude Sonnet 4.6 --- .../LiveActivity/LiveActivityManager.swift | 48 +++++++++++-------- 1 file changed, 29 insertions(+), 19 deletions(-) diff --git a/LoopFollow/LiveActivity/LiveActivityManager.swift b/LoopFollow/LiveActivity/LiveActivityManager.swift index 4c67531e4..75449d2d6 100644 --- a/LoopFollow/LiveActivity/LiveActivityManager.swift +++ b/LoopFollow/LiveActivity/LiveActivityManager.swift @@ -28,27 +28,37 @@ final class LiveActivityManager { // We cannot call startIfNeeded() here: it finds the existing activity in // Activity.activities and reuses it rather than replacing it. LogManager.shared.log(category: .general, message: "[LA] ending stale LA and restarting after renewal failure") - // Clear the expired deadline synchronously so any snapshot built between now - // and when the new LA is started computes showRenewalOverlay = false. + // Clear state synchronously so any snapshot built between now and when the + // new LA is started computes showRenewalOverlay = false. Storage.shared.laRenewBy.value = 0 - if let activity = current { - current = nil - updateTask?.cancel() - updateTask = nil - tokenObservationTask?.cancel() - tokenObservationTask = nil - stateObserverTask?.cancel() - stateObserverTask = nil - pushToken = nil - Task { - await activity.end(nil, dismissalPolicy: .immediate) - await MainActor.run { - self.startFromCurrentState() - LogManager.shared.log(category: .general, message: "[LA] Live Activity restarted after foreground retry") - } - } - } else { + Storage.shared.laRenewalFailed.value = false + + guard let activity = current else { startFromCurrentState() + return + } + + current = nil + updateTask?.cancel() + updateTask = nil + tokenObservationTask?.cancel() + tokenObservationTask = nil + stateObserverTask?.cancel() + stateObserverTask = nil + pushToken = nil + + Task { + // Await end so the activity is removed from Activity.activities before + // startIfNeeded() runs — otherwise it hits the reuse path and skips + // writing a new laRenewBy deadline. + await activity.end(nil, dismissalPolicy: .immediate) + await MainActor.run { + // startFromCurrentState rebuilds the snapshot (showRenewalOverlay = false + // since laRenewBy is 0), saves it to the store, then calls startIfNeeded() + // which finds no existing activity and requests a fresh LA with a new deadline. + self.startFromCurrentState() + LogManager.shared.log(category: .general, message: "[LA] Live Activity restarted after foreground retry") + } } } From cdd4f8509bd5c11980592467122511b55c5d4f56 Mon Sep 17 00:00:00 2001 From: MtlPhil <76601115+MtlPhil@users.noreply.github.com> Date: Fri, 13 Mar 2026 12:10:47 -0400 Subject: [PATCH 18/82] chore: restore production renewal timing (7.5h threshold, 20min warning) Co-Authored-By: Claude Sonnet 4.6 --- LoopFollow/LiveActivity/LiveActivityManager.swift | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/LoopFollow/LiveActivity/LiveActivityManager.swift b/LoopFollow/LiveActivity/LiveActivityManager.swift index 75449d2d6..97386de3d 100644 --- a/LoopFollow/LiveActivity/LiveActivityManager.swift +++ b/LoopFollow/LiveActivity/LiveActivityManager.swift @@ -62,11 +62,8 @@ final class LiveActivityManager { } } - // TEST VALUES — restore both to production before merging: - // renewalThreshold = 7.5 * 3600 - // renewalWarning = 20 * 60 - static let renewalThreshold: TimeInterval = 20 * 60 - static let renewalWarning: TimeInterval = 5 * 60 + static let renewalThreshold: TimeInterval = 7.5 * 3600 + static let renewalWarning: TimeInterval = 20 * 60 private(set) var current: Activity? private var stateObserverTask: Task? From e737bce61c98ae7b75a33cf7d90492bc0a8907bc Mon Sep 17 00:00:00 2001 From: MtlPhil <76601115+MtlPhil@users.noreply.github.com> Date: Fri, 13 Mar 2026 14:32:45 -0400 Subject: [PATCH 19/82] **Live Activity auto-renewal (8-hour limit workaround)** (#539) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: Live Activity auto-renewal to work around 8-hour system limit Co-Authored-By: Claude Sonnet 4.6 * test: reduce LA renewal threshold to 20 min for testing Co-Authored-By: Claude Sonnet 4.6 * feat: improve LA renewal robustness and stale indicator - staleDate on every ActivityContent now tracks the renewal deadline, so the system shows Apple's built-in stale indicator if renewal fails - Add laRenewalFailed StorageValue; set on Activity.request() failure, cleared on any successful LA start - Observe willEnterForegroundNotification: retry startIfNeeded() if a previous renewal attempt failed - New-first renewal order: request the replacement LA before ending the old one — if the request throws the existing LA stays alive so the user keeps live data until the system kills it at the 8-hour mark Co-Authored-By: Claude Sonnet 4.6 * feat: renewal warning overlay + restore 7.5h threshold - Restore renewalThreshold to 7.5 * 3600 (testing complete) - Add showRenewalOverlay: Bool to GlucoseSnapshot (Codable, default false) - GlucoseSnapshotBuilder sets it true when now >= laRenewBy - 1800 (30 minutes before the renewal deadline) - Lock screen: 60% gray overlay with "Tap to update" centered in white, layered above the existing isNotLooping overlay - DI expanded: RenewalOverlayView applied to leading/trailing/bottom regions; "Tap to update" text shown on the bottom region only - showRenewalOverlay resets to false automatically on renewal since laRenewBy is updated and the next snapshot rebuild clears the flag Co-Authored-By: Claude Sonnet 4.6 * fix: overlay not appearing + foreground restart not working Overlay rendering: - Replace Group{if condition{ZStack{RoundedRectangle...}}} with a permanently-present ZStack toggled via .opacity(). The Group/if pattern causes SwiftUI sizing ambiguity when the condition transitions from false→true inside an .overlay(), producing a zero-size result. The .opacity() approach keeps a stable view hierarchy. - Same fix applied to RenewalOverlayView used on DI expanded regions. Foreground restart: - handleForeground() was calling startIfNeeded(), which finds the still-alive (failed-to-renew) LA in Activity.activities and reuses it, doing nothing useful. Fixed to manually nil out current, cancel all tasks, await activity.end(.immediate), then startFromCurrentState(). Overlay timing: - Changed warning window from 30 min (1800s) to 20 min (1200s) before the renewal deadline, matching the intended test cadence. Logging: - handleForeground: log on every foreground event with laRenewalFailed value - renewIfNeeded: log how many seconds past the deadline when firing - GlucoseSnapshotBuilder: log when overlay activates with seconds to deadline - performRefresh: log when sending an update with the overlay visible Co-Authored-By: Claude Sonnet 4.6 * test: set renewalThreshold to 20 min for testing Co-Authored-By: Claude Sonnet 4.6 * fix: renewal overlay not clearing after LA is refreshed Normal renewal path (renewIfNeeded success): - Build a fresh snapshot with showRenewalOverlay: false for the new LA's initial content — it has a new deadline so the overlay should never be visible from the first frame. - Save that fresh snapshot to GlucoseSnapshotStore so the next duplicate check has the correct baseline and doesn't suppress the first real BG update. Foreground restart path (handleForeground): - Zero laRenewBy before calling startFromCurrentState() so GlucoseSnapshotBuilder computes showRenewalOverlay = false for the seed snapshot. startIfNeeded() then sets the new deadline after the request succeeds. Co-Authored-By: Claude Sonnet 4.6 * fix: overlay permanently active when warning window equals threshold With renewalThreshold=20min and the hardcoded 1200s warning window, renewBy-1200 = start, so showRenewalOverlay is always true from the moment the LA begins. Extract a named renewalWarning constant (5 min for testing) so the warning window is always less than the threshold. The builder now reads LiveActivityManager.renewalWarning instead of a hardcoded literal. Production values to restore before merging: renewalThreshold = 7.5 * 3600 renewalWarning = 20 * 60 Co-Authored-By: Claude Sonnet 4.6 * fix: include showRenewalOverlay in APNs payload and clear laRenewBy synchronously APNSClient was missing showRenewalOverlay from the push payload, so background APNs updates never delivered the overlay flag to the extension — only foreground direct ActivityKit updates did. In handleForeground, laRenewBy is now zeroed synchronously before spawning the async end/restart Task. This means any snapshot built between the foreground notification and the new LA start (e.g. from viewDidAppear's startFromCurrentState) computes showRenewalOverlay = false rather than reading the stale expired deadline. Co-Authored-By: Claude Sonnet 4.6 * fix: await LA end before restarting on foreground retry to avoid reuse path Reset laRenewBy and laRenewalFailed synchronously before tearing down the failed LA, then await activity.end() before calling startFromCurrentState(). This guarantees Activity.activities is clear when startIfNeeded() runs, so it takes the fresh-request path and writes a new laRenewBy. startFromCurrentState rebuilds the snapshot with showRenewalOverlay=false (laRenewBy=0), saves it to the store, then startIfNeeded uses that clean snapshot as the seed. Co-Authored-By: Claude Sonnet 4.6 * chore: restore production renewal timing (7.5h threshold, 20min warning) Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Claude Sonnet 4.6 --- LoopFollow/LiveActivity/APNSClient.swift | 1 + LoopFollow/LiveActivity/GlucoseSnapshot.swift | 14 +- .../LiveActivity/GlucoseSnapshotBuilder.swift | 14 +- .../LiveActivity/LiveActivityManager.swift | 156 +++++++++++++++++- LoopFollow/Storage/Storage.swift | 4 + .../LoopFollowLiveActivity.swift | 32 ++++ 6 files changed, 215 insertions(+), 6 deletions(-) diff --git a/LoopFollow/LiveActivity/APNSClient.swift b/LoopFollow/LiveActivity/APNSClient.swift index 4679989f9..358d99469 100644 --- a/LoopFollow/LiveActivity/APNSClient.swift +++ b/LoopFollow/LiveActivity/APNSClient.swift @@ -107,6 +107,7 @@ class APNSClient { ] snapshotDict["isNotLooping"] = snapshot.isNotLooping + snapshotDict["showRenewalOverlay"] = snapshot.showRenewalOverlay if let iob = snapshot.iob { snapshotDict["iob"] = iob } if let cob = snapshot.cob { snapshotDict["cob"] = cob } if let projected = snapshot.projected { snapshotDict["projected"] = projected } diff --git a/LoopFollow/LiveActivity/GlucoseSnapshot.swift b/LoopFollow/LiveActivity/GlucoseSnapshot.swift index 934f44eac..1e573cba6 100644 --- a/LoopFollow/LiveActivity/GlucoseSnapshot.swift +++ b/LoopFollow/LiveActivity/GlucoseSnapshot.swift @@ -50,6 +50,12 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable { /// True when LoopFollow detects the loop has not reported in 15+ minutes (Nightscout only). let isNotLooping: Bool + // MARK: - Renewal + + /// True when the Live Activity is within 30 minutes of its renewal deadline. + /// The extension renders a "Tap to update" overlay so the user knows renewal is imminent. + let showRenewalOverlay: Bool + init( glucose: Double, delta: Double, @@ -59,7 +65,8 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable { cob: Double?, projected: Double?, unit: Unit, - isNotLooping: Bool + isNotLooping: Bool, + showRenewalOverlay: Bool = false ) { self.glucose = glucose self.delta = delta @@ -70,6 +77,7 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable { self.projected = projected self.unit = unit self.isNotLooping = isNotLooping + self.showRenewalOverlay = showRenewalOverlay } func encode(to encoder: Encoder) throws { @@ -83,10 +91,11 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable { try container.encodeIfPresent(projected, forKey: .projected) try container.encode(unit, forKey: .unit) try container.encode(isNotLooping, forKey: .isNotLooping) + try container.encode(showRenewalOverlay, forKey: .showRenewalOverlay) } private enum CodingKeys: String, CodingKey { - case glucose, delta, trend, updatedAt, iob, cob, projected, unit, isNotLooping + case glucose, delta, trend, updatedAt, iob, cob, projected, unit, isNotLooping, showRenewalOverlay } // MARK: - Codable @@ -102,6 +111,7 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable { projected = try container.decodeIfPresent(Double.self, forKey: .projected) unit = try container.decode(Unit.self, forKey: .unit) isNotLooping = try container.decodeIfPresent(Bool.self, forKey: .isNotLooping) ?? false + showRenewalOverlay = try container.decodeIfPresent(Bool.self, forKey: .showRenewalOverlay) ?? false } // MARK: - Derived Convenience diff --git a/LoopFollow/LiveActivity/GlucoseSnapshotBuilder.swift b/LoopFollow/LiveActivity/GlucoseSnapshotBuilder.swift index ad5b93dae..f6a1d7208 100644 --- a/LoopFollow/LiveActivity/GlucoseSnapshotBuilder.swift +++ b/LoopFollow/LiveActivity/GlucoseSnapshotBuilder.swift @@ -53,6 +53,17 @@ enum GlucoseSnapshotBuilder { // Not Looping — read from Observable, set by evaluateNotLooping() in DeviceStatus.swift let isNotLooping = Observable.shared.isNotLooping.value + // Renewal overlay — show renewalWarning seconds before the renewal deadline + // so the user knows the LA is about to be replaced. + let renewBy = Storage.shared.laRenewBy.value + let now = Date().timeIntervalSince1970 + let showRenewalOverlay = renewBy > 0 && now >= renewBy - LiveActivityManager.renewalWarning + + if showRenewalOverlay { + let timeLeft = max(renewBy - now, 0) + LogManager.shared.log(category: .general, message: "[LA] renewal overlay ON — \(Int(timeLeft))s until deadline") + } + LogManager.shared.log( category: .general, message: "LA snapshot built: updatedAt=\(updatedAt) interval=\(updatedAt.timeIntervalSince1970)", @@ -68,7 +79,8 @@ enum GlucoseSnapshotBuilder { cob: provider.cob, projected: provider.projectedMgdl, unit: preferredUnit, - isNotLooping: isNotLooping + isNotLooping: isNotLooping, + showRenewalOverlay: showRenewalOverlay ) } diff --git a/LoopFollow/LiveActivity/LiveActivityManager.swift b/LoopFollow/LiveActivity/LiveActivityManager.swift index bec4b4d4d..1313739f0 100644 --- a/LoopFollow/LiveActivity/LiveActivityManager.swift +++ b/LoopFollow/LiveActivity/LiveActivityManager.swift @@ -13,7 +13,59 @@ import UIKit final class LiveActivityManager { static let shared = LiveActivityManager() - private init() {} + private init() { + NotificationCenter.default.addObserver( + self, + selector: #selector(handleForeground), + name: UIApplication.willEnterForegroundNotification, + object: nil + ) + } + + @objc private func handleForeground() { + LogManager.shared.log(category: .general, message: "[LA] foreground notification received, laRenewalFailed=\(Storage.shared.laRenewalFailed.value)") + guard Storage.shared.laRenewalFailed.value else { return } + + // Renewal previously failed — end the stale LA and start a fresh one. + // We cannot call startIfNeeded() here: it finds the existing activity in + // Activity.activities and reuses it rather than replacing it. + LogManager.shared.log(category: .general, message: "[LA] ending stale LA and restarting after renewal failure") + // Clear state synchronously so any snapshot built between now and when the + // new LA is started computes showRenewalOverlay = false. + Storage.shared.laRenewBy.value = 0 + Storage.shared.laRenewalFailed.value = false + + guard let activity = current else { + startFromCurrentState() + return + } + + current = nil + updateTask?.cancel() + updateTask = nil + tokenObservationTask?.cancel() + tokenObservationTask = nil + stateObserverTask?.cancel() + stateObserverTask = nil + pushToken = nil + + Task { + // Await end so the activity is removed from Activity.activities before + // startIfNeeded() runs — otherwise it hits the reuse path and skips + // writing a new laRenewBy deadline. + await activity.end(nil, dismissalPolicy: .immediate) + await MainActor.run { + // startFromCurrentState rebuilds the snapshot (showRenewalOverlay = false + // since laRenewBy is 0), saves it to the store, then calls startIfNeeded() + // which finds no existing activity and requests a fresh LA with a new deadline. + self.startFromCurrentState() + LogManager.shared.log(category: .general, message: "[LA] Live Activity restarted after foreground retry") + } + } + } + + static let renewalThreshold: TimeInterval = 7.5 * 3600 + static let renewalWarning: TimeInterval = 20 * 60 private(set) var current: Activity? private var stateObserverTask: Task? @@ -34,6 +86,7 @@ final class LiveActivityManager { if let existing = Activity.activities.first { bind(to: existing, logReason: "reuse") + Storage.shared.laRenewalFailed.value = false return } @@ -59,10 +112,13 @@ final class LiveActivityManager { producedAt: Date() ) - let content = ActivityContent(state: initialState, staleDate: Date().addingTimeInterval(15 * 60)) + let renewDeadline = Date().addingTimeInterval(LiveActivityManager.renewalThreshold) + let content = ActivityContent(state: initialState, staleDate: renewDeadline) let activity = try Activity.request(attributes: attributes, content: content, pushType: .token) bind(to: activity, logReason: "start-new") + Storage.shared.laRenewBy.value = renewDeadline.timeIntervalSince1970 + Storage.shared.laRenewalFailed.value = false LogManager.shared.log(category: .general, message: "Live Activity started id=\(activity.id)") } catch { LogManager.shared.log(category: .general, message: "Live Activity failed to start: \(error)") @@ -100,11 +156,13 @@ final class LiveActivityManager { if current?.id == activity.id { current = nil + Storage.shared.laRenewBy.value = 0 } } } func startFromCurrentState() { + endOrphanedActivities() let provider = StorageCurrentGlucoseStateProvider() if let snapshot = GlucoseSnapshotBuilder.build(from: provider) { LAAppGroupSettings.setThresholds( @@ -125,6 +183,77 @@ final class LiveActivityManager { DispatchQueue.main.asyncAfter(deadline: .now() + 5.0, execute: workItem) } + // MARK: - Renewal + + /// Requests a fresh Live Activity to replace the current one when the renewal + /// deadline has passed, working around Apple's 8-hour maximum LA lifetime. + /// The new LA is requested FIRST — the old one is only ended if that succeeds, + /// so the user keeps live data if Activity.request() throws. + /// Returns true if renewal was performed (caller should return early). + private func renewIfNeeded(snapshot: GlucoseSnapshot) -> Bool { + guard let oldActivity = current else { return false } + + let renewBy = Storage.shared.laRenewBy.value + guard renewBy > 0, Date().timeIntervalSince1970 >= renewBy else { return false } + + let overdueBy = Date().timeIntervalSince1970 - renewBy + LogManager.shared.log(category: .general, message: "[LA] renewal deadline passed by \(Int(overdueBy))s, requesting new LA") + + let renewDeadline = Date().addingTimeInterval(LiveActivityManager.renewalThreshold) + let attributes = GlucoseLiveActivityAttributes(title: "LoopFollow") + + // Strip the overlay flag — the new LA has a fresh deadline so it should + // open clean, without the warning visible from the first frame. + let freshSnapshot = GlucoseSnapshot( + glucose: snapshot.glucose, + delta: snapshot.delta, + trend: snapshot.trend, + updatedAt: snapshot.updatedAt, + iob: snapshot.iob, + cob: snapshot.cob, + projected: snapshot.projected, + unit: snapshot.unit, + isNotLooping: snapshot.isNotLooping, + showRenewalOverlay: false + ) + let state = GlucoseLiveActivityAttributes.ContentState( + snapshot: freshSnapshot, + seq: seq, + reason: "renew", + producedAt: Date() + ) + let content = ActivityContent(state: state, staleDate: renewDeadline) + + do { + let newActivity = try Activity.request(attributes: attributes, content: content, pushType: .token) + + // New LA is live — now it's safe to remove the old card. + Task { + await oldActivity.end(nil, dismissalPolicy: .immediate) + } + + updateTask?.cancel() + updateTask = nil + tokenObservationTask?.cancel() + tokenObservationTask = nil + stateObserverTask?.cancel() + stateObserverTask = nil + pushToken = nil + + bind(to: newActivity, logReason: "renew") + Storage.shared.laRenewBy.value = renewDeadline.timeIntervalSince1970 + Storage.shared.laRenewalFailed.value = false + // Update the store so the next duplicate check has the correct baseline. + GlucoseSnapshotStore.shared.save(freshSnapshot) + LogManager.shared.log(category: .general, message: "[LA] Live Activity renewed successfully id=\(newActivity.id)") + return true + } catch { + Storage.shared.laRenewalFailed.value = true + LogManager.shared.log(category: .general, message: "[LA] renewal failed, keeping existing LA: \(error)") + return false + } + } + private func performRefresh(reason: String) { let provider = StorageCurrentGlucoseStateProvider() guard let snapshot = GlucoseSnapshotBuilder.build(from: provider) else { @@ -136,6 +265,14 @@ final class LiveActivityManager { "at=\(snapshot.updatedAt.timeIntervalSince1970) iob=\(snapshot.iob?.description ?? "nil") " + "cob=\(snapshot.cob?.description ?? "nil") proj=\(snapshot.projected?.description ?? "nil") u=\(snapshot.unit.rawValue)" LogManager.shared.log(category: .general, message: "[LA] snapshot \(fingerprint) reason=\(reason)", isDebug: true) + + // Check if the Live Activity is approaching Apple's 8-hour limit and renew if so. + if renewIfNeeded(snapshot: snapshot) { return } + + if snapshot.showRenewalOverlay { + LogManager.shared.log(category: .general, message: "[LA] sending update with renewal overlay visible") + } + let now = Date() let timeSinceLastUpdate = now.timeIntervalSince(lastUpdateTime ?? .distantPast) let forceRefreshNeeded = timeSinceLastUpdate >= 5 * 60 @@ -202,7 +339,7 @@ final class LiveActivityManager { let content = ActivityContent( state: state, - staleDate: Date().addingTimeInterval(15 * 60), + staleDate: Date(timeIntervalSince1970: Storage.shared.laRenewBy.value), relevanceScore: 100.0 ) @@ -240,6 +377,19 @@ final class LiveActivityManager { // MARK: - Binding / Lifecycle + /// Ends any Live Activities of this type that are not the one currently tracked. + /// Called on app launch to clean up cards left behind by a previous crash. + private func endOrphanedActivities() { + for activity in Activity.activities { + guard activity.id != current?.id else { continue } + let orphanID = activity.id + Task { + await activity.end(nil, dismissalPolicy: .immediate) + LogManager.shared.log(category: .general, message: "Ended orphaned Live Activity id=\(orphanID)") + } + } + } + private func bind(to activity: Activity, logReason: String) { if current?.id == activity.id { return } current = activity diff --git a/LoopFollow/Storage/Storage.swift b/LoopFollow/Storage/Storage.swift index dc0c8a282..7de9ac7e7 100644 --- a/LoopFollow/Storage/Storage.swift +++ b/LoopFollow/Storage/Storage.swift @@ -91,6 +91,10 @@ class Storage { var lastCOB = StorageValue(key: "lastCOB", defaultValue: nil) var projectedBgMgdl = StorageValue(key: "projectedBgMgdl", defaultValue: nil) + // Live Activity renewal + var laRenewBy = StorageValue(key: "laRenewBy", defaultValue: 0) + var laRenewalFailed = StorageValue(key: "laRenewalFailed", defaultValue: false) + // Graph Settings [BEGIN] var showDots = StorageValue(key: "showDots", defaultValue: true) var showLines = StorageValue(key: "showLines", defaultValue: true) diff --git a/LoopFollowLAExtension/LoopFollowLiveActivity.swift b/LoopFollowLAExtension/LoopFollowLiveActivity.swift index cca77be83..2ef72f6fe 100644 --- a/LoopFollowLAExtension/LoopFollowLiveActivity.swift +++ b/LoopFollowLAExtension/LoopFollowLiveActivity.swift @@ -21,14 +21,17 @@ struct LoopFollowLiveActivityWidget: Widget { DynamicIslandExpandedRegion(.leading) { DynamicIslandLeadingView(snapshot: context.state.snapshot) .id(context.state.seq) + .overlay(RenewalOverlayView(show: context.state.snapshot.showRenewalOverlay)) } DynamicIslandExpandedRegion(.trailing) { DynamicIslandTrailingView(snapshot: context.state.snapshot) .id(context.state.seq) + .overlay(RenewalOverlayView(show: context.state.snapshot.showRenewalOverlay)) } DynamicIslandExpandedRegion(.bottom) { DynamicIslandBottomView(snapshot: context.state.snapshot) .id(context.state.seq) + .overlay(RenewalOverlayView(show: context.state.snapshot.showRenewalOverlay, showText: true)) } } compactLeading: { DynamicIslandCompactLeadingView(snapshot: context.state.snapshot) @@ -130,6 +133,35 @@ private struct LockScreenLiveActivityView: View { } } ) + .overlay( + ZStack { + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill(Color.gray.opacity(0.6)) + Text("Tap to update") + .font(.system(size: 20, weight: .semibold)) + .foregroundStyle(.white) + } + .opacity(state.snapshot.showRenewalOverlay ? 1 : 0) + ) + } +} + +/// Full-size gray overlay shown 30 minutes before the LA renewal deadline. +/// Applied to both the lock screen view and each expanded Dynamic Island region. +private struct RenewalOverlayView: View { + let show: Bool + var showText: Bool = false + + var body: some View { + ZStack { + Color.gray.opacity(0.6) + if showText { + Text("Tap to update") + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(.white) + } + } + .opacity(show ? 1 : 0) } } From e0a729a69b4d3b798d13f390402ca01417d45696 Mon Sep 17 00:00:00 2001 From: MtlPhil <76601115+MtlPhil@users.noreply.github.com> Date: Sat, 14 Mar 2026 08:05:59 -0400 Subject: [PATCH 20/82] feat: laEnabled toggle, forceRestart(), and RestartLiveActivityIntent - SettingsMenuView: rename "APN" menu entry to "Live Activity" - Storage: add laEnabled: Bool StorageValue (default false) - APNSettingsView: on/off toggle bound to laEnabled; APNs key fields and Restart button shown only when enabled; disabling immediately ends any running LA - LiveActivityManager: - forceRestart() (@MainActor) ends all running activities, resets laRenewBy and laRenewalFailed, then calls startFromCurrentState() - laEnabled guard added to startFromCurrentState(), refreshFromCurrentState(), handleForeground(), and handleDidBecomeActive() - didBecomeActiveNotification observer calls forceRestart() on every foreground transition when laEnabled is true - RestartLiveActivityIntent: AppIntent + ForegroundContinuableIntent; sets laEnabled, validates credentials, opens settings deep link if missing, otherwise calls forceRestart() in foreground; LoopFollowAppShortcuts exposes the intent with Siri phrase Note: RestartLiveActivityIntent.swift must be added to the app target in Xcode. Co-Authored-By: Claude Sonnet 4.6 --- .../LiveActivity/LiveActivityManager.swift | 38 +++++++++++++ .../RestartLiveActivityIntent.swift | 43 +++++++++++++++ LoopFollow/Settings/APNSettingsView.swift | 53 +++++++++++++------ LoopFollow/Settings/SettingsMenuView.swift | 2 +- LoopFollow/Storage/Storage.swift | 3 +- 5 files changed, 120 insertions(+), 19 deletions(-) create mode 100644 LoopFollow/LiveActivity/RestartLiveActivityIntent.swift diff --git a/LoopFollow/LiveActivity/LiveActivityManager.swift b/LoopFollow/LiveActivity/LiveActivityManager.swift index 97386de3d..69c2fd9b9 100644 --- a/LoopFollow/LiveActivity/LiveActivityManager.swift +++ b/LoopFollow/LiveActivity/LiveActivityManager.swift @@ -18,9 +18,21 @@ final class LiveActivityManager { name: UIApplication.willEnterForegroundNotification, object: nil ) + NotificationCenter.default.addObserver( + self, + selector: #selector(handleDidBecomeActive), + name: UIApplication.didBecomeActiveNotification, + object: nil + ) + } + + @objc private func handleDidBecomeActive() { + guard Storage.shared.laEnabled.value else { return } + forceRestart() } @objc private func handleForeground() { + guard Storage.shared.laEnabled.value else { return } LogManager.shared.log(category: .general, message: "[LA] foreground notification received, laRenewalFailed=\(Storage.shared.laRenewalFailed.value)") guard Storage.shared.laRenewalFailed.value else { return } @@ -159,7 +171,32 @@ final class LiveActivityManager { } } + /// Ends all running Live Activities and starts a fresh one from the current state. + /// Intended for the "Restart Live Activity" button and the AppIntent. + @MainActor + func forceRestart() { + guard Storage.shared.laEnabled.value else { return } + LogManager.shared.log(category: .general, message: "[LA] forceRestart called") + Storage.shared.laRenewBy.value = 0 + Storage.shared.laRenewalFailed.value = false + current = nil + updateTask?.cancel(); updateTask = nil + tokenObservationTask?.cancel(); tokenObservationTask = nil + stateObserverTask?.cancel(); stateObserverTask = nil + pushToken = nil + Task { + for activity in Activity.activities { + await activity.end(nil, dismissalPolicy: .immediate) + } + await MainActor.run { + self.startFromCurrentState() + LogManager.shared.log(category: .general, message: "[LA] forceRestart: Live Activity restarted") + } + } + } + func startFromCurrentState() { + guard Storage.shared.laEnabled.value else { return } endOrphanedActivities() let provider = StorageCurrentGlucoseStateProvider() if let snapshot = GlucoseSnapshotBuilder.build(from: provider) { @@ -173,6 +210,7 @@ final class LiveActivityManager { } func refreshFromCurrentState(reason: String) { + guard Storage.shared.laEnabled.value else { return } refreshWorkItem?.cancel() let workItem = DispatchWorkItem { [weak self] in self?.performRefresh(reason: reason) diff --git a/LoopFollow/LiveActivity/RestartLiveActivityIntent.swift b/LoopFollow/LiveActivity/RestartLiveActivityIntent.swift new file mode 100644 index 000000000..4a615b2e0 --- /dev/null +++ b/LoopFollow/LiveActivity/RestartLiveActivityIntent.swift @@ -0,0 +1,43 @@ +// LoopFollow +// RestartLiveActivityIntent.swift + +import AppIntents +import UIKit + +@available(iOS 16.4, *) +struct RestartLiveActivityIntent: AppIntent, ForegroundContinuableIntent { + static var title: LocalizedStringResource = "Restart Live Activity" + static var description = IntentDescription("Starts or restarts the LoopFollow Live Activity.") + + func perform() async throws -> some IntentResult & ProvidesDialog { + Storage.shared.laEnabled.value = true + + let keyId = Storage.shared.lfKeyId.value + let apnsKey = Storage.shared.lfApnsKey.value + + if keyId.isEmpty || apnsKey.isEmpty { + if let url = URL(string: "loopfollow://settings/live-activity") { + await MainActor.run { UIApplication.shared.open(url) } + } + return .result(dialog: "Please enter your APNs credentials in LoopFollow settings to use the Live Activity.") + } + + try await continueInForeground() + + await MainActor.run { LiveActivityManager.shared.forceRestart() } + + return .result(dialog: "Live Activity restarted.") + } +} + +@available(iOS 16.4, *) +struct LoopFollowAppShortcuts: AppShortcutsProvider { + static var appShortcuts: [AppShortcut] { + AppShortcut( + intent: RestartLiveActivityIntent(), + phrases: ["Restart Live Activity in \(.applicationName)"], + shortTitle: "Restart Live Activity", + systemImageName: "dot.radiowaves.left.and.right" + ) + } +} diff --git a/LoopFollow/Settings/APNSettingsView.swift b/LoopFollow/Settings/APNSettingsView.swift index 79b07e7cd..7f7828ca9 100644 --- a/LoopFollow/Settings/APNSettingsView.swift +++ b/LoopFollow/Settings/APNSettingsView.swift @@ -4,32 +4,51 @@ import SwiftUI struct APNSettingsView: View { + @State private var laEnabled: Bool = Storage.shared.laEnabled.value @State private var keyId: String = Storage.shared.lfKeyId.value @State private var apnsKey: String = Storage.shared.lfApnsKey.value var body: some View { Form { - Section(header: Text("LoopFollow APNs Credentials")) { - HStack { - Text("APNS Key ID") - TogglableSecureInput( - placeholder: "Enter APNS Key ID", - text: $keyId, - style: .singleLine - ) + Section(header: Text("Live Activity")) { + Toggle("Enable Live Activity", isOn: $laEnabled) + } + + if laEnabled { + Section(header: Text("LoopFollow APNs Credentials")) { + HStack { + Text("APNS Key ID") + TogglableSecureInput( + placeholder: "Enter APNS Key ID", + text: $keyId, + style: .singleLine + ) + } + + VStack(alignment: .leading) { + Text("APNS Key") + TogglableSecureInput( + placeholder: "Paste APNS Key", + text: $apnsKey, + style: .multiLine + ) + .frame(minHeight: 110) + } } - VStack(alignment: .leading) { - Text("APNS Key") - TogglableSecureInput( - placeholder: "Paste APNS Key", - text: $apnsKey, - style: .multiLine - ) - .frame(minHeight: 110) + Section { + Button("Restart Live Activity") { + LiveActivityManager.shared.forceRestart() + } } } } + .onChange(of: laEnabled) { newValue in + Storage.shared.laEnabled.value = newValue + if !newValue { + LiveActivityManager.shared.end(dismissalPolicy: .immediate) + } + } .onChange(of: keyId) { newValue in Storage.shared.lfKeyId.value = newValue } @@ -38,7 +57,7 @@ struct APNSettingsView: View { Storage.shared.lfApnsKey.value = apnsService.validateAndFixAPNSKey(newValue) } .preferredColorScheme(Storage.shared.appearanceMode.value.colorScheme) - .navigationTitle("APN") + .navigationTitle("Live Activity") .navigationBarTitleDisplayMode(.inline) } } diff --git a/LoopFollow/Settings/SettingsMenuView.swift b/LoopFollow/Settings/SettingsMenuView.swift index 1ddcffc77..8b562be9b 100644 --- a/LoopFollow/Settings/SettingsMenuView.swift +++ b/LoopFollow/Settings/SettingsMenuView.swift @@ -60,7 +60,7 @@ struct SettingsMenuView: View { settingsPath.value.append(Sheet.importExport) } - NavigationRow(title: "APN", + NavigationRow(title: "Live Activity", icon: "bell.and.waves.left.and.right") { settingsPath.value.append(Sheet.apn) diff --git a/LoopFollow/Storage/Storage.swift b/LoopFollow/Storage/Storage.swift index 7de9ac7e7..141293e7c 100644 --- a/LoopFollow/Storage/Storage.swift +++ b/LoopFollow/Storage/Storage.swift @@ -91,7 +91,8 @@ class Storage { var lastCOB = StorageValue(key: "lastCOB", defaultValue: nil) var projectedBgMgdl = StorageValue(key: "projectedBgMgdl", defaultValue: nil) - // Live Activity renewal + // Live Activity + var laEnabled = StorageValue(key: "laEnabled", defaultValue: false) var laRenewBy = StorageValue(key: "laRenewBy", defaultValue: 0) var laRenewalFailed = StorageValue(key: "laRenewalFailed", defaultValue: false) From 7588c93a28fef97aa800851527ceca02629c27f3 Mon Sep 17 00:00:00 2001 From: MtlPhil <76601115+MtlPhil@users.noreply.github.com> Date: Sat, 14 Mar 2026 08:13:09 -0400 Subject: [PATCH 21/82] Added RestartLiveActivityIntent to project --- LoopFollow.xcodeproj/project.pbxproj | 20 +++++++------ RestartLiveActivityIntent.swift | 43 ++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 9 deletions(-) create mode 100644 RestartLiveActivityIntent.swift diff --git a/LoopFollow.xcodeproj/project.pbxproj b/LoopFollow.xcodeproj/project.pbxproj index d7ed09428..1ec8f05eb 100644 --- a/LoopFollow.xcodeproj/project.pbxproj +++ b/LoopFollow.xcodeproj/project.pbxproj @@ -12,7 +12,6 @@ 374A77A62F5BE17000E96858 /* GlucoseSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A77A12F5BE17000E96858 /* GlucoseSnapshot.swift */; }; 374A77A72F5BE17000E96858 /* LAAppGroupSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A77A32F5BE17000E96858 /* LAAppGroupSettings.swift */; }; 374A77A82F5BE17000E96858 /* GlucoseLiveActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A77A02F5BE17000E96858 /* GlucoseLiveActivityAttributes.swift */; }; - DD91E4DE2BDEC3F8002D9E97 /* GlucoseConversion.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD91E4DC2BDEC3F8002D9E97 /* GlucoseConversion.swift */; }; 374A77AA2F5BE17000E96858 /* AppGroupID.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A779F2F5BE17000E96858 /* AppGroupID.swift */; }; 374A77AB2F5BE17000E96858 /* GlucoseSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A77A12F5BE17000E96858 /* GlucoseSnapshot.swift */; }; 374A77AC2F5BE17000E96858 /* LAAppGroupSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A77A32F5BE17000E96858 /* LAAppGroupSettings.swift */; }; @@ -22,10 +21,12 @@ 374A77B72F5BE1AC00E96858 /* LiveActivityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A77B12F5BE1AC00E96858 /* LiveActivityManager.swift */; }; 374A77B82F5BE1AC00E96858 /* GlucoseSnapshotStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A77AF2F5BE1AC00E96858 /* GlucoseSnapshotStore.swift */; }; 374A77B92F5BE1AC00E96858 /* PreferredGlucoseUnit.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A77B22F5BE1AC00E96858 /* PreferredGlucoseUnit.swift */; }; + 379BECAA2F6588300069DC62 /* RestartLiveActivityIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 379BECA92F6588300069DC62 /* RestartLiveActivityIntent.swift */; }; 37A4BDDB2F5B6B4A00EEB289 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 37A4BDDA2F5B6B4A00EEB289 /* WidgetKit.framework */; }; 37A4BDDD2F5B6B4A00EEB289 /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 37A4BDDC2F5B6B4A00EEB289 /* SwiftUI.framework */; }; 37A4BDE82F5B6B4C00EEB289 /* LoopFollowLAExtensionExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 37A4BDD92F5B6B4A00EEB289 /* LoopFollowLAExtensionExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 3F1335F351590E573D8E6962 /* Pods_LoopFollow.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A7D55B42A22051DAD69E89D0 /* Pods_LoopFollow.framework */; }; + 5544D8C363FB5D3B9BF8CE4A /* APNSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7C2676561D686C6459CAA2D /* APNSettingsView.swift */; }; 654132E72E19EA7E00BDBE08 /* SimpleQRCodeScannerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 654132E62E19EA7E00BDBE08 /* SimpleQRCodeScannerView.swift */; }; 654132EA2E19F24800BDBE08 /* TOTPGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 654132E92E19F24800BDBE08 /* TOTPGenerator.swift */; }; 654134182E1DC09700BDBE08 /* OverridePresetsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 654134172E1DC09700BDBE08 /* OverridePresetsView.swift */; }; @@ -39,7 +40,6 @@ 6584B1012E4A263900135D4D /* TOTPService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6584B1002E4A263900135D4D /* TOTPService.swift */; }; 6589CC622E9E7D1600BB18FE /* ImportExportSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6589CC532E9E7D1600BB18FE /* ImportExportSettingsView.swift */; }; 6589CC632E9E7D1600BB18FE /* GeneralSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6589CC5D2E9E7D1600BB18FE /* GeneralSettingsView.swift */; }; - 5544D8C363FB5D3B9BF8CE4A /* APNSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7C2676561D686C6459CAA2D /* APNSettingsView.swift */; }; 6589CC642E9E7D1600BB18FE /* ContactSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6589CC592E9E7D1600BB18FE /* ContactSettingsView.swift */; }; 6589CC652E9E7D1600BB18FE /* DexcomSettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6589CC5C2E9E7D1600BB18FE /* DexcomSettingsViewModel.swift */; }; 6589CC662E9E7D1600BB18FE /* AdvancedSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6589CC562E9E7D1600BB18FE /* AdvancedSettingsView.swift */; }; @@ -54,7 +54,6 @@ 6589CC6F2E9E7D1600BB18FE /* AdvancedSettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6589CC572E9E7D1600BB18FE /* AdvancedSettingsViewModel.swift */; }; 6589CC712E9E814F00BB18FE /* AlarmSelectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6589CC702E9E814F00BB18FE /* AlarmSelectionView.swift */; }; 6589CC752E9EAFB700BB18FE /* SettingsMigrationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6589CC742E9EAFB700BB18FE /* SettingsMigrationManager.swift */; }; - 6584B1012E4A263900135D4D /* TOTPService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6584B1002E4A263900135D4D /* TOTPService.swift */; }; 65E153C32E4BB69100693A4F /* URLTokenValidationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65E153C22E4BB69100693A4F /* URLTokenValidationView.swift */; }; 65E8A2862E44B0300065037B /* VolumeButtonHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65E8A2852E44B0300065037B /* VolumeButtonHandler.swift */; }; DD0247592DB2E89600FCADF6 /* AlarmCondition.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD0247582DB2E89600FCADF6 /* AlarmCondition.swift */; }; @@ -177,6 +176,7 @@ DD83164C2DE4DB3A004467AA /* BinaryFloatingPoint+localized.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD83164B2DE4DB3A004467AA /* BinaryFloatingPoint+localized.swift */; }; DD85E9952D739CFE001C8BB7 /* OmnipodDashHeartbeatBluetoothTransmitter.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD85E9942D739CED001C8BB7 /* OmnipodDashHeartbeatBluetoothTransmitter.swift */; }; DD91E4DD2BDEC3F8002D9E97 /* GlucoseConversion.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD91E4DC2BDEC3F8002D9E97 /* GlucoseConversion.swift */; }; + DD91E4DE2BDEC3F8002D9E97 /* GlucoseConversion.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD91E4DC2BDEC3F8002D9E97 /* GlucoseConversion.swift */; }; DD98F54424BCEFEE0007425A /* ShareClientExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD98F54324BCEFEE0007425A /* ShareClientExtension.swift */; }; DD9ACA042D32821400415D8A /* DeviceStatusTask.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD9ACA032D32821200415D8A /* DeviceStatusTask.swift */; }; DD9ACA062D32AF7900415D8A /* TreatmentsTask.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD9ACA052D32AF6E00415D8A /* TreatmentsTask.swift */; }; @@ -463,6 +463,7 @@ 374A77B22F5BE1AC00E96858 /* PreferredGlucoseUnit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferredGlucoseUnit.swift; sourceTree = ""; }; 374A77B32F5BE1AC00E96858 /* StorageCurrentGlucoseStateProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StorageCurrentGlucoseStateProvider.swift; sourceTree = ""; }; 374DAACA2F5B924B00BB663B /* LoopFollowLAExtensionExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = LoopFollowLAExtensionExtension.entitlements; sourceTree = ""; }; + 379BECA92F6588300069DC62 /* RestartLiveActivityIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RestartLiveActivityIntent.swift; sourceTree = ""; }; 37A4BDD92F5B6B4A00EEB289 /* LoopFollowLAExtensionExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = LoopFollowLAExtensionExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 37A4BDDA2F5B6B4A00EEB289 /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = /System/Library/Frameworks/WidgetKit.framework; sourceTree = ""; }; 37A4BDDC2F5B6B4A00EEB289 /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = /System/Library/Frameworks/SwiftUI.framework; sourceTree = ""; }; @@ -488,13 +489,11 @@ 6589CC5B2E9E7D1600BB18FE /* DexcomSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DexcomSettingsView.swift; sourceTree = ""; }; 6589CC5C2E9E7D1600BB18FE /* DexcomSettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DexcomSettingsViewModel.swift; sourceTree = ""; }; 6589CC5D2E9E7D1600BB18FE /* GeneralSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeneralSettingsView.swift; sourceTree = ""; }; - E7C2676561D686C6459CAA2D /* APNSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APNSettingsView.swift; sourceTree = ""; }; 6589CC5E2E9E7D1600BB18FE /* GraphSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GraphSettingsView.swift; sourceTree = ""; }; 6589CC5F2E9E7D1600BB18FE /* SettingsMenuView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsMenuView.swift; sourceTree = ""; }; 6589CC602E9E7D1600BB18FE /* TabCustomizationModal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabCustomizationModal.swift; sourceTree = ""; }; 6589CC702E9E814F00BB18FE /* AlarmSelectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlarmSelectionView.swift; sourceTree = ""; }; 6589CC742E9EAFB700BB18FE /* SettingsMigrationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsMigrationManager.swift; sourceTree = ""; }; - 6584B1002E4A263900135D4D /* TOTPService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TOTPService.swift; sourceTree = ""; }; 65E153C22E4BB69100693A4F /* URLTokenValidationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLTokenValidationView.swift; sourceTree = ""; }; 65E8A2852E44B0300065037B /* VolumeButtonHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VolumeButtonHandler.swift; sourceTree = ""; }; A7D55B42A22051DAD69E89D0 /* Pods_LoopFollow.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_LoopFollow.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -712,6 +711,7 @@ DDFF3D842D14279B00BF9D9E /* BackgroundRefreshSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundRefreshSettingsView.swift; sourceTree = ""; }; DDFF3D862D14280500BF9D9E /* BackgroundRefreshSettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundRefreshSettingsViewModel.swift; sourceTree = ""; }; DDFF3D882D1429AB00BF9D9E /* BackgroundRefreshType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundRefreshType.swift; sourceTree = ""; }; + E7C2676561D686C6459CAA2D /* APNSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APNSettingsView.swift; sourceTree = ""; }; ECA3EFB4037410B4973BB632 /* Pods-LoopFollow.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-LoopFollow.debug.xcconfig"; path = "Target Support Files/Pods-LoopFollow/Pods-LoopFollow.debug.xcconfig"; sourceTree = ""; }; FC16A97924996673003D6245 /* NightScout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NightScout.swift; sourceTree = ""; }; FC16A97C24996747003D6245 /* SpeakBG.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpeakBG.swift; sourceTree = ""; }; @@ -1597,6 +1597,7 @@ FC97880B2485969B00A7906C = { isa = PBXGroup; children = ( + 379BECA92F6588300069DC62 /* RestartLiveActivityIntent.swift */, 374DAACA2F5B924B00BB663B /* LoopFollowLAExtensionExtension.entitlements */, DDF2C0132BEFD468007A20E6 /* blacklisted-versions.json */, DDB0AF542BB1B24A00AFA48B /* BuildDetails.plist */, @@ -2318,6 +2319,7 @@ 654132EA2E19F24800BDBE08 /* TOTPGenerator.swift in Sources */, 6541341C2E1DC28000BDBE08 /* DateExtensions.swift in Sources */, DD0650F52DCF303F004D3B41 /* AlarmStepperSection.swift in Sources */, + 379BECAA2F6588300069DC62 /* RestartLiveActivityIntent.swift in Sources */, DDCF9A802D85FD0B004DF4DD /* Alarm.swift in Sources */, DD7F4C132DD51FD500D449E9 /* TempTargetEndCondition.swift in Sources */, DDD10F012C510C6B00D76A8E /* ObservableUserDefaults.swift in Sources */, @@ -2657,8 +2659,8 @@ isa = XCBuildConfiguration; baseConfigurationReference = ECA3EFB4037410B4973BB632 /* Pods-LoopFollow.debug.xcconfig */; buildSettings = { - ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES; CODE_SIGN_ENTITLEMENTS = "LoopFollow/Loop Follow.entitlements"; CODE_SIGN_STYLE = Automatic; DEFINES_MODULE = YES; @@ -2682,8 +2684,8 @@ isa = XCBuildConfiguration; baseConfigurationReference = 059B0FA59AABFE72FE13DDDA /* Pods-LoopFollow.release.xcconfig */; buildSettings = { - ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES; CODE_SIGN_ENTITLEMENTS = "LoopFollow/Loop Follow.entitlements"; CODE_SIGN_STYLE = Automatic; DEFINES_MODULE = YES; @@ -2753,14 +2755,14 @@ minimumVersion = 1.9.0; }; }; - /* End XCRemoteSwiftPackageReference section */ +/* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ DD485F152E46631000CE8CBF /* CryptoSwift */ = { isa = XCSwiftPackageProductDependency; productName = CryptoSwift; }; - /* End XCSwiftPackageProductDependency section */ +/* End XCSwiftPackageProductDependency section */ /* Begin XCVersionGroup section */ FC3AE7B3249E8E0E00AAE1E0 /* LoopFollow.xcdatamodeld */ = { diff --git a/RestartLiveActivityIntent.swift b/RestartLiveActivityIntent.swift new file mode 100644 index 000000000..4a615b2e0 --- /dev/null +++ b/RestartLiveActivityIntent.swift @@ -0,0 +1,43 @@ +// LoopFollow +// RestartLiveActivityIntent.swift + +import AppIntents +import UIKit + +@available(iOS 16.4, *) +struct RestartLiveActivityIntent: AppIntent, ForegroundContinuableIntent { + static var title: LocalizedStringResource = "Restart Live Activity" + static var description = IntentDescription("Starts or restarts the LoopFollow Live Activity.") + + func perform() async throws -> some IntentResult & ProvidesDialog { + Storage.shared.laEnabled.value = true + + let keyId = Storage.shared.lfKeyId.value + let apnsKey = Storage.shared.lfApnsKey.value + + if keyId.isEmpty || apnsKey.isEmpty { + if let url = URL(string: "loopfollow://settings/live-activity") { + await MainActor.run { UIApplication.shared.open(url) } + } + return .result(dialog: "Please enter your APNs credentials in LoopFollow settings to use the Live Activity.") + } + + try await continueInForeground() + + await MainActor.run { LiveActivityManager.shared.forceRestart() } + + return .result(dialog: "Live Activity restarted.") + } +} + +@available(iOS 16.4, *) +struct LoopFollowAppShortcuts: AppShortcutsProvider { + static var appShortcuts: [AppShortcut] { + AppShortcut( + intent: RestartLiveActivityIntent(), + phrases: ["Restart Live Activity in \(.applicationName)"], + shortTitle: "Restart Live Activity", + systemImageName: "dot.radiowaves.left.and.right" + ) + } +} From 0c2190997e40bcc8ceeb34dcb7a5bc76bfafa78d Mon Sep 17 00:00:00 2001 From: MtlPhil <76601115+MtlPhil@users.noreply.github.com> Date: Sat, 14 Mar 2026 08:38:08 -0400 Subject: [PATCH 22/82] fix: resolve two build errors in LiveActivityManager and RestartLiveActivityIntent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - LiveActivityManager: handleDidBecomeActive() calls @MainActor forceRestart() via Task { @MainActor in ... } to satisfy Swift concurrency isolation - RestartLiveActivityIntent: drop ForegroundContinuableIntent — continueInForeground() was renamed to continueInForeground(_:alwaysConfirm:) in the iOS 26 SDK and requires iOS 26+; the didBecomeActiveNotification observer handles restart when the app comes to foreground, making explicit continuation unnecessary Co-Authored-By: Claude Sonnet 4.6 --- LoopFollow/LiveActivity/LiveActivityManager.swift | 2 +- LoopFollow/LiveActivity/RestartLiveActivityIntent.swift | 4 +--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/LoopFollow/LiveActivity/LiveActivityManager.swift b/LoopFollow/LiveActivity/LiveActivityManager.swift index 69c2fd9b9..0d2cf7c56 100644 --- a/LoopFollow/LiveActivity/LiveActivityManager.swift +++ b/LoopFollow/LiveActivity/LiveActivityManager.swift @@ -28,7 +28,7 @@ final class LiveActivityManager { @objc private func handleDidBecomeActive() { guard Storage.shared.laEnabled.value else { return } - forceRestart() + Task { @MainActor in self.forceRestart() } } @objc private func handleForeground() { diff --git a/LoopFollow/LiveActivity/RestartLiveActivityIntent.swift b/LoopFollow/LiveActivity/RestartLiveActivityIntent.swift index 4a615b2e0..9e3179244 100644 --- a/LoopFollow/LiveActivity/RestartLiveActivityIntent.swift +++ b/LoopFollow/LiveActivity/RestartLiveActivityIntent.swift @@ -5,7 +5,7 @@ import AppIntents import UIKit @available(iOS 16.4, *) -struct RestartLiveActivityIntent: AppIntent, ForegroundContinuableIntent { +struct RestartLiveActivityIntent: AppIntent { static var title: LocalizedStringResource = "Restart Live Activity" static var description = IntentDescription("Starts or restarts the LoopFollow Live Activity.") @@ -22,8 +22,6 @@ struct RestartLiveActivityIntent: AppIntent, ForegroundContinuableIntent { return .result(dialog: "Please enter your APNs credentials in LoopFollow settings to use the Live Activity.") } - try await continueInForeground() - await MainActor.run { LiveActivityManager.shared.forceRestart() } return .result(dialog: "Live Activity restarted.") From 9f5ddf29eb58268f8bfec57aa4d4150ef7c6bb4a Mon Sep 17 00:00:00 2001 From: MtlPhil <76601115+MtlPhil@users.noreply.github.com> Date: Sat, 14 Mar 2026 09:15:45 -0400 Subject: [PATCH 23/82] fix: guard continueInForeground() behind iOS 26 availability check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit continueInForeground(_:alwaysConfirm:) is iOS 26+ only. On earlier versions, forceRestart() runs directly via the existing background audio session — no foreground continuation needed. Co-Authored-By: Claude Sonnet 4.6 --- RestartLiveActivityIntent.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/RestartLiveActivityIntent.swift b/RestartLiveActivityIntent.swift index 4a615b2e0..c594d5fa9 100644 --- a/RestartLiveActivityIntent.swift +++ b/RestartLiveActivityIntent.swift @@ -22,7 +22,9 @@ struct RestartLiveActivityIntent: AppIntent, ForegroundContinuableIntent { return .result(dialog: "Please enter your APNs credentials in LoopFollow settings to use the Live Activity.") } - try await continueInForeground() + if #available(iOS 26.0, *) { + try await continueInForeground() + } await MainActor.run { LiveActivityManager.shared.forceRestart() } From c2e4c34ab3b5f5fe2ff31303d8e7ab0ac385fa34 Mon Sep 17 00:00:00 2001 From: MtlPhil <76601115+MtlPhil@users.noreply.github.com> Date: Sat, 14 Mar 2026 09:45:36 -0400 Subject: [PATCH 24/82] fix: use startFromCurrentState in handleDidBecomeActive instead of forceRestart MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit forceRestart() was killing the LA that the RestartLiveActivityIntent had just created, because continueInForeground() triggers didBecomeActive. startFromCurrentState() reuses an existing active LA via startIfNeeded() and only creates one if none is present — no destructive race. Co-Authored-By: Claude Sonnet 4.6 --- LoopFollow/LiveActivity/LiveActivityManager.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/LoopFollow/LiveActivity/LiveActivityManager.swift b/LoopFollow/LiveActivity/LiveActivityManager.swift index 0d2cf7c56..042e05e96 100644 --- a/LoopFollow/LiveActivity/LiveActivityManager.swift +++ b/LoopFollow/LiveActivity/LiveActivityManager.swift @@ -28,7 +28,7 @@ final class LiveActivityManager { @objc private func handleDidBecomeActive() { guard Storage.shared.laEnabled.value else { return } - Task { @MainActor in self.forceRestart() } + Task { @MainActor in self.startFromCurrentState() } } @objc private func handleForeground() { From 2869d2492f907993cd9121b0721b6759f3bd0328 Mon Sep 17 00:00:00 2001 From: MtlPhil <76601115+MtlPhil@users.noreply.github.com> Date: Sat, 14 Mar 2026 12:56:37 -0400 Subject: [PATCH 25/82] feat: LA foreground tab navigation, button feedback, and toggle sync APNSettingsView: - "Restart Live Activity" button shows "Live Activity Restarted" for 2 seconds and disables itself during that window to prevent double-taps - Toggle observes Storage.shared.laEnabled via onReceive so it reflects changes made externally (e.g. by the App Intent / Shortcuts) LiveActivityManager: - handleDidBecomeActive posts .liveActivityDidForeground after startFromCurrentState - Notification.Name.liveActivityDidForeground extension defined here MainViewController: - Observes .liveActivityDidForeground; navigates to Snoozer tab if an alarm is currently active, otherwise navigates to tab 0 (Home) Co-Authored-By: Claude Sonnet 4.6 --- LoopFollow/LiveActivity/LiveActivityManager.swift | 11 ++++++++++- LoopFollow/Settings/APNSettingsView.swift | 11 ++++++++++- LoopFollow/ViewControllers/MainViewController.swift | 12 ++++++++++++ 3 files changed, 32 insertions(+), 2 deletions(-) diff --git a/LoopFollow/LiveActivity/LiveActivityManager.swift b/LoopFollow/LiveActivity/LiveActivityManager.swift index 042e05e96..660c25465 100644 --- a/LoopFollow/LiveActivity/LiveActivityManager.swift +++ b/LoopFollow/LiveActivity/LiveActivityManager.swift @@ -28,7 +28,10 @@ final class LiveActivityManager { @objc private func handleDidBecomeActive() { guard Storage.shared.laEnabled.value else { return } - Task { @MainActor in self.startFromCurrentState() } + Task { @MainActor in + self.startFromCurrentState() + NotificationCenter.default.post(name: .liveActivityDidForeground, object: nil) + } } @objc private func handleForeground() { @@ -465,3 +468,9 @@ final class LiveActivityManager { } } } + +extension Notification.Name { + /// Posted on the main actor after the Live Activity manager handles a didBecomeActive event. + /// MainViewController observes this to navigate to the Home or Snoozer tab. + static let liveActivityDidForeground = Notification.Name("liveActivityDidForeground") +} diff --git a/LoopFollow/Settings/APNSettingsView.swift b/LoopFollow/Settings/APNSettingsView.swift index 7f7828ca9..d43afe0bc 100644 --- a/LoopFollow/Settings/APNSettingsView.swift +++ b/LoopFollow/Settings/APNSettingsView.swift @@ -7,6 +7,7 @@ struct APNSettingsView: View { @State private var laEnabled: Bool = Storage.shared.laEnabled.value @State private var keyId: String = Storage.shared.lfKeyId.value @State private var apnsKey: String = Storage.shared.lfApnsKey.value + @State private var restartConfirmed = false var body: some View { Form { @@ -37,12 +38,20 @@ struct APNSettingsView: View { } Section { - Button("Restart Live Activity") { + Button(restartConfirmed ? "Live Activity Restarted" : "Restart Live Activity") { LiveActivityManager.shared.forceRestart() + restartConfirmed = true + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { + restartConfirmed = false + } } + .disabled(restartConfirmed) } } } + .onReceive(Storage.shared.laEnabled.$value) { newValue in + if newValue != laEnabled { laEnabled = newValue } + } .onChange(of: laEnabled) { newValue in Storage.shared.laEnabled.value = newValue if !newValue { diff --git a/LoopFollow/ViewControllers/MainViewController.swift b/LoopFollow/ViewControllers/MainViewController.swift index 9173bec9f..fe4d97d70 100644 --- a/LoopFollow/ViewControllers/MainViewController.swift +++ b/LoopFollow/ViewControllers/MainViewController.swift @@ -206,6 +206,7 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele let notificationCenter = NotificationCenter.default notificationCenter.addObserver(self, selector: #selector(appMovedToBackground), name: UIApplication.didEnterBackgroundNotification, object: nil) notificationCenter.addObserver(self, selector: #selector(appCameToForeground), name: UIApplication.willEnterForegroundNotification, object: nil) + notificationCenter.addObserver(self, selector: #selector(navigateOnLAForeground), name: .liveActivityDidForeground, object: nil) // Setup the Graph if firstGraphLoad { @@ -682,6 +683,17 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele updateNightscoutTabState() } + @objc private func navigateOnLAForeground() { + guard let tabBarController = tabBarController, + let vcs = tabBarController.viewControllers, !vcs.isEmpty else { return } + if Observable.shared.currentAlarm.value != nil, + let snoozerIndex = getSnoozerTabIndex(), snoozerIndex < vcs.count { + tabBarController.selectedIndex = snoozerIndex + } else { + tabBarController.selectedIndex = 0 + } + } + private func getSnoozerTabIndex() -> Int? { guard let tabBarController = tabBarController, let viewControllers = tabBarController.viewControllers else { return nil } From 3259dcbe14312c65da05dbefbf10e2b0f42ac0a6 Mon Sep 17 00:00:00 2001 From: MtlPhil <76601115+MtlPhil@users.noreply.github.com> Date: Sat, 14 Mar 2026 13:17:59 -0400 Subject: [PATCH 26/82] fix: flush LA update on willResignActive to ensure lock screen shows latest data The 5-second debounce on refreshFromCurrentState means a BG update arriving while the app is foreground won't reach the LA before the user returns to the lock screen. When the debounce fires in background, isForeground=false so the direct ActivityKit update is skipped. Fix: observe willResignActiveNotification, cancel the pending debounce, and immediately push the latest snapshot via direct ActivityKit update while the app is still foreground-active. The APNs push is also sent as a backup. Co-Authored-By: Claude Sonnet 4.6 --- .../LiveActivity/LiveActivityManager.swift | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/LoopFollow/LiveActivity/LiveActivityManager.swift b/LoopFollow/LiveActivity/LiveActivityManager.swift index 660c25465..81d05c4d7 100644 --- a/LoopFollow/LiveActivity/LiveActivityManager.swift +++ b/LoopFollow/LiveActivity/LiveActivityManager.swift @@ -24,6 +24,56 @@ final class LiveActivityManager { name: UIApplication.didBecomeActiveNotification, object: nil ) + NotificationCenter.default.addObserver( + self, + selector: #selector(handleWillResignActive), + name: UIApplication.willResignActiveNotification, + object: nil + ) + } + + /// Fires before the app loses focus (lock screen, home button, etc.). + /// Cancels any pending debounced refresh and pushes the latest snapshot + /// directly to the Live Activity while the app is still foreground-active, + /// ensuring the LA is up to date the moment the lock screen appears. + @objc private func handleWillResignActive() { + guard Storage.shared.laEnabled.value, let activity = current else { return } + + refreshWorkItem?.cancel() + refreshWorkItem = nil + + let provider = StorageCurrentGlucoseStateProvider() + guard let snapshot = GlucoseSnapshotBuilder.build(from: provider) else { return } + + LAAppGroupSettings.setThresholds( + lowMgdl: Storage.shared.lowLine.value, + highMgdl: Storage.shared.highLine.value + ) + GlucoseSnapshotStore.shared.save(snapshot) + + seq += 1 + let nextSeq = seq + let state = GlucoseLiveActivityAttributes.ContentState( + snapshot: snapshot, + seq: nextSeq, + reason: "resign-active", + producedAt: Date() + ) + let content = ActivityContent( + state: state, + staleDate: Date(timeIntervalSince1970: Storage.shared.laRenewBy.value), + relevanceScore: 100.0 + ) + + Task { + // Direct ActivityKit update — app is still active at this point. + await activity.update(content) + LogManager.shared.log(category: .general, message: "[LA] resign-active flush sent seq=\(nextSeq)", isDebug: true) + // Also send APNs so the extension receives the latest token-based update. + if let token = pushToken { + await APNSClient.shared.sendLiveActivityUpdate(pushToken: token, state: state) + } + } } @objc private func handleDidBecomeActive() { From 54e3ed979ed809380d84dfdd10e9cbee7bc9cb7a Mon Sep 17 00:00:00 2001 From: MtlPhil <76601115+MtlPhil@users.noreply.github.com> Date: Sat, 14 Mar 2026 13:22:11 -0400 Subject: [PATCH 27/82] feat: redesign Dynamic Island compact and expanded views Compact: - Trailing now shows delta instead of trend arrow (leading already showed BG) Expanded leading: - Large BG value at top - Second row: trend arrow, delta, and "Proj: X" in smaller text below Expanded trailing: - IOB and COB (moved from bottom) Expanded bottom: - "Updated at: HH:mm" (moved from trailing) Co-Authored-By: Claude Sonnet 4.6 --- .../LoopFollowLiveActivity.swift | 66 +++++++++---------- 1 file changed, 33 insertions(+), 33 deletions(-) diff --git a/LoopFollowLAExtension/LoopFollowLiveActivity.swift b/LoopFollowLAExtension/LoopFollowLiveActivity.swift index 2ef72f6fe..86441d974 100644 --- a/LoopFollowLAExtension/LoopFollowLiveActivity.swift +++ b/LoopFollowLAExtension/LoopFollowLiveActivity.swift @@ -193,30 +193,31 @@ private struct DynamicIslandLeadingView: View { let snapshot: GlucoseSnapshot var body: some View { if snapshot.isNotLooping { - VStack(alignment: .leading, spacing: 2) { - Text("⚠️ Not Looping") - .font(.system(size: 20, weight: .heavy, design: .rounded)) - .foregroundStyle(.white) - .tracking(1.0) - .lineLimit(1) - .minimumScaleFactor(0.7) - } + Text("⚠️ Not Looping") + .font(.system(size: 20, weight: .heavy, design: .rounded)) + .foregroundStyle(.white) + .tracking(1.0) + .lineLimit(1) + .minimumScaleFactor(0.7) } else { VStack(alignment: .leading, spacing: 2) { - HStack(alignment: .firstTextBaseline, spacing: 6) { - Text(LAFormat.glucose(snapshot)) - .font(.system(size: 28, weight: .bold, design: .rounded)) - .monospacedDigit() - .foregroundStyle(.white) + Text(LAFormat.glucose(snapshot)) + .font(.system(size: 28, weight: .bold, design: .rounded)) + .monospacedDigit() + .foregroundStyle(.white) + HStack(spacing: 5) { Text(LAFormat.trendArrow(snapshot)) - .font(.system(size: 16, weight: .semibold, design: .rounded)) - .foregroundStyle(.white.opacity(0.95)) - .padding(.top, 2) + .font(.system(size: 13, weight: .semibold, design: .rounded)) + .foregroundStyle(.white.opacity(0.9)) + Text(LAFormat.delta(snapshot)) + .font(.system(size: 13, weight: .semibold, design: .rounded)) + .monospacedDigit() + .foregroundStyle(.white.opacity(0.9)) + Text("Proj: \(LAFormat.projected(snapshot))") + .font(.system(size: 12, weight: .regular, design: .rounded)) + .monospacedDigit() + .foregroundStyle(.white.opacity(0.75)) } - Text(LAFormat.delta(snapshot)) - .font(.system(size: 14, weight: .semibold, design: .rounded)) - .monospacedDigit() - .foregroundStyle(.white.opacity(0.9)) } } } @@ -230,10 +231,11 @@ private struct DynamicIslandTrailingView: View { EmptyView() } else { VStack(alignment: .trailing, spacing: 3) { - Text("Upd \(LAFormat.updated(snapshot))") - .font(.system(size: 12, weight: .semibold, design: .rounded)) - .foregroundStyle(.white.opacity(0.85)) - Text("Proj \(LAFormat.projected(snapshot))") + Text("IOB \(LAFormat.iob(snapshot))") + .font(.system(size: 13, weight: .bold, design: .rounded)) + .monospacedDigit() + .foregroundStyle(.white.opacity(0.95)) + Text("COB \(LAFormat.cob(snapshot))") .font(.system(size: 13, weight: .bold, design: .rounded)) .monospacedDigit() .foregroundStyle(.white.opacity(0.95)) @@ -253,14 +255,11 @@ private struct DynamicIslandBottomView: View { .lineLimit(1) .minimumScaleFactor(0.75) } else { - HStack(spacing: 14) { - Text("IOB \(LAFormat.iob(snapshot))") - Text("COB \(LAFormat.cob(snapshot))") - } - .font(.system(size: 13, weight: .semibold, design: .rounded)) - .foregroundStyle(.white.opacity(0.92)) - .lineLimit(1) - .minimumScaleFactor(0.85) + Text("Updated at: \(LAFormat.updated(snapshot))") + .font(.system(size: 13, weight: .semibold, design: .rounded)) + .foregroundStyle(.white.opacity(0.92)) + .lineLimit(1) + .minimumScaleFactor(0.85) } } } @@ -276,8 +275,9 @@ private struct DynamicIslandCompactTrailingView: View { .lineLimit(1) .minimumScaleFactor(0.7) } else { - Text(LAFormat.trendArrow(snapshot)) + Text(LAFormat.delta(snapshot)) .font(.system(size: 14, weight: .semibold, design: .rounded)) + .monospacedDigit() .foregroundStyle(.white.opacity(0.95)) } } From 6752fb2b857e8798fcf9d332e2ca08ddf3e5936f Mon Sep 17 00:00:00 2001 From: MtlPhil <76601115+MtlPhil@users.noreply.github.com> Date: Sat, 14 Mar 2026 13:37:01 -0400 Subject: [PATCH 28/82] fix: match Proj text style to delta; add trailing padding to IOB/COB - Proj label now uses same size (13), weight (semibold), and opacity (0.9) as the delta text beside it - Added 6pt trailing padding to the expanded DI trailing VStack so the IOB decimal is not clipped by the Dynamic Island edge Co-Authored-By: Claude Sonnet 4.6 --- LoopFollowLAExtension/LoopFollowLiveActivity.swift | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/LoopFollowLAExtension/LoopFollowLiveActivity.swift b/LoopFollowLAExtension/LoopFollowLiveActivity.swift index 86441d974..d62c96b81 100644 --- a/LoopFollowLAExtension/LoopFollowLiveActivity.swift +++ b/LoopFollowLAExtension/LoopFollowLiveActivity.swift @@ -214,9 +214,9 @@ private struct DynamicIslandLeadingView: View { .monospacedDigit() .foregroundStyle(.white.opacity(0.9)) Text("Proj: \(LAFormat.projected(snapshot))") - .font(.system(size: 12, weight: .regular, design: .rounded)) + .font(.system(size: 13, weight: .semibold, design: .rounded)) .monospacedDigit() - .foregroundStyle(.white.opacity(0.75)) + .foregroundStyle(.white.opacity(0.9)) } } } @@ -240,6 +240,7 @@ private struct DynamicIslandTrailingView: View { .monospacedDigit() .foregroundStyle(.white.opacity(0.95)) } + .padding(.trailing, 6) } } } From a3a37a072257ad598d5b5e784e1002108ea90fda Mon Sep 17 00:00:00 2001 From: MtlPhil <76601115+MtlPhil@users.noreply.github.com> Date: Sat, 14 Mar 2026 13:57:48 -0400 Subject: [PATCH 29/82] feat: separate Live Activity and APN settings into distinct menus Per maintainer preference, the APNs credentials (Key ID and Key) remain in their own "APN" settings screen. A new separate "Live Activity" settings screen contains the enable/disable toggle and the Restart Live Activity button. - APNSettingsView: restored to Key ID + Key only (no toggle or restart button) - LiveActivitySettingsView: new view with laEnabled toggle and restart button (with 2-second confirmation feedback); toggle stays in sync via onReceive - SettingsMenuView: "APN" entry restored; new "Live Activity" entry added with dot.radiowaves icon; Sheet.liveActivity case wired to LiveActivitySettingsView Co-Authored-By: Claude Sonnet 4.6 --- LoopFollow/Settings/APNSettingsView.swift | 62 +++++-------------- .../Settings/LiveActivitySettingsView.swift | 42 +++++++++++++ LoopFollow/Settings/SettingsMenuView.swift | 10 ++- 3 files changed, 68 insertions(+), 46 deletions(-) create mode 100644 LoopFollow/Settings/LiveActivitySettingsView.swift diff --git a/LoopFollow/Settings/APNSettingsView.swift b/LoopFollow/Settings/APNSettingsView.swift index d43afe0bc..79b07e7cd 100644 --- a/LoopFollow/Settings/APNSettingsView.swift +++ b/LoopFollow/Settings/APNSettingsView.swift @@ -4,60 +4,32 @@ import SwiftUI struct APNSettingsView: View { - @State private var laEnabled: Bool = Storage.shared.laEnabled.value @State private var keyId: String = Storage.shared.lfKeyId.value @State private var apnsKey: String = Storage.shared.lfApnsKey.value - @State private var restartConfirmed = false var body: some View { Form { - Section(header: Text("Live Activity")) { - Toggle("Enable Live Activity", isOn: $laEnabled) - } - - if laEnabled { - Section(header: Text("LoopFollow APNs Credentials")) { - HStack { - Text("APNS Key ID") - TogglableSecureInput( - placeholder: "Enter APNS Key ID", - text: $keyId, - style: .singleLine - ) - } - - VStack(alignment: .leading) { - Text("APNS Key") - TogglableSecureInput( - placeholder: "Paste APNS Key", - text: $apnsKey, - style: .multiLine - ) - .frame(minHeight: 110) - } + Section(header: Text("LoopFollow APNs Credentials")) { + HStack { + Text("APNS Key ID") + TogglableSecureInput( + placeholder: "Enter APNS Key ID", + text: $keyId, + style: .singleLine + ) } - Section { - Button(restartConfirmed ? "Live Activity Restarted" : "Restart Live Activity") { - LiveActivityManager.shared.forceRestart() - restartConfirmed = true - DispatchQueue.main.asyncAfter(deadline: .now() + 2) { - restartConfirmed = false - } - } - .disabled(restartConfirmed) + VStack(alignment: .leading) { + Text("APNS Key") + TogglableSecureInput( + placeholder: "Paste APNS Key", + text: $apnsKey, + style: .multiLine + ) + .frame(minHeight: 110) } } } - .onReceive(Storage.shared.laEnabled.$value) { newValue in - if newValue != laEnabled { laEnabled = newValue } - } - .onChange(of: laEnabled) { newValue in - Storage.shared.laEnabled.value = newValue - if !newValue { - LiveActivityManager.shared.end(dismissalPolicy: .immediate) - } - } .onChange(of: keyId) { newValue in Storage.shared.lfKeyId.value = newValue } @@ -66,7 +38,7 @@ struct APNSettingsView: View { Storage.shared.lfApnsKey.value = apnsService.validateAndFixAPNSKey(newValue) } .preferredColorScheme(Storage.shared.appearanceMode.value.colorScheme) - .navigationTitle("Live Activity") + .navigationTitle("APN") .navigationBarTitleDisplayMode(.inline) } } diff --git a/LoopFollow/Settings/LiveActivitySettingsView.swift b/LoopFollow/Settings/LiveActivitySettingsView.swift new file mode 100644 index 000000000..bfe39b3ee --- /dev/null +++ b/LoopFollow/Settings/LiveActivitySettingsView.swift @@ -0,0 +1,42 @@ +// LoopFollow +// LiveActivitySettingsView.swift + +import SwiftUI + +struct LiveActivitySettingsView: View { + @State private var laEnabled: Bool = Storage.shared.laEnabled.value + @State private var restartConfirmed = false + + var body: some View { + Form { + Section(header: Text("Live Activity")) { + Toggle("Enable Live Activity", isOn: $laEnabled) + } + + if laEnabled { + Section { + Button(restartConfirmed ? "Live Activity Restarted" : "Restart Live Activity") { + LiveActivityManager.shared.forceRestart() + restartConfirmed = true + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { + restartConfirmed = false + } + } + .disabled(restartConfirmed) + } + } + } + .onReceive(Storage.shared.laEnabled.$value) { newValue in + if newValue != laEnabled { laEnabled = newValue } + } + .onChange(of: laEnabled) { newValue in + Storage.shared.laEnabled.value = newValue + if !newValue { + LiveActivityManager.shared.end(dismissalPolicy: .immediate) + } + } + .preferredColorScheme(Storage.shared.appearanceMode.value.colorScheme) + .navigationTitle("Live Activity") + .navigationBarTitleDisplayMode(.inline) + } +} diff --git a/LoopFollow/Settings/SettingsMenuView.swift b/LoopFollow/Settings/SettingsMenuView.swift index 8b562be9b..b57d00503 100644 --- a/LoopFollow/Settings/SettingsMenuView.swift +++ b/LoopFollow/Settings/SettingsMenuView.swift @@ -60,12 +60,18 @@ struct SettingsMenuView: View { settingsPath.value.append(Sheet.importExport) } - NavigationRow(title: "Live Activity", + NavigationRow(title: "APN", icon: "bell.and.waves.left.and.right") { settingsPath.value.append(Sheet.apn) } + NavigationRow(title: "Live Activity", + icon: "dot.radiowaves.left.and.right") + { + settingsPath.value.append(Sheet.liveActivity) + } + if !nightscoutURL.value.isEmpty { NavigationRow(title: "Information Display Settings", icon: "info.circle") @@ -245,6 +251,7 @@ private enum Sheet: Hashable, Identifiable { case infoDisplay case alarmSettings case apn + case liveActivity case remote case importExport case calendar, contact @@ -265,6 +272,7 @@ private enum Sheet: Hashable, Identifiable { case .infoDisplay: InfoDisplaySettingsView(viewModel: .init()) case .alarmSettings: AlarmSettingsView() case .apn: APNSettingsView() + case .liveActivity: LiveActivitySettingsView() case .remote: RemoteSettingsView(viewModel: .init()) case .importExport: ImportExportSettingsView() case .calendar: CalendarSettingsView() From 6f43a2c841912e6c33050940345449182a94fdd5 Mon Sep 17 00:00:00 2001 From: MtlPhil <76601115+MtlPhil@users.noreply.github.com> Date: Sat, 14 Mar 2026 14:01:15 -0400 Subject: [PATCH 30/82] Added Live Activity menu --- .../LiveActivitySettingsView.swift | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 LoopFollowLAExtension/LiveActivitySettingsView.swift diff --git a/LoopFollowLAExtension/LiveActivitySettingsView.swift b/LoopFollowLAExtension/LiveActivitySettingsView.swift new file mode 100644 index 000000000..bfe39b3ee --- /dev/null +++ b/LoopFollowLAExtension/LiveActivitySettingsView.swift @@ -0,0 +1,42 @@ +// LoopFollow +// LiveActivitySettingsView.swift + +import SwiftUI + +struct LiveActivitySettingsView: View { + @State private var laEnabled: Bool = Storage.shared.laEnabled.value + @State private var restartConfirmed = false + + var body: some View { + Form { + Section(header: Text("Live Activity")) { + Toggle("Enable Live Activity", isOn: $laEnabled) + } + + if laEnabled { + Section { + Button(restartConfirmed ? "Live Activity Restarted" : "Restart Live Activity") { + LiveActivityManager.shared.forceRestart() + restartConfirmed = true + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { + restartConfirmed = false + } + } + .disabled(restartConfirmed) + } + } + } + .onReceive(Storage.shared.laEnabled.$value) { newValue in + if newValue != laEnabled { laEnabled = newValue } + } + .onChange(of: laEnabled) { newValue in + Storage.shared.laEnabled.value = newValue + if !newValue { + LiveActivityManager.shared.end(dismissalPolicy: .immediate) + } + } + .preferredColorScheme(Storage.shared.appearanceMode.value.colorScheme) + .navigationTitle("Live Activity") + .navigationBarTitleDisplayMode(.inline) + } +} From 48ddc770c059d94a4d065081d0368fb4917177ad Mon Sep 17 00:00:00 2001 From: MtlPhil <76601115+MtlPhil@users.noreply.github.com> Date: Sat, 14 Mar 2026 14:05:02 -0400 Subject: [PATCH 31/82] chore: add LiveActivitySettingsView to Xcode project Co-Authored-By: Claude Sonnet 4.6 --- LoopFollow.xcodeproj/project.pbxproj | 4 ++++ .../LiveActivitySettingsView.swift | 0 2 files changed, 4 insertions(+) rename {LoopFollowLAExtension => LoopFollow}/LiveActivitySettingsView.swift (100%) diff --git a/LoopFollow.xcodeproj/project.pbxproj b/LoopFollow.xcodeproj/project.pbxproj index 1ec8f05eb..11a191956 100644 --- a/LoopFollow.xcodeproj/project.pbxproj +++ b/LoopFollow.xcodeproj/project.pbxproj @@ -22,6 +22,7 @@ 374A77B82F5BE1AC00E96858 /* GlucoseSnapshotStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A77AF2F5BE1AC00E96858 /* GlucoseSnapshotStore.swift */; }; 374A77B92F5BE1AC00E96858 /* PreferredGlucoseUnit.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A77B22F5BE1AC00E96858 /* PreferredGlucoseUnit.swift */; }; 379BECAA2F6588300069DC62 /* RestartLiveActivityIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 379BECA92F6588300069DC62 /* RestartLiveActivityIntent.swift */; }; + 379BECB02F65DA4B0069DC62 /* LiveActivitySettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 379BECAF2F65DA4B0069DC62 /* LiveActivitySettingsView.swift */; }; 37A4BDDB2F5B6B4A00EEB289 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 37A4BDDA2F5B6B4A00EEB289 /* WidgetKit.framework */; }; 37A4BDDD2F5B6B4A00EEB289 /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 37A4BDDC2F5B6B4A00EEB289 /* SwiftUI.framework */; }; 37A4BDE82F5B6B4C00EEB289 /* LoopFollowLAExtensionExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 37A4BDD92F5B6B4A00EEB289 /* LoopFollowLAExtensionExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; @@ -464,6 +465,7 @@ 374A77B32F5BE1AC00E96858 /* StorageCurrentGlucoseStateProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StorageCurrentGlucoseStateProvider.swift; sourceTree = ""; }; 374DAACA2F5B924B00BB663B /* LoopFollowLAExtensionExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = LoopFollowLAExtensionExtension.entitlements; sourceTree = ""; }; 379BECA92F6588300069DC62 /* RestartLiveActivityIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RestartLiveActivityIntent.swift; sourceTree = ""; }; + 379BECAF2F65DA4B0069DC62 /* LiveActivitySettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActivitySettingsView.swift; sourceTree = ""; }; 37A4BDD92F5B6B4A00EEB289 /* LoopFollowLAExtensionExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = LoopFollowLAExtensionExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 37A4BDDA2F5B6B4A00EEB289 /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = /System/Library/Frameworks/WidgetKit.framework; sourceTree = ""; }; 37A4BDDC2F5B6B4A00EEB289 /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = /System/Library/Frameworks/SwiftUI.framework; sourceTree = ""; }; @@ -1566,6 +1568,7 @@ FC8DEEE32485D1680075863F /* LoopFollow */ = { isa = PBXGroup; children = ( + 379BECAF2F65DA4B0069DC62 /* LiveActivitySettingsView.swift */, 376310762F5CD65100656488 /* LiveActivity */, 6589CC612E9E7D1600BB18FE /* Settings */, 65AC26702ED245DF00421360 /* Treatments */, @@ -2257,6 +2260,7 @@ DDFF3D852D14279B00BF9D9E /* BackgroundRefreshSettingsView.swift in Sources */, DDCF9A882D85FD33004DF4DD /* AlarmData.swift in Sources */, DD608A0C2C27415C00F91132 /* BackgroundAlertManager.swift in Sources */, + 379BECB02F65DA4B0069DC62 /* LiveActivitySettingsView.swift in Sources */, DD4878082C7B30BF0048F05C /* RemoteSettingsView.swift in Sources */, DDE75D2B2DE5E613007C1FC1 /* NavigationRow.swift in Sources */, DD4AFB3B2DB55CB600BB593F /* TimeOfDay.swift in Sources */, diff --git a/LoopFollowLAExtension/LiveActivitySettingsView.swift b/LoopFollow/LiveActivitySettingsView.swift similarity index 100% rename from LoopFollowLAExtension/LiveActivitySettingsView.swift rename to LoopFollow/LiveActivitySettingsView.swift From 5939ed9c3e42ac36e6f32a3d2a9656224e93574a Mon Sep 17 00:00:00 2001 From: MtlPhil <76601115+MtlPhil@users.noreply.github.com> Date: Sat, 14 Mar 2026 18:36:38 -0400 Subject: [PATCH 32/82] fix: LA tap navigation, manual dismissal prevention, and toggle start MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - widgetURL on lock screen LA → opens app via loopfollow://la-tap - Link views in expanded DI regions → same URL on tap - AppDelegate.application(_:open:) handles loopfollow://la-tap and posts .liveActivityDidForeground so MainViewController navigates to Snoozer (if alarm active) or Home tab — fires only on LA/DI taps, not every foreground - Notification.Name.liveActivityDidForeground moved outside #if macCatalyst so AppDelegate can reference it unconditionally - handleDidBecomeActive no longer posts .liveActivityDidForeground (was firing on every app foreground, not just LA taps) - attachStateObserver: .dismissed state (user swipe) sets laEnabled = false so the LA does not auto-restart when the app foregrounds - LiveActivitySettingsView: toggling ON calls startFromCurrentState() Co-Authored-By: Claude Sonnet 4.6 --- LoopFollow/Application/AppDelegate.swift | 10 ++++++++ .../LiveActivity/LiveActivityManager.swift | 16 ++++++++---- LoopFollow/LiveActivitySettingsView.swift | 4 ++- .../LoopFollowLiveActivity.swift | 25 ++++++++++++------- 4 files changed, 40 insertions(+), 15 deletions(-) diff --git a/LoopFollow/Application/AppDelegate.swift b/LoopFollow/Application/AppDelegate.swift index 604cf3e9e..14b5879d3 100644 --- a/LoopFollow/Application/AppDelegate.swift +++ b/LoopFollow/Application/AppDelegate.swift @@ -97,6 +97,16 @@ class AppDelegate: UIResponder, UIApplicationDelegate { completionHandler(.newData) } + // MARK: - URL handling + + func application(_: UIApplication, open url: URL, options _: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool { + if url.scheme == "loopfollow" && url.host == "la-tap" { + NotificationCenter.default.post(name: .liveActivityDidForeground, object: nil) + return true + } + return false + } + // MARK: UISceneSession Lifecycle func application(_: UIApplication, willFinishLaunchingWithOptions _: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { diff --git a/LoopFollow/LiveActivity/LiveActivityManager.swift b/LoopFollow/LiveActivity/LiveActivityManager.swift index bf0b21ad5..1504952bd 100644 --- a/LoopFollow/LiveActivity/LiveActivityManager.swift +++ b/LoopFollow/LiveActivity/LiveActivityManager.swift @@ -82,7 +82,6 @@ final class LiveActivityManager { guard Storage.shared.laEnabled.value else { return } Task { @MainActor in self.startFromCurrentState() - NotificationCenter.default.post(name: .liveActivityDidForeground, object: nil) } } @@ -513,18 +512,25 @@ final class LiveActivityManager { if state == .ended || state == .dismissed { if current?.id == activity.id { current = nil + Storage.shared.laRenewBy.value = 0 LogManager.shared.log(category: .general, message: "Live Activity cleared id=\(activity.id)", isDebug: true) } + if state == .dismissed { + // User manually swiped away the LA — treat as an implicit disable + // so it does not auto-restart when the app foregrounds. + Storage.shared.laEnabled.value = false + LogManager.shared.log(category: .general, message: "Live Activity dismissed by user — laEnabled set to false") + } } } } } } +#endif + extension Notification.Name { - /// Posted on the main actor after the Live Activity manager handles a didBecomeActive event. - /// MainViewController observes this to navigate to the Home or Snoozer tab. + /// Posted when the user taps the Live Activity or Dynamic Island. + /// Observers navigate to the Home or Snoozer tab as appropriate. static let liveActivityDidForeground = Notification.Name("liveActivityDidForeground") } - -#endif diff --git a/LoopFollow/LiveActivitySettingsView.swift b/LoopFollow/LiveActivitySettingsView.swift index bfe39b3ee..20ef50f5f 100644 --- a/LoopFollow/LiveActivitySettingsView.swift +++ b/LoopFollow/LiveActivitySettingsView.swift @@ -31,7 +31,9 @@ struct LiveActivitySettingsView: View { } .onChange(of: laEnabled) { newValue in Storage.shared.laEnabled.value = newValue - if !newValue { + if newValue { + LiveActivityManager.shared.startFromCurrentState() + } else { LiveActivityManager.shared.end(dismissalPolicy: .immediate) } } diff --git a/LoopFollowLAExtension/LoopFollowLiveActivity.swift b/LoopFollowLAExtension/LoopFollowLiveActivity.swift index d62c96b81..294ba8645 100644 --- a/LoopFollowLAExtension/LoopFollowLiveActivity.swift +++ b/LoopFollowLAExtension/LoopFollowLiveActivity.swift @@ -15,23 +15,30 @@ struct LoopFollowLiveActivityWidget: Widget { .activitySystemActionForegroundColor(.white) .activityBackgroundTint(LAColors.backgroundTint(for: context.state.snapshot)) .applyActivityContentMarginsFixIfAvailable() + .widgetURL(URL(string: "loopfollow://la-tap")!) } dynamicIsland: { context in // DYNAMIC ISLAND UI DynamicIsland { DynamicIslandExpandedRegion(.leading) { - DynamicIslandLeadingView(snapshot: context.state.snapshot) - .id(context.state.seq) - .overlay(RenewalOverlayView(show: context.state.snapshot.showRenewalOverlay)) + Link(destination: URL(string: "loopfollow://la-tap")!) { + DynamicIslandLeadingView(snapshot: context.state.snapshot) + .overlay(RenewalOverlayView(show: context.state.snapshot.showRenewalOverlay)) + } + .id(context.state.seq) } DynamicIslandExpandedRegion(.trailing) { - DynamicIslandTrailingView(snapshot: context.state.snapshot) - .id(context.state.seq) - .overlay(RenewalOverlayView(show: context.state.snapshot.showRenewalOverlay)) + Link(destination: URL(string: "loopfollow://la-tap")!) { + DynamicIslandTrailingView(snapshot: context.state.snapshot) + .overlay(RenewalOverlayView(show: context.state.snapshot.showRenewalOverlay)) + } + .id(context.state.seq) } DynamicIslandExpandedRegion(.bottom) { - DynamicIslandBottomView(snapshot: context.state.snapshot) - .id(context.state.seq) - .overlay(RenewalOverlayView(show: context.state.snapshot.showRenewalOverlay, showText: true)) + Link(destination: URL(string: "loopfollow://la-tap")!) { + DynamicIslandBottomView(snapshot: context.state.snapshot) + .overlay(RenewalOverlayView(show: context.state.snapshot.showRenewalOverlay, showText: true)) + } + .id(context.state.seq) } } compactLeading: { DynamicIslandCompactLeadingView(snapshot: context.state.snapshot) From ef3f2f54884019296a34f876b3215581c2b831bb Mon Sep 17 00:00:00 2001 From: MtlPhil <76601115+MtlPhil@users.noreply.github.com> Date: Sat, 14 Mar 2026 18:57:14 -0400 Subject: [PATCH 33/82] fix: end Live Activity on app force-quit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds endOnTerminate() to LiveActivityManager — blocks up to 3 s using Task.detached + DispatchSemaphore so the async activity.end() completes before the process exits. Called from applicationWillTerminate so the LA clears from the lock screen immediately on force-quit. laEnabled is preserved so the LA restarts correctly on next launch. Co-Authored-By: Claude Sonnet 4.6 --- LoopFollow/Application/AppDelegate.swift | 6 +++++- .../LiveActivity/LiveActivityManager.swift | 16 ++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/LoopFollow/Application/AppDelegate.swift b/LoopFollow/Application/AppDelegate.swift index 14b5879d3..c34b5b3b3 100644 --- a/LoopFollow/Application/AppDelegate.swift +++ b/LoopFollow/Application/AppDelegate.swift @@ -48,7 +48,11 @@ class AppDelegate: UIResponder, UIApplicationDelegate { return true } - func applicationWillTerminate(_: UIApplication) {} + func applicationWillTerminate(_: UIApplication) { + #if !targetEnvironment(macCatalyst) + LiveActivityManager.shared.endOnTerminate() + #endif + } // MARK: - Remote Notifications diff --git a/LoopFollow/LiveActivity/LiveActivityManager.swift b/LoopFollow/LiveActivity/LiveActivityManager.swift index 1504952bd..7e8b4dd20 100644 --- a/LoopFollow/LiveActivity/LiveActivityManager.swift +++ b/LoopFollow/LiveActivity/LiveActivityManager.swift @@ -189,6 +189,22 @@ final class LiveActivityManager { } } + /// Called from applicationWillTerminate. Ends the LA synchronously (blocking + /// up to 3 s) so it clears from the lock screen before the process exits. + /// Does not clear laEnabled — the user's preference is preserved for relaunch. + func endOnTerminate() { + guard let activity = current else { return } + current = nil + Storage.shared.laRenewBy.value = 0 + let semaphore = DispatchSemaphore(value: 0) + Task.detached { + await activity.end(nil, dismissalPolicy: .immediate) + semaphore.signal() + } + _ = semaphore.wait(timeout: .now() + 3) + LogManager.shared.log(category: .general, message: "[LA] ended on app terminate") + } + func end(dismissalPolicy: ActivityUIDismissalPolicy = .default) { updateTask?.cancel() updateTask = nil From 11aeadd5b480b3aa2ea60b5e039190d2ea87ccfe Mon Sep 17 00:00:00 2001 From: MtlPhil <76601115+MtlPhil@users.noreply.github.com> Date: Sat, 14 Mar 2026 19:02:24 -0400 Subject: [PATCH 34/82] fix: use dismissedByUser flag instead of disabling laEnabled on manual dismiss - Manual LA swipe-away sets dismissedByUser (in-memory) instead of laEnabled = false, preserving the user's preference - startFromCurrentState() guards on !dismissedByUser so the LA does not auto-restart on foreground, BG refresh, or handleDidBecomeActive - forceRestart() clears dismissedByUser before starting, so the Restart button and App Intent both work as the explicit re-enable mechanism - Toggle ON in settings calls forceRestart() (clears flag + starts) instead of startFromCurrentState() which would have been blocked by the flag - dismissedByUser resets to false on app relaunch (in-memory only), so a kill + relaunch starts the LA fresh as expected Co-Authored-By: Claude Sonnet 4.6 --- .../LiveActivity/LiveActivityManager.swift | 17 ++++++++++++----- LoopFollow/LiveActivitySettingsView.swift | 2 +- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/LoopFollow/LiveActivity/LiveActivityManager.swift b/LoopFollow/LiveActivity/LiveActivityManager.swift index 7e8b4dd20..5521c2dfa 100644 --- a/LoopFollow/LiveActivity/LiveActivityManager.swift +++ b/LoopFollow/LiveActivity/LiveActivityManager.swift @@ -139,6 +139,11 @@ final class LiveActivityManager { private var pushToken: String? private var tokenObservationTask: Task? private var refreshWorkItem: DispatchWorkItem? + /// Set when the user manually swipes away the LA. Blocks auto-restart until + /// an explicit user action (Restart button, App Intent) clears it. + /// In-memory only — resets to false on app relaunch, so a kill + relaunch + /// starts fresh as expected. + private var dismissedByUser = false // MARK: - Public API @@ -247,6 +252,7 @@ final class LiveActivityManager { func forceRestart() { guard Storage.shared.laEnabled.value else { return } LogManager.shared.log(category: .general, message: "[LA] forceRestart called") + dismissedByUser = false Storage.shared.laRenewBy.value = 0 Storage.shared.laRenewalFailed.value = false current = nil @@ -266,7 +272,7 @@ final class LiveActivityManager { } func startFromCurrentState() { - guard Storage.shared.laEnabled.value else { return } + guard Storage.shared.laEnabled.value, !dismissedByUser else { return } endOrphanedActivities() let provider = StorageCurrentGlucoseStateProvider() if let snapshot = GlucoseSnapshotBuilder.build(from: provider) { @@ -532,10 +538,11 @@ final class LiveActivityManager { LogManager.shared.log(category: .general, message: "Live Activity cleared id=\(activity.id)", isDebug: true) } if state == .dismissed { - // User manually swiped away the LA — treat as an implicit disable - // so it does not auto-restart when the app foregrounds. - Storage.shared.laEnabled.value = false - LogManager.shared.log(category: .general, message: "Live Activity dismissed by user — laEnabled set to false") + // User manually swiped away the LA. Block auto-restart until + // the user explicitly restarts via button or App Intent. + // laEnabled is left true — the user's preference is preserved. + dismissedByUser = true + LogManager.shared.log(category: .general, message: "Live Activity dismissed by user — auto-restart blocked until explicit restart") } } } diff --git a/LoopFollow/LiveActivitySettingsView.swift b/LoopFollow/LiveActivitySettingsView.swift index 20ef50f5f..0a29d702a 100644 --- a/LoopFollow/LiveActivitySettingsView.swift +++ b/LoopFollow/LiveActivitySettingsView.swift @@ -32,7 +32,7 @@ struct LiveActivitySettingsView: View { .onChange(of: laEnabled) { newValue in Storage.shared.laEnabled.value = newValue if newValue { - LiveActivityManager.shared.startFromCurrentState() + LiveActivityManager.shared.forceRestart() } else { LiveActivityManager.shared.end(dismissalPolicy: .immediate) } From c81911c6b0aaff28994990ddf25cab672a4d589b Mon Sep 17 00:00:00 2001 From: MtlPhil <76601115+MtlPhil@users.noreply.github.com> Date: Sat, 14 Mar 2026 19:06:31 -0400 Subject: [PATCH 35/82] fix: dismiss modal (Settings sheet) before tab switch on LA tap navigateOnLAForeground now checks tabBarController.presentedViewController and dismisses it before switching to the target tab, matching the existing pattern used elsewhere in MainViewController for the same scenario. Co-Authored-By: Claude Sonnet 4.6 --- .../ViewControllers/MainViewController.swift | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/LoopFollow/ViewControllers/MainViewController.swift b/LoopFollow/ViewControllers/MainViewController.swift index 8787948d7..516b58e3c 100644 --- a/LoopFollow/ViewControllers/MainViewController.swift +++ b/LoopFollow/ViewControllers/MainViewController.swift @@ -686,11 +686,21 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele @objc private func navigateOnLAForeground() { guard let tabBarController = tabBarController, let vcs = tabBarController.viewControllers, !vcs.isEmpty else { return } + + let targetIndex: Int if Observable.shared.currentAlarm.value != nil, let snoozerIndex = getSnoozerTabIndex(), snoozerIndex < vcs.count { - tabBarController.selectedIndex = snoozerIndex + targetIndex = snoozerIndex } else { - tabBarController.selectedIndex = 0 + targetIndex = 0 + } + + if let presented = tabBarController.presentedViewController { + presented.dismiss(animated: false) { + tabBarController.selectedIndex = targetIndex + } + } else { + tabBarController.selectedIndex = targetIndex } } From 9ccc806e8bfa2daa874c4ab17e72198aeeb5e88f Mon Sep 17 00:00:00 2001 From: MtlPhil <76601115+MtlPhil@users.noreply.github.com> Date: Sat, 14 Mar 2026 21:03:52 -0400 Subject: [PATCH 36/82] fix: LA tap navigation timing and LA reappear-after-dismiss Navigation fix: - loopfollow://la-tap URL can arrive before applicationDidBecomeActive while UIKit is still restoring the view hierarchy from background. At that moment tabBarController.presentedViewController is nil so the Settings modal is never dismissed and the navigation notification fires too early. - Now stores a pendingLATapNavigation flag if app is not yet .active, and fires the notification in applicationDidBecomeActive when the full view hierarchy (including presented modals) is restored. LA reappear fix: - performRefresh() calls startIfNeeded() directly, bypassing the dismissedByUser guard that only exists in startFromCurrentState(). On every BG refresh with laEnabled=true and current==nil (post-dismiss), it would recreate the LA. - Added !dismissedByUser guard to refreshFromCurrentState() so the entire refresh pipeline is skipped while dismissed, matching the existing guard in startFromCurrentState(). Co-Authored-By: Claude Sonnet 4.6 --- LoopFollow/Application/AppDelegate.swift | 24 +++++++++++++++++-- .../LiveActivity/LiveActivityManager.swift | 2 +- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/LoopFollow/Application/AppDelegate.swift b/LoopFollow/Application/AppDelegate.swift index c34b5b3b3..802175527 100644 --- a/LoopFollow/Application/AppDelegate.swift +++ b/LoopFollow/Application/AppDelegate.swift @@ -103,14 +103,34 @@ class AppDelegate: UIResponder, UIApplicationDelegate { // MARK: - URL handling - func application(_: UIApplication, open url: URL, options _: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool { + /// Set when loopfollow://la-tap arrives while the app is still transitioning + /// from background. Consumed in applicationDidBecomeActive once the view + /// hierarchy is fully restored and the modal can actually be dismissed. + private var pendingLATapNavigation = false + + func application(_ app: UIApplication, open url: URL, options _: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool { if url.scheme == "loopfollow" && url.host == "la-tap" { - NotificationCenter.default.post(name: .liveActivityDidForeground, object: nil) + if app.applicationState == .active { + // App already fully active — safe to navigate immediately. + NotificationCenter.default.post(name: .liveActivityDidForeground, object: nil) + } else { + // URL arrived during the background→foreground transition. + // Defer until applicationDidBecomeActive so UIKit has finished + // restoring the view hierarchy (including any presented modals). + pendingLATapNavigation = true + } return true } return false } + func applicationDidBecomeActive(_: UIApplication) { + if pendingLATapNavigation { + pendingLATapNavigation = false + NotificationCenter.default.post(name: .liveActivityDidForeground, object: nil) + } + } + // MARK: UISceneSession Lifecycle func application(_: UIApplication, willFinishLaunchingWithOptions _: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { diff --git a/LoopFollow/LiveActivity/LiveActivityManager.swift b/LoopFollow/LiveActivity/LiveActivityManager.swift index 5521c2dfa..41f129c60 100644 --- a/LoopFollow/LiveActivity/LiveActivityManager.swift +++ b/LoopFollow/LiveActivity/LiveActivityManager.swift @@ -286,7 +286,7 @@ final class LiveActivityManager { } func refreshFromCurrentState(reason: String) { - guard Storage.shared.laEnabled.value else { return } + guard Storage.shared.laEnabled.value, !dismissedByUser else { return } refreshWorkItem?.cancel() let workItem = DispatchWorkItem { [weak self] in self?.performRefresh(reason: reason) From 31a8e97b76ef32d59bc6eed513f76b10882f946f Mon Sep 17 00:00:00 2001 From: MtlPhil <76601115+MtlPhil@users.noreply.github.com> Date: Sat, 14 Mar 2026 23:29:13 -0400 Subject: [PATCH 37/82] fix: handle loopfollow://la-tap URL in SceneDelegate, not AppDelegate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With scene-based lifecycle (iOS 13+), widget/LA tap URLs are delivered to scene(_:openURLContexts:) on UIWindowSceneDelegate — AppDelegate.application (_:open:url:options:) is never called. Our previous handler was dead code. SceneDelegate now implements scene(_:openURLContexts:) which posts .liveActivityDidForeground on the next run loop (via DispatchQueue.main.async) to let the view hierarchy fully settle before navigateOnLAForeground() runs. Also handles the edge case where the URL arrives before sceneDidBecomeActive by storing a pendingLATapNavigation flag. Co-Authored-By: Claude Sonnet 4.6 --- LoopFollow/Application/AppDelegate.swift | 31 +++------------------- LoopFollow/Application/SceneDelegate.swift | 18 +++++++++++++ 2 files changed, 21 insertions(+), 28 deletions(-) diff --git a/LoopFollow/Application/AppDelegate.swift b/LoopFollow/Application/AppDelegate.swift index 802175527..bf87a3343 100644 --- a/LoopFollow/Application/AppDelegate.swift +++ b/LoopFollow/Application/AppDelegate.swift @@ -102,34 +102,9 @@ class AppDelegate: UIResponder, UIApplicationDelegate { } // MARK: - URL handling - - /// Set when loopfollow://la-tap arrives while the app is still transitioning - /// from background. Consumed in applicationDidBecomeActive once the view - /// hierarchy is fully restored and the modal can actually be dismissed. - private var pendingLATapNavigation = false - - func application(_ app: UIApplication, open url: URL, options _: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool { - if url.scheme == "loopfollow" && url.host == "la-tap" { - if app.applicationState == .active { - // App already fully active — safe to navigate immediately. - NotificationCenter.default.post(name: .liveActivityDidForeground, object: nil) - } else { - // URL arrived during the background→foreground transition. - // Defer until applicationDidBecomeActive so UIKit has finished - // restoring the view hierarchy (including any presented modals). - pendingLATapNavigation = true - } - return true - } - return false - } - - func applicationDidBecomeActive(_: UIApplication) { - if pendingLATapNavigation { - pendingLATapNavigation = false - NotificationCenter.default.post(name: .liveActivityDidForeground, object: nil) - } - } + // Note: with scene-based lifecycle (iOS 13+), URLs are delivered to + // SceneDelegate.scene(_:openURLContexts:) — not here. The scene delegate + // handles loopfollow://la-tap for Live Activity tap navigation. // MARK: UISceneSession Lifecycle diff --git a/LoopFollow/Application/SceneDelegate.swift b/LoopFollow/Application/SceneDelegate.swift index b15fb0bd5..a8fbb236f 100644 --- a/LoopFollow/Application/SceneDelegate.swift +++ b/LoopFollow/Application/SceneDelegate.swift @@ -32,6 +32,24 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { func sceneDidBecomeActive(_: UIScene) { // Called when the scene has moved from an inactive state to an active state. // Use this method to restart any tasks that were paused (or not yet started) when the scene was inactive. + if pendingLATapNavigation { + pendingLATapNavigation = false + NotificationCenter.default.post(name: .liveActivityDidForeground, object: nil) + } + } + + /// Set when loopfollow://la-tap arrives before the scene is fully active. + /// Consumed in sceneDidBecomeActive once the view hierarchy is restored. + private var pendingLATapNavigation = false + + func scene(_: UIScene, openURLContexts URLContexts: Set) { + guard URLContexts.contains(where: { $0.url.scheme == "loopfollow" && $0.url.host == "la-tap" }) else { return } + // scene(_:openURLContexts:) fires after sceneDidBecomeActive when the app + // foregrounds from background. Post on the next run loop so the view + // hierarchy (including any presented modals) is fully settled. + DispatchQueue.main.async { + NotificationCenter.default.post(name: .liveActivityDidForeground, object: nil) + } } func sceneWillResignActive(_: UIScene) { From 26b244e31dc01285b45e9326413bdf7d50cb34c0 Mon Sep 17 00:00:00 2001 From: MtlPhil <76601115+MtlPhil@users.noreply.github.com> Date: Sun, 15 Mar 2026 11:25:14 -0400 Subject: [PATCH 38/82] =?UTF-8?q?Live=20Activity=20=E2=80=94=20UX=20Improv?= =?UTF-8?q?ements=20and=20Reliability=20Fixes=20(#540)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: Live Activity auto-renewal to work around 8-hour system limit Co-Authored-By: Claude Sonnet 4.6 * test: reduce LA renewal threshold to 20 min for testing Co-Authored-By: Claude Sonnet 4.6 * feat: improve LA renewal robustness and stale indicator - staleDate on every ActivityContent now tracks the renewal deadline, so the system shows Apple's built-in stale indicator if renewal fails - Add laRenewalFailed StorageValue; set on Activity.request() failure, cleared on any successful LA start - Observe willEnterForegroundNotification: retry startIfNeeded() if a previous renewal attempt failed - New-first renewal order: request the replacement LA before ending the old one — if the request throws the existing LA stays alive so the user keeps live data until the system kills it at the 8-hour mark Co-Authored-By: Claude Sonnet 4.6 * feat: renewal warning overlay + restore 7.5h threshold - Restore renewalThreshold to 7.5 * 3600 (testing complete) - Add showRenewalOverlay: Bool to GlucoseSnapshot (Codable, default false) - GlucoseSnapshotBuilder sets it true when now >= laRenewBy - 1800 (30 minutes before the renewal deadline) - Lock screen: 60% gray overlay with "Tap to update" centered in white, layered above the existing isNotLooping overlay - DI expanded: RenewalOverlayView applied to leading/trailing/bottom regions; "Tap to update" text shown on the bottom region only - showRenewalOverlay resets to false automatically on renewal since laRenewBy is updated and the next snapshot rebuild clears the flag Co-Authored-By: Claude Sonnet 4.6 * fix: overlay not appearing + foreground restart not working Overlay rendering: - Replace Group{if condition{ZStack{RoundedRectangle...}}} with a permanently-present ZStack toggled via .opacity(). The Group/if pattern causes SwiftUI sizing ambiguity when the condition transitions from false→true inside an .overlay(), producing a zero-size result. The .opacity() approach keeps a stable view hierarchy. - Same fix applied to RenewalOverlayView used on DI expanded regions. Foreground restart: - handleForeground() was calling startIfNeeded(), which finds the still-alive (failed-to-renew) LA in Activity.activities and reuses it, doing nothing useful. Fixed to manually nil out current, cancel all tasks, await activity.end(.immediate), then startFromCurrentState(). Overlay timing: - Changed warning window from 30 min (1800s) to 20 min (1200s) before the renewal deadline, matching the intended test cadence. Logging: - handleForeground: log on every foreground event with laRenewalFailed value - renewIfNeeded: log how many seconds past the deadline when firing - GlucoseSnapshotBuilder: log when overlay activates with seconds to deadline - performRefresh: log when sending an update with the overlay visible Co-Authored-By: Claude Sonnet 4.6 * test: set renewalThreshold to 20 min for testing Co-Authored-By: Claude Sonnet 4.6 * fix: renewal overlay not clearing after LA is refreshed Normal renewal path (renewIfNeeded success): - Build a fresh snapshot with showRenewalOverlay: false for the new LA's initial content — it has a new deadline so the overlay should never be visible from the first frame. - Save that fresh snapshot to GlucoseSnapshotStore so the next duplicate check has the correct baseline and doesn't suppress the first real BG update. Foreground restart path (handleForeground): - Zero laRenewBy before calling startFromCurrentState() so GlucoseSnapshotBuilder computes showRenewalOverlay = false for the seed snapshot. startIfNeeded() then sets the new deadline after the request succeeds. Co-Authored-By: Claude Sonnet 4.6 * fix: overlay permanently active when warning window equals threshold With renewalThreshold=20min and the hardcoded 1200s warning window, renewBy-1200 = start, so showRenewalOverlay is always true from the moment the LA begins. Extract a named renewalWarning constant (5 min for testing) so the warning window is always less than the threshold. The builder now reads LiveActivityManager.renewalWarning instead of a hardcoded literal. Production values to restore before merging: renewalThreshold = 7.5 * 3600 renewalWarning = 20 * 60 Co-Authored-By: Claude Sonnet 4.6 * fix: include showRenewalOverlay in APNs payload and clear laRenewBy synchronously APNSClient was missing showRenewalOverlay from the push payload, so background APNs updates never delivered the overlay flag to the extension — only foreground direct ActivityKit updates did. In handleForeground, laRenewBy is now zeroed synchronously before spawning the async end/restart Task. This means any snapshot built between the foreground notification and the new LA start (e.g. from viewDidAppear's startFromCurrentState) computes showRenewalOverlay = false rather than reading the stale expired deadline. Co-Authored-By: Claude Sonnet 4.6 * fix: await LA end before restarting on foreground retry to avoid reuse path Reset laRenewBy and laRenewalFailed synchronously before tearing down the failed LA, then await activity.end() before calling startFromCurrentState(). This guarantees Activity.activities is clear when startIfNeeded() runs, so it takes the fresh-request path and writes a new laRenewBy. startFromCurrentState rebuilds the snapshot with showRenewalOverlay=false (laRenewBy=0), saves it to the store, then startIfNeeded uses that clean snapshot as the seed. Co-Authored-By: Claude Sonnet 4.6 * chore: restore production renewal timing (7.5h threshold, 20min warning) Co-Authored-By: Claude Sonnet 4.6 * feat: laEnabled toggle, forceRestart(), and RestartLiveActivityIntent - SettingsMenuView: rename "APN" menu entry to "Live Activity" - Storage: add laEnabled: Bool StorageValue (default false) - APNSettingsView: on/off toggle bound to laEnabled; APNs key fields and Restart button shown only when enabled; disabling immediately ends any running LA - LiveActivityManager: - forceRestart() (@MainActor) ends all running activities, resets laRenewBy and laRenewalFailed, then calls startFromCurrentState() - laEnabled guard added to startFromCurrentState(), refreshFromCurrentState(), handleForeground(), and handleDidBecomeActive() - didBecomeActiveNotification observer calls forceRestart() on every foreground transition when laEnabled is true - RestartLiveActivityIntent: AppIntent + ForegroundContinuableIntent; sets laEnabled, validates credentials, opens settings deep link if missing, otherwise calls forceRestart() in foreground; LoopFollowAppShortcuts exposes the intent with Siri phrase Note: RestartLiveActivityIntent.swift must be added to the app target in Xcode. Co-Authored-By: Claude Sonnet 4.6 * Added RestartLiveActivityIntent to project * fix: resolve two build errors in LiveActivityManager and RestartLiveActivityIntent - LiveActivityManager: handleDidBecomeActive() calls @MainActor forceRestart() via Task { @MainActor in ... } to satisfy Swift concurrency isolation - RestartLiveActivityIntent: drop ForegroundContinuableIntent — continueInForeground() was renamed to continueInForeground(_:alwaysConfirm:) in the iOS 26 SDK and requires iOS 26+; the didBecomeActiveNotification observer handles restart when the app comes to foreground, making explicit continuation unnecessary Co-Authored-By: Claude Sonnet 4.6 * fix: guard continueInForeground() behind iOS 26 availability check continueInForeground(_:alwaysConfirm:) is iOS 26+ only. On earlier versions, forceRestart() runs directly via the existing background audio session — no foreground continuation needed. Co-Authored-By: Claude Sonnet 4.6 * fix: use startFromCurrentState in handleDidBecomeActive instead of forceRestart forceRestart() was killing the LA that the RestartLiveActivityIntent had just created, because continueInForeground() triggers didBecomeActive. startFromCurrentState() reuses an existing active LA via startIfNeeded() and only creates one if none is present — no destructive race. Co-Authored-By: Claude Sonnet 4.6 * feat: LA foreground tab navigation, button feedback, and toggle sync APNSettingsView: - "Restart Live Activity" button shows "Live Activity Restarted" for 2 seconds and disables itself during that window to prevent double-taps - Toggle observes Storage.shared.laEnabled via onReceive so it reflects changes made externally (e.g. by the App Intent / Shortcuts) LiveActivityManager: - handleDidBecomeActive posts .liveActivityDidForeground after startFromCurrentState - Notification.Name.liveActivityDidForeground extension defined here MainViewController: - Observes .liveActivityDidForeground; navigates to Snoozer tab if an alarm is currently active, otherwise navigates to tab 0 (Home) Co-Authored-By: Claude Sonnet 4.6 * fix: flush LA update on willResignActive to ensure lock screen shows latest data The 5-second debounce on refreshFromCurrentState means a BG update arriving while the app is foreground won't reach the LA before the user returns to the lock screen. When the debounce fires in background, isForeground=false so the direct ActivityKit update is skipped. Fix: observe willResignActiveNotification, cancel the pending debounce, and immediately push the latest snapshot via direct ActivityKit update while the app is still foreground-active. The APNs push is also sent as a backup. Co-Authored-By: Claude Sonnet 4.6 * feat: redesign Dynamic Island compact and expanded views Compact: - Trailing now shows delta instead of trend arrow (leading already showed BG) Expanded leading: - Large BG value at top - Second row: trend arrow, delta, and "Proj: X" in smaller text below Expanded trailing: - IOB and COB (moved from bottom) Expanded bottom: - "Updated at: HH:mm" (moved from trailing) Co-Authored-By: Claude Sonnet 4.6 * fix: match Proj text style to delta; add trailing padding to IOB/COB - Proj label now uses same size (13), weight (semibold), and opacity (0.9) as the delta text beside it - Added 6pt trailing padding to the expanded DI trailing VStack so the IOB decimal is not clipped by the Dynamic Island edge Co-Authored-By: Claude Sonnet 4.6 * feat: separate Live Activity and APN settings into distinct menus Per maintainer preference, the APNs credentials (Key ID and Key) remain in their own "APN" settings screen. A new separate "Live Activity" settings screen contains the enable/disable toggle and the Restart Live Activity button. - APNSettingsView: restored to Key ID + Key only (no toggle or restart button) - LiveActivitySettingsView: new view with laEnabled toggle and restart button (with 2-second confirmation feedback); toggle stays in sync via onReceive - SettingsMenuView: "APN" entry restored; new "Live Activity" entry added with dot.radiowaves icon; Sheet.liveActivity case wired to LiveActivitySettingsView Co-Authored-By: Claude Sonnet 4.6 * Added Live Activity menu * chore: add LiveActivitySettingsView to Xcode project Co-Authored-By: Claude Sonnet 4.6 * fix: LA tap navigation, manual dismissal prevention, and toggle start - widgetURL on lock screen LA → opens app via loopfollow://la-tap - Link views in expanded DI regions → same URL on tap - AppDelegate.application(_:open:) handles loopfollow://la-tap and posts .liveActivityDidForeground so MainViewController navigates to Snoozer (if alarm active) or Home tab — fires only on LA/DI taps, not every foreground - Notification.Name.liveActivityDidForeground moved outside #if macCatalyst so AppDelegate can reference it unconditionally - handleDidBecomeActive no longer posts .liveActivityDidForeground (was firing on every app foreground, not just LA taps) - attachStateObserver: .dismissed state (user swipe) sets laEnabled = false so the LA does not auto-restart when the app foregrounds - LiveActivitySettingsView: toggling ON calls startFromCurrentState() Co-Authored-By: Claude Sonnet 4.6 * fix: end Live Activity on app force-quit Adds endOnTerminate() to LiveActivityManager — blocks up to 3 s using Task.detached + DispatchSemaphore so the async activity.end() completes before the process exits. Called from applicationWillTerminate so the LA clears from the lock screen immediately on force-quit. laEnabled is preserved so the LA restarts correctly on next launch. Co-Authored-By: Claude Sonnet 4.6 * fix: use dismissedByUser flag instead of disabling laEnabled on manual dismiss - Manual LA swipe-away sets dismissedByUser (in-memory) instead of laEnabled = false, preserving the user's preference - startFromCurrentState() guards on !dismissedByUser so the LA does not auto-restart on foreground, BG refresh, or handleDidBecomeActive - forceRestart() clears dismissedByUser before starting, so the Restart button and App Intent both work as the explicit re-enable mechanism - Toggle ON in settings calls forceRestart() (clears flag + starts) instead of startFromCurrentState() which would have been blocked by the flag - dismissedByUser resets to false on app relaunch (in-memory only), so a kill + relaunch starts the LA fresh as expected Co-Authored-By: Claude Sonnet 4.6 * fix: dismiss modal (Settings sheet) before tab switch on LA tap navigateOnLAForeground now checks tabBarController.presentedViewController and dismisses it before switching to the target tab, matching the existing pattern used elsewhere in MainViewController for the same scenario. Co-Authored-By: Claude Sonnet 4.6 * fix: LA tap navigation timing and LA reappear-after-dismiss Navigation fix: - loopfollow://la-tap URL can arrive before applicationDidBecomeActive while UIKit is still restoring the view hierarchy from background. At that moment tabBarController.presentedViewController is nil so the Settings modal is never dismissed and the navigation notification fires too early. - Now stores a pendingLATapNavigation flag if app is not yet .active, and fires the notification in applicationDidBecomeActive when the full view hierarchy (including presented modals) is restored. LA reappear fix: - performRefresh() calls startIfNeeded() directly, bypassing the dismissedByUser guard that only exists in startFromCurrentState(). On every BG refresh with laEnabled=true and current==nil (post-dismiss), it would recreate the LA. - Added !dismissedByUser guard to refreshFromCurrentState() so the entire refresh pipeline is skipped while dismissed, matching the existing guard in startFromCurrentState(). Co-Authored-By: Claude Sonnet 4.6 * fix: handle loopfollow://la-tap URL in SceneDelegate, not AppDelegate With scene-based lifecycle (iOS 13+), widget/LA tap URLs are delivered to scene(_:openURLContexts:) on UIWindowSceneDelegate — AppDelegate.application (_:open:url:options:) is never called. Our previous handler was dead code. SceneDelegate now implements scene(_:openURLContexts:) which posts .liveActivityDidForeground on the next run loop (via DispatchQueue.main.async) to let the view hierarchy fully settle before navigateOnLAForeground() runs. Also handles the edge case where the URL arrives before sceneDidBecomeActive by storing a pendingLATapNavigation flag. Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Claude Sonnet 4.6 --- LoopFollow.xcodeproj/project.pbxproj | 24 ++-- LoopFollow/Application/AppDelegate.swift | 11 +- LoopFollow/Application/SceneDelegate.swift | 18 +++ .../LiveActivity/LiveActivityManager.swift | 126 ++++++++++++++++++ .../RestartLiveActivityIntent.swift | 41 ++++++ LoopFollow/LiveActivitySettingsView.swift | 44 ++++++ .../Settings/LiveActivitySettingsView.swift | 42 ++++++ LoopFollow/Settings/SettingsMenuView.swift | 8 ++ LoopFollow/Storage/Storage.swift | 3 +- .../ViewControllers/MainViewController.swift | 22 +++ .../LoopFollowLiveActivity.swift | 92 +++++++------ RestartLiveActivityIntent.swift | 45 +++++++ 12 files changed, 423 insertions(+), 53 deletions(-) create mode 100644 LoopFollow/LiveActivity/RestartLiveActivityIntent.swift create mode 100644 LoopFollow/LiveActivitySettingsView.swift create mode 100644 LoopFollow/Settings/LiveActivitySettingsView.swift create mode 100644 RestartLiveActivityIntent.swift diff --git a/LoopFollow.xcodeproj/project.pbxproj b/LoopFollow.xcodeproj/project.pbxproj index fb04f561a..cb778f75d 100644 --- a/LoopFollow.xcodeproj/project.pbxproj +++ b/LoopFollow.xcodeproj/project.pbxproj @@ -12,7 +12,6 @@ 374A77A62F5BE17000E96858 /* GlucoseSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A77A12F5BE17000E96858 /* GlucoseSnapshot.swift */; }; 374A77A72F5BE17000E96858 /* LAAppGroupSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A77A32F5BE17000E96858 /* LAAppGroupSettings.swift */; }; 374A77A82F5BE17000E96858 /* GlucoseLiveActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A77A02F5BE17000E96858 /* GlucoseLiveActivityAttributes.swift */; }; - DD91E4DE2BDEC3F8002D9E97 /* GlucoseConversion.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD91E4DC2BDEC3F8002D9E97 /* GlucoseConversion.swift */; }; 374A77AA2F5BE17000E96858 /* AppGroupID.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A779F2F5BE17000E96858 /* AppGroupID.swift */; }; 374A77AB2F5BE17000E96858 /* GlucoseSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A77A12F5BE17000E96858 /* GlucoseSnapshot.swift */; }; 374A77AC2F5BE17000E96858 /* LAAppGroupSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A77A32F5BE17000E96858 /* LAAppGroupSettings.swift */; }; @@ -22,10 +21,13 @@ 374A77B72F5BE1AC00E96858 /* LiveActivityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A77B12F5BE1AC00E96858 /* LiveActivityManager.swift */; }; 374A77B82F5BE1AC00E96858 /* GlucoseSnapshotStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A77AF2F5BE1AC00E96858 /* GlucoseSnapshotStore.swift */; }; 374A77B92F5BE1AC00E96858 /* PreferredGlucoseUnit.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A77B22F5BE1AC00E96858 /* PreferredGlucoseUnit.swift */; }; + 379BECAA2F6588300069DC62 /* RestartLiveActivityIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 379BECA92F6588300069DC62 /* RestartLiveActivityIntent.swift */; }; + 379BECB02F65DA4B0069DC62 /* LiveActivitySettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 379BECAF2F65DA4B0069DC62 /* LiveActivitySettingsView.swift */; }; 37A4BDDB2F5B6B4A00EEB289 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 37A4BDDA2F5B6B4A00EEB289 /* WidgetKit.framework */; }; 37A4BDDD2F5B6B4A00EEB289 /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 37A4BDDC2F5B6B4A00EEB289 /* SwiftUI.framework */; }; 37A4BDE82F5B6B4C00EEB289 /* LoopFollowLAExtensionExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 37A4BDD92F5B6B4A00EEB289 /* LoopFollowLAExtensionExtension.appex */; platformFilter = ios; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 3F1335F351590E573D8E6962 /* Pods_LoopFollow.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A7D55B42A22051DAD69E89D0 /* Pods_LoopFollow.framework */; }; + 5544D8C363FB5D3B9BF8CE4A /* APNSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7C2676561D686C6459CAA2D /* APNSettingsView.swift */; }; 654132E72E19EA7E00BDBE08 /* SimpleQRCodeScannerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 654132E62E19EA7E00BDBE08 /* SimpleQRCodeScannerView.swift */; }; 654132EA2E19F24800BDBE08 /* TOTPGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 654132E92E19F24800BDBE08 /* TOTPGenerator.swift */; }; 654134182E1DC09700BDBE08 /* OverridePresetsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 654134172E1DC09700BDBE08 /* OverridePresetsView.swift */; }; @@ -39,7 +41,6 @@ 6584B1012E4A263900135D4D /* TOTPService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6584B1002E4A263900135D4D /* TOTPService.swift */; }; 6589CC622E9E7D1600BB18FE /* ImportExportSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6589CC532E9E7D1600BB18FE /* ImportExportSettingsView.swift */; }; 6589CC632E9E7D1600BB18FE /* GeneralSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6589CC5D2E9E7D1600BB18FE /* GeneralSettingsView.swift */; }; - 5544D8C363FB5D3B9BF8CE4A /* APNSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7C2676561D686C6459CAA2D /* APNSettingsView.swift */; }; 6589CC642E9E7D1600BB18FE /* ContactSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6589CC592E9E7D1600BB18FE /* ContactSettingsView.swift */; }; 6589CC652E9E7D1600BB18FE /* DexcomSettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6589CC5C2E9E7D1600BB18FE /* DexcomSettingsViewModel.swift */; }; 6589CC662E9E7D1600BB18FE /* AdvancedSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6589CC562E9E7D1600BB18FE /* AdvancedSettingsView.swift */; }; @@ -54,7 +55,6 @@ 6589CC6F2E9E7D1600BB18FE /* AdvancedSettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6589CC572E9E7D1600BB18FE /* AdvancedSettingsViewModel.swift */; }; 6589CC712E9E814F00BB18FE /* AlarmSelectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6589CC702E9E814F00BB18FE /* AlarmSelectionView.swift */; }; 6589CC752E9EAFB700BB18FE /* SettingsMigrationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6589CC742E9EAFB700BB18FE /* SettingsMigrationManager.swift */; }; - 6584B1012E4A263900135D4D /* TOTPService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6584B1002E4A263900135D4D /* TOTPService.swift */; }; 65E153C32E4BB69100693A4F /* URLTokenValidationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65E153C22E4BB69100693A4F /* URLTokenValidationView.swift */; }; 65E8A2862E44B0300065037B /* VolumeButtonHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65E8A2852E44B0300065037B /* VolumeButtonHandler.swift */; }; DD0247592DB2E89600FCADF6 /* AlarmCondition.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD0247582DB2E89600FCADF6 /* AlarmCondition.swift */; }; @@ -177,6 +177,7 @@ DD83164C2DE4DB3A004467AA /* BinaryFloatingPoint+localized.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD83164B2DE4DB3A004467AA /* BinaryFloatingPoint+localized.swift */; }; DD85E9952D739CFE001C8BB7 /* OmnipodDashHeartbeatBluetoothTransmitter.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD85E9942D739CED001C8BB7 /* OmnipodDashHeartbeatBluetoothTransmitter.swift */; }; DD91E4DD2BDEC3F8002D9E97 /* GlucoseConversion.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD91E4DC2BDEC3F8002D9E97 /* GlucoseConversion.swift */; }; + DD91E4DE2BDEC3F8002D9E97 /* GlucoseConversion.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD91E4DC2BDEC3F8002D9E97 /* GlucoseConversion.swift */; }; DD98F54424BCEFEE0007425A /* ShareClientExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD98F54324BCEFEE0007425A /* ShareClientExtension.swift */; }; DD9ACA042D32821400415D8A /* DeviceStatusTask.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD9ACA032D32821200415D8A /* DeviceStatusTask.swift */; }; DD9ACA062D32AF7900415D8A /* TreatmentsTask.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD9ACA052D32AF6E00415D8A /* TreatmentsTask.swift */; }; @@ -463,6 +464,8 @@ 374A77B22F5BE1AC00E96858 /* PreferredGlucoseUnit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferredGlucoseUnit.swift; sourceTree = ""; }; 374A77B32F5BE1AC00E96858 /* StorageCurrentGlucoseStateProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StorageCurrentGlucoseStateProvider.swift; sourceTree = ""; }; 374DAACA2F5B924B00BB663B /* LoopFollowLAExtensionExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = LoopFollowLAExtensionExtension.entitlements; sourceTree = ""; }; + 379BECA92F6588300069DC62 /* RestartLiveActivityIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RestartLiveActivityIntent.swift; sourceTree = ""; }; + 379BECAF2F65DA4B0069DC62 /* LiveActivitySettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActivitySettingsView.swift; sourceTree = ""; }; 37A4BDD92F5B6B4A00EEB289 /* LoopFollowLAExtensionExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = LoopFollowLAExtensionExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 37A4BDDA2F5B6B4A00EEB289 /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = /System/Library/Frameworks/WidgetKit.framework; sourceTree = ""; }; 37A4BDDC2F5B6B4A00EEB289 /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = /System/Library/Frameworks/SwiftUI.framework; sourceTree = ""; }; @@ -488,13 +491,11 @@ 6589CC5B2E9E7D1600BB18FE /* DexcomSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DexcomSettingsView.swift; sourceTree = ""; }; 6589CC5C2E9E7D1600BB18FE /* DexcomSettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DexcomSettingsViewModel.swift; sourceTree = ""; }; 6589CC5D2E9E7D1600BB18FE /* GeneralSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeneralSettingsView.swift; sourceTree = ""; }; - E7C2676561D686C6459CAA2D /* APNSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APNSettingsView.swift; sourceTree = ""; }; 6589CC5E2E9E7D1600BB18FE /* GraphSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GraphSettingsView.swift; sourceTree = ""; }; 6589CC5F2E9E7D1600BB18FE /* SettingsMenuView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsMenuView.swift; sourceTree = ""; }; 6589CC602E9E7D1600BB18FE /* TabCustomizationModal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabCustomizationModal.swift; sourceTree = ""; }; 6589CC702E9E814F00BB18FE /* AlarmSelectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlarmSelectionView.swift; sourceTree = ""; }; 6589CC742E9EAFB700BB18FE /* SettingsMigrationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsMigrationManager.swift; sourceTree = ""; }; - 6584B1002E4A263900135D4D /* TOTPService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TOTPService.swift; sourceTree = ""; }; 65E153C22E4BB69100693A4F /* URLTokenValidationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLTokenValidationView.swift; sourceTree = ""; }; 65E8A2852E44B0300065037B /* VolumeButtonHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VolumeButtonHandler.swift; sourceTree = ""; }; A7D55B42A22051DAD69E89D0 /* Pods_LoopFollow.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_LoopFollow.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -712,6 +713,7 @@ DDFF3D842D14279B00BF9D9E /* BackgroundRefreshSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundRefreshSettingsView.swift; sourceTree = ""; }; DDFF3D862D14280500BF9D9E /* BackgroundRefreshSettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundRefreshSettingsViewModel.swift; sourceTree = ""; }; DDFF3D882D1429AB00BF9D9E /* BackgroundRefreshType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundRefreshType.swift; sourceTree = ""; }; + E7C2676561D686C6459CAA2D /* APNSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APNSettingsView.swift; sourceTree = ""; }; ECA3EFB4037410B4973BB632 /* Pods-LoopFollow.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-LoopFollow.debug.xcconfig"; path = "Target Support Files/Pods-LoopFollow/Pods-LoopFollow.debug.xcconfig"; sourceTree = ""; }; FC16A97924996673003D6245 /* NightScout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NightScout.swift; sourceTree = ""; }; FC16A97C24996747003D6245 /* SpeakBG.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpeakBG.swift; sourceTree = ""; }; @@ -1566,6 +1568,7 @@ FC8DEEE32485D1680075863F /* LoopFollow */ = { isa = PBXGroup; children = ( + 379BECAF2F65DA4B0069DC62 /* LiveActivitySettingsView.swift */, 376310762F5CD65100656488 /* LiveActivity */, 6589CC612E9E7D1600BB18FE /* Settings */, 65AC26702ED245DF00421360 /* Treatments */, @@ -1597,6 +1600,7 @@ FC97880B2485969B00A7906C = { isa = PBXGroup; children = ( + 379BECA92F6588300069DC62 /* RestartLiveActivityIntent.swift */, 374DAACA2F5B924B00BB663B /* LoopFollowLAExtensionExtension.entitlements */, DDF2C0132BEFD468007A20E6 /* blacklisted-versions.json */, DDB0AF542BB1B24A00AFA48B /* BuildDetails.plist */, @@ -2256,6 +2260,7 @@ DDFF3D852D14279B00BF9D9E /* BackgroundRefreshSettingsView.swift in Sources */, DDCF9A882D85FD33004DF4DD /* AlarmData.swift in Sources */, DD608A0C2C27415C00F91132 /* BackgroundAlertManager.swift in Sources */, + 379BECB02F65DA4B0069DC62 /* LiveActivitySettingsView.swift in Sources */, DD4878082C7B30BF0048F05C /* RemoteSettingsView.swift in Sources */, DDE75D2B2DE5E613007C1FC1 /* NavigationRow.swift in Sources */, DD4AFB3B2DB55CB600BB593F /* TimeOfDay.swift in Sources */, @@ -2318,6 +2323,7 @@ 654132EA2E19F24800BDBE08 /* TOTPGenerator.swift in Sources */, 6541341C2E1DC28000BDBE08 /* DateExtensions.swift in Sources */, DD0650F52DCF303F004D3B41 /* AlarmStepperSection.swift in Sources */, + 379BECAA2F6588300069DC62 /* RestartLiveActivityIntent.swift in Sources */, DDCF9A802D85FD0B004DF4DD /* Alarm.swift in Sources */, DD7F4C132DD51FD500D449E9 /* TempTargetEndCondition.swift in Sources */, DDD10F012C510C6B00D76A8E /* ObservableUserDefaults.swift in Sources */, @@ -2658,8 +2664,8 @@ isa = XCBuildConfiguration; baseConfigurationReference = ECA3EFB4037410B4973BB632 /* Pods-LoopFollow.debug.xcconfig */; buildSettings = { - ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES; CODE_SIGN_ENTITLEMENTS = "LoopFollow/Loop Follow.entitlements"; CODE_SIGN_STYLE = Automatic; DEFINES_MODULE = YES; @@ -2683,8 +2689,8 @@ isa = XCBuildConfiguration; baseConfigurationReference = 059B0FA59AABFE72FE13DDDA /* Pods-LoopFollow.release.xcconfig */; buildSettings = { - ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES; CODE_SIGN_ENTITLEMENTS = "LoopFollow/Loop Follow.entitlements"; CODE_SIGN_STYLE = Automatic; DEFINES_MODULE = YES; @@ -2754,14 +2760,14 @@ minimumVersion = 1.9.0; }; }; - /* End XCRemoteSwiftPackageReference section */ +/* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ DD485F152E46631000CE8CBF /* CryptoSwift */ = { isa = XCSwiftPackageProductDependency; productName = CryptoSwift; }; - /* End XCSwiftPackageProductDependency section */ +/* End XCSwiftPackageProductDependency section */ /* Begin XCVersionGroup section */ FC3AE7B3249E8E0E00AAE1E0 /* LoopFollow.xcdatamodeld */ = { diff --git a/LoopFollow/Application/AppDelegate.swift b/LoopFollow/Application/AppDelegate.swift index 604cf3e9e..bf87a3343 100644 --- a/LoopFollow/Application/AppDelegate.swift +++ b/LoopFollow/Application/AppDelegate.swift @@ -48,7 +48,11 @@ class AppDelegate: UIResponder, UIApplicationDelegate { return true } - func applicationWillTerminate(_: UIApplication) {} + func applicationWillTerminate(_: UIApplication) { + #if !targetEnvironment(macCatalyst) + LiveActivityManager.shared.endOnTerminate() + #endif + } // MARK: - Remote Notifications @@ -97,6 +101,11 @@ class AppDelegate: UIResponder, UIApplicationDelegate { completionHandler(.newData) } + // MARK: - URL handling + // Note: with scene-based lifecycle (iOS 13+), URLs are delivered to + // SceneDelegate.scene(_:openURLContexts:) — not here. The scene delegate + // handles loopfollow://la-tap for Live Activity tap navigation. + // MARK: UISceneSession Lifecycle func application(_: UIApplication, willFinishLaunchingWithOptions _: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { diff --git a/LoopFollow/Application/SceneDelegate.swift b/LoopFollow/Application/SceneDelegate.swift index b15fb0bd5..a8fbb236f 100644 --- a/LoopFollow/Application/SceneDelegate.swift +++ b/LoopFollow/Application/SceneDelegate.swift @@ -32,6 +32,24 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { func sceneDidBecomeActive(_: UIScene) { // Called when the scene has moved from an inactive state to an active state. // Use this method to restart any tasks that were paused (or not yet started) when the scene was inactive. + if pendingLATapNavigation { + pendingLATapNavigation = false + NotificationCenter.default.post(name: .liveActivityDidForeground, object: nil) + } + } + + /// Set when loopfollow://la-tap arrives before the scene is fully active. + /// Consumed in sceneDidBecomeActive once the view hierarchy is restored. + private var pendingLATapNavigation = false + + func scene(_: UIScene, openURLContexts URLContexts: Set) { + guard URLContexts.contains(where: { $0.url.scheme == "loopfollow" && $0.url.host == "la-tap" }) else { return } + // scene(_:openURLContexts:) fires after sceneDidBecomeActive when the app + // foregrounds from background. Post on the next run loop so the view + // hierarchy (including any presented modals) is fully settled. + DispatchQueue.main.async { + NotificationCenter.default.post(name: .liveActivityDidForeground, object: nil) + } } func sceneWillResignActive(_: UIScene) { diff --git a/LoopFollow/LiveActivity/LiveActivityManager.swift b/LoopFollow/LiveActivity/LiveActivityManager.swift index 1313739f0..41f129c60 100644 --- a/LoopFollow/LiveActivity/LiveActivityManager.swift +++ b/LoopFollow/LiveActivity/LiveActivityManager.swift @@ -20,9 +20,73 @@ final class LiveActivityManager { name: UIApplication.willEnterForegroundNotification, object: nil ) + NotificationCenter.default.addObserver( + self, + selector: #selector(handleDidBecomeActive), + name: UIApplication.didBecomeActiveNotification, + object: nil + ) + NotificationCenter.default.addObserver( + self, + selector: #selector(handleWillResignActive), + name: UIApplication.willResignActiveNotification, + object: nil + ) + } + + /// Fires before the app loses focus (lock screen, home button, etc.). + /// Cancels any pending debounced refresh and pushes the latest snapshot + /// directly to the Live Activity while the app is still foreground-active, + /// ensuring the LA is up to date the moment the lock screen appears. + @objc private func handleWillResignActive() { + guard Storage.shared.laEnabled.value, let activity = current else { return } + + refreshWorkItem?.cancel() + refreshWorkItem = nil + + let provider = StorageCurrentGlucoseStateProvider() + guard let snapshot = GlucoseSnapshotBuilder.build(from: provider) else { return } + + LAAppGroupSettings.setThresholds( + lowMgdl: Storage.shared.lowLine.value, + highMgdl: Storage.shared.highLine.value + ) + GlucoseSnapshotStore.shared.save(snapshot) + + seq += 1 + let nextSeq = seq + let state = GlucoseLiveActivityAttributes.ContentState( + snapshot: snapshot, + seq: nextSeq, + reason: "resign-active", + producedAt: Date() + ) + let content = ActivityContent( + state: state, + staleDate: Date(timeIntervalSince1970: Storage.shared.laRenewBy.value), + relevanceScore: 100.0 + ) + + Task { + // Direct ActivityKit update — app is still active at this point. + await activity.update(content) + LogManager.shared.log(category: .general, message: "[LA] resign-active flush sent seq=\(nextSeq)", isDebug: true) + // Also send APNs so the extension receives the latest token-based update. + if let token = pushToken { + await APNSClient.shared.sendLiveActivityUpdate(pushToken: token, state: state) + } + } + } + + @objc private func handleDidBecomeActive() { + guard Storage.shared.laEnabled.value else { return } + Task { @MainActor in + self.startFromCurrentState() + } } @objc private func handleForeground() { + guard Storage.shared.laEnabled.value else { return } LogManager.shared.log(category: .general, message: "[LA] foreground notification received, laRenewalFailed=\(Storage.shared.laRenewalFailed.value)") guard Storage.shared.laRenewalFailed.value else { return } @@ -75,6 +139,11 @@ final class LiveActivityManager { private var pushToken: String? private var tokenObservationTask: Task? private var refreshWorkItem: DispatchWorkItem? + /// Set when the user manually swipes away the LA. Blocks auto-restart until + /// an explicit user action (Restart button, App Intent) clears it. + /// In-memory only — resets to false on app relaunch, so a kill + relaunch + /// starts fresh as expected. + private var dismissedByUser = false // MARK: - Public API @@ -125,6 +194,22 @@ final class LiveActivityManager { } } + /// Called from applicationWillTerminate. Ends the LA synchronously (blocking + /// up to 3 s) so it clears from the lock screen before the process exits. + /// Does not clear laEnabled — the user's preference is preserved for relaunch. + func endOnTerminate() { + guard let activity = current else { return } + current = nil + Storage.shared.laRenewBy.value = 0 + let semaphore = DispatchSemaphore(value: 0) + Task.detached { + await activity.end(nil, dismissalPolicy: .immediate) + semaphore.signal() + } + _ = semaphore.wait(timeout: .now() + 3) + LogManager.shared.log(category: .general, message: "[LA] ended on app terminate") + } + func end(dismissalPolicy: ActivityUIDismissalPolicy = .default) { updateTask?.cancel() updateTask = nil @@ -161,7 +246,33 @@ final class LiveActivityManager { } } + /// Ends all running Live Activities and starts a fresh one from the current state. + /// Intended for the "Restart Live Activity" button and the AppIntent. + @MainActor + func forceRestart() { + guard Storage.shared.laEnabled.value else { return } + LogManager.shared.log(category: .general, message: "[LA] forceRestart called") + dismissedByUser = false + Storage.shared.laRenewBy.value = 0 + Storage.shared.laRenewalFailed.value = false + current = nil + updateTask?.cancel(); updateTask = nil + tokenObservationTask?.cancel(); tokenObservationTask = nil + stateObserverTask?.cancel(); stateObserverTask = nil + pushToken = nil + Task { + for activity in Activity.activities { + await activity.end(nil, dismissalPolicy: .immediate) + } + await MainActor.run { + self.startFromCurrentState() + LogManager.shared.log(category: .general, message: "[LA] forceRestart: Live Activity restarted") + } + } + } + func startFromCurrentState() { + guard Storage.shared.laEnabled.value, !dismissedByUser else { return } endOrphanedActivities() let provider = StorageCurrentGlucoseStateProvider() if let snapshot = GlucoseSnapshotBuilder.build(from: provider) { @@ -175,6 +286,7 @@ final class LiveActivityManager { } func refreshFromCurrentState(reason: String) { + guard Storage.shared.laEnabled.value, !dismissedByUser else { return } refreshWorkItem?.cancel() let workItem = DispatchWorkItem { [weak self] in self?.performRefresh(reason: reason) @@ -422,8 +534,16 @@ final class LiveActivityManager { if state == .ended || state == .dismissed { if current?.id == activity.id { current = nil + Storage.shared.laRenewBy.value = 0 LogManager.shared.log(category: .general, message: "Live Activity cleared id=\(activity.id)", isDebug: true) } + if state == .dismissed { + // User manually swiped away the LA. Block auto-restart until + // the user explicitly restarts via button or App Intent. + // laEnabled is left true — the user's preference is preserved. + dismissedByUser = true + LogManager.shared.log(category: .general, message: "Live Activity dismissed by user — auto-restart blocked until explicit restart") + } } } } @@ -431,3 +551,9 @@ final class LiveActivityManager { } #endif + +extension Notification.Name { + /// Posted when the user taps the Live Activity or Dynamic Island. + /// Observers navigate to the Home or Snoozer tab as appropriate. + static let liveActivityDidForeground = Notification.Name("liveActivityDidForeground") +} diff --git a/LoopFollow/LiveActivity/RestartLiveActivityIntent.swift b/LoopFollow/LiveActivity/RestartLiveActivityIntent.swift new file mode 100644 index 000000000..9e3179244 --- /dev/null +++ b/LoopFollow/LiveActivity/RestartLiveActivityIntent.swift @@ -0,0 +1,41 @@ +// LoopFollow +// RestartLiveActivityIntent.swift + +import AppIntents +import UIKit + +@available(iOS 16.4, *) +struct RestartLiveActivityIntent: AppIntent { + static var title: LocalizedStringResource = "Restart Live Activity" + static var description = IntentDescription("Starts or restarts the LoopFollow Live Activity.") + + func perform() async throws -> some IntentResult & ProvidesDialog { + Storage.shared.laEnabled.value = true + + let keyId = Storage.shared.lfKeyId.value + let apnsKey = Storage.shared.lfApnsKey.value + + if keyId.isEmpty || apnsKey.isEmpty { + if let url = URL(string: "loopfollow://settings/live-activity") { + await MainActor.run { UIApplication.shared.open(url) } + } + return .result(dialog: "Please enter your APNs credentials in LoopFollow settings to use the Live Activity.") + } + + await MainActor.run { LiveActivityManager.shared.forceRestart() } + + return .result(dialog: "Live Activity restarted.") + } +} + +@available(iOS 16.4, *) +struct LoopFollowAppShortcuts: AppShortcutsProvider { + static var appShortcuts: [AppShortcut] { + AppShortcut( + intent: RestartLiveActivityIntent(), + phrases: ["Restart Live Activity in \(.applicationName)"], + shortTitle: "Restart Live Activity", + systemImageName: "dot.radiowaves.left.and.right" + ) + } +} diff --git a/LoopFollow/LiveActivitySettingsView.swift b/LoopFollow/LiveActivitySettingsView.swift new file mode 100644 index 000000000..0a29d702a --- /dev/null +++ b/LoopFollow/LiveActivitySettingsView.swift @@ -0,0 +1,44 @@ +// LoopFollow +// LiveActivitySettingsView.swift + +import SwiftUI + +struct LiveActivitySettingsView: View { + @State private var laEnabled: Bool = Storage.shared.laEnabled.value + @State private var restartConfirmed = false + + var body: some View { + Form { + Section(header: Text("Live Activity")) { + Toggle("Enable Live Activity", isOn: $laEnabled) + } + + if laEnabled { + Section { + Button(restartConfirmed ? "Live Activity Restarted" : "Restart Live Activity") { + LiveActivityManager.shared.forceRestart() + restartConfirmed = true + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { + restartConfirmed = false + } + } + .disabled(restartConfirmed) + } + } + } + .onReceive(Storage.shared.laEnabled.$value) { newValue in + if newValue != laEnabled { laEnabled = newValue } + } + .onChange(of: laEnabled) { newValue in + Storage.shared.laEnabled.value = newValue + if newValue { + LiveActivityManager.shared.forceRestart() + } else { + LiveActivityManager.shared.end(dismissalPolicy: .immediate) + } + } + .preferredColorScheme(Storage.shared.appearanceMode.value.colorScheme) + .navigationTitle("Live Activity") + .navigationBarTitleDisplayMode(.inline) + } +} diff --git a/LoopFollow/Settings/LiveActivitySettingsView.swift b/LoopFollow/Settings/LiveActivitySettingsView.swift new file mode 100644 index 000000000..bfe39b3ee --- /dev/null +++ b/LoopFollow/Settings/LiveActivitySettingsView.swift @@ -0,0 +1,42 @@ +// LoopFollow +// LiveActivitySettingsView.swift + +import SwiftUI + +struct LiveActivitySettingsView: View { + @State private var laEnabled: Bool = Storage.shared.laEnabled.value + @State private var restartConfirmed = false + + var body: some View { + Form { + Section(header: Text("Live Activity")) { + Toggle("Enable Live Activity", isOn: $laEnabled) + } + + if laEnabled { + Section { + Button(restartConfirmed ? "Live Activity Restarted" : "Restart Live Activity") { + LiveActivityManager.shared.forceRestart() + restartConfirmed = true + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { + restartConfirmed = false + } + } + .disabled(restartConfirmed) + } + } + } + .onReceive(Storage.shared.laEnabled.$value) { newValue in + if newValue != laEnabled { laEnabled = newValue } + } + .onChange(of: laEnabled) { newValue in + Storage.shared.laEnabled.value = newValue + if !newValue { + LiveActivityManager.shared.end(dismissalPolicy: .immediate) + } + } + .preferredColorScheme(Storage.shared.appearanceMode.value.colorScheme) + .navigationTitle("Live Activity") + .navigationBarTitleDisplayMode(.inline) + } +} diff --git a/LoopFollow/Settings/SettingsMenuView.swift b/LoopFollow/Settings/SettingsMenuView.swift index 1ddcffc77..b57d00503 100644 --- a/LoopFollow/Settings/SettingsMenuView.swift +++ b/LoopFollow/Settings/SettingsMenuView.swift @@ -66,6 +66,12 @@ struct SettingsMenuView: View { settingsPath.value.append(Sheet.apn) } + NavigationRow(title: "Live Activity", + icon: "dot.radiowaves.left.and.right") + { + settingsPath.value.append(Sheet.liveActivity) + } + if !nightscoutURL.value.isEmpty { NavigationRow(title: "Information Display Settings", icon: "info.circle") @@ -245,6 +251,7 @@ private enum Sheet: Hashable, Identifiable { case infoDisplay case alarmSettings case apn + case liveActivity case remote case importExport case calendar, contact @@ -265,6 +272,7 @@ private enum Sheet: Hashable, Identifiable { case .infoDisplay: InfoDisplaySettingsView(viewModel: .init()) case .alarmSettings: AlarmSettingsView() case .apn: APNSettingsView() + case .liveActivity: LiveActivitySettingsView() case .remote: RemoteSettingsView(viewModel: .init()) case .importExport: ImportExportSettingsView() case .calendar: CalendarSettingsView() diff --git a/LoopFollow/Storage/Storage.swift b/LoopFollow/Storage/Storage.swift index 7de9ac7e7..141293e7c 100644 --- a/LoopFollow/Storage/Storage.swift +++ b/LoopFollow/Storage/Storage.swift @@ -91,7 +91,8 @@ class Storage { var lastCOB = StorageValue(key: "lastCOB", defaultValue: nil) var projectedBgMgdl = StorageValue(key: "projectedBgMgdl", defaultValue: nil) - // Live Activity renewal + // Live Activity + var laEnabled = StorageValue(key: "laEnabled", defaultValue: false) var laRenewBy = StorageValue(key: "laRenewBy", defaultValue: 0) var laRenewalFailed = StorageValue(key: "laRenewalFailed", defaultValue: false) diff --git a/LoopFollow/ViewControllers/MainViewController.swift b/LoopFollow/ViewControllers/MainViewController.swift index 270b9be87..516b58e3c 100644 --- a/LoopFollow/ViewControllers/MainViewController.swift +++ b/LoopFollow/ViewControllers/MainViewController.swift @@ -206,6 +206,7 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele let notificationCenter = NotificationCenter.default notificationCenter.addObserver(self, selector: #selector(appMovedToBackground), name: UIApplication.didEnterBackgroundNotification, object: nil) notificationCenter.addObserver(self, selector: #selector(appCameToForeground), name: UIApplication.willEnterForegroundNotification, object: nil) + notificationCenter.addObserver(self, selector: #selector(navigateOnLAForeground), name: .liveActivityDidForeground, object: nil) // Setup the Graph if firstGraphLoad { @@ -682,6 +683,27 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele updateNightscoutTabState() } + @objc private func navigateOnLAForeground() { + guard let tabBarController = tabBarController, + let vcs = tabBarController.viewControllers, !vcs.isEmpty else { return } + + let targetIndex: Int + if Observable.shared.currentAlarm.value != nil, + let snoozerIndex = getSnoozerTabIndex(), snoozerIndex < vcs.count { + targetIndex = snoozerIndex + } else { + targetIndex = 0 + } + + if let presented = tabBarController.presentedViewController { + presented.dismiss(animated: false) { + tabBarController.selectedIndex = targetIndex + } + } else { + tabBarController.selectedIndex = targetIndex + } + } + private func getSnoozerTabIndex() -> Int? { guard let tabBarController = tabBarController, let viewControllers = tabBarController.viewControllers else { return nil } diff --git a/LoopFollowLAExtension/LoopFollowLiveActivity.swift b/LoopFollowLAExtension/LoopFollowLiveActivity.swift index 2ef72f6fe..294ba8645 100644 --- a/LoopFollowLAExtension/LoopFollowLiveActivity.swift +++ b/LoopFollowLAExtension/LoopFollowLiveActivity.swift @@ -15,23 +15,30 @@ struct LoopFollowLiveActivityWidget: Widget { .activitySystemActionForegroundColor(.white) .activityBackgroundTint(LAColors.backgroundTint(for: context.state.snapshot)) .applyActivityContentMarginsFixIfAvailable() + .widgetURL(URL(string: "loopfollow://la-tap")!) } dynamicIsland: { context in // DYNAMIC ISLAND UI DynamicIsland { DynamicIslandExpandedRegion(.leading) { - DynamicIslandLeadingView(snapshot: context.state.snapshot) - .id(context.state.seq) - .overlay(RenewalOverlayView(show: context.state.snapshot.showRenewalOverlay)) + Link(destination: URL(string: "loopfollow://la-tap")!) { + DynamicIslandLeadingView(snapshot: context.state.snapshot) + .overlay(RenewalOverlayView(show: context.state.snapshot.showRenewalOverlay)) + } + .id(context.state.seq) } DynamicIslandExpandedRegion(.trailing) { - DynamicIslandTrailingView(snapshot: context.state.snapshot) - .id(context.state.seq) - .overlay(RenewalOverlayView(show: context.state.snapshot.showRenewalOverlay)) + Link(destination: URL(string: "loopfollow://la-tap")!) { + DynamicIslandTrailingView(snapshot: context.state.snapshot) + .overlay(RenewalOverlayView(show: context.state.snapshot.showRenewalOverlay)) + } + .id(context.state.seq) } DynamicIslandExpandedRegion(.bottom) { - DynamicIslandBottomView(snapshot: context.state.snapshot) - .id(context.state.seq) - .overlay(RenewalOverlayView(show: context.state.snapshot.showRenewalOverlay, showText: true)) + Link(destination: URL(string: "loopfollow://la-tap")!) { + DynamicIslandBottomView(snapshot: context.state.snapshot) + .overlay(RenewalOverlayView(show: context.state.snapshot.showRenewalOverlay, showText: true)) + } + .id(context.state.seq) } } compactLeading: { DynamicIslandCompactLeadingView(snapshot: context.state.snapshot) @@ -193,30 +200,31 @@ private struct DynamicIslandLeadingView: View { let snapshot: GlucoseSnapshot var body: some View { if snapshot.isNotLooping { - VStack(alignment: .leading, spacing: 2) { - Text("⚠️ Not Looping") - .font(.system(size: 20, weight: .heavy, design: .rounded)) - .foregroundStyle(.white) - .tracking(1.0) - .lineLimit(1) - .minimumScaleFactor(0.7) - } + Text("⚠️ Not Looping") + .font(.system(size: 20, weight: .heavy, design: .rounded)) + .foregroundStyle(.white) + .tracking(1.0) + .lineLimit(1) + .minimumScaleFactor(0.7) } else { VStack(alignment: .leading, spacing: 2) { - HStack(alignment: .firstTextBaseline, spacing: 6) { - Text(LAFormat.glucose(snapshot)) - .font(.system(size: 28, weight: .bold, design: .rounded)) - .monospacedDigit() - .foregroundStyle(.white) + Text(LAFormat.glucose(snapshot)) + .font(.system(size: 28, weight: .bold, design: .rounded)) + .monospacedDigit() + .foregroundStyle(.white) + HStack(spacing: 5) { Text(LAFormat.trendArrow(snapshot)) - .font(.system(size: 16, weight: .semibold, design: .rounded)) - .foregroundStyle(.white.opacity(0.95)) - .padding(.top, 2) + .font(.system(size: 13, weight: .semibold, design: .rounded)) + .foregroundStyle(.white.opacity(0.9)) + Text(LAFormat.delta(snapshot)) + .font(.system(size: 13, weight: .semibold, design: .rounded)) + .monospacedDigit() + .foregroundStyle(.white.opacity(0.9)) + Text("Proj: \(LAFormat.projected(snapshot))") + .font(.system(size: 13, weight: .semibold, design: .rounded)) + .monospacedDigit() + .foregroundStyle(.white.opacity(0.9)) } - Text(LAFormat.delta(snapshot)) - .font(.system(size: 14, weight: .semibold, design: .rounded)) - .monospacedDigit() - .foregroundStyle(.white.opacity(0.9)) } } } @@ -230,14 +238,16 @@ private struct DynamicIslandTrailingView: View { EmptyView() } else { VStack(alignment: .trailing, spacing: 3) { - Text("Upd \(LAFormat.updated(snapshot))") - .font(.system(size: 12, weight: .semibold, design: .rounded)) - .foregroundStyle(.white.opacity(0.85)) - Text("Proj \(LAFormat.projected(snapshot))") + Text("IOB \(LAFormat.iob(snapshot))") + .font(.system(size: 13, weight: .bold, design: .rounded)) + .monospacedDigit() + .foregroundStyle(.white.opacity(0.95)) + Text("COB \(LAFormat.cob(snapshot))") .font(.system(size: 13, weight: .bold, design: .rounded)) .monospacedDigit() .foregroundStyle(.white.opacity(0.95)) } + .padding(.trailing, 6) } } } @@ -253,14 +263,11 @@ private struct DynamicIslandBottomView: View { .lineLimit(1) .minimumScaleFactor(0.75) } else { - HStack(spacing: 14) { - Text("IOB \(LAFormat.iob(snapshot))") - Text("COB \(LAFormat.cob(snapshot))") - } - .font(.system(size: 13, weight: .semibold, design: .rounded)) - .foregroundStyle(.white.opacity(0.92)) - .lineLimit(1) - .minimumScaleFactor(0.85) + Text("Updated at: \(LAFormat.updated(snapshot))") + .font(.system(size: 13, weight: .semibold, design: .rounded)) + .foregroundStyle(.white.opacity(0.92)) + .lineLimit(1) + .minimumScaleFactor(0.85) } } } @@ -276,8 +283,9 @@ private struct DynamicIslandCompactTrailingView: View { .lineLimit(1) .minimumScaleFactor(0.7) } else { - Text(LAFormat.trendArrow(snapshot)) + Text(LAFormat.delta(snapshot)) .font(.system(size: 14, weight: .semibold, design: .rounded)) + .monospacedDigit() .foregroundStyle(.white.opacity(0.95)) } } diff --git a/RestartLiveActivityIntent.swift b/RestartLiveActivityIntent.swift new file mode 100644 index 000000000..c594d5fa9 --- /dev/null +++ b/RestartLiveActivityIntent.swift @@ -0,0 +1,45 @@ +// LoopFollow +// RestartLiveActivityIntent.swift + +import AppIntents +import UIKit + +@available(iOS 16.4, *) +struct RestartLiveActivityIntent: AppIntent, ForegroundContinuableIntent { + static var title: LocalizedStringResource = "Restart Live Activity" + static var description = IntentDescription("Starts or restarts the LoopFollow Live Activity.") + + func perform() async throws -> some IntentResult & ProvidesDialog { + Storage.shared.laEnabled.value = true + + let keyId = Storage.shared.lfKeyId.value + let apnsKey = Storage.shared.lfApnsKey.value + + if keyId.isEmpty || apnsKey.isEmpty { + if let url = URL(string: "loopfollow://settings/live-activity") { + await MainActor.run { UIApplication.shared.open(url) } + } + return .result(dialog: "Please enter your APNs credentials in LoopFollow settings to use the Live Activity.") + } + + if #available(iOS 26.0, *) { + try await continueInForeground() + } + + await MainActor.run { LiveActivityManager.shared.forceRestart() } + + return .result(dialog: "Live Activity restarted.") + } +} + +@available(iOS 16.4, *) +struct LoopFollowAppShortcuts: AppShortcutsProvider { + static var appShortcuts: [AppShortcut] { + AppShortcut( + intent: RestartLiveActivityIntent(), + phrases: ["Restart Live Activity in \(.applicationName)"], + shortTitle: "Restart Live Activity", + systemImageName: "dot.radiowaves.left.and.right" + ) + } +} From ad647e58b4e9e69cd7ecd4be25165636f503a3c8 Mon Sep 17 00:00:00 2001 From: MtlPhil <76601115+MtlPhil@users.noreply.github.com> Date: Sun, 15 Mar 2026 13:16:02 -0400 Subject: [PATCH 39/82] feat: configurable LA grid slots + full InfoType snapshot coverage Lock screen layout: - Left column: glucose + trend arrow, delta below (replaces "Last Update:") - Right grid: 4 configurable slots read from LAAppGroupSettings (defaults: IOB/COB/Proj/empty) - Footer: centered HH:MM update time at reduced opacity Slot configuration: - LiveActivitySlotOption enum with all 22 InfoType-aligned cases - LAAppGroupSettings.setSlots() / slots() persisted to App Group UserDefaults - Uniqueness enforced: selecting an option clears it from any other slot - Settings UI: "Grid slots" section with 4 pickers in LiveActivitySettingsView - Changes take effect immediately (refreshFromCurrentState called on save) GlucoseSnapshot extended with 19 new fields covering all InfoType items: override, recBolus, battery, pumpBattery, basalRate, pumpReservoirU, autosens, tdd, targetLowMgdl/High, isfMgdlPerU, carbRatio, carbsToday, profileName, sageInsertTime, cageInsertTime, iageInsertTime, minBgMgdl/Max Storage.swift: 13 new UserDefaults-backed fields for the above metrics Controllers updated to write new Storage fields on each data fetch: Basals, DeviceStatus, DeviceStatusLoop, DeviceStatusOpenAPS, Carbs, Profile, IAge Co-Authored-By: Claude Sonnet 4.6 --- .../Controllers/Nightscout/DeviceStatus.swift | 2 + .../Nightscout/DeviceStatusLoop.swift | 6 + .../Nightscout/DeviceStatusOpenAPS.swift | 15 ++ LoopFollow/Controllers/Nightscout/IAge.swift | 1 + .../Controllers/Nightscout/Profile.swift | 1 + .../Nightscout/Treatments/Basals.swift | 1 + .../Nightscout/Treatments/Carbs.swift | 1 + LoopFollow/LiveActivity/GlucoseSnapshot.swift | 142 +++++++++++- .../LiveActivity/GlucoseSnapshotBuilder.swift | 20 ++ .../LiveActivity/LAAppGroupSettings.swift | 149 +++++++++++- .../LiveActivity/LiveActivitySlotConfig.swift | 44 ++++ LoopFollow/LiveActivitySettingsView.swift | 31 +++ .../Settings/LiveActivitySettingsView.swift | 42 ---- LoopFollow/Storage/Storage.swift | 15 ++ .../LoopFollowLiveActivity.swift | 216 +++++++++++++++--- 15 files changed, 605 insertions(+), 81 deletions(-) create mode 100644 LoopFollow/LiveActivity/LiveActivitySlotConfig.swift delete mode 100644 LoopFollow/Settings/LiveActivitySettingsView.swift diff --git a/LoopFollow/Controllers/Nightscout/DeviceStatus.swift b/LoopFollow/Controllers/Nightscout/DeviceStatus.swift index b7f88634e..ae3967b3e 100644 --- a/LoopFollow/Controllers/Nightscout/DeviceStatus.swift +++ b/LoopFollow/Controllers/Nightscout/DeviceStatus.swift @@ -132,9 +132,11 @@ extension MainViewController { if let reservoirData = lastPumpRecord["reservoir"] as? Double { latestPumpVolume = reservoirData infoManager.updateInfoData(type: .pump, value: String(format: "%.0f", reservoirData) + "U") + Storage.shared.lastPumpReservoirU.value = reservoirData } else { latestPumpVolume = 50.0 infoManager.updateInfoData(type: .pump, value: "50+U") + Storage.shared.lastPumpReservoirU.value = nil } } diff --git a/LoopFollow/Controllers/Nightscout/DeviceStatusLoop.swift b/LoopFollow/Controllers/Nightscout/DeviceStatusLoop.swift index 650092237..89c4163cd 100644 --- a/LoopFollow/Controllers/Nightscout/DeviceStatusLoop.swift +++ b/LoopFollow/Controllers/Nightscout/DeviceStatusLoop.swift @@ -30,12 +30,14 @@ extension MainViewController { let profileISF = profileManager.currentISF() if let profileISF = profileISF { infoManager.updateInfoData(type: .isf, value: profileISF) + Storage.shared.lastIsfMgdlPerU.value = profileISF.doubleValue(for: .milligramsPerDeciliter) } // Carb Ratio (CR) let profileCR = profileManager.currentCarbRatio() if let profileCR = profileCR { infoManager.updateInfoData(type: .carbRatio, value: profileCR) + Storage.shared.lastCarbRatio.value = profileCR } // Target @@ -47,6 +49,8 @@ extension MainViewController { } else if let profileTargetLow = profileTargetLow { infoManager.updateInfoData(type: .target, value: profileTargetLow) } + Storage.shared.lastTargetLowMgdl.value = profileTargetLow?.doubleValue(for: .milligramsPerDeciliter) + Storage.shared.lastTargetHighMgdl.value = profileTargetHigh?.doubleValue(for: .milligramsPerDeciliter) // IOB if let insulinMetric = InsulinMetric(from: lastLoopRecord["iob"], key: "iob") { @@ -87,6 +91,8 @@ extension MainViewController { let formattedMax = Localizer.toDisplayUnits(String(predMax)) let value = "\(formattedMin)/\(formattedMax)" infoManager.updateInfoData(type: .minMax, value: value) + Storage.shared.lastMinBgMgdl.value = predMin + Storage.shared.lastMaxBgMgdl.value = predMax } updatePredictionGraph() diff --git a/LoopFollow/Controllers/Nightscout/DeviceStatusOpenAPS.swift b/LoopFollow/Controllers/Nightscout/DeviceStatusOpenAPS.swift index fc3b3c5b5..20827c253 100644 --- a/LoopFollow/Controllers/Nightscout/DeviceStatusOpenAPS.swift +++ b/LoopFollow/Controllers/Nightscout/DeviceStatusOpenAPS.swift @@ -37,8 +37,10 @@ extension MainViewController { } if let profileISF = profileISF, let enactedISF = enactedISF, profileISF != enactedISF { infoManager.updateInfoData(type: .isf, firstValue: profileISF, secondValue: enactedISF, separator: .arrow) + Storage.shared.lastIsfMgdlPerU.value = enactedISF.doubleValue(for: .milligramsPerDeciliter) } else if let profileISF = profileISF { infoManager.updateInfoData(type: .isf, value: profileISF) + Storage.shared.lastIsfMgdlPerU.value = profileISF.doubleValue(for: .milligramsPerDeciliter) } // Carb Ratio (CR) @@ -57,8 +59,10 @@ extension MainViewController { if let profileCR = profileCR, let enactedCR = enactedCR, profileCR != enactedCR { infoManager.updateInfoData(type: .carbRatio, value: profileCR, enactedValue: enactedCR, separator: .arrow) + Storage.shared.lastCarbRatio.value = enactedCR } else if let profileCR = profileCR { infoManager.updateInfoData(type: .carbRatio, value: profileCR) + Storage.shared.lastCarbRatio.value = profileCR } // IOB @@ -98,6 +102,7 @@ extension MainViewController { if let sens = enactedOrSuggested["sensitivityRatio"] as? Double { let formattedSens = String(format: "%.0f", sens * 100.0) + "%" infoManager.updateInfoData(type: .autosens, value: formattedSens) + Storage.shared.lastAutosens.value = sens } // Recommended Bolus @@ -136,11 +141,19 @@ extension MainViewController { } else { infoManager.updateInfoData(type: .target, value: profileTargetHigh) } + let effectiveMgdl = enactedTarget.doubleValue(for: .milligramsPerDeciliter) + Storage.shared.lastTargetLowMgdl.value = effectiveMgdl + Storage.shared.lastTargetHighMgdl.value = effectiveMgdl + } else if let profileTargetHigh = profileTargetHigh { + let profileMgdl = profileTargetHigh.doubleValue(for: .milligramsPerDeciliter) + Storage.shared.lastTargetLowMgdl.value = profileMgdl + Storage.shared.lastTargetHighMgdl.value = profileMgdl } // TDD if let tddMetric = InsulinMetric(from: enactedOrSuggested, key: "TDD") { infoManager.updateInfoData(type: .tdd, value: tddMetric) + Storage.shared.lastTdd.value = tddMetric.value } let predBGsData: [String: AnyObject]? = { @@ -201,6 +214,8 @@ extension MainViewController { if minPredBG != Double.infinity, maxPredBG != -Double.infinity { let value = "\(Localizer.toDisplayUnits(String(minPredBG)))/\(Localizer.toDisplayUnits(String(maxPredBG)))" infoManager.updateInfoData(type: .minMax, value: value) + Storage.shared.lastMinBgMgdl.value = minPredBG + Storage.shared.lastMaxBgMgdl.value = maxPredBG } else { infoManager.updateInfoData(type: .minMax, value: "N/A") } diff --git a/LoopFollow/Controllers/Nightscout/IAge.swift b/LoopFollow/Controllers/Nightscout/IAge.swift index 69a683c57..50e9bd592 100644 --- a/LoopFollow/Controllers/Nightscout/IAge.swift +++ b/LoopFollow/Controllers/Nightscout/IAge.swift @@ -45,6 +45,7 @@ extension MainViewController { .withColonSeparatorInTime] if let iageTime = formatter.date(from: (lastIageString as! String))?.timeIntervalSince1970 { + Storage.shared.iageInsertTime.value = iageTime let now = dateTimeUtils.getNowTimeIntervalUTC() let secondsAgo = now - iageTime diff --git a/LoopFollow/Controllers/Nightscout/Profile.swift b/LoopFollow/Controllers/Nightscout/Profile.swift index c00ac195e..f76c74a4c 100644 --- a/LoopFollow/Controllers/Nightscout/Profile.swift +++ b/LoopFollow/Controllers/Nightscout/Profile.swift @@ -23,6 +23,7 @@ extension MainViewController { } profileManager.loadProfile(from: profileData) infoManager.updateInfoData(type: .profile, value: profileData.defaultProfile) + Storage.shared.lastProfileName.value = profileData.defaultProfile // Mark profile data as loaded for initial loading state markDataLoaded("profile") diff --git a/LoopFollow/Controllers/Nightscout/Treatments/Basals.swift b/LoopFollow/Controllers/Nightscout/Treatments/Basals.swift index 5ee0891fe..405281926 100644 --- a/LoopFollow/Controllers/Nightscout/Treatments/Basals.swift +++ b/LoopFollow/Controllers/Nightscout/Treatments/Basals.swift @@ -147,5 +147,6 @@ extension MainViewController { latestBasal = "\(profileBasal) → \(latestBasal)" } infoManager.updateInfoData(type: .basal, value: latestBasal) + Storage.shared.lastBasal.value = latestBasal } } diff --git a/LoopFollow/Controllers/Nightscout/Treatments/Carbs.swift b/LoopFollow/Controllers/Nightscout/Treatments/Carbs.swift index baa4af7a1..5d75adb2d 100644 --- a/LoopFollow/Controllers/Nightscout/Treatments/Carbs.swift +++ b/LoopFollow/Controllers/Nightscout/Treatments/Carbs.swift @@ -82,5 +82,6 @@ extension MainViewController { let resultString = String(format: "%.0f", totalCarbs) infoManager.updateInfoData(type: .carbsToday, value: resultString) + Storage.shared.lastCarbsToday.value = totalCarbs } } diff --git a/LoopFollow/LiveActivity/GlucoseSnapshot.swift b/LoopFollow/LiveActivity/GlucoseSnapshot.swift index 1e573cba6..4e914ab7e 100644 --- a/LoopFollow/LiveActivity/GlucoseSnapshot.swift +++ b/LoopFollow/LiveActivity/GlucoseSnapshot.swift @@ -39,6 +39,65 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable { /// Projected glucose in mg/dL (if available) let projected: Double? + // MARK: - Extended InfoType Metrics + + /// Active override name (nil if no active override) + let override: String? + + /// Recommended bolus in units (nil if not available) + let recBolus: Double? + + /// CGM/uploader device battery % (nil if not available) + let battery: Double? + + /// Pump battery % (nil if not available) + let pumpBattery: Double? + + /// Formatted current basal rate string (empty if not available) + let basalRate: String + + /// Pump reservoir in units (nil if >50U or unknown) + let pumpReservoirU: Double? + + /// Autosensitivity ratio, e.g. 0.9 = 90% (nil if not available) + let autosens: Double? + + /// Total daily dose in units (nil if not available) + let tdd: Double? + + /// BG target low in mg/dL (nil if not available) + let targetLowMgdl: Double? + + /// BG target high in mg/dL (nil if not available) + let targetHighMgdl: Double? + + /// Insulin Sensitivity Factor in mg/dL per unit (nil if not available) + let isfMgdlPerU: Double? + + /// Carb ratio in g per unit (nil if not available) + let carbRatio: Double? + + /// Total carbs entered today in grams (nil if not available) + let carbsToday: Double? + + /// Active profile name (nil if not available) + let profileName: String? + + /// Sensor insert time as Unix epoch seconds UTC (0 = not set) + let sageInsertTime: TimeInterval + + /// Cannula insert time as Unix epoch seconds UTC (0 = not set) + let cageInsertTime: TimeInterval + + /// Insulin/pod insert time as Unix epoch seconds UTC (0 = not set) + let iageInsertTime: TimeInterval + + /// Min predicted BG in mg/dL (nil if not available) + let minBgMgdl: Double? + + /// Max predicted BG in mg/dL (nil if not available) + let maxBgMgdl: Double? + // MARK: - Unit Context /// User's preferred display unit. Values are always stored in mg/dL; @@ -64,6 +123,25 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable { iob: Double?, cob: Double?, projected: Double?, + override: String? = nil, + recBolus: Double? = nil, + battery: Double? = nil, + pumpBattery: Double? = nil, + basalRate: String = "", + pumpReservoirU: Double? = nil, + autosens: Double? = nil, + tdd: Double? = nil, + targetLowMgdl: Double? = nil, + targetHighMgdl: Double? = nil, + isfMgdlPerU: Double? = nil, + carbRatio: Double? = nil, + carbsToday: Double? = nil, + profileName: String? = nil, + sageInsertTime: TimeInterval = 0, + cageInsertTime: TimeInterval = 0, + iageInsertTime: TimeInterval = 0, + minBgMgdl: Double? = nil, + maxBgMgdl: Double? = nil, unit: Unit, isNotLooping: Bool, showRenewalOverlay: Bool = false @@ -75,6 +153,25 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable { self.iob = iob self.cob = cob self.projected = projected + self.override = override + self.recBolus = recBolus + self.battery = battery + self.pumpBattery = pumpBattery + self.basalRate = basalRate + self.pumpReservoirU = pumpReservoirU + self.autosens = autosens + self.tdd = tdd + self.targetLowMgdl = targetLowMgdl + self.targetHighMgdl = targetHighMgdl + self.isfMgdlPerU = isfMgdlPerU + self.carbRatio = carbRatio + self.carbsToday = carbsToday + self.profileName = profileName + self.sageInsertTime = sageInsertTime + self.cageInsertTime = cageInsertTime + self.iageInsertTime = iageInsertTime + self.minBgMgdl = minBgMgdl + self.maxBgMgdl = maxBgMgdl self.unit = unit self.isNotLooping = isNotLooping self.showRenewalOverlay = showRenewalOverlay @@ -89,13 +186,37 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable { try container.encodeIfPresent(iob, forKey: .iob) try container.encodeIfPresent(cob, forKey: .cob) try container.encodeIfPresent(projected, forKey: .projected) + try container.encodeIfPresent(override, forKey: .override) + try container.encodeIfPresent(recBolus, forKey: .recBolus) + try container.encodeIfPresent(battery, forKey: .battery) + try container.encodeIfPresent(pumpBattery, forKey: .pumpBattery) + try container.encode(basalRate, forKey: .basalRate) + try container.encodeIfPresent(pumpReservoirU, forKey: .pumpReservoirU) + try container.encodeIfPresent(autosens, forKey: .autosens) + try container.encodeIfPresent(tdd, forKey: .tdd) + try container.encodeIfPresent(targetLowMgdl, forKey: .targetLowMgdl) + try container.encodeIfPresent(targetHighMgdl, forKey: .targetHighMgdl) + try container.encodeIfPresent(isfMgdlPerU, forKey: .isfMgdlPerU) + try container.encodeIfPresent(carbRatio, forKey: .carbRatio) + try container.encodeIfPresent(carbsToday, forKey: .carbsToday) + try container.encodeIfPresent(profileName, forKey: .profileName) + try container.encode(sageInsertTime, forKey: .sageInsertTime) + try container.encode(cageInsertTime, forKey: .cageInsertTime) + try container.encode(iageInsertTime, forKey: .iageInsertTime) + try container.encodeIfPresent(minBgMgdl, forKey: .minBgMgdl) + try container.encodeIfPresent(maxBgMgdl, forKey: .maxBgMgdl) try container.encode(unit, forKey: .unit) try container.encode(isNotLooping, forKey: .isNotLooping) try container.encode(showRenewalOverlay, forKey: .showRenewalOverlay) } private enum CodingKeys: String, CodingKey { - case glucose, delta, trend, updatedAt, iob, cob, projected, unit, isNotLooping, showRenewalOverlay + case glucose, delta, trend, updatedAt + case iob, cob, projected + case override, recBolus, battery, pumpBattery, basalRate, pumpReservoirU + case autosens, tdd, targetLowMgdl, targetHighMgdl, isfMgdlPerU, carbRatio, carbsToday + case profileName, sageInsertTime, cageInsertTime, iageInsertTime, minBgMgdl, maxBgMgdl + case unit, isNotLooping, showRenewalOverlay } // MARK: - Codable @@ -109,6 +230,25 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable { iob = try container.decodeIfPresent(Double.self, forKey: .iob) cob = try container.decodeIfPresent(Double.self, forKey: .cob) projected = try container.decodeIfPresent(Double.self, forKey: .projected) + override = try container.decodeIfPresent(String.self, forKey: .override) + recBolus = try container.decodeIfPresent(Double.self, forKey: .recBolus) + battery = try container.decodeIfPresent(Double.self, forKey: .battery) + pumpBattery = try container.decodeIfPresent(Double.self, forKey: .pumpBattery) + basalRate = try container.decodeIfPresent(String.self, forKey: .basalRate) ?? "" + pumpReservoirU = try container.decodeIfPresent(Double.self, forKey: .pumpReservoirU) + autosens = try container.decodeIfPresent(Double.self, forKey: .autosens) + tdd = try container.decodeIfPresent(Double.self, forKey: .tdd) + targetLowMgdl = try container.decodeIfPresent(Double.self, forKey: .targetLowMgdl) + targetHighMgdl = try container.decodeIfPresent(Double.self, forKey: .targetHighMgdl) + isfMgdlPerU = try container.decodeIfPresent(Double.self, forKey: .isfMgdlPerU) + carbRatio = try container.decodeIfPresent(Double.self, forKey: .carbRatio) + carbsToday = try container.decodeIfPresent(Double.self, forKey: .carbsToday) + profileName = try container.decodeIfPresent(String.self, forKey: .profileName) + sageInsertTime = try container.decodeIfPresent(Double.self, forKey: .sageInsertTime) ?? 0 + cageInsertTime = try container.decodeIfPresent(Double.self, forKey: .cageInsertTime) ?? 0 + iageInsertTime = try container.decodeIfPresent(Double.self, forKey: .iageInsertTime) ?? 0 + minBgMgdl = try container.decodeIfPresent(Double.self, forKey: .minBgMgdl) + maxBgMgdl = try container.decodeIfPresent(Double.self, forKey: .maxBgMgdl) unit = try container.decode(Unit.self, forKey: .unit) isNotLooping = try container.decodeIfPresent(Bool.self, forKey: .isNotLooping) ?? false showRenewalOverlay = try container.decodeIfPresent(Bool.self, forKey: .showRenewalOverlay) ?? false diff --git a/LoopFollow/LiveActivity/GlucoseSnapshotBuilder.swift b/LoopFollow/LiveActivity/GlucoseSnapshotBuilder.swift index f6a1d7208..dd845b116 100644 --- a/LoopFollow/LiveActivity/GlucoseSnapshotBuilder.swift +++ b/LoopFollow/LiveActivity/GlucoseSnapshotBuilder.swift @@ -70,6 +70,7 @@ enum GlucoseSnapshotBuilder { isDebug: true ) + let profileNameRaw = Storage.shared.lastProfileName.value return GlucoseSnapshot( glucose: glucoseMgdl, delta: deltaMgdl, @@ -78,6 +79,25 @@ enum GlucoseSnapshotBuilder { iob: provider.iob, cob: provider.cob, projected: provider.projectedMgdl, + override: Observable.shared.override.value, + recBolus: Observable.shared.deviceRecBolus.value, + battery: Observable.shared.deviceBatteryLevel.value, + pumpBattery: Observable.shared.pumpBatteryLevel.value, + basalRate: Storage.shared.lastBasal.value, + pumpReservoirU: Storage.shared.lastPumpReservoirU.value, + autosens: Storage.shared.lastAutosens.value, + tdd: Storage.shared.lastTdd.value, + targetLowMgdl: Storage.shared.lastTargetLowMgdl.value, + targetHighMgdl: Storage.shared.lastTargetHighMgdl.value, + isfMgdlPerU: Storage.shared.lastIsfMgdlPerU.value, + carbRatio: Storage.shared.lastCarbRatio.value, + carbsToday: Storage.shared.lastCarbsToday.value, + profileName: profileNameRaw.isEmpty ? nil : profileNameRaw, + sageInsertTime: Storage.shared.sageInsertTime.value, + cageInsertTime: Storage.shared.cageInsertTime.value, + iageInsertTime: Storage.shared.iageInsertTime.value, + minBgMgdl: Storage.shared.lastMinBgMgdl.value, + maxBgMgdl: Storage.shared.lastMaxBgMgdl.value, unit: preferredUnit, isNotLooping: isNotLooping, showRenewalOverlay: showRenewalOverlay diff --git a/LoopFollow/LiveActivity/LAAppGroupSettings.swift b/LoopFollow/LiveActivity/LAAppGroupSettings.swift index 7615b2cf7..2880c0efe 100644 --- a/LoopFollow/LiveActivity/LAAppGroupSettings.swift +++ b/LoopFollow/LiveActivity/LAAppGroupSettings.swift @@ -3,6 +3,129 @@ import Foundation +// MARK: - Slot option enum + +/// One displayable metric that can occupy a slot in the Live Activity 2×2 grid. +/// +/// - `.none` is the empty/blank state — leaves the slot visually empty. +/// - Optional cases (isOptional == true) may display "—" for Dexcom-only users +/// whose setup does not provide that metric. +/// - All values are read from GlucoseSnapshot at render time inside the widget +/// extension; no additional App Group reads are required per slot. +enum LiveActivitySlotOption: String, CaseIterable, Codable { + // Core glucose + case none + case delta + case projectedBG + case minMax + // Loop metrics + case iob + case cob + case recBolus + case autosens + case tdd + // Pump / device + case basal + case pump + case pumpBattery + case battery + case target + case isf + case carbRatio + // Ages + case sage + case cage + case iage + // Other + case carbsToday + case override + case profile + + /// Human-readable label shown in the slot picker in Settings. + var displayName: String { + switch self { + case .none: return "Empty" + case .delta: return "Delta" + case .projectedBG: return "Projected BG" + case .minMax: return "Min/Max" + case .iob: return "IOB" + case .cob: return "COB" + case .recBolus: return "Rec. Bolus" + case .autosens: return "Autosens" + case .tdd: return "TDD" + case .basal: return "Basal" + case .pump: return "Pump" + case .pumpBattery: return "Pump Battery" + case .battery: return "Battery" + case .target: return "Target" + case .isf: return "ISF" + case .carbRatio: return "CR" + case .sage: return "SAGE" + case .cage: return "CAGE" + case .iage: return "IAGE" + case .carbsToday: return "Carbs today" + case .override: return "Override" + case .profile: return "Profile" + } + } + + /// Short label used inside the MetricBlock on the Live Activity card. + var gridLabel: String { + switch self { + case .none: return "" + case .delta: return "Delta" + case .projectedBG: return "Proj" + case .minMax: return "Min/Max" + case .iob: return "IOB" + case .cob: return "COB" + case .recBolus: return "Rec." + case .autosens: return "Sens" + case .tdd: return "TDD" + case .basal: return "Basal" + case .pump: return "Pump" + case .pumpBattery: return "Pump%" + case .battery: return "Bat." + case .target: return "Target" + case .isf: return "ISF" + case .carbRatio: return "CR" + case .sage: return "SAGE" + case .cage: return "CAGE" + case .iage: return "IAGE" + case .carbsToday: return "Carbs" + case .override: return "Ovrd" + case .profile: return "Prof" + } + } + + /// True when the underlying value may be nil (e.g. Dexcom-only users who have + /// no Loop data). The widget renders "—" in those cases. + var isOptional: Bool { + switch self { + case .none, .delta: return false + default: return true + } + } +} + +// MARK: - Default slot assignments + +struct LiveActivitySlotDefaults { + /// Top-left slot + static let slot1: LiveActivitySlotOption = .iob + /// Bottom-left slot + static let slot2: LiveActivitySlotOption = .cob + /// Top-right slot + static let slot3: LiveActivitySlotOption = .projectedBG + /// Bottom-right slot — intentionally empty until the user configures it + static let slot4: LiveActivitySlotOption = .none + + static var all: [LiveActivitySlotOption] { + [slot1, slot2, slot3, slot4] + } +} + +// MARK: - App Group settings + /// Minimal App Group settings needed by the Live Activity UI. /// /// We keep this separate from Storage.shared to avoid target-coupling and @@ -11,24 +134,46 @@ enum LAAppGroupSettings { private enum Keys { static let lowLineMgdl = "la.lowLine.mgdl" static let highLineMgdl = "la.highLine.mgdl" + static let slots = "la.slots" } private static var defaults: UserDefaults? { UserDefaults(suiteName: AppGroupID.current()) } - // MARK: - Write (App) + // MARK: - Thresholds (Write) static func setThresholds(lowMgdl: Double, highMgdl: Double) { defaults?.set(lowMgdl, forKey: Keys.lowLineMgdl) defaults?.set(highMgdl, forKey: Keys.highLineMgdl) } - // MARK: - Read (Extension) + // MARK: - Thresholds (Read) static func thresholdsMgdl(fallbackLow: Double = 70, fallbackHigh: Double = 180) -> (low: Double, high: Double) { let low = defaults?.object(forKey: Keys.lowLineMgdl) as? Double ?? fallbackLow let high = defaults?.object(forKey: Keys.highLineMgdl) as? Double ?? fallbackHigh return (low, high) } + + // MARK: - Slot configuration (Write) + + /// Persists a 4-slot configuration to the App Group container. + /// - Parameter slots: Array of exactly 4 `LiveActivitySlotOption` values; + /// extra elements are ignored, missing elements are filled with `.none`. + static func setSlots(_ slots: [LiveActivitySlotOption]) { + let raw = slots.prefix(4).map { $0.rawValue } + defaults?.set(raw, forKey: Keys.slots) + } + + // MARK: - Slot configuration (Read) + + /// Returns the current 4-slot configuration, falling back to defaults + /// if no configuration has been saved yet. + static func slots() -> [LiveActivitySlotOption] { + guard let raw = defaults?.stringArray(forKey: Keys.slots), raw.count == 4 else { + return LiveActivitySlotDefaults.all + } + return raw.map { LiveActivitySlotOption(rawValue: $0) ?? .none } + } } diff --git a/LoopFollow/LiveActivity/LiveActivitySlotConfig.swift b/LoopFollow/LiveActivity/LiveActivitySlotConfig.swift new file mode 100644 index 000000000..2b097a6b1 --- /dev/null +++ b/LoopFollow/LiveActivity/LiveActivitySlotConfig.swift @@ -0,0 +1,44 @@ +// LoopFollow +// LiveActivitySlotConfig.swift + +// MARK: - Information Display Settings audit +// +// LoopFollow exposes 20 items in Information Display Settings (InfoType.swift). +// The table below maps each item to its availability as a Live Activity grid slot. +// +// AVAILABLE NOW — value present in GlucoseSnapshot: +// Display name | InfoType case | Snapshot field | Optional (nil for Dexcom-only) +// ───────────────────────────────────────────────────────────────────────────────── +// IOB | .iob | snapshot.iob | YES +// COB | .cob | snapshot.cob | YES +// Projected BG | (none) | snapshot.projected | YES +// Delta | (none) | snapshot.delta | NO (always available) +// +// Note: "Updated" (InfoType.updated) is intentionally excluded — it is displayed +// in the card footer and is not a configurable slot. +// +// NOT YET AVAILABLE — requires adding fields to GlucoseSnapshot, GlucoseSnapshotBuilder, +// and the APNs payload before they can be offered as slot options: +// Display name | InfoType case | Source in app +// ───────────────────────────────────────────────────────────────────────────────── +// Basal | .basal | DeviceStatus basal rate +// Override | .override | DeviceStatus override name +// Battery | .battery | DeviceStatus CGM/device battery % +// Pump | .pump | DeviceStatus pump name / status +// Pump Battery | .pumpBattery | DeviceStatus pump battery % +// SAGE | .sage | DeviceStatus sensor age (hours) +// CAGE | .cage | DeviceStatus cannula age (hours) +// Rec. Bolus | .recBolus | DeviceStatus recommended bolus +// Min/Max | .minMax | Computed from recent BG history +// Carbs today | .carbsToday | Computed from COB history +// Autosens | .autosens | DeviceStatusOpenAPS autosens ratio +// Profile | .profile | DeviceStatus profile name +// Target | .target | DeviceStatus BG target +// ISF | .isf | DeviceStatus insulin sensitivity factor +// CR | .carbRatio | DeviceStatus carb ratio +// TDD | .tdd | DeviceStatus total daily dose +// IAGE | .iage | DeviceStatus insulin/pod age (hours) +// +// The LiveActivitySlotOption enum, LiveActivitySlotDefaults struct, and +// LAAppGroupSettings.setSlots() / slots() storage are defined in +// LAAppGroupSettings.swift (shared between app and extension targets). diff --git a/LoopFollow/LiveActivitySettingsView.swift b/LoopFollow/LiveActivitySettingsView.swift index 0a29d702a..99dbc13e6 100644 --- a/LoopFollow/LiveActivitySettingsView.swift +++ b/LoopFollow/LiveActivitySettingsView.swift @@ -6,6 +6,9 @@ import SwiftUI struct LiveActivitySettingsView: View { @State private var laEnabled: Bool = Storage.shared.laEnabled.value @State private var restartConfirmed = false + @State private var slots: [LiveActivitySlotOption] = LAAppGroupSettings.slots() + + private let slotLabels = ["Top left", "Top right", "Bottom left", "Bottom right"] var body: some View { Form { @@ -25,6 +28,19 @@ struct LiveActivitySettingsView: View { .disabled(restartConfirmed) } } + + Section(header: Text("Grid slots")) { + ForEach(0 ..< 4, id: \.self) { index in + Picker(slotLabels[index], selection: Binding( + get: { slots[index] }, + set: { selectSlot($0, at: index) } + )) { + ForEach(LiveActivitySlotOption.allCases, id: \.self) { option in + Text(option.displayName).tag(option) + } + } + } + } } .onReceive(Storage.shared.laEnabled.$value) { newValue in if newValue != laEnabled { laEnabled = newValue } @@ -41,4 +57,19 @@ struct LiveActivitySettingsView: View { .navigationTitle("Live Activity") .navigationBarTitleDisplayMode(.inline) } + + /// Selects an option for the given slot index, enforcing uniqueness: + /// if the chosen option is already in another slot, that slot is cleared to `.none`. + private func selectSlot(_ option: LiveActivitySlotOption, at index: Int) { + if option != .none { + for i in 0 ..< slots.count where i != index && slots[i] == option { + slots[i] = .none + } + } + slots[index] = option + LAAppGroupSettings.setSlots(slots) + Task { + await LiveActivityManager.shared.refreshFromCurrentState(reason: "slot config changed") + } + } } diff --git a/LoopFollow/Settings/LiveActivitySettingsView.swift b/LoopFollow/Settings/LiveActivitySettingsView.swift deleted file mode 100644 index bfe39b3ee..000000000 --- a/LoopFollow/Settings/LiveActivitySettingsView.swift +++ /dev/null @@ -1,42 +0,0 @@ -// LoopFollow -// LiveActivitySettingsView.swift - -import SwiftUI - -struct LiveActivitySettingsView: View { - @State private var laEnabled: Bool = Storage.shared.laEnabled.value - @State private var restartConfirmed = false - - var body: some View { - Form { - Section(header: Text("Live Activity")) { - Toggle("Enable Live Activity", isOn: $laEnabled) - } - - if laEnabled { - Section { - Button(restartConfirmed ? "Live Activity Restarted" : "Restart Live Activity") { - LiveActivityManager.shared.forceRestart() - restartConfirmed = true - DispatchQueue.main.asyncAfter(deadline: .now() + 2) { - restartConfirmed = false - } - } - .disabled(restartConfirmed) - } - } - } - .onReceive(Storage.shared.laEnabled.$value) { newValue in - if newValue != laEnabled { laEnabled = newValue } - } - .onChange(of: laEnabled) { newValue in - Storage.shared.laEnabled.value = newValue - if !newValue { - LiveActivityManager.shared.end(dismissalPolicy: .immediate) - } - } - .preferredColorScheme(Storage.shared.appearanceMode.value.colorScheme) - .navigationTitle("Live Activity") - .navigationBarTitleDisplayMode(.inline) - } -} diff --git a/LoopFollow/Storage/Storage.swift b/LoopFollow/Storage/Storage.swift index 141293e7c..7884e6589 100644 --- a/LoopFollow/Storage/Storage.swift +++ b/LoopFollow/Storage/Storage.swift @@ -91,6 +91,21 @@ class Storage { var lastCOB = StorageValue(key: "lastCOB", defaultValue: nil) var projectedBgMgdl = StorageValue(key: "projectedBgMgdl", defaultValue: nil) + // Live Activity extended InfoType data + var lastBasal = StorageValue(key: "lastBasal", defaultValue: "") + var lastPumpReservoirU = StorageValue(key: "lastPumpReservoirU", defaultValue: nil) + var lastAutosens = StorageValue(key: "lastAutosens", defaultValue: nil) + var lastTdd = StorageValue(key: "lastTdd", defaultValue: nil) + var lastTargetLowMgdl = StorageValue(key: "lastTargetLowMgdl", defaultValue: nil) + var lastTargetHighMgdl = StorageValue(key: "lastTargetHighMgdl", defaultValue: nil) + var lastIsfMgdlPerU = StorageValue(key: "lastIsfMgdlPerU", defaultValue: nil) + var lastCarbRatio = StorageValue(key: "lastCarbRatio", defaultValue: nil) + var lastCarbsToday = StorageValue(key: "lastCarbsToday", defaultValue: nil) + var lastProfileName = StorageValue(key: "lastProfileName", defaultValue: "") + var iageInsertTime = StorageValue(key: "iageInsertTime", defaultValue: 0) + var lastMinBgMgdl = StorageValue(key: "lastMinBgMgdl", defaultValue: nil) + var lastMaxBgMgdl = StorageValue(key: "lastMaxBgMgdl", defaultValue: nil) + // Live Activity var laEnabled = StorageValue(key: "laEnabled", defaultValue: false) var laRenewBy = StorageValue(key: "laRenewBy", defaultValue: 0) diff --git a/LoopFollowLAExtension/LoopFollowLiveActivity.swift b/LoopFollowLAExtension/LoopFollowLiveActivity.swift index 294ba8645..9a108a21a 100644 --- a/LoopFollowLAExtension/LoopFollowLiveActivity.swift +++ b/LoopFollowLAExtension/LoopFollowLiveActivity.swift @@ -78,50 +78,62 @@ private struct LockScreenLiveActivityView: View { var body: some View { let s = state.snapshot + let slotConfig = LAAppGroupSettings.slots() + + VStack(spacing: 6) { + HStack(spacing: 12) { + // LEFT: Glucose + trend arrow, delta below + VStack(alignment: .leading, spacing: 4) { + HStack(alignment: .firstTextBaseline, spacing: 8) { + Text(LAFormat.glucose(s)) + .font(.system(size: 46, weight: .bold, design: .rounded)) + .monospacedDigit() + .foregroundStyle(.white) - HStack(spacing: 12) { - // LEFT: Glucose + trend, update time below - VStack(alignment: .leading, spacing: 4) { - HStack(alignment: .firstTextBaseline, spacing: 8) { - Text(LAFormat.glucose(s)) - .font(.system(size: 46, weight: .bold, design: .rounded)) - .monospacedDigit() - .foregroundStyle(.white) - - Text(LAFormat.trendArrow(s)) - .font(.system(size: 46, weight: .bold, design: .rounded)) - .foregroundStyle(.white.opacity(0.95)) - } + Text(LAFormat.trendArrow(s)) + .font(.system(size: 46, weight: .bold, design: .rounded)) + .foregroundStyle(.white.opacity(0.95)) + } - Text("Last Update: \(LAFormat.updated(s))") - .font(.system(size: 13, weight: .regular, design: .rounded)) - .foregroundStyle(.white.opacity(0.75)) - } - .frame(width: 168, alignment: .leading) - .layoutPriority(2) - - // Divider - Rectangle() - .fill(Color.white.opacity(0.20)) - .frame(width: 1) - .padding(.vertical, 8) - - // RIGHT: 2x2 grid — delta/proj | iob/cob - VStack(spacing: 10) { - HStack(spacing: 16) { - MetricBlock(label: "Delta", value: LAFormat.delta(s)) - MetricBlock(label: "IOB", value: LAFormat.iob(s)) + Text(LAFormat.delta(s)) + .font(.system(size: 15, weight: .semibold, design: .rounded)) + .monospacedDigit() + .foregroundStyle(.white.opacity(0.80)) } - HStack(spacing: 16) { - MetricBlock(label: "Proj", value: LAFormat.projected(s)) - MetricBlock(label: "COB", value: LAFormat.cob(s)) + .frame(width: 168, alignment: .leading) + .layoutPriority(2) + + // Divider + Rectangle() + .fill(Color.white.opacity(0.20)) + .frame(width: 1) + .padding(.vertical, 8) + + // RIGHT: configurable 2×2 grid + VStack(spacing: 10) { + HStack(spacing: 16) { + SlotView(option: slotConfig[0], snapshot: s) + SlotView(option: slotConfig[1], snapshot: s) + } + HStack(spacing: 16) { + SlotView(option: slotConfig[2], snapshot: s) + SlotView(option: slotConfig[3], snapshot: s) + } } + .frame(maxWidth: .infinity, alignment: .trailing) } - .frame(maxWidth: .infinity, alignment: .trailing) + + // Footer: last update time + Text(LAFormat.updated(s)) + .font(.system(size: 11, weight: .regular, design: .rounded)) + .monospacedDigit() + .foregroundStyle(.white.opacity(0.65)) + .frame(maxWidth: .infinity, alignment: .center) } .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal, 14) - .padding(.vertical, 12) + .padding(.top, 12) + .padding(.bottom, 8) .overlay( RoundedRectangle(cornerRadius: 16, style: .continuous) .stroke(Color.white.opacity(0.20), lineWidth: 1) @@ -193,6 +205,50 @@ private struct MetricBlock: View { } } +/// Renders one configurable slot in the lock screen 2×2 grid. +/// Shows nothing (invisible placeholder) when the slot option is `.none`. +private struct SlotView: View { + let option: LiveActivitySlotOption + let snapshot: GlucoseSnapshot + + var body: some View { + if option == .none { + // Invisible spacer — preserves grid alignment + Color.clear + .frame(width: 64, height: 36) + } else { + MetricBlock(label: option.gridLabel, value: value(for: option)) + } + } + + private func value(for option: LiveActivitySlotOption) -> String { + switch option { + case .none: return "" + case .delta: return LAFormat.delta(snapshot) + case .projectedBG: return LAFormat.projected(snapshot) + case .minMax: return LAFormat.minMax(snapshot) + case .iob: return LAFormat.iob(snapshot) + case .cob: return LAFormat.cob(snapshot) + case .recBolus: return LAFormat.recBolus(snapshot) + case .autosens: return LAFormat.autosens(snapshot) + case .tdd: return LAFormat.tdd(snapshot) + case .basal: return LAFormat.basal(snapshot) + case .pump: return LAFormat.pump(snapshot) + case .pumpBattery: return LAFormat.pumpBattery(snapshot) + case .battery: return LAFormat.battery(snapshot) + case .target: return LAFormat.target(snapshot) + case .isf: return LAFormat.isf(snapshot) + case .carbRatio: return LAFormat.carbRatio(snapshot) + case .sage: return LAFormat.age(insertTime: snapshot.sageInsertTime) + case .cage: return LAFormat.age(insertTime: snapshot.cageInsertTime) + case .iage: return LAFormat.age(insertTime: snapshot.iageInsertTime) + case .carbsToday: return LAFormat.carbsToday(snapshot) + case .override: return LAFormat.override(snapshot) + case .profile: return LAFormat.profileName(snapshot) + } + } +} + // MARK: - Dynamic Island @available(iOS 16.1, *) @@ -409,6 +465,94 @@ private enum LAFormat { return formatGlucoseValue(v, unit: s.unit) } + // MARK: Extended InfoType formatters + + private static let ageFormatter: DateComponentsFormatter = { + let f = DateComponentsFormatter() + f.unitsStyle = .positional + f.allowedUnits = [.day, .hour] + f.zeroFormattingBehavior = [.pad] + return f + }() + + /// Formats an insert-time epoch into "D:HH" age string. Returns "—" if time is 0. + static func age(insertTime: TimeInterval) -> String { + guard insertTime > 0 else { return "—" } + let secondsAgo = Date().timeIntervalSince1970 - insertTime + return ageFormatter.string(from: secondsAgo) ?? "—" + } + + static func recBolus(_ s: GlucoseSnapshot) -> String { + guard let v = s.recBolus else { return "—" } + return String(format: "%.2fU", v) + } + + static func autosens(_ s: GlucoseSnapshot) -> String { + guard let v = s.autosens else { return "—" } + return String(format: "%.0f%%", v * 100) + } + + static func tdd(_ s: GlucoseSnapshot) -> String { + guard let v = s.tdd else { return "—" } + return String(format: "%.1fU", v) + } + + static func basal(_ s: GlucoseSnapshot) -> String { + s.basalRate.isEmpty ? "—" : s.basalRate + } + + static func pump(_ s: GlucoseSnapshot) -> String { + guard let v = s.pumpReservoirU else { return "50+U" } + return "\(Int(round(v)))U" + } + + static func pumpBattery(_ s: GlucoseSnapshot) -> String { + guard let v = s.pumpBattery else { return "—" } + return String(format: "%.0f%%", v) + } + + static func battery(_ s: GlucoseSnapshot) -> String { + guard let v = s.battery else { return "—" } + return String(format: "%.0f%%", v) + } + + static func target(_ s: GlucoseSnapshot) -> String { + guard let low = s.targetLowMgdl, low > 0 else { return "—" } + let lowStr = formatGlucoseValue(low, unit: s.unit) + if let high = s.targetHighMgdl, high > 0, abs(high - low) > 0.5 { + return "\(lowStr)-\(formatGlucoseValue(high, unit: s.unit))" + } + return lowStr + } + + static func isf(_ s: GlucoseSnapshot) -> String { + guard let v = s.isfMgdlPerU, v > 0 else { return "—" } + return formatGlucoseValue(v, unit: s.unit) + } + + static func carbRatio(_ s: GlucoseSnapshot) -> String { + guard let v = s.carbRatio, v > 0 else { return "—" } + return String(format: "%.0fg", v) + } + + static func carbsToday(_ s: GlucoseSnapshot) -> String { + guard let v = s.carbsToday else { return "—" } + return "\(Int(round(v)))g" + } + + static func minMax(_ s: GlucoseSnapshot) -> String { + guard let mn = s.minBgMgdl, let mx = s.maxBgMgdl else { return "—" } + return "\(formatGlucoseValue(mn, unit: s.unit))/\(formatGlucoseValue(mx, unit: s.unit))" + } + + static func override(_ s: GlucoseSnapshot) -> String { + s.override ?? "—" + } + + static func profileName(_ s: GlucoseSnapshot) -> String { + s.profileName ?? "—" + } + // MARK: Update time private static let hhmmFormatter: DateFormatter = { From 0401c48e30635c7f1004f445c11d9022d716b85c Mon Sep 17 00:00:00 2001 From: MtlPhil <76601115+MtlPhil@users.noreply.github.com> Date: Sun, 15 Mar 2026 13:32:34 -0400 Subject: [PATCH 40/82] fix: label delta and footer on lock screen LA card Co-Authored-By: Claude Sonnet 4.6 --- LoopFollowLAExtension/LoopFollowLiveActivity.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/LoopFollowLAExtension/LoopFollowLiveActivity.swift b/LoopFollowLAExtension/LoopFollowLiveActivity.swift index 9a108a21a..d0f351611 100644 --- a/LoopFollowLAExtension/LoopFollowLiveActivity.swift +++ b/LoopFollowLAExtension/LoopFollowLiveActivity.swift @@ -95,7 +95,7 @@ private struct LockScreenLiveActivityView: View { .foregroundStyle(.white.opacity(0.95)) } - Text(LAFormat.delta(s)) + Text("Delta: \(LAFormat.delta(s))") .font(.system(size: 15, weight: .semibold, design: .rounded)) .monospacedDigit() .foregroundStyle(.white.opacity(0.80)) @@ -124,7 +124,7 @@ private struct LockScreenLiveActivityView: View { } // Footer: last update time - Text(LAFormat.updated(s)) + Text("Last Update: \(LAFormat.updated(s))") .font(.system(size: 11, weight: .regular, design: .rounded)) .monospacedDigit() .foregroundStyle(.white.opacity(0.65)) From f42e502b0c09938901ea7f29b2235aae273b39a1 Mon Sep 17 00:00:00 2001 From: MtlPhil <76601115+MtlPhil@users.noreply.github.com> Date: Sun, 15 Mar 2026 14:24:54 -0400 Subject: [PATCH 41/82] docs: add PR description for configurable LA grid slots Co-Authored-By: Claude Sonnet 4.6 --- docs/PR_configurable_slots.md | 117 ++++++++++++++++++++++++++++++++++ 1 file changed, 117 insertions(+) create mode 100644 docs/PR_configurable_slots.md diff --git a/docs/PR_configurable_slots.md b/docs/PR_configurable_slots.md new file mode 100644 index 000000000..46db92cf2 --- /dev/null +++ b/docs/PR_configurable_slots.md @@ -0,0 +1,117 @@ +# Configurable Live Activity Grid Slots + Full InfoType Snapshot Coverage + +## Summary + +- Replace the hardcoded 2×2 grid on the Live Activity lock screen with four fully configurable slots, each independently selectable from all 20+ available metrics via a new Settings picker UI +- Extend `GlucoseSnapshot` with 19 new fields covering all InfoType items (basal, pump, autosens, TDD, ISF, CR, target, ages, carbs today, profile name, min/max BG, override) +- Wire up all downstream data sources (controllers + Storage) so every new field is populated on each data refresh cycle +- Redesign the lock screen layout: glucose + trend arrow left-aligned, delta below the BG value, configurable grid on the right, "Last Update: HH:MM" footer centered at the bottom + +--- + +## Changes + +### Lock screen layout redesign (`LoopFollowLAExtension/LoopFollowLiveActivity.swift`) + +The previous layout had glucose + a fixed four-slot grid side by side with no clear hierarchy. The new layout: + +- **Left column:** Large glucose value + trend arrow (`.system(size: 46)`), with `Delta: ±X` below in a smaller semibold font +- **Right column:** Configurable 2×2 grid — slot content driven by `LAAppGroupSettings.slots()`, read from the shared App Group container +- **Footer:** `Last Update: HH:MM` centered below both columns + +A new `SlotView` struct handles dispatch for all 22 slot cases. Fifteen new `LAFormat` static methods were added to format each metric consistently (locale-aware number formatting, unit suffix, graceful `—` for nil/unavailable values). + +### Configurable slot picker UI (`LoopFollow/LiveActivitySettingsView.swift`) + +A new **Grid slots** section appears in the Live Activity settings screen with four pickers labelled Top left, Top right, Bottom left, Bottom right. Selecting a metric for one slot automatically clears that metric from any other slot (uniqueness enforced). Changes take effect immediately — `LiveActivityManager.shared.refreshFromCurrentState(reason: "slot config changed")` is called on every picker change. + +### Slot type definitions (`LoopFollow/LiveActivity/LAAppGroupSettings.swift`) + +- New `LiveActivitySlotOption` enum (22 cases: `none`, `delta`, `projectedBG`, `minMax`, `iob`, `cob`, `recBolus`, `autosens`, `tdd`, `basal`, `pump`, `pumpBattery`, `battery`, `target`, `isf`, `carbRatio`, `sage`, `cage`, `iage`, `carbsToday`, `override`, `profile`) +- `displayName` (used in Settings picker) and `gridLabel` (used inside the MetricBlock on the LA card) computed properties +- `isOptional` flag — `true` for metrics that may be absent for Dexcom-only users; the widget renders `—` in those cases +- `LiveActivitySlotDefaults` struct with out-of-the-box defaults: IOB / COB / Projected BG / Empty +- `LAAppGroupSettings.setSlots()` / `slots()` — persist and read the 4-slot configuration via the shared App Group `UserDefaults` container, so the extension always sees the current user selection + +All of this is placed in `LAAppGroupSettings.swift` because that file is already compiled into both the app target and the extension target. No new Xcode project file membership was required. + +### Extended GlucoseSnapshot (`LoopFollow/LiveActivity/GlucoseSnapshot.swift`) + +Added 19 new stored properties. All are optional or have safe defaults so decoding an older snapshot (e.g. from a push that arrived before the app updated) never crashes: + +| Property | Type | Source | +|---|---|---| +| `override` | `String?` | `Observable.shared.override` | +| `recBolus` | `Double?` | `Observable.shared.recBolus` | +| `battery` | `Double?` | `Observable.shared.battery` | +| `pumpBattery` | `Double?` | `Observable.shared.pumpBattery` | +| `basalRate` | `String` | `Storage.shared.lastBasal` | +| `pumpReservoirU` | `Double?` | `Storage.shared.lastPumpReservoirU` | +| `autosens` | `Double?` | `Storage.shared.lastAutosens` | +| `tdd` | `Double?` | `Storage.shared.lastTdd` | +| `targetLowMgdl` | `Double?` | `Storage.shared.lastTargetLowMgdl` | +| `targetHighMgdl` | `Double?` | `Storage.shared.lastTargetHighMgdl` | +| `isfMgdlPerU` | `Double?` | `Storage.shared.lastIsfMgdlPerU` | +| `carbRatio` | `Double?` | `Storage.shared.lastCarbRatio` | +| `carbsToday` | `Double?` | `Storage.shared.lastCarbsToday` | +| `profileName` | `String?` | `Storage.shared.lastProfileName` | +| `sageInsertTime` | `TimeInterval` | `Storage.shared.sageInsertTime` | +| `cageInsertTime` | `TimeInterval` | `Storage.shared.cageInsertTime` | +| `iageInsertTime` | `TimeInterval` | `Storage.shared.iageInsertTime` | +| `minBgMgdl` | `Double?` | `Storage.shared.lastMinBgMgdl` | +| `maxBgMgdl` | `Double?` | `Storage.shared.lastMaxBgMgdl` | + +All glucose-valued fields are stored in **mg/dL**; conversion to mmol/L happens at display time in `LAFormat`, consistent with the existing snapshot design. + +Age-based fields (SAGE, CAGE, IAGE) are stored as Unix epoch `TimeInterval` (0 = not set). `LAFormat.age(insertTime:)` computes the human-readable age string at render time using `DateComponentsFormatter` with `.positional` style and `[.day, .hour]` units. + +### GlucoseSnapshotBuilder (`LoopFollow/LiveActivity/GlucoseSnapshotBuilder.swift`) + +Extended `build(from:)` to populate all 19 new fields from `Observable.shared` and `Storage.shared`. + +### Storage additions (`LoopFollow/Storage/Storage.swift`) + +13 new `StorageValue`-backed fields in a dedicated "Live Activity extended InfoType data" section: + +``` +lastBasal, lastPumpReservoirU, lastAutosens, lastTdd, +lastTargetLowMgdl, lastTargetHighMgdl, lastIsfMgdlPerU, +lastCarbRatio, lastCarbsToday, lastProfileName, +iageInsertTime, lastMinBgMgdl, lastMaxBgMgdl +``` + +### Controller writes + +Each data-fetching controller now writes one additional `Storage.shared` value alongside its existing `infoManager.updateInfoData` call. No existing logic was changed — these are purely additive writes: + +| Controller | Field written | +|---|---| +| `Basals.swift` | `lastBasal` | +| `DeviceStatus.swift` | `lastPumpReservoirU` | +| `DeviceStatusLoop.swift` | `lastIsfMgdlPerU`, `lastCarbRatio`, `lastTargetLowMgdl`, `lastTargetHighMgdl`, `lastMinBgMgdl`, `lastMaxBgMgdl` | +| `DeviceStatusOpenAPS.swift` | `lastAutosens`, `lastTdd`, `lastIsfMgdlPerU`, `lastCarbRatio`, `lastTargetLowMgdl`, `lastTargetHighMgdl`, `lastMinBgMgdl`, `lastMaxBgMgdl` | +| `Carbs.swift` | `lastCarbsToday` | +| `Profile.swift` | `lastProfileName` | +| `IAge.swift` | `iageInsertTime` | + +--- + +## What was not changed + +- APNs push infrastructure — no changes to `APNSClient`, `JWTManager`, or the push payload format beyond what was already present +- Dynamic Island layout — compact, expanded, and minimal presentations are unchanged +- Threshold-driven background color logic — unchanged +- "Not Looping" banner logic — unchanged +- All existing `LAFormat` methods — unchanged; new methods were added alongside + +--- + +## Testing + +- Build and run on a device with Live Activity enabled +- Open Settings → Live Activity → Grid slots; verify four pickers appear with all options +- Select a metric in one slot; verify it is cleared from any other slot that had it +- Verify the lock screen shows the new layout: large BG + arrow left, delta below, configurable grid right, footer bottom +- For Loop users: verify IOB, COB, basal, ISF, CR, target, TDD, autosens, projected BG, pump, override, profile name all populate correctly +- For Dexcom-only users: verify optional slots show `—` rather than crashing +- Verify SAGE, CAGE, IAGE display as `D:HH` age strings From b8c19cf2068a8e742694a93360c1c9deaa687a26 Mon Sep 17 00:00:00 2001 From: MtlPhil <76601115+MtlPhil@users.noreply.github.com> Date: Sun, 15 Mar 2026 14:27:32 -0400 Subject: [PATCH 42/82] Update PR_configurable_slots.md --- docs/PR_configurable_slots.md | 22 +--------------------- 1 file changed, 1 insertion(+), 21 deletions(-) diff --git a/docs/PR_configurable_slots.md b/docs/PR_configurable_slots.md index 46db92cf2..685e0204e 100644 --- a/docs/PR_configurable_slots.md +++ b/docs/PR_configurable_slots.md @@ -94,24 +94,4 @@ Each data-fetching controller now writes one additional `Storage.shared` value a | `Profile.swift` | `lastProfileName` | | `IAge.swift` | `iageInsertTime` | ---- - -## What was not changed - -- APNs push infrastructure — no changes to `APNSClient`, `JWTManager`, or the push payload format beyond what was already present -- Dynamic Island layout — compact, expanded, and minimal presentations are unchanged -- Threshold-driven background color logic — unchanged -- "Not Looping" banner logic — unchanged -- All existing `LAFormat` methods — unchanged; new methods were added alongside - ---- - -## Testing - -- Build and run on a device with Live Activity enabled -- Open Settings → Live Activity → Grid slots; verify four pickers appear with all options -- Select a metric in one slot; verify it is cleared from any other slot that had it -- Verify the lock screen shows the new layout: large BG + arrow left, delta below, configurable grid right, footer bottom -- For Loop users: verify IOB, COB, basal, ISF, CR, target, TDD, autosens, projected BG, pump, override, profile name all populate correctly -- For Dexcom-only users: verify optional slots show `—` rather than crashing -- Verify SAGE, CAGE, IAGE display as `D:HH` age strings +--- \ No newline at end of file From b571cad6770e3d9253f463a31cc5e6ad7b33bc46 Mon Sep 17 00:00:00 2001 From: MtlPhil <76601115+MtlPhil@users.noreply.github.com> Date: Sun, 15 Mar 2026 15:54:07 -0400 Subject: [PATCH 43/82] chore: remove PR notes from tracking, keep docs/LiveActivity.md only - Untrack docs/PR_configurable_slots.md (local-only reference doc) - Add docs/PR_configurable_slots.md and docs/LiveActivityTestPlan.md to .gitignore Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 2 + docs/PR_configurable_slots.md | 97 ----------------------------------- 2 files changed, 2 insertions(+), 97 deletions(-) delete mode 100644 docs/PR_configurable_slots.md diff --git a/.gitignore b/.gitignore index 178842387..d372f7c5a 100644 --- a/.gitignore +++ b/.gitignore @@ -81,3 +81,5 @@ fastlane/FastlaneRunner LoopFollowConfigOverride.xcconfig .history*.xcuserstate +docs/PR_configurable_slots.md +docs/LiveActivityTestPlan.md diff --git a/docs/PR_configurable_slots.md b/docs/PR_configurable_slots.md deleted file mode 100644 index 685e0204e..000000000 --- a/docs/PR_configurable_slots.md +++ /dev/null @@ -1,97 +0,0 @@ -# Configurable Live Activity Grid Slots + Full InfoType Snapshot Coverage - -## Summary - -- Replace the hardcoded 2×2 grid on the Live Activity lock screen with four fully configurable slots, each independently selectable from all 20+ available metrics via a new Settings picker UI -- Extend `GlucoseSnapshot` with 19 new fields covering all InfoType items (basal, pump, autosens, TDD, ISF, CR, target, ages, carbs today, profile name, min/max BG, override) -- Wire up all downstream data sources (controllers + Storage) so every new field is populated on each data refresh cycle -- Redesign the lock screen layout: glucose + trend arrow left-aligned, delta below the BG value, configurable grid on the right, "Last Update: HH:MM" footer centered at the bottom - ---- - -## Changes - -### Lock screen layout redesign (`LoopFollowLAExtension/LoopFollowLiveActivity.swift`) - -The previous layout had glucose + a fixed four-slot grid side by side with no clear hierarchy. The new layout: - -- **Left column:** Large glucose value + trend arrow (`.system(size: 46)`), with `Delta: ±X` below in a smaller semibold font -- **Right column:** Configurable 2×2 grid — slot content driven by `LAAppGroupSettings.slots()`, read from the shared App Group container -- **Footer:** `Last Update: HH:MM` centered below both columns - -A new `SlotView` struct handles dispatch for all 22 slot cases. Fifteen new `LAFormat` static methods were added to format each metric consistently (locale-aware number formatting, unit suffix, graceful `—` for nil/unavailable values). - -### Configurable slot picker UI (`LoopFollow/LiveActivitySettingsView.swift`) - -A new **Grid slots** section appears in the Live Activity settings screen with four pickers labelled Top left, Top right, Bottom left, Bottom right. Selecting a metric for one slot automatically clears that metric from any other slot (uniqueness enforced). Changes take effect immediately — `LiveActivityManager.shared.refreshFromCurrentState(reason: "slot config changed")` is called on every picker change. - -### Slot type definitions (`LoopFollow/LiveActivity/LAAppGroupSettings.swift`) - -- New `LiveActivitySlotOption` enum (22 cases: `none`, `delta`, `projectedBG`, `minMax`, `iob`, `cob`, `recBolus`, `autosens`, `tdd`, `basal`, `pump`, `pumpBattery`, `battery`, `target`, `isf`, `carbRatio`, `sage`, `cage`, `iage`, `carbsToday`, `override`, `profile`) -- `displayName` (used in Settings picker) and `gridLabel` (used inside the MetricBlock on the LA card) computed properties -- `isOptional` flag — `true` for metrics that may be absent for Dexcom-only users; the widget renders `—` in those cases -- `LiveActivitySlotDefaults` struct with out-of-the-box defaults: IOB / COB / Projected BG / Empty -- `LAAppGroupSettings.setSlots()` / `slots()` — persist and read the 4-slot configuration via the shared App Group `UserDefaults` container, so the extension always sees the current user selection - -All of this is placed in `LAAppGroupSettings.swift` because that file is already compiled into both the app target and the extension target. No new Xcode project file membership was required. - -### Extended GlucoseSnapshot (`LoopFollow/LiveActivity/GlucoseSnapshot.swift`) - -Added 19 new stored properties. All are optional or have safe defaults so decoding an older snapshot (e.g. from a push that arrived before the app updated) never crashes: - -| Property | Type | Source | -|---|---|---| -| `override` | `String?` | `Observable.shared.override` | -| `recBolus` | `Double?` | `Observable.shared.recBolus` | -| `battery` | `Double?` | `Observable.shared.battery` | -| `pumpBattery` | `Double?` | `Observable.shared.pumpBattery` | -| `basalRate` | `String` | `Storage.shared.lastBasal` | -| `pumpReservoirU` | `Double?` | `Storage.shared.lastPumpReservoirU` | -| `autosens` | `Double?` | `Storage.shared.lastAutosens` | -| `tdd` | `Double?` | `Storage.shared.lastTdd` | -| `targetLowMgdl` | `Double?` | `Storage.shared.lastTargetLowMgdl` | -| `targetHighMgdl` | `Double?` | `Storage.shared.lastTargetHighMgdl` | -| `isfMgdlPerU` | `Double?` | `Storage.shared.lastIsfMgdlPerU` | -| `carbRatio` | `Double?` | `Storage.shared.lastCarbRatio` | -| `carbsToday` | `Double?` | `Storage.shared.lastCarbsToday` | -| `profileName` | `String?` | `Storage.shared.lastProfileName` | -| `sageInsertTime` | `TimeInterval` | `Storage.shared.sageInsertTime` | -| `cageInsertTime` | `TimeInterval` | `Storage.shared.cageInsertTime` | -| `iageInsertTime` | `TimeInterval` | `Storage.shared.iageInsertTime` | -| `minBgMgdl` | `Double?` | `Storage.shared.lastMinBgMgdl` | -| `maxBgMgdl` | `Double?` | `Storage.shared.lastMaxBgMgdl` | - -All glucose-valued fields are stored in **mg/dL**; conversion to mmol/L happens at display time in `LAFormat`, consistent with the existing snapshot design. - -Age-based fields (SAGE, CAGE, IAGE) are stored as Unix epoch `TimeInterval` (0 = not set). `LAFormat.age(insertTime:)` computes the human-readable age string at render time using `DateComponentsFormatter` with `.positional` style and `[.day, .hour]` units. - -### GlucoseSnapshotBuilder (`LoopFollow/LiveActivity/GlucoseSnapshotBuilder.swift`) - -Extended `build(from:)` to populate all 19 new fields from `Observable.shared` and `Storage.shared`. - -### Storage additions (`LoopFollow/Storage/Storage.swift`) - -13 new `StorageValue`-backed fields in a dedicated "Live Activity extended InfoType data" section: - -``` -lastBasal, lastPumpReservoirU, lastAutosens, lastTdd, -lastTargetLowMgdl, lastTargetHighMgdl, lastIsfMgdlPerU, -lastCarbRatio, lastCarbsToday, lastProfileName, -iageInsertTime, lastMinBgMgdl, lastMaxBgMgdl -``` - -### Controller writes - -Each data-fetching controller now writes one additional `Storage.shared` value alongside its existing `infoManager.updateInfoData` call. No existing logic was changed — these are purely additive writes: - -| Controller | Field written | -|---|---| -| `Basals.swift` | `lastBasal` | -| `DeviceStatus.swift` | `lastPumpReservoirU` | -| `DeviceStatusLoop.swift` | `lastIsfMgdlPerU`, `lastCarbRatio`, `lastTargetLowMgdl`, `lastTargetHighMgdl`, `lastMinBgMgdl`, `lastMaxBgMgdl` | -| `DeviceStatusOpenAPS.swift` | `lastAutosens`, `lastTdd`, `lastIsfMgdlPerU`, `lastCarbRatio`, `lastTargetLowMgdl`, `lastTargetHighMgdl`, `lastMinBgMgdl`, `lastMaxBgMgdl` | -| `Carbs.swift` | `lastCarbsToday` | -| `Profile.swift` | `lastProfileName` | -| `IAge.swift` | `iageInsertTime` | - ---- \ No newline at end of file From fec3f79927a355d238f93f43780bf9cc0245d185 Mon Sep 17 00:00:00 2001 From: Phil A <76601115+MtlPhil@users.noreply.github.com> Date: Sun, 15 Mar 2026 16:08:25 -0400 Subject: [PATCH 44/82] Configurable Live Activity Grid Slots + Full InfoType Snapshot Coverage (#547) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: Live Activity auto-renewal to work around 8-hour system limit Co-Authored-By: Claude Sonnet 4.6 * test: reduce LA renewal threshold to 20 min for testing Co-Authored-By: Claude Sonnet 4.6 * feat: improve LA renewal robustness and stale indicator - staleDate on every ActivityContent now tracks the renewal deadline, so the system shows Apple's built-in stale indicator if renewal fails - Add laRenewalFailed StorageValue; set on Activity.request() failure, cleared on any successful LA start - Observe willEnterForegroundNotification: retry startIfNeeded() if a previous renewal attempt failed - New-first renewal order: request the replacement LA before ending the old one — if the request throws the existing LA stays alive so the user keeps live data until the system kills it at the 8-hour mark Co-Authored-By: Claude Sonnet 4.6 * feat: renewal warning overlay + restore 7.5h threshold - Restore renewalThreshold to 7.5 * 3600 (testing complete) - Add showRenewalOverlay: Bool to GlucoseSnapshot (Codable, default false) - GlucoseSnapshotBuilder sets it true when now >= laRenewBy - 1800 (30 minutes before the renewal deadline) - Lock screen: 60% gray overlay with "Tap to update" centered in white, layered above the existing isNotLooping overlay - DI expanded: RenewalOverlayView applied to leading/trailing/bottom regions; "Tap to update" text shown on the bottom region only - showRenewalOverlay resets to false automatically on renewal since laRenewBy is updated and the next snapshot rebuild clears the flag Co-Authored-By: Claude Sonnet 4.6 * fix: overlay not appearing + foreground restart not working Overlay rendering: - Replace Group{if condition{ZStack{RoundedRectangle...}}} with a permanently-present ZStack toggled via .opacity(). The Group/if pattern causes SwiftUI sizing ambiguity when the condition transitions from false→true inside an .overlay(), producing a zero-size result. The .opacity() approach keeps a stable view hierarchy. - Same fix applied to RenewalOverlayView used on DI expanded regions. Foreground restart: - handleForeground() was calling startIfNeeded(), which finds the still-alive (failed-to-renew) LA in Activity.activities and reuses it, doing nothing useful. Fixed to manually nil out current, cancel all tasks, await activity.end(.immediate), then startFromCurrentState(). Overlay timing: - Changed warning window from 30 min (1800s) to 20 min (1200s) before the renewal deadline, matching the intended test cadence. Logging: - handleForeground: log on every foreground event with laRenewalFailed value - renewIfNeeded: log how many seconds past the deadline when firing - GlucoseSnapshotBuilder: log when overlay activates with seconds to deadline - performRefresh: log when sending an update with the overlay visible Co-Authored-By: Claude Sonnet 4.6 * test: set renewalThreshold to 20 min for testing Co-Authored-By: Claude Sonnet 4.6 * fix: renewal overlay not clearing after LA is refreshed Normal renewal path (renewIfNeeded success): - Build a fresh snapshot with showRenewalOverlay: false for the new LA's initial content — it has a new deadline so the overlay should never be visible from the first frame. - Save that fresh snapshot to GlucoseSnapshotStore so the next duplicate check has the correct baseline and doesn't suppress the first real BG update. Foreground restart path (handleForeground): - Zero laRenewBy before calling startFromCurrentState() so GlucoseSnapshotBuilder computes showRenewalOverlay = false for the seed snapshot. startIfNeeded() then sets the new deadline after the request succeeds. Co-Authored-By: Claude Sonnet 4.6 * fix: overlay permanently active when warning window equals threshold With renewalThreshold=20min and the hardcoded 1200s warning window, renewBy-1200 = start, so showRenewalOverlay is always true from the moment the LA begins. Extract a named renewalWarning constant (5 min for testing) so the warning window is always less than the threshold. The builder now reads LiveActivityManager.renewalWarning instead of a hardcoded literal. Production values to restore before merging: renewalThreshold = 7.5 * 3600 renewalWarning = 20 * 60 Co-Authored-By: Claude Sonnet 4.6 * fix: include showRenewalOverlay in APNs payload and clear laRenewBy synchronously APNSClient was missing showRenewalOverlay from the push payload, so background APNs updates never delivered the overlay flag to the extension — only foreground direct ActivityKit updates did. In handleForeground, laRenewBy is now zeroed synchronously before spawning the async end/restart Task. This means any snapshot built between the foreground notification and the new LA start (e.g. from viewDidAppear's startFromCurrentState) computes showRenewalOverlay = false rather than reading the stale expired deadline. Co-Authored-By: Claude Sonnet 4.6 * fix: await LA end before restarting on foreground retry to avoid reuse path Reset laRenewBy and laRenewalFailed synchronously before tearing down the failed LA, then await activity.end() before calling startFromCurrentState(). This guarantees Activity.activities is clear when startIfNeeded() runs, so it takes the fresh-request path and writes a new laRenewBy. startFromCurrentState rebuilds the snapshot with showRenewalOverlay=false (laRenewBy=0), saves it to the store, then startIfNeeded uses that clean snapshot as the seed. Co-Authored-By: Claude Sonnet 4.6 * chore: restore production renewal timing (7.5h threshold, 20min warning) Co-Authored-By: Claude Sonnet 4.6 * feat: laEnabled toggle, forceRestart(), and RestartLiveActivityIntent - SettingsMenuView: rename "APN" menu entry to "Live Activity" - Storage: add laEnabled: Bool StorageValue (default false) - APNSettingsView: on/off toggle bound to laEnabled; APNs key fields and Restart button shown only when enabled; disabling immediately ends any running LA - LiveActivityManager: - forceRestart() (@MainActor) ends all running activities, resets laRenewBy and laRenewalFailed, then calls startFromCurrentState() - laEnabled guard added to startFromCurrentState(), refreshFromCurrentState(), handleForeground(), and handleDidBecomeActive() - didBecomeActiveNotification observer calls forceRestart() on every foreground transition when laEnabled is true - RestartLiveActivityIntent: AppIntent + ForegroundContinuableIntent; sets laEnabled, validates credentials, opens settings deep link if missing, otherwise calls forceRestart() in foreground; LoopFollowAppShortcuts exposes the intent with Siri phrase Note: RestartLiveActivityIntent.swift must be added to the app target in Xcode. Co-Authored-By: Claude Sonnet 4.6 * Added RestartLiveActivityIntent to project * fix: resolve two build errors in LiveActivityManager and RestartLiveActivityIntent - LiveActivityManager: handleDidBecomeActive() calls @MainActor forceRestart() via Task { @MainActor in ... } to satisfy Swift concurrency isolation - RestartLiveActivityIntent: drop ForegroundContinuableIntent — continueInForeground() was renamed to continueInForeground(_:alwaysConfirm:) in the iOS 26 SDK and requires iOS 26+; the didBecomeActiveNotification observer handles restart when the app comes to foreground, making explicit continuation unnecessary Co-Authored-By: Claude Sonnet 4.6 * fix: guard continueInForeground() behind iOS 26 availability check continueInForeground(_:alwaysConfirm:) is iOS 26+ only. On earlier versions, forceRestart() runs directly via the existing background audio session — no foreground continuation needed. Co-Authored-By: Claude Sonnet 4.6 * fix: use startFromCurrentState in handleDidBecomeActive instead of forceRestart forceRestart() was killing the LA that the RestartLiveActivityIntent had just created, because continueInForeground() triggers didBecomeActive. startFromCurrentState() reuses an existing active LA via startIfNeeded() and only creates one if none is present — no destructive race. Co-Authored-By: Claude Sonnet 4.6 * feat: LA foreground tab navigation, button feedback, and toggle sync APNSettingsView: - "Restart Live Activity" button shows "Live Activity Restarted" for 2 seconds and disables itself during that window to prevent double-taps - Toggle observes Storage.shared.laEnabled via onReceive so it reflects changes made externally (e.g. by the App Intent / Shortcuts) LiveActivityManager: - handleDidBecomeActive posts .liveActivityDidForeground after startFromCurrentState - Notification.Name.liveActivityDidForeground extension defined here MainViewController: - Observes .liveActivityDidForeground; navigates to Snoozer tab if an alarm is currently active, otherwise navigates to tab 0 (Home) Co-Authored-By: Claude Sonnet 4.6 * fix: flush LA update on willResignActive to ensure lock screen shows latest data The 5-second debounce on refreshFromCurrentState means a BG update arriving while the app is foreground won't reach the LA before the user returns to the lock screen. When the debounce fires in background, isForeground=false so the direct ActivityKit update is skipped. Fix: observe willResignActiveNotification, cancel the pending debounce, and immediately push the latest snapshot via direct ActivityKit update while the app is still foreground-active. The APNs push is also sent as a backup. Co-Authored-By: Claude Sonnet 4.6 * feat: redesign Dynamic Island compact and expanded views Compact: - Trailing now shows delta instead of trend arrow (leading already showed BG) Expanded leading: - Large BG value at top - Second row: trend arrow, delta, and "Proj: X" in smaller text below Expanded trailing: - IOB and COB (moved from bottom) Expanded bottom: - "Updated at: HH:mm" (moved from trailing) Co-Authored-By: Claude Sonnet 4.6 * fix: match Proj text style to delta; add trailing padding to IOB/COB - Proj label now uses same size (13), weight (semibold), and opacity (0.9) as the delta text beside it - Added 6pt trailing padding to the expanded DI trailing VStack so the IOB decimal is not clipped by the Dynamic Island edge Co-Authored-By: Claude Sonnet 4.6 * feat: separate Live Activity and APN settings into distinct menus Per maintainer preference, the APNs credentials (Key ID and Key) remain in their own "APN" settings screen. A new separate "Live Activity" settings screen contains the enable/disable toggle and the Restart Live Activity button. - APNSettingsView: restored to Key ID + Key only (no toggle or restart button) - LiveActivitySettingsView: new view with laEnabled toggle and restart button (with 2-second confirmation feedback); toggle stays in sync via onReceive - SettingsMenuView: "APN" entry restored; new "Live Activity" entry added with dot.radiowaves icon; Sheet.liveActivity case wired to LiveActivitySettingsView Co-Authored-By: Claude Sonnet 4.6 * Added Live Activity menu * chore: add LiveActivitySettingsView to Xcode project Co-Authored-By: Claude Sonnet 4.6 * fix: LA tap navigation, manual dismissal prevention, and toggle start - widgetURL on lock screen LA → opens app via loopfollow://la-tap - Link views in expanded DI regions → same URL on tap - AppDelegate.application(_:open:) handles loopfollow://la-tap and posts .liveActivityDidForeground so MainViewController navigates to Snoozer (if alarm active) or Home tab — fires only on LA/DI taps, not every foreground - Notification.Name.liveActivityDidForeground moved outside #if macCatalyst so AppDelegate can reference it unconditionally - handleDidBecomeActive no longer posts .liveActivityDidForeground (was firing on every app foreground, not just LA taps) - attachStateObserver: .dismissed state (user swipe) sets laEnabled = false so the LA does not auto-restart when the app foregrounds - LiveActivitySettingsView: toggling ON calls startFromCurrentState() Co-Authored-By: Claude Sonnet 4.6 * fix: end Live Activity on app force-quit Adds endOnTerminate() to LiveActivityManager — blocks up to 3 s using Task.detached + DispatchSemaphore so the async activity.end() completes before the process exits. Called from applicationWillTerminate so the LA clears from the lock screen immediately on force-quit. laEnabled is preserved so the LA restarts correctly on next launch. Co-Authored-By: Claude Sonnet 4.6 * fix: use dismissedByUser flag instead of disabling laEnabled on manual dismiss - Manual LA swipe-away sets dismissedByUser (in-memory) instead of laEnabled = false, preserving the user's preference - startFromCurrentState() guards on !dismissedByUser so the LA does not auto-restart on foreground, BG refresh, or handleDidBecomeActive - forceRestart() clears dismissedByUser before starting, so the Restart button and App Intent both work as the explicit re-enable mechanism - Toggle ON in settings calls forceRestart() (clears flag + starts) instead of startFromCurrentState() which would have been blocked by the flag - dismissedByUser resets to false on app relaunch (in-memory only), so a kill + relaunch starts the LA fresh as expected Co-Authored-By: Claude Sonnet 4.6 * fix: dismiss modal (Settings sheet) before tab switch on LA tap navigateOnLAForeground now checks tabBarController.presentedViewController and dismisses it before switching to the target tab, matching the existing pattern used elsewhere in MainViewController for the same scenario. Co-Authored-By: Claude Sonnet 4.6 * fix: LA tap navigation timing and LA reappear-after-dismiss Navigation fix: - loopfollow://la-tap URL can arrive before applicationDidBecomeActive while UIKit is still restoring the view hierarchy from background. At that moment tabBarController.presentedViewController is nil so the Settings modal is never dismissed and the navigation notification fires too early. - Now stores a pendingLATapNavigation flag if app is not yet .active, and fires the notification in applicationDidBecomeActive when the full view hierarchy (including presented modals) is restored. LA reappear fix: - performRefresh() calls startIfNeeded() directly, bypassing the dismissedByUser guard that only exists in startFromCurrentState(). On every BG refresh with laEnabled=true and current==nil (post-dismiss), it would recreate the LA. - Added !dismissedByUser guard to refreshFromCurrentState() so the entire refresh pipeline is skipped while dismissed, matching the existing guard in startFromCurrentState(). Co-Authored-By: Claude Sonnet 4.6 * fix: handle loopfollow://la-tap URL in SceneDelegate, not AppDelegate With scene-based lifecycle (iOS 13+), widget/LA tap URLs are delivered to scene(_:openURLContexts:) on UIWindowSceneDelegate — AppDelegate.application (_:open:url:options:) is never called. Our previous handler was dead code. SceneDelegate now implements scene(_:openURLContexts:) which posts .liveActivityDidForeground on the next run loop (via DispatchQueue.main.async) to let the view hierarchy fully settle before navigateOnLAForeground() runs. Also handles the edge case where the URL arrives before sceneDidBecomeActive by storing a pendingLATapNavigation flag. Co-Authored-By: Claude Sonnet 4.6 * feat: configurable LA grid slots + full InfoType snapshot coverage Lock screen layout: - Left column: glucose + trend arrow, delta below (replaces "Last Update:") - Right grid: 4 configurable slots read from LAAppGroupSettings (defaults: IOB/COB/Proj/empty) - Footer: centered HH:MM update time at reduced opacity Slot configuration: - LiveActivitySlotOption enum with all 22 InfoType-aligned cases - LAAppGroupSettings.setSlots() / slots() persisted to App Group UserDefaults - Uniqueness enforced: selecting an option clears it from any other slot - Settings UI: "Grid slots" section with 4 pickers in LiveActivitySettingsView - Changes take effect immediately (refreshFromCurrentState called on save) GlucoseSnapshot extended with 19 new fields covering all InfoType items: override, recBolus, battery, pumpBattery, basalRate, pumpReservoirU, autosens, tdd, targetLowMgdl/High, isfMgdlPerU, carbRatio, carbsToday, profileName, sageInsertTime, cageInsertTime, iageInsertTime, minBgMgdl/Max Storage.swift: 13 new UserDefaults-backed fields for the above metrics Controllers updated to write new Storage fields on each data fetch: Basals, DeviceStatus, DeviceStatusLoop, DeviceStatusOpenAPS, Carbs, Profile, IAge Co-Authored-By: Claude Sonnet 4.6 * fix: label delta and footer on lock screen LA card Co-Authored-By: Claude Sonnet 4.6 * docs: add PR description for configurable LA grid slots Co-Authored-By: Claude Sonnet 4.6 * Update PR_configurable_slots.md * chore: remove PR notes from tracking, keep docs/LiveActivity.md only - Untrack docs/PR_configurable_slots.md (local-only reference doc) - Add docs/PR_configurable_slots.md and docs/LiveActivityTestPlan.md to .gitignore Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Claude Sonnet 4.6 --- .gitignore | 2 + .../Controllers/Nightscout/DeviceStatus.swift | 2 + .../Nightscout/DeviceStatusLoop.swift | 6 + .../Nightscout/DeviceStatusOpenAPS.swift | 15 ++ LoopFollow/Controllers/Nightscout/IAge.swift | 1 + .../Controllers/Nightscout/Profile.swift | 1 + .../Nightscout/Treatments/Basals.swift | 1 + .../Nightscout/Treatments/Carbs.swift | 1 + LoopFollow/LiveActivity/GlucoseSnapshot.swift | 142 +++++++++++- .../LiveActivity/GlucoseSnapshotBuilder.swift | 20 ++ .../LiveActivity/LAAppGroupSettings.swift | 149 +++++++++++- .../LiveActivity/LiveActivitySlotConfig.swift | 44 ++++ LoopFollow/LiveActivitySettingsView.swift | 31 +++ LoopFollow/Storage/Storage.swift | 16 ++ .../LoopFollowLiveActivity.swift | 216 +++++++++++++++--- 15 files changed, 608 insertions(+), 39 deletions(-) create mode 100644 LoopFollow/LiveActivity/LiveActivitySlotConfig.swift diff --git a/.gitignore b/.gitignore index 178842387..d372f7c5a 100644 --- a/.gitignore +++ b/.gitignore @@ -81,3 +81,5 @@ fastlane/FastlaneRunner LoopFollowConfigOverride.xcconfig .history*.xcuserstate +docs/PR_configurable_slots.md +docs/LiveActivityTestPlan.md diff --git a/LoopFollow/Controllers/Nightscout/DeviceStatus.swift b/LoopFollow/Controllers/Nightscout/DeviceStatus.swift index b7f88634e..ae3967b3e 100644 --- a/LoopFollow/Controllers/Nightscout/DeviceStatus.swift +++ b/LoopFollow/Controllers/Nightscout/DeviceStatus.swift @@ -132,9 +132,11 @@ extension MainViewController { if let reservoirData = lastPumpRecord["reservoir"] as? Double { latestPumpVolume = reservoirData infoManager.updateInfoData(type: .pump, value: String(format: "%.0f", reservoirData) + "U") + Storage.shared.lastPumpReservoirU.value = reservoirData } else { latestPumpVolume = 50.0 infoManager.updateInfoData(type: .pump, value: "50+U") + Storage.shared.lastPumpReservoirU.value = nil } } diff --git a/LoopFollow/Controllers/Nightscout/DeviceStatusLoop.swift b/LoopFollow/Controllers/Nightscout/DeviceStatusLoop.swift index 650092237..89c4163cd 100644 --- a/LoopFollow/Controllers/Nightscout/DeviceStatusLoop.swift +++ b/LoopFollow/Controllers/Nightscout/DeviceStatusLoop.swift @@ -30,12 +30,14 @@ extension MainViewController { let profileISF = profileManager.currentISF() if let profileISF = profileISF { infoManager.updateInfoData(type: .isf, value: profileISF) + Storage.shared.lastIsfMgdlPerU.value = profileISF.doubleValue(for: .milligramsPerDeciliter) } // Carb Ratio (CR) let profileCR = profileManager.currentCarbRatio() if let profileCR = profileCR { infoManager.updateInfoData(type: .carbRatio, value: profileCR) + Storage.shared.lastCarbRatio.value = profileCR } // Target @@ -47,6 +49,8 @@ extension MainViewController { } else if let profileTargetLow = profileTargetLow { infoManager.updateInfoData(type: .target, value: profileTargetLow) } + Storage.shared.lastTargetLowMgdl.value = profileTargetLow?.doubleValue(for: .milligramsPerDeciliter) + Storage.shared.lastTargetHighMgdl.value = profileTargetHigh?.doubleValue(for: .milligramsPerDeciliter) // IOB if let insulinMetric = InsulinMetric(from: lastLoopRecord["iob"], key: "iob") { @@ -87,6 +91,8 @@ extension MainViewController { let formattedMax = Localizer.toDisplayUnits(String(predMax)) let value = "\(formattedMin)/\(formattedMax)" infoManager.updateInfoData(type: .minMax, value: value) + Storage.shared.lastMinBgMgdl.value = predMin + Storage.shared.lastMaxBgMgdl.value = predMax } updatePredictionGraph() diff --git a/LoopFollow/Controllers/Nightscout/DeviceStatusOpenAPS.swift b/LoopFollow/Controllers/Nightscout/DeviceStatusOpenAPS.swift index fc3b3c5b5..20827c253 100644 --- a/LoopFollow/Controllers/Nightscout/DeviceStatusOpenAPS.swift +++ b/LoopFollow/Controllers/Nightscout/DeviceStatusOpenAPS.swift @@ -37,8 +37,10 @@ extension MainViewController { } if let profileISF = profileISF, let enactedISF = enactedISF, profileISF != enactedISF { infoManager.updateInfoData(type: .isf, firstValue: profileISF, secondValue: enactedISF, separator: .arrow) + Storage.shared.lastIsfMgdlPerU.value = enactedISF.doubleValue(for: .milligramsPerDeciliter) } else if let profileISF = profileISF { infoManager.updateInfoData(type: .isf, value: profileISF) + Storage.shared.lastIsfMgdlPerU.value = profileISF.doubleValue(for: .milligramsPerDeciliter) } // Carb Ratio (CR) @@ -57,8 +59,10 @@ extension MainViewController { if let profileCR = profileCR, let enactedCR = enactedCR, profileCR != enactedCR { infoManager.updateInfoData(type: .carbRatio, value: profileCR, enactedValue: enactedCR, separator: .arrow) + Storage.shared.lastCarbRatio.value = enactedCR } else if let profileCR = profileCR { infoManager.updateInfoData(type: .carbRatio, value: profileCR) + Storage.shared.lastCarbRatio.value = profileCR } // IOB @@ -98,6 +102,7 @@ extension MainViewController { if let sens = enactedOrSuggested["sensitivityRatio"] as? Double { let formattedSens = String(format: "%.0f", sens * 100.0) + "%" infoManager.updateInfoData(type: .autosens, value: formattedSens) + Storage.shared.lastAutosens.value = sens } // Recommended Bolus @@ -136,11 +141,19 @@ extension MainViewController { } else { infoManager.updateInfoData(type: .target, value: profileTargetHigh) } + let effectiveMgdl = enactedTarget.doubleValue(for: .milligramsPerDeciliter) + Storage.shared.lastTargetLowMgdl.value = effectiveMgdl + Storage.shared.lastTargetHighMgdl.value = effectiveMgdl + } else if let profileTargetHigh = profileTargetHigh { + let profileMgdl = profileTargetHigh.doubleValue(for: .milligramsPerDeciliter) + Storage.shared.lastTargetLowMgdl.value = profileMgdl + Storage.shared.lastTargetHighMgdl.value = profileMgdl } // TDD if let tddMetric = InsulinMetric(from: enactedOrSuggested, key: "TDD") { infoManager.updateInfoData(type: .tdd, value: tddMetric) + Storage.shared.lastTdd.value = tddMetric.value } let predBGsData: [String: AnyObject]? = { @@ -201,6 +214,8 @@ extension MainViewController { if minPredBG != Double.infinity, maxPredBG != -Double.infinity { let value = "\(Localizer.toDisplayUnits(String(minPredBG)))/\(Localizer.toDisplayUnits(String(maxPredBG)))" infoManager.updateInfoData(type: .minMax, value: value) + Storage.shared.lastMinBgMgdl.value = minPredBG + Storage.shared.lastMaxBgMgdl.value = maxPredBG } else { infoManager.updateInfoData(type: .minMax, value: "N/A") } diff --git a/LoopFollow/Controllers/Nightscout/IAge.swift b/LoopFollow/Controllers/Nightscout/IAge.swift index 69a683c57..50e9bd592 100644 --- a/LoopFollow/Controllers/Nightscout/IAge.swift +++ b/LoopFollow/Controllers/Nightscout/IAge.swift @@ -45,6 +45,7 @@ extension MainViewController { .withColonSeparatorInTime] if let iageTime = formatter.date(from: (lastIageString as! String))?.timeIntervalSince1970 { + Storage.shared.iageInsertTime.value = iageTime let now = dateTimeUtils.getNowTimeIntervalUTC() let secondsAgo = now - iageTime diff --git a/LoopFollow/Controllers/Nightscout/Profile.swift b/LoopFollow/Controllers/Nightscout/Profile.swift index c00ac195e..f76c74a4c 100644 --- a/LoopFollow/Controllers/Nightscout/Profile.swift +++ b/LoopFollow/Controllers/Nightscout/Profile.swift @@ -23,6 +23,7 @@ extension MainViewController { } profileManager.loadProfile(from: profileData) infoManager.updateInfoData(type: .profile, value: profileData.defaultProfile) + Storage.shared.lastProfileName.value = profileData.defaultProfile // Mark profile data as loaded for initial loading state markDataLoaded("profile") diff --git a/LoopFollow/Controllers/Nightscout/Treatments/Basals.swift b/LoopFollow/Controllers/Nightscout/Treatments/Basals.swift index 5ee0891fe..405281926 100644 --- a/LoopFollow/Controllers/Nightscout/Treatments/Basals.swift +++ b/LoopFollow/Controllers/Nightscout/Treatments/Basals.swift @@ -147,5 +147,6 @@ extension MainViewController { latestBasal = "\(profileBasal) → \(latestBasal)" } infoManager.updateInfoData(type: .basal, value: latestBasal) + Storage.shared.lastBasal.value = latestBasal } } diff --git a/LoopFollow/Controllers/Nightscout/Treatments/Carbs.swift b/LoopFollow/Controllers/Nightscout/Treatments/Carbs.swift index baa4af7a1..5d75adb2d 100644 --- a/LoopFollow/Controllers/Nightscout/Treatments/Carbs.swift +++ b/LoopFollow/Controllers/Nightscout/Treatments/Carbs.swift @@ -82,5 +82,6 @@ extension MainViewController { let resultString = String(format: "%.0f", totalCarbs) infoManager.updateInfoData(type: .carbsToday, value: resultString) + Storage.shared.lastCarbsToday.value = totalCarbs } } diff --git a/LoopFollow/LiveActivity/GlucoseSnapshot.swift b/LoopFollow/LiveActivity/GlucoseSnapshot.swift index 1e573cba6..4e914ab7e 100644 --- a/LoopFollow/LiveActivity/GlucoseSnapshot.swift +++ b/LoopFollow/LiveActivity/GlucoseSnapshot.swift @@ -39,6 +39,65 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable { /// Projected glucose in mg/dL (if available) let projected: Double? + // MARK: - Extended InfoType Metrics + + /// Active override name (nil if no active override) + let override: String? + + /// Recommended bolus in units (nil if not available) + let recBolus: Double? + + /// CGM/uploader device battery % (nil if not available) + let battery: Double? + + /// Pump battery % (nil if not available) + let pumpBattery: Double? + + /// Formatted current basal rate string (empty if not available) + let basalRate: String + + /// Pump reservoir in units (nil if >50U or unknown) + let pumpReservoirU: Double? + + /// Autosensitivity ratio, e.g. 0.9 = 90% (nil if not available) + let autosens: Double? + + /// Total daily dose in units (nil if not available) + let tdd: Double? + + /// BG target low in mg/dL (nil if not available) + let targetLowMgdl: Double? + + /// BG target high in mg/dL (nil if not available) + let targetHighMgdl: Double? + + /// Insulin Sensitivity Factor in mg/dL per unit (nil if not available) + let isfMgdlPerU: Double? + + /// Carb ratio in g per unit (nil if not available) + let carbRatio: Double? + + /// Total carbs entered today in grams (nil if not available) + let carbsToday: Double? + + /// Active profile name (nil if not available) + let profileName: String? + + /// Sensor insert time as Unix epoch seconds UTC (0 = not set) + let sageInsertTime: TimeInterval + + /// Cannula insert time as Unix epoch seconds UTC (0 = not set) + let cageInsertTime: TimeInterval + + /// Insulin/pod insert time as Unix epoch seconds UTC (0 = not set) + let iageInsertTime: TimeInterval + + /// Min predicted BG in mg/dL (nil if not available) + let minBgMgdl: Double? + + /// Max predicted BG in mg/dL (nil if not available) + let maxBgMgdl: Double? + // MARK: - Unit Context /// User's preferred display unit. Values are always stored in mg/dL; @@ -64,6 +123,25 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable { iob: Double?, cob: Double?, projected: Double?, + override: String? = nil, + recBolus: Double? = nil, + battery: Double? = nil, + pumpBattery: Double? = nil, + basalRate: String = "", + pumpReservoirU: Double? = nil, + autosens: Double? = nil, + tdd: Double? = nil, + targetLowMgdl: Double? = nil, + targetHighMgdl: Double? = nil, + isfMgdlPerU: Double? = nil, + carbRatio: Double? = nil, + carbsToday: Double? = nil, + profileName: String? = nil, + sageInsertTime: TimeInterval = 0, + cageInsertTime: TimeInterval = 0, + iageInsertTime: TimeInterval = 0, + minBgMgdl: Double? = nil, + maxBgMgdl: Double? = nil, unit: Unit, isNotLooping: Bool, showRenewalOverlay: Bool = false @@ -75,6 +153,25 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable { self.iob = iob self.cob = cob self.projected = projected + self.override = override + self.recBolus = recBolus + self.battery = battery + self.pumpBattery = pumpBattery + self.basalRate = basalRate + self.pumpReservoirU = pumpReservoirU + self.autosens = autosens + self.tdd = tdd + self.targetLowMgdl = targetLowMgdl + self.targetHighMgdl = targetHighMgdl + self.isfMgdlPerU = isfMgdlPerU + self.carbRatio = carbRatio + self.carbsToday = carbsToday + self.profileName = profileName + self.sageInsertTime = sageInsertTime + self.cageInsertTime = cageInsertTime + self.iageInsertTime = iageInsertTime + self.minBgMgdl = minBgMgdl + self.maxBgMgdl = maxBgMgdl self.unit = unit self.isNotLooping = isNotLooping self.showRenewalOverlay = showRenewalOverlay @@ -89,13 +186,37 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable { try container.encodeIfPresent(iob, forKey: .iob) try container.encodeIfPresent(cob, forKey: .cob) try container.encodeIfPresent(projected, forKey: .projected) + try container.encodeIfPresent(override, forKey: .override) + try container.encodeIfPresent(recBolus, forKey: .recBolus) + try container.encodeIfPresent(battery, forKey: .battery) + try container.encodeIfPresent(pumpBattery, forKey: .pumpBattery) + try container.encode(basalRate, forKey: .basalRate) + try container.encodeIfPresent(pumpReservoirU, forKey: .pumpReservoirU) + try container.encodeIfPresent(autosens, forKey: .autosens) + try container.encodeIfPresent(tdd, forKey: .tdd) + try container.encodeIfPresent(targetLowMgdl, forKey: .targetLowMgdl) + try container.encodeIfPresent(targetHighMgdl, forKey: .targetHighMgdl) + try container.encodeIfPresent(isfMgdlPerU, forKey: .isfMgdlPerU) + try container.encodeIfPresent(carbRatio, forKey: .carbRatio) + try container.encodeIfPresent(carbsToday, forKey: .carbsToday) + try container.encodeIfPresent(profileName, forKey: .profileName) + try container.encode(sageInsertTime, forKey: .sageInsertTime) + try container.encode(cageInsertTime, forKey: .cageInsertTime) + try container.encode(iageInsertTime, forKey: .iageInsertTime) + try container.encodeIfPresent(minBgMgdl, forKey: .minBgMgdl) + try container.encodeIfPresent(maxBgMgdl, forKey: .maxBgMgdl) try container.encode(unit, forKey: .unit) try container.encode(isNotLooping, forKey: .isNotLooping) try container.encode(showRenewalOverlay, forKey: .showRenewalOverlay) } private enum CodingKeys: String, CodingKey { - case glucose, delta, trend, updatedAt, iob, cob, projected, unit, isNotLooping, showRenewalOverlay + case glucose, delta, trend, updatedAt + case iob, cob, projected + case override, recBolus, battery, pumpBattery, basalRate, pumpReservoirU + case autosens, tdd, targetLowMgdl, targetHighMgdl, isfMgdlPerU, carbRatio, carbsToday + case profileName, sageInsertTime, cageInsertTime, iageInsertTime, minBgMgdl, maxBgMgdl + case unit, isNotLooping, showRenewalOverlay } // MARK: - Codable @@ -109,6 +230,25 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable { iob = try container.decodeIfPresent(Double.self, forKey: .iob) cob = try container.decodeIfPresent(Double.self, forKey: .cob) projected = try container.decodeIfPresent(Double.self, forKey: .projected) + override = try container.decodeIfPresent(String.self, forKey: .override) + recBolus = try container.decodeIfPresent(Double.self, forKey: .recBolus) + battery = try container.decodeIfPresent(Double.self, forKey: .battery) + pumpBattery = try container.decodeIfPresent(Double.self, forKey: .pumpBattery) + basalRate = try container.decodeIfPresent(String.self, forKey: .basalRate) ?? "" + pumpReservoirU = try container.decodeIfPresent(Double.self, forKey: .pumpReservoirU) + autosens = try container.decodeIfPresent(Double.self, forKey: .autosens) + tdd = try container.decodeIfPresent(Double.self, forKey: .tdd) + targetLowMgdl = try container.decodeIfPresent(Double.self, forKey: .targetLowMgdl) + targetHighMgdl = try container.decodeIfPresent(Double.self, forKey: .targetHighMgdl) + isfMgdlPerU = try container.decodeIfPresent(Double.self, forKey: .isfMgdlPerU) + carbRatio = try container.decodeIfPresent(Double.self, forKey: .carbRatio) + carbsToday = try container.decodeIfPresent(Double.self, forKey: .carbsToday) + profileName = try container.decodeIfPresent(String.self, forKey: .profileName) + sageInsertTime = try container.decodeIfPresent(Double.self, forKey: .sageInsertTime) ?? 0 + cageInsertTime = try container.decodeIfPresent(Double.self, forKey: .cageInsertTime) ?? 0 + iageInsertTime = try container.decodeIfPresent(Double.self, forKey: .iageInsertTime) ?? 0 + minBgMgdl = try container.decodeIfPresent(Double.self, forKey: .minBgMgdl) + maxBgMgdl = try container.decodeIfPresent(Double.self, forKey: .maxBgMgdl) unit = try container.decode(Unit.self, forKey: .unit) isNotLooping = try container.decodeIfPresent(Bool.self, forKey: .isNotLooping) ?? false showRenewalOverlay = try container.decodeIfPresent(Bool.self, forKey: .showRenewalOverlay) ?? false diff --git a/LoopFollow/LiveActivity/GlucoseSnapshotBuilder.swift b/LoopFollow/LiveActivity/GlucoseSnapshotBuilder.swift index f6a1d7208..dd845b116 100644 --- a/LoopFollow/LiveActivity/GlucoseSnapshotBuilder.swift +++ b/LoopFollow/LiveActivity/GlucoseSnapshotBuilder.swift @@ -70,6 +70,7 @@ enum GlucoseSnapshotBuilder { isDebug: true ) + let profileNameRaw = Storage.shared.lastProfileName.value return GlucoseSnapshot( glucose: glucoseMgdl, delta: deltaMgdl, @@ -78,6 +79,25 @@ enum GlucoseSnapshotBuilder { iob: provider.iob, cob: provider.cob, projected: provider.projectedMgdl, + override: Observable.shared.override.value, + recBolus: Observable.shared.deviceRecBolus.value, + battery: Observable.shared.deviceBatteryLevel.value, + pumpBattery: Observable.shared.pumpBatteryLevel.value, + basalRate: Storage.shared.lastBasal.value, + pumpReservoirU: Storage.shared.lastPumpReservoirU.value, + autosens: Storage.shared.lastAutosens.value, + tdd: Storage.shared.lastTdd.value, + targetLowMgdl: Storage.shared.lastTargetLowMgdl.value, + targetHighMgdl: Storage.shared.lastTargetHighMgdl.value, + isfMgdlPerU: Storage.shared.lastIsfMgdlPerU.value, + carbRatio: Storage.shared.lastCarbRatio.value, + carbsToday: Storage.shared.lastCarbsToday.value, + profileName: profileNameRaw.isEmpty ? nil : profileNameRaw, + sageInsertTime: Storage.shared.sageInsertTime.value, + cageInsertTime: Storage.shared.cageInsertTime.value, + iageInsertTime: Storage.shared.iageInsertTime.value, + minBgMgdl: Storage.shared.lastMinBgMgdl.value, + maxBgMgdl: Storage.shared.lastMaxBgMgdl.value, unit: preferredUnit, isNotLooping: isNotLooping, showRenewalOverlay: showRenewalOverlay diff --git a/LoopFollow/LiveActivity/LAAppGroupSettings.swift b/LoopFollow/LiveActivity/LAAppGroupSettings.swift index 7615b2cf7..2880c0efe 100644 --- a/LoopFollow/LiveActivity/LAAppGroupSettings.swift +++ b/LoopFollow/LiveActivity/LAAppGroupSettings.swift @@ -3,6 +3,129 @@ import Foundation +// MARK: - Slot option enum + +/// One displayable metric that can occupy a slot in the Live Activity 2×2 grid. +/// +/// - `.none` is the empty/blank state — leaves the slot visually empty. +/// - Optional cases (isOptional == true) may display "—" for Dexcom-only users +/// whose setup does not provide that metric. +/// - All values are read from GlucoseSnapshot at render time inside the widget +/// extension; no additional App Group reads are required per slot. +enum LiveActivitySlotOption: String, CaseIterable, Codable { + // Core glucose + case none + case delta + case projectedBG + case minMax + // Loop metrics + case iob + case cob + case recBolus + case autosens + case tdd + // Pump / device + case basal + case pump + case pumpBattery + case battery + case target + case isf + case carbRatio + // Ages + case sage + case cage + case iage + // Other + case carbsToday + case override + case profile + + /// Human-readable label shown in the slot picker in Settings. + var displayName: String { + switch self { + case .none: return "Empty" + case .delta: return "Delta" + case .projectedBG: return "Projected BG" + case .minMax: return "Min/Max" + case .iob: return "IOB" + case .cob: return "COB" + case .recBolus: return "Rec. Bolus" + case .autosens: return "Autosens" + case .tdd: return "TDD" + case .basal: return "Basal" + case .pump: return "Pump" + case .pumpBattery: return "Pump Battery" + case .battery: return "Battery" + case .target: return "Target" + case .isf: return "ISF" + case .carbRatio: return "CR" + case .sage: return "SAGE" + case .cage: return "CAGE" + case .iage: return "IAGE" + case .carbsToday: return "Carbs today" + case .override: return "Override" + case .profile: return "Profile" + } + } + + /// Short label used inside the MetricBlock on the Live Activity card. + var gridLabel: String { + switch self { + case .none: return "" + case .delta: return "Delta" + case .projectedBG: return "Proj" + case .minMax: return "Min/Max" + case .iob: return "IOB" + case .cob: return "COB" + case .recBolus: return "Rec." + case .autosens: return "Sens" + case .tdd: return "TDD" + case .basal: return "Basal" + case .pump: return "Pump" + case .pumpBattery: return "Pump%" + case .battery: return "Bat." + case .target: return "Target" + case .isf: return "ISF" + case .carbRatio: return "CR" + case .sage: return "SAGE" + case .cage: return "CAGE" + case .iage: return "IAGE" + case .carbsToday: return "Carbs" + case .override: return "Ovrd" + case .profile: return "Prof" + } + } + + /// True when the underlying value may be nil (e.g. Dexcom-only users who have + /// no Loop data). The widget renders "—" in those cases. + var isOptional: Bool { + switch self { + case .none, .delta: return false + default: return true + } + } +} + +// MARK: - Default slot assignments + +struct LiveActivitySlotDefaults { + /// Top-left slot + static let slot1: LiveActivitySlotOption = .iob + /// Bottom-left slot + static let slot2: LiveActivitySlotOption = .cob + /// Top-right slot + static let slot3: LiveActivitySlotOption = .projectedBG + /// Bottom-right slot — intentionally empty until the user configures it + static let slot4: LiveActivitySlotOption = .none + + static var all: [LiveActivitySlotOption] { + [slot1, slot2, slot3, slot4] + } +} + +// MARK: - App Group settings + /// Minimal App Group settings needed by the Live Activity UI. /// /// We keep this separate from Storage.shared to avoid target-coupling and @@ -11,24 +134,46 @@ enum LAAppGroupSettings { private enum Keys { static let lowLineMgdl = "la.lowLine.mgdl" static let highLineMgdl = "la.highLine.mgdl" + static let slots = "la.slots" } private static var defaults: UserDefaults? { UserDefaults(suiteName: AppGroupID.current()) } - // MARK: - Write (App) + // MARK: - Thresholds (Write) static func setThresholds(lowMgdl: Double, highMgdl: Double) { defaults?.set(lowMgdl, forKey: Keys.lowLineMgdl) defaults?.set(highMgdl, forKey: Keys.highLineMgdl) } - // MARK: - Read (Extension) + // MARK: - Thresholds (Read) static func thresholdsMgdl(fallbackLow: Double = 70, fallbackHigh: Double = 180) -> (low: Double, high: Double) { let low = defaults?.object(forKey: Keys.lowLineMgdl) as? Double ?? fallbackLow let high = defaults?.object(forKey: Keys.highLineMgdl) as? Double ?? fallbackHigh return (low, high) } + + // MARK: - Slot configuration (Write) + + /// Persists a 4-slot configuration to the App Group container. + /// - Parameter slots: Array of exactly 4 `LiveActivitySlotOption` values; + /// extra elements are ignored, missing elements are filled with `.none`. + static func setSlots(_ slots: [LiveActivitySlotOption]) { + let raw = slots.prefix(4).map { $0.rawValue } + defaults?.set(raw, forKey: Keys.slots) + } + + // MARK: - Slot configuration (Read) + + /// Returns the current 4-slot configuration, falling back to defaults + /// if no configuration has been saved yet. + static func slots() -> [LiveActivitySlotOption] { + guard let raw = defaults?.stringArray(forKey: Keys.slots), raw.count == 4 else { + return LiveActivitySlotDefaults.all + } + return raw.map { LiveActivitySlotOption(rawValue: $0) ?? .none } + } } diff --git a/LoopFollow/LiveActivity/LiveActivitySlotConfig.swift b/LoopFollow/LiveActivity/LiveActivitySlotConfig.swift new file mode 100644 index 000000000..2b097a6b1 --- /dev/null +++ b/LoopFollow/LiveActivity/LiveActivitySlotConfig.swift @@ -0,0 +1,44 @@ +// LoopFollow +// LiveActivitySlotConfig.swift + +// MARK: - Information Display Settings audit +// +// LoopFollow exposes 20 items in Information Display Settings (InfoType.swift). +// The table below maps each item to its availability as a Live Activity grid slot. +// +// AVAILABLE NOW — value present in GlucoseSnapshot: +// Display name | InfoType case | Snapshot field | Optional (nil for Dexcom-only) +// ───────────────────────────────────────────────────────────────────────────────── +// IOB | .iob | snapshot.iob | YES +// COB | .cob | snapshot.cob | YES +// Projected BG | (none) | snapshot.projected | YES +// Delta | (none) | snapshot.delta | NO (always available) +// +// Note: "Updated" (InfoType.updated) is intentionally excluded — it is displayed +// in the card footer and is not a configurable slot. +// +// NOT YET AVAILABLE — requires adding fields to GlucoseSnapshot, GlucoseSnapshotBuilder, +// and the APNs payload before they can be offered as slot options: +// Display name | InfoType case | Source in app +// ───────────────────────────────────────────────────────────────────────────────── +// Basal | .basal | DeviceStatus basal rate +// Override | .override | DeviceStatus override name +// Battery | .battery | DeviceStatus CGM/device battery % +// Pump | .pump | DeviceStatus pump name / status +// Pump Battery | .pumpBattery | DeviceStatus pump battery % +// SAGE | .sage | DeviceStatus sensor age (hours) +// CAGE | .cage | DeviceStatus cannula age (hours) +// Rec. Bolus | .recBolus | DeviceStatus recommended bolus +// Min/Max | .minMax | Computed from recent BG history +// Carbs today | .carbsToday | Computed from COB history +// Autosens | .autosens | DeviceStatusOpenAPS autosens ratio +// Profile | .profile | DeviceStatus profile name +// Target | .target | DeviceStatus BG target +// ISF | .isf | DeviceStatus insulin sensitivity factor +// CR | .carbRatio | DeviceStatus carb ratio +// TDD | .tdd | DeviceStatus total daily dose +// IAGE | .iage | DeviceStatus insulin/pod age (hours) +// +// The LiveActivitySlotOption enum, LiveActivitySlotDefaults struct, and +// LAAppGroupSettings.setSlots() / slots() storage are defined in +// LAAppGroupSettings.swift (shared between app and extension targets). diff --git a/LoopFollow/LiveActivitySettingsView.swift b/LoopFollow/LiveActivitySettingsView.swift index 0a29d702a..99dbc13e6 100644 --- a/LoopFollow/LiveActivitySettingsView.swift +++ b/LoopFollow/LiveActivitySettingsView.swift @@ -6,6 +6,9 @@ import SwiftUI struct LiveActivitySettingsView: View { @State private var laEnabled: Bool = Storage.shared.laEnabled.value @State private var restartConfirmed = false + @State private var slots: [LiveActivitySlotOption] = LAAppGroupSettings.slots() + + private let slotLabels = ["Top left", "Top right", "Bottom left", "Bottom right"] var body: some View { Form { @@ -25,6 +28,19 @@ struct LiveActivitySettingsView: View { .disabled(restartConfirmed) } } + + Section(header: Text("Grid slots")) { + ForEach(0 ..< 4, id: \.self) { index in + Picker(slotLabels[index], selection: Binding( + get: { slots[index] }, + set: { selectSlot($0, at: index) } + )) { + ForEach(LiveActivitySlotOption.allCases, id: \.self) { option in + Text(option.displayName).tag(option) + } + } + } + } } .onReceive(Storage.shared.laEnabled.$value) { newValue in if newValue != laEnabled { laEnabled = newValue } @@ -41,4 +57,19 @@ struct LiveActivitySettingsView: View { .navigationTitle("Live Activity") .navigationBarTitleDisplayMode(.inline) } + + /// Selects an option for the given slot index, enforcing uniqueness: + /// if the chosen option is already in another slot, that slot is cleared to `.none`. + private func selectSlot(_ option: LiveActivitySlotOption, at index: Int) { + if option != .none { + for i in 0 ..< slots.count where i != index && slots[i] == option { + slots[i] = .none + } + } + slots[index] = option + LAAppGroupSettings.setSlots(slots) + Task { + await LiveActivityManager.shared.refreshFromCurrentState(reason: "slot config changed") + } + } } diff --git a/LoopFollow/Storage/Storage.swift b/LoopFollow/Storage/Storage.swift index 141293e7c..754c6e0d7 100644 --- a/LoopFollow/Storage/Storage.swift +++ b/LoopFollow/Storage/Storage.swift @@ -91,6 +91,22 @@ class Storage { var lastCOB = StorageValue(key: "lastCOB", defaultValue: nil) var projectedBgMgdl = StorageValue(key: "projectedBgMgdl", defaultValue: nil) + // Live Activity extended InfoType data + var lastBasal = StorageValue(key: "lastBasal", defaultValue: "") + var lastPumpReservoirU = StorageValue(key: "lastPumpReservoirU", defaultValue: nil) + var lastAutosens = StorageValue(key: "lastAutosens", defaultValue: nil) + var lastTdd = StorageValue(key: "lastTdd", defaultValue: nil) + var lastTargetLowMgdl = StorageValue(key: "lastTargetLowMgdl", defaultValue: nil) + var lastTargetHighMgdl = StorageValue(key: "lastTargetHighMgdl", defaultValue: nil) + var lastIsfMgdlPerU = StorageValue(key: "lastIsfMgdlPerU", defaultValue: nil) + var lastCarbRatio = StorageValue(key: "lastCarbRatio", defaultValue: nil) + var lastCarbsToday = StorageValue(key: "lastCarbsToday", defaultValue: nil) + var lastProfileName = StorageValue(key: "lastProfileName", defaultValue: "") + var iageInsertTime = StorageValue(key: "iageInsertTime", defaultValue: 0) + var lastMinBgMgdl = StorageValue(key: "lastMinBgMgdl", defaultValue: nil) + var lastMaxBgMgdl = StorageValue(key: "lastMaxBgMgdl", defaultValue: nil) + + // Live Activity var laEnabled = StorageValue(key: "laEnabled", defaultValue: false) var laRenewBy = StorageValue(key: "laRenewBy", defaultValue: 0) diff --git a/LoopFollowLAExtension/LoopFollowLiveActivity.swift b/LoopFollowLAExtension/LoopFollowLiveActivity.swift index 294ba8645..d0f351611 100644 --- a/LoopFollowLAExtension/LoopFollowLiveActivity.swift +++ b/LoopFollowLAExtension/LoopFollowLiveActivity.swift @@ -78,50 +78,62 @@ private struct LockScreenLiveActivityView: View { var body: some View { let s = state.snapshot + let slotConfig = LAAppGroupSettings.slots() + + VStack(spacing: 6) { + HStack(spacing: 12) { + // LEFT: Glucose + trend arrow, delta below + VStack(alignment: .leading, spacing: 4) { + HStack(alignment: .firstTextBaseline, spacing: 8) { + Text(LAFormat.glucose(s)) + .font(.system(size: 46, weight: .bold, design: .rounded)) + .monospacedDigit() + .foregroundStyle(.white) - HStack(spacing: 12) { - // LEFT: Glucose + trend, update time below - VStack(alignment: .leading, spacing: 4) { - HStack(alignment: .firstTextBaseline, spacing: 8) { - Text(LAFormat.glucose(s)) - .font(.system(size: 46, weight: .bold, design: .rounded)) - .monospacedDigit() - .foregroundStyle(.white) - - Text(LAFormat.trendArrow(s)) - .font(.system(size: 46, weight: .bold, design: .rounded)) - .foregroundStyle(.white.opacity(0.95)) - } + Text(LAFormat.trendArrow(s)) + .font(.system(size: 46, weight: .bold, design: .rounded)) + .foregroundStyle(.white.opacity(0.95)) + } - Text("Last Update: \(LAFormat.updated(s))") - .font(.system(size: 13, weight: .regular, design: .rounded)) - .foregroundStyle(.white.opacity(0.75)) - } - .frame(width: 168, alignment: .leading) - .layoutPriority(2) - - // Divider - Rectangle() - .fill(Color.white.opacity(0.20)) - .frame(width: 1) - .padding(.vertical, 8) - - // RIGHT: 2x2 grid — delta/proj | iob/cob - VStack(spacing: 10) { - HStack(spacing: 16) { - MetricBlock(label: "Delta", value: LAFormat.delta(s)) - MetricBlock(label: "IOB", value: LAFormat.iob(s)) + Text("Delta: \(LAFormat.delta(s))") + .font(.system(size: 15, weight: .semibold, design: .rounded)) + .monospacedDigit() + .foregroundStyle(.white.opacity(0.80)) } - HStack(spacing: 16) { - MetricBlock(label: "Proj", value: LAFormat.projected(s)) - MetricBlock(label: "COB", value: LAFormat.cob(s)) + .frame(width: 168, alignment: .leading) + .layoutPriority(2) + + // Divider + Rectangle() + .fill(Color.white.opacity(0.20)) + .frame(width: 1) + .padding(.vertical, 8) + + // RIGHT: configurable 2×2 grid + VStack(spacing: 10) { + HStack(spacing: 16) { + SlotView(option: slotConfig[0], snapshot: s) + SlotView(option: slotConfig[1], snapshot: s) + } + HStack(spacing: 16) { + SlotView(option: slotConfig[2], snapshot: s) + SlotView(option: slotConfig[3], snapshot: s) + } } + .frame(maxWidth: .infinity, alignment: .trailing) } - .frame(maxWidth: .infinity, alignment: .trailing) + + // Footer: last update time + Text("Last Update: \(LAFormat.updated(s))") + .font(.system(size: 11, weight: .regular, design: .rounded)) + .monospacedDigit() + .foregroundStyle(.white.opacity(0.65)) + .frame(maxWidth: .infinity, alignment: .center) } .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal, 14) - .padding(.vertical, 12) + .padding(.top, 12) + .padding(.bottom, 8) .overlay( RoundedRectangle(cornerRadius: 16, style: .continuous) .stroke(Color.white.opacity(0.20), lineWidth: 1) @@ -193,6 +205,50 @@ private struct MetricBlock: View { } } +/// Renders one configurable slot in the lock screen 2×2 grid. +/// Shows nothing (invisible placeholder) when the slot option is `.none`. +private struct SlotView: View { + let option: LiveActivitySlotOption + let snapshot: GlucoseSnapshot + + var body: some View { + if option == .none { + // Invisible spacer — preserves grid alignment + Color.clear + .frame(width: 64, height: 36) + } else { + MetricBlock(label: option.gridLabel, value: value(for: option)) + } + } + + private func value(for option: LiveActivitySlotOption) -> String { + switch option { + case .none: return "" + case .delta: return LAFormat.delta(snapshot) + case .projectedBG: return LAFormat.projected(snapshot) + case .minMax: return LAFormat.minMax(snapshot) + case .iob: return LAFormat.iob(snapshot) + case .cob: return LAFormat.cob(snapshot) + case .recBolus: return LAFormat.recBolus(snapshot) + case .autosens: return LAFormat.autosens(snapshot) + case .tdd: return LAFormat.tdd(snapshot) + case .basal: return LAFormat.basal(snapshot) + case .pump: return LAFormat.pump(snapshot) + case .pumpBattery: return LAFormat.pumpBattery(snapshot) + case .battery: return LAFormat.battery(snapshot) + case .target: return LAFormat.target(snapshot) + case .isf: return LAFormat.isf(snapshot) + case .carbRatio: return LAFormat.carbRatio(snapshot) + case .sage: return LAFormat.age(insertTime: snapshot.sageInsertTime) + case .cage: return LAFormat.age(insertTime: snapshot.cageInsertTime) + case .iage: return LAFormat.age(insertTime: snapshot.iageInsertTime) + case .carbsToday: return LAFormat.carbsToday(snapshot) + case .override: return LAFormat.override(snapshot) + case .profile: return LAFormat.profileName(snapshot) + } + } +} + // MARK: - Dynamic Island @available(iOS 16.1, *) @@ -409,6 +465,94 @@ private enum LAFormat { return formatGlucoseValue(v, unit: s.unit) } + // MARK: Extended InfoType formatters + + private static let ageFormatter: DateComponentsFormatter = { + let f = DateComponentsFormatter() + f.unitsStyle = .positional + f.allowedUnits = [.day, .hour] + f.zeroFormattingBehavior = [.pad] + return f + }() + + /// Formats an insert-time epoch into "D:HH" age string. Returns "—" if time is 0. + static func age(insertTime: TimeInterval) -> String { + guard insertTime > 0 else { return "—" } + let secondsAgo = Date().timeIntervalSince1970 - insertTime + return ageFormatter.string(from: secondsAgo) ?? "—" + } + + static func recBolus(_ s: GlucoseSnapshot) -> String { + guard let v = s.recBolus else { return "—" } + return String(format: "%.2fU", v) + } + + static func autosens(_ s: GlucoseSnapshot) -> String { + guard let v = s.autosens else { return "—" } + return String(format: "%.0f%%", v * 100) + } + + static func tdd(_ s: GlucoseSnapshot) -> String { + guard let v = s.tdd else { return "—" } + return String(format: "%.1fU", v) + } + + static func basal(_ s: GlucoseSnapshot) -> String { + s.basalRate.isEmpty ? "—" : s.basalRate + } + + static func pump(_ s: GlucoseSnapshot) -> String { + guard let v = s.pumpReservoirU else { return "50+U" } + return "\(Int(round(v)))U" + } + + static func pumpBattery(_ s: GlucoseSnapshot) -> String { + guard let v = s.pumpBattery else { return "—" } + return String(format: "%.0f%%", v) + } + + static func battery(_ s: GlucoseSnapshot) -> String { + guard let v = s.battery else { return "—" } + return String(format: "%.0f%%", v) + } + + static func target(_ s: GlucoseSnapshot) -> String { + guard let low = s.targetLowMgdl, low > 0 else { return "—" } + let lowStr = formatGlucoseValue(low, unit: s.unit) + if let high = s.targetHighMgdl, high > 0, abs(high - low) > 0.5 { + return "\(lowStr)-\(formatGlucoseValue(high, unit: s.unit))" + } + return lowStr + } + + static func isf(_ s: GlucoseSnapshot) -> String { + guard let v = s.isfMgdlPerU, v > 0 else { return "—" } + return formatGlucoseValue(v, unit: s.unit) + } + + static func carbRatio(_ s: GlucoseSnapshot) -> String { + guard let v = s.carbRatio, v > 0 else { return "—" } + return String(format: "%.0fg", v) + } + + static func carbsToday(_ s: GlucoseSnapshot) -> String { + guard let v = s.carbsToday else { return "—" } + return "\(Int(round(v)))g" + } + + static func minMax(_ s: GlucoseSnapshot) -> String { + guard let mn = s.minBgMgdl, let mx = s.maxBgMgdl else { return "—" } + return "\(formatGlucoseValue(mn, unit: s.unit))/\(formatGlucoseValue(mx, unit: s.unit))" + } + + static func override(_ s: GlucoseSnapshot) -> String { + s.override ?? "—" + } + + static func profileName(_ s: GlucoseSnapshot) -> String { + s.profileName ?? "—" + } + // MARK: Update time private static let hhmmFormatter: DateFormatter = { From 83ba7c57a5c6bb3620e1c187625b5e490d945f42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Sun, 15 Mar 2026 21:39:10 +0100 Subject: [PATCH 45/82] Linting --- LoopFollow/Application/AppDelegate.swift | 3 ++- LoopFollow/LiveActivity/LAAppGroupSettings.swift | 2 +- LoopFollow/LiveActivity/LiveActivitySlotConfig.swift | 1 + LoopFollow/Storage/Storage.swift | 1 - LoopFollow/ViewControllers/MainViewController.swift | 3 ++- 5 files changed, 6 insertions(+), 4 deletions(-) diff --git a/LoopFollow/Application/AppDelegate.swift b/LoopFollow/Application/AppDelegate.swift index bf87a3343..81b01cf50 100644 --- a/LoopFollow/Application/AppDelegate.swift +++ b/LoopFollow/Application/AppDelegate.swift @@ -50,7 +50,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { func applicationWillTerminate(_: UIApplication) { #if !targetEnvironment(macCatalyst) - LiveActivityManager.shared.endOnTerminate() + LiveActivityManager.shared.endOnTerminate() #endif } @@ -102,6 +102,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { } // MARK: - URL handling + // Note: with scene-based lifecycle (iOS 13+), URLs are delivered to // SceneDelegate.scene(_:openURLContexts:) — not here. The scene delegate // handles loopfollow://la-tap for Live Activity tap navigation. diff --git a/LoopFollow/LiveActivity/LAAppGroupSettings.swift b/LoopFollow/LiveActivity/LAAppGroupSettings.swift index 2880c0efe..4e1d7b126 100644 --- a/LoopFollow/LiveActivity/LAAppGroupSettings.swift +++ b/LoopFollow/LiveActivity/LAAppGroupSettings.swift @@ -109,7 +109,7 @@ enum LiveActivitySlotOption: String, CaseIterable, Codable { // MARK: - Default slot assignments -struct LiveActivitySlotDefaults { +enum LiveActivitySlotDefaults { /// Top-left slot static let slot1: LiveActivitySlotOption = .iob /// Bottom-left slot diff --git a/LoopFollow/LiveActivity/LiveActivitySlotConfig.swift b/LoopFollow/LiveActivity/LiveActivitySlotConfig.swift index 2b097a6b1..10d8b13c3 100644 --- a/LoopFollow/LiveActivity/LiveActivitySlotConfig.swift +++ b/LoopFollow/LiveActivity/LiveActivitySlotConfig.swift @@ -2,6 +2,7 @@ // LiveActivitySlotConfig.swift // MARK: - Information Display Settings audit + // // LoopFollow exposes 20 items in Information Display Settings (InfoType.swift). // The table below maps each item to its availability as a Live Activity grid slot. diff --git a/LoopFollow/Storage/Storage.swift b/LoopFollow/Storage/Storage.swift index 754c6e0d7..7884e6589 100644 --- a/LoopFollow/Storage/Storage.swift +++ b/LoopFollow/Storage/Storage.swift @@ -106,7 +106,6 @@ class Storage { var lastMinBgMgdl = StorageValue(key: "lastMinBgMgdl", defaultValue: nil) var lastMaxBgMgdl = StorageValue(key: "lastMaxBgMgdl", defaultValue: nil) - // Live Activity var laEnabled = StorageValue(key: "laEnabled", defaultValue: false) var laRenewBy = StorageValue(key: "laRenewBy", defaultValue: 0) diff --git a/LoopFollow/ViewControllers/MainViewController.swift b/LoopFollow/ViewControllers/MainViewController.swift index 516b58e3c..72934bccb 100644 --- a/LoopFollow/ViewControllers/MainViewController.swift +++ b/LoopFollow/ViewControllers/MainViewController.swift @@ -689,7 +689,8 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele let targetIndex: Int if Observable.shared.currentAlarm.value != nil, - let snoozerIndex = getSnoozerTabIndex(), snoozerIndex < vcs.count { + let snoozerIndex = getSnoozerTabIndex(), snoozerIndex < vcs.count + { targetIndex = snoozerIndex } else { targetIndex = 0 From a20f3ecd99ddfb6cf010365ad6f0568b6f416efa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Sun, 15 Mar 2026 21:53:19 +0100 Subject: [PATCH 46/82] Fix PRODUCT_BUNDLE_IDENTIFIER for Tests --- LoopFollow.xcodeproj/project.pbxproj | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/LoopFollow.xcodeproj/project.pbxproj b/LoopFollow.xcodeproj/project.pbxproj index 4af8291b3..795346767 100644 --- a/LoopFollow.xcodeproj/project.pbxproj +++ b/LoopFollow.xcodeproj/project.pbxproj @@ -2499,7 +2499,7 @@ IPHONEOS_DEPLOYMENT_TARGET = 18.4; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = "com.--unique-id-.LoopFollowTests--app-suffix-.Tests"; + PRODUCT_BUNDLE_IDENTIFIER = "com.$(unique_id).LoopFollowTests$(app_suffix).Tests"; PRODUCT_MODULE_NAME = Tests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; @@ -2526,7 +2526,7 @@ IPHONEOS_DEPLOYMENT_TARGET = 18.4; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = "com.--unique-id-.LoopFollowTests--app-suffix-.Tests"; + PRODUCT_BUNDLE_IDENTIFIER = "com.$(unique_id).LoopFollowTests$(app_suffix).Tests"; PRODUCT_MODULE_NAME = Tests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = NO; From 145744c1f0f39b561c2d56e1b640cc5593f94d26 Mon Sep 17 00:00:00 2001 From: MtlPhil <76601115+MtlPhil@users.noreply.github.com> Date: Sun, 15 Mar 2026 18:32:57 -0400 Subject: [PATCH 47/82] fix: include all extended InfoType fields in APNs push payload buildPayload() was only serializing the base fields (glucose, delta, trend, updatedAt, unit, iob, cob, projected). All extended fields added with the configurable grid slots (battery, sageInsertTime, pumpBattery, basalRate, autosens, tdd, targets, isf, carbRatio, carbsToday, profileName, CAGE, IAGE, minBg, maxBg, override, recBolus) were missing from the APNs payload, causing the extension to decode them as nil/0 and display '--' on every push-driven refresh. Co-Authored-By: Claude Sonnet 4.6 --- LoopFollow/LiveActivity/APNSClient.swift | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/LoopFollow/LiveActivity/APNSClient.swift b/LoopFollow/LiveActivity/APNSClient.swift index 358d99469..94cee2a85 100644 --- a/LoopFollow/LiveActivity/APNSClient.swift +++ b/LoopFollow/LiveActivity/APNSClient.swift @@ -111,6 +111,25 @@ class APNSClient { if let iob = snapshot.iob { snapshotDict["iob"] = iob } if let cob = snapshot.cob { snapshotDict["cob"] = cob } if let projected = snapshot.projected { snapshotDict["projected"] = projected } + if let override = snapshot.override { snapshotDict["override"] = override } + if let recBolus = snapshot.recBolus { snapshotDict["recBolus"] = recBolus } + if let battery = snapshot.battery { snapshotDict["battery"] = battery } + if let pumpBattery = snapshot.pumpBattery { snapshotDict["pumpBattery"] = pumpBattery } + if !snapshot.basalRate.isEmpty { snapshotDict["basalRate"] = snapshot.basalRate } + if let pumpReservoirU = snapshot.pumpReservoirU { snapshotDict["pumpReservoirU"] = pumpReservoirU } + if let autosens = snapshot.autosens { snapshotDict["autosens"] = autosens } + if let tdd = snapshot.tdd { snapshotDict["tdd"] = tdd } + if let targetLowMgdl = snapshot.targetLowMgdl { snapshotDict["targetLowMgdl"] = targetLowMgdl } + if let targetHighMgdl = snapshot.targetHighMgdl { snapshotDict["targetHighMgdl"] = targetHighMgdl } + if let isfMgdlPerU = snapshot.isfMgdlPerU { snapshotDict["isfMgdlPerU"] = isfMgdlPerU } + if let carbRatio = snapshot.carbRatio { snapshotDict["carbRatio"] = carbRatio } + if let carbsToday = snapshot.carbsToday { snapshotDict["carbsToday"] = carbsToday } + if let profileName = snapshot.profileName { snapshotDict["profileName"] = profileName } + if snapshot.sageInsertTime > 0 { snapshotDict["sageInsertTime"] = snapshot.sageInsertTime } + if snapshot.cageInsertTime > 0 { snapshotDict["cageInsertTime"] = snapshot.cageInsertTime } + if snapshot.iageInsertTime > 0 { snapshotDict["iageInsertTime"] = snapshot.iageInsertTime } + if let minBgMgdl = snapshot.minBgMgdl { snapshotDict["minBgMgdl"] = minBgMgdl } + if let maxBgMgdl = snapshot.maxBgMgdl { snapshotDict["maxBgMgdl"] = maxBgMgdl } let contentState: [String: Any] = [ "snapshot": snapshotDict, From dfe53b3ee18ac7fc10da6f90d4561ef2948c372e Mon Sep 17 00:00:00 2001 From: MtlPhil <76601115+MtlPhil@users.noreply.github.com> Date: Sun, 15 Mar 2026 22:57:45 -0400 Subject: [PATCH 48/82] feat: add small family view for CarPlay Dashboard and Watch Smart Stack Registers the Live Activity for the small activity family via .supplementalActivityFamilies([.small]), enabling automatic display on: - CarPlay Dashboard (iOS 26+) - Apple Watch Smart Stack (watchOS 11+) The small view is hardcoded to the essentials appropriate for a driving context: glucose value, trend arrow, delta, and time since last reading. Background tint matches the existing threshold-based color logic. The lock screen layout (full grid with configurable slots) is unchanged. Co-Authored-By: Claude Sonnet 4.6 --- .../LoopFollowLiveActivity.swift | 72 +++++++++++++++++-- 1 file changed, 65 insertions(+), 7 deletions(-) diff --git a/LoopFollowLAExtension/LoopFollowLiveActivity.swift b/LoopFollowLAExtension/LoopFollowLiveActivity.swift index d0f351611..354ea13df 100644 --- a/LoopFollowLAExtension/LoopFollowLiveActivity.swift +++ b/LoopFollowLAExtension/LoopFollowLiveActivity.swift @@ -9,13 +9,10 @@ import WidgetKit struct LoopFollowLiveActivityWidget: Widget { var body: some WidgetConfiguration { ActivityConfiguration(for: GlucoseLiveActivityAttributes.self) { context in - // LOCK SCREEN / BANNER UI - LockScreenLiveActivityView(state: context.state /* , activityID: context.activityID */ ) - .id(context.state.seq) // force SwiftUI to re-render on every update - .activitySystemActionForegroundColor(.white) - .activityBackgroundTint(LAColors.backgroundTint(for: context.state.snapshot)) - .applyActivityContentMarginsFixIfAvailable() - .widgetURL(URL(string: "loopfollow://la-tap")!) + // LOCK SCREEN / BANNER UI — also used for CarPlay Dashboard and Watch Smart Stack + // (small family) via supplementalActivityFamilies([.small]) + LockScreenFamilyAdaptiveView(state: context.state) + .id(context.state.seq) } dynamicIsland: { context in // DYNAMIC ISLAND UI DynamicIsland { @@ -52,6 +49,7 @@ struct LoopFollowLiveActivityWidget: Widget { } .keylineTint(LAColors.keyline(for: context.state.snapshot).opacity(0.75)) } + .supplementalActivityFamilies([.small]) } } @@ -69,6 +67,66 @@ private extension View { } } +// MARK: - Family-adaptive wrapper (Lock Screen / CarPlay / Watch Smart Stack) + +/// Reads the activityFamily environment value and routes to the appropriate layout. +/// - `.small` → CarPlay Dashboard & Watch Smart Stack: compact glucose-only view +/// - everything else → full lock screen layout with configurable grid +@available(iOS 16.1, *) +private struct LockScreenFamilyAdaptiveView: View { + let state: GlucoseLiveActivityAttributes.ContentState + + @Environment(\.activityFamily) var activityFamily + + var body: some View { + if activityFamily == .small { + SmallFamilyView(snapshot: state.snapshot) + } else { + LockScreenLiveActivityView(state: state) + .activitySystemActionForegroundColor(.white) + .activityBackgroundTint(LAColors.backgroundTint(for: state.snapshot)) + .applyActivityContentMarginsFixIfAvailable() + .widgetURL(URL(string: "loopfollow://la-tap")!) + } + } +} + +// MARK: - Small family view (CarPlay Dashboard + Watch Smart Stack) + +/// Compact view shown on CarPlay Dashboard (iOS 26+) and Apple Watch Smart Stack (watchOS 11+). +/// Hardcoded to glucose + trend arrow + delta + time since last reading. +@available(iOS 16.1, *) +private struct SmallFamilyView: View { + let snapshot: GlucoseSnapshot + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + HStack(alignment: .firstTextBaseline, spacing: 6) { + Text(LAFormat.glucose(snapshot)) + .font(.system(size: 28, weight: .bold, design: .rounded)) + .monospacedDigit() + .foregroundStyle(.white) + Text(LAFormat.trendArrow(snapshot)) + .font(.system(size: 22, weight: .semibold, design: .rounded)) + .foregroundStyle(.white.opacity(0.9)) + } + HStack(spacing: 8) { + Text(LAFormat.delta(snapshot)) + .font(.system(size: 14, weight: .semibold, design: .rounded)) + .monospacedDigit() + .foregroundStyle(.white.opacity(0.85)) + Text(LAFormat.updated(snapshot)) + .font(.system(size: 14, weight: .regular, design: .rounded)) + .monospacedDigit() + .foregroundStyle(.white.opacity(0.65)) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) + .padding(10) + .activityBackgroundTint(LAColors.backgroundTint(for: snapshot)) + } +} + // MARK: - Lock Screen Contract View @available(iOS 16.1, *) From 2f28a1f0695e1410c1e3d54f1740df3422244776 Mon Sep 17 00:00:00 2001 From: Phil A <76601115+MtlPhil@users.noreply.github.com> Date: Mon, 16 Mar 2026 03:23:38 -0400 Subject: [PATCH 49/82] fix: include all extended InfoType fields in APNs push payload (#548) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: Live Activity auto-renewal to work around 8-hour system limit Co-Authored-By: Claude Sonnet 4.6 * test: reduce LA renewal threshold to 20 min for testing Co-Authored-By: Claude Sonnet 4.6 * feat: improve LA renewal robustness and stale indicator - staleDate on every ActivityContent now tracks the renewal deadline, so the system shows Apple's built-in stale indicator if renewal fails - Add laRenewalFailed StorageValue; set on Activity.request() failure, cleared on any successful LA start - Observe willEnterForegroundNotification: retry startIfNeeded() if a previous renewal attempt failed - New-first renewal order: request the replacement LA before ending the old one — if the request throws the existing LA stays alive so the user keeps live data until the system kills it at the 8-hour mark Co-Authored-By: Claude Sonnet 4.6 * feat: renewal warning overlay + restore 7.5h threshold - Restore renewalThreshold to 7.5 * 3600 (testing complete) - Add showRenewalOverlay: Bool to GlucoseSnapshot (Codable, default false) - GlucoseSnapshotBuilder sets it true when now >= laRenewBy - 1800 (30 minutes before the renewal deadline) - Lock screen: 60% gray overlay with "Tap to update" centered in white, layered above the existing isNotLooping overlay - DI expanded: RenewalOverlayView applied to leading/trailing/bottom regions; "Tap to update" text shown on the bottom region only - showRenewalOverlay resets to false automatically on renewal since laRenewBy is updated and the next snapshot rebuild clears the flag Co-Authored-By: Claude Sonnet 4.6 * fix: overlay not appearing + foreground restart not working Overlay rendering: - Replace Group{if condition{ZStack{RoundedRectangle...}}} with a permanently-present ZStack toggled via .opacity(). The Group/if pattern causes SwiftUI sizing ambiguity when the condition transitions from false→true inside an .overlay(), producing a zero-size result. The .opacity() approach keeps a stable view hierarchy. - Same fix applied to RenewalOverlayView used on DI expanded regions. Foreground restart: - handleForeground() was calling startIfNeeded(), which finds the still-alive (failed-to-renew) LA in Activity.activities and reuses it, doing nothing useful. Fixed to manually nil out current, cancel all tasks, await activity.end(.immediate), then startFromCurrentState(). Overlay timing: - Changed warning window from 30 min (1800s) to 20 min (1200s) before the renewal deadline, matching the intended test cadence. Logging: - handleForeground: log on every foreground event with laRenewalFailed value - renewIfNeeded: log how many seconds past the deadline when firing - GlucoseSnapshotBuilder: log when overlay activates with seconds to deadline - performRefresh: log when sending an update with the overlay visible Co-Authored-By: Claude Sonnet 4.6 * test: set renewalThreshold to 20 min for testing Co-Authored-By: Claude Sonnet 4.6 * fix: renewal overlay not clearing after LA is refreshed Normal renewal path (renewIfNeeded success): - Build a fresh snapshot with showRenewalOverlay: false for the new LA's initial content — it has a new deadline so the overlay should never be visible from the first frame. - Save that fresh snapshot to GlucoseSnapshotStore so the next duplicate check has the correct baseline and doesn't suppress the first real BG update. Foreground restart path (handleForeground): - Zero laRenewBy before calling startFromCurrentState() so GlucoseSnapshotBuilder computes showRenewalOverlay = false for the seed snapshot. startIfNeeded() then sets the new deadline after the request succeeds. Co-Authored-By: Claude Sonnet 4.6 * fix: overlay permanently active when warning window equals threshold With renewalThreshold=20min and the hardcoded 1200s warning window, renewBy-1200 = start, so showRenewalOverlay is always true from the moment the LA begins. Extract a named renewalWarning constant (5 min for testing) so the warning window is always less than the threshold. The builder now reads LiveActivityManager.renewalWarning instead of a hardcoded literal. Production values to restore before merging: renewalThreshold = 7.5 * 3600 renewalWarning = 20 * 60 Co-Authored-By: Claude Sonnet 4.6 * fix: include showRenewalOverlay in APNs payload and clear laRenewBy synchronously APNSClient was missing showRenewalOverlay from the push payload, so background APNs updates never delivered the overlay flag to the extension — only foreground direct ActivityKit updates did. In handleForeground, laRenewBy is now zeroed synchronously before spawning the async end/restart Task. This means any snapshot built between the foreground notification and the new LA start (e.g. from viewDidAppear's startFromCurrentState) computes showRenewalOverlay = false rather than reading the stale expired deadline. Co-Authored-By: Claude Sonnet 4.6 * fix: await LA end before restarting on foreground retry to avoid reuse path Reset laRenewBy and laRenewalFailed synchronously before tearing down the failed LA, then await activity.end() before calling startFromCurrentState(). This guarantees Activity.activities is clear when startIfNeeded() runs, so it takes the fresh-request path and writes a new laRenewBy. startFromCurrentState rebuilds the snapshot with showRenewalOverlay=false (laRenewBy=0), saves it to the store, then startIfNeeded uses that clean snapshot as the seed. Co-Authored-By: Claude Sonnet 4.6 * chore: restore production renewal timing (7.5h threshold, 20min warning) Co-Authored-By: Claude Sonnet 4.6 * feat: laEnabled toggle, forceRestart(), and RestartLiveActivityIntent - SettingsMenuView: rename "APN" menu entry to "Live Activity" - Storage: add laEnabled: Bool StorageValue (default false) - APNSettingsView: on/off toggle bound to laEnabled; APNs key fields and Restart button shown only when enabled; disabling immediately ends any running LA - LiveActivityManager: - forceRestart() (@MainActor) ends all running activities, resets laRenewBy and laRenewalFailed, then calls startFromCurrentState() - laEnabled guard added to startFromCurrentState(), refreshFromCurrentState(), handleForeground(), and handleDidBecomeActive() - didBecomeActiveNotification observer calls forceRestart() on every foreground transition when laEnabled is true - RestartLiveActivityIntent: AppIntent + ForegroundContinuableIntent; sets laEnabled, validates credentials, opens settings deep link if missing, otherwise calls forceRestart() in foreground; LoopFollowAppShortcuts exposes the intent with Siri phrase Note: RestartLiveActivityIntent.swift must be added to the app target in Xcode. Co-Authored-By: Claude Sonnet 4.6 * Added RestartLiveActivityIntent to project * fix: resolve two build errors in LiveActivityManager and RestartLiveActivityIntent - LiveActivityManager: handleDidBecomeActive() calls @MainActor forceRestart() via Task { @MainActor in ... } to satisfy Swift concurrency isolation - RestartLiveActivityIntent: drop ForegroundContinuableIntent — continueInForeground() was renamed to continueInForeground(_:alwaysConfirm:) in the iOS 26 SDK and requires iOS 26+; the didBecomeActiveNotification observer handles restart when the app comes to foreground, making explicit continuation unnecessary Co-Authored-By: Claude Sonnet 4.6 * fix: guard continueInForeground() behind iOS 26 availability check continueInForeground(_:alwaysConfirm:) is iOS 26+ only. On earlier versions, forceRestart() runs directly via the existing background audio session — no foreground continuation needed. Co-Authored-By: Claude Sonnet 4.6 * fix: use startFromCurrentState in handleDidBecomeActive instead of forceRestart forceRestart() was killing the LA that the RestartLiveActivityIntent had just created, because continueInForeground() triggers didBecomeActive. startFromCurrentState() reuses an existing active LA via startIfNeeded() and only creates one if none is present — no destructive race. Co-Authored-By: Claude Sonnet 4.6 * feat: LA foreground tab navigation, button feedback, and toggle sync APNSettingsView: - "Restart Live Activity" button shows "Live Activity Restarted" for 2 seconds and disables itself during that window to prevent double-taps - Toggle observes Storage.shared.laEnabled via onReceive so it reflects changes made externally (e.g. by the App Intent / Shortcuts) LiveActivityManager: - handleDidBecomeActive posts .liveActivityDidForeground after startFromCurrentState - Notification.Name.liveActivityDidForeground extension defined here MainViewController: - Observes .liveActivityDidForeground; navigates to Snoozer tab if an alarm is currently active, otherwise navigates to tab 0 (Home) Co-Authored-By: Claude Sonnet 4.6 * fix: flush LA update on willResignActive to ensure lock screen shows latest data The 5-second debounce on refreshFromCurrentState means a BG update arriving while the app is foreground won't reach the LA before the user returns to the lock screen. When the debounce fires in background, isForeground=false so the direct ActivityKit update is skipped. Fix: observe willResignActiveNotification, cancel the pending debounce, and immediately push the latest snapshot via direct ActivityKit update while the app is still foreground-active. The APNs push is also sent as a backup. Co-Authored-By: Claude Sonnet 4.6 * feat: redesign Dynamic Island compact and expanded views Compact: - Trailing now shows delta instead of trend arrow (leading already showed BG) Expanded leading: - Large BG value at top - Second row: trend arrow, delta, and "Proj: X" in smaller text below Expanded trailing: - IOB and COB (moved from bottom) Expanded bottom: - "Updated at: HH:mm" (moved from trailing) Co-Authored-By: Claude Sonnet 4.6 * fix: match Proj text style to delta; add trailing padding to IOB/COB - Proj label now uses same size (13), weight (semibold), and opacity (0.9) as the delta text beside it - Added 6pt trailing padding to the expanded DI trailing VStack so the IOB decimal is not clipped by the Dynamic Island edge Co-Authored-By: Claude Sonnet 4.6 * feat: separate Live Activity and APN settings into distinct menus Per maintainer preference, the APNs credentials (Key ID and Key) remain in their own "APN" settings screen. A new separate "Live Activity" settings screen contains the enable/disable toggle and the Restart Live Activity button. - APNSettingsView: restored to Key ID + Key only (no toggle or restart button) - LiveActivitySettingsView: new view with laEnabled toggle and restart button (with 2-second confirmation feedback); toggle stays in sync via onReceive - SettingsMenuView: "APN" entry restored; new "Live Activity" entry added with dot.radiowaves icon; Sheet.liveActivity case wired to LiveActivitySettingsView Co-Authored-By: Claude Sonnet 4.6 * Added Live Activity menu * chore: add LiveActivitySettingsView to Xcode project Co-Authored-By: Claude Sonnet 4.6 * fix: LA tap navigation, manual dismissal prevention, and toggle start - widgetURL on lock screen LA → opens app via loopfollow://la-tap - Link views in expanded DI regions → same URL on tap - AppDelegate.application(_:open:) handles loopfollow://la-tap and posts .liveActivityDidForeground so MainViewController navigates to Snoozer (if alarm active) or Home tab — fires only on LA/DI taps, not every foreground - Notification.Name.liveActivityDidForeground moved outside #if macCatalyst so AppDelegate can reference it unconditionally - handleDidBecomeActive no longer posts .liveActivityDidForeground (was firing on every app foreground, not just LA taps) - attachStateObserver: .dismissed state (user swipe) sets laEnabled = false so the LA does not auto-restart when the app foregrounds - LiveActivitySettingsView: toggling ON calls startFromCurrentState() Co-Authored-By: Claude Sonnet 4.6 * fix: end Live Activity on app force-quit Adds endOnTerminate() to LiveActivityManager — blocks up to 3 s using Task.detached + DispatchSemaphore so the async activity.end() completes before the process exits. Called from applicationWillTerminate so the LA clears from the lock screen immediately on force-quit. laEnabled is preserved so the LA restarts correctly on next launch. Co-Authored-By: Claude Sonnet 4.6 * fix: use dismissedByUser flag instead of disabling laEnabled on manual dismiss - Manual LA swipe-away sets dismissedByUser (in-memory) instead of laEnabled = false, preserving the user's preference - startFromCurrentState() guards on !dismissedByUser so the LA does not auto-restart on foreground, BG refresh, or handleDidBecomeActive - forceRestart() clears dismissedByUser before starting, so the Restart button and App Intent both work as the explicit re-enable mechanism - Toggle ON in settings calls forceRestart() (clears flag + starts) instead of startFromCurrentState() which would have been blocked by the flag - dismissedByUser resets to false on app relaunch (in-memory only), so a kill + relaunch starts the LA fresh as expected Co-Authored-By: Claude Sonnet 4.6 * fix: dismiss modal (Settings sheet) before tab switch on LA tap navigateOnLAForeground now checks tabBarController.presentedViewController and dismisses it before switching to the target tab, matching the existing pattern used elsewhere in MainViewController for the same scenario. Co-Authored-By: Claude Sonnet 4.6 * fix: LA tap navigation timing and LA reappear-after-dismiss Navigation fix: - loopfollow://la-tap URL can arrive before applicationDidBecomeActive while UIKit is still restoring the view hierarchy from background. At that moment tabBarController.presentedViewController is nil so the Settings modal is never dismissed and the navigation notification fires too early. - Now stores a pendingLATapNavigation flag if app is not yet .active, and fires the notification in applicationDidBecomeActive when the full view hierarchy (including presented modals) is restored. LA reappear fix: - performRefresh() calls startIfNeeded() directly, bypassing the dismissedByUser guard that only exists in startFromCurrentState(). On every BG refresh with laEnabled=true and current==nil (post-dismiss), it would recreate the LA. - Added !dismissedByUser guard to refreshFromCurrentState() so the entire refresh pipeline is skipped while dismissed, matching the existing guard in startFromCurrentState(). Co-Authored-By: Claude Sonnet 4.6 * fix: handle loopfollow://la-tap URL in SceneDelegate, not AppDelegate With scene-based lifecycle (iOS 13+), widget/LA tap URLs are delivered to scene(_:openURLContexts:) on UIWindowSceneDelegate — AppDelegate.application (_:open:url:options:) is never called. Our previous handler was dead code. SceneDelegate now implements scene(_:openURLContexts:) which posts .liveActivityDidForeground on the next run loop (via DispatchQueue.main.async) to let the view hierarchy fully settle before navigateOnLAForeground() runs. Also handles the edge case where the URL arrives before sceneDidBecomeActive by storing a pendingLATapNavigation flag. Co-Authored-By: Claude Sonnet 4.6 * feat: configurable LA grid slots + full InfoType snapshot coverage Lock screen layout: - Left column: glucose + trend arrow, delta below (replaces "Last Update:") - Right grid: 4 configurable slots read from LAAppGroupSettings (defaults: IOB/COB/Proj/empty) - Footer: centered HH:MM update time at reduced opacity Slot configuration: - LiveActivitySlotOption enum with all 22 InfoType-aligned cases - LAAppGroupSettings.setSlots() / slots() persisted to App Group UserDefaults - Uniqueness enforced: selecting an option clears it from any other slot - Settings UI: "Grid slots" section with 4 pickers in LiveActivitySettingsView - Changes take effect immediately (refreshFromCurrentState called on save) GlucoseSnapshot extended with 19 new fields covering all InfoType items: override, recBolus, battery, pumpBattery, basalRate, pumpReservoirU, autosens, tdd, targetLowMgdl/High, isfMgdlPerU, carbRatio, carbsToday, profileName, sageInsertTime, cageInsertTime, iageInsertTime, minBgMgdl/Max Storage.swift: 13 new UserDefaults-backed fields for the above metrics Controllers updated to write new Storage fields on each data fetch: Basals, DeviceStatus, DeviceStatusLoop, DeviceStatusOpenAPS, Carbs, Profile, IAge Co-Authored-By: Claude Sonnet 4.6 * fix: label delta and footer on lock screen LA card Co-Authored-By: Claude Sonnet 4.6 * docs: add PR description for configurable LA grid slots Co-Authored-By: Claude Sonnet 4.6 * Update PR_configurable_slots.md * chore: remove PR notes from tracking, keep docs/LiveActivity.md only - Untrack docs/PR_configurable_slots.md (local-only reference doc) - Add docs/PR_configurable_slots.md and docs/LiveActivityTestPlan.md to .gitignore Co-Authored-By: Claude Sonnet 4.6 * fix: include all extended InfoType fields in APNs push payload buildPayload() was only serializing the base fields (glucose, delta, trend, updatedAt, unit, iob, cob, projected). All extended fields added with the configurable grid slots (battery, sageInsertTime, pumpBattery, basalRate, autosens, tdd, targets, isf, carbRatio, carbsToday, profileName, CAGE, IAGE, minBg, maxBg, override, recBolus) were missing from the APNs payload, causing the extension to decode them as nil/0 and display '--' on every push-driven refresh. Co-Authored-By: Claude Sonnet 4.6 * feat: add small family view for CarPlay Dashboard and Watch Smart Stack Registers the Live Activity for the small activity family via .supplementalActivityFamilies([.small]), enabling automatic display on: - CarPlay Dashboard (iOS 26+) - Apple Watch Smart Stack (watchOS 11+) The small view is hardcoded to the essentials appropriate for a driving context: glucose value, trend arrow, delta, and time since last reading. Background tint matches the existing threshold-based color logic. The lock screen layout (full grid with configurable slots) is unchanged. Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Claude Sonnet 4.6 --- LoopFollow/LiveActivity/APNSClient.swift | 19 +++++ .../LoopFollowLiveActivity.swift | 72 +++++++++++++++++-- 2 files changed, 84 insertions(+), 7 deletions(-) diff --git a/LoopFollow/LiveActivity/APNSClient.swift b/LoopFollow/LiveActivity/APNSClient.swift index 358d99469..94cee2a85 100644 --- a/LoopFollow/LiveActivity/APNSClient.swift +++ b/LoopFollow/LiveActivity/APNSClient.swift @@ -111,6 +111,25 @@ class APNSClient { if let iob = snapshot.iob { snapshotDict["iob"] = iob } if let cob = snapshot.cob { snapshotDict["cob"] = cob } if let projected = snapshot.projected { snapshotDict["projected"] = projected } + if let override = snapshot.override { snapshotDict["override"] = override } + if let recBolus = snapshot.recBolus { snapshotDict["recBolus"] = recBolus } + if let battery = snapshot.battery { snapshotDict["battery"] = battery } + if let pumpBattery = snapshot.pumpBattery { snapshotDict["pumpBattery"] = pumpBattery } + if !snapshot.basalRate.isEmpty { snapshotDict["basalRate"] = snapshot.basalRate } + if let pumpReservoirU = snapshot.pumpReservoirU { snapshotDict["pumpReservoirU"] = pumpReservoirU } + if let autosens = snapshot.autosens { snapshotDict["autosens"] = autosens } + if let tdd = snapshot.tdd { snapshotDict["tdd"] = tdd } + if let targetLowMgdl = snapshot.targetLowMgdl { snapshotDict["targetLowMgdl"] = targetLowMgdl } + if let targetHighMgdl = snapshot.targetHighMgdl { snapshotDict["targetHighMgdl"] = targetHighMgdl } + if let isfMgdlPerU = snapshot.isfMgdlPerU { snapshotDict["isfMgdlPerU"] = isfMgdlPerU } + if let carbRatio = snapshot.carbRatio { snapshotDict["carbRatio"] = carbRatio } + if let carbsToday = snapshot.carbsToday { snapshotDict["carbsToday"] = carbsToday } + if let profileName = snapshot.profileName { snapshotDict["profileName"] = profileName } + if snapshot.sageInsertTime > 0 { snapshotDict["sageInsertTime"] = snapshot.sageInsertTime } + if snapshot.cageInsertTime > 0 { snapshotDict["cageInsertTime"] = snapshot.cageInsertTime } + if snapshot.iageInsertTime > 0 { snapshotDict["iageInsertTime"] = snapshot.iageInsertTime } + if let minBgMgdl = snapshot.minBgMgdl { snapshotDict["minBgMgdl"] = minBgMgdl } + if let maxBgMgdl = snapshot.maxBgMgdl { snapshotDict["maxBgMgdl"] = maxBgMgdl } let contentState: [String: Any] = [ "snapshot": snapshotDict, diff --git a/LoopFollowLAExtension/LoopFollowLiveActivity.swift b/LoopFollowLAExtension/LoopFollowLiveActivity.swift index d0f351611..354ea13df 100644 --- a/LoopFollowLAExtension/LoopFollowLiveActivity.swift +++ b/LoopFollowLAExtension/LoopFollowLiveActivity.swift @@ -9,13 +9,10 @@ import WidgetKit struct LoopFollowLiveActivityWidget: Widget { var body: some WidgetConfiguration { ActivityConfiguration(for: GlucoseLiveActivityAttributes.self) { context in - // LOCK SCREEN / BANNER UI - LockScreenLiveActivityView(state: context.state /* , activityID: context.activityID */ ) - .id(context.state.seq) // force SwiftUI to re-render on every update - .activitySystemActionForegroundColor(.white) - .activityBackgroundTint(LAColors.backgroundTint(for: context.state.snapshot)) - .applyActivityContentMarginsFixIfAvailable() - .widgetURL(URL(string: "loopfollow://la-tap")!) + // LOCK SCREEN / BANNER UI — also used for CarPlay Dashboard and Watch Smart Stack + // (small family) via supplementalActivityFamilies([.small]) + LockScreenFamilyAdaptiveView(state: context.state) + .id(context.state.seq) } dynamicIsland: { context in // DYNAMIC ISLAND UI DynamicIsland { @@ -52,6 +49,7 @@ struct LoopFollowLiveActivityWidget: Widget { } .keylineTint(LAColors.keyline(for: context.state.snapshot).opacity(0.75)) } + .supplementalActivityFamilies([.small]) } } @@ -69,6 +67,66 @@ private extension View { } } +// MARK: - Family-adaptive wrapper (Lock Screen / CarPlay / Watch Smart Stack) + +/// Reads the activityFamily environment value and routes to the appropriate layout. +/// - `.small` → CarPlay Dashboard & Watch Smart Stack: compact glucose-only view +/// - everything else → full lock screen layout with configurable grid +@available(iOS 16.1, *) +private struct LockScreenFamilyAdaptiveView: View { + let state: GlucoseLiveActivityAttributes.ContentState + + @Environment(\.activityFamily) var activityFamily + + var body: some View { + if activityFamily == .small { + SmallFamilyView(snapshot: state.snapshot) + } else { + LockScreenLiveActivityView(state: state) + .activitySystemActionForegroundColor(.white) + .activityBackgroundTint(LAColors.backgroundTint(for: state.snapshot)) + .applyActivityContentMarginsFixIfAvailable() + .widgetURL(URL(string: "loopfollow://la-tap")!) + } + } +} + +// MARK: - Small family view (CarPlay Dashboard + Watch Smart Stack) + +/// Compact view shown on CarPlay Dashboard (iOS 26+) and Apple Watch Smart Stack (watchOS 11+). +/// Hardcoded to glucose + trend arrow + delta + time since last reading. +@available(iOS 16.1, *) +private struct SmallFamilyView: View { + let snapshot: GlucoseSnapshot + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + HStack(alignment: .firstTextBaseline, spacing: 6) { + Text(LAFormat.glucose(snapshot)) + .font(.system(size: 28, weight: .bold, design: .rounded)) + .monospacedDigit() + .foregroundStyle(.white) + Text(LAFormat.trendArrow(snapshot)) + .font(.system(size: 22, weight: .semibold, design: .rounded)) + .foregroundStyle(.white.opacity(0.9)) + } + HStack(spacing: 8) { + Text(LAFormat.delta(snapshot)) + .font(.system(size: 14, weight: .semibold, design: .rounded)) + .monospacedDigit() + .foregroundStyle(.white.opacity(0.85)) + Text(LAFormat.updated(snapshot)) + .font(.system(size: 14, weight: .regular, design: .rounded)) + .monospacedDigit() + .foregroundStyle(.white.opacity(0.65)) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) + .padding(10) + .activityBackgroundTint(LAColors.backgroundTint(for: snapshot)) + } +} + // MARK: - Lock Screen Contract View @available(iOS 16.1, *) From a98f0a88cfc2683313dd75aa7536a8d107b97e16 Mon Sep 17 00:00:00 2001 From: MtlPhil <76601115+MtlPhil@users.noreply.github.com> Date: Mon, 16 Mar 2026 07:41:41 -0400 Subject: [PATCH 50/82] fix: guard CarPlay/Watch small family behind iOS 18 availability; increase renewal overlay opacity - supplementalActivityFamilies and activityFamily require iOS 18.0+; restructured into two Widget structs sharing a makeDynamicIsland() helper: - LoopFollowLiveActivityWidget (iOS 16.1+): lock screen + Dynamic Island - LoopFollowLiveActivityWidgetWithCarPlay (iOS 18.0+): adds .supplementalActivityFamilies([.small]) - Bundle uses if #available(iOS 18.0, *) to select the right variant - Podfile: set minimum deployment target to 16.6 for all pods - Increase 'Tap to update' renewal overlay opacity from 60% to 90% Co-Authored-By: Claude Sonnet 4.6 --- .../LoopFollowLABundle.swift | 5 +- .../LoopFollowLiveActivity.swift | 101 +++++++++++------- Podfile | 9 ++ 3 files changed, 74 insertions(+), 41 deletions(-) diff --git a/LoopFollowLAExtension/LoopFollowLABundle.swift b/LoopFollowLAExtension/LoopFollowLABundle.swift index e3a043783..1f901463f 100644 --- a/LoopFollowLAExtension/LoopFollowLABundle.swift +++ b/LoopFollowLAExtension/LoopFollowLABundle.swift @@ -11,7 +11,10 @@ import WidgetKit @main struct LoopFollowLABundle: WidgetBundle { var body: some Widget { - if #available(iOS 16.1, *) { + if #available(iOS 18.0, *) { + // CarPlay Dashboard + Watch Smart Stack support (iOS 18+) + LoopFollowLiveActivityWidgetWithCarPlay() + } else { LoopFollowLiveActivityWidget() } } diff --git a/LoopFollowLAExtension/LoopFollowLiveActivity.swift b/LoopFollowLAExtension/LoopFollowLiveActivity.swift index 354ea13df..51e144f54 100644 --- a/LoopFollowLAExtension/LoopFollowLiveActivity.swift +++ b/LoopFollowLAExtension/LoopFollowLiveActivity.swift @@ -5,49 +5,70 @@ import ActivityKit import SwiftUI import WidgetKit +/// Builds the shared Dynamic Island content used by both widget variants. +@available(iOS 16.1, *) +private func makeDynamicIsland(context: ActivityViewContext) -> DynamicIsland { + DynamicIsland { + DynamicIslandExpandedRegion(.leading) { + Link(destination: URL(string: "loopfollow://la-tap")!) { + DynamicIslandLeadingView(snapshot: context.state.snapshot) + .overlay(RenewalOverlayView(show: context.state.snapshot.showRenewalOverlay)) + } + .id(context.state.seq) + } + DynamicIslandExpandedRegion(.trailing) { + Link(destination: URL(string: "loopfollow://la-tap")!) { + DynamicIslandTrailingView(snapshot: context.state.snapshot) + .overlay(RenewalOverlayView(show: context.state.snapshot.showRenewalOverlay)) + } + .id(context.state.seq) + } + DynamicIslandExpandedRegion(.bottom) { + Link(destination: URL(string: "loopfollow://la-tap")!) { + DynamicIslandBottomView(snapshot: context.state.snapshot) + .overlay(RenewalOverlayView(show: context.state.snapshot.showRenewalOverlay, showText: true)) + } + .id(context.state.seq) + } + } compactLeading: { + DynamicIslandCompactLeadingView(snapshot: context.state.snapshot) + .id(context.state.seq) + } compactTrailing: { + DynamicIslandCompactTrailingView(snapshot: context.state.snapshot) + .id(context.state.seq) + } minimal: { + DynamicIslandMinimalView(snapshot: context.state.snapshot) + .id(context.state.seq) + } + .keylineTint(LAColors.keyline(for: context.state.snapshot).opacity(0.75)) +} + +/// Base widget — Lock Screen + Dynamic Island. Used on iOS 16.1–17.x. @available(iOS 16.1, *) struct LoopFollowLiveActivityWidget: Widget { var body: some WidgetConfiguration { ActivityConfiguration(for: GlucoseLiveActivityAttributes.self) { context in - // LOCK SCREEN / BANNER UI — also used for CarPlay Dashboard and Watch Smart Stack - // (small family) via supplementalActivityFamilies([.small]) + LockScreenLiveActivityView(state: context.state) + .id(context.state.seq) + .activitySystemActionForegroundColor(.white) + .activityBackgroundTint(LAColors.backgroundTint(for: context.state.snapshot)) + .applyActivityContentMarginsFixIfAvailable() + .widgetURL(URL(string: "loopfollow://la-tap")!) + } dynamicIsland: { context in + makeDynamicIsland(context: context) + } + } +} + +/// iOS 18+ widget — adds CarPlay Dashboard + Watch Smart Stack via the small activity family. +@available(iOS 18.0, *) +struct LoopFollowLiveActivityWidgetWithCarPlay: Widget { + var body: some WidgetConfiguration { + ActivityConfiguration(for: GlucoseLiveActivityAttributes.self) { context in LockScreenFamilyAdaptiveView(state: context.state) .id(context.state.seq) } dynamicIsland: { context in - // DYNAMIC ISLAND UI - DynamicIsland { - DynamicIslandExpandedRegion(.leading) { - Link(destination: URL(string: "loopfollow://la-tap")!) { - DynamicIslandLeadingView(snapshot: context.state.snapshot) - .overlay(RenewalOverlayView(show: context.state.snapshot.showRenewalOverlay)) - } - .id(context.state.seq) - } - DynamicIslandExpandedRegion(.trailing) { - Link(destination: URL(string: "loopfollow://la-tap")!) { - DynamicIslandTrailingView(snapshot: context.state.snapshot) - .overlay(RenewalOverlayView(show: context.state.snapshot.showRenewalOverlay)) - } - .id(context.state.seq) - } - DynamicIslandExpandedRegion(.bottom) { - Link(destination: URL(string: "loopfollow://la-tap")!) { - DynamicIslandBottomView(snapshot: context.state.snapshot) - .overlay(RenewalOverlayView(show: context.state.snapshot.showRenewalOverlay, showText: true)) - } - .id(context.state.seq) - } - } compactLeading: { - DynamicIslandCompactLeadingView(snapshot: context.state.snapshot) - .id(context.state.seq) - } compactTrailing: { - DynamicIslandCompactTrailingView(snapshot: context.state.snapshot) - .id(context.state.seq) - } minimal: { - DynamicIslandMinimalView(snapshot: context.state.snapshot) - .id(context.state.seq) - } - .keylineTint(LAColors.keyline(for: context.state.snapshot).opacity(0.75)) + makeDynamicIsland(context: context) } .supplementalActivityFamilies([.small]) } @@ -72,7 +93,7 @@ private extension View { /// Reads the activityFamily environment value and routes to the appropriate layout. /// - `.small` → CarPlay Dashboard & Watch Smart Stack: compact glucose-only view /// - everything else → full lock screen layout with configurable grid -@available(iOS 16.1, *) +@available(iOS 18.0, *) private struct LockScreenFamilyAdaptiveView: View { let state: GlucoseLiveActivityAttributes.ContentState @@ -95,7 +116,7 @@ private struct LockScreenFamilyAdaptiveView: View { /// Compact view shown on CarPlay Dashboard (iOS 26+) and Apple Watch Smart Stack (watchOS 11+). /// Hardcoded to glucose + trend arrow + delta + time since last reading. -@available(iOS 16.1, *) +@available(iOS 18.0, *) private struct SmallFamilyView: View { let snapshot: GlucoseSnapshot @@ -213,7 +234,7 @@ private struct LockScreenLiveActivityView: View { .overlay( ZStack { RoundedRectangle(cornerRadius: 16, style: .continuous) - .fill(Color.gray.opacity(0.6)) + .fill(Color.gray.opacity(0.9)) Text("Tap to update") .font(.system(size: 20, weight: .semibold)) .foregroundStyle(.white) @@ -231,7 +252,7 @@ private struct RenewalOverlayView: View { var body: some View { ZStack { - Color.gray.opacity(0.6) + Color.gray.opacity(0.9) if showText { Text("Tap to update") .font(.system(size: 14, weight: .semibold)) diff --git a/Podfile b/Podfile index 5a8c0f868..f8c2df3b2 100644 --- a/Podfile +++ b/Podfile @@ -7,6 +7,15 @@ target 'LoopFollow' do end post_install do |installer| + # Set minimum deployment target for all pods to match the app (suppresses deprecation warnings) + installer.pods_project.targets.each do |target| + target.build_configurations.each do |config| + if config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'].to_f < 16.6 + config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '16.6' + end + end + end + # Patch Charts Transformer to avoid "CGAffineTransformInvert: singular matrix" # warnings when chart views have zero dimensions (before layout). transformer = 'Pods/Charts/Source/Charts/Utils/Transformer.swift' From 65e679ad30eda00d2d032ab44e635be059340deb Mon Sep 17 00:00:00 2001 From: MtlPhil <76601115+MtlPhil@users.noreply.github.com> Date: Mon, 16 Mar 2026 08:24:36 -0400 Subject: [PATCH 51/82] fix: move if #available into Widget.body to avoid WidgetBundleBuilder limitation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @WidgetBundleBuilder does not support if #available { } else { }, but @WidgetConfigurationBuilder (used by Widget.body) does. Collapsed back to a single LoopFollowLiveActivityWidget struct with the iOS 18 conditional inside body — iOS 18+ branch adds .supplementalActivityFamilies([.small]) for CarPlay and Watch Smart Stack; else branch uses the plain lock screen view. Bundle reverts to the original single if #available(iOS 16.1, *) pattern. Co-Authored-By: Claude Sonnet 4.6 --- .../LoopFollowLABundle.swift | 5 +- .../LoopFollowLiveActivity.swift | 46 +++++++++---------- 2 files changed, 23 insertions(+), 28 deletions(-) diff --git a/LoopFollowLAExtension/LoopFollowLABundle.swift b/LoopFollowLAExtension/LoopFollowLABundle.swift index 1f901463f..e3a043783 100644 --- a/LoopFollowLAExtension/LoopFollowLABundle.swift +++ b/LoopFollowLAExtension/LoopFollowLABundle.swift @@ -11,10 +11,7 @@ import WidgetKit @main struct LoopFollowLABundle: WidgetBundle { var body: some Widget { - if #available(iOS 18.0, *) { - // CarPlay Dashboard + Watch Smart Stack support (iOS 18+) - LoopFollowLiveActivityWidgetWithCarPlay() - } else { + if #available(iOS 16.1, *) { LoopFollowLiveActivityWidget() } } diff --git a/LoopFollowLAExtension/LoopFollowLiveActivity.swift b/LoopFollowLAExtension/LoopFollowLiveActivity.swift index 51e144f54..4e3c3a84c 100644 --- a/LoopFollowLAExtension/LoopFollowLiveActivity.swift +++ b/LoopFollowLAExtension/LoopFollowLiveActivity.swift @@ -43,34 +43,32 @@ private func makeDynamicIsland(context: ActivityViewContext Date: Mon, 16 Mar 2026 08:34:19 -0400 Subject: [PATCH 52/82] fix: use two separate single-branch if #available in bundle for CarPlay support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @WidgetConfigurationBuilder's buildEither requires both branches to return the same concrete type, making if/else with supplementalActivityFamilies impossible (it wraps to a different opaque type). @WidgetBundleBuilder does not support if #available { } else { } at all. Solution: two separate single-branch if #available blocks in the bundle — the pattern that @WidgetBundleBuilder already supported in the original code: - LoopFollowLiveActivityWidget (iOS 16.1+): primary, lock screen + Dynamic Island - LoopFollowLiveActivityWidgetWithCarPlay (iOS 18.0+): supplemental, adds .supplementalActivityFamilies([.small]) for CarPlay Dashboard + Watch Smart Stack ActivityKit uses the supplemental widget for small-family surfaces and the primary widget for lock screen / Dynamic Island, keeping iOS 16.6+ support intact. Co-Authored-By: Claude Sonnet 4.6 --- .../LoopFollowLABundle.swift | 3 ++ .../LoopFollowLiveActivity.swift | 48 ++++++++++--------- 2 files changed, 29 insertions(+), 22 deletions(-) diff --git a/LoopFollowLAExtension/LoopFollowLABundle.swift b/LoopFollowLAExtension/LoopFollowLABundle.swift index e3a043783..a9f7daf6c 100644 --- a/LoopFollowLAExtension/LoopFollowLABundle.swift +++ b/LoopFollowLAExtension/LoopFollowLABundle.swift @@ -14,5 +14,8 @@ struct LoopFollowLABundle: WidgetBundle { if #available(iOS 16.1, *) { LoopFollowLiveActivityWidget() } + if #available(iOS 18.0, *) { + LoopFollowLiveActivityWidgetWithCarPlay() + } } } diff --git a/LoopFollowLAExtension/LoopFollowLiveActivity.swift b/LoopFollowLAExtension/LoopFollowLiveActivity.swift index 4e3c3a84c..5e33cb500 100644 --- a/LoopFollowLAExtension/LoopFollowLiveActivity.swift +++ b/LoopFollowLAExtension/LoopFollowLiveActivity.swift @@ -43,32 +43,36 @@ private func makeDynamicIsland(context: ActivityViewContext Date: Mon, 16 Mar 2026 11:33:59 -0400 Subject: [PATCH 53/82] Live Activity: CarPlay Dashboard + Apple Watch Smart Stack support (#549) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: Live Activity auto-renewal to work around 8-hour system limit Co-Authored-By: Claude Sonnet 4.6 * test: reduce LA renewal threshold to 20 min for testing Co-Authored-By: Claude Sonnet 4.6 * feat: improve LA renewal robustness and stale indicator - staleDate on every ActivityContent now tracks the renewal deadline, so the system shows Apple's built-in stale indicator if renewal fails - Add laRenewalFailed StorageValue; set on Activity.request() failure, cleared on any successful LA start - Observe willEnterForegroundNotification: retry startIfNeeded() if a previous renewal attempt failed - New-first renewal order: request the replacement LA before ending the old one — if the request throws the existing LA stays alive so the user keeps live data until the system kills it at the 8-hour mark Co-Authored-By: Claude Sonnet 4.6 * feat: renewal warning overlay + restore 7.5h threshold - Restore renewalThreshold to 7.5 * 3600 (testing complete) - Add showRenewalOverlay: Bool to GlucoseSnapshot (Codable, default false) - GlucoseSnapshotBuilder sets it true when now >= laRenewBy - 1800 (30 minutes before the renewal deadline) - Lock screen: 60% gray overlay with "Tap to update" centered in white, layered above the existing isNotLooping overlay - DI expanded: RenewalOverlayView applied to leading/trailing/bottom regions; "Tap to update" text shown on the bottom region only - showRenewalOverlay resets to false automatically on renewal since laRenewBy is updated and the next snapshot rebuild clears the flag Co-Authored-By: Claude Sonnet 4.6 * fix: overlay not appearing + foreground restart not working Overlay rendering: - Replace Group{if condition{ZStack{RoundedRectangle...}}} with a permanently-present ZStack toggled via .opacity(). The Group/if pattern causes SwiftUI sizing ambiguity when the condition transitions from false→true inside an .overlay(), producing a zero-size result. The .opacity() approach keeps a stable view hierarchy. - Same fix applied to RenewalOverlayView used on DI expanded regions. Foreground restart: - handleForeground() was calling startIfNeeded(), which finds the still-alive (failed-to-renew) LA in Activity.activities and reuses it, doing nothing useful. Fixed to manually nil out current, cancel all tasks, await activity.end(.immediate), then startFromCurrentState(). Overlay timing: - Changed warning window from 30 min (1800s) to 20 min (1200s) before the renewal deadline, matching the intended test cadence. Logging: - handleForeground: log on every foreground event with laRenewalFailed value - renewIfNeeded: log how many seconds past the deadline when firing - GlucoseSnapshotBuilder: log when overlay activates with seconds to deadline - performRefresh: log when sending an update with the overlay visible Co-Authored-By: Claude Sonnet 4.6 * test: set renewalThreshold to 20 min for testing Co-Authored-By: Claude Sonnet 4.6 * fix: renewal overlay not clearing after LA is refreshed Normal renewal path (renewIfNeeded success): - Build a fresh snapshot with showRenewalOverlay: false for the new LA's initial content — it has a new deadline so the overlay should never be visible from the first frame. - Save that fresh snapshot to GlucoseSnapshotStore so the next duplicate check has the correct baseline and doesn't suppress the first real BG update. Foreground restart path (handleForeground): - Zero laRenewBy before calling startFromCurrentState() so GlucoseSnapshotBuilder computes showRenewalOverlay = false for the seed snapshot. startIfNeeded() then sets the new deadline after the request succeeds. Co-Authored-By: Claude Sonnet 4.6 * fix: overlay permanently active when warning window equals threshold With renewalThreshold=20min and the hardcoded 1200s warning window, renewBy-1200 = start, so showRenewalOverlay is always true from the moment the LA begins. Extract a named renewalWarning constant (5 min for testing) so the warning window is always less than the threshold. The builder now reads LiveActivityManager.renewalWarning instead of a hardcoded literal. Production values to restore before merging: renewalThreshold = 7.5 * 3600 renewalWarning = 20 * 60 Co-Authored-By: Claude Sonnet 4.6 * fix: include showRenewalOverlay in APNs payload and clear laRenewBy synchronously APNSClient was missing showRenewalOverlay from the push payload, so background APNs updates never delivered the overlay flag to the extension — only foreground direct ActivityKit updates did. In handleForeground, laRenewBy is now zeroed synchronously before spawning the async end/restart Task. This means any snapshot built between the foreground notification and the new LA start (e.g. from viewDidAppear's startFromCurrentState) computes showRenewalOverlay = false rather than reading the stale expired deadline. Co-Authored-By: Claude Sonnet 4.6 * fix: await LA end before restarting on foreground retry to avoid reuse path Reset laRenewBy and laRenewalFailed synchronously before tearing down the failed LA, then await activity.end() before calling startFromCurrentState(). This guarantees Activity.activities is clear when startIfNeeded() runs, so it takes the fresh-request path and writes a new laRenewBy. startFromCurrentState rebuilds the snapshot with showRenewalOverlay=false (laRenewBy=0), saves it to the store, then startIfNeeded uses that clean snapshot as the seed. Co-Authored-By: Claude Sonnet 4.6 * chore: restore production renewal timing (7.5h threshold, 20min warning) Co-Authored-By: Claude Sonnet 4.6 * feat: laEnabled toggle, forceRestart(), and RestartLiveActivityIntent - SettingsMenuView: rename "APN" menu entry to "Live Activity" - Storage: add laEnabled: Bool StorageValue (default false) - APNSettingsView: on/off toggle bound to laEnabled; APNs key fields and Restart button shown only when enabled; disabling immediately ends any running LA - LiveActivityManager: - forceRestart() (@MainActor) ends all running activities, resets laRenewBy and laRenewalFailed, then calls startFromCurrentState() - laEnabled guard added to startFromCurrentState(), refreshFromCurrentState(), handleForeground(), and handleDidBecomeActive() - didBecomeActiveNotification observer calls forceRestart() on every foreground transition when laEnabled is true - RestartLiveActivityIntent: AppIntent + ForegroundContinuableIntent; sets laEnabled, validates credentials, opens settings deep link if missing, otherwise calls forceRestart() in foreground; LoopFollowAppShortcuts exposes the intent with Siri phrase Note: RestartLiveActivityIntent.swift must be added to the app target in Xcode. Co-Authored-By: Claude Sonnet 4.6 * Added RestartLiveActivityIntent to project * fix: resolve two build errors in LiveActivityManager and RestartLiveActivityIntent - LiveActivityManager: handleDidBecomeActive() calls @MainActor forceRestart() via Task { @MainActor in ... } to satisfy Swift concurrency isolation - RestartLiveActivityIntent: drop ForegroundContinuableIntent — continueInForeground() was renamed to continueInForeground(_:alwaysConfirm:) in the iOS 26 SDK and requires iOS 26+; the didBecomeActiveNotification observer handles restart when the app comes to foreground, making explicit continuation unnecessary Co-Authored-By: Claude Sonnet 4.6 * fix: guard continueInForeground() behind iOS 26 availability check continueInForeground(_:alwaysConfirm:) is iOS 26+ only. On earlier versions, forceRestart() runs directly via the existing background audio session — no foreground continuation needed. Co-Authored-By: Claude Sonnet 4.6 * fix: use startFromCurrentState in handleDidBecomeActive instead of forceRestart forceRestart() was killing the LA that the RestartLiveActivityIntent had just created, because continueInForeground() triggers didBecomeActive. startFromCurrentState() reuses an existing active LA via startIfNeeded() and only creates one if none is present — no destructive race. Co-Authored-By: Claude Sonnet 4.6 * feat: LA foreground tab navigation, button feedback, and toggle sync APNSettingsView: - "Restart Live Activity" button shows "Live Activity Restarted" for 2 seconds and disables itself during that window to prevent double-taps - Toggle observes Storage.shared.laEnabled via onReceive so it reflects changes made externally (e.g. by the App Intent / Shortcuts) LiveActivityManager: - handleDidBecomeActive posts .liveActivityDidForeground after startFromCurrentState - Notification.Name.liveActivityDidForeground extension defined here MainViewController: - Observes .liveActivityDidForeground; navigates to Snoozer tab if an alarm is currently active, otherwise navigates to tab 0 (Home) Co-Authored-By: Claude Sonnet 4.6 * fix: flush LA update on willResignActive to ensure lock screen shows latest data The 5-second debounce on refreshFromCurrentState means a BG update arriving while the app is foreground won't reach the LA before the user returns to the lock screen. When the debounce fires in background, isForeground=false so the direct ActivityKit update is skipped. Fix: observe willResignActiveNotification, cancel the pending debounce, and immediately push the latest snapshot via direct ActivityKit update while the app is still foreground-active. The APNs push is also sent as a backup. Co-Authored-By: Claude Sonnet 4.6 * feat: redesign Dynamic Island compact and expanded views Compact: - Trailing now shows delta instead of trend arrow (leading already showed BG) Expanded leading: - Large BG value at top - Second row: trend arrow, delta, and "Proj: X" in smaller text below Expanded trailing: - IOB and COB (moved from bottom) Expanded bottom: - "Updated at: HH:mm" (moved from trailing) Co-Authored-By: Claude Sonnet 4.6 * fix: match Proj text style to delta; add trailing padding to IOB/COB - Proj label now uses same size (13), weight (semibold), and opacity (0.9) as the delta text beside it - Added 6pt trailing padding to the expanded DI trailing VStack so the IOB decimal is not clipped by the Dynamic Island edge Co-Authored-By: Claude Sonnet 4.6 * feat: separate Live Activity and APN settings into distinct menus Per maintainer preference, the APNs credentials (Key ID and Key) remain in their own "APN" settings screen. A new separate "Live Activity" settings screen contains the enable/disable toggle and the Restart Live Activity button. - APNSettingsView: restored to Key ID + Key only (no toggle or restart button) - LiveActivitySettingsView: new view with laEnabled toggle and restart button (with 2-second confirmation feedback); toggle stays in sync via onReceive - SettingsMenuView: "APN" entry restored; new "Live Activity" entry added with dot.radiowaves icon; Sheet.liveActivity case wired to LiveActivitySettingsView Co-Authored-By: Claude Sonnet 4.6 * Added Live Activity menu * chore: add LiveActivitySettingsView to Xcode project Co-Authored-By: Claude Sonnet 4.6 * fix: LA tap navigation, manual dismissal prevention, and toggle start - widgetURL on lock screen LA → opens app via loopfollow://la-tap - Link views in expanded DI regions → same URL on tap - AppDelegate.application(_:open:) handles loopfollow://la-tap and posts .liveActivityDidForeground so MainViewController navigates to Snoozer (if alarm active) or Home tab — fires only on LA/DI taps, not every foreground - Notification.Name.liveActivityDidForeground moved outside #if macCatalyst so AppDelegate can reference it unconditionally - handleDidBecomeActive no longer posts .liveActivityDidForeground (was firing on every app foreground, not just LA taps) - attachStateObserver: .dismissed state (user swipe) sets laEnabled = false so the LA does not auto-restart when the app foregrounds - LiveActivitySettingsView: toggling ON calls startFromCurrentState() Co-Authored-By: Claude Sonnet 4.6 * fix: end Live Activity on app force-quit Adds endOnTerminate() to LiveActivityManager — blocks up to 3 s using Task.detached + DispatchSemaphore so the async activity.end() completes before the process exits. Called from applicationWillTerminate so the LA clears from the lock screen immediately on force-quit. laEnabled is preserved so the LA restarts correctly on next launch. Co-Authored-By: Claude Sonnet 4.6 * fix: use dismissedByUser flag instead of disabling laEnabled on manual dismiss - Manual LA swipe-away sets dismissedByUser (in-memory) instead of laEnabled = false, preserving the user's preference - startFromCurrentState() guards on !dismissedByUser so the LA does not auto-restart on foreground, BG refresh, or handleDidBecomeActive - forceRestart() clears dismissedByUser before starting, so the Restart button and App Intent both work as the explicit re-enable mechanism - Toggle ON in settings calls forceRestart() (clears flag + starts) instead of startFromCurrentState() which would have been blocked by the flag - dismissedByUser resets to false on app relaunch (in-memory only), so a kill + relaunch starts the LA fresh as expected Co-Authored-By: Claude Sonnet 4.6 * fix: dismiss modal (Settings sheet) before tab switch on LA tap navigateOnLAForeground now checks tabBarController.presentedViewController and dismisses it before switching to the target tab, matching the existing pattern used elsewhere in MainViewController for the same scenario. Co-Authored-By: Claude Sonnet 4.6 * fix: LA tap navigation timing and LA reappear-after-dismiss Navigation fix: - loopfollow://la-tap URL can arrive before applicationDidBecomeActive while UIKit is still restoring the view hierarchy from background. At that moment tabBarController.presentedViewController is nil so the Settings modal is never dismissed and the navigation notification fires too early. - Now stores a pendingLATapNavigation flag if app is not yet .active, and fires the notification in applicationDidBecomeActive when the full view hierarchy (including presented modals) is restored. LA reappear fix: - performRefresh() calls startIfNeeded() directly, bypassing the dismissedByUser guard that only exists in startFromCurrentState(). On every BG refresh with laEnabled=true and current==nil (post-dismiss), it would recreate the LA. - Added !dismissedByUser guard to refreshFromCurrentState() so the entire refresh pipeline is skipped while dismissed, matching the existing guard in startFromCurrentState(). Co-Authored-By: Claude Sonnet 4.6 * fix: handle loopfollow://la-tap URL in SceneDelegate, not AppDelegate With scene-based lifecycle (iOS 13+), widget/LA tap URLs are delivered to scene(_:openURLContexts:) on UIWindowSceneDelegate — AppDelegate.application (_:open:url:options:) is never called. Our previous handler was dead code. SceneDelegate now implements scene(_:openURLContexts:) which posts .liveActivityDidForeground on the next run loop (via DispatchQueue.main.async) to let the view hierarchy fully settle before navigateOnLAForeground() runs. Also handles the edge case where the URL arrives before sceneDidBecomeActive by storing a pendingLATapNavigation flag. Co-Authored-By: Claude Sonnet 4.6 * feat: configurable LA grid slots + full InfoType snapshot coverage Lock screen layout: - Left column: glucose + trend arrow, delta below (replaces "Last Update:") - Right grid: 4 configurable slots read from LAAppGroupSettings (defaults: IOB/COB/Proj/empty) - Footer: centered HH:MM update time at reduced opacity Slot configuration: - LiveActivitySlotOption enum with all 22 InfoType-aligned cases - LAAppGroupSettings.setSlots() / slots() persisted to App Group UserDefaults - Uniqueness enforced: selecting an option clears it from any other slot - Settings UI: "Grid slots" section with 4 pickers in LiveActivitySettingsView - Changes take effect immediately (refreshFromCurrentState called on save) GlucoseSnapshot extended with 19 new fields covering all InfoType items: override, recBolus, battery, pumpBattery, basalRate, pumpReservoirU, autosens, tdd, targetLowMgdl/High, isfMgdlPerU, carbRatio, carbsToday, profileName, sageInsertTime, cageInsertTime, iageInsertTime, minBgMgdl/Max Storage.swift: 13 new UserDefaults-backed fields for the above metrics Controllers updated to write new Storage fields on each data fetch: Basals, DeviceStatus, DeviceStatusLoop, DeviceStatusOpenAPS, Carbs, Profile, IAge Co-Authored-By: Claude Sonnet 4.6 * fix: label delta and footer on lock screen LA card Co-Authored-By: Claude Sonnet 4.6 * docs: add PR description for configurable LA grid slots Co-Authored-By: Claude Sonnet 4.6 * Update PR_configurable_slots.md * chore: remove PR notes from tracking, keep docs/LiveActivity.md only - Untrack docs/PR_configurable_slots.md (local-only reference doc) - Add docs/PR_configurable_slots.md and docs/LiveActivityTestPlan.md to .gitignore Co-Authored-By: Claude Sonnet 4.6 * fix: include all extended InfoType fields in APNs push payload buildPayload() was only serializing the base fields (glucose, delta, trend, updatedAt, unit, iob, cob, projected). All extended fields added with the configurable grid slots (battery, sageInsertTime, pumpBattery, basalRate, autosens, tdd, targets, isf, carbRatio, carbsToday, profileName, CAGE, IAGE, minBg, maxBg, override, recBolus) were missing from the APNs payload, causing the extension to decode them as nil/0 and display '--' on every push-driven refresh. Co-Authored-By: Claude Sonnet 4.6 * feat: add small family view for CarPlay Dashboard and Watch Smart Stack Registers the Live Activity for the small activity family via .supplementalActivityFamilies([.small]), enabling automatic display on: - CarPlay Dashboard (iOS 26+) - Apple Watch Smart Stack (watchOS 11+) The small view is hardcoded to the essentials appropriate for a driving context: glucose value, trend arrow, delta, and time since last reading. Background tint matches the existing threshold-based color logic. The lock screen layout (full grid with configurable slots) is unchanged. Co-Authored-By: Claude Sonnet 4.6 * fix: guard CarPlay/Watch small family behind iOS 18 availability; increase renewal overlay opacity - supplementalActivityFamilies and activityFamily require iOS 18.0+; restructured into two Widget structs sharing a makeDynamicIsland() helper: - LoopFollowLiveActivityWidget (iOS 16.1+): lock screen + Dynamic Island - LoopFollowLiveActivityWidgetWithCarPlay (iOS 18.0+): adds .supplementalActivityFamilies([.small]) - Bundle uses if #available(iOS 18.0, *) to select the right variant - Podfile: set minimum deployment target to 16.6 for all pods - Increase 'Tap to update' renewal overlay opacity from 60% to 90% Co-Authored-By: Claude Sonnet 4.6 * fix: move if #available into Widget.body to avoid WidgetBundleBuilder limitation @WidgetBundleBuilder does not support if #available { } else { }, but @WidgetConfigurationBuilder (used by Widget.body) does. Collapsed back to a single LoopFollowLiveActivityWidget struct with the iOS 18 conditional inside body — iOS 18+ branch adds .supplementalActivityFamilies([.small]) for CarPlay and Watch Smart Stack; else branch uses the plain lock screen view. Bundle reverts to the original single if #available(iOS 16.1, *) pattern. Co-Authored-By: Claude Sonnet 4.6 * fix: use two separate single-branch if #available in bundle for CarPlay support @WidgetConfigurationBuilder's buildEither requires both branches to return the same concrete type, making if/else with supplementalActivityFamilies impossible (it wraps to a different opaque type). @WidgetBundleBuilder does not support if #available { } else { } at all. Solution: two separate single-branch if #available blocks in the bundle — the pattern that @WidgetBundleBuilder already supported in the original code: - LoopFollowLiveActivityWidget (iOS 16.1+): primary, lock screen + Dynamic Island - LoopFollowLiveActivityWidgetWithCarPlay (iOS 18.0+): supplemental, adds .supplementalActivityFamilies([.small]) for CarPlay Dashboard + Watch Smart Stack ActivityKit uses the supplemental widget for small-family surfaces and the primary widget for lock screen / Dynamic Island, keeping iOS 16.6+ support intact. Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Claude Sonnet 4.6 --- LoopFollowLAExtension/LoopFollowLiveActivity.swift | 4 ++-- Podfile | 9 +++++++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/LoopFollowLAExtension/LoopFollowLiveActivity.swift b/LoopFollowLAExtension/LoopFollowLiveActivity.swift index 354ea13df..ee535f952 100644 --- a/LoopFollowLAExtension/LoopFollowLiveActivity.swift +++ b/LoopFollowLAExtension/LoopFollowLiveActivity.swift @@ -213,7 +213,7 @@ private struct LockScreenLiveActivityView: View { .overlay( ZStack { RoundedRectangle(cornerRadius: 16, style: .continuous) - .fill(Color.gray.opacity(0.6)) + .fill(Color.gray.opacity(0.9)) Text("Tap to update") .font(.system(size: 20, weight: .semibold)) .foregroundStyle(.white) @@ -231,7 +231,7 @@ private struct RenewalOverlayView: View { var body: some View { ZStack { - Color.gray.opacity(0.6) + Color.gray.opacity(0.9) if showText { Text("Tap to update") .font(.system(size: 14, weight: .semibold)) diff --git a/Podfile b/Podfile index 5a8c0f868..f8c2df3b2 100644 --- a/Podfile +++ b/Podfile @@ -7,6 +7,15 @@ target 'LoopFollow' do end post_install do |installer| + # Set minimum deployment target for all pods to match the app (suppresses deprecation warnings) + installer.pods_project.targets.each do |target| + target.build_configurations.each do |config| + if config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'].to_f < 16.6 + config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '16.6' + end + end + end + # Patch Charts Transformer to avoid "CGAffineTransformInvert: singular matrix" # warnings when chart views have zero dimensions (before layout). transformer = 'Pods/Charts/Source/Charts/Utils/Transformer.swift' From 98de41692b2abdef1c06b57efc6d27a516daaf4f Mon Sep 17 00:00:00 2001 From: MtlPhil <76601115+MtlPhil@users.noreply.github.com> Date: Mon, 16 Mar 2026 11:41:41 -0400 Subject: [PATCH 54/82] fix: restore two-widget bundle; guard supplementalActivityFamilies and activityFamily behind iOS 18 Upstream's single-widget approach placed iOS 18+ APIs (supplementalActivityFamilies, activityFamily, ActivityFamily) behind @available(iOS 16.1, *), which fails to compile at the 16.6 deployment target. Restoring the two-widget pattern: - LoopFollowLiveActivityWidget (@available iOS 16.1): lock screen + DI, uses LockScreenLiveActivityView, no supplementalActivityFamilies - LoopFollowLiveActivityWidgetWithCarPlay (@available iOS 18.0): adds CarPlay Dashboard + Watch Smart Stack via supplementalActivityFamilies([.small]), uses LockScreenFamilyAdaptiveView (also @available iOS 18.0) - SmallFamilyView availability corrected to @available(iOS 18.0, *) - Bundle registers both via separate if #available blocks Co-Authored-By: Claude Sonnet 4.6 --- .../LoopFollowLABundle.swift | 3 + .../LoopFollowLiveActivity.swift | 98 ++++++++++++------- 2 files changed, 63 insertions(+), 38 deletions(-) diff --git a/LoopFollowLAExtension/LoopFollowLABundle.swift b/LoopFollowLAExtension/LoopFollowLABundle.swift index e3a043783..a9f7daf6c 100644 --- a/LoopFollowLAExtension/LoopFollowLABundle.swift +++ b/LoopFollowLAExtension/LoopFollowLABundle.swift @@ -14,5 +14,8 @@ struct LoopFollowLABundle: WidgetBundle { if #available(iOS 16.1, *) { LoopFollowLiveActivityWidget() } + if #available(iOS 18.0, *) { + LoopFollowLiveActivityWidgetWithCarPlay() + } } } diff --git a/LoopFollowLAExtension/LoopFollowLiveActivity.swift b/LoopFollowLAExtension/LoopFollowLiveActivity.swift index ee535f952..a70008414 100644 --- a/LoopFollowLAExtension/LoopFollowLiveActivity.swift +++ b/LoopFollowLAExtension/LoopFollowLiveActivity.swift @@ -5,49 +5,71 @@ import ActivityKit import SwiftUI import WidgetKit +/// Builds the shared Dynamic Island content used by both widget variants. +@available(iOS 16.1, *) +private func makeDynamicIsland(context: ActivityViewContext) -> DynamicIsland { + DynamicIsland { + DynamicIslandExpandedRegion(.leading) { + Link(destination: URL(string: "loopfollow://la-tap")!) { + DynamicIslandLeadingView(snapshot: context.state.snapshot) + .overlay(RenewalOverlayView(show: context.state.snapshot.showRenewalOverlay)) + } + .id(context.state.seq) + } + DynamicIslandExpandedRegion(.trailing) { + Link(destination: URL(string: "loopfollow://la-tap")!) { + DynamicIslandTrailingView(snapshot: context.state.snapshot) + .overlay(RenewalOverlayView(show: context.state.snapshot.showRenewalOverlay)) + } + .id(context.state.seq) + } + DynamicIslandExpandedRegion(.bottom) { + Link(destination: URL(string: "loopfollow://la-tap")!) { + DynamicIslandBottomView(snapshot: context.state.snapshot) + .overlay(RenewalOverlayView(show: context.state.snapshot.showRenewalOverlay, showText: true)) + } + .id(context.state.seq) + } + } compactLeading: { + DynamicIslandCompactLeadingView(snapshot: context.state.snapshot) + .id(context.state.seq) + } compactTrailing: { + DynamicIslandCompactTrailingView(snapshot: context.state.snapshot) + .id(context.state.seq) + } minimal: { + DynamicIslandMinimalView(snapshot: context.state.snapshot) + .id(context.state.seq) + } + .keylineTint(LAColors.keyline(for: context.state.snapshot).opacity(0.75)) +} + +/// Primary widget (iOS 16.1+) — Lock Screen + Dynamic Island for all iOS versions. @available(iOS 16.1, *) struct LoopFollowLiveActivityWidget: Widget { var body: some WidgetConfiguration { ActivityConfiguration(for: GlucoseLiveActivityAttributes.self) { context in - // LOCK SCREEN / BANNER UI — also used for CarPlay Dashboard and Watch Smart Stack - // (small family) via supplementalActivityFamilies([.small]) + LockScreenLiveActivityView(state: context.state) + .id(context.state.seq) + .activitySystemActionForegroundColor(.white) + .activityBackgroundTint(LAColors.backgroundTint(for: context.state.snapshot)) + .applyActivityContentMarginsFixIfAvailable() + .widgetURL(URL(string: "loopfollow://la-tap")!) + } dynamicIsland: { context in + makeDynamicIsland(context: context) + } + } +} + +/// Supplemental widget (iOS 18.0+) — adds CarPlay Dashboard + Watch Smart Stack +/// via supplementalActivityFamilies([.small]). +@available(iOS 18.0, *) +struct LoopFollowLiveActivityWidgetWithCarPlay: Widget { + var body: some WidgetConfiguration { + ActivityConfiguration(for: GlucoseLiveActivityAttributes.self) { context in LockScreenFamilyAdaptiveView(state: context.state) .id(context.state.seq) } dynamicIsland: { context in - // DYNAMIC ISLAND UI - DynamicIsland { - DynamicIslandExpandedRegion(.leading) { - Link(destination: URL(string: "loopfollow://la-tap")!) { - DynamicIslandLeadingView(snapshot: context.state.snapshot) - .overlay(RenewalOverlayView(show: context.state.snapshot.showRenewalOverlay)) - } - .id(context.state.seq) - } - DynamicIslandExpandedRegion(.trailing) { - Link(destination: URL(string: "loopfollow://la-tap")!) { - DynamicIslandTrailingView(snapshot: context.state.snapshot) - .overlay(RenewalOverlayView(show: context.state.snapshot.showRenewalOverlay)) - } - .id(context.state.seq) - } - DynamicIslandExpandedRegion(.bottom) { - Link(destination: URL(string: "loopfollow://la-tap")!) { - DynamicIslandBottomView(snapshot: context.state.snapshot) - .overlay(RenewalOverlayView(show: context.state.snapshot.showRenewalOverlay, showText: true)) - } - .id(context.state.seq) - } - } compactLeading: { - DynamicIslandCompactLeadingView(snapshot: context.state.snapshot) - .id(context.state.seq) - } compactTrailing: { - DynamicIslandCompactTrailingView(snapshot: context.state.snapshot) - .id(context.state.seq) - } minimal: { - DynamicIslandMinimalView(snapshot: context.state.snapshot) - .id(context.state.seq) - } - .keylineTint(LAColors.keyline(for: context.state.snapshot).opacity(0.75)) + makeDynamicIsland(context: context) } .supplementalActivityFamilies([.small]) } @@ -72,7 +94,7 @@ private extension View { /// Reads the activityFamily environment value and routes to the appropriate layout. /// - `.small` → CarPlay Dashboard & Watch Smart Stack: compact glucose-only view /// - everything else → full lock screen layout with configurable grid -@available(iOS 16.1, *) +@available(iOS 18.0, *) private struct LockScreenFamilyAdaptiveView: View { let state: GlucoseLiveActivityAttributes.ContentState @@ -95,7 +117,7 @@ private struct LockScreenFamilyAdaptiveView: View { /// Compact view shown on CarPlay Dashboard (iOS 26+) and Apple Watch Smart Stack (watchOS 11+). /// Hardcoded to glucose + trend arrow + delta + time since last reading. -@available(iOS 16.1, *) +@available(iOS 18.0, *) private struct SmallFamilyView: View { let snapshot: GlucoseSnapshot From e8daddab6a4ad550890ecfe931653ffc427ed047 Mon Sep 17 00:00:00 2001 From: MtlPhil <76601115+MtlPhil@users.noreply.github.com> Date: Mon, 16 Mar 2026 11:51:41 -0400 Subject: [PATCH 55/82] fix: extension version inherits from parent; remove spurious await in slot config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - LoopFollowLAExtension MARKETING_VERSION now uses "$(MARKETING_VERSION)" to match the parent app version automatically, resolving CFBundleShortVersionString mismatch warning - Remove unnecessary Task/await wrapping of refreshFromCurrentState in LiveActivitySettingsView — the method is not async Co-Authored-By: Claude Sonnet 4.6 --- LoopFollow.xcodeproj/project.pbxproj | 4 ++-- LoopFollow/LiveActivitySettingsView.swift | 4 +--- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/LoopFollow.xcodeproj/project.pbxproj b/LoopFollow.xcodeproj/project.pbxproj index 795346767..34c2b838c 100644 --- a/LoopFollow.xcodeproj/project.pbxproj +++ b/LoopFollow.xcodeproj/project.pbxproj @@ -2413,7 +2413,7 @@ ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MACOSX_DEPLOYMENT_TARGET = 26.2; - MARKETING_VERSION = 1.0; + MARKETING_VERSION = "$(MARKETING_VERSION)"; PRODUCT_BUNDLE_IDENTIFIER = "com.$(unique_id).LoopFollow$(app_suffix).LoopFollowLAExtension"; PRODUCT_MODULE_NAME = LoopFollowLAExtension; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -2465,7 +2465,7 @@ ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MACOSX_DEPLOYMENT_TARGET = 26.2; - MARKETING_VERSION = 1.0; + MARKETING_VERSION = "$(MARKETING_VERSION)"; PRODUCT_BUNDLE_IDENTIFIER = "com.$(unique_id).LoopFollow$(app_suffix).LoopFollowLAExtension"; PRODUCT_MODULE_NAME = LoopFollowLAExtension; PRODUCT_NAME = "$(TARGET_NAME)"; diff --git a/LoopFollow/LiveActivitySettingsView.swift b/LoopFollow/LiveActivitySettingsView.swift index 99dbc13e6..efe4ec321 100644 --- a/LoopFollow/LiveActivitySettingsView.swift +++ b/LoopFollow/LiveActivitySettingsView.swift @@ -68,8 +68,6 @@ struct LiveActivitySettingsView: View { } slots[index] = option LAAppGroupSettings.setSlots(slots) - Task { - await LiveActivityManager.shared.refreshFromCurrentState(reason: "slot config changed") - } + LiveActivityManager.shared.refreshFromCurrentState(reason: "slot config changed") } } From 9f9229abb1235415a516789cebabaa6c108786c5 Mon Sep 17 00:00:00 2001 From: Phil A <76601115+MtlPhil@users.noreply.github.com> Date: Mon, 16 Mar 2026 14:30:34 -0400 Subject: [PATCH 56/82] Live Activity: fix iOS 18 availability guards, extension version, and minor warnings (#550) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: Live Activity auto-renewal to work around 8-hour system limit Co-Authored-By: Claude Sonnet 4.6 * test: reduce LA renewal threshold to 20 min for testing Co-Authored-By: Claude Sonnet 4.6 * feat: improve LA renewal robustness and stale indicator - staleDate on every ActivityContent now tracks the renewal deadline, so the system shows Apple's built-in stale indicator if renewal fails - Add laRenewalFailed StorageValue; set on Activity.request() failure, cleared on any successful LA start - Observe willEnterForegroundNotification: retry startIfNeeded() if a previous renewal attempt failed - New-first renewal order: request the replacement LA before ending the old one — if the request throws the existing LA stays alive so the user keeps live data until the system kills it at the 8-hour mark Co-Authored-By: Claude Sonnet 4.6 * feat: renewal warning overlay + restore 7.5h threshold - Restore renewalThreshold to 7.5 * 3600 (testing complete) - Add showRenewalOverlay: Bool to GlucoseSnapshot (Codable, default false) - GlucoseSnapshotBuilder sets it true when now >= laRenewBy - 1800 (30 minutes before the renewal deadline) - Lock screen: 60% gray overlay with "Tap to update" centered in white, layered above the existing isNotLooping overlay - DI expanded: RenewalOverlayView applied to leading/trailing/bottom regions; "Tap to update" text shown on the bottom region only - showRenewalOverlay resets to false automatically on renewal since laRenewBy is updated and the next snapshot rebuild clears the flag Co-Authored-By: Claude Sonnet 4.6 * fix: overlay not appearing + foreground restart not working Overlay rendering: - Replace Group{if condition{ZStack{RoundedRectangle...}}} with a permanently-present ZStack toggled via .opacity(). The Group/if pattern causes SwiftUI sizing ambiguity when the condition transitions from false→true inside an .overlay(), producing a zero-size result. The .opacity() approach keeps a stable view hierarchy. - Same fix applied to RenewalOverlayView used on DI expanded regions. Foreground restart: - handleForeground() was calling startIfNeeded(), which finds the still-alive (failed-to-renew) LA in Activity.activities and reuses it, doing nothing useful. Fixed to manually nil out current, cancel all tasks, await activity.end(.immediate), then startFromCurrentState(). Overlay timing: - Changed warning window from 30 min (1800s) to 20 min (1200s) before the renewal deadline, matching the intended test cadence. Logging: - handleForeground: log on every foreground event with laRenewalFailed value - renewIfNeeded: log how many seconds past the deadline when firing - GlucoseSnapshotBuilder: log when overlay activates with seconds to deadline - performRefresh: log when sending an update with the overlay visible Co-Authored-By: Claude Sonnet 4.6 * test: set renewalThreshold to 20 min for testing Co-Authored-By: Claude Sonnet 4.6 * fix: renewal overlay not clearing after LA is refreshed Normal renewal path (renewIfNeeded success): - Build a fresh snapshot with showRenewalOverlay: false for the new LA's initial content — it has a new deadline so the overlay should never be visible from the first frame. - Save that fresh snapshot to GlucoseSnapshotStore so the next duplicate check has the correct baseline and doesn't suppress the first real BG update. Foreground restart path (handleForeground): - Zero laRenewBy before calling startFromCurrentState() so GlucoseSnapshotBuilder computes showRenewalOverlay = false for the seed snapshot. startIfNeeded() then sets the new deadline after the request succeeds. Co-Authored-By: Claude Sonnet 4.6 * fix: overlay permanently active when warning window equals threshold With renewalThreshold=20min and the hardcoded 1200s warning window, renewBy-1200 = start, so showRenewalOverlay is always true from the moment the LA begins. Extract a named renewalWarning constant (5 min for testing) so the warning window is always less than the threshold. The builder now reads LiveActivityManager.renewalWarning instead of a hardcoded literal. Production values to restore before merging: renewalThreshold = 7.5 * 3600 renewalWarning = 20 * 60 Co-Authored-By: Claude Sonnet 4.6 * fix: include showRenewalOverlay in APNs payload and clear laRenewBy synchronously APNSClient was missing showRenewalOverlay from the push payload, so background APNs updates never delivered the overlay flag to the extension — only foreground direct ActivityKit updates did. In handleForeground, laRenewBy is now zeroed synchronously before spawning the async end/restart Task. This means any snapshot built between the foreground notification and the new LA start (e.g. from viewDidAppear's startFromCurrentState) computes showRenewalOverlay = false rather than reading the stale expired deadline. Co-Authored-By: Claude Sonnet 4.6 * fix: await LA end before restarting on foreground retry to avoid reuse path Reset laRenewBy and laRenewalFailed synchronously before tearing down the failed LA, then await activity.end() before calling startFromCurrentState(). This guarantees Activity.activities is clear when startIfNeeded() runs, so it takes the fresh-request path and writes a new laRenewBy. startFromCurrentState rebuilds the snapshot with showRenewalOverlay=false (laRenewBy=0), saves it to the store, then startIfNeeded uses that clean snapshot as the seed. Co-Authored-By: Claude Sonnet 4.6 * chore: restore production renewal timing (7.5h threshold, 20min warning) Co-Authored-By: Claude Sonnet 4.6 * feat: laEnabled toggle, forceRestart(), and RestartLiveActivityIntent - SettingsMenuView: rename "APN" menu entry to "Live Activity" - Storage: add laEnabled: Bool StorageValue (default false) - APNSettingsView: on/off toggle bound to laEnabled; APNs key fields and Restart button shown only when enabled; disabling immediately ends any running LA - LiveActivityManager: - forceRestart() (@MainActor) ends all running activities, resets laRenewBy and laRenewalFailed, then calls startFromCurrentState() - laEnabled guard added to startFromCurrentState(), refreshFromCurrentState(), handleForeground(), and handleDidBecomeActive() - didBecomeActiveNotification observer calls forceRestart() on every foreground transition when laEnabled is true - RestartLiveActivityIntent: AppIntent + ForegroundContinuableIntent; sets laEnabled, validates credentials, opens settings deep link if missing, otherwise calls forceRestart() in foreground; LoopFollowAppShortcuts exposes the intent with Siri phrase Note: RestartLiveActivityIntent.swift must be added to the app target in Xcode. Co-Authored-By: Claude Sonnet 4.6 * Added RestartLiveActivityIntent to project * fix: resolve two build errors in LiveActivityManager and RestartLiveActivityIntent - LiveActivityManager: handleDidBecomeActive() calls @MainActor forceRestart() via Task { @MainActor in ... } to satisfy Swift concurrency isolation - RestartLiveActivityIntent: drop ForegroundContinuableIntent — continueInForeground() was renamed to continueInForeground(_:alwaysConfirm:) in the iOS 26 SDK and requires iOS 26+; the didBecomeActiveNotification observer handles restart when the app comes to foreground, making explicit continuation unnecessary Co-Authored-By: Claude Sonnet 4.6 * fix: guard continueInForeground() behind iOS 26 availability check continueInForeground(_:alwaysConfirm:) is iOS 26+ only. On earlier versions, forceRestart() runs directly via the existing background audio session — no foreground continuation needed. Co-Authored-By: Claude Sonnet 4.6 * fix: use startFromCurrentState in handleDidBecomeActive instead of forceRestart forceRestart() was killing the LA that the RestartLiveActivityIntent had just created, because continueInForeground() triggers didBecomeActive. startFromCurrentState() reuses an existing active LA via startIfNeeded() and only creates one if none is present — no destructive race. Co-Authored-By: Claude Sonnet 4.6 * feat: LA foreground tab navigation, button feedback, and toggle sync APNSettingsView: - "Restart Live Activity" button shows "Live Activity Restarted" for 2 seconds and disables itself during that window to prevent double-taps - Toggle observes Storage.shared.laEnabled via onReceive so it reflects changes made externally (e.g. by the App Intent / Shortcuts) LiveActivityManager: - handleDidBecomeActive posts .liveActivityDidForeground after startFromCurrentState - Notification.Name.liveActivityDidForeground extension defined here MainViewController: - Observes .liveActivityDidForeground; navigates to Snoozer tab if an alarm is currently active, otherwise navigates to tab 0 (Home) Co-Authored-By: Claude Sonnet 4.6 * fix: flush LA update on willResignActive to ensure lock screen shows latest data The 5-second debounce on refreshFromCurrentState means a BG update arriving while the app is foreground won't reach the LA before the user returns to the lock screen. When the debounce fires in background, isForeground=false so the direct ActivityKit update is skipped. Fix: observe willResignActiveNotification, cancel the pending debounce, and immediately push the latest snapshot via direct ActivityKit update while the app is still foreground-active. The APNs push is also sent as a backup. Co-Authored-By: Claude Sonnet 4.6 * feat: redesign Dynamic Island compact and expanded views Compact: - Trailing now shows delta instead of trend arrow (leading already showed BG) Expanded leading: - Large BG value at top - Second row: trend arrow, delta, and "Proj: X" in smaller text below Expanded trailing: - IOB and COB (moved from bottom) Expanded bottom: - "Updated at: HH:mm" (moved from trailing) Co-Authored-By: Claude Sonnet 4.6 * fix: match Proj text style to delta; add trailing padding to IOB/COB - Proj label now uses same size (13), weight (semibold), and opacity (0.9) as the delta text beside it - Added 6pt trailing padding to the expanded DI trailing VStack so the IOB decimal is not clipped by the Dynamic Island edge Co-Authored-By: Claude Sonnet 4.6 * feat: separate Live Activity and APN settings into distinct menus Per maintainer preference, the APNs credentials (Key ID and Key) remain in their own "APN" settings screen. A new separate "Live Activity" settings screen contains the enable/disable toggle and the Restart Live Activity button. - APNSettingsView: restored to Key ID + Key only (no toggle or restart button) - LiveActivitySettingsView: new view with laEnabled toggle and restart button (with 2-second confirmation feedback); toggle stays in sync via onReceive - SettingsMenuView: "APN" entry restored; new "Live Activity" entry added with dot.radiowaves icon; Sheet.liveActivity case wired to LiveActivitySettingsView Co-Authored-By: Claude Sonnet 4.6 * Added Live Activity menu * chore: add LiveActivitySettingsView to Xcode project Co-Authored-By: Claude Sonnet 4.6 * fix: LA tap navigation, manual dismissal prevention, and toggle start - widgetURL on lock screen LA → opens app via loopfollow://la-tap - Link views in expanded DI regions → same URL on tap - AppDelegate.application(_:open:) handles loopfollow://la-tap and posts .liveActivityDidForeground so MainViewController navigates to Snoozer (if alarm active) or Home tab — fires only on LA/DI taps, not every foreground - Notification.Name.liveActivityDidForeground moved outside #if macCatalyst so AppDelegate can reference it unconditionally - handleDidBecomeActive no longer posts .liveActivityDidForeground (was firing on every app foreground, not just LA taps) - attachStateObserver: .dismissed state (user swipe) sets laEnabled = false so the LA does not auto-restart when the app foregrounds - LiveActivitySettingsView: toggling ON calls startFromCurrentState() Co-Authored-By: Claude Sonnet 4.6 * fix: end Live Activity on app force-quit Adds endOnTerminate() to LiveActivityManager — blocks up to 3 s using Task.detached + DispatchSemaphore so the async activity.end() completes before the process exits. Called from applicationWillTerminate so the LA clears from the lock screen immediately on force-quit. laEnabled is preserved so the LA restarts correctly on next launch. Co-Authored-By: Claude Sonnet 4.6 * fix: use dismissedByUser flag instead of disabling laEnabled on manual dismiss - Manual LA swipe-away sets dismissedByUser (in-memory) instead of laEnabled = false, preserving the user's preference - startFromCurrentState() guards on !dismissedByUser so the LA does not auto-restart on foreground, BG refresh, or handleDidBecomeActive - forceRestart() clears dismissedByUser before starting, so the Restart button and App Intent both work as the explicit re-enable mechanism - Toggle ON in settings calls forceRestart() (clears flag + starts) instead of startFromCurrentState() which would have been blocked by the flag - dismissedByUser resets to false on app relaunch (in-memory only), so a kill + relaunch starts the LA fresh as expected Co-Authored-By: Claude Sonnet 4.6 * fix: dismiss modal (Settings sheet) before tab switch on LA tap navigateOnLAForeground now checks tabBarController.presentedViewController and dismisses it before switching to the target tab, matching the existing pattern used elsewhere in MainViewController for the same scenario. Co-Authored-By: Claude Sonnet 4.6 * fix: LA tap navigation timing and LA reappear-after-dismiss Navigation fix: - loopfollow://la-tap URL can arrive before applicationDidBecomeActive while UIKit is still restoring the view hierarchy from background. At that moment tabBarController.presentedViewController is nil so the Settings modal is never dismissed and the navigation notification fires too early. - Now stores a pendingLATapNavigation flag if app is not yet .active, and fires the notification in applicationDidBecomeActive when the full view hierarchy (including presented modals) is restored. LA reappear fix: - performRefresh() calls startIfNeeded() directly, bypassing the dismissedByUser guard that only exists in startFromCurrentState(). On every BG refresh with laEnabled=true and current==nil (post-dismiss), it would recreate the LA. - Added !dismissedByUser guard to refreshFromCurrentState() so the entire refresh pipeline is skipped while dismissed, matching the existing guard in startFromCurrentState(). Co-Authored-By: Claude Sonnet 4.6 * fix: handle loopfollow://la-tap URL in SceneDelegate, not AppDelegate With scene-based lifecycle (iOS 13+), widget/LA tap URLs are delivered to scene(_:openURLContexts:) on UIWindowSceneDelegate — AppDelegate.application (_:open:url:options:) is never called. Our previous handler was dead code. SceneDelegate now implements scene(_:openURLContexts:) which posts .liveActivityDidForeground on the next run loop (via DispatchQueue.main.async) to let the view hierarchy fully settle before navigateOnLAForeground() runs. Also handles the edge case where the URL arrives before sceneDidBecomeActive by storing a pendingLATapNavigation flag. Co-Authored-By: Claude Sonnet 4.6 * feat: configurable LA grid slots + full InfoType snapshot coverage Lock screen layout: - Left column: glucose + trend arrow, delta below (replaces "Last Update:") - Right grid: 4 configurable slots read from LAAppGroupSettings (defaults: IOB/COB/Proj/empty) - Footer: centered HH:MM update time at reduced opacity Slot configuration: - LiveActivitySlotOption enum with all 22 InfoType-aligned cases - LAAppGroupSettings.setSlots() / slots() persisted to App Group UserDefaults - Uniqueness enforced: selecting an option clears it from any other slot - Settings UI: "Grid slots" section with 4 pickers in LiveActivitySettingsView - Changes take effect immediately (refreshFromCurrentState called on save) GlucoseSnapshot extended with 19 new fields covering all InfoType items: override, recBolus, battery, pumpBattery, basalRate, pumpReservoirU, autosens, tdd, targetLowMgdl/High, isfMgdlPerU, carbRatio, carbsToday, profileName, sageInsertTime, cageInsertTime, iageInsertTime, minBgMgdl/Max Storage.swift: 13 new UserDefaults-backed fields for the above metrics Controllers updated to write new Storage fields on each data fetch: Basals, DeviceStatus, DeviceStatusLoop, DeviceStatusOpenAPS, Carbs, Profile, IAge Co-Authored-By: Claude Sonnet 4.6 * fix: label delta and footer on lock screen LA card Co-Authored-By: Claude Sonnet 4.6 * docs: add PR description for configurable LA grid slots Co-Authored-By: Claude Sonnet 4.6 * Update PR_configurable_slots.md * chore: remove PR notes from tracking, keep docs/LiveActivity.md only - Untrack docs/PR_configurable_slots.md (local-only reference doc) - Add docs/PR_configurable_slots.md and docs/LiveActivityTestPlan.md to .gitignore Co-Authored-By: Claude Sonnet 4.6 * fix: include all extended InfoType fields in APNs push payload buildPayload() was only serializing the base fields (glucose, delta, trend, updatedAt, unit, iob, cob, projected). All extended fields added with the configurable grid slots (battery, sageInsertTime, pumpBattery, basalRate, autosens, tdd, targets, isf, carbRatio, carbsToday, profileName, CAGE, IAGE, minBg, maxBg, override, recBolus) were missing from the APNs payload, causing the extension to decode them as nil/0 and display '--' on every push-driven refresh. Co-Authored-By: Claude Sonnet 4.6 * feat: add small family view for CarPlay Dashboard and Watch Smart Stack Registers the Live Activity for the small activity family via .supplementalActivityFamilies([.small]), enabling automatic display on: - CarPlay Dashboard (iOS 26+) - Apple Watch Smart Stack (watchOS 11+) The small view is hardcoded to the essentials appropriate for a driving context: glucose value, trend arrow, delta, and time since last reading. Background tint matches the existing threshold-based color logic. The lock screen layout (full grid with configurable slots) is unchanged. Co-Authored-By: Claude Sonnet 4.6 * fix: guard CarPlay/Watch small family behind iOS 18 availability; increase renewal overlay opacity - supplementalActivityFamilies and activityFamily require iOS 18.0+; restructured into two Widget structs sharing a makeDynamicIsland() helper: - LoopFollowLiveActivityWidget (iOS 16.1+): lock screen + Dynamic Island - LoopFollowLiveActivityWidgetWithCarPlay (iOS 18.0+): adds .supplementalActivityFamilies([.small]) - Bundle uses if #available(iOS 18.0, *) to select the right variant - Podfile: set minimum deployment target to 16.6 for all pods - Increase 'Tap to update' renewal overlay opacity from 60% to 90% Co-Authored-By: Claude Sonnet 4.6 * fix: move if #available into Widget.body to avoid WidgetBundleBuilder limitation @WidgetBundleBuilder does not support if #available { } else { }, but @WidgetConfigurationBuilder (used by Widget.body) does. Collapsed back to a single LoopFollowLiveActivityWidget struct with the iOS 18 conditional inside body — iOS 18+ branch adds .supplementalActivityFamilies([.small]) for CarPlay and Watch Smart Stack; else branch uses the plain lock screen view. Bundle reverts to the original single if #available(iOS 16.1, *) pattern. Co-Authored-By: Claude Sonnet 4.6 * fix: use two separate single-branch if #available in bundle for CarPlay support @WidgetConfigurationBuilder's buildEither requires both branches to return the same concrete type, making if/else with supplementalActivityFamilies impossible (it wraps to a different opaque type). @WidgetBundleBuilder does not support if #available { } else { } at all. Solution: two separate single-branch if #available blocks in the bundle — the pattern that @WidgetBundleBuilder already supported in the original code: - LoopFollowLiveActivityWidget (iOS 16.1+): primary, lock screen + Dynamic Island - LoopFollowLiveActivityWidgetWithCarPlay (iOS 18.0+): supplemental, adds .supplementalActivityFamilies([.small]) for CarPlay Dashboard + Watch Smart Stack ActivityKit uses the supplemental widget for small-family surfaces and the primary widget for lock screen / Dynamic Island, keeping iOS 16.6+ support intact. Co-Authored-By: Claude Sonnet 4.6 * fix: restore two-widget bundle; guard supplementalActivityFamilies and activityFamily behind iOS 18 Upstream's single-widget approach placed iOS 18+ APIs (supplementalActivityFamilies, activityFamily, ActivityFamily) behind @available(iOS 16.1, *), which fails to compile at the 16.6 deployment target. Restoring the two-widget pattern: - LoopFollowLiveActivityWidget (@available iOS 16.1): lock screen + DI, uses LockScreenLiveActivityView, no supplementalActivityFamilies - LoopFollowLiveActivityWidgetWithCarPlay (@available iOS 18.0): adds CarPlay Dashboard + Watch Smart Stack via supplementalActivityFamilies([.small]), uses LockScreenFamilyAdaptiveView (also @available iOS 18.0) - SmallFamilyView availability corrected to @available(iOS 18.0, *) - Bundle registers both via separate if #available blocks Co-Authored-By: Claude Sonnet 4.6 * fix: extension version inherits from parent; remove spurious await in slot config - LoopFollowLAExtension MARKETING_VERSION now uses "$(MARKETING_VERSION)" to match the parent app version automatically, resolving CFBundleShortVersionString mismatch warning - Remove unnecessary Task/await wrapping of refreshFromCurrentState in LiveActivitySettingsView — the method is not async Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Claude Sonnet 4.6 --- LoopFollow.xcodeproj/project.pbxproj | 4 +- LoopFollow/LiveActivitySettingsView.swift | 4 +- .../LoopFollowLABundle.swift | 3 + .../LoopFollowLiveActivity.swift | 98 ++++++++++++------- 4 files changed, 66 insertions(+), 43 deletions(-) diff --git a/LoopFollow.xcodeproj/project.pbxproj b/LoopFollow.xcodeproj/project.pbxproj index 795346767..34c2b838c 100644 --- a/LoopFollow.xcodeproj/project.pbxproj +++ b/LoopFollow.xcodeproj/project.pbxproj @@ -2413,7 +2413,7 @@ ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MACOSX_DEPLOYMENT_TARGET = 26.2; - MARKETING_VERSION = 1.0; + MARKETING_VERSION = "$(MARKETING_VERSION)"; PRODUCT_BUNDLE_IDENTIFIER = "com.$(unique_id).LoopFollow$(app_suffix).LoopFollowLAExtension"; PRODUCT_MODULE_NAME = LoopFollowLAExtension; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -2465,7 +2465,7 @@ ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MACOSX_DEPLOYMENT_TARGET = 26.2; - MARKETING_VERSION = 1.0; + MARKETING_VERSION = "$(MARKETING_VERSION)"; PRODUCT_BUNDLE_IDENTIFIER = "com.$(unique_id).LoopFollow$(app_suffix).LoopFollowLAExtension"; PRODUCT_MODULE_NAME = LoopFollowLAExtension; PRODUCT_NAME = "$(TARGET_NAME)"; diff --git a/LoopFollow/LiveActivitySettingsView.swift b/LoopFollow/LiveActivitySettingsView.swift index 99dbc13e6..efe4ec321 100644 --- a/LoopFollow/LiveActivitySettingsView.swift +++ b/LoopFollow/LiveActivitySettingsView.swift @@ -68,8 +68,6 @@ struct LiveActivitySettingsView: View { } slots[index] = option LAAppGroupSettings.setSlots(slots) - Task { - await LiveActivityManager.shared.refreshFromCurrentState(reason: "slot config changed") - } + LiveActivityManager.shared.refreshFromCurrentState(reason: "slot config changed") } } diff --git a/LoopFollowLAExtension/LoopFollowLABundle.swift b/LoopFollowLAExtension/LoopFollowLABundle.swift index e3a043783..a9f7daf6c 100644 --- a/LoopFollowLAExtension/LoopFollowLABundle.swift +++ b/LoopFollowLAExtension/LoopFollowLABundle.swift @@ -14,5 +14,8 @@ struct LoopFollowLABundle: WidgetBundle { if #available(iOS 16.1, *) { LoopFollowLiveActivityWidget() } + if #available(iOS 18.0, *) { + LoopFollowLiveActivityWidgetWithCarPlay() + } } } diff --git a/LoopFollowLAExtension/LoopFollowLiveActivity.swift b/LoopFollowLAExtension/LoopFollowLiveActivity.swift index ee535f952..a70008414 100644 --- a/LoopFollowLAExtension/LoopFollowLiveActivity.swift +++ b/LoopFollowLAExtension/LoopFollowLiveActivity.swift @@ -5,49 +5,71 @@ import ActivityKit import SwiftUI import WidgetKit +/// Builds the shared Dynamic Island content used by both widget variants. +@available(iOS 16.1, *) +private func makeDynamicIsland(context: ActivityViewContext) -> DynamicIsland { + DynamicIsland { + DynamicIslandExpandedRegion(.leading) { + Link(destination: URL(string: "loopfollow://la-tap")!) { + DynamicIslandLeadingView(snapshot: context.state.snapshot) + .overlay(RenewalOverlayView(show: context.state.snapshot.showRenewalOverlay)) + } + .id(context.state.seq) + } + DynamicIslandExpandedRegion(.trailing) { + Link(destination: URL(string: "loopfollow://la-tap")!) { + DynamicIslandTrailingView(snapshot: context.state.snapshot) + .overlay(RenewalOverlayView(show: context.state.snapshot.showRenewalOverlay)) + } + .id(context.state.seq) + } + DynamicIslandExpandedRegion(.bottom) { + Link(destination: URL(string: "loopfollow://la-tap")!) { + DynamicIslandBottomView(snapshot: context.state.snapshot) + .overlay(RenewalOverlayView(show: context.state.snapshot.showRenewalOverlay, showText: true)) + } + .id(context.state.seq) + } + } compactLeading: { + DynamicIslandCompactLeadingView(snapshot: context.state.snapshot) + .id(context.state.seq) + } compactTrailing: { + DynamicIslandCompactTrailingView(snapshot: context.state.snapshot) + .id(context.state.seq) + } minimal: { + DynamicIslandMinimalView(snapshot: context.state.snapshot) + .id(context.state.seq) + } + .keylineTint(LAColors.keyline(for: context.state.snapshot).opacity(0.75)) +} + +/// Primary widget (iOS 16.1+) — Lock Screen + Dynamic Island for all iOS versions. @available(iOS 16.1, *) struct LoopFollowLiveActivityWidget: Widget { var body: some WidgetConfiguration { ActivityConfiguration(for: GlucoseLiveActivityAttributes.self) { context in - // LOCK SCREEN / BANNER UI — also used for CarPlay Dashboard and Watch Smart Stack - // (small family) via supplementalActivityFamilies([.small]) + LockScreenLiveActivityView(state: context.state) + .id(context.state.seq) + .activitySystemActionForegroundColor(.white) + .activityBackgroundTint(LAColors.backgroundTint(for: context.state.snapshot)) + .applyActivityContentMarginsFixIfAvailable() + .widgetURL(URL(string: "loopfollow://la-tap")!) + } dynamicIsland: { context in + makeDynamicIsland(context: context) + } + } +} + +/// Supplemental widget (iOS 18.0+) — adds CarPlay Dashboard + Watch Smart Stack +/// via supplementalActivityFamilies([.small]). +@available(iOS 18.0, *) +struct LoopFollowLiveActivityWidgetWithCarPlay: Widget { + var body: some WidgetConfiguration { + ActivityConfiguration(for: GlucoseLiveActivityAttributes.self) { context in LockScreenFamilyAdaptiveView(state: context.state) .id(context.state.seq) } dynamicIsland: { context in - // DYNAMIC ISLAND UI - DynamicIsland { - DynamicIslandExpandedRegion(.leading) { - Link(destination: URL(string: "loopfollow://la-tap")!) { - DynamicIslandLeadingView(snapshot: context.state.snapshot) - .overlay(RenewalOverlayView(show: context.state.snapshot.showRenewalOverlay)) - } - .id(context.state.seq) - } - DynamicIslandExpandedRegion(.trailing) { - Link(destination: URL(string: "loopfollow://la-tap")!) { - DynamicIslandTrailingView(snapshot: context.state.snapshot) - .overlay(RenewalOverlayView(show: context.state.snapshot.showRenewalOverlay)) - } - .id(context.state.seq) - } - DynamicIslandExpandedRegion(.bottom) { - Link(destination: URL(string: "loopfollow://la-tap")!) { - DynamicIslandBottomView(snapshot: context.state.snapshot) - .overlay(RenewalOverlayView(show: context.state.snapshot.showRenewalOverlay, showText: true)) - } - .id(context.state.seq) - } - } compactLeading: { - DynamicIslandCompactLeadingView(snapshot: context.state.snapshot) - .id(context.state.seq) - } compactTrailing: { - DynamicIslandCompactTrailingView(snapshot: context.state.snapshot) - .id(context.state.seq) - } minimal: { - DynamicIslandMinimalView(snapshot: context.state.snapshot) - .id(context.state.seq) - } - .keylineTint(LAColors.keyline(for: context.state.snapshot).opacity(0.75)) + makeDynamicIsland(context: context) } .supplementalActivityFamilies([.small]) } @@ -72,7 +94,7 @@ private extension View { /// Reads the activityFamily environment value and routes to the appropriate layout. /// - `.small` → CarPlay Dashboard & Watch Smart Stack: compact glucose-only view /// - everything else → full lock screen layout with configurable grid -@available(iOS 16.1, *) +@available(iOS 18.0, *) private struct LockScreenFamilyAdaptiveView: View { let state: GlucoseLiveActivityAttributes.ContentState @@ -95,7 +117,7 @@ private struct LockScreenFamilyAdaptiveView: View { /// Compact view shown on CarPlay Dashboard (iOS 26+) and Apple Watch Smart Stack (watchOS 11+). /// Hardcoded to glucose + trend arrow + delta + time since last reading. -@available(iOS 16.1, *) +@available(iOS 18.0, *) private struct SmallFamilyView: View { let snapshot: GlucoseSnapshot From 83f4ad3a5e4e410debc01bd6ec79d5ddff6c3ae0 Mon Sep 17 00:00:00 2001 From: MtlPhil <76601115+MtlPhil@users.noreply.github.com> Date: Mon, 16 Mar 2026 16:49:35 -0400 Subject: [PATCH 57/82] fix: prevent glucose + trend arrow clipping on wide mmol/L values MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit At 46pt, a 4-character mmol/L value ("10.5") plus "↑↑" overflowed the 168pt left column, truncating the glucose reading. Fix: reduce trend arrow to 32pt and add minimumScaleFactor(0.7) + lineLimit(1) to the glucose text so values above 10 mmol/L render correctly. Co-Authored-By: Claude Sonnet 4.6 --- LoopFollowLAExtension/LoopFollowLiveActivity.swift | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/LoopFollowLAExtension/LoopFollowLiveActivity.swift b/LoopFollowLAExtension/LoopFollowLiveActivity.swift index a70008414..d681a9368 100644 --- a/LoopFollowLAExtension/LoopFollowLiveActivity.swift +++ b/LoopFollowLAExtension/LoopFollowLiveActivity.swift @@ -164,15 +164,18 @@ private struct LockScreenLiveActivityView: View { HStack(spacing: 12) { // LEFT: Glucose + trend arrow, delta below VStack(alignment: .leading, spacing: 4) { - HStack(alignment: .firstTextBaseline, spacing: 8) { + HStack(alignment: .firstTextBaseline, spacing: 6) { Text(LAFormat.glucose(s)) .font(.system(size: 46, weight: .bold, design: .rounded)) .monospacedDigit() .foregroundStyle(.white) + .minimumScaleFactor(0.7) + .lineLimit(1) Text(LAFormat.trendArrow(s)) - .font(.system(size: 46, weight: .bold, design: .rounded)) + .font(.system(size: 32, weight: .bold, design: .rounded)) .foregroundStyle(.white.opacity(0.95)) + .lineLimit(1) } Text("Delta: \(LAFormat.delta(s))") From 426fa3d581d3f6ea56f8261c322c0fa67c684d08 Mon Sep 17 00:00:00 2001 From: Phil A <76601115+MtlPhil@users.noreply.github.com> Date: Tue, 17 Mar 2026 02:17:36 -0400 Subject: [PATCH 58/82] Live Activity: fix glucose + trend arrow clipping on wide mmol/L values (#552) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: Live Activity auto-renewal to work around 8-hour system limit Co-Authored-By: Claude Sonnet 4.6 * test: reduce LA renewal threshold to 20 min for testing Co-Authored-By: Claude Sonnet 4.6 * feat: improve LA renewal robustness and stale indicator - staleDate on every ActivityContent now tracks the renewal deadline, so the system shows Apple's built-in stale indicator if renewal fails - Add laRenewalFailed StorageValue; set on Activity.request() failure, cleared on any successful LA start - Observe willEnterForegroundNotification: retry startIfNeeded() if a previous renewal attempt failed - New-first renewal order: request the replacement LA before ending the old one — if the request throws the existing LA stays alive so the user keeps live data until the system kills it at the 8-hour mark Co-Authored-By: Claude Sonnet 4.6 * feat: renewal warning overlay + restore 7.5h threshold - Restore renewalThreshold to 7.5 * 3600 (testing complete) - Add showRenewalOverlay: Bool to GlucoseSnapshot (Codable, default false) - GlucoseSnapshotBuilder sets it true when now >= laRenewBy - 1800 (30 minutes before the renewal deadline) - Lock screen: 60% gray overlay with "Tap to update" centered in white, layered above the existing isNotLooping overlay - DI expanded: RenewalOverlayView applied to leading/trailing/bottom regions; "Tap to update" text shown on the bottom region only - showRenewalOverlay resets to false automatically on renewal since laRenewBy is updated and the next snapshot rebuild clears the flag Co-Authored-By: Claude Sonnet 4.6 * fix: overlay not appearing + foreground restart not working Overlay rendering: - Replace Group{if condition{ZStack{RoundedRectangle...}}} with a permanently-present ZStack toggled via .opacity(). The Group/if pattern causes SwiftUI sizing ambiguity when the condition transitions from false→true inside an .overlay(), producing a zero-size result. The .opacity() approach keeps a stable view hierarchy. - Same fix applied to RenewalOverlayView used on DI expanded regions. Foreground restart: - handleForeground() was calling startIfNeeded(), which finds the still-alive (failed-to-renew) LA in Activity.activities and reuses it, doing nothing useful. Fixed to manually nil out current, cancel all tasks, await activity.end(.immediate), then startFromCurrentState(). Overlay timing: - Changed warning window from 30 min (1800s) to 20 min (1200s) before the renewal deadline, matching the intended test cadence. Logging: - handleForeground: log on every foreground event with laRenewalFailed value - renewIfNeeded: log how many seconds past the deadline when firing - GlucoseSnapshotBuilder: log when overlay activates with seconds to deadline - performRefresh: log when sending an update with the overlay visible Co-Authored-By: Claude Sonnet 4.6 * test: set renewalThreshold to 20 min for testing Co-Authored-By: Claude Sonnet 4.6 * fix: renewal overlay not clearing after LA is refreshed Normal renewal path (renewIfNeeded success): - Build a fresh snapshot with showRenewalOverlay: false for the new LA's initial content — it has a new deadline so the overlay should never be visible from the first frame. - Save that fresh snapshot to GlucoseSnapshotStore so the next duplicate check has the correct baseline and doesn't suppress the first real BG update. Foreground restart path (handleForeground): - Zero laRenewBy before calling startFromCurrentState() so GlucoseSnapshotBuilder computes showRenewalOverlay = false for the seed snapshot. startIfNeeded() then sets the new deadline after the request succeeds. Co-Authored-By: Claude Sonnet 4.6 * fix: overlay permanently active when warning window equals threshold With renewalThreshold=20min and the hardcoded 1200s warning window, renewBy-1200 = start, so showRenewalOverlay is always true from the moment the LA begins. Extract a named renewalWarning constant (5 min for testing) so the warning window is always less than the threshold. The builder now reads LiveActivityManager.renewalWarning instead of a hardcoded literal. Production values to restore before merging: renewalThreshold = 7.5 * 3600 renewalWarning = 20 * 60 Co-Authored-By: Claude Sonnet 4.6 * fix: include showRenewalOverlay in APNs payload and clear laRenewBy synchronously APNSClient was missing showRenewalOverlay from the push payload, so background APNs updates never delivered the overlay flag to the extension — only foreground direct ActivityKit updates did. In handleForeground, laRenewBy is now zeroed synchronously before spawning the async end/restart Task. This means any snapshot built between the foreground notification and the new LA start (e.g. from viewDidAppear's startFromCurrentState) computes showRenewalOverlay = false rather than reading the stale expired deadline. Co-Authored-By: Claude Sonnet 4.6 * fix: await LA end before restarting on foreground retry to avoid reuse path Reset laRenewBy and laRenewalFailed synchronously before tearing down the failed LA, then await activity.end() before calling startFromCurrentState(). This guarantees Activity.activities is clear when startIfNeeded() runs, so it takes the fresh-request path and writes a new laRenewBy. startFromCurrentState rebuilds the snapshot with showRenewalOverlay=false (laRenewBy=0), saves it to the store, then startIfNeeded uses that clean snapshot as the seed. Co-Authored-By: Claude Sonnet 4.6 * chore: restore production renewal timing (7.5h threshold, 20min warning) Co-Authored-By: Claude Sonnet 4.6 * feat: laEnabled toggle, forceRestart(), and RestartLiveActivityIntent - SettingsMenuView: rename "APN" menu entry to "Live Activity" - Storage: add laEnabled: Bool StorageValue (default false) - APNSettingsView: on/off toggle bound to laEnabled; APNs key fields and Restart button shown only when enabled; disabling immediately ends any running LA - LiveActivityManager: - forceRestart() (@MainActor) ends all running activities, resets laRenewBy and laRenewalFailed, then calls startFromCurrentState() - laEnabled guard added to startFromCurrentState(), refreshFromCurrentState(), handleForeground(), and handleDidBecomeActive() - didBecomeActiveNotification observer calls forceRestart() on every foreground transition when laEnabled is true - RestartLiveActivityIntent: AppIntent + ForegroundContinuableIntent; sets laEnabled, validates credentials, opens settings deep link if missing, otherwise calls forceRestart() in foreground; LoopFollowAppShortcuts exposes the intent with Siri phrase Note: RestartLiveActivityIntent.swift must be added to the app target in Xcode. Co-Authored-By: Claude Sonnet 4.6 * Added RestartLiveActivityIntent to project * fix: resolve two build errors in LiveActivityManager and RestartLiveActivityIntent - LiveActivityManager: handleDidBecomeActive() calls @MainActor forceRestart() via Task { @MainActor in ... } to satisfy Swift concurrency isolation - RestartLiveActivityIntent: drop ForegroundContinuableIntent — continueInForeground() was renamed to continueInForeground(_:alwaysConfirm:) in the iOS 26 SDK and requires iOS 26+; the didBecomeActiveNotification observer handles restart when the app comes to foreground, making explicit continuation unnecessary Co-Authored-By: Claude Sonnet 4.6 * fix: guard continueInForeground() behind iOS 26 availability check continueInForeground(_:alwaysConfirm:) is iOS 26+ only. On earlier versions, forceRestart() runs directly via the existing background audio session — no foreground continuation needed. Co-Authored-By: Claude Sonnet 4.6 * fix: use startFromCurrentState in handleDidBecomeActive instead of forceRestart forceRestart() was killing the LA that the RestartLiveActivityIntent had just created, because continueInForeground() triggers didBecomeActive. startFromCurrentState() reuses an existing active LA via startIfNeeded() and only creates one if none is present — no destructive race. Co-Authored-By: Claude Sonnet 4.6 * feat: LA foreground tab navigation, button feedback, and toggle sync APNSettingsView: - "Restart Live Activity" button shows "Live Activity Restarted" for 2 seconds and disables itself during that window to prevent double-taps - Toggle observes Storage.shared.laEnabled via onReceive so it reflects changes made externally (e.g. by the App Intent / Shortcuts) LiveActivityManager: - handleDidBecomeActive posts .liveActivityDidForeground after startFromCurrentState - Notification.Name.liveActivityDidForeground extension defined here MainViewController: - Observes .liveActivityDidForeground; navigates to Snoozer tab if an alarm is currently active, otherwise navigates to tab 0 (Home) Co-Authored-By: Claude Sonnet 4.6 * fix: flush LA update on willResignActive to ensure lock screen shows latest data The 5-second debounce on refreshFromCurrentState means a BG update arriving while the app is foreground won't reach the LA before the user returns to the lock screen. When the debounce fires in background, isForeground=false so the direct ActivityKit update is skipped. Fix: observe willResignActiveNotification, cancel the pending debounce, and immediately push the latest snapshot via direct ActivityKit update while the app is still foreground-active. The APNs push is also sent as a backup. Co-Authored-By: Claude Sonnet 4.6 * feat: redesign Dynamic Island compact and expanded views Compact: - Trailing now shows delta instead of trend arrow (leading already showed BG) Expanded leading: - Large BG value at top - Second row: trend arrow, delta, and "Proj: X" in smaller text below Expanded trailing: - IOB and COB (moved from bottom) Expanded bottom: - "Updated at: HH:mm" (moved from trailing) Co-Authored-By: Claude Sonnet 4.6 * fix: match Proj text style to delta; add trailing padding to IOB/COB - Proj label now uses same size (13), weight (semibold), and opacity (0.9) as the delta text beside it - Added 6pt trailing padding to the expanded DI trailing VStack so the IOB decimal is not clipped by the Dynamic Island edge Co-Authored-By: Claude Sonnet 4.6 * feat: separate Live Activity and APN settings into distinct menus Per maintainer preference, the APNs credentials (Key ID and Key) remain in their own "APN" settings screen. A new separate "Live Activity" settings screen contains the enable/disable toggle and the Restart Live Activity button. - APNSettingsView: restored to Key ID + Key only (no toggle or restart button) - LiveActivitySettingsView: new view with laEnabled toggle and restart button (with 2-second confirmation feedback); toggle stays in sync via onReceive - SettingsMenuView: "APN" entry restored; new "Live Activity" entry added with dot.radiowaves icon; Sheet.liveActivity case wired to LiveActivitySettingsView Co-Authored-By: Claude Sonnet 4.6 * Added Live Activity menu * chore: add LiveActivitySettingsView to Xcode project Co-Authored-By: Claude Sonnet 4.6 * fix: LA tap navigation, manual dismissal prevention, and toggle start - widgetURL on lock screen LA → opens app via loopfollow://la-tap - Link views in expanded DI regions → same URL on tap - AppDelegate.application(_:open:) handles loopfollow://la-tap and posts .liveActivityDidForeground so MainViewController navigates to Snoozer (if alarm active) or Home tab — fires only on LA/DI taps, not every foreground - Notification.Name.liveActivityDidForeground moved outside #if macCatalyst so AppDelegate can reference it unconditionally - handleDidBecomeActive no longer posts .liveActivityDidForeground (was firing on every app foreground, not just LA taps) - attachStateObserver: .dismissed state (user swipe) sets laEnabled = false so the LA does not auto-restart when the app foregrounds - LiveActivitySettingsView: toggling ON calls startFromCurrentState() Co-Authored-By: Claude Sonnet 4.6 * fix: end Live Activity on app force-quit Adds endOnTerminate() to LiveActivityManager — blocks up to 3 s using Task.detached + DispatchSemaphore so the async activity.end() completes before the process exits. Called from applicationWillTerminate so the LA clears from the lock screen immediately on force-quit. laEnabled is preserved so the LA restarts correctly on next launch. Co-Authored-By: Claude Sonnet 4.6 * fix: use dismissedByUser flag instead of disabling laEnabled on manual dismiss - Manual LA swipe-away sets dismissedByUser (in-memory) instead of laEnabled = false, preserving the user's preference - startFromCurrentState() guards on !dismissedByUser so the LA does not auto-restart on foreground, BG refresh, or handleDidBecomeActive - forceRestart() clears dismissedByUser before starting, so the Restart button and App Intent both work as the explicit re-enable mechanism - Toggle ON in settings calls forceRestart() (clears flag + starts) instead of startFromCurrentState() which would have been blocked by the flag - dismissedByUser resets to false on app relaunch (in-memory only), so a kill + relaunch starts the LA fresh as expected Co-Authored-By: Claude Sonnet 4.6 * fix: dismiss modal (Settings sheet) before tab switch on LA tap navigateOnLAForeground now checks tabBarController.presentedViewController and dismisses it before switching to the target tab, matching the existing pattern used elsewhere in MainViewController for the same scenario. Co-Authored-By: Claude Sonnet 4.6 * fix: LA tap navigation timing and LA reappear-after-dismiss Navigation fix: - loopfollow://la-tap URL can arrive before applicationDidBecomeActive while UIKit is still restoring the view hierarchy from background. At that moment tabBarController.presentedViewController is nil so the Settings modal is never dismissed and the navigation notification fires too early. - Now stores a pendingLATapNavigation flag if app is not yet .active, and fires the notification in applicationDidBecomeActive when the full view hierarchy (including presented modals) is restored. LA reappear fix: - performRefresh() calls startIfNeeded() directly, bypassing the dismissedByUser guard that only exists in startFromCurrentState(). On every BG refresh with laEnabled=true and current==nil (post-dismiss), it would recreate the LA. - Added !dismissedByUser guard to refreshFromCurrentState() so the entire refresh pipeline is skipped while dismissed, matching the existing guard in startFromCurrentState(). Co-Authored-By: Claude Sonnet 4.6 * fix: handle loopfollow://la-tap URL in SceneDelegate, not AppDelegate With scene-based lifecycle (iOS 13+), widget/LA tap URLs are delivered to scene(_:openURLContexts:) on UIWindowSceneDelegate — AppDelegate.application (_:open:url:options:) is never called. Our previous handler was dead code. SceneDelegate now implements scene(_:openURLContexts:) which posts .liveActivityDidForeground on the next run loop (via DispatchQueue.main.async) to let the view hierarchy fully settle before navigateOnLAForeground() runs. Also handles the edge case where the URL arrives before sceneDidBecomeActive by storing a pendingLATapNavigation flag. Co-Authored-By: Claude Sonnet 4.6 * feat: configurable LA grid slots + full InfoType snapshot coverage Lock screen layout: - Left column: glucose + trend arrow, delta below (replaces "Last Update:") - Right grid: 4 configurable slots read from LAAppGroupSettings (defaults: IOB/COB/Proj/empty) - Footer: centered HH:MM update time at reduced opacity Slot configuration: - LiveActivitySlotOption enum with all 22 InfoType-aligned cases - LAAppGroupSettings.setSlots() / slots() persisted to App Group UserDefaults - Uniqueness enforced: selecting an option clears it from any other slot - Settings UI: "Grid slots" section with 4 pickers in LiveActivitySettingsView - Changes take effect immediately (refreshFromCurrentState called on save) GlucoseSnapshot extended with 19 new fields covering all InfoType items: override, recBolus, battery, pumpBattery, basalRate, pumpReservoirU, autosens, tdd, targetLowMgdl/High, isfMgdlPerU, carbRatio, carbsToday, profileName, sageInsertTime, cageInsertTime, iageInsertTime, minBgMgdl/Max Storage.swift: 13 new UserDefaults-backed fields for the above metrics Controllers updated to write new Storage fields on each data fetch: Basals, DeviceStatus, DeviceStatusLoop, DeviceStatusOpenAPS, Carbs, Profile, IAge Co-Authored-By: Claude Sonnet 4.6 * fix: label delta and footer on lock screen LA card Co-Authored-By: Claude Sonnet 4.6 * docs: add PR description for configurable LA grid slots Co-Authored-By: Claude Sonnet 4.6 * Update PR_configurable_slots.md * chore: remove PR notes from tracking, keep docs/LiveActivity.md only - Untrack docs/PR_configurable_slots.md (local-only reference doc) - Add docs/PR_configurable_slots.md and docs/LiveActivityTestPlan.md to .gitignore Co-Authored-By: Claude Sonnet 4.6 * fix: include all extended InfoType fields in APNs push payload buildPayload() was only serializing the base fields (glucose, delta, trend, updatedAt, unit, iob, cob, projected). All extended fields added with the configurable grid slots (battery, sageInsertTime, pumpBattery, basalRate, autosens, tdd, targets, isf, carbRatio, carbsToday, profileName, CAGE, IAGE, minBg, maxBg, override, recBolus) were missing from the APNs payload, causing the extension to decode them as nil/0 and display '--' on every push-driven refresh. Co-Authored-By: Claude Sonnet 4.6 * feat: add small family view for CarPlay Dashboard and Watch Smart Stack Registers the Live Activity for the small activity family via .supplementalActivityFamilies([.small]), enabling automatic display on: - CarPlay Dashboard (iOS 26+) - Apple Watch Smart Stack (watchOS 11+) The small view is hardcoded to the essentials appropriate for a driving context: glucose value, trend arrow, delta, and time since last reading. Background tint matches the existing threshold-based color logic. The lock screen layout (full grid with configurable slots) is unchanged. Co-Authored-By: Claude Sonnet 4.6 * fix: guard CarPlay/Watch small family behind iOS 18 availability; increase renewal overlay opacity - supplementalActivityFamilies and activityFamily require iOS 18.0+; restructured into two Widget structs sharing a makeDynamicIsland() helper: - LoopFollowLiveActivityWidget (iOS 16.1+): lock screen + Dynamic Island - LoopFollowLiveActivityWidgetWithCarPlay (iOS 18.0+): adds .supplementalActivityFamilies([.small]) - Bundle uses if #available(iOS 18.0, *) to select the right variant - Podfile: set minimum deployment target to 16.6 for all pods - Increase 'Tap to update' renewal overlay opacity from 60% to 90% Co-Authored-By: Claude Sonnet 4.6 * fix: move if #available into Widget.body to avoid WidgetBundleBuilder limitation @WidgetBundleBuilder does not support if #available { } else { }, but @WidgetConfigurationBuilder (used by Widget.body) does. Collapsed back to a single LoopFollowLiveActivityWidget struct with the iOS 18 conditional inside body — iOS 18+ branch adds .supplementalActivityFamilies([.small]) for CarPlay and Watch Smart Stack; else branch uses the plain lock screen view. Bundle reverts to the original single if #available(iOS 16.1, *) pattern. Co-Authored-By: Claude Sonnet 4.6 * fix: use two separate single-branch if #available in bundle for CarPlay support @WidgetConfigurationBuilder's buildEither requires both branches to return the same concrete type, making if/else with supplementalActivityFamilies impossible (it wraps to a different opaque type). @WidgetBundleBuilder does not support if #available { } else { } at all. Solution: two separate single-branch if #available blocks in the bundle — the pattern that @WidgetBundleBuilder already supported in the original code: - LoopFollowLiveActivityWidget (iOS 16.1+): primary, lock screen + Dynamic Island - LoopFollowLiveActivityWidgetWithCarPlay (iOS 18.0+): supplemental, adds .supplementalActivityFamilies([.small]) for CarPlay Dashboard + Watch Smart Stack ActivityKit uses the supplemental widget for small-family surfaces and the primary widget for lock screen / Dynamic Island, keeping iOS 16.6+ support intact. Co-Authored-By: Claude Sonnet 4.6 * fix: restore two-widget bundle; guard supplementalActivityFamilies and activityFamily behind iOS 18 Upstream's single-widget approach placed iOS 18+ APIs (supplementalActivityFamilies, activityFamily, ActivityFamily) behind @available(iOS 16.1, *), which fails to compile at the 16.6 deployment target. Restoring the two-widget pattern: - LoopFollowLiveActivityWidget (@available iOS 16.1): lock screen + DI, uses LockScreenLiveActivityView, no supplementalActivityFamilies - LoopFollowLiveActivityWidgetWithCarPlay (@available iOS 18.0): adds CarPlay Dashboard + Watch Smart Stack via supplementalActivityFamilies([.small]), uses LockScreenFamilyAdaptiveView (also @available iOS 18.0) - SmallFamilyView availability corrected to @available(iOS 18.0, *) - Bundle registers both via separate if #available blocks Co-Authored-By: Claude Sonnet 4.6 * fix: extension version inherits from parent; remove spurious await in slot config - LoopFollowLAExtension MARKETING_VERSION now uses "$(MARKETING_VERSION)" to match the parent app version automatically, resolving CFBundleShortVersionString mismatch warning - Remove unnecessary Task/await wrapping of refreshFromCurrentState in LiveActivitySettingsView — the method is not async Co-Authored-By: Claude Sonnet 4.6 * fix: prevent glucose + trend arrow clipping on wide mmol/L values At 46pt, a 4-character mmol/L value ("10.5") plus "↑↑" overflowed the 168pt left column, truncating the glucose reading. Fix: reduce trend arrow to 32pt and add minimumScaleFactor(0.7) + lineLimit(1) to the glucose text so values above 10 mmol/L render correctly. Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Claude Sonnet 4.6 --- LoopFollowLAExtension/LoopFollowLiveActivity.swift | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/LoopFollowLAExtension/LoopFollowLiveActivity.swift b/LoopFollowLAExtension/LoopFollowLiveActivity.swift index a70008414..d681a9368 100644 --- a/LoopFollowLAExtension/LoopFollowLiveActivity.swift +++ b/LoopFollowLAExtension/LoopFollowLiveActivity.swift @@ -164,15 +164,18 @@ private struct LockScreenLiveActivityView: View { HStack(spacing: 12) { // LEFT: Glucose + trend arrow, delta below VStack(alignment: .leading, spacing: 4) { - HStack(alignment: .firstTextBaseline, spacing: 8) { + HStack(alignment: .firstTextBaseline, spacing: 6) { Text(LAFormat.glucose(s)) .font(.system(size: 46, weight: .bold, design: .rounded)) .monospacedDigit() .foregroundStyle(.white) + .minimumScaleFactor(0.7) + .lineLimit(1) Text(LAFormat.trendArrow(s)) - .font(.system(size: 46, weight: .bold, design: .rounded)) + .font(.system(size: 32, weight: .bold, design: .rounded)) .foregroundStyle(.white.opacity(0.95)) + .lineLimit(1) } Text("Delta: \(LAFormat.delta(s))") From e20ec46b776a5e438682614574d9768794ec2d95 Mon Sep 17 00:00:00 2001 From: MtlPhil <76601115+MtlPhil@users.noreply.github.com> Date: Tue, 17 Mar 2026 13:43:32 -0400 Subject: [PATCH 59/82] chore: remove redundant @available(iOS 16.1) guards The app's minimum deployment target is iOS 16.6, making all iOS 16.1 availability checks redundant. Removed @available(iOS 16.1, *) annotations from all types and the if #available(iOS 16.1, *) wrapper in the bundle. Co-Authored-By: Claude Sonnet 4.6 --- LoopFollowLAExtension/LoopFollowLABundle.swift | 4 +--- LoopFollowLAExtension/LoopFollowLiveActivity.swift | 11 +---------- 2 files changed, 2 insertions(+), 13 deletions(-) diff --git a/LoopFollowLAExtension/LoopFollowLABundle.swift b/LoopFollowLAExtension/LoopFollowLABundle.swift index a9f7daf6c..fef1aa1fb 100644 --- a/LoopFollowLAExtension/LoopFollowLABundle.swift +++ b/LoopFollowLAExtension/LoopFollowLABundle.swift @@ -11,9 +11,7 @@ import WidgetKit @main struct LoopFollowLABundle: WidgetBundle { var body: some Widget { - if #available(iOS 16.1, *) { - LoopFollowLiveActivityWidget() - } + LoopFollowLiveActivityWidget() if #available(iOS 18.0, *) { LoopFollowLiveActivityWidgetWithCarPlay() } diff --git a/LoopFollowLAExtension/LoopFollowLiveActivity.swift b/LoopFollowLAExtension/LoopFollowLiveActivity.swift index d681a9368..353606b44 100644 --- a/LoopFollowLAExtension/LoopFollowLiveActivity.swift +++ b/LoopFollowLAExtension/LoopFollowLiveActivity.swift @@ -6,7 +6,6 @@ import SwiftUI import WidgetKit /// Builds the shared Dynamic Island content used by both widget variants. -@available(iOS 16.1, *) private func makeDynamicIsland(context: ActivityViewContext) -> DynamicIsland { DynamicIsland { DynamicIslandExpandedRegion(.leading) { @@ -43,8 +42,7 @@ private func makeDynamicIsland(context: ActivityViewContext Date: Tue, 17 Mar 2026 14:48:29 -0400 Subject: [PATCH 60/82] Fix Live Activity glucose overflow with flexible layout and tighter grid spacing --- CLAUDE.md | 479 ++++++++++++++++++ .../LoopFollowLiveActivity.swift | 20 +- 2 files changed, 491 insertions(+), 8 deletions(-) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..0f9f62f04 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,479 @@ +# LoopFollow Live Activity — Project Context for Claude Code + +## Who you're working with + +This codebase is being developed by **Philippe** (GitHub: `MtlPhil`), contributing to +`loopandlearn/LoopFollow` — an open-source iOS app that lets parents and caregivers of T1D +Loop users monitor glucose and loop status in real time. + +- **Upstream repo:** `https://github.com/loopandlearn/LoopFollow` +- **Philippe's fork:** `https://github.com/achkars-org/LoopFollow` +- **Local clone:** `/Users/philippe/Documents/GitHub/LoopFollowLA/` +- **Active upstream branch:** `live-activity` (PR #537, draft, targeting `dev`) +- **Philippe's original PR:** `#534` (closed, superseded by #537) +- **Maintainer:** `bjorkert` (Jonas Björkert) + +--- + +## What this feature is + +A **Live Activity** for LoopFollow that displays real-time glucose data on the iOS lock screen +and in the Dynamic Island. The feature uses **APNs self-push** — the app sends a push +notification to itself — to drive reliable background updates without interfering with the +background audio session LoopFollow uses to stay alive. + +### What the Live Activity shows +- Current glucose value + trend arrow +- Delta (change since last reading) +- IOB, COB, projected BG (optional — omitted gracefully for Dexcom-only users) +- Time since last reading +- "Not Looping" red banner when Loop hasn't reported in 15+ minutes +- Threshold-driven background color (green / orange / red) +- Dynamic Island: compact, expanded, and minimal presentations + +--- + +## Architecture overview (current state in PR #537) + +### Data flow +``` +BGData / DeviceStatusLoop / DeviceStatusOpenAPS + → write canonical values to Storage.shared + → GlucoseSnapshotBuilder reads Storage + → builds GlucoseSnapshot + → LiveActivityManager pushes via APNSClient + → LoopFollowLAExtension renders the UI +``` + +### Key files + +| File | Purpose | +|------|---------| +| `LiveActivity/LiveActivityManager.swift` | Orchestrates start/stop/refresh of the Live Activity; called from `MainViewController` | +| `LiveActivity/APNSClient.swift` | Sends the APNs self-push; uses `JWTManager.shared` for JWT; reads credentials from `Storage.shared` | +| `Helpers/JWTManager.swift` | **bjorkert addition** — replaces `APNSJWTGenerator`; uses CryptoKit (P256/ES256); multi-slot in-memory cache keyed by `keyId:teamId`, 55-min TTL | +| `LiveActivity/GlucoseSnapshot.swift` | The value-type snapshot passed to the extension; timestamp stored as Unix epoch seconds (UTC) — **timezone bug was fixed here** | +| `LiveActivity/GlucoseSnapshotBuilder.swift` | Reads from Storage, constructs GlucoseSnapshot | +| `LiveActivity/GlucoseSnapshotStore.swift` | In-memory store; debounces rapid successive refreshes | +| `LiveActivity/GlucoseLiveActivityAttributes.swift` | ActivityKit attributes struct | +| `LiveActivity/AppGroupID.swift` | Derives App Group ID dynamically from bundle identifier — no hardcoded team IDs | +| `LiveActivity/LAAppGroupSettings.swift` | Persists LA-specific settings to the shared App Group container | +| `LiveActivity/LAFormat.swift` | **bjorkert addition** — display formatting for LA values; uses `NumberFormatter` with `Locale.current` so decimal separators match device locale (e.g. "5,6" in Swedish) | +| `LiveActivity/PreferredGlucoseUnit.swift` | Reads preferred unit; delegates to `Localizer.getPreferredUnit()` — no longer duplicates unit detection logic | +| `GlucoseConversion.swift` | **Replaces `GlucoseUnitConversion.swift`** — unified constant `18.01559`; `mgDlToMmolL` is a computed reciprocal. Note: the old file used `18.0182` — do not use that constant anywhere | +| `LiveActivity/StorageCurrentGlucoseStateProvider.swift` | Protocol adapter between Storage and LiveActivityManager | +| `LoopFollowLAExtension/LoopFollowLiveActivity.swift` | SwiftUI widget views for lock screen + Dynamic Island | +| `LoopFollowLAExtension/LoopFollowLABundle.swift` | Extension bundle entry point | +| `Settings/APNSettingsView.swift` | **bjorkert addition** — dedicated settings screen for LoopFollow's own APNs key ID and key | +| `Storage/Storage.swift` | Added: `lastBgReadingTimeSeconds`, `lastDeltaMgdl`, `lastTrendCode`, `lastIOB`, `lastCOB`, `projectedBgMgdl` | +| `Storage/Observable.swift` | Added: `isNotLooping` | +| `Storage/Storage+Migrate.swift` | Added: `migrateStep5` — migrates legacy APNs credential keys to new split format | + +--- + +## The core design decisions Philippe made (and why) + +### 1. APNs self-push for background updates +LoopFollow uses a background audio session to stay alive in the background. Initially, the +temptation was to use `ActivityKit` updates directly from the app. The self-push approach was +chosen because it is more reliable and doesn't create timing conflicts with the audio session. +The app sends a push to itself using its own APNs key; the system delivers it with high +priority, waking the extension. + +### 2. Dynamic App Group ID (no hardcoded team IDs) +`AppGroupID.swift` derives the App Group ID from the bundle identifier at runtime. This makes +the feature work across all fork/build configurations without embedding any team-specific +identifiers in code. + +### 3. Single source of truth in Storage +All glucose and loop state is written to `Storage.shared` (and `Observable`) by the existing +data-fetching controllers (BGData, DeviceStatusLoop, DeviceStatusOpenAPS). The Live Activity +layer is purely a consumer — it never fetches its own data. This keeps the architecture clean +and source-agnostic. + +### 4. GlucoseSnapshot stores glucose in mg/dL only — conversion at display time only +The snapshot is a simple struct with no dependencies, designed to be safe to pass across the +app/extension boundary. All glucose values in `GlucoseSnapshot` are stored as **mg/dL**. +Conversion to mmol/L happens exclusively at display time inside `LAFormat`. This eliminates +the previous round-trip (mg/dL → mmol/L at snapshot creation, then mmol/L → mg/dL for +threshold comparison) that bjorkert identified and removed. + +**Rule for all future code:** anything writing a glucose value into a `GlucoseSnapshot` must +supply mg/dL. Anything reading a glucose value from a snapshot for display must convert via +`GlucoseConversion.mgDlToMmolL` if the user's preferred unit is mmol/L. + +### 5. Unix epoch timestamps (UTC) in GlucoseSnapshot +**Critical bug that was discovered and fixed:** ActivityKit operates in UTC epoch seconds, +but the original code was constructing timestamps using local time offsets, causing DST +errors of ±1 hour. The fix ensures all timestamps in `GlucoseSnapshot` are stored as +`TimeInterval` (seconds since Unix epoch, UTC) and converted to display strings only in the +extension, using the device's local calendar. This fix is in the codebase. + +### 6. Debounce on rapid refreshes +A coalescing `DispatchWorkItem` pattern is used in `GlucoseSnapshotStore` to debounce +rapid successive calls to refresh (e.g., when multiple Storage values update in quick +succession during a data fetch). Only one APNs push is sent per update cycle. + +### 7. APNs key injected via xcconfig/Info.plist (Philippe's original approach) +In Philippe's original PR #534, the APNs key was injected at build time via +`xcconfig` / `Info.plist`, sourced from a GitHub Actions secret. This meant credentials were +baked into the build and never committed. + +--- + +## What bjorkert changed (and why it differs from Philippe's approach) + +### Change 1: SwiftJWT → CryptoKit (`JWTManager.swift`) +**Philippe used:** `SwiftJWT` + `swift-crypto` SPM packages for JWT signing. +**bjorkert replaced with:** Apple's built-in `CryptoKit` (P256/ES256) via a new +`JWTManager.swift`. +**Rationale:** Eliminates two third-party dependencies. `JWTManager` adds a multi-slot +in-memory cache (keyed by `keyId:teamId`, 55-min TTL) instead of persisting JWT tokens to +UserDefaults. +**Impact:** `APNSJWTGenerator.swift` is deleted. All JWT logic lives in `JWTManager.shared`. + +### Change 2: Split APNs credentials (lf vs remote) +**Philippe's approach:** One set of APNs credentials shared between Live Activity and remote +commands. +**bjorkert's approach:** Two distinct credential sets: +- `lfApnsKey` / `lfKeyId` — for LoopFollow's own Live Activity self-push +- `remoteApnsKey` / `remoteKeyId` — for remote commands to Loop/Trio + +**Rationale:** Users who don't use remote commands shouldn't need to configure remote +credentials to get Live Activity working. Users who use both (different team IDs for Loop +vs LoopFollow) previously saw confusing "Return Notification Settings" UI that's now removed. +**Migration:** `migrateStep5` in `Storage+Migrate.swift` handles migrating the legacy keys. + +### Change 3: Runtime credential entry via APNSettingsView +**Philippe's approach:** APNs key injected at build time via xcconfig / CI secret. +**bjorkert's approach:** User enters APNs Key ID and Key at runtime via a new +`APNSettingsView` (under Settings menu). +**Rationale:** Removes the `Inject APNs Key Content` CI step entirely. No credentials are +baked into the build or present in `Info.plist`. Browser Build users don't need to manage +GitHub secrets for APNs. Credentials stored in `Storage.shared` at runtime. +**Impact:** `APNSKeyContent`, `APNSKeyID`, `APNSTeamID` removed from `Info.plist`. The CI +workflow no longer has an APNs key injection step. + +### Change 4: APNSClient reads from Storage instead of Info.plist +Follows directly from Change 3. `APNSClient` now calls `Storage.shared` for credentials +and uses `JWTManager.shared` instead of `APNSJWTGenerator`. Sandbox vs production APNs +host selection is based on `BuildDetails.isTestFlightBuild()`. + +### Change 5: Remote command settings UI simplification +The old "Return Notification Settings" section (which appeared when team IDs differed) is +removed. Remote credential fields only appear when team IDs differ. The new `APNSettingsView` +is always the place to enter LoopFollow's own credentials. + +### Change 6: CI / build updates +- `runs-on` updated from `macos-15` to `macos-26` +- Xcode version updated to `Xcode_26.2` +- APNs key injection step removed from `build_LoopFollow.yml` + +### Change 8: Consolidation pass (post-PR-#534 cleanup) +This batch of changes was made by bjorkert after integrating Philippe's code, to reduce +duplication and fix several bugs found during review. + +**mg/dL-only snapshot storage:** +All glucose values in `GlucoseSnapshot` are now stored in mg/dL. The previous code converted +to mmol/L at snapshot creation time, then converted back to mg/dL for threshold comparison — +a pointless round-trip. Conversion now happens only in `LAFormat` at display time. + +**Unified conversion constant:** +`GlucoseUnitConversion.swift` (used `18.0182`) is deleted. +`GlucoseConversion.swift` (uses `18.01559`) is the single source. Do not use `18.0182` anywhere. + +**Deduplicated unit detection:** +`PreferredGlucoseUnit.hkUnit()` now delegates to `Localizer.getPreferredUnit()` instead of +reimplementing the same logic. + +**New trend cases (↗ / ↘):** +`GlucoseSnapshot` trend now includes `upSlight` / `downSlight` cases (FortyFiveUp/Down), +rendering as `↗` / `↘` instead of collapsing to `↑` / `↓`. All trend switch statements +must handle these cases. + +**Locale bug fixed in `LAFormat`:** +`LAFormat` now uses `NumberFormatter` with `Locale.current` so decimal separators match +the device locale. Do not format glucose floats with string interpolation directly — +always go through `LAFormat`. + +**`LAThresholdSync.swift` deleted:** +Was never called. Removed as dead code. Do not re-introduce it. + +**APNs payload fix — `isNotLooping`:** +The APNs push payload was missing the `isNotLooping` field, so push-based updates never +showed the "Not Looping" overlay. Now fixed — the field is included in every push. + + +bjorkert ran swiftformat across all Live Activity files: standardized file headers, +alphabetized imports, added trailing commas, cleaned whitespace. No logic changes. + +--- + +## What was preserved from Philippe's PR intact + +- All `LiveActivity/` Swift files except those explicitly deleted: + - **Deleted:** `APNSJWTGenerator.swift` (replaced by `JWTManager.swift`) + - **Deleted:** `GlucoseUnitConversion.swift` (replaced by `GlucoseConversion.swift`) + - **Deleted:** `LAThresholdSync.swift` (dead code) +- The `LoopFollowLAExtension/` files (both `LoopFollowLiveActivity.swift` and + `LoopFollowLABundle.swift`) +- The data flow architecture (Storage → SnapshotBuilder → LiveActivityManager → APNSClient) +- The DST/timezone fix in `GlucoseSnapshot.swift` +- The debounce pattern in `GlucoseSnapshotStore.swift` +- The `AppGroupID` dynamic derivation approach +- The "Not Looping" detection via `Observable.isNotLooping` +- The Storage fields added for Live Activity data +- The `docs/LiveActivity.md` architecture + APNs setup guide +- The Fastfile changes for the extension App ID and provisioning profile + +--- + +## Current task: Live Activity auto-renewal (8-hour limit workaround) + +### Background +Apple enforces an **8-hour maximum lifetime** on Live Activities in the Dynamic Island +(12 hours on the Lock Screen, but the DA kills at 8). For a continuous glucose monitor +follower app used overnight or during long days, this is a hard UX problem: the LA simply +disappears mid-use without warning. + +bjorkert has asked Philippe to implement a workaround. + +### Apple's constraints (confirmed) +- 8 hours from `Activity.request()` call — not from last update +- System terminates the LA hard at that point; no callback before termination +- The app **can** call `Activity.end()` + `Activity.request()` from the background via + the existing audio session LoopFollow already holds +- `Activity.end(dismissalPolicy: .immediate)` removes the card from the Lock Screen + immediately — critical to avoid two cards appearing simultaneously during renewal +- There is no built-in Apple API to query an LA's remaining lifetime + +### Design decision: piggyback on the existing refresh heartbeat +**Rejected approach:** A standalone `Timer` or `DispatchQueue.asyncAfter` set for 7.5 hrs. +This is fragile — timers don't survive suspension, and adding a separate scheduling +mechanism is complexity for no benefit when a natural heartbeat already exists. + +**Chosen approach:** Check LA age on every call to `refreshFromCurrentState(reason:)`. +Since this is called on every glucose update (~every 5 minutes via LoopFollow's existing +BGData polling cycle), the worst-case gap before renewal is one polling interval. The +check is cheap (one subtraction). If age ≥ threshold, end the current LA and immediately +re-request before doing the normal refresh. + +### Files to change +| File | Change | +|------|--------| +| `Storage/Storage.swift` | Add `laStartTime: TimeInterval` stored property (UserDefaults-backed, default 0) | +| `LiveActivity/LiveActivityManager.swift` | Record `laStartTime` on every successful `Activity.request()`; check age in `refreshFromCurrentState(reason:)`; add `renewIfNeeded()` helper | + +No other files need to change. The renewal is fully encapsulated in `LiveActivityManager`. + +### Key constants +```swift +static let renewalThreshold: TimeInterval = 7.5 * 3600 // 27,000 s — renew at 7.5 hrs +static let storageKey = "laStartTime" // key in Storage/UserDefaults +``` + +### Behaviour spec +1. On every `refreshFromCurrentState(reason:)` call, before building the snapshot: + - Compute `age = now - Storage.shared.laStartTime` + - If `age >= renewalThreshold` AND a live activity is currently active: + - End it with `.immediate` dismissal (clears the Lock Screen card instantly) + - Re-request a new LA with the current snapshot content + - Record new `laStartTime = now` + - Return (the re-request itself sends the first APNs update) +2. On every successful `Activity.request()` (including normal `startFromCurrentState()`): + - Set `Storage.shared.laStartTime = Date().timeIntervalSince1970` +3. On `stopLiveActivity()` (user-initiated stop or app termination): + - Reset `Storage.shared.laStartTime = 0` +4. On app launch / `startFromCurrentState()` with an already-running LA (resume path): + - Do NOT reset `laStartTime` — the existing value is the correct age anchor + - This handles the case where the app is killed and relaunched mid-session + +### Edge cases to handle +- **User dismisses the LA manually:** ActivityKit transitions to `.dismissed`. The existing + `activityStateUpdates` observer in `LiveActivityManager` already handles this. `laStartTime` + will be stale but harmless — next call to `startFromCurrentState()` will overwrite it. +- **App is not running at the 8-hr mark:** The system kills the LA. When the app next + becomes active and calls `startFromCurrentState()`, it will detect no active LA and + request a fresh one, resetting `laStartTime`. No special handling needed. +- **Multiple rapid calls to `refreshFromCurrentState` during renewal:** The existing + debounce in `GlucoseSnapshotStore` guards this. The renewal path returns early after + re-requesting, so the debounce never even fires. +- **laStartTime = 0 (never set / first launch):** Age will be enormous (current epoch), + but the guard `currentActivity != nil` prevents a spurious renewal when there's no + active LA. Safe. + +### Full implementation (ready to apply) + +#### `Storage/Storage.swift` addition +Add alongside the other LA-related stored properties: + +```swift +// Live Activity renewal +var laStartTime: TimeInterval { + get { return UserDefaults.standard.double(forKey: "laStartTime") } + set { UserDefaults.standard.set(newValue, forKey: "laStartTime") } +} +``` + +#### `LiveActivity/LiveActivityManager.swift` changes + +Add the constant and the helper near the top of the class: + +```swift +// MARK: - Constants +private static let renewalThreshold: TimeInterval = 7.5 * 3600 + +// MARK: - Renewal + +/// Ends the current Live Activity immediately and re-requests a fresh one, +/// working around Apple's 8-hour maximum LA lifetime. +/// Returns true if renewal was performed (caller should return early). +@discardableResult +private func renewIfNeeded(snapshot: GlucoseSnapshot) async -> Bool { + guard let activity = currentActivity else { return false } + + let age = Date().timeIntervalSince1970 - Storage.shared.laStartTime + guard age >= LiveActivityManager.renewalThreshold else { return false } + + os_log(.info, log: log, "Live Activity age %.0f s >= threshold, renewing", age) + + // End with .immediate so the stale card clears before the new one appears + await activity.end(nil, dismissalPolicy: .immediate) + currentActivity = nil + + // Re-request using the snapshot we already built + await startWithSnapshot(snapshot) + return true +} +``` + +Modify `startFromCurrentState()` to record the start time after a successful request: + +```swift +func startFromCurrentState() async { + guard ActivityAuthorizationInfo().areActivitiesEnabled else { return } + guard currentActivity == nil else { return } + + let snapshot = GlucoseSnapshotBuilder.build() + await startWithSnapshot(snapshot) +} + +/// Internal helper — requests a new LA and records the start time. +private func startWithSnapshot(_ snapshot: GlucoseSnapshot) async { + let attributes = GlucoseLiveActivityAttributes() + let content = ActivityContent(state: snapshot, staleDate: nil) + do { + currentActivity = try Activity.request( + attributes: attributes, + content: content, + pushType: .token + ) + // Record when this LA was started for renewal tracking + Storage.shared.laStartTime = Date().timeIntervalSince1970 + os_log(.info, log: log, "Live Activity started, laStartTime recorded") + + // Observe push token and state updates (existing logic) + observePushTokenUpdates() + observeActivityStateUpdates() + } catch { + os_log(.error, log: log, "Failed to start Live Activity: %@", error.localizedDescription) + } +} +``` + +Modify `refreshFromCurrentState(reason:)` to call `renewIfNeeded` before the normal path: + +```swift +func refreshFromCurrentState(reason: String) async { + guard currentActivity != nil else { + // No active LA — nothing to refresh + return + } + + let snapshot = GlucoseSnapshotBuilder.build() + + // Check if the LA is approaching Apple's 8-hour limit and renew if so. + // renewIfNeeded returns true if it performed a renewal; we return early + // because startWithSnapshot already sent the first update for the new LA. + if await renewIfNeeded(snapshot: snapshot) { return } + + // Normal refresh path — send APNs self-push with updated snapshot + await GlucoseSnapshotStore.shared.update(snapshot: snapshot) +} +``` + +Modify `stopLiveActivity()` to reset the start time: + +```swift +func stopLiveActivity() async { + guard let activity = currentActivity else { return } + await activity.end(nil, dismissalPolicy: .immediate) + currentActivity = nil + Storage.shared.laStartTime = 0 + os_log(.info, log: log, "Live Activity stopped, laStartTime reset") +} +``` + +### Testing checklist +- [ ] Manually set `renewalThreshold` to 60 seconds during testing to verify the + renewal cycle works without waiting 7.5 hours +- [ ] Confirm the old Lock Screen card disappears before the new one appears + (`.immediate` dismissal working correctly) +- [ ] Confirm `laStartTime` is reset to 0 on manual stop +- [ ] Confirm `laStartTime` is NOT reset when the app is relaunched with an existing + active LA (resume path) +- [ ] Confirm no duplicate LAs appear during renewal +- [ ] Restore `renewalThreshold` to `7.5 * 3600` before committing + +--- + +## Known issues / things still in progress + +- PR #537 is currently marked **Draft** as of March 12, 2026 +- bjorkert's last commit (`524b3bb`) was March 11, 2026 +- The PR is targeting `dev` and has 6 commits total (5 from Philippe, 1 from bjorkert) +- **Active task:** LA auto-renewal (8-hour limit workaround) — see section above + +--- + +## APNs self-push mechanics (important context) + +The self-push flow: +1. `LiveActivityManager.refreshFromCurrentState(reason:)` is called (from MainViewController + or on a not-looping state change) +2. It calls `GlucoseSnapshotBuilder` → `GlucoseSnapshotStore` +3. The store debounces and triggers `APNSClient.sendUpdate(snapshot:)` +4. `APNSClient` fetches credentials from `Storage.shared`, calls `JWTManager.shared` for a + signed JWT (cached for 55 min), then POSTs to the APNs HTTP/2 endpoint +5. The system delivers the push to `LoopFollowLAExtension`, which updates the Live Activity UI + +**APNs environments:** +- Sandbox (development/TestFlight): `api.sandbox.push.apple.com` +- Production: `api.push.apple.com` +- Selection is automatic via `BuildDetails.isTestFlightBuild()` + +**Token expiry handling:** APNs self-push token expiry (HTTP 410 / 400 BadDeviceToken) +is handled in `APNSClient` with appropriate error logging. The token is the Live Activity +push token obtained from `ActivityKit`, not a device token. + +--- + +## Repo / branch conventions + +- `main` — released versions only (version ends in `.0`) +- `dev` — integration branch; PR #537 targets this +- `live-activity` — bjorkert's working branch for the feature (upstream) +- Philippe's fork branches: `dev`, `live-activity-pr` (original work) +- Version format: `M.N.P` — P increments on each `dev` merge, N increments on release + +--- + +## Build configuration notes + +- App Group ID is derived dynamically — do not hardcode team IDs anywhere +- APNs credentials are now entered by the user at runtime in APNSettingsView +- No APNs secrets in xcconfig, Info.plist, or CI environment variables (as of bjorkert's + latest commit) +- The extension target is `LoopFollowLAExtension` with its own entitlements file + (`LoopFollowLAExtensionExtension.entitlements`) +- `Package.resolved` has been updated to remove SwiftJWT and swift-crypto dependencies diff --git a/LoopFollowLAExtension/LoopFollowLiveActivity.swift b/LoopFollowLAExtension/LoopFollowLiveActivity.swift index 353606b44..753402e05 100644 --- a/LoopFollowLAExtension/LoopFollowLiveActivity.swift +++ b/LoopFollowLAExtension/LoopFollowLiveActivity.swift @@ -161,26 +161,30 @@ private struct LockScreenLiveActivityView: View { HStack(spacing: 12) { // LEFT: Glucose + trend arrow, delta below VStack(alignment: .leading, spacing: 4) { - HStack(alignment: .firstTextBaseline, spacing: 6) { + HStack(alignment: .firstTextBaseline, spacing: 4) { Text(LAFormat.glucose(s)) .font(.system(size: 46, weight: .bold, design: .rounded)) .monospacedDigit() .foregroundStyle(.white) - .minimumScaleFactor(0.7) .lineLimit(1) + .minimumScaleFactor(0.78) + .allowsTightening(true) + .layoutPriority(3) Text(LAFormat.trendArrow(s)) .font(.system(size: 32, weight: .bold, design: .rounded)) .foregroundStyle(.white.opacity(0.95)) .lineLimit(1) + .fixedSize(horizontal: true, vertical: false) } Text("Delta: \(LAFormat.delta(s))") .font(.system(size: 15, weight: .semibold, design: .rounded)) .monospacedDigit() .foregroundStyle(.white.opacity(0.80)) + .lineLimit(1) } - .frame(width: 168, alignment: .leading) + .frame(minWidth: 168, maxWidth: 190, alignment: .leading) .layoutPriority(2) // Divider @@ -190,12 +194,12 @@ private struct LockScreenLiveActivityView: View { .padding(.vertical, 8) // RIGHT: configurable 2×2 grid - VStack(spacing: 10) { - HStack(spacing: 16) { + VStack(spacing: 8) { + HStack(spacing: 12) { SlotView(option: slotConfig[0], snapshot: s) SlotView(option: slotConfig[1], snapshot: s) } - HStack(spacing: 16) { + HStack(spacing: 12) { SlotView(option: slotConfig[2], snapshot: s) SlotView(option: slotConfig[3], snapshot: s) } @@ -281,7 +285,7 @@ private struct MetricBlock: View { .lineLimit(1) .minimumScaleFactor(0.85) } - .frame(width: 64, alignment: .leading) // consistent 2×2 columns + .frame(width: 60, alignment: .leading) // slightly tighter columns to free space for glucose } } @@ -295,7 +299,7 @@ private struct SlotView: View { if option == .none { // Invisible spacer — preserves grid alignment Color.clear - .frame(width: 64, height: 36) + .frame(width: 60, height: 36) } else { MetricBlock(label: option.gridLabel, value: value(for: option)) } From d99e7784bb6c84bbb4ca89ad3b930a6341662aa6 Mon Sep 17 00:00:00 2001 From: MtlPhil <76601115+MtlPhil@users.noreply.github.com> Date: Tue, 17 Mar 2026 17:25:37 -0400 Subject: [PATCH 61/82] Fix Live Activity glucose overflow with flexible layout and tighter grid spacing Co-Authored-By: Claude Sonnet 4.6 --- .../LoopFollowLiveActivity.swift | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/LoopFollowLAExtension/LoopFollowLiveActivity.swift b/LoopFollowLAExtension/LoopFollowLiveActivity.swift index 353606b44..753402e05 100644 --- a/LoopFollowLAExtension/LoopFollowLiveActivity.swift +++ b/LoopFollowLAExtension/LoopFollowLiveActivity.swift @@ -161,26 +161,30 @@ private struct LockScreenLiveActivityView: View { HStack(spacing: 12) { // LEFT: Glucose + trend arrow, delta below VStack(alignment: .leading, spacing: 4) { - HStack(alignment: .firstTextBaseline, spacing: 6) { + HStack(alignment: .firstTextBaseline, spacing: 4) { Text(LAFormat.glucose(s)) .font(.system(size: 46, weight: .bold, design: .rounded)) .monospacedDigit() .foregroundStyle(.white) - .minimumScaleFactor(0.7) .lineLimit(1) + .minimumScaleFactor(0.78) + .allowsTightening(true) + .layoutPriority(3) Text(LAFormat.trendArrow(s)) .font(.system(size: 32, weight: .bold, design: .rounded)) .foregroundStyle(.white.opacity(0.95)) .lineLimit(1) + .fixedSize(horizontal: true, vertical: false) } Text("Delta: \(LAFormat.delta(s))") .font(.system(size: 15, weight: .semibold, design: .rounded)) .monospacedDigit() .foregroundStyle(.white.opacity(0.80)) + .lineLimit(1) } - .frame(width: 168, alignment: .leading) + .frame(minWidth: 168, maxWidth: 190, alignment: .leading) .layoutPriority(2) // Divider @@ -190,12 +194,12 @@ private struct LockScreenLiveActivityView: View { .padding(.vertical, 8) // RIGHT: configurable 2×2 grid - VStack(spacing: 10) { - HStack(spacing: 16) { + VStack(spacing: 8) { + HStack(spacing: 12) { SlotView(option: slotConfig[0], snapshot: s) SlotView(option: slotConfig[1], snapshot: s) } - HStack(spacing: 16) { + HStack(spacing: 12) { SlotView(option: slotConfig[2], snapshot: s) SlotView(option: slotConfig[3], snapshot: s) } @@ -281,7 +285,7 @@ private struct MetricBlock: View { .lineLimit(1) .minimumScaleFactor(0.85) } - .frame(width: 64, alignment: .leading) // consistent 2×2 columns + .frame(width: 60, alignment: .leading) // slightly tighter columns to free space for glucose } } @@ -295,7 +299,7 @@ private struct SlotView: View { if option == .none { // Invisible spacer — preserves grid alignment Color.clear - .frame(width: 64, height: 36) + .frame(width: 60, height: 36) } else { MetricBlock(label: option.gridLabel, value: value(for: option)) } From 68d2a069acaa9c0357cd2e2c5d01bc1421e4a5b9 Mon Sep 17 00:00:00 2001 From: MtlPhil <76601115+MtlPhil@users.noreply.github.com> Date: Tue, 17 Mar 2026 17:33:29 -0400 Subject: [PATCH 62/82] fix: restart LA on foreground when renewal overlay is showing Previously handleForeground() only restarted the LA when laRenewalFailed=true, but the renewal overlay also appears as a warning before renewal is attempted (while laRenewalFailed is still false). Users who foregrounded during the warning window saw the overlay persist with no restart occurring. Now triggers a restart whenever the overlay is showing (within the warning window before the deadline) OR renewal previously failed. Co-Authored-By: Claude Sonnet 4.6 --- LoopFollow/LiveActivity/LiveActivityManager.swift | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/LoopFollow/LiveActivity/LiveActivityManager.swift b/LoopFollow/LiveActivity/LiveActivityManager.swift index 41f129c60..fc110d195 100644 --- a/LoopFollow/LiveActivity/LiveActivityManager.swift +++ b/LoopFollow/LiveActivity/LiveActivityManager.swift @@ -87,13 +87,19 @@ final class LiveActivityManager { @objc private func handleForeground() { guard Storage.shared.laEnabled.value else { return } - LogManager.shared.log(category: .general, message: "[LA] foreground notification received, laRenewalFailed=\(Storage.shared.laRenewalFailed.value)") - guard Storage.shared.laRenewalFailed.value else { return } - // Renewal previously failed — end the stale LA and start a fresh one. + let renewalFailed = Storage.shared.laRenewalFailed.value + let renewBy = Storage.shared.laRenewBy.value + let now = Date().timeIntervalSince1970 + let overlayIsShowing = renewBy > 0 && now >= renewBy - LiveActivityManager.renewalWarning + + LogManager.shared.log(category: .general, message: "[LA] foreground notification received, laRenewalFailed=\(renewalFailed), overlayShowing=\(overlayIsShowing)") + guard renewalFailed || overlayIsShowing else { return } + + // Overlay is showing or renewal previously failed — end the stale LA and start a fresh one. // We cannot call startIfNeeded() here: it finds the existing activity in // Activity.activities and reuses it rather than replacing it. - LogManager.shared.log(category: .general, message: "[LA] ending stale LA and restarting after renewal failure") + LogManager.shared.log(category: .general, message: "[LA] ending stale LA and restarting (renewalFailed=\(renewalFailed), overlayShowing=\(overlayIsShowing))") // Clear state synchronously so any snapshot built between now and when the // new LA is started computes showRenewalOverlay = false. Storage.shared.laRenewBy.value = 0 From 749264bdde1b753bb36616ad4b228ee21cbfe916 Mon Sep 17 00:00:00 2001 From: MtlPhil <76601115+MtlPhil@users.noreply.github.com> Date: Tue, 17 Mar 2026 18:24:48 -0400 Subject: [PATCH 63/82] fix: recover from audio session failure and alert user via LA overlay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When AVAudioSession.setActive() fails (e.g. another app holds the session exclusively), the app loses its background keep-alive with no recovery path. Two bugs fixed and recovery logic added: 1. interruptedAudio handler was calling playAudio() on interruption *began* (intValue == 1) instead of *ended* — corrected to restart on .ended only. 2. playAudio() catch block now retries up to 3 times (2s apart). After all retries are exhausted it posts a BackgroundAudioFailed notification. 3. LiveActivityManager observes BackgroundAudioFailed and immediately sets laRenewBy to now (making showRenewalOverlay = true) then pushes a refresh so the lock screen overlay tells the user to foreground the app. Co-Authored-By: Claude Sonnet 4.6 --- LoopFollow/Helpers/BackgroundTaskAudio.swift | 38 ++++++++++++++++--- .../LiveActivity/LiveActivityManager.swift | 16 ++++++++ 2 files changed, 48 insertions(+), 6 deletions(-) diff --git a/LoopFollow/Helpers/BackgroundTaskAudio.swift b/LoopFollow/Helpers/BackgroundTaskAudio.swift index 91504ab5d..98e0ba2f5 100755 --- a/LoopFollow/Helpers/BackgroundTaskAudio.swift +++ b/LoopFollow/Helpers/BackgroundTaskAudio.swift @@ -9,26 +9,37 @@ class BackgroundTask { var player = AVAudioPlayer() var timer = Timer() + private var retryCount = 0 + private let maxRetries = 3 + private var retryTimer: Timer? + // MARK: - Methods func startBackgroundTask() { NotificationCenter.default.addObserver(self, selector: #selector(interruptedAudio), name: AVAudioSession.interruptionNotification, object: AVAudioSession.sharedInstance()) + retryCount = 0 playAudio() } func stopBackgroundTask() { NotificationCenter.default.removeObserver(self, name: AVAudioSession.interruptionNotification, object: nil) + retryTimer?.invalidate() + retryTimer = nil player.stop() LogManager.shared.log(category: .general, message: "Silent audio stopped", isDebug: true) } @objc fileprivate func interruptedAudio(_ notification: Notification) { LogManager.shared.log(category: .general, message: "Silent audio interrupted") - if notification.name == AVAudioSession.interruptionNotification, notification.userInfo != nil { - var info = notification.userInfo! - var intValue = 0 - (info[AVAudioSessionInterruptionTypeKey]! as AnyObject).getValue(&intValue) - if intValue == 1 { playAudio() } + guard notification.name == AVAudioSession.interruptionNotification, + let userInfo = notification.userInfo, + let typeValue = userInfo[AVAudioSessionInterruptionTypeKey] as? UInt, + let type = AVAudioSession.InterruptionType(rawValue: typeValue) + else { return } + + if type == .ended { + retryCount = 0 + playAudio() } } @@ -36,7 +47,6 @@ class BackgroundTask { do { let bundle = Bundle.main.path(forResource: "blank", ofType: "wav") let alertSound = URL(fileURLWithPath: bundle!) - // try AVAudioSession.sharedInstance().setCategory(AVAudioSession.Category.playback) try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default, options: .mixWithOthers) try AVAudioSession.sharedInstance().setActive(true) try player = AVAudioPlayer(contentsOf: alertSound) @@ -45,9 +55,25 @@ class BackgroundTask { player.volume = 0.01 player.prepareToPlay() player.play() + retryCount = 0 LogManager.shared.log(category: .general, message: "Silent audio playing", isDebug: true) } catch { LogManager.shared.log(category: .general, message: "playAudio, error: \(error)") + if retryCount < maxRetries { + retryCount += 1 + LogManager.shared.log(category: .general, message: "playAudio retry \(retryCount)/\(maxRetries) in 2s") + retryTimer?.invalidate() + retryTimer = Timer.scheduledTimer(withTimeInterval: 2.0, repeats: false) { [weak self] _ in + self?.playAudio() + } + } else { + LogManager.shared.log(category: .general, message: "playAudio failed after \(maxRetries) retries — posting BackgroundAudioFailed") + NotificationCenter.default.post(name: .backgroundAudioFailed, object: nil) + } } } } + +extension Notification.Name { + static let backgroundAudioFailed = Notification.Name("BackgroundAudioFailed") +} diff --git a/LoopFollow/LiveActivity/LiveActivityManager.swift b/LoopFollow/LiveActivity/LiveActivityManager.swift index fc110d195..00d230e40 100644 --- a/LoopFollow/LiveActivity/LiveActivityManager.swift +++ b/LoopFollow/LiveActivity/LiveActivityManager.swift @@ -32,6 +32,12 @@ final class LiveActivityManager { name: UIApplication.willResignActiveNotification, object: nil ) + NotificationCenter.default.addObserver( + self, + selector: #selector(handleBackgroundAudioFailed), + name: .backgroundAudioFailed, + object: nil + ) } /// Fires before the app loses focus (lock screen, home button, etc.). @@ -134,6 +140,16 @@ final class LiveActivityManager { } } + @objc private func handleBackgroundAudioFailed() { + guard Storage.shared.laEnabled.value, current != nil else { return } + // The background audio session has permanently failed — the app will lose its + // background keep-alive. Immediately push the renewal overlay so the user sees + // "Tap to update" on the lock screen and knows to foreground the app. + LogManager.shared.log(category: .general, message: "[LA] background audio failed — forcing renewal overlay") + Storage.shared.laRenewBy.value = Date().timeIntervalSince1970 + refreshFromCurrentState(reason: "audio-session-failed") + } + static let renewalThreshold: TimeInterval = 7.5 * 3600 static let renewalWarning: TimeInterval = 20 * 60 From 3769275294aaabbe875707dce7cce96feea37ac0 Mon Sep 17 00:00:00 2001 From: Phil A <76601115+MtlPhil@users.noreply.github.com> Date: Wed, 18 Mar 2026 08:25:31 -0400 Subject: [PATCH 64/82] Update BackgroundTaskAudio.swift --- LoopFollow/Helpers/BackgroundTaskAudio.swift | 61 +++++++++++++------- 1 file changed, 39 insertions(+), 22 deletions(-) diff --git a/LoopFollow/Helpers/BackgroundTaskAudio.swift b/LoopFollow/Helpers/BackgroundTaskAudio.swift index 98e0ba2f5..67e76a03e 100755 --- a/LoopFollow/Helpers/BackgroundTaskAudio.swift +++ b/LoopFollow/Helpers/BackgroundTaskAudio.swift @@ -5,45 +5,62 @@ import AVFoundation class BackgroundTask { // MARK: - Vars - + var player = AVAudioPlayer() - var timer = Timer() - + private var retryCount = 0 private let maxRetries = 3 - private var retryTimer: Timer? - + // MARK: - Methods - + func startBackgroundTask() { NotificationCenter.default.addObserver(self, selector: #selector(interruptedAudio), name: AVAudioSession.interruptionNotification, object: AVAudioSession.sharedInstance()) retryCount = 0 playAudio() } - + func stopBackgroundTask() { NotificationCenter.default.removeObserver(self, name: AVAudioSession.interruptionNotification, object: nil) - retryTimer?.invalidate() - retryTimer = nil player.stop() LogManager.shared.log(category: .general, message: "Silent audio stopped", isDebug: true) } - + @objc fileprivate func interruptedAudio(_ notification: Notification) { - LogManager.shared.log(category: .general, message: "Silent audio interrupted") guard notification.name == AVAudioSession.interruptionNotification, let userInfo = notification.userInfo, let typeValue = userInfo[AVAudioSessionInterruptionTypeKey] as? UInt, let type = AVAudioSession.InterruptionType(rawValue: typeValue) else { return } - - if type == .ended { + + switch type { + case .began: + LogManager.shared.log(category: .general, message: "[LA] Silent audio session interrupted (began)") + + case .ended: + // Check shouldResume hint — skip restart if iOS says not to + if let optionsValue = userInfo[AVAudioSessionInterruptionOptionKey] as? UInt { + let options = AVAudioSession.InterruptionOptions(rawValue: optionsValue) + guard options.contains(.shouldResume) else { + LogManager.shared.log(category: .general, message: "[LA] Silent audio interruption ended — shouldResume not set, skipping restart") + return + } + } + LogManager.shared.log(category: .general, message: "[LA] Silent audio interruption ended — scheduling restart in 0.5s") retryCount = 0 - playAudio() + // Brief delay to let the interrupting app (e.g. Clock alarm) fully release the audio + // session before we attempt to reactivate. Without this, setActive(true) races with + // the alarm and fails with AVAudioSession.ErrorCode.cannotInterruptOthers (560557684). + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in + self?.playAudio() + } + + @unknown default: + break } } - + fileprivate func playAudio() { + let attemptDesc = retryCount == 0 ? "initial attempt" : "retry \(retryCount)/\(maxRetries)" do { let bundle = Bundle.main.path(forResource: "blank", ofType: "wav") let alertSound = URL(fileURLWithPath: bundle!) @@ -56,14 +73,13 @@ class BackgroundTask { player.prepareToPlay() player.play() retryCount = 0 - LogManager.shared.log(category: .general, message: "Silent audio playing", isDebug: true) + LogManager.shared.log(category: .general, message: "Silent audio playing (\(attemptDesc))", isDebug: true) } catch { - LogManager.shared.log(category: .general, message: "playAudio, error: \(error)") + LogManager.shared.log(category: .general, message: "playAudio failed (\(attemptDesc)), error: \(error)") if retryCount < maxRetries { retryCount += 1 - LogManager.shared.log(category: .general, message: "playAudio retry \(retryCount)/\(maxRetries) in 2s") - retryTimer?.invalidate() - retryTimer = Timer.scheduledTimer(withTimeInterval: 2.0, repeats: false) { [weak self] _ in + LogManager.shared.log(category: .general, message: "playAudio scheduling retry \(retryCount)/\(maxRetries) in 2s") + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { [weak self] in self?.playAudio() } } else { @@ -72,8 +88,9 @@ class BackgroundTask { } } } + } extension Notification.Name { - static let backgroundAudioFailed = Notification.Name("BackgroundAudioFailed") -} + static let backgroundAudioFailed = Notification.Name(“BackgroundAudioFailed”) +} \ No newline at end of file From 27a6efc89326cdee96d1a53816f653392b29f2fb Mon Sep 17 00:00:00 2001 From: Phil A <76601115+MtlPhil@users.noreply.github.com> Date: Wed, 18 Mar 2026 08:28:49 -0400 Subject: [PATCH 65/82] Live Activity: foreground restart on overlay, audio session recovery, layout fixes, cleanup (#554) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: Live Activity auto-renewal to work around 8-hour system limit Co-Authored-By: Claude Sonnet 4.6 * test: reduce LA renewal threshold to 20 min for testing Co-Authored-By: Claude Sonnet 4.6 * feat: improve LA renewal robustness and stale indicator - staleDate on every ActivityContent now tracks the renewal deadline, so the system shows Apple's built-in stale indicator if renewal fails - Add laRenewalFailed StorageValue; set on Activity.request() failure, cleared on any successful LA start - Observe willEnterForegroundNotification: retry startIfNeeded() if a previous renewal attempt failed - New-first renewal order: request the replacement LA before ending the old one — if the request throws the existing LA stays alive so the user keeps live data until the system kills it at the 8-hour mark Co-Authored-By: Claude Sonnet 4.6 * feat: renewal warning overlay + restore 7.5h threshold - Restore renewalThreshold to 7.5 * 3600 (testing complete) - Add showRenewalOverlay: Bool to GlucoseSnapshot (Codable, default false) - GlucoseSnapshotBuilder sets it true when now >= laRenewBy - 1800 (30 minutes before the renewal deadline) - Lock screen: 60% gray overlay with "Tap to update" centered in white, layered above the existing isNotLooping overlay - DI expanded: RenewalOverlayView applied to leading/trailing/bottom regions; "Tap to update" text shown on the bottom region only - showRenewalOverlay resets to false automatically on renewal since laRenewBy is updated and the next snapshot rebuild clears the flag Co-Authored-By: Claude Sonnet 4.6 * fix: overlay not appearing + foreground restart not working Overlay rendering: - Replace Group{if condition{ZStack{RoundedRectangle...}}} with a permanently-present ZStack toggled via .opacity(). The Group/if pattern causes SwiftUI sizing ambiguity when the condition transitions from false→true inside an .overlay(), producing a zero-size result. The .opacity() approach keeps a stable view hierarchy. - Same fix applied to RenewalOverlayView used on DI expanded regions. Foreground restart: - handleForeground() was calling startIfNeeded(), which finds the still-alive (failed-to-renew) LA in Activity.activities and reuses it, doing nothing useful. Fixed to manually nil out current, cancel all tasks, await activity.end(.immediate), then startFromCurrentState(). Overlay timing: - Changed warning window from 30 min (1800s) to 20 min (1200s) before the renewal deadline, matching the intended test cadence. Logging: - handleForeground: log on every foreground event with laRenewalFailed value - renewIfNeeded: log how many seconds past the deadline when firing - GlucoseSnapshotBuilder: log when overlay activates with seconds to deadline - performRefresh: log when sending an update with the overlay visible Co-Authored-By: Claude Sonnet 4.6 * test: set renewalThreshold to 20 min for testing Co-Authored-By: Claude Sonnet 4.6 * fix: renewal overlay not clearing after LA is refreshed Normal renewal path (renewIfNeeded success): - Build a fresh snapshot with showRenewalOverlay: false for the new LA's initial content — it has a new deadline so the overlay should never be visible from the first frame. - Save that fresh snapshot to GlucoseSnapshotStore so the next duplicate check has the correct baseline and doesn't suppress the first real BG update. Foreground restart path (handleForeground): - Zero laRenewBy before calling startFromCurrentState() so GlucoseSnapshotBuilder computes showRenewalOverlay = false for the seed snapshot. startIfNeeded() then sets the new deadline after the request succeeds. Co-Authored-By: Claude Sonnet 4.6 * fix: overlay permanently active when warning window equals threshold With renewalThreshold=20min and the hardcoded 1200s warning window, renewBy-1200 = start, so showRenewalOverlay is always true from the moment the LA begins. Extract a named renewalWarning constant (5 min for testing) so the warning window is always less than the threshold. The builder now reads LiveActivityManager.renewalWarning instead of a hardcoded literal. Production values to restore before merging: renewalThreshold = 7.5 * 3600 renewalWarning = 20 * 60 Co-Authored-By: Claude Sonnet 4.6 * fix: include showRenewalOverlay in APNs payload and clear laRenewBy synchronously APNSClient was missing showRenewalOverlay from the push payload, so background APNs updates never delivered the overlay flag to the extension — only foreground direct ActivityKit updates did. In handleForeground, laRenewBy is now zeroed synchronously before spawning the async end/restart Task. This means any snapshot built between the foreground notification and the new LA start (e.g. from viewDidAppear's startFromCurrentState) computes showRenewalOverlay = false rather than reading the stale expired deadline. Co-Authored-By: Claude Sonnet 4.6 * fix: await LA end before restarting on foreground retry to avoid reuse path Reset laRenewBy and laRenewalFailed synchronously before tearing down the failed LA, then await activity.end() before calling startFromCurrentState(). This guarantees Activity.activities is clear when startIfNeeded() runs, so it takes the fresh-request path and writes a new laRenewBy. startFromCurrentState rebuilds the snapshot with showRenewalOverlay=false (laRenewBy=0), saves it to the store, then startIfNeeded uses that clean snapshot as the seed. Co-Authored-By: Claude Sonnet 4.6 * chore: restore production renewal timing (7.5h threshold, 20min warning) Co-Authored-By: Claude Sonnet 4.6 * feat: laEnabled toggle, forceRestart(), and RestartLiveActivityIntent - SettingsMenuView: rename "APN" menu entry to "Live Activity" - Storage: add laEnabled: Bool StorageValue (default false) - APNSettingsView: on/off toggle bound to laEnabled; APNs key fields and Restart button shown only when enabled; disabling immediately ends any running LA - LiveActivityManager: - forceRestart() (@MainActor) ends all running activities, resets laRenewBy and laRenewalFailed, then calls startFromCurrentState() - laEnabled guard added to startFromCurrentState(), refreshFromCurrentState(), handleForeground(), and handleDidBecomeActive() - didBecomeActiveNotification observer calls forceRestart() on every foreground transition when laEnabled is true - RestartLiveActivityIntent: AppIntent + ForegroundContinuableIntent; sets laEnabled, validates credentials, opens settings deep link if missing, otherwise calls forceRestart() in foreground; LoopFollowAppShortcuts exposes the intent with Siri phrase Note: RestartLiveActivityIntent.swift must be added to the app target in Xcode. Co-Authored-By: Claude Sonnet 4.6 * Added RestartLiveActivityIntent to project * fix: resolve two build errors in LiveActivityManager and RestartLiveActivityIntent - LiveActivityManager: handleDidBecomeActive() calls @MainActor forceRestart() via Task { @MainActor in ... } to satisfy Swift concurrency isolation - RestartLiveActivityIntent: drop ForegroundContinuableIntent — continueInForeground() was renamed to continueInForeground(_:alwaysConfirm:) in the iOS 26 SDK and requires iOS 26+; the didBecomeActiveNotification observer handles restart when the app comes to foreground, making explicit continuation unnecessary Co-Authored-By: Claude Sonnet 4.6 * fix: guard continueInForeground() behind iOS 26 availability check continueInForeground(_:alwaysConfirm:) is iOS 26+ only. On earlier versions, forceRestart() runs directly via the existing background audio session — no foreground continuation needed. Co-Authored-By: Claude Sonnet 4.6 * fix: use startFromCurrentState in handleDidBecomeActive instead of forceRestart forceRestart() was killing the LA that the RestartLiveActivityIntent had just created, because continueInForeground() triggers didBecomeActive. startFromCurrentState() reuses an existing active LA via startIfNeeded() and only creates one if none is present — no destructive race. Co-Authored-By: Claude Sonnet 4.6 * feat: LA foreground tab navigation, button feedback, and toggle sync APNSettingsView: - "Restart Live Activity" button shows "Live Activity Restarted" for 2 seconds and disables itself during that window to prevent double-taps - Toggle observes Storage.shared.laEnabled via onReceive so it reflects changes made externally (e.g. by the App Intent / Shortcuts) LiveActivityManager: - handleDidBecomeActive posts .liveActivityDidForeground after startFromCurrentState - Notification.Name.liveActivityDidForeground extension defined here MainViewController: - Observes .liveActivityDidForeground; navigates to Snoozer tab if an alarm is currently active, otherwise navigates to tab 0 (Home) Co-Authored-By: Claude Sonnet 4.6 * fix: flush LA update on willResignActive to ensure lock screen shows latest data The 5-second debounce on refreshFromCurrentState means a BG update arriving while the app is foreground won't reach the LA before the user returns to the lock screen. When the debounce fires in background, isForeground=false so the direct ActivityKit update is skipped. Fix: observe willResignActiveNotification, cancel the pending debounce, and immediately push the latest snapshot via direct ActivityKit update while the app is still foreground-active. The APNs push is also sent as a backup. Co-Authored-By: Claude Sonnet 4.6 * feat: redesign Dynamic Island compact and expanded views Compact: - Trailing now shows delta instead of trend arrow (leading already showed BG) Expanded leading: - Large BG value at top - Second row: trend arrow, delta, and "Proj: X" in smaller text below Expanded trailing: - IOB and COB (moved from bottom) Expanded bottom: - "Updated at: HH:mm" (moved from trailing) Co-Authored-By: Claude Sonnet 4.6 * fix: match Proj text style to delta; add trailing padding to IOB/COB - Proj label now uses same size (13), weight (semibold), and opacity (0.9) as the delta text beside it - Added 6pt trailing padding to the expanded DI trailing VStack so the IOB decimal is not clipped by the Dynamic Island edge Co-Authored-By: Claude Sonnet 4.6 * feat: separate Live Activity and APN settings into distinct menus Per maintainer preference, the APNs credentials (Key ID and Key) remain in their own "APN" settings screen. A new separate "Live Activity" settings screen contains the enable/disable toggle and the Restart Live Activity button. - APNSettingsView: restored to Key ID + Key only (no toggle or restart button) - LiveActivitySettingsView: new view with laEnabled toggle and restart button (with 2-second confirmation feedback); toggle stays in sync via onReceive - SettingsMenuView: "APN" entry restored; new "Live Activity" entry added with dot.radiowaves icon; Sheet.liveActivity case wired to LiveActivitySettingsView Co-Authored-By: Claude Sonnet 4.6 * Added Live Activity menu * chore: add LiveActivitySettingsView to Xcode project Co-Authored-By: Claude Sonnet 4.6 * fix: LA tap navigation, manual dismissal prevention, and toggle start - widgetURL on lock screen LA → opens app via loopfollow://la-tap - Link views in expanded DI regions → same URL on tap - AppDelegate.application(_:open:) handles loopfollow://la-tap and posts .liveActivityDidForeground so MainViewController navigates to Snoozer (if alarm active) or Home tab — fires only on LA/DI taps, not every foreground - Notification.Name.liveActivityDidForeground moved outside #if macCatalyst so AppDelegate can reference it unconditionally - handleDidBecomeActive no longer posts .liveActivityDidForeground (was firing on every app foreground, not just LA taps) - attachStateObserver: .dismissed state (user swipe) sets laEnabled = false so the LA does not auto-restart when the app foregrounds - LiveActivitySettingsView: toggling ON calls startFromCurrentState() Co-Authored-By: Claude Sonnet 4.6 * fix: end Live Activity on app force-quit Adds endOnTerminate() to LiveActivityManager — blocks up to 3 s using Task.detached + DispatchSemaphore so the async activity.end() completes before the process exits. Called from applicationWillTerminate so the LA clears from the lock screen immediately on force-quit. laEnabled is preserved so the LA restarts correctly on next launch. Co-Authored-By: Claude Sonnet 4.6 * fix: use dismissedByUser flag instead of disabling laEnabled on manual dismiss - Manual LA swipe-away sets dismissedByUser (in-memory) instead of laEnabled = false, preserving the user's preference - startFromCurrentState() guards on !dismissedByUser so the LA does not auto-restart on foreground, BG refresh, or handleDidBecomeActive - forceRestart() clears dismissedByUser before starting, so the Restart button and App Intent both work as the explicit re-enable mechanism - Toggle ON in settings calls forceRestart() (clears flag + starts) instead of startFromCurrentState() which would have been blocked by the flag - dismissedByUser resets to false on app relaunch (in-memory only), so a kill + relaunch starts the LA fresh as expected Co-Authored-By: Claude Sonnet 4.6 * fix: dismiss modal (Settings sheet) before tab switch on LA tap navigateOnLAForeground now checks tabBarController.presentedViewController and dismisses it before switching to the target tab, matching the existing pattern used elsewhere in MainViewController for the same scenario. Co-Authored-By: Claude Sonnet 4.6 * fix: LA tap navigation timing and LA reappear-after-dismiss Navigation fix: - loopfollow://la-tap URL can arrive before applicationDidBecomeActive while UIKit is still restoring the view hierarchy from background. At that moment tabBarController.presentedViewController is nil so the Settings modal is never dismissed and the navigation notification fires too early. - Now stores a pendingLATapNavigation flag if app is not yet .active, and fires the notification in applicationDidBecomeActive when the full view hierarchy (including presented modals) is restored. LA reappear fix: - performRefresh() calls startIfNeeded() directly, bypassing the dismissedByUser guard that only exists in startFromCurrentState(). On every BG refresh with laEnabled=true and current==nil (post-dismiss), it would recreate the LA. - Added !dismissedByUser guard to refreshFromCurrentState() so the entire refresh pipeline is skipped while dismissed, matching the existing guard in startFromCurrentState(). Co-Authored-By: Claude Sonnet 4.6 * fix: handle loopfollow://la-tap URL in SceneDelegate, not AppDelegate With scene-based lifecycle (iOS 13+), widget/LA tap URLs are delivered to scene(_:openURLContexts:) on UIWindowSceneDelegate — AppDelegate.application (_:open:url:options:) is never called. Our previous handler was dead code. SceneDelegate now implements scene(_:openURLContexts:) which posts .liveActivityDidForeground on the next run loop (via DispatchQueue.main.async) to let the view hierarchy fully settle before navigateOnLAForeground() runs. Also handles the edge case where the URL arrives before sceneDidBecomeActive by storing a pendingLATapNavigation flag. Co-Authored-By: Claude Sonnet 4.6 * feat: configurable LA grid slots + full InfoType snapshot coverage Lock screen layout: - Left column: glucose + trend arrow, delta below (replaces "Last Update:") - Right grid: 4 configurable slots read from LAAppGroupSettings (defaults: IOB/COB/Proj/empty) - Footer: centered HH:MM update time at reduced opacity Slot configuration: - LiveActivitySlotOption enum with all 22 InfoType-aligned cases - LAAppGroupSettings.setSlots() / slots() persisted to App Group UserDefaults - Uniqueness enforced: selecting an option clears it from any other slot - Settings UI: "Grid slots" section with 4 pickers in LiveActivitySettingsView - Changes take effect immediately (refreshFromCurrentState called on save) GlucoseSnapshot extended with 19 new fields covering all InfoType items: override, recBolus, battery, pumpBattery, basalRate, pumpReservoirU, autosens, tdd, targetLowMgdl/High, isfMgdlPerU, carbRatio, carbsToday, profileName, sageInsertTime, cageInsertTime, iageInsertTime, minBgMgdl/Max Storage.swift: 13 new UserDefaults-backed fields for the above metrics Controllers updated to write new Storage fields on each data fetch: Basals, DeviceStatus, DeviceStatusLoop, DeviceStatusOpenAPS, Carbs, Profile, IAge Co-Authored-By: Claude Sonnet 4.6 * fix: label delta and footer on lock screen LA card Co-Authored-By: Claude Sonnet 4.6 * docs: add PR description for configurable LA grid slots Co-Authored-By: Claude Sonnet 4.6 * Update PR_configurable_slots.md * chore: remove PR notes from tracking, keep docs/LiveActivity.md only - Untrack docs/PR_configurable_slots.md (local-only reference doc) - Add docs/PR_configurable_slots.md and docs/LiveActivityTestPlan.md to .gitignore Co-Authored-By: Claude Sonnet 4.6 * fix: include all extended InfoType fields in APNs push payload buildPayload() was only serializing the base fields (glucose, delta, trend, updatedAt, unit, iob, cob, projected). All extended fields added with the configurable grid slots (battery, sageInsertTime, pumpBattery, basalRate, autosens, tdd, targets, isf, carbRatio, carbsToday, profileName, CAGE, IAGE, minBg, maxBg, override, recBolus) were missing from the APNs payload, causing the extension to decode them as nil/0 and display '--' on every push-driven refresh. Co-Authored-By: Claude Sonnet 4.6 * feat: add small family view for CarPlay Dashboard and Watch Smart Stack Registers the Live Activity for the small activity family via .supplementalActivityFamilies([.small]), enabling automatic display on: - CarPlay Dashboard (iOS 26+) - Apple Watch Smart Stack (watchOS 11+) The small view is hardcoded to the essentials appropriate for a driving context: glucose value, trend arrow, delta, and time since last reading. Background tint matches the existing threshold-based color logic. The lock screen layout (full grid with configurable slots) is unchanged. Co-Authored-By: Claude Sonnet 4.6 * fix: guard CarPlay/Watch small family behind iOS 18 availability; increase renewal overlay opacity - supplementalActivityFamilies and activityFamily require iOS 18.0+; restructured into two Widget structs sharing a makeDynamicIsland() helper: - LoopFollowLiveActivityWidget (iOS 16.1+): lock screen + Dynamic Island - LoopFollowLiveActivityWidgetWithCarPlay (iOS 18.0+): adds .supplementalActivityFamilies([.small]) - Bundle uses if #available(iOS 18.0, *) to select the right variant - Podfile: set minimum deployment target to 16.6 for all pods - Increase 'Tap to update' renewal overlay opacity from 60% to 90% Co-Authored-By: Claude Sonnet 4.6 * fix: move if #available into Widget.body to avoid WidgetBundleBuilder limitation @WidgetBundleBuilder does not support if #available { } else { }, but @WidgetConfigurationBuilder (used by Widget.body) does. Collapsed back to a single LoopFollowLiveActivityWidget struct with the iOS 18 conditional inside body — iOS 18+ branch adds .supplementalActivityFamilies([.small]) for CarPlay and Watch Smart Stack; else branch uses the plain lock screen view. Bundle reverts to the original single if #available(iOS 16.1, *) pattern. Co-Authored-By: Claude Sonnet 4.6 * fix: use two separate single-branch if #available in bundle for CarPlay support @WidgetConfigurationBuilder's buildEither requires both branches to return the same concrete type, making if/else with supplementalActivityFamilies impossible (it wraps to a different opaque type). @WidgetBundleBuilder does not support if #available { } else { } at all. Solution: two separate single-branch if #available blocks in the bundle — the pattern that @WidgetBundleBuilder already supported in the original code: - LoopFollowLiveActivityWidget (iOS 16.1+): primary, lock screen + Dynamic Island - LoopFollowLiveActivityWidgetWithCarPlay (iOS 18.0+): supplemental, adds .supplementalActivityFamilies([.small]) for CarPlay Dashboard + Watch Smart Stack ActivityKit uses the supplemental widget for small-family surfaces and the primary widget for lock screen / Dynamic Island, keeping iOS 16.6+ support intact. Co-Authored-By: Claude Sonnet 4.6 * fix: restore two-widget bundle; guard supplementalActivityFamilies and activityFamily behind iOS 18 Upstream's single-widget approach placed iOS 18+ APIs (supplementalActivityFamilies, activityFamily, ActivityFamily) behind @available(iOS 16.1, *), which fails to compile at the 16.6 deployment target. Restoring the two-widget pattern: - LoopFollowLiveActivityWidget (@available iOS 16.1): lock screen + DI, uses LockScreenLiveActivityView, no supplementalActivityFamilies - LoopFollowLiveActivityWidgetWithCarPlay (@available iOS 18.0): adds CarPlay Dashboard + Watch Smart Stack via supplementalActivityFamilies([.small]), uses LockScreenFamilyAdaptiveView (also @available iOS 18.0) - SmallFamilyView availability corrected to @available(iOS 18.0, *) - Bundle registers both via separate if #available blocks Co-Authored-By: Claude Sonnet 4.6 * fix: extension version inherits from parent; remove spurious await in slot config - LoopFollowLAExtension MARKETING_VERSION now uses "$(MARKETING_VERSION)" to match the parent app version automatically, resolving CFBundleShortVersionString mismatch warning - Remove unnecessary Task/await wrapping of refreshFromCurrentState in LiveActivitySettingsView — the method is not async Co-Authored-By: Claude Sonnet 4.6 * fix: prevent glucose + trend arrow clipping on wide mmol/L values At 46pt, a 4-character mmol/L value ("10.5") plus "↑↑" overflowed the 168pt left column, truncating the glucose reading. Fix: reduce trend arrow to 32pt and add minimumScaleFactor(0.7) + lineLimit(1) to the glucose text so values above 10 mmol/L render correctly. Co-Authored-By: Claude Sonnet 4.6 * chore: remove redundant @available(iOS 16.1) guards The app's minimum deployment target is iOS 16.6, making all iOS 16.1 availability checks redundant. Removed @available(iOS 16.1, *) annotations from all types and the if #available(iOS 16.1, *) wrapper in the bundle. Co-Authored-By: Claude Sonnet 4.6 * Fix Live Activity glucose overflow with flexible layout and tighter grid spacing * Fix Live Activity glucose overflow with flexible layout and tighter grid spacing Co-Authored-By: Claude Sonnet 4.6 * fix: restart LA on foreground when renewal overlay is showing Previously handleForeground() only restarted the LA when laRenewalFailed=true, but the renewal overlay also appears as a warning before renewal is attempted (while laRenewalFailed is still false). Users who foregrounded during the warning window saw the overlay persist with no restart occurring. Now triggers a restart whenever the overlay is showing (within the warning window before the deadline) OR renewal previously failed. Co-Authored-By: Claude Sonnet 4.6 * fix: recover from audio session failure and alert user via LA overlay When AVAudioSession.setActive() fails (e.g. another app holds the session exclusively), the app loses its background keep-alive with no recovery path. Two bugs fixed and recovery logic added: 1. interruptedAudio handler was calling playAudio() on interruption *began* (intValue == 1) instead of *ended* — corrected to restart on .ended only. 2. playAudio() catch block now retries up to 3 times (2s apart). After all retries are exhausted it posts a BackgroundAudioFailed notification. 3. LiveActivityManager observes BackgroundAudioFailed and immediately sets laRenewBy to now (making showRenewalOverlay = true) then pushes a refresh so the lock screen overlay tells the user to foreground the app. Co-Authored-By: Claude Sonnet 4.6 * Update BackgroundTaskAudio.swift --------- Co-authored-by: Claude Sonnet 4.6 --- CLAUDE.md | 479 ++++++++++++++++++ LoopFollow/Helpers/BackgroundTaskAudio.swift | 75 ++- .../LiveActivity/LiveActivityManager.swift | 30 +- .../LoopFollowLABundle.swift | 5 +- .../LoopFollowLiveActivity.swift | 31 +- 5 files changed, 580 insertions(+), 40 deletions(-) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..0f9f62f04 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,479 @@ +# LoopFollow Live Activity — Project Context for Claude Code + +## Who you're working with + +This codebase is being developed by **Philippe** (GitHub: `MtlPhil`), contributing to +`loopandlearn/LoopFollow` — an open-source iOS app that lets parents and caregivers of T1D +Loop users monitor glucose and loop status in real time. + +- **Upstream repo:** `https://github.com/loopandlearn/LoopFollow` +- **Philippe's fork:** `https://github.com/achkars-org/LoopFollow` +- **Local clone:** `/Users/philippe/Documents/GitHub/LoopFollowLA/` +- **Active upstream branch:** `live-activity` (PR #537, draft, targeting `dev`) +- **Philippe's original PR:** `#534` (closed, superseded by #537) +- **Maintainer:** `bjorkert` (Jonas Björkert) + +--- + +## What this feature is + +A **Live Activity** for LoopFollow that displays real-time glucose data on the iOS lock screen +and in the Dynamic Island. The feature uses **APNs self-push** — the app sends a push +notification to itself — to drive reliable background updates without interfering with the +background audio session LoopFollow uses to stay alive. + +### What the Live Activity shows +- Current glucose value + trend arrow +- Delta (change since last reading) +- IOB, COB, projected BG (optional — omitted gracefully for Dexcom-only users) +- Time since last reading +- "Not Looping" red banner when Loop hasn't reported in 15+ minutes +- Threshold-driven background color (green / orange / red) +- Dynamic Island: compact, expanded, and minimal presentations + +--- + +## Architecture overview (current state in PR #537) + +### Data flow +``` +BGData / DeviceStatusLoop / DeviceStatusOpenAPS + → write canonical values to Storage.shared + → GlucoseSnapshotBuilder reads Storage + → builds GlucoseSnapshot + → LiveActivityManager pushes via APNSClient + → LoopFollowLAExtension renders the UI +``` + +### Key files + +| File | Purpose | +|------|---------| +| `LiveActivity/LiveActivityManager.swift` | Orchestrates start/stop/refresh of the Live Activity; called from `MainViewController` | +| `LiveActivity/APNSClient.swift` | Sends the APNs self-push; uses `JWTManager.shared` for JWT; reads credentials from `Storage.shared` | +| `Helpers/JWTManager.swift` | **bjorkert addition** — replaces `APNSJWTGenerator`; uses CryptoKit (P256/ES256); multi-slot in-memory cache keyed by `keyId:teamId`, 55-min TTL | +| `LiveActivity/GlucoseSnapshot.swift` | The value-type snapshot passed to the extension; timestamp stored as Unix epoch seconds (UTC) — **timezone bug was fixed here** | +| `LiveActivity/GlucoseSnapshotBuilder.swift` | Reads from Storage, constructs GlucoseSnapshot | +| `LiveActivity/GlucoseSnapshotStore.swift` | In-memory store; debounces rapid successive refreshes | +| `LiveActivity/GlucoseLiveActivityAttributes.swift` | ActivityKit attributes struct | +| `LiveActivity/AppGroupID.swift` | Derives App Group ID dynamically from bundle identifier — no hardcoded team IDs | +| `LiveActivity/LAAppGroupSettings.swift` | Persists LA-specific settings to the shared App Group container | +| `LiveActivity/LAFormat.swift` | **bjorkert addition** — display formatting for LA values; uses `NumberFormatter` with `Locale.current` so decimal separators match device locale (e.g. "5,6" in Swedish) | +| `LiveActivity/PreferredGlucoseUnit.swift` | Reads preferred unit; delegates to `Localizer.getPreferredUnit()` — no longer duplicates unit detection logic | +| `GlucoseConversion.swift` | **Replaces `GlucoseUnitConversion.swift`** — unified constant `18.01559`; `mgDlToMmolL` is a computed reciprocal. Note: the old file used `18.0182` — do not use that constant anywhere | +| `LiveActivity/StorageCurrentGlucoseStateProvider.swift` | Protocol adapter between Storage and LiveActivityManager | +| `LoopFollowLAExtension/LoopFollowLiveActivity.swift` | SwiftUI widget views for lock screen + Dynamic Island | +| `LoopFollowLAExtension/LoopFollowLABundle.swift` | Extension bundle entry point | +| `Settings/APNSettingsView.swift` | **bjorkert addition** — dedicated settings screen for LoopFollow's own APNs key ID and key | +| `Storage/Storage.swift` | Added: `lastBgReadingTimeSeconds`, `lastDeltaMgdl`, `lastTrendCode`, `lastIOB`, `lastCOB`, `projectedBgMgdl` | +| `Storage/Observable.swift` | Added: `isNotLooping` | +| `Storage/Storage+Migrate.swift` | Added: `migrateStep5` — migrates legacy APNs credential keys to new split format | + +--- + +## The core design decisions Philippe made (and why) + +### 1. APNs self-push for background updates +LoopFollow uses a background audio session to stay alive in the background. Initially, the +temptation was to use `ActivityKit` updates directly from the app. The self-push approach was +chosen because it is more reliable and doesn't create timing conflicts with the audio session. +The app sends a push to itself using its own APNs key; the system delivers it with high +priority, waking the extension. + +### 2. Dynamic App Group ID (no hardcoded team IDs) +`AppGroupID.swift` derives the App Group ID from the bundle identifier at runtime. This makes +the feature work across all fork/build configurations without embedding any team-specific +identifiers in code. + +### 3. Single source of truth in Storage +All glucose and loop state is written to `Storage.shared` (and `Observable`) by the existing +data-fetching controllers (BGData, DeviceStatusLoop, DeviceStatusOpenAPS). The Live Activity +layer is purely a consumer — it never fetches its own data. This keeps the architecture clean +and source-agnostic. + +### 4. GlucoseSnapshot stores glucose in mg/dL only — conversion at display time only +The snapshot is a simple struct with no dependencies, designed to be safe to pass across the +app/extension boundary. All glucose values in `GlucoseSnapshot` are stored as **mg/dL**. +Conversion to mmol/L happens exclusively at display time inside `LAFormat`. This eliminates +the previous round-trip (mg/dL → mmol/L at snapshot creation, then mmol/L → mg/dL for +threshold comparison) that bjorkert identified and removed. + +**Rule for all future code:** anything writing a glucose value into a `GlucoseSnapshot` must +supply mg/dL. Anything reading a glucose value from a snapshot for display must convert via +`GlucoseConversion.mgDlToMmolL` if the user's preferred unit is mmol/L. + +### 5. Unix epoch timestamps (UTC) in GlucoseSnapshot +**Critical bug that was discovered and fixed:** ActivityKit operates in UTC epoch seconds, +but the original code was constructing timestamps using local time offsets, causing DST +errors of ±1 hour. The fix ensures all timestamps in `GlucoseSnapshot` are stored as +`TimeInterval` (seconds since Unix epoch, UTC) and converted to display strings only in the +extension, using the device's local calendar. This fix is in the codebase. + +### 6. Debounce on rapid refreshes +A coalescing `DispatchWorkItem` pattern is used in `GlucoseSnapshotStore` to debounce +rapid successive calls to refresh (e.g., when multiple Storage values update in quick +succession during a data fetch). Only one APNs push is sent per update cycle. + +### 7. APNs key injected via xcconfig/Info.plist (Philippe's original approach) +In Philippe's original PR #534, the APNs key was injected at build time via +`xcconfig` / `Info.plist`, sourced from a GitHub Actions secret. This meant credentials were +baked into the build and never committed. + +--- + +## What bjorkert changed (and why it differs from Philippe's approach) + +### Change 1: SwiftJWT → CryptoKit (`JWTManager.swift`) +**Philippe used:** `SwiftJWT` + `swift-crypto` SPM packages for JWT signing. +**bjorkert replaced with:** Apple's built-in `CryptoKit` (P256/ES256) via a new +`JWTManager.swift`. +**Rationale:** Eliminates two third-party dependencies. `JWTManager` adds a multi-slot +in-memory cache (keyed by `keyId:teamId`, 55-min TTL) instead of persisting JWT tokens to +UserDefaults. +**Impact:** `APNSJWTGenerator.swift` is deleted. All JWT logic lives in `JWTManager.shared`. + +### Change 2: Split APNs credentials (lf vs remote) +**Philippe's approach:** One set of APNs credentials shared between Live Activity and remote +commands. +**bjorkert's approach:** Two distinct credential sets: +- `lfApnsKey` / `lfKeyId` — for LoopFollow's own Live Activity self-push +- `remoteApnsKey` / `remoteKeyId` — for remote commands to Loop/Trio + +**Rationale:** Users who don't use remote commands shouldn't need to configure remote +credentials to get Live Activity working. Users who use both (different team IDs for Loop +vs LoopFollow) previously saw confusing "Return Notification Settings" UI that's now removed. +**Migration:** `migrateStep5` in `Storage+Migrate.swift` handles migrating the legacy keys. + +### Change 3: Runtime credential entry via APNSettingsView +**Philippe's approach:** APNs key injected at build time via xcconfig / CI secret. +**bjorkert's approach:** User enters APNs Key ID and Key at runtime via a new +`APNSettingsView` (under Settings menu). +**Rationale:** Removes the `Inject APNs Key Content` CI step entirely. No credentials are +baked into the build or present in `Info.plist`. Browser Build users don't need to manage +GitHub secrets for APNs. Credentials stored in `Storage.shared` at runtime. +**Impact:** `APNSKeyContent`, `APNSKeyID`, `APNSTeamID` removed from `Info.plist`. The CI +workflow no longer has an APNs key injection step. + +### Change 4: APNSClient reads from Storage instead of Info.plist +Follows directly from Change 3. `APNSClient` now calls `Storage.shared` for credentials +and uses `JWTManager.shared` instead of `APNSJWTGenerator`. Sandbox vs production APNs +host selection is based on `BuildDetails.isTestFlightBuild()`. + +### Change 5: Remote command settings UI simplification +The old "Return Notification Settings" section (which appeared when team IDs differed) is +removed. Remote credential fields only appear when team IDs differ. The new `APNSettingsView` +is always the place to enter LoopFollow's own credentials. + +### Change 6: CI / build updates +- `runs-on` updated from `macos-15` to `macos-26` +- Xcode version updated to `Xcode_26.2` +- APNs key injection step removed from `build_LoopFollow.yml` + +### Change 8: Consolidation pass (post-PR-#534 cleanup) +This batch of changes was made by bjorkert after integrating Philippe's code, to reduce +duplication and fix several bugs found during review. + +**mg/dL-only snapshot storage:** +All glucose values in `GlucoseSnapshot` are now stored in mg/dL. The previous code converted +to mmol/L at snapshot creation time, then converted back to mg/dL for threshold comparison — +a pointless round-trip. Conversion now happens only in `LAFormat` at display time. + +**Unified conversion constant:** +`GlucoseUnitConversion.swift` (used `18.0182`) is deleted. +`GlucoseConversion.swift` (uses `18.01559`) is the single source. Do not use `18.0182` anywhere. + +**Deduplicated unit detection:** +`PreferredGlucoseUnit.hkUnit()` now delegates to `Localizer.getPreferredUnit()` instead of +reimplementing the same logic. + +**New trend cases (↗ / ↘):** +`GlucoseSnapshot` trend now includes `upSlight` / `downSlight` cases (FortyFiveUp/Down), +rendering as `↗` / `↘` instead of collapsing to `↑` / `↓`. All trend switch statements +must handle these cases. + +**Locale bug fixed in `LAFormat`:** +`LAFormat` now uses `NumberFormatter` with `Locale.current` so decimal separators match +the device locale. Do not format glucose floats with string interpolation directly — +always go through `LAFormat`. + +**`LAThresholdSync.swift` deleted:** +Was never called. Removed as dead code. Do not re-introduce it. + +**APNs payload fix — `isNotLooping`:** +The APNs push payload was missing the `isNotLooping` field, so push-based updates never +showed the "Not Looping" overlay. Now fixed — the field is included in every push. + + +bjorkert ran swiftformat across all Live Activity files: standardized file headers, +alphabetized imports, added trailing commas, cleaned whitespace. No logic changes. + +--- + +## What was preserved from Philippe's PR intact + +- All `LiveActivity/` Swift files except those explicitly deleted: + - **Deleted:** `APNSJWTGenerator.swift` (replaced by `JWTManager.swift`) + - **Deleted:** `GlucoseUnitConversion.swift` (replaced by `GlucoseConversion.swift`) + - **Deleted:** `LAThresholdSync.swift` (dead code) +- The `LoopFollowLAExtension/` files (both `LoopFollowLiveActivity.swift` and + `LoopFollowLABundle.swift`) +- The data flow architecture (Storage → SnapshotBuilder → LiveActivityManager → APNSClient) +- The DST/timezone fix in `GlucoseSnapshot.swift` +- The debounce pattern in `GlucoseSnapshotStore.swift` +- The `AppGroupID` dynamic derivation approach +- The "Not Looping" detection via `Observable.isNotLooping` +- The Storage fields added for Live Activity data +- The `docs/LiveActivity.md` architecture + APNs setup guide +- The Fastfile changes for the extension App ID and provisioning profile + +--- + +## Current task: Live Activity auto-renewal (8-hour limit workaround) + +### Background +Apple enforces an **8-hour maximum lifetime** on Live Activities in the Dynamic Island +(12 hours on the Lock Screen, but the DA kills at 8). For a continuous glucose monitor +follower app used overnight or during long days, this is a hard UX problem: the LA simply +disappears mid-use without warning. + +bjorkert has asked Philippe to implement a workaround. + +### Apple's constraints (confirmed) +- 8 hours from `Activity.request()` call — not from last update +- System terminates the LA hard at that point; no callback before termination +- The app **can** call `Activity.end()` + `Activity.request()` from the background via + the existing audio session LoopFollow already holds +- `Activity.end(dismissalPolicy: .immediate)` removes the card from the Lock Screen + immediately — critical to avoid two cards appearing simultaneously during renewal +- There is no built-in Apple API to query an LA's remaining lifetime + +### Design decision: piggyback on the existing refresh heartbeat +**Rejected approach:** A standalone `Timer` or `DispatchQueue.asyncAfter` set for 7.5 hrs. +This is fragile — timers don't survive suspension, and adding a separate scheduling +mechanism is complexity for no benefit when a natural heartbeat already exists. + +**Chosen approach:** Check LA age on every call to `refreshFromCurrentState(reason:)`. +Since this is called on every glucose update (~every 5 minutes via LoopFollow's existing +BGData polling cycle), the worst-case gap before renewal is one polling interval. The +check is cheap (one subtraction). If age ≥ threshold, end the current LA and immediately +re-request before doing the normal refresh. + +### Files to change +| File | Change | +|------|--------| +| `Storage/Storage.swift` | Add `laStartTime: TimeInterval` stored property (UserDefaults-backed, default 0) | +| `LiveActivity/LiveActivityManager.swift` | Record `laStartTime` on every successful `Activity.request()`; check age in `refreshFromCurrentState(reason:)`; add `renewIfNeeded()` helper | + +No other files need to change. The renewal is fully encapsulated in `LiveActivityManager`. + +### Key constants +```swift +static let renewalThreshold: TimeInterval = 7.5 * 3600 // 27,000 s — renew at 7.5 hrs +static let storageKey = "laStartTime" // key in Storage/UserDefaults +``` + +### Behaviour spec +1. On every `refreshFromCurrentState(reason:)` call, before building the snapshot: + - Compute `age = now - Storage.shared.laStartTime` + - If `age >= renewalThreshold` AND a live activity is currently active: + - End it with `.immediate` dismissal (clears the Lock Screen card instantly) + - Re-request a new LA with the current snapshot content + - Record new `laStartTime = now` + - Return (the re-request itself sends the first APNs update) +2. On every successful `Activity.request()` (including normal `startFromCurrentState()`): + - Set `Storage.shared.laStartTime = Date().timeIntervalSince1970` +3. On `stopLiveActivity()` (user-initiated stop or app termination): + - Reset `Storage.shared.laStartTime = 0` +4. On app launch / `startFromCurrentState()` with an already-running LA (resume path): + - Do NOT reset `laStartTime` — the existing value is the correct age anchor + - This handles the case where the app is killed and relaunched mid-session + +### Edge cases to handle +- **User dismisses the LA manually:** ActivityKit transitions to `.dismissed`. The existing + `activityStateUpdates` observer in `LiveActivityManager` already handles this. `laStartTime` + will be stale but harmless — next call to `startFromCurrentState()` will overwrite it. +- **App is not running at the 8-hr mark:** The system kills the LA. When the app next + becomes active and calls `startFromCurrentState()`, it will detect no active LA and + request a fresh one, resetting `laStartTime`. No special handling needed. +- **Multiple rapid calls to `refreshFromCurrentState` during renewal:** The existing + debounce in `GlucoseSnapshotStore` guards this. The renewal path returns early after + re-requesting, so the debounce never even fires. +- **laStartTime = 0 (never set / first launch):** Age will be enormous (current epoch), + but the guard `currentActivity != nil` prevents a spurious renewal when there's no + active LA. Safe. + +### Full implementation (ready to apply) + +#### `Storage/Storage.swift` addition +Add alongside the other LA-related stored properties: + +```swift +// Live Activity renewal +var laStartTime: TimeInterval { + get { return UserDefaults.standard.double(forKey: "laStartTime") } + set { UserDefaults.standard.set(newValue, forKey: "laStartTime") } +} +``` + +#### `LiveActivity/LiveActivityManager.swift` changes + +Add the constant and the helper near the top of the class: + +```swift +// MARK: - Constants +private static let renewalThreshold: TimeInterval = 7.5 * 3600 + +// MARK: - Renewal + +/// Ends the current Live Activity immediately and re-requests a fresh one, +/// working around Apple's 8-hour maximum LA lifetime. +/// Returns true if renewal was performed (caller should return early). +@discardableResult +private func renewIfNeeded(snapshot: GlucoseSnapshot) async -> Bool { + guard let activity = currentActivity else { return false } + + let age = Date().timeIntervalSince1970 - Storage.shared.laStartTime + guard age >= LiveActivityManager.renewalThreshold else { return false } + + os_log(.info, log: log, "Live Activity age %.0f s >= threshold, renewing", age) + + // End with .immediate so the stale card clears before the new one appears + await activity.end(nil, dismissalPolicy: .immediate) + currentActivity = nil + + // Re-request using the snapshot we already built + await startWithSnapshot(snapshot) + return true +} +``` + +Modify `startFromCurrentState()` to record the start time after a successful request: + +```swift +func startFromCurrentState() async { + guard ActivityAuthorizationInfo().areActivitiesEnabled else { return } + guard currentActivity == nil else { return } + + let snapshot = GlucoseSnapshotBuilder.build() + await startWithSnapshot(snapshot) +} + +/// Internal helper — requests a new LA and records the start time. +private func startWithSnapshot(_ snapshot: GlucoseSnapshot) async { + let attributes = GlucoseLiveActivityAttributes() + let content = ActivityContent(state: snapshot, staleDate: nil) + do { + currentActivity = try Activity.request( + attributes: attributes, + content: content, + pushType: .token + ) + // Record when this LA was started for renewal tracking + Storage.shared.laStartTime = Date().timeIntervalSince1970 + os_log(.info, log: log, "Live Activity started, laStartTime recorded") + + // Observe push token and state updates (existing logic) + observePushTokenUpdates() + observeActivityStateUpdates() + } catch { + os_log(.error, log: log, "Failed to start Live Activity: %@", error.localizedDescription) + } +} +``` + +Modify `refreshFromCurrentState(reason:)` to call `renewIfNeeded` before the normal path: + +```swift +func refreshFromCurrentState(reason: String) async { + guard currentActivity != nil else { + // No active LA — nothing to refresh + return + } + + let snapshot = GlucoseSnapshotBuilder.build() + + // Check if the LA is approaching Apple's 8-hour limit and renew if so. + // renewIfNeeded returns true if it performed a renewal; we return early + // because startWithSnapshot already sent the first update for the new LA. + if await renewIfNeeded(snapshot: snapshot) { return } + + // Normal refresh path — send APNs self-push with updated snapshot + await GlucoseSnapshotStore.shared.update(snapshot: snapshot) +} +``` + +Modify `stopLiveActivity()` to reset the start time: + +```swift +func stopLiveActivity() async { + guard let activity = currentActivity else { return } + await activity.end(nil, dismissalPolicy: .immediate) + currentActivity = nil + Storage.shared.laStartTime = 0 + os_log(.info, log: log, "Live Activity stopped, laStartTime reset") +} +``` + +### Testing checklist +- [ ] Manually set `renewalThreshold` to 60 seconds during testing to verify the + renewal cycle works without waiting 7.5 hours +- [ ] Confirm the old Lock Screen card disappears before the new one appears + (`.immediate` dismissal working correctly) +- [ ] Confirm `laStartTime` is reset to 0 on manual stop +- [ ] Confirm `laStartTime` is NOT reset when the app is relaunched with an existing + active LA (resume path) +- [ ] Confirm no duplicate LAs appear during renewal +- [ ] Restore `renewalThreshold` to `7.5 * 3600` before committing + +--- + +## Known issues / things still in progress + +- PR #537 is currently marked **Draft** as of March 12, 2026 +- bjorkert's last commit (`524b3bb`) was March 11, 2026 +- The PR is targeting `dev` and has 6 commits total (5 from Philippe, 1 from bjorkert) +- **Active task:** LA auto-renewal (8-hour limit workaround) — see section above + +--- + +## APNs self-push mechanics (important context) + +The self-push flow: +1. `LiveActivityManager.refreshFromCurrentState(reason:)` is called (from MainViewController + or on a not-looping state change) +2. It calls `GlucoseSnapshotBuilder` → `GlucoseSnapshotStore` +3. The store debounces and triggers `APNSClient.sendUpdate(snapshot:)` +4. `APNSClient` fetches credentials from `Storage.shared`, calls `JWTManager.shared` for a + signed JWT (cached for 55 min), then POSTs to the APNs HTTP/2 endpoint +5. The system delivers the push to `LoopFollowLAExtension`, which updates the Live Activity UI + +**APNs environments:** +- Sandbox (development/TestFlight): `api.sandbox.push.apple.com` +- Production: `api.push.apple.com` +- Selection is automatic via `BuildDetails.isTestFlightBuild()` + +**Token expiry handling:** APNs self-push token expiry (HTTP 410 / 400 BadDeviceToken) +is handled in `APNSClient` with appropriate error logging. The token is the Live Activity +push token obtained from `ActivityKit`, not a device token. + +--- + +## Repo / branch conventions + +- `main` — released versions only (version ends in `.0`) +- `dev` — integration branch; PR #537 targets this +- `live-activity` — bjorkert's working branch for the feature (upstream) +- Philippe's fork branches: `dev`, `live-activity-pr` (original work) +- Version format: `M.N.P` — P increments on each `dev` merge, N increments on release + +--- + +## Build configuration notes + +- App Group ID is derived dynamically — do not hardcode team IDs anywhere +- APNs credentials are now entered by the user at runtime in APNSettingsView +- No APNs secrets in xcconfig, Info.plist, or CI environment variables (as of bjorkert's + latest commit) +- The extension target is `LoopFollowLAExtension` with its own entitlements file + (`LoopFollowLAExtensionExtension.entitlements`) +- `Package.resolved` has been updated to remove SwiftJWT and swift-crypto dependencies diff --git a/LoopFollow/Helpers/BackgroundTaskAudio.swift b/LoopFollow/Helpers/BackgroundTaskAudio.swift index 91504ab5d..67e76a03e 100755 --- a/LoopFollow/Helpers/BackgroundTaskAudio.swift +++ b/LoopFollow/Helpers/BackgroundTaskAudio.swift @@ -5,38 +5,65 @@ import AVFoundation class BackgroundTask { // MARK: - Vars - + var player = AVAudioPlayer() - var timer = Timer() - + + private var retryCount = 0 + private let maxRetries = 3 + // MARK: - Methods - + func startBackgroundTask() { NotificationCenter.default.addObserver(self, selector: #selector(interruptedAudio), name: AVAudioSession.interruptionNotification, object: AVAudioSession.sharedInstance()) + retryCount = 0 playAudio() } - + func stopBackgroundTask() { NotificationCenter.default.removeObserver(self, name: AVAudioSession.interruptionNotification, object: nil) player.stop() LogManager.shared.log(category: .general, message: "Silent audio stopped", isDebug: true) } - + @objc fileprivate func interruptedAudio(_ notification: Notification) { - LogManager.shared.log(category: .general, message: "Silent audio interrupted") - if notification.name == AVAudioSession.interruptionNotification, notification.userInfo != nil { - var info = notification.userInfo! - var intValue = 0 - (info[AVAudioSessionInterruptionTypeKey]! as AnyObject).getValue(&intValue) - if intValue == 1 { playAudio() } + guard notification.name == AVAudioSession.interruptionNotification, + let userInfo = notification.userInfo, + let typeValue = userInfo[AVAudioSessionInterruptionTypeKey] as? UInt, + let type = AVAudioSession.InterruptionType(rawValue: typeValue) + else { return } + + switch type { + case .began: + LogManager.shared.log(category: .general, message: "[LA] Silent audio session interrupted (began)") + + case .ended: + // Check shouldResume hint — skip restart if iOS says not to + if let optionsValue = userInfo[AVAudioSessionInterruptionOptionKey] as? UInt { + let options = AVAudioSession.InterruptionOptions(rawValue: optionsValue) + guard options.contains(.shouldResume) else { + LogManager.shared.log(category: .general, message: "[LA] Silent audio interruption ended — shouldResume not set, skipping restart") + return + } + } + LogManager.shared.log(category: .general, message: "[LA] Silent audio interruption ended — scheduling restart in 0.5s") + retryCount = 0 + // Brief delay to let the interrupting app (e.g. Clock alarm) fully release the audio + // session before we attempt to reactivate. Without this, setActive(true) races with + // the alarm and fails with AVAudioSession.ErrorCode.cannotInterruptOthers (560557684). + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in + self?.playAudio() + } + + @unknown default: + break } } - + fileprivate func playAudio() { + let attemptDesc = retryCount == 0 ? "initial attempt" : "retry \(retryCount)/\(maxRetries)" do { let bundle = Bundle.main.path(forResource: "blank", ofType: "wav") let alertSound = URL(fileURLWithPath: bundle!) - // try AVAudioSession.sharedInstance().setCategory(AVAudioSession.Category.playback) try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default, options: .mixWithOthers) try AVAudioSession.sharedInstance().setActive(true) try player = AVAudioPlayer(contentsOf: alertSound) @@ -45,9 +72,25 @@ class BackgroundTask { player.volume = 0.01 player.prepareToPlay() player.play() - LogManager.shared.log(category: .general, message: "Silent audio playing", isDebug: true) + retryCount = 0 + LogManager.shared.log(category: .general, message: "Silent audio playing (\(attemptDesc))", isDebug: true) } catch { - LogManager.shared.log(category: .general, message: "playAudio, error: \(error)") + LogManager.shared.log(category: .general, message: "playAudio failed (\(attemptDesc)), error: \(error)") + if retryCount < maxRetries { + retryCount += 1 + LogManager.shared.log(category: .general, message: "playAudio scheduling retry \(retryCount)/\(maxRetries) in 2s") + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { [weak self] in + self?.playAudio() + } + } else { + LogManager.shared.log(category: .general, message: "playAudio failed after \(maxRetries) retries — posting BackgroundAudioFailed") + NotificationCenter.default.post(name: .backgroundAudioFailed, object: nil) + } } } + } + +extension Notification.Name { + static let backgroundAudioFailed = Notification.Name(“BackgroundAudioFailed”) +} \ No newline at end of file diff --git a/LoopFollow/LiveActivity/LiveActivityManager.swift b/LoopFollow/LiveActivity/LiveActivityManager.swift index 41f129c60..00d230e40 100644 --- a/LoopFollow/LiveActivity/LiveActivityManager.swift +++ b/LoopFollow/LiveActivity/LiveActivityManager.swift @@ -32,6 +32,12 @@ final class LiveActivityManager { name: UIApplication.willResignActiveNotification, object: nil ) + NotificationCenter.default.addObserver( + self, + selector: #selector(handleBackgroundAudioFailed), + name: .backgroundAudioFailed, + object: nil + ) } /// Fires before the app loses focus (lock screen, home button, etc.). @@ -87,13 +93,19 @@ final class LiveActivityManager { @objc private func handleForeground() { guard Storage.shared.laEnabled.value else { return } - LogManager.shared.log(category: .general, message: "[LA] foreground notification received, laRenewalFailed=\(Storage.shared.laRenewalFailed.value)") - guard Storage.shared.laRenewalFailed.value else { return } - // Renewal previously failed — end the stale LA and start a fresh one. + let renewalFailed = Storage.shared.laRenewalFailed.value + let renewBy = Storage.shared.laRenewBy.value + let now = Date().timeIntervalSince1970 + let overlayIsShowing = renewBy > 0 && now >= renewBy - LiveActivityManager.renewalWarning + + LogManager.shared.log(category: .general, message: "[LA] foreground notification received, laRenewalFailed=\(renewalFailed), overlayShowing=\(overlayIsShowing)") + guard renewalFailed || overlayIsShowing else { return } + + // Overlay is showing or renewal previously failed — end the stale LA and start a fresh one. // We cannot call startIfNeeded() here: it finds the existing activity in // Activity.activities and reuses it rather than replacing it. - LogManager.shared.log(category: .general, message: "[LA] ending stale LA and restarting after renewal failure") + LogManager.shared.log(category: .general, message: "[LA] ending stale LA and restarting (renewalFailed=\(renewalFailed), overlayShowing=\(overlayIsShowing))") // Clear state synchronously so any snapshot built between now and when the // new LA is started computes showRenewalOverlay = false. Storage.shared.laRenewBy.value = 0 @@ -128,6 +140,16 @@ final class LiveActivityManager { } } + @objc private func handleBackgroundAudioFailed() { + guard Storage.shared.laEnabled.value, current != nil else { return } + // The background audio session has permanently failed — the app will lose its + // background keep-alive. Immediately push the renewal overlay so the user sees + // "Tap to update" on the lock screen and knows to foreground the app. + LogManager.shared.log(category: .general, message: "[LA] background audio failed — forcing renewal overlay") + Storage.shared.laRenewBy.value = Date().timeIntervalSince1970 + refreshFromCurrentState(reason: "audio-session-failed") + } + static let renewalThreshold: TimeInterval = 7.5 * 3600 static let renewalWarning: TimeInterval = 20 * 60 diff --git a/LoopFollowLAExtension/LoopFollowLABundle.swift b/LoopFollowLAExtension/LoopFollowLABundle.swift index a9f7daf6c..1b058a210 100644 --- a/LoopFollowLAExtension/LoopFollowLABundle.swift +++ b/LoopFollowLAExtension/LoopFollowLABundle.swift @@ -11,8 +11,9 @@ import WidgetKit @main struct LoopFollowLABundle: WidgetBundle { var body: some Widget { - if #available(iOS 16.1, *) { - LoopFollowLiveActivityWidget() + LoopFollowLiveActivityWidget() + if #available(iOS 18.0, *) { + LoopFollowLiveActivityWidgetWithCarPlay() } if #available(iOS 18.0, *) { LoopFollowLiveActivityWidgetWithCarPlay() diff --git a/LoopFollowLAExtension/LoopFollowLiveActivity.swift b/LoopFollowLAExtension/LoopFollowLiveActivity.swift index d681a9368..753402e05 100644 --- a/LoopFollowLAExtension/LoopFollowLiveActivity.swift +++ b/LoopFollowLAExtension/LoopFollowLiveActivity.swift @@ -6,7 +6,6 @@ import SwiftUI import WidgetKit /// Builds the shared Dynamic Island content used by both widget variants. -@available(iOS 16.1, *) private func makeDynamicIsland(context: ActivityViewContext) -> DynamicIsland { DynamicIsland { DynamicIslandExpandedRegion(.leading) { @@ -43,8 +42,7 @@ private func makeDynamicIsland(context: ActivityViewContext Date: Wed, 18 Mar 2026 11:25:26 -0400 Subject: [PATCH 66/82] Update BackgroundTaskAudio.swift --- LoopFollow/Helpers/BackgroundTaskAudio.swift | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/LoopFollow/Helpers/BackgroundTaskAudio.swift b/LoopFollow/Helpers/BackgroundTaskAudio.swift index 67e76a03e..474363acd 100755 --- a/LoopFollow/Helpers/BackgroundTaskAudio.swift +++ b/LoopFollow/Helpers/BackgroundTaskAudio.swift @@ -3,9 +3,12 @@ import AVFoundation +extension Notification.Name { + static let backgroundAudioFailed = Notification.Name(“BackgroundAudioFailed”) +} + class BackgroundTask { // MARK: - Vars - var player = AVAudioPlayer() private var retryCount = 0 @@ -88,9 +91,4 @@ class BackgroundTask { } } } - -} - -extension Notification.Name { - static let backgroundAudioFailed = Notification.Name(“BackgroundAudioFailed”) } \ No newline at end of file From e8ee8059254321a46014329f99f9d1b2bb7135e8 Mon Sep 17 00:00:00 2001 From: Phil A <76601115+MtlPhil@users.noreply.github.com> Date: Wed, 18 Mar 2026 11:39:36 -0400 Subject: [PATCH 67/82] Update BackgroundTaskAudio.swift --- LoopFollow/Helpers/BackgroundTaskAudio.swift | 27 ++++++++++---------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/LoopFollow/Helpers/BackgroundTaskAudio.swift b/LoopFollow/Helpers/BackgroundTaskAudio.swift index 474363acd..08e58062d 100755 --- a/LoopFollow/Helpers/BackgroundTaskAudio.swift +++ b/LoopFollow/Helpers/BackgroundTaskAudio.swift @@ -3,42 +3,41 @@ import AVFoundation -extension Notification.Name { - static let backgroundAudioFailed = Notification.Name(“BackgroundAudioFailed”) -} - class BackgroundTask { // MARK: - Vars + var player = AVAudioPlayer() - + private var retryCount = 0 private let maxRetries = 3 - + + static let backgroundAudioFailedNotification = Notification.Name(rawValue: "BackgroundAudioFailed") + // MARK: - Methods - + func startBackgroundTask() { NotificationCenter.default.addObserver(self, selector: #selector(interruptedAudio), name: AVAudioSession.interruptionNotification, object: AVAudioSession.sharedInstance()) retryCount = 0 playAudio() } - + func stopBackgroundTask() { NotificationCenter.default.removeObserver(self, name: AVAudioSession.interruptionNotification, object: nil) player.stop() LogManager.shared.log(category: .general, message: "Silent audio stopped", isDebug: true) } - + @objc fileprivate func interruptedAudio(_ notification: Notification) { guard notification.name == AVAudioSession.interruptionNotification, let userInfo = notification.userInfo, let typeValue = userInfo[AVAudioSessionInterruptionTypeKey] as? UInt, let type = AVAudioSession.InterruptionType(rawValue: typeValue) else { return } - + switch type { case .began: LogManager.shared.log(category: .general, message: "[LA] Silent audio session interrupted (began)") - + case .ended: // Check shouldResume hint — skip restart if iOS says not to if let optionsValue = userInfo[AVAudioSessionInterruptionOptionKey] as? UInt { @@ -56,12 +55,12 @@ class BackgroundTask { DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in self?.playAudio() } - + @unknown default: break } } - + fileprivate func playAudio() { let attemptDesc = retryCount == 0 ? "initial attempt" : "retry \(retryCount)/\(maxRetries)" do { @@ -87,7 +86,7 @@ class BackgroundTask { } } else { LogManager.shared.log(category: .general, message: "playAudio failed after \(maxRetries) retries — posting BackgroundAudioFailed") - NotificationCenter.default.post(name: .backgroundAudioFailed, object: nil) + NotificationCenter.default.post(name: BackgroundTask.backgroundAudioFailedNotification, object: nil) } } } From cffc043cfa87fba11837b82123df7fa0b6373a70 Mon Sep 17 00:00:00 2001 From: Phil A <76601115+MtlPhil@users.noreply.github.com> Date: Wed, 18 Mar 2026 12:57:13 -0400 Subject: [PATCH 68/82] Update LiveActivityManager.swift --- LoopFollow/LiveActivity/LiveActivityManager.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/LoopFollow/LiveActivity/LiveActivityManager.swift b/LoopFollow/LiveActivity/LiveActivityManager.swift index 00d230e40..d5cad0589 100644 --- a/LoopFollow/LiveActivity/LiveActivityManager.swift +++ b/LoopFollow/LiveActivity/LiveActivityManager.swift @@ -35,7 +35,7 @@ final class LiveActivityManager { NotificationCenter.default.addObserver( self, selector: #selector(handleBackgroundAudioFailed), - name: .backgroundAudioFailed, + name: .backgroundAudioFailedNotification, object: nil ) } From 61a6035b3f9bb95e49bdcab5bb4bd90d299079d2 Mon Sep 17 00:00:00 2001 From: Phil A <76601115+MtlPhil@users.noreply.github.com> Date: Wed, 18 Mar 2026 13:40:52 -0400 Subject: [PATCH 69/82] Update LiveActivityManager.swift --- LoopFollow/LiveActivity/LiveActivityManager.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/LoopFollow/LiveActivity/LiveActivityManager.swift b/LoopFollow/LiveActivity/LiveActivityManager.swift index d5cad0589..91159cecc 100644 --- a/LoopFollow/LiveActivity/LiveActivityManager.swift +++ b/LoopFollow/LiveActivity/LiveActivityManager.swift @@ -35,7 +35,7 @@ final class LiveActivityManager { NotificationCenter.default.addObserver( self, selector: #selector(handleBackgroundAudioFailed), - name: .backgroundAudioFailedNotification, + name: BackgroundTask.backgroundAudioFailedNotification, object: nil ) } From adbec892e64f10269cda5ad919b1025fe574d21b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Wed, 18 Mar 2026 20:32:00 +0100 Subject: [PATCH 70/82] Linting --- LoopFollow/Helpers/BackgroundTaskAudio.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/LoopFollow/Helpers/BackgroundTaskAudio.swift b/LoopFollow/Helpers/BackgroundTaskAudio.swift index e0c933d2f..e5ddcf988 100755 --- a/LoopFollow/Helpers/BackgroundTaskAudio.swift +++ b/LoopFollow/Helpers/BackgroundTaskAudio.swift @@ -88,7 +88,6 @@ class BackgroundTask { } } } - } extension Notification.Name { From f677b2cc2727eca2bbc0c9d2cdd83ebb04020b58 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Wed, 18 Mar 2026 20:34:11 +0100 Subject: [PATCH 71/82] Removed CLAUDE.md --- CLAUDE.md | 479 ------------------------------------------------------ 1 file changed, 479 deletions(-) delete mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 0f9f62f04..000000000 --- a/CLAUDE.md +++ /dev/null @@ -1,479 +0,0 @@ -# LoopFollow Live Activity — Project Context for Claude Code - -## Who you're working with - -This codebase is being developed by **Philippe** (GitHub: `MtlPhil`), contributing to -`loopandlearn/LoopFollow` — an open-source iOS app that lets parents and caregivers of T1D -Loop users monitor glucose and loop status in real time. - -- **Upstream repo:** `https://github.com/loopandlearn/LoopFollow` -- **Philippe's fork:** `https://github.com/achkars-org/LoopFollow` -- **Local clone:** `/Users/philippe/Documents/GitHub/LoopFollowLA/` -- **Active upstream branch:** `live-activity` (PR #537, draft, targeting `dev`) -- **Philippe's original PR:** `#534` (closed, superseded by #537) -- **Maintainer:** `bjorkert` (Jonas Björkert) - ---- - -## What this feature is - -A **Live Activity** for LoopFollow that displays real-time glucose data on the iOS lock screen -and in the Dynamic Island. The feature uses **APNs self-push** — the app sends a push -notification to itself — to drive reliable background updates without interfering with the -background audio session LoopFollow uses to stay alive. - -### What the Live Activity shows -- Current glucose value + trend arrow -- Delta (change since last reading) -- IOB, COB, projected BG (optional — omitted gracefully for Dexcom-only users) -- Time since last reading -- "Not Looping" red banner when Loop hasn't reported in 15+ minutes -- Threshold-driven background color (green / orange / red) -- Dynamic Island: compact, expanded, and minimal presentations - ---- - -## Architecture overview (current state in PR #537) - -### Data flow -``` -BGData / DeviceStatusLoop / DeviceStatusOpenAPS - → write canonical values to Storage.shared - → GlucoseSnapshotBuilder reads Storage - → builds GlucoseSnapshot - → LiveActivityManager pushes via APNSClient - → LoopFollowLAExtension renders the UI -``` - -### Key files - -| File | Purpose | -|------|---------| -| `LiveActivity/LiveActivityManager.swift` | Orchestrates start/stop/refresh of the Live Activity; called from `MainViewController` | -| `LiveActivity/APNSClient.swift` | Sends the APNs self-push; uses `JWTManager.shared` for JWT; reads credentials from `Storage.shared` | -| `Helpers/JWTManager.swift` | **bjorkert addition** — replaces `APNSJWTGenerator`; uses CryptoKit (P256/ES256); multi-slot in-memory cache keyed by `keyId:teamId`, 55-min TTL | -| `LiveActivity/GlucoseSnapshot.swift` | The value-type snapshot passed to the extension; timestamp stored as Unix epoch seconds (UTC) — **timezone bug was fixed here** | -| `LiveActivity/GlucoseSnapshotBuilder.swift` | Reads from Storage, constructs GlucoseSnapshot | -| `LiveActivity/GlucoseSnapshotStore.swift` | In-memory store; debounces rapid successive refreshes | -| `LiveActivity/GlucoseLiveActivityAttributes.swift` | ActivityKit attributes struct | -| `LiveActivity/AppGroupID.swift` | Derives App Group ID dynamically from bundle identifier — no hardcoded team IDs | -| `LiveActivity/LAAppGroupSettings.swift` | Persists LA-specific settings to the shared App Group container | -| `LiveActivity/LAFormat.swift` | **bjorkert addition** — display formatting for LA values; uses `NumberFormatter` with `Locale.current` so decimal separators match device locale (e.g. "5,6" in Swedish) | -| `LiveActivity/PreferredGlucoseUnit.swift` | Reads preferred unit; delegates to `Localizer.getPreferredUnit()` — no longer duplicates unit detection logic | -| `GlucoseConversion.swift` | **Replaces `GlucoseUnitConversion.swift`** — unified constant `18.01559`; `mgDlToMmolL` is a computed reciprocal. Note: the old file used `18.0182` — do not use that constant anywhere | -| `LiveActivity/StorageCurrentGlucoseStateProvider.swift` | Protocol adapter between Storage and LiveActivityManager | -| `LoopFollowLAExtension/LoopFollowLiveActivity.swift` | SwiftUI widget views for lock screen + Dynamic Island | -| `LoopFollowLAExtension/LoopFollowLABundle.swift` | Extension bundle entry point | -| `Settings/APNSettingsView.swift` | **bjorkert addition** — dedicated settings screen for LoopFollow's own APNs key ID and key | -| `Storage/Storage.swift` | Added: `lastBgReadingTimeSeconds`, `lastDeltaMgdl`, `lastTrendCode`, `lastIOB`, `lastCOB`, `projectedBgMgdl` | -| `Storage/Observable.swift` | Added: `isNotLooping` | -| `Storage/Storage+Migrate.swift` | Added: `migrateStep5` — migrates legacy APNs credential keys to new split format | - ---- - -## The core design decisions Philippe made (and why) - -### 1. APNs self-push for background updates -LoopFollow uses a background audio session to stay alive in the background. Initially, the -temptation was to use `ActivityKit` updates directly from the app. The self-push approach was -chosen because it is more reliable and doesn't create timing conflicts with the audio session. -The app sends a push to itself using its own APNs key; the system delivers it with high -priority, waking the extension. - -### 2. Dynamic App Group ID (no hardcoded team IDs) -`AppGroupID.swift` derives the App Group ID from the bundle identifier at runtime. This makes -the feature work across all fork/build configurations without embedding any team-specific -identifiers in code. - -### 3. Single source of truth in Storage -All glucose and loop state is written to `Storage.shared` (and `Observable`) by the existing -data-fetching controllers (BGData, DeviceStatusLoop, DeviceStatusOpenAPS). The Live Activity -layer is purely a consumer — it never fetches its own data. This keeps the architecture clean -and source-agnostic. - -### 4. GlucoseSnapshot stores glucose in mg/dL only — conversion at display time only -The snapshot is a simple struct with no dependencies, designed to be safe to pass across the -app/extension boundary. All glucose values in `GlucoseSnapshot` are stored as **mg/dL**. -Conversion to mmol/L happens exclusively at display time inside `LAFormat`. This eliminates -the previous round-trip (mg/dL → mmol/L at snapshot creation, then mmol/L → mg/dL for -threshold comparison) that bjorkert identified and removed. - -**Rule for all future code:** anything writing a glucose value into a `GlucoseSnapshot` must -supply mg/dL. Anything reading a glucose value from a snapshot for display must convert via -`GlucoseConversion.mgDlToMmolL` if the user's preferred unit is mmol/L. - -### 5. Unix epoch timestamps (UTC) in GlucoseSnapshot -**Critical bug that was discovered and fixed:** ActivityKit operates in UTC epoch seconds, -but the original code was constructing timestamps using local time offsets, causing DST -errors of ±1 hour. The fix ensures all timestamps in `GlucoseSnapshot` are stored as -`TimeInterval` (seconds since Unix epoch, UTC) and converted to display strings only in the -extension, using the device's local calendar. This fix is in the codebase. - -### 6. Debounce on rapid refreshes -A coalescing `DispatchWorkItem` pattern is used in `GlucoseSnapshotStore` to debounce -rapid successive calls to refresh (e.g., when multiple Storage values update in quick -succession during a data fetch). Only one APNs push is sent per update cycle. - -### 7. APNs key injected via xcconfig/Info.plist (Philippe's original approach) -In Philippe's original PR #534, the APNs key was injected at build time via -`xcconfig` / `Info.plist`, sourced from a GitHub Actions secret. This meant credentials were -baked into the build and never committed. - ---- - -## What bjorkert changed (and why it differs from Philippe's approach) - -### Change 1: SwiftJWT → CryptoKit (`JWTManager.swift`) -**Philippe used:** `SwiftJWT` + `swift-crypto` SPM packages for JWT signing. -**bjorkert replaced with:** Apple's built-in `CryptoKit` (P256/ES256) via a new -`JWTManager.swift`. -**Rationale:** Eliminates two third-party dependencies. `JWTManager` adds a multi-slot -in-memory cache (keyed by `keyId:teamId`, 55-min TTL) instead of persisting JWT tokens to -UserDefaults. -**Impact:** `APNSJWTGenerator.swift` is deleted. All JWT logic lives in `JWTManager.shared`. - -### Change 2: Split APNs credentials (lf vs remote) -**Philippe's approach:** One set of APNs credentials shared between Live Activity and remote -commands. -**bjorkert's approach:** Two distinct credential sets: -- `lfApnsKey` / `lfKeyId` — for LoopFollow's own Live Activity self-push -- `remoteApnsKey` / `remoteKeyId` — for remote commands to Loop/Trio - -**Rationale:** Users who don't use remote commands shouldn't need to configure remote -credentials to get Live Activity working. Users who use both (different team IDs for Loop -vs LoopFollow) previously saw confusing "Return Notification Settings" UI that's now removed. -**Migration:** `migrateStep5` in `Storage+Migrate.swift` handles migrating the legacy keys. - -### Change 3: Runtime credential entry via APNSettingsView -**Philippe's approach:** APNs key injected at build time via xcconfig / CI secret. -**bjorkert's approach:** User enters APNs Key ID and Key at runtime via a new -`APNSettingsView` (under Settings menu). -**Rationale:** Removes the `Inject APNs Key Content` CI step entirely. No credentials are -baked into the build or present in `Info.plist`. Browser Build users don't need to manage -GitHub secrets for APNs. Credentials stored in `Storage.shared` at runtime. -**Impact:** `APNSKeyContent`, `APNSKeyID`, `APNSTeamID` removed from `Info.plist`. The CI -workflow no longer has an APNs key injection step. - -### Change 4: APNSClient reads from Storage instead of Info.plist -Follows directly from Change 3. `APNSClient` now calls `Storage.shared` for credentials -and uses `JWTManager.shared` instead of `APNSJWTGenerator`. Sandbox vs production APNs -host selection is based on `BuildDetails.isTestFlightBuild()`. - -### Change 5: Remote command settings UI simplification -The old "Return Notification Settings" section (which appeared when team IDs differed) is -removed. Remote credential fields only appear when team IDs differ. The new `APNSettingsView` -is always the place to enter LoopFollow's own credentials. - -### Change 6: CI / build updates -- `runs-on` updated from `macos-15` to `macos-26` -- Xcode version updated to `Xcode_26.2` -- APNs key injection step removed from `build_LoopFollow.yml` - -### Change 8: Consolidation pass (post-PR-#534 cleanup) -This batch of changes was made by bjorkert after integrating Philippe's code, to reduce -duplication and fix several bugs found during review. - -**mg/dL-only snapshot storage:** -All glucose values in `GlucoseSnapshot` are now stored in mg/dL. The previous code converted -to mmol/L at snapshot creation time, then converted back to mg/dL for threshold comparison — -a pointless round-trip. Conversion now happens only in `LAFormat` at display time. - -**Unified conversion constant:** -`GlucoseUnitConversion.swift` (used `18.0182`) is deleted. -`GlucoseConversion.swift` (uses `18.01559`) is the single source. Do not use `18.0182` anywhere. - -**Deduplicated unit detection:** -`PreferredGlucoseUnit.hkUnit()` now delegates to `Localizer.getPreferredUnit()` instead of -reimplementing the same logic. - -**New trend cases (↗ / ↘):** -`GlucoseSnapshot` trend now includes `upSlight` / `downSlight` cases (FortyFiveUp/Down), -rendering as `↗` / `↘` instead of collapsing to `↑` / `↓`. All trend switch statements -must handle these cases. - -**Locale bug fixed in `LAFormat`:** -`LAFormat` now uses `NumberFormatter` with `Locale.current` so decimal separators match -the device locale. Do not format glucose floats with string interpolation directly — -always go through `LAFormat`. - -**`LAThresholdSync.swift` deleted:** -Was never called. Removed as dead code. Do not re-introduce it. - -**APNs payload fix — `isNotLooping`:** -The APNs push payload was missing the `isNotLooping` field, so push-based updates never -showed the "Not Looping" overlay. Now fixed — the field is included in every push. - - -bjorkert ran swiftformat across all Live Activity files: standardized file headers, -alphabetized imports, added trailing commas, cleaned whitespace. No logic changes. - ---- - -## What was preserved from Philippe's PR intact - -- All `LiveActivity/` Swift files except those explicitly deleted: - - **Deleted:** `APNSJWTGenerator.swift` (replaced by `JWTManager.swift`) - - **Deleted:** `GlucoseUnitConversion.swift` (replaced by `GlucoseConversion.swift`) - - **Deleted:** `LAThresholdSync.swift` (dead code) -- The `LoopFollowLAExtension/` files (both `LoopFollowLiveActivity.swift` and - `LoopFollowLABundle.swift`) -- The data flow architecture (Storage → SnapshotBuilder → LiveActivityManager → APNSClient) -- The DST/timezone fix in `GlucoseSnapshot.swift` -- The debounce pattern in `GlucoseSnapshotStore.swift` -- The `AppGroupID` dynamic derivation approach -- The "Not Looping" detection via `Observable.isNotLooping` -- The Storage fields added for Live Activity data -- The `docs/LiveActivity.md` architecture + APNs setup guide -- The Fastfile changes for the extension App ID and provisioning profile - ---- - -## Current task: Live Activity auto-renewal (8-hour limit workaround) - -### Background -Apple enforces an **8-hour maximum lifetime** on Live Activities in the Dynamic Island -(12 hours on the Lock Screen, but the DA kills at 8). For a continuous glucose monitor -follower app used overnight or during long days, this is a hard UX problem: the LA simply -disappears mid-use without warning. - -bjorkert has asked Philippe to implement a workaround. - -### Apple's constraints (confirmed) -- 8 hours from `Activity.request()` call — not from last update -- System terminates the LA hard at that point; no callback before termination -- The app **can** call `Activity.end()` + `Activity.request()` from the background via - the existing audio session LoopFollow already holds -- `Activity.end(dismissalPolicy: .immediate)` removes the card from the Lock Screen - immediately — critical to avoid two cards appearing simultaneously during renewal -- There is no built-in Apple API to query an LA's remaining lifetime - -### Design decision: piggyback on the existing refresh heartbeat -**Rejected approach:** A standalone `Timer` or `DispatchQueue.asyncAfter` set for 7.5 hrs. -This is fragile — timers don't survive suspension, and adding a separate scheduling -mechanism is complexity for no benefit when a natural heartbeat already exists. - -**Chosen approach:** Check LA age on every call to `refreshFromCurrentState(reason:)`. -Since this is called on every glucose update (~every 5 minutes via LoopFollow's existing -BGData polling cycle), the worst-case gap before renewal is one polling interval. The -check is cheap (one subtraction). If age ≥ threshold, end the current LA and immediately -re-request before doing the normal refresh. - -### Files to change -| File | Change | -|------|--------| -| `Storage/Storage.swift` | Add `laStartTime: TimeInterval` stored property (UserDefaults-backed, default 0) | -| `LiveActivity/LiveActivityManager.swift` | Record `laStartTime` on every successful `Activity.request()`; check age in `refreshFromCurrentState(reason:)`; add `renewIfNeeded()` helper | - -No other files need to change. The renewal is fully encapsulated in `LiveActivityManager`. - -### Key constants -```swift -static let renewalThreshold: TimeInterval = 7.5 * 3600 // 27,000 s — renew at 7.5 hrs -static let storageKey = "laStartTime" // key in Storage/UserDefaults -``` - -### Behaviour spec -1. On every `refreshFromCurrentState(reason:)` call, before building the snapshot: - - Compute `age = now - Storage.shared.laStartTime` - - If `age >= renewalThreshold` AND a live activity is currently active: - - End it with `.immediate` dismissal (clears the Lock Screen card instantly) - - Re-request a new LA with the current snapshot content - - Record new `laStartTime = now` - - Return (the re-request itself sends the first APNs update) -2. On every successful `Activity.request()` (including normal `startFromCurrentState()`): - - Set `Storage.shared.laStartTime = Date().timeIntervalSince1970` -3. On `stopLiveActivity()` (user-initiated stop or app termination): - - Reset `Storage.shared.laStartTime = 0` -4. On app launch / `startFromCurrentState()` with an already-running LA (resume path): - - Do NOT reset `laStartTime` — the existing value is the correct age anchor - - This handles the case where the app is killed and relaunched mid-session - -### Edge cases to handle -- **User dismisses the LA manually:** ActivityKit transitions to `.dismissed`. The existing - `activityStateUpdates` observer in `LiveActivityManager` already handles this. `laStartTime` - will be stale but harmless — next call to `startFromCurrentState()` will overwrite it. -- **App is not running at the 8-hr mark:** The system kills the LA. When the app next - becomes active and calls `startFromCurrentState()`, it will detect no active LA and - request a fresh one, resetting `laStartTime`. No special handling needed. -- **Multiple rapid calls to `refreshFromCurrentState` during renewal:** The existing - debounce in `GlucoseSnapshotStore` guards this. The renewal path returns early after - re-requesting, so the debounce never even fires. -- **laStartTime = 0 (never set / first launch):** Age will be enormous (current epoch), - but the guard `currentActivity != nil` prevents a spurious renewal when there's no - active LA. Safe. - -### Full implementation (ready to apply) - -#### `Storage/Storage.swift` addition -Add alongside the other LA-related stored properties: - -```swift -// Live Activity renewal -var laStartTime: TimeInterval { - get { return UserDefaults.standard.double(forKey: "laStartTime") } - set { UserDefaults.standard.set(newValue, forKey: "laStartTime") } -} -``` - -#### `LiveActivity/LiveActivityManager.swift` changes - -Add the constant and the helper near the top of the class: - -```swift -// MARK: - Constants -private static let renewalThreshold: TimeInterval = 7.5 * 3600 - -// MARK: - Renewal - -/// Ends the current Live Activity immediately and re-requests a fresh one, -/// working around Apple's 8-hour maximum LA lifetime. -/// Returns true if renewal was performed (caller should return early). -@discardableResult -private func renewIfNeeded(snapshot: GlucoseSnapshot) async -> Bool { - guard let activity = currentActivity else { return false } - - let age = Date().timeIntervalSince1970 - Storage.shared.laStartTime - guard age >= LiveActivityManager.renewalThreshold else { return false } - - os_log(.info, log: log, "Live Activity age %.0f s >= threshold, renewing", age) - - // End with .immediate so the stale card clears before the new one appears - await activity.end(nil, dismissalPolicy: .immediate) - currentActivity = nil - - // Re-request using the snapshot we already built - await startWithSnapshot(snapshot) - return true -} -``` - -Modify `startFromCurrentState()` to record the start time after a successful request: - -```swift -func startFromCurrentState() async { - guard ActivityAuthorizationInfo().areActivitiesEnabled else { return } - guard currentActivity == nil else { return } - - let snapshot = GlucoseSnapshotBuilder.build() - await startWithSnapshot(snapshot) -} - -/// Internal helper — requests a new LA and records the start time. -private func startWithSnapshot(_ snapshot: GlucoseSnapshot) async { - let attributes = GlucoseLiveActivityAttributes() - let content = ActivityContent(state: snapshot, staleDate: nil) - do { - currentActivity = try Activity.request( - attributes: attributes, - content: content, - pushType: .token - ) - // Record when this LA was started for renewal tracking - Storage.shared.laStartTime = Date().timeIntervalSince1970 - os_log(.info, log: log, "Live Activity started, laStartTime recorded") - - // Observe push token and state updates (existing logic) - observePushTokenUpdates() - observeActivityStateUpdates() - } catch { - os_log(.error, log: log, "Failed to start Live Activity: %@", error.localizedDescription) - } -} -``` - -Modify `refreshFromCurrentState(reason:)` to call `renewIfNeeded` before the normal path: - -```swift -func refreshFromCurrentState(reason: String) async { - guard currentActivity != nil else { - // No active LA — nothing to refresh - return - } - - let snapshot = GlucoseSnapshotBuilder.build() - - // Check if the LA is approaching Apple's 8-hour limit and renew if so. - // renewIfNeeded returns true if it performed a renewal; we return early - // because startWithSnapshot already sent the first update for the new LA. - if await renewIfNeeded(snapshot: snapshot) { return } - - // Normal refresh path — send APNs self-push with updated snapshot - await GlucoseSnapshotStore.shared.update(snapshot: snapshot) -} -``` - -Modify `stopLiveActivity()` to reset the start time: - -```swift -func stopLiveActivity() async { - guard let activity = currentActivity else { return } - await activity.end(nil, dismissalPolicy: .immediate) - currentActivity = nil - Storage.shared.laStartTime = 0 - os_log(.info, log: log, "Live Activity stopped, laStartTime reset") -} -``` - -### Testing checklist -- [ ] Manually set `renewalThreshold` to 60 seconds during testing to verify the - renewal cycle works without waiting 7.5 hours -- [ ] Confirm the old Lock Screen card disappears before the new one appears - (`.immediate` dismissal working correctly) -- [ ] Confirm `laStartTime` is reset to 0 on manual stop -- [ ] Confirm `laStartTime` is NOT reset when the app is relaunched with an existing - active LA (resume path) -- [ ] Confirm no duplicate LAs appear during renewal -- [ ] Restore `renewalThreshold` to `7.5 * 3600` before committing - ---- - -## Known issues / things still in progress - -- PR #537 is currently marked **Draft** as of March 12, 2026 -- bjorkert's last commit (`524b3bb`) was March 11, 2026 -- The PR is targeting `dev` and has 6 commits total (5 from Philippe, 1 from bjorkert) -- **Active task:** LA auto-renewal (8-hour limit workaround) — see section above - ---- - -## APNs self-push mechanics (important context) - -The self-push flow: -1. `LiveActivityManager.refreshFromCurrentState(reason:)` is called (from MainViewController - or on a not-looping state change) -2. It calls `GlucoseSnapshotBuilder` → `GlucoseSnapshotStore` -3. The store debounces and triggers `APNSClient.sendUpdate(snapshot:)` -4. `APNSClient` fetches credentials from `Storage.shared`, calls `JWTManager.shared` for a - signed JWT (cached for 55 min), then POSTs to the APNs HTTP/2 endpoint -5. The system delivers the push to `LoopFollowLAExtension`, which updates the Live Activity UI - -**APNs environments:** -- Sandbox (development/TestFlight): `api.sandbox.push.apple.com` -- Production: `api.push.apple.com` -- Selection is automatic via `BuildDetails.isTestFlightBuild()` - -**Token expiry handling:** APNs self-push token expiry (HTTP 410 / 400 BadDeviceToken) -is handled in `APNSClient` with appropriate error logging. The token is the Live Activity -push token obtained from `ActivityKit`, not a device token. - ---- - -## Repo / branch conventions - -- `main` — released versions only (version ends in `.0`) -- `dev` — integration branch; PR #537 targets this -- `live-activity` — bjorkert's working branch for the feature (upstream) -- Philippe's fork branches: `dev`, `live-activity-pr` (original work) -- Version format: `M.N.P` — P increments on each `dev` merge, N increments on release - ---- - -## Build configuration notes - -- App Group ID is derived dynamically — do not hardcode team IDs anywhere -- APNs credentials are now entered by the user at runtime in APNSettingsView -- No APNs secrets in xcconfig, Info.plist, or CI environment variables (as of bjorkert's - latest commit) -- The extension target is `LoopFollowLAExtension` with its own entitlements file - (`LoopFollowLAExtensionExtension.entitlements`) -- `Package.resolved` has been updated to remove SwiftJWT and swift-crypto dependencies From 01e2c1bbc2d02750d1b7faa578625347c7720603 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Thu, 19 Mar 2026 18:54:31 +0100 Subject: [PATCH 72/82] Removed duplicate code --- LoopFollowLAExtension/LoopFollowLABundle.swift | 7 ------- 1 file changed, 7 deletions(-) diff --git a/LoopFollowLAExtension/LoopFollowLABundle.swift b/LoopFollowLAExtension/LoopFollowLABundle.swift index 1b058a210..d98475b8e 100644 --- a/LoopFollowLAExtension/LoopFollowLABundle.swift +++ b/LoopFollowLAExtension/LoopFollowLABundle.swift @@ -1,10 +1,6 @@ // LoopFollow // LoopFollowLABundle.swift -// LoopFollowLABundle.swift -// Philippe Achkar -// 2026-03-07 - import SwiftUI import WidgetKit @@ -15,8 +11,5 @@ struct LoopFollowLABundle: WidgetBundle { if #available(iOS 18.0, *) { LoopFollowLiveActivityWidgetWithCarPlay() } - if #available(iOS 18.0, *) { - LoopFollowLiveActivityWidgetWithCarPlay() - } } } From b3f2436d195e712fd47d1a782b5531050140e313 Mon Sep 17 00:00:00 2001 From: Phil A <76601115+MtlPhil@users.noreply.github.com> Date: Fri, 20 Mar 2026 03:31:22 -0400 Subject: [PATCH 73/82] Live activity - final fixes (#557) * Update BackgroundTaskAudio.swift * Update GlucoseLiveActivityAttributes.swift * Update GlucoseLiveActivityAttributes.swift * Restore explanatory comment for 0.5s audio restart delay --- LoopFollow/Helpers/BackgroundTaskAudio.swift | 7 +++---- .../LiveActivity/GlucoseLiveActivityAttributes.swift | 10 ++++++++++ 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/LoopFollow/Helpers/BackgroundTaskAudio.swift b/LoopFollow/Helpers/BackgroundTaskAudio.swift index e5ddcf988..acbf15cbc 100755 --- a/LoopFollow/Helpers/BackgroundTaskAudio.swift +++ b/LoopFollow/Helpers/BackgroundTaskAudio.swift @@ -37,12 +37,10 @@ class BackgroundTask { LogManager.shared.log(category: .general, message: "[LA] Silent audio session interrupted (began)") case .ended: - // Check shouldResume hint — skip restart if iOS says not to if let optionsValue = userInfo[AVAudioSessionInterruptionOptionKey] as? UInt { let options = AVAudioSession.InterruptionOptions(rawValue: optionsValue) - guard options.contains(.shouldResume) else { - LogManager.shared.log(category: .general, message: "[LA] Silent audio interruption ended — shouldResume not set, skipping restart") - return + if !options.contains(.shouldResume) { + LogManager.shared.log(category: .general, message: "[LA] Silent audio interruption ended — shouldResume not set, attempting restart anyway") } } LogManager.shared.log(category: .general, message: "[LA] Silent audio interruption ended — scheduling restart in 0.5s") @@ -54,6 +52,7 @@ class BackgroundTask { self?.playAudio() } + @unknown default: break } diff --git a/LoopFollow/LiveActivity/GlucoseLiveActivityAttributes.swift b/LoopFollow/LiveActivity/GlucoseLiveActivityAttributes.swift index b04768fab..e1d6b1332 100644 --- a/LoopFollow/LiveActivity/GlucoseLiveActivityAttributes.swift +++ b/LoopFollow/LiveActivity/GlucoseLiveActivityAttributes.swift @@ -29,6 +29,16 @@ struct GlucoseLiveActivityAttributes: ActivityAttributes { let producedAtInterval = try container.decode(Double.self, forKey: .producedAt) producedAt = Date(timeIntervalSince1970: producedAtInterval) } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(snapshot, forKey: .snapshot) + try container.encode(seq, forKey: .seq) + try container.encode(reason, forKey: .reason) + try container.encode(producedAt.timeIntervalSince1970, forKey: .producedAt) + } + + private enum CodingKeys: String, CodingKey { case snapshot, seq, reason, producedAt From db5ddbf89970fa8b2ee406fcbf6aa33556383674 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Sat, 21 Mar 2026 10:54:36 +0100 Subject: [PATCH 74/82] Remove unnecessary @available(iOS 16.4) checks Deployment target is iOS 16.6, so these annotations are redundant. --- LoopFollow/LiveActivity/RestartLiveActivityIntent.swift | 2 -- 1 file changed, 2 deletions(-) diff --git a/LoopFollow/LiveActivity/RestartLiveActivityIntent.swift b/LoopFollow/LiveActivity/RestartLiveActivityIntent.swift index 9e3179244..da0487ec4 100644 --- a/LoopFollow/LiveActivity/RestartLiveActivityIntent.swift +++ b/LoopFollow/LiveActivity/RestartLiveActivityIntent.swift @@ -4,7 +4,6 @@ import AppIntents import UIKit -@available(iOS 16.4, *) struct RestartLiveActivityIntent: AppIntent { static var title: LocalizedStringResource = "Restart Live Activity" static var description = IntentDescription("Starts or restarts the LoopFollow Live Activity.") @@ -28,7 +27,6 @@ struct RestartLiveActivityIntent: AppIntent { } } -@available(iOS 16.4, *) struct LoopFollowAppShortcuts: AppShortcutsProvider { static var appShortcuts: [AppShortcut] { AppShortcut( From a62595ce0edb94d290b3c1aef713c8fc2900c78a Mon Sep 17 00:00:00 2001 From: Phil A <76601115+MtlPhil@users.noreply.github.com> Date: Sun, 22 Mar 2026 09:54:36 -0400 Subject: [PATCH 75/82] BGAppRefreshTask audio recovery; LA expiry notification; code quality (#570) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Update BackgroundTaskAudio.swift * Update GlucoseLiveActivityAttributes.swift * Update GlucoseLiveActivityAttributes.swift * Restore explanatory comment for 0.5s audio restart delay * Add BGAppRefreshTask support for silent audio recovery Registers com.loopfollow.audiorefresh with BGTaskScheduler so iOS can wake the app every ~15 min to check if the silent audio session is still alive and restart it if not. Co-Authored-By: Claude Sonnet 4.6 * Fix BGAppRefreshTask: add fetch background mode, fix duplicate observer - Add 'fetch' to UIBackgroundModes so BGTaskScheduler.submit() doesn't throw notPermitted on every background transition - Call stopBackgroundTask() before startBackgroundTask() in the refresh handler to prevent accumulating duplicate AVAudioSession observers Co-Authored-By: Claude Sonnet 4.6 * Fix duplicate audio observer; add restart confirmation log - startBackgroundTask() now removes the old observer before adding, making it idempotent and preventing duplicate interrupt callbacks - Add 'audio restart initiated' log after restart so success is visible without debug mode - Temporarily make 'Silent audio playing' log always visible for testing Co-Authored-By: Claude Sonnet 4.6 * Delete LiveActivitySlotConfig.swift Forgotten stub. * Update GlucoseSnapshotBuilder.swift * Update StorageCurrentGlucoseStateProvider.swift * Update LiveActivityManager.swift * Update GlucoseSnapshot.swift * Update GlucoseSnapshot.swift * Update LiveActivityManager.swift * Update LiveActivityManager.swift * Update GlucoseLiveActivityAttributes.swift * Update LiveActivityManager.swift * Add LA expiry notification; fix OS-dismissed vs user-dismissed - When renewIfNeeded fails in the background (app can't start a new LA because it's not visible), schedule a local notification on the first failure: "Live Activity Expiring — Open LoopFollow to restart." Subsequent failures in the same cycle are suppressed. Notification is cancelled if renewal later succeeds or forceRestart is called. - In attachStateObserver, distinguish iOS force-dismiss (laRenewalFailed == true) from user swipe (laRenewalFailed == false). OS-dismissed LAs no longer set dismissedByUser, so opening the app triggers auto-restart as expected. Co-Authored-By: Claude Sonnet 4.6 * Remove dead pendingLATapNavigation code Force-quitting an app kills its Live Activities, so cold-launch via LA tap only occurs when iOS terminates the app — in which case scene(_:openURLContexts:) already handles navigation correctly via DispatchQueue.main.async. The flag was never set and never needed. Co-Authored-By: Claude Sonnet 4.6 * Code quality pass: log categories, SwiftFormat, dead code cleanup - BackgroundRefreshManager: all logs → .taskScheduler - AppDelegate: APNs registration/notification logs → .apns - APNSClient: all logs → .apns - BackgroundTaskAudio: restore isDebug:true on silent audio log; fix double blank line - LiveActivityManager: fix trailing whitespace; remove double blank line; SwiftFormat - GlucoseSnapshotBuilder: fix file header (date → standard LoopFollow header) - LoopFollowLiveActivity: remove dead commented-out activityID property - SwiftFormat applied across all reviewed LiveActivity/, Storage/, extension files Co-Authored-By: Claude Sonnet 4.6 * Round prediction value before Int conversion Prevents truncation toward zero (e.g. 179.9 → 179); now correctly rounds to nearest integer. Co-Authored-By: Claude Sonnet 4.6 * Fix double setTaskCompleted race; fix renewal deadline write ordering BackgroundRefreshManager: guard against double setTaskCompleted if the expiration handler fires while the main-queue block is in-flight. Apple documents calling setTaskCompleted more than once as a programming error. LiveActivityManager.renewIfNeeded: write laRenewBy to Storage only after Activity.request succeeds, eliminating the narrow window where a crash between the write and the request could leave the deadline permanently stuck in the future. No rollback needed on failure. The fresh snapshot is built via withRenewalOverlay(false) directly rather than re-running the builder, since the caller already has a current snapshot. Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Claude Sonnet 4.6 --- LoopFollow.xcodeproj/project.pbxproj | 4 + LoopFollow/Application/AppDelegate.swift | 26 +-- LoopFollow/Application/SceneDelegate.swift | 12 +- .../Nightscout/DeviceStatusLoop.swift | 2 +- .../Helpers/BackgroundRefreshManager.swift | 96 ++++++++++ LoopFollow/Helpers/BackgroundTaskAudio.swift | 5 +- LoopFollow/Info.plist | 2 + LoopFollow/LiveActivity/APNSClient.swift | 36 ++-- .../GlucoseLiveActivityAttributes.swift | 2 +- LoopFollow/LiveActivity/GlucoseSnapshot.swift | 75 ++++++-- .../LiveActivity/GlucoseSnapshotBuilder.swift | 164 ++++++++++++------ .../LiveActivity/GlucoseSnapshotStore.swift | 2 +- .../LiveActivity/LAAppGroupSettings.swift | 94 +++++----- .../LiveActivity/LiveActivityManager.swift | 146 ++++++++++------ .../LiveActivity/LiveActivitySlotConfig.swift | 45 ----- .../LiveActivity/PreferredGlucoseUnit.swift | 4 +- .../RestartLiveActivityIntent.swift | 2 +- .../StorageCurrentGlucoseStateProvider.swift | 113 ++++++++++-- LoopFollow/Storage/Storage.swift | 18 +- .../ViewControllers/MainViewController.swift | 1 + .../LoopFollowLiveActivity.swift | 69 ++++---- 21 files changed, 603 insertions(+), 315 deletions(-) create mode 100644 LoopFollow/Helpers/BackgroundRefreshManager.swift delete mode 100644 LoopFollow/LiveActivity/LiveActivitySlotConfig.swift diff --git a/LoopFollow.xcodeproj/project.pbxproj b/LoopFollow.xcodeproj/project.pbxproj index 34c2b838c..305f058b3 100644 --- a/LoopFollow.xcodeproj/project.pbxproj +++ b/LoopFollow.xcodeproj/project.pbxproj @@ -140,6 +140,7 @@ DD608A082C1F584900F91132 /* DeviceStatusLoop.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD608A072C1F584900F91132 /* DeviceStatusLoop.swift */; }; DD608A0A2C23593900F91132 /* SMB.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD608A092C23593900F91132 /* SMB.swift */; }; DD608A0C2C27415C00F91132 /* BackgroundAlertManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD608A0B2C27415C00F91132 /* BackgroundAlertManager.swift */; }; + 66E3D12E66AA4534A144A54B /* BackgroundRefreshManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8CA8BE0B3D247408FE088B4 /* BackgroundRefreshManager.swift */; }; DD6A935E2BFA6FA2003FFB8E /* DeviceStatusOpenAPS.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD6A935D2BFA6FA2003FFB8E /* DeviceStatusOpenAPS.swift */; }; DD7B0D442D730A3B0063DCB6 /* CycleHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD7B0D432D730A320063DCB6 /* CycleHelper.swift */; }; DD7E19842ACDA50C00DBD158 /* Overrides.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD7E19832ACDA50C00DBD158 /* Overrides.swift */; }; @@ -581,6 +582,7 @@ DD608A072C1F584900F91132 /* DeviceStatusLoop.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceStatusLoop.swift; sourceTree = ""; }; DD608A092C23593900F91132 /* SMB.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SMB.swift; sourceTree = ""; }; DD608A0B2C27415C00F91132 /* BackgroundAlertManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundAlertManager.swift; sourceTree = ""; }; + A8CA8BE0B3D247408FE088B4 /* BackgroundRefreshManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundRefreshManager.swift; sourceTree = ""; }; DD6A935D2BFA6FA2003FFB8E /* DeviceStatusOpenAPS.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceStatusOpenAPS.swift; sourceTree = ""; }; DD7B0D432D730A320063DCB6 /* CycleHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CycleHelper.swift; sourceTree = ""; }; DD7E19832ACDA50C00DBD158 /* Overrides.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Overrides.swift; sourceTree = ""; }; @@ -1655,6 +1657,7 @@ FCC6886A24898FD800A0279D /* ObservationToken.swift */, FCC6886C2489909D00A0279D /* AnyConvertible.swift */, FCC688592489554800A0279D /* BackgroundTaskAudio.swift */, + A8CA8BE0B3D247408FE088B4 /* BackgroundRefreshManager.swift */, FCFEEC9F2488157B00402A7F /* Chart.swift */, FCC0FAC124922A22003E610E /* DictionaryKeyPath.swift */, FC16A98024996C07003D6245 /* DateTime.swift */, @@ -2256,6 +2259,7 @@ DDFF3D852D14279B00BF9D9E /* BackgroundRefreshSettingsView.swift in Sources */, DDCF9A882D85FD33004DF4DD /* AlarmData.swift in Sources */, DD608A0C2C27415C00F91132 /* BackgroundAlertManager.swift in Sources */, + 66E3D12E66AA4534A144A54B /* BackgroundRefreshManager.swift in Sources */, 379BECB02F65DA4B0069DC62 /* LiveActivitySettingsView.swift in Sources */, DD4878082C7B30BF0048F05C /* RemoteSettingsView.swift in Sources */, DDE75D2B2DE5E613007C1FC1 /* NavigationRow.swift in Sources */, diff --git a/LoopFollow/Application/AppDelegate.swift b/LoopFollow/Application/AppDelegate.swift index 81b01cf50..d79de7d18 100644 --- a/LoopFollow/Application/AppDelegate.swift +++ b/LoopFollow/Application/AppDelegate.swift @@ -6,7 +6,7 @@ import EventKit import UIKit import UserNotifications -@UIApplicationMain +@main class AppDelegate: UIResponder, UIApplicationDelegate { var window: UIWindow? let notificationCenter = UNUserNotificationCenter.current() @@ -45,6 +45,8 @@ class AppDelegate: UIResponder, UIApplicationDelegate { DispatchQueue.main.async { UIApplication.shared.registerForRemoteNotifications() } + + BackgroundRefreshManager.shared.register() return true } @@ -56,23 +58,23 @@ class AppDelegate: UIResponder, UIApplicationDelegate { // MARK: - Remote Notifications - // Called when successfully registered for remote notifications + /// Called when successfully registered for remote notifications func application(_: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { let tokenString = deviceToken.map { String(format: "%02.2hhx", $0) }.joined() Observable.shared.loopFollowDeviceToken.value = tokenString - LogManager.shared.log(category: .general, message: "Successfully registered for remote notifications with token: \(tokenString)") + LogManager.shared.log(category: .apns, message: "Successfully registered for remote notifications with token: \(tokenString)") } - // Called when failed to register for remote notifications + /// Called when failed to register for remote notifications func application(_: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) { - LogManager.shared.log(category: .general, message: "Failed to register for remote notifications: \(error.localizedDescription)") + LogManager.shared.log(category: .apns, message: "Failed to register for remote notifications: \(error.localizedDescription)") } - // Called when a remote notification is received + /// Called when a remote notification is received func application(_: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable: Any], fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) { - LogManager.shared.log(category: .general, message: "Received remote notification: \(userInfo)") + LogManager.shared.log(category: .apns, message: "Received remote notification: \(userInfo)") // Check if this is a response notification from Loop or Trio if let aps = userInfo["aps"] as? [String: Any] { @@ -80,7 +82,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { if let alert = aps["alert"] as? [String: Any] { let title = alert["title"] as? String ?? "" let body = alert["body"] as? String ?? "" - LogManager.shared.log(category: .general, message: "Notification - Title: \(title), Body: \(body)") + LogManager.shared.log(category: .apns, message: "Notification - Title: \(title), Body: \(body)") } // Handle silent notification (content-available) @@ -88,11 +90,11 @@ class AppDelegate: UIResponder, UIApplicationDelegate { // This is a silent push, nothing implemented but logging for now if let commandStatus = userInfo["command_status"] as? String { - LogManager.shared.log(category: .general, message: "Command status: \(commandStatus)") + LogManager.shared.log(category: .apns, message: "Command status: \(commandStatus)") } if let commandType = userInfo["command_type"] as? String { - LogManager.shared.log(category: .general, message: "Command type: \(commandType)") + LogManager.shared.log(category: .apns, message: "Command type: \(commandType)") } } } @@ -120,7 +122,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { func application(_: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options _: UIScene.ConnectionOptions) -> UISceneConfiguration { // Called when a new scene session is being created. // Use this method to select a configuration to create the new scene with. - return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role) + UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role) } func application(_: UIApplication, didDiscardSceneSessions _: Set) { @@ -176,7 +178,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { func userNotificationCenter(_: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) { if response.actionIdentifier == "OPEN_APP_ACTION" { - if let window = window { + if let window { window.rootViewController?.dismiss(animated: true, completion: nil) window.rootViewController?.present(MainViewController(), animated: true, completion: nil) } diff --git a/LoopFollow/Application/SceneDelegate.swift b/LoopFollow/Application/SceneDelegate.swift index a8fbb236f..3819a7ac6 100644 --- a/LoopFollow/Application/SceneDelegate.swift +++ b/LoopFollow/Application/SceneDelegate.swift @@ -32,16 +32,8 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { func sceneDidBecomeActive(_: UIScene) { // Called when the scene has moved from an inactive state to an active state. // Use this method to restart any tasks that were paused (or not yet started) when the scene was inactive. - if pendingLATapNavigation { - pendingLATapNavigation = false - NotificationCenter.default.post(name: .liveActivityDidForeground, object: nil) - } } - /// Set when loopfollow://la-tap arrives before the scene is fully active. - /// Consumed in sceneDidBecomeActive once the view hierarchy is restored. - private var pendingLATapNavigation = false - func scene(_: UIScene, openURLContexts URLContexts: Set) { guard URLContexts.contains(where: { $0.url.scheme == "loopfollow" && $0.url.host == "la-tap" }) else { return } // scene(_:openURLContexts:) fires after sceneDidBecomeActive when the app @@ -71,7 +63,7 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { (UIApplication.shared.delegate as? AppDelegate)?.saveContext() } - // Handle the UIApplicationShortcutItem when the user taps on the Home Screen Quick Action. This function toggles the "Speak BG" setting in UserDefaultsRepository, speaks the current state (on/off) using AVSpeechSynthesizer, and updates the Quick Action appearance. + /// Handle the UIApplicationShortcutItem when the user taps on the Home Screen Quick Action. This function toggles the "Speak BG" setting in UserDefaultsRepository, speaks the current state (on/off) using AVSpeechSynthesizer, and updates the Quick Action appearance. func handleShortcutItem(_ shortcutItem: UIApplicationShortcutItem) { if let bundleIdentifier = Bundle.main.bundleIdentifier { let expectedType = bundleIdentifier + ".toggleSpeakBG" @@ -84,7 +76,7 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { } } - // The following method is called when the user taps on the Home Screen Quick Action + /// The following method is called when the user taps on the Home Screen Quick Action func windowScene(_: UIWindowScene, performActionFor shortcutItem: UIApplicationShortcutItem, completionHandler _: @escaping (Bool) -> Void) { handleShortcutItem(shortcutItem) } diff --git a/LoopFollow/Controllers/Nightscout/DeviceStatusLoop.swift b/LoopFollow/Controllers/Nightscout/DeviceStatusLoop.swift index 89c4163cd..daeea40f7 100644 --- a/LoopFollow/Controllers/Nightscout/DeviceStatusLoop.swift +++ b/LoopFollow/Controllers/Nightscout/DeviceStatusLoop.swift @@ -66,7 +66,7 @@ extension MainViewController { if let predictdata = lastLoopRecord["predicted"] as? [String: AnyObject] { let prediction = predictdata["values"] as! [Double] - PredictionLabel.text = Localizer.toDisplayUnits(String(Int(prediction.last!))) + PredictionLabel.text = Localizer.toDisplayUnits(String(Int(round(prediction.last!)))) PredictionLabel.textColor = UIColor.systemPurple if Storage.shared.downloadPrediction.value, previousLastLoopTime < lastLoopTime { predictionData.removeAll() diff --git a/LoopFollow/Helpers/BackgroundRefreshManager.swift b/LoopFollow/Helpers/BackgroundRefreshManager.swift new file mode 100644 index 000000000..bac7e1c8e --- /dev/null +++ b/LoopFollow/Helpers/BackgroundRefreshManager.swift @@ -0,0 +1,96 @@ +// LoopFollow +// BackgroundRefreshManager.swift + +import BackgroundTasks +import UIKit + +class BackgroundRefreshManager { + static let shared = BackgroundRefreshManager() + private init() {} + + private let taskIdentifier = "com.loopfollow.audiorefresh" + + func register() { + BGTaskScheduler.shared.register(forTaskWithIdentifier: taskIdentifier, using: nil) { task in + guard let refreshTask = task as? BGAppRefreshTask else { return } + self.handleRefreshTask(refreshTask) + } + } + + private func handleRefreshTask(_ task: BGAppRefreshTask) { + LogManager.shared.log(category: .taskScheduler, message: "BGAppRefreshTask fired") + + // Guard against double setTaskCompleted if expiration fires while the + // main-queue block is in-flight (Apple documents this as a programming error). + var completed = false + + task.expirationHandler = { + guard !completed else { return } + completed = true + LogManager.shared.log(category: .taskScheduler, message: "BGAppRefreshTask expired") + task.setTaskCompleted(success: false) + self.scheduleRefresh() + } + + DispatchQueue.main.async { + guard !completed else { return } + completed = true + if let mainVC = self.getMainViewController() { + if !mainVC.backgroundTask.player.isPlaying { + LogManager.shared.log(category: .taskScheduler, message: "audio dead, attempting restart") + mainVC.backgroundTask.stopBackgroundTask() + mainVC.backgroundTask.startBackgroundTask() + LogManager.shared.log(category: .taskScheduler, message: "audio restart initiated") + } else { + LogManager.shared.log(category: .taskScheduler, message: "audio alive, no action needed", isDebug: true) + } + } + self.scheduleRefresh() + task.setTaskCompleted(success: true) + } + } + + func scheduleRefresh() { + let request = BGAppRefreshTaskRequest(identifier: taskIdentifier) + request.earliestBeginDate = Date(timeIntervalSinceNow: 15 * 60) + do { + try BGTaskScheduler.shared.submit(request) + } catch { + LogManager.shared.log(category: .taskScheduler, message: "Failed to schedule BGAppRefreshTask: \(error)") + } + } + + private func getMainViewController() -> MainViewController? { + guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, + let window = windowScene.windows.first, + let rootVC = window.rootViewController + else { + return nil + } + + if let mainVC = rootVC as? MainViewController { + return mainVC + } + + if let navVC = rootVC as? UINavigationController, + let mainVC = navVC.viewControllers.first as? MainViewController + { + return mainVC + } + + if let tabVC = rootVC as? UITabBarController { + for vc in tabVC.viewControllers ?? [] { + if let mainVC = vc as? MainViewController { + return mainVC + } + if let navVC = vc as? UINavigationController, + let mainVC = navVC.viewControllers.first as? MainViewController + { + return mainVC + } + } + } + + return nil + } +} diff --git a/LoopFollow/Helpers/BackgroundTaskAudio.swift b/LoopFollow/Helpers/BackgroundTaskAudio.swift index 3f19ac63c..25aa6b3c8 100755 --- a/LoopFollow/Helpers/BackgroundTaskAudio.swift +++ b/LoopFollow/Helpers/BackgroundTaskAudio.swift @@ -14,6 +14,7 @@ class BackgroundTask { // MARK: - Methods func startBackgroundTask() { + NotificationCenter.default.removeObserver(self, name: AVAudioSession.interruptionNotification, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(interruptedAudio), name: AVAudioSession.interruptionNotification, object: AVAudioSession.sharedInstance()) retryCount = 0 playAudio() @@ -25,7 +26,7 @@ class BackgroundTask { LogManager.shared.log(category: .general, message: "Silent audio stopped", isDebug: true) } - @objc fileprivate func interruptedAudio(_ notification: Notification) { + @objc private func interruptedAudio(_ notification: Notification) { guard notification.name == AVAudioSession.interruptionNotification, let userInfo = notification.userInfo, let typeValue = userInfo[AVAudioSessionInterruptionTypeKey] as? UInt, @@ -57,7 +58,7 @@ class BackgroundTask { } } - fileprivate func playAudio() { + private func playAudio() { let attemptDesc = retryCount == 0 ? "initial attempt" : "retry \(retryCount)/\(maxRetries)" do { let bundle = Bundle.main.path(forResource: "blank", ofType: "wav") diff --git a/LoopFollow/Info.plist b/LoopFollow/Info.plist index 28385ac6e..5cc7f4146 100644 --- a/LoopFollow/Info.plist +++ b/LoopFollow/Info.plist @@ -7,6 +7,7 @@ BGTaskSchedulerPermittedIdentifiers com.$(unique_id).LoopFollow$(app_suffix) + com.loopfollow.audiorefresh CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) @@ -87,6 +88,7 @@ UIBackgroundModes audio + fetch processing bluetooth-central remote-notification diff --git a/LoopFollow/LiveActivity/APNSClient.swift b/LoopFollow/LiveActivity/APNSClient.swift index 94cee2a85..8755b1b27 100644 --- a/LoopFollow/LiveActivity/APNSClient.swift +++ b/LoopFollow/LiveActivity/APNSClient.swift @@ -21,25 +21,33 @@ class APNSClient { : "https://api.sandbox.push.apple.com" } - private var lfKeyId: String { Storage.shared.lfKeyId.value } - private var lfTeamId: String { BuildDetails.default.teamID ?? "" } - private var lfApnsKey: String { Storage.shared.lfApnsKey.value } + private var lfKeyId: String { + Storage.shared.lfKeyId.value + } + + private var lfTeamId: String { + BuildDetails.default.teamID ?? "" + } + + private var lfApnsKey: String { + Storage.shared.lfApnsKey.value + } // MARK: - Send Live Activity Update func sendLiveActivityUpdate( pushToken: String, - state: GlucoseLiveActivityAttributes.ContentState + state: GlucoseLiveActivityAttributes.ContentState, ) async { guard let jwt = JWTManager.shared.getOrGenerateJWT(keyId: lfKeyId, teamId: lfTeamId, apnsKey: lfApnsKey) else { - LogManager.shared.log(category: .general, message: "APNs failed to generate JWT for Live Activity push") + LogManager.shared.log(category: .apns, message: "APNs failed to generate JWT for Live Activity push") return } let payload = buildPayload(state: state) guard let url = URL(string: "\(apnsHost)/3/device/\(pushToken)") else { - LogManager.shared.log(category: .general, message: "APNs invalid URL", isDebug: true) + LogManager.shared.log(category: .apns, message: "APNs invalid URL", isDebug: true) return } @@ -58,38 +66,38 @@ class APNSClient { if let httpResponse = response as? HTTPURLResponse { switch httpResponse.statusCode { case 200: - LogManager.shared.log(category: .general, message: "APNs push sent successfully", isDebug: true) + LogManager.shared.log(category: .apns, message: "APNs push sent successfully", isDebug: true) case 400: let responseBody = String(data: data, encoding: .utf8) ?? "empty" - LogManager.shared.log(category: .general, message: "APNs bad request (400) — malformed payload: \(responseBody)") + LogManager.shared.log(category: .apns, message: "APNs bad request (400) — malformed payload: \(responseBody)") case 403: // JWT rejected — force regenerate on next push JWTManager.shared.invalidateCache() - LogManager.shared.log(category: .general, message: "APNs JWT rejected (403) — token cache cleared, will regenerate") + LogManager.shared.log(category: .apns, message: "APNs JWT rejected (403) — token cache cleared, will regenerate") case 404, 410: // Activity token not found or expired — end and restart on next refresh let reason = httpResponse.statusCode == 410 ? "expired (410)" : "not found (404)" - LogManager.shared.log(category: .general, message: "APNs token \(reason) — restarting Live Activity") + LogManager.shared.log(category: .apns, message: "APNs token \(reason) — restarting Live Activity") LiveActivityManager.shared.handleExpiredToken() case 429: - LogManager.shared.log(category: .general, message: "APNs rate limited (429) — will retry on next refresh") + LogManager.shared.log(category: .apns, message: "APNs rate limited (429) — will retry on next refresh") case 500 ... 599: let responseBody = String(data: data, encoding: .utf8) ?? "empty" - LogManager.shared.log(category: .general, message: "APNs server error (\(httpResponse.statusCode)) — will retry on next refresh: \(responseBody)") + LogManager.shared.log(category: .apns, message: "APNs server error (\(httpResponse.statusCode)) — will retry on next refresh: \(responseBody)") default: let responseBody = String(data: data, encoding: .utf8) ?? "empty" - LogManager.shared.log(category: .general, message: "APNs push failed status=\(httpResponse.statusCode) body=\(responseBody)") + LogManager.shared.log(category: .apns, message: "APNs push failed status=\(httpResponse.statusCode) body=\(responseBody)") } } } catch { - LogManager.shared.log(category: .general, message: "APNs error: \(error.localizedDescription)") + LogManager.shared.log(category: .apns, message: "APNs error: \(error.localizedDescription)") } } diff --git a/LoopFollow/LiveActivity/GlucoseLiveActivityAttributes.swift b/LoopFollow/LiveActivity/GlucoseLiveActivityAttributes.swift index db4836b88..6d6ddb9a9 100644 --- a/LoopFollow/LiveActivity/GlucoseLiveActivityAttributes.swift +++ b/LoopFollow/LiveActivity/GlucoseLiveActivityAttributes.swift @@ -8,7 +8,7 @@ import ActivityKit import Foundation struct GlucoseLiveActivityAttributes: ActivityAttributes { - public struct ContentState: Codable, Hashable { + struct ContentState: Codable, Hashable { let snapshot: GlucoseSnapshot let seq: Int let reason: String diff --git a/LoopFollow/LiveActivity/GlucoseSnapshot.swift b/LoopFollow/LiveActivity/GlucoseSnapshot.swift index 4e914ab7e..8860391c2 100644 --- a/LoopFollow/LiveActivity/GlucoseSnapshot.swift +++ b/LoopFollow/LiveActivity/GlucoseSnapshot.swift @@ -111,10 +111,12 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable { // MARK: - Renewal - /// True when the Live Activity is within 30 minutes of its renewal deadline. + /// True when the Live Activity is within renewalWarning seconds of its renewal deadline. /// The extension renders a "Tap to update" overlay so the user knows renewal is imminent. let showRenewalOverlay: Bool + // MARK: - Init + init( glucose: Double, delta: Double, @@ -144,7 +146,7 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable { maxBgMgdl: Double? = nil, unit: Unit, isNotLooping: Bool, - showRenewalOverlay: Bool = false + showRenewalOverlay: Bool = false, ) { self.glucose = glucose self.delta = delta @@ -177,6 +179,52 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable { self.showRenewalOverlay = showRenewalOverlay } + // MARK: - Derived Convenience + + /// Age of reading in seconds. + var age: TimeInterval { + Date().timeIntervalSince(updatedAt) + } + + /// Returns a copy of this snapshot with `showRenewalOverlay` set to the given value. + /// All other fields are preserved exactly. Use this instead of manually copying + /// every field when only the overlay flag needs to change. + func withRenewalOverlay(_ value: Bool) -> GlucoseSnapshot { + GlucoseSnapshot( + glucose: glucose, + delta: delta, + trend: trend, + updatedAt: updatedAt, + iob: iob, + cob: cob, + projected: projected, + override: override, + recBolus: recBolus, + battery: battery, + pumpBattery: pumpBattery, + basalRate: basalRate, + pumpReservoirU: pumpReservoirU, + autosens: autosens, + tdd: tdd, + targetLowMgdl: targetLowMgdl, + targetHighMgdl: targetHighMgdl, + isfMgdlPerU: isfMgdlPerU, + carbRatio: carbRatio, + carbsToday: carbsToday, + profileName: profileName, + sageInsertTime: sageInsertTime, + cageInsertTime: cageInsertTime, + iageInsertTime: iageInsertTime, + minBgMgdl: minBgMgdl, + maxBgMgdl: maxBgMgdl, + unit: unit, + isNotLooping: isNotLooping, + showRenewalOverlay: value, + ) + } + + // MARK: - Codable + func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) try container.encode(glucose, forKey: .glucose) @@ -210,17 +258,6 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable { try container.encode(showRenewalOverlay, forKey: .showRenewalOverlay) } - private enum CodingKeys: String, CodingKey { - case glucose, delta, trend, updatedAt - case iob, cob, projected - case override, recBolus, battery, pumpBattery, basalRate, pumpReservoirU - case autosens, tdd, targetLowMgdl, targetHighMgdl, isfMgdlPerU, carbRatio, carbsToday - case profileName, sageInsertTime, cageInsertTime, iageInsertTime, minBgMgdl, maxBgMgdl - case unit, isNotLooping, showRenewalOverlay - } - - // MARK: - Codable - init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) glucose = try container.decode(Double.self, forKey: .glucose) @@ -254,11 +291,13 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable { showRenewalOverlay = try container.decodeIfPresent(Bool.self, forKey: .showRenewalOverlay) ?? false } - // MARK: - Derived Convenience - - /// Age of reading in seconds. - var age: TimeInterval { - Date().timeIntervalSince(updatedAt) + private enum CodingKeys: String, CodingKey { + case glucose, delta, trend, updatedAt + case iob, cob, projected + case override, recBolus, battery, pumpBattery, basalRate, pumpReservoirU + case autosens, tdd, targetLowMgdl, targetHighMgdl, isfMgdlPerU, carbRatio, carbsToday + case profileName, sageInsertTime, cageInsertTime, iageInsertTime, minBgMgdl, maxBgMgdl + case unit, isNotLooping, showRenewalOverlay } } diff --git a/LoopFollow/LiveActivity/GlucoseSnapshotBuilder.swift b/LoopFollow/LiveActivity/GlucoseSnapshotBuilder.swift index dd845b116..40ff076af 100644 --- a/LoopFollow/LiveActivity/GlucoseSnapshotBuilder.swift +++ b/LoopFollow/LiveActivity/GlucoseSnapshotBuilder.swift @@ -3,10 +3,12 @@ import Foundation -/// Provides the *latest* glucose-relevant values from LoopFollow’s single source of truth. -/// This is intentionally provider-agnostic (Nightscout vs Dexcom doesn’t matter). +/// Provides the latest glucose-relevant values from LoopFollow's single source of truth. +/// Intentionally provider-agnostic (Nightscout vs Dexcom doesn't matter). protocol CurrentGlucoseStateProviding { - /// Canonical glucose value in mg/dL (recommended internal canonical form). + // MARK: - Core Glucose + + /// Canonical glucose value in mg/dL. var glucoseMgdl: Double? { get } /// Canonical delta in mg/dL. @@ -15,18 +17,92 @@ protocol CurrentGlucoseStateProviding { /// Canonical projected glucose in mg/dL. var projectedMgdl: Double? { get } - /// Timestamp of the last reading/update. + /// Timestamp of the last reading. var updatedAt: Date? { get } - /// Trend string / code from LoopFollow (we map to GlucoseSnapshot.Trend). + /// Trend string from LoopFollow (mapped to GlucoseSnapshot.Trend by the builder). var trendCode: String? { get } - /// Secondary metrics (typically already unitless) + // MARK: - Secondary Metrics + var iob: Double? { get } var cob: Double? { get } + + // MARK: - Extended Metrics + + /// Active override name (nil if no active override). + var override: String? { get } + + /// Recommended bolus in units. + var recBolus: Double? { get } + + /// CGM/uploader device battery %. + var battery: Double? { get } + + /// Pump battery %. + var pumpBattery: Double? { get } + + /// Formatted current basal rate string (empty if not available). + var basalRate: String { get } + + /// Pump reservoir in units (nil if >50U or unknown). + var pumpReservoirU: Double? { get } + + /// Autosensitivity ratio, e.g. 0.9 = 90%. + var autosens: Double? { get } + + /// Total daily dose in units. + var tdd: Double? { get } + + /// BG target low in mg/dL. + var targetLowMgdl: Double? { get } + + /// BG target high in mg/dL. + var targetHighMgdl: Double? { get } + + /// Insulin Sensitivity Factor in mg/dL per unit. + var isfMgdlPerU: Double? { get } + + /// Carb ratio in g per unit. + var carbRatio: Double? { get } + + /// Total carbs entered today in grams. + var carbsToday: Double? { get } + + /// Active profile name. + var profileName: String? { get } + + /// Sensor insert time as Unix epoch seconds UTC (0 = not set). + var sageInsertTime: TimeInterval { get } + + /// Cannula insert time as Unix epoch seconds UTC (0 = not set). + var cageInsertTime: TimeInterval { get } + + /// Insulin/pod insert time as Unix epoch seconds UTC (0 = not set). + var iageInsertTime: TimeInterval { get } + + /// Min predicted BG in mg/dL. + var minBgMgdl: Double? { get } + + /// Max predicted BG in mg/dL. + var maxBgMgdl: Double? { get } + + // MARK: - Loop Status + + /// True when LoopFollow detects the loop has not reported in 15+ minutes. + var isNotLooping: Bool { get } + + // MARK: - Renewal + + /// True when the Live Activity is within renewalWarning seconds of its deadline. + var showRenewalOverlay: Bool { get } } -/// Builds a GlucoseSnapshot in the user’s preferred unit, without embedding provider logic. +// MARK: - Builder + +/// Pure transformation layer. Reads exclusively from the provider — no direct +/// Storage.shared or Observable.shared access. This makes it testable and reusable +/// across Live Activity, Watch, and CarPlay. enum GlucoseSnapshotBuilder { static func build(from provider: CurrentGlucoseStateProviding) -> GlucoseSnapshot? { guard @@ -34,43 +110,28 @@ enum GlucoseSnapshotBuilder { glucoseMgdl > 0, let updatedAt = provider.updatedAt else { - // Debug-only signal: we’re missing core state. - // (If you prefer no logs here, remove this line.) LogManager.shared.log( category: .general, message: "GlucoseSnapshotBuilder: missing/invalid core values glucoseMgdl=\(provider.glucoseMgdl?.description ?? "nil") updatedAt=\(provider.updatedAt?.description ?? "nil")", - isDebug: true + isDebug: true, ) return nil } let preferredUnit = PreferredGlucoseUnit.snapshotUnit() - let deltaMgdl = provider.deltaMgdl ?? 0.0 - let trend = mapTrend(provider.trendCode) - // Not Looping — read from Observable, set by evaluateNotLooping() in DeviceStatus.swift - let isNotLooping = Observable.shared.isNotLooping.value - - // Renewal overlay — show renewalWarning seconds before the renewal deadline - // so the user knows the LA is about to be replaced. - let renewBy = Storage.shared.laRenewBy.value - let now = Date().timeIntervalSince1970 - let showRenewalOverlay = renewBy > 0 && now >= renewBy - LiveActivityManager.renewalWarning - - if showRenewalOverlay { - let timeLeft = max(renewBy - now, 0) - LogManager.shared.log(category: .general, message: "[LA] renewal overlay ON — \(Int(timeLeft))s until deadline") + if provider.showRenewalOverlay { + LogManager.shared.log(category: .general, message: "[LA] renewal overlay ON") } LogManager.shared.log( category: .general, message: "LA snapshot built: updatedAt=\(updatedAt) interval=\(updatedAt.timeIntervalSince1970)", - isDebug: true + isDebug: true, ) - let profileNameRaw = Storage.shared.lastProfileName.value return GlucoseSnapshot( glucose: glucoseMgdl, delta: deltaMgdl, @@ -79,31 +140,33 @@ enum GlucoseSnapshotBuilder { iob: provider.iob, cob: provider.cob, projected: provider.projectedMgdl, - override: Observable.shared.override.value, - recBolus: Observable.shared.deviceRecBolus.value, - battery: Observable.shared.deviceBatteryLevel.value, - pumpBattery: Observable.shared.pumpBatteryLevel.value, - basalRate: Storage.shared.lastBasal.value, - pumpReservoirU: Storage.shared.lastPumpReservoirU.value, - autosens: Storage.shared.lastAutosens.value, - tdd: Storage.shared.lastTdd.value, - targetLowMgdl: Storage.shared.lastTargetLowMgdl.value, - targetHighMgdl: Storage.shared.lastTargetHighMgdl.value, - isfMgdlPerU: Storage.shared.lastIsfMgdlPerU.value, - carbRatio: Storage.shared.lastCarbRatio.value, - carbsToday: Storage.shared.lastCarbsToday.value, - profileName: profileNameRaw.isEmpty ? nil : profileNameRaw, - sageInsertTime: Storage.shared.sageInsertTime.value, - cageInsertTime: Storage.shared.cageInsertTime.value, - iageInsertTime: Storage.shared.iageInsertTime.value, - minBgMgdl: Storage.shared.lastMinBgMgdl.value, - maxBgMgdl: Storage.shared.lastMaxBgMgdl.value, + override: provider.override, + recBolus: provider.recBolus, + battery: provider.battery, + pumpBattery: provider.pumpBattery, + basalRate: provider.basalRate, + pumpReservoirU: provider.pumpReservoirU, + autosens: provider.autosens, + tdd: provider.tdd, + targetLowMgdl: provider.targetLowMgdl, + targetHighMgdl: provider.targetHighMgdl, + isfMgdlPerU: provider.isfMgdlPerU, + carbRatio: provider.carbRatio, + carbsToday: provider.carbsToday, + profileName: provider.profileName, + sageInsertTime: provider.sageInsertTime, + cageInsertTime: provider.cageInsertTime, + iageInsertTime: provider.iageInsertTime, + minBgMgdl: provider.minBgMgdl, + maxBgMgdl: provider.maxBgMgdl, unit: preferredUnit, - isNotLooping: isNotLooping, - showRenewalOverlay: showRenewalOverlay + isNotLooping: provider.isNotLooping, + showRenewalOverlay: provider.showRenewalOverlay, ) } + // MARK: - Trend Mapping + private static func mapTrend(_ code: String?) -> GlucoseSnapshot.Trend { guard let raw = code? @@ -112,11 +175,6 @@ enum GlucoseSnapshotBuilder { !raw.isEmpty else { return .unknown } - // Common Nightscout strings: - // "Flat", "FortyFiveUp", "SingleUp", "DoubleUp", "FortyFiveDown", "SingleDown", "DoubleDown" - // Common variants: - // "rising", "falling", "rapidRise", "rapidFall" - if raw.contains("doubleup") || raw.contains("rapidrise") || raw == "up2" || raw == "upfast" { return .upFast } @@ -126,11 +184,9 @@ enum GlucoseSnapshotBuilder { if raw.contains("singleup") || raw == "up" || raw == "up1" || raw == "rising" { return .up } - if raw.contains("flat") || raw == "steady" || raw == "none" { return .flat } - if raw.contains("doubledown") || raw.contains("rapidfall") || raw == "down2" || raw == "downfast" { return .downFast } diff --git a/LoopFollow/LiveActivity/GlucoseSnapshotStore.swift b/LoopFollow/LiveActivity/GlucoseSnapshotStore.swift index b45a7a0b9..7951e122a 100644 --- a/LoopFollow/LiveActivity/GlucoseSnapshotStore.swift +++ b/LoopFollow/LiveActivity/GlucoseSnapshotStore.swift @@ -67,7 +67,7 @@ final class GlucoseSnapshotStore { throw NSError( domain: "GlucoseSnapshotStore", code: 1, - userInfo: [NSLocalizedDescriptionKey: "App Group containerURL is nil for id=\(groupID)"] + userInfo: [NSLocalizedDescriptionKey: "App Group containerURL is nil for id=\(groupID)"], ) } return containerURL.appendingPathComponent(fileName, isDirectory: false) diff --git a/LoopFollow/LiveActivity/LAAppGroupSettings.swift b/LoopFollow/LiveActivity/LAAppGroupSettings.swift index 4e1d7b126..8fedeb155 100644 --- a/LoopFollow/LiveActivity/LAAppGroupSettings.swift +++ b/LoopFollow/LiveActivity/LAAppGroupSettings.swift @@ -44,56 +44,56 @@ enum LiveActivitySlotOption: String, CaseIterable, Codable { /// Human-readable label shown in the slot picker in Settings. var displayName: String { switch self { - case .none: return "Empty" - case .delta: return "Delta" - case .projectedBG: return "Projected BG" - case .minMax: return "Min/Max" - case .iob: return "IOB" - case .cob: return "COB" - case .recBolus: return "Rec. Bolus" - case .autosens: return "Autosens" - case .tdd: return "TDD" - case .basal: return "Basal" - case .pump: return "Pump" - case .pumpBattery: return "Pump Battery" - case .battery: return "Battery" - case .target: return "Target" - case .isf: return "ISF" - case .carbRatio: return "CR" - case .sage: return "SAGE" - case .cage: return "CAGE" - case .iage: return "IAGE" - case .carbsToday: return "Carbs today" - case .override: return "Override" - case .profile: return "Profile" + case .none: "Empty" + case .delta: "Delta" + case .projectedBG: "Projected BG" + case .minMax: "Min/Max" + case .iob: "IOB" + case .cob: "COB" + case .recBolus: "Rec. Bolus" + case .autosens: "Autosens" + case .tdd: "TDD" + case .basal: "Basal" + case .pump: "Pump" + case .pumpBattery: "Pump Battery" + case .battery: "Battery" + case .target: "Target" + case .isf: "ISF" + case .carbRatio: "CR" + case .sage: "SAGE" + case .cage: "CAGE" + case .iage: "IAGE" + case .carbsToday: "Carbs today" + case .override: "Override" + case .profile: "Profile" } } /// Short label used inside the MetricBlock on the Live Activity card. var gridLabel: String { switch self { - case .none: return "" - case .delta: return "Delta" - case .projectedBG: return "Proj" - case .minMax: return "Min/Max" - case .iob: return "IOB" - case .cob: return "COB" - case .recBolus: return "Rec." - case .autosens: return "Sens" - case .tdd: return "TDD" - case .basal: return "Basal" - case .pump: return "Pump" - case .pumpBattery: return "Pump%" - case .battery: return "Bat." - case .target: return "Target" - case .isf: return "ISF" - case .carbRatio: return "CR" - case .sage: return "SAGE" - case .cage: return "CAGE" - case .iage: return "IAGE" - case .carbsToday: return "Carbs" - case .override: return "Ovrd" - case .profile: return "Prof" + case .none: "" + case .delta: "Delta" + case .projectedBG: "Proj" + case .minMax: "Min/Max" + case .iob: "IOB" + case .cob: "COB" + case .recBolus: "Rec." + case .autosens: "Sens" + case .tdd: "TDD" + case .basal: "Basal" + case .pump: "Pump" + case .pumpBattery: "Pump%" + case .battery: "Bat." + case .target: "Target" + case .isf: "ISF" + case .carbRatio: "CR" + case .sage: "SAGE" + case .cage: "CAGE" + case .iage: "IAGE" + case .carbsToday: "Carbs" + case .override: "Ovrd" + case .profile: "Prof" } } @@ -101,8 +101,8 @@ enum LiveActivitySlotOption: String, CaseIterable, Codable { /// no Loop data). The widget renders "—" in those cases. var isOptional: Bool { switch self { - case .none, .delta: return false - default: return true + case .none, .delta: false + default: true } } } @@ -162,7 +162,7 @@ enum LAAppGroupSettings { /// - Parameter slots: Array of exactly 4 `LiveActivitySlotOption` values; /// extra elements are ignored, missing elements are filled with `.none`. static func setSlots(_ slots: [LiveActivitySlotOption]) { - let raw = slots.prefix(4).map { $0.rawValue } + let raw = slots.prefix(4).map(\.rawValue) defaults?.set(raw, forKey: Keys.slots) } diff --git a/LoopFollow/LiveActivity/LiveActivityManager.swift b/LoopFollow/LiveActivity/LiveActivityManager.swift index 00d230e40..9faa8a41e 100644 --- a/LoopFollow/LiveActivity/LiveActivityManager.swift +++ b/LoopFollow/LiveActivity/LiveActivityManager.swift @@ -8,8 +8,9 @@ import Foundation import os import UIKit +import UserNotifications -/// Live Activity manager for LoopFollow. +// Live Activity manager for LoopFollow. final class LiveActivityManager { static let shared = LiveActivityManager() @@ -18,25 +19,25 @@ final class LiveActivityManager { self, selector: #selector(handleForeground), name: UIApplication.willEnterForegroundNotification, - object: nil + object: nil, ) NotificationCenter.default.addObserver( self, selector: #selector(handleDidBecomeActive), name: UIApplication.didBecomeActiveNotification, - object: nil + object: nil, ) NotificationCenter.default.addObserver( self, selector: #selector(handleWillResignActive), name: UIApplication.willResignActiveNotification, - object: nil + object: nil, ) NotificationCenter.default.addObserver( self, selector: #selector(handleBackgroundAudioFailed), name: .backgroundAudioFailed, - object: nil + object: nil, ) } @@ -55,7 +56,7 @@ final class LiveActivityManager { LAAppGroupSettings.setThresholds( lowMgdl: Storage.shared.lowLine.value, - highMgdl: Storage.shared.highLine.value + highMgdl: Storage.shared.highLine.value, ) GlucoseSnapshotStore.shared.save(snapshot) @@ -65,12 +66,12 @@ final class LiveActivityManager { snapshot: snapshot, seq: nextSeq, reason: "resign-active", - producedAt: Date() + producedAt: Date(), ) let content = ActivityContent( state: state, staleDate: Date(timeIntervalSince1970: Storage.shared.laRenewBy.value), - relevanceScore: 100.0 + relevanceScore: 100.0, ) Task { @@ -184,23 +185,29 @@ final class LiveActivityManager { do { let attributes = GlucoseLiveActivityAttributes(title: "LoopFollow") - let seedSnapshot = GlucoseSnapshotStore.shared.load() ?? GlucoseSnapshot( - glucose: 0, - delta: 0, - trend: .unknown, - updatedAt: Date(), - iob: nil, - cob: nil, - projected: nil, - unit: .mgdl, - isNotLooping: false - ) + // Prefer a freshly built snapshot so all extended fields are populated. + // Fall back to the persisted store (covers cold-start with real data), + // then to a zero seed (true first-ever launch with no data yet). + let provider = StorageCurrentGlucoseStateProvider() + let seedSnapshot = GlucoseSnapshotBuilder.build(from: provider) + ?? GlucoseSnapshotStore.shared.load() + ?? GlucoseSnapshot( + glucose: 0, + delta: 0, + trend: .unknown, + updatedAt: Date(), + iob: nil, + cob: nil, + projected: nil, + unit: .mgdl, + isNotLooping: false, + ) let initialState = GlucoseLiveActivityAttributes.ContentState( snapshot: seedSnapshot, seq: 0, reason: "start", - producedAt: Date() + producedAt: Date(), ) let renewDeadline = Date().addingTimeInterval(LiveActivityManager.renewalThreshold) @@ -249,11 +256,11 @@ final class LiveActivityManager { cob: nil, projected: nil, unit: .mgdl, - isNotLooping: false + isNotLooping: false, ), seq: seq, reason: "end", - producedAt: Date() + producedAt: Date(), ) let content = ActivityContent(state: finalState, staleDate: nil) @@ -277,6 +284,7 @@ final class LiveActivityManager { dismissedByUser = false Storage.shared.laRenewBy.value = 0 Storage.shared.laRenewalFailed.value = false + cancelRenewalFailedNotification() current = nil updateTask?.cancel(); updateTask = nil tokenObservationTask?.cancel(); tokenObservationTask = nil @@ -300,7 +308,7 @@ final class LiveActivityManager { if let snapshot = GlucoseSnapshotBuilder.build(from: provider) { LAAppGroupSettings.setThresholds( lowMgdl: Storage.shared.lowLine.value, - highMgdl: Storage.shared.highLine.value + highMgdl: Storage.shared.highLine.value, ) GlucoseSnapshotStore.shared.save(snapshot) } @@ -336,32 +344,24 @@ final class LiveActivityManager { let renewDeadline = Date().addingTimeInterval(LiveActivityManager.renewalThreshold) let attributes = GlucoseLiveActivityAttributes(title: "LoopFollow") - // Strip the overlay flag — the new LA has a fresh deadline so it should - // open clean, without the warning visible from the first frame. - let freshSnapshot = GlucoseSnapshot( - glucose: snapshot.glucose, - delta: snapshot.delta, - trend: snapshot.trend, - updatedAt: snapshot.updatedAt, - iob: snapshot.iob, - cob: snapshot.cob, - projected: snapshot.projected, - unit: snapshot.unit, - isNotLooping: snapshot.isNotLooping, - showRenewalOverlay: false - ) + // Build the fresh snapshot with showRenewalOverlay: false — the new LA has a + // fresh deadline so no overlay is needed from the first frame. We pass the + // deadline as staleDate to ActivityContent below, not to Storage yet; Storage + // is only updated after Activity.request succeeds so a crash between the two + // can't leave the deadline permanently stuck in the future. + let freshSnapshot = snapshot.withRenewalOverlay(false) + let state = GlucoseLiveActivityAttributes.ContentState( snapshot: freshSnapshot, seq: seq, reason: "renew", - producedAt: Date() + producedAt: Date(), ) let content = ActivityContent(state: state, staleDate: renewDeadline) do { let newActivity = try Activity.request(attributes: attributes, content: content, pushType: .token) - // New LA is live — now it's safe to remove the old card. Task { await oldActivity.end(nil, dismissalPolicy: .immediate) } @@ -374,16 +374,23 @@ final class LiveActivityManager { stateObserverTask = nil pushToken = nil - bind(to: newActivity, logReason: "renew") + // Write deadline only on success — avoids a stuck future deadline if we crash + // between the write and the Activity.request call. Storage.shared.laRenewBy.value = renewDeadline.timeIntervalSince1970 + bind(to: newActivity, logReason: "renew") Storage.shared.laRenewalFailed.value = false - // Update the store so the next duplicate check has the correct baseline. + cancelRenewalFailedNotification() GlucoseSnapshotStore.shared.save(freshSnapshot) LogManager.shared.log(category: .general, message: "[LA] Live Activity renewed successfully id=\(newActivity.id)") return true } catch { + // Renewal failed — deadline was never written, so no rollback needed. + let isFirstFailure = !Storage.shared.laRenewalFailed.value Storage.shared.laRenewalFailed.value = true LogManager.shared.log(category: .general, message: "[LA] renewal failed, keeping existing LA: \(error)") + if isFirstFailure { + scheduleRenewalFailedNotification() + } return false } } @@ -415,7 +422,7 @@ final class LiveActivityManager { } LAAppGroupSettings.setThresholds( lowMgdl: Storage.shared.lowLine.value, - highMgdl: Storage.shared.highLine.value + highMgdl: Storage.shared.highLine.value, ) GlucoseSnapshotStore.shared.save(snapshot) guard ActivityAuthorizationInfo().areActivitiesEnabled else { @@ -460,21 +467,21 @@ final class LiveActivityManager { snapshot: snapshot, seq: nextSeq, reason: reason, - producedAt: Date() + producedAt: Date(), ) updateTask = Task { [weak self] in guard let self else { return } if activity.activityState == .ended || activity.activityState == .dismissed { - if self.current?.id == activityID { self.current = nil } + if current?.id == activityID { current = nil } return } let content = ActivityContent( state: state, staleDate: Date(timeIntervalSince1970: Storage.shared.laRenewBy.value), - relevanceScore: 100.0 + relevanceScore: 100.0, ) if Task.isCancelled { return } @@ -495,15 +502,15 @@ final class LiveActivityManager { if Task.isCancelled { return } - guard self.current?.id == activityID else { + guard current?.id == activityID else { LogManager.shared.log(category: .general, message: "Live Activity update — activity ID mismatch, discarding") return } - self.lastUpdateTime = Date() + lastUpdateTime = Date() LogManager.shared.log(category: .general, message: "[LA] updated id=\(activityID) seq=\(nextSeq) reason=\(reason)", isDebug: true) - if let token = self.pushToken { + if let token = pushToken { await APNSClient.shared.sendLiveActivityUpdate(pushToken: token, state: state) } } @@ -548,6 +555,33 @@ final class LiveActivityManager { // Activity will restart on next BG refresh via refreshFromCurrentState() } + // MARK: - Renewal Notifications + + private func scheduleRenewalFailedNotification() { + let content = UNMutableNotificationContent() + content.title = "Live Activity Expiring" + content.body = "Live Activity will expire soon. Open LoopFollow to restart." + content.sound = .default + let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 1, repeats: false) + let request = UNNotificationRequest( + identifier: "loopfollow.la.renewal.failed", + content: content, + trigger: trigger, + ) + UNUserNotificationCenter.current().add(request) { error in + if let error { + LogManager.shared.log(category: .general, message: "[LA] failed to schedule renewal notification: \(error)") + } + } + LogManager.shared.log(category: .general, message: "[LA] renewal failed notification scheduled") + } + + private func cancelRenewalFailedNotification() { + let id = "loopfollow.la.renewal.failed" + UNUserNotificationCenter.current().removePendingNotificationRequests(withIdentifiers: [id]) + UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: [id]) + } + private func attachStateObserver(to activity: Activity) { stateObserverTask?.cancel() stateObserverTask = Task { @@ -560,11 +594,17 @@ final class LiveActivityManager { LogManager.shared.log(category: .general, message: "Live Activity cleared id=\(activity.id)", isDebug: true) } if state == .dismissed { - // User manually swiped away the LA. Block auto-restart until - // the user explicitly restarts via button or App Intent. - // laEnabled is left true — the user's preference is preserved. - dismissedByUser = true - LogManager.shared.log(category: .general, message: "Live Activity dismissed by user — auto-restart blocked until explicit restart") + if Storage.shared.laRenewalFailed.value { + // iOS force-dismissed after 8-hour limit with a failed renewal. + // Allow auto-restart when the user opens the app. + LogManager.shared.log(category: .general, message: "Live Activity dismissed by iOS after expiry — auto-restart enabled") + } else { + // User manually swiped away the LA. Block auto-restart until + // the user explicitly restarts via button or App Intent. + // laEnabled is left true — the user's preference is preserved. + dismissedByUser = true + LogManager.shared.log(category: .general, message: "Live Activity dismissed by user — auto-restart blocked until explicit restart") + } } } } diff --git a/LoopFollow/LiveActivity/LiveActivitySlotConfig.swift b/LoopFollow/LiveActivity/LiveActivitySlotConfig.swift deleted file mode 100644 index 10d8b13c3..000000000 --- a/LoopFollow/LiveActivity/LiveActivitySlotConfig.swift +++ /dev/null @@ -1,45 +0,0 @@ -// LoopFollow -// LiveActivitySlotConfig.swift - -// MARK: - Information Display Settings audit - -// -// LoopFollow exposes 20 items in Information Display Settings (InfoType.swift). -// The table below maps each item to its availability as a Live Activity grid slot. -// -// AVAILABLE NOW — value present in GlucoseSnapshot: -// Display name | InfoType case | Snapshot field | Optional (nil for Dexcom-only) -// ───────────────────────────────────────────────────────────────────────────────── -// IOB | .iob | snapshot.iob | YES -// COB | .cob | snapshot.cob | YES -// Projected BG | (none) | snapshot.projected | YES -// Delta | (none) | snapshot.delta | NO (always available) -// -// Note: "Updated" (InfoType.updated) is intentionally excluded — it is displayed -// in the card footer and is not a configurable slot. -// -// NOT YET AVAILABLE — requires adding fields to GlucoseSnapshot, GlucoseSnapshotBuilder, -// and the APNs payload before they can be offered as slot options: -// Display name | InfoType case | Source in app -// ───────────────────────────────────────────────────────────────────────────────── -// Basal | .basal | DeviceStatus basal rate -// Override | .override | DeviceStatus override name -// Battery | .battery | DeviceStatus CGM/device battery % -// Pump | .pump | DeviceStatus pump name / status -// Pump Battery | .pumpBattery | DeviceStatus pump battery % -// SAGE | .sage | DeviceStatus sensor age (hours) -// CAGE | .cage | DeviceStatus cannula age (hours) -// Rec. Bolus | .recBolus | DeviceStatus recommended bolus -// Min/Max | .minMax | Computed from recent BG history -// Carbs today | .carbsToday | Computed from COB history -// Autosens | .autosens | DeviceStatusOpenAPS autosens ratio -// Profile | .profile | DeviceStatus profile name -// Target | .target | DeviceStatus BG target -// ISF | .isf | DeviceStatus insulin sensitivity factor -// CR | .carbRatio | DeviceStatus carb ratio -// TDD | .tdd | DeviceStatus total daily dose -// IAGE | .iage | DeviceStatus insulin/pod age (hours) -// -// The LiveActivitySlotOption enum, LiveActivitySlotDefaults struct, and -// LAAppGroupSettings.setSlots() / slots() storage are defined in -// LAAppGroupSettings.swift (shared between app and extension targets). diff --git a/LoopFollow/LiveActivity/PreferredGlucoseUnit.swift b/LoopFollow/LiveActivity/PreferredGlucoseUnit.swift index eb26b9b54..3ce52f948 100644 --- a/LoopFollow/LiveActivity/PreferredGlucoseUnit.swift +++ b/LoopFollow/LiveActivity/PreferredGlucoseUnit.swift @@ -14,9 +14,9 @@ enum PreferredGlucoseUnit { static func snapshotUnit() -> GlucoseSnapshot.Unit { switch hkUnit() { case .millimolesPerLiter: - return .mmol + .mmol default: - return .mgdl + .mgdl } } } diff --git a/LoopFollow/LiveActivity/RestartLiveActivityIntent.swift b/LoopFollow/LiveActivity/RestartLiveActivityIntent.swift index da0487ec4..cb1f84d18 100644 --- a/LoopFollow/LiveActivity/RestartLiveActivityIntent.swift +++ b/LoopFollow/LiveActivity/RestartLiveActivityIntent.swift @@ -33,7 +33,7 @@ struct LoopFollowAppShortcuts: AppShortcutsProvider { intent: RestartLiveActivityIntent(), phrases: ["Restart Live Activity in \(.applicationName)"], shortTitle: "Restart Live Activity", - systemImageName: "dot.radiowaves.left.and.right" + systemImageName: "dot.radiowaves.left.and.right", ) } } diff --git a/LoopFollow/LiveActivity/StorageCurrentGlucoseStateProvider.swift b/LoopFollow/LiveActivity/StorageCurrentGlucoseStateProvider.swift index b5a5cf7ea..90e74f5b8 100644 --- a/LoopFollow/LiveActivity/StorageCurrentGlucoseStateProvider.swift +++ b/LoopFollow/LiveActivity/StorageCurrentGlucoseStateProvider.swift @@ -1,19 +1,17 @@ -// LoopFollow // StorageCurrentGlucoseStateProvider.swift +// 2026-03-21 import Foundation -/// Reads the latest glucose state from LoopFollow’s existing single source of truth. -/// Provider remains source-agnostic (Nightscout vs Dexcom). +/// Reads the latest glucose state from LoopFollow's Storage and Observable layers. +/// This is the only file in the pipeline that is allowed to touch Storage.shared +/// or Observable.shared — all other layers read exclusively from this provider. struct StorageCurrentGlucoseStateProvider: CurrentGlucoseStateProviding { - var glucoseMgdl: Double? { - guard - let bg = Observable.shared.bg.value, - bg > 0 - else { - return nil - } + // MARK: - Core Glucose + + var glucoseMgdl: Double? { + guard let bg = Observable.shared.bg.value, bg > 0 else { return nil } return Double(bg) } @@ -34,6 +32,8 @@ struct StorageCurrentGlucoseStateProvider: CurrentGlucoseStateProviding { Storage.shared.lastTrendCode.value } + // MARK: - Secondary Metrics + var iob: Double? { Storage.shared.lastIOB.value } @@ -41,4 +41,97 @@ struct StorageCurrentGlucoseStateProvider: CurrentGlucoseStateProviding { var cob: Double? { Storage.shared.lastCOB.value } + + // MARK: - Extended Metrics + + var override: String? { + Observable.shared.override.value + } + + var recBolus: Double? { + Observable.shared.deviceRecBolus.value + } + + var battery: Double? { + Observable.shared.deviceBatteryLevel.value + } + + var pumpBattery: Double? { + Observable.shared.pumpBatteryLevel.value + } + + var basalRate: String { + Storage.shared.lastBasal.value + } + + var pumpReservoirU: Double? { + Storage.shared.lastPumpReservoirU.value + } + + var autosens: Double? { + Storage.shared.lastAutosens.value + } + + var tdd: Double? { + Storage.shared.lastTdd.value + } + + var targetLowMgdl: Double? { + Storage.shared.lastTargetLowMgdl.value + } + + var targetHighMgdl: Double? { + Storage.shared.lastTargetHighMgdl.value + } + + var isfMgdlPerU: Double? { + Storage.shared.lastIsfMgdlPerU.value + } + + var carbRatio: Double? { + Storage.shared.lastCarbRatio.value + } + + var carbsToday: Double? { + Storage.shared.lastCarbsToday.value + } + + var profileName: String? { + let raw = Storage.shared.lastProfileName.value + return raw.isEmpty ? nil : raw + } + + var sageInsertTime: TimeInterval { + Storage.shared.sageInsertTime.value + } + + var cageInsertTime: TimeInterval { + Storage.shared.cageInsertTime.value + } + + var iageInsertTime: TimeInterval { + Storage.shared.iageInsertTime.value + } + + var minBgMgdl: Double? { + Storage.shared.lastMinBgMgdl.value + } + + var maxBgMgdl: Double? { + Storage.shared.lastMaxBgMgdl.value + } + + // MARK: - Loop Status + + var isNotLooping: Bool { + Observable.shared.isNotLooping.value + } + + // MARK: - Renewal + + var showRenewalOverlay: Bool { + let renewBy = Storage.shared.laRenewBy.value + let now = Date().timeIntervalSince1970 + return renewBy > 0 && now >= renewBy - LiveActivityManager.renewalWarning + } } diff --git a/LoopFollow/Storage/Storage.swift b/LoopFollow/Storage/Storage.swift index fd402c592..efb55b031 100644 --- a/LoopFollow/Storage/Storage.swift +++ b/LoopFollow/Storage/Storage.swift @@ -179,8 +179,8 @@ class Storage { var token = StorageValue(key: "token", defaultValue: "") var units = StorageValue(key: "units", defaultValue: "mg/dL") - var infoSort = StorageValue<[Int]>(key: "infoSort", defaultValue: InfoType.allCases.map { $0.sortOrder }) - var infoVisible = StorageValue<[Bool]>(key: "infoVisible", defaultValue: InfoType.allCases.map { $0.defaultVisible }) + var infoSort = StorageValue<[Int]>(key: "infoSort", defaultValue: InfoType.allCases.map(\.sortOrder)) + var infoVisible = StorageValue<[Bool]>(key: "infoVisible", defaultValue: InfoType.allCases.map(\.defaultVisible)) var url = StorageValue(key: "url", defaultValue: "") var device = StorageValue(key: "device", defaultValue: "") @@ -221,13 +221,13 @@ class Storage { /// Get the position for a given tab item func position(for item: TabItem) -> TabPosition { switch item { - case .home: return homePosition.value - case .alarms: return alarmsPosition.value - case .remote: return remotePosition.value - case .nightscout: return nightscoutPosition.value - case .snoozer: return snoozerPosition.value - case .stats: return statisticsPosition.value - case .treatments: return treatmentsPosition.value + case .home: homePosition.value + case .alarms: alarmsPosition.value + case .remote: remotePosition.value + case .nightscout: nightscoutPosition.value + case .snoozer: snoozerPosition.value + case .stats: statisticsPosition.value + case .treatments: treatmentsPosition.value } } diff --git a/LoopFollow/ViewControllers/MainViewController.swift b/LoopFollow/ViewControllers/MainViewController.swift index 7d6f8bf35..bbf2de63c 100644 --- a/LoopFollow/ViewControllers/MainViewController.swift +++ b/LoopFollow/ViewControllers/MainViewController.swift @@ -964,6 +964,7 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele if Storage.shared.backgroundRefreshType.value == .silentTune { backgroundTask.startBackgroundTask() + BackgroundRefreshManager.shared.scheduleRefresh() } if Storage.shared.backgroundRefreshType.value != .none { diff --git a/LoopFollowLAExtension/LoopFollowLiveActivity.swift b/LoopFollowLAExtension/LoopFollowLiveActivity.swift index 753402e05..5c28eed3b 100644 --- a/LoopFollowLAExtension/LoopFollowLiveActivity.swift +++ b/LoopFollowLAExtension/LoopFollowLiveActivity.swift @@ -80,7 +80,7 @@ private extension View { func applyActivityContentMarginsFixIfAvailable() -> some View { if #available(iOS 17.0, *) { // Use the generic SwiftUI API available in iOS 17+ (no placement enum) - self.contentMargins(Edge.Set.all, 0) + contentMargins(Edge.Set.all, 0) } else { self } @@ -151,7 +151,6 @@ private struct SmallFamilyView: View { private struct LockScreenLiveActivityView: View { let state: GlucoseLiveActivityAttributes.ContentState - /* let activityID: String */ var body: some View { let s = state.snapshot @@ -220,7 +219,7 @@ private struct LockScreenLiveActivityView: View { .padding(.bottom, 8) .overlay( RoundedRectangle(cornerRadius: 16, style: .continuous) - .stroke(Color.white.opacity(0.20), lineWidth: 1) + .stroke(Color.white.opacity(0.20), lineWidth: 1), ) .overlay( Group { @@ -234,7 +233,7 @@ private struct LockScreenLiveActivityView: View { .tracking(1.5) } } - } + }, ) .overlay( ZStack { @@ -244,7 +243,7 @@ private struct LockScreenLiveActivityView: View { .font(.system(size: 20, weight: .semibold)) .foregroundStyle(.white) } - .opacity(state.snapshot.showRenewalOverlay ? 1 : 0) + .opacity(state.snapshot.showRenewalOverlay ? 1 : 0), ) } } @@ -307,28 +306,28 @@ private struct SlotView: View { private func value(for option: LiveActivitySlotOption) -> String { switch option { - case .none: return "" - case .delta: return LAFormat.delta(snapshot) - case .projectedBG: return LAFormat.projected(snapshot) - case .minMax: return LAFormat.minMax(snapshot) - case .iob: return LAFormat.iob(snapshot) - case .cob: return LAFormat.cob(snapshot) - case .recBolus: return LAFormat.recBolus(snapshot) - case .autosens: return LAFormat.autosens(snapshot) - case .tdd: return LAFormat.tdd(snapshot) - case .basal: return LAFormat.basal(snapshot) - case .pump: return LAFormat.pump(snapshot) - case .pumpBattery: return LAFormat.pumpBattery(snapshot) - case .battery: return LAFormat.battery(snapshot) - case .target: return LAFormat.target(snapshot) - case .isf: return LAFormat.isf(snapshot) - case .carbRatio: return LAFormat.carbRatio(snapshot) - case .sage: return LAFormat.age(insertTime: snapshot.sageInsertTime) - case .cage: return LAFormat.age(insertTime: snapshot.cageInsertTime) - case .iage: return LAFormat.age(insertTime: snapshot.iageInsertTime) - case .carbsToday: return LAFormat.carbsToday(snapshot) - case .override: return LAFormat.override(snapshot) - case .profile: return LAFormat.profileName(snapshot) + case .none: "" + case .delta: LAFormat.delta(snapshot) + case .projectedBG: LAFormat.projected(snapshot) + case .minMax: LAFormat.minMax(snapshot) + case .iob: LAFormat.iob(snapshot) + case .cob: LAFormat.cob(snapshot) + case .recBolus: LAFormat.recBolus(snapshot) + case .autosens: LAFormat.autosens(snapshot) + case .tdd: LAFormat.tdd(snapshot) + case .basal: LAFormat.basal(snapshot) + case .pump: LAFormat.pump(snapshot) + case .pumpBattery: LAFormat.pumpBattery(snapshot) + case .battery: LAFormat.battery(snapshot) + case .target: LAFormat.target(snapshot) + case .isf: LAFormat.isf(snapshot) + case .carbRatio: LAFormat.carbRatio(snapshot) + case .sage: LAFormat.age(insertTime: snapshot.sageInsertTime) + case .cage: LAFormat.age(insertTime: snapshot.cageInsertTime) + case .iage: LAFormat.age(insertTime: snapshot.iageInsertTime) + case .carbsToday: LAFormat.carbsToday(snapshot) + case .override: LAFormat.override(snapshot) + case .profile: LAFormat.profileName(snapshot) } } } @@ -515,14 +514,14 @@ private enum LAFormat { static func trendArrow(_ s: GlucoseSnapshot) -> String { switch s.trend { - case .upFast: return "↑↑" - case .up: return "↑" - case .upSlight: return "↗" - case .flat: return "→" - case .downSlight: return "↘︎" - case .down: return "↓" - case .downFast: return "↓↓" - case .unknown: return "–" + case .upFast: "↑↑" + case .up: "↑" + case .upSlight: "↗" + case .flat: "→" + case .downSlight: "↘︎" + case .down: "↓" + case .downFast: "↓↓" + case .unknown: "–" } } From 84e1736181fd253598e5b24e28e2e1eb6372cdba Mon Sep 17 00:00:00 2001 From: Phil A <76601115+MtlPhil@users.noreply.github.com> Date: Tue, 24 Mar 2026 09:08:42 -0400 Subject: [PATCH 76/82] Live Activity: CarPlay/Watch Smart Stack widget + BFU crash fix + BGAppRefreshTask (#574) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Update BackgroundTaskAudio.swift * Update GlucoseLiveActivityAttributes.swift * Update GlucoseLiveActivityAttributes.swift * Restore explanatory comment for 0.5s audio restart delay * Add BGAppRefreshTask support for silent audio recovery Registers com.loopfollow.audiorefresh with BGTaskScheduler so iOS can wake the app every ~15 min to check if the silent audio session is still alive and restart it if not. Co-Authored-By: Claude Sonnet 4.6 * Fix BGAppRefreshTask: add fetch background mode, fix duplicate observer - Add 'fetch' to UIBackgroundModes so BGTaskScheduler.submit() doesn't throw notPermitted on every background transition - Call stopBackgroundTask() before startBackgroundTask() in the refresh handler to prevent accumulating duplicate AVAudioSession observers Co-Authored-By: Claude Sonnet 4.6 * Fix duplicate audio observer; add restart confirmation log - startBackgroundTask() now removes the old observer before adding, making it idempotent and preventing duplicate interrupt callbacks - Add 'audio restart initiated' log after restart so success is visible without debug mode - Temporarily make 'Silent audio playing' log always visible for testing Co-Authored-By: Claude Sonnet 4.6 * Delete LiveActivitySlotConfig.swift Forgotten stub. * Update GlucoseSnapshotBuilder.swift * Update StorageCurrentGlucoseStateProvider.swift * Update LiveActivityManager.swift * Update GlucoseSnapshot.swift * Update GlucoseSnapshot.swift * Update LiveActivityManager.swift * Update LiveActivityManager.swift * Update GlucoseLiveActivityAttributes.swift * Update LiveActivityManager.swift * Add LA expiry notification; fix OS-dismissed vs user-dismissed - When renewIfNeeded fails in the background (app can't start a new LA because it's not visible), schedule a local notification on the first failure: "Live Activity Expiring — Open LoopFollow to restart." Subsequent failures in the same cycle are suppressed. Notification is cancelled if renewal later succeeds or forceRestart is called. - In attachStateObserver, distinguish iOS force-dismiss (laRenewalFailed == true) from user swipe (laRenewalFailed == false). OS-dismissed LAs no longer set dismissedByUser, so opening the app triggers auto-restart as expected. Co-Authored-By: Claude Sonnet 4.6 * Remove dead pendingLATapNavigation code Force-quitting an app kills its Live Activities, so cold-launch via LA tap only occurs when iOS terminates the app — in which case scene(_:openURLContexts:) already handles navigation correctly via DispatchQueue.main.async. The flag was never set and never needed. Co-Authored-By: Claude Sonnet 4.6 * Code quality pass: log categories, SwiftFormat, dead code cleanup - BackgroundRefreshManager: all logs → .taskScheduler - AppDelegate: APNs registration/notification logs → .apns - APNSClient: all logs → .apns - BackgroundTaskAudio: restore isDebug:true on silent audio log; fix double blank line - LiveActivityManager: fix trailing whitespace; remove double blank line; SwiftFormat - GlucoseSnapshotBuilder: fix file header (date → standard LoopFollow header) - LoopFollowLiveActivity: remove dead commented-out activityID property - SwiftFormat applied across all reviewed LiveActivity/, Storage/, extension files Co-Authored-By: Claude Sonnet 4.6 * Round prediction value before Int conversion Prevents truncation toward zero (e.g. 179.9 → 179); now correctly rounds to nearest integer. Co-Authored-By: Claude Sonnet 4.6 * Fix double setTaskCompleted race; fix renewal deadline write ordering BackgroundRefreshManager: guard against double setTaskCompleted if the expiration handler fires while the main-queue block is in-flight. Apple documents calling setTaskCompleted more than once as a programming error. LiveActivityManager.renewIfNeeded: write laRenewBy to Storage only after Activity.request succeeds, eliminating the narrow window where a crash between the write and the request could leave the deadline permanently stuck in the future. No rollback needed on failure. The fresh snapshot is built via withRenewalOverlay(false) directly rather than re-running the builder, since the caller already has a current snapshot. Co-Authored-By: Claude Sonnet 4.6 * Scope all identifiers to bundle ID for multi-instance support Derive BGTask IDs, notification IDs, URL schemes, and notification categories from Bundle.main.bundleIdentifier so that LoopFollow, LoopFollow_Second, and LoopFollow_Third each get isolated identifiers and don't interfere with each other's background tasks, notifications, or Live Activities. Also show the configured display name in the Live Activity footer (next to the update time) when the existing "Show Display Name" toggle is enabled, so users can identify which instance a LA belongs to. * Linting * Add migration step 7: cancel legacy notification identifiers Users upgrading from the old hardcoded identifiers would have orphaned pending notifications that the new bundle-ID-scoped code can't cancel. This one-time migration cleans them up on first launch. * Increase LA refresh debounce from 5s to 20s to coalesce double push The `bg` and `loopingResumed` refresh triggers fire ~10s apart. With a 5s debounce, `loopingResumed` arrives after the debounce has already executed, causing two APNs pushes per BG cycle instead of one. Widening the window to 20s ensures both events are coalesced into a single push containing the most up-to-date post-loop-cycle state (fresh IOB, predicted BG, etc.). Co-Authored-By: Claude Sonnet 4.6 * Guard migrations against background launch to prevent BFU settings wipe When BGAppRefreshTask fires after a reboot (before the user has unlocked the device), UserDefaults files are still encrypted (Before First Unlock state). Reading migrationStep returns 0, causing all migrations to re-run. migrateStep1 reads old_url from the also-locked App Group suite, gets "", and writes "" to url — wiping Nightscout and other settings. Fix: skip the entire migration block when the app is in background state. Migrations will run correctly on the next foreground open. This is safe since no migration is time-critical and all steps are guarded by version checks. Co-Authored-By: Claude Sonnet 4.6 * Fix BFU migration guard: wrap only migrations, not all of viewDidLoad The previous fix used guard+return which skipped the entire viewDidLoad when the app launched in background (BGAppRefreshTask). viewDidLoad only runs once per VC lifecycle, so the UI was never initialized when the user later foregrounded the app — causing a blank screen. Fix: wrap only the migration block in an if-check, so UI setup always runs. Migrations are still skipped in background to avoid BFU corruption. Co-Authored-By: Claude Sonnet 4.6 * Defer migrations to first foreground after BFU background launch runMigrationsIfNeeded() extracts the migration block and is called from both viewDidLoad (normal launch) and appCameToForeground() (deferred case). The guard skips execution when applicationState == .background to prevent BFU corruption, and appCameToForeground() picks up any deferred migrations the first time the user unlocks after a reboot. The previous fix (wrapping migrations in an if-block inside viewDidLoad) correctly prevented BFU corruption but left migrations permanently unrun after a background cold-start, causing the app to behave as a fresh install and prompt for Nightscout/Dexcom setup. Co-Authored-By: Claude Sonnet 4.6 * Use didBecomeActive (not willEnterForeground) for deferred migration recovery willEnterForegroundNotification fires while applicationState may still be .background, causing the BFU guard in runMigrationsIfNeeded() to skip migrations a second time. didBecomeActiveNotification guarantees applicationState == .active, so the guard always passes. Adds a dedicated appDidBecomeActive() handler that only calls runMigrationsIfNeeded(). Since that function is idempotent (each step checks migrationStep.value < N), calling it on every activation after migrations have already completed is a fast no-op. Co-Authored-By: Claude Sonnet 4.6 * Remove BGAppRefreshTask completely BGAppRefreshTask caused iOS to cold-launch the app in the background after a reboot. In Before-First-Unlock state, UserDefaults is encrypted and all reads return defaults, causing migrations to re-run and wipe settings (Nightscout URL, etc.). Multiple fix attempts could not reliably guard against this without risking the UI never initialising. Removed entirely: - BackgroundRefreshManager.swift (deleted) - AppDelegate: BackgroundRefreshManager.shared.register() - MainViewController: BackgroundRefreshManager.shared.scheduleRefresh() and all migration-guard code added to work around the BFU issue - Info.plist: com.loopfollow.audiorefresh BGTaskSchedulerPermittedIdentifier - Info.plist: fetch UIBackgroundMode - project.pbxproj: all four BackgroundRefreshManager.swift references Migrations restored to their original unconditional form in viewDidLoad. Co-Authored-By: Claude Sonnet 4.6 * Revert "Remove BGAppRefreshTask completely" This reverts commit 7e6b19135ba8079314683cc6f2b6d64707375b45. * Guard migrateStep1 core fields against BFU empty reads The four primary fields (url, device, nsWriteAuth, nsAdminAuth) were unconditionally copied from the App Group suite to UserDefaults.standard with no .exists check — unlike every other field in the same function. When the app launches in the background (remote-notification mode) while the device is in Before-First-Unlock state, the App Group UserDefaults file is encrypted and unreadable. object(forKey:) returns nil, .exists returns false, and .value returns the default ("" / false). Without the guard, "" was written to url in Standard UserDefaults and flushed to disk on first unlock, wiping the Nightscout URL. Adding .exists checks matches the pattern used by all helper migrations in the same function. A fresh install correctly skips (nothing to migrate). An existing user correctly copies (old key still present in App Group since migrateStep1 never removes it). BFU state correctly skips (App Group unreadable, Standard value preserved). Co-Authored-By: Claude Sonnet 4.6 * Fix reboot settings wipe: reload StorageValues on foreground after BFU launch BGAppRefreshTask cold-launches the app while the device is locked (BFU), causing StorageValue to cache empty defaults from encrypted UserDefaults. The scene connects during that background launch, so viewDidLoad does not run again when the user foregrounds — leaving url="" in the @Published cache and the setup screen showing despite correct data on disk. Fix: add StorageValue.reload() (re-reads disk, fires @Published only if changed) and call it for url/shareUserName/sharePassword at the top of appCameToForeground(), correcting the stale cache the first time the user opens the app after a reboot. Co-Authored-By: Claude Sonnet 4.6 * Reload all Nightscout credentials on foreground, not just url/share fields token, sharedSecret, nsWriteAuth, nsAdminAuth would all be stale after a BFU background launch — Nightscout API calls would fail or use wrong auth even if the setup screen was correctly dismissed by the url reload. Co-Authored-By: Claude Sonnet 4.6 * Gate BFU reload behind isProtectedDataAvailable flag; reload all StorageValues Instead of calling individual reloads on every foreground (noisy, unnecessary disk reads, cascade of observers on normal launches), capture whether protected data was unavailable at launch time. On the first foreground after a BFU launch, call Storage.reloadAll() — which reloads every StorageValue, firing @Published only where the cached value actually changed. Normal foregrounds are unaffected. Co-Authored-By: Claude Sonnet 4.6 * Add BFU diagnostic logs to AppDelegate and appCameToForeground Co-Authored-By: Claude Sonnet 4.6 * Reschedule all tasks after BFU reload to fix blank charts on first foreground During BFU viewDidLoad, all tasks fire with url="" and reschedule 60s out. checkTasksNow() on first foreground finds nothing overdue. Fix: call scheduleAllTasks() after reloadAll() so tasks reset to their normal 2-5s initial delay, displacing the stale 60s BFU schedule. Co-Authored-By: Claude Sonnet 4.6 * Show loading overlay during BFU data reload instead of blank charts After BFU reloadAll(), viewDidLoad left isInitialLoad=false and no overlay. Reset loading state and show the overlay so the user sees the same spinner they see on a normal cold launch, rather than blank charts for 2-5 seconds. The overlay auto-hides via the normal markLoaded() path when data arrives. Co-Authored-By: Claude Sonnet 4.6 * Redesign CarPlay SmallFamilyView to match Loop's LA layout Two-column layout: BG + trend arrow + delta/unit on the left (colored by glucose threshold), projected BG + unit label on the right in white. Dynamic Island and lock screen views are unchanged. Co-Authored-By: Claude Sonnet 4.6 * Fix CarPlay: bypass activityFamily detection in supplemental widget LoopFollowLiveActivityWidgetWithCarPlay is declared with .supplementalActivityFamilies([.small]) so it is only ever rendered in .small contexts (CarPlay, Watch Smart Stack). Use SmallFamilyView directly instead of routing through LockScreenFamilyAdaptiveView, which was falling through to LockScreenLiveActivityView when activityFamily wasn't detected as .small. Co-Authored-By: Claude Sonnet 4.6 * Fix Watch/CarPlay: register only one widget per iOS version band Two ActivityConfiguration widgets for the same attributes type were registered simultaneously. The system used the primary widget for all contexts, ignoring the supplemental one. On iOS 18+: register only LoopFollowLiveActivityWidgetWithCarPlay (with .supplementalActivityFamilies([.small]) and family-adaptive routing via LockScreenFamilyAdaptiveView for all contexts). On iOS <18: register only LoopFollowLiveActivityWidget (lock screen and Dynamic Island only). Co-Authored-By: Claude Sonnet 4.6 * Fix build error and harden Watch/CarPlay routing - Revert bundle to if #available without else (WidgetBundleBuilder does not support if/else with #available) - Make primary widget also use LockScreenFamilyAdaptiveView on iOS 18+ so SmallFamilyView renders correctly regardless of which widget the system selects for .small contexts (CarPlay / Watch Smart Stack) Co-Authored-By: Claude Sonnet 4.6 * Watch & CarPlay widget * Update LoopFollowLiveActivity.swift * Update LoopFollowLiveActivity.swift * Update LoopFollowLABundle.swift * Update LoopFollowLABundle.swift * Update LoopFollowLABundle.swift * Update LoopFollowLiveActivity.swift * Update LoopFollowLABundle.swift * Update LoopFollowLiveActivity.swift * Update LoopFollowLiveActivity.swift * Update LoopFollowLiveActivity.swift * Remove docs/ directory from PR scope The LiveActivity.md doc file should not be part of this PR. https://claude.ai/code/session_01WaUhT8PoPNKumX9ZK9jeBy --------- Co-authored-by: Claude Sonnet 4.6 Co-authored-by: Jonas Björkert --- LoopFollow/Application/AppDelegate.swift | 12 +- LoopFollow/Application/SceneDelegate.swift | 2 +- .../Controllers/BackgroundAlertManager.swift | 25 ++- .../Helpers/BackgroundRefreshManager.swift | 2 +- LoopFollow/Info.plist | 4 +- LoopFollow/LiveActivity/AppGroupID.swift | 26 ++- .../LiveActivity/LAAppGroupSettings.swift | 17 ++ .../LiveActivity/LiveActivityManager.swift | 12 +- .../RestartLiveActivityIntent.swift | 2 +- .../StorageCurrentGlucoseStateProvider.swift | 3 +- .../Storage/Framework/StorageValue.swift | 12 ++ LoopFollow/Storage/Storage+Migrate.swift | 35 +++- LoopFollow/Storage/Storage.swift | 185 ++++++++++++++++++ .../ViewControllers/MainViewController.swift | 114 +++++++---- .../LoopFollowLABundle.swift | 8 +- .../LoopFollowLiveActivity.swift | 177 +++++++++-------- docs/LiveActivity.md | 165 ---------------- 17 files changed, 481 insertions(+), 320 deletions(-) delete mode 100644 docs/LiveActivity.md diff --git a/LoopFollow/Application/AppDelegate.swift b/LoopFollow/Application/AppDelegate.swift index d79de7d18..a6fd9f2b9 100644 --- a/LoopFollow/Application/AppDelegate.swift +++ b/LoopFollow/Application/AppDelegate.swift @@ -32,7 +32,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { } let action = UNNotificationAction(identifier: "OPEN_APP_ACTION", title: "Open App", options: .foreground) - let category = UNNotificationCategory(identifier: "loopfollow.background.alert", actions: [action], intentIdentifiers: [], options: []) + let category = UNNotificationCategory(identifier: BackgroundAlertIdentifier.categoryIdentifier, actions: [action], intentIdentifiers: [], options: []) UNUserNotificationCenter.current().setNotificationCategories([category]) UNUserNotificationCenter.current().delegate = self @@ -47,6 +47,14 @@ class AppDelegate: UIResponder, UIApplicationDelegate { } BackgroundRefreshManager.shared.register() + + // Detect Before-First-Unlock launch. If protected data is unavailable here, + // StorageValues were cached from encrypted UserDefaults and need a reload + // on the first foreground after the user unlocks. + let bfu = !UIApplication.shared.isProtectedDataAvailable + Storage.shared.needsBFUReload = bfu + LogManager.shared.log(category: .general, message: "BFU check: isProtectedDataAvailable=\(!bfu), needsBFUReload=\(bfu)") + return true } @@ -107,7 +115,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { // Note: with scene-based lifecycle (iOS 13+), URLs are delivered to // SceneDelegate.scene(_:openURLContexts:) — not here. The scene delegate - // handles loopfollow://la-tap for Live Activity tap navigation. + // handles ://la-tap for Live Activity tap navigation. // MARK: UISceneSession Lifecycle diff --git a/LoopFollow/Application/SceneDelegate.swift b/LoopFollow/Application/SceneDelegate.swift index 3819a7ac6..e702db267 100644 --- a/LoopFollow/Application/SceneDelegate.swift +++ b/LoopFollow/Application/SceneDelegate.swift @@ -35,7 +35,7 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { } func scene(_: UIScene, openURLContexts URLContexts: Set) { - guard URLContexts.contains(where: { $0.url.scheme == "loopfollow" && $0.url.host == "la-tap" }) else { return } + guard URLContexts.contains(where: { $0.url.scheme == AppGroupID.urlScheme && $0.url.host == "la-tap" }) else { return } // scene(_:openURLContexts:) fires after sceneDidBecomeActive when the app // foregrounds from background. Post on the next run loop so the view // hierarchy (including any presented modals) is fully settled. diff --git a/LoopFollow/Controllers/BackgroundAlertManager.swift b/LoopFollow/Controllers/BackgroundAlertManager.swift index d8a80b8f1..0ba3664b1 100644 --- a/LoopFollow/Controllers/BackgroundAlertManager.swift +++ b/LoopFollow/Controllers/BackgroundAlertManager.swift @@ -11,11 +11,24 @@ enum BackgroundAlertDuration: TimeInterval, CaseIterable { case eighteenMinutes = 1080 // 18 minutes in seconds } -/// Enum representing unique identifiers for each background alert. -enum BackgroundAlertIdentifier: String, CaseIterable { - case sixMin = "loopfollow.background.alert.6min" - case twelveMin = "loopfollow.background.alert.12min" - case eighteenMin = "loopfollow.background.alert.18min" +/// Unique identifiers for each background alert, scoped to the current bundle +/// so multiple LoopFollow instances don't interfere with each other's notifications. +enum BackgroundAlertIdentifier: CaseIterable { + case sixMin + case twelveMin + case eighteenMin + + private static let prefix = Bundle.main.bundleIdentifier ?? "loopfollow" + + var rawValue: String { + switch self { + case .sixMin: "\(Self.prefix).background.alert.6min" + case .twelveMin: "\(Self.prefix).background.alert.12min" + case .eighteenMin: "\(Self.prefix).background.alert.18min" + } + } + + static let categoryIdentifier = "\(prefix).background.alert" } class BackgroundAlertManager { @@ -118,7 +131,7 @@ class BackgroundAlertManager { content.title = title content.body = body content.sound = .defaultCritical - content.categoryIdentifier = "loopfollow.background.alert" + content.categoryIdentifier = BackgroundAlertIdentifier.categoryIdentifier return content } diff --git a/LoopFollow/Helpers/BackgroundRefreshManager.swift b/LoopFollow/Helpers/BackgroundRefreshManager.swift index bac7e1c8e..a1168174d 100644 --- a/LoopFollow/Helpers/BackgroundRefreshManager.swift +++ b/LoopFollow/Helpers/BackgroundRefreshManager.swift @@ -8,7 +8,7 @@ class BackgroundRefreshManager { static let shared = BackgroundRefreshManager() private init() {} - private let taskIdentifier = "com.loopfollow.audiorefresh" + private let taskIdentifier = "\(Bundle.main.bundleIdentifier ?? "com.loopfollow").audiorefresh" func register() { BGTaskScheduler.shared.register(forTaskWithIdentifier: taskIdentifier, using: nil) { task in diff --git a/LoopFollow/Info.plist b/LoopFollow/Info.plist index 5cc7f4146..9e0f99340 100644 --- a/LoopFollow/Info.plist +++ b/LoopFollow/Info.plist @@ -7,7 +7,7 @@ BGTaskSchedulerPermittedIdentifiers com.$(unique_id).LoopFollow$(app_suffix) - com.loopfollow.audiorefresh + com.$(unique_id).LoopFollow$(app_suffix).audiorefresh CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) @@ -34,7 +34,7 @@ CFBundleURLSchemes - loopfollow + loopfollow$(app_suffix) diff --git a/LoopFollow/LiveActivity/AppGroupID.swift b/LoopFollow/LiveActivity/AppGroupID.swift index 6fc2bb9a6..5eb1187b8 100644 --- a/LoopFollow/LiveActivity/AppGroupID.swift +++ b/LoopFollow/LiveActivity/AppGroupID.swift @@ -20,19 +20,33 @@ enum AppGroupID { /// to force a shared base bundle id (recommended for reliability). private static let baseBundleIDPlistKey = "LFAppGroupBaseBundleID" - static func current() -> String { + /// The base bundle identifier for the main app, with extension suffixes stripped. + /// Usable from both the main app and extensions. + static var baseBundleID: String { if let base = Bundle.main.object(forInfoDictionaryKey: baseBundleIDPlistKey) as? String, !base.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - return "group.\(base)" + return base } - let bundleID = Bundle.main.bundleIdentifier ?? "unknown" + return stripLikelyExtensionSuffixes(from: bundleID) + } - // Heuristic: strip common extension suffixes so the extension can land on the main app’s group id. - let base = stripLikelyExtensionSuffixes(from: bundleID) + /// URL scheme derived from the bundle identifier. Works across app and extensions. + /// Default build: "loopfollow", second: "loopfollow2", third: "loopfollow3", etc. + static var urlScheme: String { + let base = baseBundleID + // Extract the suffix after "LoopFollow" in the bundle ID + // e.g. "com.TEAM.LoopFollow2" → "2", "com.TEAM.LoopFollow" → "" + if let range = base.range(of: "LoopFollow", options: .backwards) { + let suffix = base[range.upperBound...] + return "loopfollow\(suffix)" + } + return "loopfollow" + } - return "group.\(base)" + static func current() -> String { + "group.\(baseBundleID)" } private static func stripLikelyExtensionSuffixes(from bundleID: String) -> String { diff --git a/LoopFollow/LiveActivity/LAAppGroupSettings.swift b/LoopFollow/LiveActivity/LAAppGroupSettings.swift index 8fedeb155..b61487f27 100644 --- a/LoopFollow/LiveActivity/LAAppGroupSettings.swift +++ b/LoopFollow/LiveActivity/LAAppGroupSettings.swift @@ -135,6 +135,8 @@ enum LAAppGroupSettings { static let lowLineMgdl = "la.lowLine.mgdl" static let highLineMgdl = "la.highLine.mgdl" static let slots = "la.slots" + static let displayName = "la.displayName" + static let showDisplayName = "la.showDisplayName" } private static var defaults: UserDefaults? { @@ -176,4 +178,19 @@ enum LAAppGroupSettings { } return raw.map { LiveActivitySlotOption(rawValue: $0) ?? .none } } + + // MARK: - Display Name + + static func setDisplayName(_ name: String, show: Bool) { + defaults?.set(name, forKey: Keys.displayName) + defaults?.set(show, forKey: Keys.showDisplayName) + } + + static func displayName() -> String { + defaults?.string(forKey: Keys.displayName) ?? "LoopFollow" + } + + static func showDisplayName() -> Bool { + defaults?.bool(forKey: Keys.showDisplayName) ?? false + } } diff --git a/LoopFollow/LiveActivity/LiveActivityManager.swift b/LoopFollow/LiveActivity/LiveActivityManager.swift index 9faa8a41e..746e5609d 100644 --- a/LoopFollow/LiveActivity/LiveActivityManager.swift +++ b/LoopFollow/LiveActivity/LiveActivityManager.swift @@ -310,6 +310,10 @@ final class LiveActivityManager { lowMgdl: Storage.shared.lowLine.value, highMgdl: Storage.shared.highLine.value, ) + LAAppGroupSettings.setDisplayName( + Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String ?? "LoopFollow", + show: Storage.shared.showDisplayName.value + ) GlucoseSnapshotStore.shared.save(snapshot) } startIfNeeded() @@ -322,7 +326,7 @@ final class LiveActivityManager { self?.performRefresh(reason: reason) } refreshWorkItem = workItem - DispatchQueue.main.asyncAfter(deadline: .now() + 5.0, execute: workItem) + DispatchQueue.main.asyncAfter(deadline: .now() + 20.0, execute: workItem) } // MARK: - Renewal @@ -557,6 +561,8 @@ final class LiveActivityManager { // MARK: - Renewal Notifications + private static let renewalNotificationID = "\(Bundle.main.bundleIdentifier ?? "loopfollow").la.renewal.failed" + private func scheduleRenewalFailedNotification() { let content = UNMutableNotificationContent() content.title = "Live Activity Expiring" @@ -564,7 +570,7 @@ final class LiveActivityManager { content.sound = .default let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 1, repeats: false) let request = UNNotificationRequest( - identifier: "loopfollow.la.renewal.failed", + identifier: LiveActivityManager.renewalNotificationID, content: content, trigger: trigger, ) @@ -577,7 +583,7 @@ final class LiveActivityManager { } private func cancelRenewalFailedNotification() { - let id = "loopfollow.la.renewal.failed" + let id = LiveActivityManager.renewalNotificationID UNUserNotificationCenter.current().removePendingNotificationRequests(withIdentifiers: [id]) UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: [id]) } diff --git a/LoopFollow/LiveActivity/RestartLiveActivityIntent.swift b/LoopFollow/LiveActivity/RestartLiveActivityIntent.swift index cb1f84d18..00740e10e 100644 --- a/LoopFollow/LiveActivity/RestartLiveActivityIntent.swift +++ b/LoopFollow/LiveActivity/RestartLiveActivityIntent.swift @@ -15,7 +15,7 @@ struct RestartLiveActivityIntent: AppIntent { let apnsKey = Storage.shared.lfApnsKey.value if keyId.isEmpty || apnsKey.isEmpty { - if let url = URL(string: "loopfollow://settings/live-activity") { + if let url = URL(string: "\(AppGroupID.urlScheme)://settings/live-activity") { await MainActor.run { UIApplication.shared.open(url) } } return .result(dialog: "Please enter your APNs credentials in LoopFollow settings to use the Live Activity.") diff --git a/LoopFollow/LiveActivity/StorageCurrentGlucoseStateProvider.swift b/LoopFollow/LiveActivity/StorageCurrentGlucoseStateProvider.swift index 90e74f5b8..b1a416b97 100644 --- a/LoopFollow/LiveActivity/StorageCurrentGlucoseStateProvider.swift +++ b/LoopFollow/LiveActivity/StorageCurrentGlucoseStateProvider.swift @@ -1,5 +1,5 @@ +// LoopFollow // StorageCurrentGlucoseStateProvider.swift -// 2026-03-21 import Foundation @@ -7,7 +7,6 @@ import Foundation /// This is the only file in the pipeline that is allowed to touch Storage.shared /// or Observable.shared — all other layers read exclusively from this provider. struct StorageCurrentGlucoseStateProvider: CurrentGlucoseStateProviding { - // MARK: - Core Glucose var glucoseMgdl: Double? { diff --git a/LoopFollow/Storage/Framework/StorageValue.swift b/LoopFollow/Storage/Framework/StorageValue.swift index f27f49b17..86faa34da 100644 --- a/LoopFollow/Storage/Framework/StorageValue.swift +++ b/LoopFollow/Storage/Framework/StorageValue.swift @@ -40,4 +40,16 @@ class StorageValue: ObservableObject { func remove() { StorageValue.defaults.removeObject(forKey: key) } + + /// Re-reads the value from UserDefaults, updating the @Published cache. + /// Call this when the app foregrounds after a Before-First-Unlock background launch, + /// where StorageValue was initialized while UserDefaults was locked (returning defaults). + func reload() { + if let data = StorageValue.defaults.data(forKey: key), + let decodedValue = try? JSONDecoder().decode(T.self, from: data), + decodedValue != value + { + value = decodedValue + } + } } diff --git a/LoopFollow/Storage/Storage+Migrate.swift b/LoopFollow/Storage/Storage+Migrate.swift index aa0868543..70cd30dc1 100644 --- a/LoopFollow/Storage/Storage+Migrate.swift +++ b/LoopFollow/Storage/Storage+Migrate.swift @@ -2,6 +2,7 @@ // Storage+Migrate.swift import Foundation +import UserNotifications extension Storage { func migrateStep5() { @@ -32,6 +33,21 @@ extension Storage { } } + func migrateStep7() { + // Cancel notifications scheduled with old hardcoded identifiers. + // Replaced with bundle-ID-scoped identifiers for multi-instance support. + LogManager.shared.log(category: .general, message: "Running migrateStep7 — cancel legacy notification identifiers") + + let legacyNotificationIDs = [ + "loopfollow.background.alert.6min", + "loopfollow.background.alert.12min", + "loopfollow.background.alert.18min", + "loopfollow.la.renewal.failed", + ] + UNUserNotificationCenter.current().removePendingNotificationRequests(withIdentifiers: legacyNotificationIDs) + UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: legacyNotificationIDs) + } + func migrateStep6() { // APNs credential separation LogManager.shared.log(category: .general, message: "Running migrateStep6 — APNs credential separation") @@ -125,10 +141,21 @@ extension Storage { } func migrateStep1() { - Storage.shared.url.value = ObservableUserDefaults.shared.old_url.value - Storage.shared.device.value = ObservableUserDefaults.shared.old_device.value - Storage.shared.nsWriteAuth.value = ObservableUserDefaults.shared.old_nsWriteAuth.value - Storage.shared.nsAdminAuth.value = ObservableUserDefaults.shared.old_nsAdminAuth.value + // Guard each field with .exists so that if the App Group suite is unreadable + // (e.g. Before-First-Unlock state after a reboot), we skip the write rather + // than overwriting the already-migrated Standard value with an empty default. + if ObservableUserDefaults.shared.old_url.exists { + Storage.shared.url.value = ObservableUserDefaults.shared.old_url.value + } + if ObservableUserDefaults.shared.old_device.exists { + Storage.shared.device.value = ObservableUserDefaults.shared.old_device.value + } + if ObservableUserDefaults.shared.old_nsWriteAuth.exists { + Storage.shared.nsWriteAuth.value = ObservableUserDefaults.shared.old_nsWriteAuth.value + } + if ObservableUserDefaults.shared.old_nsAdminAuth.exists { + Storage.shared.nsAdminAuth.value = ObservableUserDefaults.shared.old_nsAdminAuth.value + } // Helper: 1-to-1 type ----------------------------------------------------------------- func move( diff --git a/LoopFollow/Storage/Storage.swift b/LoopFollow/Storage/Storage.swift index efb55b031..6e24b3788 100644 --- a/LoopFollow/Storage/Storage.swift +++ b/LoopFollow/Storage/Storage.swift @@ -216,6 +216,191 @@ class Storage { static let shared = Storage() private init() {} + /// Set to true at launch if isProtectedDataAvailable was false (BFU state). + /// Consumed and cleared on the first foreground after that launch. + var needsBFUReload = false + + /// Re-reads every StorageValue from UserDefaults, firing @Published only where the value + /// actually changed. Call this when foregrounding after a Before-First-Unlock (BFU) background + /// launch, where Storage was initialized while UserDefaults was encrypted and all values were + /// cached as their defaults. + /// + /// `migrationStep` is intentionally excluded: viewDidLoad writes it to 6 during the BFU + /// launch; if we reloaded it and the flush had somehow not landed yet, migrations would re-run. + /// + /// SecureStorageValue properties (maxBolus, maxCarbs, maxProtein, maxFat, bolusIncrement) are + /// not covered here — SecureStorageValue does not implement reload() and Keychain has the same + /// BFU inaccessibility; that is a separate problem. + func reloadAll() { + remoteType.reload() + deviceToken.reload() + expirationDate.reload() + sharedSecret.reload() + productionEnvironment.reload() + remoteApnsKey.reload() + teamId.reload() + remoteKeyId.reload() + + lfApnsKey.reload() + lfKeyId.reload() + bundleId.reload() + user.reload() + + mealWithBolus.reload() + mealWithFatProtein.reload() + hasSeenFatProteinOrderChange.reload() + + backgroundRefreshType.reload() + selectedBLEDevice.reload() + debugLogLevel.reload() + + contactTrend.reload() + contactDelta.reload() + contactEnabled.reload() + contactBackgroundColor.reload() + contactTextColor.reload() + + sensorScheduleOffset.reload() + alarms.reload() + alarmConfiguration.reload() + + lastOverrideStartNotified.reload() + lastOverrideEndNotified.reload() + lastTempTargetStartNotified.reload() + lastTempTargetEndNotified.reload() + lastRecBolusNotified.reload() + lastCOBNotified.reload() + lastMissedBolusNotified.reload() + + appBadge.reload() + colorBGText.reload() + appearanceMode.reload() + showStats.reload() + useIFCC.reload() + showSmallGraph.reload() + screenlockSwitchState.reload() + showDisplayName.reload() + snoozerEmoji.reload() + forcePortraitMode.reload() + + speakBG.reload() + speakBGAlways.reload() + speakLowBG.reload() + speakProactiveLowBG.reload() + speakFastDropDelta.reload() + speakLowBGLimit.reload() + speakHighBGLimit.reload() + speakHighBG.reload() + speakLanguage.reload() + + lastBgReadingTimeSeconds.reload() + lastDeltaMgdl.reload() + lastTrendCode.reload() + lastIOB.reload() + lastCOB.reload() + projectedBgMgdl.reload() + + lastBasal.reload() + lastPumpReservoirU.reload() + lastAutosens.reload() + lastTdd.reload() + lastTargetLowMgdl.reload() + lastTargetHighMgdl.reload() + lastIsfMgdlPerU.reload() + lastCarbRatio.reload() + lastCarbsToday.reload() + lastProfileName.reload() + iageInsertTime.reload() + lastMinBgMgdl.reload() + lastMaxBgMgdl.reload() + + laEnabled.reload() + laRenewBy.reload() + laRenewalFailed.reload() + + showDots.reload() + showLines.reload() + showValues.reload() + showAbsorption.reload() + showDIALines.reload() + show30MinLine.reload() + show90MinLine.reload() + showMidnightLines.reload() + smallGraphTreatments.reload() + smallGraphHeight.reload() + predictionToLoad.reload() + minBasalScale.reload() + minBGScale.reload() + lowLine.reload() + highLine.reload() + downloadDays.reload() + graphTimeZoneEnabled.reload() + graphTimeZoneIdentifier.reload() + + writeCalendarEvent.reload() + calendarIdentifier.reload() + watchLine1.reload() + watchLine2.reload() + + shareUserName.reload() + sharePassword.reload() + shareServer.reload() + + chartScaleX.reload() + + downloadTreatments.reload() + downloadPrediction.reload() + graphOtherTreatments.reload() + graphBasal.reload() + graphBolus.reload() + graphCarbs.reload() + bgUpdateDelay.reload() + + cageInsertTime.reload() + sageInsertTime.reload() + + cachedForVersion.reload() + latestVersion.reload() + latestVersionChecked.reload() + currentVersionBlackListed.reload() + lastBlacklistNotificationShown.reload() + lastVersionUpdateNotificationShown.reload() + lastExpirationNotificationShown.reload() + + hideInfoTable.reload() + token.reload() + units.reload() + infoSort.reload() + infoVisible.reload() + + url.reload() + device.reload() + nsWriteAuth.reload() + nsAdminAuth.reload() + + // migrationStep intentionally excluded — see method comment above. + + persistentNotification.reload() + persistentNotificationLastBGTime.reload() + + lastLoopingChecked.reload() + lastBGChecked.reload() + + homePosition.reload() + alarmsPosition.reload() + snoozerPosition.reload() + nightscoutPosition.reload() + remotePosition.reload() + statisticsPosition.reload() + treatmentsPosition.reload() + + loopAPNSQrCodeURL.reload() + bolusIncrementDetected.reload() + showGMI.reload() + showStdDev.reload() + showTITR.reload() + } + // MARK: - Tab Position Helpers /// Get the position for a given tab item diff --git a/LoopFollow/ViewControllers/MainViewController.swift b/LoopFollow/ViewControllers/MainViewController.swift index bbf2de63c..1a3b7c03d 100644 --- a/LoopFollow/ViewControllers/MainViewController.swift +++ b/LoopFollow/ViewControllers/MainViewController.swift @@ -142,40 +142,12 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele loadDebugData() - // Capture before migrations run: true for existing users, false for fresh installs. - let isExistingUser = Storage.shared.migrationStep.exists - - if Storage.shared.migrationStep.value < 1 { - Storage.shared.migrateStep1() - Storage.shared.migrationStep.value = 1 - } - - if Storage.shared.migrationStep.value < 2 { - Storage.shared.migrateStep2() - Storage.shared.migrationStep.value = 2 - } - - if Storage.shared.migrationStep.value < 3 { - Storage.shared.migrateStep3() - Storage.shared.migrationStep.value = 3 - } - - // TODO: This migration step can be deleted in March 2027. Check the commit for other places to cleanup. - if Storage.shared.migrationStep.value < 4 { - // Existing users need to see the fat/protein order change banner. - // New users never saw the old order, so mark it as already seen. - Storage.shared.hasSeenFatProteinOrderChange.value = !isExistingUser - Storage.shared.migrationStep.value = 4 - } - - if Storage.shared.migrationStep.value < 5 { - Storage.shared.migrateStep5() - Storage.shared.migrationStep.value = 5 - } + // Migrations run in foreground only — see runMigrationsIfNeeded() for details. + runMigrationsIfNeeded() - if Storage.shared.migrationStep.value < 6 { - Storage.shared.migrateStep6() - Storage.shared.migrationStep.value = 6 + if Storage.shared.migrationStep.value < 7 { + Storage.shared.migrateStep7() + Storage.shared.migrationStep.value = 7 } // Synchronize info types to ensure arrays are the correct size @@ -211,6 +183,10 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele let notificationCenter = NotificationCenter.default notificationCenter.addObserver(self, selector: #selector(appMovedToBackground), name: UIApplication.didEnterBackgroundNotification, object: nil) notificationCenter.addObserver(self, selector: #selector(appCameToForeground), name: UIApplication.willEnterForegroundNotification, object: nil) + // didBecomeActive is used (not willEnterForeground) to ensure applicationState == .active + // when runMigrationsIfNeeded() is called. This catches migrations deferred by a + // background BGAppRefreshTask launch in Before-First-Unlock state. + notificationCenter.addObserver(self, selector: #selector(appDidBecomeActive), name: UIApplication.didBecomeActiveNotification, object: nil) notificationCenter.addObserver(self, selector: #selector(navigateOnLAForeground), name: .liveActivityDidForeground, object: nil) // Setup the Graph @@ -972,7 +948,79 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele } } + // Migrations must only run when UserDefaults is accessible (i.e. after first unlock). + // When the app is launched in the background by BGAppRefreshTask immediately after a + // reboot, the device may be in Before-First-Unlock (BFU) state: UserDefaults files are + // still encrypted, so every read returns the default value (0 / ""). Running migrations + // in that state would overwrite real settings with empty strings. + // + // Strategy: skip migrations if applicationState == .background; call this method again + // from appCameToForeground() so they run on the first foreground after a BFU launch. + func runMigrationsIfNeeded() { + guard UIApplication.shared.applicationState != .background else { return } + + // Capture before migrations run: true for existing users, false for fresh installs. + let isExistingUser = Storage.shared.migrationStep.exists + + if Storage.shared.migrationStep.value < 1 { + Storage.shared.migrateStep1() + Storage.shared.migrationStep.value = 1 + } + if Storage.shared.migrationStep.value < 2 { + Storage.shared.migrateStep2() + Storage.shared.migrationStep.value = 2 + } + if Storage.shared.migrationStep.value < 3 { + Storage.shared.migrateStep3() + Storage.shared.migrationStep.value = 3 + } + // TODO: This migration step can be deleted in March 2027. Check the commit for other places to cleanup. + if Storage.shared.migrationStep.value < 4 { + // Existing users need to see the fat/protein order change banner. + // New users never saw the old order, so mark it as already seen. + Storage.shared.hasSeenFatProteinOrderChange.value = !isExistingUser + Storage.shared.migrationStep.value = 4 + } + if Storage.shared.migrationStep.value < 5 { + Storage.shared.migrateStep5() + Storage.shared.migrationStep.value = 5 + } + if Storage.shared.migrationStep.value < 6 { + Storage.shared.migrateStep6() + Storage.shared.migrationStep.value = 6 + } + } + + @objc func appDidBecomeActive() { + // applicationState == .active is guaranteed here, so the BFU guard in + // runMigrationsIfNeeded() will always pass. Catches the case where viewDidLoad + // ran during a BGAppRefreshTask background launch and deferred migrations. + runMigrationsIfNeeded() + } + @objc func appCameToForeground() { + // If the app was cold-launched in Before-First-Unlock state (e.g. by BGAppRefreshTask + // after a reboot), all StorageValues were cached from encrypted UserDefaults and hold + // their defaults. Reload everything from disk now that the device is unlocked, firing + // Combine observers only for values that actually changed. + LogManager.shared.log(category: .general, message: "appCameToForeground: needsBFUReload=\(Storage.shared.needsBFUReload), url='\(Storage.shared.url.value)'") + if Storage.shared.needsBFUReload { + Storage.shared.needsBFUReload = false + LogManager.shared.log(category: .general, message: "BFU reload triggered — reloading all StorageValues") + Storage.shared.reloadAll() + LogManager.shared.log(category: .general, message: "BFU reload complete: url='\(Storage.shared.url.value)'") + // Show the loading overlay so the user sees feedback during the 2-5s + // while tasks re-run with the now-correct credentials. + loadingStates = ["bg": false, "profile": false, "deviceStatus": false] + isInitialLoad = true + setupLoadingState() + showLoadingOverlay() + // Tasks were scheduled during BFU viewDidLoad with url="" — they fired, found no + // data source, and rescheduled themselves 60s out. Reset them now so they run + // within their normal 2-5s initial delay using the now-correct credentials. + scheduleAllTasks() + } + // reset screenlock state if needed UIApplication.shared.isIdleTimerDisabled = Storage.shared.screenlockSwitchState.value diff --git a/LoopFollowLAExtension/LoopFollowLABundle.swift b/LoopFollowLAExtension/LoopFollowLABundle.swift index d98475b8e..fa75e44e4 100644 --- a/LoopFollowLAExtension/LoopFollowLABundle.swift +++ b/LoopFollowLAExtension/LoopFollowLABundle.swift @@ -1,6 +1,3 @@ -// LoopFollow -// LoopFollowLABundle.swift - import SwiftUI import WidgetKit @@ -8,8 +5,5 @@ import WidgetKit struct LoopFollowLABundle: WidgetBundle { var body: some Widget { LoopFollowLiveActivityWidget() - if #available(iOS 18.0, *) { - LoopFollowLiveActivityWidgetWithCarPlay() - } } -} +} \ No newline at end of file diff --git a/LoopFollowLAExtension/LoopFollowLiveActivity.swift b/LoopFollowLAExtension/LoopFollowLiveActivity.swift index 5c28eed3b..30f4e4589 100644 --- a/LoopFollowLAExtension/LoopFollowLiveActivity.swift +++ b/LoopFollowLAExtension/LoopFollowLiveActivity.swift @@ -5,25 +5,25 @@ import ActivityKit import SwiftUI import WidgetKit -/// Builds the shared Dynamic Island content used by both widget variants. +/// Builds the shared Dynamic Island content used by the Live Activity widget. private func makeDynamicIsland(context: ActivityViewContext) -> DynamicIsland { DynamicIsland { DynamicIslandExpandedRegion(.leading) { - Link(destination: URL(string: "loopfollow://la-tap")!) { + Link(destination: URL(string: "\(AppGroupID.urlScheme)://la-tap")!) { DynamicIslandLeadingView(snapshot: context.state.snapshot) .overlay(RenewalOverlayView(show: context.state.snapshot.showRenewalOverlay)) } .id(context.state.seq) } DynamicIslandExpandedRegion(.trailing) { - Link(destination: URL(string: "loopfollow://la-tap")!) { + Link(destination: URL(string: "\(AppGroupID.urlScheme)://la-tap")!) { DynamicIslandTrailingView(snapshot: context.state.snapshot) .overlay(RenewalOverlayView(show: context.state.snapshot.showRenewalOverlay)) } .id(context.state.seq) } DynamicIslandExpandedRegion(.bottom) { - Link(destination: URL(string: "loopfollow://la-tap")!) { + Link(destination: URL(string: "\(AppGroupID.urlScheme)://la-tap")!) { DynamicIslandBottomView(snapshot: context.state.snapshot) .overlay(RenewalOverlayView(show: context.state.snapshot.showRenewalOverlay, showText: true)) } @@ -42,34 +42,37 @@ private func makeDynamicIsland(context: ActivityViewContext some View { if #available(iOS 17.0, *) { - // Use the generic SwiftUI API available in iOS 17+ (no placement enum) contentMargins(Edge.Set.all, 0) } else { self @@ -90,60 +92,72 @@ private extension View { // MARK: - Family-adaptive wrapper (Lock Screen / CarPlay / Watch Smart Stack) /// Reads the activityFamily environment value and routes to the appropriate layout. -/// - `.small` → CarPlay Dashboard & Watch Smart Stack: compact glucose-only view -/// - everything else → full lock screen layout with configurable grid +/// - `.small` → CarPlay Dashboard & Watch Smart Stack +/// - everything else → full lock screen layout @available(iOS 18.0, *) private struct LockScreenFamilyAdaptiveView: View { let state: GlucoseLiveActivityAttributes.ContentState - @Environment(\.activityFamily) var activityFamily + @Environment(\.activityFamily) private var activityFamily var body: some View { if activityFamily == .small { SmallFamilyView(snapshot: state.snapshot) + .activityBackgroundTint(Color.black.opacity(0.25)) } else { LockScreenLiveActivityView(state: state) - .activitySystemActionForegroundColor(.white) .activityBackgroundTint(LAColors.backgroundTint(for: state.snapshot)) - .applyActivityContentMarginsFixIfAvailable() - .widgetURL(URL(string: "loopfollow://la-tap")!) } } } // MARK: - Small family view (CarPlay Dashboard + Watch Smart Stack) - -/// Compact view shown on CarPlay Dashboard (iOS 26+) and Apple Watch Smart Stack (watchOS 11+). -/// Hardcoded to glucose + trend arrow + delta + time since last reading. @available(iOS 18.0, *) private struct SmallFamilyView: View { let snapshot: GlucoseSnapshot + private var unitLabel: String { + switch snapshot.unit { + case .mgdl: return "mg/dL" + case .mmol: return "mmol/L" + } + } + var body: some View { - VStack(alignment: .leading, spacing: 4) { - HStack(alignment: .firstTextBaseline, spacing: 6) { - Text(LAFormat.glucose(snapshot)) - .font(.system(size: 28, weight: .bold, design: .rounded)) - .monospacedDigit() - .foregroundStyle(.white) - Text(LAFormat.trendArrow(snapshot)) - .font(.system(size: 22, weight: .semibold, design: .rounded)) - .foregroundStyle(.white.opacity(0.9)) - } - HStack(spacing: 8) { - Text(LAFormat.delta(snapshot)) + HStack(alignment: .center, spacing: 0) { + VStack(alignment: .leading, spacing: 2) { + HStack(alignment: .firstTextBaseline, spacing: 4) { + Text(LAFormat.glucose(snapshot)) + .font(.system(size: 28, weight: .bold, design: .rounded)) + .monospacedDigit() + .foregroundStyle(LAColors.keyline(for: snapshot)) + + Text(LAFormat.trendArrow(snapshot)) + .font(.system(size: 22, weight: .semibold, design: .rounded)) + .foregroundStyle(LAColors.keyline(for: snapshot)) + } + + Text("\(LAFormat.delta(snapshot)) \(unitLabel)") .font(.system(size: 14, weight: .semibold, design: .rounded)) .monospacedDigit() .foregroundStyle(.white.opacity(0.85)) - Text(LAFormat.updated(snapshot)) - .font(.system(size: 14, weight: .regular, design: .rounded)) + } + + Spacer() + + VStack(alignment: .trailing, spacing: 2) { + Text(LAFormat.projected(snapshot)) + .font(.system(size: 28, weight: .bold, design: .rounded)) .monospacedDigit() + .foregroundStyle(.white) + + Text(unitLabel) + .font(.system(size: 14, weight: .regular, design: .rounded)) .foregroundStyle(.white.opacity(0.65)) } } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) .padding(10) - .activityBackgroundTint(LAColors.backgroundTint(for: snapshot)) } } @@ -158,7 +172,6 @@ private struct LockScreenLiveActivityView: View { VStack(spacing: 6) { HStack(spacing: 12) { - // LEFT: Glucose + trend arrow, delta below VStack(alignment: .leading, spacing: 4) { HStack(alignment: .firstTextBaseline, spacing: 4) { Text(LAFormat.glucose(s)) @@ -186,13 +199,11 @@ private struct LockScreenLiveActivityView: View { .frame(minWidth: 168, maxWidth: 190, alignment: .leading) .layoutPriority(2) - // Divider Rectangle() .fill(Color.white.opacity(0.20)) .frame(width: 1) .padding(.vertical, 8) - // RIGHT: configurable 2×2 grid VStack(spacing: 8) { HStack(spacing: 12) { SlotView(option: slotConfig[0], snapshot: s) @@ -206,8 +217,9 @@ private struct LockScreenLiveActivityView: View { .frame(maxWidth: .infinity, alignment: .trailing) } - // Footer: last update time - Text("Last Update: \(LAFormat.updated(s))") + Text(LAAppGroupSettings.showDisplayName() + ? "\(LAAppGroupSettings.displayName()) — \(LAFormat.updated(s))" + : "Last Update: \(LAFormat.updated(s))") .font(.system(size: 11, weight: .regular, design: .rounded)) .monospacedDigit() .foregroundStyle(.white.opacity(0.65)) @@ -219,7 +231,7 @@ private struct LockScreenLiveActivityView: View { .padding(.bottom, 8) .overlay( RoundedRectangle(cornerRadius: 16, style: .continuous) - .stroke(Color.white.opacity(0.20), lineWidth: 1), + .stroke(Color.white.opacity(0.20), lineWidth: 1) ) .overlay( Group { @@ -227,23 +239,25 @@ private struct LockScreenLiveActivityView: View { ZStack { RoundedRectangle(cornerRadius: 16, style: .continuous) .fill(Color(uiColor: UIColor.systemRed).opacity(0.85)) + Text("Not Looping") .font(.system(size: 20, weight: .heavy, design: .rounded)) .foregroundStyle(.white) .tracking(1.5) } } - }, + } ) .overlay( ZStack { RoundedRectangle(cornerRadius: 16, style: .continuous) .fill(Color.gray.opacity(0.9)) + Text("Tap to update") .font(.system(size: 20, weight: .semibold)) .foregroundStyle(.white) } - .opacity(state.snapshot.showRenewalOverlay ? 1 : 0), + .opacity(state.snapshot.showRenewalOverlay ? 1 : 0) ) } } @@ -284,19 +298,16 @@ private struct MetricBlock: View { .lineLimit(1) .minimumScaleFactor(0.85) } - .frame(width: 60, alignment: .leading) // slightly tighter columns to free space for glucose + .frame(width: 60, alignment: .leading) } } -/// Renders one configurable slot in the lock screen 2×2 grid. -/// Shows nothing (invisible placeholder) when the slot option is `.none`. private struct SlotView: View { let option: LiveActivitySlotOption let snapshot: GlucoseSnapshot var body: some View { if option == .none { - // Invisible spacer — preserves grid alignment Color.clear .frame(width: 60, height: 36) } else { @@ -336,6 +347,7 @@ private struct SlotView: View { private struct DynamicIslandLeadingView: View { let snapshot: GlucoseSnapshot + var body: some View { if snapshot.isNotLooping { Text("⚠️ Not Looping") @@ -350,14 +362,17 @@ private struct DynamicIslandLeadingView: View { .font(.system(size: 28, weight: .bold, design: .rounded)) .monospacedDigit() .foregroundStyle(.white) + HStack(spacing: 5) { Text(LAFormat.trendArrow(snapshot)) .font(.system(size: 13, weight: .semibold, design: .rounded)) .foregroundStyle(.white.opacity(0.9)) + Text(LAFormat.delta(snapshot)) .font(.system(size: 13, weight: .semibold, design: .rounded)) .monospacedDigit() .foregroundStyle(.white.opacity(0.9)) + Text("Proj: \(LAFormat.projected(snapshot))") .font(.system(size: 13, weight: .semibold, design: .rounded)) .monospacedDigit() @@ -370,6 +385,7 @@ private struct DynamicIslandLeadingView: View { private struct DynamicIslandTrailingView: View { let snapshot: GlucoseSnapshot + var body: some View { if snapshot.isNotLooping { EmptyView() @@ -379,6 +395,7 @@ private struct DynamicIslandTrailingView: View { .font(.system(size: 13, weight: .bold, design: .rounded)) .monospacedDigit() .foregroundStyle(.white.opacity(0.95)) + Text("COB \(LAFormat.cob(snapshot))") .font(.system(size: 13, weight: .bold, design: .rounded)) .monospacedDigit() @@ -391,6 +408,7 @@ private struct DynamicIslandTrailingView: View { private struct DynamicIslandBottomView: View { let snapshot: GlucoseSnapshot + var body: some View { if snapshot.isNotLooping { Text("Loop has not reported in 15+ minutes") @@ -410,6 +428,7 @@ private struct DynamicIslandBottomView: View { private struct DynamicIslandCompactTrailingView: View { let snapshot: GlucoseSnapshot + var body: some View { if snapshot.isNotLooping { Text("Not Looping") @@ -428,6 +447,7 @@ private struct DynamicIslandCompactTrailingView: View { private struct DynamicIslandCompactLeadingView: View { let snapshot: GlucoseSnapshot + var body: some View { if snapshot.isNotLooping { Text("⚠️") @@ -443,6 +463,7 @@ private struct DynamicIslandCompactLeadingView: View { private struct DynamicIslandMinimalView: View { let snapshot: GlucoseSnapshot + var body: some View { if snapshot.isNotLooping { Text("⚠️") @@ -459,8 +480,6 @@ private struct DynamicIslandMinimalView: View { // MARK: - Formatting private enum LAFormat { - // MARK: - NumberFormatters (locale-aware) - private static let mgdlFormatter: NumberFormatter = { let nf = NumberFormatter() nf.numberStyle = .decimal @@ -488,8 +507,6 @@ private enum LAFormat { } } - // MARK: Glucose - static func glucose(_ s: GlucoseSnapshot) -> String { formatGlucoseValue(s.glucose, unit: s.unit) } @@ -500,7 +517,6 @@ private enum LAFormat { let v = Int(round(s.delta)) if v == 0 { return "0" } return v > 0 ? "+\(v)" : "\(v)" - case .mmol: let mmol = GlucoseConversion.toMmol(s.delta) let d = (abs(mmol) < 0.05) ? 0.0 : mmol @@ -510,8 +526,6 @@ private enum LAFormat { } } - // MARK: Trend - static func trendArrow(_ s: GlucoseSnapshot) -> String { switch s.trend { case .upFast: "↑↑" @@ -525,8 +539,6 @@ private enum LAFormat { } } - // MARK: Secondary - static func iob(_ s: GlucoseSnapshot) -> String { guard let v = s.iob else { return "—" } return String(format: "%.1f", v) @@ -542,8 +554,6 @@ private enum LAFormat { return formatGlucoseValue(v, unit: s.unit) } - // MARK: Extended InfoType formatters - private static let ageFormatter: DateComponentsFormatter = { let f = DateComponentsFormatter() f.unitsStyle = .positional @@ -552,7 +562,6 @@ private enum LAFormat { return f }() - /// Formats an insert-time epoch into "D:HH" age string. Returns "—" if time is 0. static func age(insertTime: TimeInterval) -> String { guard insertTime > 0 else { return "—" } let secondsAgo = Date().timeIntervalSince1970 - insertTime @@ -630,13 +639,11 @@ private enum LAFormat { s.profileName ?? "—" } - // MARK: Update time - private static let hhmmFormatter: DateFormatter = { let df = DateFormatter() df.locale = .current df.timeZone = .current - df.dateFormat = "HH:mm" // 24h format + df.dateFormat = "HH:mm" return df }() @@ -657,12 +664,11 @@ private enum LAFormat { } } -// MARK: - Threshold-driven colors (Option A, App Group-backed) +// MARK: - Threshold-driven colors private enum LAColors { static func backgroundTint(for snapshot: GlucoseSnapshot) -> Color { let mgdl = snapshot.glucose - let t = LAAppGroupSettings.thresholdsMgdl() let low = t.low let high = t.high @@ -671,12 +677,10 @@ private enum LAColors { let raw = 0.48 + (0.85 - 0.48) * ((low - mgdl) / (low - 54.0)) let opacity = min(max(raw, 0.48), 0.85) return Color(uiColor: UIColor.systemRed).opacity(opacity) - } else if mgdl > high { let raw = 0.44 + (0.85 - 0.44) * ((mgdl - high) / (324.0 - high)) let opacity = min(max(raw, 0.44), 0.85) return Color(uiColor: UIColor.systemOrange).opacity(opacity) - } else { return Color(uiColor: UIColor.systemGreen).opacity(0.36) } @@ -684,7 +688,6 @@ private enum LAColors { static func keyline(for snapshot: GlucoseSnapshot) -> Color { let mgdl = snapshot.glucose - let t = LAAppGroupSettings.thresholdsMgdl() let low = t.low let high = t.high @@ -697,4 +700,4 @@ private enum LAColors { return Color(uiColor: UIColor.systemGreen) } } -} +} \ No newline at end of file diff --git a/docs/LiveActivity.md b/docs/LiveActivity.md deleted file mode 100644 index 979213a96..000000000 --- a/docs/LiveActivity.md +++ /dev/null @@ -1,165 +0,0 @@ -# LoopFollow Live Activity — Architecture & Design Decisions - -**Author:** Philippe Achkar (supported by Claude) -**Date:** 2026-03-07 - ---- - -## What Is the Live Activity? - -The Live Activity displays real-time glucose data on the iPhone lock screen and in the Dynamic Island. It shows: - -- Current glucose value (mg/dL or mmol/L) -- Trend arrow and delta -- IOB, COB, and projected glucose (when available) -- Threshold-driven background color (red (low) / green (in-range) / orange (high)) with user-set thresholds -- A "Not Looping" overlay when Loop has not reported in 15+ minutes - -It updates every 5 minutes, driven by LoopFollow's existing refresh engine. No separate data pipeline exists — the Live Activity is a rendering surface only. - ---- - -## Core Principles - -### 1. Single Source of Truth - -The Live Activity never fetches data directly from Nightscout or Dexcom. It reads exclusively from LoopFollow's internal storage layer (`Storage.shared`, `Observable.shared`). All glucose values, thresholds, IOB, COB, and loop status flow through the same path as the rest of the app. - -This means: -- No duplicated business logic -- No risk of the Live Activity showing different data than the app -- The architecture is reusable for Apple Watch and CarPlay in future phases - -### 2. Source-Agnostic Design - -LoopFollow supports both Nightscout and Dexcom. IOB, COB, or predicted glucose are modeled as optional (`Double?`) in `GlucoseSnapshot` and the UI renders a dash (—) when they are absent. The Live Activity never assumes these values exist. - -### 3. No Hardcoded Identifiers - -The App Group ID is derived dynamically at runtime: group.. No team-specific bundle IDs or App Group IDs are hardcoded anywhere. This ensures the project is safe to fork, clone, and submit as a pull request by any contributor. - ---- - -## Update Architecture — Why APNs Self-Push? - -This is the most important architectural decision in Phase 1. Understanding it will help you maintain and extend this feature correctly. - -### What We Tried First — Direct ``activity.update()`` - -The obvious approach to updating a Live Activity is to call ``activity.update()`` directly from the app. This works reliably when the app is in the foreground. - -The problem appears when the app is in the background. LoopFollow uses a background audio session (`.playback` category, silent WAV file) to stay alive in the background and continue fetching glucose data. We discovered that _liveactivitiesd_ (the iOS system daemon responsible for rendering Live Activities) refuses to process ``activity.update()`` calls from processes that hold an active background audio session. The update call either hangs indefinitely or is silently dropped. The Live Activity freezes on the lock screen while the app continues running normally. - -We attempted several workarounds; none of these approaches were reliable or production-safe: -- Call ``activity.update()`` while audio is playing | Updates hang or are dropped -- Pause the audio player before updating | Insufficient — iOS checks the process-level audio assertion, not just the player state -- Call `AVAudioSession.setActive(false)` before updating | Intermittently worked, but introduced a race condition and broke the audio session unpredictably -- Add a fixed 3-second wait after deactivation | Fragile, caused background task timeout warnings, and still failed intermittently - -### The Solution — APNs Self-Push - -Our solution is for LoopFollow to send an APNs (Apple Push Notification service) push notification to itself. - -Here is how it works: - -1. When a Live Activity is started, ActivityKit provides a **push token** — a unique identifier for that specific Live Activity instance. -2. LoopFollow captures this token via `activity.pushTokenUpdates`. -3. After each BG refresh, LoopFollow generates a signed JWT using its APNs authentication key and posts an HTTP/2 request directly to Apple's APNs servers. -4. Apple's APNs infrastructure delivers the push to `liveactivitiesd` on the device. -5. `liveactivitiesd` updates the Live Activity directly — the app process is **never involved in the rendering path**. - -Because `liveactivitiesd` receives the update via APNs rather than via an inter-process call from LoopFollow, it does not care that LoopFollow holds a background audio session. The update is processed reliably every time. - -### Why This Is Safe and Appropriate - -- This is an officially supported ActivityKit feature. Apple documents push-token-based Live Activity updates as the **recommended** update mechanism. -- The push is sent from the app itself, to itself. No external server or provider infrastructure is required. -- The APNs authentication key is injected at build time via xcconfig and Info.plist. It is never stored in the repository. -- The JWT is generated on-device using CryptoKit (`P256.Signing`) and cached for 55 minutes (APNs tokens are valid for 60 minutes). - ---- - -## File Map - -### Main App Target - -| File | Responsibility | -|---|---| -| `LiveActivityManager.swift` | Orchestration — start, update, end, bind, observe lifecycle | -| `GlucoseSnapshotBuilder.swift` | Pure data transformation — builds `GlucoseSnapshot` from storage | -| `StorageCurrentGlucoseStateProvider.swift` | Thin abstraction over `Storage.shared` and `Observable.shared` | -| `GlucoseSnapshotStore.swift` | App Group persistence — saves/loads latest snapshot | -| `PreferredGlucoseUnit.swift` | Reads user unit preference, converts mg/dL ↔ mmol/L | -| `APNSClient.swift` | Sends APNs self-push with Live Activity content state | -| `APNSJWTGenerator.swift` | Generates ES256-signed JWT for APNs authentication | - -### Shared (App + Extension) - -| File | Responsibility | -|---|---| -| `GlucoseLiveActivityAttributes.swift` | ActivityKit attributes and content state definition | -| `GlucoseSnapshot.swift` | Canonical cross-platform glucose data struct | -| `GlucoseConversion.swift` | Single source of truth for mg/dL ↔ mmol/L conversion | -| `LAAppGroupSettings.swift` | App Group UserDefaults access | -| `AppGroupID.swift` | Derives App Group ID dynamically from bundle identifier | - -### Extension Target - -| File | Responsibility | -|---|---| -| `LoopFollowLiveActivity.swift` | SwiftUI rendering — lock screen card and Dynamic Island | -| `LoopFollowLABundle.swift` | WidgetBundle entry point | - ---- - -## Update Flow - -``` -LoopFollow BG refresh completes - → Storage.shared updated (glucose, delta, trend, IOB, COB, projected) - → Observable.shared updated (isNotLooping) - → BGData calls LiveActivityManager.refreshFromCurrentState(reason: "bg") - → GlucoseSnapshotBuilder.build() reads from StorageCurrentGlucoseStateProvider - → GlucoseSnapshot constructed (unit-converted, threshold-classified) - → GlucoseSnapshotStore persists snapshot to App Group - → activity.update(content) called (direct update for foreground reliability) - → APNSClient.sendLiveActivityUpdate() sends self-push via APNs - → liveactivitiesd receives push - → Lock screen re-renders -``` - ---- - -## APNs Setup — Required for Contributors - -To build and run the Live Activity locally or via CI, you need an APNs authentication key. The key content is injected at build time via `LoopFollowConfigOverride.xcconfig` and is **never stored in the repository**. - -### What you need - -- An Apple Developer account -- An APNs Auth Key (`.p8` file) with the **Apple Push Notifications service (APNs)** capability enabled -- The 10-character Key ID associated with that key - -### Local Build Setup - -1. Generate or download your `.p8` key from [developer.apple.com](https://developer.apple.com) → Certificates, Identifiers & Profiles → Keys. -2. Open the key file in a text editor. Copy the base64 content between the header and footer lines — **exclude** `-----BEGIN PRIVATE KEY-----` and `-----END PRIVATE KEY-----`. Join all lines into a single unbroken string with no spaces or line breaks. -3. Create or edit `LoopFollowConfigOverride.xcconfig` in the project root (this file is gitignored): - -``` -APNS_KEY_ID = -APNS_KEY_CONTENT = -``` - -4. Build and run. The key is read at runtime from `Info.plist` which resolves `$(APNS_KEY_CONTENT)` from the xcconfig. - -### CI / GitHub Actions Setup - -Add two repository secrets under **Settings → Secrets and variables → Actions**: - -| Secret Name | Value | -|---|---| -| `APNS_KEY_ID` | Your 10-character key ID | -| `APNS_KEY` | Full contents of your `.p8` file including PEM headers | - -The build workflow strips the PEM headers automatically and injects the content into `LoopFollowConfigOverride.xcconfig` before building. From 2576dcacecd82eb1bc196de778a1b8e32e7c0392 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Tue, 24 Mar 2026 17:13:14 +0100 Subject: [PATCH 77/82] Linting --- LoopFollowLAExtension/LoopFollowLABundle.swift | 5 ++++- LoopFollowLAExtension/LoopFollowLiveActivity.swift | 3 ++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/LoopFollowLAExtension/LoopFollowLABundle.swift b/LoopFollowLAExtension/LoopFollowLABundle.swift index fa75e44e4..1dc9d75ed 100644 --- a/LoopFollowLAExtension/LoopFollowLABundle.swift +++ b/LoopFollowLAExtension/LoopFollowLABundle.swift @@ -1,3 +1,6 @@ +// LoopFollow +// LoopFollowLABundle.swift + import SwiftUI import WidgetKit @@ -6,4 +9,4 @@ struct LoopFollowLABundle: WidgetBundle { var body: some Widget { LoopFollowLiveActivityWidget() } -} \ No newline at end of file +} diff --git a/LoopFollowLAExtension/LoopFollowLiveActivity.swift b/LoopFollowLAExtension/LoopFollowLiveActivity.swift index 30f4e4589..f388dfbf9 100644 --- a/LoopFollowLAExtension/LoopFollowLiveActivity.swift +++ b/LoopFollowLAExtension/LoopFollowLiveActivity.swift @@ -112,6 +112,7 @@ private struct LockScreenFamilyAdaptiveView: View { } // MARK: - Small family view (CarPlay Dashboard + Watch Smart Stack) + @available(iOS 18.0, *) private struct SmallFamilyView: View { let snapshot: GlucoseSnapshot @@ -700,4 +701,4 @@ private enum LAColors { return Color(uiColor: UIColor.systemGreen) } } -} \ No newline at end of file +} From 2a5c1eff74fe0e2dda1ba81b76461d55d51cc65d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Tue, 24 Mar 2026 19:58:39 +0100 Subject: [PATCH 78/82] Fix JWT cache thread-safety to prevent TooManyProviderTokenUpdates Add NSLock to JWTManager to prevent concurrent cache corruption when Live Activity pushes and remote commands race on different threads. Invalidate JWT cache on 403 in all APNs clients. Add logging for JWT generation and cache invalidation. --- LoopFollow/Helpers/JWTManager.swift | 8 ++++++++ LoopFollow/Remote/LoopAPNS/LoopAPNSService.swift | 1 + LoopFollow/Remote/TRC/PushNotificationManager.swift | 1 + 3 files changed, 10 insertions(+) diff --git a/LoopFollow/Helpers/JWTManager.swift b/LoopFollow/Helpers/JWTManager.swift index 06f4a5583..3e847b999 100644 --- a/LoopFollow/Helpers/JWTManager.swift +++ b/LoopFollow/Helpers/JWTManager.swift @@ -15,12 +15,16 @@ class JWTManager { /// Cache keyed by "keyId:teamId", 55 min TTL private var cache: [String: CachedToken] = [:] private let ttl: TimeInterval = 55 * 60 + private let lock = NSLock() private init() {} func getOrGenerateJWT(keyId: String, teamId: String, apnsKey: String) -> String? { let cacheKey = "\(keyId):\(teamId)" + lock.lock() + defer { lock.unlock() } + if let cached = cache[cacheKey], Date() < cached.expiresAt { return cached.jwt } @@ -41,6 +45,7 @@ class JWTManager { let signedJWT = "\(signingInput).\(signatureBase64)" cache[cacheKey] = CachedToken(jwt: signedJWT, expiresAt: Date().addingTimeInterval(ttl)) + LogManager.shared.log(category: .apns, message: "JWT generated for key \(keyId) (TTL 55 min)") return signedJWT } catch { LogManager.shared.log(category: .apns, message: "Failed to sign JWT: \(error.localizedDescription)") @@ -49,7 +54,10 @@ class JWTManager { } func invalidateCache() { + lock.lock() + defer { lock.unlock() } cache.removeAll() + LogManager.shared.log(category: .apns, message: "JWT cache invalidated") } // MARK: - Private Helpers diff --git a/LoopFollow/Remote/LoopAPNS/LoopAPNSService.swift b/LoopFollow/Remote/LoopAPNS/LoopAPNSService.swift index 61aaa6ef4..feca46e0d 100644 --- a/LoopFollow/Remote/LoopAPNS/LoopAPNSService.swift +++ b/LoopFollow/Remote/LoopAPNS/LoopAPNSService.swift @@ -429,6 +429,7 @@ class LoopAPNSService { LogManager.shared.log(category: .apns, message: "APNS error 400: \(responseBodyMessage) - Check device token and environment settings") completion(false, errorMessage) case 403: + JWTManager.shared.invalidateCache() let errorMessage = "Authentication error. Check your certificate or authentication token. \(responseBodyMessage)" LogManager.shared.log(category: .apns, message: "APNS error 403: \(responseBodyMessage) - Check APNS key permissions for bundle ID") completion(false, errorMessage) diff --git a/LoopFollow/Remote/TRC/PushNotificationManager.swift b/LoopFollow/Remote/TRC/PushNotificationManager.swift index e0c70d746..04c9977c2 100644 --- a/LoopFollow/Remote/TRC/PushNotificationManager.swift +++ b/LoopFollow/Remote/TRC/PushNotificationManager.swift @@ -295,6 +295,7 @@ class PushNotificationManager { case 400: completion(false, "Bad request. The request was invalid or malformed. \(responseBodyMessage)") case 403: + JWTManager.shared.invalidateCache() completion(false, "Authentication error. Check your certificate or authentication token. \(responseBodyMessage)") case 404: completion(false, "Invalid request: The :path value was incorrect. \(responseBodyMessage)") From 39c4ae758274fdbce13f5ff53555b5efc527bbf4 Mon Sep 17 00:00:00 2001 From: Phil A <76601115+MtlPhil@users.noreply.github.com> Date: Thu, 9 Apr 2026 10:32:45 -0400 Subject: [PATCH 79/82] Apple Watch app: complications, data grid, background delivery --- .github/workflows/add_identifiers.yml | 2 +- .github/workflows/auto_version_dev.yml | 2 +- .github/workflows/build_LoopFollow.yml | 8 +- .github/workflows/create_certs.yml | 4 +- .github/workflows/validate_secrets.yml | 2 +- .gitignore | 5 + CLAUDE.md | 144 +++++++++ Config.xcconfig | 2 +- Gemfile.lock | 14 +- LoopFollow.xcodeproj/project.pbxproj | 255 ++++++++++++++- LoopFollow/Alarm/Alarm.swift | 10 +- .../AlarmCondition/FutureCarbsCondition.swift | 104 ++++++ .../Alarm/AlarmEditing/AlarmEditor.swift | 1 + .../Editors/FutureCarbsAlarmEditor.swift | 46 +++ LoopFollow/Alarm/AlarmManager.swift | 1 + .../Alarm/AlarmType/AlarmType+Snooze.swift | 2 +- .../AlarmType/AlarmType+canAcknowledge.swift | 2 +- LoopFollow/Alarm/AlarmType/AlarmType.swift | 1 + .../Alarm/DataStructs/PendingFutureCarb.swift | 17 + LoopFollow/Application/AppDelegate.swift | 19 +- LoopFollow/Controllers/Graphs.swift | 35 +- .../Controllers/Nightscout/BGData.swift | 1 + .../Controllers/Nightscout/DeviceStatus.swift | 1 + .../Nightscout/DeviceStatusOpenAPS.swift | 4 +- .../Controllers/Nightscout/Treatments.swift | 4 +- LoopFollow/Controllers/SpeakBG.swift | 8 + LoopFollow/Helpers/Chart.swift | 22 +- LoopFollow/Helpers/DateTime.swift | 10 +- LoopFollow/LiveActivity/AppGroupID.swift | 1 + LoopFollow/LiveActivity/GlucoseSnapshot.swift | 8 + .../LiveActivity/GlucoseSnapshotStore.swift | 19 +- .../LiveActivity/LAAppGroupSettings.swift | 63 ++++ .../LiveActivity/LALivenessMarker.swift | 27 ++ LoopFollow/LiveActivity/LALivenessStore.swift | 44 +++ .../LiveActivity/LiveActivityManager.swift | 242 +++++++++++--- .../StorageCurrentGlucoseStateProvider.swift | 8 +- LoopFollow/LiveActivitySettingsView.swift | 18 +- LoopFollow/Log/LogManager.swift | 1 + .../AppIcon.appiconset/Contents.json | 75 +++-- LoopFollow/Settings/GeneralSettingsView.swift | 1 + .../ImportExport/AlarmSelectionView.swift | 2 + LoopFollow/Settings/SettingsMenuView.swift | 2 +- LoopFollow/Stats/AggregatedStatsView.swift | 41 ++- LoopFollow/Stats/GRI/GRIRiskGridView.swift | 5 +- LoopFollow/Stats/GRI/GRIView.swift | 46 ++- LoopFollow/Storage/Storage.swift | 4 + .../ViewControllers/MainViewController.swift | 91 +++++- .../MoreMenuViewController.swift | 5 +- .../ComplicationEntryBuilder.swift | 227 +++++++++++++ .../WatchComplicationProvider.swift | 127 ++++++++ .../WatchComplication/WatchFormat.swift | 197 ++++++++++++ .../WatchSessionReceiver.swift | 219 +++++++++++++ .../LoopFollowLiveActivity.swift | 192 ++++++----- .../AccentColor.colorset/Contents.json | 11 + .../AppIcon.appiconset/1024-dark.png | Bin 0 -> 226219 bytes .../AppIcon.appiconset/1024-tinted.png | Bin 0 -> 154575 bytes .../AppIcon.appiconset/1024.png | Bin 0 -> 477801 bytes .../AppIcon.appiconset/Contents.json | 14 + .../Assets.xcassets/Contents.json | 6 + LoopFollowWatch Watch App/ContentView.swift | 237 ++++++++++++++ .../LoopFollowWatch Watch App.entitlements | 10 + .../LoopFollowWatchApp.swift | 105 ++++++ README.md | 33 ++ .../FutureCarbsConditionTests.swift | 300 ++++++++++++++++++ Tests/AlarmConditions/Helpers.swift | 35 +- WatchConnectivityManager.swift | 153 +++++++++ fastlane/Fastfile | 24 +- 67 files changed, 3052 insertions(+), 267 deletions(-) create mode 100644 CLAUDE.md create mode 100644 LoopFollow/Alarm/AlarmCondition/FutureCarbsCondition.swift create mode 100644 LoopFollow/Alarm/AlarmEditing/Editors/FutureCarbsAlarmEditor.swift create mode 100644 LoopFollow/Alarm/DataStructs/PendingFutureCarb.swift create mode 100644 LoopFollow/LiveActivity/LALivenessMarker.swift create mode 100644 LoopFollow/LiveActivity/LALivenessStore.swift create mode 100644 LoopFollow/WatchComplication/ComplicationEntryBuilder.swift create mode 100644 LoopFollow/WatchComplication/WatchComplicationProvider.swift create mode 100644 LoopFollow/WatchComplication/WatchFormat.swift create mode 100644 LoopFollow/WatchComplication/WatchSessionReceiver.swift create mode 100644 LoopFollowWatch Watch App/Assets.xcassets/AccentColor.colorset/Contents.json create mode 100644 LoopFollowWatch Watch App/Assets.xcassets/AppIcon.appiconset/1024-dark.png create mode 100644 LoopFollowWatch Watch App/Assets.xcassets/AppIcon.appiconset/1024-tinted.png create mode 100644 LoopFollowWatch Watch App/Assets.xcassets/AppIcon.appiconset/1024.png create mode 100644 LoopFollowWatch Watch App/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 LoopFollowWatch Watch App/Assets.xcassets/Contents.json create mode 100644 LoopFollowWatch Watch App/ContentView.swift create mode 100644 LoopFollowWatch Watch App/LoopFollowWatch Watch App.entitlements create mode 100644 LoopFollowWatch Watch App/LoopFollowWatchApp.swift create mode 100644 Tests/AlarmConditions/FutureCarbsConditionTests.swift create mode 100644 WatchConnectivityManager.swift diff --git a/.github/workflows/add_identifiers.yml b/.github/workflows/add_identifiers.yml index 2f61ec7ec..702e7a91d 100644 --- a/.github/workflows/add_identifiers.yml +++ b/.github/workflows/add_identifiers.yml @@ -16,7 +16,7 @@ jobs: steps: # Checks-out the repo - name: Checkout Repo - uses: actions/checkout@v4 + uses: actions/checkout@v5 # Patch Fastlane Match to not print tables - name: Patch Match Tables diff --git a/.github/workflows/auto_version_dev.yml b/.github/workflows/auto_version_dev.yml index 846242bd9..2317d261d 100644 --- a/.github/workflows/auto_version_dev.yml +++ b/.github/workflows/auto_version_dev.yml @@ -40,7 +40,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: token: ${{ secrets.LOOPFOLLOW_TOKEN_AUTOBUMP }} diff --git a/.github/workflows/build_LoopFollow.yml b/.github/workflows/build_LoopFollow.yml index 2e8c0be54..443f7bf0c 100644 --- a/.github/workflows/build_LoopFollow.yml +++ b/.github/workflows/build_LoopFollow.yml @@ -90,7 +90,7 @@ jobs: if: | steps.workflow-permission.outputs.has_permission == 'true' && (vars.SCHEDULED_BUILD != 'false' || vars.SCHEDULED_SYNC != 'false') - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: token: ${{ secrets.GH_PAT }} @@ -100,7 +100,7 @@ jobs: steps.workflow-permission.outputs.has_permission == 'true' && vars.SCHEDULED_SYNC != 'false' && github.repository_owner != 'loopandlearn' id: sync - uses: aormsby/Fork-Sync-With-Upstream-action@v3.4.1 + uses: aormsby/Fork-Sync-With-Upstream-action@v3.4.2 with: target_sync_branch: ${{ env.TARGET_BRANCH }} shallow_since: 6 months ago @@ -178,7 +178,7 @@ jobs: run: "sudo xcode-select --switch /Applications/Xcode_26.2.app/Contents/Developer" - name: Checkout Repo for building - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: token: ${{ secrets.GH_PAT }} submodules: recursive @@ -228,7 +228,7 @@ jobs: # Upload Build artifacts - name: Upload build log, IPA and Symbol artifacts if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v6 with: name: build-artifacts path: | diff --git a/.github/workflows/create_certs.yml b/.github/workflows/create_certs.yml index d0f1c4cb9..c284e397a 100644 --- a/.github/workflows/create_certs.yml +++ b/.github/workflows/create_certs.yml @@ -28,7 +28,7 @@ jobs: # Checks-out the repo - name: Checkout Repo - uses: actions/checkout@v4 + uses: actions/checkout@v5 # Patch Fastlane Match not print tables - name: Patch Match Tables @@ -99,7 +99,7 @@ jobs: run: echo "new_certificate_needed=${{ needs.create_certs.outputs.new_certificate_needed }}" - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Install dependencies run: bundle install diff --git a/.github/workflows/validate_secrets.yml b/.github/workflows/validate_secrets.yml index c0132f67a..415ba0019 100644 --- a/.github/workflows/validate_secrets.yml +++ b/.github/workflows/validate_secrets.yml @@ -116,7 +116,7 @@ jobs: TEAMID: ${{ secrets.TEAMID }} steps: - name: Checkout Repo - uses: actions/checkout@v4 + uses: actions/checkout@v5 # Sync the GitHub runner clock with the Windows time server (workaround as suggested in https://github.com/actions/runner/issues/2996) - name: Sync clock diff --git a/.gitignore b/.gitignore index d372f7c5a..35849edfe 100644 --- a/.gitignore +++ b/.gitignore @@ -83,3 +83,8 @@ LoopFollowConfigOverride.xcconfig .history*.xcuserstate docs/PR_configurable_slots.md docs/LiveActivityTestPlan.md + +# Claude +CLAUDE.md + +node_modules/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..1814899d5 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,144 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +LoopFollow is an iOS app for caregivers/parents of Type 1 Diabetics (T1D) to monitor CGM glucose data, loop status, and AID system metrics. This fork (`LoopFollowLA`) is built on top of the upstream [loopandlearn/LoopFollow](https://github.com/loopandlearn/LoopFollow) and adds: + +- **Live Activity** (Dynamic Island / Lock Screen) — **complete**, do not modify +- **Apple Watch complications + Watch app** — **active development focus** +- **APNS-based remote commands** — complete + +The Live Activity work is considered stable. If it evolves upstream, the branch is rebased. All current development effort is on the Watch app (`LoopFollowWatch Watch App` target) and its complications. + +## Build System + +This is a CocoaPods project. Always open `LoopFollow.xcworkspace` (not `.xcodeproj`) in Xcode. + +```bash +# Install/update pods after cloning or when Podfile changes +pod install + +# Build from command line (simulator) +xcodebuild -workspace LoopFollow.xcworkspace -scheme LoopFollow -destination 'platform=iOS Simulator,name=iPhone 16' build + +# Run tests +xcodebuild -workspace LoopFollow.xcworkspace -scheme LoopFollow -destination 'platform=iOS Simulator,name=iPhone 16' test + +# Run a single test class +xcodebuild -workspace LoopFollow.xcworkspace -scheme LoopFollow -destination 'platform=iOS Simulator,name=iPhone 16' test -only-testing:LoopFollowTests/AlarmConditions/BatteryConditionTests +``` + +Fastlane lanes (`build_LoopFollow`, `release`, `identifiers`, `certs`) are CI-only and require App Store Connect credentials. + +## Xcode Targets + +| Target | Purpose | +|---|---| +| `LoopFollow` | Main iOS app | +| `LoopFollowLAExtensionExtension` | Live Activity widget extension | +| `LoopFollowWatch Watch App` | watchOS complication app | + +Bundle IDs are derived from `DEVELOPMENT_TEAM`: `com.$(TEAMID).LoopFollow`, etc. `Config.xcconfig` sets the marketing version; never edit version numbers directly (CI auto-bumps on merge to `dev`). + +## Architecture + +### Data Flow + +1. **Data sources** → `MainViewController` pulls BG/treatment data from: + - **Nightscout** (`Controllers/Nightscout/`) via REST API + - **Dexcom Share** (`BackgroundRefresh/BT/`, uses `ShareClient` pod) + - **BLE heartbeat** (`BackgroundRefresh/BT/BLEManager.swift`) for background refresh +2. `MainViewController` stores parsed data in its own arrays (`bgData`, `bolusData`, etc.) and calls `update*Graph()` methods. +3. **Reactive state bridge**: After processing, values are pushed into `Observable.shared` (in-memory) and `Storage.shared` (UserDefaults-backed). These feed SwiftUI views and the Live Activity pipeline. + +### Key Singletons + +- **`Storage`** (`Storage/Storage.swift`) — All persisted user settings as `StorageValue` (UserDefaults) or `SecureStorageValue` (Keychain). The single source of truth for configuration. +- **`Observable`** (`Storage/Observable.swift`) — In-memory reactive state (`ObservableValue`) for transient display values (BG text, color, direction, current alarm, etc.). +- **`ProfileManager`** — Manages Nightscout basal profiles. +- **`AlarmManager`** — Evaluates alarm conditions and triggers sound/notification. + +### Live Activity & Watch Complication Pipeline + +`GlucoseSnapshot` (`LiveActivity/GlucoseSnapshot.swift`) is the **canonical, source-agnostic data model** shared by all Watch and Live Activity surfaces. It is unit-aware (mg/dL or mmol/L) and self-contained. Fields: `glucose`, `delta`, `trend`, `updatedAt`, `iob`, `cob`, `projected`, `unit`, `isNotLooping`. + +``` +MainViewController / BackgroundRefresh + │ + ▼ +GlucoseSnapshotBuilder.build(...) ← assembles from Observable/Storage + │ + ▼ +GlucoseSnapshotStore.shared.save() ← persists to App Group container (JSON, atomic) + │ + ├──► LiveActivityManager.update() ← Dynamic Island / Lock Screen [COMPLETE] + ├──► WatchConnectivityManager.send() ← transferUserInfo to Watch + │ └──► WatchSessionReceiver ← saves snapshot + reloads complications (Watch-side) + └──► WatchComplicationProvider ← CLKComplicationDataSource (watchOS) + └── ComplicationEntryBuilder ← builds CLKComplicationTemplate +``` + +Thresholds for colour classification (green / orange / red) are read via `LAAppGroupSettings.thresholdsMgdl()` from the shared App Group UserDefaults — the same thresholds used by the Live Activity. The stale threshold is **15 minutes** (900 s) throughout. + +### Watch Complications (active development) + +Two corner complications to build in `ComplicationEntryBuilder` (`LoopFollow/WatchComplication/ComplicationEntryBuilder.swift`): + +**Complication 1 — `graphicCorner`, Open Gauge Text** +- Centre: BG value, coloured green/orange/red via `LAAppGroupSettings` thresholds +- Bottom text: delta (e.g. `+3` or `-2`) +- Gauge: fills from 0 → 15 min based on `snapshot.age / 900` +- Stale (>15 min) or `isNotLooping == true`: replace BG with `⚠` (yellow warning symbol) + +**Complication 2 — `graphicCorner`, Stacked Text** +- Top line: BG value (coloured) +- Bottom line: delta + minutes since update (e.g. `+3 4m`) +- Stale (>15 min): display `--` + +Both complications open the Watch app on tap (default watchOS behaviour when linked to the Watch app). `WatchComplicationProvider` handles timeline lifecycle and delegates all template construction to `ComplicationEntryBuilder`. + +### Watch App (active development) + +Entry point: `LoopFollowWatch Watch App/LoopFollowWatchApp.swift` — activates `WatchSessionReceiver`. +Main view: `LoopFollowWatch Watch App/ContentView.swift` — currently a placeholder stub. + +**Screen 1 — Main glucose view** +- Large BG value, coloured green/orange/red +- Right column: delta, projected BG, time since last update +- Button to open the phone app (shown only when `WCSession.default.isReachable`) + +**Subsequent screens — scrollable data cards** +- Each screen shows up to 4 data points from `GlucoseSnapshot` +- User-configurable via Watch app settings; every field in `GlucoseSnapshot` is eligible (glucose, delta, projected, IOB, COB, trend, age); units displayed alongside each value +- Default: IOB, COB, projected BG, battery + +Watch app settings persist in the Watch-side App Group UserDefaults (same suite as `LAAppGroupSettings`). + +### Background Refresh + +Three modes (set in `Storage.backgroundRefreshType`): +- **Silent tune** — plays an inaudible audio track to keep app alive +- **BLE heartbeat** — paired BLE device (e.g. Dexcom G7) wakes the app +- **APNS** — server push via `APNSClient` / `APNSJWTGenerator` + +### Remote Commands + +Remote bolus/carb/temp-target commands flow through `BackgroundRefresh/Remote/` using TOTP-authenticated APNS pushes. Settings live in `Storage` (APNS key, team ID, bundle ID, shared secret). + +### Settings Architecture + +Settings are split between: +- **SwiftUI views** in `Settings/` (new) — `GeneralSettingsView`, `AlarmSettingsView`, `AdvancedSettingsView`, etc. +- **Legacy UIKit** `SettingsViewController` — being migrated to SwiftUI + +### Tests + +Tests use the Swift Testing framework (`import Testing`). Test files are in `Tests/AlarmConditions/`. + +## Branch & PR Conventions + +- **All PRs target `dev`**, never `main`. PRs to `main` will be redirected. +- Never modify version numbers — CI auto-bumps after merge. +- Branch from `dev` and name it `feature_name` or `fix_name`. diff --git a/Config.xcconfig b/Config.xcconfig index a22c71132..1c80c644b 100644 --- a/Config.xcconfig +++ b/Config.xcconfig @@ -6,4 +6,4 @@ unique_id = ${DEVELOPMENT_TEAM} //Version (DEFAULT) -LOOP_FOLLOW_MARKETING_VERSION = 5.0.0 +LOOP_FOLLOW_MARKETING_VERSION = 5.1.0 diff --git a/Gemfile.lock b/Gemfile.lock index bfeab1ee4..f80c23ed6 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -43,16 +43,17 @@ GEM dotenv (2.8.1) emoji_regex (3.2.3) excon (0.112.0) - faraday (1.8.0) + faraday (1.10.5) faraday-em_http (~> 1.0) faraday-em_synchrony (~> 1.0) faraday-excon (~> 1.1) - faraday-httpclient (~> 1.0.1) + faraday-httpclient (~> 1.0) + faraday-multipart (~> 1.0) faraday-net_http (~> 1.0) - faraday-net_http_persistent (~> 1.1) + faraday-net_http_persistent (~> 1.0) faraday-patron (~> 1.0) faraday-rack (~> 1.0) - multipart-post (>= 1.2, < 3) + faraday-retry (~> 1.0) ruby2_keywords (>= 0.0.4) faraday-cookie_jar (0.0.8) faraday (>= 0.8.0) @@ -61,10 +62,13 @@ GEM faraday-em_synchrony (1.0.1) faraday-excon (1.1.0) faraday-httpclient (1.0.1) + faraday-multipart (1.2.0) + multipart-post (~> 2.0) faraday-net_http (1.0.2) faraday-net_http_persistent (1.2.0) faraday-patron (1.0.0) faraday-rack (1.0.0) + faraday-retry (1.0.4) faraday_middleware (1.2.1) faraday (~> 1.0) fastimage (2.4.0) @@ -163,7 +167,7 @@ GEM httpclient (2.9.0) mutex_m jmespath (1.6.2) - json (2.18.0) + json (2.19.3) jwt (2.10.2) base64 logger (1.7.0) diff --git a/LoopFollow.xcodeproj/project.pbxproj b/LoopFollow.xcodeproj/project.pbxproj index 305f058b3..3ecec0fbd 100644 --- a/LoopFollow.xcodeproj/project.pbxproj +++ b/LoopFollow.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + 2D8068C66833EEAED7B4BEB8 /* FutureCarbsCondition.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2EBAB9EECE7095238A558060 /* FutureCarbsCondition.swift */; }; 374A77992F5BD8B200E96858 /* APNSClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A77982F5BD8AB00E96858 /* APNSClient.swift */; }; 374A77A52F5BE17000E96858 /* AppGroupID.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A779F2F5BE17000E96858 /* AppGroupID.swift */; }; 374A77A62F5BE17000E96858 /* GlucoseSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A77A12F5BE17000E96858 /* GlucoseSnapshot.swift */; }; @@ -23,9 +24,24 @@ 374A77B92F5BE1AC00E96858 /* PreferredGlucoseUnit.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A77B22F5BE1AC00E96858 /* PreferredGlucoseUnit.swift */; }; 379BECAA2F6588300069DC62 /* RestartLiveActivityIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 379BECA92F6588300069DC62 /* RestartLiveActivityIntent.swift */; }; 379BECB02F65DA4B0069DC62 /* LiveActivitySettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 379BECAF2F65DA4B0069DC62 /* LiveActivitySettingsView.swift */; }; + 37989DBF2F609B0E0004BD8B /* LoopFollowWatch Watch App.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = 37989DB52F609B0C0004BD8B /* LoopFollowWatch Watch App.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + 37989DC52F609C550004BD8B /* GlucoseSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A77A12F5BE17000E96858 /* GlucoseSnapshot.swift */; }; + 37989DC62F609C5E0004BD8B /* GlucoseSnapshotStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A77AF2F5BE1AC00E96858 /* GlucoseSnapshotStore.swift */; }; + 37989DC72F609C730004BD8B /* AppGroupID.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A779F2F5BE17000E96858 /* AppGroupID.swift */; }; + 37989DC82F609C7A0004BD8B /* LAAppGroupSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A77A32F5BE17000E96858 /* LAAppGroupSettings.swift */; }; + 37989DCE2F609ED40004BD8B /* WatchComplicationProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37989DCC2F609DAE0004BD8B /* WatchComplicationProvider.swift */; }; + 37989DD02F609F570004BD8B /* ComplicationEntryBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37989DCF2F609F570004BD8B /* ComplicationEntryBuilder.swift */; }; + 37989DDC2F60A2010004BD8B /* WatchFormat.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37989DDB2F60A2000004BD8B /* WatchFormat.swift */; }; + 37989DDD2F60A2020004BD8B /* GlucoseConversion.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD91E4DC2BDEC3F8002D9E97 /* GlucoseConversion.swift */; }; + 37989DD22F60A0ED0004BD8B /* WatchConnectivity.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 37989DD12F60A0ED0004BD8B /* WatchConnectivity.framework */; }; + 37989DD42F60A11E0004BD8B /* WatchConnectivityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37989DD32F60A11E0004BD8B /* WatchConnectivityManager.swift */; }; + 37989DD62F60A15E0004BD8B /* WatchSessionReceiver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37989DD52F60A15E0004BD8B /* WatchSessionReceiver.swift */; }; 37A4BDDB2F5B6B4A00EEB289 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 37A4BDDA2F5B6B4A00EEB289 /* WidgetKit.framework */; }; 37A4BDDD2F5B6B4A00EEB289 /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 37A4BDDC2F5B6B4A00EEB289 /* SwiftUI.framework */; }; 37A4BDE82F5B6B4C00EEB289 /* LoopFollowLAExtensionExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 37A4BDD92F5B6B4A00EEB289 /* LoopFollowLAExtensionExtension.appex */; platformFilter = ios; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + 37E4DD0D2F7E0967000511C8 /* LALivenessStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37E4DD0C2F7E0967000511C8 /* LALivenessStore.swift */; }; + 37E4DD0E2F7E097D000511C8 /* LALivenessStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37E4DD0C2F7E0967000511C8 /* LALivenessStore.swift */; }; + 37E4DD112F7E0D35000511C8 /* LALivenessMarker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37E4DD0F2F7E0985000511C8 /* LALivenessMarker.swift */; }; 3F1335F351590E573D8E6962 /* Pods_LoopFollow.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A7D55B42A22051DAD69E89D0 /* Pods_LoopFollow.framework */; }; 5544D8C363FB5D3B9BF8CE4A /* APNSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7C2676561D686C6459CAA2D /* APNSettingsView.swift */; }; 654132E72E19EA7E00BDBE08 /* SimpleQRCodeScannerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 654132E62E19EA7E00BDBE08 /* SimpleQRCodeScannerView.swift */; }; @@ -57,6 +73,8 @@ 6589CC752E9EAFB700BB18FE /* SettingsMigrationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6589CC742E9EAFB700BB18FE /* SettingsMigrationManager.swift */; }; 65E153C32E4BB69100693A4F /* URLTokenValidationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65E153C22E4BB69100693A4F /* URLTokenValidationView.swift */; }; 65E8A2862E44B0300065037B /* VolumeButtonHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65E8A2852E44B0300065037B /* VolumeButtonHandler.swift */; }; + 66E3D12E66AA4534A144A54B /* BackgroundRefreshManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8CA8BE0B3D247408FE088B4 /* BackgroundRefreshManager.swift */; }; + ACE7F6DE0D065BEB52CDC0DB /* FutureCarbsAlarmEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = B7D2A4EFD18B7B7748B6669E /* FutureCarbsAlarmEditor.swift */; }; DD0247592DB2E89600FCADF6 /* AlarmCondition.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD0247582DB2E89600FCADF6 /* AlarmCondition.swift */; }; DD0247712DB4337700FCADF6 /* BuildExpireCondition.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD02475B2DB2E8FB00FCADF6 /* BuildExpireCondition.swift */; }; DD026E592EA2C8A200A39CB5 /* InsulinPrecisionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD026E582EA2C8A200A39CB5 /* InsulinPrecisionManager.swift */; }; @@ -140,7 +158,6 @@ DD608A082C1F584900F91132 /* DeviceStatusLoop.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD608A072C1F584900F91132 /* DeviceStatusLoop.swift */; }; DD608A0A2C23593900F91132 /* SMB.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD608A092C23593900F91132 /* SMB.swift */; }; DD608A0C2C27415C00F91132 /* BackgroundAlertManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD608A0B2C27415C00F91132 /* BackgroundAlertManager.swift */; }; - 66E3D12E66AA4534A144A54B /* BackgroundRefreshManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8CA8BE0B3D247408FE088B4 /* BackgroundRefreshManager.swift */; }; DD6A935E2BFA6FA2003FFB8E /* DeviceStatusOpenAPS.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD6A935D2BFA6FA2003FFB8E /* DeviceStatusOpenAPS.swift */; }; DD7B0D442D730A3B0063DCB6 /* CycleHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD7B0D432D730A320063DCB6 /* CycleHelper.swift */; }; DD7E19842ACDA50C00DBD158 /* Overrides.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD7E19832ACDA50C00DBD158 /* Overrides.swift */; }; @@ -270,6 +287,7 @@ DDFF3D852D14279B00BF9D9E /* BackgroundRefreshSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDFF3D842D14279B00BF9D9E /* BackgroundRefreshSettingsView.swift */; }; DDFF3D872D14280500BF9D9E /* BackgroundRefreshSettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDFF3D862D14280500BF9D9E /* BackgroundRefreshSettingsViewModel.swift */; }; DDFF3D892D1429AB00BF9D9E /* BackgroundRefreshType.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDFF3D882D1429AB00BF9D9E /* BackgroundRefreshType.swift */; }; + F19449721F3B792730A0F4FD /* PendingFutureCarb.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B9BEC26E4E48EF9B811A372 /* PendingFutureCarb.swift */; }; FC16A97A24996673003D6245 /* NightScout.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC16A97924996673003D6245 /* NightScout.swift */; }; FC16A97B249966A3003D6245 /* AlarmSound.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC7CE589248ABEA3001F83B8 /* AlarmSound.swift */; }; FC16A97D24996747003D6245 /* SpeakBG.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC16A97C24996747003D6245 /* SpeakBG.swift */; }; @@ -421,6 +439,13 @@ /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ + 37989DBD2F609B0E0004BD8B /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = FC97880C2485969B00A7906C /* Project object */; + proxyType = 1; + remoteGlobalIDString = 37989DB42F609B0C0004BD8B; + remoteInfo = "LoopFollowWatch Watch App"; + }; 37A4BDE62F5B6B4C00EEB289 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = FC97880C2485969B00A7906C /* Project object */; @@ -438,6 +463,17 @@ /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ + 37989DC32F609B0E0004BD8B /* Embed Watch Content */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = "$(CONTENTS_FOLDER_PATH)/Watch"; + dstSubfolderSpec = 16; + files = ( + 37989DBF2F609B0E0004BD8B /* LoopFollowWatch Watch App.app in Embed Watch Content */, + ); + name = "Embed Watch Content"; + runOnlyForDeploymentPostprocessing = 0; + }; 37A4BDED2F5B6B4C00EEB289 /* Embed Foundation Extensions */ = { isa = PBXCopyFilesBuildPhase; buildActionMask = 2147483647; @@ -453,6 +489,8 @@ /* Begin PBXFileReference section */ 059B0FA59AABFE72FE13DDDA /* Pods-LoopFollow.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-LoopFollow.release.xcconfig"; path = "Target Support Files/Pods-LoopFollow/Pods-LoopFollow.release.xcconfig"; sourceTree = ""; }; + 2B9BEC26E4E48EF9B811A372 /* PendingFutureCarb.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PendingFutureCarb.swift; sourceTree = ""; }; + 2EBAB9EECE7095238A558060 /* FutureCarbsCondition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FutureCarbsCondition.swift; sourceTree = ""; }; 374A77982F5BD8AB00E96858 /* APNSClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APNSClient.swift; sourceTree = ""; }; 374A779F2F5BE17000E96858 /* AppGroupID.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppGroupID.swift; sourceTree = ""; }; 374A77A02F5BE17000E96858 /* GlucoseLiveActivityAttributes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseLiveActivityAttributes.swift; sourceTree = ""; }; @@ -466,9 +504,18 @@ 374DAACA2F5B924B00BB663B /* LoopFollowLAExtensionExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = LoopFollowLAExtensionExtension.entitlements; sourceTree = ""; }; 379BECA92F6588300069DC62 /* RestartLiveActivityIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RestartLiveActivityIntent.swift; sourceTree = ""; }; 379BECAF2F65DA4B0069DC62 /* LiveActivitySettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActivitySettingsView.swift; sourceTree = ""; }; + 37989DB52F609B0C0004BD8B /* LoopFollowWatch Watch App.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "LoopFollowWatch Watch App.app"; sourceTree = BUILT_PRODUCTS_DIR; }; + 37989DCC2F609DAE0004BD8B /* WatchComplicationProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchComplicationProvider.swift; sourceTree = ""; }; + 37989DCF2F609F570004BD8B /* ComplicationEntryBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComplicationEntryBuilder.swift; sourceTree = ""; }; + 37989DDB2F60A2000004BD8B /* WatchFormat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchFormat.swift; sourceTree = ""; }; + 37989DD12F60A0ED0004BD8B /* WatchConnectivity.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WatchConnectivity.framework; path = Platforms/MacOSX.platform/Developer/SDKs/MacOSX26.2.sdk/System/iOSSupport/System/Library/Frameworks/WatchConnectivity.framework; sourceTree = DEVELOPER_DIR; }; + 37989DD32F60A11E0004BD8B /* WatchConnectivityManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchConnectivityManager.swift; sourceTree = ""; }; + 37989DD52F60A15E0004BD8B /* WatchSessionReceiver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchSessionReceiver.swift; sourceTree = ""; }; 37A4BDD92F5B6B4A00EEB289 /* LoopFollowLAExtensionExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = LoopFollowLAExtensionExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 37A4BDDA2F5B6B4A00EEB289 /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = /System/Library/Frameworks/WidgetKit.framework; sourceTree = ""; }; 37A4BDDC2F5B6B4A00EEB289 /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = /System/Library/Frameworks/SwiftUI.framework; sourceTree = ""; }; + 37E4DD0C2F7E0967000511C8 /* LALivenessStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LALivenessStore.swift; sourceTree = ""; }; + 37E4DD0F2F7E0985000511C8 /* LALivenessMarker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LALivenessMarker.swift; sourceTree = ""; }; 654132E62E19EA7E00BDBE08 /* SimpleQRCodeScannerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimpleQRCodeScannerView.swift; sourceTree = ""; }; 654132E92E19F24800BDBE08 /* TOTPGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TOTPGenerator.swift; sourceTree = ""; }; 654134172E1DC09700BDBE08 /* OverridePresetsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverridePresetsView.swift; sourceTree = ""; }; @@ -499,6 +546,8 @@ 65E153C22E4BB69100693A4F /* URLTokenValidationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLTokenValidationView.swift; sourceTree = ""; }; 65E8A2852E44B0300065037B /* VolumeButtonHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VolumeButtonHandler.swift; sourceTree = ""; }; A7D55B42A22051DAD69E89D0 /* Pods_LoopFollow.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_LoopFollow.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + A8CA8BE0B3D247408FE088B4 /* BackgroundRefreshManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundRefreshManager.swift; sourceTree = ""; }; + B7D2A4EFD18B7B7748B6669E /* FutureCarbsAlarmEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FutureCarbsAlarmEditor.swift; sourceTree = ""; }; DD0247582DB2E89600FCADF6 /* AlarmCondition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlarmCondition.swift; sourceTree = ""; }; DD02475B2DB2E8FB00FCADF6 /* BuildExpireCondition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BuildExpireCondition.swift; sourceTree = ""; }; DD026E582EA2C8A200A39CB5 /* InsulinPrecisionManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InsulinPrecisionManager.swift; sourceTree = ""; }; @@ -582,7 +631,6 @@ DD608A072C1F584900F91132 /* DeviceStatusLoop.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceStatusLoop.swift; sourceTree = ""; }; DD608A092C23593900F91132 /* SMB.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SMB.swift; sourceTree = ""; }; DD608A0B2C27415C00F91132 /* BackgroundAlertManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundAlertManager.swift; sourceTree = ""; }; - A8CA8BE0B3D247408FE088B4 /* BackgroundRefreshManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundRefreshManager.swift; sourceTree = ""; }; DD6A935D2BFA6FA2003FFB8E /* DeviceStatusOpenAPS.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceStatusOpenAPS.swift; sourceTree = ""; }; DD7B0D432D730A320063DCB6 /* CycleHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CycleHelper.swift; sourceTree = ""; }; DD7E19832ACDA50C00DBD158 /* Overrides.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Overrides.swift; sourceTree = ""; }; @@ -871,6 +919,7 @@ /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ + 37989DB62F609B0C0004BD8B /* LoopFollowWatch Watch App */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = "LoopFollowWatch Watch App"; sourceTree = ""; }; 37A4BDDE2F5B6B4A00EEB289 /* LoopFollowLAExtension */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = LoopFollowLAExtension; sourceTree = ""; }; 65AC25F52ECFD5E800421360 /* Stats */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = Stats; sourceTree = ""; }; 65AC26702ED245DF00421360 /* Treatments */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = Treatments; sourceTree = ""; }; @@ -878,6 +927,13 @@ /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ + 37989DB22F609B0C0004BD8B /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 37A4BDD62F5B6B4A00EEB289 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -900,6 +956,8 @@ files = ( FCFEEC9E2486E68E00402A7F /* WebKit.framework in Frameworks */, 3F1335F351590E573D8E6962 /* Pods_LoopFollow.framework in Frameworks */, + DD48781C2C7DAF140048F05C /* SwiftJWT in Frameworks */, + 37989DD22F60A0ED0004BD8B /* WatchConnectivity.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -909,6 +967,8 @@ 376310762F5CD65100656488 /* LiveActivity */ = { isa = PBXGroup; children = ( + 37E4DD0F2F7E0985000511C8 /* LALivenessMarker.swift */, + 37E4DD0C2F7E0967000511C8 /* LALivenessStore.swift */, 374A77AE2F5BE1AC00E96858 /* GlucoseSnapshotBuilder.swift */, 374A77AF2F5BE1AC00E96858 /* GlucoseSnapshotStore.swift */, 374A77B12F5BE1AC00E96858 /* LiveActivityManager.swift */, @@ -923,6 +983,17 @@ path = LiveActivity; sourceTree = ""; }; + 37989DCB2F609D9C0004BD8B /* WatchComplication */ = { + isa = PBXGroup; + children = ( + 37989DD52F60A15E0004BD8B /* WatchSessionReceiver.swift */, + 37989DCF2F609F570004BD8B /* ComplicationEntryBuilder.swift */, + 37989DCC2F609DAE0004BD8B /* WatchComplicationProvider.swift */, + 37989DDB2F60A2000004BD8B /* WatchFormat.swift */, + ); + path = WatchComplication; + sourceTree = ""; + }; 6589CC552E9E7D1600BB18FE /* ImportExport */ = { isa = PBXGroup; children = ( @@ -959,6 +1030,7 @@ 6A5880E0B811AF443B05AB02 /* Frameworks */ = { isa = PBXGroup; children = ( + 37989DD12F60A0ED0004BD8B /* WatchConnectivity.framework */, DDCC3ABF2DDE10B0006F1C10 /* Testing.framework */, FCFEEC9D2486E68E00402A7F /* WebKit.framework */, A7D55B42A22051DAD69E89D0 /* Pods_LoopFollow.framework */, @@ -982,6 +1054,7 @@ isa = PBXGroup; children = ( DDCC3A552DDC9617006F1C10 /* MissedBolusCondition.swift */, + 2EBAB9EECE7095238A558060 /* FutureCarbsCondition.swift */, DDCC3A4E2DDC5B54006F1C10 /* BatteryDropCondition.swift */, DDCC3A4A2DDBB5E4006F1C10 /* BatteryCondition.swift */, DDCC3A512DDED000006F1C10 /* PumpBatteryCondition.swift */, @@ -1228,6 +1301,7 @@ isa = PBXGroup; children = ( DDCC3A572DDC9655006F1C10 /* MissedBolusAlarmEditor.swift */, + B7D2A4EFD18B7B7748B6669E /* FutureCarbsAlarmEditor.swift */, DDCC3A532DDC5D62006F1C10 /* BatteryDropAlarmEditor.swift */, DDCC3A4C2DDBB77C006F1C10 /* PhoneBatteryAlarmEditor.swift */, DDCC3A5C2DDE2000006F1C10 /* PumpBatteryAlarmEditor.swift */, @@ -1279,6 +1353,7 @@ isa = PBXGroup; children = ( DDCC3A592DDC988F006F1C10 /* CarbSample.swift */, + 2B9BEC26E4E48EF9B811A372 /* PendingFutureCarb.swift */, DDB9FC7E2DDB584500EFAA76 /* BolusEntry.swift */, DD5DA27B2DC930D6003D44FC /* GlucoseValue.swift */, ); @@ -1569,6 +1644,7 @@ isa = PBXGroup; children = ( 379BECAF2F65DA4B0069DC62 /* LiveActivitySettingsView.swift */, + 37989DCB2F609D9C0004BD8B /* WatchComplication */, 376310762F5CD65100656488 /* LiveActivity */, 6589CC612E9E7D1600BB18FE /* Settings */, 65AC26702ED245DF00421360 /* Treatments */, @@ -1601,6 +1677,7 @@ isa = PBXGroup; children = ( 379BECA92F6588300069DC62 /* RestartLiveActivityIntent.swift */, + 37989DD32F60A11E0004BD8B /* WatchConnectivityManager.swift */, 374DAACA2F5B924B00BB663B /* LoopFollowLAExtensionExtension.entitlements */, DDF2C0132BEFD468007A20E6 /* blacklisted-versions.json */, DDB0AF542BB1B24A00AFA48B /* BuildDetails.plist */, @@ -1611,6 +1688,7 @@ FC8DEEE32485D1680075863F /* LoopFollow */, DDCC3AD72DDE1790006F1C10 /* Tests */, 37A4BDDE2F5B6B4A00EEB289 /* LoopFollowLAExtension */, + 37989DB62F609B0C0004BD8B /* LoopFollowWatch Watch App */, FC9788152485969B00A7906C /* Products */, 8E32230C453C93FDCE59C2B9 /* Pods */, 6A5880E0B811AF443B05AB02 /* Frameworks */, @@ -1623,6 +1701,7 @@ FC9788142485969B00A7906C /* Loop Follow.app */, DDCC3AD62DDE1790006F1C10 /* Tests.xctest */, 37A4BDD92F5B6B4A00EEB289 /* LoopFollowLAExtensionExtension.appex */, + 37989DB52F609B0C0004BD8B /* LoopFollowWatch Watch App.app */, ); name = Products; sourceTree = ""; @@ -1695,6 +1774,28 @@ /* End PBXGroup section */ /* Begin PBXNativeTarget section */ + 37989DB42F609B0C0004BD8B /* LoopFollowWatch Watch App */ = { + isa = PBXNativeTarget; + buildConfigurationList = 37989DC02F609B0E0004BD8B /* Build configuration list for PBXNativeTarget "LoopFollowWatch Watch App" */; + buildPhases = ( + 37989DB12F609B0C0004BD8B /* Sources */, + 37989DB22F609B0C0004BD8B /* Frameworks */, + 37989DB32F609B0C0004BD8B /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + 37989DB62F609B0C0004BD8B /* LoopFollowWatch Watch App */, + ); + name = "LoopFollowWatch Watch App"; + packageProductDependencies = ( + ); + productName = "LoopFollowWatch Watch App"; + productReference = 37989DB52F609B0C0004BD8B /* LoopFollowWatch Watch App.app */; + productType = "com.apple.product-type.application"; + }; 37A4BDD82F5B6B4A00EEB289 /* LoopFollowLAExtensionExtension */ = { isa = PBXNativeTarget; buildConfigurationList = 37A4BDEA2F5B6B4C00EEB289 /* Build configuration list for PBXNativeTarget "LoopFollowLAExtensionExtension" */; @@ -1752,11 +1853,13 @@ 04DA71CCA0280FA5FA2DF7A6 /* [CP] Embed Pods Frameworks */, DDB0AF532BB1AA0900AFA48B /* Capture Build Details */, 37A4BDED2F5B6B4C00EEB289 /* Embed Foundation Extensions */, + 37989DC32F609B0E0004BD8B /* Embed Watch Content */, ); buildRules = ( ); dependencies = ( 37A4BDE72F5B6B4C00EEB289 /* PBXTargetDependency */, + 37989DBE2F609B0E0004BD8B /* PBXTargetDependency */, ); fileSystemSynchronizedGroups = ( 65AC25F52ECFD5E800421360 /* Stats */, @@ -1775,10 +1878,13 @@ FC97880C2485969B00A7906C /* Project object */ = { isa = PBXProject; attributes = { - LastSwiftUpdateCheck = 2620; + LastSwiftUpdateCheck = 2630; LastUpgradeCheck = 1140; ORGANIZATIONNAME = "Jon Fawcett"; TargetAttributes = { + 37989DB42F609B0C0004BD8B = { + CreatedOnToolsVersion = 26.3; + }; 37A4BDD82F5B6B4A00EEB289 = { CreatedOnToolsVersion = 26.2; }; @@ -1809,11 +1915,19 @@ FC9788132485969B00A7906C /* LoopFollow */, DDCC3AD52DDE1790006F1C10 /* Tests */, 37A4BDD82F5B6B4A00EEB289 /* LoopFollowLAExtensionExtension */, + 37989DB42F609B0C0004BD8B /* LoopFollowWatch Watch App */, ); }; /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ + 37989DB32F609B0C0004BD8B /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 37A4BDD72F5B6B4A00EEB289 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -2044,13 +2158,31 @@ /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ + 37989DB12F609B0C0004BD8B /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 37989DC62F609C5E0004BD8B /* GlucoseSnapshotStore.swift in Sources */, + 37989DD02F609F570004BD8B /* ComplicationEntryBuilder.swift in Sources */, + 37989DC52F609C550004BD8B /* GlucoseSnapshot.swift in Sources */, + 37989DC72F609C730004BD8B /* AppGroupID.swift in Sources */, + 37989DD62F60A15E0004BD8B /* WatchSessionReceiver.swift in Sources */, + 37989DC82F609C7A0004BD8B /* LAAppGroupSettings.swift in Sources */, + 37989DCE2F609ED40004BD8B /* WatchComplicationProvider.swift in Sources */, + 37989DDC2F60A2010004BD8B /* WatchFormat.swift in Sources */, + 37989DDD2F60A2020004BD8B /* GlucoseConversion.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 37A4BDD52F5B6B4A00EEB289 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( DD91E4DE2BDEC3F8002D9E97 /* GlucoseConversion.swift in Sources */, 374A77AA2F5BE17000E96858 /* AppGroupID.swift in Sources */, + 37E4DD0E2F7E097D000511C8 /* LALivenessStore.swift in Sources */, 374A77AB2F5BE17000E96858 /* GlucoseSnapshot.swift in Sources */, + 37E4DD112F7E0D35000511C8 /* LALivenessMarker.swift in Sources */, 374A77AC2F5BE17000E96858 /* LAAppGroupSettings.swift in Sources */, 374A77AD2F5BE17000E96858 /* GlucoseLiveActivityAttributes.swift in Sources */, ); @@ -2132,6 +2264,9 @@ DDB9FC7B2DDB573F00EFAA76 /* IOBCondition.swift in Sources */, DD7E19862ACDA59700DBD158 /* BGCheck.swift in Sources */, DDCC3A5A2DDC988F006F1C10 /* CarbSample.swift in Sources */, + F19449721F3B792730A0F4FD /* PendingFutureCarb.swift in Sources */, + 2D8068C66833EEAED7B4BEB8 /* FutureCarbsCondition.swift in Sources */, + ACE7F6DE0D065BEB52CDC0DB /* FutureCarbsAlarmEditor.swift in Sources */, DD0650F12DCE9A9E004D3B41 /* MissedReadingCondition.swift in Sources */, DDC6CA4B2DD8E4960060EE25 /* PumpVolumeAlarmEditor.swift in Sources */, 6589CC752E9EAFB700BB18FE /* SettingsMigrationManager.swift in Sources */, @@ -2204,10 +2339,12 @@ FC8589BF252B54F500C8FC73 /* Mobileprovision.swift in Sources */, DD4878052C7B2C970048F05C /* Storage.swift in Sources */, DD493AE12ACF22FE009A6922 /* Profile.swift in Sources */, + 37989DD42F60A11E0004BD8B /* WatchConnectivityManager.swift in Sources */, 6589CC622E9E7D1600BB18FE /* ImportExportSettingsView.swift in Sources */, 6589CC632E9E7D1600BB18FE /* GeneralSettingsView.swift in Sources */, 5544D8C363FB5D3B9BF8CE4A /* APNSettingsView.swift in Sources */, 6589CC642E9E7D1600BB18FE /* ContactSettingsView.swift in Sources */, + 37E4DD0D2F7E0967000511C8 /* LALivenessStore.swift in Sources */, 6589CC652E9E7D1600BB18FE /* DexcomSettingsViewModel.swift in Sources */, 6589CC662E9E7D1600BB18FE /* AdvancedSettingsView.swift in Sources */, 6589CC672E9E7D1600BB18FE /* ImportExportSettingsViewModel.swift in Sources */, @@ -2351,6 +2488,11 @@ /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ + 37989DBE2F609B0E0004BD8B /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 37989DB42F609B0C0004BD8B /* LoopFollowWatch Watch App */; + targetProxy = 37989DBD2F609B0E0004BD8B /* PBXContainerItemProxy */; + }; 37A4BDE72F5B6B4C00EEB289 /* PBXTargetDependency */ = { isa = PBXTargetDependency; platformFilter = ios; @@ -2384,6 +2526,91 @@ /* End PBXVariantGroup section */ /* Begin XCBuildConfiguration section */ + 37989DC12F609B0E0004BD8B /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_COMPLICATION_NAME = LoopFollow; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CODE_SIGN_ENTITLEMENTS = "LoopFollowWatch Watch App/LoopFollowWatch Watch App.entitlements"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = "$(LF_DEVELOPMENT_TEAM)"; + ENABLE_PREVIEWS = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = LoopFollowWatch; + INFOPLIST_KEY_CLKComplicationPrincipalClass = "$(PRODUCT_MODULE_NAME).WatchComplicationProvider"; + INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; + INFOPLIST_KEY_WKCompanionAppBundleIdentifier = "com.$(unique_id).LoopFollow$(app_suffix)"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "com.$(unique_id).LoopFollow$(app_suffix).watchkitapp"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = watchos; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 4; + WATCHOS_DEPLOYMENT_TARGET = 9.6; + }; + name = Debug; + }; + 37989DC22F609B0E0004BD8B /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_COMPLICATION_NAME = LoopFollow; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CODE_SIGN_ENTITLEMENTS = "LoopFollowWatch Watch App/LoopFollowWatch Watch App.entitlements"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = "$(LF_DEVELOPMENT_TEAM)"; + ENABLE_PREVIEWS = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = LoopFollowWatch; + INFOPLIST_KEY_CLKComplicationPrincipalClass = "$(PRODUCT_MODULE_NAME).WatchComplicationProvider"; + INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; + INFOPLIST_KEY_WKCompanionAppBundleIdentifier = "com.$(unique_id).LoopFollow$(app_suffix)"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "com.$(unique_id).LoopFollow$(app_suffix).watchkitapp"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = watchos; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 4; + WATCHOS_DEPLOYMENT_TARGET = 9.6; + }; + name = Release; + }; 37A4BDEB2F5B6B4C00EEB289 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -2498,6 +2725,12 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = "$(LF_DEVELOPMENT_TEAM)"; ENABLE_USER_SCRIPT_SANDBOXING = YES; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(BUILT_PRODUCTS_DIR)/Charts", + "$(BUILT_PRODUCTS_DIR)/ShareClient", + "$(BUILT_PRODUCTS_DIR)/SwiftAlgorithms", + ); GCC_C_LANGUAGE_STANDARD = gnu17; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 18.4; @@ -2525,6 +2758,12 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = "$(LF_DEVELOPMENT_TEAM)"; ENABLE_USER_SCRIPT_SANDBOXING = YES; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(BUILT_PRODUCTS_DIR)/Charts", + "$(BUILT_PRODUCTS_DIR)/ShareClient", + "$(BUILT_PRODUCTS_DIR)/SwiftAlgorithms", + ); GCC_C_LANGUAGE_STANDARD = gnu17; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 18.4; @@ -2713,6 +2952,15 @@ /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ + 37989DC02F609B0E0004BD8B /* Build configuration list for PBXNativeTarget "LoopFollowWatch Watch App" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 37989DC12F609B0E0004BD8B /* Debug */, + 37989DC22F609B0E0004BD8B /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; 37A4BDEA2F5B6B4C00EEB289 /* Build configuration list for PBXNativeTarget "LoopFollowLAExtensionExtension" */ = { isa = XCConfigurationList; buildConfigurations = ( @@ -2751,7 +2999,6 @@ }; /* End XCConfigurationList section */ - /* Begin XCVersionGroup section */ FC3AE7B3249E8E0E00AAE1E0 /* LoopFollow.xcdatamodeld */ = { isa = XCVersionGroup; diff --git a/LoopFollow/Alarm/Alarm.swift b/LoopFollow/Alarm/Alarm.swift index 6b6b37f6d..8fff17f4d 100644 --- a/LoopFollow/Alarm/Alarm.swift +++ b/LoopFollow/Alarm/Alarm.swift @@ -309,6 +309,12 @@ struct Alarm: Identifiable, Codable, Equatable { predictiveMinutes = 15 delta = 0.1 threshold = 4 + case .futureCarbs: + soundFile = .alertToneRingtone1 + threshold = 45 // max lookahead minutes + delta = 5 // min grams + snoozeDuration = 0 + repeatSoundOption = .never case .sensorChange: soundFile = .wakeUpWillYou threshold = 12 @@ -364,7 +370,7 @@ extension AlarmType { switch self { case .low, .high, .fastDrop, .fastRise, .missedReading, .temporary: return .glucose - case .iob, .cob, .missedBolus, .recBolus: + case .iob, .cob, .missedBolus, .futureCarbs, .recBolus: return .insulin case .battery, .batteryDrop, .pump, .pumpBattery, .pumpChange, .sensorChange, .notLooping, .buildExpire: @@ -384,6 +390,7 @@ extension AlarmType { case .iob: return "syringe" case .cob: return "fork.knife" case .missedBolus: return "exclamationmark.arrow.triangle.2.circlepath" + case .futureCarbs: return "clock.arrow.circlepath" case .recBolus: return "bolt.horizontal" case .battery: return "battery.25" case .batteryDrop: return "battery.100.bolt" @@ -411,6 +418,7 @@ extension AlarmType { case .iob: return "High insulin-on-board." case .cob: return "High carbs-on-board." case .missedBolus: return "Carbs without bolus." + case .futureCarbs: return "Reminder when future carbs are due." case .recBolus: return "Recommended bolus issued." case .battery: return "Phone battery low." case .batteryDrop: return "Battery drops quickly." diff --git a/LoopFollow/Alarm/AlarmCondition/FutureCarbsCondition.swift b/LoopFollow/Alarm/AlarmCondition/FutureCarbsCondition.swift new file mode 100644 index 000000000..24631e5c9 --- /dev/null +++ b/LoopFollow/Alarm/AlarmCondition/FutureCarbsCondition.swift @@ -0,0 +1,104 @@ +// LoopFollow +// FutureCarbsCondition.swift + +import Foundation + +/// Fires once when a future-dated carb entry's scheduled time arrives. +/// +/// **How it works:** +/// 1. Each alarm tick scans `recentCarbs` for entries whose `date` is in the future. +/// New ones are added to a persistent "pending" list regardless of lookahead distance, +/// capturing the moment they were first observed (`observedAt`). +/// 2. When a pending entry's `carbDate` passes (i.e. `carbDate <= now`), verify the +/// carb still exists in `recentCarbs` **and** that the original distance +/// (`carbDate − observedAt`) was within the max lookahead window. If both hold, +/// fire the alarm. Otherwise silently remove the entry. +/// 3. Stale entries (observed > 2 hours ago) whose carb no longer exists in +/// `recentCarbs` are cleaned up automatically. +struct FutureCarbsCondition: AlarmCondition { + static let type: AlarmType = .futureCarbs + init() {} + + func evaluate(alarm: Alarm, data: AlarmData, now: Date) -> Bool { + // ──────────────────────────────── + // 0. Pull settings + // ──────────────────────────────── + let maxLookaheadMin = alarm.threshold ?? 45 // max lookahead in minutes + let minGrams = alarm.delta ?? 5 // ignore carbs below this + + let nowTI = now.timeIntervalSince1970 + let maxLookaheadSec = maxLookaheadMin * 60 + + var pending = Storage.shared.pendingFutureCarbs.value + let tolerance: TimeInterval = 5 // seconds, for matching carb entries + + // ──────────────────────────────── + // 1. Scan for new future carbs + // ──────────────────────────────── + for carb in data.recentCarbs { + let carbTI = carb.date.timeIntervalSince1970 + + // Must be in the future and meet the minimum grams threshold. + // We track ALL future carbs (not just those within the lookahead + // window) so that carbs originally outside the window cannot + // drift in later with a fresh observedAt. + guard carbTI > nowTI, + carb.grams >= minGrams + else { continue } + + // Already tracked? + let alreadyTracked = pending.contains { entry in + abs(entry.carbDate - carbTI) < tolerance && entry.grams == carb.grams + } + if !alreadyTracked { + pending.append(PendingFutureCarb( + carbDate: carbTI, + grams: carb.grams, + observedAt: nowTI + )) + } + } + + // ──────────────────────────────── + // 2. Check if any pending entry is due + // ──────────────────────────────── + var fired = false + + pending.removeAll { entry in + let stillExists = data.recentCarbs.contains { carb in + abs(carb.date.timeIntervalSince1970 - entry.carbDate) < tolerance + && carb.grams == entry.grams + } + + // Cleanup stale entries (observed > 2 hours ago) only if + // the carb no longer exists — prevents eviction and + // re-observation with a fresh observedAt. + if nowTI - entry.observedAt > 7200, !stillExists { + return true + } + + // Not yet due + guard entry.carbDate <= nowTI else { return false } + + // Carb was deleted — remove silently + if !stillExists { return true } + + // Carb was originally outside the lookahead window — remove without firing + if entry.carbDate - entry.observedAt > maxLookaheadSec { return true } + + // Fire (one per tick) + if !fired { + fired = true + return true + } + + return false + } + + // ──────────────────────────────── + // 3. Persist and return + // ──────────────────────────────── + Storage.shared.pendingFutureCarbs.value = pending + return fired + } +} diff --git a/LoopFollow/Alarm/AlarmEditing/AlarmEditor.swift b/LoopFollow/Alarm/AlarmEditing/AlarmEditor.swift index 6ca26d576..e8ff4aff5 100644 --- a/LoopFollow/Alarm/AlarmEditing/AlarmEditor.swift +++ b/LoopFollow/Alarm/AlarmEditing/AlarmEditor.swift @@ -82,6 +82,7 @@ struct AlarmEditor: View { case .battery: PhoneBatteryAlarmEditor(alarm: $alarm) case .batteryDrop: BatteryDropAlarmEditor(alarm: $alarm) case .missedBolus: MissedBolusAlarmEditor(alarm: $alarm) + case .futureCarbs: FutureCarbsAlarmEditor(alarm: $alarm) } } } diff --git a/LoopFollow/Alarm/AlarmEditing/Editors/FutureCarbsAlarmEditor.swift b/LoopFollow/Alarm/AlarmEditing/Editors/FutureCarbsAlarmEditor.swift new file mode 100644 index 000000000..0df1e2177 --- /dev/null +++ b/LoopFollow/Alarm/AlarmEditing/Editors/FutureCarbsAlarmEditor.swift @@ -0,0 +1,46 @@ +// LoopFollow +// FutureCarbsAlarmEditor.swift + +import SwiftUI + +struct FutureCarbsAlarmEditor: View { + @Binding var alarm: Alarm + + var body: some View { + Group { + InfoBanner( + text: "Alerts when a future-dated carb entry's scheduled time arrives — " + + "a reminder to start eating. Use the max lookahead to ignore " + + "fat/protein entries that are typically scheduled further ahead.", + alarmType: alarm.type + ) + + AlarmGeneralSection(alarm: $alarm) + + AlarmStepperSection( + header: "Max Lookahead", + footer: "Only track carb entries scheduled up to this many minutes " + + "in the future. Entries beyond this window are ignored.", + title: "Lookahead", + range: 5 ... 120, + step: 5, + unitLabel: "min", + value: $alarm.threshold + ) + + AlarmStepperSection( + header: "Minimum Carbs", + footer: "Ignore carb entries below this amount.", + title: "At or Above", + range: 0 ... 50, + step: 1, + unitLabel: "g", + value: $alarm.delta + ) + + AlarmActiveSection(alarm: $alarm) + AlarmAudioSection(alarm: $alarm) + AlarmSnoozeSection(alarm: $alarm) + } + } +} diff --git a/LoopFollow/Alarm/AlarmManager.swift b/LoopFollow/Alarm/AlarmManager.swift index 436535ea6..3f5aa84ec 100644 --- a/LoopFollow/Alarm/AlarmManager.swift +++ b/LoopFollow/Alarm/AlarmManager.swift @@ -33,6 +33,7 @@ class AlarmManager { IOBCondition.self, BatteryCondition.self, BatteryDropCondition.self, + FutureCarbsCondition.self, ] ) { var dict = [AlarmType: AlarmCondition]() diff --git a/LoopFollow/Alarm/AlarmType/AlarmType+Snooze.swift b/LoopFollow/Alarm/AlarmType/AlarmType+Snooze.swift index e242226cd..134e1fb5b 100644 --- a/LoopFollow/Alarm/AlarmType/AlarmType+Snooze.swift +++ b/LoopFollow/Alarm/AlarmType/AlarmType+Snooze.swift @@ -11,7 +11,7 @@ extension AlarmType { return .day case .low, .high, .fastDrop, .fastRise, .missedReading, .notLooping, .missedBolus, - .recBolus, + .futureCarbs, .recBolus, .overrideStart, .overrideEnd, .tempTargetStart, .tempTargetEnd: return .minute diff --git a/LoopFollow/Alarm/AlarmType/AlarmType+canAcknowledge.swift b/LoopFollow/Alarm/AlarmType/AlarmType+canAcknowledge.swift index 151dd3914..9f5f3b5d1 100644 --- a/LoopFollow/Alarm/AlarmType/AlarmType+canAcknowledge.swift +++ b/LoopFollow/Alarm/AlarmType/AlarmType+canAcknowledge.swift @@ -8,7 +8,7 @@ extension AlarmType { var canAcknowledge: Bool { switch self { // These are alarms that typically has a "memory", they will only alarm once and acknowledge them is fine - case .low, .high, .fastDrop, .fastRise, .temporary, .cob, .missedBolus, .recBolus, .overrideStart, .overrideEnd, .tempTargetStart, .tempTargetEnd: + case .low, .high, .fastDrop, .fastRise, .temporary, .cob, .missedBolus, .futureCarbs, .recBolus, .overrideStart, .overrideEnd, .tempTargetStart, .tempTargetEnd: return true // These are alarms without memory, if they only are acknowledged - they would alarm again immediately case diff --git a/LoopFollow/Alarm/AlarmType/AlarmType.swift b/LoopFollow/Alarm/AlarmType/AlarmType.swift index 7df7780a4..11a51885e 100644 --- a/LoopFollow/Alarm/AlarmType/AlarmType.swift +++ b/LoopFollow/Alarm/AlarmType/AlarmType.swift @@ -16,6 +16,7 @@ enum AlarmType: String, CaseIterable, Codable { case missedReading = "Missed Reading Alert" case notLooping = "Not Looping Alert" case missedBolus = "Missed Bolus Alert" + case futureCarbs = "Future Carbs Alert" case sensorChange = "Sensor Change Alert" case pumpChange = "Pump Change Alert" case pump = "Pump Insulin Alert" diff --git a/LoopFollow/Alarm/DataStructs/PendingFutureCarb.swift b/LoopFollow/Alarm/DataStructs/PendingFutureCarb.swift new file mode 100644 index 000000000..b28e9851a --- /dev/null +++ b/LoopFollow/Alarm/DataStructs/PendingFutureCarb.swift @@ -0,0 +1,17 @@ +// LoopFollow +// PendingFutureCarb.swift + +import Foundation + +/// Tracks a future-dated carb entry that has been observed but whose scheduled time +/// has not yet arrived. Used by `FutureCarbsCondition` to fire a reminder when it's time to eat. +struct PendingFutureCarb: Codable, Equatable { + /// Scheduled eating time (`timeIntervalSince1970`) + let carbDate: TimeInterval + + /// Grams of carbs (used together with `carbDate` to identify unique entries) + let grams: Double + + /// When the entry was first observed (`timeIntervalSince1970`, for staleness cleanup) + let observedAt: TimeInterval +} diff --git a/LoopFollow/Application/AppDelegate.swift b/LoopFollow/Application/AppDelegate.swift index a6fd9f2b9..09a433e41 100644 --- a/LoopFollow/Application/AppDelegate.swift +++ b/LoopFollow/Application/AppDelegate.swift @@ -40,6 +40,8 @@ class AppDelegate: UIResponder, UIApplicationDelegate { _ = BLEManager.shared // Ensure VolumeButtonHandler is initialized so it can receive alarm notifications _ = VolumeButtonHandler.shared + + WatchConnectivityManager.shared.activate() // Register for remote notifications DispatchQueue.main.async { @@ -48,12 +50,19 @@ class AppDelegate: UIResponder, UIApplicationDelegate { BackgroundRefreshManager.shared.register() - // Detect Before-First-Unlock launch. If protected data is unavailable here, - // StorageValues were cached from encrypted UserDefaults and need a reload - // on the first foreground after the user unlocks. - let bfu = !UIApplication.shared.isProtectedDataAvailable + // Detect Before-First-Unlock launch. isProtectedDataAvailable returns false + // for ANY locked-screen background launch, not only post-reboot. Standard + // UserDefaults use NSFileProtectionCompleteUntilFirstUserAuthentication — + // they stay readable after the first unlock even when the screen is locked. + // True BFU (boot before first unlock) is the only case where UserDefaults + // is actually inaccessible; in that state every StorageValue reads as its + // default — including migrationStep, which is always ≥ 1 for existing users. + // Guard against false positives by checking that migrationStep is still 0 + // (its default), meaning the real value couldn't be read from disk. + let protectedDataUnavailable = !UIApplication.shared.isProtectedDataAvailable + let bfu = protectedDataUnavailable && Storage.shared.migrationStep.value == 0 Storage.shared.needsBFUReload = bfu - LogManager.shared.log(category: .general, message: "BFU check: isProtectedDataAvailable=\(!bfu), needsBFUReload=\(bfu)") + LogManager.shared.log(category: .general, message: "BFU check: isProtectedDataAvailable=\(!protectedDataUnavailable), migrationStep=\(Storage.shared.migrationStep.value), needsBFUReload=\(bfu)") return true } diff --git a/LoopFollow/Controllers/Graphs.swift b/LoopFollow/Controllers/Graphs.swift index 0b272697f..0365122e0 100644 --- a/LoopFollow/Controllers/Graphs.swift +++ b/LoopFollow/Controllers/Graphs.swift @@ -321,13 +321,8 @@ extension MainViewController { lineBolus.drawCirclesEnabled = true lineBolus.drawFilledEnabled = false - if Storage.shared.showValues.value { - lineBolus.drawValuesEnabled = true - lineBolus.highlightEnabled = false - } else { - lineBolus.drawValuesEnabled = false - lineBolus.highlightEnabled = true - } + lineBolus.drawValuesEnabled = Storage.shared.showValues.value + lineBolus.highlightEnabled = true // Carbs let chartEntryCarbs = [ChartDataEntry]() @@ -347,13 +342,8 @@ extension MainViewController { lineCarbs.drawCirclesEnabled = true lineCarbs.drawFilledEnabled = false - if Storage.shared.showValues.value { - lineCarbs.drawValuesEnabled = true - lineCarbs.highlightEnabled = false - } else { - lineCarbs.drawValuesEnabled = false - lineCarbs.highlightEnabled = true - } + lineCarbs.drawValuesEnabled = Storage.shared.showValues.value + lineCarbs.highlightEnabled = true // create Scheduled Basal graph data let chartBasalScheduledEntry = [ChartDataEntry]() @@ -569,13 +559,8 @@ extension MainViewController { lineSmb.drawCirclesEnabled = false lineSmb.drawFilledEnabled = false - if Storage.shared.showValues.value { - lineSmb.drawValuesEnabled = true - lineSmb.highlightEnabled = false - } else { - lineSmb.drawValuesEnabled = false - lineSmb.highlightEnabled = true - } + lineSmb.drawValuesEnabled = Storage.shared.showValues.value + lineSmb.highlightEnabled = true // TempTarget graph data let chartTempTargetEntry = [ChartDataEntry]() @@ -1021,7 +1006,8 @@ extension MainViewController { let graphHours = 24 * Storage.shared.downloadDays.value if dateTimeStamp < dateTimeUtils.getTimeIntervalNHoursAgo(N: graphHours) { continue } - let dot = ChartDataEntry(x: Double(dateTimeStamp), y: Double(bolusData[i].sgv), data: formatter.string(from: NSNumber(value: bolusData[i].value))) + let valueString = formatter.string(from: NSNumber(value: bolusData[i].value)) ?? "" + let dot = ChartDataEntry(x: Double(dateTimeStamp), y: Double(bolusData[i].sgv), data: valueString + "\r\r" + formatPillText(line1: valueString + " U", time: bolusData[i].date)) mainChart.addEntry(dot) if Storage.shared.smallGraphTreatments.value { smallChart.addEntry(dot) @@ -1093,7 +1079,8 @@ extension MainViewController { let graphHours = 24 * Storage.shared.downloadDays.value if dateTimeStamp < dateTimeUtils.getTimeIntervalNHoursAgo(N: graphHours) { continue } - let dot = ChartDataEntry(x: Double(dateTimeStamp), y: Double(smbData[i].sgv), data: formatter.string(from: NSNumber(value: smbData[i].value))) + let valueString = formatter.string(from: NSNumber(value: smbData[i].value)) ?? "" + let dot = ChartDataEntry(x: Double(dateTimeStamp), y: Double(smbData[i].sgv), data: valueString + "\r\r" + formatPillText(line1: valueString + " U", time: smbData[i].date)) mainChart.addEntry(dot) if Storage.shared.smallGraphTreatments.value { smallChart.addEntry(dot) @@ -1146,7 +1133,7 @@ extension MainViewController { dateTimeStamp = dateTimeStamp - 250 } - let dot = ChartDataEntry(x: Double(dateTimeStamp), y: Double(carbData[i].sgv), data: valueString) + let dot = ChartDataEntry(x: Double(dateTimeStamp), y: Double(carbData[i].sgv), data: valueString + "\r\r" + formatPillText(line1: valueString + " g", time: carbData[i].date)) BGChart.data?.dataSets[dataIndex].addEntry(dot) if Storage.shared.smallGraphTreatments.value { BGChartFull.data?.dataSets[dataIndex].addEntry(dot) diff --git a/LoopFollow/Controllers/Nightscout/BGData.swift b/LoopFollow/Controllers/Nightscout/BGData.swift index c0721b8a4..958428dd1 100644 --- a/LoopFollow/Controllers/Nightscout/BGData.swift +++ b/LoopFollow/Controllers/Nightscout/BGData.swift @@ -262,6 +262,7 @@ extension MainViewController { // Live Activity storage Storage.shared.lastBgReadingTimeSeconds.value = lastBGTime + Storage.shared.lastBgMgdl.value = Double(latestBG) Storage.shared.lastDeltaMgdl.value = Double(deltaBG) Storage.shared.lastTrendCode.value = entries[latestEntryIndex].direction diff --git a/LoopFollow/Controllers/Nightscout/DeviceStatus.swift b/LoopFollow/Controllers/Nightscout/DeviceStatus.swift index ae3967b3e..6c6cac03f 100644 --- a/LoopFollow/Controllers/Nightscout/DeviceStatus.swift +++ b/LoopFollow/Controllers/Nightscout/DeviceStatus.swift @@ -127,6 +127,7 @@ extension MainViewController { let storedTime = Observable.shared.alertLastLoopTime.value ?? 0 if lastPumpTime > storedTime { Observable.shared.alertLastLoopTime.value = lastPumpTime + Storage.shared.lastLoopTime.value = lastPumpTime } if let reservoirData = lastPumpRecord["reservoir"] as? Double { diff --git a/LoopFollow/Controllers/Nightscout/DeviceStatusOpenAPS.swift b/LoopFollow/Controllers/Nightscout/DeviceStatusOpenAPS.swift index 20827c253..150e5da4f 100644 --- a/LoopFollow/Controllers/Nightscout/DeviceStatusOpenAPS.swift +++ b/LoopFollow/Controllers/Nightscout/DeviceStatusOpenAPS.swift @@ -117,6 +117,9 @@ extension MainViewController { if let eventualBGValue = enactedOrSuggested["eventualBG"] as? Double { let eventualBGQuantity = HKQuantity(unit: .milligramsPerDeciliter, doubleValue: eventualBGValue) PredictionLabel.text = Localizer.formatQuantity(eventualBGQuantity) + Storage.shared.projectedBgMgdl.value = eventualBGValue + } else { + Storage.shared.projectedBgMgdl.value = nil } // Target @@ -243,7 +246,6 @@ extension MainViewController { // Live Activity storage Storage.shared.lastIOB.value = latestIOB?.value Storage.shared.lastCOB.value = latestCOB?.value - Storage.shared.projectedBgMgdl.value = nil } } } diff --git a/LoopFollow/Controllers/Nightscout/Treatments.swift b/LoopFollow/Controllers/Nightscout/Treatments.swift index 8ff20df87..307a37e79 100644 --- a/LoopFollow/Controllers/Nightscout/Treatments.swift +++ b/LoopFollow/Controllers/Nightscout/Treatments.swift @@ -10,11 +10,11 @@ extension MainViewController { if !Storage.shared.downloadTreatments.value { return } let startTimeString = dateTimeUtils.getDateTimeString(addingDays: -1 * Storage.shared.downloadDays.value) - let currentTimeString = dateTimeUtils.getDateTimeString() + let endTimeString = dateTimeUtils.getDateTimeString(addingHours: 6) let estimatedCount = max(Storage.shared.downloadDays.value * 100, 5000) let parameters: [String: String] = [ "find[created_at][$gte]": startTimeString, - "find[created_at][$lte]": currentTimeString, + "find[created_at][$lte]": endTimeString, "count": "\(estimatedCount)", ] NightscoutUtils.executeDynamicRequest(eventType: .treatments, parameters: parameters) { (result: Result) in diff --git a/LoopFollow/Controllers/SpeakBG.swift b/LoopFollow/Controllers/SpeakBG.swift index 6f4c0c6b0..ee003ce49 100644 --- a/LoopFollow/Controllers/SpeakBG.swift +++ b/LoopFollow/Controllers/SpeakBG.swift @@ -73,6 +73,13 @@ extension MainViewController { static func forLanguage(_ language: String) -> AnnouncementTexts { switch language { + case "fr": + return AnnouncementTexts( + stable: "et c'est stable", + increase: "et c'est monté de", + decrease: "et c'est descendu de", + currentBGIs: "La glycémie est" + ) case "it": return AnnouncementTexts( stable: "ed è stabile", @@ -109,6 +116,7 @@ extension MainViewController { enum LanguageVoiceMapping { static let voiceLanguageMap: [String: String] = [ "en": "en-US", + "fr": "fr-FR", "it": "it-IT", "sk": "sk-SK", "sv": "sv-SE", diff --git a/LoopFollow/Helpers/Chart.swift b/LoopFollow/Helpers/Chart.swift index 8825be93c..2df0ae0d2 100644 --- a/LoopFollow/Helpers/Chart.swift +++ b/LoopFollow/Helpers/Chart.swift @@ -44,11 +44,12 @@ final class ChartXValueFormatter: AxisValueFormatter { final class ChartYDataValueFormatter: ValueFormatter { func stringForValue(_: Double, entry: ChartDataEntry, dataSetIndex _: Int, viewPortHandler _: ViewPortHandler?) -> String { - if entry.data != nil { - return entry.data as? String ?? "" - } else { - return "" + guard let text = entry.data as? String else { return "" } + // Treatment entries store "label\r\rpillText" — extract only the label portion. + if let range = text.range(of: "\r\r") { + return String(text[.. String { + static func getDateTimeString(addingMinutes minutes: Int? = nil, addingHours hours: Int? = nil, addingDays days: Int? = nil) -> String { let currentDate = Date() var date = currentDate + if let minutesToAdd = minutes { + date = Calendar.current.date(byAdding: .minute, value: minutesToAdd, to: date)! + } + if let hoursToAdd = hours { - date = Calendar.current.date(byAdding: .hour, value: hoursToAdd, to: currentDate)! + date = Calendar.current.date(byAdding: .hour, value: hoursToAdd, to: date)! } if let daysToAdd = days { - date = Calendar.current.date(byAdding: .day, value: daysToAdd, to: currentDate)! + date = Calendar.current.date(byAdding: .day, value: daysToAdd, to: date)! } let dateFormatter = DateFormatter() diff --git a/LoopFollow/LiveActivity/AppGroupID.swift b/LoopFollow/LiveActivity/AppGroupID.swift index 5eb1187b8..2ce09159c 100644 --- a/LoopFollow/LiveActivity/AppGroupID.swift +++ b/LoopFollow/LiveActivity/AppGroupID.swift @@ -58,6 +58,7 @@ enum AppGroupID { ".WidgetExtension", ".Widgets", ".WidgetsExtension", + ".watchkitapp", ".Watch", ".WatchExtension", ".CarPlay", diff --git a/LoopFollow/LiveActivity/GlucoseSnapshot.swift b/LoopFollow/LiveActivity/GlucoseSnapshot.swift index 8860391c2..c5f5fffcc 100644 --- a/LoopFollow/LiveActivity/GlucoseSnapshot.swift +++ b/LoopFollow/LiveActivity/GlucoseSnapshot.swift @@ -12,6 +12,14 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable { enum Unit: String, Codable, Hashable { case mgdl case mmol + + /// Human-readable display string for the unit (e.g. "mg/dL" or "mmol/L"). + var displayName: String { + switch self { + case .mgdl: return "mg/dL" + case .mmol: return "mmol/L" + } + } } // MARK: - Core Glucose diff --git a/LoopFollow/LiveActivity/GlucoseSnapshotStore.swift b/LoopFollow/LiveActivity/GlucoseSnapshotStore.swift index 7951e122a..17569c46f 100644 --- a/LoopFollow/LiveActivity/GlucoseSnapshotStore.swift +++ b/LoopFollow/LiveActivity/GlucoseSnapshotStore.swift @@ -2,6 +2,9 @@ // GlucoseSnapshotStore.swift import Foundation +import os.log + +private let storeLog = OSLog(subsystem: Bundle.main.bundleIdentifier ?? "com.loopfollow", category: "GlucoseSnapshotStore") /// Persists the latest GlucoseSnapshot into the App Group container so that: /// - the Live Activity extension can read it @@ -17,7 +20,7 @@ final class GlucoseSnapshotStore { // MARK: - Public API - func save(_ snapshot: GlucoseSnapshot) { + func save(_ snapshot: GlucoseSnapshot, completion: (() -> Void)? = nil) { queue.async { do { let url = try self.fileURL() @@ -25,23 +28,29 @@ final class GlucoseSnapshotStore { encoder.dateEncodingStrategy = .iso8601 let data = try encoder.encode(snapshot) try data.write(to: url, options: [.atomic]) + os_log("GlucoseSnapshotStore: saved snapshot g=%d to %{public}@", log: storeLog, type: .debug, Int(snapshot.glucose), url.lastPathComponent) } catch { - // Intentionally silent (extension-safe, no dependencies). + os_log("GlucoseSnapshotStore: save failed — %{public}@", log: storeLog, type: .error, error.localizedDescription) } + completion?() } } func load() -> GlucoseSnapshot? { do { let url = try fileURL() - guard FileManager.default.fileExists(atPath: url.path) else { return nil } + guard FileManager.default.fileExists(atPath: url.path) else { + os_log("GlucoseSnapshotStore: file not found at %{public}@", log: storeLog, type: .debug, url.lastPathComponent) + return nil + } let data = try Data(contentsOf: url) let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601 - return try decoder.decode(GlucoseSnapshot.self, from: data) + let snapshot = try decoder.decode(GlucoseSnapshot.self, from: data) + return snapshot } catch { - // Intentionally silent (extension-safe, no dependencies). + os_log("GlucoseSnapshotStore: load failed — %{public}@", log: storeLog, type: .error, error.localizedDescription) return nil } } diff --git a/LoopFollow/LiveActivity/LAAppGroupSettings.swift b/LoopFollow/LiveActivity/LAAppGroupSettings.swift index b61487f27..f7c4114d0 100644 --- a/LoopFollow/LiveActivity/LAAppGroupSettings.swift +++ b/LoopFollow/LiveActivity/LAAppGroupSettings.swift @@ -97,6 +97,15 @@ enum LiveActivitySlotOption: String, CaseIterable, Codable { } } + /// True when the value is a glucose measurement and should be followed by + /// the user's preferred unit label (mg/dL or mmol/L) in compact displays. + var isGlucoseUnit: Bool { + switch self { + case .projectedBG, .delta, .minMax, .target, .isf: return true + default: return false + } + } + /// True when the underlying value may be nil (e.g. Dexcom-only users who have /// no Loop data). The widget renders "—" in those cases. var isOptional: Bool { @@ -118,6 +127,8 @@ enum LiveActivitySlotDefaults { static let slot3: LiveActivitySlotOption = .projectedBG /// Bottom-right slot — intentionally empty until the user configures it static let slot4: LiveActivitySlotOption = .none + /// Small widget (CarPlay / Watch Smart Stack) right slot + static let smallWidgetSlot: LiveActivitySlotOption = .projectedBG static var all: [LiveActivitySlotOption] { [slot1, slot2, slot3, slot4] @@ -135,8 +146,11 @@ enum LAAppGroupSettings { static let lowLineMgdl = "la.lowLine.mgdl" static let highLineMgdl = "la.highLine.mgdl" static let slots = "la.slots" + static let smallWidgetSlot = "la.smallWidgetSlot" static let displayName = "la.displayName" static let showDisplayName = "la.showDisplayName" + static let watchSlots = "watch.slots" + static let watchSelectedSlots = "watch.selectedSlots" } private static var defaults: UserDefaults? { @@ -179,6 +193,55 @@ enum LAAppGroupSettings { return raw.map { LiveActivitySlotOption(rawValue: $0) ?? .none } } + // MARK: - Small widget slot (Write) + + static func setSmallWidgetSlot(_ slot: LiveActivitySlotOption) { + defaults?.set(slot.rawValue, forKey: Keys.smallWidgetSlot) + } + + // MARK: - Small widget slot (Read) + + static func smallWidgetSlot() -> LiveActivitySlotOption { + guard let raw = defaults?.string(forKey: Keys.smallWidgetSlot) else { + return LiveActivitySlotDefaults.smallWidgetSlot + } + return LiveActivitySlotOption(rawValue: raw) ?? LiveActivitySlotDefaults.smallWidgetSlot + } + + // MARK: - Watch slots (Write) + + /// Persists the 4-position Watch data card slot configuration. + static func setWatchSlots(_ slots: [LiveActivitySlotOption]) { + let raw = slots.prefix(4).map(\.rawValue) + defaults?.set(raw, forKey: Keys.watchSlots) + } + + // MARK: - Watch slots (Read) + + static func watchSlots() -> [LiveActivitySlotOption] { + guard let raw = defaults?.stringArray(forKey: Keys.watchSlots), raw.count == 4 else { + return [.iob, .cob, .projectedBG, .battery] + } + return raw.map { LiveActivitySlotOption(rawValue: $0) ?? .none } + } + + // MARK: - Watch selected slots (ordered, variable-length) + + /// Persists the user's ordered list of selected Watch data slots. + static func setWatchSelectedSlots(_ slots: [LiveActivitySlotOption]) { + defaults?.set(slots.map(\.rawValue), forKey: Keys.watchSelectedSlots) + } + + /// Returns the ordered list of selected Watch data slots. + /// Falls back to a sensible default if nothing is saved. + static func watchSelectedSlots() -> [LiveActivitySlotOption] { + guard let raw = defaults?.stringArray(forKey: Keys.watchSelectedSlots) else { + return [.iob, .cob, .projectedBG, .battery] + } + return raw.compactMap { LiveActivitySlotOption(rawValue: $0) } + } + + // MARK: - Display Name static func setDisplayName(_ name: String, show: Bool) { diff --git a/LoopFollow/LiveActivity/LALivenessMarker.swift b/LoopFollow/LiveActivity/LALivenessMarker.swift new file mode 100644 index 000000000..62da0a8a6 --- /dev/null +++ b/LoopFollow/LiveActivity/LALivenessMarker.swift @@ -0,0 +1,27 @@ +// +// LALivenessMarker.swift +// LoopFollow +// +// Created by Philippe Achkar on 2026-04-01. +// Copyright © 2026 Jon Fawcett. All rights reserved. +// + + +import SwiftUI + +struct LALivenessMarker: View { + let seq: Int + let producedAt: Date + + var body: some View { + Color.clear + .frame(width: 0, height: 0) + .task(id: markerID) { + LALivenessStore.markExtensionRender(seq: seq, producedAt: producedAt) + } + } + + private var markerID: String { + "\(seq)-\(producedAt.timeIntervalSince1970)" + } +} \ No newline at end of file diff --git a/LoopFollow/LiveActivity/LALivenessStore.swift b/LoopFollow/LiveActivity/LALivenessStore.swift new file mode 100644 index 000000000..6bb552395 --- /dev/null +++ b/LoopFollow/LiveActivity/LALivenessStore.swift @@ -0,0 +1,44 @@ +// +// LALivenessStore.swift +// LoopFollow +// +// Created by Philippe Achkar on 2026-04-01. +// Copyright © 2026 Jon Fawcett. All rights reserved. +// + + +import Foundation + +enum LALivenessStore { + private static let defaults = UserDefaults(suiteName: AppGroupID.baseBundleID) + + private enum Key { + static let lastExtensionSeenAt = "la.liveness.lastExtensionSeenAt" + static let lastExtensionSeq = "la.liveness.lastExtensionSeq" + static let lastExtensionProducedAt = "la.liveness.lastExtensionProducedAt" + } + + static func markExtensionRender(seq: Int, producedAt: Date) { + defaults?.set(Date().timeIntervalSince1970, forKey: Key.lastExtensionSeenAt) + defaults?.set(seq, forKey: Key.lastExtensionSeq) + defaults?.set(producedAt.timeIntervalSince1970, forKey: Key.lastExtensionProducedAt) + } + + static var lastExtensionSeenAt: TimeInterval { + defaults?.double(forKey: Key.lastExtensionSeenAt) ?? 0 + } + + static var lastExtensionSeq: Int { + defaults?.integer(forKey: Key.lastExtensionSeq) ?? 0 + } + + static var lastExtensionProducedAt: TimeInterval { + defaults?.double(forKey: Key.lastExtensionProducedAt) ?? 0 + } + + static func clear() { + defaults?.removeObject(forKey: Key.lastExtensionSeenAt) + defaults?.removeObject(forKey: Key.lastExtensionSeq) + defaults?.removeObject(forKey: Key.lastExtensionProducedAt) + } +} diff --git a/LoopFollow/LiveActivity/LiveActivityManager.swift b/LoopFollow/LiveActivity/LiveActivityManager.swift index 746e5609d..5a7ef6de4 100644 --- a/LoopFollow/LiveActivity/LiveActivityManager.swift +++ b/LoopFollow/LiveActivity/LiveActivityManager.swift @@ -87,6 +87,12 @@ final class LiveActivityManager { @objc private func handleDidBecomeActive() { guard Storage.shared.laEnabled.value else { return } + if skipNextDidBecomeActive { + LogManager.shared.log(category: .general, message: "[LA] didBecomeActive: skipped (handleForeground owns restart)", isDebug: true) + skipNextDidBecomeActive = false + return + } + LogManager.shared.log(category: .general, message: "[LA] didBecomeActive: calling startFromCurrentState, dismissedByUser=\(dismissedByUser)", isDebug: true) Task { @MainActor in self.startFromCurrentState() } @@ -100,24 +106,31 @@ final class LiveActivityManager { let now = Date().timeIntervalSince1970 let overlayIsShowing = renewBy > 0 && now >= renewBy - LiveActivityManager.renewalWarning - LogManager.shared.log(category: .general, message: "[LA] foreground notification received, laRenewalFailed=\(renewalFailed), overlayShowing=\(overlayIsShowing)") - guard renewalFailed || overlayIsShowing else { return } - - // Overlay is showing or renewal previously failed — end the stale LA and start a fresh one. - // We cannot call startIfNeeded() here: it finds the existing activity in - // Activity.activities and reuses it rather than replacing it. - LogManager.shared.log(category: .general, message: "[LA] ending stale LA and restarting (renewalFailed=\(renewalFailed), overlayShowing=\(overlayIsShowing))") - // Clear state synchronously so any snapshot built between now and when the - // new LA is started computes showRenewalOverlay = false. - Storage.shared.laRenewBy.value = 0 - Storage.shared.laRenewalFailed.value = false + LogManager.shared.log( + category: .general, + message: "[LA] foreground: renewalFailed=\(renewalFailed), overlayShowing=\(overlayIsShowing), current=\(current?.id ?? "nil"), dismissedByUser=\(dismissedByUser), renewBy=\(renewBy), now=\(now)" + ) - guard let activity = current else { - startFromCurrentState() + guard renewalFailed || overlayIsShowing else { + LogManager.shared.log(category: .general, message: "[LA] foreground: no action needed (not in renewal window)") return } - current = nil + LogManager.shared.log( + category: .general, + message: "[LA] ending stale LA and restarting (renewalFailed=\(renewalFailed), overlayShowing=\(overlayIsShowing))" + ) + + skipNextDidBecomeActive = true + + // Mark restart intent BEFORE clearing storage flags, so any late .dismissed + // from the old activity is never misclassified as a user swipe. + endingForRestart = true + dismissedByUser = false + + // Stop any observers/tasks tied to the previous activity instance. In the + // current=nil branch below, the old observer can otherwise deliver a late + // .dismissed and poison dismissedByUser. updateTask?.cancel() updateTask = nil tokenObservationTask?.cancel() @@ -126,21 +139,46 @@ final class LiveActivityManager { stateObserverTask = nil pushToken = nil + // Clear renewal state so the new snapshot does not show the renewal overlay. + Storage.shared.laRenewBy.value = 0 + Storage.shared.laRenewalFailed.value = false + cancelRenewalFailedNotification() + + guard let activity = current else { + LogManager.shared.log( + category: .general, + message: "[LA] foreground restart: current=nil (old activity not bound locally), ending all existing LAs before restart" + ) + current = nil + + Task { + for activity in Activity.activities { + await activity.end(nil, dismissalPolicy: .immediate) + } + await MainActor.run { + self.dismissedByUser = false + self.startFromCurrentState(cleanupOrphans: false) + LogManager.shared.log( + category: .general, + message: "[LA] foreground restart: fresh LA started after ending unbound existing activity" + ) + } + } + return + } + + current = nil + Task { - // Await end so the activity is removed from Activity.activities before - // startIfNeeded() runs — otherwise it hits the reuse path and skips - // writing a new laRenewBy deadline. await activity.end(nil, dismissalPolicy: .immediate) await MainActor.run { - // startFromCurrentState rebuilds the snapshot (showRenewalOverlay = false - // since laRenewBy is 0), saves it to the store, then calls startIfNeeded() - // which finds no existing activity and requests a fresh LA with a new deadline. - self.startFromCurrentState() + self.dismissedByUser = false + self.startFromCurrentState(cleanupOrphans: false) LogManager.shared.log(category: .general, message: "[LA] Live Activity restarted after foreground retry") } } } - + @objc private func handleBackgroundAudioFailed() { guard Storage.shared.laEnabled.value, current != nil else { return } // The background audio session has permanently failed — the app will lose its @@ -150,9 +188,48 @@ final class LiveActivityManager { Storage.shared.laRenewBy.value = Date().timeIntervalSince1970 refreshFromCurrentState(reason: "audio-session-failed") } + + private func shouldRestartBecauseExtensionLooksStuck() -> Bool { + guard Storage.shared.laEnabled.value else { return false } + guard !dismissedByUser else { return false } + + guard let activity = current ?? Activity.activities.first else { + return false + } + + let now = Date().timeIntervalSince1970 + let staleDatePassed = activity.content.staleDate.map { $0 <= Date() } ?? false + if staleDatePassed { + LogManager.shared.log( + category: .general, + message: "[LA] liveness check: staleDate already passed" + ) + return true + } + + let expectedSeq = activity.content.state.seq + let seenSeq = LALivenessStore.lastExtensionSeq + let lastSeenAt = LALivenessStore.lastExtensionSeenAt + let lastProducedAt = LALivenessStore.lastExtensionProducedAt + + let extensionHasNeverCheckedIn = lastSeenAt <= 0 + let extensionLooksBehind = seenSeq < expectedSeq + let noRecentExtensionTouch = extensionHasNeverCheckedIn || (now - lastSeenAt > LiveActivityManager.extensionLivenessGrace) + + LogManager.shared.log( + category: .general, + message: "[LA] liveness check: expectedSeq=\(expectedSeq), seenSeq=\(seenSeq), lastSeenAt=\(lastSeenAt), lastProducedAt=\(lastProducedAt), behind=\(extensionLooksBehind), noRecentTouch=\(noRecentExtensionTouch)", + isDebug: true + ) + + // Conservative rule: + // only suspect "stuck" if the extension is both behind AND has not checked in recently. + return extensionLooksBehind && noRecentExtensionTouch + } static let renewalThreshold: TimeInterval = 7.5 * 3600 - static let renewalWarning: TimeInterval = 20 * 60 + static let renewalWarning: TimeInterval = 30 * 60 + static let extensionLivenessGrace: TimeInterval = 15 * 60 private(set) var current: Activity? private var stateObserverTask: Task? @@ -167,6 +244,14 @@ final class LiveActivityManager { /// In-memory only — resets to false on app relaunch, so a kill + relaunch /// starts fresh as expected. private var dismissedByUser = false + /// Set to true immediately before we call activity.end() as part of a planned restart. + /// Cleared after the restart completes. The state observer checks this flag so that + /// a .dismissed delivery triggered by our own end() call is never misclassified as a + /// user swipe — regardless of the order in which the MainActor executes the two writes. + private var endingForRestart = false + /// Set by handleForeground() when it takes ownership of the restart sequence. + /// Prevents handleDidBecomeActive() from racing with an in-flight end+restart. + private var skipNextDidBecomeActive = false // MARK: - Public API @@ -177,6 +262,36 @@ final class LiveActivityManager { } if let existing = Activity.activities.first { + // Before reusing, check whether this activity needs a restart. This covers cold + // starts (app was killed while the overlay was showing — willEnterForeground is + // never sent, so handleForeground never runs) and any other path that lands here + // without first going through handleForeground. + let renewBy = Storage.shared.laRenewBy.value + let now = Date().timeIntervalSince1970 + let staleDatePassed = existing.content.staleDate.map { $0 <= Date() } ?? false + let inRenewalWindow = renewBy > 0 && now >= renewBy - LiveActivityManager.renewalWarning + let needsRestart = Storage.shared.laRenewalFailed.value || inRenewalWindow || staleDatePassed + + if needsRestart { + LogManager.shared.log( + category: .general, + message: "[LA] existing activity is stale on startIfNeeded — ending and restarting (staleDatePassed=\(staleDatePassed), inRenewalWindow=\(inRenewalWindow))" + ) + + endingForRestart = true + dismissedByUser = false + + Storage.shared.laRenewBy.value = 0 + Storage.shared.laRenewalFailed.value = false + cancelRenewalFailedNotification() + + Task { + await existing.end(nil, dismissalPolicy: .immediate) + await MainActor.run { self.startIfNeeded() } + } + return + } + bind(to: existing, logReason: "reuse") Storage.shared.laRenewalFailed.value = false return @@ -212,6 +327,7 @@ final class LiveActivityManager { let renewDeadline = Date().addingTimeInterval(LiveActivityManager.renewalThreshold) let content = ActivityContent(state: initialState, staleDate: renewDeadline) + LALivenessStore.clear() let activity = try Activity.request(attributes: attributes, content: content, pushType: .token) bind(to: activity, logReason: "start-new") @@ -230,6 +346,7 @@ final class LiveActivityManager { guard let activity = current else { return } current = nil Storage.shared.laRenewBy.value = 0 + LALivenessStore.clear() let semaphore = DispatchSemaphore(value: 0) Task.detached { await activity.end(nil, dismissalPolicy: .immediate) @@ -271,6 +388,7 @@ final class LiveActivityManager { if current?.id == activity.id { current = nil Storage.shared.laRenewBy.value = 0 + LALivenessStore.clear() } } } @@ -284,6 +402,7 @@ final class LiveActivityManager { dismissedByUser = false Storage.shared.laRenewBy.value = 0 Storage.shared.laRenewalFailed.value = false + LALivenessStore.clear() cancelRenewalFailedNotification() current = nil updateTask?.cancel(); updateTask = nil @@ -295,15 +414,19 @@ final class LiveActivityManager { await activity.end(nil, dismissalPolicy: .immediate) } await MainActor.run { - self.startFromCurrentState() + self.startFromCurrentState(cleanupOrphans: false) LogManager.shared.log(category: .general, message: "[LA] forceRestart: Live Activity restarted") } } } - func startFromCurrentState() { + func startFromCurrentState(cleanupOrphans: Bool = false) { guard Storage.shared.laEnabled.value, !dismissedByUser else { return } - endOrphanedActivities() + + if cleanupOrphans { + endOrphanedActivities() + } + let provider = StorageCurrentGlucoseStateProvider() if let snapshot = GlucoseSnapshotBuilder.build(from: provider) { LAAppGroupSettings.setThresholds( @@ -320,7 +443,8 @@ final class LiveActivityManager { } func refreshFromCurrentState(reason: String) { - guard Storage.shared.laEnabled.value, !dismissedByUser else { return } + // No LA guard here — Watch and store must update regardless of LA state. + // LA-specific gating (laEnabled, dismissedByUser) is applied inside performRefresh. refreshWorkItem?.cancel() let workItem = DispatchWorkItem { [weak self] in self?.performRefresh(reason: reason) @@ -421,14 +545,20 @@ final class LiveActivityManager { let now = Date() let timeSinceLastUpdate = now.timeIntervalSince(lastUpdateTime ?? .distantPast) let forceRefreshNeeded = timeSinceLastUpdate >= 5 * 60 - if let previous = GlucoseSnapshotStore.shared.load(), previous == snapshot, !forceRefreshNeeded { - return - } + // Capture dedup result BEFORE saving so the store comparison is valid. + let snapshotUnchanged = GlucoseSnapshotStore.shared.load() == snapshot + + // Store + Watch: always update, independent of LA state. LAAppGroupSettings.setThresholds( lowMgdl: Storage.shared.lowLine.value, highMgdl: Storage.shared.highLine.value, ) GlucoseSnapshotStore.shared.save(snapshot) + WatchConnectivityManager.shared.send(snapshot: snapshot) + + // LA update: gated on LA being active, snapshot having changed, and activities enabled. + guard Storage.shared.laEnabled.value, !dismissedByUser else { return } + guard !snapshotUnchanged || forceRefreshNeeded else { return } guard ActivityAuthorizationInfo().areActivitiesEnabled else { return } @@ -538,6 +668,8 @@ final class LiveActivityManager { private func bind(to activity: Activity, logReason: String) { if current?.id == activity.id { return } current = activity + dismissedByUser = false + endingForRestart = false attachStateObserver(to: activity) LogManager.shared.log(category: .general, message: "Live Activity bound id=\(activity.id) (\(logReason))", isDebug: true) observePushToken(for: activity) @@ -596,20 +728,50 @@ final class LiveActivityManager { if state == .ended || state == .dismissed { if current?.id == activity.id { current = nil - Storage.shared.laRenewBy.value = 0 - LogManager.shared.log(category: .general, message: "Live Activity cleared id=\(activity.id)", isDebug: true) + // Do NOT clear laRenewBy here. Preserving it means handleForeground() + // can detect the renewal window on the next foreground event and restart + // automatically — whether the LA ended normally (.ended) or was + // system-dismissed (.dismissed). laRenewBy is only set to 0 when: + // • the user explicitly swipes (below) — renewal intent cancelled + // • a new LA starts (startIfNeeded writes the new deadline) + // • handleForeground() clears it synchronously before restarting + // • the user disables LA or calls forceRestart + LogManager.shared.log(category: .general, message: "[LA] activity cleared id=\(activity.id) state=\(state)", isDebug: true) } if state == .dismissed { - if Storage.shared.laRenewalFailed.value { - // iOS force-dismissed after 8-hour limit with a failed renewal. - // Allow auto-restart when the user opens the app. - LogManager.shared.log(category: .general, message: "Live Activity dismissed by iOS after expiry — auto-restart enabled") + // Three possible sources of .dismissed — only the third blocks restart: + // + // (a) endingForRestart: our own end() during a planned restart. + // Must be checked first: handleForeground() clears laRenewalFailed + // and laRenewBy synchronously before calling end(), so those flags + // would read as "no problem" even though we initiated the dismissal. + // + // (b) iOS system force-dismiss: either laRenewalFailed is set (our 8-hour + // renewal logic marked it) or the renewal deadline has already passed + // (laRenewBy > 0 && now >= laRenewBy). In both cases iOS acted, not + // the user. laRenewBy is preserved so handleForeground() restarts on + // the next foreground. + // + // (c) User decision: the user explicitly swiped the LA away. Block + // auto-restart until forceRestart() is called. Clear laRenewBy so + // handleForeground() does NOT re-enter the renewal path on the next + // foreground — the renewal intent is cancelled by the user's choice. + let now = Date().timeIntervalSince1970 + let renewBy = Storage.shared.laRenewBy.value + let renewalFailed = Storage.shared.laRenewalFailed.value + let pastDeadline = renewBy > 0 && now >= renewBy + LogManager.shared.log(category: .general, message: "[LA] .dismissed: endingForRestart=\(endingForRestart), renewalFailed=\(renewalFailed), pastDeadline=\(pastDeadline), renewBy=\(renewBy), now=\(now)") + if endingForRestart { + // (a) Our own restart — do nothing, Task handles the rest. + LogManager.shared.log(category: .general, message: "[LA] dismissed by self (endingForRestart) — restart in-flight, no action") + } else if renewalFailed || pastDeadline { + // (b) iOS system force-dismiss — allow auto-restart on next foreground. + LogManager.shared.log(category: .general, message: "[LA] dismissed by iOS (renewalFailed=\(renewalFailed), pastDeadline=\(pastDeadline)) — auto-restart on next foreground") } else { - // User manually swiped away the LA. Block auto-restart until - // the user explicitly restarts via button or App Intent. - // laEnabled is left true — the user's preference is preserved. + // (c) User decision — cancel renewal intent, block auto-restart. dismissedByUser = true - LogManager.shared.log(category: .general, message: "Live Activity dismissed by user — auto-restart blocked until explicit restart") + Storage.shared.laRenewBy.value = 0 + LogManager.shared.log(category: .general, message: "[LA] dismissed by USER (renewBy=\(renewBy), now=\(now)) — laRenewBy cleared, auto-restart BLOCKED until forceRestart") } } } diff --git a/LoopFollow/LiveActivity/StorageCurrentGlucoseStateProvider.swift b/LoopFollow/LiveActivity/StorageCurrentGlucoseStateProvider.swift index b1a416b97..a5984ee12 100644 --- a/LoopFollow/LiveActivity/StorageCurrentGlucoseStateProvider.swift +++ b/LoopFollow/LiveActivity/StorageCurrentGlucoseStateProvider.swift @@ -10,8 +10,8 @@ struct StorageCurrentGlucoseStateProvider: CurrentGlucoseStateProviding { // MARK: - Core Glucose var glucoseMgdl: Double? { - guard let bg = Observable.shared.bg.value, bg > 0 else { return nil } - return Double(bg) + guard let bg = Storage.shared.lastBgMgdl.value, bg > 0 else { return nil } + return bg } var deltaMgdl: Double? { @@ -123,7 +123,9 @@ struct StorageCurrentGlucoseStateProvider: CurrentGlucoseStateProviding { // MARK: - Loop Status var isNotLooping: Bool { - Observable.shared.isNotLooping.value + let lastLoopTime = Storage.shared.lastLoopTime.value + guard lastLoopTime > 0, !Storage.shared.url.value.isEmpty else { return false } + return Date().timeIntervalSince1970 - lastLoopTime >= 15 * 60 } // MARK: - Renewal diff --git a/LoopFollow/LiveActivitySettingsView.swift b/LoopFollow/LiveActivitySettingsView.swift index efe4ec321..2264881bb 100644 --- a/LoopFollow/LiveActivitySettingsView.swift +++ b/LoopFollow/LiveActivitySettingsView.swift @@ -7,6 +7,7 @@ struct LiveActivitySettingsView: View { @State private var laEnabled: Bool = Storage.shared.laEnabled.value @State private var restartConfirmed = false @State private var slots: [LiveActivitySlotOption] = LAAppGroupSettings.slots() + @State private var smallWidgetSlot: LiveActivitySlotOption = LAAppGroupSettings.smallWidgetSlot() private let slotLabels = ["Top left", "Top right", "Bottom left", "Bottom right"] @@ -29,7 +30,7 @@ struct LiveActivitySettingsView: View { } } - Section(header: Text("Grid slots")) { + Section(header: Text("Grid Slots - Live Activity")) { ForEach(0 ..< 4, id: \.self) { index in Picker(slotLabels[index], selection: Binding( get: { slots[index] }, @@ -41,6 +42,21 @@ struct LiveActivitySettingsView: View { } } } + + Section(header: Text("Grid Slot - CarPlay / Watch")) { + Picker("Right slot", selection: Binding( + get: { smallWidgetSlot }, + set: { newValue in + smallWidgetSlot = newValue + LAAppGroupSettings.setSmallWidgetSlot(newValue) + LiveActivityManager.shared.refreshFromCurrentState(reason: "small widget slot changed") + } + )) { + ForEach(LiveActivitySlotOption.allCases, id: \.self) { option in + Text(option.displayName).tag(option) + } + } + } } .onReceive(Storage.shared.laEnabled.$value) { newValue in if newValue != laEnabled { laEnabled = newValue } diff --git a/LoopFollow/Log/LogManager.swift b/LoopFollow/Log/LogManager.swift index 22403cd0f..545aa586f 100644 --- a/LoopFollow/Log/LogManager.swift +++ b/LoopFollow/Log/LogManager.swift @@ -29,6 +29,7 @@ class LogManager { case calendar = "Calendar" case deviceStatus = "Device Status" case remote = "Remote" + case watch = "Watch" } init() { diff --git a/LoopFollow/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json b/LoopFollow/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json index c928251a4..500362e57 100644 --- a/LoopFollow/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/LoopFollow/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -1,41 +1,46 @@ { - "images" : [ + "images" : [ + { + "filename" : "1024.png", + "idiom" : "universal", + "platform" : "ios", + "scale" : "1x", + "size" : "1024x1024" + }, + { + "appearances" : [ { - "filename" : "1024.png", - "idiom" : "universal", - "platform" : "ios", - "scale" : "1x", - "size" : "1024x1024" - }, - { - "appearances" : [ - { - "appearance" : "luminosity", - "value" : "dark" - } - ], - "filename" : "1024-dark.png", - "idiom" : "universal", - "platform" : "ios", - "scale" : "1x", - "size" : "1024x1024" - }, + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "1024-dark.png", + "idiom" : "universal", + "platform" : "ios", + "scale" : "1x", + "size" : "1024x1024" + }, + { + "appearances" : [ { - "appearances" : [ - { - "appearance" : "luminosity", - "value" : "tinted" - } - ], - "filename" : "1024-tinted.png", - "idiom" : "universal", - "platform" : "ios", - "scale" : "1x", - "size" : "1024x1024" + "appearance" : "luminosity", + "value" : "tinted" } - ], - "info" : { - "author" : "xcode", - "version" : 1 + ], + "filename" : "1024-tinted.png", + "idiom" : "universal", + "platform" : "ios", + "scale" : "1x", + "size" : "1024x1024" + }, + { + "idiom" : "universal", + "platform" : "watchos", + "size" : "1024x1024" } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } } diff --git a/LoopFollow/Settings/GeneralSettingsView.swift b/LoopFollow/Settings/GeneralSettingsView.swift index 033e19953..93b7c8f4f 100644 --- a/LoopFollow/Settings/GeneralSettingsView.swift +++ b/LoopFollow/Settings/GeneralSettingsView.swift @@ -81,6 +81,7 @@ struct GeneralSettingsView: View { if speakBG.value { Picker("Language", selection: $speakLanguage.value) { Text("English").tag("en") + Text("French").tag("fr") Text("Italian").tag("it") Text("Slovak").tag("sk") Text("Swedish").tag("sv") diff --git a/LoopFollow/Settings/ImportExport/AlarmSelectionView.swift b/LoopFollow/Settings/ImportExport/AlarmSelectionView.swift index f69d83181..3574eb2b2 100644 --- a/LoopFollow/Settings/ImportExport/AlarmSelectionView.swift +++ b/LoopFollow/Settings/ImportExport/AlarmSelectionView.swift @@ -213,6 +213,8 @@ struct AlarmSelectionRow: View { return "Not Looping Alert" case .missedBolus: return "Missed Bolus Alert" + case .futureCarbs: + return "Future Carbs Alert" case .sensorChange: return "Sensor Change Alert" case .pumpChange: diff --git a/LoopFollow/Settings/SettingsMenuView.swift b/LoopFollow/Settings/SettingsMenuView.swift index 4668db6bf..80ae07f16 100644 --- a/LoopFollow/Settings/SettingsMenuView.swift +++ b/LoopFollow/Settings/SettingsMenuView.swift @@ -211,7 +211,7 @@ struct AggregatedStatsViewWrapper: View { var body: some View { Group { if let mainVC = mainViewController { - AggregatedStatsView(viewModel: AggregatedStatsViewModel(mainViewController: mainVC)) + AggregatedStatsContentView(mainViewController: mainVC) } else { Text("Loading stats...") .onAppear { diff --git a/LoopFollow/Stats/AggregatedStatsView.swift b/LoopFollow/Stats/AggregatedStatsView.swift index fd338c3e0..35dfeb29d 100644 --- a/LoopFollow/Stats/AggregatedStatsView.swift +++ b/LoopFollow/Stats/AggregatedStatsView.swift @@ -7,6 +7,7 @@ import UIKit struct AggregatedStatsView: View { @ObservedObject var viewModel: AggregatedStatsViewModel @Environment(\.dismiss) var dismiss + var onDismiss: (() -> Void)? @State private var showGMI: Bool @State private var showStdDev: Bool @State private var startDate: Date @@ -17,8 +18,9 @@ struct AggregatedStatsView: View { @State private var loadingTimer: Timer? @State private var timeoutTimer: Timer? - init(viewModel: AggregatedStatsViewModel) { + init(viewModel: AggregatedStatsViewModel, onDismiss: (() -> Void)? = nil) { self.viewModel = viewModel + self.onDismiss = onDismiss _showGMI = State(initialValue: Storage.shared.showGMI.value) _showStdDev = State(initialValue: Storage.shared.showStdDev.value) @@ -105,6 +107,11 @@ struct AggregatedStatsView: View { } .navigationBarTitleDisplayMode(.inline) .toolbar { + if let onDismiss { + ToolbarItem(placement: .navigationBarLeading) { + Button("Done", action: onDismiss) + } + } ToolbarItem(placement: .navigationBarTrailing) { Button("Refresh") { loadingError = false @@ -163,6 +170,38 @@ struct AggregatedStatsView: View { } } +struct AggregatedStatsContentView: View { + @StateObject private var viewModel: AggregatedStatsViewModel + private let onDismiss: (() -> Void)? + + init(mainViewController: MainViewController?, onDismiss: (() -> Void)? = nil) { + _viewModel = StateObject(wrappedValue: AggregatedStatsViewModel(mainViewController: mainViewController)) + self.onDismiss = onDismiss + } + + var body: some View { + AggregatedStatsView(viewModel: viewModel, onDismiss: onDismiss) + .preferredColorScheme(Storage.shared.appearanceMode.value.colorScheme) + } +} + +struct AggregatedStatsModalView: View { + @Environment(\.dismiss) private var dismiss + + let mainViewController: MainViewController? + + var body: some View { + NavigationView { + AggregatedStatsContentView( + mainViewController: mainViewController, + onDismiss: { dismiss() } + ) + .navigationBarTitleDisplayMode(.inline) + } + .preferredColorScheme(Storage.shared.appearanceMode.value.colorScheme) + } +} + struct StatCard: View { let title: String let value: String diff --git a/LoopFollow/Stats/GRI/GRIRiskGridView.swift b/LoopFollow/Stats/GRI/GRIRiskGridView.swift index 31559b4fd..8ff7e763b 100644 --- a/LoopFollow/Stats/GRI/GRIRiskGridView.swift +++ b/LoopFollow/Stats/GRI/GRIRiskGridView.swift @@ -52,6 +52,9 @@ struct GRIRiskGridView: UIViewRepresentable { chartView.data = nil + let xHypoValue = hypoComponent + let yHyperValue = hyperComponent + var zoneAEntries: [ChartDataEntry] = [] var zoneBEntries: [ChartDataEntry] = [] var zoneCEntries: [ChartDataEntry] = [] @@ -106,7 +109,7 @@ struct GRIRiskGridView: UIViewRepresentable { zoneEDataSet.scatterShapeSize = 4 zoneEDataSet.drawValuesEnabled = false - let currentPoint = ChartDataEntry(x: hypoComponent, y: hyperComponent) + let currentPoint = ChartDataEntry(x: xHypoValue, y: yHyperValue) let currentDataSet = ScatterChartDataSet(entries: [currentPoint], label: "Current GRI") currentDataSet.setColor(NSUIColor.label) currentDataSet.scatterShapeSize = 12 diff --git a/LoopFollow/Stats/GRI/GRIView.swift b/LoopFollow/Stats/GRI/GRIView.swift index ba8606040..cd6889008 100644 --- a/LoopFollow/Stats/GRI/GRIView.swift +++ b/LoopFollow/Stats/GRI/GRIView.swift @@ -7,6 +7,7 @@ struct GRIView: View { @ObservedObject var viewModel: GRIViewModel private let legendColumns = [GridItem(.adaptive(minimum: 90), spacing: 12, alignment: .leading)] + private let yAxisLabelInset: CGFloat = 24 var body: some View { VStack(alignment: .leading, spacing: 8) { @@ -34,22 +35,35 @@ struct GRIView: View { } if let hypo = viewModel.griHypoComponent, let hyper = viewModel.griHyperComponent { - GRIRiskGridView( - hypoComponent: hypo, - hyperComponent: hyper, - gri: viewModel.gri ?? 0 - ) - .frame(height: 250) - .allowsHitTesting(false) - .clipped() - HStack { - Text("Hypoglycemia Component (%)") - .font(.caption2) - .foregroundColor(.secondary) - Spacer() - Text("Hyperglycemia Component (%)") - .font(.caption2) - .foregroundColor(.secondary) + VStack(alignment: .leading, spacing: 6) { + ZStack(alignment: .leading) { + GRIRiskGridView( + hypoComponent: hypo, + hyperComponent: hyper, + gri: viewModel.gri ?? 0 + ) + .frame(height: 250) + .padding(.leading, yAxisLabelInset) + .allowsHitTesting(false) + .clipped() + + Text("Hyperglycemia Component (%)") + .font(.caption2) + .foregroundColor(.secondary) + .rotationEffect(.degrees(-90)) + .fixedSize() + .frame(width: yAxisLabelInset) + } + + HStack(spacing: 0) { + Spacer() + .frame(width: yAxisLabelInset) + + Text("Hypoglycemia Component (%)") + .font(.caption2) + .foregroundColor(.secondary) + .frame(maxWidth: .infinity, alignment: .center) + } } LazyVGrid(columns: legendColumns, alignment: .leading, spacing: 8) { diff --git a/LoopFollow/Storage/Storage.swift b/LoopFollow/Storage/Storage.swift index 6e24b3788..7ab8b82c7 100644 --- a/LoopFollow/Storage/Storage.swift +++ b/LoopFollow/Storage/Storage.swift @@ -59,6 +59,7 @@ class Storage { var lastRecBolusNotified = StorageValue(key: "lastRecBolusNotified", defaultValue: nil) var lastCOBNotified = StorageValue(key: "lastCOBNotified", defaultValue: nil) var lastMissedBolusNotified = StorageValue(key: "lastMissedBolusNotified", defaultValue: nil) + var pendingFutureCarbs = StorageValue<[PendingFutureCarb]>(key: "pendingFutureCarbs", defaultValue: []) // General Settings [BEGIN] var appBadge = StorageValue(key: "appBadge", defaultValue: true) @@ -87,6 +88,7 @@ class Storage { var lastBgReadingTimeSeconds = StorageValue(key: "lastBgReadingTimeSeconds", defaultValue: nil) var lastDeltaMgdl = StorageValue(key: "lastDeltaMgdl", defaultValue: nil) var lastTrendCode = StorageValue(key: "lastTrendCode", defaultValue: nil) + var lastBgMgdl = StorageValue(key: "lastBgMgdl", defaultValue: nil) var lastIOB = StorageValue(key: "lastIOB", defaultValue: nil) var lastCOB = StorageValue(key: "lastCOB", defaultValue: nil) var projectedBgMgdl = StorageValue(key: "projectedBgMgdl", defaultValue: nil) @@ -194,6 +196,7 @@ class Storage { var lastLoopingChecked = StorageValue(key: "lastLoopingChecked", defaultValue: nil) var lastBGChecked = StorageValue(key: "lastBGChecked", defaultValue: nil) + var lastLoopTime = StorageValue(key: "lastLoopTime", defaultValue: 0) // Tab positions - which position each item is in (positions 1-4 are customizable, 5 is always Menu) var homePosition = StorageValue(key: "homePosition", defaultValue: .position1) @@ -385,6 +388,7 @@ class Storage { lastLoopingChecked.reload() lastBGChecked.reload() + lastLoopTime.reload() homePosition.reload() alarmsPosition.reload() diff --git a/LoopFollow/ViewControllers/MainViewController.swift b/LoopFollow/ViewControllers/MainViewController.swift index 1a3b7c03d..715922f48 100644 --- a/LoopFollow/ViewControllers/MainViewController.swift +++ b/LoopFollow/ViewControllers/MainViewController.swift @@ -15,6 +15,14 @@ func IsNightscoutEnabled() -> Bool { return !Storage.shared.url.value.isEmpty } +private struct APNSCredentialSnapshot: Equatable { + let remoteApnsKey: String + let teamId: String? + let remoteKeyId: String + let lfApnsKey: String + let lfKeyId: String +} + class MainViewController: UIViewController, UITableViewDataSource, ChartViewDelegate, UNUserNotificationCenterDelegate, UIScrollViewDelegate { var isPresentedAsModal: Bool = false @@ -145,11 +153,6 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele // Migrations run in foreground only — see runMigrationsIfNeeded() for details. runMigrationsIfNeeded() - if Storage.shared.migrationStep.value < 7 { - Storage.shared.migrateStep7() - Storage.shared.migrationStep.value = 7 - } - // Synchronize info types to ensure arrays are the correct size synchronizeInfoTypes() @@ -173,6 +176,10 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele updateGraphVisibility() statsView.isHidden = !Storage.shared.showStats.value + // Tap on stats view to open full statistics screen + let statsTap = UITapGestureRecognizer(target: self, action: #selector(statsViewTapped)) + statsView.addGestureRecognizer(statsTap) + BGChart.delegate = self BGChartFull.delegate = self @@ -372,14 +379,25 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele } .store(in: &cancellables) - Publishers.MergeMany( - Storage.shared.remoteApnsKey.$value.map { _ in () }.eraseToAnyPublisher(), - Storage.shared.teamId.$value.map { _ in () }.eraseToAnyPublisher(), - Storage.shared.remoteKeyId.$value.map { _ in () }.eraseToAnyPublisher(), - Storage.shared.lfApnsKey.$value.map { _ in () }.eraseToAnyPublisher(), - Storage.shared.lfKeyId.$value.map { _ in () }.eraseToAnyPublisher() + Publishers.CombineLatest4( + Storage.shared.remoteApnsKey.$value, + Storage.shared.teamId.$value, + Storage.shared.remoteKeyId.$value, + Storage.shared.lfApnsKey.$value ) - .receive(on: DispatchQueue.main) + .combineLatest(Storage.shared.lfKeyId.$value) + .map { values, lfKeyId in + APNSCredentialSnapshot( + remoteApnsKey: values.0, + teamId: values.1, + remoteKeyId: values.2, + lfApnsKey: values.3, + lfKeyId: lfKeyId + ) + } + .removeDuplicates() + .dropFirst() + .debounce(for: .milliseconds(500), scheduler: DispatchQueue.main) .sink { _ in JWTManager.shared.invalidateCache() } .store(in: &cancellables) @@ -636,7 +654,7 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele return treatmentsVC case .stats: - let statsVC = UIHostingController(rootView: AggregatedStatsView(viewModel: AggregatedStatsViewModel(mainViewController: nil))) + let statsVC = UIHostingController(rootView: AggregatedStatsContentView(mainViewController: nil)) let navController = UINavigationController(rootViewController: statsVC) navController.tabBarItem = UITabBarItem(title: item.displayName, image: UIImage(systemName: item.icon), tag: tag) return navController @@ -686,8 +704,10 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele if Observable.shared.currentAlarm.value != nil, let snoozerIndex = getSnoozerTabIndex(), snoozerIndex < vcs.count { + LogManager.shared.log(category: .general, message: "[LA] tap nav: alarm active → snoozer at index \(snoozerIndex)", isDebug: true) targetIndex = snoozerIndex } else { + LogManager.shared.log(category: .general, message: "[LA] tap nav: no alarm (or snoozer not found) → home", isDebug: true) targetIndex = 0 } @@ -704,15 +724,42 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele guard let tabBarController = tabBarController, let viewControllers = tabBarController.viewControllers else { return nil } + // First: search for SnoozerViewController directly in the tab bar. for (index, vc) in viewControllers.enumerated() { if let _ = vc as? SnoozerViewController { return index } } + // Fallback: derive the expected index from the stored snoozer position. + // When snoozer is in the menu (.menu), tabIndex == 4 — the Menu tab. + // This is better than falling back to Home (0) because the user can at + // least reach the snoozer from the menu tab in one tap. + let position = Storage.shared.snoozerPosition.value.normalized + if let tabIndex = position.tabIndex, tabIndex < viewControllers.count { + LogManager.shared.log(category: .general, message: "[LA] tap nav: snoozer not a direct tab — using stored position tabIndex=\(tabIndex)", isDebug: true) + return tabIndex + } + return nil } + @objc private func statsViewTapped() { + #if !targetEnvironment(macCatalyst) + let position = Storage.shared.position(for: .stats).normalized + if position != .menu, let tabIndex = position.tabIndex, let tbc = tabBarController { + tbc.selectedIndex = tabIndex + return + } + #endif + + let statsModalView = AggregatedStatsModalView(mainViewController: self) + let hostingController = UIHostingController(rootView: statsModalView) + hostingController.overrideUserInterfaceStyle = Storage.shared.appearanceMode.value.userInterfaceStyle + hostingController.modalPresentationStyle = .fullScreen + present(hostingController, animated: true) + } + private func createViewController(for item: TabItem, position: TabPosition, storyboard: UIStoryboard) -> UIViewController? { let tag = position.tabIndex ?? 0 @@ -747,7 +794,7 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele return treatmentsVC case .stats: - let statsVC = UIHostingController(rootView: AggregatedStatsView(viewModel: AggregatedStatsViewModel(mainViewController: self))) + let statsVC = UIHostingController(rootView: AggregatedStatsContentView(mainViewController: self)) let navController = UINavigationController(rootViewController: statsVC) navController.tabBarItem = UITabBarItem(title: item.displayName, image: UIImage(systemName: item.icon), tag: tag) return navController @@ -962,33 +1009,47 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele // Capture before migrations run: true for existing users, false for fresh installs. let isExistingUser = Storage.shared.migrationStep.exists + // Step 1: Released in v3.0.0 (2025-07-07). Can be removed after 2026-07-07. if Storage.shared.migrationStep.value < 1 { Storage.shared.migrateStep1() Storage.shared.migrationStep.value = 1 } + + // Step 2: Released in v3.1.0 (2025-07-21). Can be removed after 2026-07-21. if Storage.shared.migrationStep.value < 2 { Storage.shared.migrateStep2() Storage.shared.migrationStep.value = 2 } + + // Step 3: Released in v4.5.0 (2026-02-01). Can be removed after 2027-02-01. if Storage.shared.migrationStep.value < 3 { Storage.shared.migrateStep3() Storage.shared.migrationStep.value = 3 } - // TODO: This migration step can be deleted in March 2027. Check the commit for other places to cleanup. + + // Step 4: Released in v5.0.0 (2026-03-20). Can be removed after 2027-03-20. if Storage.shared.migrationStep.value < 4 { // Existing users need to see the fat/protein order change banner. // New users never saw the old order, so mark it as already seen. Storage.shared.hasSeenFatProteinOrderChange.value = !isExistingUser Storage.shared.migrationStep.value = 4 } + + // Step 5: Released in v5.0.0 (2026-03-20). Can be removed after 2027-03-20. if Storage.shared.migrationStep.value < 5 { Storage.shared.migrateStep5() Storage.shared.migrationStep.value = 5 } + if Storage.shared.migrationStep.value < 6 { Storage.shared.migrateStep6() Storage.shared.migrationStep.value = 6 } + + if Storage.shared.migrationStep.value < 7 { + Storage.shared.migrateStep7() + Storage.shared.migrationStep.value = 7 + } } @objc func appDidBecomeActive() { diff --git a/LoopFollow/ViewControllers/MoreMenuViewController.swift b/LoopFollow/ViewControllers/MoreMenuViewController.swift index 5f78bcaff..2693cde33 100644 --- a/LoopFollow/ViewControllers/MoreMenuViewController.swift +++ b/LoopFollow/ViewControllers/MoreMenuViewController.swift @@ -314,9 +314,8 @@ class MoreMenuViewController: UIViewController { return } - let statsVC = UIHostingController( - rootView: AggregatedStatsView(viewModel: AggregatedStatsViewModel(mainViewController: mainVC)) - ) + let statsView = AggregatedStatsContentView(mainViewController: mainVC) + let statsVC = UIHostingController(rootView: statsView) statsVC.overrideUserInterfaceStyle = Storage.shared.appearanceMode.value.userInterfaceStyle navigationController?.pushViewController(statsVC, animated: true) } diff --git a/LoopFollow/WatchComplication/ComplicationEntryBuilder.swift b/LoopFollow/WatchComplication/ComplicationEntryBuilder.swift new file mode 100644 index 000000000..8f045bf7a --- /dev/null +++ b/LoopFollow/WatchComplication/ComplicationEntryBuilder.swift @@ -0,0 +1,227 @@ +// ComplicationEntryBuilder.swift +// Philippe Achkar +// 2026-03-25 + +import ClockKit + +// MARK: - Complication identifiers + +enum ComplicationID { + /// graphicCircular + graphicCorner with gauge arc (Complication 1). + static let gaugeCorner = "LoopFollowGaugeCorner" + /// graphicCorner stacked text only (Complication 2). + static let stackCorner = "LoopFollowStackCorner" + // DEBUG COMPLICATION — enabled for pipeline diagnostics. + // Shows two timestamps to isolate pipeline failures: + // outer (top): HH:mm of snapshot.updatedAt — when CGM data last reached the Watch + // inner (↺): HH:mm when ClockKit last called getCurrentTimelineEntry + // If outer changes but inner is stale → reloadTimeline() not firing or ClockKit ignoring it. + // If inner changes but outer is stale → data delivery broken, complication rebuilding with old data. + static let debugCorner = "LoopFollowDebugCorner" +} + +// MARK: - Entry builder + +enum ComplicationEntryBuilder { + + // MARK: - Live template + + static func template( + for family: CLKComplicationFamily, + snapshot: GlucoseSnapshot, + identifier: String + ) -> CLKComplicationTemplate? { + switch family { + case .graphicCircular: + return graphicCircularTemplate(snapshot: snapshot) + case .graphicCorner: + switch identifier { + case ComplicationID.stackCorner: return graphicCornerStackTemplate(snapshot: snapshot) + case ComplicationID.debugCorner: return graphicCornerDebugTemplate(snapshot: snapshot) + default: return graphicCornerGaugeTemplate(snapshot: snapshot) + } + default: + return nil + } + } + + // MARK: - Stale template + + static func staleTemplate(for family: CLKComplicationFamily, identifier: String) -> CLKComplicationTemplate? { + switch family { + case .graphicCircular: + return CLKComplicationTemplateGraphicCircularStackText( + line1TextProvider: CLKSimpleTextProvider(text: "--"), + line2TextProvider: CLKSimpleTextProvider(text: "") + ) + case .graphicCorner: + switch identifier { + case ComplicationID.stackCorner: + return CLKComplicationTemplateGraphicCornerStackText( + innerTextProvider: CLKSimpleTextProvider(text: ""), + outerTextProvider: CLKSimpleTextProvider(text: "--") + ) + case ComplicationID.debugCorner: + return CLKComplicationTemplateGraphicCornerStackText( + innerTextProvider: CLKSimpleTextProvider(text: "STALE"), + outerTextProvider: CLKSimpleTextProvider(text: "--:--") + ) + default: + return staleGaugeTemplate() + } + default: + return nil + } + } + + // MARK: - Placeholder template + + static func placeholderTemplate(for family: CLKComplicationFamily, identifier: String) -> CLKComplicationTemplate? { + switch family { + case .graphicCircular: + return CLKComplicationTemplateGraphicCircularStackText( + line1TextProvider: CLKSimpleTextProvider(text: "---"), + line2TextProvider: CLKSimpleTextProvider(text: "→") + ) + case .graphicCorner: + switch identifier { + case ComplicationID.stackCorner: + let outer = CLKSimpleTextProvider(text: "---") + outer.tintColor = .green + return CLKComplicationTemplateGraphicCornerStackText( + innerTextProvider: CLKSimpleTextProvider(text: "→ --"), + outerTextProvider: outer + ) + case ComplicationID.debugCorner: + return CLKComplicationTemplateGraphicCornerStackText( + innerTextProvider: CLKSimpleTextProvider(text: "DEBUG"), + outerTextProvider: CLKSimpleTextProvider(text: "--:--") + ) + default: + let outer = CLKSimpleTextProvider(text: "---") + outer.tintColor = .green + let gauge = CLKSimpleGaugeProvider(style: .fill, gaugeColor: .green, fillFraction: 0) + return CLKComplicationTemplateGraphicCornerGaugeText( + gaugeProvider: gauge, + leadingTextProvider: CLKSimpleTextProvider(text: "0"), + trailingTextProvider: nil, + outerTextProvider: outer + ) + } + default: + return nil + } + } + + // MARK: - Graphic Circular + // BG (top, colored) + trend arrow (bottom). + + private static func graphicCircularTemplate(snapshot: GlucoseSnapshot) -> CLKComplicationTemplate { + let bgText = CLKSimpleTextProvider(text: WatchFormat.glucose(snapshot)) + bgText.tintColor = thresholdColor(for: snapshot) + + return CLKComplicationTemplateGraphicCircularStackText( + line1TextProvider: bgText, + line2TextProvider: CLKSimpleTextProvider(text: WatchFormat.trendArrow(snapshot)) + ) + } + + // MARK: - Graphic Corner — Gauge Text (Complication 1) + // Gauge arc fills from 0 (fresh) to 100% (15 min stale). + // Outer text: BG (colored). Leading text: delta. + // Stale / isNotLooping → "⚠" in yellow, gauge full. + + private static func graphicCornerGaugeTemplate(snapshot: GlucoseSnapshot) -> CLKComplicationTemplate { + guard snapshot.age < 900, !snapshot.isNotLooping else { + return staleGaugeTemplate() + } + + let fraction = Float(min(snapshot.age / 900.0, 1.0)) + let color = thresholdColor(for: snapshot) + + let bgText = CLKSimpleTextProvider(text: WatchFormat.glucose(snapshot)) + bgText.tintColor = color + + let gauge = CLKSimpleGaugeProvider(style: .fill, gaugeColor: color, fillFraction: fraction) + + return CLKComplicationTemplateGraphicCornerGaugeText( + gaugeProvider: gauge, + leadingTextProvider: CLKSimpleTextProvider(text: WatchFormat.delta(snapshot)), + trailingTextProvider: nil, + outerTextProvider: bgText + ) + } + + private static func staleGaugeTemplate() -> CLKComplicationTemplate { + let warnText = CLKSimpleTextProvider(text: "⚠") + warnText.tintColor = .yellow + + let gauge = CLKSimpleGaugeProvider(style: .fill, gaugeColor: .yellow, fillFraction: 1.0) + + return CLKComplicationTemplateGraphicCornerGaugeText( + gaugeProvider: gauge, + leadingTextProvider: nil, + trailingTextProvider: nil, + outerTextProvider: warnText + ) + } + + // MARK: - Graphic Corner — Stacked Text (Complication 2) + // Outer (top, large): BG value, colored. + // Inner (bottom, small): "→ projected" (falls back to delta if no projection). + // Stale / isNotLooping: outer = "--", inner = "". + + private static func graphicCornerStackTemplate(snapshot: GlucoseSnapshot) -> CLKComplicationTemplate { + guard snapshot.age < 900, !snapshot.isNotLooping else { + return CLKComplicationTemplateGraphicCornerStackText( + innerTextProvider: CLKSimpleTextProvider(text: ""), + outerTextProvider: CLKSimpleTextProvider(text: "--") + ) + } + + let bgText = CLKSimpleTextProvider(text: WatchFormat.glucose(snapshot)) + bgText.tintColor = thresholdColor(for: snapshot) + + let bottomLabel: String + if let _ = snapshot.projected { + // ⇢ = dashed arrow (U+21E2); swap for ▸ (U+25B8) if it renders poorly on-device + bottomLabel = "\(WatchFormat.delta(snapshot)) | ⇢\(WatchFormat.projected(snapshot))" + } else { + bottomLabel = WatchFormat.delta(snapshot) + } + + return CLKComplicationTemplateGraphicCornerStackText( + innerTextProvider: CLKSimpleTextProvider(text: bottomLabel), + outerTextProvider: bgText + ) + } + + // MARK: - Graphic Corner — Debug (Complication 3) + // Outer (top): HH:mm of the snapshot's updatedAt — when the CGM reading arrived. + // Inner (bottom): "↺ HH:mm" — when ClockKit last called getCurrentTimelineEntry. + // + // Reading the two times tells you: + // outer changes → Watch is receiving new data + // inner changes → ClockKit is refreshing the complication face + // inner stale → reloadTimeline is not being called or ClockKit is ignoring it + + private static func graphicCornerDebugTemplate(snapshot: GlucoseSnapshot) -> CLKComplicationTemplate { + let dataTime = WatchFormat.updateTime(snapshot) + let buildTime = WatchFormat.currentTime() + + return CLKComplicationTemplateGraphicCornerStackText( + innerTextProvider: CLKSimpleTextProvider(text: "↺ \(buildTime)"), + outerTextProvider: CLKSimpleTextProvider(text: dataTime) + ) + } + + // MARK: - Threshold color + + /// snapshot.glucose is always in mg/dL (builder stores canonical mg/dL). + static func thresholdColor(for snapshot: GlucoseSnapshot) -> UIColor { + let t = LAAppGroupSettings.thresholdsMgdl() + if snapshot.glucose < t.low { return .red } + if snapshot.glucose > t.high { return .orange } + return .green + } +} diff --git a/LoopFollow/WatchComplication/WatchComplicationProvider.swift b/LoopFollow/WatchComplication/WatchComplicationProvider.swift new file mode 100644 index 000000000..3e59c9d97 --- /dev/null +++ b/LoopFollow/WatchComplication/WatchComplicationProvider.swift @@ -0,0 +1,127 @@ +// WatchComplicationProvider.swift +// Philippe Achkar +// 2026-03-10 + +import ClockKit +import Foundation +import os.log + +private let watchLog = OSLog( + subsystem: Bundle.main.bundleIdentifier ?? "com.loopfollow.watch", + category: "Watch" +) + +final class WatchComplicationProvider: NSObject, CLKComplicationDataSource { + + // MARK: - Complication Descriptors + + func getComplicationDescriptors(handler: @escaping ([CLKComplicationDescriptor]) -> Void) { + let descriptors = [ + // Complication 1: BG + gauge arc (graphicCircular + graphicCorner) + CLKComplicationDescriptor( + identifier: ComplicationID.gaugeCorner, + displayName: "LoopFollow", + supportedFamilies: [.graphicCircular, .graphicCorner] + ), + // Complication 2: BG + projected BG stacked text (graphicCorner only) + CLKComplicationDescriptor( + identifier: ComplicationID.stackCorner, + displayName: "LoopFollow Text", + supportedFamilies: [.graphicCorner] + ), + // DEBUG COMPLICATION — enabled for pipeline diagnostics. + CLKComplicationDescriptor( + identifier: ComplicationID.debugCorner, + displayName: "LoopFollow Debug", + supportedFamilies: [.graphicCorner] + ) + ] + handler(descriptors) + } + + // MARK: - Timeline + + func getCurrentTimelineEntry( + for complication: CLKComplication, + withHandler handler: @escaping (CLKComplicationTimelineEntry?) -> Void + ) { + // Whenever ClockKit calls us it hands us the CLKComplication object. + // Cache it so WatchSessionReceiver can call reloadTimeline() even when + // activeComplications is nil (common during background execution on watchOS 9+). + WatchSessionReceiver.shared.cacheComplication(complication) + + // Prefer the file store (persists across launches); fall back to the in-memory + // cache in case the file write hasn't completed or the store is unavailable. + guard let snapshot = GlucoseSnapshotStore.shared.load() + ?? WatchSessionReceiver.shared.lastSnapshot + else { + os_log("WatchComplicationProvider: no snapshot available (store and cache both nil)", log: watchLog, type: .error) + handler(nil) + return + } + + os_log("WatchComplicationProvider: getCurrentTimelineEntry g=%d age=%ds id=%{public}@", log: watchLog, type: .info, Int(snapshot.glucose), Int(snapshot.age), complication.identifier) + + guard snapshot.age < 900 else { + os_log("WatchComplicationProvider: snapshot stale (%d s)", log: watchLog, type: .debug, Int(snapshot.age)) + handler(staleEntry(for: complication)) + return + } + + let template = ComplicationEntryBuilder.template( + for: complication.family, + snapshot: snapshot, + identifier: complication.identifier + ) + let entry = template.map { + CLKComplicationTimelineEntry(date: snapshot.updatedAt, complicationTemplate: $0) + } + handler(entry) + } + + func getTimelineEndDate( + for complication: CLKComplication, + withHandler handler: @escaping (Date?) -> Void + ) { + WatchSessionReceiver.shared.cacheComplication(complication) + // Expire timeline 15 minutes after last reading + // so Watch does not display indefinitely stale data + if let snapshot = GlucoseSnapshotStore.shared.load() { + handler(snapshot.updatedAt.addingTimeInterval(900)) + } else { + handler(nil) + } + } + + func getPrivacyBehavior( + for complication: CLKComplication, + withHandler handler: @escaping (CLKComplicationPrivacyBehavior) -> Void + ) { + // Glucose is sensitive — hide on locked watch face + handler(.hideOnLockScreen) + } + + // MARK: - Placeholder + + func getLocalizableSampleTemplate( + for complication: CLKComplication, + withHandler handler: @escaping (CLKComplicationTemplate?) -> Void + ) { + handler(ComplicationEntryBuilder.placeholderTemplate( + for: complication.family, + identifier: complication.identifier + )) + } + + // MARK: - Private + + private func staleEntry(for complication: CLKComplication) -> CLKComplicationTimelineEntry? { + let template = ComplicationEntryBuilder.staleTemplate( + for: complication.family, + identifier: complication.identifier + ) + return template.map { + CLKComplicationTimelineEntry(date: Date(), complicationTemplate: $0) + } + } +} diff --git a/LoopFollow/WatchComplication/WatchFormat.swift b/LoopFollow/WatchComplication/WatchFormat.swift new file mode 100644 index 000000000..acc7e9ec2 --- /dev/null +++ b/LoopFollow/WatchComplication/WatchFormat.swift @@ -0,0 +1,197 @@ +// WatchFormat.swift +// Philippe Achkar +// 2026-03-25 + +import Foundation + +/// Formatting helpers for Watch complications and Watch app UI. +/// All glucose values in GlucoseSnapshot are stored in mg/dL; this module +/// converts to mmol/L for display when snapshot.unit == .mmol. +enum WatchFormat { + + // MARK: - Glucose + + static func glucose(_ s: GlucoseSnapshot) -> String { + formatGlucoseValue(s.glucose, unit: s.unit) + } + + static func delta(_ s: GlucoseSnapshot) -> String { + switch s.unit { + case .mgdl: + let v = Int(round(s.delta)) + if v == 0 { return "0" } + return v > 0 ? "+\(v)" : "\(v)" + case .mmol: + let mmol = GlucoseConversion.toMmol(s.delta) + let d = abs(mmol) < 0.05 ? 0.0 : mmol + if d == 0 { return "0.0" } + let str = String(format: "%.1f", abs(d)) + return d > 0 ? "+\(str)" : "-\(str)" + } + } + + static func projected(_ s: GlucoseSnapshot) -> String { + guard let v = s.projected else { return "—" } + return formatGlucoseValue(v, unit: s.unit) + } + + static func trendArrow(_ s: GlucoseSnapshot) -> String { + switch s.trend { + case .upFast: return "↑↑" + case .up: return "↑" + case .upSlight: return "↗" + case .flat: return "→" + case .downSlight: return "↘" + case .down: return "↓" + case .downFast: return "↓↓" + case .unknown: return "–" + } + } + + // MARK: - Time + + /// "Xm" from now, capped display at 99m + static func minAgo(_ s: GlucoseSnapshot) -> String { + let mins = Int(s.age / 60) + return "\(min(mins, 99))m" + } + + /// Time of last update formatted as "HH:mm" + static func updateTime(_ s: GlucoseSnapshot) -> String { + let f = DateFormatter() + f.dateFormat = "HH:mm" + return f.string(from: s.updatedAt) + } + + static func currentTime() -> String { + let f = DateFormatter() + f.dateFormat = "HH:mm" + return f.string(from: Date()) + } + + // MARK: - Secondary metrics + + static func iob(_ s: GlucoseSnapshot) -> String { + guard let v = s.iob else { return "—" } + return String(format: "%.1f", v) + } + + static func cob(_ s: GlucoseSnapshot) -> String { + guard let v = s.cob else { return "—" } + return "\(Int(round(v)))" + } + + static func battery(_ s: GlucoseSnapshot) -> String { + guard let v = s.battery else { return "—" } + return String(format: "%.0f%%", v) + } + + static func pumpBattery(_ s: GlucoseSnapshot) -> String { + guard let v = s.pumpBattery else { return "—" } + return String(format: "%.0f%%", v) + } + + static func pump(_ s: GlucoseSnapshot) -> String { + guard let v = s.pumpReservoirU else { return "50+U" } + return "\(Int(round(v)))U" + } + + static func recBolus(_ s: GlucoseSnapshot) -> String { + guard let v = s.recBolus else { return "—" } + return String(format: "%.2fU", v) + } + + static func autosens(_ s: GlucoseSnapshot) -> String { + guard let v = s.autosens else { return "—" } + return String(format: "%.0f%%", v * 100) + } + + static func tdd(_ s: GlucoseSnapshot) -> String { + guard let v = s.tdd else { return "—" } + return String(format: "%.1fU", v) + } + + static func basal(_ s: GlucoseSnapshot) -> String { + s.basalRate.isEmpty ? "—" : s.basalRate + } + + static func target(_ s: GlucoseSnapshot) -> String { + guard let low = s.targetLowMgdl, low > 0 else { return "—" } + let lowStr = formatGlucoseValue(low, unit: s.unit) + if let high = s.targetHighMgdl, high > 0, abs(high - low) > 0.5 { + return "\(lowStr)-\(formatGlucoseValue(high, unit: s.unit))" + } + return lowStr + } + + static func isf(_ s: GlucoseSnapshot) -> String { + guard let v = s.isfMgdlPerU, v > 0 else { return "—" } + return formatGlucoseValue(v, unit: s.unit) + } + + static func carbRatio(_ s: GlucoseSnapshot) -> String { + guard let v = s.carbRatio, v > 0 else { return "—" } + return String(format: "%.0fg", v) + } + + static func carbsToday(_ s: GlucoseSnapshot) -> String { + guard let v = s.carbsToday else { return "—" } + return "\(Int(round(v)))g" + } + + static func minMax(_ s: GlucoseSnapshot) -> String { + guard let mn = s.minBgMgdl, let mx = s.maxBgMgdl else { return "—" } + return "\(formatGlucoseValue(mn, unit: s.unit))/\(formatGlucoseValue(mx, unit: s.unit))" + } + + static func age(insertTime: TimeInterval) -> String { + guard insertTime > 0 else { return "—" } + let secs = Date().timeIntervalSince1970 - insertTime + let days = Int(secs / 86400) + let hours = Int(secs.truncatingRemainder(dividingBy: 86400) / 3600) + return days > 0 ? "\(days)d\(hours)h" : "\(hours)h" + } + + static func override(_ s: GlucoseSnapshot) -> String { s.override ?? "—" } + static func profileName(_ s: GlucoseSnapshot) -> String { s.profileName ?? "—" } + + // MARK: - Slot dispatch + + static func slotValue(option: LiveActivitySlotOption, snapshot s: GlucoseSnapshot) -> String { + switch option { + case .none: return "" + case .delta: return delta(s) + case .projectedBG: return projected(s) + case .minMax: return minMax(s) + case .iob: return iob(s) + case .cob: return cob(s) + case .recBolus: return recBolus(s) + case .autosens: return autosens(s) + case .tdd: return tdd(s) + case .basal: return basal(s) + case .pump: return pump(s) + case .pumpBattery: return pumpBattery(s) + case .battery: return battery(s) + case .target: return target(s) + case .isf: return isf(s) + case .carbRatio: return carbRatio(s) + case .sage: return age(insertTime: s.sageInsertTime) + case .cage: return age(insertTime: s.cageInsertTime) + case .iage: return age(insertTime: s.iageInsertTime) + case .carbsToday: return carbsToday(s) + case .override: return override(s) + case .profile: return profileName(s) + } + } + + // MARK: - Private + + private static func formatGlucoseValue(_ mgdl: Double, unit: GlucoseSnapshot.Unit) -> String { + switch unit { + case .mgdl: + return "\(Int(round(mgdl)))" + case .mmol: + return String(format: "%.1f", GlucoseConversion.toMmol(mgdl)) + } + } +} diff --git a/LoopFollow/WatchComplication/WatchSessionReceiver.swift b/LoopFollow/WatchComplication/WatchSessionReceiver.swift new file mode 100644 index 000000000..89a4e13b6 --- /dev/null +++ b/LoopFollow/WatchComplication/WatchSessionReceiver.swift @@ -0,0 +1,219 @@ +// WatchSessionReceiver.swift +// Philippe Achkar +// 2026-03-10 + +import Foundation +import WatchConnectivity +import ClockKit +import WatchKit +import os.log + +private let watchLog = OSLog( + subsystem: Bundle.main.bundleIdentifier ?? "com.loopfollow.watch", + category: "Watch" +) + +final class WatchSessionReceiver: NSObject { + + // MARK: - Shared Instance + + static let shared = WatchSessionReceiver() + + static let snapshotReceivedNotification = Notification.Name("WatchSnapshotReceived") + + /// Held open while WatchConnectivity delivers a pending transferUserInfo in the background. + /// Completed after the snapshot is saved to disk. + var pendingConnectivityTask: WKWatchConnectivityRefreshBackgroundTask? + + /// In-memory cache of the last received snapshot. Used by WatchComplicationProvider as a + /// fallback when GlucoseSnapshotStore.load() returns nil (e.g. file write race or first launch). + /// Always reflects the most recently delivered snapshot regardless of file-store state. + private(set) var lastSnapshot: GlucoseSnapshot? + + /// Cache of CLKComplication objects keyed by "-". + /// Populated when ClockKit calls getCurrentTimelineEntry (complication is on an active face) + /// or when activeComplications is non-nil. Used as a fallback when activeComplications + /// returns nil/empty during background execution — a known watchOS 9+ limitation. + private var cachedComplications: [String: CLKComplication] = [:] + + /// Called by WatchComplicationProvider whenever ClockKit passes a CLKComplication to us. + /// Must be called on the main thread (ClockKit callbacks are main-thread). + func cacheComplication(_ complication: CLKComplication) { + let key = "\(complication.identifier)-\(complication.family.rawValue)" + cachedComplications[key] = complication + } + + // MARK: - Init + + private override init() { + super.init() + } + + // MARK: - Setup + + /// Call once from the Watch extension entry point after launch. + func activate() { + guard WCSession.isSupported() else { + os_log("WatchSessionReceiver: WCSession not supported", log: watchLog, type: .debug) + return + } + WCSession.default.delegate = self + WCSession.default.activate() + os_log("WatchSessionReceiver: WCSession activation requested", log: watchLog, type: .debug) + } + + /// Triggers a complication timeline reload. Called from background refresh tasks + /// after a snapshot has already been saved to GlucoseSnapshotStore. + func triggerComplicationReload() { + reloadComplications() + } +} + +// MARK: - WCSessionDelegate + +extension WatchSessionReceiver: WCSessionDelegate { + + func session( + _ session: WCSession, + activationDidCompleteWith activationState: WCSessionActivationState, + error: Error? + ) { + if let error = error { + os_log("WatchSessionReceiver: activation failed — %{public}@", log: watchLog, type: .error, error.localizedDescription) + } else { + os_log("WatchSessionReceiver: activation complete — state %d", log: watchLog, type: .debug, activationState.rawValue) + bootstrapFromApplicationContext(session) + } + } + + /// Loads a snapshot from the last received applicationContext so the Watch app + /// has data immediately on launch without waiting for the next transferUserInfo. + private func bootstrapFromApplicationContext(_ session: WCSession) { + guard let data = session.receivedApplicationContext["snapshot"] as? Data else { return } + do { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + let snapshot = try decoder.decode(GlucoseSnapshot.self, from: data) + GlucoseSnapshotStore.shared.save(snapshot) { [weak self] in + os_log("WatchSessionReceiver: bootstrapped snapshot from applicationContext", log: watchLog, type: .debug) + self?.reloadComplications() + DispatchQueue.main.async { + NotificationCenter.default.post( + name: WatchSessionReceiver.snapshotReceivedNotification, + object: nil, + userInfo: ["snapshot": snapshot] + ) + } + } + } catch { + os_log("WatchSessionReceiver: failed to decode applicationContext snapshot — %{public}@", log: watchLog, type: .error, error.localizedDescription) + } + } + + /// Handles immediate delivery when Watch app is in foreground (sendMessage path). + func session( + _ session: WCSession, + didReceiveMessage message: [String: Any] + ) { + process(payload: message, source: "sendMessage") + } + + /// Handles queued background delivery (transferUserInfo path). + func session( + _ session: WCSession, + didReceiveUserInfo userInfo: [String: Any] + ) { + process(payload: userInfo, source: "userInfo") + } + + func session(_ session: WCSession, didReceiveApplicationContext applicationContext: [String : Any]) { + process(payload: applicationContext, source: "applicationContext") + } + + // MARK: - Private + + private func process(payload: [String: Any], source: String) { + guard let data = payload["snapshot"] as? Data else { + os_log("WatchSessionReceiver: %{public}@ — no snapshot key", log: watchLog, type: .debug, source) + return + } + do { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + let snapshot = try decoder.decode(GlucoseSnapshot.self, from: data) + // Cache in memory immediately — complication provider can use this as a + // fallback if the App Group file store hasn't flushed yet. + lastSnapshot = snapshot + os_log("WatchSessionReceiver: %{public}@ snapshot decoded g=%d, saving", log: watchLog, type: .debug, source, Int(snapshot.glucose)) + GlucoseSnapshotStore.shared.save(snapshot) { [weak self] in + os_log("WatchSessionReceiver: %{public}@ snapshot saved, reloading complications", log: watchLog, type: .debug, source) + // ACK to iPhone so it can detect missed deliveries. + self?.sendAck(for: snapshot) + // Capture and clear the pending task before dispatching to main, + // then complete it AFTER reloadTimeline() so watchOS doesn't suspend + // the extension before ClockKit processes the reload request. + let task = self?.pendingConnectivityTask + self?.pendingConnectivityTask = nil + DispatchQueue.main.async { [weak self] in + self?.reloadComplicationsOnMainThread() + // Complete background task only after reloadTimeline() has been called. + task?.setTaskCompletedWithSnapshot(false) + NotificationCenter.default.post( + name: WatchSessionReceiver.snapshotReceivedNotification, + object: nil, + userInfo: ["snapshot": snapshot] + ) + } + } + } catch { + os_log("WatchSessionReceiver: %{public}@ decode failed — %{public}@", log: watchLog, type: .error, source, error.localizedDescription) + } + } + + private func sendAck(for snapshot: GlucoseSnapshot) { + let session = WCSession.default + guard session.activationState == .activated else { return } + let ack: [String: Any] = ["watchAck": snapshot.updatedAt.timeIntervalSince1970] + if session.isReachable { + session.sendMessage(ack, replyHandler: nil, errorHandler: nil) + } else { + session.transferUserInfo(ack) + } + os_log("WatchSessionReceiver: ACK sent for snapshot at %f", log: watchLog, type: .debug, snapshot.updatedAt.timeIntervalSince1970) + } + + /// Reloads all known complications. May be called from any thread. + func reloadComplications() { + DispatchQueue.main.async { self.reloadComplicationsOnMainThread() } + } + + /// Must be called on the main thread. Used directly when already on main (e.g., from process()). + private func reloadComplicationsOnMainThread() { + let server = CLKComplicationServer.sharedInstance() + + let complications: [CLKComplication] + if let active = server.activeComplications, !active.isEmpty { + // Update the cache whenever activeComplications is non-nil. + active.forEach { self.cacheComplication($0) } + complications = active + os_log("WatchSessionReceiver: reloading %d active complication(s)", log: watchLog, type: .info, complications.count) + } else if !cachedComplications.isEmpty { + // activeComplications is nil/empty — common during background execution on watchOS 9+. + // Use the cached CLKComplication objects from the last call where activeComplications was valid. + complications = Array(cachedComplications.values) + os_log("WatchSessionReceiver: activeComplications nil/empty — using %d cached complication(s)", log: watchLog, type: .info, complications.count) + } else { + os_log("WatchSessionReceiver: no active or cached complications — reloadTimeline skipped", log: watchLog, type: .error) + return + } + + for complication in complications { server.reloadTimeline(for: complication) } + os_log("WatchSessionReceiver: reloadTimeline called for %d complication(s)", log: watchLog, type: .info, complications.count) + } + + // NOTE: reloadComplications() is safe to call from any thread for foreground paths + // (bootstrap, reloadComplicationsIfNeeded). For background task paths (process()), + // setTaskCompletedWithSnapshot() must be called INSIDE DispatchQueue.main.async + // after reloadTimeline() — otherwise watchOS suspends the extension before ClockKit + // receives the reload request. +} diff --git a/LoopFollowLAExtension/LoopFollowLiveActivity.swift b/LoopFollowLAExtension/LoopFollowLiveActivity.swift index f388dfbf9..88e57cec3 100644 --- a/LoopFollowLAExtension/LoopFollowLiveActivity.swift +++ b/LoopFollowLAExtension/LoopFollowLiveActivity.swift @@ -54,6 +54,12 @@ struct LoopFollowLiveActivityWidget: Widget { return ActivityConfiguration(for: GlucoseLiveActivityAttributes.self) { context in LockScreenFamilyAdaptiveView(state: context.state) .id(context.state.seq) + .background( + LALivenessMarker( + seq: context.state.seq, + producedAt: context.state.producedAt + ) + ) .activitySystemActionForegroundColor(.white) .applyActivityContentMarginsFixIfAvailable() .widgetURL(URL(string: "\(AppGroupID.urlScheme)://la-tap")!) @@ -65,6 +71,12 @@ struct LoopFollowLiveActivityWidget: Widget { return ActivityConfiguration(for: GlucoseLiveActivityAttributes.self) { context in LockScreenLiveActivityView(state: context.state) .id(context.state.seq) + .background( + LALivenessMarker( + seq: context.state.seq, + producedAt: context.state.producedAt + ) + ) .activitySystemActionForegroundColor(.white) .activityBackgroundTint(LAColors.backgroundTint(for: context.state.snapshot)) .applyActivityContentMarginsFixIfAvailable() @@ -117,14 +129,17 @@ private struct LockScreenFamilyAdaptiveView: View { private struct SmallFamilyView: View { let snapshot: GlucoseSnapshot - private var unitLabel: String { - switch snapshot.unit { - case .mgdl: return "mg/dL" - case .mmol: return "mmol/L" - } + /// Unit label for the right slot — ISF appends "/U", other glucose slots + /// use the plain glucose unit, non-glucose slots return nil. + private func rightSlotUnitLabel(for slot: LiveActivitySlotOption) -> String? { + guard slot.isGlucoseUnit else { return nil } + if slot == .isf { return snapshot.unit.displayName + "/U" } + return snapshot.unit.displayName } var body: some View { + let rightSlot = LAAppGroupSettings.smallWidgetSlot() + HStack(alignment: .center, spacing: 0) { VStack(alignment: .leading, spacing: 2) { HStack(alignment: .firstTextBaseline, spacing: 4) { @@ -138,23 +153,60 @@ private struct SmallFamilyView: View { .foregroundStyle(LAColors.keyline(for: snapshot)) } - Text("\(LAFormat.delta(snapshot)) \(unitLabel)") + Text("\(LAFormat.delta(snapshot)) \(snapshot.unit.displayName)") .font(.system(size: 14, weight: .semibold, design: .rounded)) .monospacedDigit() .foregroundStyle(.white.opacity(0.85)) } + .layoutPriority(1) Spacer() - VStack(alignment: .trailing, spacing: 2) { - Text(LAFormat.projected(snapshot)) - .font(.system(size: 28, weight: .bold, design: .rounded)) - .monospacedDigit() - .foregroundStyle(.white) - - Text(unitLabel) - .font(.system(size: 14, weight: .regular, design: .rounded)) - .foregroundStyle(.white.opacity(0.65)) + if rightSlot != .none { + if let unitLabel = rightSlotUnitLabel(for: rightSlot) { + // Use ViewThatFits so the unit label appears on surfaces with + // enough vertical space (CarPlay) and is omitted where it doesn't + // fit (Watch Smart Stack). + ViewThatFits(in: .vertical) { + VStack(alignment: .trailing, spacing: 2) { + Text(rightSlot.gridLabel) + .font(.system(size: 12, weight: .semibold, design: .rounded)) + .foregroundStyle(.white.opacity(0.65)) + Text(slotFormattedValue(option: rightSlot, snapshot: snapshot)) + .font(.system(size: 20, weight: .bold, design: .rounded)) + .monospacedDigit() + .foregroundStyle(.white) + .lineLimit(1) + .minimumScaleFactor(0.8) + Text(unitLabel) + .font(.system(size: 11, weight: .regular, design: .rounded)) + .foregroundStyle(.white.opacity(0.55)) + } + VStack(alignment: .trailing, spacing: 2) { + Text(rightSlot.gridLabel) + .font(.system(size: 12, weight: .semibold, design: .rounded)) + .foregroundStyle(.white.opacity(0.65)) + Text(slotFormattedValue(option: rightSlot, snapshot: snapshot)) + .font(.system(size: 20, weight: .bold, design: .rounded)) + .monospacedDigit() + .foregroundStyle(.white) + .lineLimit(1) + .minimumScaleFactor(0.8) + } + } + } else { + VStack(alignment: .trailing, spacing: 2) { + Text(rightSlot.gridLabel) + .font(.system(size: 12, weight: .semibold, design: .rounded)) + .foregroundStyle(.white.opacity(0.65)) + Text(slotFormattedValue(option: rightSlot, snapshot: snapshot)) + .font(.system(size: 20, weight: .bold, design: .rounded)) + .monospacedDigit() + .foregroundStyle(.white) + .lineLimit(1) + .minimumScaleFactor(0.8) + } + } } } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) @@ -303,6 +355,33 @@ private struct MetricBlock: View { } } +private func slotFormattedValue(option: LiveActivitySlotOption, snapshot: GlucoseSnapshot) -> String { + switch option { + case .none: "" + case .delta: LAFormat.delta(snapshot) + case .projectedBG: LAFormat.projected(snapshot) + case .minMax: LAFormat.minMax(snapshot) + case .iob: LAFormat.iob(snapshot) + case .cob: LAFormat.cob(snapshot) + case .recBolus: LAFormat.recBolus(snapshot) + case .autosens: LAFormat.autosens(snapshot) + case .tdd: LAFormat.tdd(snapshot) + case .basal: LAFormat.basal(snapshot) + case .pump: LAFormat.pump(snapshot) + case .pumpBattery: LAFormat.pumpBattery(snapshot) + case .battery: LAFormat.battery(snapshot) + case .target: LAFormat.target(snapshot) + case .isf: LAFormat.isf(snapshot) + case .carbRatio: LAFormat.carbRatio(snapshot) + case .sage: LAFormat.age(insertTime: snapshot.sageInsertTime) + case .cage: LAFormat.age(insertTime: snapshot.cageInsertTime) + case .iage: LAFormat.age(insertTime: snapshot.iageInsertTime) + case .carbsToday: LAFormat.carbsToday(snapshot) + case .override: LAFormat.override(snapshot) + case .profile: LAFormat.profileName(snapshot) + } +} + private struct SlotView: View { let option: LiveActivitySlotOption let snapshot: GlucoseSnapshot @@ -312,34 +391,7 @@ private struct SlotView: View { Color.clear .frame(width: 60, height: 36) } else { - MetricBlock(label: option.gridLabel, value: value(for: option)) - } - } - - private func value(for option: LiveActivitySlotOption) -> String { - switch option { - case .none: "" - case .delta: LAFormat.delta(snapshot) - case .projectedBG: LAFormat.projected(snapshot) - case .minMax: LAFormat.minMax(snapshot) - case .iob: LAFormat.iob(snapshot) - case .cob: LAFormat.cob(snapshot) - case .recBolus: LAFormat.recBolus(snapshot) - case .autosens: LAFormat.autosens(snapshot) - case .tdd: LAFormat.tdd(snapshot) - case .basal: LAFormat.basal(snapshot) - case .pump: LAFormat.pump(snapshot) - case .pumpBattery: LAFormat.pumpBattery(snapshot) - case .battery: LAFormat.battery(snapshot) - case .target: LAFormat.target(snapshot) - case .isf: LAFormat.isf(snapshot) - case .carbRatio: LAFormat.carbRatio(snapshot) - case .sage: LAFormat.age(insertTime: snapshot.sageInsertTime) - case .cage: LAFormat.age(insertTime: snapshot.cageInsertTime) - case .iage: LAFormat.age(insertTime: snapshot.iageInsertTime) - case .carbsToday: LAFormat.carbsToday(snapshot) - case .override: LAFormat.override(snapshot) - case .profile: LAFormat.profileName(snapshot) + MetricBlock(label: option.gridLabel, value: slotFormattedValue(option: option, snapshot: snapshot)) } } } @@ -359,26 +411,19 @@ private struct DynamicIslandLeadingView: View { .minimumScaleFactor(0.7) } else { VStack(alignment: .leading, spacing: 2) { - Text(LAFormat.glucose(snapshot)) - .font(.system(size: 28, weight: .bold, design: .rounded)) - .monospacedDigit() - .foregroundStyle(.white) - - HStack(spacing: 5) { - Text(LAFormat.trendArrow(snapshot)) - .font(.system(size: 13, weight: .semibold, design: .rounded)) - .foregroundStyle(.white.opacity(0.9)) - - Text(LAFormat.delta(snapshot)) - .font(.system(size: 13, weight: .semibold, design: .rounded)) - .monospacedDigit() - .foregroundStyle(.white.opacity(0.9)) - - Text("Proj: \(LAFormat.projected(snapshot))") - .font(.system(size: 13, weight: .semibold, design: .rounded)) + HStack(alignment: .firstTextBaseline, spacing: 4) { + Text(LAFormat.glucose(snapshot)) + .font(.system(size: 28, weight: .bold, design: .rounded)) .monospacedDigit() - .foregroundStyle(.white.opacity(0.9)) + .foregroundStyle(LAColors.keyline(for: snapshot)) + Text(LAFormat.trendArrow(snapshot)) + .font(.system(size: 22, weight: .semibold, design: .rounded)) + .foregroundStyle(LAColors.keyline(for: snapshot)) } + Text("\(LAFormat.delta(snapshot)) \(snapshot.unit.displayName)") + .font(.system(size: 13, weight: .semibold, design: .rounded)) + .monospacedDigit() + .foregroundStyle(.white.opacity(0.85)) } } } @@ -391,18 +436,21 @@ private struct DynamicIslandTrailingView: View { if snapshot.isNotLooping { EmptyView() } else { - VStack(alignment: .trailing, spacing: 3) { - Text("IOB \(LAFormat.iob(snapshot))") - .font(.system(size: 13, weight: .bold, design: .rounded)) - .monospacedDigit() - .foregroundStyle(.white.opacity(0.95)) - - Text("COB \(LAFormat.cob(snapshot))") - .font(.system(size: 13, weight: .bold, design: .rounded)) - .monospacedDigit() - .foregroundStyle(.white.opacity(0.95)) + let slot = LAAppGroupSettings.smallWidgetSlot() + if slot != .none { + VStack(alignment: .trailing, spacing: 2) { + Text(slot.gridLabel) + .font(.system(size: 11, weight: .semibold, design: .rounded)) + .foregroundStyle(.white.opacity(0.65)) + Text(slotFormattedValue(option: slot, snapshot: snapshot)) + .font(.system(size: 18, weight: .bold, design: .rounded)) + .monospacedDigit() + .foregroundStyle(.white) + .lineLimit(1) + .minimumScaleFactor(0.8) + } + .padding(.trailing, 6) } - .padding(.trailing, 6) } } } diff --git a/LoopFollowWatch Watch App/Assets.xcassets/AccentColor.colorset/Contents.json b/LoopFollowWatch Watch App/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 000000000..eb8789700 --- /dev/null +++ b/LoopFollowWatch Watch App/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/LoopFollowWatch Watch App/Assets.xcassets/AppIcon.appiconset/1024-dark.png b/LoopFollowWatch Watch App/Assets.xcassets/AppIcon.appiconset/1024-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..0eaf8706bab19c4b7d0b7999772e7845172e0abc GIT binary patch literal 226219 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∋Xpc8wDb|^L2&SL5_)-T6?dK|={R=O(PXwxuG}s@ZJ5kH zVXLQFXdU!edZWXU>!Vn?SS4*eMEOPvUz6@Z;S5U6DZL57c*adGv-0=SZ&#L{Ce{r@ z%zHheC(?vSXwYI#l|bUHoHWA=IdUUMwx{`ZEy`AUVcsaUTx6u*DWfdDsadTXe)uxV z4#N~#=vOqENs(=BIGA(D397Xm{U{AhcToxJUpK=%CPwz8;S->tn zPzvRQ6ye!pJYy+Fe<-y_+Uw@6M)LmfM4bb|PdeAymSlKS(k{p-z#ez&dH#PhnI zeq!mxF0T7KnAf#xR$v81$_ncS5?n2_QL4>NW>two1+J|Xb~S^p(bX*}WL;m1Gw1GiDaQ*JC#aGRhS_w7;B^#d3?s{aD-Oy z0RXL)#^l}cjR;ukMj2qS!GS)J^ohN}>8$hxS^n93O~LxHe&K&MllCik=2Er_(jlejaq=2p6BU1@ElL2&F$shoP4v)xVR-=+Ht-=M?Ca+PKi!f!c8{FtlS z65kqgOpibOz+E?KQtXE<-5MPU#zpVF`f}$D{nm^%l=p4kC}_*0$2eDXYMnsCUGkK@ zy7#IRUH&xYv(h$UtE)vm_Dsut(!=}YfD%pMZz| zGK(1<8Q~qx_113=Ixn^0z^Ccy1a;0p)faR{k*ksUXXZU&hK|vrreRIra#eJ@4$wM? zQ8@;A)S;y?jjt+K!^e*?*ZprAmL!P0Z`&+;sE_KEGrtHP0+Gvn)`sa7ptztJH&U1C z;_(+d3Pb7wMDghG=BO=(`z`dl205@23i~Qf*y!lUs3i2j1>W_Jm#K1J6+=YdN(+@w zixJS`Zct|MT1Hm$g)-ic@k&B9HX<<#9gT3;D1X*qwknn7|MSLv5gt?C&pXp)1^|$9 z29vGdw3#jnzI$iq8+xntkqtk_xrH-`k%0i^iXg%4=q zrM*K5rY&lcBX|+KtA?@6A>HS5*uB;El8DreT|$??zdq0Lj@3U$wa>(41o*Zox^7kq zSb4NcqfVdHlpl8=n4(G>9Zj*@)RV^azj1YQBvmM@_C{8H*4o*97Z_OYrZdVUEsbVh z#BQky0gRRd03frTfWx6R^euqqF9HB9 z@ENN-y<1VVpvMaXi#2n3#m#4r#l#>cn|b-!dhnYmw50UW8{(wr2RY&C28iKR_E{LJcYe_ezl&376-zQ>HiQDXu()pxSRn;uE*ssFyP zjg6nT!TYkHmb4cD0AN6rNXBqS%5==Tl#ek8E2xl)FIg%VKQ#`5G}>;4sz0(#832EBld}`C+2+Zm@XzyQ}NnmT@+CJ0Vs%CPzR!SzY)! zWn<{;Xt%9smWpG|juSGiy(*z& z(y2z6dia>`iTG}28)3YyD0797>Fx#Huaw|tD)GB}Q7WaAtPj z#6h8IHUCp!qQyM@u3OaB*m&tP!6=QGF)s>1%O&3RwVY< z-AuE>uXeP!@Gk-3XM+v^q{8U-)Bgcubae^< literal 0 HcmV?d00001 diff --git a/LoopFollowWatch Watch App/Assets.xcassets/AppIcon.appiconset/1024-tinted.png b/LoopFollowWatch Watch App/Assets.xcassets/AppIcon.appiconset/1024-tinted.png new file mode 100644 index 0000000000000000000000000000000000000000..61aaa37f1d25fe82a2f34d8cde1aa1ce09a9c573 GIT binary patch literal 154575 zcmeFZhgVbE7d3hUp^4HgC`b`$B7&fxv;^-3m97FJQk5nGB3)`CSU{yquU8R~-a7;= zv;a|%UJ?aFsi7w%ByS&o-}nBAH{Kn?OB|AO&R%=%wdR^@t`l|5_{xEOLi->HI-qw| z=LQ5J!AB&-$qs&CL{3M5A6yTwTKYf`&u91#BIz`bFvMc@SWoAY*`v&*snFNQhXP5= z_f0N&mlVajIk`@-UE_1$6mM%j`YJj)YK=RlH(g%(;kC#y%5$URiH}59B_H}8$=On~ z&6)cA{L5lnf~<55*JsO4+q?VOdtaXAJQ-JRFK>B-Gggv!|3Ci>G!s^NL!N7>Yh1~;t!O!2nXJUF= z)w2HKf5-ht?<7P-S5@URzGhXDd|oK}lwC$@YFtG{g=?M2v19Q+e*EZW^78QXkjdr8H6LBO zeEG5?ldIypthZ7-NOZn{Cv(z0DDff#Da81V{zWAt0 z4g{p#1V5j=ZS$>Uc}odyL50_B+(){8JQq`dKmW_CW#-(Ud8*2}FCix8Ljxs(gZHwi z&)uJWc4IR$Gsc|{1}7VQ-qSI@zGly!J%gX162lDpv$~pMVH>E+&d%;QQff0WG9uID zzns8eFcL2BhqP%Dz(Csj;2X^0Q}C5ec*nJ$Un+h7+)(=&dD4+Vrz*P*7oR$}#KCj? zth)2tZ>h}o>8_Ky+|sAPjb5z=Q+_-*p@RF-=C(%cQl^>$@OVX>d}wHBr9&sr zSgnU}6!57V@L9v=H#J$^EWUN3^SDp1<3pT%J4>31+wiQ1nZ4Le=N!qSZ8B_(zF$_$ zL59u@4;fCwg9m%Mx~>*h{Ej8O)|dVr6%JP2<5t$9nkG3~>yhC;R;e2l9Be8k24Vem z!CkJMu5uw{?_ZBa?cP({{r%RdJ3&g>r9ZQK;

o&kNy^u5G)A8@hjtqb-PB#xZuM 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

?YpwzU%nq_g%*?zyCTOe*7d32HR{#vWJ5>)4@(i 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΄re>kh6IpgoMJ)wd`9)Yb!go8G0M;5^ozfam0 zUSbaqIJi6kBzEh(#|SpV$vmnyFY|!_^}R3Jo9spfrX`#8{*)60Wz{!TAUMhsO7an~ zI6U%zzZ1h!|M3KUc>P~`%ka91{NKC>FVof#Kko+iI8kvKZ|`*!=O~f#AFql|y$m7{ zwhKhF@_!eIqky6xk{M(9PoC5aBW7aFmc7EPCs#Ay3H{|}aelll*p{8Vm!gFIaIfS3 z`q7QlIA-h3d2nFMO3os?8xs60WZ}B5T+aW19b9YAX1eXq0mMft?{XQnc`e6}c4hV^ zu19{)?Ir)o&#P<&=o`oV8SrO=1bK6-{(QHKncf^sFw$3I?>;h-Y~V6mUdO~;e$^~D z`7`i3&w3Ah5FuW#UKs{L#93u>UTr`@FY$-CoD#{s%Y2D+4GN&HgU8tH&nZ}UkG&b- z)pnA;$8kL4v)wdo=p~yVW*myu4uSvWo_j$&K$o=p6#pRUkG4}roMJ|D7MFRg*uc?; zWge^T_>|<@!jO9dxT*Yi*V}E|%H~Q>ko=zsxFv63msha(x?WN2vu{4*N59v8*Vp{K zO#aK_v=~;rCG%qNR22VY>}nL7fu=?;3GcH1Ev8^XrTtqR>>hs^A0CX7*l8&ZnW|3Z z2z&KmclhI$eO`jF$ZWc_RWxn7ns~)M>3+Um^)AbW0n6BSDP1cTTdBw$f51`r)I<`; z7)21CY}*ur&MSqBWO$cR2g&!Mw$n9s`R4*2_gL-?VPHC% zb%TtaU%>w+OB3Jfy3?0a#?SKm_Xj`x5&MQe^zHV?|L4DIY^H3x zUfTA7r_!j;6j$5(fefq^F|A*@@?s6WC48^rf0uuvnOUPfdA_{2#*+q@+ritD`&C$G z9cHMNBypoQje-ejs&Se4Uj(3631s*$o7xxdNiT)>Nrz3wb0%xi^91P)?LI?sLMNIt zK&?DSqRm&qR|(Nh2rxK~69U%!ccJY|X7Vq0`SyHKe{Aa*Mj8CXl^gKy@MkAV!BhIK zoi|?V1IRVoSu%;w4fvU?CP-pf&g}a|JjRTFAi_JvKW=8*xBb&2{x*ydOD`YMXrs&9 z!K1>TR|90tTw!357uB8t7-PW3a91Q)gb)b>;TwZQcp1^L)3xLg1A?ZqWuCZ{ ztSQ~^m|FB3fExpVQ^vW>NgI7$EKxdj5coGd_?eG}X+Adrd~&hi`&^zAC8?9|yjK{N zkY`#?hj9i+J=xX(k_^R>tnm`lK6o(K>v(in(N@(Z{YkiOEX!s1ng$FGm@m=9$%K>9^Q1ts8 zKqCCp5H)A^%({_Wp8CI=Ylh*8Gf^V$%?0^o3#BR{;`CmQG9-CmA zXOa8JRv`)l($aVF)R6(6fZPG!mQaNm^# z+`!S~P6N1ti~BnV9niifJyG}dSJw$s{x*C!SPBo*aYC(rb8z4(({H!^e>NC6*YgHP zeP6v6Z)~@}?arBT-n0%0Ad_$DPUI^R^#{|F~|DofYIAkJRCk(Ls;21U7583Q3THkoD+({k&F83M${h3zy z8v_aZoh2R6c-hzs3^`;1K_rfPtYlZ^BT=K%4}=!DZ?c_~TqX&nWfRZ|WW~XM_gs>{ z0)pJ|&}&&f@JE23iLK}oeZFykT>}QW)c<7vmKQg}jDPlNDrb`T5&ks@qjrIJ_|K6i zf_dwZ8}@NflgSB$=N}BXaXhfE_N{-icZvRhhj7Ed|FM?+3t#r*?JIxb7uemUlEa?< z?TLTISF|{OFuG{$e8<-j|2!#E4@`t(sHgbqpg`eiV{9;mW8Loy{@kgx=j_`SHEzQ* zidjkk7BSW5cP+Lx@RaB${*V0WksP<@8=7G-Amy|T3`kry@Usz{+mhl_JkU)emuuzI z6h1983>>uQn|y7MrTNinI?5k=0GyW3?lHdmi|Xa5Iu)j*?N-5Fpm=@Y;V$vwa+wtL z0Q@GH0xY6{z{s_|e*Y{$>*byWZ&QCikn1!g&*6V)#zFa0h1`rrSp_V>Q`L!xaq(nw*P61MHn&$_CY zJm5x7dg{~p`B1+(p+mN_ezzx<{qtY~y*2(r6fi%e+=%B*Hh|sYcl@KllXSWBm1v8c zF=*FmdZYL7_(kq6AMBk@JWrc9z^FtMoaY(U)!;#UZ8W;muRBw6R8q`m`p(y4lC(Fc z<70?R{+(zu*@i*fDFDevMF2?FPl3IGZ+6-?$UR{oAej@M!hbb2D97O1Sg*dfcPG3E zkepq1{dRnPP~nWH4*bn!e~6l`)|C~RXb1i^SmR|k4_~cL2jk~tlbA9!3AO9Q=CXv*>3)K>w>y>lXfIA$xUVUx5x5vfRxabR#a!Js@3Bm{k5%&$@YMc=z4=e%z0=-}^N`$3FMR{P27#64vC61D-TK*>%u{ z0*0xX9^|GW3E3B!TtJFlwP!mBE|M2qm+QMp8)?YA`WF1zp`j|_pY&uC7qU^%cK6Z$ zN=JttWqa;|y%Aa!tLz_6&5!z-!4I=(aBv6y(FQG>E?p?w0CyocGEgHu3jh6o?HN!3 zmy;-T`5^d+r$xtVznoy%T7{{>)$uRoJ7g|{h_u(`bPwLIL9g5m|4Fo#vRr;5WxC^^ zvfOZU!jXai^jWrH(Y@lkL2CR5PR{rjxb>cB0J#AvB9Q~{>x}d(6MA!6F5UD4i4g0D zS$#X7&qV$sdBpOkXcP7eue^?F>tfBLS_;FTx^Rx@z3w~VK>(@w>mXozq>-!2>&qb zSF>%G$VP40XF(6Nk3@LUth$kW6+g;GTvypZ4%YYJM9FUYg5V?eXkhu@?Ohc9ddg7# zy~5Ud*_;Q(zrYbPY|CU&C;f2XbL9yY`_02vtSaHXVKlx<8wuPm&y^K>yl2_xeEN^D z|Ke-E+`jY+K9xw>;7nSTsJT)6==7W7E6@`r7s=)d;5f#U0S9WQph;%dyCHt{;|XB& zdcP2-ETS9@vQbT#((=8?d@xhXxlus#5@3(o{jaCZ|t-Qya%jVOh|POzIz( z2M1dJX02Uri(w!`D}8rP^2`_Und$&G9gQTzx7r8~HQ3+3?kaIV+v;uJwm}B=t{nIC zrq}EG|ACf$X(2W%Y%ZmZdZ?gkG7{|5M`=v?#jYjvAXoZV+{H^ffvYqy7m!saQ&ZNqn;p!U2P6i&ke$F7}LJTGSegXdK|0@1VpP@#W>XlxkmK^?z29uX! zA!Tm-pW?^pb6nfX4fP~;R5x)jF_Zmf%Z{=jOU4VJ2gPU9X3>KIBwsFj06Ys+4iYv@ z4rMsoJd$pHcBf2IPaYoaa=F(&3rePe8p`rD8H--G-*eq(Wv9V_j$eRl$@mwxb(FJH z*6+%`$-B4nx~12C@LK#4^l*D-cF^a(z8&-$odt}hX4< z-v(?FUaw%~h&E1Zzv8*@A3}~M;vb|Ivv%hY(9-BJ|UGPmd?**_|B-Aqg-945v6YW4s+ISAMO1r-n;l@0r{SF?L z^wU^G;S+q!qt!gx$RpKi{Wrd0_n^_kCj9Hjxt&mYu$OIZ(2;hu=S0ihW2HWC%E+Gs z6b_Eg54qd#9z5tncF`;bKXOc`$2Lr3(DrI_whTu{x*3+O$}GPwwdWZ^qhvbfo`wr-mLhyT+Aw`57XKc5|FB>i+!K+4}$Y;+X+cZYw{s(7}D z$+UJT$Xu}%@a1x#=D6}9eL|yCw6LyiwV4zHrl#-5jG!r*nFTe&QoF+8{t0W*Dnk{yQ$(#f{EbIc0$(4g4D0%DTo? z760>Mcf)bY8Qd6^d{Hty^>)}BhO?aLxnkpNzr$;U_ksUZDciNf$+F$!wm1oS5&q3k z^_2(r_TlW~MFS+K05tS_Kyt#<(HZe)`G02rr=Z#nuDt$z23v~Fot>F>{MR1P5hwZk z*=3dIB>veho#(#HmObRzBW=!=`_bj(`0mayd7qoSg-yTAf&tzCYl657duL8UZ5fhw=)zTW;Uu^GE^Yp}(AR?oppu!!v;9E2ZqCYvQL%i|U) z-c8x)xqq_2TRY2Av-&%U+jTG?MT}@H zkx|bRvYn`jn1S^FMS}qp*Bv%=s*^ZrS@rQYmhF+=RQ@dUvgd_wy<}S5l~DkjQ=b6q zv25H+{M8yzTel&b?Vqe8CE85Mzdq-8@|ztI#R%i|5+`dmG85uADS+x`$+|ofQ`=(} zQ(MsLKzcbYDbO0-uIoDZKPE0s=(}@G$XIY}DBt@5MF3VJSM8P4Pz5f#r0u>eA=hV& z$4kofU~Re=Mz^;5H6^OkZx4FimA&SXD(?UQAOJ~3K~$63XYK2Ku_epCu+7(9&&r?h zDI71sAF^-`xSY?&QPG$1Kdy1qS>#RSe{ve?5>-umxiSJe%KV}4e`UY*zx`kBo4)nC z1hzSOSwaU^S8=dSwz+O^rO$>61KyV*}CA}F7-(g!Z{SvXpkGPGTFDx9Ep z;-5uH{u}?PViZvvJE_jT6g|bE2b$dx6HRUc#z{uZN={~d>2g-6B{fzjXZI7}pBkP%Y?%clH!*Om%^h^^?tHD;l4``80 zA%KAn=VolpR_tWc+pKC&mK=mh{BrhGxU_%O25pfXN}HCyJKeTSo=HC+XE&{^F{2&X zZ5d|!AH;vm+!p>sKP1f|Of?{Z7zsR_XZq!$`Xz5FzixE8Kz#1N_9YWs$a}PTG5$;Y zOnF?Ev;8@fzKmwBv2vmGqI=u2m;0CS@M2gt*)BdkR>p*wh_%j+ax68ilCx5nm z-7onfd(XSyaf%SxQ~q7amkww4nC^LuS9mQd%K|VBuAi1VbAf3(rJa5 z`#{4vc|T{uT?i~7GVurbX+}luI?L!?dm>6_B9HPt{u3RdzKVvbUkt2FNaXuMLg;hV z$4*T#iqVH)9MmvX&i?yo!>lN46-3?DCs;P9%JeBQ>@zAl>!LHjc5^R0rGR*?^Eb+% z?W0X3P_|6pC69{K!+CsX}9C&Bj<-=6WG+f|iN`RPV3+m36L z-~D26K4l5IAw03KwpjMQ>CgS&_S^pOpR*5s_!SwQ&i@WC>hk~t930=EwvH&Wi7;z@ zr6XL8(cMa#2J_L6I8t_8VwJ6MF~BnEFx#@;P6ZO`?*^JoUcjim7U0aY!m8U<+PFq< zjyRy$_cr@Lo+nKj+=7A#!_l^w@tJZ1ymOms|5vt)Cn8TRkSrivf8$C}+F*?>;qNG~ zw-#yWd-$7vDS6t03^YN`m!EA2%3hQ`Vg0itE5ls=r{i|Y0qYdXC@uUzF$0oSg8+O~ z+FH9kKCgb!4*F9Z=~fBoBNDxIJMPW{iN_?AgA2kB>$(@zPQ@mKp_ zyY#ays-%kSUtgk4_m|f<)(`rWLY8>{f5g3e@V?tsAN1Mp$%T+6B_RZoP)IZ&Ni>u| z5K86J0#l*YOu3|DTRXNLom$j->(u&(jN`52W$LIcHWpDwrbU?A6beOct*tE)h)IBO zhj3511pTzwi4z&%UhtW$*p$%^NwABzUZ3vPW%R z_GbxpdINtN!HK`m{5JC9iY)j;=w!>s%|{vjF8>?<#@su9Jb9wTcYXe_?7yEzAk~d; z)x5-PKYU*ZAB-~od*XYcL6=zV!pB>l{J-cr$y4m~&-6$%Wd7OTocOyvWuYNi<&^={ zstoGcfRW*4t=7#p9)e<~jR&kz0%;Um-nZU?BbGcI689(Eo+uYxbY+v;C*C zJkaNMM)Y5!&hY;fD>#T+w~vEVkNQmjTR~?mx@QelM6&KEq_wf@l%!q23xlQ(9oo`)}`tRSL-tpB1@9~bEFQh)>CGDY+;Rp2nsLy^QuABH1f7UOO z3z9RS;_wo;*PQ^WR$zrPNV_ZDrVqeg9AXBL4J$|M&3YKl5JYe|B=- zKJ_+0Emeop%5GFDMpg{pLGC%Z3@}sT=Z-YBD>O=NQNN67i-*?uQRGi}NRD&%1$PpyMzy(d%CV^W2ES&H+!L$#``HnDVaTKlgJ> zYS-3s*BMKv=bZk1G!Fs{&~oQtONk?>S?$aSHbk~#K!M$MOEUjPdM80Cp#u;{$nAzW z_%`0D{g1vX|FM(Ty>xs7;C1nA5R}juLprdkT$!0UmV05K~1P z=-Kz^>ph<~aJlTk6p~}V8Ul<#%ku$D``(>qirrRkn)~eZ2>Ph|Gr%T&8vVZ0=e-}qDxKK-VAl9`w`-qD%!w^u126lC=i@*4!@nJ` z_^9WTPgVQFx=!Lxwlnz%<60M|QC*eKL;Szxzr=AI|uk}Y!l;~fBRyOW-qvBCNRAY-BA6G;P{akmpCs*jq; z=ejlFS0Pb@8gO>Wy*+pSP-ajZiM8ngZ8bJ_oo0-SOdRlHcy0{}JY>+|QIdHh0Y84! z`cLIjqgVSl<4?}`4*pygsC?TGi_?hrbtXv51SSmAefdXuzFYA=^gaTtDqB=1^K-xb z)%QPv|Lp7DhHv`TA9CC6(x3V2P5{a752{1`PDrQ9BWr_b!thgHy1bF?L)e7}CzN8d z7`C|A#4()iNcdOl9qm+^vEPYH)&w^|wnAx_^c+FI;Qvs16`|;LwaP6*{`aHc0mH6? z1IRK{U{CC5;Rgi>N*62W{7&{?oF)ea_w1A!yXG1D2-ZNgRxJM&$G-nMz@qreeWHT~ zUxm1xQOKS9a|wJl8o~c6lK^Nba_IYNht5ie&a7q?jO5F}x25uCUnBYV-Pq}~15Q2) z`WptLf8Y~!G2k=dHlxe@G@Y}vhn8jZBn+_!%Gn?asBtft%;kO2@CSW|lEQvo0G`j^8 zKBKGcA22Zd7{Uu;=AZR>jEn1#4N8Qb7{50Ucy&Zk0NV^u)!%p%dqcp>O&a_$c;q}mT5B-p5w)E$#`0oeCzd^3RyTs2zlkhJZh#>Sf$6`5F zv%34fB>uS!mpjCI*+ggWg8aUi|4a`x{|-;_=On*k>#4x$eo(t?&~qJ*6SB{e7$WeF z2?kl?M)BR0a$2A1#}w3dP{9D`T~(N%xm*eTT*vM!{^^~^J*n)*!S$W)t&Ke4r8E0c zC!}h%+6@v+`d8?+?ZH|Bkd;E1%G5R_GT9aw_mleE2G`?uwY~hl8!5HtVnpm&G9Wx? zU8**~)b?|AnVu z@h9bxdVD4MH??%V$`+-sU+I#~V09*)EC9gYddE-UKmK3-U%dBMe%*jywaqGV@2+3` ziVg0T{Ucvgk2NulYeECFXpc2(5czn-_)Z&yS21TZ>a_Ih+Cb%q4j3hSWAC#!Ppx%Q zpXG|%ZR;d*oncR~A?u7)$u1=-DjK(~MYVrf4w6g+aPf?aXi&iYAi=LZZkoDPDbY&@ zp5%?#X$9ZI2#dwma&g`m%gd@fY@fi4}Uq(>1KrH z2I0bJ(BB1OB!fJgKIfE~b=$SwWG7Ath@{fVY3-Bh`o+uGId(|^>QeJ~?x(x)O#Pfa z{;vPZ+6bN(jrjP#%RYx4GZOL(KuK?k!RGYA7DKdt>u!AqOiN7I{v{tq@~>iT1lr~I zUVgr+09TO-=l`TZBKr1SMVSUhe09dA`-Wu&Nq8^;C;3f4ss4i*C<-&NK0E zyzx`-gJ;Oivx zBvF#!3uREAm4rPhq8O87zcW4Jvf58O2!Uo6m*Mt4bZ{WIZ_jV`#RljQ9Nv3=`kU)! zW8Ag>WCjX8SXXCXZuKv;Z0I;e{?_ll+(Y?auR1v8FCJ0;xt_|B+MQ%niEhQFGRlfR z#sJ>^o?pdR{7>J8w|@UmlhI_Se?(f0BV+H-xnfv@Wa&7H76Y7|q_nY1ssAlrvRghl zN;}bUX>UL5oU4vj2Taj8x101|KxMv;s3x=uT!QQ;o09QQiDagCF!?BnRWC+i-*5tU z(=mke3sxRi2mskDXAfn#D}NCEI&2 zg!w<qkgMzBGBcK7In{JxuN9l3mFY-0Dt%1E6 zpbf+X5FV_%{g2Hy1Ak_(%2*64?e1o*An)trgfy!^%Z_y5pu!$-d0 zIUB;W{i%N;j#up%q(3trrOo2FQSa|PYXg8oo$hiTDa_f&b28fb41R}0x$=&GY<+im z?fmb(@?1#DRqUkP?nm`G>>|f>Opi>jyb@p(+E$q=>|na=4$U|EMek)rQhHaI&@qt8 zitlvtYy9QDaVFgm->UuTB%9i$OozA+AZxr8??sOUiT(a+hsg?cL4vUh4jma*uBejL zpmLV>xK4DN6%^jLUDj@w5OEm|%Ay~6ziy=cT>s{%=G1n%Zg&Y|Ss)Jm8b$in?q5%( zF2nP5U1$8ozTr_p=mrj2{&(`VJM9M^SpLtJBPRqWb03bKok?e-9^zUUe^s}y`lj#2 zSAFw$;R%~Xndtdig35T*CbES&)mAV@*?|=K>MSf!_#=?BA!z9`q$qGhzt~A8In1N7 z3IfZp-Lw5e^EcVQ4E+uJB=o6hHvCa3^7c0()&6mHw%6330xOjz$P>Nk{xWZ;${FL5 zx-N1=!OE#WD{z{97v?7UzNLX)Jv)2v@5KIW|EgM5UCoh6y);o81z)|d$|FnhFT9w2 z|J?zV%{H@ac%8=n@v&noF`HO>_N})06LOo(wFJ-G-|gDo^S03$hUds3gK?f)FQd+D z2vdW8vCDn59)&IEW7UYI;WMM}+s(_j^b{j4e4;lX23X+t%V%)X!QXo?xN5>{aALw| zi(^nn6Bw5p`ZU7^StR~z?5@uuQ&<^fpCb3Ri@u4}oL82zLl=$qO}*g(fHB}3R(jGr z+5VXh22OL%IOPGCtNv@F=j!~?Wc5rz&;O= zeGo+a181G}UtM1Y@Wr3~YWz#T=QVi7<*|x~Q@R;^ycPduyVS&g5^rrBvyZ3|QSxsc zt$=J))z&jj_cNhu!-FpDFrQTV7I=?lskn7O`zZdec~&|o@c}!S4hfR0wto6SWpqFw zYi>VS6mR2dCsC+WZik;aEfNS!9}e|;CLZ49a?mm@&D3qy7-9EycYvQomXKms!*y~- zL7pr6nd?fJC69Qx?#+DW_c`dy{qm@ilt~J8bJjK)5a+=RZm|>NJ_f1*YvHFpqD&r? zm<3;?_UD$sob={$R+gP7*X_#Z>y&J-R&LArley;XVLRfLV7TiX4sz7d((6Ydm!~dS z5BjcqswnrjXjkPb1$J@No6?Nx2p?E%1Q|(OXye2PqLK>iRhPS2&9P`U>$5;9Y_pBI6mq5 zpZaUxfE%~Qs3Hfk+L!-M$QiyoBN#zLKy=az#!ZqMAjv78@f}C(f&7crl@;*5RD8X) z*-UGj&#JfCZaLC#*v{_pn=hLkwxjOs*gw-JGa5r?Q4Lg*hdE=J`<696p1%kWQ2*9Qi0*`EsRC$W@#HGA1~U#q=sdH?kH_QUmPRf^^H z1Vemdz8><40x)`x;WOkp{t26K5VUo+BY>-K*uKFAHH`Lr>|yu=z*a~QVFdp&`$h_2{88Jw>)9TXa-^RPDvjU)DfsY} z4AUM|{Gm9z)Upk;xPVv}gB6>7TOaJC1VjHtANCyldw<|F@JX+Hk>g+dK2l7Ve6jk! zh!0$k`L_X8;%W8s503wH8L`ns52IZslv8zTxpc7~>dJ^|qW1z))2lx>9+n0*{yznP zTvurAWoG@2+-8O{(S`igkoP(e9=K`zTl`;0v)}W;WPrzVmFPolNjo{%Y&n*5tPoVe}(lO0m-79d$?k?MQr1N|oeCGpOGli*llS`)NDtWUuB{U%0g zqXdmWdGEYx`gW2LQ@ez;OY}L2Y)5Sm?Z{td!i_-l*86+)I{Z8OJmh++Fg%s(p?zoG z)aQ@J|Lg=>KHiZ36rok8(mENwLd9EW<9KShVyk9REpL7R_~l>w0RGHh{{QeT-}b|5 zyu@(9H>^_mN0BM7i<7L4K;J8%7$@t%ok~L+)&Fn;F*=%fMo!4uVK#68YCx60Tp$hw z*vSKpWHa8ADl=S(KCS)-MVLIqKyJHU#nwm@b!5HHsGq!C?dNR&i09d{Iej31W5A;C zR{{y*SHUC6bfH)ARoY!|w>x!Z+U8P0t&$*@kgr&@T7%*I?m9Iq;ZZF#!N6J&Y)QMwk}=0OLd3?^@F#h3eMtIcLFX89 zVt|#ex-~;CMv~uxA8ky?oA43uk$8?d$yZJfl3k85V(ka5hA>`4ax=i6KrERh{igwo z@E7qJ$JpeRl_q4AV$gg5G-#5o>2)7{7)NwY_x)Vbj!48@gH2ZUx4-sd@Tb1)^?2TM zo`rmM_OFX=s}IsLPP_Cz+sDCeNJ2%FM+uwu{juVHMZ`*LI| z^j*O}%2|6%-x>(Q<)HYXM2nmFr}&TK-!}IU`^WfK+hp{~HZdpixm@5+iTpu9ysa)M zV^L_@Xmbv>|0(`4P`Evfm(K5_YxU=&lv8_|>81F0(}|!{0EDT0DK&17zA3}IdpAsg zC^z?e96*qAb>P8?#0)_-3>=WPi6qfM5>JCD2Y0Ejd&z3X|1^kk>;38e-t`-i$V-3d z3}(gi^t?+%G#HBa;p}enKdfiRKZDw?N0I+rr@Ln(7J-t|ivp+&L6+6zH1c!Di9q;k zZ~0ODS6}~D{PM51L4Yrad?M*)H@KPe$CPPV6Lx5~_T}+{SJUc%mAli)8Y$ z<%jtdHe7$JZ=OU%qM`{iZ%CJjoL9!!z6a~?vRknk;Uxww#HNCl-=~E78o-W0XSqvk zW3%Hcnba)<7=If`&`$yuA}Q+*jQgeR8=#!{%N_|sXZ~Zb!gm_j^=RJ$^7{ir&anqJ z{+EEk`R{TViMR39XfbENCb0tj72XiOcAs^!EUE9}KR>#1B>s_Ipd_AYoR9ardNR+# z1LTzk*4CWq^O`a6+~+(CU;cYP1;6X{A0u)MwXE_^eJ|O+?9UHnoVH9;vN2tZt+q4# zzZ$)R{XbpeYolzj+f+Ue28jJj^-g764vAh!&rMJzJUH3^X*3BvFSL-3)9(bdC+NBJ zfRpSWnJF7IcsLz}xIzRxT)g(~39dR@h_}TZqA7Al5z6Ia$ zeeVV@S8}6YaMgy}B!}>I{8Jk`SVPG$d#~)26`JB8t$pj)s79R^87niTO6QoTI5w%X^3<5rwctR5t}ck+!2$OTL@uvRdTIQ$J^-KuLFp>2Rsuj14N~-Mv+Q}k zAA^pq12Fe)a(h-cX0LbBH@q|Xo?L7L$&V*c+x~^j@7>1tZ%+0NU(0*OhOWwiwX$hLmQ;7;IwMI3!NV^hE669G%s zeb)o$Td-omXR9y_|M(PSdJ?ShK(x&R3Rd1WZk(`9DpxSIzYOPQ9ADIK{v-q8` zpXri*=bWMs03Xmfm=-hu-o(FUkdNaZZkvpCxZSV^>i+^ro!vKiQ=0lS^0PVdzjc*? z0!9C(`SU(AL$bgNL$gjc@eho?t8`xB*sT`G6AfHJlYPAp{B!w*qjnZwC{Vk02bJ|&M=_BsQuNpa=R6W z+jTye*V%}sa>>`#cvhW3aUQ80-vdv9|GQi#Xa4RW|0=_%^{DJUI%S?^r_ujq`OnTl zLC^I5y1)A)_^-eIt$6?APXv7IFd3^cwCxRm2jXV3&CpC71aMHB!X+|%qox0P_4YrzlQcNi_2jZDY?3gC{E1ezyPAUmt$2ZcD6- z#6dOy*0~7>ZP+#^XfOcEn=xo+GB}#QBhITKIHTS^`OL`6MhbMtyv1_Oz!*~d+atMIA`7F zwr#^k06~isS6wbKXi$$pBuPn2-9xa}KXH8B6xrsP?D&?Q_d&?|f2H4(bc6%n8FGQ? zOY#RGyYF8J|FLP4t<)HZ@A4=2GiATR*zmAfakAgD0<5vLw8ua)E1rD#T${C9t!cp@ zkc}aIjgFZtr@!HEG65gp7+ZIJ(?v`?`gHa$f-dta%u2n z{U`2s{#uE(%@W1`Mg9$_^c>o@T%93yKTGuAZJ=u-*^u~~{6V5y=JM$`hz|aoY&QF} z9Cq~nzrj@h@U?N<#t7yOgztiPK4v7oBZ*2@@}2O~=I zpZe4mQa`LnOZ7A4^R~5dk8s_iUt3NTsez1XU1+q*6{myS{5NN6 zZAbGwV>B8hxc`IvBNeEM5lUmn!wVYnI4J6{C98FFMkc%Ww++`8k-jo1b$(yU~xq zOVUr08+N#pRi*)`p0~Ceo2f7PYc;0nAj`s3btIaZOd7skXXCt(~#GhMEJjNd@aB27w0!)8o>XSiN2(dP9Ew?kuC(lz(s!-msVDgYvn zHtQanxLsF+E>j$-1&vLKuGi^0d%YU|doaK@w%f4IE9>J`SPQR?D}2TubNiF)6>EdVJy=BXnBg_X1!K&J1B@pdW=pKE`&c|o zwX>7tAY$4pCa%sO8?0HY3a;U6#RgtiTSi~Nd+n}AtvLr|blY3aw)7hd;IJ6q6g8@uK)5WN1|L?LK z*UZVs0A^|=Q8z^0SItxoNx*;>~WXs-bqigG-&mQ%?UHAE4c@Tq050n2W+VvC8v1=<7l~p0leuOz7y|%{E5KcBfJ9-BJ&O!@1WC7*KQ+> zQ^scyOk~J1eF_XpkI?ble40CD0thkfS(sd2Wst8aB?JG!+y2l9Ey0-e}Tj1g=S{EQ$+__3E+0sgEvhixU$NB((Mj^Kw%Rd z+zl~Z7||qO=h=@u;ve)5=t4iZ8;5ZcJ(Byi_cjpf$;gOB&OU=+OM~gVYn}{`(*Q%4 z=d9lo^X#%5`L_eeHH*I>UVY5)?V#`WDM&Ho){%c#(VN&C0DIH)9L^a1DEQ$Z+XJKc zF5O-l-ad28biGF11#Oxc+jRC|InU5;QSwji5B%3}-Urqa?}#g|G`oIU{h+c@XOfK0 zoouO%QojXRhQIka^Gul0`|1M)y5fZOKTCZq^@qKgG3c=97x)kUyu#4h{1~$Qon+kL zO`l~1bC#}@*|rn|g!;#4C3dQT%Vh|uGbK)HkVXQ3aDtp?z_C|kSk*|VB{{^B>t{SR z@W$Wq8vKzjd`;_TWWa;@pKWyFR?t~zpSf-~L(TSka2wouiU0e!+R79I*u4|ERGDTh z+Zcp&ruct^Tq4yC-xECH@};Z;!Tznn#PO@fKdEE~gf^5tQP?h%i-Zi9x8J%#Lfb$p z1Nn{8UcRypW_@rU*P>5)TSa->T*b5Coys#)!Zpr>cRUAJM+c0Gn!HnciOQx9h5t>Q zA#e_TCvnGBhH-~8w_i$<*g-_<5_}a(ZY#vRCT;ay-#+}_`OzewOsG8jelICklBl5x zo2|6UeVwj{!pP+_%};H1`C5nH2lKj>BlhJPR|sBHWUyDDb*0rIEibsf4m$O6#(m$C zfAo%|lrJXT;q7e7(N1^GkL0kXf+TG=P8SWGzxOA9319Km--;jk>0gX?*ru8#Ds(gY zrhtLb&%jB6T3#GI=-Jd6l~lrsmmNiyXwThdW=J;Tat$}hHAu*|bfk(ooJiur1E4(0 zt^)`;F-=~`YB?y?envmc4suGx;U_f6Rf|LE@JX`EZXh?WoD3WtXL5qxs9hqk9LhBG z%epQY5&ub2$Q3KFr{MA!ZQOok?}G*X0D{xVyEk5}1;Y%=`u;kOy^|x_XxD$teU7?6 z1J*);Nz~l>e@Q#9BbMMBoOX_SO^^}%pBd>T%f4U0paeRfZ$HAOi~VE%0I;R<{Ci?= zMmTI&{>}d@=mZ4Nx8lmOF?IsE;jlWidv%7bG%@+__JatUur?-~3IDL6(Ra+OZ~hpsY%_n_cR&Q+?n$;o z9#&tM5&B~DbTR0aY!OTs116BZz3d~Nk3anfJ{_yrE8(_o=OD3(8$mQ z@7!0=;9RCt*Uk+64E(jNKEcfa#F<{@vbe?khRX}Fh2WVTkyCpuGtks2%(BOIvO;nf z^2GzG?b39OIC6(&(6OZ#O)0kVPqH@*G$x$_kgq0@;Y6$|+D5NaT5mE1;#9|koD<;O zrzRSNG{XntBP-JE|2kvtJm;%R`nxOB>R$U#HZkw?{pq|OrN1(m@<9BRk1Px8*^&QZ z$IkTEBq&<~+?W5>f$Z+kT@00Kg0xA40AB=h=kLsrU(WV`U_C0h(BM$aI z4+L_ckbmF2$oU0gVGy#5wLuQ!LmtRT)q^G?O9!}^Rs^5Yor#G?HjF2C3~mXCcqXL! zWVrVtL>p zj?OEwhA2SLkm+HsCw<#)Nq-C1oP~aNoTAl;=};w3DJyW#OQMI$2wWE z5(8OAe;M*@^5MkREqf?;; z)4bAm$~!{X`M%rFrvP*#ta&E1b3WxcI~k{BRk&_OrVUsO!sNJfGTFlfa^SrIXyHiD z6^%pUO6{GlbKqNj=&eD4C;P60=VpBQ2yH#2e8s>1e#n5#!}*^rXmcf*%g?LH@7f7< z?MFa~1`bg(B8}co14&kzcew`vU(invOHerRJwNt~_>aEk+wlWG@$(*32u(7NvpzW; zN^efBUhvo!g@f>R1pnJ|DT4utC2pHQ#?yi)H_R1RF=O6lP29ZjZ33j18TKww4+lE9 z{S*F&_D_9jOu;a)ZEqV1!aUGkbaqz<47sTqK#T({jIbxk+U9}*_ z>Ye!NuygQm3?kTHXSh29FddFD069y|>p;$J2S794tGwf3XWH)*1U&e;>Wuo5|CNAX z!Z(uZ>^kEa?Qi-f#{10uuJ5ZCuwcBK6@Uk|L$8fJV-;e3ge6*f>~GvT~(qRE-ZEbKrqgvTa%OOJGRJNsO>;~R!c zTI#R9W)D|vy3Ea29{h<4Q&Q~3s+jF|wjBMx$=xi;G!M8~Z@!`e(Js@$coQ@J>#r@C zumK1kR8aD6^sgand8vQwI_Uki_QheIA)cnEV*lgVOCB&|{H^3a`mz_|PyPN+!^gk$ zHzQv;Q7FmT27q~?R0~g35_iKZ+iL6YWM5r=$MvUU1rWo9L*C zZYoR9K+j!A4t?+g_8&7BROix^CHx(b)RV+*MkjSThPU&X24DZT6 z+H!ej-)ga8)D$t{&%H4%u$6d*no4Zx;8iCbuMUH0Ou=k0!{6d-{^k$jFMPw>@&3m@ z;0CAg(r7NaYGi8UR{G(#SjYDh##fAfO1g`DWk)zYm_D!Dp$=Od!+V6TCtF25w@&n^ z2QlKS31=h!6~n;4HD+2@>p}X;O>lt0I5U!EG@Sm%621WHqzShY?oR%_o!Rg|Ir!;i zGJ!gx-M(T~%&yJ$Kfwx-f3_3RZ(Z>)xHAe8!s#=#YuWx#q8k2lrUaiqJl{ZamSpz? zq6a4|%T&(72(itE|IoxWWAAWa)sIgU?7MtRgcJV>N5BRKsxNW9x0(Bra5d7%jnHv% zia$K}wG9#1`}KS6Tn_*jpWTmV(exPby$d|azvP|$4ZdPsf_4|8y_K2(C$=$QqBuw3 zzW$Y+^ovYY8|KGEQpU}Ls5ZOJ$&_JZd~(sdvM<~6?mOF~9n@HIvaP3y?v@k(tjyQsVmt3spFVP zaG#CH9U2tPBmANMXjjT+_GtgOeTjcV=FSd~@aRjjLT)Zdv|9|9x3>f|F`m{{~Uu;M*-TdPxajAqUuzq z01+1^;FkgbLf6d&TLEyI`H`4u< zSPm#qNj9DBb7$+BmO|hCPg28*vKV(V1Z2cQAK<^=_3mHBfA}@uj_>^8pCN-t_$Pqa zfn~!j3G4`(^zsyy+2)$DtYq&DH1i^P0{^2)s*HpSOkJ=rnr4U4Nd9gncO%aM>*fQP zR%iPceKjNRIr1p|Rki5sZtlz435TA@bS41AG2PIl-v0OY&GFv>bfBy*woRfbaB+fc z7*7K(w=U*!CP3D$+xj2DPo7Q5G&8&`*^JrUJ*$tE2wO5{N2}%Mx)R1_;FSOqli&ab zY?nZTW8Hg#WVl`T{sNeBv>&ZTh@<`%-kki}EVS7v8_S3Q%=oLv!0}8sh9y(N5`dF4 zz!TXCz(407`~RIQUU5zM>&A*;NCf|}zw_r={*?IKjDMOV-{!)@zJU*!U;8jzH_!t+ zd1jmtM;W^O<{5txw9RMMm&=IN^W=!8%Y@M(J|CD9F)+pc-t?0acfsE_&Lg8Wrwszc z_wojG$j~JDQ2viGf)AK605uW1gCOt;Bx{tJZB z&-eLRj_=a54ooa4{GGro*d8vvp*wJVQ?CK=C%;9ug0(CNTG zTFmhW(};aA=(pHNB4ii3{@m}qt8-sWbxEQV-&oMROiDbu z#IwUKU$^jY%Mol3xw_=Jga7Hg?hS~X=yoc1Z{VXilLJ52<=&j?fHb6ok9uMGKUfgu znHpI$ATpb`ojaHB>{K%*-q2UU6tzr3USu9l8f^#E7;Y7um~-Ik{>~5KEC1R%@V;Mr zJdub;*bSaRZK?nEK+h8(R>)L8VH3WRM|{;Fwi%)jikwU8za)r-wDyK zIzP#m`zPXOi3upE;%E2#U+7I||ESP_@66s`tA5y|l?D1P|5)%{r|tf~+O?#L>Fvj} zQ8*$S)(!gRdMCX6t+%!1o0j=;WdQb|!x(hFImuN%Z6FaT@rH$ME}Z8+&TaqpD#rhP zzXn9s2k<;-GGN+i_IwYZT(1IK%;pbT<_=1D4HsJ0zj4sPzKy@l`i>!|9dye=)th8W zL>W7s!P}ja3Wja*px=UITgG5XzikM)2a?t!#mxsrB+)QBgzN`CszJWdpzS3vV)mP5 zVT{lbigmXf0TEDdsjxb*I>DcCf7A6#94z%|pTh8GHNNu2?Vt^cklmY;HVb5)Rs1gl zK#{eH4}JDC@rSQwzVe?P-!^8}It3V|c^ zg>mV!iO!~s{usa#SaF4fC?`~^SQ`;j?4-Q<{8s;TPevinq6nFaaTo#1jil4?W3uu4 zoRs#)EyW*A{nORs_~euOr+Z-FQ3f(jG*ZK;ij(Sw{?c3Vt>66<&W9m&0wCrS zd$r(MHFmf3RAd6$R>nFoA|or;P5P9PlEz3}FTVVp6LhQN zzddnS?O9UHe0JQ}PS5Gf_WfeQmw*QjJP*Fk2|a>tg0s%57aGNf{O&|=4BKs;zTuzL z>~7OfTVVXDYY`ou*}q=>g)ez0JqeQ4AoYr%-ENR})#qdLn9eR(iN^aSZH8yRIpMz$ z|7FF^?!#rVlHQ++|EHPr3valH8Cwewy^Y^X{wMVm?b{}hIr%i68u~`VfH&|Tn@vkc z{6{cEm^=L(O?9Vhz{vK?2nesc_W|Fd!9E?Bu?j%;8B&jhE02{z(_Gz4C60{U&2wkMy!o;P+%2F)hGx!e9&^qM3f?n09 zg}c@SUnhFdGE3S!JJ$QmL%L@-liH|sSe=WWczvZtB>Y*jZu$RC0zvIU+Nnf0-HH3d zCjiw!oDzBd&B;JFV^kA7Qc7yS{LYGz>aC(`K=L(P_(BmzDR&+y;6A(GU0LTxQzylS z6?UAA%Kbhc^|}Ykx!z$QMraF|TydS_;J^d$U^Qd| zsAV89p~49Wz1S8HqOhHwoXiVcJ9Ud zB>b~T4xwg)u8!!(iiS+4pnZzbQg{78C6fo_)FddSAfx~)POA?qx|IV46SsA?{|4i3 zaR&MGEQmSmE=(@C6rRwt!F?qI&=KQ+KhG+gW4AV|{=bqDC~eB!6I}_nDv3 zugSkJlXm*5{hxrJ01!)^q4rq}4)o=eo|24pJZVDJ+1uk)ioG+{&Sy&KlX-C#_xLl$3z{Y zZ)bzJZPW8ZeA}%8u=j3@(r=y&idX?!-e}>@G-NjHSmTUOzZ2bxe`fz5%t-z($}{|8 z|Hb-7U;ak?hT$KDnI>I+(+zS*V~OT2-v|82pGR9$Xwu`)K9!T({C@S(&GcJ~HwUZ{ z{Lj8tXqD)|0|PQhfK(ylF0|AR+SL!lNFNyHIKp&(k}YfeKM#9^^cb$IRvwf?`#aGM zfxqfaqSY+|7#-)j0#FvLM!_WTMH1j-z)p!S8v=W8_Z%cRUa1c2MMx3V0RrZ1pxQvS z!gCK^HJYr4T~hmi!GSyFZ#{pM>&dq95cv4rqu^fx!^F$#JQDv&7C~j>%9=mYBQoWF zuXVzfvs0*TX9ofgYEn6u88wDRk4)Z7mv|N(dVT+gK}x= zg9nAk%X2f;zTXmmlc8vngP$V*O;F*yM7o#X>BZa$!^OU}xA&~iA#bZZ;>bPv&OU!4 zFegI(20XA4tL8!IN@RH8DmIy$GQ%Ey??l%Enz`G%?Dp*2b3eWfy6gOgn`v~H#hB=P zkj>V!EKKinHDRAb@ahO`*jF1@=XWIHn)ItZ&vtv#ry7BO;JIeKnTPXg8zQQnWluE# zUluMhAx2H{hs{LWXCS;szy$#l1~AfwekuOr#6ouV7rrrXLLSCmI$=lj!SRUIB6f72 z$tlZ%p%wHY+pvL{KA;oH&2`K7*z(PTCA$FRqywMvcY@pZF5sRoL-BFFx$=qz|Ho@YIw3LY;Ex*w@dly3Y3m!q4K6Ex+*zs!nr>Ql-fH53TY^Zbx} zl>pw%@Uwj+3h1B+q|GkrUp?nUx6(TaD}0cM`fQljKRct}=PvA{K`zzntU_JlZ|?_{ zGvc27zqGgM6L`iu;s3^82a2+s4}G3!%CI_&z6U^!9K9h<%;Wx`m`JmQsoh#Ph;?ru z!6BFxj`jGQeBOIz&&?=tIlcm=hb6kqhbNPK--03kerWv<#+B)C3zkZ=bdTgnIO0a; z5zxt@;mX$6oeRlem9LYjM^mQO(PW@6PjKl`2IqY9t_WtAmr-Ssig#4ymVM+2JBUr8rZ z;_r$1h~Gk*u?GhTd`{8~fc1=Kx@|+fM*JlY!@}u8gAnsda#5Z~oq|8fsSQ31*rt3# z;O1Fk3w$`S<5LnH9>+hvIX9RlW-NOjBUS+j{~1n_t7-V{ey<@+ivLx_y(Inn<;pp) zo)liMw7eZ59}&$ObJHexc3tWB2>iYN41W0D4wg{E5@)Ym_*MNqbw40G=I>|1>6KhGux!`uxff6LvGO`V~me0s*me&lb) zpZJnb#wWel60Im6aQhpB@RI-J@-AJ;--dg0-xj^40fIhAP{PuCp(oV|{HYy@A%F^~6-$F9 zR&*fYKicxFXYS`Rp2z15VoLfQ=N#9OzGazUqLJPmR{%(GM@n}}&}4=-2YfS#whOsH zi7;(|+tEVlKk9?;Z~DrHD;d7rhS0UCa~fdC^-6Nm^u1YLbRb_(SEBz=I3AAw z!v<%#JYPlDRv~)}mB;>`W%@WEkP?^jUZE1>XTfH;Q&C^y_1T7l$`r5=zoSgN%%JPN zHlnnNGOlOYvN8blY|e>!1^&w4e;5AT|MoV#@7Er8XOS$jB3K9j<$t2Ow`Z0(@qwER zlyc(EO=KslGQ)!>fXUpXc9YLxc@~99Mi@;0%-1{k7Xi>_)ww8%=WKL{oT$sPR4OE{ zxOI*QNOY`v%lPj{A~QN$0ELHOVZN$NRcG7LKblX$O|lQ!|A=oJ5eUb-d-~6TSPsD< z`13nUe67?x}<%B4i$beKz1L2w=0kgYY-uUd`-J%3b~d^O0HK$^T&ul*9&zi!gH{_zbTO zUy{EW*ZNX2D1&Gi3AWLTj~md{05&tuNi=`fGO)^stKm-~+>R4GhCdIkB$#Y7M@(Jj zZ#K|n#PD_jCfSwG7t#QOeKs+<9ajLNMU8)Kz{-l*>XYX_`SiFTllYdp5sX#{IlPA@Y*D>&h#rXW%UhRN=G7qDgczK zNuLJU;;VU>qtcM!U(^9fIvKy@AfUm5-7|rU(J&Or>8LHJ$>Gf;KmRo-OFOxK-yWK} z-80qkC)vgW%A78dg-5tf?cawzY*D_?vcYZCa)9vPWh3zeAem9QJ0|o+K5v?up z+kbp7N8Rq_AVYZ&f=y4i)G8h@C>=i&U7!vr7wF|b+rNOTE^SM_yK|+!HoX|_B%+&6Hxi6-zYvdo7(V{yWtd|UyCgVlPSEhp=L zp=0wm`!ThDs($v&aOX0DKYc5`=n+fNftZ!&Z^JE#ym*JPGkef$(e;Y8>%Gz0?yoyh z$Haa%gj$_EnT|-}HCF1vOXe4yG|`94q|)CLt6eUgwjOZsO!(ze?xP)rS9BF1d~69f zz=^)|fsW#1T^<8vmLHRrgkPgA#(xa>S@mPs3iEtST^x_-eVGv15`);S5Ad-eJ9ZJ_ zwl?4)H_@Ka{j$H0h#CLzBQ=4V!bgVD+OVbkR?hJ^xw&5VCnBpK!>}ur2aX%A*H}HVaF|Z#*T3qe z_>=$K>+z!Je+VgrRF|vzqh7A#{;bEhVpoa7OvWkxdUEkU8YSxZQkOoI101lhdmZhg4+fj(+(zp2lw2H**FEEwgW=|$Vd(mM$n~h@JJ|kVTpj)R z`Mr8Tl8x@m!^h__ z?erV>ot*B*)~@x8etgf3!*83#&1c?UDe1K(+AHB-$y8p^U^6)yK+!Dp)brsn{IlwU zKdqtn1b^%V9X3c}c=`rv7fQ0=GGw#+{F{S1w`*5U^5bm!%qN>*x2j^-F}>i}@-Pv{ z8u~2wbq#IU68i=JNW0r_|7WxfgdKp@$JK$HG=Q=h=c^n59ds#!>sP{;X8bjojKPqe z0C}zp&i5;(4=fqZlJpzzSraGZGl04A9pmDC_y&TYZ~J#+PE)5Z37Z7bh*c7pfDI}+ z|0!8!e$C0OQrMj&wvr$QH%x~<@`b+%fBX%fgwK5K%d`&hU3?Cd+_kShd5>q+j&eBz z(326yKP4$)Gozj24^M{fPE3}4QEQCJ##lK{{MC1?@-iU&-ar(1j|1%8}J@Smd(3$DS4Ge9g z(l^SxtmkKrb5;LbF2aUvgX1|55M6o z0`h2Z5K$j8cyJy_P+@LI3aO5xX78P2Ix^k2wTYe`0ku^OV-CX8{i%%5gJ64bf;=HK zzIAmu&@E?h@9N;V)ozdfiT@N>Bwn2cY3%9;VGOD&WBZ|6?Ddd-qdc3tEp^ZV4cq%FsLZa*cDbW*+~*#GAh9o%q_n^?msD z$Dh!^A|q^6&3c-ggpt15y-wOSk&JjCk5ornj-fNc`d9ghc4IVJf*lEjqMXcPCs+k% z6qBqvMok2WA?q?*pAl>~PqBKDv(Z_tgb(zA6486T;V< z_HPF5eIq{BBMQk#p5(!U{^UnU^|3YVs0K6BU@7FN$H~!Xd zEZMhqG(n$2+&b$Y0?&h3s-q!)$HX$nn;Y)XyDOgaA%S#5s)%{<6gvb&tPx57$rhz{Gc2mC($?aHI8 zpZ$G~{~JG~VQDsV-cQz# zFeLgsomb_B;&H5Z1ApOldfv7D==@iCR0EqGO12zm>W~trGI}4+yXAX|Q-I#tCUL`! zJ#WwtI(orqitK&hl?l(f>~#qLTb(l&?uUjcy0X6+D2-ZB{zGWe;zclU?e6M)4*P}5*OLyK%=Av(*~^uJz|=u@ z&fZYMNlC#?%kxt(obh+Mt=c~82*~pG0mRX4*Wk+-t5e=Q4pS4P%RZ9N1~si)K6))S zK40`013T%T`@8PxmO z$;a~b9NbN#3p_RVXPN)=`Y8C5PUawzlG>}x=-YDjD^%TW^+>euM=IK?ip3y^6c1{1 z56bYsxB7pxJ-t>7{+l<~Yh6`Q{Xd~UcDbv5O5%3Wm-=6m&m212Z@>@4*Asy3qcIa+ z{@3;o0F`U~W4`v0lM?@3@u{!+X#DG6^g6ukMbFjZr>4^Lb~RM`ZM3`}|57rN@wRtt z|57f%iEQ>6L36T|H|$?hfd0Aozmxs1<@kM9M{G5PN1c7go%dq@GhN!o8;91$Zj2Z4 zujt?{|7Tq=|G8^T_%*rhaIMKNuMF^*qR3LinEVV##5@q0-7?i_<=GkVct&;!EtMV zQv10c!*TEZavq>Enc0nTyz!1Nt#ccuhg?sk?xS9n4rGfS5r5;M3--|23g8C+o4`Wl zX^_5DS@l7nByVzc8P@*GhS1@o=GbOCJf`A8GeO_mvC86*nlhOe{&tKOeeO$~Zs+<0 zZKYvN`pr31hY;v9?ZQmG?T3C2|Hc3MHvHgE{6e86lKf;mRc`|g32b(~6!BV%f&<%F zaYS4)1+&E&Q$qn?7+Ak=AW{5BZ4X?JTzKK{XiBYij!X2@-|V!qxi7esfRMo-%6}KY zM0U>DzPdXZ7zJ>FqN4ocVi$8->3cK0CZXI7iF>F*aVD$)@*< zK(rAZZ;oj86r&t_w_v6{6>KkrUD*^}Z_2U67gPBi!7-R!@zl${(fqf)n zY#hio%P^d>cc8t*Ys9xI%qKwT@8}E63%^&qOv_G5}JS^|$yxDWC6C{i*Q-w|UG5k|4DM!=3ZUSH|yT6K60HB5$BaMlUQqYlH9jtT0BNn1~n%BA3;|xQ|L+Y)kyu7zfgNe^a0a|;=Mk`Yuw+V zY~_Z*n3&cv(fkFspw?Rh0IZ;mva$p35@A4RVspK2fWYe3WW4rS+CuO&4+so;*1gXLi?3UWzLcZ> zyQ?2mcvw3<>}nhDT~BpT;9mU`{$l*m^S|roA^ca~@uM7IF6q7}HM-pYgOh|2DVL8rzdHjV*pS)@)A%^Ya{-8t19hz@++-~AE% zg>QH}-uo-RF8I0q65|yGIGH{f;MK-9qvl9N{#ZsbdMVJcwz7Rp%>F2Y)Dml(M?wm-}q&e-0q?GjgI%{#CiiVC6sKpENG{L*WO@e=67o zY#ptFB#rz_0H=UupB#ZJs4(GA&Uwx$=fkg!<0fL)wvZvFwIy5@6xl3w;8X*a3-J}D zOrYkwrX#*rAG_VFm)W@_*#YCbFN3Eu_t;M`1c571llW|_2Rz}Yo!ou*{TYesbe8?t0^-6 z#~ux46T<#ACo{-N59)g?;a|XP+S6VFdZgqg;D*KU!Wd4q3-%Iu`|f0$Hy$+Nl?;yC zmE}2j#-{*WCx|_AA;5Dyg}i0CV(q{#?Vhh?7Ge? zKJ0nV!IypRtMSIq{&>+-&~MfH{t}yYh<{JXe6HuQ@87;)09{ix#l15alOVr=oS1w~Qk)ozCgWxaq_S+HdR3F9wWZH#|TQ;rQ zbe-vD4}nq^M5oS|x69k7p`h^}3-NTWG*0NGk!ac3|NigbJpfebgf@hjCKf=akTt2}x=E>%F(}4}3D&g5Ba)rL z$$FA6PF7i-qzuua8yH|m(Y1d{e7ipWVO$R#FgW8MG|X+Cll(Ajng7a9_C=k8Acb@J z5-(&8R!1a;4^2I7tFycI+-)?}v1FS)xgqtlu7X`XFC*yB20v?C^%xaxYc=@6PN$UV zMnK`u0X6thbJCp}xnKFU58y9;)A!=*{_pR@;~#ip8&bE%Nnr&bwKb045IPS)30!1c z`K{s- zn3r3RNMbQ}pkq_m)UIo^kL4xd+rqxoBrLzLy@R4 zWV6CdBNGdq^{zAWWv1Kh*z}bWp4`gvcHY_E5_%#c?I~fa67nF3k(xh!aGABEAdSx? z=3^trn^i7&!Q(}n{g#--QGO9P&p1+&?5rg3PBZc(_PsvEawD1bX#-KV%wtYT64V~( z6`N^pQf^qE@z@2Q_xgVXfBX%<70>(7XR2YGO+(rIL+wcKcdNb27q1%X>Mi{Hc-sZD zi~plC;V|^Rd*1z)AcY_BvCPK=03QpU-Z{9E;}dhfYg zviaP;iF($zObt!uMe@9lDM%o-k=JG+W;;r>F{)A7V$!Oj&{u4j&Q@?~i`}J?fTfhI^TE=Bh+hLxI z&x0Cafi@dTVnz)Z2|!1mlX|s(i2zB-t&a+);ctd|2C z=Pu8l@Topol56jbu9DPr{C=s5lLv6`)=ytMQw&~zyucm;D z)8g0V;>oi0Yl8r+ZM;VUn~gDO6+(2HP|sW&ngj%W~g6FJ(**f;QGa-}AFvZqmc-x4EwR^Mro}84!9! z$JxG7Yj^G6XaQ*lr${CPCiP7~k24 z^xNPRnytbm|4VU>b*!-igxWCyR0jj}9%}F({<2mfxCOgusv#`e;Q1b{Ps6Ff-J)X1bVO9nXUWp%qEb%A)8DJoXUVu zV_J&C**^mPS@GHg4EY~MYAQ$2W>3a|oV*~@S}eDjHSg7*%d#eF7SL|E&Rf3kXYk+t z)py|ge(dK;Vi^Hd^0;Di;!(dE0ab6jt%_rixF-AA`VA*KH`U3JUO5QM4uWZ9@RvkV z=(v8Dz-4NG6{^973fn84y5Cft<)1oo=h)dyDBqNSH}Id-Dfd&1?8%Dt{_o_AxFrt? zOi$_g3iiU|ivBw}4Q|$*y%c>XQ;S@YpvQUbL`y@KQZ4TKDrU2%rf=@2@dk+HJ9It$8!n|l>RN_BqwS#fnR*Y;w zIfrzTI4Nex1aC&+`WiTNlR~DWyb|OUFL@#U<$vZA@#(MrD6d;=EM2Er{oXzXS4LAm z+uU&PYP%{(Q*3FLd5Nu3zEI=;diC+lRD0z3kiHO%{NCU{)<2a`?>qOG%SJ=$XVeZ* z3poHxbWhGh+a+P;K_+fn@Xh`KDSpCCIPt(qGpI=KxNb*Su~FpzI%bpsfsXi0aPkjF zCV4qxOXL5H3dh-hYufsxa;zUrMKpBNO94Ht76=5WP5_gfG9m1sFaai2r?vq41fUTp zWF<~z*qE5m=WZA{?z&ax=}PQ# z9DKwd)p$Kw{Pnl;?-u{FfIoB~sJ9qNtG6&!H`&@IDmMcpRGGB)Eok62%I}QgF2H(H z>{{;!sI3HCLBDrCa!)l=M4!2<&VHE9O!P?j zcm8xR{~H_aIJvV^0n8vz;`Pz~3l;>CHQ=;B$X%9yu4|5y2xk2-tlgzBM)&qksLVlf z3bqH8$+R{#VoXu*=!Zq%A4;rS5b3j_`A) zW7}q&B4FLF*=p-2c4m*b0K1(FCAVRZV*|S&vOQmoS{dRkVn_RICygX=q+Pn-axFfI z_%#8iL-kv^BHc9t7+*~{ z)_n}P*s}F2=9ZHyZF(4V;A0O5=(x&+v&_iy-UEI@1sz41=x_L6Hp_FkItKQPzkGP$ z(i8XwFKYZlgEb>yLxNEj=w|c2`Cxov6@0<-J_Nt-cfJ~5`dJ^h>Tm&J)hzr!m6dw8 zV#*EdQ;C1MjXdaLeYnhTTC{=6p&cui6LxK@v;Xs4FVTSC*LYaNr#?obSYY6QKXt?) z3oxDkZ#pcFn1JPm&Htr%joUQ)YCy7ISNqHMuO_kGDgbMtPj+ja9P23o6Qj`4k6t)Q z8vjpib_3ttz0px(+$R3zn1=zKjR2^JWB)G^fHklMQ~RiK6;np@j;_rJe>ODJVwY*h z0Gzdpr%iqK^`6 za6cPQLBpFKNtO~6slRRoLNvv)aSNq0?V{f#UM^ZG@C4kp6#OY@5n7YOE`F2YG(Z}M z8_W25^g4Qqd?b6g(8&JJ5|5zi7?7p)n5)gpp32U<&c}Xinz&oga3R2A!@AY>1Ss)8 z{f!d`^30Cov*~h4v-8dq0E1@HjhY&8?lwS6x*Pnb(CFgJy~l_n3MFA1n;(+Ig)NFz z3ah`EwPpBB+Cr`jHnx>{<-@w)$1;u)GxS>@>WK_7d(Mg1YWuSL-2G8<8aSp^2gV#T z4*blHhIAns;6#`QL}K{G2C4LO2+@!9nS-y=5cyxFZDTx**LVfQv>>ZGQRjnw3ZZcG zV5|+;q;Lg5=wA%#=-|ZeR4bbQOGY5*v*hEsAMz}`;h*|={0pD|Dm?pHkM%Y)Uu>Ja zca~cuIplaE$<2V&Ly}{+x4F$GE??*;-&6!(xUH7DBd`1vCbVtv`|2fo-^2+VlQXcZ zB<}oN`5P`)-id5d;q1Ete*vnAx2VH{zdH6vPD3`s|D|YWx5>{u7E5%ii7-mN?sU~B z001BWNklgCObj1W^dQGI!U${=*Z6-#xtW>)qCA)loWr28|0Txm>BGr; zmO$5sp@{0r#VPnz{BQGrfd5Ht89ihZz-;J{33AnrV^7}5SI41tbpq|S>VY^c&8@VQ)TOgd|T z5JqTwVX&(KD?C7`F1Jt6YoCIDidm5E$u@EM>{wVoSZB`YhhB&iVwx1N8%r#v3)Cqm z`CS`PyP7?CeZDvRI>+&gzw$WV^w+-!Z~lM2ACEr~L0utgy3}sPvv8ovrn(JF24zrt zMQ&@-ELF2Jb^+kR0Y%7vyc$Fwk%!cu2+vVB1NI{R#C}hHPx=oIEVOPS0J0Znhib6w zu878glgNL zk0o<{59po(e54$In?HdFm<`V0lyP&?41Bv%20I|;v*M@GlW0D4rl0Md4b)hxR;M19 zXY#%vy3QCpSUG#=cc%9*M*g~CzmWXXxAs1Rk2F9u_X+^Bne!^RiXUPaz={8q0|YQ) zrW^>HO#ADB2RaND8>I1jqK9Z5+przM^Qi$=!&fpa;?pvJLyUk{*p%>N+Lu<5)+9T|jRV-#UqdY82H0 z6nfhGNjh_i-;^!^UG)_-aLlw|_U{)ZzevxItUScOj;nP<&=P(dpnKhKwH=TQa#`hx3eOrqw++`Ar>h$GvXj(%uq&iBlBuG7upuPbCl z`|9`)G3q?H#0E>`EJwc7=Q+bMimZE`t&aMgb7d!yV}fv=mFS(5Q5&AT$w-hAS+kB5 z+KllU(*YQS{(gEmF~Tf`MI zYoR0)BPaP0eGETNZuObJv%b)7>#}3~pn(KCqAq>kjxqe0ylKh1VYvYHW#WFWJq9r2 zm^(L&Zmc$`24Zu(V*gF)S6fRCoA(=!6aFsJMrD?N(d|5AY<~eiP;Q$3PiG4z;`{VV z4Ajl3=hCyyvcHz}+i~&xlt5o! zh1ANU#=mY;LA2gIl7k^W1VT6Cf2vm)GW%GoH~@Oy`5B#1;CFXSd6+4w&vk2qHwHQy z`&S#C4Sp*V_&8I3(}`ObIBli1JSKZ_NzuJ9dc}NEBMd&ZS6nK@}Fr`SI%@aBfET*e_A3hK zacaP|0i)+pFB@EDSU4qvA{(tX$pfN|-B1qDwe38J&?mb2It?yl_)A$dMyzS`-qn?T z)B%DI)_}sjfsGo-jh41I`Hz^Xb~#FpS1x%M3Ug}!;%0l=_=a`=CWX~!h8M{K^;q%O za&G5$gDOmJDAC64SkP9tMC4? z_ux(6_)dK5cmHIdksTxnVl~c6^g+*ZBHRu5={1Dr>hr6f@HK(@yciOtr`q~DVy)y# z@}C2egh#fWivKEe)c@RPa=4=?0HH_Jnyf5M2$y}`+}=%nQ~0G!*0Y^v`9_uh41f3A zfXde3h|`39*Keh-{#|(l1{&O~2kz937P8qM+2V;d#1pg{4voMm)-n6|1Bp8n5oN7Q?7{GjD5u5q$ z=eG~|AO;Y--P`Yhs*?b%1fXOm;+xQ!pktC1ORPgm8XQ7U=5_Z^XYx(wf38We4*1Nx zC+ak~vXWm~AyJn0_x|7T_f;*!_S2g#d;a_yyZHx`>9YxhuVE~fpI)@HL}XB{>t4A~ zs;c{oPgGd{ZC?Zbz3Q!fHMT5Z;x#XSG5**aJ`w-K%RjtjkBD1$x&6uZ+_W^V&W`06 zO3J$pina>>tsGSTbNo*Mu>Rh3YZ8E3PVkO8oZLSI*U|=*wAex)0? zJ)n0WmKz_k;M4uw&d_rla6_`fYJUR)Au| zX%OMmAewjCTvEK6pA~-Fy^ANuKj-*2>y8epBygGk#i>O})vrhMe`5cq;$${47x&m9 zJ^_FVAN}Sax)_IDwjf**GEjZC36ozsA=;oh1!d+ax_}@bk4>~sN)dslQUqaqJbC(aoQJm|JEP; zdHne|zXRX-BR`|YT?{Bwa6M;dOk|6`#jtamM1P?bg6@ZQ`5MBLRj~F0$6vK4c&etl zv17UWQ!*rBq*>+=MdZHil-`J)>&q?ksm)MXE*vrxv zk<;$)dbJy>XF~3o?To(!$VmX==)Tk(G` z)8l_I-1I)vy{A9xWVPF!dlS^`z4-s0HQn!H-4g!-%w&(-#Q#z2+3+IDS^OIIKiecE zS~Us0`;Cw#hmGzPXMR3z*PRf(Q(~Jer9RG1Sg(_BWxGu#H@n)i{Hx@s{jURN%m=Ou zWV$8=7yM6aEKKIPRVY%$|FLW{0hpF-Bc|smXlnJK`=6l&1Azfp#|75vqz=am(KzZxb__=$aaaA3}fw|xK4;w#_$4t(#A{(Q2J z>L^=gu#=tIha`$4llD%VR&xw7^F1BSK9evV>_TUE!xr7JcLynue#lWM42?r0nJXjr zgh#bKH{>YsL4I>z#d&V6E$Xjub%T=XJ9-ur{ZD)heE~s+GhX{I?=-F-+E1(x%&#v0 z4v#PQ9@@radp9s`b2Hl%&Nua&LN^AD`NeWm16o^l&T?oo*OhtImOB9gAK6Xc_M4x8 z$Q2N08*W+J$Hw`4pYfit(|p#4O`pJjb>K6)gJ%7~n=aB$^&?J07yy0#pz?)**f1}> zYP8YuH(ojZ#&V|R7`}wvc)>Px3|%76wqw#vw+Wa*F-ARpQ0#Tu#UJ3A`|UTk>9(c* z+|HGj-B*9MN(QW^04DMacC)K->cDQ>{SXbplpQm->FN^n)71`2BlNVi`Jnh0(xwT`pmuxx*S}W&h&w{YS{?DStuQFg zsf@;VeUx+hOOVxj!9V+!?ElcvCMp*8pJKF;-v?wBfATxjR@k>}@Az-Vw|G*LH;r2i z7zfYZ6*~>0j}8=^gOT)}8Dm!}&jaD&TZ_G?I7zVLZnHlpyLF(YwNaD!065co29P8N z!3{yC&`8U3LeK9s9z3hw7+5O+j-!H5r*lgDZza`+K{ueliFEXtf?Fny7#(Q2ro0Hr z;W{V&_mlaDCh-p`cWWTzQTuz8>rwI7Kv`v>gZDoFn}k0_^EHS(c-79awBwt*Xg0ngoai7lq5ua+(xLXImigXuw=rQvtKrW$*WW-G`EV#qT?3&j zuLA;Bz4}=i5NPZq2JpAv@l*KkzTvy^!$0+&fWY5(910?q!9F2%PWHkX_xhN4$oF1C z2T-hCuYGEp9YNzkMQsY5lYH*NQ}CxgZ#r{*p>c`YFncKjDp3=yNAKXjLtN)7~ z{g*uKBk^zruJ9w_0{_spfgv04`8U|Zo&!2C5&ZUz+C&GPIHH%+W}*durc=T{jX5m* zN30lkYVXe~rXOU7`1Il`bL}+r;C&731pjjJWAHE*1#R{^P9uN^%fYR z9?Q^G2IU?Y|1th;{G055v0qJ`r@l6fXC8pU>30B3I5*|#?xW-u9ZgL!z;^y)`0jevUAY}_4rsrOVx}uBy z9mju1#_>Jk*-`$P-qfkMlLtJvKAmpV{v-g|8gfh*&ly*x@GPCy@+1HRTLyf$W{(;= zu6N14`Fnbv6d?nF{~>e6u|N5DFxI>cH{j$lxutt0K+V+N4)=-^1$i3Ii1 zh-m?hXCtC~prND79{iE9jK-NR_btF_@VB>r2hf5_n3$fVM4KxyTXip?Z>FOco5kOCJW<)W`3!7b z+Fb|j={Eu2#EhOaxvsHj+6*xJa5;)Dbdv}G3AP?$04D%Hn|{|J{MYN%#`SvLN&MQ3 z%{De104({x0#1AGexs|_Zw5VirhnC)Wv_$0cS6We;>Vou)f0{IgsBdG`RqE|Sbx82 z@uvuJ`Tz~u?2`!Mh(u-4O&^zB%bZsoXc{Viw#npzCyr*c4G(1!3CUlu{n&K#BOwz{ z*8A9-g2r?r;m4rgM*Az192hb9Bx>OLw4~#uFM1xn{qV|=8cO|7f z&%Wf=6|mF+T(;*6#y7FSq9u`$DViimjI9}KfwM8Zt9cU{D){G6EaMDmQ{ye6MlZaAURL? zw7*#w43&;K9f47wxvpkAge3Euo|6BO1n3>ZejbJ|CnTzL42Q{8RZ5^W zjwMRXrkS2m`8sNfRL^SHM831t{NA4Dd+(*O{f;ToMA6R<7fHx(p`RT~XKkzJtE0rp za7jz7uVFUho@Qsv7?Fdpwgc5P>uPOWR|EyxsnWna`|WOP^^5c20qLZD@yR3y_Tlc~g2`T~uw|p3&a@ zSsFffV&}};gfse>O#xy?#Qt74joj~J$anA8-G<0B_`7{N{eT8uSYV3(GnwF(5#7p% z37^q78cf8|f9|hLW(Iq_q$MSU=Bsi|*a+;`5QreKr4q9bk^-l4B(*K16(~_Q*SncqR>Mr%j zHYW6wiFL~|SKpCiklSsXa)qZ90Cwe5Y@q&BZMg#svVRBrFMeRt*~%CHZL}S!Pmz^w zNZ`;lq$%ZE3Me=h?B2HlKz@Fs&Q|C6UF;v}76BUmQ{&%GnRSJLJqe1{l@HNIshi&G zGws8XyHO`6{Y(cy>0>YVCV+!vA~`mucX)3#6zTq)B&Yh>@Nm*eh9S4z&2l&$yssOE zuTglUdiRXQ`TkzgujQYv>sCM6@N2y~7?9rsw0eNjnw3Omy|;0RE4+{v`f~Z?;bZ%n>ACa#EBb)JbFwN71h| zxP9e>larggJZx~=pgwY7ReKSB=i7`6p{KG-0xkF=7j)qKCIuz_;;|e{m=4` zWZOvmdjarPO5eE}ljL|$@-6-k(~}Lxe;vDbX^t3MeOucTi0_ao(JBePm07j-5oLzq zfE|U$w#sODr5X42=>Xw>wWJHMqc8ISYvx$C^D$6DpCz<$0E_q7eZiOE+fjTbkD))C zR+LDVS?L)wzRutM-Zw=|{&S4Ic_AnIm>Q!PlSa~KL&V|TeNFu=W8#VnF3zt|Tk2~A z!Y4nh{vzowdP)Nc3$V(6dq0Ub*~ZKn{98vxVy!f|Y@W|#|L2T-E=KnMPD)HVj*?ez zSm|}lp5iYXC^}l!`BdbRo#YvR=D+C>0DSa|pN}v3tWUu2`Se%dvByUED^R7zu9dhU zmAjSnXUkKyNfWiQ5`g+H@$dgn-JAdJcUMT<5G5GP36Y<3*QvFpT=CEK!Pi4rAB+{B%dDDs{DaOa%! zJkL4vzMn5e8R#YW{oHr%%$c(EA&`SRUCnROhY9_Rh|v7E z+6(fE8Q^gxbRr20-d}KkocR{TU4f+I2r1-%$?X&yc&ACg$ zZ?))0Ufd+Hzv-M8`!{&JQFC49o%pijq9o@TC|fSg+%q913IUJyj1ivPY;+Hbc1J2 zU?_JFXrbkf%OsddFrU(ogE8nz7kFCUnElQd9=HGDpM2i_;<0DU1Gsm>ov#F1btiRD z{Ga2#u1|wLmcOfEtFEK2ZTubj`TVE#%SZ#;)2RF;`6NpbO0q}{qBF;#!tgGs*QH2; zzEKLeFhe=j3UU<98j5!TihHtk=usE#*LW2RRLcza8(p!EuYdLD6;yF!W(x{G?xs zI}z4+-;YR z@lwE0DC45v;T^QZ=yU3gEu9JX?K-#c#6_UvqQ6@}Z+}lcKkgDiKjp9{_!YNDzw|i(md;f+Ge`m>b?L(9j>N3P_@#pU?5Q2m0gq{GXY9;NADy zZ~V=V+pmB6;WmGvIlDQU^WCog?zlDo$9=+=N3`b1?T9PI z;5jEttcT5b&R3Wxm`AnZSo4vcYko!j0kGy^1I|5;H}n$*nwx6^I7HqavoL=^<&(I! zUlgguefl77S6IYg=tJio$MGELFG+;Jg=<&qy(Zt_MG-9aIZk5S_Bq?4KiZA1P5FCF zkGwGITWJKOtF{^C3l4p{y#~T)5YPuq%dEAvn8nHDmjEJn(@B&S`slnE)3jm1h6g$! z4&u5CU;*2&yWw_yv_Ok7W&{_nwMB26NY~?1zYdpcM`s&18uvQ!yhK0B^=Nbb(V}1b z?d6X!=y1nosKL9aN&_v;*`zNRPiY;S_QX?tbrS>nehtvh+%ZZe`JmGneULv0t+YcM zj8AvSl;Uc6*A8y9ae7mDl5L7!2LnaRq+$Y7Ne)z1MJgHFF~9d`Puc(YPrksP4CTdhBV!utVk9h;LQ`rG zA6y7Ql91oL17^fa^#c%P7^j)~Sb`MB?2)hv4HtttzG#lS9mVPdwOF`Li)XZH3M?uY zL*_iM=g=qcZAQ`P*qrC_B>~?p^&)y&##5W{{s1$&b^0j6^KpPVCp<^rYy<=kFD$5= znfY7uNBW0&b{*diH;rB%=gdT2p&XAw(&91u+#%yc_>g{Mt7bI+LDwp(DgT-A;($5x zZUi@8(k=5vqU0BAX3pz(RYaWJ(2u(3UwBBT>-s!XZ;C&gp@V6^#{}^{1kek=o8%0O z0F&;N7y(m{8vhrq82^?%`l1ou@qk4|&`+O1l(>hwcFsFLYEhMMei&oe#~!-R{<~lP zxc$mcKLp5UjJp_jZt=Ku7r?HKBRTkgkaros$$xX4W!%j{2>|kK(yi7>@0bEigd(K# z3qzRO#Tz`&yrC68UspF@;41O=fYY)<^2D zEhR3LXdo6SYro#_1S1S(d|6a5Bbbdy&CKjGfBK~TPyg(X?T;UMDsd_iNIfU(9m?Gk z?Lt37qmw0Hlp%(4kw5FsX5o?MQrww;xk8;Zb#sMWK+aBknY%H7pwY>T!|}tG#OWsV zvmUZAIzQj?$Ae|G5lc*WUjpU$r_+<1Id+QYw0y1qf%iFXCtrMCh5E1q)Onhn;Em~L z&==<`E)dX8&cH>1Rh$8BrxO%gtB^XKash$g;C>Y5r*Wb;-w!i-$y@9QlmF*JEB)Ju z0xk4R-liYVii+Z zyv?6olJhQvy<~L;`tg%{Bk=(rqCY0N^u!{#O3*1HNz+M551d9<2FKC;I#!mp2FfbDr&! zAAYy}{a^iY`}D`(4Y^~0;zMIe7h|N4`XD@X=P-2_`N%o>wfwj9f1%MHd*C|qt>ph0 zLqvWV|D>P8qXekqCFp{*jn#^UuJSU^8=Ey2jb791Y5!H6B}F|F001BWNkleyE)-aI9cXQL|21cTm`yN}ES^gpUni7} z9VPwnTl_!5>UQTHX7T@!jK;On#suh{4=4v+po??Lagx9;|4lnO8e91n69fI942n)T z{HIU$I{>g4qt$Lo!UyPabr7AS*ew*4+*A$lg0o})!t)e(O)_|C(W0~G76*XG^Y(@G zO|QSv$F+qLv${iZ@vpIbxHwlwXA~${k2b|^>oF`~A>0yNv~me346n)<0zX}22iNNB zb@HXZ6g4*@ALZn<;OA*7!F%B%(2!R}%!Rkoyjv9vCTy=oJKM zm`CF_I{T`OK1I*4c0s_B&$KX5*&MQG`Z=iY=_Zfe(9iM@J9d0F$*}S!=;`!d{U6A= z(6Ro~=i36YERf=M?kM15Ajt&bJ55gWw7f+c6Ou7_w?6D~?TF)B@FRxXSOq*3>Z|CC zx82Jv+P%U0Q3dPYWY1z2^$X?IfN}aGj;xEp8!sZ^D+)Gj!gKVfr)D$n5?Hv5VQ}XI z7yMNCL;mBBKwOJjW2wL~{zQ*_47jab7sDlCVcT`R^7BKuJZ{O$-xm=t7xXV8tZR#N z@v=YH6Tc$^<%SEScvpntow~Jr&!XQSQ8{+{^OK9iEFOW-1!Z5HI^=ZBYammb%T`WY zkj^u}D|S5Xr#|+e{r$i7ar>E%zN^v^etZ*8E*z~}bN6codU$g@B zagsJpi27dm+s%yv>*g8&+b*16I&P&%zts2j6#K7t0bxtC^?~FjIB4E+Ne{Yl-QqwL z{iUty3LoNC#DK6s)qs(7p$~72PWsg#v|A|X$Ia4^KdYbb=!e-gsNnVRum-$P&zp{~ z_rkk=%b@`LYzvouitSuhZvLdx#b`5g$c;AI9o_DkK*d8@x` zO5C|0`n%qNAn@vL|B`>mN&Ln+@1|k-C%$jG<2L)HpZuWx_rLsO_L2A9LotU9Kn{sh zZ9i>uJX#M^eM?#R2&?C~%JEY3-<|%~!e9L$+h(6{)CB>FCB>J|O8v%J$AYB+eZs4Y z;U7a4qJ(2_?Em`sh`x2=#G@`Eu>5bxEB(7>-4(1_s4{OjCiC4Uv;4(ts+C8gQ-0MQtYB{sW)^4ih$-NEU8li{x5v76)d zEF|2FhSt~IaO|bU2Zr0yZ;i}1<8CJZ+BfFv9Df|Uh>DkXF#6EIJJv>~Y&rs+n2y6~ zHvuM4v#Q=5j69LSSIDnylCg-Kcwa64i_;|0znNvb&h2G z6*zi8JEM^H|J;kO*gyEpBlZt|`_Ju}7rx6u!;GDDcL48;`~{gU*;|KDqiFhRt2Q_W zkLu{B;DPgYcEvc99MLxNN7@PnsCUCc&=={~HoS_9jMB47;A_lFPhn<-pDY0m`;JB4 z4t+Qb-}QB*A}Lw1GM-&W;8oOx{JQu83j-5g6lb6pk+|^7@%VfI56?2O%D=Yf7)L($ zoQW2*aw9}h7H_;lfB3bK7ueGch(j{g+CegcmElm@)8pXX2;S&w8mzbS{4 z$5?`grZ0tDlWcQ?1A(+K5&z7^Ae+e_l)sO-`_4DpfBxx@*x&n=kK4U>-O){xv}xjf z>9|Rs=AZ@InLfN#|1As)zWeWi@JOEq>1goXjCjAx|Er^K&i$aa;YbzScVW98dnR8? zBM8DKe@Gt&@oWH$3khJ72Ee0nG&b&2*W$iVP>2ycjLTCKczkyt$JrfsFD?c&%-$^+ z+~{|lav{G}gwFrr*hP%`dE^!@EL_TenX6zg=oq6<E>0w;kY({n8-i+1T^S7q^4H1PO z>emC^Bu#v>&?Z3!7r}x3+E0Evt>69rYxbM}{;T%C{p&B;V^6(MowWZX2e7V75~T8P z{U3O1a^BrM4q5ChD-H`D7yHM#3>ud56@JCE4Fd=V!D}b~(4|5QXZ${@=UwqW;Q7w z57sz}XFC=cTpl>ioTaN^8Y7>1Uqw~ku3r7T${=nN_cjPh^k@X>@=Q3MoxltB%8pe$ z({1bAj2;89nH(7KMS*#aD<@C@^{}n#~R)=CsU7xzM*(ON@r(-jV z2K*S(yrX|zL_eu8lOIP9s(Szi&pxzU?=mVuU;=M2DA=aG$}H*XSFCHmVSX{0 zoP6yPtlX_=#-VUberx1h*WH;Coy0REUifi7Py1(o@OAq?|IL@|i(mUT1tFgInyrIg zH@KHTqX}1M*AVI0R|nHXwW{A_`D1Vxy#Zkq0tOVi3~@1$tA#-N0K1{zbsl*Ec#>_B z1E}~C&yY~iAGJZ%P2I)AY4e-rhdqqpcWoa)eA6~cLb#5FP9U+0-LoBVZRT^+KDWP+ z@2G*mPY*k8F<;L~`^)r>7wdU}xW>Rs=g3zh{qS@^5%uavk2EX>;39x0xmX-bnBR;0 z^?-UX$e(GqIoC@Jm+u*Q>w6Z&D35$h{=5|BWI(@}O@Hov=0c9&9e{RpI^_;~n3{jJ zG58{PJ-GdQp^tX4x+dL|Ztusp-uoiNA%E8`kpFr-1QWmTe_}=G@)LHy5K?r1lY_qnJC|4#d_f9YfPYd`mausw}K zrtLN4^|55vE^S)8V4I(3goXtU;lCa=|Ap8qIk)ER;qxW;ZjwD=knwN*>(?BH)sP-- zYs{7KPels$BzO|0H2h_3<-Ua%T{2i1&;0DJ5#hq03?3QBR(maQ8y?d*)FJnOY1ewk z3@#!%iastDzC|8--o-zIYY0R83o?skn1eZ)l3O)@c@aSDpZrTL+ArFk;TXQINM!#c z7GVfcY5-11;S2lho&iN&9R|vB3h2Ro(PaAt0EcerB)Vcgj*Vlf3k4}?9HWa4CheCB zRBZ2l?`-;#tydT~EUazsxc@SboA|U#{PlCQ8_M7KcF+RX5jieyoMLtM{qJiLnu}C4 zW$eEVP{2b(7Xu3g8ADyJW1^c*dX@Ys9ZWh8fTexANXTPrf+B@HBp0}QTLM6!BKiRi zH$#<>79?TP((tNl)4s&Jp3lYE8XxSR7cIb}&wuq9``>@-OZGc|^bPyo_g|waqmVHN zvN;<0AwTQNz1p%USDnDZj~9Iexr0p0$~m$QZ5abFb=u<)-PHV@{F~4ZIE{|as8E-T zdY4r|XAa~&KT)@wH)B}d`V235vw-Zj)B6eEM0QN#(`)N5wnZ{0h42CM#Gllg#&1$z ze|<;R)@|g|KPkA*bB<$-@ASG1GKoKm+QX)D*O=Z?9||zd%z1M#lJIig8s9dK1@B4a zKaJ@Jp5a13dFdRyjL#Qp%;}tUS2(hwI08Hk(oXBJD zp;q{%g;OpB`JEDtPt3atFy^Jr;@1vBpO*Y{hu`JD^xt{5=su0~>y%$kT@+D1nY+T59zRLI;Z@?(S@wg@!fOUB@5?tk!Tw#D8o5(XB zTlsMyxQ+kB7jmSDj3CAf1pp6|&qP}2@6Bka9opnvc5pugU;dh1c?ZB`hT?B5dUky- zJ7IQe!KPO55b#RBJuK~q#@zMU!T8HOy4=2{%iQPO0{!SFgqPmNMI6sT@`wHkzs_g1 zE%Zk{adqdH0&9lCzF*)hj2qI2eE3M13S(%0X(KX9Ok^tz`WJBIEs&o;u7t?CJFkuo zujDUXjW#oaqj1~RYfr4!SYJR$`ne4YfFxHJ7J+m;w7u&m!6CWEzs+@c2^7zZk6*{L zFMZGc(PzGD|M1`bg+2A`%N&#rVz2uVkbl}*!w1Gz6ipw|b zAMoN+cWZduDsAwlfA}<-Cl+4xPRm=rb>rAfZ)x}OX6bYM<#lHMk~=@|o+n59N16J| z2BVKZHn5T=Exz3T{bhOv_&OZ#>*EaR4_N%E!-0j6+4fU*)`p@K2EB>bH%(bo{xJ0JiF z$1xU6_cKI)YC!bXHi;8y9e-k}a!gm`xnlqQb$S{S! zna>N?;0?4nL;Hm(i;z{s386^yG~#D6j$#+`GR|GsI$@f?P`LP;0s#7oCTEXW3;YNh zPF#^s`3vJ;7Zdlil=v_4uT^o8N6#af82vuAKPBPz(~H@eH8Q~ zTPt~h_HT&8qzU|Sf>HZlanxfS`Ea+F3LZdh_`5Mj?-mOHl8`!bM{jl(UEzIntJmEL zp;JS`(V^XZaX{Jw1~TJ%A+I>p>kV_8YnNc(6qlPE%B$V=o6gzuoOx)rk*fwC=*LmG z*P;ICqPNL!Ehn9|R6AOX-BzFzyR*jP-jmPW)x+4HY)gpDu zx%;j{(P(62L^0#g;x^R+5{)?P{}0U)!8((6~XLt^G?VjYTlO zHi!N5&pu}V$8Y_)ef}#?H9j8o?2;|A-d9}&nUeggebvCC3_!V%U?h1^`f&>J!Gwcl zfF?o?C~W`26Oh02&65$NY3u(1Vg^mnf0<5`k8%h=+gPxFnD^MUMAIvykOF$7U>c;kL7NCXd!_~tT<1*Nr^6}-0f z8jF}JzfxfmXFA`G3pKi+;bM!&3eIPH_`!GBfA!aX)PDWvKVY|?PT&{P9UW}JqPB@a z-GMIiChg7jOGUn3$KPeg7yESi+Hv7>PNsew#2Oz zkHko$u}s@8{5wA}W|cz{!0keUCWiFeD zxnKa=C*_5H&J%~iJ8*48zZk%r<_iGHK$Q>$qhM%VIYa>ka0+l*K$^f}TYS9>0vybF z4jre*>nYlQ7=`{0ez|7MEAOw#Ot;vA$)3)IiO){{ou2e_qi%V34;0I=V$N$3)|x(S z5OsID(?w1O!U=0k8yGC|H>1il7u_=$(~_@&C9s4B^1_UOI7AH&$p;NKNb=1m*?tqUTX>%m1R{3!^r^Vc!EATey%7ry$e{jdM(i}r87 z@J;)^KOTS%y-T)2A7r-$WJ|%-GRPqQ>ELT}a52G&xxq$5uqoF{e`R&djhWfj6*3f! z3jqLdNpkwd$V~QnJP{R+waKT{6M!;gkQvFa_y5D9*h?i$T?CmN1^=+~SzgA5d;$Kp zFQr?>=xN>leVmWDyC4cjR}{Egp%itVmCFwB#Zsmmkf@9x0h!- z@=|@<3FJM2{xJ?&(%bsor9k{Ufb%W$grg`)EC%ETKPk|(eAEN!&tl(kJ%jJe*mUq- zUbN^kigzfCXv@19Q2+B2fId$f|6J!{@#3K0b@RCU0KQ`2_F)$9LZHPDiId0Nr;ruu zFWQ0Q?4NHubn{HUY*N2N@9wz$Hv5H7z27vQWn7d0_y0$C$B>Xx@FT5A!$6QQKtu%T zk`O70fxt$HbW6u50YyQib95+@(lL7U$gyDCpYQ+i_}#hgzONgv>%6aX&g(o6anTl+ z9eS#LnF^v^I&b$i_x=p|(p#?n(%$yxC4uVp-@o;tj^O$svCx!icFT{2yutL5-0SKb zlTr_lru$W!+_h2p8cJ77pNynXVP@IjFB;BPj`*>;h_&il3KX&)WE8J<7P+`ZoQGH> zF~J(sA&sObzshP?X?U>{7oX-2BdqfP?>;tg-06dR`V_qjz_2=S%`&I<0%Y#OvN_(E z=+#)lc>rW~6Lc{ibgxA2-Tc|{8O8`%C{LCQK#S=QD&f_=hUE31#}?~R+)iAnunt;M zDm{|vPE|%nfsXOigI3g*uN>KE96O|DT?9zF0?SJtaLYb2tgCa+6Bw5Il!kb?nHl|o zHX!86@)|Z6cC+mUtQo3u&uA~=bfD=zbR6N^rNBe+-Kk=1;tugecO3(J-~FwwXKK|{ zYK+WtGR9r~KpBmWPj~wHww;;g0z2cO;VwtH zDohqB!krV;zQu*Y;hn5@&b2&YO>jNcBk8fF^L^Np1RX7HE5^?GbWT+@B(hL49S z!pQpCxHsi^LfXsf5~q03U8+aFY_ToThj8qe##>b2VqQGi!9(%ZrucXYKvkmc(35k) zXM%|g)E1SGg8z!1FHoxUDu3BzuiW}FM%FwjNNYxd8-;~5vAyR&Mb28&_p;64ay zn{$%TjwiGNa@%;yuvAeF!ahtoQ{-VT;FA}~c{U=Go|INum33&f;5_LJTU!_q($|ma7RO z7gm7xOf8PYlAIXcOas!?MRL>f^V;17WUm6XP_ zpRE=QIRjsYw2Qg4(RwX0w}?@u{X@RLCIeUL8BTMhe)rP!$yYrX3&p<1N5inc2xu3< z2D46*MGpLfS9*K_!QRY}J9W?ivRpmKbeQy@jspw`Y`61DC{0m-S5B#<&#I?0I)f~lSE)DY0SF6%HT5(5I$ zPORb|h>yRn_{s#}=zO~xf3IEE^^>5;cMUJeklyVAn?UhG(x?&LhszK*QOGBIa+@L0 zKm5i%pjS3WEt058a9Pjmx4w>!GATTgk6#KXYgT}ckr&#)@c!=0i;cj~Zx-@A;eR1G zy%k3&5(sjDV`6FYtel6T>7*}X4z{M($HT#Tv4VTXUJ!5BMU3GAG#*>SS0#PV#}nxn zKK3@9a|b-7F>lvh|5vFz3rLkV>PJ`VE^hKePGo1$;Mu|xfUCpHVh}+)KkvVX7yN-~ zEqqtyIw>36+@l`ECs<3Vy$qXCm&OQTb4&d0=fKDCb&`Fq2h4d*bAbgtEE2ydMyC>v zd}&RcqJMabP^V~hXLl6h9!GUQt!^3l7yu9!`-YW?_d0z7U=jDo$l28KC#58gDj>7y z{SLuj15w+->U#dSvQFUiD4IUxt2GZGWkM(`PNq$>G{+|eV7=NCTGo^JQCa-&(~bDv z`ox)t6Mro`Js0Rp9?KQ0<1Gt2owi|HsyXVViPP_=U-QDhc3JjXcr70DFAJ6PxAJI` z_4-gnn^}v=v_7|sElYb|tn-61(>iSTsscSvkhYrKEWoBR*U4bGoKJ`2u57Q4+|kL< zONswI*n-bbkjZj8NG6KK&0Aqfx-wn){DMpYANO0muifF3Ua-Uiiv!zQpB(AHhE;BQ z1tNSLIz6uXI5>;ra9gcyl{C|Win(juW4fT6cqUhZNg!v^@QrQ)3S3_2d9|AsiWpAI`c*!~rUzIc7~r$K_Qt@ zQ$ug`_GDg_;7a!zkR_jGxpeTM^NR$AQH_h1q0#bya^hvA>H?bjDg}e=wZ!xYR;7>BATN`Z7ma zlACB4o$SCzjxnj}L6r$I5vx`b|BTNm=T%)3xPKHK$y4{4ULmHBZ3Utxul*ZOWZl>S z31+vUnj~bsuH|gaf9pRQH^PGoy0fKZ6s{y0sI#-MMMpv_GrnaRFL?+vtB>-21yQ|4 zEL%IyX=o;O<%~QMD-adjx3*%~efdu4MQkdBXW!Lzd%!uDBwRq(ST7oH|MtF|FPmWD zpbOwNEuP$*qo-B>Im4i5|1S>Qmwo3fAJP8#uBElIXbg8}WA{b0k5x&rjTz4CXR`x0 zwjYx8HaqZuJ67JFZgBq1)}fcDi|@V_fF@&azlA69WAXXo@iY=f`8v=y=L(?T{+z%z zHM8m%6QYjdM-r35TCY7z+nbftAFEy67(Te`zdCWu!D| zg4{IC^8KsEH2$JU^XcehjWaI%G6*u#iT_>2_VFd+aV}$$@&9=NKHkcGvHfgJo34%rU`WR&QpKh!LWvM$QR;M+JWgQwWl%3ukzZQXy62#=oRkclNZiq2-{ zYwASjK#et^#$T}u ze04Pi4-17(IQlD#!?Z#2=+DcCcWX96PQF=NDZ9v_pai}P=aXNZfszizYVSfdSpD7% zJREF&0O<%BC$(z)#oW?f_+u`gK7L&CERH3cGtG10&!rgL^?FfbyS59#IPLyM+x8!z zo(RhP<{M$Kwd#a58}0#J(rw3imc1`bb&AW7JVK8+!t4JBLJ%Pp{37sxKFBIX8p#fG zO&BPX{^UpV4L)G1e@g}QYCU?V>Hdc<|Hj2!vy7z!%tYTheTBrH|61#{=u>%79*l}D!!t=T!GW$ zIdKm>6%v-|jvvO{@c>jwegJrhjK+ulz0E&jFE2j+D8nYchg7le*gmM+W|ssQKv2{ng>Sq2;igu$|AHgO77O&j!hivzXaa zyIy2l@;I>0ZnsQtyyB*Xr1XD$at>^1k&9gq(9#yr_@NWYKRez0b5U*wYUZL_h<7Nn zaf}~zYKx@D)kgDvw3_w+jFgqv5)XEbp;%JhrW%BgbW=puc14h$d;@bPV!m`_JNeN+;;=O zx38|L&2Fl4q`xI8RrNPLh^OtZ{tk5~78zT=7XQ}fX^#JF|XK38Az3zzE-e*RA zEV3t8#JwfVuG)=qZ>)n_QX${CG2C6Pf5}Ca>c-91H8tL6*2*3q zez)8xDv?D@ZfOszRtS(d`>^x4tnhlfZfILS!2gvVzB|z}+!XhEs7_s4n%b%vt$WiF zBEV#}t2k8xDGz}ZXv!&mE6rG z>IND$FVT@yWCu??G*~qO4#O#pSt`Vel=RzCi@ibb5FOsGHpyY z*P2g#2)IuF3-cKKHbQfbGD%U_6nx;WcHqjfD97+RZK_{^mUXnL26T}|m_k7R5$93r z4n}Hc?3Cr3Y{O7wWZaL7?Fs|89+LSWR?HfZAIi31_I#p(*R)EPaOMtRpX&L>y z81~Ap0w1pON8)$?z5D6*y8>VSBWo__2QXJ)tOC!hJ{cFa6Er$^0i*Uhqgng{938tF zZ6h187iB=mVK{u8xlsUf84{oK3DbuTQ&RRnyqM9%EoA-!^lH23fAW@^TE*NGx+p2pL{`3g_}&w`Pk# z6_a*kj@cq^#EZV68JltFS>IJXg2WRhjUZjbBU^lo`c>ER=k$YhO|$PMwvM)?9-#*g zc0zYAq0dO}wc2BjOA4?j%j7JUIlx$f@xnvAz@(m~oF!$Vqxcphkz><8N5ray4Yg3QI{l8K@tYZJWL$ISeQe zy$mD+jdAGK$Fh+7ieG5YCd>|pFkvMKtY|f6Ukc@qJJ*WR4F2gv_lRL^MXuOLE4l_u zYbCwvbM#F5x9?Dc;#;}ZL?usp+Yk%4{Tf>vE91htt9d{}w*D9PzYB%wzh5WLyc+ae zp7!~{4ObI8mj5irCxn*U(d!=Y=PeqP)Dhbw`N;8`2}9|43<5{A1*<-CMJU=W4WtOy3 z{v5C+OXl#VzUMx`e=zotFCIdfOO!|&;okIgFll}C^!*RbB|JC$uyr6Bt9aw zXEts*Zf@Kdcdpvea9&jcDQ1|@8lB=Sw&RV35xZ}{BG`e}}d5_lAZg+y~+cK&bbcjFlL$ily7?D^*y=c!C ze7-{alTVuI2!)^jQDrTxRL8C85cgfp3fOECJ8!#s>pR__hWI0SgD7elgi-{wVC##q zQ0x4SeXQjI*-c@m45N&fbPD=p%6}}X?Z=(Y3|Deur{c>DFi0)obB`B!kw-ZGk1x9i2CxbML zqLSTg{t0?y?OrbK7Z5k@M=MBz`ztblvSKE4fKQTE{5Vw9X}BLGNYj?BdA0b;(GUuc zywjCm8;R;97J&$)TG?Mm_sEvByYSK|zrcQ%M^=i&pI!MathE@~zZZ&XhMgu|Gi{Wa zo{XxSA&Va3_`c|yKW*_XEDJSPBiaGTJd_ZL#$tSsW!LOIQ;hYN>lF#zIz8uo_`&S^ zhR;_x!4=nHDQ^MWIfm;%{U{c%F#oUQ%&45ySiqy6ytu9+|Awt&KC zEAQV_^;r8Taf^Lcqx=aS&~r`VCMLc=bh4j6cYHMDG3kd2#O8NnM*n&MB5bv>XWUH9dLNma;GR z=u$O=sY(>xUutT($~q18^{JOyB^wJhW@&7K>}0Q!O)~2K@(rOkO3vfFXtF3Hy%G4G z?CRw@mx!REQ$uZL#sLanH>V@=uK&jj+{Z5u2EW|mBtXpS zepdgphjVtQoKNL&= zC+@45)@f%4s&4>vcO&m&WhkHgExY$ZZddh~gQHQv3Yp2us$oI~A|u+lyfMNf=5vlZ zeo_K0L)vge*MKgW@Jm^+`dJ%QKG(-WyHVlu1EdEANzx%=OS+&Xaq|th?t;~h<39YG zT{?v1@-xp|yQlF#R+oFMXQ@|YMvOh2T^`VzdRfy#qE}34ZVZgz3)vyQDvs)8_xdu> z80f1~^g)vKdnKIr?6&7%QezI(g}qp2%P-9-pK4Qzs$`g1f8Q*e2QJ9GTo;kF2Xo_p87G#|qU z;!t*hkwy}rj!J5!SZ4lYs>J70MI`eS-d-?nEh?Flg&X2T!t&_G7Q`1V|oZrsOwOzH71@ zeQr|R_^(t-D8V& zzu_yei}}$=9jadA&D}P3abjTIYa13ZY*d&cVA-_V;7#={R1KT zl`(?-i?RE<>t5^+_VFq|IG>N7#2R_7o6u4JhCd-A1DZT$CF?A|+!8X`lFL0wl|4o5mBjy>t##9QD?C5Vf|7^{Pf}(M9ZQkN&n) z+QidVtbw`s$6^PYb_ehJ0VN1*l$oVCP%RWereKMpId0mKnG;vtQNB)Bg`F`1T+U>^ zxKh@A6s6TW!^xbOx_z(q?_&h|$|1Q9kTcmg3dn6gN5iRtt0N(j7k9*v>Lcg5x0BF_ zN0jC<3+=ASz58j4t;le{H^+Z)REMk0BACs!Y~V-O|!(hD>};!%#rGzrNr2K z<675?sKQiv4&2Eo3G8SF1Xa+2rqTdTgI4Yf)U19x{Psy>OcwEarsx5g1(f;gpZ=52 z&N_VWgyegq6z)5HAK&x+MaVN1!S~pRMbl9nziGQMK9;Z;37VfNgHnPZ7F+it9#qYG zf8dPDvC+2q?tZ2;R9sbeQtE%u^MZrtHmwxBj1>Kn)5PklxFCnUeST6T|H1hle=x2f zadvmG)7jLss+KabeN^>9^}|+`Xo9p1=MdQ;!{d=|xU$xph-OS7MoZEM;;Pu8!r`_`(O zO1M{Se3Sf4uV=!k=3-C>ZpEUd*~RJ=-QQyCE45M5Am-+*>nAK2U(QV$oMrbqj^y>u zt<=0H)LHcF<}*a#-Wxm;8YoG9HD79!?km_6M$xx|Jb$Q#MSi&p)pyNP%JyZDj`tj( z0?-;3co-y62i*D4mYs%%c`fQ;H=eA&Te#~KJwLMS#@DXdH8FpR_8IgWwR#Fz+_>gU zW8MnCUPtyIW;{{(nJUzsp$S$(w4S(2q_$okc+F*xaogH=PRY(ic#MI0FkiA$xo!tE zG}aFoRMx2Ak&${wfA^nM1wu}0tjc;QCvkxW@A1Av<9kJ6t_}EKIAoiqDD^4# zqTi&5{=|dlCB#cWbESSBrR*-vtrOny0<6F!0*Iq{CAWL=oXL z_Hte1!gXBP3)k;@0jexDA55gaF9_VJbvst-a(R&E!AV0Gx)VaU{lPE)wA9LF;8&9~ zF~{`8iiz|N@DwzWtC2Bg(!_-4un(JdUC@NjdlG(~ra3-4AyN8+1?+{a*=52&7W`RE z0=uwthN%XoD`=Y#0Lmh8;%js3xUnzURPL{!Bkkg+ZEJn^*)gY1ZfZWwdzw4GAYEE_ z8h~-@{0t?9=T8CqA|-h%A?n1~yk}SR0Ke$pR1A6tCJa~{&}Ay)sakadinwR)KOk^5 zTlf4}SHPn))Sl>>Wev2n;!e$OAn1tA9ISgKSLiS*ZCCSWzPJ@za|8{| zjtTwUbKL?=oBQIB=kCjA5mZ-=?;$N9m7r3zItBaf0GzdOmi~eIaU<&kjIbI}RU+DAq~UCVNUXs5cGWOss^5ccv2W zuRMmHT)s5~`cSHR@B`9547hYVR&SKJYpNx8Vq#z=y=^_l*Z9sv;VAQC?-}#`IppG* zh~=G5XNwgGg8vJ2ZC>_6tiT&F=My5KYd-o%2|qR?y_%T6Iocmxijub#*6(nl2~)k5 zpIfGOb`+5hVn9$RhWK@3H9qw(GZXaz__z(3-@lrm&1aepGQ0c3>vA!XSEd-)-*Rjl zhZR|pD$`W~`|Y?em9gevyMZMi;Gf)$kWmD)V?z64xF3y}YQcyGZk&MLO$NEbuS`&N zEbz<2{Lj1?P>N}m$#_E|NmVld`?$1mcER(#lzz2b8CsdRELAxR*M;QAf6($!P9`_0 z3#!B6E%JyR^5v>sdZMGPZe7a1ZFJbz@vdC$NzFKO=)VfbX z*0i|ZI{JO~u(CzTne^p!P;_SY)SSfUGpPHPY+<8=( zoeze)^NEWFW>6$Fukasg^{1=!3d!_)MkFd&uu7h}%tCyqT=D&QZxVl7yt>U2Y-j;&Yy zxvwPr9wV(ybV3MOJ(o}X!*ss%Ml9bZpD(d@i)e`@gC zvTKM>!E@h>;@0d#B~ku&qTxAR?S2J69~2kwo7LEkKp#}gV+FS?^-4EgZq%|Fy{n4) zF(s)xt~jKZ!t|H^#9?r{nf*IK<%L z3o}1^tr*GhorhQHLSu3qHFSb^My0BshHF9S1&gw~$?p}NA4d{GzLr%sqB!K?O zUYx&;@nq%Fj<&D$v(Xy&iLpUQ2y#kQZziYhMqgM{q}qF(Y9Gm1-rHwM~6?w-NiKl#+GRNNMx?>!kG-*0ZwS~nulYr z6c6icKP=hcR;qSQV!^!QDMaU_9@vHqmhENGtSVczII~>Yz-SR{{c+Zijq(y4$6O1# zUmftIe9awDwG1vSC{KSg$%ZIya;whkO)|O?FiI5+O|9k_;#w<(=Qaj3&H65VxJ<^g zwoBM0_-9cZ55QUX^9BNbyKi4-7uHJj%V*ft_&-7(ffuzD zHz&j5Z`8J-&a0%Fy&{aTIVTmYOiJZ2aY+N58praI6 zugBOiPqw*fC(3HqsXzgO-~g}WHrJ)ROU$!2rC-W%sKfP`pr_J>Kh7oW6Hfu_XOedxe{ph2ZBuL_aT0mU9G^^2(8D?@&I4Ze#X`*Yv(j_qf+)GuoX`M&O zI>=t*F7(^iBs=cQ@$L1D?fRA>zF$@(2LwB*iw5g8f@fF%C$YxTC8G2>TBz+7$;96C zKpN$&d2PXzGx8-R22@}p&Zb5jLUfxSFODi#k|cuial$WFt=Chb6C|#e$143|ug@u$ zX+z&$*%tr2|62JLA)Qx-E?yPT`p^~}`sXOb*M?ZaS_B0jR3rSvA!JUccv-TP~$f@l(Xo1AA( zf}Iw+QQMomlSEF*LF-8ljnKgP|SNWbFmxq7D+TcHyqr$@)WqLDmJs+xw( z_?IgfI5*v@@B7}FKSwjv)u?SjsL_c*vm3FeNs=--Dva)ulOKxAKQVxlRbmUsMw@nM z`ad#Z&#g_9KT}|2p-C%Zw{BKk921A&CneX3?EODTK}MUnXs=xG;IEsGF`K{(+N-Vk z5yNn=$<*^2h$d)HL!@3Vc5-I_J86G^WZSJHDMU?O_Lh0^GWi|ZuK`TuzVPVOh- znyGp1?}RALDcBsXU->XVlNS5obuud0l73}X#>*)MkHWt<0|%|qbs+Rje7}*JD|BlR z;n;-Oj?Wnu&wqm?^&~g8WW{Fz<7DPZ(x0;F@<+Q59b&cM3~obKq<}UeFN;s>QL8k6 zpkULir#;!>3&+Pvk&tJk_!e$4^*l&)YtlWveG_)?xd7mdPwM2teh#$xPmy~R@c?<8 z0&nC0zEO9&E<)Jz6}qu7aI+rO1q70(iTNg#+&Q<`kf6FHNSkeK!P$ivY5LfKLWt+jLTpuu_vnLi$5f(U?=Cff8RSyW zZ=B5*@HgUvCd#%9s;>CmLFP}qe&O0L?*!d6^t??!BBL+FNil6o$GdP-o{V0zbHF(7 zeC+wD3ALq>#DoD}iH(7cp0lfFZ&R#Z8?=^AvP{2UtUUVQD;Y9p)o$RKTrx6?NdJzW zU+nNEqaE|fZWl-cCqlwZ7o;EYKeb$Ui2fEY(9aQl?}1G|=RJb0!GO@(DdKa{0Zz+y zGp`Z~#2xGcJ6+%eJ;blEkhSZdO?Y5W=UE3TPuL~g<}r0fBU&F)M)*lWexMC}Cmrc* zT#*ii+(v&w~z;2>RGq~|$08a;>NKRZz1SLv(S}O`{-#Cxh z-%Bv7yHubj!)^OHjeG19_fOZMM#y7b-1Hm~`+fAGR+j%?a zc-ZUBA`zhVy~o?I@}5DsV#y78#HwT~_k1Q>gR<-x^x zdHi@KNp7XNU|DdGrvp;k5wEB7`U1LKYpI5IufJpEASJzi5tc|h9mobH;YBkBtVd2-H9pz-!Vt-~LA&B!MDe*b%u zG4JdfAYRO0WH+Jh+&+u=29}3`pI_Et{}OmEUu;_&hXqmsz#h$A>m@aaVuhP^ybs70 zxe(UD6?s|Qyww6(IMOxv)FRh23bOVUy*M28*`4feY-4S^avGoNqnyFykN#byj9r%c zb<@fgBR|BLSX-n5d!lN{YGiL{DRH`{Se3AiPx^e`+QB^3@Y@sH)CMcvI8%ey`z|kQ zWVUZPZD~fEJ$@omu^~Hr;Vw($p^+FFxclgJSR>gIJF+!wBbx08VD!75@tq*6+D8a}=Ud&g*W5eOh0waOuB)M1DVhkh{Fw1-1PS>%;kPh2*N< zZ`HfwPA>Xj{ydjUj)tkSlg7Yi?&j6;qeK(QY<~063k2P$lX+rVn)4iop~J5k0h#i3k*nKE9;b~2(i{LauCMt6JycUu`z|`Fg!4bUC%emG%*W86FK8d?6h_$< zPIK+^$3U}UquGEdigl`D8J{olqlZ0?yS*DpDaqJRn|5*J+x{z^3G`zgvzd&Whz~1f zG*Zh+YxNSuoedmV^~{?aWoxJqGJwFJYP)*$ z2PrWhMXcB(N%VAI>Z@z|U5HWdmYmmSEx2UTD~@$5Kk%3I!4c&hFm*Q9YyQE?pVK+j z5NS9wwX*6<#m9x;lOj83F=h9riY7=~IuVf+rJWn0CG;Nx`S za~gqx!3pVQar_|^qJwyuTNTudAFcak=yDR;_@}Ys{j85i?`Zy%>8$^@av1na^`mVH zqkEXLbYy$X2s0$!L}KQf@$6 zNsJMLlxpVje5<4040xAA@boauu&>sBQ2e|L?$rGUPB8qRP+7jA_JFL(zJUzH{7hTP zN-D7x2ue+z{(zVo%`pzOi39TnB}D~~hx{@<9@Adk=MI~4Ye;01(aUFmowZ7=)!&@8 zlgi9)355Bu9c6%VO!i@rv=Yk&K}lJsHK?Jrg_+$`Q_T4GEzq>SA$K-6%<*w})pti)k)^UZX`&)+L0$y@pD$m`e zY0_&E>r>lvsB(S?KiDHEc9jWZGPj()8g(vnO0HIcaq(xqzo809>yj^7$1qoImi*&| zFno7!Ta#jDTJpRuvokl~Ed}7cTd(jQKoxF>--n$uGD)E;CiTmql9`?3 z<7%?XuWw{#eF!lRrQ{FE2IA{QqUwVbIHvnp_>^M7$Xmu7RgREkI}a`v zfHmBIp~%?(agbbW8gS zI!bVcZD~SJm|uUyS2_ga$R_R8%I6?yqf+Ctx5WHV1^AxYItdOGy@P+D=znT;d}eWwXBCgg zhOs3}%Wx|hE6?oi?jVrhFO^cJAEyzqA%AX`X9>>owrP0jMHK zE-C~48&4dc&b{2R(dvOq+5#u?oKcB$=R_k-&IRCBTxpPNSmV8(;qkJGg5=7g% zt=snK#fv|mbU_xWB?exccKKtz&Jk}qj6KsH=z4FA173A`4ICy3qAz9_FL@L4I0=e5 zXJWI2Iqv=U&zNcwKYx?-?3!rEi;fS7n*Qo}VgJ4$B&6T>Zd_FUgMIz_Sk5m7GI6($ zhEc4~`<~(g?RM30oDj5)pqZ+F6i?e_H`S|O{cBS%`!XfEE6*MmfG2$gIPAhf^hW23 zW?(MiPH+VHji#z7s}rkh=<6<;vhj^Lnr(+?5l`s!X^qyO%WvA+1Z^YwUP)!^d`ySj zHd3(>zNjgvAWWDQ)?PWxNjA)(Go-?+Ei6Ao6obkxayzr`< zn-ci!c&TC^JYl3k%O6XF+6Y-Nm$Bm$zU1hxSR0-CX*BcC&-Sb0OTRwbz_#Eao(Fch zKbi<*QQ$R%hyweAwE`?2Af9SOuN8>BjqDu;?*NU;fk0iaJ+FVHBCm-f^yqtSoY8sP zRKaRnK=%FJ3$efx!A2KoH+U0B^&%@xCJ3yv;qY^q?4jEj36M zK^%nk#Q*EPT;$>L!ee5N>uk?ulkanfCTnD!!3iarr%OmLddudd%8iHNH{XDLfnGd+ z#tWFm-gjT4jUs_D{!ek*KYh>f{lx3uA3=&LtX~p^*qY8LjtZiTU)R@WA#S6kEs8pv z9oabc;?EJW^?y2cWCoz8q{%w%2r`3mKzv;_@L>XO|Gi6Cm+Y>iU*`$QyPkpCj>TS8 z3TtO(-CU4K1iduc$t2$SoN#8cv#r#zI7k0^nnYo?#2!SeJ)bHsrD@9j{myrJhtF@0cyI0OkQ?kG^i~Rt z=5f2sS1Dn)2ef4KirfWYK1UBVazw?)skCAv)HD26s5lvQou`M_*2%;y3(p(C%-{D7 zs=jM-_2h};@1;RgGMW$8%u2R&6P2UcbqWEa5 ziqB=pl(5f|r-^zt&NFTkv>5-TZB)du>PI+cb=d4Cv>S41R9HV2y7eDEy#}&a2szp+ z)DWh1OFHB6?(WWfyxyG5?=c5XkM15lJ#Pb?jsohg3|p&zmzpx)uS@SXBTv#9g#nH5 z%H8@WgR7;H5*lmrL`Iu^(hs*qeTBwjLWp{=25=OD>i8>~)aQ#55lkT}b+^&Z{Gr{5 zr(>?-yz$QDt?hiMnAM$wHj%0Cm&V!e*8ZG5oZ*N+{a3SVE1zuu`T`=d{G4VWY0wqQ zNgwH%PYE=Km*o?K8iS7@RiT)U%LQ@JxisDgdSHznjz-lj8aBm-oHg%|7M9|Gwfr_x zAvwf^wqU~3#f^R{19jkq#|?Ta^5Kp7+^60H#X2!tFVD=YO zo~A1T@ZV&Oy;!~&s^c7ibgC7WTmYw~F!$C&%k0>bK}YzQz*~i*XGOLl>`) z=ciBdt8z@$bLY8s*-2n|E#mU=xkbz8f9{0YU+MZUx_uCzH0n0k!b*SG9v;SRRqvpf zA1t^Z+j`kU{!_fN=Wn`a+vMoafinPS_5=HUoRTsJk`J-TT5;(}Yo~#Jirrx0yH$@* zlN1h0cz6s;TbC2!KDhX12i8Nc(Qfy)P|e@xM>4MrFdu!xptx8zWm;3|9qFHD90n+ zRp-i>;KkAU+k{=~X@R5jR07;OpB~~hq1)+%q-D7P35D#>qvo!YnRIlEUSR_WvT1<7 zUO*A;S$mbZiyHOvUVXSNhi*=Uj=Sv5*do*7-*Nf1xCf8KeF$pYKp&ocXzW-(X$_q) z2A`^fII@WlLr5(QHcMscXx$Z=pAfL0@ayFBmm61gH-;ku1_Fp<|5S@fS{OVA*m0{x zTE6w~CuZeBfGusXlRnDpQlsr%TZrkJ!^3k1>z z4C)Gcz4v1nYwPFy3pITljMEyFTlws}w(;dR+2YTXW$W4B4tf+s>bAX}Yk~{Z^_`XL z(V?K&E^A*ImCqSe`gRz(Hi}0W`-Bha5M$NTv+)8_egd^U_T)(<3Jxyy5wt-}4c2kK zqNHRWcmJJ%gHw16bhM&}VKZn8RpzegJ!GBnIOqm#30XD{w8ez}ftER;+K)`!J(>hL zw*&^=+t-QfI6pxt)ifdf+PW5wV%&tQkQ$y~rI4uDoq)5KN;N2p=&>)=O|7y%KcHXW zg$O6D^0sn4>OI~0mQrH}nZvAqEGm{+etx0GOn>6)FCYs~{l2u6-)F_?bjq_6R90$4 zbDq773)nQiCSq!X&Z@=UvKKwwf=g`5Wy^2Imwr`!=buH&994W<0eLljD^CefN@Q852pGqI%p^Q9xoLsqhlgnvwA$WW%sF zMJw5yZq;)>XVJ~q=M-1&UZf)uVV{Bvw7nb0h5T0$@~Lm@?OM|cu6*&ZnC7U~2y(5t zuh(+Bu6$FVN1NcU1IrEn?SQ2WIm92!^XWYm_CS)pdm#n9UbZ=lbB35ANedWu;1>_z z3%-i-sHLM;eCvyR>pM@MWQvAHN`H(#dvKDmk<>?7|7G+QrP%tsG^drFC{E}8$fMlO zap!T*B1=o-M*|o~jIpW_bHeFaS`E;}dvl_W5QOQ1f3YL;v2F zjqBYap646Cv-7NiOOu``At-4^i}4>TWhPDfnu~h6Ujw=1F?_uyV^iN)FQ*FJ1f!Bp zT2&OD3Lr;^fAk-2C!S&taj3}@-!>Sm<*d_e3{Mj(kJ0EwoILlt6*6>E18wQ@(J?*oIxiO65*2D$ zT|HZY;D=ZIQ3dGOXv7;B=DHI-%(CJIIwb}-5^E?GWBz-PB<)3YJ$?D=9DYc3d<7oh z*!oXeEpPg|1x4xQ`uu?P=I7&BS()p3P^)TXltQRp1$=u75`1-kdwG7bEWT)7?4dc+7aZYW_)a9*O8 zI*pEep6m7O_G^L5K_qbrc#<9S?v03lKp&MqCOv^et3FqQ6JnXGmK+pxN2~HBLG~_C zSB)WnUJ9m7d!--Agouy~OMs}L;*mrDOJY`I8wDJn+~hZqiv97RU=sK<2eqAol5IqK z&Cy~X{o7o_MD5kH*&jXfw0-f>=k1GMf5sm9)^qlaCttMhzVa#r){g!W1!(vf{Py}- z#i+?5vIg#u(OOvW^?_k_)TNWSGC*+Cj?dUlXX6e6CJkAd;y}^Jemw7DgVWR|fL4Xt^|yv6U4eFuephZM6!PDsKOyQa zHS#EI4f7G(Z0^YKtLopm6YU7`fevk#n9>QA@`Vj@)z&1p)@#9=LD!SV=*g7S$$l(& zQUYY-c2oXD5HWxmgDoArF8(13W{OTzpySMdQa5F`v9NG4WeGZc(L9c}@a9#W>bUgS ztz2$*^8wA99J?L$H>#k&RUCHLI==1tU0e7l!DJ(kPd4V-J&fHU>>uc`pM#e$p$;aJGO=!5XP zdgEk3_F?Z{b{`zR0i5od%MgBN4aUhz$I6bMYIkPVa1US+UnF?Qf&FWAlm5FzSFa<| z3;yHJe8;}$k34OE@z`_r=#$Uelh3?lHco-V7>xr6xNEn6jNpduXk?HUe=D}* zxKD$}0!F?(Zw$+K4NSNj96m3Qg|EMW@IV6Z9MEJk77YTQX~&WS>vlb=e=<)}V2GoK zFSf(C+mE2%`E}rX271hSa?ac5J%Krc*nOT8#Jh>V!3NLkE)9GHqucRv{ATb&-4sTg z2|N0OejmaZbRI?j7k1YCYk(&5;jG;x+ZT@Eo(+$iL&b4dhC~?8I6yN07EKLA+O zzq6|*f>A8#h~Wu!N5AaWH}SUI!zKBz_vvyxUKF@~O=;=#5ux|eu@kue4Av%Jn6`zj z%)ixtWB=b3__--$(GJ`NM2+xBdqG;dYC^Tyn(_D>76e58(UthezHSX`QydiCEu??R zd)NO~=os6sAM_GH2d*Ifx)!Cqb7TAK#_tYwxVr^}n;t*xg@IdH$k_3S9is$XHZ)+RsGuvO4yz3g4hOE$Eo0DE+0n34_`v+%`rBQf0Lu=p+okb- zxKgUN$Hl6}Ip6-(?fy7C?6(Mx2xbXOlEOr2$e3CAq3txh?ndD1bnE)TW_Q-OvE*(0 zB^c_Xi*YKfT^~5#23)kUk@I@;`t289u|N6Rv-YKLK5t)p{CWG@6VKZ>o_fJvc=;8} z1SX&F50Sw){=A)R9FE=M6WMCe4E@~x#ebhpx8qI-j8l{Pla5#}aIDUDI{AW!S@=Ip zG>u~$`*fiHGz{Y(>8^kAzybOyg_*tWtw*8%fp_0y55N2E_M!K@-9GuD`|RGk?vTPg zr*j6>qF>wb*aSfOtFY8jCmb%sz>kdltucdROcIl3T8r9lallC~g}uh2<|w?m+{C9C|ZL zzNi`h6I1{IAOJ~3K~x-w#1F>CF>)RwKFMmXUpJV`c;42dK_7Mgx&n*`a)MXLrAUeP z#9NP&08$`u`iCrE3(1A>G^z>|lvNTBq& z{0Y`1p7ivCUKrq3t{-!BJN@MU38UKIh-VT{8a`m&LOG1dfSb6I>CJ2}n#bZouo?f> z{Hx1w&jE|KK?-zIRNM- z2N1j_XSU-HVB-DB9KktCf4y!kzY-EA#QPv)+>U5&#Sh7v1{U>3X0?6O&;6ho=sE?5 zbc_*rcKkwzDbcOJLm>Z!$OQ>)vsASZCi!Eq2v8Y32)SAj1OsrP#``CKo_ ziMFM`ImRD|rp(94xBL4-ge@A$J)`7@z*nU>Srquhlk+-XFSa|Mj|GN#&M}Uj7xKD$ z?8SeWz+5mEd_Ny%ap!~MaXtf`bFOPAlKFwd%!1!(k^qpgvfBUYbh3Bc{T6%o{dd`e z_rJ{^y#Hm}{s@CS)Z`^M5Ed!4J6TEM?+F65zBXqMu7twe<#rGu~^!%pd--tu`a0!>nGx=>2NJ1oFNC3-4 z-0>g1Zv+=gEH&WnT)j1Y>X_JtywD%~Y#TVZpE$dn&o_>R!J9Qvx0r=v0-6*Q9Y##f zq=|VKVlzI>+JgcHqvTl|NZh$#FbcFf`(r1EZ8&BtfJEnVz`=l`Jld!dC2&ojaKh6} zgQmhUvvR!~LpzGW{pt+UR)7KPH?6XQN2X*L`Ui1hR!Cn-)FiPZHVdz&4w32E?RUYn z9&J0sgQ6Nl{}pG9wxS8_<}cGf2v*YH=s(I|;uMZix_#U$-+#@%{?yC%wI^S)uRZa+ zee>Hd+f&bd*PeR*WqbO$@7QxMe%H+4dH3_Q(=etlP-WjcXMVOX@kk%PE2Or^80J;_ zF%91Oe^m3w9Sr#?#N7Qxej7aJzPPgB4Lcoo9!v|nJRVUv-qe4*IFNZwVz;!{!)6x! z{EMq-S_sl+Wq(3X%F0cr@iMLciIQveRmZ3 zcies|>W26dYawRfbyn3HBX+gfK0d&28r(Z!+J!{@cUy0BvV>Xh7RXpm?ipf6NO4-# z)lvIv3{S>3WK#RB=zn#&$OkG)w4`ZvYn=Ja3g*Cvs0lw|hk5Uhtgig> z?PEEn^r2)V>NWtee{BkV2@1}QT@jxgV_xejad6eWYV_O$m+(2W z(&?PXJ|$ovA&uA8q^s@IfBi|A%&qF#I^5r#<%V@7OoL{jxp&%**z~v)?)Xf956o z_6y&$=U@7sJ@?{w?fb7Cx4`=w?_-`h<86SkAaGuJft zW#9ja&-_jIZD-)iaccniE`N-{%xw5)0M~GT&qDaE)GY%Vuk9A!arFAh`8DgG~&sOT>7sQSMG=Y}z)RX)5X=cF|kl(uM^#;fQ93q{t8gxb~#(;t6w z93gOd5jo%@giQkIIP*Xp4sSG)VK!cDhT(d4~LhIO_Ph2G*T^CZ^nd0|)a_())OzuMR}sR`MHw#gTQ|wc}$A9!GCZ&JOx#(3?&OVW15qUMYn>VN;ve z#epsQoAK>=uQn14Sl4M`1Z0+=YeLs84ltUWjQxMiz3a9u%Wa&AJQi=JZE3i>-~R!- zhugAAiKNJr{bLpoUm%gS)}~0ei`r{ePo{yBd4FMpIOaJ9fAh=u?XQ0s|K+#8h=2O+uj8No)34(H_{ZPGKmGO>@n8S*ujBvv z$KS;N`H#Pj-~Rd+P407r>frg}j>S7o|A{v`pD=bK)-mq=>f9Ucz6^zj@8RDqlHCpi zZ(N?|etM3FyOXEb?x4*$Z8IJ`4DH72s{kGB)Oq1%N8(@K)W7Af2X40fYdQ?!7{{T3 z8lFbH{T_Yp8~1E?WD`-|)|0 zV0gc1_xbGlqunhhLtnP}_vCR%hgv!8g6C=DKlSTx_Qg@pkQ@X1kls#0MhV#Swy}>X zn2#K*kL`BrcImv4_*X9YGF`O$e2!@2!q&w-rQ2%b_BN;XCfK1^R>O`z|NQeF!3{yW zF3+9V`xxK3ezg50+y5WeL=G(fs$ayjU^R{dWCY1o2LTOUtxZ(@x{pOyxq0is$fvt? zlIi6)hVE-d(TV+qF^zEQj}h*nzK>NZv7rZ*1=xZRr3!2}Y%;zgm%LYdf9`L=!^&k_ z8*&GHufcbs!{nrY~dm}(QxdkT|M_?v<%*!`_%$k$%V?f`LB3- zV(`$P7`&m)1^-OXOR|1@i(}6{iTGc#{NYc3jz9g|U*gYy`8j_7hd-4s ztrhXRfBDm+pZ-S!|M+iziNE~iFCY6pW&iLWeu`iG!%z0#%U^57fBe-y)Mxph=U>Dx zfAMMit6%;@{Ng{X`0cNM6+f+r|NPru###}-{q-;6H^2Nv{KsGaL;UC8{38DGx4(-2 z^dJ8r{>yKE@jU-BV)VEUK0^RR3pL;$>$3l?uJ56`E8q5GdOoA?(7((U2-zV_{q4B@s^Lpjlbs|pZ4}bCS?9`Su6*kJux-gM;o}FrFXTv zC4jVzO%5SqYQG*RVQ#S9CXH*RLu8q}t-txAIE3tazR8xjDN=vqZ+52rIKXQH;O*@I zon@tS!^b$=J=9|Ocl#0msz8*UZ_GKI*`xD<=}p16$_4}2!SB=V4gZ*zO)nep_}8X? zcHi4wV<@`qX9u7MeySK7&pPA*z-_}H?S6W`wGj~l6oRI4Jf0Lk*L(<*QVrz%DRjjC6h=gAXJHuti(4O|j|Eqp7=8fSKpYbf zI@p}pE)nT()dGG;@w?rcKyhn>IKPOx)W@@>`biMJ!}=Z?l1Ufw&5JGRm6#ZKJWeQ z%vAQ(TYrmV%b4Q2IF^LKt|Bh%x}| zbb6uiPj6fN7g7vd!TVd;NB{G_a|ijq_c{A@{`n5jzWvJ1#QEEsY)f8~Q%Zk7Ea>=0 z+n!t#Rd#Mr*5FreqgT+GKxinpL)}ZC@gSM8WL;;DDK7bR9`oOmYm#3Z{|Jz-4m9R! z*$(rJnAye>-<+KCo%^r7Q1>zQZ#MCKtQ-ci8~mG22k-Qxv6R+W{${$NR2#o|WDD8k zKj>>?N68?2%p3pPCZ6}!mRF0->!N(Ms3C0jg-YFUpX05$?=`oBOw4~b{=RW;&+W)r z=E2|$i_t@=LkX2l1aR26#ZKd)tT?8!9LMp`ri|u29V?#+$jwv!J4F3_-vvJ$AoM>1 zXvszcP3w;OPrn|MUvJiLZM%Q}J+C8{njY7FFZ$P_JqtJ`C+}l5pu>1aeMC6Gc0%dM z5knsYackqxD(^sf>Lk%8*EAX)@O)5TcC3aP3V1KQj;s$-R3GU7HaWFGGJe{Y;oZ@C z_Uzl+nrY;;J+CTfg2n`U9_Vb>+cqMx2`5q-hOzcj-_o~)!bZou#E7L^IqI2uY5fl} z&DEI_77_u=0_D@L~UoRVz=hgI%x9hyCtix{o^8|GG@tNxudhHDC12f%Zj%b4TA zvPp_$4dVm-%5Nfse{Q>;bVS*@#yBEtzP|P5{|89y$|X1Gct&VWa*%|Mdgr>8lIj z!Ko}avi9Oela><}CtdY_b;`Ow@$bAp4i&1LI|%B<2MV)kYTUmfmLVS3R5Td!ubyhEC)sPJ+f}BeNqm zXNhRr%EJNNFIwy?X*ms0bLG4n{vTdm zW=vmRXB>5SPi!W9^`+`dm}5gi*B`P)|81M`y0VUO#PSOCX7m31{M(NMTboQ4A$#fH z6X8hzdT_O#8z*Iimfq06?4Y&>adi55t3TXk3BzDAO9sFrpK{?w_D$ZP49Qi+OQ8b- zVs>ES@XMF~!L5dU z#wEbF14A2*61xxmi$lnxG0TK!VWoQ>8`TmgH_cWPkGQ2xoRsSE zCngVlQ`=8O?aP3kFg8$9j0HJrwBuyS!zmnDr+q9Wo$M z%Y;<6U1e({Iy&3HON_Z#sjHRzc7FOt-c|yi`s0^va>%XA%Ti!{;h?^?;o8vW!N15~2v7(4WhX7ckz}m?yR5DK zh7JeVry;(3QRf+qr*f=<>G6yQOrpCv7oX!Z8RIp$q}jpiw%LL95oJp5T9=xO(U^T& z^jcZ|1K)=>>q_!^F4l85@dnpJoJ5k49R60m#ewAOGHoyC<`_<2_-9m;NDsC_+SFZ}lk{C|ikF=+Ym*k`}qXD-P~Nf#cJfO5R9La_Azh1*mELIL|X#0baRtkx&CHtdGFagG98t zuj6x#Os*(Zo;K)Xn{YbK!e9b%-IOun_;U`=Pf>Gu1=*>3X z4sD@Sjj`J&NyB&q_Rfc~vHl>7g*o*`e6Ry?WCDOdyB!xjm$f;{t5_TKHT55!vn&TE zIa$1*PWqp7>O>j-{*2@OR3_x#RU~_eo3uFU8oez|owyO|dn`l4b@tz1ltr9#a!IEh z)6u54>-UhIV{y|y>P9?8+||Yt;}QS(cC+H|QdV;g^|xzm;|}W!bR)2#ope*($s5O# znC!+i7}I&iH;9~5E-gkoZFtdo%lBe01r00t7W3r4?iY%<#W$nCRe5% zv>EQs0NHKFG&xGQ)vaB_ztk+jq3oi=e=)i)}izkTD9SjRq-p2w!X;|?(N zQ*91)a?CXWia4Bfa@t;gv~i#L2K^uH=>8TXtuMQnS1=KsXn~jAc*G~ ze)!=!Xr7z@Yf8tz&K>;#;dKArUKCjkBixu%{Z!ui7|(9y5_O+)n$13qkNuzVswVdM z53}~$OBa8Ru+Aw+ElbN~M=pYcQkf$FV1R7d`efO-{@(pK5XkwE+ExE9rni|E9GEe1 z#tDmu10V+J01|LNSZDul0N<`lTt9U$8{ty=$2a=7!-Vg?vHx3MH(F=lfkW+xg}O{+H~YfwBufE6oSG{q_Qt{X2G_x zKm1?7t*Osb=aVQ};;l+HoOD#^pHA8JwdWe@#J`M@+gpCw>#{*PVsPHz|Io(&ut78j zfVt?~7f$ZuOq_Wfc$j{~zegMP-(mLSb8jzSXt8zp$(ya##Q>AdPOx!5kUgXHy#JO2 z6ruI!b8r7PM{hSM<7FfFX8V}?40ia`4ER^|YM;X%UkH>){@mMp-_-x|hVpi;Fj-%( z8-T^hE1egn`asEX5McJqMxS-M(;vBIctbrn-I>F^s6Xa2z)-UXacvH%l>gfQ*1px> zxMfTq8aI4s;N<@!0c>{HWX&Eq2lXo3Yi!fk+Iu(9bxexS%fwaQ)?SBrPbD|yNgI0_H3ZRdK!EaNzL}?sU z-7ywTyjuS^Q9{oh-6s*>E~;@Z=hXqgtuGPX93ZirZGR$2-G13&s_RJ#%W}RmkvWag z!Sc4r|Iy8VUxYqS>C~Uu?qZv@T(>UTr*6fnjqEnt?7dCE-G0{th--a;Nvr?>AOJ~3 zK~yax6xp?Ph|hclyhsYuX*SxaD`jBG(`UCj#L9XiqsL>!KICMZbYZE_01gqvmYqLs zlh%*-T&32oNPNa!(=(*V^5D8FfDYd%Ypn+uC$QUDTYo?L%K{s=HQAnDCHPB(@|4$a ziof(P+wC198~O^`+EungyWPeMJyRB6#`UN`{gZ@Xp&Z++y6ktSl}mrsr>SzvvU7ld zb7NEBpJXONv*{T-J!ZDZB#pWr-;(}MvcOoENunD7u7`5(lS`$T^v2zCT*4^K&|55x zupmP&u6_@s?QI^vv_PeVg>_Gikdz+wbDV9fzqFQ?P%j_9bPV8ZtZ=mBvww?Xv5VVk zb@)iI(a;Y!i(+Q{s*fkMQ3)#XaldfA*k}74 zi3O6ep0ljs-L~Il-rnC1Ca(3vPU(-uA{IVnX4^*w%3@si%#r27N61ryy?h0*z5SUV z1I{5=mhstSsodZ*`Y!uGkA3ogOMoCLuonT${3-cSeECR~-231k?Uy-(BBkjcw@vI5 zKlPNkAF%rA|B(x_Vlj@kTRL?0JC9iZg)ydf&u?+glmAv+t}y+S+v|BX1MRKWw1MzX z{`gqktku^q`J_P*^j|en31jYoFB9lzX@I>eovXvo@~6<2AZ5rfuHR+rQu%bs2lp=V z6~YSt6qooN;uc>4T35!pOR_0hsCA+xSB{4h>&3c65(MkUTm>#lQpy5bzgS)XzslU8ep$GbU&2e4 z=AnP4O=!-o0px94#N(mq-Vmkpwo=od^AUkdHYLuLyk^`O8I9W4SXNpI55~;R!c$*5 zBRem5W7~ca9|JB6^>X=EmDzi)G|+)>il2560Q~QIX1f7I6+_r%jrh;x4>FB)unYO6 z=19G_V#w@f{T2Tk&sj(GE43bHn*4YCrLeZ%&|i~+YlUIA5m^1BUAz=iUFzMv^dSO=6ep|M6B!6#LRW4#54u&IC>dFuA7OfG(5c!hPx>LGCAc z^VwxdAoV(OjKAsRn7%$|^4sTbI2W#e@?FlyO*U(#an{q?~v$?n9tE&F-iVU%r9J>c+IZUSs5GLx}}b`!DoI}a;EIZz_F-EF8R=oZH`#Ym;v6= zeO6%Y7Uf0!!KgD%t#M==AjSjYGiZ=-=&#`yC0Elg)-QJnsUUfW9KCZa@NiWm$$DfxG$a==Br0rtsP74ud*%#42goW_vTuSO^ zpgn`|H^8|ta<|o7(ez(0?Y+K_@v1-UeA;UIT4z!sd=b7XzqR4|#%#XxF!AyYnN`G}P{2SWzvR=-;*GYpX1G>$; z-GxSnv8L#~<6qj~%tbVY4o_w4#J_&1!S%1U8;6ihKflMD(1w3g*=_mjdf|+_J*MYd zXwKXc-JYBp`LDQR>f<=sIi~WxKBnp4oTh^UlKqZ<>o$hz8qF^zKJml%vPG?{`vu32 zR(@T|Oa5EB=0s?JB#dk`jsdM61rpyKdcYm#>_j#HI_&5Jo6*KzhUcx=p^+_S!fkzp zi>d4+wR17hm-$$4Tk##t!9IW4hZ|S6o5Q|;Jwa=3D=8mYwrB+WP5_NVnw_eh#@az& zHf5^Lra1ao7nM`ob+-93{A(D0fjDuINA?{7&+?N`%42x|AVv29mISn^ttT+|4f1z4 z+26UowGoewGpC$i<-JVEdLQcVk+rIi&*f!x!83Xyn05XMtcd8?4UYtIA_q6A>_5AZ zNWyR=fsO%*0Va}h3~y|*nxrr*6W7u9t4+iLL)wr1xam0CxV;?U*QI>MW!l=-zfh(V zFkxY~^HBz4*I0Soh-7xz^WK_*6!2VcWS`Q!Ki1#KLzB_-T-^3K9+NLi+`{Jc#1t!b z-3>tLLilYqDBkFaTHDkg@dI0Ps!jwny@}H7M|<_JPI|i|S$*3LmC;849An1Uf6%`r zxD)<)jId8zfB3p%agH;6wi#A8EJ8~=9) zm^b=;!VcJ-KLWOF>km6q+#K^YesaUo?Aot$4?AphtwXn^%I3rL`(^x*@=v@0=a{}a zJ@juFsm(k%V9LY3rT+zM_a%)#Hn}Q4akjruVBHyt=AF%zPp+N@d^zZ-!Ek>Jx(EH1 z>hd1r({4BXE8ZZ^!bbGBfZe9e{%-enB@O}q-tynFb+=<*jG>>~EllT+X|NXw@2h-F z_R8{)saCU}+o&G|%r^UUc=!L7t5~dU==_dB;gLqTLZT&Y^ZgwtDSTr{`GLs5tyRWXP?T;Q1{H+cYzB<@A z6Is2TxsRFaC6}_?v1c^>x@0J?3CP=cKDZ|rQ^FA-ANm4jJoWc>h4W8`PTFTM$-8a| z8ulYjB4+O!9j+RWL^6S6-A4yWClJojH~b}biP=DFd3`inSzT4#-{{Y8ASNg<+f2o-ZPF|W4J@=z&oEYl(=E2vX zxoJ56xcjAiM_<_8xpkTA9?RdvVNRpIzOi>Zy6xT^;P$nctV+Fh*=nM*yG`2xX++`Z z#0{Nea_sx&_`)&Wmfr%X%XRRpV;3zoSzGq{EV{LC4kK}VZN5Al_zwPc$DZ1m-i>Q6 zdcBsx$xm*XUrtQpxUOMwKvgy#%0~lrfh8|a$AqWzg-_7^I@I<^09oGJhuals7tV@_ ztuy{re8kDmU%LEOEZWtK|CYnmeGGMNG2e2yW4-hF3;gT(Z;zeMIVLXG|KJa`^!VCm z(-L$awtIx*59)`wn(S)$=dQS(!s4gxwmZV#M7+f+?`kPN<{S!xPn60Ky(!{0SrJJr5 z=_C3#!M-kG5yucUQy<(jaqK&;bC(ADw6^7=!aviWJd+YV7@Gf1{h!D}0$%IalkBt) zF&gr$T1odYS#KZ4C0-l}+!ROENxuue(77htee|b1j~^HQt%_w|j%{*D|9rcjbBdqR zmWq`p0>Leri1^r@JAs{}LDC;X>(lVB=BwC^dd_;d){$H6Rl; z6aci7=)=a{1)OI}2kSkLoyGZaJoFX>x0;nFVJV7ZN+k!d5HyeBQV|Jy-9 z`N|GUNXl1AA?UHg{DM0Ij%^GLU3+qz#IpCrx3%FXw$cqJj?Z|?<(if<;}>*M{!i>G ztbweZ5G_HS?ul&`$iUN$O+3MLX%oXYY91pZuEYOBhX>P}6usGSJ@_`_W07sY{a)i8 zM$X3bFYav z{aig90rVAsc!l`qiRvmJa#-^>R?=pN`(C%(DygM`p1S`I354fe-JQ}8i!;uv`yjU& zJ=S@!v9UJBH7yThoYn^OktKO!eJa_>@GpNWmrEy&W*cr`y0bQ(D4klp@qe1Mr2<;! zUF8R7h&GRDt;v}HI>u#_rS`@vT`eSlWqUenWPP0W>G?0#ywS{0gN|7Qk}Amk?Se?PqC-fjK-^UwR-eBSSQ-OB%(cD>Ei=ezU$$n_na z{ht2$1i`w?o9-oo#-}yyw=Qm8VyJgmANm~?t-TPYhw+F*rStMOAc513O$7zkmYCen zGNCH)dKyTrHlfev(fd{TGe({K=RM#(=~gSQ9PMQXj3C?DQ*Na)C(j@FyVyLV~Cu? zx;2PnqD`M3R+We(#wkzxz+k#$Cv&_2{*iMg2RP~VD<}Ds5&v$0ocLGVQa%I3EZN03 zuBSHr!qry(JeR)V&xej!T8qQ4z~&8fE*ulI(l_%z@(1~X<8~b7O2pnx|BeMRi0kio zc5ulvwsaoaH~e$C86KZ@FAy{8_f`F=|LOi|+gPjo$;z1*VB%_Kom#wuc_ed4%P>Gy}$YTR1+4%C7!Tdt*}LlW&ygoXr2kxe_orMEKXs zsjFLfACsQ#;Q)}W3*PbXv}56#@k-XCyam9g+Q#`GS25fCA3{y>xcdlK0(>>V zG1p{A8sPaK0UXA?T;KBL%Xoi}4SXN>J9Bg69`P>!S$7i6Id%XrTLKG^Qb4hN&Pu|0 z7S1!5bBwy+l13g3=dUiK3F&|I#hV6*Ht(r$5K<)4+GEPn4MZT~3A3@uOCnepbR^O_ z99=-Qz7ffhRHto7Du-x8;s?^u8&ip&_RdK-hAohh?Qq}K?^j>`r(NPH%SlYOk%4WJ zR8F$!?e$l>t?Cu~CtG|4Do+n!6%8=BPIE7{qr6j}FdIMmPw_T! z&o><4pxU~?M-n7Vk@0RSrP)qd;%wVUHP^Fb z9Pn>if6FguVZy%!!P72X|Lgx7z_+q~KRw^d zex&|yas9~Qz?DcMW@32C1$=%=7A@!~M=tdllo2C1?(RGtno)-K=c#G5e%brn$WK^p ziP3^fvRxVW#QxG0HgWYk;f7ECs(vSyd$4hg^OU#&myLw=Fa5cZ?{8jC!B* zkYze-d)D8U*4s*n*>V*h2!Nh&96B?2vS^3L+dA#k%Wp19eZ1_^pZbC#7S`!FNFJkV ziq077Zg2ieGw84JrZU;r-(;5GQ~vc{CYK#}szA(^$*&&*pZC7gfxzvMU^)m;T{MfJ zeb3p}@GVBm|B{=Ij6TAhk7d_gK&wOF&Iy#M8*9@0)c>}&?_=~hh>)%R>I86VZ-L#h z4l&acMQ&&#X zg1~Lki3e>9oufVX!;t_;*39A8biUbOp!D}-EuSTpZ_@kQJ}%pINYH)ay6KOX=YHK> z1^v#A{QbFOHNoFD94)!^jXB!pfIUuD8;@-xwEhJCu6GP|s~KQpz1zmsrZwC7m>WYI zr?uZM-kmrm;)#Lz2GH(_X|J0AwDob)jqn6+S9!*r^j{up-T$foH_G}U!OeE|+yLtK zHpun&KCk>Y0AM8wg*8fg&yDDYt{m{hKOawRg;Fs*^Wx!kx7Gw4v%?erCXZH*qCFST*9`&2zyMAErZ>vRxA+Y&ORqK_J!i60zoxqB1xT;^ z0uD}2;bNQ)KgU&rTIv58kZ=0c);IoTjmA5n8fzQT;?(dj^%|bdAIL{xO7#5qj2GI< z4UqU(J|V@*_C5WnVPY7*bnH1bY>IKRZiYjP=A)h0zd67l;iD7}s$c*U_SpEt0LHua z1ptkaL)MzSZ|vW$AMHEI@wb}mebe8t>{HpuSw!(hFt@i*eB({K+Ge%|_|wt)s4dz= zw_SIlIYBFj82aA!0}ba(Hr7pvR?m$a=GOprGKR^Dqz)B_pdq6fFv4~Ph#XLIdq~1& z12G(54;a9ujhvvyFAZ8dCu2|OAnHjT=kgmd$h^bJ@I*Rhv+jL-GwC{U;Cu213p2Jp z0($I0sf9M4)OA5SVmJRE%8#TAH}=M}h{*59_;1ghB)YAsKl`{BG-rth**`Y0w*Glq)peFuZaeCTG&`?QkS|Te_(q zBJ_S|LKiki>0h?GZSH$KspJ3?v(X4F2#`3L;?vdSIvt?5 zHv^DY$~e)UPx-NGw7_9c_qG0|Koqao*0{l6)*oL0lzx5y(e-}&il)zVf{fqa-s<13 z9&5*Q3?WR+c>=?k7Wfx~JxjOwg$)(gJP3I7?Tg`|GdN}2C6G&|{+_S6P3V?y_|N{- z<%oU`+;gyn``B0gO@cS>c?P`VpD7s-Kt6Uq6gfBdIpA&6e|uYh=(kAdhB__=}1Q&We(762?MO3f8N`T;TzuD_1118`me4j zHon#Guaxt;>1QxhLaPP!O@D8P1%Ci*P6}}$(KR3(S=Bm*!Z})7Ecj8nF6EN5WA(rR zbGu>CDcs3YQXhvNWFOjxqaEcaLw~`cYs%B|sT?pmfZlF4QCSyn8iPygsxpB&RW4Dx zi=1lQ`>;G({iLhaxo1s8;3RPqNrKgQ1$t19gwYUp`YIyP+fJh+fY`>E&|mYNTqU%x zA$mLLHpLX}e1!kdx7XjJKTNN~HLm)j-(`38(fZ2t8_iN z{w(vpbj#l7NaV;ZF|}!2()lQS;1y>-ce1AISf~5xjsN?GciZME|N7g1*{QgN;*cvK zza`1fn5SJD2W5SV&kOUt^3D%lZ(jl2%ACm?#sl}QKR4#g-Kt-d-L%ARn3dyPHtX5$ zC{raDt?+M#7B}w1Exb4PzKq%2AGQhT&-RyiLEEX_IOv;Dv`qjr$NV?f{PU~>fy)b; zXYRg9_issbjo(GJcsaS%CO&n1;~ZD*(O8ZR?N*JqBAsPF zW4Cig1I-;8>5;I>=N6JHea7id!ZBCtah6-@lI$s7cmBMG`sIi@UP_hiO|-JpW1HP1 zL9m{|4MFmrk($DkKD3*JklhmeX&0x8B#3-lveC};fBuv5sC_d+`q#e4ht_AE7z3PG zH2TKB-ZolYGfOT(%3t6Q0Y4%`f3`D%rO)kf@6X-|;QLs9*Z)(fH^Lu5^kx(5@c2@H zqa$M4Qnnmj{mL(&#K*>}_?IoJ-O`YKooq?8{FZc0F0dl3W|jOYvg2@|Y(vPZXUvcd z)2erLsPrN?su@EP{>`^k?r~bRDq27QlGNK zD~U%ton!Cjlc`&7_Jn^;J`>M3FaBmfEs$lqFzVD_(Wvr_k6q)OfKoG?ZzW%R%A@*{ zvvRY&;Q-06`wjmfcHg{kpFqx~drQp34aeK_jT*k_>BOd)VUzBcETilvDCe5(je7Gp z2mFc$$ewvgt^ebjHfFYk9p%5}tObibCo+CYk-9m$!k4fZ0nUB^03ZNKL_t&vYr@#~ zz&qZ$*3`nU2{iD~FlA^`vRR=&3@!x6WVds<$S^Uy2EBWB3L zKX2P{l%8ozgf(LQ1plQ!IvTD4|AhUBlb#f=5Vv+mTDckAr~AV`NyPNyTor^ymr>?&>mF#IOO?$$57jm1JfmpdxHQwHPz{gID2pZ)Uy zrQ#;C?CFzy5>b}%gY~U;sef6h3J|lhq1n#-qS+~Oy;u1g``4FE$m2TK9`W&rl6bmM zUVIXY4aP;rjE`MfJ~nX%a71hSzgGw7ws_W{d~eEso&TR^O@Gy=-F~{w*@?0zoE_Lj z!h0;Q@fnq)d~Y7l?>qA`kKmktU@6eZTw-c%EYEMI&{;1cSs@FWJ-)Ft`W=!ZUGFXM&`YI<5Fsjf=a;eQ& z9!e+~%6fwkY;4PJAdI}9_MphC4je4PDH7b!KaTrKEZ3>$j2{pp?PNy5nCh)(;Gfwg zF)wgX3Vf5oI*}$xbIy7bzGVT^AO0rUPD6Lv%>nvb(4ph_oIYmxEafS`BgUNrWgQO; zb0qSEFQ4Y%eI14r&&Y!|e3pmBf4QTU{NZp^W)i`9JhDVOuN}rb8yF zTOa_egVM}JE_n%YHt~=0$kf2p$u?-$>&-hEKiLnJJ^Hz(M3a&O31IUUO}L1twWdEkWuD+eBJ<-#z<|vyuzQK!h8OG89POfbmBdp(U`k(8c1aRn2F5Wl* z=qCjoD?4~4F%3x(u%#-{| zl4_tXvbiJX)&l4AhHNusK>w`6Jhpdyd~YAJ`3WeiGFM6UDj^Jsn)Wm-5F<*KGF(?b zqd(jh%mzdDcJN(If7!uoQsl9MYm@cO+Y8WgvzGb^yhR*L+(;lfjxbQ*&P#7Lj^&r1 zJ@*s2)TwE8TOu0*Lm+8tZExw3{6&_e{$%3ci2PoVp-nd&la78ThChuES#H(;)GF#AKVdWw>B9II-Z*xvT}J?JobPcr#t*X?s|h|de|hqk-B=JU*h_+911 zY^|%lTu@jvFfFd@I=3}3+xV9iuD{nS1hzk8czXkw{002u#-1C2`Z}OFt@%v#9pa+d zu_Wj_0;_CQ{g;d_rvka+UbQ1H+{$Ey2VV6%P$1-g+w~np?_C=z|q@g zKY!Qh(AGBjWxBN0jpi9qo433#_UpLDvZbTK;?5h{}j5pfvM4^hx(Wa_BaEeWMwWV z{beUx`$uNROyMAmp6goq+LSMK6*k5x#W%*?C9?=(Q(+s&Ft_FFmE*M7EUrnv&1rn8 zFZ)r+kmm!+S6s#;#GlHAvhqoEq!yk30k+Q%9b!O9Zl<1LD;}`V>=*wGhXQ_$9r z2bU*vPWI1$(3Sbqajf^NyN=w~ zIb8Ffhxs1<{qX%+^wNtjJx)i-&8LH`Y#!Dl$D90cm)x2kb1lhQ?=zPUPpjWTj2XVA zPd1zll}mr0-q3o86;Dp*Hm=HqfMNV$o05-^B?~#G${IFl$(Fde4g^BslI0JPW{ehg zm#-jJTP|t`qh2wV+1F1f@6f}(tNNL`V06Mu)naeeiQ4K;~LvlPWRZ0Jdtm=VP)ki5l5X}3s#4Ww)uX>OQWA(2Sn4| z&7*dWyV9z;*y%#s<*E((N_#w59RB5`@)*5*5@*vE<^T|QyZ&WE9T?G?rq_|L!#LJY za~EQk?>G@8{(W@NIN3D!9YacPdzann{J#6`BkQK^qU7V@%fR?HgY;cqj zu02ct&r`j*9PBFl7*!NWkth^HdUM6GXlMJ9q05-Q4BJa*)g>61|4;4m`I(bIxkLBP z9%I+<41dH@pv+Mu+Pr7)v2DLt?mMw@4!)&7_0LH+bQ1>kvXZvppS?lZiqVd})8#Ji zUjmF5{p1_{%+#E4STw^H;1kn*dJmaXEh;`a@?zhqPW&oF~Vx@6M zp#h$`S!WxL;~V<vk;}nk+f_h#Az9CGf*YN9_&O6ChvK#t=m*4VViRrea?LN!h zZPjAu$Wvwm^qi^dtb^`nmX2Eij(W*ij{I!km?_T-*oAtGzT0xR7Z~+8pvg`HRD0Qj z_Qd*|oz(89{$}6bqz|%Du~vV3JKyX6J=gt9gt?9+v0@;*a&&;@<21bI+rRZ0$O&iV zT-rQw5lF_L#n_*fO-I}&4(zow!8mSNAWp6Hroq78?1a_;`W1ekT`~wh`f~P1<`v#B z9S;tjvhST!_jB1yqH{bvBdgXa=0_NRx2Sa$_^sBD{_Gq5PfOkN^pkHVi8vO32wI*T zr_bb9Cp*w)W3;s8YR^(b>zM62;cOD2F7{(0$ONwR2N~50Js`hqvYd94gWz9#;q5170S|MW&g zbII#yuhorNhO|SXW;esoEpsvU$kx?-xlAb|*JeF)8;4}_wYU5bxK&~o?k)SRB(UM1 zW06}&DxUogn;ltHlMOMpxY&i!&)mr+8@7Ob3;!YSA;$14{?*!vi|`h|#y<}`{0$D} zpm*gY7dx1Pq>PS#oZAk+;QyST_Tbka>1&ze@IHOEAJp#T6w2K2uWTAN@$2%NiA;IL zD#tuG5~|<5ErTl*PyFq9T*d?Ig4|jH3DYw7GWP13{!_NOINga`+a)J;vjP9IFR^d8 zJ~dth{_zz=)Oh~v>Xu$LNtEBzGIHL;`dk9s(+-*cdQO^iHNJ_oHSHU<2IPKQ{kHbW zIm*8l^vGOX&N;Db*(ub|@i)cW$G!B2pF{n{33A9>Fx~^lItN?Mn(@?rlZS2nGyY2u za^7R_(QaJ6zWHBv&v>S1YR%*i8)Mc(Iw*XiHULi>=YxfPmVS}`jrf;SBE>JurrQ=a z7=K}z0GAz`)846bLoBRkTh-!K@@MfwKxN}h###sfF?iNoAHEay4xzelY zY6%q7f0q+()OpJ!R>;92fNQB5X_IFH9@JP|;BS#g zx+MK+OVf(}JuJiWCF-&)HIv>YS*MXyYuP>J!7j<_f^~JDGY%J$;L0TNR>xY=&-ktzPb~yAb}X zxPdZC|5FxOVt4a&3xpa7cz3JcGJqv@+9PU>xEE8>uJtZ{vSf~j25OuAR_TBGorFzf zOZv_EqWMX`as~Ur-7~n*@@oAfu%V58tj(O`_1rjPu9HK!KPT4IBMc$&95OiEseUXhh|-@`eRSY% zA_@N@{hwSVT(_4|spAUh{}%iiDe}1LQ+9cjrJovzGnS;66se&jbG3@f)IS2aCj85( ziT{B-ByBUPWqgu08Gs{8R*I4mFUgKyZy9KZ$r7(m@y9@bw3ocgQp>SH`| z)c*#6>N$A4-XC@G@4qHenl|fusN)-f-ZI}fq#~$t9!~v3&1x&{P zSYR#JQ-cjhmp1Z*p`J9N{O!6mM61E?JFU@;)VSnBwNzjArMwQ;!Iw2#)*W!UT=i22xRcbg5Hec{zW zVUvw&!+P4RHpLCw_{Jc%#-@&bh?!0Ic%Kb-uWNYFptVesWlXLV_s)Nbb!$P!y<5zD zgeeYiXCJUt2Lf#`SLiLW|6rGhpK(Z?8Xp}-XiDQ=yq-MJ%gGU|T#C<;d~-PCdGSEt z+-D)_>h8Yhh&3=TDT_A>4Y5YPNl-Ztl2zG#KY7F$@mZ2xK36=m zRZd^EHEv~mLsS|~>Ic@@!?v5lV*^!txaWbgDiaS9>M6{phHv^mW>|%Pm8(4y4Q`K^ z*k!kLww_QQ`P!kgfdqaB1JRj*$)Y@cftE0!lTr=Z0`4rZu)F-K`v1824kk| zoG}0gD9zySk3UtoXa=Q(WKGwoGkYdce*M~2+7iQc zk}mOR<%HOLkJpr>u4Rdxy*0Lk2Y$RkI7phsTTWU@%!ff50P?NPZicKk8$b=ME~ETv z@H0mGFWRuka+qACC0j+A0XidYY^7H;34!71pM@n`@;tuHgXTK0EbvX9@=vQ>IlQ^4 z`8%L=1LU8SXUi4p{S(p)4;h{FB(?q|KMLH$-qB-B>)u8;emg(U*my6TZbIPzCe>; zqmFa^IWWHpFs=(=z|v10G%vmB|3EVi=wT;y?;H7T*f$;2aD#65wrf}cXFK#`0(iJp z%D51bklVSaMQzet+GQhx7j>XW4=blG<^ITkwq+{CKZfI&w0zluGxdiig0_fg8&-r%up;<3)lt&Bb?t-;U| z1Ofj(&9=vT+0q9${g*cS{+}-F!krGLm;v&onPkrRGW0#h)MFa>;U_Ib2uIPza4v#5 zSjEz0^W8!=6i2q5Is!Fp%#F%~(uP=NhiOPRhaKxwSqU7z3c0ZUrXOEqW?eVH<0JfX zQ+r>H7x5ebh%}&Xk-E#-pKlxex330%2N~GChB$Q5|rybHAm2~;%!4<^KKm3$wnGdG(SCYYg0qkGdE`t>J zlrjUj@hB0~lehfDQ@|6=93|5;pVaRge|47zyV~^e_}O74Ux~&K0H=?wmF;qj<$a(N zKigumruqH3$S`qY0Jy4KL;G-_>Tg38Qw!^*c4?*lMz{PU{1m>nCmVeXJ91sHSpixu z%2E0xdJ}4W)$FSqqW$jrr)@rUmV7{7=6W@9+Yy47YH1dRx0Ou7L0m%O$NhWqn*JX@wHS47gvG-^+(cXZ|rJ%w?N0|eerrxbJ+&}t=4Ekht|$H)A3K(iZ+ISl!G|2-5y2@+(7uj^qJz(V<%9-K~CZt zF$|+o2M^e3J;`71fAx2a?@qHG~e7Ljw@5x7>KEzJXAwTPH9>-kyDQ$E<>z}t9?YXY- zca9~SHm(N#Wk7ZK7tt-Xd;GchW4T?G|7Os}p?}IDeUxJu0Ei4N-V*<~@$V*6=BJkr zc5p_R*j}G=nBfT%v`?G3)?#`48WZ+NIhbQA{L!lH{fx2$`hyFAf3E+=gnp1hygBuM z>T7((FR`#W0F3Jf*j=xj3GU(qfS6~(mHO|*g7B-b)WaJzEsVzBRjMF|w?&-s&n!3P z;=NBZLB>)Y6tl_lzWG;QuuqsPY}EcX{J#RgH#;Y2O-g#Zf6qo=g8DU$X>YCu8P0M= zC{K^eVNhQ6E0YYIq(tCe?M(7AEr^rWDPHrjp_iKqUqRz{=#q2__QI0u8h8io;2^;7j))9H|eyUe8~Jy$Ix~ zd9r#P2I#c$Xh-oj0<-jYe{J+zByf-f19{L}rW*W;^+_1ev*g(#25!{2fv`<}h?ujT z$OHrw4qjOP;v}w%5eS^I>=W1I(FK41=2Z+EtZvL~K_y~t0;Eo#TLXU+h;DL%o^KE|pwefe=IH_3QLip4) z!0B^Q`1dK_2mo@yr(Mpgd+m>=&HH@$hMv!^+uo2VG`HO^FHhNWp{Zw%cY-g`T2A9! z?VI~r^IWUC4+n{Q)&t-8%OB*Eg2v<)NheozIk>#zhCJJ)Jam!CAzmH!JZ*?$4S5vj&p#fb8ZQ9<=$IzQT4u{KH~Ig0b~Cn&pzsem z)o&iOyAH-H#QDZa-EP#gQkRFQiffko?5B?gXaoM@W1p{PF61t z3ip6pjaRC#wUZ5wFHtu9sN6DV+tQVbld5y!@_x5_T)WAiZ_^oz^80XnDfK-7R?{uN z3b1&D^VHj8_2i0$gXV9--0-|TY^JUiTnoS`F2 z#4t%Ha0FaW<0_Y{Epuw3u5dl|Qy>)KAf8h^;yr=1Vm$*Xc>)Y%_a+ZNUE1Cr`lV2{ zNhT76$_Xrfk}1I88|~bvq+}uuoq*P6cxg;JX(o88S8!ExBnU_U)Co8-ehmO~)I#LH zY6p{T=$*3V+Q?W&LS6ta$!pXh<7cxzJeE>jx1GnVMgKyKv~tPj8yF{B;);Id8;F3! zu1sQcj!Jr)eSGIv_}Jgn9ftzA2Xs6l;0-3)voXn+iJMzA;_w6f+9`XB-=lo7UnquK zd=@_xXGng%@%Q_h0ORXTb#jes#!YZyHxsysDLHAs8~8;t6+bo(N}CR2AfLl7@G5+VZ_E5p0#i;g#H;j4{l_M_r`PA2nIRrAg&kgpKJ9Q|p}gcp z8^g>98^8F#Kzmug!zqnFP}Z>a%%$;n845(k?jG9rHe9mWz(km@tfXhj%2 zh$BtrTuO5TDnMAMJn>%3(E03ZNKL_t)ybB`YNwl7-pwxug2%aaZ z&oSwo?dk^-Q=1g3Y7Q=G_z{XwEyH=}o;-rEgiTeS_ArQRw({7(D9lUEZonl60146n zpxhsRu6#ysk|Xp&V3!!+Hp9Q>e`aIHKOMWdbLR^hjs16~Mwk{{B z>%Qh#ER!6ydCxlPzZK8fG4o92m z1Qy^H_(HZ%{V~+iBu^|qT|=C)Fok4ReD&12d}afSmgdQOu%zqX5U~E55R%ye*l|c< zmiGFz=OG2{kTF|1!@`Sd(;tV-X#W{==>Wh#BU&+*H{EPQG-m^U^~F;U&PCz9-f#HE z{%Vv+=UENxrsO6Gf7jv!$XbU539S2A)JoPvKUNP1!3tRO43wVg<-OYZ=^qiNcrZjB z?X2UNU*aqH(ynPksh=Q)c`ZWw#>(Z>yc>eKa0_y&E$qM~%n*43MSp9_1cK8@uS~!I z!0<=5%lT~im+B5;k$`3f4K&NwNri0-o6AP{r0bD)e9NznMKGcZZS-SN+g~ynA>zKr z3hpg7T1QLvhWT^Duf#-$#@w_?f39-~W42G8cGQM2?U3_!Z%Ua~yLZm;M3~?(0Zae# zK+@Gldb!JK&$ME2r^6k&*Rs5keNg6yB9B#elPNG(jr;ggPr^*U<^W4J46lK#Ot5$1 zU)ox0%ljOxC1}(?agFU#)&nc0@rADjCccHYp^k5}qi zm_{7+vU=-ZF}I5X$i*L5@Wi*)33;6k8Ow;}e0g}#uL+Gl6^`4r=3Rf2$s*B4XRb!^ z0(~gVYV|hm1|7Dt!U))-e8Y0K5L}Q`(?>r}tGp%0ga2Fa@*})S1{mulfy&Z`{UwAf ztUK|lFp-B#J9}*Zwb9BPvwxeRQxx7Wl%0@`IBfy8u?AaZ^(8}cZY|D!;CR`+$6^#V zrEQ!?xB67w;ekaL9y3^nTDJ!Jy0k!mT^#? zb;P8y(nbeaHq?+WW>GhAc5w^@rKj1~NyQ<;LFWMGYyKrb&Xqjl`%~#t<@!bTvT2@pr`_~sBEkN;>1V)AWnKL}0Q{}I*|Ihs0m7{*x_+HP+?s*s>Nn}1wl|O8 z%`TnPo0E}f(*I4jRzBb{lg0z%Kp-~rIx7bEKmET=KSuNx*=H8`nPt*)@jr@0+2V>i&EtYum#SEB3{Nx!Tz_Eh1suQi1rpd6V7<4E*s-?sbl%w3BQawE?hv5YrzpZ~C8ZZQ&j< zjOz=1D~FV5)OB+3_08GM$y6uIB`f5XSzKpA7(*Lh6R$km zzm_*^lm#bU;3wRqtRzDE4>omZG$-t@QRG}|`(J7jodBwtiKK(;ggn9$F2va8We z{egd`#VPouE;jkGh~iBw*&sutSwft3>>e_Gvk%ZY#D9M|t0XHcvZaQw5piFuS^ zWrcD1I9Kf|w-89a53Fq#MdCuvFz+WH$;-&gN0?z#FTyJH&q2TLb zz)RiV@-pHu5{Ux%Tf2GF@1Xy6|FAh1#;~h)%{}SGqW27D1T`<)K0cN5=}hbJKrrFa&WvR!_DxV}GOK z`p2>9m-SEeeT+s#(`8tAZV-qk_J$=?MqroD^dD~c&trpGw-!4QwU!2TECWH5-B=cJ z+M%yM(T?>@G{6QYK-TmJaN36UMWkv&J@MpF@Af~*<^*^b55!H$3le{24<%?uCCy+Bn33?FWzSAwC&@7=L379>h#Q zFduxl6}uO{m-VZ~&x&IV`Nho*A76lb$#(BNbMB>lk@yz;)d7r4IsP{s09+ydfDVOM zU1zL+Y%8bj60^9`CH}qfz_eL4d#$FvmCfAcgUJ-dDMS0OxTD`!ziU1x&h?ioZinNb zgIp4W4Pl_E?8S*+%O_|lTfDS+_c+JD70rbY{M*p~({2UD^Z=Bd)>&`!c>a7UF*wc_ z_($##Cbs+w_(uk{X&U~KUs&(?8|>T44mHYt{Y6FNovCB9qwSGy`j@T1IauV*1Ad^K zysLI;&GE|z;9q~)6H|XAm2R(Xde-1n#M(G_IR5z2UQb)(gZ{fx2jmOn@?=bM)fn%Vo9qK(HB(nD+n?s&@Fm%m)X3CNazaG|&R z>8-vK@TCnzwOAS2(lT-T}H zbbU#;Wrw!72UlF2WZ;U6_Tq)cE5viX*$#|*9;z@w<2a_<(tMxthP{S&VM{x{^?nJo z*DuJ-QfJ$@H~zlaI5!~n!Y4x9K#^M>N1g%!m}7nGFaR;vy<+Us?BLH__sM3^=hA-S zjqj98UsWFbGPH34%i&%UfEfUse|-RcB~~#J4JWH*%6{g&Z{@7B=aye7M;Wfe0fzdB zaWT{VF6mFjsr|weC~;PsEimQPgtLg>3zdL9R}F{8^Dw; zuLFp?>Nke|X8U7fZf)64^*h3y1QPDeaaPUI%L@pHpyFx1PVzQn*fzg!G8m>T2k4ep z=lrnoOCpzH`>7#G$m2JmjG&)G$2K3-RNC-XU~Q~F>SOEqW1}2|ZK6H}xJ4kmWO#1=>x37eR?Cm|(}JOX zz?Yh83)IUNOBN_|mY(z>A83Pn z=g-&){HfpGO}|z6SNcD{RR;gh`Nyv&Qh#7J-}>cm*MMb?e#;BC_ZjO}`z?3s1PYmw zCsx)&|NPy0j*JnwDrF{aWTL6DKC;-Rsp4~o@l{JCl+-mGJCvsvr@Wzlj}Oyj63dmO zdfC*bbZ++?Bl(kKtmOwC=bQ?E$qxIYujG1hfZd)enUZ~YU`b!e+URRo0FSYE)Os^P zr+?-6`Ssr5sOIURGcHQ_s)(L#V|~JK}R= z?3HEUoO8qf%=$PEd98mvSVnbc#=T=(_2W3e$ZCtR;aQMzRfN(r9Rt;Vw>C&6M1N(w ztv=<7iQ(!5l#a~9%ra@en(*mdlW!A1d|k?i?iS`qdnuQQik)TiD_`3+jt9bSNgAoK3iphSPfxG%A0sndZd^e z_O0oqgnY{FCR}XsW4Va0Qc0_m5UT_1!Q+5oVId)E?t9A&F>jC@m=b%}<0I*%rzU3wToDPc07fPcPG z5E;3BdtbcODbf+KD4Bu5vaXW5^EtXH=mKoRvMDkd^C@$IXnV`pbWAK-Z}+raOl9Y9 z(F_LU^J&9~=&`~w3Pqh;xr!1r@BS|m+STVgflWJNX<6pY2Df&PoDx;7zXHh)ks3aY z8-26Y_wers+#49AAGJO^*U9|K*WiYKQw;I)ciJkm`04hGXbaWw@TogE#GU&69%VG& z@f(_J!oPl>Hm0qnvXeh1OJ)6JfGoOu?5uPj8v)R`r^j6Co^51{80*fm(`F{kV&7gr z^_|;%|GxJlkNEihV#4u-ypvcZozy>@*xP(ebG+qe{nWShKY{2{KV4tc_gFJLina;> zQac=c{H*XVf&ec6Z}uam8g_P^?M`{%-%vj}@?jf4R6U-%t=RQa*3+k>UFVlK{iiX9 zd$+|@HvB$=SiZ*R!-ntpmt*jG59N%Bj48vS@-KPM#6t%seI5Q6j_A3T=VwsPd9>pH zGZAw*X-A$38&H06&;Q1Jt=(5{2IXcu%{=jje@2wKRymVM5qkQIg`%Ib6J{nwwdpLE zox=NQ-?f?{bG%x<=6L8%<@u=;lb-&zzGIbP#js!Q<1-)3K6?*e08qg1l>N2W4H!uv z9vGkb=@jQ_xxbBdBf&sOtazeQC&)_xqEj24!pMs>lS~7dkK`SI8LZzW?ef;)PICe$ zdoIY+1}Ttl>Ob~Vy#i%RkBzcJ{SAkFz%HdU{V_>K-CBI?T{P&^gYp~7wfHC^%sJOFz^GH{ z|J_G7=u%Q(GWBIf;RPW9-3x zpYlpH!{_j;BDuzQn+k|E&$d{|-&H*P?74ByGUJ<$vc$B^&qDEEWEiqV2?u`QdX!F_ zaVfu`Pi7tXslEW&dUMVEEZXyq;H-2 zcziGl|1!!xYYfyzsAgS+yW@u9HB+^7+|D+NYx?m%|2mlKem4U!A9G<)_vv77{8zs1 zk80ERqHNa>j+I|*PGxng$@r55m63W`4Kig|-?(;vZUl9EW)hO~A4->zs&nLqK0$(p z;~d#H=jsOzk>j*YNw!-mkd}-BItwR8eGCLZqH05Ofrb#6;3V=8Vah5=1_C#dM3P^ncyM2xt|n+pMuIhy zl)Oxrg{57tKM7-c3t7Y)YFE1Tpn*DHRwy3DhTQ zn1RGn>z_o`5^63E`}^;L{tsa=rV}KyHJn?UShtC-SD+l2%@|=jE)bTk>NZ>KZI7`Y2h{q@Sm|?R$K{D!f9sO=SzN?G z2h3+>Q+dO>j78em_@i*m4>zT5AeHlj8wX-Dbb-bO~0jgS3_DY;PE73KZV zzCwi@2mA`U(WXlsysx@t?&tWbvcA9aw-cI=T6+j!9moI(YR?r2j4MuCg|8oM#;__( zoBZ9vFW1;KfS&vc4SvCxB&R?=ZKfUnW6O>?W?z0KdtP2e&DU%`vx-}4wRSA$e6?`_ zz6JE=CVPB&!}tj4pLNCA3tW=@mh;7lDT$HZC&w;b(1vTZpE`PEHz(-7F{A901)UH2 zK&Ahp{>gJif6^81e{d!Fn=zF>S_0O@B|}cy@lQKA3C)eA`Trvz*Nizvw^G*k17qei zb+DXj!RE}Bs&dOdy_|Nha*DR#yX;5Vw@jiJkKi8S;lZg-f38J1{^dqLJ+L22Gb)*l zNJQnxSlDzfFKhgd4T1av1~oq}oCkKK4z`*4z`6Zo*+~>npVGgNA>yL(3?QgiiHT3oFt?1!g*f{k*R7so zK-;>s}w&gkuRP{@9tfet*AG#ak4C;Th{wTYd?9{5bQ%c>1#n` zW7s}nu~*~AI7RQ)#mo7xA)>h>y3j8kv(_p)Jf)AD|h=P3j?#a9k>IGu;k z;JmcQQSXMGJg=8UoXa>ZSD6bBoqwc9{j~e1H3ysmJ!f4*8T{M%H>a}cK8rM0<>!)OBh%@yRiU z2Y?LF^s7xQ1?~+n#L+CSA!zwp*4G$Zj)5;~ijjllk#`7vLp1n*A^TkaD*%6w%P`lCQO{BOJ=iy95)>Y`c?8#JwQa;#->VV{`=62G#y+khve^#7EaVRSB)@Sw+6|Foe@?+G+i1C&tC9F(}J{w+G_n-9(gV)&02}m|U>V_6IKSS(f}lj95tg zTaRx${;lZZ0Uaw z;almyJxQDK4}S1otXBtz-q4QuNDbqrT*#(qG|MI+XU0-R1MU##IbMf8ZAe2suF3 z!Y(jV*gPBk_=VFZ{8e1lTl)u}%5E(mBmU{&L3Z-}Z}$$b|KIBW4WLdm!WQ*MMX-|* z7<3)=5fM#>XVTu^-|BR7(FH81#|Ub6v)Wv8<^*@?Gw;qSv9UuSZGH zZyIaamjDq{N9eEqJncO@$L0Af^V4P4E~}T#ef*EUs{)0D^!ir6Z0BDB9AaJ;+O)X4 z*Oz4~_7mN1pSv|hG}Bw1-R|tvxcUquzdGh_cA+xOc?0zUrbw+jfrM3`!#A6c%LF^W zF9lE7=ZNk4SDVQ$e;oi8fml4%kUD)%Qc>SWhu$9mP>D(D=&_-`pIXM@biKZT`D?DX zIEy&k?sQXsn}36##CUf_U}j|5iSWXaRZHIaVHWT!0O$tb#rXKDzPsMcGZ7bV8C>}M zW%4rVs`1&E;!I^0NxocMR^pBQgnjRF5wo1^%o@k_?YZTaoZ=A&#Mhh=VyHs&iZ zxkX%usong=*ez!~cxA}{EZ_PRw*2ggnI4E*9#9%b+BcfY57Owh}Cql3KWYmFnu{jYFcWj|tou<=d*1v}^bd!O$o{uWn1 znRxqDf5xaU$e~Ej73~KI)?bY(lBUhN4hKHZ=xEQUk-H0Zw>dZce%<>|m_7m76z&n= zqiloZwQtsI%<4F>lG&+EZUgzm4tgyFp^Ou0-^@3lA1v4p61X8JIaE?*MA0_+|6grq zw=F@6Dq%hE|IVI^68Iv(s@**^*{gSDn)pKq0cuW{*Faox!vm2A3QYaZ%J-k&77TkoBS60c_nM3 zJpD@)v5x%yaV6hfIr5O+ueP9%PCTiaKh5{|Q@jY?HT}IkiA~}FYdJ3M>imZsL6R>$ zF>u^CJ~7v)yT(90K5XFmTA!8rQyiW=b0XQH*yC{O?1`8%$-D_1`d@}*EV1`~8p%zA zZwXpFXKecBh{M&mcZwsl`A0iF9z^^^PB%nc*$BQi+xX86hdE;EfOs^($vfN zQfnt*Ev~Ts5Qn^drzRQF&}){5pS1?fuO0~?>k8H>bNc=AHhq+a%a!Xnt!@ODZkqp2 z{QqfN`Bco8alwYoNocV~@({vh`JYeb!|y^r8OHFQdD;|fXeq>Vt%?U5n7D2)a`1zn zmd7~{i2-}O>A&Q^HWa?M+{AfTsy+CxX*jv8U6%gY8m|=FpJM|{&?`+CX$Ukp3deF*;Qa2mj``_J?JwLYdV1NL&ocFJnIA0s;F zemZeVCP0n*3KD0jnN?D>ImozSa^~67wohV^TtF*Uh*7takN!oVm5kJQ_JRtx+DQyD z{(k;l3wVJ0Q~h#$_9>0u4om&r-)9xz6XX|4tviQC2p?Li!Bw&npNO51NZI%VODHdJ zxN{kDK{jD?lKY7oJ4uo)uN6)UN9DVvS2;Wc$T?I0WJF|O*e z0!(V`?tE);x)@;hsy0LQG^(#88BK^8<)R+=t2kz5IZXGXe-c3P-P)C$Wk9~V zDErme%D&a(j^WDppEl0;?DttFt4+vsss!+LAN32ivghEtnCyKGfM2;B*q>Ox`RNXO zyWHXN!PguStEeB7jvLu5#`eX3a9}5osGq>C;J>i!ok4D}Ta%j>+tchu@!!nbjwB5$7WRg-_~xZ3F%vy@LN}i=eua zp&j}3yZXdZRgpRshjp~CQrz+C001BWNkl3Hv30#5Ncr@TCjy}_=SmQ%q#R_VZSdvJKqyi-DKav9TAH`yY7R`WzHRlb_UTQ)r6Zq$i*| ziCN@V*(=K9zGW=eK>AKej5@BE=q>W!FOso8i%Y9fWKUa+Uq%4qUwC$$DQ9^k)lXQ+ z0rh9Q`ZWjsLRcSO>oTFl9Y~U*;nhEfIw*&n;S_)6Gb$3vas8E0+oHpaSBYA29U*x@ z6!NuGD4oc{j9er03^~81&&VQ#P4{9uAJFb}Z4B*{{;3n9*`hHWeQpM+Bm;)nOD$eBT*i13uLmD=Im}bpKRL1+K)F+&XY!xB-jSGs z=bWeA=D%a=CqDZAYxxw9exNd4id27BIf}*4EGw>?WK@60RO7-JCn7d676mAuPFlPf zuSaZpVuP47d;d&VE>pHscr7aryZ>(f5ZHiXa&+m~?g#$ciH{CW^=D4@%GY>^Gt9s7 zoyUdFhqIi>nwsMTeRqHYnPspw+H}ZzOp)~qWM=(8jeGeNM^;L!h1Kvh&r6xJmL^7z zAS?c4vFIw}smofutjqj`|FVl5qPuy?>DXVf=9d99s^o;V>!}>_k%|?bW4-vV^i^CY zoWeI*FWRj9_gcso`um^#M*z8-<7GL}H3hq5H^*K(+12`XH!8hQx^Q+WLZ9^Oh5OLi<@xvIP}ka9 z>m5qsGzPy}mcb=QuA~#V9g=%AQ2Lhs8cYPsT&10#;w7zrZ6oD0i@(*{&Q{*`p{iyng9E4?VaPv z9`_2MF#QpY0SxUv-Y2Hm;RpMtEl0vc@=JdX|HM2XA$55INAq8h<3$=)`8MfrySkc1!skNMJF|2QqW+B<0 z9EP7iun;gE%&qv9Pa*@UlynrA;nKE zc5B(4vt2VQ|)0UJq@Ct;MG-tDbr-X5R#)-us2<|gpM_;h}Aa+YHG!vJ#5 z>jaG>s2l+73I2=v!GJaQMcNU6GARum?z4W;un0fs&wONK?f{n_R@miG+&Zpv!ifHK zc-$t8*ii4*s&P=6HqFAQq-?pNj}Nf#oZ=8sd;gBe9aS2~a7@`j zrqKfdPw>fIYiOhDE0wN8r*p8@E}(aB{=xS$OzQhVU;8 zAcEU{&{MO8dhYd|0s4ux#R|^2D3Y&Vu~iWgY(?3w}xeTKxBz@g@3MSeoJu?wLdu0EWw3mi&_U9xJYLJmjIDwdX(Z-z2-%{Q}_M zUh4FJloLl1sT4?gB&n2ZL1zV!JAv6des>1NXuF!%otmx$`#-Qg5NQ~JprFQc@Gkxy zk+%~X$PDU(^ozEy`l-vlbwe%2gw*03aX5}JIx6)8E0P1F((P<*SKo+ir${;n0$Vy{ zHlqNXQ{@s2_68Q8e&SRi0%ZK27}q%&WOnN*#f zc8oy+M2>&webk);%q=nh-Eo6)$H`V=b&V;5;7yhne68x`psN#J(uUTe2njVuoJ1ev z35<$Y$|lNW$&G^ZFn40cr;yL`gMhjVb-rKxrJSzLtYfun9}|O4dTq8FT(*nHJAgf zb)qhhGnQ~-l*?oM%&R1)H_AvF`F2H} z!1_cisJ_yNFL)WtU`UnQmD~b`U2W(vdZca!c)ZR0SFDo!M_&j`%>eS+u(kRUZK0mOImlKf5fI`++p!Q+dn8_<$rtm3Ci^DSJ^*(AJ=pD*WUFn2gnRr zV+m{IVkJbUet7E8+d?`~<6l1g_WsbO`Z$jdv8i)=-8F1c#dY$R%<#UdKMlYKwoK-d zXk+QR|9*`w9(*Kmc1ampTAW~6quBfY zF6MX8ALE?$*Y@jly4w1d%dB-ux7$qr5+M4YPiRD+jW)ti?dm7srtaW?ZvU_AT(Er8 zNWX^Yb;??C5=-7^x#)n6mAE&5N&~)Ng}i@(0D!uIOz+&d2YvW#78+>H;U9%yP|=`a>3;=Uzr2rNt5 zc{ml?qB>ubw8<2ne5y&;{P~<(kO#-<-CFQ7>biPRYF`Z)8*jEXBzPkLhS$r#&Uz8( z5UYluu~FW!ou_4buT!R8+XJz0?|r!%*{j1%`tJ)sB~wdCR~9Tm4v+d`N0#Jy;`b}4 z%*lmf^ruVWsV_!qPI<48{%!O5Tb!BN2{~wf_5Wi3SM#=@`4cIG801F)mbB6NAKW^a zo8&x#z4GWte}m~3CMtJERWHN0_~Ks3cizZ9;J;!188g^lU-%xyAmeWL5ywq=7NPNh zmt6lMhPI=^u*Mx9;a zjtkyEMi;>|rqT4^5&^bgIP1TcOx=_K^4*n}MTFxtwR4KWN`Gx@Uf_1)ec{zE_>Sgp zm==sDDcXT|Y#Yu)od;DnUlE8Z<_bNVZ@M3dPgsXj4mNEvm;NAjZ@a~hp8=rB39Pp* z%{e}8W&S;2`ZHdn_R|J(r#hWO@VxbJoBpw-v zVG=0%&+4=NF@OwhOqhuMQ-2F0Y`f+AxRNXf3BH>a_1t-Og6ctK^)d#P2>_>-A<@YA z!Z zw0+sgUYvzcI#C<$p(bL8laHF+a6&Q4O>OtG9*euFbMjK>uz$QSJZw{dMl)Vc!uqae z?{hbA65s^WVz6SnP^L+l`%qihF2#TG{f7M3y5Eww=QW$jpRQ@+`L&tCxm>He>05uq z9kB<$HyppM99ZVOIlXrye(AvHE8n?tzRdhnuW$OxyR3swrU5c(;n-eI*>I{h`WI7} ze=E~mjDxGTczw&y!XNTJW6NWMz<~xP?a;z$arZh*S?KOliX$83z{-?uYlW%KEB{L_ z44fJk9w!vvBk!Pa&ifQ=Seyf-11#r1$A+@q|1DNY?lQaXEB#Z*jpfCy5q}cP{cPhe z0Z?-&08HZFzi^sX@#1GOX&dZn*D?Em{I!~>Ua7k%T)`>tcvo8HlHg16-$cjExTP{hd0106wVZ}Tbx~h$bZ_U z@*`zaFSf`2CVr8)W5&n@3%S_tRlf*HvjILW?#dTsJn}QEzfitlThFPw1z|t5MK%B8 z`!1_-sW-47j0ViVLhsrX)=g!WFP1eddyLTepG;hHJ$;Aawl=X4WxW;vt;2y{B#&a* zogm#TUyYVsb|s!=x@rr9{XcxMFIXQwmCC7}ebeAPa8h4O{fx)NKOGB;CocyON*m&i zTW98GMhBd?mxn*-uMfxv45fUXF~{MO{pdBdhggvM4}O<=Vw2(cT&&gN%W$>ztiM0s z-u{DYTJ(QgtoQoKbDD5x@=;czO4SrsU(15CZz_23U)r8C&uBs_=O@!qi^TXBb8J|w z>%`}X1N7nbAYHFae;FllvWnI|qO)%hxS%jbYI2Zqujp8Dl_&%E3NjYtiFLiIoP9*wWP#Bg`@(y{uU zaCG|R&`^i^v(5we$^0?O(h|o zT~qQlCvvu7^y_kpfJ(yQcfjQMu{q3x@(o;6{aO*l`i#06QVn+V_duLU>6lpaSfTY>+(ZolV$W`K$(@mg^#xi0z@368si zxpMml|NSyX$6)s0>~hEnoRVL0MpTph7yL3Mfax!5jw@(S2GJ4#6b?RE<5vEw>9hG0 zoHR~&mZAFpIB=L~lz#FG?yre+nd$iy3eEmS|Kh*U&NmS%5B8zm`luKFAsYb_0Cc>y z8DxY1IKWzhYcl{eX3qzb!v6*8*ZIdn_7!K-z6ST4VoJX(zK9m3qi&e|^0@3L@G5?PLN=${vdZ zuQ%6G!u`f4?nLBS?%KG)lbRU zHHg$$vb|)Pz=RtCHvJniKL1R5atx#S{0vSUW$jvOQ1BD2j!QK;Q=sn}BY=JjrbBtx zpK2l^6kyfhbM>GkdRE~sygCJ(=zQ5TRHZyG2_`vyMbOJ7D6PkoPLAl;%gKj#o~TV5 zA%*Ey6`DOUsB(cnCrRJc0-PWXQ}`RN(84VxEnPVala-nQtU zB^Gal=hmZ~FrbQXD|B}05Cqm#uH=u%mB2*{<6+CJ)uAEr`QlWjS1lDlRC+-0Tb8r zFZzdZhoZ=TOaE%oPpOGf`kLh?0 z*lc-U7?a6d)uTL?n3yu2|J`yPDzrKd=FyU=z2wi~mAh*&v@g)5XAEiTvdVO_+E;kZ z-&oF`SNF-hj)~6m=#r=D_`T<>YVt{oKX-PdiH{QHBUc^3xy;KKr4uG0uPb1S#%? z-`(u2Fe~152TIj%JB$@jFMkt-eR14ZFLszJ2ch5l6TrZG*RBpzvIZqHy-EJvsq{R% zp7;IdoKqxmqNj7q1CMj@`uxLjB~!(aMc*TMF?q{-!+KDmF4n~y?6onRUBA1Y6OX+` zPBtNc^}(Byc&+@q>^}NR`%k-qKg6GO$qxW0jPL{CH2shd(E@yvj#EP#bm`pVvoaet~k@n2Ka{+yp*kmn!3xhBKi6{6s^wVd(!t^)?ee?fh zf0Z|t@x{2>X)fMHJ1Fesf?=><#H!dt3I@cS$KAkxpK_c1qxTVO!qzH&F0^y`6aK{9 zhqg=2vaR>@cfa}KV$*$UXo*7q5K_nyme}$!NzF{%V}HOvu+tB z2OX9BxJ>IzsIQES=#c4{UsSB|7;~H;dz5t-l{gVCMEA|^QG5CHO9fpm5F9VRs_xh%=Cm%Z} zvqfH6>G@=ycirCWDzl`{y&hv1n`Rsgxpa;mOX0ZMtL`>?Kal0n@;!)9$fDl>`A9sbf)PH>$5N*i`w{-b9K!^0WL~8_Gk2 zb9!4Z8QiCigZ^odyZVhJ^|cY(#{R@rlQQ)!$L4in@xluyp+AE}R!aHObq$lg#&*jb ziy!ps2tA1jf6EbT99{<8f zC%ON~PeUQD0>99~9AmysnGRZd9eyX->FW49|FwMVTW+@zSDOCHfAST;S~8WlNsMPT ztg4;`8e>ek+3e&Hq{ylpXox&@=whFS!4n@7*rYzW{Co z;K}dt`ka5pm+*bsWHc;o#o`Y9e#<}578VtMDj|`L>J>4kl;Wvt0_y9bU!JK=x5rFCTgxp#kgv`#%V!hr2(W~$9^eG>|>q|%JwSmuQ?|k z2Qjebm)*oyXJsE{b2YyetD~PHNPaPp?QYoqF#R54Jch)5TNEvnAIhaVo}eI3A2kW6B+C<}|k>jt1=(4mf<)_uDY1akWOHQ2r z1n7`EVbI(;4{TO52FIn!szb^12|-4v-5M7hndGKte!~YN3Hc1IB>mbIU0I^<*N9iO z#M1>SGc~20!+cJ^F0g#}Ie!JPFTd zzBn#XY^GYWpBwQhS*Wm$iY@H_`_~aW+h^My6DsHJxy4TL;#>J-S|$6@N#$Oq_)V5^ z_VWxYddRKBWXXM#E5|WBy1{5%SSC9#VCp)Y)`eJuj z!jDT4IoVg!|ANI$KE{+OK4>0hC;HHTJ>;)Id!CF@G5Zp4yo~{iZdty-Qr;W+FS@rj z&HP*ZpX8!Ft02k#D6_1gQUK*%SYLqXz!4R%e=|dv_-~`6?4|SHr)U5Aq~q=G%Txb7 z$q<9dC1&;i6kF!>=M4DN`xpO7+f#cNpVG758T$hlS8_oT9Xkf4FFu|=rLg8Gv%`(PwMIG-;?$IeLU+sY!VDIqy9hM z`}ODl27vD@qad}-HumQ}j5v)t+vD?2n}YgRW@lOCAoZ^5tc%Uw)&8f^aMYEJm-^Sx zPsyrd49>Ar8w*Bxo&Zc@FF3{=1b?zdwzWh04Eo=bzLM7-&m7Lm)SEWitSFxG(={Zlr!yX-L)kln2kQx)3Ae+6G^W7jNn2dilT0rC+cg zaKjE>4h%2<2@l=-%+(l?lFy*0@!QHod5JNWj%K`?0A@Mm>0Q~cz*@0|&%9drk5ILA zj&mrG2?WWtA(!SKHk0O$yfXBOMu|--m4hkGE+!JsQ~!t)#(y7!G`fzjV{HBtU&ZS) zl#-<%T@bEZ#i1J4wLSXIDH$2q6mCcv@K=$O{C6&|JmVCUkvAZQBp+noH}AUg?D`zV zAaXpgn_zj`ATghrR|NrRz73#TP=Hk$K+Sd3$sFKtL$VIkLO!@p4zSkJwdq3aK>q88<}G$F zwdeAgCG{x(>-tCk5kQV-)1Yq0#ZO0mDNDI(xPy>Jw*rha*R{kg2x|ers-hFdv57Ir z3mS|+pE6kQW0h%E@F{L$mmFshN(7R4FTN>+9`WEb2c>JoZ6z$!mXS#H8UqU-qutOf z@2klQ3y=feL_QYwS>ulR;2o>)+h6GQA(7C(1C}!VUvbKS;9_&FyZbTSk@)V!qhJerK4562StnXupp`#mpxn{t z$~ntq@6O|1(N{HXu`I_)--sbjG??#KHL$BG!6W*Y>84*tAD;ZmfMKL-RZz%G#8jG5}l zDN^|Qv@l~scdYFM=nnp4KN0A4bD5jGY+K_CW8JA|?Fh<(b6K8o>}u+Kl4UWT1^r{p z+s0N`m|sgyZ@I_UYG0pA{~MlH6)4f@Nd>V4|J?fr!5%K0L%$>|~7Gq}l5ks?-P zq7R};|GLAF`G?PlZG!)JQb<)N^Zb+_PJh1)maHy`-){FcBUrGVDhpZ0AacW5Ys4I}H#kDOlDL6vMv$Iayyg)mN Bool { + selectedSlots.contains(option) + } + + func toggleSlot(_ option: LiveActivitySlotOption) { + if let idx = selectedSlots.firstIndex(of: option) { + selectedSlots.remove(at: idx) + } else { + selectedSlots.append(option) + } + LAAppGroupSettings.setWatchSelectedSlots(selectedSlots) + } +} + +// MARK: - Page 1: Glucose + +struct GlucoseView: View { + @ObservedObject var model: WatchViewModel + + var body: some View { + if let s = model.snapshot, s.age < 900 { + VStack(alignment: .leading, spacing: 6) { + // Large BG + trend arrow, single line, auto-scaled to fit 7 chars + Text("\(WatchFormat.glucose(s)) \(WatchFormat.trendArrow(s))") + .font(.system(size: 56, weight: .bold, design: .rounded)) + .minimumScaleFactor(0.4) + .lineLimit(1) + .foregroundColor(ComplicationEntryBuilder.thresholdColor(for: s).swiftUIColor) + .frame(maxWidth: .infinity, alignment: .leading) + + VStack(alignment: .leading, spacing: 3) { + Text("Delta: \(WatchFormat.delta(s)) \(s.unit.displayName)") + .font(.system(size: 14)) + .foregroundColor(.white) + + if s.projected != nil { + Text("Projected: \(WatchFormat.projected(s)) \(s.unit.displayName)") + .font(.system(size: 14)) + .foregroundColor(.white) + } + + Text("Last update: \(WatchFormat.updateTime(s))") + .font(.system(size: 14)) + .foregroundColor(.white) + + if s.isNotLooping { + Text("⚠ Loop inactive") + .font(.system(size: 12)) + .foregroundColor(.yellow) + } + } + } + .padding(.horizontal, 4) + } else { + VStack(spacing: 4) { + Text("--") + .font(.system(size: 44, weight: .semibold, design: .rounded)) + .foregroundColor(.secondary) + Text(model.snapshot == nil ? "No data" : "Stale") + .font(.caption) + .foregroundColor(.secondary) + } + } + } +} + +// MARK: - Data grid page (2×2, up to 4 slots) + +struct DataGridPage: View { + let slots: [LiveActivitySlotOption] + let snapshot: GlucoseSnapshot? + + var body: some View { + LazyVGrid( + columns: [GridItem(.flexible()), GridItem(.flexible())], + spacing: 8 + ) { + ForEach(0..<4, id: \.self) { i in + if i < slots.count { + let option = slots[i] + MetricCell( + label: option.gridLabel, + value: snapshot.map { WatchFormat.slotValue(option: option, snapshot: $0) } ?? "—" + ) + } else { + Color.clear.frame(height: 52) + } + } + } + .padding(.horizontal, 4) + } +} + +// MARK: - Metric cell + +struct MetricCell: View { + let label: String + let value: String + + var body: some View { + VStack(spacing: 2) { + Text(label) + .font(.system(size: 10)) + .foregroundColor(.secondary) + .lineLimit(1) + Text(value) + .font(.system(size: 15, weight: .medium, design: .rounded)) + .lineLimit(1) + .minimumScaleFactor(0.6) + } + .frame(maxWidth: .infinity) + .padding(6) + .background(Color.secondary.opacity(0.15)) + .cornerRadius(8) + } +} + +// MARK: - Last tab: slot selection checklist + +struct SlotSelectionView: View { + @ObservedObject var model: WatchViewModel + + var body: some View { + List { + ForEach(LiveActivitySlotOption.allCases.filter { $0 != .none && $0 != .delta && $0 != .projectedBG }, id: \.self) { option in + Button(action: { model.toggleSlot(option) }) { + HStack { + Text(option.displayName) + .foregroundColor(.primary) + Spacer() + Image( + systemName: model.isSelected(option) + ? "checkmark.circle.fill" + : "circle" + ) + .foregroundColor(model.isSelected(option) ? .green : .secondary) + } + } + .buttonStyle(.plain) + } + } + .navigationTitle("Data") + } +} + +// MARK: - UIColor → SwiftUI Color bridge + +private extension UIColor { + var swiftUIColor: Color { Color(self) } +} diff --git a/LoopFollowWatch Watch App/LoopFollowWatch Watch App.entitlements b/LoopFollowWatch Watch App/LoopFollowWatch Watch App.entitlements new file mode 100644 index 000000000..5b963cc90 --- /dev/null +++ b/LoopFollowWatch Watch App/LoopFollowWatch Watch App.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.com.$(unique_id).LoopFollow$(app_suffix) + + + diff --git a/LoopFollowWatch Watch App/LoopFollowWatchApp.swift b/LoopFollowWatch Watch App/LoopFollowWatchApp.swift new file mode 100644 index 000000000..3a1dc5781 --- /dev/null +++ b/LoopFollowWatch Watch App/LoopFollowWatchApp.swift @@ -0,0 +1,105 @@ +// LoopFollowWatchApp.swift +// Philippe Achkar +// 2026-03-10 + +import SwiftUI +import WatchConnectivity +import WatchKit +import OSLog + +private let logger = Logger( + subsystem: Bundle.main.bundleIdentifier ?? "com.loopfollow.watch", + category: "Watch" +) + +@main +struct LoopFollowWatch_Watch_AppApp: App { + + @WKApplicationDelegateAdaptor(WatchAppDelegate.self) var delegate + + init() { + WatchSessionReceiver.shared.activate() + } + + var body: some Scene { + WindowGroup { + ContentView() + } + } +} + +// MARK: - App delegate for background tasks + +final class WatchAppDelegate: NSObject, WKApplicationDelegate { + + func applicationDidFinishLaunching() { + WatchAppDelegate.scheduleNextRefresh() + } + + func handle(_ backgroundTasks: Set) { + for task in backgroundTasks { + switch task { + case let refreshTask as WKApplicationRefreshBackgroundTask: + handleRefresh(refreshTask) + + case let connectivityTask as WKWatchConnectivityRefreshBackgroundTask: + // Hold the task open — WatchConnectivity will deliver the pending + // transferUserInfo to session(_:didReceiveUserInfo:) while the app + // is awake. WatchSessionReceiver completes it after saving the snapshot. + WatchSessionReceiver.shared.pendingConnectivityTask = connectivityTask + + default: + task.setTaskCompletedWithSnapshot(false) + } + } + } + + private func handleRefresh(_ task: WKApplicationRefreshBackgroundTask) { + // receivedApplicationContext always holds the last value the iPhone sent — + // no active Bluetooth or WKWatchConnectivityRefreshBackgroundTask needed. + // If it's newer than what's in the file store, persist it and reload complications. + let contextSnapshot = Self.decodeContextSnapshot() + let storeSnapshot = GlucoseSnapshotStore.shared.load() + + if let ctx = contextSnapshot, + ctx.updatedAt > (storeSnapshot?.updatedAt ?? .distantPast) { + GlucoseSnapshotStore.shared.save(ctx) { + WatchSessionReceiver.shared.triggerComplicationReload() + WatchAppDelegate.scheduleNextRefresh() + task.setTaskCompletedWithSnapshot(false) + } + } else { + if storeSnapshot != nil { + WatchSessionReceiver.shared.triggerComplicationReload() + } + WatchAppDelegate.scheduleNextRefresh() + task.setTaskCompletedWithSnapshot(false) + } + } + + static func decodeContextSnapshot() -> GlucoseSnapshot? { + guard let data = WCSession.default.receivedApplicationContext["snapshot"] as? Data else { + return nil + } + + do { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + return try decoder.decode(GlucoseSnapshot.self, from: data) + } catch { + logger.error("WatchAppDelegate: failed to decode applicationContext snapshot — \(error.localizedDescription, privacy: .public)") + return nil + } + } + + static func scheduleNextRefresh() { + WKApplication.shared().scheduleBackgroundRefresh( + withPreferredDate: Date(timeIntervalSinceNow: 5 * 60), + userInfo: nil + ) { _ in } + } + + private func scheduleNextRefresh() { + WatchAppDelegate.scheduleNextRefresh() + } +} \ No newline at end of file diff --git a/README.md b/README.md index d0cd2d020..1afce9fa8 100644 --- a/README.md +++ b/README.md @@ -92,6 +92,39 @@ Always rename your branch (that is aligned with `dev`) to a name suitable for yo After a PR is merged to `dev`, there is an automatic bump up the version number - please do not modify the version in your branch. +### Pull Request Guidelines + +Each pull request should focus on a **single concern** — one bug fix, one feature, or one improvement. Avoid combining unrelated changes in the same PR. + +Focused PRs are easier to review, simpler to test, and safer to revert if needed. If your work touches multiple areas, consider splitting it into separate PRs that can be reviewed and merged independently. + +### Commit Guidelines + +Write commit messages that complete the sentence: **"If applied, this commit will..."** + +For example: +* "Add alarm snooze functionality" ✓ +* "Fix crash when loading empty profile" ✓ +* "Update documentation for build process" ✓ +* "Remove deprecated API calls" ✓ + +**Commit message structure:** + +``` + + + +``` + +**Best practices:** +* Use imperative mood in the subject line (Add, Fix, Update, Remove, Refactor) +* Keep the subject line concise (50 characters or less) +* Capitalize the first letter of the subject +* Do not end the subject line with a period +* Separate subject from body with a blank line +* Use the body to explain *what* and *why*, not *how* +* Reference related issues when applicable (e.g., "Fixes #123") + ### Version Updates Only the maintainers for LoopFollow will update version numbers. diff --git a/Tests/AlarmConditions/FutureCarbsConditionTests.swift b/Tests/AlarmConditions/FutureCarbsConditionTests.swift new file mode 100644 index 000000000..878f78ab2 --- /dev/null +++ b/Tests/AlarmConditions/FutureCarbsConditionTests.swift @@ -0,0 +1,300 @@ +// LoopFollow +// FutureCarbsConditionTests.swift + +import Foundation +@testable import LoopFollow +import Testing + +@Suite(.serialized) +struct FutureCarbsConditionTests { + let cond = FutureCarbsCondition() + + private func resetPending() { + Storage.shared.pendingFutureCarbs.value = [] + } + + private func carb(minutesFromNow offset: Double, grams: Double = 20, relativeTo now: Date = .init()) -> CarbSample { + CarbSample(grams: grams, date: now.addingTimeInterval(offset * 60)) + } + + // MARK: - 1. Tracking — future carb within lookahead gets tracked + + @Test("#tracking — future carb within lookahead gets tracked") + func futureWithinLookaheadTracked() { + resetPending() + let now = Date() + let alarm = Alarm.futureCarbs(threshold: 45, delta: 5) + let data = AlarmData.withCarbs([carb(minutesFromNow: 10, grams: 20, relativeTo: now)]) + + let result = cond.evaluate(alarm: alarm, data: data, now: now) + + #expect(!result) + #expect(Storage.shared.pendingFutureCarbs.value.count == 1) + } + + // MARK: - 2. Firing — pending carb whose time arrives fires + + @Test("#firing — pending carb whose time arrives fires") + func pendingCarbFires() { + resetPending() + let now = Date() + let pastDate = now.addingTimeInterval(-60) // 1 min ago + + Storage.shared.pendingFutureCarbs.value = [ + PendingFutureCarb(carbDate: pastDate.timeIntervalSince1970, grams: 20, observedAt: now.addingTimeInterval(-600).timeIntervalSince1970), + ] + + let alarm = Alarm.futureCarbs() + let data = AlarmData.withCarbs([CarbSample(grams: 20, date: pastDate)]) + + let result = cond.evaluate(alarm: alarm, data: data, now: now) + + #expect(result) + #expect(Storage.shared.pendingFutureCarbs.value.isEmpty) + } + + // MARK: - 3. Deleted carb — no fire, removed from pending + + @Test("#deleted carb — no fire, removed from pending") + func deletedCarbNoFire() { + resetPending() + let now = Date() + let pastDate = now.addingTimeInterval(-60) + + Storage.shared.pendingFutureCarbs.value = [ + PendingFutureCarb(carbDate: pastDate.timeIntervalSince1970, grams: 20, observedAt: now.addingTimeInterval(-600).timeIntervalSince1970), + ] + + let alarm = Alarm.futureCarbs() + let data = AlarmData.withCarbs([]) // carb was deleted + + let result = cond.evaluate(alarm: alarm, data: data, now: now) + + #expect(!result) + #expect(Storage.shared.pendingFutureCarbs.value.isEmpty) + } + + // MARK: - 4. Beyond lookahead — tracked but does not fire + + @Test("#beyond lookahead — tracked but does not fire") + func beyondLookaheadTrackedButNoFire() { + resetPending() + let now = Date() + let alarm = Alarm.futureCarbs(threshold: 45) + let data = AlarmData.withCarbs([carb(minutesFromNow: 60, grams: 20, relativeTo: now)]) + + let result = cond.evaluate(alarm: alarm, data: data, now: now) + + #expect(!result) + // Carb is tracked (to prevent re-observation with fresh observedAt) + // but will never fire because original distance > lookahead + #expect(Storage.shared.pendingFutureCarbs.value.count == 1) + } + + // MARK: - 5. Below min grams — carb ignored + + @Test("#below min grams — carb ignored") + func belowMinGramsIgnored() { + resetPending() + let now = Date() + let alarm = Alarm.futureCarbs(delta: 5) + let data = AlarmData.withCarbs([carb(minutesFromNow: 10, grams: 3, relativeTo: now)]) + + let result = cond.evaluate(alarm: alarm, data: data, now: now) + + #expect(!result) + #expect(Storage.shared.pendingFutureCarbs.value.isEmpty) + } + + // MARK: - 6. Past carb — not tracked + + @Test("#past carb — not tracked") + func pastCarbNotTracked() { + resetPending() + let now = Date() + let alarm = Alarm.futureCarbs() + let data = AlarmData.withCarbs([carb(minutesFromNow: -5, grams: 20, relativeTo: now)]) + + let result = cond.evaluate(alarm: alarm, data: data, now: now) + + #expect(!result) + #expect(Storage.shared.pendingFutureCarbs.value.isEmpty) + } + + // MARK: - 7. Stale cleanup — entry observed > 2h ago is removed + + @Test("#stale cleanup — entry observed > 2h ago is removed") + func staleCleanup() { + resetPending() + let now = Date() + let futureDate = now.addingTimeInterval(300) // still in the future + + Storage.shared.pendingFutureCarbs.value = [ + PendingFutureCarb(carbDate: futureDate.timeIntervalSince1970, grams: 20, observedAt: now.addingTimeInterval(-3 * 3600).timeIntervalSince1970), + ] + + let alarm = Alarm.futureCarbs() + let data = AlarmData.withCarbs([]) + + let result = cond.evaluate(alarm: alarm, data: data, now: now) + + #expect(!result) + #expect(Storage.shared.pendingFutureCarbs.value.isEmpty) + } + + // MARK: - 8. Multiple carbs — only one fires per tick + + @Test("#multiple carbs — only one fires per tick") + func multipleOnlyOnePerTick() { + resetPending() + let now = Date() + let past1 = now.addingTimeInterval(-60) + let past2 = now.addingTimeInterval(-120) + + Storage.shared.pendingFutureCarbs.value = [ + PendingFutureCarb(carbDate: past1.timeIntervalSince1970, grams: 20, observedAt: now.addingTimeInterval(-600).timeIntervalSince1970), + PendingFutureCarb(carbDate: past2.timeIntervalSince1970, grams: 30, observedAt: now.addingTimeInterval(-600).timeIntervalSince1970), + ] + + let alarm = Alarm.futureCarbs() + let data = AlarmData.withCarbs([ + CarbSample(grams: 20, date: past1), + CarbSample(grams: 30, date: past2), + ]) + + let result = cond.evaluate(alarm: alarm, data: data, now: now) + + #expect(result) + #expect(Storage.shared.pendingFutureCarbs.value.count == 1) + } + + // MARK: - 9. Second tick fires second carb + + @Test("#second tick fires second carb") + func secondTickFiresSecond() { + resetPending() + let now = Date() + let past1 = now.addingTimeInterval(-60) + let past2 = now.addingTimeInterval(-120) + + Storage.shared.pendingFutureCarbs.value = [ + PendingFutureCarb(carbDate: past1.timeIntervalSince1970, grams: 20, observedAt: now.addingTimeInterval(-600).timeIntervalSince1970), + PendingFutureCarb(carbDate: past2.timeIntervalSince1970, grams: 30, observedAt: now.addingTimeInterval(-600).timeIntervalSince1970), + ] + + let alarm = Alarm.futureCarbs() + let data = AlarmData.withCarbs([ + CarbSample(grams: 20, date: past1), + CarbSample(grams: 30, date: past2), + ]) + + // First tick + let result1 = cond.evaluate(alarm: alarm, data: data, now: now) + #expect(result1) + #expect(Storage.shared.pendingFutureCarbs.value.count == 1) + + // Second tick + let result2 = cond.evaluate(alarm: alarm, data: data, now: now) + #expect(result2) + #expect(Storage.shared.pendingFutureCarbs.value.isEmpty) + } + + // MARK: - 10. Duplicate carb not double-tracked + + @Test("#duplicate carb not double-tracked") + func duplicateNotDoubleTracked() { + resetPending() + let now = Date() + let alarm = Alarm.futureCarbs() + let data = AlarmData.withCarbs([carb(minutesFromNow: 10, grams: 20, relativeTo: now)]) + + _ = cond.evaluate(alarm: alarm, data: data, now: now) + #expect(Storage.shared.pendingFutureCarbs.value.count == 1) + + _ = cond.evaluate(alarm: alarm, data: data, now: now) + #expect(Storage.shared.pendingFutureCarbs.value.count == 1) + } + + // MARK: - 11. Sliding window — carb outside lookahead never fires + + @Test("#sliding window — carb outside lookahead never fires") + func slidingWindowNeverFires() { + resetPending() + let t0 = Date() + let alarm = Alarm.futureCarbs(threshold: 10) // 10-minute lookahead + let carbDate = t0.addingTimeInterval(15 * 60) // 15 min in future + let carbSample = CarbSample(grams: 20, date: carbDate) + + // Tick at T+0: carb is 15 min away, outside 10-min window but tracked + let data = AlarmData.withCarbs([carbSample]) + let r0 = cond.evaluate(alarm: alarm, data: data, now: t0) + #expect(!r0) + #expect(Storage.shared.pendingFutureCarbs.value.count == 1) + + // Tick at T+5min: carb is now 10 min away (inside window), but + // original distance was 15 min — must NOT fire + let t1 = t0.addingTimeInterval(5 * 60) + let r1 = cond.evaluate(alarm: alarm, data: data, now: t1) + #expect(!r1) + + // Tick at T+15min: carb is due — still must NOT fire + let t2 = t0.addingTimeInterval(15 * 60) + let r2 = cond.evaluate(alarm: alarm, data: data, now: t2) + #expect(!r2) + // Entry should be removed (due, outside original window) + #expect(Storage.shared.pendingFutureCarbs.value.isEmpty) + } + + // MARK: - 12. Due entry outside original window removed without firing + + @Test("#due entry outside original window removed without firing") + func dueOutsideWindowRemovedNoFire() { + resetPending() + let now = Date() + let pastDate = now.addingTimeInterval(-60) // 1 min ago + + // Entry was observed 20 min before its carb date (outside 10-min window) + Storage.shared.pendingFutureCarbs.value = [ + PendingFutureCarb( + carbDate: pastDate.timeIntervalSince1970, + grams: 20, + observedAt: pastDate.timeIntervalSince1970 - 20 * 60 + ), + ] + + let alarm = Alarm.futureCarbs(threshold: 10) + let data = AlarmData.withCarbs([CarbSample(grams: 20, date: pastDate)]) + + let result = cond.evaluate(alarm: alarm, data: data, now: now) + + #expect(!result) + #expect(Storage.shared.pendingFutureCarbs.value.isEmpty) + } + + // MARK: - 13. Stale entry with existing carb is not evicted + + @Test("#stale entry with existing carb is not evicted") + func staleWithExistingCarbNotEvicted() { + resetPending() + let now = Date() + let futureDate = now.addingTimeInterval(300) // 5 min in the future + + // Entry observed 3 hours ago, but carb still exists in recentCarbs + Storage.shared.pendingFutureCarbs.value = [ + PendingFutureCarb( + carbDate: futureDate.timeIntervalSince1970, + grams: 20, + observedAt: now.addingTimeInterval(-3 * 3600).timeIntervalSince1970 + ), + ] + + let alarm = Alarm.futureCarbs() + let data = AlarmData.withCarbs([CarbSample(grams: 20, date: futureDate)]) + + let result = cond.evaluate(alarm: alarm, data: data, now: now) + + #expect(!result) + // Entry must survive — carb still exists, don't evict + #expect(Storage.shared.pendingFutureCarbs.value.count == 1) + } +} diff --git a/Tests/AlarmConditions/Helpers.swift b/Tests/AlarmConditions/Helpers.swift index 37220d12f..c615f4972 100644 --- a/Tests/AlarmConditions/Helpers.swift +++ b/Tests/AlarmConditions/Helpers.swift @@ -6,9 +6,6 @@ import Foundation @testable import LoopFollow import Testing -@testable import LoopFollow -import Testing - // MARK: - Alarm helpers extension Alarm { @@ -17,6 +14,13 @@ extension Alarm { alarm.threshold = threshold return alarm } + + static func futureCarbs(threshold: Double = 45, delta: Double = 5) -> Self { + var alarm = Alarm(type: .futureCarbs) + alarm.threshold = threshold + alarm.delta = delta + return alarm + } } // MARK: - AlarmData helpers @@ -40,8 +44,33 @@ extension AlarmData { IOB: nil, recentBoluses: [], latestBattery: level, + latestPumpBattery: nil, batteryHistory: [], recentCarbs: [] ) } + + static func withCarbs(_ carbs: [CarbSample]) -> Self { + AlarmData( + bgReadings: [], + predictionData: [], + expireDate: nil, + lastLoopTime: nil, + latestOverrideStart: nil, + latestOverrideEnd: nil, + latestTempTargetStart: nil, + latestTempTargetEnd: nil, + recBolus: nil, + COB: nil, + sageInsertTime: nil, + pumpInsertTime: nil, + latestPumpVolume: nil, + IOB: nil, + recentBoluses: [], + latestBattery: nil, + latestPumpBattery: nil, + batteryHistory: [], + recentCarbs: carbs + ) + } } diff --git a/WatchConnectivityManager.swift b/WatchConnectivityManager.swift new file mode 100644 index 000000000..97678710a --- /dev/null +++ b/WatchConnectivityManager.swift @@ -0,0 +1,153 @@ +// WatchConnectivityManager.swift +// LoopFollow +// +// Copyright © 2026 Jon Fawcett. All rights reserved. +// + + +// WatchConnectivityManager.swift +// Philippe Achkar +// 2026-03-10 + +import Foundation +import WatchConnectivity + +final class WatchConnectivityManager: NSObject { + + // MARK: - Shared Instance + + static let shared = WatchConnectivityManager() + + // MARK: - Init + + /// Timestamp of the last snapshot the Watch ACK'd via sendAck(). + private var lastWatchAckTimestamp: TimeInterval = 0 + + private override init() { + super.init() + } + + // MARK: - Setup + + /// Call once from AppDelegate after app launch. + func activate() { + guard WCSession.isSupported() else { + LogManager.shared.log(category: .watch, message: "WatchConnectivityManager: WCSession not supported on this device") + return + } + WCSession.default.delegate = self + WCSession.default.activate() + LogManager.shared.log(category: .watch, message: "WatchConnectivityManager: WCSession activation requested") + } + + // MARK: - Send Snapshot + + /// Sends the latest GlucoseSnapshot to the Watch via transferUserInfo. + /// Safe to call from any thread. + /// No-ops silently if Watch is not paired or reachable. + func send(snapshot: GlucoseSnapshot) { + guard WCSession.isSupported() else { return } + + let session = WCSession.default + + guard session.activationState == .activated else { + LogManager.shared.log(category: .watch, message: "WatchConnectivityManager: session not activated, skipping send") + return + } + + guard session.isPaired else { + LogManager.shared.log(category: .watch, message: "WatchConnectivityManager: no paired Watch, skipping send") + return + } + + do { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + let data = try encoder.encode(snapshot) + let payload: [String: Any] = ["snapshot": data] + + // Warn if Watch hasn't ACK'd this or a recent snapshot. + let behindBy = snapshot.updatedAt.timeIntervalSince1970 - lastWatchAckTimestamp + if lastWatchAckTimestamp > 0, behindBy > 600 { + LogManager.shared.log(category: .watch, message: "WatchConnectivityManager: Watch ACK is \(Int(behindBy))s behind — Watch may be missing deliveries") + } + + // sendMessage: immediate delivery when Watch app is in foreground. + if session.isReachable { + session.sendMessage(payload, replyHandler: nil, errorHandler: nil) + LogManager.shared.log(category: .watch, message: "WatchConnectivityManager: snapshot sent via sendMessage (reachable)") + } + + // Cancel outstanding transfers before queuing — only the latest snapshot matters. + // session.outstandingUserInfoTransfers.forEach { $0.cancel() } + + // transferUserInfo: guaranteed queued delivery for background wakes. + session.transferUserInfo(payload) + + // applicationContext: latest-state mirror for next launch / scheduled refresh. + do { + try session.updateApplicationContext(payload) + } catch { + LogManager.shared.log( + category: .watch, + message: "WatchConnectivityManager: failed to update applicationContext — \(error)" + ) + } + + LogManager.shared.log(category: .watch, message: "WatchConnectivityManager: snapshot queued via transferUserInfo") + } catch { + LogManager.shared.log(category: .watch, message: "WatchConnectivityManager: failed to encode snapshot — \(error)") + } + } +} + +// MARK: - WCSessionDelegate + +extension WatchConnectivityManager: WCSessionDelegate { + + func session( + _ session: WCSession, + activationDidCompleteWith activationState: WCSessionActivationState, + error: Error? + ) { + if let error = error { + LogManager.shared.log(category: .watch, message: "WatchConnectivityManager: activation failed — \(error)") + } else { + LogManager.shared.log(category: .watch, message: "WatchConnectivityManager: activation complete — state \(activationState.rawValue)") + } + } + + /// When the Watch app comes to the foreground, send the latest snapshot immediately + /// so the Watch app has fresh data without waiting for the next BG poll. + /// Receives ACKs from the Watch (sent after each snapshot is saved). + func session(_ session: WCSession, didReceiveMessage message: [String: Any]) { + if let ackTimestamp = message["watchAck"] as? TimeInterval { + lastWatchAckTimestamp = ackTimestamp + LogManager.shared.log(category: .watch, message: "WatchConnectivityManager: Watch ACK received for snapshot at \(ackTimestamp)") + } + } + + func session(_ session: WCSession, didReceiveUserInfo userInfo: [String: Any]) { + if let ackTimestamp = userInfo["watchAck"] as? TimeInterval { + lastWatchAckTimestamp = ackTimestamp + LogManager.shared.log(category: .watch, message: "WatchConnectivityManager: Watch ACK (userInfo) received for snapshot at \(ackTimestamp)") + } + } + + func sessionReachabilityDidChange(_ session: WCSession) { + guard session.isReachable else { return } + if let snapshot = GlucoseSnapshotStore.shared.load() { + send(snapshot: snapshot) + LogManager.shared.log(category: .watch, message: "WatchConnectivityManager: Watch became reachable — snapshot pushed") + } + } + + func sessionDidBecomeInactive(_ session: WCSession) { + LogManager.shared.log(category: .watch, message: "WatchConnectivityManager: session became inactive") + } + + func sessionDidDeactivate(_ session: WCSession) { + LogManager.shared.log(category: .watch, message: "WatchConnectivityManager: session deactivated — reactivating") + WCSession.default.activate() + } +} diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 0871e7414..6aa54cfbb 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -56,7 +56,8 @@ platform :ios do git_basic_authorization: Base64.strict_encode64("#{GITHUB_REPOSITORY_OWNER}:#{GH_PAT}"), app_identifier: [ "com.#{TEAMID}.LoopFollow", - "com.#{TEAMID}.LoopFollow.LoopFollowLAExtension" + "com.#{TEAMID}.LoopFollow.LoopFollowLAExtension", + "com.#{TEAMID}.LoopFollow.watchkitapp" ] ) @@ -72,12 +73,19 @@ platform :ios do ) update_code_signing_settings( - path: "#{GITHUB_WORKSPACE}/LoopFollow.xcodeproj", + path: "#{GITHUB_WORKSPACE}/LoopFollow.xcodeproj", profile_name: mapping["com.#{TEAMID}.LoopFollow.LoopFollowLAExtension"], code_sign_identity: "iPhone Distribution", targets: ["LoopFollowLAExtensionExtension"] ) + update_code_signing_settings( + path: "#{GITHUB_WORKSPACE}/LoopFollow.xcodeproj", + profile_name: mapping["com.#{TEAMID}.LoopFollow.watchkitapp"], + code_sign_identity: "iPhone Distribution", + targets: ["LoopFollowWatch Watch App"] + ) + gym( export_method: "app-store", scheme: "LoopFollow", @@ -88,9 +96,10 @@ platform :ios do export_options: { provisioningProfiles: { "com.#{TEAMID}.LoopFollow" => mapping["com.#{TEAMID}.LoopFollow"], - "com.#{TEAMID}.LoopFollow.LoopFollowLAExtension" => mapping["com.#{TEAMID}.LoopFollow.LoopFollowLAExtension"] + "com.#{TEAMID}.LoopFollow.LoopFollowLAExtension" => mapping["com.#{TEAMID}.LoopFollow.LoopFollowLAExtension"], + "com.#{TEAMID}.LoopFollow.watchkitapp" => mapping["com.#{TEAMID}.LoopFollow.watchkitapp"] } - } + } ) copy_artifacts( @@ -141,9 +150,11 @@ platform :ios do configure_bundle_id("LoopFollow", "com.#{TEAMID}.LoopFollow", [ Spaceship::ConnectAPI::BundleIdCapability::Type::PUSH_NOTIFICATIONS ]) - + configure_bundle_id("LoopFollow Live Activity Extension", "com.#{TEAMID}.LoopFollow.LoopFollowLAExtension", []) + configure_bundle_id("LoopFollow Watch App", "com.#{TEAMID}.LoopFollow.watchkitapp", []) + end desc "Provision Certificates" @@ -164,7 +175,8 @@ platform :ios do git_basic_authorization: Base64.strict_encode64("#{GITHUB_REPOSITORY_OWNER}:#{GH_PAT}"), app_identifier: [ "com.#{TEAMID}.LoopFollow", - "com.#{TEAMID}.LoopFollow.LoopFollowLAExtension" + "com.#{TEAMID}.LoopFollow.LoopFollowLAExtension", + "com.#{TEAMID}.LoopFollow.watchkitapp" ] ) end From 002df85926dbd4ea3c6b88579c90fe9e8797a017 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Thu, 9 Apr 2026 17:58:26 +0200 Subject: [PATCH 80/82] SwiftFormat --- LoopFollow/Application/AppDelegate.swift | 2 +- LoopFollow/Contact/ContactImageUpdater.swift | 9 ++- .../LiveActivity/LALivenessMarker.swift | 12 +--- LoopFollow/LiveActivity/LALivenessStore.swift | 10 +--- .../LiveActivity/LiveActivityManager.swift | 6 +- .../ComplicationEntryBuilder.swift | 18 +++--- .../WatchComplicationProvider.swift | 10 ++-- .../WatchComplication/WatchFormat.swift | 58 +++++++++---------- .../WatchSessionReceiver.swift | 23 ++++---- LoopFollowWatch Watch App/ContentView.swift | 13 ++--- .../LoopFollowWatchApp.swift | 12 ++-- WatchConnectivityManager.swift | 26 ++++----- 12 files changed, 87 insertions(+), 112 deletions(-) diff --git a/LoopFollow/Application/AppDelegate.swift b/LoopFollow/Application/AppDelegate.swift index 09a433e41..e22822545 100644 --- a/LoopFollow/Application/AppDelegate.swift +++ b/LoopFollow/Application/AppDelegate.swift @@ -40,7 +40,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { _ = BLEManager.shared // Ensure VolumeButtonHandler is initialized so it can receive alarm notifications _ = VolumeButtonHandler.shared - + WatchConnectivityManager.shared.activate() // Register for remote notifications diff --git a/LoopFollow/Contact/ContactImageUpdater.swift b/LoopFollow/Contact/ContactImageUpdater.swift index 939da4500..fd02575e1 100644 --- a/LoopFollow/Contact/ContactImageUpdater.swift +++ b/LoopFollow/Contact/ContactImageUpdater.swift @@ -119,15 +119,18 @@ class ContactImageUpdater { private func getIncludedFields(for contactType: ContactType) -> [ContactType] { var included: [ContactType] = [] if Storage.shared.contactTrend.value == .include, - Storage.shared.contactTrendTarget.value == contactType { + Storage.shared.contactTrendTarget.value == contactType + { included.append(.Trend) } if Storage.shared.contactDelta.value == .include, - Storage.shared.contactDeltaTarget.value == contactType { + Storage.shared.contactDeltaTarget.value == contactType + { included.append(.Delta) } if Storage.shared.contactIOB.value == .include, - Storage.shared.contactIOBTarget.value == contactType { + Storage.shared.contactIOBTarget.value == contactType + { included.append(.IOB) } return included diff --git a/LoopFollow/LiveActivity/LALivenessMarker.swift b/LoopFollow/LiveActivity/LALivenessMarker.swift index 62da0a8a6..ce066e489 100644 --- a/LoopFollow/LiveActivity/LALivenessMarker.swift +++ b/LoopFollow/LiveActivity/LALivenessMarker.swift @@ -1,11 +1,5 @@ -// -// LALivenessMarker.swift -// LoopFollow -// -// Created by Philippe Achkar on 2026-04-01. -// Copyright © 2026 Jon Fawcett. All rights reserved. -// - +// LoopFollow +// LALivenessMarker.swift import SwiftUI @@ -24,4 +18,4 @@ struct LALivenessMarker: View { private var markerID: String { "\(seq)-\(producedAt.timeIntervalSince1970)" } -} \ No newline at end of file +} diff --git a/LoopFollow/LiveActivity/LALivenessStore.swift b/LoopFollow/LiveActivity/LALivenessStore.swift index 6bb552395..e6f8ebe98 100644 --- a/LoopFollow/LiveActivity/LALivenessStore.swift +++ b/LoopFollow/LiveActivity/LALivenessStore.swift @@ -1,11 +1,5 @@ -// -// LALivenessStore.swift -// LoopFollow -// -// Created by Philippe Achkar on 2026-04-01. -// Copyright © 2026 Jon Fawcett. All rights reserved. -// - +// LoopFollow +// LALivenessStore.swift import Foundation diff --git a/LoopFollow/LiveActivity/LiveActivityManager.swift b/LoopFollow/LiveActivity/LiveActivityManager.swift index 111cfd3ad..6e7e97707 100644 --- a/LoopFollow/LiveActivity/LiveActivityManager.swift +++ b/LoopFollow/LiveActivity/LiveActivityManager.swift @@ -150,7 +150,7 @@ final class LiveActivityManager { message: "[LA] foreground restart: current=nil (old activity not bound locally), ending all existing LAs before restart" ) current = nil - + Task { for activity in Activity.activities { await activity.end(nil, dismissalPolicy: .immediate) @@ -178,7 +178,7 @@ final class LiveActivityManager { } } } - + @objc private func handleBackgroundAudioFailed() { guard Storage.shared.laEnabled.value, current != nil else { return } // The background audio session has permanently failed — the app will lose its @@ -188,7 +188,7 @@ final class LiveActivityManager { Storage.shared.laRenewBy.value = Date().timeIntervalSince1970 refreshFromCurrentState(reason: "audio-session-failed") } - + private func shouldRestartBecauseExtensionLooksStuck() -> Bool { guard Storage.shared.laEnabled.value else { return false } guard !dismissedByUser else { return false } diff --git a/LoopFollow/WatchComplication/ComplicationEntryBuilder.swift b/LoopFollow/WatchComplication/ComplicationEntryBuilder.swift index 8f045bf7a..754aaa38d 100644 --- a/LoopFollow/WatchComplication/ComplicationEntryBuilder.swift +++ b/LoopFollow/WatchComplication/ComplicationEntryBuilder.swift @@ -1,6 +1,5 @@ +// LoopFollow // ComplicationEntryBuilder.swift -// Philippe Achkar -// 2026-03-25 import ClockKit @@ -23,7 +22,6 @@ enum ComplicationID { // MARK: - Entry builder enum ComplicationEntryBuilder { - // MARK: - Live template static func template( @@ -36,9 +34,9 @@ enum ComplicationEntryBuilder { return graphicCircularTemplate(snapshot: snapshot) case .graphicCorner: switch identifier { - case ComplicationID.stackCorner: return graphicCornerStackTemplate(snapshot: snapshot) - case ComplicationID.debugCorner: return graphicCornerDebugTemplate(snapshot: snapshot) - default: return graphicCornerGaugeTemplate(snapshot: snapshot) + case ComplicationID.stackCorner: return graphicCornerStackTemplate(snapshot: snapshot) + case ComplicationID.debugCorner: return graphicCornerDebugTemplate(snapshot: snapshot) + default: return graphicCornerGaugeTemplate(snapshot: snapshot) } default: return nil @@ -114,6 +112,7 @@ enum ComplicationEntryBuilder { } // MARK: - Graphic Circular + // BG (top, colored) + trend arrow (bottom). private static func graphicCircularTemplate(snapshot: GlucoseSnapshot) -> CLKComplicationTemplate { @@ -127,6 +126,7 @@ enum ComplicationEntryBuilder { } // MARK: - Graphic Corner — Gauge Text (Complication 1) + // Gauge arc fills from 0 (fresh) to 100% (15 min stale). // Outer text: BG (colored). Leading text: delta. // Stale / isNotLooping → "⚠" in yellow, gauge full. @@ -167,6 +167,7 @@ enum ComplicationEntryBuilder { } // MARK: - Graphic Corner — Stacked Text (Complication 2) + // Outer (top, large): BG value, colored. // Inner (bottom, small): "→ projected" (falls back to delta if no projection). // Stale / isNotLooping: outer = "--", inner = "". @@ -197,6 +198,7 @@ enum ComplicationEntryBuilder { } // MARK: - Graphic Corner — Debug (Complication 3) + // Outer (top): HH:mm of the snapshot's updatedAt — when the CGM reading arrived. // Inner (bottom): "↺ HH:mm" — when ClockKit last called getCurrentTimelineEntry. // @@ -206,7 +208,7 @@ enum ComplicationEntryBuilder { // inner stale → reloadTimeline is not being called or ClockKit is ignoring it private static func graphicCornerDebugTemplate(snapshot: GlucoseSnapshot) -> CLKComplicationTemplate { - let dataTime = WatchFormat.updateTime(snapshot) + let dataTime = WatchFormat.updateTime(snapshot) let buildTime = WatchFormat.currentTime() return CLKComplicationTemplateGraphicCornerStackText( @@ -220,7 +222,7 @@ enum ComplicationEntryBuilder { /// snapshot.glucose is always in mg/dL (builder stores canonical mg/dL). static func thresholdColor(for snapshot: GlucoseSnapshot) -> UIColor { let t = LAAppGroupSettings.thresholdsMgdl() - if snapshot.glucose < t.low { return .red } + if snapshot.glucose < t.low { return .red } if snapshot.glucose > t.high { return .orange } return .green } diff --git a/LoopFollow/WatchComplication/WatchComplicationProvider.swift b/LoopFollow/WatchComplication/WatchComplicationProvider.swift index 3e59c9d97..f7df784b6 100644 --- a/LoopFollow/WatchComplication/WatchComplicationProvider.swift +++ b/LoopFollow/WatchComplication/WatchComplicationProvider.swift @@ -1,6 +1,5 @@ +// LoopFollow // WatchComplicationProvider.swift -// Philippe Achkar -// 2026-03-10 import ClockKit import Foundation @@ -12,7 +11,6 @@ private let watchLog = OSLog( ) final class WatchComplicationProvider: NSObject, CLKComplicationDataSource { - // MARK: - Complication Descriptors func getComplicationDescriptors(handler: @escaping ([CLKComplicationDescriptor]) -> Void) { @@ -34,7 +32,7 @@ final class WatchComplicationProvider: NSObject, CLKComplicationDataSource { identifier: ComplicationID.debugCorner, displayName: "LoopFollow Debug", supportedFamilies: [.graphicCorner] - ) + ), ] handler(descriptors) } @@ -53,7 +51,7 @@ final class WatchComplicationProvider: NSObject, CLKComplicationDataSource { // Prefer the file store (persists across launches); fall back to the in-memory // cache in case the file write hasn't completed or the store is unavailable. guard let snapshot = GlucoseSnapshotStore.shared.load() - ?? WatchSessionReceiver.shared.lastSnapshot + ?? WatchSessionReceiver.shared.lastSnapshot else { os_log("WatchComplicationProvider: no snapshot available (store and cache both nil)", log: watchLog, type: .error) handler(nil) @@ -94,7 +92,7 @@ final class WatchComplicationProvider: NSObject, CLKComplicationDataSource { } func getPrivacyBehavior( - for complication: CLKComplication, + for _: CLKComplication, withHandler handler: @escaping (CLKComplicationPrivacyBehavior) -> Void ) { // Glucose is sensitive — hide on locked watch face diff --git a/LoopFollow/WatchComplication/WatchFormat.swift b/LoopFollow/WatchComplication/WatchFormat.swift index acc7e9ec2..194857e38 100644 --- a/LoopFollow/WatchComplication/WatchFormat.swift +++ b/LoopFollow/WatchComplication/WatchFormat.swift @@ -1,6 +1,5 @@ +// LoopFollow // WatchFormat.swift -// Philippe Achkar -// 2026-03-25 import Foundation @@ -8,7 +7,6 @@ import Foundation /// All glucose values in GlucoseSnapshot are stored in mg/dL; this module /// converts to mmol/L for display when snapshot.unit == .mmol. enum WatchFormat { - // MARK: - Glucose static func glucose(_ s: GlucoseSnapshot) -> String { @@ -37,14 +35,14 @@ enum WatchFormat { static func trendArrow(_ s: GlucoseSnapshot) -> String { switch s.trend { - case .upFast: return "↑↑" - case .up: return "↑" - case .upSlight: return "↗" - case .flat: return "→" + case .upFast: return "↑↑" + case .up: return "↑" + case .upSlight: return "↗" + case .flat: return "→" case .downSlight: return "↘" - case .down: return "↓" - case .downFast: return "↓↓" - case .unknown: return "–" + case .down: return "↓" + case .downFast: return "↓↓" + case .unknown: return "–" } } @@ -159,28 +157,28 @@ enum WatchFormat { static func slotValue(option: LiveActivitySlotOption, snapshot s: GlucoseSnapshot) -> String { switch option { - case .none: return "" - case .delta: return delta(s) + case .none: return "" + case .delta: return delta(s) case .projectedBG: return projected(s) - case .minMax: return minMax(s) - case .iob: return iob(s) - case .cob: return cob(s) - case .recBolus: return recBolus(s) - case .autosens: return autosens(s) - case .tdd: return tdd(s) - case .basal: return basal(s) - case .pump: return pump(s) + case .minMax: return minMax(s) + case .iob: return iob(s) + case .cob: return cob(s) + case .recBolus: return recBolus(s) + case .autosens: return autosens(s) + case .tdd: return tdd(s) + case .basal: return basal(s) + case .pump: return pump(s) case .pumpBattery: return pumpBattery(s) - case .battery: return battery(s) - case .target: return target(s) - case .isf: return isf(s) - case .carbRatio: return carbRatio(s) - case .sage: return age(insertTime: s.sageInsertTime) - case .cage: return age(insertTime: s.cageInsertTime) - case .iage: return age(insertTime: s.iageInsertTime) - case .carbsToday: return carbsToday(s) - case .override: return override(s) - case .profile: return profileName(s) + case .battery: return battery(s) + case .target: return target(s) + case .isf: return isf(s) + case .carbRatio: return carbRatio(s) + case .sage: return age(insertTime: s.sageInsertTime) + case .cage: return age(insertTime: s.cageInsertTime) + case .iage: return age(insertTime: s.iageInsertTime) + case .carbsToday: return carbsToday(s) + case .override: return override(s) + case .profile: return profileName(s) } } diff --git a/LoopFollow/WatchComplication/WatchSessionReceiver.swift b/LoopFollow/WatchComplication/WatchSessionReceiver.swift index 89a4e13b6..c332c1ecc 100644 --- a/LoopFollow/WatchComplication/WatchSessionReceiver.swift +++ b/LoopFollow/WatchComplication/WatchSessionReceiver.swift @@ -1,12 +1,11 @@ +// LoopFollow // WatchSessionReceiver.swift -// Philippe Achkar -// 2026-03-10 +import ClockKit import Foundation +import os.log import WatchConnectivity -import ClockKit import WatchKit -import os.log private let watchLog = OSLog( subsystem: Bundle.main.bundleIdentifier ?? "com.loopfollow.watch", @@ -14,7 +13,6 @@ private let watchLog = OSLog( ) final class WatchSessionReceiver: NSObject { - // MARK: - Shared Instance static let shared = WatchSessionReceiver() @@ -45,7 +43,7 @@ final class WatchSessionReceiver: NSObject { // MARK: - Init - private override init() { + override private init() { super.init() } @@ -72,7 +70,6 @@ final class WatchSessionReceiver: NSObject { // MARK: - WCSessionDelegate extension WatchSessionReceiver: WCSessionDelegate { - func session( _ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, @@ -112,7 +109,7 @@ extension WatchSessionReceiver: WCSessionDelegate { /// Handles immediate delivery when Watch app is in foreground (sendMessage path). func session( - _ session: WCSession, + _: WCSession, didReceiveMessage message: [String: Any] ) { process(payload: message, source: "sendMessage") @@ -120,16 +117,16 @@ extension WatchSessionReceiver: WCSessionDelegate { /// Handles queued background delivery (transferUserInfo path). func session( - _ session: WCSession, + _: WCSession, didReceiveUserInfo userInfo: [String: Any] ) { process(payload: userInfo, source: "userInfo") } - func session(_ session: WCSession, didReceiveApplicationContext applicationContext: [String : Any]) { + func session(_: WCSession, didReceiveApplicationContext applicationContext: [String: Any]) { process(payload: applicationContext, source: "applicationContext") } - + // MARK: - Private private func process(payload: [String: Any], source: String) { @@ -207,7 +204,9 @@ extension WatchSessionReceiver: WCSessionDelegate { return } - for complication in complications { server.reloadTimeline(for: complication) } + for complication in complications { + server.reloadTimeline(for: complication) + } os_log("WatchSessionReceiver: reloadTimeline called for %d complication(s)", log: watchLog, type: .info, complications.count) } diff --git a/LoopFollowWatch Watch App/ContentView.swift b/LoopFollowWatch Watch App/ContentView.swift index 1ae32dc63..61b24c634 100644 --- a/LoopFollowWatch Watch App/ContentView.swift +++ b/LoopFollowWatch Watch App/ContentView.swift @@ -1,10 +1,5 @@ -// -// ContentView.swift -// LoopFollowWatch Watch App -// -// Created by Philippe Achkar on 2026-03-10. -// Copyright © 2026 Jon Fawcett. All rights reserved. -// +// LoopFollow +// ContentView.swift import Combine import SwiftUI @@ -81,7 +76,7 @@ final class WatchViewModel: ObservableObject { var pages: [[LiveActivitySlotOption]] { guard !selectedSlots.isEmpty else { return [] } return stride(from: 0, to: selectedSlots.count, by: 4).map { - Array(selectedSlots[$0.. (storeSnapshot?.updatedAt ?? .distantPast) { + ctx.updatedAt > (storeSnapshot?.updatedAt ?? .distantPast) + { GlucoseSnapshotStore.shared.save(ctx) { WatchSessionReceiver.shared.triggerComplicationReload() WatchAppDelegate.scheduleNextRefresh() @@ -102,4 +100,4 @@ final class WatchAppDelegate: NSObject, WKApplicationDelegate { private func scheduleNextRefresh() { WatchAppDelegate.scheduleNextRefresh() } -} \ No newline at end of file +} diff --git a/WatchConnectivityManager.swift b/WatchConnectivityManager.swift index 97678710a..a2e8f6be0 100644 --- a/WatchConnectivityManager.swift +++ b/WatchConnectivityManager.swift @@ -1,9 +1,5 @@ -// WatchConnectivityManager.swift -// LoopFollow -// -// Copyright © 2026 Jon Fawcett. All rights reserved. -// - +// LoopFollow +// WatchConnectivityManager.swift // WatchConnectivityManager.swift // Philippe Achkar @@ -13,7 +9,6 @@ import Foundation import WatchConnectivity final class WatchConnectivityManager: NSObject { - // MARK: - Shared Instance static let shared = WatchConnectivityManager() @@ -23,7 +18,7 @@ final class WatchConnectivityManager: NSObject { /// Timestamp of the last snapshot the Watch ACK'd via sendAck(). private var lastWatchAckTimestamp: TimeInterval = 0 - private override init() { + override private init() { super.init() } @@ -83,7 +78,7 @@ final class WatchConnectivityManager: NSObject { // transferUserInfo: guaranteed queued delivery for background wakes. session.transferUserInfo(payload) - + // applicationContext: latest-state mirror for next launch / scheduled refresh. do { try session.updateApplicationContext(payload) @@ -93,7 +88,7 @@ final class WatchConnectivityManager: NSObject { message: "WatchConnectivityManager: failed to update applicationContext — \(error)" ) } - + LogManager.shared.log(category: .watch, message: "WatchConnectivityManager: snapshot queued via transferUserInfo") } catch { LogManager.shared.log(category: .watch, message: "WatchConnectivityManager: failed to encode snapshot — \(error)") @@ -104,9 +99,8 @@ final class WatchConnectivityManager: NSObject { // MARK: - WCSessionDelegate extension WatchConnectivityManager: WCSessionDelegate { - func session( - _ session: WCSession, + _: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error? ) { @@ -120,14 +114,14 @@ extension WatchConnectivityManager: WCSessionDelegate { /// When the Watch app comes to the foreground, send the latest snapshot immediately /// so the Watch app has fresh data without waiting for the next BG poll. /// Receives ACKs from the Watch (sent after each snapshot is saved). - func session(_ session: WCSession, didReceiveMessage message: [String: Any]) { + func session(_: WCSession, didReceiveMessage message: [String: Any]) { if let ackTimestamp = message["watchAck"] as? TimeInterval { lastWatchAckTimestamp = ackTimestamp LogManager.shared.log(category: .watch, message: "WatchConnectivityManager: Watch ACK received for snapshot at \(ackTimestamp)") } } - func session(_ session: WCSession, didReceiveUserInfo userInfo: [String: Any]) { + func session(_: WCSession, didReceiveUserInfo userInfo: [String: Any]) { if let ackTimestamp = userInfo["watchAck"] as? TimeInterval { lastWatchAckTimestamp = ackTimestamp LogManager.shared.log(category: .watch, message: "WatchConnectivityManager: Watch ACK (userInfo) received for snapshot at \(ackTimestamp)") @@ -142,11 +136,11 @@ extension WatchConnectivityManager: WCSessionDelegate { } } - func sessionDidBecomeInactive(_ session: WCSession) { + func sessionDidBecomeInactive(_: WCSession) { LogManager.shared.log(category: .watch, message: "WatchConnectivityManager: session became inactive") } - func sessionDidDeactivate(_ session: WCSession) { + func sessionDidDeactivate(_: WCSession) { LogManager.shared.log(category: .watch, message: "WatchConnectivityManager: session deactivated — reactivating") WCSession.default.activate() } From c40be2fe28464c24da7ee05012cd47632c8915bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Thu, 9 Apr 2026 17:47:19 +0200 Subject: [PATCH 81/82] Fix viewUpdateNSBG: re-declare lastBGTime local Restore `let lastBGTime = entries[latestEntryIndex].date` in viewUpdateNSBG. Dev currently fails to compile with "Cannot find 'lastBGTime' in scope" because two PRs that merged into dev on the same day textually did not conflict but semantically interfered: - #537 (Live Activity) added `Storage.shared.lastBgReadingTimeSeconds.value = lastBGTime` later in the function, relying on the existing local. - #565 (Display HIGH/LOW) removed the local declaration as part of an unrelated refactor; that branch was cut before #537 landed, so from its perspective the local was unused. Git's 3-way merge produced a syntactically clean file but the surviving reference now dangles. Re-adding the local is the minimal, intent-preserving fix. --- LoopFollow/Controllers/Nightscout/BGData.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/LoopFollow/Controllers/Nightscout/BGData.swift b/LoopFollow/Controllers/Nightscout/BGData.swift index f6d7b1f92..12b9568cd 100644 --- a/LoopFollow/Controllers/Nightscout/BGData.swift +++ b/LoopFollow/Controllers/Nightscout/BGData.swift @@ -236,6 +236,7 @@ extension MainViewController { let latestBG = entries[latestEntryIndex].sgv let priorBG = entries[latestEntryIndex - 1].sgv let deltaBG = latestBG - priorBG + let lastBGTime = entries[latestEntryIndex].date self.updateServerText(with: sourceName) From cb286c111182132b2616159e71a2033b850a9984 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Fri, 10 Apr 2026 10:28:50 +0200 Subject: [PATCH 82/82] Watch PR review fixes (#598) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Watch PR #594 review fixes - Guard LoopFollowDebugCorner descriptor, template, and placeholder branches with #if DEBUG so the diagnostic complication does not ship to end users. - Remove unused LAAppGroupSettings.setWatchSlots / watchSlots (the fixed-4-slot variant superseded by watchSelectedSlots). - Move WatchConnectivityManager.swift from the repo root into LoopFollow/LiveActivity/ next to the rest of the snapshot pipeline and drop the duplicate file header. - Serialize WatchConnectivityManager.lastWatchAckTimestamp access through a dedicated state queue. send(snapshot:) may run on any thread and WCSession delegate callbacks arrive on a background queue, so the bare TimeInterval was racy. - Assert main-thread access for WatchSessionReceiver.cacheComplication and document the invariant on cachedComplications. - Drop the no-op JSONEncoder/JSONDecoder iso8601 date strategies — GlucoseSnapshot has a custom encoder/decoder that writes/reads updatedAt as a Double, so the strategy was never consulted. - Nit: replace `if let _ = snapshot.projected` with `if snapshot.projected != nil`. - Nit: comment why SlotSelectionView excludes .delta / .projectedBG. - Remove CLAUDE.md. * Remove main-thread precondition from cacheComplication cacheComplication is called from 3 sites: getCurrentTimelineEntry, getTimelineEndDate (ClockKit callbacks — not documented as strictly main-thread), and reloadComplicationsOnMainThread. The precondition could crash if ClockKit ever delivers on a background queue. --- CLAUDE.md | 144 ------------------ LoopFollow.xcodeproj/project.pbxproj | 2 +- .../LiveActivity/GlucoseSnapshotStore.swift | 6 +- .../LiveActivity/LAAppGroupSettings.swift | 18 --- .../WatchConnectivityManager.swift | 24 ++- .../ComplicationEntryBuilder.swift | 66 ++++---- .../WatchComplicationProvider.swift | 18 ++- .../WatchSessionReceiver.swift | 7 +- LoopFollowWatch Watch App/ContentView.swift | 2 + .../LoopFollowWatchApp.swift | 3 +- 10 files changed, 79 insertions(+), 211 deletions(-) delete mode 100644 CLAUDE.md rename WatchConnectivityManager.swift => LoopFollow/LiveActivity/WatchConnectivityManager.swift (85%) diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 1814899d5..000000000 --- a/CLAUDE.md +++ /dev/null @@ -1,144 +0,0 @@ -# CLAUDE.md - -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. - -## Project Overview - -LoopFollow is an iOS app for caregivers/parents of Type 1 Diabetics (T1D) to monitor CGM glucose data, loop status, and AID system metrics. This fork (`LoopFollowLA`) is built on top of the upstream [loopandlearn/LoopFollow](https://github.com/loopandlearn/LoopFollow) and adds: - -- **Live Activity** (Dynamic Island / Lock Screen) — **complete**, do not modify -- **Apple Watch complications + Watch app** — **active development focus** -- **APNS-based remote commands** — complete - -The Live Activity work is considered stable. If it evolves upstream, the branch is rebased. All current development effort is on the Watch app (`LoopFollowWatch Watch App` target) and its complications. - -## Build System - -This is a CocoaPods project. Always open `LoopFollow.xcworkspace` (not `.xcodeproj`) in Xcode. - -```bash -# Install/update pods after cloning or when Podfile changes -pod install - -# Build from command line (simulator) -xcodebuild -workspace LoopFollow.xcworkspace -scheme LoopFollow -destination 'platform=iOS Simulator,name=iPhone 16' build - -# Run tests -xcodebuild -workspace LoopFollow.xcworkspace -scheme LoopFollow -destination 'platform=iOS Simulator,name=iPhone 16' test - -# Run a single test class -xcodebuild -workspace LoopFollow.xcworkspace -scheme LoopFollow -destination 'platform=iOS Simulator,name=iPhone 16' test -only-testing:LoopFollowTests/AlarmConditions/BatteryConditionTests -``` - -Fastlane lanes (`build_LoopFollow`, `release`, `identifiers`, `certs`) are CI-only and require App Store Connect credentials. - -## Xcode Targets - -| Target | Purpose | -|---|---| -| `LoopFollow` | Main iOS app | -| `LoopFollowLAExtensionExtension` | Live Activity widget extension | -| `LoopFollowWatch Watch App` | watchOS complication app | - -Bundle IDs are derived from `DEVELOPMENT_TEAM`: `com.$(TEAMID).LoopFollow`, etc. `Config.xcconfig` sets the marketing version; never edit version numbers directly (CI auto-bumps on merge to `dev`). - -## Architecture - -### Data Flow - -1. **Data sources** → `MainViewController` pulls BG/treatment data from: - - **Nightscout** (`Controllers/Nightscout/`) via REST API - - **Dexcom Share** (`BackgroundRefresh/BT/`, uses `ShareClient` pod) - - **BLE heartbeat** (`BackgroundRefresh/BT/BLEManager.swift`) for background refresh -2. `MainViewController` stores parsed data in its own arrays (`bgData`, `bolusData`, etc.) and calls `update*Graph()` methods. -3. **Reactive state bridge**: After processing, values are pushed into `Observable.shared` (in-memory) and `Storage.shared` (UserDefaults-backed). These feed SwiftUI views and the Live Activity pipeline. - -### Key Singletons - -- **`Storage`** (`Storage/Storage.swift`) — All persisted user settings as `StorageValue` (UserDefaults) or `SecureStorageValue` (Keychain). The single source of truth for configuration. -- **`Observable`** (`Storage/Observable.swift`) — In-memory reactive state (`ObservableValue`) for transient display values (BG text, color, direction, current alarm, etc.). -- **`ProfileManager`** — Manages Nightscout basal profiles. -- **`AlarmManager`** — Evaluates alarm conditions and triggers sound/notification. - -### Live Activity & Watch Complication Pipeline - -`GlucoseSnapshot` (`LiveActivity/GlucoseSnapshot.swift`) is the **canonical, source-agnostic data model** shared by all Watch and Live Activity surfaces. It is unit-aware (mg/dL or mmol/L) and self-contained. Fields: `glucose`, `delta`, `trend`, `updatedAt`, `iob`, `cob`, `projected`, `unit`, `isNotLooping`. - -``` -MainViewController / BackgroundRefresh - │ - ▼ -GlucoseSnapshotBuilder.build(...) ← assembles from Observable/Storage - │ - ▼ -GlucoseSnapshotStore.shared.save() ← persists to App Group container (JSON, atomic) - │ - ├──► LiveActivityManager.update() ← Dynamic Island / Lock Screen [COMPLETE] - ├──► WatchConnectivityManager.send() ← transferUserInfo to Watch - │ └──► WatchSessionReceiver ← saves snapshot + reloads complications (Watch-side) - └──► WatchComplicationProvider ← CLKComplicationDataSource (watchOS) - └── ComplicationEntryBuilder ← builds CLKComplicationTemplate -``` - -Thresholds for colour classification (green / orange / red) are read via `LAAppGroupSettings.thresholdsMgdl()` from the shared App Group UserDefaults — the same thresholds used by the Live Activity. The stale threshold is **15 minutes** (900 s) throughout. - -### Watch Complications (active development) - -Two corner complications to build in `ComplicationEntryBuilder` (`LoopFollow/WatchComplication/ComplicationEntryBuilder.swift`): - -**Complication 1 — `graphicCorner`, Open Gauge Text** -- Centre: BG value, coloured green/orange/red via `LAAppGroupSettings` thresholds -- Bottom text: delta (e.g. `+3` or `-2`) -- Gauge: fills from 0 → 15 min based on `snapshot.age / 900` -- Stale (>15 min) or `isNotLooping == true`: replace BG with `⚠` (yellow warning symbol) - -**Complication 2 — `graphicCorner`, Stacked Text** -- Top line: BG value (coloured) -- Bottom line: delta + minutes since update (e.g. `+3 4m`) -- Stale (>15 min): display `--` - -Both complications open the Watch app on tap (default watchOS behaviour when linked to the Watch app). `WatchComplicationProvider` handles timeline lifecycle and delegates all template construction to `ComplicationEntryBuilder`. - -### Watch App (active development) - -Entry point: `LoopFollowWatch Watch App/LoopFollowWatchApp.swift` — activates `WatchSessionReceiver`. -Main view: `LoopFollowWatch Watch App/ContentView.swift` — currently a placeholder stub. - -**Screen 1 — Main glucose view** -- Large BG value, coloured green/orange/red -- Right column: delta, projected BG, time since last update -- Button to open the phone app (shown only when `WCSession.default.isReachable`) - -**Subsequent screens — scrollable data cards** -- Each screen shows up to 4 data points from `GlucoseSnapshot` -- User-configurable via Watch app settings; every field in `GlucoseSnapshot` is eligible (glucose, delta, projected, IOB, COB, trend, age); units displayed alongside each value -- Default: IOB, COB, projected BG, battery - -Watch app settings persist in the Watch-side App Group UserDefaults (same suite as `LAAppGroupSettings`). - -### Background Refresh - -Three modes (set in `Storage.backgroundRefreshType`): -- **Silent tune** — plays an inaudible audio track to keep app alive -- **BLE heartbeat** — paired BLE device (e.g. Dexcom G7) wakes the app -- **APNS** — server push via `APNSClient` / `APNSJWTGenerator` - -### Remote Commands - -Remote bolus/carb/temp-target commands flow through `BackgroundRefresh/Remote/` using TOTP-authenticated APNS pushes. Settings live in `Storage` (APNS key, team ID, bundle ID, shared secret). - -### Settings Architecture - -Settings are split between: -- **SwiftUI views** in `Settings/` (new) — `GeneralSettingsView`, `AlarmSettingsView`, `AdvancedSettingsView`, etc. -- **Legacy UIKit** `SettingsViewController` — being migrated to SwiftUI - -### Tests - -Tests use the Swift Testing framework (`import Testing`). Test files are in `Tests/AlarmConditions/`. - -## Branch & PR Conventions - -- **All PRs target `dev`**, never `main`. PRs to `main` will be redirected. -- Never modify version numbers — CI auto-bumps after merge. -- Branch from `dev` and name it `feature_name` or `fix_name`. diff --git a/LoopFollow.xcodeproj/project.pbxproj b/LoopFollow.xcodeproj/project.pbxproj index d4f022be9..af35f685c 100644 --- a/LoopFollow.xcodeproj/project.pbxproj +++ b/LoopFollow.xcodeproj/project.pbxproj @@ -981,6 +981,7 @@ 374A77A12F5BE17000E96858 /* GlucoseSnapshot.swift */, 374A77A32F5BE17000E96858 /* LAAppGroupSettings.swift */, 374A77982F5BD8AB00E96858 /* APNSClient.swift */, + 37989DD32F60A11E0004BD8B /* WatchConnectivityManager.swift */, ); path = LiveActivity; sourceTree = ""; @@ -1680,7 +1681,6 @@ isa = PBXGroup; children = ( 379BECA92F6588300069DC62 /* RestartLiveActivityIntent.swift */, - 37989DD32F60A11E0004BD8B /* WatchConnectivityManager.swift */, 374DAACA2F5B924B00BB663B /* LoopFollowLAExtensionExtension.entitlements */, DDF2C0132BEFD468007A20E6 /* blacklisted-versions.json */, DDB0AF542BB1B24A00AFA48B /* BuildDetails.plist */, diff --git a/LoopFollow/LiveActivity/GlucoseSnapshotStore.swift b/LoopFollow/LiveActivity/GlucoseSnapshotStore.swift index 17569c46f..4e645b6b2 100644 --- a/LoopFollow/LiveActivity/GlucoseSnapshotStore.swift +++ b/LoopFollow/LiveActivity/GlucoseSnapshotStore.swift @@ -24,8 +24,9 @@ final class GlucoseSnapshotStore { queue.async { do { let url = try self.fileURL() + // GlucoseSnapshot writes `updatedAt` as a Double via its custom + // encoder, so no JSONEncoder date strategy is required. let encoder = JSONEncoder() - encoder.dateEncodingStrategy = .iso8601 let data = try encoder.encode(snapshot) try data.write(to: url, options: [.atomic]) os_log("GlucoseSnapshotStore: saved snapshot g=%d to %{public}@", log: storeLog, type: .debug, Int(snapshot.glucose), url.lastPathComponent) @@ -45,8 +46,9 @@ final class GlucoseSnapshotStore { } let data = try Data(contentsOf: url) + // GlucoseSnapshot reads `updatedAt` as a Double via its custom decoder, + // so no JSONDecoder date strategy is required. let decoder = JSONDecoder() - decoder.dateDecodingStrategy = .iso8601 let snapshot = try decoder.decode(GlucoseSnapshot.self, from: data) return snapshot } catch { diff --git a/LoopFollow/LiveActivity/LAAppGroupSettings.swift b/LoopFollow/LiveActivity/LAAppGroupSettings.swift index e0e3da22d..715323524 100644 --- a/LoopFollow/LiveActivity/LAAppGroupSettings.swift +++ b/LoopFollow/LiveActivity/LAAppGroupSettings.swift @@ -149,7 +149,6 @@ enum LAAppGroupSettings { static let smallWidgetSlot = "la.smallWidgetSlot" static let displayName = "la.displayName" static let showDisplayName = "la.showDisplayName" - static let watchSlots = "watch.slots" static let watchSelectedSlots = "watch.selectedSlots" } @@ -208,23 +207,6 @@ enum LAAppGroupSettings { return LiveActivitySlotOption(rawValue: raw) ?? LiveActivitySlotDefaults.smallWidgetSlot } - // MARK: - Watch slots (Write) - - /// Persists the 4-position Watch data card slot configuration. - static func setWatchSlots(_ slots: [LiveActivitySlotOption]) { - let raw = slots.prefix(4).map(\.rawValue) - defaults?.set(raw, forKey: Keys.watchSlots) - } - - // MARK: - Watch slots (Read) - - static func watchSlots() -> [LiveActivitySlotOption] { - guard let raw = defaults?.stringArray(forKey: Keys.watchSlots), raw.count == 4 else { - return [.iob, .cob, .projectedBG, .battery] - } - return raw.map { LiveActivitySlotOption(rawValue: $0) ?? .none } - } - // MARK: - Watch selected slots (ordered, variable-length) /// Persists the user's ordered list of selected Watch data slots. diff --git a/WatchConnectivityManager.swift b/LoopFollow/LiveActivity/WatchConnectivityManager.swift similarity index 85% rename from WatchConnectivityManager.swift rename to LoopFollow/LiveActivity/WatchConnectivityManager.swift index a2e8f6be0..a3e61c16d 100644 --- a/WatchConnectivityManager.swift +++ b/LoopFollow/LiveActivity/WatchConnectivityManager.swift @@ -1,10 +1,6 @@ // LoopFollow // WatchConnectivityManager.swift -// WatchConnectivityManager.swift -// Philippe Achkar -// 2026-03-10 - import Foundation import WatchConnectivity @@ -15,8 +11,21 @@ final class WatchConnectivityManager: NSObject { // MARK: - Init - /// Timestamp of the last snapshot the Watch ACK'd via sendAck(). - private var lastWatchAckTimestamp: TimeInterval = 0 + /// Serial queue protecting mutable state (the last-ack timestamp) from + /// concurrent access: `send(snapshot:)` may be called from any thread, and + /// WCSession delegate callbacks arrive on an arbitrary background queue. + private let stateQueue = DispatchQueue(label: "com.loopfollow.WatchConnectivityManager.state") + + /// Backing storage for `lastWatchAckTimestamp`. Always access via the + /// thread-safe accessor below. + private var _lastWatchAckTimestamp: TimeInterval = 0 + + /// Timestamp of the last snapshot the Watch ACK'd. Read/write is serialized + /// through `stateQueue`. + private var lastWatchAckTimestamp: TimeInterval { + get { stateQueue.sync { _lastWatchAckTimestamp } } + set { stateQueue.sync { _lastWatchAckTimestamp = newValue } } + } override private init() { super.init() @@ -56,8 +65,9 @@ final class WatchConnectivityManager: NSObject { } do { + // GlucoseSnapshot has a custom encoder that writes updatedAt as a + // Double (timeIntervalSince1970), so no date strategy needs to be set. let encoder = JSONEncoder() - encoder.dateEncodingStrategy = .iso8601 let data = try encoder.encode(snapshot) let payload: [String: Any] = ["snapshot": data] diff --git a/LoopFollow/WatchComplication/ComplicationEntryBuilder.swift b/LoopFollow/WatchComplication/ComplicationEntryBuilder.swift index 754aaa38d..fca7a84cc 100644 --- a/LoopFollow/WatchComplication/ComplicationEntryBuilder.swift +++ b/LoopFollow/WatchComplication/ComplicationEntryBuilder.swift @@ -10,13 +10,15 @@ enum ComplicationID { static let gaugeCorner = "LoopFollowGaugeCorner" /// graphicCorner stacked text only (Complication 2). static let stackCorner = "LoopFollowStackCorner" - // DEBUG COMPLICATION — enabled for pipeline diagnostics. - // Shows two timestamps to isolate pipeline failures: - // outer (top): HH:mm of snapshot.updatedAt — when CGM data last reached the Watch - // inner (↺): HH:mm when ClockKit last called getCurrentTimelineEntry - // If outer changes but inner is stale → reloadTimeline() not firing or ClockKit ignoring it. - // If inner changes but outer is stale → data delivery broken, complication rebuilding with old data. - static let debugCorner = "LoopFollowDebugCorner" + #if DEBUG + // DEBUG COMPLICATION — pipeline diagnostics only, never shipped in release builds. + // Shows two timestamps to isolate pipeline failures: + // outer (top): HH:mm of snapshot.updatedAt — when CGM data last reached the Watch + // inner (↺): HH:mm when ClockKit last called getCurrentTimelineEntry + // If outer changes but inner is stale → reloadTimeline() not firing or ClockKit ignoring it. + // If inner changes but outer is stale → data delivery broken, complication rebuilding with old data. + static let debugCorner = "LoopFollowDebugCorner" + #endif } // MARK: - Entry builder @@ -35,7 +37,9 @@ enum ComplicationEntryBuilder { case .graphicCorner: switch identifier { case ComplicationID.stackCorner: return graphicCornerStackTemplate(snapshot: snapshot) - case ComplicationID.debugCorner: return graphicCornerDebugTemplate(snapshot: snapshot) + #if DEBUG + case ComplicationID.debugCorner: return graphicCornerDebugTemplate(snapshot: snapshot) + #endif default: return graphicCornerGaugeTemplate(snapshot: snapshot) } default: @@ -59,11 +63,13 @@ enum ComplicationEntryBuilder { innerTextProvider: CLKSimpleTextProvider(text: ""), outerTextProvider: CLKSimpleTextProvider(text: "--") ) - case ComplicationID.debugCorner: - return CLKComplicationTemplateGraphicCornerStackText( - innerTextProvider: CLKSimpleTextProvider(text: "STALE"), - outerTextProvider: CLKSimpleTextProvider(text: "--:--") - ) + #if DEBUG + case ComplicationID.debugCorner: + return CLKComplicationTemplateGraphicCornerStackText( + innerTextProvider: CLKSimpleTextProvider(text: "STALE"), + outerTextProvider: CLKSimpleTextProvider(text: "--:--") + ) + #endif default: return staleGaugeTemplate() } @@ -90,11 +96,13 @@ enum ComplicationEntryBuilder { innerTextProvider: CLKSimpleTextProvider(text: "→ --"), outerTextProvider: outer ) - case ComplicationID.debugCorner: - return CLKComplicationTemplateGraphicCornerStackText( - innerTextProvider: CLKSimpleTextProvider(text: "DEBUG"), - outerTextProvider: CLKSimpleTextProvider(text: "--:--") - ) + #if DEBUG + case ComplicationID.debugCorner: + return CLKComplicationTemplateGraphicCornerStackText( + innerTextProvider: CLKSimpleTextProvider(text: "DEBUG"), + outerTextProvider: CLKSimpleTextProvider(text: "--:--") + ) + #endif default: let outer = CLKSimpleTextProvider(text: "---") outer.tintColor = .green @@ -184,7 +192,7 @@ enum ComplicationEntryBuilder { bgText.tintColor = thresholdColor(for: snapshot) let bottomLabel: String - if let _ = snapshot.projected { + if snapshot.projected != nil { // ⇢ = dashed arrow (U+21E2); swap for ▸ (U+25B8) if it renders poorly on-device bottomLabel = "\(WatchFormat.delta(snapshot)) | ⇢\(WatchFormat.projected(snapshot))" } else { @@ -197,7 +205,7 @@ enum ComplicationEntryBuilder { ) } - // MARK: - Graphic Corner — Debug (Complication 3) + // MARK: - Graphic Corner — Debug (Complication 3, DEBUG builds only) // Outer (top): HH:mm of the snapshot's updatedAt — when the CGM reading arrived. // Inner (bottom): "↺ HH:mm" — when ClockKit last called getCurrentTimelineEntry. @@ -207,15 +215,17 @@ enum ComplicationEntryBuilder { // inner changes → ClockKit is refreshing the complication face // inner stale → reloadTimeline is not being called or ClockKit is ignoring it - private static func graphicCornerDebugTemplate(snapshot: GlucoseSnapshot) -> CLKComplicationTemplate { - let dataTime = WatchFormat.updateTime(snapshot) - let buildTime = WatchFormat.currentTime() + #if DEBUG + private static func graphicCornerDebugTemplate(snapshot: GlucoseSnapshot) -> CLKComplicationTemplate { + let dataTime = WatchFormat.updateTime(snapshot) + let buildTime = WatchFormat.currentTime() - return CLKComplicationTemplateGraphicCornerStackText( - innerTextProvider: CLKSimpleTextProvider(text: "↺ \(buildTime)"), - outerTextProvider: CLKSimpleTextProvider(text: dataTime) - ) - } + return CLKComplicationTemplateGraphicCornerStackText( + innerTextProvider: CLKSimpleTextProvider(text: "↺ \(buildTime)"), + outerTextProvider: CLKSimpleTextProvider(text: dataTime) + ) + } + #endif // MARK: - Threshold color diff --git a/LoopFollow/WatchComplication/WatchComplicationProvider.swift b/LoopFollow/WatchComplication/WatchComplicationProvider.swift index f7df784b6..755fc3dae 100644 --- a/LoopFollow/WatchComplication/WatchComplicationProvider.swift +++ b/LoopFollow/WatchComplication/WatchComplicationProvider.swift @@ -14,7 +14,7 @@ final class WatchComplicationProvider: NSObject, CLKComplicationDataSource { // MARK: - Complication Descriptors func getComplicationDescriptors(handler: @escaping ([CLKComplicationDescriptor]) -> Void) { - let descriptors = [ + var descriptors: [CLKComplicationDescriptor] = [ // Complication 1: BG + gauge arc (graphicCircular + graphicCorner) CLKComplicationDescriptor( identifier: ComplicationID.gaugeCorner, @@ -27,13 +27,17 @@ final class WatchComplicationProvider: NSObject, CLKComplicationDataSource { displayName: "LoopFollow Text", supportedFamilies: [.graphicCorner] ), - // DEBUG COMPLICATION — enabled for pipeline diagnostics. - CLKComplicationDescriptor( - identifier: ComplicationID.debugCorner, - displayName: "LoopFollow Debug", - supportedFamilies: [.graphicCorner] - ), ] + #if DEBUG + // DEBUG COMPLICATION — pipeline diagnostics only, not shipped in release builds. + descriptors.append( + CLKComplicationDescriptor( + identifier: ComplicationID.debugCorner, + displayName: "LoopFollow Debug", + supportedFamilies: [.graphicCorner] + ) + ) + #endif handler(descriptors) } diff --git a/LoopFollow/WatchComplication/WatchSessionReceiver.swift b/LoopFollow/WatchComplication/WatchSessionReceiver.swift index c332c1ecc..b5a5e2f8e 100644 --- a/LoopFollow/WatchComplication/WatchSessionReceiver.swift +++ b/LoopFollow/WatchComplication/WatchSessionReceiver.swift @@ -35,7 +35,6 @@ final class WatchSessionReceiver: NSObject { private var cachedComplications: [String: CLKComplication] = [:] /// Called by WatchComplicationProvider whenever ClockKit passes a CLKComplication to us. - /// Must be called on the main thread (ClockKit callbacks are main-thread). func cacheComplication(_ complication: CLKComplication) { let key = "\(complication.identifier)-\(complication.family.rawValue)" cachedComplications[key] = complication @@ -88,8 +87,9 @@ extension WatchSessionReceiver: WCSessionDelegate { private func bootstrapFromApplicationContext(_ session: WCSession) { guard let data = session.receivedApplicationContext["snapshot"] as? Data else { return } do { + // GlucoseSnapshot has a custom decoder that reads `updatedAt` as a + // Double, so no JSONDecoder date strategy is required. let decoder = JSONDecoder() - decoder.dateDecodingStrategy = .iso8601 let snapshot = try decoder.decode(GlucoseSnapshot.self, from: data) GlucoseSnapshotStore.shared.save(snapshot) { [weak self] in os_log("WatchSessionReceiver: bootstrapped snapshot from applicationContext", log: watchLog, type: .debug) @@ -135,8 +135,9 @@ extension WatchSessionReceiver: WCSessionDelegate { return } do { + // GlucoseSnapshot has a custom decoder that reads `updatedAt` as a + // Double, so no JSONDecoder date strategy is required. let decoder = JSONDecoder() - decoder.dateDecodingStrategy = .iso8601 let snapshot = try decoder.decode(GlucoseSnapshot.self, from: data) // Cache in memory immediately — complication provider can use this as a // fallback if the App Group file store hasn't flushed yet. diff --git a/LoopFollowWatch Watch App/ContentView.swift b/LoopFollowWatch Watch App/ContentView.swift index 61b24c634..b9c9066d6 100644 --- a/LoopFollowWatch Watch App/ContentView.swift +++ b/LoopFollowWatch Watch App/ContentView.swift @@ -204,6 +204,8 @@ struct SlotSelectionView: View { var body: some View { List { + // `.delta` and `.projectedBG` are always shown on the glucose page, + // so they're excluded from the grid slot picker to avoid duplication. ForEach(LiveActivitySlotOption.allCases.filter { $0 != .none && $0 != .delta && $0 != .projectedBG }, id: \.self) { option in Button(action: { model.toggleSlot(option) }) { HStack { diff --git a/LoopFollowWatch Watch App/LoopFollowWatchApp.swift b/LoopFollowWatch Watch App/LoopFollowWatchApp.swift index 0b2928338..aec3e0549 100644 --- a/LoopFollowWatch Watch App/LoopFollowWatchApp.swift +++ b/LoopFollowWatch Watch App/LoopFollowWatchApp.swift @@ -81,8 +81,9 @@ final class WatchAppDelegate: NSObject, WKApplicationDelegate { } do { + // GlucoseSnapshot has a custom decoder that reads `updatedAt` as a + // Double, so no JSONDecoder date strategy is required. let decoder = JSONDecoder() - decoder.dateDecodingStrategy = .iso8601 return try decoder.decode(GlucoseSnapshot.self, from: data) } catch { logger.error("WatchAppDelegate: failed to decode applicationContext snapshot — \(error.localizedDescription, privacy: .public)")