diff --git a/.github/workflows/add_identifiers.yml b/.github/workflows/add_identifiers.yml index 702e7a91d..4ebd4b26a 100644 --- a/.github/workflows/add_identifiers.yml +++ b/.github/workflows/add_identifiers.yml @@ -12,7 +12,7 @@ jobs: identifiers: name: Add Identifiers needs: validate - runs-on: macos-15 + runs-on: xcode-27 steps: # Checks-out the repo - name: Checkout Repo diff --git a/.github/workflows/auto_version_dev.yml b/.github/workflows/auto_version_dev.yml index a2adf3c3d..1ae91da8f 100644 --- a/.github/workflows/auto_version_dev.yml +++ b/.github/workflows/auto_version_dev.yml @@ -36,7 +36,7 @@ on: jobs: bump-version: if: ${{ !github.event.repository.fork }} - runs-on: ubuntu-latest + runs-on: ubuntu-26.04 steps: - name: Checkout repository diff --git a/.github/workflows/build_LoopFollow.yml b/.github/workflows/build_LoopFollow.yml index e6e1a5dda..dfb110d7f 100644 --- a/.github/workflows/build_LoopFollow.yml +++ b/.github/workflows/build_LoopFollow.yml @@ -16,7 +16,7 @@ env: jobs: # use a single runner for these sequential steps check_status: - runs-on: ubuntu-latest + runs-on: ubuntu-26.04 name: Check status to decide whether to build permissions: contents: write @@ -154,7 +154,7 @@ jobs: cancel_when_no_build: needs: check_status name: Cancel run when there is nothing to build - runs-on: ubuntu-latest + runs-on: ubuntu-26.04 permissions: actions: write if: | @@ -189,7 +189,7 @@ jobs: build: name: Build needs: [check_certs, check_status] - runs-on: macos-26 + runs-on: xcode-27 permissions: contents: write if: @@ -199,7 +199,7 @@ jobs: (vars.SCHEDULED_SYNC != 'false' && needs.check_status.outputs.NEW_COMMITS == 'true' ) steps: - name: Select Xcode version - run: "sudo xcode-select --switch /Applications/Xcode_26.2.app/Contents/Developer" + run: "sudo xcode-select --switch /Applications/Xcode_27.0.app/Contents/Developer" - name: Checkout Repo for building uses: actions/checkout@v5 diff --git a/.github/workflows/create_certs.yml b/.github/workflows/create_certs.yml index c284e397a..2f8bde21b 100644 --- a/.github/workflows/create_certs.yml +++ b/.github/workflows/create_certs.yml @@ -20,7 +20,7 @@ jobs: create_certs: name: Certificates needs: validate - runs-on: macos-15 + runs-on: xcode-27 outputs: new_certificate_needed: ${{ steps.set_output.outputs.new_certificate_needed }} @@ -92,7 +92,7 @@ jobs: nuke_certs: name: Nuke certificates needs: [validate, create_certs] - runs-on: macos-15 + runs-on: xcode-27 if: ${{ (needs.create_certs.outputs.new_certificate_needed == 'true' && vars.ENABLE_NUKE_CERTS == 'true') || vars.FORCE_NUKE_CERTS == 'true' }} steps: - name: Output from step id 'check_certs' diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 9d5005aa1..7044fdd31 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -10,7 +10,9 @@ on: workflow_dispatch: concurrency: - group: lint-${{ github.ref }} + # Edits that don't change the base branch get their own group, so they never + # cancel an in-flight lint run. + group: lint-${{ github.ref }}${{ github.event.action == 'edited' && github.event.changes.base == null && format('-noop-{0}', github.run_id) || '' }} cancel-in-progress: true permissions: @@ -22,7 +24,7 @@ jobs: # Run on open/sync/reopen always; on 'edited' only when the base branch # actually changed, so routine title/description edits don't re-lint. if: ${{ github.event.action != 'edited' || github.event.changes.base != null }} - runs-on: ubuntu-latest + runs-on: ubuntu-26.04 container: swift:6.0 steps: - name: Checkout diff --git a/.github/workflows/tag_on_main.yml b/.github/workflows/tag_on_main.yml index 388a2b537..24ab348f1 100644 --- a/.github/workflows/tag_on_main.yml +++ b/.github/workflows/tag_on_main.yml @@ -25,7 +25,7 @@ on: jobs: tag: if: ${{ !github.event.repository.fork }} - runs-on: ubuntu-latest + runs-on: ubuntu-26.04 permissions: contents: write diff --git a/.github/workflows/validate_secrets.yml b/.github/workflows/validate_secrets.yml index 415ba0019..0c87b7d29 100644 --- a/.github/workflows/validate_secrets.yml +++ b/.github/workflows/validate_secrets.yml @@ -5,7 +5,7 @@ on: [workflow_call, workflow_dispatch] jobs: validate-access-token: name: Access - runs-on: ubuntu-latest + runs-on: ubuntu-26.04 env: GH_PAT: ${{ secrets.GH_PAT }} GH_TOKEN: ${{ secrets.GH_PAT }} @@ -105,7 +105,7 @@ jobs: validate-fastlane-secrets: name: Fastlane needs: [validate-access-token] - runs-on: macos-15 + runs-on: xcode-27 env: GH_PAT: ${{ secrets.GH_PAT }} GH_TOKEN: ${{ secrets.GH_PAT }} diff --git a/.github/workflows/warn_main_pr.yml b/.github/workflows/warn_main_pr.yml index 3f918f8f8..867c4ffbb 100644 --- a/.github/workflows/warn_main_pr.yml +++ b/.github/workflows/warn_main_pr.yml @@ -9,7 +9,7 @@ on: jobs: warn: if: ${{ !github.event.repository.fork }} - runs-on: ubuntu-latest + runs-on: ubuntu-26.04 permissions: pull-requests: write diff --git a/Config.xcconfig b/Config.xcconfig index 04259f731..906e04a70 100644 --- a/Config.xcconfig +++ b/Config.xcconfig @@ -6,4 +6,4 @@ unique_id = ${DEVELOPMENT_TEAM} //Version (DEFAULT) -LOOP_FOLLOW_MARKETING_VERSION = 7.1.0 +LOOP_FOLLOW_MARKETING_VERSION = 8.0.0 diff --git a/Gemfile b/Gemfile index 502d6331b..599db6eab 100644 --- a/Gemfile +++ b/Gemfile @@ -1,2 +1,2 @@ source "https://rubygems.org" -gem "fastlane", "2.237.0" +gem "fastlane", "2.240.1" diff --git a/Gemfile.lock b/Gemfile.lock index 8e70830ea..67110040a 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -30,6 +30,7 @@ GEM base64 (0.3.0) benchmark (0.5.0) bigdecimal (4.1.2) + cgi (0.5.2) claide (1.1.0) colored (1.2) colored2 (3.1.2) @@ -42,38 +43,26 @@ GEM domain_name (0.6.20240107) dotenv (2.8.1) emoji_regex (3.2.3) + erb (6.0.7) excon (1.5.0) logger - faraday (1.10.6) - faraday-em_http (~> 1.0) - faraday-em_synchrony (~> 1.0) - faraday-excon (~> 1.1) - faraday-httpclient (~> 1.0) - faraday-multipart (~> 1.0) - faraday-net_http (~> 1.0) - faraday-net_http_persistent (~> 1.0) - faraday-patron (~> 1.0) - faraday-rack (~> 1.0) - faraday-retry (~> 1.0) - ruby2_keywords (>= 0.0.4) + faraday (2.14.4) + faraday-net_http (>= 2.0, < 3.5) + json + logger faraday-cookie_jar (0.0.8) faraday (>= 0.8.0) http-cookie (>= 1.0.0) - faraday-em_http (1.0.0) - faraday-em_synchrony (1.0.1) - faraday-excon (1.1.0) - faraday-httpclient (1.0.1) + faraday-follow_redirects (0.5.0) + faraday (>= 1, < 3) 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) + faraday-net_http (3.4.4) + net-http (~> 0.5) + faraday-retry (2.4.0) + faraday (~> 2.0) fastimage (2.4.1) - fastlane (2.237.0) + fastlane (2.240.1) CFPropertyList (>= 2.3, < 5.0.0) abbrev (~> 0.1) addressable (>= 2.9.0, < 3.0.0) @@ -83,24 +72,27 @@ GEM base64 (~> 0.2) benchmark (>= 0.1.0) bundler (>= 2.4.0, < 5.0.0) + cgi (~> 0.4) colored (~> 1.2) commander (~> 4.6) csv (~> 3.3) dotenv (>= 2.1.1, < 3.0.0) emoji_regex (>= 0.1, < 4.0) excon (>= 0.71.0, < 2.0.0) - faraday (~> 1.0) - faraday-cookie_jar (~> 0.0.6) - faraday_middleware (~> 1.0) + faraday (~> 2.7) + faraday-cookie_jar (~> 0.0.8) + faraday-follow_redirects (~> 0.3) + faraday-multipart (~> 1.0) + faraday-retry (~> 2.0) fastimage (>= 2.1.0, < 3.0.0) fastlane-sirp (>= 1.1.0) gh_inspector (>= 1.1.2, < 2.0.0) - google-apis-androidpublisher_v3 (~> 0.3) + google-apis-androidpublisher_v3 (~> 0.99) google-apis-playcustomapp_v1 (~> 0.1) - google-cloud-env (>= 1.6.0, < 2.3.0) + google-cloud-env (>= 1.6.0, < 2.4.0) google-cloud-storage (~> 1.31) highline (~> 2.0) - http-cookie (~> 1.0.5) + irb (>= 1.8) json (< 3.0.0) jwt (>= 2.10.3, < 4) logger (>= 1.6, < 2.0) @@ -113,11 +105,11 @@ GEM optparse (>= 0.1.1, < 1.0.0) ostruct (>= 0.1.0) plist (>= 3.1.0, < 4.0.0) - rubyzip (>= 2.0.0, < 3.0.0) - security (= 0.1.5) + rubyzip (>= 3.4.0, < 4.0.0) + security (~> 0.3) simctl (~> 1.6.3) terminal-notifier (>= 2.0.0, < 3.0.0) - terminal-table (~> 3) + terminal-table (~> 4) tty-screen (>= 0.6.3, < 1.0.0) tty-spinner (>= 0.8.0, < 1.0.0) word_wrap (~> 1.0.0) @@ -172,8 +164,14 @@ GEM domain_name (~> 0.5) httpclient (2.9.0) mutex_m + io-console (0.9.4) + irb (1.18.0) + pp (>= 0.6.0) + prism (>= 1.3.0) + rdoc (>= 4.0.0) + reline (>= 0.4.2) jmespath (1.6.2) - json (2.20.0) + json (2.21.2) jwt (3.2.0) base64 logger (1.7.0) @@ -184,14 +182,31 @@ GEM mutex_m (0.3.0) nanaimo (0.4.0) naturally (2.3.0) + net-http (0.9.1) + uri (>= 0.11.1) nkf (0.3.0) optparse (0.8.1) os (1.1.4) ostruct (0.6.3) plist (3.7.2) + pp (0.6.4) + prettyprint + prettyprint (0.2.0) + prism (1.9.0) pstore (0.2.1) public_suffix (7.0.5) rake (13.4.2) + rbs (4.2.0) + logger + prism (>= 1.6.0) + tsort + rdoc (8.1.0) + erb + prism (>= 1.6.0) + rbs (>= 4.0.0) + tsort + reline (0.7.0) + io-console (~> 0.5) representable (3.2.0) declarative (< 0.1.0) trailblazer-option (>= 0.1.1, < 0.2.0) @@ -199,9 +214,8 @@ GEM retriable (3.8.0) rexml (3.4.4) rouge (3.28.0) - ruby2_keywords (0.0.5) - rubyzip (2.4.1) - security (0.1.5) + rubyzip (3.7.0) + security (0.3.0) signet (0.22.0) addressable (~> 2.8) faraday (>= 0.17.5, < 3.a) @@ -210,15 +224,17 @@ GEM CFPropertyList naturally terminal-notifier (2.0.0) - terminal-table (3.0.2) - unicode-display_width (>= 1.1.1, < 3) + terminal-table (4.0.0) + unicode-display_width (>= 1.1.1, < 4) trailblazer-option (0.1.2) + tsort (0.2.0) tty-cursor (0.7.1) tty-screen (0.8.2) tty-spinner (0.9.3) tty-cursor (~> 0.7) uber (0.1.0) unicode-display_width (2.6.0) + uri (1.1.1) word_wrap (1.0.0) xcodeproj (1.28.1) CFPropertyList (>= 2.3.3, < 4.0) @@ -239,7 +255,7 @@ PLATFORMS ruby DEPENDENCIES - fastlane (= 2.237.0) + fastlane (= 2.240.1) BUNDLED WITH 4.0.12 diff --git a/LoopFollow.xcodeproj/project.pbxproj b/LoopFollow.xcodeproj/project.pbxproj index 560cd2f8e..e9db02c3f 100644 --- a/LoopFollow.xcodeproj/project.pbxproj +++ b/LoopFollow.xcodeproj/project.pbxproj @@ -17,6 +17,9 @@ DDF2C0142BEFD468007A20E6 /* blacklisted-versions.json in Resources */ = {isa = PBXBuildFile; fileRef = DDF2C0132BEFD468007A20E6 /* blacklisted-versions.json */; }; FC5A5C3D2497B229009C550E /* Config.xcconfig in Resources */ = {isa = PBXBuildFile; fileRef = FC5A5C3C2497B229009C550E /* Config.xcconfig */; }; FCFEEC9E2486E68E00402A7F /* WebKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = FCFEEC9D2486E68E00402A7F /* WebKit.framework */; }; + DD57A7C02F70000000000001 /* LoopFollowWatch.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = DD57A7C02F70000000000002 /* LoopFollowWatch.app */; platformFilter = ios; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + DD57A7C02F70000000000003 /* LoopFollowWatchWidgets.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = DD57A7C02F70000000000004 /* LoopFollowWatchWidgets.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + DD57A7C02F70000000000103 /* ShareClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD57A7C02F70000000000102 /* ShareClient.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -34,6 +37,20 @@ remoteGlobalIDString = FC9788132485969B00A7906C; remoteInfo = LoopFollow; }; + DD57A7C02F70000000000005 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = FC97880C2485969B00A7906C /* Project object */; + proxyType = 1; + remoteGlobalIDString = DD57A7C02F70000000000006; + remoteInfo = LoopFollowWatch; + }; + DD57A7C02F70000000000007 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = FC97880C2485969B00A7906C /* Project object */; + proxyType = 1; + remoteGlobalIDString = DD57A7C02F70000000000008; + remoteInfo = LoopFollowWatchWidgets; + }; /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -48,6 +65,28 @@ name = "Embed Foundation Extensions"; runOnlyForDeploymentPostprocessing = 0; }; + DD57A7C02F70000000000009 /* Embed Watch Content */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = "$(CONTENTS_FOLDER_PATH)/Watch"; + dstSubfolderSpec = 16; + files = ( + DD57A7C02F70000000000001 /* LoopFollowWatch.app in Embed Watch Content */, + ); + name = "Embed Watch Content"; + runOnlyForDeploymentPostprocessing = 0; + }; + DD57A7C02F7000000000000A /* Embed Foundation Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 13; + files = ( + DD57A7C02F70000000000003 /* LoopFollowWatchWidgets.appex in Embed Foundation Extensions */, + ); + name = "Embed Foundation Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ @@ -68,6 +107,9 @@ FC9788142485969B00A7906C /* Loop Follow.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Loop Follow.app"; sourceTree = BUILT_PRODUCTS_DIR; }; FCE537C2249AAB2600F80BF8 /* NotificationCenter.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = NotificationCenter.framework; path = System/Library/Frameworks/NotificationCenter.framework; sourceTree = SDKROOT; }; FCFEEC9D2486E68E00402A7F /* WebKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WebKit.framework; path = System/Library/Frameworks/WebKit.framework; sourceTree = SDKROOT; }; + DD57A7C02F70000000000002 /* LoopFollowWatch.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = LoopFollowWatch.app; sourceTree = BUILT_PRODUCTS_DIR; }; + DD57A7C02F70000000000004 /* LoopFollowWatchWidgets.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = LoopFollowWatchWidgets.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + DD57A7C02F70000000000102 /* ShareClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ShareClient.swift; path = Pods/ShareClient/ShareClient/ShareClient.swift; sourceTree = SOURCE_ROOT; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ @@ -78,6 +120,42 @@ ); target = FC9788132485969B00A7906C /* LoopFollow */; }; + DD57A7C02F7000000000000B /* Exceptions for "LoopFollowWatch" folder in "LoopFollowWatch" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + LoopFollowWatch.entitlements, + ); + target = DD57A7C02F70000000000006 /* LoopFollowWatch */; + }; + DD57A7C02F7000000000000C /* Exceptions for "LoopFollowWatchWidgets" folder in "LoopFollowWatchWidgets" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + LoopFollowWatchWidgets.entitlements, + ); + target = DD57A7C02F70000000000008 /* LoopFollowWatchWidgets */; + }; + DD57A7C02F7000000000000D /* Exceptions for "Shared" folder in "LoopFollowWatch" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + GlucoseLiveActivityAttributes.swift, + LAAppGroupSettings.swift, + LALivenessMarker.swift, + LALivenessStore.swift, + ); + target = DD57A7C02F70000000000006 /* LoopFollowWatch */; + }; + DD57A7C02F7000000000000E /* Exceptions for "Shared" folder in "LoopFollowWatchWidgets" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + GlucoseLiveActivityAttributes.swift, + LAAppGroupSettings.swift, + LALivenessMarker.swift, + LALivenessStore.swift, + ); + target = DD57A7C02F70000000000008 /* LoopFollowWatchWidgets */; + }; /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ @@ -103,6 +181,8 @@ BC0FBFD3754955D028EF6631 /* Shared */ = { isa = PBXFileSystemSynchronizedRootGroup; exceptions = ( + DD57A7C02F7000000000000D /* Exceptions for "Shared" folder in "LoopFollowWatch" target */, + DD57A7C02F7000000000000E /* Exceptions for "Shared" folder in "LoopFollowWatchWidgets" target */, ); path = Shared; sourceTree = ""; @@ -118,6 +198,36 @@ path = Tests; sourceTree = ""; }; + DD57A7C02F7000000000000F /* LoopFollowWatch */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + DD57A7C02F7000000000000B /* Exceptions */, + ); + path = LoopFollowWatch; + sourceTree = ""; + }; + DD57A7C02F70000000000010 /* LoopFollowWatchWidgets */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + DD57A7C02F7000000000000C /* Exceptions */, + ); + path = LoopFollowWatchWidgets; + sourceTree = ""; + }; + DD57A7C02F70000000000011 /* WatchShared */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + ); + path = WatchShared; + sourceTree = ""; + }; + DD57A7C02F70000000000101 /* RemoteShared */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + ); + path = RemoteShared; + sourceTree = ""; + }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ @@ -147,6 +257,20 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + DD57A7C02F70000000000012 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + DD57A7C02F70000000000013 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -168,6 +292,7 @@ children = ( ECA3EFB4037410B4973BB632 /* Pods-LoopFollow.debug.xcconfig */, 059B0FA59AABFE72FE13DDDA /* Pods-LoopFollow.release.xcconfig */, + DD57A7C02F70000000000102 /* ShareClient.swift */, ); path = Pods; sourceTree = ""; @@ -196,6 +321,10 @@ 6A5880E0B811AF443B05AB02 /* Frameworks */, 9555FE45F7FAEFA6142D04CA /* LoopFollow */, BC0FBFD3754955D028EF6631 /* Shared */, + DD57A7C02F7000000000000F /* LoopFollowWatch */, + DD57A7C02F70000000000010 /* LoopFollowWatchWidgets */, + DD57A7C02F70000000000011 /* WatchShared */, + DD57A7C02F70000000000101 /* RemoteShared */, ); sourceTree = ""; }; @@ -205,6 +334,8 @@ FC9788142485969B00A7906C /* Loop Follow.app */, DDCC3AD62DDE1790006F1C10 /* Tests.xctest */, 37A4BDD92F5B6B4A00EEB289 /* LoopFollowLAExtensionExtension.appex */, + DD57A7C02F70000000000002 /* LoopFollowWatch.app */, + DD57A7C02F70000000000004 /* LoopFollowWatchWidgets.appex */, ); name = Products; sourceTree = ""; @@ -266,15 +397,19 @@ 04DA71CCA0280FA5FA2DF7A6 /* [CP] Embed Pods Frameworks */, DDB0AF532BB1AA0900AFA48B /* Capture Build Details */, 37A4BDED2F5B6B4C00EEB289 /* Embed Foundation Extensions */, + DD57A7C02F70000000000009 /* Embed Watch Content */, ); buildRules = ( ); dependencies = ( 37A4BDE72F5B6B4C00EEB289 /* PBXTargetDependency */, + DD57A7C02F7000000000001B /* PBXTargetDependency */, ); fileSystemSynchronizedGroups = ( 9555FE45F7FAEFA6142D04CA /* LoopFollow */, BC0FBFD3754955D028EF6631 /* Shared */, + DD57A7C02F70000000000011 /* WatchShared */, + DD57A7C02F70000000000101 /* RemoteShared */, ); name = LoopFollow; packageProductDependencies = ( @@ -284,6 +419,53 @@ productReference = FC9788142485969B00A7906C /* Loop Follow.app */; productType = "com.apple.product-type.application"; }; + DD57A7C02F70000000000006 /* LoopFollowWatch */ = { + isa = PBXNativeTarget; + buildConfigurationList = DD57A7C02F70000000000014 /* Build configuration list for PBXNativeTarget "LoopFollowWatch" */; + buildPhases = ( + DD57A7C02F70000000000015 /* Sources */, + DD57A7C02F70000000000012 /* Frameworks */, + DD57A7C02F70000000000016 /* Resources */, + DD57A7C02F7000000000000A /* Embed Foundation Extensions */, + ); + buildRules = ( + ); + dependencies = ( + DD57A7C02F70000000000017 /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + DD57A7C02F7000000000000F /* LoopFollowWatch */, + DD57A7C02F70000000000011 /* WatchShared */, + BC0FBFD3754955D028EF6631 /* Shared */, + DD57A7C02F70000000000101 /* RemoteShared */, + ); + name = LoopFollowWatch; + productName = LoopFollowWatch; + productReference = DD57A7C02F70000000000002 /* LoopFollowWatch.app */; + productType = "com.apple.product-type.application"; + }; + DD57A7C02F70000000000008 /* LoopFollowWatchWidgets */ = { + isa = PBXNativeTarget; + buildConfigurationList = DD57A7C02F70000000000018 /* Build configuration list for PBXNativeTarget "LoopFollowWatchWidgets" */; + buildPhases = ( + DD57A7C02F70000000000019 /* Sources */, + DD57A7C02F70000000000013 /* Frameworks */, + DD57A7C02F7000000000001A /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + DD57A7C02F70000000000010 /* LoopFollowWatchWidgets */, + DD57A7C02F70000000000011 /* WatchShared */, + BC0FBFD3754955D028EF6631 /* Shared */, + ); + name = LoopFollowWatchWidgets; + productName = LoopFollowWatchWidgets; + productReference = DD57A7C02F70000000000004 /* LoopFollowWatchWidgets.appex */; + productType = "com.apple.product-type.app-extension"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -297,6 +479,12 @@ 37A4BDD82F5B6B4A00EEB289 = { CreatedOnToolsVersion = 26.2; }; + DD57A7C02F70000000000006 = { + CreatedOnToolsVersion = 27.0; + }; + DD57A7C02F70000000000008 = { + CreatedOnToolsVersion = 27.0; + }; DDCC3AD52DDE1790006F1C10 = { CreatedOnToolsVersion = 16.3; TestTargetID = FC9788132485969B00A7906C; @@ -325,6 +513,8 @@ FC9788132485969B00A7906C /* LoopFollow */, DDCC3AD52DDE1790006F1C10 /* Tests */, 37A4BDD82F5B6B4A00EEB289 /* LoopFollowLAExtensionExtension */, + DD57A7C02F70000000000006 /* LoopFollowWatch */, + DD57A7C02F70000000000008 /* LoopFollowWatchWidgets */, ); }; /* End PBXProject section */ @@ -355,6 +545,20 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + DD57A7C02F70000000000016 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + DD57A7C02F7000000000001A /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ @@ -462,6 +666,21 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + DD57A7C02F70000000000015 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + DD57A7C02F70000000000103 /* ShareClient.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + DD57A7C02F70000000000019 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ @@ -476,6 +695,17 @@ target = FC9788132485969B00A7906C /* LoopFollow */; targetProxy = DDCC3ADA2DDE1790006F1C10 /* PBXContainerItemProxy */; }; + DD57A7C02F7000000000001B /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + platformFilter = ios; + target = DD57A7C02F70000000000006 /* LoopFollowWatch */; + targetProxy = DD57A7C02F70000000000005 /* PBXContainerItemProxy */; + }; + DD57A7C02F70000000000017 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = DD57A7C02F70000000000008 /* LoopFollowWatchWidgets */; + targetProxy = DD57A7C02F70000000000007 /* PBXContainerItemProxy */; + }; /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ @@ -813,6 +1043,114 @@ }; name = Release; }; + DD57A7C02F7000000000001C /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_ENTITLEMENTS = LoopFollowWatch/LoopFollowWatch.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = "$(LF_DEVELOPMENT_TEAM)"; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = LoopFollowWatch/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = "com.$(unique_id).LoopFollow$(app_suffix).watchkitapp"; + PRODUCT_MODULE_NAME = LoopFollowWatch; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = watchos; + SKIP_INSTALL = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 4; + WATCHOS_DEPLOYMENT_TARGET = 10.0; + }; + name = Debug; + }; + DD57A7C02F7000000000001D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_ENTITLEMENTS = LoopFollowWatch/LoopFollowWatch.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = "$(LF_DEVELOPMENT_TEAM)"; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = LoopFollowWatch/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = "com.$(unique_id).LoopFollow$(app_suffix).watchkitapp"; + PRODUCT_MODULE_NAME = LoopFollowWatch; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = watchos; + SKIP_INSTALL = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 4; + WATCHOS_DEPLOYMENT_TARGET = 10.0; + }; + name = Release; + }; + DD57A7C02F7000000000001E /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + APPLICATION_EXTENSION_API_ONLY = YES; + CODE_SIGN_ENTITLEMENTS = LoopFollowWatchWidgets/LoopFollowWatchWidgets.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = "$(LF_DEVELOPMENT_TEAM)"; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = LoopFollowWatchWidgets/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = "com.$(unique_id).LoopFollow$(app_suffix).watchkitapp.widgets"; + PRODUCT_MODULE_NAME = LoopFollowWatchWidgets; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = watchos; + SKIP_INSTALL = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 4; + WATCHOS_DEPLOYMENT_TARGET = 10.0; + }; + name = Debug; + }; + DD57A7C02F7000000000001F /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + APPLICATION_EXTENSION_API_ONLY = YES; + CODE_SIGN_ENTITLEMENTS = LoopFollowWatchWidgets/LoopFollowWatchWidgets.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = "$(LF_DEVELOPMENT_TEAM)"; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = LoopFollowWatchWidgets/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = "com.$(unique_id).LoopFollow$(app_suffix).watchkitapp.widgets"; + PRODUCT_MODULE_NAME = LoopFollowWatchWidgets; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = watchos; + SKIP_INSTALL = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 4; + WATCHOS_DEPLOYMENT_TARGET = 10.0; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -852,6 +1190,24 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + DD57A7C02F70000000000014 /* Build configuration list for PBXNativeTarget "LoopFollowWatch" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + DD57A7C02F7000000000001C /* Debug */, + DD57A7C02F7000000000001D /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + DD57A7C02F70000000000018 /* Build configuration list for PBXNativeTarget "LoopFollowWatchWidgets" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + DD57A7C02F7000000000001E /* Debug */, + DD57A7C02F7000000000001F /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ diff --git a/LoopFollow/Alarm/Alarm.swift b/LoopFollow/Alarm/Alarm.swift index f429aa04a..7bf83811a 100644 --- a/LoopFollow/Alarm/Alarm.swift +++ b/LoopFollow/Alarm/Alarm.swift @@ -218,10 +218,18 @@ struct Alarm: Identifiable, Codable, Equatable { /// CGM sensor lifetime in days (e.g. 10 for Dexcom G6, 15 for G7 15-day) var sensorLifetimeDays: Int? + /// Extra notification text: the CGM state behind a gap in readings. + private var notificationBody: String? { + guard type == .missedReading || type == .notLooping, + let state = Observable.shared.cgmSensorState.value + else { return nil } + return "CGM reported: \(state.summary)" + } + /// Function for when the alarm is triggered. /// If this alarm, all alarms is disabled or snoozed, then should not be called. This or all alarmd could be muted, then this function will just generate a notification. - func trigger(config: AlarmConfiguration, now: Date) { - LogManager.shared.log(category: .alarm, message: "Alarm triggered: \(type.rawValue)") + func trigger(config: AlarmConfiguration, now: Date, titleOverride: String? = nil) { + LogManager.shared.log(category: .alarm, message: "Alarm triggered: \(titleOverride ?? type.rawValue)") var playSound = true @@ -277,7 +285,7 @@ struct Alarm: Identifiable, Codable, Equatable { } }() - AlarmManager.shared.sendNotification(title: type.rawValue, actionTitle: snoozeDuration == 0 ? "Acknowledge" : "Snooze") + AlarmManager.shared.sendNotification(title: titleOverride ?? type.rawValue, body: notificationBody, actionTitle: snoozeDuration == 0 ? "Acknowledge" : "Snooze") if playSound { AlarmSound.setSoundFile(soundFile) diff --git a/LoopFollow/Alarm/AlarmCondition/AlarmCondition.swift b/LoopFollow/Alarm/AlarmCondition/AlarmCondition.swift index 8e60bcb9d..242d84069 100644 --- a/LoopFollow/Alarm/AlarmCondition/AlarmCondition.swift +++ b/LoopFollow/Alarm/AlarmCondition/AlarmCondition.swift @@ -8,6 +8,9 @@ protocol AlarmCondition { init() /// pure, per-alarm logic against `AlarmData` func evaluate(alarm: Alarm, data: AlarmData, now: Date) -> Bool + /// Title for the firing `evaluate` just reported; nil (the default) uses + /// the alarm type's name. + var firedTitle: String? { get } /// `true` when `belowBG`/`aboveBG` are this condition's own trigger /// threshold, checked in `evaluate`; `false` (the default) treats them /// as activation limits enforced by `passesBGLimits`. @@ -17,6 +20,8 @@ protocol AlarmCondition { extension AlarmCondition { var checksOwnBGLimits: Bool { false } + var firedTitle: String? { nil } + /// Returns `true` when the alarm is allowed to continue evaluating /// after BG-limit checks; `false` blocks it immediately. func passesBGLimits(alarm: Alarm, data: AlarmData) -> Bool { diff --git a/LoopFollow/Alarm/AlarmCondition/EndAlarmPhases.swift b/LoopFollow/Alarm/AlarmCondition/EndAlarmPhases.swift new file mode 100644 index 000000000..db4dd32b9 --- /dev/null +++ b/LoopFollow/Alarm/AlarmCondition/EndAlarmPhases.swift @@ -0,0 +1,47 @@ +// LoopFollow +// EndAlarmPhases.swift + +import Foundation + +/// What an end alarm reports: the event has ended, or its scheduled end is +/// within the alarm's early-warning lead time. +enum EndAlarmPhase { + case ended + case endingSoon +} + +/// Two-phase evaluation shared by the temp target and override end alarms. +/// Each phase fires at most once per event; the markers hold the end +/// timestamp of the event that last fired that phase. +struct EndAlarmPhases { + /// Ends older than this are not reported. + static let endedGrace: TimeInterval = 15 * 60 + + let latestStart: TimeInterval? + let latestEnd: TimeInterval? + let activeEnd: TimeInterval? + let leadMinutes: Int? + let endedMarker: StorageValue + let warnedMarker: StorageValue + + /// The ended phase takes precedence when both are due in the same tick. + func fire(now: Date) -> EndAlarmPhase? { + let nowTS = now.timeIntervalSince1970 + + if let endTS = latestEnd, endTS > 0, + nowTS - endTS <= Self.endedGrace, + endTS > (endedMarker.value ?? 0) + { + endedMarker.value = endTS + return .ended + } + + guard let lead = leadMinutes, lead > 0, let endTS = activeEnd else { return nil } + let warningOpensAt = endTS - Double(lead) * 60 + // An event shorter than the lead time is covered by its start alarm. + if let startTS = latestStart, warningOpensAt <= startTS { return nil } + guard nowTS >= warningOpensAt, warnedMarker.value != endTS else { return nil } + warnedMarker.value = endTS + return .endingSoon + } +} diff --git a/LoopFollow/Alarm/AlarmCondition/LowBGCondition.swift b/LoopFollow/Alarm/AlarmCondition/LowBGCondition.swift index cb4610f74..2d48fe25f 100644 --- a/LoopFollow/Alarm/AlarmCondition/LowBGCondition.swift +++ b/LoopFollow/Alarm/AlarmCondition/LowBGCondition.swift @@ -8,6 +8,17 @@ import Foundation /// • any predicted BG within `predictiveMinutes` is ≤ `belowBG`. struct LowBGCondition: AlarmCondition { static let type: AlarmType = .low + + /// Longest predictive look-ahead offered by the alarm editor, in minutes. + static let maxPredictiveMinutes = 60 + + /// Number of forecast points (5-minute spacing) needed to look `minutes` + /// ahead: the first point is the current value, so the horizon takes + /// ceil(minutes / 5) points beyond it. + static func forecastPoints(forMinutes minutes: Int) -> Int { + Int(ceil(Double(minutes) / 5.0)) + 1 + } + init() {} /// `belowBG` is this alarm's trigger threshold, not an activation limit: @@ -34,15 +45,9 @@ struct LowBGCondition: AlarmCondition { predictiveMinutes > 0, !data.predictionData.isEmpty { - let lookAhead = min( - data.predictionData.count, - Int(ceil(Double(predictiveMinutes) / 5.0)) - ) + let points = Self.forecastPoints(forMinutes: predictiveMinutes) - for i in 0 ..< lookAhead where isLow(data.predictionData[i]) { - predictiveTrigger = true - break - } + predictiveTrigger = data.predictionData.prefix(points).contains(where: isLow) } // ──────────────────────────────── diff --git a/LoopFollow/Alarm/AlarmCondition/MissedBolusCondition.swift b/LoopFollow/Alarm/AlarmCondition/MissedBolusCondition.swift index 53bbeabc4..3faf61ce9 100644 --- a/LoopFollow/Alarm/AlarmCondition/MissedBolusCondition.swift +++ b/LoopFollow/Alarm/AlarmCondition/MissedBolusCondition.swift @@ -5,7 +5,8 @@ import Foundation /// Fires when a carb entry is logged but **no** qualifying bolus is given /// within the user-defined “delay” (after allowing for a pre-bolus window). -/// • Ignores small-carb treatments, tiny boluses, and low-BG scenarios. +/// • Ignores small-carb treatments and tiny boluses; the optional BG limit +/// (`aboveBG`) is enforced by `passesBGLimits`. /// • Triggers once per carb entry. struct MissedBolusCondition: AlarmCondition { static let type: AlarmType = .missedBolus @@ -19,14 +20,13 @@ struct MissedBolusCondition: AlarmCondition { let delayMin = alarm.monitoringWindow, delayMin > 0, let prebolusMin = alarm.predictiveMinutes, let minBolusU = alarm.delta, // ignore bolus ≤ - let minCarbGr = alarm.threshold, // ignore carbs ≤ - let minBG = alarm.aboveBG // ignore BG ≤ + let minCarbGr = alarm.threshold // ignore carbs ≤ else { return false } // ──────────────────────────────── - // 1. get most-recent carb entry + // 1. get most-recent past carb entry // ──────────────────────────────── - guard let carb = data.recentCarbs.last else { return false } + guard let carb = data.recentCarbs.last(where: { $0.date <= now }) else { return false } // – must be at least `delayMin` old, but not older than 60 min guard carb.date > now.addingTimeInterval(-3600), @@ -36,10 +36,6 @@ struct MissedBolusCondition: AlarmCondition { // – ignore tiny carbs guard carb.grams > minCarbGr else { return false } - // – ignore if BG is low - if let latestBG = data.bgReadings.last, - Double(latestBG.sgv) <= minBG { return false } - // ──────────────────────────────── // 2. already alerted for this carb? // ──────────────────────────────── diff --git a/LoopFollow/Alarm/AlarmCondition/OverrideEndCondition.swift b/LoopFollow/Alarm/AlarmCondition/OverrideEndCondition.swift index e54ebd08d..e10bae970 100644 --- a/LoopFollow/Alarm/AlarmCondition/OverrideEndCondition.swift +++ b/LoopFollow/Alarm/AlarmCondition/OverrideEndCondition.swift @@ -3,18 +3,23 @@ import Foundation -struct OverrideEndCondition: AlarmCondition { +/// Fires once when the active override ends and, with `predictiveMinutes` +/// set, once that many minutes before the scheduled end. +final class OverrideEndCondition: AlarmCondition { static let type: AlarmType = .overrideEnd + private(set) var firedTitle: String? init() {} - func evaluate(alarm _: Alarm, data: AlarmData, now: Date) -> Bool { - guard let endTS = data.latestOverrideEnd, endTS > 0 else { return false } - guard now.timeIntervalSince1970 - endTS <= 15 * 60 else { return false } - - let last = Storage.shared.lastOverrideEndNotified.value ?? 0 - guard endTS > last else { return false } - - Storage.shared.lastOverrideEndNotified.value = endTS - return true + func evaluate(alarm: Alarm, data: AlarmData, now: Date) -> Bool { + let phase = EndAlarmPhases( + latestStart: data.latestOverrideStart, + latestEnd: data.latestOverrideEnd, + activeEnd: data.activeOverrideEnd, + leadMinutes: alarm.predictiveMinutes, + endedMarker: Storage.shared.lastOverrideEndNotified, + warnedMarker: Storage.shared.lastOverridePreEndNotified + ).fire(now: now) + firedTitle = phase == .endingSoon ? "Override Ending Soon" : nil + return phase != nil } } diff --git a/LoopFollow/Alarm/AlarmCondition/TempTargetEndCondition.swift b/LoopFollow/Alarm/AlarmCondition/TempTargetEndCondition.swift index 207332e96..800129f96 100644 --- a/LoopFollow/Alarm/AlarmCondition/TempTargetEndCondition.swift +++ b/LoopFollow/Alarm/AlarmCondition/TempTargetEndCondition.swift @@ -3,19 +3,23 @@ import Foundation -/// Fires once when the active temp target ends. -struct TempTargetEndCondition: AlarmCondition { +/// Fires once when the active temp target ends and, with `predictiveMinutes` +/// set, once that many minutes before the scheduled end. +final class TempTargetEndCondition: AlarmCondition { static let type: AlarmType = .tempTargetEnd + private(set) var firedTitle: String? init() {} - func evaluate(alarm _: Alarm, data: AlarmData, now: Date) -> Bool { - guard let endTS = data.latestTempTargetEnd, endTS > 0 else { return false } - guard now.timeIntervalSince1970 - endTS <= 15 * 60 else { return false } - - let last = Storage.shared.lastTempTargetEndNotified.value ?? 0 - guard endTS > last else { return false } - - Storage.shared.lastTempTargetEndNotified.value = endTS - return true + func evaluate(alarm: Alarm, data: AlarmData, now: Date) -> Bool { + let phase = EndAlarmPhases( + latestStart: data.latestTempTargetStart, + latestEnd: data.latestTempTargetEnd, + activeEnd: data.activeTempTargetEnd, + leadMinutes: alarm.predictiveMinutes, + endedMarker: Storage.shared.lastTempTargetEndNotified, + warnedMarker: Storage.shared.lastTempTargetPreEndNotified + ).fire(now: now) + firedTitle = phase == .endingSoon ? "Temp Target Ending Soon" : nil + return phase != nil } } diff --git a/LoopFollow/Alarm/AlarmData.swift b/LoopFollow/Alarm/AlarmData.swift index 588c37c5f..dcf6bf946 100644 --- a/LoopFollow/Alarm/AlarmData.swift +++ b/LoopFollow/Alarm/AlarmData.swift @@ -12,6 +12,10 @@ struct AlarmData: Codable { let latestOverrideEnd: TimeInterval? let latestTempTargetStart: TimeInterval? let latestTempTargetEnd: TimeInterval? + /// Scheduled end of the currently-active override/temp target, if known + /// in advance — used by the end alarms' early warning. + var activeOverrideEnd: TimeInterval? + var activeTempTargetEnd: TimeInterval? let recBolus: Double? let COB: Double? let sageInsertTime: TimeInterval? diff --git a/LoopFollow/Alarm/AlarmEditing/Editors/LowBgAlarmEditor.swift b/LoopFollow/Alarm/AlarmEditing/Editors/LowBgAlarmEditor.swift index a21db2239..a71f7f60b 100644 --- a/LoopFollow/Alarm/AlarmEditing/Editors/LowBgAlarmEditor.swift +++ b/LoopFollow/Alarm/AlarmEditing/Editors/LowBgAlarmEditor.swift @@ -37,7 +37,7 @@ struct LowBgAlarmEditor: View { + "if any future value is at or below the threshold, " + "you’ll be warned early. Set 0 to disable.", title: "Predictive", - range: 0 ... 60, + range: 0 ... Double(LowBGCondition.maxPredictiveMinutes), step: 5, unitLabel: alarm.type.snoozeTimeUnit.label, value: $alarm.predictiveMinutes diff --git a/LoopFollow/Alarm/AlarmEditing/Editors/OverrideEndAlarmEditor.swift b/LoopFollow/Alarm/AlarmEditing/Editors/OverrideEndAlarmEditor.swift index 5d88a527a..d29a4a4e3 100644 --- a/LoopFollow/Alarm/AlarmEditing/Editors/OverrideEndAlarmEditor.swift +++ b/LoopFollow/Alarm/AlarmEditing/Editors/OverrideEndAlarmEditor.swift @@ -8,10 +8,21 @@ struct OverrideEndAlarmEditor: View { var body: some View { Group { - InfoBanner(text: "Alerts when an override ends.", alarmType: alarm.type) + InfoBanner(text: "Alerts when an override ends, with an optional early warning before the scheduled end.", alarmType: alarm.type) AlarmGeneralSection(alarm: $alarm) + AlarmStepperSection( + header: "Early Warning", + footer: "Also alert this many minutes before the override " + + "is scheduled to end. Set to 0 to alert only when it ends.", + title: "Warn before end", + range: 0 ... 30, + step: 5, + unitLabel: alarm.type.snoozeTimeUnit.label, + value: $alarm.predictiveMinutes + ) + AlarmActiveSection(alarm: $alarm) AlarmAudioSection(alarm: $alarm, hideRepeat: true) AlarmSnoozeSection(alarm: $alarm) diff --git a/LoopFollow/Alarm/AlarmEditing/Editors/TempTargetEndAlarmEditor.swift b/LoopFollow/Alarm/AlarmEditing/Editors/TempTargetEndAlarmEditor.swift index 03a7756f9..b191da54f 100644 --- a/LoopFollow/Alarm/AlarmEditing/Editors/TempTargetEndAlarmEditor.swift +++ b/LoopFollow/Alarm/AlarmEditing/Editors/TempTargetEndAlarmEditor.swift @@ -8,10 +8,21 @@ struct TempTargetEndAlarmEditor: View { var body: some View { Group { - InfoBanner(text: "Alerts when a temp target ends.", alarmType: alarm.type) + InfoBanner(text: "Alerts when a temp target ends, with an optional early warning before the scheduled end.", alarmType: alarm.type) AlarmGeneralSection(alarm: $alarm) + AlarmStepperSection( + header: "Early Warning", + footer: "Also alert this many minutes before the temp target " + + "is scheduled to end. Set to 0 to alert only when it ends.", + title: "Warn before end", + range: 0 ... 30, + step: 5, + unitLabel: alarm.type.snoozeTimeUnit.label, + value: $alarm.predictiveMinutes + ) + AlarmActiveSection(alarm: $alarm) AlarmAudioSection(alarm: $alarm, hideRepeat: true) AlarmSnoozeSection(alarm: $alarm) diff --git a/LoopFollow/Alarm/AlarmManager.swift b/LoopFollow/Alarm/AlarmManager.swift index 29c6128d9..878bf5701 100644 --- a/LoopFollow/Alarm/AlarmManager.swift +++ b/LoopFollow/Alarm/AlarmManager.swift @@ -34,6 +34,7 @@ class AlarmManager { BatteryCondition.self, BatteryDropCondition.self, FutureCarbsCondition.self, + MissedBolusCondition.self, DBSizeCondition.self, ] ) { @@ -126,16 +127,24 @@ class AlarmManager { continue } - // If this alarm is active, and still fulfill the requirements, let it be active - // Break the loop, nothing else to do + let titleOverride = checker.firedTitle + + // An active alarm that still fulfills the requirements stays active. + // A different phase of it (an end alarm's early warning followed + // by the end itself) is announced with its own title. if Observable.shared.currentAlarm.value == alarm.id { + if titleOverride != Observable.shared.currentAlarmTitleOverride.value { + Observable.shared.currentAlarmTitleOverride.value = titleOverride + alarm.trigger(config: Storage.shared.alarmConfiguration.value, now: now, titleOverride: titleOverride) + } break } // Fire the alarm and break the loop; we only allow one alarm per evaluation tick. + Observable.shared.currentAlarmTitleOverride.value = titleOverride Observable.shared.currentAlarm.value = alarm.id - alarm.trigger(config: Storage.shared.alarmConfiguration.value, now: now) + alarm.trigger(config: Storage.shared.alarmConfiguration.value, now: now, titleOverride: titleOverride) // Store the latest bg time so we don't use it again if alarm.type.isBGBased, @@ -183,10 +192,11 @@ class AlarmManager { func stopAlarm() { AlarmSound.stop() Observable.shared.currentAlarm.value = nil + Observable.shared.currentAlarmTitleOverride.value = nil UNUserNotificationCenter.current().removeAllPendingNotificationRequests() } - func sendNotification(title: String, actionTitle: String? = nil) { + func sendNotification(title: String, body: String? = nil, actionTitle: String? = nil) { UNUserNotificationCenter.current().removeAllPendingNotificationRequests() let content = UNMutableNotificationContent() @@ -194,6 +204,9 @@ class AlarmManager { content.subtitle += Observable.shared.bgText.value + " " content.subtitle += Observable.shared.directionText.value + " " content.subtitle += Observable.shared.deltaText.value + if let body { + content.body = body + } content.categoryIdentifier = "category" content.sound = .default diff --git a/LoopFollow/Application/AppDelegate.swift b/LoopFollow/Application/AppDelegate.swift index 3c364fc2c..9a061cc1f 100644 --- a/LoopFollow/Application/AppDelegate.swift +++ b/LoopFollow/Application/AppDelegate.swift @@ -11,6 +11,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { func application(_: UIApplication, didFinishLaunchingWithOptions _: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { LogManager.shared.log(category: .general, message: "App started") LogManager.shared.cleanupOldLogs() + ExitDiagnostics.shared.start() // Notification and calendar permissions are no longer requested here. // They're deferred to the moment the user opts into the feature that @@ -55,6 +56,8 @@ class AppDelegate: UIResponder, UIApplicationDelegate { UNUserNotificationCenter.current().delegate = self + WatchSessionManager.shared.activate() + // Only spin up Bluetooth if the user has chosen a BLE-based background // refresh. Initializing BLEManager creates a CBCentralManager, which // triggers the Bluetooth permission prompt — deferring it keeps that @@ -109,6 +112,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { } func applicationWillTerminate(_: UIApplication) { + LogManager.shared.log(category: .general, message: "App will terminate (\(ExitDiagnostics.memoryDescription()))") #if !targetEnvironment(macCatalyst) LiveActivityManager.shared.endOnTerminate() #endif @@ -134,6 +138,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { func application(_: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable: Any], fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) { let userInfoKeys = userInfo.keys.compactMap { $0 as? String }.sorted() LogManager.shared.log(category: .apns, message: "Received remote notification: keys=\(userInfoKeys)") + RemoteCommandTracker.shared.handleNotification(userInfo: userInfo) // Check if this is a response notification from Loop or Trio if let aps = userInfo["aps"] as? [String: Any] { @@ -183,6 +188,8 @@ class AppDelegate: UIResponder, UIApplicationDelegate { } func userNotificationCenter(_: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) { + RemoteCommandTracker.shared.handleNotification(userInfo: response.notification.request.content.userInfo) + if response.actionIdentifier == "OPEN_APP_ACTION" { // Dismiss any presented modal/sheet so the user actually sees Home UIApplication.shared.topMost?.dismiss(animated: true) @@ -267,6 +274,7 @@ extension AppDelegate: UNUserNotificationCenterDelegate { withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) { let content = notification.request.content + RemoteCommandTracker.shared.handleNotification(userInfo: content.userInfo) let userInfoKeys = content.userInfo.keys.compactMap { $0 as? String }.sorted() LogManager.shared.log( category: .general, diff --git a/LoopFollow/Charts/BGChartHistory.swift b/LoopFollow/Charts/BGChartHistory.swift new file mode 100644 index 000000000..8d625d948 --- /dev/null +++ b/LoopFollow/Charts/BGChartHistory.swift @@ -0,0 +1,322 @@ +// LoopFollow +// BGChartHistory.swift + +import Foundation +import SwiftUI + +/// Chart series built from DeviceStatusHistory: the panes under the main +/// chart, the smoothed BG line, the selection pill values and the forecast of +/// a selected reading. +enum BGChartHistory { + /// Consecutive samples further apart than this are not joined by a line. + static let maxJoinGap: TimeInterval = 15 * 60 + + struct Point: Identifiable { + let date: Date + let value: Double + /// Index of the unbroken run this point belongs to (see `maxJoinGap`). + let segment: Int + var id: TimeInterval { date.timeIntervalSince1970 } + } + + enum PaneKind: Int, CaseIterable { + case iob, cob, sensitivityRatio + + var title: String { + switch self { + case .iob: return "IOB" + case .cob: return "COB" + case .sensitivityRatio: return "Sens. Ratio" + } + } + + var color: Color { + switch self { + case .iob: return Color("Insulin") + case .cob: return Color(.systemOrange) + case .sensitivityRatio: return Color(.systemIndigo) + } + } + + var fillsArea: Bool { + self == .iob || self == .cob + } + + var isEnabled: Bool { + switch self { + case .iob: return Storage.shared.showIOBGraph.value + case .cob: return Storage.shared.showCOBGraph.value + case .sensitivityRatio: return Storage.shared.showSensitivityRatioGraph.value && Storage.shared.device.value != "Loop" + } + } + + func value(of sample: DeviceStatusHistorySample) -> Double? { + switch self { + case .iob: return sample.iob + case .cob: return sample.cob + case .sensitivityRatio: return sample.sensitivityRatio + } + } + + func format(_ value: Double) -> String { + switch self { + case .iob: + return Localizer.formatToLocalizedString(value, maxFractionDigits: 2) + "U" + case .cob: + return Localizer.formatToLocalizedString(value, maxFractionDigits: 0) + "g" + case .sensitivityRatio: + return Localizer.formatToLocalizedString(value * 100, maxFractionDigits: 0) + "%" + } + } + + func axisLabel(_ value: Double) -> String { + switch self { + case .iob: + return Localizer.formatToLocalizedString(value, maxFractionDigits: 1) + "U" + default: + return format(value) + } + } + } + + struct Pane: Identifiable { + let kind: PaneKind + let points: [Point] + let yDomain: ClosedRange + /// Values labelled on the axis and drawn as grid lines. + let ticks: [Double] + /// Bottom of the area fill. + let baseline: Double + var id: Int { kind.rawValue } + } + + /// History curves drawn as lines in the bottom band of the main chart, each + /// scaled to its own range. + struct Overlay { + struct Series: Identifiable { + let kind: PaneKind + /// Negative IOB, drawn mirrored above the baseline. + let isNegative: Bool + let points: [Point] + var id: String { "\(kind.rawValue)-\(isNegative)" } + var color: Color { isNegative ? BGChartHistory.negativeIOBColor : kind.color } + } + + let series: [Series] + } + + static let negativeIOBColor = Color(.systemPink) + + static func overlay(kinds: [PaneKind], samples: [DeviceStatusHistorySample], bandTop: Double) -> Overlay? { + var series: [Overlay.Series] = [] + + for kind in kinds { + let points = points(from: samples, value: kind.value(of:)) + guard let minValue = points.map(\.value).min(), + let maxValue = points.map(\.value).max() + else { continue } + + if kind == .iob { + // Magnitude scale: negative IOB is mirrored upward in its own color. + let magnitude = max(abs(minValue), maxValue, 1).rounded(.up) + let (positive, negative) = splitAtZero(points) + series.append(.init(kind: kind, isNegative: false, points: positive.map { scaled($0, abs($0.value) / magnitude * bandTop) })) + if !negative.isEmpty { + series.append(.init(kind: kind, isNegative: true, points: negative.map { scaled($0, abs($0.value) / magnitude * bandTop) })) + } + continue + } + + let lower: Double + let upper: Double + switch kind { + case .cob: + lower = 0 + upper = max(10, (maxValue / 10).rounded(.up) * 10) + case .sensitivityRatio: + // Same range as the ratio graph, so a flat curve sits mid-band. + lower = min(minValue, 0.9) + upper = max(maxValue, 1.1) + case .iob: + continue + } + let span = max(upper - lower, .ulpOfOne) + series.append(.init(kind: kind, isNegative: false, points: points.map { scaled($0, ($0.value - lower) / span * bandTop) })) + } + + guard !series.isEmpty else { return nil } + return Overlay(series: series) + } + + private static func scaled(_ point: Point, _ value: Double) -> Point { + Point(date: point.date, value: value, segment: point.segment) + } + + /// Splits a curve into its positive and negative runs. A zero crossing ends + /// one run and starts the other at the interpolated crossing time, so both + /// meet the baseline there. + static func splitAtZero(_ points: [Point]) -> (positive: [Point], negative: [Point]) { + var positive: [Point] = [] + var negative: [Point] = [] + var segment = 0 + var previous: Point? + for point in points { + if let prev = previous { + if prev.segment != point.segment { + segment += 1 + } else if prev.value * point.value < 0 { + let fraction = abs(prev.value) / (abs(prev.value) + abs(point.value)) + let crossing = prev.date.addingTimeInterval(point.date.timeIntervalSince(prev.date) * fraction) + let zeroEnd = Point(date: crossing, value: 0, segment: segment) + if prev.value < 0 { negative.append(zeroEnd) } else { positive.append(zeroEnd) } + segment += 1 + let zeroStart = Point(date: crossing, value: 0, segment: segment) + if point.value < 0 { negative.append(zeroStart) } else { positive.append(zeroStart) } + } else if prev.value < 0, point.value == 0 { + // A reading of exactly zero belongs to the positive run; the + // negative run ends on it. + negative.append(Point(date: point.date, value: 0, segment: segment)) + segment += 1 + } else if prev.value == 0, point.value < 0 { + segment += 1 + negative.append(Point(date: prev.date, value: 0, segment: segment)) + } + } + let run = Point(date: point.date, value: point.value, segment: segment) + if point.value < 0 { negative.append(run) } else { positive.append(run) } + previous = point + } + return (positive, negative) + } + + struct ForecastCurve: Identifiable { + let name: String + let color: Color + let points: [(date: Date, value: Double)] + var id: String { name } + } + + struct Forecast { + let curves: [ForecastCurve] + /// Min/max envelope across the curves when the cone style is selected. + let cone: [(date: Date, yMin: Double, yMax: Double)] + } + + // MARK: - Building + + /// Splits the samples carrying a value into points, breaking the line at gaps. + static func points( + from samples: [DeviceStatusHistorySample], + value: (DeviceStatusHistorySample) -> Double? + ) -> [Point] { + var points: [Point] = [] + var segment = 0 + var previousDate: TimeInterval? + for sample in samples { + guard let v = value(sample) else { continue } + if let previousDate, sample.date - previousDate > maxJoinGap { + segment += 1 + } + points.append(Point(date: Date(timeIntervalSince1970: sample.date), value: v, segment: segment)) + previousDate = sample.date + } + return points + } + + static func pane(kind: PaneKind, samples: [DeviceStatusHistorySample]) -> Pane? { + let points = points(from: samples, value: kind.value(of:)) + guard let minValue = points.map(\.value).min(), + let maxValue = points.map(\.value).max() + else { return nil } + + let lower: Double + let upper: Double + var ticks: [Double] + switch kind { + case .iob: + lower = min(0, minValue) + upper = max(1, maxValue.rounded(.up)) + ticks = [0, upper] + case .cob: + lower = 0 + upper = max(10, (maxValue / 10).rounded(.up) * 10) + ticks = [0, upper] + case .sensitivityRatio: + lower = min(minValue, 0.9) + upper = max(maxValue, 1.1) + ticks = [1] + if minValue < 0.95 { ticks.insert(minValue, at: 0) } + if maxValue > 1.05 { ticks.append(maxValue) } + } + let pad = (upper - lower) * 0.08 + return Pane( + kind: kind, + points: points, + yDomain: (lower - pad) ... (upper + pad), + ticks: ticks, + baseline: kind == .iob || kind == .cob ? 0 : lower - pad + ) + } + + /// The forecast the loop made when it ran on the reading at `readingDate`, + /// cut to the Hours of Prediction setting. + static func forecast(forReadingAt readingDate: Date) -> Forecast? { + guard let stored = DeviceStatusHistory.shared.cycle(forReadingAt: readingDate)?.forecast else { return nil } + + let count = Int(Storage.shared.predictionToLoad.value * 12) + 1 + guard count > 1 else { return nil } + let minDisplay = Double(globalVariables.minDisplayGlucose) + let maxDisplay = Double(globalVariables.maxDisplayGlucose) + let step = DeviceStatusHistorySample.Forecast.step + + func curvePoints(_ values: [Int]) -> [(date: Date, value: Double)] { + values.prefix(count).enumerated().map { index, value in + ( + date: Date(timeIntervalSince1970: stored.start + Double(index) * step), + value: min(max(Double(value), minDisplay), maxDisplay) + ) + } + } + + if let main = stored.curves[DeviceStatusHistorySample.Forecast.loopCurve] { + return Forecast(curves: [ForecastCurve(name: "main", color: .purple, points: curvePoints(main))], cone: []) + } + + let names = DeviceStatusHistorySample.Forecast.openAPSCurves.filter { stored.curves[$0] != nil } + guard !names.isEmpty else { return nil } + + if Storage.shared.predictionDisplayType.value == .cone { + let arrays = names.compactMap { stored.curves[$0] } + let length = min(arrays.map(\.count).min() ?? 0, count) + let cone = (0 ..< length).map { index in + let values = arrays.map { Double($0[index]) } + var yMin = max(values.min() ?? 0, minDisplay) + var yMax = min(values.max() ?? 0, maxDisplay) + // Same ±1 mg/dL minimum band as the live cone, so agreeing curves stay visible. + if yMin == yMax { + yMin -= 1 + yMax += 1 + } + return (date: Date(timeIntervalSince1970: stored.start + Double(index) * step), yMin: yMin, yMax: yMax) + } + return Forecast(curves: [], cone: cone) + } + + let curves = names.compactMap { name -> ForecastCurve? in + guard let values = stored.curves[name] else { return nil } + return ForecastCurve(name: name, color: color(forCurve: name), points: curvePoints(values)) + } + return Forecast(curves: curves, cone: []) + } + + /// Same colors as the live forecast lines. + private static func color(forCurve name: String) -> Color { + switch name { + case "ZT": return Color("ZT") + case "IOB": return Color("Insulin") + case "COB": return Color(.systemOrange) + case "UAM": return Color("UAM") + default: return .purple + } + } +} diff --git a/LoopFollow/Charts/BGChartHistoryPanes.swift b/LoopFollow/Charts/BGChartHistoryPanes.swift new file mode 100644 index 000000000..dfd45f37f --- /dev/null +++ b/LoopFollow/Charts/BGChartHistoryPanes.swift @@ -0,0 +1,113 @@ +// LoopFollow +// BGChartHistoryPanes.swift + +import Charts +import SwiftUI + +/// One device status history pane (IOB, COB or sensitivity ratio) laid +/// out over the main chart's render window. Like BGChartCanvas, it compares +/// only cheap metadata so panning, which translates it, skips its body. +struct BGChartHistoryPaneCanvas: View, Equatable { + let pane: BGChartHistory.Pane + let generation: Int + let now: Date + let windowStart: Date + let windowEnd: Date + let canvasWidth: CGFloat + let height: CGFloat + + static func == (lhs: Self, rhs: Self) -> Bool { + lhs.generation == rhs.generation && + lhs.pane.kind == rhs.pane.kind && + lhs.now == rhs.now && + lhs.windowStart == rhs.windowStart && + lhs.windowEnd == rhs.windowEnd && + lhs.canvasWidth == rhs.canvasWidth && + lhs.height == rhs.height + } + + var body: some View { + let points = chartWindowedLine(pane.points, windowStart: windowStart, windowEnd: windowEnd) { $0.date } + let color = pane.kind.color + Chart { + ForEach(pane.ticks, id: \.self) { tick in + RuleMark(y: .value("tick", tick)) + .lineStyle(StrokeStyle(lineWidth: 0.5, dash: [2, 3])) + .foregroundStyle(Color.gray.opacity(0.4)) + } + if pane.kind.fillsArea { + ForEach(points) { pt in + AreaMark( + x: .value("time", pt.date), + yStart: .value("baseline", pane.baseline), + yEnd: .value(pane.kind.title, pt.value), + series: .value("series", "area-\(pt.segment)") + ) + .foregroundStyle(color.opacity(0.25)) + .interpolationMethod(.linear) + } + } + if now >= windowStart, now <= windowEnd { + RuleMark(x: .value("now", now)) + .lineStyle(StrokeStyle(lineWidth: 1)) + .foregroundStyle(Color.gray.opacity(0.6)) + } + ForEach(points) { pt in + LineMark( + x: .value("time", pt.date), + y: .value(pane.kind.title, pt.value), + series: .value("series", "line-\(pt.segment)") + ) + .foregroundStyle(color) + .lineStyle(StrokeStyle(lineWidth: 1.5)) + .interpolationMethod(.linear) + } + } + .chartXScale(domain: windowStart ... windowEnd) + .chartYScale(domain: pane.yDomain) + .chartLegend(.hidden) + .chartXAxis(.hidden) + .chartYAxis(.hidden) + .frame(width: canvasWidth, height: height) + } +} + +/// Pinned title and value labels for a history pane, drawn over its +/// scrolling canvas. Labels are kept inside the pane so they never spill into +/// a neighbouring one. +struct BGChartHistoryPaneAxisOverlay: View, Equatable { + let pane: BGChartHistory.Pane + let generation: Int + + static func == (lhs: Self, rhs: Self) -> Bool { + lhs.generation == rhs.generation && lhs.pane.kind == rhs.pane.kind + } + + var body: some View { + GeometryReader { geo in + let height = geo.size.height + let span = pane.yDomain.upperBound - pane.yDomain.lowerBound + ZStack(alignment: .topLeading) { + ForEach(pane.ticks, id: \.self) { tick in + let fraction = span > 0 ? (pane.yDomain.upperBound - tick) / span : 0.5 + Text(pane.kind.axisLabel(tick)) + .font(.caption2) + .foregroundStyle(.secondary) + .padding(.trailing, 4) + .frame(width: geo.size.width, height: 14, alignment: .trailing) + .position(x: geo.size.width / 2, y: min(max(CGFloat(fraction) * height, 7), height - 7)) + } + + Text(pane.kind.title) + .font(.caption2.bold()) + .foregroundStyle(pane.kind.color) + .padding(.leading, 4) + .padding(.top, 2) + + Rectangle() + .fill(Color.primary.opacity(0.15)) + .frame(height: 0.5) + } + } + } +} diff --git a/LoopFollow/Charts/BGChartModel.swift b/LoopFollow/Charts/BGChartModel.swift index 8acc2859c..525fcfcb2 100644 --- a/LoopFollow/Charts/BGChartModel.swift +++ b/LoopFollow/Charts/BGChartModel.swift @@ -54,17 +54,20 @@ final class BGChartModel: ObservableObject { let sgv: Double let label: String let pillText: String + /// The Treatments list entry the pill links to; nil for marks the list doesn't show. + let detail: Treatment? /// Where the symbol is drawn. Equals `date` unless `spread` nudged it /// left to keep a crowded run of treatments from stacking up. var drawnDate: Date var id: Double { date.timeIntervalSince1970 } - init(date: Date, value: Double, sgv: Double, label: String, pillText: String) { + init(date: Date, value: Double, sgv: Double, label: String, pillText: String, detail: Treatment? = nil) { self.date = date self.value = value self.sgv = sgv self.label = label self.pillText = pillText + self.detail = detail drawnDate = date } } @@ -89,6 +92,8 @@ final class BGChartModel: ObservableObject { let yTop: Double let label: String let pillText: String + /// The Treatments list entry the pill links to. + var detail: Treatment? = nil var id: String { "\(start.timeIntervalSince1970)-\(end.timeIntervalSince1970)" } } @@ -140,6 +145,13 @@ final class BGChartModel: ObservableObject { @Published var resumes: [TreatmentPoint] = [] @Published var sensorStarts: [TreatmentPoint] = [] @Published var notes: [TreatmentPoint] = [] + @Published var sensorStates: [TreatmentPoint] = [] + + /// Device status history panes under the main chart, in display order. + @Published var historyPanes: [BGChartHistory.Pane] = [] + @Published var smoothedBG: [BGChartHistory.Point] = [] + /// History curves in the main chart's bottom band, when placed there. + @Published var historyOverlay: BGChartHistory.Overlay? @Published var overrides: [BandRect] = [] @Published var tempTargets: [BandRect] = [] @@ -391,6 +403,7 @@ final class BGChartModel: ObservableObject { guard let self else { return } self.rebuildScheduled = false self.performRebuild() + WatchSessionManager.shared.requestUpdate(reason: "chart") } } @@ -402,8 +415,8 @@ final class BGChartModel: ObservableObject { let maxBGValue = Double(vc.calculateMaxBgGraphValue()) maxBG = max(maxBGValue, Storage.shared.minBGScale.value) - // Same thresholds the stats and main header use: fixed 70–180 / 70–140 - // for the TIR/TITR range modes, the user's lines for custom mode. + // Same thresholds the stats and main header use: the range mode's fixed + // cut-offs, or the user's lines for custom mode. let thresholds = UnitSettingsStore.shared.effectiveThresholds() lowLine = thresholds.low highLine = thresholds.high @@ -461,7 +474,8 @@ final class BGChartModel: ObservableObject { value: $0.value, sgv: Double($0.sgv), label: dose, - pillText: "Bolus\n\(dose)U\n\(pillTimeString(for: Date(timeIntervalSince1970: $0.date)))" + pillText: "Bolus\n\(dose)U\n\(pillTimeString(for: Date(timeIntervalSince1970: $0.date)))", + detail: $0.detail ) } carbs = Self.spread((showCarbs ? vc.carbData : []).map { @@ -475,9 +489,11 @@ final class BGChartModel: ObservableObject { value: $0.value, sgv: Double($0.sgv), label: label, - pillText: "Carbs\n\(grams)g\n\(pillTimeString(for: Date(timeIntervalSince1970: $0.date)))" + pillText: "Carbs\n\(grams)g\n\(pillTimeString(for: Date(timeIntervalSince1970: $0.date)))", + detail: $0.detail ) }, minGap: Spread.carbGap, maxShift: Spread.carbShift) + let automaticBolusTitle = isLoop ? "Auto Bolus" : "SMB" let smbPoints = (showBolus ? vc.smbData : []).map { let dose = self.formatDose($0.value) return TreatmentPoint( @@ -485,7 +501,8 @@ final class BGChartModel: ObservableObject { value: $0.value, sgv: Double($0.sgv), label: dose, - pillText: "SMB\n\(dose)U\n\(pillTimeString(for: Date(timeIntervalSince1970: $0.date)))" + pillText: "\(automaticBolusTitle)\n\(dose)U\n\(pillTimeString(for: Date(timeIntervalSince1970: $0.date)))", + detail: $0.detail ) } (boluses, smbs) = Self.spreadTogether(bolusPoints, smbPoints, minGap: Spread.bolusGap, maxShift: Spread.bolusShift) @@ -517,6 +534,17 @@ final class BGChartModel: ObservableObject { ) } + sensorStates = (showOtherTreatments ? vc.cgmSensorStates : []).compactMap { state in + guard let sgv = CGMSensorState.anchorSGV(at: state.date.timeIntervalSince1970, readings: vc.bgData) else { return nil } + return TreatmentPoint( + date: state.date, + value: Double(sgv), + sgv: Double(sgv), + label: state.name, + pillText: "CGM: \(state.displayName)\n\(pillTimeString(for: state.date))" + ) + } + basalScheduled = (showBasal ? vc.basalScheduleData : []).map { ScheduledBasalPoint(date: Date(timeIntervalSince1970: $0.date), rate: $0.basalRate) } @@ -550,7 +578,8 @@ final class BGChartModel: ObservableObject { yBottom: yBottom, yTop: yTop, label: displayName, - pillText: "Override\n\(displayName)\n\(pillTimeString(for: Date(timeIntervalSince1970: $0.date)))" + pillText: "Override\n\(displayName)\n\(pillTimeString(for: Date(timeIntervalSince1970: $0.date)))", + detail: $0.detail ) } tempTargets = (showOtherTreatments ? vc.tempTargetGraphData : []).map { @@ -564,7 +593,8 @@ final class BGChartModel: ObservableObject { yBottom: yCenter - 5, yTop: yCenter + 5, label: "Temp Target", - pillText: "Temp Target\n\(Localizer.toDisplayUnits(target))\n\(pillTimeString(for: Date(timeIntervalSince1970: $0.date)))" + pillText: "Temp Target\n\(Localizer.toDisplayUnits(target))\n\(pillTimeString(for: Date(timeIntervalSince1970: $0.date)))", + detail: $0.detail ) } @@ -623,6 +653,27 @@ final class BGChartModel: ObservableObject { } midnightMarkers = midnights + rebuildHistory(from: currentNow.addingTimeInterval(-hoursBack - BGChartHistory.maxJoinGap)) + generation &+= 1 } + + private func rebuildHistory(from start: Date) { + let enabledKinds = BGChartHistory.PaneKind.allCases.filter(\.isEnabled) + let showSmoothed = Storage.shared.showSmoothedBG.value && Storage.shared.device.value != "Loop" + let startTime = start.timeIntervalSince1970 + let samples = enabledKinds.isEmpty && !showSmoothed + ? [] + : DeviceStatusHistory.shared.state.samples.filter { $0.date >= startTime } + + if Storage.shared.historyCurvePlacement.value == .mainGraph { + historyPanes = [] + // The band stays below the low line so it does not cover in-range readings. + historyOverlay = BGChartHistory.overlay(kinds: enabledKinds, samples: samples, bandTop: min(maxBG * 0.22, lowLine * 0.9)) + } else { + historyPanes = enabledKinds.compactMap { BGChartHistory.pane(kind: $0, samples: samples) } + historyOverlay = nil + } + smoothedBG = showSmoothed ? BGChartHistory.points(from: samples) { $0.smoothedBG } : [] + } } diff --git a/LoopFollow/Charts/BGChartStubs.swift b/LoopFollow/Charts/BGChartStubs.swift index 700821a66..8ecab09dc 100644 --- a/LoopFollow/Charts/BGChartStubs.swift +++ b/LoopFollow/Charts/BGChartStubs.swift @@ -90,6 +90,8 @@ extension MainViewController { // Removes the Loop forecast line. Used when the active system switches away from Loop. func clearLoopPredictionGraph() { + loopPredBGs = nil + loopPredUpdatedTime = nil guard !predictionData.isEmpty else { return } predictionData.removeAll() updatePredictionGraph() diff --git a/LoopFollow/Charts/BGChartView.swift b/LoopFollow/Charts/BGChartView.swift index 74f9ec03e..e2949839a 100644 --- a/LoopFollow/Charts/BGChartView.swift +++ b/LoopFollow/Charts/BGChartView.swift @@ -46,6 +46,21 @@ private enum BGChartConfig { static let autoFollowPause: TimeInterval = 5 * 60 /// Screen-space radius (pt) within which a tap selects a mark. static let tapHitRadius: CGFloat = 30 + /// How far (pt) outside a linked pill a tap still opens its treatment. + static let pillLinkSlop: CGFloat = 8 +} + +/// For line/area series: the items inside the window plus one beyond each +/// edge, so segments crossing (or spanning) the window survive. Assumes +/// ascending dates. +func chartWindowedLine(_ items: [T], windowStart: Date, windowEnd: Date, date: (T) -> Date) -> [T] { + guard items.count > 1 else { return items } + let firstInside = items.firstIndex { date($0) >= windowStart } ?? items.count + let firstBeyond = items.firstIndex { date($0) > windowEnd } ?? items.count - 1 + let lo = max(firstInside - 1, 0) + let hi = min(firstBeyond, items.count - 1) + guard lo <= hi else { return [] } + return Array(items[lo ... hi]) } /// Small y-domain headroom keeps the top axis label readable instead of @@ -70,18 +85,36 @@ struct BGChartView: View { /// attachments while BGChartInteraction preserves the viewport. @State private var gestureMountEpoch = 0 + /// Treatment opened from a tapped pill on the main chart. Owned here, outside + /// the remounted subtree, so the sheet survives a foregrounding remount. + @State private var selectedTreatment: Treatment? + var body: some View { Group { if config == .small { SmallBGChart(model: model, interaction: model.interaction) } else { - MainBGChart(model: model, interaction: model.interaction) + MainBGChart(model: model, interaction: model.interaction, selectedTreatment: $selectedTreatment) } } .id(gestureMountEpoch) .onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in gestureMountEpoch &+= 1 } + .sheet(item: $selectedTreatment) { treatment in + NavigationStack { + TreatmentDetailView(treatment: treatment, rootMeal: rootMeal(for: treatment)) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Done") { selectedTreatment = nil } + } + } + } + } + } + + private func rootMeal(for treatment: Treatment) -> Treatment? { + treatment.rootMeal(in: Treatment.rootMealsByFPUID(model.carbs.compactMap(\.detail))) } } @@ -98,11 +131,15 @@ struct BGChartView: View { /// whose live preview is a `.scaleEffect(x:)` stretch anchored under the /// pinch centroid, committed on a geometric zoom grid. A one-finger press /// held stationary latches into inspect mode and scrubs a selection that is -/// rendered by a shell overlay (never re-laying the canvas). Double-tap -/// cycles zoom presets. No `.chartScrollableAxes`, no UIKit gesture hacks. +/// rendered by a shell overlay (never re-laying the canvas). A tap on a mark +/// shows its pill, and a tap on a pill that links to the Treatments list opens +/// that treatment's details. Double-tap cycles zoom presets. +/// No `.chartScrollableAxes`, no UIKit gesture hacks. private struct MainBGChart: View { @ObservedObject var model: BGChartModel @ObservedObject var interaction: BGChartInteraction + /// Treatment to show in the detail sheet; the shell owns the sheet. + @Binding var selectedTreatment: Treatment? /// Rendered slice of the domain. The canvas covers only this window /// (visible ± `renderWindowPadFactor` viewports), bounding canvas width @@ -110,9 +147,10 @@ private struct MainBGChart: View { @State private var renderWindowStart: Date @State private var renderWindowEnd: Date - init(model: BGChartModel, interaction: BGChartInteraction) { + init(model: BGChartModel, interaction: BGChartInteraction, selectedTreatment: Binding) { _model = ObservedObject(wrappedValue: model) _interaction = ObservedObject(wrappedValue: interaction) + _selectedTreatment = selectedTreatment // Seed the render window around the current viewport so a remount's // first frame draws in place. let pad = BGChartConfig.renderWindowPadFactor * interaction.visibleSeconds @@ -172,6 +210,11 @@ private struct MainBGChart: View { /// away from the live edge, cleared on return to it. @State private var autoFollowPausedUntil: Date? + /// The jump to now button hides while the user pans, zooms or scrubs and + /// fades back in a moment after, so it stays out of the way. + @State private var showJumpToNow = true + @State private var jumpToNowFadeTask: Task? + private var timeZoneForAxis: TimeZone { if Storage.shared.graphTimeZoneEnabled.value, let tz = TimeZone(identifier: Storage.shared.graphTimeZoneIdentifier.value) @@ -197,36 +240,71 @@ private struct MainBGChart: View { let canvasOffsetX = CGFloat( interaction.scrollPosition.timeIntervalSince(renderWindowStart) / windowSeconds ) * canvasWidth + let scaleAnchor = pinchScaleAnchor(viewportWidth: viewportWidth, canvasWidth: canvasWidth) + + // Device status history panes stack under the main chart and share its + // render window, offset and pinch transform. + let panes = model.historyPanes + let paneHeight = historyPaneHeight(viewportHeight: viewport.height, count: panes.count) + let mainHeight = viewport.height - paneHeight * CGFloat(panes.count) return ZStack(alignment: .topLeading) { - BGChartCanvas( - model: model, - generation: model.generation, - isSmall: false, - windowStart: renderWindowStart, - windowEnd: renderWindowEnd, - canvasWidth: canvasWidth, - height: viewport.height, - visibleSeconds: interaction.visibleSeconds, - timeZone: timeZoneForAxis - ) - .equatable() - .offset(x: -canvasOffsetX) - .scaleEffect( - x: pinchScale, - y: 1, - anchor: pinchScaleAnchor(viewportWidth: viewportWidth, canvasWidth: canvasWidth) - ) + VStack(spacing: 0) { + ZStack(alignment: .topLeading) { + BGChartCanvas( + model: model, + generation: model.generation, + isSmall: false, + windowStart: renderWindowStart, + windowEnd: renderWindowEnd, + canvasWidth: canvasWidth, + height: mainHeight, + visibleSeconds: interaction.visibleSeconds, + timeZone: timeZoneForAxis + ) + .equatable() + .offset(x: -canvasOffsetX) + .scaleEffect(x: pinchScale, y: 1, anchor: scaleAnchor) + + // Pinned y-axis labels (BG trailing, basal leading) over the + // scrolling canvas. The canvas's own y-axis is hidden — its + // trailing edge sits far off-screen on the wide canvas. + StaticYAxisOverlay(generation: model.generation, maxBG: model.maxBG, maxBasal: model.maxBasal) + .equatable() + .frame(width: viewportWidth, height: mainHeight) + .allowsHitTesting(false) + } + .frame(width: viewportWidth, height: mainHeight, alignment: .topLeading) + + ForEach(panes) { pane in + ZStack(alignment: .topLeading) { + BGChartHistoryPaneCanvas( + pane: pane, + generation: model.generation, + now: model.now, + windowStart: renderWindowStart, + windowEnd: renderWindowEnd, + canvasWidth: canvasWidth, + height: paneHeight + ) + .equatable() + .offset(x: -canvasOffsetX) + .scaleEffect(x: pinchScale, y: 1, anchor: scaleAnchor) + + BGChartHistoryPaneAxisOverlay(pane: pane, generation: model.generation) + .equatable() + .frame(width: viewportWidth, height: paneHeight) + .allowsHitTesting(false) + } + .frame(width: viewportWidth, height: paneHeight, alignment: .topLeading) + .clipped() + } + } - // Pinned y-axis labels (BG trailing, basal leading) over the - // scrolling canvas. The canvas's own y-axis is hidden — its - // trailing edge sits far off-screen on the wide canvas. - StaticYAxisOverlay(generation: model.generation, maxBG: model.maxBG, maxBasal: model.maxBasal) - .equatable() - .frame(width: viewportWidth, height: viewport.height) + historyForecastOverlay(viewportWidth: viewportWidth) .allowsHitTesting(false) - selectionOverlay(viewportWidth: viewportWidth) + selectionOverlay(viewportWidth: viewportWidth, indicatorBottom: viewport.height) .allowsHitTesting(false) overrideBandLabelsOverlay(viewportWidth: viewportWidth) @@ -234,6 +312,9 @@ private struct MainBGChart: View { if !interaction.followLatest { jumpToNowButton + .frame(width: viewportWidth, height: mainHeight) + .opacity(showJumpToNow ? 1 : 0) + .allowsHitTesting(showJumpToNow) } } .frame(width: viewportWidth, height: viewport.height, alignment: .topLeading) @@ -241,21 +322,28 @@ private struct MainBGChart: View { .contentShape(Rectangle()) .simultaneousGesture(panAndInspectGesture(viewportWidth: viewportWidth)) .simultaneousGesture(magnifyGesture(viewportWidth: viewportWidth)) - // Double-tap zooms; a single tap (only recognized once the double-tap - // window lapses) selects the mark under the finger, or clears the pill. + // Double-tap zooms. Single-tap waits for the double-tap window before + // selecting a mark or following a pill's link. .simultaneousGesture( - TapGesture(count: 2) + SpatialTapGesture(count: 2) .exclusively(before: SpatialTapGesture()) .onEnded { value in switch value { case .first: cycleZoomPreset() case let .second(tap): - handleTap(at: tap.location, viewportWidth: viewportWidth) + handleTap(at: tap.location, viewportWidth: viewportWidth, mainHeight: mainHeight) } } ) .onPreferenceChange(PlotFramePreferenceKey.self) { plotFrame = $0 } + // The displays show the scrubbed time only while the finger is down. + .onChange(of: isInspectLatched) { _, latched in + if !latched { RetroSelection.shared.select(nil) } + } + .onChange(of: isInteracting) { _, interacting in + updateJumpToNowVisibility(interacting: interacting) + } .onPreferenceChange(PillSizePreferenceKey.self) { pillSize = $0 } .onChange(of: interaction.scrollPosition) { _, _ in updateRenderWindow() @@ -265,6 +353,11 @@ private struct MainBGChart: View { updateRenderWindow(force: true) } .onChange(of: model.now) { _, _ in + // New data while holding still: rebuild the scrubbed-time displays. + if isInspectLatched, let selection { + let anchor = selectionAnchor(for: selection) + RetroSelection.shared.select(anchor.map { $0.readingDate ?? $0.date }, refresh: true) + } if interaction.followLatest { scrollToNow(animated: true) } else if let pausedUntil = autoFollowPausedUntil, Date() >= pausedUntil { @@ -287,6 +380,7 @@ private struct MainBGChart: View { .onDisappear { momentumTask?.cancel() momentumTask = nil + jumpToNowFadeTask?.cancel() resetGestureState() } } @@ -307,6 +401,26 @@ private struct MainBGChart: View { .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomTrailing) } + /// A finger is on the chart (scrub, pan, pinch) or a flick is still gliding. + private var isInteracting: Bool { + isInspectLatched || panBaseline != nil || pinchAnchor != nil || momentumTask != nil + } + + private func updateJumpToNowVisibility(interacting: Bool) { + jumpToNowFadeTask?.cancel() + if interacting { + showJumpToNow = false + return + } + jumpToNowFadeTask = Task { @MainActor in + try? await Task.sleep(nanoseconds: 1_500_000_000) + guard !Task.isCancelled else { return } + withAnimation(.easeIn(duration: 0.4)) { + showJumpToNow = true + } + } + } + // MARK: Render window / follow state /// Re-anchors the render window when the visible window nears its edge. @@ -481,6 +595,7 @@ private struct MainBGChart: View { } private func resetGestureState() { + RetroSelection.shared.select(nil) inspectHoldTask?.cancel() inspectHoldTask = nil pinchAnchor = nil @@ -523,12 +638,14 @@ private struct MainBGChart: View { interaction.visibleSeconds * TimeInterval(fraction) ) selection = date + let anchor = selectionAnchor(for: date) // A featherlight tick whenever the indicator snaps to a different slot. - if let anchor = selectionAnchor(for: date), anchor.date != lastHapticAnchorDate { + if let anchor, anchor.date != lastHapticAnchorDate { lastHapticAnchorDate = anchor.date scrubHaptic.selectionChanged() scrubHaptic.prepare() } + RetroSelection.shared.select(anchor.map { $0.readingDate ?? $0.date }) } /// Deceleration after a flick. Mutates only scrollPosition (a transform), @@ -665,6 +782,9 @@ private struct MainBGChart: View { } updateRenderWindow(force: true) interaction.persistZoom() + // A double tap is over at once: hide the button and fade it back in. + showJumpToNow = false + updateJumpToNowVisibility(interacting: false) } // MARK: Selection @@ -676,13 +796,17 @@ private struct MainBGChart: View { let value: Double /// One pill entry per item under the selector (see PillLabel). let texts: [String] + /// The BG reading under the selector, whose forecast can be shown. + var readingDate: Date? + /// The Treatments list entry a tap on the pill opens. + var detail: Treatment? } /// Feeds every treatment mark to `body`. Single source for both the scrub /// lookup and the tap hit test. private func forEachTreatmentAnchor(_ body: (BGChartModel.TreatmentPoint) -> Void) { for group in [model.boluses, model.carbs, model.smbs, model.bgChecks, - model.notes, model.suspends, model.resumes, model.sensorStarts] + model.notes, model.sensorStates, model.suspends, model.resumes, model.sensorStarts] { for t in group { body(t) @@ -712,18 +836,30 @@ private struct MainBGChart: View { return texts } + /// Treatments list entry of a band at `date`, the override's before the + /// temp target's (the order bandPillTexts lists them in). + private func bandDetail(at date: Date) -> Treatment? { + let override = model.overrides + .filter { date >= $0.start && date <= $0.end } + .max { $0.start < $1.start } + let tempTarget = model.tempTargets + .filter { date >= $0.start && date <= $0.end } + .max { $0.start < $1.start } + return override?.detail ?? tempTarget?.detail + } + /// Band (override / temp target) under the given date+value, if any. private func bandAnchor(at date: Date, value: Double) -> SelectionAnchor? { for band in model.overrides where date >= band.start && date <= band.end { if value >= band.yBottom, value <= band.yTop { let midY = (band.yTop + band.yBottom) / 2 - return SelectionAnchor(date: date, value: midY, texts: [band.pillText]) + return SelectionAnchor(date: date, value: midY, texts: [band.pillText], detail: band.detail) } } for band in model.tempTargets where date >= band.start && date <= band.end { if value >= band.yBottom, value <= band.yTop { let midY = (band.yTop + band.yBottom) / 2 - return SelectionAnchor(date: date, value: midY, texts: [band.pillText]) + return SelectionAnchor(date: date, value: midY, texts: [band.pillText], detail: band.detail) } } return nil @@ -736,7 +872,14 @@ private struct MainBGChart: View { /// across the block. The indicator's height comes from the reading nearest /// the mark, else the nearest treatment, else the band; an empty block /// shows nothing. - private func selectionAnchor(for selected: Date) -> SelectionAnchor? { + private enum SelectionPurpose { + /// Press-and-hold scrubbing: how things were at that time. + case scrub + /// A tap: what happened at that point. + case tap + } + + private func selectionAnchor(for selected: Date, purpose: SelectionPurpose = .scrub) -> SelectionAnchor? { let slot = model.scrubSlots.slot(containing: selected) let mark = slot.date @@ -746,15 +889,17 @@ private struct MainBGChart: View { } treatments.sort { $0.date < $1.date } let readings = model.bg.filter { slot.contains($0.date) } - - var texts = treatments.map(\.pillText) + readings.map(bgPillText) let bandTexts = bandPillTexts(at: mark) - texts += bandTexts + + // Scrubbing shows no pill: the BG display and info table show the + // values at the scrubbed time. A tap lists what happened there. + let texts = purpose == .tap ? treatments.map(\.pillText) + readings.map(bgPillText) + bandTexts : [] func distanceToMark(_ date: Date) -> TimeInterval { abs(date.timeIntervalSince(mark)) } var value: Double? - if let reading = readings.min(by: { distanceToMark($0.date) < distanceToMark($1.date) }) { + let nearestReading = readings.min(by: { distanceToMark($0.date) < distanceToMark($1.date) }) + if let reading = nearestReading { value = reading.value } else if let nearest = treatments.min(by: { distanceToMark($0.date) < distanceToMark($1.date) }) { value = nearest.sgv @@ -767,7 +912,7 @@ private struct MainBGChart: View { } guard let value else { return nil } - return SelectionAnchor(date: mark, value: value, texts: texts) + return SelectionAnchor(date: mark, value: value, texts: texts, readingDate: nearestReading?.date) } /// Tap hit test (screen-space, 2D). Treatments take priority, then BG @@ -778,20 +923,20 @@ private struct MainBGChart: View { var best: SelectionAnchor? var bestDistance2 = radius * radius - func consider(_ date: Date, _ value: Double, _ text: String) { + func consider(_ date: Date, _ value: Double, _ text: String, isReading: Bool = false, detail: Treatment? = nil) { let dx = xPosition(for: date, viewportWidth: viewportWidth) - location.x let dy = yPosition(forValue: value) - location.y let d2 = dx * dx + dy * dy if d2 <= bestDistance2 { bestDistance2 = d2 - best = SelectionAnchor(date: date, value: value, texts: [text]) + best = SelectionAnchor(date: date, value: value, texts: [text], readingDate: isReading ? date : nil, detail: detail) } } - forEachTreatmentAnchor { consider($0.drawnDate, $0.sgv, $0.pillText) } + forEachTreatmentAnchor { consider($0.drawnDate, $0.sgv, $0.pillText, detail: $0.detail) } if best == nil { for p in model.bg { - consider(p.date, p.value, bgPillText(for: p)) + consider(p.date, p.value, bgPillText(for: p), isReading: true) } } if best == nil { @@ -802,14 +947,43 @@ private struct MainBGChart: View { } if let best { let texts = best.texts + bandPillTexts(at: best.date) - return SelectionAnchor(date: best.date, value: best.value, texts: texts) + let detail = best.detail ?? bandDetail(at: best.date) + return SelectionAnchor(date: best.date, value: best.value, texts: texts, readingDate: best.readingDate, detail: detail) } return nil } - private func handleTap(at location: CGPoint, viewportWidth: CGFloat) { + /// A tap on a linked pill opens its treatment. Otherwise taps on the main + /// chart hit-test its marks, and taps on a history pane select by time + /// alone, like a scrub. + private func handleTap(at location: CGPoint, viewportWidth: CGFloat, mainHeight: CGFloat) { guard plotFrame.height > 0 else { return } - tapped = tappedAnchor(at: location, viewportWidth: viewportWidth) + if let detail = tapped?.detail, + let frame = pillFrame(for: tapped, viewportWidth: viewportWidth), + frame.insetBy(dx: -BGChartConfig.pillLinkSlop, dy: -BGChartConfig.pillLinkSlop).contains(location) + { + momentumTask?.cancel() + momentumTask = nil + resetGestureState() + tapped = nil + selectedTreatment = detail + return + } + if location.y > mainHeight { + let date = interaction.scrollPosition.addingTimeInterval( + interaction.visibleSeconds * TimeInterval(location.x / viewportWidth) + ) + tapped = selectionAnchor(for: date, purpose: .tap) + } else { + tapped = tappedAnchor(at: location, viewportWidth: viewportWidth) + } + } + + /// Pane height for `count` history panes, leaving the main chart most of the space. + private func historyPaneHeight(viewportHeight: CGFloat, count: Int) -> CGFloat { + guard count > 0 else { return 0 } + let preferred = min(max(viewportHeight * 0.15, 44), 72) + return min(preferred, viewportHeight * 0.45 / CGFloat(count)) } /// The anchor the overlay should show: a live scrub wins over a sticky tap. @@ -870,6 +1044,65 @@ private struct MainBGChart: View { } } + /// Screen frame of the pill for `anchor`, or nil when no pill is drawn. + /// Shared by the overlay and the tap that follows a pill's link. + private func pillFrame(for anchor: SelectionAnchor?, viewportWidth: CGFloat) -> CGRect? { + guard plotFrame.height > 0, let anchor, !anchor.texts.isEmpty else { return nil } + let x = xPosition(for: anchor.date, viewportWidth: viewportWidth) + guard x >= 0, x <= viewportWidth else { return nil } + // Measured size lags the text by one frame; fall back to a + // small nominal size until the first measurement lands. + let pillW = max(pillSize.width, 60) + let pillH = max(pillSize.height, 28) + let center = pillPosition(x: x, y: yPosition(forValue: anchor.value), pillW: pillW, pillH: pillH, viewportWidth: viewportWidth) + return CGRect(x: center.x - pillW / 2, y: center.y - pillH / 2, width: pillW, height: pillH) + } + + /// Where the pill goes for an indicator at (x, y): under the anchor when + /// there is room, else above it. + private func pillPosition(x: CGFloat, y: CGFloat, pillW: CGFloat, pillH: CGFloat, viewportWidth: CGFloat) -> CGPoint { + let labelX = min(max(x, pillW / 2 + 4), viewportWidth - pillW / 2 - 4) + let below = y + 14 + pillH / 2 + let above = y - 14 - pillH / 2 + let fitsBelow = below + pillH / 2 <= plotFrame.maxY - 4 + return CGPoint(x: labelX, y: fitsBelow ? below : max(above, plotFrame.minY + pillH / 2 + 4)) + } + + /// The forecast the loop made from the scrubbed BG reading, drawn in the + /// shell like the pill so scrubbing never re-lays the canvas. + @ViewBuilder + private func historyForecastOverlay(viewportWidth: CGFloat) -> some View { + if isInspectLatched, plotFrame.height > 0, + let readingDate = activeAnchor()?.readingDate, + let forecast = BGChartHistory.forecast(forReadingAt: readingDate) + { + ZStack(alignment: .topLeading) { + if !forecast.cone.isEmpty { + Path { path in + for (index, pt) in forecast.cone.enumerated() { + let point = CGPoint(x: xPosition(for: pt.date, viewportWidth: viewportWidth), y: yPosition(forValue: pt.yMax)) + if index == 0 { path.move(to: point) } else { path.addLine(to: point) } + } + for pt in forecast.cone.reversed() { + path.addLine(to: CGPoint(x: xPosition(for: pt.date, viewportWidth: viewportWidth), y: yPosition(forValue: pt.yMin))) + } + path.closeSubpath() + } + .fill(Color(.systemBlue).opacity(0.3)) + } + ForEach(forecast.curves) { curve in + Path { path in + for (index, pt) in curve.points.enumerated() { + let point = CGPoint(x: xPosition(for: pt.date, viewportWidth: viewportWidth), y: yPosition(forValue: pt.value)) + if index == 0 { path.move(to: point) } else { path.addLine(to: point) } + } + } + .stroke(curve.color, style: StrokeStyle(lineWidth: 2, dash: [5, 3])) + } + } + } + } + /// Vertical indicator + pill for the current selection, rendered in the /// shell with the same linear maps the canvas uses — neither scrubbing /// nor a tapped pill ever re-lays the canvas. @@ -880,28 +1113,22 @@ private struct MainBGChart: View { /// SwiftUI's word wrapping, which respects the actual font metrics, so /// there is no manual line splitting. @ViewBuilder - private func selectionOverlay(viewportWidth: CGFloat) -> some View { + private func selectionOverlay(viewportWidth: CGFloat, indicatorBottom: CGFloat) -> some View { if plotFrame.height > 0, let anchor = activeAnchor() { let x = xPosition(for: anchor.date, viewportWidth: viewportWidth) if x >= 0, x <= viewportWidth { - let y = yPosition(forValue: anchor.value) + // With history panes the indicator runs down through them too. + let lineBottom = model.historyPanes.isEmpty ? plotFrame.maxY : indicatorBottom Rectangle() .fill(Color.primary.opacity(0.5)) - .frame(width: 1, height: plotFrame.height) - .position(x: x, y: plotFrame.midY) - - // Measured size lags the text by one frame; fall back to a - // small nominal size until the first measurement lands. - let pillW = max(pillSize.width, 60) - let pillH = max(pillSize.height, 28) - let labelX = min(max(x, pillW / 2 + 4), viewportWidth - pillW / 2 - 4) - let below = y + 14 + pillH / 2 - let above = y - 14 - pillH / 2 - let fitsBelow = below + pillH / 2 <= plotFrame.maxY - 4 - let labelY = fitsBelow ? below : max(above, plotFrame.minY + pillH / 2 + 4) - PillLabel(texts: anchor.texts, maxWidth: min(300, viewportWidth - 16)) - .position(x: labelX, y: labelY) + .frame(width: 1, height: lineBottom - plotFrame.minY) + .position(x: x, y: (plotFrame.minY + lineBottom) / 2) + + if let frame = pillFrame(for: anchor, viewportWidth: viewportWidth) { + PillLabel(texts: anchor.texts, maxWidth: min(300, viewportWidth - 16), showsLink: anchor.detail != nil) + .position(x: frame.midX, y: frame.midY) + } } } } @@ -1046,9 +1273,14 @@ private struct BGChartCanvas: View, Equatable { coneMarks if !isSmall { yesterdayMarks + // Under the readings, so low BG stays visible above the band. + historyOverlayMarks } bgLineMarks bgPointsMark + if !isSmall { + smoothedBGMarks + } predictionLineMark predictionVariantMarks if showTreatments { @@ -1099,17 +1331,8 @@ private struct BGChartCanvas: View, Equatable { } } - /// For line/area series: like `windowed`, but keeps one point beyond each - /// edge so segments crossing (or spanning) the window survive. Assumes - /// ascending dates. private func windowedLine(_ items: [T], date: (T) -> Date) -> [T] { - guard items.count > 1 else { return items } - let firstInside = items.firstIndex { date($0) >= windowStart } ?? items.count - let firstBeyond = items.firstIndex { date($0) > windowEnd } ?? items.count - 1 - let lo = max(firstInside - 1, 0) - let hi = min(firstBeyond, items.count - 1) - guard lo <= hi else { return [] } - return Array(items[lo ... hi]) + chartWindowedLine(items, windowStart: windowStart, windowEnd: windowEnd, date: date) } // MARK: X axis @@ -1274,6 +1497,37 @@ private struct BGChartCanvas: View, Equatable { } } + @ChartContentBuilder + private var historyOverlayMarks: some ChartContent { + if let overlay = model.historyOverlay { + ForEach(overlay.series) { series in + ForEach(windowedLine(series.points) { $0.date }) { pt in + LineMark( + x: .value("time", pt.date), + y: .value(series.kind.title, pt.value), + series: .value("series", "overlay-\(series.id)-\(pt.segment)") + ) + .foregroundStyle(series.color) + .lineStyle(StrokeStyle(lineWidth: 1.5)) + } + } + } + } + + @ChartContentBuilder + private var smoothedBGMarks: some ChartContent { + ForEach(windowedLine(model.smoothedBG) { $0.date }) { pt in + LineMark( + x: .value("time", pt.date), + y: .value("bg", pt.value), + series: .value("series", "smoothed-\(pt.segment)") + ) + .foregroundStyle(Color.cyan) + .lineStyle(StrokeStyle(lineWidth: 2)) + .interpolationMethod(.linear) + } + } + @ChartContentBuilder private var predictionLineMark: some ChartContent { ForEach(windowedLine(model.prediction) { $0.date }) { pt in @@ -1422,6 +1676,16 @@ private struct BGChartCanvas: View, Equatable { .symbolSize(isSmall ? 22 : 54) .foregroundStyle(Color.gray.opacity(0.75)) } + + ForEach(windowed(model.sensorStates) { $0.drawnDate }) { pt in + PointMark( + x: .value("time", pt.drawnDate), + y: .value("sgv", pt.sgv) + ) + .symbol(.triangle) + .symbolSize(isSmall ? 22 : 54) + .foregroundStyle(Color.orange.opacity(0.85)) + } } @ChartContentBuilder @@ -1554,10 +1818,15 @@ private struct StaticYAxisOverlay: View, Equatable { } } +/// Only the main chart's axis overlay reports a frame; siblings that report +/// nothing must not reset it to zero. private struct PlotFramePreferenceKey: PreferenceKey { static var defaultValue: CGRect = .zero static func reduce(value: inout CGRect, nextValue: () -> CGRect) { - value = nextValue() + let next = nextValue() + if next != .zero { + value = next + } } } @@ -1566,7 +1835,10 @@ private struct PlotFramePreferenceKey: PreferenceKey { private struct PillSizePreferenceKey: PreferenceKey { static var defaultValue: CGSize = .zero static func reduce(value: inout CGSize, nextValue: () -> CGSize) { - value = nextValue() + let next = nextValue() + if next != .zero { + value = next + } } } @@ -1591,27 +1863,36 @@ private struct PillLabel: View { /// readable over a busy cluster. let texts: [String] let maxWidth: CGFloat + /// The pill opens a Treatments list entry when tapped. + var showsLink = false var body: some View { - content - .padding(.horizontal, 6) - .padding(.vertical, 3) - .background( - RoundedRectangle(cornerRadius: 6) - .fill(Color(.secondarySystemBackground)) - .overlay( - RoundedRectangle(cornerRadius: 6) - .stroke(Color.primary, lineWidth: 0.5) - ) - ) - .background( - GeometryReader { geo in - Color.clear.preference(key: PillSizePreferenceKey.self, value: geo.size) - } - ) - // Transparent flexible container: it caps the width the text can - // wrap to, while the visible pill above still hugs its content. - .frame(maxWidth: maxWidth) + HStack(spacing: 4) { + content + if showsLink { + Image(systemName: "chevron.right") + .font(.caption2.weight(.semibold)) + .foregroundColor(.accentColor) + } + } + .padding(.horizontal, 6) + .padding(.vertical, 3) + .background( + RoundedRectangle(cornerRadius: 6) + .fill(Color(.secondarySystemBackground)) + .overlay( + RoundedRectangle(cornerRadius: 6) + .stroke(showsLink ? Color.accentColor : Color.primary, lineWidth: showsLink ? 1 : 0.5) + ) + ) + .background( + GeometryReader { geo in + Color.clear.preference(key: PillSizePreferenceKey.self, value: geo.size) + } + ) + // Transparent flexible container: it caps the width the text can + // wrap to, while the visible pill above still hugs its content. + .frame(maxWidth: maxWidth) } @ViewBuilder diff --git a/LoopFollow/Controllers/MainViewController+updateStats.swift b/LoopFollow/Controllers/MainViewController+updateStats.swift index f3c4c0d10..b1a87710c 100644 --- a/LoopFollow/Controllers/MainViewController+updateStats.swift +++ b/LoopFollow/Controllers/MainViewController+updateStats.swift @@ -4,21 +4,34 @@ import Foundation extension MainViewController { + /// Start of the period both home screen stats cover: the last 24 hours, or since + /// midnight when chosen in Settings. Midnight follows the display time zone, so a + /// Time Zone Override is respected. + func homeStatsPeriodStart() -> TimeInterval { + if Storage.shared.statsSinceMidnight.value { + return dateTimeUtils.displayCalendar().startOfDay(for: Date()).timeIntervalSince1970 + } + return dateTimeUtils.getTimeIntervalNHoursAgo(N: 24) + } + func updateStats() { - if bgData.count > 0 { - var lastDayOfData = bgData - let graphHours = 24 * Storage.shared.downloadDays.value - // If we loaded more than 1 day of data, only use the last day for the stats - if graphHours > 24 { - let oneDayAgo = dateTimeUtils.getTimeIntervalNHoursAgo(N: 24) - var startIndex = 0 - while startIndex < bgData.count, bgData[startIndex].date < oneDayAgo { - startIndex += 1 - } - lastDayOfData = Array(bgData.dropFirst(startIndex)) - } + let periodStart = homeStatsPeriodStart() + let periodData = bgData.filter { $0.date >= periodStart } - let stats = StatsData(bgData: lastDayOfData) + if periodData.isEmpty { + // Nothing in the period yet (e.g. just after midnight): show placeholders + // rather than the previous period's numbers. + statsDisplayModel.lowPercent = "--" + statsDisplayModel.inRangePercent = "--" + statsDisplayModel.highPercent = "--" + statsDisplayModel.avgBG = "--" + statsDisplayModel.estA1C = "--" + statsDisplayModel.stdDev = "--" + statsDisplayModel.pieLow = 0 + statsDisplayModel.pieRange = 0 + statsDisplayModel.pieHigh = 0 + } else { + let stats = StatsData(bgData: periodData) statsDisplayModel.lowPercent = String(format: "%.1f%%", stats.percentLow) statsDisplayModel.inRangePercent = String(format: "%.1f%%", stats.percentRange) @@ -48,5 +61,36 @@ extension MainViewController { statsDisplayModel.pieRange = Double(stats.percentRange) statsDisplayModel.pieHigh = Double(stats.percentHigh) } + updateTIRBand(periodStart: periodStart) + } + + /// Range distribution for the Time in Range band over the same period as the + /// statistics box (last 24 hours, or today since midnight). + /// Low, in range and high follow the Range Mode chosen in Settings (TIR, TITR, + /// TING or Custom). Very low is below 54 mg/dL and very high above 250 mg/dL, or + /// the range's own bounds when a custom range extends past them. + func updateTIRBand(periodStart: TimeInterval) { + let thresholds = UnitSettingsStore.shared.effectiveBandThresholds() + let values = bgData + .filter { $0.date >= periodStart && $0.sgv > 0 } + .map { Double($0.sgv) } + + statsDisplayModel.bandTitle = UnitSettingsStore.shared.timeInRangeMode.title + + let percentages = TIRCalculator.calculatePercentages( + readings: values, + veryLowThreshold: thresholds.veryLow, + lowThreshold: thresholds.low, + highThreshold: thresholds.high, + veryHighThreshold: thresholds.veryHigh + ) + + statsDisplayModel.bandVeryLowPct = percentages.veryLow + statsDisplayModel.bandLowPct = percentages.low + statsDisplayModel.bandInRangePct = percentages.inRange + statsDisplayModel.bandHighPct = percentages.high + statsDisplayModel.bandVeryHighPct = percentages.veryHigh + statsDisplayModel.bandHasData = !values.isEmpty + statsDisplayModel.bandPeriod = Storage.shared.statsSinceMidnight.value ? "today" : "last 24h" } } diff --git a/LoopFollow/Controllers/NightScout.swift b/LoopFollow/Controllers/NightScout.swift index 90e3c1e9c..1b9b438c7 100644 --- a/LoopFollow/Controllers/NightScout.swift +++ b/LoopFollow/Controllers/NightScout.swift @@ -31,18 +31,22 @@ extension MainViewController { } // NS Bolus Data Struct - struct bolusGraphStruct: Codable { + struct bolusGraphStruct { var value: Double var date: TimeInterval var sgv: Int + /// The Treatments list entry this dot opens. + var detail: Treatment? = nil } // NS Bolus Data Struct - struct carbGraphStruct: Codable { + struct carbGraphStruct { var value: Double var date: TimeInterval var sgv: Int var absorptionTime: Int + /// The Treatments list entry this dot opens. + var detail: Treatment? = nil } func clearOldTempBasal() { @@ -101,6 +105,8 @@ extension MainViewController { func clearOldNotes() { noteGraphData.removeAll() + cgmSensorStates.removeAll() updateNotes() + updateCGMSensorState() } } diff --git a/LoopFollow/Controllers/Nightscout/BGData.swift b/LoopFollow/Controllers/Nightscout/BGData.swift index a568746c7..52e3555be 100644 --- a/LoopFollow/Controllers/Nightscout/BGData.swift +++ b/LoopFollow/Controllers/Nightscout/BGData.swift @@ -279,6 +279,7 @@ extension MainViewController { self.updateBGGraph() self.updateStats() + self.updateCGMSensorState() let latestEntryIndex = entries.count - 1 let latestBG = entries[latestEntryIndex].sgv diff --git a/LoopFollow/Controllers/Nightscout/CAge.swift b/LoopFollow/Controllers/Nightscout/CAge.swift index 7e20bfade..ae02c46b4 100644 --- a/LoopFollow/Controllers/Nightscout/CAge.swift +++ b/LoopFollow/Controllers/Nightscout/CAge.swift @@ -39,7 +39,11 @@ extension MainViewController { .withTime, .withDashSeparatorInDate, .withColonSeparatorInTime] - Storage.shared.cageInsertTime.value = formatter.date(from: lastCageString)?.timeIntervalSince1970 as! TimeInterval + guard let cageInsertTime = formatter.date(from: lastCageString)?.timeIntervalSince1970 else { + LogManager.shared.log(category: .nightscout, message: "updateCage: unparseable created_at '\(lastCageString)'") + return + } + Storage.shared.cageInsertTime.value = cageInsertTime if let cageTime = formatter.date(from: lastCageString)?.timeIntervalSince1970 { let now = dateTimeUtils.getNowTimeIntervalUTC() let secondsAgo = now - cageTime diff --git a/LoopFollow/Controllers/Nightscout/DeviceStatus.swift b/LoopFollow/Controllers/Nightscout/DeviceStatus.swift index 4cd3a65a1..0c23ccc73 100644 --- a/LoopFollow/Controllers/Nightscout/DeviceStatus.swift +++ b/LoopFollow/Controllers/Nightscout/DeviceStatus.swift @@ -67,7 +67,7 @@ extension MainViewController { func updateDeviceStatusDisplay(jsonDeviceStatus: [[String: AnyObject]]) { let previousIOBText = Observable.shared.iobText.value let previousDeviceWasLoop = Storage.shared.device.value == "Loop" - infoManager.clearInfoData(types: [.iob, .cob, .battery, .pump, .pumpBattery, .target, .isf, .carbRatio, .updated, .recBolus, .tdd]) + infoManager.clearInfoData(types: [.iob, .cob, .battery, .pump, .pumpBattery, .target, .isf, .carbRatio, .updated, .recBolus, .tdd, .smoothedBG]) // For Loop, clear the current override here - For Trio, it is handled using treatments if Storage.shared.device.value == "Loop" { @@ -92,7 +92,7 @@ extension MainViewController { Observable.shared.previousAlertLastLoopTime.value = Observable.shared.alertLastLoopTime.value - if let lastPumpRecord = lastDeviceStatus?["pump"] as! [String: AnyObject]? { + if let lastPumpRecord = lastDeviceStatus?["pump"] as? [String: AnyObject] { if let bolusIncrement = lastPumpRecord["bolusIncrement"] as? Double, bolusIncrement > 0 { Storage.shared.bolusIncrement.value = HKQuantity(unit: .internationalUnit(), doubleValue: bolusIncrement) Storage.shared.bolusIncrementDetected.value = true @@ -117,8 +117,9 @@ extension MainViewController { pumpID: lastPumpRecord["pumpID"] as? String, manufacturer: lastPumpRecord["manufacturer"] as? String, model: lastPumpRecord["model"] as? String, + displayOverride: lastPumpRecord["reservoir_display_override"] as? String, cache: Storage.shared.pumpReservoirCache.value, - now: Date() + date: Date(timeIntervalSince1970: lastPumpTime) ) Storage.shared.pumpReservoirCache.value = reservoir.cache @@ -133,6 +134,10 @@ extension MainViewController { latestPumpVolume = 50.0 infoManager.updateInfoData(type: .pump, value: "50+U", numericValue: 50.0) Storage.shared.lastPumpReservoirU.value = nil + case let .status(status): + latestPumpVolume = nil + infoManager.updateInfoData(type: .pump, value: status) + Storage.shared.lastPumpReservoirU.value = nil case .unknown: // The row stays cleared, which the info table renders as an em dash. latestPumpVolume = nil @@ -146,6 +151,14 @@ extension MainViewController { { infoManager.updateInfoData(type: .pumpBattery, value: String(format: "%.0f", pumpBatteryPercent) + "%", numericValue: pumpBatteryPercent) Observable.shared.pumpBatteryLevel.value = pumpBatteryPercent + latestPumpBatteryDate = Date() + } else if let lastPercent = Observable.shared.pumpBatteryLevel.value, + let lastDate = latestPumpBatteryDate, + Date().timeIntervalSince(lastDate) <= 60 * 60 + { + // Some records leave out the pump battery, and the level changes + // slowly, so the last reading is shown for up to an hour. + infoManager.updateInfoData(type: .pumpBattery, value: String(format: "%.0f", lastPercent) + "%", numericValue: lastPercent) } if let uploader = lastDeviceStatus?["uploader"] as? [String: AnyObject], @@ -174,10 +187,10 @@ extension MainViewController { } // Loop - handle new data - if let lastLoopRecord = lastDeviceStatus?["loop"] as! [String: AnyObject]? { + if let lastLoopRecord = lastDeviceStatus?["loop"] as? [String: AnyObject] { // Some pumps report no `pump.clock`; without it alertLastLoopTime stays 0 // and the forecast anchors to epoch 0. Fall back to the loop cycle timestamp. - if (lastDeviceStatus?["pump"] as? [String: AnyObject])?["clock"] == nil, + if (lastDeviceStatus?["pump"] as? [String: AnyObject])?["clock"] as? String == nil, let loopTimestampString = lastLoopRecord["timestamp"] as? String, let loopTimestamp = formatter.date(from: loopTimestampString)?.timeIntervalSince1970, loopTimestamp > (Observable.shared.alertLastLoopTime.value ?? 0) @@ -215,7 +228,7 @@ extension MainViewController { } // OpenAPS - handle new data - if let lastLoopRecord = lastDeviceStatus?["openaps"] as! [String: AnyObject]? { + if let lastLoopRecord = lastDeviceStatus?["openaps"] as? [String: AnyObject] { DeviceStatusOpenAPS(formatter: formatter, lastDeviceStatus: lastDeviceStatus, lastLoopRecord: lastLoopRecord) } @@ -261,6 +274,8 @@ extension MainViewController { evaluateNotLooping() + DeviceStatusHistory.shared.ingestLatest(lastDeviceStatus) + // Mark device status as loaded for initial loading state markDataLoaded("deviceStatus") diff --git a/LoopFollow/Controllers/Nightscout/DeviceStatusHistory.swift b/LoopFollow/Controllers/Nightscout/DeviceStatusHistory.swift new file mode 100644 index 000000000..c2e252300 --- /dev/null +++ b/LoopFollow/Controllers/Nightscout/DeviceStatusHistory.swift @@ -0,0 +1,362 @@ +// LoopFollow +// DeviceStatusHistory.swift + +import Combine +import Foundation + +/// History of the values the looping app reports in Nightscout devicestatus +/// records (IOB, COB, smoothed BG, sensitivity ratio, forecasts and the info +/// table values), shared by the history graphs and the scrubbed-time displays. +/// +/// Devicestatus records never change once written, so the extracted fields are +/// cached on disk and only missing spans are fetched: the Show Days Back window +/// once, then gaps after the app was suspended. The regular device status poll +/// hands each record it reads to `ingestLatest`, which extends the history +/// without a request. Changing the Nightscout address drops the cache. +/// +/// Main-thread confined. +final class DeviceStatusHistory { + static let shared = DeviceStatusHistory() + + private(set) var state = DeviceStatusHistoryState() + + /// Called on the main thread whenever the history changes. + var onChange: (() -> Void)? + + private enum LoadState { + case notLoaded, loading, loaded + } + + private struct CacheFile: Codable { + static let currentVersion = 2 + let version: Int + let url: String + let state: DeviceStatusHistoryState + } + + /// Trio records carry four forecast curves (a few KB each), so a page stays + /// around half a megabyte; small Nightscout hosts fail on much larger responses. + private static let pageSize = 100 + private static let saveDelay: TimeInterval = 5 + + private var loadState = LoadState.notLoaded + /// Bumped whenever the history is dropped; work started under an older + /// generation is discarded when it completes. + private var generation = 0 + private var isFetching = false + private var consecutiveFailures = 0 + private var retryAfter = Date.distantPast + /// Where a range that failed part-way resumes, so pages already merged are + /// not downloaded again. + private var resumePoint: ResumePoint? + + private struct ResumePoint { + let range: DeviceStatusHistoryState.FetchRange + let upperBound: String + let pagesFetched: Int + } + + private var saveScheduled = false + private var cancellables = Set() + private let ioQueue = DispatchQueue(label: "com.loopfollow.devicestatushistory", qos: .utility) + + private static var cacheFileURL: URL? { + FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first? + .appendingPathComponent("DeviceStatusHistory.json") + } + + private init() {} + + /// Subscribes to the settings the history depends on. Idempotent. + func start() { + guard cancellables.isEmpty else { return } + + Storage.shared.url.$value + .removeDuplicates() + .dropFirst() + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in self?.reset() } + .store(in: &cancellables) + + let s = Storage.shared + Publishers.MergeMany( + s.showIOBGraph.$value.dropFirst().map { _ in () }.eraseToAnyPublisher(), + s.showCOBGraph.$value.dropFirst().map { _ in () }.eraseToAnyPublisher(), + s.showSensitivityRatioGraph.$value.dropFirst().map { _ in () }.eraseToAnyPublisher(), + s.showSmoothedBG.$value.dropFirst().map { _ in () }.eraseToAnyPublisher(), + s.historyCurvePlacement.$value.dropFirst().map { _ in () }.eraseToAnyPublisher(), + s.downloadDays.$value.dropFirst().map { _ in () }.eraseToAnyPublisher() + ) + .receive(on: DispatchQueue.main) + .sink { [weak self] in self?.settingsChanged() } + .store(in: &cancellables) + + loadIfNeeded() + } + + /// Takes the record the regular device status poll just read. Extends the + /// history directly when it continues it, otherwise fetches what is missing. + func ingestLatest(_ record: [String: Any]?) { + guard let record else { return } + guard loadState == .loaded else { + loadIfNeeded() + return + } + guard let parsed = DeviceStatusHistoryParser.parse(record) else { + // Uploader-only records (for example from a CGM app) still show that + // newer records exist. + let createdAt = (record["created_at"] as? String).flatMap(DeviceStatusHistoryParser.parseDate) + refreshIfNeeded(latest: createdAt?.timeIntervalSince1970) + return + } + + let createdAtTime = parsed.createdAt.timeIntervalSince1970 + let previous = state + if state.appendLatest(parsed.sample, createdAt: createdAtTime), state != previous { + state.prune(before: windowStart()) + scheduleSave() + onChange?() + } + refreshIfNeeded(latest: createdAtTime) + } + + /// The sample of the loop cycle nearest `date`, within `tolerance`. + func sample(near date: Date, tolerance: TimeInterval = 5 * 60) -> DeviceStatusHistorySample? { + state.sample(near: date.timeIntervalSince1970, tolerance: tolerance) + } + + /// The loop cycle that ran on the BG reading at `date`. + func cycle(forReadingAt date: Date) -> DeviceStatusHistorySample? { + state.cycle(forReadingAt: date.timeIntervalSince1970) + } + + // MARK: - Settings + + private func settingsChanged() { + if loadState == .loaded { + state.prune(before: windowStart()) + refreshIfNeeded(latest: nil) + } + onChange?() + } + + /// Drops the history (memory and disk) after the Nightscout address changed. + private func reset() { + generation &+= 1 + state = DeviceStatusHistoryState() + isFetching = false + consecutiveFailures = 0 + retryAfter = .distantPast + resumePoint = nil + loadState = .loaded + ioQueue.async { + if let url = Self.cacheFileURL { + try? FileManager.default.removeItem(at: url) + } + } + onChange?() + } + + private func windowStart(now: Date = Date()) -> TimeInterval { + now.timeIntervalSince1970 - TimeInterval(Storage.shared.downloadDays.value) * 24 * 3600 + } + + // MARK: - Fetching + + /// Fetches the spans the history is missing. `latest` is the `created_at` of + /// the newest record known to exist; nil assumes one exists up to now. + private func refreshIfNeeded(latest: TimeInterval?) { + guard loadState == .loaded, !isFetching, + IsNightscoutEnabled(), Date() >= retryAfter + else { return } + + let now = Date().timeIntervalSince1970 + let resume = resumePoint + resumePoint = nil + // A resumed range runs on its own; the next poll plans what is still missing. + let ranges = resume.map { [$0.range] } + ?? state.missingRanges(windowStart: windowStart(), latest: latest ?? now, now: now) + guard !ranges.isEmpty else { return } + + isFetching = true + fetch(ranges, resume: resume, generation: generation) + } + + /// Fetches `ranges` in order; the first continues from `resume` when set. + private func fetch(_ ranges: [DeviceStatusHistoryState.FetchRange], resume: ResumePoint? = nil, generation: Int) { + guard let range = ranges.first else { + isFetching = false + return + } + LogManager.shared.log( + category: .deviceStatus, + message: "Device status history fetch \(DeviceStatusHistoryParser.formatDate(Date(timeIntervalSince1970: range.from))) – \(DeviceStatusHistoryParser.formatDate(Date(timeIntervalSince1970: range.to)))", + isDebug: true + ) + fetchPages( + range: range, + upperBound: resume?.upperBound, + pagesFetched: resume?.pagesFetched ?? 0, + generation: generation + ) { [weak self] outcome in + guard let self, self.generation == generation else { return } + switch outcome { + case .completed: + self.consecutiveFailures = 0 + self.state.markCovered(from: range.from, to: range.to) + self.state.prune(before: self.windowStart()) + self.scheduleSave() + self.fetch(Array(ranges.dropFirst()), generation: generation) + case let .failed(reason, upperBound, pagesFetched): + if let upperBound { + self.resumePoint = ResumePoint(range: range, upperBound: upperBound, pagesFetched: pagesFetched) + } + self.consecutiveFailures += 1 + let backoff = min(30 * pow(2, Double(self.consecutiveFailures - 1)), 600) + self.retryAfter = Date().addingTimeInterval(backoff) + self.isFetching = false + LogManager.shared.log( + category: .deviceStatus, + message: "Device status history fetch failed (\(reason)), retrying in \(Int(backoff)) s", + limitIdentifier: "Device status history fetch failed" + ) + } + } + } + + private enum PageOutcome { + case completed + /// `upperBound` is the cursor of the page that failed; nil for the first page. + case failed(reason: String, upperBound: String?, pagesFetched: Int) + } + + /// Reads one range newest-first, a page at a time. Each page is merged as + /// it arrives so the graphs fill in progressively. + private func fetchPages( + range: DeviceStatusHistoryState.FetchRange, + upperBound: String?, + pagesFetched: Int, + generation: Int, + completion: @escaping (PageOutcome) -> Void + ) { + var parameters = [ + "count": String(Self.pageSize), + "find[created_at][$gte]": DeviceStatusHistoryParser.formatDate(Date(timeIntervalSince1970: range.from)), + ] + if let upperBound { + parameters["find[created_at][$lt]"] = upperBound + } else { + parameters["find[created_at][$lte]"] = DeviceStatusHistoryParser.formatDate(Date(timeIntervalSince1970: range.to)) + } + + NightscoutUtils.executeDynamicRequest(eventType: .deviceStatus, parameters: parameters) { [weak self] result in + let records: [Any] + switch result { + case let .success(json): + guard let array = json as? [Any] else { + DispatchQueue.main.async { + completion(.failed(reason: "unexpected response", upperBound: upperBound, pagesFetched: pagesFetched)) + } + return + } + records = array + case let .failure(error): + DispatchQueue.main.async { + completion(.failed(reason: error.localizedDescription, upperBound: upperBound, pagesFetched: pagesFetched)) + } + return + } + DispatchQueue.global(qos: .utility).async { + var samples: [DeviceStatusHistorySample] = [] + var oldestCreatedAt: String? + var oldestDate = Date.distantFuture + for case let record as [String: Any] in records { + if let raw = record["created_at"] as? String, + let date = DeviceStatusHistoryParser.parseDate(raw), date < oldestDate + { + oldestDate = date + oldestCreatedAt = raw + } + if let parsed = DeviceStatusHistoryParser.parse(record) { + samples.append(parsed.sample) + } + } + + DispatchQueue.main.async { + guard let self, self.generation == generation else { return } + self.state.merge(samples) + self.onChange?() + + let pages = pagesFetched + 1 + let isFull = records.count >= Self.pageSize && oldestCreatedAt != nil + if isFull, pages < Self.maxPages(for: range), let oldestCreatedAt { + self.fetchPages(range: range, upperBound: oldestCreatedAt, pagesFetched: pages, generation: generation, completion: completion) + } else { + if isFull { + LogManager.shared.log(category: .deviceStatus, message: "Device status history page limit reached; older records in the range are skipped") + } + completion(.completed) + } + } + } + } + } + + /// Room for one record per minute across the range, several times the + /// loop cycle rate, so only a runaway uploader hits the limit. + private static func maxPages(for range: DeviceStatusHistoryState.FetchRange) -> Int { + Int(((range.to - range.from) / 60 / Double(pageSize)).rounded(.up)) + 1 + } + + // MARK: - Persistence + + private func loadIfNeeded() { + guard loadState == .notLoaded else { return } + loadState = .loading + let generation = generation + let expectedURL = Storage.shared.url.value + + ioQueue.async { + var loaded: DeviceStatusHistoryState? + if let fileURL = Self.cacheFileURL, + let data = try? Data(contentsOf: fileURL), + let file = try? JSONDecoder().decode(CacheFile.self, from: data), + file.version == CacheFile.currentVersion, file.url == expectedURL + { + loaded = file.state + } + + DispatchQueue.main.async { [weak self] in + guard let self, self.generation == generation else { return } + if let loaded { + self.state = loaded + self.state.prune(before: self.windowStart()) + } + self.loadState = .loaded + self.onChange?() + self.refreshIfNeeded(latest: nil) + } + } + } + + private func scheduleSave() { + guard !saveScheduled else { return } + saveScheduled = true + DispatchQueue.main.asyncAfter(deadline: .now() + Self.saveDelay) { [weak self] in + guard let self else { return } + self.saveScheduled = false + let file = CacheFile(version: CacheFile.currentVersion, url: Storage.shared.url.value, state: self.state) + self.ioQueue.async { + guard let fileURL = Self.cacheFileURL, + let data = try? JSONEncoder().encode(file) + else { return } + do { + try FileManager.default.createDirectory(at: fileURL.deletingLastPathComponent(), withIntermediateDirectories: true) + try data.write(to: fileURL, options: [.atomic, .completeFileProtectionUntilFirstUserAuthentication]) + } catch { + LogManager.shared.log(category: .deviceStatus, message: "Device status history save failed: \(error.localizedDescription)") + } + } + } + } +} diff --git a/LoopFollow/Controllers/Nightscout/DeviceStatusHistorySample.swift b/LoopFollow/Controllers/Nightscout/DeviceStatusHistorySample.swift new file mode 100644 index 000000000..bcc233fba --- /dev/null +++ b/LoopFollow/Controllers/Nightscout/DeviceStatusHistorySample.swift @@ -0,0 +1,192 @@ +// LoopFollow +// DeviceStatusHistorySample.swift + +import Foundation + +/// The fields LoopFollow graphs over time or shows for a scrubbed time, +/// extracted from one Nightscout devicestatus record. Glucose values (smoothed +/// BG, eventual BG, target, forecasts) are mg/dL. +struct DeviceStatusHistorySample: Codable, Equatable { + /// Time of the loop cycle that produced the record. + let date: TimeInterval + var iob: Double? + var cob: Double? + /// Glucose the OpenAPS/Trio algorithm ran on (`suggested.bg`); smoothed in Trio. + var smoothedBG: Double? + /// Effective ISF of the loop cycle, as uploaded (Trio reports mg/dL per U). + var isf: Double? + /// `sensitivityRatio`: the autosens ratio, or the dynamic ratio when Dynamic ISF is on. + var sensitivityRatio: Double? + var forecast: Forecast? + /// Loop: the last forecast value. OpenAPS/Trio: `eventualBG`. + var eventualBG: Double? + var recommendedBolus: Double? + /// OpenAPS/Trio `current_target`. + var target: Double? + /// OpenAPS/Trio `CR`. + var carbRatio: Double? + var tdd: Double? + var reservoir: Double? + var pumpBattery: Double? + var uploaderBattery: Double? + /// Loop's active override, as reported in the record. + var loopOverride: LoopOverride? + + struct LoopOverride: Codable, Equatable { + let multiplier: Double? + let minTarget: Double + let maxTarget: Double + } + + struct Forecast: Codable, Equatable { + /// Time of the first value; values follow at five-minute steps. + let start: TimeInterval + /// Loop publishes one curve (`main`); OpenAPS/Trio publish `ZT`, `IOB`, `COB` and `UAM`. + let curves: [String: [Int]] + + static let loopCurve = "main" + static let openAPSCurves = ["ZT", "IOB", "COB", "UAM"] + static let step: TimeInterval = 300 + /// Six hours, the longest forecast the graph settings allow. + static let maxValues = 73 + } +} + +enum DeviceStatusHistoryParser { + /// Parses a devicestatus record. Returns nil for records without loop data + /// (for example uploader-only records from a CGM app). + static func parse(_ record: [String: Any]) -> (sample: DeviceStatusHistorySample, createdAt: Date)? { + let createdAt = (record["created_at"] as? String).flatMap(parseDate) + + if let loop = record["loop"] as? [String: Any] { + guard let date = (loop["timestamp"] as? String).flatMap(parseDate) ?? createdAt else { return nil } + var sample = DeviceStatusHistorySample(date: date.timeIntervalSince1970) + sample.iob = number((loop["iob"] as? [String: Any])?["iob"]) + sample.cob = number((loop["cob"] as? [String: Any])?["cob"]) + if let predicted = loop["predicted"] as? [String: Any], + let values = predicted["values"] as? [Any] + { + let start = (predicted["startDate"] as? String).flatMap(parseDate) ?? date + sample.forecast = forecast(start: start, curves: [DeviceStatusHistorySample.Forecast.loopCurve: values]) + sample.eventualBG = number(values.last) + } + sample.recommendedBolus = number(loop["recommendedBolus"]) + if let override = record["override"] as? [String: Any], + override["active"] as? Bool == true, + let range = override["currentCorrectionRange"] as? [String: Any], + let minTarget = number(range["minValue"]), + let maxTarget = number(range["maxValue"]) + { + sample.loopOverride = .init(multiplier: number(override["multiplier"]), minTarget: minTarget, maxTarget: maxTarget) + } + addDeviceFields(from: record, to: &sample) + return (sample, createdAt ?? date) + } + + if let openAPS = record["openaps"] as? [String: Any] { + guard let determination = openAPS["suggested"] as? [String: Any] ?? openAPS["enacted"] as? [String: Any] else { return nil } + let determinationDate = (determination["deliverAt"] as? String ?? determination["timestamp"] as? String).flatMap(parseDate) + guard let date = determinationDate ?? createdAt else { return nil } + + var sample = DeviceStatusHistorySample(date: date.timeIntervalSince1970) + sample.iob = openAPSIOB(openAPS["iob"]) + sample.cob = number(determination["COB"]) ?? reasonValue("COB", in: determination["reason"] as? String) + sample.smoothedBG = number(determination["bg"]).flatMap { $0 > 0 ? $0 : nil } + sample.isf = number(determination["ISF"]).flatMap { $0 > 0 ? $0 : nil } + sample.sensitivityRatio = number(determination["sensitivityRatio"]).flatMap { $0 > 0 ? $0 : nil } + sample.eventualBG = number(determination["eventualBG"]) + sample.recommendedBolus = number(openAPS["recommendedBolus"]) + // Same convention as the live Target row: values below 40 are mmol/L. + sample.target = number(determination["current_target"]).map { $0 < 40 ? $0 * GlucoseConversion.mmolToMgDl : $0 } + sample.carbRatio = number(determination["CR"]).flatMap { $0 > 0 ? $0 : nil } + sample.tdd = number(determination["TDD"]) ?? number((openAPS["enacted"] as? [String: Any])?["TDD"]) + addDeviceFields(from: record, to: &sample) + if let predBGs = determination["predBGs"] as? [String: Any] { + var curves: [String: [Any]] = [:] + for name in DeviceStatusHistorySample.Forecast.openAPSCurves { + if let values = predBGs[name] as? [Any] { curves[name] = values } + } + sample.forecast = forecast(start: date, curves: curves) + } + return (sample, createdAt ?? date) + } + + return nil + } + + private static func addDeviceFields(from record: [String: Any], to sample: inout DeviceStatusHistorySample) { + if let pump = record["pump"] as? [String: Any] { + sample.reservoir = number(pump["reservoir"]) + sample.pumpBattery = number((pump["battery"] as? [String: Any])?["percent"]) + } + sample.uploaderBattery = number((record["uploader"] as? [String: Any])?["battery"]) + } + + /// `openaps.iob` is a dictionary on Trio/AAPS and an array of projections on + /// oref0 rigs, where the first element is the current value. + private static func openAPSIOB(_ value: Any?) -> Double? { + if let dict = value as? [String: Any] { return number(dict["iob"]) } + if let array = value as? [[String: Any]] { return array.first.flatMap { number($0["iob"]) } } + return nil + } + + private static func forecast(start: Date, curves raw: [String: [Any]]) -> DeviceStatusHistorySample.Forecast? { + var curves: [String: [Int]] = [:] + for (name, values) in raw { + let ints = values.prefix(DeviceStatusHistorySample.Forecast.maxValues).compactMap { value in + number(value).map { Int(min(max($0, 0), 10000).rounded()) } + } + if !ints.isEmpty { curves[name] = ints } + } + guard !curves.isEmpty else { return nil } + return DeviceStatusHistorySample.Forecast(start: start.timeIntervalSince1970, curves: curves) + } + + private static func number(_ value: Any?) -> Double? { + switch value { + case let n as NSNumber: + // JSONSerialization decodes true/false as NSNumber too. + guard CFGetTypeID(n) != CFBooleanGetTypeID() else { return nil } + let d = n.doubleValue + return d.isFinite ? d : nil + case let s as String: + return Double(s) + default: + return nil + } + } + + private static func reasonValue(_ key: String, in reason: String?) -> Double? { + guard let reason, + let range = reason.range(of: "\\b\(key): (-?\\d+(?:\\.\\d+)?)", options: .regularExpression) + else { return nil } + return Double(reason[range].dropFirst(key.count + 2)) + } + + private static let fractionalFormatter: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter + }() + + private static let wholeSecondFormatter: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime] + return formatter + }() + + /// ISO 8601 with a `Z` or `±HH:MM` offset, with or without fractional seconds. + static func parseDate(_ string: String) -> Date? { + fractionalFormatter.date(from: string) ?? wholeSecondFormatter.date(from: string) + } + + static func formatDate(_ date: Date) -> String { + fractionalFormatter.string(from: date) + } +} + +extension DeviceStatusHistorySample { + init(date: TimeInterval) { + self.date = date + } +} diff --git a/LoopFollow/Controllers/Nightscout/DeviceStatusHistoryState.swift b/LoopFollow/Controllers/Nightscout/DeviceStatusHistoryState.swift new file mode 100644 index 000000000..ed8a157f0 --- /dev/null +++ b/LoopFollow/Controllers/Nightscout/DeviceStatusHistoryState.swift @@ -0,0 +1,136 @@ +// LoopFollow +// DeviceStatusHistoryState.swift + +import Foundation + +/// The cached device status history and the span of Nightscout records it +/// covers. Pure value logic; DeviceStatusHistory owns I/O and networking. +struct DeviceStatusHistoryState: Codable, Equatable { + struct FetchRange: Equatable { + let from: TimeInterval + let to: TimeInterval + } + + /// Ascending by date, one sample per loop cycle. + private(set) var samples: [DeviceStatusHistorySample] = [] + /// Every record whose `created_at` falls in `coverageStart ... coverageEnd` + /// has been fetched. + private(set) var coverageStart: TimeInterval? + private(set) var coverageEnd: TimeInterval? + + /// A new record this close to the covered span extends it without a fetch: + /// loops upload every five minutes. + static let gapTolerance: TimeInterval = 6.5 * 60 + /// Fetched ranges re-read this much of the covered span so a record written + /// right at the edge is not missed. + static let overlap: TimeInterval = 60 + + var isEmpty: Bool { samples.isEmpty && coverageStart == nil } + + /// Inserts samples, replacing any existing sample from the same second. + mutating func merge(_ newSamples: [DeviceStatusHistorySample]) { + guard !newSamples.isEmpty else { return } + var byKey: [Int: DeviceStatusHistorySample] = [:] + byKey.reserveCapacity(samples.count + newSamples.count) + for sample in samples { + byKey[Int(sample.date.rounded())] = sample + } + for sample in newSamples { + byKey[Int(sample.date.rounded())] = sample + } + samples = byKey.values.sorted { $0.date < $1.date } + } + + /// Records that `from ... to` has been fetched. A range that neither + /// overlaps nor touches the covered span replaces it. + mutating func markCovered(from: TimeInterval, to: TimeInterval) { + guard to >= from else { return } + if let start = coverageStart, let end = coverageEnd, + from <= end + Self.gapTolerance, to >= start - Self.gapTolerance + { + coverageStart = min(start, from) + coverageEnd = max(end, to) + } else { + coverageStart = from + coverageEnd = to + } + } + + /// Adds the record the regular device status poll just read, when it + /// continues the covered span. Returns false when there is a gap to fetch. + mutating func appendLatest(_ sample: DeviceStatusHistorySample, createdAt: TimeInterval) -> Bool { + guard let start = coverageStart, let end = coverageEnd, + createdAt >= start, createdAt <= end + Self.gapTolerance + else { return false } + merge([sample]) + coverageEnd = max(end, createdAt) + return true + } + + /// Drops samples before `cutoff` and shrinks the covered span to match. + mutating func prune(before cutoff: TimeInterval) { + if let firstKept = samples.firstIndex(where: { $0.date >= cutoff }) { + if firstKept > 0 { samples.removeFirst(firstKept) } + } else { + samples.removeAll() + } + if let end = coverageEnd, end < cutoff { + coverageStart = nil + coverageEnd = nil + } else if let start = coverageStart, start < cutoff { + coverageStart = cutoff + } + } + + /// Ranges still to fetch so the history spans `windowStart` up to + /// `latest`, the newest record known to exist. + func missingRanges(windowStart: TimeInterval, latest: TimeInterval, now: TimeInterval) -> [FetchRange] { + guard let start = coverageStart, let end = coverageEnd, end >= windowStart else { + return [FetchRange(from: windowStart, to: now)] + } + var ranges: [FetchRange] = [] + if latest > end + Self.gapTolerance { + // An uploader clock ahead of the phone can stamp records after `now`. + ranges.append(FetchRange(from: end - Self.overlap, to: max(now, latest))) + } + if windowStart < start - Self.gapTolerance { + ranges.append(FetchRange(from: windowStart, to: start + Self.overlap)) + } + return ranges + } + + /// The sample of the loop cycle nearest `date`, within `tolerance`. + func sample(near date: TimeInterval, tolerance: TimeInterval) -> DeviceStatusHistorySample? { + guard let index = nearestIndex(to: date) else { return nil } + let sample = samples[index] + return abs(sample.date - date) <= tolerance ? sample : nil + } + + /// The loop cycle that ran on a BG reading at `readingDate`: the first cycle + /// at or after the reading (allowing a little clock skew), within one cycle. + func cycle(forReadingAt readingDate: TimeInterval) -> DeviceStatusHistorySample? { + let earliest = readingDate - 60 + let latest = readingDate + Self.gapTolerance + var lo = 0 + var hi = samples.count + while lo < hi { + let mid = (lo + hi) / 2 + if samples[mid].date < earliest { lo = mid + 1 } else { hi = mid } + } + guard lo < samples.count, samples[lo].date <= latest else { return nil } + return samples[lo] + } + + private func nearestIndex(to date: TimeInterval) -> Int? { + guard !samples.isEmpty else { return nil } + var lo = 0 + var hi = samples.count + while lo < hi { + let mid = (lo + hi) / 2 + if samples[mid].date < date { lo = mid + 1 } else { hi = mid } + } + if lo == samples.count { return samples.count - 1 } + if lo == 0 { return 0 } + return date - samples[lo - 1].date <= samples[lo].date - date ? lo - 1 : lo + } +} diff --git a/LoopFollow/Controllers/Nightscout/DeviceStatusLoop.swift b/LoopFollow/Controllers/Nightscout/DeviceStatusLoop.swift index bd5699726..9333fad4a 100644 --- a/LoopFollow/Controllers/Nightscout/DeviceStatusLoop.swift +++ b/LoopFollow/Controllers/Nightscout/DeviceStatusLoop.swift @@ -8,6 +8,7 @@ import SwiftUI extension MainViewController { func DeviceStatusLoop(formatter: ISO8601DateFormatter, lastLoopRecord: [String: AnyObject]) { Storage.shared.device.value = "Loop" + Storage.shared.loopRemoteCommands.value = (lastLoopRecord["testingDetails"] as? [String: AnyObject])?["remoteCommands"] as? [String] ?? [] if Storage.shared.remoteType.value == .trc { Storage.shared.remoteType.value = .none @@ -16,7 +17,22 @@ extension MainViewController { let previousLastLoopTime = Observable.shared.previousAlertLastLoopTime.value ?? 0 let lastLoopTime = Observable.shared.alertLastLoopTime.value ?? 0 - if lastLoopRecord["failureReason"] != nil { + // Loop reports IOB and COB even when the loop failed. + if let insulinMetric = InsulinMetric(from: lastLoopRecord["iob"], key: "iob") { + infoManager.updateInfoData(type: .iob, value: insulinMetric) + latestIOB = insulinMetric + Observable.shared.iobText.value = insulinMetric.formattedValue() + } + + if let cobMetric = CarbMetric(from: lastLoopRecord["cob"], key: "cob") { + infoManager.updateInfoData(type: .cob, value: cobMetric) + latestCOB = cobMetric + } + + Storage.shared.lastIOB.value = latestIOB?.value + Storage.shared.lastCOB.value = latestCOB?.value + + if let failureReason = lastLoopRecord["failureReason"], !(failureReason is NSNull) { Observable.shared.loopStatusText.value = "X" latestLoopStatusString = "X" } else { @@ -51,24 +67,15 @@ extension MainViewController { Storage.shared.lastTargetLowMgdl.value = profileTargetLow?.doubleValue(for: .milligramsPerDeciliter) Storage.shared.lastTargetHighMgdl.value = profileTargetHigh?.doubleValue(for: .milligramsPerDeciliter) - // IOB - if let insulinMetric = InsulinMetric(from: lastLoopRecord["iob"], key: "iob") { - infoManager.updateInfoData(type: .iob, value: insulinMetric) - latestIOB = insulinMetric - Observable.shared.iobText.value = insulinMetric.formattedValue() - } - - // COB - if let cobMetric = CarbMetric(from: lastLoopRecord["cob"], key: "cob") { - infoManager.updateInfoData(type: .cob, value: cobMetric) - latestCOB = cobMetric - } - - if let predictdata = lastLoopRecord["predicted"] as? [String: AnyObject] { - let prediction = predictdata["values"] as! [Double] - Observable.shared.predictionText.value = Localizer.toDisplayUnits(String(Int(round(prediction.last!)))) + if let predictdata = lastLoopRecord["predicted"] as? [String: AnyObject], + let prediction = predictdata["values"] as? [Double], + let lastPrediction = prediction.last + { + Observable.shared.predictionText.value = Localizer.toDisplayUnits(String(Int(round(lastPrediction)))) Observable.shared.predictionColor.value = .purple - if Storage.shared.downloadPrediction.value, previousLastLoopTime < lastLoopTime || predictionData.isEmpty { + loopPredBGs = prediction + loopPredUpdatedTime = lastLoopTime + if previousLastLoopTime < lastLoopTime || predictionData.isEmpty { predictionData.removeAll() var predictionTime = lastLoopTime let toLoad = Int(Storage.shared.predictionToLoad.value * 12) @@ -96,6 +103,8 @@ extension MainViewController { updatePredictionGraph() } } else { + loopPredBGs = nil + loopPredUpdatedTime = nil predictionData.removeAll() infoManager.clearInfoData(type: .minMax) updatePredictionGraph() @@ -103,12 +112,21 @@ extension MainViewController { if let recBolus = lastLoopRecord["recommendedBolus"] as? Double { infoManager.updateInfoData(type: .recBolus, value: InsulinFormatter.shared.string(recBolus), numericValue: recBolus) Observable.shared.deviceRecBolus.value = recBolus + let cycleTime = (lastLoopRecord["timestamp"] as? String).flatMap { formatter.date(from: $0)?.timeIntervalSince1970 } + Observable.shared.deviceRecBolusAt.value = cycleTime ?? lastLoopTime } else { infoManager.clearInfoData(type: .recBolus) Observable.shared.deviceRecBolus.value = nil + Observable.shared.deviceRecBolusAt.value = nil } - if let loopStatus = lastLoopRecord["recommendedTempBasal"] as? [String: AnyObject] { - if let tempBasalTime = formatter.date(from: (loopStatus["timestamp"] as! String))?.timeIntervalSince1970 { + // Loop uploads its Closed Loop toggle as loopSettings.dosingEnabled in the profile + if profileManager.loopDosingEnabled == false { + Observable.shared.loopStatusText.value = "⏀" + latestLoopStatusString = "⏀" + } else if let loopStatus = lastLoopRecord["recommendedTempBasal"] as? [String: AnyObject] { + if let tempBasalString = loopStatus["timestamp"] as? String, + let tempBasalTime = formatter.date(from: tempBasalString)?.timeIntervalSince1970 + { var lastBGTime = lastLoopTime if bgData.count > 0 { lastBGTime = bgData[bgData.count - 1].date @@ -127,8 +145,6 @@ extension MainViewController { } // Live Activity storage - Storage.shared.lastIOB.value = latestIOB?.value - Storage.shared.lastCOB.value = latestCOB?.value if let predictdata = lastLoopRecord["predicted"] as? [String: AnyObject], let values = predictdata["values"] as? [Double] { diff --git a/LoopFollow/Controllers/Nightscout/DeviceStatusOpenAPS.swift b/LoopFollow/Controllers/Nightscout/DeviceStatusOpenAPS.swift index 7dfdb4cdb..f2885952d 100644 --- a/LoopFollow/Controllers/Nightscout/DeviceStatusOpenAPS.swift +++ b/LoopFollow/Controllers/Nightscout/DeviceStatusOpenAPS.swift @@ -7,7 +7,7 @@ import HealthKit extension MainViewController { func DeviceStatusOpenAPS(formatter: ISO8601DateFormatter, lastDeviceStatus: [String: AnyObject]?, lastLoopRecord: [String: AnyObject]) { Storage.shared.device.value = lastDeviceStatus?["device"] as? String ?? "" - if lastLoopRecord["failureReason"] != nil { + if let failureReason = lastLoopRecord["failureReason"], !(failureReason is NSNull) { Observable.shared.loopStatusText.value = "X" latestLoopStatusString = "X" } else { @@ -72,7 +72,9 @@ extension MainViewController { } // IOB - if let iobMetric = InsulinMetric(from: lastLoopRecord["iob"], key: "iob") { + if let iobMetric = InsulinMetric(from: lastLoopRecord["iob"], key: "iob") + ?? InsulinMetric(from: enactedOrSuggested, key: "IOB") + { infoManager.updateInfoData(type: .iob, value: iobMetric) latestIOB = iobMetric Observable.shared.iobText.value = iobMetric.formattedValue() @@ -105,6 +107,11 @@ extension MainViewController { } } + // Smoothed BG: the glucose the algorithm ran on (smoothed in Trio) + if let smoothedBG = enactedOrSuggested["bg"] as? Double, smoothedBG > 0 { + infoManager.updateInfoData(type: .smoothedBG, value: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: smoothedBG)) + } + // Autosens if let sens = enactedOrSuggested["sensitivityRatio"] as? Double { let formattedSens = String(format: "%.0f", sens * 100.0) + "%" @@ -116,9 +123,11 @@ extension MainViewController { if let rec = lastLoopRecord["recommendedBolus"] as? Double { infoManager.updateInfoData(type: .recBolus, value: InsulinFormatter.shared.string(rec)) Observable.shared.deviceRecBolus.value = rec + Observable.shared.deviceRecBolusAt.value = updatedTime ?? Observable.shared.enactedOrSuggested.value } else { infoManager.clearInfoData(type: .recBolus) Observable.shared.deviceRecBolus.value = nil + Observable.shared.deviceRecBolusAt.value = nil } // Eventual BG @@ -219,8 +228,14 @@ extension MainViewController { openAPSPredUpdatedTime = nil } - if let loopStatus = lastLoopRecord["recommendedTempBasal"] as? [String: AnyObject] { - if let tempBasalTime = formatter.date(from: (loopStatus["timestamp"] as! String))?.timeIntervalSince1970 { + // Trio uploads dosingMode "open" while Closed Loop is off + if lastLoopRecord["dosingMode"] as? String == "open" { + Observable.shared.loopStatusText.value = "⏀" + latestLoopStatusString = "⏀" + } else if let loopStatus = lastLoopRecord["recommendedTempBasal"] as? [String: AnyObject] { + if let tempBasalString = loopStatus["timestamp"] as? String, + let tempBasalTime = formatter.date(from: tempBasalString)?.timeIntervalSince1970 + { var lastBGTime = updatedTime ?? Date().timeIntervalSince1970 if bgData.count > 0 { lastBGTime = bgData[bgData.count - 1].date diff --git a/LoopFollow/Controllers/Nightscout/IAge.swift b/LoopFollow/Controllers/Nightscout/IAge.swift index 71909cc49..d1f28dac7 100644 --- a/LoopFollow/Controllers/Nightscout/IAge.swift +++ b/LoopFollow/Controllers/Nightscout/IAge.swift @@ -44,7 +44,7 @@ extension MainViewController { .withDashSeparatorInDate, .withColonSeparatorInTime] - if let iageTime = formatter.date(from: (lastIageString as! String))?.timeIntervalSince1970 { + if let iageTime = formatter.date(from: lastIageString)?.timeIntervalSince1970 { Storage.shared.iageInsertTime.value = iageTime let now = dateTimeUtils.getNowTimeIntervalUTC() let secondsAgo = now - iageTime diff --git a/LoopFollow/Controllers/Nightscout/NSProfile.swift b/LoopFollow/Controllers/Nightscout/NSProfile.swift index 669de7297..e4f5146cf 100644 --- a/LoopFollow/Controllers/Nightscout/NSProfile.swift +++ b/LoopFollow/Controllers/Nightscout/NSProfile.swift @@ -50,6 +50,7 @@ struct NSProfile: Decodable { let expirationDate: String? let startDate: String? let createdAt: String? + let remoteCommands: [String]? struct TrioOverrideEntry: Decodable { let name: String @@ -101,5 +102,6 @@ struct NSProfile: Decodable { case expirationDate case startDate case createdAt = "created_at" + case remoteCommands } } diff --git a/LoopFollow/Controllers/Nightscout/ProfileManager.swift b/LoopFollow/Controllers/Nightscout/ProfileManager.swift index 520afde88..5f53d9abb 100644 --- a/LoopFollow/Controllers/Nightscout/ProfileManager.swift +++ b/LoopFollow/Controllers/Nightscout/ProfileManager.swift @@ -18,6 +18,7 @@ final class ProfileManager { var targetHighSchedule: [TimeValue] var loopOverrides: [LoopOverride] var trioOverrides: [TrioOverride] + var loopDosingEnabled: Bool? var units: HKUnit var timezone: TimeZone var defaultProfile: String @@ -94,6 +95,8 @@ final class ProfileManager { loopOverrides = [] } + loopDosingEnabled = profileData.loopSettings?.dosingEnabled + if let trioOverrides = profileData.trioOverrides { self.trioOverrides = trioOverrides.map { entry in let targetQuantity = entry.target != nil ? HKQuantity(unit: .milligramsPerDeciliter, doubleValue: entry.target!) : nil @@ -122,41 +125,99 @@ final class ProfileManager { } Storage.shared.teamId.value = profileData.teamID ?? Storage.shared.teamId.value ?? "" + Storage.shared.remoteCommands.value = profileData.remoteCommands ?? [] + Observable.shared.loopProfiles.set(LoopProfileSet(profileData)) } func currentISF() -> HKQuantity? { - return getCurrentValue(from: isfSchedule) + isf(at: Date()) } func currentBasal() -> String? { - if let basal = getCurrentValue(from: basalSchedule) { - return Localizer.formatToLocalizedString(basal, maxFractionDigits: 2, minFractionDigits: 0) - } - return nil + basal(at: Date()) } func currentCarbRatio() -> Double? { - return getCurrentValue(from: carbRatioSchedule) + carbRatio(at: Date()) } func currentTargetLow() -> HKQuantity? { - return getCurrentValue(from: targetLowSchedule) + targetLow(at: Date()) } func currentTargetHigh() -> HKQuantity? { - return getCurrentValue(from: targetHighSchedule) + targetHigh(at: Date()) + } + + /// The loaded profile's scheduled values at the clock time of `date`. + func isf(at date: Date) -> HKQuantity? { + value(from: isfSchedule, at: date) + } + + func basal(at date: Date) -> String? { + basalRate(at: date).map { + Localizer.formatToLocalizedString($0, maxFractionDigits: 2, minFractionDigits: 0) + } + } + + func basalRate(at date: Date) -> Double? { + value(from: basalSchedule, at: date) + } + + /// Scheduled basal from `start` to `end`, one segment per schedule entry crossed. + func scheduledBasalSegments(from start: Date, to end: Date) -> [(start: Date, rate: Double)] { + guard start < end, let firstRate = basalRate(at: start) else { return [] } + + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = timezone + + var boundaries: [Date] = [] + var day = calendar.startOfDay(for: start) + while day < end { + for entry in basalSchedule { + guard let boundary = calendar.date(bySettingHour: entry.timeAsSeconds / 3600, + minute: entry.timeAsSeconds % 3600 / 60, + second: entry.timeAsSeconds % 60, + of: day) + else { continue } + if boundary > start, boundary < end { + boundaries.append(boundary) + } + } + guard let nextDay = calendar.date(byAdding: .day, value: 1, to: day) else { break } + day = nextDay + } + + var segments = [(start: start, rate: firstRate)] + for boundary in boundaries.sorted() { + if let rate = basalRate(at: boundary), rate != segments.last?.rate { + segments.append((start: boundary, rate: rate)) + } + } + return segments + } + + func carbRatio(at date: Date) -> Double? { + value(from: carbRatioSchedule, at: date) + } + + func targetLow(at date: Date) -> HKQuantity? { + value(from: targetLowSchedule, at: date) + } + + func targetHigh(at date: Date) -> HKQuantity? { + value(from: targetHighSchedule, at: date) } - private func getCurrentValue(from schedule: [TimeValue]) -> T? { + private func value(from schedule: [TimeValue], at date: Date) -> T? { guard !schedule.isEmpty else { return nil } - let now = Date() var calendar = Calendar.current calendar.timeZone = timezone - let currentTimeInSeconds = calendar.component(.hour, from: now) * 3600 + - calendar.component(.minute, from: now) * 60 + - calendar.component(.second, from: now) + let currentTimeInSeconds = calendar.component(.hour, from: date) * 3600 + + calendar.component(.minute, from: date) * 60 + + calendar.component(.second, from: date) var lastValue: T? for timeValue in schedule { @@ -207,5 +268,6 @@ final class ProfileManager { units = .millimolesPerLiter timezone = TimeZone.current defaultProfile = "" + Observable.shared.loopProfiles.set(nil) } } diff --git a/LoopFollow/Controllers/Nightscout/PumpReservoir.swift b/LoopFollow/Controllers/Nightscout/PumpReservoir.swift index ad843a296..b65ce50bd 100644 --- a/LoopFollow/Controllers/Nightscout/PumpReservoir.swift +++ b/LoopFollow/Controllers/Nightscout/PumpReservoir.swift @@ -14,6 +14,24 @@ struct PumpReservoirCache: Codable, Equatable { /// When this pump first appeared in a device status record LoopFollow fetched. let pumpSince: Date var reading: Reading? + /// Whether a volume in a record is this pump's own. Right after a pod change Loop + /// still uploads the previous pod's final volume. + var isSettled: Bool + + init(pumpID: String, pumpSince: Date, reading: Reading? = nil, isSettled: Bool = true) { + self.pumpID = pumpID + self.pumpSince = pumpSince + self.reading = reading + self.isSettled = isSettled + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + pumpID = try container.decode(String.self, forKey: .pumpID) + pumpSince = try container.decode(Date.self, forKey: .pumpSince) + reading = try container.decodeIfPresent(Reading.self, forKey: .reading) + isSettled = try container.decodeIfPresent(Bool.self, forKey: .isSettled) ?? true + } } /// What a device status record says about the reservoir. @@ -22,6 +40,8 @@ enum PumpReservoirState: Equatable { case units(Double) /// A pump that reports a volume only once it drops below 50U, and is above it. case aboveReportingLimit + /// The status the uploader shows in place of the volume, such as "No Pod". + case status(String) /// No volume to show. case unknown } @@ -31,8 +51,9 @@ enum PumpReservoirResolver { /// while the pod is below 50U. A reading carries across those gaps for this long. static let maxReadingAge: TimeInterval = 30 * 60 - /// A volume reported within this long of a pump first appearing can still be the - /// previous pod's final reading, so it says nothing about the pump now on. + /// Loop uploads its last reservoir reading until it is 15 minutes old, whichever pod it + /// came from, so the previous pod's final volume can appear for up to this long after a + /// pod change. A new pod reports no volume of its own until it drops below 50U. static let pumpSettleTime: TimeInterval = 15 * 60 struct Resolution: Equatable { @@ -41,59 +62,94 @@ enum PumpReservoirResolver { let cache: PumpReservoirCache? } + /// - Parameters: + /// - displayOverride: The status Loop shows in place of the reservoir, such as + /// "Finish Setup" or "No Pod", in the uploader's language. + /// - date: When the record was made, from the pump clock it carries. static func resolve( + reservoir: Double?, + pumpID: String?, + manufacturer: String?, + model: String?, + displayOverride: String? = nil, + cache storedCache: PumpReservoirCache?, + date: Date + ) -> Resolution { + let resolution = resolveVolume( + reservoir: reservoir, + pumpID: pumpID, + manufacturer: manufacturer, + model: model, + cache: storedCache, + date: date + ) + // The pump keeps being tracked while its status replaces the volume. + if let status = displayOverride?.trimmingCharacters(in: .whitespacesAndNewlines), !status.isEmpty { + return Resolution(state: .status(status), cache: resolution.cache) + } + return resolution + } + + private static func resolveVolume( reservoir: Double?, pumpID: String?, manufacturer: String?, model: String?, cache storedCache: PumpReservoirCache?, - now: Date + date: Date ) -> Resolution { - let withoutReading: PumpReservoirState = reportsVolumeOnlyWhenLow(manufacturer: manufacturer, model: model) - ? .aboveReportingLimit - : .unknown + let onlyWhenLow = reportsVolumeOnlyWhenLow(manufacturer: manufacturer, model: model) + let withoutReading: PumpReservoirState = onlyWhenLow ? .aboveReportingLimit : .unknown + + let pumpID = pumpID?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + + // Loop reports an Omnipod as "Unknown" while no pod is paired, so there is no volume + // to show and one in the record is the previous pod's. Medtrum is always "Unknown" + // on Loop. + if pumpID == "Unknown", onlyWhenLow { + return Resolution(state: .unknown, cache: storedCache) + } // A reading is carried between records only when the uploader names the pump it - // came from, so that a pod change discards it. - guard let pumpID = identifiedPump(pumpID) else { - guard let reservoir else { return Resolution(state: withoutReading, cache: nil) } - return Resolution(state: .units(reservoir), cache: nil) + // came from, so that a pod change discards it. The stored pump is kept across + // records that name none, so the next one reads as a change. + guard !pumpID.isEmpty, pumpID != "Unknown" else { + guard let reservoir else { return Resolution(state: withoutReading, cache: storedCache) } + return Resolution(state: .units(reservoir), cache: storedCache) } - var cache = storedCache?.pumpID == pumpID - ? storedCache! - : PumpReservoirCache(pumpID: pumpID, pumpSince: now, reading: nil) + var cache: PumpReservoirCache + if let storedCache, storedCache.pumpID == pumpID { + cache = storedCache + } else { + // With no pump stored there is no change to have seen, so the pump is taken + // as one already in use. + cache = PumpReservoirCache(pumpID: pumpID, pumpSince: date, isSettled: storedCache == nil || !onlyWhenLow) + } + + if !cache.isSettled, reservoir == nil || date.timeIntervalSince(cache.pumpSince) >= pumpSettleTime { + cache.isSettled = true + } if let reservoir { - cache.reading = PumpReservoirCache.Reading(units: reservoir, date: now) + guard cache.isSettled else { return Resolution(state: withoutReading, cache: cache) } + cache.reading = PumpReservoirCache.Reading(units: reservoir, date: date) return Resolution(state: .units(reservoir), cache: cache) } if let reading = cache.reading { - let age = now.timeIntervalSince(reading.date) + let age = date.timeIntervalSince(reading.date) if age >= 0, age <= maxReadingAge { return Resolution(state: .units(reading.units), cache: cache) } - if reading.date.timeIntervalSince(cache.pumpSince) >= pumpSettleTime { - // The pump has reported a volume long enough after coming online for that - // to be its own, so it is below its reporting limit and the only thing - // missing is a fresh number. A pump first seen mid-pod has no such gap, - // so its first reading is treated as a pod change's and dropped below. - return Resolution(state: .unknown, cache: cache) - } - cache.reading = nil + // The pump has reported a volume of its own, so it is below its reporting limit + // and the only thing missing is a fresh number. + return Resolution(state: .unknown, cache: cache) } return Resolution(state: withoutReading, cache: cache) } - private static func identifiedPump(_ pumpID: String?) -> String? { - let trimmed = pumpID?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - // Loop reports "Unknown" while no pod is paired. - guard !trimmed.isEmpty, trimmed != "Unknown" else { return nil } - return trimmed - } - private static func reportsVolumeOnlyWhenLow(manufacturer: String?, model: String?) -> Bool { let pump = [manufacturer, model].compactMap { $0 }.joined(separator: " ").lowercased() // An uploader that names no pump cannot be told apart from one that reports its diff --git a/LoopFollow/Controllers/Nightscout/SAge.swift b/LoopFollow/Controllers/Nightscout/SAge.swift index 580d39f1a..596974e12 100644 --- a/LoopFollow/Controllers/Nightscout/SAge.swift +++ b/LoopFollow/Controllers/Nightscout/SAge.swift @@ -36,14 +36,18 @@ extension MainViewController { return } currentSage = data[0] - var lastSageString = data[0].created_at + let lastSageString = data[0].created_at let formatter = ISO8601DateFormatter() formatter.formatOptions = [.withFullDate, .withTime, .withDashSeparatorInDate, .withColonSeparatorInTime] - Storage.shared.sageInsertTime.value = formatter.date(from: lastSageString)?.timeIntervalSince1970 as! TimeInterval + guard let sageInsertTime = formatter.date(from: lastSageString)?.timeIntervalSince1970 else { + LogManager.shared.log(category: .nightscout, message: "updateSage: unparseable created_at '\(lastSageString)'") + return + } + Storage.shared.sageInsertTime.value = sageInsertTime // -- Auto-snooze CGM start ──────────────────────────────────────────────── let now = Date() @@ -62,7 +66,7 @@ extension MainViewController { } } - if let sageTime = formatter.date(from: (lastSageString as! String))?.timeIntervalSince1970 { + if let sageTime = formatter.date(from: lastSageString)?.timeIntervalSince1970 { let now = dateTimeUtils.getNowTimeIntervalUTC() let secondsAgo = now - sageTime diff --git a/LoopFollow/Controllers/Nightscout/Treatments.swift b/LoopFollow/Controllers/Nightscout/Treatments.swift index 14e9e391b..969039ab6 100644 --- a/LoopFollow/Controllers/Nightscout/Treatments.swift +++ b/LoopFollow/Controllers/Nightscout/Treatments.swift @@ -138,11 +138,22 @@ extension MainViewController { } if tempBasal.count > 0 { - processNSBasals(entries: tempBasal) + let suspensions = PumpSuspension.intervals( + notes: note.compactMap { entry in + guard let date = NightscoutUtils.treatmentDate(from: entry), let text = entry["notes"] as? String else { return nil } + return (date, text) + }, + tempBasalStarts: tempBasal.compactMap { NightscoutUtils.treatmentDate(from: $0) } + ) + processNSBasals(entries: tempBasal, suspensions: suspensions) } else { if basalData.count > 0 { clearOldTempBasal() } + if let profileBasal = profileManager.currentBasal() { + infoManager.updateInfoData(type: .basal, value: profileBasal) + Storage.shared.lastBasal.value = profileBasal + } } if bolus.count > 0 { processNSBolus(entries: bolus) @@ -214,7 +225,7 @@ extension MainViewController { if note.count > 0 { processNotes(entries: note) } else { - if noteGraphData.count > 0 { + if noteGraphData.count > 0 || cgmSensorStates.count > 0 { clearOldNotes() } } diff --git a/LoopFollow/Controllers/Nightscout/Treatments/Basals.swift b/LoopFollow/Controllers/Nightscout/Treatments/Basals.swift index 405281926..7846de081 100644 --- a/LoopFollow/Controllers/Nightscout/Treatments/Basals.swift +++ b/LoopFollow/Controllers/Nightscout/Treatments/Basals.swift @@ -5,7 +5,7 @@ import Foundation extension MainViewController { // NS Temp Basal Response Processor - func processNSBasals(entries: [[String: AnyObject]]) { + func processNSBasals(entries: [[String: AnyObject]], suspensions: [DateInterval] = []) { infoManager.clearInfoData(type: .basal) basalData.removeAll() @@ -114,27 +114,31 @@ extension MainViewController { lastEndDot = lastDot } - // If last basal was prior to right now, we need to create one last scheduled entry + // If last basal was prior to right now, fill up to now with the scheduled basal if lastEndDot <= dateTimeUtils.getNowTimeIntervalUTC() { - var scheduled = 0.0 - for b in 0 ..< basalProfile.count { - let scheduleTimeToday = basalProfile[b].timeAsSeconds - + dateTimeUtils.getTimeIntervalMidnightToday() - if lastEndDot >= scheduleTimeToday { - scheduled = basalProfile[b].value - } - } + let now = Date() + let tailStart = Date(timeIntervalSince1970: max(lastEndDot, dateTimeUtils.getTimeIntervalNHoursAgo(N: 24 * Storage.shared.downloadDays.value))) + let tailEnd = now.addingTimeInterval(60 * 10) - latestBasal = Localizer.formatToLocalizedString(scheduled, + latestBasal = Localizer.formatToLocalizedString(profileManager.basalRate(at: now) ?? 0.0, maxFractionDigits: 2, minFractionDigits: 0) - let startDot = basalGraphStruct(basalRate: scheduled, date: lastEndDot) - basalData.append(startDot) + var segments = profileManager.scheduledBasalSegments(from: tailStart, to: tailEnd) + if segments.isEmpty { + segments = [(start: tailStart, rate: 0.0)] + } - let endDot = basalGraphStruct(basalRate: scheduled, - date: Date().timeIntervalSince1970 + (60 * 10)) - basalData.append(endDot) + for (index, segment) in segments.enumerated() { + let segmentEnd = index < segments.count - 1 ? segments[index + 1].start : tailEnd + basalData.append(basalGraphStruct(basalRate: segment.rate, date: segment.start.timeIntervalSince1970)) + basalData.append(basalGraphStruct(basalRate: segment.rate, date: segmentEnd.timeIntervalSince1970)) + } + } + + basalData = PumpSuspension.applying(suspensions, to: basalData) + if suspensions.contains(where: { $0.contains(Date()) }) { + latestBasal = Localizer.formatToLocalizedString(0.0, maxFractionDigits: 2, minFractionDigits: 0) } if Storage.shared.graphBasal.value { diff --git a/LoopFollow/Controllers/Nightscout/Treatments/Bolus.swift b/LoopFollow/Controllers/Nightscout/Treatments/Bolus.swift index 58314a4d9..1c8a4c160 100644 --- a/LoopFollow/Controllers/Nightscout/Treatments/Bolus.swift +++ b/LoopFollow/Controllers/Nightscout/Treatments/Bolus.swift @@ -11,16 +11,7 @@ extension MainViewController { var lastFoundIndex = 0 for currentEntry in entries.reversed() { - var bolusDate: String - if currentEntry["timestamp"] != nil { - bolusDate = currentEntry["timestamp"] as! String - } else if currentEntry["created_at"] != nil { - bolusDate = currentEntry["created_at"] as! String - } else { - continue - } - - guard let parsedDate = NightscoutUtils.parseDate(bolusDate), + guard let parsedDate = NightscoutUtils.treatmentDate(from: currentEntry), let bolus = currentEntry["insulin"] as? Double else { continue } let dateTimeStamp = parsedDate.timeIntervalSince1970 @@ -29,7 +20,8 @@ extension MainViewController { if dateTimeStamp < (dateTimeUtils.getNowTimeIntervalUTC() + (60 * 60)) { // Make the dot - let dot = bolusGraphStruct(value: bolus, date: Double(dateTimeStamp), sgv: Int(sgv.sgv + 20)) + let detail = Treatment.historyEntries(from: currentEntry, date: dateTimeStamp) { Int(sgv.sgv) }.bolus + let dot = bolusGraphStruct(value: bolus, date: Double(dateTimeStamp), sgv: Int(sgv.sgv + 20), detail: detail) bolusData.append(dot) } } diff --git a/LoopFollow/Controllers/Nightscout/Treatments/Carbs.swift b/LoopFollow/Controllers/Nightscout/Treatments/Carbs.swift index 23e2e0427..43a1b2b9b 100644 --- a/LoopFollow/Controllers/Nightscout/Treatments/Carbs.swift +++ b/LoopFollow/Controllers/Nightscout/Treatments/Carbs.swift @@ -13,18 +13,9 @@ extension MainViewController { var lastFoundSmb = 0 for currentEntry in entries.reversed() { - var carbDate: String - if currentEntry["timestamp"] != nil { - carbDate = currentEntry["timestamp"] as! String - } else if currentEntry["created_at"] != nil { - carbDate = currentEntry["created_at"] as! String - } else { - continue - } - let absorptionTime = currentEntry["absorptionTime"] as? Int ?? 0 - guard let parsedDate = NightscoutUtils.parseDate(carbDate), + guard let parsedDate = NightscoutUtils.treatmentDate(from: currentEntry), let carbs = currentEntry["carbs"] as? Double else { continue } let dateTimeStamp = parsedDate.timeIntervalSince1970 @@ -44,7 +35,13 @@ extension MainViewController { if dateTimeStamp < (dateTimeUtils.getNowTimeIntervalUTC() + (3600 * Storage.shared.predictionToLoad.value)) { // Make the dot - let dot = carbGraphStruct(value: Double(carbs), date: Double(dateTimeStamp), sgv: Int(sgv.sgv + Double(offset)), absorptionTime: absorptionTime) + let dot = carbGraphStruct( + value: Double(carbs), + date: Double(dateTimeStamp), + sgv: Int(sgv.sgv + Double(offset)), + absorptionTime: absorptionTime, + detail: Treatment.historyEntries(from: currentEntry, date: dateTimeStamp) { Int(sgv.sgv) }.carb + ) carbData.append(dot) } } diff --git a/LoopFollow/Controllers/Nightscout/Treatments/Notes.swift b/LoopFollow/Controllers/Nightscout/Treatments/Notes.swift index cc4435212..93d9878bd 100644 --- a/LoopFollow/Controllers/Nightscout/Treatments/Notes.swift +++ b/LoopFollow/Controllers/Nightscout/Treatments/Notes.swift @@ -8,31 +8,29 @@ extension MainViewController { func processNotes(entries: [[String: AnyObject]]) { // because it's a small array, we're going to destroy and reload every time. noteGraphData.removeAll() + cgmSensorStates.removeAll() var lastFoundIndex = 0 for currentEntry in entries.reversed() { guard let currentEntry = currentEntry as? [String: AnyObject] else { continue } - var date: String - if currentEntry["timestamp"] != nil { - date = currentEntry["timestamp"] as! String - } else if currentEntry["created_at"] != nil { - date = currentEntry["created_at"] as! String - } else { - continue - } - - if let parsedDate = NightscoutUtils.parseDate(date) { + if let parsedDate = NightscoutUtils.treatmentDate(from: currentEntry) { let dateTimeStamp = parsedDate.timeIntervalSince1970 - let sgv = findNearestBGbyTime(needle: dateTimeStamp, haystack: bgData, startingIndex: lastFoundIndex) - lastFoundIndex = sgv.foundIndex - guard let thisNote = currentEntry["notes"] as? String else { continue } + guard let thisNote = currentEntry["notes"] as? String, + dateTimeStamp < (dateTimeUtils.getNowTimeIntervalUTC() + (60 * 60)) + else { continue } - if dateTimeStamp < (dateTimeUtils.getNowTimeIntervalUTC() + (60 * 60)) { - let dot = DataStructs.noteStruct(date: Double(dateTimeStamp), sgv: Int(sgv.sgv), note: thisNote) - noteGraphData.append(dot) + if let state = CGMSensorState(note: thisNote, date: parsedDate) { + cgmSensorStates.append(state) + continue } + + let sgv = findNearestBGbyTime(needle: dateTimeStamp, haystack: bgData, startingIndex: lastFoundIndex) + lastFoundIndex = sgv.foundIndex + + let dot = DataStructs.noteStruct(date: Double(dateTimeStamp), sgv: Int(sgv.sgv), note: thisNote) + noteGraphData.append(dot) } else { print("Failed to parse date") } @@ -41,5 +39,23 @@ extension MainViewController { if Storage.shared.graphOtherTreatments.value { updateNotes() } + updateCGMSensorState() + } + + /// Publishes the CGM state reported after the newest reading, if any. + func updateCGMSensorState() { + let latestBGDate = bgData.last.map { Date(timeIntervalSince1970: $0.date) } + let active = CGMSensorState.active(in: cgmSensorStates, latestBGDate: latestBGDate) + guard active != Observable.shared.cgmSensorState.value else { return } + + Observable.shared.cgmSensorState.value = active + if let active { + LogManager.shared.log(category: .nightscout, message: "CGM sensor state: \(active.name) at \(active.date)") + } else { + LogManager.shared.log(category: .nightscout, message: "CGM sensor state cleared") + } + #if !targetEnvironment(macCatalyst) + LiveActivityManager.shared.refreshFromCurrentState(reason: "cgmSensorState") + #endif } } diff --git a/LoopFollow/Controllers/Nightscout/Treatments/Overrides.swift b/LoopFollow/Controllers/Nightscout/Treatments/Overrides.swift index 00dd745bd..3a56a641c 100644 --- a/LoopFollow/Controllers/Nightscout/Treatments/Overrides.swift +++ b/LoopFollow/Controllers/Nightscout/Treatments/Overrides.swift @@ -51,8 +51,9 @@ extension MainViewController { var end: TimeInterval = isIndefinite ? maxEndDate : start + durationSeconds - // True end for countdown display: based on the raw start and never - // clamped to the graph edge; nil while indefinite. + // True end for countdown display and the end alarm's early + // warning: based on the raw start and never clamped to the graph + // edge; nil while indefinite. var trueEnd: TimeInterval? = isIndefinite ? nil : startDate.timeIntervalSince1970 + durationSeconds if let nextStart = nextStart { @@ -84,7 +85,9 @@ extension MainViewController { reason: (e["notes"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? (e["reason"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "", - sgv: -20 + sgv: -20, + scheduledEndDate: trueEnd, + detail: Treatment.historyEntries(from: e, date: startDate.timeIntervalSince1970) { 0 }.entries.first ) overrideGraphData.append(dot) diff --git a/LoopFollow/Controllers/Nightscout/Treatments/PumpSuspension.swift b/LoopFollow/Controllers/Nightscout/Treatments/PumpSuspension.swift new file mode 100644 index 000000000..b31789773 --- /dev/null +++ b/LoopFollow/Controllers/Nightscout/Treatments/PumpSuspension.swift @@ -0,0 +1,86 @@ +// LoopFollow +// PumpSuspension.swift + +import Foundation + +/// Periods when the pump delivers no basal, from Trio's `PumpSuspend` and `PumpResume` notes. +enum PumpSuspension { + static let suspendNote = "PumpSuspend" + static let resumeNote = "PumpResume" + + /// Pairs each suspend with the next resume. A suspend without a resume lasts until the next + /// temp basal starts, as Trio sets no temp basal while the pump is suspended. Without either it + /// is still ongoing and ends at `.distantFuture`. + static func intervals(notes: [(date: Date, note: String)], tempBasalStarts: [Date]) -> [DateInterval] { + let events = notes + .filter { $0.note == suspendNote || $0.note == resumeNote } + .sorted { $0.date < $1.date } + let tempStarts = tempBasalStarts.sorted() + + var result: [DateInterval] = [] + var suspendStart: Date? + + func close(at resume: Date?) { + guard let start = suspendStart else { return } + let nextTemp = tempStarts.first { $0 > start } + let end = [resume, nextTemp].compactMap { $0 }.min() ?? .distantFuture + if end > start { + result.append(DateInterval(start: start, end: end)) + } + suspendStart = nil + } + + for event in events { + if event.note == suspendNote { + if let start = suspendStart, let nextTemp = tempStarts.first(where: { $0 > start }), nextTemp <= event.date { + close(at: nil) + } + if suspendStart == nil { + suspendStart = event.date + } + } else { + close(at: event.date) + } + } + close(at: nil) + return result + } + + /// The basal steps with the rate set to 0 inside the intervals. Each point's rate holds until + /// the next point, as the chart draws them. + static func applying(_ intervals: [DateInterval], to basal: [MainViewController.basalGraphStruct]) -> [MainViewController.basalGraphStruct] { + guard !intervals.isEmpty else { return basal } + let points = basal.sorted { $0.date < $1.date } + let suspensions = intervals.sorted { $0.start < $1.start } + var result: [MainViewController.basalGraphStruct] = [] + + for (index, point) in points.enumerated() where index + 1 < points.count { + let end = points[index + 1].date + guard end > point.date else { continue } + + var cursor = point.date + for suspension in suspensions { + let suspendStart = max(suspension.start.timeIntervalSince1970, cursor) + let suspendEnd = min(suspension.end.timeIntervalSince1970, end) + guard suspendStart < suspendEnd else { continue } + if cursor < suspendStart { + result.append(.init(basalRate: point.basalRate, date: cursor)) + result.append(.init(basalRate: point.basalRate, date: suspendStart)) + } + result.append(.init(basalRate: 0, date: suspendStart)) + result.append(.init(basalRate: 0, date: suspendEnd)) + cursor = suspendEnd + } + if cursor < end { + result.append(.init(basalRate: point.basalRate, date: cursor)) + result.append(.init(basalRate: point.basalRate, date: end)) + } + } + if let last = points.last { + let lastDate = Date(timeIntervalSince1970: last.date) + let suspended = suspensions.contains { $0.start <= lastDate && lastDate < $0.end } + result.append(.init(basalRate: suspended ? 0 : last.basalRate, date: last.date)) + } + return result + } +} diff --git a/LoopFollow/Controllers/Nightscout/Treatments/SMB.swift b/LoopFollow/Controllers/Nightscout/Treatments/SMB.swift index f896e4941..466503e87 100644 --- a/LoopFollow/Controllers/Nightscout/Treatments/SMB.swift +++ b/LoopFollow/Controllers/Nightscout/Treatments/SMB.swift @@ -10,16 +10,7 @@ extension MainViewController { var lastFoundIndex = 0 for currentEntry in entries.reversed() { - var bolusDate: String - if currentEntry["timestamp"] != nil { - bolusDate = currentEntry["timestamp"] as! String - } else if currentEntry["created_at"] != nil { - bolusDate = currentEntry["created_at"] as! String - } else { - continue - } - - guard let parsedDate = NightscoutUtils.parseDate(bolusDate), + guard let parsedDate = NightscoutUtils.treatmentDate(from: currentEntry), let bolus = currentEntry["insulin"] as? Double else { continue } let dateTimeStamp = parsedDate.timeIntervalSince1970 @@ -28,7 +19,8 @@ extension MainViewController { if dateTimeStamp < (dateTimeUtils.getNowTimeIntervalUTC() + (60 * 60)) { // Make the dot - let dot = bolusGraphStruct(value: bolus, date: Double(dateTimeStamp), sgv: Int(sgv.sgv + 20)) + let detail = Treatment.historyEntries(from: currentEntry, date: dateTimeStamp) { Int(sgv.sgv) }.bolus + let dot = bolusGraphStruct(value: bolus, date: Double(dateTimeStamp), sgv: Int(sgv.sgv + 20), detail: detail) smbData.append(dot) } } diff --git a/LoopFollow/Controllers/Nightscout/Treatments/TemporaryTarget.swift b/LoopFollow/Controllers/Nightscout/Treatments/TemporaryTarget.swift index 592e47f7a..9f8cea5a0 100644 --- a/LoopFollow/Controllers/Nightscout/Treatments/TemporaryTarget.swift +++ b/LoopFollow/Controllers/Nightscout/Treatments/TemporaryTarget.swift @@ -53,16 +53,15 @@ extension MainViewController { let endDate = dateTimeStamp + duration - let dot = DataStructs.tempTargetStruct(date: dateTimeStamp, endDate: endDate, duration: duration, correctionRange: [Int(targetValue!)], enteredBy: enteredBy, reason: reason) + let detail = Treatment.historyEntries(from: currentEntry, date: parsedDate.timeIntervalSince1970) { 0 }.entries.first + let dot = DataStructs.tempTargetStruct(date: dateTimeStamp, endDate: endDate, duration: duration, correctionRange: [Int(targetValue!)], enteredBy: enteredBy, reason: reason, detail: detail) tempTargetGraphData.append(dot) // Set activeTempTarget only if it is still active let currentTime = Date().timeIntervalSince1970 if currentTime < endDate { activeTempTarget = Int(targetValue!) - // Trio represents indefinite temp targets as a ~30-day duration; - // treat a week or longer as indefinite (no end to display). - activeTempTargetEndAt = duration >= 7 * 24 * 3600 ? nil : endDate + activeTempTargetEndAt = dot.scheduledEndDate } } diff --git a/LoopFollow/Controllers/StatsDisplayModel.swift b/LoopFollow/Controllers/StatsDisplayModel.swift index 72e60560a..425a4487b 100644 --- a/LoopFollow/Controllers/StatsDisplayModel.swift +++ b/LoopFollow/Controllers/StatsDisplayModel.swift @@ -15,4 +15,14 @@ class StatsDisplayModel: ObservableObject { @Published var pieLow: Double = 0 @Published var pieRange: Double = 0 @Published var pieHigh: Double = 0 + + // Time in Range band (same period as the statistics box) + @Published var bandTitle: String = "Time in Range" + @Published var bandPeriod: String = "last 24h" + @Published var bandHasData: Bool = false + @Published var bandVeryLowPct: Double = 0 + @Published var bandLowPct: Double = 0 + @Published var bandInRangePct: Double = 0 + @Published var bandHighPct: Double = 0 + @Published var bandVeryHighPct: Double = 0 } diff --git a/LoopFollow/Controllers/TIRBandView.swift b/LoopFollow/Controllers/TIRBandView.swift new file mode 100644 index 000000000..dfb301088 --- /dev/null +++ b/LoopFollow/Controllers/TIRBandView.swift @@ -0,0 +1,99 @@ +// LoopFollow +// TIRBandView.swift + +import SwiftUI + +/// Time in Range band for the home screen: today's in-range +/// percentage above a segmented bar (very low / low / in range / high / very high). +/// The range follows the Range Mode in Settings (TIR, TITR or Custom). +/// Tapping opens the statistics screen. +struct TIRBandView: View { + @ObservedObject var model: StatsDisplayModel + var onTap: (() -> Void)? + + private var percentText: String { + guard model.bandHasData else { return "-- %" } + return model.bandInRangePct.formatted(.number.precision(.fractionLength(0 ... 1))) + " %" + } + + private var segments: [(color: Color, fraction: CGFloat)] { + guard model.bandHasData else { return [(Color.secondary.opacity(0.3), 1)] } + return [ + (TIRGraphView.Band.veryLow.color, CGFloat(model.bandVeryLowPct / 100)), + (TIRGraphView.Band.low.color, CGFloat(model.bandLowPct / 100)), + (TIRGraphView.Band.inRange.color, CGFloat(model.bandInRangePct / 100)), + (TIRGraphView.Band.high.color, CGFloat(model.bandHighPct / 100)), + (TIRGraphView.Band.veryHigh.color, CGFloat(model.bandVeryHighPct / 100)), + ] + } + + var body: some View { + HStack(alignment: .center, spacing: 12) { + VStack(alignment: .leading, spacing: 6) { + HStack(alignment: .firstTextBaseline, spacing: 6) { + Text(percentText) + .font(.title2) + .fontWeight(.bold) + .fontDesign(.rounded) + .foregroundStyle(.primary) + (Text(model.bandTitle).fontWeight(.semibold) + Text(" " + model.bandPeriod)) + .font(.subheadline) + .foregroundStyle(.secondary) + .lineLimit(1) + .minimumScaleFactor(0.8) + } + + DistributionBar(segments: segments) + .frame(height: 6) + } + .frame(maxWidth: .infinity, alignment: .leading) + + Image(systemName: "chevron.right") + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(.secondary) + } + .padding(.horizontal, 16) + .frame(height: 64) + .background( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .fill(Color(.secondarySystemBackground)) + ) + .contentShape(Rectangle()) + .onTapGesture { onTap?() } + } +} + +/// Horizontal bar of capsules, one per non-empty segment, sized by share. +/// Every non-empty segment is at least `minSegmentWidth` wide so a single +/// reading stays visible. +private struct DistributionBar: View { + let segments: [(color: Color, fraction: CGFloat)] + + private let spacing: CGFloat = 2 + private let minSegmentWidth: CGFloat = 4 + + var body: some View { + GeometryReader { geo in + let shown = segments.filter { $0.fraction > 0 } + let available = max(geo.size.width - spacing * CGFloat(max(shown.count - 1, 0)), 0) + let widths = segmentWidths(shown.map(\.fraction), available: available) + HStack(spacing: spacing) { + ForEach(Array(shown.enumerated()), id: \.offset) { index, segment in + Capsule() + .fill(segment.color) + .frame(width: widths[index]) + } + } + .frame(maxHeight: .infinity) + } + } + + private func segmentWidths(_ fractions: [CGFloat], available: CGFloat) -> [CGFloat] { + var widths = fractions.map { max($0 * available, minSegmentWidth) } + let excess = widths.reduce(0, +) - available + if excess > 0, let widest = widths.indices.max(by: { widths[$0] < widths[$1] }) { + widths[widest] = max(widths[widest] - excess, minSegmentWidth) + } + return widths + } +} diff --git a/LoopFollow/Helpers/CGMSensorState.swift b/LoopFollow/Helpers/CGMSensorState.swift new file mode 100644 index 000000000..5c6f33cf7 --- /dev/null +++ b/LoopFollow/Helpers/CGMSensorState.swift @@ -0,0 +1,93 @@ +// LoopFollow +// CGMSensorState.swift + +import Foundation + +/// A CGM state without reliable glucose, as uploaded by Loop or Trio in a +/// Nightscout note of the form "CGM: ". The state is the CGM kit's own +/// name, for example "temporarySensorIssue", "warmup" or ".unknown(23)". +struct CGMSensorState: Equatable { + /// The kit's name for the state. + let name: String + let date: Date + + init(name: String, date: Date) { + self.name = name + self.date = date + } + + /// Parses a note text; nil when the note is not a sensor-state note. + init?(note: String, date: Date) { + let prefix = "CGM: " + guard note.hasPrefix(prefix) else { return nil } + let name = String(note.dropFirst(prefix.count)) + guard !name.isEmpty, !name.contains(where: \.isWhitespace) else { return nil } + self.init(name: name, date: date) + } + + /// The state in plain words, e.g. "Temporary sensor issue". + var displayName: String { + Self.displayName(for: name) + } + + /// The state and its time, e.g. "Temporary sensor issue at 22:33". + var summary: String { + "\(displayName) at \(date.formatted(date: .omitted, time: .shortened))" + } + + /// The newest state reported after the newest reading. A reading after a + /// state means the CGM delivers glucose again, so that state is over. + static func active(in states: [CGMSensorState], latestBGDate: Date?) -> CGMSensorState? { + guard let newest = states.max(by: { $0.date < $1.date }) else { return nil } + if let latestBGDate, newest.date <= latestBGDate { + return nil + } + return newest + } + + /// Glucose to draw a state's marker at: the last reading at or before the + /// state, else the first reading after it. `readings` are sorted by date. + static func anchorSGV(at date: TimeInterval, readings: [ShareGlucoseData]) -> Int? { + if let before = readings.last(where: { $0.date <= date }) { + return before.sgv + } + return readings.first?.sgv + } + + static func displayName(for name: String) -> String { + if name.hasPrefix(".unknown("), name.hasSuffix(")") { + let code = name.dropFirst(".unknown(".count).dropLast() + return "Unknown state (\(code))" + } + if name == "questionMarks" { + return "Question marks (???)" + } + + // G7SensorKit spells some names "Dueto". + let identifier = name.replacingOccurrences(of: "Dueto", with: "DueTo") + + var words: [String] = [] + var current = "" + var previous: Character? + for character in identifier { + if let previous { + let startsWord = (character.isUppercase && (previous.isLowercase || previous.isNumber)) + || (character.isNumber && previous.isLetter) + if startsWord { + words.append(current) + current = "" + } + } + current.append(character) + previous = character + } + words.append(current) + + // Acronyms such as "BGs" keep their case. + let normalized = words.map { word -> String in + word.dropFirst().contains(where: \.isUppercase) ? word : word.lowercased() + } + guard let first = normalized.first, !first.isEmpty else { return name } + return ([first.prefix(1).uppercased() + first.dropFirst()] + normalized.dropFirst()).joined(separator: " ") + } +} diff --git a/LoopFollow/Helpers/DataStructs.swift b/LoopFollow/Helpers/DataStructs.swift index 9cd8a175f..f2690d279 100644 --- a/LoopFollow/Helpers/DataStructs.swift +++ b/LoopFollow/Helpers/DataStructs.swift @@ -37,7 +37,7 @@ class DataStructs { } // NS Override Data Struct - struct overrideStruct: Codable { + struct overrideStruct { var insulNeedsScaleFactor: Double var date: TimeInterval var endDate: TimeInterval @@ -46,14 +46,28 @@ class DataStructs { var enteredBy: String var reason: String var sgv: Float + /// Scheduled end (start + duration); unlike `endDate` it is never + /// clamped to the graph horizon. Nil when the override runs + /// indefinitely, so the end is not known in advance. + var scheduledEndDate: TimeInterval? + /// The Treatments list entry this band opens. + var detail: Treatment? = nil } - struct tempTargetStruct: Codable { + struct tempTargetStruct { var date: TimeInterval var endDate: TimeInterval var duration: Double var correctionRange: [Int] var enteredBy: String var reason: String + /// The Treatments list entry this band opens. + var detail: Treatment? = nil + + /// Scheduled end; nil for an indefinite target, which Trio stores + /// with a duration of about 30 days. + var scheduledEndDate: TimeInterval? { + duration >= 7 * 24 * 3600 ? nil : endDate + } } } diff --git a/LoopFollow/Helpers/ExitDiagnostics.swift b/LoopFollow/Helpers/ExitDiagnostics.swift new file mode 100644 index 000000000..8664ef084 --- /dev/null +++ b/LoopFollow/Helpers/ExitDiagnostics.swift @@ -0,0 +1,89 @@ +// LoopFollow +// ExitDiagnostics.swift + +import Foundation +import MetricKit +import os + +/// Logs MetricKit app exit counts and crash diagnostics. iOS delivers these +/// at most once a day, so a log shared the day after a termination shows why +/// the process ended. +final class ExitDiagnostics: NSObject, MXMetricManagerSubscriber { + static let shared = ExitDiagnostics() + + func start() { + MXMetricManager.shared.add(self) + } + + func didReceive(_ payloads: [MXMetricPayload]) { + for payload in payloads { + guard let exits = payload.applicationExitMetrics else { continue } + let range = "\(payload.timeStampBegin) – \(payload.timeStampEnd)" + + let bg = exits.backgroundExitData + let background = Self.describe([ + ("normal", bg.cumulativeNormalAppExitCount), + ("memoryLimit", bg.cumulativeMemoryResourceLimitExitCount), + ("memoryPressure", bg.cumulativeMemoryPressureExitCount), + ("cpuLimit", bg.cumulativeCPUResourceLimitExitCount), + ("badAccess", bg.cumulativeBadAccessExitCount), + ("illegalInstruction", bg.cumulativeIllegalInstructionExitCount), + ("abnormal", bg.cumulativeAbnormalExitCount), + ("watchdog", bg.cumulativeAppWatchdogExitCount), + ("lockedFile", bg.cumulativeSuspendedWithLockedFileExitCount), + ("taskAssertionTimeout", bg.cumulativeBackgroundTaskAssertionTimeoutExitCount), + ]) + + let fg = exits.foregroundExitData + let foreground = Self.describe([ + ("normal", fg.cumulativeNormalAppExitCount), + ("memoryLimit", fg.cumulativeMemoryResourceLimitExitCount), + ("badAccess", fg.cumulativeBadAccessExitCount), + ("illegalInstruction", fg.cumulativeIllegalInstructionExitCount), + ("abnormal", fg.cumulativeAbnormalExitCount), + ("watchdog", fg.cumulativeAppWatchdogExitCount), + ]) + + LogManager.shared.log(category: .general, message: "App exits \(range): background [\(background)], foreground [\(foreground)]") + } + } + + func didReceive(_ payloads: [MXDiagnosticPayload]) { + for payload in payloads { + for crash in payload.crashDiagnostics ?? [] { + let signal = crash.signal.map { "\($0)" } ?? "-" + let exceptionType = crash.exceptionType.map { "\($0)" } ?? "-" + let exceptionCode = crash.exceptionCode.map { "\($0)" } ?? "-" + let reason = crash.terminationReason ?? "-" + LogManager.shared.log( + category: .general, + message: "Crash reported for \(payload.timeStampBegin) – \(payload.timeStampEnd): version=\(crash.applicationVersion), signal=\(signal), exceptionType=\(exceptionType), exceptionCode=\(exceptionCode), terminationReason=\(reason)" + ) + } + } + } + + /// Current memory footprint and the headroom left before iOS terminates the + /// process for exceeding its memory limit. + static func memoryDescription() -> String { + var info = task_vm_info_data_t() + var count = mach_msg_type_number_t(MemoryLayout.size / MemoryLayout.size) + let result = withUnsafeMutablePointer(to: &info) { + $0.withMemoryRebound(to: integer_t.self, capacity: Int(count)) { + task_info(mach_task_self_, task_flavor_t(TASK_VM_INFO), $0, &count) + } + } + let footprint = result == KERN_SUCCESS ? "\(info.phys_footprint / 1_048_576) MB" : "unknown" + #if targetEnvironment(macCatalyst) + return "memoryFootprint=\(footprint)" + #else + let available = os_proc_available_memory() / 1_048_576 + return "memoryFootprint=\(footprint), memoryAvailable=\(available) MB" + #endif + } + + private static func describe(_ counts: [(String, Int)]) -> String { + let nonZero = counts.filter { $0.1 > 0 } + return nonZero.isEmpty ? "none" : nonZero.map { "\($0.0)=\($0.1)" }.joined(separator: ", ") + } +} diff --git a/LoopFollow/Helpers/HistoryCurvePlacement.swift b/LoopFollow/Helpers/HistoryCurvePlacement.swift new file mode 100644 index 000000000..d5dc6919f --- /dev/null +++ b/LoopFollow/Helpers/HistoryCurvePlacement.swift @@ -0,0 +1,17 @@ +// LoopFollow +// HistoryCurvePlacement.swift + +/// Where the device status history curves (IOB, COB, sensitivity ratio) are drawn. +enum HistoryCurvePlacement: String, CaseIterable, Codable { + /// Each curve in its own graph under the main graph, with its own axis. + case separate + /// All curves in the bottom band of the main graph, each scaled to its own range. + case mainGraph + + var displayName: String { + switch self { + case .separate: return "Separate Graphs" + case .mainGraph: return "In Main Graph" + } + } +} diff --git a/LoopFollow/Helpers/Localizer.swift b/LoopFollow/Helpers/Localizer.swift index b615a4cf6..3d32a51da 100644 --- a/LoopFollow/Helpers/Localizer.swift +++ b/LoopFollow/Helpers/Localizer.swift @@ -18,10 +18,41 @@ enum GlucoseDisplayUnit: String, Codable, CaseIterable { } } +/// The selectable range modes. To add one, define its low/high in `GlucoseThresholds`, +/// then add a case here with a title and `fixedThresholds` pointing at those constants; +/// the settings range list, stats card and toggle pick it up from `allCases`. +/// The raw value is persisted, so don't change existing ones. enum TimeInRangeDisplayMode: String, Codable, CaseIterable { case tir = "TIR" case titr = "TITR" + case ting = "TING" case custom = "Custom" + + var title: String { + switch self { + case .tir: return "Time in Range" + case .titr: return "Time in Tight Range" + case .ting: return "Extended Tight Range" + case .custom: return "Custom Range" + } + } + + /// Low/high cut-offs in mg/dL from `GlucoseThresholds`, or nil when the user's own low/high lines apply. + var fixedThresholds: (low: Double, high: Double)? { + switch self { + case .tir: return (GlucoseThresholds.tirLow, GlucoseThresholds.tirHigh) + case .titr: return (GlucoseThresholds.titrLow, GlucoseThresholds.titrHigh) + case .ting: return (GlucoseThresholds.tingLow, GlucoseThresholds.tingHigh) + case .custom: return nil + } + } + + /// The mode after this one in `allCases`, wrapping around. + var next: TimeInRangeDisplayMode { + let all = Self.allCases + let index = all.firstIndex(of: self)! + return all[(index + 1) % all.count] + } } enum GlycemicMetricMode: String, Codable, CaseIterable { @@ -64,14 +95,20 @@ final class UnitSettingsStore { /// Returns the effective low/high thresholds (in mg/dL) for the current range mode. func effectiveThresholds() -> (low: Double, high: Double) { - switch timeInRangeMode { - case .tir: - return (70.0, 180.0) - case .titr: - return (70.0, 140.0) - case .custom: - return (Storage.shared.lowLine.value, Storage.shared.highLine.value) - } + timeInRangeMode.fixedThresholds ?? (Storage.shared.lowLine.value, Storage.shared.highLine.value) + } + + /// Returns the very low, low, high and very high thresholds (in mg/dL) for + /// the current range mode. Very low and very high come from `GlucoseThresholds`, + /// or the range's own bounds when a custom range extends past them. + func effectiveBandThresholds() -> (veryLow: Double, low: Double, high: Double, veryHigh: Double) { + let range = effectiveThresholds() + return ( + min(GlucoseThresholds.veryLow, range.low), + range.low, + range.high, + max(GlucoseThresholds.veryHigh, range.high) + ) } var glycemicMetricMode: GlycemicMetricMode { diff --git a/LoopFollow/Helpers/NightscoutUtils.swift b/LoopFollow/Helpers/NightscoutUtils.swift index 07a6246bc..1fa6c5bdb 100644 --- a/LoopFollow/Helpers/NightscoutUtils.swift +++ b/LoopFollow/Helpers/NightscoutUtils.swift @@ -274,6 +274,32 @@ class NightscoutUtils { task.resume() } + /// Effective time of a Nightscout treatment: `timestamp` when present, otherwise `created_at`. + /// Uploaders disagree on the value's type (ISO 8601 string, epoch milliseconds, or JSON `null`), + /// so never force-cast it; an unreadable value yields `nil` and the entry is skipped. + static func treatmentDate(from entry: [String: AnyObject]) -> Date? { + for key in ["timestamp", "created_at"] { + guard let value = entry[key], !(value is NSNull) else { continue } + return parseDateValue(value) + } + return nil + } + + static func parseDateValue(_ value: Any) -> Date? { + switch value { + case let string as String: + return parseDate(string) + case let number as NSNumber: + // Booleans are NSNumbers too; they are never a date. + if CFGetTypeID(number) == CFBooleanGetTypeID() { return nil } + let raw = number.doubleValue + // Epoch milliseconds are ~1e12; epoch seconds are ~1e9. + return Date(timeIntervalSince1970: raw > 1e11 ? raw / 1000 : raw) + default: + return nil + } + } + static func parseDate(_ rawString: String) -> Date? { var mutableDate = rawString diff --git a/LoopFollow/InfoTable/InfoDisplayItem.swift b/LoopFollow/InfoTable/InfoDisplayItem.swift index cf3a3c1b9..8e8da861a 100644 --- a/LoopFollow/InfoTable/InfoDisplayItem.swift +++ b/LoopFollow/InfoTable/InfoDisplayItem.swift @@ -59,15 +59,25 @@ struct InfoColoring: Codable, Equatable { /// the default text color); when enabled, an in-range value reads green, /// mirroring the app's BG text coloring. func color(for value: Double, direction: InfoColorDirection) -> Color? { + switch level(for: value, direction: direction) { + case .urgent: .red + case .warning: .yellow + case .normal: .green + case nil: nil + } + } + + /// The threshold band a value falls in, or `nil` when coloring is off. + func level(for value: Double, direction: InfoColorDirection) -> InfoColorLevel? { guard enabled else { return nil } switch direction { case .above: - if let urgent, value >= urgent { return .red } - if let warning, value >= warning { return .yellow } + if let urgent, value >= urgent { return .urgent } + if let warning, value >= warning { return .warning } case .below: - if let urgent, value <= urgent { return .red } - if let warning, value <= warning { return .yellow } + if let urgent, value <= urgent { return .urgent } + if let warning, value <= warning { return .warning } } - return .green + return .normal } } diff --git a/LoopFollow/InfoTable/InfoTableView.swift b/LoopFollow/InfoTable/InfoTableView.swift index 6c36c6bbe..337def504 100644 --- a/LoopFollow/InfoTable/InfoTableView.swift +++ b/LoopFollow/InfoTable/InfoTableView.swift @@ -6,6 +6,7 @@ import SwiftUI struct InfoTableView: View { @ObservedObject var infoManager: InfoManager var timeZoneOverride: String? + @ObservedObject private var retro = RetroSelection.shared @ScaledMetric(relativeTo: .body) private var fontSize: CGFloat = 17 @ScaledMetric(relativeTo: .body) private var rowHeight: CGFloat = 21 @@ -16,17 +17,30 @@ struct InfoTableView: View { row(name: "Time Zone", value: tz) } ForEach(infoManager.visibleRows) { item in - row(name: item.name, value: item.value, valueColor: color(for: item)) + if let snapshot = retro.snapshot { + // Scrubbing: the value at the scrubbed time, or "—" when unknown. + let retroRow = InfoType(rawValue: item.id).flatMap { snapshot.rows[$0] } + row(name: item.name, value: retroRow?.value ?? "", valueColor: color(for: item.id, numericValue: retroRow?.numericValue)) + .listRowBackground(Color.clear) + } else { + row(name: item.name, value: item.value, valueColor: color(for: item.id, numericValue: item.numericValue)) + } } } .listStyle(.plain) .environment(\.defaultMinListRowHeight, rowHeight) + .scrollContentBackground(retro.snapshot != nil ? .hidden : .automatic) + .background { + if retro.snapshot != nil { + RetroCardBackground() + } + } } /// Threshold-based color for a row's value, or nil to use the default color. - private func color(for item: InfoData) -> Color? { - guard let numericValue = item.numericValue, - let type = InfoType(rawValue: item.id), + private func color(for id: Int, numericValue: Double?) -> Color? { + guard let numericValue, + let type = InfoType(rawValue: id), let config = type.colorConfig else { return nil } return Storage.shared.infoDisplayItems.value.item(for: type)? diff --git a/LoopFollow/InfoTable/InfoType.swift b/LoopFollow/InfoTable/InfoType.swift index 173ca416e..ca127c027 100644 --- a/LoopFollow/InfoTable/InfoType.swift +++ b/LoopFollow/InfoTable/InfoType.swift @@ -4,7 +4,7 @@ import Foundation enum InfoType: Int, CaseIterable, Codable { - case iob, cob, basal, override, battery, pump, pumpBattery, sage, cage, recBolus, minMax, carbsToday, autosens, profile, target, isf, carbRatio, updated, tdd, iage, dbSize + case iob, cob, basal, override, battery, pump, pumpBattery, sage, cage, recBolus, minMax, carbsToday, autosens, profile, target, isf, carbRatio, updated, tdd, iage, dbSize, smoothedBG var name: String { switch self { @@ -29,6 +29,7 @@ enum InfoType: Int, CaseIterable, Codable { case .tdd: return "TDD" case .iage: return "IAGE" case .dbSize: return "DB Size" + case .smoothedBG: return "Smoothed BG" } } diff --git a/LoopFollow/InfoTable/RetroSnapshot.swift b/LoopFollow/InfoTable/RetroSnapshot.swift new file mode 100644 index 000000000..d7a0a7cff --- /dev/null +++ b/LoopFollow/InfoTable/RetroSnapshot.swift @@ -0,0 +1,269 @@ +// LoopFollow +// RetroSnapshot.swift + +import HealthKit +import SwiftUI + +/// What the BG display and info table show while the user scrubs the main +/// chart: the state at the scrubbed time instead of the latest values. +struct RetroSnapshot: Equatable { + struct Row: Equatable { + let value: String + let numericValue: Double? + } + + let date: Date + let timeText: String + let bgText: String + let bgColor: Color + let directionText: String + let deltaText: String + let predictionText: String + let predictionColor: Color + /// Info rows with a value at the scrubbed time; other rows show "—". + let rows: [InfoType: Row] + + /// Color that marks the displays as showing a past time. + static let accent = Color(.systemGray) +} + +/// Card behind the BG display and info table while they show a past time. +/// It extends past the content into the surrounding spacing, so the text +/// keeps its place and does not touch the card's edge. +struct RetroCardBackground: View { + var body: some View { + RoundedRectangle(cornerRadius: 14, style: .continuous) + .fill(LinearGradient( + colors: [RetroSnapshot.accent.opacity(0.4), RetroSnapshot.accent.opacity(0.12)], + startPoint: .top, + endPoint: .bottom + )) + .padding(EdgeInsets(top: -8, leading: -4, bottom: -4, trailing: -4)) + } +} + +/// Publishes the snapshot for the time under the user's finger while +/// scrubbing, and nil otherwise. Only the displays change; alarms, the Live +/// Activity and everything else keep using the latest values. +final class RetroSelection: ObservableObject { + static let shared = RetroSelection() + + @Published private(set) var snapshot: RetroSnapshot? + + private init() { + // A touch the system cancels (a call, Notification Center, leaving the + // app) may never end the scrub; the displays return to live anyway. + for name in [UIApplication.willResignActiveNotification, UIApplication.didEnterBackgroundNotification] { + NotificationCenter.default.addObserver(forName: name, object: nil, queue: .main) { [weak self] _ in + self?.clear() + } + } + } + + /// Shows the state at `date`, or the live values when nil. `refresh` + /// rebuilds the snapshot for an unchanged date after new data arrived. + func select(_ date: Date?, refresh: Bool = false) { + guard let date, let vc = MainViewController.shared + else { + clear() + return + } + guard refresh || snapshot?.date != date else { return } + snapshot = vc.retroSnapshot(at: date) + } + + private func clear() { + if snapshot != nil { snapshot = nil } + } +} + +extension MainViewController { + /// The BG display and info table values at `date`, a scrub position on the + /// BG reading grid. + func retroSnapshot(at date: Date) -> RetroSnapshot { + let time = date.timeIntervalSince1970 + let history = DeviceStatusHistory.shared + let sample = history.cycle(forReadingAt: date) ?? history.sample(near: date) + + // BG, direction and delta of the reading at the scrub position. + var bgText = "—" + var bgColor = Color.primary + var directionText = "" + var deltaText = "" + // Scrub positions on a reading carry its exact time; slots without one show no BG. + if let index = bgData.lastIndex(where: { abs($0.date - time) <= 30 }) { + let reading = bgData[index] + if reading.sgv <= globalVariables.minDisplayGlucose { + bgText = "LOW" + } else if reading.sgv >= globalVariables.maxDisplayGlucose { + bgText = "HIGH" + } else { + bgText = Localizer.toDisplayUnits(String(reading.sgv)) + } + if Storage.shared.colorBGText.value { + let thresholds = UnitSettingsStore.shared.effectiveThresholds() + if Double(reading.sgv) >= thresholds.high { + bgColor = .yellow + } else if Double(reading.sgv) <= thresholds.low { + bgColor = .red + } else { + bgColor = .green + } + } + if let direction = reading.direction { + directionText = bgDirectionGraphic(direction) + } + if index > 0 { + let delta = reading.sgv - bgData[index - 1].sgv + deltaText = (delta < 0 ? "" : "+") + Localizer.toDisplayUnits(String(delta)) + } + } + + let isLoopSample = sample?.forecast?.curves[DeviceStatusHistorySample.Forecast.loopCurve] != nil + let predictionText = sample?.eventualBG.map { Localizer.formatQuantity($0) } ?? "" + + return RetroSnapshot( + date: date, + timeText: chartModel.pillTimeString(for: date), + bgText: bgText, + bgColor: bgColor, + directionText: directionText, + deltaText: deltaText, + predictionText: predictionText, + predictionColor: isLoopSample ? .purple : .gray, + rows: retroRows(at: date, sample: sample) + ) + } + + private func retroRows(at date: Date, sample: DeviceStatusHistorySample?) -> [InfoType: RetroSnapshot.Row] { + let time = date.timeIntervalSince1970 + let isLoop = Storage.shared.device.value == "Loop" + var rows: [InfoType: RetroSnapshot.Row] = [:] + + func set(_ type: InfoType, _ value: String?, _ numeric: Double? = nil) { + guard let value, !value.isEmpty else { return } + rows[type] = .init(value: value, numericValue: numeric) + } + + // Values from the loop cycle's devicestatus record. + if let sample { + if let iob = sample.iob, let metric = InsulinMetric(from: ["iob": iob as AnyObject], key: "iob") { + set(.iob, metric.formattedValue(), iob) + } + if let cob = sample.cob, let metric = CarbMetric(from: ["cob": cob as AnyObject], key: "cob") { + set(.cob, metric.formattedValue(), cob) + } + if let tdd = sample.tdd, let metric = InsulinMetric(from: ["TDD": tdd as AnyObject], key: "TDD") { + set(.tdd, metric.formattedValue(), tdd) + } + set(.recBolus, sample.recommendedBolus.map { InsulinFormatter.shared.string($0) }, sample.recommendedBolus) + set(.battery, sample.uploaderBattery.map { String(format: "%.0f", $0) + "%" }, sample.uploaderBattery) + set(.pumpBattery, sample.pumpBattery.map { String(format: "%.0f", $0) + "%" }, sample.pumpBattery) + set(.pump, sample.reservoir.map { String(format: "%.0f", $0) + "U" }, sample.reservoir) + set(.autosens, sample.sensitivityRatio.map { String(format: "%.0f", $0 * 100) + "%" }) + set(.smoothedBG, sample.smoothedBG.map { Localizer.formatQuantity($0) }) + set(.updated, Localizer.formatTimestampToLocalString(sample.date)) + set(.minMax, retroMinMax(sample.forecast)) + if let override = sample.loopOverride { + let percent = override.multiplier.map { String(format: "%.0f%%", $0 * 100) } ?? "100%" + set(.override, "\(percent) (\(Localizer.toDisplayUnits(String(override.minTarget)))-\(Localizer.toDisplayUnits(String(override.maxTarget))))") + } + } + + // Profile-based rows, from the loaded profile's schedule at that time + // of day, with the loop's own value where the record carries one. + if let profileISF = profileManager.isf(at: date) { + if let isf = sample?.isf, + Localizer.formatQuantity(profileISF) != Localizer.formatQuantity(isf) + { + set(.isf, "\(Localizer.formatQuantity(profileISF)) \(InfoDataSeparator.arrow.rawValue) \(Localizer.formatQuantity(isf))") + } else { + set(.isf, Localizer.formatQuantity(profileISF)) + } + } else if let isf = sample?.isf { + set(.isf, Localizer.formatQuantity(isf)) + } + + let profileCR = profileManager.carbRatio(at: date) + let loopCR = sample?.carbRatio + if let profileCR, let loopCR, profileCR != loopCR { + set(.carbRatio, "\(Localizer.formatToLocalizedString(profileCR)) \(InfoDataSeparator.arrow.rawValue) \(Localizer.formatToLocalizedString(loopCR))") + } else if let cr = profileCR ?? loopCR { + set(.carbRatio, Localizer.formatToLocalizedString(cr)) + } + + if let target = sample?.target { + set(.target, Localizer.formatQuantity(target)) + } else if let low = profileManager.targetLow(at: date) { + let lowText = Localizer.formatQuantity(low) + if let high = profileManager.targetHigh(at: date), Localizer.formatQuantity(high) != lowText { + set(.target, "\(lowText) \(InfoDataSeparator.dash.rawValue) \(Localizer.formatQuantity(high))") + } else { + set(.target, lowText) + } + } + + // Rows that do not depend on the time keep their live value. + for type in [InfoType.profile, .dbSize] { + let live = infoManager.tableData[type.rawValue] + set(type, live.value, live.numericValue) + } + + // Rows derived from data the chart already holds. + if let rate = basalData.filter({ $0.date <= time }).max(by: { $0.date < $1.date })?.basalRate { + let actual = Localizer.formatToLocalizedString(rate, maxFractionDigits: 2, minFractionDigits: 0) + if let scheduled = profileManager.basal(at: date), scheduled != actual { + set(.basal, "\(scheduled) \(InfoDataSeparator.arrow.rawValue) \(actual)") + } else { + set(.basal, actual) + } + } + + if !isLoop, + let band = overrideGraphData.last(where: { $0.date <= time && time < $0.endDate }) + { + set(.override, band.reason.isEmpty ? "Override" : band.reason) + } + + var calendar = Calendar.current + calendar.timeZone = .current + let carbs = carbData + .filter { $0.date <= time && calendar.isDate(Date(timeIntervalSince1970: $0.date), inSameDayAs: date) } + .reduce(0) { $0 + $1.value } + set(.carbsToday, String(format: "%.0f", carbs), carbs) + + // Site and sensor ages, when the current insert predates the scrubbed time. + for (type, insertTime) in [ + (InfoType.sage, Storage.shared.sageInsertTime.value), + (.cage, Storage.shared.cageInsertTime.value), + (.iage, Storage.shared.iageInsertTime.value), + ] where insertTime > 0 && insertTime <= time { + let age = time - insertTime + set(type, Self.ageFormatter.string(from: age), age / 86400) + } + + return rows + } + + /// Min/max of the forecast, as the live row shows it. + private func retroMinMax(_ forecast: DeviceStatusHistorySample.Forecast?) -> String? { + guard let forecast else { return nil } + let values: [Int] + if let main = forecast.curves[DeviceStatusHistorySample.Forecast.loopCurve] { + values = main + } else { + let count = Int(Storage.shared.predictionToLoad.value * 12) + 1 + values = forecast.curves.values.flatMap { $0.prefix(count) } + } + guard let low = values.min(), let high = values.max() else { return nil } + return "\(Localizer.toDisplayUnits(String(low)))/\(Localizer.toDisplayUnits(String(high)))" + } + + private static let ageFormatter: DateComponentsFormatter = { + let formatter = DateComponentsFormatter() + formatter.unitsStyle = .positional + formatter.allowedUnits = [.day, .hour] + formatter.zeroFormattingBehavior = [.pad] + return formatter + }() +} diff --git a/LoopFollow/LiveActivity/APNSClient.swift b/LoopFollow/LiveActivity/APNSClient.swift index ff20144c0..ca8dd5261 100644 --- a/LoopFollow/LiveActivity/APNSClient.swift +++ b/LoopFollow/LiveActivity/APNSClient.swift @@ -241,6 +241,7 @@ class APNSClient { ] snapshotDict["isNotLooping"] = snapshot.isNotLooping + if let cgmSensorState = snapshot.cgmSensorState { snapshotDict["cgmSensorState"] = cgmSensorState } snapshotDict["showRenewalOverlay"] = snapshot.showRenewalOverlay if let iob = snapshot.iob { snapshotDict["iob"] = iob } if let cob = snapshot.cob { snapshotDict["cob"] = cob } diff --git a/LoopFollow/LiveActivity/GlucoseSnapshotBuilder.swift b/LoopFollow/LiveActivity/GlucoseSnapshotBuilder.swift index 61dd92ab7..bb59255dd 100644 --- a/LoopFollow/LiveActivity/GlucoseSnapshotBuilder.swift +++ b/LoopFollow/LiveActivity/GlucoseSnapshotBuilder.swift @@ -101,6 +101,9 @@ protocol CurrentGlucoseStateProviding { /// True when LoopFollow detects the loop has not reported in 15+ minutes. var isNotLooping: Bool { get } + /// CGM state reported after the newest reading, as display text. + var cgmSensorState: String? { get } + // MARK: - Renewal /// True when the Live Activity is within renewalWarning seconds of its deadline. @@ -129,7 +132,7 @@ enum GlucoseSnapshotBuilder { let preferredUnit = PreferredGlucoseUnit.snapshotUnit() let deltaMgdl = provider.deltaMgdl ?? 0.0 - let trend = mapTrend(provider.trendCode) + let trend = GlucoseSnapshot.Trend(code: provider.trendCode) if provider.showRenewalOverlay { LogManager.shared.log(category: .general, message: "[LA] renewal overlay ON") @@ -173,42 +176,8 @@ enum GlucoseSnapshotBuilder { maxBgMgdl: provider.maxBgMgdl, unit: preferredUnit, isNotLooping: provider.isNotLooping, + cgmSensorState: provider.cgmSensorState, showRenewalOverlay: provider.showRenewalOverlay, ) } - - // MARK: - Trend Mapping - - private static func mapTrend(_ code: String?) -> GlucoseSnapshot.Trend { - guard - let raw = code? - .trimmingCharacters(in: .whitespacesAndNewlines) - .lowercased(), - !raw.isEmpty - else { return .unknown } - - if raw.contains("doubleup") || raw.contains("rapidrise") || raw == "up2" || raw == "upfast" { - return .upFast - } - if raw.contains("fortyfiveup") { - return .upSlight - } - if raw.contains("singleup") || raw == "up" || raw == "up1" || raw == "rising" { - return .up - } - if raw.contains("flat") || raw == "steady" || raw == "none" { - return .flat - } - if raw.contains("doubledown") || raw.contains("rapidfall") || raw == "down2" || raw == "downfast" { - return .downFast - } - if raw.contains("fortyfivedown") { - return .downSlight - } - if raw.contains("singledown") || raw == "down" || raw == "down1" || raw == "falling" { - return .down - } - - return .unknown - } } diff --git a/LoopFollow/LiveActivity/LiveActivityManager.swift b/LoopFollow/LiveActivity/LiveActivityManager.swift index 125ba4e09..0d3e61fe5 100644 --- a/LoopFollow/LiveActivity/LiveActivityManager.swift +++ b/LoopFollow/LiveActivity/LiveActivityManager.swift @@ -10,8 +10,10 @@ import os import UIKit import UserNotifications -// Live Activity manager for LoopFollow. Every LA creation (start, renewal, -// restart) and update goes through APNs push-to-start. +// Live Activity manager for LoopFollow. LA creation prefers a local +// Activity.request() while the app is foreground-active (no APNs round-trip, +// no banner/Dynamic Island presentation); APNs push-to-start is the creation +// path in the background. Updates go over APNs self-push. final class LiveActivityManager { static let shared = LiveActivityManager() @@ -176,16 +178,12 @@ final class LiveActivityManager { @objc private func handleWillResignActive() { guard Storage.shared.laEnabled.value, let activity = current else { return } - refreshWorkItem?.cancel() - refreshWorkItem = nil + refreshCoalescer.cancel() let provider = StorageCurrentGlucoseStateProvider() guard let snapshot = GlucoseSnapshotBuilder.build(from: provider) else { return } - LAAppGroupSettings.setThresholds( - lowMgdl: Storage.shared.lowLine.value, - highMgdl: Storage.shared.highLine.value, - ) + writeThresholds() GlucoseSnapshotStore.shared.save(snapshot) seq += 1 @@ -409,6 +407,11 @@ final class LiveActivityManager { static let renewalThreshold: TimeInterval = 7.5 * 3600 static let renewalWarning: TimeInterval = 30 * 60 + /// Remaining time on `laRenewBy` below which a foreground entry proactively + /// replaces the healthy LA with a locally created one, resetting the 8-hour + /// clock without an APNs push-to-start. Local-only by design: if the local + /// request fails, the existing LA still has hours left, so no fallback fires. + static let opportunisticRenewalThreshold: TimeInterval = 4 * 3600 static let extensionLivenessGrace: TimeInterval = 15 * 60 /// Base backoff after a 429 for push-to-start; doubled on each subsequent 429, @@ -437,7 +440,10 @@ final class LiveActivityManager { private var lastUpdateTime: Date? private var pushToken: String? private var tokenObservationTask: Task? - private var refreshWorkItem: DispatchWorkItem? + private lazy var refreshCoalescer = RefreshCoalescer(delay: 20) { [weak self] reason in + self?.performRefresh(reason: reason) + } + /// Set when the user manually swipes away the LA. Blocks auto-restart until /// an explicit user action (Restart button, App Intent) clears it. /// In-memory only — resets to false on app relaunch, so a kill + relaunch @@ -496,9 +502,9 @@ final class LiveActivityManager { let startReason = nextStartReasonOverride ?? "user-start" nextStartReasonOverride = nil - // Push-to-start is used for every creation path. If an activity is - // already running and not stale we adopt/reuse it (covers warm starts - // where the LA survived a relaunch); only truly new starts dispatch APNs. + // If an activity is already running and not stale we adopt/reuse it + // (covers warm starts where the LA survived a relaunch); only truly new + // starts go through createActivity (local when foreground, APNs otherwise). if let existing = Activity.activities.first { let renewBy = Storage.shared.laRenewBy.value let now = Date().timeIntervalSince1970 @@ -506,18 +512,30 @@ final class LiveActivityManager { let inRenewalWindow = renewBy > 0 && now >= renewBy - LiveActivityManager.renewalWarning let needsRestart = Storage.shared.laRenewalFailed.value || inRenewalWindow || staleDatePassed if !needsRestart { + // Opportunistic renewal: the LA is healthy but in the back half + // of its 8-hour lifetime and the app is open — replace it locally + // now so the background push-to-start renewal (and its banner) is + // not needed later. On failure the existing LA is kept. + let remaining = renewBy - now + if renewBy > 0, remaining < LiveActivityManager.opportunisticRenewalThreshold, + isAppVisibleForLiveActivityStart(), !isAwaitingPushToStartAdoption + { + if attemptLocalCreate(reason: "opportunistic-renew", oldActivity: existing) { + return + } + } bind(to: existing, logReason: "reuse") Storage.shared.laRenewalFailed.value = false return } LogManager.shared.log( category: .general, - message: "[LA] existing activity is stale on startIfNeeded — push-to-start replace (staleDatePassed=\(staleDatePassed), inRenewalWindow=\(inRenewalWindow))" + message: "[LA] existing activity is stale on startIfNeeded — replacing (staleDatePassed=\(staleDatePassed), inRenewalWindow=\(inRenewalWindow))" ) - attemptPushToStartCreate(reason: startReason, oldActivity: existing) + createActivity(reason: startReason, oldActivity: existing) return } - attemptPushToStartCreate(reason: startReason, oldActivity: nil) + createActivity(reason: startReason, oldActivity: nil) } /// Called from applicationWillTerminate. Ends the LA synchronously (blocking @@ -624,10 +642,7 @@ final class LiveActivityManager { let provider = StorageCurrentGlucoseStateProvider() if let snapshot = GlucoseSnapshotBuilder.build(from: provider) { - LAAppGroupSettings.setThresholds( - lowMgdl: Storage.shared.lowLine.value, - highMgdl: Storage.shared.highLine.value, - ) + writeThresholds() LAAppGroupSettings.setDisplayName( Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String ?? "LoopFollow", show: Storage.shared.showDisplayName.value @@ -640,12 +655,12 @@ final class LiveActivityManager { func refreshFromCurrentState(reason: String) { // 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) + guard Thread.isMainThread else { + DispatchQueue.main.async { self.refreshFromCurrentState(reason: reason) } + return } - refreshWorkItem = workItem - DispatchQueue.main.asyncAfter(deadline: .now() + 20.0, execute: workItem) + WatchSessionManager.shared.requestUpdate(reason: reason) + refreshCoalescer.request(reason: reason) } // MARK: - Renewal @@ -662,18 +677,148 @@ final class LiveActivityManager { let overdueBy = Date().timeIntervalSince1970 - renewBy LogManager.shared.log(category: .general, message: "[LA] renewal deadline passed by \(Int(overdueBy))s, requesting new LA") - // Renewal goes through push-to-start. The dispatch hops to MainActor - // and returns immediately; adoption (or failure) lands in the observer. + // Renewal goes through createActivity: local if the app happens to be + // foreground-active at the deadline, push-to-start otherwise. The + // dispatch hops to MainActor and returns immediately; push-to-start + // adoption (or failure) lands in the observer. // Return true so performRefresh stops processing this tick. Task { @MainActor [weak self] in - self?.attemptPushToStartCreate(reason: "renew", oldActivity: oldActivity, snapshot: snapshot) + self?.createActivity(reason: "renew", oldActivity: oldActivity, snapshot: snapshot) } return true } + // MARK: - Creation dispatch + + /// Single entry point for every LA creation (initial start, renewal, + /// forced restart, expired-token recovery). Prefers a local + /// Activity.request() while the app is foreground-active and falls back to + /// APNs push-to-start, the only creation transport while backgrounded. + @MainActor + private func createActivity( + reason: String, + oldActivity: Activity?, + snapshot: GlucoseSnapshot? = nil + ) { + if isAwaitingPushToStartAdoption { + LogManager.shared.log( + category: .general, + message: "[LA] create (\(reason)): a push-to-start is awaiting adoption — not creating" + ) + return + } + if isAppVisibleForLiveActivityStart() { + if attemptLocalCreate(reason: reason, oldActivity: oldActivity, snapshot: snapshot) { + return + } + } + attemptPushToStartCreate(reason: reason, oldActivity: oldActivity, snapshot: snapshot) + } + + /// A push-to-start accepted by APNs this recently is still expected to + /// arrive through activityUpdates; creating meanwhile would replace the LA twice. + static let pushToStartAdoptionWindow: TimeInterval = 30 + + private var isAwaitingPushToStartAdoption: Bool { + guard let sentAt = lastPushToStartSuccessAt else { return false } + return Date().timeIntervalSince(sentAt) < LiveActivityManager.pushToStartAdoptionWindow + } + + /// Foreground-only local creation via Activity.request(pushType: .token). + /// Returns false when creation is not possible; the caller decides whether + /// to fall back to push-to-start. APNs credentials stay a hard prerequisite + /// even here — background renewal and updates depend on them. + @MainActor + private func attemptLocalCreate( + reason: String, + oldActivity: Activity?, + snapshot: GlucoseSnapshot? = nil + ) -> Bool { + let keyId = Storage.shared.lfKeyId.value + let apnsKey = Storage.shared.lfApnsKey.value + guard APNsCredentialValidator.isFullyConfigured(keyId: keyId, apnsKey: apnsKey) else { + LogManager.shared.log( + category: .general, + message: "[LA] local create (\(reason)) blocked — APNs credentials missing" + ) + return false + } + + let workingSnapshot = snapshot ?? buildWorkingSnapshot() + seq += 1 + let nextSeq = seq + let state = GlucoseLiveActivityAttributes.ContentState( + snapshot: workingSnapshot.withRenewalOverlay(false), + seq: nextSeq, + reason: reason, + producedAt: Date(), + ) + let staleDate = Date().addingTimeInterval(LiveActivityManager.renewalThreshold) + let content = ActivityContent( + state: state, + staleDate: staleDate, + relevanceScore: 100.0, + ) + + do { + let activity = try Activity.request( + attributes: GlucoseLiveActivityAttributes(title: "LoopFollow"), + content: content, + pushType: .token, + ) + LogManager.shared.log( + category: .general, + message: "[LA] local create (\(reason)) succeeded id=\(activity.id) seq=\(nextSeq)" + ) + Storage.shared.laRenewBy.value = staleDate.timeIntervalSince1970 + Storage.shared.laRenewalFailed.value = false + cancelRenewalFailedNotification() + pendingForegroundRestart = false + // The new activity's token arrives through its own observation; + // the old token must not be used for updates meanwhile. + pushToken = nil + lastPushToStartSuccessAt = nil + pushToStartSendsWithoutAdoption = 0 + Storage.shared.laPushToStartBackoff.value = 0 + // Bind before ending the old activity so its .dismissed is never + // misclassified as a user swipe. + bind(to: activity, logReason: "local-create-\(reason)") + if let oldActivity, oldActivity.id != activity.id { + Task { + await oldActivity.end(nil, dismissalPolicy: .immediate) + } + } + return true + } catch { + LogManager.shared.log( + category: .general, + message: "[LA] local create (\(reason)) failed: \(error.localizedDescription)" + ) + return false + } + } + + /// Snapshot fallback chain for creation paths that were not handed one. + private func buildWorkingSnapshot() -> GlucoseSnapshot { + let provider = StorageCurrentGlucoseStateProvider() + return GlucoseSnapshotBuilder.build(from: provider) + ?? GlucoseSnapshotStore.shared.load() + ?? GlucoseSnapshot( + glucose: 0, + delta: 0, + trend: .unknown, + updatedAt: Date(), + iob: nil, + cob: nil, + projected: nil, + unit: .mgdl, + isNotLooping: false, + ) + } + // MARK: - Push-to-start - /// Single creation path. Handles initial start, renewal, and forced restart. + /// Background creation path (also the fallback when a local create fails). /// Verifies token + APNs credentials, applies backoff, then dispatches the /// APNs push-to-start call. The old activity is only ended after a confirmed /// successful send, preserving it if the send fails. Adoption is delivered @@ -711,23 +856,7 @@ final class LiveActivityManager { } // Build snapshot if caller didn't supply one (initial start path). - let workingSnapshot: GlucoseSnapshot = { - if let snapshot { return snapshot } - let provider = StorageCurrentGlucoseStateProvider() - return GlucoseSnapshotBuilder.build(from: provider) - ?? GlucoseSnapshotStore.shared.load() - ?? GlucoseSnapshot( - glucose: 0, - delta: 0, - trend: .unknown, - updatedAt: Date(), - iob: nil, - cob: nil, - projected: nil, - unit: .mgdl, - isNotLooping: false, - ) - }() + let workingSnapshot = snapshot ?? buildWorkingSnapshot() Task { [weak self] in guard let self else { return } @@ -904,6 +1033,12 @@ final class LiveActivityManager { } } + /// Publishes the range for the selected time-in-range mode, matching the chart and BG header. + private func writeThresholds() { + let thresholds = UnitSettingsStore.shared.effectiveThresholds() + LAAppGroupSettings.setThresholds(lowMgdl: thresholds.low, highMgdl: thresholds.high) + } + private func performRefresh(reason: String) { let provider = StorageCurrentGlucoseStateProvider() guard let snapshot = GlucoseSnapshotBuilder.build(from: provider) else { @@ -928,14 +1063,11 @@ final class LiveActivityManager { let forceRefreshNeeded = timeSinceLastUpdate >= 5 * 60 // Capture dedup result BEFORE saving so the store comparison is valid. let snapshotUnchanged = GlucoseSnapshotStore.shared.load() == snapshot + let thresholdsChanged = LAAppGroupSettings.thresholdsMgdl() != UnitSettingsStore.shared.effectiveThresholds() - // Store + Watch: always update, independent of LA state. - LAAppGroupSettings.setThresholds( - lowMgdl: Storage.shared.lowLine.value, - highMgdl: Storage.shared.highLine.value, - ) + // Store: always update, independent of LA state. + writeThresholds() GlucoseSnapshotStore.shared.save(snapshot) - // WatchConnectivityManager.shared.send(snapshot: snapshot) // LA update: gated on LA being active, snapshot having changed, and activities enabled. if !Storage.shared.laEnabled.value { @@ -946,7 +1078,7 @@ final class LiveActivityManager { LogManager.shared.log(category: .general, message: "[LA] refresh: LA update skipped — dismissedByUser=true reason=\(reason)") return } - guard !snapshotUnchanged || forceRefreshNeeded else { return } + guard !snapshotUnchanged || thresholdsChanged || forceRefreshNeeded else { return } guard ActivityAuthorizationInfo().areActivitiesEnabled else { LogManager.shared.log(category: .general, message: "[LA] refresh: LA update skipped — areActivitiesEnabled=false reason=\(reason)") return @@ -1125,7 +1257,7 @@ final class LiveActivityManager { // endingForRestart and turning the eventual iOS dismissal into a misclassified // user swipe. Drive the restart synchronously instead. Task { @MainActor [weak self] in - self?.attemptPushToStartCreate(reason: "expired-token", oldActivity: nil) + self?.createActivity(reason: "expired-token", oldActivity: nil) } } diff --git a/LoopFollow/LiveActivity/RefreshCoalescer.swift b/LoopFollow/LiveActivity/RefreshCoalescer.swift new file mode 100644 index 000000000..85af21c95 --- /dev/null +++ b/LoopFollow/LiveActivity/RefreshCoalescer.swift @@ -0,0 +1,54 @@ +// LoopFollow +// RefreshCoalescer.swift + +import Foundation + +/// Coalesces refresh requests into one trailing call per window. +/// +/// The first request schedules the call; requests that arrive while it is +/// pending join it without moving its deadline. A steady stream of requests +/// therefore flushes once per window. Not thread-safe: use from one queue. +final class RefreshCoalescer { + typealias Scheduler = (TimeInterval, @escaping () -> Void) -> Void + + private let delay: TimeInterval + private let scheduler: Scheduler + private let action: (String) -> Void + private var pendingReasons: [String] = [] + private var generation = 0 + + init( + delay: TimeInterval, + scheduler: @escaping Scheduler = { delay, work in + DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: work) + }, + action: @escaping (String) -> Void + ) { + self.delay = delay + self.scheduler = scheduler + self.action = action + } + + var isPending: Bool { !pendingReasons.isEmpty } + + func request(reason: String) { + let wasPending = isPending + if !pendingReasons.contains(reason) { + pendingReasons.append(reason) + } + guard !wasPending else { return } + + let scheduledGeneration = generation + scheduler(delay) { [weak self] in + guard let self, self.generation == scheduledGeneration else { return } + let reason = self.pendingReasons.joined(separator: ",") + self.cancel() + self.action(reason) + } + } + + func cancel() { + pendingReasons.removeAll() + generation += 1 + } +} diff --git a/LoopFollow/LiveActivity/StorageCurrentGlucoseStateProvider.swift b/LoopFollow/LiveActivity/StorageCurrentGlucoseStateProvider.swift index 76b3664a4..30337ac12 100644 --- a/LoopFollow/LiveActivity/StorageCurrentGlucoseStateProvider.swift +++ b/LoopFollow/LiveActivity/StorageCurrentGlucoseStateProvider.swift @@ -141,6 +141,10 @@ struct StorageCurrentGlucoseStateProvider: CurrentGlucoseStateProviding { return Date().timeIntervalSince1970 - lastLoopTime >= 15 * 60 } + var cgmSensorState: String? { + Observable.shared.cgmSensorState.value?.summary + } + // MARK: - Renewal var showRenewalOverlay: Bool { diff --git a/LoopFollow/Onboarding/Steps/UnitsStepView.swift b/LoopFollow/Onboarding/Steps/UnitsStepView.swift index 25138e611..00b4d333a 100644 --- a/LoopFollow/Onboarding/Steps/UnitsStepView.swift +++ b/LoopFollow/Onboarding/Steps/UnitsStepView.swift @@ -19,23 +19,10 @@ struct UnitsStepView: View { var body: some View { VStack(spacing: 0) { - Form { - switch page { - case .units: - header( - systemImage: "ruler", - title: "Units", - subtitle: "Choose how glucose values are displayed. You can change this later in Settings." - ) - UnitsConfigurationView(sections: .units) - case .statistics: - header( - systemImage: "chart.bar.xaxis", - title: "Statistics", - subtitle: "Choose how LoopFollow's statistics are measured and displayed." - ) - UnitsConfigurationView(sections: .statistics) - } + // Lets the range mode row push its list; the bar only shows on that list. + NavigationStack { + form + .toolbar(.hidden, for: .navigationBar) } footer @@ -44,6 +31,27 @@ struct UnitsStepView: View { .onChange(of: page) { _ in reportProgress() } } + private var form: some View { + Form { + switch page { + case .units: + header( + systemImage: "ruler", + title: "Units", + subtitle: "Choose how glucose values are displayed. You can change this later in Settings." + ) + UnitsConfigurationView(sections: .units) + case .statistics: + header( + systemImage: "chart.bar.xaxis", + title: "Statistics", + subtitle: "Choose how LoopFollow's statistics are measured and displayed." + ) + UnitsConfigurationView(sections: .statistics) + } + } + } + private func reportProgress() { onboarding.phaseProgress = .init(page: page.rawValue, count: Page.allCases.count) } diff --git a/LoopFollow/Remote/LoopAPNS/LoopAPNSBolusView.swift b/LoopFollow/Remote/LoopAPNS/LoopAPNSBolusView.swift index 09222270c..59260eb55 100644 --- a/LoopFollow/Remote/LoopAPNS/LoopAPNSBolusView.swift +++ b/LoopFollow/Remote/LoopAPNS/LoopAPNSBolusView.swift @@ -307,8 +307,9 @@ struct LoopAPNSBolusView: View { private func loadRecommendedBolus() { // Load recommended bolus from Observable - recommendedBolus = Observable.shared.deviceRecBolus.value - lastLoopTime = Observable.shared.alertLastLoopTime.value + let recommended = RecommendedBolus.current() + recommendedBolus = recommended?.units + lastLoopTime = recommended?.at // Reset warning state when new data is loaded showOldCalculationWarning = false diff --git a/LoopFollow/Remote/LoopAPNS/LoopAPNSCarbsView.swift b/LoopFollow/Remote/LoopAPNS/LoopAPNSCarbsView.swift index 537fa29d2..1392e697e 100644 --- a/LoopFollow/Remote/LoopAPNS/LoopAPNSCarbsView.swift +++ b/LoopFollow/Remote/LoopAPNS/LoopAPNSCarbsView.swift @@ -623,29 +623,3 @@ struct LoopAPNSCarbsView: View { min(max(date, oldestAcceptedDate), latestAcceptedDate) } } - -// APNS Payload structure for carbs -struct LoopAPNSPayload { - enum PayloadType { - case carbs - case bolus - } - - let type: PayloadType - let carbsAmount: Double? - let absorptionTime: Double? - let foodType: String? - let bolusAmount: Double? - let consumedDate: Date? - let otp: String - - init(type: PayloadType, carbsAmount: Double? = nil, absorptionTime: Double? = nil, foodType: String? = nil, bolusAmount: Double? = nil, consumedDate: Date? = nil, otp: String) { - self.type = type - self.carbsAmount = carbsAmount - self.absorptionTime = absorptionTime - self.foodType = foodType - self.bolusAmount = bolusAmount - self.consumedDate = consumedDate - self.otp = otp - } -} diff --git a/LoopFollow/Remote/LoopAPNS/LoopAPNSRemoteView.swift b/LoopFollow/Remote/LoopAPNS/LoopAPNSRemoteView.swift index 73de2aae4..aedeb8e3e 100644 --- a/LoopFollow/Remote/LoopAPNS/LoopAPNSRemoteView.swift +++ b/LoopFollow/Remote/LoopAPNS/LoopAPNSRemoteView.swift @@ -7,6 +7,7 @@ struct LoopAPNSRemoteView: View { @Environment(\.presentationMode) var presentationMode @StateObject private var viewModel = RemoteSettingsViewModel() @ObservedObject private var activeOverrideNote = Observable.shared.override + @ObservedObject private var loopRemoteCommands = Storage.shared.loopRemoteCommands var body: some View { NavigationView { @@ -22,6 +23,16 @@ struct LoopAPNSRemoteView: View { CommandButtonView(command: "Meal", iconName: "fork.knife", destination: LoopAPNSCarbsView()) CommandButtonView(command: "Bolus", iconName: "syringe", destination: LoopAPNSBolusView()) CommandButtonView(command: "Overrides", iconName: "slider.horizontal.3", destination: OverridePresetsView(), isActive: activeOverrideNote.value != nil) + if loopRemoteCommands.value.contains(LoopProfile.switchCommand) { + CommandButtonView(command: "Profiles", iconName: "arrow.triangle.2.circlepath", destination: LoopProfilesView()) + } else { + UnavailableCommandButtonView( + command: "Profiles", + iconName: "arrow.triangle.2.circlepath", + title: "Profiles Not Available", + message: "Switching Loop profiles from LoopFollow needs Loop built with the Profile Save & Load customization, added with the Loop and Learn Customization Select script." + ) + } } .padding(.horizontal) } else { diff --git a/LoopFollow/Remote/LoopAPNS/LoopAPNSService.swift b/LoopFollow/Remote/LoopAPNS/LoopAPNSService.swift index 38dc4fb23..e5a641cca 100644 --- a/LoopFollow/Remote/LoopAPNS/LoopAPNSService.swift +++ b/LoopFollow/Remote/LoopAPNS/LoopAPNSService.swift @@ -10,15 +10,13 @@ class LoopAPNSService { /// Returns the effective APNs credentials for sending commands to the remote app. /// Same team → use LoopFollow's own key. Different team → use remote-specific key. private func effectiveCredentials() -> (apnsKey: String, keyId: String, teamId: String) { - let lfTeamId = BuildDetails.default.teamID ?? "" - let remoteTeamId = storage.teamId.value ?? "" - let sameTeam = !lfTeamId.isEmpty && !remoteTeamId.isEmpty && lfTeamId == remoteTeamId - - if sameTeam || remoteTeamId.isEmpty { - return (storage.lfApnsKey.value, storage.lfKeyId.value, lfTeamId) - } else { - return (storage.remoteApnsKey.value, storage.remoteKeyId.value, remoteTeamId) - } + let resolved = RemoteSigningKey.resolve( + lfTeamID: BuildDetails.default.teamID ?? "", + remoteTeamID: storage.teamId.value ?? "", + lfKey: (storage.lfApnsKey.value, storage.lfKeyId.value), + remoteKey: (storage.remoteApnsKey.value, storage.remoteKeyId.value) + ) + return (resolved.key, resolved.keyID, resolved.teamID) } enum LoopAPNSError: Error, LocalizedError { @@ -59,34 +57,6 @@ class LoopAPNSService { } } - private func createReturnNotificationInfo() -> ReturnNotificationInfo? { - let loopFollowDeviceToken = Observable.shared.loopFollowDeviceToken.value - guard !loopFollowDeviceToken.isEmpty else { return nil } - - // Get LoopFollow's own Team ID from BuildDetails. - guard let loopFollowTeamID = BuildDetails.default.teamID, !loopFollowTeamID.isEmpty else { - LogManager.shared.log(category: .apns, message: "LoopFollow Team ID not found in BuildDetails.plist. Cannot create return notification info.") - return nil - } - - let lfKeyId = storage.lfKeyId.value - let lfApnsKey = storage.lfApnsKey.value - - guard !lfKeyId.isEmpty, !lfApnsKey.isEmpty else { - LogManager.shared.log(category: .apns, message: "Missing LoopFollow APNS credentials. Configure them in App Settings → APN.") - return nil - } - - return ReturnNotificationInfo( - productionEnvironment: BuildDetails.default.isTestFlightBuild(), - deviceToken: loopFollowDeviceToken, - bundleId: Bundle.main.bundleIdentifier ?? "", - teamId: loopFollowTeamID, - keyId: lfKeyId, - apnsKey: lfApnsKey - ) - } - /// Encrypts return notification info using OTP code private func encryptReturnNotificationInfo(returnInfo: ReturnNotificationInfo, otpCode: String) -> String? { guard let messenger = OTPSecureMessenger(otpCode: otpCode) else { @@ -122,137 +92,105 @@ class LoopAPNSService { return hasFullSetup } - /// Sends carbs via APNS push notification - /// - Parameters: - /// - payload: The carbs payload to send - /// - completion: Completion handler with success status and error message - func sendCarbsViaAPNS(payload: LoopAPNSPayload, completion: @escaping (Bool, String?) -> Void) { + private static let returnNotificationRequiredMessage = "This command needs LoopFollow's own APNS credentials so Loop can confirm the result. Configure them in App Settings → APN." + + /// The return-notification block encrypted with the OTP, when LoopFollow's own credentials are configured. + private func encryptedReturnNotification(otp: String) -> String? { + guard let returnInfo = ReturnNotificationInfo.loopFollow() else { + LogManager.shared.log(category: .apns, message: "Return notification info unavailable; Loop cannot confirm this command") + return nil + } + return encryptReturnNotificationInfo(returnInfo: returnInfo, otpCode: otp) + } + + private func sendRemoteCommand(_ payload: [String: Any], completion: @escaping (Bool, String?) -> Void) { guard validateSetup() else { let errorMessage = "Loop APNS Configuration not valid" LogManager.shared.log(category: .apns, message: errorMessage) completion(false, errorMessage) return } - - let deviceToken = Storage.shared.deviceToken.value - let bundleIdentifier = Storage.shared.bundleId.value let creds = effectiveCredentials() - - // Create APNS notification payload (matching Loop's expected format) - let now = Date() - let expiration = Date(timeIntervalSinceNow: 5 * 60) // 5 minutes from now - - // Create the complete notification payload (matching Nightscout's exact format) - // Based on Nightscout's loop.js implementation - let carbsAmount = payload.carbsAmount ?? 0.0 - let absorptionTime = payload.absorptionTime ?? 3.0 - let startTime = payload.consumedDate ?? now - var finalPayload = [ - "carbs-entry": carbsAmount, - "absorption-time": absorptionTime, - "otp": String(payload.otp), - "remote-address": "LoopFollow", - "notes": "Sent via LoopFollow APNS", - "entered-by": "LoopFollow", - "sent-at": formatDateForAPNS(now), - "expiration": formatDateForAPNS(expiration), - "start-time": formatDateForAPNS(startTime), - "alert": "Remote Carbs Entry: \(String(format: "%.1f", carbsAmount)) grams\nAbsorption Time: \(String(format: "%.1f", absorptionTime)) hours", - ] as [String: Any] - - // Encrypt and include return notification info using OTP - if let returnInfo = createReturnNotificationInfo() { - LogManager.shared.log(category: .apns, message: "Created return notification info for carbs - deviceToken: \(LogRedactor.head(returnInfo.deviceToken)), bundleId: \(LogRedactor.bundleId(returnInfo.bundleId))") - if let encryptedReturnInfo = encryptReturnNotificationInfo(returnInfo: returnInfo, otpCode: String(payload.otp)) { - finalPayload["encrypted_return_notification"] = encryptedReturnInfo - LogManager.shared.log(category: .apns, message: "Added encrypted_return_notification to carbs payload, length: \(encryptedReturnInfo.count)") - } else { - LogManager.shared.log(category: .apns, message: "Failed to encrypt return notification info for carbs command") - } - } else { - LogManager.shared.log(category: .apns, message: "Failed to create return notification info for carbs command") - } - - // Log the exact carbs amount for debugging precision issues - LogManager.shared.log(category: .apns, message: "Carbs amount - Raw: \(payload.carbsAmount ?? 0.0), Formatted: \(String(format: "%.1f", carbsAmount)), JSON: \(carbsAmount)") - LogManager.shared.log(category: .apns, message: "Absorption time - Raw: \(payload.absorptionTime ?? 3.0), Formatted: \(String(format: "%.1f", absorptionTime)), JSON: \(absorptionTime)") - - // Log carbs entry attempt - LogManager.shared.log(category: .apns, message: "Sending carbs: \(String(format: "%.1f", carbsAmount))g, absorption: \(String(format: "%.1f", absorptionTime))h") - sendAPNSNotification( - deviceToken: deviceToken, - bundleIdentifier: bundleIdentifier, + deviceToken: Storage.shared.deviceToken.value, + bundleIdentifier: Storage.shared.bundleId.value, keyId: creds.keyId, apnsKey: creds.apnsKey, teamId: creds.teamId, - payload: finalPayload, + payload: payload, completion: completion ) } - /// Sends bolus via APNS push notification - /// - Parameters: - /// - payload: The bolus payload to send - /// - completion: Completion handler with success status and error message - func sendBolusViaAPNS(payload: LoopAPNSPayload, completion: @escaping (Bool, String?) -> Void) { - guard validateSetup() else { - let errorMessage = "Loop APNS Configuration not valid" - LogManager.shared.log(category: .apns, message: errorMessage) - completion(false, errorMessage) + /// Sends a command whose only confirmation is Loop's return push, so the encrypted return block is mandatory. + private func sendConfirmedCommand(_ payload: [String: Any], otp: String, completion: @escaping (Bool, String?) -> Void) { + guard let encryptedReturnInfo = encryptedReturnNotification(otp: otp) else { + completion(false, Self.returnNotificationRequiredMessage) return } + var payload = payload + payload["encrypted_return_notification"] = encryptedReturnInfo + sendRemoteCommand(payload, completion: completion) + } - let deviceToken = Storage.shared.deviceToken.value - let bundleIdentifier = Storage.shared.bundleId.value - let creds = effectiveCredentials() + /// Deletes the Loop carb entry with this `syncIdentifier`. Requires the Loop remote carb edit patch. + func sendCarbsDelete(syncIdentifier: String, otp: String, completion: @escaping (Bool, String?) -> Void) { + LogManager.shared.log(category: .apns, message: "Sending carbs delete for syncIdentifier=\(LogRedactor.tail(syncIdentifier))") + sendConfirmedCommand(LoopAPNSCommands.carbsDeleteCommandPayload(syncIdentifier: syncIdentifier, otp: otp), otp: otp, completion: completion) + } - // Create APNS notification payload (matching Loop's expected format) - let now = Date() - let expiration = Date(timeIntervalSinceNow: 5 * 60) // 5 minutes from now - - // Create the complete notification payload (matching Nightscout's exact format) - // Based on Nightscout's loop.js implementation - let bolusAmount = payload.bolusAmount ?? 0.0 - var finalPayload = [ - "bolus-entry": bolusAmount, - "otp": String(payload.otp), - "remote-address": "LoopFollow", - "notes": "Sent via LoopFollow APNS", - "entered-by": "LoopFollow", - "sent-at": formatDateForAPNS(now), - "expiration": formatDateForAPNS(expiration), - "alert": "Remote Bolus Entry: \(String(format: "%.2f", bolusAmount)) U", - ] as [String: Any] - - // Encrypt and include return notification info using OTP - if let returnInfo = createReturnNotificationInfo() { - LogManager.shared.log(category: .apns, message: "Created return notification info for carbs - deviceToken: \(LogRedactor.head(returnInfo.deviceToken)), bundleId: \(LogRedactor.bundleId(returnInfo.bundleId))") - if let encryptedReturnInfo = encryptReturnNotificationInfo(returnInfo: returnInfo, otpCode: String(payload.otp)) { - finalPayload["encrypted_return_notification"] = encryptedReturnInfo - LogManager.shared.log(category: .apns, message: "Added encrypted_return_notification to carbs payload, length: \(encryptedReturnInfo.count)") - } else { - LogManager.shared.log(category: .apns, message: "Failed to encrypt return notification info for carbs command") - } - } else { - LogManager.shared.log(category: .apns, message: "Failed to create return notification info for carbs command") - } + /// Replaces the Loop carb entry with this `syncIdentifier`. Requires the Loop remote carb edit patch. + func sendCarbsEdit( + syncIdentifier: String, + carbsAmount: Double, + absorptionTimeHours: Double, + foodType: String?, + consumedDate: Date, + otp: String, + completion: @escaping (Bool, String?) -> Void + ) { + LogManager.shared.log(category: .apns, message: "Sending carbs edit for syncIdentifier=\(LogRedactor.tail(syncIdentifier)): \(String(format: "%.1f", carbsAmount))g, absorption \(String(format: "%.1f", absorptionTimeHours))h") + let payload = LoopAPNSCommands.carbsEditCommandPayload( + syncIdentifier: syncIdentifier, + carbsAmount: carbsAmount, + absorptionTimeHours: absorptionTimeHours, + foodType: foodType, + consumedDate: consumedDate, + otp: otp + ) + sendConfirmedCommand(payload, otp: otp, completion: completion) + } - // Log the exact bolus amount for debugging precision issues - LogManager.shared.log(category: .apns, message: "Bolus amount - Raw: \(payload.bolusAmount ?? 0.0), Formatted: \(String(format: "%.2f", bolusAmount)), JSON: \(bolusAmount)") + /// Switches Loop to the saved profile `name`. Requires the Loop remote profile patch. + func sendProfileSwitch(name: String, fingerprint: String, otp: String, completion: @escaping (Bool, String?) -> Void) { + LogManager.shared.log(category: .apns, message: "Sending profile switch to \(name) fingerprint=\(fingerprint)") + sendConfirmedCommand(LoopAPNSCommands.profileSwitchCommandPayload(name: name, fingerprint: fingerprint, otp: otp), otp: otp, completion: completion) + } - // Log bolus entry attempt - LogManager.shared.log(category: .apns, message: "Sending bolus: \(String(format: "%.2f", bolusAmount))U") + /// Sends carbs via APNS push notification + /// - Parameters: + /// - payload: The carbs payload to send + /// - completion: Completion handler with success status and error message + func sendCarbsViaAPNS(payload: LoopAPNSPayload, completion: @escaping (Bool, String?) -> Void) { + var finalPayload = LoopAPNSCommands.carbsCommandPayload(payload) + if let encryptedReturnInfo = encryptedReturnNotification(otp: payload.otp) { + finalPayload["encrypted_return_notification"] = encryptedReturnInfo + } + LogManager.shared.log(category: .apns, message: "Sending carbs: \(String(format: "%.1f", payload.carbsAmount ?? 0.0))g, absorption: \(String(format: "%.1f", payload.absorptionTime ?? 3.0))h") + sendRemoteCommand(finalPayload, completion: RemoteCommandRefresh.following(.meal(grams: payload.carbsAmount ?? 0, bolus: 0), completion)) + } - sendAPNSNotification( - deviceToken: deviceToken, - bundleIdentifier: bundleIdentifier, - keyId: creds.keyId, - apnsKey: creds.apnsKey, - teamId: creds.teamId, - payload: finalPayload, - completion: completion - ) + /// Sends bolus via APNS push notification + /// - Parameters: + /// - payload: The bolus payload to send + /// - completion: Completion handler with success status and error message + func sendBolusViaAPNS(payload: LoopAPNSPayload, completion: @escaping (Bool, String?) -> Void) { + var finalPayload = LoopAPNSCommands.bolusCommandPayload(payload) + if let encryptedReturnInfo = encryptedReturnNotification(otp: payload.otp) { + finalPayload["encrypted_return_notification"] = encryptedReturnInfo + } + LogManager.shared.log(category: .apns, message: "Sending bolus: \(String(format: "%.2f", payload.bolusAmount ?? 0.0))U") + sendRemoteCommand(finalPayload, completion: RemoteCommandRefresh.following(.bolus(units: payload.bolusAmount ?? 0), completion)) } /// Validates APNS credentials similar to PushNotificationManager @@ -305,15 +243,6 @@ class LoopAPNSService { return regex?.firstMatch(in: text, options: [], range: range) != nil } - /// Provides simple environment guidance for APNS configuration - /// - Returns: String with simple guidance to try opposite setting - private func getEnvironmentGuidance() -> String { - let currentSetting = storage.productionEnvironment.value ? "ON" : "OFF" - let trySetting = storage.productionEnvironment.value ? "OFF" : "ON" - - return "Try changing Production Environment from \(currentSetting) to \(trySetting) in your Loop APNS settings." - } - /// Sends an APNS notification /// - Parameters: /// - deviceToken: The device token to send to @@ -339,146 +268,37 @@ class LoopAPNSService { return } - // Create JWT token for APNS authentication - guard let jwt = JWTManager.shared.getOrGenerateJWT(keyId: keyId, teamId: teamId, apnsKey: apnsKey) else { - let errorMessage = "Failed to generate JWT, please check that the APNS Key ID, APNS Key and Team ID are correct." - LogManager.shared.log(category: .apns, message: errorMessage) - completion(false, errorMessage) - return - } - - // Determine APNS environment - let isProduction = storage.productionEnvironment.value - let apnsURL = isProduction ? "https://api.push.apple.com" : "https://api.sandbox.push.apple.com" - guard let requestURL = URL(string: "\(apnsURL)/3/device/\(deviceToken)") else { - let errorMessage = "Failed to construct APNs URL" - LogManager.shared.log(category: .apns, message: errorMessage) - completion(false, errorMessage) - return - } - - var request = URLRequest(url: requestURL) - request.httpMethod = "POST" - request.setValue("application/json", forHTTPHeaderField: "content-type") - request.setValue("bearer \(jwt)", forHTTPHeaderField: "authorization") - request.setValue(bundleIdentifier, forHTTPHeaderField: "apns-topic") - request.setValue("alert", forHTTPHeaderField: "apns-push-type") - request.setValue("10", forHTTPHeaderField: "apns-priority") // High priority - // Validate bundle identifier format if !bundleIdentifier.contains(".") { LogManager.shared.log(category: .apns, message: "Warning: Bundle identifier may be in wrong format: \(bundleIdentifier)") } - // Create the proper APNS payload structure (matching @parse/node-apn format) - var apnsPayload: [String: Any] = [ - "aps": [ - "alert": payload["alert"] as? String ?? "", - "content-available": 1, - "interruption-level": "time-sensitive", - ], - ] - - // Add all the custom payload fields (excluding APNS-specific fields) - for (key, value) in payload { - if key != "alert", key != "content-available", key != "interruption-level" { - apnsPayload[key] = value - } - } - - // Remove nil values to clean up the payload - let cleanPayload = apnsPayload.compactMapValues { $0 } - - // Log if encrypted_return_notification is in the payload - if cleanPayload["encrypted_return_notification"] != nil { + if payload["encrypted_return_notification"] != nil { LogManager.shared.log(category: .apns, message: "encrypted_return_notification is present in final APNS payload") } else { - LogManager.shared.log(category: .apns, message: "WARNING: encrypted_return_notification is NOT in final APNS payload. Available keys: \(Array(cleanPayload.keys).joined(separator: ", "))") + LogManager.shared.log(category: .apns, message: "WARNING: encrypted_return_notification is NOT in final APNS payload. Available keys: \(Array(payload.keys).joined(separator: ", "))") } + let body: Data do { - let jsonData = try JSONSerialization.data(withJSONObject: cleanPayload) - - request.httpBody = jsonData - - let task = URLSession.shared.dataTask(with: request) { data, response, error in - if let error = error { - let errorMessage = "Failed to send push notification: \(error.localizedDescription)" - LogManager.shared.log(category: .apns, message: errorMessage) - completion(false, errorMessage) - return - } - - if let httpResponse = response as? HTTPURLResponse { - var responseBodyMessage = "" - if let data = data, let responseBody = String(data: data, encoding: .utf8) { - if let json = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any], - let reason = json["reason"] as? String - { - responseBodyMessage = reason - } - } - - switch httpResponse.statusCode { - case 200: - LogManager.shared.log(category: .apns, message: "APNS notification sent successfully") - completion(true, nil) - case 400: - let environmentGuidance = self.getEnvironmentGuidance() - let errorMessage = "Bad request. The request was invalid or malformed. \(responseBodyMessage)\n\n\(environmentGuidance)" - LogManager.shared.log(category: .apns, message: "APNS error 400: \(responseBodyMessage) - Check device token and environment settings") - completion(false, errorMessage) - case 403: - JWTManager.shared.invalidateCache() - let errorMessage = "Authentication error. Check your certificate or authentication token. \(responseBodyMessage)" - LogManager.shared.log(category: .apns, message: "APNS error 403: \(responseBodyMessage) - Check APNS key permissions for bundle ID") - completion(false, errorMessage) - case 404: - let errorMessage = "Invalid request: The :path value was incorrect. \(responseBodyMessage)" - LogManager.shared.log(category: .apns, message: "APNS error 404: \(responseBodyMessage)") - completion(false, errorMessage) - case 405: - let errorMessage = "Invalid request: Only POST requests are supported. \(responseBodyMessage)" - LogManager.shared.log(category: .apns, message: "APNS error 405: \(responseBodyMessage)") - completion(false, errorMessage) - case 410: - let errorMessage = "The device token is no longer active for the topic. \(responseBodyMessage)" - LogManager.shared.log(category: .apns, message: "APNS error 410: Device token is invalid or expired") - completion(false, errorMessage) - case 413: - let errorMessage = "Payload too large. The notification payload exceeded the size limit. \(responseBodyMessage)" - LogManager.shared.log(category: .apns, message: "APNS error 413: \(responseBodyMessage)") - completion(false, errorMessage) - case 429: - let errorMessage = "Too many requests. \(responseBodyMessage)" - LogManager.shared.log(category: .apns, message: "APNS error 429: Rate limited - wait before retrying") - completion(false, errorMessage) - case 500: - let errorMessage = "Internal server error at APNs. \(responseBodyMessage)" - LogManager.shared.log(category: .apns, message: "APNS error 500: \(responseBodyMessage)") - completion(false, errorMessage) - case 503: - let errorMessage = "Service unavailable. The server is temporarily unavailable. Try again later. \(responseBodyMessage)" - LogManager.shared.log(category: .apns, message: "APNS error 503: \(responseBodyMessage)") - completion(false, errorMessage) - default: - let errorMessage = "Unexpected status code: \(httpResponse.statusCode). \(responseBodyMessage)" - LogManager.shared.log(category: .apns, message: "APNS error \(httpResponse.statusCode): \(responseBodyMessage)") - completion(false, errorMessage) - } - } else { - let errorMessage = "Failed to get a valid HTTP response." - LogManager.shared.log(category: .apns, message: errorMessage) - completion(false, errorMessage) - } - } - task.resume() - + body = try LoopAPNSCommands.apnsBody(payload) } catch { let errorMessage = "Failed to serialize APNS payload: \(error.localizedDescription)" LogManager.shared.log(category: .apns, message: errorMessage) completion(false, errorMessage) + return } + + APNSDelivery.send( + body: body, + deviceToken: deviceToken, + topic: bundleIdentifier, + production: storage.productionEnvironment.value, + keyId: keyId, + teamId: teamId, + apnsKey: apnsKey, + completion: completion + ) } /// Validates and fixes APNS key format if needed @@ -629,15 +449,6 @@ class LoopAPNSService { // MARK: - Date Formatting Helper - /// Creates a properly formatted ISO8601 date string with milliseconds (matching Nightscout's format) - /// - Parameter date: The date to format - /// - Returns: Formatted date string like "2022-12-24T21:34:02.090Z" - private func formatDateForAPNS(_ date: Date) -> String { - let dateFormatter = ISO8601DateFormatter() - dateFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - return dateFormatter.string(from: date) - } - // MARK: - Override Methods func sendOverrideNotification(presetName: String, duration: TimeInterval? = nil, completion: @escaping (Bool, String?) -> Void) { @@ -657,47 +468,7 @@ class LoopAPNSService { return } - // Create APNS notification payload (matching Loop's expected format) - let now = Date() - let expiration = Date(timeIntervalSinceNow: 5 * 60) // 5 minutes from now - - // Create alert text (matching Nightscout's format) - var alertText = "\(presetName) Temporary Override" - if let duration = duration, duration > 0 { - let hours = Int(duration / 3600) - let minutes = Int((duration.truncatingRemainder(dividingBy: 3600)) / 60) - if hours > 0 { - alertText += " (\(hours)h \(minutes)m)" - } else { - alertText += " (\(minutes)m)" - } - } - - var payload: [String: Any] = [ - "override-name": presetName, - "remote-address": "LoopFollow", - "entered-by": "LoopFollow", - "sent-at": formatDateForAPNS(now), - "expiration": formatDateForAPNS(expiration), - "alert": alertText, - ] - - if let duration = duration { - payload["override-duration-minutes"] = Int(duration / 60) - } - - // For override commands, we can include return notification info unencrypted - // since override commands don't require OTP validation in Loop - if let returnInfo = createReturnNotificationInfo() { - payload["return_notification"] = [ - "production_environment": returnInfo.productionEnvironment, - "device_token": returnInfo.deviceToken, - "bundle_id": returnInfo.bundleId, - "team_id": returnInfo.teamId, - "key_id": returnInfo.keyId, - "apns_key": returnInfo.apnsKey, - ] - } + let payload = LoopAPNSCommands.overridePayload(presetName: presetName, duration: duration, returnInfo: ReturnNotificationInfo.loopFollow()) // Send the notification using the existing APNS infrastructure let creds = effectiveCredentials() @@ -708,7 +479,7 @@ class LoopAPNSService { apnsKey: creds.apnsKey, teamId: creds.teamId, payload: payload, - completion: completion + completion: RemoteCommandRefresh.following(.override, completion) ) } @@ -729,31 +500,7 @@ class LoopAPNSService { return } - // Create APNS notification payload (matching Loop's expected format) - let now = Date() - let expiration = Date(timeIntervalSinceNow: 5 * 60) // 5 minutes from now - - var payload: [String: Any] = [ - "cancel-temporary-override": "true", - "remote-address": "LoopFollow", - "entered-by": "LoopFollow", - "sent-at": formatDateForAPNS(now), - "expiration": formatDateForAPNS(expiration), - "alert": "Cancel Temporary Override", - ] - - // For override commands, we can include return notification info unencrypted - // since override commands don't require OTP validation in Loop - if let returnInfo = createReturnNotificationInfo() { - payload["return_notification"] = [ - "production_environment": returnInfo.productionEnvironment, - "device_token": returnInfo.deviceToken, - "bundle_id": returnInfo.bundleId, - "team_id": returnInfo.teamId, - "key_id": returnInfo.keyId, - "apns_key": returnInfo.apnsKey, - ] - } + let payload = LoopAPNSCommands.cancelOverridePayload(returnInfo: ReturnNotificationInfo.loopFollow()) // Send the notification using the existing APNS infrastructure let creds = effectiveCredentials() @@ -764,7 +511,7 @@ class LoopAPNSService { apnsKey: creds.apnsKey, teamId: creds.teamId, payload: payload, - completion: completion + completion: RemoteCommandRefresh.following(.override, completion) ) } } diff --git a/LoopFollow/Remote/LoopAPNS/LoopCarbEditSheet.swift b/LoopFollow/Remote/LoopAPNS/LoopCarbEditSheet.swift new file mode 100644 index 000000000..fdf10c8f8 --- /dev/null +++ b/LoopFollow/Remote/LoopAPNS/LoopCarbEditSheet.swift @@ -0,0 +1,194 @@ +// LoopFollow +// LoopCarbEditSheet.swift + +import HealthKit +import SwiftUI + +/// Edit form for a carb entry already logged in Loop. Loop replaces the entry, keeping its sync identifier. +struct LoopCarbEditSheet: View { + let carb: LoopCarbTreatment + + @Environment(\.presentationMode) private var presentationMode + @State private var carbsAmount: HKQuantity + @State private var absorptionHours: Int + @State private var absorptionMinutes: Int + @State private var foodType: String + @State private var consumedDate: Date + @State private var isSending = false + @State private var alertType: AlertType? + @FocusState private var carbsFocused: Bool + + private let maxPastHours = 12 + private let maxFutureHours = 1 + private let minAbsorptionHours = 0.5 + private let maxAbsorptionHours = 8 + + private enum AlertType: Identifiable { + case confirm + case validation(String) + case sendFailed(String) + + var id: String { + switch self { + case .confirm: return "confirm" + case let .validation(message): return "validation-\(message)" + case let .sendFailed(message): return "failed-\(message)" + } + } + } + + init(carb: LoopCarbTreatment) { + self.carb = carb + _carbsAmount = State(initialValue: HKQuantity(unit: .gram(), doubleValue: carb.carbs)) + let totalMinutes = Int((carb.absorptionMinutes ?? 180).rounded()) + _absorptionHours = State(initialValue: totalMinutes / 60) + _absorptionMinutes = State(initialValue: totalMinutes % 60 >= 30 ? 30 : 0) + _foodType = State(initialValue: carb.foodType ?? "") + _consumedDate = State(initialValue: Date(timeIntervalSince1970: carb.date)) + } + + private var absorptionTimeValue: Double { + Double(absorptionHours) + Double(absorptionMinutes) / 60 + } + + private var dateRange: ClosedRange { + let now = Date() + let lower = min(now.addingTimeInterval(-TimeInterval(maxPastHours) * 3600), consumedDate) + return lower ... now.addingTimeInterval(TimeInterval(maxFutureHours) * 3600) + } + + var body: some View { + NavigationView { + Form { + Section(header: Text("Current")) { + LabeledValueRow(label: "Carbs", value: String(format: "%.0f g", carb.carbs)) + if let hours = carb.absorptionHours { + LabeledValueRow(label: "Absorption", value: String(format: "%.1f h", hours)) + } + if let foodType = carb.foodType { + LabeledValueRow(label: "Food type", value: foodType) + } + } + + Section(header: Text("New values"), footer: Text("Loop replaces the entry with these values and keeps its history.")) { + HKQuantityInputView( + label: "Carbs", + quantity: $carbsAmount, + unit: .gram(), + maxLength: 4, + minValue: HKQuantity(unit: .gram(), doubleValue: 1), + maxValue: Storage.shared.maxCarbs.value, + isFocused: $carbsFocused, + onValidationError: { alertType = .validation($0) } + ) + HStack { + Text("Absorption") + Spacer() + Picker("Hours", selection: $absorptionHours) { + ForEach(0 ... maxAbsorptionHours, id: \.self) { Text("\($0) hr").tag($0) } + } + .labelsHidden() + Picker("Minutes", selection: $absorptionMinutes) { + ForEach([0, 30], id: \.self) { Text("\($0) min").tag($0) } + } + .labelsHidden() + } + TextField("Food type (optional)", text: $foodType) + DatePicker("Time", selection: $consumedDate, in: dateRange, displayedComponents: [.date, .hourAndMinute]) + .environment(\.timeZone, dateTimeUtils.displayTimeZone()) + } + } + .safeAreaInset(edge: .bottom) { + Button { + carbsFocused = false + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { validateAndConfirm() } + } label: { + if isSending { + HStack { + ProgressView().scaleEffect(0.8) + Text("Sending...") + } + .frame(maxWidth: .infinity) + } else { + Text("Update Carbs").frame(maxWidth: .infinity) + } + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + .disabled(isSending) + .padding(.horizontal) + .padding(.vertical, 8) + .background(.bar) + } + .navigationTitle("Edit Carbs") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { presentationMode.wrappedValue.dismiss() } + } + } + .alert(item: $alertType) { type in + switch type { + case .confirm: + return Alert( + title: Text("Update carbs in Loop?"), + message: Text(confirmationMessage), + primaryButton: .default(Text("Update"), action: send), + secondaryButton: .cancel() + ) + case let .validation(message): + return Alert(title: Text("Validation Error"), message: Text(message), dismissButton: .default(Text("OK"))) + case let .sendFailed(message): + return Alert(title: Text("Not Sent"), message: Text(message), dismissButton: .default(Text("OK"))) + } + } + } + } + + private var confirmationMessage: String { + let formatter = DateFormatter() + formatter.dateStyle = .short + formatter.timeStyle = .short + dateTimeUtils.applyDisplayTimeZone(to: formatter) + var lines = [ + String(format: "Carbs: %.0f g", carbsAmount.doubleValue(for: .gram())), + String(format: "Absorption: %.1f h", absorptionTimeValue), + ] + if !foodType.isEmpty { lines.append("Food type: \(foodType)") } + lines.append("Time: \(formatter.string(from: consumedDate))") + return lines.joined(separator: "\n") + } + + private func validateAndConfirm() { + guard alertType == nil else { return } + guard carbsAmount.doubleValue(for: .gram()) > 0 else { + alertType = .validation("Enter a carb amount, or use Delete carbs to remove the entry.") + return + } + guard absorptionTimeValue >= minAbsorptionHours, absorptionTimeValue <= Double(maxAbsorptionHours) else { + alertType = .validation(String(format: "Please enter a valid absorption time between %.1f and %d hours", minAbsorptionHours, maxAbsorptionHours)) + return + } + alertType = .confirm + } + + private func send() { + isSending = true + RemoteCommandTracker.shared.sendLoopCarbEdit( + carb: carb, + carbsAmount: carbsAmount.doubleValue(for: .gram()), + absorptionHours: absorptionTimeValue, + foodType: foodType.isEmpty ? nil : foodType, + consumedDate: consumedDate + ) { success, error in + DispatchQueue.main.async { + isSending = false + if success { + presentationMode.wrappedValue.dismiss() + } else { + alertType = .sendFailed(error ?? "Failed to send the carb update.") + } + } + } + } +} diff --git a/LoopFollow/Remote/LoopAPNS/LoopProfile.swift b/LoopFollow/Remote/LoopAPNS/LoopProfile.swift new file mode 100644 index 000000000..99b9c7a5b --- /dev/null +++ b/LoopFollow/Remote/LoopAPNS/LoopProfile.swift @@ -0,0 +1,108 @@ +// LoopFollow +// LoopProfile.swift + +import CryptoKit +import Foundation +import HealthKit + +/// A saved Loop profile from the Nightscout profile store, offered for a remote switch. +struct LoopProfile: Identifiable, Equatable { + struct Item: Equatable { + let seconds: Int + let value: Double + } + + struct RangeItem: Equatable { + let seconds: Int + let low: Double + let high: Double + } + + /// Loop lists this in `remoteCommands` when it accepts remote profile switches. + static let switchCommand = "profile-switch" + + let name: String + let units: HKUnit + let basal: [Item] + let carbRatio: [Item] + let sensitivity: [Item] + let correctionRange: [RangeItem] + /// Sent with the switch command. Loop only switches when its saved profile has the same fingerprint. + let fingerprint: String + + var id: String { name } + + init(name: String, store: NSProfile.Store) { + func items(_ entries: [(Double, Double)]) -> [Item] { + entries.map { Item(seconds: Int($0.0), value: $0.1) } + } + let basal = items(store.basal.map { ($0.timeAsSeconds, $0.value) }) + let carbRatio = items(store.carbratio.map { ($0.timeAsSeconds, $0.value) }) + let sensitivity = items(store.sens.map { ($0.timeAsSeconds, $0.value) }) + let targetLow = items((store.target_low ?? []).map { ($0.timeAsSeconds, $0.value) }) + let targetHigh = items((store.target_high ?? []).map { ($0.timeAsSeconds, $0.value) }) + + self.name = name + units = store.units.lowercased() == "mg/dl" ? .milligramsPerDeciliter : .millimolesPerLiter + self.basal = basal + self.carbRatio = carbRatio + self.sensitivity = sensitivity + correctionRange = zip(targetLow, targetHigh).map { RangeItem(seconds: $0.seconds, low: $0.value, high: $1.value) } + fingerprint = Self.fingerprint(basal: basal, carbRatio: carbRatio, sensitivity: sensitivity, targetLow: targetLow, targetHigh: targetHigh) + } + + /// Scheduled basal over a full day, in units. + var basalTotal: Double { + basal.enumerated().reduce(0) { total, entry in + let end = entry.offset + 1 < basal.count ? basal[entry.offset + 1].seconds : 24 * 3600 + return total + entry.element.value * Double(end - entry.element.seconds) / 3600 + } + } + + /// Saved profiles in a Loop profile document, by name. `Default` mirrors the active settings and is left out. + static func profiles(from profile: NSProfile) -> [LoopProfile] { + profile.store + .filter { $0.key != "Default" } + .map { LoopProfile(name: $0.key, store: $0.value) } + .sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending } + } + + /// Matches Loop's fingerprint of a profile store entry: + /// `basal=:,...;carbratio=...;sens=...;target_low=...;target_high=...` with values to three + /// decimals, hashed with SHA-256, first 16 hex characters. + static func fingerprint(basal: [Item], carbRatio: [Item], sensitivity: [Item], targetLow: [Item], targetHigh: [Item]) -> String { + func encode(_ items: [Item]) -> String { + items.map { "\($0.seconds):" + String(format: "%.3f", $0.value) }.joined(separator: ",") + } + let canonical = [ + "basal=" + encode(basal), + "carbratio=" + encode(carbRatio), + "sens=" + encode(sensitivity), + "target_low=" + encode(targetLow), + "target_high=" + encode(targetHigh), + ].joined(separator: ";") + let digest = SHA256.hash(data: Data(canonical.utf8)) + return String(digest.map { String(format: "%02x", $0) }.joined().prefix(16)) + } +} + +/// The saved profiles from the latest Loop profile upload. +struct LoopProfileSet: Equatable { + let profiles: [LoopProfile] + let activeName: String + let uploadedAt: Date? + + init(profiles: [LoopProfile], activeName: String, uploadedAt: Date?) { + self.profiles = profiles + self.activeName = activeName + self.uploadedAt = uploadedAt + } + + init(_ profile: NSProfile) { + self.init( + profiles: LoopProfile.profiles(from: profile), + activeName: profile.defaultProfile, + uploadedAt: profile.startDate.flatMap { NightscoutUtils.parseDate($0) } + ) + } +} diff --git a/LoopFollow/Remote/LoopAPNS/LoopProfilesView.swift b/LoopFollow/Remote/LoopAPNS/LoopProfilesView.swift new file mode 100644 index 000000000..05733b530 --- /dev/null +++ b/LoopFollow/Remote/LoopAPNS/LoopProfilesView.swift @@ -0,0 +1,272 @@ +// LoopFollow +// LoopProfilesView.swift + +import HealthKit +import SwiftUI + +/// Lists the profiles saved in Loop. Selecting one shows its values before it can be activated. +struct LoopProfilesView: View { + @ObservedObject private var loopProfiles = Observable.shared.loopProfiles + + var body: some View { + List { + if let profileSet = loopProfiles.value, !profileSet.profiles.isEmpty { + Section( + header: Text("Saved profiles"), + footer: uploadedFooter(profileSet.uploadedAt) + ) { + ForEach(profileSet.profiles) { profile in + NavigationLink(destination: LoopProfilePreviewView(profile: profile)) { + HStack { + Text(profile.name) + Spacer() + if profile.name == profileSet.activeName { + Text("Active") + .font(.caption) + .foregroundColor(.green) + } + } + } + } + } + } else { + Text("No saved profiles found. Profiles are saved in Loop under Settings → Profiles.") + .foregroundColor(.secondary) + .italic() + } + } + .navigationBarTitle("Remote Profiles", displayMode: .inline) + } + + private func uploadedFooter(_ date: Date?) -> Text { + guard let date else { return Text("") } + let formatter = DateFormatter() + formatter.dateStyle = .short + formatter.timeStyle = .short + return Text("Uploaded by Loop \(formatter.string(from: date)).") + } +} + +/// Shows the values a profile switch would activate, the same sections Loop shows before loading a profile. +struct LoopProfilePreviewView: View { + let profile: LoopProfile + + @Environment(\.presentationMode) private var presentationMode + @ObservedObject private var loopProfiles = Observable.shared.loopProfiles + @ObservedObject private var tracker = RemoteCommandTracker.shared + @State private var alertType: AlertType? + + private enum AlertType: Identifiable { + case confirm + case result(success: Bool, message: String) + + var id: String { + switch self { + case .confirm: return "confirm" + case let .result(success, message): return "result-\(success)-\(message)" + } + } + } + + private var commandKey: String { + LoopProfileAckAdapter.key(for: profile.name) + } + + private var isActive: Bool { + loopProfiles.value?.activeName == profile.name + } + + private var isSending: Bool { + tracker.isBusy(key: commandKey) + } + + /// Only one profile switch is in flight at a time. + private var otherSwitchPending: Bool { + !isSending && tracker.isBusy(keyPrefix: LoopProfileAckAdapter.keyPrefix) + } + + private var commandState: RemoteCommandTracker.State? { + tracker.states[commandKey] + } + + var body: some View { + List { + Section(footer: Text("Loop writes the basal rates to the pump when the profile is activated.")) { + Text("Review the profile before activating it in Loop.") + } + + Section(header: Text("Correction Range")) { + ForEach(profile.correctionRange, id: \.seconds) { item in + LabeledValueRow( + label: Self.timeText(item.seconds), + value: "\(glucoseText(item.low)) – \(glucoseText(item.high)) \(glucoseUnitText)" + ) + } + } + + Section(header: Text("Carb Ratios")) { + ForEach(profile.carbRatio, id: \.seconds) { item in + LabeledValueRow( + label: Self.timeText(item.seconds), + value: "\(Localizer.formatToLocalizedString(item.value, maxFractionDigits: 1)) g/U" + ) + } + } + + Section(header: Text("Basal Rates")) { + ForEach(profile.basal, id: \.seconds) { item in + LabeledValueRow( + label: Self.timeText(item.seconds), + value: "\(Localizer.formatToLocalizedString(item.value, maxFractionDigits: 3)) U/hr" + ) + } + HStack { + Text("Total").bold() + Spacer() + Text("\(Localizer.formatToLocalizedString(profile.basalTotal, maxFractionDigits: 2, minFractionDigits: 2)) U/day") + .foregroundColor(.secondary) + } + } + + Section(header: Text("Insulin Sensitivities")) { + ForEach(profile.sensitivity, id: \.seconds) { item in + LabeledValueRow( + label: Self.timeText(item.seconds), + value: "\(glucoseText(item.value)) \(glucoseUnitText)/U" + ) + } + } + } + .safeAreaInset(edge: .bottom) { + Button { + alertType = .confirm + } label: { + if isSending { + HStack { + ProgressView().scaleEffect(0.8) + Text("Waiting for Loop...") + } + .frame(maxWidth: .infinity) + } else if otherSwitchPending { + Text("Another Switch Is Pending").frame(maxWidth: .infinity) + } else { + Text(isActive ? "Active Profile" : "Activate Profile").frame(maxWidth: .infinity) + } + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + .disabled(isActive || isSending || otherSwitchPending) + .padding(.horizontal) + .padding(.vertical, 8) + .background(.bar) + } + .navigationBarTitle(profile.name, displayMode: .inline) + .onAppear { showResult(commandState) } + .onChange(of: commandState) { _, state in showResult(state) } + .alert(item: $alertType) { type in + switch type { + case .confirm: + return Alert( + title: Text("Activate \(profile.name) in Loop?"), + message: Text("Loop replaces its correction range, carb ratios, basal rates and insulin sensitivities with these values and writes the basal rates to the pump."), + primaryButton: .default(Text("Activate"), action: authenticateAndSend), + secondaryButton: .cancel() + ) + case let .result(success, message): + return Alert( + title: Text(success ? "Profile Activated" : message == LoopProfileAckAdapter.timeoutMessage ? "No Reply From Loop" : "Profile Not Activated"), + message: Text(message), + dismissButton: .default(Text("OK")) { + if success { presentationMode.wrappedValue.dismiss() } + } + ) + } + } + } + + /// Shows a finished command once, also when the view was left while it was pending. + private func showResult(_ state: RemoteCommandTracker.State?) { + guard case let .done(success, message) = state else { return } + tracker.consume(key: commandKey) + presentResult(success: success, message: message) + } + + /// A result that arrives at once comes while the confirmation alert is still closing. + private func presentResult(success: Bool, message: String) { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + alertType = .result(success: success, message: message) + } + } + + private func authenticateAndSend() { + AuthService.authenticate(reason: "Confirm your identity to activate \(profile.name) in Loop.") { result in + switch result { + case .success: + send() + case let .unavailable(message): + presentResult(success: false, message: message) + case .failed: + presentResult(success: false, message: "Authentication failed") + case .canceled: + break + } + } + } + + private func send() { + RemoteCommandTracker.shared.sendLoopProfileSwitch(profile: profile) { success, error in + if !success { + LogManager.shared.log(category: .apns, message: "Profile switch not sent: \(error ?? "unknown error")") + } + } + } + + private var glucoseUnitText: String { + Localizer.getPreferredUnit() == .milligramsPerDeciliter ? "mg/dL" : "mmol/L" + } + + private func glucoseText(_ value: Double) -> String { + Localizer.formatQuantity(HKQuantity(unit: profile.units, doubleValue: value)) + } + + /// Schedule start times are offsets from midnight in the profile's time zone. + static func timeText(_ seconds: Int) -> String { + let formatter = DateFormatter() + formatter.timeStyle = .short + formatter.timeZone = TimeZone(identifier: "UTC") + return formatter.string(from: Date(timeIntervalSince1970: TimeInterval(seconds))) + } +} + +/// A remote command button for a command the connected Loop build does not offer. +struct UnavailableCommandButtonView: View { + let command: String + let iconName: String + let title: String + let message: String + + @State private var showInfo = false + + var body: some View { + Button { + showInfo = true + } label: { + VStack { + Image(systemName: iconName) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 50, height: 50) + Text(command) + } + .frame(maxWidth: .infinity, minHeight: 100) + .padding() + .background(Color.gray.opacity(0.35)) + .foregroundColor(.white) + .cornerRadius(8) + } + .buttonStyle(PlainButtonStyle()) + .alert(isPresented: $showInfo) { + Alert(title: Text(title), message: Text(message), dismissButton: .default(Text("OK"))) + } + } +} diff --git a/LoopFollow/Remote/RecommendedBolus.swift b/LoopFollow/Remote/RecommendedBolus.swift new file mode 100644 index 000000000..9d4ac9015 --- /dev/null +++ b/LoopFollow/Remote/RecommendedBolus.swift @@ -0,0 +1,28 @@ +// LoopFollow +// RecommendedBolus.swift + +import Foundation + +/// The AID's latest bolus recommendation, offered only while no bolus came +/// after the loop cycle that calculated it. Until the next cycle, an earlier +/// recommendation leaves out that bolus, and using it could double it. +enum RecommendedBolus { + static func current() -> (units: Double, at: TimeInterval)? { + guard let units = Observable.shared.deviceRecBolus.value, + let at = Observable.shared.deviceRecBolusAt.value + else { return nil } + if let lastBolus = lastBolusAt(), lastBolus >= at { return nil } + return (units, at) + } + + /// The latest manual bolus in Nightscout, or sent from LoopFollow or the + /// watch, which Nightscout may not show yet. Automatic boluses are left + /// out: the cycle that recommends a bolus can also give one. + static func lastBolusAt() -> TimeInterval? { + let nightscout = MainViewController.shared?.bolusData.map(\.date).max() + let sentBolus = Storage.shared.remoteBolusHistory.value.map(\.date).max() + let sentMeal = Storage.shared.remoteMealHistory.value.filter { $0.bolus > 0 }.map(\.date).max() + let sent = [sentBolus, sentMeal].compactMap { $0?.timeIntervalSince1970 }.max() + return [nightscout, sent].compactMap { $0 }.max() + } +} diff --git a/LoopFollow/Remote/RemoteCommandRefresh.swift b/LoopFollow/Remote/RemoteCommandRefresh.swift new file mode 100644 index 000000000..f1773f86d --- /dev/null +++ b/LoopFollow/Remote/RemoteCommandRefresh.swift @@ -0,0 +1,136 @@ +// LoopFollow +// RemoteCommandRefresh.swift + +import Foundation +import HealthKit + +/// Loop and Trio apply a remote command within seconds, but LoopFollow only sees +/// the result on its next regular Nightscout fetch, which can be minutes away. +/// After a command is sent, treatments and device status are fetched a few extra +/// times, stopping as soon as the change the command makes shows up, so the new +/// override, temp target, bolus or carbs reach the phone, the watch and the Live +/// Activity sooner. +final class RemoteCommandRefresh { + /// What a command changes, and so what ends the follow-up fetches. + enum Change { + case override + case tempTarget + case bolus(units: Double) + /// A bolus sent with the meal (Trio) also has to show up; 0 g of carbs + /// (fat and protein only) matches any new carb entry. + case meal(grams: Double, bolus: Double) + + var name: String { + switch self { + case .override: "override" + case .tempTarget: "temp target" + case .bolus: "bolus" + case .meal: "meal" + } + } + } + + static let shared = RemoteCommandRefresh() + + /// Seconds after the send. Most uploads land in the first half minute; the + /// later fetches cover a pump that is slow to deliver or an upload that is retried. + static let delays: [TimeInterval] = [10, 20, 30, 60, 90, 120] + + private var pending: DispatchWorkItem? + + private init() {} + + /// Wraps a send's completion so that a successful send starts the follow-up fetches. + static func following(_ change: Change, _ completion: @escaping (Bool, String?) -> Void) -> (Bool, String?) -> Void { + { success, error in + if success { shared.commandSent(change) } + completion(success, error) + } + } + + /// Restarts the follow-up fetches; a newer command replaces the remaining ones. + func commandSent(_ change: Change) { + DispatchQueue.main.async { + self.cancel() + LogManager.shared.log(category: .apns, message: "Fetching Nightscout early until the \(change.name) shows up") + self.schedule(step: 0, after: Self.delays[0], arrived: Self.arrivalCheck(for: change), change: change) + } + } + + /// Each fetch schedules the next one, so when the app was suspended past + /// several of them (Bluetooth heartbeat modes), it fetches once on resuming + /// instead of all the overdue ones at once. + private func schedule(step: Int, after delay: TimeInterval, arrived: @escaping () -> Bool, change: Change) { + let work = DispatchWorkItem { [weak self] in + guard let self else { return } + if arrived() { + LogManager.shared.log(category: .apns, message: "The \(change.name) showed up; \(Self.delays.count - step) early fetches skipped") + self.pending = nil + return + } + TaskScheduler.shared.rescheduleTask(id: .treatments, to: Date()) + TaskScheduler.shared.rescheduleTask(id: .deviceStatus, to: Date()) + let next = step + 1 + guard next < Self.delays.count else { + self.pending = nil + return + } + self.schedule(step: next, after: Self.delays[next] - Self.delays[step], arrived: arrived, change: change) + } + pending = work + DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: work) + } + + private func cancel() { + pending?.cancel() + pending = nil + } + + /// Captures the phone's data now and returns a check that is true once the + /// command's change is in it. A change that never matches (a partial bolus, + /// a renamed preset) only means the fetches run their full two minutes. + private static func arrivalCheck(for change: Change) -> () -> Bool { + switch change { + case .override: + let before = (Observable.shared.override.value, Observable.shared.overrideEndAt.value) + return { (Observable.shared.override.value, Observable.shared.overrideEndAt.value) != before } + case .tempTarget: + // Compared as numbers: each treatments fetch creates a new HKQuantity. + func current() -> (Double?, TimeInterval?) { + (Observable.shared.tempTarget.value?.doubleValue(for: .milligramsPerDeciliter), Observable.shared.tempTargetEndAt.value) + } + let before = current() + return { current() != before } + case let .bolus(units): + return newEntry(in: boluses, matching: units, tolerance: 0.01) + case let .meal(grams, bolus): + let carbsArrived = newEntry(in: carbs, matching: grams > 0 ? grams : nil, tolerance: 0.5) + guard bolus > 0 else { return carbsArrived } + let bolusArrived = newEntry(in: boluses, matching: bolus, tolerance: 0.01) + return { carbsArrived() && bolusArrived() } + } + } + + /// True once an entry that was not there at the send appears, with `value` when given. + private static func newEntry( + in entries: @escaping () -> [(date: TimeInterval, value: Double)], + matching value: Double?, + tolerance: Double + ) -> () -> Bool { + let known = Set(entries().map { $0.date }) + return { + entries().contains { entry in + !known.contains(entry.date) && value.map { abs(entry.value - $0) < tolerance } ?? true + } + } + } + + private static func boluses() -> [(date: TimeInterval, value: Double)] { + let vc = MainViewController.shared + return ((vc?.bolusData ?? []) + (vc?.smbData ?? [])).map { (date: $0.date, value: $0.value) } + } + + private static func carbs() -> [(date: TimeInterval, value: Double)] { + (MainViewController.shared?.carbData ?? []).map { (date: $0.date, value: $0.value) } + } +} diff --git a/LoopFollow/Remote/RemoteCommandTracker.swift b/LoopFollow/Remote/RemoteCommandTracker.swift new file mode 100644 index 000000000..be772414b --- /dev/null +++ b/LoopFollow/Remote/RemoteCommandTracker.swift @@ -0,0 +1,302 @@ +// LoopFollow +// RemoteCommandTracker.swift + +import Foundation + +extension Notification.Name { + /// Posted when a tracked remote command reaches a terminal state, with its key in `userInfo["key"]`, so lists can refresh. + static let remoteMealCommandDidComplete = Notification.Name("LoopFollow.remoteMealCommandDidComplete") +} + +/// Tracks one in-flight remote command per key (Trio meal id or Loop sync identifier) until the +/// AID app's return push resolves it or the timeout fires. All state changes happen on the main queue. +final class RemoteCommandTracker: ObservableObject { + static let shared = RemoteCommandTracker() + static let timeoutMessage = "No confirmation received. Refresh the list to check whether the change was applied." + + enum State: Equatable { + case pending(since: Date) + case done(success: Bool, message: String) + } + + struct Resolution: Equatable { + let key: String + let success: Bool + let message: String + } + + @Published private(set) var states: [String: State] = [:] + + let trioAcks = TRCMealAckAdapter() + private let timeout: TimeInterval + private var timeouts: [String: DispatchWorkItem] = [:] + + init(timeout: TimeInterval = 60) { + self.timeout = timeout + } + + func isBusy(key: String) -> Bool { + if case .pending = states[key] { return true } + return false + } + + func begin(key: String, timeout: TimeInterval? = nil, timeoutMessage: String = RemoteCommandTracker.timeoutMessage) { + onMain { + self.timeouts[key]?.cancel() + self.states[key] = .pending(since: Date()) + let work = DispatchWorkItem { [weak self] in + self?.resolve(key: key, success: false, message: timeoutMessage) + } + self.timeouts[key] = work + DispatchQueue.main.asyncAfter(deadline: .now() + (timeout ?? self.timeout), execute: work) + } + } + + /// True while any command whose key starts with `prefix` waits for its reply. + func isBusy(keyPrefix prefix: String) -> Bool { + states.contains { key, state in + if case .pending = state { return key.hasPrefix(prefix) } + return false + } + } + + func resolve(key: String, success: Bool, message: String) { + onMain { + guard case .pending = self.states[key] else { return } + self.timeouts[key]?.cancel() + self.timeouts[key] = nil + self.states[key] = .done(success: success, message: message) + LogManager.shared.log(category: .apns, message: "Remote command for \(LogRedactor.tail(key)) finished: success=\(success) \(message)") + NotificationCenter.default.post(name: .remoteMealCommandDidComplete, object: nil, userInfo: ["key": key]) + } + } + + /// Clears a terminal state once the UI has acted on it. + func consume(key: String) { + onMain { + if case .done = self.states[key] { self.states[key] = nil } + } + } + + /// Returns true when the push resolved a pending command. + @discardableResult + func handleNotification(userInfo: [AnyHashable: Any]) -> Bool { + guard let resolution = trioAcks.resolution(for: userInfo) + ?? LoopCarbAckAdapter.resolution(for: userInfo) + ?? LoopProfileAckAdapter.resolution(for: userInfo) + else { return false } + guard isBusy(key: resolution.key) else { + LogManager.shared.log(category: .apns, message: "Ack for \(LogRedactor.tail(resolution.key)) matched no pending command") + return false + } + resolve(key: resolution.key, success: resolution.success, message: resolution.message) + return true + } + + private func onMain(_ work: @escaping () -> Void) { + if Thread.isMainThread { work() } else { DispatchQueue.main.async(execute: work) } + } +} + +/// Correlates Trio's return push with the meal a command was sent for via the per-send command id. +final class TRCMealAckAdapter { + private var keys: [String: String] = [:] + + func register(commandID: String, key: String) { + keys[commandID] = key + } + + func unregister(commandID: String) { + keys[commandID] = nil + } + + func resolution(for userInfo: [AnyHashable: Any]) -> RemoteCommandTracker.Resolution? { + guard let status = userInfo["command_status"] as? String, + let commandID = userInfo["command_id"] as? String, + let key = keys[commandID] + else { return nil } + keys[commandID] = nil + let success = status == "success" + let result = userInfo["result"] as? String + LogManager.shared.log(category: .apns, message: "TRC ack: status=\(status) result=\(result ?? "-") command_id=\(commandID)") + return RemoteCommandTracker.Resolution( + key: key, + success: success, + message: alertBody(userInfo) ?? Self.defaultMessage(result: result, success: success) + ) + } + + private static func defaultMessage(result: String?, success: Bool) -> String { + switch result { + case "deleted": return "Meal deleted." + case "updated": return "Meal updated." + case "not_found": return "Trio could not find this meal. It may already have been deleted or changed on the phone." + case "rejected": return "Trio rejected the command." + default: return success ? "Command confirmed." : "Trio reported a failure." + } + } +} + +/// Matches Loop's return push for carb delete/edit commands by sync identifier. +enum LoopCarbAckAdapter { + static let commandTypes: Set = ["carbs_delete", "carbs_edit"] + + static func resolution(for userInfo: [AnyHashable: Any]) -> RemoteCommandTracker.Resolution? { + guard let commandType = userInfo["command_type"] as? String, + commandTypes.contains(commandType), + let status = userInfo["command_status"] as? String, + let key = userInfo["sync_identifier"] as? String + else { return nil } + let success = status == "success" + LogManager.shared.log(category: .apns, message: "Loop ack: type=\(commandType) status=\(status) sync_identifier=\(LogRedactor.tail(key))") + let defaultMessage: String + if !success { + defaultMessage = "Loop reported a failure." + } else { + defaultMessage = commandType == "carbs_delete" ? "Carb entry deleted." : "Carb entry updated." + } + return RemoteCommandTracker.Resolution(key: key, success: success, message: alertBody(userInfo) ?? defaultMessage) + } +} + +/// Matches Loop's return push for a profile switch by the profile name in its message +/// ("Profile '' activated" or "... Profile '' not activated: "). +enum LoopProfileAckAdapter { + static let keyPrefix = "loop-profile-switch:" + /// Pump programming and both pushes can take a while; a missing reply does not mean the switch failed. + static let timeout: TimeInterval = 180 + static let timeoutMessage = "Loop has not replied. The switch may still complete; the list shows the active profile once Loop uploads it. Check it before sending again." + + static func key(for name: String) -> String { + keyPrefix + name + } + + static func resolution(for userInfo: [AnyHashable: Any]) -> RemoteCommandTracker.Resolution? { + guard userInfo["command_type"] as? String == "profile_switch", + let status = userInfo["command_status"] as? String, + let body = alertBody(userInfo), + let name = profileName(in: body) + else { return nil } + LogManager.shared.log(category: .apns, message: "Loop ack: type=profile_switch status=\(status)") + return RemoteCommandTracker.Resolution(key: key(for: name), success: status == "success", message: body) + } + + static func profileName(in message: String) -> String? { + guard let match = message.firstMatch(of: /Profile '(.+)' (?:not )?activated/) else { return nil } + return String(match.1) + } +} + +private func alertBody(_ userInfo: [AnyHashable: Any]) -> String? { + let alert = (userInfo["aps"] as? [String: Any])?["alert"] as? [String: Any] + guard let body = alert?["body"] as? String, !body.isEmpty else { return nil } + return body +} + +// MARK: - Trio meals + +extension RemoteCommandTracker { + func sendTrioMealDelete(mealID: String, completion: @escaping (Bool, String?) -> Void) { + let commandID = UUID().uuidString + trioAcks.register(commandID: commandID, key: mealID) + begin(key: mealID) + PushNotificationManager().sendDeleteMealPushNotification(mealID: mealID, commandID: commandID) { [weak self] success, error in + self?.finishTrioSend(key: mealID, commandID: commandID, success: success, error: error, completion: completion) + } + } + + func sendTrioMealEdit(mealID: String, carbs: Int, fat: Int, protein: Int, date: Date, completion: @escaping (Bool, String?) -> Void) { + let commandID = UUID().uuidString + trioAcks.register(commandID: commandID, key: mealID) + begin(key: mealID) + PushNotificationManager().sendEditMealPushNotification( + mealID: mealID, + commandID: commandID, + carbs: carbs, + fat: fat, + protein: protein, + scheduledTime: date + ) { [weak self] success, error in + self?.finishTrioSend(key: mealID, commandID: commandID, success: success, error: error, completion: completion) + } + } + + private func finishTrioSend(key: String, commandID: String, success: Bool, error: String?, completion: @escaping (Bool, String?) -> Void) { + DispatchQueue.main.async { + if success { + LogManager.shared.log(category: .apns, message: "Meal command sent command_id=\(commandID) meal_id=\(key)") + } else { + self.trioAcks.unregister(commandID: commandID) + self.resolve(key: key, success: false, message: error ?? "The command could not be sent.") + } + completion(success, error) + } + } +} + +// MARK: - Loop carb entries + +extension RemoteCommandTracker { + func sendLoopCarbDelete(carb: LoopCarbTreatment, completion: @escaping (Bool, String?) -> Void) { + let key = carb.syncIdentifier + begin(key: key) + guard let otp = loopOTP(key: key, completion: completion) else { return } + LoopAPNSService().sendCarbsDelete(syncIdentifier: key, otp: otp) { [weak self] success, error in + self?.finishLoopSend(key: key, success: success, error: error, completion: completion) + } + } + + func sendLoopCarbEdit(carb: LoopCarbTreatment, carbsAmount: Double, absorptionHours: Double, foodType: String?, consumedDate: Date, completion: @escaping (Bool, String?) -> Void) { + let key = carb.syncIdentifier + begin(key: key) + guard let otp = loopOTP(key: key, completion: completion) else { return } + LoopAPNSService().sendCarbsEdit( + syncIdentifier: key, + carbsAmount: carbsAmount, + absorptionTimeHours: absorptionHours, + foodType: foodType, + consumedDate: consumedDate, + otp: otp + ) { [weak self] success, error in + self?.finishLoopSend(key: key, success: success, error: error, completion: completion) + } + } + + func sendLoopProfileSwitch(profile: LoopProfile, completion: @escaping (Bool, String?) -> Void) { + let key = LoopProfileAckAdapter.key(for: profile.name) + begin(key: key, timeout: LoopProfileAckAdapter.timeout, timeoutMessage: LoopProfileAckAdapter.timeoutMessage) + guard let otp = loopOTP(key: key, completion: completion) else { return } + LoopAPNSService().sendProfileSwitch(name: profile.name, fingerprint: profile.fingerprint, otp: otp) { [weak self] success, error in + self?.finishLoopSend(key: key, success: success, error: error, completion: completion) + } + } + + private func loopOTP(key: String, completion: (Bool, String?) -> Void) -> String? { + let qrCodeURL = Storage.shared.loopAPNSQrCodeURL.value + let otp = TOTPGenerator.extractOTPFromURL(qrCodeURL) + let message: String? + if otp == nil { + message = "Invalid QR code URL. Please re-scan the QR code in settings." + } else if TOTPService.shared.isTOTPBlocked(qrCodeURL: qrCodeURL) { + message = "The current one-time code was already used. Wait for the next code and try again." + } else { + message = nil + } + guard let message else { return otp } + resolve(key: key, success: false, message: message) + completion(false, message) + return nil + } + + private func finishLoopSend(key: String, success: Bool, error: String?, completion: @escaping (Bool, String?) -> Void) { + DispatchQueue.main.async { + if success { + TOTPService.shared.markTOTPAsUsed(qrCodeURL: Storage.shared.loopAPNSQrCodeURL.value) + LogManager.shared.log(category: .apns, message: "Loop command sent for \(LogRedactor.tail(key))") + } else { + self.resolve(key: key, success: false, message: error ?? "The command could not be sent.") + } + completion(success, error) + } + } +} diff --git a/LoopFollow/Remote/ReturnNotificationInfo+LoopFollow.swift b/LoopFollow/Remote/ReturnNotificationInfo+LoopFollow.swift new file mode 100644 index 000000000..34c61d15a --- /dev/null +++ b/LoopFollow/Remote/ReturnNotificationInfo+LoopFollow.swift @@ -0,0 +1,35 @@ +// LoopFollow +// ReturnNotificationInfo+LoopFollow.swift + +import Foundation + +extension ReturnNotificationInfo { + /// How Loop or Trio reaches this LoopFollow app to confirm a command, or nil + /// when LoopFollow's own APNs credentials or device token are missing. + static func loopFollow() -> ReturnNotificationInfo? { + let loopFollowDeviceToken = Observable.shared.loopFollowDeviceToken.value + guard !loopFollowDeviceToken.isEmpty else { return nil } + + guard let loopFollowTeamID = BuildDetails.default.teamID, !loopFollowTeamID.isEmpty else { + LogManager.shared.log(category: .apns, message: "LoopFollow Team ID not found in BuildDetails.plist. Cannot create return notification info.") + return nil + } + + let lfKeyId = Storage.shared.lfKeyId.value + let lfApnsKey = Storage.shared.lfApnsKey.value + + guard !lfKeyId.isEmpty, !lfApnsKey.isEmpty else { + LogManager.shared.log(category: .apns, message: "Missing LoopFollow APNS credentials. Configure them in App Settings → APN.") + return nil + } + + return ReturnNotificationInfo( + productionEnvironment: BuildDetails.default.isTestFlightBuild(), + deviceToken: loopFollowDeviceToken, + bundleId: Bundle.main.bundleIdentifier ?? "", + teamId: loopFollowTeamID, + keyId: lfKeyId, + apnsKey: lfApnsKey + ) + } +} diff --git a/LoopFollow/Remote/Settings/RemoteSettingsView.swift b/LoopFollow/Remote/Settings/RemoteSettingsView.swift index 6e9b2acaa..c3146108c 100644 --- a/LoopFollow/Remote/Settings/RemoteSettingsView.swift +++ b/LoopFollow/Remote/Settings/RemoteSettingsView.swift @@ -9,6 +9,7 @@ struct RemoteSettingsView: View { @ObservedObject var viewModel: RemoteSettingsViewModel @ObservedObject private var device = Storage.shared.device @ObservedObject var bolusIncrement = Storage.shared.bolusIncrement + @ObservedObject private var watchRemoteCommands = Storage.shared.watchRemoteCommands @State private var showAlert: Bool = false @State private var alertType: AlertType? = nil @@ -93,6 +94,13 @@ struct RemoteSettingsView: View { if viewModel.remoteType == .trc || viewModel.remoteType == .loopAPNS { guardrailsSection + + Section( + header: Text("Apple Watch"), + footer: Text("Commands from the LoopFollow watch app are sent by this iPhone, with the same guardrails as here. The iPhone must be within reach of the watch.") + ) { + Toggle("Allow Commands from Apple Watch", isOn: $watchRemoteCommands.value) + } } if !Storage.shared.bolusIncrementDetected.value { @@ -235,17 +243,6 @@ struct RemoteSettingsView: View { .buttonStyle(.borderedProminent) .frame(maxWidth: .infinity) .padding(.vertical, 10) - - HStack { - Text("Environment") - Spacer() - Toggle("Production", isOn: $viewModel.productionEnvironment) - .toggleStyle(SwitchToggleStyle()) - } - - Text("Production is used for browser builders and should be switched off for Xcode builders") - .font(.caption) - .foregroundColor(.secondary) } if let errorMessage = viewModel.loopAPNSErrorMessage, !errorMessage.isEmpty { @@ -258,6 +255,7 @@ struct RemoteSettingsView: View { Section(header: Text("Debug / Info")) { Text("Device Token: \(Storage.shared.deviceToken.value)") + Text("APNS Environment: \(Storage.shared.productionEnvironment.value ? "Production" : "Development")") Text("Bundle ID: \(Storage.shared.bundleId.value)") if let otpCode = TOTPGenerator.extractOTPFromURL(Storage.shared.loopAPNSQrCodeURL.value) { diff --git a/LoopFollow/Remote/Settings/RemoteSettingsViewModel.swift b/LoopFollow/Remote/Settings/RemoteSettingsViewModel.swift index 2b3ab9af0..04c1a8ce7 100644 --- a/LoopFollow/Remote/Settings/RemoteSettingsViewModel.swift +++ b/LoopFollow/Remote/Settings/RemoteSettingsViewModel.swift @@ -25,7 +25,6 @@ class RemoteSettingsViewModel: ObservableObject { @Published var loopDeveloperTeamId: String @Published var loopAPNSQrCodeURL: String - @Published var productionEnvironment: Bool @Published var isShowingLoopAPNSScanner: Bool = false @Published var loopAPNSErrorMessage: String? @@ -103,8 +102,6 @@ class RemoteSettingsViewModel: ObservableObject { loopDeveloperTeamId = storage.teamId.value ?? "" loopAPNSQrCodeURL = storage.loopAPNSQrCodeURL.value - productionEnvironment = storage.productionEnvironment.value - setupBindings() } @@ -188,11 +185,6 @@ class RemoteSettingsViewModel: ObservableObject { .dropFirst() .sink { [weak self] in self?.storage.loopAPNSQrCodeURL.value = $0 } .store(in: &cancellables) - - $productionEnvironment - .dropFirst() - .sink { [weak self] in self?.storage.productionEnvironment.value = $0 } - .store(in: &cancellables) } func handleLoopAPNSQRCodeScanResult(_ result: Result) { @@ -233,7 +225,6 @@ class RemoteSettingsViewModel: ObservableObject { mealWithFatProtein = storage.mealWithFatProtein.value loopDeveloperTeamId = storage.teamId.value ?? "" loopAPNSQrCodeURL = storage.loopAPNSQrCodeURL.value - productionEnvironment = storage.productionEnvironment.value // Update device-related properties isTrioDevice = (storage.device.value == "Trio") diff --git a/LoopFollow/Remote/TRC/BolusView.swift b/LoopFollow/Remote/TRC/BolusView.swift index e41ab89ce..c0be6e408 100644 --- a/LoopFollow/Remote/TRC/BolusView.swift +++ b/LoopFollow/Remote/TRC/BolusView.swift @@ -12,8 +12,6 @@ struct BolusView: View { @ObservedObject private var maxBolus = Storage.shared.maxBolus @ObservedObject private var bolusIncrement = Storage.shared.bolusIncrement - @ObservedObject private var deviceRecBolus = Observable.shared.deviceRecBolus - @ObservedObject private var enactedOrSuggested = Observable.shared.enactedOrSuggested @ObservedObject private var quickPickBoluses = QuickPickBolusesManager.shared @FocusState private var bolusFieldIsFocused: Bool @@ -203,8 +201,8 @@ struct BolusView: View { title: Text("Old Calculation Warning"), message: Text(alertMessage ?? ""), primaryButton: .default(Text("Use Anyway")) { - if let rec = deviceRecBolus.value { - applyRecommendedBolus(rec) + if let rec = RecommendedBolus.current() { + applyRecommendedBolus(rec.units) } }, secondaryButton: .cancel() @@ -220,10 +218,10 @@ struct BolusView: View { @ViewBuilder private func recommendedBlocks(now: Date) -> some View { - if let rec = deviceRecBolus.value, - let t = enactedOrSuggested.value - { - let ageSec = max(0, now.timeIntervalSince1970 - t) + // The TimelineView redraws this every second, so a new loop cycle or bolus shows up here. + if let recommended = RecommendedBolus.current() { + let rec = recommended.units + let ageSec = max(0, now.timeIntervalSince1970 - recommended.at) if ageSec < 12 * 60 { let maxU = maxBolus.value.doubleValue(for: .internationalUnit()) diff --git a/LoopFollow/Remote/TRC/MealMacroInputs.swift b/LoopFollow/Remote/TRC/MealMacroInputs.swift new file mode 100644 index 000000000..71bf9b29b --- /dev/null +++ b/LoopFollow/Remote/TRC/MealMacroInputs.swift @@ -0,0 +1,65 @@ +// LoopFollow +// MealMacroInputs.swift + +import HealthKit +import SwiftUI + +/// Carbs, fat and protein rows bound to the remote guardrail maxima. +struct MealMacroInputs: View { + @Binding var carbs: HKQuantity + @Binding var fat: HKQuantity + @Binding var protein: HKQuantity + var showFatProtein: Bool + @FocusState.Binding var carbsFocused: Bool + @FocusState.Binding var fatFocused: Bool + @FocusState.Binding var proteinFocused: Bool + var onValidationError: (String) -> Void + /// Values already in the meal stay editable even when they exceed the guardrail maxima. + var currentValues: (carbs: HKQuantity, fat: HKQuantity, protein: HKQuantity)? = nil + + @ObservedObject private var maxCarbs = Storage.shared.maxCarbs + @ObservedObject private var maxProtein = Storage.shared.maxProtein + @ObservedObject private var maxFat = Storage.shared.maxFat + + var body: some View { + HKQuantityInputView( + label: "Carbs", + quantity: $carbs, + unit: .gram(), + maxLength: 4, + minValue: HKQuantity(unit: .gram(), doubleValue: 0), + maxValue: ceiling(maxCarbs.value, currentValues?.carbs), + isFocused: $carbsFocused, + onValidationError: onValidationError + ) + + if showFatProtein { + HKQuantityInputView( + label: "Fat", + quantity: $fat, + unit: .gram(), + maxLength: 4, + minValue: HKQuantity(unit: .gram(), doubleValue: 0), + maxValue: ceiling(maxFat.value, currentValues?.fat), + isFocused: $fatFocused, + onValidationError: onValidationError + ) + + HKQuantityInputView( + label: "Protein", + quantity: $protein, + unit: .gram(), + maxLength: 4, + minValue: HKQuantity(unit: .gram(), doubleValue: 0), + maxValue: ceiling(maxProtein.value, currentValues?.protein), + isFocused: $proteinFocused, + onValidationError: onValidationError + ) + } + } + + private func ceiling(_ limit: HKQuantity, _ current: HKQuantity?) -> HKQuantity { + guard let current, current.compare(limit) == .orderedDescending else { return limit } + return current + } +} diff --git a/LoopFollow/Remote/TRC/MealView.swift b/LoopFollow/Remote/TRC/MealView.swift index 5db2ef14b..9d9ed0e7e 100644 --- a/LoopFollow/Remote/TRC/MealView.swift +++ b/LoopFollow/Remote/TRC/MealView.swift @@ -100,47 +100,17 @@ struct MealView: View { .transition(.opacity) } - HKQuantityInputView( - label: "Carbs", - quantity: $carbs, - unit: .gram(), - maxLength: 4, - minValue: HKQuantity(unit: .gram(), doubleValue: 0), - maxValue: maxCarbs.value, - isFocused: $carbsFieldIsFocused, - onValidationError: { message in - handleValidationError(message) - } + MealMacroInputs( + carbs: $carbs, + fat: $fat, + protein: $protein, + showFatProtein: mealWithFatProtein.value, + carbsFocused: $carbsFieldIsFocused, + fatFocused: $fatFieldIsFocused, + proteinFocused: $proteinFieldIsFocused, + onValidationError: handleValidationError ) - if mealWithFatProtein.value { - HKQuantityInputView( - label: "Fat", - quantity: $fat, - unit: .gram(), - maxLength: 4, - minValue: HKQuantity(unit: .gram(), doubleValue: 0), - maxValue: maxFat.value, - isFocused: $fatFieldIsFocused, - onValidationError: { message in - handleValidationError(message) - } - ) - - HKQuantityInputView( - label: "Protein", - quantity: $protein, - unit: .gram(), - maxLength: 4, - minValue: HKQuantity(unit: .gram(), doubleValue: 0), - maxValue: maxProtein.value, - isFocused: $proteinFieldIsFocused, - onValidationError: { message in - handleValidationError(message) - } - ) - } - if mealWithBolus.value { HKQuantityInputView( label: "Bolus Amount", diff --git a/LoopFollow/Remote/TRC/PushNotificationManager.swift b/LoopFollow/Remote/TRC/PushNotificationManager.swift index aa1f661a2..532d9bf27 100644 --- a/LoopFollow/Remote/TRC/PushNotificationManager.swift +++ b/LoopFollow/Remote/TRC/PushNotificationManager.swift @@ -21,49 +21,15 @@ class PushNotificationManager { user = Storage.shared.user.value bundleId = Storage.shared.bundleId.value - let lfTeamId = BuildDetails.default.teamID ?? "" - let remoteTeamId = Storage.shared.teamId.value ?? "" - let sameTeam = !lfTeamId.isEmpty && !remoteTeamId.isEmpty && lfTeamId == remoteTeamId - - if sameTeam || remoteTeamId.isEmpty { - apnsKey = Storage.shared.lfApnsKey.value - keyId = Storage.shared.lfKeyId.value - teamId = lfTeamId - } else { - apnsKey = Storage.shared.remoteApnsKey.value - keyId = Storage.shared.remoteKeyId.value - teamId = remoteTeamId - } - } - - private func createReturnNotificationInfo() -> CommandPayload.ReturnNotificationInfo? { - let loopFollowDeviceToken = Observable.shared.loopFollowDeviceToken.value - - guard !loopFollowDeviceToken.isEmpty else { - return nil - } - - guard let loopFollowTeamID = BuildDetails.default.teamID, !loopFollowTeamID.isEmpty else { - LogManager.shared.log(category: .apns, message: "LoopFollow Team ID not found in BuildDetails.plist. Cannot create return notification info.") - return nil - } - - let lfKeyId = Storage.shared.lfKeyId.value - let lfApnsKey = Storage.shared.lfApnsKey.value - - guard !lfKeyId.isEmpty, !lfApnsKey.isEmpty else { - LogManager.shared.log(category: .apns, message: "Missing LoopFollow APNS credentials. Configure them in App Settings → APN.") - return nil - } - - return CommandPayload.ReturnNotificationInfo( - productionEnvironment: BuildDetails.default.isTestFlightBuild(), - deviceToken: loopFollowDeviceToken, - bundleId: Bundle.main.bundleIdentifier ?? "", - teamId: loopFollowTeamID, - keyId: lfKeyId, - apnsKey: lfApnsKey + let resolved = RemoteSigningKey.resolve( + lfTeamID: BuildDetails.default.teamID ?? "", + remoteTeamID: Storage.shared.teamId.value ?? "", + lfKey: (Storage.shared.lfApnsKey.value, Storage.shared.lfKeyId.value), + remoteKey: (Storage.shared.remoteApnsKey.value, Storage.shared.remoteKeyId.value) ) + apnsKey = resolved.key + keyId = resolved.keyID + teamId = resolved.teamID } func sendOverridePushNotification(override: ProfileManager.TrioOverride, completion: @escaping (Bool, String?) -> Void) { @@ -72,9 +38,9 @@ class PushNotificationManager { commandType: .startOverride, timestamp: Date().timeIntervalSince1970, overrideName: override.name, - returnNotification: createReturnNotificationInfo() + returnNotification: ReturnNotificationInfo.loopFollow() ) - sendEncryptedCommand(payload: payload, completion: completion) + sendEncryptedCommand(payload: payload, completion: RemoteCommandRefresh.following(.override, completion)) } func sendCancelOverridePushNotification(completion: @escaping (Bool, String?) -> Void) { @@ -83,9 +49,9 @@ class PushNotificationManager { commandType: .cancelOverride, timestamp: Date().timeIntervalSince1970, overrideName: nil, - returnNotification: createReturnNotificationInfo() + returnNotification: ReturnNotificationInfo.loopFollow() ) - sendEncryptedCommand(payload: payload, completion: completion) + sendEncryptedCommand(payload: payload, completion: RemoteCommandRefresh.following(.override, completion)) } func sendBolusPushNotification(bolusAmount: HKQuantity, completion: @escaping (Bool, String?) -> Void) { @@ -95,9 +61,9 @@ class PushNotificationManager { commandType: .bolus, timestamp: Date().timeIntervalSince1970, bolusAmount: bolusAmountDecimal, - returnNotification: createReturnNotificationInfo() + returnNotification: ReturnNotificationInfo.loopFollow() ) - sendEncryptedCommand(payload: payload, completion: completion) + sendEncryptedCommand(payload: payload, completion: RemoteCommandRefresh.following(.bolus(units: bolusAmount.doubleValue(for: .internationalUnit())), completion)) } func sendTempTargetPushNotification(target: HKQuantity, duration: HKQuantity, completion: @escaping (Bool, String?) -> Void) { @@ -109,9 +75,9 @@ class PushNotificationManager { timestamp: Date().timeIntervalSince1970, target: targetValue, duration: durationValue, - returnNotification: createReturnNotificationInfo() + returnNotification: ReturnNotificationInfo.loopFollow() ) - sendEncryptedCommand(payload: payload, completion: completion) + sendEncryptedCommand(payload: payload, completion: RemoteCommandRefresh.following(.tempTarget, completion)) } func sendCancelTempTargetPushNotification(completion: @escaping (Bool, String?) -> Void) { @@ -119,9 +85,9 @@ class PushNotificationManager { user: user, commandType: .cancelTempTarget, timestamp: Date().timeIntervalSince1970, - returnNotification: createReturnNotificationInfo() + returnNotification: ReturnNotificationInfo.loopFollow() ) - sendEncryptedCommand(payload: payload, completion: completion) + sendEncryptedCommand(payload: payload, completion: RemoteCommandRefresh.following(.tempTarget, completion)) } func sendMealPushNotification( @@ -159,11 +125,65 @@ class PushNotificationManager { protein: proteinValue, fat: fatValue, scheduledTime: scheduledTimeInterval, - returnNotification: createReturnNotificationInfo() + returnNotification: ReturnNotificationInfo.loopFollow() + ) + sendEncryptedCommand(payload: payload, completion: RemoteCommandRefresh.following( + .meal(grams: Double(carbsValue ?? 0), bolus: bolusAmount.doubleValue(for: .internationalUnit())), + completion + )) + } + + /// Trio confirms edit/delete through a return notification, so LoopFollow's own APNs credentials are required. + func sendDeleteMealPushNotification(mealID: String, commandID: String, completion: @escaping (Bool, String?) -> Void) { + guard let returnNotification = ReturnNotificationInfo.loopFollow() else { + completion(false, Self.returnNotificationRequiredMessage) + return + } + let payload = CommandPayload( + user: user, + commandType: .deleteMeal, + timestamp: Date().timeIntervalSince1970, + commandID: commandID, + mealID: mealID, + returnNotification: returnNotification ) sendEncryptedCommand(payload: payload, completion: completion) } + func sendEditMealPushNotification( + mealID: String, + commandID: String, + carbs: Int, + fat: Int, + protein: Int, + scheduledTime: Date, + completion: @escaping (Bool, String?) -> Void + ) { + guard carbs >= 0, fat >= 0, protein >= 0, carbs > 0 || fat > 0 || protein > 0 else { + completion(false, "No nutrient data provided. At least one of carbs, fat, or protein must be greater than 0.") + return + } + guard let returnNotification = ReturnNotificationInfo.loopFollow() else { + completion(false, Self.returnNotificationRequiredMessage) + return + } + let payload = CommandPayload( + user: user, + commandType: .editMeal, + timestamp: Date().timeIntervalSince1970, + carbs: carbs, + protein: protein, + fat: fat, + scheduledTime: scheduledTime.timeIntervalSince1970, + commandID: commandID, + mealID: mealID, + returnNotification: returnNotification + ) + sendEncryptedCommand(payload: payload, completion: completion) + } + + private static let returnNotificationRequiredMessage = "Editing or deleting a meal needs LoopFollow's own APNS credentials so Trio can confirm the result. Configure them in App Settings → APN." + private func validateCredentials() -> [String]? { var errors = [String]() let keyIdPattern = "^[A-Z0-9]{10}$" @@ -230,20 +250,6 @@ class PushNotificationManager { return } - guard let url = constructAPNsURL() else { - let errorMessage = "Failed to construct APNs URL" - LogManager.shared.log(category: .apns, message: errorMessage) - completion(false, errorMessage) - return - } - - guard let jwt = JWTManager.shared.getOrGenerateJWT(keyId: keyId, teamId: teamId, apnsKey: apnsKey) else { - let errorMessage = "Failed to generate JWT, please check that the token is correct." - LogManager.shared.log(category: .apns, message: errorMessage) - completion(false, errorMessage) - return - } - do { guard let messenger = SecureMessenger(sharedSecret: sharedSecret) else { let errorMessage = "Failed to initialize security module. Check shared secret." @@ -255,80 +261,22 @@ class PushNotificationManager { let encryptedDataString = try messenger.encrypt(payload) let finalMessage = EncryptedPushMessage(encryptedData: encryptedDataString, commandType: payload.commandType) - var request = URLRequest(url: url) - request.httpMethod = "POST" - request.setValue("bearer \(jwt)", forHTTPHeaderField: "authorization") - request.setValue("application/json", forHTTPHeaderField: "content-type") - request.setValue("10", forHTTPHeaderField: "apns-priority") - request.setValue("600", forHTTPHeaderField: "apns-expiration") - request.setValue(bundleId, forHTTPHeaderField: "apns-topic") - request.setValue("alert", forHTTPHeaderField: "apns-push-type") - request.setValue(payload.commandType.rawValue, forHTTPHeaderField: "apns-collapse-id") - - request.httpBody = try JSONEncoder().encode(finalMessage) - - let task = URLSession.shared.dataTask(with: request) { data, response, error in - if let error = error { - let errorMessage = "Failed to send push notification: \(error.localizedDescription)" - LogManager.shared.log(category: .apns, message: errorMessage) - completion(false, errorMessage) - return - } - - if let httpResponse = response as? HTTPURLResponse { - LogManager.shared.log(category: .apns, message: "Push notification sent. Status code: \(httpResponse.statusCode)", isDebug: true) - - var responseBodyMessage = "" - if let data = data, let responseBody = String(data: data, encoding: .utf8) { - LogManager.shared.log(category: .apns, message: "Response body: \(responseBody)", isDebug: true) - if let json = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any], - let reason = json["reason"] as? String - { - responseBodyMessage = reason - } - } - - switch httpResponse.statusCode { - case 200: - completion(true, nil) - case 400: - completion(false, "Bad request. The request was invalid or malformed. \(responseBodyMessage)") - case 403: - JWTManager.shared.invalidateCache() - completion(false, "Authentication error. Check your certificate or authentication token. \(responseBodyMessage)") - case 404: - completion(false, "Invalid request: The :path value was incorrect. \(responseBodyMessage)") - case 405: - completion(false, "Invalid request: Only POST requests are supported. \(responseBodyMessage)") - case 410: - completion(false, "The device token is no longer active for the topic. \(responseBodyMessage)") - case 413: - completion(false, "Payload too large. The notification payload exceeded the size limit. \(responseBodyMessage)") - case 429: - completion(false, "Too many requests. \(responseBodyMessage)") - case 500: - completion(false, "Internal server error at APNs. \(responseBodyMessage)") - case 503: - completion(false, "Service unavailable. The server is temporarily unavailable. Try again later. \(responseBodyMessage)") - default: - completion(false, "Unexpected status code: \(httpResponse.statusCode). \(responseBodyMessage)") - } - } else { - completion(false, "Failed to get a valid HTTP response.") - } - } - task.resume() - + try APNSDelivery.send( + body: JSONEncoder().encode(finalMessage), + deviceToken: deviceToken, + topic: bundleId, + production: productionEnvironment, + keyId: keyId, + teamId: teamId, + apnsKey: apnsKey, + expiration: "600", + collapseID: payload.apnsCollapseID, + completion: completion + ) } catch { let errorMessage = "Failed to encode or encrypt push message: \(error.localizedDescription)" LogManager.shared.log(category: .apns, message: errorMessage) completion(false, errorMessage) } } - - private func constructAPNsURL() -> URL? { - let host = productionEnvironment ? "api.push.apple.com" : "api.sandbox.push.apple.com" - let urlString = "https://\(host)/3/device/\(deviceToken)" - return URL(string: urlString) - } } diff --git a/LoopFollow/Remote/TRC/TRCMealEditView.swift b/LoopFollow/Remote/TRC/TRCMealEditView.swift new file mode 100644 index 000000000..9da718162 --- /dev/null +++ b/LoopFollow/Remote/TRC/TRCMealEditView.swift @@ -0,0 +1,199 @@ +// LoopFollow +// TRCMealEditView.swift + +import HealthKit +import SwiftUI + +/// Edit form for a meal already logged in Trio. Trio replaces the meal with these values. +struct TRCMealEditView: View { + let meal: TrioMealTreatment + + @Environment(\.presentationMode) private var presentationMode + @ObservedObject private var mealWithFatProtein = Storage.shared.mealWithFatProtein + + @State private var carbs: HKQuantity + @State private var fat: HKQuantity + @State private var protein: HKQuantity + @State private var mealDate: Date + @State private var isSending = false + @State private var alertType: AlertType? + + @FocusState private var carbsFocused: Bool + @FocusState private var fatFocused: Bool + @FocusState private var proteinFocused: Bool + + private enum AlertType: Identifiable { + case confirm + case validation(String) + case sendFailed(String) + + var id: String { + switch self { + case .confirm: return "confirm" + case let .validation(message): return "validation-\(message)" + case let .sendFailed(message): return "failed-\(message)" + } + } + } + + init(meal: TrioMealTreatment) { + self.meal = meal + _carbs = State(initialValue: HKQuantity(unit: .gram(), doubleValue: Double(meal.carbsForEdit))) + _fat = State(initialValue: HKQuantity(unit: .gram(), doubleValue: Double(meal.fat))) + _protein = State(initialValue: HKQuantity(unit: .gram(), doubleValue: Double(meal.protein))) + _mealDate = State(initialValue: Date(timeIntervalSince1970: meal.date)) + } + + private var showFatProtein: Bool { + mealWithFatProtein.value || meal.fat > 0 || meal.protein > 0 + } + + private var dateRange: ClosedRange { + let now = Date() + return now.addingTimeInterval(-TrioMealTreatment.pastEditWindow) ... now.addingTimeInterval(TrioMealTreatment.futureEditWindow) + } + + private var carbsValue: Int { Int(carbs.doubleValue(for: .gram()).rounded()) } + private var fatValue: Int { showFatProtein ? Int(fat.doubleValue(for: .gram()).rounded()) : 0 } + private var proteinValue: Int { showFatProtein ? Int(protein.doubleValue(for: .gram()).rounded()) : 0 } + + var body: some View { + NavigationView { + Form { + Section(header: Text("Current")) { + TRCMealMacroRows(carbs: meal.carbs, fat: meal.fat, protein: meal.protein, date: meal.date) + } + + Section(header: Text("New values"), footer: Text("Trio replaces the meal, including any fat and protein entries, with these values.")) { + MealMacroInputs( + carbs: $carbs, + fat: $fat, + protein: $protein, + showFatProtein: showFatProtein, + carbsFocused: $carbsFocused, + fatFocused: $fatFocused, + proteinFocused: $proteinFocused, + onValidationError: { alertType = .validation($0) }, + currentValues: ( + HKQuantity(unit: .gram(), doubleValue: meal.carbs), + HKQuantity(unit: .gram(), doubleValue: Double(meal.fat)), + HKQuantity(unit: .gram(), doubleValue: Double(meal.protein)) + ) + ) + DatePicker("Meal time", selection: $mealDate, in: dateRange, displayedComponents: [.date, .hourAndMinute]) + .environment(\.timeZone, dateTimeUtils.displayTimeZone()) + } + } + .safeAreaInset(edge: .bottom) { + Button { + carbsFocused = false + fatFocused = false + proteinFocused = false + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + guard alertType == nil else { return } + guard carbsValue > 0 || fatValue > 0 || proteinValue > 0 else { + alertType = .validation("Enter at least one of carbs, fat, or protein. Use Delete meal to remove the meal.") + return + } + alertType = .confirm + } + } label: { + if isSending { + HStack { + ProgressView().scaleEffect(0.8) + Text("Sending...") + } + .frame(maxWidth: .infinity) + } else { + Text("Update Meal").frame(maxWidth: .infinity) + } + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + .disabled(isSending) + .padding(.horizontal) + .padding(.vertical, 8) + .background(.bar) + } + .navigationTitle("Edit Meal") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { presentationMode.wrappedValue.dismiss() } + } + } + .alert(item: $alertType) { type in + switch type { + case .confirm: + return Alert( + title: Text("Update meal in Trio?"), + message: Text(confirmationMessage), + primaryButton: .default(Text("Update"), action: send), + secondaryButton: .cancel() + ) + case let .validation(message): + return Alert(title: Text("Validation Error"), message: Text(message), dismissButton: .default(Text("OK"))) + case let .sendFailed(message): + return Alert(title: Text("Not Sent"), message: Text(message), dismissButton: .default(Text("OK"))) + } + } + } + } + + private var confirmationMessage: String { + let formatter = DateFormatter() + formatter.dateStyle = .short + formatter.timeStyle = .short + dateTimeUtils.applyDisplayTimeZone(to: formatter) + var lines = ["Carbs: \(carbsValue) g"] + if showFatProtein { + lines.append("Fat: \(fatValue) g") + lines.append("Protein: \(proteinValue) g") + } + lines.append("Time: \(formatter.string(from: mealDate))") + return lines.joined(separator: "\n") + } + + private func send() { + isSending = true + RemoteCommandTracker.shared.sendTrioMealEdit( + mealID: meal.mealID.uuidString, + carbs: carbsValue, + fat: fatValue, + protein: proteinValue, + date: mealDate + ) { success, error in + DispatchQueue.main.async { + isSending = false + if success { + presentationMode.wrappedValue.dismiss() + } else { + alertType = .sendFailed(error ?? "Failed to send the meal update.") + } + } + } + } +} + +/// Read-only macro rows shared by the edit form and the treatment detail view. +struct TRCMealMacroRows: View { + let carbs: Double + let fat: Int + let protein: Int + let date: TimeInterval + + var body: some View { + LabeledValueRow(label: "Carbs", value: carbs == carbs.rounded() ? String(format: "%.0f g", carbs) : String(format: "%.1f g", carbs)) + if fat > 0 { LabeledValueRow(label: "Fat", value: "\(fat) g") } + if protein > 0 { LabeledValueRow(label: "Protein", value: "\(protein) g") } + LabeledValueRow(label: "Time", value: formattedTime) + } + + private var formattedTime: String { + let formatter = DateFormatter() + formatter.dateStyle = .medium + formatter.timeStyle = .short + dateTimeUtils.applyDisplayTimeZone(to: formatter) + return formatter.string(from: Date(timeIntervalSince1970: date)) + } +} diff --git a/LoopFollow/Settings/AdvancedSettingsView.swift b/LoopFollow/Settings/AdvancedSettingsView.swift index 9665df882..9f3c00fee 100644 --- a/LoopFollow/Settings/AdvancedSettingsView.swift +++ b/LoopFollow/Settings/AdvancedSettingsView.swift @@ -10,7 +10,6 @@ struct AdvancedSettingsView: View { Form { Section(header: Text("Advanced Settings")) { Toggle("Download Treatments", isOn: $viewModel.downloadTreatments) - Toggle("Download Prediction", isOn: $viewModel.downloadPrediction) Toggle("Graph Basal", isOn: $viewModel.graphBasal) Toggle("Graph Bolus", isOn: $viewModel.graphBolus) Toggle("Graph Carbs", isOn: $viewModel.graphCarbs) diff --git a/LoopFollow/Settings/AdvancedSettingsViewModel.swift b/LoopFollow/Settings/AdvancedSettingsViewModel.swift index 078e62288..38d68b993 100644 --- a/LoopFollow/Settings/AdvancedSettingsViewModel.swift +++ b/LoopFollow/Settings/AdvancedSettingsViewModel.swift @@ -10,12 +10,6 @@ class AdvancedSettingsViewModel: ObservableObject { } } - @Published var downloadPrediction: Bool { - didSet { - Storage.shared.downloadPrediction.value = downloadPrediction - } - } - @Published var graphBasal: Bool { didSet { Storage.shared.graphBasal.value = graphBasal @@ -58,7 +52,6 @@ class AdvancedSettingsViewModel: ObservableObject { init() { downloadTreatments = Storage.shared.downloadTreatments.value - downloadPrediction = Storage.shared.downloadPrediction.value graphBasal = Storage.shared.graphBasal.value graphBolus = Storage.shared.graphBolus.value graphCarbs = Storage.shared.graphCarbs.value diff --git a/LoopFollow/Settings/GeneralSettingsView.swift b/LoopFollow/Settings/GeneralSettingsView.swift index 0ac3acc69..073d9c72d 100644 --- a/LoopFollow/Settings/GeneralSettingsView.swift +++ b/LoopFollow/Settings/GeneralSettingsView.swift @@ -8,6 +8,8 @@ struct GeneralSettingsView: View { @ObservedObject var appBadge = Storage.shared.appBadge @ObservedObject var appearanceMode = Storage.shared.appearanceMode @ObservedObject var showStats = Storage.shared.showStats + @ObservedObject var showTIRBand = Storage.shared.showTIRBand + @ObservedObject var statsSinceMidnight = Storage.shared.statsSinceMidnight @ObservedObject var showSmallGraph = Storage.shared.showSmallGraph @ObservedObject var screenlockSwitchState = Storage.shared.screenlockSwitchState @ObservedObject var showDisplayName = Storage.shared.showDisplayName @@ -44,7 +46,6 @@ struct GeneralSettingsView: View { Text(mode.displayName).tag(mode) } } - Toggle("Display Stats", isOn: $showStats.value) Toggle("Display Small Graph", isOn: $showSmallGraph.value) Toggle("Color BG Text", isOn: $colorBGText.value) Toggle("Keep Screen Active", isOn: $screenlockSwitchState.value) @@ -61,6 +62,24 @@ struct GeneralSettingsView: View { } } + Section { + Toggle("Display Stats", isOn: $showStats.value) + if showStats.value { + Picker("Stats Style", selection: $showTIRBand.value) { + Text("Statistics Box").tag(false) + Text("Time in Range Band").tag(true) + } + Picker("Stats Period", selection: $statsSinceMidnight.value) { + Text("Last 24 Hours").tag(false) + Text("Today (since midnight)").tag(true) + } + } + } header: { + Text("Statistics") + } footer: { + Text("The statistics box and the Time in Range band both cover this period. Today starts at midnight in your time zone, or in the Time Zone Override below when it is on.") + } + Section("Time Zone") { Toggle("Time Zone Override", isOn: $graphTimeZoneEnabled.value) .onChange(of: graphTimeZoneEnabled.value) { _ in markChartSettingsDirty() } diff --git a/LoopFollow/Settings/GraphSettingsView.swift b/LoopFollow/Settings/GraphSettingsView.swift index 7efb38c40..493142ca9 100644 --- a/LoopFollow/Settings/GraphSettingsView.swift +++ b/LoopFollow/Settings/GraphSettingsView.swift @@ -15,6 +15,12 @@ struct GraphSettingsView: View { @ObservedObject private var showPriorDayTimeLines = Storage.shared.showPriorDayTimeLines @ObservedObject private var showYesterdayLine = Storage.shared.showYesterdayLine @ObservedObject private var smallGraphTreatments = Storage.shared.smallGraphTreatments + @ObservedObject private var showIOBGraph = Storage.shared.showIOBGraph + @ObservedObject private var showCOBGraph = Storage.shared.showCOBGraph + @ObservedObject private var showSensitivityRatioGraph = Storage.shared.showSensitivityRatioGraph + @ObservedObject private var showSmoothedBG = Storage.shared.showSmoothedBG + @ObservedObject private var device = Storage.shared.device + @ObservedObject private var historyCurvePlacement = Storage.shared.historyCurvePlacement @ObservedObject private var smallGraphHeight = Storage.shared.smallGraphHeight @ObservedObject private var predictionToLoad = Storage.shared.predictionToLoad @@ -69,6 +75,27 @@ struct GraphSettingsView: View { } } + // ── History Graphs ─────────────────────────────────────────── + if nightscoutEnabled { + Section { + Toggle("Show IOB Graph", isOn: $showIOBGraph.value) + Toggle("Show COB Graph", isOn: $showCOBGraph.value) + if device.value != "Loop" { + Toggle("Show Smoothed BG", isOn: $showSmoothedBG.value) + Toggle("Show Sensitivity Ratio Graph", isOn: $showSensitivityRatioGraph.value) + } + if showIOBGraph.value || showCOBGraph.value || showSensitivityRatioGraph.value { + Picker("Curve Placement", selection: $historyCurvePlacement.value) { + ForEach(HistoryCurvePlacement.allCases, id: \.self) { placement in + Text(placement.displayName).tag(placement) + } + } + } + } header: { + Text("History Graphs") + } + } + // ── Small Graph ────────────────────────────────────────────── Section("Small Graph") { SettingsStepperRow( diff --git a/LoopFollow/Settings/SettingsMenuView.swift b/LoopFollow/Settings/SettingsMenuView.swift index 93fd0fd93..828d36058 100644 --- a/LoopFollow/Settings/SettingsMenuView.swift +++ b/LoopFollow/Settings/SettingsMenuView.swift @@ -157,13 +157,15 @@ enum SettingsRoute: Hashable, Identifiable { SettingsLeaf("Display App Badge", ["badge"]), SettingsLeaf("Persistent Notification"), SettingsLeaf("Appearance", ["dark mode", "light mode", "theme"]), - SettingsLeaf("Display Stats"), SettingsLeaf("Display Small Graph"), SettingsLeaf("Color BG Text"), SettingsLeaf("Keep Screen Active", ["screen lock", "screenlock"]), SettingsLeaf("Show Display Name"), SettingsLeaf("Snoozer emoji"), SettingsLeaf("Force portrait mode", ["orientation", "landscape"]), + SettingsLeaf("Display Stats", ["statistics"]), + SettingsLeaf("Stats Style", ["time in range band", "statistics box", "tir band", "progress bar"]), + SettingsLeaf("Stats Period", ["statistics period", "last 24 hours", "since midnight", "today", "midnight"]), SettingsLeaf("Time Zone Override", ["timezone"]), SettingsLeaf("Speak BG", ["voice", "speech"]), SettingsLeaf("Send anonymous usage stats", ["telemetry", "diagnostics"]), @@ -192,7 +194,7 @@ enum SettingsRoute: Hashable, Identifiable { + InfoType.allCases.map { SettingsLeaf($0.name) } case .units: return [ SettingsLeaf("Glucose Unit"), - SettingsLeaf("Range Mode", ["tir", "titr", "time in range"]), + SettingsLeaf("Range Mode", ["tir", "titr", "ting", "time in range", "extended"]), SettingsLeaf("Glycemic Metrics", ["hba1c", "ehba1c", "gmi"]), SettingsLeaf("Variability", ["standard deviation", "cv"]), ] diff --git a/LoopFollow/Settings/UnitsConfigurationView.swift b/LoopFollow/Settings/UnitsConfigurationView.swift index 2124077c7..5eb4cf6f8 100644 --- a/LoopFollow/Settings/UnitsConfigurationView.swift +++ b/LoopFollow/Settings/UnitsConfigurationView.swift @@ -30,13 +30,19 @@ struct UnitsConfigurationView: View { @State private var highValue = Storage.shared.highLine.value /// Formats a mg/dL threshold pair in the currently selected glucose unit, - /// e.g. "70–180 mg/dL" or "3.9–10.0 mmol/L". + /// e.g. "70–180" or "3.9–10.0". private func rangeBounds(_ lowMgdl: Double, _ highMgdl: Double) -> String { let factor = glucoseUnit == .mmolL ? GlucoseConversion.mgDlToMmolL : 1.0 let digits = glucoseUnit.fractionDigits let low = Localizer.formatToLocalizedString(lowMgdl * factor, maxFractionDigits: digits, minFractionDigits: digits) let high = Localizer.formatToLocalizedString(highMgdl * factor, maxFractionDigits: digits, minFractionDigits: digits) - return "\(low)–\(high) \(glucoseUnit.rawValue)" + return "\(low)–\(high)" + } + + /// The mode's range in the selected unit; custom uses the user's own low and high. + private func rangeBounds(for mode: TimeInRangeDisplayMode) -> String { + let thresholds = mode.fixedThresholds ?? (lowValue, highValue) + return rangeBounds(thresholds.low, thresholds.high) } var body: some View { @@ -68,43 +74,22 @@ struct UnitsConfigurationView: View { private var statisticsSections: some View { Group { Section { - Picker("Range Mode", selection: $rangeMode) { - Text("TIR").tag(TimeInRangeDisplayMode.tir) - Text("TITR").tag(TimeInRangeDisplayMode.titr) - Text("Custom").tag(TimeInRangeDisplayMode.custom) - } - .pickerStyle(.segmented) - .onChange(of: rangeMode) { newValue in - UnitSettingsStore.shared.timeInRangeMode = newValue - Observable.shared.chartSettingsChanged.value = true - } - - if rangeMode == .custom { - BGPicker( - title: "Low", - range: 40 ... 120, - value: $lowValue + NavigationLink { + RangeModeList( + selection: $rangeMode, + lowValue: $lowValue, + highValue: $highValue, + glucoseUnit: glucoseUnit, + bounds: rangeBounds(for:) ) - .id(glucoseUnit) - .onChange(of: lowValue) { newValue in - Storage.shared.lowLine.value = newValue - Observable.shared.chartSettingsChanged.value = true - } - BGPicker( - title: "High", - range: 120 ... 400, - value: $highValue - ) - .id(glucoseUnit) - .onChange(of: highValue) { newValue in - Storage.shared.highLine.value = newValue - Observable.shared.chartSettingsChanged.value = true - } + } label: { + RangeModeRow(mode: rangeMode, bounds: rangeBounds(for: rangeMode)) } + .accessibilityLabel("Range Mode, \(rangeMode.title)") } header: { Text("Range") } footer: { - Text("TIR — Time in Range, the share of readings within \(rangeBounds(70, 180)).\nTITR — Time in Tight Range, within \(rangeBounds(70, 140)).\nCustom — set your own low and high.") + Text("Cut-offs in \(glucoseUnit.rawValue).") } Section { @@ -149,6 +134,94 @@ struct UnitsConfigurationView: View { } } +/// A range mode's name with its cut-offs trailing, on one line. +private struct RangeModeRow: View { + let mode: TimeInRangeDisplayMode + let bounds: String + + var body: some View { + HStack { + Text(mode.title) + .foregroundStyle(Color.primary) + Spacer() + // Color.secondary, not .secondary, which inside a Button is a shade of the tint. + Text(bounds) + .foregroundStyle(Color.secondary) + .monospacedDigit() + } + .lineLimit(1) + } +} + +/// Full-width list of range modes, so names and cut-offs fit on one line. +/// Picking a fixed mode goes back; picking custom stays to set its low and high. +private struct RangeModeList: View { + @Binding var selection: TimeInRangeDisplayMode + @Binding var lowValue: Double + @Binding var highValue: Double + let glucoseUnit: GlucoseDisplayUnit + let bounds: (TimeInRangeDisplayMode) -> String + + @Environment(\.dismiss) private var dismiss + + var body: some View { + Form { + Section { + ForEach(TimeInRangeDisplayMode.allCases, id: \.self) { mode in + Button { + selection = mode + if mode != .custom { + dismiss() + } + } label: { + HStack { + RangeModeRow(mode: mode, bounds: bounds(mode)) + // Always laid out so the cut-offs line up whichever row is selected. + Image(systemName: "checkmark") + .foregroundStyle(.tint) + .opacity(selection == mode ? 1 : 0) + } + } + .accessibilityAddTraits(selection == mode ? .isSelected : []) + } + } footer: { + Text("Cut-offs in \(glucoseUnit.rawValue).") + } + + if selection == .custom { + Section("Custom Range") { + BGPicker( + title: "Low", + range: 40 ... 120, + value: $lowValue + ) + .id(glucoseUnit) + BGPicker( + title: "High", + range: 120 ... 400, + value: $highValue + ) + .id(glucoseUnit) + } + } + } + .onChange(of: selection) { newValue in + UnitSettingsStore.shared.timeInRangeMode = newValue + Observable.shared.chartSettingsChanged.value = true + } + .onChange(of: lowValue) { newValue in + Storage.shared.lowLine.value = newValue + Observable.shared.chartSettingsChanged.value = true + } + .onChange(of: highValue) { newValue in + Storage.shared.highLine.value = newValue + Observable.shared.chartSettingsChanged.value = true + } + .navigationTitle("Range") + .navigationBarTitleDisplayMode(.inline) + } +} + /// Standalone page for units configuration during onboarding. /// Shows a checkmark button in the toolbar to complete setup. struct UnitsOnboardingView: View { diff --git a/LoopFollow/Snoozer/SnoozerView.swift b/LoopFollow/Snoozer/SnoozerView.swift index f70daaf8c..939356d74 100644 --- a/LoopFollow/Snoozer/SnoozerView.swift +++ b/LoopFollow/Snoozer/SnoozerView.swift @@ -13,6 +13,7 @@ struct SnoozerView: View { @ObservedObject var directionText = Observable.shared.directionText @ObservedObject var deltaText = Observable.shared.deltaText @ObservedObject var bgStale = Observable.shared.bgStale + @ObservedObject var cgmSensorState = Observable.shared.cgmSensorState @ObservedObject var bg = Observable.shared.bg @ObservedObject var snoozerEmoji = Storage.shared.snoozerEmoji @@ -46,9 +47,16 @@ struct SnoozerView: View { rightColumn(isLandscape: true) } } else { - VStack(spacing: 0) { - leftColumn(isLandscape: false, barShowing: barShowing) - rightColumn(isLandscape: false) + GeometryReader { content in + VStack(spacing: 0) { + leftColumn( + isLandscape: false, + barShowing: barShowing, + availableHeight: content.size.height - portraitRightColumnMinHeight + ) + .layoutPriority(1) + rightColumn(isLandscape: false) + } } } } @@ -102,7 +110,7 @@ struct SnoozerView: View { if let alarm = vm.activeAlarm { VStack(spacing: 16) { // Alarm name at the top - Text(alarm.name) + Text(vm.alarmTitle) .font(.system(size: 30, weight: .semibold)) .foregroundColor(.white) .lineLimit(1) @@ -155,23 +163,49 @@ struct SnoozerView: View { // MARK: - Columns - private func leftColumn(isLandscape: Bool, barShowing: Bool) -> some View { + private let portraitEmojiMinHeight: CGFloat = 70 + private let portraitClockMinHeight: CGFloat = 80 + private let portraitClockBottomSpace: CGFloat = 16 + private let displayNameHeight: CGFloat = 60 + + /// Height the emoji and clock need below the left column in portrait. + private var portraitRightColumnMinHeight: CGFloat { + let emoji = snoozerEmoji.value ? portraitEmojiMinHeight + 4 : 0 + return emoji + portraitClockMinHeight + portraitClockBottomSpace + 8 + } + + /// The left column's heights are scaled down together when `availableHeight` + /// is smaller than their combined maximum, so the column never pushes the + /// emoji and clock off screen. + private func leftColumn(isLandscape: Bool, barShowing: Bool, availableHeight: CGFloat? = nil) -> some View { let topPad: CGFloat = barShowing ? 0 : 16 - let bigMaxH: CGFloat = barShowing ? (isLandscape ? 210 : 220) : 240 - let dirMaxH: CGFloat = barShowing ? (isLandscape ? 72 : 72) : 80 - let deltaMaxH: CGFloat = barShowing ? (isLandscape ? 60 : 60) : 68 - let ageMaxH: CGFloat = barShowing ? 36 : 40 + let nameH: CGFloat = (!isLandscape && showDisplayName.value) ? displayNameHeight : 0 + let maxHeights: [CGFloat] = [ + barShowing ? (isLandscape ? 210 : 200) : 240, + barShowing ? (isLandscape ? 72 : 90) : (isLandscape ? 80 : 110), + barShowing ? (isLandscape ? 60 : 80) : (isLandscape ? 68 : 100), + barShowing ? (isLandscape ? 36 : 60) : (isLandscape ? 40 : 80), + ] + let fit: CGFloat = availableHeight.map { + min(1, max(0, $0 - topPad - nameH) / maxHeights.reduce(0, +)) + } ?? 1 + let bigMaxH = maxHeights[0] * fit + let dirMaxH = maxHeights[1] * fit + let deltaMaxH = maxHeights[2] * fit + let ageMaxH = maxHeights[3] * fit + let minScale: CGFloat = isLandscape ? 0.5 : 0.3 return VStack(spacing: 0) { if !isLandscape && showDisplayName.value { Text(Bundle.main.displayName) .font(.system(size: 50, weight: .bold)) .foregroundColor(.white.opacity(0.9)) + .frame(height: displayNameHeight) } Text(bgText.value) .font(.system(size: 300, weight: .black)) - .minimumScaleFactor(0.5) + .minimumScaleFactor(minScale) .foregroundColor(bgTextColor.value) .strikethrough( bgStale.value, @@ -185,30 +219,40 @@ struct SnoozerView: View { Text(directionText.value) .font(.system(size: 90, weight: .black)) Text(deltaText.value) - .font(.system(size: 70)) + .font(.system(size: 70).monospacedDigit()) } - .minimumScaleFactor(0.5) + .minimumScaleFactor(minScale) .foregroundColor(.white) .frame(maxWidth: .infinity, maxHeight: dirMaxH) } else { Text(directionText.value) - .font(.system(size: 110, weight: .black)) - .minimumScaleFactor(0.5) + .font(.system(size: 160, weight: .black)) + .minimumScaleFactor(minScale) .foregroundColor(.white) .frame(maxWidth: .infinity, maxHeight: dirMaxH) Text(deltaText.value) - .font(.system(size: 70)) - .minimumScaleFactor(0.5) - .foregroundColor(.white.opacity(0.8)) + .font(.system(size: 140).monospacedDigit()) + .minimumScaleFactor(minScale) + .foregroundColor(.white) .frame(maxWidth: .infinity, maxHeight: deltaMaxH) } Text(minAgoText.value) - .font(.system(size: 60)) - .minimumScaleFactor(0.5) - .foregroundColor(.white.opacity(0.6)) + .font(.system(size: isLandscape ? 60 : 80).monospacedDigit()) + .minimumScaleFactor(minScale) + .foregroundColor(.white) .frame(maxWidth: .infinity, maxHeight: ageMaxH) + + if let state = cgmSensorState.value { + Text("CGM: \(state.summary)") + .font(.system(size: 30, weight: .semibold)) + .minimumScaleFactor(0.5) + .lineLimit(2) + .multilineTextAlignment(.center) + .foregroundColor(.orange) + .frame(maxWidth: .infinity) + } } .padding(.top, topPad) .padding(.horizontal, 16) @@ -224,32 +268,23 @@ struct SnoozerView: View { .padding(.bottom, 8) } - if snoozerEmoji.value { - TimelineView(.periodic(from: .now, by: 1)) { context in - VStack(spacing: 4) { + TimelineView(.periodic(from: .now, by: 1)) { context in + VStack(spacing: 4) { + if snoozerEmoji.value { Text(bgEmoji) .font(.system(size: 128)) .minimumScaleFactor(0.5) - - Text(context.date, format: Date.FormatStyle(date: .omitted, time: .shortened)) - .font(.system(size: 70)) - .minimumScaleFactor(0.5) - .foregroundColor(.white) - .frame(height: 78) - } - } - } else { - TimelineView(.periodic(from: .now, by: 1)) { context in - VStack(spacing: 4) { - Text(context.date, format: Date.FormatStyle(date: .omitted, time: .shortened)) - .font(.system(size: 70)) - .minimumScaleFactor(0.5) - .foregroundColor(.white) - .frame(height: 78) + .frame(minHeight: isLandscape ? nil : portraitEmojiMinHeight) } + + Text(context.date, format: Date.FormatStyle(date: .omitted, time: .shortened)) + .font(.system(size: isLandscape ? 70 : 100).monospacedDigit()) + .minimumScaleFactor(0.5) + .foregroundColor(.white) + .frame(minHeight: isLandscape ? 78 : portraitClockMinHeight, maxHeight: isLandscape ? 78 : 100) } } - Spacer() + Spacer(minLength: isLandscape ? nil : portraitClockBottomSpace) } } diff --git a/LoopFollow/Snoozer/SnoozerViewModel.swift b/LoopFollow/Snoozer/SnoozerViewModel.swift index bb6a3aa16..7b806e52f 100644 --- a/LoopFollow/Snoozer/SnoozerViewModel.swift +++ b/LoopFollow/Snoozer/SnoozerViewModel.swift @@ -6,6 +6,7 @@ import Foundation final class SnoozerViewModel: ObservableObject { @Published var activeAlarm: Alarm? + @Published var alarmTitle: String = "" @Published var snoozeUnits: Int = 5 @Published var timeUnitLabel: String = "minutes" @@ -21,11 +22,19 @@ final class SnoozerViewModel: ObservableObject { .sink { [weak self] alarm in self?.activeAlarm = alarm if let a = alarm { + self?.alarmTitle = Observable.shared.currentAlarmTitleOverride.value ?? a.name self?.snoozeUnits = a.snoozeDuration self?.timeUnitLabel = a.type.snoozeTimeUnit.label } } .store(in: &cancellables) + Observable.shared.currentAlarmTitleOverride.$value + .receive(on: DispatchQueue.main) + .sink { [weak self] title in + guard let self, let alarm = self.activeAlarm else { return } + self.alarmTitle = title ?? alarm.name + } + .store(in: &cancellables) if let alarm = activeAlarm { snoozeUnits = alarm.snoozeDuration } diff --git a/LoopFollow/Stats/GRI/GRICalculator.swift b/LoopFollow/Stats/GRI/GRICalculator.swift index 1d84e17cc..fd170f55b 100644 --- a/LoopFollow/Stats/GRI/GRICalculator.swift +++ b/LoopFollow/Stats/GRI/GRICalculator.swift @@ -15,10 +15,10 @@ class GRICalculator { static func calculate(bgData: [ShareGlucoseData]) -> GRICalculationResult { guard !bgData.isEmpty else { return GRICalculationResult(gri: 0.0, hypoComponent: 0.0, hyperComponent: 0.0) } - let vLowThreshold = 54.0 - let lowThreshold = 70.0 - let highThreshold = 180.0 - let vHighThreshold = 250.0 + let vLowThreshold = GlucoseThresholds.veryLow + let lowThreshold = GlucoseThresholds.tirLow + let highThreshold = GlucoseThresholds.tirHigh + let vHighThreshold = GlucoseThresholds.veryHigh var vLowCount = 0 var lowCount = 0 diff --git a/LoopFollow/Stats/SimpleStatsViewModel.swift b/LoopFollow/Stats/SimpleStatsViewModel.swift index 841f4ca2a..220816ba2 100644 --- a/LoopFollow/Stats/SimpleStatsViewModel.swift +++ b/LoopFollow/Stats/SimpleStatsViewModel.swift @@ -226,9 +226,10 @@ class SimpleStatsViewModel: ObservableObject { var currentTime = startTime while currentTime < endTime { - let currentDate = Date(timeIntervalSince1970: currentTime) - let dayStart = calendar.startOfDay(for: currentDate).timeIntervalSince1970 - let nextDayStart = dayStart + 24 * 60 * 60 + let dayStartDate = calendar.startOfDay(for: Date(timeIntervalSince1970: currentTime)) + guard let nextDayStartDate = calendar.date(byAdding: .day, value: 1, to: dayStartDate) else { break } + let dayStart = dayStartDate.timeIntervalSince1970 + let nextDayStart = nextDayStartDate.timeIntervalSince1970 // Process each profile segment for this day for i in 0 ..< sortedProfile.count { diff --git a/LoopFollow/Stats/StatsDataFetcher.swift b/LoopFollow/Stats/StatsDataFetcher.swift index 18353dce0..a65464d47 100644 --- a/LoopFollow/Stats/StatsDataFetcher.swift +++ b/LoopFollow/Stats/StatsDataFetcher.swift @@ -201,16 +201,7 @@ class StatsDataFetcher { var lastFoundIndex = 0 for currentEntry in bolusEntries.reversed() { - var bolusDate: String - if currentEntry["timestamp"] != nil { - bolusDate = currentEntry["timestamp"] as! String - } else if currentEntry["created_at"] != nil { - bolusDate = currentEntry["created_at"] as! String - } else { - continue - } - - guard let parsedDate = NightscoutUtils.parseDate(bolusDate), + guard let parsedDate = NightscoutUtils.treatmentDate(from: currentEntry), let bolus = currentEntry["insulin"] as? Double else { continue } let dateTimeStamp = parsedDate.timeIntervalSince1970 @@ -250,16 +241,7 @@ class StatsDataFetcher { var lastFoundIndex = 0 for currentEntry in smbEntries.reversed() { - var bolusDate: String - if currentEntry["timestamp"] != nil { - bolusDate = currentEntry["timestamp"] as! String - } else if currentEntry["created_at"] != nil { - bolusDate = currentEntry["created_at"] as! String - } else { - continue - } - - guard let parsedDate = NightscoutUtils.parseDate(bolusDate), + guard let parsedDate = NightscoutUtils.treatmentDate(from: currentEntry), let bolus = currentEntry["insulin"] as? Double else { continue } let dateTimeStamp = parsedDate.timeIntervalSince1970 @@ -296,18 +278,9 @@ class StatsDataFetcher { var lastFoundBolus = 0 for currentEntry in carbEntries.reversed() { - var carbDate: String - if currentEntry["timestamp"] != nil { - carbDate = currentEntry["timestamp"] as! String - } else if currentEntry["created_at"] != nil { - carbDate = currentEntry["created_at"] as! String - } else { - continue - } - let absorptionTime = currentEntry["absorptionTime"] as? Int ?? 0 - guard let parsedDate = NightscoutUtils.parseDate(carbDate), + guard let parsedDate = NightscoutUtils.treatmentDate(from: currentEntry), let carbs = currentEntry["carbs"] as? Double else { continue } let dateTimeStamp = parsedDate.timeIntervalSince1970 @@ -539,9 +512,10 @@ class StatsDataFetcher { var currentTime = startTime while currentTime < endTime { - let currentDate = Date(timeIntervalSince1970: currentTime) - let dayStart = calendar.startOfDay(for: currentDate).timeIntervalSince1970 - let nextDayStart = dayStart + 24 * 60 * 60 + let dayStartDate = calendar.startOfDay(for: Date(timeIntervalSince1970: currentTime)) + guard let nextDayStartDate = calendar.date(byAdding: .day, value: 1, to: dayStartDate) else { break } + let dayStart = dayStartDate.timeIntervalSince1970 + let nextDayStart = nextDayStartDate.timeIntervalSince1970 for i in 0 ..< sortedProfile.count { let segmentRate = sortedProfile[i].value diff --git a/LoopFollow/Stats/TIR/TIRCalculator.swift b/LoopFollow/Stats/TIR/TIRCalculator.swift index b387b5702..a27eef414 100644 --- a/LoopFollow/Stats/TIR/TIRCalculator.swift +++ b/LoopFollow/Stats/TIR/TIRCalculator.swift @@ -7,11 +7,11 @@ class TIRCalculator { static func calculate(bgData: [ShareGlucoseData]) -> [TIRDataPoint] { guard !bgData.isEmpty else { return [] } - let thresholds = UnitSettingsStore.shared.effectiveThresholds() - let veryLowThreshold = 54.0 + let thresholds = UnitSettingsStore.shared.effectiveBandThresholds() + let veryLowThreshold = thresholds.veryLow let lowThreshold = thresholds.low let highThreshold = thresholds.high - let veryHighThreshold = 250.0 + let veryHighThreshold = thresholds.veryHigh var periodData: [TIRPeriod: [Double]] = [:] let calendar = dateTimeUtils.displayCalendar() @@ -91,11 +91,11 @@ class TIRCalculator { return tirPoints } - private static func calculatePercentages(readings: [Double], - veryLowThreshold: Double, - lowThreshold: Double, - highThreshold: Double, - veryHighThreshold: Double) -> (veryLow: Double, low: Double, inRange: Double, high: Double, veryHigh: Double) + static func calculatePercentages(readings: [Double], + veryLowThreshold: Double, + lowThreshold: Double, + highThreshold: Double, + veryHighThreshold: Double) -> (veryLow: Double, low: Double, inRange: Double, high: Double, veryHigh: Double) { let total = Double(readings.count) guard total > 0 else { diff --git a/LoopFollow/Stats/TIR/TIRGraphView.swift b/LoopFollow/Stats/TIR/TIRGraphView.swift index 03a84a14b..1eba0f767 100644 --- a/LoopFollow/Stats/TIR/TIRGraphView.swift +++ b/LoopFollow/Stats/TIR/TIRGraphView.swift @@ -7,7 +7,7 @@ import SwiftUI struct TIRGraphView: View { let tirData: [TIRDataPoint] - private enum Band: String, CaseIterable, Plottable { + enum Band: String, CaseIterable, Plottable { case veryLow = "Very Low" case low = "Low" case inRange = "In Range" diff --git a/LoopFollow/Stats/TIR/TIRView.swift b/LoopFollow/Stats/TIR/TIRView.swift index 8ff66e273..a31aa5d5e 100644 --- a/LoopFollow/Stats/TIR/TIRView.swift +++ b/LoopFollow/Stats/TIR/TIRView.swift @@ -13,7 +13,7 @@ struct TIRView: View { ZStack(alignment: .topTrailing) { VStack(alignment: .leading, spacing: 12) { HStack { - Text(tirTitle) + Text(UnitSettingsStore.shared.timeInRangeMode.title) .font(.caption) .foregroundColor(.secondary) Spacer() @@ -42,21 +42,25 @@ struct TIRView: View { label: "Very High (\(veryHighCutoffText))", percentage: average.veryHigh ) - TIRLegendItem( - color: .yellow, - label: "High (\(highCutoffText))", - percentage: average.high - ) + if hasHighBand { + TIRLegendItem( + color: .yellow, + label: "High (\(highCutoffText))", + percentage: average.high + ) + } TIRLegendItem( color: .green, label: "In Range (\(inRangeCutoffText))", percentage: average.inRange ) - TIRLegendItem( - color: .red.opacity(0.5), - label: "Low (\(lowCutoffText))", - percentage: average.low - ) + if hasLowBand { + TIRLegendItem( + color: .red.opacity(0.5), + label: "Low (\(lowCutoffText))", + percentage: average.low + ) + } TIRLegendItem( color: .red.opacity(0.8), label: "Very Low (\(veryLowCutoffText))", @@ -86,17 +90,6 @@ struct TIRView: View { .buttonStyle(PlainButtonStyle()) } - private var tirTitle: String { - switch UnitSettingsStore.shared.timeInRangeMode { - case .tir: - return "Time in Range" - case .titr: - return "Time in Tight Range" - case .custom: - return "Custom Range" - } - } - private func formatRange(_: Double) -> String { let thresholds = UnitSettingsStore.shared.effectiveThresholds() let glucoseUnit = UnitSettingsStore.shared.glucoseUnit @@ -108,19 +101,31 @@ struct TIRView: View { } private var lowThreshold: Double { - UnitSettingsStore.shared.convertMgdlToDisplay(UnitSettingsStore.shared.effectiveThresholds().low) + UnitSettingsStore.shared.convertMgdlToDisplay(UnitSettingsStore.shared.effectiveBandThresholds().low) } private var highThreshold: Double { - UnitSettingsStore.shared.convertMgdlToDisplay(UnitSettingsStore.shared.effectiveThresholds().high) + UnitSettingsStore.shared.convertMgdlToDisplay(UnitSettingsStore.shared.effectiveBandThresholds().high) } private var veryLowThreshold: Double { - UnitSettingsStore.shared.convertMgdlToDisplay(54.0) + UnitSettingsStore.shared.convertMgdlToDisplay(UnitSettingsStore.shared.effectiveBandThresholds().veryLow) } private var veryHighThreshold: Double { - UnitSettingsStore.shared.convertMgdlToDisplay(250.0) + UnitSettingsStore.shared.convertMgdlToDisplay(UnitSettingsStore.shared.effectiveBandThresholds().veryHigh) + } + + /// A custom range that reaches past the very low cutoff leaves no low band. + private var hasLowBand: Bool { + let thresholds = UnitSettingsStore.shared.effectiveBandThresholds() + return thresholds.veryLow < thresholds.low + } + + /// A custom range that reaches past the very high cutoff leaves no high band. + private var hasHighBand: Bool { + let thresholds = UnitSettingsStore.shared.effectiveBandThresholds() + return thresholds.high < thresholds.veryHigh } private var veryLowCutoffText: String { diff --git a/LoopFollow/Stats/TIR/TIRViewModel.swift b/LoopFollow/Stats/TIR/TIRViewModel.swift index 6f04bddec..c3b932b28 100644 --- a/LoopFollow/Stats/TIR/TIRViewModel.swift +++ b/LoopFollow/Stats/TIR/TIRViewModel.swift @@ -20,15 +20,7 @@ class TIRViewModel: ObservableObject { } func toggleTIRMode() { - let mode = UnitSettingsStore.shared.timeInRangeMode - switch mode { - case .tir: - UnitSettingsStore.shared.timeInRangeMode = .titr - case .titr: - UnitSettingsStore.shared.timeInRangeMode = .custom - case .custom: - UnitSettingsStore.shared.timeInRangeMode = .tir - } + UnitSettingsStore.shared.timeInRangeMode = UnitSettingsStore.shared.timeInRangeMode.next calculateTIR() } diff --git a/LoopFollow/Storage/Observable.swift b/LoopFollow/Storage/Observable.swift index 07877f8a8..229ee6c65 100644 --- a/LoopFollow/Storage/Observable.swift +++ b/LoopFollow/Storage/Observable.swift @@ -36,6 +36,9 @@ class Observable { var predictionColor = ObservableValue(default: .purple) var currentAlarm = ObservableValue(default: nil) + /// Replaces the current alarm's name on screen when the firing needs its + /// own wording (e.g. an end alarm's early warning); nil shows the name. + var currentAlarmTitleOverride = ObservableValue(default: nil) var alarmSoundPlaying = ObservableValue(default: false) var debug = ObservableValue(default: false) @@ -45,6 +48,9 @@ class Observable { var alertLastLoopTime = ObservableValue(default: nil) var previousAlertLastLoopTime = ObservableValue(default: nil) var deviceRecBolus = ObservableValue(default: nil) + /// When the loop cycle behind `deviceRecBolus` ran. Unlike `alertLastLoopTime`, + /// a failed cycle, which keeps the previous recommendation, does not move it. + var deviceRecBolusAt = ObservableValue(default: nil) var deviceBatteryLevel = ObservableValue(default: nil) var deviceBatteryIsCharging = ObservableValue(default: nil) var pumpBatteryLevel = ObservableValue(default: nil) @@ -55,8 +61,14 @@ class Observable { var loopFollowDeviceToken = ObservableValue(default: "") + /// Saved profiles from the latest Loop profile upload; nil until one is loaded. + var loopProfiles = ObservableValue(default: nil) + var isNotLooping = ObservableValue(default: false) + /// CGM state reported after the newest reading; nil while readings flow. + var cgmSensorState = ObservableValue(default: nil) + /// Selected tab index used by SwiftUI TabView — set from MainViewController to switch tabs var selectedTabIndex = ObservableValue(default: 0) diff --git a/LoopFollow/Storage/Storage.swift b/LoopFollow/Storage/Storage.swift index dd63f6dd6..4f533740a 100644 --- a/LoopFollow/Storage/Storage.swift +++ b/LoopFollow/Storage/Storage.swift @@ -22,6 +22,10 @@ class Storage { var lfKeyId = StorageValue(key: "lfKeyId", defaultValue: "") var bundleId = StorageValue(key: "bundleId", defaultValue: "") var user = StorageValue(key: "user", defaultValue: "") + /// Remote command types the AID app advertises in its Nightscout profile. + var remoteCommands = StorageValue<[String]>(key: "remoteCommands", defaultValue: []) + /// Remote command types the Loop build advertises in its devicestatus. + var loopRemoteCommands = StorageValue<[String]>(key: "loopRemoteCommands", defaultValue: []) var maxBolus = SecureStorageValue(key: "maxBolus", defaultValue: HKQuantity(unit: .internationalUnit(), doubleValue: 1.0)) var maxCarbs = SecureStorageValue(key: "maxCarbs", defaultValue: HKQuantity(unit: .gram(), doubleValue: 30.0)) @@ -30,6 +34,8 @@ class Storage { var mealWithBolus = StorageValue(key: "mealWithBolus", defaultValue: false) var mealWithFatProtein = StorageValue(key: "mealWithFatProtein", defaultValue: false) + /// Lets the Apple Watch app send remote commands through this phone. + var watchRemoteCommands = StorageValue(key: "watchRemoteCommands", defaultValue: true) // TODO: This flag can be deleted in March 2027. Check the commit for other places to cleanup. var hasSeenFatProteinOrderChange = StorageValue(key: "hasSeenFatProteinOrderChange", defaultValue: true) @@ -58,8 +64,10 @@ class Storage { var lastOverrideStartNotified = StorageValue(key: "lastOverrideStartNotified", defaultValue: nil) var lastOverrideEndNotified = StorageValue(key: "lastOverrideEndNotified", defaultValue: nil) + var lastOverridePreEndNotified = StorageValue(key: "lastOverridePreEndNotified", defaultValue: nil) var lastTempTargetStartNotified = StorageValue(key: "lastTempTargetStartNotified", defaultValue: nil) var lastTempTargetEndNotified = StorageValue(key: "lastTempTargetEndNotified", defaultValue: nil) + var lastTempTargetPreEndNotified = StorageValue(key: "lastTempTargetPreEndNotified", defaultValue: nil) var lastRecBolusNotified = StorageValue(key: "lastRecBolusNotified", defaultValue: nil) var lastCOBNotified = StorageValue(key: "lastCOBNotified", defaultValue: nil) var lastMissedBolusNotified = StorageValue(key: "lastMissedBolusNotified", defaultValue: nil) @@ -70,6 +78,11 @@ class Storage { var colorBGText = StorageValue(key: "colorBGText", defaultValue: true) var appearanceMode = StorageValue(key: "appearanceMode", defaultValue: .dark) var showStats = StorageValue(key: "showStats", defaultValue: true) + /// Show the home screen stats as a Time in Range band instead of the statistics box. + var showTIRBand = StorageValue(key: "showTIRBand", defaultValue: false) + /// Period both home screen stats (statistics box and Time in Range band) cover: + /// the last 24 hours, or today since midnight. + var statsSinceMidnight = StorageValue(key: "statsSinceMidnight", defaultValue: false) var useIFCC = StorageValue(key: "useIFCC", defaultValue: false) var showSmallGraph = StorageValue(key: "showSmallGraph", defaultValue: true) var screenlockSwitchState = StorageValue(key: "screenlockSwitchState", defaultValue: true) @@ -132,6 +145,11 @@ class Storage { var showPriorDayTimeLines = StorageValue(key: "showPriorDayTimeMarkers", defaultValue: false) var showYesterdayLine = StorageValue(key: "showYesterdayLine", defaultValue: false) var smallGraphTreatments = StorageValue(key: "smallGraphTreatments", defaultValue: true) + var showIOBGraph = StorageValue(key: "showIOBGraph", defaultValue: false) + var showCOBGraph = StorageValue(key: "showCOBGraph", defaultValue: false) + var showSensitivityRatioGraph = StorageValue(key: "showSensitivityRatioGraph", defaultValue: false) + var showSmoothedBG = StorageValue(key: "showSmoothedBG", defaultValue: false) + var historyCurvePlacement = StorageValue(key: "historyCurvePlacement", defaultValue: .separate) var smallGraphHeight = StorageValue(key: "smallGraphHeight", defaultValue: 40) var predictionToLoad = StorageValue(key: "predictionToLoad", defaultValue: 1.0) @@ -165,7 +183,6 @@ class Storage { // MARK: - Advanced settings --------------------------------------------------- var downloadTreatments = StorageValue(key: "downloadTreatments", defaultValue: true) - var downloadPrediction = StorageValue(key: "downloadPrediction", defaultValue: true) var graphOtherTreatments = StorageValue(key: "graphOtherTreatments", defaultValue: true) var graphBasal = StorageValue(key: "graphBasal", defaultValue: true) var graphBolus = StorageValue(key: "graphBolus", defaultValue: true) diff --git a/LoopFollow/Task/AlarmTask.swift b/LoopFollow/Task/AlarmTask.swift index c665c738d..d74289ecf 100644 --- a/LoopFollow/Task/AlarmTask.swift +++ b/LoopFollow/Task/AlarmTask.swift @@ -19,6 +19,8 @@ extension MainViewController { let latestOverrideEnd = self.overrideGraphData.last { $0.endDate <= now }?.endDate let latestTempTargetStart = self.tempTargetGraphData.last { $0.date <= now }?.date let latestTempTargetEnd = self.tempTargetGraphData.last { $0.endDate <= now }?.endDate + let activeOverrideEnd = self.overrideGraphData.last { $0.date <= now && ($0.scheduledEndDate ?? 0) > now }?.scheduledEndDate + let activeTempTargetEnd = self.tempTargetGraphData.last { $0.date <= now && $0.endDate > now }?.scheduledEndDate let recBolus = Observable.shared.deviceRecBolus.value let COB = self.latestCOB?.value let sensorInsertedAt = Storage.shared.sageInsertTime.value @@ -40,6 +42,8 @@ extension MainViewController { latestOverrideEnd: latestOverrideEnd, latestTempTargetStart: latestTempTargetStart, latestTempTargetEnd: latestTempTargetEnd, + activeOverrideEnd: activeOverrideEnd, + activeTempTargetEnd: activeTempTargetEnd, recBolus: recBolus, COB: COB, sageInsertTime: sensorInsertedAt, @@ -75,14 +79,17 @@ extension MainViewController { /// Builds the forward glucose series the low alarm looks ahead in, /// oldest .. newest at 5-minute spacing. /// - /// Loop reports a single forecast, already stored in `predictionData`. Trio - /// reports four forecasts, collapsed into the lowest value per point in time - /// by `lowestForecast(forecasts:start:cap:)`. + /// Both series come from the full forecast as uploaded, independent of the + /// graph settings. Loop reports a single forecast; Trio reports four, + /// collapsed into the lowest value per point in time by + /// `lowestForecast(forecasts:start:cap:)`. func alarmPredictionData() -> [GlucoseValue] { if Storage.shared.device.value == "Loop" { - return predictionData - .prefix(MainViewController.alarmForecastPointCap) - .map { GlucoseValue(sgv: $0.sgv, date: Date(timeIntervalSince1970: $0.date)) } + guard let predBGs = loopPredBGs else { return [] } + return MainViewController.lowestForecast( + forecasts: [predBGs], + start: loopPredUpdatedTime ?? Date().timeIntervalSince1970 + ) } guard let predBGs = openAPSPredBGs else { return [] } @@ -94,9 +101,9 @@ extension MainViewController { ) } - /// Maximum number of forward points (5-minute spacing) the low alarm looks at: - /// 12 points = 60 minutes, matching the predictive look-ahead's upper bound. - static let alarmForecastPointCap = 12 + /// Maximum number of points (5-minute spacing) the low alarm looks at: + /// enough to reach the longest predictive look-ahead the editor offers. + static let alarmForecastPointCap = LowBGCondition.forecastPoints(forMinutes: LowBGCondition.maxPredictiveMinutes) /// Collapses several forecasts into a single series by taking the **lowest** /// value at each point in time, oldest .. newest at 5-minute spacing. @@ -104,7 +111,8 @@ extension MainViewController { /// Trio/OpenAPS reports four forecasts (ZT, IOB, COB, UAM) rather than the /// single one Loop provides, so this lets the predictive-low alarm fire if /// *any* forecast dips to or below the threshold. Empty forecasts are ignored, - /// and each point uses whichever forecasts still extend that far. + /// and each point uses whichever forecasts still extend that far, so one short + /// forecast does not shorten the look-ahead. static func lowestForecast( forecasts: [[Double]], start: TimeInterval, diff --git a/LoopFollow/Task/TreatmentsTask.swift b/LoopFollow/Task/TreatmentsTask.swift index 4e7aa0bc9..bb5a036a8 100644 --- a/LoopFollow/Task/TreatmentsTask.swift +++ b/LoopFollow/Task/TreatmentsTask.swift @@ -15,6 +15,10 @@ extension MainViewController { func treatmentsTaskAction() { // If Nightscout not enabled, wait 60s and try again guard IsNightscoutEnabled(), Storage.shared.downloadTreatments.value else { + if !cgmSensorStates.isEmpty { + cgmSensorStates.removeAll() + updateCGMSensorState() + } TaskScheduler.shared.rescheduleTask(id: .treatments, to: Date().addingTimeInterval(60)) return } diff --git a/LoopFollow/Treatments/LabeledValueRow.swift b/LoopFollow/Treatments/LabeledValueRow.swift new file mode 100644 index 000000000..93e214469 --- /dev/null +++ b/LoopFollow/Treatments/LabeledValueRow.swift @@ -0,0 +1,18 @@ +// LoopFollow +// LabeledValueRow.swift + +import SwiftUI + +/// A label on the left and its value, in secondary color, on the right. +struct LabeledValueRow: View { + let label: String + let value: String + + var body: some View { + HStack { + Text(label) + Spacer() + Text(value).foregroundColor(.secondary) + } + } +} diff --git a/LoopFollow/Treatments/LoopCarbTreatment.swift b/LoopFollow/Treatments/LoopCarbTreatment.swift new file mode 100644 index 000000000..b7a28f480 --- /dev/null +++ b/LoopFollow/Treatments/LoopCarbTreatment.swift @@ -0,0 +1,62 @@ +// LoopFollow +// LoopCarbTreatment.swift + +import Foundation + +/// Loop carb entry metadata from a Nightscout "Carb Correction" treatment. +/// +/// `syncIdentifier` is Loop's stable handle for the entry; it survives edits on the phone, +/// so it addresses the entry in remote delete/edit commands. +struct LoopCarbTreatment: Codable, Equatable { + static let editWindow: TimeInterval = 23 * 3600 + static let requiredRemoteCommands: Set = ["carbs-delete", "carbs-edit"] + + let nightscoutID: String? + let syncIdentifier: String + let enteredBy: String + let date: TimeInterval + let carbs: Double + let absorptionMinutes: Double? + let foodType: String? + + init?(nightscoutEntry entry: [String: AnyObject], date: TimeInterval) { + guard let enteredBy = entry["enteredBy"] as? String, + enteredBy.hasPrefix("loop://"), + entry["eventType"] as? String == "Carb Correction", + let syncIdentifier = entry["syncIdentifier"] as? String, + !syncIdentifier.isEmpty, + let carbs = (entry["carbs"] as? NSNumber)?.doubleValue, + carbs > 0 + else { + return nil + } + + nightscoutID = entry["_id"] as? String + self.syncIdentifier = syncIdentifier + self.enteredBy = enteredBy + self.date = date + self.carbs = carbs + absorptionMinutes = (entry["absorptionTime"] as? NSNumber)?.doubleValue + let rawFoodType = (entry["foodType"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) + foodType = rawFoodType?.isEmpty == false ? rawFoodType : nil + } + + var absorptionHours: Double? { + absorptionMinutes.map { $0 / 60 } + } + + func isWithinEditWindow(now: Date = Date()) -> Bool { + let age = now.timeIntervalSince1970 - date + return age <= Self.editWindow && age >= -3600 + } + + /// Loop APNS targets a Loop device, whatever commands that build advertises. + static func remoteControlActive(remoteType: RemoteType, device: String) -> Bool { + remoteType == .loopAPNS && device == "Loop" + } + + /// Remote edit/delete is offered only when Loop APNS targets a Loop that lists both commands. + static func remoteActionsAvailable(remoteType: RemoteType, device: String, remoteCommands: [String]) -> Bool { + remoteControlActive(remoteType: remoteType, device: device) && requiredRemoteCommands.isSubset(of: remoteCommands) + } +} diff --git a/LoopFollow/Treatments/TreatmentsView.swift b/LoopFollow/Treatments/TreatmentsView.swift index f1b1c7595..c2a91c208 100644 --- a/LoopFollow/Treatments/TreatmentsView.swift +++ b/LoopFollow/Treatments/TreatmentsView.swift @@ -121,7 +121,7 @@ struct TreatmentsView: View { .padding(.bottom, 2) .background(Color(.systemBackground)) } else if let treatment = row.treatment { - TreatmentRow(treatment: treatment) + TreatmentRow(treatment: treatment, rootMeal: viewModel.rootMeal(forFPUChild: treatment)) } } } header: { @@ -199,6 +199,9 @@ struct TreatmentsView: View { .onChange(of: device.value) { newValue in normalizeSelectedFilter(for: newValue) } + .onReceive(NotificationCenter.default.publisher(for: .remoteMealCommandDidComplete)) { _ in + viewModel.refreshTreatments() + } } } } @@ -393,7 +396,31 @@ private struct DayRow: Identifiable { struct TreatmentDetailView: View { let treatment: Treatment + var rootMeal: Treatment? = nil @StateObject private var viewModel = TreatmentDetailViewModel() + @Environment(\.presentationMode) private var presentationMode + @ObservedObject private var commandTracker = RemoteCommandTracker.shared + @ObservedObject private var remoteType = Storage.shared.remoteType + @ObservedObject private var device = Storage.shared.device + @ObservedObject private var remoteCommands = Storage.shared.remoteCommands + @ObservedObject private var loopRemoteCommands = Storage.shared.loopRemoteCommands + @State private var showEditSheet = false + @State private var showDeleteConfirmation = false + + /// The Trio meal remote commands act on: the root meal for an FPU child whose root is loaded. + private var commandMeal: TrioMealTreatment? { + guard let meal = treatment.trioMeal else { return nil } + return meal.isFPUChild ? (rootMeal?.trioMeal ?? meal) : meal + } + + /// Key under which the tracker follows this treatment's remote commands. + private var commandKey: String? { + commandMeal?.mealID.uuidString ?? treatment.loopCarb?.syncIdentifier + } + + private var commandState: RemoteCommandTracker.State? { + commandKey.flatMap { commandTracker.states[$0] } + } var body: some View { List { @@ -415,6 +442,24 @@ struct TreatmentDetailView: View { } } + if let meal = treatment.trioMeal { + let controlActive = TrioMealTreatment.remoteControlActive(remoteType: remoteType.value, device: device.value) + let actionsAvailable = TrioMealTreatment.remoteActionsAvailable(remoteType: remoteType.value, device: device.value, remoteCommands: remoteCommands.value) + trioMealSection(meal, needsNewerTrio: controlActive && !actionsAvailable) + if actionsAvailable { + trioRemoteActionsSection(meal) + } + } + + if let carb = treatment.loopCarb { + let controlActive = LoopCarbTreatment.remoteControlActive(remoteType: remoteType.value, device: device.value) + let actionsAvailable = LoopCarbTreatment.remoteActionsAvailable(remoteType: remoteType.value, device: device.value, remoteCommands: loopRemoteCommands.value) + loopCarbSection(carb, needsCustomization: controlActive && !actionsAvailable) + if actionsAvailable { + loopRemoteActionsSection(carb) + } + } + // Glucose at time if viewModel.isLoading { Section { @@ -558,6 +603,143 @@ struct TreatmentDetailView: View { .onAppear { viewModel.loadDetails(for: treatment) } + .sheet(isPresented: $showEditSheet) { + if let meal = commandMeal { + TRCMealEditView(meal: meal) + } else if let carb = treatment.loopCarb { + LoopCarbEditSheet(carb: carb) + } + } + .confirmationDialog(deleteDialogTitle, isPresented: $showDeleteConfirmation, titleVisibility: .visible) { + Button(treatment.trioMeal != nil ? "Delete meal" : "Delete carbs", role: .destructive, action: sendDelete) + } message: { + Text(deleteDialogMessage) + } + .onChange(of: commandState) { _, state in + guard let commandKey, case .done(success: true, message: _) = state else { return } + commandTracker.consume(key: commandKey) + presentationMode.wrappedValue.dismiss() + } + } + + private var deleteDialogTitle: String { + treatment.trioMeal != nil ? "Delete this meal in Trio?" : "Delete this carb entry in Loop?" + } + + private var deleteDialogMessage: String { + treatment.trioMeal != nil + ? "This removes the carb entry and any fat/protein entries Trio created from it." + : "This removes the carb entry from Loop. Loop recalculates carbs on board right away." + } + + private func sendDelete() { + if let meal = commandMeal { + commandTracker.sendTrioMealDelete(mealID: meal.mealID.uuidString) { _, _ in } + } else if let carb = treatment.loopCarb { + commandTracker.sendLoopCarbDelete(carb: carb) { _, _ in } + } + } + + /// Pending indicator or last failure for this treatment's remote command. + @ViewBuilder + private func commandStatusRow(pendingText: String) -> some View { + switch commandState { + case .pending: + HStack { + ProgressView().scaleEffect(0.8) + Text(pendingText).foregroundColor(.secondary) + } + case let .done(success, message) where !success: + Text(message) + .font(.footnote) + .foregroundColor(.red) + default: + EmptyView() + } + } + + @ViewBuilder + private func loopCarbSection(_ carb: LoopCarbTreatment, needsCustomization: Bool) -> some View { + Section(header: Text("Carb entry"), footer: needsCustomization ? Text("Editing carbs needs the remote carb edit customization in Loop.") : nil) { + LabeledValueRow(label: "Carbs", value: String(format: "%.0f g", carb.carbs)) + if let hours = carb.absorptionHours { + LabeledValueRow(label: "Absorption", value: String(format: "%.1f h", hours)) + } + if let foodType = carb.foodType { + LabeledValueRow(label: "Food type", value: foodType) + } + } + } + + @ViewBuilder + private func loopRemoteActionsSection(_ carb: LoopCarbTreatment) -> some View { + let busy = commandTracker.isBusy(key: carb.syncIdentifier) + let withinWindow = carb.isWithinEditWindow() + + Section(header: Text("Remote actions"), footer: loopRemoteActionsFooter(withinWindow: withinWindow)) { + commandStatusRow(pendingText: "Sent, awaiting confirmation from Loop…") + + Button("Edit carbs") { showEditSheet = true } + .disabled(!withinWindow || busy) + Button("Delete carbs", role: .destructive) { showDeleteConfirmation = true } + .disabled(!withinWindow || busy) + } + } + + private func loopRemoteActionsFooter(withinWindow: Bool) -> Text? { + withinWindow ? nil : Text("Carb entries can be changed remotely for 23 hours.") + } + + @ViewBuilder + private func trioMealSection(_ meal: TrioMealTreatment, needsNewerTrio: Bool) -> some View { + Section(header: Text("Meal"), footer: trioMealFooter(meal, needsNewerTrio: needsNewerTrio)) { + TRCMealMacroRows(carbs: meal.carbs, fat: meal.fat, protein: meal.protein, date: meal.date) + if let note = meal.note { + HStack(alignment: .top) { + Text("Note") + Spacer() + Text(note) + .foregroundColor(.secondary) + .multilineTextAlignment(.trailing) + } + } + if meal.isFPUChild, let rootMeal { + NavigationLink("Show original meal", destination: TreatmentDetailView(treatment: rootMeal)) + } + } + } + + private func trioMealFooter(_ meal: TrioMealTreatment, needsNewerTrio: Bool) -> Text? { + var lines: [String] = [] + if meal.isFPUChild { + lines.append("One of the small carb entries Trio created from this meal's fat and protein. Editing or deleting affects the whole meal.") + } + if needsNewerTrio { + lines.append("Editing meals needs a newer Trio version.") + } + return lines.isEmpty ? nil : Text(lines.joined(separator: "\n")) + } + + @ViewBuilder + private func trioRemoteActionsSection(_ meal: TrioMealTreatment) -> some View { + let target = commandMeal ?? meal + let busy = commandTracker.isBusy(key: target.mealID.uuidString) + let withinWindow = target.isWithinEditWindow() + + Section(header: Text("Remote actions"), footer: remoteActionsFooter(withinWindow: withinWindow)) { + commandStatusRow(pendingText: "Sent, awaiting confirmation from Trio…") + + if !target.isFPUChild { + Button("Edit meal") { showEditSheet = true } + .disabled(!withinWindow || busy) + } + Button("Delete meal", role: .destructive) { showDeleteConfirmation = true } + .disabled(!withinWindow || busy) + } + } + + private func remoteActionsFooter(withinWindow: Bool) -> Text? { + withinWindow ? nil : Text("Meals can be changed remotely up to 24 hours after and 12 hours before their time.") } private func formatNavigationTitle(_ timeInterval: TimeInterval) -> String { @@ -832,9 +1014,10 @@ class TreatmentDetailViewModel: ObservableObject { struct TreatmentRow: View { let treatment: Treatment + var rootMeal: Treatment? = nil var body: some View { - NavigationLink(destination: TreatmentDetailView(treatment: treatment)) { + NavigationLink(destination: TreatmentDetailView(treatment: treatment, rootMeal: rootMeal)) { HStack { Image(systemName: treatment.icon) .foregroundColor(treatment.color) @@ -938,6 +1121,18 @@ enum TreatmentFilter: Hashable { } } +/// What the Treatments list shows for one Nightscout treatment. +struct TreatmentHistoryEntries { + var entries: [Treatment] = [] + /// The entry is an SMB, which picks the list's automatic filter label. + var sawSMB = false + /// The entry is flagged automatic, which picks the list's automatic filter label. + var sawAutomatic = false + + var carb: Treatment? { entries.first { $0.type == .carb } } + var bolus: Treatment? { entries.first { [.bolusManual, .bolusAutomatic, .smb].contains($0.type) } } +} + struct Treatment: Identifiable { let id: String let type: TreatmentType @@ -947,8 +1142,10 @@ struct Treatment: Identifiable { let icon: String let color: Color let bgValue: Int + let trioMeal: TrioMealTreatment? + let loopCarb: LoopCarbTreatment? - init(id: String? = nil, type: TreatmentType, date: TimeInterval, title: String, subtitle: String?, icon: String, color: Color, bgValue: Int) { + init(id: String? = nil, type: TreatmentType, date: TimeInterval, title: String, subtitle: String?, icon: String, color: Color, bgValue: Int, trioMeal: TrioMealTreatment? = nil, loopCarb: LoopCarbTreatment? = nil) { self.id = id ?? "\(type)-\(date)-\(title)" self.type = type self.date = date @@ -957,6 +1154,8 @@ struct Treatment: Identifiable { self.icon = icon self.color = color self.bgValue = bgValue + self.trioMeal = trioMeal + self.loopCarb = loopCarb } var hourKey: String { @@ -967,6 +1166,233 @@ struct Treatment: Identifiable { } } +extension Treatment { + /// A carb entry; `trioMeal` / `loopCarb` carry the AID app's handle for remote edit and delete. + static func carb(nightscoutID: String?, date: TimeInterval, carbs: Double, bgValue: Int, trioMeal: TrioMealTreatment? = nil, loopCarb: LoopCarbTreatment? = nil) -> Treatment { + Treatment( + id: "\(nightscoutID ?? "unknown-\(date)")-carb", + type: .carb, + date: date, + title: carbs > 0 ? "\(Int(carbs))g" : "Meal", + subtitle: carbSubtitle(carbs: carbs, trioMeal: trioMeal), + icon: "circle.fill", + color: .orange, + bgValue: bgValue, + trioMeal: trioMeal, + loopCarb: loopCarb + ) + } + + /// "Carbs" for a carb entry, "Carbs • FPU" for a Trio FPU child, and the fat/protein grams for a Trio meal without carbs. + private static func carbSubtitle(carbs: Double, trioMeal: TrioMealTreatment?) -> String { + if trioMeal?.isFPUChild == true { return "Carbs • FPU" } + guard carbs == 0, let trioMeal else { return "Carbs" } + let parts = [ + trioMeal.fat > 0 ? "\(trioMeal.fat) g fat" : nil, + trioMeal.protein > 0 ? "\(trioMeal.protein) g protein" : nil, + ] + return parts.compactMap { $0 }.joined(separator: " • ") + } + + /// The Treatments list entries for one Nightscout treatment. A Meal Bolus yields a carb entry and a bolus entry. + static func historyEntries(from entry: [String: AnyObject], date: TimeInterval, nearestBG: () -> Int) -> TreatmentHistoryEntries { + var result = TreatmentHistoryEntries() + guard let eventType = entry["eventType"] as? String else { return result } + let nsId = entry["_id"] as? String ?? "unknown-\(date)" + + switch eventType { + case "Carb Correction", "Meal Bolus": + let trioMeal = eventType == "Carb Correction" ? TrioMealTreatment(nightscoutEntry: entry, date: date) : nil + let loopCarb = trioMeal == nil ? LoopCarbTreatment(nightscoutEntry: entry, date: date) : nil + let carbs = entry["carbs"] as? Double ?? 0 + if carbs > 0 || trioMeal != nil { + let actualBG = nearestBG() + let treatment = Treatment.carb( + nightscoutID: nsId, + date: date, + carbs: carbs, + bgValue: actualBG, + trioMeal: trioMeal, + loopCarb: loopCarb + ) + result.entries.append(treatment) + } + + // Also process bolus from Meal Bolus + if eventType == "Meal Bolus", + let insulin = entry["insulin"] as? Double, insulin > 0 + { + let actualBG = nearestBG() + let treatment = Treatment( + id: "\(nsId)-bolus", + type: .bolusManual, + date: date, + title: String(format: "%.2f U", insulin), + subtitle: "Bolus", + icon: "circle.fill", + color: .blue, + bgValue: actualBG + ) + result.entries.append(treatment) + } + + case "Correction Bolus", "Bolus", "External Insulin": + if let insulin = entry["insulin"] as? Double, insulin > 0 { + let isAutomatic = entry["automatic"] as? Bool ?? false + let isSMB = entry["isSMB"] as? Bool ?? false + let typeString = (entry["type"] as? String)?.lowercased() + let isTrioSMBType = typeString == "smb" + let treatAsAutomatic = isAutomatic || isSMB || isTrioSMBType + let actualBG = nearestBG() + + if treatAsAutomatic { + result.sawSMB = isSMB || isTrioSMBType + result.sawAutomatic = isAutomatic + let treatment = Treatment( + id: "\(nsId)-smb", + type: .bolusAutomatic, + date: date, + title: String(format: "%.2f U", insulin), + subtitle: isSMB || isTrioSMBType ? "SMB" : "Automatic Bolus", + icon: "arrowtriangle.down.fill", + color: .blue, + bgValue: actualBG + ) + result.entries.append(treatment) + } else { + let treatment = Treatment( + id: "\(nsId)-bolus", + type: .bolusManual, + date: date, + title: String(format: "%.2f U", insulin), + subtitle: "Bolus", + icon: "circle.fill", + color: .blue, + bgValue: actualBG + ) + result.entries.append(treatment) + } + } + + case "SMB": + if let insulin = entry["insulin"] as? Double, insulin > 0 { + result.sawSMB = true + let actualBG = nearestBG() + let treatment = Treatment( + id: "\(nsId)-smb", + type: .smb, + date: date, + title: String(format: "%.2f U", insulin), + subtitle: "SMB", + icon: "arrowtriangle.down.fill", + color: .blue, + bgValue: actualBG + ) + result.entries.append(treatment) + } + + case "Temp Basal": + if let rate = entry["rate"] as? Double { + let treatment = Treatment( + id: "\(nsId)-basal", + type: .tempBasal, + date: date, + title: String(format: "%.2f U/hr", rate), + subtitle: "Temp Basal", + icon: "chart.xyaxis.line", + color: .blue, + bgValue: 0 + ) + result.entries.append(treatment) + } + + case "Temporary Override", "Exercise": + let durationMinutes = (entry["duration"] as? Double) ?? Double(entry["duration"] as? Int ?? 0) + if durationMinutes == 0 { + return result + } + + let reason = (entry["notes"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) + ?? (entry["reason"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) + ?? "" + + let subtitleParts = ["Override", durationMinutes > 0 ? "\(Int(durationMinutes))m" : nil].compactMap { $0 } + let subtitle = subtitleParts.joined(separator: " • ") + let title = reason.isEmpty ? "Temporary Override" : reason + + let treatment = Treatment( + id: "\(nsId)-override", + type: .override, + date: date, + title: title, + subtitle: subtitle, + icon: "slider.horizontal.3", + color: .purple, + bgValue: 0 + ) + result.entries.append(treatment) + + case "Temporary Target": + let durationMinutes = (entry["duration"] as? Double) ?? Double(entry["duration"] as? Int ?? 0) + if durationMinutes == 0 { + return result + } + + let targetBottom = (entry["targetBottom"] as? Double) ?? Double(entry["targetBottom"] as? Int ?? 0) + let targetTop = (entry["targetTop"] as? Double) ?? Double(entry["targetTop"] as? Int ?? 0) + let targetValue = targetBottom > 0 ? targetBottom : targetTop + + guard targetValue > 0 else { + return result + } + + let title: String + if targetBottom > 0, targetTop > 0, abs(targetTop - targetBottom) >= 0.1 { + title = "\(formatBG(targetBottom)) - \(formatBG(targetTop))" + } else { + title = formatBG(targetValue) + } + + let reason = (entry["reason"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) + let subtitleParts = ["Temp Target", durationMinutes > 0 ? "\(Int(durationMinutes))m" : nil, reason?.isEmpty == false ? reason : nil].compactMap { $0 } + let subtitle = subtitleParts.joined(separator: " • ") + + let treatment = Treatment( + id: "\(nsId)-temp-target", + type: .tempTarget, + date: date, + title: title, + subtitle: subtitle, + icon: "scope", + color: .green, + bgValue: 0 + ) + result.entries.append(treatment) + + default: + break + } + return result + } + + /// Trio root meals keyed by the `fpuID` their FPU children carry. + static func rootMealsByFPUID(_ treatments: some Sequence) -> [UUID: Treatment] { + var roots: [UUID: Treatment] = [:] + for treatment in treatments { + if let meal = treatment.trioMeal, !meal.isFPUChild, let fpuID = meal.fpuID { + roots[fpuID] = treatment + } + } + return roots + } + + /// The root meal for an FPU child, when the Trio build publishes `fpuID` and `roots` holds it. + func rootMeal(in roots: [UUID: Treatment]) -> Treatment? { + guard let child = trioMeal, child.isFPUChild, let fpuID = child.fpuID else { return nil } + return roots[fpuID] + } +} + class TreatmentsViewModel: ObservableObject { @Published var groupedTreatments: [String: [Treatment]] = [:] @Published var isInitialLoading = false @@ -976,6 +1402,7 @@ class TreatmentsViewModel: ObservableObject { @Published var hasAutomaticEntries = false private var allTreatments: [Treatment] = [] + private var rootMealsByFPUID: [UUID: Treatment] = [:] private var processedNightscoutIds = Set() // Track which NS entries we've already processed private var oldestFetchedDate: Date? // Track the oldest treatment date we've fetched private let pageSize = 100 @@ -1016,6 +1443,11 @@ class TreatmentsViewModel: ObservableObject { } } + /// The root meal for an FPU child, when it is loaded. + func rootMeal(forFPUChild treatment: Treatment) -> Treatment? { + treatment.rootMeal(in: rootMealsByFPUID) + } + func refreshTreatments() { allTreatments.removeAll() processedNightscoutIds.removeAll() @@ -1152,7 +1584,7 @@ class TreatmentsViewModel: ObservableObject { guard let mainVC = getMainViewController() else { return ([], false, false) } for entry in entries { - guard let eventType = entry["eventType"] as? String, + guard entry["eventType"] is String, let createdAt = entry["created_at"] as? String, let date = NightscoutUtils.parseDate(createdAt) else { @@ -1170,181 +1602,10 @@ class TreatmentsViewModel: ObservableObject { // Mark this entry as processed processedNightscoutIds.insert(nsId) - switch eventType { - case "Carb Correction", "Meal Bolus": - if let carbs = entry["carbs"] as? Double, carbs > 0 { - let actualBG = findNearestBG(at: timestamp, in: mainVC.bgData) - let treatment = Treatment( - id: "\(nsId)-carb", - type: .carb, - date: timestamp, - title: "\(Int(carbs))g", - subtitle: "Carbs", - icon: "circle.fill", - color: .orange, - bgValue: actualBG - ) - treatments.append(treatment) - } - - // Also process bolus from Meal Bolus - if eventType == "Meal Bolus", - let insulin = entry["insulin"] as? Double, insulin > 0 - { - let actualBG = findNearestBG(at: timestamp, in: mainVC.bgData) - let treatment = Treatment( - id: "\(nsId)-bolus", - type: .bolusManual, - date: timestamp, - title: String(format: "%.2f U", insulin), - subtitle: "Bolus", - icon: "circle.fill", - color: .blue, - bgValue: actualBG - ) - treatments.append(treatment) - } - - case "Correction Bolus", "Bolus", "External Insulin": - if let insulin = entry["insulin"] as? Double, insulin > 0 { - let isAutomatic = entry["automatic"] as? Bool ?? false - let isSMB = entry["isSMB"] as? Bool ?? false - let typeString = (entry["type"] as? String)?.lowercased() - let isTrioSMBType = typeString == "smb" - let treatAsAutomatic = isAutomatic || isSMB || isTrioSMBType - let actualBG = findNearestBG(at: timestamp, in: mainVC.bgData) - - if treatAsAutomatic { - if isSMB || isTrioSMBType { - detectedSMB = true - } - if isAutomatic { - detectedAutomatic = true - } - let treatment = Treatment( - id: "\(nsId)-smb", - type: .bolusAutomatic, - date: timestamp, - title: String(format: "%.2f U", insulin), - subtitle: isSMB || isTrioSMBType ? "SMB" : "Automatic Bolus", - icon: "arrowtriangle.down.fill", - color: .blue, - bgValue: actualBG - ) - treatments.append(treatment) - } else { - let treatment = Treatment( - id: "\(nsId)-bolus", - type: .bolusManual, - date: timestamp, - title: String(format: "%.2f U", insulin), - subtitle: "Bolus", - icon: "circle.fill", - color: .blue, - bgValue: actualBG - ) - treatments.append(treatment) - } - } - - case "SMB": - if let insulin = entry["insulin"] as? Double, insulin > 0 { - detectedSMB = true - let actualBG = findNearestBG(at: timestamp, in: mainVC.bgData) - let treatment = Treatment( - id: "\(nsId)-smb", - type: .smb, - date: timestamp, - title: String(format: "%.2f U", insulin), - subtitle: "SMB", - icon: "arrowtriangle.down.fill", - color: .blue, - bgValue: actualBG - ) - treatments.append(treatment) - } - - case "Temp Basal": - if let rate = entry["rate"] as? Double { - let treatment = Treatment( - id: "\(nsId)-basal", - type: .tempBasal, - date: timestamp, - title: String(format: "%.2f U/hr", rate), - subtitle: "Temp Basal", - icon: "chart.xyaxis.line", - color: .blue, - bgValue: 0 - ) - treatments.append(treatment) - } - - case "Temporary Override", "Exercise": - let durationMinutes = (entry["duration"] as? Double) ?? Double(entry["duration"] as? Int ?? 0) - if durationMinutes == 0 { - continue - } - - let reason = (entry["notes"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) - ?? (entry["reason"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) - ?? "" - - let subtitleParts = ["Override", durationMinutes > 0 ? "\(Int(durationMinutes))m" : nil].compactMap { $0 } - let subtitle = subtitleParts.joined(separator: " • ") - let title = reason.isEmpty ? "Temporary Override" : reason - - let treatment = Treatment( - id: "\(nsId)-override", - type: .override, - date: timestamp, - title: title, - subtitle: subtitle, - icon: "slider.horizontal.3", - color: .purple, - bgValue: 0 - ) - treatments.append(treatment) - - case "Temporary Target": - let durationMinutes = (entry["duration"] as? Double) ?? Double(entry["duration"] as? Int ?? 0) - if durationMinutes == 0 { - continue - } - - let targetBottom = (entry["targetBottom"] as? Double) ?? Double(entry["targetBottom"] as? Int ?? 0) - let targetTop = (entry["targetTop"] as? Double) ?? Double(entry["targetTop"] as? Int ?? 0) - let targetValue = targetBottom > 0 ? targetBottom : targetTop - - guard targetValue > 0 else { - continue - } - - let title: String - if targetBottom > 0, targetTop > 0, abs(targetTop - targetBottom) >= 0.1 { - title = "\(formatBG(targetBottom)) - \(formatBG(targetTop))" - } else { - title = formatBG(targetValue) - } - - let reason = (entry["reason"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) - let subtitleParts = ["Temp Target", durationMinutes > 0 ? "\(Int(durationMinutes))m" : nil, reason?.isEmpty == false ? reason : nil].compactMap { $0 } - let subtitle = subtitleParts.joined(separator: " • ") - - let treatment = Treatment( - id: "\(nsId)-temp-target", - type: .tempTarget, - date: timestamp, - title: title, - subtitle: subtitle, - icon: "scope", - color: .green, - bgValue: 0 - ) - treatments.append(treatment) - - default: - break - } + let history = Treatment.historyEntries(from: entry, date: timestamp) { findNearestBG(at: timestamp, in: mainVC.bgData) } + treatments.append(contentsOf: history.entries) + detectedSMB = detectedSMB || history.sawSMB + detectedAutomatic = detectedAutomatic || history.sawAutomatic } // Sort by date descending (most recent first) @@ -1361,6 +1622,7 @@ class TreatmentsViewModel: ObservableObject { } grouped[key]?.append(treatment) } + rootMealsByFPUID = Treatment.rootMealsByFPUID(allTreatments) // Sort treatments within each hour for key in grouped.keys { diff --git a/LoopFollow/Treatments/TrioMealTreatment.swift b/LoopFollow/Treatments/TrioMealTreatment.swift new file mode 100644 index 000000000..b87559a07 --- /dev/null +++ b/LoopFollow/Treatments/TrioMealTreatment.swift @@ -0,0 +1,78 @@ +// LoopFollow +// TrioMealTreatment.swift + +import Foundation + +/// Trio meal metadata from a Nightscout "Carb Correction" treatment. +/// +/// Nightscout's `_id` identifies the document; `id` is Trio's meal UUID. FPU children are +/// separate documents whose `id` equals the root's `fpuID`, so either value works as a handle +/// for Trio's remote edit/delete commands. A document without `fpuID` is a root. +struct TrioMealTreatment: Codable, Equatable { + static let pastEditWindow: TimeInterval = 24 * 3600 + static let futureEditWindow: TimeInterval = 12 * 3600 + static let requiredRemoteCommands: Set = [TRCCommandType.editMeal.rawValue, TRCCommandType.deleteMeal.rawValue] + + let nightscoutID: String? + let mealID: UUID + let fpuID: UUID? + let date: TimeInterval + let carbs: Double + let fat: Int + let protein: Int + let note: String? + let isFPUChild: Bool + + init?(nightscoutEntry entry: [String: AnyObject], date: TimeInterval) { + guard entry["enteredBy"] as? String == "Trio", + entry["eventType"] as? String == "Carb Correction", + let rawID = entry["id"] as? String, + let mealID = UUID(uuidString: rawID) + else { + return nil + } + + let carbs = Self.number(entry["carbs"]) ?? 0 + let fat = Int((Self.number(entry["fat"]) ?? 0).rounded()) + let protein = Int((Self.number(entry["protein"]) ?? 0).rounded()) + guard carbs > 0 || fat > 0 || protein > 0 else { return nil } + + let fpuID = (entry["fpuID"] as? String).flatMap(UUID.init(uuidString:)) + + let rawNote = (entry["notes"] as? String) ?? (entry["foodType"] as? String) + let trimmedNote = rawNote?.trimmingCharacters(in: .whitespacesAndNewlines) + + nightscoutID = entry["_id"] as? String + self.mealID = mealID + self.fpuID = fpuID + self.date = date + self.carbs = carbs + self.fat = fat + self.protein = protein + note = trimmedNote?.isEmpty == false ? trimmedNote : nil + isFPUChild = fpuID == mealID + } + + var carbsForEdit: Int { Int(carbs.rounded()) } + + func isWithinEditWindow(now: Date = Date()) -> Bool { + let age = now.timeIntervalSince1970 - date + return age <= Self.pastEditWindow && age >= -Self.futureEditWindow + } + + /// Trio Remote Control targets a Trio device, whatever commands that build advertises. + static func remoteControlActive(remoteType: RemoteType, device: String) -> Bool { + remoteType == .trc && device == "Trio" + } + + /// Remote edit/delete is offered only when Trio Remote Control targets a Trio that lists both commands. + static func remoteActionsAvailable(remoteType: RemoteType, device: String, remoteCommands: [String]) -> Bool { + remoteControlActive(remoteType: remoteType, device: device) && requiredRemoteCommands.isSubset(of: remoteCommands) + } + + private static func number(_ value: AnyObject?) -> Double? { + if let number = value as? NSNumber { return number.doubleValue } + if let string = value as? String { return Double(string) } + return nil + } +} diff --git a/LoopFollow/ViewControllers/BGDisplayView.swift b/LoopFollow/ViewControllers/BGDisplayView.swift index ae626fa47..036a92e56 100644 --- a/LoopFollow/ViewControllers/BGDisplayView.swift +++ b/LoopFollow/ViewControllers/BGDisplayView.swift @@ -16,57 +16,87 @@ struct BGDisplayView: View { @ObservedObject var predictionText = Observable.shared.predictionText @ObservedObject var predictionColor = Observable.shared.predictionColor @ObservedObject var isNotLooping = Observable.shared.isNotLooping + @ObservedObject var cgmSensorState = Observable.shared.cgmSensorState + @ObservedObject var retro = RetroSelection.shared var onRefresh: (() -> Void)? var body: some View { + // While scrubbing the chart, the display shows the scrubbed time. + let snapshot = retro.snapshot + let isStale = snapshot == nil && bgStale.value + ScrollView { VStack(spacing: 0) { Text(serverText.value) .font(.system(size: 13)) - Text(bgText.value) + Text(snapshot?.bgText ?? bgText.value) .font(.system(size: 85, weight: .black)) - .foregroundColor(bgTextColor.value) + .foregroundColor(snapshot?.bgColor ?? bgTextColor.value) .strikethrough( - bgStale.value, + isStale, pattern: .solid, - color: bgStale.value ? .red : .clear + color: isStale ? .red : .clear ) .frame(maxWidth: .infinity) .lineLimit(1) .minimumScaleFactor(0.5) HStack { - Text(directionText.value) + Text(snapshot?.directionText ?? directionText.value) .font(.system(size: 60, weight: .black)) - Text(deltaText.value) + Text(snapshot?.deltaText ?? deltaText.value) .font(.system(size: 32)) } .lineLimit(1) .minimumScaleFactor(0.5) - Text(minAgoText.value) - .font(.system(size: 17)) - - if isNotLooping.value { - Text(loopStatusText.value) - .font(.system(size: 18, weight: .bold)) - .foregroundColor(loopStatusColor.value) - .frame(maxWidth: .infinity) + if let snapshot { + Label(snapshot.timeText, systemImage: "clock.arrow.circlepath") + .font(.system(size: 17, weight: .semibold)) + .padding(.horizontal, 10) + .padding(.vertical, 2) + .background(Capsule().fill(RetroSnapshot.accent.opacity(0.3))) + Text(snapshot.predictionText) + .foregroundColor(snapshot.predictionColor) + .font(.system(size: 17)) } else { - HStack { - Spacer() + Text(minAgoText.value) + .font(.system(size: 17)) + + if let state = cgmSensorState.value { + Text("CGM: \(state.summary)") + .font(.system(size: 17, weight: .semibold)) + .foregroundColor(.orange) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity) + } + + if isNotLooping.value { Text(loopStatusText.value) + .font(.system(size: 18, weight: .bold)) .foregroundColor(loopStatusColor.value) - Text(predictionText.value) - .foregroundColor(predictionColor.value) - Spacer() + .frame(maxWidth: .infinity) + } else { + HStack { + Spacer() + Text(loopStatusText.value) + .foregroundColor(loopStatusColor.value) + Text(predictionText.value) + .foregroundColor(predictionColor.value) + Spacer() + } + .font(.system(size: 17)) } - .font(.system(size: 17)) } } } + .background { + if snapshot != nil { + RetroCardBackground() + } + } .refreshable { onRefresh?() } diff --git a/LoopFollow/ViewControllers/MainHomeView.swift b/LoopFollow/ViewControllers/MainHomeView.swift index 599120f7d..63732e65b 100644 --- a/LoopFollow/ViewControllers/MainHomeView.swift +++ b/LoopFollow/ViewControllers/MainHomeView.swift @@ -13,6 +13,7 @@ struct MainHomeView: View { @ObservedObject var showSmallGraph = Storage.shared.showSmallGraph @ObservedObject var showStats = Storage.shared.showStats + @ObservedObject var showTIRBand = Storage.shared.showTIRBand @ObservedObject var hideInfoTable = Storage.shared.hideInfoTable @ObservedObject var smallGraphHeight = Storage.shared.smallGraphHeight @ObservedObject var url = Storage.shared.url @@ -43,12 +44,6 @@ struct MainHomeView: View { InfoTableView(infoManager: infoManager, timeZoneOverride: timeZoneOverride) .dynamicTypeSize(...DynamicTypeSize.accessibility1) .frame(minWidth: 160, maxWidth: 250) - .overlay( - Rectangle() - .fill(Color(UIColor.darkGray)) - .frame(width: 2), - alignment: .leading - ) } } .fixedSize(horizontal: false, vertical: true) @@ -64,7 +59,11 @@ struct MainHomeView: View { // Statistics if showStats.value { - StatsDisplayView(model: statsModel, onTap: onStatsTap) + if showTIRBand.value { + TIRBandView(model: statsModel, onTap: onStatsTap) + } else { + StatsDisplayView(model: statsModel, onTap: onStatsTap) + } } } .padding(8) diff --git a/LoopFollow/ViewControllers/MainViewController.swift b/LoopFollow/ViewControllers/MainViewController.swift index 06c5e1f76..b4af72f72 100644 --- a/LoopFollow/ViewControllers/MainViewController.swift +++ b/LoopFollow/ViewControllers/MainViewController.swift @@ -91,6 +91,8 @@ class MainViewController: UIViewController, UNUserNotificationCenterDelegate { var iobPredictionData: [ShareGlucoseData] = [] var cobPredictionData: [ShareGlucoseData] = [] var uamPredictionData: [ShareGlucoseData] = [] + var loopPredBGs: [Double]? + var loopPredUpdatedTime: TimeInterval? var openAPSPredBGs: [String: [Double]]? var openAPSPredUpdatedTime: TimeInterval? var bgCheckData: [ShareGlucoseData] = [] @@ -98,12 +100,14 @@ class MainViewController: UIViewController, UNUserNotificationCenterDelegate { var resumeGraphData: [DataStructs.timestampOnlyStruct] = [] var sensorStartGraphData: [DataStructs.timestampOnlyStruct] = [] var noteGraphData: [DataStructs.noteStruct] = [] + var cgmSensorStates: [CGMSensorState] = [] var deviceBatteryData: [DataStructs.batteryStruct] = [] var lastCalDate: Double = 0 var latestLoopStatusString = "" var latestCOB: CarbMetric? var latestBasal = "" var latestPumpVolume: Double? + var latestPumpBatteryDate: Date? var latestIOB: InsulinMetric? var lastOverrideStartTime: TimeInterval = 0 var lastOverrideEndTime: TimeInterval = 0 @@ -151,6 +155,7 @@ class MainViewController: UIViewController, UNUserNotificationCenterDelegate { view.backgroundColor = .systemBackground infoManager = InfoManager() + WatchSessionManager.shared.observe(infoManager, stats: statsDisplayModel) let mainView = MainHomeView( chartModel: chartModel, @@ -219,6 +224,8 @@ class MainViewController: UIViewController, UNUserNotificationCenterDelegate { // (this controller is built only after storage is ready, so it never fires // while we're alive); retained one release as a safety net. notificationCenter.addObserver(self, selector: #selector(handleBFUReloadCompleted), name: .bfuReloadCompleted, object: nil) + // A remote meal edit/delete changes the carb marks on the chart, whichever screen sent it. + notificationCenter.addObserver(self, selector: #selector(handleRemoteMealCommandDidComplete), name: .remoteMealCommandDidComplete, object: nil) #if !targetEnvironment(macCatalyst) notificationCenter.addObserver(self, selector: #selector(navigateOnLAForeground), name: .liveActivityDidForeground, object: nil) @@ -233,6 +240,11 @@ class MainViewController: UIViewController, UNUserNotificationCenterDelegate { // setup display for NS vs Dex showHideNSDetails() + DeviceStatusHistory.shared.onChange = { [weak self] in + self?.chartModel.rebuild() + } + DeviceStatusHistory.shared.start() + scheduleAllTasks() setupNightscoutSocket() @@ -269,7 +281,12 @@ class MainViewController: UIViewController, UNUserNotificationCenterDelegate { Storage.shared.units.$value.map { _ in () }.eraseToAnyPublisher(), Storage.shared.useIFCC.$value.map { _ in () }.eraseToAnyPublisher(), Storage.shared.showGMI.$value.map { _ in () }.eraseToAnyPublisher(), - Storage.shared.showStdDev.$value.map { _ in () }.eraseToAnyPublisher() + Storage.shared.showStdDev.$value.map { _ in () }.eraseToAnyPublisher(), + Storage.shared.statsSinceMidnight.$value.map { _ in () }.eraseToAnyPublisher(), + Storage.shared.lowLine.$value.map { _ in () }.eraseToAnyPublisher(), + Storage.shared.highLine.$value.map { _ in () }.eraseToAnyPublisher(), + Storage.shared.graphTimeZoneEnabled.$value.map { _ in () }.eraseToAnyPublisher(), + Storage.shared.graphTimeZoneIdentifier.$value.map { _ in () }.eraseToAnyPublisher() ) .receive(on: DispatchQueue.main) .sink { [weak self] _ in @@ -282,6 +299,8 @@ class MainViewController: UIViewController, UNUserNotificationCenterDelegate { .sink { [weak self] _ in self?.updateBGGraphSettings() self?.updateBGGraph() + LiveActivityManager.shared.refreshFromCurrentState(reason: "range mode changed") + self?.updateStats() } .store(in: &cancellables) @@ -581,7 +600,7 @@ class MainViewController: UIViewController, UNUserNotificationCenterDelegate { @objc func appMovedToBackground() { LogManager.shared.log( category: .general, - message: "App moved to background (refreshType=\(Storage.shared.backgroundRefreshType.value.rawValue), lowPowerMode=\(ProcessInfo.processInfo.isLowPowerModeEnabled), backgroundRefreshStatus=\(Self.describe(UIApplication.shared.backgroundRefreshStatus)))" + message: "App moved to background (refreshType=\(Storage.shared.backgroundRefreshType.value.rawValue), lowPowerMode=\(ProcessInfo.processInfo.isLowPowerModeEnabled), backgroundRefreshStatus=\(Self.describe(UIApplication.shared.backgroundRefreshStatus)), \(ExitDiagnostics.memoryDescription()))" ) // Allow screen to turn off @@ -681,6 +700,15 @@ class MainViewController: UIViewController, UNUserNotificationCenterDelegate { runMigrationsIfNeeded() } + @objc func handleRemoteMealCommandDidComplete(_ notification: Notification) { + if (notification.userInfo?["key"] as? String)?.hasPrefix(LoopProfileAckAdapter.keyPrefix) == true { + // Loop uploads the switched profile right after it confirms the command. + TaskScheduler.shared.rescheduleTask(id: .profile, to: Date().addingTimeInterval(10)) + } else { + TaskScheduler.shared.rescheduleTask(id: .treatments, to: Date()) + } + } + @objc func handleBFUReloadCompleted() { // Show the loading overlay so the user sees feedback during the 2-5s // while tasks re-run with the now-correct credentials. Tasks scheduled @@ -800,7 +828,11 @@ class MainViewController: UIViewController, UNUserNotificationCenterDelegate { } #if !targetEnvironment(macCatalyst) - LiveActivityManager.shared.startFromCurrentState() + // Creation at launch belongs to didBecomeActive; this covers + // returning to the tab while the app is already active. + if UIApplication.shared.applicationState == .active { + LiveActivityManager.shared.startFromCurrentState() + } #endif } diff --git a/LoopFollow/Watch/WatchCommandHandler.swift b/LoopFollow/Watch/WatchCommandHandler.swift new file mode 100644 index 000000000..238e1d964 --- /dev/null +++ b/LoopFollow/Watch/WatchCommandHandler.swift @@ -0,0 +1,213 @@ +// LoopFollow +// WatchCommandHandler.swift + +import Foundation +import HealthKit + +/// Executes commands from the watch with the same checks and send paths as the +/// phone's Remote screens. Runs on the main queue. +final class WatchCommandHandler { + static let shared = WatchCommandHandler() + + private var handledIDs: [UUID] = [] + + private init() {} + + /// The guardrails from the phone's Remote settings. + static func limits() -> WatchCommandValidator.Limits { + WatchCommandValidator.Limits( + maxBolus: Storage.shared.maxBolus.value.doubleValue(for: .internationalUnit()), + bolusIncrement: Storage.shared.bolusIncrement.value.doubleValue(for: .internationalUnit()), + maxCarbs: Storage.shared.maxCarbs.value.doubleValue(for: .gram()), + maxFat: Storage.shared.maxFat.value.doubleValue(for: .gram()), + maxProtein: Storage.shared.maxProtein.value.doubleValue(for: .gram()), + mealWithBolus: Storage.shared.mealWithBolus.value, + mealWithFatProtein: Storage.shared.mealWithFatProtein.value + ) + } + + func handle(_ command: WatchCommand, reply: @escaping (WatchCommandReply) -> Void) { + let finish: (Bool, String) -> Void = { success, message in + LogManager.shared.log(category: .apns, message: "Watch command \(command.action.name) finished: success=\(success) \(message)") + reply(WatchCommandReply(success: success, message: message)) + } + + guard !handledIDs.contains(command.id) else { + finish(false, "This command was already handled.") + return + } + handledIDs.append(command.id) + if handledIDs.count > 50 { handledIDs.removeFirst() } + + guard abs(Date().timeIntervalSince(command.createdAt)) <= WatchCommand.maxAge else { + finish(false, "The command arrived too late and was not sent. Try again.") + return + } + + LogManager.shared.log(category: .apns, message: "Watch command received: \(command.action.name)") + + if case .snoozeAlarm = command.action { + guard Observable.shared.currentAlarm.value != nil else { + finish(false, "No alarm is active.") + return + } + AlarmManager.shared.performSnooze() + finish(true, "Alarm snoozed.") + return + } + + guard Storage.shared.watchRemoteCommands.value else { + finish(false, "Commands from Apple Watch are turned off in LoopFollow's Remote settings.") + return + } + guard let kind = WatchStateBuilder.remoteKind() else { + finish(false, "Remote control is not set up in LoopFollow.") + return + } + + switch WatchCommandValidator.validate(command.action, kind: kind, limits: Self.limits()) { + case let .failure(error): + finish(false, error.message) + case let .success(action): + switch kind { + case .loop: sendLoop(action, finish: finish) + case .trio: sendTrio(action, finish: finish) + } + } + } + + // MARK: - Loop + + private func sendLoop(_ action: WatchCommand.Action, finish: @escaping (Bool, String) -> Void) { + let service = LoopAPNSService() + let sent: (Bool, String?) -> Void = { success, error in + DispatchQueue.main.async { + finish(success, success ? RemoteCommandMessage.sent : (error ?? "Failed to send the command.")) + } + } + + switch action { + case let .bolus(units): + guard service.validateSetup() else { return finish(false, loopSetupIncomplete) } + guard let otp = loopOTP(finish: finish) else { return } + service.sendBolusViaAPNS(payload: LoopAPNSPayload(type: .bolus, bolusAmount: units, otp: otp)) { success, error in + DispatchQueue.main.async { + if success { + TOTPService.shared.markTOTPAsUsed(qrCodeURL: Storage.shared.loopAPNSQrCodeURL.value) + QuickPickBolusesManager.shared.recordBolus(units: units) + } + } + sent(success, error) + } + + case let .meal(carbs, _, _, _, consumedAt): + guard service.validateSetup() else { return finish(false, loopSetupIncomplete) } + guard let otp = loopOTP(finish: finish) else { return } + let payload = LoopAPNSPayload( + type: .carbs, + carbsAmount: carbs, + absorptionTime: WatchCommandValidator.loopAbsorptionHours, + consumedDate: consumedAt.dateUsingCurrentSeconds(), + otp: otp + ) + service.sendCarbsViaAPNS(payload: payload) { success, error in + DispatchQueue.main.async { + if success { + TOTPService.shared.markTOTPAsUsed(qrCodeURL: Storage.shared.loopAPNSQrCodeURL.value) + QuickPickMealsManager.shared.recordMeal(carbs: carbs) + } + } + sent(success, error) + } + + case let .startOverride(presetID): + guard let preset = ProfileManager.shared.loopOverrides.first(where: { $0.name == presetID }) else { + return finish(false, "The override preset was not found.") + } + let duration = TimeInterval(preset.duration ?? 0) + service.sendOverrideNotification(presetName: preset.name, duration: duration > 0 ? duration : nil, completion: sent) + + case .cancelOverride: + service.sendCancelOverrideNotification(completion: sent) + + case .startTempTarget, .cancelTempTarget, .snoozeAlarm: + finish(false, "This command is not available for Loop.") + } + } + + private var loopSetupIncomplete: String { + "Loop APNS setup is incomplete. Configure it in LoopFollow's Remote settings." + } + + /// Mirrors the one-time code checks in the phone's Loop bolus and carbs screens. + private func loopOTP(finish: (Bool, String) -> Void) -> String? { + let url = Storage.shared.loopAPNSQrCodeURL.value + guard let otp = TOTPGenerator.extractOTPFromURL(url) else { + finish(false, "Invalid QR code URL. Re-scan the QR code in LoopFollow's Remote settings.") + return nil + } + guard !TOTPService.shared.isTOTPBlocked(qrCodeURL: url) else { + finish(false, "The current one-time code was already used. Wait for the next code (up to 30 seconds) and try again.") + return nil + } + return otp + } + + // MARK: - Trio + + private func sendTrio(_ action: WatchCommand.Action, finish: @escaping (Bool, String) -> Void) { + let manager = PushNotificationManager() + let sent: (Bool, String?) -> Void = { success, error in + DispatchQueue.main.async { + finish(success, success ? RemoteCommandMessage.sent : (error ?? "Failed to send the command.")) + } + } + + switch action { + case let .bolus(units): + manager.sendBolusPushNotification(bolusAmount: HKQuantity(unit: .internationalUnit(), doubleValue: units)) { success, error in + if success { + DispatchQueue.main.async { QuickPickBolusesManager.shared.recordBolus(units: units) } + } + sent(success, error) + } + + case let .meal(carbs, fat, protein, bolus, consumedAt): + manager.sendMealPushNotification( + carbs: HKQuantity(unit: .gram(), doubleValue: carbs), + protein: HKQuantity(unit: .gram(), doubleValue: protein), + fat: HKQuantity(unit: .gram(), doubleValue: fat), + bolusAmount: HKQuantity(unit: .internationalUnit(), doubleValue: bolus), + scheduledTime: WatchCommand.trioScheduledTime(consumedAt) + ) { success, error in + if success, carbs > 0 { + DispatchQueue.main.async { + QuickPickMealsManager.shared.recordMeal(carbs: carbs, fat: fat, protein: protein, bolus: bolus) + } + } + sent(success, error) + } + + case let .startOverride(presetID): + guard let override = ProfileManager.shared.trioOverrides.first(where: { $0.name == presetID }) else { + return finish(false, "The override preset was not found.") + } + manager.sendOverridePushNotification(override: override, completion: sent) + + case .cancelOverride: + manager.sendCancelOverridePushNotification(completion: sent) + + case let .startTempTarget(presetID): + guard let preset = TempTargetPresetManager.shared.presets.first(where: { $0.id.uuidString == presetID }) else { + return finish(false, "The temp target preset was not found.") + } + manager.sendTempTargetPushNotification(target: preset.target, duration: preset.duration, completion: sent) + + case .cancelTempTarget: + manager.sendCancelTempTargetPushNotification(completion: sent) + + case .snoozeAlarm: + finish(false, "Unknown request.") + } + } +} diff --git a/LoopFollow/Watch/WatchCredentialsBuilder.swift b/LoopFollow/Watch/WatchCredentialsBuilder.swift new file mode 100644 index 000000000..52ef3965d --- /dev/null +++ b/LoopFollow/Watch/WatchCredentialsBuilder.swift @@ -0,0 +1,41 @@ +// LoopFollow +// WatchCredentialsBuilder.swift + +import Foundation + +/// Collects what the watch needs to fetch data and send commands on its own. +enum WatchCredentialsBuilder { + static func build() -> WatchCredentials { + WatchCredentials( + nightscoutURL: Storage.shared.url.value, + nightscoutToken: Storage.shared.token.value, + dexcomUsername: Storage.shared.shareUserName.value, + dexcomPassword: Storage.shared.sharePassword.value, + dexcomServer: Storage.shared.shareServer.value, + remote: remote() + ) + } + + private static func remote() -> WatchCredentials.Remote? { + guard Storage.shared.watchRemoteCommands.value, let kind = WatchStateBuilder.remoteKind() else { return nil } + let signing = RemoteSigningKey.resolve( + lfTeamID: BuildDetails.default.teamID ?? "", + remoteTeamID: Storage.shared.teamId.value ?? "", + lfKey: (Storage.shared.lfApnsKey.value, Storage.shared.lfKeyId.value), + remoteKey: (Storage.shared.remoteApnsKey.value, Storage.shared.remoteKeyId.value) + ) + return WatchCredentials.Remote( + kind: kind, + deviceToken: Storage.shared.deviceToken.value, + bundleID: Storage.shared.bundleId.value, + production: Storage.shared.productionEnvironment.value, + apnsKey: signing.key, + keyID: signing.keyID, + teamID: signing.teamID, + user: Storage.shared.user.value, + sharedSecret: Storage.shared.sharedSecret.value, + loopQRCodeURL: Storage.shared.loopAPNSQrCodeURL.value, + returnNotification: ReturnNotificationInfo.loopFollow() + ) + } +} diff --git a/LoopFollow/Watch/WatchSessionManager.swift b/LoopFollow/Watch/WatchSessionManager.swift new file mode 100644 index 000000000..041169524 --- /dev/null +++ b/LoopFollow/Watch/WatchSessionManager.swift @@ -0,0 +1,242 @@ +// LoopFollow +// WatchSessionManager.swift + +import Combine +import Foundation +import WatchConnectivity + +/// Phone side of the Apple Watch link. Publishes `WatchState` as the +/// application context and relays commands from the watch to +/// `WatchCommandHandler`. +final class WatchSessionManager: NSObject { + static let shared = WatchSessionManager() + + /// Complication pushes are rationed by watchOS (about 50 per day), so they + /// are only used when a new reading arrives and this long has passed. + private let complicationTransferInterval: TimeInterval = 15 * 60 + + /// A state equal to the last one is still sent this often, well inside the + /// watch's 10 minutes before it treats the phone's data as stale. + private let unchangedResendInterval: TimeInterval = 5 * 60 + + private var lastSentState: WatchState? + private var lastContextUpdate: Date = .distantPast + private var lastSentCredentials: WatchCredentials? + private var lastComplicationTransfer: Date = .distantPast + private var cancellables = Set() + private lazy var coalescer = RefreshCoalescer(delay: 2) { [weak self] reason in + self?.publish(reason: reason) + } + + private var session: WCSession? { + WCSession.isSupported() ? WCSession.default : nil + } + + func activate() { + guard let session else { return } + session.delegate = self + session.activate() + observeSettings() + } + + /// Schedules a state update. Safe to call often; requests are coalesced. + func requestUpdate(reason: String) { + guard Thread.isMainThread else { + DispatchQueue.main.async { self.requestUpdate(reason: reason) } + return + } + guard let session, session.activationState == .activated, session.isPaired, session.isWatchAppInstalled else { return } + coalescer.request(reason: reason) + } + + private func observeSettings() { + let triggers: [AnyPublisher] = [ + Observable.shared.currentAlarm.$value.map { _ in () }.eraseToAnyPublisher(), + Storage.shared.watchRemoteCommands.$value.map { _ in () }.eraseToAnyPublisher(), + Storage.shared.remoteType.$value.map { _ in () }.eraseToAnyPublisher(), + Storage.shared.colorBGText.$value.map { _ in () }.eraseToAnyPublisher(), + Storage.shared.units.$value.map { _ in () }.eraseToAnyPublisher(), + Storage.shared.mealWithBolus.$value.map { _ in () }.eraseToAnyPublisher(), + Storage.shared.mealWithFatProtein.$value.map { _ in () }.eraseToAnyPublisher(), + Storage.shared.infoDisplayItems.$value.map { _ in () }.eraseToAnyPublisher(), + Storage.shared.graphTimeZoneEnabled.$value.map { _ in () }.eraseToAnyPublisher(), + ] + Publishers.MergeMany(triggers) + .dropFirst(triggers.count) + .sink { [weak self] in self?.requestUpdate(reason: "settings") } + .store(in: &cancellables) + + let credentialTriggers: [AnyPublisher] = [ + Storage.shared.url.$value.map { _ in () }.eraseToAnyPublisher(), + Storage.shared.token.$value.map { _ in () }.eraseToAnyPublisher(), + Storage.shared.shareUserName.$value.map { _ in () }.eraseToAnyPublisher(), + Storage.shared.sharePassword.$value.map { _ in () }.eraseToAnyPublisher(), + Storage.shared.shareServer.$value.map { _ in () }.eraseToAnyPublisher(), + Storage.shared.remoteType.$value.map { _ in () }.eraseToAnyPublisher(), + Storage.shared.watchRemoteCommands.$value.map { _ in () }.eraseToAnyPublisher(), + Storage.shared.deviceToken.$value.map { _ in () }.eraseToAnyPublisher(), + Storage.shared.bundleId.$value.map { _ in () }.eraseToAnyPublisher(), + Storage.shared.productionEnvironment.$value.map { _ in () }.eraseToAnyPublisher(), + Storage.shared.teamId.$value.map { _ in () }.eraseToAnyPublisher(), + Storage.shared.lfApnsKey.$value.map { _ in () }.eraseToAnyPublisher(), + Storage.shared.lfKeyId.$value.map { _ in () }.eraseToAnyPublisher(), + Storage.shared.remoteApnsKey.$value.map { _ in () }.eraseToAnyPublisher(), + Storage.shared.remoteKeyId.$value.map { _ in () }.eraseToAnyPublisher(), + Storage.shared.user.$value.map { _ in () }.eraseToAnyPublisher(), + Storage.shared.sharedSecret.$value.map { _ in () }.eraseToAnyPublisher(), + Storage.shared.loopAPNSQrCodeURL.$value.map { _ in () }.eraseToAnyPublisher(), + Observable.shared.loopFollowDeviceToken.$value.map { _ in () }.eraseToAnyPublisher(), + ] + Publishers.MergeMany(credentialTriggers) + .debounce(for: .seconds(2), scheduler: DispatchQueue.main) + .sink { [weak self] in self?.sendCredentialsIfChanged(force: false) } + .store(in: &cancellables) + } + + /// Sends the credentials when they differ from what was last sent, or when + /// the watch asks for them. They never travel with the state updates. + private func sendCredentialsIfChanged(force: Bool) { + guard let session, session.activationState == .activated, session.isPaired, session.isWatchAppInstalled else { return } + let credentials = WatchCredentialsBuilder.build() + guard force || credentials != lastSentCredentials, + let data = try? JSONEncoder().encode(credentials) + else { return } + for transfer in session.outstandingUserInfoTransfers where transfer.userInfo[WatchCredentials.userInfoKey] != nil { + transfer.cancel() + } + // Immediate when the watch app is reachable; the queued transfer covers the rest. + if session.isReachable { + session.sendMessage([WatchCredentials.userInfoKey: data], replyHandler: nil, errorHandler: nil) + } + session.transferUserInfo([WatchCredentials.userInfoKey: data]) + lastSentCredentials = credentials + LogManager.shared.log(category: .general, message: "[Watch] credentials sent") + } + + /// Sends the info table and statistics to the watch whenever they change. + func observe(_ infoManager: InfoManager, stats: StatsDisplayModel) { + infoManager.objectWillChange + .sink { [weak self] in self?.requestUpdate(reason: "info") } + .store(in: &cancellables) + stats.objectWillChange + .sink { [weak self] in self?.requestUpdate(reason: "stats") } + .store(in: &cancellables) + } + + private func publish(reason: String) { + guard let session, session.activationState == .activated else { return } + let state = WatchStateBuilder.build() + // Most refreshes rebuild the chart without changing anything the watch shows; + // sending those would only wake the watch. + if let lastSentState, Self.sameContent(state, lastSentState), + Date().timeIntervalSince(lastContextUpdate) < unchangedResendInterval + { + LogManager.shared.log(category: .general, message: "[Watch] state unchanged, not sent reason=\(reason)", isDebug: true) + return + } + guard let data = state.encoded() else { return } + + do { + try session.updateApplicationContext([WatchState.contextKey: data]) + lastContextUpdate = Date() + } catch { + lastContextUpdate = .distantPast + LogManager.shared.log(category: .general, message: "[Watch] context update failed: \(error.localizedDescription)") + } + + let newReading = state.snapshot?.updatedAt != lastSentState?.snapshot?.updatedAt + if newReading, + session.isComplicationEnabled, + session.remainingComplicationUserInfoTransfers > 0, + Date().timeIntervalSince(lastComplicationTransfer) >= complicationTransferInterval + { + session.transferCurrentComplicationUserInfo([WatchState.contextKey: data]) + lastComplicationTransfer = Date() + } + + lastSentState = state + LogManager.shared.log(category: .general, message: "[Watch] state sent (\(data.count) bytes) reason=\(reason)", isDebug: true) + } + + /// Equal apart from when the two states were built. + private static func sameContent(_ lhs: WatchState, _ rhs: WatchState) -> Bool { + var lhs = lhs + lhs.builtAt = rhs.builtAt + lhs.phoneBuiltAt = rhs.phoneBuiltAt + return lhs == rhs + } +} + +// MARK: - WCSessionDelegate + +extension WatchSessionManager: WCSessionDelegate { + func session(_: WCSession, activationDidCompleteWith state: WCSessionActivationState, error: Error?) { + if let error { + LogManager.shared.log(category: .general, message: "[Watch] activation failed: \(error.localizedDescription)") + return + } + if state == .activated { + resendFullState(reason: "activated") + DispatchQueue.main.async { self.sendCredentialsIfChanged(force: false) } + } + } + + func sessionDidBecomeInactive(_: WCSession) {} + + func sessionDidDeactivate(_ session: WCSession) { + // Required when the user switches to another watch. + session.activate() + } + + func sessionWatchStateDidChange(_: WCSession) { + resendFullState(reason: "watchStateChanged") + } + + /// A newly activated session or a newly installed or paired watch may not have + /// the last state, so the next one is sent even if it has not changed. + private func resendFullState(reason: String) { + DispatchQueue.main.async { + self.lastContextUpdate = .distantPast + self.requestUpdate(reason: reason) + } + } + + func session(_: WCSession, didReceiveMessage message: [String: Any], replyHandler: @escaping ([String: Any]) -> Void) { + DispatchQueue.main.async { + if message[WatchCredentials.requestKey] != nil { + let data = (try? JSONEncoder().encode(WatchCredentialsBuilder.build())) ?? Data() + replyHandler([WatchCredentials.userInfoKey: data]) + return + } + + if message[WatchMessageKey.requestState] != nil { + let data = WatchStateBuilder.build().encoded() ?? Data() + replyHandler([WatchState.contextKey: data]) + return + } + + guard let data = message[WatchCommand.messageKey] as? Data, + let command = try? JSONDecoder().decode(WatchCommand.self, from: data) + else { + Self.reply(WatchCommandReply(success: false, message: "Unknown request."), to: replyHandler) + return + } + + WatchCommandHandler.shared.handle(command) { result in + Self.reply(result, to: replyHandler) + self.requestUpdate(reason: "command") + } + } + } + + func session(_: WCSession, didReceiveUserInfo userInfo: [String: Any] = [:]) { + if userInfo[WatchCredentials.requestKey] != nil { + DispatchQueue.main.async { self.sendCredentialsIfChanged(force: true) } + } + } + + private static func reply(_ reply: WatchCommandReply, to handler: ([String: Any]) -> Void) { + let data = (try? JSONEncoder().encode(reply)) ?? Data() + handler([WatchCommandReply.messageKey: data]) + } +} diff --git a/LoopFollow/Watch/WatchStateBuilder.swift b/LoopFollow/Watch/WatchStateBuilder.swift new file mode 100644 index 000000000..12a379c99 --- /dev/null +++ b/LoopFollow/Watch/WatchStateBuilder.swift @@ -0,0 +1,249 @@ +// LoopFollow +// WatchStateBuilder.swift + +import Foundation +import HealthKit + +/// Collects the phone's current data into the state the watch renders. +enum WatchStateBuilder { + /// History sent to the watch; the watch chart scrolls back this far. + static let historyWindow: TimeInterval = 24 * 3600 + + static func build(now: Date = Date()) -> WatchState { + let thresholds = UnitSettingsStore.shared.effectiveThresholds() + let vc = MainViewController.shared + let cutoff = now.timeIntervalSince1970 - historyWindow + + let readings = (vc?.bgData ?? []) + .filter { $0.date >= cutoff && $0.sgv > 0 } + .map { WatchReading(t: $0.date, v: Double($0.sgv)) } + + let boluses = ((vc?.bolusData ?? []) + (vc?.smbData ?? [])) + .filter { $0.date >= cutoff } + .map { WatchReading(t: $0.date, v: $0.value) } + .sorted { $0.t < $1.t } + + let carbs = (vc?.carbData ?? []) + .filter { $0.date >= cutoff } + .map { WatchReading(t: $0.date, v: $0.value) } + + let overrides = (vc?.overrideGraphData ?? []) + .filter { $0.endDate >= cutoff } + .map { WatchOverride(start: $0.date, end: $0.endDate, name: $0.reason.isEmpty ? "Override" : $0.reason, percent: overridePercent($0)) } + + var predictions: [WatchPredictionLine] = [] + if let vc { + let lines: [(WatchPredictionLine.Kind, [ShareGlucoseData])] = [ + (.loop, vc.predictionData), + (.zt, vc.ztPredictionData), + (.iob, vc.iobPredictionData), + (.cob, vc.cobPredictionData), + (.uam, vc.uamPredictionData), + ] + for (kind, data) in lines where !data.isEmpty { + predictions.append(WatchPredictionLine(kind: kind, points: data.map { WatchReading(t: $0.date, v: Double($0.sgv)) })) + } + } + + let cone = (vc?.chartModel.cone ?? []).map { + WatchConePoint(t: $0.date.timeIntervalSince1970, low: $0.yMin, high: $0.yMax) + } + + return WatchState( + builtAt: now, + source: .phone, + phoneBuiltAt: now, + snapshot: GlucoseSnapshotBuilder.build(from: StorageCurrentGlucoseStateProvider()), + readings: readings, + predictions: predictions, + cone: cone, + boluses: boluses, + carbs: carbs, + overrides: overrides, + lowMgdl: thresholds.low, + highMgdl: thresholds.high, + colorBGText: Storage.shared.colorBGText.value, + infoRows: infoRows(vc), + stats: vc.map { stats($0.statsDisplayModel) }, + device: Storage.shared.device.value, + alarm: currentAlarm(), + remote: remoteConfig() + ) + } + + /// Same rows, order and coloring as the phone's info table (InfoTableView). + private static func infoRows(_ vc: MainViewController?) -> [WatchInfoRow] { + var rows: [WatchInfoRow] = [] + if Storage.shared.graphTimeZoneEnabled.value, + let tz = TimeZone(identifier: Storage.shared.graphTimeZoneIdentifier.value) + { + rows.append(WatchInfoRow(name: "Time Zone", value: tz.identifier, level: nil)) + } + guard let infoManager = vc?.infoManager else { return rows } + let items = Storage.shared.infoDisplayItems.value + for row in infoManager.visibleRows { + var level: InfoColorLevel? + if let numeric = row.numericValue, + let type = InfoType(rawValue: row.id), + let config = type.colorConfig + { + level = items.item(for: type)?.coloring.level(for: numeric, direction: config.direction) + } + rows.append(WatchInfoRow(name: row.name, value: row.value.isEmpty ? "—" : row.value, level: level)) + } + return rows + } + + private static func stats(_ model: StatsDisplayModel) -> WatchStats { + WatchStats( + lowPercent: model.lowPercent, + inRangePercent: model.inRangePercent, + highPercent: model.highPercent, + avgBG: model.avgBG, + estA1CTitle: model.estA1CTitle, + estA1C: model.estA1C, + stdDevTitle: model.stdDevTitle, + stdDev: model.stdDev, + pieLow: model.pieLow, + pieRange: model.pieRange, + pieHigh: model.pieHigh + ) + } + + private static func currentAlarm() -> WatchAlarm? { + guard let id = Observable.shared.currentAlarm.value, + let alarm = Storage.shared.alarms.value.first(where: { $0.id == id }) + else { return nil } + return WatchAlarm(id: id, name: alarm.name) + } + + // MARK: - Remote + + static func remoteKind() -> WatchRemoteConfig.Kind? { + switch Storage.shared.remoteType.value { + case .loopAPNS: + return Storage.shared.device.value == "Loop" ? .loop : nil + case .trc: + return Storage.shared.device.value == "Trio" ? .trio : nil + case .none: + return nil + } + } + + private static func remoteConfig() -> WatchRemoteConfig? { + guard Storage.shared.watchRemoteCommands.value, let kind = remoteKind() else { return nil } + + let maxBolus = Storage.shared.maxBolus.value.doubleValue(for: .internationalUnit()) + let bolusIncrement = max(0.001, Storage.shared.bolusIncrement.value.doubleValue(for: .internationalUnit())) + let maxCarbs = Storage.shared.maxCarbs.value.doubleValue(for: .gram()) + let mealWithFatProtein = kind == .trio && Storage.shared.mealWithFatProtein.value + let now = Date() + let recommended = RecommendedBolus.current() + + return WatchRemoteConfig( + kind: kind, + maxBolus: maxBolus, + bolusIncrement: bolusIncrement, + maxCarbs: maxCarbs, + maxFat: Storage.shared.maxFat.value.doubleValue(for: .gram()), + maxProtein: Storage.shared.maxProtein.value.doubleValue(for: .gram()), + mealWithBolus: kind == .trio && Storage.shared.mealWithBolus.value, + mealWithFatProtein: mealWithFatProtein, + recommendedBolus: recommended?.units, + recommendedBolusAt: recommended.map { Date(timeIntervalSince1970: $0.at) }, + overridePresets: kind == .trio ? trioOverridePresets() : loopOverridePresets(), + tempTargetPresets: kind == .trio ? tempTargetPresets() : [], + quickPickBoluses: QuickPickBolusesManager.computeQuickPickBoluses( + from: Storage.shared.remoteBolusHistory.value, + now: now, + stepIncrement: bolusIncrement, + maxBolus: maxBolus + ).map(\.units), + quickPickMeals: QuickPickMealsManager.computeQuickPickMeals( + from: Storage.shared.remoteMealHistory.value, + now: now, + carbStep: 1, + maxCarbs: maxCarbs, + includeFatProtein: mealWithFatProtein + ).map { WatchMealPick(carbs: $0.carbs, fat: $0.fat, protein: $0.protein, bolus: $0.bolus) } + ) + } + + /// Insulin needs of an override, from its Nightscout entry or else from the preset of the same name, + /// since Trio does not upload the factor with the override. + private static func overridePercent(_ override: DataStructs.overrideStruct) -> Int? { + var factor = override.insulNeedsScaleFactor + if factor == 1 { + if let loop = ProfileManager.shared.loopOverrides.first(where: { $0.name == override.reason }) { + factor = loop.insulinNeedsScaleFactor + } else if let percentage = ProfileManager.shared.trioOverrides.first(where: { $0.name == override.reason })?.percentage { + factor = percentage / 100 + } + } + return factor == 1 ? nil : Int(round(factor * 100)) + } + + private static func loopOverridePresets() -> [WatchPreset] { + ProfileManager.shared.loopOverrides.map { override in + var parts: [String] = [] + if override.insulinNeedsScaleFactor != 1 { + parts.append("\(Int(round(override.insulinNeedsScaleFactor * 100))) %") + } + if override.targetRange.count >= 2 { + let low = Localizer.formatQuantity(override.targetRange[0]) + let high = Localizer.formatQuantity(override.targetRange[1]) + parts.append(low == high ? low : "\(low)–\(high)") + } + parts.append(durationText(seconds: TimeInterval(override.duration ?? 0))) + let symbol = override.symbol.isEmpty ? "" : "\(override.symbol) " + return WatchPreset( + id: override.name, + name: symbol + override.name, + detail: parts.joined(separator: ", "), + durationSeconds: TimeInterval(override.duration ?? 0) + ) + } + } + + private static func trioOverridePresets() -> [WatchPreset] { + ProfileManager.shared.trioOverrides.map { override in + var parts: [String] = [] + if let percentage = override.percentage { + parts.append("\(Int(percentage)) %") + } + if let target = override.target { + parts.append(Localizer.formatQuantity(target)) + } + parts.append(durationText(seconds: (override.duration ?? 0) * 60)) + return WatchPreset( + id: override.name, + name: override.name, + detail: parts.joined(separator: ", "), + durationSeconds: (override.duration ?? 0) * 60 + ) + } + } + + private static func tempTargetPresets() -> [WatchPreset] { + TempTargetPresetManager.shared.presets.map { preset in + let target = Localizer.formatQuantity(preset.target) + let duration = durationText(seconds: preset.duration.doubleValue(for: .second())) + return WatchPreset( + id: preset.id.uuidString, + name: preset.name, + detail: "\(target), \(duration)", + durationSeconds: preset.duration.doubleValue(for: .second()), + targetMgdl: preset.target.doubleValue(for: .milligramsPerDeciliter) + ) + } + } + + private static func durationText(seconds: TimeInterval) -> String { + guard seconds > 0 else { return "Indefinite" } + let hours = Int(seconds) / 3600 + let minutes = (Int(seconds) % 3600) / 60 + if hours > 0, minutes > 0 { return "\(hours) h \(minutes) min" } + if hours > 0 { return "\(hours) h" } + return "\(minutes) min" + } +} diff --git a/LoopFollowLAExtension/LoopFollowLiveActivity.swift b/LoopFollowLAExtension/LoopFollowLiveActivity.swift index 6d438531e..a9018ca94 100644 --- a/LoopFollowLAExtension/LoopFollowLiveActivity.swift +++ b/LoopFollowLAExtension/LoopFollowLiveActivity.swift @@ -214,8 +214,7 @@ private struct LockScreenLiveActivityView: View { .foregroundStyle(.white.opacity(0.80)) .lineLimit(1) } - .frame(minWidth: 160, maxWidth: 184, alignment: .leading) - .layoutPriority(2) + .frame(maxWidth: .infinity, alignment: .leading) Rectangle() .fill(Color.white.opacity(0.20)) @@ -237,6 +236,16 @@ private struct LockScreenLiveActivityView: View { ActiveAdjustmentsView(snapshot: s) + if let sensorState = s.cgmSensorState { + Text("CGM: \(sensorState)") + .font(.system(size: 13, weight: .semibold, design: .rounded)) + .foregroundStyle(.white) + .lineLimit(2) + .minimumScaleFactor(0.8) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity, alignment: .center) + } + Text(LAAppGroupSettings.showDisplayName() ? "\(LAAppGroupSettings.displayName()) — \(LAFormat.updated(s))" : "Last Update: \(LAFormat.updated(s))") @@ -260,10 +269,22 @@ private struct LockScreenLiveActivityView: View { RoundedRectangle(cornerRadius: 16, style: .continuous) .fill(Color(uiColor: UIColor.systemRed).opacity(0.85)) - Text("Not Looping") - .font(.system(size: 20, weight: .heavy, design: .rounded)) - .foregroundStyle(.white) - .tracking(1.5) + VStack(spacing: 4) { + Text("Not Looping") + .font(.system(size: 20, weight: .heavy, design: .rounded)) + .foregroundStyle(.white) + .tracking(1.5) + + if let sensorState = state.snapshot.cgmSensorState { + Text("CGM: \(sensorState)") + .font(.system(size: 14, weight: .semibold, design: .rounded)) + .foregroundStyle(.white) + .lineLimit(2) + .minimumScaleFactor(0.8) + .multilineTextAlignment(.center) + } + } + .padding(.horizontal, 12) } } } @@ -322,7 +343,7 @@ private struct MetricBlock: View { .allowsTightening(true) .layoutPriority(1) } - .frame(width: 72, alignment: .leading) + .frame(maxWidth: 72, alignment: .leading) } } @@ -360,7 +381,7 @@ private struct SlotView: View { var body: some View { if option == .none { Color.clear - .frame(width: 72, height: 36) + .frame(maxWidth: 72, minHeight: 36, maxHeight: 36) } else { MetricBlock(label: option.gridLabel, value: slotFormattedValue(option: option, snapshot: snapshot)) } @@ -507,16 +528,31 @@ private struct DynamicIslandTrailingView: View { private struct DynamicIslandBottomView: View { let snapshot: GlucoseSnapshot - var body: some View { - if snapshot.isNotLooping { - Text("Loop has not reported in 15+ minutes") + @ViewBuilder + private var sensorStateText: some View { + if let sensorState = snapshot.cgmSensorState { + Text("CGM: \(sensorState)") .font(.system(size: 12, weight: .semibold, design: .rounded)) .foregroundStyle(.white.opacity(0.92)) .lineLimit(1) .minimumScaleFactor(0.75) + } + } + + var body: some View { + if snapshot.isNotLooping { + VStack(spacing: 2) { + Text("Loop has not reported in 15+ minutes") + .font(.system(size: 12, weight: .semibold, design: .rounded)) + .foregroundStyle(.white.opacity(0.92)) + .lineLimit(1) + .minimumScaleFactor(0.75) + sensorStateText + } } else { VStack(spacing: 2) { ActiveAdjustmentsView(snapshot: snapshot) + sensorStateText Text("Updated at: \(LAFormat.updated(snapshot))") .font(.system(size: 13, weight: .semibold, design: .rounded)) .foregroundStyle(.white.opacity(0.92)) @@ -779,12 +815,12 @@ private enum LAColors { let low = t.low let high = t.high - if mgdl < low { - let raw = 0.48 + (0.85 - 0.48) * ((low - mgdl) / (low - 54.0)) + if mgdl <= low { + let raw = 0.48 + (0.85 - 0.48) * ((low - mgdl) / max(low - GlucoseThresholds.veryLow, 1)) let opacity = min(max(raw, 0.48), 0.85) return Color(uiColor: UIColor.systemRed).opacity(opacity) - } else if mgdl > high { - let raw = 0.44 + (0.85 - 0.44) * ((mgdl - high) / (324.0 - high)) + } else if mgdl >= high { + let raw = 0.44 + (0.85 - 0.44) * ((mgdl - high) / max(324.0 - high, 1)) let opacity = min(max(raw, 0.44), 0.85) return Color(uiColor: UIColor.systemOrange).opacity(opacity) } else { @@ -798,9 +834,9 @@ private enum LAColors { let low = t.low let high = t.high - if mgdl < low { + if mgdl <= low { return Color(uiColor: UIColor.systemRed) - } else if mgdl > high { + } else if mgdl >= high { return Color(uiColor: UIColor.systemOrange) } else { return Color(uiColor: UIColor.systemGreen) diff --git a/LoopFollowWatch/Assets.xcassets/AppIcon.appiconset/1024.png b/LoopFollowWatch/Assets.xcassets/AppIcon.appiconset/1024.png new file mode 100644 index 000000000..a975ee063 Binary files /dev/null and b/LoopFollowWatch/Assets.xcassets/AppIcon.appiconset/1024.png differ diff --git a/LoopFollowWatch/Assets.xcassets/AppIcon.appiconset/Contents.json b/LoopFollowWatch/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 000000000..113d82165 --- /dev/null +++ b/LoopFollowWatch/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/Assets.xcassets/Contents.json b/LoopFollowWatch/Assets.xcassets/Contents.json new file mode 100644 index 000000000..73c00596a --- /dev/null +++ b/LoopFollowWatch/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/LoopFollowWatch/ChartPage.swift b/LoopFollowWatch/ChartPage.swift new file mode 100644 index 000000000..d8f17d7f9 --- /dev/null +++ b/LoopFollowWatch/ChartPage.swift @@ -0,0 +1,134 @@ +// LoopFollow +// ChartPage.swift + +import SwiftUI + +/// The full chart on its own page. Tap the chart to open it for scrubbing with the Digital Crown. +/// The button in the corner changes the time span. +struct ChartPage: View { + let state: WatchState + + @EnvironmentObject private var navigator: WatchNavigator + @AppStorage("chartSpanHours") private var spanHours: Double = 3 + + var body: some View { + TimelineView(.periodic(from: .now, by: 60)) { context in + VStack(spacing: 2) { + WatchChartView(state: state, now: context.date, spanHours: spanHours, scrubOffset: nil) + // Keeps the axis labels clear of the page indicator. + .padding(.trailing, 10) + .contentShape(Rectangle()) + .onTapGesture { navigator.push(.chartScrub) } + if let override = state.activeOverride(at: context.date) { + OverrideLine(name: override.name, percent: override.percent) + .padding(.trailing, 10) + } + } + } + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + SpanButton(spanHours: $spanHours) + } + } + } +} + +/// The chart on a screen of its own, where the Digital Crown moves the marker back in time. +/// The time, BG, carbs, insulin and override at the marker show below the chart. Tap the chart +/// or go back to leave; the crown then pages between the watch pages again. +struct ChartScrubView: View { + let state: WatchState + + @Environment(\.dismiss) private var dismiss + @AppStorage("chartSpanHours") private var spanHours: Double = 3 + @State private var scrubSteps = 0.0 + @FocusState private var crownFocused: Bool + + /// The crown moves in five-minute steps, back to the start of the 24 hours the phone sends. + private var maxSteps: Double { max(0, (24 - spanHours * 0.75) * 12) } + + var body: some View { + TimelineView(.periodic(from: .now, by: 60)) { context in + VStack(spacing: 2) { + WatchChartView(state: state, now: context.date, spanHours: spanHours, scrubOffset: scrubSteps * 300) + .contentShape(Rectangle()) + .onTapGesture { dismiss() } + scrubDetails(now: context.date) + if let override = state.activeOverride(at: markerDate(now: context.date)) { + OverrideLine(name: override.name, percent: override.percent) + } + } + .focusable() + .focused($crownFocused) + .digitalCrownRotation($scrubSteps, from: 0, through: maxSteps, by: 1, sensitivity: .medium, isContinuous: false, isHapticFeedbackEnabled: true) + } + .onAppear { crownFocused = true } + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + SpanButton(spanHours: $spanHours) + } + } + .onChange(of: spanHours) { + scrubSteps = min(scrubSteps, maxSteps) + } + } + + /// Time, BG, carbs and insulin at the marker. Carbs and boluses count when they fall within the + /// marker's five-minute crown step, so each entry shows at exactly one step. + private func scrubDetails(now: Date) -> some View { + let marker = markerDate(now: now) + let nearest = state.readings.min { abs($0.date.timeIntervalSince(marker)) < abs($1.date.timeIntervalSince(marker)) } + let reading = nearest.flatMap { abs($0.date.timeIntervalSince(marker)) <= 450 ? $0 : nil } + let carbs = total(of: state.carbs, near: marker) + let insulin = total(of: state.boluses, near: marker) + return HStack(spacing: 8) { + Text(marker.formatted(date: .omitted, time: .shortened)) + .foregroundStyle(.secondary) + HStack(spacing: 3) { + if let reading { + Circle() + .fill(WatchFormat.bgColor(reading.v, lowMgdl: state.lowMgdl, highMgdl: state.highMgdl)) + .frame(width: 7, height: 7) + Text(WatchFormat.glucose(reading.v, unit: state.snapshot?.unit ?? .mgdl)) + } else { + Text("—") + } + } + if let carbs { + Text("\(Image(systemName: "fork.knife")) \(WatchFormat.cob(carbs))") + .accessibilityLabel("Carbs \(WatchFormat.cob(carbs))") + } + if let insulin { + Text("\(Image(systemName: "syringe")) \(WatchFormat.insulin(insulin)) U") + .accessibilityLabel("Bolus \(WatchFormat.insulin(insulin)) U") + } + } + .font(.footnote) + .monospacedDigit() + .lineLimit(1) + .minimumScaleFactor(0.6) + } + + private func markerDate(now: Date) -> Date { + WatchChartView.markerDate(state: state, now: now, spanHours: spanHours, scrubOffset: scrubSteps * 300) + } + + /// Sum of the entries within half a crown step of the marker, or nil when there are none. + private func total(of entries: [WatchReading], near marker: Date) -> Double? { + let matching = entries.filter { abs($0.date.timeIntervalSince(marker)) < 150 } + return matching.isEmpty ? nil : matching.reduce(0) { $0 + $1.v } + } +} + +/// Steps through the chart time spans. +private struct SpanButton: View { + @Binding var spanHours: Double + + var body: some View { + Button("\(Int(spanHours)) h") { + let index = WatchChartView.spans.firstIndex(of: spanHours) ?? 1 + spanHours = WatchChartView.spans[(index + 1) % WatchChartView.spans.count] + } + .accessibilityLabel("Time span \(Int(spanHours)) hours") + } +} diff --git a/LoopFollowWatch/ContentView.swift b/LoopFollowWatch/ContentView.swift new file mode 100644 index 000000000..5db091911 --- /dev/null +++ b/LoopFollowWatch/ContentView.swift @@ -0,0 +1,114 @@ +// LoopFollow +// ContentView.swift + +import SwiftUI + +/// Screens pushed on the main navigation stack. +enum WatchRoute: Hashable { + case chartScrub + case treatments + case meal + case bolus + case bolusPresets + case overrides + case tempTargets + case confirm(CommandRequest) +} + +/// What a confirmation screen sends, and how it describes it. +struct CommandRequest: Hashable { + let title: String + let lines: [String] + let action: WatchCommand.Action + let deliversInsulin: Bool + /// Set when a bolus amount is the AID's recommendation, so it can be checked again before sending. + var recommendation: BolusRecommendation? = nil +} + +/// Owns the navigation path so a finished command can return to the glucose page. +final class WatchNavigator: ObservableObject { + @Published var path: [WatchRoute] = [] + + func push(_ route: WatchRoute) { + path.append(route) + } + + func popToRoot() { + path.removeAll() + } +} + +struct ContentView: View { + @EnvironmentObject private var session: WatchSession + @StateObject private var navigator = WatchNavigator() + + var body: some View { + NavigationStack(path: $navigator.path) { + if let state = session.state { + TabView { + GlucosePage(state: state) + ChartPage(state: state) + if let stats = state.stats { + StatsPage(state: state, stats: stats) + } + InfoPage(state: state) + } + .tabViewStyle(.verticalPage) + .navigationDestination(for: WatchRoute.self) { route in + destination(route, state: state) + } + } else { + WaitingForPhoneView() + } + } + .environmentObject(navigator) + } + + @ViewBuilder + private func destination(_ route: WatchRoute, state: WatchState) -> some View { + if route == .chartScrub { + ChartScrubView(state: state) + } else if let remote = state.remote { + switch route { + case .chartScrub: + EmptyView() + case .treatments: + TreatmentMenuView() + case .meal: + MealEntryView(remote: remote) + case .bolus: + BolusAmountView(remote: remote) + case .bolusPresets: + BolusPresetsView(remote: remote) + case .overrides: + PresetListView(kind: .override, state: state, presets: remote.overridePresets) + case .tempTargets: + PresetListView(kind: .tempTarget, state: state, presets: remote.tempTargetPresets) + case let .confirm(request): + CommandConfirmView(request: request) + } + } else { + Text("Remote commands are turned off in LoopFollow on the iPhone.") + .multilineTextAlignment(.center) + } + } +} + +private struct WaitingForPhoneView: View { + var body: some View { + VStack(spacing: 8) { + Image(systemName: "iphone.radiowaves.left.and.right") + .font(.title2) + .foregroundStyle(.secondary) + Text("Waiting for LoopFollow on iPhone") + .font(.headline) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + Text("Open LoopFollow on the paired iPhone.") + .font(.footnote) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + .padding() + } +} diff --git a/LoopFollowWatch/CrownSymbol.swift b/LoopFollowWatch/CrownSymbol.swift new file mode 100644 index 000000000..1b6d66f23 --- /dev/null +++ b/LoopFollowWatch/CrownSymbol.swift @@ -0,0 +1,11 @@ +// LoopFollow +// CrownSymbol.swift + +import WatchKit + +enum CrownSymbol { + /// The crown symbol turning the way that raises a value, for the wrist the watch is worn on. + static var increase: String { + WKInterfaceDevice.current().wristLocation == .left ? "digitalcrown.arrow.clockwise" : "digitalcrown.arrow.counterclockwise" + } +} diff --git a/LoopFollowWatch/DirectCommandSender.swift b/LoopFollowWatch/DirectCommandSender.swift new file mode 100644 index 000000000..b2320524f --- /dev/null +++ b/LoopFollowWatch/DirectCommandSender.swift @@ -0,0 +1,172 @@ +// LoopFollow +// DirectCommandSender.swift + +import Foundation + +/// Sends a command from the watch straight to Loop or Trio when the phone is +/// out of reach, with the credentials and guardrails the phone last sent. +enum DirectCommandSender { + /// At least this long between two insulin commands sent from the watch. + static let insulinInterval: TimeInterval = 60 + + private static let lastInsulinKey = "directLastInsulinAt" + private static let lastLoopOTPKey = "directLastLoopOTP" + + static func send(_ requested: WatchCommand.Action, credentials: WatchCredentials, config: WatchRemoteConfig) async -> WatchCommandReply { + guard let remote = credentials.remote, remote.kind == config.kind else { + return WatchCommandReply(success: false, message: "iPhone not reachable, and commands from the watch alone are not set up. Open LoopFollow on the iPhone once.") + } + + let action: WatchCommand.Action + switch WatchCommandValidator.validate(requested, kind: remote.kind, limits: config.limits) { + case let .failure(error): return WatchCommandReply(success: false, message: error.message) + case let .success(validated): action = validated + } + + if action.deliversInsulin, let last = UserDefaults.standard.object(forKey: lastInsulinKey) as? Date, + Date().timeIntervalSince(last) < insulinInterval + { + return WatchCommandReply(success: false, message: "Insulin was sent from the watch less than a minute ago. Check Loop or Trio before sending more.") + } + + let result: (Bool, String?) + switch remote.kind { + case .trio: result = await sendTrio(action, remote: remote, config: config) + case .loop: result = await sendLoop(action, remote: remote, config: config) + } + + LogManager.shared.log(category: .apns, message: "Direct command \(action.name) finished: success=\(result.0) \(result.1 ?? "")") + guard result.0 else { + return WatchCommandReply(success: false, message: result.1 ?? "Failed to send the command.") + } + if action.deliversInsulin { + UserDefaults.standard.set(Date(), forKey: lastInsulinKey) + } + return WatchCommandReply(success: true, message: "Sent from the watch without the iPhone. Check Loop or Trio for the result.") + } + + // MARK: - Trio + + private static func sendTrio(_ action: WatchCommand.Action, remote: WatchCredentials.Remote, config: WatchRemoteConfig) async -> (Bool, String?) { + var payload = CommandPayload(user: remote.user, commandType: .bolus, timestamp: Date().timeIntervalSince1970, returnNotification: remote.returnNotification) + switch action { + case let .bolus(units): + payload.bolusAmount = Decimal(units) + case let .meal(carbs, fat, protein, bolus, consumedAt): + payload.commandType = .meal + payload.carbs = carbs > 0 ? Int(carbs) : nil + payload.fat = fat > 0 ? Int(fat) : nil + payload.protein = protein > 0 ? Int(protein) : nil + payload.bolusAmount = bolus > 0 ? Decimal(bolus) : nil + payload.scheduledTime = WatchCommand.trioScheduledTime(consumedAt)?.timeIntervalSince1970 + case let .startOverride(presetID): + payload.commandType = .startOverride + payload.overrideName = presetID + case .cancelOverride: + payload.commandType = .cancelOverride + case let .startTempTarget(presetID): + guard let preset = config.tempTargetPresets.first(where: { $0.id == presetID }), let target = preset.targetMgdl else { + return (false, "The temp target preset was not found.") + } + payload.commandType = .tempTarget + payload.target = Int(target) + payload.duration = Int(preset.durationSeconds / 60) + case .cancelTempTarget: + payload.commandType = .cancelTempTarget + case .snoozeAlarm: + return (false, "Snoozing needs the iPhone.") + } + + guard let messenger = SecureMessenger(sharedSecret: remote.sharedSecret) else { + return (false, "Failed to initialize security module. Check shared secret.") + } + do { + let message = try EncryptedPushMessage(encryptedData: messenger.encrypt(payload), commandType: payload.commandType) + let body = try JSONEncoder().encode(message) + return await deliver(body: body, remote: remote, expiration: "600", collapseID: payload.apnsCollapseID) + } catch { + return (false, "Failed to encode or encrypt push message: \(error.localizedDescription)") + } + } + + // MARK: - Loop + + private static func sendLoop(_ action: WatchCommand.Action, remote: WatchCredentials.Remote, config: WatchRemoteConfig) async -> (Bool, String?) { + var payload: [String: Any] + var usedOTP: String? + + switch action { + case let .bolus(units): + guard let otp = loopOTP(remote) else { return (false, otpUnavailable) } + payload = LoopAPNSCommands.bolusCommandPayload(LoopAPNSPayload(type: .bolus, bolusAmount: units, otp: otp)) + usedOTP = otp + case let .meal(carbs, _, _, _, consumedAt): + guard let otp = loopOTP(remote) else { return (false, otpUnavailable) } + payload = LoopAPNSCommands.carbsCommandPayload(LoopAPNSPayload( + type: .carbs, + carbsAmount: carbs, + absorptionTime: WatchCommandValidator.loopAbsorptionHours, + consumedDate: consumedAt.dateUsingCurrentSeconds(), + otp: otp + )) + usedOTP = otp + case let .startOverride(presetID): + guard let preset = config.overridePresets.first(where: { $0.id == presetID }) else { + return (false, "The override preset was not found.") + } + payload = LoopAPNSCommands.overridePayload( + presetName: presetID, + duration: preset.durationSeconds > 0 ? preset.durationSeconds : nil, + returnInfo: remote.returnNotification + ) + case .cancelOverride: + payload = LoopAPNSCommands.cancelOverridePayload(returnInfo: remote.returnNotification) + case .startTempTarget, .cancelTempTarget, .snoozeAlarm: + return (false, "This command is not available for Loop.") + } + + if let usedOTP, let returnInfo = remote.returnNotification, + let messenger = OTPSecureMessenger(otpCode: usedOTP), + let encrypted = try? messenger.encrypt(returnInfo) + { + payload["encrypted_return_notification"] = encrypted + } + + do { + let body = try LoopAPNSCommands.apnsBody(payload) + let result = await deliver(body: body, remote: remote) + if result.0, let usedOTP { + UserDefaults.standard.set(usedOTP, forKey: lastLoopOTPKey) + } + return result + } catch { + return (false, "Failed to serialize APNS payload: \(error.localizedDescription)") + } + } + + private static let otpUnavailable = "No unused one-time code right now. Wait for the next code (up to 30 seconds) and try again." + + /// The current one-time code, unless the watch already used it: Loop rejects a code it has seen. + private static func loopOTP(_ remote: WatchCredentials.Remote) -> String? { + guard let otp = TOTPGenerator.extractOTPFromURL(remote.loopQRCodeURL) else { return nil } + return otp == UserDefaults.standard.string(forKey: lastLoopOTPKey) ? nil : otp + } + + private static func deliver(body: Data, remote: WatchCredentials.Remote, expiration: String? = nil, collapseID: String? = nil) async -> (Bool, String?) { + await withCheckedContinuation { continuation in + APNSDelivery.send( + body: body, + deviceToken: remote.deviceToken, + topic: remote.bundleID, + production: remote.production, + keyId: remote.keyID, + teamId: remote.teamID, + apnsKey: remote.apnsKey, + expiration: expiration, + collapseID: collapseID + ) { success, error in + continuation.resume(returning: (success, error)) + } + } + } +} diff --git a/LoopFollowWatch/GlucosePage.swift b/LoopFollowWatch/GlucosePage.swift new file mode 100644 index 000000000..d07086dd2 --- /dev/null +++ b/LoopFollowWatch/GlucosePage.swift @@ -0,0 +1,263 @@ +// LoopFollow +// GlucosePage.swift + +import Charts +import SwiftUI + +/// Main page: one large BG value with trend and delta, IOB and COB, the running override, a two-hour sparkline, +/// and the remote commands in the bottom toolbar. +struct GlucosePage: View { + let state: WatchState + + @EnvironmentObject private var session: WatchSession + @Environment(\.isLuminanceReduced) private var isLuminanceReduced + + var body: some View { + TimelineView(.periodic(from: .now, by: 30)) { context in + VStack(spacing: 2) { + if let alarm = state.alarm { + AlarmBanner(alarm: alarm) + } + glucose(now: context.date) + if !isLuminanceReduced { + details + overrideLine(now: context.date) + Sparkline(state: state, now: context.date) + .frame(minHeight: 36) + } + } + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + age(now: context.date) + } + if !isLuminanceReduced, let remote = state.remote { + ToolbarItemGroup(placement: .bottomBar) { + RemoteToolbar(state: state, remote: remote) + } + } + } + } + } + + @ViewBuilder + private func glucose(now: Date) -> some View { + if let snapshot = state.snapshot { + let isStale = now.timeIntervalSince(snapshot.updatedAt) >= WatchFormat.staleAfter + VStack(spacing: -4) { + // The arrow hangs off the number's trailing edge so the digits, and the delta under them, stay centered. + Text(WatchFormat.glucose(snapshot.glucose, unit: snapshot.unit)) + .font(.system(size: 56, weight: .black)) + .foregroundStyle(WatchFormat.bgColor(snapshot.glucose, lowMgdl: state.lowMgdl, highMgdl: state.highMgdl, colored: state.colorBGText)) + .strikethrough(isStale, pattern: .solid, color: .red) + .lineLimit(1) + .minimumScaleFactor(0.5) + .overlay(alignment: .trailing) { + Text(WatchFormat.trendArrow(snapshot.trend)) + .font(.system(size: 28, weight: .black)) + .fixedSize() + .alignmentGuide(.trailing) { $0[.leading] - 4 } + } + .padding(.horizontal, 34) + Text(WatchFormat.delta(snapshot.delta, unit: snapshot.unit)) + .font(.title3) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity) + .layoutPriority(1) + .accessibilityElement(children: .combine) + } else { + Text("No BG data") + .font(.headline) + .foregroundStyle(.secondary) + } + } + + /// Age of the reading, with a marker while the watch fetches on its own or cannot reach the phone. + @ViewBuilder + private func age(now: Date) -> some View { + HStack(spacing: 2) { + if state.source == .watch { + Image(systemName: "applewatch.radiowaves.left.and.right") + .foregroundStyle(.blue) + .accessibilityLabel("Fetched by the watch") + } else if !session.isReachable { + Image(systemName: "iphone.slash") + .foregroundStyle(.orange) + .accessibilityLabel("iPhone not reachable") + } + if let snapshot = state.snapshot { + Text(WatchFormat.minutesAgo(snapshot.updatedAt, now: now)) + .foregroundStyle(now.timeIntervalSince(snapshot.updatedAt) >= WatchFormat.staleAfter ? .red : .secondary) + } + } + .font(.footnote) + } + + @ViewBuilder + private var details: some View { + if let snapshot = state.snapshot { + if snapshot.isNotLooping { + Label("Not looping", systemImage: "exclamationmark.triangle.fill") + .font(.footnote) + .foregroundStyle(.red) + } else if snapshot.iob != nil || snapshot.cob != nil { + HStack(spacing: 12) { + if let iob = snapshot.iob { + Text("\(Image(systemName: "syringe")) \(WatchFormat.iob(iob))") + .accessibilityLabel("Insulin on board \(WatchFormat.iob(iob))") + } + if let cob = snapshot.cob { + Text("\(Image(systemName: "fork.knife")) \(WatchFormat.cob(cob))") + .accessibilityLabel("Carbs on board \(WatchFormat.cob(cob))") + } + } + .font(.footnote) + .foregroundStyle(.secondary) + .lineLimit(1) + .minimumScaleFactor(0.7) + } + } + } + + /// The running override's name and insulin needs. + @ViewBuilder + private func overrideLine(now: Date) -> some View { + let active = state.activeOverride(at: now) + // The phone's periods end on time; the watch's own fetch only has the snapshot's name. + if let name = state.overrides == nil ? state.snapshot?.override : active?.name { + OverrideLine(name: name, percent: active?.percent) + } + } +} + +/// An override's name and insulin needs, in the green of the overrides button and chart band. +struct OverrideLine: View { + let name: String + let percent: Int? + + var body: some View { + Text("\(Image(systemName: "slider.horizontal.3")) \(name)\(percent.map { " · \($0) %" } ?? "")") + .font(.footnote) + .foregroundStyle(.green) + .lineLimit(1) + .minimumScaleFactor(0.7) + .accessibilityLabel("Override \(name)\(percent.map { ", insulin needs \($0) percent" } ?? "")") + } +} + +/// Override, treatments and temp target, as in Apple's bottom-toolbar pattern. +private struct RemoteToolbar: View { + let state: WatchState + let remote: WatchRemoteConfig + @EnvironmentObject private var session: WatchSession + @EnvironmentObject private var navigator: WatchNavigator + + var body: some View { + Button { + navigator.push(.overrides) + } label: { + Image(systemName: "slider.horizontal.3") + } + .tint(state.snapshot?.override != nil ? .green : nil) + .accessibilityLabel("Overrides") + .disabled(!session.canSendCommands) + + Button { + navigator.push(.treatments) + } label: { + Image(systemName: "plus") + } + .tint(.blue) + .accessibilityLabel("Meal or bolus") + .disabled(!session.canSendCommands) + + if remote.kind == .trio { + Button { + navigator.push(.tempTargets) + } label: { + Image(systemName: "scope") + } + .tint(state.snapshot?.tempTargetMgdl != nil ? .green : nil) + .accessibilityLabel("Temp Target") + .disabled(!session.canSendCommands) + } else { + // Holds the trailing slot so + stays centered. + Button {} label: { + Image(systemName: "scope") + } + .hidden() + .accessibilityHidden(true) + } + } +} + +/// The last two hours in range colors, without axes, scaled to the readings and the range lines. +struct Sparkline: View { + let state: WatchState + let now: Date + + var body: some View { + let start = now.addingTimeInterval(-2 * 3600) + let readings = state.readings.filter { $0.date >= start } + let values = readings.map(\.v) + let bottom = min(values.min() ?? state.lowMgdl, state.lowMgdl) - 10 + let top = max(values.max() ?? state.highMgdl, state.highMgdl) + 10 + Chart { + RuleMark(y: .value("High", state.highMgdl)) + .foregroundStyle(Color.yellow.opacity(0.5)) + .lineStyle(StrokeStyle(lineWidth: 1, dash: [3, 3])) + RuleMark(y: .value("Low", state.lowMgdl)) + .foregroundStyle(Color.red.opacity(0.5)) + .lineStyle(StrokeStyle(lineWidth: 1, dash: [3, 3])) + ForEach(readings, id: \.t) { reading in + PointMark(x: .value("Time", reading.date), y: .value("BG", reading.v)) + .symbolSize(12) + .foregroundStyle(WatchFormat.bgColor(reading.v, lowMgdl: state.lowMgdl, highMgdl: state.highMgdl)) + } + } + .chartXScale(domain: start ... now) + .chartYScale(domain: bottom ... top) + .chartXAxis(.hidden) + .chartYAxis(.hidden) + .accessibilityHidden(true) + } +} + +/// Shown while an alarm is sounding on the phone. +struct AlarmBanner: View { + let alarm: WatchAlarm + @EnvironmentObject private var session: WatchSession + @State private var isSending = false + @State private var error: String? + + var body: some View { + Button { + Task { + isSending = true + let reply = await session.send(.snoozeAlarm) + isSending = false + error = reply.success ? nil : reply.message + } + } label: { + HStack { + Image(systemName: "bell.fill") + VStack(alignment: .leading) { + Text(alarm.name) + .lineLimit(1) + Text(isSending ? "Snoozing…" : "Tap to snooze") + .font(.caption2) + .opacity(0.8) + } + Spacer(minLength: 0) + } + } + .buttonStyle(.borderedProminent) + .tint(.red) + .disabled(isSending) + .alert("Snooze failed", isPresented: Binding(get: { error != nil }, set: { if !$0 { error = nil } })) { + Button("OK", role: .cancel) {} + } message: { + Text(error ?? "") + } + } +} diff --git a/LoopFollowWatch/Info.plist b/LoopFollowWatch/Info.plist new file mode 100644 index 000000000..cdb0808fa --- /dev/null +++ b/LoopFollowWatch/Info.plist @@ -0,0 +1,28 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + $(display_name) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + $(LOOP_FOLLOW_MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + WKApplication + + WKCompanionAppBundleIdentifier + com.$(unique_id).LoopFollow$(app_suffix) + + diff --git a/LoopFollowWatch/InfoPage.swift b/LoopFollowWatch/InfoPage.swift new file mode 100644 index 000000000..c8a6d5826 --- /dev/null +++ b/LoopFollowWatch/InfoPage.swift @@ -0,0 +1,55 @@ +// LoopFollow +// InfoPage.swift + +import SwiftUI + +/// The phone's info table: the same rows, order and threshold coloring. +struct InfoPage: View { + let state: WatchState + + var body: some View { + ScrollView { + VStack(spacing: 0) { + if state.source == .watch, !state.infoRows.isEmpty { + Text("From the iPhone at \(state.phoneBuiltAt.formatted(date: .omitted, time: .shortened))") + .font(.caption2) + .foregroundStyle(.orange) + .frame(maxWidth: .infinity, alignment: .leading) + } + if state.infoRows.isEmpty { + Text("No info rows are shown on the iPhone.") + .font(.footnote) + .foregroundStyle(.secondary) + } + ForEach(Array(state.infoRows.enumerated()), id: \.offset) { _, row in + HStack { + Text(row.name) + .foregroundStyle(.secondary) + Spacer() + Text(row.value) + .foregroundStyle(Self.color(row.level)) + .multilineTextAlignment(.trailing) + } + .font(.footnote) + .lineLimit(1) + .minimumScaleFactor(0.6) + .padding(.vertical, 3) + Divider() + } + } + // Keeps the values clear of the page indicator. + .padding(.trailing, 10) + } + .navigationTitle("Info") + } + + /// Same colors as the phone's InfoColoring. + static func color(_ level: InfoColorLevel?) -> Color { + switch level { + case .urgent: .red + case .warning: .yellow + case .normal: .green + case nil: .primary + } + } +} diff --git a/LoopFollowWatch/LogManager.swift b/LoopFollowWatch/LogManager.swift new file mode 100644 index 000000000..5494d37f3 --- /dev/null +++ b/LoopFollowWatch/LogManager.swift @@ -0,0 +1,32 @@ +// LoopFollow +// LogManager.swift + +import Foundation +import os + +/// The watch's logger. Same call shape as the phone's LogManager so the code in +/// RemoteShared compiles for both; lines go to the unified system log. +final class LogManager { + static let shared = LogManager() + + enum Category: String { + case apns = "APNS" + case nightscout = "Nightscout" + case dexcom = "Dexcom" + case general = "General" + case remote = "Remote" + } + + private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "LoopFollowWatch", category: "watch") + + private init() {} + + func log(category: Category, message: String, isDebug: Bool = false) { + let line = "[\(category.rawValue)] \(LogRedactor.sweep(message))" + if isDebug { + logger.debug("\(line, privacy: .public)") + } else { + logger.log("\(line, privacy: .public)") + } + } +} diff --git a/LoopFollowWatch/LoopFollowWatch.entitlements b/LoopFollowWatch/LoopFollowWatch.entitlements new file mode 100644 index 000000000..5b963cc90 --- /dev/null +++ b/LoopFollowWatch/LoopFollowWatch.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.com.$(unique_id).LoopFollow$(app_suffix) + + + diff --git a/LoopFollowWatch/LoopFollowWatchApp.swift b/LoopFollowWatch/LoopFollowWatchApp.swift new file mode 100644 index 000000000..8db456fa3 --- /dev/null +++ b/LoopFollowWatch/LoopFollowWatchApp.swift @@ -0,0 +1,38 @@ +// LoopFollow +// LoopFollowWatchApp.swift + +import SwiftUI + +@main +struct LoopFollowWatchApp: App { + @StateObject private var session = WatchSession.shared + @Environment(\.scenePhase) private var scenePhase + + init() { + WatchSession.shared.activate() + } + + var body: some Scene { + WindowGroup { + ContentView() + .environmentObject(session) + .task(id: scenePhase) { + guard scenePhase == .active else { return } + session.requestState() + session.scheduleBackgroundRefresh() + // While open, check every minute whether the watch has to fetch on its own. + while !Task.isCancelled { + await session.refreshIfNeeded() + try? await Task.sleep(nanoseconds: 60_000_000_000) + } + } + } + .backgroundTask(.watchConnectivity) { + await WatchSession.shared.waitForPendingContent() + } + .backgroundTask(.appRefresh(WatchSession.backgroundRefreshID)) { + await WatchSession.shared.refreshIfNeeded() + await WatchSession.shared.scheduleBackgroundRefresh() + } + } +} diff --git a/LoopFollowWatch/Remote/BolusEntryView.swift b/LoopFollowWatch/Remote/BolusEntryView.swift new file mode 100644 index 000000000..211d32eca --- /dev/null +++ b/LoopFollowWatch/Remote/BolusEntryView.swift @@ -0,0 +1,366 @@ +// LoopFollow +// BolusEntryView.swift + +import SwiftUI +import WatchKit + +/// The AID's recommendation at the watch's bolus step. +struct BolusRecommendation: Hashable { + let units: Double + let at: Date + + var age: TimeInterval { Date().timeIntervalSince(at) } +} + +/// The AID's recommendation and the phone's Quick-Pick boluses, opened from the +/// amount screen. Every choice still needs the crown confirmation. +struct BolusPresetsView: View { + let remote: WatchRemoteConfig + + @EnvironmentObject private var navigator: WatchNavigator + @State private var staleRecommendation: BolusRecommendation? + + /// Same cut-offs and warning as the phone's bolus screens. + static let recommendationMaxAge: TimeInterval = 12 * 60 + static let recommendationWarnAge: TimeInterval = 5 * 60 + static let oldCalculationWarning = "This recommended bolus was calculated more than 5 minutes ago. New treatments may have occurred since then. Proceed with caution." + + static func quickPicks(_ remote: WatchRemoteConfig) -> [Double] { + remote.quickPickBoluses.filter { $0 > 0 && $0 <= remote.maxBolus } + } + + /// The latest recommendation whatever its age, rounded down to the watch's + /// step; nil when the loop cycle carries none, or ran before insulin was + /// last sent from the watch and so leaves that insulin out. The phone drops + /// one from before any other bolus. + static func latestRecommendation(_ remote: WatchRemoteConfig) -> BolusRecommendation? { + guard let at = remote.recommendedBolusAt, let recommended = remote.recommendedBolus else { return nil } + if let sent = WatchSession.lastInsulinSentAt, at <= sent { return nil } + let rec = max(recommended, 0) + return BolusRecommendation(units: BolusAmountView.roundedDown(min(rec, remote.maxBolus), step: BolusAmountView.step(remote)), at: at) + } + + /// A recommendation recent enough to offer. + static func freshRecommendation(_ remote: WatchRemoteConfig) -> BolusRecommendation? { + guard let rec = latestRecommendation(remote), rec.units > 0, rec.age < recommendationMaxAge else { return nil } + return rec + } + + /// Under 5 minutes old the recommendation is already the starting amount, + /// so it is only listed here once it needs the warning. + static func presetRecommendation(_ remote: WatchRemoteConfig) -> BolusRecommendation? { + guard let rec = freshRecommendation(remote), rec.age >= recommendationWarnAge else { return nil } + return rec + } + + static func hasPresets(_ remote: WatchRemoteConfig) -> Bool { + presetRecommendation(remote) != nil || !quickPicks(remote).isEmpty + } + + var body: some View { + List { + recommendation + ForEach(Self.quickPicks(remote), id: \.self) { units in + Button { + navigator.push(.confirm(BolusAmountView.request(units))) + } label: { + Text("\(WatchFormat.insulin(units)) U") + } + } + } + .navigationTitle("Presets") + .alert( + "Old Calculation Warning", + isPresented: Binding(get: { staleRecommendation != nil }, set: { if !$0 { staleRecommendation = nil } }) + ) { + Button("Use Anyway") { + if let staleRecommendation { + navigator.push(.confirm(BolusAmountView.request(staleRecommendation.units, recommendation: staleRecommendation))) + } + } + Button("Cancel", role: .cancel) {} + } message: { + Text(Self.oldCalculationWarning) + } + } + + @ViewBuilder + private var recommendation: some View { + if let rec = Self.presetRecommendation(remote) { + Section { + Button { + staleRecommendation = rec + } label: { + VStack(alignment: .leading, spacing: 2) { + Text("Recommended \(WatchFormat.insulin(rec.units)) U") + Text("Calculated \(Int(rec.age / 60)) min ago") + .font(.caption2) + .foregroundStyle(.red) + } + } + } + } + } +} + +/// Any amount, set with the Digital Crown or − and +, starting at the AID's +/// recommendation while it is under 5 minutes old. Until the amount is changed +/// by hand, a newer recommendation that is lower or the same replaces it, and a +/// higher one is offered with a tap. A starting recommendation past 12 minutes +/// is cleared at Continue. Presets are one tap away when the phone has any. +struct BolusAmountView: View { + let remote: WatchRemoteConfig + + @EnvironmentObject private var session: WatchSession + @EnvironmentObject private var navigator: WatchNavigator + @State private var units = 0.0 + /// Seeded once, so coming back from the confirmation keeps the user's amount. + @State private var seeded = false + @State private var seededRecommendation: BolusRecommendation? + /// True until the amount is changed by hand. + @State private var followsRecommendation = true + /// A newer, higher recommendation, only applied when tapped. + @State private var higherRecommendation: BolusRecommendation? + /// Recommendations are followed only while this screen is on top; the + /// confirmation checks its own amount. + @State private var isVisible = false + @State private var showOldCalculationWarning = false + @State private var showExpiredRecommendation = false + @FocusState private var amountFocused: Bool + + var body: some View { + ScrollView { + VStack(spacing: 8) { + AmountField(value: $units, unit: "U", step: Self.step(remote), maximum: remote.maxBolus, isHighlighted: amountFocused, sensitivity: .low, format: WatchFormat.insulin) + .focused($amountFocused) + if let higher = higherRecommendation, higher.units != units { + Button { + seededRecommendation = higher + units = higher.units + followsRecommendation = true + higherRecommendation = nil + amountFocused = true + } label: { + Label("New \(WatchFormat.insulin(higher.units)) U, tap to use", systemImage: "wand.and.stars") + .font(.footnote) + } + .buttonStyle(.plain) + .foregroundStyle(.tint) + .accessibilityLabel("Use the new, higher recommendation, \(WatchFormat.insulin(higher.units)) U") + } else if let rec = seededRecommendation { + // Redrawn every few seconds so the age keeps counting while the screen stays open. + TimelineView(.periodic(from: .now, by: 10)) { context in + let age = context.date.timeIntervalSince(rec.at) + Label("\(WatchFormat.insulin(rec.units)) U, \(Int(age / 60)) min ago", systemImage: "wand.and.stars") + .font(.footnote) + .foregroundStyle(age >= BolusPresetsView.recommendationWarnAge ? .red : .secondary) + .accessibilityLabel("Recommended \(WatchFormat.insulin(rec.units)) U, \(Int(age / 60)) min ago") + } + } + Button("Continue") { + if let age = startingRecommendationAge, age >= BolusPresetsView.recommendationMaxAge { + showExpiredRecommendation = true + } else if let age = startingRecommendationAge, age >= BolusPresetsView.recommendationWarnAge { + showOldCalculationWarning = true + } else { + continueToConfirm() + } + } + .buttonStyle(CompactButtonStyle(prominent: true)) + .tint(.blue) + .disabled(units <= 0) + // Redrawn so the button appears once the recommendation is old enough to be listed there. + TimelineView(.periodic(from: .now, by: 10)) { _ in + if BolusPresetsView.hasPresets(remote) { + Button("Presets") { + navigator.push(.bolusPresets) + } + .buttonStyle(CompactButtonStyle()) + } + } + } + } + .navigationTitle("Bolus") + .alert("Old Calculation Warning", isPresented: $showOldCalculationWarning) { + Button("Use Anyway") { + continueToConfirm() + } + Button("Cancel", role: .cancel) {} + } message: { + Text(BolusPresetsView.oldCalculationWarning) + } + .alert("Recommendation Expired", isPresented: $showExpiredRecommendation) { + Button("OK") { + seededRecommendation = nil + units = 0 + followsRecommendation = true + } + } message: { + Text("This recommended bolus was calculated more than 12 minutes ago. Set the amount again.") + } + .onAppear { + isVisible = true + amountFocused = true + guard !seeded else { + // Catches up on a cycle that came in while another screen was on top. + followNewerRecommendation() + return + } + seeded = true + // The phone also sends new loop cycles as they come; this covers one it has not sent yet. + session.requestState() + if let rec = BolusPresetsView.freshRecommendation(remote), rec.age < BolusPresetsView.recommendationWarnAge { + seededRecommendation = rec + units = rec.units + } + } + .onDisappear { isVisible = false } + .onChange(of: units) { _, newUnits in + if newUnits != (seededRecommendation?.units ?? 0) { followsRecommendation = false } + } + .onChange(of: session.state?.remote?.recommendedBolusAt) { _, _ in + followNewerRecommendation() + } + } + + /// An untouched amount moves down to a newer, lower recommendation; a + /// higher one, or the first fresh one when nothing was filled in, is only + /// offered, so insulin is never raised without a tap. + private func followNewerRecommendation() { + guard isVisible, followsRecommendation, + let latest = session.state?.remote.flatMap(BolusPresetsView.latestRecommendation), + latest != higherRecommendation + else { return } + if let seededRecommendation { + guard latest.at > seededRecommendation.at else { return } + } else { + guard latest.units > 0, latest.age < BolusPresetsView.recommendationWarnAge else { return } + } + let previousUnits = units + if latest.units > units { + higherRecommendation = latest + } else { + seededRecommendation = latest + units = latest.units + higherRecommendation = nil + } + if latest.units != previousUnits { + WKInterfaceDevice.current().play(.notification) + } + } + + /// Age of the starting recommendation while it is still the amount. A newer, + /// higher recommendation means the old one no longer risks too much insulin. + private var startingRecommendationAge: TimeInterval? { + guard let rec = seededRecommendation, units == rec.units, higherRecommendation == nil else { return nil } + return rec.age + } + + static func step(_ remote: WatchRemoteConfig) -> Double { + max(remote.bolusIncrement, 0.025) + } + + /// The recommendation goes along only while the amount is still the recommendation. + private func continueToConfirm() { + let basis = seededRecommendation.flatMap { $0.units == units ? $0 : nil } + navigator.push(.confirm(Self.request(units, recommendation: basis))) + } + + static func request(_ units: Double, recommendation: BolusRecommendation? = nil) -> CommandRequest { + CommandRequest(title: "Bolus", lines: ["\(WatchFormat.insulin(units)) U"], action: .bolus(units: units), deliversInsulin: true, recommendation: recommendation) + } + + static func roundedDown(_ value: Double, step: Double) -> Double { + let stepped = ((value / step) + 1e-10).rounded(.down) * step + return (stepped * 1000).rounded() / 1000 + } +} + +/// A value edited with the Digital Crown or the − and + buttons, in fixed steps between a minimum and a maximum. +struct AmountField: View { + @Binding var value: Double + let unit: String + let step: Double + var minimum: Double = 0 + let maximum: Double + /// The caller owns focus; this only draws the highlight. + let isHighlighted: Bool + var sensitivity: DigitalCrownRotationalSensitivity = .medium + let format: (Double) -> String + + private var maxSteps: Double { max(((maximum - minimum) / step + 1e-10).rounded(.down), 0) } + + /// The crown works on whole step counts, so every detent is exactly one step. + private var steps: Binding { + Binding( + get: { ((value - minimum) / step).rounded() }, + set: { newSteps in + let clamped = min(max(newSteps.rounded(), 0), maxSteps) + value = ((minimum + clamped * step) * 1000).rounded() / 1000 + } + ) + } + + var body: some View { + HStack(spacing: 2) { + nudge("minus", by: -1) + .disabled(steps.wrappedValue <= 0) + HStack(alignment: .firstTextBaseline, spacing: 4) { + Text(format(value)) + .font(.system(size: 34, weight: .bold)) + .monospacedDigit() + .minimumScaleFactor(0.6) + .lineLimit(1) + Text(unit) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity) + nudge("plus", by: 1) + .disabled(steps.wrappedValue >= maxSteps) + } + // Keeps − clear of the left edge, where watchOS takes swipes for going back. + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(RoundedRectangle(cornerRadius: 8).stroke(isHighlighted ? Color.green : Color.gray.opacity(0.4), lineWidth: 1.5)) + .focusable() + .digitalCrownRotation(detent: steps, from: 0, through: max(maxSteps, 1), by: 1, sensitivity: sensitivity, isContinuous: false, isHapticFeedbackEnabled: true) + } + + private func nudge(_ symbol: String, by delta: Double) -> some View { + Button { + steps.wrappedValue += delta + WKInterfaceDevice.current().play(.click) + } label: { + Image(systemName: symbol) + .font(.footnote.weight(.semibold)) + .frame(width: 36, height: 36) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .foregroundStyle(.tint) + } +} + +/// A full-width capsule button shorter than the system's, scaled to the screen, +/// so an amount and two buttons fit without scrolling on every watch size. +struct CompactButtonStyle: ButtonStyle { + var prominent = false + + @Environment(\.isEnabled) private var isEnabled + + /// About 34 pt on a 40 mm watch up to about 43 pt on the largest. + private static let height = min(max(WKInterfaceDevice.current().screenBounds.height * 0.17, 32), 44) + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .lineLimit(2) + .minimumScaleFactor(0.7) + .multilineTextAlignment(.center) + .foregroundStyle(prominent ? AnyShapeStyle(Color.white) : AnyShapeStyle(.primary)) + .padding(.horizontal, 12) + .padding(.vertical, 4) + .frame(maxWidth: .infinity, minHeight: Self.height) + .background(Capsule().fill(prominent ? AnyShapeStyle(.tint) : AnyShapeStyle(Color.gray.opacity(0.3)))) + .opacity(isEnabled ? (configuration.isPressed ? 0.7 : 1) : 0.4) + } +} diff --git a/LoopFollowWatch/Remote/CommandConfirmView.swift b/LoopFollowWatch/Remote/CommandConfirmView.swift new file mode 100644 index 000000000..c7b5c3038 --- /dev/null +++ b/LoopFollowWatch/Remote/CommandConfirmView.swift @@ -0,0 +1,228 @@ +// LoopFollow +// CommandConfirmView.swift + +import LocalAuthentication +import SwiftUI +import WatchKit + +/// Final step for every command: shows what will be sent, asks for confirmation +/// and shows the phone's reply. Insulin needs a full turn of the Digital Crown +/// and an unlocked watch on the wrist. A bolus taken from the recommendation +/// says so, and is checked against the latest one the watch has before +/// sending. A sent command is not yet delivered: Loop or Trio confirms it +/// later with a notification. +struct CommandConfirmView: View { + /// Replaced when the user switches to a lowered recommendation. + @State private var request: CommandRequest + + init(request: CommandRequest) { + _request = State(initialValue: request) + } + + @EnvironmentObject private var session: WatchSession + @EnvironmentObject private var navigator: WatchNavigator + @Environment(\.dismiss) private var dismiss + + /// How long the "sent" screen stays before returning to the glucose page. + private let sentDisplayTime: TimeInterval = 3 + + @State private var phase: Phase = .confirm + @State private var crownProgress = 0.0 + @State private var lastCrownChange = Date() + @State private var lowerRecommendation: BolusRecommendation? + @State private var showLowerRecommendation = false + @FocusState private var crownFocused: Bool + + enum Phase: Equatable { + case confirm + case sending + case done(success: Bool, message: String) + } + + var body: some View { + ScrollView { + VStack(spacing: 8) { + switch phase { + case .confirm: + summary + if request.deliversInsulin { + crownConfirm + } else { + Button("Send") { Task { await send() } } + .buttonStyle(.borderedProminent) + .tint(.blue) + } + case .sending: + ProgressView() + Text("Sending…") + .foregroundStyle(.secondary) + case let .done(success, message): + if success { + Image(systemName: "paperplane.fill") + .font(.largeTitle) + .foregroundStyle(.blue) + Text("Sent") + .font(.headline) + Text(message) + .font(.footnote) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } else { + Image(systemName: "xmark.octagon.fill") + .font(.largeTitle) + .foregroundStyle(.red) + Text(message) + .font(.footnote) + .multilineTextAlignment(.center) + Button("OK") { dismiss() } + } + } + } + .frame(maxWidth: .infinity) + } + .navigationTitle(request.title) + .navigationBarBackButtonHidden(phase != .confirm) + .onAppear { + if request.recommendation != nil { session.requestState() } + } + .alert("Recommendation Lowered", isPresented: $showLowerRecommendation, presenting: lowerRecommendation) { latest in + if latest.units > 0 { + // A new amount needs its own turn of the crown. + Button("Change to \(WatchFormat.insulin(latest.units)) U") { + request = BolusAmountView.request(latest.units, recommendation: latest) + } + } + Button("Send Anyway") { + Task { await authenticateAndSend() } + } + Button("Cancel", role: .cancel) {} + } message: { latest in + Text("The recommendation is now \(WatchFormat.insulin(latest.units)) U, down from \(WatchFormat.insulin(request.recommendation?.units ?? 0)) U. New treatments may have occurred.") + } + } + + private var summary: some View { + VStack(spacing: 2) { + ForEach(request.lines, id: \.self) { line in + Text(line) + .font(.headline) + .multilineTextAlignment(.center) + } + if let rec = request.recommendation { + TimelineView(.periodic(from: .now, by: 10)) { context in + let age = context.date.timeIntervalSince(rec.at) + Label("Recommended, \(Int(age / 60)) min ago", systemImage: "wand.and.stars") + .font(.footnote) + .foregroundStyle(age >= BolusPresetsView.recommendationWarnAge ? .red : .secondary) + } + } + } + } + + private var crownConfirm: some View { + VStack(spacing: 4) { + ZStack { + Circle() + .stroke(Color.gray.opacity(0.3), lineWidth: 6) + Circle() + .trim(from: 0, to: crownProgress) + .stroke(Color.blue, style: StrokeStyle(lineWidth: 6, lineCap: .round)) + .rotationEffect(.degrees(-90)) + Image(systemName: CrownSymbol.increase) + .font(.title3) + } + .frame(width: 56, height: 56) + Text("Turn the Digital Crown to confirm") + .font(.footnote) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + .focusable() + .focused($crownFocused) + .digitalCrownRotation($crownProgress, from: 0, through: 1, by: 0.02, sensitivity: .low, isContinuous: false, isHapticFeedbackEnabled: true) + .onAppear { crownFocused = true } + .onChange(of: crownProgress) { _, value in + lastCrownChange = Date() + if value >= 1 { + WKInterfaceDevice.current().play(.success) + Task { await checkRecommendationThenSend() } + } else { + resetCrownIfIdle() + } + } + } + + /// Partial turns reset after a pause, so a bump of the crown never adds up to a confirmation. + private func resetCrownIfIdle() { + let changedAt = lastCrownChange + DispatchQueue.main.asyncAfter(deadline: .now() + 3) { + if phase == .confirm, lastCrownChange == changedAt, crownProgress < 1 { + withAnimation { crownProgress = 0 } + } + } + } + + /// Stops before sending when a newer loop cycle lowered the recommendation + /// this amount came from. The screens ask the phone for fresh state when + /// they open, so this uses what the watch already has and never waits. + private func checkRecommendationThenSend() async { + if let basis = request.recommendation { + let latest = session.state?.remote.flatMap(BolusPresetsView.latestRecommendation) + if let latest, latest.at > basis.at, latest.units < basis.units { + crownProgress = 0 + phase = .confirm + lowerRecommendation = latest + showLowerRecommendation = true + WKInterfaceDevice.current().play(.notification) + return + } + } + await authenticateAndSend() + } + + private func authenticateAndSend() async { + switch await Self.authenticate() { + case .success: + await send() + case let .failure(message): + crownProgress = 0 + phase = .done(success: false, message: message) + WKInterfaceDevice.current().play(.failure) + } + } + + private func send() async { + phase = .sending + let reply = await session.send(request.action) + phase = .done(success: reply.success, message: reply.message) + WKInterfaceDevice.current().play(reply.success ? .success : .failure) + if reply.success { + try? await Task.sleep(nanoseconds: UInt64(sentDisplayTime * 1_000_000_000)) + navigator.popToRoot() + } + } + + private enum AuthOutcome { + case success + case failure(String) + } + + /// Requires a passcode-protected watch that is unlocked on the wrist. + private static func authenticate() async -> AuthOutcome { + #if targetEnvironment(simulator) + return .success + #else + let context = LAContext() + var error: NSError? + guard context.canEvaluatePolicy(.deviceOwnerAuthenticationWithWristDetection, error: &error) else { + return .failure("Insulin can only be sent from a watch with a passcode and Wrist Detection turned on.") + } + do { + let ok = try await context.evaluatePolicy(.deviceOwnerAuthenticationWithWristDetection, localizedReason: "Confirm to send insulin") + return ok ? .success : .failure("Authentication failed.") + } catch { + return .failure("Authentication failed.") + } + #endif + } +} diff --git a/LoopFollowWatch/Remote/MealEntryView.swift b/LoopFollowWatch/Remote/MealEntryView.swift new file mode 100644 index 000000000..3be27fdd7 --- /dev/null +++ b/LoopFollowWatch/Remote/MealEntryView.swift @@ -0,0 +1,148 @@ +// LoopFollow +// MealEntryView.swift + +import SwiftUI + +/// Carbs, and for Trio fat, protein and a bolus per the phone's Meal settings, +/// as on the phone; the time behind a button. Everything goes as one command. +/// The crown scrolls the page until a field is tapped. +struct MealEntryView: View { + let remote: WatchRemoteConfig + + @EnvironmentObject private var navigator: WatchNavigator + @State private var carbs = 0.0 + @State private var fat = 0.0 + @State private var protein = 0.0 + @State private var bolus = 0.0 + @State private var offsetMinutes = 0.0 + @State private var showTime = false + @FocusState private var focus: Field? + + private enum Field: Hashable { + case carbs, fat, protein, bolus, time + } + + private var bolusStep: Double { BolusAmountView.step(remote) } + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 6) { + quickPicks + + field("Carbs", .carbs) { + AmountField(value: $carbs, unit: "g", step: 1, maximum: remote.maxCarbs, isHighlighted: focus == .carbs, format: grams) + } + + if remote.mealWithFatProtein { + field("Fat", .fat) { + AmountField(value: $fat, unit: "g", step: 1, maximum: remote.maxFat, isHighlighted: focus == .fat, format: grams) + } + field("Protein", .protein) { + AmountField(value: $protein, unit: "g", step: 1, maximum: remote.maxProtein, isHighlighted: focus == .protein, format: grams) + } + } + if remote.mealWithBolus { + field("Bolus", .bolus) { + AmountField(value: $bolus, unit: "U", step: bolusStep, maximum: remote.maxBolus, isHighlighted: focus == .bolus, sensitivity: .low, format: WatchFormat.insulin) + } + } + if showTime { + field("Time", .time) { + AmountField(value: $offsetMinutes, unit: "min", step: 5, minimum: -120, maximum: 60, isHighlighted: focus == .time, format: timeText) + } + } else { + Button { + showTime = true + focus = .time + } label: { + Label("Time", systemImage: "clock") + .font(.footnote) + } + .buttonStyle(CompactButtonStyle()) + } + + Button { + navigator.push(.confirm(request(carbs: carbs, fat: fat, protein: protein, bolus: bolus, offsetMinutes: offsetMinutes))) + } label: { + Text(logTitle) + } + .buttonStyle(CompactButtonStyle(prominent: true)) + .tint(.blue) + .disabled(carbs <= 0 && fat <= 0 && protein <= 0) + } + } + .navigationTitle("Meal") + } + + /// The phone's Quick-Pick meals, limited to what the phone's Meal settings allow. + @ViewBuilder + private var quickPicks: some View { + let picks = remote.quickPickMeals.filter { $0.carbs > 0 && $0.carbs <= remote.maxCarbs } + if !picks.isEmpty { + ForEach(Array(picks.enumerated()), id: \.offset) { _, pick in + let fat = remote.mealWithFatProtein ? pick.fat : 0 + let protein = remote.mealWithFatProtein ? pick.protein : 0 + let bolus = remote.mealWithBolus && pick.bolus <= remote.maxBolus ? pick.bolus : 0 + Button { + navigator.push(.confirm(request(carbs: pick.carbs, fat: fat, protein: protein, bolus: bolus, offsetMinutes: 0))) + } label: { + Text(pickTitle(carbs: pick.carbs, fat: fat, protein: protein, bolus: bolus)) + } + .buttonStyle(CompactButtonStyle()) + } + } + } + + private func field(_ label: String, _ id: Field, content: () -> AmountField) -> some View { + VStack(alignment: .leading, spacing: 2) { + Text(label) + .font(.footnote) + .foregroundStyle(.secondary) + content() + .focused($focus, equals: id) + .onTapGesture { focus = id } + } + } + + private var logTitle: String { + if bolus > 0 { return "Log \(Int(carbs)) g + \(WatchFormat.insulin(bolus)) U" } + return "Log \(Int(carbs)) g" + } + + private func pickTitle(carbs: Double, fat: Double, protein: Double, bolus: Double) -> String { + var parts = ["\(Int(carbs)) g"] + if fat > 0 || protein > 0 { parts.append("F \(Int(fat)) P \(Int(protein))") } + if bolus > 0 { parts.append("\(WatchFormat.insulin(bolus)) U") } + return parts.joined(separator: " · ") + } + + private func grams(_ value: Double) -> String { + "\(Int(value))" + } + + private func timeText(_ minutes: Double) -> String { + let m = Int(minutes) + if m == 0 { return "Now" } + return m > 0 ? "+\(m)" : "\(m)" + } + + private func request(carbs: Double, fat: Double, protein: Double, bolus: Double, offsetMinutes: Double) -> CommandRequest { + var lines: [String] = [] + if carbs > 0 { lines.append("Carbs \(Int(carbs)) g") } + if fat > 0 { lines.append("Fat \(Int(fat)) g") } + if protein > 0 { lines.append("Protein \(Int(protein)) g") } + if bolus > 0 { lines.append("Bolus \(WatchFormat.insulin(bolus)) U") } + let at = Date().addingTimeInterval(offsetMinutes * 60) + lines.append(offsetMinutes == 0 ? "Now" : at.formatted(date: .omitted, time: .shortened)) + if bolus > 0, offsetMinutes != 0 { + // Same note as the phone's meal screen. + lines.append("The meal will be scheduled, but the bolus is enacted immediately.") + } + return CommandRequest( + title: "Meal", + lines: lines, + action: .meal(carbs: carbs, fat: fat, protein: protein, bolus: bolus, consumedAt: at), + deliversInsulin: bolus > 0 + ) + } +} diff --git a/LoopFollowWatch/Remote/PresetListView.swift b/LoopFollowWatch/Remote/PresetListView.swift new file mode 100644 index 000000000..71846db82 --- /dev/null +++ b/LoopFollowWatch/Remote/PresetListView.swift @@ -0,0 +1,77 @@ +// LoopFollow +// PresetListView.swift + +import SwiftUI + +/// Override or temp target presets synced from the phone, with a cancel row +/// while one is active. +struct PresetListView: View { + enum Kind { + case override + case tempTarget + } + + let kind: Kind + let state: WatchState + let presets: [WatchPreset] + + @EnvironmentObject private var navigator: WatchNavigator + + private var activeText: String? { + guard let snapshot = state.snapshot else { return nil } + switch kind { + case .override: + return snapshot.override + case .tempTarget: + return snapshot.tempTargetMgdl.map { WatchFormat.glucose($0, unit: snapshot.unit) } + } + } + + private var title: String { kind == .override ? "Overrides" : "Temp Target" } + + var body: some View { + List { + if let activeText { + Section("Active") { + Button { + navigator.push(.confirm(CommandRequest( + title: kind == .override ? "Cancel Override" : "Cancel Temp Target", + lines: ["Cancel \(activeText)?"], + action: kind == .override ? .cancelOverride : .cancelTempTarget, + deliversInsulin: false + ))) + } label: { + Label(activeText, systemImage: "xmark.circle") + .foregroundStyle(.red) + } + } + } + + Section("Presets") { + if presets.isEmpty { + Text(kind == .override ? "No override presets found." : "No temp target presets. Add them in LoopFollow on the iPhone.") + .font(.footnote) + .foregroundStyle(.secondary) + } + ForEach(presets) { preset in + Button { + navigator.push(.confirm(CommandRequest( + title: title, + lines: [preset.name, preset.detail], + action: kind == .override ? .startOverride(presetID: preset.id) : .startTempTarget(presetID: preset.id), + deliversInsulin: false + ))) + } label: { + VStack(alignment: .leading) { + Text(preset.name) + Text(preset.detail) + .font(.caption2) + .foregroundStyle(.secondary) + } + } + } + } + } + .navigationTitle(title) + } +} diff --git a/LoopFollowWatch/Remote/TreatmentMenuView.swift b/LoopFollowWatch/Remote/TreatmentMenuView.swift new file mode 100644 index 000000000..830648630 --- /dev/null +++ b/LoopFollowWatch/Remote/TreatmentMenuView.swift @@ -0,0 +1,24 @@ +// LoopFollow +// TreatmentMenuView.swift + +import SwiftUI + +struct TreatmentMenuView: View { + @EnvironmentObject private var navigator: WatchNavigator + + var body: some View { + List { + Button { + navigator.push(.meal) + } label: { + Label("Meal", systemImage: "fork.knife") + } + Button { + navigator.push(.bolus) + } label: { + Label("Bolus", systemImage: "syringe") + } + } + .navigationTitle("Treatment") + } +} diff --git a/LoopFollowWatch/StandaloneFetcher.swift b/LoopFollowWatch/StandaloneFetcher.swift new file mode 100644 index 000000000..0617371c0 --- /dev/null +++ b/LoopFollowWatch/StandaloneFetcher.swift @@ -0,0 +1,211 @@ +// LoopFollow +// StandaloneFetcher.swift + +import Foundation + +/// Builds the watch state straight from Nightscout or Dexcom while the phone is +/// out of reach. What only the phone knows (info rows, presets, guardrails, +/// thresholds) is kept from the last state the phone sent. +enum StandaloneFetcher { + enum FetchError: LocalizedError { + case noDataSource + case badResponse + + var errorDescription: String? { + switch self { + case .noDataSource: "No Nightscout or Dexcom account on the watch yet." + case .badResponse: "Unexpected response from the data source." + } + } + } + + private static let history: TimeInterval = 24 * 3600 + + static func fetch(credentials: WatchCredentials, base: WatchState?, now: Date = Date()) async throws -> WatchState { + if !credentials.nightscoutURL.isEmpty { + return try await fetchNightscout(credentials: credentials, base: base, now: now) + } + if !credentials.dexcomUsername.isEmpty, !credentials.dexcomPassword.isEmpty { + return try await fetchDexcom(credentials: credentials, base: base, now: now) + } + throw FetchError.noDataSource + } + + // MARK: - Nightscout + + private static func fetchNightscout(credentials: WatchCredentials, base: WatchState?, now: Date) async throws -> WatchState { + let since = now.addingTimeInterval(-history) + async let entries = get(credentials, "/api/v1/entries.json", [ + "find[date][$gte]": String(Int(since.timeIntervalSince1970 * 1000)), + "find[type][$ne]": "cal", + "count": "400", + ]) + async let deviceStatus = get(credentials, "/api/v1/devicestatus.json", ["count": "1"]) + async let treatments = get(credentials, "/api/v1/treatments.json", [ + "find[created_at][$gte]": DeviceStatusSummary.iso.string(from: since), + "count": "500", + ]) + + // Nightscout dates are milliseconds; the rest of the app works in seconds, as on the phone. + let glucose = try JSONDecoder().decode([ShareGlucoseData].self, from: await entries) + .filter { $0.sgv > 0 } + .map { ShareGlucoseData(sgv: $0.sgv, date: $0.date / 1000, direction: $0.direction) } + .sorted { $0.date < $1.date } + let status = (try? JSONSerialization.jsonObject(with: await deviceStatus) as? [[String: Any]])?.first + let treatmentList = (try? JSONSerialization.jsonObject(with: await treatments) as? [[String: Any]]) ?? [] + + return build(glucose: glucose, status: status.map { DeviceStatusSummary.parse($0, showsCone: base?.cone.isEmpty == false) }, treatments: treatmentList, base: base, now: now) + } + + private static func get(_ credentials: WatchCredentials, _ path: String, _ parameters: [String: String]) async throws -> Data { + var components = URLComponents(string: credentials.nightscoutURL) + components?.path = path + var items = parameters.map { URLQueryItem(name: $0.key, value: $0.value) } + if !credentials.nightscoutToken.isEmpty { + items.append(URLQueryItem(name: "token", value: credentials.nightscoutToken)) + } + components?.queryItems = items + guard let url = components?.url else { throw FetchError.badResponse } + var request = URLRequest(url: url) + request.timeoutInterval = 20 + let (data, response) = try await URLSession.shared.data(for: request) + guard (response as? HTTPURLResponse)?.statusCode == 200 else { throw FetchError.badResponse } + return data + } + + // MARK: - Dexcom + + private static func fetchDexcom(credentials: WatchCredentials, base: WatchState?, now: Date) async throws -> WatchState { + let server = credentials.dexcomServer == "US" ? KnownShareServers.US.rawValue : KnownShareServers.NON_US.rawValue + let client = ShareClient(username: credentials.dexcomUsername, password: credentials.dexcomPassword, shareServer: server) + let glucose: [ShareGlucoseData] = try await withCheckedThrowingContinuation { continuation in + client.fetchData(288) { error, data in + if let data { + continuation.resume(returning: data) + } else { + continuation.resume(throwing: error ?? FetchError.badResponse) + } + } + } + return build(glucose: glucose.sorted { $0.date < $1.date }, status: nil, treatments: [], base: base, now: now) + } + + // MARK: - Building + + private static func build(glucose: [ShareGlucoseData], status: DeviceStatusSummary?, treatments: [[String: Any]], base: WatchState?, now: Date) -> WatchState { + let cutoff = now.timeIntervalSince1970 - history + let readings = glucose.filter { $0.date >= cutoff }.map { WatchReading(t: $0.date, v: Double($0.sgv)) } + let latest = glucose.last + let previous = glucose.dropLast().last + + // As on the phone (RecommendedBolus), a recommendation from a loop cycle + // that ran before the latest manual bolus leaves that bolus out, and + // offering it could double it. Insulin sent from the watch is left out + // on the bolus screen. + var recommendedBolus = status?.recommendedBolus + if let loopAt = status?.loopAt, let lastBolus = lastManualBolus(treatments), lastBolus >= loopAt { + recommendedBolus = nil + } + + let active = activeAdjustments(treatments, now: now) + var snapshot: GlucoseSnapshot? + if let latest { + let loopAge = status?.loopAt.map { now.timeIntervalSince($0) } + snapshot = GlucoseSnapshot( + glucose: Double(latest.sgv), + delta: previous.map { Double(latest.sgv - $0.sgv) } ?? 0, + trend: GlucoseSnapshot.Trend(code: latest.direction), + updatedAt: Date(timeIntervalSince1970: latest.date), + iob: status?.iob, + cob: status?.cob, + projected: nil, + override: active.override, + tempTargetMgdl: active.tempTargetMgdl, + recBolus: recommendedBolus, + unit: base?.snapshot?.unit ?? .mgdl, + isNotLooping: loopAge.map { $0 >= 15 * 60 } ?? false + ) + } + + var remote = base?.remote + if remote != nil { + remote?.recommendedBolus = recommendedBolus + remote?.recommendedBolusAt = status?.loopAt + } + + return WatchState( + builtAt: now, + source: .watch, + phoneBuiltAt: base?.phoneBuiltAt ?? .distantPast, + snapshot: snapshot, + readings: readings, + predictions: status?.predictions ?? [], + cone: status?.cone ?? [], + boluses: treatments.compactMap { reading($0, key: "insulin", cutoff: cutoff) }.sorted { $0.t < $1.t }, + carbs: treatments.compactMap { reading($0, key: "carbs", cutoff: cutoff) }.sorted { $0.t < $1.t }, + lowMgdl: base?.lowMgdl ?? 70, + highMgdl: base?.highMgdl ?? 180, + colorBGText: base?.colorBGText ?? true, + infoRows: base?.infoRows ?? [], + stats: base?.stats, + device: base?.device ?? "", + alarm: nil, + remote: remote + ) + } + + private static func reading(_ treatment: [String: Any], key: String, cutoff: TimeInterval) -> WatchReading? { + guard let value = treatment[key] as? Double, value > 0, + let at = DeviceStatusSummary.date(treatment["timestamp"] ?? treatment["created_at"]), + at.timeIntervalSince1970 >= cutoff + else { return nil } + return WatchReading(t: at.timeIntervalSince1970, v: value) + } + + /// The latest manual bolus, classified as the phone does (Treatments.swift): + /// automatic boluses and SMBs are left out, since the cycle that recommends + /// a bolus can also give one. + private static func lastManualBolus(_ treatments: [[String: Any]]) -> Date? { + treatments.compactMap { entry -> Date? in + guard let insulin = entry["insulin"] as? Double, insulin > 0 else { return nil } + switch entry["eventType"] as? String { + case "Correction Bolus", "Bolus", "External Insulin": + if entry["automatic"] as? Bool == true { return nil } + case "Meal Bolus": + break + default: + return nil + } + return DeviceStatusSummary.date(entry["timestamp"] ?? entry["created_at"]) + }.max() + } + + /// The override and temp target that are running now, from the latest of each in the treatments. + private static func activeAdjustments(_ treatments: [[String: Any]], now: Date) -> (override: String?, tempTargetMgdl: Double?) { + func latest(_ eventType: String) -> (start: Date, entry: [String: Any])? { + treatments + .filter { $0["eventType"] as? String == eventType } + .compactMap { entry in DeviceStatusSummary.date(entry["timestamp"] ?? entry["created_at"]).map { ($0, entry) } } + .max { $0.0 < $1.0 } + } + + var override: String? + if let (start, entry) = latest("Temporary Override") { + let minutes = entry["duration"] as? Double ?? 0 + let indefinite = minutes == 0 || (entry["durationType"] as? String) == "indefinite" + if start <= now, indefinite || now < start.addingTimeInterval(minutes * 60) { + // Same order as the phone: Trio keeps the name in "notes", Loop in "reason". + override = entry["notes"] as? String ?? entry["reason"] as? String ?? "Override" + } + } + + var tempTarget: Double? + if let (start, entry) = latest("Temporary Target") { + let minutes = entry["duration"] as? Double ?? 0 + if minutes > 0, start <= now, now < start.addingTimeInterval(minutes * 60) { + tempTarget = entry["targetTop"] as? Double ?? entry["targetBottom"] as? Double + } + } + return (override, tempTarget) + } +} diff --git a/LoopFollowWatch/StatsPage.swift b/LoopFollowWatch/StatsPage.swift new file mode 100644 index 000000000..aa9057e2c --- /dev/null +++ b/LoopFollowWatch/StatsPage.swift @@ -0,0 +1,69 @@ +// LoopFollow +// StatsPage.swift + +import Charts +import SwiftUI + +/// The statistics panel from under the phone's chart: the same pie and values. +struct StatsPage: View { + let state: WatchState + let stats: WatchStats + + var body: some View { + VStack(spacing: 8) { + Chart(slices, id: \.0) { slice in + SectorMark(angle: .value("share", slice.1)) + .foregroundStyle(slice.2) + } + .chartLegend(.hidden) + // Shrinks so the page fits the smaller watch sizes without scrolling. + .frame(minHeight: 30, maxHeight: 70) + .accessibilityHidden(true) + + Grid(horizontalSpacing: 4, verticalSpacing: 6) { + GridRow { + value("Low:", stats.lowPercent) + value("In Range:", stats.inRangePercent) + value("High:", stats.highPercent) + } + GridRow { + value("Avg BG:", stats.avgBG) + value(stats.estA1CTitle, stats.estA1C) + value(stats.stdDevTitle, stats.stdDev) + } + } + .layoutPriority(1) + + if state.source == .watch { + Text("From the iPhone at \(state.phoneBuiltAt.formatted(date: .omitted, time: .shortened))") + .font(.caption2) + .foregroundStyle(.orange) + .layoutPriority(1) + } + } + // Keeps the values clear of the page indicator. + .padding(.trailing, 10) + .navigationTitle("Statistics") + } + + /// Same colors as the phone's pie: red low, green in range, yellow high. + private var slices: [(String, Double, Color)] { + [ + ("low", max(stats.pieLow, 0.1), .red), + ("range", max(stats.pieRange, 0.1), .green), + ("high", max(stats.pieHigh, 0.1), .yellow), + ] + } + + private func value(_ title: String, _ value: String) -> some View { + VStack(spacing: 0) { + Text(title) + .foregroundStyle(.secondary) + Text(value.isEmpty ? "—" : value) + } + .font(.caption2) + .lineLimit(1) + .minimumScaleFactor(0.6) + .frame(maxWidth: .infinity) + } +} diff --git a/LoopFollowWatch/WatchChartView.swift b/LoopFollowWatch/WatchChartView.swift new file mode 100644 index 000000000..71cc3225f --- /dev/null +++ b/LoopFollowWatch/WatchChartView.swift @@ -0,0 +1,199 @@ +// LoopFollow +// WatchChartView.swift + +import Charts +import SwiftUI + +/// BG chart in the phone's colors: readings colored by range, dashed low/high +/// lines, prediction lines or cone in their phone colors, boluses and carbs as markers, +/// and overrides as a green band along the top. +struct WatchChartView: View { + let state: WatchState + let now: Date + let spanHours: Double + /// How far back the chart is scrolled; nil when not scrubbing. + var scrubOffset: TimeInterval? + + static let spans: [Double] = [1, 3, 6, 12, 24] + + private var unit: GlucoseSnapshot.Unit { state.snapshot?.unit ?? .mgdl } + + var body: some View { + let window = visibleWindow + let readings = state.readings.filter { window.contains($0.date) } + let predictions = state.predictions.map { line in + (line.kind, line.points.filter { window.contains($0.date) }) + } + let cone = state.cone.filter { window.contains($0.date) } + let overrides = (state.overrides ?? []).filter { $0.endDate > window.lowerBound && $0.startDate < window.upperBound } + let top = yTop(readings: readings, predictions: predictions.flatMap(\.1) + cone.map { WatchReading(t: $0.t, v: $0.high) }) + + Chart { + RuleMark(y: .value("High", state.highMgdl)) + .foregroundStyle(Color.yellow.opacity(0.5)) + .lineStyle(StrokeStyle(lineWidth: 1, dash: [4, 3])) + RuleMark(y: .value("Low", state.lowMgdl)) + .foregroundStyle(Color.red.opacity(0.5)) + .lineStyle(StrokeStyle(lineWidth: 1, dash: [4, 3])) + RuleMark(x: .value("Now", now)) + .foregroundStyle(Color.gray.opacity(0.5)) + .lineStyle(StrokeStyle(lineWidth: 1)) + + ForEach(cone, id: \.t) { point in + AreaMark( + x: .value("Time", point.date), + yStart: .value("Low", point.low), + yEnd: .value("High", min(point.high, top)) + ) + .foregroundStyle(Color.blue.opacity(0.4)) + } + + // A thin band along the top edge, like the override strip on the phone's chart. + ForEach(overrides, id: \.start) { override in + RectangleMark( + xStart: .value("Start", max(override.startDate, window.lowerBound)), + xEnd: .value("End", min(override.endDate, window.upperBound)), + y: .value("Override", top), + height: .fixed(4) + ) + .foregroundStyle(Color.green.opacity(0.8)) + } + + if let marker = markerDate { + RuleMark(x: .value("Marker", marker)) + .foregroundStyle(Color.white.opacity(0.8)) + .lineStyle(StrokeStyle(lineWidth: 1)) + } + + ForEach(predictions, id: \.0) { kind, points in + ForEach(points, id: \.t) { point in + LineMark( + x: .value("Time", point.date), + y: .value("BG", min(point.v, top)), + series: .value("Series", kind.rawValue) + ) + .foregroundStyle(Self.color(for: kind)) + .lineStyle(StrokeStyle(lineWidth: 1.5)) + } + } + + ForEach(readings, id: \.t) { reading in + PointMark( + x: .value("Time", reading.date), + y: .value("BG", min(reading.v, top)) + ) + .symbolSize(spanHours <= 3 ? 14 : 6) + .foregroundStyle(WatchFormat.bgColor(reading.v, lowMgdl: state.lowMgdl, highMgdl: state.highMgdl)) + } + + ForEach(state.carbs.filter { window.contains($0.date) }, id: \.t) { carb in + PointMark( + x: .value("Time", carb.date), + y: .value("BG", max(markerY(at: carb.date, top: top) - 30, 45)) + ) + .symbolSize(30) + .foregroundStyle(Color.orange.opacity(0.85)) + } + + ForEach(state.boluses.filter { window.contains($0.date) }, id: \.t) { bolus in + PointMark( + x: .value("Time", bolus.date), + y: .value("BG", markerY(at: bolus.date, top: top) + 30) + ) + .symbol(.triangle) + .symbolSize(24) + .foregroundStyle(Color.blue.opacity(0.85)) + } + } + .chartXScale(domain: window.lowerBound ... window.upperBound) + .chartYScale(domain: 39 ... top) + .chartXAxis { + AxisMarks(values: hourTicks(in: window)) { _ in + AxisGridLine().foregroundStyle(Color.gray.opacity(0.3)) + AxisValueLabel(format: .dateTime.hour()) + } + } + .chartYAxis { + AxisMarks(position: .trailing, values: yAxisValues(top: top)) { value in + AxisGridLine().foregroundStyle(Color.gray.opacity(0.3)) + AxisValueLabel { + if let mgdl = value.as(Double.self) { + Text(WatchFormat.glucose(mgdl, unit: unit)) + } + } + } + } + .accessibilityLabel("BG chart, last \(Int(spanHours)) hours.") + } + + private var visibleWindow: ClosedRange { + Self.window(state: state, now: now, spanHours: spanHours, scrubOffset: scrubOffset ?? 0) + } + + private var markerDate: Date? { + scrubOffset.map { Self.markerDate(state: state, now: now, spanHours: spanHours, scrubOffset: $0) } + } + + /// The visible span ends a little after now so the prediction stays visible; + /// while scrubbing it moves back by the scroll offset. + static func window(state: WatchState, now: Date, spanHours: Double, scrubOffset: TimeInterval) -> ClosedRange { + let span = spanHours * 3600 + let lookAhead = state.predictions.isEmpty && state.cone.isEmpty ? span * 0.05 : span * 0.25 + let end = now.addingTimeInterval(lookAhead - scrubOffset) + return end.addingTimeInterval(-span) ... end + } + + /// While scrubbing, the time the marker points at: three quarters across the chart. + static func markerDate(state: WatchState, now: Date, spanHours: Double, scrubOffset: TimeInterval) -> Date { + window(state: state, now: now, spanHours: spanHours, scrubOffset: scrubOffset).upperBound + .addingTimeInterval(-spanHours * 3600 * 0.25) + } + + /// Whole hours in the window, leaving out those too close to an edge for their label to fit. + private func hourTicks(in window: ClosedRange) -> [Date] { + let step = spanHours > 6 ? 6 : (spanHours > 3 ? 2 : 1) + let margin = (window.upperBound.timeIntervalSince(window.lowerBound)) * 0.06 + let calendar = Calendar.current + guard var tick = calendar.dateInterval(of: .hour, for: window.lowerBound)?.end else { return [] } + var ticks: [Date] = [] + while tick < window.upperBound { + let hour = calendar.component(.hour, from: tick) + if hour % step == 0, + tick.timeIntervalSince(window.lowerBound) > margin, + window.upperBound.timeIntervalSince(tick) > margin + { + ticks.append(tick) + } + tick = tick.addingTimeInterval(3600) + } + return ticks + } + + private func yTop(readings: [WatchReading], predictions: [WatchReading]) -> Double { + let highest = max(readings.map(\.v).max() ?? 0, predictions.map(\.v).max() ?? 0) + return min(max(250, highest + 20), 400) + } + + private func yAxisValues(top: Double) -> [Double] { + let steps: [Double] = unit == .mmol + ? [72, 144, 216, 288, 360].filter { $0 < top } + : [100, 200, 300].filter { $0 < top } + return steps + } + + /// Markers sit next to the BG reading closest in time: boluses above, carbs below, as on the phone. + private func markerY(at date: Date, top: Double) -> Double { + let nearest = state.readings.min { abs($0.date.timeIntervalSince(date)) < abs($1.date.timeIntervalSince(date)) } + return min(nearest?.v ?? 100, top - 30) + } + + static func color(for kind: WatchPredictionLine.Kind) -> Color { + switch kind { + case .loop: .purple + case .zt: Color(red: 0.443, green: 0.380, blue: 0.937) + case .iob: Color(red: 0.118, green: 0.588, blue: 0.988) + case .cob: .orange + case .uam: Color(red: 0.820, green: 0.169, blue: 0.969) + } + } +} diff --git a/LoopFollowWatch/WatchKeychain.swift b/LoopFollowWatch/WatchKeychain.swift new file mode 100644 index 000000000..3c53feb87 --- /dev/null +++ b/LoopFollowWatch/WatchKeychain.swift @@ -0,0 +1,46 @@ +// LoopFollow +// WatchKeychain.swift + +import Foundation +import Security + +/// Stores the credentials from the phone in the watch's Keychain. Items are +/// readable after the first unlock and never leave this watch, not even in a backup. +enum WatchKeychain { + private static let service = "LoopFollowWatch" + private static let account = "credentials" + + private static var query: [String: Any] { + [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + ] + } + + static func save(_ credentials: WatchCredentials) { + guard let data = try? JSONEncoder().encode(credentials) else { return } + let attributes: [String: Any] = [ + kSecValueData as String: data, + kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, + ] + var status = SecItemUpdate(query as CFDictionary, attributes as CFDictionary) + if status == errSecItemNotFound { + status = SecItemAdd(query.merging(attributes) { $1 } as CFDictionary, nil) + } + if status != errSecSuccess { + LogManager.shared.log(category: .general, message: "Keychain save failed: \(status)") + } + } + + static func load() -> WatchCredentials? { + var search = query + search[kSecReturnData as String] = true + search[kSecMatchLimit as String] = kSecMatchLimitOne + var result: AnyObject? + guard SecItemCopyMatching(search as CFDictionary, &result) == errSecSuccess, + let data = result as? Data + else { return nil } + return try? JSONDecoder().decode(WatchCredentials.self, from: data) + } +} diff --git a/LoopFollowWatch/WatchSession.swift b/LoopFollowWatch/WatchSession.swift new file mode 100644 index 000000000..1c7d97eac --- /dev/null +++ b/LoopFollowWatch/WatchSession.swift @@ -0,0 +1,356 @@ +// LoopFollow +// WatchSession.swift + +import Foundation +import WatchConnectivity +import WatchKit +import WidgetKit + +/// Watch side of the phone link. The phone is the main source: it sends +/// `WatchState` and relays commands. While the phone's data is stale and the +/// phone does not answer, the watch fetches from Nightscout or Dexcom itself +/// and sends commands directly. +@MainActor +final class WatchSession: NSObject, ObservableObject { + static let shared = WatchSession() + + /// Phone data older than this hands over to the watch's own fetching. + static let phoneStaleAfter: TimeInterval = 10 * 60 + /// Minimum time between two fetches made by the watch itself. + private static let standaloneInterval: TimeInterval = 60 + /// After a command sent without the phone, the watch fetches this many seconds + /// after the send instead of waiting for its regular minute, until the change shows up. + private static let directFollowUpDelays: [TimeInterval] = [10, 20, 40, 60] + + @Published private(set) var state: WatchState? + @Published private(set) var isReachable = false + + private var session: WCSession { WCSession.default } + private var credentials: WatchCredentials? + private var lastPhoneBuiltAt: Date + private var lastStandaloneAttempt: Date = .distantPast + private var directFollowUp: Task? + + override private init() { + let stored = WatchStateStore.load() + state = stored + lastPhoneBuiltAt = stored?.phoneBuiltAt ?? .distantPast + credentials = WatchKeychain.load() + super.init() + } + + func activate() { + guard WCSession.isSupported(), session.delegate == nil else { return } + session.delegate = self + session.activate() + } + + /// Commands go through the phone, or directly when the phone sent what that needs. + var canSendCommands: Bool { + isReachable || credentials?.remote != nil + } + + /// True while the phone's latest state is recent enough to rely on. + var isPhoneDataFresh: Bool { + Date().timeIntervalSince(lastPhoneBuiltAt) < Self.phoneStaleAfter + } + + /// Asks the phone for fresh state; the application context covers the case + /// where the phone is out of reach. + func requestState() { + guard session.activationState == .activated, session.isReachable else { return } + session.sendMessage([WatchMessageKey.requestState: true], replyHandler: { reply in + guard let data = reply[WatchState.contextKey] as? Data else { return } + Task { @MainActor in self.applyFromPhone(data) } + }, errorHandler: nil) + } + + /// Fetches from Nightscout or Dexcom when the phone's data is stale and the phone does not answer. + func refreshIfNeeded() async { + guard !isPhoneDataFresh else { return } + if session.activationState == .activated, session.isReachable { + requestState() + try? await Task.sleep(nanoseconds: 8_000_000_000) + guard !isPhoneDataFresh else { return } + } + guard let credentials, credentials.hasDataSource else { + LogManager.shared.log(category: .general, message: "Phone data is stale, but the watch has no data source credentials yet") + return + } + guard Date().timeIntervalSince(lastStandaloneAttempt) >= Self.standaloneInterval else { return } + guard let fetched = await fetchStandalone(credentials), !isPhoneDataFresh else { return } + store(fetched) + } + + private func fetchStandalone(_ credentials: WatchCredentials) async -> WatchState? { + lastStandaloneAttempt = Date() + do { + let fetched = try await StandaloneFetcher.fetch(credentials: credentials, base: state) + LogManager.shared.log(category: .nightscout, message: "Standalone fetch: \(fetched.readings.count) readings") + return fetched + } catch { + LogManager.shared.log(category: .nightscout, message: "Standalone fetch failed: \(error.localizedDescription)") + return nil + } + } + + private nonisolated static let lastInsulinSentKey = "lastInsulinSentAt" + + /// When insulin was last sent from the watch, through the phone or directly. + /// Set before sending, since a command without a clear reply may still have + /// gone through. + nonisolated static var lastInsulinSentAt: Date? { + UserDefaults.standard.object(forKey: lastInsulinSentKey) as? Date + } + + /// Sends a command through the phone when it is reachable, otherwise directly. + /// Falls back to sending directly only when the phone turned out to be out of + /// reach before the command was delivered; a command that may have reached + /// the phone is never sent a second time. + func send(_ action: WatchCommand.Action) async -> WatchCommandReply { + if action.deliversInsulin { + UserDefaults.standard.set(Date(), forKey: Self.lastInsulinSentKey) + } + if session.activationState == .activated, session.isReachable { + switch await sendThroughPhone(action) { + case let .replied(reply): return reply + case .notDelivered: break + } + } + if case .snoozeAlarm = action { + return WatchCommandReply(success: false, message: "Snoozing needs the iPhone, which is not reachable right now.") + } + guard let credentials, let config = state?.remote else { + return WatchCommandReply(success: false, message: "iPhone not reachable. Open LoopFollow on the iPhone and keep it near the watch.") + } + let reply = await DirectCommandSender.send(action, credentials: credentials, config: config) + if reply.success { + followUpDirectCommand(action, credentials: credentials, config: config) + } + return reply + } + + /// The phone did not send this command, so it does not fetch early for it. + /// The watch fetches a few times itself, even while the phone's last state is + /// still fresh, and stops at the first fetch that shows the change. + private func followUpDirectCommand(_ action: WatchCommand.Action, credentials: WatchCredentials, config: WatchRemoteConfig) { + guard credentials.hasDataSource, + let arrived = Self.arrivalCheck(for: action, config: config, sentAt: Date()) + else { return } + let phoneBuiltAtSend = lastPhoneBuiltAt + directFollowUp?.cancel() + directFollowUp = Task { + var elapsed: TimeInterval = 0 + for delay in Self.directFollowUpDelays { + try? await Task.sleep(nanoseconds: UInt64((delay - elapsed) * 1_000_000_000)) + elapsed = delay + guard !Task.isCancelled else { return } + guard var fetched = await fetchStandalone(credentials) else { continue } + // Once the phone is back, its states take over again; a fetch started + // before one arrived would replace it with the watch's own. + guard !Task.isCancelled, lastPhoneBuiltAt == phoneBuiltAtSend else { return } + // The watch's own fetch has no alarms. While the phone's last state is + // still fresh, its alarm stays shown, as it would without this fetch. + if isPhoneDataFresh { + fetched.alarm = state?.alarm + } + store(fetched) + if arrived(fetched) { + LogManager.shared.log(category: .nightscout, message: "Direct \(action.name) showed up after \(Int(delay)) s") + return + } + } + } + } + + /// True once a fetched state shows what the command changes. A check that never + /// matches (a partial bolus, a renamed preset) only means every follow-up fetch runs. + private static func arrivalCheck(for action: WatchCommand.Action, config: WatchRemoteConfig, sentAt: Date) -> ((WatchState) -> Bool)? { + let sent = sentAt.timeIntervalSince1970 + switch action { + case let .startOverride(presetID): + let name = config.overridePresets.first { $0.id == presetID }?.name ?? presetID + return { $0.snapshot?.override?.contains(name) == true } + case .cancelOverride: + return { $0.snapshot.map { $0.override == nil } ?? false } + case let .startTempTarget(presetID): + let target = config.tempTargetPresets.first { $0.id == presetID }?.targetMgdl + return { state in + guard let current = state.snapshot?.tempTargetMgdl else { return false } + return target.map { abs(current - $0) < 1 } ?? true + } + case .cancelTempTarget: + return { $0.snapshot.map { $0.tempTargetMgdl == nil } ?? false } + case let .bolus(units): + return { $0.boluses.contains { $0.t >= sent - 60 && abs($0.v - units) < 0.01 } } + case let .meal(carbs, _, _, _, consumedAt): + guard carbs > 0 else { return { _ in false } } + let at = consumedAt.timeIntervalSince1970 + return { $0.carbs.contains { abs($0.t - at) < 5 * 60 && abs($0.v - carbs) < 0.5 } } + case .snoozeAlarm: + return nil + } + } + + private enum PhoneSendResult { + case replied(WatchCommandReply) + /// WatchConnectivity reported the phone unreachable, so the command never left the watch. + case notDelivered + } + + private func sendThroughPhone(_ action: WatchCommand.Action) async -> PhoneSendResult { + guard let data = try? JSONEncoder().encode(WatchCommand(action: action)) else { + return .replied(WatchCommandReply(success: false, message: "Could not encode the command.")) + } + let noReply = WatchCommandReply( + success: false, + message: "No reply from the iPhone. The command may still have been sent: check Loop or Trio before trying again." + ) + return await withCheckedContinuation { continuation in + let once = ResumeOnce(continuation) + session.sendMessage([WatchCommand.messageKey: data], replyHandler: { reply in + let result = (reply[WatchCommandReply.messageKey] as? Data) + .flatMap { try? JSONDecoder().decode(WatchCommandReply.self, from: $0) } + once.resume(.replied(result ?? WatchCommandReply(success: false, message: "Unexpected reply from the iPhone."))) + }, errorHandler: { error in + once.resume((error as? WCError)?.code == .notReachable ? .notDelivered : .replied(noReply)) + }) + DispatchQueue.main.asyncAfter(deadline: .now() + Self.phoneReplyTimeout) { + once.resume(.replied(noReply)) + } + } + } + + /// Longest wait for the phone's verdict before telling the user it is unknown. + private static let phoneReplyTimeout: TimeInterval = 30 + + /// Lets a WatchConnectivity background task finish once queued data has been delivered. + func waitForPendingContent() async { + activate() + for _ in 0 ..< 40 { + if session.activationState == .activated, !session.hasContentPending { return } + try? await Task.sleep(nanoseconds: 500_000_000) + } + } + + static let backgroundRefreshID = "standalone" + + /// Schedules the next background check for stale phone data. + func scheduleBackgroundRefresh() { + WKApplication.shared().scheduleBackgroundRefresh( + withPreferredDate: Date().addingTimeInterval(15 * 60), + userInfo: Self.backgroundRefreshID as NSString + ) { _ in } + } + + private func applyFromPhone(_ data: Data) { + guard let incoming = WatchState.decode(data), incoming.builtAt > lastPhoneBuiltAt else { return } + lastPhoneBuiltAt = incoming.builtAt + store(incoming) + } + + private func store(_ incoming: WatchState) { + guard let encoded = incoming.encoded() else { return } + let newAlarm = incoming.alarm != nil && incoming.alarm?.id != state?.alarm?.id + let complicationChanged = Self.complicationKey(incoming) != state.map(Self.complicationKey) + state = incoming + WatchStateStore.save(encoded) + // Widget reloads are budgeted, so only reload when something the complication shows has changed. + if complicationChanged { + WidgetCenter.shared.reloadAllTimelines() + } + + if newAlarm, WKApplication.shared().applicationState == .active { + WKInterfaceDevice.current().play(.notification) + } + } + + private static func complicationKey(_ state: WatchState) -> String { + let s = state.snapshot + return "\(s?.updatedAt.timeIntervalSince1970 ?? 0)|\(s?.unit.rawValue ?? "")|\(state.lowMgdl)|\(state.highMgdl)|\(state.colorBGText)" + } + + private func receiveCredentials(_ data: Data) { + guard let received = try? JSONDecoder().decode(WatchCredentials.self, from: data) else { + LogManager.shared.log(category: .general, message: "Could not decode credentials from the phone") + return + } + credentials = received + WatchKeychain.save(received) + LogManager.shared.log(category: .general, message: "Credentials received (remote commands: \(received.remote != nil))") + } + + /// Asks the phone for credentials when the watch has none, e.g. after a reinstall. + private func requestCredentialsIfMissing() { + guard credentials == nil, session.activationState == .activated else { return } + if session.isReachable { + session.sendMessage([WatchCredentials.requestKey: true], replyHandler: { reply in + guard let data = reply[WatchCredentials.userInfoKey] as? Data else { return } + Task { @MainActor in self.receiveCredentials(data) } + }, errorHandler: nil) + } else { + session.transferUserInfo([WatchCredentials.requestKey: true]) + } + } +} + +extension WatchSession: WCSessionDelegate { + nonisolated func session(_ session: WCSession, activationDidCompleteWith _: WCSessionActivationState, error _: Error?) { + let context = session.receivedApplicationContext[WatchState.contextKey] as? Data + let reachable = session.isReachable + Task { @MainActor in + if let context { self.applyFromPhone(context) } + self.isReachable = reachable + self.requestState() + self.requestCredentialsIfMissing() + } + } + + nonisolated func sessionReachabilityDidChange(_ session: WCSession) { + let reachable = session.isReachable + Task { @MainActor in + self.isReachable = reachable + if reachable { + self.requestState() + self.requestCredentialsIfMissing() + } + } + } + + nonisolated func session(_: WCSession, didReceiveApplicationContext applicationContext: [String: Any]) { + guard let data = applicationContext[WatchState.contextKey] as? Data else { return } + Task { @MainActor in self.applyFromPhone(data) } + } + + nonisolated func session(_: WCSession, didReceiveMessage message: [String: Any]) { + guard let credentials = message[WatchCredentials.userInfoKey] as? Data else { return } + Task { @MainActor in self.receiveCredentials(credentials) } + } + + nonisolated func session(_: WCSession, didReceiveUserInfo userInfo: [String: Any] = [:]) { + let state = userInfo[WatchState.contextKey] as? Data + let credentials = userInfo[WatchCredentials.userInfoKey] as? Data + Task { @MainActor in + if let state { self.applyFromPhone(state) } + if let credentials { self.receiveCredentials(credentials) } + } + } +} + +/// Resumes a continuation at most once, from whichever callback comes first. +private final class ResumeOnce: @unchecked Sendable { + private var continuation: CheckedContinuation? + private let lock = NSLock() + + init(_ continuation: CheckedContinuation) { + self.continuation = continuation + } + + func resume(_ value: T) { + lock.lock() + let pending = continuation + continuation = nil + lock.unlock() + pending?.resume(returning: value) + } +} diff --git a/LoopFollowWatchWidgets/GlucoseComplicationView.swift b/LoopFollowWatchWidgets/GlucoseComplicationView.swift new file mode 100644 index 000000000..7e42e5f29 --- /dev/null +++ b/LoopFollowWatchWidgets/GlucoseComplicationView.swift @@ -0,0 +1,132 @@ +// LoopFollow +// GlucoseComplicationView.swift + +import Charts +import SwiftUI +import WidgetKit + +struct GlucoseComplicationView: View { + let entry: GlucoseEntry + @Environment(\.widgetFamily) private var family + + private var snapshot: GlucoseSnapshot? { entry.state?.snapshot } + + private var isStale: Bool { + guard let snapshot else { return true } + return entry.date.timeIntervalSince(snapshot.updatedAt) >= WatchFormat.staleAfter + } + + private var bgText: String { + guard let snapshot else { return "--" } + return WatchFormat.glucose(snapshot.glucose, unit: snapshot.unit) + } + + private var bgColor: Color { + guard let state = entry.state, let snapshot else { return .secondary } + if isStale { return .secondary } + return WatchFormat.bgColor(snapshot.glucose, lowMgdl: state.lowMgdl, highMgdl: state.highMgdl, colored: state.colorBGText) + } + + private var trendText: String { + snapshot.map { WatchFormat.trendArrow($0.trend) } ?? "" + } + + private var deltaText: String { + snapshot.map { WatchFormat.delta($0.delta, unit: $0.unit) } ?? "" + } + + private var ageText: String { + snapshot.map { WatchFormat.minutesAgo($0.updatedAt, now: entry.date) } ?? "" + } + + var body: some View { + switch family { + case .accessoryCircular: + circular + case .accessoryRectangular: + rectangular + case .accessoryCorner: + corner + default: + inline + } + } + + private var circular: some View { + VStack(spacing: -2) { + Text(bgText) + .font(.system(size: 20, weight: .bold)) + .foregroundStyle(bgColor) + .strikethrough(isStale, color: .red) + .minimumScaleFactor(0.5) + Text(trendText) + .font(.system(size: 12, weight: .bold)) + Text(ageText) + .font(.system(size: 9)) + .foregroundStyle(.secondary) + } + .widgetAccentable() + } + + private var rectangular: some View { + HStack(spacing: 6) { + VStack(alignment: .leading, spacing: 0) { + HStack(alignment: .firstTextBaseline, spacing: 2) { + Text(bgText) + .font(.system(size: 26, weight: .bold)) + .foregroundStyle(bgColor) + .strikethrough(isStale, color: .red) + Text(trendText) + .font(.system(size: 14, weight: .bold)) + } + Text("\(deltaText) \(ageText)") + .font(.system(size: 12)) + .foregroundStyle(.secondary) + } + .lineLimit(1) + .minimumScaleFactor(0.6) + .widgetAccentable() + sparkline + } + } + + /// Last two hours of readings in range colors. + @ViewBuilder + private var sparkline: some View { + if let state = entry.state { + let start = entry.date.addingTimeInterval(-2 * 3600) + let readings = state.readings.filter { $0.date >= start } + Chart { + RuleMark(y: .value("High", state.highMgdl)) + .foregroundStyle(Color.yellow.opacity(0.4)) + .lineStyle(StrokeStyle(lineWidth: 0.5, dash: [2, 2])) + RuleMark(y: .value("Low", state.lowMgdl)) + .foregroundStyle(Color.red.opacity(0.4)) + .lineStyle(StrokeStyle(lineWidth: 0.5, dash: [2, 2])) + ForEach(readings, id: \.t) { reading in + PointMark(x: .value("Time", reading.date), y: .value("BG", min(max(reading.v, 40), 300))) + .symbolSize(6) + .foregroundStyle(WatchFormat.bgColor(reading.v, lowMgdl: state.lowMgdl, highMgdl: state.highMgdl)) + } + } + .chartXScale(domain: start ... entry.date) + .chartYScale(domain: 40 ... 300) + .chartXAxis(.hidden) + .chartYAxis(.hidden) + } + } + + private var corner: some View { + Text(bgText) + .font(.system(size: 18, weight: .bold)) + .foregroundStyle(bgColor) + .widgetCurvesContent() + .widgetLabel { + Text("\(trendText) \(deltaText) \(ageText)") + } + } + + private var inline: some View { + Text(snapshot == nil ? "LoopFollow --" : "\(bgText) \(trendText) \(deltaText) · \(ageText)") + } +} diff --git a/LoopFollowWatchWidgets/Info.plist b/LoopFollowWatchWidgets/Info.plist new file mode 100644 index 000000000..c8ec08987 --- /dev/null +++ b/LoopFollowWatchWidgets/Info.plist @@ -0,0 +1,29 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + $(display_name) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + $(LOOP_FOLLOW_MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + NSExtension + + NSExtensionPointIdentifier + com.apple.widgetkit-extension + + + diff --git a/LoopFollowWatchWidgets/LoopFollowWatchWidgets.entitlements b/LoopFollowWatchWidgets/LoopFollowWatchWidgets.entitlements new file mode 100644 index 000000000..5b963cc90 --- /dev/null +++ b/LoopFollowWatchWidgets/LoopFollowWatchWidgets.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.com.$(unique_id).LoopFollow$(app_suffix) + + + diff --git a/LoopFollowWatchWidgets/LoopFollowWatchWidgets.swift b/LoopFollowWatchWidgets/LoopFollowWatchWidgets.swift new file mode 100644 index 000000000..456d3a9bb --- /dev/null +++ b/LoopFollowWatchWidgets/LoopFollowWatchWidgets.swift @@ -0,0 +1,50 @@ +// LoopFollow +// LoopFollowWatchWidgets.swift + +import SwiftUI +import WidgetKit + +@main +struct LoopFollowWatchWidgets: WidgetBundle { + var body: some Widget { + GlucoseComplication() + } +} + +struct GlucoseComplication: Widget { + var body: some WidgetConfiguration { + StaticConfiguration(kind: "GlucoseComplication", provider: GlucoseTimelineProvider()) { entry in + GlucoseComplicationView(entry: entry) + .containerBackground(.black, for: .widget) + } + .configurationDisplayName("BG") + .description("Latest BG from LoopFollow on your iPhone.") + .supportedFamilies([.accessoryCircular, .accessoryRectangular, .accessoryInline, .accessoryCorner]) + } +} + +struct GlucoseEntry: TimelineEntry { + let date: Date + let state: WatchState? +} + +/// Reads the state the watch app stored. One entry per minute keeps the +/// "minutes ago" text and the stale marking current until the next reload. +struct GlucoseTimelineProvider: TimelineProvider { + func placeholder(in _: Context) -> GlucoseEntry { + GlucoseEntry(date: Date(), state: nil) + } + + func getSnapshot(in _: Context, completion: @escaping (GlucoseEntry) -> Void) { + completion(GlucoseEntry(date: Date(), state: WatchStateStore.load())) + } + + func getTimeline(in _: Context, completion: @escaping (Timeline) -> Void) { + let state = WatchStateStore.load() + let now = Date() + let entries = (0 ..< 30).map { minute in + GlucoseEntry(date: now.addingTimeInterval(Double(minute) * 60), state: state) + } + completion(Timeline(entries: entries, policy: .atEnd)) + } +} diff --git a/RemoteShared/APNSDelivery.swift b/RemoteShared/APNSDelivery.swift new file mode 100644 index 000000000..9ec7d44c9 --- /dev/null +++ b/RemoteShared/APNSDelivery.swift @@ -0,0 +1,100 @@ +// LoopFollow +// APNSDelivery.swift + +import Foundation + +/// Sends one remote command push to the Loop or Trio app. Used by the phone and +/// by the watch when it sends without the phone. +enum APNSDelivery { + /// Calls `completion` on an arbitrary queue with success, or the reason for failure. + static func send( + body: Data, + deviceToken: String, + topic: String, + production: Bool, + keyId: String, + teamId: String, + apnsKey: String, + expiration: String? = nil, + collapseID: String? = nil, + completion: @escaping (Bool, String?) -> Void + ) { + guard let jwt = JWTManager.shared.getOrGenerateJWT(keyId: keyId, teamId: teamId, apnsKey: apnsKey) else { + let errorMessage = "Failed to generate JWT, please check that the APNS Key ID, APNS Key and Team ID are correct." + LogManager.shared.log(category: .apns, message: errorMessage) + completion(false, errorMessage) + return + } + + guard let url = URL(string: "\(APNSEnvironment.baseURL(production: production))/3/device/\(deviceToken)") else { + let errorMessage = "Failed to construct APNs URL" + LogManager.shared.log(category: .apns, message: errorMessage) + completion(false, errorMessage) + return + } + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("bearer \(jwt)", forHTTPHeaderField: "authorization") + request.setValue("application/json", forHTTPHeaderField: "content-type") + request.setValue("10", forHTTPHeaderField: "apns-priority") + request.setValue(topic, forHTTPHeaderField: "apns-topic") + request.setValue("alert", forHTTPHeaderField: "apns-push-type") + if let expiration { + request.setValue(expiration, forHTTPHeaderField: "apns-expiration") + } + if let collapseID { + request.setValue(collapseID, forHTTPHeaderField: "apns-collapse-id") + } + request.httpBody = body + + URLSession.shared.dataTask(with: request) { data, response, error in + if let error { + let errorMessage = "Failed to send push notification: \(error.localizedDescription)" + LogManager.shared.log(category: .apns, message: errorMessage) + completion(false, errorMessage) + return + } + guard let httpResponse = response as? HTTPURLResponse else { + let errorMessage = "Failed to get a valid HTTP response." + LogManager.shared.log(category: .apns, message: errorMessage) + completion(false, errorMessage) + return + } + + var reason = "" + if let data, + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let value = json["reason"] as? String + { + reason = value + } + + if httpResponse.statusCode == 200 { + LogManager.shared.log(category: .apns, message: "APNS notification sent successfully") + completion(true, nil) + return + } + if httpResponse.statusCode == 403 { + JWTManager.shared.invalidateCache() + } + LogManager.shared.log(category: .apns, message: "APNS error \(httpResponse.statusCode): \(reason)") + completion(false, "\(message(forStatus: httpResponse.statusCode)) \(reason)") + }.resume() + } + + static func message(forStatus status: Int) -> String { + switch status { + case 400: "Bad request. The request was invalid or malformed." + case 403: "Authentication error. Check your certificate or authentication token." + case 404: "Invalid request: The :path value was incorrect." + case 405: "Invalid request: Only POST requests are supported." + case 410: "The device token is no longer active for the topic." + case 413: "Payload too large. The notification payload exceeded the size limit." + case 429: "Too many requests." + case 500: "Internal server error at APNs." + case 503: "Service unavailable. The server is temporarily unavailable. Try again later." + default: "Unexpected status code: \(status)." + } + } +} diff --git a/RemoteShared/APNSEnvironment.swift b/RemoteShared/APNSEnvironment.swift new file mode 100644 index 000000000..5f1e03ebb --- /dev/null +++ b/RemoteShared/APNSEnvironment.swift @@ -0,0 +1,16 @@ +// LoopFollow +// APNSEnvironment.swift + +import Foundation + +enum APNSEnvironment { + /// Base URL of the APNs HTTP/2 endpoint. Debug builds honor `LOOPFOLLOW_APNS_BASE_URL` as a local stand-in. + static func baseURL(production: Bool) -> String { + #if DEBUG + if let override = ProcessInfo.processInfo.environment["LOOPFOLLOW_APNS_BASE_URL"], !override.isEmpty { + return override + } + #endif + return production ? "https://api.push.apple.com" : "https://api.sandbox.push.apple.com" + } +} diff --git a/LoopFollow/Helpers/DateExtensions.swift b/RemoteShared/DateExtensions.swift similarity index 100% rename from LoopFollow/Helpers/DateExtensions.swift rename to RemoteShared/DateExtensions.swift diff --git a/RemoteShared/DeviceStatusSummary.swift b/RemoteShared/DeviceStatusSummary.swift new file mode 100644 index 000000000..99216bba3 --- /dev/null +++ b/RemoteShared/DeviceStatusSummary.swift @@ -0,0 +1,92 @@ +// LoopFollow +// DeviceStatusSummary.swift + +import Foundation + +/// The parts of the latest Nightscout devicestatus the watch shows when it +/// fetches on its own. +struct DeviceStatusSummary: Equatable { + var loopAt: Date? + var iob: Double? + var cob: Double? + var recommendedBolus: Double? + var predictions: [WatchPredictionLine] = [] + var cone: [WatchConePoint] = [] + + /// How far ahead predictions are drawn. + static let predictionPoints = 36 + + /// Reads a devicestatus record the way the phone does: `loop` for Loop, `openaps` for Trio. + static func parse(_ status: [String: Any], showsCone: Bool) -> DeviceStatusSummary { + var result = DeviceStatusSummary() + if let loop = status["loop"] as? [String: Any] { + result.loopAt = date(loop["timestamp"]) + result.iob = (loop["iob"] as? [String: Any])?["iob"] as? Double + result.cob = (loop["cob"] as? [String: Any])?["cob"] as? Double + result.recommendedBolus = loop["recommendedBolus"] as? Double + if let predicted = loop["predicted"] as? [String: Any], + let values = predicted["values"] as? [Double], + let start = date(predicted["startDate"]) + { + result.predictions = [WatchPredictionLine(kind: .loop, points: points(values, start: start))] + } + } else if let openaps = status["openaps"] as? [String: Any] { + let suggested = openaps["suggested"] as? [String: Any] ?? openaps["enacted"] as? [String: Any] ?? [:] + result.loopAt = date(suggested["deliverAt"] ?? suggested["timestamp"]) + let iob = openaps["iob"] as? [String: Any] ?? (openaps["iob"] as? [[String: Any]])?.first + result.iob = iob?["iob"] as? Double ?? suggested["IOB"] as? Double + result.cob = suggested["COB"] as? Double + result.recommendedBolus = openaps["recommendedBolus"] as? Double + if let predBGs = suggested["predBGs"] as? [String: [Double]], let start = result.loopAt { + let lines: [(WatchPredictionLine.Kind, String)] = [(.zt, "ZT"), (.iob, "IOB"), (.cob, "COB"), (.uam, "UAM")] + let series = lines.compactMap { kind, key in predBGs[key].map { (kind, points($0, start: start)) } } + // The phone shows Trio's forecast as a cone or as lines; keep what it showed last. + if showsCone { + result.cone = cone(series.map(\.1)) + } else { + result.predictions = series.map { WatchPredictionLine(kind: $0.0, points: $0.1) } + } + } + } + return result + } + + private static func points(_ values: [Double], start: Date) -> [WatchReading] { + values.prefix(predictionPoints + 1).enumerated().map { index, value in + WatchReading(t: start.timeIntervalSince1970 + Double(index) * 300, v: value) + } + } + + /// Same construction as the phone: the shortest series sets the length, min and max per step. + private static func cone(_ series: [[WatchReading]]) -> [WatchConePoint] { + guard let length = series.map(\.count).min(), length > 0 else { return [] } + return (0 ..< length).map { index in + let values = series.map { $0[index].v } + var low = values.min() ?? 0 + var high = values.max() ?? 0 + if low == high { + low -= 1 + high += 1 + } + return WatchConePoint(t: series[0][index].t, low: low, high: high) + } + } + + // MARK: - Dates + + static let iso: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter + }() + + private static let isoNoFraction = ISO8601DateFormatter() + + static func date(_ value: Any?) -> Date? { + if let number = value as? Double { + return Date(timeIntervalSince1970: number > 1e11 ? number / 1000 : number) + } + guard let string = value as? String else { return nil } + return iso.date(from: string) ?? isoNoFraction.date(from: string) + } +} diff --git a/LoopFollow/Helpers/JWTManager.swift b/RemoteShared/JWTManager.swift similarity index 100% rename from LoopFollow/Helpers/JWTManager.swift rename to RemoteShared/JWTManager.swift diff --git a/LoopFollow/Helpers/LogRedactor.swift b/RemoteShared/LogRedactor.swift similarity index 100% rename from LoopFollow/Helpers/LogRedactor.swift rename to RemoteShared/LogRedactor.swift diff --git a/RemoteShared/LoopAPNSCommands.swift b/RemoteShared/LoopAPNSCommands.swift new file mode 100644 index 000000000..e709e0989 --- /dev/null +++ b/RemoteShared/LoopAPNSCommands.swift @@ -0,0 +1,173 @@ +// LoopFollow +// LoopAPNSCommands.swift + +import Foundation + +/// Loop remote command payloads, matching Nightscout's loop.js format. +enum LoopAPNSCommands { + /// Fields every Loop remote command carries. + static func commandHeader(otp: String, alert: String, now: Date) -> [String: Any] { + [ + "otp": otp, + "remote-address": "LoopFollow", + "notes": "Sent via LoopFollow APNS", + "entered-by": "LoopFollow", + "sent-at": formatDateForAPNS(now), + "expiration": formatDateForAPNS(now.addingTimeInterval(5 * 60)), + "alert": alert, + ] + } + + static func carbsCommandPayload(_ payload: LoopAPNSPayload, now: Date = Date()) -> [String: Any] { + let carbsAmount = payload.carbsAmount ?? 0.0 + let absorptionTime = payload.absorptionTime ?? 3.0 + var fields = commandHeader( + otp: payload.otp, + alert: "Remote Carbs Entry: \(String(format: "%.1f", carbsAmount)) grams\nAbsorption Time: \(String(format: "%.1f", absorptionTime)) hours", + now: now + ) + fields["carbs-entry"] = carbsAmount + fields["absorption-time"] = absorptionTime + fields["start-time"] = formatDateForAPNS(payload.consumedDate ?? now) + return fields + } + + static func bolusCommandPayload(_ payload: LoopAPNSPayload, now: Date = Date()) -> [String: Any] { + let bolusAmount = payload.bolusAmount ?? 0.0 + var fields = commandHeader(otp: payload.otp, alert: "Remote Bolus Entry: \(String(format: "%.2f", bolusAmount)) U", now: now) + fields["bolus-entry"] = bolusAmount + return fields + } + + static func carbsDeleteCommandPayload(syncIdentifier: String, otp: String, now: Date = Date()) -> [String: Any] { + var fields = commandHeader(otp: otp, alert: "Remote Carbs Delete", now: now) + fields["carbs-delete"] = syncIdentifier + return fields + } + + /// Keys are prefixed so an unpatched Loop rejects the command. Amount, absorption time and start time are always sent; food type only when set. + static func carbsEditCommandPayload( + syncIdentifier: String, + carbsAmount: Double, + absorptionTimeHours: Double, + foodType: String?, + consumedDate: Date, + otp: String, + now: Date = Date() + ) -> [String: Any] { + var fields = commandHeader( + otp: otp, + alert: "Remote Carbs Edit: \(String(format: "%.1f", carbsAmount)) grams\nAbsorption Time: \(String(format: "%.1f", absorptionTimeHours)) hours", + now: now + ) + fields["carbs-edit"] = syncIdentifier + fields["carbs-edit-entry"] = carbsAmount + fields["carbs-edit-absorption-time"] = absorptionTimeHours + fields["carbs-edit-start-time"] = formatDateForAPNS(consumedDate) + if let foodType, !foodType.isEmpty { + fields["carbs-edit-food-type"] = foodType + } + return fields + } + + /// Keys are prefixed so an unpatched Loop rejects the command. The fingerprint identifies the previewed profile values. + static func profileSwitchCommandPayload(name: String, fingerprint: String, otp: String, now: Date = Date()) -> [String: Any] { + var fields = commandHeader(otp: otp, alert: "Remote Profile Switch: \(name)", now: now) + fields["profile-switch"] = name + fields["profile-switch-fingerprint"] = fingerprint + return fields + } + + /// Creates a properly formatted ISO8601 date string with milliseconds (matching Nightscout's format) + /// - Parameter date: The date to format + /// - Returns: Formatted date string like "2022-12-24T21:34:02.090Z" + /// Override commands need no one-time code, so the return block travels unencrypted. + static func overridePayload(presetName: String, duration: TimeInterval?, returnInfo: ReturnNotificationInfo?, now: Date = Date()) -> [String: Any] { + // Alert text matches Nightscout's format. + var alertText = "\(presetName) Temporary Override" + if let duration, duration > 0 { + let hours = Int(duration / 3600) + let minutes = Int(duration.truncatingRemainder(dividingBy: 3600) / 60) + alertText += hours > 0 ? " (\(hours)h \(minutes)m)" : " (\(minutes)m)" + } + var payload = overrideHeader(alert: alertText, now: now, returnInfo: returnInfo) + payload["override-name"] = presetName + if let duration { + payload["override-duration-minutes"] = Int(duration / 60) + } + return payload + } + + static func cancelOverridePayload(returnInfo: ReturnNotificationInfo?, now: Date = Date()) -> [String: Any] { + var payload = overrideHeader(alert: "Cancel Temporary Override", now: now, returnInfo: returnInfo) + payload["cancel-temporary-override"] = "true" + return payload + } + + private static func overrideHeader(alert: String, now: Date, returnInfo: ReturnNotificationInfo?) -> [String: Any] { + var payload: [String: Any] = [ + "remote-address": "LoopFollow", + "entered-by": "LoopFollow", + "sent-at": formatDateForAPNS(now), + "expiration": formatDateForAPNS(now.addingTimeInterval(5 * 60)), + "alert": alert, + ] + if let returnInfo { + payload["return_notification"] = [ + "production_environment": returnInfo.productionEnvironment, + "device_token": returnInfo.deviceToken, + "bundle_id": returnInfo.bundleId, + "team_id": returnInfo.teamId, + "key_id": returnInfo.keyId, + "apns_key": returnInfo.apnsKey, + ] + } + return payload + } + + /// Wraps command fields in the push payload Loop expects (matching @parse/node-apn). + static func apnsBody(_ payload: [String: Any]) throws -> Data { + var body: [String: Any] = [ + "aps": [ + "alert": payload["alert"] as? String ?? "", + "content-available": 1, + "interruption-level": "time-sensitive", + ], + ] + for (key, value) in payload where key != "alert" && key != "content-available" && key != "interruption-level" { + body[key] = value + } + return try JSONSerialization.data(withJSONObject: body) + } + + static func formatDateForAPNS(_ date: Date) -> String { + let dateFormatter = ISO8601DateFormatter() + dateFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return dateFormatter.string(from: date) + } +} + +struct LoopAPNSPayload { + enum PayloadType { + case carbs + case bolus + } + + let type: PayloadType + let carbsAmount: Double? + let absorptionTime: Double? + let foodType: String? + let bolusAmount: Double? + let consumedDate: Date? + let otp: String + + init(type: PayloadType, carbsAmount: Double? = nil, absorptionTime: Double? = nil, foodType: String? = nil, bolusAmount: Double? = nil, consumedDate: Date? = nil, otp: String) { + self.type = type + self.carbsAmount = carbsAmount + self.absorptionTime = absorptionTime + self.foodType = foodType + self.bolusAmount = bolusAmount + self.consumedDate = consumedDate + self.otp = otp + } +} diff --git a/LoopFollow/Remote/LoopAPNS/OTPSecureMessenger.swift b/RemoteShared/OTPSecureMessenger.swift similarity index 97% rename from LoopFollow/Remote/LoopAPNS/OTPSecureMessenger.swift rename to RemoteShared/OTPSecureMessenger.swift index 02762338b..4e201a965 100644 --- a/LoopFollow/Remote/LoopAPNS/OTPSecureMessenger.swift +++ b/RemoteShared/OTPSecureMessenger.swift @@ -38,7 +38,7 @@ struct OTPSecureMessenger { } /// Information needed to send a response notification back via APNS -struct ReturnNotificationInfo: Codable { +struct ReturnNotificationInfo: Codable, Equatable { let productionEnvironment: Bool let deviceToken: String let bundleId: String diff --git a/LoopFollow/Remote/TRC/PushMessage.swift b/RemoteShared/PushMessage.swift similarity index 75% rename from LoopFollow/Remote/TRC/PushMessage.swift rename to RemoteShared/PushMessage.swift index 09ea4e817..1d6267ae6 100644 --- a/LoopFollow/Remote/TRC/PushMessage.swift +++ b/RemoteShared/PushMessage.swift @@ -43,24 +43,15 @@ struct CommandPayload: Encodable { var fat: Int? var overrideName: String? var scheduledTime: TimeInterval? + var commandID: String? + var mealID: String? var returnNotification: ReturnNotificationInfo? - struct ReturnNotificationInfo: Encodable { - let productionEnvironment: Bool - let deviceToken: String - let bundleId: String - let teamId: String - let keyId: String - let apnsKey: String - - enum CodingKeys: String, CodingKey { - case productionEnvironment = "production_environment" - case deviceToken = "device_token" - case bundleId = "bundle_id" - case teamId = "team_id" - case keyId = "key_id" - case apnsKey = "apns_key" + var apnsCollapseID: String { + if commandType.usesCommandIDAsCollapseID, let commandID { + return commandID } + return commandType.rawValue } enum CodingKeys: String, CodingKey { @@ -75,6 +66,8 @@ struct CommandPayload: Encodable { case fat case overrideName case scheduledTime = "scheduled_time" + case commandID = "command_id" + case mealID = "meal_id" case returnNotification = "return_notification" } } diff --git a/LoopFollow/Remote/TRC/SecureMessenger.swift b/RemoteShared/SecureMessenger.swift similarity index 100% rename from LoopFollow/Remote/TRC/SecureMessenger.swift rename to RemoteShared/SecureMessenger.swift diff --git a/LoopFollow/Extensions/ShareClientExtension.swift b/RemoteShared/ShareClientExtension.swift similarity index 96% rename from LoopFollow/Extensions/ShareClientExtension.swift rename to RemoteShared/ShareClientExtension.swift index 6201e9100..c8b492b78 100644 --- a/LoopFollow/Extensions/ShareClientExtension.swift +++ b/RemoteShared/ShareClientExtension.swift @@ -2,7 +2,9 @@ // ShareClientExtension.swift import Foundation -import ShareClient +#if canImport(ShareClient) + import ShareClient +#endif public struct ShareGlucoseData: Decodable { var sgv: Int @@ -56,7 +58,6 @@ private var TrendTable: [String] = [ "RATE OUT OF RANGE", // 9 ] -// TODO: probably better to make this an inherited class rather than an extension public extension ShareClient { func fetchData(_ entries: Int, callback: @escaping (ShareError?, [ShareGlucoseData]?) -> Void) { fetchLast(entries) { error, result in diff --git a/LoopFollow/Helpers/TOTPGenerator.swift b/RemoteShared/TOTPGenerator.swift similarity index 100% rename from LoopFollow/Helpers/TOTPGenerator.swift rename to RemoteShared/TOTPGenerator.swift diff --git a/LoopFollow/Remote/TRC/TRCCommandType.swift b/RemoteShared/TRCCommandType.swift similarity index 69% rename from LoopFollow/Remote/TRC/TRCCommandType.swift rename to RemoteShared/TRCCommandType.swift index 520f8a43a..b6a6bfe0d 100644 --- a/LoopFollow/Remote/TRC/TRCCommandType.swift +++ b/RemoteShared/TRCCommandType.swift @@ -10,6 +10,13 @@ enum TRCCommandType: String, Encodable { case meal case startOverride = "start_override" case cancelOverride = "cancel_override" + case editMeal = "edit_meal" + case deleteMeal = "delete_meal" + + /// One collapse id per command. + var usesCommandIDAsCollapseID: Bool { + self == .editMeal || self == .deleteMeal + } var displayName: String { switch self { @@ -19,6 +26,8 @@ enum TRCCommandType: String, Encodable { case .meal: return "Meal" case .startOverride: return "Start Override" case .cancelOverride: return "Cancel Override" + case .editMeal: return "Edit Meal" + case .deleteMeal: return "Delete Meal" } } } diff --git a/RemoteShared/WatchCommandValidator.swift b/RemoteShared/WatchCommandValidator.swift new file mode 100644 index 000000000..15769a0b2 --- /dev/null +++ b/RemoteShared/WatchCommandValidator.swift @@ -0,0 +1,134 @@ +// LoopFollow +// WatchCommandValidator.swift + +import Foundation + +/// The guardrails for a watch command, applied by the phone before it sends +/// one and by the watch before it sends one directly. Same limits and messages +/// as the phone's Remote screens. +enum WatchCommandValidator { + struct Limits: Equatable { + var maxBolus: Double + var bolusIncrement: Double + var maxCarbs: Double + var maxFat: Double + var maxProtein: Double + var mealWithBolus: Bool + var mealWithFatProtein: Bool + } + + /// Loop rejects carb entries outside this window; same limits as LoopAPNSCarbsView. + static let loopMaxPastHours: Double = 12 + static let loopMaxFutureHours: Double = 1 + /// Loop's default absorption time for a remote carb entry, in hours. + static let loopAbsorptionHours = 3.0 + + /// Returns the action to send (Trio boluses rounded down to the increment) or the reason it is refused. + static func validate( + _ action: WatchCommand.Action, + kind: WatchRemoteConfig.Kind, + limits: Limits, + now: Date = Date() + ) -> Result { + switch (kind, action) { + case let (.loop, .bolus(units)): + guard units > 0 else { return .failure(ValidationError("Enter an amount above zero.")) } + guard units <= limits.maxBolus else { return .failure(ValidationError(exceedsMaxBolus(units, limits))) } + return .success(action) + + case let (.trio, .bolus(units)): + let stepped = roundedToStep(units, step: limits.bolusIncrement) + guard stepped > 0 else { return .failure(ValidationError("Enter an amount above zero.")) } + guard stepped <= limits.maxBolus else { return .failure(ValidationError(exceedsMaxBolus(stepped, limits))) } + return .success(.bolus(units: stepped)) + + case let (.loop, .meal(carbs, fat, protein, bolus, consumedAt)): + guard fat == 0, protein == 0, bolus == 0 else { return .failure(ValidationError("Loop accepts carbs only from the watch.")) } + guard carbs > 0 else { return .failure(ValidationError("Enter an amount above zero.")) } + guard carbs <= limits.maxCarbs else { return .failure(ValidationError(exceeds("Carbs", carbs, limits.maxCarbs))) } + guard consumedAt >= now.addingTimeInterval(-loopMaxPastHours * 3600) else { + return .failure(ValidationError("Time must be within the prior \(Int(loopMaxPastHours)) hours.")) + } + guard consumedAt <= now.addingTimeInterval(loopMaxFutureHours * 3600) else { + return .failure(ValidationError("Time must be within the next \(Int(loopMaxFutureHours)) hour.")) + } + return .success(action) + + case let (.trio, .meal(carbs, fat, protein, bolus, consumedAt)): + guard limits.mealWithFatProtein || (fat == 0 && protein == 0) else { + return .failure(ValidationError("Meal with Fat/Protein is turned off in LoopFollow's Remote settings.")) + } + guard limits.mealWithBolus || bolus == 0 else { + return .failure(ValidationError("Meal with Bolus is turned off in LoopFollow's Remote settings.")) + } + guard carbs >= 0, fat >= 0, protein >= 0, carbs > 0 || fat > 0 || protein > 0 else { + return .failure(ValidationError("Enter carbs, fat or protein.")) + } + guard carbs <= limits.maxCarbs else { return .failure(ValidationError(exceeds("Carbs", carbs, limits.maxCarbs))) } + guard fat <= limits.maxFat else { return .failure(ValidationError(exceeds("Fat", fat, limits.maxFat))) } + guard protein <= limits.maxProtein else { return .failure(ValidationError(exceeds("Protein", protein, limits.maxProtein))) } + let steppedBolus = roundedToStep(bolus, step: limits.bolusIncrement) + guard steppedBolus <= limits.maxBolus else { return .failure(ValidationError(exceedsMaxBolus(steppedBolus, limits))) } + return .success(.meal(carbs: carbs, fat: fat, protein: protein, bolus: steppedBolus, consumedAt: consumedAt)) + + case (.loop, .startTempTarget), (.loop, .cancelTempTarget): + return .failure(ValidationError("Temp targets are not available for Loop.")) + + default: + return .success(action) + } + } + + /// Rounds down to the bolus increment, as the phone's Trio bolus screen does. + static func roundedToStep(_ value: Double, step: Double) -> Double { + let step = max(0.001, step) + let stepped = ((value / step) + 1e-10).rounded(.down) * step + return (stepped * 1000).rounded() / 1000 + } + + private static func exceedsMaxBolus(_ units: Double, _ limits: Limits) -> String { + "\(format(units)) U exceeds the maximum bolus of \(format(limits.maxBolus)) U set in LoopFollow." + } + + private static func exceeds(_ what: String, _ grams: Double, _ max: Double) -> String { + "\(what) (\(Int(grams)) g) exceeds the maximum of \(Int(max)) g set in LoopFollow." + } + + private static func format(_ units: Double) -> String { + let nf = NumberFormatter() + nf.numberStyle = .decimal + nf.minimumFractionDigits = 0 + nf.maximumFractionDigits = 3 + return nf.string(from: NSNumber(value: units)) ?? "\(units)" + } + + struct ValidationError: Error, Equatable { + let message: String + + init(_ message: String) { + self.message = message + } + } +} + +extension WatchCommand { + /// Trio treats a missing time as now; within a minute of now counts as now, as in the phone's meal screen. + static func trioScheduledTime(_ consumedAt: Date, now: Date = Date()) -> Date? { + abs(consumedAt.timeIntervalSince(now)) < 60 ? nil : consumedAt.dateUsingCurrentSeconds() + } +} + +extension WatchRemoteConfig { + /// The phone's guardrails as last sent to the watch. + var limits: WatchCommandValidator.Limits { + WatchCommandValidator.Limits( + maxBolus: maxBolus, + bolusIncrement: bolusIncrement, + maxCarbs: maxCarbs, + maxFat: maxFat, + maxProtein: maxProtein, + mealWithBolus: mealWithBolus, + mealWithFatProtein: mealWithFatProtein + ) + } +} diff --git a/RemoteShared/WatchCredentials.swift b/RemoteShared/WatchCredentials.swift new file mode 100644 index 000000000..4d3a30d9d --- /dev/null +++ b/RemoteShared/WatchCredentials.swift @@ -0,0 +1,64 @@ +// LoopFollow +// WatchCredentials.swift + +import Foundation + +/// What the watch needs to keep working without the phone: read access to the +/// data source and, when commands from the watch are allowed, what the phone +/// itself uses to send them. Sent with `transferUserInfo` only when it changes, +/// and kept in the watch's Keychain. +struct WatchCredentials: Codable, Equatable { + static let userInfoKey = "watchCredentials" + /// Watch → phone: asks the phone to send its credentials again. + static let requestKey = "requestCredentials" + + var nightscoutURL: String + var nightscoutToken: String + var dexcomUsername: String + var dexcomPassword: String + /// "US" or anything else for outside the US, as in the phone's Dexcom settings. + var dexcomServer: String + /// Present only when remote commands from the watch are allowed. + var remote: Remote? + + struct Remote: Codable, Equatable { + var kind: WatchRemoteConfig.Kind + /// Push target: the Loop or Trio app. + var deviceToken: String + var bundleID: String + var production: Bool + /// The key the phone signs commands with (LoopFollow's own key when both apps share a team). + var apnsKey: String + var keyID: String + var teamID: String + /// Trio Remote Control. + var user: String + var sharedSecret: String + /// Loop: the one-time-password QR code URL. + var loopQRCodeURL: String + /// Lets Loop or Trio confirm a command to the LoopFollow app on the phone. + var returnNotification: ReturnNotificationInfo? + } + + var hasDataSource: Bool { + !nightscoutURL.isEmpty || (!dexcomUsername.isEmpty && !dexcomPassword.isEmpty) + } +} + +/// Picks the key that signs commands to the Loop or Trio app: LoopFollow's own +/// key when both apps belong to the same team or no remote team is set, +/// otherwise the remote app's key. +enum RemoteSigningKey { + static func resolve( + lfTeamID: String, + remoteTeamID: String, + lfKey: (key: String, keyID: String), + remoteKey: (key: String, keyID: String) + ) -> (key: String, keyID: String, teamID: String) { + let sameTeam = !lfTeamID.isEmpty && !remoteTeamID.isEmpty && lfTeamID == remoteTeamID + if sameTeam || remoteTeamID.isEmpty { + return (lfKey.key, lfKey.keyID, lfTeamID) + } + return (remoteKey.key, remoteKey.keyID, remoteTeamID) + } +} diff --git a/Shared/AppGroupID.swift b/Shared/AppGroupID.swift index 5eb1187b8..507bfb7c2 100644 --- a/Shared/AppGroupID.swift +++ b/Shared/AppGroupID.swift @@ -58,6 +58,8 @@ enum AppGroupID { ".WidgetExtension", ".Widgets", ".WidgetsExtension", + ".watchkitapp.widgets", + ".watchkitapp", ".Watch", ".WatchExtension", ".CarPlay", diff --git a/Shared/GlucoseSnapshot.swift b/Shared/GlucoseSnapshot.swift index 6800ccd3b..a8c80038e 100644 --- a/Shared/GlucoseSnapshot.swift +++ b/Shared/GlucoseSnapshot.swift @@ -128,6 +128,10 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable { /// True when LoopFollow detects the loop has not reported in 15+ minutes (Nightscout only). let isNotLooping: Bool + /// CGM state reported after the newest reading, e.g. "Temporary sensor + /// issue at 22:33" (nil while readings flow). + let cgmSensorState: String? + // MARK: - Renewal /// True when the Live Activity is within renewalWarning seconds of its renewal deadline. @@ -168,6 +172,7 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable { maxBgMgdl: Double? = nil, unit: Unit, isNotLooping: Bool, + cgmSensorState: String? = nil, showRenewalOverlay: Bool = false, ) { self.glucose = glucose @@ -201,6 +206,7 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable { self.maxBgMgdl = maxBgMgdl self.unit = unit self.isNotLooping = isNotLooping + self.cgmSensorState = cgmSensorState self.showRenewalOverlay = showRenewalOverlay } @@ -247,6 +253,7 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable { maxBgMgdl: maxBgMgdl, unit: unit, isNotLooping: isNotLooping, + cgmSensorState: cgmSensorState, showRenewalOverlay: value, ) } @@ -286,6 +293,7 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable { try container.encodeIfPresent(maxBgMgdl, forKey: .maxBgMgdl) try container.encode(unit, forKey: .unit) try container.encode(isNotLooping, forKey: .isNotLooping) + try container.encodeIfPresent(cgmSensorState, forKey: .cgmSensorState) try container.encode(showRenewalOverlay, forKey: .showRenewalOverlay) } @@ -322,6 +330,7 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable { maxBgMgdl = try container.decodeIfPresent(Double.self, forKey: .maxBgMgdl) unit = try container.decode(Unit.self, forKey: .unit) isNotLooping = try container.decodeIfPresent(Bool.self, forKey: .isNotLooping) ?? false + cgmSensorState = try container.decodeIfPresent(String.self, forKey: .cgmSensorState) showRenewalOverlay = try container.decodeIfPresent(Bool.self, forKey: .showRenewalOverlay) ?? false } @@ -332,7 +341,7 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable { case recBolus, battery, pumpBattery, basalRate, pumpReservoirU case autosens, tdd, targetLowMgdl, targetHighMgdl, isfMgdlPerU, carbRatio, carbsToday case profileName, sageInsertTime, cageInsertTime, iageInsertTime, minBgMgdl, maxBgMgdl - case unit, isNotLooping, showRenewalOverlay + case unit, isNotLooping, cgmSensorState, showRenewalOverlay } } @@ -354,5 +363,27 @@ extension GlucoseSnapshot { let raw = try container.decode(String.self) self = Trend(rawValue: raw) ?? .unknown } + + /// Maps a Nightscout or Dexcom direction string, e.g. "FortyFiveUp". + init(code: String?) { + let raw = code?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() ?? "" + if raw.contains("doubleup") || raw.contains("rapidrise") || raw == "up2" || raw == "upfast" { + self = .upFast + } else if raw.contains("fortyfiveup") { + self = .upSlight + } else if raw.contains("singleup") || raw == "up" || raw == "up1" || raw == "rising" { + self = .up + } else if raw.contains("flat") || raw == "steady" || raw == "none" { + self = .flat + } else if raw.contains("doubledown") || raw.contains("rapidfall") || raw == "down2" || raw == "downfast" { + self = .downFast + } else if raw.contains("fortyfivedown") { + self = .downSlight + } else if raw.contains("singledown") || raw == "down" || raw == "down1" || raw == "falling" { + self = .down + } else { + self = .unknown + } + } } } diff --git a/Shared/GlucoseThresholds.swift b/Shared/GlucoseThresholds.swift new file mode 100644 index 000000000..d4e5d6bc4 --- /dev/null +++ b/Shared/GlucoseThresholds.swift @@ -0,0 +1,21 @@ +// LoopFollow +// GlucoseThresholds.swift + +import Foundation + +/// Fixed glucose cut-offs (mg/dL) shared by the stats, range modes and Live Activity. +/// Range modes read their low/high from here via `TimeInRangeDisplayMode.fixedThresholds`, +/// so a new mode's cut-offs go here first. +enum GlucoseThresholds { + static let veryLow = 54.0 + static let veryHigh = 250.0 + /// Time in Range, also used by GRI regardless of the selected range mode. + static let tirLow = 70.0 + static let tirHigh = 180.0 + /// Time in Tight Range. + static let titrLow = 70.0 + static let titrHigh = 140.0 + /// Extended Tight Range (TING, Time in Normoglycemia). + static let tingLow = 63.0 + static let tingHigh = 140.0 +} diff --git a/Tests/AlarmConditions/Helpers.swift b/Tests/AlarmConditions/Helpers.swift index b4287504e..55a50aac6 100644 --- a/Tests/AlarmConditions/Helpers.swift +++ b/Tests/AlarmConditions/Helpers.swift @@ -29,6 +29,15 @@ extension Alarm { return alarm } + static func missedBolus(delay: Int = 15, prebolus: Int = 15, minBolus: Double = 0.1, minCarbs: Double = 4) -> Self { + var alarm = Alarm(type: .missedBolus) + alarm.monitoringWindow = delay + alarm.predictiveMinutes = prebolus + alarm.delta = minBolus + alarm.threshold = minCarbs + return alarm + } + static func low(belowBG: Double?, predictiveMinutes: Int? = nil, persistentMinutes: Int? = nil) -> Self { var alarm = Alarm(type: .low) alarm.belowBG = belowBG @@ -43,6 +52,18 @@ extension Alarm { alarm.monitoringWindow = window return alarm } + + static func tempTargetEnd(warnBefore: Int? = nil) -> Self { + var alarm = Alarm(type: .tempTargetEnd) + alarm.predictiveMinutes = warnBefore + return alarm + } + + static func overrideEnd(warnBefore: Int? = nil) -> Self { + var alarm = Alarm(type: .overrideEnd) + alarm.predictiveMinutes = warnBefore + return alarm + } } // MARK: - AlarmData helpers @@ -160,7 +181,42 @@ extension AlarmData { ) } - static func withCarbs(_ carbs: [CarbSample]) -> Self { + static func withEnds( + latestTempTargetStart: TimeInterval? = nil, + latestTempTargetEnd: TimeInterval? = nil, + activeTempTargetEnd: TimeInterval? = nil, + latestOverrideStart: TimeInterval? = nil, + latestOverrideEnd: TimeInterval? = nil, + activeOverrideEnd: TimeInterval? = nil + ) -> Self { + AlarmData( + bgReadings: [], + predictionData: [], + expireDate: nil, + lastLoopTime: nil, + latestOverrideStart: latestOverrideStart, + latestOverrideEnd: latestOverrideEnd, + latestTempTargetStart: latestTempTargetStart, + latestTempTargetEnd: latestTempTargetEnd, + activeOverrideEnd: activeOverrideEnd, + activeTempTargetEnd: activeTempTargetEnd, + recBolus: nil, + COB: nil, + sageInsertTime: nil, + pumpInsertTime: nil, + latestPumpVolume: nil, + IOB: nil, + recentBoluses: [], + latestBattery: nil, + latestBatteryIsCharging: nil, + latestPumpBattery: nil, + batteryHistory: [], + recentCarbs: [], + dbSizePercentage: nil + ) + } + + static func withCarbs(_ carbs: [CarbSample], boluses: [BolusEntry] = []) -> Self { AlarmData( bgReadings: [], predictionData: [], @@ -176,7 +232,7 @@ extension AlarmData { pumpInsertTime: nil, latestPumpVolume: nil, IOB: nil, - recentBoluses: [], + recentBoluses: boluses, latestBattery: nil, latestBatteryIsCharging: nil, latestPumpBattery: nil, diff --git a/Tests/AlarmConditions/LowBGConditionTests.swift b/Tests/AlarmConditions/LowBGConditionTests.swift index 287799d82..c7dad6bec 100644 --- a/Tests/AlarmConditions/LowBGConditionTests.swift +++ b/Tests/AlarmConditions/LowBGConditionTests.swift @@ -34,7 +34,7 @@ struct LowBGConditionTests { @Test("#loop — predictive low within window fires") func loopPredictiveLowFires() { let alarm = Alarm.low(belowBG: 80, predictiveMinutes: 30, persistentMinutes: 15) - // ceil(30/5) = 6 points looked at; index 5 dips to 75 + // 30 minutes ahead is index 6; the dip to 75 sits at index 5 let data = AlarmData.withGlucose(readings: recentHigh, prediction: pred([120, 110, 100, 90, 85, 75])) #expect(cond.evaluate(alarm: alarm, data: data, now: Date())) @@ -43,12 +43,48 @@ struct LowBGConditionTests { @Test("#loop — forecast low beyond window does not fire") func loopPredictiveLowBeyondWindow() { let alarm = Alarm.low(belowBG: 80, predictiveMinutes: 15, persistentMinutes: 15) - // ceil(15/5) = 3 points looked at (120, 110, 100); the low only appears later + // 15 minutes ahead is index 3, so 120, 110, 100 and 90; the low only appears later let data = AlarmData.withGlucose(readings: recentHigh, prediction: pred([120, 110, 100, 90, 85, 75])) #expect(!cond.evaluate(alarm: alarm, data: data, now: Date())) } + @Test("#loop — the look-ahead reaches the full requested horizon") + func loopHorizonReachesRequestedMinutes() { + // The dip sits exactly 20 minutes out, at index 4. A 20-minute + // look-ahead must reach it; a 15-minute one must stop short. + let forecast = pred([118, 106, 95, 85, 70, 62]) + let data = AlarmData.withGlucose(readings: recentHigh, prediction: forecast) + + let twenty = Alarm.low(belowBG: 80, predictiveMinutes: 20, persistentMinutes: 15) + let fifteen = Alarm.low(belowBG: 80, predictiveMinutes: 15, persistentMinutes: 15) + + #expect(cond.evaluate(alarm: twenty, data: data, now: Date())) + #expect(!cond.evaluate(alarm: fifteen, data: data, now: Date())) + } + + @Test("#loop — a horizon longer than the forecast uses what is published") + func loopHorizonBeyondSeriesLength() { + // 60 minutes asks for 13 points but only 3 exist. The look-ahead must + // stay in bounds and still see the low at the end of what is published. + let alarm = Alarm.low(belowBG: 80, predictiveMinutes: 60, persistentMinutes: 15) + let data = AlarmData.withGlucose(readings: recentHigh, prediction: pred([120, 100, 75])) + + #expect(cond.evaluate(alarm: alarm, data: data, now: Date())) + } + + @Test("#loop — a single forecast point is the current value only") + func loopSinglePointForecast() { + // Index 0 is the current value: a lone point is examined, so it fires + // exactly when it is at or below the threshold. + let alarm = Alarm.low(belowBG: 80, predictiveMinutes: 30, persistentMinutes: 15) + let high = AlarmData.withGlucose(readings: recentHigh, prediction: pred([120])) + let low = AlarmData.withGlucose(readings: recentHigh, prediction: pred([75])) + + #expect(!cond.evaluate(alarm: alarm, data: high, now: Date())) + #expect(cond.evaluate(alarm: alarm, data: low, now: Date())) + } + @Test("#loop — forecast staying above threshold does not fire") func loopForecastAboveThreshold() { let alarm = Alarm.low(belowBG: 80, predictiveMinutes: 60, persistentMinutes: 15) @@ -99,6 +135,25 @@ struct LowBGConditionTests { #expect(!cond.evaluate(alarm: alarm, data: data, now: Date())) } + @Test("#trio — a forecast running short does not shorten the look-ahead") + func trioShortForecastKeepsHorizon() { + // ZT stops after three points while IOB keeps falling to 69 at index 5. + // Every earlier combined point stays above the threshold, so the alarm + // fires only if the 25-minute look-ahead reaches index 5. + let alarm = Alarm.low(belowBG: 80, predictiveMinutes: 25, persistentMinutes: 15) + let forecasts: [[Double]] = [ + [118, 115, 113], // ZT + [118, 106, 95, 85, 82, 69], // IOB + [118, 116, 114, 113, 112, 111], // COB + [118, 112, 108, 105, 103, 101], // UAM + ] + let combined = MainViewController.lowestForecast(forecasts: forecasts, start: Date().timeIntervalSince1970) + let data = AlarmData.withGlucose(readings: recentHigh, prediction: combined) + + #expect(combined.count == 6) + #expect(cond.evaluate(alarm: alarm, data: data, now: Date())) + } + @Test("#trio — deep forecast below display floor still fires (not masked)") func trioDeepLowNotMasked() { let alarm = Alarm.low(belowBG: 70, predictiveMinutes: 30, persistentMinutes: 15) diff --git a/Tests/AlarmConditions/LowestForecastTests.swift b/Tests/AlarmConditions/LowestForecastTests.swift index 3e821aa7d..acd6662fe 100644 --- a/Tests/AlarmConditions/LowestForecastTests.swift +++ b/Tests/AlarmConditions/LowestForecastTests.swift @@ -69,13 +69,25 @@ struct LowestForecastTests { #expect(result.count == 12) } - @Test("#default cap is 12 points") + @Test("#the default cap reaches 60 minutes ahead") func defaultCap() { let long = Array(repeating: 100.0, count: 30) let result = MainViewController.lowestForecast(forecasts: [long], start: start) #expect(result.count == MainViewController.alarmForecastPointCap) - #expect(result.count == 12) + // The first point is the current value, so the last one is 60 minutes out. + #expect(result.last?.date.timeIntervalSince1970 == start + 3600) + } + + @Test("#a short forecast does not cap the rest") + func shortForecastDoesNotCapTheRest() { + // Which forecast runs shortest varies from cycle to cycle; the series + // follows the longest one. + let short = Array(repeating: 100.0, count: 8) + let long = Array(repeating: 100.0, count: 20) + let result = MainViewController.lowestForecast(forecasts: [short, long], start: start) + + #expect(result.count == MainViewController.alarmForecastPointCap) } // MARK: - Timestamps & rounding diff --git a/Tests/AlarmConditions/MissedBolusConditionTests.swift b/Tests/AlarmConditions/MissedBolusConditionTests.swift new file mode 100644 index 000000000..942b548ea --- /dev/null +++ b/Tests/AlarmConditions/MissedBolusConditionTests.swift @@ -0,0 +1,108 @@ +// LoopFollow +// MissedBolusConditionTests.swift + +import Foundation +@testable import LoopFollow +import Testing + +@Suite(.serialized) +struct MissedBolusConditionTests { + let cond = MissedBolusCondition() + + private func resetNotified() { + Storage.shared.lastMissedBolusNotified.value = nil + } + + private func carb(minutesAgo: Double, grams: Double = 30, relativeTo now: Date) -> CarbSample { + CarbSample(grams: grams, date: now.addingTimeInterval(-minutesAgo * 60)) + } + + private func bolus(minutesAgo: Double, units: Double, relativeTo now: Date) -> BolusEntry { + BolusEntry(units: units, date: now.addingTimeInterval(-minutesAgo * 60)) + } + + @Test("#fires — default alarm, carbs past delay with no bolus") + func firesWithDefaults() { + resetNotified() + let now = Date() + let data = AlarmData.withCarbs([carb(minutesAgo: 20, relativeTo: now)]) + + #expect(cond.evaluate(alarm: .missedBolus(), data: data, now: now)) + } + + @Test("#fires once — same carb entry does not fire twice") + func firesOncePerCarb() { + resetNotified() + let now = Date() + let data = AlarmData.withCarbs([carb(minutesAgo: 20, relativeTo: now)]) + + #expect(cond.evaluate(alarm: .missedBolus(), data: data, now: now)) + #expect(!cond.evaluate(alarm: .missedBolus(), data: data, now: now.addingTimeInterval(300))) + } + + @Test("#within delay — carbs newer than delay do not fire") + func withinDelayNoFire() { + resetNotified() + let now = Date() + let data = AlarmData.withCarbs([carb(minutesAgo: 10, relativeTo: now)]) + + #expect(!cond.evaluate(alarm: .missedBolus(), data: data, now: now)) + } + + @Test("#bolus given — bolus after carbs suppresses") + func bolusAfterCarbsNoFire() { + resetNotified() + let now = Date() + let data = AlarmData.withCarbs( + [carb(minutesAgo: 20, relativeTo: now)], + boluses: [bolus(minutesAgo: 18, units: 2, relativeTo: now)] + ) + + #expect(!cond.evaluate(alarm: .missedBolus(), data: data, now: now)) + } + + @Test("#pre-bolus — bolus inside pre-bolus window suppresses") + func preBolusNoFire() { + resetNotified() + let now = Date() + let data = AlarmData.withCarbs( + [carb(minutesAgo: 20, relativeTo: now)], + boluses: [bolus(minutesAgo: 30, units: 2, relativeTo: now)] + ) + + #expect(!cond.evaluate(alarm: .missedBolus(), data: data, now: now)) + } + + @Test("#small bolus — bolus at or below limit is ignored") + func smallBolusIgnored() { + resetNotified() + let now = Date() + let data = AlarmData.withCarbs( + [carb(minutesAgo: 20, relativeTo: now)], + boluses: [bolus(minutesAgo: 18, units: 0.1, relativeTo: now)] + ) + + #expect(cond.evaluate(alarm: .missedBolus(), data: data, now: now)) + } + + @Test("#small carbs — carbs at or below limit do not fire") + func smallCarbsNoFire() { + resetNotified() + let now = Date() + let data = AlarmData.withCarbs([carb(minutesAgo: 20, grams: 4, relativeTo: now)]) + + #expect(!cond.evaluate(alarm: .missedBolus(), data: data, now: now)) + } + + @Test("#future carbs — a future-dated entry does not mask a past one") + func futureCarbDoesNotMaskPast() { + resetNotified() + let now = Date() + let data = AlarmData.withCarbs([ + carb(minutesAgo: 20, relativeTo: now), + carb(minutesAgo: -30, relativeTo: now), + ]) + + #expect(cond.evaluate(alarm: .missedBolus(), data: data, now: now)) + } +} diff --git a/Tests/AlarmConditions/OverrideEndConditionTests.swift b/Tests/AlarmConditions/OverrideEndConditionTests.swift new file mode 100644 index 000000000..dd8ac1175 --- /dev/null +++ b/Tests/AlarmConditions/OverrideEndConditionTests.swift @@ -0,0 +1,143 @@ +// LoopFollow +// OverrideEndConditionTests.swift + +import Foundation +@testable import LoopFollow +import Testing + +@Suite(.serialized) +struct OverrideEndConditionTests { + let cond = OverrideEndCondition() + + private func reset() { + Storage.shared.lastOverrideEndNotified.value = nil + Storage.shared.lastOverridePreEndNotified.value = nil + } + + // MARK: - End firing + + @Test("end firing fires once, then dedups") + func endFiresOnceThenDedups() { + reset() + let now = Date() + let end = now.timeIntervalSince1970 - 60 + let alarm = Alarm.overrideEnd() + let data = AlarmData.withEnds(latestOverrideEnd: end) + + #expect(cond.evaluate(alarm: alarm, data: data, now: now)) + #expect(!cond.evaluate(alarm: alarm, data: data, now: now)) + } + + @Test("end firing ignores ends older than 15 minutes") + func endIgnoresStaleEnds() { + reset() + let now = Date() + let end = now.timeIntervalSince1970 - 16 * 60 + let alarm = Alarm.overrideEnd() + let data = AlarmData.withEnds(latestOverrideEnd: end) + + #expect(!cond.evaluate(alarm: alarm, data: data, now: now)) + } + + // MARK: - Early warning + + @Test("no early warning without a lead time") + func noEarlyWarningByDefault() { + reset() + let now = Date() + let end = now.timeIntervalSince1970 + 4 * 60 + let data = AlarmData.withEnds(activeOverrideEnd: end) + + #expect(!cond.evaluate(alarm: .overrideEnd(), data: data, now: now)) + #expect(!cond.evaluate(alarm: .overrideEnd(warnBefore: 0), data: data, now: now)) + } + + @Test("no early warning when the end is unknown (indefinite override)") + func noEarlyWarningWithoutScheduledEnd() { + reset() + let now = Date() + let alarm = Alarm.overrideEnd(warnBefore: 10) + let data = AlarmData.withEnds(activeOverrideEnd: nil) + + #expect(!cond.evaluate(alarm: alarm, data: data, now: now)) + } + + @Test("early warning fires inside the lead window, once") + func earlyWarningFiresOnceInsideWindow() { + reset() + let now = Date() + let end = now.timeIntervalSince1970 + 5 * 60 + let alarm = Alarm.overrideEnd(warnBefore: 10) + let data = AlarmData.withEnds(activeOverrideEnd: end) + + #expect(cond.evaluate(alarm: alarm, data: data, now: now)) + #expect(!cond.evaluate(alarm: alarm, data: data, now: now)) + } + + @Test("early warning does NOT fire before the lead window") + func earlyWarningWaitsForWindow() { + reset() + let now = Date() + let end = now.timeIntervalSince1970 + 6 * 60 + let alarm = Alarm.overrideEnd(warnBefore: 5) + let data = AlarmData.withEnds(activeOverrideEnd: end) + + #expect(!cond.evaluate(alarm: alarm, data: data, now: now)) + } + + @Test("early warning and end both fire for one event, with the right titles") + func bothPhasesFireWithTitles() { + reset() + let now = Date() + let end = now.timeIntervalSince1970 + 4 * 60 + let alarm = Alarm.overrideEnd(warnBefore: 5) + + let preData = AlarmData.withEnds(activeOverrideEnd: end) + #expect(cond.evaluate(alarm: alarm, data: preData, now: now)) + #expect(cond.firedTitle == "Override Ending Soon") + + let endNow = Date(timeIntervalSince1970: end + 60) + let endData = AlarmData.withEnds(latestOverrideEnd: end) + #expect(cond.evaluate(alarm: alarm, data: endData, now: endNow)) + #expect(cond.firedTitle == nil) + } + + @Test("extending the override re-arms the early warning for the new end") + func extensionRearmsEarlyWarning() { + reset() + let now = Date() + let firstEnd = now.timeIntervalSince1970 + 4 * 60 + let alarm = Alarm.overrideEnd(warnBefore: 5) + + #expect(cond.evaluate(alarm: alarm, data: .withEnds(activeOverrideEnd: firstEnd), now: now)) + + let extendedEnd = firstEnd + 30 * 60 + let laterNow = Date(timeIntervalSince1970: extendedEnd - 4 * 60) + #expect(cond.evaluate(alarm: alarm, data: .withEnds(activeOverrideEnd: extendedEnd), now: laterNow)) + } + + @Test("a replacement whose end is earlier than a warned end still warns") + func replacementWithEarlierEndWarns() { + reset() + let now = Date() + let alarm = Alarm.overrideEnd(warnBefore: 30) + let firstEnd = now.timeIntervalSince1970 + 25 * 60 + #expect(cond.evaluate(alarm: alarm, data: .withEnds(activeOverrideEnd: firstEnd), now: now)) + + let replacementEnd = now.timeIntervalSince1970 + 10 * 60 + #expect(cond.evaluate(alarm: alarm, data: .withEnds(activeOverrideEnd: replacementEnd), now: now)) + #expect(cond.firedTitle == "Override Ending Soon") + } + + @Test("no early warning when the lead time covers the whole event") + func noEarlyWarningForEventShorterThanLead() { + reset() + let now = Date() + let alarm = Alarm.overrideEnd(warnBefore: 30) + let start = now.timeIntervalSince1970 - 60 + let end = now.timeIntervalSince1970 + 20 * 60 + let data = AlarmData.withEnds(latestOverrideStart: start, activeOverrideEnd: end) + + #expect(!cond.evaluate(alarm: alarm, data: data, now: now)) + } +} diff --git a/Tests/AlarmConditions/TempTargetEndConditionTests.swift b/Tests/AlarmConditions/TempTargetEndConditionTests.swift new file mode 100644 index 000000000..9cc83f223 --- /dev/null +++ b/Tests/AlarmConditions/TempTargetEndConditionTests.swift @@ -0,0 +1,151 @@ +// LoopFollow +// TempTargetEndConditionTests.swift + +import Foundation +@testable import LoopFollow +import Testing + +@Suite(.serialized) +struct TempTargetEndConditionTests { + let cond = TempTargetEndCondition() + + private func reset() { + Storage.shared.lastTempTargetEndNotified.value = nil + Storage.shared.lastTempTargetPreEndNotified.value = nil + } + + // MARK: - End firing + + @Test("end firing fires once, then dedups") + func endFiresOnceThenDedups() { + reset() + let now = Date() + let end = now.timeIntervalSince1970 - 60 + let alarm = Alarm.tempTargetEnd() + let data = AlarmData.withEnds(latestTempTargetEnd: end) + + #expect(cond.evaluate(alarm: alarm, data: data, now: now)) + #expect(!cond.evaluate(alarm: alarm, data: data, now: now)) + } + + @Test("end firing ignores ends older than 15 minutes") + func endIgnoresStaleEnds() { + reset() + let now = Date() + let end = now.timeIntervalSince1970 - 16 * 60 + let alarm = Alarm.tempTargetEnd() + let data = AlarmData.withEnds(latestTempTargetEnd: end) + + #expect(!cond.evaluate(alarm: alarm, data: data, now: now)) + } + + // MARK: - Early warning + + @Test("no early warning without a lead time") + func noEarlyWarningByDefault() { + reset() + let now = Date() + let end = now.timeIntervalSince1970 + 4 * 60 + let data = AlarmData.withEnds(activeTempTargetEnd: end) + + #expect(!cond.evaluate(alarm: .tempTargetEnd(), data: data, now: now)) + #expect(!cond.evaluate(alarm: .tempTargetEnd(warnBefore: 0), data: data, now: now)) + } + + @Test("early warning fires inside the lead window, once") + func earlyWarningFiresOnceInsideWindow() { + reset() + let now = Date() + let end = now.timeIntervalSince1970 + 5 * 60 + let alarm = Alarm.tempTargetEnd(warnBefore: 10) + let data = AlarmData.withEnds(activeTempTargetEnd: end) + + #expect(cond.evaluate(alarm: alarm, data: data, now: now)) + #expect(!cond.evaluate(alarm: alarm, data: data, now: now)) + } + + @Test("early warning does NOT fire before the lead window") + func earlyWarningWaitsForWindow() { + reset() + let now = Date() + let end = now.timeIntervalSince1970 + 6 * 60 + let alarm = Alarm.tempTargetEnd(warnBefore: 5) + let data = AlarmData.withEnds(activeTempTargetEnd: end) + + #expect(!cond.evaluate(alarm: alarm, data: data, now: now)) + } + + @Test("early warning and end both fire for one event, with the right titles") + func bothPhasesFireWithTitles() { + reset() + let now = Date() + let end = now.timeIntervalSince1970 + 4 * 60 + let alarm = Alarm.tempTargetEnd(warnBefore: 5) + + let preData = AlarmData.withEnds(activeTempTargetEnd: end) + #expect(cond.evaluate(alarm: alarm, data: preData, now: now)) + #expect(cond.firedTitle == "Temp Target Ending Soon") + + let endNow = Date(timeIntervalSince1970: end + 60) + let endData = AlarmData.withEnds(latestTempTargetEnd: end) + #expect(cond.evaluate(alarm: alarm, data: endData, now: endNow)) + #expect(cond.firedTitle == nil) + } + + @Test("extending the temp target re-arms the early warning for the new end") + func extensionRearmsEarlyWarning() { + reset() + let now = Date() + let firstEnd = now.timeIntervalSince1970 + 4 * 60 + let alarm = Alarm.tempTargetEnd(warnBefore: 5) + + #expect(cond.evaluate(alarm: alarm, data: .withEnds(activeTempTargetEnd: firstEnd), now: now)) + + let extendedEnd = firstEnd + 30 * 60 + let laterNow = Date(timeIntervalSince1970: extendedEnd - 4 * 60) + #expect(cond.evaluate(alarm: alarm, data: .withEnds(activeTempTargetEnd: extendedEnd), now: laterNow)) + } + + @Test("when both phases are due, the end fires first and the warning follows") + func endPhaseWinsSameTick() { + reset() + let now = Date() + let previousEnd = now.timeIntervalSince1970 - 60 + let activeEnd = now.timeIntervalSince1970 + 2 * 60 + let alarm = Alarm.tempTargetEnd(warnBefore: 5) + let data = AlarmData.withEnds(latestTempTargetEnd: previousEnd, activeTempTargetEnd: activeEnd) + + #expect(cond.evaluate(alarm: alarm, data: data, now: now)) + #expect(cond.firedTitle == nil) + + #expect(cond.evaluate(alarm: alarm, data: data, now: now)) + #expect(cond.firedTitle == "Temp Target Ending Soon") + + #expect(!cond.evaluate(alarm: alarm, data: data, now: now)) + } + + @Test("a replacement whose end is earlier than a warned end still warns") + func replacementWithEarlierEndWarns() { + reset() + let now = Date() + let alarm = Alarm.tempTargetEnd(warnBefore: 30) + let firstEnd = now.timeIntervalSince1970 + 25 * 60 + #expect(cond.evaluate(alarm: alarm, data: .withEnds(activeTempTargetEnd: firstEnd), now: now)) + + let replacementEnd = now.timeIntervalSince1970 + 10 * 60 + #expect(cond.evaluate(alarm: alarm, data: .withEnds(activeTempTargetEnd: replacementEnd), now: now)) + #expect(cond.firedTitle == "Temp Target Ending Soon") + } + + @Test("no early warning when the lead time covers the whole event") + func noEarlyWarningForEventShorterThanLead() { + reset() + let now = Date() + let alarm = Alarm.tempTargetEnd(warnBefore: 30) + let start = now.timeIntervalSince1970 - 60 + let end = now.timeIntervalSince1970 + 20 * 60 + let data = AlarmData.withEnds(latestTempTargetStart: start, activeTempTargetEnd: end) + + #expect(!cond.evaluate(alarm: alarm, data: data, now: now)) + } +} diff --git a/Tests/CGMSensorStateTests.swift b/Tests/CGMSensorStateTests.swift new file mode 100644 index 000000000..cc4cf59ed --- /dev/null +++ b/Tests/CGMSensorStateTests.swift @@ -0,0 +1,106 @@ +// LoopFollow +// CGMSensorStateTests.swift + +import Foundation +@testable import LoopFollow +import Testing + +struct CGMSensorStateTests { + private let t0 = Date(timeIntervalSince1970: 1_790_000_000) + + private func state(_ name: String, minutes: Double) -> CGMSensorState { + CGMSensorState(name: name, date: t0.addingTimeInterval(minutes * 60)) + } + + private func reading(_ sgv: Int, minutes: Double) -> ShareGlucoseData { + ShareGlucoseData(sgv: sgv, date: t0.addingTimeInterval(minutes * 60).timeIntervalSince1970, direction: nil) + } + + // MARK: - Parsing + + @Test("Sensor-state notes parse to the kit's state name") + func parsesSensorStateNotes() { + #expect(CGMSensorState(note: "CGM: temporarySensorIssue", date: t0)?.name == "temporarySensorIssue") + #expect(CGMSensorState(note: "CGM: warmup", date: t0)?.name == "warmup") + #expect(CGMSensorState(note: "CGM: .unknown(23)", date: t0)?.name == ".unknown(23)") + } + + @Test("Other notes are not sensor states") + func ignoresOtherNotes() { + #expect(CGMSensorState(note: "Pizza", date: t0) == nil) + #expect(CGMSensorState(note: "CGM: ", date: t0) == nil) + #expect(CGMSensorState(note: "CGM: sensor was wet after swim", date: t0) == nil) + #expect(CGMSensorState(note: "cgm: warmup", date: t0) == nil) + } + + // MARK: - Display names + + @Test("Kit names read as plain words") + func displayNames() { + #expect(CGMSensorState.displayName(for: "temporarySensorIssue") == "Temporary sensor issue") + #expect(CGMSensorState.displayName(for: "warmup") == "Warmup") + #expect(CGMSensorState.displayName(for: "sensorFailedDuetoCountsAberration") == "Sensor failed due to counts aberration") + #expect(CGMSensorState.displayName(for: "firstOfTwoBGsNeeded") == "First of two BGs needed") + #expect(CGMSensorState.displayName(for: "calibrationError8") == "Calibration error 8") + #expect(CGMSensorState.displayName(for: "sensorFailure11") == "Sensor failure 11") + #expect(CGMSensorState.displayName(for: "needCalibration7") == "Need calibration 7") + #expect(CGMSensorState.displayName(for: "questionMarks") == "Question marks (???)") + #expect(CGMSensorState.displayName(for: ".unknown(23)") == "Unknown state (23)") + } + + // MARK: - Active state + + @Test("A state reported after the newest reading is active") + func stateAfterReadingIsActive() { + let issue = state("temporarySensorIssue", minutes: 5) + #expect(CGMSensorState.active(in: [issue], latestBGDate: t0) == issue) + } + + @Test("A reading after the state ends it") + func readingAfterStateEndsIt() { + let issue = state("temporarySensorIssue", minutes: 5) + #expect(CGMSensorState.active(in: [issue], latestBGDate: t0.addingTimeInterval(20 * 60)) == nil) + } + + @Test("A note stamped at the newest reading's time is not active") + func noteAtReadingTimeIsNotActive() { + let issue = state("temporarySensorIssue", minutes: 0) + #expect(CGMSensorState.active(in: [issue], latestBGDate: t0) == nil) + } + + @Test("The newest state wins") + func newestStateWins() { + let warmup = state("warmup", minutes: 1) + let failed = state("sensorFailed", minutes: 6) + let duplicate = state("warmup", minutes: 2) + #expect(CGMSensorState.active(in: [failed, warmup, duplicate], latestBGDate: t0) == failed) + } + + @Test("Without readings the newest state is active") + func noReadings() { + let failed = state("sensorFailed", minutes: 1) + #expect(CGMSensorState.active(in: [failed], latestBGDate: nil) == failed) + #expect(CGMSensorState.active(in: [], latestBGDate: nil) == nil) + } + + // MARK: - Chart anchor + + @Test("The marker sits at the last reading before the state") + func anchorsAtLastReadingBefore() { + let readings = [reading(80, minutes: -10), reading(47, minutes: -5), reading(55, minutes: 15)] + #expect(CGMSensorState.anchorSGV(at: t0.timeIntervalSince1970, readings: readings) == 47) + } + + @Test("A state newer than every reading sits at the newest reading") + func anchorsAfterNewestReading() { + let readings = [reading(80, minutes: -10), reading(47, minutes: -5)] + #expect(CGMSensorState.anchorSGV(at: t0.timeIntervalSince1970, readings: readings) == 47) + } + + @Test("A state older than every reading sits at the first reading") + func anchorsBeforeFirstReading() { + let readings = [reading(120, minutes: 10)] + #expect(CGMSensorState.anchorSGV(at: t0.timeIntervalSince1970, readings: readings) == 120) + #expect(CGMSensorState.anchorSGV(at: t0.timeIntervalSince1970, readings: []) == nil) + } +} diff --git a/Tests/DeviceStatusHistoryTests.swift b/Tests/DeviceStatusHistoryTests.swift new file mode 100644 index 000000000..39d3f1770 --- /dev/null +++ b/Tests/DeviceStatusHistoryTests.swift @@ -0,0 +1,298 @@ +// LoopFollow +// DeviceStatusHistoryTests.swift + +import Foundation +@testable import LoopFollow +import Testing + +struct DeviceStatusHistoryParserTests { + private func json(_ text: String) throws -> [String: Any] { + try #require(JSONSerialization.jsonObject(with: Data(text.utf8)) as? [String: Any]) + } + + @Test("Loop record yields IOB, COB and the forecast from its start date") + func loopRecord() throws { + let record = try json(""" + {"created_at": "2026-09-27T10:00:30.000Z", + "loop": {"timestamp": "2026-09-27T10:00:05Z", + "iob": {"iob": 1.25, "timestamp": "2026-09-27T10:00:00Z"}, + "cob": {"cob": 20}, + "predicted": {"startDate": "2026-09-27T09:59:00Z", "values": [110.4, 112.6, 115]}}} + """) + let (sample, createdAt) = try #require(DeviceStatusHistoryParser.parse(record)) + #expect(sample.eventualBG == 115) + #expect(sample.date == DeviceStatusHistoryParser.parseDate("2026-09-27T10:00:05Z")!.timeIntervalSince1970) + #expect(createdAt == DeviceStatusHistoryParser.parseDate("2026-09-27T10:00:30.000Z")) + #expect(sample.iob == 1.25) + #expect(sample.cob == 20) + #expect(sample.smoothedBG == nil) + #expect(sample.forecast?.curves["main"] == [110, 113, 115]) + #expect(sample.forecast?.start == DeviceStatusHistoryParser.parseDate("2026-09-27T09:59:00Z")!.timeIntervalSince1970) + } + + @Test("Trio record yields smoothed BG, ISF, ratio and the four forecast curves") + func trioRecord() throws { + let record = try json(""" + {"created_at": "2026-09-27T10:00:30.000Z", "device": "Trio", + "pump": {"reservoir": 120.5, "battery": {"percent": 80}}, "uploader": {"battery": 55}, + "openaps": {"iob": {"iob": 2.5, "time": "2026-09-27T10:00:00Z"}, + "suggested": {"deliverAt": "2026-09-27T10:00:10.123Z", "bg": 142, "COB": 12.5, + "ISF": 45.2, "sensitivityRatio": 1.15, "eventualBG": 130, + "current_target": 5.5, "CR": 9, "TDD": 42.1, + "predBGs": {"IOB": [142, 140], "ZT": [142, 138], "UAM": [142, 145]}}}} + """) + let (sample, _) = try #require(DeviceStatusHistoryParser.parse(record)) + #expect(sample.date == DeviceStatusHistoryParser.parseDate("2026-09-27T10:00:10.123Z")!.timeIntervalSince1970) + #expect(sample.iob == 2.5) + #expect(sample.cob == 12.5) + #expect(sample.smoothedBG == 142) + #expect(sample.isf == 45.2) + #expect(sample.sensitivityRatio == 1.15) + #expect(sample.forecast?.curves.keys.sorted() == ["IOB", "UAM", "ZT"]) + #expect(sample.forecast?.start == sample.date) + #expect(sample.eventualBG == 130) + #expect(abs((sample.target ?? 0) - 99.09) < 0.1) + #expect(sample.carbRatio == 9) + #expect(sample.tdd == 42.1) + #expect(sample.reservoir == 120.5) + #expect(sample.pumpBattery == 80) + #expect(sample.uploaderBattery == 55) + } + + @Test("Loop's active override is kept") + func loopOverride() throws { + let record = try json(""" + {"created_at": "2026-09-27T10:00:30Z", + "override": {"active": true, "multiplier": 1.5, "currentCorrectionRange": {"minValue": 90, "maxValue": 100}}, + "loop": {"timestamp": "2026-09-27T10:00:05Z", "recommendedBolus": 0.4}} + """) + let (sample, _) = try #require(DeviceStatusHistoryParser.parse(record)) + #expect(sample.loopOverride == .init(multiplier: 1.5, minTarget: 90, maxTarget: 100)) + #expect(sample.recommendedBolus == 0.4) + } + + @Test("A zero ISF is a placeholder, COB falls back to the reason string") + func openAPSFallbacks() throws { + let record = try json(""" + {"created_at": "2026-09-27T10:00:30Z", + "openaps": {"iob": [{"iob": 0.8}, {"iob": 0.6}], + "enacted": {"timestamp": "2026-09-27T10:00:00Z", "ISF": 0, + "reason": "maxCOB: 120, COB: 7.5, Dev: 3, ISF: 54"}}} + """) + let (sample, _) = try #require(DeviceStatusHistoryParser.parse(record)) + #expect(sample.isf == nil) + #expect(sample.cob == 7.5) + #expect(sample.iob == 0.8) + } + + @Test("Out-of-range forecast values are clamped") + func hugeForecastValue() throws { + let record = try json(""" + {"created_at": "2026-09-27T10:00:30Z", + "loop": {"timestamp": "2026-09-27T10:00:05Z", "predicted": {"values": [1e30, -5, 120]}}} + """) + let (sample, _) = try #require(DeviceStatusHistoryParser.parse(record)) + #expect(sample.forecast?.curves["main"] == [10000, 0, 120]) + } + + @Test("Uploader-only records carry nothing to graph") + func uploaderOnly() throws { + let record = try json(""" + {"created_at": "2026-09-27T10:00:30Z", "uploader": {"battery": 80}} + """) + #expect(DeviceStatusHistoryParser.parse(record) == nil) + } + + @Test("Explicit nulls are treated as missing fields") + func explicitNulls() throws { + let record = try json(""" + {"created_at": "2026-09-27T10:00:30Z", "loop": null, "override": null, "pump": {"clock": null}, + "openaps": {"iob": [{"iob": 0.8}], "suggested": null, + "enacted": {"timestamp": "2026-09-27T10:00:00Z", "COB": 12, "ISF": null}}} + """) + let (sample, _) = try #require(DeviceStatusHistoryParser.parse(record)) + #expect(sample.iob == 0.8) + #expect(sample.cob == 12) + #expect(sample.isf == nil) + } + + @Test("Offsets are honored") + func offsetDates() { + let utc = DeviceStatusHistoryParser.parseDate("2026-09-27T10:00:00Z") + let offset = DeviceStatusHistoryParser.parseDate("2026-09-27T12:00:00+02:00") + #expect(utc != nil) + #expect(utc == offset) + } +} + +struct DeviceStatusHistoryStateTests { + private let t0: TimeInterval = 1_800_000_000 + + private func sample(_ minutes: Double, iob: Double = 1) -> DeviceStatusHistorySample { + var s = DeviceStatusHistorySample(date: t0 + minutes * 60) + s.iob = iob + return s + } + + @Test("An empty history fetches the whole window") + func emptyPlan() { + let state = DeviceStatusHistoryState() + let ranges = state.missingRanges(windowStart: t0, latest: t0 + 3600, now: t0 + 3600) + #expect(ranges == [.init(from: t0, to: t0 + 3600)]) + } + + @Test("The next record extends the history without a fetch") + func appendNext() { + var state = DeviceStatusHistoryState() + state.markCovered(from: t0, to: t0 + 3600) + let appended = state.appendLatest(sample(65), createdAt: t0 + 65 * 60) + #expect(appended) + #expect(state.coverageEnd == t0 + 65 * 60) + #expect(state.missingRanges(windowStart: t0, latest: t0 + 65 * 60, now: t0 + 66 * 60).isEmpty) + } + + @Test("A record after a gap is refused and only the gap is fetched") + func gapFetch() { + var state = DeviceStatusHistoryState() + state.markCovered(from: t0, to: t0 + 3600) + let appended = state.appendLatest(sample(120), createdAt: t0 + 120 * 60) + #expect(!appended) + let now = t0 + 121 * 60 + let ranges = state.missingRanges(windowStart: t0, latest: t0 + 120 * 60, now: now) + #expect(ranges == [.init(from: t0 + 3600 - DeviceStatusHistoryState.overlap, to: now)]) + } + + @Test("A record stamped ahead of the phone clock is included in the gap fetch") + func futureRecord() { + var state = DeviceStatusHistoryState() + state.markCovered(from: t0, to: t0 + 3600) + let now = t0 + 3600 + 60 + let latest = now + 600 + let ranges = state.missingRanges(windowStart: t0, latest: latest, now: now) + #expect(ranges == [.init(from: t0 + 3600 - DeviceStatusHistoryState.overlap, to: latest)]) + } + + @Test("A longer Show Days Back window fetches only the older span") + func widenedWindow() { + var state = DeviceStatusHistoryState() + state.markCovered(from: t0, to: t0 + 3600) + let ranges = state.missingRanges(windowStart: t0 - 86400, latest: t0 + 3600, now: t0 + 3600) + #expect(ranges == [.init(from: t0 - 86400, to: t0 + DeviceStatusHistoryState.overlap)]) + } + + @Test("A cache that ended before the window refetches the window") + func staleCache() { + var state = DeviceStatusHistoryState() + state.markCovered(from: t0, to: t0 + 3600) + let windowStart = t0 + 2 * 86400 + let ranges = state.missingRanges(windowStart: windowStart, latest: windowStart + 86400, now: windowStart + 86400) + #expect(ranges == [.init(from: windowStart, to: windowStart + 86400)]) + } + + @Test("Merging replaces same-second samples and keeps dates ascending") + func mergeDedupes() { + var state = DeviceStatusHistoryState() + state.merge([sample(10), sample(0)]) + state.merge([sample(5), sample(10, iob: 3)]) + #expect(state.samples.map(\.date) == [t0, t0 + 300, t0 + 600]) + #expect(state.samples.last?.iob == 3) + } + + @Test("Pruning drops old samples and shrinks coverage") + func prune() { + var state = DeviceStatusHistoryState() + state.merge([sample(0), sample(5), sample(10)]) + state.markCovered(from: t0, to: t0 + 600) + state.prune(before: t0 + 240) + #expect(state.samples.count == 2) + #expect(state.coverageStart == t0 + 240) + state.prune(before: t0 + 3600) + #expect(state.isEmpty) + } + + @Test("The forecast of a reading comes from the first loop cycle after it") + func cycleForReading() { + var state = DeviceStatusHistoryState() + state.merge([sample(0), sample(5), sample(10)]) + #expect(state.cycle(forReadingAt: t0 + 4 * 60)?.date == t0 + 300) + #expect(state.cycle(forReadingAt: t0 + 5.5 * 60)?.date == t0 + 300) + #expect(state.cycle(forReadingAt: t0 + 30 * 60) == nil) + } + + @Test("Round-trips through JSON") + func codable() throws { + var state = DeviceStatusHistoryState() + var s = sample(0) + s.forecast = .init(start: t0, curves: ["main": [100, 105]]) + state.merge([s]) + state.markCovered(from: t0, to: t0 + 600) + let decoded = try JSONDecoder().decode(DeviceStatusHistoryState.self, from: JSONEncoder().encode(state)) + #expect(decoded == state) + } +} + +struct BGChartHistoryTests { + private let t0: TimeInterval = 1_800_000_000 + + private func samples(_ values: [(minutes: Double, iob: Double)]) -> [DeviceStatusHistorySample] { + values.map { + var s = DeviceStatusHistorySample(date: t0 + $0.minutes * 60) + s.iob = $0.iob + return s + } + } + + @Test("Lines break across gaps longer than fifteen minutes") + func segments() { + let points = BGChartHistory.points(from: samples([(0, 1), (5, 1), (30, 1), (35, 1)])) { $0.iob } + #expect(points.map(\.segment) == [0, 0, 1, 1]) + } + + @Test("The IOB pane includes zero and negative values") + func iobScale() throws { + let pane = try #require(BGChartHistory.pane(kind: .iob, samples: samples([(0, -0.4), (5, 2.3)]))) + #expect(pane.ticks == [0, 3]) + #expect(pane.yDomain.lowerBound < -0.4) + #expect(pane.yDomain.upperBound > 3) + } + + @Test("Negative IOB splits into its own run at the zero crossing") + func negativeIOBSplit() { + let points = BGChartHistory.points(from: samples([(0, 1), (5, -1), (10, -2)])) { $0.iob } + let (positive, negative) = BGChartHistory.splitAtZero(points) + #expect(positive.map(\.value) == [1, 0]) + #expect(negative.map(\.value) == [0, -1, -2]) + let crossing = t0 + 150 + #expect(positive.last?.date.timeIntervalSince1970 == crossing) + #expect(negative.first?.date.timeIntervalSince1970 == crossing) + #expect(positive.last?.segment != negative.first?.segment) + } + + @Test("An IOB of exactly zero joins both runs without duplicate points") + func exactZeroSplit() { + for values in [[1.0, 0, -1], [-1.0, 0, 1]] { + let points = BGChartHistory.points(from: samples(values.enumerated().map { (Double($0.offset * 5), $0.element) })) { $0.iob } + let (positive, negative) = BGChartHistory.splitAtZero(points) + for run in [positive, negative] { + let keys = run.map { "\($0.segment)-\($0.date.timeIntervalSince1970)" } + #expect(Set(keys).count == keys.count) + } + #expect(negative.contains { $0.value == 0 }) + #expect(positive.contains { $0.value == 0 }) + } + } + + @Test("The main-graph overlay mirrors negative IOB inside the band") + func overlayNegativeIOB() throws { + let overlay = try #require(BGChartHistory.overlay(kinds: [.iob], samples: samples([(0, 2), (5, -1)]), bandTop: 60)) + #expect(overlay.series.map(\.isNegative) == [false, true]) + let maxDrawn = overlay.series.flatMap(\.points).map(\.value).max() ?? 0 + #expect(maxDrawn <= 60) + #expect(overlay.series.flatMap(\.points).allSatisfy { $0.value >= 0 }) + } + + @Test("A pane without values is not shown") + func emptyPane() { + #expect(BGChartHistory.pane(kind: .cob, samples: samples([(0, 1)])) == nil) + } +} diff --git a/Tests/LoopAPNSCommandPayloadTests.swift b/Tests/LoopAPNSCommandPayloadTests.swift new file mode 100644 index 000000000..2040bb127 --- /dev/null +++ b/Tests/LoopAPNSCommandPayloadTests.swift @@ -0,0 +1,78 @@ +// LoopFollow +// LoopAPNSCommandPayloadTests.swift + +import Foundation +@testable import LoopFollow +import Testing + +struct LoopAPNSCommandPayloadTests { + private let now = Date(timeIntervalSince1970: 1_700_000_000) + private let headerKeys: Set = ["otp", "remote-address", "notes", "entered-by", "sent-at", "expiration", "alert"] + + @Test("carbs payload carries the loop.js keys") + func carbsKeys() { + let payload = LoopAPNSPayload(type: .carbs, carbsAmount: 30, absorptionTime: 3, consumedDate: now.addingTimeInterval(-600), otp: "123456") + let json = LoopAPNSCommands.carbsCommandPayload(payload, now: now) + #expect(Set(json.keys) == headerKeys.union(["carbs-entry", "absorption-time", "start-time"])) + #expect(json["carbs-entry"] as? Double == 30) + #expect(json["absorption-time"] as? Double == 3) + #expect(json["otp"] as? String == "123456") + #expect(json["remote-address"] as? String == "LoopFollow") + #expect(json["entered-by"] as? String == "LoopFollow") + #expect(json["notes"] as? String == "Sent via LoopFollow APNS") + #expect(json["sent-at"] as? String == "2023-11-14T22:13:20.000Z") + #expect(json["expiration"] as? String == "2023-11-14T22:18:20.000Z") + #expect(json["start-time"] as? String == "2023-11-14T22:03:20.000Z") + #expect(json["alert"] as? String == "Remote Carbs Entry: 30.0 grams\nAbsorption Time: 3.0 hours") + } + + @Test("bolus payload carries the loop.js keys") + func bolusKeys() { + let json = LoopAPNSCommands.bolusCommandPayload(LoopAPNSPayload(type: .bolus, bolusAmount: 1.25, otp: "123456"), now: now) + #expect(Set(json.keys) == headerKeys.union(["bolus-entry"])) + #expect(json["bolus-entry"] as? Double == 1.25) + #expect(json["alert"] as? String == "Remote Bolus Entry: 1.25 U") + } + + @Test("carbs delete addresses the entry by sync identifier") + func deleteKeys() { + let json = LoopAPNSCommands.carbsDeleteCommandPayload(syncIdentifier: "SYNC-1", otp: "123456", now: now) + #expect(Set(json.keys) == headerKeys.union(["carbs-delete"])) + #expect(json["carbs-delete"] as? String == "SYNC-1") + #expect(json["carbs-entry"] == nil) + } + + @Test("carbs edit always sends amount, absorption and start time; food type only when set") + func editKeys() { + let json = LoopAPNSCommands.carbsEditCommandPayload( + syncIdentifier: "SYNC-1", + carbsAmount: 45, + absorptionTimeHours: 2.5, + foodType: nil, + consumedDate: now.addingTimeInterval(-3600), + otp: "123456", + now: now + ) + #expect(Set(json.keys) == headerKeys.union(["carbs-edit", "carbs-edit-entry", "carbs-edit-absorption-time", "carbs-edit-start-time"])) + #expect(json["carbs-edit"] as? String == "SYNC-1") + #expect(json["carbs-edit-entry"] as? Double == 45) + #expect(json["carbs-edit-absorption-time"] as? Double == 2.5) + #expect(json["carbs-edit-start-time"] as? String == "2023-11-14T21:13:20.000Z") + #expect(json["carbs-entry"] == nil) + #expect(json["start-time"] == nil) + + let withFood = LoopAPNSCommands.carbsEditCommandPayload( + syncIdentifier: "SYNC-1", carbsAmount: 45, absorptionTimeHours: 2.5, foodType: "🍕", consumedDate: now, otp: "123456", now: now + ) + #expect(withFood["carbs-edit-food-type"] as? String == "🍕") + } + + @Test("profile switch names the profile and carries its fingerprint") + func profileSwitchKeys() { + let json = LoopAPNSCommands.profileSwitchCommandPayload(name: "Sport", fingerprint: "fec4f066b5a54ecd", otp: "123456", now: now) + #expect(Set(json.keys) == headerKeys.union(["profile-switch", "profile-switch-fingerprint"])) + #expect(json["profile-switch"] as? String == "Sport") + #expect(json["profile-switch-fingerprint"] as? String == "fec4f066b5a54ecd") + #expect(json["alert"] as? String == "Remote Profile Switch: Sport") + } +} diff --git a/Tests/LoopCarbTreatmentTests.swift b/Tests/LoopCarbTreatmentTests.swift new file mode 100644 index 000000000..9768dbcc2 --- /dev/null +++ b/Tests/LoopCarbTreatmentTests.swift @@ -0,0 +1,66 @@ +// LoopFollow +// LoopCarbTreatmentTests.swift + +import Foundation +@testable import LoopFollow +import Testing + +struct LoopCarbTreatmentTests { + private typealias Entry = [String: AnyObject] + + private func entry(enteredBy: String = "loop://phone", eventType: String = "Carb Correction", syncIdentifier: String? = "SYNC-1", carbs: Double = 30, absorption: Double? = 180, foodType: String? = "🍕") -> Entry { + var result: Entry = [ + "_id": "mongo" as AnyObject, + "enteredBy": enteredBy as AnyObject, + "eventType": eventType as AnyObject, + "carbs": carbs as AnyObject, + ] + if let syncIdentifier { result["syncIdentifier"] = syncIdentifier as AnyObject } + if let absorption { result["absorptionTime"] = absorption as AnyObject } + if let foodType { result["foodType"] = foodType as AnyObject } + return result + } + + @Test("parses a Loop carb entry") + func parses() { + let carb = LoopCarbTreatment(nightscoutEntry: entry(), date: 100) + #expect(carb?.syncIdentifier == "SYNC-1") + #expect(carb?.carbs == 30) + #expect(carb?.absorptionHours == 3) + #expect(carb?.foodType == "🍕") + #expect(carb?.nightscoutID == "mongo") + } + + @Test("rejects entries Loop cannot address") + func rejects() { + #expect(LoopCarbTreatment(nightscoutEntry: entry(enteredBy: "Trio"), date: 0) == nil) + #expect(LoopCarbTreatment(nightscoutEntry: entry(syncIdentifier: nil), date: 0) == nil) + #expect(LoopCarbTreatment(nightscoutEntry: entry(eventType: "Meal Bolus"), date: 0) == nil) + #expect(LoopCarbTreatment(nightscoutEntry: entry(carbs: 0), date: 0) == nil) + } + + @Test("edit window is 23 h back and 1 h ahead") + func window() { + let now = Date(timeIntervalSince1970: 1_700_000_000) + func carb(offsetHours: Double) -> LoopCarbTreatment? { + LoopCarbTreatment(nightscoutEntry: entry(), date: now.timeIntervalSince1970 + offsetHours * 3600) + } + #expect(carb(offsetHours: -22)?.isWithinEditWindow(now: now) == true) + #expect(carb(offsetHours: -24)?.isWithinEditWindow(now: now) == false) + #expect(carb(offsetHours: 0.5)?.isWithinEditWindow(now: now) == true) + #expect(carb(offsetHours: 2)?.isWithinEditWindow(now: now) == false) + } + + @Test("actions need Loop APNS, a Loop device and both commands advertised") + func gating() { + let both = ["carbs-delete", "carbs-edit"] + #expect(LoopCarbTreatment.remoteActionsAvailable(remoteType: .loopAPNS, device: "Loop", remoteCommands: both) == true) + #expect(LoopCarbTreatment.remoteActionsAvailable(remoteType: .loopAPNS, device: "Loop", remoteCommands: ["carbs-delete"]) == false) + #expect(LoopCarbTreatment.remoteActionsAvailable(remoteType: .loopAPNS, device: "Loop", remoteCommands: []) == false) + #expect(LoopCarbTreatment.remoteActionsAvailable(remoteType: .loopAPNS, device: "Trio", remoteCommands: both) == false) + #expect(LoopCarbTreatment.remoteActionsAvailable(remoteType: .trc, device: "Loop", remoteCommands: both) == false) + #expect(LoopCarbTreatment.remoteActionsAvailable(remoteType: .none, device: "Loop", remoteCommands: both) == false) + #expect(LoopCarbTreatment.remoteControlActive(remoteType: .loopAPNS, device: "Loop") == true) + #expect(LoopCarbTreatment.remoteControlActive(remoteType: .loopAPNS, device: "Trio") == false) + } +} diff --git a/Tests/LoopProfileTests.swift b/Tests/LoopProfileTests.swift new file mode 100644 index 000000000..5a70f0941 --- /dev/null +++ b/Tests/LoopProfileTests.swift @@ -0,0 +1,46 @@ +// LoopFollow +// LoopProfileTests.swift + +import Foundation +@testable import LoopFollow +import Testing + +struct LoopProfileTests { + private func store(units: String, sens: Double, low: Double, high: Double) throws -> NSProfile.Store { + let json = """ + {"timezone": "ETC/GMT-1", "units": "\(units)", + "basal": [{"time": "00:00", "value": 0.85, "timeAsSeconds": 0}, {"time": "06:00", "value": 1.1, "timeAsSeconds": 21600}], + "carbratio": [{"time": "00:00", "value": 10, "timeAsSeconds": 0}], + "sens": [{"time": "00:00", "value": \(sens), "timeAsSeconds": 0}], + "target_low": [{"time": "00:00", "value": \(low), "timeAsSeconds": 0}], + "target_high": [{"time": "00:00", "value": \(high), "timeAsSeconds": 0}]} + """ + return try JSONDecoder().decode(NSProfile.Store.self, from: Data(json.utf8)) + } + + @Test("fingerprint matches the value Loop computes for the same store entry") + func fingerprint() throws { + let mgdl = try LoopProfile(name: "Sport", store: store(units: "mg/dL", sens: 45, low: 100, high: 110)) + #expect(mgdl.fingerprint == "fec4f066b5a54ecd") + let mmol = try LoopProfile(name: "Sport", store: store(units: "mmol/L", sens: 2.497_646_904_752_9, low: 5.550_747_991_045_533, high: 6.105_822_790_150_087)) + #expect(mmol.fingerprint == "f7d9520ff02af97c") + } + + @Test("basal total weights each rate by how long it runs") + func basalTotal() throws { + let profile = try LoopProfile(name: "Sport", store: store(units: "mg/dL", sens: 45, low: 100, high: 110)) + #expect(abs(profile.basalTotal - (0.85 * 6 + 1.1 * 18)) < 0.000_1) + #expect(profile.correctionRange == [LoopProfile.RangeItem(seconds: 0, low: 100, high: 110)]) + } + + @Test("the Default entry is left out and names are sorted") + func savedProfiles() throws { + let entry = try store(units: "mg/dL", sens: 45, low: 100, high: 110) + let profile = NSProfile(store: ["Default": entry, "Work": entry, "Sport": entry], defaultProfile: "Work", units: "mg/dL", + bundleIdentifier: nil, isAPNSProduction: nil, deviceToken: nil, teamID: nil, expirationDate: nil, + startDate: nil, createdAt: nil, remoteCommands: nil, trioOverrides: nil, loopSettings: nil) + let set = LoopProfileSet(profile) + #expect(set.profiles.map(\.name) == ["Sport", "Work"]) + #expect(set.activeName == "Work") + } +} diff --git a/Tests/NightscoutTreatmentDateTests.swift b/Tests/NightscoutTreatmentDateTests.swift new file mode 100644 index 000000000..abfb74105 --- /dev/null +++ b/Tests/NightscoutTreatmentDateTests.swift @@ -0,0 +1,53 @@ +// LoopFollow +// NightscoutTreatmentDateTests.swift + +import Foundation +@testable import LoopFollow +import Testing + +struct NightscoutTreatmentDateTests { + private typealias Entry = [String: AnyObject] + + private let expected = Date(timeIntervalSince1970: 1_787_000_000) + + @Test("parses an ISO 8601 timestamp string") + func parsesStringTimestamp() { + let entry: Entry = ["timestamp": "2026-08-17T20:53:20.000Z" as AnyObject] + #expect(NightscoutUtils.treatmentDate(from: entry) == expected) + } + + @Test("falls back to created_at when timestamp is missing") + func fallsBackToCreatedAt() { + let entry: Entry = ["created_at": "2026-08-17T20:53:20Z" as AnyObject] + #expect(NightscoutUtils.treatmentDate(from: entry) == expected) + } + + @Test("falls back to created_at when timestamp is JSON null") + func nullTimestampFallsBack() { + let entry: Entry = [ + "timestamp": NSNull(), + "created_at": "2026-08-17T20:53:20Z" as AnyObject, + ] + #expect(NightscoutUtils.treatmentDate(from: entry) == expected) + } + + @Test("accepts epoch milliseconds and seconds") + func parsesNumericTimestamp() { + let millis: Entry = ["timestamp": NSNumber(value: 1_787_000_000_000)] + let seconds: Entry = ["timestamp": NSNumber(value: 1_787_000_000)] + #expect(NightscoutUtils.treatmentDate(from: millis) == expected) + #expect(NightscoutUtils.treatmentDate(from: seconds) == expected) + } + + @Test("returns nil instead of trapping on unsupported values") + func unsupportedValuesReturnNil() { + let bool: Entry = ["timestamp": NSNumber(value: true)] + let array: Entry = ["timestamp": ["2026-08-17T20:53:20Z"] as AnyObject] + let garbage: Entry = ["timestamp": "not a date" as AnyObject] + let empty: Entry = [:] + #expect(NightscoutUtils.treatmentDate(from: bool) == nil) + #expect(NightscoutUtils.treatmentDate(from: array) == nil) + #expect(NightscoutUtils.treatmentDate(from: garbage) == nil) + #expect(NightscoutUtils.treatmentDate(from: empty) == nil) + } +} diff --git a/Tests/PumpReservoirTests.swift b/Tests/PumpReservoirTests.swift index 625e43802..8ebabab6f 100644 --- a/Tests/PumpReservoirTests.swift +++ b/Tests/PumpReservoirTests.swift @@ -14,6 +14,7 @@ struct PumpReservoirTests { pumpID: String? = "17CB71F7", manufacturer: String? = "Insulet", model: String? = "Omnipod DASH", + displayOverride: String? = nil, cache: PumpReservoirCache? = nil, at date: Date? = nil ) -> PumpReservoirResolver.Resolution { @@ -22,8 +23,9 @@ struct PumpReservoirTests { pumpID: pumpID, manufacturer: manufacturer, model: model, + displayOverride: displayOverride, cache: cache, - now: date ?? now + date: date ?? now ) } @@ -70,17 +72,59 @@ struct PumpReservoirTests { #expect(resolve(cache: cache(units: 9.9, readingAge: -60 * 60)).state == .unknown) } - @Test("a volume reported right after a pod change is shown but not trusted once stale") - func podChangeCarryoverIsDiscarded() { + @Test("a volume reported right after a pod change is the previous pod's and is not shown") + func podChangeCarryoverIsIgnored() { // Loop's first records for a new pod still carry the previous pod's final volume. - let carryover = resolve(reservoir: 9.9, cache: PumpReservoirCache(pumpID: "17CB71F6", pumpSince: now.addingTimeInterval(-3 * 24 * 60 * 60), reading: nil)) - #expect(carryover.state == .units(9.9)) - #expect(carryover.cache?.pumpID == pump) + let previousPod = PumpReservoirCache(pumpID: "17CB71F6", pumpSince: now.addingTimeInterval(-3 * 24 * 60 * 60), reading: .init(units: 25.6, date: now.addingTimeInterval(-5 * 60))) + var result = resolve(reservoir: 25.5, cache: previousPod) + #expect(result.state == .aboveReportingLimit) + #expect(result.cache?.pumpID == pump) + #expect(result.cache?.reading == nil) + + result = resolve(reservoir: 25.5, cache: result.cache, at: now.addingTimeInterval(10 * 60)) + #expect(result.state == .aboveReportingLimit) + #expect(result.cache?.reading == nil) + + result = resolve(cache: result.cache, at: now.addingTimeInterval(15 * 60)) + #expect(result.state == .aboveReportingLimit) + #expect(result.cache?.isSettled == true) + } + + @Test("a new pod's volumes count once it has uploaded a record without one") + func podChangeSettlesOnMissingVolume() { + let changed = resolve(reservoir: 25.5, cache: PumpReservoirCache(pumpID: "17CB71F6", pumpSince: now.addingTimeInterval(-3 * 24 * 60 * 60))) + let gap = resolve(cache: changed.cache, at: now.addingTimeInterval(5 * 60)) + let reported = resolve(reservoir: 48.5, cache: gap.cache, at: now.addingTimeInterval(10 * 60)) + #expect(reported.state == .units(48.5)) + #expect(reported.cache?.reading?.units == 48.5) + } + + @Test("a new pod's volumes count 15 minutes after it appeared") + func podChangeSettlesAfterTime() { + let changed = resolve(reservoir: 25.5, cache: PumpReservoirCache(pumpID: "17CB71F6", pumpSince: now.addingTimeInterval(-3 * 24 * 60 * 60))) + #expect(resolve(reservoir: 25.5, cache: changed.cache, at: now.addingTimeInterval(14 * 60)).state == .aboveReportingLimit) + #expect(resolve(reservoir: 48.5, cache: changed.cache, at: now.addingTimeInterval(15 * 60)).state == .units(48.5)) + } + + @Test("a pump change is seen across records that name no pump") + func podChangeAcrossUnknown() { + let previousPod = PumpReservoirCache(pumpID: "17CB71F6", pumpSince: now.addingTimeInterval(-3 * 24 * 60 * 60), reading: .init(units: 43.3, date: now.addingTimeInterval(-10 * 60))) + let pairing = resolve(pumpID: "Unknown", cache: previousPod, at: now.addingTimeInterval(-5 * 60)) + #expect(pairing.cache == previousPod) + #expect(resolve(reservoir: 42.9, cache: pairing.cache).state == .aboveReportingLimit) + } + + @Test("a pump seen with nothing stored has its volume taken as its own") + func firstSeenPumpIsSettled() { + let result = resolve(reservoir: 25.5) + #expect(result.state == .units(25.5)) + #expect(result.cache?.isSettled == true) + } - let laterOnTheSamePod = resolve(cache: carryover.cache, at: now.addingTimeInterval(31 * 60)) - #expect(laterOnTheSamePod.state == .aboveReportingLimit) - #expect(laterOnTheSamePod.cache?.pumpID == pump) - #expect(laterOnTheSamePod.cache?.reading == nil) + @Test("a pump that reports its volume in every record is used right after a change") + func otherPumpChangeIsSettled() { + let previous = PumpReservoirCache(pumpID: "17CB71F6", pumpSince: now.addingTimeInterval(-3 * 24 * 60 * 60)) + #expect(resolve(reservoir: 180, manufacturer: "Medtronic", model: "723", cache: previous).state == .units(180)) } @Test("a pod change drops the previous pod's reading") @@ -119,10 +163,49 @@ struct PumpReservoirTests { #expect(withoutVolume.cache == nil) } - @Test("a pump with no pod paired is not an identity to cache against") + @Test("an Omnipod with no pod paired shows no volume and keeps the stored pump") func unknownPumpID() { - let result = resolve(pumpID: "Unknown", cache: cache(units: 9.9, readingAge: 5 * 60)) - #expect(result.state == .aboveReportingLimit) + let stored = cache(units: 9.9, readingAge: 5 * 60) + let result = resolve(pumpID: "Unknown", cache: stored) + #expect(result.state == .unknown) + #expect(result.cache == stored) + } + + @Test("a volume uploaded while no pod is paired is the previous pod's") + func unknownPumpIDWithVolume() { + let stored = cache(units: 12.6, readingAge: 5 * 60) + let result = resolve(reservoir: 12.5, pumpID: "Unknown", cache: stored) + #expect(result.state == .unknown) + #expect(result.cache == stored) + } + + @Test("a status Loop shows in place of the reservoir is shown instead of a volume") + func displayOverride() { + let stored = cache(units: 9.9, readingAge: 5 * 60) + let result = resolve(displayOverride: " Signal Loss ", cache: stored) + #expect(result.state == .status("Signal Loss")) + #expect(result.cache == stored) + } + + @Test("a new pod finishing setup shows its status and is still seen as a change") + func displayOverrideDuringSetup() { + let previousPod = PumpReservoirCache(pumpID: "17CB71F6", pumpSince: now.addingTimeInterval(-3 * 24 * 60 * 60), reading: .init(units: 21.0, date: now.addingTimeInterval(-5 * 60))) + let setup = resolve(reservoir: 21.0, displayOverride: "Finish Setup", cache: previousPod) + #expect(setup.state == .status("Finish Setup")) + #expect(setup.cache?.pumpID == pump) + #expect(setup.cache?.reading == nil) + #expect(resolve(reservoir: 21.0, cache: setup.cache, at: now.addingTimeInterval(5 * 60)).state == .aboveReportingLimit) + } + + @Test("an empty status shows the volume") + func emptyDisplayOverride() { + #expect(resolve(reservoir: 21.0, displayOverride: " ", cache: cache(units: 21.0, readingAge: 5 * 60)).state == .units(21.0)) + } + + @Test("a pump Loop names Unknown that reports its volume in every record shows it") + func unknownPumpIDOtherPump() { + let result = resolve(reservoir: 185.6, pumpID: "Unknown", manufacturer: "Medtrum", model: "MD8201") + #expect(result.state == .units(185.6)) #expect(result.cache == nil) } @@ -148,4 +231,12 @@ struct PumpReservoirTests { let decoded = try JSONDecoder().decode(PumpReservoirCache.self, from: JSONEncoder().encode(original)) #expect(decoded == original) } + + @Test("a cache stored without the settled flag decodes as settled") + func cacheDecodesWithoutSettledFlag() throws { + let json = #"{"pumpID":"17CB71F7","pumpSince":0}"# + let decoded = try JSONDecoder().decode(PumpReservoirCache.self, from: Data(json.utf8)) + #expect(decoded.isSettled) + #expect(decoded.reading == nil) + } } diff --git a/Tests/PumpSuspensionTests.swift b/Tests/PumpSuspensionTests.swift new file mode 100644 index 000000000..75efb56cd --- /dev/null +++ b/Tests/PumpSuspensionTests.swift @@ -0,0 +1,108 @@ +// LoopFollow +// PumpSuspensionTests.swift + +import Foundation +@testable import LoopFollow +import Testing + +struct PumpSuspensionTests { + private let t0 = Date(timeIntervalSince1970: 1_790_000_000) + + private func at(_ minutes: Double) -> Date { + t0.addingTimeInterval(minutes * 60) + } + + private func note(_ text: String, _ minutes: Double) -> (date: Date, note: String) { + (at(minutes), text) + } + + private func dot(_ rate: Double, _ minutes: Double) -> MainViewController.basalGraphStruct { + .init(basalRate: rate, date: at(minutes).timeIntervalSince1970) + } + + /// The rate the chart draws at a time: each point holds until the next one. + private func rate(at minutes: Double, in basal: [MainViewController.basalGraphStruct]) -> Double? { + let time = at(minutes).timeIntervalSince1970 + return basal.sorted { $0.date < $1.date }.last { $0.date <= time }?.basalRate + } + + // MARK: - Intervals + + @Test("A suspend pairs with the next resume") + func pairsSuspendAndResume() { + let intervals = PumpSuspension.intervals( + notes: [note("PumpSuspend", 10), note("Pizza", 15), note("PumpResume", 40)], + tempBasalStarts: [at(0), at(45)] + ) + #expect(intervals == [DateInterval(start: at(10), end: at(40))]) + } + + @Test("A suspend without a resume ends at the next temp basal") + func endsAtNextTempBasal() { + let intervals = PumpSuspension.intervals( + notes: [note("PumpSuspend", 10)], + tempBasalStarts: [at(0), at(30)] + ) + #expect(intervals == [DateInterval(start: at(10), end: at(30))]) + } + + @Test("A suspend without a resume or a later temp basal is ongoing") + func ongoingSuspend() { + let intervals = PumpSuspension.intervals(notes: [note("PumpSuspend", 10)], tempBasalStarts: [at(0)]) + #expect(intervals == [DateInterval(start: at(10), end: .distantFuture)]) + } + + @Test("A repeated suspend keeps the first start") + func repeatedSuspend() { + let intervals = PumpSuspension.intervals( + notes: [note("PumpSuspend", 10), note("PumpSuspend", 20), note("PumpResume", 40)], + tempBasalStarts: [] + ) + #expect(intervals == [DateInterval(start: at(10), end: at(40))]) + } + + @Test("A suspend ended by a temp basal is separate from a later suspend") + func suspendAfterTempBasal() { + let intervals = PumpSuspension.intervals( + notes: [note("PumpSuspend", 10), note("PumpSuspend", 50), note("PumpResume", 60)], + tempBasalStarts: [at(30)] + ) + #expect(intervals == [DateInterval(start: at(10), end: at(30)), DateInterval(start: at(50), end: at(60))]) + } + + @Test("A resume without a suspend is ignored") + func resumeWithoutSuspend() { + let intervals = PumpSuspension.intervals(notes: [note("PumpResume", 10)], tempBasalStarts: []) + #expect(intervals.isEmpty) + } + + // MARK: - Basal steps + + @Test("Basal inside a suspension is drawn as 0") + func zeroInsideSuspension() { + let basal = [dot(1.2, 0), dot(1.2, 30), dot(0.8, 30), dot(0.8, 60)] + let result = PumpSuspension.applying([DateInterval(start: at(20), end: at(40))], to: basal) + #expect(rate(at: 10, in: result) == 1.2) + #expect(rate(at: 25, in: result) == 0) + #expect(rate(at: 35, in: result) == 0) + #expect(rate(at: 50, in: result) == 0.8) + #expect(result.map(\.date).max() == at(60).timeIntervalSince1970) + } + + @Test("An ongoing suspension holds 0 to the end of the basal") + func ongoingSuspensionToEnd() { + let basal = [dot(1.0, 0), dot(1.0, 60)] + let result = PumpSuspension.applying([DateInterval(start: at(30), end: .distantFuture)], to: basal) + #expect(rate(at: 15, in: result) == 1.0) + #expect(rate(at: 45, in: result) == 0) + #expect(result.map(\.date).max() == at(60).timeIntervalSince1970) + } + + @Test("Basal without suspensions is unchanged") + func noSuspensions() { + let basal = [dot(1.0, 0), dot(1.0, 60)] + let result = PumpSuspension.applying([], to: basal) + #expect(result.map(\.date) == basal.map(\.date)) + #expect(result.map(\.basalRate) == basal.map(\.basalRate)) + } +} diff --git a/Tests/RefreshCoalescerTests.swift b/Tests/RefreshCoalescerTests.swift new file mode 100644 index 000000000..046287b08 --- /dev/null +++ b/Tests/RefreshCoalescerTests.swift @@ -0,0 +1,96 @@ +// LoopFollow +// RefreshCoalescerTests.swift + +import Foundation +@testable import LoopFollow +import Testing + +struct RefreshCoalescerTests { + final class ManualScheduler { + var scheduled: [(delay: TimeInterval, work: () -> Void)] = [] + + func schedule(_ delay: TimeInterval, _ work: @escaping () -> Void) { + scheduled.append((delay, work)) + } + + func runAll() { + let pending = scheduled + scheduled.removeAll() + pending.forEach { $0.work() } + } + } + + @Test("first request schedules one call after the delay") + func firstRequestSchedules() { + let scheduler = ManualScheduler() + var fired: [String] = [] + let coalescer = RefreshCoalescer(delay: 20, scheduler: scheduler.schedule) { fired.append($0) } + + coalescer.request(reason: "bg") + + #expect(scheduler.scheduled.count == 1) + #expect(scheduler.scheduled.first?.delay == 20) + #expect(fired.isEmpty) + scheduler.runAll() + #expect(fired == ["bg"]) + #expect(!coalescer.isPending) + } + + @Test("repeated requests while pending keep the original deadline") + func repeatedRequestsDoNotReschedule() { + let scheduler = ManualScheduler() + var fired: [String] = [] + let coalescer = RefreshCoalescer(delay: 20, scheduler: scheduler.schedule) { fired.append($0) } + + for _ in 0 ..< 10 { + coalescer.request(reason: "bg") + } + coalescer.request(reason: "overrideChanged") + + #expect(scheduler.scheduled.count == 1) + scheduler.runAll() + #expect(fired == ["bg,overrideChanged"]) + } + + @Test("a request after a flush opens a new window") + func requestAfterFlushSchedulesAgain() { + let scheduler = ManualScheduler() + var fired: [String] = [] + let coalescer = RefreshCoalescer(delay: 20, scheduler: scheduler.schedule) { fired.append($0) } + + coalescer.request(reason: "bg") + scheduler.runAll() + coalescer.request(reason: "bg") + + #expect(scheduler.scheduled.count == 1) + scheduler.runAll() + #expect(fired == ["bg", "bg"]) + } + + @Test("cancel drops the pending call") + func cancelDropsPending() { + let scheduler = ManualScheduler() + var fired: [String] = [] + let coalescer = RefreshCoalescer(delay: 20, scheduler: scheduler.schedule) { fired.append($0) } + + coalescer.request(reason: "bg") + coalescer.cancel() + #expect(!coalescer.isPending) + scheduler.runAll() + #expect(fired.isEmpty) + } + + @Test("a request after cancel fires only once") + func requestAfterCancelFiresOnce() { + let scheduler = ManualScheduler() + var fired: [String] = [] + let coalescer = RefreshCoalescer(delay: 20, scheduler: scheduler.schedule) { fired.append($0) } + + coalescer.request(reason: "bg") + coalescer.cancel() + coalescer.request(reason: "notLooping") + scheduler.runAll() + + #expect(fired == ["notLooping"]) + } +} diff --git a/Tests/RemoteCommandTrackerTests.swift b/Tests/RemoteCommandTrackerTests.swift new file mode 100644 index 000000000..5381ec1d0 --- /dev/null +++ b/Tests/RemoteCommandTrackerTests.swift @@ -0,0 +1,108 @@ +// LoopFollow +// RemoteCommandTrackerTests.swift + +import Foundation +@testable import LoopFollow +import Testing + +@MainActor +struct RemoteCommandTrackerTests { + @Test("begin, resolve and consume walk one key through its states") + func beginResolveConsume() { + let tracker = RemoteCommandTracker(timeout: 60) + tracker.begin(key: "K") + #expect(tracker.isBusy(key: "K")) + + tracker.resolve(key: "K", success: true, message: "Done") + #expect(tracker.states["K"] == .done(success: true, message: "Done")) + #expect(!tracker.isBusy(key: "K")) + + tracker.consume(key: "K") + #expect(tracker.states["K"] == nil) + } + + @Test("resolve without a pending command is ignored") + func resolveWhenIdle() { + let tracker = RemoteCommandTracker(timeout: 60) + tracker.resolve(key: "K", success: true, message: "Done") + #expect(tracker.states["K"] == nil) + } + + @Test("a pending command times out") + func timeout() async throws { + let tracker = RemoteCommandTracker(timeout: 0.05) + tracker.begin(key: "K") + try await Task.sleep(for: .milliseconds(400)) + #expect(tracker.states["K"] == .done(success: false, message: RemoteCommandTracker.timeoutMessage)) + } + + @Test("acks for unknown keys are ignored") + func unknownKey() { + let tracker = RemoteCommandTracker(timeout: 60) + #expect(tracker.handleNotification(userInfo: ["command_type": "carbs_delete", "command_status": "success", "sync_identifier": "nope"]) == false) + #expect(tracker.handleNotification(userInfo: ["command_type": "delete_meal", "command_status": "success", "command_id": "nope"]) == false) + #expect(tracker.states.isEmpty) + } + + @Test("TRC ack resolves the meal registered under its command id, once") + func trioAdapter() { + let tracker = RemoteCommandTracker(timeout: 60) + tracker.trioAcks.register(commandID: "CMD", key: "MEAL") + tracker.begin(key: "MEAL") + let ack: [AnyHashable: Any] = [ + "command_status": "failed", + "command_type": "delete_meal", + "command_id": "CMD", + "meal_id": "MEAL", + "result": "not_found", + "aps": ["alert": ["title": "Command Failed", "body": "Meal not found"]], + ] + #expect(tracker.handleNotification(userInfo: ack) == true) + #expect(tracker.states["MEAL"] == .done(success: false, message: "Meal not found")) + + tracker.begin(key: "MEAL") + #expect(tracker.handleNotification(userInfo: ack) == false) + #expect(tracker.isBusy(key: "MEAL")) + } + + @Test("TRC ack without an alert body falls back to the result message") + func trioDefaultMessage() { + let adapter = TRCMealAckAdapter() + adapter.register(commandID: "CMD", key: "MEAL") + let resolution = adapter.resolution(for: ["command_status": "success", "command_id": "CMD", "result": "updated"]) + #expect(resolution == RemoteCommandTracker.Resolution(key: "MEAL", success: true, message: "Meal updated.")) + } + + @Test("Loop ack resolves by sync identifier") + func loopAdapter() { + let tracker = RemoteCommandTracker(timeout: 60) + tracker.begin(key: "SYNC-1") + #expect(tracker.handleNotification(userInfo: ["command_type": "carbs_edit", "command_status": "success", "sync_identifier": "SYNC-1"]) == true) + #expect(tracker.states["SYNC-1"] == .done(success: true, message: "Carb entry updated.")) + #expect(LoopCarbAckAdapter.resolution(for: ["command_type": "meal", "command_status": "success", "sync_identifier": "SYNC-1"]) == nil) + #expect(LoopCarbAckAdapter.resolution(for: ["command_type": "carbs_delete", "command_status": "failed"]) == nil) + } + + @Test("Loop profile switch acks resolve the profile named in the message") + func loopProfileAdapter() { + let tracker = RemoteCommandTracker(timeout: 60) + let sickDay = LoopProfileAckAdapter.key(for: "Sick Day") + tracker.begin(key: sickDay) + let lateSport: [AnyHashable: Any] = [ + "command_type": "profile_switch", + "command_status": "success", + "aps": ["alert": ["title": "Command Successful", "body": "Profile 'Sport' activated"]], + ] + #expect(tracker.handleNotification(userInfo: lateSport) == false) + #expect(tracker.isBusy(key: sickDay)) + let failed: [AnyHashable: Any] = [ + "command_type": "profile_switch", + "command_status": "failed", + "aps": ["alert": ["title": "Command Failed", "body": "Command failed: Profile 'Sick Day' not activated: Communication Failure"]], + ] + #expect(tracker.handleNotification(userInfo: failed) == true) + #expect(tracker.states[sickDay] == .done(success: false, message: "Command failed: Profile 'Sick Day' not activated: Communication Failure")) + #expect(LoopProfileAckAdapter.resolution(for: ["command_type": "profile_switch", "command_status": "failed"]) == nil) + #expect(LoopProfileAckAdapter.resolution(for: ["command_type": "carbs_edit", "command_status": "success"]) == nil) + } +} diff --git a/Tests/ScheduledBasalSegmentsTests.swift b/Tests/ScheduledBasalSegmentsTests.swift new file mode 100644 index 000000000..2d70c8029 --- /dev/null +++ b/Tests/ScheduledBasalSegmentsTests.swift @@ -0,0 +1,73 @@ +// LoopFollow +// ScheduledBasalSegmentsTests.swift + +import Foundation +@testable import LoopFollow +import Testing + +@Suite(.serialized) +struct ScheduledBasalSegmentsTests { + private let schedule: [ProfileManager.TimeValue] = [ + .init(timeAsSeconds: 0, value: 0.2), + .init(timeAsSeconds: 4 * 3600, value: 0.2), + .init(timeAsSeconds: 6 * 3600 + 1800, value: 0.6), + .init(timeAsSeconds: 14 * 3600 + 1800, value: 0.65), + .init(timeAsSeconds: 16 * 3600 + 1800, value: 0.5), + .init(timeAsSeconds: 20 * 3600 + 1800, value: 0.25), + ] + + private func withSchedule(_ body: (ProfileManager) -> Void) { + let manager = ProfileManager.shared + let savedSchedule = manager.basalSchedule + let savedTimeZone = manager.timezone + defer { + manager.basalSchedule = savedSchedule + manager.timezone = savedTimeZone + } + manager.basalSchedule = schedule + manager.timezone = TimeZone(secondsFromGMT: 3 * 3600)! + body(manager) + } + + private func utc(_ string: String) -> Date { + ISO8601DateFormatter().date(from: string)! + } + + @Test("splits at a schedule entry between start and end") + func splitsAtScheduleEntry() { + withSchedule { manager in + let segments = manager.scheduledBasalSegments(from: utc("2026-10-28T13:27:55Z"), to: utc("2026-10-28T13:48:00Z")) + #expect(segments.map(\.rate) == [0.65, 0.5]) + #expect(segments.map(\.start) == [utc("2026-10-28T13:27:55Z"), utc("2026-10-28T13:30:00Z")]) + #expect(manager.basalRate(at: utc("2026-10-28T13:38:00Z")) == 0.5) + } + } + + @Test("splits at midnight in the profile time zone") + func splitsAtMidnight() { + withSchedule { manager in + let segments = manager.scheduledBasalSegments(from: utc("2026-10-28T20:58:00Z"), to: utc("2026-10-28T21:10:00Z")) + #expect(segments.map(\.rate) == [0.25, 0.2]) + #expect(segments.map(\.start) == [utc("2026-10-28T20:58:00Z"), utc("2026-10-28T21:00:00Z")]) + } + } + + @Test("splits at the schedule's clock time on a daylight saving change day") + func splitsAtClockTimeAcrossDST() { + withSchedule { manager in + manager.timezone = TimeZone(identifier: "Europe/Stockholm")! + // 2026-10-25 falls back from UTC+2 to UTC+1 at 03:00, so 16:30 local is 15:30Z + let segments = manager.scheduledBasalSegments(from: utc("2026-10-25T15:00:00Z"), to: utc("2026-10-25T16:00:00Z")) + #expect(segments.map(\.rate) == [0.65, 0.5]) + #expect(segments.map(\.start) == [utc("2026-10-25T15:00:00Z"), utc("2026-10-25T15:30:00Z")]) + } + } + + @Test("skips entries that keep the same rate") + func skipsUnchangedRate() { + withSchedule { manager in + let segments = manager.scheduledBasalSegments(from: utc("2026-10-28T00:00:00Z"), to: utc("2026-10-28T02:00:00Z")) + #expect(segments.map(\.rate) == [0.2]) + } + } +} diff --git a/Tests/TRCMealCommandPayloadTests.swift b/Tests/TRCMealCommandPayloadTests.swift new file mode 100644 index 000000000..34f21c484 --- /dev/null +++ b/Tests/TRCMealCommandPayloadTests.swift @@ -0,0 +1,65 @@ +// LoopFollow +// TRCMealCommandPayloadTests.swift + +import Foundation +@testable import LoopFollow +import Testing + +struct TRCMealCommandPayloadTests { + private func encode(_ payload: CommandPayload) throws -> [String: Any] { + let data = try JSONEncoder().encode(payload) + return try JSONSerialization.jsonObject(with: data) as? [String: Any] ?? [:] + } + + @Test("edit_meal carries ids, explicit macros and the new time") + func editMealKeys() throws { + let payload = CommandPayload( + user: "u", + commandType: .editMeal, + timestamp: 1_700_000_000, + carbs: 45, + protein: 15, + fat: 0, + scheduledTime: 1_700_000_600, + commandID: "CMD", + mealID: "MEAL" + ) + let json = try encode(payload) + #expect(json["command_type"] as? String == "edit_meal") + #expect(json["command_id"] as? String == "CMD") + #expect(json["meal_id"] as? String == "MEAL") + #expect(json["scheduled_time"] as? Double == 1_700_000_600) + #expect(json["carbs"] as? Int == 45) + #expect(json["protein"] as? Int == 15) + #expect(json["fat"] as? Int == 0) + } + + @Test("delete_meal carries no macros") + func deleteMealKeys() throws { + let payload = CommandPayload( + user: "u", + commandType: .deleteMeal, + timestamp: 1_700_000_000, + commandID: "CMD", + mealID: "MEAL" + ) + let json = try encode(payload) + #expect(json["command_type"] as? String == "delete_meal") + #expect(json["meal_id"] as? String == "MEAL") + #expect(json["carbs"] == nil) + #expect(json["fat"] == nil) + #expect(json["protein"] == nil) + #expect(json["scheduled_time"] == nil) + #expect(json["bolus_amount"] == nil) + } + + @Test("collapse id is the command id for meal mutations only") + func collapseID() { + let edit = CommandPayload(user: "u", commandType: .editMeal, timestamp: 0, commandID: "CMD", mealID: "M") + let delete = CommandPayload(user: "u", commandType: .deleteMeal, timestamp: 0, commandID: "CMD2", mealID: "M") + let meal = CommandPayload(user: "u", commandType: .meal, timestamp: 0, carbs: 10, commandID: "CMD3") + #expect(edit.apnsCollapseID == "CMD") + #expect(delete.apnsCollapseID == "CMD2") + #expect(meal.apnsCollapseID == "meal") + } +} diff --git a/Tests/TreatmentHistoryEntriesTests.swift b/Tests/TreatmentHistoryEntriesTests.swift new file mode 100644 index 000000000..faf84b39d --- /dev/null +++ b/Tests/TreatmentHistoryEntriesTests.swift @@ -0,0 +1,58 @@ +// LoopFollow +// TreatmentHistoryEntriesTests.swift + +import Foundation +@testable import LoopFollow +import Testing + +struct TreatmentHistoryEntriesTests { + private typealias Entry = [String: AnyObject] + + private func history(_ fields: [String: Any]) -> TreatmentHistoryEntries { + var entry: Entry = ["_id": "ns1" as AnyObject] + for (key, value) in fields { + entry[key] = value as AnyObject + } + return Treatment.historyEntries(from: entry, date: 100) { 120 } + } + + @Test("a Meal Bolus yields a carb entry and a bolus entry") + func mealBolus() { + let result = history(["eventType": "Meal Bolus", "carbs": 30.0, "insulin": 2.5]) + #expect(result.entries.map(\.type) == [.carb, .bolusManual]) + #expect(result.carb?.id == "ns1-carb") + #expect(result.bolus?.id == "ns1-bolus") + } + + @Test("automatic and SMB boluses are listed as automatic") + func automaticBolus() { + let automatic = history(["eventType": "Correction Bolus", "insulin": 0.4, "automatic": true]) + #expect(automatic.bolus?.type == .bolusAutomatic) + #expect(automatic.sawAutomatic) + #expect(!automatic.sawSMB) + + let smb = history(["eventType": "Correction Bolus", "insulin": 0.4, "type": "SMB"]) + #expect(smb.bolus?.type == .bolusAutomatic) + #expect(smb.sawSMB) + #expect(!smb.sawAutomatic) + + let manual = history(["eventType": "Bolus", "insulin": 1.0]) + #expect(manual.bolus?.type == .bolusManual) + #expect(!manual.sawSMB && !manual.sawAutomatic) + } + + @Test("entries the Treatments list skips yield nothing") + func skipped() { + #expect(history(["eventType": "Carb Correction", "carbs": 0.0]).entries.isEmpty) + #expect(history(["eventType": "Bolus", "insulin": 0.0]).entries.isEmpty) + #expect(history(["eventType": "Temporary Override", "duration": 0.0]).entries.isEmpty) + #expect(history(["eventType": "Temporary Target", "duration": 30.0]).entries.isEmpty) + #expect(history(["eventType": "Note", "notes": "hello"]).entries.isEmpty) + } + + @Test("overrides and temp targets with a duration are listed") + func bands() { + #expect(history(["eventType": "Temporary Override", "duration": 60.0, "reason": "Run"]).entries.first?.type == .override) + #expect(history(["eventType": "Temporary Target", "duration": 30.0, "targetBottom": 140.0, "targetTop": 140.0]).entries.first?.type == .tempTarget) + } +} diff --git a/Tests/TrioMealTreatmentTests.swift b/Tests/TrioMealTreatmentTests.swift new file mode 100644 index 000000000..71fe70771 --- /dev/null +++ b/Tests/TrioMealTreatmentTests.swift @@ -0,0 +1,85 @@ +// LoopFollow +// TrioMealTreatmentTests.swift + +import Foundation +@testable import LoopFollow +import Testing + +struct TrioMealTreatmentTests { + private typealias Entry = [String: AnyObject] + + private let rootID = "11111111-1111-4111-8111-111111111111" + private let familyID = "22222222-2222-4222-8222-222222222222" + + private func entry(id: String, fpuID: String? = nil, carbs: Double = 45, fat: Double = 0, protein: Double = 0, enteredBy: String = "Trio", eventType: String = "Carb Correction", notes: String? = nil) -> Entry { + var result: Entry = [ + "_id": "mongo-\(id)" as AnyObject, + "id": id as AnyObject, + "enteredBy": enteredBy as AnyObject, + "eventType": eventType as AnyObject, + "carbs": carbs as AnyObject, + "fat": fat as AnyObject, + "protein": protein as AnyObject, + ] + if let fpuID { result["fpuID"] = fpuID as AnyObject } + if let notes { result["notes"] = notes as AnyObject } + return result + } + + @Test("root with fpuID is not a child") + func root() { + let meal = TrioMealTreatment(nightscoutEntry: entry(id: rootID, fpuID: familyID, fat: 20, protein: 15, notes: " 📡 "), date: 0) + #expect(meal?.isFPUChild == false) + #expect(meal?.mealID.uuidString == rootID) + #expect(meal?.fpuID?.uuidString == familyID) + #expect(meal?.fat == 20) + #expect(meal?.protein == 15) + #expect(meal?.note == "📡") + #expect(meal?.nightscoutID == "mongo-\(rootID)") + } + + @Test("child carries the family id as both id and fpuID") + func child() { + let meal = TrioMealTreatment(nightscoutEntry: entry(id: familyID, fpuID: familyID, carbs: 12), date: 0) + #expect(meal?.isFPUChild == true) + #expect(meal?.mealID.uuidString == familyID) + } + + @Test("without fpuID the document is a root") + func missingFPUID() { + #expect(TrioMealTreatment(nightscoutEntry: entry(id: rootID), date: 0)?.isFPUChild == false) + #expect(TrioMealTreatment(nightscoutEntry: entry(id: rootID), date: 0)?.fpuID == nil) + } + + @Test("rejects non-Trio, non-carb, non-UUID and empty entries") + func rejects() { + #expect(TrioMealTreatment(nightscoutEntry: entry(id: rootID, enteredBy: "loop://phone"), date: 0) == nil) + #expect(TrioMealTreatment(nightscoutEntry: entry(id: rootID, eventType: "Meal Bolus"), date: 0) == nil) + #expect(TrioMealTreatment(nightscoutEntry: entry(id: "not-a-uuid"), date: 0) == nil) + #expect(TrioMealTreatment(nightscoutEntry: entry(id: rootID, carbs: 0), date: 0) == nil) + } + + @Test("edit window is 24 h back and 12 h ahead") + func window() { + let now = Date(timeIntervalSince1970: 1_700_000_000) + func meal(offsetHours: Double) -> TrioMealTreatment? { + TrioMealTreatment(nightscoutEntry: entry(id: rootID), date: now.timeIntervalSince1970 + offsetHours * 3600) + } + #expect(meal(offsetHours: -23)?.isWithinEditWindow(now: now) == true) + #expect(meal(offsetHours: -25)?.isWithinEditWindow(now: now) == false) + #expect(meal(offsetHours: 11)?.isWithinEditWindow(now: now) == true) + #expect(meal(offsetHours: 13)?.isWithinEditWindow(now: now) == false) + } + + @Test("actions need TRC, a Trio device and both commands advertised") + func gating() { + let both = ["meal", "edit_meal", "delete_meal"] + #expect(TrioMealTreatment.remoteActionsAvailable(remoteType: .trc, device: "Trio", remoteCommands: both) == true) + #expect(TrioMealTreatment.remoteActionsAvailable(remoteType: .trc, device: "Trio", remoteCommands: ["meal"]) == false) + #expect(TrioMealTreatment.remoteActionsAvailable(remoteType: .trc, device: "Loop", remoteCommands: both) == false) + #expect(TrioMealTreatment.remoteActionsAvailable(remoteType: .loopAPNS, device: "Trio", remoteCommands: both) == false) + #expect(TrioMealTreatment.remoteActionsAvailable(remoteType: .none, device: "Trio", remoteCommands: both) == false) + #expect(TrioMealTreatment.remoteControlActive(remoteType: .trc, device: "Trio") == true) + #expect(TrioMealTreatment.remoteControlActive(remoteType: .trc, device: "Loop") == false) + } +} diff --git a/Tests/WatchProtocolTests.swift b/Tests/WatchProtocolTests.swift new file mode 100644 index 000000000..ebb5231b0 --- /dev/null +++ b/Tests/WatchProtocolTests.swift @@ -0,0 +1,104 @@ +// LoopFollow +// WatchProtocolTests.swift + +import Foundation +@testable import LoopFollow +import Testing + +struct WatchProtocolTests { + private let t0 = Date(timeIntervalSince1970: 1_790_000_000) + + private func state(alarm: WatchAlarm? = nil) -> WatchState { + WatchState( + builtAt: t0, + source: .phone, + phoneBuiltAt: t0, + snapshot: GlucoseSnapshot(glucose: 142, delta: -3, trend: .downSlight, updatedAt: t0, iob: 1.2, cob: 15, projected: 120, unit: .mmol, isNotLooping: false), + readings: [WatchReading(t: t0.timeIntervalSince1970 - 300, v: 145), WatchReading(t: t0.timeIntervalSince1970, v: 142)], + predictions: [WatchPredictionLine(kind: .iob, points: [WatchReading(t: t0.timeIntervalSince1970 + 300, v: 138)])], + cone: [WatchConePoint(t: t0.timeIntervalSince1970 + 300, low: 120, high: 150)], + boluses: [WatchReading(t: t0.timeIntervalSince1970 - 600, v: 2.5)], + carbs: [WatchReading(t: t0.timeIntervalSince1970 - 600, v: 30)], + overrides: [ + WatchOverride(start: t0.timeIntervalSince1970 - 7200, end: t0.timeIntervalSince1970 - 3600, name: "Sleep", percent: nil), + WatchOverride(start: t0.timeIntervalSince1970 - 1800, end: t0.timeIntervalSince1970 + 1800, name: "Exercise", percent: 80), + ], + lowMgdl: 70, + highMgdl: 180, + colorBGText: true, + infoRows: [WatchInfoRow(name: "IOB", value: "1,2", level: nil), WatchInfoRow(name: "SAGE", value: "9d 2h", level: .warning)], + stats: WatchStats(lowPercent: "2%", inRangePercent: "80%", highPercent: "18%", avgBG: "8,1", estA1CTitle: "GMI:", estA1C: "6,6", stdDevTitle: "Std Dev:", stdDev: "2,3", pieLow: 2, pieRange: 80, pieHigh: 18), + device: "Trio", + alarm: alarm, + remote: WatchRemoteConfig( + kind: .trio, + maxBolus: 5, + bolusIncrement: 0.05, + maxCarbs: 80, + maxFat: 30, + maxProtein: 30, + mealWithBolus: true, + mealWithFatProtein: false, + recommendedBolus: 0.4, + recommendedBolusAt: t0, + overridePresets: [WatchPreset(id: "Exercise", name: "Exercise", detail: "80 %, 1 h")], + tempTargetPresets: [], + quickPickBoluses: [1, 2.5], + quickPickMeals: [WatchMealPick(carbs: 45, fat: 0, protein: 0, bolus: 0)] + ) + ) + } + + @Test("State survives the round trip through the application context") + func stateRoundTrip() throws { + let original = state(alarm: WatchAlarm(id: UUID(), name: "Low")) + let data = try #require(original.encoded()) + #expect(WatchState.decode(data) == original) + } + + @Test("The active override is the one whose period contains the time") + func activeOverride() { + let state = state() + #expect(state.activeOverride(at: t0)?.name == "Exercise") + #expect(state.activeOverride(at: t0.addingTimeInterval(-5400))?.name == "Sleep") + #expect(state.activeOverride(at: t0.addingTimeInterval(-3000)) == nil) + #expect(state.activeOverride(at: t0.addingTimeInterval(1800)) == nil) + } + + @Test("Commands keep their action, id and creation time") + func commandRoundTrip() throws { + let actions: [WatchCommand.Action] = [ + .bolus(units: 1.25), + .meal(carbs: 30, fat: 10, protein: 5, bolus: 2, consumedAt: t0), + .startOverride(presetID: "Exercise"), + .cancelOverride, + .startTempTarget(presetID: UUID().uuidString), + .cancelTempTarget, + .snoozeAlarm, + ] + for action in actions { + let command = WatchCommand(action: action) + let decoded = try JSONDecoder().decode(WatchCommand.self, from: JSONEncoder().encode(command)) + #expect(decoded == command) + } + } + + @Test("Glucose and delta follow the phone's unit formatting") + func glucoseFormatting() { + #expect(WatchFormat.glucose(142, unit: .mgdl) == "142") + #expect(WatchFormat.delta(-3, unit: .mgdl) == "-3") + #expect(WatchFormat.delta(4, unit: .mgdl) == "+4") + #expect(WatchFormat.delta(0.4, unit: .mgdl) == "0") + let oneMmol = WatchFormat.delta(GlucoseConversion.mmolToMgDl, unit: .mmol) + #expect(oneMmol.hasPrefix("+1") && oneMmol.count == 4) + #expect(!WatchFormat.delta(0.5, unit: .mmol).hasPrefix("+")) + } + + @Test("Range colors match the phone: red at or below low, yellow at or above high") + func rangeColors() { + #expect(WatchFormat.bgColor(70, lowMgdl: 70, highMgdl: 180) == .red) + #expect(WatchFormat.bgColor(71, lowMgdl: 70, highMgdl: 180) == .green) + #expect(WatchFormat.bgColor(180, lowMgdl: 70, highMgdl: 180) == .yellow) + #expect(WatchFormat.bgColor(250, lowMgdl: 70, highMgdl: 180, colored: false) == .primary) + } +} diff --git a/Tests/WatchRemoteTests.swift b/Tests/WatchRemoteTests.swift new file mode 100644 index 000000000..55c0c5a22 --- /dev/null +++ b/Tests/WatchRemoteTests.swift @@ -0,0 +1,138 @@ +// LoopFollow +// WatchRemoteTests.swift + +import Foundation +@testable import LoopFollow +import Testing + +struct WatchRemoteTests { + private let now = Date(timeIntervalSince1970: 1_790_000_000) + + private let limits = WatchCommandValidator.Limits( + maxBolus: 2, + bolusIncrement: 0.05, + maxCarbs: 60, + maxFat: 30, + maxProtein: 30, + mealWithBolus: true, + mealWithFatProtein: false + ) + + private func validate(_ action: WatchCommand.Action, _ kind: WatchRemoteConfig.Kind, limits: WatchCommandValidator.Limits? = nil) -> Result { + WatchCommandValidator.validate(action, kind: kind, limits: limits ?? self.limits, now: now) + } + + // MARK: - Validation + + @Test("Trio boluses round down to the increment and respect the maximum") + func trioBolus() { + #expect(validate(.bolus(units: 1.27), .trio) == .success(.bolus(units: 1.25))) + #expect((try? validate(.bolus(units: 2.5), .trio).get()) == nil) + #expect((try? validate(.bolus(units: 0.01), .trio).get()) == nil) + } + + @Test("Loop boluses are sent as entered, up to the maximum") + func loopBolus() { + #expect(validate(.bolus(units: 1.27), .loop) == .success(.bolus(units: 1.27))) + #expect((try? validate(.bolus(units: 2.01), .loop).get()) == nil) + } + + @Test("Loop meals are carbs only, within the time window Loop accepts") + func loopMeal() { + #expect((try? validate(.meal(carbs: 30, fat: 0, protein: 0, bolus: 0, consumedAt: now), .loop).get()) != nil) + #expect((try? validate(.meal(carbs: 30, fat: 0, protein: 0, bolus: 1, consumedAt: now), .loop).get()) == nil) + #expect((try? validate(.meal(carbs: 70, fat: 0, protein: 0, bolus: 0, consumedAt: now), .loop).get()) == nil) + #expect((try? validate(.meal(carbs: 30, fat: 0, protein: 0, bolus: 0, consumedAt: now.addingTimeInterval(-13 * 3600)), .loop).get()) == nil) + #expect((try? validate(.meal(carbs: 30, fat: 0, protein: 0, bolus: 0, consumedAt: now.addingTimeInterval(2 * 3600)), .loop).get()) == nil) + } + + @Test("Trio meals follow the phone's Meal settings") + func trioMeal() { + #expect((try? validate(.meal(carbs: 30, fat: 10, protein: 0, bolus: 0, consumedAt: now), .trio).get()) == nil) + #expect(validate(.meal(carbs: 30, fat: 0, protein: 0, bolus: 1.27, consumedAt: now), .trio) + == .success(.meal(carbs: 30, fat: 0, protein: 0, bolus: 1.25, consumedAt: now))) + var noBolus = limits + noBolus.mealWithBolus = false + #expect((try? validate(.meal(carbs: 30, fat: 0, protein: 0, bolus: 1, consumedAt: now), .trio, limits: noBolus).get()) == nil) + } + + @Test("Temp targets are refused for Loop") + func loopTempTarget() { + #expect((try? validate(.startTempTarget(presetID: "x"), .loop).get()) == nil) + #expect((try? validate(.startTempTarget(presetID: "x"), .trio).get()) != nil) + } + + // MARK: - Signing key + + @Test("LoopFollow's own key signs when both apps share a team or no remote team is set") + func signingKey() { + let lf = (key: "LF", keyID: "LFKEY") + let remote = (key: "R", keyID: "RKEY") + #expect(RemoteSigningKey.resolve(lfTeamID: "T1", remoteTeamID: "T1", lfKey: lf, remoteKey: remote).key == "LF") + #expect(RemoteSigningKey.resolve(lfTeamID: "T1", remoteTeamID: "", lfKey: lf, remoteKey: remote).key == "LF") + let other = RemoteSigningKey.resolve(lfTeamID: "T1", remoteTeamID: "T2", lfKey: lf, remoteKey: remote) + #expect(other.key == "R") + #expect(other.teamID == "T2") + } + + // MARK: - Device status + + @Test("Loop devicestatus gives IOB, COB, recommendation and the prediction") + func loopDeviceStatus() { + let status: [String: Any] = [ + "loop": [ + "timestamp": "2026-10-19T08:00:00Z", + "iob": ["iob": 1.2], + "cob": ["cob": 15.0], + "recommendedBolus": 0.3, + "predicted": ["startDate": "2026-10-19T08:00:00Z", "values": [120.0, 125.0, 130.0]], + ], + ] + let summary = DeviceStatusSummary.parse(status, showsCone: false) + #expect(summary.iob == 1.2) + #expect(summary.cob == 15) + #expect(summary.recommendedBolus == 0.3) + #expect(summary.predictions.first?.kind == .loop) + #expect(summary.predictions.first?.points.map(\.v) == [120, 125, 130]) + #expect(summary.loopAt == DeviceStatusSummary.date("2026-10-19T08:00:00.000Z")) + } + + @Test("Trio devicestatus gives lines or the cone, matching what the phone showed") + func trioDeviceStatus() { + let status: [String: Any] = [ + "openaps": [ + "iob": ["iob": 1.5], + "suggested": [ + "timestamp": "2026-10-19T08:00:00.000Z", + "COB": 20.0, + "predBGs": ["IOB": [140.0, 138.0], "ZT": [140.0, 130.0], "COB": [140.0, 150.0]], + ], + ], + ] + let lines = DeviceStatusSummary.parse(status, showsCone: false) + #expect(lines.iob == 1.5) + #expect(lines.cob == 20) + #expect(Set(lines.predictions.map(\.kind)) == [.iob, .zt, .cob]) + + let cone = DeviceStatusSummary.parse(status, showsCone: true) + #expect(cone.predictions.isEmpty) + #expect(cone.cone.map(\.low) == [139, 130]) + #expect(cone.cone.map(\.high) == [141, 150]) + } + + // MARK: - Loop override payload + + @Test("Loop override payload carries name, duration and return info") + func loopOverridePayload() { + let info = ReturnNotificationInfo(productionEnvironment: false, deviceToken: "tok", bundleId: "b", teamId: "t", keyId: "k", apnsKey: "a") + let payload = LoopAPNSCommands.overridePayload(presetName: "Running", duration: 3600, returnInfo: info, now: now) + #expect(payload["override-name"] as? String == "Running") + #expect(payload["override-duration-minutes"] as? Int == 60) + #expect(payload["alert"] as? String == "Running Temporary Override (1h 0m)") + #expect((payload["return_notification"] as? [String: Any])?["device_token"] as? String == "tok") + + let cancel = LoopAPNSCommands.cancelOverridePayload(returnInfo: nil, now: now) + #expect(cancel["cancel-temporary-override"] as? String == "true") + #expect(cancel["return_notification"] == nil) + } +} diff --git a/WatchShared/WatchCommand.swift b/WatchShared/WatchCommand.swift new file mode 100644 index 000000000..5668fb463 --- /dev/null +++ b/WatchShared/WatchCommand.swift @@ -0,0 +1,69 @@ +// LoopFollow +// WatchCommand.swift + +import Foundation + +/// A request from the watch. The phone validates it against its own settings and +/// guardrails and sends it with the same code path as its Remote screens. +struct WatchCommand: Codable, Equatable { + static let messageKey = "watchCommand" + /// The phone ignores commands older than this, so a delayed delivery is never acted on. + static let maxAge: TimeInterval = 60 + + enum Action: Codable, Hashable { + case bolus(units: Double) + /// Loop sends carbs only; Trio may add fat, protein and a bolus in the same command. + case meal(carbs: Double, fat: Double, protein: Double, bolus: Double, consumedAt: Date) + case startOverride(presetID: String) + case cancelOverride + case startTempTarget(presetID: String) + case cancelTempTarget + case snoozeAlarm + } + + let id: UUID + let createdAt: Date + let action: Action + + init(action: Action) { + id = UUID() + createdAt = Date() + self.action = action + } +} + +extension WatchCommand.Action { + /// Short name for logs. + var name: String { + switch self { + case .bolus: "bolus" + case .meal: "meal" + case .startOverride: "startOverride" + case .cancelOverride: "cancelOverride" + case .startTempTarget: "startTempTarget" + case .cancelTempTarget: "cancelTempTarget" + case .snoozeAlarm: "snoozeAlarm" + } + } + + /// Whether the command can deliver insulin. + var deliversInsulin: Bool { + switch self { + case .bolus: true + case let .meal(_, _, _, bolus, _): bolus > 0 + default: false + } + } +} + +struct WatchCommandReply: Codable, Equatable { + static let messageKey = "watchCommandReply" + + let success: Bool + let message: String +} + +enum WatchMessageKey { + /// Watch → phone: asks for the current state; the reply carries `WatchState.contextKey`. + static let requestState = "requestState" +} diff --git a/WatchShared/WatchFormat.swift b/WatchShared/WatchFormat.swift new file mode 100644 index 000000000..24502d318 --- /dev/null +++ b/WatchShared/WatchFormat.swift @@ -0,0 +1,104 @@ +// LoopFollow +// WatchFormat.swift + +import Foundation +import SwiftUI + +/// Display formatting shared by the watch app and its complications. Values +/// arrive in mg/dL and are rendered in the unit chosen on the phone. +enum WatchFormat { + /// A reading this old is shown struck through, as on the phone. + static let staleAfter: TimeInterval = 12 * 60 + + private static let mgdlFormatter: NumberFormatter = { + let nf = NumberFormatter() + nf.numberStyle = .decimal + nf.maximumFractionDigits = 0 + nf.locale = .current + return nf + }() + + private static let oneDecimalFormatter: NumberFormatter = { + let nf = NumberFormatter() + nf.numberStyle = .decimal + nf.minimumFractionDigits = 1 + nf.maximumFractionDigits = 1 + nf.locale = .current + return nf + }() + + private static let insulinFormatter: NumberFormatter = { + let nf = NumberFormatter() + nf.numberStyle = .decimal + nf.minimumFractionDigits = 0 + nf.maximumFractionDigits = 2 + nf.locale = .current + return nf + }() + + static func glucose(_ mgdl: Double, unit: GlucoseSnapshot.Unit) -> String { + switch unit { + case .mgdl: + return mgdlFormatter.string(from: NSNumber(value: round(mgdl))) ?? "\(Int(round(mgdl)))" + case .mmol: + let mmol = GlucoseConversion.toMmol(mgdl) + return oneDecimalFormatter.string(from: NSNumber(value: mmol)) ?? String(format: "%.1f", mmol) + } + } + + static func delta(_ mgdl: Double, unit: GlucoseSnapshot.Unit) -> String { + switch unit { + case .mgdl: + let v = Int(round(mgdl)) + if v == 0 { return "0" } + return v > 0 ? "+\(v)" : "\(v)" + case .mmol: + let mmol = GlucoseConversion.toMmol(mgdl) + let d = abs(mmol) < 0.05 ? 0.0 : mmol + let formatted = oneDecimalFormatter.string(from: NSNumber(value: abs(d))) ?? String(format: "%.1f", abs(d)) + if d == 0 { return formatted } + return d > 0 ? "+\(formatted)" : "-\(formatted)" + } + } + + static func trendArrow(_ trend: GlucoseSnapshot.Trend) -> String { + switch trend { + case .upFast: "↑↑" + case .up: "↑" + case .upSlight: "↗" + case .flat: "→" + case .downSlight: "↘︎" + case .down: "↓" + case .downFast: "↓↓" + case .unknown: "–" + } + } + + static func insulin(_ units: Double) -> String { + insulinFormatter.string(from: NSNumber(value: units)) ?? String(format: "%.2f", units) + } + + static func iob(_ units: Double?) -> String { + guard let units else { return "—" } + return (oneDecimalFormatter.string(from: NSNumber(value: units)) ?? String(format: "%.1f", units)) + " U" + } + + static func cob(_ grams: Double?) -> String { + guard let grams else { return "—" } + return "\(Int(round(grams))) g" + } + + static func minutesAgo(_ date: Date, now: Date = Date()) -> String { + let minutes = max(0, Int(now.timeIntervalSince(date) / 60)) + return "\(minutes) min" + } + + /// Phone semantics: yellow at or above high, red at or below low, green in + /// range. White when the phone's "Color BG Text" setting is off. + static func bgColor(_ mgdl: Double, lowMgdl: Double, highMgdl: Double, colored: Bool = true) -> Color { + guard colored else { return .primary } + if mgdl >= highMgdl { return .yellow } + if mgdl <= lowMgdl { return .red } + return .green + } +} diff --git a/WatchShared/WatchState.swift b/WatchShared/WatchState.swift new file mode 100644 index 000000000..91c73c69c --- /dev/null +++ b/WatchShared/WatchState.swift @@ -0,0 +1,189 @@ +// LoopFollow +// WatchState.swift + +import Foundation + +/// Everything the watch shows, built by the phone and delivered as the +/// WatchConnectivity application context. The watch never talks to Nightscout, +/// Dexcom or APNs itself, so no credentials are part of this state. +struct WatchState: Codable, Equatable { + static let contextKey = "watchState" + + /// When this state was built, by the phone or by the watch itself. + var builtAt: Date + var source: WatchDataSource + /// When the phone last built a state; info rows, presets and guardrails date from then. + var phoneBuiltAt: Date + /// Latest glucose state, shared with the Live Activity. + var snapshot: GlucoseSnapshot? + /// BG readings for the last 24 hours, oldest first. + var readings: [WatchReading] + /// Prediction lines as drawn on the phone's main chart. + var predictions: [WatchPredictionLine] + /// Trio/OpenAPS forecast drawn as a band when the phone shows the cone. + var cone: [WatchConePoint] + var boluses: [WatchReading] + var carbs: [WatchReading] + /// Override periods in the last 24 hours, oldest first; nil from the watch's own fetch. + var overrides: [WatchOverride]? + /// Range thresholds used for coloring, in mg/dL (same as the phone). + var lowMgdl: Double + var highMgdl: Double + /// Mirrors the phone's "Color BG Text" setting. + var colorBGText: Bool + /// The phone's visible info table rows, in its order and with its coloring. + var infoRows: [WatchInfoRow] + /// The statistics shown under the phone's chart. + var stats: WatchStats? + /// "Loop", "Trio" or another uploader name; empty for CGM-only setups. + var device: String + /// The alarm currently sounding on the phone. + var alarm: WatchAlarm? + /// Present when remote commands from the watch are enabled on the phone. + var remote: WatchRemoteConfig? + + func encoded() -> Data? { + try? JSONEncoder().encode(self) + } + + static func decode(_ data: Data) -> WatchState? { + try? JSONDecoder().decode(WatchState.self, from: data) + } +} + +enum WatchDataSource: String, Codable { + /// Built and sent by the phone. + case phone + /// Fetched by the watch from Nightscout or Dexcom while the phone was out of reach. + case watch +} + +struct WatchReading: Codable, Equatable { + /// Unix epoch seconds. + let t: TimeInterval + /// mg/dL for glucose, units for boluses, grams for carbs. + let v: Double + + var date: Date { Date(timeIntervalSince1970: t) } +} + +/// One override period, as drawn in the band at the top of the phone's chart. +struct WatchOverride: Codable, Equatable { + /// Unix epoch seconds. + let start: TimeInterval + let end: TimeInterval + let name: String + /// Insulin needs as a percentage of normal; nil when unchanged. + let percent: Int? + + var startDate: Date { Date(timeIntervalSince1970: start) } + var endDate: Date { Date(timeIntervalSince1970: end) } +} + +extension WatchState { + /// The override running at `date`, if any. + func activeOverride(at date: Date) -> WatchOverride? { + overrides?.last { $0.startDate <= date && date < $0.endDate } + } +} + +/// The phone's statistics panel, formatted on the phone. +struct WatchStats: Codable, Equatable { + var lowPercent: String + var inRangePercent: String + var highPercent: String + var avgBG: String + var estA1CTitle: String + var estA1C: String + var stdDevTitle: String + var stdDev: String + var pieLow: Double + var pieRange: Double + var pieHigh: Double +} + +struct WatchInfoRow: Codable, Equatable { + let name: String + let value: String + /// Threshold coloring from the phone's Info Display settings; nil uses the default text color. + let level: InfoColorLevel? +} + +/// Threshold band of an info table value, shared by the phone and the watch. +enum InfoColorLevel: String, Codable { + case normal + case warning + case urgent +} + +struct WatchPredictionLine: Codable, Equatable { + /// Matches the phone's chart series and colors. + enum Kind: String, Codable { + case loop + case zt + case iob + case cob + case uam + } + + let kind: Kind + let points: [WatchReading] +} + +struct WatchConePoint: Codable, Equatable { + let t: TimeInterval + let low: Double + let high: Double + + var date: Date { Date(timeIntervalSince1970: t) } +} + +struct WatchAlarm: Codable, Equatable { + let id: UUID + let name: String +} + +struct WatchPreset: Codable, Equatable, Identifiable { + let id: String + let name: String + /// Preformatted on the phone in the user's units, e.g. "120 %, 1 h". + let detail: String + /// Raw values the watch needs to send the preset without the phone: the + /// duration (0 = indefinite) and, for temp targets, the target in mg/dL. + var durationSeconds: Double = 0 + var targetMgdl: Double? +} + +struct WatchRemoteConfig: Codable, Equatable { + enum Kind: String, Codable { + case loop + case trio + } + + let kind: Kind + let maxBolus: Double + let bolusIncrement: Double + let maxCarbs: Double + let maxFat: Double + let maxProtein: Double + /// Trio only: a meal may carry a bolus. + let mealWithBolus: Bool + /// Trio only: a meal may carry fat and protein. + let mealWithFatProtein: Bool + /// The AID's own recommendation, as shown in the phone's bolus screen. + var recommendedBolus: Double? + var recommendedBolusAt: Date? + let overridePresets: [WatchPreset] + /// Trio only. + let tempTargetPresets: [WatchPreset] + /// The phone's Quick-Pick suggestions for this time of day. + let quickPickBoluses: [Double] + let quickPickMeals: [WatchMealPick] +} + +struct WatchMealPick: Codable, Equatable { + let carbs: Double + let fat: Double + let protein: Double + let bolus: Double +} diff --git a/WatchShared/WatchStateStore.swift b/WatchShared/WatchStateStore.swift new file mode 100644 index 000000000..65c078618 --- /dev/null +++ b/WatchShared/WatchStateStore.swift @@ -0,0 +1,26 @@ +// LoopFollow +// WatchStateStore.swift + +import Foundation + +/// Keeps the last state received from the phone in the App Group container, so +/// the watch app shows data at launch and the complications can read it. +enum WatchStateStore { + private static let fileName = "watch-state.json" + + private static var fileURL: URL? { + FileManager.default + .containerURL(forSecurityApplicationGroupIdentifier: AppGroupID.current())? + .appendingPathComponent(fileName) + } + + static func save(_ data: Data) { + guard let url = fileURL else { return } + try? data.write(to: url, options: .atomic) + } + + static func load() -> WatchState? { + guard let url = fileURL, let data = try? Data(contentsOf: url) else { return nil } + return WatchState.decode(data) + } +} diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 8f46645c5..8bf9986ce 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -43,6 +43,40 @@ end APP_SUFFIX = loopfollow_app_suffix APP_IDENTIFIER = "com.#{TEAMID}.LoopFollow#{APP_SUFFIX}" LA_IDENTIFIER = "#{APP_IDENTIFIER}.LoopFollowLAExtension" +WATCH_IDENTIFIER = "#{APP_IDENTIFIER}.watchkitapp" +WATCH_WIDGETS_IDENTIFIER = "#{WATCH_IDENTIFIER}.widgets" +WATCH_SETUP_DOCS = "https://loopfollowdocs.org/build/lf-browser-build/#update-for-the-apple-watch-app" + +# The watch app needs two identifiers with the LoopFollow App Group, which +# existing browser builders have to add once by hand. +def watch_setup_error! + UI.user_error!( + "The Apple Watch app identifiers are not set up yet. Run the Add Identifiers action, " \ + "add the LoopFollow App Group to the LoopFollow Watch App and LoopFollow Watch Complications identifiers, " \ + "then build again. Instructions: #{WATCH_SETUP_DOCS}" + ) +end + +# Stops before match, whose read-only failure does not name the missing profile. +def ensure_watch_identifiers_exist + missing = [WATCH_IDENTIFIER, WATCH_WIDGETS_IDENTIFIER].reject { |id| Spaceship::ConnectAPI::BundleId.find(id) } + watch_setup_error! unless missing.empty? +end + +# Turns a signing failure caused by a watch identifier without the App Group into a pointer to the docs. +def explain_watch_setup_failure(error, log_dir = nil) + text = error.message.to_s + if log_dir + roots = [log_dir, File.join("..", log_dir), File.join(GITHUB_WORKSPACE.to_s, log_dir)].uniq + roots.flat_map { |root| Dir.glob(File.join(root, "**", "*.log")) }.uniq.each do |log| + text += File.read(log) rescue nil + end + end + + mentions_watch = [WATCH_IDENTIFIER, WATCH_WIDGETS_IDENTIFIER, "LoopFollowWatch"].any? { |id| text.include?(id) } + missing_app_group = text.match?(/App Groups capability|com\.apple\.security\.application-groups/i) + watch_setup_error! if mentions_watch && missing_app_group +end platform :ios do desc "Build Loop Follow" @@ -72,12 +106,16 @@ platform :ios do build_number: current_build_number ) + ensure_watch_identifiers_exist + match( type: "appstore", git_basic_authorization: Base64.strict_encode64("#{GITHUB_REPOSITORY_OWNER}:#{GH_PAT}"), app_identifier: [ APP_IDENTIFIER, - LA_IDENTIFIER + LA_IDENTIFIER, + WATCH_IDENTIFIER, + WATCH_WIDGETS_IDENTIFIER ] ) @@ -99,21 +137,42 @@ platform :ios do targets: ["LoopFollowLAExtensionExtension"] ) - gym( - export_method: "app-store", - scheme: "LoopFollow", - output_name: "LoopFollow.ipa", - configuration: "Release", - destination: 'generic/platform=iOS', - buildlog_path: 'buildlog', - export_options: { - provisioningProfiles: { - APP_IDENTIFIER => mapping[APP_IDENTIFIER], - LA_IDENTIFIER => mapping[LA_IDENTIFIER] - } - } + update_code_signing_settings( + path: "#{GITHUB_WORKSPACE}/LoopFollow.xcodeproj", + profile_name: mapping[WATCH_IDENTIFIER], + code_sign_identity: "iPhone Distribution", + targets: ["LoopFollowWatch"] ) + update_code_signing_settings( + path: "#{GITHUB_WORKSPACE}/LoopFollow.xcodeproj", + profile_name: mapping[WATCH_WIDGETS_IDENTIFIER], + code_sign_identity: "iPhone Distribution", + targets: ["LoopFollowWatchWidgets"] + ) + + begin + gym( + export_method: "app-store", + scheme: "LoopFollow", + output_name: "LoopFollow.ipa", + configuration: "Release", + destination: 'generic/platform=iOS', + buildlog_path: 'buildlog', + export_options: { + provisioningProfiles: { + APP_IDENTIFIER => mapping[APP_IDENTIFIER], + LA_IDENTIFIER => mapping[LA_IDENTIFIER], + WATCH_IDENTIFIER => mapping[WATCH_IDENTIFIER], + WATCH_WIDGETS_IDENTIFIER => mapping[WATCH_WIDGETS_IDENTIFIER] + } + } + ) + rescue => e + explain_watch_setup_failure(e, "buildlog") + raise + end + copy_artifacts( target_path: "artifacts", artifacts: ["*.mobileprovision", "*.ipa", "*.dSYM.zip"] @@ -168,6 +227,14 @@ platform :ios do Spaceship::ConnectAPI::BundleIdCapability::Type::APP_GROUPS ]) + configure_bundle_id("LoopFollow Watch App", WATCH_IDENTIFIER, [ + Spaceship::ConnectAPI::BundleIdCapability::Type::APP_GROUPS + ]) + + configure_bundle_id("LoopFollow Watch Complications", WATCH_WIDGETS_IDENTIFIER, [ + Spaceship::ConnectAPI::BundleIdCapability::Type::APP_GROUPS + ]) + end desc "Provision Certificates" @@ -188,7 +255,9 @@ platform :ios do git_basic_authorization: Base64.strict_encode64("#{GITHUB_REPOSITORY_OWNER}:#{GH_PAT}"), app_identifier: [ APP_IDENTIFIER, - LA_IDENTIFIER + LA_IDENTIFIER, + WATCH_IDENTIFIER, + WATCH_WIDGETS_IDENTIFIER ] ) end