Przeglądaj źródła

Merge 'dev' into 'oref-swift'

Marvin Polscheit 1 rok temu
rodzic
commit
c606cd3d43
100 zmienionych plików z 57156 dodań i 10344 usunięć
  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
      Config.xcconfig
  6. 40 36
      Gemfile.lock
  7. 3 1
      LiveActivity/Views/LiveActivityChartView.swift
  8. 19 0
      Model/Helper/Determination+helper.swift
  9. 12 0
      Model/Helper/NSPredicates.swift
  10. 2 3
      Model/Helper/PumpEvent+helper.swift
  11. 839 0
      Model/JSONImporter.swift
  12. 119 0
      PRIVACY_POLICY.md
  13. 13 0
      Trio Watch App Extension/Helper/Helper+Enums.swift
  14. 9 0
      Trio Watch App Extension/TrioWatchApp.swift
  15. 3 3
      Trio Watch App Extension/Views/AcknowledgementPendingView.swift
  16. 0 10
      Trio Watch App Extension/Views/BolusConfirmationView.swift
  17. 0 9
      Trio Watch App Extension/Views/BolusInputView.swift
  18. 86 86
      Trio Watch App Extension/Views/BolusProgressOverlay.swift
  19. 1 13
      Trio Watch App Extension/Views/TrioMainWatchView.swift
  20. 150 0
      Trio Watch App Extension/WatchLogger.swift
  21. 143 45
      Trio Watch App Extension/WatchState+Requests.swift
  22. 140 165
      Trio Watch App Extension/WatchState.swift
  23. 39 0
      Trio Watch App Extension/WatchStateSnapshot.swift
  24. 350 20
      Trio.xcodeproj/project.pbxproj
  25. 4 1
      Trio.xcodeproj/xcshareddata/xcschemes/Trio.xcscheme
  26. 118 1
      Trio.xcworkspace/xcshareddata/swiftpm/Package.resolved
  27. 30 0
      Trio/GoogleService-Info.plist
  28. 12 0
      Trio/Resources/Assets.xcassets/logo.bluetooth.capsule.portrait.fill.symbolset/Contents.json
  29. 196 0
      Trio/Resources/Assets.xcassets/logo.bluetooth.capsule.portrait.fill.symbolset/logo.bluetooth.capsule.portrait.fill.svg
  30. 1 0
      Trio/Resources/Info.plist
  31. 2 2
      Trio/Resources/InfoPlist.xcstrings
  32. 1 1
      Trio/Resources/javascript/bundle/autosens.js
  33. 1 1
      Trio/Resources/javascript/bundle/autotune-core.js
  34. 1 1
      Trio/Resources/javascript/bundle/autotune-prep.js
  35. 1 1
      Trio/Resources/javascript/bundle/basal-set-temp.js
  36. 1 1
      Trio/Resources/javascript/bundle/determine-basal.js
  37. 1 1
      Trio/Resources/javascript/bundle/iob.js
  38. 1 1
      Trio/Resources/javascript/bundle/meal.js
  39. 1 1
      Trio/Resources/javascript/bundle/profile.js
  40. 1 2
      Trio/Resources/json/defaults/preferences.json
  41. 1 1
      Trio/Resources/json/defaults/settings/basal_profile.json
  42. 2 2
      Trio/Resources/json/defaults/settings/bg_targets.json
  43. 1 1
      Trio/Resources/json/defaults/settings/carb_ratios.json
  44. 1 1
      Trio/Resources/json/defaults/settings/insulin_sensitivities.json
  45. 20 3
      Trio/Sources/APS/APSManager.swift
  46. 1 0
      Trio/Sources/APS/DeviceDataManager.swift
  47. 9 1
      Trio/Sources/APS/OpenAPS/OpenAPS.swift
  48. 0 2
      Trio/Sources/APS/OpenAPSSwift/Models/Profile.swift
  49. 0 1
      Trio/Sources/APS/OpenAPSSwift/Profile/ProfileGenerator.swift
  50. 2 3
      Trio/Sources/APS/Storage/OverrideStorage.swift
  51. 13 14
      Trio/Sources/APS/Storage/PumpHistoryStorage.swift
  52. 13 2
      Trio/Sources/Application/AppDelegate.swift
  53. 174 27
      Trio/Sources/Application/TrioApp.swift
  54. 9 3
      Trio/Sources/Helpers/BuildDetails.swift
  55. 26 0
      Trio/Sources/Helpers/PropertyPersistentFlags.swift
  56. 63 4
      Trio/Sources/Helpers/PropertyWrappers/PersistedProperty.swift
  57. 15 5
      Trio/Sources/Helpers/CheckboxToggleStyle.swift
  58. 53601 9507
      Trio/Sources/Localizations/Main/Localizable.xcstrings
  59. 40 0
      Trio/Sources/Logger/IssueReporter/SimpleLogReporter.swift
  60. 10 6
      Trio/Sources/Models/BloodGlucose.swift
  61. 10 3
      Trio/Sources/Models/DecimalPickerSettings.swift
  62. 0 32
      Trio/Sources/Models/Glucose.swift
  63. 1 8
      Trio/Sources/Models/Preferences.swift
  64. 5 1
      Trio/Sources/Models/PumpHistoryEvent.swift
  65. 2 0
      Trio/Sources/Models/WatchMessageKeys.swift
  66. 1 1
      Trio/Sources/Models/WatchState.swift
  67. 39 0
      Trio/Sources/Models/WatchStateSnapshot.swift
  68. 4 4
      Trio/Sources/Modules/AlgorithmAdvancedSettings/AlgorithmAdvancedSettingsStateModel.swift
  69. 34 28
      Trio/Sources/Modules/AlgorithmAdvancedSettings/View/AlgorithmAdvancedSettingsRootView.swift
  70. 5 0
      Trio/Sources/Modules/AppDiagnostics/AppDiagnosticsDataFlow.swift
  71. 3 0
      Trio/Sources/Modules/AppDiagnostics/AppDiagnosticsProvider.swift
  72. 35 0
      Trio/Sources/Modules/AppDiagnostics/AppDiagnosticsStateModel.swift
  73. 102 0
      Trio/Sources/Modules/AppDiagnostics/View/AppDiagnosticsRootView.swift
  74. 233 0
      Trio/Sources/Modules/AppDiagnostics/View/PrivacyPolicyView.swift
  75. 1 1
      Trio/Sources/Modules/AutosensSettings/AutosensSettingsStateModel.swift
  76. 19 22
      Trio/Sources/Modules/AutosensSettings/View/AutosensSettingsRootView.swift
  77. 17 10
      Trio/Sources/Modules/BasalProfileEditor/View/BasalProfileEditorRootView.swift
  78. 1 1
      Trio/Sources/Modules/BolusCalculatorConfig/View/BolusCalculatorConfigRootView.swift
  79. 1 0
      Trio/Sources/Modules/CGMSettings/CGMSettingsStateModel.swift
  80. 46 38
      Trio/Sources/Modules/CGMSettings/View/CGMRootView.swift
  81. 1 1
      Trio/Sources/Modules/CarbRatioEditor/CarbRatioEditorStateModel.swift
  82. 15 15
      Trio/Sources/Modules/CarbRatioEditor/View/CarbRatioEditorRootView.swift
  83. 4 2
      Trio/Sources/Modules/DataTable/View/DataTableRootView.swift
  84. 0 2
      Trio/Sources/Modules/DynamicSettings/DynamicSettingsStateModel.swift
  85. 28 75
      Trio/Sources/Modules/DynamicSettings/View/DynamicSettingsRootView.swift
  86. 2 0
      Trio/Sources/Modules/GeneralSettings/UnitsLimitsSettingsStateModel.swift
  87. 61 11
      Trio/Sources/Modules/GeneralSettings/View/UnitsLimitsSettingsRootView.swift
  88. 1 1
      Trio/Sources/Modules/GlucoseNotificationSettings/View/GlucoseNotificationSettingsRootView.swift
  89. 1 2
      Trio/Sources/Modules/Home/HomeStateModel+Setup/ChartAxisSetup.swift
  90. 1 0
      Trio/Sources/Modules/Home/HomeStateModel.swift
  91. 16 5
      Trio/Sources/Modules/Home/View/Chart/ChartElements/CobIobChart.swift
  92. 0 1
      Trio/Sources/Modules/Home/View/Header/CurrentGlucoseView.swift
  93. 0 1
      Trio/Sources/Modules/Home/View/Header/PumpView.swift
  94. 22 16
      Trio/Sources/Modules/Home/View/HomeRootView.swift
  95. 23 9
      Trio/Sources/Modules/ISFEditor/ISFEditorStateModel.swift
  96. 16 14
      Trio/Sources/Modules/ISFEditor/View/ISFEditorRootView.swift
  97. 1 1
      Trio/Sources/Modules/LiveActivitySettings/View/LiveActivityWidgetConfiguration.swift
  98. 19 6
      Trio/Sources/Modules/Main/MainStateModel.swift
  99. 2 2
      Trio/Sources/Modules/Main/View/MainLoadingView.swift
  100. 0 0
      Trio/Sources/Modules/Main/View/MainMigrationErrorView.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
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##

+ 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)
+    }
+}

Plik diff jest za duży
+ 350 - 20
Trio.xcodeproj/project.pbxproj


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

@@ -232,7 +232,6 @@
       selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
       selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
       enableThreadSanitizer = "YES"
-      enableUBSanitizer = "YES"
       launchStyle = "0"
       useCustomWorkingDirectory = "NO"
       ignoresPersistentStateOnLaunch = "NO"
@@ -255,6 +254,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" : "b10fee57248e5d754951672d55dd1e425fadd3089d06858aed6f0f5206be7e5c",
+  "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"
+    }
+  ]
+}

Plik diff jest za duży
+ 196 - 0
Trio/Resources/Assets.xcassets/logo.bluetooth.capsule.portrait.fill.symbolset/logo.bluetooth.capsule.portrait.fill.svg


+ 1 - 0
Trio/Resources/Info.plist

@@ -103,6 +103,7 @@
 		<string>fetch</string>
 		<string>processing</string>
 		<string>remote-notification</string>
+		<string>audio</string>
 	</array>
 	<key>UIFileSharingEnabled</key>
 	<true/>

+ 2 - 2
Trio/Resources/InfoPlist.xcstrings

@@ -8,7 +8,7 @@
         "en" : {
           "stringUnit" : {
             "state" : "new",
-            "value" : "Trio"
+            "value" : "Trio_test"
           }
         }
       }
@@ -20,7 +20,7 @@
         "en" : {
           "stringUnit" : {
             "state" : "new",
-            "value" : "Trio"
+            "value" : "Trio_test"
           }
         }
       }

Plik diff jest za duży
+ 1 - 1
Trio/Resources/javascript/bundle/autosens.js


Plik diff jest za duży
+ 1 - 1
Trio/Resources/javascript/bundle/autotune-core.js


Plik diff jest za duży
+ 1 - 1
Trio/Resources/javascript/bundle/autotune-prep.js


Plik diff jest za duży
+ 1 - 1
Trio/Resources/javascript/bundle/basal-set-temp.js


Plik diff jest za duży
+ 1 - 1
Trio/Resources/javascript/bundle/determine-basal.js


Plik diff jest za duży
+ 1 - 1
Trio/Resources/javascript/bundle/iob.js


Plik diff jest za duży
+ 1 - 1
Trio/Resources/javascript/bundle/meal.js


Plik diff jest za duży
+ 1 - 1
Trio/Resources/javascript/bundle/profile.js


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

@@ -38,12 +38,11 @@
   "insulinPeakTime" : 75,
   "carbsReqThreshold" : 1,
   "noisyCGMTargetMultiplier" : 1.3,
-  "suspend_zeros_iob" : false,
+  "suspend_zeros_iob" : true,
   "maxDelta_bg_threshold" : 0.2,
   "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 {
@@ -519,7 +530,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)
 
@@ -547,7 +561,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)")
             )
         }
     }
@@ -564,7 +578,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)

+ 1 - 0
Trio/Sources/APS/DeviceDataManager.swift

@@ -520,6 +520,7 @@ extension BaseDeviceDataManager: PumpManagerDelegate {
                     guard let type = $0.type, type == .tempBasal else { return true }
                     return $0.dose?.unitsPerHour ?? 0 <= Double(settingsManager.pumpSettings.maxBasal)
                 }
+                debug(.deviceManager, "Storing \(events.count) new pump events: \(events)")
                 try await pumpHistoryStorage.storePumpEvents(events)
                 lastEventDate = events.last?.date
                 completion(nil)

+ 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

+ 0 - 2
Trio/Sources/APS/OpenAPSSwift/Models/Profile.swift

@@ -60,7 +60,6 @@ struct Profile: Codable {
     var adjustmentFactor: Decimal = 0.8
     var adjustmentFactorSigmoid: Decimal = 0.5
     var useNewFormula: Bool = false
-    var enableDynamicCR: Bool = false
     var sigmoid: Bool = false
     var weightPercentage: Decimal = 0.65
     var tddAdjBasal: Bool = false
@@ -126,7 +125,6 @@ struct Profile: Codable {
         case adjustmentFactor
         case adjustmentFactorSigmoid
         case useNewFormula
-        case enableDynamicCR
         case sigmoid
         case weightPercentage
         case tddAdjBasal

+ 0 - 1
Trio/Sources/APS/OpenAPSSwift/Profile/ProfileGenerator.swift

@@ -54,7 +54,6 @@ extension Profile {
         useCustomPeakTime = preferences.useCustomPeakTime
         suspendZerosIob = preferences.suspendZerosIOB
         useNewFormula = preferences.useNewFormula
-        enableDynamicCR = preferences.enableDynamicCR
         sigmoid = preferences.sigmoid
         tddAdjBasal = preferences.tddAdjBasal
 

+ 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
         }
     }
 }

+ 13 - 14
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
@@ -69,7 +69,7 @@ final class BasePumpHistoryStorage: PumpHistoryStorage, Injectable {
 
                     guard existingEvents.isEmpty else {
                         // Duplicate found, do not store the event
-                        print("Duplicate event found with timestamp: \(event.date)")
+                        debug(.coreData, "Duplicate event found with timestamp: \(event.date)")
 
                         if let existingEvent = existingEvents.first(where: { $0.type == EventType.bolus.rawValue }) {
                             if existingEvent.timestamp == event.date {
@@ -81,7 +81,7 @@ final class BasePumpHistoryStorage: PumpHistoryStorage, Injectable {
                                     existingEvent.isUploadedToHealth = false
                                     existingEvent.isUploadedToTidepool = false
 
-                                    print("Updated existing event with smaller value: \(amount)")
+                                    debug(.coreData, "Updated existing event with smaller value: \(amount)")
                                 }
                             }
                         }
@@ -108,7 +108,7 @@ final class BasePumpHistoryStorage: PumpHistoryStorage, Injectable {
 
                     guard existingEvents.isEmpty else {
                         // Duplicate found, do not store the event
-                        print("Duplicate event found with timestamp: \(event.date)")
+                        debug(.coreData, "Duplicate event found with timestamp: \(event.date)")
                         continue
                     }
 
@@ -137,7 +137,7 @@ final class BasePumpHistoryStorage: PumpHistoryStorage, Injectable {
                 case .suspend:
                     guard existingEvents.isEmpty else {
                         // Duplicate found, do not store the event
-                        print("Duplicate event found with timestamp: \(event.date)")
+                        debug(.coreData, "Duplicate event found with timestamp: \(event.date)")
                         continue
                     }
                     let newPumpEvent = PumpEventStored(context: self.context)
@@ -151,7 +151,7 @@ final class BasePumpHistoryStorage: PumpHistoryStorage, Injectable {
                 case .resume:
                     guard existingEvents.isEmpty else {
                         // Duplicate found, do not store the event
-                        print("Duplicate event found with timestamp: \(event.date)")
+                        debug(.coreData, "Duplicate event found with timestamp: \(event.date)")
                         continue
                     }
                     let newPumpEvent = PumpEventStored(context: self.context)
@@ -165,7 +165,7 @@ final class BasePumpHistoryStorage: PumpHistoryStorage, Injectable {
                 case .rewind:
                     guard existingEvents.isEmpty else {
                         // Duplicate found, do not store the event
-                        print("Duplicate event found with timestamp: \(event.date)")
+                        debug(.coreData, "Duplicate event found with timestamp: \(event.date)")
                         continue
                     }
                     let newPumpEvent = PumpEventStored(context: self.context)
@@ -179,7 +179,7 @@ final class BasePumpHistoryStorage: PumpHistoryStorage, Injectable {
                 case .prime:
                     guard existingEvents.isEmpty else {
                         // Duplicate found, do not store the event
-                        print("Duplicate event found with timestamp: \(event.date)")
+                        debug(.coreData, "Duplicate event found with timestamp: \(event.date)")
                         continue
                     }
                     let newPumpEvent = PumpEventStored(context: self.context)
@@ -193,7 +193,7 @@ final class BasePumpHistoryStorage: PumpHistoryStorage, Injectable {
                 case .alarm:
                     guard existingEvents.isEmpty else {
                         // Duplicate found, do not store the event
-                        print("Duplicate event found with timestamp: \(event.date)")
+                        debug(.coreData, "Duplicate event found with timestamp: \(event.date)")
                         continue
                     }
                     let newPumpEvent = PumpEventStored(context: self.context)
@@ -215,16 +215,15 @@ final class BasePumpHistoryStorage: PumpHistoryStorage, Injectable {
                 try self.context.save()
 
                 self.updateSubject.send(())
-                debugPrint("\(DebuggingIdentifiers.succeeded) stored pump events in Core Data")
+                debug(.coreData, "\(DebuggingIdentifiers.succeeded) stored pump events in Core Data")
             } catch let error as NSError {
-                debugPrint("\(DebuggingIdentifiers.failed) failed to store pump events with error: \(error.userInfo)")
+                debug(.coreData, "\(DebuggingIdentifiers.failed) failed to store pump events with error: \(error.userInfo)")
                 throw error
             }
         }
     }
 
     func storeExternalInsulinEvent(amount: Decimal, timestamp: Date) async {
-        debug(.default, "External insulin saved")
         await context.perform {
             // create pump event
             let newPumpEvent = PumpEventStored(context: self.context)
@@ -246,10 +245,10 @@ final class BasePumpHistoryStorage: PumpHistoryStorage, Injectable {
             do {
                 guard self.context.hasChanges else { return }
                 try self.context.save()
-
+                debug(.coreData, "External insulin saved")
                 self.updateSubject.send(())
             } catch {
-                print(error.localizedDescription)
+                debug(.coreData, "Failed to store external insulin in context: \(error)")
             }
         }
     }

+ 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(

+ 174 - 27
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,15 +86,39 @@ extension Notification.Name {
     }
 
     init() {
+        FileProtectionFixer.fixFlagFileProtectionForPropertyPersistentFlags() // TODO: ‼️ REMOVE ME BEFORE PUBLIC BETA / RELEASE
+
+        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: ", ")
 
         debug(
             .default,
-            "Trio Started: v\(Bundle.main.releaseVersionNumber ?? "")(\(Bundle.main.buildVersionNumber ?? "")) [buildDate: \(String(describing: BuildDetails.shared.buildDate()))] [buildExpires: \(String(describing: BuildDetails.shared.calculateExpirationDate()))] [submodules: \(submodulesInfo)]"
+            "Trio Started: v\(Bundle.main.releaseVersionNumber ?? "")(\(Bundle.main.buildVersionNumber ?? "")) [buildDate: \(String(describing: BuildDetails.shared.buildDate()))] [buildExpires: \(String(describing: BuildDetails.shared.calculateExpirationDate()))] [Branch: \(BuildDetails.shared.branchAndSha)] [submodules: \(submodulesInfo)]"
         )
-
         // Fix bug in iOS 18 related to the translucent tab bar
         configureTabBarAppearance()
 
@@ -104,6 +134,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()
@@ -112,8 +145,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 {
@@ -134,6 +173,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
@@ -146,36 +253,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
@@ -221,7 +368,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()
@@ -238,7 +385,7 @@ extension Notification.Name {
             try await purgeData
 
             // Update the last cleanup date
-            UserDefaults.standard.set(Date(), forKey: "lastCleanupDate")
+            PropertyPersistentFlags.shared.lastCleanupDate = Date()
         }
     }
 

+ 9 - 3
Trio/Sources/Helpers/BuildDetails.swift

@@ -23,10 +23,16 @@ class BuildDetails: Injectable {
         dict["com-trio-build-date"] as? String
     }
 
+    var trioBranch: String {
+        dict["com-trio-branch"] as? String ?? String(localized: "Unknown")
+    }
+
+    var trioCommitSHA: String {
+        dict["com-trio-commit-sha"] as? String ?? String(localized: "Unknown")
+    }
+
     var branchAndSha: String {
-        let branch = dict["com-trio-branch"] as? String ?? String(localized: "Unknown")
-        let sha = dict["com-trio-commit-sha"] as? String ?? String(localized: "Unknown")
-        return "\(branch) \(sha)"
+        "\(trioBranch) \(trioCommitSHA)"
     }
 
     /// Returns a dictionary of submodule details.

+ 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?
+}

+ 63 - 4
Trio/Sources/Helpers/PropertyWrappers/PersistedProperty.swift

@@ -76,24 +76,83 @@ import Foundation
 
                 guard let value = try PropertyListSerialization.propertyList(from: data, options: [], format: nil) as? Value
                 else {
+                    debug(.storage, "[PersistedProperty:\(key)] Could not cast property list to expected type.")
                     return nil
                 }
                 return value
-            } catch {}
-            return nil
+            } catch {
+                debug(.storage, "❌ [PersistedProperty:\(key)] Failed to read value: \(error.localizedDescription)")
+                return nil
+            }
         }
         set {
             guard let newValue = newValue else {
                 do {
                     try FileManager.default.removeItem(at: storageURL)
-                } catch {}
+                    debug(.storage, "[PersistedProperty:\(key)] Removed value.")
+                } catch {
+                    debug(.storage, "❌ [PersistedProperty:\(key)] Failed to remove value: \(error.localizedDescription)")
+                }
                 return
             }
+
             do {
                 let data = try PropertyListSerialization.data(fromPropertyList: newValue, format: .binary, options: 0)
                 try data.write(to: storageURL, options: .atomic)
 
-            } catch {}
+                // Ensure appropriate protection level
+                try FileManager.default.setAttributes(
+                    [.protectionKey: FileProtectionType.none],
+                    ofItemAtPath: storageURL.path
+                )
+
+                debug(.storage, "✅ [PersistedProperty:\(key)] Saved value successfully.")
+            } catch {
+                debug(.storage, "❌ [PersistedProperty:\(key)] Failed to write value: \(error.localizedDescription)")
+            }
+        }
+    }
+}
+
+import Foundation
+
+enum FileProtectionFixer {
+    /// Ensures only critical persisted flags have safe file protection set.
+    static func fixFlagFileProtectionForPropertyPersistentFlags() {
+        let flagFiles = [
+            "onboardingCompleted.plist",
+            "diagnosticsSharing.plist",
+            "lastCleanupDate.plist"
+        ]
+
+        let fileManager = FileManager.default
+
+        guard let documentsURL = try? fileManager.url(
+            for: .documentDirectory,
+            in: .userDomainMask,
+            appropriateFor: nil,
+            create: false
+        ) else {
+            debug(.storage, "⚠️ Could not access the documents directory.")
+            return
+        }
+
+        for fileName in flagFiles {
+            let fileURL = documentsURL.appendingPathComponent(fileName)
+
+            guard fileManager.fileExists(atPath: fileURL.path) else {
+                continue // Skip if file doesn’t exist yet
+            }
+
+            do {
+                try fileManager.setAttributes(
+                    [.protectionKey: FileProtectionType.none],
+                    ofItemAtPath: fileURL.path
+                )
+                debug(.storage, "✅ Updated protection for \(fileName)")
+            } catch {
+                debug(.storage, "❌ Failed to update protection for \(fileName): \(error)")
+            }
         }
     }
 }

+ 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()
+        }
     }
 }

Plik diff jest za duży
+ 53601 - 9507
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 {

+ 10 - 3
Trio/Sources/Models/DecimalPickerSettings.swift

@@ -66,8 +66,8 @@ struct DecimalPickerSettings {
         max: 5,
         type: PickerSetting.PickerSettingType.factor
     )
-    var autosensMax = PickerSetting(value: 1.2, step: 0.1, min: 0.5, max: 2, type: PickerSetting.PickerSettingType.factor)
-    var autosensMin = PickerSetting(value: 0.7, step: 0.1, min: 0.5, max: 1, type: PickerSetting.PickerSettingType.factor)
+    var autosensMax = PickerSetting(value: 1.2, step: 0.05, min: 0.5, max: 2, type: PickerSetting.PickerSettingType.factor)
+    var autosensMin = PickerSetting(value: 0.7, step: 0.05, min: 0.5, max: 1, type: PickerSetting.PickerSettingType.factor)
     var smbDeliveryRatio = PickerSetting(value: 0.5, step: 0.05, min: 0.3, max: 0.7, type: PickerSetting.PickerSettingType.factor)
     var halfBasalExerciseTarget = PickerSetting(
         value: 160,
@@ -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"
-}

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

@@ -39,13 +39,12 @@ struct Preferences: JSON, Equatable {
     var insulinPeakTime: Decimal = 75
     var carbsReqThreshold: Decimal = 1.0
     var noisyCGMTargetMultiplier: Decimal = 1.3
-    var suspendZerosIOB: Bool = false
+    var suspendZerosIOB: Bool = true
     var timestamp: Date?
     var maxDeltaBGthreshold: Decimal = 0.2
     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)
+    }
+}

+ 4 - 4
Trio/Sources/Modules/AlgorithmAdvancedSettings/AlgorithmAdvancedSettingsStateModel.swift

@@ -17,7 +17,6 @@ extension AlgorithmAdvancedSettings {
         @Published var insulinPeakTime: Decimal = 75
         @Published var skipNeutralTemps: Bool = false
         @Published var unsuspendIfNoTemp: Bool = false
-        @Published var suspendZerosIOB: Bool = false
         @Published var min5mCarbimpact: Decimal = 8
         @Published var remainingCarbsFraction: Decimal = 1.0
         @Published var remainingCarbsCap: Decimal = 90
@@ -25,6 +24,8 @@ extension AlgorithmAdvancedSettings {
         @Published var useSwiftOref: Bool = false
         // preference
         @Published var insulinActionCurve: Decimal = 10
+        @Published var smbDeliveryRatio: Decimal = 0.5
+        @Published var smbInterval: Decimal = 3
 
         var pumpSettings: PumpSettings {
             provider.settings()
@@ -41,8 +42,6 @@ extension AlgorithmAdvancedSettings {
             subscribePreferencesSetting(\.insulinPeakTime, on: $insulinPeakTime) { insulinPeakTime = $0 }
             subscribePreferencesSetting(\.skipNeutralTemps, on: $skipNeutralTemps) { skipNeutralTemps = $0 }
             subscribePreferencesSetting(\.unsuspendIfNoTemp, on: $unsuspendIfNoTemp) { unsuspendIfNoTemp = $0 }
-            subscribePreferencesSetting(\.suspendZerosIOB, on: $suspendZerosIOB) { suspendZerosIOB = $0 }
-            subscribePreferencesSetting(\.suspendZerosIOB, on: $suspendZerosIOB) { suspendZerosIOB = $0 }
             subscribePreferencesSetting(\.min5mCarbimpact, on: $min5mCarbimpact) { min5mCarbimpact = $0 }
             subscribePreferencesSetting(\.remainingCarbsFraction, on: $remainingCarbsFraction) { remainingCarbsFraction = $0 }
             subscribePreferencesSetting(\.remainingCarbsCap, on: $remainingCarbsCap) { remainingCarbsCap = $0 }
@@ -50,7 +49,8 @@ extension AlgorithmAdvancedSettings {
                 noisyCGMTargetMultiplier = $0 }
             subscribeSetting(\.useSwiftOref, on: $useSwiftOref) {
                 useSwiftOref = $0 }
-
+            subscribePreferencesSetting(\.smbDeliveryRatio, on: $smbDeliveryRatio) { smbDeliveryRatio = $0 }
+            subscribePreferencesSetting(\.smbInterval, on: $smbInterval) { smbInterval = $0 }
             insulinActionCurve = pumpSettings.insulinActionCurve
         }
 

+ 34 - 28
Trio/Sources/Modules/AlgorithmAdvancedSettings/View/AlgorithmAdvancedSettingsRootView.swift

@@ -220,33 +220,54 @@ extension AlgorithmAdvancedSettings {
                 )
 
                 SettingInputSection(
-                    decimalValue: $decimalPlaceholder,
-                    booleanValue: $state.suspendZerosIOB,
+                    decimalValue: $state.smbDeliveryRatio,
+                    booleanValue: $booleanPlaceholder,
                     shouldDisplayHint: $shouldDisplayHint,
                     selectedVerboseHint: Binding(
                         get: { selectedVerboseHint },
                         set: {
                             selectedVerboseHint = $0.map { AnyView($0) }
-                            hintLabel = String(localized: "Suspend Zeros IOB", comment: "Suspend Zeros IOB")
+                            hintLabel = String(localized: "SMB Delivery Ratio", comment: "SMB Delivery Ratio")
                         }
                     ),
                     units: state.units,
-                    type: .boolean,
-                    label: String(localized: "Suspend Zeros IOB", comment: "Suspend Zeros IOB"),
-                    miniHint: String(
-                        localized: "Clear temporary basal rates and reset IOB when suspended.",
-                        comment: "Mini Hint for Suspend Zeros IOB"
-                    ),
+                    type: .decimal("smbDeliveryRatio"),
+                    label: String(localized: "SMB Delivery Ratio", comment: "SMB Delivery Ratio"),
+                    miniHint: String(localized: "Percentage of calculated insulin required that is given as SMB."),
                     verboseHint:
                     VStack(alignment: .leading, spacing: 10) {
-                        Text("Default: OFF").bold()
+                        Text("Default: 50%").bold()
+                        Text(
+                            "Once the total insulin required is calculated, this safety limit specifies what percentage of the insulin required can be delivered as an SMB."
+                        )
                         Text(
-                            "When Suspend Zeros IOB is enabled, any active temporary basal rates during a pump suspension are reset, with new 0 U/hr temporary basal rates added to counteract those done during suspension."
+                            "Due to SMBs potentially occurring every 5 minutes with each loop cycle, it is important to set this value to a reasonable level that allows Trio to safely zero temp should dosing needs suddenly change. Increase this value with caution."
                         )
+                        Text("Note: Allowed range is 30 - 70%")
+                    }
+                )
+
+                SettingInputSection(
+                    decimalValue: $state.smbInterval,
+                    booleanValue: $booleanPlaceholder,
+                    shouldDisplayHint: $shouldDisplayHint,
+                    selectedVerboseHint: Binding(
+                        get: { selectedVerboseHint },
+                        set: {
+                            selectedVerboseHint = $0.map { AnyView($0) }
+                            hintLabel = String(localized: "SMB Interval", comment: "SMB Interval")
+                        }
+                    ),
+                    units: state.units,
+                    type: .decimal("smbInterval"),
+                    label: String(localized: "SMB Interval", comment: "SMB Interval"),
+                    miniHint: String(localized: "Minimum minutes since the last SMB or manual bolus to allow an automated SMB."),
+                    verboseHint:
+                    VStack(alignment: .leading, spacing: 10) {
+                        Text("Default: 3 min").bold()
                         Text(
-                            "This prevents lingering insulin effects when your pump is suspended, ensuring safer management of insulin on board."
+                            "This is the minimum number of minutes since the last SMB or manual bolus before Trio will permit an automated SMB."
                         )
-                        Text("Note: Applies only to pumps with on-pump suspend options.")
                     }
                 )
 
@@ -424,18 +445,3 @@ extension AlgorithmAdvancedSettings {
         }
     }
 }
-
-struct BulletPoint: View {
-    let text: String
-
-    init(_ text: String) {
-        self.text = text
-    }
-
-    var body: some View {
-        HStack(alignment: .top) {
-            Text("•")
-            Text(text)
-        }
-    }
-}

+ 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
                                 }

+ 0 - 2
Trio/Sources/Modules/DynamicSettings/DynamicSettingsStateModel.swift

@@ -34,7 +34,6 @@ extension DynamicSettings {
         @Published var adjustmentFactorSigmoid: Decimal = 0.5
         @Published var weightPercentage: Decimal = 0.65
         @Published var tddAdjBasal: Bool = false
-        @Published var threshold_setting: Decimal = 60
 
         @ObservedObject var pickerSettingsProvider = PickerSettingsProvider.shared
 
@@ -58,7 +57,6 @@ extension DynamicSettings {
             subscribePreferencesSetting(\.adjustmentFactorSigmoid, on: $adjustmentFactorSigmoid) { adjustmentFactorSigmoid = $0 }
             subscribePreferencesSetting(\.weightPercentage, on: $weightPercentage) { weightPercentage = $0 }
             subscribePreferencesSetting(\.tddAdjBasal, on: $tddAdjBasal) { tddAdjBasal = $0 }
-            subscribePreferencesSetting(\.threshold_setting, on: $threshold_setting) { threshold_setting = $0 }
 
             Task {
                 do {

+ 28 - 75
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,79 +205,61 @@ extension DynamicSettings {
                     }
 
                     SettingInputSection(
-                        decimalValue: $decimalPlaceholder,
-                        booleanValue: $state.tddAdjBasal,
+                        decimalValue: $state.weightPercentage,
+                        booleanValue: $booleanPlaceholder,
                         shouldDisplayHint: $shouldDisplayHint,
                         selectedVerboseHint: Binding(
                             get: { selectedVerboseHint },
                             set: {
                                 selectedVerboseHint = $0.map { AnyView($0) }
-                                hintLabel = String(localized: "Adjust Basal")
+                                hintLabel = String(localized: "Weighted Average of TDD")
                             }
                         ),
                         units: state.units,
-                        type: .boolean,
-                        label: String(localized: "Adjust Basal"),
-                        miniHint: String(localized: "Use Dynamic Ratio to adjust basal rates."),
-                        verboseHint: VStack(alignment: .leading, spacing: 10) {
-                            Text("Default: OFF").bold()
+                        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(
-                                "Turn this setting on to give basal adjustments more agility. Keep this setting off if your basal needs are not highly variable."
+                                "This setting adjusts how much weight is given to your recent total daily insulin dose when calculating Dynamic ISF and Dynamic CR."
                             )
                             Text(
-                                "Enabling Adjust Basal replaces the standard Autosens Ratio calculation with its own Autosens Ratio calculated as such:"
+                                "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("Autosens Ratio =\n(Weighted Average of TDD) / (10-day Average of TDD)")
-                            Text("New Basal Profile =\n(Current Basal Profile) × (Autosens Ratio)")
-                        },
-                        headerText: String(localized: "Dynamic-dependent Features")
+                            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: $state.threshold_setting,
-                        booleanValue: $booleanPlaceholder,
+                        decimalValue: $decimalPlaceholder,
+                        booleanValue: $state.tddAdjBasal,
                         shouldDisplayHint: $shouldDisplayHint,
                         selectedVerboseHint: Binding(
                             get: { selectedVerboseHint },
                             set: {
                                 selectedVerboseHint = $0.map { AnyView($0) }
-                                hintLabel = String(localized: "Minimum Safety Threshold")
+                                hintLabel = String(localized: "Adjust Basal")
                             }
                         ),
                         units: state.units,
-                        type: .decimal("threshold_setting"),
-                        label: String(localized: "Minimum Safety Threshold"),
-                        miniHint: String(localized: "Increase the safety threshold used to suspend insulin delivery."),
-                        verboseHint:
-                        VStack(alignment: .leading, spacing: 10) {
-                            Text("Default: Set by Algorithm").bold()
+                        type: .boolean,
+                        label: String(localized: "Adjust Basal"),
+                        miniHint: String(localized: "Use Dynamic Ratio to adjust basal rates."),
+                        verboseHint: VStack(alignment: .leading, spacing: 10) {
+                            Text("Default: OFF").bold()
                             Text(
-                                "Minimum Threshold Setting is, by default, determined by your set Glucose Target. This threshold automatically suspends insulin delivery if your glucose levels are forecasted to fall below this value. It’s designed to protect against hypoglycemia, particularly during sleep or other vulnerable times."
+                                "Turn this setting on to give basal adjustments more agility. Keep this setting off if your basal needs are not highly variable."
                             )
                             Text(
-                                "Trio will use the larger of the default setting calculation below and the value entered here."
+                                "Enabling Adjust Basal replaces the standard Autosens Ratio calculation with its own Autosens Ratio calculated as such:"
                             )
-                            VStack(alignment: .leading, spacing: 10) {
-                                VStack(alignment: .leading, spacing: 5) {
-                                    Text("The default setting is based on this calculation:").bold()
-                                    Text("TargetGlucose - 0.5 × (TargetGlucose - 40)")
-                                }
-                                VStack(alignment: .leading, spacing: 5) {
-                                    Text(
-                                        "If your glucose target is \(state.units == .mgdL ? "110" : 110.formattedAsMmolL) \(state.units.rawValue), Trio will use a safety threshold of \(state.units == .mgdL ? "75" : 75.formattedAsMmolL) \(state.units.rawValue), unless you set Minimum Safety Threshold to something > \(state.units == .mgdL ? "75" : 75.formattedAsMmolL) \(state.units.rawValue)."
-                                    )
-                                    Text(
-                                        "\(state.units == .mgdL ? "110" : 110.formattedAsMmolL) - 0.5 × (\(state.units == .mgdL ? "110" : 110.formattedAsMmolL) - \(state.units == .mgdL ? "40" : 40.formattedAsMmolL)) = \(state.units == .mgdL ? "75" : 75.formattedAsMmolL)"
-                                    )
-                                }
-                                Text(
-                                    "This setting is limited to values between \(state.units == .mgdL ? "60" : 60.formattedAsMmolL) - \(state.units == .mgdL ? "120" : 120.formattedAsMmolL) \(state.units.rawValue)"
-                                )
-                                Text(
-                                    "Note: Basal may be resumed if there is negative IOB and glucose is rising faster than the forecast."
-                                )
-                            }
-                        }
+                            Text("Autosens Ratio =\n(Weighted Average of TDD) / (10-day Average of TDD)")
+                            Text("New Basal Profile =\n(Current Basal Profile) × (Autosens Ratio)")
+                        },
+                        headerText: String(localized: "Dynamic-dependent Features")
                     )
                 }
             }

+ 2 - 0
Trio/Sources/Modules/GeneralSettings/UnitsLimitsSettingsStateModel.swift

@@ -14,6 +14,7 @@ extension UnitsLimitsSettings {
         @Published var maxIOB: Decimal = 0
         @Published var maxCOB: Decimal = 120
         @Published var hasChanged: Bool = false
+        @Published var threshold_setting: Decimal = 60
 
         var preferences: Preferences {
             settingsManager.preferences
@@ -31,6 +32,7 @@ extension UnitsLimitsSettings {
 
             subscribePreferencesSetting(\.maxIOB, on: $maxIOB) { maxIOB = $0 }
             subscribePreferencesSetting(\.maxCOB, on: $maxCOB) { maxCOB = $0 }
+            subscribePreferencesSetting(\.threshold_setting, on: $threshold_setting) { threshold_setting = $0 }
 
             maxBasal = pumpSettings.maxBasal
             maxBolus = pumpSettings.maxBolus

+ 61 - 11
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,18 +72,18 @@ 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) {
                         Text("Default: 10 units").bold()
                         Text(
-                            "This is the maximum bolus allowed to be delivered at one time. This limits manual and automatic bolus."
+                            "This is the maximum bolus allowed to be delivered at one time. This only limits manual boluses and does not limit SMBs."
                         )
                         Text("Most set this to their largest meal bolus. Then, adjust if needed.")
                         Text("If you attempt to request a bolus larger than this, the bolus will not be accepted.")
@@ -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()
@@ -141,6 +144,53 @@ extension UnitsLimitsSettings {
                         Text("This is an important limit when UAM is ON.")
                     }
                 )
+
+                SettingInputSection(
+                    decimalValue: $state.threshold_setting,
+                    booleanValue: $booleanPlaceholder,
+                    shouldDisplayHint: $shouldDisplayHint,
+                    selectedVerboseHint: Binding(
+                        get: { selectedVerboseHint },
+                        set: {
+                            selectedVerboseHint = $0.map { AnyView($0) }
+                            hintLabel = String(localized: "Minimum Safety Threshold")
+                        }
+                    ),
+                    units: state.units,
+                    type: .decimal("threshold_setting"),
+                    label: String(localized: "Minimum Safety Threshold"),
+                    miniHint: String(localized: "Increase the safety threshold used to suspend insulin delivery."),
+                    verboseHint:
+                    VStack(alignment: .leading, spacing: 10) {
+                        Text("Default: Set by Algorithm").bold()
+                        Text(
+                            "Minimum Threshold Setting is, by default, determined by your set Glucose Target. This threshold automatically suspends insulin delivery if your glucose levels are forecasted to fall below this value. It’s designed to protect against hypoglycemia, particularly during sleep or other vulnerable times."
+                        )
+                        Text(
+                            "Trio will use the larger of the default setting calculation below and the value entered here."
+                        )
+                        VStack(alignment: .leading, spacing: 10) {
+                            VStack(alignment: .leading, spacing: 5) {
+                                Text("The default setting is based on this calculation:").bold()
+                                Text("TargetGlucose - 0.5 × (TargetGlucose - 40)")
+                            }
+                            VStack(alignment: .leading, spacing: 5) {
+                                Text(
+                                    "If your glucose target is \(state.units == .mgdL ? "110" : 110.formattedAsMmolL) \(state.units.rawValue), Trio will use a safety threshold of \(state.units == .mgdL ? "75" : 75.formattedAsMmolL) \(state.units.rawValue), unless you set Minimum Safety Threshold to something > \(state.units == .mgdL ? "75" : 75.formattedAsMmolL) \(state.units.rawValue)."
+                                )
+                                Text(
+                                    "\(state.units == .mgdL ? "110" : 110.formattedAsMmolL) - 0.5 × (\(state.units == .mgdL ? "110" : 110.formattedAsMmolL) - \(state.units == .mgdL ? "40" : 40.formattedAsMmolL)) = \(state.units == .mgdL ? "75" : 75.formattedAsMmolL)"
+                                )
+                            }
+                            Text(
+                                "This setting is limited to values between \(state.units == .mgdL ? "60" : 60.formattedAsMmolL) - \(state.units == .mgdL ? "120" : 120.formattedAsMmolL) \(state.units.rawValue)"
+                            )
+                            Text(
+                                "Note: Basal may be resumed if there is negative IOB and glucose is rising faster than the forecast."
+                            )
+                        }
+                    }
+                )
             }
             .listSectionSpacing(sectionSpacing)
             .sheet(isPresented: $shouldDisplayHint) {

+ 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 - 2
Trio/Sources/Modules/Home/HomeStateModel+Setup/ChartAxisSetup.swift

@@ -93,9 +93,8 @@ extension Home.StateModel {
 
             // Ensure min and max IOB values exist, or set defaults
             if let minIob = minIob, let maxIob = maxIob {
-                let adjustedMin = minIob < 0 ? minIob - 2 : 0
                 Task {
-                    await self.updateIobChartBounds(minValue: adjustedMin, maxValue: maxIob + 2)
+                    await self.updateIobChartBounds(minValue: minIob, maxValue: maxIob)
                 }
             } else {
                 Task {

+ 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

+ 16 - 5
Trio/Sources/Modules/Home/View/Chart/ChartElements/CobIobChart.swift

@@ -54,8 +54,10 @@ extension MainChartView {
     }
 
     func combinedYDomain() -> ClosedRange<Double> {
-        let minValue = min(state.minValueCobChart, state.minValueIobChart)
-        let maxValue = max(state.maxValueCobChart, state.maxValueIobChart)
+        let iobMin = scaleIobAmountForChart(state.minValueIobChart)
+        let iobMax = scaleIobAmountForChart(state.maxValueIobChart)
+        let minValue = min(state.minValueCobChart, iobMin)
+        let maxValue = max(state.maxValueCobChart, iobMax)
         return Double(minValue) ... Double(maxValue)
     }
 
@@ -79,6 +81,17 @@ extension MainChartView {
         .position(by: .value("Axis", axis))
     }
 
+    /// Scales IOB amounts for chart display.
+    ///
+    /// As IOB and COB share the same Y axis and COB is usually >> IOB,
+    /// we need to visually weigh IOB by multiplying it by a scaling factor:
+    ///
+    /// - Parameter rawAmount: The unscaled IOB amount
+    /// - Returns: The scaled IOB amount for visual representation
+    private func scaleIobAmountForChart<T: Numeric & Comparable>(_ rawAmount: T) -> T where T: ExpressibleByIntegerLiteral {
+        rawAmount > 0 ? rawAmount * 8 : rawAmount * 9
+    }
+
     func drawCOBIOBChart() -> some ChartContent {
         // Filter out duplicate entries by `deliverAt`,
         // We sometimes get two determinations when editing carbs, one without the entry-to-be-edited and then another one after editing the entry.
@@ -115,9 +128,7 @@ extension MainChartView {
             // MARK: - IOB line and area mark
 
             let rawAmount = item.iob?.doubleValue ?? 0
-
-            // as iob and cob share the same y axis and cob is usually >> iob we need to weigh iob visually
-            let amountIOB: Double = rawAmount > 0 ? rawAmount * 8 : rawAmount * 9
+            let amountIOB: Double = scaleIobAmountForChart(rawAmount)
 
             AreaMark(x: .value("Time", date), y: .value("Amount", amountIOB))
                 .foregroundStyle(by: .value("Type", "IOB"))

+ 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)
             }
         }

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


Niektóre pliki nie zostały wyświetlone z powodu dużej ilości zmienionych plików