瀏覽代碼

Merge pull request #480 from nightscout/dev

v0.4.0 (1) — Onboarding, Watch App Improvements, Misc. Fixes
Mike Plante 1 年之前
父節點
當前提交
e8c86b4b8f
共有 100 個文件被更改,包括 26697 次插入2820 次删除
  1. 1 1
      .github/ISSUE_TEMPLATE/feature-request.md
  2. 0 1
      .github/workflows/add_identifiers.yml
  3. 11 10
      .github/workflows/build_trio.yml
  4. 30 30
      .github/workflows/create_certs.yml
  5. 1 1
      CGMBLEKit
  6. 1 1
      Config.xcconfig
  7. 1 1
      DanaKit
  8. 1 1
      G7SensorKit
  9. 40 36
      Gemfile.lock
  10. 3 1
      LiveActivity/Views/LiveActivityChartView.swift
  11. 19 0
      Model/Helper/Determination+helper.swift
  12. 12 0
      Model/Helper/NSPredicates.swift
  13. 2 3
      Model/Helper/PumpEvent+helper.swift
  14. 839 0
      Model/JSONImporter.swift
  15. 119 0
      PRIVACY_POLICY.md
  16. 13 0
      Trio Watch App Extension/Helper/Helper+Enums.swift
  17. 9 0
      Trio Watch App Extension/TrioWatchApp.swift
  18. 3 3
      Trio Watch App Extension/Views/AcknowledgementPendingView.swift
  19. 0 10
      Trio Watch App Extension/Views/BolusConfirmationView.swift
  20. 0 9
      Trio Watch App Extension/Views/BolusInputView.swift
  21. 86 86
      Trio Watch App Extension/Views/BolusProgressOverlay.swift
  22. 1 13
      Trio Watch App Extension/Views/TrioMainWatchView.swift
  23. 150 0
      Trio Watch App Extension/WatchLogger.swift
  24. 143 45
      Trio Watch App Extension/WatchState+Requests.swift
  25. 140 165
      Trio Watch App Extension/WatchState.swift
  26. 39 0
      Trio Watch App Extension/WatchStateSnapshot.swift
  27. 350 20
      Trio.xcodeproj/project.pbxproj
  28. 4 1
      Trio.xcodeproj/xcshareddata/xcschemes/Trio.xcscheme
  29. 118 1
      Trio.xcworkspace/xcshareddata/swiftpm/Package.resolved
  30. 30 0
      Trio/GoogleService-Info.plist
  31. 12 0
      Trio/Resources/Assets.xcassets/logo.bluetooth.capsule.portrait.fill.symbolset/Contents.json
  32. 196 0
      Trio/Resources/Assets.xcassets/logo.bluetooth.capsule.portrait.fill.symbolset/logo.bluetooth.capsule.portrait.fill.svg
  33. 1 1
      Trio/Resources/javascript/bundle/autosens.js
  34. 1 1
      Trio/Resources/javascript/bundle/autotune-core.js
  35. 1 1
      Trio/Resources/javascript/bundle/autotune-prep.js
  36. 1 1
      Trio/Resources/javascript/bundle/basal-set-temp.js
  37. 1 1
      Trio/Resources/javascript/bundle/determine-basal.js
  38. 1 1
      Trio/Resources/javascript/bundle/iob.js
  39. 1 1
      Trio/Resources/javascript/bundle/meal.js
  40. 1 1
      Trio/Resources/javascript/bundle/profile.js
  41. 0 1
      Trio/Resources/json/defaults/preferences.json
  42. 1 1
      Trio/Resources/json/defaults/settings/basal_profile.json
  43. 2 2
      Trio/Resources/json/defaults/settings/bg_targets.json
  44. 1 1
      Trio/Resources/json/defaults/settings/carb_ratios.json
  45. 1 1
      Trio/Resources/json/defaults/settings/insulin_sensitivities.json
  46. 20 3
      Trio/Sources/APS/APSManager.swift
  47. 9 1
      Trio/Sources/APS/OpenAPS/OpenAPS.swift
  48. 2 3
      Trio/Sources/APS/Storage/OverrideStorage.swift
  49. 1 1
      Trio/Sources/APS/Storage/PumpHistoryStorage.swift
  50. 13 2
      Trio/Sources/Application/AppDelegate.swift
  51. 171 25
      Trio/Sources/Application/TrioApp.swift
  52. 26 0
      Trio/Sources/Helpers/PropertyPersistentFlags.swift
  53. 15 5
      Trio/Sources/Helpers/CheckboxToggleStyle.swift
  54. 22096 1522
      Trio/Sources/Localizations/Main/Localizable.xcstrings
  55. 40 0
      Trio/Sources/Logger/IssueReporter/SimpleLogReporter.swift
  56. 10 6
      Trio/Sources/Models/BloodGlucose.swift
  57. 8 1
      Trio/Sources/Models/DecimalPickerSettings.swift
  58. 0 32
      Trio/Sources/Models/Glucose.swift
  59. 0 7
      Trio/Sources/Models/Preferences.swift
  60. 5 1
      Trio/Sources/Models/PumpHistoryEvent.swift
  61. 2 0
      Trio/Sources/Models/WatchMessageKeys.swift
  62. 1 1
      Trio/Sources/Models/WatchState.swift
  63. 39 0
      Trio/Sources/Models/WatchStateSnapshot.swift
  64. 5 0
      Trio/Sources/Modules/AppDiagnostics/AppDiagnosticsDataFlow.swift
  65. 3 0
      Trio/Sources/Modules/AppDiagnostics/AppDiagnosticsProvider.swift
  66. 35 0
      Trio/Sources/Modules/AppDiagnostics/AppDiagnosticsStateModel.swift
  67. 102 0
      Trio/Sources/Modules/AppDiagnostics/View/AppDiagnosticsRootView.swift
  68. 233 0
      Trio/Sources/Modules/AppDiagnostics/View/PrivacyPolicyView.swift
  69. 1 1
      Trio/Sources/Modules/AutosensSettings/AutosensSettingsStateModel.swift
  70. 19 22
      Trio/Sources/Modules/AutosensSettings/View/AutosensSettingsRootView.swift
  71. 17 10
      Trio/Sources/Modules/BasalProfileEditor/View/BasalProfileEditorRootView.swift
  72. 1 1
      Trio/Sources/Modules/BolusCalculatorConfig/View/BolusCalculatorConfigRootView.swift
  73. 1 0
      Trio/Sources/Modules/CGMSettings/CGMSettingsStateModel.swift
  74. 46 38
      Trio/Sources/Modules/CGMSettings/View/CGMRootView.swift
  75. 1 1
      Trio/Sources/Modules/CarbRatioEditor/CarbRatioEditorStateModel.swift
  76. 15 15
      Trio/Sources/Modules/CarbRatioEditor/View/CarbRatioEditorRootView.swift
  77. 4 2
      Trio/Sources/Modules/DataTable/View/DataTableRootView.swift
  78. 29 29
      Trio/Sources/Modules/DynamicSettings/View/DynamicSettingsRootView.swift
  79. 13 10
      Trio/Sources/Modules/GeneralSettings/View/UnitsLimitsSettingsRootView.swift
  80. 1 1
      Trio/Sources/Modules/GlucoseNotificationSettings/View/GlucoseNotificationSettingsRootView.swift
  81. 1 0
      Trio/Sources/Modules/Home/HomeStateModel.swift
  82. 0 1
      Trio/Sources/Modules/Home/View/Header/CurrentGlucoseView.swift
  83. 0 1
      Trio/Sources/Modules/Home/View/Header/PumpView.swift
  84. 22 16
      Trio/Sources/Modules/Home/View/HomeRootView.swift
  85. 23 9
      Trio/Sources/Modules/ISFEditor/ISFEditorStateModel.swift
  86. 16 14
      Trio/Sources/Modules/ISFEditor/View/ISFEditorRootView.swift
  87. 1 1
      Trio/Sources/Modules/LiveActivitySettings/View/LiveActivityWidgetConfiguration.swift
  88. 19 6
      Trio/Sources/Modules/Main/MainStateModel.swift
  89. 2 2
      Trio/Sources/Modules/Main/View/MainLoadingView.swift
  90. 131 0
      Trio/Sources/Modules/Main/View/MainMigrationErrorView.swift
  91. 0 266
      Trio/Sources/Modules/NightscoutConfig/NightscoutConfigStateModel.swift
  92. 0 94
      Trio/Sources/Modules/NightscoutConfig/View/NightscoutConfigRootView.swift
  93. 20 22
      Trio/Sources/Modules/NightscoutConfig/View/NightscoutUploadView.swift
  94. 0 128
      Trio/Sources/Modules/NightscoutConfig/View/ProfileImport/NightscoutImportResultView.swift
  95. 0 67
      Trio/Sources/Modules/NightscoutConfig/View/ProfileImport/ReviewInsulinActionView.swift
  96. 5 0
      Trio/Sources/Modules/Onboarding/OnboardingDataFlow.swift
  97. 67 0
      Trio/Sources/Modules/Onboarding/OnboardingProvider.swift
  98. 266 0
      Trio/Sources/Modules/Onboarding/OnboardingStateModel+Nightscout.swift
  99. 753 0
      Trio/Sources/Modules/Onboarding/OnboardingStateModel.swift
  100. 0 0
      Trio/Sources/Modules/Onboarding/View/Animations/LogoBurstSplash.swift

+ 1 - 1
.github/ISSUE_TEMPLATE/feature-request.md

@@ -3,7 +3,7 @@ name: "\U0001F4A1 Feature request \U0001F4A1"
 about: Suggest an idea for this project
 title: ''
 labels: ['needs-triage']
-types: "feature"
+type: "feature"
 assignees: ''
 projects: ['nightscout/2']
 

+ 0 - 1
.github/workflows/add_identifiers.yml

@@ -14,7 +14,6 @@ jobs:
     needs: validate
     runs-on: macos-15
     steps:
-
       # Checks-out the repo
       - name: Checkout Repo
         uses: actions/checkout@v4

+ 11 - 10
.github/workflows/build_trio.yml

@@ -168,12 +168,14 @@ jobs:
 
       # Keep repository "alive": add empty commits to ALIVE_BRANCH after "time_elapsed" days of inactivity to avoid inactivation of scheduled workflows
       - name: Keep alive
-        if: |
-          needs.check_alive_and_permissions.outputs.WORKFLOW_PERMISSION == 'true' &&
-          (vars.SCHEDULED_BUILD != 'false' || vars.SCHEDULED_SYNC != 'false')
-        uses: gautamkrishnar/keepalive-workflow@v1 # using the workflow with default settings
-        with:
-          time_elapsed: 20 # Time elapsed from the previous commit to trigger a new automated commit (in days)
+        run: |
+          echo "Keep Alive temporarily removed while gautamkrishnar/keepalive-workflow is not available"
+      #  if: |
+      #    needs.check_alive_and_permissions.outputs.WORKFLOW_PERMISSION == 'true' &&
+      #    (vars.SCHEDULED_BUILD != 'false' || vars.SCHEDULED_SYNC != 'false')
+      #  uses: gautamkrishnar/keepalive-workflow@v1 # using the workflow with default settings
+      #  with:
+      #    time_elapsed: 20 # Time elapsed from the previous commit to trigger a new automated commit (in days)
 
       - name: Show scheduled build configuration message
         if: needs.check_alive_and_permissions.outputs.WORKFLOW_PERMISSION != 'true'
@@ -187,8 +189,7 @@ jobs:
   # Builds Trio
   build:
     name: Build
-    needs:
-      [check_certs, check_alive_and_permissions, check_latest_from_upstream]
+    needs: [check_certs, check_alive_and_permissions, check_latest_from_upstream]
     runs-on: macos-15
     permissions:
       contents: write
@@ -201,8 +202,8 @@ jobs:
       )
     steps:
       - name: Select Xcode version
-        run: "sudo xcode-select --switch /Applications/Xcode_16.2.app/Contents/Developer"
-
+        run: "sudo xcode-select --switch /Applications/Xcode_16.3.app/Contents/Developer"
+      
       - name: Checkout Repo for syncing
         if: |
           needs.check_alive_and_permissions.outputs.WORKFLOW_PERMISSION == 'true' &&

+ 30 - 30
.github/workflows/create_certs.yml

@@ -88,33 +88,33 @@ jobs:
 
   # Nuke Certs if needed, and if the repository variable ENABLE_NUKE_CERTS is set to 'true', or if FORCE_NUKE_CERTS is set to 'true', which will always force certs to be nuked
   nuke_certs:
-    name: Nuke certificates
-    needs: [validate, create_certs]
-    runs-on: macos-14
-    if: ${{ (needs.create_certs.outputs.new_certificate_needed == 'true' && vars.ENABLE_NUKE_CERTS == 'true') || vars.FORCE_NUKE_CERTS == 'true' }}
-    steps:
-      - name: Output from step id 'check_certs'
-        run: echo "new_certificate_needed=${{ needs.create_certs.outputs.new_certificate_needed }}"
-
-      - name: Checkout repository
-        uses: actions/checkout@v4
-
-      - name: Install dependencies
-        run: bundle install
-
-      - name: Run Fastlane nuke_certs
-        run: |
-          set -e # Set error immediately after this step if error occurs
-          bundle exec fastlane nuke_certs
-
-      - name: Recreate Distribution certificate after nuking
-        run: |
-          set -e # Set error immediately after this step if error occurs
-          bundle exec fastlane certs
-
-      - name: Add success annotations for nuke and certificate recreation
-        if: ${{ success() }}
-        run: |
-          echo "::warning::⚠️ All Distribution certificates and TestFlight profiles have been revoked and recreated."
-          echo "::warning::❗️ If you have other apps being distributed by GitHub Actions / Fastlane / TestFlight that does not renew certificates automatically, please run the '3. Create Certificates' workflow for each of these apps to allow these apps to be built."
-          echo "::warning::✅ But don't worry about your existing TestFlight builds, they will keep working!"
+      name: Nuke certificates
+      needs: [validate, create_certs]
+      runs-on: macos-15
+      if: ${{ (needs.create_certs.outputs.new_certificate_needed == 'true' && vars.ENABLE_NUKE_CERTS == 'true') || vars.FORCE_NUKE_CERTS == 'true' }}
+      steps:
+        - name: Output from step id 'check_certs'
+          run: echo "new_certificate_needed=${{ needs.create_certs.outputs.new_certificate_needed }}"
+
+        - name: Checkout repository
+          uses: actions/checkout@v4
+
+        - name: Install dependencies
+          run: bundle install
+
+        - name: Run Fastlane nuke_certs
+          run: |
+            set -e # Set error immediately after this step if error occurs
+            bundle exec fastlane nuke_certs
+
+        - name: Recreate Distribution certificate after nuking
+          run: |
+            set -e # Set error immediately after this step if error occurs
+            bundle exec fastlane certs
+
+        - name: Add success annotations for nuke and certificate recreation
+          if: ${{ success() }}
+          run: |
+            echo "::warning::⚠️ All Distribution certificates and TestFlight profiles have been revoked and recreated."
+            echo "::warning::❗️ If you have other apps being distributed by GitHub Actions / Fastlane / TestFlight that does not renew certificates automatically, please run the '3. Create Certificates' workflow for each of these apps to allow these apps to be built."
+            echo "::warning::✅ But don't worry about your existing TestFlight builds, they will keep working!"

+ 1 - 1
CGMBLEKit

@@ -1 +1 @@
-Subproject commit b786e8b5531cb08c259103c472dcd6a6752728f8
+Subproject commit cd8f6faec67b30231987b79daf0117dfcbb54741

+ 1 - 1
Config.xcconfig

@@ -1,5 +1,5 @@
 APP_DISPLAY_NAME = Trio
-APP_VERSION = 0.3.0
+APP_VERSION = 0.4.0
 APP_BUILD_NUMBER = 1
 COPYRIGHT_NOTICE =
 DEVELOPER_TEAM = ##TEAM_ID##

+ 1 - 1
DanaKit

@@ -1 +1 @@
-Subproject commit 200937c3c985de4cb05604cf1d7af2a307dfcaf3
+Subproject commit 89062b019687a61976a077293ee5a3928cf63900

+ 1 - 1
G7SensorKit

@@ -1 +1 @@
-Subproject commit 1a7989adaa37c3bb737839bbee3b17bef2865efc
+Subproject commit 7a9341c77c89b3493254da25912e3dc558e1ae26

+ 40 - 36
Gemfile.lock

@@ -9,21 +9,23 @@ GEM
       public_suffix (>= 2.0.2, < 7.0)
     artifactory (3.0.17)
     atomos (0.1.3)
-    aws-eventstream (1.3.0)
-    aws-partitions (1.1007.0)
-    aws-sdk-core (3.213.0)
+    aws-eventstream (1.3.2)
+    aws-partitions (1.1086.0)
+    aws-sdk-core (3.222.1)
       aws-eventstream (~> 1, >= 1.3.0)
       aws-partitions (~> 1, >= 1.992.0)
       aws-sigv4 (~> 1.9)
+      base64
       jmespath (~> 1, >= 1.6.1)
-    aws-sdk-kms (1.95.0)
-      aws-sdk-core (~> 3, >= 3.210.0)
+      logger
+    aws-sdk-kms (1.99.0)
+      aws-sdk-core (~> 3, >= 3.216.0)
       aws-sigv4 (~> 1.5)
-    aws-sdk-s3 (1.171.0)
-      aws-sdk-core (~> 3, >= 3.210.0)
+    aws-sdk-s3 (1.183.0)
+      aws-sdk-core (~> 3, >= 3.216.0)
       aws-sdk-kms (~> 1)
       aws-sigv4 (~> 1.5)
-    aws-sigv4 (1.10.1)
+    aws-sigv4 (1.11.0)
       aws-eventstream (~> 1, >= 1.0.2)
     babosa (1.0.4)
     base64 (0.2.0)
@@ -33,13 +35,12 @@ GEM
     commander (4.6.0)
       highline (~> 2.0.0)
     declarative (0.0.20)
-    digest-crc (0.6.5)
+    digest-crc (0.7.0)
       rake (>= 12.0.0, < 14.0.0)
-    domain_name (0.5.20190701)
-      unf (>= 0.0.5, < 1.0.0)
+    domain_name (0.6.20240107)
     dotenv (2.8.1)
     emoji_regex (3.2.3)
-    excon (0.109.0)
+    excon (0.112.0)
     faraday (1.10.4)
       faraday-em_http (~> 1.0)
       faraday-em_synchrony (~> 1.0)
@@ -59,8 +60,8 @@ GEM
     faraday-em_synchrony (1.0.0)
     faraday-excon (1.1.0)
     faraday-httpclient (1.0.1)
-    faraday-multipart (1.0.4)
-      multipart-post (~> 2)
+    faraday-multipart (1.1.0)
+      multipart-post (~> 2.0)
     faraday-net_http (1.0.2)
     faraday-net_http_persistent (1.2.0)
     faraday-patron (1.0.0)
@@ -68,8 +69,8 @@ GEM
     faraday-retry (1.0.3)
     faraday_middleware (1.2.1)
       faraday (~> 1.0)
-    fastimage (2.3.1)
-    fastlane (2.225.0)
+    fastimage (2.4.0)
+    fastlane (2.227.1)
       CFPropertyList (>= 2.3, < 4.0.0)
       addressable (>= 2.8, < 3.0.0)
       artifactory (~> 3.0)
@@ -109,7 +110,7 @@ GEM
       tty-spinner (>= 0.8.0, < 1.0.0)
       word_wrap (~> 1.0.0)
       xcodeproj (>= 1.13.0, < 2.0.0)
-      xcpretty (~> 0.3.0)
+      xcpretty (~> 0.4.1)
       xcpretty-travis-formatter (>= 0.0.3, < 2.0.0)
     fastlane-sirp (1.0.0)
       sysrandom (~> 1.0)
@@ -128,19 +129,19 @@ GEM
       google-apis-core (>= 0.11.0, < 2.a)
     google-apis-playcustomapp_v1 (0.13.0)
       google-apis-core (>= 0.11.0, < 2.a)
-    google-apis-storage_v1 (0.29.0)
+    google-apis-storage_v1 (0.31.0)
       google-apis-core (>= 0.11.0, < 2.a)
-    google-cloud-core (1.6.1)
+    google-cloud-core (1.8.0)
       google-cloud-env (>= 1.0, < 3.a)
       google-cloud-errors (~> 1.0)
     google-cloud-env (1.6.0)
       faraday (>= 0.17.3, < 3.0)
-    google-cloud-errors (1.3.1)
-    google-cloud-storage (1.45.0)
+    google-cloud-errors (1.5.0)
+    google-cloud-storage (1.47.0)
       addressable (~> 2.8)
       digest-crc (~> 0.4)
       google-apis-iamcredentials_v1 (~> 0.1)
-      google-apis-storage_v1 (~> 0.29.0)
+      google-apis-storage_v1 (~> 0.31.0)
       google-cloud-core (~> 1.6)
       googleauth (>= 0.16.2, < 2.a)
       mini_mime (~> 1.0)
@@ -151,36 +152,39 @@ GEM
       os (>= 0.9, < 2.0)
       signet (>= 0.16, < 2.a)
     highline (2.0.3)
-    http-cookie (1.0.7)
+    http-cookie (1.0.8)
       domain_name (~> 0.5)
-    httpclient (2.8.3)
+    httpclient (2.9.0)
+      mutex_m
     jmespath (1.6.2)
-    json (2.7.6)
-    jwt (2.9.3)
+    json (2.10.2)
+    jwt (2.10.1)
       base64
+    logger (1.7.0)
     mini_magick (4.13.2)
     mini_mime (1.1.5)
     multi_json (1.15.0)
     multipart-post (2.4.1)
+    mutex_m (0.3.0)
     nanaimo (0.4.0)
     naturally (2.2.1)
     nkf (0.2.0)
     optparse (0.6.0)
     os (1.1.4)
-    plist (3.7.1)
-    public_suffix (5.1.1)
+    plist (3.7.2)
+    public_suffix (6.0.1)
     rake (13.2.1)
     representable (3.2.0)
       declarative (< 0.1.0)
       trailblazer-option (>= 0.1.1, < 0.2.0)
       uber (< 0.2.0)
     retriable (3.1.2)
-    rexml (3.3.9)
-    rouge (2.0.7)
+    rexml (3.4.1)
+    rouge (3.28.0)
     ruby2_keywords (0.0.5)
-    rubyzip (2.3.2)
+    rubyzip (2.4.1)
     security (0.1.5)
-    signet (0.18.0)
+    signet (0.19.0)
       addressable (~> 2.8)
       faraday (>= 0.17.5, < 3.a)
       jwt (>= 1.5, < 3.0)
@@ -198,7 +202,6 @@ GEM
     tty-spinner (0.9.3)
       tty-cursor (~> 0.7)
     uber (0.1.0)
-    unf (0.2.0)
     unicode-display_width (2.6.0)
     word_wrap (1.0.0)
     xcodeproj (1.27.0)
@@ -208,8 +211,8 @@ GEM
       colored2 (~> 3.1)
       nanaimo (~> 0.4.0)
       rexml (>= 3.3.6, < 4.0)
-    xcpretty (0.3.0)
-      rouge (~> 2.0.7)
+    xcpretty (0.4.1)
+      rouge (~> 3.28.0)
     xcpretty-travis-formatter (1.0.1)
       xcpretty (~> 0.2, >= 0.0.7)
 
@@ -217,6 +220,7 @@ PLATFORMS
   arm64-darwin-21
   arm64-darwin-22
   arm64-darwin-23
+  arm64-darwin-24
   x86_64-darwin-19
   x86_64-darwin-24
   x86_64-linux
@@ -225,4 +229,4 @@ DEPENDENCIES
   fastlane
 
 BUNDLED WITH
-   2.4.19
+   2.6.2

+ 3 - 1
LiveActivity/Views/LiveActivityChartView.swift

@@ -144,7 +144,9 @@ struct LiveActivityChartView: View {
             let pointMark = PointMark(
                 x: .value("Time", chartDate),
                 y: .value("Value", displayValue)
-            ).symbolSize(16)
+            )
+            .symbolSize(16)
+            .shadow(color: Color.black.opacity(0.25), radius: 2, x: 0, y: 0)
 
             pointMark.foregroundStyle(pointMarkColor)
         }

+ 19 - 0
Model/Helper/Determination+helper.swift

@@ -11,6 +11,25 @@ extension OrefDetermination {
     }
 }
 
+extension Determination {
+    var minPredBGFromReason: Decimal? {
+        // Split reason into parts by semicolon and get first part
+        let reasonParts = reason.components(separatedBy: "; ").first?.components(separatedBy: ", ") ?? []
+
+        // Find the part that contains "minPredBG"
+        if let minPredBGPart = reasonParts.first(where: { $0.contains("minPredBG") }) {
+            // Extract the number after "minPredBG"
+            let components = minPredBGPart.components(separatedBy: "minPredBG ")
+            if let valueComponent = components.dropFirst().first {
+                // Get everything after "minPredBG " and convert to Decimal
+                let valueString = valueComponent.trimmingCharacters(in: CharacterSet(charactersIn: "0123456789.-").inverted)
+                return Decimal(string: valueString)
+            }
+        }
+        return nil
+    }
+}
+
 extension OrefDetermination {
     var reasonParts: [String] {
         reason?.components(separatedBy: "; ").first?.components(separatedBy: ", ") ?? []

+ 12 - 0
Model/Helper/NSPredicates.swift

@@ -120,4 +120,16 @@ extension NSPredicate {
         let date = Date.threeMonthsAgo
         return NSPredicate(format: "date >= %@", date as NSDate)
     }
+
+    static func predicateForDateBetween(start: Date, end: Date) -> NSPredicate {
+        NSPredicate(format: "date >= %@ AND date <= %@", start as NSDate, end as NSDate)
+    }
+
+    static func predicateForDeliverAtBetween(start: Date, end: Date) -> NSPredicate {
+        NSPredicate(format: "deliverAt >= %@ AND deliverAt <= %@", start as NSDate, end as NSDate)
+    }
+
+    static func predicateForTimestampBetween(start: Date, end: Date) -> NSPredicate {
+        NSPredicate(format: "timestamp >= %@ AND timestamp <= %@", start as NSDate, end as NSDate)
+    }
 }

+ 2 - 3
Model/Helper/PumpEvent+helper.swift

@@ -108,9 +108,8 @@ extension NSPredicate {
         return NSPredicate(format: "timestamp >= %@ AND bolus.isExternal == %@", date as NSDate, false as NSNumber)
     }
 
-    static func duplicateInLastHour(_ date: Date) -> NSPredicate {
-        let date60m = Date.oneHourAgo
-        return NSPredicate(format: "timestamp >= %@ && timestamp == %@", date60m as NSDate, date as NSDate)
+    static func duplicates(_ date: Date) -> NSPredicate {
+        NSPredicate(format: "timestamp == %@", date as NSDate)
     }
 
     static var pumpEventsNotYetUploadedToNightscout: NSPredicate {

+ 839 - 0
Model/JSONImporter.swift

@@ -0,0 +1,839 @@
+import CoreData
+import Foundation
+
+/// Migration-specific errors that might happen during migration
+enum JSONImporterError: Error {
+    case missingGlucoseValueInGlucoseEntry
+    case tempBasalAndDurationMismatch
+    case missingRequiredPropertyInPumpEntry
+    case suspendResumePumpEventMismatch
+    case duplicatePumpEvents
+    case missingCarbsValueInCarbEntry
+    case missingRequiredPropertyInDetermination(String)
+    case invalidDeterminationReason
+
+    var errorDescription: String? {
+        switch self {
+        case let .missingRequiredPropertyInDetermination(field):
+            return "Missing required property: \(field)"
+        case .invalidDeterminationReason:
+            return "Determination reason cannot be empty!"
+        default:
+            return nil
+        }
+    }
+}
+
+// MARK: - JSONImporter Class
+
+/// Responsible for importing JSON data into Core Data.
+///
+/// The importer handles two important states:
+/// - JSON files stored in the file system that contain data to import
+/// - Existing entries in CoreData that should not be duplicated
+///
+/// Imports are performed when a JSON file exists. The importer checks
+/// CoreData for existing entries to avoid duplicating records from partial imports.
+class JSONImporter {
+    private let context: NSManagedObjectContext
+    private let coreDataStack: CoreDataStack
+
+    /// Initializes the importer with a Core Data context.
+    init(context: NSManagedObjectContext, coreDataStack: CoreDataStack) {
+        self.context = context
+        self.coreDataStack = coreDataStack
+    }
+
+    /// Reads and parses a JSON file from the file system.
+    ///
+    /// - Parameters:
+    ///   - url: The URL of the JSON file to read.
+    /// - Returns: A decoded object of the specified type.
+    /// - Throws: An error if the file cannot be read or decoded.
+    private func readJsonFile<T: Decodable>(url: URL) throws -> T {
+        let data = try Data(contentsOf: url)
+        let decoder = JSONCoding.decoder
+        return try decoder.decode(T.self, from: data)
+    }
+
+    /// Fetches a set of unique `Date` values for a specific `NSManagedObject` type from Core Data.
+    ///
+    /// This helper function is used to retrieve all existing date-like values (e.g., `date`, `timestamp`, `deliverAt`)
+    /// from a given entity type within a specified time range. It wraps the fetch and transformation
+    /// in a `context.perform` block to ensure thread safety when used on private background contexts.
+    ///
+    /// - Parameters:
+    ///   - type: The `NSManagedObject` subclass to fetch (e.g., `GlucoseStored.self`, `PumpEventStored.self`)
+    ///   - predicate: A preconstructed predicate that filters the entity by date/timestamp range.
+    ///   - sortKey: The string name of the date-like field used to sort the fetch results. **This must match the key used in Core Data.**
+    ///   - dateKeyPath: A key path pointing to the `Date?` property on the entity used to extract the actual date value from each record.
+    ///
+    /// - Returns: A `Set<Date>` containing all non-nil date values from the fetched entities.
+    /// - Throws: `CoreDataError.fetchError` if casting the fetched objects fails, or if the fetch itself fails.
+
+    private func fetchDates<T: NSManagedObject>(
+        ofType type: T.Type,
+        predicate: NSPredicate,
+        sortKey: String,
+        dateKeyPath: KeyPath<T, Date?>
+    ) async throws -> Set<Date> {
+        let fetched = try await coreDataStack.fetchEntitiesAsync(
+            ofType: type,
+            onContext: context,
+            predicate: predicate,
+            key: sortKey,
+            ascending: false
+        )
+
+        return try await context.perform {
+            guard let typed = fetched as? [T] else {
+                throw CoreDataError.fetchError(function: #function, file: #file)
+            }
+
+            return Set(typed.compactMap { $0[keyPath: dateKeyPath] })
+        }
+    }
+
+    /// Imports glucose history from a JSON file into CoreData.
+    ///
+    /// The function reads glucose data from the provided JSON file and stores new entries
+    /// in CoreData, skipping entries with dates that already exist in the database.
+    ///
+    /// - Parameters:
+    ///   - url: The URL of the JSON file containing glucose history.
+    ///   - now: The current time, used to skip old entries
+    /// - Throws:
+    ///   - JSONImporterError.missingGlucoseValueInGlucoseEntry if a glucose entry is missing a value.
+    ///   - An error if the file cannot be read or decoded.
+    ///   - An error if the CoreData operation fails.
+    func importGlucoseHistory(url: URL, now: Date) async throws {
+        let twentyFourHoursAgo = now - 24.hours.timeInterval
+        let glucoseHistoryFull: [BloodGlucose] = try readJsonFile(url: url)
+        let existingDates = try await fetchDates(
+            ofType: GlucoseStored.self,
+            predicate: .predicateForDateBetween(start: twentyFourHoursAgo, end: now),
+            sortKey: "date",
+            dateKeyPath: \.date
+        )
+
+        // only import glucose values from the last 24 hours that don't exist
+        let glucoseHistory = glucoseHistoryFull
+            .filter { $0.dateString >= twentyFourHoursAgo && $0.dateString <= now && !existingDates.contains($0.dateString) }
+
+        // Create a background context for batch processing
+        let backgroundContext = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType)
+        backgroundContext.parent = context
+
+        try await backgroundContext.perform {
+            for glucoseEntry in glucoseHistory {
+                try glucoseEntry.store(in: backgroundContext)
+            }
+
+            try backgroundContext.save()
+        }
+
+        try await context.perform {
+            try self.context.save()
+        }
+    }
+
+    /// combines tempBasal and tempBasalDuration events into one PumpHistoryEvent
+    private func combineTempBasalAndDuration(pumpHistory: [PumpHistoryEvent]) throws -> [PumpHistoryEvent] {
+        let tempBasal = pumpHistory.filter({ $0.type == .tempBasal }).sorted { $0.timestamp < $1.timestamp }
+        let tempBasalDuration = pumpHistory.filter({ $0.type == .tempBasalDuration }).sorted { $0.timestamp < $1.timestamp }
+        let nonTempBasal = pumpHistory.filter { $0.type != .tempBasal && $0.type != .tempBasalDuration }
+
+        guard tempBasal.count == tempBasalDuration.count else {
+            throw JSONImporterError.tempBasalAndDurationMismatch
+        }
+
+        let combinedTempBasal = try zip(tempBasal, tempBasalDuration).map { rate, duration in
+            guard rate.timestamp == duration.timestamp else {
+                throw JSONImporterError.tempBasalAndDurationMismatch
+            }
+            return PumpHistoryEvent(
+                id: duration.id,
+                type: .tempBasal,
+                timestamp: duration.timestamp,
+                duration: duration.durationMin,
+                rate: rate.rate,
+                temp: rate.temp
+            )
+        }
+
+        return (combinedTempBasal + nonTempBasal).sorted { $0.timestamp < $1.timestamp }
+    }
+
+    /// checks for pumpHistory inconsistencies that might cause issues if we import these events into CoreData
+    private func checkForInconsistencies(pumpHistory: [PumpHistoryEvent]) throws {
+        // make sure that pump suspends / resumes match up
+        let suspendsAndResumes = pumpHistory.filter({ $0.type == .pumpSuspend || $0.type == .pumpResume })
+            .sorted { $0.timestamp < $1.timestamp }
+
+        for (current, next) in zip(suspendsAndResumes, suspendsAndResumes.dropFirst()) {
+            guard current.type != next.type else {
+                throw JSONImporterError.suspendResumePumpEventMismatch
+            }
+        }
+
+        // check for duplicate events
+        struct TypeTimestamp: Hashable {
+            let timestamp: Date
+            let type: EventType
+        }
+
+        let duplicates = Dictionary(grouping: pumpHistory) { TypeTimestamp(timestamp: $0.timestamp, type: $0.type) }
+            .values.first(where: { $0.count > 1 })
+
+        if duplicates != nil {
+            throw JSONImporterError.duplicatePumpEvents
+        }
+    }
+
+    /// Imports pump history from a JSON file into CoreData.
+    ///
+    /// The function reads pump history data from the provided JSON file and stores new entries
+    /// in CoreData, skipping entries with timestamps that already exist in the database.
+    ///
+    /// - Parameters:
+    ///   - url: The URL of the JSON file containing pump history.
+    ///   - now: The current time, used to skip old entries
+    /// - Throws:
+    ///   - JSONImporterError.tempBasalAndDurationMismatch if we can't match tempBasals with their duration.
+    ///   - An error if the file cannot be read or decoded.
+    ///   - An error if the CoreData operation fails.
+    func importPumpHistory(url: URL, now: Date) async throws {
+        let twentyFourHoursAgo = now - 24.hours.timeInterval
+        let pumpHistoryRaw: [PumpHistoryEvent] = try readJsonFile(url: url)
+        let existingTimestamps = try await fetchDates(
+            ofType: PumpEventStored.self,
+            predicate: .predicateForTimestampBetween(start: twentyFourHoursAgo, end: now),
+            sortKey: "timestamp",
+            dateKeyPath: \.timestamp
+        )
+        let pumpHistoryFiltered = pumpHistoryRaw
+            .filter { $0.timestamp >= twentyFourHoursAgo && $0.timestamp <= now && !existingTimestamps.contains($0.timestamp) }
+
+        let pumpHistory = try combineTempBasalAndDuration(pumpHistory: pumpHistoryFiltered)
+        try checkForInconsistencies(pumpHistory: pumpHistory)
+
+        // Create a background context for batch processing
+        let backgroundContext = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType)
+        backgroundContext.parent = context
+
+        try await backgroundContext.perform {
+            for pumpEntry in pumpHistory {
+                try pumpEntry.store(in: backgroundContext)
+            }
+
+            try backgroundContext.save()
+        }
+
+        try await context.perform {
+            try self.context.save()
+        }
+    }
+
+    /// Imports carb history from a JSON file into CoreData.
+    ///
+    /// The function reads carb entries data from the provided JSON file and stores new entries
+    /// in CoreData, skipping entries with dates that already exist in the database.
+    /// We ignore all FPU entries (aka carb equivalents) when performing an import.
+    ///
+    /// - Parameters:
+    ///   - url: The URL of the JSON file containing glucose history.
+    ///   - now: The current datetime
+    /// - Throws:
+    ///   - JSONImporterError.missingCarbsValueInCarbEntry if a carb entry is missing a `carbs: Decimal` value.
+    ///   - An error if the file cannot be read or decoded.
+    ///   - An error if the CoreData operation fails.
+    func importCarbHistory(url: URL, now: Date) async throws {
+        let twentyFourHoursAgo = now - 24.hours.timeInterval
+        let carbHistoryFull: [CarbsEntry] = try readJsonFile(url: url)
+        let existingDates = try await fetchDates(
+            ofType: CarbEntryStored.self,
+            predicate: .predicateForDateBetween(start: twentyFourHoursAgo, end: now),
+            sortKey: "date",
+            dateKeyPath: \.date
+        )
+
+        // Only import carb entries from the last 24 hours that do not exist yet in Core Data
+        // Only import "true" carb entries; ignore all FPU entries (aka carb equivalents)
+        let carbHistory = carbHistoryFull
+            .filter {
+                let dateToCheck = $0.actualDate ?? $0.createdAt
+                return dateToCheck >= twentyFourHoursAgo && dateToCheck <= now && !existingDates.contains(dateToCheck) && $0
+                    .isFPU ?? false == false }
+
+        // Create a background context for batch processing
+        let backgroundContext = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType)
+        backgroundContext.parent = context
+
+        try await backgroundContext.perform {
+            for carbEntry in carbHistory {
+                try carbEntry.store(in: backgroundContext)
+            }
+
+            try backgroundContext.save()
+        }
+
+        try await context.perform {
+            try self.context.save()
+        }
+    }
+
+    /// Imports oref determination from a JSON file into CoreData.
+    ///
+    /// The function reads oref determination data from the provided JSON file and stores new entries
+    /// in CoreData, skipping entries with dates that already exist in the database.
+    ///
+    /// - Parameters:
+    ///   - url: The URL of the JSON file containing determination data.
+    /// - Throws:
+    ///   - JSONImporterError.missingGlucoseValueInGlucoseEntry if a glucose entry is missing a value.
+    ///   - An error if the file cannot be read or decoded.
+    ///   - An error if the CoreData operation fails.
+    func importOrefDetermination(enactedUrl: URL, suggestedUrl: URL, now: Date) async throws {
+        let twentyFourHoursAgo = now - 24.hours.timeInterval
+        let enactedDetermination: Determination = try readJsonFile(url: enactedUrl)
+        let suggestedDetermination: Determination = try readJsonFile(url: suggestedUrl)
+        let existingDates = try await fetchDates(
+            ofType: OrefDetermination.self,
+            predicate: .predicateForDeliverAtBetween(start: twentyFourHoursAgo, end: now),
+            sortKey: "deliverAt",
+            dateKeyPath: \.deliverAt
+        )
+
+        /// Helper function to check if entries are from within the last 24 hours that do not yet exist in Core Data
+        func checkDeterminationDate(_ date: Date) -> Bool {
+            date >= twentyFourHoursAgo && date <= now && !existingDates.contains(date)
+        }
+
+        guard let enactedDeliverAt = enactedDetermination.deliverAt,
+              let suggestedDeliverAt = suggestedDetermination.deliverAt
+        else {
+            throw JSONImporterError.missingRequiredPropertyInDetermination("deliverAt")
+        }
+
+        guard checkDeterminationDate(enactedDeliverAt), checkDeterminationDate(suggestedDeliverAt) else {
+            return
+        }
+
+        try enactedDetermination.checkForRequiredFields()
+        try suggestedDetermination.checkForRequiredFields()
+
+        // Create a background context for batch processing
+        let backgroundContext = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType)
+        backgroundContext.parent = context
+
+        try await backgroundContext.perform {
+            /// We know both determination entries are from within last 24 hrs via `checkDeterminationDate()` in the earlier `guard` clause
+            /// If their `deliverAt` does not match, and if `suggestedDeliverAt` is newer, it is worth storing them both, as that represents
+            /// a more recent algorithm run that did not cause a dosing enactment, e.g., a carb entry or a manual bolus.
+            if suggestedDeliverAt > enactedDeliverAt {
+                try suggestedDetermination.store(in: backgroundContext)
+            }
+
+            try enactedDetermination.store(in: backgroundContext)
+
+            try backgroundContext.save()
+        }
+
+        try await context.perform {
+            try self.context.save()
+        }
+    }
+}
+
+// MARK: - Extension for Specific Import Functions
+
+extension BloodGlucose {
+    /// Helper function to convert `BloodGlucose` to `GlucoseStored` while importing JSON glucose entries
+    func store(in context: NSManagedObjectContext) throws {
+        guard let glucoseValue = glucose ?? sgv else {
+            throw JSONImporterError.missingGlucoseValueInGlucoseEntry
+        }
+
+        let glucoseEntry = GlucoseStored(context: context)
+        glucoseEntry.id = _id.flatMap({ UUID(uuidString: $0) }) ?? UUID()
+        glucoseEntry.date = dateString
+        glucoseEntry.glucose = Int16(glucoseValue)
+        glucoseEntry.direction = direction?.rawValue
+        glucoseEntry.isManual = type == "Manual"
+        glucoseEntry.isUploadedToNS = true
+        glucoseEntry.isUploadedToHealth = true
+        glucoseEntry.isUploadedToTidepool = true
+    }
+}
+
+extension PumpHistoryEvent {
+    /// Helper function to convert `PumpHistoryEvent` to `PumpEventStored` while importing JSON pump histories
+    func store(in context: NSManagedObjectContext) throws {
+        let pumpEntry = PumpEventStored(context: context)
+        pumpEntry.id = id
+        pumpEntry.timestamp = timestamp
+        pumpEntry.type = type.rawValue
+        pumpEntry.isUploadedToNS = true
+        pumpEntry.isUploadedToHealth = true
+        pumpEntry.isUploadedToTidepool = true
+
+        if type == .bolus {
+            guard let amount = amount else {
+                throw JSONImporterError.missingRequiredPropertyInPumpEntry
+            }
+            let bolusEntry = BolusStored(context: context)
+            bolusEntry.amount = NSDecimalNumber(decimal: amount)
+            bolusEntry.isSMB = isSMB ?? false
+            bolusEntry.isExternal = isExternal ?? isExternalInsulin ?? false
+            pumpEntry.bolus = bolusEntry
+        } else if type == .tempBasal {
+            guard let rate = rate, let duration = duration else {
+                throw JSONImporterError.missingRequiredPropertyInPumpEntry
+            }
+            let tempEntry = TempBasalStored(context: context)
+            tempEntry.rate = NSDecimalNumber(decimal: rate)
+            tempEntry.duration = Int16(duration)
+            tempEntry.tempType = temp?.rawValue
+            pumpEntry.tempBasal = tempEntry
+        }
+    }
+}
+
+/// Extension to support decoding `CarbsEntry` from JSON with multiple possible key formats for entry notes.
+///
+/// This is needed because some JSON sources (e.g., Trio v0.2.5) use the singular key `"note"`
+/// for the `note` field, while others (e.g., Nightscout or oref) use the plural `"notes"`.
+///
+/// To ensure compatibility across all sources without duplicating models or requiring upstream fixes,
+/// this custom implementation attempts to decode the `note` field first from `"note"`, then from `"notes"`.
+/// Encoding will always use the canonical `"notes"` key to preserve consistency in output,
+/// as this is what's established throughout the backend now.
+extension CarbsEntry: Codable {
+    init(from decoder: Decoder) throws {
+        let container = try decoder.container(keyedBy: CodingKeys.self)
+
+        id = try container.decodeIfPresent(String.self, forKey: .id)
+        createdAt = try container.decode(Date.self, forKey: .createdAt)
+        actualDate = try container.decodeIfPresent(Date.self, forKey: .actualDate)
+        carbs = try container.decode(Decimal.self, forKey: .carbs)
+        fat = try container.decodeIfPresent(Decimal.self, forKey: .fat)
+        protein = try container.decodeIfPresent(Decimal.self, forKey: .protein)
+
+        // Handle both `note` and `notes`
+        if let noteValue = try? container.decodeIfPresent(String.self, forKey: .note) {
+            note = noteValue
+        } else if let notesValue = try? container.decodeIfPresent(String.self, forKey: .noteAlt) {
+            note = notesValue
+        } else {
+            note = nil
+        }
+
+        enteredBy = try container.decodeIfPresent(String.self, forKey: .enteredBy)
+        isFPU = try container.decodeIfPresent(Bool.self, forKey: .isFPU)
+        fpuID = try container.decodeIfPresent(String.self, forKey: .fpuID)
+    }
+
+    func encode(to encoder: Encoder) throws {
+        var container = encoder.container(keyedBy: CodingKeys.self)
+
+        try container.encodeIfPresent(id, forKey: .id)
+        try container.encode(createdAt, forKey: .createdAt)
+        try container.encodeIfPresent(actualDate, forKey: .actualDate)
+        try container.encode(carbs, forKey: .carbs)
+        try container.encodeIfPresent(fat, forKey: .fat)
+        try container.encodeIfPresent(protein, forKey: .protein)
+        try container.encodeIfPresent(note, forKey: .note)
+        try container.encodeIfPresent(enteredBy, forKey: .enteredBy)
+        try container.encodeIfPresent(isFPU, forKey: .isFPU)
+        try container.encodeIfPresent(fpuID, forKey: .fpuID)
+    }
+
+    private enum CodingKeys: String, CodingKey {
+        case id = "_id"
+        case createdAt = "created_at"
+        case actualDate
+        case carbs
+        case fat
+        case protein
+        case note = "notes" // standard key
+        case noteAlt = "note" // import key
+        case enteredBy
+        case isFPU
+        case fpuID
+    }
+
+    /// Helper function to convert `CarbsStored` to `CarbEntryStored` while importing JSON carb entries
+    func store(in context: NSManagedObjectContext) throws {
+        guard carbs >= 0 else {
+            throw JSONImporterError.missingCarbsValueInCarbEntry
+        }
+
+        // skip FPU entries for now
+
+        let carbEntry = CarbEntryStored(context: context)
+        carbEntry.id = id
+            .flatMap({ UUID(uuidString: $0) }) ?? UUID() /// The `CodingKey` of `id` is `_id`, so this fine to use here
+        carbEntry.date = actualDate ?? createdAt
+        carbEntry.carbs = Double(truncating: NSDecimalNumber(decimal: carbs.rounded(toPlaces: 0)))
+        carbEntry.fat = Double(truncating: NSDecimalNumber(decimal: fat?.rounded(toPlaces: 0) ?? 0))
+        carbEntry.protein = Double(truncating: NSDecimalNumber(decimal: protein?.rounded(toPlaces: 0) ?? 0))
+        carbEntry.note = note ?? ""
+        carbEntry.isFPU = false
+        carbEntry.isUploadedToNS = true
+        carbEntry.isUploadedToHealth = true
+        carbEntry.isUploadedToTidepool = true
+    }
+}
+
+/// Extension to support decoding `Determination` entries with misspelled keys from external JSON sources.
+///
+/// Some legacy or third-party tools occasionally serialize the `received` property as `"recieved"`
+/// (misspelled) instead of the correct `"received"`. To prevent decoding failures or data loss,
+/// this custom decoder attempts to decode from `"received"` first, then falls back to `"recieved"`
+/// if necessary.
+///
+/// Encoding always uses the correct `"received"` key to ensure consistent, standards-compliant output.
+///
+/// This improves resilience and ensures compatibility with imported loop history, simulations,
+/// or devicestatus artifacts that may contain typos in their keys.
+extension Determination: Codable {
+    private enum CodingKeys: String, CodingKey {
+        case id
+        case reason
+        case units
+        case insulinReq
+        case eventualBG
+        case sensitivityRatio
+        case rate
+        case duration
+        case iob = "IOB"
+        case cob = "COB"
+        case predictions = "predBGs"
+        case deliverAt
+        case carbsReq
+        case temp
+        case bg
+        case reservoir
+        case timestamp
+        case isf = "ISF"
+        case current_target
+        case tdd = "TDD"
+        case insulinForManualBolus
+        case manualBolusErrorString
+        case minDelta
+        case expectedDelta
+        case minGuardBG
+        case minPredBG
+        case threshold
+        case carbRatio = "CR"
+        case received
+        case receivedAlt = "recieved"
+    }
+
+    init(from decoder: Decoder) throws {
+        let container = try decoder.container(keyedBy: CodingKeys.self)
+
+        id = try container.decodeIfPresent(UUID.self, forKey: .id)
+        reason = try container.decode(String.self, forKey: .reason)
+        units = try container.decodeIfPresent(Decimal.self, forKey: .units)
+        insulinReq = try container.decodeIfPresent(Decimal.self, forKey: .insulinReq)
+        eventualBG = try container.decodeIfPresent(Int.self, forKey: .eventualBG)
+        sensitivityRatio = try container.decodeIfPresent(Decimal.self, forKey: .sensitivityRatio)
+        rate = try container.decodeIfPresent(Decimal.self, forKey: .rate)
+        duration = try container.decodeIfPresent(Decimal.self, forKey: .duration)
+        iob = try container.decodeIfPresent(Decimal.self, forKey: .iob)
+        cob = try container.decodeIfPresent(Decimal.self, forKey: .cob)
+        predictions = try container.decodeIfPresent(Predictions.self, forKey: .predictions)
+        deliverAt = try container.decodeIfPresent(Date.self, forKey: .deliverAt)
+        carbsReq = try container.decodeIfPresent(Decimal.self, forKey: .carbsReq)
+        temp = try container.decodeIfPresent(TempType.self, forKey: .temp)
+        bg = try container.decodeIfPresent(Decimal.self, forKey: .bg)
+        reservoir = try container.decodeIfPresent(Decimal.self, forKey: .reservoir)
+        timestamp = try container.decodeIfPresent(Date.self, forKey: .timestamp)
+        isf = try container.decodeIfPresent(Decimal.self, forKey: .isf)
+        current_target = try container.decodeIfPresent(Decimal.self, forKey: .current_target)
+        tdd = try container.decodeIfPresent(Decimal.self, forKey: .tdd)
+        insulinForManualBolus = try container.decodeIfPresent(Decimal.self, forKey: .insulinForManualBolus)
+        manualBolusErrorString = try container.decodeIfPresent(Decimal.self, forKey: .manualBolusErrorString)
+        minDelta = try container.decodeIfPresent(Decimal.self, forKey: .minDelta)
+        expectedDelta = try container.decodeIfPresent(Decimal.self, forKey: .expectedDelta)
+        minGuardBG = try container.decodeIfPresent(Decimal.self, forKey: .minGuardBG)
+        minPredBG = try container.decodeIfPresent(Decimal.self, forKey: .minPredBG)
+        threshold = try container.decodeIfPresent(Decimal.self, forKey: .threshold)
+        carbRatio = try container.decodeIfPresent(Decimal.self, forKey: .carbRatio)
+
+        // Handle both spellings of "received"
+        if let value = try container.decodeIfPresent(Bool.self, forKey: .received) {
+            received = value
+        } else if let fallback = try container.decodeIfPresent(Bool.self, forKey: .receivedAlt) {
+            received = fallback
+        } else {
+            received = nil
+        }
+    }
+
+    func encode(to encoder: Encoder) throws {
+        var container = encoder.container(keyedBy: CodingKeys.self)
+
+        try container.encodeIfPresent(id, forKey: .id)
+        try container.encode(reason, forKey: .reason)
+        try container.encodeIfPresent(units, forKey: .units)
+        try container.encodeIfPresent(insulinReq, forKey: .insulinReq)
+        try container.encodeIfPresent(eventualBG, forKey: .eventualBG)
+        try container.encodeIfPresent(sensitivityRatio, forKey: .sensitivityRatio)
+        try container.encodeIfPresent(rate, forKey: .rate)
+        try container.encodeIfPresent(duration, forKey: .duration)
+        try container.encodeIfPresent(iob, forKey: .iob)
+        try container.encodeIfPresent(cob, forKey: .cob)
+        try container.encodeIfPresent(predictions, forKey: .predictions)
+        try container.encodeIfPresent(deliverAt, forKey: .deliverAt)
+        try container.encodeIfPresent(carbsReq, forKey: .carbsReq)
+        try container.encodeIfPresent(temp, forKey: .temp)
+        try container.encodeIfPresent(bg, forKey: .bg)
+        try container.encodeIfPresent(reservoir, forKey: .reservoir)
+        try container.encodeIfPresent(timestamp, forKey: .timestamp)
+        try container.encodeIfPresent(isf, forKey: .isf)
+        try container.encodeIfPresent(current_target, forKey: .current_target)
+        try container.encodeIfPresent(tdd, forKey: .tdd)
+        try container.encodeIfPresent(insulinForManualBolus, forKey: .insulinForManualBolus)
+        try container.encodeIfPresent(manualBolusErrorString, forKey: .manualBolusErrorString)
+        try container.encodeIfPresent(minDelta, forKey: .minDelta)
+        try container.encodeIfPresent(expectedDelta, forKey: .expectedDelta)
+        try container.encodeIfPresent(minGuardBG, forKey: .minGuardBG)
+        try container.encodeIfPresent(minPredBG, forKey: .minPredBG)
+        try container.encodeIfPresent(threshold, forKey: .threshold)
+        try container.encodeIfPresent(carbRatio, forKey: .carbRatio)
+        try container.encodeIfPresent(received, forKey: .received) // always encode the correct spelling
+    }
+
+    func checkForRequiredFields() throws {
+        guard let deliverAt = deliverAt else {
+            throw JSONImporterError.missingRequiredPropertyInDetermination("deliverAt")
+        }
+        guard let timestamp = timestamp else {
+            throw JSONImporterError.missingRequiredPropertyInDetermination("timestamp")
+        }
+        guard reason.isNotEmpty else {
+            throw JSONImporterError.invalidDeterminationReason
+        }
+        guard let insulinReq = insulinReq else {
+            throw JSONImporterError.missingRequiredPropertyInDetermination("insulinReq")
+        }
+        guard let currentTarget = current_target else {
+            throw JSONImporterError.missingRequiredPropertyInDetermination("current_target")
+        }
+        guard let reservoir = reservoir else {
+            throw JSONImporterError.missingRequiredPropertyInDetermination("reservoir")
+        }
+        guard let threshold = threshold else {
+            throw JSONImporterError.missingRequiredPropertyInDetermination("threshold")
+        }
+        guard let iob = iob else {
+            throw JSONImporterError.missingRequiredPropertyInDetermination("IOB")
+        }
+        guard let isf = isf else {
+            throw JSONImporterError.missingRequiredPropertyInDetermination("ISF")
+        }
+        guard let manualBolusErrorString = manualBolusErrorString else {
+            throw JSONImporterError.missingRequiredPropertyInDetermination("manualBolusErrorString")
+        }
+        guard let insulinForManualBolus = insulinForManualBolus else {
+            throw JSONImporterError.missingRequiredPropertyInDetermination("insulinForManualBolus")
+        }
+        guard let cob = cob else {
+            throw JSONImporterError.missingRequiredPropertyInDetermination("COB")
+        }
+        guard let tdd = tdd else {
+            throw JSONImporterError.missingRequiredPropertyInDetermination("TDD")
+        }
+        guard let bg = bg else {
+            throw JSONImporterError.missingRequiredPropertyInDetermination("bg")
+        }
+        guard let minDelta = minDelta else {
+            throw JSONImporterError.missingRequiredPropertyInDetermination("minDelta")
+        }
+        guard let eventualBG = eventualBG else {
+            throw JSONImporterError.missingRequiredPropertyInDetermination("eventualBG")
+        }
+        guard let sensitivityRatio = sensitivityRatio else {
+            throw JSONImporterError.missingRequiredPropertyInDetermination("sensitivityRatio")
+        }
+        guard let temp = temp else {
+            throw JSONImporterError.missingRequiredPropertyInDetermination("temp")
+        }
+        guard let expectedDelta = expectedDelta else {
+            throw JSONImporterError.missingRequiredPropertyInDetermination("expectedDelta")
+        }
+    }
+
+    /// Helper function to convert `Determination` to `OrefDetermination` while importing JSON glucose entries
+    func store(in context: NSManagedObjectContext) throws {
+        let newOrefDetermination = OrefDetermination(context: context)
+        newOrefDetermination.id = UUID()
+        newOrefDetermination.insulinSensitivity = decimalToNSDecimalNumber(isf)
+        newOrefDetermination.currentTarget = decimalToNSDecimalNumber(current_target)
+        newOrefDetermination.eventualBG = eventualBG.map(NSDecimalNumber.init)
+        newOrefDetermination.deliverAt = deliverAt
+        newOrefDetermination.timestamp = timestamp
+        newOrefDetermination.enacted = received ?? false
+        newOrefDetermination.insulinForManualBolus = decimalToNSDecimalNumber(insulinForManualBolus)
+        newOrefDetermination.carbRatio = decimalToNSDecimalNumber(carbRatio)
+        newOrefDetermination.glucose = decimalToNSDecimalNumber(bg)
+        newOrefDetermination.reservoir = decimalToNSDecimalNumber(reservoir)
+        newOrefDetermination.insulinReq = decimalToNSDecimalNumber(insulinReq)
+        newOrefDetermination.temp = temp?.rawValue ?? "absolute"
+        newOrefDetermination.rate = decimalToNSDecimalNumber(rate)
+        newOrefDetermination.reason = reason
+        newOrefDetermination.duration = decimalToNSDecimalNumber(duration)
+        newOrefDetermination.iob = decimalToNSDecimalNumber(iob)
+        newOrefDetermination.threshold = decimalToNSDecimalNumber(threshold)
+        newOrefDetermination.minDelta = decimalToNSDecimalNumber(minDelta)
+        newOrefDetermination.sensitivityRatio = decimalToNSDecimalNumber(sensitivityRatio)
+        newOrefDetermination.expectedDelta = decimalToNSDecimalNumber(expectedDelta)
+        newOrefDetermination.cob = Int16(Int(cob ?? 0))
+        newOrefDetermination.manualBolusErrorString = decimalToNSDecimalNumber(manualBolusErrorString)
+        newOrefDetermination.smbToDeliver = units.map { NSDecimalNumber(decimal: $0) }
+        newOrefDetermination.carbsRequired = Int16(Int(carbsReq ?? 0))
+        newOrefDetermination.isUploadedToNS = true
+
+        if let predictions = predictions {
+            ["iob": predictions.iob, "zt": predictions.zt, "cob": predictions.cob, "uam": predictions.uam]
+                .forEach { type, values in
+                    if let values = values {
+                        let forecast = Forecast(context: context)
+                        forecast.id = UUID()
+                        forecast.type = type
+                        forecast.date = Date()
+                        forecast.orefDetermination = newOrefDetermination
+
+                        for (index, value) in values.enumerated() {
+                            let forecastValue = ForecastValue(context: context)
+                            forecastValue.index = Int32(index)
+                            forecastValue.value = Int32(value)
+                            forecast.addToForecastValues(forecastValue)
+                        }
+                        newOrefDetermination.addToForecasts(forecast)
+                    }
+                }
+        }
+    }
+
+    func decimalToNSDecimalNumber(_ value: Decimal?) -> NSDecimalNumber? {
+        guard let value = value else { return nil }
+        return NSDecimalNumber(decimal: value)
+    }
+}
+
+extension JSONImporter {
+    private func openAPSFileURL(_ relativePath: String) -> URL {
+        FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
+            .appendingPathComponent(relativePath)
+    }
+
+    func importGlucoseHistoryIfNeeded() async throws {
+        debug(.coreData, "Checking for glucose history JSON file...")
+
+        let url = openAPSFileURL(OpenAPS.Monitor.glucose)
+        let suffix = "migrated.json"
+
+        guard FileManager.default.fileExists(atPath: url.path) else {
+            debug(.coreData, "❌ No JSON file to import at \(url.path)")
+            return
+        }
+
+        debug(.coreData, "Glucose history JSON file found, proceeding with import of glucose history...")
+
+        try await importGlucoseHistory(url: url, now: Date())
+
+        debug(.coreData, "Glucose history JSON file imported successfully, moving to \(suffix)")
+
+        try FileManager.default.moveItem(
+            at: url,
+            to: url.deletingPathExtension().appendingPathExtension(suffix)
+        )
+
+        debug(.coreData, "Import of glucose history completed successfully.")
+    }
+
+    func importPumpHistoryIfNeeded() async throws {
+        debug(.coreData, "Checking for pump history JSON file...")
+
+        let url = openAPSFileURL(OpenAPS.Monitor.pumpHistory)
+        let suffix = "migrated.json"
+
+        guard FileManager.default.fileExists(atPath: url.path) else {
+            debug(.coreData, "❌ No JSON file to import at \(url.path)")
+            return
+        }
+
+        debug(.coreData, "Pump history JSON file found, proceeding with import of glucose history...")
+
+        try await importPumpHistory(url: url, now: Date())
+
+        debug(.coreData, "Pump history JSON file imported successfully, moving to \(suffix)")
+
+        try FileManager.default.moveItem(
+            at: url,
+            to: url.deletingPathExtension().appendingPathExtension(suffix)
+        )
+
+        debug(.coreData, "Import of pump history completed successfully.")
+    }
+
+    func importCarbHistoryIfNeeded() async throws {
+        debug(.coreData, "Checking for carb history JSON file...")
+
+        let url = openAPSFileURL(OpenAPS.Monitor.carbHistory)
+        let suffix = "migrated.json"
+
+        guard FileManager.default.fileExists(atPath: url.path) else {
+            debug(.coreData, "❌ No JSON file to import at \(url.path)")
+            return
+        }
+
+        debug(.coreData, "Carb history JSON file found, proceeding with import of glucose history...")
+
+        try await importCarbHistory(url: url, now: Date())
+
+        debug(.coreData, "Carb history JSON file imported successfully, moving to \(suffix)")
+
+        try FileManager.default.moveItem(
+            at: url,
+            to: url.deletingPathExtension().appendingPathExtension(suffix)
+        )
+
+        debug(.coreData, "Import of carb history completed successfully.")
+    }
+
+    func importDeterminationIfNeeded() async throws {
+        debug(.coreData, "Checking for determination JSON files...")
+
+        let enactedPath = OpenAPS.Enact.enacted // "enact/enacted.json"
+        let suggestedPath = OpenAPS.Enact.suggested // "enact/suggested.json"
+        let suffix = "migrated.json"
+
+        let enactedURL = openAPSFileURL(enactedPath)
+        let suggestedURL = openAPSFileURL(suggestedPath)
+
+        guard FileManager.default.fileExists(atPath: enactedURL.path),
+              FileManager.default.fileExists(atPath: suggestedURL.path)
+        else {
+            debug(.coreData, "❌ No JSON file to import at \(enactedURL.path) and/or \(suggestedURL.path)")
+            return
+        }
+
+        debug(.coreData, "Determination JSON files found, proceeding with import...")
+
+        try await importOrefDetermination(enactedUrl: enactedURL, suggestedUrl: suggestedURL, now: Date())
+
+        debug(.coreData, "Determination JSON file(s) imported successfully, moving to \(suffix)")
+
+        try FileManager.default.moveItem(at: enactedURL, to: enactedURL.deletingPathExtension().appendingPathExtension(suffix))
+        try FileManager.default.moveItem(
+            at: suggestedURL,
+            to: suggestedURL.deletingPathExtension().appendingPathExtension(suffix)
+        )
+
+        debug(.coreData, "Import of determination data completed successfully.")
+    }
+}

+ 119 - 0
PRIVACY_POLICY.md

@@ -0,0 +1,119 @@
+# Privacy Policy
+
+## Introduction
+
+This Privacy Policy explains how we collect, use, and share
+information when you use Trio. We respect your privacy and are
+committed to protecting your personal data. Please read this Privacy
+Policy carefully to understand our practices regarding your personal
+data.
+
+## Information We Collect
+
+### What We Do NOT Collect
+
+For complete transparency, we want to clarify that Trio does not collect:
+- Blood glucose (BG) readings
+- Treatment data
+- Total daily doses (TDD)
+- Any health-related statistics or personal medical information
+- Personal identifiable information such as name, address, or email
+
+### Crash Reporting (Opt-In by default, with ability to Opt-Out)
+
+Trio uses Google Firebase Crashlytics to collect crash reports. During
+the initial app setup (onboarding process), you will be asked to opt
+in to crash reporting. The onboarding process is the series of screens
+you see when first launching Trio that helps you set up the app.
+
+The following information may be sent to Crashlytics when Trio crashes:
+
+- Time and date of the crash (example: "Trio crashed on April 6, 2025 at 2:15 PM")
+- Device state at the time of the crash (example: "Trio was in the foreground" or "Battery level was 42%")
+- Stack trace information (technical information showing which line of code failed)
+- Device model and OS version (example: "iPhone 14 Pro running iOS 17.4.1")
+- A generated unique identifier (a random code like "A7B2C9D3" that doesn't identify you personally)
+
+### Debug Symbols (dSYMs)
+
+When we build the Trio app, we create special files called debug
+symbols (dSYMs) that help us read crash reports. Think of these like a
+decoder ring for crashes:
+
+Without dSYMs, a crash might look like: "Error at memory address
+0x1234ABCD" With dSYMs, we can see: "Error in function
+'calculateInsulin' at line 157"
+
+These files only contain code-related information that helps us
+understand where crashes happen. They contain no personal information
+about you or how you use Trio.
+
+## How We Use Your Information
+
+We use anonymous crash report information exclusively to:
+
+- Identify and fix bugs and crashes
+- Improve Trio's stability
+
+We do not use this information for any other purpose, such as
+analytics, marketing, or user profiling.
+
+## Data Sharing and Third-Party Services
+
+### Crashlytics
+
+We use Google Firebase Crashlytics to collect and analyze crash
+reports. Crashlytics' privacy practices are governed by the [Google
+Privacy Policy](https://policies.google.com/privacy). For more
+information about how Crashlytics processes data, please visit their
+documentation.
+
+### Open Source Contributors
+
+As an open source project, crash reports and debugging information may
+be visible to project contributors who help maintain and improve
+Trio. All contributors are expected to adhere to this privacy policy
+and handle any data responsibly.
+
+## Opting Out and Data Retention
+
+You can opt out of crash reporting at any time through the Trio
+settings. If you opt out:
+
+- No new crash data will be collected or sent to us
+- Previously collected crash data will still be retained for approximately 90 days
+
+To avoid sending dSYMs to Crashlytics, you can delete the Trio target
+Build Phase script, titled "Copy dSYMs to Crashlytics".
+
+## Your Rights
+
+You have certain rights regarding your information, including:
+
+- The right to opt-out of crash reporting
+- The right to request deletion of your data
+
+To opt-out of crash reporting, please see the section above for
+details about how to configure Trio to not record crash reports.
+
+The information we store is anonymous, so we are unable to look up
+information for a particular individual. However, our general data
+retention policy ensures that data older than 90 days is deleted,
+enabling us to accommodate data deletion requests by design despite
+having anonymous data.
+
+## Changes to This Privacy Policy
+
+We may update this Privacy Policy from time to time. We will notify
+you of any changes by posting the new Privacy Policy on this page and
+updating the "Last Updated" date.
+
+## Contact Us
+
+If you have any questions about this Privacy Policy, please contact us
+on [Discord](http://discord.diy-trio.org/) or send us an email at
+trio.diy.diabetes@gmail.com.
+
+## Last Updated
+
+April 15, 2025

+ 13 - 0
Trio Watch App Extension/Helper/Helper+Enums.swift

@@ -19,6 +19,19 @@ enum AcknowledgementStatus: String, CaseIterable {
     case pending
 }
 
+enum AcknowledgmentCode: String, Codable {
+    case savingCarbs = "saving_carbs"
+    case enactingBolus = "enacting_bolus"
+    case comboComplete = "combo_complete"
+    case carbsLogged = "carbs_logged"
+    case overrideStarted = "override_started"
+    case overrideStopped = "override_stopped"
+    case tempTargetStarted = "temp_target_started"
+    case tempTargetStopped = "temp_target_stopped"
+    case genericSuccess = "success"
+    case genericFailure = "failure"
+}
+
 enum WatchSize {
     case watch40mm
     case watch41mm

+ 9 - 0
Trio Watch App Extension/TrioWatchApp.swift

@@ -1,9 +1,18 @@
 import SwiftUI
 
 @main struct TrioWatchApp: App {
+    @Environment(\.scenePhase) private var scenePhase
+
     var body: some Scene {
         WindowGroup {
             TrioMainWatchView()
         }
+        .onChange(of: scenePhase) { _, newScenePhase in
+            if newScenePhase == .background {
+                Task {
+                    await WatchLogger.shared.flushPersistedLogs()
+                }
+            }
+        }
     }
 }

+ 3 - 3
Trio Watch App Extension/Views/AcknowledgementPendingView.swift

@@ -28,13 +28,13 @@ struct AcknowledgementPendingView: View {
                 if state.isMealBolusCombo {
                     ProgressView()
                     Text(state.mealBolusStep.rawValue).multilineTextAlignment(.center)
-                } else if state.showCommsAnimation {
-                    ProgressView()
-                    Text("Processing…")
                 } else if state.showAcknowledgmentBanner {
                     statusIcon.padding()
                     Text(state.acknowledgmentMessage).multilineTextAlignment(.center)
                         .foregroundStyle(state.acknowledgementStatus == .failure ? Color.loopRed : Color.primary)
+                } else if state.showCommsAnimation {
+                    ProgressView()
+                    Text("Processing…")
                 }
             }
             .padding()

+ 0 - 10
Trio Watch App Extension/Views/BolusConfirmationView.swift

@@ -84,7 +84,6 @@ struct BolusConfirmationView: View {
                         state.sendCarbsRequest(state.carbsAmount, Date())
                         state.carbsAmount = 0 // reset carbs in state
                     }
-                    state.activeBolusAmount = bolusAmount
                     state.sendBolusRequest(Decimal(bolusAmount))
                     bolusAmount = 0 // reset bolus in state
                     confirmationProgress = 0 // reset auth progress
@@ -110,14 +109,5 @@ struct BolusConfirmationView: View {
                 )
             }
         }
-        .blur(radius: state.showBolusProgressOverlay ? 3 : 0)
-        .overlay {
-            if state.showBolusProgressOverlay {
-                BolusProgressOverlay(state: state) {
-                    state.shouldNavigateToRoot = false
-                    navigationPath.append(NavigationDestinations.acknowledgmentPending)
-                }.transition(.opacity)
-            }
-        }
     }
 }

+ 0 - 9
Trio Watch App Extension/Views/BolusInputView.swift

@@ -142,15 +142,6 @@ struct BolusInputView: View {
                     .clipShape(Circle())
             }
         }
-        .blur(radius: state.showBolusProgressOverlay ? 3 : 0)
-        .overlay {
-            if state.showBolusProgressOverlay {
-                BolusProgressOverlay(state: state) {
-                    state.shouldNavigateToRoot = false
-                    navigationPath.append(NavigationDestinations.acknowledgmentPending)
-                }.transition(.opacity)
-            }
-        }
         .onAppear {
             // Set initial bolus amount to recommended value
             // Only do this if user has not updated amount previously, e.g., when navigating to next and then back to this view

+ 86 - 86
Trio Watch App Extension/Views/BolusProgressOverlay.swift

@@ -1,86 +1,86 @@
-import SwiftUI
-
-struct BolusProgressOverlay: View {
-    let state: WatchState
-    let onCancelBolus: () -> Void
-
-    private let progressGradient = LinearGradient(
-        colors: [
-            Color(red: 0.7215686275, green: 0.3411764706, blue: 1), // #B857FF
-            Color(red: 0.6235294118, green: 0.4235294118, blue: 0.9803921569), // #9F6CFA
-            Color(red: 0.4862745098, green: 0.5450980392, blue: 0.9529411765), // #7C8BF3
-            Color(red: 0.3411764706, green: 0.6666666667, blue: 0.9254901961), // #57AAEC
-            Color(red: 0.262745098, green: 0.7333333333, blue: 0.9137254902) // #43BBE9
-        ],
-        startPoint: .leading,
-        endPoint: .trailing
-    )
-
-    private var isWatchStateDated: Bool {
-        // If `lastWatchStateUpdate` is nil, treat as "dated"
-        guard let lastUpdateTimestamp = state.lastWatchStateUpdate else {
-            return true
-        }
-        let now = Date().timeIntervalSince1970
-        let secondsSinceUpdate = now - lastUpdateTimestamp
-        // Return true if last update older than 5 min, so 1 loop cycle
-        return secondsSinceUpdate > 5 * 60
-    }
-
-    private var isSessionUnreachable: Bool {
-        guard let session = state.session else {
-            return true // No session at all => unreachable
-        }
-        // Return true if not .activated OR not reachable
-        return session.activationState != .activated
-    }
-
-    var body: some View {
-        VStack(spacing: 10) {
-            VStack {
-                Text("Bolusing")
-                    .font(.footnote)
-                    .foregroundStyle(.secondary)
-                    .padding(.top)
-
-                ProgressView(value: state.bolusProgress, total: 1.0)
-                    .tint(progressGradient)
-
-                Text(String(
-                    format: String(
-                        localized: "%.2f U of %.2f U",
-                        comment: "Format for showing delivered and active bolus amounts, 'x U of y U' on watch"
-                    ),
-                    state.deliveredAmount,
-                    state.activeBolusAmount
-                ))
-                    .font(.footnote)
-                    .foregroundStyle(.secondary)
-
-                Spacer()
-
-                Button(action: {
-                    state.sendCancelBolusRequest()
-                    onCancelBolus()
-                }) {
-                    Text("Cancel Bolus")
-                }
-                .buttonStyle(.bordered)
-                .padding()
-                .disabled(isWatchStateDated || isSessionUnreachable)
-            }
-            .padding()
-            .background(Color.black.opacity(0.9))
-            .cornerRadius(10)
-        }
-        .scenePadding()
-        .onChange(of: state.bolusProgress) { _, newProgress in
-            if newProgress >= 1.0 {
-                state.activeBolusAmount = 0 // Reset only when bolus is complete
-            }
-        }
-        .onDisappear {
-            state.activeBolusAmount = 0 // Triple-check to reset when view disappears
-        }
-    }
-}
+// import SwiftUI
+//
+// struct BolusProgressOverlay: View {
+//    let state: WatchState
+//    let onCancelBolus: () -> Void
+//
+//    private let progressGradient = LinearGradient(
+//        colors: [
+//            Color(red: 0.7215686275, green: 0.3411764706, blue: 1), // #B857FF
+//            Color(red: 0.6235294118, green: 0.4235294118, blue: 0.9803921569), // #9F6CFA
+//            Color(red: 0.4862745098, green: 0.5450980392, blue: 0.9529411765), // #7C8BF3
+//            Color(red: 0.3411764706, green: 0.6666666667, blue: 0.9254901961), // #57AAEC
+//            Color(red: 0.262745098, green: 0.7333333333, blue: 0.9137254902) // #43BBE9
+//        ],
+//        startPoint: .leading,
+//        endPoint: .trailing
+//    )
+//
+//    private var isWatchStateDated: Bool {
+//        // If `lastWatchStateUpdate` is nil, treat as "dated"
+//        guard let lastUpdateTimestamp = state.lastWatchStateUpdate else {
+//            return true
+//        }
+//        let now = Date().timeIntervalSince1970
+//        let secondsSinceUpdate = now - lastUpdateTimestamp
+//        // Return true if last update older than 5 min, so 1 loop cycle
+//        return secondsSinceUpdate > 5 * 60
+//    }
+//
+//    private var isSessionUnreachable: Bool {
+//        guard let session = state.session else {
+//            return true // No session at all => unreachable
+//        }
+//        // Return true if not .activated OR not reachable
+//        return session.activationState != .activated
+//    }
+//
+//    var body: some View {
+//        VStack(spacing: 10) {
+//            VStack {
+//                Text("Bolusing")
+//                    .font(.footnote)
+//                    .foregroundStyle(.secondary)
+//                    .padding(.top)
+//
+////                ProgressView(value: state.bolusProgress, total: 1.0)
+//                    .tint(progressGradient)
+//
+//                Text(String(
+//                    format: String(
+//                        localized: "%.2f U of %.2f U",
+//                        comment: "Format for showing delivered and active bolus amounts, 'x U of y U' on watch"
+//                    ),
+//                    state.deliveredAmount,
+//                    state.activeBolusAmount
+//                ))
+//                    .font(.footnote)
+//                    .foregroundStyle(.secondary)
+//
+//                Spacer()
+//
+//                Button(action: {
+//                    state.sendCancelBolusRequest()
+//                    onCancelBolus()
+//                }) {
+//                    Text("Cancel Bolus")
+//                }
+//                .buttonStyle(.bordered)
+//                .padding()
+//                .disabled(isWatchStateDated || isSessionUnreachable)
+//            }
+//            .padding()
+//            .background(Color.black.opacity(0.9))
+//            .cornerRadius(10)
+//        }
+//        .scenePadding()
+//        .onChange(of: state.bolusProgress) { _, newProgress in
+//            if newProgress >= 1.0 {
+//                state.activeBolusAmount = 0 // Reset only when bolus is complete
+//            }
+//        }
+//        .onDisappear {
+//            state.activeBolusAmount = 0 // Triple-check to reset when view disappears
+//        }
+//    }
+// }

+ 1 - 13
Trio Watch App Extension/Views/TrioMainWatchView.swift

@@ -96,10 +96,7 @@ struct TrioMainWatchView: View {
                 .tag(1)
             }
             .onAppear {
-                // Hard reset variables when main view appears
-                /// Reset `bolusProgress` and `activeBolusAmount` to ensure no stale bolus progressbar is stuck on home view
-                state.bolusProgress = 0
-                state.activeBolusAmount = 0
+                /// Hard reset variables when main view appears
                 /// Reset `bolusAmount` and `recommendedBolus` to ensure no stale / old value is set when user opens bolus input or meal combo the next time.
                 state.bolusAmount = 0
                 state.recommendedBolus = 0
@@ -226,15 +223,6 @@ struct TrioMainWatchView: View {
             }
         }
         .ignoresSafeArea()
-        .blur(radius: state.showBolusProgressOverlay ? 3 : 0)
-        .overlay {
-            if state.showBolusProgressOverlay {
-                BolusProgressOverlay(state: state) {
-                    state.shouldNavigateToRoot = false
-                    navigationPath.append(NavigationDestinations.acknowledgmentPending)
-                }.transition(.opacity)
-            }
-        }
     }
 
     private func updateRotation(for trend: String?) {

+ 150 - 0
Trio Watch App Extension/WatchLogger.swift

@@ -0,0 +1,150 @@
+import Foundation
+import WatchConnectivity
+
+actor WatchLogger {
+    static let shared = WatchLogger()
+
+    private var logs: [String] = []
+    private let maxEntries = 500
+    private let flushInterval: TimeInterval = 3 * 60
+    private let flushSizeThreshold = 100
+    private var lastFlush = Date()
+
+    private let session = WCSession.default
+    private var timerTask: Task<Void, Never>?
+
+    private init() {
+        Task {
+            await startFlushTimer()
+        }
+    }
+
+    private var dateFormatter: DateFormatter {
+        let formatter = DateFormatter()
+        formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssZ"
+        return formatter
+    }
+
+    private func startFlushTimer() async {
+        timerTask = Task {
+            while true {
+                try? await Task.sleep(nanoseconds: UInt64(flushInterval * 1_000_000_000))
+                await flushIfNeeded(force: false)
+            }
+        }
+    }
+
+    func log(
+        _ message: String,
+        force: Bool = false,
+        function: String = #function,
+        file: String = #fileID,
+        line: Int = #line
+    ) async {
+        let shortFile = (file as NSString).lastPathComponent
+        let timestamp = dateFormatter.string(from: Date())
+        let entry = "[\(timestamp)] [\(shortFile):\(line)] \(function) → \(message)"
+
+        logs.append(entry)
+        if logs.count > maxEntries {
+            logs.removeFirst(logs.count - maxEntries)
+        }
+
+        print(entry)
+        await flushIfNeeded(force: force)
+    }
+
+    func flushIfNeeded(force: Bool = false) async {
+        let now = Date()
+        let shouldFlush = force || now.timeIntervalSince(lastFlush) >= flushInterval || logs.count >= flushSizeThreshold
+
+        if shouldFlush {
+            await flushToPhone()
+        }
+    }
+
+    private func flushToPhone() async {
+        guard !logs.isEmpty else {
+            return
+        }
+
+        let payload: [String: Any] = ["watchLogs": logs.joined(separator: "\n")]
+
+        if session.activationState != .activated {
+            session.activate()
+        }
+
+        if session.isReachable {
+            session.sendMessage(payload, replyHandler: nil) { _ in
+                Task {
+                    await self.persistLogsLocally()
+                }
+            }
+        } else {
+            await persistLogsLocally()
+        }
+
+        lastFlush = Date()
+        logs.removeAll()
+    }
+
+    func persistLogsLocally() async {
+        let logDir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
+            .appendingPathComponent("logs", isDirectory: true)
+
+        try? FileManager.default.createDirectory(at: logDir, withIntermediateDirectories: true)
+
+        let logFile = logDir.appendingPathComponent("watch_log.txt")
+        let previousLogFile = logDir.appendingPathComponent("watch_log_prev.txt")
+        let startOfDay = Calendar.current.startOfDay(for: Date())
+
+        if let attributes = try? FileManager.default.attributesOfItem(atPath: logFile.path),
+           let creationDate = attributes[.creationDate] as? Date,
+           creationDate < startOfDay
+        {
+            try? FileManager.default.removeItem(at: previousLogFile)
+            try? FileManager.default.moveItem(at: logFile, to: previousLogFile)
+            FileManager.default.createFile(atPath: logFile.path, contents: nil, attributes: [.creationDate: startOfDay])
+        }
+
+        let fullLog = logs.joined(separator: "\n") + "\n"
+        if let data = fullLog.data(using: .utf8) {
+            if let handle = try? FileHandle(forWritingTo: logFile) {
+                try? handle.seekToEnd()
+                handle.write(data)
+                try? handle.close()
+            } else {
+                try? data.write(to: logFile)
+            }
+        }
+    }
+
+    func flushPersistedLogs() async {
+        let logDir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
+            .appendingPathComponent("logs", isDirectory: true)
+        let logFile = logDir.appendingPathComponent("watch_log.txt")
+
+        guard let data = try? Data(contentsOf: logFile),
+              let logString = String(data: data, encoding: .utf8),
+              !logString.isEmpty
+        else { return }
+
+        let payload: [String: Any] = ["watchLogs": logString]
+
+        if session.activationState != .activated {
+            session.activate()
+        }
+
+        if session.isReachable {
+            session.sendMessage(payload, replyHandler: nil) { _ in
+                Task {
+                    await self.persistLogsLocally()
+                }
+            }
+            try? FileManager.default.removeItem(at: logFile)
+        } else {
+            _ = session.transferUserInfo(payload)
+            try? FileManager.default.removeItem(at: logFile)
+        }
+    }
+}

+ 143 - 45
Trio Watch App Extension/WatchState+Requests.swift

@@ -8,20 +8,32 @@ extension WatchState {
     /// - Parameters:
     ///   - amount: The insulin amount to be delivered
     func sendBolusRequest(_ amount: Decimal) {
-        guard let session = session, session.isReachable else { return }
-        isBolusCanceled = false // Reset canceled state when starting new bolus
-        activeBolusAmount = Double(truncating: amount as NSNumber) // Set active bolus amount
+        guard let session = session, session.isReachable else {
+            Task {
+                await WatchLogger.shared.log("⌚️ Bolus request aborted: session unreachable")
+            }
+            return
+        }
+
+        Task {
+            await WatchLogger.shared.log("⌚️ Sending bolus request: \(amount)U")
+        }
 
         let message: [String: Any] = [
             WatchMessageKeys.bolus: amount
         ]
 
         session.sendMessage(message, replyHandler: nil) { error in
-            print("Error sending bolus request: \(error.localizedDescription)")
+            Task {
+                await WatchLogger.shared.log("Error sending bolus request: \(error.localizedDescription)")
+            }
         }
 
         // Display pending communication animation
         showCommsAnimation = true
+        Task {
+            await WatchLogger.shared.log("⌚️ showCommsAnimation = true")
+        }
     }
 
     /// Sends a carbohydrate entry request to the paired iPhone
@@ -29,7 +41,16 @@ extension WatchState {
     ///   - amount: The amount of carbs in grams
     ///   - date: The timestamp for the carb entry (defaults to current time)
     func sendCarbsRequest(_ amount: Int, _ date: Date = Date()) {
-        guard let session = session, session.isReachable else { return }
+        guard let session = session, session.isReachable else {
+            Task {
+                await WatchLogger.shared.log("⌚️ Carbs request aborted: session unreachable")
+            }
+            return
+        }
+
+        Task {
+            await WatchLogger.shared.log("⌚️ Sending carbs request: \(amount)g at \(date)")
+        }
 
         let message: [String: Any] = [
             WatchMessageKeys.carbs: amount,
@@ -37,104 +58,162 @@ extension WatchState {
         ]
 
         session.sendMessage(message, replyHandler: nil) { error in
-            print("Error sending carbs request: \(error.localizedDescription)")
+            Task {
+                await WatchLogger.shared.log("Error sending carbs request: \(error.localizedDescription)")
+                await WatchLogger.shared.log("⌚️ Saving logs to disk as fallback!")
+                await WatchLogger.shared.persistLogsLocally()
+            }
         }
 
         // Display pending communication animation
         showCommsAnimation = true
+        Task {
+            await WatchLogger.shared.log("⌚️ showCommsAnimation = true")
+        }
     }
 
     /// Sends a request to cancel the current override preset to the paired iPhone
     func sendCancelOverrideRequest() {
-        guard let session = session, session.isReachable else { return }
+        guard let session = session, session.isReachable else {
+            Task {
+                await WatchLogger.shared.log("⌚️ Cancel override request aborted: session unreachable")
+            }
+            return
+        }
+
+        Task {
+            await WatchLogger.shared.log("⌚️ Sending cancel override request")
+        }
 
         let message: [String: Any] = [
             WatchMessageKeys.cancelOverride: true
         ]
 
         session.sendMessage(message, replyHandler: nil) { error in
-            print("⌚️ Error sending cancel override request: \(error.localizedDescription)")
+            Task {
+                await WatchLogger.shared.log("⌚️ Error sending cancel override request: \(error.localizedDescription)")
+                await WatchLogger.shared.log("⌚️ Saving logs to disk as fallback!")
+                await WatchLogger.shared.persistLogsLocally()
+            }
         }
 
         // Display pending communication animation
         showCommsAnimation = true
+        Task {
+            await WatchLogger.shared.log("⌚️ showCommsAnimation = true")
+        }
     }
 
     /// Sends a request to activate an override preset to the paired iPhone
     /// - Parameter presetName: The name of the override preset to activate
     func sendActivateOverrideRequest(presetName: String) {
-        guard let session = session, session.isReachable else { return }
+        guard let session = session, session.isReachable else {
+            Task {
+                await WatchLogger.shared.log("⌚️ Activate override request aborted: session unreachable")
+            }
+            return
+        }
+
+        Task {
+            await WatchLogger.shared.log("⌚️ Sending activate override request for preset: \(presetName)")
+        }
 
         let message: [String: Any] = [
             WatchMessageKeys.activateOverride: presetName
         ]
 
         session.sendMessage(message, replyHandler: nil) { error in
-            print("⌚️ Error sending activate override request: \(error.localizedDescription)")
+            Task {
+                await WatchLogger.shared.log("⌚️ Error sending activate override request: \(error.localizedDescription)")
+                await WatchLogger.shared.log("⌚️ Saving logs to disk as fallback!")
+                await WatchLogger.shared.persistLogsLocally()
+            }
         }
 
         // Display pending communication animation
         showCommsAnimation = true
+        Task {
+            await WatchLogger.shared.log("⌚️ showCommsAnimation = true")
+        }
     }
 
     /// Sends a request to cancel the current temporary target to the paired iPhone
     func sendCancelTempTargetRequest() {
-        guard let session = session, session.isReachable else { return }
+        guard let session = session, session.isReachable else {
+            Task {
+                await WatchLogger.shared.log("⌚️ Cancel temp target request aborted: session unreachable")
+            }
+            return
+        }
+
+        Task {
+            await WatchLogger.shared.log("⌚️ Sending cancel temp target request")
+        }
 
         let message: [String: Any] = [
             WatchMessageKeys.cancelTempTarget: true
         ]
 
         session.sendMessage(message, replyHandler: nil) { error in
-            print("⌚️ Error sending cancel temp target request: \(error.localizedDescription)")
+            Task {
+                await WatchLogger.shared.log("⌚️ Error sending cancel temp target request: \(error.localizedDescription)")
+                await WatchLogger.shared.log("⌚️ Saving logs to disk as fallback!")
+                await WatchLogger.shared.persistLogsLocally()
+            }
         }
 
         // Display pending communication animation
         showCommsAnimation = true
+        Task {
+            await WatchLogger.shared.log("⌚️ showCommsAnimation = true")
+        }
     }
 
     /// Sends a request to activate a temporary target preset to the paired iPhone
     /// - Parameter presetName: The name of the temporary target preset to activate
     func sendActivateTempTargetRequest(presetName: String) {
-        guard let session = session, session.isReachable else { return }
-
-        let message: [String: Any] = [
-            WatchMessageKeys.activateTempTarget: presetName
-        ]
-
-        session.sendMessage(message, replyHandler: nil) { error in
-            print("⌚️ Error sending activate temp target request: \(error.localizedDescription)")
+        guard let session = session, session.isReachable else {
+            Task {
+                await WatchLogger.shared.log("⌚️ Activate temp target request aborted: session unreachable")
+            }
+            return
         }
 
-        // Display pending communication animation
-        showCommsAnimation = true
-    }
-
-    /// Sends a request to cancel the current bolus delivery to the paired iPhone
-    func sendCancelBolusRequest() {
-        isBolusCanceled = true
-
-        guard let session = session, session.isReachable else { return }
+        Task {
+            await WatchLogger.shared.log("⌚️ Sending activate temp target request for preset: \(presetName)")
+        }
 
         let message: [String: Any] = [
-            WatchMessageKeys.cancelBolus: true
+            WatchMessageKeys.activateTempTarget: presetName
         ]
 
         session.sendMessage(message, replyHandler: nil) { error in
-            print("Error sending cancel bolus request: \(error.localizedDescription)")
+            Task {
+                await WatchLogger.shared.log("⌚️ Error sending activate temp target request: \(error.localizedDescription)")
+                await WatchLogger.shared.log("⌚️ Saving logs to disk as fallback!")
+                await WatchLogger.shared.persistLogsLocally()
+            }
         }
 
-        // Reset when cancelled
-        bolusProgress = 0
-        activeBolusAmount = 0
-
         // Display pending communication animation
         showCommsAnimation = true
+        Task {
+            await WatchLogger.shared.log("⌚️ showCommsAnimation = true")
+        }
     }
 
     /// Sends a request to calculate a bolus recommendation based on the current carbs amount
     func requestBolusRecommendation() {
-        guard let session = session, session.isReachable else { return }
+        guard let session = session, session.isReachable else {
+            Task {
+                await WatchLogger.shared.log("⌚️ Bolus recommendation request aborted: session unreachable")
+            }
+            return
+        }
+
+        Task {
+            await WatchLogger.shared.log("⌚️ Requesting bolus recommendation for carbs: \(carbsAmount)")
+        }
 
         let message: [String: Any] = [
             WatchMessageKeys.requestBolusRecommendation: true,
@@ -142,29 +221,48 @@ extension WatchState {
         ]
 
         session.sendMessage(message, replyHandler: nil) { error in
-            print("Error requesting bolus recommendation: \(error.localizedDescription)")
+            Task {
+                await WatchLogger.shared.log("Error requesting bolus recommendation: \(error.localizedDescription)")
+                await WatchLogger.shared.log("⌚️ Saving logs to disk as fallback!")
+                await WatchLogger.shared.persistLogsLocally()
+            }
         }
-
-        showBolusCalculationProgress = true
     }
 
     func requestWatchStateUpdate() {
-        guard let session = session, session.activationState == .activated else {
-            print("⌚️ Session not activated, activating...")
-            session?.activate()
+        guard let session = session else {
+            Task {
+                await WatchLogger.shared.log("⌚️ No session available for state update")
+            }
+            return
+        }
+
+        guard session.activationState == .activated else {
+            Task {
+                await WatchLogger.shared.log("⌚️ Session not activated. Activating...")
+            }
+            session.activate()
             return
         }
 
         if session.isReachable {
-            print("⌚️ Request an update for watch state from Trio iPhone app...")
+            Task {
+                await WatchLogger.shared.log("⌚️ Requesting WatchState update from iPhone")
+            }
 
             let message = [WatchMessageKeys.requestWatchUpdate: WatchMessageKeys.watchState]
 
             session.sendMessage(message, replyHandler: nil) { error in
-                print("⌚️ Update request for fresh watch state data: \(error.localizedDescription)")
+                Task {
+                    await WatchLogger.shared.log("⌚️ Error requesting WatchState update: \(error.localizedDescription)")
+                    await WatchLogger.shared.log("⌚️ Saving logs to disk as fallback!")
+                    await WatchLogger.shared.persistLogsLocally()
+                }
             }
         } else {
-            print("⌚️ Phone not reachable for watch state update")
+            Task {
+                await WatchLogger.shared.log("⌚️ Phone not reachable for WatchState update")
+            }
         }
     }
 }

+ 140 - 165
Trio Watch App Extension/WatchState.swift

@@ -36,11 +36,6 @@ import WatchConnectivity
     var bolusAmount: Double = 0.0
     var confirmationProgress: Double = 0.0
 
-    var bolusProgress: Double = 0.0
-    var activeBolusAmount: Double = 0.0
-    var deliveredAmount: Double = 0.0
-    var isBolusCanceled = false
-
     // Safety limits
     var maxBolus: Decimal = 10
     var maxCarbs: Decimal = 250
@@ -65,13 +60,9 @@ import WatchConnectivity
     var mealBolusStep: MealBolusStep = .savingCarbs
     var isMealBolusCombo: Bool = false
 
-    var showBolusProgressOverlay: Bool {
-        (!showAcknowledgmentBanner || !showCommsAnimation) && bolusProgress > 0 && bolusProgress < 1.0 && !isBolusCanceled
-    }
-
     var recommendedBolus: Decimal = 0
 
-    // Debouncing and batch processing helpers
+    // MARK: - Debouncing and batch processing helpers
 
     /// Temporary storage for new data arriving via WatchConnectivity.
     private var pendingData: [String: Any] = [:]
@@ -96,32 +87,58 @@ import WatchConnectivity
             session.delegate = self
             session.activate()
             self.session = session
+            Task {
+                await WatchLogger.shared.log("⌚️ WCSession setup complete.")
+            }
         } else {
-            print("⌚️ WCSession is not supported on this device")
+            Task {
+                await WatchLogger.shared.log("⌚️ WCSession is not supported on this device")
+            }
         }
     }
 
     // MARK: – Handle Acknowledgement Messages FROM Phone
 
     func handleAcknowledgment(success: Bool, message: String, isFinal: Bool = true) {
+        Task {
+            await WatchLogger.shared.log("Handling acknowledgment: \(message), success: \(success), isFinal: \(isFinal)")
+        }
+
         if success {
-            print("⌚️ Acknowledgment received: \(message)")
+            Task {
+                await WatchLogger.shared.log("⌚️ Acknowledgment received: \(message)")
+            }
             acknowledgementStatus = .success
-            acknowledgmentMessage = "\(message)"
+            acknowledgmentMessage = message
+
+            // Hide progress animation
+            DispatchQueue.main.async {
+                self.showCommsAnimation = false
+            }
         } else {
-            print("⌚️ Acknowledgment failed: \(message)")
+            Task {
+                await WatchLogger.shared.log("⌚️ Acknowledgment failed: \(message)")
+            }
+
+            // Hide progress animation
             DispatchQueue.main.async {
-                self.showCommsAnimation = false // Hide progress animation
+                self.showCommsAnimation = false
             }
             acknowledgementStatus = .failure
             acknowledgmentMessage = "\(message)"
         }
 
         if isFinal {
-            showAcknowledgmentBanner = true
+            DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
+                self.showAcknowledgmentBanner = true
+            }
+
             DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
                 self.showAcknowledgmentBanner = false
                 self.showSyncingAnimation = false // Just ensure this is 100% set to false
+                Task {
+                    await WatchLogger.shared.log("Cleared ack banner and syncing animation")
+                }
             }
         }
     }
@@ -133,25 +150,35 @@ import WatchConnectivity
     func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) {
         DispatchQueue.main.async {
             if let error = error {
-                print("⌚️ Watch session activation failed: \(error.localizedDescription)")
+                Task {
+                    await WatchLogger.shared.log("⌚️ Watch session activation failed: \(error.localizedDescription)", force: true)
+                    await WatchLogger.shared.log("⌚️ Saving logs to disk as fallback!")
+                    await WatchLogger.shared.persistLogsLocally()
+                }
                 return
             }
 
             if activationState == .activated {
-                print("⌚️ Watch session activated with state: \(activationState.rawValue)")
+                Task {
+                    await WatchLogger.shared.log("⌚️ Watch session activated with state: \(activationState.rawValue)")
+                }
 
                 self.forceConditionalWatchStateUpdate()
 
                 self.isReachable = session.isReachable
 
-                print("⌚️ Watch isReachable after activation: \(session.isReachable)")
+                Task {
+                    await WatchLogger.shared.log("⌚️ Watch isReachable after activation: \(session.isReachable)")
+                }
             }
         }
     }
 
     /// Handles incoming messages from the paired iPhone when Phone is in the foreground
     func session(_: WCSession, didReceiveMessage message: [String: Any]) {
-        print("⌚️ Watch received data: \(message)")
+        Task {
+            await WatchLogger.shared.log("⌚️ Watch received data: \(message)")
+        }
 
         // If the message has a nested "watchState" dictionary with date as TimeInterval
         if let watchStateDict = message[WatchMessageKeys.watchState] as? [String: Any],
@@ -161,10 +188,14 @@ import WatchConnectivity
 
             // Check if it's not older than 15 min
             if date >= Date().addingTimeInterval(-15 * 60) {
-                print("⌚️ Handling watchState from \(date)")
+                Task {
+                    await WatchLogger.shared.log("⌚️ Handling watchState from \(date)")
+                }
                 processWatchMessage(message)
             } else {
-                print("⌚️ Received outdated watchState data (\(date))")
+                Task {
+                    await WatchLogger.shared.log("⌚️ Received outdated watchState data (\(date))")
+                }
                 DispatchQueue.main.async {
                     self.showSyncingAnimation = false
                 }
@@ -176,9 +207,13 @@ import WatchConnectivity
         // e.g. { "acknowledged": true, "message": "Started Temp Target...", "date": Date(...) }
         else if
             let acknowledged = message[WatchMessageKeys.acknowledged] as? Bool,
-            let ackMessage = message[WatchMessageKeys.message] as? String
+            let ackMessage = message[WatchMessageKeys.message] as? String,
+            let ackCodeRaw = message[WatchMessageKeys.ackCode] as? String
         {
-            print("⌚️ Handling ack with message: \(ackMessage), success: \(acknowledged)")
+            Task {
+                await WatchLogger.shared
+                    .log("⌚️ Handling ack with message: \(ackMessage), success: \(acknowledged), ackCode: \(ackCodeRaw)")
+            }
             DispatchQueue.main.async {
                 // For ack messages, we do NOT show “Syncing...”
                 self.showSyncingAnimation = false
@@ -190,7 +225,9 @@ import WatchConnectivity
         } else if
             let recommendedBolus = message[WatchMessageKeys.recommendedBolus] as? NSNumber
         {
-            print("⌚️ Received recommended bolus: \(recommendedBolus)")
+            Task {
+                await WatchLogger.shared.log("⌚️ Received recommended bolus: \(recommendedBolus)")
+            }
 
             DispatchQueue.main.async {
                 self.recommendedBolus = recommendedBolus.decimalValue
@@ -198,142 +235,46 @@ import WatchConnectivity
             }
 
             return
-
-                    // Handle bolus progress updates
-        } else if
-            let timestamp = message[WatchMessageKeys.bolusProgressTimestamp] as? TimeInterval,
-            let progress = message[WatchMessageKeys.bolusProgress] as? Double,
-            let activeBolusAmount = message[WatchMessageKeys.activeBolusAmount] as? Double,
-            let deliveredAmount = message[WatchMessageKeys.deliveredAmount] as? Double
-        {
-            let date = Date(timeIntervalSince1970: timestamp)
-
-            // Check if it's not older than 5 min
-            if date >= Date().addingTimeInterval(-5 * 60) {
-                print("⌚️ Handling bolusProgress (sent at \(date))")
-                DispatchQueue.main.async {
-                    if !self.isBolusCanceled {
-                        self.bolusProgress = progress
-                        self.activeBolusAmount = activeBolusAmount
-                        self.deliveredAmount = deliveredAmount
-                    }
-                }
-            } else {
-                print("⌚️ Received outdated bolus progress (sent at \(date))")
-                DispatchQueue.main.async {
-                    self.bolusProgress = 0
-                    self.activeBolusAmount = 0
-                }
-            }
-            return
-
-                    // Handle bolus cancellation
-        } else if
-            message[WatchMessageKeys.bolusCanceled] as? Bool == true
-        {
-            DispatchQueue.main.async {
-                self.bolusProgress = 0
-                self.activeBolusAmount = 0
-                self
-                    .isBolusCanceled =
-                    false /// Reset flag to ensure a bolus progress is also shown after canceling bolus from watch
-            }
-            return
         } else {
-            print("⌚️ Faulty data. Skipping...")
+            Task {
+                await WatchLogger.shared.log("⌚️ Faulty data. Skipping...")
+            }
             DispatchQueue.main.async {
                 self.showSyncingAnimation = false
             }
         }
     }
 
-    /// Handles incoming messages from the paired iPhone when Phone is in the background
     func session(_: WCSession, didReceiveUserInfo userInfo: [String: Any] = [:]) {
-        print("⌚️ Watch received data: \(userInfo)")
-
-        // If the message has a nested "watchState" dictionary with date as TimeInterval
-        if let watchStateDict = userInfo[WatchMessageKeys.watchState] as? [String: Any],
-           let timestamp = watchStateDict[WatchMessageKeys.date] as? TimeInterval
-        {
-            let date = Date(timeIntervalSince1970: timestamp)
-
-            // Check if it's not older than 15 min
-            if date >= Date().addingTimeInterval(-15 * 60) {
-                print("⌚️ Handling watchState from \(date)")
-                processWatchMessage(userInfo)
-            } else {
-                print("⌚️ Received outdated watchState data (\(date))")
-                DispatchQueue.main.async {
-                    self.showSyncingAnimation = false
-                }
+        guard let snapshot = WatchStateSnapshot(from: userInfo) else {
+            Task {
+                await WatchLogger.shared.log("⌚️ Invalid snapshot received", force: true)
             }
             return
         }
 
-        // Else if the message is an "ack" at the top level
-        // e.g. { "acknowledged": true, "message": "Started Temp Target...", "date": Date(...) }
-        else if
-            let acknowledged = userInfo[WatchMessageKeys.acknowledged] as? Bool,
-            let ackMessage = userInfo[WatchMessageKeys.message] as? String
-        {
-            print("⌚️ Handling ack with message: \(ackMessage), success: \(acknowledged)")
-            DispatchQueue.main.async {
-                // For ack messages, we do NOT show “Syncing...”
-                self.showSyncingAnimation = false
-            }
-            processWatchMessage(userInfo)
-            return
+        let lastProcessed = WatchStateSnapshot.loadLatestDateFromDisk()
 
-                    // Recommended bolus is also not part of the WatchState message, hence the extra condition here
-        } else if
-            let recommendedBolus = userInfo[WatchMessageKeys.recommendedBolus] as? NSNumber
-        {
-            print("⌚️ Received recommended bolus: \(recommendedBolus)")
-            self.recommendedBolus = recommendedBolus.decimalValue
-            showBolusCalculationProgress = false
+        guard snapshot.date > lastProcessed else {
+            Task {
+                await WatchLogger.shared.log("⌚️ Ignoring outdated or duplicate WatchState snapshot", force: true)
+            }
             return
+        }
 
-                    // Handle bolus progress updates
-        } else if
-            let timestamp = userInfo[WatchMessageKeys.bolusProgressTimestamp] as? TimeInterval,
-            let progress = userInfo[WatchMessageKeys.bolusProgress] as? Double,
-            let activeBolusAmount = userInfo[WatchMessageKeys.activeBolusAmount] as? Double,
-            let deliveredAmount = userInfo[WatchMessageKeys.deliveredAmount] as? Double
-        {
-            let date = Date(timeIntervalSince1970: timestamp)
+        WatchStateSnapshot.saveLatestDateToDisk(snapshot.date)
 
-            // Check if it's not older than 5 min
-            if date >= Date().addingTimeInterval(-5 * 60) {
-                print("⌚️ Handling bolusProgress (sent at \(date))")
-                DispatchQueue.main.async {
-                    if !self.isBolusCanceled {
-                        self.bolusProgress = progress
-                        self.activeBolusAmount = activeBolusAmount
-                        self.deliveredAmount = deliveredAmount
-                    }
-                }
-            } else {
-                print("⌚️ Received outdated bolus progress (sent at \(date))")
-                DispatchQueue.main.async {
-                    self.bolusProgress = 0
-                    self.activeBolusAmount = 0
-                }
-            }
-            return
+        DispatchQueue.main.async {
+            self.scheduleUIUpdate(with: snapshot.payload)
+        }
+    }
 
-                    // Handle bolus cancellation
-        } else if
-            userInfo[WatchMessageKeys.bolusCanceled] as? Bool == true
-        {
-            DispatchQueue.main.async {
-                self.bolusProgress = 0
-                self.activeBolusAmount = 0
-            }
-            return
-        } else {
-            print("⌚️ Faulty data. Skipping...")
-            DispatchQueue.main.async {
-                self.showSyncingAnimation = false
+    func session(_: WCSession, didFinish _: WCSessionUserInfoTransfer, error: (any Error)?) {
+        if let error = error {
+            Task {
+                await WatchLogger.shared.log("⌚️ transferUserInfo failed with error: \(error.localizedDescription)")
+                await WatchLogger.shared.log("⌚️ Saving logs to disk as fallback!")
+                await WatchLogger.shared.persistLogsLocally()
             }
         }
     }
@@ -342,7 +283,9 @@ import WatchConnectivity
     /// Updates the local reachability status
     func sessionReachabilityDidChange(_ session: WCSession) {
         DispatchQueue.main.async {
-            print("⌚️ Watch reachability changed: \(session.isReachable)")
+            Task {
+                await WatchLogger.shared.log("⌚️ Watch reachability changed: \(session.isReachable)")
+            }
 
             if session.isReachable {
                 self.forceConditionalWatchStateUpdate()
@@ -367,6 +310,10 @@ import WatchConnectivity
     /// it will show a syncing animation and request a new watch state update from the iPhone app.
     private func forceConditionalWatchStateUpdate() {
         guard let lastUpdateTimestamp = lastWatchStateUpdate else {
+            Task {
+                await WatchLogger.shared.log("Forcing initial WatchState update")
+            }
+
             // If there's no recorded timestamp, we must force a fresh update immediately.
             showSyncingAnimation = true
             requestWatchStateUpdate()
@@ -375,6 +322,9 @@ import WatchConnectivity
 
         let now = Date().timeIntervalSince1970
         let secondsSinceUpdate = now - lastUpdateTimestamp
+        Task {
+            await WatchLogger.shared.log("Time since last update: \(secondsSinceUpdate) seconds")
+        }
 
         // If more than 15 seconds have elapsed since the last update, force an(other) update.
         if secondsSinceUpdate > 15 {
@@ -389,26 +339,30 @@ import WatchConnectivity
         DispatchQueue.main.async {
             // 1) Acknowledgment logic
             if let acknowledged = message[WatchMessageKeys.acknowledged] as? Bool,
-               let ackMessage = message[WatchMessageKeys.message] as? String
+               let ackMessage = message[WatchMessageKeys.message] as? String,
+               let ackCodeRaw = message[WatchMessageKeys.ackCode] as? String,
+               let ackCode = AcknowledgmentCode(rawValue: ackCodeRaw)
             {
                 DispatchQueue.main.async {
                     self.showSyncingAnimation = false
                 }
 
-                print("⌚️ Received acknowledgment: \(ackMessage), success: \(acknowledged)")
+                Task {
+                    await WatchLogger.shared.log("⌚️ Received acknowledgment: \(ackMessage), success: \(acknowledged)")
+                }
 
-                switch ackMessage {
-                case "Saving carbs...":
+                switch ackCode {
+                case .savingCarbs:
                     self.isMealBolusCombo = true
                     self.mealBolusStep = .savingCarbs
                     self.showCommsAnimation = true
                     self.handleAcknowledgment(success: acknowledged, message: ackMessage, isFinal: false)
-                case "Enacting bolus...":
+                case .enactingBolus:
                     self.isMealBolusCombo = true
                     self.mealBolusStep = .enactingBolus
                     self.showCommsAnimation = true
                     self.handleAcknowledgment(success: acknowledged, message: ackMessage, isFinal: false)
-                case "Carbs and bolus logged successfully":
+                case .comboComplete:
                     self.isMealBolusCombo = false
                     self.handleAcknowledgment(success: acknowledged, message: ackMessage, isFinal: true)
                 default:
@@ -426,11 +380,25 @@ import WatchConnectivity
 
     /// Accumulate new data, set isSyncing, and debounce final update
     private func scheduleUIUpdate(with newData: [String: Any]) {
+        if let incomingTimestamp = newData[WatchMessageKeys.date] as? TimeInterval,
+           let lastTimestamp = lastWatchStateUpdate,
+           incomingTimestamp <= lastTimestamp
+        {
+            Task {
+                await WatchLogger.shared.log("Skipping UI update — outdated WatchState (\(incomingTimestamp))")
+            }
+            return
+        }
+
         // 1) Mark as syncing
         DispatchQueue.main.async {
             self.showSyncingAnimation = true
         }
 
+        Task {
+            await WatchLogger.shared.log("Merging new WatchState data with keys: \(newData.keys.joined(separator: ", "))")
+        }
+
         // 2) Merge data into our pendingData
         pendingData.merge(newData) { _, newVal in newVal }
 
@@ -439,6 +407,9 @@ import WatchConnectivity
 
         // 4) Create and schedule a new finalization
         let workItem = DispatchWorkItem { [self] in
+            Task {
+                await WatchLogger.shared.log("⏳ Debounced update fired")
+            }
             self.finalizePendingData()
         }
         finalizeWorkItem = workItem
@@ -448,6 +419,10 @@ import WatchConnectivity
     /// Applies all pending data to the watch state in one shot
     private func finalizePendingData() {
         guard !pendingData.isEmpty else {
+            Task {
+                await WatchLogger.shared.log("⚠️ finalizePendingData called with empty data")
+            }
+
             // If we have no actual data, just end syncing
             DispatchQueue.main.async {
                 self.showSyncingAnimation = false
@@ -455,7 +430,9 @@ import WatchConnectivity
             return
         }
 
-        print("⌚️ Finalizing pending data: \(pendingData)")
+        Task {
+            await WatchLogger.shared.log("⌚️ Finalizing pending data")
+        }
 
         // Actually set your main UI properties here
         processRawDataForWatchState(pendingData)
@@ -467,12 +444,23 @@ import WatchConnectivity
         DispatchQueue.main.async {
             self.showSyncingAnimation = false
         }
+
+        Task {
+            await WatchLogger.shared.log("✅ Watch UI update complete")
+        }
     }
 
     /// Updates the UI properties
     private func processRawDataForWatchState(_ message: [String: Any]) {
+        Task {
+            await WatchLogger.shared.log("Processing raw WatchState data with keys: \(message.keys.joined(separator: ", "))")
+        }
+
         if let timestamp = message[WatchMessageKeys.date] as? TimeInterval {
             lastWatchStateUpdate = timestamp
+            Task {
+                await WatchLogger.shared.log("Updated lastWatchStateUpdate: \(timestamp)")
+            }
         }
 
         if let currentGlucose = message[WatchMessageKeys.currentGlucose] as? String {
@@ -549,22 +537,9 @@ import WatchConnectivity
             }
         }
 
-        if let bolusProgress = message[WatchMessageKeys.bolusProgress] as? Double {
-            if !isBolusCanceled {
-                self.bolusProgress = bolusProgress
-            }
-        }
-
-        if let bolusWasCanceled = message[WatchMessageKeys.bolusCanceled] as? Bool, bolusWasCanceled {
-            bolusProgress = 0
-            activeBolusAmount = 0
-        }
-
         if let maxBolusValue = message[WatchMessageKeys.maxBolus] {
-            print("⌚️ Received maxBolus: \(maxBolusValue) of type \(type(of: maxBolusValue))")
             if let decimalValue = (maxBolusValue as? NSNumber)?.decimalValue {
                 maxBolus = decimalValue
-                print("⌚️ Converted maxBolus to: \(decimalValue)")
             }
         }
 

+ 39 - 0
Trio Watch App Extension/WatchStateSnapshot.swift

@@ -0,0 +1,39 @@
+//
+//  WatchStateSnapshot.swift
+//  Trio
+//
+//  Created by Cengiz Deniz on 18.04.25.
+//
+import Foundation
+
+struct WatchStateSnapshot {
+    let date: Date
+    let payload: [String: Any]
+
+    init?(from dictionary: [String: Any]) {
+        guard let timestamp = dictionary[WatchMessageKeys.date] as? TimeInterval,
+              let payload = dictionary[WatchMessageKeys.watchState] as? [String: Any]
+        else {
+            return nil
+        }
+
+        date = Date(timeIntervalSince1970: timestamp)
+        self.payload = payload
+    }
+
+    func toDictionary() -> [String: Any] {
+        [
+            WatchMessageKeys.date: date.timeIntervalSince1970,
+            WatchMessageKeys.watchState: payload
+        ]
+    }
+
+    static func saveLatestDateToDisk(_ date: Date) {
+        UserDefaults.standard.set(date.timeIntervalSince1970, forKey: "WatchStateSnapshot.latest")
+    }
+
+    static func loadLatestDateFromDisk() -> Date {
+        let interval = UserDefaults.standard.double(forKey: "WatchStateSnapshot.latest")
+        return Date(timeIntervalSince1970: interval)
+    }
+}

文件差異過大導致無法顯示
+ 350 - 20
Trio.xcodeproj/project.pbxproj


+ 4 - 1
Trio.xcodeproj/xcshareddata/xcschemes/Trio.xcscheme

@@ -221,7 +221,6 @@
       selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
       selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
       enableThreadSanitizer = "YES"
-      enableUBSanitizer = "YES"
       launchStyle = "0"
       useCustomWorkingDirectory = "NO"
       ignoresPersistentStateOnLaunch = "NO"
@@ -244,6 +243,10 @@
             isEnabled = "YES">
          </CommandLineArgument>
          <CommandLineArgument
+            argument = "-FIRDebugEnabled "
+            isEnabled = "NO">
+         </CommandLineArgument>
+         <CommandLineArgument
             argument = "-com.apple.CoreData.SQLDebug 1"
             isEnabled = "NO">
          </CommandLineArgument>

+ 118 - 1
Trio.xcworkspace/xcshareddata/swiftpm/Package.resolved

@@ -1,7 +1,25 @@
 {
-  "originHash" : "52d77fc35af7fe71614051dee0b291e2a0d38522eac7ae4d37d2442e81c7530c",
+  "originHash" : "89074a88ed67a58ecd7534519854c5a0928a4046d7c8a6123a7d70f27bf8b44d",
   "pins" : [
     {
+      "identity" : "abseil-cpp-binary",
+      "kind" : "remoteSourceControl",
+      "location" : "https://github.com/google/abseil-cpp-binary.git",
+      "state" : {
+        "revision" : "bbe8b69694d7873315fd3a4ad41efe043e1c07c5",
+        "version" : "1.2024072200.0"
+      }
+    },
+    {
+      "identity" : "app-check",
+      "kind" : "remoteSourceControl",
+      "location" : "https://github.com/google/app-check.git",
+      "state" : {
+        "revision" : "61b85103a1aeed8218f17c794687781505fbbef5",
+        "version" : "11.2.0"
+      }
+    },
+    {
       "identity" : "cryptoswift",
       "kind" : "remoteSourceControl",
       "location" : "https://github.com/krzyzanowskim/CryptoSwift",
@@ -11,6 +29,78 @@
       }
     },
     {
+      "identity" : "firebase-ios-sdk",
+      "kind" : "remoteSourceControl",
+      "location" : "https://github.com/firebase/firebase-ios-sdk.git",
+      "state" : {
+        "revision" : "d1f7c7e8eaa74d7e44467184dc5f592268247d33",
+        "version" : "11.11.0"
+      }
+    },
+    {
+      "identity" : "googleappmeasurement",
+      "kind" : "remoteSourceControl",
+      "location" : "https://github.com/google/GoogleAppMeasurement.git",
+      "state" : {
+        "revision" : "dd89fc79a77183830742a16866d87e4e54785734",
+        "version" : "11.11.0"
+      }
+    },
+    {
+      "identity" : "googledatatransport",
+      "kind" : "remoteSourceControl",
+      "location" : "https://github.com/google/GoogleDataTransport.git",
+      "state" : {
+        "revision" : "617af071af9aa1d6a091d59a202910ac482128f9",
+        "version" : "10.1.0"
+      }
+    },
+    {
+      "identity" : "googleutilities",
+      "kind" : "remoteSourceControl",
+      "location" : "https://github.com/google/GoogleUtilities.git",
+      "state" : {
+        "revision" : "53156c7ec267db846e6b64c9f4c4e31ba4cf75eb",
+        "version" : "8.0.2"
+      }
+    },
+    {
+      "identity" : "grpc-binary",
+      "kind" : "remoteSourceControl",
+      "location" : "https://github.com/google/grpc-binary.git",
+      "state" : {
+        "revision" : "cc0001a0cf963aa40501d9c2b181e7fc9fd8ec71",
+        "version" : "1.69.0"
+      }
+    },
+    {
+      "identity" : "gtm-session-fetcher",
+      "kind" : "remoteSourceControl",
+      "location" : "https://github.com/google/gtm-session-fetcher.git",
+      "state" : {
+        "revision" : "4d70340d55d7d07cc2fdf8e8125c4c126c1d5f35",
+        "version" : "4.4.0"
+      }
+    },
+    {
+      "identity" : "interop-ios-for-google-sdks",
+      "kind" : "remoteSourceControl",
+      "location" : "https://github.com/google/interop-ios-for-google-sdks.git",
+      "state" : {
+        "revision" : "040d087ac2267d2ddd4cca36c757d1c6a05fdbfe",
+        "version" : "101.0.0"
+      }
+    },
+    {
+      "identity" : "leveldb",
+      "kind" : "remoteSourceControl",
+      "location" : "https://github.com/firebase/leveldb.git",
+      "state" : {
+        "revision" : "a0bc79961d7be727d258d33d5a6b2f1023270ba1",
+        "version" : "1.22.5"
+      }
+    },
+    {
       "identity" : "mkringprogressview",
       "kind" : "remoteSourceControl",
       "location" : "https://github.com/maxkonovalov/MKRingProgressView.git",
@@ -20,6 +110,24 @@
       }
     },
     {
+      "identity" : "nanopb",
+      "kind" : "remoteSourceControl",
+      "location" : "https://github.com/firebase/nanopb.git",
+      "state" : {
+        "revision" : "b7e1104502eca3a213b46303391ca4d3bc8ddec1",
+        "version" : "2.30910.0"
+      }
+    },
+    {
+      "identity" : "promises",
+      "kind" : "remoteSourceControl",
+      "location" : "https://github.com/google/promises.git",
+      "state" : {
+        "revision" : "540318ecedd63d883069ae7f1ed811a2df00b6ac",
+        "version" : "2.4.0"
+      }
+    },
+    {
       "identity" : "slidebutton",
       "kind" : "remoteSourceControl",
       "location" : "https://github.com/no-comment/SlideButton",
@@ -47,6 +155,15 @@
       }
     },
     {
+      "identity" : "swift-protobuf",
+      "kind" : "remoteSourceControl",
+      "location" : "https://github.com/apple/swift-protobuf.git",
+      "state" : {
+        "revision" : "d72aed98f8253ec1aa9ea1141e28150f408cf17f",
+        "version" : "1.29.0"
+      }
+    },
+    {
       "identity" : "swiftcharts",
       "kind" : "remoteSourceControl",
       "location" : "https://github.com/ivanschuetz/SwiftCharts.git",

+ 30 - 0
Trio/GoogleService-Info.plist

@@ -0,0 +1,30 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+	<key>API_KEY</key>
+	<string>AIzaSyBXceaGy1LrHLHfCNxhGqjxyjDR0fOLkOM</string>
+	<key>GCM_SENDER_ID</key>
+	<string>376584015262</string>
+	<key>PLIST_VERSION</key>
+	<string>1</string>
+	<key>BUNDLE_ID</key>
+	<string>org.nightscout.trio</string>
+	<key>PROJECT_ID</key>
+	<string>trio-e776c</string>
+	<key>STORAGE_BUCKET</key>
+	<string>trio-e776c.firebasestorage.app</string>
+	<key>IS_ADS_ENABLED</key>
+	<false></false>
+	<key>IS_ANALYTICS_ENABLED</key>
+	<false></false>
+	<key>IS_APPINVITE_ENABLED</key>
+	<false></false>
+	<key>IS_GCM_ENABLED</key>
+	<false></false>
+	<key>IS_SIGNIN_ENABLED</key>
+	<false></false>
+	<key>GOOGLE_APP_ID</key>
+	<string>1:376584015262:ios:7a4dd770ce4c46b486da8f</string>
+</dict>
+</plist>

+ 12 - 0
Trio/Resources/Assets.xcassets/logo.bluetooth.capsule.portrait.fill.symbolset/Contents.json

@@ -0,0 +1,12 @@
+{
+  "info" : {
+    "author" : "xcode",
+    "version" : 1
+  },
+  "symbols" : [
+    {
+      "filename" : "logo.bluetooth.capsule.portrait.fill.svg",
+      "idiom" : "universal"
+    }
+  ]
+}

文件差異過大導致無法顯示
+ 196 - 0
Trio/Resources/Assets.xcassets/logo.bluetooth.capsule.portrait.fill.symbolset/logo.bluetooth.capsule.portrait.fill.svg


文件差異過大導致無法顯示
+ 1 - 1
Trio/Resources/javascript/bundle/autosens.js


文件差異過大導致無法顯示
+ 1 - 1
Trio/Resources/javascript/bundle/autotune-core.js


文件差異過大導致無法顯示
+ 1 - 1
Trio/Resources/javascript/bundle/autotune-prep.js


文件差異過大導致無法顯示
+ 1 - 1
Trio/Resources/javascript/bundle/basal-set-temp.js


文件差異過大導致無法顯示
+ 1 - 1
Trio/Resources/javascript/bundle/determine-basal.js


文件差異過大導致無法顯示
+ 1 - 1
Trio/Resources/javascript/bundle/iob.js


文件差異過大導致無法顯示
+ 1 - 1
Trio/Resources/javascript/bundle/meal.js


文件差異過大導致無法顯示
+ 1 - 1
Trio/Resources/javascript/bundle/profile.js


+ 0 - 1
Trio/Resources/json/defaults/preferences.json

@@ -43,7 +43,6 @@
   "adjustmentFactor" : 0.8,
   "adjustmentFactorSigmoid" : 0.5,
   "sigmoid" : false,
-  "enableDynamicCR" : false,
   "useNewFormula" : false,
   "useWeightedAverage" : false,
   "weightPercentage" : 0.35,

+ 1 - 1
Trio/Resources/json/defaults/settings/basal_profile.json

@@ -2,6 +2,6 @@
     {
         "start": "00:00:00",
         "minutes": 0,
-        "rate": 1.0
+        "rate": 0.1
     }
 ]

+ 2 - 2
Trio/Resources/json/defaults/settings/bg_targets.json

@@ -3,8 +3,8 @@
     "user_preferred_units": "mg/dL",
     "targets": [
         {
-            "low": 100,
-            "high": 100,
+            "low": 110,
+            "high": 110,
             "start": "00:00:00",
             "offset": 0
         }

+ 1 - 1
Trio/Resources/json/defaults/settings/carb_ratios.json

@@ -4,7 +4,7 @@
         {
             "start": "00:00:00",
             "offset": 0,
-            "ratio": 10
+            "ratio": 30
         }
     ]
 }

+ 1 - 1
Trio/Resources/json/defaults/settings/insulin_sensitivities.json

@@ -3,7 +3,7 @@
     "user_preferred_units": "mg/dL",
     "sensitivities": [
         {
-            "sensitivity": 54,
+            "sensitivity": 200,
             "offset": 0,
             "start": "00:00:00"
         }

+ 20 - 3
Trio/Sources/APS/APSManager.swift

@@ -49,6 +49,17 @@ enum APSError: LocalizedError {
             return String(localized: "Manual Temporary Basal Rate (\(message)). Looping suspended.")
         }
     }
+
+    static func pumpErrorMatches(message: String) -> Bool {
+        message.contains(String(localized: "Pump Error"))
+    }
+
+    static func pumpWarningMatches(message: String) -> Bool {
+        message.contains(String(localized: "Invalid Pump State")) || message
+            .contains("PumpMessage") || message
+            .contains("PumpOpsError") || message.contains("RileyLink") || message
+            .contains(String(localized: "Pump did not respond in time"))
+    }
 }
 
 final class BaseAPSManager: APSManager, Injectable {
@@ -514,7 +525,10 @@ final class BaseAPSManager: APSManager, Injectable {
             return
         }
 
-        guard let pump = pumpManager else { return }
+        guard let pump = pumpManager else {
+            callback?(false, String(localized: "Error! Failed to enact bolus.", comment: "Error message for enacting a bolus"))
+            return
+        }
 
         let roundedAmount = pump.roundToSupportedBolusVolume(units: amount)
 
@@ -542,7 +556,7 @@ final class BaseAPSManager: APSManager, Injectable {
             }
             callback?(
                 false,
-                String(localized: "Error! Failed to enact bolus.", comment: "Error message for failing to enact a bolus")
+                String(localized: "Error! Bolus failed with error: \(error.localizedDescription)")
             )
         }
     }
@@ -559,7 +573,10 @@ final class BaseAPSManager: APSManager, Injectable {
             processError(APSError.pumpError(error))
             callback?(
                 false,
-                String(localized: "Error! Bolus cancellation failed.", comment: "Error message for canceling a bolus")
+                String(
+                    localized: "Error! Bolus cancellation failed with error: \(error.localizedDescription)",
+                    comment: "Error message for canceling a bolus"
+                )
             )
         }
         bolusReporter?.removeObserver(self)

+ 9 - 1
Trio/Sources/APS/OpenAPS/OpenAPS.swift

@@ -211,9 +211,17 @@ final class OpenAPS {
     }
 
     private func loadAndMapPumpEvents(_ pumpHistoryObjectIDs: [NSManagedObjectID]) -> [PumpEventDTO] {
+        OpenAPS.loadAndMapPumpEvents(pumpHistoryObjectIDs, from: context)
+    }
+
+    /// Fetches and parses pump events, expose this as static and not private for testing
+    static func loadAndMapPumpEvents(
+        _ pumpHistoryObjectIDs: [NSManagedObjectID],
+        from context: NSManagedObjectContext
+    ) -> [PumpEventDTO] {
         // Load the pump events from the object IDs
         let pumpHistory: [PumpEventStored] = pumpHistoryObjectIDs
-            .compactMap { self.context.object(with: $0) as? PumpEventStored }
+            .compactMap { context.object(with: $0) as? PumpEventStored }
 
         // Create the DTOs
         let dtos: [PumpEventDTO] = pumpHistory.flatMap { event -> [PumpEventDTO] in

+ 2 - 3
Trio/Sources/APS/Storage/OverrideStorage.swift

@@ -394,13 +394,12 @@ final class BaseOverrideStorage: @preconcurrency OverrideStorage, Injectable {
         )
 
         return try await context.perform {
-            guard let fetchedResults = results as? [OverrideStored],
-                  let latestOverride = fetchedResults.first
+            guard let fetchedResults = results as? [OverrideStored]
             else {
                 throw CoreDataError.fetchError(function: #function, file: #file)
             }
 
-            return latestOverride.objectID
+            return fetchedResults.first?.objectID
         }
     }
 }

+ 1 - 1
Trio/Sources/APS/Storage/PumpHistoryStorage.swift

@@ -52,7 +52,7 @@ final class BasePumpHistoryStorage: PumpHistoryStorage, Injectable {
                 let existingEvents: [PumpEventStored] = try CoreDataStack.shared.fetchEntities(
                     ofType: PumpEventStored.self,
                     onContext: self.context,
-                    predicate: NSPredicate.duplicateInLastHour(event.date),
+                    predicate: NSPredicate.duplicates(event.date),
                     key: "timestamp",
                     ascending: false,
                     batchSize: 50

+ 13 - 2
Trio/Sources/Application/AppDelegate.swift

@@ -1,3 +1,5 @@
+import FirebaseCore
+import FirebaseCrashlytics
 import SwiftUI
 import UIKit
 import UserNotifications
@@ -7,8 +9,17 @@ class AppDelegate: NSObject, UIApplicationDelegate, ObservableObject, UNUserNoti
         _: UIApplication,
         didFinishLaunchingWithOptions _: [UIApplication.LaunchOptionsKey: Any]?
     ) -> Bool {
-        // application.registerForRemoteNotifications()
-        true
+        FirebaseApp.configure()
+
+        // Default to `true` if the key doesn't exist
+        let crashReportingEnabled: Bool = PropertyPersistentFlags.shared.diagnosticsSharingEnabled ?? true
+
+        // The docs say that changes to this don't take effect until
+        // the next app boot, but this is fine since the app will need
+        // to boot after a crash
+        Crashlytics.crashlytics().setCrashlyticsCollectionEnabled(crashReportingEnabled)
+
+        return true
     }
 
     func application(

+ 171 - 25
Trio/Sources/Application/TrioApp.swift

@@ -1,4 +1,3 @@
-import ActivityKit
 import BackgroundTasks
 import CoreData
 import Foundation
@@ -8,6 +7,7 @@ import Swinject
 extension Notification.Name {
     static let initializationCompleted = Notification.Name("initializationCompleted")
     static let initializationError = Notification.Name("initializationError")
+    static let onboardingCompleted = Notification.Name("onboardingCompleted")
 }
 
 @main struct TrioApp: App {
@@ -19,9 +19,13 @@ extension Notification.Name {
     @AppStorage("colorSchemePreference") private var colorSchemePreference: ColorSchemeOption = .systemDefault
 
     let coreDataStack = CoreDataStack.shared
+    let onboardingManager = OnboardingManager.shared
+
     class InitState {
-        var complete = false
-        var error = false
+        var complete: Bool = false
+        var error: Bool = false
+        var migrationErrors: [String] = []
+        var migrationFailed: Bool = false
     }
 
     // We use both InitState and @State variables to track coreDataStack
@@ -33,6 +37,8 @@ extension Notification.Name {
     @State private var appState = AppState()
     @State private var showLoadingView = true
     @State private var showLoadingError = false
+    @State private var showOnboardingCompletedSplash = false
+    @State private var showMigrationError: Bool = false
 
     // Dependencies Assembler
     // contain all dependencies Assemblies
@@ -80,6 +86,29 @@ extension Notification.Name {
     }
 
     init() {
+        let notificationCenter = Foundation.NotificationCenter.default
+        notificationCenter.addObserver(
+            forName: .initializationCompleted,
+            object: nil,
+            queue: .main
+        ) { [self] _ in
+            showLoadingView = false
+        }
+        notificationCenter.addObserver(
+            forName: .initializationError,
+            object: nil,
+            queue: .main
+        ) { [self] _ in
+            showLoadingError = true
+        }
+        notificationCenter.addObserver(
+            forName: .onboardingCompleted,
+            object: nil,
+            queue: .main
+        ) { [self] _ in
+            showOnboardingCompletedSplash = true
+        }
+
         let submodulesInfo = BuildDetails.shared.submodules.map { key, value in
             "\(key): \(value.branch) \(value.commitSHA)"
         }.joined(separator: ", ")
@@ -103,6 +132,9 @@ extension Notification.Name {
             do {
                 try await coreDataStack.initializeStack()
 
+                // TODO: possibly wrap this in a UserDefault / TinyStorage flag check, so we do not even attempt to fetch files unnecessary, but early exit the import
+                await performJsonToCoreDataMigrationIfNeeded()
+
                 await Task { @MainActor in
                     // Only load services after successful Core Data initialization
                     loadServices()
@@ -111,8 +143,14 @@ extension Notification.Name {
                     cleanupOldData()
 
                     self.initState.complete = true
+
+                    // Notifications handling
+                    // Notify of completed initialization
                     Foundation.NotificationCenter.default.post(name: .initializationCompleted, object: nil)
                     UIApplication.shared.registerForRemoteNotifications()
+                    // Cancel scheduled not looping notifications when app was completely shut down and has now re-initialized completely
+                    self.clearNotLoopingNotifications()
+
                     do {
                         try await BuildDetails.shared.handleExpireDateChange()
                     } catch {
@@ -133,6 +171,74 @@ extension Notification.Name {
         }
     }
 
+    @MainActor private func performJsonToCoreDataMigrationIfNeeded() async {
+        let importer = JSONImporter(context: coreDataStack.newTaskContext(), coreDataStack: coreDataStack)
+        var importErrors: [String] = []
+
+        do {
+            try await importer.importGlucoseHistoryIfNeeded()
+        } catch {
+            importErrors
+                .append(String(localized: "Failed to import glucose history."))
+            debug(.coreData, "❌ Failed to import JSON-based Glucose History: \(error)")
+        }
+
+        do {
+            try await importer.importPumpHistoryIfNeeded()
+        } catch {
+            importErrors.append(String(localized: "Failed to import pump history."))
+            debug(.coreData, "❌ Failed to import JSON-based Pump History: \(error)")
+        }
+
+        do {
+            try await importer.importCarbHistoryIfNeeded()
+        } catch {
+            importErrors.append(String(localized: "Failed to import algorithm data."))
+            debug(.coreData, "❌ Failed to import JSON-based Carb History: \(error)")
+        }
+
+        do {
+            try await importer.importDeterminationIfNeeded()
+        } catch {
+            importErrors
+                .append(
+                    String(localized: "Migration of JSON-based OpenAPS Determination Data failed: \(error.localizedDescription)")
+                )
+            debug(.coreData, "❌ Failed to import JSON-based OpenAPS Determination Data: \(error)")
+        }
+
+        initState.migrationErrors = importErrors
+        initState.migrationFailed = importErrors.isNotEmpty
+    }
+
+    /// Clears any legacy (Trio 0.2.x) delivered and pending notifications related to non-looping alerts.
+    /// It targets the following notifications:
+    /// - `noLoopFirstNotification`: The first notification for non-looping alerts.
+    /// - `noLoopSecondNotification`: The second notification for non-looping alerts.
+    ///
+    /// It ensures that any notifications that have already been shown to the user, as well as
+    /// any that are scheduled for the future, are removed when the system no longer needs to
+    /// alert about non-looping conditions.
+    ///
+    /// This function is typically used when the app was completely shut down and restarted,
+    /// i.e., underwent a fresh initialization and boot-up,  to avoid bogus not looping notifications
+    /// due to dangling "zombie" pending notification requests for users that update from
+    /// old Trio versions to the new generation of the app.
+    ///
+    /// Delivered notifications are cleared for completeness.
+    private func clearNotLoopingNotifications() {
+        let legacyNoLoopFirstNotification = "FreeAPS.noLoopFirstNotification"
+        let legacyNoLoopSecondNotification = "FreeAPS.noLoopSecondNotification"
+        UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: [
+            legacyNoLoopFirstNotification,
+            legacyNoLoopSecondNotification
+        ])
+        UNUserNotificationCenter.current().removePendingNotificationRequests(withIdentifiers: [
+            legacyNoLoopFirstNotification,
+            legacyNoLoopSecondNotification
+        ])
+    }
+
     /// Attempts to initialize the CoreDataStack again after a previous failure.
     ///
     /// Resets error states and triggers the initialization process from the beginning. Called in response
@@ -145,36 +251,76 @@ extension Notification.Name {
 
     var body: some Scene {
         WindowGroup {
-            if self.showLoadingView {
-                Main.LoadingView(showError: $showLoadingError, retry: retryCoreDataInitialization)
-                    .onAppear {
-                        if self.initState.complete {
+            ZStack {
+                if self.showLoadingView {
+                    Main.LoadingView(showError: $showLoadingError, retry: retryCoreDataInitialization)
+                        .onAppear {
+                            if self.initState.complete {
+                                Task { @MainActor in
+                                    try? await Task.sleep(for: .seconds(1.8))
+                                    self.showLoadingView = false
+                                    if self.initState.migrationErrors.isNotEmpty {
+                                        self.showMigrationError = true
+                                    }
+                                }
+                            }
+                            if self.initState.error {
+                                self.showLoadingError = true
+                            }
+                        }
+                        .onReceive(Foundation.NotificationCenter.default.publisher(for: .initializationCompleted)) { _ in
                             Task { @MainActor in
                                 try? await Task.sleep(for: .seconds(1.8))
                                 self.showLoadingView = false
+                                if self.initState.migrationErrors.isNotEmpty {
+                                    self.showMigrationError = true
+                                }
                             }
                         }
-                        if self.initState.error {
+                        .onReceive(Foundation.NotificationCenter.default.publisher(for: .initializationError)) { _ in
                             self.showLoadingError = true
                         }
-                    }
-                    .onReceive(Foundation.NotificationCenter.default.publisher(for: .initializationCompleted)) { _ in
+                } else if showMigrationError { // FIXME: display of this is not yet working, despite migration errors
+                    Main.MainMigrationErrorView(migrationErrors: self.initState.migrationErrors, onConfirm: {
                         Task { @MainActor in
-                            try? await Task.sleep(for: .seconds(1.8))
-                            self.showLoadingView = false
+                            showMigrationError = false
+                            initState.migrationErrors = []
                         }
+                    })
+                } else if showOnboardingCompletedSplash {
+                    LogoBurstSplash(isActive: $showOnboardingCompletedSplash) {
+                        Main.RootView(resolver: resolver)
+                            .preferredColorScheme(colorScheme(for: colorSchemePreference))
+                            .environment(
+                                \.managedObjectContext,
+                                coreDataStack.persistentContainer.viewContext
+                            )
+                            .environment(appState)
+                            .environmentObject(Icons())
+                            .onOpenURL(perform: handleURL)
                     }
-                    .onReceive(Foundation.NotificationCenter.default.publisher(for: .initializationError)) { _ in
-                        self.showLoadingError = true
-                    }
-
-            } else {
-                Main.RootView(resolver: resolver)
-                    .preferredColorScheme(colorScheme(for: colorSchemePreference) ?? nil)
-                    .environment(\.managedObjectContext, coreDataStack.persistentContainer.viewContext)
-                    .environment(appState)
-                    .environmentObject(Icons())
-                    .onOpenURL(perform: handleURL)
+                } else if onboardingManager.shouldShowOnboarding {
+                    // Show onboarding if needed
+                    Onboarding.RootView(
+                        resolver: resolver,
+                        onboardingManager: onboardingManager,
+                        wasMigrationSuccessful: !initState.migrationFailed
+                    )
+                    .preferredColorScheme(colorScheme(for: .dark) ?? nil)
+                    .transition(.opacity)
+                } else {
+                    Main.RootView(resolver: resolver)
+                        .preferredColorScheme(colorScheme(for: colorSchemePreference) ?? nil)
+                        .environment(\.managedObjectContext, coreDataStack.persistentContainer.viewContext)
+                        .environment(appState)
+                        .environmentObject(Icons())
+                        .onOpenURL(perform: handleURL)
+                }
+            }
+            .onReceive(Foundation.NotificationCenter.default.publisher(for: .onboardingCompleted)) { _ in
+                Task { @MainActor in
+                    self.showOnboardingCompletedSplash = true
+                }
             }
         }
         .onChange(of: scenePhase) { _, newScenePhase in
@@ -220,7 +366,7 @@ extension Notification.Name {
     }
 
     private func performCleanupIfNecessary() {
-        if let lastCleanupDate = UserDefaults.standard.object(forKey: "lastCleanupDate") as? Date {
+        if let lastCleanupDate = PropertyPersistentFlags.shared.lastCleanupDate {
             let sevenDaysAgo = Date().addingTimeInterval(-7 * 24 * 60 * 60)
             if lastCleanupDate < sevenDaysAgo {
                 cleanupOldData()
@@ -237,7 +383,7 @@ extension Notification.Name {
             try await purgeData
 
             // Update the last cleanup date
-            UserDefaults.standard.set(Date(), forKey: "lastCleanupDate")
+            PropertyPersistentFlags.shared.lastCleanupDate = Date()
         }
     }
 

+ 26 - 0
Trio/Sources/Helpers/PropertyPersistentFlags.swift

@@ -0,0 +1,26 @@
+//
+//  PropertyPersistentFlags.swift
+//  Trio
+//
+//  Created by Cengiz Deniz on 06.05.25.
+//
+import Foundation
+
+/// Centralized store for app-wide persistent flags backed by property list (.plist) files.
+///
+/// This class uses the `@PersistedProperty` wrapper to store simple state flags such as
+/// onboarding completion, diagnostics sharing preference, and the last cleanup timestamp.
+///
+/// All values are persisted independently in the app’s documents directory as `.plist` files,
+/// and survive app restarts and reinstallations (unless the sandbox is cleared).
+///
+/// Accessed as a singleton via `PropertyPersistentFlags.shared`.
+final class PropertyPersistentFlags {
+    static let shared = PropertyPersistentFlags()
+
+    @PersistedProperty(key: "onboardingCompleted") var onboardingCompleted: Bool?
+
+    @PersistedProperty(key: "diagnosticsSharing") var diagnosticsSharingEnabled: Bool?
+
+    @PersistedProperty(key: "lastCleanupDate") var lastCleanupDate: Date?
+}

+ 15 - 5
Trio/Sources/Helpers/CheckboxToggleStyle.swift

@@ -1,6 +1,6 @@
 import SwiftUI
 
-struct CheckboxToggleStyle: ToggleStyle {
+struct RadioButtonToggleStyle: ToggleStyle {
     func makeBody(configuration: Self.Configuration) -> some View {
         HStack {
             Circle()
@@ -22,22 +22,32 @@ struct CheckboxToggleStyle: ToggleStyle {
     }
 }
 
-struct Checkbox: ToggleStyle {
-    func makeBody(configuration: Self.Configuration) -> some View {
+struct CheckboxToggleStyle: ToggleStyle {
+    var tint = Color.primary
+
+    func makeBody(configuration: Configuration) -> some View {
         HStack {
             RoundedRectangle(cornerRadius: 5)
                 .stroke(lineWidth: 2)
-                .foregroundColor(.secondary)
+                .foregroundColor(Color.secondary)
                 .frame(width: 20, height: 20)
                 .overlay {
                     if configuration.isOn {
-                        Image(systemName: "checkmark").font(.body).fontWeight(.bold)
+                        Image(systemName: "checkmark")
+                            .font(.body)
+                            .fontWeight(.bold)
+                            .foregroundColor(tint)
                     }
                 }
                 .onTapGesture {
                     configuration.isOn.toggle()
                 }
+
             configuration.label
         }
+        .contentShape(Rectangle()) // make entire HStack tappable
+        .onTapGesture {
+            configuration.isOn.toggle()
+        }
     }
 }

文件差異過大導致無法顯示
+ 22096 - 1522
Trio/Sources/Localizations/Main/Localizable.xcstrings


+ 40 - 0
Trio/Sources/Logger/IssueReporter/SimpleLogReporter.swift

@@ -70,6 +70,46 @@ final class SimpleLogReporter: IssueReporter {
     }
 }
 
+extension SimpleLogReporter {
+    static var watchLogFile: String {
+        getDocumentsDirectory().appendingPathComponent("logs/watch_log.txt").path
+    }
+
+    static var watchLogFilePrev: String {
+        getDocumentsDirectory().appendingPathComponent("logs/watch_log_prev.txt").path
+    }
+
+    static func appendToWatchLog(_ logContent: String) {
+        let fileManager = FileManager.default
+        let logDir = getDocumentsDirectory().appendingPathComponent("logs")
+        let logFile = URL(fileURLWithPath: watchLogFile)
+        let prevLogFile = URL(fileURLWithPath: watchLogFilePrev)
+
+        let now = Date()
+        let startOfDay = Calendar.current.startOfDay(for: now)
+
+        // Create logs directory if needed
+        if !fileManager.fileExists(atPath: logDir.path) {
+            try? fileManager.createDirectory(at: logDir, withIntermediateDirectories: true)
+        }
+
+        // Rotate if needed
+        if fileManager.fileExists(atPath: logFile.path),
+           let attributes = try? fileManager.attributesOfItem(atPath: logFile.path),
+           let creationDate = attributes[.creationDate] as? Date,
+           creationDate < startOfDay
+        {
+            try? fileManager.removeItem(at: prevLogFile)
+            try? fileManager.moveItem(at: logFile, to: prevLogFile)
+            fileManager.createFile(atPath: logFile.path, contents: nil, attributes: [.creationDate: startOfDay])
+        }
+
+        if let data = (logContent + "\n").data(using: .utf8) {
+            try? data.append(fileURL: logFile)
+        }
+    }
+}
+
 private extension Data {
     func append(fileURL: URL) throws {
         if let fileHandle = FileHandle(forWritingAtPath: fileURL.path) {

+ 10 - 6
Trio/Sources/Models/BloodGlucose.swift

@@ -79,11 +79,13 @@ struct BloodGlucose: JSON, Identifiable, Hashable, Codable {
         let container = try decoder.container(keyedBy: CodingKeys.self)
         _id = try container.decode(String.self, forKey: ._id)
 
-        do {
-            sgv = try container.decode(Int.self, forKey: .sgv)
-        } catch {
-            // The nightscout API returns a double instead of an int
-            sgv = Int(try container.decode(Double.self, forKey: .sgv))
+        sgv = try? container.decodeIfPresent(Int.self, forKey: .sgv)
+        if sgv == nil {
+            // The nightscout API might return a double instead of an int, or the key might be missing
+            if let doubleValue = try? container.decodeIfPresent(Double.self, forKey: .sgv) {
+                sgv = Int(doubleValue)
+            }
+            // If both attempts fail, sgv remains nil
         }
 
         direction = try container.decodeIfPresent(Direction.self, forKey: .direction)
@@ -157,11 +159,13 @@ struct BloodGlucose: JSON, Identifiable, Hashable, Codable {
     }
 }
 
-enum GlucoseUnits: String, JSON, Equatable {
+enum GlucoseUnits: String, JSON, Equatable, CaseIterable, Identifiable {
     case mgdL = "mg/dL"
     case mmolL = "mmol/L"
 
     static let exchangeRate: Decimal = 0.0555
+
+    var id: String { rawValue }
 }
 
 extension Int {

+ 8 - 1
Trio/Sources/Models/DecimalPickerSettings.swift

@@ -137,7 +137,13 @@ struct DecimalPickerSettings {
     var hours = PickerSetting(value: 6, step: 0.5, min: 2, max: 24, type: PickerSetting.PickerSettingType.hour)
     var dia = PickerSetting(value: 10, step: 0.5, min: 5, max: 10, type: PickerSetting.PickerSettingType.hour)
     var maxBolus = PickerSetting(value: 10, step: 0.5, min: 0.5, max: 30, type: PickerSetting.PickerSettingType.insulinUnit)
-    var maxBasal = PickerSetting(value: 10, step: 0.5, min: 0.5, max: 30, type: PickerSetting.PickerSettingType.insulinUnit)
+    var maxBasal = PickerSetting(
+        value: 10,
+        step: 0.5,
+        min: 0.5,
+        max: 30,
+        type: PickerSetting.PickerSettingType.insulinUnitPerHour
+    )
 }
 
 struct PickerSetting {
@@ -152,6 +158,7 @@ struct PickerSetting {
         case factor
         case gram
         case insulinUnit
+        case insulinUnitPerHour
         case minute
         case hour
     }

+ 0 - 32
Trio/Sources/Models/Glucose.swift

@@ -1,32 +0,0 @@
-import Foundation
-
-struct Glucose: JSON {
-    let sgv: Int?
-    let glucose: Int?
-    let type: GlucoseType
-    let noise: Int?
-    let date: Date
-    let filtered: Double?
-    let direction: Direction?
-}
-
-enum GlucoseType: String, JSON {
-    case sgv
-    case cal
-    case manual = "Manual"
-}
-
-enum Direction: String, JSON {
-    case tripleUp = "TripleUp"
-    case doubleUp = "DoubleUp"
-    case singleUp = "SingleUp"
-    case fortyFiveUp = "FortyFiveUp"
-    case flat = "Flat"
-    case fortyFiveDown = "FortyFiveDown"
-    case singleDown = "SingleDown"
-    case doubleDown = "DoubleDown"
-    case tripleDown = "TripleDown"
-    case none = "NONE"
-    case notComputable = "NOT COMPUTABLE"
-    case rateOutOfRange = "RATE OUT OF RANGE"
-}

+ 0 - 7
Trio/Sources/Models/Preferences.swift

@@ -45,7 +45,6 @@ struct Preferences: JSON, Equatable {
     var adjustmentFactor: Decimal = 0.8
     var adjustmentFactorSigmoid: Decimal = 0.5
     var sigmoid: Bool = false
-    var enableDynamicCR: Bool = false
     var useNewFormula: Bool = false
     var useWeightedAverage: Bool = false
     var weightPercentage: Decimal = 0.35
@@ -101,7 +100,6 @@ extension Preferences {
         case adjustmentFactor
         case adjustmentFactorSigmoid
         case sigmoid
-        case enableDynamicCR
         case useNewFormula
         case useWeightedAverage
         case weightPercentage
@@ -298,11 +296,6 @@ extension Preferences: Decodable {
             preferences.sigmoid = sigmoid
         }
 
-        // FIXME: remove this at a later release; hard code it to false for now
-        if let enableDynamicCR = try? container.decode(Bool.self, forKey: .enableDynamicCR) {
-            preferences.enableDynamicCR = false
-        }
-
         if let useNewFormula = try? container.decode(Bool.self, forKey: .useNewFormula) {
             preferences.useNewFormula = useNewFormula
         }

+ 5 - 1
Trio/Sources/Models/PumpHistoryEvent.swift

@@ -16,6 +16,7 @@ struct PumpHistoryEvent: JSON, Equatable, Identifiable {
     let note: String?
     let isSMB: Bool?
     let isExternal: Bool?
+    let isExternalInsulin: Bool?
 
     init(
         id: String,
@@ -31,7 +32,8 @@ struct PumpHistoryEvent: JSON, Equatable, Identifiable {
         proteinInput: Int? = nil,
         note: String? = nil,
         isSMB: Bool? = nil,
-        isExternal: Bool? = nil
+        isExternal: Bool? = nil,
+        isExternalInsulin: Bool? = nil
     ) {
         self.id = id
         self.type = type
@@ -47,6 +49,7 @@ struct PumpHistoryEvent: JSON, Equatable, Identifiable {
         self.note = note
         self.isSMB = isSMB
         self.isExternal = isExternal
+        self.isExternalInsulin = isExternalInsulin
     }
 }
 
@@ -101,6 +104,7 @@ extension PumpHistoryEvent {
         case note
         case isSMB
         case isExternal
+        case isExternalInsulin
     }
 }
 

+ 2 - 0
Trio/Sources/Models/WatchMessageKeys.swift

@@ -1,9 +1,11 @@
 enum WatchMessageKeys {
     // Request/Response Keys
     static let date = "date"
+    static let units = "units"
     static let requestWatchUpdate = "requestWatchUpdate"
     static let watchState = "watchState"
     static let acknowledged = "acknowledged"
+    static let ackCode = "ackCode"
     static let message = "message"
 
     // Treatment Keys

+ 1 - 1
Trio/Sources/Models/WatchState.swift

@@ -1,7 +1,7 @@
 import Foundation
 import SwiftUI
 
-struct WatchState: Hashable, Equatable, Sendable, Encodable {
+struct WatchState: Hashable, Equatable, Sendable, Encodable, Decodable {
     var date: Date
     var currentGlucose: String?
     var currentGlucoseColorString: String?

+ 39 - 0
Trio/Sources/Models/WatchStateSnapshot.swift

@@ -0,0 +1,39 @@
+//
+//  WatchStateSnapshot.swift
+//  Trio
+//
+//  Created by Cengiz Deniz on 18.04.25.
+//
+import Foundation
+
+struct WatchStateSnapshot {
+    let date: Date
+    let payload: [String: Any]
+
+    init?(from dictionary: [String: Any]) {
+        guard let timestamp = dictionary[WatchMessageKeys.date] as? TimeInterval,
+              let payload = dictionary[WatchMessageKeys.watchState] as? [String: Any]
+        else {
+            return nil
+        }
+
+        date = Date(timeIntervalSince1970: timestamp)
+        self.payload = payload
+    }
+
+    func toDictionary() -> [String: Any] {
+        [
+            WatchMessageKeys.date: date.timeIntervalSince1970,
+            WatchMessageKeys.watchState: payload
+        ]
+    }
+
+    static func saveLatestDateToDisk(_ date: Date) {
+        UserDefaults.standard.set(date.timeIntervalSince1970, forKey: "WatchStateSnapshot.latest")
+    }
+
+    static func loadLatestDateFromDisk() -> Date {
+        let interval = UserDefaults.standard.double(forKey: "WatchStateSnapshot.latest")
+        return Date(timeIntervalSince1970: interval)
+    }
+}

+ 5 - 0
Trio/Sources/Modules/AppDiagnostics/AppDiagnosticsDataFlow.swift

@@ -0,0 +1,5 @@
+enum AppDiagnostics {
+    enum Config {}
+}
+
+protocol AppDiagnosticsProvider {}

+ 3 - 0
Trio/Sources/Modules/AppDiagnostics/AppDiagnosticsProvider.swift

@@ -0,0 +1,3 @@
+extension AppDiagnostics {
+    final class Provider: BaseProvider, AppDiagnosticsProvider {}
+}

+ 35 - 0
Trio/Sources/Modules/AppDiagnostics/AppDiagnosticsStateModel.swift

@@ -0,0 +1,35 @@
+import FirebaseCrashlytics
+import Observation
+import SwiftUI
+
+extension AppDiagnostics {
+    @Observable final class StateModel: BaseStateModel<Provider> {
+        // MARK: - Diagnostics Sharing Option
+
+        var diagnosticsSharingOption: DiagnosticsSharingOption = .enabled
+
+        override func subscribe() {
+            loadDiagnostics()
+        }
+
+        /// Loads the diagnostics sharing option from UserDefaults as a boolean.
+        func loadDiagnostics() {
+            if let storedDiagnosticsSharingOption = PropertyPersistentFlags.shared.diagnosticsSharingEnabled {
+                diagnosticsSharingOption = storedDiagnosticsSharingOption ? .enabled : .disabled
+            } else {
+                diagnosticsSharingOption = .enabled
+            }
+        }
+
+        /// Persists the current diagnostics sharing option to UserDefaults as a boolean.
+        func applyDiagnostics() {
+            let booleanValue: Bool = diagnosticsSharingOption == .enabled
+            PropertyPersistentFlags.shared.diagnosticsSharingEnabled = booleanValue
+            Crashlytics.crashlytics().setCrashlyticsCollectionEnabled(booleanValue)
+        }
+    }
+}
+
+extension AppDiagnostics.StateModel: SettingsObserver {
+    func settingsDidChange(_: TrioSettings) {}
+}

+ 102 - 0
Trio/Sources/Modules/AppDiagnostics/View/AppDiagnosticsRootView.swift

@@ -0,0 +1,102 @@
+import SwiftUI
+import Swinject
+
+extension AppDiagnostics {
+    struct RootView: BaseView {
+        let resolver: Resolver
+
+        @State var state = StateModel()
+
+        @Environment(\.colorScheme) var colorScheme
+        @Environment(AppState.self) var appState
+
+        @State private var shouldDisplayPrivacyPolicy: Bool = false
+
+        var body: some View {
+            List {
+                Section(
+                    header: Text("Anonymized Data Sharing"),
+                    content: {
+                        VStack(alignment: .leading) {
+                            ForEach(DiagnosticsSharingOption.allCases, id: \.self) { option in
+                                Button(action: {
+                                    state.diagnosticsSharingOption = option
+                                }) {
+                                    HStack {
+                                        Image(
+                                            systemName: state
+                                                .diagnosticsSharingOption == option ? "largecircle.fill.circle" : "circle"
+                                        )
+                                        .foregroundColor(state.diagnosticsSharingOption == option ? .accentColor : .secondary)
+                                        .imageScale(.large)
+
+                                        Text(option.displayName)
+                                            .foregroundColor(.primary)
+
+                                        Spacer()
+                                    }
+                                    .background(Color.chart.opacity(0.65))
+                                    .cornerRadius(10)
+                                }
+                                .buttonStyle(.plain)
+                            }
+                            .padding()
+                        }
+                        .onChange(of: state.diagnosticsSharingOption) {
+                            state.applyDiagnostics()
+                        }
+                    }
+                ).listRowBackground(Color.chart)
+
+                Section {
+                    VStack(alignment: .leading, spacing: 8) {
+                        Text("Why does Trio collect this data?").bold()
+                        VStack(alignment: .leading, spacing: 4) {
+                            BulletPoint(
+                                String(
+                                    localized: "App diagnostic insights help us enhance app stability, ensure safety for all users, and enable us to quickly identify and resolve critical issues."
+                                )
+                            )
+                            BulletPoint(
+                                String(
+                                    localized: "Trio collects the app's state on crash, device, iOS and general system info, and a stack trace."
+                                )
+                            )
+                            BulletPoint(
+                                String(
+                                    localized: "Trio does not collect any health related data, e.g. glucose readings, insulin rates or doses, meal data, setting values, or similar."
+                                )
+                            )
+                            BulletPoint(
+                                String(
+                                    localized: "Trio does not track any usage metrics or any other personal data about users other than the used iPhone model and iOS version."
+                                )
+                            )
+                        }
+                        Text(
+                            "Diagnostics are sent to a Google Firebase Crashlytics project, which is securely maintained and accessed only by the Trio team."
+                        )
+                    }
+                    .font(.footnote)
+                    .multilineTextAlignment(.leading)
+                    .foregroundStyle(Color.secondary)
+                }.listRowBackground(Color.clear)
+            }
+            .listSectionSpacing(sectionSpacing)
+            .scrollContentBackground(.hidden).background(appState.trioBackgroundColor(for: colorScheme))
+            .onAppear(perform: configureView)
+            .navigationBarTitle("App Diagnostics")
+            .navigationBarTitleDisplayMode(.automatic)
+            .toolbar {
+                ToolbarItem(placement: .topBarTrailing) {
+                    Button("Privacy Policy") {
+                        shouldDisplayPrivacyPolicy = true
+                    }
+                }
+            }
+            .sheet(isPresented: $shouldDisplayPrivacyPolicy) {
+                PrivacyPolicyView()
+            }
+        }
+    }
+}

+ 233 - 0
Trio/Sources/Modules/AppDiagnostics/View/PrivacyPolicyView.swift

@@ -0,0 +1,233 @@
+//
+//  PrivacyPolicyView.swift
+//  Trio
+//
+//  Created by Cengiz Deniz on 17.04.25.
+//
+import SwiftUI
+
+struct PrivacyPolicyView: View {
+    @Environment(\.openURL) var openURL
+    @Environment(\.dismiss) var dismiss
+
+    var body: some View {
+        NavigationStack {
+            List {
+                VStack(alignment: .leading, spacing: 20) {
+                    Text("Introduction").font(.headline).bold().foregroundStyle(Color.primary)
+                    Text(
+                        "This Privacy Policy explains how we collect, use, and share information when you use Trio. We respect your privacy and are committed to protecting your personal data. Please read this Privacy Policy carefully to understand our practices regarding your personal data."
+                    )
+
+                    Divider()
+
+                    Text("Information We Collect").font(.headline).bold().foregroundStyle(Color.primary)
+                    Text("What We Do NOT Collect").foregroundStyle(Color.primary)
+
+                    Text("For complete transparency, we want to clarify that Trio does not collect:")
+
+                    VStack(alignment: .leading, spacing: 10) {
+                        BulletPoint(String(localized: "Blood glucose (BG) readings"))
+                        BulletPoint(String(localized: "Treatment data"))
+                        BulletPoint(String(localized: "Total daily doses (TDD)"))
+                        BulletPoint(String(localized: "Any health-related statistics or personal medical information"))
+                        BulletPoint(String(localized: "Personal identifiable information such as name, address, or email"))
+                    }
+
+                    Text("Crash Reporting (Opt-In by default, with ability to Opt-Out)").foregroundStyle(Color.primary)
+                    Text(
+                        "Trio uses Google Firebase Crashlytics to collect crash reports. During the initial app setup (onboarding process), you will be asked to opt in to crash reporting. The onboarding process is the series of screens you see when first launching Trio that helps you set up the app."
+                    )
+
+                    Text("The following information may be sent to Crashlytics when Trio crashes:")
+
+                    VStack(alignment: .leading, spacing: 10) {
+                        BulletPoint(
+                            String(
+                                localized: "Time and date of the crash (example: \"Trio crashed on April 6, 2025 at 2:15 PM\")"
+                            )
+                        )
+                        BulletPoint(
+                            String(
+                                localized: "Device state at the time of the crash (example: \"Trio was in the foreground\" or \"Battery level was 42%\")"
+                            )
+                        )
+                        BulletPoint(
+                            String(localized: "Stack trace information (technical information showing which line of code failed)")
+                        )
+                        BulletPoint(
+                            String(localized: "Device model and OS version (example: \"iPhone 14 Pro running iOS 17.4.1\")")
+                        )
+                        BulletPoint(
+                            String(
+                                localized: "A generated unique identifier (a random code like \"A7B2C9D3\" that doesn't identify you personally)"
+                            )
+                        )
+                    }
+
+                    Text("Debug Symbols (dSYMs)").foregroundStyle(Color.primary)
+
+                    Text(
+                        "When we build the Trio app, we create special files called debug symbols (dSYMs) that help us read crash reports. Think of these like a decoder ring for crashes:"
+                    )
+
+                    Text(
+                        "Without dSYMs, a crash might look like: \"Error at memory address 0x1234ABCD\". With dSYMs, we can see: \"Error in function 'calculateInsulin' at line 157\""
+                    )
+
+                    Text(
+                        "These files only contain code-related information that helps us understand where crashes happen. They contain no personal information about you or how you use Trio."
+                    )
+
+                    Divider()
+
+                    Text("How We Use Your Information").font(.headline).bold().foregroundStyle(Color.primary)
+
+                    Text("We use anonymous crash report information exclusively to:")
+
+                    VStack(alignment: .leading, spacing: 10) {
+                        BulletPoint(String(localized: "Identify and fix bugs and crashes"))
+                        BulletPoint(String(localized: "Improve Trio's stability"))
+                    }
+
+                    Text("We do not use this information for any other purpose, such as analytics, marketing, or user profiling.")
+
+                    Divider()
+
+                    Text("Data Sharing and Third-Party Services").font(.headline).bold().foregroundStyle(Color.primary)
+
+                    Text("Crashlytics").foregroundStyle(Color.primary)
+
+                    VStack(alignment: .leading, spacing: 10) {
+                        Text(
+                            "We use Google Firebase Crashlytics to collect and analyze crash reports. Crashlytics' privacy practices are governed by the Google Privacy Policy. For more information about how Crashlytics processes data, please visit their documentation."
+                        )
+
+                        Button {
+                            openURL(URL(string: "https://policies.google.com/privacy")!)
+                        } label: {
+                            Text("Google Privacy Policy")
+                                .padding(.horizontal, 12)
+                                .padding(.vertical, 8)
+                                .background(Color.blue.opacity(0.2))
+                                .cornerRadius(8)
+                        }
+                        .frame(maxWidth: .infinity, alignment: .center)
+                        .padding(.horizontal)
+                    }
+
+                    Text("Open Source Contributors").foregroundStyle(Color.primary)
+
+                    Text(
+                        "As an open source project, crash reports and debugging information may be visible to project contributors who help maintain and improve Trio. All contributors are expected to adhere to this privacy policy and handle any data responsibly."
+                    )
+
+                    Divider()
+
+                    Text("Opting Out and Data Retention")
+
+                    Text("You can opt out of crash reporting at any time through the Trio settings. If you opt out:")
+
+                    VStack(alignment: .leading, spacing: 10) {
+                        BulletPoint(String(localized: "No new crash data will be collected or sent to us"))
+                        BulletPoint(
+                            String(localized: "Previously collected crash data will still be retained for approximately 90 days")
+                        )
+                    }
+
+                    Text(
+                        "To avoid sending dSYMs to Crashlytics, you can delete the Trio target Build Phase script, titled \"Copy dSYMs to Crashlytics\"."
+                    )
+
+                    Divider()
+
+                    Text("Your Rights").font(.headline).bold().foregroundStyle(Color.primary)
+
+                    Text("You have certain rights regarding your information, including:")
+
+                    VStack(alignment: .leading, spacing: 10) {
+                        BulletPoint(String(localized: "The right to opt-out of crash reporting"))
+                        BulletPoint(String(localized: "The right to request deletion of your data"))
+                    }
+
+                    Text(
+                        "To opt-out of crash reporting, please see the section above for details about how to configure Trio to not record crash reports."
+                    )
+
+                    Text(
+                        "The information we store is anonymous, so we are unable to look up information for a particular individual. However, our general data retention policy ensures that data older than 90 days is deleted, enabling us to accommodate data deletion requests by design despite having anonymous data."
+                    )
+
+                    Divider()
+
+                    Text("Changes to This Privacy Policy").font(.headline).bold().foregroundStyle(Color.primary)
+
+                    Text(
+                        "We may update this Privacy Policy from time to time. We will notify you of any changes by posting the new Privacy Policy on this page and updating the \"Last Updated\" date."
+                    )
+
+                    Divider()
+
+                    Text("Contact Us").font(.headline).bold().foregroundStyle(Color.primary)
+
+                    VStack(alignment: .leading, spacing: 10) {
+                        Text(
+                            "If you have any questions about this Privacy Policy, please contact us on Discord, or send us an email."
+                        ).multilineTextAlignment(.leading)
+
+                        HStack(alignment: .center, spacing: 10) {
+                            Button {
+                                openURL(URL(string: "http://discord.diy-trio.org/")!)
+                            } label: {
+                                Text("Trio Discord")
+                                    .padding(.horizontal, 12)
+                                    .padding(.vertical, 8)
+                                    .background(Color.blue.opacity(0.2))
+                                    .cornerRadius(8)
+                            }
+                            .frame(maxWidth: .infinity, alignment: .center)
+                            .padding(.horizontal)
+
+                            Button {
+                                openURL(URL(string: "mailto:trio.diy.diabetes@gmail.com")!)
+                            } label: {
+                                Text("Email us")
+                                    .padding(.horizontal, 12)
+                                    .padding(.vertical, 8)
+                                    .background(Color.blue.opacity(0.2))
+                                    .cornerRadius(8)
+                            }
+                            .frame(maxWidth: .infinity, alignment: .center)
+                            .padding(.horizontal)
+                        }
+                    }
+
+                    Divider()
+
+                    HStack {
+                        Text("Last Updated:").bold()
+                        Text("April 15, 2025")
+                    }
+                    .font(.headline).foregroundStyle(Color.primary)
+                }
+                .font(.footnote)
+                .foregroundStyle(Color.secondary)
+                .listRowBackground(Color.clear)
+                .fixedSize(horizontal: false, vertical: true)
+                .multilineTextAlignment(.leading)
+            }
+            .scrollContentBackground(.hidden)
+            .navigationBarTitle("Privacy Policy", displayMode: .inline)
+
+            Spacer()
+
+            Button {
+                dismiss()
+            } label: {
+                Text("Got it!").bold().frame(maxWidth: .infinity, minHeight: 30, alignment: .center)
+            }
+            .buttonStyle(.bordered)
+            .padding([.top, .horizontal])
+        }.ignoresSafeArea(edges: .top)
+    }
+}

+ 1 - 1
Trio/Sources/Modules/AutosensSettings/AutosensSettingsStateModel.swift

@@ -11,7 +11,7 @@ extension AutosensSettings {
         var units: GlucoseUnits = .mgdL
 
         private(set) var autosensISF: Decimal?
-        private(set) var autosensRatio: Decimal = 0
+        private(set) var autosensRatio: Decimal = 1
         @Published var determinationsFromPersistence: [OrefDetermination] = []
 
         let viewContext = CoreDataStack.shared.persistentContainer.viewContext

+ 19 - 22
Trio/Sources/Modules/AutosensSettings/View/AutosensSettingsRootView.swift

@@ -39,12 +39,12 @@ extension AutosensSettings {
                 VStack(alignment: .leading, spacing: 5) {
                     Text("What it Adjusts").bold()
                     Text(
-                        "Autosens modifies Insulin Sensitivity Factor (ISF), basal rates, and target blood sugar levels. It doesn’t account for carbs but adjusts for insulin effectiveness based on patterns in your glucose data."
+                        "Autosens modifies Insulin Sensitivity Factor (ISF), basal rates, and target glucose. It doesn’t account for carbs but adjusts for insulin effectiveness based on patterns in your glucose data."
                     )
                 }
 
                 VStack(alignment: .leading, spacing: 5) {
-                    Text("Key Limitations").bold()
+                    Text("Safety").bold()
                     Text(
                         "Autosens has safety limits determined by your Autosens Max and Autosens Min settings. These settings prevent over-adjusting."
                     )
@@ -65,18 +65,15 @@ extension AutosensSettings {
                     let dynamicRatio = state.determinationsFromPersistence.first?.sensitivityRatio
                     let dynamicISF = state.determinationsFromPersistence.first?.insulinSensitivity
                     let newISF = state.autosensISF
+                    let decimalValue = !state.settingsManager.preferences.useNewFormula ? state
+                        .autosensRatio as NSDecimalNumber : dynamicRatio ?? 1
+                    let decimalValueText = rateFormatter
+                        .string(from: ((decimalValue as Decimal) * Decimal(100)) as NSNumber) ?? "100"
+
                     HStack {
                         Text("Sensitivity Ratio")
                         Spacer()
-                        Text(
-                            rateFormatter
-                                .string(from: (
-                                    (
-                                        !state.settingsManager.preferences.useNewFormula ? state
-                                            .autosensRatio as NSDecimalNumber : dynamicRatio
-                                    ) ?? 1
-                                ) as NSNumber) ?? "1"
-                        )
+                        Text("\(decimalValueText) \(String(localized: "%", comment: "Percentage symbol"))")
                     }.padding(.vertical)
                     HStack {
                         Text("Calculated Sensitivity")
@@ -140,18 +137,18 @@ extension AutosensSettings {
                     units: state.units,
                     type: .decimal("autosensMax"),
                     label: String(localized: "Autosens Max", comment: "Autosens Max"),
-                    miniHint: String(localized: "Upper limit of the Autosens Ratio."),
+                    miniHint: String(localized: "Upper limit of the Sensitivity Ratio."),
                     verboseHint:
                     VStack(alignment: .leading, spacing: 10) {
                         Text("Default: 120%").bold()
                         Text(
-                            "Autosens Max sets the maximum Autosens Ratio used by Autosens, Dynamic ISF, and Sigmoid Formula."
+                            "Autosens Max sets the maximum Sensitivity Ratio used by Autosens, Dynamic ISF, and Sigmoid Formula."
                         )
                         Text(
-                            "The Autosens Ratio is used to calculate the amount of adjustment needed to basal rates, ISF, and CR."
+                            "The Sensitivity Ratio is used to calculate the amount of adjustment needed to basal rates and ISF."
                         )
                         Text(
-                            "Tip: Increasing this value allows automatic adjustments of basal rates to be higher, ISF to be lower, and CR to be lower."
+                            "Tip: Increasing this value allows automatic adjustments of basal rates to be higher and ISF to be lower."
                         )
                     },
                     headerText: String(localized: "Glucose Deviations Algorithm")
@@ -171,18 +168,18 @@ extension AutosensSettings {
                     units: state.units,
                     type: .decimal("autosensMin"),
                     label: String(localized: "Autosens Min", comment: "Autosens Min"),
-                    miniHint: String(localized: "Lower limit of the Autosens Ratio."),
+                    miniHint: String(localized: "Lower limit of the Sensitivity Ratio."),
                     verboseHint:
                     VStack(alignment: .leading, spacing: 10) {
                         Text("Default: 70%").bold()
                         Text(
-                            "Autosens Min sets the minimum Autosens Ratio used by Autosens, Dynamic ISF, and Sigmoid Formula."
+                            "Autosens Min sets the minimum Sensitivity Ratio used by Autosens, Dynamic ISF, and Sigmoid Formula."
                         )
                         Text(
-                            "The Autosens Ratio is used to calculate the amount of adjustment needed to basal rates, ISF, and CR."
+                            "The Sensitivity Ratio is used to calculate the amount of adjustment needed to basal rates and ISF."
                         )
                         Text(
-                            "Tip: Decreasing this value allows automatic adjustments of basal rates to be lower, ISF to be higher, and CR to be higher."
+                            "Tip: Decreasing this value allows automatic adjustments of basal rates to be lower and ISF to be higher."
                         )
                     }
                 )
@@ -201,13 +198,13 @@ extension AutosensSettings {
                     units: state.units,
                     type: .boolean,
                     label: String(localized: "Rewind Resets Autosens", comment: "Rewind Resets Autosens"),
-                    miniHint: String(localized: "Pump rewind initiates a reset in Autosens Ratio."),
+                    miniHint: String(localized: "Pump rewind initiates a reset in Sensitivity Ratio."),
                     verboseHint: VStack(alignment: .leading, spacing: 5) {
                         Text("Default: ON").bold()
-                        Text("Medtronic Users Only").bold()
+                        Text("Medtronic and Dana Users Only").bold()
                         VStack(alignment: .leading, spacing: 10) {
                             Text(
-                                "This feature resets the Autosens Ratio to neutral when you rewind your pump on the assumption that this corresponds to a site change."
+                                "This feature resets the Sensitivity Ratio to neutral when you rewind your pump on the assumption that this corresponds to a site change."
                             )
                             Text(
                                 "Autosens will begin learning sensitivity anew from the time of the rewind, which may take up to 6 hours."

+ 17 - 10
Trio/Sources/Modules/BasalProfileEditor/View/BasalProfileEditorRootView.swift

@@ -10,6 +10,7 @@ extension BasalProfileEditor {
 
         let chartScale = Calendar.current
             .date(from: DateComponents(year: 2001, month: 01, day: 01, hour: 0, minute: 0, second: 0))
+        let tzOffset = TimeZone.current.secondsFromGMT()
 
         @Environment(\.colorScheme) var colorScheme
         @Environment(AppState.self) var appState
@@ -28,30 +29,35 @@ extension BasalProfileEditor {
             return formatter
         }
 
+        var now = Date()
         var basalScheduleChart: some View {
             Chart {
                 ForEach(state.chartData!, id: \.self) { profile in
+                    let startDate = Calendar.current.startOfDay(for: now)
+                        .addingTimeInterval(profile.startDate.timeIntervalSinceReferenceDate + Double(tzOffset))
+                    let endDate = Calendar.current.startOfDay(for: now)
+                        .addingTimeInterval(profile.endDate!.timeIntervalSinceReferenceDate + Double(tzOffset))
                     RectangleMark(
-                        xStart: .value("start", profile.startDate),
-                        xEnd: .value("end", profile.endDate!),
+                        xStart: .value("start", startDate),
+                        xEnd: .value("end", endDate),
                         yStart: .value("rate-start", profile.amount),
                         yEnd: .value("rate-end", 0)
                     ).foregroundStyle(
                         .linearGradient(
                             colors: [
-                                Color.insulin.opacity(0.6),
-                                Color.insulin.opacity(0.1)
+                                Color.purple.opacity(0.6),
+                                Color.purple.opacity(0.1)
                             ],
                             startPoint: .bottom,
                             endPoint: .top
                         )
                     ).alignsMarkStylesWithPlotArea()
 
-                    LineMark(x: .value("End Date", profile.endDate!), y: .value("Amount", profile.amount))
-                        .lineStyle(.init(lineWidth: 1)).foregroundStyle(Color.insulin)
+                    LineMark(x: .value("End Date", endDate), y: .value("Amount", profile.amount))
+                        .lineStyle(.init(lineWidth: 1)).foregroundStyle(Color.purple)
 
-                    LineMark(x: .value("Start Date", profile.startDate), y: .value("Amount", profile.amount))
-                        .lineStyle(.init(lineWidth: 1)).foregroundStyle(Color.insulin)
+                    LineMark(x: .value("Start Date", startDate), y: .value("Amount", profile.amount))
+                        .lineStyle(.init(lineWidth: 1)).foregroundStyle(Color.purple)
                 }
             }
             .chartXAxis {
@@ -67,7 +73,8 @@ extension BasalProfileEditor {
                 }
             }
             .chartXScale(
-                domain: Calendar.current.startOfDay(for: chartScale!) ... Calendar.current.startOfDay(for: chartScale!)
+                domain: Calendar.current.startOfDay(for: now) ... Calendar
+                    .current.startOfDay(for: now)
                     .addingTimeInterval(60 * 60 * 24)
             )
         }
@@ -169,7 +176,7 @@ extension BasalProfileEditor {
                 state.calculateChartData()
             }
             .scrollContentBackground(.hidden).background(appState.trioBackgroundColor(for: colorScheme))
-            .navigationTitle("Basal Profile")
+            .navigationTitle("Basal Rates")
             .navigationBarTitleDisplayMode(.automatic)
             .toolbar(content: {
                 ToolbarItem(placement: .topBarTrailing) {

+ 1 - 1
Trio/Sources/Modules/BolusCalculatorConfig/View/BolusCalculatorConfigRootView.swift

@@ -179,7 +179,7 @@ extension BolusCalculatorConfig {
                             "Also triggered when the lowest forecasted glucose (minPredBG) is < \(state.units == .mgdL ? 54.description : 54.formattedAsMmolL) \(state.units.rawValue)."
                         )
                         Text(
-                            "Note: The forecast used for this warning does not include carbs or insulin that have not yet been logged."
+                            "Note: The forecast used for this warning does not include carbs or insulin that have been logged but not yet effective."
                         )
                     }
                 )

+ 1 - 0
Trio/Sources/Modules/CGMSettings/CGMSettingsStateModel.swift

@@ -42,6 +42,7 @@ extension CGMSettings {
         @Injected() var pluginCGMManager: PluginManager!
         @Injected() var broadcaster: Broadcaster!
         @Injected() var nightscoutManager: NightscoutManager!
+        @Injected() var bluetoothManager: BluetoothStateManager!
 
         @Published var units: GlucoseUnits = .mgdL
         @Published var shouldDisplayCGMSetupSheet: Bool = false

+ 46 - 38
Trio/Sources/Modules/CGMSettings/View/CGMRootView.swift

@@ -6,8 +6,8 @@ extension CGMSettings {
     struct RootView: BaseView {
         let resolver: Resolver
         let displayClose: Bool
+        let bluetoothManager: BluetoothStateManager
         @StateObject var state = StateModel()
-
         @State private var shouldDisplayHint: Bool = false
         @State var hintDetent = PresentationDetent.large
         @State var selectedVerboseHint: AnyView?
@@ -35,48 +35,56 @@ extension CGMSettings {
                     Section(
                         header: Text("CGM Integration to Trio"),
                         content: {
-                            let cgmState = state.cgmCurrent
-                            if cgmState.type != .none {
-                                Button {
-                                    state.shouldDisplayCGMSetupSheet = true
-                                } label: {
-                                    HStack {
-                                        Image(systemName: "sensor.tag.radiowaves.forward.fill")
-                                        Text(cgmState.displayName)
-                                    }
-                                    .frame(maxWidth: .infinity, minHeight: 50, alignment: .center)
-                                    .font(.title2)
-                                }.padding()
+                            if bluetoothManager.bluetoothAuthorization != .authorized {
+                                HStack {
+                                    Spacer()
+                                    BluetoothRequiredView()
+                                    Spacer()
+                                }
                             } else {
-                                VStack {
+                                let cgmState = state.cgmCurrent
+                                if cgmState.type != .none {
                                     Button {
-                                        showCGMSelection.toggle()
+                                        state.shouldDisplayCGMSetupSheet = true
                                     } label: {
-                                        Text("Add CGM")
-                                            .font(.title3) }
-                                        .frame(maxWidth: .infinity, alignment: .center)
-                                        .buttonStyle(.bordered)
+                                        HStack {
+                                            Image(systemName: "sensor.tag.radiowaves.forward.fill")
+                                            Text(cgmState.displayName)
+                                        }
+                                        .frame(maxWidth: .infinity, minHeight: 50, alignment: .center)
+                                        .font(.title2)
+                                    }.padding()
+                                } else {
+                                    VStack {
+                                        Button {
+                                            showCGMSelection.toggle()
+                                        } label: {
+                                            Text("Add CGM")
+                                                .font(.title3) }
+                                            .frame(maxWidth: .infinity, alignment: .center)
+                                            .buttonStyle(.bordered)
 
-                                    HStack(alignment: .center) {
-                                        Text(
-                                            "Pair your CGM with Trio. See hint for compatible devices."
-                                        )
-                                        .font(.footnote)
-                                        .foregroundColor(.secondary)
-                                        .lineLimit(nil)
-                                        Spacer()
-                                        Button(
-                                            action: {
-                                                shouldDisplayHint.toggle()
-                                            },
-                                            label: {
-                                                HStack {
-                                                    Image(systemName: "questionmark.circle")
+                                        HStack(alignment: .center) {
+                                            Text(
+                                                "Pair your CGM with Trio. See hint for compatible devices."
+                                            )
+                                            .font(.footnote)
+                                            .foregroundColor(.secondary)
+                                            .lineLimit(nil)
+                                            Spacer()
+                                            Button(
+                                                action: {
+                                                    shouldDisplayHint.toggle()
+                                                },
+                                                label: {
+                                                    HStack {
+                                                        Image(systemName: "questionmark.circle")
+                                                    }
                                                 }
-                                            }
-                                        ).buttonStyle(BorderlessButtonStyle())
-                                    }.padding(.top)
-                                }.padding(.vertical)
+                                            ).buttonStyle(BorderlessButtonStyle())
+                                        }.padding(.top)
+                                    }.padding(.vertical)
+                                }
                             }
                         }
                     )

+ 1 - 1
Trio/Sources/Modules/CarbRatioEditor/CarbRatioEditorStateModel.swift

@@ -9,7 +9,7 @@ extension CarbRatioEditor {
 
         let timeValues = stride(from: 0.0, to: 1.days.timeInterval, by: 30.minutes.timeInterval).map { $0 }
 
-        let rateValues = stride(from: 30.0, to: 501.0, by: 1.0).map { ($0.decimal ?? .zero) / 10 }
+        let rateValues = stride(from: 10.0, to: 501.0, by: 1.0).map { ($0.decimal ?? .zero) / 10 }
 
         var canAdd: Bool {
             guard let lastItem = items.last else { return true }

+ 15 - 15
Trio/Sources/Modules/CarbRatioEditor/View/CarbRatioEditorRootView.swift

@@ -177,23 +177,22 @@ extension CarbRatioEditor {
             }
         }
 
-        let chartScale = Calendar.current
-            .date(from: DateComponents(year: 2001, month: 01, day: 01, hour: 0, minute: 0, second: 0))
-
+        var now = Date()
         var chart: some View {
             Chart {
                 ForEach(state.items.indexed(), id: \.1.id) { index, item in
                     let displayValue = state.rateValues[item.rateIndex]
 
-                    let tzOffset = TimeZone.current.secondsFromGMT() * -1
-                    let startDate = Date(timeIntervalSinceReferenceDate: state.timeValues[item.timeIndex])
-                        .addingTimeInterval(TimeInterval(tzOffset))
+                    let startDate = Calendar.current
+                        .startOfDay(for: now)
+                        .addingTimeInterval(state.timeValues[item.timeIndex])
                     let endDate = state.items
                         .count > index + 1 ?
-                        Date(timeIntervalSinceReferenceDate: state.timeValues[state.items[index + 1].timeIndex])
-                        .addingTimeInterval(TimeInterval(tzOffset)) :
-                        Date(timeIntervalSinceReferenceDate: state.timeValues.last!).addingTimeInterval(30 * 60)
-                        .addingTimeInterval(TimeInterval(tzOffset))
+                        Calendar.current.startOfDay(for: now)
+                        .addingTimeInterval(state.timeValues[state.items[index + 1].timeIndex])
+                        :
+                        Calendar.current.startOfDay(for: now)
+                        .addingTimeInterval(state.timeValues.last! + 30 * 60)
                     RectangleMark(
                         xStart: .value("start", startDate),
                         xEnd: .value("end", endDate),
@@ -202,8 +201,8 @@ extension CarbRatioEditor {
                     ).foregroundStyle(
                         .linearGradient(
                             colors: [
-                                Color.insulin.opacity(0.6),
-                                Color.insulin.opacity(0.1)
+                                Color.orange.opacity(0.6),
+                                Color.orange.opacity(0.1)
                             ],
                             startPoint: .bottom,
                             endPoint: .top
@@ -211,10 +210,10 @@ extension CarbRatioEditor {
                     ).alignsMarkStylesWithPlotArea()
 
                     LineMark(x: .value("End Date", startDate), y: .value("Ratio", displayValue))
-                        .lineStyle(.init(lineWidth: 1)).foregroundStyle(Color.insulin)
+                        .lineStyle(.init(lineWidth: 1)).foregroundStyle(Color.orange)
 
                     LineMark(x: .value("Start Date", endDate), y: .value("Ratio", displayValue))
-                        .lineStyle(.init(lineWidth: 1)).foregroundStyle(Color.insulin)
+                        .lineStyle(.init(lineWidth: 1)).foregroundStyle(Color.orange)
                 }
             }
             .chartXAxis {
@@ -224,7 +223,8 @@ extension CarbRatioEditor {
                 }
             }
             .chartXScale(
-                domain: Calendar.current.startOfDay(for: chartScale!) ... Calendar.current.startOfDay(for: chartScale!)
+                domain: Calendar.current.startOfDay(for: now) ... Calendar
+                    .current.startOfDay(for: now)
                     .addingTimeInterval(60 * 60 * 24)
             )
             .chartYAxis {

+ 4 - 2
Trio/Sources/Modules/DataTable/View/DataTableRootView.swift

@@ -367,10 +367,12 @@ extension DataTable {
                                 action: {
                                     alertGlucoseToDelete = glucose
 
+                                    let glucoseToDisplay = state.units == .mgdL ? glucose.glucose
+                                        .description : Int(glucose.glucose).formattedAsMmolL
                                     alertTitle = String(localized: "Delete Glucose?", comment: "Alert title for deleting glucose")
                                     alertMessage = Formatter.dateFormatter
-                                        .string(from: glucose.date ?? Date()) + ", " +
-                                        (Formatter.decimalFormatterWithTwoFractionDigits.string(for: glucose.glucose) ?? "0")
+                                        .string(from: glucose.date ?? Date()) + ", " + glucoseToDisplay + " " + state.units
+                                        .rawValue
 
                                     isRemoveHistoryItemAlertPresented = true
                                 }

+ 29 - 29
Trio/Sources/Modules/DynamicSettings/View/DynamicSettingsRootView.swift

@@ -169,35 +169,6 @@ extension DynamicSettings {
                                 )
                             }
                         )
-
-                        SettingInputSection(
-                            decimalValue: $state.weightPercentage,
-                            booleanValue: $booleanPlaceholder,
-                            shouldDisplayHint: $shouldDisplayHint,
-                            selectedVerboseHint: Binding(
-                                get: { selectedVerboseHint },
-                                set: {
-                                    selectedVerboseHint = $0.map { AnyView($0) }
-                                    hintLabel = String(localized: "Weighted Average of TDD")
-                                }
-                            ),
-                            units: state.units,
-                            type: .decimal("weightPercentage"),
-                            label: String(localized: "Weighted Average of TDD"),
-                            miniHint: String(localized: "Weight of 24-hr TDD against 10-day TDD."),
-                            verboseHint:
-                            VStack(alignment: .leading, spacing: 10) {
-                                Text("Default: 35%").bold()
-                                Text(
-                                    "This setting adjusts how much weight is given to your recent total daily insulin dose when calculating Dynamic ISF and Dynamic CR."
-                                )
-                                Text(
-                                    "At the default setting, 35% of the calculation is based on the last 24 hours of insulin use, with the remaining 65% considering the last 10 days of data."
-                                )
-                                Text("Setting this to 100% means only the past 24 hours will be used.")
-                                Text("A lower value smooths out these variations for more stability.")
-                            }
-                        )
                     } else {
                         SettingInputSection(
                             decimalValue: $state.adjustmentFactorSigmoid,
@@ -234,6 +205,35 @@ extension DynamicSettings {
                     }
 
                     SettingInputSection(
+                        decimalValue: $state.weightPercentage,
+                        booleanValue: $booleanPlaceholder,
+                        shouldDisplayHint: $shouldDisplayHint,
+                        selectedVerboseHint: Binding(
+                            get: { selectedVerboseHint },
+                            set: {
+                                selectedVerboseHint = $0.map { AnyView($0) }
+                                hintLabel = String(localized: "Weighted Average of TDD")
+                            }
+                        ),
+                        units: state.units,
+                        type: .decimal("weightPercentage"),
+                        label: String(localized: "Weighted Average of TDD"),
+                        miniHint: String(localized: "Weight of 24-hr TDD against 10-day TDD."),
+                        verboseHint:
+                        VStack(alignment: .leading, spacing: 10) {
+                            Text("Default: 35%").bold()
+                            Text(
+                                "This setting adjusts how much weight is given to your recent total daily insulin dose when calculating Dynamic ISF and Dynamic CR."
+                            )
+                            Text(
+                                "At the default setting, 35% of the calculation is based on the last 24 hours of insulin use, with the remaining 65% considering the last 10 days of data."
+                            )
+                            Text("Setting this to 100% means only the past 24 hours will be used.")
+                            Text("A lower value smooths out these variations for more stability.")
+                        }
+                    )
+
+                    SettingInputSection(
                         decimalValue: $decimalPlaceholder,
                         booleanValue: $state.tddAdjBasal,
                         shouldDisplayHint: $shouldDisplayHint,

+ 13 - 10
Trio/Sources/Modules/GeneralSettings/View/UnitsLimitsSettingsRootView.swift

@@ -36,12 +36,12 @@ extension UnitsLimitsSettings {
                         get: { selectedVerboseHint },
                         set: {
                             selectedVerboseHint = $0.map { AnyView($0) }
-                            hintLabel = String(localized: "Max IOB", comment: "Max IOB")
+                            hintLabel = String(localized: "Maximum Insulin on Board (IOB)", comment: "Max IOB")
                         }
                     ),
                     units: state.units,
                     type: .decimal("maxIOB"),
-                    label: String(localized: "Max IOB", comment: "Max IOB"),
+                    label: String(localized: "Maximum Insulin on Board (IOB)", comment: "Max IOB"),
                     miniHint: String(localized: "Maximum units of insulin allowed to be active."),
                     verboseHint:
                     VStack(alignment: .leading, spacing: 10) {
@@ -50,6 +50,9 @@ extension UnitsLimitsSettings {
                             "Warning: This must be greater than 0 for any automatic temporary basal rates or SMBs to be given."
                         ).bold()
                         Text(
+                            "This setting helps prevent delivering too much insulin at once. It’s typically a value close to the amount you might need for a very high blood sugar and the biggest meal of your life combined."
+                        )
+                        Text(
                             "This is the maximum amount of Insulin On Board (IOB) above profile basal rates from all sources - positive temporary basal rates, manual or meal boluses, and SMBs - that Trio is allowed to accumulate to address an above target glucose."
                         )
                         Text(
@@ -69,12 +72,12 @@ extension UnitsLimitsSettings {
                         get: { selectedVerboseHint },
                         set: {
                             selectedVerboseHint = $0.map { AnyView($0) }
-                            hintLabel = String(localized: "Max Bolus")
+                            hintLabel = String(localized: "Maximum Bolus")
                         }
                     ),
                     units: state.units,
                     type: .decimal("maxBolus"),
-                    label: String(localized: "Max Bolus"),
+                    label: String(localized: "Maximum Bolus"),
                     miniHint: String(localized: "Largest bolus of insulin allowed."),
                     verboseHint:
                     VStack(alignment: .leading, spacing: 10) {
@@ -95,16 +98,16 @@ extension UnitsLimitsSettings {
                         get: { selectedVerboseHint },
                         set: {
                             selectedVerboseHint = $0.map { AnyView($0) }
-                            hintLabel = String(localized: "Max Basal")
+                            hintLabel = String(localized: "Max Basal Rate")
                         }
                     ),
                     units: state.units,
                     type: .decimal("maxBasal"),
-                    label: String(localized: "Max Basal"),
+                    label: String(localized: "Maximum Basal Rate"),
                     miniHint: String(localized: "Largest basal rate allowed."),
                     verboseHint:
                     VStack(alignment: .leading, spacing: 10) {
-                        Text("Default: 2 units").bold()
+                        Text("Default: 2 \(String(localized: "U/hr", comment: "Insulin unit per hour abbreviation"))").bold()
                         Text(
                             "This is the maximum basal rate allowed to be set or scheduled. This applies to both automatic and manual basal rates."
                         )
@@ -122,13 +125,13 @@ extension UnitsLimitsSettings {
                         get: { selectedVerboseHint },
                         set: {
                             selectedVerboseHint = $0.map { AnyView($0) }
-                            hintLabel = String(localized: "Max COB", comment: "Max COB")
+                            hintLabel = String(localized: "Maximum Carbs on Board (COB)", comment: "Max COB")
                         }
                     ),
                     units: state.units,
                     type: .decimal("maxCOB"),
-                    label: String(localized: "Max COB", comment: "Max COB"),
-                    miniHint: String(localized: "Maximum Carbs On Board (COB) allowed."),
+                    label: String(localized: "Maximum Carbs on Board (COB)", comment: "Max COB"),
+                    miniHint: String(localized: "Maximum amount of active carbs considered by the algorithm."),
                     verboseHint:
                     VStack(alignment: .leading, spacing: 10) {
                         Text("Default: 120 grams of carbs").bold()

+ 1 - 1
Trio/Sources/Modules/GlucoseNotificationSettings/View/GlucoseNotificationSettingsRootView.swift

@@ -198,7 +198,7 @@ extension GlucoseNotificationSettings {
                                                 )
                                                 VStack(alignment: .leading, spacing: 5) {
                                                     Text("Disabled:").bold()
-                                                    Text("No Glucose Notificatitons will be triggered.")
+                                                    Text("No Glucose Notifications will be triggered.")
                                                 }
                                                 VStack(alignment: .leading, spacing: 5) {
                                                     Text("Always:").bold()

+ 1 - 0
Trio/Sources/Modules/Home/HomeStateModel.swift

@@ -19,6 +19,7 @@ extension Home {
         @ObservationIgnored @Injected() var carbsStorage: CarbsStorage!
         @ObservationIgnored @Injected() var tempTargetStorage: TempTargetsStorage!
         @ObservationIgnored @Injected() var overrideStorage: OverrideStorage!
+        @ObservationIgnored @Injected() var bluetoothManager: BluetoothStateManager!
 
         var cgmStateModel: CGMSettings.StateModel {
             CGMSettings.StateModel.shared

+ 0 - 1
Trio/Sources/Modules/Home/View/Header/CurrentGlucoseView.swift

@@ -11,7 +11,6 @@ struct CurrentGlucoseView: View {
     var currentGlucoseTarget: Decimal
     let glucoseColorScheme: GlucoseColorScheme
     let glucose: [GlucoseStored] // This contains the last two glucose values, no matter if its manual or a cgm reading
-
     @State private var rotationDegrees: Double = 0.0
     @State private var angularGradient = AngularGradient(colors: [
         Color(red: 0.7215686275, green: 0.3411764706, blue: 1),

+ 0 - 1
Trio/Sources/Modules/Home/View/Header/PumpView.swift

@@ -8,7 +8,6 @@ struct PumpView: View {
     let timerDate: Date
     let pumpStatusHighlightMessage: String?
     let battery: [OpenAPS_Battery]
-
     @Environment(\.colorScheme) var colorScheme
 
     private var batteryFormatter: NumberFormatter {

+ 22 - 16
Trio/Sources/Modules/Home/View/HomeRootView.swift

@@ -844,20 +844,26 @@ extension Home {
         @ViewBuilder func mainViewElements(_ geo: GeometryProxy) -> some View {
             VStack(spacing: 0) {
                 ZStack {
-                    /// glucose bobble
-                    glucoseView
+                    if let apsManager = state.apsManager, let bluetoothManager = apsManager.bluetoothManager,
+                       bluetoothManager.bluetoothAuthorization != .authorized
+                    {
+                        BluetoothRequiredView()
+                    } else {
+                        /// right panel with loop status and evBG
+                        HStack {
+                            Spacer()
+                            rightHeaderPanel(geo)
+                        }.padding(.trailing, 20)
 
-                    /// right panel with loop status and evBG
-                    HStack {
-                        Spacer()
-                        rightHeaderPanel(geo)
-                    }.padding(.trailing, 20)
+                        /// glucose bobble
+                        glucoseView
 
-                    /// left panel with pump related info
-                    HStack {
-                        pumpView
-                        Spacer()
-                    }.padding(.leading, 20)
+                        /// left panel with pump related info
+                        HStack {
+                            pumpView
+                            Spacer()
+                        }.padding(.leading, 20)
+                    }
                 }
                 .padding(.top, 10)
                 .safeAreaInset(edge: .top, spacing: 0) {
@@ -945,7 +951,7 @@ extension Home {
             .confirmationDialog("Pump Model", isPresented: $showPumpSelection) {
                 Button("Medtronic") { state.addPump(.minimed) }
                 Button("Omnipod Eros") { state.addPump(.omnipod) }
-                Button("Omnipod Dash") { state.addPump(.omnipodBLE) }
+                Button("Omnipod DASH") { state.addPump(.omnipodBLE) }
                 Button("Dana(RS/-i)") { state.addPump(.dana) }
                 Button("Pump Simulator") { state.addPump(.simulator) }
             } message: { Text("Select Pump Model") }
@@ -1056,13 +1062,13 @@ extension Home {
 
                 Button(
                     action: {
-                        state.showModal(for: .bolus) },
+                        state.showModal(for: .treatmentView) },
                     label: {
                         Image(systemName: "plus.circle.fill")
                             .font(.system(size: 40))
                             .foregroundStyle(Color.tabBar)
-                            .padding(.bottom, 1)
-                            .padding(.horizontal, 22.5)
+                            .padding(.vertical, 2)
+                            .padding(.horizontal, 24)
                     }
                 )
             }.ignoresSafeArea(.keyboard, edges: .bottom).blur(radius: state.waitForSuggestion ? 8 : 0)

+ 23 - 9
Trio/Sources/Modules/ISFEditor/ISFEditorStateModel.swift

@@ -2,6 +2,16 @@ import CoreData
 import Observation
 import SwiftUI
 
+extension [Decimal] {
+    func findClosestIndex(to target: Element) -> Int? {
+        guard !isEmpty else { return nil }
+
+        return enumerated().min(by: {
+            abs($0.element - target) < abs($1.element - target)
+        })?.offset
+    }
+}
+
 extension ISFEditor {
     @Observable final class StateModel: BaseStateModel<Provider> {
         @ObservationIgnored @Injected() var determinationStorage: DeterminationStorage!
@@ -16,13 +26,9 @@ extension ISFEditor {
         let timeValues = stride(from: 0.0, to: 1.days.timeInterval, by: 30.minutes.timeInterval).map { $0 }
 
         var rateValues: [Decimal] {
-            var values = stride(from: 9, to: 540.01, by: 1.0).map { Decimal($0) }
-
-            if units == .mmolL {
-                values = values.filter { Int(truncating: $0 as NSNumber) % 2 == 0 }
-            }
-
-            return values
+            let settingsProvider = PickerSettingsProvider.shared
+            let sensitivityPickerSetting = PickerSetting(value: 100, step: 1, min: 9, max: 540, type: .glucose)
+            return settingsProvider.generatePickerValues(from: sensitivityPickerSetting, units: units)
         }
 
         var canAdd: Bool {
@@ -43,8 +49,16 @@ extension ISFEditor {
 
             items = profile.sensitivities.map { value in
                 let timeIndex = timeValues.firstIndex(of: Double(value.offset * 60)) ?? 0
-                let rateIndex = rateValues.firstIndex(of: value.sensitivity) ?? 0
-                return Item(rateIndex: rateIndex, timeIndex: timeIndex)
+                var rateIndex = rateValues.firstIndex(of: value.sensitivity)
+                if rateIndex == nil {
+                    // try to look up the closest value
+                    if let min = rateValues.first, let max = rateValues.last {
+                        if value.sensitivity >= (min - 1), value.sensitivity <= (max + 1) {
+                            rateIndex = rateValues.findClosestIndex(to: value.sensitivity)
+                        }
+                    }
+                }
+                return Item(rateIndex: rateIndex ?? 0, timeIndex: timeIndex)
             }
 
             initialItems = items.map { Item(rateIndex: $0.rateIndex, timeIndex: $0.timeIndex) }

+ 16 - 14
Trio/Sources/Modules/ISFEditor/View/ISFEditorRootView.swift

@@ -177,8 +177,7 @@ extension ISFEditor {
             }
         }
 
-        let chartScale = Calendar.current
-            .date(from: DateComponents(year: 2001, month: 01, day: 01, hour: 0, minute: 0, second: 0))
+        var now = Date()
 
         var chart: some View {
             Chart {
@@ -190,15 +189,17 @@ extension ISFEditor {
                     // However, swift doesn't understand languages that use comma as decimal delminator
                     let displayValueFloat = Double(displayValue.replacingOccurrences(of: ",", with: "."))
 
-                    let tzOffset = TimeZone.current.secondsFromGMT() * -1
-                    let startDate = Date(timeIntervalSinceReferenceDate: state.timeValues[item.timeIndex])
-                        .addingTimeInterval(TimeInterval(tzOffset))
+                    let startDate = Calendar.current
+                        .startOfDay(for: now)
+                        .addingTimeInterval(state.timeValues[item.timeIndex])
+
                     let endDate = state.items
                         .count > index + 1 ?
-                        Date(timeIntervalSinceReferenceDate: state.timeValues[state.items[index + 1].timeIndex])
-                        .addingTimeInterval(TimeInterval(tzOffset)) :
-                        Date(timeIntervalSinceReferenceDate: state.timeValues.last!).addingTimeInterval(30 * 60)
-                        .addingTimeInterval(TimeInterval(tzOffset))
+                        Calendar.current.startOfDay(for: now)
+                        .addingTimeInterval(state.timeValues[state.items[index + 1].timeIndex])
+                        :
+                        Calendar.current.startOfDay(for: now)
+                        .addingTimeInterval(state.timeValues.last! + 30 * 60)
                     RectangleMark(
                         xStart: .value("start", startDate),
                         xEnd: .value("end", endDate),
@@ -207,8 +208,8 @@ extension ISFEditor {
                     ).foregroundStyle(
                         .linearGradient(
                             colors: [
-                                Color.insulin.opacity(0.6),
-                                Color.insulin.opacity(0.1)
+                                Color.cyan.opacity(0.6),
+                                Color.cyan.opacity(0.1)
                             ],
                             startPoint: .bottom,
                             endPoint: .top
@@ -216,10 +217,10 @@ extension ISFEditor {
                     ).alignsMarkStylesWithPlotArea()
 
                     LineMark(x: .value("End Date", startDate), y: .value("ISF", displayValueFloat ?? 0))
-                        .lineStyle(.init(lineWidth: 1)).foregroundStyle(Color.insulin)
+                        .lineStyle(.init(lineWidth: 1)).foregroundStyle(Color.cyan)
 
                     LineMark(x: .value("Start Date", endDate), y: .value("ISF", displayValueFloat ?? 0))
-                        .lineStyle(.init(lineWidth: 1)).foregroundStyle(Color.insulin)
+                        .lineStyle(.init(lineWidth: 1)).foregroundStyle(Color.cyan)
                 }
             }
             .chartXAxis {
@@ -229,7 +230,8 @@ extension ISFEditor {
                 }
             }
             .chartXScale(
-                domain: Calendar.current.startOfDay(for: chartScale!) ... Calendar.current.startOfDay(for: chartScale!)
+                domain: Calendar.current.startOfDay(for: now) ... Calendar
+                    .current.startOfDay(for: now)
                     .addingTimeInterval(60 * 60 * 24)
             )
             .chartYAxis {

+ 1 - 1
Trio/Sources/Modules/LiveActivitySettings/View/LiveActivityWidgetConfiguration.swift

@@ -377,7 +377,7 @@ enum LiveActivityItem: String, CaseIterable, Identifiable {
     var id: String { rawValue }
 
     static var defaultItems: [LiveActivityItem] {
-        [.currentGlucoseLarge, .iob, .cob, .updatedLabel]
+        [.currentGlucose, .iob, .cob, .updatedLabel]
     }
 
     var displayName: String {

+ 19 - 6
Trio/Sources/Modules/Main/MainStateModel.swift

@@ -6,6 +6,7 @@ import Swinject
 
 extension Main {
     final class StateModel: BaseStateModel<Provider> {
+        @Injected() private var apsManager: APSManager!
         @Injected() var alertPermissionsChecker: AlertPermissionsChecker!
         @Injected() var broadcaster: Broadcaster!
         private(set) var modal: Modal?
@@ -204,24 +205,36 @@ extension Main {
         /*
           Reclassification is needed for Medtronic pumps for 'Pump error:' RileyLink related messages.
           For details, see https://discord.com/channels/1020905149037813862/1338245444186279946/1343469793013141525.
-          Reclassification of Info type messages is based on APSManager.APSError enum values.
-          Currently, we only re-classify APSError.pumpError 'Pump error:' type to MessageType.error.
+          These messages are repeatedly displayed causing users to simply ignore them.
+          Reclassification of these Info type messages is based on APSManager.APSError enum values.
+          We reclassify APSError.pumpError and APSError.invalidPumpState as MessageType.info and MessageSubtype.pump.
+          This allows the user to disable these messages using using the 'Trio Notification' -> 'Always Notify Pump' setting.
           MessageType.error messagges are always displayed to the user and the user cannot disable them.
           Other APSManager.APSError remain as MessageType.info which allows users to disable them
           using the 'Trio Notification' -> 'Always Notify Algorithm' setting.
          */
+
         func reclassifyInfoNotification(_ message: inout MessageContent) {
             if message.title == "" {
                 switch message.type {
                 case .info:
-                    if let errorIndex = message.content.range(of: "error", options: .caseInsensitive) {
+                    if message.content.range(of: "error", options: .caseInsensitive) != nil || message.content
+                        .range(of: String(localized: "Error"), options: .caseInsensitive) != nil
+                    {
                         message.title = String(localized: "Error", comment: "Error title")
-                        if let errorPumpIndex = message.content.range(of: "Pump error:", options: .caseInsensitive) {
-                            message.type = .error
-                        }
                     } else {
                         message.title = String(localized: "Info", comment: "Info title")
                     }
+                    if APSError.pumpWarningMatches(message: message.content) {
+                        message.subtype = .pump
+                        let lastLoopMinutes = Int((Date().timeIntervalSince(apsManager.lastLoopDate) - 30) / 60) + 1
+                        if lastLoopMinutes > 10 {
+                            message.type = .error
+                        }
+                    } else if APSError.pumpErrorMatches(message: message.content) {
+                        message.subtype = .pump
+                        message.type = .error
+                    }
                 case .warning:
                     message.title = String(localized: "Warning", comment: "Warning title")
                 case .error:

+ 2 - 2
Trio/Sources/Modules/Main/View/MainLoadingView.swift

@@ -70,9 +70,9 @@ extension Main {
                 .font(.title3).bold()
                 .background(
                     Capsule()
-                        .fill(Color.tabBar)
+                        .fill(Color.blue)
                 )
-                .foregroundColor(.white)
+                .foregroundColor(Color.white)
                 .shadow(color: Color.black.opacity(0.1), radius: 5, x: 0, y: 2)
             }
         }

+ 131 - 0
Trio/Sources/Modules/Main/View/MainMigrationErrorView.swift

@@ -0,0 +1,131 @@
+//
+//  MainMigrationErrorView.swift
+//  Trio
+//
+//  Created by Cengiz Deniz on 21.04.25.
+//
+import SwiftUI
+
+extension Main {
+    struct MainMigrationErrorView: View {
+        let migrationErrors: [String]
+        let onConfirm: () -> Void
+
+        private let versionNumber = Bundle.main.releaseVersionNumber ?? String(localized: "Unknown")
+
+        var body: some View {
+            ZStack(alignment: .bottom) {
+                LinearGradient(
+                    gradient: Gradient(colors: [Color.bgDarkBlue, Color.bgDarkerDarkBlue]),
+                    startPoint: .top,
+                    endPoint: .bottom
+                )
+                .ignoresSafeArea()
+
+                ScrollView {
+                    VStack {
+                        Spacer().frame(maxHeight: 20)
+
+                        Image(.trioCircledNoBackground)
+                            .resizable()
+                            .scaledToFit()
+                            .frame(width: 80, height: 80)
+                            .shadow(color: Color.white.opacity(0.1), radius: 5, x: 0, y: 0)
+
+                        Text("Trio v\(versionNumber)")
+                            .fontWeight(.heavy)
+                            .foregroundStyle(Color(red: 148 / 255, green: 102 / 255, blue: 234 / 255))
+                            .padding(.vertical)
+
+                        Spacer().frame(maxHeight: 20)
+
+                        VStack(alignment: .leading, spacing: 20) {
+                            Text("Oops! Some data didn’t make it over.").font(.title3).bold()
+
+                            Text(
+                                "While upgrading Trio to the new version, we ran into an issue transferring some of your historical data."
+                            )
+                            .multilineTextAlignment(.leading)
+
+                            VStack(alignment: .leading, spacing: 10) {
+                                ForEach(migrationErrors, id: \.self) { message in
+                                    BulletPoint(message)
+                                }
+                            }
+
+                            Text(
+                                "This means Trio may not have complete information about how much active insulin or carbs were still on board when you switched over."
+                            )
+                            .bold()
+
+                            VStack(alignment: .leading, spacing: 10) {
+                                HStack(alignment: .top, spacing: 10) {
+                                    Image(systemName: "exclamationmark.triangle.fill")
+                                        .foregroundStyle(Color.bgDarkBlue, Color.orange)
+                                        .symbolRenderingMode(.palette)
+                                    Text("To stay safe, we recommend:").foregroundStyle(Color.orange)
+                                }.bold()
+
+                                VStack(alignment: .leading, spacing: 10) {
+                                    BulletPoint(
+                                        String(
+                                            localized: "Manually backdate some recent carbs or insulin you’ve entered in the last 6 to 8 hours."
+                                        )
+                                    )
+                                    BulletPoint(
+                                        String(
+                                            localized: "Stay in open loop (no automated dosing) for a bit to help Trio catch up to keep you safe"
+                                        )
+                                    )
+                                }
+                            }
+                            .frame(maxWidth: .infinity)
+                            .padding()
+                            .background(Color.clear)
+                            .overlay(
+                                RoundedRectangle(cornerRadius: 10)
+                                    .stroke(Color.orange, lineWidth: 2)
+                            )
+                            .cornerRadius(10)
+
+                            Text(
+                                "Trio is still fully functional and will adapt quickly — but your awareness right now helps it keep you safer."
+                            )
+                            .multilineTextAlignment(.leading)
+                            .padding(.bottom)
+                        }
+                        .padding(.horizontal, 24)
+                        .foregroundStyle(.white)
+                    }
+                }
+                .padding(.bottom, 80)
+
+                Button(action: onConfirm) {
+                    Text("I understand! Proceed")
+                        .frame(width: UIScreen.main.bounds.width - 60, height: 50)
+                        .background(
+                            Capsule()
+                                .fill(Color.blue)
+                        )
+                        .foregroundColor(Color.white)
+                }.padding(.bottom)
+            }
+        }
+    }
+}
+
+struct MainMigrationErrorView_Previews: PreviewProvider {
+    static var previews: some View {
+        Group {
+            Main.MainMigrationErrorView(
+                migrationErrors: [
+                    "Failed to import glucose history.",
+                    "Failed to import pump history.",
+                    "Failed to import carb history.",
+                    "Failed to import algorithm data."
+                ],
+                onConfirm: { print("Proceed") }
+            )
+        }
+    }
+}

+ 0 - 266
Trio/Sources/Modules/NightscoutConfig/NightscoutConfigStateModel.swift

@@ -26,7 +26,6 @@ extension NightscoutConfig {
         @Published var isUploadEnabled = false // Allow uploads
         @Published var isDownloadEnabled = false // Allow downloads
         @Published var uploadGlucose = true // Upload Glucose
-        @Published var changeUploadGlucose = true // if plugin, need to be change in CGM configuration
         @Published var useLocalSource = false
         @Published var localPort: Decimal = 0
         @Published var units: GlucoseUnits = .mgdL
@@ -35,19 +34,6 @@ extension NightscoutConfig {
         @Published var maxBolus: Decimal = 10
         @Published var isConnectedToNS: Bool = false
 
-        @Published var isImportResultReviewPresented: Bool = false
-        @Published var importErrors: [String] = []
-        @Published var importStatus: ImportStatus = .finished
-        @Published var importedInsulinActionCurve: Decimal = 6
-
-        var pumpSettings: PumpSettings {
-            provider.getPumpSettings()
-        }
-
-        var isPumpSettingUnchanged: Bool {
-            pumpSettings.insulinActionCurve == importedInsulinActionCurve
-        }
-
         override func subscribe() {
             url = keychain.getValue(String.self, forKey: Config.urlKey) ?? ""
             secret = keychain.getValue(String.self, forKey: Config.secretKey) ?? ""
@@ -55,7 +41,6 @@ extension NightscoutConfig {
             dia = settingsManager.pumpSettings.insulinActionCurve
             maxBasal = settingsManager.pumpSettings.maxBasal
             maxBolus = settingsManager.pumpSettings.maxBolus
-            changeUploadGlucose = (cgmManager.cgmGlucoseSourceType != CGMType.plugin)
 
             subscribeSetting(\.isUploadEnabled, on: $isUploadEnabled) { isUploadEnabled = $0 }
             subscribeSetting(\.isDownloadEnabled, on: $isDownloadEnabled) { isDownloadEnabled = $0 }
@@ -63,8 +48,6 @@ extension NightscoutConfig {
             subscribeSetting(\.localGlucosePort, on: $localPort.map(Int.init)) { localPort = Decimal($0) }
             subscribeSetting(\.uploadGlucose, on: $uploadGlucose, initial: { uploadGlucose = $0 })
 
-            importedInsulinActionCurve = pumpSettings.insulinActionCurve
-
             isConnectedToNS = nightscoutAPI != nil
 
             $isUploadEnabled
@@ -154,217 +137,6 @@ extension NightscoutConfig {
             return lowTargetValue
         }
 
-        func correctUnitParsingOffsets(_ parsedValue: Decimal) -> Decimal {
-            Int(parsedValue) % 2 == 0 ? parsedValue : parsedValue + 1
-        }
-
-        func importSettings() async {
-            importStatus = .running
-
-            do {
-                guard let fetchedProfile = await nightscoutManager.importSettings() else {
-                    await MainActor.run {
-                        importStatus = .failed
-                    }
-                    throw NSError(
-                        domain: "ImportError",
-                        code: 1,
-                        userInfo: [NSLocalizedDescriptionKey: "Cannot find the default Nightscout Profile."]
-                    )
-                }
-
-                // determine, i.e. guesstimate, whether fetched values are mmol/L or mg/dL values
-                let shouldConvertToMgdL = fetchedProfile.units.contains("mmol") || fetchedProfile.target_low
-                    .contains(where: { $0.value <= 39 }) || fetchedProfile.target_high.contains(where: { $0.value <= 39 })
-
-                // Carb Ratios
-                let carbratios = fetchedProfile.carbratio.map { carbratio in
-                    CarbRatioEntry(
-                        start: carbratio.time,
-                        offset: offset(carbratio.time) / 60,
-                        ratio: carbratio.value
-                    )
-                }
-
-                if carbratios.contains(where: { $0.ratio <= 0 }) {
-                    await MainActor.run {
-                        importStatus = .failed
-                    }
-                    throw NSError(
-                        domain: "ImportError",
-                        code: 2,
-                        userInfo: [NSLocalizedDescriptionKey: "Invalid Carb Ratio settings in Nightscout. Import aborted."]
-                    )
-                }
-
-                let carbratiosProfile = CarbRatios(units: .grams, schedule: carbratios)
-
-                // Basal Profile
-                let pumpName = apsManager.pumpName.value
-                let basals = fetchedProfile.basal.map { basal in
-                    BasalProfileEntry(
-                        start: basal.time,
-                        minutes: offset(basal.time) / 60,
-                        rate: basal.value
-                    )
-                }
-
-                if pumpName != "Omnipod DASH", basals.contains(where: { $0.rate <= 0 }) {
-                    await MainActor.run {
-                        importStatus = .failed
-                    }
-                    throw NSError(
-                        domain: "ImportError",
-                        code: 3,
-                        userInfo: [NSLocalizedDescriptionKey: "Invalid Nightscout basal rates found. Import aborted."]
-                    )
-                }
-
-                if pumpName == "Omnipod DASH", basals.reduce(0, { $0 + $1.rate }) <= 0 {
-                    await MainActor.run {
-                        importStatus = .failed
-                    }
-                    throw NSError(
-                        domain: "ImportError",
-                        code: 4,
-                        userInfo: [
-                            NSLocalizedDescriptionKey: "Invalid Nightscout basal rates found. Basal rate total cannot be 0 U/hr. Import aborted."
-                        ]
-                    )
-                }
-
-                // Sensitivities
-                let sensitivities = fetchedProfile.sens.map { sensitivity in
-                    InsulinSensitivityEntry(
-                        sensitivity: shouldConvertToMgdL ? correctUnitParsingOffsets(sensitivity.value.asMgdL) : sensitivity
-                            .value,
-                        offset: offset(sensitivity.time) / 60,
-                        start: sensitivity.time
-                    )
-                }
-
-                if sensitivities.contains(where: { $0.sensitivity <= 0 }) {
-                    await MainActor.run {
-                        importStatus = .failed
-                    }
-                    throw NSError(
-                        domain: "ImportError",
-                        code: 5,
-                        userInfo: [NSLocalizedDescriptionKey: "Invalid Nightscout insulin sensitivity profile. Import aborted."]
-                    )
-                }
-
-                let sensitivitiesProfile = InsulinSensitivities(
-                    units: .mgdL,
-                    userPreferredUnits: .mgdL,
-                    sensitivities: sensitivities
-                )
-
-                // Targets
-                let targets = fetchedProfile.target_low.map { target in
-                    BGTargetEntry(
-                        low: shouldConvertToMgdL ? correctUnitParsingOffsets(target.value.asMgdL) : target.value,
-                        high: shouldConvertToMgdL ? correctUnitParsingOffsets(target.value.asMgdL) : target.value,
-                        start: target.time,
-                        offset: offset(target.time) / 60
-                    )
-                }
-
-                let targetsProfile = BGTargets(units: .mgdL, userPreferredUnits: .mgdL, targets: targets)
-
-                // Save to storage and pump
-                if let pump = apsManager.pumpManager {
-                    let syncValues = basals.map {
-                        RepeatingScheduleValue(startTime: TimeInterval($0.minutes * 60), value: Double($0.rate))
-                    }
-
-                    await withCheckedContinuation { continuation in
-                        pump.syncBasalRateSchedule(items: syncValues) { [weak self] result in
-                            guard let self else {
-                                continuation.resume()
-                                return
-                            }
-
-                            switch result {
-                            case .success:
-                                self.storage.save(basals, as: OpenAPS.Settings.basalProfile)
-                                self.finalizeImport(
-                                    carbratiosProfile: carbratiosProfile,
-                                    sensitivitiesProfile: sensitivitiesProfile,
-                                    targetsProfile: targetsProfile,
-                                    dia: fetchedProfile.dia
-                                )
-                            case .failure:
-                                Task { @MainActor in
-                                    self.importErrors.append(
-                                        "Settings were imported but the basal rates could not be saved to pump (communication error)."
-                                    )
-                                    self.importStatus = .failed
-                                }
-                            }
-                            continuation.resume()
-                        }
-                    }
-
-                    if await MainActor.run(body: { importErrors.isNotEmpty && importStatus == .failed }) {
-                        throw NSError(
-                            domain: "ImportError",
-                            code: 6,
-                            userInfo: [
-                                NSLocalizedDescriptionKey: "Settings were imported but the basal rates could not be saved to pump (communication error)."
-                            ]
-                        )
-                    }
-                } else {
-                    storage.save(basals, as: OpenAPS.Settings.basalProfile)
-                    finalizeImport(
-                        carbratiosProfile: carbratiosProfile,
-                        sensitivitiesProfile: sensitivitiesProfile,
-                        targetsProfile: targetsProfile,
-                        dia: fetchedProfile.dia
-                    )
-                }
-            } catch {
-                await MainActor.run {
-                    self.importErrors.append(error.localizedDescription)
-                    debug(.service, "Settings import failed with error: \(error.localizedDescription)")
-                }
-            }
-        }
-
-        private func finalizeImport(
-            carbratiosProfile: CarbRatios,
-            sensitivitiesProfile: InsulinSensitivities,
-            targetsProfile: BGTargets,
-            dia: Decimal
-        ) {
-            storage.save(carbratiosProfile, as: OpenAPS.Settings.carbRatios)
-            storage.save(sensitivitiesProfile, as: OpenAPS.Settings.insulinSensitivities)
-            storage.save(targetsProfile, as: OpenAPS.Settings.bgTargets)
-
-            // Save DIA if different
-            if dia != self.dia, dia >= 0 {
-                let file = PumpSettings(insulinActionCurve: dia, maxBolus: maxBolus, maxBasal: maxBasal)
-                storage.save(file, as: OpenAPS.Settings.settings)
-                debug(.nightscout, "DIA setting updated to \(dia) after a NS import.")
-            }
-
-            debug(.service, "Settings imported successfully.")
-
-            DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
-                // stop blur
-                self.importStatus = .finished
-                // display next import rewview step
-                self.isImportResultReviewPresented = true
-            }
-        }
-
-        func offset(_ string: String) -> Int {
-            let hours = Int(string.prefix(2)) ?? 0
-            let minutes = Int(string.suffix(2)) ?? 0
-            return ((hours * 60) + minutes) * 60
-        }
-
         func backfillGlucose() async {
             await MainActor.run {
                 backfilling = true
@@ -402,35 +174,6 @@ extension NightscoutConfig {
             secret = ""
             isConnectedToNS = false
         }
-
-        func saveReviewedInsulinAction() {
-            if !isPumpSettingUnchanged {
-                let settings = PumpSettings(
-                    insulinActionCurve: importedInsulinActionCurve,
-                    maxBolus: pumpSettings.maxBolus,
-                    maxBasal: pumpSettings.maxBasal
-                )
-                provider.savePumpSettings(settings: settings)
-                    .receive(on: DispatchQueue.main)
-                    .sink { _ in
-                        let settings = self.provider.getPumpSettings()
-                        self.importedInsulinActionCurve = settings.insulinActionCurve
-
-                        Task.detached(priority: .low) {
-                            do {
-                                debug(.nightscout, "Attempting to upload DIA to Nightscout after import review")
-                                try await self.nightscoutManager.uploadProfiles()
-                            } catch {
-                                debug(
-                                    .default,
-                                    "\(DebuggingIdentifiers.failed) failed to upload DIA to Nightscout: \(error.localizedDescription)"
-                                )
-                            }
-                        }
-                    } receiveValue: {}
-                    .store(in: &lifetime)
-            }
-        }
     }
 }
 
@@ -439,12 +182,3 @@ extension NightscoutConfig.StateModel: SettingsObserver {
         units = settingsManager.settings.units
     }
 }
-
-extension NightscoutConfig.StateModel {
-    enum ImportStatus {
-        case running
-        case finished
-        case failed
-        case noPumpConnected
-    }
-}

+ 0 - 94
Trio/Sources/Modules/NightscoutConfig/View/NightscoutConfigRootView.swift

@@ -47,89 +47,6 @@ extension NightscoutConfig {
                         }
                     ).listRowBackground(Color.chart)
 
-                    Section {
-                        VStack {
-                            Button {
-                                importAlert = Alert(
-                                    title: Text("Import Therapy Settings?"),
-                                    message: Text("Are you sure you want to import profile settings from Nightscout?\n\n")
-                                        + Text("This will overwrite the following Trio therapy settings:\n")
-                                        + Text("• Basal Rates\n")
-                                        + Text("• Insulin Sensitivities\n")
-                                        + Text("• Carb Ratios\n")
-                                        + Text("• Glucose Targets\n")
-                                        + Text("• Duration of Insulin Action"),
-                                    primaryButton: .default(
-                                        Text("Yes, Import!"),
-                                        action: {
-                                            Task {
-                                                await state.importSettings()
-                                                if state.importStatus == .failed, state.importErrors.isNotEmpty,
-                                                   let errorMessage = state.importErrors.first
-                                                {
-                                                    DispatchQueue.main.async {
-                                                        importAlert = Alert(
-                                                            title: Text("Import Failed"),
-                                                            message: Text(errorMessage.description),
-                                                            dismissButton: .default(Text("OK"))
-                                                        )
-                                                        isImportAlertPresented = true
-                                                    }
-                                                }
-                                            }
-                                        }
-                                    ),
-                                    secondaryButton: .cancel()
-                                )
-                                isImportAlertPresented = true
-                            } label: {
-                                Text("Import Settings")
-                                    .font(.title3) }
-                                .frame(maxWidth: .infinity, alignment: .center)
-                                .buttonStyle(.bordered)
-                                .disabled(state.url.isEmpty || state.connecting)
-
-                            HStack(alignment: .center) {
-                                Text(
-                                    "Import therapy settings from Nightscout.\nSee hint for the list of settings available for import."
-                                )
-                                .font(.footnote)
-                                .foregroundColor(.secondary)
-                                .lineLimit(nil)
-                                Spacer()
-                                Button(
-                                    action: {
-                                        hintLabel = String(localized: "Import Settings from Nightscout")
-                                        selectedVerboseHint =
-                                            AnyView(
-                                                VStack(alignment: .leading, spacing: 10) {
-                                                    Text(
-                                                        "This will overwrite the following Trio therapy settings:"
-                                                    )
-                                                    VStack(alignment: .leading) {
-                                                        Text("• Basal Rates")
-                                                        Text("• Insulin Sensitivities")
-                                                        Text("• Carb Ratios")
-                                                        Text("• Glucose Targets")
-                                                        Text("• Duration of Insulin Action")
-                                                    }
-                                                }
-                                            )
-                                        shouldDisplayHint.toggle()
-                                    },
-                                    label: {
-                                        HStack {
-                                            Image(systemName: "questionmark.circle")
-                                        }
-                                    }
-                                ).buttonStyle(BorderlessButtonStyle())
-                                    .alert(isPresented: $isImportAlertPresented) {
-                                        importAlert ?? Alert(title: Text("Unknown Error"))
-                                    }
-                            }.padding(.top)
-                        }.padding(.vertical)
-                    }.listRowBackground(Color.chart)
-
                     Section(
                         content:
                         {
@@ -189,18 +106,7 @@ extension NightscoutConfig {
                     ).listRowBackground(Color.chart)
                 }
                 .listSectionSpacing(sectionSpacing)
-                .blur(radius: state.importStatus == .running ? 5 : 0)
-
-                if state.importStatus == .running {
-                    CustomProgressView(text: String(
-                        localized: "Importing Profile...",
-                        comment: "Progress text when importing profile via Nightscout"
-                    ))
-                }
             }
-            .fullScreenCover(isPresented: $state.isImportResultReviewPresented, content: {
-                NightscoutImportResultView(resolver: resolver, state: state)
-            })
             .sheet(isPresented: $shouldDisplayHint) {
                 SettingInputHintView(
                     hintDetent: $hintDetent,

+ 20 - 22
Trio/Sources/Modules/NightscoutConfig/View/NightscoutUploadView.swift

@@ -47,29 +47,27 @@ struct NightscoutUploadView: View {
                 }
             )
 
-            if state.changeUploadGlucose {
-                SettingInputSection(
-                    decimalValue: $decimalPlaceholder,
-                    booleanValue: $state.uploadGlucose,
-                    shouldDisplayHint: $shouldDisplayHint,
-                    selectedVerboseHint: Binding(
-                        get: { selectedVerboseHint },
-                        set: {
-                            selectedVerboseHint = $0.map { AnyView($0) }
-                            hintLabel = String(localized: "Upload Glucose")
-                            shouldDisplayHint = true
-                        }
-                    ),
-                    units: state.units,
-                    type: .boolean,
-                    label: String(localized: "Upload Glucose"),
-                    miniHint: String(localized: "Enable uploading of CGM readings to Nightscout."),
-                    verboseHint: VStack(alignment: .leading, spacing: 10) {
-                        Text("Default: OFF").bold()
-                        Text("Enabling this setting allows CGM readings from Trio to be used in Nightscout.")
+            SettingInputSection(
+                decimalValue: $decimalPlaceholder,
+                booleanValue: $state.uploadGlucose,
+                shouldDisplayHint: $shouldDisplayHint,
+                selectedVerboseHint: Binding(
+                    get: { selectedVerboseHint },
+                    set: {
+                        selectedVerboseHint = $0.map { AnyView($0) }
+                        hintLabel = String(localized: "Upload Glucose")
+                        shouldDisplayHint = true
                     }
-                )
-            }
+                ),
+                units: state.units,
+                type: .boolean,
+                label: String(localized: "Upload Glucose"),
+                miniHint: String(localized: "Enable uploading of CGM readings to Nightscout."),
+                verboseHint: VStack(alignment: .leading, spacing: 10) {
+                    Text("Default: OFF").bold()
+                    Text("Enabling this setting allows CGM readings from Trio to be used in Nightscout.")
+                }
+            )
         }
         .listSectionSpacing(sectionSpacing)
         .sheet(isPresented: $shouldDisplayHint) {

+ 0 - 128
Trio/Sources/Modules/NightscoutConfig/View/ProfileImport/NightscoutImportResultView.swift

@@ -1,128 +0,0 @@
-import SwiftUI
-import Swinject
-
-struct NightscoutImportResultView: BaseView {
-    var resolver: any Swinject.Resolver
-
-    @ObservedObject var state: NightscoutConfig.StateModel
-
-    @State private var shouldDisplayHint: Bool = false
-    @State private var hintDetent = PresentationDetent.large
-    @State private var selectedVerboseHint: String?
-    @State private var hintLabel: String?
-    @State private var decimalPlaceholder: Decimal = 0.0
-    @State private var booleanPlaceholder: Bool = false
-
-    @State private var hasVisitedBasalProfileEditor = false
-    @State private var hasVisitedISFEditor = false
-    @State private var hasVisitedCREditor = false
-    @State private var hasVisitedPumpSettingsEditor = false
-
-    @Environment(\.colorScheme) var colorScheme
-    @Environment(AppState.self) var appState
-
-    private var allViewsVisited: Bool {
-        hasVisitedBasalProfileEditor &&
-            hasVisitedISFEditor &&
-            hasVisitedCREditor &&
-            hasVisitedPumpSettingsEditor
-    }
-
-    var body: some View {
-        NavigationStack {
-            Form {
-                Section(
-                    header: Text("Imported Nightscout Data"),
-                    content: {
-                        Text(
-                            "Trio has successfully imported your default Nightscout profile and stored it as therapy settings. "
-                        ) +
-                            Text("This has replaced your previous therapy settings.").bold().foregroundColor(.accentColor)
-                        Text("Please review the following settings:").bold()
-                    }
-                ).listRowBackground(Color.chart)
-
-                Section {
-                    NavigationLink(
-                        destination: BasalProfileEditor.RootView(resolver: resolver)
-                            .onDisappear { hasVisitedBasalProfileEditor = true }
-                    ) {
-                        HStack {
-                            Text("Basal Rates")
-                            if hasVisitedBasalProfileEditor {
-                                Image(systemName: "checkmark.circle.fill")
-                                    .imageScale(.large)
-                                    .fontWeight(.bold)
-                                    .foregroundStyle(Color.green)
-                            }
-                        }.foregroundColor(hasVisitedBasalProfileEditor ? .secondary : .primary)
-                    }
-
-                    NavigationLink(
-                        destination: ISFEditor.RootView(resolver: resolver)
-                            .onDisappear { hasVisitedISFEditor = true }
-                    ) {
-                        HStack {
-                            Text("Insulin Sensitivities")
-                            if hasVisitedISFEditor {
-                                Image(systemName: "checkmark.circle.fill")
-                                    .imageScale(.large)
-                                    .fontWeight(.bold)
-                                    .foregroundStyle(Color.green)
-                            }
-                        }.foregroundColor(hasVisitedISFEditor ? .secondary : .primary)
-                    }
-
-                    NavigationLink(
-                        destination: CarbRatioEditor.RootView(resolver: resolver)
-                            .onDisappear { hasVisitedCREditor = true }
-                    ) {
-                        HStack {
-                            Text("Carb Ratios")
-                            if hasVisitedCREditor {
-                                Image(systemName: "checkmark.circle.fill")
-                                    .imageScale(.large)
-                                    .fontWeight(.bold)
-                                    .foregroundStyle(Color.green)
-                            }
-                        }.foregroundColor(hasVisitedCREditor ? .secondary : .primary)
-                    }
-
-                    NavigationLink(
-                        destination: ReviewInsulinActionView(resolver: resolver, state: state)
-                            .onDisappear { hasVisitedPumpSettingsEditor = true }
-                    ) {
-                        HStack {
-                            Text("Duration of Insulin Action (DIA)")
-                            if hasVisitedPumpSettingsEditor {
-                                Image(systemName: "checkmark.circle.fill")
-                                    .imageScale(.large)
-                                    .fontWeight(.bold)
-                                    .foregroundStyle(Color.green)
-                            }
-                        }.foregroundColor(hasVisitedPumpSettingsEditor ? .secondary : .primary)
-                    }
-                }.listRowBackground(Color.chart)
-
-                Section {
-                    HStack {
-                        Button {
-                            state.isImportResultReviewPresented = false
-                        } label: {
-                            Text("Finish").font(.title3)
-                        }
-                        .disabled(!allViewsVisited)
-                        .frame(maxWidth: .infinity, alignment: .center)
-                        .tint(.white)
-                    }
-                }.listRowBackground(allViewsVisited ? Color(.systemBlue) : Color(.systemGray4))
-            }
-            .navigationTitle("Review Import")
-            .navigationBarTitleDisplayMode(.large)
-            .scrollContentBackground(.hidden)
-            .background(appState.trioBackgroundColor(for: colorScheme))
-            .interactiveDismissDisabled(true)
-            .screenNavigation(self)
-        }
-    }
-}

+ 0 - 67
Trio/Sources/Modules/NightscoutConfig/View/ProfileImport/ReviewInsulinActionView.swift

@@ -1,67 +0,0 @@
-import Foundation
-
-import SwiftUI
-import Swinject
-
-struct ReviewInsulinActionView: BaseView {
-    var resolver: any Swinject.Resolver
-
-    @ObservedObject var state: NightscoutConfig.StateModel
-
-    @State private var shouldDisplayHint: Bool = false
-    @State private var hintDetent = PresentationDetent.large
-    @State private var selectedVerboseHint: AnyView?
-    @State private var hintLabel: String?
-    @State private var decimalPlaceholder: Decimal = 0.0
-    @State private var booleanPlaceholder: Bool = false
-
-    @Environment(\.colorScheme) var colorScheme
-    @Environment(AppState.self) var appState
-
-    var body: some View {
-        List {
-            SettingInputSection(
-                decimalValue: $state.importedInsulinActionCurve,
-                booleanValue: $booleanPlaceholder,
-                shouldDisplayHint: $shouldDisplayHint,
-                selectedVerboseHint: Binding(
-                    get: { selectedVerboseHint },
-                    set: {
-                        selectedVerboseHint = $0.map { AnyView($0) }
-                        hintLabel = String(localized: "Duration of Insulin Action")
-                    }
-                ),
-                units: state.units,
-                type: .decimal("dia"),
-                label: String(localized: "Duration of Insulin Action"),
-                miniHint: String(localized: "Number of hours insulin is active in your body."),
-                verboseHint:
-                VStack(alignment: .leading, spacing: 10) {
-                    Text("Default: 10 hours").bold()
-                    Text("Number of hours insulin will contribute to IOB after dosing.")
-                    Text(
-                        "Tip: It is better to use a Custom Peak Time than to adjust Duration of Insulin Action (DIA)."
-                    )
-                },
-                headerText: String(localized: "Review imported DIA")
-            )
-        }
-        .sheet(isPresented: $shouldDisplayHint) {
-            SettingInputHintView(
-                hintDetent: $hintDetent,
-                shouldDisplayHint: $shouldDisplayHint,
-                hintLabel: hintLabel ?? "",
-                hintText: selectedVerboseHint ?? AnyView(EmptyView()),
-                sheetTitle: String(localized: "Help", comment: "Help sheet title")
-            )
-        }
-        .scrollContentBackground(.hidden)
-        .background(appState.trioBackgroundColor(for: colorScheme))
-        .onAppear(perform: configureView)
-        .navigationTitle("Duration of Insulin Action")
-        .navigationBarTitleDisplayMode(.automatic)
-        .onDisappear {
-            state.saveReviewedInsulinAction()
-        }
-    }
-}

+ 5 - 0
Trio/Sources/Modules/Onboarding/OnboardingDataFlow.swift

@@ -0,0 +1,5 @@
+enum Onboarding {
+    enum Config {}
+}
+
+protocol OnboardingProvider: Provider {}

+ 67 - 0
Trio/Sources/Modules/Onboarding/OnboardingProvider.swift

@@ -0,0 +1,67 @@
+import Combine
+
+extension Onboarding {
+    final class Provider: BaseProvider, MainProvider {
+        var glucoseTargetsOnFile: BGTargets {
+            var retrievedTargets = storage.retrieve(OpenAPS.Settings.bgTargets, as: BGTargets.self)
+                ?? BGTargets(units: .mgdL, userPreferredUnits: .mgdL, targets: [])
+
+            // migrate existing mmol/L Trio users from mmol/L settings to pure mg/dl settings
+            if retrievedTargets.units == .mmolL || retrievedTargets.userPreferredUnits == .mmolL {
+                let convertedTargets = retrievedTargets.targets.map { target in
+                    BGTargetEntry(
+                        low: storage.parseSettingIfMmolL(value: target.low),
+                        high: storage.parseSettingIfMmolL(value: target.high),
+                        start: target.start,
+                        offset: target.offset
+                    )
+                }
+                retrievedTargets = BGTargets(units: .mgdL, userPreferredUnits: .mgdL, targets: convertedTargets)
+            }
+
+            return retrievedTargets
+        }
+
+        var basalProfileOnFile: [BasalProfileEntry] {
+            storage.retrieve(OpenAPS.Settings.basalProfile, as: [BasalProfileEntry].self)
+                ?? []
+        }
+
+        var carbRatiosOnFile: CarbRatios {
+            storage.retrieve(OpenAPS.Settings.carbRatios, as: CarbRatios.self) ?? CarbRatios(units: .grams, schedule: [])
+        }
+
+        var isfOnFile: InsulinSensitivities {
+            var retrievedSensitivities = storage.retrieve(OpenAPS.Settings.insulinSensitivities, as: InsulinSensitivities.self)
+                ?? InsulinSensitivities(from: OpenAPS.defaults(for: OpenAPS.Settings.insulinSensitivities))
+                ?? InsulinSensitivities(
+                    units: .mgdL,
+                    userPreferredUnits: .mgdL,
+                    sensitivities: []
+                )
+
+            // migrate existing mmol/L Trio users from mmol/L settings to pure mg/dl settings
+            if retrievedSensitivities.units == .mmolL || retrievedSensitivities.userPreferredUnits == .mmolL {
+                let convertedSensitivities = retrievedSensitivities.sensitivities.map { isf in
+                    InsulinSensitivityEntry(
+                        sensitivity: storage.parseSettingIfMmolL(value: isf.sensitivity),
+                        offset: isf.offset,
+                        start: isf.start
+                    )
+                }
+                retrievedSensitivities = InsulinSensitivities(
+                    units: .mgdL,
+                    userPreferredUnits: .mgdL,
+                    sensitivities: convertedSensitivities
+                )
+            }
+
+            return retrievedSensitivities
+        }
+
+        var pumpSettingsFromFile: PumpSettings? {
+            storage.retrieve(OpenAPS.Settings.settings, as: PumpSettings.self)
+                ?? PumpSettings(from: OpenAPS.defaults(for: OpenAPS.Settings.settings))
+        }
+    }
+}

+ 266 - 0
Trio/Sources/Modules/Onboarding/OnboardingStateModel+Nightscout.swift

@@ -0,0 +1,266 @@
+import Combine
+import Foundation
+import SwiftUI
+
+// MARK: - Setup Nightscout Connection
+
+extension Onboarding.StateModel {
+    func connectToNightscout() {
+        if let CheckURL = nightscoutUrl.last, CheckURL == "/" {
+            let fixedURL = nightscoutUrl.dropLast()
+            nightscoutUrl = String(fixedURL)
+        }
+
+        guard let nightscoutUrl = URL(string: nightscoutUrl), self.nightscoutUrl.hasPrefix("https://") else {
+            nightscoutResponseMessage = "Invalid URL"
+            isValidNightscoutURL = false
+            return
+        }
+
+        isConnectingToNS = true
+        isValidNightscoutURL = true
+        nightscoutResponseMessage = ""
+
+        NightscoutAPI(url: nightscoutUrl, secret: nightscoutSecret).checkConnection()
+            .receive(on: DispatchQueue.main)
+            .sink { completion in
+                switch completion {
+                case .finished: break
+                case let .failure(error):
+                    self.nightscoutResponseMessage = "Error: \(error.localizedDescription)"
+                }
+                DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
+                    self.isConnectingToNS = false
+                }
+            } receiveValue: {
+                self.keychain.setValue(self.nightscoutUrl, forKey: NightscoutConfig.Config.urlKey)
+                self.keychain.setValue(self.nightscoutSecret, forKey: NightscoutConfig.Config.secretKey)
+                self.isConnectedToNS = true
+            }
+            .store(in: &lifetime)
+    }
+
+    var nightscoutAPI: NightscoutAPI? {
+        guard let urlString = keychain.getValue(String.self, forKey: NightscoutConfig.Config.urlKey),
+              let url = URL(string: urlString),
+              let secret = keychain.getValue(String.self, forKey: NightscoutConfig.Config.secretKey)
+        else {
+            return nil
+        }
+        return NightscoutAPI(url: url, secret: secret)
+    }
+
+    func importSettingsFromNightscout(currentStep: Binding<OnboardingStep>) async {
+        guard nightscoutAPI != nil, isConnectedToNS else {
+            return
+        }
+
+        nightscoutImportStatus = .running
+
+        do {
+            guard let fetchedProfile = await nightscoutManager.importSettings() else {
+                await MainActor.run {
+                    nightscoutImportStatus = .failed
+                }
+                throw NSError(
+                    domain: "ImportError",
+                    code: 1,
+                    userInfo: [NSLocalizedDescriptionKey: "Cannot find the default Nightscout Profile."]
+                )
+            }
+
+            // determine, i.e. guesstimate, whether fetched values are mmol/L or mg/dL values
+            let shouldConvertToMgdL = fetchedProfile.units.contains("mmol") || fetchedProfile.target_low
+                .contains(where: { $0.value <= 39 }) || fetchedProfile.target_high.contains(where: { $0.value <= 39 })
+
+            // Carb Ratios
+            let carbratios = fetchedProfile.carbratio.map { carbratio in
+                CarbRatioEntry(
+                    start: carbratio.time,
+                    offset: offset(carbratio.time) / 60,
+                    ratio: carbratio.value
+                )
+            }
+
+            if carbratios.contains(where: { $0.ratio <= 0 }) {
+                await MainActor.run {
+                    nightscoutImportStatus = .failed
+                }
+                throw NSError(
+                    domain: "ImportError",
+                    code: 2,
+                    userInfo: [NSLocalizedDescriptionKey: "Invalid Carb Ratio settings in Nightscout. Import aborted."]
+                )
+            }
+
+            let carbratiosProfile = CarbRatios(units: .grams, schedule: carbratios)
+
+            // Basal Profile
+            let basals = fetchedProfile.basal.map { basal in
+                BasalProfileEntry(
+                    start: basal.time,
+                    minutes: offset(basal.time) / 60,
+                    rate: basal.value
+                )
+            }
+
+            if basals.contains(where: { $0.rate <= 0 }) {
+                await MainActor.run {
+                    nightscoutImportStatus = .failed
+                }
+                throw NSError(
+                    domain: "ImportError",
+                    code: 3,
+                    userInfo: [NSLocalizedDescriptionKey: "Invalid Nightscout basal rates found. Import aborted."]
+                )
+            }
+
+            if basals.reduce(0, { $0 + $1.rate }) <= 0 {
+                await MainActor.run {
+                    nightscoutImportStatus = .failed
+                }
+                throw NSError(
+                    domain: "ImportError",
+                    code: 4,
+                    userInfo: [
+                        NSLocalizedDescriptionKey: "Invalid Nightscout basal rates found. Basal rate total cannot be 0 U/hr. Import aborted."
+                    ]
+                )
+            }
+
+            // Sensitivities
+            let sensitivities = fetchedProfile.sens.map { sensitivity in
+                InsulinSensitivityEntry(
+                    sensitivity: shouldConvertToMgdL ? correctUnitParsingOffsets(sensitivity.value.asMgdL) : sensitivity
+                        .value,
+                    offset: offset(sensitivity.time) / 60,
+                    start: sensitivity.time
+                )
+            }
+
+            if sensitivities.contains(where: { $0.sensitivity <= 0 }) {
+                await MainActor.run {
+                    nightscoutImportStatus = .failed
+                }
+                throw NSError(
+                    domain: "ImportError",
+                    code: 5,
+                    userInfo: [NSLocalizedDescriptionKey: "Invalid Nightscout insulin sensitivity profile. Import aborted."]
+                )
+            }
+
+            let sensitivitiesProfile = InsulinSensitivities(
+                units: .mgdL,
+                userPreferredUnits: .mgdL,
+                sensitivities: sensitivities
+            )
+
+            // Targets
+            let targets = fetchedProfile.target_low.map { target in
+                BGTargetEntry(
+                    low: shouldConvertToMgdL ? correctUnitParsingOffsets(target.value.asMgdL) : target.value,
+                    high: shouldConvertToMgdL ? correctUnitParsingOffsets(target.value.asMgdL) : target.value,
+                    start: target.time,
+                    offset: offset(target.time) / 60
+                )
+            }
+
+            let targetsProfile = BGTargets(units: .mgdL, userPreferredUnits: .mgdL, targets: targets)
+
+            // Store therapy settings in-memory in state model for further review
+            finalizeImport(
+                targets: targetsProfile,
+                basals: basals,
+                carbRatios: carbratiosProfile,
+                sensitivities: sensitivitiesProfile,
+                userPreferredUnitsFromImport: fetchedProfile.units,
+                currentStep: currentStep
+            )
+        } catch {
+            await MainActor.run {
+                self.nightscoutImportErrors.append(error.localizedDescription)
+                debug(.service, "Settings import failed with error: \(error.localizedDescription)")
+            }
+        }
+    }
+
+    fileprivate func finalizeImport(
+        targets targetsProfile: BGTargets,
+        basals: [BasalProfileEntry],
+        carbRatios carbratiosProfile: CarbRatios,
+        sensitivities sensitivitiesProfile: InsulinSensitivities,
+        userPreferredUnitsFromImport: String,
+        currentStep: Binding<OnboardingStep>
+    ) {
+        /// First, very important: assign `units` so that `xxxRateValues` contain the proper values
+        /// and array has the correct number of elements.
+        /// If not done here, this may lead to index-out-of-bound errors for users importing mmol/L settings.
+        units = userPreferredUnitsFromImport.contains("mmol") ? .mmolL : .mgdL
+
+        // Parse: targetsProfile → targetItems
+        targetItems = targetsProfile.targets.map { entry in
+            let timeIndex = targetTimeValues.firstIndex(where: { Int($0) == entry.offset * 60 }) ?? 0
+            let lowIndex = targetRateValues.enumerated().min(by: {
+                abs($0.element - entry.low) < abs($1.element - entry.low)
+            })?.offset ?? 0
+
+            return TargetsEditor.Item(lowIndex: lowIndex, highIndex: lowIndex, timeIndex: timeIndex)
+        }
+        initialTargetItems = targetItems
+
+        // Parse: basals → basalProfileItems
+        basalProfileItems = basals.map { entry in
+            let timeIndex = basalProfileTimeValues.firstIndex(where: { Int($0) == entry.minutes * 60 }) ?? 0
+            let rateIndex = basalProfileRateValues.enumerated().min(by: {
+                abs($0.element - entry.rate) < abs($1.element - entry.rate)
+            })?.offset ?? 0
+            return BasalProfileEditor.Item(rateIndex: rateIndex, timeIndex: timeIndex)
+        }
+        initialBasalProfileItems = basalProfileItems
+
+        // Parse: carbratiosProfile → carbRatioItems
+        carbRatioItems = carbratiosProfile.schedule.map { entry in
+            let timeIndex = carbRatioTimeValues.firstIndex(where: { Int($0) == entry.offset * 60 }) ?? 0
+            let rateIndex = carbRatioRateValues.enumerated().min(by: {
+                abs($0.element - entry.ratio) < abs($1.element - entry.ratio)
+            })?.offset ?? 0
+            return CarbRatioEditor.Item(rateIndex: rateIndex, timeIndex: timeIndex)
+        }
+        initialCarbRatioItems = carbRatioItems
+
+        // Parse: sensitivitiesProfile → isfItems
+        isfItems = sensitivitiesProfile.sensitivities.map { entry in
+            let timeIndex = isfTimeValues.firstIndex(where: { Int($0) == entry.offset * 60 }) ?? 0
+            let rateIndex = isfRateValues.enumerated().min(by: {
+                abs($0.element - entry.sensitivity) < abs($1.element - entry.sensitivity)
+            })?.offset ?? 0
+
+            return ISFEditor.Item(rateIndex: rateIndex, timeIndex: timeIndex)
+        }
+        initialISFItems = isfItems
+
+        DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
+            self.nightscoutImportStatus = .finished
+            // navigate to the next onboarding step
+            if let next = currentStep.wrappedValue.next {
+                currentStep.wrappedValue = next
+            }
+        }
+    }
+
+    fileprivate func correctUnitParsingOffsets(_ parsedValue: Decimal) -> Decimal {
+        Int(parsedValue) % 2 == 0 ? parsedValue : parsedValue + 1
+    }
+
+    fileprivate func offset(_ string: String) -> Int {
+        let hours = Int(string.prefix(2)) ?? 0
+        let minutes = Int(string.suffix(2)) ?? 0
+        return ((hours * 60) + minutes) * 60
+    }
+
+    enum ImportStatus {
+        case running
+        case finished
+        case failed
+    }
+}

+ 753 - 0
Trio/Sources/Modules/Onboarding/OnboardingStateModel.swift

@@ -0,0 +1,753 @@
+import Combine
+import DanaKit
+import FirebaseCrashlytics
+import Foundation
+import LoopKit
+import MinimedKit
+import Observation
+import OmniBLE
+import OmniKit
+import SwiftUI
+
+/// Model that holds the data collected during onboarding.
+extension Onboarding {
+    @Observable final class StateModel: BaseStateModel<Provider> {
+        @ObservationIgnored @Injected() var fileStorage: FileStorage!
+        @ObservationIgnored @Injected() var deviceManager: DeviceDataManager!
+        @ObservationIgnored @Injected() var broadcaster: Broadcaster!
+        @ObservationIgnored @Injected() var keychain: Keychain!
+        @ObservationIgnored @Injected() var nightscoutManager: NightscoutManager!
+        @ObservationIgnored @Injected() var notificationsManager: UserNotificationsManager!
+        @ObservationIgnored @Injected() var bluetoothManager: BluetoothStateManager!
+        @ObservationIgnored @Injected() var apsManager: APSManager!
+
+        private let settingsProvider = PickerSettingsProvider.shared
+
+        // MARK: - App Diagnostics
+
+        var diagnosticsSharingOption: DiagnosticsSharingOption {
+            get { (PropertyPersistentFlags.shared.diagnosticsSharingEnabled ?? true) ? .enabled : .disabled }
+            set { PropertyPersistentFlags.shared.diagnosticsSharingEnabled = (newValue == .enabled) }
+        }
+
+        var hasAcceptedPrivacyPolicy: Bool = false
+
+        // MARK: - Determine Initial Build State
+
+        /// Determines whether the app is in a fresh install state for Trio v0.3.0.
+        ///
+        /// This check is based on the assumption that a truly clean install will only contain
+        /// the `logs/` directory and the `preferences.json` file in the app's Documents directory.
+        ///
+        /// If this condition is met, the onboarding flow skips the `.returningUser` step and treats
+        /// the user as new. If more files or directories are found, it is assumed the user is returning.
+        ///
+        /// Note: This check is not directly connected to a completed migration. However, if a migration
+        /// has been triggered (whether successful or not), additional files such as treatment JSONs
+        /// will exist, which naturally causes this check to return `false`.
+        var isFreshTrioInstall: Bool {
+            let fileManager = FileManager.default
+            guard let documentsURL = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first else {
+                return false
+            }
+
+            debug(.default, "Checking for fresh install in \(documentsURL.path)...")
+
+            let expectedLogsFolder = "logs"
+            let expectedPreferencesFile = OpenAPS.Settings.preferences
+
+            do {
+                let contents = try fileManager.contentsOfDirectory(atPath: documentsURL.path)
+
+                debug(.default, "Found \(contents) in \(documentsURL.path)...")
+
+                // Expect exactly 2 entries: "logs" and the preferences file
+                guard contents.count == 2 else {
+                    debug(.default, "Trio install is not fresh; returning user.")
+                    return false
+                }
+
+                // Ensure they match exactly
+                let expectedSet = Set([expectedLogsFolder, expectedPreferencesFile])
+                let actualSet = Set(contents)
+
+                debug(.default, "Expected: \(expectedSet), Actual: \(actualSet)")
+
+                let isFreshInstall = expectedSet == actualSet
+                debug(.default, "Trio install is fresh; new user.")
+
+                return isFreshInstall
+
+            } catch {
+                debug(.default, "Cannot determine Initial Build State. Failed to read documents directory: \(error)")
+                return false
+            }
+        }
+
+        // MARK: - Nightscout Setup
+
+        var nightscoutSetupOption: NightscoutSetupOption = .noSelection
+        var nightscoutImportOption: NightscoutImportOption = .noSelection
+        var nightscoutUrl = ""
+        var nightscoutSecret = ""
+        var nightscoutResponseMessage = ""
+        var isValidNightscoutURL: Bool = false
+        var isConnectingToNS: Bool = false
+        var isConnectedToNS: Bool = false
+        var nightscoutImportErrors: [String] = []
+        var nightscoutImportStatus: ImportStatus = .finished
+
+        // MARK: - Units and Pump Omboarding Option
+
+        var units: GlucoseUnits = .mgdL
+        private var selectedPumpOption: PumpOptionForOnboardingUnits?
+        var pumpOptionForOnboardingUnits: PumpOptionForOnboardingUnits {
+            get {
+                // let user edit selection and return user-selection, if present
+                if let selected = selectedPumpOption {
+                    return selected
+                }
+
+                let defaultOption: PumpOptionForOnboardingUnits
+                if let pumpManager = apsManager?.pumpManager {
+                    if pumpManager is OmniBLEPumpManager {
+                        defaultOption = .omnipodDash
+                    } else if pumpManager is OmnipodPumpManager {
+                        defaultOption = .omnipodEros
+                    } else if pumpManager is DanaKitPumpManager {
+                        defaultOption = .dana
+                    } else if pumpManager is MinimedPumpManager {
+                        defaultOption = .minimed
+                    } else {
+                        defaultOption = .omnipodDash
+                    }
+                } else {
+                    defaultOption = .omnipodDash
+                }
+
+                // cache it so picker can stay in sync
+                selectedPumpOption = defaultOption
+
+                return defaultOption
+            }
+            set {
+                selectedPumpOption = newValue
+            }
+        }
+
+        // MARK: - Time Values (shared)
+
+        let sharedTimeValues = stride(from: 0.0, to: 1.days.timeInterval, by: 30.minutes.timeInterval).map { $0 }.sorted()
+
+        // MARK: - Carb Ratio
+
+        let carbRatioPickerSetting = PickerSetting(value: 30, step: 0.1, min: 1, max: 50, type: .gram)
+        var carbRatioItems: [CarbRatioEditor.Item] = []
+        var initialCarbRatioItems: [CarbRatioEditor.Item] = []
+        var carbRatioTimeValues: [TimeInterval] { sharedTimeValues }
+        var carbRatioRateValues: [Decimal] { settingsProvider.generatePickerValues(from: carbRatioPickerSetting, units: units) }
+
+        // MARK: - Basal Profile
+
+        var basalRatePickerSetting: PickerSetting {
+            switch selectedPumpOption {
+            case .dana:
+                return PickerSetting(value: 0.1, step: 0.05, min: 0, max: 3, type: .insulinUnitPerHour)
+            case .minimed:
+                return PickerSetting(value: 0.1, step: 0.05, min: 0, max: 35, type: .insulinUnitPerHour)
+            case .omnipodDash:
+                return PickerSetting(value: 0.1, step: 0.05, min: 0, max: 30, type: .insulinUnitPerHour)
+            case .omnipodEros:
+                return PickerSetting(value: 0.1, step: 0.05, min: 0.05, max: 30, type: .insulinUnitPerHour)
+            case .none:
+                // same as dash, as that is the fallback
+                return PickerSetting(value: 0.1, step: 0.05, min: 0, max: 30, type: .insulinUnitPerHour)
+            }
+        }
+
+        var basalProfileItems: [BasalProfileEditor.Item] = []
+        var initialBasalProfileItems: [BasalProfileEditor.Item] = []
+        var basalProfileTimeValues: [TimeInterval] { sharedTimeValues }
+        var basalProfileRateValues: [Decimal] { settingsProvider.generatePickerValues(from: basalRatePickerSetting, units: units)
+        }
+
+        // MARK: - Insulin Sensitivity Factor (ISF)
+
+        var sensitivityPickerSetting = PickerSetting(value: 200, step: 1, min: 9, max: 540, type: .glucose)
+        var isfItems: [ISFEditor.Item] = []
+        var initialISFItems: [ISFEditor.Item] = []
+        var isfTimeValues: [TimeInterval] { sharedTimeValues }
+        var isfRateValues: [Decimal] { settingsProvider.generatePickerValues(from: sensitivityPickerSetting, units: units) }
+
+        // MARK: - Glucose Targets
+
+        let letTargetPickerSetting = PickerSetting(value: 110, step: 1, min: 72, max: 180, type: .glucose)
+        var targetItems: [TargetsEditor.Item] = []
+        var initialTargetItems: [TargetsEditor.Item] = []
+        var targetTimeValues: [TimeInterval] { sharedTimeValues }
+        var targetRateValues: [Decimal] { settingsProvider.generatePickerValues(from: letTargetPickerSetting, units: units) }
+
+        // MARK: - Delivery Limit Defaults
+
+        var maxBolus: Decimal = 10
+        var maxBasal: Decimal = 2
+        var maxIOB: Decimal = 0
+        var maxCOB: Decimal = 120
+        var minimumSafetyThreshold: Decimal = 60
+
+        // MARK: - Algorithm Settings Defaults
+
+        // Autosens Settings
+        var autosensMin: Decimal = 0.7
+        var autosensMax: Decimal = 1.2
+        var rewindResetsAutosens: Bool = true
+
+        var filteredAutosensSettingsSubsteps: [AutosensSettingsSubstep] {
+            if pumpOptionForOnboardingUnits == .minimed || pumpOptionForOnboardingUnits == .dana {
+                return AutosensSettingsSubstep.allCases
+            } else {
+                return [AutosensSettingsSubstep.autosensMin, AutosensSettingsSubstep.autosensMax]
+            }
+        }
+
+        // SMB Settings
+        var enableSMBAlways: Bool = false
+        var enableSMBWithCOB: Bool = false
+        var enableSMBWithTempTarget: Bool = false
+        var enableSMBAfterCarbs: Bool = false
+        var enableSMBWithHighGlucoseTarget: Bool = false
+        var highGlucoseTarget: Decimal = 110
+        var allowSMBWithHighTempTarget: Bool = false
+        var enableUAM: Bool = false
+        var maxSMBMinutes: Decimal = 30
+        var maxUAMMinutes: Decimal = 30
+        var maxDeltaGlucoseThreshold: Decimal = 0.2
+
+        // Target Behavior
+        var highTempTargetRaisesSensitivity: Bool = false
+        var lowTempTargetLowersSensitivity: Bool = false
+        var sensitivityRaisesTarget: Bool = false
+        var resistanceLowersTarget: Bool = false
+        var halfBasalTarget: Decimal = 160
+
+        // MARK: - Permission Requests
+
+        var hasNotificationsGranted = false
+        var shouldDisplayCustomNotificationAlert: Bool = false
+
+        var shouldDisplayBluetoothRequestAlert: Bool = false
+        var hasBluetoothGranted = false
+
+        // MARK: - Subscribe
+
+        override func subscribe() {
+            // Keychain items are not removed, even after uninstalling the app. Attempt to read them initially.
+            nightscoutUrl = keychain.getValue(String.self, forKey: NightscoutConfig.Config.urlKey) ?? ""
+            nightscoutSecret = keychain.getValue(String.self, forKey: NightscoutConfig.Config.secretKey) ?? ""
+            isConnectedToNS = false
+            isConnectingToNS = false
+            isValidNightscoutURL = false
+
+            // Attempt to fetch existing units, therapy settings and delivery limits from file
+            units = settingsManager.settings.units
+            fetchExistingTherapySettingsFromFile()
+            fetchExistingDeliveryLimtisFromFile()
+        }
+
+        // MARK: - Helpers
+
+        /// Finds the index of the closest `Decimal` value in the given array.
+        /// - Parameters:
+        ///   - value: The value to match.
+        ///   - array: The array to search in.
+        /// - Returns: Closest index in array.
+        func closestIndex(for value: Decimal, in array: [Decimal]) -> Int {
+            array.enumerated().min(by: {
+                abs($0.element - value) < abs($1.element - value)
+            })?.offset ?? 0
+        }
+
+        /// Finds the index of the closest `TimeInterval` value in the given array.
+        /// - Parameters:
+        ///   - value: The time value to match.
+        ///   - array: The array to search in.
+        /// - Returns: Closest index in array.
+        func closestIndex(for value: TimeInterval, in array: [TimeInterval]) -> Int {
+            array.enumerated().min(by: {
+                abs($0.element - value) < abs($1.element - value)
+            })?.offset ?? 0
+        }
+
+        /// A date formatter for time strings used in saved settings.
+        private var timeFormatter: DateFormatter {
+            let formatter = DateFormatter()
+            formatter.timeZone = TimeZone(secondsFromGMT: 0)
+            formatter.dateFormat = "HH:mm:ss"
+            return formatter
+        }
+
+        /// Remaps therapy items affected by a glucose unit change (mg/dL vs mmol/L).
+        ///
+        /// This function updates glucose target and insulin sensitivity (ISF) items to use the closest valid index
+        /// from the newly available rate arrays, preserving the original value intent.
+        ///
+        /// Call this after the user changes the unit selection.
+        ///
+        /// See also: `UnitSelectionStepView` `.onChange()` handlers.
+        func remapTherapyItemsForChangedUnits() {
+            // Targets
+            targetItems = targetItems.map { item in
+                let newLowIndex = closestIndex(for: targetRateValues[item.lowIndex], in: targetRateValues)
+                let newTimeIndex = closestIndex(for: targetTimeValues[item.timeIndex], in: targetTimeValues)
+                return TargetsEditor.Item(lowIndex: newLowIndex, highIndex: newLowIndex, timeIndex: newTimeIndex)
+            }
+
+            // ISF
+            isfItems = isfItems.map { item in
+                let newRateIndex = closestIndex(for: isfRateValues[item.rateIndex], in: isfRateValues)
+                let newTimeIndex = closestIndex(for: isfTimeValues[item.timeIndex], in: isfTimeValues)
+                return ISFEditor.Item(rateIndex: newRateIndex, timeIndex: newTimeIndex)
+            }
+        }
+
+        /// Remaps therapy items affected by a pump model change.
+        ///
+        /// Updates basal profile items to use the closest valid index from
+        /// the updated basal rate and time arrays, preserving the user's settings
+        /// as closely as possible when switching between pump models.
+        ///
+        /// If an imported item's `rateIndex` or `timeIndex` exceeds the bounds of the
+        /// current pump's allowed values, it is clamped to the last valid index to avoid
+        /// crashes and preserve data integrity. A debug message is logged if clamping occurs.
+        ///
+        /// Call this after the user selects a new pump model.
+        ///
+        /// See also: `UnitSelectionStepView` `.onChange()` handlers.
+        func remapTherapyItemsForChangedPumpModel() {
+            let maxValidRateIndex = max(basalProfileRateValues.count - 1, 0)
+            let maxValidTimeIndex = max(basalProfileTimeValues.count - 1, 0)
+
+            basalProfileItems = basalProfileItems.map { item in
+                let safeRateIndex = min(item.rateIndex, maxValidRateIndex)
+                let safeTimeIndex = min(item.timeIndex, maxValidTimeIndex)
+
+                let originalRate = basalProfileRateValues[safeRateIndex]
+                let originalTime = basalProfileTimeValues[safeTimeIndex]
+
+                let newRateIndex = closestIndex(for: originalRate, in: basalProfileRateValues)
+                let newTimeIndex = closestIndex(for: originalTime, in: basalProfileTimeValues)
+
+                if safeRateIndex != item.rateIndex {
+                    debug(.default, "⚠️ rateIndex \(item.rateIndex) out of bounds; clamped to \(safeRateIndex)")
+                }
+
+                return BasalProfileEditor.Item(rateIndex: newRateIndex, timeIndex: newTimeIndex)
+            }
+        }
+
+        // MARK: - Fetch existing therapy settings from file
+
+        /// Loads existing therapy settings from the provider and maps them into UI editor items.
+        ///
+        /// This function processes therapy-related configurations (glucose targets, basal rates,
+        /// carb ratios, and insulin sensitivity factors) stored in file-backed models from the provider.
+        /// It calculates the closest matching indices for time and rate values to map them to corresponding
+        /// `Editor.Item` models for use in the UI.
+        ///
+        /// - Populates:
+        ///   - `targetItems` and `initialTargetItems` with glucose target entries.
+        ///   - `basalProfileItems` and `initialBasalProfileItems` with basal rate entries.
+        ///   - `carbRatioItems` and `initialCarbRatioItems` with carbohydrate ratio entries.
+        ///   - `isfItems` and `initialISFItems` with insulin sensitivity factor entries.
+        func fetchExistingTherapySettingsFromFile() {
+            targetItems = provider.glucoseTargetsOnFile.targets.map { value in
+                let timeIndex = closestIndex(for: TimeInterval(Double(value.offset * 60)), in: targetTimeValues)
+                let lowIndex = closestIndex(for: value.low, in: targetRateValues)
+                let highIndex = closestIndex(for: value.high, in: targetRateValues)
+                return TargetsEditor.Item(lowIndex: lowIndex, highIndex: highIndex, timeIndex: timeIndex)
+            }
+            initialTargetItems = targetItems
+                .map { TargetsEditor.Item(lowIndex: $0.lowIndex, highIndex: $0.highIndex, timeIndex: $0.timeIndex) }
+
+            basalProfileItems = provider.basalProfileOnFile.map { value in
+                let timeIndex = closestIndex(for: TimeInterval(Double(value.minutes * 60)), in: basalProfileTimeValues)
+                let rateIndex = closestIndex(for: value.rate, in: basalProfileRateValues)
+                return BasalProfileEditor.Item(rateIndex: rateIndex, timeIndex: timeIndex)
+            }
+            initialBasalProfileItems = basalProfileItems
+                .map { BasalProfileEditor.Item(rateIndex: $0.rateIndex, timeIndex: $0.timeIndex) }
+
+            carbRatioItems = provider.carbRatiosOnFile.schedule.map { value in
+                let timeIndex = closestIndex(for: TimeInterval(Double(value.offset * 60)), in: carbRatioTimeValues)
+                let rateIndex = closestIndex(for: value.ratio, in: carbRatioRateValues)
+                return CarbRatioEditor.Item(rateIndex: rateIndex, timeIndex: timeIndex)
+            }
+
+            initialCarbRatioItems = carbRatioItems.map { CarbRatioEditor.Item(rateIndex: $0.rateIndex, timeIndex: $0.timeIndex) }
+
+            isfItems = provider.isfOnFile.sensitivities.map { value in
+                let timeIndex = closestIndex(for: TimeInterval(Double(value.offset * 60)), in: isfTimeValues)
+                let rateIndex = closestIndex(for: value.sensitivity, in: isfRateValues)
+
+                return ISFEditor.Item(rateIndex: rateIndex, timeIndex: timeIndex)
+            }
+
+            initialISFItems = isfItems.map { ISFEditor.Item(rateIndex: $0.rateIndex, timeIndex: $0.timeIndex) }
+        }
+
+        /// Loads delivery limit settings (Units, Max IOB, Max COB, Max Bolus, Max Basal) from the provider.
+        ///
+        /// Retrieves pump-related safety and delivery limits from both the provider's
+        /// file-backed pump settings and app-specific preferences. These values are used
+        /// to pre-fill the delivery limits editor in the onboarding or settings UI.
+        ///
+        /// - Populates:
+        ///   - `maxBolus` and `maxBasal` from file-based pump settings.
+        ///   - `maxIOB`, `maxCOB`, and `minimumSafetyThreshold` from app preferences.
+        ///   - `units` from app settings.
+        func fetchExistingDeliveryLimtisFromFile() {
+            let pumpSettingsFromFile = provider.pumpSettingsFromFile
+
+            if let pumpSettingsFromFile = pumpSettingsFromFile {
+                maxBolus = pumpSettingsFromFile.maxBolus
+                maxBasal = pumpSettingsFromFile.maxBasal
+            }
+
+            let preferences = settingsManager.preferences
+            maxIOB = preferences.maxIOB
+            maxCOB = preferences.maxCOB
+            minimumSafetyThreshold = preferences.threshold_setting
+        }
+
+        // MARK: - Get Therapy Items
+
+        /// Converts ISF editor items to a list of `TherapySettingItem`.
+        /// - Returns: Sorted list of therapy setting items based on ISF.
+        func getISFTherapyItems() -> [TherapySettingItem] {
+            getTherapyItems(from: isfItems, rateValues: isfRateValues, timeValues: isfTimeValues)
+        }
+
+        /// Converts basal profile editor items to a list of `TherapySettingItem`.
+        /// - Returns: Sorted list of therapy setting items based on basal rates.
+        func getBasalTherapyItems() -> [TherapySettingItem] {
+            getTherapyItems(
+                from: basalProfileItems,
+                rateValues: basalProfileRateValues,
+                timeValues: basalProfileTimeValues
+            )
+        }
+
+        /// Converts carb ratio editor items to a list of `TherapySettingItem`.
+        /// - Returns: Sorted list of therapy setting items based on carb ratios.
+        func getCarbRatioTherapyItems() -> [TherapySettingItem] {
+            getTherapyItems(from: carbRatioItems, rateValues: carbRatioRateValues, timeValues: carbRatioTimeValues)
+        }
+
+        /// Converts glucose target editor items to a list of `TherapySettingItem`.
+        /// - Returns: Sorted list of therapy setting items based on glucose targets.
+        func getTargetTherapyItems() -> [TherapySettingItem] {
+            targetItems.map {
+                TherapySettingItem(
+                    time: targetTimeValues[$0.timeIndex],
+                    value: targetRateValues[$0.lowIndex]
+                )
+            }.sorted { $0.time < $1.time }
+        }
+
+        /// Generic helper to convert any type of editor item into therapy setting items.
+        /// - Parameters:
+        ///   - items: An array of items conforming to `TherapyItemConvertible`.
+        ///   - rateValues: The rate values to be used.
+        ///   - timeValues: The time values to be used.
+        /// - Returns: A sorted array of `TherapySettingItem`.
+        private func getTherapyItems<T: TherapyItemConvertible>(
+            from items: [T],
+            rateValues: [Decimal],
+            timeValues: [TimeInterval]
+        ) -> [TherapySettingItem] {
+            items.map {
+                TherapySettingItem(
+                    time: timeValues[$0.timeIndex],
+                    value: rateValues[$0.rateIndex]
+                )
+            }.sorted { $0.time < $1.time }
+        }
+
+        // MARK: - Unified Update Methods
+
+        /// Updates the ISF editor items based on the provided therapy setting items.
+        /// - Parameter therapyItems: The list of therapy items to update from.
+        func updateISF(from therapyItems: [TherapySettingItem]) {
+            isfItems = therapyItems.map {
+                ISFEditor.Item(
+                    rateIndex: closestIndex(for: $0.value, in: isfRateValues),
+                    timeIndex: closestIndex(for: $0.time, in: isfTimeValues)
+                )
+            }.sorted { $0.timeIndex < $1.timeIndex }
+        }
+
+        /// Updates the basal rate editor items based on the provided therapy setting items.
+        /// - Parameter therapyItems: The list of therapy items to update from.
+        func updateBasal(from therapyItems: [TherapySettingItem]) {
+            basalProfileItems = therapyItems.map {
+                BasalProfileEditor.Item(
+                    rateIndex: closestIndex(for: $0.value, in: basalProfileRateValues),
+                    timeIndex: closestIndex(for: $0.time, in: basalProfileTimeValues)
+                )
+            }.sorted { $0.timeIndex < $1.timeIndex }
+        }
+
+        /// Updates the carb ratio editor items based on the provided therapy setting items.
+        /// - Parameter therapyItems: The list of therapy items to update from.
+        func updateCarbRatio(from therapyItems: [TherapySettingItem]) {
+            carbRatioItems = therapyItems.map {
+                CarbRatioEditor.Item(
+                    rateIndex: closestIndex(for: $0.value, in: carbRatioRateValues),
+                    timeIndex: closestIndex(for: $0.time, in: carbRatioTimeValues)
+                )
+            }.sorted { $0.timeIndex < $1.timeIndex }
+        }
+
+        /// Updates the glucose target editor items based on the provided therapy setting items.
+        /// - Parameter therapyItems: The list of therapy items to update from.
+        func updateTargets(from therapyItems: [TherapySettingItem]) {
+            targetItems = therapyItems.map {
+                let rateIndex = closestIndex(for: $0.value, in: targetRateValues)
+                let timeIndex = closestIndex(for: $0.time, in: targetTimeValues)
+
+                return TargetsEditor.Item(
+                    lowIndex: rateIndex,
+                    highIndex: rateIndex,
+                    timeIndex: timeIndex
+                )
+            }.sorted { $0.timeIndex < $1.timeIndex }
+        }
+
+        // MARK: - Add Initials
+
+        /// Adds a default ISF editor item at 00:00 with a standard sensitivity value.
+        func addInitialISF() {
+            addInitialItem(
+                defaultValue: 200,
+                rateValues: isfRateValues,
+                assign: { isfItems = $0 },
+                makeItem: ISFEditor.Item.init
+            )
+        }
+
+        /// Adds a default basal rate editor item at 00:00 with a typical rate value.
+        func addInitialBasalRate() {
+            addInitialItem(
+                defaultValue: 0.1,
+                rateValues: basalProfileRateValues,
+                assign: { basalProfileItems = $0 },
+                makeItem: BasalProfileEditor.Item.init
+            )
+        }
+
+        /// Adds a default carb ratio editor item at 00:00 with a standard ratio.
+        func addInitialCarbRatio() {
+            addInitialItem(
+                defaultValue: 30,
+                rateValues: carbRatioRateValues,
+                assign: { carbRatioItems = $0 },
+                makeItem: CarbRatioEditor.Item.init
+            )
+        }
+
+        /// Adds a default glucose target item at 00:00 with a typical target value.
+        func addInitialTarget() {
+            let timeIndex = 0
+            let rateIndex = closestIndex(for: 110, in: targetRateValues)
+            targetItems = [TargetsEditor.Item(lowIndex: rateIndex, highIndex: rateIndex, timeIndex: timeIndex)]
+        }
+
+        /// Adds an initial therapy setting item for a given editor item type.
+        /// - Parameters:
+        ///   - defaultValue: The expected default value to use.
+        ///   - rateValues: The array of rate values for the item.
+        ///   - assign: A closure that assigns the newly created array to the correct property.
+        private func addInitialItem<ItemType>(
+            defaultValue: Decimal,
+            rateValues: [Decimal],
+            assign: ([ItemType]) -> Void,
+            makeItem: (Int, Int) -> ItemType
+        ) {
+            let timeIndex = 0
+            let rateIndex = closestIndex(for: defaultValue, in: rateValues)
+            assign([makeItem(rateIndex, timeIndex)])
+        }
+
+        // MARK: - Validate
+
+        /// Removes duplicate entries from `carbRatioItems`, ensures sorting by time index,
+        /// and forces the first entry to start at 00:00 (timeIndex 0).
+        func validateCarbRatios() {
+            carbRatioItems = validated(items: carbRatioItems, timeIndexKeyPath: \.timeIndex)
+        }
+
+        /// Removes duplicate entries from `basalProfileItems`, ensures sorting by time index,
+        /// and forces the first entry to start at 00:00 (timeIndex 0).
+        func validateBasal() {
+            basalProfileItems = validated(items: basalProfileItems, timeIndexKeyPath: \.timeIndex)
+        }
+
+        /// Removes duplicate entries from `isfItems`, ensures sorting by time index,
+        /// and forces the first entry to start at 00:00 (timeIndex 0).
+        func validateISF() {
+            isfItems = validated(items: isfItems, timeIndexKeyPath: \.timeIndex)
+        }
+
+        /// Removes duplicate entries from `targetItems`, ensures sorting by time index,
+        /// and forces the first entry to start at 00:00 (timeIndex 0).
+        func validateTarget() {
+            targetItems = validated(items: targetItems, timeIndexKeyPath: \.timeIndex)
+        }
+
+        /// Removes duplicates, sorts by time, and ensures the first entry starts at 00:00.
+        /// - Parameters:
+        ///   - items: The list of items to validate.
+        ///   - timeIndexKeyPath: A writable key path to the timeIndex property.
+        /// - Returns: A validated and sorted list of items with the first entry at 00:00.
+        private func validated<T: Hashable>(items: [T], timeIndexKeyPath: WritableKeyPath<T, Int>) -> [T] {
+            var result = Array(Set(items)).sorted { $0[keyPath: timeIndexKeyPath] < $1[keyPath: timeIndexKeyPath] }
+            if !result.isEmpty, result[0][keyPath: timeIndexKeyPath] != 0 {
+                result[0][keyPath: timeIndexKeyPath] = 0
+            }
+            return result
+        }
+
+        // MARK: - Save
+
+        /// Saves the carb ratio items to file storage and sets them as initial values.
+        func saveCarbRatios() {
+            let schedule = carbRatioItems.map { item in
+                let time = timeFormatter.string(from: Date(timeIntervalSince1970: carbRatioTimeValues[item.timeIndex]))
+                let offset = Int(carbRatioTimeValues[item.timeIndex] / 60)
+                let value = carbRatioRateValues[item.rateIndex]
+                return CarbRatioEntry(start: time, offset: offset, ratio: value)
+            }
+            fileStorage.save(CarbRatios(units: .grams, schedule: schedule), as: OpenAPS.Settings.carbRatios)
+            initialCarbRatioItems = carbRatioItems
+        }
+
+        /// Saves the basal profile items to file storage and sets them as initial values.
+        func saveBasalProfile() {
+            let profile = basalProfileItems.map { item in
+                let time = timeFormatter.string(from: Date(timeIntervalSince1970: basalProfileTimeValues[item.timeIndex]))
+                let offset = Int(basalProfileTimeValues[item.timeIndex] / 60)
+                let rate = basalProfileRateValues[item.rateIndex]
+                return BasalProfileEntry(start: time, minutes: offset, rate: rate)
+            }
+            fileStorage.save(profile, as: OpenAPS.Settings.basalProfile)
+            initialBasalProfileItems = basalProfileItems
+        }
+
+        /// Saves the insulin sensitivity (ISF) items to file storage and sets them as initial values.
+        func saveISFValues() {
+            let sensitivities = isfItems.map { item in
+                let time = timeFormatter.string(from: Date(timeIntervalSince1970: isfTimeValues[item.timeIndex]))
+                let offset = Int(isfTimeValues[item.timeIndex] / 60)
+                let value = isfRateValues[item.rateIndex]
+                return InsulinSensitivityEntry(sensitivity: value, offset: offset, start: time)
+            }
+            let profile = InsulinSensitivities(units: .mgdL, userPreferredUnits: .mgdL, sensitivities: sensitivities)
+            fileStorage.save(profile, as: OpenAPS.Settings.insulinSensitivities)
+            initialISFItems = isfItems
+        }
+
+        /// Saves the glucose target items to file storage and sets them as initial values.
+        func saveTargets() {
+            let targets = targetItems.map { item in
+                let time = timeFormatter.string(from: Date(timeIntervalSince1970: targetTimeValues[item.timeIndex]))
+                let offset = Int(targetTimeValues[item.timeIndex] / 60)
+                let value = targetRateValues[item.lowIndex]
+                return BGTargetEntry(low: value, high: value, start: time, offset: offset)
+            }
+            let profile = BGTargets(units: .mgdL, userPreferredUnits: .mgdL, targets: targets)
+            fileStorage.save(profile, as: OpenAPS.Settings.bgTargets)
+            initialTargetItems = targetItems
+        }
+
+        /// Persists all onboarding data by applying settings and saving therapy values.
+        func saveOnboardingData() {
+            applyDiagnostics()
+            applyToSettings()
+            applyToPreferences()
+            applyToPumpSettings()
+            saveTargets()
+            saveBasalProfile()
+            saveCarbRatios()
+            saveISFValues()
+        }
+
+        /// Persists the current diagnostics sharing option to UserDefaults as a boolean.
+        func applyDiagnostics() {
+            let booleanValue = diagnosticsSharingOption == .enabled
+            PropertyPersistentFlags.shared.diagnosticsSharingEnabled = booleanValue
+            Crashlytics.crashlytics().setCrashlyticsCollectionEnabled(booleanValue)
+        }
+
+        /// Applies the selected glucose units to the app's settings.
+        func applyToSettings() {
+            var settingsCopy = settingsManager.settings
+            settingsCopy.units = units
+            settingsManager.settings = settingsCopy
+        }
+
+        /// Applies the selected delivery preferences to the app's settings.
+        func applyToPreferences() {
+            var preferences = Preferences()
+
+            // delivery limits (those that are preference-bound, not pump-settings-bound
+            preferences.maxIOB = maxIOB
+            preferences.maxCOB = maxCOB
+            preferences.threshold_setting = minimumSafetyThreshold
+
+            // autosens
+            preferences.autosensMin = autosensMin
+            preferences.autosensMax = autosensMax
+            preferences.rewindResetsAutosens = rewindResetsAutosens
+
+            // smb settings
+            preferences.enableSMBAlways = enableSMBAlways
+            preferences.enableSMBWithCOB = enableSMBWithCOB
+            preferences.enableSMBWithTemptarget = enableSMBWithTempTarget
+            preferences.enableSMBAfterCarbs = enableSMBAfterCarbs
+            preferences.enableSMB_high_bg = enableSMBWithHighGlucoseTarget
+            preferences.enableSMB_high_bg_target = highGlucoseTarget
+            preferences.allowSMBWithHighTemptarget = allowSMBWithHighTempTarget
+            preferences.enableUAM = enableUAM
+            preferences.maxSMBBasalMinutes = maxSMBMinutes
+            preferences.maxUAMSMBBasalMinutes = maxUAMMinutes
+            preferences.maxDeltaBGthreshold = maxDeltaGlucoseThreshold
+
+            // target behavior
+            preferences.highTemptargetRaisesSensitivity = highTempTargetRaisesSensitivity
+            preferences.lowTemptargetLowersSensitivity = lowTempTargetLowersSensitivity
+            preferences.sensitivityRaisesTarget = sensitivityRaisesTarget
+            preferences.resistanceLowersTarget = resistanceLowersTarget
+            preferences.halfBasalExerciseTarget = halfBasalTarget
+
+            settingsManager.preferences = preferences
+        }
+
+        /// Saves pump delivery limits to persistent storage and broadcasts changes.
+        func applyToPumpSettings() {
+            let defaultDIA = settingsProvider.settings.dia.value
+            let pumpSettings = PumpSettings(insulinActionCurve: defaultDIA, maxBolus: maxBolus, maxBasal: maxBasal)
+            fileStorage.save(pumpSettings, as: OpenAPS.Settings.settings)
+        }
+    }
+}
+
+// MARK: - Protocol (optional) to unify type mapping
+
+protocol TherapyItemConvertible {
+    var rateIndex: Int { get }
+    var timeIndex: Int { get }
+}
+
+extension ISFEditor.Item: TherapyItemConvertible {}
+extension CarbRatioEditor.Item: TherapyItemConvertible {}
+extension BasalProfileEditor.Item: TherapyItemConvertible {}

+ 0 - 0
Trio/Sources/Modules/Onboarding/View/Animations/LogoBurstSplash.swift


部分文件因文件數量過多而無法顯示