Skip to content

Apple Watch app: complications, data grid, background delivery#580

Open
MtlPhil wants to merge 36 commits intoloopandlearn:apple-watchfrom
achkars-org:watch-app
Open

Apple Watch app: complications, data grid, background delivery#580
MtlPhil wants to merge 36 commits intoloopandlearn:apple-watchfrom
achkars-org:watch-app

Conversation

@MtlPhil
Copy link
Copy Markdown

@MtlPhil MtlPhil commented Mar 28, 2026

Overview

This PR adds a fully functional Apple Watch app and two watchOS complications to LoopFollow, built on top of the live-activity branch. It shares the same GlucoseSnapshot data 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

  • Large BG value coloured green / orange / red using the same thresholds as the Live Activity
  • Delta in the leading position
  • Arc gauge fills from empty (fresh reading) to full (15 min stale)
  • Stale or loop-inactive → BG replaced with ⚠ in yellow, gauge full

Complication 2 — Stack Corner

  • Large BG value (coloured) on top
  • Bottom line: delta | ⇢projected when a projected value is available, otherwise just delta
  • Stale → --

Both complications open the Watch app on tap.

Watch app (4 swipeable tabs)

Tab 1 — Glucose view

  • Large BG value coloured by threshold
  • Delta, projected BG, time since last update
  • Loop-inactive warning banner

Tabs 2–N — Data grid pages

  • 2×2 grid of metric cards, up to 4 slots per page
  • Pages are generated dynamically from the user's slot selection

Last tab — Slot selection

  • Checklist of all available data fields (IOB, COB, projected BG, battery, etc.)
  • Selection persists in the Watch-side App Group UserDefaults

Data flow

iPhone (MainViewController / BackgroundRefresh)
    │
    ├── GlucoseSnapshotStore.save()       → App Group JSON file (shared with Watch)
    ├── LiveActivityManager.update()      → Dynamic Island / Lock Screen
    └── WatchConnectivityManager.send()
            ├── sendMessage()             → immediate delivery when Watch app is foreground
            ├── transferUserInfo()        → guaranteed background delivery (queued)
            └── updateApplicationContext() → always holds latest value; readable without connection

Watch side
    ├── WatchSessionReceiver              → decodes payload, saves to store, reloads complications
    ├── WatchAppDelegate.handle()         → WKWatchConnectivityRefreshBackgroundTask (BT delivery)
    │                                        WKApplicationRefreshBackgroundTask (5-min fallback)
    └── WatchComplicationProvider         → CLKComplicationDataSource; delegates to ComplicationEntryBuilder

Reliability design decisions

  • Dual delivery path: sendMessage (instant, foreground only) + transferUserInfo (guaranteed, background). Both sent on every update so neither is a single point of failure.
  • updateApplicationContext: always updated alongside transferUserInfo. 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.
  • ACK mechanism: Watch sends 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.
  • Background task ordering: setTaskCompletedWithSnapshot(false) is called inside DispatchQueue.main.async, after CLKComplicationServer.reloadTimeline() — prevents watchOS from suspending the extension before ClockKit receives the reload request.
  • Stale threshold: 15 minutes (900 s) throughout, matching the Live Activity.

Key files

File Purpose
LoopFollowWatch Watch App/LoopFollowWatchApp.swift App entry point, background task handler
LoopFollowWatch Watch App/ContentView.swift All Watch UI (glucose view, data grid, slot picker)
LoopFollow/WatchComplication/ComplicationEntryBuilder.swift Builds all CLKComplicationTemplates
LoopFollow/WatchComplication/WatchComplicationProvider.swift CLKComplicationDataSource
LoopFollow/WatchComplication/WatchSessionReceiver.swift WCSessionDelegate on Watch side
LoopFollow/WatchComplication/WatchFormat.swift Display formatting helpers
LoopFollow/WatchConnectivity/WatchConnectivityManager.swift WCSession management on iPhone side
LoopFollow/LiveActivity/GlucoseSnapshot.swift Shared data model (unchanged)
LoopFollow/LiveActivity/LAAppGroupSettings.swift Watch slot persistence added alongside existing LA settings

🤖 Generated with Claude Code

bjorkert and others added 30 commits March 20, 2026 21:57
- 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>
Bump json 2.18.0 → 2.19.3 (CVE-2026-33210) and faraday 1.8.0 → 1.10.5
(CVE-2026-25765) to resolve Dependabot alerts loopandlearn#12 and loopandlearn#13.
…gem-updates

Fix Dependabot security alerts for json and faraday gems
…n#581)

* Add separate Watch and CarPlay toggles to Live Activity settings

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>

* Fix Watch/CarPlay toggle detection: use width, not height; use Color.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>

* Fix stale overlay tap: endingForRestart flag prevents dismissedByUser 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>

* Use forceRestart on Watch/CarPlay toggle instead of refreshFromCurrentState

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>

* Remove Watch/CarPlay toggles; redesign expanded Dynamic Island

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>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* Add Future Carbs Alert

Notify when a future-dated carb entry's scheduled time arrives, serving
as a reminder to start eating in pre-bolus scenarios. Tracks future carb
entries across alarm ticks using persistent storage, with configurable
max lookahead window (default 45 min) to filter out fat/protein entries
and minimum carb threshold (default 5g).

* Add FutureCarbsCondition unit tests

10 test cases covering tracking, firing, deletion, lookahead bounds,
min grams filter, past carbs, stale cleanup, multi-carb per-tick
behavior, and duplicate prevention. Also fix Tests target missing
FRAMEWORK_SEARCH_PATHS for CocoaPods dependencies and add missing
latestPumpBattery field in withBattery test helper.

* Fix future-dated treatments not being downloaded from Nightscout

Trio sets created_at to the scheduled future time, but the Nightscout
query had an upper bound of "now", excluding any future-dated entries.
Extend the query window by predictionToLoad minutes so treatments
within the graph lookahead are fetched. Also add addingMinutes
parameter to getDateTimeString for precise minute-level offsets.

* Default Future Carbs Alert to acknowledge instead of snooze

* Download treatments 6 hours into the future

Replace the prediction-based lookahead with a fixed 6-hour window and
rename currentTimeString to endTimeString for clarity.

* Fix max lookahead sliding window bug

Carbs originally outside the lookahead window could drift into it over
time and fire incorrectly. Now all future carbs are tracked from first
observation, and only fire if their original distance (carbDate minus
observedAt) was within the max lookahead. Stale cleanup also preserves
entries whose carb still exists to prevent re-observation with a fresh
timestamp.
* Add commit guidelines and best practices to README.md

* Add pull request guidelines to README

---------

Co-authored-by: codebymini <daniel@codebymini.se>
…n#585)

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>
* Add removal dates to migration step comments

Document when each migration step reached main and when it can safely
be removed (one year after its first release).

* Fix step 3 removal date and improve comment wording

Step 3 was first released in v4.5.0 (2026-02-01), not v5.0.0.
Also use consistent "Released in" wording across all steps.
…bolus

Show time and value when tapping carb, bolus and SMB dots
* Open statistics from home screen by tapping the stats area

Closes loopandlearn#563

Modal (home screen tap) shows Done button, menu uses navigation push
with native back chevron, and tab shows only Refresh.

---------

Co-authored-by: codebymini <daniel@codebymini.se>
…oopandlearn#584)

* Improve GRIView layout and axis label positioning in GRIRiskGridView

* Update label for hypoglycemia component in GRIView
github-actions bot and others added 4 commits March 30, 2026 02:59
# Conflicts:
#	LoopFollow.xcodeproj/project.pbxproj
#	LoopFollow/ViewControllers/MainViewController.swift
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>
Brings all Watch complications, Watch app, WatchConnectivity, and background
delivery work on top of the current live-activity branch tip.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants