diff --git a/.github/workflows/build_trio.yml b/.github/workflows/build_trio.yml index e835e18c35..e5be7b5a96 100644 --- a/.github/workflows/build_trio.yml +++ b/.github/workflows/build_trio.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: | diff --git a/.github/workflows/stale_issues.yml b/.github/workflows/stale_issues.yml index 6c027ccca1..7466016713 100644 --- a/.github/workflows/stale_issues.yml +++ b/.github/workflows/stale_issues.yml @@ -5,7 +5,7 @@ on: jobs: close-issues: - runs-on: ubuntu-latest + runs-on: ubuntu-26.04 permissions: issues: write pull-requests: write @@ -26,7 +26,7 @@ jobs: close-issues-triage: - runs-on: ubuntu-latest + runs-on: ubuntu-26.04 permissions: issues: write pull-requests: write diff --git a/.github/workflows/validate_secrets.yml b/.github/workflows/validate_secrets.yml index ea150c0c53..eefb1e68f2 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 }} diff --git a/.gitmodules b/.gitmodules index dda73c274d..2f32785274 100644 --- a/.gitmodules +++ b/.gitmodules @@ -13,6 +13,7 @@ [submodule "G7SensorKit"] path = G7SensorKit url = https://github.com/loopandlearn/G7SensorKit.git + branch = feat/trio-g7-direct [submodule "MinimedKit"] path = MinimedKit url = https://github.com/loopandlearn/MinimedKit.git diff --git a/ACKNOWLEDGEMENTS.md b/ACKNOWLEDGEMENTS.md new file mode 100644 index 0000000000..3e583ba60b --- /dev/null +++ b/ACKNOWLEDGEMENTS.md @@ -0,0 +1,35 @@ +# Acknowledgements + +Third-party material bundled with Trio, and the terms it is used under. + +## Alarm and alert sounds + +The themed alarm sets (Acoustic, Electronic) and the one-off tones were +created for Trio by a sound designer and are distributed as part of Trio. + +## Voice alerts + +Trio's spoken alert sounds were generated with +[Piper](https://github.com/rhasspy/piper) using voices from the +[rhasspy/piper-voices](https://huggingface.co/rhasspy/piper-voices) collection. + +### Amy (`voice_*_amy.caf`) + +- Model: `en/en_US/amy/medium` +- Source dataset: [MycroftAI/mimic3-voices](https://github.com/MycroftAI/mimic3-voices) +- Licence: **Creative Commons Attribution-ShareAlike 4.0 International + (CC BY-SA 4.0)** — +- Changes made by Trio: the generated phrases were looped to a minimum + duration of five seconds so that a spoken alert lasts long enough to be + heard as an alarm. No other processing was applied. +- These audio files are made available under CC BY-SA 4.0. This does not + change the licence of Trio itself. + +### Bryce (`voice_*_bryce.caf`) + +- Model: `en/en_US/bryce/medium` +- Source: (training data released into the public + domain by the speaker) +- Licence: **Public domain.** Credited here as a courtesy; no attribution is + required. +- Changes made by Trio: looped to a minimum duration of five seconds, as above. diff --git a/Config.xcconfig b/Config.xcconfig index f5b26b3b20..7443e63adc 100644 --- a/Config.xcconfig +++ b/Config.xcconfig @@ -19,7 +19,7 @@ TRIO_APP_GROUP_ID = group.org.nightscout.$(DEVELOPMENT_TEAM).trio.trio-app-group // The developers set the version numbers, please leave them alone APP_VERSION = 1.0.2 -APP_DEV_VERSION = 1.0.2.1 +APP_DEV_VERSION = 1.0.2.14 APP_BUILD_NUMBER = 1 COPYRIGHT_NOTICE = autoISF 3.01 diff --git a/G7SensorKit b/G7SensorKit index dd91f465b8..5c0fc075a3 160000 --- a/G7SensorKit +++ b/G7SensorKit @@ -1 +1 @@ -Subproject commit dd91f465b8b7392354b74463b9b9cc7aae32db9f +Subproject commit 5c0fc075a323ccceca0cec28a1ea094570bdfc5d diff --git a/Gemfile b/Gemfile index 4d2c972bab..599db6eabf 100644 --- a/Gemfile +++ b/Gemfile @@ -1,2 +1,2 @@ source "https://rubygems.org" -gem "fastlane", "2.236.1" +gem "fastlane", "2.240.1" diff --git a/Gemfile.lock b/Gemfile.lock index 8cdb5ec822..93511b5ace 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -8,8 +8,8 @@ GEM artifactory (3.0.17) atomos (0.1.3) aws-eventstream (1.4.0) - aws-partitions (1.1261.0) - aws-sdk-core (3.252.0) + aws-partitions (1.1267.0) + aws-sdk-core (3.253.0) aws-eventstream (~> 1, >= 1.3.0) aws-partitions (~> 1, >= 1.992.0) aws-sigv4 (~> 1.9) @@ -27,9 +27,10 @@ GEM aws-sigv4 (1.12.1) aws-eventstream (~> 1, >= 1.0.2) babosa (1.0.4) - base64 (0.2.0) + base64 (0.3.0) benchmark (0.5.0) - bigdecimal (4.0.1) + bigdecimal (4.1.2) + cgi (0.5.2) claide (1.1.0) colored (1.2) colored2 (3.1.2) @@ -42,81 +43,73 @@ GEM domain_name (0.6.20240107) dotenv (2.8.1) emoji_regex (3.2.3) - excon (0.112.0) - faraday (1.10.5) - 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) + erb (6.0.7) + excon (1.5.0) + logger + 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.236.1) + fastlane (2.240.1) CFPropertyList (>= 2.3, < 5.0.0) abbrev (~> 0.1) - addressable (>= 2.8, < 3.0.0) + addressable (>= 2.9.0, < 3.0.0) artifactory (~> 3.0) aws-sdk-s3 (~> 1.197) babosa (>= 1.0.3, < 2.0.0) - base64 (~> 0.2.0) + base64 (~> 0.2) benchmark (>= 0.1.0) - bundler (>= 1.17.3, < 5.0.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, < 1.0.0) - faraday (~> 1.0) - faraday-cookie_jar (~> 0.0.6) - faraday_middleware (~> 1.0) + excon (>= 0.71.0, < 2.0.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.1.1) + 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) mini_magick (>= 4.9.4, < 5.0.0) multi_json (~> 1.12) multipart-post (>= 2.0.0, < 3.0.0) - mutex_m (~> 0.3.0) + mutex_m (~> 0.3) naturally (~> 2.2) - nkf (~> 0.2.0) + nkf (~> 0.2) 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) @@ -125,13 +118,14 @@ GEM xcpretty-travis-formatter (>= 0.0.3, < 2.0.0) fastlane-sirp (1.1.0) gh_inspector (1.1.3) - google-apis-androidpublisher_v3 (0.103.0) + google-apis-androidpublisher_v3 (0.104.0) google-apis-core (>= 0.15.0, < 2.a) google-apis-core (0.18.0) addressable (~> 2.5, >= 2.5.1) - googleauth (>= 0.16.2, < 2.a) - httpclient (>= 2.8.1, < 3.a) + googleauth (~> 1.9) + httpclient (>= 2.8.3, < 3.a) mini_mime (~> 1.0) + mutex_m representable (~> 3.0) retriable (>= 2.0, < 4.a) google-apis-iamcredentials_v1 (0.28.0) @@ -146,14 +140,15 @@ GEM google-cloud-env (2.2.2) base64 (~> 0.2) faraday (>= 1.0, < 3.a) - google-cloud-errors (1.6.0) - google-cloud-storage (1.61.0) + google-cloud-errors (1.7.0) + google-cloud-storage (1.62.0) addressable (~> 2.8) digest-crc (~> 0.4) - google-apis-iamcredentials_v1 (~> 0.1) - google-apis-storage_v1 (~> 0.31.0) + google-apis-core (>= 0.18, < 2) + google-apis-iamcredentials_v1 (~> 0.18) + google-apis-storage_v1 (>= 0.42) google-cloud-core (~> 1.6) - googleauth (>= 0.16.2, < 2.a) + googleauth (~> 1.9) mini_mime (~> 1.0) google-logging-utils (0.2.0) googleauth (1.17.1) @@ -169,36 +164,58 @@ 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.19.9) + json (2.20.0) jwt (3.2.0) base64 logger (1.7.0) mini_magick (4.13.2) mini_mime (1.1.5) - multi_json (1.19.1) + multi_json (1.21.1) multipart-post (2.4.1) 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.3.1) + 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) uber (< 0.2.0) - retriable (3.1.2) + 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) @@ -207,22 +224,26 @@ 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.27.0) + xcodeproj (1.28.1) CFPropertyList (>= 2.3.3, < 4.0) atomos (~> 0.1.3) + base64 claide (>= 1.0.2, < 2.0) colored2 (~> 3.1) nanaimo (~> 0.4.0) + nkf rexml (>= 3.3.6, < 4.0) xcpretty (0.4.1) rouge (~> 3.28.0) @@ -234,7 +255,7 @@ PLATFORMS ruby DEPENDENCIES - fastlane (= 2.236.1) + fastlane (= 2.240.1) BUNDLED WITH 4.0.12 diff --git a/LibreLoop b/LibreLoop index c6e9cc158b..82c92f2cfb 160000 --- a/LibreLoop +++ b/LibreLoop @@ -1 +1 @@ -Subproject commit c6e9cc158b1922edb147a0ac3bd2ccc8e8541aa9 +Subproject commit 82c92f2cfb265dd7147a2b36ee1796e30cb49ab9 diff --git a/LiveActivity/LiveActivity+Helper.swift b/LiveActivity/LiveActivity+Helper.swift index 322288f0ea..59e1211b4f 100644 --- a/LiveActivity/LiveActivity+Helper.swift +++ b/LiveActivity/LiveActivity+Helper.swift @@ -69,6 +69,25 @@ extension NumberFormatter { }() } +extension LiveActivityAttributes.ContentState { + /// Color for the current glucose reading; reading, thresholds and target are all compared in mg/dL. + var glucoseColor: Color { + let hasStaticColorScheme = glucoseColorScheme == "staticColor" + + // TODO: workaround for now: set low value to 55, to have dynamic color shades between 55 and user-set low (approx. 70); same for high glucose + let hardCodedLow = Decimal(55) + let hardCodedHigh = Decimal(220) + + return Color.getDynamicGlucoseColor( + glucoseValue: glucose ?? 100, + highGlucoseColorValue: hasStaticColorScheme ? highGlucose : hardCodedHigh, + lowGlucoseColorValue: hasStaticColorScheme ? lowGlucose : hardCodedLow, + targetGlucose: target, + glucoseColorScheme: glucoseColorScheme + ) + } +} + extension Color { // Static Glucose Color Scheme band colors — needs to be kept in sync with DynamicGlucoseColor.swift static let staticLow = Color(hue: 0.0 / 360.0, saturation: 0.6, brightness: 0.9) @@ -227,17 +246,20 @@ extension View { } } - @ViewBuilder func addLiveActivityModifiers(isWatchOS: Bool) -> some View { - modifier(LiveActivityModifiers(isWatchOS: isWatchOS)) + @ViewBuilder func addLiveActivityModifiers(isWatchOS: Bool, verticalPadding: CGFloat? = nil) -> some View { + modifier(LiveActivityModifiers(isWatchOS: isWatchOS, verticalPadding: verticalPadding)) } } struct LiveActivityModifiers: ViewModifier { let isWatchOS: Bool + var verticalPadding: CGFloat? = nil func body(content: Content) -> some View { + let defaultPadding: CGFloat = isWatchOS ? 10 : 14 content - .padding(.all, isWatchOS ? 10 : 14) + .padding(.horizontal, defaultPadding) + .padding(.vertical, verticalPadding ?? defaultPadding) .frame(minHeight: 0, maxHeight: .infinity) .privacySensitive() // Semantic BackgroundStyle and Color values work here. They adapt to the given interface style (light mode, dark diff --git a/LiveActivity/LiveActivity.swift b/LiveActivity/LiveActivity.swift index ee6f65f03d..bee5e6dda3 100644 --- a/LiveActivity/LiveActivity.swift +++ b/LiveActivity/LiveActivity.swift @@ -9,25 +9,7 @@ struct LiveActivity: Widget { .addIsWatchOS() } dynamicIsland: { context in let hasStaticColorScheme = context.state.glucoseColorScheme == "staticColor" - - var glucoseColor: Color { - let state = context.state - let isMgdL = state.unit == "mg/dL" - - // TODO: workaround for now: set low value to 55, to have dynamic color shades between 55 and user-set low (approx. 70); same for high glucose - let hardCodedLow = isMgdL ? Decimal(55) : 55.asMmolL - let hardCodedHigh = isMgdL ? Decimal(220) : 220.asMmolL - - return Color.getDynamicGlucoseColor( - glucoseValue: Decimal(string: state.bg) ?? 100, - highGlucoseColorValue: !hasStaticColorScheme - ? hardCodedHigh : state.highGlucose, - lowGlucoseColorValue: !hasStaticColorScheme - ? hardCodedLow : state.lowGlucose, - targetGlucose: isMgdL ? state.target : state.target.asMmolL, - glucoseColorScheme: state.glucoseColorScheme - ) - } + let glucoseColor = context.state.glucoseColor return DynamicIsland { DynamicIslandExpandedRegion(.leading) { @@ -140,6 +122,7 @@ private extension LiveActivityAttributes.ContentState { LiveActivityAttributes.ContentState( unit: "mg/dL", bg: "00.0", + glucose: nil, direction: "→", change: "+0.0", date: Date(), @@ -149,6 +132,7 @@ private extension LiveActivityAttributes.ContentState { glucoseColorScheme: "staticColor", useDetailedViewIOS: false, useDetailedViewWatchOS: false, + simpleViewStyle: .default, detailedViewState: detailedViewState, isInitialState: false ) @@ -158,6 +142,7 @@ private extension LiveActivityAttributes.ContentState { LiveActivityAttributes.ContentState( unit: "mg/dL", bg: "00.0", + glucose: nil, direction: "↑↑", change: "+0.0", date: Date(), @@ -167,6 +152,7 @@ private extension LiveActivityAttributes.ContentState { glucoseColorScheme: "staticColor", useDetailedViewIOS: false, useDetailedViewWatchOS: false, + simpleViewStyle: .default, detailedViewState: detailedViewState, isInitialState: false ) @@ -176,6 +162,7 @@ private extension LiveActivityAttributes.ContentState { LiveActivityAttributes.ContentState( unit: "mg/dL", bg: "00.0", + glucose: nil, direction: "↑↑↑", change: "+0.0", date: Date(), @@ -185,6 +172,7 @@ private extension LiveActivityAttributes.ContentState { glucoseColorScheme: "staticColor", useDetailedViewIOS: false, useDetailedViewWatchOS: false, + simpleViewStyle: .default, detailedViewState: detailedViewState, isInitialState: false ) @@ -195,6 +183,7 @@ private extension LiveActivityAttributes.ContentState { LiveActivityAttributes.ContentState( unit: "mg/dL", bg: "00", + glucose: nil, direction: "↑", change: "+0", date: Date(), @@ -204,6 +193,7 @@ private extension LiveActivityAttributes.ContentState { glucoseColorScheme: "staticColor", useDetailedViewIOS: false, useDetailedViewWatchOS: false, + simpleViewStyle: .default, detailedViewState: detailedViewState, isInitialState: false ) @@ -213,6 +203,7 @@ private extension LiveActivityAttributes.ContentState { LiveActivityAttributes.ContentState( unit: "mg/dL", bg: "000", + glucose: nil, direction: "↗︎", change: "+00", date: Date(), @@ -222,6 +213,7 @@ private extension LiveActivityAttributes.ContentState { glucoseColorScheme: "staticColor", useDetailedViewIOS: false, useDetailedViewWatchOS: false, + simpleViewStyle: .default, detailedViewState: detailedViewState, isInitialState: false ) @@ -231,6 +223,7 @@ private extension LiveActivityAttributes.ContentState { LiveActivityAttributes.ContentState( unit: "mg/dL", bg: "--", + glucose: nil, direction: nil, change: "--", date: Date().addingTimeInterval(-60 * 60), @@ -240,6 +233,7 @@ private extension LiveActivityAttributes.ContentState { glucoseColorScheme: "staticColor", useDetailedViewIOS: false, useDetailedViewWatchOS: false, + simpleViewStyle: .default, detailedViewState: detailedViewState, isInitialState: false ) @@ -250,6 +244,7 @@ private extension LiveActivityAttributes.ContentState { LiveActivityAttributes.ContentState( unit: "mg/dL", bg: "00.0", + glucose: nil, direction: "→", change: "+0.0", date: Date(), @@ -259,6 +254,7 @@ private extension LiveActivityAttributes.ContentState { glucoseColorScheme: "staticColor", useDetailedViewIOS: true, useDetailedViewWatchOS: true, + simpleViewStyle: .default, detailedViewState: detailedViewState, isInitialState: false ) @@ -270,6 +266,7 @@ private extension LiveActivityAttributes.ContentState { LiveActivityAttributes.ContentState( unit: "mg/dL", bg: "00.0", + glucose: nil, direction: "↑↑", change: "+0.0", date: Date(), @@ -279,6 +276,7 @@ private extension LiveActivityAttributes.ContentState { glucoseColorScheme: "staticColor", useDetailedViewIOS: true, useDetailedViewWatchOS: true, + simpleViewStyle: .default, detailedViewState: detailedViewState, isInitialState: false ) @@ -290,6 +288,7 @@ private extension LiveActivityAttributes.ContentState { LiveActivityAttributes.ContentState( unit: "mg/dL", bg: "00.0", + glucose: nil, direction: "↑↑↑", change: "+0.0", date: Date(), @@ -299,6 +298,7 @@ private extension LiveActivityAttributes.ContentState { glucoseColorScheme: "staticColor", useDetailedViewIOS: true, useDetailedViewWatchOS: true, + simpleViewStyle: .default, detailedViewState: detailedViewState, isInitialState: false ) @@ -311,6 +311,7 @@ private extension LiveActivityAttributes.ContentState { LiveActivityAttributes.ContentState( unit: "mg/dL", bg: "00", + glucose: nil, direction: "↑", change: "+0", date: Date(), @@ -320,6 +321,7 @@ private extension LiveActivityAttributes.ContentState { glucoseColorScheme: "staticColor", useDetailedViewIOS: true, useDetailedViewWatchOS: true, + simpleViewStyle: .default, detailedViewState: detailedViewState, isInitialState: false ) @@ -331,6 +333,7 @@ private extension LiveActivityAttributes.ContentState { LiveActivityAttributes.ContentState( unit: "mg/dL", bg: "000", + glucose: nil, direction: "↗︎", change: "+00", date: Date(), @@ -340,6 +343,7 @@ private extension LiveActivityAttributes.ContentState { glucoseColorScheme: "staticColor", useDetailedViewIOS: true, useDetailedViewWatchOS: true, + simpleViewStyle: .default, detailedViewState: detailedViewState, isInitialState: false ) @@ -351,6 +355,7 @@ private extension LiveActivityAttributes.ContentState { LiveActivityAttributes.ContentState( unit: "mg/dL", bg: "--", + glucose: nil, direction: nil, change: "--", date: Date().addingTimeInterval(-60 * 60), @@ -360,6 +365,7 @@ private extension LiveActivityAttributes.ContentState { glucoseColorScheme: "staticColor", useDetailedViewIOS: true, useDetailedViewWatchOS: true, + simpleViewStyle: .default, detailedViewState: detailedViewState, isInitialState: false ) diff --git a/LiveActivity/Views/LiveActivityView.swift b/LiveActivity/Views/LiveActivityView.swift index 7e001a0a95..d33f633038 100644 --- a/LiveActivity/Views/LiveActivityView.swift +++ b/LiveActivity/Views/LiveActivityView.swift @@ -14,21 +14,7 @@ struct LiveActivityView: View { } private var glucoseColor: Color { - let state = context.state - let isMgdL = state.unit == "mg/dL" - - // TODO: workaround for now: set low value to 55, to have dynamic color shades between 55 and user-set low (approx. 70); same for high glucose - let hardCodedLow = isMgdL ? Decimal(55) : 55.asMmolL - let hardCodedHigh = isMgdL ? Decimal(220) : 220.asMmolL - - return Color.getDynamicGlucoseColor( - glucoseValue: Decimal(string: state.bg) ?? 100, - highGlucoseColorValue: !hasStaticColorScheme ? hardCodedHigh : - (isMgdL ? state.highGlucose : state.highGlucose.asMmolL), - lowGlucoseColorValue: !hasStaticColorScheme ? hardCodedLow : (isMgdL ? state.lowGlucose : state.lowGlucose.asMmolL), - targetGlucose: isMgdL ? state.target : state.target.asMmolL, - glucoseColorScheme: state.glucoseColorScheme - ) + context.state.glucoseColor } var body: some View { @@ -89,11 +75,13 @@ struct LiveActivityView: View { } } + let widgetItems = context.state.detailedViewState.widgetItems.filter { $0 != .wideContinuation } + HStack { - if context.state.detailedViewState.widgetItems.contains(where: { $0 != .empty }) { + if widgetItems.contains(where: { $0 != .empty }) { ForEach( - Array(context.state.detailedViewState.widgetItems.enumerated()), - id: \.element + Array(widgetItems.enumerated()), + id: \.offset ) { index, widgetItem in switch widgetItem { case .currentGlucose: @@ -118,6 +106,50 @@ struct LiveActivityView: View { context: context, glucoseColor: glucoseColor ) + case .currentGlucoseLargeUncolored: + LiveActivityBGLabelLargeView( + context: context, + glucoseColor: .primary + ) + case .currentGlucoseColored: + VStack { + LiveActivityBGLabelView( + context: context, + additionalState: context.state.detailedViewState, + glucoseColor: glucoseColor + ) + + HStack { + LiveActivityGlucoseDeltaLabelView( + context: context, + glucoseColor: glucoseColor + ) + if !context.isStale, let direction = context.state.direction { + Text(direction).font(.headline).foregroundStyle(glucoseColor) + } + } + } + case .currentGlucoseWide, + .currentGlucoseWideUncolored: + let wideColor: Color = widgetItem == .currentGlucoseWideUncolored ? .primary : glucoseColor + let wideFont: Font = isWatchOS ? .title : .largeTitle + let secondaryFont: Font = isWatchOS ? .title3 : .title2 + HStack(alignment: .firstTextBaseline, spacing: 6) { + LiveActivityBGLabelLargeView( + context: context, + glucoseColor: wideColor, + glucoseFont: wideFont, + arrowFont: secondaryFont + ) + LiveActivityGlucoseDeltaLabelView( + context: context, + glucoseColor: .primary + ) + .fontWeight(.bold) + .font(secondaryFont) + } + .lineLimit(1) + .minimumScaleFactor(0.6) case .iob: LiveActivityIOBLabelView(context: context, additionalState: context.state.detailedViewState) case .cob: @@ -131,12 +163,14 @@ struct LiveActivityView: View { ) case .empty: Text("").frame(width: 50, height: 50) + case .wideContinuation: + EmptyView() } /// Check if the next item is also non-empty to determine if a divider should be shown - if index < context.state.detailedViewState.widgetItems.count - 1 { - let currentItem = context.state.detailedViewState.widgetItems[index] - let nextItem = context.state.detailedViewState.widgetItems[index + 1] + if index < widgetItems.count - 1 { + let currentItem = widgetItems[index] + let nextItem = widgetItems[index + 1] if currentItem != .empty, nextItem != .empty { Divider() @@ -151,12 +185,17 @@ struct LiveActivityView: View { } .addLiveActivityModifiers(isWatchOS: false) } else { + let simpleViewStyle = context.state.simpleViewStyle + Group { if context.state.isInitialState { Text("Live Activity Expired. Open Trio to Refresh").minimumScaleFactor(0.01) } else { HStack(spacing: 3) { - LiveActivityBGAndTrendView(context: context, size: .expanded, glucoseColor: glucoseColor).font(.title) + LiveActivityBGAndTrendView(context: context, size: .expanded, glucoseColor: glucoseColor) + .font(simpleViewStyle.glucoseFont) + .lineLimit(1) + .minimumScaleFactor(0.6) Spacer() VStack(alignment: .trailing, spacing: 5) { LiveActivityGlucoseDeltaLabelView( @@ -170,7 +209,7 @@ struct LiveActivityView: View { } } } - .addLiveActivityModifiers(isWatchOS: false) + .addLiveActivityModifiers(isWatchOS: false, verticalPadding: simpleViewStyle.fontSize.verticalPadding) } } } diff --git a/LiveActivity/Views/WidgetItems/LiveActivityBGLabelLargeView.swift b/LiveActivity/Views/WidgetItems/LiveActivityBGLabelLargeView.swift index 106e9b8eb7..0ce1603c44 100644 --- a/LiveActivity/Views/WidgetItems/LiveActivityBGLabelLargeView.swift +++ b/LiveActivity/Views/WidgetItems/LiveActivityBGLabelLargeView.swift @@ -7,12 +7,14 @@ struct LiveActivityBGLabelLargeView: View { var context: ActivityViewContext var glucoseColor: Color + var glucoseFont: Font? = nil + var arrowFont: Font? = nil var body: some View { HStack(alignment: .center) { Text(context.state.bg) .fontWeight(.bold) - .font(!isWatchOS ? .title : .title3) + .font(glucoseFont ?? (!isWatchOS ? .title : .title3)) .foregroundStyle(context.isStale ? .secondary : glucoseColor) .strikethrough(context.isStale, pattern: .solid, color: .red.opacity(0.6)) @@ -20,7 +22,7 @@ struct LiveActivityBGLabelLargeView: View { Text(trendArrow) .foregroundStyle(context.isStale ? .secondary : glucoseColor) .fontWeight(.bold) - .font(!isWatchOS ? .headline : .subheadline) + .font(arrowFont ?? (!isWatchOS ? .headline : .subheadline)) .padding(.leading, !isWatchOS ? 0 : -5) } } diff --git a/LiveActivity/Views/WidgetItems/LiveActivityBGLabelView.swift b/LiveActivity/Views/WidgetItems/LiveActivityBGLabelView.swift index 94663b6306..bcc90468e4 100644 --- a/LiveActivity/Views/WidgetItems/LiveActivityBGLabelView.swift +++ b/LiveActivity/Views/WidgetItems/LiveActivityBGLabelView.swift @@ -5,12 +5,13 @@ import WidgetKit struct LiveActivityBGLabelView: View { var context: ActivityViewContext var additionalState: LiveActivityAttributes.ContentAdditionalState + var glucoseColor: Color = .primary var body: some View { Text(context.state.bg) .fontWeight(.bold) .font(.title3) - .foregroundStyle(context.isStale ? .secondary : .primary) + .foregroundStyle(context.isStale ? .secondary : glucoseColor) .strikethrough(context.isStale, pattern: .solid, color: .red.opacity(0.6)) } } diff --git a/OmnipodKit b/OmnipodKit index 043b91dbf1..928bb79503 160000 --- a/OmnipodKit +++ b/OmnipodKit @@ -1 +1 @@ -Subproject commit 043b91dbf1958d26c55d927a6a8d651bc91f208d +Subproject commit 928bb79503fc7eaf20fe9b26caa55fded09f7f0c diff --git a/Trio.xcodeproj/project.pbxproj b/Trio.xcodeproj/project.pbxproj index 1ccb139c51..91101e413a 100644 --- a/Trio.xcodeproj/project.pbxproj +++ b/Trio.xcodeproj/project.pbxproj @@ -136,6 +136,7 @@ 38AEE73D25F0200C0013F05B /* TrioSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38AEE73C25F0200C0013F05B /* TrioSettings.swift */; }; 38AEE75225F022080013F05B /* SettingsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38AEE75125F022080013F05B /* SettingsManager.swift */; }; 38AEE75725F0F18E0013F05B /* CarbsStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38AEE75625F0F18E0013F05B /* CarbsStorage.swift */; }; + E73259BE7A7D1AFB93D6B8DF /* CarbEntryMutationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A2F107674CF1D9C0A2A9ACF /* CarbEntryMutationService.swift */; }; 38B17B6625DD90E0005CAE3D /* SwiftDate in Frameworks */ = {isa = PBXBuildFile; productRef = 38B17B6525DD90E0005CAE3D /* SwiftDate */; }; 38B4F3AF25E2979F00E76A18 /* IndexedCollection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38B4F3AE25E2979F00E76A18 /* IndexedCollection.swift */; }; 38B4F3C325E2A20B00E76A18 /* PumpSetupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38B4F3C225E2A20B00E76A18 /* PumpSetupView.swift */; }; @@ -453,8 +454,11 @@ 6B1A8D242B14D91700E76752 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 6B1A8D232B14D91700E76752 /* Assets.xcassets */; }; 6B1A8D282B14D91700E76752 /* LiveActivityExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 6B1A8D172B14D91600E76752 /* LiveActivityExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 6B1A8D2E2B156EEF00E76752 /* LiveActivityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6B1A8D2D2B156EEF00E76752 /* LiveActivityManager.swift */; }; - 6BCF84DD2B16843A003AD46E /* LiveActitiyAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6BCF84DC2B16843A003AD46E /* LiveActitiyAttributes.swift */; }; - 6BCF84DE2B16843A003AD46E /* LiveActitiyAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6BCF84DC2B16843A003AD46E /* LiveActitiyAttributes.swift */; }; + 6BCF84DD2B16843A003AD46E /* LiveActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6BCF84DC2B16843A003AD46E /* LiveActivityAttributes.swift */; }; + AC096399E4D2F4950BA8E5CF /* LiveActivityGlucoseStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E2CF3BABA4BF1C48CC96FD7 /* LiveActivityGlucoseStyle.swift */; }; + 39714DB1CF5B2C1F8D990CED /* LiveActivityGlucoseStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E2CF3BABA4BF1C48CC96FD7 /* LiveActivityGlucoseStyle.swift */; }; + 8B74AA51DC49429FB9F19159 /* LiveActivitySimpleWidgetConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD6672C97F2AB74125F46DD5 /* LiveActivitySimpleWidgetConfiguration.swift */; }; + 6BCF84DE2B16843A003AD46E /* LiveActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6BCF84DC2B16843A003AD46E /* LiveActivityAttributes.swift */; }; 6D58D168F03780DA23CB2E52 /* QuickPickTreatmentsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7DA23B64976670AC36D9E84 /* QuickPickTreatmentsTests.swift */; }; 4A3C7DA31A04D14F83DC2957 /* CGMSensorStateLogTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71A58E19DE23455CB842C50C /* CGMSensorStateLogTests.swift */; }; 71C28D9780229AB06CF67D27 /* ProfileScheduleStored+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3161E258E69321163BE5696D /* ProfileScheduleStored+CoreDataProperties.swift */; }; @@ -533,6 +537,89 @@ BD1179242F4E22C100F90001 /* TrioUserNotificationAlertScheduler.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD1179252F4E22C100F90001 /* TrioUserNotificationAlertScheduler.swift */; }; BD1179282F4E22C100F90001 /* TrioAlertCategory.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD1179292F4E22C100F90001 /* TrioAlertCategory.swift */; }; BD1179322F4E22C100F90001 /* CriticalAlertAudioPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD1179312F4E22C100F90001 /* CriticalAlertAudioPlayer.swift */; }; + CAF300000000000000000002 /* CriticalAlertAlarmScheduler.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAF300000000000000000001 /* CriticalAlertAlarmScheduler.swift */; }; + CAF200000000000000000000 /* Trio/Resources/Sounds/alarm.caf in Resources */ = {isa = PBXBuildFile; fileRef = CAF100000000000000000000 /* Trio/Resources/Sounds/alarm.caf */; }; + CAF500000000000000000001 /* Trio/Resources/Sounds/acoustic_cr_harsh.caf in Resources */ = {isa = PBXBuildFile; fileRef = CAF500000000000000000000 /* Trio/Resources/Sounds/acoustic_cr_harsh.caf */; }; + CAF500000000000000000003 /* Trio/Resources/Sounds/acoustic_cr_medium.caf in Resources */ = {isa = PBXBuildFile; fileRef = CAF500000000000000000002 /* Trio/Resources/Sounds/acoustic_cr_medium.caf */; }; + CAF500000000000000000005 /* Trio/Resources/Sounds/acoustic_cr_mild.caf in Resources */ = {isa = PBXBuildFile; fileRef = CAF500000000000000000004 /* Trio/Resources/Sounds/acoustic_cr_mild.caf */; }; + CAF500000000000000000007 /* Trio/Resources/Sounds/acoustic_dcr_harsh.caf in Resources */ = {isa = PBXBuildFile; fileRef = CAF500000000000000000006 /* Trio/Resources/Sounds/acoustic_dcr_harsh.caf */; }; + CAF500000000000000000009 /* Trio/Resources/Sounds/acoustic_dcr_medium.caf in Resources */ = {isa = PBXBuildFile; fileRef = CAF500000000000000000008 /* Trio/Resources/Sounds/acoustic_dcr_medium.caf */; }; + CAF50000000000000000000B /* Trio/Resources/Sounds/acoustic_dcr_mild.caf in Resources */ = {isa = PBXBuildFile; fileRef = CAF50000000000000000000A /* Trio/Resources/Sounds/acoustic_dcr_mild.caf */; }; + CAF50000000000000000000D /* Trio/Resources/Sounds/acoustic_dts_harsh.caf in Resources */ = {isa = PBXBuildFile; fileRef = CAF50000000000000000000C /* Trio/Resources/Sounds/acoustic_dts_harsh.caf */; }; + CAF50000000000000000000F /* Trio/Resources/Sounds/acoustic_dts_medium.caf in Resources */ = {isa = PBXBuildFile; fileRef = CAF50000000000000000000E /* Trio/Resources/Sounds/acoustic_dts_medium.caf */; }; + CAF500000000000000000011 /* Trio/Resources/Sounds/acoustic_dts_mild.caf in Resources */ = {isa = PBXBuildFile; fileRef = CAF500000000000000000010 /* Trio/Resources/Sounds/acoustic_dts_mild.caf */; }; + CAF500000000000000000013 /* Trio/Resources/Sounds/acoustic_hg_harsh.caf in Resources */ = {isa = PBXBuildFile; fileRef = CAF500000000000000000012 /* Trio/Resources/Sounds/acoustic_hg_harsh.caf */; }; + CAF500000000000000000015 /* Trio/Resources/Sounds/acoustic_hg_medium.caf in Resources */ = {isa = PBXBuildFile; fileRef = CAF500000000000000000014 /* Trio/Resources/Sounds/acoustic_hg_medium.caf */; }; + CAF500000000000000000017 /* Trio/Resources/Sounds/acoustic_hg_mild.caf in Resources */ = {isa = PBXBuildFile; fileRef = CAF500000000000000000016 /* Trio/Resources/Sounds/acoustic_hg_mild.caf */; }; + CAF500000000000000000019 /* Trio/Resources/Sounds/acoustic_lg_harsh.caf in Resources */ = {isa = PBXBuildFile; fileRef = CAF500000000000000000018 /* Trio/Resources/Sounds/acoustic_lg_harsh.caf */; }; + CAF50000000000000000001B /* Trio/Resources/Sounds/acoustic_lg_medium.caf in Resources */ = {isa = PBXBuildFile; fileRef = CAF50000000000000000001A /* Trio/Resources/Sounds/acoustic_lg_medium.caf */; }; + CAF50000000000000000001D /* Trio/Resources/Sounds/acoustic_lg_mild.caf in Resources */ = {isa = PBXBuildFile; fileRef = CAF50000000000000000001C /* Trio/Resources/Sounds/acoustic_lg_mild.caf */; }; + CAF50000000000000000001F /* Trio/Resources/Sounds/acoustic_lgs_harsh.caf in Resources */ = {isa = PBXBuildFile; fileRef = CAF50000000000000000001E /* Trio/Resources/Sounds/acoustic_lgs_harsh.caf */; }; + CAF500000000000000000021 /* Trio/Resources/Sounds/acoustic_lgs_medium.caf in Resources */ = {isa = PBXBuildFile; fileRef = CAF500000000000000000020 /* Trio/Resources/Sounds/acoustic_lgs_medium.caf */; }; + CAF500000000000000000023 /* Trio/Resources/Sounds/acoustic_lgs_mild.caf in Resources */ = {isa = PBXBuildFile; fileRef = CAF500000000000000000022 /* Trio/Resources/Sounds/acoustic_lgs_mild.caf */; }; + CAF500000000000000000025 /* Trio/Resources/Sounds/acoustic_ulg_harsh.caf in Resources */ = {isa = PBXBuildFile; fileRef = CAF500000000000000000024 /* Trio/Resources/Sounds/acoustic_ulg_harsh.caf */; }; + CAF500000000000000000027 /* Trio/Resources/Sounds/acoustic_ulg_medium.caf in Resources */ = {isa = PBXBuildFile; fileRef = CAF500000000000000000026 /* Trio/Resources/Sounds/acoustic_ulg_medium.caf */; }; + CAF500000000000000000029 /* Trio/Resources/Sounds/acoustic_ulg_mild.caf in Resources */ = {isa = PBXBuildFile; fileRef = CAF500000000000000000028 /* Trio/Resources/Sounds/acoustic_ulg_mild.caf */; }; + CAF50000000000000000002B /* Trio/Resources/Sounds/electronic_cr_harsh.caf in Resources */ = {isa = PBXBuildFile; fileRef = CAF50000000000000000002A /* Trio/Resources/Sounds/electronic_cr_harsh.caf */; }; + CAF50000000000000000002D /* Trio/Resources/Sounds/electronic_cr_medium.caf in Resources */ = {isa = PBXBuildFile; fileRef = CAF50000000000000000002C /* Trio/Resources/Sounds/electronic_cr_medium.caf */; }; + CAF50000000000000000002F /* Trio/Resources/Sounds/electronic_cr_mild.caf in Resources */ = {isa = PBXBuildFile; fileRef = CAF50000000000000000002E /* Trio/Resources/Sounds/electronic_cr_mild.caf */; }; + CAF500000000000000000031 /* Trio/Resources/Sounds/electronic_dcr_harsh.caf in Resources */ = {isa = PBXBuildFile; fileRef = CAF500000000000000000030 /* Trio/Resources/Sounds/electronic_dcr_harsh.caf */; }; + CAF500000000000000000033 /* Trio/Resources/Sounds/electronic_dcr_medium.caf in Resources */ = {isa = PBXBuildFile; fileRef = CAF500000000000000000032 /* Trio/Resources/Sounds/electronic_dcr_medium.caf */; }; + CAF500000000000000000035 /* Trio/Resources/Sounds/electronic_dcr_mild.caf in Resources */ = {isa = PBXBuildFile; fileRef = CAF500000000000000000034 /* Trio/Resources/Sounds/electronic_dcr_mild.caf */; }; + CAF500000000000000000037 /* Trio/Resources/Sounds/electronic_dts_harsh.caf in Resources */ = {isa = PBXBuildFile; fileRef = CAF500000000000000000036 /* Trio/Resources/Sounds/electronic_dts_harsh.caf */; }; + CAF500000000000000000039 /* Trio/Resources/Sounds/electronic_dts_medium.caf in Resources */ = {isa = PBXBuildFile; fileRef = CAF500000000000000000038 /* Trio/Resources/Sounds/electronic_dts_medium.caf */; }; + CAF50000000000000000003B /* Trio/Resources/Sounds/electronic_dts_mild.caf in Resources */ = {isa = PBXBuildFile; fileRef = CAF50000000000000000003A /* Trio/Resources/Sounds/electronic_dts_mild.caf */; }; + CAF50000000000000000003D /* Trio/Resources/Sounds/electronic_hg_harsh.caf in Resources */ = {isa = PBXBuildFile; fileRef = CAF50000000000000000003C /* Trio/Resources/Sounds/electronic_hg_harsh.caf */; }; + CAF50000000000000000003F /* Trio/Resources/Sounds/electronic_hg_medium.caf in Resources */ = {isa = PBXBuildFile; fileRef = CAF50000000000000000003E /* Trio/Resources/Sounds/electronic_hg_medium.caf */; }; + CAF500000000000000000041 /* Trio/Resources/Sounds/electronic_hg_mild.caf in Resources */ = {isa = PBXBuildFile; fileRef = CAF500000000000000000040 /* Trio/Resources/Sounds/electronic_hg_mild.caf */; }; + CAF500000000000000000043 /* Trio/Resources/Sounds/electronic_lg_harsh.caf in Resources */ = {isa = PBXBuildFile; fileRef = CAF500000000000000000042 /* Trio/Resources/Sounds/electronic_lg_harsh.caf */; }; + CAF500000000000000000045 /* Trio/Resources/Sounds/electronic_lg_medium.caf in Resources */ = {isa = PBXBuildFile; fileRef = CAF500000000000000000044 /* Trio/Resources/Sounds/electronic_lg_medium.caf */; }; + CAF500000000000000000047 /* Trio/Resources/Sounds/electronic_lg_mild.caf in Resources */ = {isa = PBXBuildFile; fileRef = CAF500000000000000000046 /* Trio/Resources/Sounds/electronic_lg_mild.caf */; }; + CAF500000000000000000049 /* Trio/Resources/Sounds/electronic_lgs_harsh.caf in Resources */ = {isa = PBXBuildFile; fileRef = CAF500000000000000000048 /* Trio/Resources/Sounds/electronic_lgs_harsh.caf */; }; + CAF50000000000000000004B /* Trio/Resources/Sounds/electronic_lgs_medium.caf in Resources */ = {isa = PBXBuildFile; fileRef = CAF50000000000000000004A /* Trio/Resources/Sounds/electronic_lgs_medium.caf */; }; + CAF50000000000000000004D /* Trio/Resources/Sounds/electronic_lgs_mild.caf in Resources */ = {isa = PBXBuildFile; fileRef = CAF50000000000000000004C /* Trio/Resources/Sounds/electronic_lgs_mild.caf */; }; + CAF50000000000000000004F /* Trio/Resources/Sounds/electronic_ulg_harsh.caf in Resources */ = {isa = PBXBuildFile; fileRef = CAF50000000000000000004E /* Trio/Resources/Sounds/electronic_ulg_harsh.caf */; }; + CAF500000000000000000051 /* Trio/Resources/Sounds/electronic_ulg_medium.caf in Resources */ = {isa = PBXBuildFile; fileRef = CAF500000000000000000050 /* Trio/Resources/Sounds/electronic_ulg_medium.caf */; }; + CAF500000000000000000053 /* Trio/Resources/Sounds/electronic_ulg_mild.caf in Resources */ = {isa = PBXBuildFile; fileRef = CAF500000000000000000052 /* Trio/Resources/Sounds/electronic_ulg_mild.caf */; }; + CAF500000000000000000055 /* Trio/Resources/Sounds/glass_bell.caf in Resources */ = {isa = PBXBuildFile; fileRef = CAF500000000000000000054 /* Trio/Resources/Sounds/glass_bell.caf */; }; + CAF500000000000000000057 /* Trio/Resources/Sounds/hard_saw_wave.caf in Resources */ = {isa = PBXBuildFile; fileRef = CAF500000000000000000056 /* Trio/Resources/Sounds/hard_saw_wave.caf */; }; + CAF500000000000000000059 /* Trio/Resources/Sounds/harp.caf in Resources */ = {isa = PBXBuildFile; fileRef = CAF500000000000000000058 /* Trio/Resources/Sounds/harp.caf */; }; + CAF50000000000000000005B /* Trio/Resources/Sounds/meow.caf in Resources */ = {isa = PBXBuildFile; fileRef = CAF50000000000000000005A /* Trio/Resources/Sounds/meow.caf */; }; + CAF50000000000000000005D /* Trio/Resources/Sounds/open_can.caf in Resources */ = {isa = PBXBuildFile; fileRef = CAF50000000000000000005C /* Trio/Resources/Sounds/open_can.caf */; }; + CAF50000000000000000005F /* Trio/Resources/Sounds/open_wrapper.caf in Resources */ = {isa = PBXBuildFile; fileRef = CAF50000000000000000005E /* Trio/Resources/Sounds/open_wrapper.caf */; }; + CAF500000000000000000061 /* Trio/Resources/Sounds/saw_wave.caf in Resources */ = {isa = PBXBuildFile; fileRef = CAF500000000000000000060 /* Trio/Resources/Sounds/saw_wave.caf */; }; + CAF500000000000000000063 /* Trio/Resources/Sounds/sine_wave.caf in Resources */ = {isa = PBXBuildFile; fileRef = CAF500000000000000000062 /* Trio/Resources/Sounds/sine_wave.caf */; }; + CAF500000000000000000065 /* Trio/Resources/Sounds/voice_cr_amy.caf in Resources */ = {isa = PBXBuildFile; fileRef = CAF500000000000000000064 /* Trio/Resources/Sounds/voice_cr_amy.caf */; }; + CAF500000000000000000067 /* Trio/Resources/Sounds/voice_cr_bryce.caf in Resources */ = {isa = PBXBuildFile; fileRef = CAF500000000000000000066 /* Trio/Resources/Sounds/voice_cr_bryce.caf */; }; + CAF500000000000000000069 /* Trio/Resources/Sounds/voice_dcr_amy.caf in Resources */ = {isa = PBXBuildFile; fileRef = CAF500000000000000000068 /* Trio/Resources/Sounds/voice_dcr_amy.caf */; }; + CAF50000000000000000006B /* Trio/Resources/Sounds/voice_dcr_bryce.caf in Resources */ = {isa = PBXBuildFile; fileRef = CAF50000000000000000006A /* Trio/Resources/Sounds/voice_dcr_bryce.caf */; }; + CAF50000000000000000006D /* Trio/Resources/Sounds/voice_dts_amy.caf in Resources */ = {isa = PBXBuildFile; fileRef = CAF50000000000000000006C /* Trio/Resources/Sounds/voice_dts_amy.caf */; }; + CAF50000000000000000006F /* Trio/Resources/Sounds/voice_dts_bryce.caf in Resources */ = {isa = PBXBuildFile; fileRef = CAF50000000000000000006E /* Trio/Resources/Sounds/voice_dts_bryce.caf */; }; + CAF500000000000000000071 /* Trio/Resources/Sounds/voice_hg_amy.caf in Resources */ = {isa = PBXBuildFile; fileRef = CAF500000000000000000070 /* Trio/Resources/Sounds/voice_hg_amy.caf */; }; + CAF500000000000000000073 /* Trio/Resources/Sounds/voice_hg_bryce.caf in Resources */ = {isa = PBXBuildFile; fileRef = CAF500000000000000000072 /* Trio/Resources/Sounds/voice_hg_bryce.caf */; }; + CAF500000000000000000075 /* Trio/Resources/Sounds/voice_lg_amy.caf in Resources */ = {isa = PBXBuildFile; fileRef = CAF500000000000000000074 /* Trio/Resources/Sounds/voice_lg_amy.caf */; }; + CAF500000000000000000077 /* Trio/Resources/Sounds/voice_lg_bryce.caf in Resources */ = {isa = PBXBuildFile; fileRef = CAF500000000000000000076 /* Trio/Resources/Sounds/voice_lg_bryce.caf */; }; + CAF500000000000000000079 /* Trio/Resources/Sounds/voice_lgs_amy.caf in Resources */ = {isa = PBXBuildFile; fileRef = CAF500000000000000000078 /* Trio/Resources/Sounds/voice_lgs_amy.caf */; }; + CAF50000000000000000007B /* Trio/Resources/Sounds/voice_lgs_bryce.caf in Resources */ = {isa = PBXBuildFile; fileRef = CAF50000000000000000007A /* Trio/Resources/Sounds/voice_lgs_bryce.caf */; }; + CAF50000000000000000007D /* Trio/Resources/Sounds/voice_ulg_amy.caf in Resources */ = {isa = PBXBuildFile; fileRef = CAF50000000000000000007C /* Trio/Resources/Sounds/voice_ulg_amy.caf */; }; + CAF50000000000000000007F /* Trio/Resources/Sounds/voice_ulg_bryce.caf in Resources */ = {isa = PBXBuildFile; fileRef = CAF50000000000000000007E /* Trio/Resources/Sounds/voice_ulg_bryce.caf */; }; + CAF200000000000000000001 /* Trio/Resources/Sounds/bloom.caf in Resources */ = {isa = PBXBuildFile; fileRef = CAF100000000000000000001 /* Trio/Resources/Sounds/bloom.caf */; }; + CAF200000000000000000002 /* Trio/Resources/Sounds/bloop.caf in Resources */ = {isa = PBXBuildFile; fileRef = CAF100000000000000000002 /* Trio/Resources/Sounds/bloop.caf */; }; + CAF200000000000000000003 /* Trio/Resources/Sounds/bright_alarm.caf in Resources */ = {isa = PBXBuildFile; fileRef = CAF100000000000000000003 /* Trio/Resources/Sounds/bright_alarm.caf */; }; + CAF200000000000000000004 /* Trio/Resources/Sounds/chime.caf in Resources */ = {isa = PBXBuildFile; fileRef = CAF100000000000000000004 /* Trio/Resources/Sounds/chime.caf */; }; + CAF200000000000000000005 /* Trio/Resources/Sounds/clear_chimes.caf in Resources */ = {isa = PBXBuildFile; fileRef = CAF100000000000000000005 /* Trio/Resources/Sounds/clear_chimes.caf */; }; + CAF200000000000000000006 /* Trio/Resources/Sounds/critical.caf in Resources */ = {isa = PBXBuildFile; fileRef = CAF100000000000000000006 /* Trio/Resources/Sounds/critical.caf */; }; + CAF200000000000000000007 /* Trio/Resources/Sounds/crying.caf in Resources */ = {isa = PBXBuildFile; fileRef = CAF100000000000000000007 /* Trio/Resources/Sounds/crying.caf */; }; + CAF200000000000000000008 /* Trio/Resources/Sounds/dings.caf in Resources */ = {isa = PBXBuildFile; fileRef = CAF100000000000000000008 /* Trio/Resources/Sounds/dings.caf */; }; + CAF200000000000000000009 /* Trio/Resources/Sounds/high_chimes.caf in Resources */ = {isa = PBXBuildFile; fileRef = CAF100000000000000000009 /* Trio/Resources/Sounds/high_chimes.caf */; }; + CAF20000000000000000000A /* Trio/Resources/Sounds/honk.caf in Resources */ = {isa = PBXBuildFile; fileRef = CAF10000000000000000000A /* Trio/Resources/Sounds/honk.caf */; }; + CAF20000000000000000000B /* Trio/Resources/Sounds/minimal.caf in Resources */ = {isa = PBXBuildFile; fileRef = CAF10000000000000000000B /* Trio/Resources/Sounds/minimal.caf */; }; + CAF20000000000000000000C /* Trio/Resources/Sounds/mood_synth.caf in Resources */ = {isa = PBXBuildFile; fileRef = CAF10000000000000000000C /* Trio/Resources/Sounds/mood_synth.caf */; }; + CAF20000000000000000000D /* Trio/Resources/Sounds/simple.caf in Resources */ = {isa = PBXBuildFile; fileRef = CAF10000000000000000000D /* Trio/Resources/Sounds/simple.caf */; }; + CAF20000000000000000000E /* Trio/Resources/Sounds/spring.caf in Resources */ = {isa = PBXBuildFile; fileRef = CAF10000000000000000000E /* Trio/Resources/Sounds/spring.caf */; }; + CAF20000000000000000000F /* Trio/Resources/Sounds/synth.caf in Resources */ = {isa = PBXBuildFile; fileRef = CAF10000000000000000000F /* Trio/Resources/Sounds/synth.caf */; }; + CAF200000000000000000010 /* Trio/Resources/Sounds/trill.caf in Resources */ = {isa = PBXBuildFile; fileRef = CAF100000000000000000010 /* Trio/Resources/Sounds/trill.caf */; }; + CAF200000000000000000011 /* Trio/Resources/Sounds/urgent_low.caf in Resources */ = {isa = PBXBuildFile; fileRef = CAF100000000000000000011 /* Trio/Resources/Sounds/urgent_low.caf */; }; BD1179402F4E22C100F90001 /* ForecastedGlucoseEvaluator.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD1179412F4E22C100F90001 /* ForecastedGlucoseEvaluator.swift */; }; BD1179522F4E22C100F90001 /* DayNightOptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD1179512F4E22C100F90001 /* DayNightOptions.swift */; }; BD1179542F4E22C100F90001 /* GlucoseAlert.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD1179532F4E22C100F90001 /* GlucoseAlert.swift */; }; @@ -569,11 +656,13 @@ BD1179AC2F4E22C100F90001 /* GlucoseSourceKey.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD1179AD2F4E22C100F90001 /* GlucoseSourceKey.swift */; }; BD11A001000000000000A002 /* AlertMuterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD11A000000000000000A002 /* AlertMuterTests.swift */; }; BD11A001000000000000A003 /* DeviceAlertsStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD11A000000000000000A003 /* DeviceAlertsStoreTests.swift */; }; + CAF600000000000000000002 /* AlarmSoundCatalogTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAF600000000000000000001 /* AlarmSoundCatalogTests.swift */; }; BD11A001000000000000A006 /* ForecastedGlucoseEvaluatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD11A000000000000000A006 /* ForecastedGlucoseEvaluatorTests.swift */; }; BD11A001000000000000A007 /* GlucoseAlertConfigurationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD11A000000000000000A007 /* GlucoseAlertConfigurationTests.swift */; }; BD11A001000000000000A008 /* GlucoseAlertTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD11A000000000000000A008 /* GlucoseAlertTests.swift */; }; BD11A001000000000000A009 /* TrioAlertCategoryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD11A000000000000000A009 /* TrioAlertCategoryTests.swift */; }; BD11A001000000000000A00A /* GlucoseAlertCoordinatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD11A000000000000000A00A /* GlucoseAlertCoordinatorTests.swift */; }; + BD11A011000000000000A01A /* CGMManagerAlertOwnershipTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD11A010000000000000A01A /* CGMManagerAlertOwnershipTests.swift */; }; BD11A001000000000000A00B /* TrioModalAlertSchedulerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD11A000000000000000A00B /* TrioModalAlertSchedulerTests.swift */; }; BD11A001000000000000A00C /* NotLoopingMonitorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD11A000000000000000A00C /* NotLoopingMonitorTests.swift */; }; BD11A001000000000000A00D /* AlertCatalogRegistryEntriesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD11A000000000000000A00D /* AlertCatalogRegistryEntriesTests.swift */; }; @@ -662,10 +751,13 @@ BD8FC05B2D6618AF00B95AED /* DeterminationStorageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD8FC05A2D6618AF00B95AED /* DeterminationStorageTests.swift */; }; BD8FC05E2D6618CE00B95AED /* BolusCalculatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD8FC05D2D6618CE00B95AED /* BolusCalculatorTests.swift */; }; BD8FC0602D6619DB00B95AED /* CarbsStorageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD8FC05F2D6619DB00B95AED /* CarbsStorageTests.swift */; }; + 36BDB3927CB9ABFB548ED2B0 /* CarbEntryMutationServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A19B7FC1E8CD873546B563CB /* CarbEntryMutationServiceTests.swift */; }; BD8FC0622D6619E600B95AED /* OverrideStorageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD8FC0612D6619E600B95AED /* OverrideStorageTests.swift */; }; BD8FC0642D6619EF00B95AED /* TempTargetStorageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD8FC0632D6619EF00B95AED /* TempTargetStorageTests.swift */; }; BD8FC0662D661A0000B95AED /* GlucoseStorageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD8FC0652D661A0000B95AED /* GlucoseStorageTests.swift */; }; BD8FC0712D661B0000B95AED /* TidepoolTherapySettingsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD8FC0702D661B0000B95AED /* TidepoolTherapySettingsTests.swift */; }; + CD1235B000000000000000F2 /* TidepoolUploadSerializerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD1235A000000000000000F1 /* TidepoolUploadSerializerTests.swift */; }; + CE1425B000000000000000A2 /* CarbsEntryLoopKitTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE1425A000000000000000A1 /* CarbsEntryLoopKitTests.swift */; }; BDA25EE42D260CD500035F34 /* AppleWatchManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDA25EE32D260CCF00035F34 /* AppleWatchManager.swift */; }; BDA25EE62D260D5E00035F34 /* WatchState.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDA25EE52D260D5800035F34 /* WatchState.swift */; }; BDA25EFD2D261C0000035F34 /* WatchState.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDA25EFC2D261BF200035F34 /* WatchState.swift */; }; @@ -741,33 +833,14 @@ CA06000000000000000010C2 /* GlucoseAlertsStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA06000000000000000010C1 /* GlucoseAlertsStoreTests.swift */; }; CA370FC152BC98B3D1832968 /* BasalProfileEditorRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF8BCB0C37DEB5EC377B9612 /* BasalProfileEditorRootView.swift */; }; CA787AC39A25B579FB8FE8C7 /* ProfileScheduleStored+Helper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DFA038269CBF3DAC50A54E7 /* ProfileScheduleStored+Helper.swift */; }; - CAF200000000000000000000 /* Trio/Resources/Sounds/alarm.caf in Resources */ = {isa = PBXBuildFile; fileRef = CAF100000000000000000000 /* Trio/Resources/Sounds/alarm.caf */; }; - CAF200000000000000000001 /* Trio/Resources/Sounds/bloom.caf in Resources */ = {isa = PBXBuildFile; fileRef = CAF100000000000000000001 /* Trio/Resources/Sounds/bloom.caf */; }; - CAF200000000000000000002 /* Trio/Resources/Sounds/bloop.caf in Resources */ = {isa = PBXBuildFile; fileRef = CAF100000000000000000002 /* Trio/Resources/Sounds/bloop.caf */; }; - CAF200000000000000000003 /* Trio/Resources/Sounds/bright_alarm.caf in Resources */ = {isa = PBXBuildFile; fileRef = CAF100000000000000000003 /* Trio/Resources/Sounds/bright_alarm.caf */; }; - CAF200000000000000000004 /* Trio/Resources/Sounds/chime.caf in Resources */ = {isa = PBXBuildFile; fileRef = CAF100000000000000000004 /* Trio/Resources/Sounds/chime.caf */; }; - CAF200000000000000000005 /* Trio/Resources/Sounds/clear_chimes.caf in Resources */ = {isa = PBXBuildFile; fileRef = CAF100000000000000000005 /* Trio/Resources/Sounds/clear_chimes.caf */; }; - CAF200000000000000000006 /* Trio/Resources/Sounds/critical.caf in Resources */ = {isa = PBXBuildFile; fileRef = CAF100000000000000000006 /* Trio/Resources/Sounds/critical.caf */; }; - CAF200000000000000000007 /* Trio/Resources/Sounds/crying.caf in Resources */ = {isa = PBXBuildFile; fileRef = CAF100000000000000000007 /* Trio/Resources/Sounds/crying.caf */; }; - CAF200000000000000000008 /* Trio/Resources/Sounds/dings.caf in Resources */ = {isa = PBXBuildFile; fileRef = CAF100000000000000000008 /* Trio/Resources/Sounds/dings.caf */; }; - CAF200000000000000000009 /* Trio/Resources/Sounds/high_chimes.caf in Resources */ = {isa = PBXBuildFile; fileRef = CAF100000000000000000009 /* Trio/Resources/Sounds/high_chimes.caf */; }; - CAF20000000000000000000A /* Trio/Resources/Sounds/honk.caf in Resources */ = {isa = PBXBuildFile; fileRef = CAF10000000000000000000A /* Trio/Resources/Sounds/honk.caf */; }; - CAF20000000000000000000B /* Trio/Resources/Sounds/minimal.caf in Resources */ = {isa = PBXBuildFile; fileRef = CAF10000000000000000000B /* Trio/Resources/Sounds/minimal.caf */; }; - CAF20000000000000000000C /* Trio/Resources/Sounds/mood_synth.caf in Resources */ = {isa = PBXBuildFile; fileRef = CAF10000000000000000000C /* Trio/Resources/Sounds/mood_synth.caf */; }; - CAF20000000000000000000D /* Trio/Resources/Sounds/simple.caf in Resources */ = {isa = PBXBuildFile; fileRef = CAF10000000000000000000D /* Trio/Resources/Sounds/simple.caf */; }; - CAF20000000000000000000E /* Trio/Resources/Sounds/spring.caf in Resources */ = {isa = PBXBuildFile; fileRef = CAF10000000000000000000E /* Trio/Resources/Sounds/spring.caf */; }; - CAF20000000000000000000F /* Trio/Resources/Sounds/synth.caf in Resources */ = {isa = PBXBuildFile; fileRef = CAF10000000000000000000F /* Trio/Resources/Sounds/synth.caf */; }; - CAF200000000000000000010 /* Trio/Resources/Sounds/trill.caf in Resources */ = {isa = PBXBuildFile; fileRef = CAF100000000000000000010 /* Trio/Resources/Sounds/trill.caf */; }; - CAF200000000000000000011 /* Trio/Resources/Sounds/urgent_low.caf in Resources */ = {isa = PBXBuildFile; fileRef = CAF100000000000000000011 /* Trio/Resources/Sounds/urgent_low.caf */; }; - CAF300000000000000000002 /* CriticalAlertAlarmScheduler.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAF300000000000000000001 /* CriticalAlertAlarmScheduler.swift */; }; CC6C406E2ACDD69E009B8058 /* RawFetchedProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC6C406D2ACDD69E009B8058 /* RawFetchedProfile.swift */; }; CC76E9512BD4812E008BEB61 /* Forecast+helper.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC76E9502BD4812E008BEB61 /* Forecast+helper.swift */; }; - CD1235B000000000000000F2 /* TidepoolUploadSerializerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD1235A000000000000000F1 /* TidepoolUploadSerializerTests.swift */; }; CD1235D000000000000000F4 /* TidepoolUploadSerializer.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD1235C000000000000000F3 /* TidepoolUploadSerializer.swift */; }; CE1856F52ADC4858007E39C7 /* AddCarbPresetIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE1856F42ADC4858007E39C7 /* AddCarbPresetIntent.swift */; }; CE1856F72ADC4869007E39C7 /* CarbPresetIntentRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE1856F62ADC4869007E39C7 /* CarbPresetIntentRequest.swift */; }; CE1F6DD92BADF4620064EB8D /* PluginManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE1F6DD82BADF4620064EB8D /* PluginManagerTests.swift */; }; 10DAA318F47843A4B894173D /* ContainerResolutionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98546920BB7247B0AC73449A /* ContainerResolutionTests.swift */; }; + 940584C38CB62676214A2107 /* RemoteMealMutationPayloadTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AF74BBFE9F7B8CA9187C4DB /* RemoteMealMutationPayloadTests.swift */; }; CE1F6DDB2BAE08B60064EB8D /* TidepoolManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE1F6DDA2BAE08B60064EB8D /* TidepoolManager.swift */; }; CE1F6DE72BAF1A180064EB8D /* BuildDetails.plist in Resources */ = {isa = PBXBuildFile; fileRef = CE1F6DE62BAF1A180064EB8D /* BuildDetails.plist */; }; CE1F6DE92BAF37C90064EB8D /* TidepoolConfigView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE1F6DE82BAF37C90064EB8D /* TidepoolConfigView.swift */; }; @@ -902,6 +975,7 @@ DD30BA1A2E08AB9F00DA677C /* CarbImpactParams.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD30BA192E08AB9F00DA677C /* CarbImpactParams.swift */; }; DD32CF982CC82463003686D6 /* TrioRemoteControl+Bolus.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD32CF972CC82460003686D6 /* TrioRemoteControl+Bolus.swift */; }; DD32CF9A2CC8247B003686D6 /* TrioRemoteControl+Meal.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD32CF992CC8246F003686D6 /* TrioRemoteControl+Meal.swift */; }; + FF0425EA9C0AFF6A6DF0B10D /* TrioRemoteControl+MealMutation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0480CC863D1BBC71668F34B3 /* TrioRemoteControl+MealMutation.swift */; }; DD32CF9C2CC82499003686D6 /* TrioRemoteControl+TempTarget.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD32CF9B2CC82495003686D6 /* TrioRemoteControl+TempTarget.swift */; }; DD32CF9E2CC824C5003686D6 /* TrioRemoteControl+Override.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD32CF9D2CC824C2003686D6 /* TrioRemoteControl+Override.swift */; }; DD32CFA02CC824D6003686D6 /* TrioRemoteControl+APNS.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD32CF9F2CC824D3003686D6 /* TrioRemoteControl+APNS.swift */; }; @@ -1104,6 +1178,9 @@ FE66D16B291F74F8005D6F77 /* Bundle+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE66D16A291F74F8005D6F77 /* Bundle+Extensions.swift */; }; FEFFA7A22929FE49007B8193 /* UIDevice+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEFFA7A12929FE49007B8193 /* UIDevice+Extensions.swift */; }; FF40A77F3C63A37109CD2F85 /* ProfileSchedulerProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07629711A990D7666480FF41 /* ProfileSchedulerProvider.swift */; }; + CA0924000000000000000002 /* GRICalculator.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA0924000000000000000001 /* GRICalculator.swift */; }; + CA0924000000000000000004 /* GRIView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA0924000000000000000003 /* GRIView.swift */; }; + CA0924000000000000000006 /* GRICalculatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA0924000000000000000005 /* GRICalculatorTests.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -1359,6 +1436,7 @@ 38AEE73C25F0200C0013F05B /* TrioSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrioSettings.swift; sourceTree = ""; }; 38AEE75125F022080013F05B /* SettingsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsManager.swift; sourceTree = ""; }; 38AEE75625F0F18E0013F05B /* CarbsStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarbsStorage.swift; sourceTree = ""; }; + 3A2F107674CF1D9C0A2A9ACF /* CarbEntryMutationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarbEntryMutationService.swift; sourceTree = ""; }; 38B4F3AE25E2979F00E76A18 /* IndexedCollection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IndexedCollection.swift; sourceTree = ""; }; 38B4F3C225E2A20B00E76A18 /* PumpSetupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PumpSetupView.swift; sourceTree = ""; }; 38B4F3C525E5017E00E76A18 /* NotificationCenter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NotificationCenter.swift; sourceTree = ""; }; @@ -1651,7 +1729,9 @@ 6B1A8D232B14D91700E76752 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 6B1A8D252B14D91700E76752 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 6B1A8D2D2B156EEF00E76752 /* LiveActivityManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActivityManager.swift; sourceTree = ""; }; - 6BCF84DC2B16843A003AD46E /* LiveActitiyAttributes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActitiyAttributes.swift; sourceTree = ""; }; + 6BCF84DC2B16843A003AD46E /* LiveActivityAttributes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActivityAttributes.swift; sourceTree = ""; }; + 7E2CF3BABA4BF1C48CC96FD7 /* LiveActivityGlucoseStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActivityGlucoseStyle.swift; sourceTree = ""; }; + CD6672C97F2AB74125F46DD5 /* LiveActivitySimpleWidgetConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActivitySimpleWidgetConfiguration.swift; sourceTree = ""; }; 6EFF860E684B4C9488D438DD /* NightscoutImportRoundTripTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NightscoutImportRoundTripTests.swift; sourceTree = ""; }; 7099D5F80250373EA64FB4EF /* ProfileScheduleStored+CoreDataClass.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "ProfileScheduleStored+CoreDataClass.swift"; sourceTree = ""; }; 71D44AAA2CA5F5EA0036EE9E /* AlertPermissionsChecker.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AlertPermissionsChecker.swift; sourceTree = ""; }; @@ -1735,6 +1815,89 @@ BD1179252F4E22C100F90001 /* TrioUserNotificationAlertScheduler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrioUserNotificationAlertScheduler.swift; sourceTree = ""; }; BD1179292F4E22C100F90001 /* TrioAlertCategory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrioAlertCategory.swift; sourceTree = ""; }; BD1179312F4E22C100F90001 /* CriticalAlertAudioPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CriticalAlertAudioPlayer.swift; sourceTree = ""; }; + CAF300000000000000000001 /* CriticalAlertAlarmScheduler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CriticalAlertAlarmScheduler.swift; sourceTree = ""; }; + CAF100000000000000000000 /* Trio/Resources/Sounds/alarm.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = Trio/Resources/Sounds/alarm.caf; sourceTree = SOURCE_ROOT; }; + CAF500000000000000000000 /* Trio/Resources/Sounds/acoustic_cr_harsh.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = Trio/Resources/Sounds/acoustic_cr_harsh.caf; sourceTree = SOURCE_ROOT; }; + CAF500000000000000000002 /* Trio/Resources/Sounds/acoustic_cr_medium.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = Trio/Resources/Sounds/acoustic_cr_medium.caf; sourceTree = SOURCE_ROOT; }; + CAF500000000000000000004 /* Trio/Resources/Sounds/acoustic_cr_mild.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = Trio/Resources/Sounds/acoustic_cr_mild.caf; sourceTree = SOURCE_ROOT; }; + CAF500000000000000000006 /* Trio/Resources/Sounds/acoustic_dcr_harsh.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = Trio/Resources/Sounds/acoustic_dcr_harsh.caf; sourceTree = SOURCE_ROOT; }; + CAF500000000000000000008 /* Trio/Resources/Sounds/acoustic_dcr_medium.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = Trio/Resources/Sounds/acoustic_dcr_medium.caf; sourceTree = SOURCE_ROOT; }; + CAF50000000000000000000A /* Trio/Resources/Sounds/acoustic_dcr_mild.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = Trio/Resources/Sounds/acoustic_dcr_mild.caf; sourceTree = SOURCE_ROOT; }; + CAF50000000000000000000C /* Trio/Resources/Sounds/acoustic_dts_harsh.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = Trio/Resources/Sounds/acoustic_dts_harsh.caf; sourceTree = SOURCE_ROOT; }; + CAF50000000000000000000E /* Trio/Resources/Sounds/acoustic_dts_medium.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = Trio/Resources/Sounds/acoustic_dts_medium.caf; sourceTree = SOURCE_ROOT; }; + CAF500000000000000000010 /* Trio/Resources/Sounds/acoustic_dts_mild.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = Trio/Resources/Sounds/acoustic_dts_mild.caf; sourceTree = SOURCE_ROOT; }; + CAF500000000000000000012 /* Trio/Resources/Sounds/acoustic_hg_harsh.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = Trio/Resources/Sounds/acoustic_hg_harsh.caf; sourceTree = SOURCE_ROOT; }; + CAF500000000000000000014 /* Trio/Resources/Sounds/acoustic_hg_medium.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = Trio/Resources/Sounds/acoustic_hg_medium.caf; sourceTree = SOURCE_ROOT; }; + CAF500000000000000000016 /* Trio/Resources/Sounds/acoustic_hg_mild.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = Trio/Resources/Sounds/acoustic_hg_mild.caf; sourceTree = SOURCE_ROOT; }; + CAF500000000000000000018 /* Trio/Resources/Sounds/acoustic_lg_harsh.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = Trio/Resources/Sounds/acoustic_lg_harsh.caf; sourceTree = SOURCE_ROOT; }; + CAF50000000000000000001A /* Trio/Resources/Sounds/acoustic_lg_medium.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = Trio/Resources/Sounds/acoustic_lg_medium.caf; sourceTree = SOURCE_ROOT; }; + CAF50000000000000000001C /* Trio/Resources/Sounds/acoustic_lg_mild.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = Trio/Resources/Sounds/acoustic_lg_mild.caf; sourceTree = SOURCE_ROOT; }; + CAF50000000000000000001E /* Trio/Resources/Sounds/acoustic_lgs_harsh.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = Trio/Resources/Sounds/acoustic_lgs_harsh.caf; sourceTree = SOURCE_ROOT; }; + CAF500000000000000000020 /* Trio/Resources/Sounds/acoustic_lgs_medium.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = Trio/Resources/Sounds/acoustic_lgs_medium.caf; sourceTree = SOURCE_ROOT; }; + CAF500000000000000000022 /* Trio/Resources/Sounds/acoustic_lgs_mild.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = Trio/Resources/Sounds/acoustic_lgs_mild.caf; sourceTree = SOURCE_ROOT; }; + CAF500000000000000000024 /* Trio/Resources/Sounds/acoustic_ulg_harsh.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = Trio/Resources/Sounds/acoustic_ulg_harsh.caf; sourceTree = SOURCE_ROOT; }; + CAF500000000000000000026 /* Trio/Resources/Sounds/acoustic_ulg_medium.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = Trio/Resources/Sounds/acoustic_ulg_medium.caf; sourceTree = SOURCE_ROOT; }; + CAF500000000000000000028 /* Trio/Resources/Sounds/acoustic_ulg_mild.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = Trio/Resources/Sounds/acoustic_ulg_mild.caf; sourceTree = SOURCE_ROOT; }; + CAF50000000000000000002A /* Trio/Resources/Sounds/electronic_cr_harsh.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = Trio/Resources/Sounds/electronic_cr_harsh.caf; sourceTree = SOURCE_ROOT; }; + CAF50000000000000000002C /* Trio/Resources/Sounds/electronic_cr_medium.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = Trio/Resources/Sounds/electronic_cr_medium.caf; sourceTree = SOURCE_ROOT; }; + CAF50000000000000000002E /* Trio/Resources/Sounds/electronic_cr_mild.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = Trio/Resources/Sounds/electronic_cr_mild.caf; sourceTree = SOURCE_ROOT; }; + CAF500000000000000000030 /* Trio/Resources/Sounds/electronic_dcr_harsh.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = Trio/Resources/Sounds/electronic_dcr_harsh.caf; sourceTree = SOURCE_ROOT; }; + CAF500000000000000000032 /* Trio/Resources/Sounds/electronic_dcr_medium.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = Trio/Resources/Sounds/electronic_dcr_medium.caf; sourceTree = SOURCE_ROOT; }; + CAF500000000000000000034 /* Trio/Resources/Sounds/electronic_dcr_mild.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = Trio/Resources/Sounds/electronic_dcr_mild.caf; sourceTree = SOURCE_ROOT; }; + CAF500000000000000000036 /* Trio/Resources/Sounds/electronic_dts_harsh.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = Trio/Resources/Sounds/electronic_dts_harsh.caf; sourceTree = SOURCE_ROOT; }; + CAF500000000000000000038 /* Trio/Resources/Sounds/electronic_dts_medium.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = Trio/Resources/Sounds/electronic_dts_medium.caf; sourceTree = SOURCE_ROOT; }; + CAF50000000000000000003A /* Trio/Resources/Sounds/electronic_dts_mild.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = Trio/Resources/Sounds/electronic_dts_mild.caf; sourceTree = SOURCE_ROOT; }; + CAF50000000000000000003C /* Trio/Resources/Sounds/electronic_hg_harsh.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = Trio/Resources/Sounds/electronic_hg_harsh.caf; sourceTree = SOURCE_ROOT; }; + CAF50000000000000000003E /* Trio/Resources/Sounds/electronic_hg_medium.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = Trio/Resources/Sounds/electronic_hg_medium.caf; sourceTree = SOURCE_ROOT; }; + CAF500000000000000000040 /* Trio/Resources/Sounds/electronic_hg_mild.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = Trio/Resources/Sounds/electronic_hg_mild.caf; sourceTree = SOURCE_ROOT; }; + CAF500000000000000000042 /* Trio/Resources/Sounds/electronic_lg_harsh.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = Trio/Resources/Sounds/electronic_lg_harsh.caf; sourceTree = SOURCE_ROOT; }; + CAF500000000000000000044 /* Trio/Resources/Sounds/electronic_lg_medium.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = Trio/Resources/Sounds/electronic_lg_medium.caf; sourceTree = SOURCE_ROOT; }; + CAF500000000000000000046 /* Trio/Resources/Sounds/electronic_lg_mild.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = Trio/Resources/Sounds/electronic_lg_mild.caf; sourceTree = SOURCE_ROOT; }; + CAF500000000000000000048 /* Trio/Resources/Sounds/electronic_lgs_harsh.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = Trio/Resources/Sounds/electronic_lgs_harsh.caf; sourceTree = SOURCE_ROOT; }; + CAF50000000000000000004A /* Trio/Resources/Sounds/electronic_lgs_medium.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = Trio/Resources/Sounds/electronic_lgs_medium.caf; sourceTree = SOURCE_ROOT; }; + CAF50000000000000000004C /* Trio/Resources/Sounds/electronic_lgs_mild.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = Trio/Resources/Sounds/electronic_lgs_mild.caf; sourceTree = SOURCE_ROOT; }; + CAF50000000000000000004E /* Trio/Resources/Sounds/electronic_ulg_harsh.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = Trio/Resources/Sounds/electronic_ulg_harsh.caf; sourceTree = SOURCE_ROOT; }; + CAF500000000000000000050 /* Trio/Resources/Sounds/electronic_ulg_medium.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = Trio/Resources/Sounds/electronic_ulg_medium.caf; sourceTree = SOURCE_ROOT; }; + CAF500000000000000000052 /* Trio/Resources/Sounds/electronic_ulg_mild.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = Trio/Resources/Sounds/electronic_ulg_mild.caf; sourceTree = SOURCE_ROOT; }; + CAF500000000000000000054 /* Trio/Resources/Sounds/glass_bell.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = Trio/Resources/Sounds/glass_bell.caf; sourceTree = SOURCE_ROOT; }; + CAF500000000000000000056 /* Trio/Resources/Sounds/hard_saw_wave.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = Trio/Resources/Sounds/hard_saw_wave.caf; sourceTree = SOURCE_ROOT; }; + CAF500000000000000000058 /* Trio/Resources/Sounds/harp.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = Trio/Resources/Sounds/harp.caf; sourceTree = SOURCE_ROOT; }; + CAF50000000000000000005A /* Trio/Resources/Sounds/meow.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = Trio/Resources/Sounds/meow.caf; sourceTree = SOURCE_ROOT; }; + CAF50000000000000000005C /* Trio/Resources/Sounds/open_can.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = Trio/Resources/Sounds/open_can.caf; sourceTree = SOURCE_ROOT; }; + CAF50000000000000000005E /* Trio/Resources/Sounds/open_wrapper.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = Trio/Resources/Sounds/open_wrapper.caf; sourceTree = SOURCE_ROOT; }; + CAF500000000000000000060 /* Trio/Resources/Sounds/saw_wave.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = Trio/Resources/Sounds/saw_wave.caf; sourceTree = SOURCE_ROOT; }; + CAF500000000000000000062 /* Trio/Resources/Sounds/sine_wave.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = Trio/Resources/Sounds/sine_wave.caf; sourceTree = SOURCE_ROOT; }; + CAF500000000000000000064 /* Trio/Resources/Sounds/voice_cr_amy.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = Trio/Resources/Sounds/voice_cr_amy.caf; sourceTree = SOURCE_ROOT; }; + CAF500000000000000000066 /* Trio/Resources/Sounds/voice_cr_bryce.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = Trio/Resources/Sounds/voice_cr_bryce.caf; sourceTree = SOURCE_ROOT; }; + CAF500000000000000000068 /* Trio/Resources/Sounds/voice_dcr_amy.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = Trio/Resources/Sounds/voice_dcr_amy.caf; sourceTree = SOURCE_ROOT; }; + CAF50000000000000000006A /* Trio/Resources/Sounds/voice_dcr_bryce.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = Trio/Resources/Sounds/voice_dcr_bryce.caf; sourceTree = SOURCE_ROOT; }; + CAF50000000000000000006C /* Trio/Resources/Sounds/voice_dts_amy.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = Trio/Resources/Sounds/voice_dts_amy.caf; sourceTree = SOURCE_ROOT; }; + CAF50000000000000000006E /* Trio/Resources/Sounds/voice_dts_bryce.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = Trio/Resources/Sounds/voice_dts_bryce.caf; sourceTree = SOURCE_ROOT; }; + CAF500000000000000000070 /* Trio/Resources/Sounds/voice_hg_amy.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = Trio/Resources/Sounds/voice_hg_amy.caf; sourceTree = SOURCE_ROOT; }; + CAF500000000000000000072 /* Trio/Resources/Sounds/voice_hg_bryce.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = Trio/Resources/Sounds/voice_hg_bryce.caf; sourceTree = SOURCE_ROOT; }; + CAF500000000000000000074 /* Trio/Resources/Sounds/voice_lg_amy.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = Trio/Resources/Sounds/voice_lg_amy.caf; sourceTree = SOURCE_ROOT; }; + CAF500000000000000000076 /* Trio/Resources/Sounds/voice_lg_bryce.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = Trio/Resources/Sounds/voice_lg_bryce.caf; sourceTree = SOURCE_ROOT; }; + CAF500000000000000000078 /* Trio/Resources/Sounds/voice_lgs_amy.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = Trio/Resources/Sounds/voice_lgs_amy.caf; sourceTree = SOURCE_ROOT; }; + CAF50000000000000000007A /* Trio/Resources/Sounds/voice_lgs_bryce.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = Trio/Resources/Sounds/voice_lgs_bryce.caf; sourceTree = SOURCE_ROOT; }; + CAF50000000000000000007C /* Trio/Resources/Sounds/voice_ulg_amy.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = Trio/Resources/Sounds/voice_ulg_amy.caf; sourceTree = SOURCE_ROOT; }; + CAF50000000000000000007E /* Trio/Resources/Sounds/voice_ulg_bryce.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = Trio/Resources/Sounds/voice_ulg_bryce.caf; sourceTree = SOURCE_ROOT; }; + CAF100000000000000000001 /* Trio/Resources/Sounds/bloom.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = Trio/Resources/Sounds/bloom.caf; sourceTree = SOURCE_ROOT; }; + CAF100000000000000000002 /* Trio/Resources/Sounds/bloop.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = Trio/Resources/Sounds/bloop.caf; sourceTree = SOURCE_ROOT; }; + CAF100000000000000000003 /* Trio/Resources/Sounds/bright_alarm.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = Trio/Resources/Sounds/bright_alarm.caf; sourceTree = SOURCE_ROOT; }; + CAF100000000000000000004 /* Trio/Resources/Sounds/chime.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = Trio/Resources/Sounds/chime.caf; sourceTree = SOURCE_ROOT; }; + CAF100000000000000000005 /* Trio/Resources/Sounds/clear_chimes.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = Trio/Resources/Sounds/clear_chimes.caf; sourceTree = SOURCE_ROOT; }; + CAF100000000000000000006 /* Trio/Resources/Sounds/critical.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = Trio/Resources/Sounds/critical.caf; sourceTree = SOURCE_ROOT; }; + CAF100000000000000000007 /* Trio/Resources/Sounds/crying.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = Trio/Resources/Sounds/crying.caf; sourceTree = SOURCE_ROOT; }; + CAF100000000000000000008 /* Trio/Resources/Sounds/dings.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = Trio/Resources/Sounds/dings.caf; sourceTree = SOURCE_ROOT; }; + CAF100000000000000000009 /* Trio/Resources/Sounds/high_chimes.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = Trio/Resources/Sounds/high_chimes.caf; sourceTree = SOURCE_ROOT; }; + CAF10000000000000000000A /* Trio/Resources/Sounds/honk.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = Trio/Resources/Sounds/honk.caf; sourceTree = SOURCE_ROOT; }; + CAF10000000000000000000B /* Trio/Resources/Sounds/minimal.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = Trio/Resources/Sounds/minimal.caf; sourceTree = SOURCE_ROOT; }; + CAF10000000000000000000C /* Trio/Resources/Sounds/mood_synth.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = Trio/Resources/Sounds/mood_synth.caf; sourceTree = SOURCE_ROOT; }; + CAF10000000000000000000D /* Trio/Resources/Sounds/simple.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = Trio/Resources/Sounds/simple.caf; sourceTree = SOURCE_ROOT; }; + CAF10000000000000000000E /* Trio/Resources/Sounds/spring.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = Trio/Resources/Sounds/spring.caf; sourceTree = SOURCE_ROOT; }; + CAF10000000000000000000F /* Trio/Resources/Sounds/synth.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = Trio/Resources/Sounds/synth.caf; sourceTree = SOURCE_ROOT; }; + CAF100000000000000000010 /* Trio/Resources/Sounds/trill.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = Trio/Resources/Sounds/trill.caf; sourceTree = SOURCE_ROOT; }; + CAF100000000000000000011 /* Trio/Resources/Sounds/urgent_low.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = Trio/Resources/Sounds/urgent_low.caf; sourceTree = SOURCE_ROOT; }; BD1179412F4E22C100F90001 /* ForecastedGlucoseEvaluator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ForecastedGlucoseEvaluator.swift; sourceTree = ""; }; BD1179512F4E22C100F90001 /* DayNightOptions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DayNightOptions.swift; sourceTree = ""; }; BD1179532F4E22C100F90001 /* GlucoseAlert.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseAlert.swift; sourceTree = ""; }; @@ -1771,11 +1934,13 @@ BD1179AD2F4E22C100F90001 /* GlucoseSourceKey.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseSourceKey.swift; sourceTree = ""; }; BD11A000000000000000A002 /* AlertMuterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertMuterTests.swift; sourceTree = ""; }; BD11A000000000000000A003 /* DeviceAlertsStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceAlertsStoreTests.swift; sourceTree = ""; }; + CAF600000000000000000001 /* AlarmSoundCatalogTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlarmSoundCatalogTests.swift; sourceTree = ""; }; BD11A000000000000000A006 /* ForecastedGlucoseEvaluatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ForecastedGlucoseEvaluatorTests.swift; sourceTree = ""; }; BD11A000000000000000A007 /* GlucoseAlertConfigurationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseAlertConfigurationTests.swift; sourceTree = ""; }; BD11A000000000000000A008 /* GlucoseAlertTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseAlertTests.swift; sourceTree = ""; }; BD11A000000000000000A009 /* TrioAlertCategoryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrioAlertCategoryTests.swift; sourceTree = ""; }; BD11A000000000000000A00A /* GlucoseAlertCoordinatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseAlertCoordinatorTests.swift; sourceTree = ""; }; + BD11A010000000000000A01A /* CGMManagerAlertOwnershipTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CGMManagerAlertOwnershipTests.swift; sourceTree = ""; }; BD11A000000000000000A00B /* TrioModalAlertSchedulerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrioModalAlertSchedulerTests.swift; sourceTree = ""; }; BD11A000000000000000A00C /* NotLoopingMonitorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotLoopingMonitorTests.swift; sourceTree = ""; }; BD11A000000000000000A00D /* AlertCatalogRegistryEntriesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertCatalogRegistryEntriesTests.swift; sourceTree = ""; }; @@ -1859,10 +2024,13 @@ BD8FC05A2D6618AF00B95AED /* DeterminationStorageTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeterminationStorageTests.swift; sourceTree = ""; }; BD8FC05D2D6618CE00B95AED /* BolusCalculatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BolusCalculatorTests.swift; sourceTree = ""; }; BD8FC05F2D6619DB00B95AED /* CarbsStorageTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarbsStorageTests.swift; sourceTree = ""; }; + A19B7FC1E8CD873546B563CB /* CarbEntryMutationServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarbEntryMutationServiceTests.swift; sourceTree = ""; }; BD8FC0612D6619E600B95AED /* OverrideStorageTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverrideStorageTests.swift; sourceTree = ""; }; BD8FC0632D6619EF00B95AED /* TempTargetStorageTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TempTargetStorageTests.swift; sourceTree = ""; }; BD8FC0652D661A0000B95AED /* GlucoseStorageTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseStorageTests.swift; sourceTree = ""; }; BD8FC0702D661B0000B95AED /* TidepoolTherapySettingsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TidepoolTherapySettingsTests.swift; sourceTree = ""; }; + CD1235A000000000000000F1 /* TidepoolUploadSerializerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TidepoolUploadSerializerTests.swift; sourceTree = ""; }; + CE1425A000000000000000A1 /* CarbsEntryLoopKitTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarbsEntryLoopKitTests.swift; sourceTree = ""; }; BDA25EE32D260CCF00035F34 /* AppleWatchManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppleWatchManager.swift; sourceTree = ""; }; BDA25EE52D260D5800035F34 /* WatchState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchState.swift; sourceTree = ""; }; BDA25EFC2D261BF200035F34 /* WatchState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchState.swift; sourceTree = ""; }; @@ -1945,33 +2113,14 @@ CA07000000000000000010C1 /* TelemetrySendGateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TelemetrySendGateTests.swift; sourceTree = ""; }; CA05000000000000000010C1 /* AlarmGramsSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlarmGramsSection.swift; sourceTree = ""; }; CA06000000000000000010C1 /* GlucoseAlertsStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseAlertsStoreTests.swift; sourceTree = ""; }; - CAF100000000000000000000 /* Trio/Resources/Sounds/alarm.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = Trio/Resources/Sounds/alarm.caf; sourceTree = SOURCE_ROOT; }; - CAF100000000000000000001 /* Trio/Resources/Sounds/bloom.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = Trio/Resources/Sounds/bloom.caf; sourceTree = SOURCE_ROOT; }; - CAF100000000000000000002 /* Trio/Resources/Sounds/bloop.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = Trio/Resources/Sounds/bloop.caf; sourceTree = SOURCE_ROOT; }; - CAF100000000000000000003 /* Trio/Resources/Sounds/bright_alarm.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = Trio/Resources/Sounds/bright_alarm.caf; sourceTree = SOURCE_ROOT; }; - CAF100000000000000000004 /* Trio/Resources/Sounds/chime.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = Trio/Resources/Sounds/chime.caf; sourceTree = SOURCE_ROOT; }; - CAF100000000000000000005 /* Trio/Resources/Sounds/clear_chimes.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = Trio/Resources/Sounds/clear_chimes.caf; sourceTree = SOURCE_ROOT; }; - CAF100000000000000000006 /* Trio/Resources/Sounds/critical.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = Trio/Resources/Sounds/critical.caf; sourceTree = SOURCE_ROOT; }; - CAF100000000000000000007 /* Trio/Resources/Sounds/crying.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = Trio/Resources/Sounds/crying.caf; sourceTree = SOURCE_ROOT; }; - CAF100000000000000000008 /* Trio/Resources/Sounds/dings.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = Trio/Resources/Sounds/dings.caf; sourceTree = SOURCE_ROOT; }; - CAF100000000000000000009 /* Trio/Resources/Sounds/high_chimes.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = Trio/Resources/Sounds/high_chimes.caf; sourceTree = SOURCE_ROOT; }; - CAF10000000000000000000A /* Trio/Resources/Sounds/honk.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = Trio/Resources/Sounds/honk.caf; sourceTree = SOURCE_ROOT; }; - CAF10000000000000000000B /* Trio/Resources/Sounds/minimal.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = Trio/Resources/Sounds/minimal.caf; sourceTree = SOURCE_ROOT; }; - CAF10000000000000000000C /* Trio/Resources/Sounds/mood_synth.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = Trio/Resources/Sounds/mood_synth.caf; sourceTree = SOURCE_ROOT; }; - CAF10000000000000000000D /* Trio/Resources/Sounds/simple.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = Trio/Resources/Sounds/simple.caf; sourceTree = SOURCE_ROOT; }; - CAF10000000000000000000E /* Trio/Resources/Sounds/spring.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = Trio/Resources/Sounds/spring.caf; sourceTree = SOURCE_ROOT; }; - CAF10000000000000000000F /* Trio/Resources/Sounds/synth.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = Trio/Resources/Sounds/synth.caf; sourceTree = SOURCE_ROOT; }; - CAF100000000000000000010 /* Trio/Resources/Sounds/trill.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = Trio/Resources/Sounds/trill.caf; sourceTree = SOURCE_ROOT; }; - CAF100000000000000000011 /* Trio/Resources/Sounds/urgent_low.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = Trio/Resources/Sounds/urgent_low.caf; sourceTree = SOURCE_ROOT; }; - CAF300000000000000000001 /* CriticalAlertAlarmScheduler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CriticalAlertAlarmScheduler.swift; sourceTree = ""; }; CC6C406D2ACDD69E009B8058 /* RawFetchedProfile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RawFetchedProfile.swift; sourceTree = ""; }; CC76E9502BD4812E008BEB61 /* Forecast+helper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Forecast+helper.swift"; sourceTree = ""; }; - CD1235A000000000000000F1 /* TidepoolUploadSerializerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TidepoolUploadSerializerTests.swift; sourceTree = ""; }; CD1235C000000000000000F3 /* TidepoolUploadSerializer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TidepoolUploadSerializer.swift; sourceTree = ""; }; CE1856F42ADC4858007E39C7 /* AddCarbPresetIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddCarbPresetIntent.swift; sourceTree = ""; }; CE1856F62ADC4869007E39C7 /* CarbPresetIntentRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarbPresetIntentRequest.swift; sourceTree = ""; }; CE1F6DD82BADF4620064EB8D /* PluginManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PluginManagerTests.swift; sourceTree = ""; }; 98546920BB7247B0AC73449A /* ContainerResolutionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContainerResolutionTests.swift; sourceTree = ""; }; + 7AF74BBFE9F7B8CA9187C4DB /* RemoteMealMutationPayloadTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteMealMutationPayloadTests.swift; sourceTree = ""; }; CE1F6DDA2BAE08B60064EB8D /* TidepoolManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TidepoolManager.swift; sourceTree = ""; }; CE1F6DE62BAF1A180064EB8D /* BuildDetails.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = BuildDetails.plist; sourceTree = ""; }; CE1F6DE82BAF37C90064EB8D /* TidepoolConfigView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TidepoolConfigView.swift; sourceTree = ""; }; @@ -2104,6 +2253,7 @@ DD30BA192E08AB9F00DA677C /* CarbImpactParams.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarbImpactParams.swift; sourceTree = ""; }; DD32CF972CC82460003686D6 /* TrioRemoteControl+Bolus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TrioRemoteControl+Bolus.swift"; sourceTree = ""; }; DD32CF992CC8246F003686D6 /* TrioRemoteControl+Meal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TrioRemoteControl+Meal.swift"; sourceTree = ""; }; + 0480CC863D1BBC71668F34B3 /* TrioRemoteControl+MealMutation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TrioRemoteControl+MealMutation.swift"; sourceTree = ""; }; DD32CF9B2CC82495003686D6 /* TrioRemoteControl+TempTarget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TrioRemoteControl+TempTarget.swift"; sourceTree = ""; }; DD32CF9D2CC824C2003686D6 /* TrioRemoteControl+Override.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TrioRemoteControl+Override.swift"; sourceTree = ""; }; DD32CF9F2CC824D3003686D6 /* TrioRemoteControl+APNS.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TrioRemoteControl+APNS.swift"; sourceTree = ""; }; @@ -2299,6 +2449,9 @@ FE41E4D529463EE20047FD55 /* NightscoutPreferences.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NightscoutPreferences.swift; sourceTree = ""; }; FE66D16A291F74F8005D6F77 /* Bundle+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Bundle+Extensions.swift"; sourceTree = ""; }; FEFFA7A12929FE49007B8193 /* UIDevice+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIDevice+Extensions.swift"; sourceTree = ""; }; + CA0924000000000000000001 /* GRICalculator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GRICalculator.swift; sourceTree = ""; }; + CA0924000000000000000003 /* GRIView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GRIView.swift; sourceTree = ""; }; + CA0924000000000000000005 /* GRICalculatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GRICalculatorTests.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ @@ -2890,6 +3043,70 @@ 49C13F822DE1AFD900556D78 /* Trio.entitlements */, 388E597125AD9CF10019842D /* json */, CAF100000000000000000000 /* Trio/Resources/Sounds/alarm.caf */, + CAF500000000000000000000 /* Trio/Resources/Sounds/acoustic_cr_harsh.caf */, + CAF500000000000000000002 /* Trio/Resources/Sounds/acoustic_cr_medium.caf */, + CAF500000000000000000004 /* Trio/Resources/Sounds/acoustic_cr_mild.caf */, + CAF500000000000000000006 /* Trio/Resources/Sounds/acoustic_dcr_harsh.caf */, + CAF500000000000000000008 /* Trio/Resources/Sounds/acoustic_dcr_medium.caf */, + CAF50000000000000000000A /* Trio/Resources/Sounds/acoustic_dcr_mild.caf */, + CAF50000000000000000000C /* Trio/Resources/Sounds/acoustic_dts_harsh.caf */, + CAF50000000000000000000E /* Trio/Resources/Sounds/acoustic_dts_medium.caf */, + CAF500000000000000000010 /* Trio/Resources/Sounds/acoustic_dts_mild.caf */, + CAF500000000000000000012 /* Trio/Resources/Sounds/acoustic_hg_harsh.caf */, + CAF500000000000000000014 /* Trio/Resources/Sounds/acoustic_hg_medium.caf */, + CAF500000000000000000016 /* Trio/Resources/Sounds/acoustic_hg_mild.caf */, + CAF500000000000000000018 /* Trio/Resources/Sounds/acoustic_lg_harsh.caf */, + CAF50000000000000000001A /* Trio/Resources/Sounds/acoustic_lg_medium.caf */, + CAF50000000000000000001C /* Trio/Resources/Sounds/acoustic_lg_mild.caf */, + CAF50000000000000000001E /* Trio/Resources/Sounds/acoustic_lgs_harsh.caf */, + CAF500000000000000000020 /* Trio/Resources/Sounds/acoustic_lgs_medium.caf */, + CAF500000000000000000022 /* Trio/Resources/Sounds/acoustic_lgs_mild.caf */, + CAF500000000000000000024 /* Trio/Resources/Sounds/acoustic_ulg_harsh.caf */, + CAF500000000000000000026 /* Trio/Resources/Sounds/acoustic_ulg_medium.caf */, + CAF500000000000000000028 /* Trio/Resources/Sounds/acoustic_ulg_mild.caf */, + CAF50000000000000000002A /* Trio/Resources/Sounds/electronic_cr_harsh.caf */, + CAF50000000000000000002C /* Trio/Resources/Sounds/electronic_cr_medium.caf */, + CAF50000000000000000002E /* Trio/Resources/Sounds/electronic_cr_mild.caf */, + CAF500000000000000000030 /* Trio/Resources/Sounds/electronic_dcr_harsh.caf */, + CAF500000000000000000032 /* Trio/Resources/Sounds/electronic_dcr_medium.caf */, + CAF500000000000000000034 /* Trio/Resources/Sounds/electronic_dcr_mild.caf */, + CAF500000000000000000036 /* Trio/Resources/Sounds/electronic_dts_harsh.caf */, + CAF500000000000000000038 /* Trio/Resources/Sounds/electronic_dts_medium.caf */, + CAF50000000000000000003A /* Trio/Resources/Sounds/electronic_dts_mild.caf */, + CAF50000000000000000003C /* Trio/Resources/Sounds/electronic_hg_harsh.caf */, + CAF50000000000000000003E /* Trio/Resources/Sounds/electronic_hg_medium.caf */, + CAF500000000000000000040 /* Trio/Resources/Sounds/electronic_hg_mild.caf */, + CAF500000000000000000042 /* Trio/Resources/Sounds/electronic_lg_harsh.caf */, + CAF500000000000000000044 /* Trio/Resources/Sounds/electronic_lg_medium.caf */, + CAF500000000000000000046 /* Trio/Resources/Sounds/electronic_lg_mild.caf */, + CAF500000000000000000048 /* Trio/Resources/Sounds/electronic_lgs_harsh.caf */, + CAF50000000000000000004A /* Trio/Resources/Sounds/electronic_lgs_medium.caf */, + CAF50000000000000000004C /* Trio/Resources/Sounds/electronic_lgs_mild.caf */, + CAF50000000000000000004E /* Trio/Resources/Sounds/electronic_ulg_harsh.caf */, + CAF500000000000000000050 /* Trio/Resources/Sounds/electronic_ulg_medium.caf */, + CAF500000000000000000052 /* Trio/Resources/Sounds/electronic_ulg_mild.caf */, + CAF500000000000000000054 /* Trio/Resources/Sounds/glass_bell.caf */, + CAF500000000000000000056 /* Trio/Resources/Sounds/hard_saw_wave.caf */, + CAF500000000000000000058 /* Trio/Resources/Sounds/harp.caf */, + CAF50000000000000000005A /* Trio/Resources/Sounds/meow.caf */, + CAF50000000000000000005C /* Trio/Resources/Sounds/open_can.caf */, + CAF50000000000000000005E /* Trio/Resources/Sounds/open_wrapper.caf */, + CAF500000000000000000060 /* Trio/Resources/Sounds/saw_wave.caf */, + CAF500000000000000000062 /* Trio/Resources/Sounds/sine_wave.caf */, + CAF500000000000000000064 /* Trio/Resources/Sounds/voice_cr_amy.caf */, + CAF500000000000000000066 /* Trio/Resources/Sounds/voice_cr_bryce.caf */, + CAF500000000000000000068 /* Trio/Resources/Sounds/voice_dcr_amy.caf */, + CAF50000000000000000006A /* Trio/Resources/Sounds/voice_dcr_bryce.caf */, + CAF50000000000000000006C /* Trio/Resources/Sounds/voice_dts_amy.caf */, + CAF50000000000000000006E /* Trio/Resources/Sounds/voice_dts_bryce.caf */, + CAF500000000000000000070 /* Trio/Resources/Sounds/voice_hg_amy.caf */, + CAF500000000000000000072 /* Trio/Resources/Sounds/voice_hg_bryce.caf */, + CAF500000000000000000074 /* Trio/Resources/Sounds/voice_lg_amy.caf */, + CAF500000000000000000076 /* Trio/Resources/Sounds/voice_lg_bryce.caf */, + CAF500000000000000000078 /* Trio/Resources/Sounds/voice_lgs_amy.caf */, + CAF50000000000000000007A /* Trio/Resources/Sounds/voice_lgs_bryce.caf */, + CAF50000000000000000007C /* Trio/Resources/Sounds/voice_ulg_amy.caf */, + CAF50000000000000000007E /* Trio/Resources/Sounds/voice_ulg_bryce.caf */, CAF100000000000000000001 /* Trio/Resources/Sounds/bloom.caf */, CAF100000000000000000002 /* Trio/Resources/Sounds/bloop.caf */, CAF100000000000000000003 /* Trio/Resources/Sounds/bright_alarm.caf */, @@ -3285,6 +3502,7 @@ isa = PBXGroup; children = ( 38AEE75625F0F18E0013F05B /* CarbsStorage.swift */, + 3A2F107674CF1D9C0A2A9ACF /* CarbEntryMutationService.swift */, DDB37CC42D05048F00D99BF4 /* ContactImageStorage.swift */, 38A0363A25ECF07E00FCBB52 /* GlucoseStorage.swift */, 38FCF3FC25E997A80078B0D1 /* PumpHistoryStorage.swift */, @@ -3430,6 +3648,7 @@ 49AD0CF22F8797F4004301B6 /* AdaptiveUKFOrderingRegressionTest.swift */, 49AD0CF42F8797F5004301B6 /* Fixtures/ukf_python_reference.json */, C2AA6D9B2E1AAC6300BF6C16 /* TDDStorageTimezoneTests.swift */, + CA0924000000000000000005 /* GRICalculatorTests.swift */, DDDD0FFC2F4E22C000F9C645 /* GlucoseNativeConversionTests.swift */, DDDD10A02F4E22C000F9C645 /* CarbsNativeConversionTests.swift */, DDDD10B02F4E22C000F9C645 /* PumpHistoryNativeConversionTests.swift */, @@ -3459,6 +3678,7 @@ BD11A000000000000000A00C /* NotLoopingMonitorTests.swift */, BD11A000000000000000A00B /* TrioModalAlertSchedulerTests.swift */, BD11A000000000000000A00A /* GlucoseAlertCoordinatorTests.swift */, + BD11A010000000000000A01A /* CGMManagerAlertOwnershipTests.swift */, CA06000000000000000010C1 /* GlucoseAlertsStoreTests.swift */, BD11A000000000000000A009 /* TrioAlertCategoryTests.swift */, BD11A000000000000000A008 /* GlucoseAlertTests.swift */, @@ -3466,11 +3686,13 @@ BD11A000000000000000A006 /* ForecastedGlucoseEvaluatorTests.swift */, BD11A000000000000000A003 /* DeviceAlertsStoreTests.swift */, 3B997DD22DC02AEF006B6BB2 /* JSONImporterData */, + CAF600000000000000000001 /* AlarmSoundCatalogTests.swift */, 3B997DCE2DC00A3A006B6BB2 /* JSONImporterTests.swift */, DDC6CA6C2DD90A2A0060EE25 /* LocalizationTests.swift */, CE1F6DD82BADF4620064EB8D /* PluginManagerTests.swift */, DC01A00D2E9F100000000004 /* DeviceCatalogTests.swift */, 98546920BB7247B0AC73449A /* ContainerResolutionTests.swift */, + 7AF74BBFE9F7B8CA9187C4DB /* RemoteMealMutationPayloadTests.swift */, 3B5CD2C72D4AECD500CE213C /* OpenAPSSwiftTests */, 49F4A34B2E044D52008A93CC /* ShareLogsTests */, B3919BBB515547118D684CA2 /* SettingsSearchTests.swift */, @@ -3485,6 +3707,7 @@ 6EFF860E684B4C9488D438DD /* NightscoutImportRoundTripTests.swift */, BD8FC0702D661B0000B95AED /* TidepoolTherapySettingsTests.swift */, CD1235A000000000000000F1 /* TidepoolUploadSerializerTests.swift */, + CE1425A000000000000000A1 /* CarbsEntryLoopKitTests.swift */, ); path = TrioTests; sourceTree = ""; @@ -3916,7 +4139,8 @@ isa = PBXGroup; children = ( 6B1A8D2D2B156EEF00E76752 /* LiveActivityManager.swift */, - 6BCF84DC2B16843A003AD46E /* LiveActitiyAttributes.swift */, + 6BCF84DC2B16843A003AD46E /* LiveActivityAttributes.swift */, + 7E2CF3BABA4BF1C48CC96FD7 /* LiveActivityGlucoseStyle.swift */, BDF34F922C10D0E100D51995 /* LiveActivityAttributes+Helper.swift */, BDF34F882C10C65E00D51995 /* Data */, ); @@ -4356,6 +4580,7 @@ BD8FC0582D66189700B95AED /* TestAssembly.swift */, BD8FC05A2D6618AF00B95AED /* DeterminationStorageTests.swift */, BD8FC05F2D6619DB00B95AED /* CarbsStorageTests.swift */, + A19B7FC1E8CD873546B563CB /* CarbEntryMutationServiceTests.swift */, BD8FC0612D6619E600B95AED /* OverrideStorageTests.swift */, BD8FC0632D6619EF00B95AED /* TempTargetStorageTests.swift */, BD8FC0652D661A0000B95AED /* GlucoseStorageTests.swift */, @@ -4968,6 +5193,7 @@ DD32CF9D2CC824C2003686D6 /* TrioRemoteControl+Override.swift */, DD32CF9B2CC82495003686D6 /* TrioRemoteControl+TempTarget.swift */, DD32CF992CC8246F003686D6 /* TrioRemoteControl+Meal.swift */, + 0480CC863D1BBC71668F34B3 /* TrioRemoteControl+MealMutation.swift */, DD32CF972CC82460003686D6 /* TrioRemoteControl+Bolus.swift */, DD9ECB672CA99F4500AA7C45 /* TrioRemoteControl.swift */, ); @@ -5058,6 +5284,8 @@ BD249D8B2D42FC2500412DEB /* GlucoseDistributionChart.swift */, BD249D892D42FC0E00412DEB /* GlucosePercentileChart.swift */, BD249D852D42FBE600412DEB /* GlucoseMetricsView.swift */, + CA0924000000000000000003 /* GRIView.swift */, + CA0924000000000000000001 /* GRICalculator.swift */, ); path = Glucose; sourceTree = ""; @@ -5180,6 +5408,7 @@ children = ( DDF847E32C5C288F0049BB3B /* LiveActivitySettingsRootView.swift */, BD6EB2D52C7D049B0086BBB6 /* LiveActivityWidgetConfiguration.swift */, + CD6672C97F2AB74125F46DD5 /* LiveActivitySimpleWidgetConfiguration.swift */, ); path = View; sourceTree = ""; @@ -5559,6 +5788,70 @@ 19DA48E829CD339B00EEA1E7 /* Assets.xcassets in Resources */, 8A91342A2D63D9A1007F8874 /* Localizable.xcstrings in Resources */, CAF200000000000000000000 /* Trio/Resources/Sounds/alarm.caf in Resources */, + CAF500000000000000000001 /* Trio/Resources/Sounds/acoustic_cr_harsh.caf in Resources */, + CAF500000000000000000003 /* Trio/Resources/Sounds/acoustic_cr_medium.caf in Resources */, + CAF500000000000000000005 /* Trio/Resources/Sounds/acoustic_cr_mild.caf in Resources */, + CAF500000000000000000007 /* Trio/Resources/Sounds/acoustic_dcr_harsh.caf in Resources */, + CAF500000000000000000009 /* Trio/Resources/Sounds/acoustic_dcr_medium.caf in Resources */, + CAF50000000000000000000B /* Trio/Resources/Sounds/acoustic_dcr_mild.caf in Resources */, + CAF50000000000000000000D /* Trio/Resources/Sounds/acoustic_dts_harsh.caf in Resources */, + CAF50000000000000000000F /* Trio/Resources/Sounds/acoustic_dts_medium.caf in Resources */, + CAF500000000000000000011 /* Trio/Resources/Sounds/acoustic_dts_mild.caf in Resources */, + CAF500000000000000000013 /* Trio/Resources/Sounds/acoustic_hg_harsh.caf in Resources */, + CAF500000000000000000015 /* Trio/Resources/Sounds/acoustic_hg_medium.caf in Resources */, + CAF500000000000000000017 /* Trio/Resources/Sounds/acoustic_hg_mild.caf in Resources */, + CAF500000000000000000019 /* Trio/Resources/Sounds/acoustic_lg_harsh.caf in Resources */, + CAF50000000000000000001B /* Trio/Resources/Sounds/acoustic_lg_medium.caf in Resources */, + CAF50000000000000000001D /* Trio/Resources/Sounds/acoustic_lg_mild.caf in Resources */, + CAF50000000000000000001F /* Trio/Resources/Sounds/acoustic_lgs_harsh.caf in Resources */, + CAF500000000000000000021 /* Trio/Resources/Sounds/acoustic_lgs_medium.caf in Resources */, + CAF500000000000000000023 /* Trio/Resources/Sounds/acoustic_lgs_mild.caf in Resources */, + CAF500000000000000000025 /* Trio/Resources/Sounds/acoustic_ulg_harsh.caf in Resources */, + CAF500000000000000000027 /* Trio/Resources/Sounds/acoustic_ulg_medium.caf in Resources */, + CAF500000000000000000029 /* Trio/Resources/Sounds/acoustic_ulg_mild.caf in Resources */, + CAF50000000000000000002B /* Trio/Resources/Sounds/electronic_cr_harsh.caf in Resources */, + CAF50000000000000000002D /* Trio/Resources/Sounds/electronic_cr_medium.caf in Resources */, + CAF50000000000000000002F /* Trio/Resources/Sounds/electronic_cr_mild.caf in Resources */, + CAF500000000000000000031 /* Trio/Resources/Sounds/electronic_dcr_harsh.caf in Resources */, + CAF500000000000000000033 /* Trio/Resources/Sounds/electronic_dcr_medium.caf in Resources */, + CAF500000000000000000035 /* Trio/Resources/Sounds/electronic_dcr_mild.caf in Resources */, + CAF500000000000000000037 /* Trio/Resources/Sounds/electronic_dts_harsh.caf in Resources */, + CAF500000000000000000039 /* Trio/Resources/Sounds/electronic_dts_medium.caf in Resources */, + CAF50000000000000000003B /* Trio/Resources/Sounds/electronic_dts_mild.caf in Resources */, + CAF50000000000000000003D /* Trio/Resources/Sounds/electronic_hg_harsh.caf in Resources */, + CAF50000000000000000003F /* Trio/Resources/Sounds/electronic_hg_medium.caf in Resources */, + CAF500000000000000000041 /* Trio/Resources/Sounds/electronic_hg_mild.caf in Resources */, + CAF500000000000000000043 /* Trio/Resources/Sounds/electronic_lg_harsh.caf in Resources */, + CAF500000000000000000045 /* Trio/Resources/Sounds/electronic_lg_medium.caf in Resources */, + CAF500000000000000000047 /* Trio/Resources/Sounds/electronic_lg_mild.caf in Resources */, + CAF500000000000000000049 /* Trio/Resources/Sounds/electronic_lgs_harsh.caf in Resources */, + CAF50000000000000000004B /* Trio/Resources/Sounds/electronic_lgs_medium.caf in Resources */, + CAF50000000000000000004D /* Trio/Resources/Sounds/electronic_lgs_mild.caf in Resources */, + CAF50000000000000000004F /* Trio/Resources/Sounds/electronic_ulg_harsh.caf in Resources */, + CAF500000000000000000051 /* Trio/Resources/Sounds/electronic_ulg_medium.caf in Resources */, + CAF500000000000000000053 /* Trio/Resources/Sounds/electronic_ulg_mild.caf in Resources */, + CAF500000000000000000055 /* Trio/Resources/Sounds/glass_bell.caf in Resources */, + CAF500000000000000000057 /* Trio/Resources/Sounds/hard_saw_wave.caf in Resources */, + CAF500000000000000000059 /* Trio/Resources/Sounds/harp.caf in Resources */, + CAF50000000000000000005B /* Trio/Resources/Sounds/meow.caf in Resources */, + CAF50000000000000000005D /* Trio/Resources/Sounds/open_can.caf in Resources */, + CAF50000000000000000005F /* Trio/Resources/Sounds/open_wrapper.caf in Resources */, + CAF500000000000000000061 /* Trio/Resources/Sounds/saw_wave.caf in Resources */, + CAF500000000000000000063 /* Trio/Resources/Sounds/sine_wave.caf in Resources */, + CAF500000000000000000065 /* Trio/Resources/Sounds/voice_cr_amy.caf in Resources */, + CAF500000000000000000067 /* Trio/Resources/Sounds/voice_cr_bryce.caf in Resources */, + CAF500000000000000000069 /* Trio/Resources/Sounds/voice_dcr_amy.caf in Resources */, + CAF50000000000000000006B /* Trio/Resources/Sounds/voice_dcr_bryce.caf in Resources */, + CAF50000000000000000006D /* Trio/Resources/Sounds/voice_dts_amy.caf in Resources */, + CAF50000000000000000006F /* Trio/Resources/Sounds/voice_dts_bryce.caf in Resources */, + CAF500000000000000000071 /* Trio/Resources/Sounds/voice_hg_amy.caf in Resources */, + CAF500000000000000000073 /* Trio/Resources/Sounds/voice_hg_bryce.caf in Resources */, + CAF500000000000000000075 /* Trio/Resources/Sounds/voice_lg_amy.caf in Resources */, + CAF500000000000000000077 /* Trio/Resources/Sounds/voice_lg_bryce.caf in Resources */, + CAF500000000000000000079 /* Trio/Resources/Sounds/voice_lgs_amy.caf in Resources */, + CAF50000000000000000007B /* Trio/Resources/Sounds/voice_lgs_bryce.caf in Resources */, + CAF50000000000000000007D /* Trio/Resources/Sounds/voice_ulg_amy.caf in Resources */, + CAF50000000000000000007F /* Trio/Resources/Sounds/voice_ulg_bryce.caf in Resources */, CAF200000000000000000001 /* Trio/Resources/Sounds/bloom.caf in Resources */, CAF200000000000000000002 /* Trio/Resources/Sounds/bloop.caf in Resources */, CAF200000000000000000003 /* Trio/Resources/Sounds/bright_alarm.caf in Resources */, @@ -5758,6 +6051,7 @@ 1967DFC229D053D300759F30 /* IconImage.swift in Sources */, 382C134B25F14E3700715CE1 /* BGTargets.swift in Sources */, 38AEE75725F0F18E0013F05B /* CarbsStorage.swift in Sources */, + E73259BE7A7D1AFB93D6B8DF /* CarbEntryMutationService.swift in Sources */, 38B4F3CA25E502E200E76A18 /* SwiftNotificationCenter.swift in Sources */, 38AEE75225F022080013F05B /* SettingsManager.swift in Sources */, ADA10008ADA10008ADA10008 /* SettingsScope.swift in Sources */, @@ -5874,6 +6168,8 @@ E013D872273AC6FE0014109C /* GlucoseSimulatorSource.swift in Sources */, 3BAE87702E480BE100FCA8D2 /* ForecastResults.swift in Sources */, BD249D862D42FBEC00412DEB /* GlucoseMetricsView.swift in Sources */, + CA0924000000000000000004 /* GRIView.swift in Sources */, + CA0924000000000000000002 /* GRICalculator.swift in Sources */, 58645BA32CA2D325008AFCE7 /* BatterySetup.swift in Sources */, DD82D4B82DCAB2BA00BAFC77 /* PropertyPersistentFlags.swift in Sources */, DD7E1E300000000000000002 /* TelemetryClient.swift in Sources */, @@ -5893,6 +6189,7 @@ BD249D942D42FC5E00412DEB /* TotalDailyDoseChart.swift in Sources */, BD249D902D42FC4500412DEB /* MealStatsView.swift in Sources */, DD32CF9A2CC8247B003686D6 /* TrioRemoteControl+Meal.swift in Sources */, + FF0425EA9C0AFF6A6DF0B10D /* TrioRemoteControl+MealMutation.swift in Sources */, BDF34F832C10C5B600D51995 /* DataManager.swift in Sources */, 38B4F3C625E5017E00E76A18 /* NotificationCenter.swift in Sources */, 19D466A729AA2C22004D5F33 /* MealSettingsStateModel.swift in Sources */, @@ -6440,7 +6737,9 @@ DB0BB0030000000000000002 /* ReleaseNotesListView.swift in Sources */, 19F95FFA29F1102A00314DDC /* StatRootView.swift in Sources */, 0D9A5E34A899219C5C4CDFAF /* HistoryStateModel.swift in Sources */, - 6BCF84DD2B16843A003AD46E /* LiveActitiyAttributes.swift in Sources */, + 6BCF84DD2B16843A003AD46E /* LiveActivityAttributes.swift in Sources */, + AC096399E4D2F4950BA8E5CF /* LiveActivityGlucoseStyle.swift in Sources */, + 8B74AA51DC49429FB9F19159 /* LiveActivitySimpleWidgetConfiguration.swift in Sources */, 195D80B92AF697F700D25097 /* DynamicSettingsProvider.swift in Sources */, DD3F1F832D9DC78800DCE7B3 /* UnitSelectionStepView.swift in Sources */, DD09D47D2C5986DA003FEA5D /* CalendarEventSettingsProvider.swift in Sources */, @@ -6586,6 +6885,7 @@ FB9012AE66284A92A6531037 /* NightscoutUploadSerializerTests.swift in Sources */, 782BE9AEFDD94938A6F3EFE9 /* NightscoutImportRoundTripTests.swift in Sources */, DDDD0FFB2F4E22C000F9C645 /* GlucoseSmoothingTests.swift in Sources */, + CA0924000000000000000006 /* GRICalculatorTests.swift in Sources */, DDDD0FFD2F4E22C000F9C645 /* GlucoseNativeConversionTests.swift in Sources */, DDDD10A12F4E22C000F9C645 /* CarbsNativeConversionTests.swift in Sources */, DDDD10B12F4E22C000F9C645 /* PumpHistoryNativeConversionTests.swift in Sources */, @@ -6611,6 +6911,7 @@ CE1F6DD92BADF4620064EB8D /* PluginManagerTests.swift in Sources */, DC01A00E2E9F100000000005 /* DeviceCatalogTests.swift in Sources */, 10DAA318F47843A4B894173D /* ContainerResolutionTests.swift in Sources */, + 940584C38CB62676214A2107 /* RemoteMealMutationPayloadTests.swift in Sources */, 3B1C5C432D68E269004E9273 /* Extensions.swift in Sources */, 3BC26E552D7418830066ACD6 /* IobSuspendTests.swift in Sources */, DD758EDE2ECC656500EF5D54 /* DetermineBasalSmbMicroBolusTests.swift in Sources */, @@ -6644,15 +6945,18 @@ BD11A001000000000000A00C /* NotLoopingMonitorTests.swift in Sources */, BD11A001000000000000A00B /* TrioModalAlertSchedulerTests.swift in Sources */, BD11A001000000000000A00A /* GlucoseAlertCoordinatorTests.swift in Sources */, + BD11A011000000000000A01A /* CGMManagerAlertOwnershipTests.swift in Sources */, CA06000000000000000010C2 /* GlucoseAlertsStoreTests.swift in Sources */, BD11A001000000000000A009 /* TrioAlertCategoryTests.swift in Sources */, BD11A001000000000000A008 /* GlucoseAlertTests.swift in Sources */, BD11A001000000000000A007 /* GlucoseAlertConfigurationTests.swift in Sources */, BD11A001000000000000A006 /* ForecastedGlucoseEvaluatorTests.swift in Sources */, BD11A001000000000000A003 /* DeviceAlertsStoreTests.swift in Sources */, + CAF600000000000000000002 /* AlarmSoundCatalogTests.swift in Sources */, 3B8221B22E5882E300585156 /* DetermineBasalEarlyExitTests.swift in Sources */, 3BA643EA2ED9FAD8007BC31F /* DetermineBasalAggressiveDosingTests.swift in Sources */, BD8FC0602D6619DB00B95AED /* CarbsStorageTests.swift in Sources */, + 36BDB3927CB9ABFB548ED2B0 /* CarbEntryMutationServiceTests.swift in Sources */, DD30BA002E0745C400DA677C /* DetermineBasalDeltaCalculationTests.swift in Sources */, BD8FC05E2D6618CE00B95AED /* BolusCalculatorTests.swift in Sources */, FA4ABBF5B87BDDE48455AF59 /* ProfileScheduleTests.swift in Sources */, @@ -6671,6 +6975,7 @@ 9A3C0DE10000000000000B0A /* WatchCommandIntegrationTests.swift in Sources */, BD8FC0712D661B0000B95AED /* TidepoolTherapySettingsTests.swift in Sources */, CD1235B000000000000000F2 /* TidepoolUploadSerializerTests.swift in Sources */, + CE1425B000000000000000A2 /* CarbsEntryLoopKitTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -6678,7 +6983,8 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 6BCF84DE2B16843A003AD46E /* LiveActitiyAttributes.swift in Sources */, + 6BCF84DE2B16843A003AD46E /* LiveActivityAttributes.swift in Sources */, + 39714DB1CF5B2C1F8D990CED /* LiveActivityGlucoseStyle.swift in Sources */, DDCEBF5B2CC1B76400DF4C36 /* LiveActivity+Helper.swift in Sources */, 6B1A8D1E2B14D91600E76752 /* LiveActivityBundle.swift in Sources */, 6B1A8D202B14D91600E76752 /* LiveActivity.swift in Sources */, diff --git a/Trio.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Trio.xcworkspace/xcshareddata/swiftpm/Package.resolved index d41eeb3fb5..0d8c52a9da 100644 --- a/Trio.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Trio.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "973feeef4e21f21c48a7c4bfeac8b4fe6a14e440161284f4172c22a02cd87ada", + "originHash" : "56a4efdc508f151775b4f0d015879657e92b0009940fe57b8dbdcef5bffa2a95", "pins" : [ { "identity" : "abseil-cpp-binary", diff --git a/Trio/Resources/Info.plist b/Trio/Resources/Info.plist index 7c61c3bb97..327746a4fa 100644 --- a/Trio/Resources/Info.plist +++ b/Trio/Resources/Info.plist @@ -53,6 +53,8 @@ gcm-ciq dexcomg7 + dexcomoneplus + stelo xdripswift dexcomg6 dexcomcgm @@ -81,6 +83,8 @@ To create events with glucose values, so they can be viewed on Apple Watch and CarPlay NSCalendarsUsageDescription Calendar is used to create new glucose events. + NSCameraUsageDescription + Camera is used to scan the code on your Dexcom sensor packaging. NSContactsUsageDescription Contact is used to create a Apple Watch complication NSFaceIDUsageDescription diff --git a/Trio/Resources/InfoPlist.xcstrings b/Trio/Resources/InfoPlist.xcstrings index 86bac04a78..79164fb470 100644 --- a/Trio/Resources/InfoPlist.xcstrings +++ b/Trio/Resources/InfoPlist.xcstrings @@ -1105,6 +1105,18 @@ } } }, + "NSCameraUsageDescription" : { + "comment" : "Privacy - Camera Usage Description", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Camera is used to scan the code on your Dexcom sensor packaging." + } + } + } + }, "NSContactsUsageDescription" : { "comment" : "Privacy - Contacts Usage Description", "localizations" : { @@ -1806,4 +1818,4 @@ } }, "version" : "1.0" -} +} \ No newline at end of file diff --git a/Trio/Resources/Sounds/acoustic_cr_harsh.caf b/Trio/Resources/Sounds/acoustic_cr_harsh.caf new file mode 100644 index 0000000000..1c938bda03 Binary files /dev/null and b/Trio/Resources/Sounds/acoustic_cr_harsh.caf differ diff --git a/Trio/Resources/Sounds/acoustic_cr_medium.caf b/Trio/Resources/Sounds/acoustic_cr_medium.caf new file mode 100644 index 0000000000..8566bcbc2b Binary files /dev/null and b/Trio/Resources/Sounds/acoustic_cr_medium.caf differ diff --git a/Trio/Resources/Sounds/acoustic_cr_mild.caf b/Trio/Resources/Sounds/acoustic_cr_mild.caf new file mode 100644 index 0000000000..b91072c8a4 Binary files /dev/null and b/Trio/Resources/Sounds/acoustic_cr_mild.caf differ diff --git a/Trio/Resources/Sounds/acoustic_dcr_harsh.caf b/Trio/Resources/Sounds/acoustic_dcr_harsh.caf new file mode 100644 index 0000000000..1252da1aaf Binary files /dev/null and b/Trio/Resources/Sounds/acoustic_dcr_harsh.caf differ diff --git a/Trio/Resources/Sounds/acoustic_dcr_medium.caf b/Trio/Resources/Sounds/acoustic_dcr_medium.caf new file mode 100644 index 0000000000..e070a311fc Binary files /dev/null and b/Trio/Resources/Sounds/acoustic_dcr_medium.caf differ diff --git a/Trio/Resources/Sounds/acoustic_dcr_mild.caf b/Trio/Resources/Sounds/acoustic_dcr_mild.caf new file mode 100644 index 0000000000..8f370a800b Binary files /dev/null and b/Trio/Resources/Sounds/acoustic_dcr_mild.caf differ diff --git a/Trio/Resources/Sounds/acoustic_dts_harsh.caf b/Trio/Resources/Sounds/acoustic_dts_harsh.caf new file mode 100644 index 0000000000..49c7ff64b3 Binary files /dev/null and b/Trio/Resources/Sounds/acoustic_dts_harsh.caf differ diff --git a/Trio/Resources/Sounds/acoustic_dts_medium.caf b/Trio/Resources/Sounds/acoustic_dts_medium.caf new file mode 100644 index 0000000000..03cfc9e1c4 Binary files /dev/null and b/Trio/Resources/Sounds/acoustic_dts_medium.caf differ diff --git a/Trio/Resources/Sounds/acoustic_dts_mild.caf b/Trio/Resources/Sounds/acoustic_dts_mild.caf new file mode 100644 index 0000000000..7e903c889e Binary files /dev/null and b/Trio/Resources/Sounds/acoustic_dts_mild.caf differ diff --git a/Trio/Resources/Sounds/acoustic_hg_harsh.caf b/Trio/Resources/Sounds/acoustic_hg_harsh.caf new file mode 100644 index 0000000000..7f22954efe Binary files /dev/null and b/Trio/Resources/Sounds/acoustic_hg_harsh.caf differ diff --git a/Trio/Resources/Sounds/acoustic_hg_medium.caf b/Trio/Resources/Sounds/acoustic_hg_medium.caf new file mode 100644 index 0000000000..8fbac81357 Binary files /dev/null and b/Trio/Resources/Sounds/acoustic_hg_medium.caf differ diff --git a/Trio/Resources/Sounds/acoustic_hg_mild.caf b/Trio/Resources/Sounds/acoustic_hg_mild.caf new file mode 100644 index 0000000000..3b960abd67 Binary files /dev/null and b/Trio/Resources/Sounds/acoustic_hg_mild.caf differ diff --git a/Trio/Resources/Sounds/acoustic_lg_harsh.caf b/Trio/Resources/Sounds/acoustic_lg_harsh.caf new file mode 100644 index 0000000000..56fdcce591 Binary files /dev/null and b/Trio/Resources/Sounds/acoustic_lg_harsh.caf differ diff --git a/Trio/Resources/Sounds/acoustic_lg_medium.caf b/Trio/Resources/Sounds/acoustic_lg_medium.caf new file mode 100644 index 0000000000..42628d0c32 Binary files /dev/null and b/Trio/Resources/Sounds/acoustic_lg_medium.caf differ diff --git a/Trio/Resources/Sounds/acoustic_lg_mild.caf b/Trio/Resources/Sounds/acoustic_lg_mild.caf new file mode 100644 index 0000000000..cdc74df643 Binary files /dev/null and b/Trio/Resources/Sounds/acoustic_lg_mild.caf differ diff --git a/Trio/Resources/Sounds/acoustic_lgs_harsh.caf b/Trio/Resources/Sounds/acoustic_lgs_harsh.caf new file mode 100644 index 0000000000..281a3303f9 Binary files /dev/null and b/Trio/Resources/Sounds/acoustic_lgs_harsh.caf differ diff --git a/Trio/Resources/Sounds/acoustic_lgs_medium.caf b/Trio/Resources/Sounds/acoustic_lgs_medium.caf new file mode 100644 index 0000000000..ff12868285 Binary files /dev/null and b/Trio/Resources/Sounds/acoustic_lgs_medium.caf differ diff --git a/Trio/Resources/Sounds/acoustic_lgs_mild.caf b/Trio/Resources/Sounds/acoustic_lgs_mild.caf new file mode 100644 index 0000000000..237f8572ca Binary files /dev/null and b/Trio/Resources/Sounds/acoustic_lgs_mild.caf differ diff --git a/Trio/Resources/Sounds/acoustic_ulg_harsh.caf b/Trio/Resources/Sounds/acoustic_ulg_harsh.caf new file mode 100644 index 0000000000..40f693661a Binary files /dev/null and b/Trio/Resources/Sounds/acoustic_ulg_harsh.caf differ diff --git a/Trio/Resources/Sounds/acoustic_ulg_medium.caf b/Trio/Resources/Sounds/acoustic_ulg_medium.caf new file mode 100644 index 0000000000..aa08139273 Binary files /dev/null and b/Trio/Resources/Sounds/acoustic_ulg_medium.caf differ diff --git a/Trio/Resources/Sounds/acoustic_ulg_mild.caf b/Trio/Resources/Sounds/acoustic_ulg_mild.caf new file mode 100644 index 0000000000..953edb266e Binary files /dev/null and b/Trio/Resources/Sounds/acoustic_ulg_mild.caf differ diff --git a/Trio/Resources/Sounds/chime.caf b/Trio/Resources/Sounds/chime.caf index 1e699420c0..cd20ec0af6 100644 Binary files a/Trio/Resources/Sounds/chime.caf and b/Trio/Resources/Sounds/chime.caf differ diff --git a/Trio/Resources/Sounds/critical.caf b/Trio/Resources/Sounds/critical.caf index f94736bf71..9ce9c84096 100644 Binary files a/Trio/Resources/Sounds/critical.caf and b/Trio/Resources/Sounds/critical.caf differ diff --git a/Trio/Resources/Sounds/electronic_cr_harsh.caf b/Trio/Resources/Sounds/electronic_cr_harsh.caf new file mode 100644 index 0000000000..58fe4149b6 Binary files /dev/null and b/Trio/Resources/Sounds/electronic_cr_harsh.caf differ diff --git a/Trio/Resources/Sounds/electronic_cr_medium.caf b/Trio/Resources/Sounds/electronic_cr_medium.caf new file mode 100644 index 0000000000..db36009a2d Binary files /dev/null and b/Trio/Resources/Sounds/electronic_cr_medium.caf differ diff --git a/Trio/Resources/Sounds/electronic_cr_mild.caf b/Trio/Resources/Sounds/electronic_cr_mild.caf new file mode 100644 index 0000000000..5560055172 Binary files /dev/null and b/Trio/Resources/Sounds/electronic_cr_mild.caf differ diff --git a/Trio/Resources/Sounds/electronic_dcr_harsh.caf b/Trio/Resources/Sounds/electronic_dcr_harsh.caf new file mode 100644 index 0000000000..778c7ad0a0 Binary files /dev/null and b/Trio/Resources/Sounds/electronic_dcr_harsh.caf differ diff --git a/Trio/Resources/Sounds/electronic_dcr_medium.caf b/Trio/Resources/Sounds/electronic_dcr_medium.caf new file mode 100644 index 0000000000..ce4b7f82a9 Binary files /dev/null and b/Trio/Resources/Sounds/electronic_dcr_medium.caf differ diff --git a/Trio/Resources/Sounds/electronic_dcr_mild.caf b/Trio/Resources/Sounds/electronic_dcr_mild.caf new file mode 100644 index 0000000000..342e03c872 Binary files /dev/null and b/Trio/Resources/Sounds/electronic_dcr_mild.caf differ diff --git a/Trio/Resources/Sounds/electronic_dts_harsh.caf b/Trio/Resources/Sounds/electronic_dts_harsh.caf new file mode 100644 index 0000000000..c419a9ab8c Binary files /dev/null and b/Trio/Resources/Sounds/electronic_dts_harsh.caf differ diff --git a/Trio/Resources/Sounds/electronic_dts_medium.caf b/Trio/Resources/Sounds/electronic_dts_medium.caf new file mode 100644 index 0000000000..0fb64107c0 Binary files /dev/null and b/Trio/Resources/Sounds/electronic_dts_medium.caf differ diff --git a/Trio/Resources/Sounds/electronic_dts_mild.caf b/Trio/Resources/Sounds/electronic_dts_mild.caf new file mode 100644 index 0000000000..c58406c758 Binary files /dev/null and b/Trio/Resources/Sounds/electronic_dts_mild.caf differ diff --git a/Trio/Resources/Sounds/electronic_hg_harsh.caf b/Trio/Resources/Sounds/electronic_hg_harsh.caf new file mode 100644 index 0000000000..3e10872fbd Binary files /dev/null and b/Trio/Resources/Sounds/electronic_hg_harsh.caf differ diff --git a/Trio/Resources/Sounds/electronic_hg_medium.caf b/Trio/Resources/Sounds/electronic_hg_medium.caf new file mode 100644 index 0000000000..76bff57943 Binary files /dev/null and b/Trio/Resources/Sounds/electronic_hg_medium.caf differ diff --git a/Trio/Resources/Sounds/electronic_hg_mild.caf b/Trio/Resources/Sounds/electronic_hg_mild.caf new file mode 100644 index 0000000000..262f7ea76a Binary files /dev/null and b/Trio/Resources/Sounds/electronic_hg_mild.caf differ diff --git a/Trio/Resources/Sounds/electronic_lg_harsh.caf b/Trio/Resources/Sounds/electronic_lg_harsh.caf new file mode 100644 index 0000000000..2bacc6d569 Binary files /dev/null and b/Trio/Resources/Sounds/electronic_lg_harsh.caf differ diff --git a/Trio/Resources/Sounds/electronic_lg_medium.caf b/Trio/Resources/Sounds/electronic_lg_medium.caf new file mode 100644 index 0000000000..d77e6f04f3 Binary files /dev/null and b/Trio/Resources/Sounds/electronic_lg_medium.caf differ diff --git a/Trio/Resources/Sounds/electronic_lg_mild.caf b/Trio/Resources/Sounds/electronic_lg_mild.caf new file mode 100644 index 0000000000..29f88442c7 Binary files /dev/null and b/Trio/Resources/Sounds/electronic_lg_mild.caf differ diff --git a/Trio/Resources/Sounds/electronic_lgs_harsh.caf b/Trio/Resources/Sounds/electronic_lgs_harsh.caf new file mode 100644 index 0000000000..6212ae047c Binary files /dev/null and b/Trio/Resources/Sounds/electronic_lgs_harsh.caf differ diff --git a/Trio/Resources/Sounds/electronic_lgs_medium.caf b/Trio/Resources/Sounds/electronic_lgs_medium.caf new file mode 100644 index 0000000000..3bb0bc8b92 Binary files /dev/null and b/Trio/Resources/Sounds/electronic_lgs_medium.caf differ diff --git a/Trio/Resources/Sounds/electronic_lgs_mild.caf b/Trio/Resources/Sounds/electronic_lgs_mild.caf new file mode 100644 index 0000000000..b24691e43d Binary files /dev/null and b/Trio/Resources/Sounds/electronic_lgs_mild.caf differ diff --git a/Trio/Resources/Sounds/electronic_ulg_harsh.caf b/Trio/Resources/Sounds/electronic_ulg_harsh.caf new file mode 100644 index 0000000000..e9cc2037aa Binary files /dev/null and b/Trio/Resources/Sounds/electronic_ulg_harsh.caf differ diff --git a/Trio/Resources/Sounds/electronic_ulg_medium.caf b/Trio/Resources/Sounds/electronic_ulg_medium.caf new file mode 100644 index 0000000000..d2fc23a459 Binary files /dev/null and b/Trio/Resources/Sounds/electronic_ulg_medium.caf differ diff --git a/Trio/Resources/Sounds/electronic_ulg_mild.caf b/Trio/Resources/Sounds/electronic_ulg_mild.caf new file mode 100644 index 0000000000..0b5155c467 Binary files /dev/null and b/Trio/Resources/Sounds/electronic_ulg_mild.caf differ diff --git a/Trio/Resources/Sounds/glass_bell.caf b/Trio/Resources/Sounds/glass_bell.caf new file mode 100644 index 0000000000..cfdf3c7446 Binary files /dev/null and b/Trio/Resources/Sounds/glass_bell.caf differ diff --git a/Trio/Resources/Sounds/hard_saw_wave.caf b/Trio/Resources/Sounds/hard_saw_wave.caf new file mode 100644 index 0000000000..e7e068d9ba Binary files /dev/null and b/Trio/Resources/Sounds/hard_saw_wave.caf differ diff --git a/Trio/Resources/Sounds/harp.caf b/Trio/Resources/Sounds/harp.caf new file mode 100644 index 0000000000..83da3ff6a0 Binary files /dev/null and b/Trio/Resources/Sounds/harp.caf differ diff --git a/Trio/Resources/Sounds/meow.caf b/Trio/Resources/Sounds/meow.caf new file mode 100644 index 0000000000..a671ec2503 Binary files /dev/null and b/Trio/Resources/Sounds/meow.caf differ diff --git a/Trio/Resources/Sounds/minimal.caf b/Trio/Resources/Sounds/minimal.caf index ad864a7acd..a53de8996f 100644 Binary files a/Trio/Resources/Sounds/minimal.caf and b/Trio/Resources/Sounds/minimal.caf differ diff --git a/Trio/Resources/Sounds/open_can.caf b/Trio/Resources/Sounds/open_can.caf new file mode 100644 index 0000000000..bf0e99cf1f Binary files /dev/null and b/Trio/Resources/Sounds/open_can.caf differ diff --git a/Trio/Resources/Sounds/open_wrapper.caf b/Trio/Resources/Sounds/open_wrapper.caf new file mode 100644 index 0000000000..d79782a515 Binary files /dev/null and b/Trio/Resources/Sounds/open_wrapper.caf differ diff --git a/Trio/Resources/Sounds/saw_wave.caf b/Trio/Resources/Sounds/saw_wave.caf new file mode 100644 index 0000000000..fa1c155395 Binary files /dev/null and b/Trio/Resources/Sounds/saw_wave.caf differ diff --git a/Trio/Resources/Sounds/sine_wave.caf b/Trio/Resources/Sounds/sine_wave.caf new file mode 100644 index 0000000000..59c444cfe8 Binary files /dev/null and b/Trio/Resources/Sounds/sine_wave.caf differ diff --git a/Trio/Resources/Sounds/trill.caf b/Trio/Resources/Sounds/trill.caf index 11dfdb0f25..057fb91ec8 100644 Binary files a/Trio/Resources/Sounds/trill.caf and b/Trio/Resources/Sounds/trill.caf differ diff --git a/Trio/Resources/Sounds/voice_cr_amy.caf b/Trio/Resources/Sounds/voice_cr_amy.caf new file mode 100644 index 0000000000..7d65e0dddc Binary files /dev/null and b/Trio/Resources/Sounds/voice_cr_amy.caf differ diff --git a/Trio/Resources/Sounds/voice_cr_bryce.caf b/Trio/Resources/Sounds/voice_cr_bryce.caf new file mode 100644 index 0000000000..b0f7b085e1 Binary files /dev/null and b/Trio/Resources/Sounds/voice_cr_bryce.caf differ diff --git a/Trio/Resources/Sounds/voice_dcr_amy.caf b/Trio/Resources/Sounds/voice_dcr_amy.caf new file mode 100644 index 0000000000..efbd0c977c Binary files /dev/null and b/Trio/Resources/Sounds/voice_dcr_amy.caf differ diff --git a/Trio/Resources/Sounds/voice_dcr_bryce.caf b/Trio/Resources/Sounds/voice_dcr_bryce.caf new file mode 100644 index 0000000000..897a041205 Binary files /dev/null and b/Trio/Resources/Sounds/voice_dcr_bryce.caf differ diff --git a/Trio/Resources/Sounds/voice_dts_amy.caf b/Trio/Resources/Sounds/voice_dts_amy.caf new file mode 100644 index 0000000000..513aaf247a Binary files /dev/null and b/Trio/Resources/Sounds/voice_dts_amy.caf differ diff --git a/Trio/Resources/Sounds/voice_dts_bryce.caf b/Trio/Resources/Sounds/voice_dts_bryce.caf new file mode 100644 index 0000000000..8230ec81a5 Binary files /dev/null and b/Trio/Resources/Sounds/voice_dts_bryce.caf differ diff --git a/Trio/Resources/Sounds/voice_hg_amy.caf b/Trio/Resources/Sounds/voice_hg_amy.caf new file mode 100644 index 0000000000..57f1b14af9 Binary files /dev/null and b/Trio/Resources/Sounds/voice_hg_amy.caf differ diff --git a/Trio/Resources/Sounds/voice_hg_bryce.caf b/Trio/Resources/Sounds/voice_hg_bryce.caf new file mode 100644 index 0000000000..693d0f07a3 Binary files /dev/null and b/Trio/Resources/Sounds/voice_hg_bryce.caf differ diff --git a/Trio/Resources/Sounds/voice_lg_amy.caf b/Trio/Resources/Sounds/voice_lg_amy.caf new file mode 100644 index 0000000000..ecf7090494 Binary files /dev/null and b/Trio/Resources/Sounds/voice_lg_amy.caf differ diff --git a/Trio/Resources/Sounds/voice_lg_bryce.caf b/Trio/Resources/Sounds/voice_lg_bryce.caf new file mode 100644 index 0000000000..140d2f5608 Binary files /dev/null and b/Trio/Resources/Sounds/voice_lg_bryce.caf differ diff --git a/Trio/Resources/Sounds/voice_lgs_amy.caf b/Trio/Resources/Sounds/voice_lgs_amy.caf new file mode 100644 index 0000000000..c3a55313cd Binary files /dev/null and b/Trio/Resources/Sounds/voice_lgs_amy.caf differ diff --git a/Trio/Resources/Sounds/voice_lgs_bryce.caf b/Trio/Resources/Sounds/voice_lgs_bryce.caf new file mode 100644 index 0000000000..5be0676b06 Binary files /dev/null and b/Trio/Resources/Sounds/voice_lgs_bryce.caf differ diff --git a/Trio/Resources/Sounds/voice_ulg_amy.caf b/Trio/Resources/Sounds/voice_ulg_amy.caf new file mode 100644 index 0000000000..d88851eba2 Binary files /dev/null and b/Trio/Resources/Sounds/voice_ulg_amy.caf differ diff --git a/Trio/Resources/Sounds/voice_ulg_bryce.caf b/Trio/Resources/Sounds/voice_ulg_bryce.caf new file mode 100644 index 0000000000..b4263c2cc4 Binary files /dev/null and b/Trio/Resources/Sounds/voice_ulg_bryce.caf differ diff --git a/Trio/Sources/APS/DeviceDataManager.swift b/Trio/Sources/APS/DeviceDataManager.swift index 51c5691a8e..de89f5aca3 100644 --- a/Trio/Sources/APS/DeviceDataManager.swift +++ b/Trio/Sources/APS/DeviceDataManager.swift @@ -88,8 +88,10 @@ final class BaseDeviceDataManager: DeviceDataManager, Injectable { /// Since the pump manager has been successfully instantiated from its saved state, /// copy its rawValue to rawPumpManager which will be saved to persistant storage. - rawPumpManager = pumpManager.rawValue - UserDefaults.standard.clearLegacyPumpManagerRawValue() + processQueue.async { + self.rawPumpManager = pumpManager.rawValue + UserDefaults.standard.clearLegacyPumpManagerRawValue() + } pumpDisplayState.value = PumpDisplayState(name: pumpManager.localizedTitle, image: pumpManager.smallImage) pumpName.send(pumpManager.localizedTitle) diff --git a/Trio/Sources/APS/Devices/DeviceCatalog.swift b/Trio/Sources/APS/Devices/DeviceCatalog.swift index 195eb8f63b..e86b76b2e2 100644 --- a/Trio/Sources/APS/Devices/DeviceCatalog.swift +++ b/Trio/Sources/APS/Devices/DeviceCatalog.swift @@ -370,7 +370,7 @@ extension DeviceCatalog { CGMCatalogEntry( .managed(G7CGMManager.self), manufacturer: .dexcom, - name: "Dexcom G7 / ONE+", + name: "Dexcom G7 / ONE+ / Stelo", icon: .uiBundle(identifier: "org.loopkit.G7SensorKitUI", asset: "g7") ), diff --git a/Trio/Sources/APS/Storage/CarbEntryMutationService.swift b/Trio/Sources/APS/Storage/CarbEntryMutationService.swift new file mode 100644 index 0000000000..235c7642e5 --- /dev/null +++ b/Trio/Sources/APS/Storage/CarbEntryMutationService.swift @@ -0,0 +1,151 @@ +import CoreData +import Foundation +import HealthKit +import Swinject + +/// Values of a meal root row, safe to pass between threads. +struct MealSnapshot: Equatable, Sendable { + let id: UUID + let fpuID: UUID? + let date: Date + let carbs: Decimal + let fat: Decimal + let protein: Decimal + let note: String? +} + +enum CarbEntryMutationError: Error, Equatable { + case rootNotFound + case notARoot + case invalidReplacementID +} + +/// Deletes and replaces a meal family (root plus FPU carb equivalents) in Core Data, Nightscout, Apple Health and Tidepool. +/// Core Data changes run one mutation at a time and finish before any remote call, so COB never waits on the network. +/// Remote deletes and uploads are awaited before returning. Callers run `determineBasalSync()` afterwards. +/// Returned failure messages are Nightscout delete failures; the list is empty when Nightscout succeeded or is not configured. +protocol CarbEntryMutationService { + func deleteMeal(rootObjectID: NSManagedObjectID) async throws -> [String] + /// Returns the new root id, which is `replacement.id`. + func replaceMeal(rootObjectID: NSManagedObjectID, with replacement: CarbsEntry) async throws + -> (newID: UUID, failures: [String]) +} + +final class BaseCarbEntryMutationService: CarbEntryMutationService, Injectable { + @Injected() private var carbsStorage: CarbsStorage! + @Injected() private var nightscoutManager: NightscoutManager! + @Injected() private var healthkitManager: HealthKitManager! + @Injected() private var tidepoolManager: TidepoolManager! + + private let makeContext: () -> NSManagedObjectContext + private let localMutations = MealMutationQueue() + + init(resolver: Resolver, contextProvider: (() -> NSManagedObjectContext)? = nil) { + makeContext = contextProvider ?? { CoreDataStack.shared.newTaskContext() } + injectServices(resolver) + } + + func deleteMeal(rootObjectID: NSManagedObjectID) async throws -> [String] { + let snapshot = try await localMutations.run { + let snapshot = try await self.snapshotRoot(rootObjectID) + await self.carbsStorage.deleteCarbsEntryStored(rootObjectID) + return snapshot + } + return await deleteFromServices(snapshot) + } + + func replaceMeal(rootObjectID: NSManagedObjectID, with replacement: CarbsEntry) async throws + -> (newID: UUID, failures: [String]) + { + guard let idString = replacement.id, let newID = UUID(uuidString: idString) else { + throw CarbEntryMutationError.invalidReplacementID + } + let snapshot = try await localMutations.run { + let snapshot = try await self.snapshotRoot(rootObjectID) + await self.carbsStorage.deleteCarbsEntryStored(rootObjectID) + try await self.carbsStorage.storeCarbs([replacement], areFetchedFromRemote: false) + return snapshot + } + let failures = await deleteFromServices(snapshot) + await syncMealsWithServices() + return (newID, failures) + } + + private func syncMealsWithServices() async { + async let nightscoutUpload: () = nightscoutManager.uploadCarbs() + async let healthKitUpload: () = healthkitManager.uploadCarbs() + async let tidepoolUpload: () = tidepoolManager.uploadCarbs() + _ = await [nightscoutUpload, healthKitUpload, tidepoolUpload] + } + + private func snapshotRoot(_ objectID: NSManagedObjectID) async throws -> MealSnapshot { + let context = makeContext() + context.name = "snapshotMealRoot" + // A fetch reads the store, so rows removed by a batch delete are not served from the row cache. + let request: NSFetchRequest = CarbEntryStored.fetchRequest() + request.predicate = NSPredicate(format: "SELF == %@", objectID) + request.fetchLimit = 1 + return try await context.perform { + guard let row = try? context.fetch(request).first, + let id = row.id, let date = row.date + else { + throw CarbEntryMutationError.rootNotFound + } + guard !row.isFPU else { + throw CarbEntryMutationError.notARoot + } + return MealSnapshot( + id: id, + fpuID: row.fpuID, + date: date, + carbs: Decimal(row.carbs), + fat: Decimal(row.fat), + protein: Decimal(row.protein), + note: row.note + ) + } + } + + private func deleteFromServices(_ snapshot: MealSnapshot) async -> [String] { + var failures: [String] = [] + + if let fpuID = snapshot.fpuID { + if await !nightscoutManager.deleteCarbs(withID: fpuID.uuidString) { + failures.append("Nightscout: carb equivalents not deleted") + } + for sampleType in [AppleHealthConfig.healthFatObject, AppleHealthConfig.healthProteinObject].compactMap({ $0 }) { + await healthkitManager.deleteMealData(byID: fpuID.uuidString, sampleType: sampleType) + } + } + + if await !nightscoutManager.deleteCarbs(withID: snapshot.id.uuidString) { + failures.append("Nightscout: meal not deleted") + } + if let sampleType = AppleHealthConfig.healthCarbObject { + await healthkitManager.deleteMealData(byID: snapshot.id.uuidString, sampleType: sampleType) + } + tidepoolManager.deleteCarbs( + withSyncId: snapshot.id, + carbs: snapshot.carbs, + at: snapshot.date, + enteredBy: CarbsEntry.local + ) + + return failures + } +} + +/// Runs operations one at a time in submission order, including across suspension points. +private actor MealMutationQueue { + private var tail: Task? + + func run(_ operation: @escaping () async throws -> T) async throws -> T { + let previous = tail + let task = Task { + await previous?.value + return try await operation() + } + tail = Task { _ = try? await task.value } + return try await task.value + } +} diff --git a/Trio/Sources/APS/Storage/CarbsStorage.swift b/Trio/Sources/APS/Storage/CarbsStorage.swift index 8616fed2e5..36bd5cfc1f 100644 --- a/Trio/Sources/APS/Storage/CarbsStorage.swift +++ b/Trio/Sources/APS/Storage/CarbsStorage.swift @@ -24,6 +24,12 @@ protocol CarbsStorage { func getFPUsNotYetUploadedToNightscout() async throws -> [NightscoutTreatment] func getCarbsNotYetUploadedToHealth() async throws -> [CarbsEntry] func getCarbsNotYetUploadedToTidepool() async throws -> [CarbsEntry] + /// Resolves a meal root by its own `id` or by the family's `fpuID`, which is the Nightscout `id` of every carb equivalent. + func fetchMealRoot(handle: UUID) async throws -> (objectID: NSManagedObjectID, snapshot: MealSnapshot)? +} + +enum CarbsStorageError: Error, Equatable { + case ambiguousMealHandle(UUID) } final class BaseCarbsStorage: CarbsStorage, Injectable { @@ -260,7 +266,8 @@ final class BaseCarbsStorage: CarbsStorage, Injectable { newItem.fat = Double(truncating: NSDecimalNumber(decimal: entry.fat ?? 0)) newItem.protein = Double(truncating: NSDecimalNumber(decimal: entry.protein ?? 0)) newItem.note = entry.note - newItem.id = UUID() + // Nightscout `_id` values are not UUIDs and are replaced. + newItem.id = entry.id.flatMap(UUID.init(uuidString:)) ?? UUID() newItem.isFPU = false newItem.isUploadedToNS = areFetchedFromRemote ? true : false newItem.isUploadedToHealth = false @@ -453,6 +460,37 @@ final class BaseCarbsStorage: CarbsStorage, Injectable { } } + func fetchMealRoot(handle: UUID) async throws -> (objectID: NSManagedObjectID, snapshot: MealSnapshot)? { + let context = makeContext() + context.name = "fetchMealRoot" + let request: NSFetchRequest = CarbEntryStored.fetchRequest() + request.predicate = NSPredicate( + format: "(id == %@ OR fpuID == %@) AND isFPU == NO", + handle as CVarArg, + handle as CVarArg + ) + request.fetchLimit = 2 + + return try await context.perform { + let rows = try context.fetch(request) + guard let row = rows.first else { return nil } + guard rows.count == 1 else { throw CarbsStorageError.ambiguousMealHandle(handle) } + guard let id = row.id, let date = row.date else { return nil } + return ( + row.objectID, + MealSnapshot( + id: id, + fpuID: row.fpuID, + date: date, + carbs: Decimal(row.carbs), + fat: Decimal(row.fat), + protein: Decimal(row.protein), + note: row.note + ) + ) + } + } + func getCarbsNotYetUploadedToNightscout() async throws -> [NightscoutTreatment] { let context = makeContext() context.name = "getCarbsNotYetUploadedToNightscout" @@ -488,7 +526,8 @@ final class BaseCarbsStorage: CarbsStorage, Injectable { foodType: result.note, targetTop: nil, targetBottom: nil, - id: result.id?.uuidString + id: result.id?.uuidString, + fpuID: result.fpuID?.uuidString ) } } @@ -529,7 +568,8 @@ final class BaseCarbsStorage: CarbsStorage, Injectable { foodType: result.note, targetTop: nil, targetBottom: nil, - id: result.fpuID?.uuidString + id: result.fpuID?.uuidString, + fpuID: result.fpuID?.uuidString ) } } diff --git a/Trio/Sources/Assemblies/ServiceAssembly.swift b/Trio/Sources/Assemblies/ServiceAssembly.swift index 13640e304b..42d18a9bc0 100644 --- a/Trio/Sources/Assemblies/ServiceAssembly.swift +++ b/Trio/Sources/Assemblies/ServiceAssembly.swift @@ -39,6 +39,8 @@ final class ServiceAssembly: Assembly { ProfileScheduleFirer(resolver: r) }.inObjectScope(.container) container.register(BolusSafetyValidator.self) { r in BaseBolusSafetyValidator(resolver: r) } + container.register(CarbEntryMutationService.self) { r in BaseCarbEntryMutationService(resolver: r) } + .inObjectScope(.container) container.register(AdjustmentManager.self) { r in BaseAdjustmentManager(resolver: r) } .inObjectScope(.container) container.register(WatchCommandProcessor.self) { r in BaseWatchCommandProcessor(resolver: r) } diff --git a/Trio/Sources/Helpers/BuildDetails.swift b/Trio/Sources/Helpers/BuildDetails.swift index 0ed3f63230..eafc814d82 100644 --- a/Trio/Sources/Helpers/BuildDetails.swift +++ b/Trio/Sources/Helpers/BuildDetails.swift @@ -7,6 +7,7 @@ class BuildDetails: Injectable { let dict: [String: Any] let previousExpireDateKey = "previousExpireDate" + let previousRemoteCommandsKey = "previousRemoteCommands" init() { guard let url = Bundle.main.url(forResource: "BuildDetails", withExtension: "plist"), @@ -99,7 +100,7 @@ class BuildDetails: Injectable { } } - // Upload new profile if expire date has changed + // Upload new profile if expire date or supported remote commands have changed func handleExpireDateChange() async throws { if nightscoutManager == nil { @@ -109,9 +110,15 @@ class BuildDetails: Injectable { let previousExpireDate = UserDefaults.standard.object(forKey: previousExpireDateKey) as? Date let expireDate = calculateExpirationDate() + let previousRemoteCommands = UserDefaults.standard.stringArray(forKey: previousRemoteCommandsKey) + let remoteCommands = TrioRemoteControl.CommandType.supported.map(\.rawValue) + if previousExpireDate != expireDate { debug(.nightscout, "New build expire date detected, uploading profile") try await nightscoutManager.uploadProfiles() + } else if previousRemoteCommands != remoteCommands { + debug(.nightscout, "Supported remote commands changed, uploading profile") + try await nightscoutManager.uploadProfiles() } } @@ -123,4 +130,9 @@ class BuildDetails: Injectable { UserDefaults.standard.removeObject(forKey: previousExpireDateKey) } } + + // Store the uploaded remote command list + func recordUploadedRemoteCommands(_ commands: [String]) { + UserDefaults.standard.set(commands, forKey: previousRemoteCommandsKey) + } } diff --git a/Trio/Sources/Localizations/Main/Localizable.xcstrings b/Trio/Sources/Localizations/Main/Localizable.xcstrings index ee4ad32567..a2647da69d 100644 --- a/Trio/Sources/Localizations/Main/Localizable.xcstrings +++ b/Trio/Sources/Localizations/Main/Localizable.xcstrings @@ -1617,13 +1617,6 @@ } } }, - " - diluted Insulin" : { - "comment" : "A description of a insulin concentration that is lower than 100 mg/dL.", - "isCommentAutoGenerated" : true - }, - " - not enacted!" : { - "comment" : "Suffix appended to Home status popup title when the suggestion was not enacted" - }, " »%@« " : { "localizations" : { "bg" : { @@ -2020,9 +2013,6 @@ } } }, - " ⚠️" : { - "comment" : "Manual Temp basal" - }, " Allow Trio to send you Notifications. These may include alerts, sounds, and icon badges." : { "localizations" : { "bg" : { @@ -3089,7 +3079,6 @@ } }, " needed as min. Glucose Target)!" : { - "comment" : "Trailing fragment of Add Temp Target validation error — concatenated with a minimum-glucose value and unit", "localizations" : { "bg" : { "stringUnit" : { @@ -3351,7 +3340,6 @@ } }, " of usage data and is not yet configurable." : { - "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -4676,12 +4664,6 @@ } } } - }, - "- higher custom concentration" : { - - }, - "- Standard Insulin 100 U/mL" : { - }, "--" : { "localizations" : { @@ -7729,6 +7711,10 @@ } } }, + "/ 100" : { + "comment" : "A unit of measurement for the GRI score", + "isCommentAutoGenerated" : true + }, "%" : { "comment" : "Percentage symbol", "localizations" : { @@ -8122,7 +8108,6 @@ } }, "%@ " : { - "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -9069,7 +9054,6 @@ } }, "%@ %%" : { - "comment" : "Profile summary badge — applied therapy percentage", "localizations" : { "bg" : { "stringUnit" : { @@ -9199,29 +9183,6 @@ } } }, - "%@ →" : { - - }, - "%@ → %@ %@" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "%1$@ → %2$@ %3$@" - } - } - } - }, - "%@ − 0.5 × (%@ − %@) = %@" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "%1$@ − 0.5 × (%2$@ − %3$@) = %4$@" - } - } - } - }, "%@ and %lld g fat" : { "comment" : "The text to be displayed when fat is > 0 for the Add Carbs shortcut", "localizations" : { @@ -10288,9 +10249,6 @@ } } } - }, - "%@ is active" : { - }, "%@ is on. %@" : { "localizations" : { @@ -10820,9 +10778,6 @@ } } }, - "%@ U/day" : { - "comment" : "Profile summary badge — total daily basal in insulin units per day" - }, "%@-%@" : { "localizations" : { "bg" : { @@ -10958,9 +10913,6 @@ } } } - }, - "%@-Day moving Avg of TDD" : { - }, "%@: %@" : { "localizations" : { @@ -11099,7 +11051,6 @@ } }, "%@?" : { - "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -13072,17 +13023,6 @@ } } }, - "%lld · %@ U/day" : { - "comment" : "Draft editor Basal Rates row summary — entry count and total daily insulin units per day", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "%1$lld · %2$@ U/day" - } - } - } - }, "%lld %%" : { "localizations" : { "bg" : { @@ -13213,9 +13153,6 @@ } } }, - "%lld entries" : { - "comment" : "Draft editor Carb Ratios row summary — number of schedule entries\nDraft editor Glucose Targets row summary — number of schedule entries\nDraft editor ISF row summary — number of schedule entries" - }, "%lld g" : { "localizations" : { "bg" : { @@ -13477,7 +13414,6 @@ } }, "%lld hr" : { - "comment" : "Activation duration row — duration in whole hours\nTemp-activation notification body fragment — duration in whole hours", "localizations" : { "bg" : { "stringUnit" : { @@ -13607,17 +13543,6 @@ } } }, - "%lld hr %lld min" : { - "comment" : "Activation duration row — duration in hours and minutes\nTemp-activation notification body fragment — duration in hours and minutes", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "%1$lld hr %2$lld min" - } - } - } - }, "%lld m" : { "localizations" : { "bg" : { @@ -13749,7 +13674,6 @@ } }, "%lld min" : { - "comment" : "Activation duration row — duration in minutes only\nTemp-activation notification body fragment — duration in minutes only", "localizations" : { "bg" : { "stringUnit" : { @@ -14008,9 +13932,6 @@ } } } - }, - "%lld-Day Moving Average of TDD" : { - }, "%lld%%" : { "localizations" : { @@ -14141,9 +14062,6 @@ } } } - }, - "%lldh" : { - }, "•" : { "localizations" : { @@ -14404,25 +14322,6 @@ } } } - }, - "• 0.5 — ratioFloor 70 mg/dL (midpoint between target and 40)" : { - - }, - "• 0.5 — threshold 70 mg/dL (midway between target and 40)" : { - "comment" : "An example of the `smbThresholdRatioVerbose` hint, showing how the SMB threshold is calculated for a `smbThresholdRatio` of 0.5.", - "isCommentAutoGenerated" : true - }, - "• 0.7 — ratioFloor 82 mg/dL (SMBs cut off sooner)" : { - - }, - "• 0.7 — threshold 82 mg/dL (SMBs cut off sooner)" : { - - }, - "• 1.0 — ratioFloor 100 mg/dL (no SMBs below target)" : { - - }, - "• 1.0 — threshold 100 mg/dL (no SMBs below target)" : { - }, "• A higher number means you need less insulin for the same amount of carbs" : { "localizations" : { @@ -18459,9 +18358,6 @@ } } } - }, - "• In U100 insulin: 0.1 U increment delivers 0.1 units\n• In U50 insulin: 0.1 U increment delivers 0.05 units\n• In U200 insulin: 0.1 U increment delivers 0.2 units" : { - }, "• Insulin Sensitivity" : { "localizations" : { @@ -19502,9 +19398,6 @@ } } } - }, - "• Maximum Duration" : { - }, "• None: No value displayed." : { "localizations" : { @@ -21195,9 +21088,6 @@ } } } - }, - "⇢" : { - }, "+" : { "localizations" : { @@ -21849,10 +21739,6 @@ } } }, - "<100 % = less insulin (more sensitive). Basal drops, ISF rises, CR rises." : { - "comment" : "A description of what happens when the therapy adjustment percentage is less than 100%.", - "isCommentAutoGenerated" : true - }, "=" : { "localizations" : { "bg" : { @@ -22113,10 +21999,6 @@ } } }, - ">100 % = more insulin overall (less sensitive). Basal rises, ISF drops, CR drops." : { - "comment" : "A bullet point in the verbose hint for the \"Adjust current Therapy Settings\" row of the Add Profile form.", - "isCommentAutoGenerated" : true - }, "−" : { "localizations" : { "bg" : { @@ -23167,7 +23049,6 @@ }, "0.1 U and over" : { "comment" : "Name to display for the 0.1 U and over threshold in the bolus display settings.", - "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -23296,13 +23177,9 @@ } } } - }, - "0.1U and over" : { - }, "0.5 U and over" : { "comment" : "Name of the bolus display threshold option that represents 0.5 units.", - "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -23432,13 +23309,8 @@ } } }, - "0.5U and over" : { - "comment" : "Name for the 0.5U threshold in the Bolus Display Threshold picker.", - "isCommentAutoGenerated" : true - }, "1 U and over" : { "comment" : "Name to display for the \"1 U and over\" bolus display threshold option.", - "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -23697,9 +23569,6 @@ } } } - }, - "1U and over" : { - }, "2 hours" : { "extractionState" : "manual", @@ -24098,9 +23967,6 @@ } } }, - "2nd Order Exponential" : { - "comment" : "Exponential smoothing algorithm display name" - }, "3 M" : { "comment" : "Abbreviation for three months", "localizations" : { @@ -24897,7 +24763,6 @@ } }, "7 days" : { - "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -25026,9 +24891,6 @@ } } } - }, - "10 U/mL" : { - }, "10-90" : { "localizations" : { @@ -26083,9 +25945,6 @@ } } } - }, - "24h" : { - }, "25 g" : { "localizations" : { @@ -26736,9 +26595,6 @@ } } } - }, - "50 U/mL" : { - }, "50% is half effect:" : { "localizations" : { @@ -26870,6 +26726,9 @@ } } }, + "54 to below 70 mg/dL" : { + "comment" : "Percentage of glucose readings in the low GRI range." + }, "75%:" : { "localizations" : { "bg" : { @@ -27389,9 +27248,6 @@ } } } - }, - "100 U/mL" : { - }, "100% is full effect:" : { "localizations" : { @@ -27783,10 +27639,6 @@ } } }, - "200 U/mL" : { - "comment" : "A choice in the insulin concentration picker.", - "isCommentAutoGenerated" : true - }, "A bolus command of %@ U of insulin was sent." : { "localizations" : { "bg" : { @@ -27917,9 +27769,6 @@ } } }, - "A bolus was given in the last 6 minutes." : { - "comment" : "Watch command ack" - }, "A display of On/On indicates both Dynamic ISF and CR are enabled. On/Off indicates only Dynamic ISF is enabled. Dynamic CR cannot be enabled when Dynamic ISF is disabled." : { "localizations" : { "bg" : { @@ -28049,9 +27898,6 @@ } } } - }, - "A factor that multiplies the current maxSMBBasalMinutes and maxUAM/SMBBasalMinutes beyond the 180 minute limit set in Trio." : { - }, "A few important notes…" : { "localizations" : { @@ -28841,29 +28687,6 @@ } } }, - "A Profile is a named snapshot of your full therapy and algorithm setup — basal rate, ISF, CR, BG targets, plus every Algorithm Settings toggle. You keep one baseline profile and build alternates (sick day, exercise, menstrual cycle, travel, test modes) from it. Switching profiles swaps the entire setup in one tap." : { - "comment" : "A description of what a profile is.", - "isCommentAutoGenerated" : true - }, - "A safety knob that controls how close to your low-target SMBs are still allowed. It interpolates the SMB cutoff along the line between your low-target and 40 mg/dL." : { - - }, - "A safety knob that controls how close to your low-target SMBs are still allowed. It shifts the SMB cutoff (\"threshold\") along the line between your low-target and 40 mg/dL." : { - - }, - "A Schedule fires a profile activation at a future time — once, or on a repeating cadence. It lets you automate profile switches that follow a predictable pattern (sick days, night mode, workouts, menstrual cycle, travel)." : { - - }, - "A special case: setting an even-numbered temp target below %@ %@ (e.g. %@ %@) signals that you want maximum SMB aggression — autoISF calls this full-loop mode." : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "A special case: setting an even-numbered temp target below %1$@ %2$@ (e.g. %3$@ %4$@) signals that you want maximum SMB aggression — autoISF calls this full-loop mode." - } - } - } - }, "A Temporary Target replaces the current Target Glucose specified in Therapy settings." : { "localizations" : { "bg" : { @@ -28993,9 +28816,6 @@ } } } - }, - "A wider range makes SMBs ramp up more gradually; a narrower range makes them reach full aggression sooner." : { - }, "Abbreviation for 'Deviation'. How much the actual glucose change deviated from the BGI." : { "localizations" : { @@ -29257,9 +29077,6 @@ } } }, - "About Profiles" : { - "comment" : "Help hint label on the AdaptProfile list screen" - }, "About Quick-Pick Treatments" : { "comment" : "Info sheet title for quick-pick treatments feature", "localizations" : { @@ -29391,9 +29208,6 @@ } } }, - "About Schedules" : { - "comment" : "Help hint label on the Profile Scheduler list screen" - }, "About the Process" : { "extractionState" : "manual", "localizations" : { @@ -29923,6 +29737,12 @@ } } }, + "Above 180 through 250 mg/dL" : { + "comment" : "Percentage of glucose readings in the high GRI range." + }, + "Above 250 mg/dL" : { + "comment" : "Percentage of glucose readings in the very high GRI range." + }, "above range" : { "comment" : "Accessibility: glucose range", "localizations" : { @@ -30054,23 +29874,9 @@ } } }, - "Absolute Safety TBR" : { - "comment" : "Absolute TBR" - }, - "Absolute safety TBR provides a fixed insulin rate for safety, useful for consistent protection." : { - "comment" : "Absolute TBR VerboseHint" - }, - "acce" : { - "comment" : "Label for acce" - }, - "acce_ISF is calculated by\nacce_ISF = 1 + acce_weight * fit_share * cap_weight * acceleration\nwhere fit_share is a measure of fit quality, i.e., 0% if unacceptable up to 100% if perfect;\ncap_weight is 0.5 below target and 1.0 otherwise;\nacce_weight is bgAccel_ISF_weight for acceleration away from target, i.e., mostly positive\nor bgBrake_ISF_weight for acceleration towards target, i.e., mostly negative.\n\nInitially, it was assumed that the weights for accelerating and braking are of similar size.\nFirst experiences suggest that the weight while decelerating should be 30-40% lower than for acceleration to reduce glucose oscillations. Quite often the acce_ISF contribution plays the dominant role inside autoISF and is therefore very important and delicate.\n\nWeights for acce_ISF of 0 disable this contribution. Start small with weights like 0.02 and observe the results before increasing them. Keep in mind that negative acceleration will start to happen while glucose is apparently still rising but the slope reduces. Here, acce_ISF will be <1, i.e., sensitivity grows and less insulin than normal will be required even before the glucose peak is reached." : { - - }, - "Acce-ISF" : { - - }, - "acce-ISF: %@" : { - + "Acoustic" : { + "comment" : "The category of the alarm sound.", + "isCommentAutoGenerated" : true }, "Activate" : { "comment" : "A button that activates a preset.", @@ -30203,14 +30009,6 @@ } } }, - "Activate a stored profile now for a chosen duration. Indefinite activation is not available via Shortcuts." : { - "comment" : "Description of the `ApplyProfileIntent`.", - "isCommentAutoGenerated" : true - }, - "Activate a Temporary Target preset now" : { - "comment" : "Title of the intent to apply a temporary target preset immediately.", - "isCommentAutoGenerated" : true - }, "Activate an override" : { "localizations" : { "bg" : { @@ -30341,15 +30139,6 @@ } } }, - "Activate autoISF" : { - "comment" : "Row label for the Activate autoISF toggle" - }, - "Activate B30 EatingSoon" : { - "comment" : "Enable B30" - }, - "Activate KetoProtection" : { - "comment" : "Enable KetoProtection" - }, "Activate Override" : { "localizations" : { "bg" : { @@ -30610,34 +30399,6 @@ } } } - }, - "Activate Profile" : { - - }, - "Activate Profile ${profile}" : { - - }, - "Activate TT preset" : { - - }, - "Activated: Safety TBR only kicks in when IOB is in neg. range below current Basal Rate and Active Insulin is also negative." : { - "comment" : "Variable Protection VerboseHint" - }, - "Activates at" : { - - }, - "Activates at %@" : { - - }, - "Activation" : { - - }, - "Activation failed" : { - "comment" : "An alert title indicating that activation of a profile failed.", - "isCommentAutoGenerated" : true - }, - "Activation modes" : { - }, "active" : { "comment" : "Accessibility: alarms active", @@ -30900,9 +30661,6 @@ } } } - }, - "Active adjustment" : { - }, "Active During" : { "comment" : "A section header for the active glucose alert.", @@ -31034,12 +30792,6 @@ } } } - }, - "Active Profile" : { - "comment" : "Fallback name on Home adjustments banner for the active profile when it has no name set\nHome stats panel face option" - }, - "Active schedules" : { - }, "Active Time" : { "comment" : "description label for active time pod details rowdescription label for active time pod details row", @@ -31179,9 +30931,6 @@ } } }, - "Adaptive UKF Smoothing" : { - "comment" : "Adaptive UKF smoothing algorithm display name" - }, "Add" : { "comment" : "Add profile basal", "localizations" : { @@ -31453,7 +31202,6 @@ }, "Add %lld g carbs" : { "comment" : "A confirmation message that asks the user to confirm adding a certain amount of carbs, optionally including fat and protein.", - "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -31843,10 +31591,6 @@ } } }, - "Add a Profile by tapping 'Add Profile +' in the top right-hand corner of the screen." : { - "comment" : "A description displayed when no profiles have been added yet.", - "isCommentAutoGenerated" : true - }, "Add Alarm" : { "comment" : "A title for a sheet that lets the user add a glucose alert.", "localizations" : { @@ -34757,9 +34501,6 @@ } } } - }, - "Add Mock Garmin Watch" : { - }, "Add Omnipod" : { "comment" : "Add Omnipod pump", @@ -35685,9 +35426,6 @@ } } } - }, - "Add Profile" : { - }, "Add pump" : { "localizations" : { @@ -35949,9 +35687,6 @@ } } } - }, - "Add Schedule" : { - }, "Add Simulator" : { "comment" : "Add Simulator pump", @@ -36743,6 +36478,7 @@ }, "Add widget" : { "comment" : "A label displayed as a button to add a widget.", + "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -36873,6 +36609,7 @@ } }, "Add Widget" : { + "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -37132,9 +36869,6 @@ } } } - }, - "Additional minutes (0–55)" : { - }, "Additional notification types" : { "extractionState" : "manual", @@ -37274,7 +37008,6 @@ } }, "Additionals" : { - "comment" : "Navigation-link title for the additional/advanced oref settings section in the draft profile editor", "localizations" : { "bg" : { "stringUnit" : { @@ -37677,6 +37410,9 @@ } } } + }, + "Adds this widget to the layout" : { + }, "Adds Transmitter Battery" : { "extractionState" : "manual", @@ -37814,9 +37550,6 @@ } } } - }, - "adj." : { - }, "Adjust basal" : { "comment" : "Headline \"Adjust basal\"", @@ -37957,7 +37690,6 @@ } }, "Adjust Basal" : { - "comment" : "Row label for the Dynamic-ISF Adjust-Basal toggle", "localizations" : { "bg" : { "stringUnit" : { @@ -38087,12 +37819,6 @@ } } }, - "Adjust current Therapy Setting" : { - "comment" : "Hint mini-label shown when tapping info on the therapy-adjustment percentage row of Add Profile form" - }, - "Adjust current Therapy Settings" : { - "comment" : "Row label on Add Profile form for the percentage that rescales Basal/ISF/CR" - }, "Adjust Dynamic ISF constant" : { "comment" : "Headline \"Adjust Dynamic ISF constant\"Adjust Dynamic ISF constantHeadline \"Adjust Dynamic ISF constant\"", "extractionState" : "manual", @@ -38363,10 +38089,6 @@ } } }, - "Adjust Limits for Diluted Insulin" : { - "comment" : "A section that provides information about adjusting insulin limits when using diluted insulin.", - "isCommentAutoGenerated" : true - }, "Adjust Ring Settings" : { "localizations" : { "bg" : { @@ -38626,13 +38348,6 @@ } } } - }, - "Adjusted basal rates are rounded down to the nearest pump-supported rate. ISF is rounded to 1 mg/dL steps, CR to 0.1 g/U. You can fine-tune each value manually in the editor after tapping Next." : { - "comment" : "A detailed explanation of how the therapy adjustment percentage affects the Basal Rate, ISF, and CR of a profile.", - "isCommentAutoGenerated" : true - }, - "Adjusted rates" : { - }, "Adjusting this value not only can adjust how quickly your sensitivity will respond to changing glucose readings, but also at what glucose readings you reach your Autosens Max/Min limits." : { "localizations" : { @@ -38895,7 +38610,6 @@ } }, "Adjustment Factor (AF)" : { - "comment" : "Row label for the Dynamic ISF Adjustment Factor (AF) setting", "localizations" : { "bg" : { "stringUnit" : { @@ -39026,7 +38740,6 @@ } }, "Adjustment Tabs" : { - "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -40088,15 +39801,6 @@ } } } - }, - "AIMI B30" : { - "comment" : "Navigation-link title for the AIMI B30 extension section in the draft profile editor" - }, - "AIMI B30 Settings" : { - - }, - "aiSR" : { - }, "Alarm" : { "comment" : "Name of a critical-alarm sound file.", @@ -40758,9 +40462,6 @@ } } }, - "Algo & Targets tuned" : { - "comment" : "Profile summary badge — both algorithm preferences and glucose targets differ from defaults" - }, "Algorithm" : { "comment" : "Algorithm menu item in the Settings main view.", "localizations" : { @@ -40892,15 +40593,6 @@ } } }, - "Algorithm — not looped in %lld minutes." : { - "comment" : "Loop status popup title when no recent loop — interpolation is minutes count" - }, - "Algorithm enacted at" : { - "comment" : "Headline in enacted popup" - }, - "Algorithm enacted at %@" : { - "comment" : "Loop status popup title when a recent loop has enacted — interpolation is a clock time" - }, "Algorithm Error" : { "localizations" : { "bg" : { @@ -41030,13 +40722,6 @@ } } } - }, - "Algorithm reasoning" : { - "comment" : "A label displayed above the list of reasons provided by the algorithm.", - "isCommentAutoGenerated" : true - }, - "Algorithm Selection" : { - }, "Algorithm Settings" : { "localizations" : { @@ -41168,14 +40853,7 @@ } } }, - "Algorithm Settings tuned" : { - "comment" : "Profile summary badge — algorithm preferences differ from defaults" - }, - "Algorithm suggested at" : { - "comment" : "Headline in suggested popup" - }, "Algorithmic Target Settings" : { - "comment" : "Section header on the Target Behavior screen grouping sensitivity-vs-target controls", "localizations" : { "bg" : { "stringUnit" : { @@ -42647,7 +42325,6 @@ } }, "All settings are at default values." : { - "comment" : "Validation error on Add Override form when the override would be a no-op (all defaults)\nValidation error on Edit Override form when the override would be a no-op (all defaults)", "localizations" : { "bg" : { "stringUnit" : { @@ -42906,9 +42583,6 @@ } } } - }, - "Allow Bolus Commands" : { - }, "Allow Bolusing with Shortcuts" : { "localizations" : { @@ -43039,12 +42713,6 @@ } } } - }, - "Allow diluted Insulin" : { - "comment" : "Allow diluted Insulin" - }, - "Allow Dilution" : { - }, "Allow Fetching from Nightscout" : { "localizations" : { @@ -43437,7 +43105,6 @@ } }, "Allow SMB for 6 hrs after a carb entry." : { - "comment" : "Mini-hint for Enable SMB After Carbs setting", "localizations" : { "bg" : { "stringUnit" : { @@ -43568,7 +43235,6 @@ } }, "Allow SMB when a manual Temporary Target is set greater than %@ %@." : { - "comment" : "Mini-hint for Allow SMB With High Temptarget — interpolated values are the threshold number and the glucose unit", "localizations" : { "bg" : { "stringUnit" : { @@ -43705,7 +43371,6 @@ } }, "Allow SMB when a manual Temporary Target is set under %@ %@." : { - "comment" : "Mini-hint for Enable SMB With Temptarget — interpolated values are the threshold number and the glucose unit", "localizations" : { "bg" : { "stringUnit" : { @@ -43842,7 +43507,6 @@ } }, "Allow SMB when carbs are on board." : { - "comment" : "Mini-hint for Enable SMB With COB setting", "localizations" : { "bg" : { "stringUnit" : { @@ -43973,7 +43637,6 @@ } }, "Allow SMB when glucose is above the High Glucose Target value." : { - "comment" : "Mini-hint for Enable SMB With High Glucose setting", "localizations" : { "bg" : { "stringUnit" : { @@ -44235,7 +43898,6 @@ } }, "Allow SMBs at all times except when a high Temp Target is set." : { - "comment" : "Mini-hint for Enable SMB Always setting", "localizations" : { "bg" : { "stringUnit" : { @@ -44494,12 +44156,6 @@ } } } - }, - "Allow the Garmin app to deliver insulin. Requires watch commands to be enabled." : { - - }, - "Allow the Garmin app to log carbs and start or stop overrides and temp targets." : { - }, "Allow to add carbs in Trio." : { "localizations" : { @@ -45962,6 +45618,9 @@ } } } + }, + "Already in the layout" : { + }, "Already set for Day & Night. Change the alarm time window to add a second alarm for the same type." : { "localizations" : { @@ -46901,7 +46560,6 @@ } }, "Alter the rate of Dynamic ISF (Sensitivity) adjustments." : { - "comment" : "Mini-hint for Dynamic ISF Adjustment Factor (AF) setting", "localizations" : { "bg" : { "stringUnit" : { @@ -47032,7 +46690,6 @@ } }, "Alter the rate of dynamic sensitivity adjustments for Sigmoid." : { - "comment" : "Mini-hint for Sigmoid Dynamic ISF Adjustment Factor", "localizations" : { "bg" : { "stringUnit" : { @@ -48094,9 +47751,6 @@ } } }, - "Amount in U/hr of small safety TBR to avoid ketoacidosis." : { - "comment" : "Absolute Safety TBR miniHint" - }, "Amount of Carbs on Board (COB) used in the most recent automated dosing calculation." : { "localizations" : { "bg" : { @@ -48357,8 +48011,9 @@ } } }, - "An EatingSoon TempTarget needs to be enabled to start B30 adaption. Set level for this target to be identified." : { - "comment" : "B30 TT Level miniHint" + "Amy" : { + "comment" : "Name of a spoken alert voice.", + "isCommentAutoGenerated" : true }, "An external bolus of %@ U of insulin was recorded." : { "localizations" : { @@ -48490,9 +48145,6 @@ } } }, - "An indefinite activation updates the pump's scheduled basal to match this profile. The pump's basal schedule will be overwritten." : { - "comment" : "Body of schedule-fire notification warning the user that confirming will overwrite the pump's basal schedule" - }, "An unknown authentication error occurred. Please try again." : { "localizations" : { "bg" : { @@ -49151,7 +48803,6 @@ } }, "Anonymous usage statistics include the app version and build, device and iOS version, which pump and CGM you have paired, and whether Nightscout, Tidepool, and Apple Health are configured (yes/no — no URLs or credentials). They are sent to a self-hosted Trio telemetry endpoint." : { - "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -49281,10 +48932,6 @@ } } }, - "Any schedules that activate this profile will also be removed." : { - "comment" : "A message displayed in a confirmation dialog when deleting a profile. It explains that any schedules that activate the deleted profile will also be removed.", - "isCommentAutoGenerated" : true - }, "API secret" : { "comment" : "API secret in NS", "localizations" : { @@ -51106,12 +50753,8 @@ } } } - }, - "Apply ${preset}" : { - }, "Apply a Temporary Target" : { - "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -51372,7 +51015,6 @@ } }, "Applying ${preset}" : { - "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -51631,9 +51273,6 @@ } } } - }, - "Are you sure to switch to U%lld?" : { - }, "Are you sure you want to delete %@?" : { "localizations" : { @@ -52176,15 +51815,6 @@ } } }, - "AS" : { - - }, - "as often diluted insulins are used for patients with small insulin dosing requirements." : { - - }, - "at %@" : { - "comment" : "Schedule summary time prefix — interpolated list is two-digit HH:MM values joined with ' & '" - }, "at least" : { "localizations" : { "bg" : { @@ -53622,9 +53252,6 @@ } } } - }, - "Auto-revert: when a temp profile expires, you get a notification (\"Profile X expired — reverted to Y\")." : { - }, "Auto-sensitivity (Autosens) adjusts insulin delivery based on observed sensitivity or resistance." : { "localizations" : { @@ -53756,63 +53383,6 @@ } } }, - "autoISF" : { - "comment" : "Navigation-link title for the autoISF extension section in the draft profile editor" - }, - "AutoISF" : { - - }, - "autoISF 3.01" : { - "comment" : "Title label of the autoISF extension (version 3.01)" - }, - "autoISF 3.01 calculates insulin sensitivity (ISF) each loop cycle based on glucose behaviour within set limits." : { - "comment" : "Mini-hint for Activate autoISF toggle" - }, - "autoISF 3.01 Documentation" : { - - }, - "autoISF allows to adapt the insulin sensitivity factor (ISF) in the following scenarios of glucose behaviour:" : { - - }, - "autoISF calculations" : { - - }, - "autoISF can adapt ISF based on glucose delta. It was introduced to help users with gastroparesis. It is also useful for users in pure UAM mode because in their case no meal start can be detected. Given a positive short_avgdelta and glucose being above target+10, the result is:\n\npp_ISF = 1 + delta * pp_ISF_weight.\n\nAs a starting value for pp_ISF_weight, use 0.005. Observe the reactions and check the Enacted Popup before you increase it with care. A weight of 0 disables this contribution." : { - - }, - "AutoISF Hourly Change" : { - - }, - "autoISF IOB Threshold Percent" : { - "comment" : "IOB Threshold" - }, - "autoISF Max" : { - "comment" : "Row label on Add Override form — autoISF Max\nRow label on Edit Override form — autoISF Max\nautoISF Max" - }, - "AutoISF Max" : { - - }, - "autoISF Min" : { - "comment" : "Row label on Add Override form — autoISF Min\nRow label on Edit Override form — autoISF Min\nautoISF Min" - }, - "AutoISF Min" : { - - }, - "AutoISF Off Sport" : { - - }, - "autoISF Sens Ratio (aiSR)" : { - - }, - "autoISF Settings" : { - - }, - "AutoISF Settings" : { - - }, - "autoISF will enable SMBs for even and block them for odd Targets." : { - "comment" : "Odd Target disables SMB miniHint" - }, "Automate boluses using the iOS Shortcuts App." : { "localizations" : { "bg" : { @@ -54342,7 +53912,6 @@ } }, "Autosens" : { - "comment" : "Hint mini-label on the Autosens settings screen when explaining the live Sensitivity Ratio\nNavigation-link title for the Autosens section in the draft profile editor", "localizations" : { "bg" : { "stringUnit" : { @@ -54861,9 +54430,6 @@ } } } - }, - "autosens is not needed for autoISF as it adapts on a longer time frame than autoISF, so any autosens adjustment is lagging behind what is done by autoISF. It can be kept to ON, and in some border cases the autosens ISF will be used. Check on Discord." : { - }, "Autosens Max" : { "comment" : "Autosens Max", @@ -56168,9 +55734,6 @@ } } } - }, - "autosens Ratio (AS)" : { - }, "Autosens Ratio =\n(Weighted Average of TDD) / (10-day Average of TDD)" : { "localizations" : { @@ -56975,9 +56538,6 @@ } } } - }, - "Available basal increment:" : { - }, "Available Layouts:" : { "localizations" : { @@ -57238,6 +56798,9 @@ } } } + }, + "Available Widgets" : { + }, "Average" : { "comment" : "Average BG =", @@ -57370,6 +56933,18 @@ } } }, + "Average of the Last 24 Hours" : { + "comment" : "Stats banner subtitle" + }, + "Average of the Last 3 Months" : { + "comment" : "Stats banner subtitle" + }, + "Average of the Last 7 Days" : { + "comment" : "Stats banner subtitle" + }, + "Average of the Last Month" : { + "comment" : "Stats banner subtitle" + }, "Average:" : { "localizations" : { "bg" : { @@ -57500,6 +57075,9 @@ } } }, + "Averages" : { + "comment" : "Home stats panel face option" + }, "Avg" : { "localizations" : { "bg" : { @@ -57761,55 +57339,6 @@ } } }, - "B30" : { - - }, - "B30 Basal Factor" : { - - }, - "B30 Basal Rate Increase Factor" : { - "comment" : "B30 Factor" - }, - "B30 iTime" : { - - }, - "B30 iTime Start Bolus" : { - - }, - "B30 iTime Target" : { - - }, - "B30 Settings" : { - - }, - "B30 Upper Delta" : { - - }, - "B30 Upper Limit" : { - - }, - "B30 will only run & supress SMB as long as BG stays underneath that level. Default is %@ %@." : { - "comment" : "B30 Upper BG Limit miniHint", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "B30 will only run & supress SMB as long as BG stays underneath that level. Default is %1$@ %2$@." - } - } - } - }, - "B30 will only run & supress SMB's as long as BG delta stays below that level. Default is %@ %@." : { - "comment" : "B30 Upper Delta miniHint", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "B30 will only run & supress SMB's as long as BG delta stays below that level. Default is %1$@ %2$@." - } - } - } - }, "Back" : { "localizations" : { "bg" : { @@ -60069,9 +59598,6 @@ } } } - }, - "Basal rate" : { - }, "Basal Rate" : { "localizations" : { @@ -60594,7 +60120,6 @@ } }, "Basal Rates" : { - "comment" : "Navigation-link title for the Basal Rates editor inside the draft profile editor", "localizations" : { "bg" : { "stringUnit" : { @@ -61649,9 +61174,6 @@ } } } - }, - "Because CR combines two independent physiological processes — CSF (carb absorption into blood) and ISF (insulin moving glucose out of blood) — calculating CR this way ensures it adjusts correctly across the day as your ISF profile changes, while CSF remains stable. If you do experience variation in carb absorption throughout the day, you can account for that in your CSF profile based on real physiological differences, rather than having CSF implicitly change as a mathematical side effect of a fixed CR and varying ISF." : { - }, "Because Trio utilizes SMBs and UAM SMBs to help you reach your target glucose and other AID systems do not bolus for COB the same way Trio does, this is initially set to below the full calculated amount (80%). When SMBs and UAM SMBs are enabled, you may find your current CR results in lows and needs to be increased before you increase this setting closer to or at 100%." : { "localizations" : { @@ -61782,9 +61304,6 @@ } } } - }, - "Becomes the new baseline. The basal profile is saved to the pump." : { - }, "Before you begin…" : { "localizations" : { @@ -62307,6 +61826,9 @@ } } }, + "Below 54 mg/dL" : { + "comment" : "Percentage of glucose readings in the very low GRI range." + }, "Below is the exact JSON object Trio would send right now. No glucose, insulin, carbs, credentials, or settings values are included." : { "localizations" : { "bg" : { @@ -62697,27 +62219,6 @@ } } } - }, - "bg" : { - "comment" : "Label for bg" - }, - "BG" : { - "comment" : "Label for BG" - }, - "BG Accel ISF Weight" : { - - }, - "BG Acceleration:" : { - - }, - "BG Brake ISF Weight" : { - - }, - "BG Plateau:" : { - - }, - "BG-ISF" : { - }, "BGI" : { "localizations" : { @@ -63898,9 +63399,6 @@ } } } - }, - "Blue entries differ from the source profile. Tap any row to review or adjust." : { - }, "Bluetooth" : { "localizations" : { @@ -64706,7 +64204,7 @@ } }, "Bolus" : { - "comment" : "Treatment history type label — Bolus\nTreatment row label — fallback when event type is missing\nTreatment type\nWatch App Treatment Option 'Bolus'", + "comment" : "Treatment type\nWatch App Treatment Option 'Bolus'", "localizations" : { "bg" : { "stringUnit" : { @@ -66018,7 +65516,6 @@ } }, "Bolus Display Threshold" : { - "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -66409,9 +65906,6 @@ } } }, - "Bolus exceeds max bolus." : { - "comment" : "Watch command ack" - }, "Bolus failed" : { "comment" : "Bolus failed", "localizations" : { @@ -66543,12 +66037,6 @@ } } }, - "Bolus failed. Check pump history before repeating." : { - "comment" : "Watch command ack" - }, - "Bolus from watch is disabled." : { - "comment" : "Watch command ack" - }, "Bolus In Progress..." : { "localizations" : { "bg" : { @@ -67076,9 +66564,6 @@ } } } - }, - "Bolus Label Threshold" : { - }, "Bolus limit cannot be fetched from phone!" : { "localizations" : { @@ -67747,9 +67232,6 @@ } } }, - "Bolus started." : { - "comment" : "Watch command ack" - }, "Bolus statistics will appear here once data is available." : { "localizations" : { "bg" : { @@ -68018,9 +67500,6 @@ } } }, - "Bolus would exceed max IOB." : { - "comment" : "Watch command ack" - }, "Bolus/Basal %" : { "localizations" : { "bg" : { @@ -69071,6 +68550,10 @@ } } }, + "Bryce" : { + "comment" : "Name of a spoken alert voice.", + "isCommentAutoGenerated" : true + }, "Build Number" : { "localizations" : { "bg" : { @@ -69471,11 +68954,9 @@ } } }, - "Calculate CR from CSF & ISF Profiles" : { - - }, - "Calculate CSF from profile CR and ISF." : { - "comment" : "Mini hint for Use Profile CSF" + "Calculated from glucose readings in the selected period. Short periods and gaps in sensor data can make the score less representative." : { + "comment" : "Explains that GRI uses readings from the selected period and may be less representative for short periods or incomplete data.", + "isCommentAutoGenerated" : true }, "Calculated Ratio" : { "extractionState" : "manual", @@ -69874,9 +69355,6 @@ } } } - }, - "Calculation Details for %@" : { - }, "Calculator Configuration" : { "localizations" : { @@ -71079,7 +70557,7 @@ } }, "Cancel" : { - "comment" : "Schedule activation notification: cancel / dismiss action", + "comment" : "Cancel preset name", "localizations" : { "bg" : { "stringUnit" : { @@ -71997,9 +71475,6 @@ } } } - }, - "Cancel Temp Basal" : { - }, "Cancel Temp Target" : { "comment" : "Alert", @@ -72268,13 +71743,6 @@ } } } - }, - "Cancel TT" : { - "comment" : "Text for a shortcut that cancels a temporary target.", - "isCommentAutoGenerated" : true - }, - "Cannot change Concentration" : { - }, "Cannot Enable This Setting" : { "localizations" : { @@ -72405,12 +71873,8 @@ } } } - }, - "Carb entries will be fully decayed by the number of hours specified as Max Meal Absorption Time. Meals that are high in fat and/or protein can have long lasting effects on BG levels. To allow such late meal effects to be considered by the carb decay model, a longer Max Meal Absorption Time than the default 6 hours can be set." : { - }, "Carb entries will be fully decayed by the number of hours specified as Max Meal Absorption Time. Meals that are high in fat and/or protein can have long lasting effects on glucose levels. To allow such late meal effects to be considered by the carb decay model, a longer Max Meal Absorption Time than the default 6 hours can be set." : { - "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -73068,7 +72532,7 @@ } }, "Carb Ratios" : { - "comment" : "Navigation-link title for the Carb Ratio (CR) editor inside the draft profile editor", + "comment" : "Carb Ratios header", "localizations" : { "bg" : { "stringUnit" : { @@ -73198,24 +72662,6 @@ } } }, - "Carb Sensitivities" : { - - }, - "Carb Sensitivity Factor (CSF)" : { - "comment" : "Header for CSF section" - }, - "Carb Sensitivity Factor (CSF) represents how much your blood glucose rises per gram of carbohydrate consumed. It describes the digestive process of carbs entering your bloodstream as glucose — a process that is independent of insulin sensitivity." : { - - }, - "Carb Sensitivity Factor (CSF) represents how much your blood glucose rises per gram of carbohydrate consumed. It describes your digestive process - how effectively carbs are absorbed into your blood." : { - - }, - "Carb Sensitivity Factor (CSF) represents how much your blood glucose rises per gram of carbohydrate consumed. It is calculated as: CSF = ISF / CR (Insulin Sensitivity Factor divided by Carb Ratio)." : { - - }, - "Carb Sensitivity Profile" : { - "comment" : "Carb Sensitivity Profile" - }, "Carbohydrates" : { "localizations" : { "bg" : { @@ -73347,7 +72793,7 @@ } }, "Carbs" : { - "comment" : "Treatment history type label — carbohydrate entry\nTreatment type", + "comment" : "Treatment type", "localizations" : { "bg" : { "stringUnit" : { @@ -73746,9 +73192,6 @@ } } }, - "Carbs exceed the maximum allowed." : { - "comment" : "Watch command ack" - }, "Carbs Limit Reached!" : { "localizations" : { "bg" : { @@ -73879,9 +73322,6 @@ } } }, - "Carbs logged and bolus started." : { - "comment" : "Watch command ack" - }, "Carbs logged successfully." : { "comment" : "Success message sent to watch when carbs are logged successfully", "localizations" : { @@ -74013,15 +73453,6 @@ } } }, - "Carbs logged." : { - "comment" : "Watch command ack" - }, - "Carbs logged. Bolus failed, check pump history before repeating." : { - "comment" : "Watch command ack" - }, - "Carbs logged. No bolus was delivered." : { - "comment" : "Watch command ack" - }, "Carbs Not Logged" : { "comment" : "Alert title when a carbs-only quick pick fails to save\nAlert title when the bolus was enacted but carbs failed to save", "localizations" : { @@ -78510,9 +77941,8 @@ } } }, - "Change Setting" : { - "comment" : "A section header that instructs the user to change the insulin concentration setting.", - "isCommentAutoGenerated" : true + "Changes the size of the current glucose reading and its trend arrow on the Simple Lock Screen widget. The delta and the time of the last reading are not affected." : { + }, "Chapter %1$d of %2$d, %3$@" : { "comment" : "Accessibility: onboarding progress", @@ -79823,13 +79253,6 @@ } } } - }, - "Check the other additional \"advanced settings\" like the basal safety multipliers, as they are also reset to defaults during onboarding." : { - "comment" : "A note explaining that the user should check the additional advanced settings, as they are also reset to their default values during onboarding.", - "isCommentAutoGenerated" : true - }, - "Check your Basal Profile after adding Pump!" : { - }, "Chime" : { "comment" : "Name of a sound file.", @@ -79962,12 +79385,6 @@ } } }, - "Choose 'Show all labels' to keep every label, or pick a higher threshold to clean up the chart when there are many small SMBs." : { - - }, - "Choose a duration first" : { - "comment" : "Slide-to-confirm button label shown (disabled) when no duration is set and indefinite is off" - }, "Choose a value that covers your highest insulin needs — think about a correction for a very high glucose reading plus your biggest meal bolus. This gives Trio room to work while keeping you safe." : { "localizations" : { "bg" : { @@ -80747,6 +80164,9 @@ } } } + }, + "Choose how far back the statistics panel on the home screen looks. The full statistics view opens on the same time span.\n\nToday: from midnight until now.\n\nLast 24 Hours: the past 24 hours.\n\nLast 7 Days: the past 7 days.\n\nLast Month: the past month.\n\nLast 3 Months: the past 3 months.\n\nThe panel's wording follows your choice, for example \"Time in Range last 7 days\"." : { + }, "Choose if you only want to use a datafield and no supported watchface!" : { "localizations" : { @@ -80877,6 +80297,9 @@ } } } + }, + "Choose the time span the home statistics panel covers." : { + }, "Choose to display eA1c and GMI in percent or mmol/mol." : { "localizations" : { @@ -81009,7 +80432,6 @@ } }, "Choose to hide small bolus amounts. See hint for more details." : { - "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -81660,8 +81082,8 @@ } } }, - "Choose what the statistics panel on the home screen shows by default. Tapping the panel always opens the full statistics view.\n\nTime in Range: today's time in range percentage with a glucose distribution bar.\n\nDistribution Bar Only: just the glucose distribution bar, without the percentage.\n\nToday's Averages: today's average glucose and GMI (Glucose Management Index).\n\nActive Profile: the active profile card. Temporary (expiring) profiles are always shown regardless of this setting.\n\nHide Statistics: shows no data at all, just a \"View Statistics\" shortcut." : { - + "Choose what the statistics panel on the home screen shows by default. Tapping the panel always opens the full statistics view.\n\nTime in Range: today's time in range percentage with a glucose distribution bar.\n\nDistribution Bar Only: just the glucose distribution bar, without the percentage.\n\nToday's Averages: today's average glucose and GMI (Glucose Management Index).\n\nHide Statistics: shows no data at all, just a \"View Statistics\" shortcut." : { + "extractionState" : "stale" }, "Choose what the statistics panel on the home screen shows by default. Tapping the panel always opens the full statistics view.\n\nTime in Range: today's time in range percentage with a glucose distribution bar.\n\nDistribution Bar Only: just the glucose distribution bar, without the percentage.\n\nToday's Averages: today's average glucose and GMI (Glucose Management Index).\n\nToday's Treatments: insulin delivered since midnight — no glucose statistics. Announced carbs (and fat and protein, if you log them) appear once logged that day.\n\nHide Statistics: shows no data at all, just a \"View Statistics\" shortcut." : { "extractionState" : "stale", @@ -81793,6 +81215,9 @@ } } } + }, + "Choose what the statistics panel on the home screen shows by default. Tapping the panel always opens the full statistics view.\n\nTime in Range: your time in range percentage with a glucose distribution bar.\n\nDistribution Bar Only: just the glucose distribution bar, without the percentage.\n\nAverages: your average glucose and GMI (Glucose Management Index).\n\nHide Statistics: shows no data at all, just a \"View Statistics\" shortcut.\n\nThe numbers cover the time span set in Home Statistics Range." : { + }, "Choose whether or not to display one or both X- and Y-Axis grid lines." : { "localizations" : { @@ -81925,7 +81350,6 @@ } }, "Choose which datafield to support. Can be used independently of watchface selection." : { - "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -82054,9 +81478,6 @@ } } } - }, - "Choose which datafields to support. Up to four can receive data at the same time, or select none at all. Can be used independently of watchface selection." : { - }, "Choose which format you'd prefer the eA1c (estimated A1c) and GMI (Glucose Management Index) value in the statistics view as a percentage (Example: eA1c: 6.5%) or mmol/mol (Example: eA1c: 48 mmol/mol)." : { "localizations" : { @@ -82188,10 +81609,6 @@ } } }, - "Choose which smoothing algorithm shall be applied." : { - "comment" : "A description below the picker in the \"Smoothing Algorithm\" section, explaining that they can choose which algorithm to use.", - "isCommentAutoGenerated" : true - }, "Choose which type of time in range Trio should adopt for all its statistical charts and displays:" : { "localizations" : { "bg" : { @@ -82852,7 +82269,6 @@ } }, "Close" : { - "comment" : "Close button label", "localizations" : { "bg" : { "stringUnit" : { @@ -83640,9 +83056,6 @@ } } } - }, - "COB value" : { - }, "COBpredBG" : { "localizations" : { @@ -84164,8 +83577,8 @@ } } }, - "Combined SMB cutoff calculation:" : { - + "Combined score" : { + "comment" : "Sum of the weighted GRI components before applying the cap." }, "Coming soon." : { "localizations" : { @@ -84296,15 +83709,6 @@ } } } - }, - "Command expired. Please try again." : { - "comment" : "Watch command ack" - }, - "Commands" : { - - }, - "Commands are only accepted from the Garmin apps selected above, must reach the phone within 2 minutes of being confirmed on the watch, and carbs are limited by your Max Carbs setting." : { - }, "complete" : { "comment" : "Accessibility: onboarding chapter complete", @@ -84437,9 +83841,6 @@ } } }, - "Completely switches off autoISF during a high TT with adjusted sensitivity." : { - "comment" : "Exercise toggles all autoISF adjustments off miniHint" - }, "Complication" : { "comment" : "Display name for the Garmin watchface setting option for the Connect IQ watch app.", "localizations" : { @@ -84570,12 +83971,6 @@ } } } - }, - "Concentration Information" : { - - }, - "Concentration Settings" : { - }, "Cone" : { "localizations" : { @@ -86835,13 +86230,6 @@ } } }, - "Confirm Before scheduling" : { - - }, - "Confirm Before Scheduling" : { - "comment" : "A toggle that allows the user to confirm before a schedule is saved.", - "isCommentAutoGenerated" : true - }, "Confirm Bolus Faster" : { "localizations" : { "bg" : { @@ -86972,40 +86360,6 @@ } } }, - "Confirm Temp Target '%@': %@ for %lld min starting %@" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Confirm Temp Target '%1$@': %2$@ for %3$lld min starting %4$@" - } - } - } - }, - "Confirm Temp Target '%@': %@ for %lld min, now" : { - "comment" : "Confirmation dialog text for creating a Temp Target. The first argument is the name of the Temp Target. The second argument is the formatted target value. The third argument is the duration in minutes.", - "isCommentAutoGenerated" : true, - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Confirm Temp Target '%1$@': %2$@ for %3$lld min, now" - } - } - } - }, - "Confirm to activate profile '%@' for %@" : { - "comment" : "Confirmation dialog text for activating a profile. The first argument is the name of the profile. The second argument is the formatted duration of activation.", - "isCommentAutoGenerated" : true, - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Confirm to activate profile '%1$@' for %2$@" - } - } - } - }, "Confirm to apply override '%@'" : { "localizations" : { "bg" : { @@ -87265,31 +86619,6 @@ } } } - }, - "Confirm to schedule profile '%@' at %@ for %@" : { - "comment" : "Confirmation dialog text for scheduling a profile. The first argument is the name of the profile. The second argument is the formatted date and time of activation. The third argument is the formatted duration of the activation.", - "isCommentAutoGenerated" : true, - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Confirm to schedule profile '%1$@' at %2$@ for %3$@" - } - } - } - }, - "Confirm to schedule Temporary Target '%@' for %@" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Confirm to schedule Temporary Target '%1$@' for %2$@" - } - } - } - }, - "Confirming pump writes" : { - }, "Connect" : { "comment" : "Connect to NS", @@ -90193,10 +89522,6 @@ } } }, - "Copied from" : { - "comment" : "A label displayed below the \"Name\" section of the draft editor, indicating that the profile was copied from another one.", - "isCommentAutoGenerated" : true - }, "Copies the recommended amount into the bolus field" : { "localizations" : { "bg" : { @@ -90716,9 +90041,6 @@ } } } - }, - "Correlation:" : { - }, "Could not access documents directory" : { "localizations" : { @@ -91117,12 +90439,6 @@ } } }, - "Could not log carbs." : { - "comment" : "Watch command ack" - }, - "Could not log carbs. No bolus was delivered." : { - "comment" : "Watch command ack" - }, "Could not save the change." : { "comment" : "Adjustment could not be saved", "localizations" : { @@ -91254,21 +90570,6 @@ } } }, - "Could not start override." : { - "comment" : "Watch command ack" - }, - "Could not start temp target." : { - "comment" : "Watch command ack" - }, - "Could not stop override." : { - "comment" : "Watch command ack" - }, - "Could not stop temp target." : { - "comment" : "Watch command ack" - }, - "Could not verify bolus safety." : { - "comment" : "Watch command ack" - }, "Count" : { "localizations" : { "bg" : { @@ -91530,10 +90831,6 @@ } } }, - "CR combines two independent physiological processes: CSF (carb absorption into blood) and ISF (insulin moving glucose out of blood). These processes operate separately, so when ISF changes due to exercise or stress, CSF remains stable, and CR adjusts accordingly through the formula CR = ISF / CSF." : { - "comment" : "A description of how the Carb Ratio (CR) is calculated.", - "isCommentAutoGenerated" : true - }, "Crash reporting and anonymous usage telemetry are enabled by default. Use the diagnostics-sharing chooser above to opt out: pick \"Crash Reports Only\" to keep crash reporting but disable telemetry, or \"Disable Sharing\" to turn off both. Changes take effect immediately." : { "comment" : "A description of the user's ability to opt out of sharing app usage data.", "localizations" : { @@ -91666,7 +90963,6 @@ } }, "Crash reports include the app's state on crash, device, iOS and general system info, and a stack trace. They are sent to a Google Firebase Crashlytics project maintained by the Trio team." : { - "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -91926,10 +91222,6 @@ } } }, - "Create and activate a Temporary Target now with name, target value and duration" : { - "comment" : "Description of the `CreateCustomTempTargetIntent`.", - "isCommentAutoGenerated" : true - }, "Create Events in Calendar" : { "extractionState" : "manual", "localizations" : { @@ -92066,15 +91358,6 @@ } } } - }, - "Create Temporary Target ${name} now" : { - - }, - "Create TT" : { - - }, - "Creating a new profile" : { - }, "Critical" : { "comment" : "Name of the critical alarm sound.", @@ -92337,18 +91620,8 @@ } } } - }, - "CSF" : { - - }, - "CSF describes the digestive process of carbs entering your bloodstream as glucose. This process is not dependent on insulin sensitivity, which describes a different process: removing glucose from the blood." : { - - }, - "CSF is defined as the expected BG rise per 10g of carbs consumed. Using 10g units keeps values in a practical range and makes it easier to test and document: e.g. eat 10g of fast carbs and observe the BG rise." : { - }, "Current" : { - "comment" : "Fallback source-profile name used in the draft editor when no profile is currently active", "localizations" : { "bg" : { "stringUnit" : { @@ -92868,9 +92141,6 @@ } } } - }, - "Current Concentration" : { - }, "Current IOB (%@ U) > Max IOB (%@ U)" : { "localizations" : { @@ -93810,9 +93080,6 @@ } } } - }, - "Currently used Insulin Sensitivity Factor (ISF) in mg/dL or mmol/L per IU." : { - }, "Custom" : { "comment" : "Custom temp target", @@ -93953,7 +93220,6 @@ } }, "Custom Override" : { - "comment" : "Default name assigned to an override when the user saves edits without providing a name\nFallback display name for a duplicated override when the preset had no name\nFallback display name for the active override when no list entry exists\nFallback display name for the active override when no name was set\nFallback name for an unnamed historical override run uploaded to Nightscout\nFallback name for an unnamed override uploaded to Nightscout\nFallback name on Home adjustments banner for an active override without a name\nFallback name when checking whether a stored Nightscout override entry needs updating", "localizations" : { "bg" : { "stringUnit" : { @@ -94222,7 +93488,6 @@ } }, "Custom Temp Target" : { - "comment" : "Fallback display name for a duplicated temp target when the preset had no name\nFallback display name for the active temp target when no list entry exists\nFallback display name for the active temp target when no name was set", "localizations" : { "bg" : { "stringUnit" : { @@ -94741,9 +94006,6 @@ } } } - }, - "cutoff = clamp( max( Minimum Safety Threshold, ratioFloor ), 60, 120 ) mg/dL" : { - }, "CV" : { "comment" : "CV", @@ -94875,9 +94137,6 @@ } } } - }, - "Cycles the zoom through the 6, 12 and 24 hour presets." : { - }, "d" : { "comment" : "Abbreviation for Days\nabbreviation for days", @@ -95140,9 +94399,6 @@ } } } - }, - "Daily / Weekdays / Weekends / Custom weekly: fires at the chosen time on matching weekdays." : { - }, "Daily glucose distribution chart" : { "localizations" : { @@ -95923,9 +95179,6 @@ } } } - }, - "Data Interval:" : { - }, "Data transmission has been disabled. Now select the new watchface on your Garmin device and resume data transmission once done." : { "localizations" : { @@ -96058,7 +95311,6 @@ } }, "Datafield Selection" : { - "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -96447,16 +95699,6 @@ } } } - }, - "Date & time the preset should activate (within the next 24 hours)" : { - - }, - "Date & time the Temp Target should begin (within the next 24 hours)" : { - "comment" : "Prompt for the user to select a start time for the custom temporary target.", - "isCommentAutoGenerated" : true - }, - "Date and time the profile should activate" : { - }, "Date of adding" : { "localizations" : { @@ -97908,12 +97150,6 @@ } } } - }, - "Days of month" : { - - }, - "Days that don't exist in a given month (e.g. 31 in February) are skipped." : { - }, "DayStart" : { "localizations" : { @@ -98718,7 +97954,6 @@ } }, "Decrease sensitivity when glucose is below target if a manual Temp Target < %@ %@ is set." : { - "comment" : "Mini-hint for Low Temp Target Lowers Sensitivity — interpolated values are the threshold number and the glucose unit", "localizations" : { "bg" : { "stringUnit" : { @@ -98853,12 +98088,6 @@ } } } - }, - "Decreasing this setting may result in fewer FPU entries with larger carb values." : { - - }, - "Decreasing this setting may result in more FPU entries with smaller carb values." : { - }, "Default" : { "localizations" : { @@ -98991,7 +98220,6 @@ } }, "Default impact of carb absorption over a 5 minute interval." : { - "comment" : "Mini-hint for Min 5m Carb Impact setting", "localizations" : { "bg" : { "stringUnit" : { @@ -99925,12 +99153,6 @@ } } }, - "Default value: 0.5 This is the higher end of a linearly increasing SMB Delivery Ratio rather than the fix value above in SMB DeliveryRatio." : { - "comment" : "SMB DeliveryRatio Maximum miniHint" - }, - "Default value: 0.5 This is the lower end of a linearly increasing SMB Delivery Ratio rather than the fix value above in SMB DeliveryRatio." : { - "comment" : "SMB DeliveryRatio Minimum miniHint" - }, "Default values are based on internationally accepted Time in Range values of %@-%@ %@." : { "localizations" : { "bg" : { @@ -100066,9 +99288,6 @@ } } } - }, - "Default: OFF " : { - }, "Default: %@ %@" : { "localizations" : { @@ -100335,15 +99554,6 @@ } } } - }, - "Default: 0.5" : { - - }, - "Default: 0.5 — valid range (0.5, 1.0]" : { - - }, - "Default: 1" : { - }, "Default: 2 %@" : { "localizations" : { @@ -100734,9 +99944,6 @@ } } } - }, - "Default: 8 hours" : { - }, "Default: 10 hours" : { "localizations" : { @@ -102038,10 +101245,6 @@ } } }, - "Default: 100 %" : { - "comment" : "A label inside the \"Therapy Adjustment\" section of the \"Add Profile\" form, explaining that the default percentage is 100%.", - "isCommentAutoGenerated" : true - }, "Default: 100%" : { "localizations" : { "bg" : { @@ -102171,9 +101374,6 @@ } } } - }, - "Default: 100% " : { - }, "Default: 120 grams of carbs" : { "localizations" : { @@ -102824,9 +102024,6 @@ } } } - }, - "Default: Badge displayed" : { - }, "Default: Day starts %1$@, Night starts %2$@." : { "localizations" : { @@ -103087,6 +102284,9 @@ } } } + }, + "Default: Large" : { + }, "Default: OFF" : { "localizations" : { @@ -107952,9 +107152,6 @@ } } } - }, - "Delete pump if you need to change Insulin Concentration" : { - }, "Delete Selected Preset" : { "extractionState" : "manual", @@ -108223,10 +107420,6 @@ } } }, - "Delete the Profile \"%@\"?" : { - "comment" : "A confirmation dialog that lets the user delete a profile.", - "isCommentAutoGenerated" : true - }, "Delete the Temp Target Preset \"%@\"?" : { "comment" : "Delete confirmation title for temporary target presets", "localizations" : { @@ -108358,10 +107551,6 @@ } } }, - "Delete this schedule?" : { - "comment" : "A confirmation dialog title.", - "isCommentAutoGenerated" : true - }, "Delete Variant" : { "localizations" : { "bg" : { @@ -109281,7 +108470,6 @@ } }, "Deselect All" : { - "comment" : "Toggle button in History filter popover — deselects every treatment type", "localizations" : { "bg" : { "stringUnit" : { @@ -109948,7 +109136,6 @@ } }, "Determined at %@" : { - "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -111396,9 +110583,6 @@ } } } - }, - "Diagnostics are sent to a Google Firebase Crashlytics project, which is securely maintained and accessed only by the Tai author." : { - }, "Dings" : { "comment" : "Name of a sound file.", @@ -113246,9 +112430,6 @@ } } } - }, - "Dismisses the loop status popup again." : { - }, "Display and allow Fat and Protein entries" : { "comment" : "UI/UX option", @@ -115398,9 +114579,6 @@ } } } - }, - "Display sensor lifecycle arc, hourglass and status around the glucose bobble." : { - }, "Display Standard Deviation (SD) instead of Coefficient of Variation (CV) in statPanel" : { "comment" : "Description for display SD", @@ -118035,9 +117213,6 @@ } } } - }, - "Double Tap" : { - }, "Double tap to confirm" : { "comment" : "Accessibility hint", @@ -118433,7 +117608,6 @@ } }, "Double-Tap" : { - "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -118693,7 +117867,7 @@ } } }, - "Drag the chart sideways to pan through your history. Flick and let go to keep it gliding; panning far enough back loads up to 72 hours." : { + "Drag a widget to move it. Drag one up from Available Widgets to add it, or drop it back onto the list to remove it." : { }, "Drives the outer-ring + tag on the home screen's glucose bobble." : { @@ -119216,18 +118390,6 @@ } } }, - "dura" : { - "comment" : "Label for dura" - }, - "dura_ISF: %@" : { - - }, - "Dura-ISF" : { - - }, - "DuraISF Weight" : { - "comment" : "DuraISF Weight\nRow label on Add Override form — DuraISF Weight\nRow label on Edit Override form — DuraISF Weight" - }, "Duration" : { "comment" : "Duration of target temp or temp basalMedian loop interval", "localizations" : { @@ -119359,23 +118521,6 @@ } } }, - "Duration (minutes)" : { - "comment" : "Label for the duration parameter in the `CreateCustomTempTargetIntent`.", - "isCommentAutoGenerated" : true - }, - "Duration in minutes?" : { - "comment" : "Label for the duration parameter in the Create Custom Temp Target intent.", - "isCommentAutoGenerated" : true - }, - "Duration must be positive" : { - - }, - "Duration of Increased B30 Basal Rate" : { - "comment" : "B30 Duration" - }, - "Duration of increased basal rate that saturates the infusion site with insulin. Default 30 minutes." : { - "comment" : "B30 Duration miniHint" - }, "Duration of Insulin Action" : { "comment" : "Duration of Insulin Action", "localizations" : { @@ -119506,12 +118651,6 @@ } } } - }, - "Duration of Insulin Action (DIA)" : { - - }, - "Duration of Plateau:" : { - }, "Dynamic" : { "localizations" : { @@ -119904,7 +119043,6 @@ } }, "Dynamic ISF" : { - "comment" : "Dynamic ISF", "localizations" : { "bg" : { "stringUnit" : { @@ -120295,7 +119433,6 @@ } }, "Dynamic ISF requires at least " : { - "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -120825,7 +119962,6 @@ } }, "Dynamic Settings" : { - "comment" : "Navigation-link title for the Dynamic ISF/Ratio section in the draft profile editor", "localizations" : { "bg" : { "stringUnit" : { @@ -121086,7 +120222,6 @@ } }, "Dynamically adjust insulin sensitivity using Dynamic Ratio rather than Autosens Ratio." : { - "comment" : "Mini-hint for Use Dynamic ISF setting", "localizations" : { "bg" : { "stringUnit" : { @@ -121215,9 +120350,6 @@ } } } - }, - "DynamicISF" : { - }, "eA1c" : { "localizations" : { @@ -122403,9 +121535,6 @@ } } } - }, - "Edit Profile" : { - }, "Edit settings json" : { "extractionState" : "manual", @@ -122674,8 +121803,9 @@ } } }, - "eff.TH" : { - + "Electronic" : { + "comment" : "Themed alarm sound catalog for electronic sounds.", + "isCommentAutoGenerated" : true }, "Emoji or short text" : { "localizations" : { @@ -122943,6 +122073,9 @@ } } } + }, + "Empty widget slot" : { + }, "Enable" : { "comment" : "Enable FPU", @@ -123083,7 +122216,6 @@ } }, "Enable a Temporary Target" : { - "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -123213,9 +122345,6 @@ } } }, - "Enable Absolute Safety TBR" : { - "comment" : "Enable Absolute TBR" - }, "Enable adjustment of basal based on the ratio of current TDD / 7 day average TDD" : { "comment" : "Enable adjustment of basal profile", "extractionState" : "manual", @@ -123354,18 +122483,6 @@ } } }, - "Enable AutoISF" : { - - }, - "Enable Autosens" : { - "comment" : "Enable Autosens" - }, - "Enable B30" : { - - }, - "Enable BG Acceleration" : { - "comment" : "Enable BG Acceleration" - }, "Enable device connectivity" : { "localizations" : { "bg" : { @@ -124558,12 +123675,8 @@ } } } - }, - "Enable Indefinitely or set a duration greater than zero." : { - }, "Enable indefinitely or set a duration." : { - "comment" : "Validation error on Add Override form when neither indefinite nor a duration is set\nValidation error on Edit Override form when neither indefinite nor a duration is set", "localizations" : { "bg" : { "stringUnit" : { @@ -124692,9 +123805,6 @@ } } } - }, - "Enable KetoProtect" : { - }, "Enable Live Activity" : { "localizations" : { @@ -125608,9 +124718,6 @@ } } } - }, - "Enable SMB Even On Odd Off Always" : { - }, "Enable SMB With COB" : { "comment" : "Enable SMB With COB", @@ -126666,7 +125773,6 @@ } }, "Enable Unannounced Meals SMB." : { - "comment" : "Mini-hint for Enable UAM setting", "localizations" : { "bg" : { "stringUnit" : { @@ -127058,9 +126164,6 @@ }, "Enable uploading of sensor errors and other CGM states to Nightscout as notes." : { - }, - "Enable Watch Commands" : { - }, "Enabled" : { "comment" : "Title string for BeepPreference.manualCommands", @@ -127200,15 +126303,6 @@ } } }, - "Enables an increased basal rate after an EatingSoon TT and a manual bolus to saturate the infusion site with insulin to increase insulin absorption for SMB's following a meal with no carb counting." : { - - }, - "Enables an increased basal rate after an EatingSoon TT and a manual bolus to saturate the infusion site with insulin." : { - "comment" : "Enable B30 miniHint" - }, - "Enables the BG acceleration adaptions, adjusting ISF for accelerating/decelerating blood glucose." : { - "comment" : "Enable BG Acceleration miniHint" - }, "Enables this temp target" : { "comment" : "Accessibility hint", "localizations" : { @@ -129821,9 +128915,6 @@ } } } - }, - "Enact" : { - }, "Enact bolus" : { "comment" : "Button", @@ -130371,7 +129462,6 @@ } }, "Enacted at %@" : { - "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -131282,16 +130372,6 @@ } } }, - "Ensure that the selected insulin concentration matches the vial or pen label. U%lld means %lld units per ml." : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Ensure that the selected insulin concentration matches the vial or pen label. U%1$lld means %2$lld units per ml." - } - } - } - }, "Enter Meal Preset Name" : { "extractionState" : "manual", "localizations" : { @@ -132347,7 +131427,6 @@ } }, "Entries are capped at 33 grams each, with up to three entries, for a max total of 99 grams." : { - "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -132479,7 +131558,6 @@ }, "Error" : { "comment" : "Error title", - "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -132746,9 +131824,6 @@ } } } - }, - "Error During Algorithm Run at %@" : { - }, "Error while processing Treatment" : { "localizations" : { @@ -133929,9 +133004,6 @@ } } } - }, - "Eventual BG value" : { - }, "Eventual glucose" : { "comment" : "A label displayed under the loop status icon", @@ -134746,12 +133818,6 @@ } } }, - "Every bolus is checked against your Max Bolus and Max IOB. A watch bolus is refused for 6 minutes after the previous watch bolus, and any watch bolus is refused if the boluses of the last 6 minutes (SMBs included) add up to 20% or more of it. These checks cannot tell who pressed the button on the watch: anyone with access to your watch can deliver insulin up to those limits." : { - - }, - "Every day" : { - "comment" : "Schedule repeat summary — every day of the week" - }, "Everything you enter here can be adjusted later in the app." : { "localizations" : { "bg" : { @@ -135011,15 +134077,6 @@ } } } - }, - "Example:" : { - - }, - "Examples (minBG = 100 mg/dL):" : { - - }, - "Examples (TargetGlucose = 100 mg/dL):" : { - }, "exceeded" : { "comment" : "Limit Exceeded label", @@ -135297,9 +134354,6 @@ } } }, - "Exercise toggles all autoISF adjustments off" : { - "comment" : "autoISF Off for Sport" - }, "Experimental" : { "comment" : "New Experimental feature", "extractionState" : "manual", @@ -135713,9 +134767,6 @@ } } }, - "Expiring" : { - "comment" : "Countdown label shown on the active-profile banner when less than a minute of a timed activation remains\nCountdown text on Home profile banner when less than a minute of a timed activation remains" - }, "Export Categories" : { "localizations" : { "bg" : { @@ -136909,10 +135960,6 @@ } } }, - "Extensions" : { - "comment" : "A section header in the draft profile editor, listing the extensions that can be applied to a profile.", - "isCommentAutoGenerated" : true - }, "External" : { "comment" : "External Insulin", "localizations" : { @@ -137304,9 +136351,6 @@ } } } - }, - "External insulin keeps its rhombus marker." : { - }, "External Insulin?" : { "localizations" : { @@ -137568,6 +136612,9 @@ } } }, + "Extra Large" : { + "comment" : "Live Activity glucose reading font size" + }, "Extracting calibrationdata from sensor" : { "comment" : "Extracting calibrationdata from sensor", "extractionState" : "manual", @@ -138229,9 +137276,6 @@ } } }, - "Factor that multiplies your regular basal rate from profile for B30. Max is 10. The TBR will ignore the maxBasalMultipliers but respect maxBasal setting!" : { - "comment" : "B30 Factor miniHint" - }, "Factory Calibration Parameters" : { "extractionState" : "manual", "localizations" : { @@ -138369,18 +137413,6 @@ } } }, - "Failed to activate profile '%@': %@" : { - "comment" : "Error message shown when an attempt to activate a profile fails. The first argument is the name of the profile that failed to activate. The second argument is a human-readable description of the failure.", - "isCommentAutoGenerated" : true, - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Failed to activate profile '%1$@': %2$@" - } - } - } - }, "Failed to create a new object in %@ from %@." : { "localizations" : { "bg" : { @@ -139062,7 +138094,6 @@ } }, "Failed to import algorithm data." : { - "comment" : "Migration error shown on first launch when legacy carb/algorithm JSON fails to import into Core Data", "localizations" : { "bg" : { "stringUnit" : { @@ -139193,7 +138224,6 @@ } }, "Failed to import glucose history." : { - "comment" : "Migration error shown on first launch when legacy glucose JSON fails to import into Core Data", "localizations" : { "bg" : { "stringUnit" : { @@ -139324,7 +138354,6 @@ } }, "Failed to import pump history." : { - "comment" : "Migration error shown on first launch when legacy pump-history JSON fails to import into Core Data", "localizations" : { "bg" : { "stringUnit" : { @@ -139453,13 +138482,6 @@ } } } - }, - "Failed to schedule profile '%@'" : { - "comment" : "A dialog shown when scheduling a profile fails.", - "isCommentAutoGenerated" : true - }, - "Failed to schedule Temporary Target '%@'" : { - }, "Failed to Suspend Insulin Delivery" : { "comment" : "Alert title for suspend error", @@ -139737,9 +138759,6 @@ } } }, - "Failed to update profile state." : { - "comment" : "Error returned when the Core Data flip marking a profile active could not be saved" - }, "Failed to validate object in %@ from %@." : { "localizations" : { "bg" : { @@ -142505,9 +141524,6 @@ } } }, - "final" : { - "comment" : "Label for final" - }, "Fine-tune the ring’s Width and Gap to suit your design preferences." : { "localizations" : { "bg" : { @@ -142637,12 +141653,6 @@ } } } - }, - "Fire at" : { - - }, - "Fires once at the chosen date and time, then the schedule is removed." : { - }, "Fires when glucose drops to or below a low threshold." : { "localizations" : { @@ -143951,15 +142961,6 @@ } } } - }, - "fit a0" : { - - }, - "fit a1" : { - - }, - "fit a2" : { - }, "Follows the phone's current color scheme setting at that time" : { "localizations" : { @@ -144742,23 +143743,6 @@ } } }, - "for %lld h" : { - "comment" : "Schedule duration summary — temporary activation in whole hours\nUpcoming-schedule row duration — whole hours" - }, - "for %lld h %lld min" : { - "comment" : "Schedule duration summary — temporary activation in hours and minutes\nUpcoming-schedule row duration — hours and minutes", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "for %1$lld h %2$lld min" - } - } - } - }, - "for %lld min" : { - "comment" : "Schedule duration summary — temporary activation under one hour\nUpcoming-schedule row duration — under one hour" - }, "For 45 g of carbs, you would need:" : { "localizations" : { "bg" : { @@ -145944,7 +144928,6 @@ } }, "Forcing loop…" : { - "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -148315,13 +147298,6 @@ } } } - }, - "Full-Loop exception: when autoISF's full-loop mode is active (an even-numbered temp target below 100 mg/dL, e.g. 80 or 90, signalling you want maximum SMB power), the delivery ratio becomes the greater of the fixed ratio and the ramp value — so the fixed ratio acts as a floor and the ramp can only raise it further." : { - "comment" : "Explanation of the \"Full-Loop exception\" in the verbose hint for `smbDeliveryRatioBGrange`.", - "isCommentAutoGenerated" : true - }, - "Full-Loop mode" : { - }, "Furthermore, you could adjust that sensitivity change independently from the Half Basal Exercise Target specified in Algorithm > Target Behavior settings by deliberately setting a customized Insulin Percentage for a Temp Target." : { "comment" : "A paragraph explaining how to set a custom insulin percentage for a temporary target.", @@ -150287,6 +149263,10 @@ } } }, + "Glass Bell" : { + "comment" : "Name of a sound that plays when a glass bell is rung.", + "isCommentAutoGenerated" : true + }, "Glossary" : { "localizations" : { "bg" : { @@ -151077,8 +150057,15 @@ } } }, + "Glucose and Trend" : { + "comment" : "Live Activity widget icon label for Glucose and Trend in the default text color" + }, + "Glucose and Trend (Colored)" : { + "comment" : "Live Activity widget icon label for Glucose and Trend in the glucose color" + }, "Glucose and Trend, no Delta" : { "comment" : "Live Activity widget icon label for Glucose and Trend, no Delta", + "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -153053,7 +152040,6 @@ } }, "Glucose Deviations Algorithm" : { - "comment" : "Section header on the Autosens settings screen grouping max/min and related controls", "localizations" : { "bg" : { "stringUnit" : { @@ -154620,6 +153606,9 @@ } } } + }, + "Glucose Reading Font" : { + }, "Glucose Settings" : { "extractionState" : "manual", @@ -155548,7 +154537,6 @@ } }, "Glucose Targets" : { - "comment" : "Navigation-link title for the Glucose Targets editor inside the draft profile editor", "localizations" : { "bg" : { "stringUnit" : { @@ -155678,9 +154666,6 @@ } } }, - "Glucose Targets tuned" : { - "comment" : "Profile summary badge — glucose targets differ from defaults" - }, "Glucose trace WILL NOT be affected by any insulin or carb entries." : { "localizations" : { "bg" : { @@ -156737,6 +155722,15 @@ } } }, + "Glucose, Trend, Delta (Colored)" : { + "comment" : "Live Activity widget icon label for Glucose, Trend, Delta in the glucose color" + }, + "Glucose, Trend, Delta (Double Width, Colored)" : { + "comment" : "Live Activity widget icon label for the double-width Glucose, Trend, Delta item in the glucose color" + }, + "Glucose, Trend, Delta (Double Width)" : { + "comment" : "Live Activity widget icon label for the double-width Glucose, Trend, Delta item in the default text color" + }, "Glucose: %@" : { "comment" : "Glucose: %@", "extractionState" : "manual", @@ -156875,6 +155869,14 @@ } } }, + "Glycemia Risk Index" : { + "comment" : "The title of the screen that shows the Glycemia Risk Index.", + "isCommentAutoGenerated" : true + }, + "Glycemia Risk Index combines low and high glucose exposure into a score from 0 to 100. Lower scores indicate less glycemic risk." : { + "comment" : "A description of how the Glycemia Risk Index (GRI) is calculated.", + "isCommentAutoGenerated" : true + }, "GMI" : { "localizations" : { "bg" : { @@ -158193,6 +157195,18 @@ } } }, + "GRI" : { + "comment" : "The title of the \"Glycemia Risk Index\" button.", + "isCommentAutoGenerated" : true + }, + "GRI risk grid, zone %@" : { + "comment" : "An element that visually represents the GRI risk grid, with the current risk zone highlighted.", + "isCommentAutoGenerated" : true + }, + "GRI uses fixed glucose thresholds, regardless of your target range or display units." : { + "comment" : "A description of how the GRI calculates its score.", + "isCommentAutoGenerated" : true + }, "h" : { "comment" : "Abbreviation for Hours\nabbreviation for hours\nh", "localizations" : { @@ -158585,6 +157599,10 @@ } } }, + "Hard Saw Wave" : { + "comment" : "Name of the hard saw wave sound.", + "isCommentAutoGenerated" : true + }, "Hardware" : { "extractionState" : "manual", "localizations" : { @@ -158722,6 +157740,14 @@ } } }, + "Harp" : { + "comment" : "Name of the harp sound.", + "isCommentAutoGenerated" : true + }, + "Harsh" : { + "comment" : "Display name for a level of alarm urgency.", + "isCommentAutoGenerated" : true + }, "Has to be > 0 and <= 1.\nDefault is 0.65 (65 %) * TDD. The rest will be from average of total data (up to 14 days) of all TDD calculations (35 %). To only use past 24 hours, set this to 1.\n\nTo avoid sudden fluctuations, for instance after a big meal, an average of the past 2 hours of TDD calculations is used instead of just the current TDD (past 24 hours at this moment)." : { "comment" : "Weight of past 24 hours of insulin", "extractionState" : "manual", @@ -158859,9 +157885,6 @@ } } } - }, - "Having the U50 active but with U100 in the pump will be very dangerous. Don't hide the badge unless you do not change anything in the longterm - diluted insulins are shown in red, concentrated in yellow." : { - }, "Header" : { "localizations" : { @@ -159659,12 +158682,8 @@ } } }, - "Hey Tai user, watch out!" : { - "comment" : "Title of the warning shown on builds that are not from the main branch" - }, "Hey Trioneer, watch out!" : { "comment" : "Title of the warning shown on builds that are not from the main branch", - "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -160330,7 +159349,6 @@ } }, "Hide Future" : { - "comment" : "Toggle button in History — hide entries with future timestamps\nToggle button on History Carb Entries view — hide entries with future timestamps", "localizations" : { "bg" : { "stringUnit" : { @@ -160459,15 +159477,6 @@ } } } - }, - "Hide Insulin Badge" : { - - }, - "Hide Insulin Concentration badge" : { - "comment" : "Hide Badge" - }, - "Hide numeric labels next to small boluses and SMBs to reduce chart clutter." : { - }, "Hide Statistics" : { "comment" : "Home stats panel face option", @@ -161528,7 +160537,6 @@ } }, "High Glucose Target" : { - "comment" : "Row label for the high-glucose target field that gates Enable SMB With High Glucose", "localizations" : { "bg" : { "stringUnit" : { @@ -162448,18 +161456,6 @@ } } }, - "Higher = more conservative. Useful if you find the default cuts SMBs off too late for your comfort. Values at or below 0.5 fall back to the default; values above 1.0 are ignored (clamped to (0.5, 1.0])." : { - - }, - "Higher = more conservative. Values at or below 0.5 fall back to 0.5; values above 1.0 are ignored. The final cutoff is also clamped to 60–120 mg/dL." : { - - }, - "Higher ISF Range Weight" : { - - }, - "Highest ISF factor allowed." : { - "comment" : "autoISF Max miniHint" - }, "History" : { "comment" : "Header", "localizations" : { @@ -162727,9 +161723,6 @@ } } } - }, - "Hold Trio Button" : { - }, "Home" : { "comment" : "Home title", @@ -162861,9 +161854,6 @@ } } } - }, - "Home Screen Gestures" : { - }, "Home Statistics Panel" : { "localizations" : { @@ -164198,17 +163188,6 @@ } } }, - "How far above your BG target the delivery ratio ramps up. Sensible values are %@–%@ %@. Set to 0 to disable the ramp and use the fixed SMB DeliveryRatio instead." : { - "comment" : "SMB DeliveryRatio BG Range miniHint", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "How far above your BG target the delivery ratio ramps up. Sensible values are %1$@–%2$@ %3$@. Set to 0 to disable the ramp and use the fixed SMB DeliveryRatio instead." - } - } - } - }, "How it Works" : { "localizations" : { "bg" : { @@ -164339,16 +163318,7 @@ } } }, - "How long the Temp Target should run (5–1440 min)" : { - "comment" : "Description of the parameter for the duration of a custom Temp Target.", - "isCommentAutoGenerated" : true - }, - "How many additional minutes?" : { - "comment" : "Request dialog title for the \"Minutes\" parameter of the `ApplyProfileIntent`.", - "isCommentAutoGenerated" : true - }, "How many grams of carbs?" : { - "comment" : "Request carbs quantity", "localizations" : { "bg" : { "stringUnit" : { @@ -164479,7 +163449,6 @@ } }, "How many grams of fat?" : { - "comment" : "Request fat quantity", "localizations" : { "bg" : { "stringUnit" : { @@ -164610,7 +163579,7 @@ } }, "How many grams of protein?" : { - "comment" : "Request protein quantity", + "comment" : "Request dialog text for the \"Quantity Protein\" parameter.", "localizations" : { "bg" : { "stringUnit" : { @@ -164739,9 +163708,6 @@ } } } - }, - "How many whole hours?" : { - }, "How to Return to Main" : { "comment" : "Button on the non-release build warning that opens documentation about returning to main", @@ -165265,6 +164231,34 @@ } } }, + "Hyperglycemia component" : { + "comment" : "A label describing the hyperglycemia component of the GRI calculation.", + "isCommentAutoGenerated" : true + }, + "Hyperglycemia Component (%)" : { + "comment" : "The vertical axis label in the GRI risk grid.", + "isCommentAutoGenerated" : true + }, + "Hypoglycemia %@ percent, hyperglycemia %@ percent" : { + "comment" : "A textual representation of the GRI risk grid, including the values for hypoglycemia and hyperglycemia.", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Hypoglycemia %1$@ percent, hyperglycemia %2$@ percent" + } + } + } + }, + "Hypoglycemia component" : { + "comment" : "A label describing one of the components of the Glycemia Risk Index.", + "isCommentAutoGenerated" : true + }, + "Hypoglycemia Component (%)" : { + "comment" : "The horizontal axis label in the GRI risk grid.", + "isCommentAutoGenerated" : true + }, "I understand! Proceed" : { "localizations" : { "bg" : { @@ -165655,9 +164649,6 @@ } } } - }, - "If all insulin delivery stopped, and assuming no food is eaten or other significant changes occur, at what level would blood glucose eventually level off within 3-4 hrs." : { - }, "If an active override preset is edited, the changes will also apply to the currently running override. However, if you edit the currently running override directly, the preset stays unchanged." : { "localizations" : { @@ -166048,9 +165039,6 @@ } } } - }, - "If enabled this function will switch off autoISF adaptions completely if you are exercising. Exercising means you have a high TempTarget enabled and HighTTraisesSens, so that this high TT will already increase your sensitivity (will be displayed in active TempTarget)." : { - }, "If enabled, a confirmation dialog will be shown when activating adjustment presets." : { "localizations" : { @@ -166714,10 +165702,6 @@ } } }, - "If the \"Use Profile CSF\" setting is enabled in Algorithm Additionals, the algorithm will also keep CSF independent of dynamic ISF adjustments from autoISF, Autosens, or Dynamic ISF. This effectively makes your CR dynamic at runtime too — some refer to this as \"dynamic CR\"." : { - "comment" : "Explanation of the \"Use Profile CSF\" setting in the context of Carb Ratio (CR) calculation.", - "isCommentAutoGenerated" : true - }, "If toggled, Insulin will be added to IOB but it will not be delivered" : { "localizations" : { "bg" : { @@ -167237,12 +166221,6 @@ } } } - }, - "If toggled, you will need to confirm before scheduling" : { - - }, - "If toggled, you will need to confirm before the schedule is saved" : { - }, "If using Dynamic ISF (with Sigmoid), overriding your ISF will adjust the ISF used at your glucose target which extends to the ISF used at other glucose. Overriding your glucose target will change glucose level your ISF will be set to your profile ISF. Both of these can be combined in a single Override." : { "localizations" : { @@ -167640,10 +166618,6 @@ } } }, - "If you are an expert user and mix your own insulin, it’s essential to review DIA and Custom Peak Time of your insulin after the onboarding, as they are reset to defaults." : { - "comment" : "A note for users who mix their own insulin, reminding them to review the DIA and Custom Peak Time settings after onboarding.", - "isCommentAutoGenerated" : true - }, "If you are not seeing calendars to choose here, please go to Settings -> Trio -> Calendars and change permissions to \"Full Access\"" : { "localizations" : { "bg" : { @@ -168164,16 +167138,6 @@ } } }, - "If your glucose target is %@ %@ and ratio = 0.5, ratioFloor = %@ %@. Setting Minimum Safety Threshold below %@ has no effect; setting it above raises the cutoff." : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "If your glucose target is %1$@ %2$@ and ratio = 0.5, ratioFloor = %3$@ %4$@. Setting Minimum Safety Threshold below %5$@ has no effect; setting it above raises the cutoff." - } - } - } - }, "If your glucose target is %@ %@, Trio will use a safety threshold of %@ %@, unless you set Minimum Safety Threshold to something > %@ %@." : { "localizations" : { "bg" : { @@ -168439,9 +167403,6 @@ } } } - }, - "Immediately apply ${preset}" : { - }, "Immediately applying ${bolusQuantity} U" : { "localizations" : { @@ -168574,7 +167535,6 @@ } }, "Immediately applying ${preset}" : { - "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -170169,9 +169129,6 @@ } } } - }, - "Important: Enabling this setting changes how the algorithm handles carb absorption. Review your algorithm and therapy settings, and consider starting with a higher glucose target while you adjust. Settings will generally need to be less aggressive, as the stable CSF combined with a reduced CR in insulin resistance situations can increase insulin dosing for meals." : { - }, "Importing Settings..." : { "comment" : "Progress text when importing settings via Nightscout", @@ -170304,9 +169261,6 @@ } } }, - "In addition to the Zero Temp the activiation of KetoProtect is dependant on IOB levels and last Active Insulin." : { - "comment" : "Variable protection miniHint" - }, "In case you're using both profiles and temp targets" : { "comment" : "UI/UX option", "extractionState" : "manual", @@ -170444,12 +169398,6 @@ } } } - }, - "In full-loop mode, the SMB delivery ratio becomes the greater of the fixed SMB DeliveryRatio and the linearly-rising ramp value — so the fixed ratio acts as a floor the ramp can only raise, never lower." : { - - }, - "In oref smb_delivery_ratio is normally hard coded as 0.5 of the insulin requested. This is a safety feature for master/follower setups in case both phones trigger an SMB in the same situation. If this does not apply in your case you may increase this setting to a value above 0.5 and up to even 1.0 if you are very courageous." : { - }, "in progress" : { "comment" : "Accessibility: loop currently running", @@ -170844,10 +169792,6 @@ } } }, - "In the algorithm, CSF is used to estimate carb absorption rates, convert carb impact to grams absorbed, project remaining carb absorption, and calculate carbs required to prevent lows. With this setting enabled, all of these calculations use a stable, profile-based CSF." : { - "comment" : "A paragraph explaining how the \"Use Profile CSF\" setting affects the algorithm's calculations.", - "isCommentAutoGenerated" : true - }, "In the next few steps, you’ll configure your algorithm settings for" : { "localizations" : { "bg" : { @@ -171109,7 +170053,6 @@ } }, "Increase sensitivity when glucose is above target if a manual Temp Target > %@ %@ is set." : { - "comment" : "Mini-hint for High Temp Target Raises Sensitivity — interpolated values are the threshold number and the glucose unit", "localizations" : { "bg" : { "stringUnit" : { @@ -171504,12 +170447,6 @@ } } } - }, - "Increasing this setting may result in fewer FPU entries with larger carb values." : { - - }, - "Increasing this setting may result in more FPU entries with smaller carb values." : { - }, "Indefinite" : { "localizations" : { @@ -171640,19 +170577,6 @@ } } } - }, - "Indefinite / until next: becomes your new baseline. The basal profile is saved to the pump and stays until you switch profiles again." : { - - }, - "Indefinite activation: an actionable notification (\"Save basal to pump?\") with Save to pump / Cancel buttons. Tapping the body re-opens an in-app confirmation dialog when you return to the app." : { - - }, - "Indefinite activations push the basal schedule to the pump. Timed activations (up to 24 h) keep the pump untouched — the algorithm compensates via temp basals, same as overrides — and auto-revert when they expire." : { - "comment" : "A footer text explaining the purpose of the \"Duration\" section in the", - "isCommentAutoGenerated" : true - }, - "Indefinite: becomes your new baseline. The basal schedule is written to the pump and everything else is applied immediately." : { - }, "Indicates glucose smoothing is enabled." : { "localizations" : { @@ -172442,9 +171366,6 @@ } } } - }, - "Initiating B30 can be done by Apple Shortcuts\nhttps://tinyurl.com/aimiB30shortcut\n" : { - }, "Initiating…" : { "comment" : "A label displayed when the pump is waiting to start bolusing.", @@ -172713,12 +171634,9 @@ } } } - }, - "Instead of a single fixed ratio, the delivery ratio can rise linearly with glucose: starting cautiously at SMB DeliveryRatio BG Minimum when glucose is at your BG target, and reaching the more ambitious SMB DeliveryRatio BG Maximum once glucose is this many points above target." : { - }, "Insulin" : { - "comment" : "Label for Insulin section\nTitle for insulin-related statistics", + "comment" : "Title for insulin-related statistics", "localizations" : { "bg" : { "stringUnit" : { @@ -172977,15 +171895,6 @@ } } } - }, - "Insulin Concentration" : { - "comment" : "Insulin Concentration" - }, - "Insulin Concentration Selected" : { - - }, - "Insulin Concentration:" : { - }, "Insulin curve" : { "comment" : "Insulin curve", @@ -173124,12 +172033,6 @@ } } } - }, - "Insulin Curve" : { - - }, - "Insulin Curve Parameters" : { - }, "Insulin Limit Reached" : { "localizations" : { @@ -174044,7 +172947,6 @@ } }, "Insulin Pump" : { - "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -174173,9 +173075,6 @@ } } } - }, - "Insulin Pump & Concentration" : { - }, "Insulin recommended" : { "extractionState" : "manual", @@ -174582,7 +173481,6 @@ } }, "Insulin Sensitivities" : { - "comment" : "Navigation-link title for the Insulin Sensitivity (ISF) editor inside the draft profile editor", "localizations" : { "bg" : { "stringUnit" : { @@ -175775,18 +174673,6 @@ } } }, - "Invalid bolus amount." : { - "comment" : "Watch command ack" - }, - "Invalid carbs amount." : { - "comment" : "Watch command ack" - }, - "Invalid CGM reading (HIGH)." : { - - }, - "Invalid command." : { - "comment" : "Watch command ack" - }, "Invalid Glucose (%@)." : { "localizations" : { "bg" : { @@ -176994,27 +175880,6 @@ } } } - }, - "IOB / maxIOB" : { - - }, - "IOB / TH" : { - - }, - "IOB Threshold %" : { - "comment" : "Row label on Add Override form — IOB Threshold %\nRow label on Edit Override form — IOB Threshold %" - }, - "IOB Threshold Percent" : { - - }, - "IOB unavailable. Bolus not delivered." : { - "comment" : "Watch command ack" - }, - "IOB value" : { - - }, - "IOB: %@ U" : { - }, "IOBpredBG" : { "localizations" : { @@ -177145,9 +176010,6 @@ } } } - }, - "iobTH %" : { - }, "is running" : { "localizations" : { @@ -177670,30 +176532,6 @@ } } }, - "ISF factors" : { - "comment" : "Label for ISF factors section" - }, - "ISF value" : { - - }, - "ISF Weight for Higher BGs" : { - "comment" : "ISF High BG Weight\nRow label on Add Override form — ISF Weight for Higher BGs\nRow label on Edit Override form — ISF Weight for Higher BGs" - }, - "ISF Weight for Lower BGs" : { - "comment" : "ISF Low BG Weight\nRow label on Add Override form — ISF Weight for Lower BGs\nRow label on Edit Override form — ISF Weight for Lower BGs" - }, - "ISF Weight for Postprandial BG Rise" : { - "comment" : "Postprandial ISF weight\nRow label on Add Override form — ISF Weight for Postprandial BG Rise\nRow label on Edit Override form — ISF Weight for Postprandial BG Rise" - }, - "ISF Weight While BG Accelerates" : { - "comment" : "BG Acceleration ISF Weight\nRow label on Add Override form — ISF Weight While BG Accelerates\nRow label on Edit Override form — ISF Weight While BG Accelerates" - }, - "ISF Weight While BG Decelerates" : { - "comment" : "Row label on Add Override form — ISF Weight While BG Decelerates\nRow label on Edit Override form — ISF Weight While BG Decelerates" - }, - "ISF Weight While BG Decelerates." : { - "comment" : "BG Brake ISF Weight" - }, "ISF/CR" : { "comment" : "Option for both ISF and CR", "localizations" : { @@ -177954,12 +176792,8 @@ } } } - }, - "It can also adapt SMB delivery settings." : { - }, "It can handle small gaps in data and ignores sensor error values. It needs 4 readings in a row, each arriving within 12 minutes of the one before it, to work properly. Only CGM readings are smoothed—manual entries are not changed." : { - "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -178218,9 +177052,6 @@ } } } - }, - "It is one factor used in combination with the Fat and Protein Delay, Spread Interval, and Fat and Protein Factor to create the FPU entries." : { - }, "It looks at the last 8-24 hours of data, excluding meal-related changes, and adjusts insulin settings like basal rates and targets when needed to match your sensitivity or resistance to insulin." : { "localizations" : { @@ -178611,6 +177442,9 @@ } } } + }, + "Its Widget Configuration lets you set the size of the glucose reading." : { + }, "Just be aware: if you force quit the app before finishing onboarding, your progress will not be saved." : { "localizations" : { @@ -178742,10 +177576,6 @@ } } }, - "Keep at 100 % if you want the new profile to share the active profile's therapy values and only differ in algorithm toggles or BG targets." : { - "comment" : "A paragraph of text under the \"Adjust current Therapy Settings\" row in the \"Add Profile\" form, explaining that keeping the percentage at 100% means the new profile will share the active profile's", - "isCommentAutoGenerated" : true - }, "Keep these turned ON in your phone’s settings to ensure you receive Trio Notifications, Critical Alerts, and Time Sensitive Notifications." : { "localizations" : { "bg" : { @@ -179006,29 +177836,8 @@ } } }, - "Keto Protection" : { - "comment" : "Navigation-link title for the Keto Protection extension section in the draft profile editor" - }, - "Ketoacidosis protection applies a small safety Temp Basal Rate continuously or under specific conditions (Variable Strategy) to reduce ketoacidosis risk." : { - - }, - "Ketoacidosis protection will apply a small configurable Temp Basal Rate (TBR) instead of a Zero Temp. This is done either always or if certain conditions arise. For the later you need to enable the Variable KetoProtect Strategy." : { - "comment" : "KetoProtect VerboseHint" - }, - "KetoProtect" : { - - }, - "KetoProtect Absolute" : { - - }, - "KetoProtect Basal Absolute" : { - - }, - "KetoProtect Basal Percent" : { - - }, - "KetoProtect Settings" : { - + "Large" : { + "comment" : "Live Activity glucose reading font size" }, "Largest basal rate allowed." : { "localizations" : { @@ -179290,6 +178099,24 @@ } } }, + "last 24 hours" : { + "comment" : "Stats banner scope" + }, + "Last 24 Hours" : { + "comment" : "Home stats panel range option" + }, + "last 3 months" : { + "comment" : "Stats banner scope" + }, + "Last 3 Months" : { + "comment" : "Home stats panel range option" + }, + "last 7 days" : { + "comment" : "Stats banner scope" + }, + "Last 7 Days" : { + "comment" : "Home stats panel range option" + }, "Last Blood Sugar prediction" : { "extractionState" : "manual", "localizations" : { @@ -179426,9 +178253,6 @@ } } } - }, - "Last Delta" : { - }, "last device communication %@" : { "comment" : "Accessibility: device age", @@ -180351,6 +179175,12 @@ } } }, + "last month" : { + "comment" : "Stats banner scope" + }, + "Last Month" : { + "comment" : "Home stats panel range option" + }, "Last run" : { "extractionState" : "manual", "localizations" : { @@ -181920,12 +180750,6 @@ } } } - }, - "Lets a supported Garmin app log carbs and start or stop override and temp target presets. Status and the preset list stay available with this switch off." : { - - }, - "Lets the Garmin app deliver a bolus, alone or together with carbs, without any confirmation on this phone." : { - }, "Libre 2 Direct" : { "comment" : "Libre 2 Direct", @@ -183925,7 +182749,6 @@ } }, "Limits the size of a single Super Micro Bolus (SMB) dose." : { - "comment" : "Mini-hint for Max SMB Basal Minutes setting", "localizations" : { "bg" : { "stringUnit" : { @@ -184056,7 +182879,6 @@ } }, "Limits the size of a single Unannounced Meal (UAM) SMB dose." : { - "comment" : "Mini-hint for Max UAM Basal Minutes setting", "localizations" : { "bg" : { "stringUnit" : { @@ -184577,7 +183399,6 @@ } }, "List State" : { - "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -185633,6 +184454,9 @@ } } } + }, + "Lock Screen widget preview" : { + }, "Lock Screen Widget Style" : { "localizations" : { @@ -187073,9 +185897,6 @@ } } } - }, - "Log Glucose" : { - }, "Log Meal" : { "localizations" : { @@ -187338,7 +186159,6 @@ } }, "Logarithmic" : { - "comment" : "Dynamic ISF picker option — logarithmic formula", "localizations" : { "bg" : { "stringUnit" : { @@ -187729,7 +186549,7 @@ } }, "Logging Failed: Max Carbs = %lld g" : { - "comment" : "Max carbs exceeded error", + "comment" : "An error message that the Add Carbs shortcut failed because the entered carbs exceeded the Max Carbs setting", "localizations" : { "bg" : { "stringUnit" : { @@ -187860,7 +186680,7 @@ } }, "Logging Failed: Max Fat = %lld g" : { - "comment" : "Max fat exceeded error", + "comment" : "An error message that the Add Carbs shortcut failed because the entered fat exceeded the Max Fat setting", "localizations" : { "bg" : { "stringUnit" : { @@ -187991,7 +186811,7 @@ } }, "Logging Failed: Max Protein = %lld g" : { - "comment" : "Max protein exceeded error", + "comment" : "An error message that the Add Carbs shortcut failed because the entered protein exceeded the Max Protein setting", "localizations" : { "bg" : { "stringUnit" : { @@ -189048,13 +187868,9 @@ } } } - }, - "LoopGraph" : { - }, "looping" : { "comment" : "Looping in progress", - "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -192355,13 +191171,6 @@ } } } - }, - "Lower end of the linearly increasing SMB delivery ratio. This value is applied at your BG target — i.e. the share of insulinReq delivered as SMB when glucose is right at target." : { - "comment" : "A description of what the \"Lower end of the linearly increasing SMB delivery ratio\" setting does.", - "isCommentAutoGenerated" : true - }, - "Lower ISF Range Weight" : { - }, "Lower limit of the Autosens Ratio." : { "localizations" : { @@ -192494,7 +191303,6 @@ } }, "Lower limit of the Sensitivity Ratio." : { - "comment" : "Mini-hint for Autosens Min setting", "localizations" : { "bg" : { "stringUnit" : { @@ -192625,7 +191433,6 @@ } }, "Lower target glucose when Autosens Ratio is greater than 1." : { - "comment" : "Mini-hint for Resistance Lowers Target setting", "localizations" : { "bg" : { "stringUnit" : { @@ -192755,9 +191562,6 @@ } } }, - "Lowest ISF factor allowed." : { - "comment" : "autoISF Min miniHint" - }, "m" : { "comment" : "Abbreviation for Minutes\nabbreviation for minutes", "localizations" : { @@ -193678,9 +192482,6 @@ } } } - }, - "Managing" : { - }, "Manual" : { "comment" : "Manual Bolus", @@ -193951,12 +192752,11 @@ } } }, - "Manual Bolus" : { - + "Manual basal %@" : { + "comment" : "Accessibility: manual temp basal rate the user set on the pump" }, "manual entry" : { "comment" : "Accessibility: manually entered glucose", - "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -194216,10 +193016,6 @@ } } }, - "Manual Temp Basal" : { - "comment" : "A button label that, when tapped, presents a view allowing the user to manually set their basal rate.", - "isCommentAutoGenerated" : true - }, "Manual Temp Basal Active" : { "localizations" : { "bg" : { @@ -200138,9 +198934,6 @@ } } } - }, - "Maximum Duration" : { - }, "Maximum Duration In Hours" : { "comment" : "Duration", @@ -200670,12 +199463,6 @@ } } } - }, - "maxIOB" : { - - }, - "maxIOB and iobTH% as of current settings!" : { - }, "Meal" : { "comment" : "Debug option view Meal", @@ -202387,6 +201174,9 @@ } } }, + "Medium" : { + "comment" : "Live Activity glucose reading font size" + }, "Medtronic and Dana Users Only" : { "localizations" : { "bg" : { @@ -202655,6 +201445,10 @@ } } }, + "Meow" : { + "comment" : "Name of a sound file in the alarm sound catalog.", + "isCommentAutoGenerated" : true + }, "Metadata" : { "localizations" : { "bg" : { @@ -203582,7 +202376,6 @@ } }, "Migration of JSON-based OpenAPS Determination Data failed: %@" : { - "comment" : "Migration error on first launch — the interpolated value is the underlying system error description", "localizations" : { "bg" : { "stringUnit" : { @@ -203712,6 +202505,10 @@ } } }, + "Mild" : { + "comment" : "Display name for a mild alarm urgency level.", + "isCommentAutoGenerated" : true + }, "milligrams" : { "comment" : "Accessibility: spoken unit", "localizations" : { @@ -203974,9 +202771,6 @@ } } }, - "milligrams per deciliter per 10 grams" : { - "comment" : "Accessibility: spoken unit" - }, "milligrams per deciliter per unit" : { "comment" : "Accessibility: spoken unit", "localizations" : { @@ -204370,9 +203164,6 @@ } } }, - "millimoles per liter per 10 grams" : { - "comment" : "Accessibility: spoken unit" - }, "millimoles per liter per unit" : { "comment" : "Accessibility: spoken unit", "localizations" : { @@ -205824,11 +204615,7 @@ } } }, - "Minimum manual bolus to start a B30 adaption." : { - "comment" : "B30 Start Bolus miniHint" - }, "Minimum minutes since the last SMB or manual bolus to allow an automated SMB." : { - "comment" : "Mini-hint for SMB Interval setting", "localizations" : { "bg" : { "stringUnit" : { @@ -206225,12 +205012,6 @@ } } } - }, - "Minimum Start Bolus Size" : { - "comment" : "B30 Start Bolus" - }, - "Minimum Threshold Setting is the hard floor for the SMB cutoff. Below this glucose value Trio will suspend insulin delivery and block SMBs. It is designed to protect against hypoglycemia, particularly during sleep or other vulnerable times." : { - }, "Minimum Threshold Setting is, by default, determined by your set Glucose Target. This threshold automatically suspends insulin delivery if your glucose levels are forecasted to fall below this value. It’s designed to protect against hypoglycemia, particularly during sleep or other vulnerable times." : { "localizations" : { @@ -207035,9 +205816,6 @@ } } }, - "Missing profile" : { - "comment" : "Schedule list row shown when the schedule's target profile has been deleted" - }, "Missing required inputs; cannot determine basal." : { "localizations" : { "bg" : { @@ -207963,9 +206741,6 @@ } } } - }, - "Modified" : { - }, "Monochrome" : { "localizations" : { @@ -208234,13 +207009,6 @@ } } }, - "Monthly on %@" : { - "comment" : "Schedule repeat summary for custom monthly days — interpolated list is comma-separated day numbers" - }, - "Monthly: fires on the chosen day-of-month." : { - "comment" : "A description of the \"Monthly\" repeat option for schedules.", - "isCommentAutoGenerated" : true - }, "Mood Synth" : { "comment" : "Display name for the \"Mood Synth\" alarm sound.", "localizations" : { @@ -208631,6 +207399,9 @@ } } } + }, + "More information about Glucose Reading Font" : { + }, "More options" : { "extractionState" : "manual", @@ -208899,10 +207670,6 @@ } } }, - "Multiplier cap on how high the autoISF ratio can be and therefore how low it can adjust ISF." : { - "comment" : "A verbose hint explaining the purpose of the \"autoISF Max\" setting.", - "isCommentAutoGenerated" : true - }, "Mute Trio Alarms" : { "localizations" : { "bg" : { @@ -209164,13 +207931,6 @@ } } }, - "Name for this Temp Target?" : { - - }, - "Name is required" : { - "comment" : "Error message displayed when a name for the temporary target is not provided.", - "isCommentAutoGenerated" : true - }, "Name Of Dish" : { "localizations" : { "bg" : { @@ -209300,9 +208060,6 @@ } } } - }, - "Name shown in history" : { - }, "Native G5 app" : { "comment" : "Native G5 app", @@ -210094,10 +208851,6 @@ } } }, - "New Schedule" : { - "comment" : "The title of the view allowing users to create new schedules.", - "isCommentAutoGenerated" : true - }, "New Sensor Detected" : { "comment" : "New Sensor Detected", "extractionState" : "manual", @@ -210365,9 +209118,6 @@ } } } - }, - "Next Delta" : { - }, "Next field" : { "localizations" : { @@ -211811,9 +210561,6 @@ } } }, - "No Algorithm result" : { - "comment" : "Home status popup title fallback when no determination exists\nHome status popup title when no determination is available" - }, "No biometric identities are enrolled. Please set up Face ID or Touch ID." : { "localizations" : { "bg" : { @@ -212473,12 +211220,7 @@ } } }, - "No Data" : { - "comment" : "A label displayed when there is no data to show.", - "isCommentAutoGenerated" : true - }, "No data." : { - "comment" : "Empty-state text on History Adjustments list\nEmpty-state text on History Glucose list\nEmpty-state text on History Treatments list", "localizations" : { "bg" : { "stringUnit" : { @@ -212608,9 +211350,6 @@ } } }, - "No days selected" : { - "comment" : "Schedule repeat summary when the monthly day set is empty\nSchedule repeat summary when the weekday set is empty (error state)" - }, "No Error" : { "extractionState" : "manual", "localizations" : { @@ -214583,12 +213322,6 @@ } } }, - "No profiles — create one first." : { - - }, - "No pump manager available." : { - "comment" : "Pump-sync failure shown when activating a profile but no pump is paired" - }, "No recent determination." : { "localizations" : { "bg" : { @@ -215116,9 +213849,6 @@ } } } - }, - "No schedules yet" : { - }, "No Sensor Detected" : { "comment" : "No Sensor Detected", @@ -216570,7 +215300,7 @@ } }, "None" : { - "comment" : "No CGM selected\nOption for no selection\nSummary shown next to the Basal Rates row in the draft editor when the schedule is empty\nSummary shown next to the Carb Ratios row in the draft editor when the schedule is empty\nSummary shown next to the Glucose Targets row in the draft editor when the schedule is empty\nSummary shown next to the Insulin Sensitivities row in the draft editor when the schedule is empty", + "comment" : "No CGM selected\nOption for no selection", "localizations" : { "bg" : { "stringUnit" : { @@ -217387,10 +216117,6 @@ } } }, - "Normally, CSF is calculated using the current dynamic ISF value, which can change frequently based on algorithms like Dynamic ISF, autoISF, or Autosens. This can cause CSF to fluctuate as your ISF adjusts." : { - "comment" : "A hint explaining that the \"Low Target Lowers Sensitivity\" setting affects how the algorithm calculates insulin doses.", - "isCommentAutoGenerated" : true - }, "Not allowed" : { "localizations" : { "bg" : { @@ -218173,6 +216899,9 @@ } } } + }, + "Not enough room" : { + }, "not looping" : { "comment" : "Accessibility: loop status", @@ -218970,9 +217699,6 @@ } } } - }, - "Note to Tai users:" : { - }, "Note: A CGM is considered noisy when it provides inconsistent readings." : { "localizations" : { @@ -219623,12 +218349,6 @@ } } } - }, - "Note: For practical reasons the profile is entered in units of mg/dL per 10g of carbs consumed (or mmol/L per 10g). This makes values easier to measure and document — eat 10g of fast carbs and observe the BG rise to determine your CSF. The conversion to per-gram is handled internally." : { - - }, - "Note: If \"Low Target Lowers Sensitivity\" is active, using an \"Eating Soon\" temporary target will be more aggressive than before. The lower target reduces ISF, which with a now reduced CR also increases meal insulin dosing. Review your targets and sensitivity settings accordingly." : { - }, "Note: If this is enabled and the criteria are met, SMBs could be utilized regardless of other SMB settings being enabled or not." : { "localizations" : { @@ -220669,9 +219389,6 @@ } } } - }, - "Note: the threshold is also clamped to at least 60 mg/dL and at most 120 mg/dL." : { - }, "Note: These values are not used to calculate insulin dosing." : { "localizations" : { @@ -223968,9 +222685,6 @@ } } }, - "Odd Target disables SMB for autoISF" : { - "comment" : "Odd Target disables SMB" - }, "of" : { "comment" : "Bolus progress view", "extractionState" : "manual", @@ -225151,9 +223865,6 @@ } } }, - "Once on %@" : { - "comment" : "Schedule repeat summary for a one-off fire — interpolated value is a localized date/time string" - }, "Once the total insulin required is calculated, this safety limit specifies what percentage of the insulin required can be delivered as an SMB." : { "localizations" : { "bg" : { @@ -225283,9 +223994,6 @@ } } } - }, - "Once: fires a single time at the picked date and time. The schedule auto-deletes after firing." : { - }, "One last thing, before you begin..." : { "localizations" : { @@ -225416,9 +224124,6 @@ } } } - }, - "Only active when SMB DeliveryRatio BG Range is non-zero; otherwise the fixed SMB DeliveryRatio applies." : { - }, "Only adjust these settings if you’re an advanced or returning user who knows what they’re doing." : { "localizations" : { @@ -225687,15 +224392,6 @@ } } } - }, - "Only boluses and SMBs at or above the selected amount will show their numeric label on the main chart. The bar or marker itself is always drawn." : { - - }, - "Only enable this if you keep your watch locked and under your control." : { - - }, - "Only indefinite (and until-next) activations write a new basal schedule into the pump's memory. Schedules will NOT write to the pump automatically — confirm via Save to pump in the notification or the in-app dialog. Until you confirm, the previous saved schedule stays on the pump." : { - }, "Only shown for the Glucose Bobble style, which replaces Display Values, Ring Settings, and Font Settings:" : { "localizations" : { @@ -225826,9 +224522,6 @@ } } } - }, - "Only the name is editable here. The active Profile's therapy values (Basal Rate, ISF, CR, BG Targets) and Algorithm Settings are edited directly in Therapy and Algorithm Settings while this Profile is active." : { - }, "Oops, there was an issue!" : { "localizations" : { @@ -226220,6 +224913,10 @@ } } }, + "Open Can" : { + "comment" : "Name of a sound file.", + "isCommentAutoGenerated" : true + }, "Open failed" : { "localizations" : { "bg" : { @@ -227002,8 +225699,9 @@ } } }, - "OpenAPS documentation" : { - + "Open Wrapper" : { + "comment" : "Name of a sound file.", + "isCommentAutoGenerated" : true }, "OpenAPS main settings" : { "comment" : "Headers for settings -----------------------", @@ -227142,9 +225840,6 @@ } } } - }, - "OpenAPS on IOB and Activity calculations" : { - }, "OpenAPS other settings" : { "extractionState" : "manual", @@ -228214,7 +226909,6 @@ }, "Opens adjustments" : { "comment" : "Accessibility hint", - "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -228867,15 +227561,6 @@ } } } - }, - "Opens the Quick-Pick Treatments picker with your recent bolus and carb amounts, so a repeat treatment takes one slide to confirm." : { - - }, - "Opens the setup for that device — or, if none is set up yet, the picker to add one." : { - - }, - "Opens the treatment view to add carbs, insulin or a manual glucose reading." : { - }, "Opt out" : { "localizations" : { @@ -229006,13 +227691,6 @@ } } } - }, - "Optional name for the schedule entry" : { - "comment" : "Placeholder text for the \"Schedule name\" parameter in the Schedule Profile intent.", - "isCommentAutoGenerated" : true - }, - "Optional profile used to derive Carb Ratios from ISF." : { - }, "Optionally sync with Nightscout and enter essentials." : { "comment" : "Description of the Prepare Trio onboarding chapter.", @@ -229274,9 +227952,6 @@ } } } - }, - "Original rates" : { - }, "Other" : { "comment" : "Other CGM setting", @@ -229676,6 +228351,10 @@ } } }, + "Other Sounds" : { + "comment" : "A section header for the other sounds available in the tone picker.", + "isCommentAutoGenerated" : true + }, "Other Sources" : { "comment" : "Device picker section for data relays", "localizations" : { @@ -230068,7 +228747,6 @@ } }, "Override" : { - "comment" : "Fallback name on History Adjustments list when an override run has no name", "localizations" : { "bg" : { "stringUnit" : { @@ -230458,9 +229136,6 @@ } } }, - "Override %@" : { - "comment" : "Default name for a new override when the user left the name field blank — interpolated value is a formatted date/time" - }, "Override canceled" : { "localizations" : { "bg" : { @@ -230851,9 +229526,6 @@ } } }, - "Override preset not found." : { - "comment" : "Watch command ack" - }, "Override Presets" : { "localizations" : { "bg" : { @@ -231526,12 +230198,6 @@ } } }, - "Override started." : { - "comment" : "Watch command ack" - }, - "Override stopped." : { - "comment" : "Watch command ack" - }, "Override Target" : { "localizations" : { "bg" : { @@ -233652,7 +232318,6 @@ } }, "Pans the chart through time. Flick and let go to keep it gliding." : { - "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -235515,7 +234180,6 @@ } }, "Percentage of calculated insulin required that is given as SMB." : { - "comment" : "Mini-hint for SMB Delivery Ratio (AlgorithmAdvanced) setting", "localizations" : { "bg" : { "stringUnit" : { @@ -236836,9 +235500,6 @@ } } } - }, - "Pick any combination. Example: M/W/F fires three times a week." : { - }, "Pick Treatment" : { "localizations" : { @@ -237229,9 +235890,6 @@ } } } - }, - "Pinch to zoom the visible time window continuously, between 1 and 24 hours." : { - }, "Play preview" : { "comment" : "Accessibility: play tone preview", @@ -239222,14 +237880,15 @@ } } }, - "Post Meal ISF Weight" : { - - }, - "pp" : { - "comment" : "Label for pp" - }, - "pp-ISF" : { - + "Position %lld of %lld" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Position %1$lld of %2$lld" + } + } + } }, "Prediction Type" : { "localizations" : { @@ -240150,7 +238809,6 @@ } }, "Presets cannot be saved with a future date!" : { - "comment" : "Validation error when saving a temp target preset with a future start date", "localizations" : { "bg" : { "stringUnit" : { @@ -240281,7 +238939,6 @@ } }, "Press and Hold" : { - "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -240410,6 +239067,9 @@ } } } + }, + "Preview" : { + }, "Preview Contact Image" : { "localizations" : { @@ -240808,9 +239468,6 @@ } } } - }, - "Previous Release Notes" : { - }, "Previous Versions" : { "extractionState" : "stale", @@ -242122,34 +240779,6 @@ } } }, - "Profile '%@' activated for %@" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Profile '%1$@' activated for %2$@" - } - } - } - }, - "Profile '%@' scheduled for %@ for %@" : { - "comment" : "Confirmation dialog text when a profile is successfully scheduled. The first argument is the name of the profile. The second argument is the formatted time string. The third argument is the formatted duration string.", - "isCommentAutoGenerated" : true, - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Profile '%1$@' scheduled for %2$@ for %3$@" - } - } - } - }, - "Profile \"%@\" activated" : { - "comment" : "Title of schedule-fire notification (timed Flow A) — interpolated value is the profile name" - }, - "Profile \"%@\" expired" : { - "comment" : "Title of profile-expired notification — interpolated value is the expiring profile's name" - }, "Profile at 100 %" : { "localizations" : { "bg" : { @@ -242280,16 +240909,6 @@ } } }, - "Profile missing" : { - "comment" : "Upcoming-schedule row shown when the target profile has been deleted" - }, - "Profile name" : { - "comment" : "A label for the text field that lets the user enter a name for their profile.", - "isCommentAutoGenerated" : true - }, - "Profile not found." : { - "comment" : "Error returned when activating a profile whose Core Data record can't be loaded" - }, "Profile Override" : { "comment" : "Service Section", "extractionState" : "manual", @@ -242427,12 +241046,6 @@ } } } - }, - "Profile to activate" : { - - }, - "Profile to schedule" : { - }, "Profiles" : { "extractionState" : "manual", @@ -243222,9 +241835,6 @@ } } } - }, - "Pull Down" : { - }, "Pull down to force loop" : { "localizations" : { @@ -243355,9 +241965,6 @@ } } } - }, - "Pull the home screen down past the hint to force a loop." : { - }, "Pump" : { "extractionState" : "manual", @@ -243495,9 +242102,6 @@ } } } - }, - "Pump & Concentration" : { - }, "Pump battery" : { "localizations" : { @@ -244155,9 +242759,6 @@ } } } - }, - "Pump confirmation required — activate from the app" : { - }, "Pump Error" : { "localizations" : { @@ -244419,9 +243020,6 @@ } } }, - "Pump Event" : { - "comment" : "Treatment row label — fallback when pump event type is missing" - }, "Pump Fault" : { "localizations" : { "bg" : { @@ -245479,7 +244077,6 @@ } }, "Pump rewind initiates a reset in Sensitivity Ratio." : { - "comment" : "Mini-hint for Rewind Resets Autosens setting", "localizations" : { "bg" : { "stringUnit" : { @@ -245877,9 +244474,6 @@ } } }, - "Pump State" : { - "comment" : "Treatment history type label — pump state change" - }, "Pump State Error" : { "localizations" : { "bg" : { @@ -246009,9 +244603,6 @@ } } } - }, - "Pump supported increment:" : { - }, "Pump suspended" : { "localizations" : { @@ -247183,9 +245774,6 @@ } } }, - "Quantity of the small safety TBR in % of Profile BR, which is given to avoid ketoacidosis." : { - "comment" : "Safety TBR miniHint" - }, "Quantity Protein" : { "localizations" : { "bg" : { @@ -247841,7 +246429,6 @@ } }, "Raise target glucose when Autosens Ratio is less than 1." : { - "comment" : "Mini-hint for Sensitivity Raises Target setting", "localizations" : { "bg" : { "stringUnit" : { @@ -247971,9 +246558,6 @@ } } }, - "Raises the glucose floor below which SMBs are blocked. 0.5 keeps default behaviour, 1.0 blocks SMBs until above target." : { - "comment" : "Mini-hint for SMB Threshold Ratio setting" - }, "Random variation added to each reading to simulate real-world sensor noise." : { "localizations" : { "bg" : { @@ -248499,9 +247083,6 @@ } } } - }, - "rate" : { - }, "Rate" : { "comment" : "Rate basal profileLabel text for temporary basal rate summary", @@ -248634,9 +247215,6 @@ } } }, - "Rate at which ISF is reduced per hour assuming BG level remains at double target for that time." : { - "comment" : "DuraISF Weight miniHint" - }, "rate-end" : { "localizations" : { "bg" : { @@ -248896,9 +247474,6 @@ } } } - }, - "Rates Adjusted" : { - }, "Ratio" : { "localizations" : { @@ -249029,12 +247604,6 @@ } } } - }, - "ratioFloor = TargetGlucose − (1 − ratio) × (TargetGlucose − 40)" : { - - }, - "ratioFloor = TargetGlucose − (1 − SMB Threshold Ratio) × (TargetGlucose − 40)" : { - }, "raw: %lf" : { "localizations" : { @@ -249296,9 +247865,6 @@ } } } - }, - "Read up on it at:" : { - }, "Readings" : { "comment" : "CGM readings in statView", @@ -250354,9 +248920,6 @@ } } } - }, - "Recenters the chart on now, from wherever you have panned to. The zoom level stays as it is." : { - }, "Recommendation" : { "comment" : "Bolus screen", @@ -252842,6 +251405,9 @@ } } } + }, + "Remove %@" : { + }, "Remove All" : { "localizations" : { @@ -252973,14 +251539,6 @@ } } }, - "Remove All Devices" : { - "comment" : "A button label that removes all connected Garmin devices.", - "isCommentAutoGenerated" : true - }, - "Remove label — make a Default" : { - "comment" : "A button that lets the user convert a labeled profile into a default one.", - "isCommentAutoGenerated" : true - }, "Remove Last" : { "localizations" : { "bg" : { @@ -253241,11 +251799,9 @@ } } } - }, - "Remove the label from this profile?" : { - }, "Remove widget" : { + "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -253376,6 +251932,7 @@ } }, "Remove Widget" : { + "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -253505,18 +252062,6 @@ } } }, - "Rename active Profile" : { - - }, - "Repeat" : { - - }, - "Repeat options" : { - - }, - "Replace" : { - "comment" : "View/Header when pod expired" - }, "Replace CGM Sensor" : { "comment" : "Title of an alert that instructs the user to replace a CGM sensor.", "localizations" : { @@ -253647,9 +252192,6 @@ } } } - }, - "Replace circles and triangles with vertical bars for bolus, SMB and carbs." : { - }, "Replace pod" : { "comment" : "View/Header when pod expired", @@ -253912,15 +252454,6 @@ } } }, - "req." : { - "comment" : "Label for req." - }, - "Request ID was already used." : { - "comment" : "Watch command ack" - }, - "Request is already in progress." : { - "comment" : "Watch command ack" - }, "Require Adjustments Confirmation" : { "localizations" : { "bg" : { @@ -254053,6 +252586,9 @@ }, "Requires Allow Uploading to Nightscout. Supported for Dexcom G6 and G7." : { + }, + "Reserved" : { + "comment" : "Live Activity widget icon label for the slot taken by a double-width item" }, "Reservoir" : { "localizations" : { @@ -255226,7 +253762,6 @@ } }, "Rest a finger on the chart to inspect a single reading with its IOB and COB." : { - "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -255355,9 +253890,6 @@ } } } - }, - "Rest your finger on the chart to inspect a single reading with its IOB and COB." : { - }, "Restart Live Activity" : { "localizations" : { @@ -255618,9 +254150,6 @@ } } } - }, - "Restores the previous indefinite profile — the one whose basal schedule is already on the pump. No pump change needed." : { - }, "Resume" : { "extractionState" : "manual", @@ -256157,9 +254686,6 @@ } } } - }, - "Resume Smoothing" : { - }, "Resuming insulin delivery..." : { "comment" : "Text for suspend resume button when insulin delivery is resuming", @@ -256697,24 +255223,6 @@ } } } - }, - "Revert" : { - - }, - "Revert to previous profile?" : { - - }, - "Reverted to \"%@\"." : { - "comment" : "Body of profile-expired notification — interpolated value is the anchor profile name we reverted to" - }, - "Reverting" : { - - }, - "Reverts to" : { - - }, - "Review the pre-filled therapy and algorithm values on the next screen, then save." : { - }, "Rewind Resets Autosens" : { "comment" : "Rewind Resets Autosens", @@ -258156,12 +256664,6 @@ } } } - }, - "Runs for" : { - - }, - "Runs for the chosen duration, then reverts. The pump's saved basal is not changed." : { - }, "Safety" : { "comment" : "Extra Safety Features when using dyn ISF", @@ -258431,9 +256933,6 @@ } } }, - "Safety TBR in %" : { - "comment" : "Safety TBR" - }, "Save" : { "comment" : "Save preset namebutton title for saving low reservoir reminder", "localizations" : { @@ -259367,18 +257866,6 @@ } } } - }, - "Save basal of Profile \"%@\" to pump?" : { - "comment" : "Title of schedule-fire notification for an indefinite activation that needs pump sync — interpolated value is the profile name" - }, - "Save basal to pump?" : { - - }, - "Save Changes" : { - - }, - "Save failed" : { - }, "Save on Pump" : { "extractionState" : "manual", @@ -259647,17 +258134,6 @@ } } }, - "Save Profile" : { - "comment" : "A button label that saves the user's current profile.", - "isCommentAutoGenerated" : true - }, - "Save Schedule" : { - "comment" : "A button label that saves a new medication schedule.", - "isCommentAutoGenerated" : true - }, - "Save to pump" : { - "comment" : "Schedule activation notification: save-basal-to-pump action" - }, "Saved Food" : { "comment" : "Saved Food Presets", "localizations" : { @@ -260188,8 +258664,11 @@ } } }, + "Saw Wave" : { + "comment" : "Name of a sound file.", + "isCommentAutoGenerated" : true + }, "Scales down your basal rate to 50% at this value." : { - "comment" : "Mini-hint for Half Basal Exercise Target setting", "localizations" : { "bg" : { "stringUnit" : { @@ -260585,40 +259064,6 @@ } } } - }, - "Schedule ${preset} at ${startTime}" : { - - }, - "Schedule a custom Temporary Target for a date+time within the next 24 hours" : { - - }, - "Schedule a profile to activate once at a specific date/time for a chosen duration. Indefinite scheduling is not available via Shortcuts." : { - "comment" : "Description of the `ScheduleProfileOnceIntent`.", - "isCommentAutoGenerated" : true - }, - "Schedule a Temporary Target preset for a date+time within the next 24 hours" : { - - }, - "Schedule name" : { - - }, - "Schedule Profile" : { - - }, - "Schedule Profile ${profile} at ${fireAt}" : { - - }, - "Schedule Temporary Target ${name} at ${startTime}" : { - - }, - "Schedule time must be in the future" : { - - }, - "Schedule TT" : { - - }, - "Schedule TT Preset" : { - }, "Schedule when SMBs are Off" : { "extractionState" : "manual", @@ -260887,9 +259332,6 @@ } } }, - "Scheduled profile" : { - "comment" : "Fallback profile name used in the pending-activation pump-save alert when the scheduled profile can't be resolved by id" - }, "Scheduled Reminder" : { "extractionState" : "manual", "localizations" : { @@ -261156,9 +259598,6 @@ } } } - }, - "Schedules" : { - }, "Schedules were saved successfully!" : { "extractionState" : "manual", @@ -261297,6 +259736,9 @@ } } }, + "Score calculation" : { + "comment" : "Expandable breakdown of the GRI score for the selected period." + }, "SD" : { "comment" : "SD", "localizations" : { @@ -261947,9 +260389,6 @@ } } } - }, - "See compatible Trio apps on Garmin ConnectIQ." : { - }, "See what's new in this version." : { "localizations" : { @@ -262211,6 +260650,10 @@ } } }, + "Select a Voice" : { + "comment" : "A label for a picker that lets the user choose a voice.", + "isCommentAutoGenerated" : true + }, "Select Adjustment" : { "localizations" : { "bg" : { @@ -262342,7 +260785,6 @@ } }, "Select All" : { - "comment" : "Toggle button in History filter popover — selects every treatment type", "localizations" : { "bg" : { "stringUnit" : { @@ -262472,10 +260914,6 @@ } } }, - "Select Concentration" : { - "comment" : "The title of the view.", - "isCommentAutoGenerated" : true - }, "Select font size, weight, and width to match your style:" : { "localizations" : { "bg" : { @@ -262735,9 +261173,6 @@ } } } - }, - "Select profile" : { - }, "Select simple or detailed style. See hint for more details." : { "localizations" : { @@ -267013,7 +265448,6 @@ }, "Set a custom time for peak insulin effect." : { "comment" : "Mini Hint for Insulin Peak Time", - "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -267144,7 +265578,6 @@ } }, "Set a duration!" : { - "comment" : "Validation error on Add Temp Target form when duration is zero", "localizations" : { "bg" : { "stringUnit" : { @@ -267533,9 +265966,6 @@ } } } - }, - "Set it conservatively; from here the ratio climbs linearly toward SMB DeliveryRatio BG Maximum as glucose rises across SMB DeliveryRatio BG Range." : { - }, "Set limits for each type of macro per meal entry." : { "localizations" : { @@ -268203,47 +266633,8 @@ } } }, - "Set the duration for the increased basal rate in B30 mode. Default is 30 minutes." : { - "comment" : "B30 Duration VerboseHint" - }, - "Set the EatingSoon TempTarget glucose level to trigger B30. Should be a low TT like %@ %@. Keep in mind it should be an even TT to allow autoISF SMB's after the duration specified, if the target would still be active. Canceling this TT will imediatly stop B30 adaptions." : { - "comment" : "B30 TT Level VerboseHint", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Set the EatingSoon TempTarget glucose level to trigger B30. Should be a low TT like %1$@ %2$@. Keep in mind it should be an even TT to allow autoISF SMB's after the duration specified, if the target would still be active. Canceling this TT will imediatly stop B30 adaptions." - } - } - } - }, - "Set the maximum BG delta limit for B30 & suppressed SMB to remain active. Default is %@ %@." : { - "comment" : "B30 Upper Delta VerboseHint", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Set the maximum BG delta limit for B30 & suppressed SMB to remain active. Default is %1$@ %2$@." - } - } - } - }, - "Set the maximum BG level for B30 & suppressed SMB to remain active. Default is %@ %@." : { - "comment" : "B30 Upper BG Limit VerboseHint", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Set the maximum BG level for B30 & suppressed SMB to remain active. Default is %1$@ %2$@." - } - } - } - }, - "Set the maximum timeframe to extend FPUs." : { + "Set the size of the glucose reading." : { - }, - "Set the percentage of the current basal rate to apply for safety against ketoacidosis. Recommended between 10% - 20%" : { - "comment" : "Safety TBR VerboseHint" }, "Set the value enableSMB_high_bg will compare against to enable SMB. If BG > than this value, SMBs should enable." : { "comment" : "... When Blood Glucose Is Over (mg/dl):", @@ -268382,9 +266773,6 @@ } } } - }, - "Set to 0 to disable the ramp — the fixed SMB DeliveryRatio is used instead." : { - }, "Set to a number, e.g. 160, which means when temp target is 160 mg/dL and exercise_mode=true, run 50% basal at this level (120 = 75%; 140 = 60%). This can be adjusted, to give you more control over your exercise modes." : { "comment" : "\"Half Basal Exercise Target\"", @@ -268913,9 +267301,6 @@ } } } - }, - "Settings for protective TBR" : { - }, "Settings imported" : { "extractionState" : "manual", @@ -270631,7 +269016,6 @@ }, "Show All" : { "comment" : "Name of the bolus display threshold option that shows all bolus amounts.", - "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -270761,10 +269145,6 @@ } } }, - "Show all labels" : { - "comment" : "Description of a bolus display threshold option to show all insulin labels.", - "isCommentAutoGenerated" : true - }, "Show carbs required as a red icon on the main graph icon." : { "localizations" : { "bg" : { @@ -271024,12 +269404,6 @@ } } } - }, - "Show CGM Status Around Bobble" : { - - }, - "Show COB-IOB Chart" : { - }, "Show Delta" : { "localizations" : { @@ -271424,7 +269798,6 @@ } }, "Show Future" : { - "comment" : "Toggle button in History — show entries with future timestamps\nToggle button on History Carb Entries view — show entries with future timestamps", "localizations" : { "bg" : { "stringUnit" : { @@ -271821,9 +270194,6 @@ } } } - }, - "Show Glucose Peaks" : { - }, "Show live activity" : { "comment" : "Notification option", @@ -272742,18 +271112,8 @@ } } } - }, - "Shows a manually entered insulin dose." : { - - }, - "Shows a small automated insulin dose (super-micro-bolus)." : { - - }, - "Shows an externally administered insulin dose (e.g., a pen shot)." : { - }, "Shows an insulin dose, which can be a small automated dose (super-micro-bolus), a manually entered dose, or one given externally (e.g., a pen shot)." : { - "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -272882,21 +271242,6 @@ } } } - }, - "Shows current basal rate being delivered in insulin units per hour." : { - - }, - "Shows current COB amount in g." : { - - }, - "Shows current final ISF / Sensitivity adjustment Ratio as derived by autoISF algorithm." : { - - }, - "Shows current final ISF / Sensitivity adjustment Ratio as derived by autosens algorithm. Only shown when autoISF is not enabled." : { - - }, - "Shows current IOB in insulin units." : { - }, "Shows current or past TBRs, which can be set by the oref algorithm or manually." : { "localizations" : { @@ -273027,9 +271372,6 @@ } } } - }, - "Shows decreasing Pump Reservoir levels, progressively colorized from green to red." : { - }, "Shows forecast on the Apple Watch" : { "comment" : "Text displayed in a hint when the user enables the feature to show the forecast on the Apple Watch.", @@ -273162,6 +271504,10 @@ } } }, + "Shows Glycemia Risk Index details" : { + "comment" : "An accessibility hint for the \"GRI\" button that triggers the sheet.", + "isCommentAutoGenerated" : true + }, "Shows the COB value calculated by the algorithm at a specific time in the past. These values are snapshots and won’t change if carbs are added or removed after the fact." : { "localizations" : { "bg" : { @@ -273421,15 +271767,8 @@ } } } - }, - "Shows the status popup with the details of the last loop cycle." : { - - }, - "Shows the total dose of insulin for the last 24 hours." : { - }, "Sigmoid" : { - "comment" : "Dynamic ISF picker option — sigmoid formula\nRow label for the Sigmoid (vs logarithmic) Dynamic-ISF formula toggle", "localizations" : { "bg" : { "stringUnit" : { @@ -273560,7 +271899,6 @@ } }, "Sigmoid Adjustment Factor" : { - "comment" : "Row label for the Sigmoid variant of the Dynamic ISF Adjustment Factor", "localizations" : { "bg" : { "stringUnit" : { @@ -274601,11 +272939,11 @@ } } }, - "Simulator only - for testing UI workflow" : { - "comment" : "A note displayed in the simulator that explains that the button below is for testing purposes only.", + "Sine Wave" : { + "comment" : "Name of a sound file in the catalog.", "isCommentAutoGenerated" : true }, - "Simulator Testing" : { + "Sizes follow your iPhone's text size setting, so the reading keeps scaling if you change the text size in iOS Settings." : { }, "Skip Bolus screen after carbs" : { @@ -275138,12 +273476,6 @@ } } }, - "Slide to activate for %@" : { - "comment" : "Slide-to-confirm button label — the interpolated value is a formatted duration like '3 hr 15 min'" - }, - "Slide to activate indefinitely" : { - "comment" : "Slide-to-confirm button label when user chose indefinite activation" - }, "Slide to Confirm" : { "comment" : "Slide to confirm label when nothing is picked yet", "localizations" : { @@ -275799,6 +274131,9 @@ } } }, + "Small" : { + "comment" : "Live Activity glucose reading font size" + }, "Smaller iPhone Screens" : { "comment" : "UI/UX option", "extractionState" : "manual", @@ -276336,7 +274671,7 @@ } }, "SMB" : { - "comment" : "Label for SMB\nTreatment history type label — SMB (super-microbolus)\nTreatment row label — SMB", + "comment" : "An Automatic delivered bolus (SMB)", "localizations" : { "bg" : { "stringUnit" : { @@ -276597,7 +274932,7 @@ } }, "SMB Delivery Ratio" : { - "comment" : "Row label on Add Override form — SMB Delivery Ratio\nRow label on Edit Override form — SMB Delivery Ratio\nSMB Delivery Ratio", + "comment" : "SMB Delivery Ratio", "localizations" : { "bg" : { "stringUnit" : { @@ -276727,15 +275062,6 @@ } } }, - "SMB Delivery Ratio BG Range" : { - "comment" : "Row label on Add Override form — SMB Delivery Ratio BG Range\nRow label on Edit Override form — SMB Delivery Ratio BG Range" - }, - "SMB Delivery Ratio Max" : { - "comment" : "Row label on Add Override form — SMB Delivery Ratio Max\nRow label on Edit Override form — SMB Delivery Ratio Max" - }, - "SMB Delivery Ratio Min" : { - "comment" : "Row label on Add Override form — SMB Delivery Ratio Min\nRow label on Edit Override form — SMB Delivery Ratio Min" - }, "SMB Delivery Ratio of calculated insulin required that is given as SMB." : { "localizations" : { "bg" : { @@ -276866,9 +275192,6 @@ } } }, - "SMB Delivery Ratios" : { - "comment" : "Section header on the AutoISF draft editor grouping the fixed and range-based SMB delivery ratio rows" - }, "SMB DeliveryRatio" : { "comment" : "Headline \"SMB DeliveryRatio\"", "extractionState" : "manual", @@ -277007,18 +275330,6 @@ } } }, - "SMB DeliveryRatio (fixed)" : { - "comment" : "SMB DeliveryRatio" - }, - "SMB DeliveryRatio BG Maximum" : { - "comment" : "SMB DeliveryRatio Maximum" - }, - "SMB DeliveryRatio BG Minimum" : { - "comment" : "SMB DeliveryRatio Minimum" - }, - "SMB DeliveryRatio BG Range" : { - "comment" : "SMB DeliveryRatio BG Range" - }, "SMB Interval" : { "comment" : "SMB Interval", "localizations" : { @@ -277150,12 +275461,6 @@ } } }, - "SMB Max Range Extension" : { - - }, - "SMB Max RangeExtension" : { - "comment" : "SMB Max RangeExtension" - }, "SMB Minutes" : { "extractionState" : "manual", "localizations" : { @@ -277553,9 +275858,6 @@ } } }, - "SMB Threshold Ratio" : { - "comment" : "SMB Threshold Ratio" - }, "SMB:" : { "localizations" : { "bg" : { @@ -277685,12 +275987,8 @@ } } } - }, - "SMBs and Non-Zero Temp. Basal Rates are disabled." : { - }, "SMBs Off%@" : { - "comment" : "Override subtitle fragment on Home showing that SMBs are disabled — interpolated value is an optional time-range like ' 08:00-10:00'", "localizations" : { "bg" : { "stringUnit" : { @@ -277820,11 +276118,7 @@ } } }, - "Smooth CGM readings to reduce noise." : { - "comment" : "Mini hint for smooth glucose value" - }, "Smooth CGM readings using exponential smoothing." : { - "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -277955,7 +276249,7 @@ } }, "Smooth Glucose Value" : { - "comment" : "Smooth Glucose Value", + "comment" : "Smoothing of CGM readings", "localizations" : { "bg" : { "stringUnit" : { @@ -278215,18 +276509,6 @@ } } }, - "Smoothing Algorithm" : { - - }, - "Smoothing Algorithm Changed" : { - - }, - "Smoothing has been disabled to clear old smoothed values. Toggle smoothing back on to apply the new algorithm to all glucose readings." : { - - }, - "Smoothing: On" : { - "comment" : "Tag chip in Home status popup — glucose smoothing is enabled\nTag chip in Loop status popup — glucose smoothing is enabled" - }, "Snooze" : { "localizations" : { "bg" : { @@ -279929,9 +278211,6 @@ } } } - }, - "Some basal rates have been rounded to match available pump increments for U%lld insulin. Highlighted entries show adjusted values. Please REVIEW and Save." : { - }, "Some helpful reminders:" : { "localizations" : { @@ -280850,18 +279129,6 @@ } } }, - "Specify a fixed basal rate for safety against ketoacidosis." : { - "comment" : "Absolute TBR VerboseHint" - }, - "Specify an absolute TBR between 0 and 2 U/hr instead of a percentage of the current basal rate." : { - "comment" : "Enable Absolute Safety TBR miniHint" - }, - "Specify the factor to increase the basal rate during B30. Max is 10x." : { - "comment" : "B30 Factor VerboseHint" - }, - "Specify the minimum bolus size required to trigger B30." : { - "comment" : "B30 Start Bolus VerboseHint" - }, "Split" : { "localizations" : { "bg" : { @@ -281253,9 +279520,6 @@ } } }, - "stabilizing" : { - "comment" : "Accessibility: sensor lifecycle tag" - }, "STALE DATA" : { "comment" : "Information displayed when watch app data outdated or stale.", "localizations" : { @@ -281386,12 +279650,6 @@ } } } - }, - "Standard" : { - "comment" : "Temp target sensitivity mode — preset ratios" - }, - "Standard insulin concentration is U100 (100 units per mL), which is the baseline for most insulin pump delivery systems. To show this increment independent of units delivered, it can be shown as volume in μL/hr. The Pump Increment in μL/hr = 10 * Pump Increment in standard units/hr " : { - }, "start" : { "localizations" : { @@ -282454,9 +280712,6 @@ } } } - }, - "Start time" : { - }, "Start Time" : { "localizations" : { @@ -282587,9 +280842,6 @@ } } } - }, - "Start time must be within the next 24 hours (now until +24 h)" : { - }, "Started Override \"%@\" successfully." : { "comment" : "Start override with override name", @@ -284047,6 +282299,9 @@ } } } + }, + "Home Statistics Range" : { + }, "Stay in open loop (no automated dosing) for a bit to help Trio catch up to keep you safe" : { "localizations" : { @@ -284447,7 +282702,6 @@ } }, "Steps through the zoom presets — 6, 12 and 24 hours — keeping the right-hand edge of the window where it is." : { - "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -285618,10 +283872,6 @@ } } }, - "Stop Temp Profile" : { - "comment" : "A button label that stops a temporary insulin profile.", - "isCommentAutoGenerated" : true - }, "Stop temp target" : { "comment" : "A label displayed as a button to stop a temp target.", "localizations" : { @@ -286142,9 +284392,6 @@ } } } - }, - "Stop the temporary profile?" : { - }, "Stop Trio Alarm" : { "comment" : "Title of the intent that stops a Trio critical-alert alarm.", @@ -286539,13 +284786,6 @@ } } }, - "Stopping a temp profile (or waiting for it to expire) returns you to the indefinite profile whose basal is already on the pump. Revert always targets that anchor — chains of temps can never leave the pump in an unfamiliar state." : { - - }, - "Stopping the temp profile failed" : { - "comment" : "An alert title when stopping a temporary profile fails.", - "isCommentAutoGenerated" : true - }, "Store not initialized in %@ from %@." : { "localizations" : { "bg" : { @@ -286682,22 +284922,6 @@ } } }, - "Strategy Definition" : { - - }, - "Strength of acce_ISF contribution with negative acceleration." : { - "comment" : "A verbose hint explaining the meaning of the \"ISF Weight While BG Decelerates\" setting.", - "isCommentAutoGenerated" : true - }, - "Strength of acce_ISF contribution with positive acceleration. Start with 0.02 as initial value." : { - - }, - "Strengthens ISF decrease while glucose accelerates." : { - "comment" : "ISF Weight While BG Accelerates miniHint" - }, - "Strengthens ISF increase while glucose decelarates." : { - "comment" : "ISF Weight While BG Accelerates miniHint" - }, "Style" : { "localizations" : { "bg" : { @@ -287348,10 +285572,6 @@ } } }, - "Suggest CSF from ISF & CR at 1pm" : { - "comment" : "A button label suggesting the user to suggest a default CSF value based on the ISF and CR values at 1 PM.", - "isCommentAutoGenerated" : true - }, "Suggested at" : { "comment" : "Headline in suggested pop up (at: at what time)", "extractionState" : "manual", @@ -288141,7 +286361,6 @@ } }, "Super Micro Bolus (SMB)" : { - "comment" : "Navigation-link title for the SMB section in the draft profile editor", "localizations" : { "bg" : { "stringUnit" : { @@ -288272,7 +286491,6 @@ } }, "Super-Micro-Bolus" : { - "comment" : "Section header on the SMB Settings screen grouping core SMB enable toggles", "localizations" : { "bg" : { "stringUnit" : { @@ -289189,6 +287407,9 @@ } } }, + "Suspended" : { + "comment" : "Basal delivery suspended on the pump" + }, "Suspending insulin delivery..." : { "comment" : "Text for suspend resume button when insulin delivery is suspending", "extractionState" : "manual", @@ -289326,12 +287547,6 @@ } } } - }, - "Swipe left on a schedule row to enable, disable, or delete it. Disabled schedules sink to the bottom and don't fire. The Upcoming section on the Profiles screen shows the next two fires across all enabled schedules." : { - - }, - "Swipe left to edit or delete a profile. Hold, drag and drop to reorder." : { - }, "Swipe left to edit or delete a temporary target preset. Hold, drag and drop to reorder a preset." : { "localizations" : { @@ -289592,9 +287807,6 @@ } } } - }, - "Swipe left to enable, disable, or delete. Enabled schedules are sorted by next fire; disabled ones sink to the bottom." : { - }, "Swipe left to end snooze." : { "localizations" : { @@ -289985,9 +288197,6 @@ } } } - }, - "Swipe Up on Status Popup" : { - }, "Swissalpine" : { "comment" : "Name of the Swissalpine watchface.", @@ -290119,12 +288328,6 @@ } } } - }, - "Switch Autosens on/off" : { - "comment" : "Autosens miniHint" - }, - "Switching insulin concentration changes the basal increments that can be delivered by pump. For diluted insulins smaller increments can be used. For high concentrations the increment will be larger than your regular U100 increment - this compulsory needs adjustment!\n\nBe aware that the pump managers will always show U100 as concentration setting, as they on their own currently only deal with U100. The seeting here in Tai will prevail nonetheless!" : { - }, "Synth" : { "comment" : "Display name for the synth alarm sound.", @@ -290647,44 +288850,6 @@ } } }, - "Tai as the Trio version of autoISF does not include ActivityTracking." : { - - }, - "Tai collects the app's state on crash, device, iOS and general system info, and a stack trace." : { - - }, - "Tai Discord" : { - - }, - "Tai does not collect any health related data, e.g. glucose readings, insulin rates or doses, meal data, setting values, or similar." : { - - }, - "Tai does not track any usage metrics or any other personal data about users other than the used iPhone model and iOS version." : { - - }, - "Tai specific settings like autoISF or Ketoacidosis Protection need to be done manually after Trio onboarding." : { - - }, - "Tai v%@ (%@)" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Tai v%1$@ (%2$@)" - } - } - } - }, - "Tai v%@ (%@) %@" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Tai v%1$@ (%2$@) %3$@" - } - } - } - }, "Take a deep breath — you've got this." : { "localizations" : { "bg" : { @@ -290814,12 +288979,6 @@ } } } - }, - "Tap \"Add Profile\" to start from the currently active profile's settings. First pick a percentage to scale Basal, ISF and CR in one step, then fine-tune any individual setting in the editor that follows." : { - - }, - "Tap + to schedule an automatic profile switch." : { - }, "Tap a bolus amount, a carb amount, or both, then slide to confirm. Face ID or Touch ID is always required before a bolus is enacted." : { "comment" : "A description of how to use the quick-pick treatments feature.", @@ -291082,9 +289241,6 @@ } } }, - "Tap an entry row for details." : { - "comment" : "Text prompting user to tap an entry row for details" - }, "Tap and hold a bar in the chart to reveal more details. Swipe to scroll through time." : { "localizations" : { "bg" : { @@ -291604,15 +289760,6 @@ } } } - }, - "Tap Glucose or Pump Panel" : { - - }, - "Tap Loop Pill" : { - - }, - "Tap Tai Button" : { - }, "Tap the button below to open your Nightscout instance in your iPhone's default browser." : { "localizations" : { @@ -292004,9 +290151,6 @@ } } }, - "Tap to set" : { - "comment" : "Placeholder on activation duration row when the user hasn't chosen hours or minutes yet" - }, "tapped save schedules" : { "extractionState" : "manual", "localizations" : { @@ -292405,7 +290549,6 @@ } }, "Target Behavior" : { - "comment" : "Navigation-link title for the Target Behavior section in the draft profile editor", "localizations" : { "bg" : { "stringUnit" : { @@ -292803,7 +290946,6 @@ } }, "Target glucose is out of range (%@)." : { - "comment" : "Validation error on Add Override form when target glucose is outside allowed range — interpolated value is the unit-dependent range\nValidation error on Edit Override form when target glucose is outside allowed range — interpolated value is the unit-dependent range", "localizations" : { "bg" : { "stringUnit" : { @@ -292932,9 +291074,6 @@ } } } - }, - "Target glucose value in the unit configured in Trio (mg/dL or mmol/L)" : { - }, "Target presets" : { "comment" : "Debug option view Target presets", @@ -293211,15 +291350,6 @@ } } } - }, - "Target value" : { - - }, - "Target value must be positive" : { - - }, - "Target value?" : { - }, "TargetGlucose - 0.5 × (TargetGlucose - 40)" : { "localizations" : { @@ -293611,14 +291741,8 @@ } } }, - "TDD 24h" : { - - }, - "TDD: %@" : { - "comment" : "Tag chip in Loop status popup — total daily dose" - }, "Temp Basal" : { - "comment" : "Treatment history type label — temporary basal\nTreatment label in history for temporary basal\nTreatment type", + "comment" : "Treatment label in history for temporary basal\nTreatment type", "localizations" : { "bg" : { "stringUnit" : { @@ -293885,32 +292009,6 @@ } } }, - "Temp Target '%@' applied for %lld min" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Temp Target '%1$@' applied for %2$lld min" - } - } - } - }, - "Temp Target '%@' failed" : { - - }, - "Temp Target '%@' scheduled for %@, %lld min" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Temp Target '%1$@' scheduled for %2$@, %3$lld min" - } - } - } - }, - "Temp target preset not found." : { - "comment" : "Watch command ack" - }, "Temp Target Presets" : { "localizations" : { "bg" : { @@ -294041,12 +292139,6 @@ } } }, - "Temp target started." : { - "comment" : "Watch command ack" - }, - "Temp target stopped." : { - "comment" : "Watch command ack" - }, "Temp Target to Stop" : { "localizations" : { "bg" : { @@ -294452,26 +292544,6 @@ } } } - }, - "Temporary — active for %@, auto-reverts at %@." : { - "comment" : "Body of schedule-fire notification for a timed activation — first interpolated value is a duration like '3 hr 15 min', second is a clock time", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Temporary — active for %1$@, auto-reverts at %2$@." - } - } - } - }, - "Temporary (up to 24 h): runs for the chosen duration, then reverts to the anchor (your last indefinite profile). Trio and the pump manager deliver the profile's basal rates live — the pump's saved basal schedule is NOT changed. If pump connectivity drops, the pump falls back to its last saved schedule." : { - - }, - "Temporary activation: an informational notification confirms the switch (\"Profile X activated — auto-reverts at hh:mm\"). No action required." : { - - }, - "Temporary activations don't overwrite the pump's saved schedule — Trio and the pump manager deliver the temp profile's rates live. That's what lets the temp auto-revert cleanly and also protects you if pump connectivity drops (the pump falls back to its last saved schedule)." : { - }, "Temporary Basal" : { "comment" : "Navigation Title for ManualTempBasalEntryView", @@ -295269,16 +293341,6 @@ } } }, - "Temporary Target '%@' scheduled for %@" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Temporary Target '%1$@' scheduled for %2$@" - } - } - } - }, "Temporary Target canceled" : { "localizations" : { "bg" : { @@ -295539,9 +293601,6 @@ } } }, - "TempTarget Level for B30" : { - "comment" : "B30 TT Level" - }, "The Adjustment Factor (AF) allows you to control how quickly and effectively Dynamic ISF responds to changes in glucose levels." : { "localizations" : { "bg" : { @@ -296198,9 +294257,6 @@ } } } - }, - "The bar height grows with the dose or carb amount, making it easier to spot large entries at a glance." : { - }, "The bolus cannot be larger than the pump setting max bolus (%@)." : { "localizations" : { @@ -296593,9 +294649,6 @@ } } } - }, - "The CSF profile has a single purpose: to re-calculate your Carb Ratio (CR) from your Insulin Sensitivity Factor (ISF) using the formula: CR = ISF / CSF. It is not used directly by the algorithm or anything else." : { - }, "The current version has a critical issue and should be updated as soon as possible." : { "comment" : "Message for critical update alert", @@ -297921,7 +295974,6 @@ } }, "The duration of insulin action (DIA) is now locked to Trio’s new default of 10 hours." : { - "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -298050,9 +296102,6 @@ } } } - }, - "The duration of insulin action (DIA) is now reset to Trio’s new default of 10 hours." : { - }, "The external bolus cannot be larger than 3 x the pump setting max bolus (%@)." : { "localizations" : { @@ -298444,6 +296493,9 @@ } } }, + "The final GRI score is capped at 100. Calculations use unrounded values." : { + "comment" : "Explains the cap and why displayed rounded values may not add up exactly." + }, "The first value is your profile Carb Ratio (CR). The second value, after the arrow, is your adjusted CR used for the most recent automated dosing calculation." : { "localizations" : { "bg" : { @@ -299353,12 +297405,6 @@ } } } - }, - "The insulin concentration can only be changed if you change the insulin in your pump. To make that sure for every pump model, you will have to delete the current pump, change the Insulin Concentration and add your pump again." : { - - }, - "The insulin concentration is given in Insulin Units per mL. The standard is U100 with 100 U/mL." : { - }, "The lowest forecasted glucose during the remaining duration of insulin action (DIA)." : { "localizations" : { @@ -300269,9 +298315,6 @@ } } } - }, - "The normal case is to pick the strongest factor as the one and only factor to be applied. Here autosense is also part of the game. But how about the exceptions, i.e., when different factors pull in different directions? In order of precedence they are:" : { - }, "The order of Fat and Protein inputs has changed." : { "localizations" : { @@ -300930,12 +298973,6 @@ } } } - }, - "the preset to schedule" : { - - }, - "The profile couldn't be saved. Check the name and try again." : { - }, "The ratio of how sensitive or resistant to insulin you are in the current loop cycle. Baseline = 1.0, Sensitive < 1.0, Resistant > 1.0" : { "localizations" : { @@ -301456,9 +299493,6 @@ } } } - }, - "The shorter the interval, the smoother the correlating dosing result." : { - }, "The Sigmoid Adjustment Factor (AF) allows you to control how quickly Sigmoid Dynamic ISF responds to changes in glucose levels and at what glucose value you will reach your Autosens Max and Autosens Min limits." : { "localizations" : { @@ -301719,9 +299753,6 @@ } } } - }, - "The time between new glucose readings (default: 5 minutes). Reducing this will generate readings more frequently." : { - }, "The time it takes to complete one full cycle from high to low and back to high." : { "localizations" : { @@ -301982,9 +300013,6 @@ } } } - }, - "The variable IOB Threshold Percent holds a percentage of the maxIOB which is used as the threshold to disable SMB. If this is enabled by setting it lower than 100%, any sensitivity changes defined by the user are modulated internally into an effective IOB Threshold.\n\nThe new capabilities are:" : { - }, "The Warsaw Method helps account for the delayed glucose spikes caused by fat and protein in meals. It uses Fat-Protein Units (FPU) to calculate the carb effect from fat and protein. The system spreads insulin delivery over several hours to mimic natural insulin release, helping to manage post-meal glucose spikes." : { "localizations" : { @@ -302246,12 +300274,6 @@ } } } - }, - "Therapy Adjustment" : { - "comment" : "Section header for the therapy-adjustment percentage on Add Profile form" - }, - "Therapy and algorithm values will be kept. The profile will no longer show a percent or tuned badge." : { - }, "Therapy Settings" : { "localizations" : { @@ -304088,13 +302110,6 @@ } } }, - "This also means that when calculating meal insulin, your Carb Ratio effectively becomes dynamic: since CR = ISF / CSF and CSF is held constant, CR automatically adjusts as ISF changes throughout the day. Some refer to this as \"dynamic CR\"." : { - - }, - "This button re-calculates your Carb Ratio (CR) profile from your Insulin Sensitivity Factor (ISF) and Carb Sensitivity Factor (CSF) profiles using the formula: CR = ISF / CSF." : { - "comment" : "A description of the Calculate CR from CSF & ISF Profiles button.", - "isCommentAutoGenerated" : true - }, "This cap prevents the system from overestimating how much insulin is needed when carb absorption isn't visible, offering a safeguard for accurate dosing." : { "localizations" : { "bg" : { @@ -305679,9 +303694,6 @@ } } }, - "This feature enables a small safety Temp Basal Rate (TBR) to reduce ketoacidosis risk. Without the Variable Protection, the safety TBR is always applied." : { - "comment" : "Mini-hint for Activate KetoProtection setting" - }, "This feature ensures more accurate insulin adjustments when carb entries are missing or incorrect." : { "localizations" : { "bg" : { @@ -306072,12 +304084,7 @@ } } }, - "This feature smooths your CGM readings to reduce noise and make them easier to read. 2 Algorithms can be used, exponential smoothing is the method used in stock AndroidAPS (AAPS). Alternatively, a more sophisticated method using UKF is adopted from the AAPS Tsunami branch." : { - "comment" : "A verbose hint for the \"Smooth Glucose Value\" setting, explaining how it works.", - "isCommentAutoGenerated" : true - }, "This feature smooths your CGM readings to reduce noise and make them easier to read. It is based on a method used in AndroidAPS (AAPS). It uses two approaches: one that reacts quickly to recent changes, and one that looks at longer trends. These are combined to give a balanced result." : { - "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -306872,9 +304879,6 @@ } } } - }, - "This is a classical insulin resistance and is typically caused by free fatty acids which grab available insulin before glucose can. Quite often, users get impatient in such a situation and administer one or even more rage boluses. Again and again, that leads to hypos later which the dura_ISF approach avoids if carefully tuned.\n\nThe strengthening of ISF is stronger the longer the situation lasts and the higher the average glucose is above target:\n\ndura_ISF = 1 + (avg05 - target_bg) / target_bg * dura05 * dura_ISF_weight\n\nwhere:\navg05 = dura_ISF_average\ndura05 = dura_ISF_minutes\n\nThe user can apply his personal weighting by using dura_ISF_weight. Start cautiously with a value of 0.2 and be very careful when you approach 1.5 or even higher. By using 0 this effect is disabled." : { - }, "This is a limit on the size of a single SMB. One SMB can only be as large as this many minutes of your current profile basal rate." : { "localizations" : { @@ -307135,9 +305139,6 @@ } } } - }, - "This is a multiplier cap for autoISF to set a limit on how low the autoISF ratio can be, which in turn determines how high it can adjust ISF." : { - }, "This is a multiplier cap for autosens (and autotune) to set a 20% max limit on how high the autosens ratio can be, which in turn determines how high autosens can adjust basals, how low it can adjust ISF, and how low it can set the BG target." : { "comment" : "\"Autosens Max\"", @@ -307950,12 +305951,6 @@ } } } - }, - "This is another key OpenAPS safety cap, and specifies what share of the total insulin required can be delivered as SMB." : { - "comment" : "SMB DeliveryRatio miniHint" - }, - "This is important because creating separate CR and ISF profiles implicitly defines an unreviewed CSF profile (calculated as CSF = ISF / CR) that may be incorrect. By explicitly defining your CSF profile, you ensure that CR automatically adjusts correctly throughout the day as your ISF changes, maintaining physiologically accurate insulin dosing for carbohydrates." : { - }, "This is the amount of the maximum number of carbs we’ll assume will absorb over 4h if we don’t yet see carb absorption." : { "comment" : "\"Remaining Carbs Cap\"", @@ -308095,12 +306090,8 @@ } } }, - "This is the development version of Tai, built from the dev branch.\n\nAny updates on this branch may contain new, lightly tested features, and may be unsafe. If you are not a tester, please do not use this branch, and switch to main." : { - "comment" : "Body of the warning shown on builds from the dev branch" - }, "This is the development version of Trio, built from the dev branch.\n\nAny updates on this branch may contain new, lightly tested features, and may be unsafe. If you are not a tester, please do not use this branch, and switch to main." : { "comment" : "Body of the warning shown on builds from the dev branch", - "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -308888,21 +306879,6 @@ } } }, - "This is the original effect of autoISF in action since August 2020. Because autoISF is now a toolbox of several effects, this original effect was renamed dura_ISF. It addresses situations when:" : { - - }, - "This is the share of maxIOB above which autoISF will disable SMB. 100% neutralizes it's effect." : { - "comment" : "autoISF IOB Threshold miniHint" - }, - "This is the weight applied to the linear slope while glucose rises and adapts ISF. With 0 this contribution is effectively disabled. Start with 0.01 - it hardly goes beyond 0.05!" : { - "comment" : "ISF weight for postprandial BG rise miniHint" - }, - "This is the weight applied to the polygon which adapts ISF if glucose is above target." : { - "comment" : "ISF Weight for Higher BGs miniHint" - }, - "This is the weight applied to the polygon which adapts ISF if glucose is below target." : { - "comment" : "ISF Weight for Lower BGs miniHint" - }, "This is useful for slow-absorbing meals where high fat, protein, or fiber can delay carb absorption and cause a slower rise in blood sugar, so a reduced bolus helps match insulin to the delayed effect." : { "localizations" : { "bg" : { @@ -309292,9 +307268,6 @@ } } } - }, - "This makes CSF independent of short-term ISF changes that occur with features like Dynamic ISF, autoISF, or Autosens." : { - }, "This means Trio may not have complete information about how much active insulin or carbs were still on board when you switched over." : { "localizations" : { @@ -309823,9 +307796,6 @@ } } } - }, - "This percentage rescales the three therapy profiles — Basal Rate, Insulin Sensitivity (ISF) and Carb Ratio (CR) — in one step. It's the same mechanism an override uses, baked into the profile so every loop cycle sees the adjusted values." : { - }, "This prevents excessive dosing, especially during times of variable insulin sensitivity, enhancing safety." : { "localizations" : { @@ -310224,9 +308194,6 @@ } } } - }, - "This sets the maximum length of time that Fat and Protein Carb Equivalents (FPUs) will be extended over from a single Fat and/or Protein bolus calcultor entry." : { - }, "This setting adjusts how much weight is given to your recent total daily insulin dose when calculating Dynamic ISF and Dynamic CR." : { "localizations" : { @@ -310749,7 +308716,6 @@ } }, "This setting controls which bolus amount labels are shown on Trio’s main chart. Boluses appear as blue upside-down triangles, with a number showing the amount. Depending on the option you choose, only boluses at or above that amount will show a label. For example, if you choose ‘0.5 U and over’, only boluses of 0.5 U or more will show a label." : { - "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -311405,16 +309371,6 @@ } } }, - "This setting is limited to values between %@ - %@ %@." : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "This setting is limited to values between %1$@ - %2$@ %3$@." - } - } - } - }, "This setting restricts the maximum temporary basal rate Trio can set. At the default of 300%, it caps it at 3 times your highest programmed basal rate." : { "localizations" : { "bg" : { @@ -311545,9 +309501,6 @@ } } }, - "This specifies by what factor you can exceed the limit of 180 maxSMB/maxUAM minutes." : { - "comment" : "SMB Max RangeExtension miniHint" - }, "This Temp Target preset is currently running. Deleting will stop it." : { "localizations" : { "bg" : { @@ -311678,12 +309631,8 @@ } } }, - "This version of Tai was built from '%@', not the released main branch.\n\nAny updates on this branch may contain new, lightly tested features, and may be unsafe. If you are not a tester, please do not use this branch, and switch to main." : { - "comment" : "Body of the warning shown on builds from a branch other than main or dev; the placeholder is the branch name" - }, "This version of Trio was built from '%@', not the released main branch.\n\nAny updates on this branch may contain new, lightly tested features, and may be unsafe. If you are not a tester, please do not use this branch, and switch to main." : { "comment" : "Body of the warning shown on builds from a branch other than main or dev; the placeholder is the branch name", - "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -312347,9 +310296,6 @@ } } } - }, - "threshold = minBG − (1 − ratio) × (minBG − 40)" : { - }, "Tidepool" : { "localizations" : { @@ -312612,7 +310558,7 @@ } }, "Time" : { - "comment" : "Label for Time", + "comment" : "Time basal profile", "localizations" : { "bg" : { "stringUnit" : { @@ -314311,9 +312257,6 @@ } } } - }, - "Timed (up to 24 h): applied until the duration expires, then auto-reverts to the indefinite baseline. Trio and the pump manager deliver the timed profile's basal rates live — the pump's saved basal schedule is NOT changed. If pump connectivity drops, the pump falls back to its last saved schedule." : { - }, "Tip: Decreasing this value allows automatic adjustments of basal rates to be lower and ISF to be higher." : { "localizations" : { @@ -315224,12 +313167,6 @@ } } } - }, - "Tip: Start with a single CSF value for the entire day. Look at your current ISF and CR profile values at a time of day where you feel most confident they are working well — usually around midday — and calculate CSF = ISF / CR from those values." : { - - }, - "Tip: Start with a single CSF value for the entire day. Look at your current ISF and CR values at a time of day where you feel most confident they are working well — usually around midday — and calculate CSF = ISF / CR from those values." : { - }, "Tip: You may find that your normal carb ratio needs to increase to a larger number when you begin adding fat and protein entries. For this reason, it is best to start with a factor of about 50%." : { "localizations" : { @@ -316270,9 +314207,6 @@ } } } - }, - "To edit insulin concentration, you must first remove the pump." : { - }, "To enable or configure Warsaw Method application in Trio, go to Settings > Features > Meal Settings." : { "localizations" : { @@ -316926,6 +314860,7 @@ } }, "To re-order widgets, remove them and re-add them in the desired order." : { + "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -317314,9 +315249,6 @@ } } } - }, - "To understand the Variable Keto Protection Strategy, read up on IOB and active Insulin (activity):" : { - }, "today" : { "comment" : "Stats banner scope", @@ -317719,6 +315651,7 @@ }, "Today's Averages" : { "comment" : "Home stats panel face option", + "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -318241,9 +316174,6 @@ } } }, - "Too many requests in progress. Please try again." : { - "comment" : "Watch command ack" - }, "Top target" : { "comment" : "Upper temp target limit", "extractionState" : "manual", @@ -319698,9 +317628,6 @@ } } } - }, - "Touch and Hold" : { - }, "Tracks the carbohydrates you eat, entered to guide insulin dosing." : { "localizations" : { @@ -322724,7 +320651,6 @@ } }, "Trio does not collect any health related data, e.g. glucose readings, insulin rates or doses, meal data, therapy setting values, or similar." : { - "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -323245,7 +321171,6 @@ } }, "Trio has not looped in %lld minutes." : { - "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -323376,7 +321301,6 @@ } }, "Trio has not run in %lld minutes." : { - "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -324157,7 +322081,6 @@ } }, "Trio is designed to help manage your diabetes efficiently. To get the most out of the app, we'll guide you through setting up some essential parameters." : { - "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -324286,9 +322209,6 @@ } } } - }, - "Trio is designed to help manage your diabetes efficiently. To get the most out of the app, we'll guide you through setting up some essential Trio parameters. Tai specific settings need to be done manually after Trio onboarding." : { - }, "Trio is holding sensitivity at 100% and ignoring these settings." : { "localizations" : { @@ -326895,9 +324815,6 @@ } } } - }, - "Trio uses whichever is higher: the Minimum Safety Threshold above or the ratio-derived floor below. So this ratio only takes effect when ratioFloor exceeds the Minimum Safety Threshold." : { - }, "Trio v%@" : { "localizations" : { @@ -327030,7 +324947,6 @@ } }, "Trio v%@ (%@)" : { - "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -328465,9 +326381,6 @@ } } } - }, - "Triple Tap" : { - }, "Try again in a moment, or configure your Therapy Settings manually instead." : { "localizations" : { @@ -328730,9 +326643,6 @@ } } }, - "TTs" : { - "comment" : "Selected Tab — short for Temp Targets" - }, "Turn this setting on to give basal adjustments more agility. Keep this setting off if your basal needs are not highly variable." : { "localizations" : { "bg" : { @@ -329383,38 +327293,6 @@ } } } - }, - "Typical: 0.1 " : { - - }, - "Typical: 0.07 " : { - - }, - "Typical: %@ %@" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Typical: %1$@ %2$@" - } - } - } - }, - "Typical: 0.02" : { - - }, - "Typical: 0.4" : { - "comment" : "Example value for the weight applied to the polygon which adapts ISF if glucose is above target.", - "isCommentAutoGenerated" : true - }, - "Typical: 0.6" : { - - }, - "Typical: 0.7" : { - - }, - "Typical: 2" : { - }, "U" : { "comment" : "Insulin unit\nInsulin unit abbreviation\nInsulin unit abbreviation shown next to a quick-pick bolus amount\nThe short unit display string for international units of insulin\nUnits for bolus amount", @@ -329678,7 +327556,7 @@ } }, "U/hr" : { - "comment" : "Insulin unit per hour abbreviation\nUnits text for temporary basal rate", + "comment" : "Insulin unit per hour abbreviation", "localizations" : { "bg" : { "stringUnit" : { @@ -329807,15 +327685,6 @@ } } } - }, - "U%lld" : { - - }, - "U50 or other diluted insulins will lead to more insulin volume being pumped. So it is essential to be aware of the setting that concentrated or especially diluted insulin is used." : { - - }, - "U50 or other diluted insulins will lead to more insulin volume being pumped. Using a diluted Insulin concentration will inject larger volumes. If not set correctly potential errors can lead to severe overdosing of insulin." : { - }, "UAM" : { "localizations" : { @@ -332593,9 +330462,6 @@ } } }, - "Unscented Kalman Filter" : { - "comment" : "UKF smoothing algorithm display name" - }, "Unsuspend If No Temp" : { "comment" : "Unsuspend If No Temp", "localizations" : { @@ -332857,9 +330723,6 @@ } } }, - "until next change" : { - "comment" : "Schedule duration summary — indefinite/until-next activation\nUpcoming-schedule row duration — indefinite or until-next activation" - }, "up to date" : { "comment" : "Accessibility: version status", "localizations" : { @@ -332990,9 +330853,6 @@ } } } - }, - "Upcoming" : { - }, "update available" : { "comment" : "Accessibility: version status", @@ -335105,16 +332965,6 @@ } } }, - "Upper BG Limit for B30" : { - "comment" : "B30 Upper BG Limit" - }, - "Upper Delta Limit for B30" : { - "comment" : "B30 Upper Delta" - }, - "Upper end of the linearly increasing SMB delivery ratio. Reached once glucose sits at BG target plus SMB DeliveryRatio BG Range — i.e. the maximum share of insulinReq delivered as SMB when glucose is deep above target." : { - "comment" : "A description of the upper end of the linearly increasing SMB delivery ratio.", - "isCommentAutoGenerated" : true - }, "Upper limit of the Autosens Ratio." : { "localizations" : { "bg" : { @@ -335246,7 +333096,6 @@ } }, "Upper limit of the Sensitivity Ratio." : { - "comment" : "Mini-hint for Autosens Max setting", "localizations" : { "bg" : { "stringUnit" : { @@ -335376,6 +333225,10 @@ } } }, + "Urgency Level" : { + "comment" : "A label for the urgency level slider.", + "isCommentAutoGenerated" : true + }, "Urgent Low" : { "comment" : "Display name for the urgent low alarm sound.", "localizations" : { @@ -335898,7 +333751,6 @@ } }, "Use \"What's sent\" above to inspect the exact JSON payload before deciding." : { - "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -336302,9 +334154,6 @@ } } } - }, - "Use Bars for Bolus and Carbs" : { - }, "Use calendar events to display current data." : { "localizations" : { @@ -336835,11 +334684,7 @@ } } }, - "Use Dynamic ISF" : { - "comment" : "Row label for the Use Dynamic ISF toggle" - }, "Use Dynamic Ratio to adjust basal rates." : { - "comment" : "Mini-hint for Dynamic-ISF Adjust-Basal toggle", "localizations" : { "bg" : { "stringUnit" : { @@ -337236,9 +335081,6 @@ } } }, - "Use Profile CSF" : { - "comment" : "Use Profile CSF" - }, "Use Sigmoid Function" : { "comment" : "Headline Use Sigmoid Function", "extractionState" : "manual", @@ -337377,9 +335219,6 @@ } } }, - "Use the sigmoid dynISF formula instead of logarithmic." : { - "comment" : "Mini-hint for the Sigmoid (vs logarithmic) Dynamic-ISF formula toggle" - }, "Use this to check whether your basal rates hold you steady. Trio makes no changes unless your glucose goes low. Then it stops your basal until you come back up.\nTrio also stops adjusting for sensitivity, so you see your own settings at work. Turning this on cancels any temp basal Trio set. Use it for short, planned times." : { "localizations" : { "bg" : { @@ -337639,13 +335478,6 @@ } } } - }, - "Used above target, strengthens ISF the more the higher this weight is. 0 disables this contribution, i.e., ISF is constant in the whole range above target.\n\nStart with a weight of 0.2 and observe the reactions.\n\nThere are indicators that higher glucose needs stronger ISF. This was evident from all the successful AAPS users defining automation rules which strengthen the profile at higher glucose levels. The drawback is that there are sudden jumps in ISF at switch points and no further or minor adaptations in between.\n\nIn autoISF a polygon is provided that defines a relationship between glucose and ISF and interpolates in between. This is currently hard coded but the user can apply weights to easily strengthen or weaken it in order to fit personal needs. In principle the polygon itself can be edited and the apk rebuilt if a different shape is required. Developing a GUI for that purpose was considered very tedious especially before knowing whether the results warrant the effort. With this approach you could even approximate the formula well enough that is used in DynamicISF for the ISF dependency on glucose.\n\nThere is a special case possible, namely below target i.e. when bg_ISF < 1. ISF will be weakened and there is no point in checking the remaining effects. Only with positive acceleration the weakening will be less pronounced as that is a sign of rising glucose to come soon." : { - "comment" : "A paragraph explaining the purpose and behaviour of a setting in the context of the app.", - "isCommentAutoGenerated" : true - }, - "Used below target, weakens ISF the more the higher this weight is. 0 disables this contribution, i.e., ISF is constant in the whole range below target. This weight is less critical as the loop is probably running at Temp basal Rate = 0 anyway and you can start around 0.2." : { - }, "User Interface" : { "localizations" : { @@ -338847,9 +336679,6 @@ } } } - }, - "value" : { - }, "Value" : { "localizations" : { @@ -339241,24 +337070,6 @@ } } }, - "Values >100% = less sensitive, more insulin nedded, Basal Rates profile is lifted, ISF and CR profiles decrease. 100% preserves current Therapy Settings." : { - "comment" : "Mini-hint beneath the therapy-adjustment percentage row on Add Profile form" - }, - "Values above 0.5 make SMBs more aggressive at high glucose. Raise with care; the ratio climbs linearly from SMB DeliveryRatio BG Minimum up to this value." : { - - }, - "Values shown are adjusted for your insulin concentration. The pump receives the correct physical delivery rates." : { - - }, - "Variable KetoProtect" : { - - }, - "Variable SMB DelRatio" : { - - }, - "Variable Strategy" : { - "comment" : "Variable Keto Protection" - }, "Very High (>%@)" : { "localizations" : { "bg" : { @@ -339780,16 +337591,6 @@ } } }, - "Very neat feature that allows the use of profile and temporary targets to trigger SMB's being enabled or disabled. So a profile target at 3:00 am of %@ %@ will prevent any SMB's in that time window. Schedule a TT of %@ %@ at 3:20 am and from then on SMB's can be enacted." : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Very neat feature that allows the use of profile and temporary targets to trigger SMB's being enabled or disabled. So a profile target at 3:00 am of %1$@ %2$@ will prevent any SMB's in that time window. Schedule a TT of %3$@ %4$@ at 3:20 am and from then on SMB's can be enacted." - } - } - } - }, "Via Watch" : { "comment" : "Note added to carb entry when entered via watch", "localizations" : { @@ -340182,6 +337983,10 @@ } } }, + "Voice" : { + "comment" : "Display name for the voice-only alarm sound set.", + "isCommentAutoGenerated" : true + }, "W" : { "comment" : "Abbreviation for week", "localizations" : { @@ -340580,9 +338385,6 @@ } } }, - "warming up" : { - "comment" : "Accessibility: sensor lifecycle tag" - }, "Warning when bolusing with a very low or forecasted very low glucose." : { "localizations" : { "bg" : { @@ -342151,12 +339953,6 @@ } } }, - "Watch command settings changed." : { - "comment" : "Watch command ack" - }, - "Watch commands are disabled." : { - "comment" : "Watch command ack" - }, "Watch Configuration" : { "extractionState" : "manual", "localizations" : { @@ -343211,17 +341007,7 @@ } } }, - "Weekdays" : { - "comment" : "Schedule repeat summary — Monday through Friday" - }, - "Weekends" : { - "comment" : "Schedule repeat summary — Saturday and Sunday" - }, - "Weekly on %@" : { - "comment" : "Schedule repeat summary prefix for a custom weekly pattern — interpolated list is comma-separated weekday abbreviations" - }, "Weight of 24-hr TDD against 10-day TDD." : { - "comment" : "Mini-hint for Dynamic ISF Weighted-Average-of-TDD setting", "localizations" : { "bg" : { "stringUnit" : { @@ -343352,7 +341138,6 @@ } }, "Weighted Average of TDD" : { - "comment" : "Row label for the Dynamic ISF Weighted-Average-of-TDD setting", "localizations" : { "bg" : { "stringUnit" : { @@ -343620,8 +341405,11 @@ } } }, - "Welcome to Tai - an automated insulin delivery system for iOS based on Trio using the OpenAPS algorithm with autoISF and other adaptations." : { - + "Weighted hyperglycemia" : { + "comment" : "Hyperglycemia component multiplied by its GRI weight." + }, + "Weighted hypoglycemia" : { + "comment" : "Hypoglycemia component multiplied by its GRI weight." }, "Welcome to Trio" : { "localizations" : { @@ -343754,7 +341542,6 @@ } }, "Welcome to Trio — an automated insulin delivery system for iOS based on the OpenAPS algorithm with adaptations." : { - "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -343884,17 +341671,6 @@ } } }, - "What is a Profile?" : { - "comment" : "A heading explaining what a profile is.", - "isCommentAutoGenerated" : true - }, - "What is a Schedule?" : { - - }, - "What is CSF?" : { - "comment" : "A heading in the verbose hint for the Carb Sensitivity Factor (CSF).", - "isCommentAutoGenerated" : true - }, "What is sent" : { "localizations" : { "bg" : { @@ -345196,22 +342972,8 @@ } } } - }, - "When all 4 effects are configured, how to deduce an end result?" : { - - }, - "When autoISF is turned off Autosens will always be activated and on HomwView, the Autosens Sensitivity Ratio (AS) will be shown instead of the autoISF Ratio (aiSR)" : { - "comment" : "A verbose hint explaining the difference between the \"Autosens Sensitivity Ratio\" and the \"autoISF Ratio\".", - "isCommentAutoGenerated" : true - }, - "When autoISF is turned on the autoISF Ratio (aiSR) will be displayed on Homeview, showing the final ISF / Sensitivity adaption, instead of the regular Autosens Sensitivity Ratio (AS)" : { - - }, - "When changing TO or FROM diluted insulins, please check whether you want to decrease (TO) or increase (FROM):" : { - }, "When did you eat ?" : { - "comment" : "Request meal time", "localizations" : { "bg" : { "stringUnit" : { @@ -345340,12 +343102,6 @@ } } } - }, - "When disabled, the bobble only shows the glucose reading, trend arrow, and delta." : { - - }, - "When enabled, boluses and SMBs are drawn as blue bars hanging down from above the glucose curve, and carbs as orange bars rising up from below." : { - }, "When enabled, long-pressing the + button on the home screen opens a Quick-Pick Treatments sheet. It suggests up to three bolus amounts and, if you have carb history, up to three carb amounts based on what you typically enter at this time of day, weighted by recency and day type (weekday vs. weekend)." : { "comment" : "A description of the Quick-Pick Treatments setting.", @@ -345477,9 +343233,6 @@ } } } - }, - "When enabled, oref will calculate CSF (Carb Sensitivity Factor) from your profile's Carb Ratio and profile's Insulin Sensitivity Factor, keeping CSF stable as a profile-based value." : { - }, "When enabled, Super Micro Boluses (SMBs) will always be allowed if dosing calculations determine insulin is needed via the SMB delivery method, except when a high Temp Target is set." : { "localizations" : { @@ -345870,9 +343623,6 @@ } } } - }, - "When enabled, the glucose bobble shows CGM sensor status: a lifecycle arc during warmup and the last 48 hours before expiry, an hourglass icon tracking the sensor's remaining time, and compact status views for error states like signal loss or sensor failure." : { - }, "When enabled, the live activity widget on the lock screen will show glucose forecasts. The visual representation will be the same as in the Main view. To change between Cone and Lines, change the setting under Features - User Interface - Forecast Display Type." : { "localizations" : { @@ -346394,13 +344144,6 @@ } } }, - "When should the profile activate?" : { - - }, - "When should the Temp Target start?" : { - "comment" : "The prompt to ask the user when the Temp Target should start.", - "isCommentAutoGenerated" : true - }, "When Skip Neutral Temps is enabled, Trio will not set neutral basal rates shortly before the hour, minimizing hourly pump alerts on MDT pumps. This can help light sleepers avoid alerts but will delay basal adjustments. This will also only come into effect if SMB's are disabled for whatever reason." : { "localizations" : { "bg" : { @@ -346921,10 +344664,6 @@ } } }, - "When the even/odd target toggle is enabled, setting an even-numbered temp target below 100 mg/dL (e.g. 80 or 90) puts autoISF into full-loop mode — a signal that you want maximum SMB power. In that mode the SMB delivery ratio is forced to at least the fixed SMB DeliveryRatio, even when the BG-range ramp would otherwise give a lower value." : { - "comment" : "Explanation of the \"full-loop mode\" feature in autoISF.", - "isCommentAutoGenerated" : true - }, "When there are carbs on board (COB > 0), enabling this feature allows Trio to use Super Micro Boluses (SMB) to deliver the insulin required." : { "localizations" : { "bg" : { @@ -348128,15 +345867,6 @@ } } } - }, - "Which preset to schedule?" : { - - }, - "Which profile do you want to activate?" : { - - }, - "Which profile do you want to schedule?" : { - }, "While onboarding, Trio continues to operate with your prior settings." : { "localizations" : { @@ -348397,15 +346127,8 @@ } } } - }, - "Whole hours of activation (0–24)" : { - - }, - "Why does Tai collect this data?" : { - }, "Why does Trio collect this data?" : { - "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -348534,9 +346257,6 @@ } } } - }, - "Why is CSF not dependent on ISF when this setting is enabled?" : { - }, "Wide BG Target Range" : { "comment" : "Headline \"Wide BG Target Range\"", @@ -349202,9 +346922,6 @@ } } } - }, - "With the default ratio of 0.5 this collapses to TargetGlucose − 0.5 × (TargetGlucose − 40), i.e. the midpoint between target and 40 mg/dL. Trio always uses whichever is higher: this Minimum Safety Threshold or that ratio-derived floor." : { - }, "With this option enabled, the SMB algorithm can recognize unannounced meals. This is helpful, if you forget to tell Trio about your carbs or estimate your carbs wrong and the amount of entered carbs is wrong or if a meal with lots of fat and protein has a longer duration than expected. Without any carb entry, UAM can recognize fast glucose increasments caused by carbs, adrenaline, etc, and tries to adjust it with SMBs. This also works the opposite way: if there is a fast glucose decreasement, it can stop SMBs earlier." : { "comment" : "\"Enable UAM\"", @@ -349344,14 +347061,6 @@ } } }, - "With this setting enabled, CSF is calculated using your static profile ISF value instead, ensuring CSF remains constant and predictable, unaffected by temporary ISF adjustments." : { - "comment" : "Explanation of the \"Dynamic CR\" feature in the context of the \"Use Profile CSF\" setting.", - "isCommentAutoGenerated" : true - }, - "With v3.01 the following 5 settings were withdrawn because over time it proved they were not really necessary. One direct impact is a flatter menu structure for the remaining settings:" : { - "comment" : "A note about changes to the algorithm settings.", - "isCommentAutoGenerated" : true - }, "𝒳 = Max SMB Basal Minutes" : { "localizations" : { "bg" : { @@ -350009,9 +347718,6 @@ } } } - }, - "You also have to redo the Tai specific algorithm settings in Settings > Algorithm > Extensions!" : { - }, "You can change these permissions any time in the iOS Settings app." : { "localizations" : { @@ -350142,9 +347848,6 @@ } } } - }, - "You can change these settings in Therapy > Units and Limits after saving the new concentration and after you have added a pump!" : { - }, "You can connect Trio to seamlessly upload and manage your diabetes data on Tidepool." : { "localizations" : { @@ -353550,8 +351253,26 @@ } } }, + "Zone %@" : { + "comment" : "A label displaying the glycemic risk index zone. The argument is the zone letter (A through E).", + "isCommentAutoGenerated" : true + }, + "Zone A (0-20)" : { + "comment" : "GRI chart legend: risk zone A and its score range." + }, + "Zone B (>20-40)" : { + "comment" : "GRI chart legend: risk zone B and its score range." + }, + "Zone C (>40-60)" : { + "comment" : "GRI chart legend: risk zone C and its score range." + }, + "Zone D (>60-80)" : { + "comment" : "GRI chart legend: risk zone D and its score range." + }, + "Zone E (>80-100)" : { + "comment" : "GRI chart legend: risk zone E and its score range." + }, "Zooms continuously, between one hour and a full day on screen." : { - "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -353940,10 +351661,7 @@ } } } - }, - "μL/hr" : { - } }, "version" : "1.1" -} \ No newline at end of file +} diff --git a/Trio/Sources/Models/CarbsEntry+LoopKit.swift b/Trio/Sources/Models/CarbsEntry+LoopKit.swift index b655fdaab7..409bcb427f 100644 --- a/Trio/Sources/Models/CarbsEntry+LoopKit.swift +++ b/Trio/Sources/Models/CarbsEntry+LoopKit.swift @@ -14,7 +14,7 @@ extension CarbsEntry { foodType: nil, grams: Double(carbs), startDate: createdAt, - uuid: UUID(uuidString: id!), + uuid: id.flatMap(UUID.init(uuidString:)), provenanceIdentifier: enteredBy ?? "Trio", syncIdentifier: id, syncVersion: nil, diff --git a/Trio/Sources/Models/CommandPayload.swift b/Trio/Sources/Models/CommandPayload.swift index 98bab3a145..f279f0068c 100644 --- a/Trio/Sources/Models/CommandPayload.swift +++ b/Trio/Sources/Models/CommandPayload.swift @@ -20,8 +20,15 @@ struct CommandPayload: Decodable, Sendable { var fat: Int? var overrideName: String? var scheduledTime: TimeInterval? + var commandId: String? + var mealId: String? var returnNotification: ReturnNotificationInfo? + /// Nightscout `id` of the meal root or of one of its carb equivalents. + var mealHandle: UUID? { + mealId.flatMap(UUID.init(uuidString:)) + } + struct ReturnNotificationInfo: Decodable, Sendable { let productionEnvironment: Bool let deviceToken: String @@ -52,6 +59,8 @@ struct CommandPayload: Decodable, Sendable { case commandType = "command_type" case bolusAmount = "bolus_amount" case scheduledTime = "scheduled_time" + case commandId = "command_id" + case mealId = "meal_id" case returnNotification = "return_notification" } @@ -80,6 +89,13 @@ struct CommandPayload: Decodable, Sendable { let fatDesc = fat != nil ? "\(fat!)g fat" : "unknown fat" let proteinDesc = protein != nil ? "\(protein!)g protein" : "unknown protein" description += "Meal with \(carbsDesc), \(fatDesc), \(proteinDesc)." + case .deleteMeal: + description += "Delete meal \(mealId ?? "unknown")." + case .editMeal: + let carbsDesc = carbs != nil ? "\(carbs!)g carbs" : "unknown carbs" + let fatDesc = fat != nil ? "\(fat!)g fat" : "unknown fat" + let proteinDesc = protein != nil ? "\(protein!)g protein" : "unknown protein" + description += "Edit meal \(mealId ?? "unknown") to \(carbsDesc), \(fatDesc), \(proteinDesc)." case .startOverride: if let override = overrideName { description += "Start Override: \(override)." @@ -88,6 +104,8 @@ struct CommandPayload: Decodable, Sendable { } case .cancelOverride: description += "Cancel Override command." + case .unknown: + description += "Unknown command." } if let scheduledTime = scheduledTime { @@ -104,13 +122,25 @@ struct CommandPayload: Decodable, Sendable { } extension TrioRemoteControl { - enum CommandType: String, Codable { + enum CommandType: String, CaseIterable, Codable { case bolus case tempTarget = "temp_target" case cancelTempTarget = "cancel_temp_target" case meal case startOverride = "start_override" case cancelOverride = "cancel_override" + case deleteMeal = "delete_meal" + case editMeal = "edit_meal" + /// Command types from newer senders decode to this case and are rejected with an ack. + case unknown + + /// Every command type this build can execute. + static var supported: [CommandType] { allCases.filter { $0 != .unknown } } + + init(from decoder: Decoder) throws { + let rawValue = try decoder.singleValueContainer().decode(String.self) + self = CommandType(rawValue: rawValue) ?? .unknown + } var description: String { switch self { @@ -126,6 +156,12 @@ extension TrioRemoteControl { return "Start Override" case .cancelOverride: return "Cancel Override" + case .deleteMeal: + return "Delete Meal" + case .editMeal: + return "Edit Meal" + case .unknown: + return "Unknown" } } } diff --git a/Trio/Sources/Models/GlucoseAlerts/AlarmSoundCatalog.swift b/Trio/Sources/Models/GlucoseAlerts/AlarmSoundCatalog.swift index 581fb3cd56..9772a5f90c 100644 --- a/Trio/Sources/Models/GlucoseAlerts/AlarmSoundCatalog.swift +++ b/Trio/Sources/Models/GlucoseAlerts/AlarmSoundCatalog.swift @@ -1,11 +1,139 @@ import Foundation -/// Catalog of the bundled critical-alarm sound files (`Trio/Resources/Sounds/`). -/// Ported from Loop's audio-critical-alerts branch. Single source of truth -/// for the alarm sound picker. +/// How disruptive a themed alarm sounds. Intensity is carried by timbre, +/// loudness and length together — each level is its own recording, not one +/// recording at three gain settings. +enum AlarmUrgencyLevel: String, CaseIterable, Identifiable, Codable { + case mild + case medium + case harsh + + var id: String { rawValue } + + var displayName: String { + switch self { + case .mild: return String(localized: "Mild") + case .medium: return String(localized: "Medium") + case .harsh: return String(localized: "Harsh") + } + } +} + +/// Spoken alert voices. +enum AlarmVoice: String, CaseIterable, Identifiable { + case amy + case bryce + + var id: String { rawValue } + + var displayName: String { + switch self { + case .amy: return String(localized: "Amy") + case .bryce: return String(localized: "Bryce") + } + } +} + +/// Catalog of the bundled alarm sound files (`Trio/Resources/Sounds/`). +/// Single source of truth for the alarm sound picker. +/// +/// Themed sounds are written per alarm — the Urgent Low set is not the High +/// Glucose set at another pitch — so each is offered only on the alarm it was +/// written for and leads the list there. Everything else is a generic tone, +/// offered everywhere, tucked under "Other Sounds". +/// +/// The stored value stays a plain filename, so the selected variant is +/// recovered by looking the filename back up and nothing needs migrating. enum AlarmSoundCatalog { - /// (filename, displayName) tuples in display order. - private static let catalog: [(filename: String, displayName: String)] = [ + /// Which alarm a sound is offered on. + enum Scope: Equatable { + case glucose(GlucoseAlertType) + case deviceTier(DeviceAlertSeverity) + } + + /// What the picker reveals once a themed entry is selected. + enum Selector: Equatable { + case none + case urgency + case voice + } + + struct Option: Identifiable, Equatable { + let id: String + let displayName: String + let filename: String + } + + struct Entry: Identifiable, Equatable { + let id: String + let displayName: String + /// Empty means generic: offered on every alarm, under "Other Sounds". + let scopes: [Scope] + let selector: Selector + let options: [Option] + + var isGeneric: Bool { scopes.isEmpty } + var filenames: [String] { options.map(\.filename) } + + func option(forFilename filename: String) -> Option? { + options.first { $0.filename == filename } + } + + /// Middle of the urgency slider, and the set the designer recommends + /// after hallway testing with PWDs and caregivers. + var defaultOption: Option { + options.first { $0.id == AlarmUrgencyLevel.medium.rawValue } ?? options[0] + } + } + + // MARK: - Themed sets + + /// Filename slug per alarm, matching the sound designer's prefixes. + private static let slugs: [(slug: String, scopes: [Scope])] = [ + ("ulg", [.glucose(.urgentLow)]), + ("lg", [.glucose(.low)]), + ("lgs", [.glucose(.forecastedLow)]), + ("hg", [.glucose(.high)]), + ("cr", [.glucose(.carbsRequired)]), + ("dcr", [.deviceTier(.critical)]), + // No set was written for the Normal tier, so it shares the + // Time-Sensitive one, the gentler of the two device sets. + ("dts", [.deviceTier(.timeSensitive), .deviceTier(.normal)]) + ] + + private static func themed(_ theme: String, _ displayName: String) -> [Entry] { + slugs.map { slug, scopes in + Entry( + id: "\(theme).\(slug)", + displayName: displayName, + scopes: scopes, + selector: .urgency, + options: AlarmUrgencyLevel.allCases.map { + Option( + id: $0.rawValue, + displayName: $0.displayName, + filename: "\(theme)_\(slug)_\($0.rawValue).caf" + ) + } + ) + } + } + + private static let voiceEntries: [Entry] = slugs.map { slug, scopes in + Entry( + id: "voice.\(slug)", + displayName: String(localized: "Voice"), + scopes: scopes, + selector: .voice, + options: AlarmVoice.allCases.map { + Option(id: $0.rawValue, displayName: $0.displayName, filename: "voice_\(slug)_\($0.rawValue).caf") + } + ) + } + + // MARK: - Generic tones + + private static let genericEntries: [Entry] = [ ("urgent_low.caf", String(localized: "Urgent Low")), ("critical.caf", String(localized: "Critical")), ("alarm.caf", String(localized: "Alarm")), @@ -23,12 +151,56 @@ enum AlarmSoundCatalog { ("simple.caf", String(localized: "Simple")), ("synth.caf", String(localized: "Synth")), ("mood_synth.caf", String(localized: "Mood Synth")), - ("crying.caf", String(localized: "Crying")) - ] + ("crying.caf", String(localized: "Crying")), + ("glass_bell.caf", String(localized: "Glass Bell")), + ("harp.caf", String(localized: "Harp")), + ("meow.caf", String(localized: "Meow")), + ("saw_wave.caf", String(localized: "Saw Wave")), + ("hard_saw_wave.caf", String(localized: "Hard Saw Wave")), + ("sine_wave.caf", String(localized: "Sine Wave")), + ("open_can.caf", String(localized: "Open Can")), + ("open_wrapper.caf", String(localized: "Open Wrapper")) + ].map { filename, name in + Entry( + id: filename, + displayName: name, + scopes: [], + selector: .none, + options: [Option(id: "default", displayName: name, filename: filename)] + ) + } + + // MARK: - Lookup + + static let entries: [Entry] = themed("acoustic", String(localized: "Acoustic")) + + themed("electronic", String(localized: "Electronic")) + + voiceEntries + + genericEntries + + static let allFilenames: [String] = entries.flatMap(\.filenames) + + /// Themed sets written for this alarm, in display order. + static func themedEntries(for scope: Scope) -> [Entry] { + entries.filter { $0.scopes.contains(scope) } + } + + /// Generic tones, offered on every alarm. + static var genericTones: [Entry] { genericEntries } - static let allFilenames: [String] = catalog.map(\.filename) + static func entry(forFilename filename: String) -> Entry? { + entries.first { $0.filenames.contains(filename) } + } + + /// The themed default for an alarm: Acoustic, Medium. + static func defaultFilename(for scope: Scope) -> String? { + themedEntries(for: scope).first { $0.id.hasPrefix("acoustic.") }?.defaultOption.filename + } static func displayName(for filename: String) -> String { - catalog.first { $0.filename == filename }?.displayName ?? filename + guard let entry = entry(forFilename: filename) else { return filename } + guard entry.selector != .none, let option = entry.option(forFilename: filename) else { + return entry.displayName + } + return "\(entry.displayName) · \(option.displayName)" } } diff --git a/Trio/Sources/Models/GlucoseAlerts/DeviceAlertSeverity.swift b/Trio/Sources/Models/GlucoseAlerts/DeviceAlertSeverity.swift index f2afa91fff..0e91fa20fa 100644 --- a/Trio/Sources/Models/GlucoseAlerts/DeviceAlertSeverity.swift +++ b/Trio/Sources/Models/GlucoseAlerts/DeviceAlertSeverity.swift @@ -58,12 +58,10 @@ enum DeviceAlertSeverity: String, Codable, CaseIterable, Identifiable { } } + /// Acoustic, Medium — the set the sound designer recommends as default + /// after hallway testing. var defaultSoundFilename: String { - switch self { - case .critical: return "high_chimes.caf" - case .timeSensitive: return "chime.caf" - case .normal: return "bloop.caf" - } + AlarmSoundCatalog.defaultFilename(for: .deviceTier(self)) ?? "alarm.caf" } /// Only the Critical tier makes noise out of the box. Device alarms are diff --git a/Trio/Sources/Models/GlucoseAlerts/GlucoseAlertType.swift b/Trio/Sources/Models/GlucoseAlerts/GlucoseAlertType.swift index d50033f6ac..c3b76bc039 100644 --- a/Trio/Sources/Models/GlucoseAlerts/GlucoseAlertType.swift +++ b/Trio/Sources/Models/GlucoseAlerts/GlucoseAlertType.swift @@ -78,14 +78,11 @@ enum GlucoseAlertType: String, Codable, CaseIterable, Identifiable { } /// Default bundled sound filename. See `Trio/Resources/Sounds/`. + /// Acoustic, Medium — the set the sound designer recommends as default + /// after hallway testing. Falls back to the old generic tone only if the + /// themed set is ever missing. var defaultSoundFilename: String { - switch self { - case .urgentLow: return "urgent_low.caf" - case .low: return "trill.caf" - case .forecastedLow: return "bloom.caf" - case .high: return "chime.caf" - case .carbsRequired: return "bloop.caf" - } + AlarmSoundCatalog.defaultFilename(for: .glucose(self)) ?? "alarm.caf" } /// Default for `GlucoseAlert.overridesSilenceAndDND` when adding a new diff --git a/Trio/Sources/Models/HomeStatsPanelFace.swift b/Trio/Sources/Models/HomeStatsPanelFace.swift index 45678180ab..cd612a361e 100644 --- a/Trio/Sources/Models/HomeStatsPanelFace.swift +++ b/Trio/Sources/Models/HomeStatsPanelFace.swift @@ -23,3 +23,83 @@ enum HomeStatsPanelFace: String, JSON, CaseIterable, Identifiable, Codable, Hash } } } + +/// Span the home stats panel reports on; the Statistics screen opens on the same span. +enum HomeStatsPanelRange: String, JSON, CaseIterable, Identifiable, Codable, Hashable { + var id: String { rawValue } + case today + case day + case week + case month + case threeMonths + + var displayName: String { + switch self { + case .today: + return String(localized: "Today") + case .day: + return String(localized: "Last 24 Hours", comment: "Home stats panel range option") + case .week: + return String(localized: "Last 7 Days", comment: "Home stats panel range option") + case .month: + return String(localized: "Last Month", comment: "Home stats panel range option") + case .threeMonths: + return String(localized: "Last 3 Months", comment: "Home stats panel range option") + } + } + + /// Trailing scope, as in "Time in Range today". + var scopeName: String { + switch self { + case .today: + return String(localized: "today", comment: "Stats banner scope") + case .day: + return String(localized: "last 24 hours", comment: "Stats banner scope") + case .week: + return String(localized: "last 7 days", comment: "Stats banner scope") + case .month: + return String(localized: "last month", comment: "Stats banner scope") + case .threeMonths: + return String(localized: "last 3 months", comment: "Stats banner scope") + } + } + + /// Subtitle of the averages face. + var averageSubtitle: String { + switch self { + case .today: + return String(localized: "Today's Average", comment: "Stats banner subtitle") + case .day: + return String(localized: "Average of the Last 24 Hours", comment: "Stats banner subtitle") + case .week: + return String(localized: "Average of the Last 7 Days", comment: "Stats banner subtitle") + case .month: + return String(localized: "Average of the Last Month", comment: "Stats banner subtitle") + case .threeMonths: + return String(localized: "Average of the Last 3 Months", comment: "Stats banner subtitle") + } + } + + /// Leading edge of the span. Same calendar arithmetic as the Statistics screen's + /// glucose fetches (`Date.oneDayAgo` … `Date.threeMonthsAgo`), so the numbers match. + func startDate(relativeTo now: Date = Date()) -> Date { + let calendar = Calendar.current + switch self { + case .today: + return calendar.startOfDay(for: now) + case .day: + return calendar.date(byAdding: .day, value: -1, to: now) ?? now + case .week: + return calendar.date(byAdding: .day, value: -7, to: now) ?? now + case .month: + return calendar.date(byAdding: .month, value: -1, to: now) ?? now + case .threeMonths: + return calendar.date(byAdding: .month, value: -3, to: now) ?? now + } + } + + /// Whether the chart's in-memory glucose (72 h) already covers the span. + var isCoveredByChartHistory: Bool { + self == .today || self == .day + } +} diff --git a/Trio/Sources/Models/NightscoutStatus.swift b/Trio/Sources/Models/NightscoutStatus.swift index f4c06557ee..72f5ecc0a9 100644 --- a/Trio/Sources/Models/NightscoutStatus.swift +++ b/Trio/Sources/Models/NightscoutStatus.swift @@ -63,6 +63,8 @@ struct NightscoutProfileStore: JSON { let overridePresets: [NightscoutPresetOverride]? let teamID: String let expirationDate: Date? + /// Remote command types this build accepts; followers hide unsupported actions. + let remoteCommands: [String]? } struct NightscoutPresetOverride: JSON { diff --git a/Trio/Sources/Models/TrioSettings.swift b/Trio/Sources/Models/TrioSettings.swift index eab40385f4..5a3ddc88e7 100644 --- a/Trio/Sources/Models/TrioSettings.swift +++ b/Trio/Sources/Models/TrioSettings.swift @@ -89,11 +89,13 @@ struct TrioSettings: JSON, Equatable, Encodable { var useLiveActivity: Bool = false var lockScreenView: LockScreenView = .simple var smartStackView: LockScreenView = .simple + var liveActivitySimpleFontSize: LiveActivityFontSize = .large var displayGlucoseForecasts: Bool = false var bolusShortcut: BolusShortcutLimit = .notAllowed var timeInRangeType: TimeInRangeType = .timeInTightRange var showGlucosePeaks: Bool = false var useChartBars: Bool = true + var homeStatsPanelRange: HomeStatsPanelRange = .today var requireAdjustmentsConfirmation: Bool = false var showCgmSensorStatus: Bool = true @@ -400,6 +402,12 @@ extension TrioSettings: Decodable { settings.smartStackView = smartStackView } + if let liveActivitySimpleFontSize = try? container + .decode(LiveActivityFontSize.self, forKey: .liveActivitySimpleFontSize) + { + settings.liveActivitySimpleFontSize = liveActivitySimpleFontSize + } + if let displayGlucoseForecasts = try? container.decode(Bool.self, forKey: .displayGlucoseForecasts) { settings.displayGlucoseForecasts = displayGlucoseForecasts } @@ -420,6 +428,10 @@ extension TrioSettings: Decodable { settings.useChartBars = useChartBars } + if let homeStatsPanelRange = try? container.decode(HomeStatsPanelRange.self, forKey: .homeStatsPanelRange) { + settings.homeStatsPanelRange = homeStatsPanelRange + } + if let requireAdjustmentsConfirmation = try? container.decode(Bool.self, forKey: .requireAdjustmentsConfirmation) { settings.requireAdjustmentsConfirmation = requireAdjustmentsConfirmation } diff --git a/Trio/Sources/Modules/DeviceAlarms/View/DeviceAlarmEditorView.swift b/Trio/Sources/Modules/DeviceAlarms/View/DeviceAlarmEditorView.swift index 7ac121b58b..227ec90683 100644 --- a/Trio/Sources/Modules/DeviceAlarms/View/DeviceAlarmEditorView.swift +++ b/Trio/Sources/Modules/DeviceAlarms/View/DeviceAlarmEditorView.swift @@ -57,7 +57,8 @@ struct DeviceAlarmEditorView: View { AlarmActiveSection(activeOption: $working.activeOption) AlarmAudioSection( playsSound: $working.playsSound, - soundFilename: $working.soundFilename + soundFilename: $working.soundFilename, + scope: .deviceTier(working.severity) ) Section(header: Text("Applies To (Cannot be changed)")) { diff --git a/Trio/Sources/Modules/GlucoseAlerts/View/Components/AlarmAudioSection.swift b/Trio/Sources/Modules/GlucoseAlerts/View/Components/AlarmAudioSection.swift index 4b30439f98..3004c796ba 100644 --- a/Trio/Sources/Modules/GlucoseAlerts/View/Components/AlarmAudioSection.swift +++ b/Trio/Sources/Modules/GlucoseAlerts/View/Components/AlarmAudioSection.swift @@ -4,6 +4,8 @@ import SwiftUI struct AlarmAudioSection: View { @Binding var playsSound: Bool @Binding var soundFilename: String + /// Which alarm is being edited — decides which themed sets are offered. + let scope: AlarmSoundCatalog.Scope @State private var showTonePicker = false @@ -27,7 +29,7 @@ struct AlarmAudioSection: View { } .buttonStyle(.plain) .sheet(isPresented: $showTonePicker) { - TonePickerSheet(selected: $soundFilename) + TonePickerSheet(selected: $soundFilename, scope: scope) } } } @@ -37,21 +39,40 @@ struct AlarmAudioSection: View { private struct TonePickerSheet: View { @Binding var selected: String + let scope: AlarmSoundCatalog.Scope + @Environment(\.dismiss) private var dismiss @Environment(\.colorScheme) private var colorScheme @Environment(AppState.self) private var appState @StateObject private var previewer = SoundPreviewPlayer() + /// Start expanded when the current pick lives in there, so the selection + /// is never hidden behind a closed group. + @State private var showOtherSounds: Bool + + init(selected: Binding, scope: AlarmSoundCatalog.Scope) { + _selected = selected + self.scope = scope + _showOtherSounds = State( + initialValue: AlarmSoundCatalog.entry(forFilename: selected.wrappedValue)?.isGeneric ?? false + ) + } var body: some View { NavigationStack { List { Section { - ForEach(AlarmSoundCatalog.allFilenames, id: \.self) { filename in - TonePickerRow( - filename: filename, - selected: $selected, - previewer: previewer - ) + ForEach(AlarmSoundCatalog.themedEntries(for: scope)) { entry in + TonePickerRow(entry: entry, selected: $selected, previewer: previewer) + } + }.listRowBackground(Color.chart) + + Section { + DisclosureGroup(isExpanded: $showOtherSounds) { + ForEach(AlarmSoundCatalog.genericTones) { entry in + TonePickerRow(entry: entry, selected: $selected, previewer: previewer) + } + } label: { + Text("Other Sounds") } }.listRowBackground(Color.chart) } @@ -76,26 +97,48 @@ private struct TonePickerSheet: View { } private struct TonePickerRow: View { - let filename: String + let entry: AlarmSoundCatalog.Entry @Binding var selected: String @ObservedObject var previewer: SoundPreviewPlayer + private var isSelected: Bool { entry.filenames.contains(selected) } + + /// The file this row stands for: the picked variant while selected, + /// otherwise the entry's default. + private var filename: String { + isSelected ? selected : entry.defaultOption.filename + } + + private var option: AlarmSoundCatalog.Option { + entry.option(forFilename: filename) ?? entry.defaultOption + } + var body: some View { + VStack(alignment: .leading, spacing: 10) { + row + if isSelected, entry.selector != .none { + variantControl + } + } + .animation(.easeInOut(duration: 0.15), value: isSelected) + } + + private var row: some View { HStack(spacing: 12) { Button { selected = filename } label: { - Image(systemName: filename == selected ? "checkmark.circle.fill" : "circle") + Image(systemName: isSelected ? "checkmark.circle.fill" : "circle") .foregroundColor(Color.tabBar) .frame(width: 20) - Text(AlarmSoundCatalog.displayName(for: filename)) + Text(entry.displayName) .foregroundColor(.primary) .frame(maxWidth: .infinity, alignment: .leading) .contentShape(Rectangle()) } .buttonStyle(.plain) - .accessibilityAddTraits(filename == selected ? [.isButton, .isSelected] : .isButton) + .accessibilityAddTraits(isSelected ? [.isButton, .isSelected] : .isButton) Spacer() @@ -117,6 +160,77 @@ private struct TonePickerRow: View { } } + @ViewBuilder private var variantControl: some View { + switch entry.selector { + case .urgency: urgencySlider + case .voice: voicePicker + case .none: EmptyView() + } + } + + /// Discrete Mild / Medium / Harsh. Changing it auditions the new level — + /// the point is hearing how the character changes. + private var urgencySlider: some View { + VStack(alignment: .leading, spacing: 2) { + HStack { + Text("Urgency Level") + Spacer() + Text(option.displayName).foregroundColor(.secondary) + } + .font(.footnote) + + Slider( + value: Binding( + get: { Double(entry.options.firstIndex(of: option) ?? 0) }, + set: { raw in + let index = min(max(Int(raw.rounded()), 0), entry.options.count - 1) + select(entry.options[index]) + } + ), + in: 0 ... Double(max(entry.options.count - 1, 1)), + step: 1 + ) + .padding(.vertical) + .tint(Color.tabBar) + .accessibilityLabel(Text("Urgency Level")) + .accessibilityValue(Text(option.displayName)) + + HStack { + ForEach(Array(entry.options.enumerated()), id: \.element.id) { index, item in + if index > 0 { Spacer() } + Text(item.displayName) + .font(.caption2) + .foregroundColor(.secondary) + } + } + .accessibilityHidden(true) + } + .padding(.leading, 32) + } + + private var voicePicker: some View { + VStack(alignment: .leading, spacing: 4) { + Text("Select a Voice").font(.footnote) + Picker("", selection: Binding( + get: { option.id }, + set: { id in + guard let match = entry.options.first(where: { $0.id == id }) else { return } + select(match) + } + )) { + ForEach(entry.options) { Text($0.displayName).tag($0.id) } + } + .pickerStyle(.segmented) + } + .padding(.leading, 32) + } + + private func select(_ option: AlarmSoundCatalog.Option) { + guard option.filename != selected else { return } + selected = option.filename + previewer.play(filename: option.filename) + } + private var isPlaying: Bool { previewer.playing == filename } } diff --git a/Trio/Sources/Modules/GlucoseAlerts/View/GlucoseAlertEditorView.swift b/Trio/Sources/Modules/GlucoseAlerts/View/GlucoseAlertEditorView.swift index 068d922244..f38181ac61 100644 --- a/Trio/Sources/Modules/GlucoseAlerts/View/GlucoseAlertEditorView.swift +++ b/Trio/Sources/Modules/GlucoseAlerts/View/GlucoseAlertEditorView.swift @@ -61,7 +61,8 @@ struct GlucoseAlertEditorView: View { ) AlarmAudioSection( playsSound: $working.playsSound, - soundFilename: $working.soundFilename + soundFilename: $working.soundFilename, + scope: .glucose(working.type) ) if !isNew, store.canDelete(working) { diff --git a/Trio/Sources/Modules/History/HistoryDataFlow.swift b/Trio/Sources/Modules/History/HistoryDataFlow.swift index 3217ffab4b..5b721d50de 100644 --- a/Trio/Sources/Modules/History/HistoryDataFlow.swift +++ b/Trio/Sources/Modules/History/HistoryDataFlow.swift @@ -88,12 +88,9 @@ enum History { } protocol HistoryProvider: Provider { - func deleteCarbsFromNightscout(withID id: String) func deleteInsulinFromNightscout(withID id: String) func deleteGlucoseFromNightscout(withID id: String, withDate date: Date) func deleteGlucoseFromHealth(withSyncID id: String) - func deleteMealDataFromHealth(byID id: String, sampleType: HKSampleType) func deleteInsulinFromHealth(withSyncID id: String) func deleteInsulinFromTidepool(withSyncId id: String, amount: Decimal, at: Date) - func deleteCarbsFromTidepool(withSyncId id: UUID, carbs: Decimal, at: Date, enteredBy: String) } diff --git a/Trio/Sources/Modules/History/HistoryProvider.swift b/Trio/Sources/Modules/History/HistoryProvider.swift index 4b361c171d..eb1073f0c8 100644 --- a/Trio/Sources/Modules/History/HistoryProvider.swift +++ b/Trio/Sources/Modules/History/HistoryProvider.swift @@ -14,13 +14,6 @@ extension History { ?? PumpSettings(insulinActionCurve: 10, maxBolus: 10, maxBasal: 2) } - func deleteCarbsFromNightscout(withID id: String) { - Task.detached { [weak self] in - guard let self = self else { return } - await self.nightscoutManager.deleteCarbs(withID: id) - } - } - func deleteInsulinFromNightscout(withID id: String) { Task.detached { [weak self] in guard let self = self else { return } @@ -49,19 +42,8 @@ extension History { } } - func deleteMealDataFromHealth(byID id: String, sampleType: HKSampleType) { - Task.detached { [weak self] in - guard let self = self else { return } - await self.healthkitManager.deleteMealData(byID: id, sampleType: sampleType) - } - } - func deleteInsulinFromTidepool(withSyncId id: String, amount: Decimal, at: Date) { tidepoolManager.deleteInsulin(withSyncId: id, amount: amount, at: at) } - - func deleteCarbsFromTidepool(withSyncId id: UUID, carbs: Decimal, at: Date, enteredBy: String) { - tidepoolManager.deleteCarbs(withSyncId: id, carbs: carbs, at: at, enteredBy: enteredBy) - } } } diff --git a/Trio/Sources/Modules/History/HistoryStateModel+Deletion/HistoryStateModel+CarbEditing.swift b/Trio/Sources/Modules/History/HistoryStateModel+Deletion/HistoryStateModel+CarbEditing.swift index eef8a30d88..e51b02be9b 100644 --- a/Trio/Sources/Modules/History/HistoryStateModel+Deletion/HistoryStateModel+CarbEditing.swift +++ b/Trio/Sources/Modules/History/HistoryStateModel+Deletion/HistoryStateModel+CarbEditing.swift @@ -4,9 +4,9 @@ import Foundation extension History.StateModel { // MARK: - Entry Management - /// Updates a carb/FPU entry with new values and handles the necessary cleanup and recreation of FPU entries + /// Replaces a meal (root entry plus carb equivalents) with new values. /// - Parameters: - /// - treatmentObjectID: The ID of the entry to update + /// - treatmentObjectID: The ID of the meal root entry /// - newCarbs: The new carbs value /// - newFat: The new fat value /// - newProtein: The new protein value @@ -22,28 +22,20 @@ extension History.StateModel { ) { Task { do { - // Get original date from entry to re-create the entry later with the updated values and the same date - guard let originalEntry = await getOriginalEntryValues(treatmentObjectID) else { return } - - // Deletion logic for carb and FPU entries - try await deleteOldEntries( - treatmentObjectID, - originalEntry: originalEntry, - newCarbs: newCarbs, - newFat: newFat, - newProtein: newProtein, - newNote: newNote - ) - - try await createNewEntries( - originalDate: newDate, - newCarbs: newCarbs, - newFat: newFat, - newProtein: newProtein, - newNote: newNote + let newEntry = CarbsEntry( + id: UUID().uuidString, + createdAt: Date(), + actualDate: newDate, + carbs: newCarbs, + fat: newFat, + protein: newProtein, + note: newNote, + enteredBy: CarbsEntry.local, + isFPU: false, + fpuID: newFat > 0 || newProtein > 0 ? UUID().uuidString : nil ) - await syncWithServices() + _ = try await carbEntryMutationService.replaceMeal(rootObjectID: treatmentObjectID, with: newEntry) // Perform a determine basal sync to update cob try await apsManager.determineBasalSync() @@ -54,104 +46,6 @@ extension History.StateModel { } } - private func createNewEntries( - originalDate: Date, - newCarbs: Decimal, - newFat: Decimal, - newProtein: Decimal, - newNote: String - ) async throws { - let newEntry = CarbsEntry( - id: UUID().uuidString, - createdAt: Date(), - actualDate: originalDate, - carbs: newCarbs, - fat: newFat, - protein: newProtein, - note: newNote, - enteredBy: CarbsEntry.local, - isFPU: false, - fpuID: newFat > 0 || newProtein > 0 ? UUID().uuidString : nil - ) - - // Handles internally whether to create fake carbs or not based on whether fat > 0 or protein > 0 - try await carbsStorage.storeCarbs([newEntry], areFetchedFromRemote: false) - } - - /// Deletes the old carb/ FPU entries and creates new ones with updated values - /// - Parameters: - /// - treatmentObjectID: The ID of the entry to delete - /// - originalDate: The original date to preserve - /// - newCarbs: The new carbs value - /// - newFat: The new fat value - /// - newProtein: The new protein value - /// - newNote: The new note text - private func deleteOldEntries( - _ treatmentObjectID: NSManagedObjectID, - originalEntry: ( - entryValues: (date: Date, carbs: Double, fat: Double, protein: Double)?, - entryId: NSManagedObjectID - ), - newCarbs _: Decimal, - newFat _: Decimal, - newProtein _: Decimal, - newNote _: String - ) async throws { - if ((originalEntry.entryValues?.carbs ?? 0) == 0 && (originalEntry.entryValues?.fat ?? 0) > 0) || - ((originalEntry.entryValues?.carbs ?? 0) == 0 && (originalEntry.entryValues?.protein ?? 0) > 0) - { - // Delete the zero-carb-entry and all its carb equivalents connected by the same fpuID from remote services and Core Data - // Use fpuID - try await deleteCarbs(treatmentObjectID, isFpuOrComplexMeal: true) - } else if ((originalEntry.entryValues?.carbs ?? 0) > 0 && (originalEntry.entryValues?.fat ?? 0) > 0) || - ((originalEntry.entryValues?.carbs ?? 0) > 0 && (originalEntry.entryValues?.protein ?? 0) > 0) - { - // Delete carb entry and carb equivalents that are all connected by the same fpuID from remote services and Core Data - // Use fpuID - try await deleteCarbs(treatmentObjectID, isFpuOrComplexMeal: true) - - } else { - // Delete just the carb entry since there are no carb equivalents - // Use NSManagedObjectID - try await deleteCarbs(treatmentObjectID) - } - } - - /// Retrieves the original entry values - /// - Parameter objectID: The ID of the entry - /// - Returns: A tuple of the old entry values and its original date and the objectID or nil - private func getOriginalEntryValues(_ objectID: NSManagedObjectID) async - -> (entryValues: (date: Date, carbs: Double, fat: Double, protein: Double)?, entryId: NSManagedObjectID)? - { - let context = CoreDataStack.shared.newTaskContext() - context.name = "updateContext" - context.transactionAuthor = "updateEntry" - - return await context.perform { - do { - guard let entry = try context.existingObject(with: objectID) as? CarbEntryStored, let entryDate = entry.date - else { return nil } - - return ( - entryValues: (date: entryDate, carbs: entry.carbs, fat: entry.fat, protein: entry.protein), - entryId: entry.objectID - ) - } catch let error as NSError { - debugPrint("\(DebuggingIdentifiers.failed) Failed to get original date with error: \(error.userInfo)") - return nil - } - } - } - - /// Synchronizes the FPU/ Carb entry with all remote services in parallel - private func syncWithServices() async { - async let nightscoutUpload: () = provider.nightscoutManager.uploadCarbs() - async let healthKitUpload: () = provider.healthkitManager.uploadCarbs() - async let tidepoolUpload: () = provider.tidepoolManager.uploadCarbs() - - _ = await [nightscoutUpload, healthKitUpload, tidepoolUpload] - } - // MARK: - Entry Loading /// Loads the values of a carb or FPU entry from Core Data diff --git a/Trio/Sources/Modules/History/HistoryStateModel+Deletion/HistoryStateModel+Carbs.swift b/Trio/Sources/Modules/History/HistoryStateModel+Deletion/HistoryStateModel+Carbs.swift index 33e9607ea6..831496852d 100644 --- a/Trio/Sources/Modules/History/HistoryStateModel+Deletion/HistoryStateModel+Carbs.swift +++ b/Trio/Sources/Modules/History/HistoryStateModel+Deletion/HistoryStateModel+Carbs.swift @@ -1,6 +1,5 @@ import CoreData import Foundation -import HealthKit extension History.StateModel { // Carb and FPU deletion from history @@ -27,93 +26,21 @@ extension History.StateModel { } } + /// Deletes the meal the tapped row belongs to from all services and Core Data, then refreshes COB. func deleteCarbs(_ treatmentObjectID: NSManagedObjectID, isFpuOrComplexMeal: Bool = false) async throws { - // Delete from Nightscout/Apple Health/Tidepool - await deleteFromServices(treatmentObjectID, isFPUDeletion: isFpuOrComplexMeal) - - // Delete carbs from Core Data - await carbsStorage.deleteCarbsEntryStored(treatmentObjectID) - - // Perform a determine basal sync to update cob - try await apsManager.determineBasalSync() - } - - /// Deletes carb and FPU entries from all connected services (Nightscout, HealthKit, Tidepool) - /// - Parameters: - /// - treatmentObjectID: The Core Data object ID of the entry to delete - /// - isFPUDeletion: Flag indicating if this is a FPU deletion that requires special handling - /// - If true: Will first fetch the corresponding carb entry and then delete both FPU and carb entries - /// - If false: Will delete the entry directly as a standard carb deletion - /// - Note: This function handles three scenarios: - /// 1. Standard carb deletion (isFPUDeletion = false) - /// 2. FPU-only deletion (isFPUDeletion = true) - /// 3. Combined carb+FPU deletion (isFPUDeletion = true) - func deleteFromServices(_ treatmentObjectID: NSManagedObjectID, isFPUDeletion: Bool = false) async { - let taskContext = CoreDataStack.shared.newTaskContext() - taskContext.name = "deleteContext" - taskContext.transactionAuthor = "deleteCarbsFromServices" - - var carbEntry: CarbEntryStored? - var objectIDToDelete = treatmentObjectID - - // For FPU deletions, first get the corresponding carb entry - if isFPUDeletion { - guard let correspondingEntry: ( - entryValues: (carbs: Decimal, fat: Decimal, protein: Decimal, note: String, date: Date)?, - entryID: NSManagedObjectID? - ) = await handleFPUEntry(treatmentObjectID), - let nsManagedObjectID = correspondingEntry.entryID - else { return } - - objectIDToDelete = nsManagedObjectID + var rootObjectID = treatmentObjectID + if isFpuOrComplexMeal, let root = await handleFPUEntry(treatmentObjectID)?.entryID { + rootObjectID = root } - // Delete entries from all services - await taskContext.perform { - do { - carbEntry = try taskContext.existingObject(with: objectIDToDelete) as? CarbEntryStored - guard let carbEntry = carbEntry else { - debugPrint("Carb entry for deletion not found. \(DebuggingIdentifiers.failed)") - return - } - - // Delete FPU related entries if they exist - if let fpuID = carbEntry.fpuID { - // Delete Fat and Protein entries from Nightscout - self.provider.deleteCarbsFromNightscout(withID: fpuID.uuidString) - - // Delete Fat and Protein entries from Apple Health - let healthObjectsToDelete: [HKSampleType?] = [ - AppleHealthConfig.healthFatObject, - AppleHealthConfig.healthProteinObject - ] - - for sampleType in healthObjectsToDelete { - if let validSampleType = sampleType { - self.provider.deleteMealDataFromHealth(byID: fpuID.uuidString, sampleType: validSampleType) - } - } - } - - // Delete carb entries if they exist - if let id = carbEntry.id, let entryDate = carbEntry.date { - self.provider.deleteCarbsFromNightscout(withID: id.uuidString) - - // Delete carbs from Apple Health - if let sampleType = AppleHealthConfig.healthCarbObject { - self.provider.deleteMealDataFromHealth(byID: id.uuidString, sampleType: sampleType) - } - - self.provider.deleteCarbsFromTidepool( - withSyncId: id, - carbs: Decimal(carbEntry.carbs), - at: entryDate, - enteredBy: CarbsEntry.local - ) - } - } catch { - debugPrint("\(DebuggingIdentifiers.failed) Error deleting entries: \(error)") - } + do { + _ = try await carbEntryMutationService.deleteMeal(rootObjectID: rootObjectID) + } catch let error as CarbEntryMutationError { + // Rows without a resolvable meal root are removed locally only, so they leave COB. + debug(.default, "\(DebuggingIdentifiers.failed) Meal root unavailable (\(error)), deleting local rows only") + await carbsStorage.deleteCarbsEntryStored(treatmentObjectID) } + + try await apsManager.determineBasalSync() } } diff --git a/Trio/Sources/Modules/History/HistoryStateModel.swift b/Trio/Sources/Modules/History/HistoryStateModel.swift index 1a051c1db7..a3f79f03b7 100644 --- a/Trio/Sources/Modules/History/HistoryStateModel.swift +++ b/Trio/Sources/Modules/History/HistoryStateModel.swift @@ -14,6 +14,7 @@ extension History { @ObservationIgnored @Injected() var glucoseStorage: GlucoseStorage! @ObservationIgnored @Injected() var healthKitManager: HealthKitManager! @ObservationIgnored @Injected() var carbsStorage: CarbsStorage! + @ObservationIgnored @Injected() var carbEntryMutationService: CarbEntryMutationService! var mode: Mode = .treatments var treatments: [Treatment] = [] diff --git a/Trio/Sources/Modules/Home/HomeStateModel+Setup/GlucoseSetup.swift b/Trio/Sources/Modules/Home/HomeStateModel+Setup/GlucoseSetup.swift index a0e2f68a57..a86d0923c5 100644 --- a/Trio/Sources/Modules/Home/HomeStateModel+Setup/GlucoseSetup.swift +++ b/Trio/Sources/Modules/Home/HomeStateModel+Setup/GlucoseSetup.swift @@ -23,6 +23,7 @@ extension Home.StateModel { latestTwoGlucoseValues = Array(objects.suffix(2)) updateGlucoseChartYAxis(glucoseValues: objects) updateGlucosePeaks() + refreshExtendedStatsPanel() } /// Re-picks the glucose turning points for the current data and zoom. The picker @@ -46,26 +47,125 @@ extension Home.StateModel { } } - /// Today's glucose range distribution for the stats banner. - var todayGlucoseDistribution: GlucoseDailyDistributionStats { - let startOfDay = Calendar.current.startOfDay(for: Date()) + /// Stats panel figures over the configured range. + /// + /// Today and the last 24 hours come straight from the chart's in-memory readings; + /// longer ranges use the result of the last background refresh. + var statsPanelStats: HomeStatsPanelStats { + let range = homeStatsPanelRange + guard range.isCoveredByChartHistory else { + // until a refresh for this range lands, show no data rather than another range's + guard let extendedStatsPanel, extendedStatsPanel.range == range else { return HomeStatsPanelStats() } + return extendedStatsPanel.stats + } + + let startDate = range.startDate() let readings = glucoseFromPersistence - .filter { ($0.date ?? .distantPast) >= startOfDay } - .map { GlucoseReading(value: Int($0.glucose), date: $0.date ?? startOfDay) } + .filter { ($0.date ?? .distantPast) >= startDate } + .map { GlucoseReading(value: Int($0.glucose), date: $0.date ?? startDate) } // first render happens before service injection let timeInRangeType = settingsManager?.settings.timeInRangeType ?? .timeInTightRange - return GlucoseDailyDistributionStats.compute( - date: startOfDay, + return HomeStatsPanelStats(readings: readings, startDate: startDate, timeInRangeType: timeInRangeType) + } + + /// Recomputes `extendedStatsPanel` for ranges the chart's 72 h of glucose doesn't cover. + /// + /// Those read up to three months of readings, so the fetch and the math run on a task + /// context. New readings barely move a week-or-longer figure, so unforced refreshes + /// (one per glucose update, every minute on some CGMs) are throttled. + @MainActor func refreshExtendedStatsPanel(force: Bool = false) { + let range = homeStatsPanelRange + let face = settingsManager?.settings.homeStatsPanelFace ?? .timeInRange + + guard !range.isCoveredByChartHistory, face != .hidden else { + extendedStatsPanelTask?.cancel() + if extendedStatsPanel != nil { extendedStatsPanel = nil } + return + } + + if !force, extendedStatsPanel?.range == range, let refreshedAt = extendedStatsPanelRefreshedAt, + Date().timeIntervalSince(refreshedAt) < Self.extendedStatsPanelRefreshInterval + { + return + } + + let timeInRangeType = settingsManager?.settings.timeInRangeType ?? .timeInTightRange + extendedStatsPanelTask?.cancel() + extendedStatsPanelTask = Task { [weak self] in + do { + let stats = try await Self.computeStatsPanelStats(range: range, timeInRangeType: timeInRangeType) + guard let self, !Task.isCancelled else { return } + self.extendedStatsPanel = (range: range, stats: stats) + self.extendedStatsPanelRefreshedAt = Date() + } catch { + debug(.default, "\(DebuggingIdentifiers.failed) Failed to compute home stats panel: \(error)") + } + } + } + + private static let extendedStatsPanelRefreshInterval: TimeInterval = 5 * 60 + + private static func computeStatsPanelStats( + range: HomeStatsPanelRange, + timeInRangeType: TimeInRangeType + ) async throws -> HomeStatsPanelStats { + let context = CoreDataStack.shared.newTaskContext() + context.name = "HomeStateModel.computeStatsPanelStats" + + let startDate = range.startDate() + // dictionary rows keep three months of readings light + let request = NSFetchRequest(entityName: "GlucoseStored") + request.predicate = NSPredicate(format: "date >= %@", startDate as NSDate) + request.resultType = .dictionaryResultType + request.propertiesToFetch = ["glucose", "date"] + + return try await context.perform { + let readings = try context.fetch(request).compactMap { row -> GlucoseReading? in + guard let value = (row["glucose"] as? NSNumber)?.intValue, let date = row["date"] as? Date else { return nil } + return GlucoseReading(value: value, date: date) + } + return HomeStatsPanelStats(readings: readings, startDate: startDate, timeInRangeType: timeInRangeType) + } + } +} + +extension Home.StateModel { + func addManualGlucose(_ amount: Decimal) { + let glucose = units == .mmolL ? amount.asMgdL : amount + glucoseStorage.addManualGlucose(glucose: Int(glucose)) + } +} + +/// What the home stats panel shows for its range. +struct HomeStatsPanelStats: Sendable { + var veryLowPct: Double = 0 + var lowPct: Double = 0 + var inRangePct: Double = 0 + var highPct: Double = 0 + var veryHighPct: Double = 0 + /// Mean glucose in mg/dL, nil without readings. + var meanGlucose: Double? + + var hasData: Bool { meanGlucose != nil } + + init() {} + + init(readings: [GlucoseReading], startDate: Date, timeInRangeType: TimeInRangeType) { + guard !readings.isEmpty else { return } + + let distribution = GlucoseDailyDistributionStats.compute( + date: startDate, readings: readings, // fixed consensus TIR bound (StatStateModel.highLimit), not the user's // chart threshold, so the banner always matches the Stats screen highLimit: 180, timeInRangeType: timeInRangeType ) - } - - func addManualGlucose(_ amount: Decimal) { - let glucose = units == .mmolL ? amount.asMgdL : amount - glucoseStorage.addManualGlucose(glucose: Int(glucose)) + veryLowPct = distribution.veryLowPct + lowPct = distribution.lowPct + inRangePct = distribution.inRangePct + highPct = distribution.highPct + veryHighPct = distribution.veryHighPct + meanGlucose = Double(readings.reduce(0) { $0 + $1.value }) / Double(readings.count) } } diff --git a/Trio/Sources/Modules/Home/HomeStateModel.swift b/Trio/Sources/Modules/Home/HomeStateModel.swift index dd7a94210e..b72983c0dd 100644 --- a/Trio/Sources/Modules/Home/HomeStateModel.swift +++ b/Trio/Sources/Modules/Home/HomeStateModel.swift @@ -117,6 +117,10 @@ extension Home { var determinationsFromSuggestion: [OrefDetermination] = [] var enactedAndNonEnactedDeterminations: [OrefDetermination] = [] var fetchedTDDs: [TDD] = [] + var homeStatsPanelRange: HomeStatsPanelRange = .today + /// Stats panel figures for ranges longer than the chart's glucose window, tagged with + /// the range they were computed for; see `refreshExtendedStatsPanel(force:)`. + var extendedStatsPanel: (range: HomeStatsPanelRange, stats: HomeStatsPanelStats)? var insulinFromPersistence: [PumpEventStored] = [] var tempBasals: [PumpEventStored] = [] var suspendAndResumeEvents: [PumpEventStored] = [] @@ -502,6 +506,10 @@ extension Home { /// which a manual re-determine fires twice in quick succession. @ObservationIgnored var forecastUpdateTask: Task? + /// In-flight fetch for `extendedStatsPanel`; a newer refresh cancels it so results land in order. + @ObservationIgnored var extendedStatsPanelTask: Task? + @ObservationIgnored var extendedStatsPanelRefreshedAt: Date? + typealias PumpEvent = PumpEventStored.EventType override init() { @@ -775,6 +783,7 @@ extension Home { lowTTlowersSens = settingsManager.preferences.lowTemptargetLowersSensitivity settingHalfBasalTarget = settingsManager.preferences.halfBasalExerciseTarget maxIOB = settingsManager.preferences.maxIOB + homeStatsPanelRange = settingsManager.settings.homeStatsPanelRange } @MainActor private func setupCGMSettings() async { @@ -1084,6 +1093,11 @@ extension Home.StateModel: showCgmSensorStatus = settingsManager.settings.showCgmSensorStatus enableQuickPickTreatments = settingsManager.settings.enableQuickPickTreatments forecastDisplayType = settingsManager.settings.forecastDisplayType + homeStatsPanelRange = settingsManager.settings.homeStatsPanelRange + // range, face or time-in-range type may have changed + Task { @MainActor in + refreshExtendedStatsPanel(force: true) + } cgmAvailable = (fetchGlucoseManager.cgmGlucoseSourceType != CGMType.none) displayPumpStatusHighlightMessage() displayPumpStatusBadge() diff --git a/Trio/Sources/Modules/Home/View/Chart/ChartElements/BasalChart.swift b/Trio/Sources/Modules/Home/View/Chart/ChartElements/BasalChart.swift index 5b4afa813c..e8f983440b 100644 --- a/Trio/Sources/Modules/Home/View/Chart/ChartElements/BasalChart.swift +++ b/Trio/Sources/Modules/Home/View/Chart/ChartElements/BasalChart.swift @@ -15,6 +15,13 @@ struct BasalProfile: Hashable { } } +struct PreparedTempBasal: Equatable { + let start: Date + let end: Date + let rate: Double + let isScheduled: Bool +} + extension MainChartCanvas { var basalChart: some View { VStack { @@ -42,16 +49,22 @@ extension MainChartCanvas { /// (drawn at `basalDomainMax - rate`), so the tallest rate spans the full strip height — /// matching the old rendering, which achieved the same look by rotating and mirroring /// the plot content. - var basalDomainMax: Double { - let tempMax = preparedTempBasals.map(\.rate).max() ?? 0 - let profileMax = basalProfiles.map(\.amount).max() ?? 0 - return max(tempMax, profileMax, 0.1) + @MainActor func recomputeBasalDomainMax() { + let tempMax = preparedTempBasals.lazy.map(\.rate).max() ?? 0 + let profileMax = basalProfiles.lazy.map(\.amount).max() ?? 0 + basalDomainMax = max(tempMax, profileMax, 0.1) } /// Converts a basal rate to its top-anchored y value. private func invertedY(_ rate: Double) -> Double { basalDomainMax - rate } + + /// To be used instead of "now" for basal calculations so we can skip + /// redundant updates happening within the same second. + static func secondAnchoredNow() -> Date { + Date(timeIntervalSince1970: Date().timeIntervalSince1970.rounded(.down)) + } } // MARK: - Draw functions @@ -149,7 +162,7 @@ extension MainChartCanvas { extension MainChartCanvas { @MainActor func calculateTempBasals() { - let now = Date() + let now = Self.secondAnchoredNow() let suspensionTimes = state.suspendAndResumeEvents.compactMap(\.timestamp) // Snapshot the managed-object fields once; plain values from here on. @@ -169,7 +182,7 @@ extension MainChartCanvas { // paints what no temp basal covered. let deliveryEvents = events.filter { !$0.isScheduled } - var prepared = [(start: Date, end: Date, rate: Double, isScheduled: Bool)]() + var prepared = [PreparedTempBasal]() prepared.reserveCapacity(deliveryEvents.count) for (index, event) in deliveryEvents.enumerated() { @@ -192,7 +205,7 @@ extension MainChartCanvas { let isInsulinSuspended = suspensionTimes.contains { $0 >= timestamp && $0 <= barEnd } let rate = Double(truncating: event.rate ?? 0) * (isInsulinSuspended ? 0 : 1) - prepared.append((timestamp, barEnd, rate, false)) + prepared.append(PreparedTempBasal(start: timestamp, end: barEnd, rate: rate, isScheduled: false)) } // gaps no event covers ran the pump's schedule; inferred in memory, drawn dimmed @@ -210,14 +223,25 @@ extension MainChartCanvas { return ScheduledBasalInference.TimelineEvent(start: timestamp, kind: isSuspend ? .suspend : .resume) } for segment in ScheduledBasalInference.segments(events: timeline, profile: state.basalProfile, now: now) { - prepared.append((segment.start, segment.end, Double(truncating: segment.rate as NSNumber), true)) + prepared.append(PreparedTempBasal( + start: segment.start, + end: segment.end, + rate: Double(truncating: segment.rate as NSNumber), + isScheduled: true + )) } // One line series strokes the whole outline: out-of-order bars backtrack across it, // and a zero-length bar leaves a stray point it then connects diagonally. - preparedTempBasals = prepared + let updated = prepared .filter { $0.end > $0.start } .sorted { $0.start < $1.start } + + // Reassigning an identical value would invalidate the body and re-enter this method. + guard updated != preparedTempBasals else { return } + + preparedTempBasals = updated + recomputeBasalDomainMax() } func findRegularBasalPoints( @@ -268,7 +292,7 @@ extension MainChartCanvas { func calculateBasals() { Task { - let dayAgoTime = Date().addingTimeInterval(-1.days.timeInterval).timeIntervalSince1970 + let dayAgoTime = Self.secondAnchoredNow().addingTimeInterval(-1.days.timeInterval).timeIntervalSince1970 async let getRegularBasalPoints = findRegularBasalPoints( timeBegin: dayAgoTime, @@ -323,7 +347,11 @@ extension MainChartCanvas { } await MainActor.run { + // Reassigning an identical value would invalidate the body and re-enter this method. + guard basals != basalProfiles else { return } + basalProfiles = basals + recomputeBasalDomainMax() } } } diff --git a/Trio/Sources/Modules/Home/View/Chart/MainChartView.swift b/Trio/Sources/Modules/Home/View/Chart/MainChartView.swift index d839aabed9..30baad86da 100644 --- a/Trio/Sources/Modules/Home/View/Chart/MainChartView.swift +++ b/Trio/Sources/Modules/Home/View/Chart/MainChartView.swift @@ -919,7 +919,9 @@ struct MainChartCanvas: View { var showCobIobChart: Bool @State var basalProfiles: [BasalProfile] = [] - @State var preparedTempBasals: [(start: Date, end: Date, rate: Double, isScheduled: Bool)] = [] + @State var preparedTempBasals: [PreparedTempBasal] = [] + /// Cached, not computed: Swift Charts reads this once per mark, so rescanning both series per read is quadratic. + @State var basalDomainMax: Double = 0.1 // Computed (not stored) on purpose: stored properties participate in SwiftUI's // change detection, and a stored reference initialized per-init could mark this view diff --git a/Trio/Sources/Modules/Home/View/HomeRootView+BottomControls.swift b/Trio/Sources/Modules/Home/View/HomeRootView+BottomControls.swift index 3cddeb4072..678a05ccc6 100644 --- a/Trio/Sources/Modules/Home/View/HomeRootView+BottomControls.swift +++ b/Trio/Sources/Modules/Home/View/HomeRootView+BottomControls.swift @@ -474,25 +474,16 @@ extension Home.RootView { } } - private var todayMeanGlucose: Double? { - let startOfDay = Calendar.current.startOfDay(for: Date()) - let values = state.glucoseFromPersistence - .filter { ($0.date ?? .distantPast) >= startOfDay } - .map { Double($0.glucose) } - guard !values.isEmpty else { return nil } - return values.reduce(0, +) / Double(values.count) - } - - private var todayAverageString: String { - guard let mean = todayMeanGlucose else { return "--" } + private func averageGlucoseString(_ meanGlucose: Double?) -> String { + guard let mean = meanGlucose else { return "--" } if state.units == .mmolL { return Decimal(mean).asMmolL.formatted(.number.precision(.fractionLength(1))) + " " + GlucoseUnits.mmolL.rawValue } return "\(Int(mean.rounded())) " + GlucoseUnits.mgdL.rawValue } - private var todayGMIString: String { - guard let mean = todayMeanGlucose else { return "--" } + private func gmiString(_ meanGlucose: Double?) -> String { + guard let mean = meanGlucose else { return "--" } let gmiPercentage = 3.31 + 0.02392 * mean // settingsManager is injected after first render; default until then if state.settingsManager?.settings.eA1cDisplayUnit == .mmolMol { @@ -504,18 +495,19 @@ extension Home.RootView { @ViewBuilder func statsBanner() -> some View { let face = state.settingsManager?.settings.homeStatsPanelFace ?? .timeInRange - let distribution = state.todayGlucoseDistribution - let coveragePct = distribution.veryLowPct + distribution.lowPct + distribution.inRangePct + distribution - .highPct + distribution.veryHighPct - let hasData = coveragePct > 0 + let range = state.homeStatsPanelRange + let stats = state.statsPanelStats + let hasData = stats.hasData + let averageString = averageGlucoseString(stats.meanGlucose) + let gmiValueString = gmiString(stats.meanGlucose) let tirString = hasData - ? distribution.inRangePct.formatted(.number.precision(.fractionLength(0 ... 1))) + " %" + ? stats.inRangePct.formatted(.number.precision(.fractionLength(0 ... 1))) + " %" : "-- %" let segments: [(color: Color, fraction: CGFloat)] = hasData ? [ - (.red, CGFloat(distribution.veryLowPct / 100)), - (.orange, CGFloat(distribution.lowPct / 100)), - (.loopGreen, CGFloat(distribution.inRangePct / 100)), - (.purple, CGFloat((distribution.highPct + distribution.veryHighPct) / 100)) + (.red, CGFloat(stats.veryLowPct / 100)), + (.orange, CGFloat(stats.lowPct / 100)), + (.loopGreen, CGFloat(stats.inRangePct / 100)), + (.purple, CGFloat((stats.highPct + stats.veryHighPct) / 100)) ] : [(Color.secondary.opacity(0.3), 1)] Button { @@ -534,11 +526,11 @@ extension Home.RootView { Text(tirString) .font(.callout).fontWeight(.bold).fontDesign(.rounded) .foregroundStyle(.primary) - // chart shows 72h; make the daily scope explicit + // chart shows 72h; make the reported scope explicit ( Text("Time in Range", comment: "Stats banner subtitle").fontWeight(.semibold) + Text(" ") - + Text("today", comment: "Stats banner scope") + + Text(range.scopeName) ) .font(.footnote) .foregroundStyle(.secondary) @@ -554,7 +546,7 @@ extension Home.RootView { ( Text("Time in Range", comment: "Stats banner subtitle").fontWeight(.semibold) + Text(" ") - + Text("today", comment: "Stats banner scope") + + Text(range.scopeName) ) .font(.footnote) .foregroundStyle(.secondary) @@ -569,13 +561,13 @@ extension Home.RootView { // "⌀" read as a diameter sign, so spell the label out (#1474) ( Text("Avg. Glucose:", comment: "Stats banner label") - + Text(" \(todayAverageString) \u{00B7} GMI \(todayGMIString)") + + Text(" \(averageString) \u{00B7} GMI \(gmiValueString)") ) .font(.subheadline).fontWeight(.semibold) .foregroundStyle(.primary) .lineLimit(1) .minimumScaleFactor(0.8) - Text("Today's Average", comment: "Stats banner subtitle") + Text(range.averageSubtitle) .font(.caption) .foregroundStyle(.secondary) } diff --git a/Trio/Sources/Modules/LiveActivitySettings/LiveActivitySettingsStateModel.swift b/Trio/Sources/Modules/LiveActivitySettings/LiveActivitySettingsStateModel.swift index 6434047773..9cbf48bd6c 100644 --- a/Trio/Sources/Modules/LiveActivitySettings/LiveActivitySettingsStateModel.swift +++ b/Trio/Sources/Modules/LiveActivitySettings/LiveActivitySettingsStateModel.swift @@ -10,12 +10,17 @@ extension LiveActivitySettings { @Published var lockScreenView: LockScreenView = .simple @Published var smartStackView: LockScreenView = .simple @Published var displayGlucoseForecasts = false + @Published var simpleFontSize: LiveActivityFontSize = .large + + var glucoseColorScheme: GlucoseColorScheme { settingsManager.settings.glucoseColorScheme } + override func subscribe() { units = settingsManager.settings.units subscribeSetting(\.useLiveActivity, on: $useLiveActivity) { useLiveActivity = $0 } subscribeSetting(\.lockScreenView, on: $lockScreenView) { lockScreenView = $0 } subscribeSetting(\.smartStackView, on: $smartStackView) { smartStackView = $0 } subscribeSetting(\.displayGlucoseForecasts, on: $displayGlucoseForecasts) { displayGlucoseForecasts = $0 } + subscribeSetting(\.liveActivitySimpleFontSize, on: $simpleFontSize) { simpleFontSize = $0 } } } } diff --git a/Trio/Sources/Modules/LiveActivitySettings/View/LiveActivitySettingsRootView.swift b/Trio/Sources/Modules/LiveActivitySettings/View/LiveActivitySettingsRootView.swift index 67744e8107..735d3e36bc 100644 --- a/Trio/Sources/Modules/LiveActivitySettings/View/LiveActivitySettingsRootView.swift +++ b/Trio/Sources/Modules/LiveActivitySettings/View/LiveActivitySettingsRootView.swift @@ -141,6 +141,9 @@ extension LiveActivitySettings { Text( "Trio's Simple Lock Screen Widget displays current glucose reading, trend arrow, delta and the timestamp of the current reading." ) + Text( + "Its Widget Configuration lets you set the size of the glucose reading." + ) } VStack(alignment: .leading, spacing: 10) { Text("Detailed:").bold() @@ -168,8 +171,8 @@ extension LiveActivitySettings { }.padding(.top) }.padding(.bottom) - if state.lockScreenView == .detailed { - HStack { + HStack { + if state.lockScreenView == .detailed { NavigationLink( "Widget Configuration", destination: LiveActivityWidgetConfiguration( @@ -177,6 +180,14 @@ extension LiveActivitySettings { state: state ) ).foregroundStyle(Color.accentColor) + } else { + NavigationLink( + "Widget Configuration", + destination: LiveActivitySimpleWidgetConfiguration( + resolver: resolver, + state: state + ) + ).foregroundStyle(Color.accentColor) } } }.listRowBackground(Color.chart) diff --git a/Trio/Sources/Modules/LiveActivitySettings/View/LiveActivitySimpleWidgetConfiguration.swift b/Trio/Sources/Modules/LiveActivitySettings/View/LiveActivitySimpleWidgetConfiguration.swift new file mode 100644 index 0000000000..67a49969ff --- /dev/null +++ b/Trio/Sources/Modules/LiveActivitySettings/View/LiveActivitySimpleWidgetConfiguration.swift @@ -0,0 +1,159 @@ +import Foundation +import SwiftUI +import Swinject + +struct LiveActivitySimpleWidgetConfiguration: BaseView { + let resolver: Resolver + + @ObservedObject var state: LiveActivitySettings.StateModel + + private static let previewGlucose = 123 + private static let previewDelta = 6 + + @State private var shouldDisplayHintFont: Bool = false + @State private var hintDetent = PresentationDetent.large + + @Environment(\.colorScheme) var colorScheme + @Environment(AppState.self) var appState + + private var selectedStyle: LiveActivityAttributes.SimpleViewStyle { + LiveActivityAttributes.SimpleViewStyle(fontSize: state.simpleFontSize) + } + + var body: some View { + List { + Section { + previewCard + } header: { + Text("Preview") + }.listRowBackground(Color.chart) + + Section { + Picker(selection: $state.simpleFontSize) { + ForEach(LiveActivityFontSize.allCases) { size in + Text(size.displayName).tag(size) + } + } label: { + Text("Font Size") + } + + hintRow + } header: { + Text("Glucose Reading Font") + }.listRowBackground(Color.chart) + } + .listSectionSpacing(sectionSpacing) + .sheet(isPresented: $shouldDisplayHintFont) { + SettingInputHintView( + hintDetent: $hintDetent, + shouldDisplayHint: $shouldDisplayHintFont, + hintLabel: String(localized: "Glucose Reading Font"), + hintText: AnyView(fontHintText), + sheetTitle: String(localized: "Help", comment: "Help sheet title") + ) + } + .scrollContentBackground(.hidden) + .background(appState.trioBackgroundColor(for: colorScheme)) + .navigationTitle("Widget Configuration") + .navigationBarTitleDisplayMode(.automatic) + } + + private var previewCard: some View { + HStack(spacing: 3) { + HStack(spacing: 3) { + Text(previewGlucoseText) + Text("\u{2192}") + .scaleEffect(x: 0.7, y: 0.7, anchor: .leading) + .padding(.trailing, -5) + } + .font(selectedStyle.glucoseFont) + .foregroundStyle(previewReadingColor) + + Spacer() + + VStack(alignment: .trailing, spacing: 5) { + Text(previewDeltaText) + .font(.title3) + .foregroundStyle(previewReadingColor) + + HStack { + Text("Updated:") + .foregroundStyle(.secondary) + Text(previewTimeText) + .bold() + .foregroundStyle(.secondary) + } + .font(.caption) + } + } + .padding(.horizontal, 14) + .padding(.vertical, selectedStyle.fontSize.verticalPadding ?? 14) + .frame(maxWidth: .infinity) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(Color.primary.opacity(colorScheme == .dark ? 0.1 : 0.05)) + ) + .padding(.vertical, 8) + .accessibilityElement(children: .combine) + .accessibilityLabel(Text("Lock Screen widget preview")) + } + + private var previewGlucoseText: String { + LiveActivityAttributes.ContentState.formatGlucose(Self.previewGlucose, units: state.units, forceSign: false) + } + + private var previewDeltaText: String { + LiveActivityAttributes.ContentState.formatGlucose(Self.previewDelta, units: state.units, forceSign: true) + } + + private var previewTimeText: String { + let formatter = DateFormatter() + formatter.dateStyle = .none + formatter.timeStyle = .short + return formatter.string(from: Date()) + } + + private var previewReadingColor: Color { + guard state.glucoseColorScheme != .staticColor else { return .primary } + + let isMgdL = state.units == .mgdL + + let hardCodedLow = isMgdL ? Decimal(55) : 55.asMmolL + let hardCodedHigh = isMgdL ? Decimal(220) : 220.asMmolL + + return Trio.getDynamicGlucoseColor( + glucoseValue: isMgdL ? Decimal(Self.previewGlucose) : Self.previewGlucose.asMmolL, + highGlucoseColorValue: hardCodedHigh, + lowGlucoseColorValue: hardCodedLow, + targetGlucose: isMgdL ? Decimal(100) : 100.asMmolL, + glucoseColorScheme: state.glucoseColorScheme + ) + } + + private var fontHintText: some View { + VStack(alignment: .leading, spacing: 10) { + Text("Default: Large").bold() + Text( + "Changes the size of the current glucose reading and its trend arrow on the Simple Lock Screen widget. The delta and the time of the last reading are not affected." + ) + Text( + "Sizes follow your iPhone's text size setting, so the reading keeps scaling if you change the text size in iOS Settings." + ) + } + } + + private var hintRow: some View { + HStack(alignment: .center) { + Text("Set the size of the glucose reading.") + .font(.footnote) + .foregroundColor(.secondary) + .lineLimit(nil) + Spacer() + Button(action: { shouldDisplayHintFont.toggle() }) { + Image(systemName: "questionmark.circle") + } + .buttonStyle(BorderlessButtonStyle()) + .accessibilityLabel(Text("More information about Glucose Reading Font")) + }.padding(.vertical) + } +} diff --git a/Trio/Sources/Modules/LiveActivitySettings/View/LiveActivityWidgetConfiguration.swift b/Trio/Sources/Modules/LiveActivitySettings/View/LiveActivityWidgetConfiguration.swift index e16235c759..a88d768081 100644 --- a/Trio/Sources/Modules/LiveActivitySettings/View/LiveActivityWidgetConfiguration.swift +++ b/Trio/Sources/Modules/LiveActivitySettings/View/LiveActivityWidgetConfiguration.swift @@ -1,4 +1,5 @@ import Charts +import CoreTransferable import Foundation import SwiftUI import Swinject @@ -9,32 +10,25 @@ struct LiveActivityWidgetConfiguration: BaseView { @ObservedObject var state: LiveActivitySettings.StateModel - @State private var selectedItems: [LiveActivityItem?] = Array(repeating: nil, count: 4) - @State private var showAddItemDialog: Bool = false - @State private var buttonIndexToUpdate: Int? - @State private var itemToRemove: LiveActivityItem? - @State private var isRemovalConfirmationPresented: Bool = false + @State private var placedItems: [LiveActivityItem] = [] @State private var glucoseData: [DummyGlucoseData] = [] + @State private var targetedSlot: Int? + @State private var isPaletteTargeted: Bool = false @Environment(\.colorScheme) var colorScheme - @Environment(\.presentationMode) var presentationMode: Binding @Environment(AppState.self) var appState - private var color: LinearGradient { - colorScheme == .dark ? LinearGradient( - gradient: Gradient(colors: [ - Color.bgDarkBlue, - Color.bgDarkerDarkBlue - ]), - startPoint: .top, - endPoint: .bottom - ) - : - LinearGradient( - gradient: Gradient(colors: [Color.gray.opacity(0.1)]), - startPoint: .top, - endPoint: .bottom - ) + private static let slotCount = 4 + private static let slotSize: CGFloat = 50 + private static let slotSpacing: CGFloat = 15 + private static let slotPadding: CGFloat = 5 + + private var usedSlots: Int { + placedItems.reduce(0) { $0 + $1.slotWidth } + } + + private var freeSlots: Int { + max(0, Self.slotCount - usedSlots) } private func generateDummyGlucoseData() -> [DummyGlucoseData] { @@ -75,52 +69,41 @@ struct LiveActivityWidgetConfiguration: BaseView { } var body: some View { - VStack { - Group { - VStack(alignment: .trailing, spacing: 0) { - Text(String(localized: "Live Activity Personalization").uppercased()) - .frame(maxWidth: .infinity, alignment: .leading) - .foregroundColor(.secondary) - .font(.footnote) - .padding(.leading) - } - }.padding(.bottom, -15) - - GroupBox { - VStack { - dummyChart(glucoseData) - - HStack(spacing: 15) { - ForEach(0 ..< 4, id: \.self) { index in - widgetButton(for: index) - } + ScrollView { + VStack { + Group { + VStack(alignment: .trailing, spacing: 0) { + Text(String(localized: "Live Activity Personalization").uppercased()) + .frame(maxWidth: .infinity, alignment: .leading) + .foregroundColor(.secondary) + .font(.footnote) + .padding(.leading) } - .padding() - .overlay( - RoundedRectangle(cornerRadius: 12) - .stroke(style: StrokeStyle(lineWidth: 2, dash: [5])) - .foregroundColor(.gray) - ) - .cornerRadius(12) - } - - }.padding(.vertical).groupBoxStyle(.dummyChart) + }.padding(.bottom, -15) - Group { - HStack { - Image(systemName: "info.circle") - Text( - "To re-order widgets, remove them and re-add them in the desired order." - ) - } - }.frame(maxWidth: .infinity, alignment: .leading) - .foregroundColor(.secondary) - .font(.footnote) - .padding(.horizontal) + GroupBox { + VStack { + dummyChart(glucoseData) + layoutRow + } + }.padding(.vertical).groupBoxStyle(.dummyChart) + + Group { + HStack(alignment: .top) { + Image(systemName: "info.circle") + Text( + "Drag a widget to move it. Drag one up from Available Widgets to add it, or drop it back onto the list to remove it." + ) + } + }.frame(maxWidth: .infinity, alignment: .leading) + .foregroundColor(.secondary) + .font(.footnote) + .padding(.horizontal) - Spacer() + paletteSection.padding(.top) + } + .padding() } - .padding() .scrollContentBackground(.hidden) .background(appState.trioBackgroundColor(for: colorScheme)) .navigationTitle("Widget Configuration") @@ -131,96 +114,183 @@ struct LiveActivityWidgetConfiguration: BaseView { } loadOrder() // Load the saved order when the view appears } - .glassActionSheet( - "Add Widget", - isPresented: $showAddItemDialog, - actions: LiveActivityItem.allCases.filter { !selectedItems.contains($0) }.map { item in - GlassSheetAction(verbatim: item.displayName) { - if let index = buttonIndexToUpdate { - addItem(item, at: index) - } - } + } + + // MARK: - Layout row + + private var layoutRow: some View { + HStack(spacing: Self.slotSpacing) { + ForEach(placedItems, id: \.self) { item in + placedSlot(item) + } + + ForEach(0 ..< freeSlots, id: \.self) { offset in + emptySlot(position: placedItems.count + offset) } + } + .padding() + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(style: StrokeStyle(lineWidth: 2, dash: [5])) + .foregroundColor(.gray) ) + .cornerRadius(12) } - @ViewBuilder private func widgetButton(for index: Int) -> some View { - if index < selectedItems.count, let selectedItem = selectedItems[index] { - // Display selected item preview - ZStack(alignment: .topTrailing) { - getItemPreview(for: selectedItem) - .frame(width: 50, height: 50) - .padding(5) - .background(Color.clear) - .cornerRadius(12) - .overlay( - RoundedRectangle(cornerRadius: 12) - .stroke(Color.primary, lineWidth: 1) + private func placedSlot(_ item: LiveActivityItem) -> some View { + let index = placedItems.firstIndex(of: item) ?? placedItems.count + let isDropTarget = targetedSlot == index + + return ZStack(alignment: .topTrailing) { + widgetTile(item, highlighted: isDropTarget) + + Button(action: { remove(item) }) { + Image(systemName: "trash.circle.fill") + .foregroundColor(Color(UIColor.systemGray2)) + .background(Color.white) + .clipShape(Circle()) + .font(.title3) + } + .accessibilityLabel(Text("Remove \(item.displayName)")) + .offset(x: 10, y: -10) + } + .draggable(item) { widgetTile(item) } + .dropDestination(for: LiveActivityItem.self) { dropped, _ in + targetedSlot = nil + guard let dropped = dropped.first else { return false } + return place(dropped, at: index) + } isTargeted: { hovering in + targetedSlot = hovering ? index : nil + } + .accessibilityLabel(Text(item.displayName)) + .accessibilityValue(Text("Position \(index + 1) of \(placedItems.count)")) + } + + private func emptySlot(position: Int) -> some View { + let isDropTarget = targetedSlot == position + + return Image(systemName: "plus") + .font(.title2) + .foregroundColor(.secondary) + .frame(width: Self.slotSize, height: Self.slotSize) + .padding(Self.slotPadding) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke( + style: StrokeStyle(lineWidth: isDropTarget ? 2 : 1, dash: isDropTarget ? [] : [5]) ) - Button(action: { - isRemovalConfirmationPresented = true - itemToRemove = selectedItem - }) { - Image(systemName: "trash.circle.fill") - .foregroundColor(Color(UIColor.systemGray2)) - .background(Color.white) - .clipShape(Circle()) - .font(.title3) + .foregroundColor(isDropTarget ? Color.accentColor : Color.primary) + ) + .dropDestination(for: LiveActivityItem.self) { dropped, _ in + targetedSlot = nil + guard let dropped = dropped.first else { return false } + return place(dropped, at: placedItems.count) + } isTargeted: { hovering in + targetedSlot = hovering ? position : nil + } + .accessibilityLabel(Text("Empty widget slot")) + } + + private func widgetTile(_ item: LiveActivityItem, highlighted: Bool = false) -> some View { + getItemPreview(for: item) + .frame(width: previewWidth(for: item), height: Self.slotSize) + .padding(Self.slotPadding) + .background(RoundedRectangle(cornerRadius: 12).fill(Color.chart)) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(highlighted ? Color.accentColor : Color.primary, lineWidth: highlighted ? 2 : 1) + ) + } + + private func previewWidth(for item: LiveActivityItem) -> CGFloat { + item.slotWidth > 1 + ? Self.slotSize * 2 + Self.slotSpacing + Self.slotPadding * 2 + : Self.slotSize + } + + // MARK: - Palette + + private static let paletteRest: [LiveActivityItem] = [ + .currentGlucoseLarge, .currentGlucoseLargeUncolored, .iob, .cob, .updatedLabel, .totalDailyDose + ] + + private static var paletteRestRows: [[LiveActivityItem]] { + stride(from: 0, to: paletteRest.count, by: 3).map { + Array(paletteRest[$0 ..< min($0 + 3, paletteRest.count)]) + } + } + + private var paletteSection: some View { + VStack(alignment: .leading, spacing: 10) { + Text(String(localized: "Available Widgets").uppercased()) + .frame(maxWidth: .infinity, alignment: .leading) + .foregroundColor(.secondary) + .font(.footnote) + + Grid(alignment: .top, horizontalSpacing: 12, verticalSpacing: 12) { + GridRow { + paletteCell(.currentGlucoseWideUncolored).gridCellColumns(2) + paletteCell(.currentGlucose) } - .accessibilityLabel(Text("Remove widget")) - .offset(x: 10, y: -10) - .glassActionSheet( - isPresented: $isRemovalConfirmationPresented, - actions: [ - GlassSheetAction("Remove Widget", role: .destructive) { - if let itemToRemove = itemToRemove { - removeItem(itemToRemove) - } + GridRow { + paletteCell(.currentGlucoseWide).gridCellColumns(2) + paletteCell(.currentGlucoseColored) + } + ForEach(Self.paletteRestRows, id: \.self) { row in + GridRow { + ForEach(row, id: \.self) { item in + paletteCell(item) } - ] - ) - } - } else { - // Show "+" symbol for empty slots - Button(action: { - buttonIndexToUpdate = index - showAddItemDialog.toggle() - }) { - VStack { - Image(systemName: "plus") - .font(.title2) - .foregroundColor(.accentColor) + } } - .frame(width: 50, height: 50) - .padding(5) - .overlay( - RoundedRectangle(cornerRadius: 12) - .stroke(style: StrokeStyle(lineWidth: 1, dash: [5])) - .foregroundColor(.primary) - ) } - .buttonStyle(.plain) - .accessibilityLabel(Text("Add widget")) } + .padding(10) + .background( + RoundedRectangle(cornerRadius: 12) + .fill(Color.gray.opacity(isPaletteTargeted ? 0.25 : 0.1)) + ) + .dropDestination(for: LiveActivityItem.self) { dropped, _ in + isPaletteTargeted = false + guard let dropped = dropped.first else { return false } + return remove(dropped) + } isTargeted: { isPaletteTargeted = $0 } } - private func getItemPreview(for item: LiveActivityItem) -> some View { - switch item { - case .currentGlucoseLarge: - return AnyView(currentGlucoseLargePreview) - case .currentGlucose: - return AnyView(currentGlucosePreview) - case .cob: - return AnyView(cobPreview) - case .iob: - return AnyView(iobPreview) - case .updatedLabel: - return AnyView(updatedLabelPreview) - case .totalDailyDose: - return AnyView(totalDailyDosePreview) + @ViewBuilder private func paletteCell(_ item: LiveActivityItem) -> some View { + let isPlaced = placedItems.contains(item) + let canPlace = !isPlaced && freeSlots >= item.slotWidth + let unavailableReason: LocalizedStringKey = isPlaced ? "Already in the layout" : "Not enough room" + + let cell = VStack(spacing: 6) { + widgetTile(item) + + Text(item.displayName) + .font(.caption2) + .multilineTextAlignment(.leading) + .foregroundColor(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + .frame(maxWidth: .infinity, alignment: .top) + .opacity(canPlace ? 1 : 0.4) + + if canPlace { + cell + .contentShape(Rectangle()) + .onTapGesture { place(item, at: placedItems.count) } + .draggable(item) { widgetTile(item) } + .accessibilityAddTraits(.isButton) + .accessibilityLabel(Text(item.displayName)) + .accessibilityHint(Text("Adds this widget to the layout")) + } else { + cell + .accessibilityLabel(Text(item.displayName)) + .accessibilityValue(Text(unavailableReason)) } } + // MARK: - Previews + @ViewBuilder private func dummyChart(_ glucoseData: [DummyGlucoseData]) -> some View { Chart { ForEach(glucoseData) { data in @@ -264,28 +334,72 @@ struct LiveActivityWidgetConfiguration: BaseView { .frame(height: 100) } - private var currentGlucoseLargePreview: some View { + private func getItemPreview(for item: LiveActivityItem) -> some View { + switch item { + case .currentGlucoseLarge: + return AnyView(currentGlucoseLargePreview(colored: true)) + case .currentGlucoseLargeUncolored: + return AnyView(currentGlucoseLargePreview(colored: false)) + case .currentGlucose: + return AnyView(currentGlucosePreview(colored: false)) + case .currentGlucoseColored: + return AnyView(currentGlucosePreview(colored: true)) + case .currentGlucoseWide: + return AnyView(currentGlucoseWidePreview(colored: true)) + case .currentGlucoseWideUncolored: + return AnyView(currentGlucoseWidePreview(colored: false)) + case .wideContinuation: + return AnyView(EmptyView()) + case .cob: + return AnyView(cobPreview) + case .iob: + return AnyView(iobPreview) + case .updatedLabel: + return AnyView(updatedLabelPreview) + case .totalDailyDose: + return AnyView(totalDailyDosePreview) + } + } + + private func currentGlucoseLargePreview(colored: Bool) -> some View { HStack(alignment: .center) { Text("123") + Text("\u{2192}") } - .foregroundStyle(Color.loopGreen) + .foregroundStyle(colored ? Color.loopGreen : Color.primary) .fontWeight(.bold) .font(.subheadline) } - private var currentGlucosePreview: some View { + private func currentGlucoseWidePreview(colored: Bool) -> some View { + HStack(alignment: .firstTextBaseline, spacing: 6) { + HStack(alignment: .center) { + Text("123").font(.largeTitle) + Text("\u{2192}").font(.title2) + } + .foregroundStyle(colored ? Color.loopGreen : Color.primary) + Text("+6") + .font(.title2) + .foregroundStyle(.primary) + } + .fontWeight(.bold) + .lineLimit(1) + .minimumScaleFactor(0.6) + } + + private func currentGlucosePreview(colored: Bool) -> some View { VStack { HStack(alignment: .center) { Text("123") .fontWeight(.bold) .font(.caption) + .foregroundStyle(colored ? Color.loopGreen : Color.primary) } HStack(spacing: -5) { HStack { Text("\u{2192}") Text("+6") - }.foregroundStyle(.primary).font(.caption2) + }.foregroundStyle(colored ? Color.loopGreen : Color.primary).font(.caption2) } } } @@ -326,30 +440,91 @@ struct LiveActivityWidgetConfiguration: BaseView { } } - private func loadOrder() { - if let savedItems = UserDefaults.standard.loadLiveActivityOrder() { - selectedItems = savedItems.count == 4 ? savedItems : savedItems + Array(repeating: nil, count: 4 - savedItems.count) + // MARK: - Layout changes + + @discardableResult private func place(_ item: LiveActivityItem, at index: Int) -> Bool { + guard item != .wideContinuation else { return false } + + var items = placedItems + + if let currentIndex = items.firstIndex(of: item) { + guard currentIndex != index else { return false } + items.remove(at: currentIndex) } else { - selectedItems = LiveActivityItem.defaultItems - saveOrder() + guard freeSlots >= item.slotWidth else { return false } + } + + items.insert(item, at: min(index, items.count)) + apply(items) + return true + } + + @discardableResult private func remove(_ item: LiveActivityItem) -> Bool { + guard let index = placedItems.firstIndex(of: item) else { return false } + + var items = placedItems + items.remove(at: index) + apply(items) + return true + } + + private func apply(_ items: [LiveActivityItem]) { + withAnimation(.easeInOut(duration: 0.2)) { + placedItems = items + } + saveOrder(items) + } + + // MARK: - Persistence + + private func loadOrder() { + guard let savedItems = UserDefaults.standard.loadLiveActivityOrder() else { + let defaults = LiveActivityItem.defaultItems + placedItems = defaults + saveOrder(defaults) + return } + + placedItems = layoutItems(from: savedItems) } - private func saveOrder() { - UserDefaults.standard.saveLiveActivityOrder(selectedItems) + private func saveOrder(_ items: [LiveActivityItem]) { + UserDefaults.standard.saveLiveActivityOrder(slotArray(from: items)) Foundation.NotificationCenter.default.post(name: .liveActivityOrderDidChange, object: nil) } - private func addItem(_ item: LiveActivityItem, at index: Int) { - selectedItems[index] = item - saveOrder() + private func slotArray(from items: [LiveActivityItem]) -> [LiveActivityItem?] { + var slots: [LiveActivityItem?] = [] + + for item in items { + slots.append(item) + + let continuations: [LiveActivityItem?] = Array( + repeating: .wideContinuation, + count: item.slotWidth - 1 + ) + slots.append(contentsOf: continuations) + } + + let padding: [LiveActivityItem?] = Array(repeating: nil, count: max(0, Self.slotCount - slots.count)) + slots.append(contentsOf: padding) + + return Array(slots.prefix(Self.slotCount)) } - private func removeItem(_ item: LiveActivityItem) { - if let index = selectedItems.firstIndex(of: item) { - selectedItems[index] = nil - saveOrder() + private func layoutItems(from slots: [LiveActivityItem?]) -> [LiveActivityItem] { + var items: [LiveActivityItem] = [] + var used = 0 + + for slot in slots.prefix(Self.slotCount) { + guard let slot, slot != .wideContinuation, !items.contains(slot) else { continue } + guard used + slot.slotWidth <= Self.slotCount else { continue } + + items.append(slot) + used += slot.slotWidth } + + return items } } @@ -373,32 +548,74 @@ extension UserDefaults { } // Enum to represent each live activity item -enum LiveActivityItem: String, CaseIterable, Identifiable { +enum LiveActivityItem: String, CaseIterable, Identifiable, Codable, Transferable { case currentGlucoseLarge + case currentGlucoseLargeUncolored case currentGlucose + case currentGlucoseColored + case currentGlucoseWide + case currentGlucoseWideUncolored case iob case cob case updatedLabel case totalDailyDose + case wideContinuation var id: String { rawValue } + static var transferRepresentation: some TransferRepresentation { + CodableRepresentation(contentType: .json) + } + static var defaultItems: [LiveActivityItem] { [.currentGlucose, .iob, .cob, .updatedLabel] } + static var selectableItems: [LiveActivityItem] { + allCases.filter { $0 != .wideContinuation } + } + + var slotWidth: Int { + (self == .currentGlucoseWide || self == .currentGlucoseWideUncolored) ? 2 : 1 + } + var displayName: String { switch self { case .currentGlucoseLarge: return String( - localized: "Glucose and Trend, no Delta", - comment: "Live Activity widget icon label for Glucose and Trend, no Delta" + localized: "Glucose and Trend (Colored)", + comment: "Live Activity widget icon label for Glucose and Trend in the glucose color" + ) + case .currentGlucoseLargeUncolored: + return String( + localized: "Glucose and Trend", + comment: "Live Activity widget icon label for Glucose and Trend in the default text color" ) case .currentGlucose: return String( localized: "Glucose, Trend, Delta", comment: "Live Activity widget icon label for Glucose, Trend, Delta" ) + case .currentGlucoseColored: + return String( + localized: "Glucose, Trend, Delta (Colored)", + comment: "Live Activity widget icon label for Glucose, Trend, Delta in the glucose color" + ) + case .currentGlucoseWide: + return String( + localized: "Glucose, Trend, Delta (Double Width, Colored)", + comment: "Live Activity widget icon label for the double-width Glucose, Trend, Delta item in the glucose color" + ) + case .currentGlucoseWideUncolored: + return String( + localized: "Glucose, Trend, Delta (Double Width)", + comment: "Live Activity widget icon label for the double-width Glucose, Trend, Delta item in the default text color" + ) + case .wideContinuation: + return String( + localized: "Reserved", + comment: "Live Activity widget icon label for the slot taken by a double-width item" + ) case .iob: return String( localized: "Insulin on Board (IOB)", diff --git a/Trio/Sources/Modules/Settings/SettingItems.swift b/Trio/Sources/Modules/Settings/SettingItems.swift index c68b07ecf5..ed020c828d 100644 --- a/Trio/Sources/Modules/Settings/SettingItems.swift +++ b/Trio/Sources/Modules/Settings/SettingItems.swift @@ -437,7 +437,8 @@ enum SettingItems { "Require Adjustments Confirmation", "Show CGM Status Around Bobble", "CGM Status", - "Bobble" + "Bobble", + "Home Statistics Range" ], scrollTargetLabels: [ "Show Y-Axis Grid Lines": "Show X-Axis Grid Lines", @@ -525,7 +526,8 @@ enum SettingItems { view: .liveActivitySettings, searchContents: [ "Enable Live Activity", - "Lock Screen Widget Style" + "Lock Screen Widget Style", + "Widget Configuration" ], path: ["Notifications", "Live Activity"] ), diff --git a/Trio/Sources/Modules/SettingsExport/SettingsExportStateModel.swift b/Trio/Sources/Modules/SettingsExport/SettingsExportStateModel.swift index 47bed315e4..f2699b1186 100644 --- a/Trio/Sources/Modules/SettingsExport/SettingsExportStateModel.swift +++ b/Trio/Sources/Modules/SettingsExport/SettingsExportStateModel.swift @@ -1129,6 +1129,18 @@ extension SettingsExport { name: String(localized: "Time in Range Type"), value: trioSettings.timeInRangeType.rawValue ) + addSetting( + category: featuresCategory, + subcategory: userInterfaceSubcategory, + name: String(localized: "Home Statistics Panel"), + value: trioSettings.homeStatsPanelFace.displayName + ) + addSetting( + category: featuresCategory, + subcategory: userInterfaceSubcategory, + name: String(localized: "Home Statistics Range"), + value: trioSettings.homeStatsPanelRange.displayName + ) addSetting( category: featuresCategory, subcategory: userInterfaceSubcategory, @@ -1188,6 +1200,14 @@ extension SettingsExport { name: String(localized: "Lock Screen Widget Style"), value: trioSettings.lockScreenView.rawValue ) + if trioSettings.lockScreenView == .simple { + addSetting( + category: notificationsCategory, + subcategory: liveActivitySubcategory, + name: String(localized: "Font Size"), + value: trioSettings.liveActivitySimpleFontSize.displayName + ) + } addSetting( category: notificationsCategory, subcategory: liveActivitySubcategory, diff --git a/Trio/Sources/Modules/Stat/StatStateModel.swift b/Trio/Sources/Modules/Stat/StatStateModel.swift index fca2d0491b..696f4c9eb3 100644 --- a/Trio/Sources/Modules/Stat/StatStateModel.swift +++ b/Trio/Sources/Modules/Stat/StatStateModel.swift @@ -80,10 +80,27 @@ extension Stat { let viewContext = CoreDataStack.shared.persistentContainer.viewContext override func subscribe() { - setupGlucoseArray(for: .day) + // open on the span the home stats panel reports + let range = settingsManager.settings.homeStatsPanelRange + selectedIntervalForInsulinStats = StatsTimeInterval(range) + selectedIntervalForMealStats = StatsTimeInterval(range) + + // `didSet` starts these fetches; assign only on change so each runs exactly once + let glucoseInterval = StatsTimeIntervalWithToday(range) + if glucoseInterval != selectedIntervalForGlucoseStats { + selectedIntervalForGlucoseStats = glucoseInterval + } else { + setupGlucoseArray(for: glucoseInterval) + } + let loopInterval = StatsTimeIntervalWithToday(range) + if loopInterval != selectedIntervalForLoopStats { + selectedIntervalForLoopStats = loopInterval + } else { + setupLoopStatRecords() + } + setupTDDStats(selectedInterval: selectedIntervalForInsulinStats) setupBolusStats() - setupLoopStatRecords() setupMealStats() setupGlucoseDailyStats() units = settingsManager.settings.units @@ -218,6 +235,8 @@ extension Stat.StateModel { case percentileByTime = "Percentile" /// Time-based distribution of glucose ranges case distributionByTime = "Distribution" + /// Glycemia Risk Index for the selected period + case glycemiaRiskIndex = "Glycemia Risk Index" /// Day-based box plot of glucose percentile ranges case percentileByDay = "Percentile (by day)" /// Day-based distribution of glucose ranges @@ -229,6 +248,8 @@ extension Stat.StateModel { return String(localized: "Percentile") case .distributionByTime: return String(localized: "Distribution") + case .glycemiaRiskIndex: + return String(localized: "Glycemia Risk Index") case .percentileByDay: return String(localized: "Percentile (by day)") case .distributionByDay: @@ -307,6 +328,16 @@ extension Stat.StateModel { var id: Self { self } + init(_ range: HomeStatsPanelRange) { + switch range { + case .today: self = .today + case .day: self = .day + case .week: self = .week + case .month: self = .month + case .threeMonths: self = .total + } + } + var displayName: String { switch self { case .today: @@ -336,6 +367,17 @@ extension Stat.StateModel { var id: Self { self } + /// No "today" bucket here, so it falls back to the day view. + init(_ range: HomeStatsPanelRange) { + switch range { + case .day, + .today: self = .day + case .week: self = .week + case .month: self = .month + case .threeMonths: self = .total + } + } + var displayName: String { switch self { case .day: diff --git a/Trio/Sources/Modules/Stat/View/StatRootView.swift b/Trio/Sources/Modules/Stat/View/StatRootView.swift index 5503c0ec63..51e3bf2dc3 100644 --- a/Trio/Sources/Modules/Stat/View/StatRootView.swift +++ b/Trio/Sources/Modules/Stat/View/StatRootView.swift @@ -112,15 +112,23 @@ extension Stat { } else { timeInRangeCard - if !isGlucoseDaySelected && state.selectedGlucoseChartType != .percentileByDay && state - .selectedGlucoseChartType != .distributionByDay - { - glucoseStatsCard - } + if state.selectedGlucoseChartType == .glycemiaRiskIndex { + StatCard { + GRIExplanationView(result: GRICalculator.calculate( + glucoseValues: state.glucoseFromPersistence.map { Int($0.glucose) } + )) + } + } else { + if !isGlucoseDaySelected && state.selectedGlucoseChartType != .percentileByDay && state + .selectedGlucoseChartType != .distributionByDay + { + glucoseStatsCard + } - HStack { - var hintText: String { + var hintText: String? { switch state.selectedGlucoseChartType { + case .glycemiaRiskIndex: + nil case .percentileByTime: String(localized: "Tap and hold the AGP graph or Time-in-Range ring to reveal more details.") case .distributionByTime: @@ -135,13 +143,17 @@ extension Stat { ) } } - Image(systemName: "hand.draw.fill") - .foregroundStyle(Color.primary) - .padding(.leading) - Text(hintText) - .foregroundStyle(Color.secondary) - .padding(.trailing) - }.font(.footnote) + if let hintText { + HStack { + Image(systemName: "hand.draw.fill") + .foregroundStyle(Color.primary) + .padding(.leading) + Text(hintText) + .foregroundStyle(Color.secondary) + .padding(.trailing) + }.font(.footnote) + } + } } } @@ -186,6 +198,11 @@ extension Stat { ) } + case .glycemiaRiskIndex: + GRIChartView(result: GRICalculator.calculate( + glucoseValues: state.glucoseFromPersistence.map { Int($0.glucose) } + )) + case .percentileByTime: GlucosePercentileChart( glucose: state.glucoseFromPersistence, diff --git a/Trio/Sources/Modules/Stat/View/ViewElements/Glucose/GRICalculator.swift b/Trio/Sources/Modules/Stat/View/ViewElements/Glucose/GRICalculator.swift new file mode 100644 index 0000000000..aeb8275ee1 --- /dev/null +++ b/Trio/Sources/Modules/Stat/View/ViewElements/Glucose/GRICalculator.swift @@ -0,0 +1,55 @@ +import Foundation + +/// https://doi.org/10.1177/19322968221085273 +enum GRICalculator { + struct Result: Equatable { + let veryLowPercentage: Double + let lowPercentage: Double + let highPercentage: Double + let veryHighPercentage: Double + + var hypoComponent: Double { veryLowPercentage + 0.8 * lowPercentage } + var hyperComponent: Double { veryHighPercentage + 0.5 * highPercentage } + var score: Double { min(100, uncappedScore) } + + var weightedHypoComponent: Double { 3 * hypoComponent } + var weightedHyperComponent: Double { 1.6 * hyperComponent } + var uncappedScore: Double { weightedHypoComponent + weightedHyperComponent } + + var zone: String { + switch score { + case ...20: return "A" + case ...40: return "B" + case ...60: return "C" + case ...80: return "D" + default: return "E" + } + } + } + + static func calculate(glucoseValues: [Int]) -> Result? { + var count = 0 + var veryLow = 0 + var low = 0 + var high = 0 + var veryHigh = 0 + for value in glucoseValues where value > 0 { + count += 1 + switch value { + case ..<54: veryLow += 1 + case ..<70: low += 1 + case 251...: veryHigh += 1 + case 181...: high += 1 + default: break + } + } + guard count > 0 else { return nil } + let percent = 100.0 / Double(count) + return Result( + veryLowPercentage: Double(veryLow) * percent, + lowPercentage: Double(low) * percent, + highPercentage: Double(high) * percent, + veryHighPercentage: Double(veryHigh) * percent + ) + } +} diff --git a/Trio/Sources/Modules/Stat/View/ViewElements/Glucose/GRIView.swift b/Trio/Sources/Modules/Stat/View/ViewElements/Glucose/GRIView.swift new file mode 100644 index 0000000000..f7149f1506 --- /dev/null +++ b/Trio/Sources/Modules/Stat/View/ViewElements/Glucose/GRIView.swift @@ -0,0 +1,336 @@ +import SwiftUI + +struct GRIChartView: View { + let result: GRICalculator.Result? + + var body: some View { + VStack(spacing: 16) { + GRIView(result: result) + if let result { + GRIRiskGridView(result: result) + } else { + ContentUnavailableView("No Glucose Data", systemImage: "chart.bar.fill") + } + } + } +} + +struct GRIView: View { + let result: GRICalculator.Result? + @State private var showingDetails = false + @Environment(\.colorScheme) private var colorScheme + + var body: some View { + Button { + showingDetails = true + } label: { + HStack { + Text("GRI") + .font(.subheadline.weight(.semibold)) + if let result { + Text(result.score, format: .number.precision(.fractionLength(1))) + .font(.headline) + Text("/ 100") + .foregroundStyle(.secondary) + Spacer() + Text("Zone \(result.zone)") + .foregroundStyle(griColor(result.score)) + } else { + Text("—") + Spacer() + } + Image(systemName: "info.circle") + .foregroundStyle(.secondary) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityHint("Shows Glycemia Risk Index details") + .sheet(isPresented: $showingDetails) { + NavigationStack { + ScrollView { + VStack(alignment: .leading, spacing: 20) { + if let result { + HStack { + Text(result.score, format: .number.precision(.fractionLength(1))) + .font(.largeTitle.bold()) + Text("/ 100") + Spacer() + Text("Zone \(result.zone)") + .foregroundStyle(griColor(result.score)) + } + GRIRiskGridView(result: result) + } else { + ContentUnavailableView("No Glucose Data", systemImage: "chart.bar.fill") + } + GRIExplanationView(result: result) + } + .padding() + } + .background(Color(uiColor: .systemBackground)) + .navigationTitle("Glycemia Risk Index") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Done") { showingDetails = false } + } + } + } + .preferredColorScheme(colorScheme) + .presentationBackground(Color(uiColor: .systemBackground)) + } + } +} + +struct GRIExplanationView: View { + let result: GRICalculator.Result? + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + Text( + "Glycemia Risk Index combines low and high glucose exposure into a score from 0 to 100. Lower scores indicate less glycemic risk." + ) + Text( + "Calculated from glucose readings in the selected period. Short periods and gaps in sensor data can make the score less representative." + ) + .font(.footnote) + .foregroundStyle(.secondary) + if let result { + LabeledContent("Hypoglycemia component", value: component(result.hypoComponent)) + .foregroundStyle(GRICalculationColors.hypo) + LabeledContent("Hyperglycemia component", value: component(result.hyperComponent)) + .foregroundStyle(GRICalculationColors.hyper) + } + Divider() + if let result { + DisclosureGroup("Score calculation") { + GRIScoreBreakdownView(result: result) + .padding(.top, 8) + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + private func component(_ value: Double) -> String { + value.formatted(.number.precision(.fractionLength(1))) + "%" + } +} + +private enum GRICalculationColors { + static let veryLow: Color = .dynamicRed + static let low = veryLow.opacity(0.65) + static let high = veryHigh.opacity(0.65) + static let veryHigh: Color = .dynamicPurple + static let hypo = veryLow + static let hyper = veryHigh +} + +private struct GRIScoreBreakdownView: View { + let result: GRICalculator.Result + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + Text("GRI uses fixed glucose thresholds, regardless of your target range or display units.") + .font(.footnote) + .foregroundStyle(.secondary) + + LabeledContent("Below 54 mg/dL") { + Text(patientValue(percentage(result.veryLowPercentage), color: GRICalculationColors.veryLow)) + } + LabeledContent("54 to below 70 mg/dL") { + Text(patientValue(percentage(result.lowPercentage), color: GRICalculationColors.low)) + } + LabeledContent("Above 180 through 250 mg/dL") { + Text(patientValue(percentage(result.highPercentage), color: GRICalculationColors.high)) + } + LabeledContent("Above 250 mg/dL") { + Text(patientValue(percentage(result.veryHighPercentage), color: GRICalculationColors.veryHigh)) + } + + Divider() + calculation( + "Hypoglycemia component", + color: GRICalculationColors.hypo, + formula: patientValue(number(result.veryLowPercentage), color: GRICalculationColors.veryLow) + + AttributedString(" + \(number(0.8)) × ") + + patientValue(number(result.lowPercentage), color: GRICalculationColors.low) + AttributedString(" = ") + + patientValue(number(result.hypoComponent), color: GRICalculationColors.hypo) + ) + calculation( + "Hyperglycemia component", + color: GRICalculationColors.hyper, + formula: patientValue(number(result.veryHighPercentage), color: GRICalculationColors.veryHigh) + + AttributedString(" + \(number(0.5)) × ") + + patientValue(number(result.highPercentage), color: GRICalculationColors.high) + AttributedString(" = ") + + patientValue(number(result.hyperComponent), color: GRICalculationColors.hyper) + ) + calculation( + "Weighted hypoglycemia", + color: GRICalculationColors.hypo, + formula: AttributedString("3 × ") + patientValue(number(result.hypoComponent), color: GRICalculationColors.hypo) + + AttributedString(" = ") + + patientValue(number(result.weightedHypoComponent), color: GRICalculationColors.hypo) + ) + calculation( + "Weighted hyperglycemia", + color: GRICalculationColors.hyper, + formula: AttributedString("\(number(1.6)) × ") + + patientValue(number(result.hyperComponent), color: GRICalculationColors.hyper) + + AttributedString(" = ") + + patientValue(number(result.weightedHyperComponent), color: GRICalculationColors.hyper) + ) + calculation( + "Combined score", + formula: patientValue(number(result.weightedHypoComponent), color: GRICalculationColors.hypo) + + AttributedString(" + ") + + patientValue(number(result.weightedHyperComponent), color: GRICalculationColors.hyper) + + AttributedString(" = ") + + patientValue(number(result.uncappedScore), color: .primary) + ) + Text("The final GRI score is capped at 100. Calculations use unrounded values.") + .font(.footnote) + .foregroundStyle(.secondary) + LabeledContent("GRI") { + Text(patientValue(number(result.score), color: .primary)) + } + .font(.headline) + } + } + + private func calculation(_ title: LocalizedStringKey, color: Color = .primary, formula: AttributedString) -> some View { + VStack(alignment: .leading, spacing: 4) { + Text(title).font(.subheadline.weight(.semibold)).foregroundStyle(color) + Text(formula).monospacedDigit() + } + } + + private func patientValue(_ value: String, color: Color) -> AttributedString { + var text = AttributedString(value) + text.foregroundColor = color + text.font = .body.weight(.semibold) + return text + } + + private func number(_ value: Double) -> String { + value.formatted(.number.precision(.fractionLength(1))) + } + + private func percentage(_ value: Double) -> String { + (value / 100).formatted(.percent.precision(.fractionLength(1))) + } +} + +private func griColor(_ score: Double) -> Color { + switch score { + case ...20: return .green + case ...40: return .yellow + case ...60: return .orange + case ...80: return .red + default: return Color(red: 0.58, green: 0.30, blue: 0.28) + } +} + +private struct GRIRiskGridView: View { + let result: GRICalculator.Result + @Environment(\.colorScheme) private var colorScheme + + private var xMax: Double { max(30, ceil(result.hypoComponent / 5) * 5) } + private var yMax: Double { max(60, ceil(result.hyperComponent / 10) * 10) } + + private let zones: [(score: Double, title: LocalizedStringKey, color: Color)] = [ + (20, "Zone A (0-20)", Color(red: 0.48, green: 0.74, blue: 0.46)), + (40, "Zone B (>20-40)", Color(red: 0.98, green: 0.96, blue: 0.48)), + (60, "Zone C (>40-60)", Color(red: 1.00, green: 0.82, blue: 0.49)), + (80, "Zone D (>60-80)", Color(red: 0.95, green: 0.46, blue: 0.48)), + (100, "Zone E (>80-100)", Color(red: 0.79, green: 0.57, blue: 0.55)) + ] + + var body: some View { + GeometryReader { geometry in + Canvas { context, size in + let side = max(1, size.width - 60) + let plot = CGRect(x: 48, y: 8, width: side, height: side) + let background = Color(uiColor: .systemBackground) + let labelFont = Font.system(size: 11) + + var plotContext = context + plotContext.clip(to: Path(plot)) + plotContext.fill(Path(plot), with: .color(background)) + for zone in zones.reversed() { + var band = Path() + band.move(to: CGPoint(x: plot.minX, y: plot.maxY)) + band.addLine(to: CGPoint(x: plot.minX + zone.score / 3 / xMax * side, y: plot.maxY)) + band.addLine(to: CGPoint(x: plot.minX, y: plot.maxY - zone.score / 1.6 / yMax * side)) + band.closeSubpath() + plotContext.fill(band, with: .color(background)) + plotContext.fill(band, with: .color(zone.color.opacity(colorScheme == .dark ? 0.75 : 1))) + } + context.stroke(Path(plot), with: .foreground, lineWidth: 1) + + for step in 0 ... 6 { + let fraction = Double(step) / 6 + context.draw( + Text((fraction * xMax).formatted(.number.precision(.fractionLength(0 ... 1)))) + .font(labelFont).foregroundStyle(.primary), + at: CGPoint(x: plot.minX + fraction * side, y: plot.maxY + 12) + ) + context.draw( + Text((fraction * yMax).formatted(.number.precision(.fractionLength(0 ... 1)))) + .font(labelFont).foregroundStyle(.primary), + at: CGPoint(x: plot.minX - 7, y: plot.maxY - fraction * side), + anchor: .trailing + ) + } + context.draw( + Text("Hypoglycemia Component (%)").font(.caption).foregroundStyle(.primary), + at: CGPoint(x: plot.midX, y: plot.maxY + 35) + ) + var verticalContext = context + verticalContext.translateBy(x: 10, y: plot.midY) + verticalContext.rotate(by: .degrees(-90)) + verticalContext.draw( + Text("Hyperglycemia Component (%)").font(.caption).foregroundStyle(.primary), + at: .zero + ) + + let legendFont = Font.system(size: min(12, side * 0.042)) + let rowHeight: CGFloat = 18 + let legendWidth = min(146, side * 0.55) + let legend = CGRect( + x: plot.maxX - legendWidth - 10, + y: plot.minY + 16, + width: legendWidth, + height: rowHeight * 5 + 12 + ) + context.fill(Path(legend), with: .color(background)) + context.stroke(Path(legend), with: .foreground, lineWidth: 1) + for (index, zone) in zones.enumerated() { + let y = legend.minY + 6 + CGFloat(index) * rowHeight + rowHeight / 2 + let swatch = CGRect(x: legend.minX + 6, y: y - 5, width: 10, height: 10) + context.fill(Path(swatch), with: .color(zone.color.opacity(colorScheme == .dark ? 0.75 : 1))) + context.stroke(Path(swatch), with: .foreground, lineWidth: 0.5) + context.draw( + Text(zone.title).font(legendFont).foregroundStyle(.primary), + at: CGPoint(x: swatch.maxX + 5, y: y), anchor: .leading + ) + } + + let point = CGPoint( + x: plot.minX + result.hypoComponent / xMax * side, + y: plot.maxY - result.hyperComponent / yMax * side + ) + let marker = Path(ellipseIn: CGRect(x: point.x - 5, y: point.y - 5, width: 10, height: 10)) + context.fill(marker, with: .foreground) + context.stroke(marker, with: .color(background), lineWidth: 2) + } + .frame(height: geometry.size.width - 8) + } + .aspectRatio(1, contentMode: .fit) + .accessibilityElement(children: .ignore) + .accessibilityLabel("GRI risk grid, zone \(result.zone)") + .accessibilityValue( + "Hypoglycemia \(result.hypoComponent.formatted()) percent, hyperglycemia \(result.hyperComponent.formatted()) percent" + ) + } +} diff --git a/Trio/Sources/Modules/UserInterfaceSettings/UserInterfaceSettingsStateModel.swift b/Trio/Sources/Modules/UserInterfaceSettings/UserInterfaceSettingsStateModel.swift index c4f25808bf..c97a770da6 100644 --- a/Trio/Sources/Modules/UserInterfaceSettings/UserInterfaceSettingsStateModel.swift +++ b/Trio/Sources/Modules/UserInterfaceSettings/UserInterfaceSettingsStateModel.swift @@ -19,6 +19,7 @@ extension UserInterfaceSettings { @Published var glucoseColorScheme: GlucoseColorScheme = .dynamicColor @Published var eA1cDisplayUnit: EstimatedA1cDisplayUnit = .percent @Published var timeInRangeType: TimeInRangeType = .timeInTightRange + @Published var homeStatsPanelRange: HomeStatsPanelRange = .today @Published var requireAdjustmentsConfirmation: Bool = false @Published var currentGlucoseTarget: Decimal = 100 @@ -57,6 +58,7 @@ extension UserInterfaceSettings { subscribeSetting(\.eA1cDisplayUnit, on: $eA1cDisplayUnit) { eA1cDisplayUnit = $0 } subscribeSetting(\.timeInRangeType, on: $timeInRangeType) { timeInRangeType = $0 } + subscribeSetting(\.homeStatsPanelRange, on: $homeStatsPanelRange) { homeStatsPanelRange = $0 } subscribeSetting(\.requireAdjustmentsConfirmation, on: $requireAdjustmentsConfirmation) { requireAdjustmentsConfirmation = $0 } diff --git a/Trio/Sources/Modules/UserInterfaceSettings/View/UserInterfaceSettingsRootView.swift b/Trio/Sources/Modules/UserInterfaceSettings/View/UserInterfaceSettingsRootView.swift index 0d41df134c..b66bab147d 100644 --- a/Trio/Sources/Modules/UserInterfaceSettings/View/UserInterfaceSettingsRootView.swift +++ b/Trio/Sources/Modules/UserInterfaceSettings/View/UserInterfaceSettingsRootView.swift @@ -236,7 +236,7 @@ extension UserInterfaceSettings { selectedVerboseHint = AnyView( Text( - "Choose what the statistics panel on the home screen shows by default. Tapping the panel always opens the full statistics view.\n\nTime in Range: today's time in range percentage with a glucose distribution bar.\n\nDistribution Bar Only: just the glucose distribution bar, without the percentage.\n\nToday's Averages: today's average glucose and GMI (Glucose Management Index).\n\nActive Profile: the active profile card. Temporary (expiring) profiles are always shown regardless of this setting.\n\nHide Statistics: shows no data at all, just a \"View Statistics\" shortcut." + "Choose what the statistics panel on the home screen shows by default. Tapping the panel always opens the full statistics view.\n\nTime in Range: time in range percentage with a glucose distribution bar.\n\nDistribution Bar Only: just the glucose distribution bar, without the percentage.\n\nToday's Averages: average glucose and GMI (Glucose Management Index).\n\nActive Profile: the active profile card. Temporary (expiring) profiles are always shown regardless of this setting.\n\nHide Statistics: shows no data at all, just a \"View Statistics\" shortcut." ) ) shouldDisplayHint.toggle() @@ -715,6 +715,48 @@ extension UserInterfaceSettings { .listRowBackground(Color.chart) .settingsSearchTarget(label: String(localized: "Time in Range Type")) + Section { + VStack(alignment: .leading) { + Picker( + selection: $state.homeStatsPanelRange, + label: Text("Home Statistics Range").multilineTextAlignment(.leading) + ) { + ForEach(HomeStatsPanelRange.allCases) { selection in + Text(selection.displayName).tag(selection) + } + }.padding(.top) + + HStack(alignment: .center) { + Text( + "Choose the time span the home statistics panel covers." + ) + .font(.footnote) + .foregroundColor(.secondary) + .lineLimit(nil) + .multilineTextAlignment(.leading) + .fixedSize(horizontal: false, vertical: true) + Spacer() + Button( + action: { + hintLabel = String(localized: "Home Statistics Range") + selectedVerboseHint = + AnyView( + Text( + "Choose how far back the statistics panel on the home screen looks. The full statistics view opens on the same time span.\n\nToday: from midnight until now.\n\nLast 24 Hours: the past 24 hours.\n\nLast 7 Days: the past 7 days.\n\nLast Month: the past month.\n\nLast 3 Months: the past 3 months.\n\nThe panel's wording follows your choice, for example \"Time in Range last 7 days\"." + ) + ) + shouldDisplayHint.toggle() + }, + label: { + HStack { + Image(systemName: "questionmark.circle").accessibilityLabel(Text("More information")) + } + } + ).buttonStyle(BorderlessButtonStyle()) + }.padding(.top) + }.padding(.bottom) + }.settingsSearchTarget(label: String(localized: "Home Statistics Range")) + SettingInputSection( decimalValue: $state.carbsRequiredThreshold, booleanValue: $state.showCarbsRequiredBadge, diff --git a/Trio/Sources/Services/Alerts/CGMManagerAlertOwnership.swift b/Trio/Sources/Services/Alerts/CGMManagerAlertOwnership.swift index 228c1d98a0..40bc3a71bb 100644 --- a/Trio/Sources/Services/Alerts/CGMManagerAlertOwnership.swift +++ b/Trio/Sources/Services/Alerts/CGMManagerAlertOwnership.swift @@ -32,7 +32,10 @@ enum CGMManagerAlertOwnership { return OwningApp(name: "Dexcom G5", deepLink: nil) case is G6CGMManager: return OwningApp(name: "Dexcom G6 / One", deepLink: URL(string: "dexcomg6://")) - case is G7CGMManager: + case let manager as G7CGMManager: + // Only an eavesdropped session has a Dexcom app behind it; direct + // pairing requires that app to be gone, so nothing else alerts. + guard manager.sessionMode == .eavesdropping else { return nil } return OwningApp(name: "Dexcom G7 / One+", deepLink: URL(string: "dexcomg7://")) case is LibreTransmitterManagerV3: return OwningApp(name: "FreeStyle Libre", deepLink: nil) diff --git a/Trio/Sources/Services/Alerts/GlucoseAlertCoordinator.swift b/Trio/Sources/Services/Alerts/GlucoseAlertCoordinator.swift index 7c7c4c4751..389d8f3e3d 100644 --- a/Trio/Sources/Services/Alerts/GlucoseAlertCoordinator.swift +++ b/Trio/Sources/Services/Alerts/GlucoseAlertCoordinator.swift @@ -75,6 +75,10 @@ final class GlucoseAlertCoordinator: Injectable { } } + static func alarmsToRetractWhenCGMOwnsAlerts(firing: Set, in alarms: [GlucoseAlert]) -> [GlucoseAlert] { + alarms.filter { firing.contains($0.id) && $0.type.isReadingDriven } + } + /// Readings older than this are considered stale and won't drive new /// alarms — matches `APSManager`'s loop-input freshness gate (12 min, /// allowing for one missed CGM transmission on a 5-min schedule). @@ -130,7 +134,7 @@ final class GlucoseAlertCoordinator: Injectable { private func evaluateGlucoseAlarms() async { guard !isInLaunchQuietWindow else { return } guard effectiveTrioAlertsEnabled else { - retractAllFiringIfNeeded() + retractReadingDrivenFiringAlarms() return } guard let latestValue = await fetchLatestReadingMgDL() else { return } @@ -317,14 +321,16 @@ final class GlucoseAlertCoordinator: Injectable { trioAlertManager.retractAlert(identifier: alertID(for: alarm)) } - private func retractAllFiringIfNeeded() { + private func retractReadingDrivenFiringAlarms() { evaluationQueue.async { [weak self] in guard let self, !self.firingAlertIDs.isEmpty else { return } - let snapshot = self.alertsSnapshot - for alarm in snapshot where self.firingAlertIDs.contains(alarm.id) { - self.trioAlertManager.retractAlert(identifier: self.alertID(for: alarm)) + let toRetract = Self.alarmsToRetractWhenCGMOwnsAlerts( + firing: self.firingAlertIDs, + in: self.alertsSnapshot + ) + for alarm in toRetract { + self.retractIfFiring(alarm) } - self.firingAlertIDs.removeAll() } } diff --git a/Trio/Sources/Services/ContactImage/ContactImageManager.swift b/Trio/Sources/Services/ContactImage/ContactImageManager.swift index f55e58eb77..d33305e9b3 100644 --- a/Trio/Sources/Services/ContactImage/ContactImageManager.swift +++ b/Trio/Sources/Services/ContactImage/ContactImageManager.swift @@ -211,6 +211,7 @@ final class BaseContactImageManager: NSObject, ContactImageManager, Injectable { : Decimal(firstGlucoseValue.glucose).asMmolL state.glucose = Formatter.glucoseFormatter(for: units).string(from: value as NSNumber) + state.glucoseValue = value state.trend = firstGlucoseValue.directionEnum?.symbol state.direction = firstGlucoseValue.directionEnum state.glucoseDate = firstGlucoseValue.date @@ -223,6 +224,7 @@ final class BaseContactImageManager: NSObject, ContactImageManager, Injectable { } else { // fetchGlucose() only looks back 20 minutes, so an empty result means dropout. state.glucose = nil + state.glucoseValue = nil state.trend = nil state.direction = nil state.glucoseDate = nil diff --git a/Trio/Sources/Services/ContactImage/ContactImageState.swift b/Trio/Sources/Services/ContactImage/ContactImageState.swift index 7794c56f4f..a4f86d0a50 100644 --- a/Trio/Sources/Services/ContactImage/ContactImageState.swift +++ b/Trio/Sources/Services/ContactImage/ContactImageState.swift @@ -2,6 +2,8 @@ import Foundation struct ContactImageState: Codable { var glucose: String? + /// Latest glucose reading in display units, matching the color threshold values below. + var glucoseValue: Decimal? var trend: String? var direction: BloodGlucose.Direction? var glucoseDate: Date? diff --git a/Trio/Sources/Services/ContactImage/ContactPicture.swift b/Trio/Sources/Services/ContactImage/ContactPicture.swift index f6de2873ba..889b0e15b4 100644 --- a/Trio/Sources/Services/ContactImage/ContactPicture.swift +++ b/Trio/Sources/Services/ContactImage/ContactPicture.swift @@ -306,9 +306,8 @@ struct ContactPicture: View { } private static func dynamicGlucoseColor(for state: ContactImageState) -> Color { - let glucoseValue = Decimal(string: state.glucose ?? "100") ?? 100 - return Trio.getDynamicGlucoseColor( - glucoseValue: glucoseValue, + Trio.getDynamicGlucoseColor( + glucoseValue: state.glucoseValue ?? 100, highGlucoseColorValue: state.highGlucoseColorValue, lowGlucoseColorValue: state.lowGlucoseColorValue, targetGlucose: state.targetGlucose, diff --git a/Trio/Sources/Services/HealthKit/HealthKitManager.swift b/Trio/Sources/Services/HealthKit/HealthKitManager.swift index bc403cdeae..c6fb6ab3d0 100644 --- a/Trio/Sources/Services/HealthKit/HealthKitManager.swift +++ b/Trio/Sources/Services/HealthKit/HealthKitManager.swift @@ -722,10 +722,10 @@ final class BaseHealthKitManager: HealthKitManager, Injectable { private func deleteObjects(of sampleType: HKSampleType, predicate: NSPredicate) async throws { try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in - healthKitStore.deleteObjects(of: sampleType, predicate: predicate) { success, _, error in + healthKitStore.deleteObjects(of: sampleType, predicate: predicate) { _, _, error in if let error = error { continuation.resume(throwing: error) - } else if success { + } else { continuation.resume(returning: ()) } } diff --git a/Trio/Sources/Services/LiveActivity/LiveActitiyAttributes.swift b/Trio/Sources/Services/LiveActivity/LiveActitiyAttributes.swift deleted file mode 100644 index 351ceae4c6..0000000000 --- a/Trio/Sources/Services/LiveActivity/LiveActitiyAttributes.swift +++ /dev/null @@ -1,69 +0,0 @@ -import ActivityKit -import Foundation - -struct LiveActivityAttributes: ActivityAttributes { - enum LiveActivityItem: String, Hashable, Codable, Equatable { - case currentGlucoseLarge - case currentGlucose - case iob - case cob - case updatedLabel - case totalDailyDose - case empty - - static let defaultItems: [Self] = [.currentGlucoseLarge, .iob, .cob, .updatedLabel] - } - - struct ContentState: Codable, Hashable { - let unit: String - let bg: String - let direction: String? - let change: String - let date: Date? - let highGlucose: Decimal - let lowGlucose: Decimal - let target: Decimal - let glucoseColorScheme: String - let useDetailedViewIOS: Bool - let useDetailedViewWatchOS: Bool - let detailedViewState: ContentAdditionalState - - /// true for the first state that is set on the activity - let isInitialState: Bool - } - - struct ContentAdditionalState: Codable, Hashable { - let chart: [ChartItem] - let rotationDegrees: Double - let cob: Decimal - let iob: Decimal - let tdd: Decimal - let isOverrideActive: Bool - let overrideName: String - let overrideDate: Date - let overrideDuration: Decimal - let overrideTarget: Decimal - let isTempTargetActive: Bool - let tempTargetName: String - let tempTargetDate: Date - let tempTargetDuration: Decimal - let tempTargetTarget: Decimal - let widgetItems: [LiveActivityItem] - let minForecast: [Int] - let maxForecast: [Int] - let forecastLines: [ForecastLine] - let forecastDisplayType: String - } - - struct ChartItem: Codable, Hashable { - let value: Decimal - let date: Date - } - - struct ForecastLine: Codable, Hashable { - let type: String - let values: [Int] - } - - let startDate: Date -} diff --git a/Trio/Sources/Services/LiveActivity/LiveActivityAttributes+Helper.swift b/Trio/Sources/Services/LiveActivity/LiveActivityAttributes+Helper.swift index f70eec3029..6bc456a76a 100644 --- a/Trio/Sources/Services/LiveActivity/LiveActivityAttributes+Helper.swift +++ b/Trio/Sources/Services/LiveActivity/LiveActivityAttributes+Helper.swift @@ -19,6 +19,12 @@ extension UserDefaults { } } +extension LiveActivityAttributes.SimpleViewStyle { + init(settings: TrioSettings) { + self.init(fontSize: settings.liveActivitySimpleFontSize) + } +} + extension LiveActivityAttributes.ContentState { static func formatGlucose(_ value: Int, units: GlucoseUnits, forceSign: Bool) -> String { let formatter = NumberFormatter() @@ -133,6 +139,7 @@ extension LiveActivityAttributes.ContentState { self.init( unit: settings.units.rawValue, bg: formattedBG, + glucose: Decimal(glucose), direction: trendString, change: change, date: determination?.date ?? nil, @@ -142,6 +149,7 @@ extension LiveActivityAttributes.ContentState { glucoseColorScheme: settings.glucoseColorScheme.rawValue, useDetailedViewIOS: settings.lockScreenView == .detailed, useDetailedViewWatchOS: settings.smartStackView == .detailed, + simpleViewStyle: LiveActivityAttributes.SimpleViewStyle(settings: settings), detailedViewState: detailedState, isInitialState: false ) diff --git a/Trio/Sources/Services/LiveActivity/LiveActivityAttributes.swift b/Trio/Sources/Services/LiveActivity/LiveActivityAttributes.swift index 5037745633..68401f47c3 100644 --- a/Trio/Sources/Services/LiveActivity/LiveActivityAttributes.swift +++ b/Trio/Sources/Services/LiveActivity/LiveActivityAttributes.swift @@ -2,21 +2,81 @@ import ActivityKit import Foundation struct LiveActivityAttributes: ActivityAttributes { + enum LiveActivityItem: String, Hashable, Codable, Equatable { + case currentGlucoseLarge + case currentGlucoseLargeUncolored + case currentGlucose + case currentGlucoseColored + case currentGlucoseWide + case currentGlucoseWideUncolored + case iob + case cob + case updatedLabel + case totalDailyDose + case empty + case wideContinuation + + static let defaultItems: [Self] = [.currentGlucoseLarge, .iob, .cob, .updatedLabel] + } + + struct SimpleViewStyle: Codable, Hashable { + let fontSize: LiveActivityFontSize + + static let `default` = SimpleViewStyle(fontSize: .large) + } + struct ContentState: Codable, Hashable { + let unit: String let bg: String + /// Latest glucose reading in mg/dL; nil when no reading is shown. + let glucose: Decimal? let direction: String? let change: String - let date: Date - let chart: [Double] - let chartDate: [Date?] + let date: Date? + let highGlucose: Decimal + let lowGlucose: Decimal + let target: Decimal + let glucoseColorScheme: String + let useDetailedViewIOS: Bool + let useDetailedViewWatchOS: Bool + let simpleViewStyle: SimpleViewStyle + let detailedViewState: ContentAdditionalState + + /// true for the first state that is set on the activity + let isInitialState: Bool + } + + struct ContentAdditionalState: Codable, Hashable { + let chart: [ChartItem] let rotationDegrees: Double - let highGlucose: Double - let lowGlucose: Double let cob: Decimal let iob: Decimal - let lockScreenView: String - let unit: String + let tdd: Decimal let isOverrideActive: Bool + let overrideName: String + let overrideDate: Date + let overrideDuration: Decimal + let overrideTarget: Decimal + let isTempTargetActive: Bool + let tempTargetName: String + let tempTargetDate: Date + let tempTargetDuration: Decimal + let tempTargetTarget: Decimal + let widgetItems: [LiveActivityItem] + let minForecast: [Int] + let maxForecast: [Int] + let forecastLines: [ForecastLine] + let forecastDisplayType: String + } + + struct ChartItem: Codable, Hashable { + let value: Decimal + let date: Date + } + + struct ForecastLine: Codable, Hashable { + let type: String + let values: [Int] } let startDate: Date diff --git a/Trio/Sources/Services/LiveActivity/LiveActivityGlucoseStyle.swift b/Trio/Sources/Services/LiveActivity/LiveActivityGlucoseStyle.swift new file mode 100644 index 0000000000..36779b541c --- /dev/null +++ b/Trio/Sources/Services/LiveActivity/LiveActivityGlucoseStyle.swift @@ -0,0 +1,61 @@ +import Foundation +import SwiftUI +import UIKit + +enum LiveActivityFontSize: String, Codable, Hashable, CaseIterable, Identifiable { + case small + case medium + case large + case extraLarge + + var id: String { rawValue } + + var basePointSize: CGFloat { + switch self { + case .small: + return 30 + case .medium: + return 40 + case .large: + return 50 + case .extraLarge: + return 64 + } + } + + var verticalPadding: CGFloat? { + self == .extraLarge ? 0 : nil + } + + var uiTextStyle: UIFont.TextStyle { + switch self { + case .small: + return .title2 + case .medium: + return .title1 + case .extraLarge, + .large: + return .largeTitle + } + } + + var displayName: String { + switch self { + case .small: + return String(localized: "Small", comment: "Live Activity glucose reading font size") + case .medium: + return String(localized: "Medium", comment: "Live Activity glucose reading font size") + case .large: + return String(localized: "Large", comment: "Live Activity glucose reading font size") + case .extraLarge: + return String(localized: "Extra Large", comment: "Live Activity glucose reading font size") + } + } +} + +extension LiveActivityAttributes.SimpleViewStyle { + var glucoseFont: Font { + let scaled = UIFontMetrics(forTextStyle: fontSize.uiTextStyle).scaledValue(for: fontSize.basePointSize) + return .system(size: scaled, weight: .bold) + } +} diff --git a/Trio/Sources/Services/LiveActivity/LiveActivityManager.swift b/Trio/Sources/Services/LiveActivity/LiveActivityManager.swift index 632b4d8a87..72d9f25ed7 100644 --- a/Trio/Sources/Services/LiveActivity/LiveActivityManager.swift +++ b/Trio/Sources/Services/LiveActivity/LiveActivityManager.swift @@ -294,6 +294,7 @@ final class LiveActivityData: ObservableObject { .ContentState( unit: settings.units.rawValue, bg: "--", + glucose: nil, direction: nil, change: "--", date: Date.now, @@ -303,6 +304,7 @@ final class LiveActivityData: ObservableObject { glucoseColorScheme: settings.glucoseColorScheme.rawValue, useDetailedViewIOS: false, useDetailedViewWatchOS: false, + simpleViewStyle: LiveActivityAttributes.SimpleViewStyle(settings: settings), detailedViewState: LiveActivityAttributes.ContentAdditionalState( chart: [], rotationDegrees: 0, diff --git a/Trio/Sources/Services/Network/Nightscout/NightscoutManager.swift b/Trio/Sources/Services/Network/Nightscout/NightscoutManager.swift index e4fd26155b..fcbae3dbbd 100644 --- a/Trio/Sources/Services/Network/Nightscout/NightscoutManager.swift +++ b/Trio/Sources/Services/Network/Nightscout/NightscoutManager.swift @@ -10,7 +10,8 @@ protocol NightscoutManager: GlucoseSource { func fetchGlucose(since date: Date) async -> [BloodGlucose] func fetchCarbs() async -> [CarbsEntry] func fetchTempTargets() async -> [TempTarget] - func deleteCarbs(withID id: String) async + /// Returns false when Nightscout rejected the delete; unconfigured or upload-disabled Nightscout counts as success. + func deleteCarbs(withID id: String) async -> Bool func deleteInsulin(withID id: String) async func deleteGlucose(withID id: String, withDate date: Date) async func uploadCarbs() async @@ -436,17 +437,19 @@ final class BaseNightscoutManager: NightscoutManager, Injectable { } } - func deleteCarbs(withID id: String) async { - guard let nightscout = nightscoutAPI, isUploadEnabled else { return } + func deleteCarbs(withID id: String) async -> Bool { + guard let nightscout = nightscoutAPI, isUploadEnabled else { return true } do { try await nightscout.deleteCarbs(withId: id) debug(.nightscout, "Carbs deleted") + return true } catch { debug( .nightscout, "\(DebuggingIdentifiers.failed) Failed to delete Carbs from Nightscout with error: \(error)" ) + return false } } @@ -834,6 +837,7 @@ final class BaseNightscoutManager: NightscoutManager, Injectable { let presetOverrides = try await overridesStorage.getPresetOverridesForNightscout() let teamID = Bundle.main.object(forInfoDictionaryKey: "TeamID") as? String ?? "" let expireDate = BuildDetails.shared.calculateExpirationDate() + let remoteCommands = TrioRemoteControl.CommandType.supported.map(\.rawValue) let profileStore = NightscoutProfileStore( defaultProfile: defaultProfile, @@ -847,7 +851,8 @@ final class BaseNightscoutManager: NightscoutManager, Injectable { isAPNSProduction: isAPNSProduction, overridePresets: presetOverrides, teamID: teamID, - expirationDate: expireDate + expirationDate: expireDate, + remoteCommands: remoteCommands ) guard let nightscout = nightscoutAPI, isNetworkReachable else { @@ -861,6 +866,7 @@ final class BaseNightscoutManager: NightscoutManager, Injectable { try await nightscout.uploadProfile(profileStore) BuildDetails.shared.recordUploadedExpireDate(expireDate: expireDate) + BuildDetails.shared.recordUploadedRemoteCommands(remoteCommands) debug(.nightscout, "Profile uploaded") } catch { diff --git a/Trio/Sources/Services/RemoteControl/RemoteNotificationResponseManager.swift b/Trio/Sources/Services/RemoteControl/RemoteNotificationResponseManager.swift index ea5805bddb..8f9e778448 100644 --- a/Trio/Sources/Services/RemoteControl/RemoteNotificationResponseManager.swift +++ b/Trio/Sources/Services/RemoteControl/RemoteNotificationResponseManager.swift @@ -1,5 +1,19 @@ import Foundation +/// Correlation data echoed back to the sender of a remote command. +struct RemoteCommandAck: Sendable { + enum Result: String, Sendable { + case deleted + case updated + case notFound = "not_found" + case rejected + } + + let commandId: String? + let mealId: String? + let result: Result +} + class RemoteNotificationResponseManager { static let shared = RemoteNotificationResponseManager() @@ -10,12 +24,18 @@ class RemoteNotificationResponseManager { let commandStatus: String let commandType: String let timestamp: TimeInterval + let commandId: String? + let mealId: String? + let result: String? enum CodingKeys: String, CodingKey { case aps case commandStatus = "command_status" case commandType = "command_type" case timestamp + case commandId = "command_id" + case mealId = "meal_id" + case result } } @@ -33,7 +53,8 @@ class RemoteNotificationResponseManager { to returnInfo: CommandPayload.ReturnNotificationInfo?, commandType: TrioRemoteControl.CommandType, success: Bool, - message: String + message: String, + ack: RemoteCommandAck? = nil ) async { guard let returnInfo = returnInfo, !returnInfo.deviceToken.isEmpty @@ -51,7 +72,10 @@ class RemoteNotificationResponseManager { ), commandStatus: success ? "success" : "failed", commandType: commandType.rawValue, - timestamp: Date().timeIntervalSince1970 + timestamp: Date().timeIntervalSince1970, + commandId: ack?.commandId, + mealId: ack?.mealId, + result: ack?.result.rawValue ) await sendPushNotification( diff --git a/Trio/Sources/Services/RemoteControl/TrioRemoteControl+Helpers.swift b/Trio/Sources/Services/RemoteControl/TrioRemoteControl+Helpers.swift index f3ba31fd01..abf906b4bb 100644 --- a/Trio/Sources/Services/RemoteControl/TrioRemoteControl+Helpers.swift +++ b/Trio/Sources/Services/RemoteControl/TrioRemoteControl+Helpers.swift @@ -1,7 +1,7 @@ import Foundation extension TrioRemoteControl { - func logError(_ errorMessage: String, payload: CommandPayload? = nil) async { + func logError(_ errorMessage: String, payload: CommandPayload? = nil, ack: RemoteCommandAck? = nil) async { var note = errorMessage if let payload = payload { note += " Details: \(payload.humanReadableDescription())" @@ -11,7 +11,8 @@ extension TrioRemoteControl { to: returnInfo, commandType: payload.commandType, success: false, - message: errorMessage + message: errorMessage, + ack: ack ) } } @@ -19,7 +20,12 @@ extension TrioRemoteControl { await nightscoutManager.uploadNoteTreatment(note: note) } - func logSuccess(_ message: String, payload: CommandPayload, customNotificationMessage: String? = nil) async { + func logSuccess( + _ message: String, + payload: CommandPayload, + customNotificationMessage: String? = nil, + ack: RemoteCommandAck? = nil + ) async { debug(.remoteControl, message) if let returnInfo = payload.returnNotification { @@ -27,7 +33,8 @@ extension TrioRemoteControl { to: returnInfo, commandType: payload.commandType, success: true, - message: customNotificationMessage ?? "Command successful" + message: customNotificationMessage ?? "Command successful", + ack: ack ) } } diff --git a/Trio/Sources/Services/RemoteControl/TrioRemoteControl+Meal.swift b/Trio/Sources/Services/RemoteControl/TrioRemoteControl+Meal.swift index 2b21161ca8..dcd7a15764 100644 --- a/Trio/Sources/Services/RemoteControl/TrioRemoteControl+Meal.swift +++ b/Trio/Sources/Services/RemoteControl/TrioRemoteControl+Meal.swift @@ -1,6 +1,8 @@ import Foundation extension TrioRemoteControl { + static let remoteMealNote = "📡" + func handleMealCommand(_ payload: CommandPayload) async throws { guard payload.carbs != nil || payload.fat != nil || payload.protein != nil else { await logError("Command rejected: meal data is incomplete or invalid.", payload: payload) @@ -62,8 +64,8 @@ extension TrioRemoteControl { let mealEntry = CarbsEntry( id: UUID().uuidString, createdAt: Date(), actualDate: actualDate, - carbs: carbsDecimal ?? 0, fat: fatDecimal, protein: proteinDecimal, - note: "📡", enteredBy: CarbsEntry.local, isFPU: false, + carbs: carbsDecimal ?? 0, fat: fatDecimal ?? 0, protein: proteinDecimal ?? 0, + note: Self.remoteMealNote, enteredBy: CarbsEntry.local, isFPU: false, fpuID: fatDecimal ?? 0 > 0 || proteinDecimal ?? 0 > 0 ? UUID().uuidString : nil ) diff --git a/Trio/Sources/Services/RemoteControl/TrioRemoteControl+MealMutation.swift b/Trio/Sources/Services/RemoteControl/TrioRemoteControl+MealMutation.swift new file mode 100644 index 0000000000..af6c70eaab --- /dev/null +++ b/Trio/Sources/Services/RemoteControl/TrioRemoteControl+MealMutation.swift @@ -0,0 +1,221 @@ +import CoreData +import Foundation + +extension TrioRemoteControl { + /// Scheduled meals may be dated ahead up to this limit. + static let mealMutationMaxFuture: TimeInterval = 12 * 60 * 60 + + /// Meals older than one calendar day cannot be changed remotely; matches the upload window (`Date.oneDayAgo`). + static func validateMealAge(_ date: Date, now: Date = Date(), label: String = "meal") -> String? { + if let oldest = Calendar.current.date(byAdding: .day, value: -1, to: now), date < oldest { + return "Command rejected: the \(label) is older than 24 hours." + } + if now.timeIntervalSince(date) < -mealMutationMaxFuture { + return "Command rejected: the \(label) is more than 12 hours in the future." + } + return nil + } + + static func validateEditMealInput( + carbs: Int, + fat: Int, + protein: Int, + maxCarbs: Decimal, + maxFat: Decimal, + maxProtein: Decimal + ) -> String? { + guard carbs >= 0, fat >= 0, protein >= 0 else { + return "Command rejected: meal amounts cannot be negative." + } + guard carbs > 0 || fat > 0 || protein > 0 else { + return "Command rejected: all meal amounts are zero. Use delete_meal to remove the meal." + } + if Decimal(carbs) > maxCarbs { + return "Command rejected: carbs amount (\(carbs)g) exceeds the maximum allowed (\(maxCarbs)g)." + } + if Decimal(fat) > maxFat { + return "Command rejected: fat amount (\(fat)g) exceeds the maximum allowed (\(maxFat)g)." + } + if Decimal(protein) > maxProtein { + return "Command rejected: protein amount (\(protein)g) exceeds the maximum allowed (\(maxProtein)g)." + } + return nil + } + + /// Carb equivalents are tied to their meal by `fpuID`; a meal with fat or protein and no `fpuID` cannot be changed as one family. + static func validateMealLink(_ snapshot: MealSnapshot) -> String? { + guard snapshot.fpuID == nil, snapshot.fat > 0 || snapshot.protein > 0 else { return nil } + return "Command rejected: this meal's fat/protein entries are not linked and it cannot be changed remotely." + } + + func handleDeleteMealCommand(_ payload: CommandPayload) async throws { + guard payload.carbs == nil, payload.fat == nil, payload.protein == nil, payload.scheduledTime == nil, + payload.bolusAmount == nil, payload.target == nil, payload.duration == nil, payload.overrideName == nil + else { + await rejectMealMutation(payload, "Command rejected: delete_meal cannot carry meal, bolus or override values.") + return + } + guard let handle = payload.mealHandle else { + await rejectMealMutation(payload, "Command rejected: meal_id is missing or not a valid UUID.") + return + } + guard let target = await resolveMealRoot(handle, payload: payload) else { return } + if let message = Self.validateMealAge(target.snapshot.date) ?? Self.validateMealLink(target.snapshot) { + await rejectMealMutation(payload, message) + return + } + + let failures: [String] + do { + failures = try await carbEntryMutationService.deleteMeal(rootObjectID: target.objectID) + } catch { + await rejectFailedMutation(payload, handle: handle, error: error) + return + } + await recalculateAfterMealMutation() + + await logSuccess( + "Remote command processed successfully. \(payload.humanReadableDescription())", + payload: payload, + customNotificationMessage: Self.mealMutationMessage("Meal deleted", failures: failures), + ack: RemoteCommandAck(commandId: payload.commandId, mealId: handle.uuidString, result: .deleted) + ) + } + + func handleEditMealCommand(_ payload: CommandPayload) async throws { + guard payload.bolusAmount == nil, payload.target == nil, payload.duration == nil, payload.overrideName == nil else { + await rejectMealMutation(payload, "Command rejected: edit_meal cannot carry bolus or override values.") + return + } + guard let handle = payload.mealHandle else { + await rejectMealMutation(payload, "Command rejected: meal_id is missing or not a valid UUID.") + return + } + guard let carbs = payload.carbs, let fat = payload.fat, let protein = payload.protein, + let scheduledTime = payload.scheduledTime + else { + await rejectMealMutation(payload, "Command rejected: edit_meal requires carbs, fat, protein and scheduled_time.") + return + } + + let limits = settings.settings + if let message = Self.validateEditMealInput( + carbs: carbs, + fat: fat, + protein: protein, + maxCarbs: limits.maxCarbs, + maxFat: limits.maxFat, + maxProtein: limits.maxProtein + ) { + await rejectMealMutation(payload, message) + return + } + + let newDate = Date(timeIntervalSince1970: scheduledTime) + if let message = Self.validateMealAge(newDate, label: "new meal time") { + await rejectMealMutation(payload, message) + return + } + + guard let target = await resolveMealRoot(handle, payload: payload) else { return } + guard handle == target.snapshot.id else { + await rejectMealMutation(payload, "Command rejected: meal_id refers to a carb equivalent, not the meal itself.") + return + } + if let message = Self.validateMealAge(target.snapshot.date) ?? Self.validateMealLink(target.snapshot) { + await rejectMealMutation(payload, message) + return + } + + let note = target.snapshot.note.flatMap { $0.isEmpty ? nil : $0 } ?? Self.remoteMealNote + let replacement = CarbsEntry( + id: UUID().uuidString, + createdAt: Date(), + actualDate: newDate, + carbs: Decimal(carbs), + fat: Decimal(fat), + protein: Decimal(protein), + note: note, + enteredBy: CarbsEntry.local, + isFPU: false, + fpuID: fat > 0 || protein > 0 ? UUID().uuidString : nil + ) + + let newID: UUID + let failures: [String] + do { + (newID, failures) = try await carbEntryMutationService.replaceMeal( + rootObjectID: target.objectID, + with: replacement + ) + } catch { + await rejectFailedMutation(payload, handle: handle, error: error) + return + } + await recalculateAfterMealMutation() + + await logSuccess( + "Remote command processed successfully. \(payload.humanReadableDescription())", + payload: payload, + customNotificationMessage: Self.mealMutationMessage("Meal updated", failures: failures), + ack: RemoteCommandAck(commandId: payload.commandId, mealId: newID.uuidString, result: .updated) + ) + } + + private func resolveMealRoot( + _ handle: UUID, + payload: CommandPayload + ) async -> (objectID: NSManagedObjectID, snapshot: MealSnapshot)? { + do { + guard let target = try await carbsStorage.fetchMealRoot(handle: handle) else { + await reportMealNotFound(payload, handle: handle) + return nil + } + return target + } catch CarbsStorageError.ambiguousMealHandle { + await rejectMealMutation(payload, "Command rejected: meal_id matches more than one meal.") + return nil + } catch { + await rejectMealMutation(payload, "Command rejected: the meal could not be read: \(error.localizedDescription)") + return nil + } + } + + private func reportMealNotFound(_ payload: CommandPayload, handle: UUID) async { + await logError( + "Command rejected: the meal was not found. It may already have been deleted or changed on the phone.", + payload: payload, + ack: RemoteCommandAck(commandId: payload.commandId, mealId: handle.uuidString, result: .notFound) + ) + } + + private func rejectFailedMutation(_ payload: CommandPayload, handle: UUID, error: Error) async { + if case CarbEntryMutationError.rootNotFound = error { + await reportMealNotFound(payload, handle: handle) + return + } + await rejectMealMutation(payload, "Command rejected: the meal could not be changed: \(error.localizedDescription)") + } + + private func rejectMealMutation(_ payload: CommandPayload, _ message: String) async { + await logError( + message, + payload: payload, + ack: RemoteCommandAck(commandId: payload.commandId, mealId: payload.mealId, result: .rejected) + ) + } + + /// The meal change is already saved; a failed determination only delays the COB update to the next loop cycle. + private func recalculateAfterMealMutation() async { + do { + try await apsManager.determineBasalSync() + } catch { + debug(.remoteControl, "COB recalculation after meal change failed: \(error.localizedDescription)") + } + } + + private static func mealMutationMessage(_ base: String, failures: [String]) -> String { + guard !failures.isEmpty else { return base } + return "\(base). \(failures.joined(separator: "; ")); the entry may still show in Nightscout." + } +} diff --git a/Trio/Sources/Services/RemoteControl/TrioRemoteControl.swift b/Trio/Sources/Services/RemoteControl/TrioRemoteControl.swift index 9e6acdec3a..be4da152f4 100644 --- a/Trio/Sources/Services/RemoteControl/TrioRemoteControl.swift +++ b/Trio/Sources/Services/RemoteControl/TrioRemoteControl.swift @@ -12,6 +12,8 @@ class TrioRemoteControl: Injectable { @Injected() internal var adjustmentManager: AdjustmentManager! @Injected() internal var settings: SettingsManager! @Injected() internal var bolusSafetyValidator: BolusSafetyValidator! + @Injected() internal var carbEntryMutationService: CarbEntryMutationService! + @Injected() internal var apsManager: APSManager! private let timeWindow: TimeInterval = 600 @@ -53,13 +55,15 @@ class TrioRemoteControl: Injectable { if timeDifference > timeWindow { await logError( "Command rejected: the message is too old (sent \(Int(timeDifference)) seconds ago).", - payload: commandPayload + payload: commandPayload, + ack: RemoteCommandAck(commandId: commandPayload.commandId, mealId: commandPayload.mealId, result: .rejected) ) return } else if timeDifference < -timeWindow { await logError( "Command rejected: the message has an invalid future timestamp.", - payload: commandPayload + payload: commandPayload, + ack: RemoteCommandAck(commandId: commandPayload.commandId, mealId: commandPayload.mealId, result: .rejected) ) return } @@ -82,6 +86,16 @@ class TrioRemoteControl: Injectable { await handleStartOverrideCommand(commandPayload) case .cancelOverride: await handleCancelOverrideCommand(commandPayload) + case .deleteMeal: + try await handleDeleteMealCommand(commandPayload) + case .editMeal: + try await handleEditMealCommand(commandPayload) + case .unknown: + await logError( + "Command rejected: unsupported command type.", + payload: commandPayload, + ack: RemoteCommandAck(commandId: commandPayload.commandId, mealId: commandPayload.mealId, result: .rejected) + ) } } } diff --git a/Trio/Sources/Services/UserNotifications/UserNotificationsManager.swift b/Trio/Sources/Services/UserNotifications/UserNotificationsManager.swift index 991574bce8..a61ffd0bc2 100644 --- a/Trio/Sources/Services/UserNotifications/UserNotificationsManager.swift +++ b/Trio/Sources/Services/UserNotifications/UserNotificationsManager.swift @@ -51,6 +51,7 @@ final class BaseUserNotificationsManager: NSObject, UserNotificationsManager, In super.init() notificationCenter.delegate = self injectServices(resolver) + broadcaster.register(SettingsObserver.self, observer: self) coreDataPublisher = CoreDataStack.shared.entityChangePublisher @@ -398,3 +399,9 @@ extension BaseUserNotificationsManager: UNUserNotificationCenterDelegate { } } } + +extension BaseUserNotificationsManager: SettingsObserver { + func settingsDidChange(_: TrioSettings) { + Task { await updateGlucoseBadge() } + } +} diff --git a/TrioTests/AlarmSoundCatalogTests.swift b/TrioTests/AlarmSoundCatalogTests.swift new file mode 100644 index 0000000000..d8e98fd895 --- /dev/null +++ b/TrioTests/AlarmSoundCatalogTests.swift @@ -0,0 +1,107 @@ +import Foundation +import Testing + +@testable import Trio + +@Suite("Trio Alerts: alarm sound catalog") struct AlarmSoundCatalogTests { + @Test("Every catalog filename is bundled at the main bundle root") func allBundled() { + for filename in AlarmSoundCatalog.allFilenames { + let resource = (filename as NSString).deletingPathExtension + let ext = (filename as NSString).pathExtension + #expect( + Bundle.main.url(forResource: resource, withExtension: ext) != nil, + "\(filename) is missing from the bundle root" + ) + } + } + + @Test("Filenames are unique across the catalog") func noDuplicateFilenames() { + let all = AlarmSoundCatalog.allFilenames + #expect(Set(all).count == all.count) + } + + @Test("Every glucose alarm gets Acoustic, Electronic and Voice") func glucoseThemedCoverage() { + for type in GlucoseAlertType.allCases { + let ids = AlarmSoundCatalog.themedEntries(for: .glucose(type)).map(\.id) + #expect(ids.contains(where: { $0.hasPrefix("acoustic.") }), "no acoustic set for \(type)") + #expect(ids.contains(where: { $0.hasPrefix("electronic.") }), "no electronic set for \(type)") + #expect(ids.contains(where: { $0.hasPrefix("voice.") }), "no voice set for \(type)") + } + } + + @Test("Every device tier gets Acoustic, Electronic and Voice") func deviceThemedCoverage() { + for tier in DeviceAlertSeverity.allCases { + let ids = AlarmSoundCatalog.themedEntries(for: .deviceTier(tier)).map(\.id) + #expect(ids.contains(where: { $0.hasPrefix("acoustic.") }), "no acoustic set for \(tier)") + #expect(ids.contains(where: { $0.hasPrefix("electronic.") }), "no electronic set for \(tier)") + #expect(ids.contains(where: { $0.hasPrefix("voice.") }), "no voice set for \(tier)") + } + } + + @Test("A themed set is offered only on the alarm it was written for") func themedSetsAreScoped() { + let urgentLow = AlarmSoundCatalog.themedEntries(for: .glucose(.urgentLow)).map(\.id) + #expect(urgentLow.contains("acoustic.ulg")) + #expect(!urgentLow.contains("acoustic.hg")) + let critical = AlarmSoundCatalog.themedEntries(for: .deviceTier(.critical)).map(\.id) + #expect(critical.contains("acoustic.dcr")) + #expect(!critical.contains("acoustic.dts")) + } + + @Test("Urgency sets carry three levels, voice sets two") func optionCounts() { + for entry in AlarmSoundCatalog.entries { + switch entry.selector { + case .urgency: #expect(entry.options.count == AlarmUrgencyLevel.allCases.count) + case .voice: #expect(entry.options.count == AlarmVoice.allCases.count) + case .none: #expect(entry.options.count == 1) + } + } + } + + @Test("Defaults are Acoustic Medium everywhere") func defaultsAreAcousticMedium() { + for type in GlucoseAlertType.allCases { + let name = type.defaultSoundFilename + #expect(name.hasPrefix("acoustic_"), "\(type) default is \(name)") + #expect(name.hasSuffix("_medium.caf"), "\(type) default is \(name)") + } + for tier in DeviceAlertSeverity.allCases { + let name = tier.defaultSoundFilename + #expect(name.hasPrefix("acoustic_"), "\(tier) default is \(name)") + #expect(name.hasSuffix("_medium.caf"), "\(tier) default is \(name)") + } + } + + @Test("Each alarm's default resolves back to its own themed entry") func defaultsResolveToOwnScope() { + for type in GlucoseAlertType.allCases { + let scope = AlarmSoundCatalog.Scope.glucose(type) + let name = type.defaultSoundFilename + #expect(AlarmSoundCatalog.themedEntries(for: scope).contains(where: { $0.filenames.contains(name) })) + } + for tier in DeviceAlertSeverity.allCases { + let scope = AlarmSoundCatalog.Scope.deviceTier(tier) + let name = tier.defaultSoundFilename + #expect(AlarmSoundCatalog.themedEntries(for: scope).contains(where: { $0.filenames.contains(name) })) + } + } + + @Test("Generic tones are offered everywhere and stay out of the themed list") func genericTones() { + let allGeneric = AlarmSoundCatalog.genericTones.allSatisfy { $0.isGeneric } + #expect(allGeneric) + #expect(AlarmSoundCatalog.genericTones.contains(where: { $0.id == "meow.caf" })) + for type in GlucoseAlertType.allCases { + let noneGeneric = AlarmSoundCatalog.themedEntries(for: .glucose(type)).allSatisfy { !$0.isGeneric } + #expect(noneGeneric, "generic tone leaked into \(type) themed list") + } + } + + @Test("Legacy tone filenames still resolve, so stored picks survive") func legacyFilenamesResolve() { + for legacy in ["urgent_low.caf", "high_chimes.caf", "bloop.caf", "trill.caf", "chime.caf", "bloom.caf"] { + #expect(AlarmSoundCatalog.entry(forFilename: legacy) != nil, "\(legacy) no longer resolves") + } + } + + @Test("Display names name the variant for themed sets only") func displayNames() { + #expect(AlarmSoundCatalog.displayName(for: "acoustic_ulg_medium.caf") == "Acoustic · Medium") + #expect(AlarmSoundCatalog.displayName(for: "voice_ulg_amy.caf") == "Voice · Amy") + #expect(AlarmSoundCatalog.displayName(for: "high_chimes.caf") == "High Chimes") + } +} diff --git a/TrioTests/CGMManagerAlertOwnershipTests.swift b/TrioTests/CGMManagerAlertOwnershipTests.swift index 6517d3aafe..6eca288f1b 100644 --- a/TrioTests/CGMManagerAlertOwnershipTests.swift +++ b/TrioTests/CGMManagerAlertOwnershipTests.swift @@ -45,6 +45,12 @@ import Testing #expect(app?.deepLink == URL(string: "dexcomg7://")) } + @Test("G7CGMManager paired directly owns no alerting app") func g7DirectMapping() { + let manager = G7CGMManager(sessionMode: .direct) + #expect(CGMManagerAlertOwnership.owningApp(manager: manager, sourceType: .plugin) == nil) + #expect(!CGMManagerAlertOwnership.providesOwnGlucoseAlerts(manager: manager, sourceType: .plugin)) + } + @Test("G5CGMManager → Dexcom G5, no known deep link") func g5Mapping() { let manager = G5CGMManager(state: TransmitterManagerState(transmitterID: "TEST5A")) let app = CGMManagerAlertOwnership.owningApp(manager: manager, sourceType: .plugin) diff --git a/TrioTests/CarbsEntryLoopKitTests.swift b/TrioTests/CarbsEntryLoopKitTests.swift new file mode 100644 index 0000000000..902d239db7 --- /dev/null +++ b/TrioTests/CarbsEntryLoopKitTests.swift @@ -0,0 +1,38 @@ +import Foundation +import LoopKit +import Testing + +@testable import Trio + +/// Tests for `CarbsEntry.convertSyncCarb()`, the mapping used for Tidepool uploads. +@Suite("CarbsEntry LoopKit Conversion Tests") struct CarbsEntryLoopKitTests { + private func entry(id: String?) -> CarbsEntry { + CarbsEntry( + id: id, + createdAt: Date(timeIntervalSince1970: 1_700_000_000), + actualDate: nil, + carbs: 20, + fat: nil, + protein: nil, + note: nil, + enteredBy: CarbsEntry.local, + isFPU: false, + fpuID: nil + ) + } + + @Test("An entry without id converts without a uuid or sync identifier") func testNilID() { + let carb = entry(id: nil).convertSyncCarb() + + #expect(carb.uuid == nil) + #expect(carb.syncIdentifier == nil) + } + + @Test("A UUID id maps to uuid and sync identifier") func testUUIDID() { + let id = "3D6BB81B-A387-4042-B0EC-BD38EF1A93C3" + let carb = entry(id: id).convertSyncCarb() + + #expect(carb.uuid?.uuidString == id) + #expect(carb.syncIdentifier == id) + } +} diff --git a/TrioTests/ContainerResolutionTests.swift b/TrioTests/ContainerResolutionTests.swift index 0ffcfa17c4..de5486674b 100644 --- a/TrioTests/ContainerResolutionTests.swift +++ b/TrioTests/ContainerResolutionTests.swift @@ -36,6 +36,7 @@ import Testing #expect(resolver.resolve(PluginManager.self) != nil) #expect(resolver.resolve(AlertPermissionsChecker.self) != nil) #expect(resolver.resolve(IOBService.self) != nil) + #expect(resolver.resolve(CarbEntryMutationService.self) != nil) #expect(resolver.resolve(GlucoseAlertCoordinator.self) != nil) #expect(resolver.resolve(NotLoopingMonitor.self) != nil) #expect(resolver.resolve(TrioAlertManager.self) != nil) @@ -54,5 +55,9 @@ import Testing // Container scope must return the same instance on every resolve #expect(resolver.resolve(TrioRemoteControl.self) === resolver.resolve(TrioRemoteControl.self)) #expect(resolver.resolve(TelemetryClient.self) === resolver.resolve(TelemetryClient.self)) + #expect( + resolver.resolve(CarbEntryMutationService.self) as AnyObject === + resolver.resolve(CarbEntryMutationService.self) as AnyObject + ) } } diff --git a/TrioTests/CoreDataTests/CarbEntryMutationServiceTests.swift b/TrioTests/CoreDataTests/CarbEntryMutationServiceTests.swift new file mode 100644 index 0000000000..6ca9db30ba --- /dev/null +++ b/TrioTests/CoreDataTests/CarbEntryMutationServiceTests.swift @@ -0,0 +1,302 @@ +import Combine +import CoreData +import Foundation +import HealthKit +import LoopKit +import LoopKitUI +import Swinject +import Testing + +@testable import Trio + +@Suite("CarbEntryMutationService Tests", .serialized) struct CarbEntryMutationServiceTests: Injectable { + @Injected() var storage: CarbsStorage! + @Injected() var service: CarbEntryMutationService! + let resolver: Resolver + private let nightscout = StubNightscoutManager() + var coreDataStack: CoreDataStack! + var testContext: NSManagedObjectContext! + + init() async throws { + coreDataStack = try await CoreDataStack.createForTests() + testContext = coreDataStack.newTaskContext() + + let assembler = Assembler([ + StorageAssembly(), + ServiceAssembly(), + APSAssembly(), + NetworkAssembly(), + UIAssembly(), + SecurityAssembly(), + TestAssembly(testContext: testContext), + RemoteServicesStubAssembly(nightscout: nightscout) + ]) + + resolver = assembler.resolver + injectServices(resolver) + } + + private func makeMeal(carbs: Decimal, fat: Decimal, protein: Decimal, date: Date = Date()) -> CarbsEntry { + CarbsEntry( + id: UUID().uuidString, + createdAt: date, + actualDate: date, + carbs: carbs, + fat: fat, + protein: protein, + note: "Mutation test", + enteredBy: "Test", + isFPU: false, + fpuID: fat > 0 || protein > 0 ? UUID().uuidString : nil + ) + } + + private func allRows() async throws -> [CarbEntryStored] { + let rows = try await coreDataStack.fetchEntitiesAsync( + ofType: CarbEntryStored.self, + onContext: testContext, + predicate: NSPredicate(format: "TRUEPREDICATE"), + key: "date", + ascending: true + ) as? [CarbEntryStored] + guard let rows else { throw TestError("Failed to fetch rows") } + return rows + } + + private func rootObjectID(id: String) async throws -> NSManagedObjectID { + let rows = try await allRows() + guard let root = rows.first(where: { $0.id?.uuidString == id }) else { throw TestError("Root not found") } + return root.objectID + } + + @Test("Service resolves") func testServiceResolves() { + #expect(service is BaseCarbEntryMutationService) + } + + @Test("Delete removes the root and its carb equivalents") func testDeleteMealRemovesFamily() async throws { + let meal = makeMeal(carbs: 30, fat: 50, protein: 100) + try await storage.storeCarbs([meal], areFetchedFromRemote: false) + // Tai keeps its duration-based FPU split and excludes upstream's capped 3-entry split (PRs #951/#1019/#1022, + // see UPSTREAM_SYNC_BASELINE.md), so the family size is asserted by provenance, not by a fixed count. + let stored = try await allRows() + let roots = stored.filter { !$0.isFPU } + let children = stored.filter(\.isFPU) + #expect(roots.count == 1) + #expect(roots.first?.id?.uuidString == meal.id) + #expect(!children.isEmpty) + #expect(children.allSatisfy { $0.fpuID?.uuidString == meal.fpuID }) + #expect(children.allSatisfy { $0.carbs > 0 }) + + let failures = try await service.deleteMeal(rootObjectID: try await rootObjectID(id: meal.id!)) + + #expect(failures.isEmpty) + #expect(try await allRows().isEmpty) + } + + @Test("Core Data rows are gone before remote deletes run") func testDeleteIsLocalFirst() async throws { + let meal = makeMeal(carbs: 30, fat: 50, protein: 100) + try await storage.storeCarbs([meal], areFetchedFromRemote: false) + let rowCounts = RowCountRecorder() + nightscout.onDeleteCarbs = { _ in await rowCounts.append((try? await self.allRows().count) ?? -1) } + + _ = try await service.deleteMeal(rootObjectID: try await rootObjectID(id: meal.id!)) + + #expect(await rowCounts.counts == [0, 0]) + } + + @Test("Overlapping replacements of one meal store a single replacement") func testConcurrentReplace() async throws { + let meal = makeMeal(carbs: 30, fat: 0, protein: 0) + try await storage.storeCarbs([meal], areFetchedFromRemote: false) + let rootID = try await rootObjectID(id: meal.id!) + let first = makeMeal(carbs: 20, fat: 0, protein: 0, date: Date().addingTimeInterval(-600)) + let second = makeMeal(carbs: 40, fat: 0, protein: 0, date: Date().addingTimeInterval(-1200)) + + async let firstResult = try? service.replaceMeal(rootObjectID: rootID, with: first) + async let secondResult = try? service.replaceMeal(rootObjectID: rootID, with: second) + let results = await [firstResult, secondResult] + + #expect(results.compactMap { $0 }.count == 1) + #expect(try await allRows().count == 1) + } + + @Test("Overlapping replacements of a meal with carb equivalents store a single family") func testConcurrentReplaceWithFPU( + ) async throws { + let meal = makeMeal(carbs: 30, fat: 50, protein: 100) + try await storage.storeCarbs([meal], areFetchedFromRemote: false) + let rootID = try await rootObjectID(id: meal.id!) + let first = makeMeal(carbs: 20, fat: 40, protein: 80, date: Date().addingTimeInterval(-600)) + let second = makeMeal(carbs: 40, fat: 40, protein: 80, date: Date().addingTimeInterval(-1200)) + + async let firstResult = try? service.replaceMeal(rootObjectID: rootID, with: first) + async let secondResult = try? service.replaceMeal(rootObjectID: rootID, with: second) + let results = await [firstResult, secondResult] + + #expect(results.compactMap { $0 }.count == 1) + let roots = try await allRows().filter { !$0.isFPU } + #expect(roots.count == 1) + } + + @Test("Delete leaves other meals untouched") func testDeleteMealKeepsOthers() async throws { + let meal = makeMeal(carbs: 30, fat: 50, protein: 100) + let other = makeMeal(carbs: 12, fat: 0, protein: 0, date: Date().addingTimeInterval(-3600)) + try await storage.storeCarbs([other], areFetchedFromRemote: false) + try await storage.storeCarbs([meal], areFetchedFromRemote: false) + + _ = try await service.deleteMeal(rootObjectID: try await rootObjectID(id: meal.id!)) + + let rows = try await allRows() + #expect(rows.count == 1) + #expect(rows.first?.id?.uuidString == other.id) + } + + @Test("Delete on a carb equivalent row is rejected") func testDeleteChildThrows() async throws { + let meal = makeMeal(carbs: 30, fat: 50, protein: 100) + try await storage.storeCarbs([meal], areFetchedFromRemote: false) + guard let child = try await allRows().first(where: { $0.isFPU }) else { throw TestError("No child") } + let before = try await allRows() + + await #expect(throws: CarbEntryMutationError.notARoot) { + _ = try await service.deleteMeal(rootObjectID: child.objectID) + } + // Tai keeps its duration-based FPU split and excludes upstream's capped 3-entry split (PRs #951/#1019/#1022, + // see UPSTREAM_SYNC_BASELINE.md), so the family size is asserted by provenance, not by a fixed count. + #expect(before.count > 1) + #expect(Set(try await allRows().map(\.objectID)) == Set(before.map(\.objectID))) + } + + @Test("Replace stores the new meal under the replacement id") func testReplaceMeal() async throws { + let meal = makeMeal(carbs: 30, fat: 50, protein: 100) + try await storage.storeCarbs([meal], areFetchedFromRemote: false) + let replacement = makeMeal(carbs: 15, fat: 20, protein: 40) + + let (newID, failures) = try await service.replaceMeal( + rootObjectID: try await rootObjectID(id: meal.id!), + with: replacement + ) + + #expect(newID.uuidString == replacement.id) + #expect(failures.isEmpty) + + let rows = try await allRows() + #expect(rows.contains(where: { $0.id?.uuidString == meal.id }) == false, "Old root is gone") + let newRoot = rows.first(where: { $0.isFPU == false }) + #expect(newRoot?.id == newID) + #expect(newRoot?.carbs == 15) + #expect(newRoot?.fpuID?.uuidString == replacement.fpuID) + #expect(newRoot?.isUploadedToNS == false) + let children = rows.filter(\.isFPU) + #expect(!children.isEmpty) + #expect(children.allSatisfy { $0.fpuID == newRoot?.fpuID }) + } + + @Test("Replace with a carbs-only meal leaves no carb equivalents") func testReplaceMealDropsFPUs() async throws { + let meal = makeMeal(carbs: 30, fat: 50, protein: 100) + try await storage.storeCarbs([meal], areFetchedFromRemote: false) + let replacement = makeMeal(carbs: 45, fat: 0, protein: 0) + + _ = try await service.replaceMeal(rootObjectID: try await rootObjectID(id: meal.id!), with: replacement) + + let rows = try await allRows() + #expect(rows.count == 1) + #expect(rows.first?.fpuID == nil) + #expect(rows.first?.carbs == 45) + } + + @Test("Replace requires a UUID replacement id") func testReplaceMealRequiresUUID() async throws { + let meal = makeMeal(carbs: 30, fat: 0, protein: 0) + try await storage.storeCarbs([meal], areFetchedFromRemote: false) + let replacement = CarbsEntry( + id: "not-a-uuid", + createdAt: Date(), + actualDate: Date(), + carbs: 10, + fat: 0, + protein: 0, + note: nil, + enteredBy: "Test", + isFPU: false, + fpuID: nil + ) + + await #expect(throws: CarbEntryMutationError.invalidReplacementID) { + _ = try await service.replaceMeal(rootObjectID: try await rootObjectID(id: meal.id!), with: replacement) + } + #expect(try await allRows().count == 1, "Nothing is deleted when the replacement is invalid") + } +} + +private actor RowCountRecorder { + private(set) var counts: [Int] = [] + func append(_ count: Int) { counts.append(count) } +} + +private final class StubNightscoutManager: NightscoutManager { + var onDeleteCarbs: (String) async -> Void = { _ in } + + var glucoseManager: FetchGlucoseManager? + var cgmManager: CGMManagerUI? + let cgmDisplayState = CurrentValueSubject(nil) + let cgmProgressHighlight = CurrentValueSubject(nil) + var cgmURL: URL? { nil } + + func fetch(_: DispatchTimer?) -> AnyPublisher<[BloodGlucose], Never> { Just([]).eraseToAnyPublisher() } + func fetchIfNeeded() -> AnyPublisher<[BloodGlucose], Never> { Just([]).eraseToAnyPublisher() } + func sourceInfo() -> [String: Any]? { nil } + func fetchGlucose(since _: Date) async -> [BloodGlucose] { [] } + func fetchCarbs() async -> [CarbsEntry] { [] } + func fetchTempTargets() async -> [TempTarget] { [] } + func deleteCarbs(withID id: String) async -> Bool { + await onDeleteCarbs(id) + return true + } + + func deleteInsulin(withID _: String) async {} + func deleteGlucose(withID _: String, withDate _: Date) async {} + func uploadCarbs() async {} + func uploadOverrides() async {} + func uploadTempTargets() async {} + func uploadProfiles() async throws {} + func uploadNoteTreatment(note _: String) async -> Bool { true } + func importSettings() async -> ScheduledNightscoutProfile? { nil } +} + +private final class StubHealthKitManager: HealthKitManager { + var hasGrantedFullWritePermissions: Bool { false } + func hasGlucoseWritePermission() -> Bool { false } + func requestPermission() async throws -> Bool { false } + func checkWriteToHealthPermissions(objectTypeToHealthStore _: HKObjectType) -> Bool { false } + func uploadGlucose() async {} + func uploadCarbs() async {} + func uploadInsulin() async {} + func deleteGlucose(syncID _: String) async {} + func deleteMealData(byID _: String, sampleType _: HKSampleType) async {} + func deleteInsulin(syncID _: String) async {} +} + +private final class StubTidepoolManager: TidepoolManager { + func addTidepoolService(service _: Service) {} + func getTidepoolServiceUI() -> ServiceUI? { nil } + func getTidepoolPluginHost() -> PluginHost? { nil } + func uploadCarbs() async {} + func deleteCarbs(withSyncId _: UUID, carbs _: Decimal, at _: Date, enteredBy _: String) {} + func uploadInsulin() async {} + func deleteInsulin(withSyncId _: String, amount _: Decimal, at _: Date) {} + func uploadGlucose() async {} + func uploadSettings() async {} + func forceTidepoolDataUpload() {} +} + +/// Keeps meal mutation tests off the Nightscout, Apple Health and Tidepool accounts configured in the host app. +private final class RemoteServicesStubAssembly: Assembly { + private let nightscout: StubNightscoutManager + + init(nightscout: StubNightscoutManager) { + self.nightscout = nightscout + } + + func assemble(container: Container) { + container.register(NightscoutManager.self) { _ in self.nightscout }.inObjectScope(.container) + container.register(HealthKitManager.self) { _ in StubHealthKitManager() }.inObjectScope(.container) + container.register(TidepoolManager.self) { _ in StubTidepoolManager() }.inObjectScope(.container) + } +} diff --git a/TrioTests/CoreDataTests/CarbsStorageTests.swift b/TrioTests/CoreDataTests/CarbsStorageTests.swift index 42661310e5..17a0885883 100644 --- a/TrioTests/CoreDataTests/CarbsStorageTests.swift +++ b/TrioTests/CoreDataTests/CarbsStorageTests.swift @@ -206,4 +206,101 @@ import Testing "All entries should share the same fpuID" ) } + + @Test("Non-UUID entry id is replaced by a fresh UUID") func testStoreReplacesNonUUIDID() async throws { + let mealEntry = CarbsEntry( + id: "66f1c4c8a1b2c3d4e5f60718", + createdAt: Date(), + actualDate: Date(), + carbs: 15, + fat: nil, + protein: nil, + note: nil, + enteredBy: "Test", + isFPU: false, + fpuID: nil + ) + + try await storage.storeCarbs([mealEntry], areFetchedFromRemote: true) + + let storedEntries = try await coreDataStack.fetchEntitiesAsync( + ofType: CarbEntryStored.self, + onContext: testContext, + predicate: NSPredicate(format: "TRUEPREDICATE"), + key: "date", + ascending: true + ) as? [CarbEntryStored] + + #expect(storedEntries?.count == 1) + #expect(storedEntries?.first?.id != nil, "Entry has a UUID id") + #expect(storedEntries?.first?.fpuID == nil, "Carb-only entry has no fpuID") + } + + @Test("fetchMealRoot resolves the root by its id") func testFetchMealRootByID() async throws { + let rootID = UUID() + let meal = CarbsEntry( + id: rootID.uuidString, createdAt: Date(), actualDate: Date(), carbs: 25, fat: 0, protein: 0, + note: nil, enteredBy: "Test", isFPU: false, fpuID: nil + ) + try await storage.storeCarbs([meal], areFetchedFromRemote: false) + + let result = try await storage.fetchMealRoot(handle: rootID) + + #expect(result?.snapshot.id == rootID) + #expect(result?.snapshot.carbs == 25) + #expect(result?.snapshot.fpuID == nil) + } + + @Test("fetchMealRoot resolves the root from the fpuID handle") func testFetchMealRootByFPUID() async throws { + let rootID = UUID() + let fpuID = UUID() + let meal = CarbsEntry( + id: rootID.uuidString, createdAt: Date(), actualDate: Date(), carbs: 30, fat: 50, protein: 100, + note: "root", enteredBy: "Test", isFPU: false, fpuID: fpuID.uuidString + ) + try await storage.storeCarbs([meal], areFetchedFromRemote: false) + + let result = try await storage.fetchMealRoot(handle: fpuID) + + #expect(result?.snapshot.id == rootID, "Handle resolves to the root, not a carb equivalent") + #expect(result?.snapshot.fpuID == fpuID) + #expect(result?.snapshot.fat == 50) + } + + @Test("fetchMealRoot resolves a fat/protein-only meal from its fpuID") func testFetchMealRootZeroCarb() async throws { + let fpuID = UUID() + let meal = CarbsEntry( + id: UUID().uuidString, createdAt: Date(), actualDate: Date(), carbs: 0, fat: 40, protein: 60, + note: nil, enteredBy: "Test", isFPU: false, fpuID: fpuID.uuidString + ) + try await storage.storeCarbs([meal], areFetchedFromRemote: false) + + let result = try await storage.fetchMealRoot(handle: fpuID) + + #expect(result != nil) + #expect(result?.snapshot.carbs == 0) + #expect(result?.snapshot.protein == 60) + } + + @Test("fetchMealRoot returns nil for an unknown handle") func testFetchMealRootUnknown() async throws { + #expect(try await storage.fetchMealRoot(handle: UUID()) == nil) + } + + @Test("Nightscout treatments carry the fpuID of root and carb equivalents") func testNightscoutTreatmentsCarryFPUID() async throws { + let fpuID = UUID().uuidString + let meal = CarbsEntry( + id: UUID().uuidString, createdAt: Date(), actualDate: Date(), carbs: 30, fat: 50, protein: 100, + note: nil, enteredBy: "Test", isFPU: false, fpuID: fpuID + ) + try await storage.storeCarbs([meal], areFetchedFromRemote: false) + + let roots = try await storage.getCarbsNotYetUploadedToNightscout() + let children = try await storage.getFPUsNotYetUploadedToNightscout() + + #expect(roots.count == 1) + #expect(roots.first?.id == meal.id) + #expect(roots.first?.fpuID == fpuID) + #expect(!children.isEmpty) + #expect(children.allSatisfy { $0.id == fpuID && $0.fpuID == fpuID }) + } } diff --git a/TrioTests/CoreDataTests/TestAssembly.swift b/TrioTests/CoreDataTests/TestAssembly.swift index db4e031824..d593f9b219 100644 --- a/TrioTests/CoreDataTests/TestAssembly.swift +++ b/TrioTests/CoreDataTests/TestAssembly.swift @@ -53,6 +53,10 @@ class TestAssembly: Assembly { BaseOverrideStorage(resolver: r, contextProvider: { self.testContext }) }.inObjectScope(.container) + container.register(CarbEntryMutationService.self) { r in + BaseCarbEntryMutationService(resolver: r, contextProvider: { self.testContext }) + }.inObjectScope(.container) + // Override AdjustmentManager registration for tests container.register(AdjustmentManager.self) { r in BaseAdjustmentManager(resolver: r, contextProvider: { self.testContext }, recompute: {}) diff --git a/TrioTests/DeviceAlertsStoreTests.swift b/TrioTests/DeviceAlertsStoreTests.swift index ca42ae66e1..ae6eb0c5f6 100644 --- a/TrioTests/DeviceAlertsStoreTests.swift +++ b/TrioTests/DeviceAlertsStoreTests.swift @@ -472,9 +472,9 @@ import Testing #expect(!DeviceAlertSeverityConfig(severity: .normal).playsSound) } - @Test("Critical default tone is High Chimes, not the siren") func criticalDefaultTone() { - #expect(DeviceAlertSeverityConfig(severity: .critical).soundFilename == "high_chimes.caf") - #expect(AlarmSoundCatalog.allFilenames.contains("high_chimes.caf")) + @Test("Critical default tone is the themed Acoustic Medium set") func criticalDefaultTone() { + #expect(DeviceAlertSeverityConfig(severity: .critical).soundFilename == "acoustic_dcr_medium.caf") + #expect(AlarmSoundCatalog.allFilenames.contains("acoustic_dcr_medium.caf")) } @Test("A stored config without playsSound falls back to the tier default") func decodeFallbackMatchesDefault() throws { diff --git a/TrioTests/GRICalculatorTests.swift b/TrioTests/GRICalculatorTests.swift new file mode 100644 index 0000000000..aaeb5c8432 --- /dev/null +++ b/TrioTests/GRICalculatorTests.swift @@ -0,0 +1,67 @@ +import Foundation +import Testing + +@testable import Trio + +@Suite("Glycemia Risk Index") struct GRICalculatorTests { + @Test("Empty and invalid readings are unavailable") func missingData() { + #expect(GRICalculator.calculate(glucoseValues: []) == nil) + #expect(GRICalculator.calculate(glucoseValues: [0, -1]) == nil) + #expect(GRICalculator.calculate(glucoseValues: [0, -1, 100])?.score == 0) + } + + @Test("Fixed thresholds classify their exact boundaries", arguments: [ + (53, 100.0, 0.0), (54, 80.0, 0.0), (69, 80.0, 0.0), + (70, 0.0, 0.0), (180, 0.0, 0.0), (181, 0.0, 50.0), + (250, 0.0, 50.0), (251, 0.0, 100.0) + ]) func boundaries(sample: (Int, Double, Double)) throws { + let result = try #require(GRICalculator.calculate(glucoseValues: [sample.0])) + #expect(abs(result.hypoComponent - sample.1) < 0.000001) + #expect(abs(result.hyperComponent - sample.2) < 0.000001) + } + + @Test("Published worked example scores 79") func publishedExample() throws { + // 5% very low, 10% low, 50% in range, 20% high, 15% very high. + let values = [50] + Array(repeating: 60, count: 2) + Array(repeating: 100, count: 10) + + Array(repeating: 200, count: 4) + Array(repeating: 300, count: 3) + let result = try #require(GRICalculator.calculate(glucoseValues: values)) + #expect(abs(result.score - 79) < 0.000001) + #expect(abs(result.hypoComponent - 13) < 0.000001) + #expect(abs(result.hyperComponent - 25) < 0.000001) + #expect(result.veryLowPercentage == 5) + #expect(result.lowPercentage == 10) + #expect(result.highPercentage == 20) + #expect(result.veryHighPercentage == 15) + #expect(result.weightedHypoComponent == 39) + #expect(result.weightedHyperComponent == 40) + #expect(result.uncappedScore == 79) + #expect(result.zone == "D") + } + + @Test("Extreme results cap the score but preserve components") func cap() throws { + let result = try #require(GRICalculator.calculate(glucoseValues: [40, 40])) + #expect(result.score == 100) + #expect(result.hypoComponent == 100) + #expect(result.uncappedScore == 300) + #expect(result.zone == "E") + #expect(GRICalculator.calculate(glucoseValues: [300])?.score == 100) + } + + @Test("A period is weighted by readings, not daily scores") func weighting() throws { + let values = [50] + Array(repeating: 100, count: 99) + let result = try #require(GRICalculator.calculate(glucoseValues: values)) + #expect(result.score == 3) + } + + @Test("Zones use unrounded scores", arguments: [ + (0.0, "A"), (20.0, "A"), (20.01, "B"), (40.0, "B"), (40.01, "C"), + (60.0, "C"), (60.01, "D"), (80.0, "D"), (80.01, "E"), (100.0, "E") + ]) func zones(sample: (Double, String)) { + #expect(GRICalculator.Result( + veryLowPercentage: 0, + lowPercentage: 0, + highPercentage: 0, + veryHighPercentage: sample.0 / 1.6 + ).zone == sample.1) + } +} diff --git a/TrioTests/GlucoseAlertCoordinatorTests.swift b/TrioTests/GlucoseAlertCoordinatorTests.swift index 435671d677..e44314e835 100644 --- a/TrioTests/GlucoseAlertCoordinatorTests.swift +++ b/TrioTests/GlucoseAlertCoordinatorTests.swift @@ -87,4 +87,37 @@ import Testing let readingDriven = Set(GlucoseAlertType.allCases.filter(\.isReadingDriven)) #expect(readingDriven == [.high, .low, .urgentLow]) } + + @Test("CGM-owned retraction keeps determination-driven alarms firing") func retractsOnlyReadingDrivenAlarms() { + let urgentLow = GlucoseAlert(type: .urgentLow) + let low = GlucoseAlert(type: .low) + let forecastedLow = GlucoseAlert(type: .forecastedLow) + let high = GlucoseAlert(type: .high) + let carbsRequired = GlucoseAlert(type: .carbsRequired) + let alarms = [urgentLow, low, forecastedLow, high, carbsRequired] + let firing = Set(alarms.map(\.id)) + + let result = GlucoseAlertCoordinator.alarmsToRetractWhenCGMOwnsAlerts(firing: firing, in: alarms) + + #expect(result.map(\.id) == [urgentLow.id, low.id, high.id]) + #expect(!result.contains(forecastedLow)) + #expect(!result.contains(carbsRequired)) + } + + @Test("CGM-owned retraction skips reading-driven alarms that are not firing") func skipsNonFiringReadingDrivenAlarms() { + let low = GlucoseAlert(type: .low) + let high = GlucoseAlert(type: .high) + + let result = GlucoseAlertCoordinator.alarmsToRetractWhenCGMOwnsAlerts(firing: [high.id], in: [low, high]) + + #expect(result == [high]) + } + + @Test("CGM-owned retraction with empty firing set returns nothing") func emptyFiringSetReturnsNothing() { + let alarms = [GlucoseAlert(type: .urgentLow), GlucoseAlert(type: .low), GlucoseAlert(type: .high)] + + let result = GlucoseAlertCoordinator.alarmsToRetractWhenCGMOwnsAlerts(firing: [], in: alarms) + + #expect(result.isEmpty) + } } diff --git a/TrioTests/GlucoseAlertTests.swift b/TrioTests/GlucoseAlertTests.swift index e2a2612bb4..6a6d7c1e06 100644 --- a/TrioTests/GlucoseAlertTests.swift +++ b/TrioTests/GlucoseAlertTests.swift @@ -68,10 +68,10 @@ import Testing // MARK: - Group A.5: type metadata - @Test("carbsRequired defaults: threshold 10 g, bloop.caf, no override") func carbsRequiredDefaults() { + @Test("carbsRequired defaults: threshold 10 g, themed tone, no override") func carbsRequiredDefaults() { let a = GlucoseAlert(type: .carbsRequired) #expect(a.thresholdMgDL == 10) - #expect(a.soundFilename == "bloop.caf") + #expect(a.soundFilename == "acoustic_cr_medium.caf") #expect(a.overridesSilenceAndDND == false) } diff --git a/TrioTests/RemoteMealMutationPayloadTests.swift b/TrioTests/RemoteMealMutationPayloadTests.swift new file mode 100644 index 0000000000..3cb20eaf9e --- /dev/null +++ b/TrioTests/RemoteMealMutationPayloadTests.swift @@ -0,0 +1,139 @@ +import Foundation +import Testing + +@testable import Trio + +@Suite("Remote Meal Mutation Payload Tests") struct RemoteMealMutationPayloadTests { + private func decode(_ json: String) throws -> CommandPayload { + try JSONDecoder().decode(CommandPayload.self, from: Data(json.utf8)) + } + + @Test("delete_meal payload decodes with command_id and meal_id") func testDecodeDeleteMeal() throws { + let mealID = UUID() + let payload = try decode(""" + {"user":"caregiver","command_type":"delete_meal","timestamp":1700000000, + "command_id":"6C0F1D2E-9B3A-4C5D-8E7F-000000000001","meal_id":"\(mealID.uuidString)"} + """) + + #expect(payload.commandType == .deleteMeal) + #expect(payload.commandId == "6C0F1D2E-9B3A-4C5D-8E7F-000000000001") + #expect(payload.mealHandle == mealID) + #expect(payload.carbs == nil) + } + + @Test("edit_meal payload decodes replacement values") func testDecodeEditMeal() throws { + let payload = try decode(""" + {"user":"caregiver","command_type":"edit_meal","timestamp":1700000000, + "command_id":"x","meal_id":"\(UUID().uuidString)", + "carbs":45,"fat":0,"protein":15,"scheduled_time":1700000100} + """) + + #expect(payload.commandType == .editMeal) + #expect(payload.carbs == 45) + #expect(payload.fat == 0) + #expect(payload.protein == 15) + #expect(payload.scheduledTime == 1_700_000_100) + #expect(payload.humanReadableDescription().contains("Edit Meal")) + } + + @Test("Unknown command types decode to .unknown") func testDecodeUnknownCommandType() throws { + let payload = try decode(""" + {"user":"caregiver","command_type":"start_dance","timestamp":1700000000} + """) + + #expect(payload.commandType == .unknown) + #expect(payload.humanReadableDescription().contains("Unknown")) + } + + @Test("Malformed meal_id yields no handle") func testMalformedMealID() throws { + let payload = try decode(""" + {"user":"caregiver","command_type":"delete_meal","timestamp":1700000000,"meal_id":"abc"} + """) + + #expect(payload.mealHandle == nil) + #expect(payload.mealId == "abc") + } + + @Test("Supported command list excludes unknown") func testSupportedCommands() { + let supported = TrioRemoteControl.CommandType.supported.map(\.rawValue) + #expect(supported.contains("delete_meal")) + #expect(supported.contains("edit_meal")) + #expect(supported.contains("meal")) + #expect(!supported.contains("unknown")) + } + + @Test("Ack payload encodes command_id, meal_id and result when present") func testAckEncoding() throws { + let payload = RemoteNotificationResponseManager.NotificationPayload( + aps: .init(alert: .init(title: "t", body: "b")), + commandStatus: "success", + commandType: "delete_meal", + timestamp: 1, + commandId: "cmd", + mealId: "meal", + result: "deleted" + ) + let json = try JSONSerialization.jsonObject(with: JSONEncoder().encode(payload)) as? [String: Any] + + #expect(json?["command_id"] as? String == "cmd") + #expect(json?["meal_id"] as? String == "meal") + #expect(json?["result"] as? String == "deleted") + #expect(json?["command_status"] as? String == "success") + } + + @Test("Ack payload omits the new keys when absent") func testAckEncodingOmitsNil() throws { + let payload = RemoteNotificationResponseManager.NotificationPayload( + aps: .init(alert: .init(title: "t", body: "b")), + commandStatus: "failed", + commandType: "bolus", + timestamp: 1, + commandId: nil, + mealId: nil, + result: nil + ) + let json = try JSONSerialization.jsonObject(with: JSONEncoder().encode(payload)) as? [String: Any] + + #expect(json?["command_id"] == nil) + #expect(json?["meal_id"] == nil) + #expect(json?["result"] == nil) + } + + @Test("Result raw values match the wire protocol") func testResultRawValues() { + #expect(RemoteCommandAck.Result.deleted.rawValue == "deleted") + #expect(RemoteCommandAck.Result.updated.rawValue == "updated") + #expect(RemoteCommandAck.Result.notFound.rawValue == "not_found") + #expect(RemoteCommandAck.Result.rejected.rawValue == "rejected") + } + + @Test("Meal age window") func testMealAgeWindow() { + let now = Date(timeIntervalSince1970: 1_700_000_000) + #expect(TrioRemoteControl.validateMealAge(now.addingTimeInterval(-23 * 3600), now: now) == nil) + #expect(TrioRemoteControl.validateMealAge(now.addingTimeInterval(-25 * 3600), now: now) != nil) + #expect(TrioRemoteControl.validateMealAge(now.addingTimeInterval(11 * 3600), now: now) == nil) + #expect(TrioRemoteControl.validateMealAge(now.addingTimeInterval(13 * 3600), now: now) != nil) + } + + @Test("Meals with unlinked fat/protein are rejected") func testMealLinkValidation() { + func snapshot(fat: Decimal, protein: Decimal, fpuID: UUID?) -> MealSnapshot { + MealSnapshot(id: UUID(), fpuID: fpuID, date: Date(), carbs: 20, fat: fat, protein: protein, note: nil) + } + #expect(TrioRemoteControl.validateMealLink(snapshot(fat: 0, protein: 0, fpuID: nil)) == nil) + #expect(TrioRemoteControl.validateMealLink(snapshot(fat: 10, protein: 5, fpuID: UUID())) == nil) + #expect(TrioRemoteControl.validateMealLink(snapshot(fat: 10, protein: 0, fpuID: nil)) != nil) + #expect(TrioRemoteControl.validateMealLink(snapshot(fat: 0, protein: 5, fpuID: nil)) != nil) + } + + @Test("Edit input validation") func testEditInputValidation() { + func validate(_ carbs: Int, _ fat: Int, _ protein: Int) -> String? { + TrioRemoteControl.validateEditMealInput( + carbs: carbs, fat: fat, protein: protein, maxCarbs: 100, maxFat: 50, maxProtein: 50 + ) + } + #expect(validate(40, 0, 0) == nil) + #expect(validate(0, 20, 10) == nil) + #expect(validate(0, 0, 0) != nil) + #expect(validate(-1, 0, 0) != nil) + #expect(validate(101, 0, 0) != nil) + #expect(validate(0, 51, 0) != nil) + #expect(validate(0, 0, 51) != nil) + } +}