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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] fix: handle loopfollow://la-tap URL in SceneDelegate, not AppDelegate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With scene-based lifecycle (iOS 13+), widget/LA tap URLs are delivered to scene(_:openURLContexts:) on UIWindowSceneDelegate — AppDelegate.application (_:open:url:options:) is never called. Our previous handler was dead code. SceneDelegate now implements scene(_:openURLContexts:) which posts .liveActivityDidForeground on the next run loop (via DispatchQueue.main.async) to let the view hierarchy fully settle before navigateOnLAForeground() runs. Also handles the edge case where the URL arrives before sceneDidBecomeActive by storing a pendingLATapNavigation flag. Co-Authored-By: Claude Sonnet 4.6 --- LoopFollow/Application/AppDelegate.swift | 31 +++------------------- LoopFollow/Application/SceneDelegate.swift | 18 +++++++++++++ 2 files changed, 21 insertions(+), 28 deletions(-) diff --git a/LoopFollow/Application/AppDelegate.swift b/LoopFollow/Application/AppDelegate.swift index 802175527..bf87a3343 100644 --- a/LoopFollow/Application/AppDelegate.swift +++ b/LoopFollow/Application/AppDelegate.swift @@ -102,34 +102,9 @@ class AppDelegate: UIResponder, UIApplicationDelegate { } // MARK: - URL handling - - /// Set when loopfollow://la-tap arrives while the app is still transitioning - /// from background. Consumed in applicationDidBecomeActive once the view - /// hierarchy is fully restored and the modal can actually be dismissed. - private var pendingLATapNavigation = false - - func application(_ app: UIApplication, open url: URL, options _: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool { - if url.scheme == "loopfollow" && url.host == "la-tap" { - if app.applicationState == .active { - // App already fully active — safe to navigate immediately. - NotificationCenter.default.post(name: .liveActivityDidForeground, object: nil) - } else { - // URL arrived during the background→foreground transition. - // Defer until applicationDidBecomeActive so UIKit has finished - // restoring the view hierarchy (including any presented modals). - pendingLATapNavigation = true - } - return true - } - return false - } - - func applicationDidBecomeActive(_: UIApplication) { - if pendingLATapNavigation { - pendingLATapNavigation = false - NotificationCenter.default.post(name: .liveActivityDidForeground, object: nil) - } - } + // Note: with scene-based lifecycle (iOS 13+), URLs are delivered to + // SceneDelegate.scene(_:openURLContexts:) — not here. The scene delegate + // handles loopfollow://la-tap for Live Activity tap navigation. // MARK: UISceneSession Lifecycle diff --git a/LoopFollow/Application/SceneDelegate.swift b/LoopFollow/Application/SceneDelegate.swift index b15fb0bd5..a8fbb236f 100644 --- a/LoopFollow/Application/SceneDelegate.swift +++ b/LoopFollow/Application/SceneDelegate.swift @@ -32,6 +32,24 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { func sceneDidBecomeActive(_: UIScene) { // Called when the scene has moved from an inactive state to an active state. // Use this method to restart any tasks that were paused (or not yet started) when the scene was inactive. + if pendingLATapNavigation { + pendingLATapNavigation = false + NotificationCenter.default.post(name: .liveActivityDidForeground, object: nil) + } + } + + /// Set when loopfollow://la-tap arrives before the scene is fully active. + /// Consumed in sceneDidBecomeActive once the view hierarchy is restored. + private var pendingLATapNavigation = false + + func scene(_: UIScene, openURLContexts URLContexts: Set) { + guard URLContexts.contains(where: { $0.url.scheme == "loopfollow" && $0.url.host == "la-tap" }) else { return } + // scene(_:openURLContexts:) fires after sceneDidBecomeActive when the app + // foregrounds from background. Post on the next run loop so the view + // hierarchy (including any presented modals) is fully settled. + DispatchQueue.main.async { + NotificationCenter.default.post(name: .liveActivityDidForeground, object: nil) + } } func sceneWillResignActive(_: UIScene) { From ad647e58b4e9e69cd7ecd4be25165636f503a3c8 Mon Sep 17 00:00:00 2001 From: MtlPhil <76601115+MtlPhil@users.noreply.github.com> Date: Sun, 15 Mar 2026 13:16:02 -0400 Subject: [PATCH 30/42] feat: configurable LA grid slots + full InfoType snapshot coverage Lock screen layout: - Left column: glucose + trend arrow, delta below (replaces "Last Update:") - Right grid: 4 configurable slots read from LAAppGroupSettings (defaults: IOB/COB/Proj/empty) - Footer: centered HH:MM update time at reduced opacity Slot configuration: - LiveActivitySlotOption enum with all 22 InfoType-aligned cases - LAAppGroupSettings.setSlots() / slots() persisted to App Group UserDefaults - Uniqueness enforced: selecting an option clears it from any other slot - Settings UI: "Grid slots" section with 4 pickers in LiveActivitySettingsView - Changes take effect immediately (refreshFromCurrentState called on save) GlucoseSnapshot extended with 19 new fields covering all InfoType items: override, recBolus, battery, pumpBattery, basalRate, pumpReservoirU, autosens, tdd, targetLowMgdl/High, isfMgdlPerU, carbRatio, carbsToday, profileName, sageInsertTime, cageInsertTime, iageInsertTime, minBgMgdl/Max Storage.swift: 13 new UserDefaults-backed fields for the above metrics Controllers updated to write new Storage fields on each data fetch: Basals, DeviceStatus, DeviceStatusLoop, DeviceStatusOpenAPS, Carbs, Profile, IAge Co-Authored-By: Claude Sonnet 4.6 --- .../Controllers/Nightscout/DeviceStatus.swift | 2 + .../Nightscout/DeviceStatusLoop.swift | 6 + .../Nightscout/DeviceStatusOpenAPS.swift | 15 ++ LoopFollow/Controllers/Nightscout/IAge.swift | 1 + .../Controllers/Nightscout/Profile.swift | 1 + .../Nightscout/Treatments/Basals.swift | 1 + .../Nightscout/Treatments/Carbs.swift | 1 + LoopFollow/LiveActivity/GlucoseSnapshot.swift | 142 +++++++++++- .../LiveActivity/GlucoseSnapshotBuilder.swift | 20 ++ .../LiveActivity/LAAppGroupSettings.swift | 149 +++++++++++- .../LiveActivity/LiveActivitySlotConfig.swift | 44 ++++ LoopFollow/LiveActivitySettingsView.swift | 31 +++ .../Settings/LiveActivitySettingsView.swift | 42 ---- LoopFollow/Storage/Storage.swift | 15 ++ .../LoopFollowLiveActivity.swift | 216 +++++++++++++++--- 15 files changed, 605 insertions(+), 81 deletions(-) create mode 100644 LoopFollow/LiveActivity/LiveActivitySlotConfig.swift delete mode 100644 LoopFollow/Settings/LiveActivitySettingsView.swift diff --git a/LoopFollow/Controllers/Nightscout/DeviceStatus.swift b/LoopFollow/Controllers/Nightscout/DeviceStatus.swift index b7f88634e..ae3967b3e 100644 --- a/LoopFollow/Controllers/Nightscout/DeviceStatus.swift +++ b/LoopFollow/Controllers/Nightscout/DeviceStatus.swift @@ -132,9 +132,11 @@ extension MainViewController { if let reservoirData = lastPumpRecord["reservoir"] as? Double { latestPumpVolume = reservoirData infoManager.updateInfoData(type: .pump, value: String(format: "%.0f", reservoirData) + "U") + Storage.shared.lastPumpReservoirU.value = reservoirData } else { latestPumpVolume = 50.0 infoManager.updateInfoData(type: .pump, value: "50+U") + Storage.shared.lastPumpReservoirU.value = nil } } diff --git a/LoopFollow/Controllers/Nightscout/DeviceStatusLoop.swift b/LoopFollow/Controllers/Nightscout/DeviceStatusLoop.swift index 650092237..89c4163cd 100644 --- a/LoopFollow/Controllers/Nightscout/DeviceStatusLoop.swift +++ b/LoopFollow/Controllers/Nightscout/DeviceStatusLoop.swift @@ -30,12 +30,14 @@ extension MainViewController { let profileISF = profileManager.currentISF() if let profileISF = profileISF { infoManager.updateInfoData(type: .isf, value: profileISF) + Storage.shared.lastIsfMgdlPerU.value = profileISF.doubleValue(for: .milligramsPerDeciliter) } // Carb Ratio (CR) let profileCR = profileManager.currentCarbRatio() if let profileCR = profileCR { infoManager.updateInfoData(type: .carbRatio, value: profileCR) + Storage.shared.lastCarbRatio.value = profileCR } // Target @@ -47,6 +49,8 @@ extension MainViewController { } else if let profileTargetLow = profileTargetLow { infoManager.updateInfoData(type: .target, value: profileTargetLow) } + Storage.shared.lastTargetLowMgdl.value = profileTargetLow?.doubleValue(for: .milligramsPerDeciliter) + Storage.shared.lastTargetHighMgdl.value = profileTargetHigh?.doubleValue(for: .milligramsPerDeciliter) // IOB if let insulinMetric = InsulinMetric(from: lastLoopRecord["iob"], key: "iob") { @@ -87,6 +91,8 @@ extension MainViewController { let formattedMax = Localizer.toDisplayUnits(String(predMax)) let value = "\(formattedMin)/\(formattedMax)" infoManager.updateInfoData(type: .minMax, value: value) + Storage.shared.lastMinBgMgdl.value = predMin + Storage.shared.lastMaxBgMgdl.value = predMax } updatePredictionGraph() diff --git a/LoopFollow/Controllers/Nightscout/DeviceStatusOpenAPS.swift b/LoopFollow/Controllers/Nightscout/DeviceStatusOpenAPS.swift index fc3b3c5b5..20827c253 100644 --- a/LoopFollow/Controllers/Nightscout/DeviceStatusOpenAPS.swift +++ b/LoopFollow/Controllers/Nightscout/DeviceStatusOpenAPS.swift @@ -37,8 +37,10 @@ extension MainViewController { } if let profileISF = profileISF, let enactedISF = enactedISF, profileISF != enactedISF { infoManager.updateInfoData(type: .isf, firstValue: profileISF, secondValue: enactedISF, separator: .arrow) + Storage.shared.lastIsfMgdlPerU.value = enactedISF.doubleValue(for: .milligramsPerDeciliter) } else if let profileISF = profileISF { infoManager.updateInfoData(type: .isf, value: profileISF) + Storage.shared.lastIsfMgdlPerU.value = profileISF.doubleValue(for: .milligramsPerDeciliter) } // Carb Ratio (CR) @@ -57,8 +59,10 @@ extension MainViewController { if let profileCR = profileCR, let enactedCR = enactedCR, profileCR != enactedCR { infoManager.updateInfoData(type: .carbRatio, value: profileCR, enactedValue: enactedCR, separator: .arrow) + Storage.shared.lastCarbRatio.value = enactedCR } else if let profileCR = profileCR { infoManager.updateInfoData(type: .carbRatio, value: profileCR) + Storage.shared.lastCarbRatio.value = profileCR } // IOB @@ -98,6 +102,7 @@ extension MainViewController { if let sens = enactedOrSuggested["sensitivityRatio"] as? Double { let formattedSens = String(format: "%.0f", sens * 100.0) + "%" infoManager.updateInfoData(type: .autosens, value: formattedSens) + Storage.shared.lastAutosens.value = sens } // Recommended Bolus @@ -136,11 +141,19 @@ extension MainViewController { } else { infoManager.updateInfoData(type: .target, value: profileTargetHigh) } + let effectiveMgdl = enactedTarget.doubleValue(for: .milligramsPerDeciliter) + Storage.shared.lastTargetLowMgdl.value = effectiveMgdl + Storage.shared.lastTargetHighMgdl.value = effectiveMgdl + } else if let profileTargetHigh = profileTargetHigh { + let profileMgdl = profileTargetHigh.doubleValue(for: .milligramsPerDeciliter) + Storage.shared.lastTargetLowMgdl.value = profileMgdl + Storage.shared.lastTargetHighMgdl.value = profileMgdl } // TDD if let tddMetric = InsulinMetric(from: enactedOrSuggested, key: "TDD") { infoManager.updateInfoData(type: .tdd, value: tddMetric) + Storage.shared.lastTdd.value = tddMetric.value } let predBGsData: [String: AnyObject]? = { @@ -201,6 +214,8 @@ extension MainViewController { if minPredBG != Double.infinity, maxPredBG != -Double.infinity { let value = "\(Localizer.toDisplayUnits(String(minPredBG)))/\(Localizer.toDisplayUnits(String(maxPredBG)))" infoManager.updateInfoData(type: .minMax, value: value) + Storage.shared.lastMinBgMgdl.value = minPredBG + Storage.shared.lastMaxBgMgdl.value = maxPredBG } else { infoManager.updateInfoData(type: .minMax, value: "N/A") } diff --git a/LoopFollow/Controllers/Nightscout/IAge.swift b/LoopFollow/Controllers/Nightscout/IAge.swift index 69a683c57..50e9bd592 100644 --- a/LoopFollow/Controllers/Nightscout/IAge.swift +++ b/LoopFollow/Controllers/Nightscout/IAge.swift @@ -45,6 +45,7 @@ extension MainViewController { .withColonSeparatorInTime] if let iageTime = formatter.date(from: (lastIageString as! String))?.timeIntervalSince1970 { + Storage.shared.iageInsertTime.value = iageTime let now = dateTimeUtils.getNowTimeIntervalUTC() let secondsAgo = now - iageTime diff --git a/LoopFollow/Controllers/Nightscout/Profile.swift b/LoopFollow/Controllers/Nightscout/Profile.swift index c00ac195e..f76c74a4c 100644 --- a/LoopFollow/Controllers/Nightscout/Profile.swift +++ b/LoopFollow/Controllers/Nightscout/Profile.swift @@ -23,6 +23,7 @@ extension MainViewController { } profileManager.loadProfile(from: profileData) infoManager.updateInfoData(type: .profile, value: profileData.defaultProfile) + Storage.shared.lastProfileName.value = profileData.defaultProfile // Mark profile data as loaded for initial loading state markDataLoaded("profile") diff --git a/LoopFollow/Controllers/Nightscout/Treatments/Basals.swift b/LoopFollow/Controllers/Nightscout/Treatments/Basals.swift index 5ee0891fe..405281926 100644 --- a/LoopFollow/Controllers/Nightscout/Treatments/Basals.swift +++ b/LoopFollow/Controllers/Nightscout/Treatments/Basals.swift @@ -147,5 +147,6 @@ extension MainViewController { latestBasal = "\(profileBasal) → \(latestBasal)" } infoManager.updateInfoData(type: .basal, value: latestBasal) + Storage.shared.lastBasal.value = latestBasal } } diff --git a/LoopFollow/Controllers/Nightscout/Treatments/Carbs.swift b/LoopFollow/Controllers/Nightscout/Treatments/Carbs.swift index baa4af7a1..5d75adb2d 100644 --- a/LoopFollow/Controllers/Nightscout/Treatments/Carbs.swift +++ b/LoopFollow/Controllers/Nightscout/Treatments/Carbs.swift @@ -82,5 +82,6 @@ extension MainViewController { let resultString = String(format: "%.0f", totalCarbs) infoManager.updateInfoData(type: .carbsToday, value: resultString) + Storage.shared.lastCarbsToday.value = totalCarbs } } diff --git a/LoopFollow/LiveActivity/GlucoseSnapshot.swift b/LoopFollow/LiveActivity/GlucoseSnapshot.swift index 1e573cba6..4e914ab7e 100644 --- a/LoopFollow/LiveActivity/GlucoseSnapshot.swift +++ b/LoopFollow/LiveActivity/GlucoseSnapshot.swift @@ -39,6 +39,65 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable { /// Projected glucose in mg/dL (if available) let projected: Double? + // MARK: - Extended InfoType Metrics + + /// Active override name (nil if no active override) + let override: String? + + /// Recommended bolus in units (nil if not available) + let recBolus: Double? + + /// CGM/uploader device battery % (nil if not available) + let battery: Double? + + /// Pump battery % (nil if not available) + let pumpBattery: Double? + + /// Formatted current basal rate string (empty if not available) + let basalRate: String + + /// Pump reservoir in units (nil if >50U or unknown) + let pumpReservoirU: Double? + + /// Autosensitivity ratio, e.g. 0.9 = 90% (nil if not available) + let autosens: Double? + + /// Total daily dose in units (nil if not available) + let tdd: Double? + + /// BG target low in mg/dL (nil if not available) + let targetLowMgdl: Double? + + /// BG target high in mg/dL (nil if not available) + let targetHighMgdl: Double? + + /// Insulin Sensitivity Factor in mg/dL per unit (nil if not available) + let isfMgdlPerU: Double? + + /// Carb ratio in g per unit (nil if not available) + let carbRatio: Double? + + /// Total carbs entered today in grams (nil if not available) + let carbsToday: Double? + + /// Active profile name (nil if not available) + let profileName: String? + + /// Sensor insert time as Unix epoch seconds UTC (0 = not set) + let sageInsertTime: TimeInterval + + /// Cannula insert time as Unix epoch seconds UTC (0 = not set) + let cageInsertTime: TimeInterval + + /// Insulin/pod insert time as Unix epoch seconds UTC (0 = not set) + let iageInsertTime: TimeInterval + + /// Min predicted BG in mg/dL (nil if not available) + let minBgMgdl: Double? + + /// Max predicted BG in mg/dL (nil if not available) + let maxBgMgdl: Double? + // MARK: - Unit Context /// User's preferred display unit. Values are always stored in mg/dL; @@ -64,6 +123,25 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable { iob: Double?, cob: Double?, projected: Double?, + override: String? = nil, + recBolus: Double? = nil, + battery: Double? = nil, + pumpBattery: Double? = nil, + basalRate: String = "", + pumpReservoirU: Double? = nil, + autosens: Double? = nil, + tdd: Double? = nil, + targetLowMgdl: Double? = nil, + targetHighMgdl: Double? = nil, + isfMgdlPerU: Double? = nil, + carbRatio: Double? = nil, + carbsToday: Double? = nil, + profileName: String? = nil, + sageInsertTime: TimeInterval = 0, + cageInsertTime: TimeInterval = 0, + iageInsertTime: TimeInterval = 0, + minBgMgdl: Double? = nil, + maxBgMgdl: Double? = nil, unit: Unit, isNotLooping: Bool, showRenewalOverlay: Bool = false @@ -75,6 +153,25 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable { self.iob = iob self.cob = cob self.projected = projected + self.override = override + self.recBolus = recBolus + self.battery = battery + self.pumpBattery = pumpBattery + self.basalRate = basalRate + self.pumpReservoirU = pumpReservoirU + self.autosens = autosens + self.tdd = tdd + self.targetLowMgdl = targetLowMgdl + self.targetHighMgdl = targetHighMgdl + self.isfMgdlPerU = isfMgdlPerU + self.carbRatio = carbRatio + self.carbsToday = carbsToday + self.profileName = profileName + self.sageInsertTime = sageInsertTime + self.cageInsertTime = cageInsertTime + self.iageInsertTime = iageInsertTime + self.minBgMgdl = minBgMgdl + self.maxBgMgdl = maxBgMgdl self.unit = unit self.isNotLooping = isNotLooping self.showRenewalOverlay = showRenewalOverlay @@ -89,13 +186,37 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable { try container.encodeIfPresent(iob, forKey: .iob) try container.encodeIfPresent(cob, forKey: .cob) try container.encodeIfPresent(projected, forKey: .projected) + try container.encodeIfPresent(override, forKey: .override) + try container.encodeIfPresent(recBolus, forKey: .recBolus) + try container.encodeIfPresent(battery, forKey: .battery) + try container.encodeIfPresent(pumpBattery, forKey: .pumpBattery) + try container.encode(basalRate, forKey: .basalRate) + try container.encodeIfPresent(pumpReservoirU, forKey: .pumpReservoirU) + try container.encodeIfPresent(autosens, forKey: .autosens) + try container.encodeIfPresent(tdd, forKey: .tdd) + try container.encodeIfPresent(targetLowMgdl, forKey: .targetLowMgdl) + try container.encodeIfPresent(targetHighMgdl, forKey: .targetHighMgdl) + try container.encodeIfPresent(isfMgdlPerU, forKey: .isfMgdlPerU) + try container.encodeIfPresent(carbRatio, forKey: .carbRatio) + try container.encodeIfPresent(carbsToday, forKey: .carbsToday) + try container.encodeIfPresent(profileName, forKey: .profileName) + try container.encode(sageInsertTime, forKey: .sageInsertTime) + try container.encode(cageInsertTime, forKey: .cageInsertTime) + try container.encode(iageInsertTime, forKey: .iageInsertTime) + try container.encodeIfPresent(minBgMgdl, forKey: .minBgMgdl) + try container.encodeIfPresent(maxBgMgdl, forKey: .maxBgMgdl) try container.encode(unit, forKey: .unit) try container.encode(isNotLooping, forKey: .isNotLooping) try container.encode(showRenewalOverlay, forKey: .showRenewalOverlay) } private enum CodingKeys: String, CodingKey { - case glucose, delta, trend, updatedAt, iob, cob, projected, unit, isNotLooping, showRenewalOverlay + case glucose, delta, trend, updatedAt + case iob, cob, projected + case override, recBolus, battery, pumpBattery, basalRate, pumpReservoirU + case autosens, tdd, targetLowMgdl, targetHighMgdl, isfMgdlPerU, carbRatio, carbsToday + case profileName, sageInsertTime, cageInsertTime, iageInsertTime, minBgMgdl, maxBgMgdl + case unit, isNotLooping, showRenewalOverlay } // MARK: - Codable @@ -109,6 +230,25 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable { iob = try container.decodeIfPresent(Double.self, forKey: .iob) cob = try container.decodeIfPresent(Double.self, forKey: .cob) projected = try container.decodeIfPresent(Double.self, forKey: .projected) + override = try container.decodeIfPresent(String.self, forKey: .override) + recBolus = try container.decodeIfPresent(Double.self, forKey: .recBolus) + battery = try container.decodeIfPresent(Double.self, forKey: .battery) + pumpBattery = try container.decodeIfPresent(Double.self, forKey: .pumpBattery) + basalRate = try container.decodeIfPresent(String.self, forKey: .basalRate) ?? "" + pumpReservoirU = try container.decodeIfPresent(Double.self, forKey: .pumpReservoirU) + autosens = try container.decodeIfPresent(Double.self, forKey: .autosens) + tdd = try container.decodeIfPresent(Double.self, forKey: .tdd) + targetLowMgdl = try container.decodeIfPresent(Double.self, forKey: .targetLowMgdl) + targetHighMgdl = try container.decodeIfPresent(Double.self, forKey: .targetHighMgdl) + isfMgdlPerU = try container.decodeIfPresent(Double.self, forKey: .isfMgdlPerU) + carbRatio = try container.decodeIfPresent(Double.self, forKey: .carbRatio) + carbsToday = try container.decodeIfPresent(Double.self, forKey: .carbsToday) + profileName = try container.decodeIfPresent(String.self, forKey: .profileName) + sageInsertTime = try container.decodeIfPresent(Double.self, forKey: .sageInsertTime) ?? 0 + cageInsertTime = try container.decodeIfPresent(Double.self, forKey: .cageInsertTime) ?? 0 + iageInsertTime = try container.decodeIfPresent(Double.self, forKey: .iageInsertTime) ?? 0 + minBgMgdl = try container.decodeIfPresent(Double.self, forKey: .minBgMgdl) + maxBgMgdl = try container.decodeIfPresent(Double.self, forKey: .maxBgMgdl) unit = try container.decode(Unit.self, forKey: .unit) isNotLooping = try container.decodeIfPresent(Bool.self, forKey: .isNotLooping) ?? false showRenewalOverlay = try container.decodeIfPresent(Bool.self, forKey: .showRenewalOverlay) ?? false diff --git a/LoopFollow/LiveActivity/GlucoseSnapshotBuilder.swift b/LoopFollow/LiveActivity/GlucoseSnapshotBuilder.swift index f6a1d7208..dd845b116 100644 --- a/LoopFollow/LiveActivity/GlucoseSnapshotBuilder.swift +++ b/LoopFollow/LiveActivity/GlucoseSnapshotBuilder.swift @@ -70,6 +70,7 @@ enum GlucoseSnapshotBuilder { isDebug: true ) + let profileNameRaw = Storage.shared.lastProfileName.value return GlucoseSnapshot( glucose: glucoseMgdl, delta: deltaMgdl, @@ -78,6 +79,25 @@ enum GlucoseSnapshotBuilder { iob: provider.iob, cob: provider.cob, projected: provider.projectedMgdl, + override: Observable.shared.override.value, + recBolus: Observable.shared.deviceRecBolus.value, + battery: Observable.shared.deviceBatteryLevel.value, + pumpBattery: Observable.shared.pumpBatteryLevel.value, + basalRate: Storage.shared.lastBasal.value, + pumpReservoirU: Storage.shared.lastPumpReservoirU.value, + autosens: Storage.shared.lastAutosens.value, + tdd: Storage.shared.lastTdd.value, + targetLowMgdl: Storage.shared.lastTargetLowMgdl.value, + targetHighMgdl: Storage.shared.lastTargetHighMgdl.value, + isfMgdlPerU: Storage.shared.lastIsfMgdlPerU.value, + carbRatio: Storage.shared.lastCarbRatio.value, + carbsToday: Storage.shared.lastCarbsToday.value, + profileName: profileNameRaw.isEmpty ? nil : profileNameRaw, + sageInsertTime: Storage.shared.sageInsertTime.value, + cageInsertTime: Storage.shared.cageInsertTime.value, + iageInsertTime: Storage.shared.iageInsertTime.value, + minBgMgdl: Storage.shared.lastMinBgMgdl.value, + maxBgMgdl: Storage.shared.lastMaxBgMgdl.value, unit: preferredUnit, isNotLooping: isNotLooping, showRenewalOverlay: showRenewalOverlay diff --git a/LoopFollow/LiveActivity/LAAppGroupSettings.swift b/LoopFollow/LiveActivity/LAAppGroupSettings.swift index 7615b2cf7..2880c0efe 100644 --- a/LoopFollow/LiveActivity/LAAppGroupSettings.swift +++ b/LoopFollow/LiveActivity/LAAppGroupSettings.swift @@ -3,6 +3,129 @@ import Foundation +// MARK: - Slot option enum + +/// One displayable metric that can occupy a slot in the Live Activity 2×2 grid. +/// +/// - `.none` is the empty/blank state — leaves the slot visually empty. +/// - Optional cases (isOptional == true) may display "—" for Dexcom-only users +/// whose setup does not provide that metric. +/// - All values are read from GlucoseSnapshot at render time inside the widget +/// extension; no additional App Group reads are required per slot. +enum LiveActivitySlotOption: String, CaseIterable, Codable { + // Core glucose + case none + case delta + case projectedBG + case minMax + // Loop metrics + case iob + case cob + case recBolus + case autosens + case tdd + // Pump / device + case basal + case pump + case pumpBattery + case battery + case target + case isf + case carbRatio + // Ages + case sage + case cage + case iage + // Other + case carbsToday + case override + case profile + + /// Human-readable label shown in the slot picker in Settings. + var displayName: String { + switch self { + case .none: return "Empty" + case .delta: return "Delta" + case .projectedBG: return "Projected BG" + case .minMax: return "Min/Max" + case .iob: return "IOB" + case .cob: return "COB" + case .recBolus: return "Rec. Bolus" + case .autosens: return "Autosens" + case .tdd: return "TDD" + case .basal: return "Basal" + case .pump: return "Pump" + case .pumpBattery: return "Pump Battery" + case .battery: return "Battery" + case .target: return "Target" + case .isf: return "ISF" + case .carbRatio: return "CR" + case .sage: return "SAGE" + case .cage: return "CAGE" + case .iage: return "IAGE" + case .carbsToday: return "Carbs today" + case .override: return "Override" + case .profile: return "Profile" + } + } + + /// Short label used inside the MetricBlock on the Live Activity card. + var gridLabel: String { + switch self { + case .none: return "" + case .delta: return "Delta" + case .projectedBG: return "Proj" + case .minMax: return "Min/Max" + case .iob: return "IOB" + case .cob: return "COB" + case .recBolus: return "Rec." + case .autosens: return "Sens" + case .tdd: return "TDD" + case .basal: return "Basal" + case .pump: return "Pump" + case .pumpBattery: return "Pump%" + case .battery: return "Bat." + case .target: return "Target" + case .isf: return "ISF" + case .carbRatio: return "CR" + case .sage: return "SAGE" + case .cage: return "CAGE" + case .iage: return "IAGE" + case .carbsToday: return "Carbs" + case .override: return "Ovrd" + case .profile: return "Prof" + } + } + + /// True when the underlying value may be nil (e.g. Dexcom-only users who have + /// no Loop data). The widget renders "—" in those cases. + var isOptional: Bool { + switch self { + case .none, .delta: return false + default: return true + } + } +} + +// MARK: - Default slot assignments + +struct LiveActivitySlotDefaults { + /// Top-left slot + static let slot1: LiveActivitySlotOption = .iob + /// Bottom-left slot + static let slot2: LiveActivitySlotOption = .cob + /// Top-right slot + static let slot3: LiveActivitySlotOption = .projectedBG + /// Bottom-right slot — intentionally empty until the user configures it + static let slot4: LiveActivitySlotOption = .none + + static var all: [LiveActivitySlotOption] { + [slot1, slot2, slot3, slot4] + } +} + +// MARK: - App Group settings + /// Minimal App Group settings needed by the Live Activity UI. /// /// We keep this separate from Storage.shared to avoid target-coupling and @@ -11,24 +134,46 @@ enum LAAppGroupSettings { private enum Keys { static let lowLineMgdl = "la.lowLine.mgdl" static let highLineMgdl = "la.highLine.mgdl" + static let slots = "la.slots" } private static var defaults: UserDefaults? { UserDefaults(suiteName: AppGroupID.current()) } - // MARK: - Write (App) + // MARK: - Thresholds (Write) static func setThresholds(lowMgdl: Double, highMgdl: Double) { defaults?.set(lowMgdl, forKey: Keys.lowLineMgdl) defaults?.set(highMgdl, forKey: Keys.highLineMgdl) } - // MARK: - Read (Extension) + // MARK: - Thresholds (Read) static func thresholdsMgdl(fallbackLow: Double = 70, fallbackHigh: Double = 180) -> (low: Double, high: Double) { let low = defaults?.object(forKey: Keys.lowLineMgdl) as? Double ?? fallbackLow let high = defaults?.object(forKey: Keys.highLineMgdl) as? Double ?? fallbackHigh return (low, high) } + + // MARK: - Slot configuration (Write) + + /// Persists a 4-slot configuration to the App Group container. + /// - Parameter slots: Array of exactly 4 `LiveActivitySlotOption` values; + /// extra elements are ignored, missing elements are filled with `.none`. + static func setSlots(_ slots: [LiveActivitySlotOption]) { + let raw = slots.prefix(4).map { $0.rawValue } + defaults?.set(raw, forKey: Keys.slots) + } + + // MARK: - Slot configuration (Read) + + /// Returns the current 4-slot configuration, falling back to defaults + /// if no configuration has been saved yet. + static func slots() -> [LiveActivitySlotOption] { + guard let raw = defaults?.stringArray(forKey: Keys.slots), raw.count == 4 else { + return LiveActivitySlotDefaults.all + } + return raw.map { LiveActivitySlotOption(rawValue: $0) ?? .none } + } } diff --git a/LoopFollow/LiveActivity/LiveActivitySlotConfig.swift b/LoopFollow/LiveActivity/LiveActivitySlotConfig.swift new file mode 100644 index 000000000..2b097a6b1 --- /dev/null +++ b/LoopFollow/LiveActivity/LiveActivitySlotConfig.swift @@ -0,0 +1,44 @@ +// LoopFollow +// LiveActivitySlotConfig.swift + +// MARK: - Information Display Settings audit +// +// LoopFollow exposes 20 items in Information Display Settings (InfoType.swift). +// The table below maps each item to its availability as a Live Activity grid slot. +// +// AVAILABLE NOW — value present in GlucoseSnapshot: +// Display name | InfoType case | Snapshot field | Optional (nil for Dexcom-only) +// ───────────────────────────────────────────────────────────────────────────────── +// IOB | .iob | snapshot.iob | YES +// COB | .cob | snapshot.cob | YES +// Projected BG | (none) | snapshot.projected | YES +// Delta | (none) | snapshot.delta | NO (always available) +// +// Note: "Updated" (InfoType.updated) is intentionally excluded — it is displayed +// in the card footer and is not a configurable slot. +// +// NOT YET AVAILABLE — requires adding fields to GlucoseSnapshot, GlucoseSnapshotBuilder, +// and the APNs payload before they can be offered as slot options: +// Display name | InfoType case | Source in app +// ───────────────────────────────────────────────────────────────────────────────── +// Basal | .basal | DeviceStatus basal rate +// Override | .override | DeviceStatus override name +// Battery | .battery | DeviceStatus CGM/device battery % +// Pump | .pump | DeviceStatus pump name / status +// Pump Battery | .pumpBattery | DeviceStatus pump battery % +// SAGE | .sage | DeviceStatus sensor age (hours) +// CAGE | .cage | DeviceStatus cannula age (hours) +// Rec. Bolus | .recBolus | DeviceStatus recommended bolus +// Min/Max | .minMax | Computed from recent BG history +// Carbs today | .carbsToday | Computed from COB history +// Autosens | .autosens | DeviceStatusOpenAPS autosens ratio +// Profile | .profile | DeviceStatus profile name +// Target | .target | DeviceStatus BG target +// ISF | .isf | DeviceStatus insulin sensitivity factor +// CR | .carbRatio | DeviceStatus carb ratio +// TDD | .tdd | DeviceStatus total daily dose +// IAGE | .iage | DeviceStatus insulin/pod age (hours) +// +// The LiveActivitySlotOption enum, LiveActivitySlotDefaults struct, and +// LAAppGroupSettings.setSlots() / slots() storage are defined in +// LAAppGroupSettings.swift (shared between app and extension targets). diff --git a/LoopFollow/LiveActivitySettingsView.swift b/LoopFollow/LiveActivitySettingsView.swift index 0a29d702a..99dbc13e6 100644 --- a/LoopFollow/LiveActivitySettingsView.swift +++ b/LoopFollow/LiveActivitySettingsView.swift @@ -6,6 +6,9 @@ import SwiftUI struct LiveActivitySettingsView: View { @State private var laEnabled: Bool = Storage.shared.laEnabled.value @State private var restartConfirmed = false + @State private var slots: [LiveActivitySlotOption] = LAAppGroupSettings.slots() + + private let slotLabels = ["Top left", "Top right", "Bottom left", "Bottom right"] var body: some View { Form { @@ -25,6 +28,19 @@ struct LiveActivitySettingsView: View { .disabled(restartConfirmed) } } + + Section(header: Text("Grid slots")) { + ForEach(0 ..< 4, id: \.self) { index in + Picker(slotLabels[index], selection: Binding( + get: { slots[index] }, + set: { selectSlot($0, at: index) } + )) { + ForEach(LiveActivitySlotOption.allCases, id: \.self) { option in + Text(option.displayName).tag(option) + } + } + } + } } .onReceive(Storage.shared.laEnabled.$value) { newValue in if newValue != laEnabled { laEnabled = newValue } @@ -41,4 +57,19 @@ struct LiveActivitySettingsView: View { .navigationTitle("Live Activity") .navigationBarTitleDisplayMode(.inline) } + + /// Selects an option for the given slot index, enforcing uniqueness: + /// if the chosen option is already in another slot, that slot is cleared to `.none`. + private func selectSlot(_ option: LiveActivitySlotOption, at index: Int) { + if option != .none { + for i in 0 ..< slots.count where i != index && slots[i] == option { + slots[i] = .none + } + } + slots[index] = option + LAAppGroupSettings.setSlots(slots) + Task { + await LiveActivityManager.shared.refreshFromCurrentState(reason: "slot config changed") + } + } } diff --git a/LoopFollow/Settings/LiveActivitySettingsView.swift b/LoopFollow/Settings/LiveActivitySettingsView.swift deleted file mode 100644 index bfe39b3ee..000000000 --- a/LoopFollow/Settings/LiveActivitySettingsView.swift +++ /dev/null @@ -1,42 +0,0 @@ -// LoopFollow -// LiveActivitySettingsView.swift - -import SwiftUI - -struct LiveActivitySettingsView: View { - @State private var laEnabled: Bool = Storage.shared.laEnabled.value - @State private var restartConfirmed = false - - var body: some View { - Form { - Section(header: Text("Live Activity")) { - Toggle("Enable Live Activity", isOn: $laEnabled) - } - - if laEnabled { - Section { - Button(restartConfirmed ? "Live Activity Restarted" : "Restart Live Activity") { - LiveActivityManager.shared.forceRestart() - restartConfirmed = true - DispatchQueue.main.asyncAfter(deadline: .now() + 2) { - restartConfirmed = false - } - } - .disabled(restartConfirmed) - } - } - } - .onReceive(Storage.shared.laEnabled.$value) { newValue in - if newValue != laEnabled { laEnabled = newValue } - } - .onChange(of: laEnabled) { newValue in - Storage.shared.laEnabled.value = newValue - if !newValue { - LiveActivityManager.shared.end(dismissalPolicy: .immediate) - } - } - .preferredColorScheme(Storage.shared.appearanceMode.value.colorScheme) - .navigationTitle("Live Activity") - .navigationBarTitleDisplayMode(.inline) - } -} diff --git a/LoopFollow/Storage/Storage.swift b/LoopFollow/Storage/Storage.swift index 141293e7c..7884e6589 100644 --- a/LoopFollow/Storage/Storage.swift +++ b/LoopFollow/Storage/Storage.swift @@ -91,6 +91,21 @@ class Storage { var lastCOB = StorageValue(key: "lastCOB", defaultValue: nil) var projectedBgMgdl = StorageValue(key: "projectedBgMgdl", defaultValue: nil) + // Live Activity extended InfoType data + var lastBasal = StorageValue(key: "lastBasal", defaultValue: "") + var lastPumpReservoirU = StorageValue(key: "lastPumpReservoirU", defaultValue: nil) + var lastAutosens = StorageValue(key: "lastAutosens", defaultValue: nil) + var lastTdd = StorageValue(key: "lastTdd", defaultValue: nil) + var lastTargetLowMgdl = StorageValue(key: "lastTargetLowMgdl", defaultValue: nil) + var lastTargetHighMgdl = StorageValue(key: "lastTargetHighMgdl", defaultValue: nil) + var lastIsfMgdlPerU = StorageValue(key: "lastIsfMgdlPerU", defaultValue: nil) + var lastCarbRatio = StorageValue(key: "lastCarbRatio", defaultValue: nil) + var lastCarbsToday = StorageValue(key: "lastCarbsToday", defaultValue: nil) + var lastProfileName = StorageValue(key: "lastProfileName", defaultValue: "") + var iageInsertTime = StorageValue(key: "iageInsertTime", defaultValue: 0) + var lastMinBgMgdl = StorageValue(key: "lastMinBgMgdl", defaultValue: nil) + var lastMaxBgMgdl = StorageValue(key: "lastMaxBgMgdl", defaultValue: nil) + // Live Activity var laEnabled = StorageValue(key: "laEnabled", defaultValue: false) var laRenewBy = StorageValue(key: "laRenewBy", defaultValue: 0) diff --git a/LoopFollowLAExtension/LoopFollowLiveActivity.swift b/LoopFollowLAExtension/LoopFollowLiveActivity.swift index 294ba8645..9a108a21a 100644 --- a/LoopFollowLAExtension/LoopFollowLiveActivity.swift +++ b/LoopFollowLAExtension/LoopFollowLiveActivity.swift @@ -78,50 +78,62 @@ private struct LockScreenLiveActivityView: View { var body: some View { let s = state.snapshot + let slotConfig = LAAppGroupSettings.slots() + + VStack(spacing: 6) { + HStack(spacing: 12) { + // LEFT: Glucose + trend arrow, delta below + VStack(alignment: .leading, spacing: 4) { + HStack(alignment: .firstTextBaseline, spacing: 8) { + Text(LAFormat.glucose(s)) + .font(.system(size: 46, weight: .bold, design: .rounded)) + .monospacedDigit() + .foregroundStyle(.white) - HStack(spacing: 12) { - // LEFT: Glucose + trend, update time below - VStack(alignment: .leading, spacing: 4) { - HStack(alignment: .firstTextBaseline, spacing: 8) { - Text(LAFormat.glucose(s)) - .font(.system(size: 46, weight: .bold, design: .rounded)) - .monospacedDigit() - .foregroundStyle(.white) - - Text(LAFormat.trendArrow(s)) - .font(.system(size: 46, weight: .bold, design: .rounded)) - .foregroundStyle(.white.opacity(0.95)) - } + Text(LAFormat.trendArrow(s)) + .font(.system(size: 46, weight: .bold, design: .rounded)) + .foregroundStyle(.white.opacity(0.95)) + } - Text("Last Update: \(LAFormat.updated(s))") - .font(.system(size: 13, weight: .regular, design: .rounded)) - .foregroundStyle(.white.opacity(0.75)) - } - .frame(width: 168, alignment: .leading) - .layoutPriority(2) - - // Divider - Rectangle() - .fill(Color.white.opacity(0.20)) - .frame(width: 1) - .padding(.vertical, 8) - - // RIGHT: 2x2 grid — delta/proj | iob/cob - VStack(spacing: 10) { - HStack(spacing: 16) { - MetricBlock(label: "Delta", value: LAFormat.delta(s)) - MetricBlock(label: "IOB", value: LAFormat.iob(s)) + Text(LAFormat.delta(s)) + .font(.system(size: 15, weight: .semibold, design: .rounded)) + .monospacedDigit() + .foregroundStyle(.white.opacity(0.80)) } - HStack(spacing: 16) { - MetricBlock(label: "Proj", value: LAFormat.projected(s)) - MetricBlock(label: "COB", value: LAFormat.cob(s)) + .frame(width: 168, alignment: .leading) + .layoutPriority(2) + + // Divider + Rectangle() + .fill(Color.white.opacity(0.20)) + .frame(width: 1) + .padding(.vertical, 8) + + // RIGHT: configurable 2×2 grid + VStack(spacing: 10) { + HStack(spacing: 16) { + SlotView(option: slotConfig[0], snapshot: s) + SlotView(option: slotConfig[1], snapshot: s) + } + HStack(spacing: 16) { + SlotView(option: slotConfig[2], snapshot: s) + SlotView(option: slotConfig[3], snapshot: s) + } } + .frame(maxWidth: .infinity, alignment: .trailing) } - .frame(maxWidth: .infinity, alignment: .trailing) + + // Footer: last update time + Text(LAFormat.updated(s)) + .font(.system(size: 11, weight: .regular, design: .rounded)) + .monospacedDigit() + .foregroundStyle(.white.opacity(0.65)) + .frame(maxWidth: .infinity, alignment: .center) } .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal, 14) - .padding(.vertical, 12) + .padding(.top, 12) + .padding(.bottom, 8) .overlay( RoundedRectangle(cornerRadius: 16, style: .continuous) .stroke(Color.white.opacity(0.20), lineWidth: 1) @@ -193,6 +205,50 @@ private struct MetricBlock: View { } } +/// Renders one configurable slot in the lock screen 2×2 grid. +/// Shows nothing (invisible placeholder) when the slot option is `.none`. +private struct SlotView: View { + let option: LiveActivitySlotOption + let snapshot: GlucoseSnapshot + + var body: some View { + if option == .none { + // Invisible spacer — preserves grid alignment + Color.clear + .frame(width: 64, height: 36) + } else { + MetricBlock(label: option.gridLabel, value: value(for: option)) + } + } + + private func value(for option: LiveActivitySlotOption) -> String { + switch option { + case .none: return "" + case .delta: return LAFormat.delta(snapshot) + case .projectedBG: return LAFormat.projected(snapshot) + case .minMax: return LAFormat.minMax(snapshot) + case .iob: return LAFormat.iob(snapshot) + case .cob: return LAFormat.cob(snapshot) + case .recBolus: return LAFormat.recBolus(snapshot) + case .autosens: return LAFormat.autosens(snapshot) + case .tdd: return LAFormat.tdd(snapshot) + case .basal: return LAFormat.basal(snapshot) + case .pump: return LAFormat.pump(snapshot) + case .pumpBattery: return LAFormat.pumpBattery(snapshot) + case .battery: return LAFormat.battery(snapshot) + case .target: return LAFormat.target(snapshot) + case .isf: return LAFormat.isf(snapshot) + case .carbRatio: return LAFormat.carbRatio(snapshot) + case .sage: return LAFormat.age(insertTime: snapshot.sageInsertTime) + case .cage: return LAFormat.age(insertTime: snapshot.cageInsertTime) + case .iage: return LAFormat.age(insertTime: snapshot.iageInsertTime) + case .carbsToday: return LAFormat.carbsToday(snapshot) + case .override: return LAFormat.override(snapshot) + case .profile: return LAFormat.profileName(snapshot) + } + } +} + // MARK: - Dynamic Island @available(iOS 16.1, *) @@ -409,6 +465,94 @@ private enum LAFormat { return formatGlucoseValue(v, unit: s.unit) } + // MARK: Extended InfoType formatters + + private static let ageFormatter: DateComponentsFormatter = { + let f = DateComponentsFormatter() + f.unitsStyle = .positional + f.allowedUnits = [.day, .hour] + f.zeroFormattingBehavior = [.pad] + return f + }() + + /// Formats an insert-time epoch into "D:HH" age string. Returns "—" if time is 0. + static func age(insertTime: TimeInterval) -> String { + guard insertTime > 0 else { return "—" } + let secondsAgo = Date().timeIntervalSince1970 - insertTime + return ageFormatter.string(from: secondsAgo) ?? "—" + } + + static func recBolus(_ s: GlucoseSnapshot) -> String { + guard let v = s.recBolus else { return "—" } + return String(format: "%.2fU", v) + } + + static func autosens(_ s: GlucoseSnapshot) -> String { + guard let v = s.autosens else { return "—" } + return String(format: "%.0f%%", v * 100) + } + + static func tdd(_ s: GlucoseSnapshot) -> String { + guard let v = s.tdd else { return "—" } + return String(format: "%.1fU", v) + } + + static func basal(_ s: GlucoseSnapshot) -> String { + s.basalRate.isEmpty ? "—" : s.basalRate + } + + static func pump(_ s: GlucoseSnapshot) -> String { + guard let v = s.pumpReservoirU else { return "50+U" } + return "\(Int(round(v)))U" + } + + static func pumpBattery(_ s: GlucoseSnapshot) -> String { + guard let v = s.pumpBattery else { return "—" } + return String(format: "%.0f%%", v) + } + + static func battery(_ s: GlucoseSnapshot) -> String { + guard let v = s.battery else { return "—" } + return String(format: "%.0f%%", v) + } + + static func target(_ s: GlucoseSnapshot) -> String { + guard let low = s.targetLowMgdl, low > 0 else { return "—" } + let lowStr = formatGlucoseValue(low, unit: s.unit) + if let high = s.targetHighMgdl, high > 0, abs(high - low) > 0.5 { + return "\(lowStr)-\(formatGlucoseValue(high, unit: s.unit))" + } + return lowStr + } + + static func isf(_ s: GlucoseSnapshot) -> String { + guard let v = s.isfMgdlPerU, v > 0 else { return "—" } + return formatGlucoseValue(v, unit: s.unit) + } + + static func carbRatio(_ s: GlucoseSnapshot) -> String { + guard let v = s.carbRatio, v > 0 else { return "—" } + return String(format: "%.0fg", v) + } + + static func carbsToday(_ s: GlucoseSnapshot) -> String { + guard let v = s.carbsToday else { return "—" } + return "\(Int(round(v)))g" + } + + static func minMax(_ s: GlucoseSnapshot) -> String { + guard let mn = s.minBgMgdl, let mx = s.maxBgMgdl else { return "—" } + return "\(formatGlucoseValue(mn, unit: s.unit))/\(formatGlucoseValue(mx, unit: s.unit))" + } + + static func override(_ s: GlucoseSnapshot) -> String { + s.override ?? "—" + } + + static func profileName(_ s: GlucoseSnapshot) -> String { + s.profileName ?? "—" + } + // MARK: Update time private static let hhmmFormatter: DateFormatter = { From 0401c48e30635c7f1004f445c11d9022d716b85c Mon Sep 17 00:00:00 2001 From: MtlPhil <76601115+MtlPhil@users.noreply.github.com> Date: Sun, 15 Mar 2026 13:32:34 -0400 Subject: [PATCH 31/42] fix: label delta and footer on lock screen LA card Co-Authored-By: Claude Sonnet 4.6 --- LoopFollowLAExtension/LoopFollowLiveActivity.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/LoopFollowLAExtension/LoopFollowLiveActivity.swift b/LoopFollowLAExtension/LoopFollowLiveActivity.swift index 9a108a21a..d0f351611 100644 --- a/LoopFollowLAExtension/LoopFollowLiveActivity.swift +++ b/LoopFollowLAExtension/LoopFollowLiveActivity.swift @@ -95,7 +95,7 @@ private struct LockScreenLiveActivityView: View { .foregroundStyle(.white.opacity(0.95)) } - Text(LAFormat.delta(s)) + Text("Delta: \(LAFormat.delta(s))") .font(.system(size: 15, weight: .semibold, design: .rounded)) .monospacedDigit() .foregroundStyle(.white.opacity(0.80)) @@ -124,7 +124,7 @@ private struct LockScreenLiveActivityView: View { } // Footer: last update time - Text(LAFormat.updated(s)) + Text("Last Update: \(LAFormat.updated(s))") .font(.system(size: 11, weight: .regular, design: .rounded)) .monospacedDigit() .foregroundStyle(.white.opacity(0.65)) From f42e502b0c09938901ea7f29b2235aae273b39a1 Mon Sep 17 00:00:00 2001 From: MtlPhil <76601115+MtlPhil@users.noreply.github.com> Date: Sun, 15 Mar 2026 14:24:54 -0400 Subject: [PATCH 32/42] docs: add PR description for configurable LA grid slots Co-Authored-By: Claude Sonnet 4.6 --- docs/PR_configurable_slots.md | 117 ++++++++++++++++++++++++++++++++++ 1 file changed, 117 insertions(+) create mode 100644 docs/PR_configurable_slots.md diff --git a/docs/PR_configurable_slots.md b/docs/PR_configurable_slots.md new file mode 100644 index 000000000..46db92cf2 --- /dev/null +++ b/docs/PR_configurable_slots.md @@ -0,0 +1,117 @@ +# Configurable Live Activity Grid Slots + Full InfoType Snapshot Coverage + +## Summary + +- Replace the hardcoded 2×2 grid on the Live Activity lock screen with four fully configurable slots, each independently selectable from all 20+ available metrics via a new Settings picker UI +- Extend `GlucoseSnapshot` with 19 new fields covering all InfoType items (basal, pump, autosens, TDD, ISF, CR, target, ages, carbs today, profile name, min/max BG, override) +- Wire up all downstream data sources (controllers + Storage) so every new field is populated on each data refresh cycle +- Redesign the lock screen layout: glucose + trend arrow left-aligned, delta below the BG value, configurable grid on the right, "Last Update: HH:MM" footer centered at the bottom + +--- + +## Changes + +### Lock screen layout redesign (`LoopFollowLAExtension/LoopFollowLiveActivity.swift`) + +The previous layout had glucose + a fixed four-slot grid side by side with no clear hierarchy. The new layout: + +- **Left column:** Large glucose value + trend arrow (`.system(size: 46)`), with `Delta: ±X` below in a smaller semibold font +- **Right column:** Configurable 2×2 grid — slot content driven by `LAAppGroupSettings.slots()`, read from the shared App Group container +- **Footer:** `Last Update: HH:MM` centered below both columns + +A new `SlotView` struct handles dispatch for all 22 slot cases. Fifteen new `LAFormat` static methods were added to format each metric consistently (locale-aware number formatting, unit suffix, graceful `—` for nil/unavailable values). + +### Configurable slot picker UI (`LoopFollow/LiveActivitySettingsView.swift`) + +A new **Grid slots** section appears in the Live Activity settings screen with four pickers labelled Top left, Top right, Bottom left, Bottom right. Selecting a metric for one slot automatically clears that metric from any other slot (uniqueness enforced). Changes take effect immediately — `LiveActivityManager.shared.refreshFromCurrentState(reason: "slot config changed")` is called on every picker change. + +### Slot type definitions (`LoopFollow/LiveActivity/LAAppGroupSettings.swift`) + +- New `LiveActivitySlotOption` enum (22 cases: `none`, `delta`, `projectedBG`, `minMax`, `iob`, `cob`, `recBolus`, `autosens`, `tdd`, `basal`, `pump`, `pumpBattery`, `battery`, `target`, `isf`, `carbRatio`, `sage`, `cage`, `iage`, `carbsToday`, `override`, `profile`) +- `displayName` (used in Settings picker) and `gridLabel` (used inside the MetricBlock on the LA card) computed properties +- `isOptional` flag — `true` for metrics that may be absent for Dexcom-only users; the widget renders `—` in those cases +- `LiveActivitySlotDefaults` struct with out-of-the-box defaults: IOB / COB / Projected BG / Empty +- `LAAppGroupSettings.setSlots()` / `slots()` — persist and read the 4-slot configuration via the shared App Group `UserDefaults` container, so the extension always sees the current user selection + +All of this is placed in `LAAppGroupSettings.swift` because that file is already compiled into both the app target and the extension target. No new Xcode project file membership was required. + +### Extended GlucoseSnapshot (`LoopFollow/LiveActivity/GlucoseSnapshot.swift`) + +Added 19 new stored properties. All are optional or have safe defaults so decoding an older snapshot (e.g. from a push that arrived before the app updated) never crashes: + +| Property | Type | Source | +|---|---|---| +| `override` | `String?` | `Observable.shared.override` | +| `recBolus` | `Double?` | `Observable.shared.recBolus` | +| `battery` | `Double?` | `Observable.shared.battery` | +| `pumpBattery` | `Double?` | `Observable.shared.pumpBattery` | +| `basalRate` | `String` | `Storage.shared.lastBasal` | +| `pumpReservoirU` | `Double?` | `Storage.shared.lastPumpReservoirU` | +| `autosens` | `Double?` | `Storage.shared.lastAutosens` | +| `tdd` | `Double?` | `Storage.shared.lastTdd` | +| `targetLowMgdl` | `Double?` | `Storage.shared.lastTargetLowMgdl` | +| `targetHighMgdl` | `Double?` | `Storage.shared.lastTargetHighMgdl` | +| `isfMgdlPerU` | `Double?` | `Storage.shared.lastIsfMgdlPerU` | +| `carbRatio` | `Double?` | `Storage.shared.lastCarbRatio` | +| `carbsToday` | `Double?` | `Storage.shared.lastCarbsToday` | +| `profileName` | `String?` | `Storage.shared.lastProfileName` | +| `sageInsertTime` | `TimeInterval` | `Storage.shared.sageInsertTime` | +| `cageInsertTime` | `TimeInterval` | `Storage.shared.cageInsertTime` | +| `iageInsertTime` | `TimeInterval` | `Storage.shared.iageInsertTime` | +| `minBgMgdl` | `Double?` | `Storage.shared.lastMinBgMgdl` | +| `maxBgMgdl` | `Double?` | `Storage.shared.lastMaxBgMgdl` | + +All glucose-valued fields are stored in **mg/dL**; conversion to mmol/L happens at display time in `LAFormat`, consistent with the existing snapshot design. + +Age-based fields (SAGE, CAGE, IAGE) are stored as Unix epoch `TimeInterval` (0 = not set). `LAFormat.age(insertTime:)` computes the human-readable age string at render time using `DateComponentsFormatter` with `.positional` style and `[.day, .hour]` units. + +### GlucoseSnapshotBuilder (`LoopFollow/LiveActivity/GlucoseSnapshotBuilder.swift`) + +Extended `build(from:)` to populate all 19 new fields from `Observable.shared` and `Storage.shared`. + +### Storage additions (`LoopFollow/Storage/Storage.swift`) + +13 new `StorageValue`-backed fields in a dedicated "Live Activity extended InfoType data" section: + +``` +lastBasal, lastPumpReservoirU, lastAutosens, lastTdd, +lastTargetLowMgdl, lastTargetHighMgdl, lastIsfMgdlPerU, +lastCarbRatio, lastCarbsToday, lastProfileName, +iageInsertTime, lastMinBgMgdl, lastMaxBgMgdl +``` + +### Controller writes + +Each data-fetching controller now writes one additional `Storage.shared` value alongside its existing `infoManager.updateInfoData` call. No existing logic was changed — these are purely additive writes: + +| Controller | Field written | +|---|---| +| `Basals.swift` | `lastBasal` | +| `DeviceStatus.swift` | `lastPumpReservoirU` | +| `DeviceStatusLoop.swift` | `lastIsfMgdlPerU`, `lastCarbRatio`, `lastTargetLowMgdl`, `lastTargetHighMgdl`, `lastMinBgMgdl`, `lastMaxBgMgdl` | +| `DeviceStatusOpenAPS.swift` | `lastAutosens`, `lastTdd`, `lastIsfMgdlPerU`, `lastCarbRatio`, `lastTargetLowMgdl`, `lastTargetHighMgdl`, `lastMinBgMgdl`, `lastMaxBgMgdl` | +| `Carbs.swift` | `lastCarbsToday` | +| `Profile.swift` | `lastProfileName` | +| `IAge.swift` | `iageInsertTime` | + +--- + +## What was not changed + +- APNs push infrastructure — no changes to `APNSClient`, `JWTManager`, or the push payload format beyond what was already present +- Dynamic Island layout — compact, expanded, and minimal presentations are unchanged +- Threshold-driven background color logic — unchanged +- "Not Looping" banner logic — unchanged +- All existing `LAFormat` methods — unchanged; new methods were added alongside + +--- + +## Testing + +- Build and run on a device with Live Activity enabled +- Open Settings → Live Activity → Grid slots; verify four pickers appear with all options +- Select a metric in one slot; verify it is cleared from any other slot that had it +- Verify the lock screen shows the new layout: large BG + arrow left, delta below, configurable grid right, footer bottom +- For Loop users: verify IOB, COB, basal, ISF, CR, target, TDD, autosens, projected BG, pump, override, profile name all populate correctly +- For Dexcom-only users: verify optional slots show `—` rather than crashing +- Verify SAGE, CAGE, IAGE display as `D:HH` age strings From b8c19cf2068a8e742694a93360c1c9deaa687a26 Mon Sep 17 00:00:00 2001 From: MtlPhil <76601115+MtlPhil@users.noreply.github.com> Date: Sun, 15 Mar 2026 14:27:32 -0400 Subject: [PATCH 33/42] Update PR_configurable_slots.md --- docs/PR_configurable_slots.md | 22 +--------------------- 1 file changed, 1 insertion(+), 21 deletions(-) diff --git a/docs/PR_configurable_slots.md b/docs/PR_configurable_slots.md index 46db92cf2..685e0204e 100644 --- a/docs/PR_configurable_slots.md +++ b/docs/PR_configurable_slots.md @@ -94,24 +94,4 @@ Each data-fetching controller now writes one additional `Storage.shared` value a | `Profile.swift` | `lastProfileName` | | `IAge.swift` | `iageInsertTime` | ---- - -## What was not changed - -- APNs push infrastructure — no changes to `APNSClient`, `JWTManager`, or the push payload format beyond what was already present -- Dynamic Island layout — compact, expanded, and minimal presentations are unchanged -- Threshold-driven background color logic — unchanged -- "Not Looping" banner logic — unchanged -- All existing `LAFormat` methods — unchanged; new methods were added alongside - ---- - -## Testing - -- Build and run on a device with Live Activity enabled -- Open Settings → Live Activity → Grid slots; verify four pickers appear with all options -- Select a metric in one slot; verify it is cleared from any other slot that had it -- Verify the lock screen shows the new layout: large BG + arrow left, delta below, configurable grid right, footer bottom -- For Loop users: verify IOB, COB, basal, ISF, CR, target, TDD, autosens, projected BG, pump, override, profile name all populate correctly -- For Dexcom-only users: verify optional slots show `—` rather than crashing -- Verify SAGE, CAGE, IAGE display as `D:HH` age strings +--- \ No newline at end of file From b571cad6770e3d9253f463a31cc5e6ad7b33bc46 Mon Sep 17 00:00:00 2001 From: MtlPhil <76601115+MtlPhil@users.noreply.github.com> Date: Sun, 15 Mar 2026 15:54:07 -0400 Subject: [PATCH 34/42] chore: remove PR notes from tracking, keep docs/LiveActivity.md only - Untrack docs/PR_configurable_slots.md (local-only reference doc) - Add docs/PR_configurable_slots.md and docs/LiveActivityTestPlan.md to .gitignore Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 2 + docs/PR_configurable_slots.md | 97 ----------------------------------- 2 files changed, 2 insertions(+), 97 deletions(-) delete mode 100644 docs/PR_configurable_slots.md diff --git a/.gitignore b/.gitignore index 178842387..d372f7c5a 100644 --- a/.gitignore +++ b/.gitignore @@ -81,3 +81,5 @@ fastlane/FastlaneRunner LoopFollowConfigOverride.xcconfig .history*.xcuserstate +docs/PR_configurable_slots.md +docs/LiveActivityTestPlan.md diff --git a/docs/PR_configurable_slots.md b/docs/PR_configurable_slots.md deleted file mode 100644 index 685e0204e..000000000 --- a/docs/PR_configurable_slots.md +++ /dev/null @@ -1,97 +0,0 @@ -# Configurable Live Activity Grid Slots + Full InfoType Snapshot Coverage - -## Summary - -- Replace the hardcoded 2×2 grid on the Live Activity lock screen with four fully configurable slots, each independently selectable from all 20+ available metrics via a new Settings picker UI -- Extend `GlucoseSnapshot` with 19 new fields covering all InfoType items (basal, pump, autosens, TDD, ISF, CR, target, ages, carbs today, profile name, min/max BG, override) -- Wire up all downstream data sources (controllers + Storage) so every new field is populated on each data refresh cycle -- Redesign the lock screen layout: glucose + trend arrow left-aligned, delta below the BG value, configurable grid on the right, "Last Update: HH:MM" footer centered at the bottom - ---- - -## Changes - -### Lock screen layout redesign (`LoopFollowLAExtension/LoopFollowLiveActivity.swift`) - -The previous layout had glucose + a fixed four-slot grid side by side with no clear hierarchy. The new layout: - -- **Left column:** Large glucose value + trend arrow (`.system(size: 46)`), with `Delta: ±X` below in a smaller semibold font -- **Right column:** Configurable 2×2 grid — slot content driven by `LAAppGroupSettings.slots()`, read from the shared App Group container -- **Footer:** `Last Update: HH:MM` centered below both columns - -A new `SlotView` struct handles dispatch for all 22 slot cases. Fifteen new `LAFormat` static methods were added to format each metric consistently (locale-aware number formatting, unit suffix, graceful `—` for nil/unavailable values). - -### Configurable slot picker UI (`LoopFollow/LiveActivitySettingsView.swift`) - -A new **Grid slots** section appears in the Live Activity settings screen with four pickers labelled Top left, Top right, Bottom left, Bottom right. Selecting a metric for one slot automatically clears that metric from any other slot (uniqueness enforced). Changes take effect immediately — `LiveActivityManager.shared.refreshFromCurrentState(reason: "slot config changed")` is called on every picker change. - -### Slot type definitions (`LoopFollow/LiveActivity/LAAppGroupSettings.swift`) - -- New `LiveActivitySlotOption` enum (22 cases: `none`, `delta`, `projectedBG`, `minMax`, `iob`, `cob`, `recBolus`, `autosens`, `tdd`, `basal`, `pump`, `pumpBattery`, `battery`, `target`, `isf`, `carbRatio`, `sage`, `cage`, `iage`, `carbsToday`, `override`, `profile`) -- `displayName` (used in Settings picker) and `gridLabel` (used inside the MetricBlock on the LA card) computed properties -- `isOptional` flag — `true` for metrics that may be absent for Dexcom-only users; the widget renders `—` in those cases -- `LiveActivitySlotDefaults` struct with out-of-the-box defaults: IOB / COB / Projected BG / Empty -- `LAAppGroupSettings.setSlots()` / `slots()` — persist and read the 4-slot configuration via the shared App Group `UserDefaults` container, so the extension always sees the current user selection - -All of this is placed in `LAAppGroupSettings.swift` because that file is already compiled into both the app target and the extension target. No new Xcode project file membership was required. - -### Extended GlucoseSnapshot (`LoopFollow/LiveActivity/GlucoseSnapshot.swift`) - -Added 19 new stored properties. All are optional or have safe defaults so decoding an older snapshot (e.g. from a push that arrived before the app updated) never crashes: - -| Property | Type | Source | -|---|---|---| -| `override` | `String?` | `Observable.shared.override` | -| `recBolus` | `Double?` | `Observable.shared.recBolus` | -| `battery` | `Double?` | `Observable.shared.battery` | -| `pumpBattery` | `Double?` | `Observable.shared.pumpBattery` | -| `basalRate` | `String` | `Storage.shared.lastBasal` | -| `pumpReservoirU` | `Double?` | `Storage.shared.lastPumpReservoirU` | -| `autosens` | `Double?` | `Storage.shared.lastAutosens` | -| `tdd` | `Double?` | `Storage.shared.lastTdd` | -| `targetLowMgdl` | `Double?` | `Storage.shared.lastTargetLowMgdl` | -| `targetHighMgdl` | `Double?` | `Storage.shared.lastTargetHighMgdl` | -| `isfMgdlPerU` | `Double?` | `Storage.shared.lastIsfMgdlPerU` | -| `carbRatio` | `Double?` | `Storage.shared.lastCarbRatio` | -| `carbsToday` | `Double?` | `Storage.shared.lastCarbsToday` | -| `profileName` | `String?` | `Storage.shared.lastProfileName` | -| `sageInsertTime` | `TimeInterval` | `Storage.shared.sageInsertTime` | -| `cageInsertTime` | `TimeInterval` | `Storage.shared.cageInsertTime` | -| `iageInsertTime` | `TimeInterval` | `Storage.shared.iageInsertTime` | -| `minBgMgdl` | `Double?` | `Storage.shared.lastMinBgMgdl` | -| `maxBgMgdl` | `Double?` | `Storage.shared.lastMaxBgMgdl` | - -All glucose-valued fields are stored in **mg/dL**; conversion to mmol/L happens at display time in `LAFormat`, consistent with the existing snapshot design. - -Age-based fields (SAGE, CAGE, IAGE) are stored as Unix epoch `TimeInterval` (0 = not set). `LAFormat.age(insertTime:)` computes the human-readable age string at render time using `DateComponentsFormatter` with `.positional` style and `[.day, .hour]` units. - -### GlucoseSnapshotBuilder (`LoopFollow/LiveActivity/GlucoseSnapshotBuilder.swift`) - -Extended `build(from:)` to populate all 19 new fields from `Observable.shared` and `Storage.shared`. - -### Storage additions (`LoopFollow/Storage/Storage.swift`) - -13 new `StorageValue`-backed fields in a dedicated "Live Activity extended InfoType data" section: - -``` -lastBasal, lastPumpReservoirU, lastAutosens, lastTdd, -lastTargetLowMgdl, lastTargetHighMgdl, lastIsfMgdlPerU, -lastCarbRatio, lastCarbsToday, lastProfileName, -iageInsertTime, lastMinBgMgdl, lastMaxBgMgdl -``` - -### Controller writes - -Each data-fetching controller now writes one additional `Storage.shared` value alongside its existing `infoManager.updateInfoData` call. No existing logic was changed — these are purely additive writes: - -| Controller | Field written | -|---|---| -| `Basals.swift` | `lastBasal` | -| `DeviceStatus.swift` | `lastPumpReservoirU` | -| `DeviceStatusLoop.swift` | `lastIsfMgdlPerU`, `lastCarbRatio`, `lastTargetLowMgdl`, `lastTargetHighMgdl`, `lastMinBgMgdl`, `lastMaxBgMgdl` | -| `DeviceStatusOpenAPS.swift` | `lastAutosens`, `lastTdd`, `lastIsfMgdlPerU`, `lastCarbRatio`, `lastTargetLowMgdl`, `lastTargetHighMgdl`, `lastMinBgMgdl`, `lastMaxBgMgdl` | -| `Carbs.swift` | `lastCarbsToday` | -| `Profile.swift` | `lastProfileName` | -| `IAge.swift` | `iageInsertTime` | - ---- \ No newline at end of file From 145744c1f0f39b561c2d56e1b640cc5593f94d26 Mon Sep 17 00:00:00 2001 From: MtlPhil <76601115+MtlPhil@users.noreply.github.com> Date: Sun, 15 Mar 2026 18:32:57 -0400 Subject: [PATCH 35/42] fix: include all extended InfoType fields in APNs push payload buildPayload() was only serializing the base fields (glucose, delta, trend, updatedAt, unit, iob, cob, projected). All extended fields added with the configurable grid slots (battery, sageInsertTime, pumpBattery, basalRate, autosens, tdd, targets, isf, carbRatio, carbsToday, profileName, CAGE, IAGE, minBg, maxBg, override, recBolus) were missing from the APNs payload, causing the extension to decode them as nil/0 and display '--' on every push-driven refresh. Co-Authored-By: Claude Sonnet 4.6 --- LoopFollow/LiveActivity/APNSClient.swift | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/LoopFollow/LiveActivity/APNSClient.swift b/LoopFollow/LiveActivity/APNSClient.swift index 358d99469..94cee2a85 100644 --- a/LoopFollow/LiveActivity/APNSClient.swift +++ b/LoopFollow/LiveActivity/APNSClient.swift @@ -111,6 +111,25 @@ class APNSClient { if let iob = snapshot.iob { snapshotDict["iob"] = iob } if let cob = snapshot.cob { snapshotDict["cob"] = cob } if let projected = snapshot.projected { snapshotDict["projected"] = projected } + if let override = snapshot.override { snapshotDict["override"] = override } + if let recBolus = snapshot.recBolus { snapshotDict["recBolus"] = recBolus } + if let battery = snapshot.battery { snapshotDict["battery"] = battery } + if let pumpBattery = snapshot.pumpBattery { snapshotDict["pumpBattery"] = pumpBattery } + if !snapshot.basalRate.isEmpty { snapshotDict["basalRate"] = snapshot.basalRate } + if let pumpReservoirU = snapshot.pumpReservoirU { snapshotDict["pumpReservoirU"] = pumpReservoirU } + if let autosens = snapshot.autosens { snapshotDict["autosens"] = autosens } + if let tdd = snapshot.tdd { snapshotDict["tdd"] = tdd } + if let targetLowMgdl = snapshot.targetLowMgdl { snapshotDict["targetLowMgdl"] = targetLowMgdl } + if let targetHighMgdl = snapshot.targetHighMgdl { snapshotDict["targetHighMgdl"] = targetHighMgdl } + if let isfMgdlPerU = snapshot.isfMgdlPerU { snapshotDict["isfMgdlPerU"] = isfMgdlPerU } + if let carbRatio = snapshot.carbRatio { snapshotDict["carbRatio"] = carbRatio } + if let carbsToday = snapshot.carbsToday { snapshotDict["carbsToday"] = carbsToday } + if let profileName = snapshot.profileName { snapshotDict["profileName"] = profileName } + if snapshot.sageInsertTime > 0 { snapshotDict["sageInsertTime"] = snapshot.sageInsertTime } + if snapshot.cageInsertTime > 0 { snapshotDict["cageInsertTime"] = snapshot.cageInsertTime } + if snapshot.iageInsertTime > 0 { snapshotDict["iageInsertTime"] = snapshot.iageInsertTime } + if let minBgMgdl = snapshot.minBgMgdl { snapshotDict["minBgMgdl"] = minBgMgdl } + if let maxBgMgdl = snapshot.maxBgMgdl { snapshotDict["maxBgMgdl"] = maxBgMgdl } let contentState: [String: Any] = [ "snapshot": snapshotDict, From dfe53b3ee18ac7fc10da6f90d4561ef2948c372e Mon Sep 17 00:00:00 2001 From: MtlPhil <76601115+MtlPhil@users.noreply.github.com> Date: Sun, 15 Mar 2026 22:57:45 -0400 Subject: [PATCH 36/42] feat: add small family view for CarPlay Dashboard and Watch Smart Stack Registers the Live Activity for the small activity family via .supplementalActivityFamilies([.small]), enabling automatic display on: - CarPlay Dashboard (iOS 26+) - Apple Watch Smart Stack (watchOS 11+) The small view is hardcoded to the essentials appropriate for a driving context: glucose value, trend arrow, delta, and time since last reading. Background tint matches the existing threshold-based color logic. The lock screen layout (full grid with configurable slots) is unchanged. Co-Authored-By: Claude Sonnet 4.6 --- .../LoopFollowLiveActivity.swift | 72 +++++++++++++++++-- 1 file changed, 65 insertions(+), 7 deletions(-) diff --git a/LoopFollowLAExtension/LoopFollowLiveActivity.swift b/LoopFollowLAExtension/LoopFollowLiveActivity.swift index d0f351611..354ea13df 100644 --- a/LoopFollowLAExtension/LoopFollowLiveActivity.swift +++ b/LoopFollowLAExtension/LoopFollowLiveActivity.swift @@ -9,13 +9,10 @@ import WidgetKit struct LoopFollowLiveActivityWidget: Widget { var body: some WidgetConfiguration { ActivityConfiguration(for: GlucoseLiveActivityAttributes.self) { context in - // LOCK SCREEN / BANNER UI - LockScreenLiveActivityView(state: context.state /* , activityID: context.activityID */ ) - .id(context.state.seq) // force SwiftUI to re-render on every update - .activitySystemActionForegroundColor(.white) - .activityBackgroundTint(LAColors.backgroundTint(for: context.state.snapshot)) - .applyActivityContentMarginsFixIfAvailable() - .widgetURL(URL(string: "loopfollow://la-tap")!) + // LOCK SCREEN / BANNER UI — also used for CarPlay Dashboard and Watch Smart Stack + // (small family) via supplementalActivityFamilies([.small]) + LockScreenFamilyAdaptiveView(state: context.state) + .id(context.state.seq) } dynamicIsland: { context in // DYNAMIC ISLAND UI DynamicIsland { @@ -52,6 +49,7 @@ struct LoopFollowLiveActivityWidget: Widget { } .keylineTint(LAColors.keyline(for: context.state.snapshot).opacity(0.75)) } + .supplementalActivityFamilies([.small]) } } @@ -69,6 +67,66 @@ private extension View { } } +// MARK: - Family-adaptive wrapper (Lock Screen / CarPlay / Watch Smart Stack) + +/// Reads the activityFamily environment value and routes to the appropriate layout. +/// - `.small` → CarPlay Dashboard & Watch Smart Stack: compact glucose-only view +/// - everything else → full lock screen layout with configurable grid +@available(iOS 16.1, *) +private struct LockScreenFamilyAdaptiveView: View { + let state: GlucoseLiveActivityAttributes.ContentState + + @Environment(\.activityFamily) var activityFamily + + var body: some View { + if activityFamily == .small { + SmallFamilyView(snapshot: state.snapshot) + } else { + LockScreenLiveActivityView(state: state) + .activitySystemActionForegroundColor(.white) + .activityBackgroundTint(LAColors.backgroundTint(for: state.snapshot)) + .applyActivityContentMarginsFixIfAvailable() + .widgetURL(URL(string: "loopfollow://la-tap")!) + } + } +} + +// MARK: - Small family view (CarPlay Dashboard + Watch Smart Stack) + +/// Compact view shown on CarPlay Dashboard (iOS 26+) and Apple Watch Smart Stack (watchOS 11+). +/// Hardcoded to glucose + trend arrow + delta + time since last reading. +@available(iOS 16.1, *) +private struct SmallFamilyView: View { + let snapshot: GlucoseSnapshot + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + HStack(alignment: .firstTextBaseline, spacing: 6) { + Text(LAFormat.glucose(snapshot)) + .font(.system(size: 28, weight: .bold, design: .rounded)) + .monospacedDigit() + .foregroundStyle(.white) + Text(LAFormat.trendArrow(snapshot)) + .font(.system(size: 22, weight: .semibold, design: .rounded)) + .foregroundStyle(.white.opacity(0.9)) + } + HStack(spacing: 8) { + Text(LAFormat.delta(snapshot)) + .font(.system(size: 14, weight: .semibold, design: .rounded)) + .monospacedDigit() + .foregroundStyle(.white.opacity(0.85)) + Text(LAFormat.updated(snapshot)) + .font(.system(size: 14, weight: .regular, design: .rounded)) + .monospacedDigit() + .foregroundStyle(.white.opacity(0.65)) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) + .padding(10) + .activityBackgroundTint(LAColors.backgroundTint(for: snapshot)) + } +} + // MARK: - Lock Screen Contract View @available(iOS 16.1, *) From a98f0a88cfc2683313dd75aa7536a8d107b97e16 Mon Sep 17 00:00:00 2001 From: MtlPhil <76601115+MtlPhil@users.noreply.github.com> Date: Mon, 16 Mar 2026 07:41:41 -0400 Subject: [PATCH 37/42] fix: guard CarPlay/Watch small family behind iOS 18 availability; increase renewal overlay opacity - supplementalActivityFamilies and activityFamily require iOS 18.0+; restructured into two Widget structs sharing a makeDynamicIsland() helper: - LoopFollowLiveActivityWidget (iOS 16.1+): lock screen + Dynamic Island - LoopFollowLiveActivityWidgetWithCarPlay (iOS 18.0+): adds .supplementalActivityFamilies([.small]) - Bundle uses if #available(iOS 18.0, *) to select the right variant - Podfile: set minimum deployment target to 16.6 for all pods - Increase 'Tap to update' renewal overlay opacity from 60% to 90% Co-Authored-By: Claude Sonnet 4.6 --- .../LoopFollowLABundle.swift | 5 +- .../LoopFollowLiveActivity.swift | 101 +++++++++++------- Podfile | 9 ++ 3 files changed, 74 insertions(+), 41 deletions(-) diff --git a/LoopFollowLAExtension/LoopFollowLABundle.swift b/LoopFollowLAExtension/LoopFollowLABundle.swift index e3a043783..1f901463f 100644 --- a/LoopFollowLAExtension/LoopFollowLABundle.swift +++ b/LoopFollowLAExtension/LoopFollowLABundle.swift @@ -11,7 +11,10 @@ import WidgetKit @main struct LoopFollowLABundle: WidgetBundle { var body: some Widget { - if #available(iOS 16.1, *) { + if #available(iOS 18.0, *) { + // CarPlay Dashboard + Watch Smart Stack support (iOS 18+) + LoopFollowLiveActivityWidgetWithCarPlay() + } else { LoopFollowLiveActivityWidget() } } diff --git a/LoopFollowLAExtension/LoopFollowLiveActivity.swift b/LoopFollowLAExtension/LoopFollowLiveActivity.swift index 354ea13df..51e144f54 100644 --- a/LoopFollowLAExtension/LoopFollowLiveActivity.swift +++ b/LoopFollowLAExtension/LoopFollowLiveActivity.swift @@ -5,49 +5,70 @@ import ActivityKit import SwiftUI import WidgetKit +/// Builds the shared Dynamic Island content used by both widget variants. +@available(iOS 16.1, *) +private func makeDynamicIsland(context: ActivityViewContext) -> DynamicIsland { + DynamicIsland { + DynamicIslandExpandedRegion(.leading) { + Link(destination: URL(string: "loopfollow://la-tap")!) { + DynamicIslandLeadingView(snapshot: context.state.snapshot) + .overlay(RenewalOverlayView(show: context.state.snapshot.showRenewalOverlay)) + } + .id(context.state.seq) + } + DynamicIslandExpandedRegion(.trailing) { + Link(destination: URL(string: "loopfollow://la-tap")!) { + DynamicIslandTrailingView(snapshot: context.state.snapshot) + .overlay(RenewalOverlayView(show: context.state.snapshot.showRenewalOverlay)) + } + .id(context.state.seq) + } + DynamicIslandExpandedRegion(.bottom) { + Link(destination: URL(string: "loopfollow://la-tap")!) { + DynamicIslandBottomView(snapshot: context.state.snapshot) + .overlay(RenewalOverlayView(show: context.state.snapshot.showRenewalOverlay, showText: true)) + } + .id(context.state.seq) + } + } compactLeading: { + DynamicIslandCompactLeadingView(snapshot: context.state.snapshot) + .id(context.state.seq) + } compactTrailing: { + DynamicIslandCompactTrailingView(snapshot: context.state.snapshot) + .id(context.state.seq) + } minimal: { + DynamicIslandMinimalView(snapshot: context.state.snapshot) + .id(context.state.seq) + } + .keylineTint(LAColors.keyline(for: context.state.snapshot).opacity(0.75)) +} + +/// Base widget — Lock Screen + Dynamic Island. Used on iOS 16.1–17.x. @available(iOS 16.1, *) struct LoopFollowLiveActivityWidget: Widget { var body: some WidgetConfiguration { ActivityConfiguration(for: GlucoseLiveActivityAttributes.self) { context in - // LOCK SCREEN / BANNER UI — also used for CarPlay Dashboard and Watch Smart Stack - // (small family) via supplementalActivityFamilies([.small]) + LockScreenLiveActivityView(state: context.state) + .id(context.state.seq) + .activitySystemActionForegroundColor(.white) + .activityBackgroundTint(LAColors.backgroundTint(for: context.state.snapshot)) + .applyActivityContentMarginsFixIfAvailable() + .widgetURL(URL(string: "loopfollow://la-tap")!) + } dynamicIsland: { context in + makeDynamicIsland(context: context) + } + } +} + +/// iOS 18+ widget — adds CarPlay Dashboard + Watch Smart Stack via the small activity family. +@available(iOS 18.0, *) +struct LoopFollowLiveActivityWidgetWithCarPlay: Widget { + var body: some WidgetConfiguration { + ActivityConfiguration(for: GlucoseLiveActivityAttributes.self) { context in LockScreenFamilyAdaptiveView(state: context.state) .id(context.state.seq) } dynamicIsland: { context in - // DYNAMIC ISLAND UI - DynamicIsland { - DynamicIslandExpandedRegion(.leading) { - Link(destination: URL(string: "loopfollow://la-tap")!) { - DynamicIslandLeadingView(snapshot: context.state.snapshot) - .overlay(RenewalOverlayView(show: context.state.snapshot.showRenewalOverlay)) - } - .id(context.state.seq) - } - DynamicIslandExpandedRegion(.trailing) { - Link(destination: URL(string: "loopfollow://la-tap")!) { - DynamicIslandTrailingView(snapshot: context.state.snapshot) - .overlay(RenewalOverlayView(show: context.state.snapshot.showRenewalOverlay)) - } - .id(context.state.seq) - } - DynamicIslandExpandedRegion(.bottom) { - Link(destination: URL(string: "loopfollow://la-tap")!) { - DynamicIslandBottomView(snapshot: context.state.snapshot) - .overlay(RenewalOverlayView(show: context.state.snapshot.showRenewalOverlay, showText: true)) - } - .id(context.state.seq) - } - } compactLeading: { - DynamicIslandCompactLeadingView(snapshot: context.state.snapshot) - .id(context.state.seq) - } compactTrailing: { - DynamicIslandCompactTrailingView(snapshot: context.state.snapshot) - .id(context.state.seq) - } minimal: { - DynamicIslandMinimalView(snapshot: context.state.snapshot) - .id(context.state.seq) - } - .keylineTint(LAColors.keyline(for: context.state.snapshot).opacity(0.75)) + makeDynamicIsland(context: context) } .supplementalActivityFamilies([.small]) } @@ -72,7 +93,7 @@ private extension View { /// Reads the activityFamily environment value and routes to the appropriate layout. /// - `.small` → CarPlay Dashboard & Watch Smart Stack: compact glucose-only view /// - everything else → full lock screen layout with configurable grid -@available(iOS 16.1, *) +@available(iOS 18.0, *) private struct LockScreenFamilyAdaptiveView: View { let state: GlucoseLiveActivityAttributes.ContentState @@ -95,7 +116,7 @@ private struct LockScreenFamilyAdaptiveView: View { /// Compact view shown on CarPlay Dashboard (iOS 26+) and Apple Watch Smart Stack (watchOS 11+). /// Hardcoded to glucose + trend arrow + delta + time since last reading. -@available(iOS 16.1, *) +@available(iOS 18.0, *) private struct SmallFamilyView: View { let snapshot: GlucoseSnapshot @@ -213,7 +234,7 @@ private struct LockScreenLiveActivityView: View { .overlay( ZStack { RoundedRectangle(cornerRadius: 16, style: .continuous) - .fill(Color.gray.opacity(0.6)) + .fill(Color.gray.opacity(0.9)) Text("Tap to update") .font(.system(size: 20, weight: .semibold)) .foregroundStyle(.white) @@ -231,7 +252,7 @@ private struct RenewalOverlayView: View { var body: some View { ZStack { - Color.gray.opacity(0.6) + Color.gray.opacity(0.9) if showText { Text("Tap to update") .font(.system(size: 14, weight: .semibold)) diff --git a/Podfile b/Podfile index 5a8c0f868..f8c2df3b2 100644 --- a/Podfile +++ b/Podfile @@ -7,6 +7,15 @@ target 'LoopFollow' do end post_install do |installer| + # Set minimum deployment target for all pods to match the app (suppresses deprecation warnings) + installer.pods_project.targets.each do |target| + target.build_configurations.each do |config| + if config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'].to_f < 16.6 + config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '16.6' + end + end + end + # Patch Charts Transformer to avoid "CGAffineTransformInvert: singular matrix" # warnings when chart views have zero dimensions (before layout). transformer = 'Pods/Charts/Source/Charts/Utils/Transformer.swift' From 65e679ad30eda00d2d032ab44e635be059340deb Mon Sep 17 00:00:00 2001 From: MtlPhil <76601115+MtlPhil@users.noreply.github.com> Date: Mon, 16 Mar 2026 08:24:36 -0400 Subject: [PATCH 38/42] fix: move if #available into Widget.body to avoid WidgetBundleBuilder limitation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @WidgetBundleBuilder does not support if #available { } else { }, but @WidgetConfigurationBuilder (used by Widget.body) does. Collapsed back to a single LoopFollowLiveActivityWidget struct with the iOS 18 conditional inside body — iOS 18+ branch adds .supplementalActivityFamilies([.small]) for CarPlay and Watch Smart Stack; else branch uses the plain lock screen view. Bundle reverts to the original single if #available(iOS 16.1, *) pattern. Co-Authored-By: Claude Sonnet 4.6 --- .../LoopFollowLABundle.swift | 5 +- .../LoopFollowLiveActivity.swift | 46 +++++++++---------- 2 files changed, 23 insertions(+), 28 deletions(-) diff --git a/LoopFollowLAExtension/LoopFollowLABundle.swift b/LoopFollowLAExtension/LoopFollowLABundle.swift index 1f901463f..e3a043783 100644 --- a/LoopFollowLAExtension/LoopFollowLABundle.swift +++ b/LoopFollowLAExtension/LoopFollowLABundle.swift @@ -11,10 +11,7 @@ import WidgetKit @main struct LoopFollowLABundle: WidgetBundle { var body: some Widget { - if #available(iOS 18.0, *) { - // CarPlay Dashboard + Watch Smart Stack support (iOS 18+) - LoopFollowLiveActivityWidgetWithCarPlay() - } else { + if #available(iOS 16.1, *) { LoopFollowLiveActivityWidget() } } diff --git a/LoopFollowLAExtension/LoopFollowLiveActivity.swift b/LoopFollowLAExtension/LoopFollowLiveActivity.swift index 51e144f54..4e3c3a84c 100644 --- a/LoopFollowLAExtension/LoopFollowLiveActivity.swift +++ b/LoopFollowLAExtension/LoopFollowLiveActivity.swift @@ -43,34 +43,32 @@ private func makeDynamicIsland(context: ActivityViewContext Date: Mon, 16 Mar 2026 08:34:19 -0400 Subject: [PATCH 39/42] fix: use two separate single-branch if #available in bundle for CarPlay support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @WidgetConfigurationBuilder's buildEither requires both branches to return the same concrete type, making if/else with supplementalActivityFamilies impossible (it wraps to a different opaque type). @WidgetBundleBuilder does not support if #available { } else { } at all. Solution: two separate single-branch if #available blocks in the bundle — the pattern that @WidgetBundleBuilder already supported in the original code: - LoopFollowLiveActivityWidget (iOS 16.1+): primary, lock screen + Dynamic Island - LoopFollowLiveActivityWidgetWithCarPlay (iOS 18.0+): supplemental, adds .supplementalActivityFamilies([.small]) for CarPlay Dashboard + Watch Smart Stack ActivityKit uses the supplemental widget for small-family surfaces and the primary widget for lock screen / Dynamic Island, keeping iOS 16.6+ support intact. Co-Authored-By: Claude Sonnet 4.6 --- .../LoopFollowLABundle.swift | 3 ++ .../LoopFollowLiveActivity.swift | 48 ++++++++++--------- 2 files changed, 29 insertions(+), 22 deletions(-) diff --git a/LoopFollowLAExtension/LoopFollowLABundle.swift b/LoopFollowLAExtension/LoopFollowLABundle.swift index e3a043783..a9f7daf6c 100644 --- a/LoopFollowLAExtension/LoopFollowLABundle.swift +++ b/LoopFollowLAExtension/LoopFollowLABundle.swift @@ -14,5 +14,8 @@ struct LoopFollowLABundle: WidgetBundle { if #available(iOS 16.1, *) { LoopFollowLiveActivityWidget() } + if #available(iOS 18.0, *) { + LoopFollowLiveActivityWidgetWithCarPlay() + } } } diff --git a/LoopFollowLAExtension/LoopFollowLiveActivity.swift b/LoopFollowLAExtension/LoopFollowLiveActivity.swift index 4e3c3a84c..5e33cb500 100644 --- a/LoopFollowLAExtension/LoopFollowLiveActivity.swift +++ b/LoopFollowLAExtension/LoopFollowLiveActivity.swift @@ -43,32 +43,36 @@ private func makeDynamicIsland(context: ActivityViewContext Date: Mon, 16 Mar 2026 11:41:41 -0400 Subject: [PATCH 40/42] fix: restore two-widget bundle; guard supplementalActivityFamilies and activityFamily behind iOS 18 Upstream's single-widget approach placed iOS 18+ APIs (supplementalActivityFamilies, activityFamily, ActivityFamily) behind @available(iOS 16.1, *), which fails to compile at the 16.6 deployment target. Restoring the two-widget pattern: - LoopFollowLiveActivityWidget (@available iOS 16.1): lock screen + DI, uses LockScreenLiveActivityView, no supplementalActivityFamilies - LoopFollowLiveActivityWidgetWithCarPlay (@available iOS 18.0): adds CarPlay Dashboard + Watch Smart Stack via supplementalActivityFamilies([.small]), uses LockScreenFamilyAdaptiveView (also @available iOS 18.0) - SmallFamilyView availability corrected to @available(iOS 18.0, *) - Bundle registers both via separate if #available blocks Co-Authored-By: Claude Sonnet 4.6 --- .../LoopFollowLABundle.swift | 3 + .../LoopFollowLiveActivity.swift | 98 ++++++++++++------- 2 files changed, 63 insertions(+), 38 deletions(-) diff --git a/LoopFollowLAExtension/LoopFollowLABundle.swift b/LoopFollowLAExtension/LoopFollowLABundle.swift index e3a043783..a9f7daf6c 100644 --- a/LoopFollowLAExtension/LoopFollowLABundle.swift +++ b/LoopFollowLAExtension/LoopFollowLABundle.swift @@ -14,5 +14,8 @@ struct LoopFollowLABundle: WidgetBundle { if #available(iOS 16.1, *) { LoopFollowLiveActivityWidget() } + if #available(iOS 18.0, *) { + LoopFollowLiveActivityWidgetWithCarPlay() + } } } diff --git a/LoopFollowLAExtension/LoopFollowLiveActivity.swift b/LoopFollowLAExtension/LoopFollowLiveActivity.swift index ee535f952..a70008414 100644 --- a/LoopFollowLAExtension/LoopFollowLiveActivity.swift +++ b/LoopFollowLAExtension/LoopFollowLiveActivity.swift @@ -5,49 +5,71 @@ import ActivityKit import SwiftUI import WidgetKit +/// Builds the shared Dynamic Island content used by both widget variants. +@available(iOS 16.1, *) +private func makeDynamicIsland(context: ActivityViewContext) -> DynamicIsland { + DynamicIsland { + DynamicIslandExpandedRegion(.leading) { + Link(destination: URL(string: "loopfollow://la-tap")!) { + DynamicIslandLeadingView(snapshot: context.state.snapshot) + .overlay(RenewalOverlayView(show: context.state.snapshot.showRenewalOverlay)) + } + .id(context.state.seq) + } + DynamicIslandExpandedRegion(.trailing) { + Link(destination: URL(string: "loopfollow://la-tap")!) { + DynamicIslandTrailingView(snapshot: context.state.snapshot) + .overlay(RenewalOverlayView(show: context.state.snapshot.showRenewalOverlay)) + } + .id(context.state.seq) + } + DynamicIslandExpandedRegion(.bottom) { + Link(destination: URL(string: "loopfollow://la-tap")!) { + DynamicIslandBottomView(snapshot: context.state.snapshot) + .overlay(RenewalOverlayView(show: context.state.snapshot.showRenewalOverlay, showText: true)) + } + .id(context.state.seq) + } + } compactLeading: { + DynamicIslandCompactLeadingView(snapshot: context.state.snapshot) + .id(context.state.seq) + } compactTrailing: { + DynamicIslandCompactTrailingView(snapshot: context.state.snapshot) + .id(context.state.seq) + } minimal: { + DynamicIslandMinimalView(snapshot: context.state.snapshot) + .id(context.state.seq) + } + .keylineTint(LAColors.keyline(for: context.state.snapshot).opacity(0.75)) +} + +/// Primary widget (iOS 16.1+) — Lock Screen + Dynamic Island for all iOS versions. @available(iOS 16.1, *) struct LoopFollowLiveActivityWidget: Widget { var body: some WidgetConfiguration { ActivityConfiguration(for: GlucoseLiveActivityAttributes.self) { context in - // LOCK SCREEN / BANNER UI — also used for CarPlay Dashboard and Watch Smart Stack - // (small family) via supplementalActivityFamilies([.small]) + LockScreenLiveActivityView(state: context.state) + .id(context.state.seq) + .activitySystemActionForegroundColor(.white) + .activityBackgroundTint(LAColors.backgroundTint(for: context.state.snapshot)) + .applyActivityContentMarginsFixIfAvailable() + .widgetURL(URL(string: "loopfollow://la-tap")!) + } dynamicIsland: { context in + makeDynamicIsland(context: context) + } + } +} + +/// Supplemental widget (iOS 18.0+) — adds CarPlay Dashboard + Watch Smart Stack +/// via supplementalActivityFamilies([.small]). +@available(iOS 18.0, *) +struct LoopFollowLiveActivityWidgetWithCarPlay: Widget { + var body: some WidgetConfiguration { + ActivityConfiguration(for: GlucoseLiveActivityAttributes.self) { context in LockScreenFamilyAdaptiveView(state: context.state) .id(context.state.seq) } dynamicIsland: { context in - // DYNAMIC ISLAND UI - DynamicIsland { - DynamicIslandExpandedRegion(.leading) { - Link(destination: URL(string: "loopfollow://la-tap")!) { - DynamicIslandLeadingView(snapshot: context.state.snapshot) - .overlay(RenewalOverlayView(show: context.state.snapshot.showRenewalOverlay)) - } - .id(context.state.seq) - } - DynamicIslandExpandedRegion(.trailing) { - Link(destination: URL(string: "loopfollow://la-tap")!) { - DynamicIslandTrailingView(snapshot: context.state.snapshot) - .overlay(RenewalOverlayView(show: context.state.snapshot.showRenewalOverlay)) - } - .id(context.state.seq) - } - DynamicIslandExpandedRegion(.bottom) { - Link(destination: URL(string: "loopfollow://la-tap")!) { - DynamicIslandBottomView(snapshot: context.state.snapshot) - .overlay(RenewalOverlayView(show: context.state.snapshot.showRenewalOverlay, showText: true)) - } - .id(context.state.seq) - } - } compactLeading: { - DynamicIslandCompactLeadingView(snapshot: context.state.snapshot) - .id(context.state.seq) - } compactTrailing: { - DynamicIslandCompactTrailingView(snapshot: context.state.snapshot) - .id(context.state.seq) - } minimal: { - DynamicIslandMinimalView(snapshot: context.state.snapshot) - .id(context.state.seq) - } - .keylineTint(LAColors.keyline(for: context.state.snapshot).opacity(0.75)) + makeDynamicIsland(context: context) } .supplementalActivityFamilies([.small]) } @@ -72,7 +94,7 @@ private extension View { /// Reads the activityFamily environment value and routes to the appropriate layout. /// - `.small` → CarPlay Dashboard & Watch Smart Stack: compact glucose-only view /// - everything else → full lock screen layout with configurable grid -@available(iOS 16.1, *) +@available(iOS 18.0, *) private struct LockScreenFamilyAdaptiveView: View { let state: GlucoseLiveActivityAttributes.ContentState @@ -95,7 +117,7 @@ private struct LockScreenFamilyAdaptiveView: View { /// Compact view shown on CarPlay Dashboard (iOS 26+) and Apple Watch Smart Stack (watchOS 11+). /// Hardcoded to glucose + trend arrow + delta + time since last reading. -@available(iOS 16.1, *) +@available(iOS 18.0, *) private struct SmallFamilyView: View { let snapshot: GlucoseSnapshot From e8daddab6a4ad550890ecfe931653ffc427ed047 Mon Sep 17 00:00:00 2001 From: MtlPhil <76601115+MtlPhil@users.noreply.github.com> Date: Mon, 16 Mar 2026 11:51:41 -0400 Subject: [PATCH 41/42] fix: extension version inherits from parent; remove spurious await in slot config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - LoopFollowLAExtension MARKETING_VERSION now uses "$(MARKETING_VERSION)" to match the parent app version automatically, resolving CFBundleShortVersionString mismatch warning - Remove unnecessary Task/await wrapping of refreshFromCurrentState in LiveActivitySettingsView — the method is not async Co-Authored-By: Claude Sonnet 4.6 --- LoopFollow.xcodeproj/project.pbxproj | 4 ++-- LoopFollow/LiveActivitySettingsView.swift | 4 +--- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/LoopFollow.xcodeproj/project.pbxproj b/LoopFollow.xcodeproj/project.pbxproj index 795346767..34c2b838c 100644 --- a/LoopFollow.xcodeproj/project.pbxproj +++ b/LoopFollow.xcodeproj/project.pbxproj @@ -2413,7 +2413,7 @@ ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MACOSX_DEPLOYMENT_TARGET = 26.2; - MARKETING_VERSION = 1.0; + MARKETING_VERSION = "$(MARKETING_VERSION)"; PRODUCT_BUNDLE_IDENTIFIER = "com.$(unique_id).LoopFollow$(app_suffix).LoopFollowLAExtension"; PRODUCT_MODULE_NAME = LoopFollowLAExtension; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -2465,7 +2465,7 @@ ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MACOSX_DEPLOYMENT_TARGET = 26.2; - MARKETING_VERSION = 1.0; + MARKETING_VERSION = "$(MARKETING_VERSION)"; PRODUCT_BUNDLE_IDENTIFIER = "com.$(unique_id).LoopFollow$(app_suffix).LoopFollowLAExtension"; PRODUCT_MODULE_NAME = LoopFollowLAExtension; PRODUCT_NAME = "$(TARGET_NAME)"; diff --git a/LoopFollow/LiveActivitySettingsView.swift b/LoopFollow/LiveActivitySettingsView.swift index 99dbc13e6..efe4ec321 100644 --- a/LoopFollow/LiveActivitySettingsView.swift +++ b/LoopFollow/LiveActivitySettingsView.swift @@ -68,8 +68,6 @@ struct LiveActivitySettingsView: View { } slots[index] = option LAAppGroupSettings.setSlots(slots) - Task { - await LiveActivityManager.shared.refreshFromCurrentState(reason: "slot config changed") - } + LiveActivityManager.shared.refreshFromCurrentState(reason: "slot config changed") } } From 83f4ad3a5e4e410debc01bd6ec79d5ddff6c3ae0 Mon Sep 17 00:00:00 2001 From: MtlPhil <76601115+MtlPhil@users.noreply.github.com> Date: Mon, 16 Mar 2026 16:49:35 -0400 Subject: [PATCH 42/42] fix: prevent glucose + trend arrow clipping on wide mmol/L values MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit At 46pt, a 4-character mmol/L value ("10.5") plus "↑↑" overflowed the 168pt left column, truncating the glucose reading. Fix: reduce trend arrow to 32pt and add minimumScaleFactor(0.7) + lineLimit(1) to the glucose text so values above 10 mmol/L render correctly. Co-Authored-By: Claude Sonnet 4.6 --- LoopFollowLAExtension/LoopFollowLiveActivity.swift | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/LoopFollowLAExtension/LoopFollowLiveActivity.swift b/LoopFollowLAExtension/LoopFollowLiveActivity.swift index a70008414..d681a9368 100644 --- a/LoopFollowLAExtension/LoopFollowLiveActivity.swift +++ b/LoopFollowLAExtension/LoopFollowLiveActivity.swift @@ -164,15 +164,18 @@ private struct LockScreenLiveActivityView: View { HStack(spacing: 12) { // LEFT: Glucose + trend arrow, delta below VStack(alignment: .leading, spacing: 4) { - HStack(alignment: .firstTextBaseline, spacing: 8) { + HStack(alignment: .firstTextBaseline, spacing: 6) { Text(LAFormat.glucose(s)) .font(.system(size: 46, weight: .bold, design: .rounded)) .monospacedDigit() .foregroundStyle(.white) + .minimumScaleFactor(0.7) + .lineLimit(1) Text(LAFormat.trendArrow(s)) - .font(.system(size: 46, weight: .bold, design: .rounded)) + .font(.system(size: 32, weight: .bold, design: .rounded)) .foregroundStyle(.white.opacity(0.95)) + .lineLimit(1) } Text("Delta: \(LAFormat.delta(s))")