From 50c771798d382768fa71da6217ab8cd57a0ac143 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Thu, 9 Apr 2026 20:40:23 +0200 Subject: [PATCH] Watch PR #594 review fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Guard LoopFollowDebugCorner descriptor, template, and placeholder branches with #if DEBUG so the diagnostic complication does not ship to end users. - Remove unused LAAppGroupSettings.setWatchSlots / watchSlots (the fixed-4-slot variant superseded by watchSelectedSlots). - Move WatchConnectivityManager.swift from the repo root into LoopFollow/LiveActivity/ next to the rest of the snapshot pipeline and drop the duplicate file header. - Serialize WatchConnectivityManager.lastWatchAckTimestamp access through a dedicated state queue. send(snapshot:) may run on any thread and WCSession delegate callbacks arrive on a background queue, so the bare TimeInterval was racy. - Assert main-thread access for WatchSessionReceiver.cacheComplication and document the invariant on cachedComplications. - Drop the no-op JSONEncoder/JSONDecoder iso8601 date strategies — GlucoseSnapshot has a custom encoder/decoder that writes/reads updatedAt as a Double, so the strategy was never consulted. - Nit: replace `if let _ = snapshot.projected` with `if snapshot.projected != nil`. - Nit: comment why SlotSelectionView excludes .delta / .projectedBG. - Remove CLAUDE.md. --- CLAUDE.md | 144 ------------------ LoopFollow.xcodeproj/project.pbxproj | 2 +- .../LiveActivity/GlucoseSnapshotStore.swift | 6 +- .../LiveActivity/LAAppGroupSettings.swift | 18 --- .../WatchConnectivityManager.swift | 24 ++- .../ComplicationEntryBuilder.swift | 66 ++++---- .../WatchComplicationProvider.swift | 18 ++- .../WatchSessionReceiver.swift | 10 +- LoopFollowWatch Watch App/ContentView.swift | 2 + .../LoopFollowWatchApp.swift | 3 +- 10 files changed, 83 insertions(+), 210 deletions(-) delete mode 100644 CLAUDE.md rename WatchConnectivityManager.swift => LoopFollow/LiveActivity/WatchConnectivityManager.swift (85%) diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 1814899d5..000000000 --- a/CLAUDE.md +++ /dev/null @@ -1,144 +0,0 @@ -# CLAUDE.md - -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. - -## Project Overview - -LoopFollow is an iOS app for caregivers/parents of Type 1 Diabetics (T1D) to monitor CGM glucose data, loop status, and AID system metrics. This fork (`LoopFollowLA`) is built on top of the upstream [loopandlearn/LoopFollow](https://github.com/loopandlearn/LoopFollow) and adds: - -- **Live Activity** (Dynamic Island / Lock Screen) — **complete**, do not modify -- **Apple Watch complications + Watch app** — **active development focus** -- **APNS-based remote commands** — complete - -The Live Activity work is considered stable. If it evolves upstream, the branch is rebased. All current development effort is on the Watch app (`LoopFollowWatch Watch App` target) and its complications. - -## Build System - -This is a CocoaPods project. Always open `LoopFollow.xcworkspace` (not `.xcodeproj`) in Xcode. - -```bash -# Install/update pods after cloning or when Podfile changes -pod install - -# Build from command line (simulator) -xcodebuild -workspace LoopFollow.xcworkspace -scheme LoopFollow -destination 'platform=iOS Simulator,name=iPhone 16' build - -# Run tests -xcodebuild -workspace LoopFollow.xcworkspace -scheme LoopFollow -destination 'platform=iOS Simulator,name=iPhone 16' test - -# Run a single test class -xcodebuild -workspace LoopFollow.xcworkspace -scheme LoopFollow -destination 'platform=iOS Simulator,name=iPhone 16' test -only-testing:LoopFollowTests/AlarmConditions/BatteryConditionTests -``` - -Fastlane lanes (`build_LoopFollow`, `release`, `identifiers`, `certs`) are CI-only and require App Store Connect credentials. - -## Xcode Targets - -| Target | Purpose | -|---|---| -| `LoopFollow` | Main iOS app | -| `LoopFollowLAExtensionExtension` | Live Activity widget extension | -| `LoopFollowWatch Watch App` | watchOS complication app | - -Bundle IDs are derived from `DEVELOPMENT_TEAM`: `com.$(TEAMID).LoopFollow`, etc. `Config.xcconfig` sets the marketing version; never edit version numbers directly (CI auto-bumps on merge to `dev`). - -## Architecture - -### Data Flow - -1. **Data sources** → `MainViewController` pulls BG/treatment data from: - - **Nightscout** (`Controllers/Nightscout/`) via REST API - - **Dexcom Share** (`BackgroundRefresh/BT/`, uses `ShareClient` pod) - - **BLE heartbeat** (`BackgroundRefresh/BT/BLEManager.swift`) for background refresh -2. `MainViewController` stores parsed data in its own arrays (`bgData`, `bolusData`, etc.) and calls `update*Graph()` methods. -3. **Reactive state bridge**: After processing, values are pushed into `Observable.shared` (in-memory) and `Storage.shared` (UserDefaults-backed). These feed SwiftUI views and the Live Activity pipeline. - -### Key Singletons - -- **`Storage`** (`Storage/Storage.swift`) — All persisted user settings as `StorageValue` (UserDefaults) or `SecureStorageValue` (Keychain). The single source of truth for configuration. -- **`Observable`** (`Storage/Observable.swift`) — In-memory reactive state (`ObservableValue`) for transient display values (BG text, color, direction, current alarm, etc.). -- **`ProfileManager`** — Manages Nightscout basal profiles. -- **`AlarmManager`** — Evaluates alarm conditions and triggers sound/notification. - -### Live Activity & Watch Complication Pipeline - -`GlucoseSnapshot` (`LiveActivity/GlucoseSnapshot.swift`) is the **canonical, source-agnostic data model** shared by all Watch and Live Activity surfaces. It is unit-aware (mg/dL or mmol/L) and self-contained. Fields: `glucose`, `delta`, `trend`, `updatedAt`, `iob`, `cob`, `projected`, `unit`, `isNotLooping`. - -``` -MainViewController / BackgroundRefresh - │ - ▼ -GlucoseSnapshotBuilder.build(...) ← assembles from Observable/Storage - │ - ▼ -GlucoseSnapshotStore.shared.save() ← persists to App Group container (JSON, atomic) - │ - ├──► LiveActivityManager.update() ← Dynamic Island / Lock Screen [COMPLETE] - ├──► WatchConnectivityManager.send() ← transferUserInfo to Watch - │ └──► WatchSessionReceiver ← saves snapshot + reloads complications (Watch-side) - └──► WatchComplicationProvider ← CLKComplicationDataSource (watchOS) - └── ComplicationEntryBuilder ← builds CLKComplicationTemplate -``` - -Thresholds for colour classification (green / orange / red) are read via `LAAppGroupSettings.thresholdsMgdl()` from the shared App Group UserDefaults — the same thresholds used by the Live Activity. The stale threshold is **15 minutes** (900 s) throughout. - -### Watch Complications (active development) - -Two corner complications to build in `ComplicationEntryBuilder` (`LoopFollow/WatchComplication/ComplicationEntryBuilder.swift`): - -**Complication 1 — `graphicCorner`, Open Gauge Text** -- Centre: BG value, coloured green/orange/red via `LAAppGroupSettings` thresholds -- Bottom text: delta (e.g. `+3` or `-2`) -- Gauge: fills from 0 → 15 min based on `snapshot.age / 900` -- Stale (>15 min) or `isNotLooping == true`: replace BG with `⚠` (yellow warning symbol) - -**Complication 2 — `graphicCorner`, Stacked Text** -- Top line: BG value (coloured) -- Bottom line: delta + minutes since update (e.g. `+3 4m`) -- Stale (>15 min): display `--` - -Both complications open the Watch app on tap (default watchOS behaviour when linked to the Watch app). `WatchComplicationProvider` handles timeline lifecycle and delegates all template construction to `ComplicationEntryBuilder`. - -### Watch App (active development) - -Entry point: `LoopFollowWatch Watch App/LoopFollowWatchApp.swift` — activates `WatchSessionReceiver`. -Main view: `LoopFollowWatch Watch App/ContentView.swift` — currently a placeholder stub. - -**Screen 1 — Main glucose view** -- Large BG value, coloured green/orange/red -- Right column: delta, projected BG, time since last update -- Button to open the phone app (shown only when `WCSession.default.isReachable`) - -**Subsequent screens — scrollable data cards** -- Each screen shows up to 4 data points from `GlucoseSnapshot` -- User-configurable via Watch app settings; every field in `GlucoseSnapshot` is eligible (glucose, delta, projected, IOB, COB, trend, age); units displayed alongside each value -- Default: IOB, COB, projected BG, battery - -Watch app settings persist in the Watch-side App Group UserDefaults (same suite as `LAAppGroupSettings`). - -### Background Refresh - -Three modes (set in `Storage.backgroundRefreshType`): -- **Silent tune** — plays an inaudible audio track to keep app alive -- **BLE heartbeat** — paired BLE device (e.g. Dexcom G7) wakes the app -- **APNS** — server push via `APNSClient` / `APNSJWTGenerator` - -### Remote Commands - -Remote bolus/carb/temp-target commands flow through `BackgroundRefresh/Remote/` using TOTP-authenticated APNS pushes. Settings live in `Storage` (APNS key, team ID, bundle ID, shared secret). - -### Settings Architecture - -Settings are split between: -- **SwiftUI views** in `Settings/` (new) — `GeneralSettingsView`, `AlarmSettingsView`, `AdvancedSettingsView`, etc. -- **Legacy UIKit** `SettingsViewController` — being migrated to SwiftUI - -### Tests - -Tests use the Swift Testing framework (`import Testing`). Test files are in `Tests/AlarmConditions/`. - -## Branch & PR Conventions - -- **All PRs target `dev`**, never `main`. PRs to `main` will be redirected. -- Never modify version numbers — CI auto-bumps after merge. -- Branch from `dev` and name it `feature_name` or `fix_name`. diff --git a/LoopFollow.xcodeproj/project.pbxproj b/LoopFollow.xcodeproj/project.pbxproj index d4f022be9..af35f685c 100644 --- a/LoopFollow.xcodeproj/project.pbxproj +++ b/LoopFollow.xcodeproj/project.pbxproj @@ -981,6 +981,7 @@ 374A77A12F5BE17000E96858 /* GlucoseSnapshot.swift */, 374A77A32F5BE17000E96858 /* LAAppGroupSettings.swift */, 374A77982F5BD8AB00E96858 /* APNSClient.swift */, + 37989DD32F60A11E0004BD8B /* WatchConnectivityManager.swift */, ); path = LiveActivity; sourceTree = ""; @@ -1680,7 +1681,6 @@ isa = PBXGroup; children = ( 379BECA92F6588300069DC62 /* RestartLiveActivityIntent.swift */, - 37989DD32F60A11E0004BD8B /* WatchConnectivityManager.swift */, 374DAACA2F5B924B00BB663B /* LoopFollowLAExtensionExtension.entitlements */, DDF2C0132BEFD468007A20E6 /* blacklisted-versions.json */, DDB0AF542BB1B24A00AFA48B /* BuildDetails.plist */, diff --git a/LoopFollow/LiveActivity/GlucoseSnapshotStore.swift b/LoopFollow/LiveActivity/GlucoseSnapshotStore.swift index 17569c46f..4e645b6b2 100644 --- a/LoopFollow/LiveActivity/GlucoseSnapshotStore.swift +++ b/LoopFollow/LiveActivity/GlucoseSnapshotStore.swift @@ -24,8 +24,9 @@ final class GlucoseSnapshotStore { queue.async { do { let url = try self.fileURL() + // GlucoseSnapshot writes `updatedAt` as a Double via its custom + // encoder, so no JSONEncoder date strategy is required. let encoder = JSONEncoder() - encoder.dateEncodingStrategy = .iso8601 let data = try encoder.encode(snapshot) try data.write(to: url, options: [.atomic]) os_log("GlucoseSnapshotStore: saved snapshot g=%d to %{public}@", log: storeLog, type: .debug, Int(snapshot.glucose), url.lastPathComponent) @@ -45,8 +46,9 @@ final class GlucoseSnapshotStore { } let data = try Data(contentsOf: url) + // GlucoseSnapshot reads `updatedAt` as a Double via its custom decoder, + // so no JSONDecoder date strategy is required. let decoder = JSONDecoder() - decoder.dateDecodingStrategy = .iso8601 let snapshot = try decoder.decode(GlucoseSnapshot.self, from: data) return snapshot } catch { diff --git a/LoopFollow/LiveActivity/LAAppGroupSettings.swift b/LoopFollow/LiveActivity/LAAppGroupSettings.swift index e0e3da22d..715323524 100644 --- a/LoopFollow/LiveActivity/LAAppGroupSettings.swift +++ b/LoopFollow/LiveActivity/LAAppGroupSettings.swift @@ -149,7 +149,6 @@ enum LAAppGroupSettings { static let smallWidgetSlot = "la.smallWidgetSlot" static let displayName = "la.displayName" static let showDisplayName = "la.showDisplayName" - static let watchSlots = "watch.slots" static let watchSelectedSlots = "watch.selectedSlots" } @@ -208,23 +207,6 @@ enum LAAppGroupSettings { return LiveActivitySlotOption(rawValue: raw) ?? LiveActivitySlotDefaults.smallWidgetSlot } - // MARK: - Watch slots (Write) - - /// Persists the 4-position Watch data card slot configuration. - static func setWatchSlots(_ slots: [LiveActivitySlotOption]) { - let raw = slots.prefix(4).map(\.rawValue) - defaults?.set(raw, forKey: Keys.watchSlots) - } - - // MARK: - Watch slots (Read) - - static func watchSlots() -> [LiveActivitySlotOption] { - guard let raw = defaults?.stringArray(forKey: Keys.watchSlots), raw.count == 4 else { - return [.iob, .cob, .projectedBG, .battery] - } - return raw.map { LiveActivitySlotOption(rawValue: $0) ?? .none } - } - // MARK: - Watch selected slots (ordered, variable-length) /// Persists the user's ordered list of selected Watch data slots. diff --git a/WatchConnectivityManager.swift b/LoopFollow/LiveActivity/WatchConnectivityManager.swift similarity index 85% rename from WatchConnectivityManager.swift rename to LoopFollow/LiveActivity/WatchConnectivityManager.swift index a2e8f6be0..a3e61c16d 100644 --- a/WatchConnectivityManager.swift +++ b/LoopFollow/LiveActivity/WatchConnectivityManager.swift @@ -1,10 +1,6 @@ // LoopFollow // WatchConnectivityManager.swift -// WatchConnectivityManager.swift -// Philippe Achkar -// 2026-03-10 - import Foundation import WatchConnectivity @@ -15,8 +11,21 @@ final class WatchConnectivityManager: NSObject { // MARK: - Init - /// Timestamp of the last snapshot the Watch ACK'd via sendAck(). - private var lastWatchAckTimestamp: TimeInterval = 0 + /// Serial queue protecting mutable state (the last-ack timestamp) from + /// concurrent access: `send(snapshot:)` may be called from any thread, and + /// WCSession delegate callbacks arrive on an arbitrary background queue. + private let stateQueue = DispatchQueue(label: "com.loopfollow.WatchConnectivityManager.state") + + /// Backing storage for `lastWatchAckTimestamp`. Always access via the + /// thread-safe accessor below. + private var _lastWatchAckTimestamp: TimeInterval = 0 + + /// Timestamp of the last snapshot the Watch ACK'd. Read/write is serialized + /// through `stateQueue`. + private var lastWatchAckTimestamp: TimeInterval { + get { stateQueue.sync { _lastWatchAckTimestamp } } + set { stateQueue.sync { _lastWatchAckTimestamp = newValue } } + } override private init() { super.init() @@ -56,8 +65,9 @@ final class WatchConnectivityManager: NSObject { } do { + // GlucoseSnapshot has a custom encoder that writes updatedAt as a + // Double (timeIntervalSince1970), so no date strategy needs to be set. let encoder = JSONEncoder() - encoder.dateEncodingStrategy = .iso8601 let data = try encoder.encode(snapshot) let payload: [String: Any] = ["snapshot": data] diff --git a/LoopFollow/WatchComplication/ComplicationEntryBuilder.swift b/LoopFollow/WatchComplication/ComplicationEntryBuilder.swift index 754aaa38d..fca7a84cc 100644 --- a/LoopFollow/WatchComplication/ComplicationEntryBuilder.swift +++ b/LoopFollow/WatchComplication/ComplicationEntryBuilder.swift @@ -10,13 +10,15 @@ enum ComplicationID { static let gaugeCorner = "LoopFollowGaugeCorner" /// graphicCorner stacked text only (Complication 2). static let stackCorner = "LoopFollowStackCorner" - // DEBUG COMPLICATION — enabled for pipeline diagnostics. - // Shows two timestamps to isolate pipeline failures: - // outer (top): HH:mm of snapshot.updatedAt — when CGM data last reached the Watch - // inner (↺): HH:mm when ClockKit last called getCurrentTimelineEntry - // If outer changes but inner is stale → reloadTimeline() not firing or ClockKit ignoring it. - // If inner changes but outer is stale → data delivery broken, complication rebuilding with old data. - static let debugCorner = "LoopFollowDebugCorner" + #if DEBUG + // DEBUG COMPLICATION — pipeline diagnostics only, never shipped in release builds. + // Shows two timestamps to isolate pipeline failures: + // outer (top): HH:mm of snapshot.updatedAt — when CGM data last reached the Watch + // inner (↺): HH:mm when ClockKit last called getCurrentTimelineEntry + // If outer changes but inner is stale → reloadTimeline() not firing or ClockKit ignoring it. + // If inner changes but outer is stale → data delivery broken, complication rebuilding with old data. + static let debugCorner = "LoopFollowDebugCorner" + #endif } // MARK: - Entry builder @@ -35,7 +37,9 @@ enum ComplicationEntryBuilder { case .graphicCorner: switch identifier { case ComplicationID.stackCorner: return graphicCornerStackTemplate(snapshot: snapshot) - case ComplicationID.debugCorner: return graphicCornerDebugTemplate(snapshot: snapshot) + #if DEBUG + case ComplicationID.debugCorner: return graphicCornerDebugTemplate(snapshot: snapshot) + #endif default: return graphicCornerGaugeTemplate(snapshot: snapshot) } default: @@ -59,11 +63,13 @@ enum ComplicationEntryBuilder { innerTextProvider: CLKSimpleTextProvider(text: ""), outerTextProvider: CLKSimpleTextProvider(text: "--") ) - case ComplicationID.debugCorner: - return CLKComplicationTemplateGraphicCornerStackText( - innerTextProvider: CLKSimpleTextProvider(text: "STALE"), - outerTextProvider: CLKSimpleTextProvider(text: "--:--") - ) + #if DEBUG + case ComplicationID.debugCorner: + return CLKComplicationTemplateGraphicCornerStackText( + innerTextProvider: CLKSimpleTextProvider(text: "STALE"), + outerTextProvider: CLKSimpleTextProvider(text: "--:--") + ) + #endif default: return staleGaugeTemplate() } @@ -90,11 +96,13 @@ enum ComplicationEntryBuilder { innerTextProvider: CLKSimpleTextProvider(text: "→ --"), outerTextProvider: outer ) - case ComplicationID.debugCorner: - return CLKComplicationTemplateGraphicCornerStackText( - innerTextProvider: CLKSimpleTextProvider(text: "DEBUG"), - outerTextProvider: CLKSimpleTextProvider(text: "--:--") - ) + #if DEBUG + case ComplicationID.debugCorner: + return CLKComplicationTemplateGraphicCornerStackText( + innerTextProvider: CLKSimpleTextProvider(text: "DEBUG"), + outerTextProvider: CLKSimpleTextProvider(text: "--:--") + ) + #endif default: let outer = CLKSimpleTextProvider(text: "---") outer.tintColor = .green @@ -184,7 +192,7 @@ enum ComplicationEntryBuilder { bgText.tintColor = thresholdColor(for: snapshot) let bottomLabel: String - if let _ = snapshot.projected { + if snapshot.projected != nil { // ⇢ = dashed arrow (U+21E2); swap for ▸ (U+25B8) if it renders poorly on-device bottomLabel = "\(WatchFormat.delta(snapshot)) | ⇢\(WatchFormat.projected(snapshot))" } else { @@ -197,7 +205,7 @@ enum ComplicationEntryBuilder { ) } - // MARK: - Graphic Corner — Debug (Complication 3) + // MARK: - Graphic Corner — Debug (Complication 3, DEBUG builds only) // Outer (top): HH:mm of the snapshot's updatedAt — when the CGM reading arrived. // Inner (bottom): "↺ HH:mm" — when ClockKit last called getCurrentTimelineEntry. @@ -207,15 +215,17 @@ enum ComplicationEntryBuilder { // inner changes → ClockKit is refreshing the complication face // inner stale → reloadTimeline is not being called or ClockKit is ignoring it - private static func graphicCornerDebugTemplate(snapshot: GlucoseSnapshot) -> CLKComplicationTemplate { - let dataTime = WatchFormat.updateTime(snapshot) - let buildTime = WatchFormat.currentTime() + #if DEBUG + private static func graphicCornerDebugTemplate(snapshot: GlucoseSnapshot) -> CLKComplicationTemplate { + let dataTime = WatchFormat.updateTime(snapshot) + let buildTime = WatchFormat.currentTime() - return CLKComplicationTemplateGraphicCornerStackText( - innerTextProvider: CLKSimpleTextProvider(text: "↺ \(buildTime)"), - outerTextProvider: CLKSimpleTextProvider(text: dataTime) - ) - } + return CLKComplicationTemplateGraphicCornerStackText( + innerTextProvider: CLKSimpleTextProvider(text: "↺ \(buildTime)"), + outerTextProvider: CLKSimpleTextProvider(text: dataTime) + ) + } + #endif // MARK: - Threshold color diff --git a/LoopFollow/WatchComplication/WatchComplicationProvider.swift b/LoopFollow/WatchComplication/WatchComplicationProvider.swift index f7df784b6..755fc3dae 100644 --- a/LoopFollow/WatchComplication/WatchComplicationProvider.swift +++ b/LoopFollow/WatchComplication/WatchComplicationProvider.swift @@ -14,7 +14,7 @@ final class WatchComplicationProvider: NSObject, CLKComplicationDataSource { // MARK: - Complication Descriptors func getComplicationDescriptors(handler: @escaping ([CLKComplicationDescriptor]) -> Void) { - let descriptors = [ + var descriptors: [CLKComplicationDescriptor] = [ // Complication 1: BG + gauge arc (graphicCircular + graphicCorner) CLKComplicationDescriptor( identifier: ComplicationID.gaugeCorner, @@ -27,13 +27,17 @@ final class WatchComplicationProvider: NSObject, CLKComplicationDataSource { displayName: "LoopFollow Text", supportedFamilies: [.graphicCorner] ), - // DEBUG COMPLICATION — enabled for pipeline diagnostics. - CLKComplicationDescriptor( - identifier: ComplicationID.debugCorner, - displayName: "LoopFollow Debug", - supportedFamilies: [.graphicCorner] - ), ] + #if DEBUG + // DEBUG COMPLICATION — pipeline diagnostics only, not shipped in release builds. + descriptors.append( + CLKComplicationDescriptor( + identifier: ComplicationID.debugCorner, + displayName: "LoopFollow Debug", + supportedFamilies: [.graphicCorner] + ) + ) + #endif handler(descriptors) } diff --git a/LoopFollow/WatchComplication/WatchSessionReceiver.swift b/LoopFollow/WatchComplication/WatchSessionReceiver.swift index c332c1ecc..5f698119b 100644 --- a/LoopFollow/WatchComplication/WatchSessionReceiver.swift +++ b/LoopFollow/WatchComplication/WatchSessionReceiver.swift @@ -32,11 +32,15 @@ final class WatchSessionReceiver: NSObject { /// Populated when ClockKit calls getCurrentTimelineEntry (complication is on an active face) /// or when activeComplications is non-nil. Used as a fallback when activeComplications /// returns nil/empty during background execution — a known watchOS 9+ limitation. + /// + /// Access must be serialized on the main thread. ClockKit callbacks are main-thread, + /// and reloadComplicationsOnMainThread() is only called from main. private var cachedComplications: [String: CLKComplication] = [:] /// Called by WatchComplicationProvider whenever ClockKit passes a CLKComplication to us. /// Must be called on the main thread (ClockKit callbacks are main-thread). func cacheComplication(_ complication: CLKComplication) { + dispatchPrecondition(condition: .onQueue(.main)) let key = "\(complication.identifier)-\(complication.family.rawValue)" cachedComplications[key] = complication } @@ -88,8 +92,9 @@ extension WatchSessionReceiver: WCSessionDelegate { private func bootstrapFromApplicationContext(_ session: WCSession) { guard let data = session.receivedApplicationContext["snapshot"] as? Data else { return } do { + // GlucoseSnapshot has a custom decoder that reads `updatedAt` as a + // Double, so no JSONDecoder date strategy is required. let decoder = JSONDecoder() - decoder.dateDecodingStrategy = .iso8601 let snapshot = try decoder.decode(GlucoseSnapshot.self, from: data) GlucoseSnapshotStore.shared.save(snapshot) { [weak self] in os_log("WatchSessionReceiver: bootstrapped snapshot from applicationContext", log: watchLog, type: .debug) @@ -135,8 +140,9 @@ extension WatchSessionReceiver: WCSessionDelegate { return } do { + // GlucoseSnapshot has a custom decoder that reads `updatedAt` as a + // Double, so no JSONDecoder date strategy is required. let decoder = JSONDecoder() - decoder.dateDecodingStrategy = .iso8601 let snapshot = try decoder.decode(GlucoseSnapshot.self, from: data) // Cache in memory immediately — complication provider can use this as a // fallback if the App Group file store hasn't flushed yet. diff --git a/LoopFollowWatch Watch App/ContentView.swift b/LoopFollowWatch Watch App/ContentView.swift index 61b24c634..b9c9066d6 100644 --- a/LoopFollowWatch Watch App/ContentView.swift +++ b/LoopFollowWatch Watch App/ContentView.swift @@ -204,6 +204,8 @@ struct SlotSelectionView: View { var body: some View { List { + // `.delta` and `.projectedBG` are always shown on the glucose page, + // so they're excluded from the grid slot picker to avoid duplication. ForEach(LiveActivitySlotOption.allCases.filter { $0 != .none && $0 != .delta && $0 != .projectedBG }, id: \.self) { option in Button(action: { model.toggleSlot(option) }) { HStack { diff --git a/LoopFollowWatch Watch App/LoopFollowWatchApp.swift b/LoopFollowWatch Watch App/LoopFollowWatchApp.swift index 0b2928338..aec3e0549 100644 --- a/LoopFollowWatch Watch App/LoopFollowWatchApp.swift +++ b/LoopFollowWatch Watch App/LoopFollowWatchApp.swift @@ -81,8 +81,9 @@ final class WatchAppDelegate: NSObject, WKApplicationDelegate { } do { + // GlucoseSnapshot has a custom decoder that reads `updatedAt` as a + // Double, so no JSONDecoder date strategy is required. let decoder = JSONDecoder() - decoder.dateDecodingStrategy = .iso8601 return try decoder.decode(GlucoseSnapshot.self, from: data) } catch { logger.error("WatchAppDelegate: failed to decode applicationContext snapshot — \(error.localizedDescription, privacy: .public)")