Skip to content

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

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

Apple Watch app: complications, data grid, background delivery#580
MtlPhil wants to merge 49 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

marionbarker and others added 30 commits March 24, 2026 06:36
- 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>
MtlPhil and others added 19 commits March 27, 2026 22:02
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>
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.

3 participants