Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
49 commits
Select commit Hold shift + click to select a range
b80a7a8
Loop: move the types the loop runs on into LoopCore
Oct 28, 2026
2dfe293
Sport Mode: the phone–watch protocol and who controls the pod
Oct 28, 2026
52b946b
Loop: one loop cycle that both apps can run
Oct 28, 2026
f2af598
Sport Mode: the phone lends the pod
Oct 28, 2026
52cd1b1
Sport Mode: the phone's hooks in stock files
Oct 28, 2026
a0bee61
Watch: one set of stores, and one display state for the screens
Oct 28, 2026
2535355
Sport Mode: the watch borrows the pod
Oct 28, 2026
58bf0ab
Sport Mode: what the user sees
Oct 28, 2026
16e54c1
Watch complications on WidgetKit, with migration from ClockKit
Oct 28, 2026
26058db
Sport Mode glance complication on WidgetKit
Oct 28, 2026
d349679
Merge upstream next-dev (setup history); project file conflicts resolved
Oct 28, 2026
8c7fa00
Merge sportmode/next-dev
Oct 28, 2026
9b13016
Merge sportmode/next-dev
Oct 28, 2026
0696c1f
Sport Mode: the banner's time counts only saved records, also for a l…
Oct 28, 2026
d6f188e
Merge sportmode/next-dev
Oct 30, 2026
e621264
Watch: one place chooses the watch app's entitlements, and both keep …
Oct 30, 2026
b6b7d9d
Sport Mode: the phone logs the grant's size and a failed urgent send …
Oct 30, 2026
50c3db8
Merge sportmode/next-dev
Nov 1, 2026
4f9afd2
Build a glucose reading's fallback ID without overflowing on 32-bit w…
Nov 1, 2026
5f9438f
Glucose Graph: stock's layout, the chart drawn to fill its slot
Nov 1, 2026
df96adc
Merge upstream next-dev: the algorithm decoupling
Nov 1, 2026
e109cb3
Run the watch's loop through the same algorithm bridge as the phone
Nov 1, 2026
f403a6a
Revert "Glucose Graph: stock's layout, the chart drawn to fill its slot"
Nov 1, 2026
261631a
Draw the Glucose Graph complication in the widget at its slot's size
ps2 Nov 1, 2026
692ae04
Glucose Graph: the reading and its trend over the chart, as stock
Nov 1, 2026
eb468af
Merge sportmode/next-dev
Nov 1, 2026
06ad84f
Complications take the input recency interval from LoopCore
Nov 1, 2026
eaccb63
Glucose Graph: during a loan, the watch's own loop, as the app's scre…
ps2 Nov 1, 2026
abf41e3
Merge complications/display-context
Nov 2, 2026
ce243ed
Glucose Graph and Loop Status redraw with the glance complication
Nov 2, 2026
3900495
Merge upstream next-dev: the LoopKit/LoopAlgorithm decoupling and slo…
Nov 2, 2026
61b93a0
Merge upstream next-dev: slow retrospective correction
Nov 2, 2026
2e3b78c
Merge the LoopCore structural branch
Nov 2, 2026
19c6219
Sport Mode: refuse Start while slow retrospective correction is on
Nov 2, 2026
c2d8a35
Merge sportmode/next-dev
Nov 2, 2026
bd7472c
Merge upstream next-dev: the loop's types in LoopCore (#2645), async …
Nov 3, 2026
4ba8e30
Sport Mode: slow retrospective correction on the watch
Nov 3, 2026
0db8c4f
Merge sportmode/next-dev
Nov 3, 2026
b871b7c
Watch app: name each device kit in one file
Nov 3, 2026
4d3c70e
Merge sportmode/next-dev
Nov 3, 2026
ef278ab
Complications: read the sensor's last connection through WatchDeviceM…
Nov 3, 2026
11d58b3
Sport Mode: a bolus asked for during an End is refused, never sent to…
Nov 3, 2026
463e386
Sport Mode: after a relaunch the watch reads the pod before its first…
Nov 3, 2026
3411bd5
Sport Mode: only the phone's answer to the final offer closes the loan
Nov 3, 2026
e51668f
Sport Mode: the watch records a pod's suspend for the phone
Nov 3, 2026
14a0773
Sport Mode: a take-back of a faulted pod finishes
Nov 3, 2026
96ea232
Sport Mode: the pod's alerts close when it goes home
Nov 3, 2026
b500ad3
Sport Mode: deactivate a faulted pod on the watch
Nov 3, 2026
99571ba
Merge sportmode/next-dev
Nov 3, 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
}
}
642 changes: 642 additions & 0 deletions Documentation/SportMode/Control.md

Large diffs are not rendered by default.

168 changes: 168 additions & 0 deletions Loop Complications/ComplicationSnapshot.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
//
// 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?
/// What the glucose graph draws; the widget renders it at its slot's size (nil: no graph).
var graph: ComplicationGraph?

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"

/// 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 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), graph: .sample(now: now))
}
}

/// The glucose graph in the display unit, as plain numbers, so the widget can draw it at its own size.
struct ComplicationGraph: Codable, Equatable {
struct Point: Codable, Equatable {
var date: Date
var value: Double
var isManual = false
}

struct Band: Codable, Equatable {
var start: Date
var end: Date
var min: Double
var max: Double
}

/// The span shown on each side of the moment drawn, as stock's ClockKit graph (4 h centered on now).
static let halfSpan: TimeInterval = 2 * 60 * 60

var glucoseMin: Double
var glucoseMax: Double
var minLabel: String
var maxLabel: String
var targetRanges: [Band]
/// The active override's target, over its active interval.
var override: Band?
var historical: [Point]
var predicted: [Point]

static func sample(now: Date) -> ComplicationGraph {
let historical = (0..<24).map { i in
Point(date: now.addingTimeInterval(TimeInterval(i - 24) * 300), value: 110 + 25 * sin(Double(i) / 4))
}
let predicted = (0..<24).map { i in
Point(date: now.addingTimeInterval(TimeInterval(i) * 300), value: 120 - Double(i))
}
return ComplicationGraph(glucoseMin: 75, glucoseMax: 150, minLabel: "75", maxLabel: "150",
targetRanges: [Band(start: now.addingTimeInterval(-halfSpan), end: now.addingTimeInterval(halfSpan),
min: 100, max: 110)],
override: nil, historical: historical, predicted: predicted)
}
}

/// 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