diff --git a/LoopFollow/Watch/WatchStateBuilder.swift b/LoopFollow/Watch/WatchStateBuilder.swift index eaf59df86..21d89021c 100644 --- a/LoopFollow/Watch/WatchStateBuilder.swift +++ b/LoopFollow/Watch/WatchStateBuilder.swift @@ -27,6 +27,10 @@ enum WatchStateBuilder { .filter { $0.date >= cutoff } .map { WatchReading(t: $0.date, v: $0.value) } + let overrides = (vc?.overrideGraphData ?? []) + .filter { $0.endDate >= cutoff } + .map { WatchOverride(start: $0.date, end: $0.endDate, name: $0.reason.isEmpty ? "Override" : $0.reason, percent: overridePercent($0)) } + var predictions: [WatchPredictionLine] = [] if let vc { let lines: [(WatchPredictionLine.Kind, [ShareGlucoseData])] = [ @@ -55,6 +59,7 @@ enum WatchStateBuilder { cone: cone, boluses: boluses, carbs: carbs, + overrides: overrides, lowMgdl: thresholds.low, highMgdl: thresholds.high, colorBGText: Storage.shared.colorBGText.value, @@ -167,6 +172,20 @@ 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? { + var factor = override.insulNeedsScaleFactor + if factor == 1 { + if let loop = ProfileManager.shared.loopOverrides.first(where: { $0.name == override.reason }) { + factor = loop.insulinNeedsScaleFactor + } else if let percentage = ProfileManager.shared.trioOverrides.first(where: { $0.name == override.reason })?.percentage { + factor = percentage / 100 + } + } + return factor == 1 ? nil : Int(round(factor * 100)) + } + private static func loopOverridePresets() -> [WatchPreset] { ProfileManager.shared.loopOverrides.map { override in var parts: [String] = [] diff --git a/LoopFollowWatch/ChartPage.swift b/LoopFollowWatch/ChartPage.swift index d354cdd3a..9af911995 100644 --- a/LoopFollowWatch/ChartPage.swift +++ b/LoopFollowWatch/ChartPage.swift @@ -4,9 +4,9 @@ import SwiftUI import WatchKit -/// The full chart on its own page. Tap the chart to scrub back in time with the -/// Digital Crown; tap again to return to now. The button in the corner changes -/// the time span. +/// The full chart on its own page. Tap the chart or the crown button to scrub back +/// in time with the Digital Crown; tap again to return to now. The button in the +/// other corner changes the time span. The override line under the chart follows the marker. struct ChartPage: View { let state: WatchState @@ -21,9 +21,6 @@ struct ChartPage: View { var body: some View { TimelineView(.periodic(from: .now, by: 60)) { context in VStack(spacing: 2) { - if isScrubbing { - scrubHeader(now: context.date) - } WatchChartView( state: state, now: context.date, @@ -33,17 +30,38 @@ struct ChartPage: View { // Keeps the axis labels clear of the page indicator. .padding(.trailing, 10) .contentShape(Rectangle()) - .onTapGesture { - isScrubbing.toggle() - scrubSteps = 0 - crownFocused = isScrubbing + .onTapGesture(perform: toggleScrubbing) + // Below the chart, clear of the toolbar buttons that float over the top of the page. + if isScrubbing { + scrubDetails(now: context.date) + } + if let override = state.activeOverride(at: isScrubbing ? markerDate(now: context.date) : context.date) { + OverrideLine(name: override.name, percent: override.percent) + .padding(.trailing, 10) } } .focusable(isScrubbing) .focused($crownFocused) + // Focus can only move once the page has become focusable, so it follows the toggle rather than + // being set in the same tap; otherwise the crown keeps paging instead of scrubbing. + .onChange(of: isScrubbing) { _, scrubbing in + crownFocused = scrubbing + } + .onChange(of: crownFocused) { _, focused in + if !focused { + isScrubbing = false + } + } .digitalCrownRotation($scrubSteps, from: 0, through: maxSteps, by: 1, sensitivity: .medium, isContinuous: false, isHapticFeedbackEnabled: true) } .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button(action: toggleScrubbing) { + Image(systemName: CrownSymbol.increase) + } + .tint(isScrubbing ? .green : nil) + .accessibilityLabel(isScrubbing ? "Stop scrubbing" : "Scrub with the Digital Crown") + } ToolbarItem(placement: .topBarTrailing) { Button("\(Int(spanHours)) h") { let index = WatchChartView.spans.firstIndex(of: spanHours) ?? 1 @@ -55,23 +73,55 @@ struct ChartPage: View { } } - /// Time and BG at the marker. - private func scrubHeader(now: Date) -> some View { - let marker = WatchChartView.markerDate(state: state, now: now, spanHours: spanHours, scrubOffset: scrubSteps * 300) + private func toggleScrubbing() { + isScrubbing.toggle() + scrubSteps = 0 + } + + /// Time, BG, carbs and insulin at the marker. Carbs and boluses count when they fall within the + /// marker's five-minute crown step, so each entry shows at exactly one step. + private func scrubDetails(now: Date) -> some View { + let marker = markerDate(now: now) let nearest = state.readings.min { abs($0.date.timeIntervalSince(marker)) < abs($1.date.timeIntervalSince(marker)) } let reading = nearest.flatMap { abs($0.date.timeIntervalSince(marker)) <= 450 ? $0 : nil } - return HStack { + let carbs = total(of: state.carbs, near: marker) + let insulin = total(of: state.boluses, near: marker) + return HStack(spacing: 8) { Text(marker.formatted(date: .omitted, time: .shortened)) .foregroundStyle(.secondary) - Spacer() - if let reading { - Text(WatchFormat.glucose(reading.v, unit: state.snapshot?.unit ?? .mgdl)) - .foregroundStyle(WatchFormat.bgColor(reading.v, lowMgdl: state.lowMgdl, highMgdl: state.highMgdl)) - } else { - Text("—") + HStack(spacing: 3) { + if let reading { + Circle() + .fill(WatchFormat.bgColor(reading.v, lowMgdl: state.lowMgdl, highMgdl: state.highMgdl)) + .frame(width: 7, height: 7) + Text(WatchFormat.glucose(reading.v, unit: state.snapshot?.unit ?? .mgdl)) + } else { + Text("—") + } + } + if let carbs { + Text("\(Image(systemName: "fork.knife")) \(WatchFormat.cob(carbs))") + .accessibilityLabel("Carbs \(WatchFormat.cob(carbs))") + } + if let insulin { + Text("\(Image(systemName: "syringe")) \(WatchFormat.insulin(insulin)) U") + .accessibilityLabel("Bolus \(WatchFormat.insulin(insulin)) U") } } - .font(.headline) + .font(.footnote) .monospacedDigit() + .lineLimit(1) + .minimumScaleFactor(0.6) + .padding(.trailing, 10) + } + + private func markerDate(now: Date) -> Date { + WatchChartView.markerDate(state: state, now: now, spanHours: spanHours, scrubOffset: scrubSteps * 300) + } + + /// Sum of the entries within half a crown step of the marker, or nil when there are none. + private func total(of entries: [WatchReading], near marker: Date) -> Double? { + let matching = entries.filter { abs($0.date.timeIntervalSince(marker)) < 150 } + return matching.isEmpty ? nil : matching.reduce(0) { $0 + $1.v } } } diff --git a/LoopFollowWatch/GlucosePage.swift b/LoopFollowWatch/GlucosePage.swift index ed5ba466a..d07086dd2 100644 --- a/LoopFollowWatch/GlucosePage.swift +++ b/LoopFollowWatch/GlucosePage.swift @@ -4,7 +4,7 @@ import Charts import SwiftUI -/// Main page: one large BG value with trend and delta, a two-hour sparkline, +/// Main page: one large BG value with trend and delta, IOB and COB, the running override, a two-hour sparkline, /// and the remote commands in the bottom toolbar. struct GlucosePage: View { let state: WatchState @@ -21,19 +21,12 @@ struct GlucosePage: View { glucose(now: context.date) if !isLuminanceReduced { details + overrideLine(now: context.date) Sparkline(state: state, now: context.date) .frame(minHeight: 36) } } .toolbar { - ToolbarItem(placement: .topBarLeading) { - if !isLuminanceReduced, let iob = state.snapshot?.iob { - Text("IOB \(WatchFormat.iob(iob))") - .font(.footnote) - .lineLimit(1) - .minimumScaleFactor(0.6) - } - } ToolbarItem(placement: .topBarTrailing) { age(now: context.date) } @@ -51,16 +44,20 @@ struct GlucosePage: View { if let snapshot = state.snapshot { let isStale = now.timeIntervalSince(snapshot.updatedAt) >= WatchFormat.staleAfter VStack(spacing: -4) { - HStack(alignment: .center, spacing: 4) { - Text(WatchFormat.glucose(snapshot.glucose, unit: snapshot.unit)) - .font(.system(size: 56, weight: .black)) - .foregroundStyle(WatchFormat.bgColor(snapshot.glucose, lowMgdl: state.lowMgdl, highMgdl: state.highMgdl, colored: state.colorBGText)) - .strikethrough(isStale, pattern: .solid, color: .red) - .lineLimit(1) - .minimumScaleFactor(0.5) - Text(WatchFormat.trendArrow(snapshot.trend)) - .font(.system(size: 28, weight: .black)) - } + // The arrow hangs off the number's trailing edge so the digits, and the delta under them, stay centered. + Text(WatchFormat.glucose(snapshot.glucose, unit: snapshot.unit)) + .font(.system(size: 56, weight: .black)) + .foregroundStyle(WatchFormat.bgColor(snapshot.glucose, lowMgdl: state.lowMgdl, highMgdl: state.highMgdl, colored: state.colorBGText)) + .strikethrough(isStale, pattern: .solid, color: .red) + .lineLimit(1) + .minimumScaleFactor(0.5) + .overlay(alignment: .trailing) { + Text(WatchFormat.trendArrow(snapshot.trend)) + .font(.system(size: 28, weight: .black)) + .fixedSize() + .alignmentGuide(.trailing) { $0[.leading] - 4 } + } + .padding(.horizontal, 34) Text(WatchFormat.delta(snapshot.delta, unit: snapshot.unit)) .font(.title3) .foregroundStyle(.secondary) @@ -103,14 +100,49 @@ struct GlucosePage: View { Label("Not looping", systemImage: "exclamationmark.triangle.fill") .font(.footnote) .foregroundStyle(.red) - } else if let cob = snapshot.cob { - Text("COB \(WatchFormat.cob(cob))") - .font(.footnote) - .foregroundStyle(.secondary) - .lineLimit(1) + } else if snapshot.iob != nil || snapshot.cob != nil { + HStack(spacing: 12) { + if let iob = snapshot.iob { + Text("\(Image(systemName: "syringe")) \(WatchFormat.iob(iob))") + .accessibilityLabel("Insulin on board \(WatchFormat.iob(iob))") + } + if let cob = snapshot.cob { + Text("\(Image(systemName: "fork.knife")) \(WatchFormat.cob(cob))") + .accessibilityLabel("Carbs on board \(WatchFormat.cob(cob))") + } + } + .font(.footnote) + .foregroundStyle(.secondary) + .lineLimit(1) + .minimumScaleFactor(0.7) } } } + + /// The running override's name and insulin needs. + @ViewBuilder + private func overrideLine(now: Date) -> some View { + let active = state.activeOverride(at: now) + // The phone's periods end on time; the watch's own fetch only has the snapshot's name. + if let name = state.overrides == nil ? state.snapshot?.override : active?.name { + OverrideLine(name: name, percent: active?.percent) + } + } +} + +/// An override's name and insulin needs, in the green of the overrides button and chart band. +struct OverrideLine: View { + let name: String + let percent: Int? + + var body: some View { + Text("\(Image(systemName: "slider.horizontal.3")) \(name)\(percent.map { " · \($0) %" } ?? "")") + .font(.footnote) + .foregroundStyle(.green) + .lineLimit(1) + .minimumScaleFactor(0.7) + .accessibilityLabel("Override \(name)\(percent.map { ", insulin needs \($0) percent" } ?? "")") + } } /// Override, treatments and temp target, as in Apple's bottom-toolbar pattern. diff --git a/LoopFollowWatch/WatchChartView.swift b/LoopFollowWatch/WatchChartView.swift index b6be4c090..71cc3225f 100644 --- a/LoopFollowWatch/WatchChartView.swift +++ b/LoopFollowWatch/WatchChartView.swift @@ -5,7 +5,8 @@ import Charts import SwiftUI /// BG chart in the phone's colors: readings colored by range, dashed low/high -/// lines, prediction lines or cone in their phone colors, boluses and carbs as markers. +/// lines, prediction lines or cone in their phone colors, boluses and carbs as markers, +/// and overrides as a green band along the top. struct WatchChartView: View { let state: WatchState let now: Date @@ -24,6 +25,7 @@ struct WatchChartView: View { (line.kind, line.points.filter { window.contains($0.date) }) } let cone = state.cone.filter { window.contains($0.date) } + let overrides = (state.overrides ?? []).filter { $0.endDate > window.lowerBound && $0.startDate < window.upperBound } let top = yTop(readings: readings, predictions: predictions.flatMap(\.1) + cone.map { WatchReading(t: $0.t, v: $0.high) }) Chart { @@ -46,6 +48,17 @@ struct WatchChartView: View { .foregroundStyle(Color.blue.opacity(0.4)) } + // A thin band along the top edge, like the override strip on the phone's chart. + ForEach(overrides, id: \.start) { override in + RectangleMark( + xStart: .value("Start", max(override.startDate, window.lowerBound)), + xEnd: .value("End", min(override.endDate, window.upperBound)), + y: .value("Override", top), + height: .fixed(4) + ) + .foregroundStyle(Color.green.opacity(0.8)) + } + if let marker = markerDate { RuleMark(x: .value("Marker", marker)) .foregroundStyle(Color.white.opacity(0.8)) diff --git a/Tests/WatchProtocolTests.swift b/Tests/WatchProtocolTests.swift index 1854e27b8..ebb5231b0 100644 --- a/Tests/WatchProtocolTests.swift +++ b/Tests/WatchProtocolTests.swift @@ -19,6 +19,10 @@ struct WatchProtocolTests { cone: [WatchConePoint(t: t0.timeIntervalSince1970 + 300, low: 120, high: 150)], boluses: [WatchReading(t: t0.timeIntervalSince1970 - 600, v: 2.5)], carbs: [WatchReading(t: t0.timeIntervalSince1970 - 600, v: 30)], + overrides: [ + WatchOverride(start: t0.timeIntervalSince1970 - 7200, end: t0.timeIntervalSince1970 - 3600, name: "Sleep", percent: nil), + WatchOverride(start: t0.timeIntervalSince1970 - 1800, end: t0.timeIntervalSince1970 + 1800, name: "Exercise", percent: 80), + ], lowMgdl: 70, highMgdl: 180, colorBGText: true, @@ -52,6 +56,15 @@ struct WatchProtocolTests { #expect(WatchState.decode(data) == original) } + @Test("The active override is the one whose period contains the time") + func activeOverride() { + let state = state() + #expect(state.activeOverride(at: t0)?.name == "Exercise") + #expect(state.activeOverride(at: t0.addingTimeInterval(-5400))?.name == "Sleep") + #expect(state.activeOverride(at: t0.addingTimeInterval(-3000)) == nil) + #expect(state.activeOverride(at: t0.addingTimeInterval(1800)) == nil) + } + @Test("Commands keep their action, id and creation time") func commandRoundTrip() throws { let actions: [WatchCommand.Action] = [ diff --git a/WatchShared/WatchState.swift b/WatchShared/WatchState.swift index 095784b9f..91c73c69c 100644 --- a/WatchShared/WatchState.swift +++ b/WatchShared/WatchState.swift @@ -24,6 +24,8 @@ struct WatchState: Codable, Equatable { var cone: [WatchConePoint] var boluses: [WatchReading] var carbs: [WatchReading] + /// Override periods in the last 24 hours, oldest first; nil from the watch's own fetch. + var overrides: [WatchOverride]? /// Range thresholds used for coloring, in mg/dL (same as the phone). var lowMgdl: Double var highMgdl: Double @@ -65,6 +67,26 @@ struct WatchReading: Codable, Equatable { var date: Date { Date(timeIntervalSince1970: t) } } +/// One override period, as drawn in the band at the top of the phone's chart. +struct WatchOverride: Codable, Equatable { + /// Unix epoch seconds. + let start: TimeInterval + let end: TimeInterval + let name: String + /// Insulin needs as a percentage of normal; nil when unchanged. + let percent: Int? + + var startDate: Date { Date(timeIntervalSince1970: start) } + var endDate: Date { Date(timeIntervalSince1970: end) } +} + +extension WatchState { + /// The override running at `date`, if any. + func activeOverride(at date: Date) -> WatchOverride? { + overrides?.last { $0.startDate <= date && date < $0.endDate } + } +} + /// The phone's statistics panel, formatted on the phone. struct WatchStats: Codable, Equatable { var lowPercent: String