Skip to content

Commit bccd5b8

Browse files
committed
Add Apple Watch app with complications, haptic alerts, and remote commands
- Add LoopFollowWatch target: glucose display, trend arrow, IOB/COB/TDD data page, haptic alert system with snooze, Watch complications (CLKComplication provider), in-memory snapshot fallback, and complication cache to fix background reloadTimeline - Add Watch remote commands via LoopAPNS relay: bolus, meal, override/preset selection; includes WatchCommandDispatcher, haptic confirmation, and local success notification - Add WatchConnectivityManager: send glucose snapshots to Watch on each refresh, cancel stale transfers to reduce latency, auto-sync APNS credentials on change, forward Loop command return notifications to Watch with haptic feedback - Extend LAAppGroupSettings and StorageCurrentGlucoseStateProvider for Watch app group - Add Trio preset support in Watch preset sync via ProfileManager - No hardcoded development team in project.pbxproj https://claude.ai/code/session_01DGCES9o6CLpeAmfe7tMoR6
1 parent 0d91b95 commit bccd5b8

39 files changed

Lines changed: 3227 additions & 53 deletions

‎LoopFollow.xcodeproj/project.pbxproj‎

Lines changed: 231 additions & 1 deletion
Large diffs are not rendered by default.

‎LoopFollow/Application/AppDelegate.swift‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
4040
_ = BLEManager.shared
4141
// Ensure VolumeButtonHandler is initialized so it can receive alarm notifications
4242
_ = VolumeButtonHandler.shared
43+
44+
WatchConnectivityManager.shared.activate()
4345

4446
// Register for remote notifications
4547
DispatchQueue.main.async {
@@ -123,6 +125,9 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
123125
}
124126
}
125127

128+
// Forward Loop command return notifications to the Watch
129+
WatchConnectivityManager.shared.forwardCommandReturnToWatch(userInfo: userInfo)
130+
126131
// Call completion handler
127132
completionHandler(.newData)
128133
}

‎LoopFollow/Controllers/Nightscout/BGData.swift‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,7 @@ extension MainViewController {
268268

269269
// Live Activity storage
270270
Storage.shared.lastBgReadingTimeSeconds.value = lastBGTime
271+
Storage.shared.lastBgMgdl.value = Double(latestBG)
271272
Storage.shared.lastDeltaMgdl.value = Double(deltaBG)
272273
Storage.shared.lastTrendCode.value = entries[latestEntryIndex].direction
273274

‎LoopFollow/Controllers/Nightscout/ProfileManager.swift‎

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -94,8 +94,8 @@ final class ProfileManager {
9494
loopOverrides = []
9595
}
9696

97-
if let trioOverrides = profileData.trioOverrides {
98-
self.trioOverrides = trioOverrides.map { entry in
97+
if let trioPresets = profileData.trioOverrides {
98+
self.trioOverrides = trioPresets.map { entry in
9999
let targetQuantity = entry.target != nil ? HKQuantity(unit: .milligramsPerDeciliter, doubleValue: entry.target!) : nil
100100
return TrioOverride(
101101
name: entry.name,
@@ -108,6 +108,28 @@ final class ProfileManager {
108108
trioOverrides = []
109109
}
110110

111+
// Write presets to the shared App Group for the Watch remote commands view.
112+
// Prefer Loop override presets; fall back to Trio presets for Trio users.
113+
let watchPresets: [WatchOverridePreset]
114+
if !loopOverrides.isEmpty {
115+
watchPresets = loopOverrides.map {
116+
WatchOverridePreset(
117+
name: $0.name,
118+
symbol: $0.symbol.isEmpty ? nil : $0.symbol,
119+
durationSeconds: TimeInterval($0.duration ?? 0)
120+
)
121+
}
122+
} else {
123+
watchPresets = trioOverrides.map {
124+
WatchOverridePreset(
125+
name: $0.name,
126+
symbol: nil,
127+
durationSeconds: $0.duration.map { TimeInterval($0) } ?? 0
128+
)
129+
}
130+
}
131+
LAAppGroupSettings.setWatchOverridePresets(watchPresets)
132+
111133
Storage.shared.deviceToken.value = profileData.deviceToken ?? profileData.loopSettings?.deviceToken ?? ""
112134

113135
if let expirationDate = profileData.expirationDate {
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import Foundation
2+
3+
enum APNsCredentialValidator {
4+
static func isValidKeyId(_ keyId: String) -> Bool {
5+
let pattern = "^[A-Z0-9]{10}$"
6+
return keyId.range(of: pattern, options: .regularExpression) != nil
7+
}
8+
9+
static func isValidApnsKey(_ apnsKey: String) -> Bool {
10+
apnsKey.contains("-----BEGIN PRIVATE KEY-----") &&
11+
apnsKey.contains("-----END PRIVATE KEY-----")
12+
}
13+
14+
static func isFullyConfigured(keyId: String, apnsKey: String) -> Bool {
15+
isValidKeyId(keyId) && isValidApnsKey(apnsKey)
16+
}
17+
}

‎LoopFollow/LiveActivity/AppGroupID.swift‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ enum AppGroupID {
5858
".WidgetExtension",
5959
".Widgets",
6060
".WidgetsExtension",
61+
".watchkitapp",
6162
".Watch",
6263
".WatchExtension",
6364
".CarPlay",

‎LoopFollow/LiveActivity/GlucoseSnapshot.swift‎

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,9 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable {
117117
/// True when LoopFollow detects the loop has not reported in 15+ minutes (Nightscout only).
118118
let isNotLooping: Bool
119119

120+
/// Unix epoch seconds of the last successful Loop run (nil if never received).
121+
let loopLastRunAt: TimeInterval?
122+
120123
// MARK: - Renewal
121124

122125
/// True when the Live Activity is within renewalWarning seconds of its renewal deadline.
@@ -154,6 +157,7 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable {
154157
maxBgMgdl: Double? = nil,
155158
unit: Unit,
156159
isNotLooping: Bool,
160+
loopLastRunAt: TimeInterval? = nil,
157161
showRenewalOverlay: Bool = false,
158162
) {
159163
self.glucose = glucose
@@ -184,6 +188,7 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable {
184188
self.maxBgMgdl = maxBgMgdl
185189
self.unit = unit
186190
self.isNotLooping = isNotLooping
191+
self.loopLastRunAt = loopLastRunAt
187192
self.showRenewalOverlay = showRenewalOverlay
188193
}
189194

@@ -194,6 +199,12 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable {
194199
Date().timeIntervalSince(updatedAt)
195200
}
196201

202+
/// Delta expressed as mg/dL per minute.
203+
/// `delta` is a 5-minute total, so divide by 5.0.
204+
var deltaRate: Double? {
205+
return delta / 5.0
206+
}
207+
197208
/// Returns a copy of this snapshot with `showRenewalOverlay` set to the given value.
198209
/// All other fields are preserved exactly. Use this instead of manually copying
199210
/// every field when only the overlay flag needs to change.
@@ -227,6 +238,7 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable {
227238
maxBgMgdl: maxBgMgdl,
228239
unit: unit,
229240
isNotLooping: isNotLooping,
241+
loopLastRunAt: loopLastRunAt,
230242
showRenewalOverlay: value,
231243
)
232244
}
@@ -263,6 +275,7 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable {
263275
try container.encodeIfPresent(maxBgMgdl, forKey: .maxBgMgdl)
264276
try container.encode(unit, forKey: .unit)
265277
try container.encode(isNotLooping, forKey: .isNotLooping)
278+
try container.encodeIfPresent(loopLastRunAt, forKey: .loopLastRunAt)
266279
try container.encode(showRenewalOverlay, forKey: .showRenewalOverlay)
267280
}
268281

@@ -296,6 +309,7 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable {
296309
maxBgMgdl = try container.decodeIfPresent(Double.self, forKey: .maxBgMgdl)
297310
unit = try container.decode(Unit.self, forKey: .unit)
298311
isNotLooping = try container.decodeIfPresent(Bool.self, forKey: .isNotLooping) ?? false
312+
loopLastRunAt = try container.decodeIfPresent(Double.self, forKey: .loopLastRunAt)
299313
showRenewalOverlay = try container.decodeIfPresent(Bool.self, forKey: .showRenewalOverlay) ?? false
300314
}
301315

@@ -305,7 +319,7 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable {
305319
case override, recBolus, battery, pumpBattery, basalRate, pumpReservoirU
306320
case autosens, tdd, targetLowMgdl, targetHighMgdl, isfMgdlPerU, carbRatio, carbsToday
307321
case profileName, sageInsertTime, cageInsertTime, iageInsertTime, minBgMgdl, maxBgMgdl
308-
case unit, isNotLooping, showRenewalOverlay
322+
case unit, isNotLooping, loopLastRunAt, showRenewalOverlay
309323
}
310324
}
311325

‎LoopFollow/LiveActivity/GlucoseSnapshotBuilder.swift‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,9 @@ protocol CurrentGlucoseStateProviding {
9292
/// True when LoopFollow detects the loop has not reported in 15+ minutes.
9393
var isNotLooping: Bool { get }
9494

95+
/// Unix epoch seconds of the last successful Loop run (nil if never received).
96+
var loopLastRunAt: TimeInterval? { get }
97+
9598
// MARK: - Renewal
9699

97100
/// True when the Live Activity is within renewalWarning seconds of its deadline.
@@ -161,6 +164,7 @@ enum GlucoseSnapshotBuilder {
161164
maxBgMgdl: provider.maxBgMgdl,
162165
unit: preferredUnit,
163166
isNotLooping: provider.isNotLooping,
167+
loopLastRunAt: provider.loopLastRunAt,
164168
showRenewalOverlay: provider.showRenewalOverlay,
165169
)
166170
}

‎LoopFollow/LiveActivity/GlucoseSnapshotStore.swift‎

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22
// GlucoseSnapshotStore.swift
33

44
import Foundation
5+
import os.log
6+
7+
private let storeLog = OSLog(subsystem: Bundle.main.bundleIdentifier ?? "com.loopfollow", category: "GlucoseSnapshotStore")
58

69
/// Persists the latest GlucoseSnapshot into the App Group container so that:
710
/// - the Live Activity extension can read it
@@ -17,31 +20,37 @@ final class GlucoseSnapshotStore {
1720

1821
// MARK: - Public API
1922

20-
func save(_ snapshot: GlucoseSnapshot) {
23+
func save(_ snapshot: GlucoseSnapshot, completion: (() -> Void)? = nil) {
2124
queue.async {
2225
do {
2326
let url = try self.fileURL()
2427
let encoder = JSONEncoder()
2528
encoder.dateEncodingStrategy = .iso8601
2629
let data = try encoder.encode(snapshot)
2730
try data.write(to: url, options: [.atomic])
31+
os_log("GlucoseSnapshotStore: saved snapshot g=%d to %{public}@", log: storeLog, type: .debug, Int(snapshot.glucose), url.lastPathComponent)
2832
} catch {
29-
// Intentionally silent (extension-safe, no dependencies).
33+
os_log("GlucoseSnapshotStore: save failed — %{public}@", log: storeLog, type: .error, error.localizedDescription)
3034
}
35+
completion?()
3136
}
3237
}
3338

3439
func load() -> GlucoseSnapshot? {
3540
do {
3641
let url = try fileURL()
37-
guard FileManager.default.fileExists(atPath: url.path) else { return nil }
42+
guard FileManager.default.fileExists(atPath: url.path) else {
43+
os_log("GlucoseSnapshotStore: file not found at %{public}@", log: storeLog, type: .debug, url.lastPathComponent)
44+
return nil
45+
}
3846

3947
let data = try Data(contentsOf: url)
4048
let decoder = JSONDecoder()
4149
decoder.dateDecodingStrategy = .iso8601
42-
return try decoder.decode(GlucoseSnapshot.self, from: data)
50+
let snapshot = try decoder.decode(GlucoseSnapshot.self, from: data)
51+
return snapshot
4352
} catch {
44-
// Intentionally silent (extension-safe, no dependencies).
53+
os_log("GlucoseSnapshotStore: load failed — %{public}@", log: storeLog, type: .error, error.localizedDescription)
4554
return nil
4655
}
4756
}

‎LoopFollow/LiveActivity/LAAppGroupSettings.swift‎

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,16 @@ enum LiveActivitySlotDefaults {
135135
}
136136
}
137137

138+
// MARK: - Watch remote preset
139+
140+
/// Lightweight override preset transferred via App Group for Watch remote commands.
141+
struct WatchOverridePreset: Codable, Identifiable {
142+
var id: String { name }
143+
var name: String
144+
var symbol: String?
145+
var durationSeconds: TimeInterval // 0 = indefinite
146+
}
147+
138148
// MARK: - App Group settings
139149

140150
/// Minimal App Group settings needed by the Live Activity UI.
@@ -149,6 +159,12 @@ enum LAAppGroupSettings {
149159
static let smallWidgetSlot = "la.smallWidgetSlot"
150160
static let displayName = "la.displayName"
151161
static let showDisplayName = "la.showDisplayName"
162+
static let watchSlots = "watch.slots"
163+
static let watchSelectedSlots = "watch.selectedSlots"
164+
static let watchOverridePresets = "watch.overridePresets"
165+
static let watchRemoteEnabled = "watch.remoteEnabled"
166+
static let watchMaxBolus = "watch.maxBolus"
167+
static let watchMaxCarbs = "watch.maxCarbs"
152168
}
153169

154170
private static var defaults: UserDefaults? {
@@ -206,6 +222,40 @@ enum LAAppGroupSettings {
206222
return LiveActivitySlotOption(rawValue: raw) ?? LiveActivitySlotDefaults.smallWidgetSlot
207223
}
208224

225+
// MARK: - Watch slots (Write)
226+
227+
/// Persists the 4-position Watch data card slot configuration.
228+
static func setWatchSlots(_ slots: [LiveActivitySlotOption]) {
229+
let raw = slots.prefix(4).map(\.rawValue)
230+
defaults?.set(raw, forKey: Keys.watchSlots)
231+
}
232+
233+
// MARK: - Watch slots (Read)
234+
235+
static func watchSlots() -> [LiveActivitySlotOption] {
236+
guard let raw = defaults?.stringArray(forKey: Keys.watchSlots), raw.count == 4 else {
237+
return [.iob, .cob, .projectedBG, .battery]
238+
}
239+
return raw.map { LiveActivitySlotOption(rawValue: $0) ?? .none }
240+
}
241+
242+
// MARK: - Watch selected slots (ordered, variable-length)
243+
244+
/// Persists the user's ordered list of selected Watch data slots.
245+
static func setWatchSelectedSlots(_ slots: [LiveActivitySlotOption]) {
246+
defaults?.set(slots.map(\.rawValue), forKey: Keys.watchSelectedSlots)
247+
}
248+
249+
/// Returns the ordered list of selected Watch data slots.
250+
/// Falls back to a sensible default if nothing is saved.
251+
static func watchSelectedSlots() -> [LiveActivitySlotOption] {
252+
guard let raw = defaults?.stringArray(forKey: Keys.watchSelectedSlots) else {
253+
return [.iob, .cob, .projectedBG, .battery]
254+
}
255+
return raw.compactMap { LiveActivitySlotOption(rawValue: $0) }
256+
}
257+
258+
209259
// MARK: - Display Name
210260

211261
static func setDisplayName(_ name: String, show: Bool) {
@@ -220,4 +270,42 @@ enum LAAppGroupSettings {
220270
static func showDisplayName() -> Bool {
221271
defaults?.bool(forKey: Keys.showDisplayName) ?? false
222272
}
273+
274+
// MARK: - Watch Remote Commands
275+
276+
static func setWatchOverridePresets(_ presets: [WatchOverridePreset]) {
277+
guard let data = try? JSONEncoder().encode(presets) else { return }
278+
defaults?.set(data, forKey: Keys.watchOverridePresets)
279+
}
280+
281+
static func watchOverridePresets() -> [WatchOverridePreset] {
282+
guard let data = defaults?.data(forKey: Keys.watchOverridePresets),
283+
let presets = try? JSONDecoder().decode([WatchOverridePreset].self, from: data)
284+
else { return [] }
285+
return presets
286+
}
287+
288+
static func setWatchRemoteEnabled(_ enabled: Bool) {
289+
defaults?.set(enabled, forKey: Keys.watchRemoteEnabled)
290+
}
291+
292+
static func isWatchRemoteEnabled() -> Bool {
293+
defaults?.bool(forKey: Keys.watchRemoteEnabled) ?? false
294+
}
295+
296+
static func setWatchMaxBolus(_ maxBolus: Double) {
297+
defaults?.set(maxBolus, forKey: Keys.watchMaxBolus)
298+
}
299+
300+
static func watchMaxBolus() -> Double {
301+
defaults?.object(forKey: Keys.watchMaxBolus) as? Double ?? 10.0
302+
}
303+
304+
static func setWatchMaxCarbs(_ maxCarbs: Double) {
305+
defaults?.set(maxCarbs, forKey: Keys.watchMaxCarbs)
306+
}
307+
308+
static func watchMaxCarbs() -> Double {
309+
defaults?.object(forKey: Keys.watchMaxCarbs) as? Double ?? 150.0
310+
}
223311
}

0 commit comments

Comments
 (0)