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/73] =?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/73] 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/73] 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/73] 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/73] 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/73] 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/73] 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/73] 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/73] 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/73] 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/73] 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/73] 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/73] 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/73] 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/73] 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/73] 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/73] 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/73] 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/73] **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/73] 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/73] 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/73] 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/73] 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/73] 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/73] 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/73] 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/73] 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/73] 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/73] 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/73] 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/73] 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/73] 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/73] 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/73] 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/73] 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/73] 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/73] 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/73] =?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/73] 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/73] 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/73] 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/73] 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/73] 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/73] 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/73] 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/73] 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/73] 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/73] 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/73] 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/73] 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/73] 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/73] 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/73] 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/73] 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/73] 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/73] 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/73] 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/73] 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/73] 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/73] 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/73] 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/73] 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/73] 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/73] 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/73] 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/73] 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/73] 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/73] 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/73] 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/73] 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/73] 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/73] 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/73] 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