diff --git a/.github/workflows/add_identifiers.yml b/.github/workflows/add_identifiers.yml index 2f61ec7ec..702e7a91d 100644 --- a/.github/workflows/add_identifiers.yml +++ b/.github/workflows/add_identifiers.yml @@ -16,7 +16,7 @@ jobs: steps: # Checks-out the repo - name: Checkout Repo - uses: actions/checkout@v4 + uses: actions/checkout@v5 # Patch Fastlane Match to not print tables - name: Patch Match Tables diff --git a/.github/workflows/auto_version_dev.yml b/.github/workflows/auto_version_dev.yml index 846242bd9..2317d261d 100644 --- a/.github/workflows/auto_version_dev.yml +++ b/.github/workflows/auto_version_dev.yml @@ -40,7 +40,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: token: ${{ secrets.LOOPFOLLOW_TOKEN_AUTOBUMP }} diff --git a/.github/workflows/build_LoopFollow.yml b/.github/workflows/build_LoopFollow.yml index 2e8c0be54..443f7bf0c 100644 --- a/.github/workflows/build_LoopFollow.yml +++ b/.github/workflows/build_LoopFollow.yml @@ -90,7 +90,7 @@ jobs: if: | steps.workflow-permission.outputs.has_permission == 'true' && (vars.SCHEDULED_BUILD != 'false' || vars.SCHEDULED_SYNC != 'false') - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: token: ${{ secrets.GH_PAT }} @@ -100,7 +100,7 @@ jobs: steps.workflow-permission.outputs.has_permission == 'true' && vars.SCHEDULED_SYNC != 'false' && github.repository_owner != 'loopandlearn' id: sync - uses: aormsby/Fork-Sync-With-Upstream-action@v3.4.1 + uses: aormsby/Fork-Sync-With-Upstream-action@v3.4.2 with: target_sync_branch: ${{ env.TARGET_BRANCH }} shallow_since: 6 months ago @@ -178,7 +178,7 @@ jobs: run: "sudo xcode-select --switch /Applications/Xcode_26.2.app/Contents/Developer" - name: Checkout Repo for building - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: token: ${{ secrets.GH_PAT }} submodules: recursive @@ -228,7 +228,7 @@ jobs: # Upload Build artifacts - name: Upload build log, IPA and Symbol artifacts if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v6 with: name: build-artifacts path: | diff --git a/.github/workflows/create_certs.yml b/.github/workflows/create_certs.yml index d0f1c4cb9..c284e397a 100644 --- a/.github/workflows/create_certs.yml +++ b/.github/workflows/create_certs.yml @@ -28,7 +28,7 @@ jobs: # Checks-out the repo - name: Checkout Repo - uses: actions/checkout@v4 + uses: actions/checkout@v5 # Patch Fastlane Match not print tables - name: Patch Match Tables @@ -99,7 +99,7 @@ jobs: run: echo "new_certificate_needed=${{ needs.create_certs.outputs.new_certificate_needed }}" - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Install dependencies run: bundle install diff --git a/.github/workflows/validate_secrets.yml b/.github/workflows/validate_secrets.yml index c0132f67a..415ba0019 100644 --- a/.github/workflows/validate_secrets.yml +++ b/.github/workflows/validate_secrets.yml @@ -116,7 +116,7 @@ jobs: TEAMID: ${{ secrets.TEAMID }} steps: - name: Checkout Repo - uses: actions/checkout@v4 + uses: actions/checkout@v5 # Sync the GitHub runner clock with the Windows time server (workaround as suggested in https://github.com/actions/runner/issues/2996) - name: Sync clock diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..1814899d5 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,144 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +LoopFollow is an iOS app for caregivers/parents of Type 1 Diabetics (T1D) to monitor CGM glucose data, loop status, and AID system metrics. This fork (`LoopFollowLA`) is built on top of the upstream [loopandlearn/LoopFollow](https://github.com/loopandlearn/LoopFollow) and adds: + +- **Live Activity** (Dynamic Island / Lock Screen) — **complete**, do not modify +- **Apple Watch complications + Watch app** — **active development focus** +- **APNS-based remote commands** — complete + +The Live Activity work is considered stable. If it evolves upstream, the branch is rebased. All current development effort is on the Watch app (`LoopFollowWatch Watch App` target) and its complications. + +## Build System + +This is a CocoaPods project. Always open `LoopFollow.xcworkspace` (not `.xcodeproj`) in Xcode. + +```bash +# Install/update pods after cloning or when Podfile changes +pod install + +# Build from command line (simulator) +xcodebuild -workspace LoopFollow.xcworkspace -scheme LoopFollow -destination 'platform=iOS Simulator,name=iPhone 16' build + +# Run tests +xcodebuild -workspace LoopFollow.xcworkspace -scheme LoopFollow -destination 'platform=iOS Simulator,name=iPhone 16' test + +# Run a single test class +xcodebuild -workspace LoopFollow.xcworkspace -scheme LoopFollow -destination 'platform=iOS Simulator,name=iPhone 16' test -only-testing:LoopFollowTests/AlarmConditions/BatteryConditionTests +``` + +Fastlane lanes (`build_LoopFollow`, `release`, `identifiers`, `certs`) are CI-only and require App Store Connect credentials. + +## Xcode Targets + +| Target | Purpose | +|---|---| +| `LoopFollow` | Main iOS app | +| `LoopFollowLAExtensionExtension` | Live Activity widget extension | +| `LoopFollowWatch Watch App` | watchOS complication app | + +Bundle IDs are derived from `DEVELOPMENT_TEAM`: `com.$(TEAMID).LoopFollow`, etc. `Config.xcconfig` sets the marketing version; never edit version numbers directly (CI auto-bumps on merge to `dev`). + +## Architecture + +### Data Flow + +1. **Data sources** → `MainViewController` pulls BG/treatment data from: + - **Nightscout** (`Controllers/Nightscout/`) via REST API + - **Dexcom Share** (`BackgroundRefresh/BT/`, uses `ShareClient` pod) + - **BLE heartbeat** (`BackgroundRefresh/BT/BLEManager.swift`) for background refresh +2. `MainViewController` stores parsed data in its own arrays (`bgData`, `bolusData`, etc.) and calls `update*Graph()` methods. +3. **Reactive state bridge**: After processing, values are pushed into `Observable.shared` (in-memory) and `Storage.shared` (UserDefaults-backed). These feed SwiftUI views and the Live Activity pipeline. + +### Key Singletons + +- **`Storage`** (`Storage/Storage.swift`) — All persisted user settings as `StorageValue` (UserDefaults) or `SecureStorageValue` (Keychain). The single source of truth for configuration. +- **`Observable`** (`Storage/Observable.swift`) — In-memory reactive state (`ObservableValue`) for transient display values (BG text, color, direction, current alarm, etc.). +- **`ProfileManager`** — Manages Nightscout basal profiles. +- **`AlarmManager`** — Evaluates alarm conditions and triggers sound/notification. + +### Live Activity & Watch Complication Pipeline + +`GlucoseSnapshot` (`LiveActivity/GlucoseSnapshot.swift`) is the **canonical, source-agnostic data model** shared by all Watch and Live Activity surfaces. It is unit-aware (mg/dL or mmol/L) and self-contained. Fields: `glucose`, `delta`, `trend`, `updatedAt`, `iob`, `cob`, `projected`, `unit`, `isNotLooping`. + +``` +MainViewController / BackgroundRefresh + │ + ▼ +GlucoseSnapshotBuilder.build(...) ← assembles from Observable/Storage + │ + ▼ +GlucoseSnapshotStore.shared.save() ← persists to App Group container (JSON, atomic) + │ + ├──► LiveActivityManager.update() ← Dynamic Island / Lock Screen [COMPLETE] + ├──► WatchConnectivityManager.send() ← transferUserInfo to Watch + │ └──► WatchSessionReceiver ← saves snapshot + reloads complications (Watch-side) + └──► WatchComplicationProvider ← CLKComplicationDataSource (watchOS) + └── ComplicationEntryBuilder ← builds CLKComplicationTemplate +``` + +Thresholds for colour classification (green / orange / red) are read via `LAAppGroupSettings.thresholdsMgdl()` from the shared App Group UserDefaults — the same thresholds used by the Live Activity. The stale threshold is **15 minutes** (900 s) throughout. + +### Watch Complications (active development) + +Two corner complications to build in `ComplicationEntryBuilder` (`LoopFollow/WatchComplication/ComplicationEntryBuilder.swift`): + +**Complication 1 — `graphicCorner`, Open Gauge Text** +- Centre: BG value, coloured green/orange/red via `LAAppGroupSettings` thresholds +- Bottom text: delta (e.g. `+3` or `-2`) +- Gauge: fills from 0 → 15 min based on `snapshot.age / 900` +- Stale (>15 min) or `isNotLooping == true`: replace BG with `⚠` (yellow warning symbol) + +**Complication 2 — `graphicCorner`, Stacked Text** +- Top line: BG value (coloured) +- Bottom line: delta + minutes since update (e.g. `+3 4m`) +- Stale (>15 min): display `--` + +Both complications open the Watch app on tap (default watchOS behaviour when linked to the Watch app). `WatchComplicationProvider` handles timeline lifecycle and delegates all template construction to `ComplicationEntryBuilder`. + +### Watch App (active development) + +Entry point: `LoopFollowWatch Watch App/LoopFollowWatchApp.swift` — activates `WatchSessionReceiver`. +Main view: `LoopFollowWatch Watch App/ContentView.swift` — currently a placeholder stub. + +**Screen 1 — Main glucose view** +- Large BG value, coloured green/orange/red +- Right column: delta, projected BG, time since last update +- Button to open the phone app (shown only when `WCSession.default.isReachable`) + +**Subsequent screens — scrollable data cards** +- Each screen shows up to 4 data points from `GlucoseSnapshot` +- User-configurable via Watch app settings; every field in `GlucoseSnapshot` is eligible (glucose, delta, projected, IOB, COB, trend, age); units displayed alongside each value +- Default: IOB, COB, projected BG, battery + +Watch app settings persist in the Watch-side App Group UserDefaults (same suite as `LAAppGroupSettings`). + +### Background Refresh + +Three modes (set in `Storage.backgroundRefreshType`): +- **Silent tune** — plays an inaudible audio track to keep app alive +- **BLE heartbeat** — paired BLE device (e.g. Dexcom G7) wakes the app +- **APNS** — server push via `APNSClient` / `APNSJWTGenerator` + +### Remote Commands + +Remote bolus/carb/temp-target commands flow through `BackgroundRefresh/Remote/` using TOTP-authenticated APNS pushes. Settings live in `Storage` (APNS key, team ID, bundle ID, shared secret). + +### Settings Architecture + +Settings are split between: +- **SwiftUI views** in `Settings/` (new) — `GeneralSettingsView`, `AlarmSettingsView`, `AdvancedSettingsView`, etc. +- **Legacy UIKit** `SettingsViewController` — being migrated to SwiftUI + +### Tests + +Tests use the Swift Testing framework (`import Testing`). Test files are in `Tests/AlarmConditions/`. + +## Branch & PR Conventions + +- **All PRs target `dev`**, never `main`. PRs to `main` will be redirected. +- Never modify version numbers — CI auto-bumps after merge. +- Branch from `dev` and name it `feature_name` or `fix_name`. diff --git a/Config.xcconfig b/Config.xcconfig index a22c71132..f114e5b03 100644 --- a/Config.xcconfig +++ b/Config.xcconfig @@ -6,4 +6,4 @@ unique_id = ${DEVELOPMENT_TEAM} //Version (DEFAULT) -LOOP_FOLLOW_MARKETING_VERSION = 5.0.0 +LOOP_FOLLOW_MARKETING_VERSION = 5.0.9 diff --git a/Gemfile.lock b/Gemfile.lock index bfeab1ee4..f80c23ed6 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -43,16 +43,17 @@ GEM dotenv (2.8.1) emoji_regex (3.2.3) excon (0.112.0) - faraday (1.8.0) + faraday (1.10.5) faraday-em_http (~> 1.0) faraday-em_synchrony (~> 1.0) faraday-excon (~> 1.1) - faraday-httpclient (~> 1.0.1) + faraday-httpclient (~> 1.0) + faraday-multipart (~> 1.0) faraday-net_http (~> 1.0) - faraday-net_http_persistent (~> 1.1) + faraday-net_http_persistent (~> 1.0) faraday-patron (~> 1.0) faraday-rack (~> 1.0) - multipart-post (>= 1.2, < 3) + faraday-retry (~> 1.0) ruby2_keywords (>= 0.0.4) faraday-cookie_jar (0.0.8) faraday (>= 0.8.0) @@ -61,10 +62,13 @@ GEM faraday-em_synchrony (1.0.1) faraday-excon (1.1.0) faraday-httpclient (1.0.1) + faraday-multipart (1.2.0) + multipart-post (~> 2.0) faraday-net_http (1.0.2) faraday-net_http_persistent (1.2.0) faraday-patron (1.0.0) faraday-rack (1.0.0) + faraday-retry (1.0.4) faraday_middleware (1.2.1) faraday (~> 1.0) fastimage (2.4.0) @@ -163,7 +167,7 @@ GEM httpclient (2.9.0) mutex_m jmespath (1.6.2) - json (2.18.0) + json (2.19.3) jwt (2.10.2) base64 logger (1.7.0) diff --git a/LoopFollow.xcodeproj/project.pbxproj b/LoopFollow.xcodeproj/project.pbxproj index 305f058b3..63ddc7dbf 100644 --- a/LoopFollow.xcodeproj/project.pbxproj +++ b/LoopFollow.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + 2D8068C66833EEAED7B4BEB8 /* FutureCarbsCondition.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2EBAB9EECE7095238A558060 /* FutureCarbsCondition.swift */; }; 374A77992F5BD8B200E96858 /* APNSClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A77982F5BD8AB00E96858 /* APNSClient.swift */; }; 374A77A52F5BE17000E96858 /* AppGroupID.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A779F2F5BE17000E96858 /* AppGroupID.swift */; }; 374A77A62F5BE17000E96858 /* GlucoseSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A77A12F5BE17000E96858 /* GlucoseSnapshot.swift */; }; @@ -23,6 +24,18 @@ 374A77B92F5BE1AC00E96858 /* PreferredGlucoseUnit.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A77B22F5BE1AC00E96858 /* PreferredGlucoseUnit.swift */; }; 379BECAA2F6588300069DC62 /* RestartLiveActivityIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 379BECA92F6588300069DC62 /* RestartLiveActivityIntent.swift */; }; 379BECB02F65DA4B0069DC62 /* LiveActivitySettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 379BECAF2F65DA4B0069DC62 /* LiveActivitySettingsView.swift */; }; + 37989DBF2F609B0E0004BD8B /* LoopFollowWatch Watch App.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = 37989DB52F609B0C0004BD8B /* LoopFollowWatch Watch App.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + 37989DC52F609C550004BD8B /* GlucoseSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A77A12F5BE17000E96858 /* GlucoseSnapshot.swift */; }; + 37989DC62F609C5E0004BD8B /* GlucoseSnapshotStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A77AF2F5BE1AC00E96858 /* GlucoseSnapshotStore.swift */; }; + 37989DC72F609C730004BD8B /* AppGroupID.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A779F2F5BE17000E96858 /* AppGroupID.swift */; }; + 37989DC82F609C7A0004BD8B /* LAAppGroupSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A77A32F5BE17000E96858 /* LAAppGroupSettings.swift */; }; + 37989DCE2F609ED40004BD8B /* WatchComplicationProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37989DCC2F609DAE0004BD8B /* WatchComplicationProvider.swift */; }; + 37989DD02F609F570004BD8B /* ComplicationEntryBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37989DCF2F609F570004BD8B /* ComplicationEntryBuilder.swift */; }; + 37989DDC2F60A2010004BD8B /* WatchFormat.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37989DDB2F60A2000004BD8B /* WatchFormat.swift */; }; + 37989DDD2F60A2020004BD8B /* GlucoseConversion.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD91E4DC2BDEC3F8002D9E97 /* GlucoseConversion.swift */; }; + 37989DD22F60A0ED0004BD8B /* WatchConnectivity.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 37989DD12F60A0ED0004BD8B /* WatchConnectivity.framework */; }; + 37989DD42F60A11E0004BD8B /* WatchConnectivityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37989DD32F60A11E0004BD8B /* WatchConnectivityManager.swift */; }; + 37989DD62F60A15E0004BD8B /* WatchSessionReceiver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37989DD52F60A15E0004BD8B /* WatchSessionReceiver.swift */; }; 37A4BDDB2F5B6B4A00EEB289 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 37A4BDDA2F5B6B4A00EEB289 /* WidgetKit.framework */; }; 37A4BDDD2F5B6B4A00EEB289 /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 37A4BDDC2F5B6B4A00EEB289 /* SwiftUI.framework */; }; 37A4BDE82F5B6B4C00EEB289 /* LoopFollowLAExtensionExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 37A4BDD92F5B6B4A00EEB289 /* LoopFollowLAExtensionExtension.appex */; platformFilter = ios; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; @@ -57,6 +70,7 @@ 6589CC752E9EAFB700BB18FE /* SettingsMigrationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6589CC742E9EAFB700BB18FE /* SettingsMigrationManager.swift */; }; 65E153C32E4BB69100693A4F /* URLTokenValidationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65E153C22E4BB69100693A4F /* URLTokenValidationView.swift */; }; 65E8A2862E44B0300065037B /* VolumeButtonHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65E8A2852E44B0300065037B /* VolumeButtonHandler.swift */; }; + ACE7F6DE0D065BEB52CDC0DB /* FutureCarbsAlarmEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = B7D2A4EFD18B7B7748B6669E /* FutureCarbsAlarmEditor.swift */; }; DD0247592DB2E89600FCADF6 /* AlarmCondition.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD0247582DB2E89600FCADF6 /* AlarmCondition.swift */; }; DD0247712DB4337700FCADF6 /* BuildExpireCondition.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD02475B2DB2E8FB00FCADF6 /* BuildExpireCondition.swift */; }; DD026E592EA2C8A200A39CB5 /* InsulinPrecisionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD026E582EA2C8A200A39CB5 /* InsulinPrecisionManager.swift */; }; @@ -270,6 +284,7 @@ DDFF3D852D14279B00BF9D9E /* BackgroundRefreshSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDFF3D842D14279B00BF9D9E /* BackgroundRefreshSettingsView.swift */; }; DDFF3D872D14280500BF9D9E /* BackgroundRefreshSettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDFF3D862D14280500BF9D9E /* BackgroundRefreshSettingsViewModel.swift */; }; DDFF3D892D1429AB00BF9D9E /* BackgroundRefreshType.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDFF3D882D1429AB00BF9D9E /* BackgroundRefreshType.swift */; }; + F19449721F3B792730A0F4FD /* PendingFutureCarb.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B9BEC26E4E48EF9B811A372 /* PendingFutureCarb.swift */; }; FC16A97A24996673003D6245 /* NightScout.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC16A97924996673003D6245 /* NightScout.swift */; }; FC16A97B249966A3003D6245 /* AlarmSound.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC7CE589248ABEA3001F83B8 /* AlarmSound.swift */; }; FC16A97D24996747003D6245 /* SpeakBG.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC16A97C24996747003D6245 /* SpeakBG.swift */; }; @@ -421,6 +436,13 @@ /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ + 37989DBD2F609B0E0004BD8B /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = FC97880C2485969B00A7906C /* Project object */; + proxyType = 1; + remoteGlobalIDString = 37989DB42F609B0C0004BD8B; + remoteInfo = "LoopFollowWatch Watch App"; + }; 37A4BDE62F5B6B4C00EEB289 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = FC97880C2485969B00A7906C /* Project object */; @@ -438,6 +460,17 @@ /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ + 37989DC32F609B0E0004BD8B /* Embed Watch Content */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = "$(CONTENTS_FOLDER_PATH)/Watch"; + dstSubfolderSpec = 16; + files = ( + 37989DBF2F609B0E0004BD8B /* LoopFollowWatch Watch App.app in Embed Watch Content */, + ); + name = "Embed Watch Content"; + runOnlyForDeploymentPostprocessing = 0; + }; 37A4BDED2F5B6B4C00EEB289 /* Embed Foundation Extensions */ = { isa = PBXCopyFilesBuildPhase; buildActionMask = 2147483647; @@ -453,6 +486,8 @@ /* Begin PBXFileReference section */ 059B0FA59AABFE72FE13DDDA /* Pods-LoopFollow.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-LoopFollow.release.xcconfig"; path = "Target Support Files/Pods-LoopFollow/Pods-LoopFollow.release.xcconfig"; sourceTree = ""; }; + 2B9BEC26E4E48EF9B811A372 /* PendingFutureCarb.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PendingFutureCarb.swift; sourceTree = ""; }; + 2EBAB9EECE7095238A558060 /* FutureCarbsCondition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FutureCarbsCondition.swift; sourceTree = ""; }; 374A77982F5BD8AB00E96858 /* APNSClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APNSClient.swift; sourceTree = ""; }; 374A779F2F5BE17000E96858 /* AppGroupID.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppGroupID.swift; sourceTree = ""; }; 374A77A02F5BE17000E96858 /* GlucoseLiveActivityAttributes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseLiveActivityAttributes.swift; sourceTree = ""; }; @@ -466,6 +501,13 @@ 374DAACA2F5B924B00BB663B /* LoopFollowLAExtensionExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = LoopFollowLAExtensionExtension.entitlements; sourceTree = ""; }; 379BECA92F6588300069DC62 /* RestartLiveActivityIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RestartLiveActivityIntent.swift; sourceTree = ""; }; 379BECAF2F65DA4B0069DC62 /* LiveActivitySettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActivitySettingsView.swift; sourceTree = ""; }; + 37989DB52F609B0C0004BD8B /* LoopFollowWatch Watch App.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "LoopFollowWatch Watch App.app"; sourceTree = BUILT_PRODUCTS_DIR; }; + 37989DCC2F609DAE0004BD8B /* WatchComplicationProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchComplicationProvider.swift; sourceTree = ""; }; + 37989DCF2F609F570004BD8B /* ComplicationEntryBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComplicationEntryBuilder.swift; sourceTree = ""; }; + 37989DDB2F60A2000004BD8B /* WatchFormat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchFormat.swift; sourceTree = ""; }; + 37989DD12F60A0ED0004BD8B /* WatchConnectivity.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WatchConnectivity.framework; path = Platforms/MacOSX.platform/Developer/SDKs/MacOSX26.2.sdk/System/iOSSupport/System/Library/Frameworks/WatchConnectivity.framework; sourceTree = DEVELOPER_DIR; }; + 37989DD32F60A11E0004BD8B /* WatchConnectivityManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchConnectivityManager.swift; sourceTree = ""; }; + 37989DD52F60A15E0004BD8B /* WatchSessionReceiver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchSessionReceiver.swift; sourceTree = ""; }; 37A4BDD92F5B6B4A00EEB289 /* LoopFollowLAExtensionExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = LoopFollowLAExtensionExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 37A4BDDA2F5B6B4A00EEB289 /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = /System/Library/Frameworks/WidgetKit.framework; sourceTree = ""; }; 37A4BDDC2F5B6B4A00EEB289 /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = /System/Library/Frameworks/SwiftUI.framework; sourceTree = ""; }; @@ -499,6 +541,7 @@ 65E153C22E4BB69100693A4F /* URLTokenValidationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLTokenValidationView.swift; sourceTree = ""; }; 65E8A2852E44B0300065037B /* VolumeButtonHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VolumeButtonHandler.swift; sourceTree = ""; }; A7D55B42A22051DAD69E89D0 /* Pods_LoopFollow.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_LoopFollow.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + B7D2A4EFD18B7B7748B6669E /* FutureCarbsAlarmEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FutureCarbsAlarmEditor.swift; sourceTree = ""; }; DD0247582DB2E89600FCADF6 /* AlarmCondition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlarmCondition.swift; sourceTree = ""; }; DD02475B2DB2E8FB00FCADF6 /* BuildExpireCondition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BuildExpireCondition.swift; sourceTree = ""; }; DD026E582EA2C8A200A39CB5 /* InsulinPrecisionManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InsulinPrecisionManager.swift; sourceTree = ""; }; @@ -871,6 +914,7 @@ /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ + 37989DB62F609B0C0004BD8B /* LoopFollowWatch Watch App */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = "LoopFollowWatch Watch App"; sourceTree = ""; }; 37A4BDDE2F5B6B4A00EEB289 /* LoopFollowLAExtension */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = LoopFollowLAExtension; sourceTree = ""; }; 65AC25F52ECFD5E800421360 /* Stats */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = Stats; sourceTree = ""; }; 65AC26702ED245DF00421360 /* Treatments */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = Treatments; sourceTree = ""; }; @@ -878,6 +922,13 @@ /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ + 37989DB22F609B0C0004BD8B /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 37A4BDD62F5B6B4A00EEB289 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -900,6 +951,8 @@ files = ( FCFEEC9E2486E68E00402A7F /* WebKit.framework in Frameworks */, 3F1335F351590E573D8E6962 /* Pods_LoopFollow.framework in Frameworks */, + DD48781C2C7DAF140048F05C /* SwiftJWT in Frameworks */, + 37989DD22F60A0ED0004BD8B /* WatchConnectivity.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -923,6 +976,17 @@ path = LiveActivity; sourceTree = ""; }; + 37989DCB2F609D9C0004BD8B /* WatchComplication */ = { + isa = PBXGroup; + children = ( + 37989DD52F60A15E0004BD8B /* WatchSessionReceiver.swift */, + 37989DCF2F609F570004BD8B /* ComplicationEntryBuilder.swift */, + 37989DCC2F609DAE0004BD8B /* WatchComplicationProvider.swift */, + 37989DDB2F60A2000004BD8B /* WatchFormat.swift */, + ); + path = WatchComplication; + sourceTree = ""; + }; 6589CC552E9E7D1600BB18FE /* ImportExport */ = { isa = PBXGroup; children = ( @@ -959,6 +1023,7 @@ 6A5880E0B811AF443B05AB02 /* Frameworks */ = { isa = PBXGroup; children = ( + 37989DD12F60A0ED0004BD8B /* WatchConnectivity.framework */, DDCC3ABF2DDE10B0006F1C10 /* Testing.framework */, FCFEEC9D2486E68E00402A7F /* WebKit.framework */, A7D55B42A22051DAD69E89D0 /* Pods_LoopFollow.framework */, @@ -982,6 +1047,7 @@ isa = PBXGroup; children = ( DDCC3A552DDC9617006F1C10 /* MissedBolusCondition.swift */, + 2EBAB9EECE7095238A558060 /* FutureCarbsCondition.swift */, DDCC3A4E2DDC5B54006F1C10 /* BatteryDropCondition.swift */, DDCC3A4A2DDBB5E4006F1C10 /* BatteryCondition.swift */, DDCC3A512DDED000006F1C10 /* PumpBatteryCondition.swift */, @@ -1228,6 +1294,7 @@ isa = PBXGroup; children = ( DDCC3A572DDC9655006F1C10 /* MissedBolusAlarmEditor.swift */, + B7D2A4EFD18B7B7748B6669E /* FutureCarbsAlarmEditor.swift */, DDCC3A532DDC5D62006F1C10 /* BatteryDropAlarmEditor.swift */, DDCC3A4C2DDBB77C006F1C10 /* PhoneBatteryAlarmEditor.swift */, DDCC3A5C2DDE2000006F1C10 /* PumpBatteryAlarmEditor.swift */, @@ -1279,6 +1346,7 @@ isa = PBXGroup; children = ( DDCC3A592DDC988F006F1C10 /* CarbSample.swift */, + 2B9BEC26E4E48EF9B811A372 /* PendingFutureCarb.swift */, DDB9FC7E2DDB584500EFAA76 /* BolusEntry.swift */, DD5DA27B2DC930D6003D44FC /* GlucoseValue.swift */, ); @@ -1569,6 +1637,7 @@ isa = PBXGroup; children = ( 379BECAF2F65DA4B0069DC62 /* LiveActivitySettingsView.swift */, + 37989DCB2F609D9C0004BD8B /* WatchComplication */, 376310762F5CD65100656488 /* LiveActivity */, 6589CC612E9E7D1600BB18FE /* Settings */, 65AC26702ED245DF00421360 /* Treatments */, @@ -1601,6 +1670,7 @@ isa = PBXGroup; children = ( 379BECA92F6588300069DC62 /* RestartLiveActivityIntent.swift */, + 37989DD32F60A11E0004BD8B /* WatchConnectivityManager.swift */, 374DAACA2F5B924B00BB663B /* LoopFollowLAExtensionExtension.entitlements */, DDF2C0132BEFD468007A20E6 /* blacklisted-versions.json */, DDB0AF542BB1B24A00AFA48B /* BuildDetails.plist */, @@ -1611,6 +1681,7 @@ FC8DEEE32485D1680075863F /* LoopFollow */, DDCC3AD72DDE1790006F1C10 /* Tests */, 37A4BDDE2F5B6B4A00EEB289 /* LoopFollowLAExtension */, + 37989DB62F609B0C0004BD8B /* LoopFollowWatch Watch App */, FC9788152485969B00A7906C /* Products */, 8E32230C453C93FDCE59C2B9 /* Pods */, 6A5880E0B811AF443B05AB02 /* Frameworks */, @@ -1623,6 +1694,7 @@ FC9788142485969B00A7906C /* Loop Follow.app */, DDCC3AD62DDE1790006F1C10 /* Tests.xctest */, 37A4BDD92F5B6B4A00EEB289 /* LoopFollowLAExtensionExtension.appex */, + 37989DB52F609B0C0004BD8B /* LoopFollowWatch Watch App.app */, ); name = Products; sourceTree = ""; @@ -1695,6 +1767,28 @@ /* End PBXGroup section */ /* Begin PBXNativeTarget section */ + 37989DB42F609B0C0004BD8B /* LoopFollowWatch Watch App */ = { + isa = PBXNativeTarget; + buildConfigurationList = 37989DC02F609B0E0004BD8B /* Build configuration list for PBXNativeTarget "LoopFollowWatch Watch App" */; + buildPhases = ( + 37989DB12F609B0C0004BD8B /* Sources */, + 37989DB22F609B0C0004BD8B /* Frameworks */, + 37989DB32F609B0C0004BD8B /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + 37989DB62F609B0C0004BD8B /* LoopFollowWatch Watch App */, + ); + name = "LoopFollowWatch Watch App"; + packageProductDependencies = ( + ); + productName = "LoopFollowWatch Watch App"; + productReference = 37989DB52F609B0C0004BD8B /* LoopFollowWatch Watch App.app */; + productType = "com.apple.product-type.application"; + }; 37A4BDD82F5B6B4A00EEB289 /* LoopFollowLAExtensionExtension */ = { isa = PBXNativeTarget; buildConfigurationList = 37A4BDEA2F5B6B4C00EEB289 /* Build configuration list for PBXNativeTarget "LoopFollowLAExtensionExtension" */; @@ -1752,11 +1846,13 @@ 04DA71CCA0280FA5FA2DF7A6 /* [CP] Embed Pods Frameworks */, DDB0AF532BB1AA0900AFA48B /* Capture Build Details */, 37A4BDED2F5B6B4C00EEB289 /* Embed Foundation Extensions */, + 37989DC32F609B0E0004BD8B /* Embed Watch Content */, ); buildRules = ( ); dependencies = ( 37A4BDE72F5B6B4C00EEB289 /* PBXTargetDependency */, + 37989DBE2F609B0E0004BD8B /* PBXTargetDependency */, ); fileSystemSynchronizedGroups = ( 65AC25F52ECFD5E800421360 /* Stats */, @@ -1775,10 +1871,13 @@ FC97880C2485969B00A7906C /* Project object */ = { isa = PBXProject; attributes = { - LastSwiftUpdateCheck = 2620; + LastSwiftUpdateCheck = 2630; LastUpgradeCheck = 1140; ORGANIZATIONNAME = "Jon Fawcett"; TargetAttributes = { + 37989DB42F609B0C0004BD8B = { + CreatedOnToolsVersion = 26.3; + }; 37A4BDD82F5B6B4A00EEB289 = { CreatedOnToolsVersion = 26.2; }; @@ -1809,11 +1908,19 @@ FC9788132485969B00A7906C /* LoopFollow */, DDCC3AD52DDE1790006F1C10 /* Tests */, 37A4BDD82F5B6B4A00EEB289 /* LoopFollowLAExtensionExtension */, + 37989DB42F609B0C0004BD8B /* LoopFollowWatch Watch App */, ); }; /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ + 37989DB32F609B0C0004BD8B /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 37A4BDD72F5B6B4A00EEB289 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -2044,6 +2151,22 @@ /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ + 37989DB12F609B0C0004BD8B /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 37989DC62F609C5E0004BD8B /* GlucoseSnapshotStore.swift in Sources */, + 37989DD02F609F570004BD8B /* ComplicationEntryBuilder.swift in Sources */, + 37989DC52F609C550004BD8B /* GlucoseSnapshot.swift in Sources */, + 37989DC72F609C730004BD8B /* AppGroupID.swift in Sources */, + 37989DD62F60A15E0004BD8B /* WatchSessionReceiver.swift in Sources */, + 37989DC82F609C7A0004BD8B /* LAAppGroupSettings.swift in Sources */, + 37989DCE2F609ED40004BD8B /* WatchComplicationProvider.swift in Sources */, + 37989DDC2F60A2010004BD8B /* WatchFormat.swift in Sources */, + 37989DDD2F60A2020004BD8B /* GlucoseConversion.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 37A4BDD52F5B6B4A00EEB289 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -2132,6 +2255,9 @@ DDB9FC7B2DDB573F00EFAA76 /* IOBCondition.swift in Sources */, DD7E19862ACDA59700DBD158 /* BGCheck.swift in Sources */, DDCC3A5A2DDC988F006F1C10 /* CarbSample.swift in Sources */, + F19449721F3B792730A0F4FD /* PendingFutureCarb.swift in Sources */, + 2D8068C66833EEAED7B4BEB8 /* FutureCarbsCondition.swift in Sources */, + ACE7F6DE0D065BEB52CDC0DB /* FutureCarbsAlarmEditor.swift in Sources */, DD0650F12DCE9A9E004D3B41 /* MissedReadingCondition.swift in Sources */, DDC6CA4B2DD8E4960060EE25 /* PumpVolumeAlarmEditor.swift in Sources */, 6589CC752E9EAFB700BB18FE /* SettingsMigrationManager.swift in Sources */, @@ -2204,6 +2330,7 @@ FC8589BF252B54F500C8FC73 /* Mobileprovision.swift in Sources */, DD4878052C7B2C970048F05C /* Storage.swift in Sources */, DD493AE12ACF22FE009A6922 /* Profile.swift in Sources */, + 37989DD42F60A11E0004BD8B /* WatchConnectivityManager.swift in Sources */, 6589CC622E9E7D1600BB18FE /* ImportExportSettingsView.swift in Sources */, 6589CC632E9E7D1600BB18FE /* GeneralSettingsView.swift in Sources */, 5544D8C363FB5D3B9BF8CE4A /* APNSettingsView.swift in Sources */, @@ -2351,6 +2478,11 @@ /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ + 37989DBE2F609B0E0004BD8B /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 37989DB42F609B0C0004BD8B /* LoopFollowWatch Watch App */; + targetProxy = 37989DBD2F609B0E0004BD8B /* PBXContainerItemProxy */; + }; 37A4BDE72F5B6B4C00EEB289 /* PBXTargetDependency */ = { isa = PBXTargetDependency; platformFilter = ios; @@ -2384,6 +2516,91 @@ /* End PBXVariantGroup section */ /* Begin XCBuildConfiguration section */ + 37989DC12F609B0E0004BD8B /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_COMPLICATION_NAME = LoopFollow; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CODE_SIGN_ENTITLEMENTS = "LoopFollowWatch Watch App/LoopFollowWatch Watch App.entitlements"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 2HEY366Q6J; + ENABLE_PREVIEWS = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = LoopFollowWatch; + INFOPLIST_KEY_CLKComplicationPrincipalClass = "$(PRODUCT_MODULE_NAME).WatchComplicationProvider"; + INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; + INFOPLIST_KEY_WKCompanionAppBundleIdentifier = "com.$(unique_id).LoopFollow$(app_suffix)"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "com.$(unique_id).LoopFollow$(app_suffix).watchkitapp"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = watchos; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 4; + WATCHOS_DEPLOYMENT_TARGET = 9.6; + }; + name = Debug; + }; + 37989DC22F609B0E0004BD8B /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_COMPLICATION_NAME = LoopFollow; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CODE_SIGN_ENTITLEMENTS = "LoopFollowWatch Watch App/LoopFollowWatch Watch App.entitlements"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 2HEY366Q6J; + ENABLE_PREVIEWS = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = LoopFollowWatch; + INFOPLIST_KEY_CLKComplicationPrincipalClass = "$(PRODUCT_MODULE_NAME).WatchComplicationProvider"; + INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; + INFOPLIST_KEY_WKCompanionAppBundleIdentifier = "com.$(unique_id).LoopFollow$(app_suffix)"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "com.$(unique_id).LoopFollow$(app_suffix).watchkitapp"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = watchos; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 4; + WATCHOS_DEPLOYMENT_TARGET = 9.6; + }; + name = Release; + }; 37A4BDEB2F5B6B4C00EEB289 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -2395,7 +2612,7 @@ CODE_SIGN_ENTITLEMENTS = LoopFollowLAExtensionExtension.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = "$(LF_DEVELOPMENT_TEAM)"; + DEVELOPMENT_TEAM = 2HEY366Q6J; ENABLE_APP_SANDBOX = NO; ENABLE_HARDENED_RUNTIME = NO; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -2447,7 +2664,7 @@ CODE_SIGN_ENTITLEMENTS = LoopFollowLAExtensionExtension.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = "$(LF_DEVELOPMENT_TEAM)"; + DEVELOPMENT_TEAM = 2HEY366Q6J; ENABLE_APP_SANDBOX = NO; ENABLE_HARDENED_RUNTIME = NO; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -2498,6 +2715,12 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = "$(LF_DEVELOPMENT_TEAM)"; ENABLE_USER_SCRIPT_SANDBOXING = YES; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(BUILT_PRODUCTS_DIR)/Charts", + "$(BUILT_PRODUCTS_DIR)/ShareClient", + "$(BUILT_PRODUCTS_DIR)/SwiftAlgorithms", + ); GCC_C_LANGUAGE_STANDARD = gnu17; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 18.4; @@ -2525,6 +2748,12 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = "$(LF_DEVELOPMENT_TEAM)"; ENABLE_USER_SCRIPT_SANDBOXING = YES; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(BUILT_PRODUCTS_DIR)/Charts", + "$(BUILT_PRODUCTS_DIR)/ShareClient", + "$(BUILT_PRODUCTS_DIR)/SwiftAlgorithms", + ); GCC_C_LANGUAGE_STANDARD = gnu17; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 18.4; @@ -2669,7 +2898,7 @@ CODE_SIGN_ENTITLEMENTS = "LoopFollow/Loop Follow.entitlements"; CODE_SIGN_STYLE = Automatic; DEFINES_MODULE = YES; - DEVELOPMENT_TEAM = "$(LF_DEVELOPMENT_TEAM)"; + DEVELOPMENT_TEAM = 2HEY366Q6J; INFOPLIST_FILE = LoopFollow/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 16.6; LD_RUNPATH_SEARCH_PATHS = ( @@ -2694,7 +2923,7 @@ CODE_SIGN_ENTITLEMENTS = "LoopFollow/Loop Follow.entitlements"; CODE_SIGN_STYLE = Automatic; DEFINES_MODULE = YES; - DEVELOPMENT_TEAM = "$(LF_DEVELOPMENT_TEAM)"; + DEVELOPMENT_TEAM = 2HEY366Q6J; INFOPLIST_FILE = LoopFollow/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 16.6; LD_RUNPATH_SEARCH_PATHS = ( @@ -2713,6 +2942,15 @@ /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ + 37989DC02F609B0E0004BD8B /* Build configuration list for PBXNativeTarget "LoopFollowWatch Watch App" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 37989DC12F609B0E0004BD8B /* Debug */, + 37989DC22F609B0E0004BD8B /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; 37A4BDEA2F5B6B4C00EEB289 /* Build configuration list for PBXNativeTarget "LoopFollowLAExtensionExtension" */ = { isa = XCConfigurationList; buildConfigurations = ( diff --git a/LoopFollow/Alarm/Alarm.swift b/LoopFollow/Alarm/Alarm.swift index 6b6b37f6d..8fff17f4d 100644 --- a/LoopFollow/Alarm/Alarm.swift +++ b/LoopFollow/Alarm/Alarm.swift @@ -309,6 +309,12 @@ struct Alarm: Identifiable, Codable, Equatable { predictiveMinutes = 15 delta = 0.1 threshold = 4 + case .futureCarbs: + soundFile = .alertToneRingtone1 + threshold = 45 // max lookahead minutes + delta = 5 // min grams + snoozeDuration = 0 + repeatSoundOption = .never case .sensorChange: soundFile = .wakeUpWillYou threshold = 12 @@ -364,7 +370,7 @@ extension AlarmType { switch self { case .low, .high, .fastDrop, .fastRise, .missedReading, .temporary: return .glucose - case .iob, .cob, .missedBolus, .recBolus: + case .iob, .cob, .missedBolus, .futureCarbs, .recBolus: return .insulin case .battery, .batteryDrop, .pump, .pumpBattery, .pumpChange, .sensorChange, .notLooping, .buildExpire: @@ -384,6 +390,7 @@ extension AlarmType { case .iob: return "syringe" case .cob: return "fork.knife" case .missedBolus: return "exclamationmark.arrow.triangle.2.circlepath" + case .futureCarbs: return "clock.arrow.circlepath" case .recBolus: return "bolt.horizontal" case .battery: return "battery.25" case .batteryDrop: return "battery.100.bolt" @@ -411,6 +418,7 @@ extension AlarmType { case .iob: return "High insulin-on-board." case .cob: return "High carbs-on-board." case .missedBolus: return "Carbs without bolus." + case .futureCarbs: return "Reminder when future carbs are due." case .recBolus: return "Recommended bolus issued." case .battery: return "Phone battery low." case .batteryDrop: return "Battery drops quickly." diff --git a/LoopFollow/Alarm/AlarmCondition/FutureCarbsCondition.swift b/LoopFollow/Alarm/AlarmCondition/FutureCarbsCondition.swift new file mode 100644 index 000000000..24631e5c9 --- /dev/null +++ b/LoopFollow/Alarm/AlarmCondition/FutureCarbsCondition.swift @@ -0,0 +1,104 @@ +// LoopFollow +// FutureCarbsCondition.swift + +import Foundation + +/// Fires once when a future-dated carb entry's scheduled time arrives. +/// +/// **How it works:** +/// 1. Each alarm tick scans `recentCarbs` for entries whose `date` is in the future. +/// New ones are added to a persistent "pending" list regardless of lookahead distance, +/// capturing the moment they were first observed (`observedAt`). +/// 2. When a pending entry's `carbDate` passes (i.e. `carbDate <= now`), verify the +/// carb still exists in `recentCarbs` **and** that the original distance +/// (`carbDate − observedAt`) was within the max lookahead window. If both hold, +/// fire the alarm. Otherwise silently remove the entry. +/// 3. Stale entries (observed > 2 hours ago) whose carb no longer exists in +/// `recentCarbs` are cleaned up automatically. +struct FutureCarbsCondition: AlarmCondition { + static let type: AlarmType = .futureCarbs + init() {} + + func evaluate(alarm: Alarm, data: AlarmData, now: Date) -> Bool { + // ──────────────────────────────── + // 0. Pull settings + // ──────────────────────────────── + let maxLookaheadMin = alarm.threshold ?? 45 // max lookahead in minutes + let minGrams = alarm.delta ?? 5 // ignore carbs below this + + let nowTI = now.timeIntervalSince1970 + let maxLookaheadSec = maxLookaheadMin * 60 + + var pending = Storage.shared.pendingFutureCarbs.value + let tolerance: TimeInterval = 5 // seconds, for matching carb entries + + // ──────────────────────────────── + // 1. Scan for new future carbs + // ──────────────────────────────── + for carb in data.recentCarbs { + let carbTI = carb.date.timeIntervalSince1970 + + // Must be in the future and meet the minimum grams threshold. + // We track ALL future carbs (not just those within the lookahead + // window) so that carbs originally outside the window cannot + // drift in later with a fresh observedAt. + guard carbTI > nowTI, + carb.grams >= minGrams + else { continue } + + // Already tracked? + let alreadyTracked = pending.contains { entry in + abs(entry.carbDate - carbTI) < tolerance && entry.grams == carb.grams + } + if !alreadyTracked { + pending.append(PendingFutureCarb( + carbDate: carbTI, + grams: carb.grams, + observedAt: nowTI + )) + } + } + + // ──────────────────────────────── + // 2. Check if any pending entry is due + // ──────────────────────────────── + var fired = false + + pending.removeAll { entry in + let stillExists = data.recentCarbs.contains { carb in + abs(carb.date.timeIntervalSince1970 - entry.carbDate) < tolerance + && carb.grams == entry.grams + } + + // Cleanup stale entries (observed > 2 hours ago) only if + // the carb no longer exists — prevents eviction and + // re-observation with a fresh observedAt. + if nowTI - entry.observedAt > 7200, !stillExists { + return true + } + + // Not yet due + guard entry.carbDate <= nowTI else { return false } + + // Carb was deleted — remove silently + if !stillExists { return true } + + // Carb was originally outside the lookahead window — remove without firing + if entry.carbDate - entry.observedAt > maxLookaheadSec { return true } + + // Fire (one per tick) + if !fired { + fired = true + return true + } + + return false + } + + // ──────────────────────────────── + // 3. Persist and return + // ──────────────────────────────── + Storage.shared.pendingFutureCarbs.value = pending + return fired + } +} diff --git a/LoopFollow/Alarm/AlarmEditing/AlarmEditor.swift b/LoopFollow/Alarm/AlarmEditing/AlarmEditor.swift index 6ca26d576..e8ff4aff5 100644 --- a/LoopFollow/Alarm/AlarmEditing/AlarmEditor.swift +++ b/LoopFollow/Alarm/AlarmEditing/AlarmEditor.swift @@ -82,6 +82,7 @@ struct AlarmEditor: View { case .battery: PhoneBatteryAlarmEditor(alarm: $alarm) case .batteryDrop: BatteryDropAlarmEditor(alarm: $alarm) case .missedBolus: MissedBolusAlarmEditor(alarm: $alarm) + case .futureCarbs: FutureCarbsAlarmEditor(alarm: $alarm) } } } diff --git a/LoopFollow/Alarm/AlarmEditing/Editors/FutureCarbsAlarmEditor.swift b/LoopFollow/Alarm/AlarmEditing/Editors/FutureCarbsAlarmEditor.swift new file mode 100644 index 000000000..0df1e2177 --- /dev/null +++ b/LoopFollow/Alarm/AlarmEditing/Editors/FutureCarbsAlarmEditor.swift @@ -0,0 +1,46 @@ +// LoopFollow +// FutureCarbsAlarmEditor.swift + +import SwiftUI + +struct FutureCarbsAlarmEditor: View { + @Binding var alarm: Alarm + + var body: some View { + Group { + InfoBanner( + text: "Alerts when a future-dated carb entry's scheduled time arrives — " + + "a reminder to start eating. Use the max lookahead to ignore " + + "fat/protein entries that are typically scheduled further ahead.", + alarmType: alarm.type + ) + + AlarmGeneralSection(alarm: $alarm) + + AlarmStepperSection( + header: "Max Lookahead", + footer: "Only track carb entries scheduled up to this many minutes " + + "in the future. Entries beyond this window are ignored.", + title: "Lookahead", + range: 5 ... 120, + step: 5, + unitLabel: "min", + value: $alarm.threshold + ) + + AlarmStepperSection( + header: "Minimum Carbs", + footer: "Ignore carb entries below this amount.", + title: "At or Above", + range: 0 ... 50, + step: 1, + unitLabel: "g", + value: $alarm.delta + ) + + AlarmActiveSection(alarm: $alarm) + AlarmAudioSection(alarm: $alarm) + AlarmSnoozeSection(alarm: $alarm) + } + } +} diff --git a/LoopFollow/Alarm/AlarmManager.swift b/LoopFollow/Alarm/AlarmManager.swift index 436535ea6..3f5aa84ec 100644 --- a/LoopFollow/Alarm/AlarmManager.swift +++ b/LoopFollow/Alarm/AlarmManager.swift @@ -33,6 +33,7 @@ class AlarmManager { IOBCondition.self, BatteryCondition.self, BatteryDropCondition.self, + FutureCarbsCondition.self, ] ) { var dict = [AlarmType: AlarmCondition]() diff --git a/LoopFollow/Alarm/AlarmType/AlarmType+Snooze.swift b/LoopFollow/Alarm/AlarmType/AlarmType+Snooze.swift index e242226cd..134e1fb5b 100644 --- a/LoopFollow/Alarm/AlarmType/AlarmType+Snooze.swift +++ b/LoopFollow/Alarm/AlarmType/AlarmType+Snooze.swift @@ -11,7 +11,7 @@ extension AlarmType { return .day case .low, .high, .fastDrop, .fastRise, .missedReading, .notLooping, .missedBolus, - .recBolus, + .futureCarbs, .recBolus, .overrideStart, .overrideEnd, .tempTargetStart, .tempTargetEnd: return .minute diff --git a/LoopFollow/Alarm/AlarmType/AlarmType+canAcknowledge.swift b/LoopFollow/Alarm/AlarmType/AlarmType+canAcknowledge.swift index 151dd3914..9f5f3b5d1 100644 --- a/LoopFollow/Alarm/AlarmType/AlarmType+canAcknowledge.swift +++ b/LoopFollow/Alarm/AlarmType/AlarmType+canAcknowledge.swift @@ -8,7 +8,7 @@ extension AlarmType { var canAcknowledge: Bool { switch self { // These are alarms that typically has a "memory", they will only alarm once and acknowledge them is fine - case .low, .high, .fastDrop, .fastRise, .temporary, .cob, .missedBolus, .recBolus, .overrideStart, .overrideEnd, .tempTargetStart, .tempTargetEnd: + case .low, .high, .fastDrop, .fastRise, .temporary, .cob, .missedBolus, .futureCarbs, .recBolus, .overrideStart, .overrideEnd, .tempTargetStart, .tempTargetEnd: return true // These are alarms without memory, if they only are acknowledged - they would alarm again immediately case diff --git a/LoopFollow/Alarm/AlarmType/AlarmType.swift b/LoopFollow/Alarm/AlarmType/AlarmType.swift index 7df7780a4..11a51885e 100644 --- a/LoopFollow/Alarm/AlarmType/AlarmType.swift +++ b/LoopFollow/Alarm/AlarmType/AlarmType.swift @@ -16,6 +16,7 @@ enum AlarmType: String, CaseIterable, Codable { case missedReading = "Missed Reading Alert" case notLooping = "Not Looping Alert" case missedBolus = "Missed Bolus Alert" + case futureCarbs = "Future Carbs Alert" case sensorChange = "Sensor Change Alert" case pumpChange = "Pump Change Alert" case pump = "Pump Insulin Alert" diff --git a/LoopFollow/Alarm/DataStructs/PendingFutureCarb.swift b/LoopFollow/Alarm/DataStructs/PendingFutureCarb.swift new file mode 100644 index 000000000..b28e9851a --- /dev/null +++ b/LoopFollow/Alarm/DataStructs/PendingFutureCarb.swift @@ -0,0 +1,17 @@ +// LoopFollow +// PendingFutureCarb.swift + +import Foundation + +/// Tracks a future-dated carb entry that has been observed but whose scheduled time +/// has not yet arrived. Used by `FutureCarbsCondition` to fire a reminder when it's time to eat. +struct PendingFutureCarb: Codable, Equatable { + /// Scheduled eating time (`timeIntervalSince1970`) + let carbDate: TimeInterval + + /// Grams of carbs (used together with `carbDate` to identify unique entries) + let grams: Double + + /// When the entry was first observed (`timeIntervalSince1970`, for staleness cleanup) + let observedAt: TimeInterval +} diff --git a/LoopFollow/Application/AppDelegate.swift b/LoopFollow/Application/AppDelegate.swift index a6fd9f2b9..eb93e8cc3 100644 --- a/LoopFollow/Application/AppDelegate.swift +++ b/LoopFollow/Application/AppDelegate.swift @@ -40,6 +40,8 @@ class AppDelegate: UIResponder, UIApplicationDelegate { _ = BLEManager.shared // Ensure VolumeButtonHandler is initialized so it can receive alarm notifications _ = VolumeButtonHandler.shared + + WatchConnectivityManager.shared.activate() // Register for remote notifications DispatchQueue.main.async { diff --git a/LoopFollow/Controllers/Graphs.swift b/LoopFollow/Controllers/Graphs.swift index 0b272697f..0365122e0 100644 --- a/LoopFollow/Controllers/Graphs.swift +++ b/LoopFollow/Controllers/Graphs.swift @@ -321,13 +321,8 @@ extension MainViewController { lineBolus.drawCirclesEnabled = true lineBolus.drawFilledEnabled = false - if Storage.shared.showValues.value { - lineBolus.drawValuesEnabled = true - lineBolus.highlightEnabled = false - } else { - lineBolus.drawValuesEnabled = false - lineBolus.highlightEnabled = true - } + lineBolus.drawValuesEnabled = Storage.shared.showValues.value + lineBolus.highlightEnabled = true // Carbs let chartEntryCarbs = [ChartDataEntry]() @@ -347,13 +342,8 @@ extension MainViewController { lineCarbs.drawCirclesEnabled = true lineCarbs.drawFilledEnabled = false - if Storage.shared.showValues.value { - lineCarbs.drawValuesEnabled = true - lineCarbs.highlightEnabled = false - } else { - lineCarbs.drawValuesEnabled = false - lineCarbs.highlightEnabled = true - } + lineCarbs.drawValuesEnabled = Storage.shared.showValues.value + lineCarbs.highlightEnabled = true // create Scheduled Basal graph data let chartBasalScheduledEntry = [ChartDataEntry]() @@ -569,13 +559,8 @@ extension MainViewController { lineSmb.drawCirclesEnabled = false lineSmb.drawFilledEnabled = false - if Storage.shared.showValues.value { - lineSmb.drawValuesEnabled = true - lineSmb.highlightEnabled = false - } else { - lineSmb.drawValuesEnabled = false - lineSmb.highlightEnabled = true - } + lineSmb.drawValuesEnabled = Storage.shared.showValues.value + lineSmb.highlightEnabled = true // TempTarget graph data let chartTempTargetEntry = [ChartDataEntry]() @@ -1021,7 +1006,8 @@ extension MainViewController { let graphHours = 24 * Storage.shared.downloadDays.value if dateTimeStamp < dateTimeUtils.getTimeIntervalNHoursAgo(N: graphHours) { continue } - let dot = ChartDataEntry(x: Double(dateTimeStamp), y: Double(bolusData[i].sgv), data: formatter.string(from: NSNumber(value: bolusData[i].value))) + let valueString = formatter.string(from: NSNumber(value: bolusData[i].value)) ?? "" + let dot = ChartDataEntry(x: Double(dateTimeStamp), y: Double(bolusData[i].sgv), data: valueString + "\r\r" + formatPillText(line1: valueString + " U", time: bolusData[i].date)) mainChart.addEntry(dot) if Storage.shared.smallGraphTreatments.value { smallChart.addEntry(dot) @@ -1093,7 +1079,8 @@ extension MainViewController { let graphHours = 24 * Storage.shared.downloadDays.value if dateTimeStamp < dateTimeUtils.getTimeIntervalNHoursAgo(N: graphHours) { continue } - let dot = ChartDataEntry(x: Double(dateTimeStamp), y: Double(smbData[i].sgv), data: formatter.string(from: NSNumber(value: smbData[i].value))) + let valueString = formatter.string(from: NSNumber(value: smbData[i].value)) ?? "" + let dot = ChartDataEntry(x: Double(dateTimeStamp), y: Double(smbData[i].sgv), data: valueString + "\r\r" + formatPillText(line1: valueString + " U", time: smbData[i].date)) mainChart.addEntry(dot) if Storage.shared.smallGraphTreatments.value { smallChart.addEntry(dot) @@ -1146,7 +1133,7 @@ extension MainViewController { dateTimeStamp = dateTimeStamp - 250 } - let dot = ChartDataEntry(x: Double(dateTimeStamp), y: Double(carbData[i].sgv), data: valueString) + let dot = ChartDataEntry(x: Double(dateTimeStamp), y: Double(carbData[i].sgv), data: valueString + "\r\r" + formatPillText(line1: valueString + " g", time: carbData[i].date)) BGChart.data?.dataSets[dataIndex].addEntry(dot) if Storage.shared.smallGraphTreatments.value { BGChartFull.data?.dataSets[dataIndex].addEntry(dot) diff --git a/LoopFollow/Controllers/Nightscout/BGData.swift b/LoopFollow/Controllers/Nightscout/BGData.swift index c0721b8a4..958428dd1 100644 --- a/LoopFollow/Controllers/Nightscout/BGData.swift +++ b/LoopFollow/Controllers/Nightscout/BGData.swift @@ -262,6 +262,7 @@ extension MainViewController { // Live Activity storage Storage.shared.lastBgReadingTimeSeconds.value = lastBGTime + Storage.shared.lastBgMgdl.value = Double(latestBG) Storage.shared.lastDeltaMgdl.value = Double(deltaBG) Storage.shared.lastTrendCode.value = entries[latestEntryIndex].direction diff --git a/LoopFollow/Controllers/Nightscout/DeviceStatus.swift b/LoopFollow/Controllers/Nightscout/DeviceStatus.swift index ae3967b3e..6c6cac03f 100644 --- a/LoopFollow/Controllers/Nightscout/DeviceStatus.swift +++ b/LoopFollow/Controllers/Nightscout/DeviceStatus.swift @@ -127,6 +127,7 @@ extension MainViewController { let storedTime = Observable.shared.alertLastLoopTime.value ?? 0 if lastPumpTime > storedTime { Observable.shared.alertLastLoopTime.value = lastPumpTime + Storage.shared.lastLoopTime.value = lastPumpTime } if let reservoirData = lastPumpRecord["reservoir"] as? Double { diff --git a/LoopFollow/Controllers/Nightscout/DeviceStatusOpenAPS.swift b/LoopFollow/Controllers/Nightscout/DeviceStatusOpenAPS.swift index 20827c253..150e5da4f 100644 --- a/LoopFollow/Controllers/Nightscout/DeviceStatusOpenAPS.swift +++ b/LoopFollow/Controllers/Nightscout/DeviceStatusOpenAPS.swift @@ -117,6 +117,9 @@ extension MainViewController { if let eventualBGValue = enactedOrSuggested["eventualBG"] as? Double { let eventualBGQuantity = HKQuantity(unit: .milligramsPerDeciliter, doubleValue: eventualBGValue) PredictionLabel.text = Localizer.formatQuantity(eventualBGQuantity) + Storage.shared.projectedBgMgdl.value = eventualBGValue + } else { + Storage.shared.projectedBgMgdl.value = nil } // Target @@ -243,7 +246,6 @@ extension MainViewController { // Live Activity storage Storage.shared.lastIOB.value = latestIOB?.value Storage.shared.lastCOB.value = latestCOB?.value - Storage.shared.projectedBgMgdl.value = nil } } } diff --git a/LoopFollow/Controllers/Nightscout/Treatments.swift b/LoopFollow/Controllers/Nightscout/Treatments.swift index 8ff20df87..307a37e79 100644 --- a/LoopFollow/Controllers/Nightscout/Treatments.swift +++ b/LoopFollow/Controllers/Nightscout/Treatments.swift @@ -10,11 +10,11 @@ extension MainViewController { if !Storage.shared.downloadTreatments.value { return } let startTimeString = dateTimeUtils.getDateTimeString(addingDays: -1 * Storage.shared.downloadDays.value) - let currentTimeString = dateTimeUtils.getDateTimeString() + let endTimeString = dateTimeUtils.getDateTimeString(addingHours: 6) let estimatedCount = max(Storage.shared.downloadDays.value * 100, 5000) let parameters: [String: String] = [ "find[created_at][$gte]": startTimeString, - "find[created_at][$lte]": currentTimeString, + "find[created_at][$lte]": endTimeString, "count": "\(estimatedCount)", ] NightscoutUtils.executeDynamicRequest(eventType: .treatments, parameters: parameters) { (result: Result) in diff --git a/LoopFollow/Controllers/SpeakBG.swift b/LoopFollow/Controllers/SpeakBG.swift index 6f4c0c6b0..ee003ce49 100644 --- a/LoopFollow/Controllers/SpeakBG.swift +++ b/LoopFollow/Controllers/SpeakBG.swift @@ -73,6 +73,13 @@ extension MainViewController { static func forLanguage(_ language: String) -> AnnouncementTexts { switch language { + case "fr": + return AnnouncementTexts( + stable: "et c'est stable", + increase: "et c'est monté de", + decrease: "et c'est descendu de", + currentBGIs: "La glycémie est" + ) case "it": return AnnouncementTexts( stable: "ed è stabile", @@ -109,6 +116,7 @@ extension MainViewController { enum LanguageVoiceMapping { static let voiceLanguageMap: [String: String] = [ "en": "en-US", + "fr": "fr-FR", "it": "it-IT", "sk": "sk-SK", "sv": "sv-SE", diff --git a/LoopFollow/Helpers/Chart.swift b/LoopFollow/Helpers/Chart.swift index 8825be93c..2df0ae0d2 100644 --- a/LoopFollow/Helpers/Chart.swift +++ b/LoopFollow/Helpers/Chart.swift @@ -44,11 +44,12 @@ final class ChartXValueFormatter: AxisValueFormatter { final class ChartYDataValueFormatter: ValueFormatter { func stringForValue(_: Double, entry: ChartDataEntry, dataSetIndex _: Int, viewPortHandler _: ViewPortHandler?) -> String { - if entry.data != nil { - return entry.data as? String ?? "" - } else { - return "" + guard let text = entry.data as? String else { return "" } + // Treatment entries store "label\r\rpillText" — extract only the label portion. + if let range = text.range(of: "\r\r") { + return String(text[.. String { + static func getDateTimeString(addingMinutes minutes: Int? = nil, addingHours hours: Int? = nil, addingDays days: Int? = nil) -> String { let currentDate = Date() var date = currentDate + if let minutesToAdd = minutes { + date = Calendar.current.date(byAdding: .minute, value: minutesToAdd, to: date)! + } + if let hoursToAdd = hours { - date = Calendar.current.date(byAdding: .hour, value: hoursToAdd, to: currentDate)! + date = Calendar.current.date(byAdding: .hour, value: hoursToAdd, to: date)! } if let daysToAdd = days { - date = Calendar.current.date(byAdding: .day, value: daysToAdd, to: currentDate)! + date = Calendar.current.date(byAdding: .day, value: daysToAdd, to: date)! } let dateFormatter = DateFormatter() diff --git a/LoopFollow/LiveActivity/AppGroupID.swift b/LoopFollow/LiveActivity/AppGroupID.swift index 5eb1187b8..2ce09159c 100644 --- a/LoopFollow/LiveActivity/AppGroupID.swift +++ b/LoopFollow/LiveActivity/AppGroupID.swift @@ -58,6 +58,7 @@ enum AppGroupID { ".WidgetExtension", ".Widgets", ".WidgetsExtension", + ".watchkitapp", ".Watch", ".WatchExtension", ".CarPlay", diff --git a/LoopFollow/LiveActivity/GlucoseSnapshot.swift b/LoopFollow/LiveActivity/GlucoseSnapshot.swift index 8860391c2..c5f5fffcc 100644 --- a/LoopFollow/LiveActivity/GlucoseSnapshot.swift +++ b/LoopFollow/LiveActivity/GlucoseSnapshot.swift @@ -12,6 +12,14 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable { enum Unit: String, Codable, Hashable { case mgdl case mmol + + /// Human-readable display string for the unit (e.g. "mg/dL" or "mmol/L"). + var displayName: String { + switch self { + case .mgdl: return "mg/dL" + case .mmol: return "mmol/L" + } + } } // MARK: - Core Glucose diff --git a/LoopFollow/LiveActivity/GlucoseSnapshotStore.swift b/LoopFollow/LiveActivity/GlucoseSnapshotStore.swift index 7951e122a..563965710 100644 --- a/LoopFollow/LiveActivity/GlucoseSnapshotStore.swift +++ b/LoopFollow/LiveActivity/GlucoseSnapshotStore.swift @@ -17,7 +17,7 @@ final class GlucoseSnapshotStore { // MARK: - Public API - func save(_ snapshot: GlucoseSnapshot) { + func save(_ snapshot: GlucoseSnapshot, completion: (() -> Void)? = nil) { queue.async { do { let url = try self.fileURL() @@ -28,6 +28,7 @@ final class GlucoseSnapshotStore { } catch { // Intentionally silent (extension-safe, no dependencies). } + completion?() } } diff --git a/LoopFollow/LiveActivity/LAAppGroupSettings.swift b/LoopFollow/LiveActivity/LAAppGroupSettings.swift index b61487f27..f7c4114d0 100644 --- a/LoopFollow/LiveActivity/LAAppGroupSettings.swift +++ b/LoopFollow/LiveActivity/LAAppGroupSettings.swift @@ -97,6 +97,15 @@ enum LiveActivitySlotOption: String, CaseIterable, Codable { } } + /// True when the value is a glucose measurement and should be followed by + /// the user's preferred unit label (mg/dL or mmol/L) in compact displays. + var isGlucoseUnit: Bool { + switch self { + case .projectedBG, .delta, .minMax, .target, .isf: return true + default: return false + } + } + /// True when the underlying value may be nil (e.g. Dexcom-only users who have /// no Loop data). The widget renders "—" in those cases. var isOptional: Bool { @@ -118,6 +127,8 @@ enum LiveActivitySlotDefaults { static let slot3: LiveActivitySlotOption = .projectedBG /// Bottom-right slot — intentionally empty until the user configures it static let slot4: LiveActivitySlotOption = .none + /// Small widget (CarPlay / Watch Smart Stack) right slot + static let smallWidgetSlot: LiveActivitySlotOption = .projectedBG static var all: [LiveActivitySlotOption] { [slot1, slot2, slot3, slot4] @@ -135,8 +146,11 @@ enum LAAppGroupSettings { static let lowLineMgdl = "la.lowLine.mgdl" static let highLineMgdl = "la.highLine.mgdl" static let slots = "la.slots" + static let smallWidgetSlot = "la.smallWidgetSlot" static let displayName = "la.displayName" static let showDisplayName = "la.showDisplayName" + static let watchSlots = "watch.slots" + static let watchSelectedSlots = "watch.selectedSlots" } private static var defaults: UserDefaults? { @@ -179,6 +193,55 @@ enum LAAppGroupSettings { return raw.map { LiveActivitySlotOption(rawValue: $0) ?? .none } } + // MARK: - Small widget slot (Write) + + static func setSmallWidgetSlot(_ slot: LiveActivitySlotOption) { + defaults?.set(slot.rawValue, forKey: Keys.smallWidgetSlot) + } + + // MARK: - Small widget slot (Read) + + static func smallWidgetSlot() -> LiveActivitySlotOption { + guard let raw = defaults?.string(forKey: Keys.smallWidgetSlot) else { + return LiveActivitySlotDefaults.smallWidgetSlot + } + return LiveActivitySlotOption(rawValue: raw) ?? LiveActivitySlotDefaults.smallWidgetSlot + } + + // MARK: - Watch slots (Write) + + /// Persists the 4-position Watch data card slot configuration. + static func setWatchSlots(_ slots: [LiveActivitySlotOption]) { + let raw = slots.prefix(4).map(\.rawValue) + defaults?.set(raw, forKey: Keys.watchSlots) + } + + // MARK: - Watch slots (Read) + + static func watchSlots() -> [LiveActivitySlotOption] { + guard let raw = defaults?.stringArray(forKey: Keys.watchSlots), raw.count == 4 else { + return [.iob, .cob, .projectedBG, .battery] + } + return raw.map { LiveActivitySlotOption(rawValue: $0) ?? .none } + } + + // MARK: - Watch selected slots (ordered, variable-length) + + /// Persists the user's ordered list of selected Watch data slots. + static func setWatchSelectedSlots(_ slots: [LiveActivitySlotOption]) { + defaults?.set(slots.map(\.rawValue), forKey: Keys.watchSelectedSlots) + } + + /// Returns the ordered list of selected Watch data slots. + /// Falls back to a sensible default if nothing is saved. + static func watchSelectedSlots() -> [LiveActivitySlotOption] { + guard let raw = defaults?.stringArray(forKey: Keys.watchSelectedSlots) else { + return [.iob, .cob, .projectedBG, .battery] + } + return raw.compactMap { LiveActivitySlotOption(rawValue: $0) } + } + + // MARK: - Display Name static func setDisplayName(_ name: String, show: Bool) { diff --git a/LoopFollow/LiveActivity/LiveActivityManager.swift b/LoopFollow/LiveActivity/LiveActivityManager.swift index 746e5609d..35f1e18bb 100644 --- a/LoopFollow/LiveActivity/LiveActivityManager.swift +++ b/LoopFollow/LiveActivity/LiveActivityManager.swift @@ -87,6 +87,12 @@ final class LiveActivityManager { @objc private func handleDidBecomeActive() { guard Storage.shared.laEnabled.value else { return } + if skipNextDidBecomeActive { + LogManager.shared.log(category: .general, message: "[LA] didBecomeActive: skipped (handleForeground owns restart)", isDebug: true) + skipNextDidBecomeActive = false + return + } + LogManager.shared.log(category: .general, message: "[LA] didBecomeActive: calling startFromCurrentState, dismissedByUser=\(dismissedByUser)", isDebug: true) Task { @MainActor in self.startFromCurrentState() } @@ -100,19 +106,33 @@ final class LiveActivityManager { let now = Date().timeIntervalSince1970 let overlayIsShowing = renewBy > 0 && now >= renewBy - LiveActivityManager.renewalWarning - LogManager.shared.log(category: .general, message: "[LA] foreground notification received, laRenewalFailed=\(renewalFailed), overlayShowing=\(overlayIsShowing)") - guard renewalFailed || overlayIsShowing else { return } + LogManager.shared.log(category: .general, message: "[LA] foreground: renewalFailed=\(renewalFailed), overlayShowing=\(overlayIsShowing), current=\(current?.id ?? "nil"), dismissedByUser=\(dismissedByUser), renewBy=\(renewBy), now=\(now)") + guard renewalFailed || overlayIsShowing else { + LogManager.shared.log(category: .general, message: "[LA] foreground: no action needed (not in renewal window)") + return + } // Overlay is showing or renewal previously failed — end the stale LA and start a fresh one. // We cannot call startIfNeeded() here: it finds the existing activity in // Activity.activities and reuses it rather than replacing it. LogManager.shared.log(category: .general, message: "[LA] ending stale LA and restarting (renewalFailed=\(renewalFailed), overlayShowing=\(overlayIsShowing))") + // Suppress the handleDidBecomeActive() call that always fires after willEnterForeground. + // Without this, the two methods race: didBecomeActive binds to the old (dying) activity + // and observes its push token, while handleForeground's async end+restart creates a new + // activity — leaving pushToken nil when the new activity tries to start. + skipNextDidBecomeActive = true // Clear state synchronously so any snapshot built between now and when the // new LA is started computes showRenewalOverlay = false. Storage.shared.laRenewBy.value = 0 Storage.shared.laRenewalFailed.value = false + cancelRenewalFailedNotification() guard let activity = current else { + // LA was already gone (ended by iOS or user). If the user explicitly swiped, + // laRenewBy was cleared to 0 at that point, so overlayIsShowing would be false + // and we would never reach here. Reaching here means iOS ended it while the + // renewal window was open — restart is correct. + LogManager.shared.log(category: .general, message: "[LA] foreground restart: current=nil (iOS-ended during renewal window), dismissedByUser=\(dismissedByUser), restarting") startFromCurrentState() return } @@ -126,12 +146,19 @@ final class LiveActivityManager { stateObserverTask = nil pushToken = nil + // Signal the state observer that the upcoming .dismissed event is our own + // end() call, not a user swipe. This must be set synchronously before end() + // is awaited so the observer sees it regardless of MainActor scheduling order. + endingForRestart = true + Task { // Await end so the activity is removed from Activity.activities before // startIfNeeded() runs — otherwise it hits the reuse path and skips // writing a new laRenewBy deadline. await activity.end(nil, dismissalPolicy: .immediate) await MainActor.run { + self.endingForRestart = false + self.dismissedByUser = false // startFromCurrentState rebuilds the snapshot (showRenewalOverlay = false // since laRenewBy is 0), saves it to the store, then calls startIfNeeded() // which finds no existing activity and requests a fresh LA with a new deadline. @@ -152,7 +179,7 @@ final class LiveActivityManager { } static let renewalThreshold: TimeInterval = 7.5 * 3600 - static let renewalWarning: TimeInterval = 20 * 60 + static let renewalWarning: TimeInterval = 30 * 60 private(set) var current: Activity? private var stateObserverTask: Task? @@ -167,6 +194,14 @@ final class LiveActivityManager { /// In-memory only — resets to false on app relaunch, so a kill + relaunch /// starts fresh as expected. private var dismissedByUser = false + /// Set to true immediately before we call activity.end() as part of a planned restart. + /// Cleared after the restart completes. The state observer checks this flag so that + /// a .dismissed delivery triggered by our own end() call is never misclassified as a + /// user swipe — regardless of the order in which the MainActor executes the two writes. + private var endingForRestart = false + /// Set by handleForeground() when it takes ownership of the restart sequence. + /// Prevents handleDidBecomeActive() from racing with an in-flight end+restart. + private var skipNextDidBecomeActive = false // MARK: - Public API @@ -177,6 +212,28 @@ final class LiveActivityManager { } if let existing = Activity.activities.first { + // Before reusing, check whether this activity needs a restart. This covers cold + // starts (app was killed while the overlay was showing — willEnterForeground is + // never sent, so handleForeground never runs) and any other path that lands here + // without first going through handleForeground. + let renewBy = Storage.shared.laRenewBy.value + let now = Date().timeIntervalSince1970 + let staleDatePassed = existing.content.staleDate.map { $0 <= Date() } ?? false + let inRenewalWindow = renewBy > 0 && now >= renewBy - LiveActivityManager.renewalWarning + let needsRestart = Storage.shared.laRenewalFailed.value || inRenewalWindow || staleDatePassed + + if needsRestart { + LogManager.shared.log(category: .general, message: "[LA] existing activity is stale on startIfNeeded — ending and restarting (staleDatePassed=\(staleDatePassed), inRenewalWindow=\(inRenewalWindow))") + Storage.shared.laRenewBy.value = 0 + Storage.shared.laRenewalFailed.value = false + cancelRenewalFailedNotification() + Task { + await existing.end(nil, dismissalPolicy: .immediate) + await MainActor.run { self.startIfNeeded() } + } + return + } + bind(to: existing, logReason: "reuse") Storage.shared.laRenewalFailed.value = false return @@ -320,7 +377,8 @@ final class LiveActivityManager { } func refreshFromCurrentState(reason: String) { - guard Storage.shared.laEnabled.value, !dismissedByUser else { return } + // No LA guard here — Watch and store must update regardless of LA state. + // LA-specific gating (laEnabled, dismissedByUser) is applied inside performRefresh. refreshWorkItem?.cancel() let workItem = DispatchWorkItem { [weak self] in self?.performRefresh(reason: reason) @@ -421,14 +479,20 @@ final class LiveActivityManager { let now = Date() let timeSinceLastUpdate = now.timeIntervalSince(lastUpdateTime ?? .distantPast) let forceRefreshNeeded = timeSinceLastUpdate >= 5 * 60 - if let previous = GlucoseSnapshotStore.shared.load(), previous == snapshot, !forceRefreshNeeded { - return - } + // Capture dedup result BEFORE saving so the store comparison is valid. + let snapshotUnchanged = GlucoseSnapshotStore.shared.load() == snapshot + + // Store + Watch: always update, independent of LA state. LAAppGroupSettings.setThresholds( lowMgdl: Storage.shared.lowLine.value, highMgdl: Storage.shared.highLine.value, ) GlucoseSnapshotStore.shared.save(snapshot) + WatchConnectivityManager.shared.send(snapshot: snapshot) + + // LA update: gated on LA being active, snapshot having changed, and activities enabled. + guard Storage.shared.laEnabled.value, !dismissedByUser else { return } + guard !snapshotUnchanged || forceRefreshNeeded else { return } guard ActivityAuthorizationInfo().areActivitiesEnabled else { return } @@ -596,20 +660,50 @@ final class LiveActivityManager { if state == .ended || state == .dismissed { if current?.id == activity.id { current = nil - Storage.shared.laRenewBy.value = 0 - LogManager.shared.log(category: .general, message: "Live Activity cleared id=\(activity.id)", isDebug: true) + // Do NOT clear laRenewBy here. Preserving it means handleForeground() + // can detect the renewal window on the next foreground event and restart + // automatically — whether the LA ended normally (.ended) or was + // system-dismissed (.dismissed). laRenewBy is only set to 0 when: + // • the user explicitly swipes (below) — renewal intent cancelled + // • a new LA starts (startIfNeeded writes the new deadline) + // • handleForeground() clears it synchronously before restarting + // • the user disables LA or calls forceRestart + LogManager.shared.log(category: .general, message: "[LA] activity cleared id=\(activity.id) state=\(state)", isDebug: true) } if state == .dismissed { - if Storage.shared.laRenewalFailed.value { - // iOS force-dismissed after 8-hour limit with a failed renewal. - // Allow auto-restart when the user opens the app. - LogManager.shared.log(category: .general, message: "Live Activity dismissed by iOS after expiry — auto-restart enabled") + // Three possible sources of .dismissed — only the third blocks restart: + // + // (a) endingForRestart: our own end() during a planned restart. + // Must be checked first: handleForeground() clears laRenewalFailed + // and laRenewBy synchronously before calling end(), so those flags + // would read as "no problem" even though we initiated the dismissal. + // + // (b) iOS system force-dismiss: either laRenewalFailed is set (our 8-hour + // renewal logic marked it) or the renewal deadline has already passed + // (laRenewBy > 0 && now >= laRenewBy). In both cases iOS acted, not + // the user. laRenewBy is preserved so handleForeground() restarts on + // the next foreground. + // + // (c) User decision: the user explicitly swiped the LA away. Block + // auto-restart until forceRestart() is called. Clear laRenewBy so + // handleForeground() does NOT re-enter the renewal path on the next + // foreground — the renewal intent is cancelled by the user's choice. + let now = Date().timeIntervalSince1970 + let renewBy = Storage.shared.laRenewBy.value + let renewalFailed = Storage.shared.laRenewalFailed.value + let pastDeadline = renewBy > 0 && now >= renewBy + LogManager.shared.log(category: .general, message: "[LA] .dismissed: endingForRestart=\(endingForRestart), renewalFailed=\(renewalFailed), pastDeadline=\(pastDeadline), renewBy=\(renewBy), now=\(now)") + if endingForRestart { + // (a) Our own restart — do nothing, Task handles the rest. + LogManager.shared.log(category: .general, message: "[LA] dismissed by self (endingForRestart) — restart in-flight, no action") + } else if renewalFailed || pastDeadline { + // (b) iOS system force-dismiss — allow auto-restart on next foreground. + LogManager.shared.log(category: .general, message: "[LA] dismissed by iOS (renewalFailed=\(renewalFailed), pastDeadline=\(pastDeadline)) — auto-restart on next foreground") } else { - // User manually swiped away the LA. Block auto-restart until - // the user explicitly restarts via button or App Intent. - // laEnabled is left true — the user's preference is preserved. + // (c) User decision — cancel renewal intent, block auto-restart. dismissedByUser = true - LogManager.shared.log(category: .general, message: "Live Activity dismissed by user — auto-restart blocked until explicit restart") + Storage.shared.laRenewBy.value = 0 + LogManager.shared.log(category: .general, message: "[LA] dismissed by USER (renewBy=\(renewBy), now=\(now)) — laRenewBy cleared, auto-restart BLOCKED until forceRestart") } } } diff --git a/LoopFollow/LiveActivity/StorageCurrentGlucoseStateProvider.swift b/LoopFollow/LiveActivity/StorageCurrentGlucoseStateProvider.swift index b1a416b97..a5984ee12 100644 --- a/LoopFollow/LiveActivity/StorageCurrentGlucoseStateProvider.swift +++ b/LoopFollow/LiveActivity/StorageCurrentGlucoseStateProvider.swift @@ -10,8 +10,8 @@ struct StorageCurrentGlucoseStateProvider: CurrentGlucoseStateProviding { // MARK: - Core Glucose var glucoseMgdl: Double? { - guard let bg = Observable.shared.bg.value, bg > 0 else { return nil } - return Double(bg) + guard let bg = Storage.shared.lastBgMgdl.value, bg > 0 else { return nil } + return bg } var deltaMgdl: Double? { @@ -123,7 +123,9 @@ struct StorageCurrentGlucoseStateProvider: CurrentGlucoseStateProviding { // MARK: - Loop Status var isNotLooping: Bool { - Observable.shared.isNotLooping.value + let lastLoopTime = Storage.shared.lastLoopTime.value + guard lastLoopTime > 0, !Storage.shared.url.value.isEmpty else { return false } + return Date().timeIntervalSince1970 - lastLoopTime >= 15 * 60 } // MARK: - Renewal diff --git a/LoopFollow/LiveActivitySettingsView.swift b/LoopFollow/LiveActivitySettingsView.swift index efe4ec321..2264881bb 100644 --- a/LoopFollow/LiveActivitySettingsView.swift +++ b/LoopFollow/LiveActivitySettingsView.swift @@ -7,6 +7,7 @@ struct LiveActivitySettingsView: View { @State private var laEnabled: Bool = Storage.shared.laEnabled.value @State private var restartConfirmed = false @State private var slots: [LiveActivitySlotOption] = LAAppGroupSettings.slots() + @State private var smallWidgetSlot: LiveActivitySlotOption = LAAppGroupSettings.smallWidgetSlot() private let slotLabels = ["Top left", "Top right", "Bottom left", "Bottom right"] @@ -29,7 +30,7 @@ struct LiveActivitySettingsView: View { } } - Section(header: Text("Grid slots")) { + Section(header: Text("Grid Slots - Live Activity")) { ForEach(0 ..< 4, id: \.self) { index in Picker(slotLabels[index], selection: Binding( get: { slots[index] }, @@ -41,6 +42,21 @@ struct LiveActivitySettingsView: View { } } } + + Section(header: Text("Grid Slot - CarPlay / Watch")) { + Picker("Right slot", selection: Binding( + get: { smallWidgetSlot }, + set: { newValue in + smallWidgetSlot = newValue + LAAppGroupSettings.setSmallWidgetSlot(newValue) + LiveActivityManager.shared.refreshFromCurrentState(reason: "small widget slot changed") + } + )) { + ForEach(LiveActivitySlotOption.allCases, id: \.self) { option in + Text(option.displayName).tag(option) + } + } + } } .onReceive(Storage.shared.laEnabled.$value) { newValue in if newValue != laEnabled { laEnabled = newValue } diff --git a/LoopFollow/Log/LogManager.swift b/LoopFollow/Log/LogManager.swift index 22403cd0f..545aa586f 100644 --- a/LoopFollow/Log/LogManager.swift +++ b/LoopFollow/Log/LogManager.swift @@ -29,6 +29,7 @@ class LogManager { case calendar = "Calendar" case deviceStatus = "Device Status" case remote = "Remote" + case watch = "Watch" } init() { diff --git a/LoopFollow/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json b/LoopFollow/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json index c928251a4..500362e57 100644 --- a/LoopFollow/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/LoopFollow/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -1,41 +1,46 @@ { - "images" : [ + "images" : [ + { + "filename" : "1024.png", + "idiom" : "universal", + "platform" : "ios", + "scale" : "1x", + "size" : "1024x1024" + }, + { + "appearances" : [ { - "filename" : "1024.png", - "idiom" : "universal", - "platform" : "ios", - "scale" : "1x", - "size" : "1024x1024" - }, - { - "appearances" : [ - { - "appearance" : "luminosity", - "value" : "dark" - } - ], - "filename" : "1024-dark.png", - "idiom" : "universal", - "platform" : "ios", - "scale" : "1x", - "size" : "1024x1024" - }, + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "1024-dark.png", + "idiom" : "universal", + "platform" : "ios", + "scale" : "1x", + "size" : "1024x1024" + }, + { + "appearances" : [ { - "appearances" : [ - { - "appearance" : "luminosity", - "value" : "tinted" - } - ], - "filename" : "1024-tinted.png", - "idiom" : "universal", - "platform" : "ios", - "scale" : "1x", - "size" : "1024x1024" + "appearance" : "luminosity", + "value" : "tinted" } - ], - "info" : { - "author" : "xcode", - "version" : 1 + ], + "filename" : "1024-tinted.png", + "idiom" : "universal", + "platform" : "ios", + "scale" : "1x", + "size" : "1024x1024" + }, + { + "idiom" : "universal", + "platform" : "watchos", + "size" : "1024x1024" } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } } diff --git a/LoopFollow/Settings/GeneralSettingsView.swift b/LoopFollow/Settings/GeneralSettingsView.swift index 033e19953..93b7c8f4f 100644 --- a/LoopFollow/Settings/GeneralSettingsView.swift +++ b/LoopFollow/Settings/GeneralSettingsView.swift @@ -81,6 +81,7 @@ struct GeneralSettingsView: View { if speakBG.value { Picker("Language", selection: $speakLanguage.value) { Text("English").tag("en") + Text("French").tag("fr") Text("Italian").tag("it") Text("Slovak").tag("sk") Text("Swedish").tag("sv") diff --git a/LoopFollow/Settings/ImportExport/AlarmSelectionView.swift b/LoopFollow/Settings/ImportExport/AlarmSelectionView.swift index f69d83181..3574eb2b2 100644 --- a/LoopFollow/Settings/ImportExport/AlarmSelectionView.swift +++ b/LoopFollow/Settings/ImportExport/AlarmSelectionView.swift @@ -213,6 +213,8 @@ struct AlarmSelectionRow: View { return "Not Looping Alert" case .missedBolus: return "Missed Bolus Alert" + case .futureCarbs: + return "Future Carbs Alert" case .sensorChange: return "Sensor Change Alert" case .pumpChange: diff --git a/LoopFollow/Settings/SettingsMenuView.swift b/LoopFollow/Settings/SettingsMenuView.swift index 4668db6bf..80ae07f16 100644 --- a/LoopFollow/Settings/SettingsMenuView.swift +++ b/LoopFollow/Settings/SettingsMenuView.swift @@ -211,7 +211,7 @@ struct AggregatedStatsViewWrapper: View { var body: some View { Group { if let mainVC = mainViewController { - AggregatedStatsView(viewModel: AggregatedStatsViewModel(mainViewController: mainVC)) + AggregatedStatsContentView(mainViewController: mainVC) } else { Text("Loading stats...") .onAppear { diff --git a/LoopFollow/Stats/AggregatedStatsView.swift b/LoopFollow/Stats/AggregatedStatsView.swift index fd338c3e0..35dfeb29d 100644 --- a/LoopFollow/Stats/AggregatedStatsView.swift +++ b/LoopFollow/Stats/AggregatedStatsView.swift @@ -7,6 +7,7 @@ import UIKit struct AggregatedStatsView: View { @ObservedObject var viewModel: AggregatedStatsViewModel @Environment(\.dismiss) var dismiss + var onDismiss: (() -> Void)? @State private var showGMI: Bool @State private var showStdDev: Bool @State private var startDate: Date @@ -17,8 +18,9 @@ struct AggregatedStatsView: View { @State private var loadingTimer: Timer? @State private var timeoutTimer: Timer? - init(viewModel: AggregatedStatsViewModel) { + init(viewModel: AggregatedStatsViewModel, onDismiss: (() -> Void)? = nil) { self.viewModel = viewModel + self.onDismiss = onDismiss _showGMI = State(initialValue: Storage.shared.showGMI.value) _showStdDev = State(initialValue: Storage.shared.showStdDev.value) @@ -105,6 +107,11 @@ struct AggregatedStatsView: View { } .navigationBarTitleDisplayMode(.inline) .toolbar { + if let onDismiss { + ToolbarItem(placement: .navigationBarLeading) { + Button("Done", action: onDismiss) + } + } ToolbarItem(placement: .navigationBarTrailing) { Button("Refresh") { loadingError = false @@ -163,6 +170,38 @@ struct AggregatedStatsView: View { } } +struct AggregatedStatsContentView: View { + @StateObject private var viewModel: AggregatedStatsViewModel + private let onDismiss: (() -> Void)? + + init(mainViewController: MainViewController?, onDismiss: (() -> Void)? = nil) { + _viewModel = StateObject(wrappedValue: AggregatedStatsViewModel(mainViewController: mainViewController)) + self.onDismiss = onDismiss + } + + var body: some View { + AggregatedStatsView(viewModel: viewModel, onDismiss: onDismiss) + .preferredColorScheme(Storage.shared.appearanceMode.value.colorScheme) + } +} + +struct AggregatedStatsModalView: View { + @Environment(\.dismiss) private var dismiss + + let mainViewController: MainViewController? + + var body: some View { + NavigationView { + AggregatedStatsContentView( + mainViewController: mainViewController, + onDismiss: { dismiss() } + ) + .navigationBarTitleDisplayMode(.inline) + } + .preferredColorScheme(Storage.shared.appearanceMode.value.colorScheme) + } +} + struct StatCard: View { let title: String let value: String diff --git a/LoopFollow/Stats/GRI/GRIRiskGridView.swift b/LoopFollow/Stats/GRI/GRIRiskGridView.swift index 31559b4fd..8ff7e763b 100644 --- a/LoopFollow/Stats/GRI/GRIRiskGridView.swift +++ b/LoopFollow/Stats/GRI/GRIRiskGridView.swift @@ -52,6 +52,9 @@ struct GRIRiskGridView: UIViewRepresentable { chartView.data = nil + let xHypoValue = hypoComponent + let yHyperValue = hyperComponent + var zoneAEntries: [ChartDataEntry] = [] var zoneBEntries: [ChartDataEntry] = [] var zoneCEntries: [ChartDataEntry] = [] @@ -106,7 +109,7 @@ struct GRIRiskGridView: UIViewRepresentable { zoneEDataSet.scatterShapeSize = 4 zoneEDataSet.drawValuesEnabled = false - let currentPoint = ChartDataEntry(x: hypoComponent, y: hyperComponent) + let currentPoint = ChartDataEntry(x: xHypoValue, y: yHyperValue) let currentDataSet = ScatterChartDataSet(entries: [currentPoint], label: "Current GRI") currentDataSet.setColor(NSUIColor.label) currentDataSet.scatterShapeSize = 12 diff --git a/LoopFollow/Stats/GRI/GRIView.swift b/LoopFollow/Stats/GRI/GRIView.swift index ba8606040..cd6889008 100644 --- a/LoopFollow/Stats/GRI/GRIView.swift +++ b/LoopFollow/Stats/GRI/GRIView.swift @@ -7,6 +7,7 @@ struct GRIView: View { @ObservedObject var viewModel: GRIViewModel private let legendColumns = [GridItem(.adaptive(minimum: 90), spacing: 12, alignment: .leading)] + private let yAxisLabelInset: CGFloat = 24 var body: some View { VStack(alignment: .leading, spacing: 8) { @@ -34,22 +35,35 @@ struct GRIView: View { } if let hypo = viewModel.griHypoComponent, let hyper = viewModel.griHyperComponent { - GRIRiskGridView( - hypoComponent: hypo, - hyperComponent: hyper, - gri: viewModel.gri ?? 0 - ) - .frame(height: 250) - .allowsHitTesting(false) - .clipped() - HStack { - Text("Hypoglycemia Component (%)") - .font(.caption2) - .foregroundColor(.secondary) - Spacer() - Text("Hyperglycemia Component (%)") - .font(.caption2) - .foregroundColor(.secondary) + VStack(alignment: .leading, spacing: 6) { + ZStack(alignment: .leading) { + GRIRiskGridView( + hypoComponent: hypo, + hyperComponent: hyper, + gri: viewModel.gri ?? 0 + ) + .frame(height: 250) + .padding(.leading, yAxisLabelInset) + .allowsHitTesting(false) + .clipped() + + Text("Hyperglycemia Component (%)") + .font(.caption2) + .foregroundColor(.secondary) + .rotationEffect(.degrees(-90)) + .fixedSize() + .frame(width: yAxisLabelInset) + } + + HStack(spacing: 0) { + Spacer() + .frame(width: yAxisLabelInset) + + Text("Hypoglycemia Component (%)") + .font(.caption2) + .foregroundColor(.secondary) + .frame(maxWidth: .infinity, alignment: .center) + } } LazyVGrid(columns: legendColumns, alignment: .leading, spacing: 8) { diff --git a/LoopFollow/Storage/Storage.swift b/LoopFollow/Storage/Storage.swift index 6e24b3788..7ab8b82c7 100644 --- a/LoopFollow/Storage/Storage.swift +++ b/LoopFollow/Storage/Storage.swift @@ -59,6 +59,7 @@ class Storage { var lastRecBolusNotified = StorageValue(key: "lastRecBolusNotified", defaultValue: nil) var lastCOBNotified = StorageValue(key: "lastCOBNotified", defaultValue: nil) var lastMissedBolusNotified = StorageValue(key: "lastMissedBolusNotified", defaultValue: nil) + var pendingFutureCarbs = StorageValue<[PendingFutureCarb]>(key: "pendingFutureCarbs", defaultValue: []) // General Settings [BEGIN] var appBadge = StorageValue(key: "appBadge", defaultValue: true) @@ -87,6 +88,7 @@ class Storage { var lastBgReadingTimeSeconds = StorageValue(key: "lastBgReadingTimeSeconds", defaultValue: nil) var lastDeltaMgdl = StorageValue(key: "lastDeltaMgdl", defaultValue: nil) var lastTrendCode = StorageValue(key: "lastTrendCode", defaultValue: nil) + var lastBgMgdl = StorageValue(key: "lastBgMgdl", defaultValue: nil) var lastIOB = StorageValue(key: "lastIOB", defaultValue: nil) var lastCOB = StorageValue(key: "lastCOB", defaultValue: nil) var projectedBgMgdl = StorageValue(key: "projectedBgMgdl", defaultValue: nil) @@ -194,6 +196,7 @@ class Storage { var lastLoopingChecked = StorageValue(key: "lastLoopingChecked", defaultValue: nil) var lastBGChecked = StorageValue(key: "lastBGChecked", defaultValue: nil) + var lastLoopTime = StorageValue(key: "lastLoopTime", defaultValue: 0) // Tab positions - which position each item is in (positions 1-4 are customizable, 5 is always Menu) var homePosition = StorageValue(key: "homePosition", defaultValue: .position1) @@ -385,6 +388,7 @@ class Storage { lastLoopingChecked.reload() lastBGChecked.reload() + lastLoopTime.reload() homePosition.reload() alarmsPosition.reload() diff --git a/LoopFollow/ViewControllers/MainViewController.swift b/LoopFollow/ViewControllers/MainViewController.swift index 1a3b7c03d..d43abb2a2 100644 --- a/LoopFollow/ViewControllers/MainViewController.swift +++ b/LoopFollow/ViewControllers/MainViewController.swift @@ -15,6 +15,14 @@ func IsNightscoutEnabled() -> Bool { return !Storage.shared.url.value.isEmpty } +private struct APNSCredentialSnapshot: Equatable { + let remoteApnsKey: String + let teamId: String? + let remoteKeyId: String + let lfApnsKey: String + let lfKeyId: String +} + class MainViewController: UIViewController, UITableViewDataSource, ChartViewDelegate, UNUserNotificationCenterDelegate, UIScrollViewDelegate { var isPresentedAsModal: Bool = false @@ -145,11 +153,6 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele // Migrations run in foreground only — see runMigrationsIfNeeded() for details. runMigrationsIfNeeded() - if Storage.shared.migrationStep.value < 7 { - Storage.shared.migrateStep7() - Storage.shared.migrationStep.value = 7 - } - // Synchronize info types to ensure arrays are the correct size synchronizeInfoTypes() @@ -173,6 +176,10 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele updateGraphVisibility() statsView.isHidden = !Storage.shared.showStats.value + // Tap on stats view to open full statistics screen + let statsTap = UITapGestureRecognizer(target: self, action: #selector(statsViewTapped)) + statsView.addGestureRecognizer(statsTap) + BGChart.delegate = self BGChartFull.delegate = self @@ -372,14 +379,25 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele } .store(in: &cancellables) - Publishers.MergeMany( - Storage.shared.remoteApnsKey.$value.map { _ in () }.eraseToAnyPublisher(), - Storage.shared.teamId.$value.map { _ in () }.eraseToAnyPublisher(), - Storage.shared.remoteKeyId.$value.map { _ in () }.eraseToAnyPublisher(), - Storage.shared.lfApnsKey.$value.map { _ in () }.eraseToAnyPublisher(), - Storage.shared.lfKeyId.$value.map { _ in () }.eraseToAnyPublisher() + Publishers.CombineLatest4( + Storage.shared.remoteApnsKey.$value, + Storage.shared.teamId.$value, + Storage.shared.remoteKeyId.$value, + Storage.shared.lfApnsKey.$value ) - .receive(on: DispatchQueue.main) + .combineLatest(Storage.shared.lfKeyId.$value) + .map { values, lfKeyId in + APNSCredentialSnapshot( + remoteApnsKey: values.0, + teamId: values.1, + remoteKeyId: values.2, + lfApnsKey: values.3, + lfKeyId: lfKeyId + ) + } + .removeDuplicates() + .dropFirst() + .debounce(for: .milliseconds(500), scheduler: DispatchQueue.main) .sink { _ in JWTManager.shared.invalidateCache() } .store(in: &cancellables) @@ -636,7 +654,7 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele return treatmentsVC case .stats: - let statsVC = UIHostingController(rootView: AggregatedStatsView(viewModel: AggregatedStatsViewModel(mainViewController: nil))) + let statsVC = UIHostingController(rootView: AggregatedStatsContentView(mainViewController: nil)) let navController = UINavigationController(rootViewController: statsVC) navController.tabBarItem = UITabBarItem(title: item.displayName, image: UIImage(systemName: item.icon), tag: tag) return navController @@ -713,6 +731,22 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele return nil } + @objc private func statsViewTapped() { + #if !targetEnvironment(macCatalyst) + let position = Storage.shared.position(for: .stats).normalized + if position != .menu, let tabIndex = position.tabIndex, let tbc = tabBarController { + tbc.selectedIndex = tabIndex + return + } + #endif + + let statsModalView = AggregatedStatsModalView(mainViewController: self) + let hostingController = UIHostingController(rootView: statsModalView) + hostingController.overrideUserInterfaceStyle = Storage.shared.appearanceMode.value.userInterfaceStyle + hostingController.modalPresentationStyle = .fullScreen + present(hostingController, animated: true) + } + private func createViewController(for item: TabItem, position: TabPosition, storyboard: UIStoryboard) -> UIViewController? { let tag = position.tabIndex ?? 0 @@ -747,7 +781,7 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele return treatmentsVC case .stats: - let statsVC = UIHostingController(rootView: AggregatedStatsView(viewModel: AggregatedStatsViewModel(mainViewController: self))) + let statsVC = UIHostingController(rootView: AggregatedStatsContentView(mainViewController: self)) let navController = UINavigationController(rootViewController: statsVC) navController.tabBarItem = UITabBarItem(title: item.displayName, image: UIImage(systemName: item.icon), tag: tag) return navController @@ -962,33 +996,47 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele // Capture before migrations run: true for existing users, false for fresh installs. let isExistingUser = Storage.shared.migrationStep.exists + // Step 1: Released in v3.0.0 (2025-07-07). Can be removed after 2026-07-07. if Storage.shared.migrationStep.value < 1 { Storage.shared.migrateStep1() Storage.shared.migrationStep.value = 1 } + + // Step 2: Released in v3.1.0 (2025-07-21). Can be removed after 2026-07-21. if Storage.shared.migrationStep.value < 2 { Storage.shared.migrateStep2() Storage.shared.migrationStep.value = 2 } + + // Step 3: Released in v4.5.0 (2026-02-01). Can be removed after 2027-02-01. if Storage.shared.migrationStep.value < 3 { Storage.shared.migrateStep3() Storage.shared.migrationStep.value = 3 } - // TODO: This migration step can be deleted in March 2027. Check the commit for other places to cleanup. + + // Step 4: Released in v5.0.0 (2026-03-20). Can be removed after 2027-03-20. if Storage.shared.migrationStep.value < 4 { // Existing users need to see the fat/protein order change banner. // New users never saw the old order, so mark it as already seen. Storage.shared.hasSeenFatProteinOrderChange.value = !isExistingUser Storage.shared.migrationStep.value = 4 } + + // Step 5: Released in v5.0.0 (2026-03-20). Can be removed after 2027-03-20. if Storage.shared.migrationStep.value < 5 { Storage.shared.migrateStep5() Storage.shared.migrationStep.value = 5 } + if Storage.shared.migrationStep.value < 6 { Storage.shared.migrateStep6() Storage.shared.migrationStep.value = 6 } + + if Storage.shared.migrationStep.value < 7 { + Storage.shared.migrateStep7() + Storage.shared.migrationStep.value = 7 + } } @objc func appDidBecomeActive() { diff --git a/LoopFollow/ViewControllers/MoreMenuViewController.swift b/LoopFollow/ViewControllers/MoreMenuViewController.swift index 5f78bcaff..2693cde33 100644 --- a/LoopFollow/ViewControllers/MoreMenuViewController.swift +++ b/LoopFollow/ViewControllers/MoreMenuViewController.swift @@ -314,9 +314,8 @@ class MoreMenuViewController: UIViewController { return } - let statsVC = UIHostingController( - rootView: AggregatedStatsView(viewModel: AggregatedStatsViewModel(mainViewController: mainVC)) - ) + let statsView = AggregatedStatsContentView(mainViewController: mainVC) + let statsVC = UIHostingController(rootView: statsView) statsVC.overrideUserInterfaceStyle = Storage.shared.appearanceMode.value.userInterfaceStyle navigationController?.pushViewController(statsVC, animated: true) } diff --git a/LoopFollow/WatchComplication/ComplicationEntryBuilder.swift b/LoopFollow/WatchComplication/ComplicationEntryBuilder.swift new file mode 100644 index 000000000..b710eba0c --- /dev/null +++ b/LoopFollow/WatchComplication/ComplicationEntryBuilder.swift @@ -0,0 +1,228 @@ +// ComplicationEntryBuilder.swift +// Philippe Achkar +// 2026-03-25 + +import ClockKit + +// MARK: - Complication identifiers + +enum ComplicationID { + /// graphicCircular + graphicCorner with gauge arc (Complication 1). + static let gaugeCorner = "LoopFollowGaugeCorner" + /// graphicCorner stacked text only (Complication 2). + static let stackCorner = "LoopFollowStackCorner" + // DEBUG COMPLICATION — commented out, not deleted. + // Shows two timestamps to isolate pipeline failures: + // outer (top): HH:mm of snapshot.updatedAt — when CGM data last reached the Watch + // inner (↺): HH:mm when ClockKit last called getCurrentTimelineEntry + // If outer changes but inner is stale → reloadTimeline() not firing or ClockKit ignoring it. + // If inner changes but outer is stale → data delivery broken, complication rebuilding with old data. + // To re-enable: uncomment this line and the three switch cases below, and the descriptor in WatchComplicationProvider. + // static let debugCorner = "LoopFollowDebugCorner" +} + +// MARK: - Entry builder + +enum ComplicationEntryBuilder { + + // MARK: - Live template + + static func template( + for family: CLKComplicationFamily, + snapshot: GlucoseSnapshot, + identifier: String + ) -> CLKComplicationTemplate? { + switch family { + case .graphicCircular: + return graphicCircularTemplate(snapshot: snapshot) + case .graphicCorner: + switch identifier { + case ComplicationID.stackCorner: return graphicCornerStackTemplate(snapshot: snapshot) + // case ComplicationID.debugCorner: return graphicCornerDebugTemplate(snapshot: snapshot) + default: return graphicCornerGaugeTemplate(snapshot: snapshot) + } + default: + return nil + } + } + + // MARK: - Stale template + + static func staleTemplate(for family: CLKComplicationFamily, identifier: String) -> CLKComplicationTemplate? { + switch family { + case .graphicCircular: + return CLKComplicationTemplateGraphicCircularStackText( + line1TextProvider: CLKSimpleTextProvider(text: "--"), + line2TextProvider: CLKSimpleTextProvider(text: "") + ) + case .graphicCorner: + switch identifier { + case ComplicationID.stackCorner: + return CLKComplicationTemplateGraphicCornerStackText( + innerTextProvider: CLKSimpleTextProvider(text: ""), + outerTextProvider: CLKSimpleTextProvider(text: "--") + ) + // case ComplicationID.debugCorner: + // return CLKComplicationTemplateGraphicCornerStackText( + // innerTextProvider: CLKSimpleTextProvider(text: "STALE"), + // outerTextProvider: CLKSimpleTextProvider(text: "--:--") + // ) + default: + return staleGaugeTemplate() + } + default: + return nil + } + } + + // MARK: - Placeholder template + + static func placeholderTemplate(for family: CLKComplicationFamily, identifier: String) -> CLKComplicationTemplate? { + switch family { + case .graphicCircular: + return CLKComplicationTemplateGraphicCircularStackText( + line1TextProvider: CLKSimpleTextProvider(text: "---"), + line2TextProvider: CLKSimpleTextProvider(text: "→") + ) + case .graphicCorner: + switch identifier { + case ComplicationID.stackCorner: + let outer = CLKSimpleTextProvider(text: "---") + outer.tintColor = .green + return CLKComplicationTemplateGraphicCornerStackText( + innerTextProvider: CLKSimpleTextProvider(text: "→ --"), + outerTextProvider: outer + ) + // case ComplicationID.debugCorner: + // return CLKComplicationTemplateGraphicCornerStackText( + // innerTextProvider: CLKSimpleTextProvider(text: "DEBUG"), + // outerTextProvider: CLKSimpleTextProvider(text: "--:--") + // ) + default: + let outer = CLKSimpleTextProvider(text: "---") + outer.tintColor = .green + let gauge = CLKSimpleGaugeProvider(style: .fill, gaugeColor: .green, fillFraction: 0) + return CLKComplicationTemplateGraphicCornerGaugeText( + gaugeProvider: gauge, + leadingTextProvider: CLKSimpleTextProvider(text: "0"), + trailingTextProvider: nil, + outerTextProvider: outer + ) + } + default: + return nil + } + } + + // MARK: - Graphic Circular + // BG (top, colored) + trend arrow (bottom). + + private static func graphicCircularTemplate(snapshot: GlucoseSnapshot) -> CLKComplicationTemplate { + let bgText = CLKSimpleTextProvider(text: WatchFormat.glucose(snapshot)) + bgText.tintColor = thresholdColor(for: snapshot) + + return CLKComplicationTemplateGraphicCircularStackText( + line1TextProvider: bgText, + line2TextProvider: CLKSimpleTextProvider(text: WatchFormat.trendArrow(snapshot)) + ) + } + + // MARK: - Graphic Corner — Gauge Text (Complication 1) + // Gauge arc fills from 0 (fresh) to 100% (15 min stale). + // Outer text: BG (colored). Leading text: delta. + // Stale / isNotLooping → "⚠" in yellow, gauge full. + + private static func graphicCornerGaugeTemplate(snapshot: GlucoseSnapshot) -> CLKComplicationTemplate { + guard snapshot.age < 900, !snapshot.isNotLooping else { + return staleGaugeTemplate() + } + + let fraction = Float(min(snapshot.age / 900.0, 1.0)) + let color = thresholdColor(for: snapshot) + + let bgText = CLKSimpleTextProvider(text: WatchFormat.glucose(snapshot)) + bgText.tintColor = color + + let gauge = CLKSimpleGaugeProvider(style: .fill, gaugeColor: color, fillFraction: fraction) + + return CLKComplicationTemplateGraphicCornerGaugeText( + gaugeProvider: gauge, + leadingTextProvider: CLKSimpleTextProvider(text: WatchFormat.delta(snapshot)), + trailingTextProvider: nil, + outerTextProvider: bgText + ) + } + + private static func staleGaugeTemplate() -> CLKComplicationTemplate { + let warnText = CLKSimpleTextProvider(text: "⚠") + warnText.tintColor = .yellow + + let gauge = CLKSimpleGaugeProvider(style: .fill, gaugeColor: .yellow, fillFraction: 1.0) + + return CLKComplicationTemplateGraphicCornerGaugeText( + gaugeProvider: gauge, + leadingTextProvider: nil, + trailingTextProvider: nil, + outerTextProvider: warnText + ) + } + + // MARK: - Graphic Corner — Stacked Text (Complication 2) + // Outer (top, large): BG value, colored. + // Inner (bottom, small): "→ projected" (falls back to delta if no projection). + // Stale / isNotLooping: outer = "--", inner = "". + + private static func graphicCornerStackTemplate(snapshot: GlucoseSnapshot) -> CLKComplicationTemplate { + guard snapshot.age < 900, !snapshot.isNotLooping else { + return CLKComplicationTemplateGraphicCornerStackText( + innerTextProvider: CLKSimpleTextProvider(text: ""), + outerTextProvider: CLKSimpleTextProvider(text: "--") + ) + } + + let bgText = CLKSimpleTextProvider(text: WatchFormat.glucose(snapshot)) + bgText.tintColor = thresholdColor(for: snapshot) + + let bottomLabel: String + if let _ = snapshot.projected { + // ⇢ = dashed arrow (U+21E2); swap for ▸ (U+25B8) if it renders poorly on-device + bottomLabel = "\(WatchFormat.delta(snapshot)) | ⇢\(WatchFormat.projected(snapshot))" + } else { + bottomLabel = WatchFormat.delta(snapshot) + } + + return CLKComplicationTemplateGraphicCornerStackText( + innerTextProvider: CLKSimpleTextProvider(text: bottomLabel), + outerTextProvider: bgText + ) + } + + // MARK: - Graphic Corner — Debug (Complication 3) + // Outer (top): HH:mm of the snapshot's updatedAt — when the CGM reading arrived. + // Inner (bottom): "↺ HH:mm" — when ClockKit last called getCurrentTimelineEntry. + // + // Reading the two times tells you: + // outer changes → Watch is receiving new data + // inner changes → ClockKit is refreshing the complication face + // inner stale → reloadTimeline is not being called or ClockKit is ignoring it + + private static func graphicCornerDebugTemplate(snapshot: GlucoseSnapshot) -> CLKComplicationTemplate { + let dataTime = WatchFormat.updateTime(snapshot) + let buildTime = WatchFormat.currentTime() + + return CLKComplicationTemplateGraphicCornerStackText( + innerTextProvider: CLKSimpleTextProvider(text: "↺ \(buildTime)"), + outerTextProvider: CLKSimpleTextProvider(text: dataTime) + ) + } + + // MARK: - Threshold color + + /// snapshot.glucose is always in mg/dL (builder stores canonical mg/dL). + static func thresholdColor(for snapshot: GlucoseSnapshot) -> UIColor { + let t = LAAppGroupSettings.thresholdsMgdl() + if snapshot.glucose < t.low { return .red } + if snapshot.glucose > t.high { return .orange } + return .green + } +} diff --git a/LoopFollow/WatchComplication/WatchComplicationProvider.swift b/LoopFollow/WatchComplication/WatchComplicationProvider.swift new file mode 100644 index 000000000..7377529c1 --- /dev/null +++ b/LoopFollow/WatchComplication/WatchComplicationProvider.swift @@ -0,0 +1,116 @@ +// WatchComplicationProvider.swift +// Philippe Achkar +// 2026-03-10 + +import ClockKit +import Foundation +import os.log + +private let watchLog = OSLog( + subsystem: Bundle.main.bundleIdentifier ?? "com.loopfollow.watch", + category: "Watch" +) + +final class WatchComplicationProvider: NSObject, CLKComplicationDataSource { + + // MARK: - Complication Descriptors + + func getComplicationDescriptors(handler: @escaping ([CLKComplicationDescriptor]) -> Void) { + let descriptors = [ + // Complication 1: BG + gauge arc (graphicCircular + graphicCorner) + CLKComplicationDescriptor( + identifier: ComplicationID.gaugeCorner, + displayName: "LoopFollow", + supportedFamilies: [.graphicCircular, .graphicCorner] + ), + // Complication 2: BG + projected BG stacked text (graphicCorner only) + CLKComplicationDescriptor( + identifier: ComplicationID.stackCorner, + displayName: "LoopFollow Text", + supportedFamilies: [.graphicCorner] + ), + // DEBUG COMPLICATION — commented out. To re-enable, uncomment this descriptor + // and the corresponding cases in ComplicationEntryBuilder + the debugCorner ID. + // CLKComplicationDescriptor( + // identifier: ComplicationID.debugCorner, + // displayName: "LoopFollow Debug", + // supportedFamilies: [.graphicCorner] + // ) + ] + handler(descriptors) + } + + // MARK: - Timeline + + func getCurrentTimelineEntry( + for complication: CLKComplication, + withHandler handler: @escaping (CLKComplicationTimelineEntry?) -> Void + ) { + guard let snapshot = GlucoseSnapshotStore.shared.load() else { + os_log("WatchComplicationProvider: no snapshot available", log: watchLog, type: .debug) + handler(nil) + return + } + + guard snapshot.age < 900 else { + os_log("WatchComplicationProvider: snapshot stale (%d s)", log: watchLog, type: .debug, Int(snapshot.age)) + handler(staleEntry(for: complication)) + return + } + + let template = ComplicationEntryBuilder.template( + for: complication.family, + snapshot: snapshot, + identifier: complication.identifier + ) + let entry = template.map { + CLKComplicationTimelineEntry(date: snapshot.updatedAt, complicationTemplate: $0) + } + handler(entry) + } + + func getTimelineEndDate( + for complication: CLKComplication, + withHandler handler: @escaping (Date?) -> Void + ) { + // Expire timeline 15 minutes after last reading + // so Watch does not display indefinitely stale data + if let snapshot = GlucoseSnapshotStore.shared.load() { + handler(snapshot.updatedAt.addingTimeInterval(900)) + } else { + handler(nil) + } + } + + func getPrivacyBehavior( + for complication: CLKComplication, + withHandler handler: @escaping (CLKComplicationPrivacyBehavior) -> Void + ) { + // Glucose is sensitive — hide on locked watch face + handler(.hideOnLockScreen) + } + + // MARK: - Placeholder + + func getLocalizableSampleTemplate( + for complication: CLKComplication, + withHandler handler: @escaping (CLKComplicationTemplate?) -> Void + ) { + handler(ComplicationEntryBuilder.placeholderTemplate( + for: complication.family, + identifier: complication.identifier + )) + } + + // MARK: - Private + + private func staleEntry(for complication: CLKComplication) -> CLKComplicationTimelineEntry? { + let template = ComplicationEntryBuilder.staleTemplate( + for: complication.family, + identifier: complication.identifier + ) + return template.map { + CLKComplicationTimelineEntry(date: Date(), complicationTemplate: $0) + } + } +} diff --git a/LoopFollow/WatchComplication/WatchFormat.swift b/LoopFollow/WatchComplication/WatchFormat.swift new file mode 100644 index 000000000..acc7e9ec2 --- /dev/null +++ b/LoopFollow/WatchComplication/WatchFormat.swift @@ -0,0 +1,197 @@ +// WatchFormat.swift +// Philippe Achkar +// 2026-03-25 + +import Foundation + +/// Formatting helpers for Watch complications and Watch app UI. +/// All glucose values in GlucoseSnapshot are stored in mg/dL; this module +/// converts to mmol/L for display when snapshot.unit == .mmol. +enum WatchFormat { + + // MARK: - Glucose + + static func glucose(_ s: GlucoseSnapshot) -> String { + formatGlucoseValue(s.glucose, unit: s.unit) + } + + static func delta(_ s: GlucoseSnapshot) -> String { + switch s.unit { + case .mgdl: + let v = Int(round(s.delta)) + if v == 0 { return "0" } + return v > 0 ? "+\(v)" : "\(v)" + case .mmol: + let mmol = GlucoseConversion.toMmol(s.delta) + let d = abs(mmol) < 0.05 ? 0.0 : mmol + if d == 0 { return "0.0" } + let str = String(format: "%.1f", abs(d)) + return d > 0 ? "+\(str)" : "-\(str)" + } + } + + static func projected(_ s: GlucoseSnapshot) -> String { + guard let v = s.projected else { return "—" } + return formatGlucoseValue(v, unit: s.unit) + } + + static func trendArrow(_ s: GlucoseSnapshot) -> String { + switch s.trend { + case .upFast: return "↑↑" + case .up: return "↑" + case .upSlight: return "↗" + case .flat: return "→" + case .downSlight: return "↘" + case .down: return "↓" + case .downFast: return "↓↓" + case .unknown: return "–" + } + } + + // MARK: - Time + + /// "Xm" from now, capped display at 99m + static func minAgo(_ s: GlucoseSnapshot) -> String { + let mins = Int(s.age / 60) + return "\(min(mins, 99))m" + } + + /// Time of last update formatted as "HH:mm" + static func updateTime(_ s: GlucoseSnapshot) -> String { + let f = DateFormatter() + f.dateFormat = "HH:mm" + return f.string(from: s.updatedAt) + } + + static func currentTime() -> String { + let f = DateFormatter() + f.dateFormat = "HH:mm" + return f.string(from: Date()) + } + + // MARK: - Secondary metrics + + static func iob(_ s: GlucoseSnapshot) -> String { + guard let v = s.iob else { return "—" } + return String(format: "%.1f", v) + } + + static func cob(_ s: GlucoseSnapshot) -> String { + guard let v = s.cob else { return "—" } + return "\(Int(round(v)))" + } + + static func battery(_ s: GlucoseSnapshot) -> String { + guard let v = s.battery else { return "—" } + return String(format: "%.0f%%", v) + } + + static func pumpBattery(_ s: GlucoseSnapshot) -> String { + guard let v = s.pumpBattery else { return "—" } + return String(format: "%.0f%%", v) + } + + static func pump(_ s: GlucoseSnapshot) -> String { + guard let v = s.pumpReservoirU else { return "50+U" } + return "\(Int(round(v)))U" + } + + static func recBolus(_ s: GlucoseSnapshot) -> String { + guard let v = s.recBolus else { return "—" } + return String(format: "%.2fU", v) + } + + static func autosens(_ s: GlucoseSnapshot) -> String { + guard let v = s.autosens else { return "—" } + return String(format: "%.0f%%", v * 100) + } + + static func tdd(_ s: GlucoseSnapshot) -> String { + guard let v = s.tdd else { return "—" } + return String(format: "%.1fU", v) + } + + static func basal(_ s: GlucoseSnapshot) -> String { + s.basalRate.isEmpty ? "—" : s.basalRate + } + + static func target(_ s: GlucoseSnapshot) -> String { + guard let low = s.targetLowMgdl, low > 0 else { return "—" } + let lowStr = formatGlucoseValue(low, unit: s.unit) + if let high = s.targetHighMgdl, high > 0, abs(high - low) > 0.5 { + return "\(lowStr)-\(formatGlucoseValue(high, unit: s.unit))" + } + return lowStr + } + + static func isf(_ s: GlucoseSnapshot) -> String { + guard let v = s.isfMgdlPerU, v > 0 else { return "—" } + return formatGlucoseValue(v, unit: s.unit) + } + + static func carbRatio(_ s: GlucoseSnapshot) -> String { + guard let v = s.carbRatio, v > 0 else { return "—" } + return String(format: "%.0fg", v) + } + + static func carbsToday(_ s: GlucoseSnapshot) -> String { + guard let v = s.carbsToday else { return "—" } + return "\(Int(round(v)))g" + } + + static func minMax(_ s: GlucoseSnapshot) -> String { + guard let mn = s.minBgMgdl, let mx = s.maxBgMgdl else { return "—" } + return "\(formatGlucoseValue(mn, unit: s.unit))/\(formatGlucoseValue(mx, unit: s.unit))" + } + + static func age(insertTime: TimeInterval) -> String { + guard insertTime > 0 else { return "—" } + let secs = Date().timeIntervalSince1970 - insertTime + let days = Int(secs / 86400) + let hours = Int(secs.truncatingRemainder(dividingBy: 86400) / 3600) + return days > 0 ? "\(days)d\(hours)h" : "\(hours)h" + } + + static func override(_ s: GlucoseSnapshot) -> String { s.override ?? "—" } + static func profileName(_ s: GlucoseSnapshot) -> String { s.profileName ?? "—" } + + // MARK: - Slot dispatch + + static func slotValue(option: LiveActivitySlotOption, snapshot s: GlucoseSnapshot) -> String { + switch option { + case .none: return "" + case .delta: return delta(s) + case .projectedBG: return projected(s) + case .minMax: return minMax(s) + case .iob: return iob(s) + case .cob: return cob(s) + case .recBolus: return recBolus(s) + case .autosens: return autosens(s) + case .tdd: return tdd(s) + case .basal: return basal(s) + case .pump: return pump(s) + case .pumpBattery: return pumpBattery(s) + case .battery: return battery(s) + case .target: return target(s) + case .isf: return isf(s) + case .carbRatio: return carbRatio(s) + case .sage: return age(insertTime: s.sageInsertTime) + case .cage: return age(insertTime: s.cageInsertTime) + case .iage: return age(insertTime: s.iageInsertTime) + case .carbsToday: return carbsToday(s) + case .override: return override(s) + case .profile: return profileName(s) + } + } + + // MARK: - Private + + private static func formatGlucoseValue(_ mgdl: Double, unit: GlucoseSnapshot.Unit) -> String { + switch unit { + case .mgdl: + return "\(Int(round(mgdl)))" + case .mmol: + return String(format: "%.1f", GlucoseConversion.toMmol(mgdl)) + } + } +} diff --git a/LoopFollow/WatchComplication/WatchSessionReceiver.swift b/LoopFollow/WatchComplication/WatchSessionReceiver.swift new file mode 100644 index 000000000..588da5f17 --- /dev/null +++ b/LoopFollow/WatchComplication/WatchSessionReceiver.swift @@ -0,0 +1,183 @@ +// WatchSessionReceiver.swift +// Philippe Achkar +// 2026-03-10 + +import Foundation +import WatchConnectivity +import ClockKit +import WatchKit +import os.log + +private let watchLog = OSLog( + subsystem: Bundle.main.bundleIdentifier ?? "com.loopfollow.watch", + category: "Watch" +) + +final class WatchSessionReceiver: NSObject { + + // MARK: - Shared Instance + + static let shared = WatchSessionReceiver() + + static let snapshotReceivedNotification = Notification.Name("WatchSnapshotReceived") + + /// Held open while WatchConnectivity delivers a pending transferUserInfo in the background. + /// Completed after the snapshot is saved to disk. + var pendingConnectivityTask: WKWatchConnectivityRefreshBackgroundTask? + + // MARK: - Init + + private override init() { + super.init() + } + + // MARK: - Setup + + /// Call once from the Watch extension entry point after launch. + func activate() { + guard WCSession.isSupported() else { + os_log("WatchSessionReceiver: WCSession not supported", log: watchLog, type: .debug) + return + } + WCSession.default.delegate = self + WCSession.default.activate() + os_log("WatchSessionReceiver: WCSession activation requested", log: watchLog, type: .debug) + } + + /// Triggers a complication timeline reload. Called from background refresh tasks + /// after a snapshot has already been saved to GlucoseSnapshotStore. + func triggerComplicationReload() { + reloadComplications() + } +} + +// MARK: - WCSessionDelegate + +extension WatchSessionReceiver: WCSessionDelegate { + + func session( + _ session: WCSession, + activationDidCompleteWith activationState: WCSessionActivationState, + error: Error? + ) { + if let error = error { + os_log("WatchSessionReceiver: activation failed — %{public}@", log: watchLog, type: .error, error.localizedDescription) + } else { + os_log("WatchSessionReceiver: activation complete — state %d", log: watchLog, type: .debug, activationState.rawValue) + bootstrapFromApplicationContext(session) + } + } + + /// Loads a snapshot from the last received applicationContext so the Watch app + /// has data immediately on launch without waiting for the next transferUserInfo. + private func bootstrapFromApplicationContext(_ session: WCSession) { + guard let data = session.receivedApplicationContext["snapshot"] as? Data else { return } + do { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + let snapshot = try decoder.decode(GlucoseSnapshot.self, from: data) + GlucoseSnapshotStore.shared.save(snapshot) { [weak self] in + os_log("WatchSessionReceiver: bootstrapped snapshot from applicationContext", log: watchLog, type: .debug) + self?.reloadComplications() + DispatchQueue.main.async { + NotificationCenter.default.post( + name: WatchSessionReceiver.snapshotReceivedNotification, + object: nil, + userInfo: ["snapshot": snapshot] + ) + } + } + } catch { + os_log("WatchSessionReceiver: failed to decode applicationContext snapshot — %{public}@", log: watchLog, type: .error, error.localizedDescription) + } + } + + /// Handles immediate delivery when Watch app is in foreground (sendMessage path). + func session( + _ session: WCSession, + didReceiveMessage message: [String: Any] + ) { + process(payload: message, source: "sendMessage") + } + + /// Handles queued background delivery (transferUserInfo path). + func session( + _ session: WCSession, + didReceiveUserInfo userInfo: [String: Any] + ) { + process(payload: userInfo, source: "userInfo") + } + + // MARK: - Private + + private func process(payload: [String: Any], source: String) { + guard let data = payload["snapshot"] as? Data else { + os_log("WatchSessionReceiver: %{public}@ — no snapshot key", log: watchLog, type: .debug, source) + return + } + do { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + let snapshot = try decoder.decode(GlucoseSnapshot.self, from: data) + os_log("WatchSessionReceiver: %{public}@ snapshot decoded, saving", log: watchLog, type: .debug, source) + GlucoseSnapshotStore.shared.save(snapshot) { [weak self] in + os_log("WatchSessionReceiver: %{public}@ snapshot saved, reloading complications", log: watchLog, type: .debug, source) + // ACK to iPhone so it can detect missed deliveries. + self?.sendAck(for: snapshot) + // Capture and clear the pending task before dispatching to main, + // then complete it AFTER reloadTimeline() so watchOS doesn't suspend + // the extension before ClockKit processes the reload request. + let task = self?.pendingConnectivityTask + self?.pendingConnectivityTask = nil + DispatchQueue.main.async { + let server = CLKComplicationServer.sharedInstance() + if let complications = server.activeComplications, !complications.isEmpty { + for complication in complications { server.reloadTimeline(for: complication) } + os_log("WatchSessionReceiver: reloaded %d complication(s)", log: watchLog, type: .debug, complications.count) + } else { + os_log("WatchSessionReceiver: no active complications to reload", log: watchLog, type: .debug) + } + // Complete background task only after reloadTimeline() has been called. + task?.setTaskCompletedWithSnapshot(false) + NotificationCenter.default.post( + name: WatchSessionReceiver.snapshotReceivedNotification, + object: nil, + userInfo: ["snapshot": snapshot] + ) + } + } + } catch { + os_log("WatchSessionReceiver: %{public}@ decode failed — %{public}@", log: watchLog, type: .error, source, error.localizedDescription) + } + } + + private func sendAck(for snapshot: GlucoseSnapshot) { + let session = WCSession.default + guard session.activationState == .activated else { return } + let ack: [String: Any] = ["watchAck": snapshot.updatedAt.timeIntervalSince1970] + if session.isReachable { + session.sendMessage(ack, replyHandler: nil, errorHandler: nil) + } else { + session.transferUserInfo(ack) + } + os_log("WatchSessionReceiver: ACK sent for snapshot at %f", log: watchLog, type: .debug, snapshot.updatedAt.timeIntervalSince1970) + } + + private func reloadComplications() { + DispatchQueue.main.async { + let server = CLKComplicationServer.sharedInstance() + guard let complications = server.activeComplications, !complications.isEmpty else { + os_log("WatchSessionReceiver: no active complications to reload", log: watchLog, type: .debug) + return + } + for complication in complications { server.reloadTimeline(for: complication) } + os_log("WatchSessionReceiver: reloaded %d complication(s)", log: watchLog, type: .debug, complications.count) + } + } + + // NOTE: reloadComplications() is safe to call from any thread for foreground paths + // (bootstrap, reloadComplicationsIfNeeded). For background task paths (process()), + // setTaskCompletedWithSnapshot() must be called INSIDE DispatchQueue.main.async + // after reloadTimeline() — otherwise watchOS suspends the extension before ClockKit + // receives the reload request. +} diff --git a/LoopFollowLAExtension/LoopFollowLiveActivity.swift b/LoopFollowLAExtension/LoopFollowLiveActivity.swift index f388dfbf9..14a513674 100644 --- a/LoopFollowLAExtension/LoopFollowLiveActivity.swift +++ b/LoopFollowLAExtension/LoopFollowLiveActivity.swift @@ -117,14 +117,17 @@ private struct LockScreenFamilyAdaptiveView: View { private struct SmallFamilyView: View { let snapshot: GlucoseSnapshot - private var unitLabel: String { - switch snapshot.unit { - case .mgdl: return "mg/dL" - case .mmol: return "mmol/L" - } + /// Unit label for the right slot — ISF appends "/U", other glucose slots + /// use the plain glucose unit, non-glucose slots return nil. + private func rightSlotUnitLabel(for slot: LiveActivitySlotOption) -> String? { + guard slot.isGlucoseUnit else { return nil } + if slot == .isf { return snapshot.unit.displayName + "/U" } + return snapshot.unit.displayName } var body: some View { + let rightSlot = LAAppGroupSettings.smallWidgetSlot() + HStack(alignment: .center, spacing: 0) { VStack(alignment: .leading, spacing: 2) { HStack(alignment: .firstTextBaseline, spacing: 4) { @@ -138,23 +141,60 @@ private struct SmallFamilyView: View { .foregroundStyle(LAColors.keyline(for: snapshot)) } - Text("\(LAFormat.delta(snapshot)) \(unitLabel)") + Text("\(LAFormat.delta(snapshot)) \(snapshot.unit.displayName)") .font(.system(size: 14, weight: .semibold, design: .rounded)) .monospacedDigit() .foregroundStyle(.white.opacity(0.85)) } + .layoutPriority(1) Spacer() - VStack(alignment: .trailing, spacing: 2) { - Text(LAFormat.projected(snapshot)) - .font(.system(size: 28, weight: .bold, design: .rounded)) - .monospacedDigit() - .foregroundStyle(.white) - - Text(unitLabel) - .font(.system(size: 14, weight: .regular, design: .rounded)) - .foregroundStyle(.white.opacity(0.65)) + if rightSlot != .none { + if let unitLabel = rightSlotUnitLabel(for: rightSlot) { + // Use ViewThatFits so the unit label appears on surfaces with + // enough vertical space (CarPlay) and is omitted where it doesn't + // fit (Watch Smart Stack). + ViewThatFits(in: .vertical) { + VStack(alignment: .trailing, spacing: 2) { + Text(rightSlot.gridLabel) + .font(.system(size: 12, weight: .semibold, design: .rounded)) + .foregroundStyle(.white.opacity(0.65)) + Text(slotFormattedValue(option: rightSlot, snapshot: snapshot)) + .font(.system(size: 20, weight: .bold, design: .rounded)) + .monospacedDigit() + .foregroundStyle(.white) + .lineLimit(1) + .minimumScaleFactor(0.8) + Text(unitLabel) + .font(.system(size: 11, weight: .regular, design: .rounded)) + .foregroundStyle(.white.opacity(0.55)) + } + VStack(alignment: .trailing, spacing: 2) { + Text(rightSlot.gridLabel) + .font(.system(size: 12, weight: .semibold, design: .rounded)) + .foregroundStyle(.white.opacity(0.65)) + Text(slotFormattedValue(option: rightSlot, snapshot: snapshot)) + .font(.system(size: 20, weight: .bold, design: .rounded)) + .monospacedDigit() + .foregroundStyle(.white) + .lineLimit(1) + .minimumScaleFactor(0.8) + } + } + } else { + VStack(alignment: .trailing, spacing: 2) { + Text(rightSlot.gridLabel) + .font(.system(size: 12, weight: .semibold, design: .rounded)) + .foregroundStyle(.white.opacity(0.65)) + Text(slotFormattedValue(option: rightSlot, snapshot: snapshot)) + .font(.system(size: 20, weight: .bold, design: .rounded)) + .monospacedDigit() + .foregroundStyle(.white) + .lineLimit(1) + .minimumScaleFactor(0.8) + } + } } } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) @@ -303,6 +343,33 @@ private struct MetricBlock: View { } } +private func slotFormattedValue(option: LiveActivitySlotOption, snapshot: GlucoseSnapshot) -> String { + switch option { + case .none: "" + case .delta: LAFormat.delta(snapshot) + case .projectedBG: LAFormat.projected(snapshot) + case .minMax: LAFormat.minMax(snapshot) + case .iob: LAFormat.iob(snapshot) + case .cob: LAFormat.cob(snapshot) + case .recBolus: LAFormat.recBolus(snapshot) + case .autosens: LAFormat.autosens(snapshot) + case .tdd: LAFormat.tdd(snapshot) + case .basal: LAFormat.basal(snapshot) + case .pump: LAFormat.pump(snapshot) + case .pumpBattery: LAFormat.pumpBattery(snapshot) + case .battery: LAFormat.battery(snapshot) + case .target: LAFormat.target(snapshot) + case .isf: LAFormat.isf(snapshot) + case .carbRatio: LAFormat.carbRatio(snapshot) + case .sage: LAFormat.age(insertTime: snapshot.sageInsertTime) + case .cage: LAFormat.age(insertTime: snapshot.cageInsertTime) + case .iage: LAFormat.age(insertTime: snapshot.iageInsertTime) + case .carbsToday: LAFormat.carbsToday(snapshot) + case .override: LAFormat.override(snapshot) + case .profile: LAFormat.profileName(snapshot) + } +} + private struct SlotView: View { let option: LiveActivitySlotOption let snapshot: GlucoseSnapshot @@ -312,34 +379,7 @@ private struct SlotView: View { Color.clear .frame(width: 60, height: 36) } else { - MetricBlock(label: option.gridLabel, value: value(for: option)) - } - } - - private func value(for option: LiveActivitySlotOption) -> String { - switch option { - case .none: "" - case .delta: LAFormat.delta(snapshot) - case .projectedBG: LAFormat.projected(snapshot) - case .minMax: LAFormat.minMax(snapshot) - case .iob: LAFormat.iob(snapshot) - case .cob: LAFormat.cob(snapshot) - case .recBolus: LAFormat.recBolus(snapshot) - case .autosens: LAFormat.autosens(snapshot) - case .tdd: LAFormat.tdd(snapshot) - case .basal: LAFormat.basal(snapshot) - case .pump: LAFormat.pump(snapshot) - case .pumpBattery: LAFormat.pumpBattery(snapshot) - case .battery: LAFormat.battery(snapshot) - case .target: LAFormat.target(snapshot) - case .isf: LAFormat.isf(snapshot) - case .carbRatio: LAFormat.carbRatio(snapshot) - case .sage: LAFormat.age(insertTime: snapshot.sageInsertTime) - case .cage: LAFormat.age(insertTime: snapshot.cageInsertTime) - case .iage: LAFormat.age(insertTime: snapshot.iageInsertTime) - case .carbsToday: LAFormat.carbsToday(snapshot) - case .override: LAFormat.override(snapshot) - case .profile: LAFormat.profileName(snapshot) + MetricBlock(label: option.gridLabel, value: slotFormattedValue(option: option, snapshot: snapshot)) } } } @@ -359,26 +399,19 @@ private struct DynamicIslandLeadingView: View { .minimumScaleFactor(0.7) } else { VStack(alignment: .leading, spacing: 2) { - Text(LAFormat.glucose(snapshot)) - .font(.system(size: 28, weight: .bold, design: .rounded)) - .monospacedDigit() - .foregroundStyle(.white) - - HStack(spacing: 5) { - Text(LAFormat.trendArrow(snapshot)) - .font(.system(size: 13, weight: .semibold, design: .rounded)) - .foregroundStyle(.white.opacity(0.9)) - - Text(LAFormat.delta(snapshot)) - .font(.system(size: 13, weight: .semibold, design: .rounded)) - .monospacedDigit() - .foregroundStyle(.white.opacity(0.9)) - - Text("Proj: \(LAFormat.projected(snapshot))") - .font(.system(size: 13, weight: .semibold, design: .rounded)) + HStack(alignment: .firstTextBaseline, spacing: 4) { + Text(LAFormat.glucose(snapshot)) + .font(.system(size: 28, weight: .bold, design: .rounded)) .monospacedDigit() - .foregroundStyle(.white.opacity(0.9)) + .foregroundStyle(LAColors.keyline(for: snapshot)) + Text(LAFormat.trendArrow(snapshot)) + .font(.system(size: 22, weight: .semibold, design: .rounded)) + .foregroundStyle(LAColors.keyline(for: snapshot)) } + Text("\(LAFormat.delta(snapshot)) \(snapshot.unit.displayName)") + .font(.system(size: 13, weight: .semibold, design: .rounded)) + .monospacedDigit() + .foregroundStyle(.white.opacity(0.85)) } } } @@ -391,18 +424,21 @@ private struct DynamicIslandTrailingView: View { if snapshot.isNotLooping { EmptyView() } else { - VStack(alignment: .trailing, spacing: 3) { - Text("IOB \(LAFormat.iob(snapshot))") - .font(.system(size: 13, weight: .bold, design: .rounded)) - .monospacedDigit() - .foregroundStyle(.white.opacity(0.95)) - - Text("COB \(LAFormat.cob(snapshot))") - .font(.system(size: 13, weight: .bold, design: .rounded)) - .monospacedDigit() - .foregroundStyle(.white.opacity(0.95)) + let slot = LAAppGroupSettings.smallWidgetSlot() + if slot != .none { + VStack(alignment: .trailing, spacing: 2) { + Text(slot.gridLabel) + .font(.system(size: 11, weight: .semibold, design: .rounded)) + .foregroundStyle(.white.opacity(0.65)) + Text(slotFormattedValue(option: slot, snapshot: snapshot)) + .font(.system(size: 18, weight: .bold, design: .rounded)) + .monospacedDigit() + .foregroundStyle(.white) + .lineLimit(1) + .minimumScaleFactor(0.8) + } + .padding(.trailing, 6) } - .padding(.trailing, 6) } } } diff --git a/LoopFollowWatch Watch App/Assets.xcassets/AccentColor.colorset/Contents.json b/LoopFollowWatch Watch App/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 000000000..eb8789700 --- /dev/null +++ b/LoopFollowWatch Watch App/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/LoopFollowWatch Watch App/Assets.xcassets/AppIcon.appiconset/1024-dark.png b/LoopFollowWatch Watch App/Assets.xcassets/AppIcon.appiconset/1024-dark.png new file mode 100644 index 000000000..0eaf8706b Binary files /dev/null and b/LoopFollowWatch Watch App/Assets.xcassets/AppIcon.appiconset/1024-dark.png differ diff --git a/LoopFollowWatch Watch App/Assets.xcassets/AppIcon.appiconset/1024-tinted.png b/LoopFollowWatch Watch App/Assets.xcassets/AppIcon.appiconset/1024-tinted.png new file mode 100644 index 000000000..61aaa37f1 Binary files /dev/null and b/LoopFollowWatch Watch App/Assets.xcassets/AppIcon.appiconset/1024-tinted.png differ diff --git a/LoopFollowWatch Watch App/Assets.xcassets/AppIcon.appiconset/1024.png b/LoopFollowWatch Watch App/Assets.xcassets/AppIcon.appiconset/1024.png new file mode 100644 index 000000000..a975ee063 Binary files /dev/null and b/LoopFollowWatch Watch App/Assets.xcassets/AppIcon.appiconset/1024.png differ diff --git a/LoopFollowWatch Watch App/Assets.xcassets/AppIcon.appiconset/Contents.json b/LoopFollowWatch Watch App/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 000000000..113d82165 --- /dev/null +++ b/LoopFollowWatch Watch App/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images" : [ + { + "filename" : "1024.png", + "idiom" : "universal", + "platform" : "watchos", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/LoopFollowWatch Watch App/Assets.xcassets/Contents.json b/LoopFollowWatch Watch App/Assets.xcassets/Contents.json new file mode 100644 index 000000000..73c00596a --- /dev/null +++ b/LoopFollowWatch Watch App/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/LoopFollowWatch Watch App/ContentView.swift b/LoopFollowWatch Watch App/ContentView.swift new file mode 100644 index 000000000..1ae32dc63 --- /dev/null +++ b/LoopFollowWatch Watch App/ContentView.swift @@ -0,0 +1,237 @@ +// +// ContentView.swift +// LoopFollowWatch Watch App +// +// Created by Philippe Achkar on 2026-03-10. +// Copyright © 2026 Jon Fawcett. All rights reserved. +// + +import Combine +import SwiftUI +import WatchConnectivity + +// MARK: - Root view + +struct ContentView: View { + @StateObject private var model = WatchViewModel() + + var body: some View { + TabView { + GlucoseView(model: model) + + ForEach(Array(model.pages.enumerated()), id: \.offset) { _, page in + DataGridPage(slots: page, snapshot: model.snapshot) + } + + SlotSelectionView(model: model) + } + .tabViewStyle(.page) + .onAppear { model.refresh() } + } +} + +// MARK: - View model + +final class WatchViewModel: ObservableObject { + @Published var snapshot: GlucoseSnapshot? + @Published var selectedSlots: [LiveActivitySlotOption] = LAAppGroupSettings.watchSelectedSlots() + + private var timer: Timer? + private var notificationObserver: Any? + + init() { + snapshot = GlucoseSnapshotStore.shared.load() + timer = Timer.scheduledTimer(withTimeInterval: 60, repeats: true) { [weak self] _ in + self?.refresh() + } + // Subscribe in init so notifications fired before the view appears are not missed. + notificationObserver = NotificationCenter.default.addObserver( + forName: WatchSessionReceiver.snapshotReceivedNotification, + object: nil, + queue: .main + ) { [weak self] notification in + if let s = notification.userInfo?["snapshot"] as? GlucoseSnapshot { + self?.update(snapshot: s) + } else { + self?.refresh() + } + } + } + + deinit { + timer?.invalidate() + if let obs = notificationObserver { + NotificationCenter.default.removeObserver(obs) + } + } + + func refresh() { + if let loaded = GlucoseSnapshotStore.shared.load() { + snapshot = loaded + } + selectedSlots = LAAppGroupSettings.watchSelectedSlots() + } + + func update(snapshot: GlucoseSnapshot) { + self.snapshot = snapshot + selectedSlots = LAAppGroupSettings.watchSelectedSlots() + } + + /// Slots grouped into pages of 4 for the swipable grid tabs. + var pages: [[LiveActivitySlotOption]] { + guard !selectedSlots.isEmpty else { return [] } + return stride(from: 0, to: selectedSlots.count, by: 4).map { + Array(selectedSlots[$0.. Bool { + selectedSlots.contains(option) + } + + func toggleSlot(_ option: LiveActivitySlotOption) { + if let idx = selectedSlots.firstIndex(of: option) { + selectedSlots.remove(at: idx) + } else { + selectedSlots.append(option) + } + LAAppGroupSettings.setWatchSelectedSlots(selectedSlots) + } +} + +// MARK: - Page 1: Glucose + +struct GlucoseView: View { + @ObservedObject var model: WatchViewModel + + var body: some View { + if let s = model.snapshot, s.age < 900 { + VStack(alignment: .leading, spacing: 6) { + // Large BG + trend arrow, single line, auto-scaled to fit 7 chars + Text("\(WatchFormat.glucose(s)) \(WatchFormat.trendArrow(s))") + .font(.system(size: 56, weight: .bold, design: .rounded)) + .minimumScaleFactor(0.4) + .lineLimit(1) + .foregroundColor(ComplicationEntryBuilder.thresholdColor(for: s).swiftUIColor) + .frame(maxWidth: .infinity, alignment: .leading) + + VStack(alignment: .leading, spacing: 3) { + Text("Delta: \(WatchFormat.delta(s)) \(s.unit.displayName)") + .font(.system(size: 14)) + .foregroundColor(.white) + + if s.projected != nil { + Text("Projected: \(WatchFormat.projected(s)) \(s.unit.displayName)") + .font(.system(size: 14)) + .foregroundColor(.white) + } + + Text("Last update: \(WatchFormat.updateTime(s))") + .font(.system(size: 14)) + .foregroundColor(.white) + + if s.isNotLooping { + Text("⚠ Loop inactive") + .font(.system(size: 12)) + .foregroundColor(.yellow) + } + } + } + .padding(.horizontal, 4) + } else { + VStack(spacing: 4) { + Text("--") + .font(.system(size: 44, weight: .semibold, design: .rounded)) + .foregroundColor(.secondary) + Text(model.snapshot == nil ? "No data" : "Stale") + .font(.caption) + .foregroundColor(.secondary) + } + } + } +} + +// MARK: - Data grid page (2×2, up to 4 slots) + +struct DataGridPage: View { + let slots: [LiveActivitySlotOption] + let snapshot: GlucoseSnapshot? + + var body: some View { + LazyVGrid( + columns: [GridItem(.flexible()), GridItem(.flexible())], + spacing: 8 + ) { + ForEach(0..<4, id: \.self) { i in + if i < slots.count { + let option = slots[i] + MetricCell( + label: option.gridLabel, + value: snapshot.map { WatchFormat.slotValue(option: option, snapshot: $0) } ?? "—" + ) + } else { + Color.clear.frame(height: 52) + } + } + } + .padding(.horizontal, 4) + } +} + +// MARK: - Metric cell + +struct MetricCell: View { + let label: String + let value: String + + var body: some View { + VStack(spacing: 2) { + Text(label) + .font(.system(size: 10)) + .foregroundColor(.secondary) + .lineLimit(1) + Text(value) + .font(.system(size: 15, weight: .medium, design: .rounded)) + .lineLimit(1) + .minimumScaleFactor(0.6) + } + .frame(maxWidth: .infinity) + .padding(6) + .background(Color.secondary.opacity(0.15)) + .cornerRadius(8) + } +} + +// MARK: - Last tab: slot selection checklist + +struct SlotSelectionView: View { + @ObservedObject var model: WatchViewModel + + var body: some View { + List { + ForEach(LiveActivitySlotOption.allCases.filter { $0 != .none && $0 != .delta && $0 != .projectedBG }, id: \.self) { option in + Button(action: { model.toggleSlot(option) }) { + HStack { + Text(option.displayName) + .foregroundColor(.primary) + Spacer() + Image( + systemName: model.isSelected(option) + ? "checkmark.circle.fill" + : "circle" + ) + .foregroundColor(model.isSelected(option) ? .green : .secondary) + } + } + .buttonStyle(.plain) + } + } + .navigationTitle("Data") + } +} + +// MARK: - UIColor → SwiftUI Color bridge + +private extension UIColor { + var swiftUIColor: Color { Color(self) } +} diff --git a/LoopFollowWatch Watch App/LoopFollowWatch Watch App.entitlements b/LoopFollowWatch Watch App/LoopFollowWatch Watch App.entitlements new file mode 100644 index 000000000..5b963cc90 --- /dev/null +++ b/LoopFollowWatch Watch App/LoopFollowWatch Watch App.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.com.$(unique_id).LoopFollow$(app_suffix) + + + diff --git a/LoopFollowWatch Watch App/LoopFollowWatchApp.swift b/LoopFollowWatch Watch App/LoopFollowWatchApp.swift new file mode 100644 index 000000000..3a1dc5781 --- /dev/null +++ b/LoopFollowWatch Watch App/LoopFollowWatchApp.swift @@ -0,0 +1,105 @@ +// LoopFollowWatchApp.swift +// Philippe Achkar +// 2026-03-10 + +import SwiftUI +import WatchConnectivity +import WatchKit +import OSLog + +private let logger = Logger( + subsystem: Bundle.main.bundleIdentifier ?? "com.loopfollow.watch", + category: "Watch" +) + +@main +struct LoopFollowWatch_Watch_AppApp: App { + + @WKApplicationDelegateAdaptor(WatchAppDelegate.self) var delegate + + init() { + WatchSessionReceiver.shared.activate() + } + + var body: some Scene { + WindowGroup { + ContentView() + } + } +} + +// MARK: - App delegate for background tasks + +final class WatchAppDelegate: NSObject, WKApplicationDelegate { + + func applicationDidFinishLaunching() { + WatchAppDelegate.scheduleNextRefresh() + } + + func handle(_ backgroundTasks: Set) { + for task in backgroundTasks { + switch task { + case let refreshTask as WKApplicationRefreshBackgroundTask: + handleRefresh(refreshTask) + + case let connectivityTask as WKWatchConnectivityRefreshBackgroundTask: + // Hold the task open — WatchConnectivity will deliver the pending + // transferUserInfo to session(_:didReceiveUserInfo:) while the app + // is awake. WatchSessionReceiver completes it after saving the snapshot. + WatchSessionReceiver.shared.pendingConnectivityTask = connectivityTask + + default: + task.setTaskCompletedWithSnapshot(false) + } + } + } + + private func handleRefresh(_ task: WKApplicationRefreshBackgroundTask) { + // receivedApplicationContext always holds the last value the iPhone sent — + // no active Bluetooth or WKWatchConnectivityRefreshBackgroundTask needed. + // If it's newer than what's in the file store, persist it and reload complications. + let contextSnapshot = Self.decodeContextSnapshot() + let storeSnapshot = GlucoseSnapshotStore.shared.load() + + if let ctx = contextSnapshot, + ctx.updatedAt > (storeSnapshot?.updatedAt ?? .distantPast) { + GlucoseSnapshotStore.shared.save(ctx) { + WatchSessionReceiver.shared.triggerComplicationReload() + WatchAppDelegate.scheduleNextRefresh() + task.setTaskCompletedWithSnapshot(false) + } + } else { + if storeSnapshot != nil { + WatchSessionReceiver.shared.triggerComplicationReload() + } + WatchAppDelegate.scheduleNextRefresh() + task.setTaskCompletedWithSnapshot(false) + } + } + + static func decodeContextSnapshot() -> GlucoseSnapshot? { + guard let data = WCSession.default.receivedApplicationContext["snapshot"] as? Data else { + return nil + } + + do { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + return try decoder.decode(GlucoseSnapshot.self, from: data) + } catch { + logger.error("WatchAppDelegate: failed to decode applicationContext snapshot — \(error.localizedDescription, privacy: .public)") + return nil + } + } + + static func scheduleNextRefresh() { + WKApplication.shared().scheduleBackgroundRefresh( + withPreferredDate: Date(timeIntervalSinceNow: 5 * 60), + userInfo: nil + ) { _ in } + } + + private func scheduleNextRefresh() { + WatchAppDelegate.scheduleNextRefresh() + } +} \ No newline at end of file diff --git a/README.md b/README.md index d0cd2d020..1afce9fa8 100644 --- a/README.md +++ b/README.md @@ -92,6 +92,39 @@ Always rename your branch (that is aligned with `dev`) to a name suitable for yo After a PR is merged to `dev`, there is an automatic bump up the version number - please do not modify the version in your branch. +### Pull Request Guidelines + +Each pull request should focus on a **single concern** — one bug fix, one feature, or one improvement. Avoid combining unrelated changes in the same PR. + +Focused PRs are easier to review, simpler to test, and safer to revert if needed. If your work touches multiple areas, consider splitting it into separate PRs that can be reviewed and merged independently. + +### Commit Guidelines + +Write commit messages that complete the sentence: **"If applied, this commit will..."** + +For example: +* "Add alarm snooze functionality" ✓ +* "Fix crash when loading empty profile" ✓ +* "Update documentation for build process" ✓ +* "Remove deprecated API calls" ✓ + +**Commit message structure:** + +``` + + + +``` + +**Best practices:** +* Use imperative mood in the subject line (Add, Fix, Update, Remove, Refactor) +* Keep the subject line concise (50 characters or less) +* Capitalize the first letter of the subject +* Do not end the subject line with a period +* Separate subject from body with a blank line +* Use the body to explain *what* and *why*, not *how* +* Reference related issues when applicable (e.g., "Fixes #123") + ### Version Updates Only the maintainers for LoopFollow will update version numbers. diff --git a/Tests/AlarmConditions/FutureCarbsConditionTests.swift b/Tests/AlarmConditions/FutureCarbsConditionTests.swift new file mode 100644 index 000000000..878f78ab2 --- /dev/null +++ b/Tests/AlarmConditions/FutureCarbsConditionTests.swift @@ -0,0 +1,300 @@ +// LoopFollow +// FutureCarbsConditionTests.swift + +import Foundation +@testable import LoopFollow +import Testing + +@Suite(.serialized) +struct FutureCarbsConditionTests { + let cond = FutureCarbsCondition() + + private func resetPending() { + Storage.shared.pendingFutureCarbs.value = [] + } + + private func carb(minutesFromNow offset: Double, grams: Double = 20, relativeTo now: Date = .init()) -> CarbSample { + CarbSample(grams: grams, date: now.addingTimeInterval(offset * 60)) + } + + // MARK: - 1. Tracking — future carb within lookahead gets tracked + + @Test("#tracking — future carb within lookahead gets tracked") + func futureWithinLookaheadTracked() { + resetPending() + let now = Date() + let alarm = Alarm.futureCarbs(threshold: 45, delta: 5) + let data = AlarmData.withCarbs([carb(minutesFromNow: 10, grams: 20, relativeTo: now)]) + + let result = cond.evaluate(alarm: alarm, data: data, now: now) + + #expect(!result) + #expect(Storage.shared.pendingFutureCarbs.value.count == 1) + } + + // MARK: - 2. Firing — pending carb whose time arrives fires + + @Test("#firing — pending carb whose time arrives fires") + func pendingCarbFires() { + resetPending() + let now = Date() + let pastDate = now.addingTimeInterval(-60) // 1 min ago + + Storage.shared.pendingFutureCarbs.value = [ + PendingFutureCarb(carbDate: pastDate.timeIntervalSince1970, grams: 20, observedAt: now.addingTimeInterval(-600).timeIntervalSince1970), + ] + + let alarm = Alarm.futureCarbs() + let data = AlarmData.withCarbs([CarbSample(grams: 20, date: pastDate)]) + + let result = cond.evaluate(alarm: alarm, data: data, now: now) + + #expect(result) + #expect(Storage.shared.pendingFutureCarbs.value.isEmpty) + } + + // MARK: - 3. Deleted carb — no fire, removed from pending + + @Test("#deleted carb — no fire, removed from pending") + func deletedCarbNoFire() { + resetPending() + let now = Date() + let pastDate = now.addingTimeInterval(-60) + + Storage.shared.pendingFutureCarbs.value = [ + PendingFutureCarb(carbDate: pastDate.timeIntervalSince1970, grams: 20, observedAt: now.addingTimeInterval(-600).timeIntervalSince1970), + ] + + let alarm = Alarm.futureCarbs() + let data = AlarmData.withCarbs([]) // carb was deleted + + let result = cond.evaluate(alarm: alarm, data: data, now: now) + + #expect(!result) + #expect(Storage.shared.pendingFutureCarbs.value.isEmpty) + } + + // MARK: - 4. Beyond lookahead — tracked but does not fire + + @Test("#beyond lookahead — tracked but does not fire") + func beyondLookaheadTrackedButNoFire() { + resetPending() + let now = Date() + let alarm = Alarm.futureCarbs(threshold: 45) + let data = AlarmData.withCarbs([carb(minutesFromNow: 60, grams: 20, relativeTo: now)]) + + let result = cond.evaluate(alarm: alarm, data: data, now: now) + + #expect(!result) + // Carb is tracked (to prevent re-observation with fresh observedAt) + // but will never fire because original distance > lookahead + #expect(Storage.shared.pendingFutureCarbs.value.count == 1) + } + + // MARK: - 5. Below min grams — carb ignored + + @Test("#below min grams — carb ignored") + func belowMinGramsIgnored() { + resetPending() + let now = Date() + let alarm = Alarm.futureCarbs(delta: 5) + let data = AlarmData.withCarbs([carb(minutesFromNow: 10, grams: 3, relativeTo: now)]) + + let result = cond.evaluate(alarm: alarm, data: data, now: now) + + #expect(!result) + #expect(Storage.shared.pendingFutureCarbs.value.isEmpty) + } + + // MARK: - 6. Past carb — not tracked + + @Test("#past carb — not tracked") + func pastCarbNotTracked() { + resetPending() + let now = Date() + let alarm = Alarm.futureCarbs() + let data = AlarmData.withCarbs([carb(minutesFromNow: -5, grams: 20, relativeTo: now)]) + + let result = cond.evaluate(alarm: alarm, data: data, now: now) + + #expect(!result) + #expect(Storage.shared.pendingFutureCarbs.value.isEmpty) + } + + // MARK: - 7. Stale cleanup — entry observed > 2h ago is removed + + @Test("#stale cleanup — entry observed > 2h ago is removed") + func staleCleanup() { + resetPending() + let now = Date() + let futureDate = now.addingTimeInterval(300) // still in the future + + Storage.shared.pendingFutureCarbs.value = [ + PendingFutureCarb(carbDate: futureDate.timeIntervalSince1970, grams: 20, observedAt: now.addingTimeInterval(-3 * 3600).timeIntervalSince1970), + ] + + let alarm = Alarm.futureCarbs() + let data = AlarmData.withCarbs([]) + + let result = cond.evaluate(alarm: alarm, data: data, now: now) + + #expect(!result) + #expect(Storage.shared.pendingFutureCarbs.value.isEmpty) + } + + // MARK: - 8. Multiple carbs — only one fires per tick + + @Test("#multiple carbs — only one fires per tick") + func multipleOnlyOnePerTick() { + resetPending() + let now = Date() + let past1 = now.addingTimeInterval(-60) + let past2 = now.addingTimeInterval(-120) + + Storage.shared.pendingFutureCarbs.value = [ + PendingFutureCarb(carbDate: past1.timeIntervalSince1970, grams: 20, observedAt: now.addingTimeInterval(-600).timeIntervalSince1970), + PendingFutureCarb(carbDate: past2.timeIntervalSince1970, grams: 30, observedAt: now.addingTimeInterval(-600).timeIntervalSince1970), + ] + + let alarm = Alarm.futureCarbs() + let data = AlarmData.withCarbs([ + CarbSample(grams: 20, date: past1), + CarbSample(grams: 30, date: past2), + ]) + + let result = cond.evaluate(alarm: alarm, data: data, now: now) + + #expect(result) + #expect(Storage.shared.pendingFutureCarbs.value.count == 1) + } + + // MARK: - 9. Second tick fires second carb + + @Test("#second tick fires second carb") + func secondTickFiresSecond() { + resetPending() + let now = Date() + let past1 = now.addingTimeInterval(-60) + let past2 = now.addingTimeInterval(-120) + + Storage.shared.pendingFutureCarbs.value = [ + PendingFutureCarb(carbDate: past1.timeIntervalSince1970, grams: 20, observedAt: now.addingTimeInterval(-600).timeIntervalSince1970), + PendingFutureCarb(carbDate: past2.timeIntervalSince1970, grams: 30, observedAt: now.addingTimeInterval(-600).timeIntervalSince1970), + ] + + let alarm = Alarm.futureCarbs() + let data = AlarmData.withCarbs([ + CarbSample(grams: 20, date: past1), + CarbSample(grams: 30, date: past2), + ]) + + // First tick + let result1 = cond.evaluate(alarm: alarm, data: data, now: now) + #expect(result1) + #expect(Storage.shared.pendingFutureCarbs.value.count == 1) + + // Second tick + let result2 = cond.evaluate(alarm: alarm, data: data, now: now) + #expect(result2) + #expect(Storage.shared.pendingFutureCarbs.value.isEmpty) + } + + // MARK: - 10. Duplicate carb not double-tracked + + @Test("#duplicate carb not double-tracked") + func duplicateNotDoubleTracked() { + resetPending() + let now = Date() + let alarm = Alarm.futureCarbs() + let data = AlarmData.withCarbs([carb(minutesFromNow: 10, grams: 20, relativeTo: now)]) + + _ = cond.evaluate(alarm: alarm, data: data, now: now) + #expect(Storage.shared.pendingFutureCarbs.value.count == 1) + + _ = cond.evaluate(alarm: alarm, data: data, now: now) + #expect(Storage.shared.pendingFutureCarbs.value.count == 1) + } + + // MARK: - 11. Sliding window — carb outside lookahead never fires + + @Test("#sliding window — carb outside lookahead never fires") + func slidingWindowNeverFires() { + resetPending() + let t0 = Date() + let alarm = Alarm.futureCarbs(threshold: 10) // 10-minute lookahead + let carbDate = t0.addingTimeInterval(15 * 60) // 15 min in future + let carbSample = CarbSample(grams: 20, date: carbDate) + + // Tick at T+0: carb is 15 min away, outside 10-min window but tracked + let data = AlarmData.withCarbs([carbSample]) + let r0 = cond.evaluate(alarm: alarm, data: data, now: t0) + #expect(!r0) + #expect(Storage.shared.pendingFutureCarbs.value.count == 1) + + // Tick at T+5min: carb is now 10 min away (inside window), but + // original distance was 15 min — must NOT fire + let t1 = t0.addingTimeInterval(5 * 60) + let r1 = cond.evaluate(alarm: alarm, data: data, now: t1) + #expect(!r1) + + // Tick at T+15min: carb is due — still must NOT fire + let t2 = t0.addingTimeInterval(15 * 60) + let r2 = cond.evaluate(alarm: alarm, data: data, now: t2) + #expect(!r2) + // Entry should be removed (due, outside original window) + #expect(Storage.shared.pendingFutureCarbs.value.isEmpty) + } + + // MARK: - 12. Due entry outside original window removed without firing + + @Test("#due entry outside original window removed without firing") + func dueOutsideWindowRemovedNoFire() { + resetPending() + let now = Date() + let pastDate = now.addingTimeInterval(-60) // 1 min ago + + // Entry was observed 20 min before its carb date (outside 10-min window) + Storage.shared.pendingFutureCarbs.value = [ + PendingFutureCarb( + carbDate: pastDate.timeIntervalSince1970, + grams: 20, + observedAt: pastDate.timeIntervalSince1970 - 20 * 60 + ), + ] + + let alarm = Alarm.futureCarbs(threshold: 10) + let data = AlarmData.withCarbs([CarbSample(grams: 20, date: pastDate)]) + + let result = cond.evaluate(alarm: alarm, data: data, now: now) + + #expect(!result) + #expect(Storage.shared.pendingFutureCarbs.value.isEmpty) + } + + // MARK: - 13. Stale entry with existing carb is not evicted + + @Test("#stale entry with existing carb is not evicted") + func staleWithExistingCarbNotEvicted() { + resetPending() + let now = Date() + let futureDate = now.addingTimeInterval(300) // 5 min in the future + + // Entry observed 3 hours ago, but carb still exists in recentCarbs + Storage.shared.pendingFutureCarbs.value = [ + PendingFutureCarb( + carbDate: futureDate.timeIntervalSince1970, + grams: 20, + observedAt: now.addingTimeInterval(-3 * 3600).timeIntervalSince1970 + ), + ] + + let alarm = Alarm.futureCarbs() + let data = AlarmData.withCarbs([CarbSample(grams: 20, date: futureDate)]) + + let result = cond.evaluate(alarm: alarm, data: data, now: now) + + #expect(!result) + // Entry must survive — carb still exists, don't evict + #expect(Storage.shared.pendingFutureCarbs.value.count == 1) + } +} diff --git a/Tests/AlarmConditions/Helpers.swift b/Tests/AlarmConditions/Helpers.swift index 37220d12f..c615f4972 100644 --- a/Tests/AlarmConditions/Helpers.swift +++ b/Tests/AlarmConditions/Helpers.swift @@ -6,9 +6,6 @@ import Foundation @testable import LoopFollow import Testing -@testable import LoopFollow -import Testing - // MARK: - Alarm helpers extension Alarm { @@ -17,6 +14,13 @@ extension Alarm { alarm.threshold = threshold return alarm } + + static func futureCarbs(threshold: Double = 45, delta: Double = 5) -> Self { + var alarm = Alarm(type: .futureCarbs) + alarm.threshold = threshold + alarm.delta = delta + return alarm + } } // MARK: - AlarmData helpers @@ -40,8 +44,33 @@ extension AlarmData { IOB: nil, recentBoluses: [], latestBattery: level, + latestPumpBattery: nil, batteryHistory: [], recentCarbs: [] ) } + + static func withCarbs(_ carbs: [CarbSample]) -> Self { + AlarmData( + bgReadings: [], + predictionData: [], + expireDate: nil, + lastLoopTime: nil, + latestOverrideStart: nil, + latestOverrideEnd: nil, + latestTempTargetStart: nil, + latestTempTargetEnd: nil, + recBolus: nil, + COB: nil, + sageInsertTime: nil, + pumpInsertTime: nil, + latestPumpVolume: nil, + IOB: nil, + recentBoluses: [], + latestBattery: nil, + latestPumpBattery: nil, + batteryHistory: [], + recentCarbs: carbs + ) + } } diff --git a/WatchConnectivityManager.swift b/WatchConnectivityManager.swift new file mode 100644 index 000000000..7ad6c3190 --- /dev/null +++ b/WatchConnectivityManager.swift @@ -0,0 +1,145 @@ +// +// WatchConnectivityManager.swift +// LoopFollow +// +// Created by Philippe Achkar on 2026-03-10. +// Copyright © 2026 Jon Fawcett. All rights reserved. +// + + +// WatchConnectivityManager.swift +// Philippe Achkar +// 2026-03-10 + +import Foundation +import WatchConnectivity + +final class WatchConnectivityManager: NSObject { + + // MARK: - Shared Instance + + static let shared = WatchConnectivityManager() + + // MARK: - Init + + /// Timestamp of the last snapshot the Watch ACK'd via sendAck(). + private var lastWatchAckTimestamp: TimeInterval = 0 + + private override init() { + super.init() + } + + // MARK: - Setup + + /// Call once from AppDelegate after app launch. + func activate() { + guard WCSession.isSupported() else { + LogManager.shared.log(category: .watch, message: "WatchConnectivityManager: WCSession not supported on this device") + return + } + WCSession.default.delegate = self + WCSession.default.activate() + LogManager.shared.log(category: .watch, message: "WatchConnectivityManager: WCSession activation requested") + } + + // MARK: - Send Snapshot + + /// Sends the latest GlucoseSnapshot to the Watch via transferUserInfo. + /// Safe to call from any thread. + /// No-ops silently if Watch is not paired or reachable. + func send(snapshot: GlucoseSnapshot) { + guard WCSession.isSupported() else { return } + + let session = WCSession.default + + guard session.activationState == .activated else { + LogManager.shared.log(category: .watch, message: "WatchConnectivityManager: session not activated, skipping send") + return + } + + guard session.isPaired else { + LogManager.shared.log(category: .watch, message: "WatchConnectivityManager: no paired Watch, skipping send") + return + } + + do { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + let data = try encoder.encode(snapshot) + let payload: [String: Any] = ["snapshot": data] + + // Warn if Watch hasn't ACK'd this or a recent snapshot. + let behindBy = snapshot.updatedAt.timeIntervalSince1970 - lastWatchAckTimestamp + if lastWatchAckTimestamp > 0, behindBy > 600 { + LogManager.shared.log(category: .watch, message: "WatchConnectivityManager: Watch ACK is \(Int(behindBy))s behind — Watch may be missing deliveries") + } + + // sendMessage: immediate delivery when Watch app is in foreground. + if session.isReachable { + session.sendMessage(payload, replyHandler: nil, errorHandler: nil) + LogManager.shared.log(category: .watch, message: "WatchConnectivityManager: snapshot sent via sendMessage (reachable)") + } + + // Cancel outstanding transfers before queuing — only the latest snapshot matters. + session.outstandingUserInfoTransfers.forEach { $0.cancel() } + + // transferUserInfo: guaranteed queued delivery for background wakes. + session.transferUserInfo(payload) + try? session.updateApplicationContext(payload) + LogManager.shared.log(category: .watch, message: "WatchConnectivityManager: snapshot queued via transferUserInfo") + } catch { + LogManager.shared.log(category: .watch, message: "WatchConnectivityManager: failed to encode snapshot — \(error)") + } + } +} + +// MARK: - WCSessionDelegate + +extension WatchConnectivityManager: WCSessionDelegate { + + func session( + _ session: WCSession, + activationDidCompleteWith activationState: WCSessionActivationState, + error: Error? + ) { + if let error = error { + LogManager.shared.log(category: .watch, message: "WatchConnectivityManager: activation failed — \(error)") + } else { + LogManager.shared.log(category: .watch, message: "WatchConnectivityManager: activation complete — state \(activationState.rawValue)") + } + } + + /// When the Watch app comes to the foreground, send the latest snapshot immediately + /// so the Watch app has fresh data without waiting for the next BG poll. + /// Receives ACKs from the Watch (sent after each snapshot is saved). + func session(_ session: WCSession, didReceiveMessage message: [String: Any]) { + if let ackTimestamp = message["watchAck"] as? TimeInterval { + lastWatchAckTimestamp = ackTimestamp + LogManager.shared.log(category: .watch, message: "WatchConnectivityManager: Watch ACK received for snapshot at \(ackTimestamp)") + } + } + + func session(_ session: WCSession, didReceiveUserInfo userInfo: [String: Any]) { + if let ackTimestamp = userInfo["watchAck"] as? TimeInterval { + lastWatchAckTimestamp = ackTimestamp + LogManager.shared.log(category: .watch, message: "WatchConnectivityManager: Watch ACK (userInfo) received for snapshot at \(ackTimestamp)") + } + } + + func sessionReachabilityDidChange(_ session: WCSession) { + guard session.isReachable else { return } + if let snapshot = GlucoseSnapshotStore.shared.load() { + send(snapshot: snapshot) + LogManager.shared.log(category: .watch, message: "WatchConnectivityManager: Watch became reachable — snapshot pushed") + } + } + + func sessionDidBecomeInactive(_ session: WCSession) { + LogManager.shared.log(category: .watch, message: "WatchConnectivityManager: session became inactive") + } + + func sessionDidDeactivate(_ session: WCSession) { + LogManager.shared.log(category: .watch, message: "WatchConnectivityManager: session deactivated — reactivating") + WCSession.default.activate() + } +} \ No newline at end of file diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 0871e7414..6aa54cfbb 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -56,7 +56,8 @@ platform :ios do git_basic_authorization: Base64.strict_encode64("#{GITHUB_REPOSITORY_OWNER}:#{GH_PAT}"), app_identifier: [ "com.#{TEAMID}.LoopFollow", - "com.#{TEAMID}.LoopFollow.LoopFollowLAExtension" + "com.#{TEAMID}.LoopFollow.LoopFollowLAExtension", + "com.#{TEAMID}.LoopFollow.watchkitapp" ] ) @@ -72,12 +73,19 @@ platform :ios do ) update_code_signing_settings( - path: "#{GITHUB_WORKSPACE}/LoopFollow.xcodeproj", + path: "#{GITHUB_WORKSPACE}/LoopFollow.xcodeproj", profile_name: mapping["com.#{TEAMID}.LoopFollow.LoopFollowLAExtension"], code_sign_identity: "iPhone Distribution", targets: ["LoopFollowLAExtensionExtension"] ) + update_code_signing_settings( + path: "#{GITHUB_WORKSPACE}/LoopFollow.xcodeproj", + profile_name: mapping["com.#{TEAMID}.LoopFollow.watchkitapp"], + code_sign_identity: "iPhone Distribution", + targets: ["LoopFollowWatch Watch App"] + ) + gym( export_method: "app-store", scheme: "LoopFollow", @@ -88,9 +96,10 @@ platform :ios do export_options: { provisioningProfiles: { "com.#{TEAMID}.LoopFollow" => mapping["com.#{TEAMID}.LoopFollow"], - "com.#{TEAMID}.LoopFollow.LoopFollowLAExtension" => mapping["com.#{TEAMID}.LoopFollow.LoopFollowLAExtension"] + "com.#{TEAMID}.LoopFollow.LoopFollowLAExtension" => mapping["com.#{TEAMID}.LoopFollow.LoopFollowLAExtension"], + "com.#{TEAMID}.LoopFollow.watchkitapp" => mapping["com.#{TEAMID}.LoopFollow.watchkitapp"] } - } + } ) copy_artifacts( @@ -141,9 +150,11 @@ platform :ios do configure_bundle_id("LoopFollow", "com.#{TEAMID}.LoopFollow", [ Spaceship::ConnectAPI::BundleIdCapability::Type::PUSH_NOTIFICATIONS ]) - + configure_bundle_id("LoopFollow Live Activity Extension", "com.#{TEAMID}.LoopFollow.LoopFollowLAExtension", []) + configure_bundle_id("LoopFollow Watch App", "com.#{TEAMID}.LoopFollow.watchkitapp", []) + end desc "Provision Certificates" @@ -164,7 +175,8 @@ platform :ios do git_basic_authorization: Base64.strict_encode64("#{GITHUB_REPOSITORY_OWNER}:#{GH_PAT}"), app_identifier: [ "com.#{TEAMID}.LoopFollow", - "com.#{TEAMID}.LoopFollow.LoopFollowLAExtension" + "com.#{TEAMID}.LoopFollow.LoopFollowLAExtension", + "com.#{TEAMID}.LoopFollow.watchkitapp" ] ) end