Skip to content

Apple Watch app with complications, haptic alerts, and remote commands#641

Draft
MtlPhil wants to merge 26 commits intoloopandlearn:apple-watchfrom
achkars-org:feat/watch-remote
Draft

Apple Watch app with complications, haptic alerts, and remote commands#641
MtlPhil wants to merge 26 commits intoloopandlearn:apple-watchfrom
achkars-org:feat/watch-remote

Conversation

@MtlPhil
Copy link
Copy Markdown
Contributor

@MtlPhil MtlPhil commented May 6, 2026

Feat: Apple Watch app with complications, haptic alerts, and remote commands

Summary

Adds a full LoopFollowWatch companion app (watchOS target) to LoopFollow. The Watch app receives live glucose data from the phone, displays it alongside IOB/COB/TDD, shows complications on the watch face, sounds haptic alerts for alarms, and — for Loop users — issues remote commands (bolus, meal, override/preset) via the existing LoopAPNS relay.


Architecture overview

iPhone app                         Apple Watch
──────────────────────────────     ──────────────────────────────
LiveActivityManager                WatchConnectivityManager (Watch side)
  └─ performRefresh()                └─ session(_:didReceiveApplicationContext:)
       │                                  └─ ContentView (SwiftUI)
       ▼                                        ├─ glucose / trend / delta
WatchConnectivityManager (phone)                ├─ IOB / COB / TDD
  └─ send(snapshot:)                            ├─ WatchAlertManager
       │ updateApplicationContext                │    └─ haptic + snooze
       ▼                                         └─ WatchRemoteCommandsView
WatchSessionReceiver                                  └─ WatchCommandDispatcher
  └─ applicationContextUpdated                             └─ LoopAPNS relay
       └─ GlucoseSnapshotStore

Data flows one-way (phone → Watch) via WCSession.updateApplicationContext, which
coalesces rapid updates so the Watch always receives the latest snapshot without
queue backlog. Remote commands flow Watch → phone via WCSession.sendMessage, which
the phone's WatchSessionReceiver dispatches through the existing LoopAPNSService.


Features

Glucose display and complications

  • Real-time glucose value, trend arrow, and delta on the main Watch face.
  • IOB, COB, and TDD on a swipeable data page.
  • ClockKit complications for Modular, Graphic Circular, and Graphic Rectangular families, backed by WatchComplicationProvider and ComplicationEntryBuilder.
  • In-memory snapshot fallback: if the Watch has not yet received a context update (e.g. immediately after a reboot), GlucoseSnapshotStore serves the last known snapshot so complications are never blank.
  • Complication object cache (CLKComplication) fixes a crash where reloadTimeline(for:) was called with a stale complication reference after the system recycled it in the background.

Haptic alert system

  • WatchAlertManager mirrors the phone's alarm state to the Watch.
  • When an alarm fires, the Watch plays a haptic sequence and shows a dismissible alert overlay.
  • A Snooze button appears only while an alarm is active; it sends a snooze command back to the phone via WatchConnectivity.
  • Haptic intensity and alert types are configurable in WatchAlertSettingsView (accessible from the Watch settings gear).

Remote commands (Loop users)

Requires LoopAPNS credentials to be configured on the phone (Settings → Remote). APNS credentials are automatically synced to the Watch; an orange warning badge appears in the remote command view if credentials are missing or invalid.

Command Details
Bolus Recommended bolus pre-filled; 0.05 U step; confirmation required
Meal Carb entry with gram picker; sends a meal announcement
Override / preset Lists Loop and Trio presets; tap to activate

WatchCommandDispatcher signs and sends the APNs payload using the same LoopAPNSService path as the phone's Remote tab. The Watch displays a local success notification and plays a confirmation haptic when the phone acknowledges the command.

Loop command return notifications (e.g. bolus accepted/rejected) are forwarded from the phone's AppDelegate to the Watch via WatchConnectivityManager, triggering a haptic and a brief status label update.


New files

Path Purpose
LoopFollowWatch Watch App/ Entire Watch app target
LoopFollowWatch Watch App/ContentView.swift Main SwiftUI view (glucose, data page, gear)
LoopFollowWatch Watch App/LoopFollowWatchApp.swift App entry point; activates WCSession
LoopFollowWatch Watch App/WatchAlertManager.swift Haptic alert engine
LoopFollowWatch Watch App/WatchAlertSettingsView.swift Alert settings UI
LoopFollowWatch Watch App/WatchAppSettings.swift Persisted Watch preferences
LoopFollowWatch Watch App/SnoozeView.swift Snooze overlay
LoopFollowWatch Watch App/Remote/WatchBolusCommandView.swift Bolus command UI
LoopFollowWatch Watch App/Remote/WatchMealCommandView.swift Meal command UI
LoopFollowWatch Watch App/Remote/WatchOverridePickerView.swift Override/preset picker
LoopFollowWatch Watch App/Remote/WatchRemoteCommandsView.swift Remote commands root view
WatchConnectivityManager.swift Phone-side WCSession manager
LoopFollow/WatchComplication/ComplicationEntryBuilder.swift Builds CLKComplicationTemplate entries
LoopFollow/WatchComplication/WatchComplicationProvider.swift CLKComplicationDataSource
LoopFollow/WatchComplication/WatchFormat.swift Formats glucose/IOB/COB strings for Watch
LoopFollow/WatchComplication/WatchSessionReceiver.swift Phone-side WCSessionDelegate
LoopFollow/Remote/Watch/WatchCommandDispatcher.swift Signs and dispatches APNs commands from Watch
LoopFollow/LiveActivity/APNsCredentialValidator.swift Lightweight credential format validator

Modified files

File Change
LoopFollow.xcodeproj/project.pbxproj Add Watch app target; remove hardcoded dev team
LoopFollow/Application/AppDelegate.swift Activate WCSession; forward Loop return notifications to Watch
LoopFollow/LiveActivity/LiveActivityManager.swift Call WatchConnectivityManager.shared.send(snapshot:) on each refresh
LoopFollow/LiveActivity/LAAppGroupSettings.swift Expose thresholds via app group for Watch
LoopFollow/LiveActivity/GlucoseSnapshot.swift Add Watch-readable fields (loopLastRunAt)
LoopFollow/LiveActivity/GlucoseSnapshotStore.swift In-memory fallback for Watch
LoopFollow/LiveActivity/StorageCurrentGlucoseStateProvider.swift Use Storage.shared.lastBgMgdl for app-group-readable glucose
LoopFollow/Storage/Storage.swift Add lastBgMgdl storage key
LoopFollow/Controllers/Nightscout/ProfileManager.swift Include Trio presets in Watch preset sync
LoopFollow/Controllers/Nightscout/BGData.swift Write latest BG to Storage.shared.lastBgMgdl
fastlane/Fastfile Add Watch app build lane

Requirements

  • Phone: iOS 16.6+ (same as existing LoopFollow minimum).
  • Watch: watchOS 9.6+ (the app uses .automatic toolbar placement for 9.x compatibility and CLKComplication for complications rather than WidgetKit, which requires watchOS 10).
  • Remote commands: LoopAPNS credentials configured on the phone.

bjorkert and others added 26 commits April 29, 2026 10:03
The row in Remote Settings used a NavigationLink (which renders its own
chevron) and also added a manual chevron Image, producing a doubled
'>>'. Remove the manual chevron and the .plain button style so the
NavigationLink renders a single chevron consistent with other rows.
Nightscout can return duplicate treatment entries with the same "id"
(Trio/Loop UUID) but different "_id" (MongoDB ObjectId), causing
duplicate boluses, carbs, etc. on the graph. Filter duplicates at the
top of updateTreatments before classification.
* Refactor unit settings management and enhance metrics configuration

- Introduced UnitSettingsStore to centralize unit and metric settings management.
- Added new enums for glucose display units, time in range modes, glycemic metrics, and variability metrics.
- Updated StatsData to calculate and store coefficient of variation.
- Refactored Localizer to utilize UnitSettingsStore for unit conversions and formatting.
- Enhanced Nightscout and Dexcom settings views to support unit configuration and onboarding.
- Created UnitsConfigurationView for reusable unit and metric settings.
- Updated AggregatedStatsView and SimpleStatsViewModel to reflect new unit settings.
- Modified TIRView and its ViewModel to use UnitSettingsStore for thresholds and display.
- Removed legacy storage references for unit settings in favor of the new centralized approach.
- Added export functionality for new unit and metric settings in Nightscout settings.

* Add commit guidelines and best practices to README.md

* Fix navigation title for UnitsSettingsView to match consistency in SettingsMenuView

* Refactor absorption time handling in LoopAPNSCarbsView to use separate hour and minute states, enhancing clarity and usability

* Add polling of data after sucessful remote command

* Remove remote command polling (moved to separate PR loopandlearn#566)

* Replace deprecated NavigationLink(isActive:) with navigationDestination

* Remove carbs screen redesign (moved to separate PR)

* Remove README commit guidelines (moved to separate PR)

* Added custom range

* Remove list_prs.sh from repo

Accidentally committed utility script that belongs outside the project.

* Fix BGPicker not updating display when value changes or unit switches

Use @State for glucoseUnit, lowValue, and highValue so SwiftUI can
track changes and re-render. Add .id(glucoseUnit) on BGPickers to
force recreation when the unit changes, ensuring allValues and
formatting update correctly.

---------

Co-authored-by: Jonas Björkert <jonas@bjorkert.se>
…rn#596)

* Fix alarm sound session activation failures in background

Use .duckOthers instead of empty options when configuring the audio
session for alarm playback. The empty options created a non-mixable
session that conflicted with the background silent audio player
(which uses .mixWithOthers), causing setActive(true) to fail with
"Session activation failed" when the app was in the background.

* Gate alarm session option on app state and add notification fallback

Limit the .duckOthers option to the only state where legacy options: []
fails: background without Silent Tune holding a mixable session alive.
In foreground or with Silent Tune, restore options: [] so the alarm
continues to dominate other audio with no behavioral change for those
users.

In that same fail-prone state, plumb the alarm's soundFile through
AlarmManager.sendNotification so the system-delivered notification
carries the user's configured alarm sound as an audible fallback. In
other states the notification keeps .default to avoid an echo with the
in-app AVAudioPlayer loop.

* Ladder audio session options instead of a binary switch

Replace the static .duckOthers/[] choice with a fallback ladder that tries
options in order [] → .duckOthers → .mixWithOthers and stops at the first
that activates. In background without Silent Tune the [] candidate is
skipped, since iOS denies it there (cannotInterruptOthers, 560557684).
Each attempt is logged with the iOS error code so failures are visible
in the field.

Revert the notification soundFile path; notifications stay on .default
and the in-app AVAudioPlayer remains the only source of the alarm tone.
Also drops the redundant enableAudio() call from play() — the do-block
already activates the session.
…oubleshooting logs (loopandlearn#615)

* Fix Live Activity restart classification and foreground race

- handleExpiredToken, endOnTerminate, forceRestart: mark endingForRestart
  before ending so the state observer does not misclassify the resulting
  .dismissed as a user swipe (which would set dismissedByUser=true and
  block auto-restart on the next background refresh).
- Defer foreground restart from willEnterForeground to didBecomeActive
  so Activity.request() is not called before the scene is active
  (avoids the "visibility" failure).
- Remove duplicate orphan LiveActivitySettingsView.swift under Settings/
  (not referenced by the Xcode project).

* Add Live Activity troubleshooting logs

- startIfNeeded: log entry state (authorized, activities, current, flags)
  and enrich Activity.request failure with NSError domain/code + scene state.
- renewIfNeeded: enrich catch with NSError domain/code + authorization state.
- handleForeground / handleDidBecomeActive: include applicationState and
  the existing activities count at entry.
- observePushToken: log token fingerprint (last 8 chars) and prior value so
  token rotations are visible.
- update: log when the direct ActivityKit update is skipped (app backgrounded)
  and when APNs is skipped because no push token has been received yet.
- performRefresh: log the gate that blocks LA updates — especially
  dismissedByUser=true, which previously caused silent extended outages.
- handleExpiredToken: log current id, activities count, and flags before
  ending so APNs 410/404 events are correlatable to the restart path.
- bind: include activityState and the previous endingForRestart value so
  the dismissal-classification path is traceable.
Update fastlane to 2.233.1 and address dependabot security warnings by pinning json >=2.19.2 and addressable >=2.9.0.
Atlas pods advertise as "InPlay BLE" instead of "TWI BOARD" but use
the same OmniBLE service and characteristic UUIDs, so the existing
heartbeat transmitter works as-is once the scanner accepts the name.

Fixes loopandlearn#630
…opandlearn#631)

Previously migrationStep defaulted to 0, forcing every fresh install to
iterate through every migration. Set the default to the latest step (7)
and update other StorageValue defaults to match the post-migration final
state on a fresh install:

- snoozerPosition: .menu -> .position3
- nightscoutPosition: .position3 -> .position4
- remotePosition: .position4 -> .menu
- hasSeenFatProteinOrderChange: false -> true

Add reminder comments at the migrationStep declaration and at the
runMigrationsIfNeeded() block so future authors bump the default and
update affected defaults whenever a new step is added.
* Fix inclusive date counting in stats period calculations

## Problem

The stats model had a systematic off-by-one error in how it calculated
the number of days in an analysis period, and used `Date()` (now, today)
as the end boundary, including the current incomplete day in averages.

### Root causes

1. **Today was used as `endDate`** (`AggregatedStatsViewModel.updatePeriod`).
   Today is a partial day and should never be included in averages.

2. **Exclusive date diff used as day count** (`StatsDataService.updateDateRange`).
   `dateComponents([.day], from: start, to: end)` returns the number of
   whole days between two instants, which excludes the end day. A range
   of Apr 19–Apr 25 returned 6, not 7.

3. **Quick-select presets were off by one** (`DateRangePicker.setDateRange`).
   Pressing "7d" subtracted 7 days from the start of yesterday, producing
   an 8-day window (Apr 18–25) instead of a 7-day window (Apr 19–25).

4. **Day count label was exclusive** (`DateRangePicker.dayCount`).
   The "(N days)" header label used the same exclusive diff, showing one
   fewer day than the range actually covered.

5. **Bolus cutoff re-derived from `Date()`** (`SimpleStatsViewModel`).
   The cutoff for filtering bolus dates was recalculated as
   `Date() - requestedDays * 86400` instead of using `dataService.startDate`,
   making it inconsistent with the resolved date range after today was
   removed from the end boundary.

6. **Same re-derivation bug in `calculateActualDaysCovered`**.
   The helper also anchored its own cutoff to `Date()` rather than
   `dataService.startDate`.

7. **Carbs denominator used days-with-data, not period length**
   (`SimpleStatsViewModel`). `avgCarbs` divided total carbs by
   `dailyCarbs.count` (number of days that had at least one carb entry),
   which inflates the average whenever the user had carb-free days in the
   period. The band-aid `max(dailyCarbs.count, 1)` was a symptom of this.

## Fix

**`AggregatedStatsViewModel.updatePeriod()`**
- `endDate` = 23:59:59 of yesterday (last complete day), computed via
  `startOfDay(for: Date()) - 1 second` using the display calendar.
- `startDate` = midnight of `endDay - (days - 1)` so that a "7d" period
  covers exactly 7 calendar days inclusive (e.g. Apr 19–Apr 25).

**`StatsDataService.updateDateRange()`**
- `daysToAnalyze` = `daysBetween + 1`, where `daysBetween` is the
  exclusive `dateComponents` diff between the start-of-day of each
  boundary. Computing on day-start timestamps avoids DST-induced
  sub-day remainders from inflating the count.

**`DateRangePicker.setDateRange()`**
- Start offset changed from `-(days)` to `-(days - 1)` so quick-select
  presets (7d, 14d, 30d, 90d) produce inclusive ranges.

**`DateRangePicker.dayCount`**
- Day count = exclusive diff between start-of-day boundaries + 1,
  ensuring the header label matches the actual number of days covered.

**`SimpleStatsViewModel` — bolus cutoff**
- `cutoffTime` now reads `dataService.startDate.timeIntervalSince1970`
  directly. This is consistent with the resolved period and avoids
  re-deriving a different value from the current clock.

**`SimpleStatsViewModel` — carbs denominator**
- Denominator changed from `dailyCarbs.count` to `dataService.daysToAnalyze`
  so that carb-free days are included in the average (total carbs spread
  over the full period, not just days with entries).

**`SimpleStatsViewModel.calculateActualDaysCovered()`**
- Cutoff changed from `Date() - requestedDays * 86400` to
  `dataService.startDate.timeIntervalSince1970` for the same reason as
  the bolus fix above.

## Time zone behaviour

All day-boundary arithmetic uses `dateTimeUtils.displayCalendar()`, which
applies the user's configured graph time zone or the device's current time
zone. This means:

- DST transitions are handled correctly: `startOfDay(for:)` and
  `date(byAdding: .day)` use calendar days, not fixed 86400-second
  intervals, so 23-hour and 25-hour DST days do not shift boundaries.
- Travel (device time zone change) causes the analysis window to be
  recomputed relative to the new local midnight on the next load, which
  is the expected behaviour.
- Users with a fixed graph time zone are fully insulated from travel:
  all boundaries stay anchored to the configured zone.

* Fix initial stats view opening with exclusive 7-day offset

AggregatedStatsView.init() was hardcoding the initial @State dates with
value: -7 from endDayStart, producing an 8-day window (Apr 18–Apr 25)
instead of the intended 7-day inclusive window (Apr 19–Apr 25).

The previous commit fixed setDateRange() and updatePeriod() but missed
this init(), which bypasses both and seeds the @State directly.

* Extract N-day range rule into StatsDateRange

The "last complete N-day period" calculation was duplicated in
AggregatedStatsView, AggregatedStatsViewModel, and DateRangePicker.
Centralise it in a new StatsDateRange.lastComplete(days:) utility and
replace all three inline copies.

https://claude.ai/code/session_016oKb1eyTs8TfMq7drfmcg3

---------

Co-authored-by: Claude <noreply@anthropic.com>
Adds a LogRedactor helper and applies it across every known leaky log
site so users can share logs without leaking APNs tokens, p8 keys,
Nightscout URLs and tokens, Dexcom usernames, key/team IDs, or bundle
identifiers. LogManager.log also runs a safety-net sweep that catches
PEM PRIVATE KEY blocks, ?token= query values, and JWTs regardless of
the call site.
* Add iOS 17.2+ push-to-start for Live Activity renewal

iOS 17.2+ now uses APNs push-to-start for every Live Activity creation
path — initial start, renewal, and forced restart — so the LA can renew
silently in the background instead of requiring the user to foreground
the app at the 7.5 h ceiling. iOS 16.x retains the existing
Activity.request() flow with the renewal-failed notification; the
#available gates are at the entry points so the legacy helpers can be
removed in one commit when the deployment target reaches 17.2.

Push-to-start uses a silent payload (alert with empty title/body +
interruption-level: passive) so adoption is invisible on phone and
watch. The push-to-start token is observed at startup and persisted
between launches; activityUpdates adoption resets the renewal deadline.
The "tap to update" overlay is suppressed on iOS 17.2+ unless renewal
has actually failed, since the time-based pre-emptive warning would be
misleading when push-to-start is handling renewal automatically.

Settings:
- APN page: inline validity badges for Key ID and APNs key, with one-
  line error text when either is malformed.
- Live Activity page: section footer noting APNs is required, plus a
  warning row when credentials are missing or invalid.

* Remove dead iOS 16.2 availability checks in LiveActivityManager

Deployment target is 16.6, so #available(iOS 16.2, *) is always true at
runtime and the @available(iOS 16.2, *) on Activity.activityUpdates is
satisfied by the deployment target alone. The runtime branch and its
'(iOS 16.2+)' log strings just made the file harder to read alongside
the real iOS 17.2 push-to-start gating.

* Log iOS/macOS version in log file header

UIDevice.current.systemVersion reports the iOS-equivalent on Mac
Catalyst, so use ProcessInfo.operatingSystemVersion (and label it
macOS) when running as a Catalyst app.

* Improve push-to-start safety, backoff, and token-wait behaviour (loopandlearn#625)

- Keep old LA alive until APNs send confirms success, so a failed
  push-to-start (rate-limited, invalid token, network error) no longer
  leaves the user with no activity and nothing to replace it
- Reset laPushToStartBackoff to 0 in adoptPushToStartActivity so a
  near-term renewal is not silently blocked by the 5-minute post-send
  base interval once the new LA is confirmed by activityUpdates
- Add apns-collapse-id (bundle-id.la.start) so APNs coalesces redundant
  push-to-start sends that race (refresh tick + user restart)
- Set apns-expiration to 10 minutes instead of 0 so a brief
  connectivity gap does not permanently lose the start notification,
  while avoiding delivery of clinically stale glucose data
- Raise pushToStartForceRestartThreshold from 2 to 4 to reduce false
  positives on slow connections where activityUpdates delivery lags
- Add a single automatic 10-second retry when the push-to-start token
  is not yet available, before surfacing the "could not start" error

https://claude.ai/code/session_01GJZERMhqLmEy8p4cpVX53q

Co-authored-by: Claude <noreply@anthropic.com>

---------

Co-authored-by: Phil A <76601115+MtlPhil@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
…mands

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

https://claude.ai/code/session_01DGCES9o6CLpeAmfe7tMoR6
@MtlPhil MtlPhil force-pushed the feat/watch-remote branch from a2fa510 to bccd5b8 Compare May 6, 2026 22:13
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.

5 participants