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()