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 01/29] 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 02/29] 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 03/29] 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 04/29] 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 05/29] 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 06/29] 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 07/29] 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 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 08/29] 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 09/29] 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 10/29] 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 11/29] 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 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 12/29] 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 13/29] 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 14/29] 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 15/29] 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 16/29] 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 17/29] 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 18/29] 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 19/29] 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 20/29] 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 21/29] 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 22/29] 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 23/29] 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 24/29] 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 25/29] 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 26/29] 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 27/29] 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 28/29] 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 29/29] 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) {