Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
90 commits
Select commit Hold shift + click to select a range
e1342fe
Sport Mode: the watch runs Loop on a pod borrowed from the phone
Oct 1, 2026
bb0801f
Sport Mode: phone loan UI, watch automatic bolus, feature flag, watch…
ps2 Sep 30, 2026
5bff32a
Watch: read G7 status from the CGM manager; drop the re-lodge toggle
ps2 Sep 30, 2026
d1d0b4e
Device hand-off contract, persisted loan state, wrist alerts, back to…
Oct 1, 2026
1125843
Hold automatic dosing from a force reclaim until its audit rules
Oct 1, 2026
9355e05
Match a wrist carb delete to the carb the phone already stored
Oct 1, 2026
7e9dd70
Drop the G7 grid-delay re-lodge tests
Oct 1, 2026
6512ad9
Follow G7SensorKit's neutral status names; drop the Reconnect button
Oct 1, 2026
5a93338
Take the audit's pulse size from the pump
Oct 1, 2026
164303f
Let the glucose store dedupe the phone's relayed G7 reading
Oct 1, 2026
a594e4b
Use stock's algorithm windows on the watch
Oct 2, 2026
1d524bf
Keep 24 hours of override history on the watch
Oct 2, 2026
831df7e
Send the phone's last 24 h of overrides with the grant
Oct 2, 2026
73d2df4
Restore the override history when a loan resumes after a relaunch
Oct 2, 2026
312dc80
Record wrist override changes on the phone at the time they were made
Oct 2, 2026
04c2a42
Disable Settings while the pod is on the watch
Oct 2, 2026
6243cb5
Tests: repair two override tests the gate never ran
Oct 2, 2026
26560c0
Apply wrist overrides on the phone's main thread
Oct 2, 2026
f8fe77c
Send the phone's last 24 h of settings with the grant
Oct 2, 2026
af68157
Tests: name the relayed G7 reading as the kit does
Oct 2, 2026
36f59e6
Watch: save the pump kit's local state and pass it to the next adopt
Oct 2, 2026
9119f6a
Watch: remove the loop's differences from stock in dosing and display
Oct 2, 2026
86f1e97
Watch: refresh the display run when stock does
Oct 2, 2026
378d62e
Watch: keep stock's dosing decisions
Oct 2, 2026
5f09c81
Watch: keep dosing decisions as long as the phone does
Oct 2, 2026
4a64c6d
Tests: wrist alert tests count only their own alert
Oct 2, 2026
b85067c
Watch: keep stock's alert history
Oct 2, 2026
1e2bc61
Watch: keep stock's device log
Oct 2, 2026
707c331
Watch entitlements from a build setting, empty by default
Oct 2, 2026
c798149
Watch: run cycles only where stock does
Oct 4, 2026
7c18e5d
Watch: wrist bolus follows stock's carb-then-bolus order
Oct 4, 2026
b029ebd
Watch: end pre-meal where stock does
Oct 4, 2026
0b8d6a8
Watch: compile stock's DoseEnactor instead of a copy
Oct 4, 2026
5a6e1a5
Watch: size the stores and name the pump as stock does
Oct 4, 2026
09426d5
Watch: word a failed wrist bolus as stock does
Oct 4, 2026
a1b4563
Return stock files to stock where nothing needs the change
Oct 4, 2026
cd50760
Watch: delete dead code
Oct 4, 2026
6ac9e2d
Watch: run stock's pod checks on every closed-loop cycle
Oct 4, 2026
70d4d60
Watch: closing the loop runs a cycle, as stock
Oct 4, 2026
30fa405
Watch: opening the loop cancels the temp as stock does
Oct 4, 2026
4fe4e9e
Watch: automatic dosing follows the wrist's loop switch
Oct 4, 2026
b8e7192
Watch: check the predicted low after every cycle, as stock
Oct 4, 2026
a057d72
Watch: a new loan starts its glucose alerts with no open episode
Oct 4, 2026
2f2007b
Watch: net basal from stock's getNetBasal
Oct 4, 2026
b0c2b08
Phone: apply every override change from the wrist, in order
Oct 4, 2026
cd6c37d
Loan: wrist carbs keep their entry time and food type
Oct 4, 2026
67ce5e6
Loan: delete the prediction snapshot the phone never sends
Oct 4, 2026
fba859d
Loan: drop never-built dosing modes and watch-only #if arms
Oct 4, 2026
ef08131
Watch: one arm/disarm helper for the wrist's alarm timers
Oct 4, 2026
2c45fba
Watch: hand the phone's reading to the loop directly
Oct 4, 2026
059339a
Phone: move the status screen's Sport Mode UI to its own file
Oct 4, 2026
0833a1b
Return three more stock files toward stock
Oct 4, 2026
43148f9
Move test-only blocking readers into the test targets
Oct 4, 2026
7404eb9
Watch: make log text and comments match the code
Oct 4, 2026
d51bef3
Watch: test that a loan's teardown releases its pod manager
Oct 4, 2026
2f3864d
Watch complications on WidgetKit, with migration from ClockKit
Oct 4, 2026
0f24be1
Merge the WidgetKit complications (stock, complications/stock-widgetk…
Oct 4, 2026
4a95cac
Complications: reload only Loop's own kinds; the throttle is generic
Oct 4, 2026
ea461e1
Merge branch 'complications/stock-widgetkit' into sportmode/next-dev-…
Oct 4, 2026
fa11658
Sport glance complication: one configurable WidgetKit complication
Oct 4, 2026
ac5cdc7
Glance complication: served-redraw diagnostics for the confirmation runs
Oct 4, 2026
72df6e7
Watch: readings relayed by the phone reach the wrist's glucose alerts
Oct 4, 2026
ee16a3f
Phone: during a loan, the watch raises the glucose alerts
Oct 4, 2026
a8a1682
Glance complication: String metric parameter (every preset rendered a…
Oct 4, 2026
4706d25
Watch: use stock's LoopError in place of a watch error type
Oct 4, 2026
21681b2
Watch: run the loop cycle in one function, in stock's order
Oct 4, 2026
0428a96
Glance complication: layout pass from the simulator screenshots
Oct 4, 2026
b326efb
Share LoopDataManager's top-level helpers with the watch app
Oct 4, 2026
e62ec48
Share LoopDataManager.fetchData's body with the watch app
Oct 4, 2026
bede3f1
Phone: start glucose alert episodes afresh after a loan
Oct 4, 2026
984ae3e
Loan: carry the glucose-based application factor setting to the watch
Oct 4, 2026
e462e48
Loan audit: no checkpoint from a reading taken while a bolus delivers
Oct 4, 2026
d69cdda
Share loop()'s input checks and the basal-delivery helper with the wa…
Oct 4, 2026
23948c7
Watch: remove dead code and correct stale comments
Oct 4, 2026
d575157
Shrink the Sport Mode hooks inside stock files
Oct 4, 2026
33c4871
Watch: alarm on relayed readings from takeover to drain; cycle at onc…
Oct 4, 2026
47636ce
Watch: hand back after any cycle in flight; book takeover insulin bef…
Oct 4, 2026
71ef5d2
Grant: seed the algorithm's 12 h glucose window, not 3 h
Oct 4, 2026
1ae1dd3
Sensor-search alert: say it is the watch app, and what success looks …
Oct 5, 2026
db2f90f
Test: only a new sensor rebuilds the watch's G7 manager
Oct 5, 2026
58f5211
Watch: each phone context re-checks the sensor's acquisition
Oct 5, 2026
04c649c
Watch: re-check the sensor's acquisition when the app comes forward
Oct 5, 2026
89c6747
Loan audit: count the scheduled basal continuously
Oct 9, 2026
f032cff
Loan records: a cancelled temp reaches the phone
Oct 9, 2026
8628f43
Loan audit: judge the whole loan, once
Oct 9, 2026
0f87114
Watch loan carbs: stop treating the entry as pending once sent
Oct 9, 2026
5f04687
Watch: never complete a Bluetooth wake task twice
Oct 9, 2026
bcf0c1d
Merge sportmode/next-dev (LoopKit/Loop#2545) into sportmode/next-dev-…
Oct 9, 2026
4f461e1
Glance complication: refresh while the watch is connected to the sens…
Oct 9, 2026
f6d09de
Glance complication: options per shape, led by the reading, labelled …
Oct 9, 2026
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
8 changes: 8 additions & 0 deletions Common/FeatureFlags.swift
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ struct FeatureFlagConfiguration: Decodable {
let allowAlgorithmExperiments: Bool
let isInvestigationalDevice: Bool
let devBranchWarningEnabled: Bool
let sportModeEnabled: Bool

fileprivate init() {
// Swift compiler config is inverse, since the default state is enabled.
Expand Down Expand Up @@ -239,6 +240,12 @@ struct FeatureFlagConfiguration: Decodable {
#else
self.devBranchWarningEnabled = true
#endif

#if SPORT_MODE_ENABLED
self.sportModeEnabled = true
#else
self.sportModeEnabled = false
#endif
}
}

Expand All @@ -260,6 +267,7 @@ extension FeatureFlagConfiguration : CustomDebugStringConvertible {
"* observeHealthKitGlucoseSamplesFromOtherApps: \(observeHealthKitGlucoseSamplesFromOtherApps)",
"* predictedGlucoseChartClampEnabled: \(predictedGlucoseChartClampEnabled)",
"* remoteCommandsEnabled: \(remoteCommandsEnabled)",
"* sportModeEnabled: \(sportModeEnabled)",
"* scenariosEnabled: \(scenariosEnabled)",
"* showEventualBloodGlucoseOnWatchEnabled: \(showEventualBloodGlucoseOnWatchEnabled)",
"* simulatedCoreDataEnabled: \(simulatedCoreDataEnabled)",
Expand Down
26 changes: 26 additions & 0 deletions Common/Models/BuildDetails+SportMode.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
//
// BuildDetails+SportMode.swift
// Loop
//
// Copyright © 2026 LoopKit Authors. All rights reserved.
//
// Sport Mode's build identity, shown on both devices and sent with a loan request.
//

import Foundation

extension BuildDetails {
/// The workspace commit plus build date and time: the same on phone and watch when built
/// together. CFBundleVersion is pinned, so it cannot tell builds apart.
var codeIdentity: String {
let sha = (dict["com-loopkit-Loop-commit-sha"] as? String)
?? (dict["com-loopkit-LoopWorkspace-git-revision"] as? String)
?? gitRevision
guard let sha else { return "sha?" }
// "Wed Aug 19 15:59:38 EDT 2026" -> "Aug19 15:59".
let parts = (dict["com-loopkit-Loop-build-date"] as? String)?
.split(separator: " ").map(String.init) ?? []
let stamp = parts.count >= 4 ? " \(parts[1])\(parts[2]) \(parts[3].prefix(5))" : ""
return sha + stamp
}
}
133 changes: 133 additions & 0 deletions Loop Complications/ComplicationSnapshot.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
//
// ComplicationSnapshot.swift
// Loop Complications
//
// What the watch's complications show, published by the watch app into the shared app group and
// read by the Loop Complications widget extension. Compiled into both (and into LoopTests). Strings
// arrive already formatted and localized by the app — the extension links nothing of Loop's — so
// the widget only decides, per moment, which of them to show.
//

import Foundation

struct ComplicationSnapshot: Codable, Equatable {
/// The reading, formatted for the display unit, or the sensor's condition ("LOW", "HIGH").
var glucoseText: String?
var trendSymbol: String?
/// Shown in place of the reading once it is stale (stock's localized "---").
var staleGlucoseText: String = "---"
/// After this, the reading is stale (stock: reading date + LoopAlgorithm.inputDataRecencyInterval).
var glucoseStaleAt: Date?
var eventualText: String?
/// The wide inline line in stock's localized "UtilitarianLargeFlat" format
/// ("106↗ 108 8:47 PM"), and the same line with the reading stale.
var inlineText: String?
var inlineStaleText: String?
/// When the loop last completed, and where its colour changes (LoopCompletionFreshness: fresh ≤ 6 min,
/// aging ≤ 16 min, then stale) — computed by the app so the constants live in one place.
var loopDate: Date?
var freshUntil: Date?
var agingUntil: Date?
/// When the glucose graph image beside this snapshot was rendered (nil: no graph).
var graphRenderedAt: Date?

enum Freshness { case fresh, aging, stale }

func freshness(at date: Date) -> Freshness {
guard loopDate != nil else { return .stale }
if let fresh = freshUntil, date <= fresh { return .fresh }
if let aging = agingUntil, date <= aging { return .aging }
return .stale
}

func glucoseIsStale(at date: Date) -> Bool {
glucoseStaleAt.map { date > $0 } ?? true
}

/// The reading as shown at `date`: the value, or the stale marker.
func glucose(at date: Date) -> String {
glucoseIsStale(at: date) ? staleGlucoseText : (glucoseText ?? staleGlucoseText)
}

/// The trend symbol, blank once the reading is stale (as stock).
func trend(at date: Date) -> String {
glucoseIsStale(at: date) ? "" : (trendSymbol ?? "")
}

func inline(at date: Date) -> String {
(glucoseIsStale(at: date) ? inlineStaleText : inlineText) ?? glucose(at: date)
}

/// The moments after `date` at which something visible changes: the reading goes stale, the loop
/// colour turns. The widget's timeline carries one entry for each, so no reload is needed for them.
func changeMoments(after date: Date) -> [Date] {
[glucoseStaleAt.map { $0.addingTimeInterval(1) }, freshUntil.map { $0.addingTimeInterval(1) },
agingUntil.map { $0.addingTimeInterval(1) }]
.compactMap { $0 }.filter { $0 > date }.sorted()
}

// MARK: - Shared storage

static let defaultsKey = "ComplicationSnapshot"
static let graphFileName = "ComplicationGlucoseGraph.png"

/// The app group both targets share, from each one's own Info.plist.
static var appGroupIdentifier: String? {
Bundle.main.object(forInfoDictionaryKey: "AppGroupIdentifier") as? String
}

static var sharedDefaults: UserDefaults? { appGroupIdentifier.flatMap { UserDefaults(suiteName: $0) } }

static var graphURL: URL? {
appGroupIdentifier
.flatMap { FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: $0) }?
.appendingPathComponent(graphFileName)
}

static func load(from defaults: UserDefaults? = sharedDefaults) -> ComplicationSnapshot? {
defaults?.data(forKey: defaultsKey).flatMap { try? JSONDecoder().decode(Self.self, from: $0) }
}

func save(to defaults: UserDefaults? = Self.sharedDefaults) {
if let data = try? JSONEncoder().encode(self) { defaults?.set(data, forKey: Self.defaultsKey) }
}

static var sample: ComplicationSnapshot {
let now = Date()
return ComplicationSnapshot(glucoseText: "106", trendSymbol: "↗", glucoseStaleAt: now.addingTimeInterval(15 * 60),
eventualText: "108", inlineText: "106↗ 108", inlineStaleText: "--- 108",
loopDate: now, freshUntil: now.addingTimeInterval(6 * 60),
agingUntil: now.addingTimeInterval(16 * 60), graphRenderedAt: nil)
}
}

/// The widget kinds, shared with the watch app (its ClockKit migrator maps old complications to them).
enum LoopComplicationKind {
static let loopStatus = "LoopStatus"
static let glucoseGraph = "GlucoseGraph"

static let all = [loopStatus, glucoseGraph]
}

/// When the watch app should ask WidgetKit for a reload. Reloads are budgeted by the system, so one is
/// requested only when the snapshot changed and at most every `minimumInterval`; a change inside the
/// window is not lost — the owed reload is paid on the next offer once the window has passed.
struct ComplicationReloadThrottle<Snapshot: Equatable> {
var minimumInterval: TimeInterval = 5 * 60
private(set) var lastPublished: Snapshot?
private(set) var lastReloadAt = Date.distantPast
private(set) var reloadOwed = false

/// `save`: the snapshot changed and should be written. `reload`: ask WidgetKit now.
mutating func offer(_ snapshot: Snapshot, now: Date) -> (save: Bool, reload: Bool) {
let changed = snapshot != lastPublished
if changed {
lastPublished = snapshot
reloadOwed = true
}
guard reloadOwed, now.timeIntervalSince(lastReloadAt) >= minimumInterval else { return (changed, false) }
reloadOwed = false
lastReloadAt = now
return (changed, true)
}
}
Loading