Apple Watch app: complications, data grid, background delivery#580
Open
MtlPhil wants to merge 49 commits intoloopandlearn:apple-watchfrom
Open
Apple Watch app: complications, data grid, background delivery#580MtlPhil wants to merge 49 commits intoloopandlearn:apple-watchfrom
MtlPhil wants to merge 49 commits intoloopandlearn:apple-watchfrom
Conversation
- Make SmallFamilyView right slot configurable via Live Activity settings - Add Unit.displayName to GlucoseSnapshot for consistent unit labelling - Use ViewThatFits for adaptive CarPlay vs Watch Smart Stack layout - Fix APNs push token lost after renewal-overlay foreground restart - Fix Not Looping overlay not showing when app is backgrounded - Rename Live Activity settings section headers
…#576) startIfNeeded() unconditionally reused any existing activity, which meant that on cold start (app killed while stale overlay was showing) willEnterForeground is never sent, handleForeground never runs, and viewDidAppear → startFromCurrentState → startIfNeeded just rebinds to the stale activity — leaving the overlay visible. Fix: before reusing an existing activity in startIfNeeded(), check whether its staleDate has passed or the renewal window is open. If so, end it (awaited) and call startIfNeeded() again so a fresh activity with a new 7.5h deadline is started. Also add cancelRenewalFailedNotification() to handleForeground() so the "Live Activity Expiring" system notification is dismissed whenever the foreground restart path fires, not only via forceRestart(). Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
…andlearn#577) * Fix LA not refreshing on foreground after stale overlay startIfNeeded() unconditionally reused any existing activity, which meant that on cold start (app killed while stale overlay was showing) willEnterForeground is never sent, handleForeground never runs, and viewDidAppear → startFromCurrentState → startIfNeeded just rebinds to the stale activity — leaving the overlay visible. Fix: before reusing an existing activity in startIfNeeded(), check whether its staleDate has passed or the renewal window is open. If so, end it (awaited) and call startIfNeeded() again so a fresh activity with a new 7.5h deadline is started. Also add cancelRenewalFailedNotification() to handleForeground() so the "Live Activity Expiring" system notification is dismissed whenever the foreground restart path fires, not only via forceRestart(). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix stale LA dismissed by iOS incorrectly blocking auto-restart When iOS dismisses a Live Activity because its staleDate passed (background stale overlay case), laRenewalFailed is false, so the state observer's else branch fired and set dismissedByUser=true — permanently blocking all auto-restart paths (startFromCurrentState has guard !dismissedByUser). Fix 1: attachStateObserver now checks staleDatePassed alongside laRenewalFailed; both are iOS-initiated dismissals that should allow auto-restart. Fix 2: handleForeground() Task resets dismissedByUser=false before calling startFromCurrentState(), guarding against the race where the state observer fires .dismissed during our own end() call before its Task cancellation takes effect. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
- Two corner complications: gauge arc (BG + delta + staleness arc) and stacked text (BG + delta/minAgo); graphicCircular updated - WatchFormat: formatting helpers mirroring LAFormat; slotValue dispatch - WatchComplicationProvider: two descriptors with identifier threading - WatchSessionReceiver: posts WatchSnapshotReceived notification - LAAppGroupSettings: watchSlots read/write (default iob/cob/proj/battery) - ContentView: TabView with GlucoseView, DataCardView (2×2 grid), SettingsView - project.pbxproj: remove stale GlucoseUnitConversion ref; add WatchFormat + GlucoseConversion to Watch target sources Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replaced hardcoded team-specific bundle identifiers with the same $(unique_id)/$(app_suffix) pattern used by all other targets, so CI can provision the Watch App with its own team credentials. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Register com.$(TEAMID).LoopFollow.watchkitapp in the identifiers lane, include it in match for certs, set its signing profile in build, and add it to gym export_options so CI can provision and sign the Watch target. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Replace unavailable .system* UIColor variants with plain .green/.red/.orange/.yellow - Add missing `import Combine` to ContentView for ObservableObject/@published Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…+startFromCurrentState forceRestart() was removed in upstream d86f1ff; replace both call sites with the equivalent end(dismissalPolicy:) + startFromCurrentState() sequence. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
forceRestart() was called in LiveActivitySettingsView and RestartLiveActivityIntent but was never defined — add it as a convenience wrapper around end+startFromCurrentState. Revert LiveActivitySettingsView to use the named method. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…vity Rebase conflict left a 43-line truncated version missing all extended metrics (override, battery, pump, basal, autosens, tdd, target, ISF, CR, carbs, ages, min/max BG, isNotLooping, showRenewalOverlay). Restore full 138-line version, keeping the intentional lastBgMgdl change from the Watch commit. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…entitlement AppGroupID.stripLikelyExtensionSuffixes did not handle .watchkitapp, causing the Watch app to resolve group.com.TEAMID.LoopFollow.watchkitapp (no entitlement for that group), so GlucoseSnapshotStore silently failed to save/load — no data shown. Also replace hardcoded team ID in Watch entitlement with $(unique_id)/$(app_suffix) to match the LA extension pattern. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…dition - ContentView: Tab 1 = BG, middle tabs = swipable 2×2 grid pages (4 slots each, one page per group of 4 selected), last tab = checklist of all slot variables - WatchViewModel: selectedSlots replaces fixed 4-slot array; pages computed property chunks them into groups of 4; toggleSlot adds/removes from ordered list - SlotSelectionView: checkmark list of all LiveActivitySlotOption cases - LAAppGroupSettings: add watchSelectedSlots/setWatchSelectedSlots (variable-length) - WatchSessionReceiver: pass snapshot in notification userInfo to fix race condition where notification fired before async file save completed, causing load() → nil Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
GlucoseView: BG + trend arrow single large line (56pt, scaled to 7 chars), below: Delta/Projected/Last update in white 14pt. Remove Open iPhone button. SlotSelectionView: exclude .delta and .projectedBG (always shown on tab 1). Launch data fix: WatchConnectivityManager now also calls updateApplicationContext on every send. WatchSessionReceiver bootstraps from receivedApplicationContext on activation so the Watch app has data immediately on launch. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
isNotLooping gates on loop status, not BG freshness — hiding the BG value when loop is inactive is wrong. Show BG whenever age < 900; show a yellow warning line if isNotLooping is true. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
isWatchAppInstalled returns false for TestFlight-installed Watch apps even when the Watch app is running. transferUserInfo queues silently if no app receives it, so removing the guard is safe. Pairing and session activation checks remain. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…pletion - WatchConnectivityManager: encode dates as iso8601 (matches GlucoseSnapshotStore) - WatchSessionReceiver.didReceiveUserInfo: decode with iso8601 to match encoder; post notification on main thread (avoids @published background-thread drop) - GlucoseSnapshotStore.save: add optional completion callback - WatchSessionReceiver: reload complications and post notification only after save completes — complication was reading stale file before write finished - bootstrapFromApplicationContext: same save-then-reload ordering Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Without this, the Watch app process was suspended in the background and transferUserInfo deliveries queued up — data only updated on foreground. WatchAppDelegate.handle(_:) wakes the app every 5 minutes (synced with the iPhone's BG poll cycle), reads the latest snapshot from GlucoseSnapshotStore, reloads complications, and reschedules the next wake. First background refresh is scheduled on WCSession activation. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
When the iPhone pushed via transferUserInfo, watchOS woke the Watch app and created a WKWatchConnectivityRefreshBackgroundTask. The default case in handle(_:) called setTaskCompletedWithSnapshot(false) immediately — terminating the process before WatchConnectivity could deliver the data to session(_:didReceiveUserInfo:). Fix: store the task in WatchSessionReceiver.pendingConnectivityTask and complete it only after GlucoseSnapshotStore.save() finishes writing the snapshot to disk. This keeps the app alive long enough for the full delivery pipeline (receive → decode → save → reload complications) to complete in the background. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…onDidFinishLaunching WKApplication.shared() was being called from App.init() via WatchSessionReceiver.activate() → WatchAppDelegate.scheduleNextRefresh(). Calling WKApplication.shared() during app struct initialization is too early in the watchOS lifecycle and causes an immediate crash on launch. Moved the initial scheduleNextRefresh() call to WatchAppDelegate.applicationDidFinishLaunching(), which fires after the WKApplication lifecycle is fully set up. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… thread reloadComplications() was being called from GlucoseSnapshotStore's background queue (save() completion). CLKComplicationServer.sharedInstance() and reloadTimeline(for:) must run on the main thread — on a background thread, activeComplications silently returns nil/empty and no reload occurs. This explains why complications only updated on foreground (when the main thread was active and the next CLKComplicationServer interaction happened to pick up the new snapshot). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…tyDidChange transferUserInfo is guaranteed but delivered on watchOS's schedule via WKWatchConnectivityRefreshBackgroundTask — subject to system budget and deferral. This caused hit-or-miss complication updates and no data on Watch app launch (store empty or stale until next background wake). Two improvements: - iPhone: sendMessage when isReachable (Watch app in foreground) for immediate delivery; transferUserInfo still sent for background guarantee - iPhone: sessionReachabilityDidChange pushes latest snapshot the moment the Watch app comes to foreground, fixing "no data on load" - Watch: didReceiveMessage handler for the sendMessage path; both handlers share a single process(payload:source:) method Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Debug complication (LoopFollow Debug): - graphicCorner, StackText template - Outer line: HH:mm of last received snapshot - Inner line: "NEW" (green) if updatedAt changed since last build, "SAME" (gray) otherwise — persisted in App Group UserDefaults so it survives between complication refreshes - Useful for diagnosing whether ClockKit is receiving and acting on reloadTimeline calls in the background stackCorner complication change: - Removed minAgo (time since last update) from bottom line - Replaced with projected BG prefixed with ⛳ (falls back to delta if no projection available) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… complication Background refresh fallback: WKApplicationRefreshBackgroundTask now reads WCSession.receivedApplicationContext directly (always holds last iPhone push, no active connection needed). If newer than the file store, persists it and reloads complications. This guarantees at most 5-min stale data even when WKWatchConnectivityRefreshBackgroundTask is deferred or skipped by watchOS budget management. ACK mechanism: After Watch processes each snapshot it sends an ACK (sendMessage if reachable, transferUserInfo otherwise) containing the snapshot's updatedAt timestamp. iPhone WatchConnectivityManager receives ACKs and logs a warning when the Watch is >10 min behind — making missed deliveries visible in logs. Debug complication redesign: - Outer line: HH:mm of snapshot data (when CGM reading arrived on Watch) - Inner line: ↺ HH:mm of when ClockKit last called getCurrentTimelineEntry Two clocks tell you exactly where the pipeline is broken: both stale → reloadTimeline not being called outer stale → Watch not receiving new data inner stale → ClockKit ignoring reloadTimeline calls stackCorner complication: bottom line now shows "Δ +3 🎯 120" (delta + projected). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
WCSession.default is used in decodeContextSnapshot() but the WatchConnectivity framework was not imported, causing a CI build error: "cannot find 'WCSession' in scope". Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The rebase conflict resolution had inadvertently dropped ~200 lines from LiveActivityManager.swift — including the declarations of skipNextDidBecomeActive, dismissedByUser, cancelRenewalFailedNotification, endOnTerminate, and the liveActivityDidForeground Notification.Name extension — all referenced by other files and causing 9 CI build errors. Restored the full upstream/live-activity version and re-applied the single Watch-specific addition: WatchConnectivityManager.shared.send() in performRefresh after saving the snapshot. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ine() Background tasks (WKWatchConnectivityRefreshBackgroundTask) were being completed with setTaskCompletedWithSnapshot() immediately after scheduling reloadTimeline() via DispatchQueue.main.async. Since the task completed before the main thread dispatch ran, watchOS could suspend the extension, killing the pending reloadTimeline() call — causing the complication to stay stale when the Watch was in background (e.g. 5:21–8:21 AM). Fix: capture the pending task, then complete it inside the main thread dispatch, after reloadTimeline() has been called. This guarantees watchOS keeps the extension alive long enough for ClockKit to receive the request. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Use GeometryReader in LockScreenFamilyAdaptiveView to distinguish Watch Smart Stack (height ≤ 75 pt) from CarPlay Dashboard (height > 75 pt) at render time — both surfaces share ActivityFamily.small with no API to tell them apart, so canvas height is the only runtime signal. Adds la.watchEnabled and la.carPlayEnabled App Group keys (default true). The Right Slot picker hides when both are disabled. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…black Two bugs with the height-based threshold: 1. System padding pushes Watch Smart Stack canvas above 75 pt, causing both Watch and CarPlay to be classified as CarPlay (blank on both when CarPlay toggle is off). 2. Color.clear is transparent — cached Watch renders show through it, leaving stale data visible after the toggle is turned off. Fix: switch to width-based detection. Watch Ultra 2 (widest model) is ~183 pt; CarPlay is always at least ~250 pt. A 210 pt threshold gives a ~14% buffer above the max Watch width. Replace Color.clear with Color.black so old frames are fully covered when the widget is disabled. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… race Root cause: handleForeground() clears laRenewalFailed=false synchronously before calling activity.end(). When the state observer fires .dismissed, renewalFailed is already false and staleDatePassed may also be false, so it falls into the user-swipe branch and sets dismissedByUser=true. Fix 4 (dismissedByUser=false in the Task) was meant to override this, but the state observer's MainActor write can be queued *after* the Task's reset write, winning the race and leaving dismissedByUser=true. The result: LA stops after tapping the overlay and never restarts. Add endingForRestart flag set synchronously (on the MainActor) before end() is called. The state observer checks it first — before renewalFailed or staleDatePassed — so any .dismissed delivery triggered by our own end() call is never misclassified as a user swipe, regardless of MainActor queue order. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…tState The LA must be ended and recreated for Watch/CarPlay content changes to take effect immediately. refreshFromCurrentState only sends a content update to the existing activity; forceRestart ends the activity and starts a fresh one, so the widget extension re-evaluates and the black/clear tile appears (or disappears) without APNs latency. Note: true per-surface dismissal (tile fully gone from Watch OR CarPlay while the other remains) requires splitting into two LAs and is a future architectural change. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Watch/CarPlay toggles removed: disfavoredLocations API requires iOS 26 and GeometryReader-based detection is unreliable. Reverts to always showing SmallFamilyView on .small activity family. Expanded Dynamic Island redesigned to match SmallFamilyView layout: - Leading: glucose + trend arrow (colour-keyed, firstTextBaseline), delta below - Trailing: configurable slot (same smallWidgetSlot setting as CarPlay/Watch) with label + value, replaces hardcoded IOB/COB - Bottom: unchanged — "Updated at" or "Not Looping" banner Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replace 🎯 (poor render) with ⇢ (U+21E2, dashed arrow) and add a pipe separator between delta and projected: "+3 | ⇢112" Swap ⇢ for ▸ (U+25B8) if it renders poorly on-device. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The debug corner complication (outer = CGM data time, inner = ClockKit build time) was used to isolate pipeline failures. Everything is working now, so it's commented out rather than deleted — uncomment the debugCorner ID, the three switch cases in ComplicationEntryBuilder, and the descriptor in WatchComplicationProvider to re-enable. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1. Cancel outstanding transferUserInfo transfers before queuing a new one — prevents stale snapshot accumulation when Watch is offline for hours. 2. Replace force-unwrap in handleRefresh with safe if-let + .distantPast fallback — eliminates fragile guard-ordering dependency. 3. Rename reloadComplicationsIfNeeded(for:) → triggerComplicationReload() — the old name implied deduplication logic that didn't exist; the snapshot parameter was unused. 4. Replace try? in decodeContextSnapshot() with do-catch + os_log — decode failures now surface in logs instead of silently returning nil. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Dismissing the LA was silently killing Watch data: refreshFromCurrentState guarded on !dismissedByUser before calling performRefresh, so the Watch received nothing after a dismiss. Fix: - Remove the LA guard from refreshFromCurrentState — Watch and store must update regardless of LA state. - In performRefresh, capture the dedup result (snapshotUnchanged) before saving to the store, then run store save + Watch send unconditionally. The laEnabled/dismissedByUser/dedup guards now gate only the LA update. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Three clearly separated .dismissed sources:
(a) endingForRestart — our own end() during planned restart, ignore
(b) iOS system force-dismiss — renewalFailed OR pastDeadline (now >= laRenewBy)
→ auto-restart on next foreground, laRenewBy preserved
(c) User decision — explicit swipe
→ dismissedByUser=true, laRenewBy=0 (renewal intent cancelled)
Remove staleDatePassed: staleDate expiry fires .ended not .dismissed.
Preserve laRenewBy on .ended and system .dismissed so handleForeground()
detects the renewal window and restarts on next foreground. Only the
user-swipe path clears laRenewBy, preventing handleForeground() from
re-entering the renewal path after the user explicitly killed the LA.
Fix handleForeground() nil-current path: reaching it means iOS ended the
LA while the renewal window was open (laRenewBy still set). A user-swipe
would have cleared laRenewBy to 0, so overlayIsShowing would be false
and this branch would never be reached — startFromCurrentState() is safe.
Set renewalWarning to 30 minutes (overlay appears 30 min before 7.5h deadline).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…on in WatchViewModel.init() WCSession activation (and the bootstrapFromApplicationContext notification) can complete before ContentView's onReceive is attached, causing the first render to show stale/no data. Moving the observer to WatchViewModel.init() ensures it is registered before the view tree is built. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Overview
This PR adds a fully functional Apple Watch app and two watchOS complications to LoopFollow, built on top of the
live-activitybranch. It shares the sameGlucoseSnapshotdata model and App Group container as the Live Activity so all surfaces stay in sync with zero duplication.What's included
watchOS complications (graphicCorner + graphicCircular)
Complication 1 — Gauge Corner
Complication 2 — Stack Corner
delta | ⇢projectedwhen a projected value is available, otherwise just delta--Both complications open the Watch app on tap.
Watch app (4 swipeable tabs)
Tab 1 — Glucose view
Tabs 2–N — Data grid pages
Last tab — Slot selection
Data flow
Reliability design decisions
sendMessage(instant, foreground only) +transferUserInfo(guaranteed, background). Both sent on every update so neither is a single point of failure.updateApplicationContext: always updated alongsidetransferUserInfo. The Watch reads it on session activation and in every background refresh task — ensures data is available even if background task budget is exhausted.sessionReachabilityDidChange: iPhone pushes immediately when Watch app opens, so the first screen is never stale.watchAck(timestamp) back to iPhone after processing each snapshot. iPhone logs a warning when Watch ACK is >600 s behind, making missed deliveries visible in logs.setTaskCompletedWithSnapshot(false)is called insideDispatchQueue.main.async, afterCLKComplicationServer.reloadTimeline()— prevents watchOS from suspending the extension before ClockKit receives the reload request.Key files
LoopFollowWatch Watch App/LoopFollowWatchApp.swiftLoopFollowWatch Watch App/ContentView.swiftLoopFollow/WatchComplication/ComplicationEntryBuilder.swiftLoopFollow/WatchComplication/WatchComplicationProvider.swiftLoopFollow/WatchComplication/WatchSessionReceiver.swiftLoopFollow/WatchComplication/WatchFormat.swiftLoopFollow/WatchConnectivity/WatchConnectivityManager.swiftLoopFollow/LiveActivity/GlucoseSnapshot.swiftLoopFollow/LiveActivity/LAAppGroupSettings.swift🤖 Generated with Claude Code