Apple Watch app with complications, haptic alerts, and remote commands#641
Draft
MtlPhil wants to merge 26 commits intoloopandlearn:apple-watchfrom
Draft
Apple Watch app with complications, haptic alerts, and remote commands#641MtlPhil wants to merge 26 commits intoloopandlearn:apple-watchfrom
MtlPhil wants to merge 26 commits intoloopandlearn:apple-watchfrom
Conversation
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
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.
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
Data flows one-way (phone → Watch) via
WCSession.updateApplicationContext, whichcoalesces rapid updates so the Watch always receives the latest snapshot without
queue backlog. Remote commands flow Watch → phone via
WCSession.sendMessage, whichthe phone's
WatchSessionReceiverdispatches through the existingLoopAPNSService.Features
Glucose display and complications
WatchComplicationProviderandComplicationEntryBuilder.GlucoseSnapshotStoreserves the last known snapshot so complications are never blank.CLKComplication) fixes a crash wherereloadTimeline(for:)was called with a stale complication reference after the system recycled it in the background.Haptic alert system
WatchAlertManagermirrors the phone's alarm state to the Watch.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.
WatchCommandDispatchersigns and sends the APNs payload using the sameLoopAPNSServicepath 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
AppDelegateto the Watch viaWatchConnectivityManager, triggering a haptic and a brief status label update.New files
LoopFollowWatch Watch App/LoopFollowWatch Watch App/ContentView.swiftLoopFollowWatch Watch App/LoopFollowWatchApp.swiftLoopFollowWatch Watch App/WatchAlertManager.swiftLoopFollowWatch Watch App/WatchAlertSettingsView.swiftLoopFollowWatch Watch App/WatchAppSettings.swiftLoopFollowWatch Watch App/SnoozeView.swiftLoopFollowWatch Watch App/Remote/WatchBolusCommandView.swiftLoopFollowWatch Watch App/Remote/WatchMealCommandView.swiftLoopFollowWatch Watch App/Remote/WatchOverridePickerView.swiftLoopFollowWatch Watch App/Remote/WatchRemoteCommandsView.swiftWatchConnectivityManager.swiftLoopFollow/WatchComplication/ComplicationEntryBuilder.swiftLoopFollow/WatchComplication/WatchComplicationProvider.swiftLoopFollow/WatchComplication/WatchFormat.swiftLoopFollow/WatchComplication/WatchSessionReceiver.swiftLoopFollow/Remote/Watch/WatchCommandDispatcher.swiftLoopFollow/LiveActivity/APNsCredentialValidator.swiftModified files
LoopFollow.xcodeproj/project.pbxprojLoopFollow/Application/AppDelegate.swiftLoopFollow/LiveActivity/LiveActivityManager.swiftWatchConnectivityManager.shared.send(snapshot:)on each refreshLoopFollow/LiveActivity/LAAppGroupSettings.swiftLoopFollow/LiveActivity/GlucoseSnapshot.swiftloopLastRunAt)LoopFollow/LiveActivity/GlucoseSnapshotStore.swiftLoopFollow/LiveActivity/StorageCurrentGlucoseStateProvider.swiftStorage.shared.lastBgMgdlfor app-group-readable glucoseLoopFollow/Storage/Storage.swiftlastBgMgdlstorage keyLoopFollow/Controllers/Nightscout/ProfileManager.swiftLoopFollow/Controllers/Nightscout/BGData.swiftStorage.shared.lastBgMgdlfastlane/FastfileRequirements
.automatictoolbar placement for 9.x compatibility andCLKComplicationfor complications rather than WidgetKit, which requires watchOS 10).