From 9c8303feb70a08c96838d8bd852be986cd48c4db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Thu, 23 Jul 2026 12:56:30 +0200 Subject: [PATCH 1/2] Show active override and temp target with remaining time on the Live Activity and in the remote command views --- LoopFollow/Controllers/NightScout.swift | 4 + .../Nightscout/Treatments/Overrides.swift | 35 ++++--- .../Treatments/TemporaryTarget.swift | 8 ++ LoopFollow/LiveActivity/APNSClient.swift | 3 + .../LiveActivity/GlucoseSnapshotBuilder.swift | 12 +++ .../StorageCurrentGlucoseStateProvider.swift | 13 +++ LoopFollow/LiveActivitySettingsView.swift | 2 +- .../Remote/LoopAPNS/OverridePresetsView.swift | 7 ++ LoopFollow/Remote/RemainingTimeText.swift | 34 +++++++ LoopFollow/Remote/TRC/OverrideView.swift | 9 ++ LoopFollow/Remote/TRC/TempTargetView.swift | 9 ++ LoopFollow/Storage/Observable.swift | 5 + .../LoopFollowLiveActivity.swift | 98 ++++++++++++++++++- Shared/GlucoseSnapshot.swift | 29 +++++- Shared/LAAppGroupSettings.swift | 12 +++ 15 files changed, 262 insertions(+), 18 deletions(-) create mode 100644 LoopFollow/Remote/RemainingTimeText.swift diff --git a/LoopFollow/Controllers/NightScout.swift b/LoopFollow/Controllers/NightScout.swift index 58d3bfee4..90e3c1e9c 100644 --- a/LoopFollow/Controllers/NightScout.swift +++ b/LoopFollow/Controllers/NightScout.swift @@ -72,11 +72,15 @@ extension MainViewController { func clearOldOverride() { overrideGraphData.removeAll() + Observable.shared.override.value = nil + Observable.shared.overrideEndAt.value = nil updateOverrideGraph() } func clearOldTempTarget() { tempTargetGraphData.removeAll() + Observable.shared.tempTarget.value = nil + Observable.shared.tempTargetEndAt.value = nil updateTempTargetGraph() } diff --git a/LoopFollow/Controllers/Nightscout/Treatments/Overrides.swift b/LoopFollow/Controllers/Nightscout/Treatments/Overrides.swift index b06de4446..cfe569abb 100644 --- a/LoopFollow/Controllers/Nightscout/Treatments/Overrides.swift +++ b/LoopFollow/Controllers/Nightscout/Treatments/Overrides.swift @@ -7,6 +7,7 @@ extension MainViewController { func processNSOverrides(entries: [[String: AnyObject]]) { overrideGraphData.removeAll() var activeOverrideNote: String? + var activeOverrideEndAt: TimeInterval? let sorted = entries.sorted { lhs, rhs in guard @@ -35,19 +36,25 @@ extension MainViewController { let start = max(startDate.timeIntervalSince1970, graphHorizon) - var end: TimeInterval - if (e["durationType"] as? String) == "indefinite" { // Only for Loop overrides - end = maxEndDate - } else { - end = start + (e["duration"] as? Double ?? 5) * 60 - } + let nextStart: TimeInterval? = { + guard i + 1 < sorted.count, + let nextDateStr = (sorted[i + 1]["timestamp"] as? String) ?? (sorted[i + 1]["created_at"] as? String) + else { return nil } + return NightscoutUtils.parseDate(nextDateStr)?.timeIntervalSince1970 + }() + + let isIndefinite = (e["durationType"] as? String) == "indefinite" // Only for Loop overrides + let durationSeconds = (e["duration"] as? Double ?? 5) * 60 - if i + 1 < sorted.count, - let nextDateStr = (sorted[i + 1]["timestamp"] as? String) ?? (sorted[i + 1]["created_at"] as? String), - let nextStart = NightscoutUtils.parseDate(nextDateStr)? - .timeIntervalSince1970 - { + var end: TimeInterval = isIndefinite ? maxEndDate : start + durationSeconds + + // True end for countdown display: based on the raw start and never + // clamped to the graph edge; nil while indefinite. + var trueEnd: TimeInterval? = isIndefinite ? nil : startDate.timeIntervalSince1970 + durationSeconds + + if let nextStart = nextStart { end = min(end, nextStart - 60) // avoid overlapping overrides + trueEnd = trueEnd.map { min($0, nextStart - 60) } } end = min(end, maxEndDate) @@ -80,10 +87,12 @@ extension MainViewController { if now >= start, now < end { activeOverrideNote = e["notes"] as? String ?? e["reason"] as? String + activeOverrideEndAt = trueEnd } } Observable.shared.override.value = activeOverrideNote + Observable.shared.overrideEndAt.value = activeOverrideEndAt if Storage.shared.device.value != "Loop" { if let note = activeOverrideNote { infoManager.updateInfoData(type: .override, value: note) @@ -94,5 +103,9 @@ extension MainViewController { if Storage.shared.graphOtherTreatments.value { updateOverrideGraph() } + + #if !targetEnvironment(macCatalyst) + LiveActivityManager.shared.refreshFromCurrentState(reason: "overrideChanged") + #endif } } diff --git a/LoopFollow/Controllers/Nightscout/Treatments/TemporaryTarget.swift b/LoopFollow/Controllers/Nightscout/Treatments/TemporaryTarget.swift index e3082e24c..ad48c71ce 100644 --- a/LoopFollow/Controllers/Nightscout/Treatments/TemporaryTarget.swift +++ b/LoopFollow/Controllers/Nightscout/Treatments/TemporaryTarget.swift @@ -9,6 +9,7 @@ extension MainViewController { func processNSTemporaryTarget(entries: [[String: AnyObject]]) { tempTargetGraphData.removeAll() var activeTempTarget: Int? + var activeTempTargetEndAt: TimeInterval? for (index, currentEntry) in entries.reversed().enumerated() { guard let dateStr = currentEntry["timestamp"] as? String ?? currentEntry["created_at"] as? String else { continue } @@ -27,6 +28,7 @@ extension MainViewController { if let activeIndex = tempTargetGraphData.lastIndex(where: { $0.endDate > dateTimeStamp }) { tempTargetGraphData[activeIndex].endDate = dateTimeStamp activeTempTarget = nil + activeTempTargetEndAt = nil } continue } @@ -58,6 +60,7 @@ extension MainViewController { let currentTime = Date().timeIntervalSince1970 if currentTime < endDate { activeTempTarget = Int(targetValue!) + activeTempTargetEndAt = endDate } } @@ -72,5 +75,10 @@ extension MainViewController { } else { Observable.shared.tempTarget.value = nil } + Observable.shared.tempTargetEndAt.value = activeTempTargetEndAt + + #if !targetEnvironment(macCatalyst) + LiveActivityManager.shared.refreshFromCurrentState(reason: "tempTargetChanged") + #endif } } diff --git a/LoopFollow/LiveActivity/APNSClient.swift b/LoopFollow/LiveActivity/APNSClient.swift index d4871f067..ff20144c0 100644 --- a/LoopFollow/LiveActivity/APNSClient.swift +++ b/LoopFollow/LiveActivity/APNSClient.swift @@ -246,6 +246,9 @@ class APNSClient { if let cob = snapshot.cob { snapshotDict["cob"] = cob } if let projected = snapshot.projected { snapshotDict["projected"] = projected } if let override = snapshot.override { snapshotDict["override"] = override } + if let overrideEndAt = snapshot.overrideEndAt { snapshotDict["overrideEndAt"] = overrideEndAt } + if let tempTargetMgdl = snapshot.tempTargetMgdl { snapshotDict["tempTargetMgdl"] = tempTargetMgdl } + if let tempTargetEndAt = snapshot.tempTargetEndAt { snapshotDict["tempTargetEndAt"] = tempTargetEndAt } if let recBolus = snapshot.recBolus { snapshotDict["recBolus"] = recBolus } if let battery = snapshot.battery { snapshotDict["battery"] = battery } if let pumpBattery = snapshot.pumpBattery { snapshotDict["pumpBattery"] = pumpBattery } diff --git a/LoopFollow/LiveActivity/GlucoseSnapshotBuilder.swift b/LoopFollow/LiveActivity/GlucoseSnapshotBuilder.swift index 40ff076af..61dd92ab7 100644 --- a/LoopFollow/LiveActivity/GlucoseSnapshotBuilder.swift +++ b/LoopFollow/LiveActivity/GlucoseSnapshotBuilder.swift @@ -33,6 +33,15 @@ protocol CurrentGlucoseStateProviding { /// Active override name (nil if no active override). var override: String? { get } + /// End of the active override as Unix epoch seconds (nil = no override or indefinite). + var overrideEndAt: TimeInterval? { get } + + /// Active temporary target in mg/dL (nil if no active temp target). + var tempTargetMgdl: Double? { get } + + /// End of the active temporary target as Unix epoch seconds. + var tempTargetEndAt: TimeInterval? { get } + /// Recommended bolus in units. var recBolus: Double? { get } @@ -141,6 +150,9 @@ enum GlucoseSnapshotBuilder { cob: provider.cob, projected: provider.projectedMgdl, override: provider.override, + overrideEndAt: provider.overrideEndAt, + tempTargetMgdl: provider.tempTargetMgdl, + tempTargetEndAt: provider.tempTargetEndAt, recBolus: provider.recBolus, battery: provider.battery, pumpBattery: provider.pumpBattery, diff --git a/LoopFollow/LiveActivity/StorageCurrentGlucoseStateProvider.swift b/LoopFollow/LiveActivity/StorageCurrentGlucoseStateProvider.swift index 3c097fe56..76b3664a4 100644 --- a/LoopFollow/LiveActivity/StorageCurrentGlucoseStateProvider.swift +++ b/LoopFollow/LiveActivity/StorageCurrentGlucoseStateProvider.swift @@ -2,6 +2,7 @@ // StorageCurrentGlucoseStateProvider.swift import Foundation +import HealthKit /// Reads the latest glucose state from LoopFollow's Storage and Observable layers. /// This is the only file in the pipeline that is allowed to touch Storage.shared @@ -47,6 +48,18 @@ struct StorageCurrentGlucoseStateProvider: CurrentGlucoseStateProviding { Observable.shared.override.value } + var overrideEndAt: TimeInterval? { + Observable.shared.overrideEndAt.value + } + + var tempTargetMgdl: Double? { + Observable.shared.tempTarget.value?.doubleValue(for: .milligramsPerDeciliter) + } + + var tempTargetEndAt: TimeInterval? { + Observable.shared.tempTargetEndAt.value + } + var recBolus: Double? { Observable.shared.deviceRecBolus.value } diff --git a/LoopFollow/LiveActivitySettingsView.swift b/LoopFollow/LiveActivitySettingsView.swift index 694e2779b..6d654f72d 100644 --- a/LoopFollow/LiveActivitySettingsView.swift +++ b/LoopFollow/LiveActivitySettingsView.swift @@ -59,7 +59,7 @@ get: { slots[index] }, set: { selectSlot($0, at: index) } )) { - ForEach(LiveActivitySlotOption.allCases, id: \.self) { option in + ForEach(LiveActivitySlotOption.gridCases, id: \.self) { option in Text(option.displayName).tag(option) } } diff --git a/LoopFollow/Remote/LoopAPNS/OverridePresetsView.swift b/LoopFollow/Remote/LoopAPNS/OverridePresetsView.swift index ce88f0b90..4d29aa6f6 100644 --- a/LoopFollow/Remote/LoopAPNS/OverridePresetsView.swift +++ b/LoopFollow/Remote/LoopAPNS/OverridePresetsView.swift @@ -7,6 +7,7 @@ struct OverridePresetsView: View { @StateObject private var viewModel = OverridePresetsViewModel() @Environment(\.presentationMode) var presentationMode @ObservedObject var overrideNote = Observable.shared.override + @ObservedObject var overrideEndAt = Observable.shared.overrideEndAt var body: some View { NavigationView { @@ -28,6 +29,12 @@ struct OverridePresetsView: View { Text(activeNote) .font(.subheadline) .foregroundColor(.secondary) + + if let endAt = overrideEndAt.value { + RemainingTimeText(endAt: endAt) + .font(.subheadline) + .foregroundColor(.secondary) + } } Spacer() diff --git a/LoopFollow/Remote/RemainingTimeText.swift b/LoopFollow/Remote/RemainingTimeText.swift new file mode 100644 index 000000000..297c0d4bc --- /dev/null +++ b/LoopFollow/Remote/RemainingTimeText.swift @@ -0,0 +1,34 @@ +// LoopFollow +// RemainingTimeText.swift + +import SwiftUI + +/// Remaining time for an active override / temp target: a live countdown when +/// 2 hours or less remain, otherwise a static "29d 23h"-style duration — +/// a multi-day ticker reads poorly. +struct RemainingTimeText: View { + let endAt: TimeInterval + + static let tickerMaxRemaining: TimeInterval = 2 * 3600 + + private static let longFormatter: DateComponentsFormatter = { + let f = DateComponentsFormatter() + f.unitsStyle = .abbreviated + f.allowedUnits = [.day, .hour, .minute] + f.maximumUnitCount = 2 + return f + }() + + var body: some View { + let end = Date(timeIntervalSince1970: endAt) + let remaining = end.timeIntervalSinceNow + if remaining <= 0 { + EmptyView() + } else if remaining <= Self.tickerMaxRemaining { + Text(timerInterval: Date() ... end, countsDown: true) + .monospacedDigit() + } else { + Text(Self.longFormatter.string(from: remaining) ?? "") + } + } +} diff --git a/LoopFollow/Remote/TRC/OverrideView.swift b/LoopFollow/Remote/TRC/OverrideView.swift index 3a402f847..703201b07 100644 --- a/LoopFollow/Remote/TRC/OverrideView.swift +++ b/LoopFollow/Remote/TRC/OverrideView.swift @@ -10,6 +10,7 @@ struct OverrideView: View { @ObservedObject var device = Storage.shared.device @ObservedObject var overrideNote = Observable.shared.override + @ObservedObject var overrideEndAt = Observable.shared.overrideEndAt @State private var showAlert: Bool = false @State private var alertType: AlertType? = nil @@ -49,6 +50,14 @@ struct OverrideView: View { Text(activeNote) .foregroundColor(.secondary) } + if let endAt = overrideEndAt.value { + HStack { + Text("Time Remaining") + Spacer() + RemainingTimeText(endAt: endAt) + .foregroundColor(.secondary) + } + } Button { alertType = .confirmCancellation showAlert = true diff --git a/LoopFollow/Remote/TRC/TempTargetView.swift b/LoopFollow/Remote/TRC/TempTargetView.swift index 3a047e66f..ac2fa7303 100644 --- a/LoopFollow/Remote/TRC/TempTargetView.swift +++ b/LoopFollow/Remote/TRC/TempTargetView.swift @@ -10,6 +10,7 @@ struct TempTargetView: View { @ObservedObject var device = Storage.shared.device @ObservedObject var tempTarget = Observable.shared.tempTarget + @ObservedObject var tempTargetEndAt = Observable.shared.tempTargetEndAt @State private var newHKTarget = HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 0.0) @State private var duration = HKQuantity(unit: .minute(), doubleValue: 0.0) @@ -51,6 +52,14 @@ struct TempTargetView: View { Text(Localizer.formatQuantity(tempTargetValue)) Text(Localizer.getPreferredUnit().localizedShortUnitString).foregroundColor(.secondary) } + if let endAt = tempTargetEndAt.value { + HStack { + Text("Time Remaining") + Spacer() + RemainingTimeText(endAt: endAt) + .foregroundColor(.secondary) + } + } Button { alertType = .confirmCancellation showAlert = true diff --git a/LoopFollow/Storage/Observable.swift b/LoopFollow/Storage/Observable.swift index b16a5994d..07877f8a8 100644 --- a/LoopFollow/Storage/Observable.swift +++ b/LoopFollow/Storage/Observable.swift @@ -15,6 +15,11 @@ class Observable { var tempTarget = ObservableValue(default: nil) var override = ObservableValue(default: nil) + /// End of the active override as Unix epoch seconds; nil = no override or indefinite. + var overrideEndAt = ObservableValue(default: nil) + /// End of the active temp target as Unix epoch seconds; nil = no active temp target. + var tempTargetEndAt = ObservableValue(default: nil) + var minAgoText = ObservableValue(default: "?? min ago") var bgText = ObservableValue(default: "BG") var bg = ObservableValue(default: nil) diff --git a/LoopFollowLAExtension/LoopFollowLiveActivity.swift b/LoopFollowLAExtension/LoopFollowLiveActivity.swift index 086a6c5b6..6d438531e 100644 --- a/LoopFollowLAExtension/LoopFollowLiveActivity.swift +++ b/LoopFollowLAExtension/LoopFollowLiveActivity.swift @@ -235,6 +235,8 @@ private struct LockScreenLiveActivityView: View { .frame(maxWidth: .infinity, alignment: .trailing) } + ActiveAdjustmentsView(snapshot: s) + Text(LAAppGroupSettings.showDisplayName() ? "\(LAAppGroupSettings.displayName()) — \(LAFormat.updated(s))" : "Last Update: \(LAFormat.updated(s))") @@ -365,6 +367,84 @@ private struct SlotView: View { } } +/// Conditional row showing the active override and/or temp target with a +/// self-ticking countdown. Shared by the lock screen card and the expanded +/// Dynamic Island bottom region; renders nothing when neither is active. +private struct ActiveAdjustmentsView: View { + let snapshot: GlucoseSnapshot + + /// Above this remaining time the ticker is skipped (name/value only) — + /// a multi-hour or multi-day countdown reads poorly in the compact row. + private static let tickerMaxRemaining: TimeInterval = 2 * 3600 + + // Ends already in the past are stale data waiting for the next refresh — + // drop them rather than render a dead 0:00 timer. + private var overrideEnd: Date? { + guard let t = snapshot.overrideEndAt, t > Date().timeIntervalSince1970 else { return nil } + return Date(timeIntervalSince1970: t) + } + + private var tempTargetEnd: Date? { + guard let t = snapshot.tempTargetEndAt, t > Date().timeIntervalSince1970 else { return nil } + 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 + } + + private var tempTargetText: String? { + guard tempTargetEnd != nil else { return nil } + return LAFormat.tempTargetValue(snapshot) + } + + var body: some View { + if overrideName != nil || tempTargetText != nil { + HStack(spacing: 5) { + Text("⏱") + .font(.system(size: 11)) + if let name = overrideName { + Text(name) + .lineLimit(1) + .minimumScaleFactor(0.7) + .layoutPriority(1) + if let end = overrideEnd, end.timeIntervalSinceNow <= Self.tickerMaxRemaining { + countdown(to: end) + } + } + if overrideName != nil, tempTargetText != nil { + Text("·") + .foregroundStyle(.white.opacity(0.5)) + } + if let tt = tempTargetText, let end = tempTargetEnd { + Text("TT \(tt)") + .lineLimit(1) + .minimumScaleFactor(0.8) + if end.timeIntervalSinceNow <= Self.tickerMaxRemaining { + countdown(to: end) + } + } + } + .font(.system(size: 12, weight: .semibold, design: .rounded)) + .monospacedDigit() + .foregroundStyle(.white.opacity(0.85)) + .frame(maxWidth: .infinity, alignment: .center) + } + } + + @ViewBuilder + private func countdown(to end: Date) -> some View { + // Text(timerInterval:) claims flexible width; cap it so the row stays centered. + Text(timerInterval: Date() ... end, countsDown: true) + .multilineTextAlignment(.leading) + .frame(maxWidth: end.timeIntervalSinceNow >= 3600 ? 62 : 46, alignment: .leading) + } +} + // MARK: - Dynamic Island private struct DynamicIslandLeadingView: View { @@ -435,11 +515,14 @@ private struct DynamicIslandBottomView: View { .lineLimit(1) .minimumScaleFactor(0.75) } else { - Text("Updated at: \(LAFormat.updated(snapshot))") - .font(.system(size: 13, weight: .semibold, design: .rounded)) - .foregroundStyle(.white.opacity(0.92)) - .lineLimit(1) - .minimumScaleFactor(0.85) + VStack(spacing: 2) { + ActiveAdjustmentsView(snapshot: snapshot) + Text("Updated at: \(LAFormat.updated(snapshot))") + .font(.system(size: 13, weight: .semibold, design: .rounded)) + .foregroundStyle(.white.opacity(0.92)) + .lineLimit(1) + .minimumScaleFactor(0.85) + } } } } @@ -653,6 +736,11 @@ private enum LAFormat { s.override ?? "—" } + static func tempTargetValue(_ s: GlucoseSnapshot) -> String? { + guard let tt = s.tempTargetMgdl, tt > 0 else { return nil } + return formatGlucoseValue(tt, unit: s.unit) + } + static func profileName(_ s: GlucoseSnapshot) -> String { s.profileName ?? "—" } diff --git a/Shared/GlucoseSnapshot.swift b/Shared/GlucoseSnapshot.swift index c5f5fffcc..6800ccd3b 100644 --- a/Shared/GlucoseSnapshot.swift +++ b/Shared/GlucoseSnapshot.swift @@ -52,6 +52,17 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable { /// Active override name (nil if no active override) let override: String? + /// End of the active override as Unix epoch seconds. Only meaningful while + /// `override` is non-nil; nil with a non-nil name means the override is + /// indefinite (no countdown). + let overrideEndAt: TimeInterval? + + /// Active temporary target in mg/dL (nil if no active temp target) + let tempTargetMgdl: Double? + + /// End of the active temporary target as Unix epoch seconds (nil if no active temp target) + let tempTargetEndAt: TimeInterval? + /// Recommended bolus in units (nil if not available) let recBolus: Double? @@ -134,6 +145,9 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable { cob: Double?, projected: Double?, override: String? = nil, + overrideEndAt: TimeInterval? = nil, + tempTargetMgdl: Double? = nil, + tempTargetEndAt: TimeInterval? = nil, recBolus: Double? = nil, battery: Double? = nil, pumpBattery: Double? = nil, @@ -164,6 +178,9 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable { self.cob = cob self.projected = projected self.override = override + self.overrideEndAt = overrideEndAt + self.tempTargetMgdl = tempTargetMgdl + self.tempTargetEndAt = tempTargetEndAt self.recBolus = recBolus self.battery = battery self.pumpBattery = pumpBattery @@ -207,6 +224,9 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable { cob: cob, projected: projected, override: override, + overrideEndAt: overrideEndAt, + tempTargetMgdl: tempTargetMgdl, + tempTargetEndAt: tempTargetEndAt, recBolus: recBolus, battery: battery, pumpBattery: pumpBattery, @@ -243,6 +263,9 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable { try container.encodeIfPresent(cob, forKey: .cob) try container.encodeIfPresent(projected, forKey: .projected) try container.encodeIfPresent(override, forKey: .override) + try container.encodeIfPresent(overrideEndAt, forKey: .overrideEndAt) + try container.encodeIfPresent(tempTargetMgdl, forKey: .tempTargetMgdl) + try container.encodeIfPresent(tempTargetEndAt, forKey: .tempTargetEndAt) try container.encodeIfPresent(recBolus, forKey: .recBolus) try container.encodeIfPresent(battery, forKey: .battery) try container.encodeIfPresent(pumpBattery, forKey: .pumpBattery) @@ -276,6 +299,9 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable { cob = try container.decodeIfPresent(Double.self, forKey: .cob) projected = try container.decodeIfPresent(Double.self, forKey: .projected) override = try container.decodeIfPresent(String.self, forKey: .override) + overrideEndAt = try container.decodeIfPresent(Double.self, forKey: .overrideEndAt) + tempTargetMgdl = try container.decodeIfPresent(Double.self, forKey: .tempTargetMgdl) + tempTargetEndAt = try container.decodeIfPresent(Double.self, forKey: .tempTargetEndAt) recBolus = try container.decodeIfPresent(Double.self, forKey: .recBolus) battery = try container.decodeIfPresent(Double.self, forKey: .battery) pumpBattery = try container.decodeIfPresent(Double.self, forKey: .pumpBattery) @@ -302,7 +328,8 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable { private enum CodingKeys: String, CodingKey { case glucose, delta, trend, updatedAt case iob, cob, projected - case override, recBolus, battery, pumpBattery, basalRate, pumpReservoirU + case override, overrideEndAt, tempTargetMgdl, tempTargetEndAt + case recBolus, battery, pumpBattery, basalRate, pumpReservoirU case autosens, tdd, targetLowMgdl, targetHighMgdl, isfMgdlPerU, carbRatio, carbsToday case profileName, sageInsertTime, cageInsertTime, iageInsertTime, minBgMgdl, maxBgMgdl case unit, isNotLooping, showRenewalOverlay diff --git a/Shared/LAAppGroupSettings.swift b/Shared/LAAppGroupSettings.swift index 6359fe55e..bdf93d56b 100644 --- a/Shared/LAAppGroupSettings.swift +++ b/Shared/LAAppGroupSettings.swift @@ -106,6 +106,14 @@ 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 } + } + /// 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 { @@ -188,7 +196,11 @@ enum LAAppGroupSettings { guard let raw = defaults?.stringArray(forKey: Keys.slots), raw.count == 4 else { return LiveActivitySlotDefaults.all } + // .override is no longer a grid option (the card has a dedicated + // override/temp-target row); selections stored by older versions + // render as empty cells. return raw.map { LiveActivitySlotOption(rawValue: $0) ?? .none } + .map { $0 == .override ? .none : $0 } } // MARK: - Small widget slot (Write) From c6547fe8736ac55d5a11051d1c7ed9b3b4b7b736 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Thu, 23 Jul 2026 13:00:59 +0200 Subject: [PATCH 2/2] Treat Trio's 30 day durations as indefinite and light up the remote command buttons while an override or temp target is active --- .../Nightscout/Treatments/Overrides.swift | 5 ++++- .../Nightscout/Treatments/TemporaryTarget.swift | 4 +++- .../Remote/LoopAPNS/LoopAPNSRemoteView.swift | 3 ++- .../Remote/TRC/TrioRemoteControlView.swift | 16 ++++++++++++++-- 4 files changed, 23 insertions(+), 5 deletions(-) diff --git a/LoopFollow/Controllers/Nightscout/Treatments/Overrides.swift b/LoopFollow/Controllers/Nightscout/Treatments/Overrides.swift index cfe569abb..00dd745bd 100644 --- a/LoopFollow/Controllers/Nightscout/Treatments/Overrides.swift +++ b/LoopFollow/Controllers/Nightscout/Treatments/Overrides.swift @@ -43,8 +43,11 @@ extension MainViewController { return NightscoutUtils.parseDate(nextDateStr)?.timeIntervalSince1970 }() - let isIndefinite = (e["durationType"] as? String) == "indefinite" // Only for Loop overrides let durationSeconds = (e["duration"] as? Double ?? 5) * 60 + // Loop marks indefinite overrides explicitly; Trio represents them + // as a ~30-day duration. Treat a week or longer as indefinite. + let isIndefinite = (e["durationType"] as? String) == "indefinite" + || durationSeconds >= 7 * 24 * 3600 var end: TimeInterval = isIndefinite ? maxEndDate : start + durationSeconds diff --git a/LoopFollow/Controllers/Nightscout/Treatments/TemporaryTarget.swift b/LoopFollow/Controllers/Nightscout/Treatments/TemporaryTarget.swift index ad48c71ce..592e47f7a 100644 --- a/LoopFollow/Controllers/Nightscout/Treatments/TemporaryTarget.swift +++ b/LoopFollow/Controllers/Nightscout/Treatments/TemporaryTarget.swift @@ -60,7 +60,9 @@ extension MainViewController { let currentTime = Date().timeIntervalSince1970 if currentTime < endDate { activeTempTarget = Int(targetValue!) - activeTempTargetEndAt = endDate + // Trio represents indefinite temp targets as a ~30-day duration; + // treat a week or longer as indefinite (no end to display). + activeTempTargetEndAt = duration >= 7 * 24 * 3600 ? nil : endDate } } diff --git a/LoopFollow/Remote/LoopAPNS/LoopAPNSRemoteView.swift b/LoopFollow/Remote/LoopAPNS/LoopAPNSRemoteView.swift index b57688b20..73de2aae4 100644 --- a/LoopFollow/Remote/LoopAPNS/LoopAPNSRemoteView.swift +++ b/LoopFollow/Remote/LoopAPNS/LoopAPNSRemoteView.swift @@ -6,6 +6,7 @@ import SwiftUI struct LoopAPNSRemoteView: View { @Environment(\.presentationMode) var presentationMode @StateObject private var viewModel = RemoteSettingsViewModel() + @ObservedObject private var activeOverrideNote = Observable.shared.override var body: some View { NavigationView { @@ -20,7 +21,7 @@ struct LoopAPNSRemoteView: View { LazyVGrid(columns: columns, spacing: 16) { CommandButtonView(command: "Meal", iconName: "fork.knife", destination: LoopAPNSCarbsView()) CommandButtonView(command: "Bolus", iconName: "syringe", destination: LoopAPNSBolusView()) - CommandButtonView(command: "Overrides", iconName: "slider.horizontal.3", destination: OverridePresetsView()) + CommandButtonView(command: "Overrides", iconName: "slider.horizontal.3", destination: OverridePresetsView(), isActive: activeOverrideNote.value != nil) } .padding(.horizontal) } else { diff --git a/LoopFollow/Remote/TRC/TrioRemoteControlView.swift b/LoopFollow/Remote/TRC/TrioRemoteControlView.swift index 5335f6451..0ad641a8b 100644 --- a/LoopFollow/Remote/TRC/TrioRemoteControlView.swift +++ b/LoopFollow/Remote/TRC/TrioRemoteControlView.swift @@ -5,6 +5,8 @@ import SwiftUI struct TrioRemoteControlView: View { @ObservedObject var viewModel: TrioRemoteControlViewModel + @ObservedObject private var activeOverrideNote = Observable.shared.override + @ObservedObject private var activeTempTarget = Observable.shared.tempTarget @Environment(\.presentationMode) var presentationMode var body: some View { @@ -18,8 +20,8 @@ struct TrioRemoteControlView: View { LazyVGrid(columns: columns, spacing: 16) { CommandButtonView(command: "Meal", iconName: "fork.knife", destination: MealView()) CommandButtonView(command: "Bolus", iconName: "syringe", destination: BolusView()) - CommandButtonView(command: "Temp Target", iconName: "scope", destination: TempTargetView()) - CommandButtonView(command: "Overrides", iconName: "slider.horizontal.3", destination: OverrideView()) + CommandButtonView(command: "Temp Target", iconName: "scope", destination: TempTargetView(), isActive: activeTempTarget.value != nil) + CommandButtonView(command: "Overrides", iconName: "slider.horizontal.3", destination: OverrideView(), isActive: activeOverrideNote.value != nil) } .padding(.horizontal) @@ -34,6 +36,9 @@ struct CommandButtonView: View { let command: String let iconName: String let destination: Destination + /// Lights the button up with a glow while the corresponding override / + /// temp target is active. + var isActive: Bool = false var body: some View { NavigationLink(destination: destination) { @@ -49,6 +54,13 @@ struct CommandButtonView: View { .background(Color.blue) .foregroundColor(.white) .cornerRadius(8) + .overlay { + if isActive { + RoundedRectangle(cornerRadius: 8) + .stroke(Color.green, lineWidth: 2.5) + } + } + .shadow(color: isActive ? Color.green.opacity(0.7) : .clear, radius: isActive ? 9 : 0) } .buttonStyle(PlainButtonStyle()) }