From 1e17855d08767d774843877eadf19c2f6f4cc71c Mon Sep 17 00:00:00 2001 From: MtlPhil <76601115+MtlPhil@users.noreply.github.com> Date: Sun, 8 Nov 2026 17:19:51 -0500 Subject: [PATCH 1/2] Show a stale cue and status line on the CarPlay Live Activity A Live Activity only redraws when an update reaches it, so with no network or a stopped app it cannot show that its data is old. staleDate is now 12 minutes after the reading and is sent in update pushes, so iOS can flag the activity without the app. Renewal relies only on laRenewBy and laRenewalFailed, so stale data no longer replaces the activity when the app opens; an activity with no stored deadline is still replaced, since it could never renew. The small family, shown on CarPlay and in the Watch Smart Stack: - A stale reading is struck out in red, with the BG and arrow grey. - A status line, on by default, shows the update time, IOB and COB (or Not Looping in their place) and any active override with its insulin needs, range and time left. While it is on, IOB, COB and Override are not offered for the right slot; a stored choice of one of them comes back when the line is turned off. Turning it off also restores the original font sizes. - A Tight CarPlay Layout switch, off by default, replaces the right slot with projected BG, IOB, COB and basal, and centres the bottom row. No API tells CarPlay from the Watch, so it also needs a width of at least 205 points: the 240-wide CarPlay layouts, not the 170-wide one. Both switches explain themselves in an info button. --- LoopFollow/Controllers/NightScout.swift | 3 + .../Nightscout/Treatments/Overrides.swift | 15 + LoopFollow/LiveActivity/APNSClient.swift | 4 + .../LiveActivity/GlucoseSnapshotBuilder.swift | 10 + .../LiveActivity/LiveActivityManager.swift | 48 ++- .../StorageCurrentGlucoseStateProvider.swift | 12 + LoopFollow/LiveActivitySettingsView.swift | 63 ++- LoopFollow/Settings/SettingsMenuView.swift | 2 +- LoopFollow/Storage/Observable.swift | 5 + LoopFollow/Watch/WatchStateBuilder.swift | 4 +- .../LoopFollowLiveActivity.swift | 385 ++++++++++++++---- Shared/GlucoseSnapshot.swift | 86 +++- Shared/LAAppGroupSettings.swift | 50 ++- Tests/GlucoseSnapshotStaleTests.swift | 54 +++ Tests/LiveActivityRenewalTests.swift | 39 ++ Tests/OverrideDetailsTests.swift | 75 ++++ Tests/SmallWidgetLayoutTests.swift | 67 +++ WatchShared/WatchFormat.swift | 2 +- 18 files changed, 831 insertions(+), 93 deletions(-) create mode 100644 Tests/GlucoseSnapshotStaleTests.swift create mode 100644 Tests/LiveActivityRenewalTests.swift create mode 100644 Tests/OverrideDetailsTests.swift create mode 100644 Tests/SmallWidgetLayoutTests.swift diff --git a/LoopFollow/Controllers/NightScout.swift b/LoopFollow/Controllers/NightScout.swift index 1b9b438c7..7edc4ed8d 100644 --- a/LoopFollow/Controllers/NightScout.swift +++ b/LoopFollow/Controllers/NightScout.swift @@ -78,6 +78,9 @@ extension MainViewController { overrideGraphData.removeAll() Observable.shared.override.value = nil Observable.shared.overrideEndAt.value = nil + Observable.shared.overridePercent.value = nil + Observable.shared.overrideRangeLowMgdl.value = nil + Observable.shared.overrideRangeHighMgdl.value = nil updateOverrideGraph() } diff --git a/LoopFollow/Controllers/Nightscout/Treatments/Overrides.swift b/LoopFollow/Controllers/Nightscout/Treatments/Overrides.swift index 3a56a641c..eea9481d1 100644 --- a/LoopFollow/Controllers/Nightscout/Treatments/Overrides.swift +++ b/LoopFollow/Controllers/Nightscout/Treatments/Overrides.swift @@ -8,6 +8,8 @@ extension MainViewController { overrideGraphData.removeAll() var activeOverrideNote: String? var activeOverrideEndAt: TimeInterval? + var activeOverridePercent: Int? + var activeOverrideRange: (low: Double, high: Double)? let sorted = entries.sorted { lhs, rhs in guard @@ -94,11 +96,16 @@ extension MainViewController { if now >= start, now < end { activeOverrideNote = e["notes"] as? String ?? e["reason"] as? String activeOverrideEndAt = trueEnd + activeOverridePercent = WatchStateBuilder.overridePercent(dot) + activeOverrideRange = Self.overrideRangeMgdl(dot.correctionRange) } } Observable.shared.override.value = activeOverrideNote Observable.shared.overrideEndAt.value = activeOverrideEndAt + Observable.shared.overridePercent.value = activeOverridePercent + Observable.shared.overrideRangeLowMgdl.value = activeOverrideRange?.low + Observable.shared.overrideRangeHighMgdl.value = activeOverrideRange?.high if Storage.shared.device.value != "Loop" { if let note = activeOverrideNote { infoManager.updateInfoData(type: .override, value: note) @@ -114,4 +121,12 @@ extension MainViewController { LiveActivityManager.shared.refreshFromCurrentState(reason: "overrideChanged") #endif } + + /// An override's target range in mg/dL, or nil when it sets none (stored as + /// [0, 0]). Values this small can only be mmol/L, so they are converted. + nonisolated static func overrideRangeMgdl(_ range: [Int]) -> (low: Double, high: Double)? { + guard range.count == 2, range[0] > 0, range[1] > 0 else { return nil } + let mgdl = range.map { $0 < 30 ? Double($0) * GlucoseConversion.mmolToMgDl : Double($0) } + return (min(mgdl[0], mgdl[1]), max(mgdl[0], mgdl[1])) + } } diff --git a/LoopFollow/LiveActivity/APNSClient.swift b/LoopFollow/LiveActivity/APNSClient.swift index ca8dd5261..5b5e0ccae 100644 --- a/LoopFollow/LiveActivity/APNSClient.swift +++ b/LoopFollow/LiveActivity/APNSClient.swift @@ -223,6 +223,7 @@ class APNSClient { "aps": [ "timestamp": Int(Date().timeIntervalSince1970), "event": "update", + "stale-date": Int(state.snapshot.staleDate().timeIntervalSince1970), "content-state": contentState, ], ] @@ -248,6 +249,9 @@ class APNSClient { if let projected = snapshot.projected { snapshotDict["projected"] = projected } if let override = snapshot.override { snapshotDict["override"] = override } if let overrideEndAt = snapshot.overrideEndAt { snapshotDict["overrideEndAt"] = overrideEndAt } + if let overridePercent = snapshot.overridePercent { snapshotDict["overridePercent"] = overridePercent } + if let overrideRangeLowMgdl = snapshot.overrideRangeLowMgdl { snapshotDict["overrideRangeLowMgdl"] = overrideRangeLowMgdl } + if let overrideRangeHighMgdl = snapshot.overrideRangeHighMgdl { snapshotDict["overrideRangeHighMgdl"] = overrideRangeHighMgdl } if let tempTargetMgdl = snapshot.tempTargetMgdl { snapshotDict["tempTargetMgdl"] = tempTargetMgdl } if let tempTargetEndAt = snapshot.tempTargetEndAt { snapshotDict["tempTargetEndAt"] = tempTargetEndAt } if let recBolus = snapshot.recBolus { snapshotDict["recBolus"] = recBolus } diff --git a/LoopFollow/LiveActivity/GlucoseSnapshotBuilder.swift b/LoopFollow/LiveActivity/GlucoseSnapshotBuilder.swift index bb59255dd..71600884a 100644 --- a/LoopFollow/LiveActivity/GlucoseSnapshotBuilder.swift +++ b/LoopFollow/LiveActivity/GlucoseSnapshotBuilder.swift @@ -36,6 +36,13 @@ protocol CurrentGlucoseStateProviding { /// End of the active override as Unix epoch seconds (nil = no override or indefinite). var overrideEndAt: TimeInterval? { get } + /// Insulin needs of the active override as a percentage (nil = none or 100 %). + var overridePercent: Int? { get } + + /// Target range of the active override in mg/dL (nil = none or not set). + var overrideRangeLowMgdl: Double? { get } + var overrideRangeHighMgdl: Double? { get } + /// Active temporary target in mg/dL (nil if no active temp target). var tempTargetMgdl: Double? { get } @@ -154,6 +161,9 @@ enum GlucoseSnapshotBuilder { projected: provider.projectedMgdl, override: provider.override, overrideEndAt: provider.overrideEndAt, + overridePercent: provider.overridePercent, + overrideRangeLowMgdl: provider.overrideRangeLowMgdl, + overrideRangeHighMgdl: provider.overrideRangeHighMgdl, tempTargetMgdl: provider.tempTargetMgdl, tempTargetEndAt: provider.tempTargetEndAt, recBolus: provider.recBolus, diff --git a/LoopFollow/LiveActivity/LiveActivityManager.swift b/LoopFollow/LiveActivity/LiveActivityManager.swift index 0d3e61fe5..5a8a374bc 100644 --- a/LoopFollow/LiveActivity/LiveActivityManager.swift +++ b/LoopFollow/LiveActivity/LiveActivityManager.swift @@ -196,7 +196,7 @@ final class LiveActivityManager { ) let content = ActivityContent( state: state, - staleDate: Date(timeIntervalSince1970: Storage.shared.laRenewBy.value), + staleDate: snapshot.staleDate(), relevanceScore: 100.0, ) @@ -376,11 +376,11 @@ final class LiveActivityManager { } let now = Date().timeIntervalSince1970 - let staleDatePassed = activity.content.staleDate.map { $0 <= Date() } ?? false - if staleDatePassed { + let renewBy = Storage.shared.laRenewBy.value + if renewBy > 0, now >= renewBy { LogManager.shared.log( category: .general, - message: "[LA] liveness check: staleDate already passed" + message: "[LA] liveness check: renewal deadline already passed" ) return true } @@ -414,6 +414,15 @@ final class LiveActivityManager { static let opportunisticRenewalThreshold: TimeInterval = 4 * 3600 static let extensionLivenessGrace: TimeInterval = 15 * 60 + /// Whether a running activity must be replaced instead of reused. Only the + /// renewal clock decides. The activity's stale date does not: it says the last + /// reading has aged, which a new activity would not fix. An activity with no + /// stored deadline is replaced because `renewIfNeeded` could never renew it, + /// leaving iOS to end it at 8 hours. + static func existingActivityNeedsReplacing(renewBy: TimeInterval, renewalFailed: Bool, now: TimeInterval) -> Bool { + renewalFailed || renewBy <= 0 || now >= renewBy - renewalWarning + } + /// Base backoff after a 429 for push-to-start; doubled on each subsequent 429, /// capped at `pushToStartMaxBackoff`. Reset to base after a successful send. private static let pushToStartBaseBackoff: TimeInterval = 300 // 5 min @@ -502,15 +511,14 @@ final class LiveActivityManager { let startReason = nextStartReasonOverride ?? "user-start" nextStartReasonOverride = nil - // If an activity is already running and not stale we adopt/reuse it + // If an activity is already running and not due for renewal we adopt/reuse it // (covers warm starts where the LA survived a relaunch); only truly new // starts go through createActivity (local when foreground, APNs otherwise). if let existing = Activity.activities.first { let renewBy = Storage.shared.laRenewBy.value let now = Date().timeIntervalSince1970 - let staleDatePassed = existing.content.staleDate.map { $0 <= Date() } ?? false - let inRenewalWindow = renewBy > 0 && now >= renewBy - LiveActivityManager.renewalWarning - let needsRestart = Storage.shared.laRenewalFailed.value || inRenewalWindow || staleDatePassed + let renewalFailed = Storage.shared.laRenewalFailed.value + let needsRestart = LiveActivityManager.existingActivityNeedsReplacing(renewBy: renewBy, renewalFailed: renewalFailed, now: now) if !needsRestart { // Opportunistic renewal: the LA is healthy but in the back half // of its 8-hour lifetime and the app is open — replace it locally @@ -530,7 +538,7 @@ final class LiveActivityManager { } LogManager.shared.log( category: .general, - message: "[LA] existing activity is stale on startIfNeeded — replacing (staleDatePassed=\(staleDatePassed), inRenewalWindow=\(inRenewalWindow))" + message: "[LA] existing activity needs renewal on startIfNeeded — replacing (renewalFailed=\(renewalFailed), renewBy=\(renewBy), now=\(now))" ) createActivity(reason: startReason, oldActivity: existing) return @@ -753,10 +761,10 @@ final class LiveActivityManager { reason: reason, producedAt: Date(), ) - let staleDate = Date().addingTimeInterval(LiveActivityManager.renewalThreshold) + let renewBy = Date().addingTimeInterval(LiveActivityManager.renewalThreshold) let content = ActivityContent( state: state, - staleDate: staleDate, + staleDate: workingSnapshot.staleDate(), relevanceScore: 100.0, ) @@ -770,7 +778,7 @@ final class LiveActivityManager { category: .general, message: "[LA] local create (\(reason)) succeeded id=\(activity.id) seq=\(nextSeq)" ) - Storage.shared.laRenewBy.value = staleDate.timeIntervalSince1970 + Storage.shared.laRenewBy.value = renewBy.timeIntervalSince1970 Storage.shared.laRenewalFailed.value = false cancelRenewalFailedNotification() pendingForegroundRestart = false @@ -930,7 +938,7 @@ final class LiveActivityManager { reason: reason, producedAt: Date(), ) - let staleDate = Date().addingTimeInterval(LiveActivityManager.renewalThreshold) + let staleDate = freshSnapshot.staleDate() let tail = String(token.suffix(8)) LogManager.shared.log( @@ -1084,7 +1092,7 @@ final class LiveActivityManager { return } if current == nil, - let existing = Activity.activities.first(where: { $0.activityState == .active }) + let existing = firstUpdatableActivity { // Skip activities already in .ended/.dismissed — those are corpses // (typically post-410 ends pending iOS dismissal). Binding to them @@ -1108,6 +1116,14 @@ final class LiveActivityManager { } } + /// The first activity that can still take updates. `.stale` counts: it only + /// means the last reading has aged, and the next update clears it. + private var firstUpdatableActivity: Activity? { + Activity.activities.first { + $0.activityState == .active || $0.activityState == .stale + } + } + private func isAppVisibleForLiveActivityStart() -> Bool { let scenes = UIApplication.shared.connectedScenes return scenes.contains { $0.activationState == .foregroundActive } @@ -1115,7 +1131,7 @@ final class LiveActivityManager { func update(snapshot: GlucoseSnapshot, reason: String) { if current == nil, - let existing = Activity.activities.first(where: { $0.activityState == .active }) + let existing = firstUpdatableActivity { bind(to: existing, logReason: "bind-existing") } @@ -1145,7 +1161,7 @@ final class LiveActivityManager { let content = ActivityContent( state: state, - staleDate: Date(timeIntervalSince1970: Storage.shared.laRenewBy.value), + staleDate: snapshot.staleDate(), relevanceScore: 100.0, ) diff --git a/LoopFollow/LiveActivity/StorageCurrentGlucoseStateProvider.swift b/LoopFollow/LiveActivity/StorageCurrentGlucoseStateProvider.swift index 30337ac12..052bcb791 100644 --- a/LoopFollow/LiveActivity/StorageCurrentGlucoseStateProvider.swift +++ b/LoopFollow/LiveActivity/StorageCurrentGlucoseStateProvider.swift @@ -52,6 +52,18 @@ struct StorageCurrentGlucoseStateProvider: CurrentGlucoseStateProviding { Observable.shared.overrideEndAt.value } + var overridePercent: Int? { + Observable.shared.overridePercent.value + } + + var overrideRangeLowMgdl: Double? { + Observable.shared.overrideRangeLowMgdl.value + } + + var overrideRangeHighMgdl: Double? { + Observable.shared.overrideRangeHighMgdl.value + } + var tempTargetMgdl: Double? { Observable.shared.tempTarget.value?.doubleValue(for: .milligramsPerDeciliter) } diff --git a/LoopFollow/LiveActivitySettingsView.swift b/LoopFollow/LiveActivitySettingsView.swift index 6d654f72d..c2b94c36f 100644 --- a/LoopFollow/LiveActivitySettingsView.swift +++ b/LoopFollow/LiveActivitySettingsView.swift @@ -10,6 +10,8 @@ @State private var restartConfirmed = false @State private var slots: [LiveActivitySlotOption] = LAAppGroupSettings.slots() @State private var smallWidgetSlot: LiveActivitySlotOption = LAAppGroupSettings.smallWidgetSlot() + @State private var showSmallWidgetStatusLine: Bool = LAAppGroupSettings.showSmallWidgetStatusLine() + @State private var smallWidgetTightLayout: Bool = LAAppGroupSettings.smallWidgetTightLayout() @State private var keyId: String = Storage.shared.lfKeyId.value @State private var apnsKey: String = Storage.shared.lfApnsKey.value @@ -67,18 +69,41 @@ } Section(header: Text("Grid Slot - CarPlay / Watch")) { + let rightSlotOptions = LiveActivitySlotOption.smallWidgetCases(showStatusLine: showSmallWidgetStatusLine) Picker("Right slot", selection: Binding( - get: { smallWidgetSlot }, + get: { rightSlotOptions.contains(smallWidgetSlot) ? smallWidgetSlot : .none }, set: { newValue in smallWidgetSlot = newValue LAAppGroupSettings.setSmallWidgetSlot(newValue) LiveActivityManager.shared.refreshFromCurrentState(reason: "small widget slot changed") } )) { - ForEach(LiveActivitySlotOption.allCases, id: \.self) { option in + ForEach(rightSlotOptions, id: \.self) { option in Text(option.displayName).tag(option) } } + + Toggle(isOn: Binding( + get: { showSmallWidgetStatusLine }, + set: { newValue in + showSmallWidgetStatusLine = newValue + LAAppGroupSettings.setShowSmallWidgetStatusLine(newValue) + LiveActivityManager.shared.refreshFromCurrentState(reason: "small widget status line changed") + } + )) { + InfoLabel(title: "Status Line", info: Self.statusLineInfo) + } + + Toggle(isOn: Binding( + get: { smallWidgetTightLayout }, + set: { newValue in + smallWidgetTightLayout = newValue + LAAppGroupSettings.setSmallWidgetTightLayout(newValue) + LiveActivityManager.shared.refreshFromCurrentState(reason: "small widget tight layout changed") + } + )) { + InfoLabel(title: "Tight CarPlay Layout", info: Self.tightLayoutInfo) + } } } .onReceive(Storage.shared.laEnabled.$value) { newValue in @@ -103,6 +128,10 @@ .navigationBarTitleDisplayMode(.inline) } + private static let statusLineInfo = "Shows a line under the reading with the update time, IOB and COB, or Not Looping when Loop stops reporting, then any active override with its insulin needs, range and time left. While it is on, IOB, COB and Override are not offered for the right slot. Changes appear with the next Live Activity update." + + private static let tightLayoutInfo = "Replaces the right slot with projected BG, IOB, COB and basal stacked on the right, and reduces the status line to the time. It only applies to wide CarPlay screens; the Watch and narrow CarPlay screens keep the regular layout. Changes appear with the next Live Activity update." + /// 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) { @@ -116,4 +145,34 @@ LiveActivityManager.shared.refreshFromCurrentState(reason: "slot config changed") } } + + /// A setting's title followed by an info button that explains it in a popover. + private struct InfoLabel: View { + let title: String + let info: String + @State private var showInfo = false + + var body: some View { + HStack(spacing: 6) { + Text(title) + Button { + showInfo = true + } label: { + Image(systemName: "info.circle") + .foregroundStyle(Color.accentColor) + } + // Borderless keeps the tap on the icon instead of the whole row. + .buttonStyle(.borderless) + .accessibilityLabel("About \(title)") + .popover(isPresented: $showInfo) { + Text(info) + .font(.callout) + .fixedSize(horizontal: false, vertical: true) + .frame(width: 280, alignment: .leading) + .padding() + .presentationCompactAdaptation(.popover) + } + } + } + } #endif diff --git a/LoopFollow/Settings/SettingsMenuView.swift b/LoopFollow/Settings/SettingsMenuView.swift index 828d36058..4fc34ccbb 100644 --- a/LoopFollow/Settings/SettingsMenuView.swift +++ b/LoopFollow/Settings/SettingsMenuView.swift @@ -213,7 +213,7 @@ enum SettingsRoute: Hashable, Identifiable { case .liveActivity: return [ SettingsLeaf("Enable Live Activity"), SettingsLeaf("Restart Live Activity"), - SettingsLeaf("Grid Slots", ["carplay", "watch"]), + SettingsLeaf("Grid Slots", ["carplay", "watch", "status line", "tight layout"]), ] #endif case .remote: return [ diff --git a/LoopFollow/Storage/Observable.swift b/LoopFollow/Storage/Observable.swift index 229ee6c65..5670526c9 100644 --- a/LoopFollow/Storage/Observable.swift +++ b/LoopFollow/Storage/Observable.swift @@ -17,6 +17,11 @@ class Observable { /// End of the active override as Unix epoch seconds; nil = no override or indefinite. var overrideEndAt = ObservableValue(default: nil) + /// Insulin needs of the active override as a percentage; nil = none or 100 %. + var overridePercent = ObservableValue(default: nil) + /// Target range of the active override in mg/dL; nil = none or not set. + var overrideRangeLowMgdl = ObservableValue(default: nil) + var overrideRangeHighMgdl = ObservableValue(default: nil) /// End of the active temp target as Unix epoch seconds; nil = no active temp target. var tempTargetEndAt = ObservableValue(default: nil) diff --git a/LoopFollow/Watch/WatchStateBuilder.swift b/LoopFollow/Watch/WatchStateBuilder.swift index 12a379c99..a72ac26e6 100644 --- a/LoopFollow/Watch/WatchStateBuilder.swift +++ b/LoopFollow/Watch/WatchStateBuilder.swift @@ -170,8 +170,8 @@ enum WatchStateBuilder { } /// Insulin needs of an override, from its Nightscout entry or else from the preset of the same name, - /// since Trio does not upload the factor with the override. - private static func overridePercent(_ override: DataStructs.overrideStruct) -> Int? { + /// since Trio does not upload the factor with the override. Also used for the Live Activity. + static func overridePercent(_ override: DataStructs.overrideStruct) -> Int? { var factor = override.insulNeedsScaleFactor if factor == 1 { if let loop = ProfileManager.shared.loopOverrides.first(where: { $0.name == override.reason }) { diff --git a/LoopFollowLAExtension/LoopFollowLiveActivity.swift b/LoopFollowLAExtension/LoopFollowLiveActivity.swift index a9018ca94..ed3a91ffd 100644 --- a/LoopFollowLAExtension/LoopFollowLiveActivity.swift +++ b/LoopFollowLAExtension/LoopFollowLiveActivity.swift @@ -50,7 +50,7 @@ private func makeDynamicIsland(context: ActivityViewContext some View { + HStack(alignment: .center, spacing: 6) { VStack(alignment: .leading, spacing: 2) { - HStack(alignment: .firstTextBaseline, spacing: 4) { - Text(LAFormat.glucose(snapshot)) - .font(.system(size: 28, weight: .bold, design: .rounded)) - .monospacedDigit() - .foregroundStyle(LAColors.keyline(for: snapshot)) + glucoseColumn(metrics: metrics) - Text(LAFormat.trendArrow(snapshot)) - .font(.system(size: 22, weight: .semibold, design: .rounded)) - .foregroundStyle(LAColors.keyline(for: snapshot)) + // Keeps the bottom row's height under the glucose. The row itself + // is drawn over the full width, so it is centred on the widget + // rather than on this column; it is short enough to clear the stack. + if showStatusLine { + tightBottomRow.hidden() } + } + .frame(maxWidth: .infinity, alignment: .leading) - Text("\(LAFormat.delta(snapshot)) \(snapshot.unit.displayName)") - .font(.system(size: 14, weight: .semibold, design: .rounded)) - .monospacedDigit() - .foregroundStyle(.white.opacity(0.85)) + SmallFamilyStack(snapshot: snapshot, fontSize: height >= 90 ? 15 : 12) + .fixedSize() + } + .overlay(alignment: .bottom) { + if showStatusLine { + tightBottomRow } - .layoutPriority(1) - - Spacer() - - if rightSlot != .none { - if let unitLabel = rightSlotUnitLabel(for: rightSlot) { - // Use ViewThatFits so the unit label appears on surfaces with - // enough vertical space (CarPlay) and is omitted where it doesn't - // fit (Watch Smart Stack). - ViewThatFits(in: .vertical) { - VStack(alignment: .trailing, spacing: 2) { - Text(rightSlot.gridLabel) - .font(.system(size: 12, weight: .semibold, design: .rounded)) - .foregroundStyle(.white.opacity(0.65)) - Text(slotFormattedValue(option: rightSlot, snapshot: snapshot)) - .font(.system(size: 20, weight: .bold, design: .rounded)) - .monospacedDigit() - .foregroundStyle(.white) - .lineLimit(1) - .minimumScaleFactor(0.8) - Text(unitLabel) - .font(.system(size: 11, weight: .regular, design: .rounded)) - .foregroundStyle(.white.opacity(0.55)) + } + } + + private var tightBottomRow: some View { + Group { + if snapshot.isNotLooping { + SmallFamilyNotLooping() + } else { + SmallFamilyUpdateTime(snapshot: snapshot, isStale: isStale) + } + } + .font(.system(size: 11, weight: .semibold, design: .rounded)) + .monospacedDigit() + } + + /// Watch, narrow CarPlay, and wide CarPlay with the tight layout off: the + /// single configurable right slot. + private func slotLayout(metrics: SmallFamilyMetrics, showStatusLine: Bool) -> some View { + // A choice the status line already shows stays stored and comes back + // when the line is turned off. + let storedSlot = LAAppGroupSettings.smallWidgetSlot() + let rightSlot = LiveActivitySlotOption.smallWidgetCases(showStatusLine: showStatusLine).contains(storedSlot) ? storedSlot : .none + + return VStack(alignment: .leading, spacing: 2) { + HStack(alignment: .center, spacing: 0) { + glucoseColumn(metrics: metrics) + .layoutPriority(1) + + Spacer(minLength: 4) + + if rightSlot != .none { + if let unitLabel = rightSlotUnitLabel(for: rightSlot) { + // Use ViewThatFits so the unit label appears on surfaces with + // enough vertical space (CarPlay) and is omitted where it doesn't + // fit (Watch Smart Stack). + ViewThatFits(in: .vertical) { + VStack(alignment: .trailing, spacing: metrics.textSpacing) { + rightSlotValue(rightSlot, metrics: metrics) + Text(unitLabel) + .font(.system(size: metrics.slotUnit, weight: .regular, design: .rounded)) + .foregroundStyle(.white.opacity(0.55)) + } + VStack(alignment: .trailing, spacing: metrics.textSpacing) { + rightSlotValue(rightSlot, metrics: metrics) + } } - VStack(alignment: .trailing, spacing: 2) { - Text(rightSlot.gridLabel) - .font(.system(size: 12, weight: .semibold, design: .rounded)) - .foregroundStyle(.white.opacity(0.65)) - Text(slotFormattedValue(option: rightSlot, snapshot: snapshot)) - .font(.system(size: 20, weight: .bold, design: .rounded)) - .monospacedDigit() - .foregroundStyle(.white) - .lineLimit(1) - .minimumScaleFactor(0.8) + } else { + VStack(alignment: .trailing, spacing: metrics.textSpacing) { + rightSlotValue(rightSlot, metrics: metrics) } } - } else { - VStack(alignment: .trailing, spacing: 2) { - Text(rightSlot.gridLabel) - .font(.system(size: 12, weight: .semibold, design: .rounded)) - .foregroundStyle(.white.opacity(0.65)) - Text(slotFormattedValue(option: rightSlot, snapshot: snapshot)) - .font(.system(size: 20, weight: .bold, design: .rounded)) - .monospacedDigit() - .foregroundStyle(.white) - .lineLimit(1) - .minimumScaleFactor(0.8) - } } } + + if showStatusLine { + SmallFamilyStatusLine(snapshot: snapshot, isStale: isStale) + .layoutPriority(1) + } + } + } + + private func glucoseColumn(metrics: SmallFamilyMetrics) -> some View { + VStack(alignment: .leading, spacing: metrics.textSpacing) { + HStack(alignment: .firstTextBaseline, spacing: 4) { + Text(LAFormat.glucose(snapshot)) + .font(.system(size: metrics.glucose, weight: .bold, design: .rounded)) + .monospacedDigit() + .foregroundStyle(glucoseColor) + .strikethrough(isStale, pattern: .solid, color: .red) + .lineLimit(1) + .minimumScaleFactor(0.85) + + Text(LAFormat.trendArrow(snapshot)) + .font(.system(size: metrics.arrow, weight: .semibold, design: .rounded)) + .foregroundStyle(glucoseColor) + } + + Text("\(LAFormat.delta(snapshot)) \(snapshot.unit.displayName)") + .font(.system(size: metrics.delta, weight: .semibold, design: .rounded)) + .monospacedDigit() + .foregroundStyle(.white.opacity(0.85)) + } + } + + @ViewBuilder + private func rightSlotValue(_ slot: LiveActivitySlotOption, metrics: SmallFamilyMetrics) -> some View { + Text(slot.gridLabel) + .font(.system(size: metrics.slotLabel, weight: .semibold, design: .rounded)) + .foregroundStyle(.white.opacity(0.65)) + Text(slotFormattedValue(option: slot, snapshot: snapshot)) + .font(.system(size: metrics.slotValue, weight: .bold, design: .rounded)) + .monospacedDigit() + .foregroundStyle(.white) + .lineLimit(1) + .minimumScaleFactor(0.8) + } +} + +/// Projected BG, IOB, COB and the basal rate running now, one per row with an +/// icon instead of a label. Icons and values each line up in their own column. +private struct SmallFamilyStack: View { + let snapshot: GlucoseSnapshot + let fontSize: CGFloat + + var body: some View { + VStack(alignment: .trailing, spacing: 1) { + row(value: LAFormat.projected(snapshot), label: "Projected glucose") { + Image(systemName: "target") + } + row(value: LAFormat.iob(snapshot), label: "Insulin on board") { + Image(systemName: "syringe") + } + row(value: LAFormat.cob(snapshot), label: "Carbs on board") { + Image(systemName: "fork.knife") + } + row(value: LAFormat.activeBasal(snapshot), label: "Basal rate") { + BasalStepIcon() + .stroke(style: StrokeStyle(lineWidth: 1.5, lineCap: .round, lineJoin: .round)) + .frame(width: fontSize, height: fontSize * 0.7) + } + } + .font(.system(size: fontSize, weight: .semibold, design: .rounded)) + } + + private func row(value: String, label: String, @ViewBuilder icon: () -> some View) -> some View { + HStack(spacing: 4) { + icon() + .foregroundStyle(.white.opacity(0.65)) + .frame(width: fontSize + 2) + .accessibilityHidden(true) + + // Wide enough for four digits, so the icons line up across rows. + Text(value) + .monospacedDigit() + .foregroundStyle(.white) + .lineLimit(1) + .frame(minWidth: fontSize * 2.6, alignment: .trailing) + .accessibilityLabel("\(label) \(value)") + } + } +} + +/// A square step, drawn because SF Symbols has no square wave. +private struct BasalStepIcon: Shape { + func path(in rect: CGRect) -> Path { + let step = rect.width / 4 + var path = Path() + path.move(to: CGPoint(x: rect.minX, y: rect.maxY)) + path.addLine(to: CGPoint(x: rect.minX + step, y: rect.maxY)) + path.addLine(to: CGPoint(x: rect.minX + step, y: rect.minY)) + path.addLine(to: CGPoint(x: rect.minX + step * 2, y: rect.minY)) + path.addLine(to: CGPoint(x: rect.minX + step * 2, y: rect.maxY)) + path.addLine(to: CGPoint(x: rect.minX + step * 3, y: rect.maxY)) + path.addLine(to: CGPoint(x: rect.minX + step * 3, y: rect.minY)) + path.addLine(to: CGPoint(x: rect.maxX, y: rect.minY)) + return path + } +} + +/// The reading's time, red once it is stale. +private struct SmallFamilyUpdateTime: View { + let snapshot: GlucoseSnapshot + let isStale: Bool + + var body: some View { + Text(LAFormat.updated(snapshot)) + .foregroundStyle(isStale ? Color.red : Color.white.opacity(0.7)) + .fixedSize() + .accessibilityLabel("Updated \(LAFormat.updated(snapshot))") + } +} + +/// "Name · 80% · 140–160 · 1h 20m". When space runs out the name truncates +/// first, then the details shrink, so the percentage, range and time left stay. +private struct SmallFamilyOverride: View { + let name: String + let details: [String] + + var body: some View { + HStack(spacing: 3) { + Text("\(Image(systemName: "slider.horizontal.3")) \(name)") + .lineLimit(1) + + if !details.isEmpty { + Text("· " + details.joined(separator: " · ")) + .lineLimit(1) + .minimumScaleFactor(0.7) + .layoutPriority(1) + } + } + .foregroundStyle(.green) + .accessibilityElement(children: .ignore) + .accessibilityLabel((["Override \(name)"] + details).joined(separator: ", ")) + } +} + +private struct SmallFamilyNotLooping: View { + var body: some View { + Text("\(Image(systemName: "exclamationmark.triangle.fill")) Not Looping") + .foregroundStyle(.red) + .fixedSize() + } +} + +/// The update time, IOB and COB, or the warning in their place while not +/// looping, then the running override if any. The override shrinks first, so +/// the time and values stay on the narrowest surfaces. +private struct SmallFamilyStatusLine: View { + let snapshot: GlucoseSnapshot + let isStale: Bool + + var body: some View { + HStack(spacing: 5) { + if snapshot.isNotLooping { + SmallFamilyNotLooping() + } else { + SmallFamilyUpdateTime(snapshot: snapshot, isStale: isStale) + if snapshot.iob != nil { + Text("\(Image(systemName: "syringe")) \(LAFormat.iob(snapshot))") + .fixedSize() + .accessibilityLabel("Insulin on board \(LAFormat.iob(snapshot))") + } + if snapshot.cob != nil { + Text("\(Image(systemName: "fork.knife")) \(LAFormat.cob(snapshot))") + .fixedSize() + .accessibilityLabel("Carbs on board \(LAFormat.cob(snapshot))") + } + } + + if let name = LAFormat.activeOverrideName(snapshot) { + SmallFamilyOverride(name: name, details: snapshot.overrideDetails()) + } } - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) - .padding(10) + .font(.system(size: 11, weight: .semibold, design: .rounded)) + .monospacedDigit() + .foregroundStyle(.white.opacity(0.85)) + .frame(maxWidth: .infinity, alignment: .center) } } @@ -410,12 +651,8 @@ private struct ActiveAdjustmentsView: View { return Date(timeIntervalSince1970: t) } - /// nil end with a non-nil name means indefinite — keep showing the name; - /// a timed override whose end has passed is hidden entirely. private var overrideName: String? { - guard let name = snapshot.override else { return nil } - if snapshot.overrideEndAt != nil, overrideEnd == nil { return nil } - return name + LAFormat.activeOverrideName(snapshot) } private var tempTargetText: String? { @@ -724,6 +961,10 @@ private enum LAFormat { s.basalRate.isEmpty ? "—" : s.basalRate } + static func activeBasal(_ s: GlucoseSnapshot) -> String { + s.activeBasalRate.isEmpty ? "—" : s.activeBasalRate + } + static func pump(_ s: GlucoseSnapshot) -> String { guard let v = s.pumpReservoirU else { return "50+U" } return "\(Int(round(v)))U" @@ -772,6 +1013,14 @@ private enum LAFormat { s.override ?? "—" } + /// nil end with a non-nil name means indefinite — keep showing the name; + /// a timed override whose end has passed is hidden entirely. + static func activeOverrideName(_ s: GlucoseSnapshot) -> String? { + guard let name = s.override else { return nil } + if let end = s.overrideEndAt, end <= Date().timeIntervalSince1970 { return nil } + return name + } + static func tempTargetValue(_ s: GlucoseSnapshot) -> String? { guard let tt = s.tempTargetMgdl, tt > 0 else { return nil } return formatGlucoseValue(tt, unit: s.unit) diff --git a/Shared/GlucoseSnapshot.swift b/Shared/GlucoseSnapshot.swift index a8c80038e..4c7173388 100644 --- a/Shared/GlucoseSnapshot.swift +++ b/Shared/GlucoseSnapshot.swift @@ -57,6 +57,13 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable { /// indefinite (no countdown). let overrideEndAt: TimeInterval? + /// Insulin needs of the active override as a percentage (nil if none or 100 %) + let overridePercent: Int? + + /// Target range of the active override in mg/dL (nil if none or not set) + let overrideRangeLowMgdl: Double? + let overrideRangeHighMgdl: Double? + /// Active temporary target in mg/dL (nil if no active temp target) let tempTargetMgdl: Double? @@ -150,6 +157,9 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable { projected: Double?, override: String? = nil, overrideEndAt: TimeInterval? = nil, + overridePercent: Int? = nil, + overrideRangeLowMgdl: Double? = nil, + overrideRangeHighMgdl: Double? = nil, tempTargetMgdl: Double? = nil, tempTargetEndAt: TimeInterval? = nil, recBolus: Double? = nil, @@ -184,6 +194,9 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable { self.projected = projected self.override = override self.overrideEndAt = overrideEndAt + self.overridePercent = overridePercent + self.overrideRangeLowMgdl = overrideRangeLowMgdl + self.overrideRangeHighMgdl = overrideRangeHighMgdl self.tempTargetMgdl = tempTargetMgdl self.tempTargetEndAt = tempTargetEndAt self.recBolus = recBolus @@ -217,6 +230,67 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable { Date().timeIntervalSince(updatedAt) } + /// A reading this old is shown struck through, as on the main screen and the watch. + static let staleAfter: TimeInterval = 12 * 60 + + /// True once the reading is old enough to be shown struck through. + func isStale(at now: Date = Date()) -> Bool { + now.timeIntervalSince(updatedAt) >= Self.staleAfter + } + + /// The date to give ActivityKit, so the system redraws the Live Activity as + /// out of date even when no further update can reach it: no network, or the + /// app was killed. A reading that is already stale gets a date just ahead of + /// `now` rather than one in the past, whose handling Apple does not document. + /// Views rely on `isStale(at:)` for that case. + func staleDate(now: Date = Date()) -> Date { + let date = updatedAt.addingTimeInterval(Self.staleAfter) + return date > now ? date : now.addingTimeInterval(60) + } + + /// The rate running now. `basalRate` reads "scheduled → actual" while a temp + /// basal differs from the schedule. + var activeBasalRate: String { + basalRate.components(separatedBy: "→").last?.trimmingCharacters(in: .whitespaces) ?? basalRate + } + + /// The active override's insulin needs, target range and time left, each + /// formatted, in that order. Parts that are unknown or don't apply are left + /// out: 100 % insulin needs, no range of its own, or no end time. + func overrideDetails(now: Date = Date()) -> [String] { + var parts: [String] = [] + if let percent = overridePercent { + parts.append("\(percent)%") + } + if let low = overrideRangeLowMgdl, let high = overrideRangeHighMgdl { + let lowText = Self.formatGlucose(low, unit: unit) + let highText = Self.formatGlucose(high, unit: unit) + parts.append(lowText == highText ? lowText : "\(lowText)–\(highText)") + } + if let end = overrideEndAt, end > now.timeIntervalSince1970 { + parts.append(Self.timeLeft(end - now.timeIntervalSince1970)) + } + return parts + } + + /// Whole minutes, rounded up so a running override never reads 0m: "45m", "2h", "1h 20m". + static func timeLeft(_ seconds: TimeInterval) -> String { + let minutes = max(1, Int((seconds / 60).rounded(.up))) + let hours = minutes / 60 + let rest = minutes % 60 + if hours == 0 { return "\(rest)m" } + return rest == 0 ? "\(hours)h" : "\(hours)h \(rest)m" + } + + private static func formatGlucose(_ mgdl: Double, unit: Unit) -> String { + switch unit { + case .mgdl: + "\(Int(mgdl.rounded()))" + case .mmol: + GlucoseConversion.toMmol(mgdl).formatted(.number.precision(.fractionLength(1))) + } + } + /// Returns a copy of this snapshot with `showRenewalOverlay` set to the given value. /// All other fields are preserved exactly. Use this instead of manually copying /// every field when only the overlay flag needs to change. @@ -231,6 +305,9 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable { projected: projected, override: override, overrideEndAt: overrideEndAt, + overridePercent: overridePercent, + overrideRangeLowMgdl: overrideRangeLowMgdl, + overrideRangeHighMgdl: overrideRangeHighMgdl, tempTargetMgdl: tempTargetMgdl, tempTargetEndAt: tempTargetEndAt, recBolus: recBolus, @@ -271,6 +348,9 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable { try container.encodeIfPresent(projected, forKey: .projected) try container.encodeIfPresent(override, forKey: .override) try container.encodeIfPresent(overrideEndAt, forKey: .overrideEndAt) + try container.encodeIfPresent(overridePercent, forKey: .overridePercent) + try container.encodeIfPresent(overrideRangeLowMgdl, forKey: .overrideRangeLowMgdl) + try container.encodeIfPresent(overrideRangeHighMgdl, forKey: .overrideRangeHighMgdl) try container.encodeIfPresent(tempTargetMgdl, forKey: .tempTargetMgdl) try container.encodeIfPresent(tempTargetEndAt, forKey: .tempTargetEndAt) try container.encodeIfPresent(recBolus, forKey: .recBolus) @@ -308,6 +388,9 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable { projected = try container.decodeIfPresent(Double.self, forKey: .projected) override = try container.decodeIfPresent(String.self, forKey: .override) overrideEndAt = try container.decodeIfPresent(Double.self, forKey: .overrideEndAt) + overridePercent = try container.decodeIfPresent(Int.self, forKey: .overridePercent) + overrideRangeLowMgdl = try container.decodeIfPresent(Double.self, forKey: .overrideRangeLowMgdl) + overrideRangeHighMgdl = try container.decodeIfPresent(Double.self, forKey: .overrideRangeHighMgdl) tempTargetMgdl = try container.decodeIfPresent(Double.self, forKey: .tempTargetMgdl) tempTargetEndAt = try container.decodeIfPresent(Double.self, forKey: .tempTargetEndAt) recBolus = try container.decodeIfPresent(Double.self, forKey: .recBolus) @@ -337,7 +420,8 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable { private enum CodingKeys: String, CodingKey { case glucose, delta, trend, updatedAt case iob, cob, projected - case override, overrideEndAt, tempTargetMgdl, tempTargetEndAt + case override, overrideEndAt, overridePercent, overrideRangeLowMgdl, overrideRangeHighMgdl + case tempTargetMgdl, tempTargetEndAt case recBolus, battery, pumpBattery, basalRate, pumpReservoirU case autosens, tdd, targetLowMgdl, targetHighMgdl, isfMgdlPerU, carbRatio, carbsToday case profileName, sageInsertTime, cageInsertTime, iageInsertTime, minBgMgdl, maxBgMgdl diff --git a/Shared/LAAppGroupSettings.swift b/Shared/LAAppGroupSettings.swift index bdf93d56b..aeb93a36c 100644 --- a/Shared/LAAppGroupSettings.swift +++ b/Shared/LAAppGroupSettings.swift @@ -108,12 +108,19 @@ enum LiveActivitySlotOption: String, CaseIterable, Codable { /// Options selectable for the lock-screen 2×2 grid. The active override is /// excluded — the card has a dedicated override/temp-target countdown row. - /// It remains selectable for the single CarPlay/Watch slot, which has no - /// such row. static var gridCases: [LiveActivitySlotOption] { allCases.filter { $0 != .override } } + /// Shown on the CarPlay/Watch status line, so not offered for the right + /// slot while the line is on. + static let onSmallWidgetStatusLine: Set = [.iob, .cob, .override] + + /// Options selectable for the CarPlay/Watch right slot. + static func smallWidgetCases(showStatusLine: Bool) -> [LiveActivitySlotOption] { + showStatusLine ? allCases.filter { !onSmallWidgetStatusLine.contains($0) } : allCases + } + /// 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 { @@ -143,6 +150,21 @@ enum LiveActivitySlotDefaults { } } +// MARK: - Small family layout + +/// CarPlay and the Watch Smart Stack share the `.small` activity family and no +/// API tells them apart, so the tight layout depends on available width as well +/// as the user's setting. +enum SmallWidgetLayout { + /// Apple's sizes: CarPlay is 240 or 170 wide and the Watch at most 198. The + /// 240-wide CarPlay layouts clear this; everything else keeps the single slot. + static let tightMinWidth: CGFloat = 205 + + static func usesTightLayout(enabled: Bool, width: CGFloat) -> Bool { + enabled && width >= tightMinWidth + } +} + // MARK: - App Group settings /// Minimal App Group settings needed by the Live Activity UI. @@ -155,6 +177,8 @@ enum LAAppGroupSettings { static let highLineMgdl = "la.highLine.mgdl" static let slots = "la.slots" static let smallWidgetSlot = "la.smallWidgetSlot" + static let smallWidgetStatusLine = "la.smallWidgetStatusLine" + static let smallWidgetTightLayout = "la.smallWidgetTightLayout" static let displayName = "la.displayName" static let showDisplayName = "la.showDisplayName" } @@ -218,6 +242,28 @@ enum LAAppGroupSettings { return LiveActivitySlotOption(rawValue: raw) ?? LiveActivitySlotDefaults.smallWidgetSlot } + // MARK: - Small widget status line + + static func setShowSmallWidgetStatusLine(_ show: Bool) { + defaults?.set(show, forKey: Keys.smallWidgetStatusLine) + } + + /// On until the user turns it off. + static func showSmallWidgetStatusLine() -> Bool { + defaults?.object(forKey: Keys.smallWidgetStatusLine) as? Bool ?? true + } + + // MARK: - Small widget tight layout + + static func setSmallWidgetTightLayout(_ enabled: Bool) { + defaults?.set(enabled, forKey: Keys.smallWidgetTightLayout) + } + + /// Off until the user turns it on. + static func smallWidgetTightLayout() -> Bool { + defaults?.bool(forKey: Keys.smallWidgetTightLayout) ?? false + } + // MARK: - Display Name static func setDisplayName(_ name: String, show: Bool) { diff --git a/Tests/GlucoseSnapshotStaleTests.swift b/Tests/GlucoseSnapshotStaleTests.swift new file mode 100644 index 000000000..d9cb46279 --- /dev/null +++ b/Tests/GlucoseSnapshotStaleTests.swift @@ -0,0 +1,54 @@ +// LoopFollow +// GlucoseSnapshotStaleTests.swift + +import Foundation +@testable import LoopFollow +import Testing + +struct GlucoseSnapshotStaleTests { + private let now = Date(timeIntervalSince1970: 1_800_000_000) + + private func snapshot(ageSeconds: TimeInterval) -> GlucoseSnapshot { + GlucoseSnapshot( + glucose: 120, + delta: 0, + trend: .flat, + updatedAt: now.addingTimeInterval(-ageSeconds), + iob: nil, + cob: nil, + projected: nil, + unit: .mgdl, + isNotLooping: false, + ) + } + + @Test("A reading turns stale at exactly 12 minutes, as on the main screen") + func staleBoundary() { + #expect(!snapshot(ageSeconds: 12 * 60 - 1).isStale(at: now)) + #expect(snapshot(ageSeconds: 12 * 60).isStale(at: now)) + #expect(snapshot(ageSeconds: 3600).isStale(at: now)) + } + + @Test("A fresh reading goes stale 12 minutes after it was taken, not after the update") + func staleDateFollowsReading() { + let fresh = snapshot(ageSeconds: 5 * 60) + #expect(fresh.staleDate(now: now) == fresh.updatedAt.addingTimeInterval(12 * 60)) + } + + @Test("A reading about to turn stale keeps its exact stale date") + func staleDateNotDelayedNearBoundary() { + let aging = snapshot(ageSeconds: 12 * 60 - 30) + #expect(aging.staleDate(now: now) == now.addingTimeInterval(30)) + } + + @Test("An old reading never gets a stale date in the past") + func staleDateNeverInPast() { + #expect(snapshot(ageSeconds: 12 * 60).staleDate(now: now) > now) + #expect(snapshot(ageSeconds: 3600).staleDate(now: now) > now) + } + + @Test("The watch and the Live Activity share one stale threshold") + func watchUsesSharedThreshold() { + #expect(WatchFormat.staleAfter == GlucoseSnapshot.staleAfter) + } +} diff --git a/Tests/LiveActivityRenewalTests.swift b/Tests/LiveActivityRenewalTests.swift new file mode 100644 index 000000000..8c179b4b8 --- /dev/null +++ b/Tests/LiveActivityRenewalTests.swift @@ -0,0 +1,39 @@ +// LoopFollow +// LiveActivityRenewalTests.swift + +#if !targetEnvironment(macCatalyst) + + @testable import LoopFollow + import Testing + + struct LiveActivityRenewalTests { + private let now: Double = 1_800_000_000 + + private func needsReplacing(renewBy: Double, renewalFailed: Bool = false) -> Bool { + LiveActivityManager.existingActivityNeedsReplacing(renewBy: renewBy, renewalFailed: renewalFailed, now: now) + } + + @Test("A healthy activity outside the renewal window is reused") + func healthyIsReused() { + #expect(!needsReplacing(renewBy: now + LiveActivityManager.renewalWarning + 1)) + } + + @Test("An activity is replaced from the start of the renewal window onward") + func renewalWindow() { + #expect(needsReplacing(renewBy: now + LiveActivityManager.renewalWarning)) + #expect(needsReplacing(renewBy: now)) + #expect(needsReplacing(renewBy: now - 3600)) + } + + @Test("A failed renewal forces replacement even with time left") + func failedRenewal() { + #expect(needsReplacing(renewBy: now + 4 * 3600, renewalFailed: true)) + } + + @Test("An activity with no stored deadline is replaced, since it could never renew") + func missingDeadline() { + #expect(needsReplacing(renewBy: 0)) + } + } + +#endif diff --git a/Tests/OverrideDetailsTests.swift b/Tests/OverrideDetailsTests.swift new file mode 100644 index 000000000..e1a7a3ae5 --- /dev/null +++ b/Tests/OverrideDetailsTests.swift @@ -0,0 +1,75 @@ +// LoopFollow +// OverrideDetailsTests.swift + +import Foundation +@testable import LoopFollow +import Testing + +struct OverrideDetailsTests { + private let now = Date(timeIntervalSince1970: 1_800_000_000) + + private func snapshot( + percent: Int? = nil, + low: Double? = nil, + high: Double? = nil, + endsIn seconds: TimeInterval? = nil + ) -> GlucoseSnapshot { + GlucoseSnapshot( + glucose: 120, + delta: 0, + trend: .flat, + updatedAt: now, + iob: nil, + cob: nil, + projected: nil, + override: "Exercise", + overrideEndAt: seconds.map { now.timeIntervalSince1970 + $0 }, + overridePercent: percent, + overrideRangeLowMgdl: low, + overrideRangeHighMgdl: high, + unit: .mgdl, + isNotLooping: false + ) + } + + @Test("Percentage, range and time left, in that order") + func allDetails() { + let details = snapshot(percent: 80, low: 140, high: 160, endsIn: 80 * 60).overrideDetails(now: now) + #expect(details == ["80%", "140–160", "1h 20m"]) + } + + @Test("A single-value range shows once") + func singleValueRange() { + #expect(snapshot(low: 150, high: 150).overrideDetails(now: now) == ["150"]) + } + + @Test("Unknown parts and indefinite overrides are left out") + func missingParts() { + #expect(snapshot().overrideDetails(now: now).isEmpty) + #expect(snapshot(endsIn: -60).overrideDetails(now: now).isEmpty) + } + + @Test("Time left rounds up to whole minutes and never reads 0m") + func timeLeft() { + #expect(GlucoseSnapshot.timeLeft(5) == "1m") + #expect(GlucoseSnapshot.timeLeft(45 * 60) == "45m") + #expect(GlucoseSnapshot.timeLeft(44 * 60 + 1) == "45m") + #expect(GlucoseSnapshot.timeLeft(2 * 3600) == "2h") + #expect(GlucoseSnapshot.timeLeft(3600 + 20 * 60) == "1h 20m") + } + + @Test("A Nightscout range is read in mg/dL, and an unset one is ignored") + func nightscoutRange() { + let mgdl = MainViewController.overrideRangeMgdl([140, 160]) + #expect(mgdl?.low == 140) + #expect(mgdl?.high == 160) + #expect(MainViewController.overrideRangeMgdl([0, 0]) == nil) + #expect(MainViewController.overrideRangeMgdl([]) == nil) + } + + @Test("Values only possible in mmol/L are converted to mg/dL") + func mmolRange() { + let range = MainViewController.overrideRangeMgdl([7, 8]) + #expect(range.map { abs($0.low - 7 * GlucoseConversion.mmolToMgDl) < 0.001 } == true) + } +} diff --git a/Tests/SmallWidgetLayoutTests.swift b/Tests/SmallWidgetLayoutTests.swift new file mode 100644 index 000000000..530bf5a89 --- /dev/null +++ b/Tests/SmallWidgetLayoutTests.swift @@ -0,0 +1,67 @@ +// LoopFollow +// SmallWidgetLayoutTests.swift + +import Foundation +@testable import LoopFollow +import Testing + +struct SmallWidgetLayoutTests { + @Test("Wide CarPlay gets the tight layout when the setting is on") + func wideCarPlayUsesTightLayout() { + #expect(SmallWidgetLayout.usesTightLayout(enabled: true, width: 240)) + } + + @Test("With the setting off, wide CarPlay keeps the single slot") + func settingOffKeepsSlot() { + #expect(!SmallWidgetLayout.usesTightLayout(enabled: false, width: 240)) + } + + @Test("Narrow CarPlay and every Watch size keep the single slot", arguments: [170.0, 152, 165, 173, 184, 191, 198]) + func otherSurfacesKeepSlot(width: Double) { + #expect(!SmallWidgetLayout.usesTightLayout(enabled: true, width: CGFloat(width))) + } + + @Test("With the status line on, the right slot doesn't offer what the line shows") + func statusLineHidesItsValues() { + let options = LiveActivitySlotOption.smallWidgetCases(showStatusLine: true) + #expect(!options.contains(.iob)) + #expect(!options.contains(.cob)) + #expect(!options.contains(.override)) + #expect(options.contains(.projectedBG)) + } + + @Test("With the status line off, the right slot offers every option") + func statusLineOffOffersAll() { + #expect(LiveActivitySlotOption.smallWidgetCases(showStatusLine: false) == LiveActivitySlotOption.allCases) + } + + private func snapshot(basalRate: String) -> GlucoseSnapshot { + GlucoseSnapshot( + glucose: 120, + delta: 0, + trend: .flat, + updatedAt: Date(timeIntervalSince1970: 1_800_000_000), + iob: nil, + cob: nil, + projected: nil, + basalRate: basalRate, + unit: .mgdl, + isNotLooping: false + ) + } + + @Test("A temp basal shows only the rate running now") + func tempBasalShowsActualRate() { + #expect(snapshot(basalRate: "0.85 → 1.2").activeBasalRate == "1.2") + } + + @Test("A scheduled basal is unchanged") + func scheduledBasalUnchanged() { + #expect(snapshot(basalRate: "0.85").activeBasalRate == "0.85") + } + + @Test("A missing basal stays empty") + func missingBasalStaysEmpty() { + #expect(snapshot(basalRate: "").activeBasalRate.isEmpty) + } +} diff --git a/WatchShared/WatchFormat.swift b/WatchShared/WatchFormat.swift index 24502d318..919f11d76 100644 --- a/WatchShared/WatchFormat.swift +++ b/WatchShared/WatchFormat.swift @@ -8,7 +8,7 @@ import SwiftUI /// arrive in mg/dL and are rendered in the unit chosen on the phone. enum WatchFormat { /// A reading this old is shown struck through, as on the phone. - static let staleAfter: TimeInterval = 12 * 60 + static let staleAfter = GlucoseSnapshot.staleAfter private static let mgdlFormatter: NumberFormatter = { let nf = NumberFormatter() From d83620c6660a177f31e0d8de429041abd9126a65 Mon Sep 17 00:00:00 2001 From: MtlPhil <76601115+MtlPhil@users.noreply.github.com> Date: Sun, 8 Nov 2026 17:19:51 -0500 Subject: [PATCH 2/2] Add a CarPlay widget with Now, Alerts and 3 h pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A small widget for the CarPlay Widgets screen, also usable on the Home Screen (iOS 26 and later). A button in the corner steps through its pages: - Now: the reading, delta and the time of the reading, the loop values, and any running override as insulin needs, target range (the override's own, else the loop's current range) and end time, or ∞ with no end. - Alerts: only in the cycle while there is something to report. - 3 h: the last three hours against the target range. Alerts come from LoopFollow's alarms. Each alarm has a "Show in CarPlay" toggle, off by default, and is listed with LoopFollow's icon and wording. Alarms about a state stay listed while it holds, including carbs on board and recommended bolus above their threshold; alarms about an event stay listed for 15 minutes after they fire. Times on the widget and on the CarPlay Live Activity are clock times rather than ages or countdowns, so the text stays still while driving. The app writes the widget's state after each alarm check and Live Activity refresh and asks for a reload only when something shown has changed. As the system holds back reloads from an app in the background, the app then also sends a WidgetKit push, coalescing changes within 10 seconds into one. The Live Activity extension gets the Push Notifications capability and entitlement for this. Reload requests and pushes are logged at debug level under a new CarPlay log category. --- LoopFollow.xcodeproj/project.pbxproj | 2 + LoopFollow/Alarm/Alarm.swift | 11 +- .../Alarm/AlarmCondition/AlarmCondition.swift | 22 +- .../Alarm/AlarmCondition/COBCondition.swift | 7 + .../AlarmCondition/RecBolusCondition.swift | 7 + .../Components/AlarmGeneralSection.swift | 3 +- LoopFollow/Alarm/AlarmManager.swift | 63 +++ LoopFollow/Alarm/AlarmType/AlarmType.swift | 15 + .../CarPlayWidgetPublisher.swift | 198 +++++++++ LoopFollow/LiveActivity/APNSClient.swift | 57 +++ .../LiveActivity/LiveActivityManager.swift | 1 + LoopFollow/LiveActivitySettingsView.swift | 2 +- LoopFollow/Log/LogManager.swift | 1 + LoopFollow/Task/AlarmTask.swift | 4 + LoopFollowLAExtension/CarPlayWidget.swift | 394 ++++++++++++++++++ .../LoopFollowLABundle.swift | 3 + .../LoopFollowLiveActivity.swift | 10 +- LoopFollowLAExtensionExtension.entitlements | 2 + Shared/CarPlayWidgetState.swift | 176 ++++++++ Shared/GlucoseSnapshot.swift | 22 +- Tests/CarPlayWidgetAlertsTests.swift | 212 ++++++++++ Tests/OverrideDetailsTests.swift | 17 +- fastlane/Fastfile | 3 +- 23 files changed, 1200 insertions(+), 32 deletions(-) create mode 100644 LoopFollow/CarPlayWidget/CarPlayWidgetPublisher.swift create mode 100644 LoopFollowLAExtension/CarPlayWidget.swift create mode 100644 Shared/CarPlayWidgetState.swift create mode 100644 Tests/CarPlayWidgetAlertsTests.swift diff --git a/LoopFollow.xcodeproj/project.pbxproj b/LoopFollow.xcodeproj/project.pbxproj index e9db02c3f..310c5959c 100644 --- a/LoopFollow.xcodeproj/project.pbxproj +++ b/LoopFollow.xcodeproj/project.pbxproj @@ -139,6 +139,7 @@ DD57A7C02F7000000000000D /* Exceptions for "Shared" folder in "LoopFollowWatch" target */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( + CarPlayWidgetState.swift, GlucoseLiveActivityAttributes.swift, LAAppGroupSettings.swift, LALivenessMarker.swift, @@ -149,6 +150,7 @@ DD57A7C02F7000000000000E /* Exceptions for "Shared" folder in "LoopFollowWatchWidgets" target */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( + CarPlayWidgetState.swift, GlucoseLiveActivityAttributes.swift, LAAppGroupSettings.swift, LALivenessMarker.swift, diff --git a/LoopFollow/Alarm/Alarm.swift b/LoopFollow/Alarm/Alarm.swift index 7bf83811a..984b20373 100644 --- a/LoopFollow/Alarm/Alarm.swift +++ b/LoopFollow/Alarm/Alarm.swift @@ -56,6 +56,10 @@ struct Alarm: Identifiable, Codable, Equatable { var isEnabled: Bool = true + /// Show this alarm on the CarPlay widget while it's triggered. Off by + /// default, so the car only shows what the user picked. + var showInCarPlay: Bool = false + /// If the alarm is manually snoozed, we store the end time for the snooze here var snoozedUntil: Date? @@ -110,7 +114,7 @@ struct Alarm: Identifiable, Codable, Equatable { // MARK: - Codable (Custom implementation for backward compatibility) enum CodingKeys: String, CodingKey { - case id, type, name, isEnabled, snoozedUntil + case id, type, name, isEnabled, showInCarPlay, snoozedUntil case aboveBG, belowBG, threshold, predictiveMinutes, delta case persistentMinutes, suppressIfRising, suppressIfFalling, suppressIfCharging case monitoringWindow, soundFile @@ -129,6 +133,7 @@ struct Alarm: Identifiable, Codable, Equatable { type = try container.decode(AlarmType.self, forKey: .type) name = try container.decode(String.self, forKey: .name) isEnabled = try container.decodeIfPresent(Bool.self, forKey: .isEnabled) ?? true + showInCarPlay = try container.decodeIfPresent(Bool.self, forKey: .showInCarPlay) ?? false snoozedUntil = try container.decodeIfPresent(Date.self, forKey: .snoozedUntil) aboveBG = try container.decodeIfPresent(Double.self, forKey: .aboveBG) belowBG = try container.decodeIfPresent(Double.self, forKey: .belowBG) @@ -162,6 +167,7 @@ struct Alarm: Identifiable, Codable, Equatable { try container.encode(type, forKey: .type) try container.encode(name, forKey: .name) try container.encode(isEnabled, forKey: .isEnabled) + try container.encode(showInCarPlay, forKey: .showInCarPlay) try container.encodeIfPresent(snoozedUntil, forKey: .snoozedUntil) try container.encodeIfPresent(aboveBG, forKey: .aboveBG) try container.encodeIfPresent(belowBG, forKey: .belowBG) @@ -219,7 +225,8 @@ struct Alarm: Identifiable, Codable, Equatable { var sensorLifetimeDays: Int? /// Extra notification text: the CGM state behind a gap in readings. - private var notificationBody: String? { + /// Also shown under the alarm on the CarPlay widget. + var notificationBody: String? { guard type == .missedReading || type == .notLooping, let state = Observable.shared.cgmSensorState.value else { return nil } diff --git a/LoopFollow/Alarm/AlarmCondition/AlarmCondition.swift b/LoopFollow/Alarm/AlarmCondition/AlarmCondition.swift index 242d84069..c9d084ac3 100644 --- a/LoopFollow/Alarm/AlarmCondition/AlarmCondition.swift +++ b/LoopFollow/Alarm/AlarmCondition/AlarmCondition.swift @@ -15,6 +15,9 @@ protocol AlarmCondition { /// threshold, checked in `evaluate`; `false` (the default) treats them /// as activation limits enforced by `passesBGLimits`. var checksOwnBGLimits: Bool { get } + /// Whether the condition holds now, without side effects. A requirement + /// so the COB and recommended bolus overrides are dispatched dynamically. + func holds(alarm: Alarm, data: AlarmData, now: Date) -> Bool? } extension AlarmCondition { @@ -69,6 +72,22 @@ extension AlarmCondition { /// applies every global & per-alarm guard exactly once func shouldFire(alarm: Alarm, data: AlarmData, now: Date, config: AlarmConfiguration) -> Bool { + guard passesGuards(alarm: alarm, data: data, now: now, config: config) else { return false } + + // finally, run the type-specific logic + return evaluate(alarm: alarm, data: data, now: now) + } + + /// Whether the condition holds now, checked without the side effects + /// `evaluate` may have, for the CarPlay widget. Nil for conditions that + /// announce an event rather than a state. + func holds(alarm: Alarm, data: AlarmData, now: Date) -> Bool? { + alarm.type.isStateCondition ? evaluate(alarm: alarm, data: data, now: now) : nil + } + + /// The on/off switch, snooze, BG limits and time of day: every check + /// before the type-specific logic. + func passesGuards(alarm: Alarm, data: AlarmData, now: Date, config: AlarmConfiguration) -> Bool { // master on/off guard alarm.isEnabled else { return false } // per-alarm snooze @@ -94,7 +113,6 @@ extension AlarmCondition { guard isNight else { return false } } - // finally, run the type-specific logic - return evaluate(alarm: alarm, data: data, now: now) + return true } } diff --git a/LoopFollow/Alarm/AlarmCondition/COBCondition.swift b/LoopFollow/Alarm/AlarmCondition/COBCondition.swift index 5a1e876ca..423d0274d 100644 --- a/LoopFollow/Alarm/AlarmCondition/COBCondition.swift +++ b/LoopFollow/Alarm/AlarmCondition/COBCondition.swift @@ -23,4 +23,11 @@ struct COBCondition: AlarmCondition { Storage.shared.lastCOBNotified.value = cob return true } + + /// Carbs on board at or above the threshold, whether or not that was + /// already announced. + func holds(alarm: Alarm, data: AlarmData, now _: Date) -> Bool? { + guard let threshold = alarm.threshold, threshold > 0, let cob = data.COB else { return false } + return cob >= threshold + } } diff --git a/LoopFollow/Alarm/AlarmCondition/RecBolusCondition.swift b/LoopFollow/Alarm/AlarmCondition/RecBolusCondition.swift index 0dbdbd004..faa0519d6 100644 --- a/LoopFollow/Alarm/AlarmCondition/RecBolusCondition.swift +++ b/LoopFollow/Alarm/AlarmCondition/RecBolusCondition.swift @@ -35,4 +35,11 @@ struct RecBolusCondition: AlarmCondition { return shouldAlert } + + /// A recommended bolus at or above the threshold, whether or not that + /// was already announced. + func holds(alarm: Alarm, data: AlarmData, now _: Date) -> Bool? { + guard let threshold = alarm.threshold, threshold > 0, let rec = data.recBolus else { return false } + return rec >= threshold + } } diff --git a/LoopFollow/Alarm/AlarmEditing/Components/AlarmGeneralSection.swift b/LoopFollow/Alarm/AlarmEditing/Components/AlarmGeneralSection.swift index 720c9c107..fbcd3b5d6 100644 --- a/LoopFollow/Alarm/AlarmEditing/Components/AlarmGeneralSection.swift +++ b/LoopFollow/Alarm/AlarmEditing/Components/AlarmGeneralSection.swift @@ -9,7 +9,7 @@ struct AlarmGeneralSection: View { var body: some View { Section( header: Text("General"), - footer: Text("Give each alarm a unique name—especially if you’ve added more than one of the same kind—so you can tell them apart at a glance.") + footer: Text("Give each alarm a unique name—especially if you’ve added more than one of the same kind—so you can tell them apart at a glance. Turn on CarPlay to also show this alarm on the LoopFollow widget in CarPlay while it’s triggered.") ) { HStack { Text("Name") @@ -19,6 +19,7 @@ struct AlarmGeneralSection: View { .foregroundColor(.secondary) } Toggle("Enabled", isOn: $alarm.isEnabled) + Toggle("CarPlay", isOn: $alarm.showInCarPlay) } } } diff --git a/LoopFollow/Alarm/AlarmManager.swift b/LoopFollow/Alarm/AlarmManager.swift index 878bf5701..fc8e48b19 100644 --- a/LoopFollow/Alarm/AlarmManager.swift +++ b/LoopFollow/Alarm/AlarmManager.swift @@ -10,6 +10,14 @@ class AlarmManager { private let evaluators: [AlarmType: AlarmCondition] private var lastBGAlarmTime: Date? + /// When each alarm last fired and with what title, for the CarPlay + /// widget's event alarms. + private var lastFired: [UUID: (title: String, date: Date)] = [:] + + /// How long the CarPlay widget lists an alarm that announces an event, + /// such as an override starting, after it fires. + static let carPlayEventDuration: TimeInterval = 15 * 60 + private init( conditionTypes: [AlarmCondition.Type] = [ BuildExpireCondition.self, @@ -136,6 +144,7 @@ class AlarmManager { if titleOverride != Observable.shared.currentAlarmTitleOverride.value { Observable.shared.currentAlarmTitleOverride.value = titleOverride alarm.trigger(config: Storage.shared.alarmConfiguration.value, now: now, titleOverride: titleOverride) + lastFired[alarm.id] = (titleOverride ?? alarm.name, now) } break } @@ -145,6 +154,7 @@ class AlarmManager { Observable.shared.currentAlarm.value = alarm.id alarm.trigger(config: Storage.shared.alarmConfiguration.value, now: now, titleOverride: titleOverride) + lastFired[alarm.id] = (titleOverride ?? alarm.name, now) // Store the latest bg time so we don't use it again if alarm.type.isBGBased, @@ -173,6 +183,59 @@ class AlarmManager { } } + /// The alarms the CarPlay widget shows: each enabled alarm marked for + /// CarPlay that is triggered now, the first of each type, most important + /// first, with the title to show. Snoozed alarms count. + /// + /// Alarms about a state (low BG, carbs on board over the threshold, a + /// sensor about to expire) are listed while the state holds, checked + /// without the side effects of the alarm's own check. Alarms about an + /// event (an override starting, a missed bolus) are listed for + /// `carPlayEventDuration` after they fire. Whatever is sounding is listed. + func carPlayAlarms(data: AlarmData, now: Date) -> [(alarm: Alarm, title: String)] { + let config = Storage.shared.alarmConfiguration.value + let sounding = Observable.shared.currentAlarm.value + lastFired = lastFired.filter { now.timeIntervalSince($0.value.date) < Self.carPlayEventDuration } + + // Between readings the latest is up to 5 minutes old; allow until it + // is stale so BG alerts don't flicker off while waiting for the next. + let readingIsCurrent = data.bgReadings.last.map { + now.timeIntervalSince($0.date) < GlucoseSnapshot.staleAfter + } ?? false + + var result: [(alarm: Alarm, title: String)] = [] + var listedTypes = Set() + + for alarm in Storage.shared.alarms.value.sorted(by: Alarm.byPriorityThenSpec) { + guard alarm.showInCarPlay, !listedTypes.contains(alarm.type) else { continue } + + var title: String? + if alarm.id == sounding { + // A temporary alarm disables itself as it fires, so this + // comes before the check that the alarm is on. + title = Observable.shared.currentAlarmTitleOverride.value ?? alarm.name + } else if alarm.isEnabled, let checker = evaluators[alarm.type] { + if alarm.type.isBGBased, alarm.type != .missedReading, !readingIsCurrent { continue } + var unsnoozed = alarm + unsnoozed.snoozedUntil = nil + + if let holds = checker.holds(alarm: unsnoozed, data: data, now: now) { + if holds, checker.passesGuards(alarm: unsnoozed, data: data, now: now, config: config) { + title = alarm.name + } + } else { + title = lastFired[alarm.id]?.title + } + } + + if let title { + result.append((alarm, title)) + listedTypes.insert(alarm.type) + } + } + return result + } + func performSnooze(_ snoozeUnits: Int? = nil) { guard let alarmID = Observable.shared.currentAlarm.value else { return } var alarms = Storage.shared.alarms.value diff --git a/LoopFollow/Alarm/AlarmType/AlarmType.swift b/LoopFollow/Alarm/AlarmType/AlarmType.swift index 45c2aa9af..bd99e5147 100644 --- a/LoopFollow/Alarm/AlarmType/AlarmType.swift +++ b/LoopFollow/Alarm/AlarmType/AlarmType.swift @@ -49,4 +49,19 @@ extension AlarmType { return false } } + + /// `true` for alarms whose check only reads its inputs, so it can be run + /// again (for the CarPlay widget) without side effects. The others record + /// that they fired, to announce each event once. + var isStateCondition: Bool { + switch self { + case .low, .high, .fastDrop, .fastRise, .missedReading, .notLooping, .temporary, + .iob, .sensorChange, .pumpChange, .pump, .pumpBattery, .battery, .batteryDrop, + .buildExpire, .dbSize: + return true + case .cob, .recBolus, .missedBolus, .futureCarbs, + .overrideStart, .overrideEnd, .tempTargetStart, .tempTargetEnd: + return false + } + } } diff --git a/LoopFollow/CarPlayWidget/CarPlayWidgetPublisher.swift b/LoopFollow/CarPlayWidget/CarPlayWidgetPublisher.swift new file mode 100644 index 000000000..9902ba464 --- /dev/null +++ b/LoopFollow/CarPlayWidget/CarPlayWidgetPublisher.swift @@ -0,0 +1,198 @@ +// LoopFollow +// CarPlayWidgetPublisher.swift + +#if !targetEnvironment(macCatalyst) + + import Foundation + import UIKit + import WidgetKit + + /// Writes the CarPlay widget's state and asks WidgetKit to redraw it, only + /// when something it shows has changed: redraws are rationed. + enum CarPlayWidgetPublisher { + private static var lastPublished: Data? + private static var lastState: CarPlayWidgetState? + + /// Changes this close together share one widget push, since each + /// push spends part of the widget's daily allowance. + static let pushDelay: TimeInterval = 10 + + private static var pendingPush: DispatchWorkItem? + private static var pushAssertion: UIBackgroundTaskIdentifier = .invalid + + /// After each alarm check: the reading, the 3 h history and the alerts. + static func publish(alarmData: AlarmData, readings: [ShareGlucoseData], now: Date = Date()) { + let unit = PreferredGlucoseUnit.snapshotUnit() + let alerts = AlarmManager.shared.carPlayAlarms(data: alarmData, now: now).map { + CarPlayAlert(alarm: $0.alarm, title: $0.title, data: alarmData, unit: unit) + } + write(CarPlayWidgetState( + snapshot: GlucoseSnapshotBuilder.build(from: StorageCurrentGlucoseStateProvider()), + history: history(from: readings, now: now), + alerts: alerts + ), reason: "alarm check") + } + + /// After each Live Activity refresh, so the widget's reading, IOB, + /// COB and override change when the Live Activity's do. The history + /// and alerts stay as the last alarm check left them. + static func publish(snapshot: GlucoseSnapshot) { + guard let previous = lastState ?? CarPlayWidgetStore.load() else { return } + var state = previous + state.snapshot = snapshot + write(state, reason: "live activity") + } + + private static func write(_ state: CarPlayWidgetState, reason: String) { + guard let data = CarPlayWidgetStore.encode(state), data != lastPublished else { return } + CarPlayWidgetStore.save(data) + lastPublished = data + lastState = state + + // The widget notes when it last built a timeline. If that stays + // older than the previous request, the system is holding reloads back. + let built = CarPlayWidgetStore.timelineBuiltAt.map { "\(Int(Date().timeIntervalSince($0)))s ago" } ?? "never" + LogManager.shared.log( + category: .carPlay, + message: "Widget reload requested (\(reason)); last timeline built \(built)", + isDebug: true + ) + WidgetCenter.shared.reloadTimelines(ofKind: CarPlayWidgetStore.kind) + schedulePush() + } + + /// In the background the system holds reload requests back, so a + /// widget push follows the request there. In the foreground the + /// request is enough. + private static func schedulePush() { + guard #available(iOS 26.0, *) else { return } + DispatchQueue.main.async { + guard UIApplication.shared.applicationState != .active else { return } + pendingPush?.cancel() + if pushAssertion == .invalid { + // Keeps the app running until the delayed push has gone. + pushAssertion = UIApplication.shared.beginBackgroundTask(withName: "CarPlay widget push") { + endPushAssertion() + } + } + let work = DispatchWorkItem { + pendingPush = nil + Task { @MainActor in + await sendPush() + if pendingPush == nil { endPushAssertion() } + } + } + pendingPush = work + DispatchQueue.main.asyncAfter(deadline: .now() + pushDelay, execute: work) + } + } + + @available(iOS 26.0, *) + private static func sendPush() async { + // WidgetKit's current token, else the one the widget last stored. + let info = await WidgetCenter.shared.currentPushInfo + guard let token = info.map({ CarPlayWidgetStore.hex($0.token) }) ?? CarPlayWidgetStore.pushToken else { + LogManager.shared.log(category: .carPlay, message: "Widget push skipped: no push token yet", isDebug: true) + return + } + if await APNSClient.shared.sendWidgetUpdate(pushToken: token) == .tokenInvalid { + CarPlayWidgetStore.pushToken = nil + } + } + + private static func endPushAssertion() { + guard pushAssertion != .invalid else { return } + UIApplication.shared.endBackgroundTask(pushAssertion) + pushAssertion = .invalid + } + + /// Readings inside the 3 h page's window, oldest first. + static func history(from readings: [ShareGlucoseData], now: Date) -> [GlucosePoint] { + let start = now.timeIntervalSince1970 - CarPlayWidgetState.historyWindow + return readings + .filter { $0.sgv > 0 && $0.date >= start } + .sorted { $0.date < $1.date } + .map { GlucosePoint(date: Date(timeIntervalSince1970: $0.date), mgdl: Double($0.sgv)) } + } + } + +#endif + +extension CarPlayAlert { + /// A triggered alarm, worded for the widget with the icon LoopFollow uses + /// for its type. + init(alarm: Alarm, title: String, data: AlarmData, unit: GlucoseSnapshot.Unit) { + self.init( + id: alarm.id, + title: title, + detail: Self.detail(for: alarm, data: data, unit: unit), + symbol: alarm.type.icon, + isUrgent: Self.isUrgent(alarm.type) + ) + } + + /// Red rather than orange: the alarms about glucose or the loop that + /// need attention now. + static func isUrgent(_ type: AlarmType) -> Bool { + switch type { + case .low, .fastDrop, .missedReading, .notLooping, .temporary: + true + default: + false + } + } + + /// The value behind the alarm. Missed reading and not looping use the + /// alarm notification's own text when the CGM reported a state. + static func detail(for alarm: Alarm, data: AlarmData, unit: GlucoseSnapshot.Unit) -> String? { + if let body = alarm.notificationBody { + return body + } + + switch alarm.type { + case .low, .high, .fastDrop, .fastRise, .temporary: + guard let sgv = data.bgReadings.last?.sgv, sgv > 0 else { return nil } + return glucose(Double(sgv), unit: unit) + case .missedReading: + return data.bgReadings.last.map { "Last reading \(GlucoseSnapshot.clock($0.date))" } + case .notLooping: + guard let loop = data.lastLoopTime, loop > 0 else { return nil } + return "Last loop \(GlucoseSnapshot.clock(Date(timeIntervalSince1970: loop)))" + case .sensorChange: + guard let inserted = data.sageInsertTime, inserted > 0 else { return nil } + let lifetime = Double(alarm.sensorLifetimeDays ?? 10) * 86400 + return "Expires \(GlucoseSnapshot.clock(Date(timeIntervalSince1970: inserted + lifetime)))" + case .pumpChange: + // The same 3-day site life the pump change alarm uses. + guard let inserted = data.pumpInsertTime, inserted > 0 else { return nil } + return "Expires \(GlucoseSnapshot.clock(Date(timeIntervalSince1970: inserted + 3 * 86400)))" + case .pump: + return data.latestPumpVolume.map { "\(Int($0.rounded())) U left" } + case .pumpBattery: + return data.latestPumpBattery.map { "\(Int($0.rounded()))%" } + case .battery, .batteryDrop: + return data.latestBattery.map { "\(Int($0.rounded()))%" } + case .iob: + return data.IOB.map { "\($0.formatted(.number.precision(.fractionLength(1)))) U" } + case .cob: + return data.COB.map { "\(Int($0.rounded())) g" } + case .recBolus: + return data.recBolus.map { "\($0.formatted(.number.precision(.fractionLength(2)))) U" } + case .buildExpire: + return data.expireDate.map { "Expires \($0.formatted(.dateTime.weekday(.abbreviated).day().month(.abbreviated)))" } + case .dbSize: + return data.dbSizePercentage.map { "\(Int($0.rounded()))% full" } + case .missedBolus, .futureCarbs, .overrideStart, .overrideEnd, .tempTargetStart, .tempTargetEnd: + return nil + } + } + + private static func glucose(_ mgdl: Double, unit: GlucoseSnapshot.Unit) -> String { + switch unit { + case .mgdl: + "\(Int(mgdl.rounded())) mg/dL" + case .mmol: + "\(GlucoseConversion.toMmol(mgdl).formatted(.number.precision(.fractionLength(1)))) mmol/L" + } + } +} diff --git a/LoopFollow/LiveActivity/APNSClient.swift b/LoopFollow/LiveActivity/APNSClient.swift index 5b5e0ccae..091251b93 100644 --- a/LoopFollow/LiveActivity/APNSClient.swift +++ b/LoopFollow/LiveActivity/APNSClient.swift @@ -101,6 +101,63 @@ class APNSClient { } } + // MARK: - Send Widget Update (iOS 26+) + + enum WidgetPushResult { + case sent + /// The token is no longer valid; the widget hands over a new one. + case tokenInvalid + case failed + } + + /// Asks WidgetKit to reload LoopFollow's widgets. The payload carries no + /// data: the widget reads the App Group when it rebuilds its timeline. + /// The system rations these and delivers them when it chooses. + func sendWidgetUpdate(pushToken: String) async -> WidgetPushResult { + guard let jwt = JWTManager.shared.getOrGenerateJWT(keyId: lfKeyId, teamId: lfTeamId, apnsKey: lfApnsKey) else { + LogManager.shared.log(category: .carPlay, message: "Widget push skipped: no APNs key to sign it", isDebug: true) + return .failed + } + + guard let url = URL(string: "\(apnsHost)/3/device/\(pushToken)"), + let payload = try? JSONSerialization.data(withJSONObject: ["aps": ["content-changed": true]]) + else { + return .failed + } + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("bearer \(jwt)", forHTTPHeaderField: "authorization") + request.setValue("application/json", forHTTPHeaderField: "content-type") + request.setValue("\(bundleID).push-type.widgets", forHTTPHeaderField: "apns-topic") + request.setValue("widgets", forHTTPHeaderField: "apns-push-type") + request.httpBody = payload + + do { + let (data, response) = try await URLSession.shared.data(for: request) + guard let httpResponse = response as? HTTPURLResponse else { return .failed } + let body = String(data: data, encoding: .utf8) ?? "" + switch httpResponse.statusCode { + case 200: + LogManager.shared.log(category: .carPlay, message: "Widget push sent", isDebug: true) + return .sent + case 403: + JWTManager.shared.invalidateCache() + LogManager.shared.log(category: .carPlay, message: "Widget push JWT rejected (403): \(body)", isDebug: true) + return .failed + case 400 where body.contains("BadDeviceToken"), 404, 410: + LogManager.shared.log(category: .carPlay, message: "Widget push token invalid (\(httpResponse.statusCode)): \(body)", isDebug: true) + return .tokenInvalid + default: + LogManager.shared.log(category: .carPlay, message: "Widget push failed (\(httpResponse.statusCode)): \(body)", isDebug: true) + return .failed + } + } catch { + LogManager.shared.log(category: .carPlay, message: "Widget push error: \(error.localizedDescription)", isDebug: true) + return .failed + } + } + // MARK: - Send Live Activity Start (push-to-start, iOS 17.2+) enum PushToStartResult { diff --git a/LoopFollow/LiveActivity/LiveActivityManager.swift b/LoopFollow/LiveActivity/LiveActivityManager.swift index 5a8a374bc..d8a3c0965 100644 --- a/LoopFollow/LiveActivity/LiveActivityManager.swift +++ b/LoopFollow/LiveActivity/LiveActivityManager.swift @@ -1076,6 +1076,7 @@ final class LiveActivityManager { // Store: always update, independent of LA state. writeThresholds() GlucoseSnapshotStore.shared.save(snapshot) + CarPlayWidgetPublisher.publish(snapshot: snapshot) // LA update: gated on LA being active, snapshot having changed, and activities enabled. if !Storage.shared.laEnabled.value { diff --git a/LoopFollow/LiveActivitySettingsView.swift b/LoopFollow/LiveActivitySettingsView.swift index c2b94c36f..912e38d28 100644 --- a/LoopFollow/LiveActivitySettingsView.swift +++ b/LoopFollow/LiveActivitySettingsView.swift @@ -128,7 +128,7 @@ .navigationBarTitleDisplayMode(.inline) } - private static let statusLineInfo = "Shows a line under the reading with the update time, IOB and COB, or Not Looping when Loop stops reporting, then any active override with its insulin needs, range and time left. While it is on, IOB, COB and Override are not offered for the right slot. Changes appear with the next Live Activity update." + private static let statusLineInfo = "Shows a line under the reading with the update time, IOB and COB, or Not Looping when Loop stops reporting, then any active override with its insulin needs, range and end time. While it is on, IOB, COB and Override are not offered for the right slot. Changes appear with the next Live Activity update." private static let tightLayoutInfo = "Replaces the right slot with projected BG, IOB, COB and basal stacked on the right, and reduces the status line to the time. It only applies to wide CarPlay screens; the Watch and narrow CarPlay screens keep the regular layout. Changes appear with the next Live Activity update." diff --git a/LoopFollow/Log/LogManager.swift b/LoopFollow/Log/LogManager.swift index 5bb40eeb6..05a4fdfc9 100644 --- a/LoopFollow/Log/LogManager.swift +++ b/LoopFollow/Log/LogManager.swift @@ -32,6 +32,7 @@ class LogManager { case remote = "Remote" case websocket = "WebSocket" case telemetry = "Telemetry" + case carPlay = "CarPlay" } init() { diff --git a/LoopFollow/Task/AlarmTask.swift b/LoopFollow/Task/AlarmTask.swift index d74289ecf..a6ebd74a9 100644 --- a/LoopFollow/Task/AlarmTask.swift +++ b/LoopFollow/Task/AlarmTask.swift @@ -72,6 +72,10 @@ extension MainViewController { AlarmManager.shared.checkAlarms(data: finalAlarmData) + #if !targetEnvironment(macCatalyst) + CarPlayWidgetPublisher.publish(alarmData: finalAlarmData, readings: self.bgData) + #endif + TaskScheduler.shared.rescheduleTask(id: .alarmCheck, to: Date().addingTimeInterval(60)) } } diff --git a/LoopFollowLAExtension/CarPlayWidget.swift b/LoopFollowLAExtension/CarPlayWidget.swift new file mode 100644 index 000000000..b51d933db --- /dev/null +++ b/LoopFollowLAExtension/CarPlayWidget.swift @@ -0,0 +1,394 @@ +// LoopFollow +// CarPlayWidget.swift + +import AppIntents +import Charts +import SwiftUI +import WidgetKit + +/// A small widget for the CarPlay Widgets screen, also usable on the Home +/// Screen. One page at a time; the button in the corner steps through them. +/// iOS 26 and later, which brought widgets to CarPlay and lets the app wake +/// the widget with a push while it runs in the background. +@available(iOS 26.0, *) +struct CarPlayWidget: Widget { + var body: some WidgetConfiguration { + StaticConfiguration(kind: CarPlayWidgetStore.kind, provider: CarPlayWidgetProvider()) { entry in + CarPlayWidgetView(entry: entry) + } + .configurationDisplayName("LoopFollow") + .description("Glucose with any override, alerts when there are any, and the last 3 hours.") + .supportedFamilies([.systemSmall]) + .pushHandler(CarPlayWidgetPushHandler.self) + } +} + +/// Leaves the widget's push token in the App Group. The app sends a widget +/// push to it after a change, since the system holds back the reloads an app +/// asks for while it runs in the background. +@available(iOS 26.0, *) +struct CarPlayWidgetPushHandler: WidgetPushHandler { + func pushTokenDidChange(_ pushInfo: WidgetPushInfo, widgets _: [WidgetInfo]) { + CarPlayWidgetStore.pushToken = CarPlayWidgetStore.hex(pushInfo.token) + } +} + +/// Moves the widget to its next page. Runs in the widget extension; WidgetKit +/// redraws the widget as soon as it returns. +struct NextCarPlayWidgetPageIntent: AppIntent { + static var title: LocalizedStringResource = "Next Widget Page" + static var description = IntentDescription("Shows the next page of the LoopFollow widget.") + static var isDiscoverable = false + + func perform() async throws -> some IntentResult { + let hasAlerts = CarPlayWidgetStore.load()?.hasAlerts ?? false + CarPlayWidgetStore.page = CarPlayWidgetStore.page.next(hasAlerts: hasAlerts) + return .result() + } +} + +// MARK: - Timeline + +struct CarPlayWidgetEntry: TimelineEntry { + let date: Date + let state: CarPlayWidgetState? + let page: CarPlayWidgetPage +} + +struct CarPlayWidgetProvider: TimelineProvider { + func placeholder(in _: Context) -> CarPlayWidgetEntry { + CarPlayWidgetEntry(date: Date(), state: nil, page: .now) + } + + func getSnapshot(in _: Context, completion: @escaping (CarPlayWidgetEntry) -> Void) { + completion(CarPlayWidgetEntry(date: Date(), state: CarPlayWidgetStore.load(), page: CarPlayWidgetStore.page)) + } + + /// The app asks for a reload whenever the data changes. In between, one + /// entry a minute for half an hour, then every five minutes to the hour, + /// brings in the stale cue on time, ends an override when it ends and + /// moves the 3 h chart along, including when the app has stopped updating. + /// The text itself holds clock times, so nothing ticks while driving. + func getTimeline(in _: Context, completion: @escaping (Timeline) -> Void) { + let now = Date() + CarPlayWidgetStore.timelineBuiltAt = now + let state = CarPlayWidgetStore.load() + let page = CarPlayWidgetStore.page + let minutes = Array(0 ..< 30) + Array(stride(from: 30, through: 60, by: 5)) + let entries = minutes.map { + CarPlayWidgetEntry(date: now.addingTimeInterval(Double($0) * 60), state: state, page: page) + } + completion(Timeline(entries: entries, policy: .atEnd)) + } +} + +// MARK: - Views + +struct CarPlayWidgetView: View { + let entry: CarPlayWidgetEntry + + var body: some View { + let alerts = entry.state?.alerts ?? [] + let page = entry.page.resolved(hasAlerts: !alerts.isEmpty) + + VStack(alignment: .leading, spacing: 4) { + Group { + if let state = entry.state, let snapshot = state.snapshot { + switch page { + case .now: + CarPlayNowPage(snapshot: snapshot, date: entry.date, hasAlerts: !alerts.isEmpty) + case .alerts: + CarPlayAlertsPage(alerts: alerts) + case .trend: + CarPlayTrendPage(snapshot: snapshot, history: state.history, date: entry.date) + } + } else { + CarPlayNoDataPage() + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .invalidatableContent() + + CarPlayWidgetFooter(page: page, hasAlerts: !alerts.isEmpty) + } + .foregroundStyle(.white) + .containerBackground(Color(white: 0.17), for: .widget) + } +} + +/// The reading, trend, delta and reading time, any running override, then +/// projected BG, IOB, COB and basal with the same icons as the tight CarPlay +/// layout. A yellow warning sign by the reading means there are alerts. +private struct CarPlayNowPage: View { + let snapshot: GlucoseSnapshot + let date: Date + let hasAlerts: Bool + + var body: some View { + let isStale = snapshot.isStale(at: date) + let color = isStale ? Color.white.opacity(0.55) : LAColors.keyline(for: snapshot) + + VStack(alignment: .leading, spacing: 2) { + HStack(alignment: .firstTextBaseline, spacing: 3) { + Group { + Text(LAFormat.glucose(snapshot)) + .font(.system(size: 32, weight: .bold, design: .rounded)) + .monospacedDigit() + .strikethrough(isStale, pattern: .solid, color: .red) + .lineLimit(1) + .minimumScaleFactor(0.7) + Text(LAFormat.trendArrow(snapshot)) + .font(.system(size: 19, weight: .semibold, design: .rounded)) + } + .foregroundStyle(color) + + Spacer(minLength: 2) + + if hasAlerts { + Image(systemName: "exclamationmark.triangle.fill") + .font(.system(size: 22, weight: .semibold)) + .foregroundStyle(.yellow) + .accessibilityLabel("Alerts on the next page") + } + } + + Text("\(LAFormat.delta(snapshot)) \(snapshot.unit.displayName) · \(GlucoseSnapshot.clock(snapshot.updatedAt, now: date))") + .font(.system(size: 11, weight: .semibold, design: .rounded)) + .monospacedDigit() + .foregroundStyle(isStale ? Color.red : Color.white.opacity(0.75)) + .lineLimit(1) + .minimumScaleFactor(0.8) + + if snapshot.hasActiveOverride(at: date) { + CarPlayOverrideLine(details: snapshot.carPlayOverrideDetails(now: date)) + } + + Spacer(minLength: 4) + + HStack(alignment: .top) { + VStack(alignment: .leading, spacing: 4) { + metric(LAFormat.projected(snapshot), label: "Projected glucose") { Image(systemName: "target") } + metric(LAFormat.cob(snapshot), label: "Carbs on board") { Image(systemName: "fork.knife") } + } + Spacer(minLength: 4) + VStack(alignment: .leading, spacing: 4) { + metric(LAFormat.iob(snapshot), label: "Insulin on board") { Image(systemName: "syringe") } + metric(LAFormat.activeBasal(snapshot), label: "Basal rate") { + BasalStepIcon() + .stroke(style: StrokeStyle(lineWidth: 1.5, lineCap: .round, lineJoin: .round)) + .frame(width: 13, height: 9) + } + } + } + .font(.system(size: 14, weight: .semibold, design: .rounded)) + } + } + + private func metric(_ value: String, label: String, @ViewBuilder icon: () -> some View) -> some View { + HStack(spacing: 4) { + icon() + .foregroundStyle(.white.opacity(0.65)) + .frame(width: 15) + .accessibilityHidden(true) + Text(value) + .monospacedDigit() + .lineLimit(1) + .minimumScaleFactor(0.8) + .accessibilityLabel("\(label) \(value)") + } + } +} + +/// "105% · 5.0–5.6 · 14:20": the override's insulin needs, target range and +/// end time, or ∞ when it has none. +private struct CarPlayOverrideLine: View { + let details: [String] + + var body: some View { + Text("\(Image(systemName: "slider.horizontal.3")) \(details.joined(separator: " · "))") + .font(.system(size: 11, weight: .semibold, design: .rounded)) + .monospacedDigit() + .foregroundStyle(.green) + .lineLimit(1) + .minimumScaleFactor(0.7) + .accessibilityLabel( + (["Override"] + details.map { $0 == "∞" ? "no end time" : $0 }).joined(separator: ", ") + ) + } +} + +/// The last three hours against the target range, with the reading in the corner. +private struct CarPlayTrendPage: View { + let snapshot: GlucoseSnapshot + let history: [GlucosePoint] + let date: Date + + var body: some View { + let thresholds = LAAppGroupSettings.thresholdsMgdl() + let low = display(thresholds.low) + let high = display(thresholds.high) + let values = history.map { display($0.mgdl) } + let yMin = min(values.min() ?? low, low) * 0.9 + let yMax = max(values.max() ?? high, high) * 1.05 + let start = date.addingTimeInterval(-CarPlayWidgetState.historyWindow) + let isStale = snapshot.isStale(at: date) + + VStack(alignment: .leading, spacing: 4) { + HStack(alignment: .firstTextBaseline) { + Text("Last 3 h") + .font(.system(size: 11, weight: .semibold, design: .rounded)) + .foregroundStyle(.white.opacity(0.75)) + Spacer() + Text("\(LAFormat.glucose(snapshot)) \(LAFormat.trendArrow(snapshot))") + .font(.system(size: 18, weight: .bold, design: .rounded)) + .monospacedDigit() + .strikethrough(isStale, pattern: .solid, color: .red) + .foregroundStyle(isStale ? Color.white.opacity(0.55) : LAColors.keyline(for: snapshot)) + } + + Chart { + RectangleMark( + xStart: .value("Start", start), + xEnd: .value("End", date), + yStart: .value("Low", low), + yEnd: .value("High", high) + ) + .foregroundStyle(Color.green.opacity(0.18)) + + ForEach(history, id: \.date) { point in + PointMark( + x: .value("Time", point.date), + y: .value("Glucose", display(point.mgdl)) + ) + .symbolSize(10) + .foregroundStyle(color(for: point.mgdl, low: thresholds.low, high: thresholds.high)) + } + } + .chartXScale(domain: start ... date) + .chartYScale(domain: yMin ... yMax) + .chartXAxis(.hidden) + .chartYAxis(.hidden) + .accessibilityLabel("Glucose over the last 3 hours") + + Text("Range \(format(low))–\(format(high))") + .font(.system(size: 10, weight: .semibold, design: .rounded)) + .foregroundStyle(.white.opacity(0.6)) + } + } + + private func display(_ mgdl: Double) -> Double { + snapshot.unit == .mmol ? GlucoseConversion.toMmol(mgdl) : mgdl + } + + private func format(_ value: Double) -> String { + snapshot.unit == .mmol + ? value.formatted(.number.precision(.fractionLength(1))) + : "\(Int(value.rounded()))" + } + + private func color(for mgdl: Double, low: Double, high: Double) -> Color { + if mgdl <= low { return .red } + if mgdl >= high { return .orange } + return .green + } +} + +/// The triggered alarms marked for CarPlay, most important first. Only in +/// the cycle while there are any. +private struct CarPlayAlertsPage: View { + let alerts: [CarPlayAlert] + + var body: some View { + // Three rows fit; with more, two and a count of the rest. + let shown = alerts.count > 3 ? Array(alerts.prefix(2)) : alerts + VStack(alignment: .leading, spacing: 6) { + ForEach(shown) { alert in + CarPlayAlertRow(alert: alert) + } + if alerts.count > shown.count { + Text("+\(alerts.count - shown.count) more") + .font(.system(size: 11, weight: .semibold, design: .rounded)) + .foregroundStyle(.white.opacity(0.7)) + } + } + } +} + +private struct CarPlayAlertRow: View { + let alert: CarPlayAlert + + var body: some View { + HStack(alignment: .top, spacing: 6) { + Image(systemName: alert.symbol) + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(alert.isUrgent ? Color.red : Color.orange) + .frame(width: 16) + .accessibilityHidden(true) + VStack(alignment: .leading, spacing: 0) { + Text(alert.title) + .font(.system(size: 13, weight: .bold, design: .rounded)) + .lineLimit(1) + .minimumScaleFactor(0.8) + if let detail = alert.detail { + Text(detail) + .font(.system(size: 11, weight: .semibold, design: .rounded)) + .monospacedDigit() + .foregroundStyle(.white.opacity(0.7)) + .lineLimit(1) + .minimumScaleFactor(0.8) + } + } + } + .accessibilityElement(children: .combine) + } +} + +private struct CarPlayNoDataPage: View { + var body: some View { + VStack(alignment: .leading, spacing: 4) { + Text("No data yet") + .font(.system(size: 16, weight: .semibold, design: .rounded)) + Text("Open LoopFollow on your iPhone.") + .font(.system(size: 12, weight: .semibold, design: .rounded)) + .foregroundStyle(.white.opacity(0.7)) + } + } +} + +/// The page name and dots, and the button to the next page, yellow when it +/// leads to Alerts. +private struct CarPlayWidgetFooter: View { + let page: CarPlayWidgetPage + let hasAlerts: Bool + + var body: some View { + let leadsToAlerts = page.next(hasAlerts: hasAlerts) == .alerts + + HStack(spacing: 5) { + Text(page.title) + .font(.system(size: 11, weight: .semibold, design: .rounded)) + .foregroundStyle(.white.opacity(0.7)) + + HStack(spacing: 3) { + ForEach(CarPlayWidgetPage.sequence(hasAlerts: hasAlerts), id: \.self) { item in + Circle() + .fill(item == page ? Color.white : Color.white.opacity(0.35)) + .frame(width: 5, height: 5) + } + } + .accessibilityHidden(true) + + Spacer(minLength: 0) + + Button(intent: NextCarPlayWidgetPageIntent()) { + Image(systemName: "chevron.right") + .font(.system(size: 13, weight: .bold)) + .foregroundStyle(leadsToAlerts ? Color.black : Color.white) + .frame(width: 28, height: 28) + .background(Circle().fill(leadsToAlerts ? Color.yellow : Color.white.opacity(0.2))) + } + .buttonStyle(.plain) + .accessibilityLabel(leadsToAlerts ? "Show alerts" : "Next page") + } + } +} diff --git a/LoopFollowLAExtension/LoopFollowLABundle.swift b/LoopFollowLAExtension/LoopFollowLABundle.swift index 1dc9d75ed..e0458415a 100644 --- a/LoopFollowLAExtension/LoopFollowLABundle.swift +++ b/LoopFollowLAExtension/LoopFollowLABundle.swift @@ -8,5 +8,8 @@ import WidgetKit struct LoopFollowLABundle: WidgetBundle { var body: some Widget { LoopFollowLiveActivityWidget() + if #available(iOS 26.0, *) { + CarPlayWidget() + } } } diff --git a/LoopFollowLAExtension/LoopFollowLiveActivity.swift b/LoopFollowLAExtension/LoopFollowLiveActivity.swift index ed3a91ffd..e509901d9 100644 --- a/LoopFollowLAExtension/LoopFollowLiveActivity.swift +++ b/LoopFollowLAExtension/LoopFollowLiveActivity.swift @@ -323,7 +323,7 @@ private struct SmallFamilyStack: View { } /// A square step, drawn because SF Symbols has no square wave. -private struct BasalStepIcon: Shape { +struct BasalStepIcon: Shape { func path(in rect: CGRect) -> Path { let step = rect.width / 4 var path = Path() @@ -352,8 +352,8 @@ private struct SmallFamilyUpdateTime: View { } } -/// "Name · 80% · 140–160 · 1h 20m". When space runs out the name truncates -/// first, then the details shrink, so the percentage, range and time left stay. +/// "Name · 80% · 140–160 · 14:20". When space runs out the name truncates +/// first, then the details shrink, so the percentage, range and end time stay. private struct SmallFamilyOverride: View { let name: String let details: [String] @@ -853,7 +853,7 @@ private struct DynamicIslandMinimalView: View { // MARK: - Formatting -private enum LAFormat { +enum LAFormat { private static let mgdlFormatter: NumberFormatter = { let nf = NumberFormatter() nf.numberStyle = .decimal @@ -1057,7 +1057,7 @@ private enum LAFormat { // MARK: - Threshold-driven colors -private enum LAColors { +enum LAColors { static func backgroundTint(for snapshot: GlucoseSnapshot) -> Color { let mgdl = snapshot.glucose let t = LAAppGroupSettings.thresholdsMgdl() diff --git a/LoopFollowLAExtensionExtension.entitlements b/LoopFollowLAExtensionExtension.entitlements index 5b963cc90..ff61cb970 100644 --- a/LoopFollowLAExtensionExtension.entitlements +++ b/LoopFollowLAExtensionExtension.entitlements @@ -2,6 +2,8 @@ + aps-environment + development com.apple.security.application-groups group.com.$(unique_id).LoopFollow$(app_suffix) diff --git a/Shared/CarPlayWidgetState.swift b/Shared/CarPlayWidgetState.swift new file mode 100644 index 000000000..96c55ea6d --- /dev/null +++ b/Shared/CarPlayWidgetState.swift @@ -0,0 +1,176 @@ +// LoopFollow +// CarPlayWidgetState.swift + +import Foundation + +/// What the CarPlay widget shows. The app writes it to the App Group and the +/// widget reads it back; nothing here depends on either side's code. +struct CarPlayWidgetState: Codable, Equatable { + /// The latest reading and loop values; nil before the first reading. + var snapshot: GlucoseSnapshot? + + /// Readings from the last `historyWindow`, oldest first. + var history: [GlucosePoint] + + /// The user's alarms marked for CarPlay that are triggered, most + /// important first. + var alerts: [CarPlayAlert] + + /// How far back the 3 h page reaches. + static let historyWindow: TimeInterval = 3 * 3600 + + var hasAlerts: Bool { + !alerts.isEmpty + } +} + +extension GlucoseSnapshot { + /// True while an override runs at `now`. + func hasActiveOverride(at now: Date) -> Bool { + guard override != nil else { return false } + if let end = overrideEndAt, end <= now.timeIntervalSince1970 { return false } + return true + } + + /// The running override as the CarPlay widget shows it: insulin needs, + /// target range and end time, without the name. The range is the + /// override's own or else the loop's current target; the end is a clock + /// time, or "∞" for an override with no end. + func carPlayOverrideDetails(now: Date = Date()) -> [String] { + var parts = ["\(overridePercent ?? 100)%"] + + let low = overrideRangeLowMgdl ?? targetLowMgdl + let high = overrideRangeHighMgdl ?? targetHighMgdl + if let low, let high { + let lowText = Self.formatGlucose(low, unit: unit) + let highText = Self.formatGlucose(high, unit: unit) + parts.append(lowText == highText ? lowText : "\(lowText)–\(highText)") + } + + if let end = overrideEndAt { + parts.append(GlucoseSnapshot.clock(Date(timeIntervalSince1970: end), now: now)) + } else { + parts.append("∞") + } + return parts + } +} + +/// One CGM reading, in mg/dL. +struct GlucosePoint: Codable, Equatable { + let date: Date + let mgdl: Double +} + +/// One line on the Alerts page: a triggered alarm, already worded. The text +/// holds no countdowns, so it only changes when the alarm's state does and +/// the widget isn't reloaded every minute. +struct CarPlayAlert: Codable, Equatable, Identifiable { + let id: UUID + /// The alarm's name, or the phase it announces ("Override Ending Soon"). + let title: String + /// The value behind it: the reading, units left, an expiry time. + let detail: String? + /// SF Symbol for the alarm's type. + let symbol: String + /// Shown in red rather than orange. + let isUrgent: Bool +} + +/// The widget's pages, in the order the page button steps through them. +/// Alerts is only in the cycle while there is something to report. +enum CarPlayWidgetPage: Int, CaseIterable, Codable { + case now + case alerts + case trend + + var title: String { + switch self { + case .now: "Now" + case .alerts: "Alerts" + case .trend: "3 h" + } + } + + /// The pages in the cycle. + static func sequence(hasAlerts: Bool) -> [CarPlayWidgetPage] { + hasAlerts ? [.now, .alerts, .trend] : [.now, .trend] + } + + /// This page if it is in the cycle; Alerts with nothing to report falls + /// back to Now. + func resolved(hasAlerts: Bool) -> CarPlayWidgetPage { + Self.sequence(hasAlerts: hasAlerts).contains(self) ? self : .now + } + + /// The page the button leads to, wrapping round to Now. From an Alerts + /// page whose alerts have cleared, that is the page after it. + func next(hasAlerts: Bool) -> CarPlayWidgetPage { + let pages = Self.sequence(hasAlerts: hasAlerts) + if let index = pages.firstIndex(of: self) { + return pages[(index + 1) % pages.count] + } + return pages.first { $0.rawValue > rawValue } ?? .now + } +} + +/// The widget's state and current page in the App Group. +enum CarPlayWidgetStore { + /// The widget kind, used by the app to ask for a reload. + static let kind = "LoopFollowCarPlayWidget" + + private static var defaults: UserDefaults? { + UserDefaults(suiteName: AppGroupID.current()) + } + + private enum Keys { + static let state = "carPlayWidget.state" + static let page = "carPlayWidget.page" + static let timelineBuiltAt = "carPlayWidget.timelineBuiltAt" + static let pushToken = "carPlayWidget.pushToken" + } + + /// The token the app sends widget pushes to, as hex. The widget writes + /// it when WidgetKit hands it a new one. + static var pushToken: String? { + get { defaults?.string(forKey: Keys.pushToken) } + set { defaults?.set(newValue, forKey: Keys.pushToken) } + } + + /// A push token in the lowercase hex APNs expects in the request path. + static func hex(_ token: Data) -> String { + token.map { String(format: "%02x", $0) }.joined() + } + + /// When the widget last built a timeline. The app logs it next to each + /// reload it asks for, to show whether the system acted on the request. + static var timelineBuiltAt: Date? { + get { + let seconds = defaults?.double(forKey: Keys.timelineBuiltAt) ?? 0 + return seconds > 0 ? Date(timeIntervalSince1970: seconds) : nil + } + set { defaults?.set(newValue?.timeIntervalSince1970 ?? 0, forKey: Keys.timelineBuiltAt) } + } + + /// Encodes with sorted keys, so equal states give equal data and the app + /// can skip a reload when nothing changed. + static func encode(_ state: CarPlayWidgetState) -> Data? { + let encoder = JSONEncoder() + encoder.outputFormatting = .sortedKeys + return try? encoder.encode(state) + } + + static func save(_ data: Data) { + defaults?.set(data, forKey: Keys.state) + } + + static func load() -> CarPlayWidgetState? { + guard let data = defaults?.data(forKey: Keys.state) else { return nil } + return try? JSONDecoder().decode(CarPlayWidgetState.self, from: data) + } + + static var page: CarPlayWidgetPage { + get { CarPlayWidgetPage(rawValue: defaults?.integer(forKey: Keys.page) ?? 0) ?? .now } + set { defaults?.set(newValue.rawValue, forKey: Keys.page) } + } +} diff --git a/Shared/GlucoseSnapshot.swift b/Shared/GlucoseSnapshot.swift index 4c7173388..f6e657e17 100644 --- a/Shared/GlucoseSnapshot.swift +++ b/Shared/GlucoseSnapshot.swift @@ -254,9 +254,10 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable { basalRate.components(separatedBy: "→").last?.trimmingCharacters(in: .whitespaces) ?? basalRate } - /// The active override's insulin needs, target range and time left, each + /// The active override's insulin needs, target range and end time, each /// formatted, in that order. Parts that are unknown or don't apply are left - /// out: 100 % insulin needs, no range of its own, or no end time. + /// out: 100 % insulin needs, no range of its own, or no end time. The end + /// is a clock time rather than a countdown, so it doesn't tick in CarPlay. func overrideDetails(now: Date = Date()) -> [String] { var parts: [String] = [] if let percent = overridePercent { @@ -268,21 +269,20 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable { parts.append(lowText == highText ? lowText : "\(lowText)–\(highText)") } if let end = overrideEndAt, end > now.timeIntervalSince1970 { - parts.append(Self.timeLeft(end - now.timeIntervalSince1970)) + parts.append(Self.clock(Date(timeIntervalSince1970: end), now: now)) } return parts } - /// Whole minutes, rounded up so a running override never reads 0m: "45m", "2h", "1h 20m". - static func timeLeft(_ seconds: TimeInterval) -> String { - let minutes = max(1, Int((seconds / 60).rounded(.up))) - let hours = minutes / 60 - let rest = minutes % 60 - if hours == 0 { return "\(rest)m" } - return rest == 0 ? "\(hours)h" : "\(hours)h \(rest)m" + /// "14:05" today, "Sat 14:05" on another day, in the user's clock format. + static func clock(_ date: Date, now: Date = Date()) -> String { + if Calendar.current.isDate(date, inSameDayAs: now) { + return date.formatted(date: .omitted, time: .shortened) + } + return date.formatted(.dateTime.weekday(.abbreviated).hour().minute()) } - private static func formatGlucose(_ mgdl: Double, unit: Unit) -> String { + static func formatGlucose(_ mgdl: Double, unit: Unit) -> String { switch unit { case .mgdl: "\(Int(mgdl.rounded()))" diff --git a/Tests/CarPlayWidgetAlertsTests.swift b/Tests/CarPlayWidgetAlertsTests.swift new file mode 100644 index 000000000..ad04962bc --- /dev/null +++ b/Tests/CarPlayWidgetAlertsTests.swift @@ -0,0 +1,212 @@ +// LoopFollow +// CarPlayWidgetAlertsTests.swift + +import Foundation +@testable import LoopFollow +import Testing + +struct CarPlayWidgetAlertsTests { + // MARK: - The CarPlay toggle on alarms + + @Test("New alarms are not shown in CarPlay") + func offByDefault() { + for type in AlarmType.allCases { + #expect(Alarm(type: type).showInCarPlay == false) + } + } + + @Test("Alarms saved before the toggle existed decode with it off") + func decodesOldAlarms() throws { + let data = try JSONEncoder().encode(Alarm(type: .low)) + var object = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any]) + object.removeValue(forKey: "showInCarPlay") + let old = try JSONSerialization.data(withJSONObject: object) + #expect(try JSONDecoder().decode(Alarm.self, from: old).showInCarPlay == false) + } + + @Test("The toggle survives saving and loading") + func roundTrip() throws { + var alarm = Alarm(type: .notLooping) + alarm.showInCarPlay = true + let decoded = try JSONDecoder().decode(Alarm.self, from: JSONEncoder().encode(alarm)) + #expect(decoded.showInCarPlay) + } + + // MARK: - Which alarms can be checked again + + @Test("Alarms that record that they fired are never checked a second time") + func oneTimeAlarms() { + let oneTime: Set = [ + .cob, .recBolus, .missedBolus, .futureCarbs, + .overrideStart, .overrideEnd, .tempTargetStart, .tempTargetEnd, + ] + for type in AlarmType.allCases { + #expect(type.isStateCondition == !oneTime.contains(type), "\(type)") + } + } + + // MARK: - Wording + + @Test("Glucose alarms show the reading in the user's unit") + func glucoseDetail() { + let data = AlarmData.withReadings([90, 75, 63]) + #expect(CarPlayAlert.detail(for: Alarm(type: .low), data: data, unit: .mgdl) == "63 mg/dL") + #expect(CarPlayAlert.detail(for: Alarm(type: .low), data: data, unit: .mmol) == "3.5 mmol/L") + } + + @Test("Sensor expiry uses the alarm's sensor lifetime") + func sensorDetail() { + let inserted = Date().addingTimeInterval(-9 * 86400) + let data = AlarmData.withSensorInsertTime(inserted.timeIntervalSince1970) + let alarm = Alarm.sensorChange(threshold: 12, lifetimeDays: 10) + let expiry = inserted.addingTimeInterval(10 * 86400) + #expect(CarPlayAlert.detail(for: alarm, data: data, unit: .mgdl) == "Expires \(GlucoseSnapshot.clock(expiry))") + } + + @Test("A triggered alarm keeps its id and the title it fired with") + func alertFromAlarm() { + var alarm = Alarm(type: .battery) + alarm.name = "Loop phone" + let alert = CarPlayAlert(alarm: alarm, title: alarm.name, data: .withBattery(14.6), unit: .mgdl) + #expect(alert.id == alarm.id) + #expect(alert.title == "Loop phone") + #expect(alert.detail == "15%") + #expect(alert.symbol == AlarmType.battery.icon) + #expect(alert.isUrgent == false) + #expect(CarPlayAlert.isUrgent(.low)) + } + + // MARK: - Carbs and recommended bolus + + @Test("Carbs on board over the threshold stay listed after the alarm has announced them") + func cobHolds() { + var alarm = Alarm(type: .cob) + alarm.threshold = 15 + #expect(COBCondition().holds(alarm: alarm, data: .withCOB(21), now: Date()) == true) + #expect(COBCondition().holds(alarm: alarm, data: .withCOB(10), now: Date()) == false) + #expect(COBCondition().holds(alarm: alarm, data: .withCOB(nil), now: Date()) == false) + } + + @Test("A recommended bolus over the threshold stays listed") + func recBolusHolds() { + var alarm = Alarm(type: .recBolus) + alarm.threshold = 1 + #expect(RecBolusCondition().holds(alarm: alarm, data: .withRecBolus(1.4), now: Date()) == true) + #expect(RecBolusCondition().holds(alarm: alarm, data: .withRecBolus(0.6), now: Date()) == false) + } + + @Test("Event alarms have no state to check, so they are listed for a while after firing") + func eventsHaveNoState() { + let data = AlarmData.withBattery(nil) + #expect(OverrideStartCondition().holds(alarm: Alarm(type: .overrideStart), data: data, now: Date()) == nil) + #expect(MissedBolusCondition().holds(alarm: .missedBolus(), data: data, now: Date()) == nil) + #expect(AlarmManager.carPlayEventDuration == 15 * 60) + } + + // MARK: - Override line + + @Test("An override with its own range and an end shows percentage, range and end time") + func overrideWithRangeAndEnd() { + let now = Date() + let end = now.addingTimeInterval(46 * 60) + let snapshot = Self.snapshot(percent: 80, low: 90, high: 100, end: end.timeIntervalSince1970) + #expect(snapshot.carPlayOverrideDetails(now: now) == ["80%", "90–100", GlucoseSnapshot.clock(end, now: now)]) + } + + @Test("Without its own range or an end, the override shows the loop's target and ∞") + func overrideFallbacks() { + let snapshot = Self.snapshot(percent: nil, low: nil, high: nil, end: nil) + #expect(snapshot.carPlayOverrideDetails() == ["100%", "100–110", "∞"]) + #expect(snapshot.hasActiveOverride(at: Date())) + } + + @Test("An override whose end has passed is not shown") + func overrideEnded() { + let snapshot = Self.snapshot(percent: 80, low: nil, high: nil, end: Date().timeIntervalSince1970 - 60) + #expect(snapshot.hasActiveOverride(at: Date()) == false) + } + + @Test("Times read as a clock: the time alone today, with the weekday on another day") + func clockTimes() throws { + let now = Date() + let earlier = try #require(Calendar.current.date(byAdding: .day, value: -2, to: now)) + #expect(GlucoseSnapshot.clock(now, now: now) == now.formatted(date: .omitted, time: .shortened)) + #expect(GlucoseSnapshot.clock(earlier, now: now) == earlier.formatted(.dateTime.weekday(.abbreviated).hour().minute())) + } + + // MARK: - Widget push + + @Test("Push tokens are written as lowercase hex with leading zeros") + func pushTokenHex() { + #expect(CarPlayWidgetStore.hex(Data([0x00, 0x0F, 0xAB, 0xFF])) == "000fabff") + } + + // MARK: - Pages + + @Test("With alerts the button goes Now, Alerts, 3 h and back to Now") + func pageCycleWithAlerts() { + #expect(CarPlayWidgetPage.now.next(hasAlerts: true) == .alerts) + #expect(CarPlayWidgetPage.alerts.next(hasAlerts: true) == .trend) + #expect(CarPlayWidgetPage.trend.next(hasAlerts: true) == .now) + } + + @Test("Without alerts the Alerts page is skipped") + func pageCycleWithoutAlerts() { + #expect(CarPlayWidgetPage.now.next(hasAlerts: false) == .trend) + #expect(CarPlayWidgetPage.trend.next(hasAlerts: false) == .now) + } + + @Test("An Alerts page whose alerts have cleared shows Now, and its button moves on to 3 h") + func clearedAlertsPage() { + #expect(CarPlayWidgetPage.alerts.resolved(hasAlerts: false) == .now) + #expect(CarPlayWidgetPage.alerts.resolved(hasAlerts: true) == .alerts) + #expect(CarPlayWidgetPage.alerts.next(hasAlerts: false) == .trend) + } + + private static func snapshot(percent: Int?, low: Double?, high: Double?, end: TimeInterval?) -> GlucoseSnapshot { + GlucoseSnapshot( + glucose: 94, delta: -5, trend: .flat, updatedAt: Date(), + iob: nil, cob: nil, projected: nil, + override: "Ajustement", overrideEndAt: end, overridePercent: percent, + overrideRangeLowMgdl: low, overrideRangeHighMgdl: high, + targetLowMgdl: 100, targetHighMgdl: 110, + unit: .mgdl, isNotLooping: false + ) + } +} + +private extension AlarmData { + static func withCOB(_ cob: Double?) -> Self { + with(cob: cob, recBolus: nil) + } + + static func withRecBolus(_ units: Double?) -> Self { + with(cob: nil, recBolus: units) + } + + static func with(cob: Double?, recBolus: Double?) -> Self { + AlarmData( + bgReadings: [], + predictionData: [], + expireDate: nil, + lastLoopTime: nil, + latestOverrideStart: nil, + latestOverrideEnd: nil, + latestTempTargetStart: nil, + latestTempTargetEnd: nil, + recBolus: recBolus, + COB: cob, + sageInsertTime: nil, + pumpInsertTime: nil, + latestPumpVolume: nil, + IOB: nil, + recentBoluses: [], + latestBattery: nil, + latestBatteryIsCharging: nil, + latestPumpBattery: nil, + batteryHistory: [], + recentCarbs: [], + dbSizePercentage: nil + ) + } +} diff --git a/Tests/OverrideDetailsTests.swift b/Tests/OverrideDetailsTests.swift index e1a7a3ae5..43b5a4d93 100644 --- a/Tests/OverrideDetailsTests.swift +++ b/Tests/OverrideDetailsTests.swift @@ -32,10 +32,11 @@ struct OverrideDetailsTests { ) } - @Test("Percentage, range and time left, in that order") + @Test("Percentage, range and end time, in that order") func allDetails() { let details = snapshot(percent: 80, low: 140, high: 160, endsIn: 80 * 60).overrideDetails(now: now) - #expect(details == ["80%", "140–160", "1h 20m"]) + let end = now.addingTimeInterval(80 * 60) + #expect(details == ["80%", "140–160", GlucoseSnapshot.clock(end, now: now)]) } @Test("A single-value range shows once") @@ -49,13 +50,11 @@ struct OverrideDetailsTests { #expect(snapshot(endsIn: -60).overrideDetails(now: now).isEmpty) } - @Test("Time left rounds up to whole minutes and never reads 0m") - func timeLeft() { - #expect(GlucoseSnapshot.timeLeft(5) == "1m") - #expect(GlucoseSnapshot.timeLeft(45 * 60) == "45m") - #expect(GlucoseSnapshot.timeLeft(44 * 60 + 1) == "45m") - #expect(GlucoseSnapshot.timeLeft(2 * 3600) == "2h") - #expect(GlucoseSnapshot.timeLeft(3600 + 20 * 60) == "1h 20m") + @Test("The end is a clock time, with the weekday when it is on another day") + func endTime() { + let tomorrow = now.addingTimeInterval(86400) + #expect(snapshot(endsIn: 86400).overrideDetails(now: now) == [GlucoseSnapshot.clock(tomorrow, now: now)]) + #expect(GlucoseSnapshot.clock(tomorrow, now: now) == tomorrow.formatted(.dateTime.weekday(.abbreviated).hour().minute())) } @Test("A Nightscout range is read in mg/dL, and an unset one is ignored") diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 8bf9986ce..1c197566e 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -224,7 +224,8 @@ platform :ios do ]) configure_bundle_id("LoopFollow Live Activity Extension", LA_IDENTIFIER, [ - Spaceship::ConnectAPI::BundleIdCapability::Type::APP_GROUPS + Spaceship::ConnectAPI::BundleIdCapability::Type::APP_GROUPS, + Spaceship::ConnectAPI::BundleIdCapability::Type::PUSH_NOTIFICATIONS ]) configure_bundle_id("LoopFollow Watch App", WATCH_IDENTIFIER, [