Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions LoopFollow/Watch/WatchStateBuilder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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])] = [
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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] = []
Expand Down
92 changes: 71 additions & 21 deletions LoopFollowWatch/ChartPage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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,
Expand All @@ -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
Expand All @@ -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 }
}
}
80 changes: 56 additions & 24 deletions LoopFollowWatch/GlucosePage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
}
Expand All @@ -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)
Expand Down Expand Up @@ -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.
Expand Down
15 changes: 14 additions & 1 deletion LoopFollowWatch/WatchChartView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
Expand All @@ -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))
Expand Down
13 changes: 13 additions & 0 deletions Tests/WatchProtocolTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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] = [
Expand Down
22 changes: 22 additions & 0 deletions WatchShared/WatchState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down