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" + ) + } +}