Просмотр исходного кода

Merge pull request #1057 from nightscout/sync-dev-eversense-06042026

Sync dev as of 06-04-2026
Deniz Cengiz 1 месяц назад
Родитель
Сommit
703406d266
91 измененных файлов с 11172 добавлено и 2314 удалено
  1. 3 0
      .github/FUNDING.yml
  2. 2 2
      .github/workflows/add_identifiers.yml
  3. 1 1
      .github/workflows/auto_version_dev.yml
  4. 6 6
      .github/workflows/build_trio.yml
  5. 4 4
      .github/workflows/create_certs.yml
  6. 2 2
      .github/workflows/stale_issues.yml
  7. 7 7
      .github/workflows/unit_tests.yml
  8. 2 2
      .github/workflows/validate_secrets.yml
  9. 4 0
      .gitmodules
  10. 1 1
      Config.xcconfig
  11. 1 1
      DanaKit
  12. 1 1
      LoopKit
  13. 1 0
      MedtrumKit
  14. 2 1
      Model/Classes+Properties/GlucoseStored+CoreDataProperties.swift
  15. 5 0
      Model/Helper/PumpEvent+helper.swift
  16. 2 1
      Model/TrioCoreDataPersistentContainer.xcdatamodeld/TrioCoreDataPersistentContainer.xcdatamodel/contents
  17. 66 0
      Trio Watch App Extension/Helper/WatchNotificationHandler.swift
  18. 5 0
      Trio Watch App Extension/TrioWatchApp.swift
  19. 94 35
      Trio.xcodeproj/project.pbxproj
  20. 3 0
      Trio.xcworkspace/contents.xcworkspacedata
  21. 2 2
      Trio.xcworkspace/xcshareddata/swiftpm/Package.resolved
  22. 5 5
      Trio/Resources/Info.plist
  23. 3 111
      Trio/Resources/InfoPlist.xcstrings
  24. 8 3
      Trio/Resources/json/defaults/freeaps/freeaps_settings.json
  25. 12 2
      Trio/Sources/APS/APSManager.swift
  26. 50 0
      Trio/Sources/APS/DeviceDataManager.swift
  27. 224 47
      Trio/Sources/APS/FetchGlucoseManager.swift
  28. 108 12
      Trio/Sources/APS/OpenAPS/OpenAPS.swift
  29. 114 59
      Trio/Sources/APS/Storage/CarbsStorage.swift
  30. 6 3
      Trio/Sources/APS/Storage/PumpHistoryStorage.swift
  31. 5 0
      Trio/Sources/Helpers/Decimal+Extensions.swift
  32. 7 0
      Trio/Sources/Helpers/Formatters.swift
  33. 0 172
      Trio/Sources/Helpers/SavitzkyGolayFilter.swift
  34. 7472 1217
      Trio/Sources/Localizations/Main/Localizable.xcstrings
  35. 86 0
      Trio/Sources/Models/AlgorithmGlucose.swift
  36. 0 17
      Trio/Sources/Models/Autotune.swift
  37. 0 12
      Trio/Sources/Models/BloodGlucose.swift
  38. 22 0
      Trio/Sources/Models/BolusDisplayThreshold.swift
  39. 1 2
      Trio/Sources/Models/DecimalPickerSettings.swift
  40. 0 24
      Trio/Sources/Models/FetchedProfile.swift
  41. 124 0
      Trio/Sources/Models/GarminWatchSettings.swift
  42. 118 17
      Trio/Sources/Models/GarminWatchState.swift
  43. 64 0
      Trio/Sources/Models/NotificationIdentifiers.swift
  44. 86 8
      Trio/Sources/Models/TrioSettings.swift
  45. 3 0
      Trio/Sources/Models/WatchMessageKeys.swift
  46. 24 5
      Trio/Sources/Modules/CGMSettings/View/CGMRootView.swift
  47. 21 3
      Trio/Sources/Modules/History/View/HistoryRootView.swift
  48. 9 0
      Trio/Sources/Modules/Home/HomeStateModel.swift
  49. 16 2
      Trio/Sources/Modules/Home/View/Chart/ChartElements/BasalChart.swift
  50. 27 37
      Trio/Sources/Modules/Home/View/Chart/ChartElements/GlucoseChartView.swift
  51. 6 3
      Trio/Sources/Modules/Home/View/Chart/ChartElements/InsulinView.swift
  52. 14 3
      Trio/Sources/Modules/Home/View/Chart/ChartElements/SelectionPopoverView.swift
  53. 4 2
      Trio/Sources/Modules/Home/View/Chart/MainChartView.swift
  54. 45 25
      Trio/Sources/Modules/Home/View/Header/PumpView.swift
  55. 2 1
      Trio/Sources/Modules/Home/View/HomeRootView.swift
  56. 2 1
      Trio/Sources/Modules/ISFEditor/View/ISFEditorRootView.swift
  57. 1 5
      Trio/Sources/Modules/MealSettings/MealSettingsStateModel.swift
  58. 4 34
      Trio/Sources/Modules/MealSettings/View/MealSettingsRootView.swift
  59. 6 2
      Trio/Sources/Modules/Onboarding/OnboardingStateModel.swift
  60. 2 1
      Trio/Sources/Modules/Onboarding/View/OnboardingRootView.swift
  61. 2 1
      Trio/Sources/Modules/Onboarding/View/OnboardingSteps/AlgorithmSettings/AlgorithmSettingsSubstepView.swift
  62. 2 2
      Trio/Sources/Modules/Onboarding/View/OnboardingView+AlgorithmUtil.swift
  63. 3 0
      Trio/Sources/Modules/Onboarding/View/OnboardingView+Util.swift
  64. 1 0
      Trio/Sources/Modules/PumpConfig/PumpConfigDataFlow.swift
  65. 5 0
      Trio/Sources/Modules/PumpConfig/View/PumpConfigRootView.swift
  66. 10 0
      Trio/Sources/Modules/PumpConfig/View/PumpSetupView.swift
  67. 4 4
      Trio/Sources/Modules/SMBSettings/View/SMBSettingsRootView.swift
  68. 7 7
      Trio/Sources/Modules/Settings/SettingItems.swift
  69. 1 8
      Trio/Sources/Modules/SettingsExport/SettingsExportStateModel.swift
  70. 31 2
      Trio/Sources/Modules/Snooze/SnoozeStateModel.swift
  71. 26 30
      Trio/Sources/Modules/Snooze/View/SnoozeRootView.swift
  72. 1 1
      Trio/Sources/Modules/Stat/View/ViewElements/Glucose/GlucoseSectorChart.swift
  73. 2 2
      Trio/Sources/Modules/Stat/View/ViewElements/Insulin/BolusStatsView.swift
  74. 2 2
      Trio/Sources/Modules/Stat/View/ViewElements/Insulin/TotalDailyDoseChart.swift
  75. 2 2
      Trio/Sources/Modules/Stat/View/ViewElements/Meal/MealStatsView.swift
  76. 39 17
      Trio/Sources/Modules/Treatments/View/ForecastChart.swift
  77. 2 0
      Trio/Sources/Modules/UserInterfaceSettings/UserInterfaceSettingsStateModel.swift
  78. 42 0
      Trio/Sources/Modules/UserInterfaceSettings/View/UserInterfaceSettingsRootView.swift
  79. 274 0
      Trio/Sources/Modules/WatchConfig/View/WatchConfigGarminAppConfigView.swift
  80. 121 16
      Trio/Sources/Modules/WatchConfig/View/WatchConfigGarminView.swift
  81. 19 1
      Trio/Sources/Modules/WatchConfig/WatchConfigStateModel.swift
  82. 112 13
      Trio/Sources/Services/UserNotifications/UserNotificationsManager.swift
  83. 31 16
      Trio/Sources/Services/WatchManager/AppleWatchManager.swift
  84. 204 0
      Trio/Sources/Services/WatchManager/FLOW_DIAGRAM.md
  85. 728 284
      Trio/Sources/Services/WatchManager/GarminManager.swift
  86. 0 2
      Trio/Sources/Views/SettingInputSection.swift
  87. 234 0
      TrioTests/CoreDataTests/CarbsStorageTests.swift
  88. 333 0
      TrioTests/GlucoseSmoothingTests.swift
  89. 2 2
      TrioTests/JSONImporterTests.swift
  90. 15 0
      TrioTests/Mocks/MockTDDStorage.swift
  91. 2 1
      scripts/swiftformat.sh

+ 3 - 0
.github/FUNDING.yml

@@ -0,0 +1,3 @@
+# These are supported funding model platforms
+
+custom: ["https://www.nightscoutfoundation.org/donate"]

+ 2 - 2
.github/workflows/add_identifiers.yml

@@ -12,11 +12,11 @@ jobs:
   identifiers:
     name: Add Identifiers
     needs: validate
-    runs-on: macos-15
+    runs-on: macos-26
     steps:
       # Checks-out the repo
       - name: Checkout Repo
-        uses: actions/checkout@v4
+        uses: actions/checkout@v5
 
       # Patch Fastlane Match to not print tables
       - name: Patch Match Tables

+ 1 - 1
.github/workflows/auto_version_dev.yml

@@ -52,7 +52,7 @@ jobs:
 
     steps:
       - name: Checkout repo
-        uses: actions/checkout@v4
+        uses: actions/checkout@v5
         with:
          token: ${{ secrets.TRIO_TOKEN_AUTOBUMP }}
 

+ 6 - 6
.github/workflows/build_trio.yml

@@ -90,7 +90,7 @@ jobs:
         if: |
           steps.workflow-permission.outputs.has_permission == 'true' &&
           (vars.SCHEDULED_BUILD != 'false' || vars.SCHEDULED_SYNC != 'false')
-        uses: actions/checkout@v4
+        uses: actions/checkout@v5
         with:
           token: ${{ secrets.GH_PAT }}
 
@@ -100,7 +100,7 @@ jobs:
           steps.workflow-permission.outputs.has_permission == 'true' &&
           vars.SCHEDULED_SYNC != 'false' && github.repository_owner != 'nightscout'
         id: sync
-        uses: aormsby/Fork-Sync-With-Upstream-action@v3.4.1
+        uses: aormsby/Fork-Sync-With-Upstream-action@v3.4.2
         with:
           target_sync_branch: ${{ env.TARGET_BRANCH }}
           shallow_since: 6 months ago
@@ -165,7 +165,7 @@ jobs:
   build:
     name: Build
     needs: [check_certs, check_status]
-    runs-on: macos-15
+    runs-on: macos-26
     permissions:
       contents: write
     if:
@@ -175,10 +175,10 @@ jobs:
         (vars.SCHEDULED_SYNC != 'false' && needs.check_status.outputs.NEW_COMMITS == 'true' )
     steps:
       - name: Select Xcode version
-        run: "sudo xcode-select --switch /Applications/Xcode_16.4.app/Contents/Developer"
+        run: "sudo xcode-select --switch /Applications/Xcode_26.2.app/Contents/Developer"
       
       - name: Checkout Repo for building
-        uses: actions/checkout@v4
+        uses: actions/checkout@v5
         with:
           token: ${{ secrets.GH_PAT }}
           submodules: recursive
@@ -258,7 +258,7 @@ jobs:
       # Upload Build artifacts
       - name: Upload build log, IPA and Symbol artifacts
         if: always()
-        uses: actions/upload-artifact@v4
+        uses: actions/upload-artifact@v6
         with:
           name: build-artifacts
           path: |

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

@@ -21,14 +21,14 @@ jobs:
   create_certs:
     name: Certificates
     needs: validate
-    runs-on: macos-15
+    runs-on: macos-26
     outputs:
       new_certificate_needed: ${{ steps.set_output.outputs.new_certificate_needed }}
 
     steps:
       # Checks-out the repo
       - name: Checkout Repo
-        uses: actions/checkout@v4
+        uses: actions/checkout@v5
 
       # Patch Fastlane Match to not print tables
       - name: Patch Match Tables
@@ -90,14 +90,14 @@ jobs:
   nuke_certs:
       name: Nuke certificates
       needs: [validate, create_certs]
-      runs-on: macos-15
+      runs-on: macos-26
       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
+          uses: actions/checkout@v5
 
         - name: Install dependencies
           run: bundle install

+ 2 - 2
.github/workflows/stale_issues.yml

@@ -11,7 +11,7 @@ jobs:
       pull-requests: write
     if: github.repository_owner == 'nightscout'
     steps:
-      - uses: actions/stale@v9.0.0
+      - uses: actions/stale@v10
         with:
           days-before-issue-stale: 30
           days-before-issue-close: 14
@@ -32,7 +32,7 @@ jobs:
       pull-requests: write
     if: github.repository_owner == 'nightscout'
     steps:
-      - uses: actions/stale@v9.0.0
+      - uses: actions/stale@v10
         with:
           days-before-issue-stale: 30
           days-before-issue-close: 30

+ 7 - 7
.github/workflows/unit_tests.yml

@@ -23,22 +23,22 @@ on:
 jobs:
   test:
     name: Run Unit Tests
-    runs-on: macos-15
+    runs-on: macos-26
     if: github.repository_owner == 'nightscout'
 
     steps:
       - name: Select Xcode version
-        run: sudo xcode-select -s /Applications/Xcode_16.4.app/Contents/Developer
+        run: sudo xcode-select -s /Applications/Xcode_26.2.app/Contents/Developer
 
       - name: Checkout code
-        uses: actions/checkout@v4
+        uses: actions/checkout@v5
         with:
           fetch-depth: 1
           submodules: recursive
 
       - name: Restore cache
         id: cache-restore
-        uses: actions/cache/restore@v4
+        uses: actions/cache/restore@v5
         with:
           path: |
             /Users/runner/Library/Developer/Xcode/DerivedData
@@ -64,7 +64,7 @@ jobs:
           time xcodebuild build-for-testing \
             -workspace Trio.xcworkspace \
             -scheme "Trio Tests" \
-            -destination 'platform=iOS Simulator,name=iPhone 16,OS=18.5' \
+            -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' \
 
       - name: Check for uncommitted changes
         run: |
@@ -94,7 +94,7 @@ jobs:
           
       - name: Save cache
         if: steps.cache-restore.outputs.cache-hit != 'true'
-        uses: actions/cache/save@v4
+        uses: actions/cache/save@v5
         with:
           path: |
             /Users/runner/Library/Developer/Xcode/DerivedData
@@ -107,7 +107,7 @@ jobs:
           time xcodebuild test-without-building \
             -workspace Trio.xcworkspace \
             -scheme "Trio Tests" \
-            -destination 'platform=iOS Simulator,name=iPhone 16,OS=18.5' \
+            -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' \
             $([ "$ENABLE_PARALLEL_TESTING" = "true" ] && echo "-parallel-testing-enabled YES") \
             2>&1 | tee xcodebuild.log
 

+ 2 - 2
.github/workflows/validate_secrets.yml

@@ -105,7 +105,7 @@ jobs:
   validate-fastlane-secrets:
     name: Fastlane
     needs: [validate-access-token]
-    runs-on: macos-15
+    runs-on: macos-26
     env:
       GH_PAT: ${{ secrets.GH_PAT }}
       GH_TOKEN: ${{ secrets.GH_PAT }}
@@ -116,7 +116,7 @@ jobs:
       TEAMID: ${{ secrets.TEAMID }}
     steps:
       - name: Checkout Repo
-        uses: actions/checkout@v4
+        uses: actions/checkout@v5
 
       - name: Install Project Dependencies
         run: bundle install

+ 4 - 0
.gitmodules

@@ -41,6 +41,10 @@
 [submodule "DanaKit"]
 	path = DanaKit
 	url = https://github.com/loopandlearn/DanaKit
+[submodule "MedtrumKit"]
+	path = MedtrumKit
+	branch = dev
+	url = https://github.com/loopandlearn/MedtrumKit
 [submodule "EversenseKit"]
 	path = EversenseKit
 	url = https://github.com/loopandlearn/EversenseKit

+ 1 - 1
Config.xcconfig

@@ -19,7 +19,7 @@ TRIO_APP_GROUP_ID = group.org.nightscout.$(DEVELOPMENT_TEAM).trio.trio-app-group
 
 // The developers set the version numbers, please leave them alone
 APP_VERSION = 0.6.0
-APP_DEV_VERSION = 0.6.0.52
+APP_DEV_VERSION = 0.6.0.78
 APP_BUILD_NUMBER = 1
 COPYRIGHT_NOTICE =
 

+ 1 - 1
DanaKit

@@ -1 +1 @@
-Subproject commit 3970b2aadc55044c851130567879fd0ae3ade6cc
+Subproject commit 0158fc85391725bb1855ea34469d48cb65667850

+ 1 - 1
LoopKit

@@ -1 +1 @@
-Subproject commit 9c09a6fea98e2638d76d610ba097c4fae14ca220
+Subproject commit 0229bb18d30b095420aae1e8fa04c37794e0a378

+ 1 - 0
MedtrumKit

@@ -0,0 +1 @@
+Subproject commit b7f3d44c06bb7c580be897e0414e64de2d6dd995

+ 2 - 1
Model/Classes+Properties/GlucoseStored+CoreDataProperties.swift

@@ -11,9 +11,10 @@ public extension GlucoseStored {
     @NSManaged var glucose: Int16
     @NSManaged var id: UUID?
     @NSManaged var isManual: Bool
-    @NSManaged var isUploadedToNS: Bool
     @NSManaged var isUploadedToHealth: Bool
+    @NSManaged var isUploadedToNS: Bool
     @NSManaged var isUploadedToTidepool: Bool
+    @NSManaged var smoothedGlucose: NSDecimalNumber?
 }
 
 extension GlucoseStored: Identifiable {}

+ 5 - 0
Model/Helper/PumpEvent+helper.swift

@@ -84,6 +84,11 @@ extension NSPredicate {
         return NSPredicate(format: "timestamp >= %@", date as NSDate)
     }
 
+    static var pumpHistoryLast48h: NSPredicate {
+        let date = Date() - TimeInterval(hours: 48)
+        return NSPredicate(format: "timestamp >= %@", date as NSDate)
+    }
+
     static var pumpHistoryLast24h: NSPredicate {
         let date = Date.oneDayAgo
         return NSPredicate(format: "timestamp >= %@", date as NSDate)

+ 2 - 1
Model/TrioCoreDataPersistentContainer.xcdatamodeld/TrioCoreDataPersistentContainer.xcdatamodel/contents

@@ -1,5 +1,5 @@
 <?xml version="1.0" encoding="UTF-8" standalone="yes"?>
-<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="24512" systemVersion="25B78" minimumToolsVersion="Automatic" sourceLanguage="Swift" usedWithSwiftData="YES" userDefinedModelVersionIdentifier="">
+<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="24512" systemVersion="25D2128" minimumToolsVersion="Automatic" sourceLanguage="Swift" usedWithSwiftData="YES" userDefinedModelVersionIdentifier="">
     <entity name="BolusStored" representedClassName="BolusStored" syncable="YES">
         <attribute name="amount" optional="YES" attributeType="Decimal" defaultValueString="0"/>
         <attribute name="isExternal" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
@@ -78,6 +78,7 @@
         <attribute name="isUploadedToHealth" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
         <attribute name="isUploadedToNS" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
         <attribute name="isUploadedToTidepool" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
+        <attribute name="smoothedGlucose" optional="YES" attributeType="Decimal"/>
         <fetchIndex name="byDate">
             <fetchIndexElement property="date" type="Binary" order="ascending"/>
         </fetchIndex>

+ 66 - 0
Trio Watch App Extension/Helper/WatchNotificationHandler.swift

@@ -0,0 +1,66 @@
+import Foundation
+import UserNotifications
+import WatchConnectivity
+
+final class WatchNotificationHandler: NSObject, UNUserNotificationCenterDelegate {
+    static let shared = WatchNotificationHandler()
+
+    override private init() {
+        super.init()
+    }
+
+    func configure() {
+        let center = UNUserNotificationCenter.current()
+        center.delegate = self
+        registerCategories(on: center)
+    }
+
+    private func registerCategories(on center: UNUserNotificationCenter) {
+        center.getNotificationCategories { existingCategories in
+            let glucoseCategory = NotificationCategoryFactory.createGlucoseCategory()
+
+            var categories = existingCategories
+            categories.update(with: glucoseCategory)
+            // UNUserNotificationCenter methods should be called on main thread
+            Task { @MainActor in
+                center.setNotificationCategories(categories)
+            }
+        }
+    }
+
+    /// UNUserNotificationCenterDelegate method called when user interacts with a notification on watch.
+    /// This can be called off the main thread. WCSession.transferUserInfo is thread-safe.
+    func userNotificationCenter(
+        _: UNUserNotificationCenter,
+        didReceive response: UNNotificationResponse,
+        withCompletionHandler completionHandler: @escaping () -> Void
+    ) {
+        defer { completionHandler() }
+
+        guard let action = NotificationResponseAction(rawValue: response.actionIdentifier) else { return }
+        sendSnoozeRequest(for: action)
+    }
+
+    /// Sends snooze request to iPhone via WatchConnectivity.
+    /// WCSession.transferUserInfo is thread-safe and can be called from any thread.
+    /// Relies on the watch app's WCSession owner (e.g., WatchState) to handle
+    /// session activation and delegate management.
+    private func sendSnoozeRequest(for action: NotificationResponseAction) {
+        guard WCSession.isSupported() else { return }
+
+        let payload: [String: Any] = [WatchMessageKeys.snoozeDuration: action.minutes]
+        let session = WCSession.default
+
+        // Try sendMessage first if session is reachable and activated (faster, immediate delivery)
+        // Fall back to transferUserInfo if not reachable or if sendMessage fails
+        if session.isReachable, session.activationState == .activated {
+            session.sendMessage(payload, replyHandler: nil) { _ in
+                // Fallback to transferUserInfo if sendMessage fails
+                session.transferUserInfo(payload)
+            }
+        } else {
+            // Session not reachable or not activated - use transferUserInfo (queued delivery)
+            session.transferUserInfo(payload)
+        }
+    }
+}

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

@@ -1,8 +1,13 @@
 import SwiftUI
+import UserNotifications
 
 @main struct TrioWatchApp: App {
     @Environment(\.scenePhase) private var scenePhase
 
+    init() {
+        WatchNotificationHandler.shared.configure()
+    }
+
     var body: some Scene {
         WindowGroup {
             TrioMainWatchView()

+ 94 - 35
Trio.xcodeproj/project.pbxproj

@@ -260,16 +260,22 @@
 		3BD9687F2D8DDD8800899469 /* CryptoSwift in Frameworks */ = {isa = PBXBuildFile; productRef = 3BD9687E2D8DDD8800899469 /* CryptoSwift */; };
 		3BF85FE32E427312000D7351 /* IOBService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BF85FE12E427312000D7351 /* IOBService.swift */; };
 		3E28F2AB2EB5337F00FB9EEB /* ConnectIQ in Frameworks */ = {isa = PBXBuildFile; productRef = 3E28F2AA2EB5337F00FB9EEB /* ConnectIQ */; };
+		3E54EF2C2E476DA40006F54D /* MedtrumKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3E54EF2B2E476DA40006F54D /* MedtrumKit.framework */; };
+		3E54EF2D2E476DA40006F54D /* MedtrumKit.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 3E54EF2B2E476DA40006F54D /* MedtrumKit.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
+		3E62C7822F54CC1B00433237 /* BolusDisplayThreshold.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E62C7812F54CC1600433237 /* BolusDisplayThreshold.swift */; };
 		3E84DA402F48D96000033608 /* EversenseKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3E84DA3F2F48D96000033608 /* EversenseKit.framework */; };
 		3E84DA412F48D96000033608 /* EversenseKit.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 3E84DA3F2F48D96000033608 /* EversenseKit.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
 		45252C95D220E796FDB3B022 /* ConfigEditorDataFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F8A87AA037BD079BA3528BA /* ConfigEditorDataFlow.swift */; };
 		45717281F743594AA9D87191 /* ConfigEditorRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 920DDB21E5D0EB813197500D /* ConfigEditorRootView.swift */; };
+		49090A8D2E9FE8D200D0F5DB /* GarminWatchSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49090A8C2E9FE8D200D0F5DB /* GarminWatchSettings.swift */; };
 		491D6FBD2D56741C00C49F67 /* TempTargetStored+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 491D6FBC2D56741C00C49F67 /* TempTargetStored+CoreDataProperties.swift */; };
 		491D6FBE2D56741C00C49F67 /* TempTargetRunStored+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 491D6FB92D56741C00C49F67 /* TempTargetRunStored+CoreDataClass.swift */; };
 		491D6FBF2D56741C00C49F67 /* TempTargetRunStored+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 491D6FBA2D56741C00C49F67 /* TempTargetRunStored+CoreDataProperties.swift */; };
 		491D6FC02D56741C00C49F67 /* TempTargetStored+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 491D6FBB2D56741C00C49F67 /* TempTargetStored+CoreDataClass.swift */; };
 		49239B432EEA27AD00469145 /* TempTargetCalculations.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49239B422EEA27AD00469145 /* TempTargetCalculations.swift */; };
+		4984D1D42EA2939E00263E83 /* WatchConfigGarminAppConfigView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4984D1D32EA2939E00263E83 /* WatchConfigGarminAppConfigView.swift */; };
 		49B9B57F2D5768D2009C6B59 /* AdjustmentStored+Helper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49B9B57E2D5768D2009C6B59 /* AdjustmentStored+Helper.swift */; };
+		49C782A72F73D9870062B0DD /* AlertEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49C782A62F73D9870062B0DD /* AlertEntry.swift */; };
 		5075C1608E6249A51495C422 /* TargetsEditorProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BDEA2DC60EDE0A3CA54DC73 /* TargetsEditorProvider.swift */; };
 		53F2382465BF74DB1A967C8B /* PumpConfigProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8630D58BDAD6D9C650B9B39 /* PumpConfigProvider.swift */; };
 		581516A42BCED84A00BF67D7 /* DebuggingIdentifiers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 581516A32BCED84A00BF67D7 /* DebuggingIdentifiers.swift */; };
@@ -462,6 +468,9 @@
 		C2AA6CF72E1A734A00BF6C16 /* SettingsExportProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2AA6CF32E1A734A00BF6C16 /* SettingsExportProvider.swift */; };
 		C2AA6CF82E1A734A00BF6C16 /* SettingsExportDataFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2AA6CF22E1A734A00BF6C16 /* SettingsExportDataFlow.swift */; };
 		C2AA6CF92E1A734A00BF6C16 /* SettingsExportStateModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2AA6CF42E1A734A00BF6C16 /* SettingsExportStateModel.swift */; };
+		C2BA6B972F758E7500348E6A /* WatchNotificationHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2BA6B962F758E7500348E6A /* WatchNotificationHandler.swift */; };
+		C2BA6B992F758E7600348E6A /* NotificationIdentifiers.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2BA6B982F758E7600348E6A /* NotificationIdentifiers.swift */; };
+		C2BA6B9A2F7593C300348E6A /* NotificationIdentifiers.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2BA6B982F758E7600348E6A /* NotificationIdentifiers.swift */; };
 		C967DACD3B1E638F8B43BE06 /* ManualTempBasalStateModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CFCFE0781F9074C2917890E8 /* ManualTempBasalStateModel.swift */; };
 		CA370FC152BC98B3D1832968 /* BasalProfileEditorRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF8BCB0C37DEB5EC377B9612 /* BasalProfileEditorRootView.swift */; };
 		CC6C406E2ACDD69E009B8058 /* RawFetchedProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC6C406D2ACDD69E009B8058 /* RawFetchedProfile.swift */; };
@@ -487,7 +496,6 @@
 		CE7CA3562A064973004BE681 /* StateIntentRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE7CA34D2A064973004BE681 /* StateIntentRequest.swift */; };
 		CE7CA3582A064E2F004BE681 /* ListStateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE7CA3572A064E2F004BE681 /* ListStateView.swift */; };
 		CE82E02528E867BA00473A9C /* AlertStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE82E02428E867BA00473A9C /* AlertStorage.swift */; };
-		CE82E02728E869DF00473A9C /* AlertEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE82E02628E869DF00473A9C /* AlertEntry.swift */; };
 		CE94597E29E9E1EE0047C9C6 /* GarminManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE94597D29E9E1EE0047C9C6 /* GarminManager.swift */; };
 		CE94598029E9E3BD0047C9C6 /* WatchConfigDataFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE94597F29E9E3BD0047C9C6 /* WatchConfigDataFlow.swift */; };
 		CE94598229E9E3D30047C9C6 /* WatchConfigProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE94598129E9E3D30047C9C6 /* WatchConfigProvider.swift */; };
@@ -505,7 +513,6 @@
 		CE95BF622BA7715900DC3DE3 /* MockKitUI.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 3818AA4F274C26A300843DB3 /* MockKitUI.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
 		CE95BF632BA771BE00DC3DE3 /* LoopTestingKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3818AA70274C278200843DB3 /* LoopTestingKit.framework */; };
 		CE95BF642BA771BE00DC3DE3 /* LoopTestingKit.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 3818AA70274C278200843DB3 /* LoopTestingKit.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
-		CEA4F62329BE10F70011ADF7 /* SavitzkyGolayFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEA4F62229BE10F70011ADF7 /* SavitzkyGolayFilter.swift */; };
 		CEB434E328B8F9DB00B70274 /* BluetoothStateManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEB434E228B8F9DB00B70274 /* BluetoothStateManager.swift */; };
 		CEB434E528B8FF5D00B70274 /* UIColor.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEB434E428B8FF5D00B70274 /* UIColor.swift */; };
 		CEB434E728B9053300B70274 /* LoopUIColorPalette+Default.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEB434E628B9053300B70274 /* LoopUIColorPalette+Default.swift */; };
@@ -625,6 +632,7 @@
 		DD9ECB712CA9A0BA00AA7C45 /* RemoteControlConfigProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD9ECB6E2CA9A0BA00AA7C45 /* RemoteControlConfigProvider.swift */; };
 		DD9ECB722CA9A0BA00AA7C45 /* RemoteControlConfigDataFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD9ECB6F2CA9A0BA00AA7C45 /* RemoteControlConfigDataFlow.swift */; };
 		DD9ECB742CA9A0C300AA7C45 /* RemoteControlConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD9ECB732CA9A0C300AA7C45 /* RemoteControlConfig.swift */; };
+		DDA40BBA2F4DB18800257798 /* AlgorithmGlucose.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDA40BB92F4DB18100257798 /* AlgorithmGlucose.swift */; };
 		DDA6E2502D22187500C2988C /* ChartLegendView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDA6E24F2D22187500C2988C /* ChartLegendView.swift */; };
 		DDA6E2852D2361F800C2988C /* LoopStatusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDA6E2842D2361F800C2988C /* LoopStatusView.swift */; };
 		DDA6E3202D258E0500C2988C /* OverrideHelpView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDA6E31F2D258E0500C2988C /* OverrideHelpView.swift */; };
@@ -654,6 +662,10 @@
 		DDD78A912DC4064800AC63F3 /* carbhistory.json in Resources */ = {isa = PBXBuildFile; fileRef = DDD78A902DC4064800AC63F3 /* carbhistory.json */; };
 		DDD78AD92DC421B500AC63F3 /* enacted.json in Resources */ = {isa = PBXBuildFile; fileRef = DDD78AD72DC421B500AC63F3 /* enacted.json */; };
 		DDD78ADA2DC421B500AC63F3 /* suggested.json in Resources */ = {isa = PBXBuildFile; fileRef = DDD78AD82DC421B500AC63F3 /* suggested.json */; };
+		DDD7C8C12F4DB45400E5CF09 /* GlucoseStored+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDD7C8BF2F4DB45400E5CF09 /* GlucoseStored+CoreDataClass.swift */; };
+		DDD7C8C22F4DB45400E5CF09 /* GlucoseStored+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDD7C8C02F4DB45400E5CF09 /* GlucoseStored+CoreDataProperties.swift */; };
+		DDDD0FFB2F4E22C000F9C645 /* GlucoseSmoothingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDDD0FFA2F4E22C000F9C645 /* GlucoseSmoothingTests.swift */; };
+		DDDD0FFF2F4E231B00F9C645 /* MockTDDStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDDD0FFE2F4E231B00F9C645 /* MockTDDStorage.swift */; };
 		DDE179522C910127003CDDB7 /* MealPresetStored+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDE179322C910127003CDDB7 /* MealPresetStored+CoreDataClass.swift */; };
 		DDE179532C910127003CDDB7 /* MealPresetStored+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDE179332C910127003CDDB7 /* MealPresetStored+CoreDataProperties.swift */; };
 		DDE179542C910127003CDDB7 /* LoopStatRecord+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDE179342C910127003CDDB7 /* LoopStatRecord+CoreDataClass.swift */; };
@@ -670,8 +682,6 @@
 		DDE179612C910127003CDDB7 /* StatsData+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDE179412C910127003CDDB7 /* StatsData+CoreDataProperties.swift */; };
 		DDE179622C910127003CDDB7 /* Forecast+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDE179422C910127003CDDB7 /* Forecast+CoreDataClass.swift */; };
 		DDE179632C910127003CDDB7 /* Forecast+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDE179432C910127003CDDB7 /* Forecast+CoreDataProperties.swift */; };
-		DDE179642C910127003CDDB7 /* GlucoseStored+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDE179442C910127003CDDB7 /* GlucoseStored+CoreDataClass.swift */; };
-		DDE179652C910127003CDDB7 /* GlucoseStored+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDE179452C910127003CDDB7 /* GlucoseStored+CoreDataProperties.swift */; };
 		DDE179662C910127003CDDB7 /* OpenAPS_Battery+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDE179462C910127003CDDB7 /* OpenAPS_Battery+CoreDataClass.swift */; };
 		DDE179672C910127003CDDB7 /* OpenAPS_Battery+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDE179472C910127003CDDB7 /* OpenAPS_Battery+CoreDataProperties.swift */; };
 		DDE179682C910127003CDDB7 /* TempBasalStored+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDE179482C910127003CDDB7 /* TempBasalStored+CoreDataClass.swift */; };
@@ -789,6 +799,7 @@
 				3B4BA7792D8DBD690069D5B8 /* MinimedKit.framework in Embed Frameworks */,
 				CE95BF5C2BA770C300DC3DE3 /* LoopKit.framework in Embed Frameworks */,
 				3B4BA7712D8DBD690069D5B8 /* G7SensorKit.framework in Embed Frameworks */,
+				3E54EF2D2E476DA40006F54D /* MedtrumKit.framework in Embed Frameworks */,
 				CEB434FE28B90B8C00B70274 /* SwiftCharts in Embed Frameworks */,
 				3B4BA7812D8DBD690069D5B8 /* OmniKitUI.framework in Embed Frameworks */,
 				3B4BA76F2D8DBD690069D5B8 /* DanaKit.framework in Embed Frameworks */,
@@ -873,7 +884,6 @@
 		1967DFBD29D052C200759F30 /* Icons.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Icons.swift; sourceTree = "<group>"; };
 		1967DFBF29D053AC00759F30 /* IconSelection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IconSelection.swift; sourceTree = "<group>"; };
 		1967DFC129D053D300759F30 /* IconImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IconImage.swift; sourceTree = "<group>"; };
-		199561C0275E61A50077B976 /* HealthKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = HealthKit.framework; path = Platforms/WatchOS.platform/Developer/SDKs/WatchOS8.0.sdk/System/Library/Frameworks/HealthKit.framework; sourceTree = DEVELOPER_DIR; };
 		19A910352A24D6D700C8951B /* DateFilter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateFilter.swift; sourceTree = "<group>"; };
 		19B0EF2028F6D66200069496 /* Statistics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Statistics.swift; sourceTree = "<group>"; };
 		19D466A229AA2B80004D5F33 /* MealSettingsDataFlow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MealSettingsDataFlow.swift; sourceTree = "<group>"; };
@@ -1093,17 +1103,22 @@
 		3BDEA2DC60EDE0A3CA54DC73 /* TargetsEditorProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TargetsEditorProvider.swift; sourceTree = "<group>"; };
 		3BF768BD6264FF7D71D66767 /* NightscoutConfigProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NightscoutConfigProvider.swift; sourceTree = "<group>"; };
 		3BF85FE12E427312000D7351 /* IOBService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IOBService.swift; sourceTree = "<group>"; };
+		3E54EF2B2E476DA40006F54D /* MedtrumKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = MedtrumKit.framework; sourceTree = BUILT_PRODUCTS_DIR; };
+		3E62C7812F54CC1600433237 /* BolusDisplayThreshold.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BolusDisplayThreshold.swift; sourceTree = "<group>"; };
 		3E84DA3F2F48D96000033608 /* EversenseKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = EversenseKit.framework; sourceTree = BUILT_PRODUCTS_DIR; };
 		3F60E97100041040446F44E7 /* PumpConfigStateModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PumpConfigStateModel.swift; sourceTree = "<group>"; };
 		3F8A87AA037BD079BA3528BA /* ConfigEditorDataFlow.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ConfigEditorDataFlow.swift; sourceTree = "<group>"; };
 		42369F66CF91F30624C0B3A6 /* BasalProfileEditorProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BasalProfileEditorProvider.swift; sourceTree = "<group>"; };
 		44080E4709E3AE4B73054563 /* ConfigEditorProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ConfigEditorProvider.swift; sourceTree = "<group>"; };
+		49090A8C2E9FE8D200D0F5DB /* GarminWatchSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GarminWatchSettings.swift; sourceTree = "<group>"; };
 		491D6FB92D56741C00C49F67 /* TempTargetRunStored+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TempTargetRunStored+CoreDataClass.swift"; sourceTree = "<group>"; };
 		491D6FBA2D56741C00C49F67 /* TempTargetRunStored+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TempTargetRunStored+CoreDataProperties.swift"; sourceTree = "<group>"; };
 		491D6FBB2D56741C00C49F67 /* TempTargetStored+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TempTargetStored+CoreDataClass.swift"; sourceTree = "<group>"; };
 		491D6FBC2D56741C00C49F67 /* TempTargetStored+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TempTargetStored+CoreDataProperties.swift"; sourceTree = "<group>"; };
 		49239B422EEA27AD00469145 /* TempTargetCalculations.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TempTargetCalculations.swift; sourceTree = "<group>"; };
+		4984D1D32EA2939E00263E83 /* WatchConfigGarminAppConfigView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchConfigGarminAppConfigView.swift; sourceTree = "<group>"; };
 		49B9B57E2D5768D2009C6B59 /* AdjustmentStored+Helper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AdjustmentStored+Helper.swift"; sourceTree = "<group>"; };
+		49C782A62F73D9870062B0DD /* AlertEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertEntry.swift; sourceTree = "<group>"; };
 		4DD795BA46B193644D48138C /* TargetsEditorRootView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TargetsEditorRootView.swift; sourceTree = "<group>"; };
 		505E09DC17A0C3D0AF4B66FE /* ISFEditorStateModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ISFEditorStateModel.swift; sourceTree = "<group>"; };
 		581516A32BCED84A00BF67D7 /* DebuggingIdentifiers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebuggingIdentifiers.swift; sourceTree = "<group>"; };
@@ -1296,6 +1311,8 @@
 		C2AA6CF22E1A734A00BF6C16 /* SettingsExportDataFlow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsExportDataFlow.swift; sourceTree = "<group>"; };
 		C2AA6CF32E1A734A00BF6C16 /* SettingsExportProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsExportProvider.swift; sourceTree = "<group>"; };
 		C2AA6CF42E1A734A00BF6C16 /* SettingsExportStateModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsExportStateModel.swift; sourceTree = "<group>"; };
+		C2BA6B962F758E7500348E6A /* WatchNotificationHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchNotificationHandler.swift; sourceTree = "<group>"; };
+		C2BA6B982F758E7600348E6A /* NotificationIdentifiers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationIdentifiers.swift; sourceTree = "<group>"; };
 		C377490C77661D75E8C50649 /* ManualTempBasalRootView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ManualTempBasalRootView.swift; sourceTree = "<group>"; };
 		C8D1A7CA8C10C4403D4BBFA7 /* TreatmentsDataFlow.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TreatmentsDataFlow.swift; sourceTree = "<group>"; };
 		CC6C406D2ACDD69E009B8058 /* RawFetchedProfile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RawFetchedProfile.swift; sourceTree = "<group>"; };
@@ -1328,7 +1345,6 @@
 		CE7CA34D2A064973004BE681 /* StateIntentRequest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StateIntentRequest.swift; sourceTree = "<group>"; };
 		CE7CA3572A064E2F004BE681 /* ListStateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListStateView.swift; sourceTree = "<group>"; };
 		CE82E02428E867BA00473A9C /* AlertStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertStorage.swift; sourceTree = "<group>"; };
-		CE82E02628E869DF00473A9C /* AlertEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertEntry.swift; sourceTree = "<group>"; };
 		CE94597929E9DF7B0047C9C6 /* ConnectIQ.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = ConnectIQ.framework; path = "Dependencies/ios-armv7_arm64/ConnectIQ.framework"; sourceTree = "<group>"; };
 		CE94597D29E9E1EE0047C9C6 /* GarminManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GarminManager.swift; sourceTree = "<group>"; };
 		CE94597F29E9E3BD0047C9C6 /* WatchConfigDataFlow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchConfigDataFlow.swift; sourceTree = "<group>"; };
@@ -1339,7 +1355,6 @@
 		CE95BF4A2BA5CED700DC3DE3 /* LoopKitUI.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = LoopKitUI.framework; sourceTree = BUILT_PRODUCTS_DIR; };
 		CE95BF562BA5F5FE00DC3DE3 /* PluginManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PluginManager.swift; sourceTree = "<group>"; };
 		CE95BF592BA62E4A00DC3DE3 /* PluginSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PluginSource.swift; sourceTree = "<group>"; };
-		CEA4F62229BE10F70011ADF7 /* SavitzkyGolayFilter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SavitzkyGolayFilter.swift; sourceTree = "<group>"; };
 		CEB434DB28B8F5B900B70274 /* MKRingProgressView.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = MKRingProgressView.framework; sourceTree = BUILT_PRODUCTS_DIR; };
 		CEB434DE28B8F5C400B70274 /* OmniBLE.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = OmniBLE.framework; sourceTree = BUILT_PRODUCTS_DIR; };
 		CEB434E228B8F9DB00B70274 /* BluetoothStateManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BluetoothStateManager.swift; sourceTree = "<group>"; };
@@ -1461,6 +1476,7 @@
 		DD9ECB6E2CA9A0BA00AA7C45 /* RemoteControlConfigProvider.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteControlConfigProvider.swift; sourceTree = "<group>"; };
 		DD9ECB6F2CA9A0BA00AA7C45 /* RemoteControlConfigDataFlow.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteControlConfigDataFlow.swift; sourceTree = "<group>"; };
 		DD9ECB732CA9A0C300AA7C45 /* RemoteControlConfig.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteControlConfig.swift; sourceTree = "<group>"; };
+		DDA40BB92F4DB18100257798 /* AlgorithmGlucose.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlgorithmGlucose.swift; sourceTree = "<group>"; };
 		DDA6E24F2D22187500C2988C /* ChartLegendView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChartLegendView.swift; sourceTree = "<group>"; };
 		DDA6E2842D2361F800C2988C /* LoopStatusView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoopStatusView.swift; sourceTree = "<group>"; };
 		DDA6E31F2D258E0500C2988C /* OverrideHelpView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverrideHelpView.swift; sourceTree = "<group>"; };
@@ -1493,6 +1509,10 @@
 		DDD78A902DC4064800AC63F3 /* carbhistory.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = carbhistory.json; sourceTree = "<group>"; };
 		DDD78AD72DC421B500AC63F3 /* enacted.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = enacted.json; sourceTree = "<group>"; };
 		DDD78AD82DC421B500AC63F3 /* suggested.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = suggested.json; sourceTree = "<group>"; };
+		DDD7C8BF2F4DB45400E5CF09 /* GlucoseStored+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GlucoseStored+CoreDataClass.swift"; sourceTree = "<group>"; };
+		DDD7C8C02F4DB45400E5CF09 /* GlucoseStored+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GlucoseStored+CoreDataProperties.swift"; sourceTree = "<group>"; };
+		DDDD0FFA2F4E22C000F9C645 /* GlucoseSmoothingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseSmoothingTests.swift; sourceTree = "<group>"; };
+		DDDD0FFE2F4E231B00F9C645 /* MockTDDStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockTDDStorage.swift; sourceTree = "<group>"; };
 		DDE179322C910127003CDDB7 /* MealPresetStored+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MealPresetStored+CoreDataClass.swift"; sourceTree = "<group>"; };
 		DDE179332C910127003CDDB7 /* MealPresetStored+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MealPresetStored+CoreDataProperties.swift"; sourceTree = "<group>"; };
 		DDE179342C910127003CDDB7 /* LoopStatRecord+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "LoopStatRecord+CoreDataClass.swift"; sourceTree = "<group>"; };
@@ -1509,8 +1529,6 @@
 		DDE179412C910127003CDDB7 /* StatsData+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "StatsData+CoreDataProperties.swift"; sourceTree = "<group>"; };
 		DDE179422C910127003CDDB7 /* Forecast+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Forecast+CoreDataClass.swift"; sourceTree = "<group>"; };
 		DDE179432C910127003CDDB7 /* Forecast+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Forecast+CoreDataProperties.swift"; sourceTree = "<group>"; };
-		DDE179442C910127003CDDB7 /* GlucoseStored+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GlucoseStored+CoreDataClass.swift"; sourceTree = "<group>"; };
-		DDE179452C910127003CDDB7 /* GlucoseStored+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GlucoseStored+CoreDataProperties.swift"; sourceTree = "<group>"; };
 		DDE179462C910127003CDDB7 /* OpenAPS_Battery+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "OpenAPS_Battery+CoreDataClass.swift"; sourceTree = "<group>"; };
 		DDE179472C910127003CDDB7 /* OpenAPS_Battery+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "OpenAPS_Battery+CoreDataProperties.swift"; sourceTree = "<group>"; };
 		DDE179482C910127003CDDB7 /* TempBasalStored+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TempBasalStored+CoreDataClass.swift"; sourceTree = "<group>"; };
@@ -1590,6 +1608,7 @@
 				3BD9687C2D8DDD4600899469 /* SlideButton in Frameworks */,
 				3B4BA7782D8DBD690069D5B8 /* MinimedKit.framework in Frameworks */,
 				3B4BA7762D8DBD690069D5B8 /* LibreTransmitterUI.framework in Frameworks */,
+				3E54EF2C2E476DA40006F54D /* MedtrumKit.framework in Frameworks */,
 				3B4BA7902D8DC0EC0069D5B8 /* TidepoolServiceKitUI.framework in Frameworks */,
 				3B4BA76A2D8DBD690069D5B8 /* CGMBLEKit.framework in Frameworks */,
 				3B4BA77C2D8DBD690069D5B8 /* OmniBLE.framework in Frameworks */,
@@ -1774,7 +1793,6 @@
 		192F0FF5276AC36D0085BE4D /* Recovered References */ = {
 			isa = PBXGroup;
 			children = (
-				199561C0275E61A50077B976 /* HealthKit.framework */,
 			);
 			name = "Recovered References";
 			sourceTree = "<group>";
@@ -2196,6 +2214,7 @@
 		3818AA48274C267000843DB3 /* Frameworks */ = {
 			isa = PBXGroup;
 			children = (
+				3E54EF2B2E476DA40006F54D /* MedtrumKit.framework */,
 				3E84DA3F2F48D96000033608 /* EversenseKit.framework */,
 				3B4BA7882D8DC0EC0069D5B8 /* TidepoolServiceKit.framework */,
 				3B4BA7892D8DC0EC0069D5B8 /* TidepoolServiceKitUI.framework */,
@@ -2375,20 +2394,9 @@
 		388E5A5925B6F0250019842D /* Models */ = {
 			isa = PBXGroup;
 			children = (
-				DD3D60302F0377350021A33B /* ExportSetting.swift */,
-				DDFF204F2DB2C11900AB8A96 /* WatchStateSnapshot.swift */,
-				DDEBB05B2D89E9050032305D /* TimeInRangeType.swift */,
-				3B2F77852D7E52ED005ED9FA /* TDD.swift */,
-				DD4FFF322D458EE600B6CFF9 /* GarminWatchState.swift */,
-				DD3078692D42F94000DE0490 /* GarminDevice.swift */,
-				DD3078672D42F5CE00DE0490 /* WatchGlucoseObject.swift */,
-				BD432CA02D2F4E3300D1EB79 /* WatchMessageKeys.swift */,
-				BD54A9722D281A9C00F9C1EE /* TempTargetPresetWatch.swift */,
-				BD54A95A2D28087700F9C1EE /* OverridePresetWatch.swift */,
-				BDA25EFC2D261BF200035F34 /* WatchState.swift */,
-				715120D12D3C2B84005D9FB6 /* GlucoseNotificationsOption.swift */,
-				DD940BA92CA7585D000830A5 /* GlucoseColorScheme.swift */,
-				DD6D67E32C9C253500660C9B /* ColorSchemeOption.swift */,
+				49C782A62F73D9870062B0DD /* AlertEntry.swift */,
+				DDA40BB92F4DB18100257798 /* AlgorithmGlucose.swift */,
+				3E62C7812F54CC1600433237 /* BolusDisplayThreshold.swift */,
 				388E5A5F25B6F2310019842D /* Autosens.swift */,
 				388358C725EEF6D200E024B2 /* BasalProfileEntry.swift */,
 				38D0B3B525EBE24900CB6E88 /* Battery.swift */,
@@ -2396,24 +2404,55 @@
 				3870FF4225EC13F40088248F /* BloodGlucose.swift */,
 				38A9260425F012D8009E3739 /* CarbRatios.swift */,
 				38D0B3D825EC07C400CB6E88 /* CarbsEntry.swift */,
-				3811DF0125CA9FEA00A708ED /* Credentials.swift */,
+				19D4E4EA29FC6A9F00351451 /* Charts.swift */,
+				DD6D67E32C9C253500660C9B /* ColorSchemeOption.swift */,
+				DD9ECB692CA99F6C00AA7C45 /* CommandPayload.swift */,
 				E592A36F2CEEC01E009A472C /* ContactTrickEntry.swift */,
-				38AEE73C25F0200C0013F05B /* TrioSettings.swift */,
+				3811DF0125CA9FEA00A708ED /* Credentials.swift */,
+				19A910352A24D6D700C8951B /* DateFilter.swift */,
+				DD21FCB42C6952AD00AF2C25 /* DecimalPickerSettings.swift */,
+				583684072BD195A700070A60 /* Determination.swift */,
+				DDD6D4D22CDE90720029439A /* EstimatedA1cDisplayUnit.swift */,
+				DD3D60302F0377350021A33B /* ExportSetting.swift */,
+				DD6B7CB32C7B71F700B75029 /* ForecastDisplayType.swift */,
+				DD3078692D42F94000DE0490 /* GarminDevice.swift */,
+				49090A8C2E9FE8D200D0F5DB /* GarminWatchSettings.swift */,
+				DD4FFF322D458EE600B6CFF9 /* GarminWatchState.swift */,
+				DD940BA92CA7585D000830A5 /* GlucoseColorScheme.swift */,
+				715120D12D3C2B84005D9FB6 /* GlucoseNotificationsOption.swift */,
+				E0D4F80427513ECF00BDF1FE /* HealthKitSample.swift */,
+				1967DFBD29D052C200759F30 /* Icons.swift */,
 				382C133625F13A1E00715CE1 /* InsulinSensitivities.swift */,
 				38887CCD25F5725200944304 /* IOBEntry.swift */,
+				BDF530D72B40F8AC002CAF43 /* LockScreenView.swift */,
+				193F6CDC2A512C8F001240FD /* Loops.swift */,
+				19012CDB291D2CB900FB8210 /* LoopStats.swift */,
 				DD68889C2C386E17006E3C44 /* NightscoutExercise.swift */,
+				FE41E4D529463EE20047FD55 /* NightscoutPreferences.swift */,
+				191F62672AD6B05A004D7911 /* NightscoutSettings.swift */,
 				385CEA8125F23DFD002D6D5B /* NightscoutStatus.swift */,
 				389442CA25F65F7100FA1F27 /* NightscoutTreatment.swift */,
+				BDC2EA462C3045AD00E5BBD0 /* Override.swift */,
+				BD54A95A2D28087700F9C1EE /* OverridePresetWatch.swift */,
 				3895E4C525B9E00D00214B37 /* Preferences.swift */,
 				38A13D3125E28B4B00EAA382 /* PumpHistoryEvent.swift */,
 				3883583325EEB38000E024B2 /* PumpSettings.swift */,
 				38E989DC25F5021400C0CED0 /* PumpStatus.swift */,
+				CC6C406D2ACDD69E009B8058 /* RawFetchedProfile.swift */,
 				38BF021C25E7E3AF00579895 /* Reservoir.swift */,
+				19B0EF2028F6D66200069496 /* Statistics.swift */,
+				3B2F77852D7E52ED005ED9FA /* TDD.swift */,
 				38A0364125ED069400FCBB52 /* TempBasal.swift */,
 				3871F39B25ED892B0013ECB5 /* TempTarget.swift */,
-				3811DE8E25C9D80400A708ED /* User.swift */,
-				E0D4F80427513ECF00BDF1FE /* HealthKitSample.swift */,
+				BD54A9722D281A9C00F9C1EE /* TempTargetPresetWatch.swift */,
+				DDEBB05B2D89E9050032305D /* TimeInRangeType.swift */,
 				1935363F28496F7D001E0B16 /* TrioCustomOrefVariables.swift */,
+				38AEE73C25F0200C0013F05B /* TrioSettings.swift */,
+				3811DE8E25C9D80400A708ED /* User.swift */,
+				DD3078672D42F5CE00DE0490 /* WatchGlucoseObject.swift */,
+				BD432CA02D2F4E3300D1EB79 /* WatchMessageKeys.swift */,
+				BDA25EFC2D261BF200035F34 /* WatchState.swift */,
+				DDFF204F2DB2C11900AB8A96 /* WatchStateSnapshot.swift */,
 				CE82E02628E869DF00473A9C /* AlertEntry.swift */,
 				19B0EF2028F6D66200069496 /* Statistics.swift */,
 				19012CDB291D2CB900FB8210 /* LoopStats.swift */,
@@ -2431,6 +2470,7 @@
 				DD6B7CB32C7B71F700B75029 /* ForecastDisplayType.swift */,
 				DD9ECB692CA99F6C00AA7C45 /* CommandPayload.swift */,
 				DDD6D4D22CDE90720029439A /* EstimatedA1cDisplayUnit.swift */,
+				C2BA6B982F758E7600348E6A /* NotificationIdentifiers.swift */,
 			);
 			path = Models;
 			sourceTree = "<group>";
@@ -2469,7 +2509,6 @@
 				3811DEE325CA063400A708ED /* PropertyWrappers */,
 				3811DE5525C9D4D500A708ED /* Publisher.swift */,
 				DD6B7CB12C7B6F0800B75029 /* Rounding.swift */,
-				CEA4F62229BE10F70011ADF7 /* SavitzkyGolayFilter.swift */,
 				38E98A3625F5509500C0CED0 /* String+Extensions.swift */,
 				49239B422EEA27AD00469145 /* TempTargetCalculations.swift */,
 				DDCAE8322D78D49C00B1BB51 /* TherapySettingsUtil.swift */,
@@ -2614,6 +2653,8 @@
 		38FCF3EE25E9028E0078B0D1 /* TrioTests */ = {
 			isa = PBXGroup;
 			children = (
+				DDDD0FFD2F4E231600F9C645 /* Mocks */,
+				DDDD0FFA2F4E22C000F9C645 /* GlucoseSmoothingTests.swift */,
 				DDC6CA6C2DD90A2A0060EE25 /* LocalizationTests.swift */,
 				3B997DD22DC02AEF006B6BB2 /* JSONImporterData */,
 				BD8FC05C2D6618BE00B95AED /* BolusCalculatorTests */,
@@ -3163,6 +3204,7 @@
 		CE94598529E9E3FE0047C9C6 /* View */ = {
 			isa = PBXGroup;
 			children = (
+				4984D1D32EA2939E00263E83 /* WatchConfigGarminAppConfigView.swift */,
 				CE94598629E9E4110047C9C6 /* WatchConfigRootView.swift */,
 				DDF847E72C5DABA30049BB3B /* WatchConfigAppleWatchView.swift */,
 				DDF847E92C5DABAC0049BB3B /* WatchConfigGarminView.swift */,
@@ -3381,6 +3423,7 @@
 				DD09D5C62D29EB26000D82C9 /* Helper+Enums.swift */,
 				DD3A3CE82D29C97800AE478E /* Helper+ButtonStyles.swift */,
 				DD3A3CE62D29C93F00AE478E /* Helper+Extensions.swift */,
+				C2BA6B962F758E7500348E6A /* WatchNotificationHandler.swift */,
 			);
 			path = Helper;
 			sourceTree = "<group>";
@@ -3583,9 +3626,19 @@
 			path = View;
 			sourceTree = "<group>";
 		};
+		DDDD0FFD2F4E231600F9C645 /* Mocks */ = {
+			isa = PBXGroup;
+			children = (
+				DDDD0FFE2F4E231B00F9C645 /* MockTDDStorage.swift */,
+			);
+			path = Mocks;
+			sourceTree = "<group>";
+		};
 		DDE179112C9100FA003CDDB7 /* Classes+Properties */ = {
 			isa = PBXGroup;
 			children = (
+				DDD7C8BF2F4DB45400E5CF09 /* GlucoseStored+CoreDataClass.swift */,
+				DDD7C8C02F4DB45400E5CF09 /* GlucoseStored+CoreDataProperties.swift */,
 				BD4D738B2D15A4080052227B /* TDDStored+CoreDataClass.swift */,
 				BD4D738C2D15A4080052227B /* TDDStored+CoreDataProperties.swift */,
 				DDE179362C910127003CDDB7 /* BolusStored+CoreDataClass.swift */,
@@ -3600,8 +3653,6 @@
 				DDE179432C910127003CDDB7 /* Forecast+CoreDataProperties.swift */,
 				DDE179382C910127003CDDB7 /* ForecastValue+CoreDataClass.swift */,
 				DDE179392C910127003CDDB7 /* ForecastValue+CoreDataProperties.swift */,
-				DDE179442C910127003CDDB7 /* GlucoseStored+CoreDataClass.swift */,
-				DDE179452C910127003CDDB7 /* GlucoseStored+CoreDataProperties.swift */,
 				DDE179342C910127003CDDB7 /* LoopStatRecord+CoreDataClass.swift */,
 				DDE179352C910127003CDDB7 /* LoopStatRecord+CoreDataProperties.swift */,
 				DDE179322C910127003CDDB7 /* MealPresetStored+CoreDataClass.swift */,
@@ -4153,6 +4204,7 @@
 			isa = PBXSourcesBuildPhase;
 			buildActionMask = 2147483647;
 			files = (
+				DDA40BBA2F4DB18800257798 /* AlgorithmGlucose.swift in Sources */,
 				DD5DC9F12CF3D97C00AB8703 /* AdjustmentsStateModel+Overrides.swift in Sources */,
 				3811DE2325C9D48300A708ED /* MainDataFlow.swift in Sources */,
 				C2A0A42F2CE03131003B98E8 /* ConstantValues.swift in Sources */,
@@ -4181,7 +4233,6 @@
 				DD98ACC02D71013200C0778F /* StatChartUtils.swift in Sources */,
 				BD47FDD92D8B657D0043966B /* InsulinSensitivityStepView.swift in Sources */,
 				3862CC2E2743F9F700BF832C /* CalendarManager.swift in Sources */,
-				CEA4F62329BE10F70011ADF7 /* SavitzkyGolayFilter.swift in Sources */,
 				38B4F3C325E2A20B00E76A18 /* PumpSetupView.swift in Sources */,
 				38E4453C274E411700EC9A94 /* Disk+Codable.swift in Sources */,
 				58D08B322C8DF88900AA37D3 /* DummyCharts.swift in Sources */,
@@ -4262,6 +4313,7 @@
 				BD8E6B232D9036F700ABF8FA /* OnboardingDataFlow.swift in Sources */,
 				C2A6D1E42DB1581D0036DB66 /* GlucoseStatsSetup.swift in Sources */,
 				3811DE3125C9D49500A708ED /* HomeProvider.swift in Sources */,
+				4984D1D42EA2939E00263E83 /* WatchConfigGarminAppConfigView.swift in Sources */,
 				FE41E4D629463EE20047FD55 /* NightscoutPreferences.swift in Sources */,
 				DD868FD82E381A54005D3308 /* APNSJWTClaims.swift in Sources */,
 				E013D872273AC6FE0014109C /* GlucoseSimulatorSource.swift in Sources */,
@@ -4310,6 +4362,7 @@
 				DDF691012DA2CA11008BF16C /* AppDiagnosticsDataFlow.swift in Sources */,
 				DDA6E3202D258E0500C2988C /* OverrideHelpView.swift in Sources */,
 				DDA6E2502D22187500C2988C /* ChartLegendView.swift in Sources */,
+				49090A8D2E9FE8D200D0F5DB /* GarminWatchSettings.swift in Sources */,
 				49239B432EEA27AD00469145 /* TempTargetCalculations.swift in Sources */,
 				3811DEAF25C9D88300A708ED /* KeyValueStorage.swift in Sources */,
 				DDD6D4D32CDE90720029439A /* EstimatedA1cDisplayUnit.swift in Sources */,
@@ -4366,6 +4419,7 @@
 				CEE9A6592BBB418300EB5194 /* CalibrationsDataFlow.swift in Sources */,
 				3811DE3525C9D49500A708ED /* HomeRootView.swift in Sources */,
 				38E98A2925F52C9300C0CED0 /* Error+Extensions.swift in Sources */,
+				3E62C7822F54CC1B00433237 /* BolusDisplayThreshold.swift in Sources */,
 				38EA05DA261F6E7C0064E39B /* SimpleLogReporter.swift in Sources */,
 				3811DE6125C9D4D500A708ED /* ViewModifiers.swift in Sources */,
 				3811DEAC25C9D88300A708ED /* NightscoutManager.swift in Sources */,
@@ -4493,6 +4547,7 @@
 				53F2382465BF74DB1A967C8B /* PumpConfigProvider.swift in Sources */,
 				5D16287A969E64D18CE40E44 /* PumpConfigStateModel.swift in Sources */,
 				DDF6902C2DA028D3008BF16C /* DiagnosticsStepView.swift in Sources */,
+				C2BA6B992F758E7600348E6A /* NotificationIdentifiers.swift in Sources */,
 				19D466AA29AA3099004D5F33 /* MealSettingsRootView.swift in Sources */,
 				CEF1ED6B2D58FB5800FAF41E /* CGMOptions.swift in Sources */,
 				E974172296125A5AE99E634C /* PumpConfigRootView.swift in Sources */,
@@ -4522,6 +4577,8 @@
 				FA630397F76B582C8D8681A7 /* BasalProfileEditorProvider.swift in Sources */,
 				DD1745172C54389F00211FAC /* FeatureSettingsView.swift in Sources */,
 				DD3D60312F0377350021A33B /* ExportSetting.swift in Sources */,
+				DDD7C8C12F4DB45400E5CF09 /* GlucoseStored+CoreDataClass.swift in Sources */,
+				DDD7C8C22F4DB45400E5CF09 /* GlucoseStored+CoreDataProperties.swift in Sources */,
 				DD9ECB712CA9A0BA00AA7C45 /* RemoteControlConfigProvider.swift in Sources */,
 				63E890B4D951EAA91C071D5C /* BasalProfileEditorStateModel.swift in Sources */,
 				38FEF3FA2737E42000574A46 /* BaseStateModel.swift in Sources */,
@@ -4611,7 +4668,6 @@
 				BDC531182D1062F200088832 /* ContactImageState.swift in Sources */,
 				BD249D9F2D42FD0600412DEB /* StackedChartSetup.swift in Sources */,
 				E592A37A2CEEC038009A472C /* ContactImageProvider.swift in Sources */,
-				CE82E02728E869DF00473A9C /* AlertEntry.swift in Sources */,
 				DD30786A2D42F94000DE0490 /* GarminDevice.swift in Sources */,
 				38E4451E274DB04600EC9A94 /* AppDelegate.swift in Sources */,
 				BD2FF1A02AE29D43005D1C5D /* ToggleStyles.swift in Sources */,
@@ -4717,14 +4773,13 @@
 				DDE179612C910127003CDDB7 /* StatsData+CoreDataProperties.swift in Sources */,
 				DDE179622C910127003CDDB7 /* Forecast+CoreDataClass.swift in Sources */,
 				DDE179632C910127003CDDB7 /* Forecast+CoreDataProperties.swift in Sources */,
-				DDE179642C910127003CDDB7 /* GlucoseStored+CoreDataClass.swift in Sources */,
-				DDE179652C910127003CDDB7 /* GlucoseStored+CoreDataProperties.swift in Sources */,
 				BDC531142D10611D00088832 /* AddContactImageSheet.swift in Sources */,
 				DDE179662C910127003CDDB7 /* OpenAPS_Battery+CoreDataClass.swift in Sources */,
 				DDE179672C910127003CDDB7 /* OpenAPS_Battery+CoreDataProperties.swift in Sources */,
 				DDE179682C910127003CDDB7 /* TempBasalStored+CoreDataClass.swift in Sources */,
 				DDE179692C910127003CDDB7 /* TempBasalStored+CoreDataProperties.swift in Sources */,
 				BD4D73A22D15A42A0052227B /* TDDStorage.swift in Sources */,
+				49C782A72F73D9870062B0DD /* AlertEntry.swift in Sources */,
 				DDE1796C2C910127003CDDB7 /* OverrideRunStored+CoreDataClass.swift in Sources */,
 				DDE1796D2C910127003CDDB7 /* OverrideRunStored+CoreDataProperties.swift in Sources */,
 				DDE1796E2C910127003CDDB7 /* OrefDetermination+CoreDataClass.swift in Sources */,
@@ -4753,12 +4808,14 @@
 				BD8FC0572D66188700B95AED /* PumpHistoryStorageTests.swift in Sources */,
 				BD8FC0642D6619EF00B95AED /* TempTargetStorageTests.swift in Sources */,
 				BD8FC0542D66186000B95AED /* TestError.swift in Sources */,
+				DDDD0FFB2F4E22C000F9C645 /* GlucoseSmoothingTests.swift in Sources */,
 				CEE9A65E2BBC9F6500EB5194 /* CalibrationsTests.swift in Sources */,
 				BD8FC0622D6619E600B95AED /* OverrideStorageTests.swift in Sources */,
 				BD8FC0592D66189700B95AED /* TestAssembly.swift in Sources */,
 				DDC6CA6D2DD90A2A0060EE25 /* LocalizationTests.swift in Sources */,
 				3B997DCF2DC00A3A006B6BB2 /* JSONImporterTests.swift in Sources */,
 				BD8FC0662D661A0000B95AED /* GlucoseStorageTests.swift in Sources */,
+				DDDD0FFF2F4E231B00F9C645 /* MockTDDStorage.swift in Sources */,
 				BD8FC05B2D6618AF00B95AED /* DeterminationStorageTests.swift in Sources */,
 				3BAAE60C2DE7766C0049589B /* DynamicISFEnableTests.swift in Sources */,
 				CE1F6DD92BADF4620064EB8D /* PluginManagerTests.swift in Sources */,
@@ -4796,6 +4853,7 @@
 				BDA25F202D26D5FE00035F34 /* CarbsInputView.swift in Sources */,
 				BDA25F1C2D26BD0700035F34 /* TrendShape.swift in Sources */,
 				BDFF7A892D25F97D0016C40C /* TrioWatchApp.swift in Sources */,
+				C2BA6B972F758E7500348E6A /* WatchNotificationHandler.swift in Sources */,
 				DD09D5C72D29EB2F000D82C9 /* Helper+Enums.swift in Sources */,
 				BD54A9742D281AEF00F9C1EE /* TempTargetPresetWatch.swift in Sources */,
 				BD54A95C2D2808A300F9C1EE /* OverridePresetWatch.swift in Sources */,
@@ -4806,6 +4864,7 @@
 				DD3A3CE72D29C93F00AE478E /* Helper+Extensions.swift in Sources */,
 				DD246F062D2836AA0027DDE0 /* GlucoseTrendView.swift in Sources */,
 				BD432CA22D2F4E4000D1EB79 /* WatchMessageKeys.swift in Sources */,
+				C2BA6B9A2F7593C300348E6A /* NotificationIdentifiers.swift in Sources */,
 				DD8262CB2D289297009F6F62 /* BolusConfirmationView.swift in Sources */,
 				BD54A9712D281A8100F9C1EE /* TempTargetPresetsView.swift in Sources */,
 				DD3A3CE92D29C97800AE478E /* Helper+ButtonStyles.swift in Sources */,

+ 3 - 0
Trio.xcworkspace/contents.xcworkspacedata

@@ -17,6 +17,9 @@
       location = "group:DanaKit/DanaKit.xcodeproj">
    </FileRef>
    <FileRef
+      location = "group:MedtrumKit/MedtrumKit.xcodeproj">
+   </FileRef>
+   <FileRef
       location = "group:RileyLinkKit/RileyLinkKit.xcodeproj">
    </FileRef>
    <FileRef

+ 2 - 2
Trio.xcworkspace/xcshareddata/swiftpm/Package.resolved

@@ -51,8 +51,8 @@
       "kind" : "remoteSourceControl",
       "location" : "https://github.com/garmin/connectiq-companion-app-sdk-ios",
       "state" : {
-        "revision" : "00594907c84884a9430c6a33825940c2769f261a",
-        "version" : "1.7.0"
+        "revision" : "f0d29ff691d700a132d86205ed9bb091e336c2f7",
+        "version" : "1.8.0"
       }
     },
     {

+ 5 - 5
Trio/Resources/Info.plist

@@ -74,13 +74,13 @@
 	<key>NSBluetoothPeripheralUsageDescription</key>
 	<string>Bluetooth is used to communicate with insulin pump and continuous glucose monitor devices</string>
 	<key>NSCalendarsFullAccessUsageDescription</key>
-	<string>To create events with BG reading values, so that they can be viewed on Apple Watch and CarPlay</string>
+	<string>To create events with glucose values, so they can be viewed on Apple Watch and CarPlay</string>
 	<key>NSCalendarsUsageDescription</key>
-	<string>Calendar is used to create a new glucose events.</string>
+	<string>Calendar is used to create new glucose events.</string>
 	<key>NSContactsUsageDescription</key>
 	<string>Contact is used to create a Apple Watch complication</string>
 	<key>NSFaceIDUsageDescription</key>
-	<string>For authorized acces to bolus</string>
+	<string>For authorized access to bolus</string>
 	<key>NSHealthShareUsageDescription</key>
 	<string>Health App is used to store blood glucose, carbs and insulin</string>
 	<key>NSHealthUpdateUsageDescription</key>
@@ -107,8 +107,8 @@
 		<string>remote-notification</string>
 		<string>audio</string>
 	</array>
-    <key>UIDesignRequiresCompatibility</key>
-    <true/>
+	<key>UIDesignRequiresCompatibility</key>
+	<true/>
 	<key>UIFileSharingEnabled</key>
 	<true/>
 	<key>UILaunchScreen</key>

+ 3 - 111
Trio/Resources/InfoPlist.xcstrings

@@ -464,7 +464,7 @@
         "en" : {
           "stringUnit" : {
             "state" : "new",
-            "value" : "To create events with BG reading values, so that they can be viewed on Apple Watch and CarPlay"
+            "value" : "To create events with glucose values, so they can be viewed on Apple Watch and CarPlay"
           }
         }
       }
@@ -473,18 +473,6 @@
       "comment" : "Privacy - Calendars Usage Description",
       "extractionState" : "extracted_with_value",
       "localizations" : {
-        "ar" : {
-          "stringUnit" : {
-            "state" : "translated",
-            "value" : "Calendar is used to create a new glucose events."
-          }
-        },
-        "ca" : {
-          "stringUnit" : {
-            "state" : "translated",
-            "value" : "Calendar is used to create a new glucose events."
-          }
-        },
         "da" : {
           "stringUnit" : {
             "state" : "translated",
@@ -500,19 +488,7 @@
         "en" : {
           "stringUnit" : {
             "state" : "new",
-            "value" : "Calendar is used to create a new glucose events."
-          }
-        },
-        "es" : {
-          "stringUnit" : {
-            "state" : "translated",
-            "value" : "Calendar is used to create a new glucose events."
-          }
-        },
-        "fi" : {
-          "stringUnit" : {
-            "state" : "translated",
-            "value" : "Calendar is used to create a new glucose events."
+            "value" : "Calendar is used to create new glucose events."
           }
         },
         "fr" : {
@@ -521,18 +497,6 @@
             "value" : "Le calendrier est utilisé pour créer un nouvel événement de glycémie."
           }
         },
-        "he" : {
-          "stringUnit" : {
-            "state" : "translated",
-            "value" : "Calendar is used to create a new glucose events."
-          }
-        },
-        "hu" : {
-          "stringUnit" : {
-            "state" : "translated",
-            "value" : "Calendar is used to create a new glucose events."
-          }
-        },
         "it" : {
           "stringUnit" : {
             "state" : "translated",
@@ -551,24 +515,6 @@
             "value" : "Agenda wordt gebruikt om nieuwe glucose gebeurtenissen aan te maken."
           }
         },
-        "pl" : {
-          "stringUnit" : {
-            "state" : "translated",
-            "value" : "Calendar is used to create a new glucose events."
-          }
-        },
-        "pt-BR" : {
-          "stringUnit" : {
-            "state" : "translated",
-            "value" : "Calendar is used to create a new glucose events."
-          }
-        },
-        "pt-PT" : {
-          "stringUnit" : {
-            "state" : "translated",
-            "value" : "Calendar is used to create a new glucose events."
-          }
-        },
         "ru" : {
           "stringUnit" : {
             "state" : "translated",
@@ -628,18 +574,6 @@
       "comment" : "Privacy - Face ID Usage Description",
       "extractionState" : "extracted_with_value",
       "localizations" : {
-        "ar" : {
-          "stringUnit" : {
-            "state" : "translated",
-            "value" : "For authorized acces to bolus"
-          }
-        },
-        "ca" : {
-          "stringUnit" : {
-            "state" : "translated",
-            "value" : "For authorized acces to bolus"
-          }
-        },
         "da" : {
           "stringUnit" : {
             "state" : "translated",
@@ -655,19 +589,7 @@
         "en" : {
           "stringUnit" : {
             "state" : "new",
-            "value" : "For authorized acces to bolus"
-          }
-        },
-        "es" : {
-          "stringUnit" : {
-            "state" : "translated",
-            "value" : "For authorized acces to bolus"
-          }
-        },
-        "fi" : {
-          "stringUnit" : {
-            "state" : "translated",
-            "value" : "For authorized acces to bolus"
+            "value" : "For authorized access to bolus"
           }
         },
         "fr" : {
@@ -676,18 +598,6 @@
             "value" : "Pour les accès autorisés au bolus"
           }
         },
-        "he" : {
-          "stringUnit" : {
-            "state" : "translated",
-            "value" : "For authorized acces to bolus"
-          }
-        },
-        "hu" : {
-          "stringUnit" : {
-            "state" : "translated",
-            "value" : "For authorized acces to bolus"
-          }
-        },
         "it" : {
           "stringUnit" : {
             "state" : "translated",
@@ -706,24 +616,6 @@
             "value" : "Voor geautoriseerde toegang tot bolus"
           }
         },
-        "pl" : {
-          "stringUnit" : {
-            "state" : "translated",
-            "value" : "For authorized acces to bolus"
-          }
-        },
-        "pt-BR" : {
-          "stringUnit" : {
-            "state" : "translated",
-            "value" : "For authorized acces to bolus"
-          }
-        },
-        "pt-PT" : {
-          "stringUnit" : {
-            "state" : "translated",
-            "value" : "For authorized acces to bolus"
-          }
-        },
         "ru" : {
           "stringUnit" : {
             "state" : "translated",

+ 8 - 3
Trio/Resources/json/defaults/freeaps/freeaps_settings.json

@@ -19,9 +19,8 @@
   "highGlucose" : 270,
   "carbsRequiredThreshold" : 10,
   "showCarbsRequiredBadge" : true,
-  "useFPUconversion" : true,
+  "useFPUconversion" : false,
   "individualAdjustmentFactor" : 0.5,
-  "timeCap" : 8,
   "minuteInterval" : 30,
   "delay" : 60,
   "useAppleHealth" : false,
@@ -34,6 +33,7 @@
   "xGridLines" : true,
   "yGridLines" : true,
   "rulerMarks" : true,
+  "bolusDisplayThreshold": 0.01,
   "forecastDisplayType": "cone",
   "maxCarbs": 250,
   "maxFat": 250,
@@ -48,5 +48,10 @@
   "useCalendar": false,
   "displayCalendarIOBandCOB": false,
   "displayCalendarEmojis": false,
-  "timeInRangeType": "timeInTightRange"
+  "timeInRangeType": "timeInTightRange",
+  "garminWatchface": "trio",
+  "garminDatafield": "none",
+  "primaryAttributeChoice": "cob",
+  "secondaryAttributeChoice": "tbr",
+  "isWatchfaceDataEnabled": true  
 }

+ 12 - 2
Trio/Sources/APS/APSManager.swift

@@ -18,6 +18,7 @@ protocol APSManager {
     var lastLoopDateSubject: PassthroughSubject<Date, Never> { get }
     var bolusProgress: CurrentValueSubject<Decimal?, Never> { get }
     var pumpExpiresAtDate: CurrentValueSubject<Date?, Never> { get }
+    var pumpActivatedAtDate: CurrentValueSubject<Date?, Never> { get }
     var isManualTempBasal: Bool { get }
     var isScheduledBasal: Bool? { get }
     var isSuspended: Bool { get }
@@ -129,6 +130,10 @@ final class BaseAPSManager: APSManager, Injectable {
         deviceDataManager.pumpExpiresAtDate
     }
 
+    var pumpActivatedAtDate: CurrentValueSubject<Date?, Never> {
+        deviceDataManager.pumpActivatedAtDate
+    }
+
     var settings: TrioSettings {
         get { settingsManager.settings }
         set { settingsManager.settings = newValue }
@@ -405,7 +410,7 @@ final class BaseAPSManager: APSManager, Injectable {
         guard let autosense = await storage.retrieveAsync(OpenAPS.Settings.autosense, as: Autosens.self),
               (autosense.timestamp ?? .distantPast).addingTimeInterval(30.minutes.timeInterval) > Date()
         else {
-            let result = try await openAPS.autosense()
+            let result = try await openAPS.autosense(shouldSmoothGlucose: settingsManager.settings.smoothGlucose)
             return result != nil
         }
 
@@ -476,7 +481,11 @@ final class BaseAPSManager: APSManager, Injectable {
 
             _ = try await autosenseResult
             try await openAPS.createProfiles()
-            let determination = try await openAPS.determineBasal(currentTemp: await currentTemp, clock: now)
+            let determination = try await openAPS.determineBasal(
+                currentTemp: await currentTemp,
+                shouldSmoothGlucose: settingsManager.settings.smoothGlucose,
+                clock: now
+            )
             iobFileDidUpdate.send(())
 
             guard isValidGlucoseData else {
@@ -520,6 +529,7 @@ final class BaseAPSManager: APSManager, Injectable {
             let temp = try await fetchCurrentTempBasal(date: Date.now)
             return try await openAPS.determineBasal(
                 currentTemp: temp,
+                shouldSmoothGlucose: settingsManager.settings.smoothGlucose,
                 clock: Date(),
                 simulatedCarbsAmount: simulatedCarbsAmount,
                 simulatedBolusAmount: simulatedBolusAmount,

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

@@ -5,6 +5,7 @@ import DanaKit
 import Foundation
 import LoopKit
 import LoopKitUI
+import MedtrumKit
 import MinimedKit
 import MockKit
 import OmniBLE
@@ -27,6 +28,7 @@ protocol DeviceDataManager: GlucoseSource {
     var errorSubject: PassthroughSubject<Error, Never> { get }
     var pumpName: CurrentValueSubject<String, Never> { get }
     var pumpExpiresAtDate: CurrentValueSubject<Date?, Never> { get }
+    var pumpActivatedAtDate: CurrentValueSubject<Date?, Never> { get }
 
     func heartbeat(date: Date)
     func createBolusProgressReporter() -> DoseProgressReporter?
@@ -38,6 +40,7 @@ private let staticPumpManagers: [PumpManagerUI.Type] = [
     OmnipodPumpManager.self,
     OmniBLEPumpManager.self,
     DanaKitPumpManager.self,
+    MedtrumPumpManager.self,
     MockPumpManager.self
 ]
 
@@ -46,6 +49,7 @@ private let staticPumpManagersByIdentifier: [String: PumpManagerUI.Type] = [
     OmnipodPumpManager.pluginIdentifier: OmnipodPumpManager.self,
     OmniBLEPumpManager.pluginIdentifier: OmniBLEPumpManager.self,
     DanaKitPumpManager.pluginIdentifier: DanaKitPumpManager.self,
+    MedtrumPumpManager.pluginIdentifier: MedtrumPumpManager.self,
     MockPumpManager.pluginIdentifier: MockPumpManager.self
 ]
 
@@ -106,6 +110,7 @@ final class BaseDeviceDataManager: DeviceDataManager, Injectable {
                 storage.save(modifiedPreferences, as: OpenAPS.Settings.preferences)
 
                 if let omnipod = pumpManager as? OmnipodPumpManager {
+                    pumpActivatedAtDate.send(nil)
                     guard let endTime = omnipod.state.podState?.expiresAt else {
                         pumpExpiresAtDate.send(nil)
                         return
@@ -113,12 +118,30 @@ final class BaseDeviceDataManager: DeviceDataManager, Injectable {
                     pumpExpiresAtDate.send(endTime)
                 }
                 if let omnipodBLE = pumpManager as? OmniBLEPumpManager {
+                    pumpActivatedAtDate.send(nil)
                     guard let endTime = omnipodBLE.state.podState?.expiresAt else {
                         pumpExpiresAtDate.send(nil)
                         return
                     }
                     pumpExpiresAtDate.send(endTime)
                 }
+                if let medtrumPump = pumpManager as? MedtrumPumpManager {
+                    // Medtrum's state.patchExpiresAt is actually lifespan + grace
+                    // keeping this in line with omnipod, we will use just the lifetime
+                    // i.e., state.patchGracePeriodFrom
+                    guard let endTime = medtrumPump.state.patchGracePeriodFrom else {
+                        pumpExpiresAtDate.send(nil)
+                        return
+                    }
+                    pumpExpiresAtDate.send(endTime)
+
+                    switch medtrumPump.state.expiryMode {
+                    case .default:
+                        pumpActivatedAtDate.send(nil)
+                    case .extended:
+                        pumpActivatedAtDate.send(medtrumPump.state.patchActivatedAt)
+                    }
+                }
                 if let simulatorPump = pumpManager as? MockPumpManager {
                     pumpDisplayState.value = PumpDisplayState(name: simulatorPump.localizedTitle, image: simulatorPump.smallImage)
                     pumpName.send(simulatorPump.localizedTitle)
@@ -163,6 +186,7 @@ final class BaseDeviceDataManager: DeviceDataManager, Injectable {
             } else {
                 pumpDisplayState.value = nil
                 pumpExpiresAtDate.send(nil)
+                pumpActivatedAtDate.send(nil)
                 pumpName.send("")
                 // Reset bolusIncrement setting to default value, which is 0.1 U
                 var modifiedPreferences = settingsManager.preferences
@@ -202,6 +226,7 @@ final class BaseDeviceDataManager: DeviceDataManager, Injectable {
 
     let pumpDisplayState = CurrentValueSubject<PumpDisplayState?, Never>(nil)
     let pumpExpiresAtDate = CurrentValueSubject<Date?, Never>(nil)
+    let pumpActivatedAtDate = CurrentValueSubject<Date?, Never>(nil)
     let pumpName = CurrentValueSubject<String, Never>("Pump")
 
     init(resolver: Resolver) {
@@ -460,6 +485,7 @@ extension BaseDeviceDataManager: PumpManagerDelegate {
                 manualTempBasal.send(false)
             }
 
+            pumpActivatedAtDate.send(nil)
             guard let endTime = omnipod.state.podState?.expiresAt else {
                 pumpExpiresAtDate.send(nil)
                 return
@@ -493,6 +519,7 @@ extension BaseDeviceDataManager: PumpManagerDelegate {
                 manualTempBasal.send(false)
             }
 
+            pumpActivatedAtDate.send(nil)
             guard let endTime = omnipodBLE.state.podState?.expiresAt else {
                 pumpExpiresAtDate.send(nil)
                 return
@@ -504,6 +531,29 @@ extension BaseDeviceDataManager: PumpManagerDelegate {
             }
         }
 
+        if let medtrumPump = pumpManager as? MedtrumPumpManager {
+            storage.save(Decimal(medtrumPump.state.reservoir), as: OpenAPS.Monitor.reservoir)
+            broadcaster.notify(PumpReservoirObserver.self, on: processQueue) {
+                $0.pumpReservoirDidChange(Decimal(medtrumPump.state.reservoir))
+            }
+
+            // Medtrum's state.patchExpiresAt is actually lifespan + grace
+            // keeping this in line with omnipod, we will use just the lifetime
+            // i.e., state.patchGracePeriodFrom
+            guard let endTime = medtrumPump.state.patchGracePeriodFrom else {
+                pumpExpiresAtDate.send(nil)
+                return
+            }
+            pumpExpiresAtDate.send(endTime)
+
+            switch medtrumPump.state.expiryMode {
+            case .default:
+                pumpActivatedAtDate.send(nil)
+            case .extended:
+                pumpActivatedAtDate.send(medtrumPump.state.patchActivatedAt)
+            }
+        }
+
         if let simulatorPump = pumpManager as? MockPumpManager {
             broadcaster.notify(PumpReservoirObserver.self, on: processQueue) {
                 $0.pumpReservoirDidChange(Decimal(simulatorPump.state.reservoirUnitsRemaining))

+ 224 - 47
Trio/Sources/APS/FetchGlucoseManager.swift

@@ -1,4 +1,5 @@
 import Combine
+import CoreData
 import Foundation
 import HealthKit
 import LoopKit
@@ -29,6 +30,7 @@ extension FetchGlucoseManager {
 final class BaseFetchGlucoseManager: FetchGlucoseManager, Injectable {
     private let processQueue = DispatchQueue(label: "BaseGlucoseManager.processQueue")
 
+    @Injected() var broadcaster: Broadcaster!
     @Injected() var glucoseStorage: GlucoseStorage!
     @Injected() var nightscoutManager: NightscoutManager!
     @Injected() var tidepoolService: TidepoolManager!
@@ -66,6 +68,8 @@ final class BaseFetchGlucoseManager: FetchGlucoseManager, Injectable {
         return cgmManager.shouldSyncToRemoteService
     }
 
+    var shouldSmoothGlucose: Bool = false
+
     init(resolver: Resolver) {
         injectServices(resolver)
         // init at the start of the app
@@ -76,6 +80,7 @@ final class BaseFetchGlucoseManager: FetchGlucoseManager, Injectable {
             cgmGlucoseSourceType: settingsManager.settings.cgm,
             cgmGlucosePluginId: settingsManager.settings.cgmPluginIdentifier
         )
+        shouldSmoothGlucose = settingsManager.settings.smoothGlucose
         subscribe()
     }
 
@@ -117,6 +122,8 @@ final class BaseFetchGlucoseManager: FetchGlucoseManager, Injectable {
             .store(in: &lifetime)
         timer.fire()
         timer.resume()
+
+        broadcaster.register(SettingsObserver.self, observer: self)
     }
 
     /// Store new glucose readings from the CGM manager
@@ -197,7 +204,6 @@ final class BaseFetchGlucoseManager: FetchGlucoseManager, Injectable {
         if let manager = newManager {
             cgmManager = manager
             removeCalibrations()
-//            glucoseSource = nil
         } else if self.cgmGlucoseSourceType == .plugin, cgmManager == nil, let rawCGMManager = rawCGMManager {
             cgmManager = cgmManagerFromRawValue(rawCGMManager)
             updateManagerUnits(cgmManager)
@@ -234,40 +240,6 @@ final class BaseFetchGlucoseManager: FetchGlucoseManager, Injectable {
         return Manager.init(rawState: rawState)
     }
 
-    private func fetchGlucose() async throws -> [GlucoseStored]? {
-        try await CoreDataStack.shared.fetchEntitiesAsync(
-            ofType: GlucoseStored.self,
-            onContext: context,
-            predicate: NSPredicate.predicateFor30MinAgo,
-            key: "date",
-            ascending: false,
-            fetchLimit: 6
-        ) as? [GlucoseStored]
-    }
-
-    private func processGlucose() async throws -> [BloodGlucose] {
-        let results = try await fetchGlucose()
-
-        return try await context.perform {
-            guard let results else {
-                throw CoreDataError.fetchError(function: #function, file: #file)
-            }
-            return results.map { result in
-                BloodGlucose(
-                    sgv: Int(result.glucose),
-                    direction: BloodGlucose.Direction(from: result.direction ?? ""),
-                    date: Decimal(result.date?.timeIntervalSince1970 ?? Date().timeIntervalSince1970) * 1000,
-                    dateString: result.date ?? Date(),
-                    unfiltered: Decimal(result.glucose),
-                    filtered: Decimal(result.glucose),
-                    noise: nil,
-                    glucose: Int(result.glucose),
-                    type: "sgv"
-                )
-            }
-        }
-    }
-
     private func glucoseStoreAndHeartDecision(syncDate: Date, glucose: [BloodGlucose]) async throws {
         // calibration add if required only for sensor
         let newGlucose = overcalibrate(entries: glucose)
@@ -303,21 +275,12 @@ final class BaseFetchGlucoseManager: FetchGlucoseManager, Injectable {
         }
         debug(.deviceManager, "New glucose found")
 
-        // filter the data if it is the case
-        if settingsManager.settings.smoothGlucose {
-            // limited to 30 min of old glucose data
-            let oldGlucoseValues = try await processGlucose()
+        try await glucoseStorage.storeGlucose(filtered)
 
-            var smoothedValues = oldGlucoseValues + filtered
-            // smooth with 3 repeats
-            for _ in 1 ... 3 {
-                smoothedValues.smoothSavitzkyGolayQuaDratic(withFilterWidth: 3)
-            }
-            // find the new values only
-            filtered = smoothedValues.filter { $0.dateString > syncDate }
+        if settingsManager.settings.smoothGlucose {
+            await exponentialSmoothingGlucose(context: context)
         }
 
-        try await glucoseStorage.storeGlucose(filtered)
         deviceDataManager.heartbeat(date: Date())
 
         endBackgroundTaskSafely(&backgroundTaskID, taskName: "Glucose Store and Heartbeat Decision")
@@ -377,3 +340,217 @@ extension CGMManager {
         ]
     }
 }
+
+extension BaseFetchGlucoseManager: SettingsObserver {
+    /// Smooth glucose data when smoothing is turned on.
+    func settingsDidChange(_: TrioSettings) {
+        let smoothingWasEnabled = shouldSmoothGlucose
+        let smoothingIsEnabled = settingsManager.settings.smoothGlucose
+        shouldSmoothGlucose = smoothingIsEnabled
+
+        guard smoothingIsEnabled, !smoothingWasEnabled else { return }
+
+        processQueue.async { [weak self] in
+            guard let self else { return }
+
+            self.glucoseStoreAndHeartLock.wait()
+            Task {
+                await self.exponentialSmoothingGlucose(context: self.context)
+                self.glucoseStoreAndHeartLock.signal()
+            }
+        }
+    }
+}
+
+extension BaseFetchGlucoseManager {
+    func fetchGlucose(context: NSManagedObjectContext) async throws -> [NSManagedObjectID] {
+        // Compound predicate: time window + non-manual + valid date
+        let timePredicate = NSPredicate.predicateForOneDayAgoInMinutes
+        let manualPredicate = NSPredicate(format: "isManual == NO")
+        let datePredicate = NSPredicate(format: "date != nil")
+
+        let compoundPredicate = NSCompoundPredicate(andPredicateWithSubpredicates: [
+            timePredicate,
+            manualPredicate,
+            datePredicate
+        ])
+
+        let results = try await CoreDataStack.shared.fetchEntitiesAsync(
+            ofType: GlucoseStored.self,
+            onContext: context,
+            // Predicate must cover at least the full glucose horizon used by downstream algorithm consumers.
+            // If autosens / oref / smoothing logic ever starts looking back further (e.g. 36h),
+            // this fetch window must be expanded accordingly.
+            predicate: compoundPredicate,
+            key: "date",
+            ascending: true, // the first element is the oldest
+            fetchLimit: 350
+        )
+
+        guard let glucoseArray = results as? [GlucoseStored] else {
+            throw CoreDataError.fetchError(function: #function, file: #file)
+        }
+
+        return glucoseArray.map(\.objectID)
+    }
+
+    /// CoreData-friendly AAPS exponential smoothing + storage.
+    /// - Important: Only stores `smoothedGlucose`. UI/alerts should still use `glucose`.
+    ///
+    func exponentialSmoothingGlucose(context: NSManagedObjectContext) async {
+        let startTime = Date()
+
+        do {
+            // get objectIDs
+            let objectIDs = try await fetchGlucose(context: context)
+
+            try await context.perform {
+                // Load managed objects from object IDs
+                // Filtering (isManual, date) already done at DB level in fetchGlucose
+                let glucoseReadings = objectIDs.compactMap {
+                    context.object(with: $0) as? GlucoseStored
+                }
+
+                guard !glucoseReadings.isEmpty else { return }
+
+                // Static method call to avoid self-capture
+                Self.applyExponentialSmoothingAndStore(
+                    glucoseReadings: glucoseReadings,
+                    minimumWindowSize: 4,
+                    maximumAllowedGapMinutes: 12,
+                    xDripErrorGlucose: 38,
+                    minimumSmoothedGlucose: 39,
+                    firstOrderWeight: 0.4,
+                    firstOrderAlpha: 0.5,
+                    secondOrderAlpha: 0.4,
+                    secondOrderBeta: 1.0
+                )
+
+                try context.save()
+            }
+
+            let duration = Date().timeIntervalSince(startTime)
+            debugPrint(String(format: "Exponential smoothing duration: %0.04fs", duration))
+        } catch {
+            debug(.deviceManager, "Failed to smooth glucose: \(error)")
+        }
+    }
+
+    private static func applyExponentialSmoothingAndStore(
+        glucoseReadings data: [GlucoseStored],
+        minimumWindowSize: Int,
+        maximumAllowedGapMinutes: Int,
+        xDripErrorGlucose: Int,
+        minimumSmoothedGlucose: Decimal,
+        firstOrderWeight: Decimal,
+        firstOrderAlpha: Decimal,
+        secondOrderAlpha: Decimal,
+        secondOrderBeta: Decimal
+    ) {
+        guard !data.isEmpty else { return }
+
+        // Determine the size of the valid most-recent smoothing window.
+        // We walk adjacent pairs from newest -> oldest to preserve the same window semantics
+        // as the original implementation, but avoid manual reverse indexing.
+        var validWindowCount = max(data.count - 1, 0)
+
+        for (recentOffset, pair) in zip(data.dropFirst().reversed(), data.dropLast().reversed()).enumerated() {
+            let (newer, older) = pair
+
+            guard let newerDate = newer.date, let olderDate = older.date else { continue }
+
+            let gapSeconds = newerDate.timeIntervalSince(olderDate)
+            let gapMinutesRounded = Int((gapSeconds / 60.0).rounded())
+
+            if gapMinutesRounded >= maximumAllowedGapMinutes {
+                validWindowCount = recentOffset + 1 // include the more recent reading
+                break
+            }
+
+            // Ported from AAPS: 38 mg/dL may represent an xDrip error state.
+            if Int(newer.glucose) == xDripErrorGlucose {
+                validWindowCount = recentOffset // exclude this 38 value
+                break
+            }
+        }
+
+        // Not enough recent contiguous readings to smooth (e.g. after CGM gap).
+        // IMPORTANT: Only apply fallback to the recent window, not all data.
+        // Otherwise a recent gap would overwrite historical smoothed values.
+        guard validWindowCount >= minimumWindowSize else {
+            let recentWindow = data.suffix(validWindowCount)
+
+            for object in recentWindow {
+                let raw = Decimal(Int(object.glucose))
+                object.smoothedGlucose = max(raw, minimumSmoothedGlucose) as NSDecimalNumber
+            }
+
+            return
+        }
+
+        // Restrict smoothing to the valid most-recent window, still in chronological order.
+        let validWindow = data.suffix(validWindowCount)
+
+        guard let oldest = validWindow.first else { return }
+
+        // ---- 1st order smoothing ----
+        var firstOrderSmoothed: [Decimal] = []
+        firstOrderSmoothed.reserveCapacity(validWindow.count)
+
+        var firstOrderCurrent = Decimal(Int(oldest.glucose))
+        firstOrderSmoothed.append(firstOrderCurrent)
+
+        for sample in validWindow.dropFirst() {
+            let raw = Decimal(Int(sample.glucose))
+            firstOrderCurrent = firstOrderCurrent + firstOrderAlpha * (raw - firstOrderCurrent)
+            firstOrderSmoothed.append(firstOrderCurrent)
+        }
+
+        // ---- 2nd order smoothing ----
+        let secondOrderInput = Array(validWindow)
+        guard secondOrderInput.count >= 2 else { return }
+
+        var secondOrderSmoothed: [Decimal] = []
+        secondOrderSmoothed.reserveCapacity(secondOrderInput.count)
+
+        var secondOrderDeltas: [Decimal] = []
+        secondOrderDeltas.reserveCapacity(secondOrderInput.count)
+
+        var previousSecondOrderSmoothed = Decimal(Int(secondOrderInput[0].glucose))
+        var previousSecondOrderDelta =
+            Decimal(Int(secondOrderInput[1].glucose) - Int(secondOrderInput[0].glucose))
+
+        secondOrderSmoothed.append(previousSecondOrderSmoothed)
+        secondOrderDeltas.append(previousSecondOrderDelta)
+
+        for sample in secondOrderInput.dropFirst() {
+            let raw = Decimal(Int(sample.glucose))
+
+            let nextSmoothed =
+                secondOrderAlpha * raw
+                    + (1 - secondOrderAlpha) * (previousSecondOrderSmoothed + previousSecondOrderDelta)
+
+            let nextDelta =
+                secondOrderBeta * (nextSmoothed - previousSecondOrderSmoothed)
+                    + (1 - secondOrderBeta) * previousSecondOrderDelta
+
+            previousSecondOrderSmoothed = nextSmoothed
+            previousSecondOrderDelta = nextDelta
+
+            secondOrderSmoothed.append(nextSmoothed)
+            secondOrderDeltas.append(nextDelta)
+        }
+
+        // ---- Weighted blend ----
+        let blended = zip(firstOrderSmoothed, secondOrderSmoothed).map { firstOrder, secondOrder in
+            firstOrderWeight * firstOrder + (1 - firstOrderWeight) * secondOrder
+        }
+
+        // Apply to the most recent valid-window readings.
+        for (object, blendedValue) in zip(validWindow, blended) {
+            let rounded = blendedValue.rounded(toPlaces: 0) // nearest integer, ties away from zero
+            let clamped = max(rounded, minimumSmoothedGlucose)
+            object.smoothedGlucose = clamped as NSDecimalNumber
+        }
+    }
+}

+ 108 - 12
Trio/Sources/APS/OpenAPS/OpenAPS.swift

@@ -98,7 +98,12 @@ final class OpenAPS {
     }
 
     // fetch glucose to pass it to the meal function and to determine basal
-    private func fetchAndProcessGlucose(fetchLimit: Int?) async throws -> String {
+    func fetchAndProcessGlucose(
+        context: NSManagedObjectContext,
+        shouldSmoothGlucose: Bool,
+        fetchLimit: Int?
+    ) async throws -> String {
+        // make it async and await it
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: GlucoseStored.self,
             onContext: context,
@@ -109,14 +114,40 @@ final class OpenAPS {
             batchSize: 48
         )
 
-        return try await context.perform {
+        // mapping within the context closure, JSON conversion outside
+        let algorithmGlucose = try await context.perform {
             guard let glucoseResults = results as? [GlucoseStored] else {
                 throw CoreDataError.fetchError(function: #function, file: #file)
             }
 
-            // convert to JSON
-            return self.jsonConverter.convertToJSON(glucoseResults)
+            // extracting handler to only create it 1x
+            let roundingBehavior = NSDecimalNumberHandler(
+                roundingMode: .plain,
+                scale: 0,
+                raiseOnExactness: false,
+                raiseOnOverflow: false,
+                raiseOnUnderflow: false,
+                raiseOnDivideByZero: false
+            )
+
+            return glucoseResults.map { glucose -> AlgorithmGlucose in
+                let glucoseValue: Int16
+                if shouldSmoothGlucose, !glucose.isManual, let smoothedGlucose = glucose.smoothedGlucose, smoothedGlucose != 0 {
+                    glucoseValue = smoothedGlucose.rounding(accordingToBehavior: roundingBehavior).int16Value
+                } else {
+                    glucoseValue = glucose.glucose
+                }
+                return AlgorithmGlucose(
+                    date: glucose.date,
+                    direction: glucose.direction,
+                    glucose: glucoseValue,
+                    id: glucose.id,
+                    isManual: glucose.isManual
+                )
+            }
         }
+
+        return jsonConverter.convertToJSON(algorithmGlucose)
     }
 
     private func fetchAndProcessCarbs(additionalCarbs: Decimal? = nil, carbsDate: Date? = nil) async throws -> String {
@@ -193,14 +224,22 @@ final class OpenAPS {
     private func parsePumpHistory(
         _ pumpHistoryObjectIDs: [NSManagedObjectID],
         simulatedBolusAmount: Decimal? = nil
-    ) async -> String {
+    ) async throws -> String {
         // Return an empty JSON object if the list of object IDs is empty
         guard !pumpHistoryObjectIDs.isEmpty else { return "{}" }
 
+        // Addresses https://github.com/nightscout/Trio/issues/898
+        //
+        // On a cold start (new user, fresh onboarding, or pump disconnected > 24h),
+        // the oldest event in pump history can be a resume with no preceding pump
+        // activity. oref interprets this as the end of a suspend that never started,
+        // which drives negative IOB and can cause excessive insulin delivery.
+        let orphanedResumes = try await fetchOrphanedResumes()
+
         // Execute all operations on the background context
         return await context.perform {
             // Load and map pump events to DTOs
-            var dtos = self.loadAndMapPumpEvents(pumpHistoryObjectIDs)
+            var dtos = self.loadAndMapPumpEvents(pumpHistoryObjectIDs, orphanedResumes: orphanedResumes)
 
             // Optionally add the IOB as a DTO
             if let simulatedBolusAmount = simulatedBolusAmount {
@@ -213,17 +252,23 @@ final class OpenAPS {
         }
     }
 
-    private func loadAndMapPumpEvents(_ pumpHistoryObjectIDs: [NSManagedObjectID]) -> [PumpEventDTO] {
-        OpenAPS.loadAndMapPumpEvents(pumpHistoryObjectIDs, from: context)
+    private func loadAndMapPumpEvents(
+        _ pumpHistoryObjectIDs: [NSManagedObjectID],
+        orphanedResumes: [NSManagedObjectID]
+    ) -> [PumpEventDTO] {
+        OpenAPS.loadAndMapPumpEvents(pumpHistoryObjectIDs, orphanedResumes: orphanedResumes, from: context)
     }
 
     /// Fetches and parses pump events, expose this as static and not private for testing
     static func loadAndMapPumpEvents(
         _ pumpHistoryObjectIDs: [NSManagedObjectID],
+        orphanedResumes: [NSManagedObjectID],
         from context: NSManagedObjectContext
     ) -> [PumpEventDTO] {
+        let orphanedSet = Set(orphanedResumes)
+        let filteredObjectIds = pumpHistoryObjectIDs.filter { !orphanedSet.contains($0) }
         // Load the pump events from the object IDs
-        let pumpHistory: [PumpEventStored] = pumpHistoryObjectIDs
+        let pumpHistory: [PumpEventStored] = filteredObjectIds
             .compactMap { context.object(with: $0) as? PumpEventStored }
 
         // Create the DTOs
@@ -276,8 +321,59 @@ final class OpenAPS {
         return .bolus(bolusDTO)
     }
 
+    /// Detects a cold-start orphaned resume: returns the resume's object ID if it's an orphaned resume
+    private func fetchOrphanedResumes() async throws -> [NSManagedObjectID] {
+        let results = try await CoreDataStack.shared.fetchEntitiesAsync(
+            ofType: PumpEventStored.self,
+            onContext: context,
+            predicate: NSPredicate.pumpHistoryLast48h,
+            key: "timestamp",
+            ascending: true,
+            batchSize: 250
+        )
+
+        return try await context.perform {
+            guard let pumpEventResultsFull = results as? [PumpEventStored] else {
+                throw CoreDataError.fetchError(function: #function, file: #file)
+            }
+
+            let pumpEventResults = pumpEventResultsFull
+                .filter { $0.type == EventType.pumpSuspend.rawValue || $0.type == EventType.pumpResume.rawValue }
+
+            // we define an orphaned resume as one without a paired suspend within
+            // the most recent 24 hours.
+            // **Important**: we pick 48 hours because the standard pump history
+            // is 24 hours + 24 hours of inspection for resumes.
+            let orphanedResumes = zip(pumpEventResults, pumpEventResults.dropFirst())
+                .compactMap { (prev, curr) -> PumpEventStored? in
+                    guard let prevTimestamp = prev.timestamp, let currTimestamp = curr.timestamp else {
+                        return nil
+                    }
+                    let interval = currTimestamp.timeIntervalSince(prevTimestamp)
+
+                    // check if the current event is an orphaned resume
+                    //  - previous event not a suspend
+                    //  - previous event is a suspend but it's more than 24 hours ago
+                    if curr.type == EventType.pumpResume.rawValue,
+                       prev.type != EventType.pumpSuspend.rawValue || interval > TimeInterval(hours: 24)
+                    {
+                        return curr
+                    }
+                    return nil
+                }
+            // check the first event to see if it's an orphaned resume
+            let firstResumeOrphaned = pumpEventResults.first.flatMap({ event -> [PumpEventStored]? in
+                guard event.type == EventType.pumpResume.rawValue else { return nil }
+                return [event]
+            }) ?? []
+
+            return (firstResumeOrphaned + orphanedResumes).map(\.objectID)
+        }
+    }
+
     func determineBasal(
         currentTemp: TempBasal,
+        shouldSmoothGlucose: Bool,
         clock: Date = Date(),
         simulatedCarbsAmount: Decimal? = nil,
         simulatedBolusAmount: Decimal? = nil,
@@ -292,7 +388,7 @@ final class OpenAPS {
         // Perform asynchronous calls in parallel
         async let pumpHistoryObjectIDs = fetchPumpHistoryObjectIDs() ?? []
         async let carbs = fetchAndProcessCarbs(additionalCarbs: simulatedCarbsAmount ?? 0, carbsDate: simulatedCarbsDate)
-        async let glucose = fetchAndProcessGlucose(fetchLimit: 72)
+        async let glucose = fetchAndProcessGlucose(context: context, shouldSmoothGlucose: shouldSmoothGlucose, fetchLimit: 72)
         async let prepareTrioCustomOrefVariables = prepareTrioCustomOrefVariables()
         async let profileAsync = loadFileFromStorageAsync(name: Settings.profile)
         async let basalAsync = loadFileFromStorageAsync(name: Settings.basalProfile)
@@ -461,13 +557,13 @@ final class OpenAPS {
         }
     }
 
-    func autosense() async throws -> Autosens? {
+    func autosense(shouldSmoothGlucose: Bool) async throws -> Autosens? {
         debug(.openAPS, "Start autosens")
 
         // Perform asynchronous calls in parallel
         async let pumpHistoryObjectIDs = fetchPumpHistoryObjectIDs() ?? []
         async let carbs = fetchAndProcessCarbs()
-        async let glucose = fetchAndProcessGlucose(fetchLimit: nil)
+        async let glucose = fetchAndProcessGlucose(context: context, shouldSmoothGlucose: shouldSmoothGlucose, fetchLimit: nil)
         async let getProfile = loadFileFromStorageAsync(name: Settings.profile)
         async let getBasalProfile = loadFileFromStorageAsync(name: Settings.basalProfile)
         async let getTempTargets = loadFileFromStorageAsync(name: Settings.tempTargets)

+ 114 - 59
Trio/Sources/APS/Storage/CarbsStorage.swift

@@ -102,38 +102,40 @@ final class BaseCarbsStorage: CarbsStorage, Injectable {
     }
 
     /**
-     Calculates the duration for processing FPUs (fat and protein units) based on the FPUs and the time cap.
+     Converts fat and protein into delayed carb-equivalent entries (FPU handling).
 
-     - The function uses predefined rules to determine the duration based on the number of FPUs.
-     - Ensures that the duration does not exceed the time cap.
+     Behavior:
 
-     - Parameters:
-       - fpus: The number of FPUs calculated from fat and protein.
-       - timeCap: The maximum allowed duration.
+     - Calculates carb equivalents from fat and protein
+       ((fat × 9 + protein × 4) / 10 × adjustment factor).
+     - Rounds down to whole grams.
+     - Drops values below 10 g.
+     - Caps total equivalents at 99 g.
+     - Splits into up to 3 entries.
+     - Caps each entry at 33 g.
+     - Distributes grams as evenly as possible.
 
-     - Returns: The computed duration in hours.
-     */
-    private func calculateComputedDuration(fpus: Decimal, timeCap: Decimal) -> Decimal {
-        switch fpus {
-        case ..<2:
-            return 3
-        case 2 ..< 3:
-            return 4
-        case 3 ..< 4:
-            return 5
-        default:
-            return timeCap
-        }
-    }
+     Timing:
 
-    /**
-     Processes fat and protein entries to generate future carb equivalents, ensuring each equivalent is at least 1.0 grams.
+     - First entry is scheduled after the configured delay
+       (default: 60 minutes) from the carb entry timestamp.
+     - Additional entries are spaced 30 minutes apart.
+
+     Example (default):
+
+     - Carb entry at T
+     - 1st equivalent at T + 60 min
+     - 2nd equivalent at T + 90 min
+     - 3rd equivalent at T + 120 min
 
-     - The function calculates the equivalent carb dosage size and adjusts the interval to ensure each equivalent is at least 1.0 grams.
-     - Creates future carb entries based on the adjusted carb equivalent size and interval.
+     Generated entries:
+
+     - Are marked with `isFPU = true`
+     - Contain only carbs (fat and protein set to 0)
+     - Share the same `fpuID` as the original carb entry
 
      - Parameters:
-       - entries: An array of `CarbsEntry` objects representing the carbohydrate entries to be processed.
+       - entries: An array of `CarbsEntry` objects representing the carb equivalent entries to be processed.
        - fat: The amount of fat in the last entry.
        - protein: The amount of protein in the last entry.
        - createdAt: The creation date of the last entry.
@@ -150,46 +152,48 @@ final class BaseCarbsStorage: CarbsStorage, Injectable {
         let trioSettings = settings.settings
         let providerSettings = settingsProvider.settings
 
-        let interval = trioSettings.minuteInterval.clamp(to: providerSettings.minuteInterval)
-        let timeCap = trioSettings.timeCap.clamp(to: providerSettings.timeCap)
-        let adjustment = trioSettings.individualAdjustmentFactor.clamp(to: providerSettings.individualAdjustmentFactor)
-        let delay = trioSettings.delay.clamp(to: providerSettings.delay)
+        let adjustment = trioSettings.individualAdjustmentFactor
+            .clamp(to: providerSettings.individualAdjustmentFactor)
 
-        let kcal = protein * 4 + fat * 9
-        let carbEquivalents = (kcal / 10) * adjustment
-        let fpus = carbEquivalents / 10
-        var computedDuration = calculateComputedDuration(fpus: fpus, timeCap: timeCap)
+        let delayMinutes = trioSettings.delay
+            .clamp(to: providerSettings.delay)
 
-        var carbEquivalentSize: Decimal = carbEquivalents / computedDuration
-        carbEquivalentSize /= Decimal(60) / interval
+        let spreadInterval = trioSettings.minuteInterval
+            .clamp(to: providerSettings.minuteInterval)
 
-        if carbEquivalentSize < 1.0 {
-            carbEquivalentSize = 1.0
-            computedDuration = min(carbEquivalents / carbEquivalentSize, timeCap)
+        // Constraints
+        let maxTotalGrams = 99
+        let maxEntries = 3
+        let maxPerEntry = 33
+        let minPerEntry = 10
+        let spacing = TimeInterval(spreadInterval * 60)
+
+        // kcal -> carb equivalents (kcal/10 * adjustment), rounded down to whole grams
+        let kcal = protein * 4 + fat * 9
+        let rawEquivalents = Int((kcal / 10) * adjustment)
+        let totalGrams = min(maxTotalGrams, max(0, rawEquivalents))
+
+        guard totalGrams >= minPerEntry else {
+            return ([], Decimal(totalGrams))
         }
 
-        let roundedEquivalent: Double = round(Double(carbEquivalentSize * 10)) / 10
-        carbEquivalentSize = Decimal(roundedEquivalent)
-        var numberOfEquivalents = carbEquivalents / carbEquivalentSize
+        let amounts = splitIntoCarbEquivalents(
+            total: totalGrams,
+            maxEntries: maxEntries,
+            maxPerEntry: maxPerEntry,
+            minPerEntry: minPerEntry
+        )
 
-        var useDate = actualDate ?? createdAt
+        let baseDate = actualDate ?? createdAt
+        let start = baseDate.addingTimeInterval(TimeInterval(delayMinutes * 60))
         let fpuID = entries.first?.fpuID ?? UUID().uuidString
-        var futureCarbArray = [CarbsEntry]()
-        var firstIndex = true
-
-        // convert Decimal minutes to TimeInterval in seconds
-        let delayTimeInterval = TimeInterval(delay * 60)
-        let intervalTimeInterval = TimeInterval(interval * 60)
-        while carbEquivalents > 0, numberOfEquivalents > 0 {
-            useDate = firstIndex ? useDate.addingTimeInterval(delayTimeInterval) : useDate
-                .addingTimeInterval(intervalTimeInterval)
-            firstIndex = false
-
-            let eachCarbEntry = CarbsEntry(
+
+        let futureEntries: [CarbsEntry] = amounts.enumerated().map { idx, grams in
+            CarbsEntry(
                 id: UUID().uuidString,
                 createdAt: createdAt,
-                actualDate: useDate,
-                carbs: carbEquivalentSize,
+                actualDate: start.addingTimeInterval(TimeInterval(idx) * spacing),
+                carbs: Decimal(grams),
                 fat: 0,
                 protein: 0,
                 note: nil,
@@ -197,11 +201,62 @@ final class BaseCarbsStorage: CarbsStorage, Injectable {
                 isFPU: true,
                 fpuID: fpuID
             )
-            futureCarbArray.append(eachCarbEntry)
-            numberOfEquivalents -= 1
         }
 
-        return (futureCarbArray, carbEquivalents)
+        let totalScheduled = futureEntries.reduce(into: Decimal(0)) { $0 += $1.carbs }
+        return (futureEntries, totalScheduled)
+    }
+
+    /**
+     Splits a total carb-equivalent value into multiple integer entries.
+
+     - Returns no entries if `total` is below `minPerEntry`.
+     - Limits output to `maxEntries`.
+     - Caps each entry at `maxPerEntry`.
+     - Distributes grams evenly (difference ≤ 1 g).
+     - Merges or removes entries below `minPerEntry`.
+
+     - Returns:
+       Integer gram values representing the split carb equivalents.
+     */
+    private func splitIntoCarbEquivalents(
+        total: Int,
+        maxEntries: Int,
+        maxPerEntry: Int,
+        minPerEntry: Int
+    ) -> [Int] {
+        guard total >= minPerEntry else { return [] }
+
+        // Choose an entry count that *guarantees* each entry can be <= maxPerEntry
+        let needed = (total + maxPerEntry - 1) / maxPerEntry
+        let count = min(maxEntries, max(1, needed))
+
+        // Even split (difference between buckets is at most 1)
+        func evenSplit(_ total: Int, count: Int) -> [Int] {
+            let base = total / count
+            let rem = total % count
+            return (0 ..< count).map { base + ($0 < rem ? 1 : 0) }
+        }
+
+        var buckets = evenSplit(total, count: count)
+
+        // Enforce minPerEntry by merging any too-small tail bucket into the previous one
+        // This should be rare, but it keeps the invariant
+        if buckets.count > 1 {
+            for i in stride(from: buckets.count - 1, through: 1, by: -1) {
+                let v = buckets[i]
+                guard v > 0, v < minPerEntry else { continue }
+                buckets[i - 1] += v
+                buckets[i] = 0
+            }
+            buckets = buckets.filter { $0 > 0 }
+        }
+
+        // Guarantee not to exceed maxPerEntry if merging a reduced count
+        // Clamp as final guard here
+        buckets = buckets.map { min(maxPerEntry, $0) }.filter { $0 >= minPerEntry }
+
+        return buckets
     }
 
     private func saveCarbEquivalents(entries: [CarbsEntry], areFetchedFromRemote: Bool) async {

+ 6 - 3
Trio/Sources/APS/Storage/PumpHistoryStorage.swift

@@ -291,13 +291,16 @@ final class BasePumpHistoryStorage: PumpHistoryStorage, Injectable {
     }
 
     func determineBolusEventType(for event: PumpEventStored) -> PumpEventStored.EventType {
-        if event.bolus!.isSMB {
+        guard let bolus = event.bolus else {
+            return event.type.flatMap({ PumpEventStored.EventType(rawValue: $0) }) ?? .bolus
+        }
+        if bolus.isSMB {
             return .smb
         }
-        if event.bolus!.isExternal {
+        if bolus.isExternal {
             return .isExternal
         }
-        return PumpEventStored.EventType(rawValue: event.type!) ?? PumpEventStored.EventType.bolus
+        return event.type.flatMap({ PumpEventStored.EventType(rawValue: $0) }) ?? .bolus
     }
 
     func getPumpHistoryNotYetUploadedToNightscout() async throws -> [NightscoutTreatment] {

+ 5 - 0
Trio/Sources/Helpers/Decimal+Extensions.swift

@@ -5,6 +5,11 @@ extension Double {
     init(_ decimal: Decimal) {
         self.init(truncating: decimal as NSNumber)
     }
+
+    func roundedDouble(toPlaces places: Int) -> Double {
+        let divisor = pow(10.0, Double(places))
+        return (self * divisor).rounded() / divisor
+    }
 }
 
 extension Int {

+ 7 - 0
Trio/Sources/Helpers/Formatters.swift

@@ -57,6 +57,13 @@ extension Formatter {
         return formatter
     }()
 
+    static let timeForLogFormatter: DateFormatter = {
+        let formatter = DateFormatter()
+        formatter.dateFormat = "HH:mm:ss"
+        formatter.timeZone = TimeZone.current
+        return formatter
+    }()
+
     static let decimalFormatterWithOneFractionDigit: NumberFormatter = {
         let formatter = NumberFormatter()
         formatter.numberStyle = .decimal

+ 0 - 172
Trio/Sources/Helpers/SavitzkyGolayFilter.swift

@@ -1,172 +0,0 @@
-import Foundation
-
-/// allowed values are 0, 1, 2 or 3. It's the index in coefficients
-private var coefficientsRowToUse = 3
-
-/// Savitzky Golay coefficients
-private let coefficients = [
-    [-3.0, 12.0, 17.0, 12.0, -3.0],
-    [-2.0, 3.0, 6.0, 7.0, 6.0, 3.0, -2.0],
-    [-21.0, 14.0, 39.0, 54.0, 59.0, 54.0, 39.0, 14.0, -21.0],
-    [-36.0, 9.0, 44.0, 69.0, 84.0, 89.0, 84.0, 69.0, 44.0, 9.0, -36.0]
-]
-
-/// an array with elements of a type that conforms to Smoothable, can be filtered using  the Savitzky Golay algorithm
-protocol SavitzkyGolaySmoothable {
-    /// value to be smoothed
-    var value: Double { get set }
-}
-
-/// local help class
-private class IsSmoothable: SavitzkyGolaySmoothable {
-    var value: Double = 0.0
-
-    init(withValue value: Double = 0.0) {
-        self.value = value
-    }
-}
-
-extension Array where Element: SavitzkyGolaySmoothable {
-    /// - apply Savitzky Golay filter
-    /// - before applying the filter, the array will be prepended and append with a number of elements equal to the filterwidth, filterWidth default 5. Allowed values are 5, 4, 3, 2. If any other value is assigned, then 5 will be used
-    /// - ...continue with 5 here in the explanation ...
-    /// - for the 5 last elements and 5 first elements, a regression is done. This regression is done used to give values to the 5 prepended and appended values. Which means it's as if we draw a line through the first 5 and 5 last original values, and use this line to give values to the 5 prepended and appended values
-    /// - the 5 prepended and appended values are then used in the filter algorithm, which means we can also filter the original 5 first and last elements
-    /// see also example https://github.com/JohanDegraeve/xdripswift/wiki/Libre-value-smoothing
-    mutating func smoothSavitzkyGolayQuaDratic(withFilterWidth filterWidth: Int = 5) {
-        // filterWidthToUse is the value of filterWidth to use in the algorithm. By default filterWidthToUse = parameter value filterWidth
-        var filterWidthToUse = filterWidth
-
-        // calculate coefficientsRowToUse based on filterWdith
-        switch filterWidth {
-        case 5:
-            coefficientsRowToUse = 3
-
-        case 4:
-            coefficientsRowToUse = 2
-
-        case 3:
-            coefficientsRowToUse = 1
-
-        case 2:
-            coefficientsRowToUse = 0
-
-        default:
-            // invalid filterWidth was given in parameterList, use default value
-            coefficientsRowToUse = 3
-
-            filterWidthToUse = 5
-        }
-
-        // using 5 here in the comments as value for filterWidthToUse
-
-        // the amount of elements must be at least 5. If that's not the case then don't apply any smoothing
-        guard count >= filterWidthToUse else { return }
-
-        // create a new array, to which we will prepend and append 5 elements so that we can do also smoothing for the 5 last and 5 first values of the input array (which is self)
-        // the 5 elements will be estimated by doing linear regression of the first 5 and last 5 elements of the original input array respectively
-        // this is only a temporary array, but it will hold the elements of the original array, those elements will get a new value when doing the smoothing
-        var tempArray = [SavitzkyGolaySmoothable]()
-        for element in self {
-            tempArray.append(element)
-        }
-
-        // now prepend and append with 5 elements, each with a default value 0.0
-        for _ in 0 ..< filterWidthToUse {
-            tempArray.insert(IsSmoothable(), at: 0)
-            tempArray.append(IsSmoothable())
-        }
-
-        // so now we have tempArray, of length size of original array + 2 * 5
-        // the first 5 and the last 5 elements are of type IsSmoothable with value 0
-
-        // - indicesArray is a help array needed for the function linearRegressionCreator
-        // - this will be the first parameter in the call to the linearRegression function, in fact it's an array of IsSmoothable with length = length of tempArray
-        // - we give each IsSmoothable the value of the index, meaning from 0 up to (length of tempArray) - 1
-        // - in fact it's not really smoothable, it's just because we use isSmoothable in function linearRegressionCreator
-        var indicesArray = [SavitzkyGolaySmoothable]()
-        for index in 0 ..< (count + (filterWidthToUse * 2)) {
-            indicesArray.append(IsSmoothable(withValue: Double(index)))
-        }
-
-        /// - this is a piece of code that we will execute two times, once for the firs 5 elements, then for the last 5, so we put it in a closure variable
-        /// - it calculates the regression function (which is nothing else but doing y = intercept + slope*x) for range defined by predictorRange in tempArray. It will be used for the 5 first and 5 last real values, ie the 5 first and 5 last real glucose values
-        /// - then executes the regression for every element in the range defined by targetRange, again in tempArray
-        let doRegression = { (predictorRange: Range<Int>, targetRange: Range<Int>) in
-
-            // calculate the linearRegression function
-            let linearRegression = linearRegressionCreator(indicesArray[predictorRange], tempArray[predictorRange])
-
-            // ready to do the linear regression for the targetRange in tempArray
-            for index in targetRange {
-                tempArray[index].value = linearRegression(indicesArray[index].value)
-            }
-        }
-
-        // now do the regression for the 5 first elements
-        doRegression(filterWidthToUse ..< (filterWidthToUse * 2), 0 ..< filterWidthToUse)
-
-        // now do the regression for the 5 last elements
-        doRegression(
-            (tempArray.count - filterWidthToUse * 2) ..< (tempArray.count - filterWidthToUse),
-            (tempArray.count - filterWidthToUse) ..< tempArray.count
-        )
-
-        // now start filtering
-
-        // initialize array that will hold the resulting filtered values
-        var filteredValues = [Double]()
-
-        // calculate divider
-        let divider = coefficients[coefficientsRowToUse].reduce(0, { x, y in
-            x + y
-        })
-
-        // filter each original value
-        for _ in 0 ..< count {
-            // add a new element to filteredValues, start value is 0.0
-            // this new value will be the last element, so we access it with index filteredValues.count - 1
-            filteredValues.append(0.0)
-
-            // iterate through the coefficients
-            for (index, coefficient) in coefficients[coefficientsRowToUse].enumerated() {
-                filteredValues[filteredValues.count - 1] = filteredValues[filteredValues.count - 1] + coefficient *
-                    tempArray[index + filteredValues.count - 1].value
-            }
-
-            filteredValues[filteredValues.count - 1] = filteredValues[filteredValues.count - 1] / divider
-        }
-
-        // now assign the new values to the original objects
-        for (index, _) in enumerated() {
-            self[index].value = filteredValues[index]
-        }
-    }
-}
-
-/// source https://github.com/raywenderlich/swift-algorithm-club/tree/master/Linear%20Regression
-private func multiply(
-    _ a: ArraySlice<SavitzkyGolaySmoothable>,
-    _ b: ArraySlice<SavitzkyGolaySmoothable>
-) -> ArraySlice<SavitzkyGolaySmoothable> {
-    zip(a, b).map({ IsSmoothable(withValue: $0.value * $1.value) })[0 ..< a.count]
-}
-
-/// source https://github.com/raywenderlich/swift-algorithm-club/tree/master/Linear%20Regression
-private func average(_ input: ArraySlice<SavitzkyGolaySmoothable>) -> Double {
-    (input.reduce(IsSmoothable(), { (x: SavitzkyGolaySmoothable, y: SavitzkyGolaySmoothable) in
-        IsSmoothable(withValue: x.value + y.value) })).value / Double(input.count)
-}
-
-/// source https://github.com/raywenderlich/swift-algorithm-club/tree/master/Linear%20Regression
-private func linearRegressionCreator(
-    _ xs: ArraySlice<SavitzkyGolaySmoothable>,
-    _ ys: ArraySlice<SavitzkyGolaySmoothable>
-) -> (Double) -> Double {
-    let sum1 = average(multiply(ys, xs)) - average(xs) * average(ys)
-    let sum2 = average(multiply(xs, xs)) - pow(average(xs), 2)
-    let slope = sum1 / sum2
-    let intercept = average(ys) - slope * average(xs)
-
-    return { x in intercept + slope * x }
-}

Разница между файлами не показана из-за своего большого размера
+ 7472 - 1217
Trio/Sources/Localizations/Main/Localizable.xcstrings


+ 86 - 0
Trio/Sources/Models/AlgorithmGlucose.swift

@@ -0,0 +1,86 @@
+import Foundation
+
+/// Helper class so that we can have a plain Swift object to serialize GlucoseStorage
+struct AlgorithmGlucose: Codable {
+    var date: Date?
+    var direction: String?
+    var glucose: Int16
+    var id: UUID?
+    var isManual: Bool
+
+    enum CodingKeys: String, CodingKey {
+        case date
+        case dateString
+        case sgv
+        case glucose
+        case direction
+        case id
+        case type
+    }
+
+    init(date: Date?, direction: String?, glucose: Int16, id: UUID?, isManual: Bool) {
+        self.date = date
+        self.direction = direction
+        self.glucose = glucose
+        self.id = id
+        self.isManual = isManual
+    }
+
+    // this constructor is just for testing
+    public init(from decoder: Decoder) throws {
+        let container = try decoder.container(keyedBy: CodingKeys.self)
+
+        if let dateString = try container.decodeIfPresent(String.self, forKey: .dateString) {
+            let dateFormatter = ISO8601DateFormatter()
+            dateFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
+            date = dateFormatter.date(from: dateString)
+        } else if let dateStringTimestamp = try container.decodeIfPresent(String.self, forKey: .date),
+                  let dateTimestamp = TimeInterval(dateStringTimestamp)
+        {
+            date = Date(timeIntervalSince1970: dateTimestamp / 1000)
+        } else {
+            date = nil
+        }
+
+        direction = try container.decodeIfPresent(String.self, forKey: .direction)
+        id = try container.decodeIfPresent(UUID.self, forKey: .id)
+
+        if let glucoseValue = try container.decodeIfPresent(Int16.self, forKey: .glucose) {
+            glucose = glucoseValue
+            isManual = true
+        } else if let sgvValue = try container.decodeIfPresent(Int16.self, forKey: .sgv) {
+            glucose = sgvValue
+            isManual = false
+        } else {
+            throw DecodingError.dataCorruptedError(
+                forKey: .sgv,
+                in: container,
+                debugDescription: "Neither 'glucose' nor 'sgv' key found or value is not Int16"
+            )
+        }
+    }
+
+    public func encode(to encoder: Encoder) throws {
+        var container = encoder.container(keyedBy: CodingKeys.self)
+
+        let dateFormatter = ISO8601DateFormatter()
+        dateFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
+
+        try container.encode(dateFormatter.string(from: date ?? Date()), forKey: .dateString)
+
+        let dateAsUnixTimestamp = String(format: "%.0f", (date?.timeIntervalSince1970 ?? Date().timeIntervalSince1970) * 1000)
+        try container.encode(dateAsUnixTimestamp, forKey: .date)
+
+        try container.encode(direction, forKey: .direction)
+        try container.encode(id, forKey: .id)
+
+        // TODO: Handle the type of the glucose entry conditionally not hardcoded
+        try container.encode("sgv", forKey: .type)
+
+        if isManual {
+            try container.encode(glucose, forKey: .glucose)
+        } else {
+            try container.encode(glucose, forKey: .sgv)
+        }
+    }
+}

+ 0 - 17
Trio/Sources/Models/Autotune.swift

@@ -1,17 +0,0 @@
-import Foundation
-
-struct Autotune: JSON, Equatable {
-    var createdAt: Date?
-    let basalProfile: [BasalProfileEntry]
-    let sensitivity: Decimal
-    let carbRatio: Decimal
-}
-
-extension Autotune {
-    private enum CodingKeys: String, CodingKey {
-        case createdAt = "created_at"
-        case basalProfile = "basalprofile"
-        case sensitivity = "sens"
-        case carbRatio = "carb_ratio"
-    }
-}

+ 0 - 12
Trio/Sources/Models/BloodGlucose.swift

@@ -249,18 +249,6 @@ extension NumberFormatter {
     }()
 }
 
-extension BloodGlucose: SavitzkyGolaySmoothable {
-    var value: Double {
-        get {
-            Double(glucose ?? 0)
-        }
-        set {
-            glucose = Int(newValue)
-            sgv = Int(newValue)
-        }
-    }
-}
-
 extension BloodGlucose {
     func convertStoredGlucoseSample(isManualGlucose: Bool) -> StoredGlucoseSample {
         StoredGlucoseSample(

+ 22 - 0
Trio/Sources/Models/BolusDisplayThreshold.swift

@@ -0,0 +1,22 @@
+import Foundation
+
+enum BolusDisplayThreshold: Decimal, JSON, CaseIterable, Identifiable, Codable, Hashable {
+    public var id: Decimal { rawValue }
+    case oneUnit = 1
+    case halfUnit = 0.5
+    case pointOneUnit = 0.1
+    case allUnits = 0.01
+
+    var displayName: String {
+        switch self {
+        case .oneUnit:
+            return String(localized: "1 U and over")
+        case .halfUnit:
+            return String(localized: "0.5 U and over")
+        case .pointOneUnit:
+            return String(localized: "0.1 U and over")
+        case .allUnits:
+            return String(localized: "Show All")
+        }
+    }
+}

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

@@ -132,8 +132,7 @@ struct DecimalPickerSettings {
     var threshold_setting = PickerSetting(value: 60, step: 1, min: 60, max: 120, type: PickerSetting.PickerSettingType.glucose)
     var updateInterval = PickerSetting(value: 20, step: 5, min: 1, max: 60, type: PickerSetting.PickerSettingType.minute)
     var delay = PickerSetting(value: 60, step: 5, min: 15, max: 120, type: PickerSetting.PickerSettingType.minute)
-    var minuteInterval = PickerSetting(value: 30, step: 5, min: 10, max: 60, type: PickerSetting.PickerSettingType.minute)
-    var timeCap = PickerSetting(value: 8, step: 1, min: 5, max: 12, type: PickerSetting.PickerSettingType.hour)
+    var minuteInterval = PickerSetting(value: 30, step: 5, min: 30, max: 60, type: PickerSetting.PickerSettingType.minute)
     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)

+ 0 - 24
Trio/Sources/Models/FetchedProfile.swift

@@ -1,24 +0,0 @@
-import Foundation
-
-struct FetchedNightscoutProfileStore: JSON {
-    let _id: String
-    let defaultProfile: String
-    let startDate: String
-    let mills: Decimal
-    let enteredBy: String
-    let store: [String: ScheduledNightscoutProfile]
-    let created_at: String
-}
-
-struct FetchedNightscoutProfile: JSON {
-    let dia: Decimal
-    let carbs_hr: Int
-    let delay: Decimal
-    let timezone: String
-    let target_low: [NightscoutTimevalue]
-    let target_high: [NightscoutTimevalue]
-    let sens: [NightscoutTimevalue]
-    let basal: [NightscoutTimevalue]
-    let carbratio: [NightscoutTimevalue]
-    let units: String
-}

+ 124 - 0
Trio/Sources/Models/GarminWatchSettings.swift

@@ -0,0 +1,124 @@
+import Foundation
+
+// MARK: - Garmin Data Type Settings
+
+/// Primary attribute selection for Garmin watchface and datafield.
+/// Determines whether to display COB, ISF, or Sensitivity Ratio alongside glucose data.
+/// Used by both Trio and SwissAlpine watchfaces.
+enum GarminPrimaryAttributeChoice: String, JSON, CaseIterable, Identifiable, Codable, Hashable {
+    var id: String { rawValue }
+
+    case cob
+    case isf
+    case sensRatio
+
+    var displayName: String {
+        switch self {
+        case .cob:
+            return String(localized: "COB", comment: "")
+        case .isf:
+            return String(localized: "ISF", comment: "")
+        case .sensRatio:
+            return String(localized: "Sens Ratio", comment: "")
+        }
+    }
+}
+
+/// Secondary attribute selection for both Trio and SwissAlpine watchfaces.
+/// Determines whether to display Temp Basal Rate or Eventual BG.
+enum GarminSecondaryAttributeChoice: String, JSON, CaseIterable, Identifiable, Codable, Hashable {
+    var id: String { rawValue }
+
+    case tbr
+    case eventualBG
+
+    var displayName: String {
+        switch self {
+        case .tbr:
+            return String(localized: "TBR", comment: "")
+        case .eventualBG:
+            return String(localized: "evBG", comment: "")
+        }
+    }
+}
+
+// MARK: - Garmin Watchface Setting
+
+/// Defines the available Garmin watchfaces with their associated UUIDs.
+enum GarminWatchface: String, JSON, CaseIterable, Identifiable, Codable, Hashable {
+    var id: String { rawValue }
+
+    case trio
+    case swissalpine
+
+    var displayName: String {
+        switch self {
+        case .trio:
+            return String(localized: "Trio", comment: "")
+        case .swissalpine:
+            return String(localized: "Swissalpine", comment: "")
+        }
+    }
+
+    /// The UUID for the watchface application in Garmin Connect IQ
+    var watchfaceUUID: UUID? {
+        switch self {
+        case .trio:
+            // return UUID(uuidString: "EC3420F6-027D-49B3-B45F-D81D6D3ED90A")  // local build
+            // return UUID(uuidString: "81204522-B1BE-4E19-8E6E-C4032AAF8C6D") // ConnectIQ test build
+            return UUID(uuidString: "7a121867-140e-41ba-9982-2e82e2aa6579") // ConnectIQ live build
+        case .swissalpine:
+            // return UUID(uuidString: "5A643C13-D5A7-40D4-B809-84789FDF4A1F") // ConnectIQ test build
+            return UUID(uuidString: "4cea4efd-4aaf-4db4-8891-ef36dde14303") // ConnectIQ live build
+        }
+    }
+}
+
+// MARK: - Garmin Datafield Setting
+
+/// Defines the available Garmin datafields with their associated UUIDs.
+enum GarminDatafield: String, JSON, CaseIterable, Identifiable, Codable, Hashable {
+    var id: String { rawValue }
+
+    case trio
+    case swissalpine
+    case none
+
+    var displayName: String {
+        switch self {
+        case .trio:
+            return String(localized: "Trio", comment: "")
+        case .swissalpine:
+            return String(localized: "Swissalpine", comment: "")
+        case .none:
+            return String(localized: "None", comment: "")
+        }
+    }
+
+    /// The UUID for the datafield application in Garmin Connect IQ
+    var datafieldUUID: UUID? {
+        switch self {
+        case .trio:
+            // return UUID(uuidString: "71cf0982-ca41-42a5-8441-ea81d36056c3")  // local build
+            // return UUID(uuidString: "f07f4ef9-108b-4397-95c9-217b5173412e")  // ConnectIQ test build
+            return UUID(uuidString: "3d9b6528-8c84-459a-bbab-989b5f001ebd") // ConnectIQ live build
+        case .swissalpine:
+            // return UUID(uuidString: "7A2268F6-3381-4474-81BD-0A3E7F458CB7") // ConnectIQ test build
+            return UUID(uuidString: "dec5292a-74b0-41bc-8e45-cd93f1d5e137") // ConnectIQ live build
+        case .none:
+            return nil
+        }
+    }
+}
+
+// MARK: - Garmin Watch Settings Group
+
+/// Groups related Garmin watch settings together for easier management.
+/// Both watchfaces use the same settings: primaryAttributeChoice and secondaryAttributeChoice.
+struct GarminWatchSettings: Codable, Hashable {
+    var watchface: GarminWatchface = .trio
+    var datafield: GarminDatafield = .trio
+    var primaryAttributeChoice: GarminPrimaryAttributeChoice = .cob
+    var secondaryAttributeChoice: GarminSecondaryAttributeChoice = .tbr
+    var isWatchfaceDataEnabled: Bool = false
+}

+ 118 - 17
Trio/Sources/Models/GarminWatchState.swift

@@ -7,35 +7,136 @@
 import Foundation
 import SwiftUI
 
+// MARK: - Unified Garmin Watch State
+
+/// Unified watch state structure for both Trio and SwissAlpine watchfaces.
+/// Uses the SwissAlpine xDrip+ compatible data format.
+/// Sent as an array where the first entry contains all extended data fields.
 struct GarminWatchState: Hashable, Equatable, Sendable, Encodable {
-    var glucose: String?
-    var trendRaw: String?
-    var delta: String?
-    var iob: String?
-    var cob: String?
-    var lastLoopDateInterval: UInt64?
-    var eventualBGRaw: String?
-    var isf: String?
+    /// Timestamp of the enacted loop determination in milliseconds since Unix epoch
+    /// Shows when the loop actually executed, used to indicate loop staleness
+    var date: UInt64?
+
+    /// Timestamp of the glucose reading in milliseconds since Unix epoch
+    /// Used by watchface to determine glucose freshness for coloring logic
+    var glucoseDate: UInt64?
+
+    /// Sensor glucose value in raw mg/dL (no unit conversion applied)
+    var sgv: Int16?
+
+    /// Change in glucose since previous reading as an integer
+    var delta: Int16?
+
+    /// Glucose trend direction (e.g., "Flat", "FortyFiveUp", "SingleUp")
+    var direction: String?
+
+    /// Signal noise level (optional, typically not used)
+    var noise: Double?
+
+    /// Unit hint for the watchface ("mgdl" or "mmol")
+    var units_hint: String?
+
+    /// Insulin on board as a decimal value (only in first array entry)
+    var iob: Double?
+
+    /// Current temp basal rate in U/hr (only in first array entry)
+    var tbr: Double?
+
+    /// Carbs on board as a decimal value (only in first array entry)
+    var cob: Double?
+
+    /// Predicted eventual blood glucose (excluded if data type 2 is set to TBR)
+    var eventualBG: Int16?
+
+    /// Current insulin sensitivity factor as an integer (only in first array entry)
+    var isf: Int16?
+
+    /// AutoISF sensitivity ratio (included only if data type 1 is set to sensRatio)
+    var sensRatio: Double?
+
+    // MARK: - Display Configuration Fields
+
+    /// Specifies which primary attribute to display
+    /// Options: "cob", "isf", or "sensRatio"
+    var displayPrimaryAttributeChoice: String?
+
+    /// Specifies which secondary attribute to display
+    /// Options: "tbr" or "eventualBG"
+    var displaySecondaryAttributeChoice: String?
 
     static func == (lhs: GarminWatchState, rhs: GarminWatchState) -> Bool {
-        lhs.glucose == rhs.glucose &&
-            lhs.trendRaw == rhs.trendRaw &&
+        lhs.date == rhs.date &&
+            lhs.glucoseDate == rhs.glucoseDate &&
+            lhs.sgv == rhs.sgv &&
             lhs.delta == rhs.delta &&
+            lhs.direction == rhs.direction &&
+            lhs.noise == rhs.noise &&
+            lhs.units_hint == rhs.units_hint &&
             lhs.iob == rhs.iob &&
+            lhs.tbr == rhs.tbr &&
             lhs.cob == rhs.cob &&
-            lhs.lastLoopDateInterval == rhs.lastLoopDateInterval &&
-            lhs.eventualBGRaw == rhs.eventualBGRaw &&
-            lhs.isf == rhs.isf
+            lhs.eventualBG == rhs.eventualBG &&
+            lhs.isf == rhs.isf &&
+            lhs.sensRatio == rhs.sensRatio &&
+            lhs.displayPrimaryAttributeChoice == rhs.displayPrimaryAttributeChoice &&
+            lhs.displaySecondaryAttributeChoice == rhs.displaySecondaryAttributeChoice
     }
 
     func hash(into hasher: inout Hasher) {
-        hasher.combine(glucose)
-        hasher.combine(trendRaw)
+        hasher.combine(date)
+        hasher.combine(glucoseDate)
+        hasher.combine(sgv)
         hasher.combine(delta)
+        hasher.combine(direction)
+        hasher.combine(noise)
+        hasher.combine(units_hint)
         hasher.combine(iob)
+        hasher.combine(tbr)
         hasher.combine(cob)
-        hasher.combine(lastLoopDateInterval)
-        hasher.combine(eventualBGRaw)
+        hasher.combine(eventualBG)
         hasher.combine(isf)
+        hasher.combine(sensRatio)
+        hasher.combine(displayPrimaryAttributeChoice)
+        hasher.combine(displaySecondaryAttributeChoice)
+    }
+
+    enum CodingKeys: String, CodingKey {
+        case date
+        case glucoseDate
+        case sgv
+        case delta
+        case direction
+        case noise
+        case units_hint
+        case iob
+        case tbr
+        case cob
+        case eventualBG
+        case isf
+        case sensRatio
+        case displayPrimaryAttributeChoice
+        case displaySecondaryAttributeChoice
+    }
+
+    /// Custom encoding that excludes nil values from the JSON output
+    /// Double values are rounded to 2 decimal places to prevent floating point artifacts
+    func encode(to encoder: Encoder) throws {
+        var container = encoder.container(keyedBy: CodingKeys.self)
+        try container.encodeIfPresent(date, forKey: .date)
+        try container.encodeIfPresent(glucoseDate, forKey: .glucoseDate)
+        try container.encodeIfPresent(sgv, forKey: .sgv)
+        try container.encodeIfPresent(delta, forKey: .delta)
+        try container.encodeIfPresent(direction, forKey: .direction)
+        try container.encodeIfPresent(noise, forKey: .noise)
+        try container.encodeIfPresent(units_hint, forKey: .units_hint)
+        // Round Double values to 2 decimal places to prevent floating point artifacts like "0.5600000000000001"
+        try container.encodeIfPresent(iob?.roundedDouble(toPlaces: 2), forKey: .iob)
+        try container.encodeIfPresent(tbr?.roundedDouble(toPlaces: 2), forKey: .tbr)
+        try container.encodeIfPresent(cob, forKey: .cob)
+        try container.encodeIfPresent(eventualBG, forKey: .eventualBG)
+        try container.encodeIfPresent(isf, forKey: .isf)
+        try container.encodeIfPresent(sensRatio?.roundedDouble(toPlaces: 2), forKey: .sensRatio)
+        try container.encodeIfPresent(displayPrimaryAttributeChoice, forKey: .displayPrimaryAttributeChoice)
+        try container.encodeIfPresent(displaySecondaryAttributeChoice, forKey: .displaySecondaryAttributeChoice)
     }
 }

+ 64 - 0
Trio/Sources/Models/NotificationIdentifiers.swift

@@ -0,0 +1,64 @@
+import Foundation
+import UserNotifications
+
+enum NotificationCategoryIdentifier: String {
+    case trioAlert = "Trio.alert"
+}
+
+enum NotificationResponseAction: String, CaseIterable {
+    case snooze20 = "Trio.snooze20"
+    case snooze1hr = "Trio.snooze1hr"
+    case snooze3hr = "Trio.snooze3hr"
+    case snooze6hr = "Trio.snooze6hr"
+
+    var duration: TimeInterval {
+        TimeInterval(minutes) * 60
+    }
+
+    var minutes: Int {
+        switch self {
+        case .snooze20:
+            return 20
+        case .snooze1hr:
+            return 60
+        case .snooze3hr:
+            return 180
+        case .snooze6hr:
+            return 360
+        }
+    }
+
+    var localizedTitle: String {
+        switch self {
+        case .snooze20:
+            return String(localized: "20 min", comment: "Snooze glucose alerts for 20 minutes")
+        case .snooze1hr:
+            return String(localized: "1 hour", comment: "Snooze glucose alerts for 1 hour")
+        case .snooze3hr:
+            return String(localized: "3 hours", comment: "Snooze glucose alerts for 3 hours")
+        case .snooze6hr:
+            return String(localized: "6 hours", comment: "Snooze glucose alerts for 6 hours")
+        }
+    }
+}
+
+// MARK: - NotificationCategoryFactory
+
+enum NotificationCategoryFactory {
+    static func createGlucoseCategory() -> UNNotificationCategory {
+        let snoozeActions = NotificationResponseAction.allCases.map { action in
+            UNNotificationAction(
+                identifier: action.rawValue,
+                title: action.localizedTitle,
+                options: []
+            )
+        }
+
+        return UNNotificationCategory(
+            identifier: NotificationCategoryIdentifier.trioAlert.rawValue,
+            actions: snoozeActions,
+            intentIdentifiers: [],
+            options: []
+        )
+    }
+}

+ 86 - 8
Trio/Sources/Models/TrioSettings.swift

@@ -15,7 +15,7 @@ enum BolusShortcutLimit: String, JSON, CaseIterable, Identifiable {
     }
 }
 
-struct TrioSettings: JSON, Equatable {
+struct TrioSettings: JSON, Equatable, Encodable {
     var units: GlucoseUnits = .mgdL
     var closedLoop: Bool = false
     var isUploadEnabled: Bool = false
@@ -40,9 +40,8 @@ struct TrioSettings: JSON, Equatable {
     var highGlucose: Decimal = 270
     var carbsRequiredThreshold: Decimal = 10
     var showCarbsRequiredBadge: Bool = true
-    var useFPUconversion: Bool = true
+    var useFPUconversion: Bool = false
     var individualAdjustmentFactor: Decimal = 0.5
-    var timeCap: Decimal = 8
     var minuteInterval: Decimal = 30
     var delay: Decimal = 60
     var useAppleHealth: Bool = false
@@ -53,7 +52,12 @@ struct TrioSettings: JSON, Equatable {
     var glucoseColorScheme: GlucoseColorScheme = .staticColor
     var xGridLines: Bool = true
     var yGridLines: Bool = true
+    var hideInsulinBadge: Bool = false
+    var allowDilution: Bool = false
+    var insulinConcentration: Decimal = 1
+    var showCobIobChart: Bool = true
     var rulerMarks: Bool = true
+    var bolusDisplayThreshold: BolusDisplayThreshold = .allUnits
     var forecastDisplayType: ForecastDisplayType = .cone
     var maxCarbs: Decimal = 250
     var maxFat: Decimal = 250
@@ -71,10 +75,43 @@ struct TrioSettings: JSON, Equatable {
     var smartStackView: LockScreenView = .simple
     var bolusShortcut: BolusShortcutLimit = .notAllowed
     var timeInRangeType: TimeInRangeType = .timeInTightRange
+
+    /// Selected Garmin watchface (Trio or SwissAlpine)
+    var garminWatchface: GarminWatchface = .trio
+    var garminDatafield: GarminDatafield = .none
+
+    /// Primary attribute choice for Garmin display (COB, ISF, or Sensitivity Ratio)
+    var primaryAttributeChoice: GarminPrimaryAttributeChoice = .cob
+
+    /// Secondary attribute choice for Garmin display (TBR or Eventual BG)
+    var secondaryAttributeChoice: GarminSecondaryAttributeChoice = .tbr
+
+    /// Controls whether watchface data transmission is enabled
+    var isWatchfaceDataEnabled: Bool = false
+
+    /// Computed property that groups all Garmin settings into a single struct
+    var garminSettings: GarminWatchSettings {
+        get {
+            GarminWatchSettings(
+                watchface: garminWatchface,
+                datafield: garminDatafield,
+                primaryAttributeChoice: primaryAttributeChoice,
+                secondaryAttributeChoice: secondaryAttributeChoice,
+                isWatchfaceDataEnabled: isWatchfaceDataEnabled
+            )
+        }
+        set {
+            garminWatchface = newValue.watchface
+            garminDatafield = newValue.datafield
+            primaryAttributeChoice = newValue.primaryAttributeChoice
+            secondaryAttributeChoice = newValue.secondaryAttributeChoice
+            isWatchfaceDataEnabled = newValue.isWatchfaceDataEnabled
+        }
+    }
 }
 
 extension TrioSettings: Decodable {
-    // Needed to decode incomplete JSON
+    /// Custom decoder to handle incomplete JSON and provide default values for missing fields
     init(from decoder: Decoder) throws {
         let container = try decoder.container(keyedBy: CodingKeys.self)
         var settings = TrioSettings()
@@ -167,10 +204,6 @@ extension TrioSettings: Decodable {
             settings.overrideFactor = overrideFactor
         }
 
-        if let timeCap = try? container.decode(Decimal.self, forKey: .timeCap) {
-            settings.timeCap = timeCap
-        }
-
         if let minuteInterval = try? container.decode(Decimal.self, forKey: .minuteInterval) {
             settings.minuteInterval = minuteInterval
         }
@@ -249,10 +282,30 @@ extension TrioSettings: Decodable {
             settings.yGridLines = yGridLines
         }
 
+        if let showCobIobChart = try? container.decode(Bool.self, forKey: .showCobIobChart) {
+            settings.showCobIobChart = showCobIobChart
+        }
+
+        if let hideInsulinBadge = try? container.decode(Bool.self, forKey: .hideInsulinBadge) {
+            settings.hideInsulinBadge = hideInsulinBadge
+        }
+
+        if let allowDilution = try? container.decode(Bool.self, forKey: .allowDilution) {
+            settings.allowDilution = allowDilution
+        }
+
+        if let insulinConcentration = try? container.decode(Decimal.self, forKey: .insulinConcentration) {
+            settings.insulinConcentration = insulinConcentration
+        }
+
         if let rulerMarks = try? container.decode(Bool.self, forKey: .rulerMarks) {
             settings.rulerMarks = rulerMarks
         }
 
+        if let bolusDisplayThreshold = try? container.decode(BolusDisplayThreshold.self, forKey: .bolusDisplayThreshold) {
+            settings.bolusDisplayThreshold = bolusDisplayThreshold
+        }
+
         if let forecastDisplayType = try? container.decode(ForecastDisplayType.self, forKey: .forecastDisplayType) {
             settings.forecastDisplayType = forecastDisplayType
         }
@@ -305,6 +358,31 @@ extension TrioSettings: Decodable {
             settings.timeInRangeType = timeInRangeType
         }
 
+        if let garminWatchface = try? container.decode(GarminWatchface.self, forKey: .garminWatchface) {
+            settings.garminWatchface = garminWatchface
+        }
+
+        if let garminDatafield = try? container.decode(GarminDatafield.self, forKey: .garminDatafield) {
+            settings.garminDatafield = garminDatafield
+        }
+
+        if let primaryAttributeChoice = try? container
+            .decode(GarminPrimaryAttributeChoice.self, forKey: .primaryAttributeChoice)
+        {
+            settings.primaryAttributeChoice = primaryAttributeChoice
+        }
+
+        if let secondaryAttributeChoice = try? container.decode(
+            GarminSecondaryAttributeChoice.self,
+            forKey: .secondaryAttributeChoice
+        ) {
+            settings.secondaryAttributeChoice = secondaryAttributeChoice
+        }
+
+        if let isWatchfaceDataEnabled = try? container.decode(Bool.self, forKey: .isWatchfaceDataEnabled) {
+            settings.isWatchfaceDataEnabled = isWatchfaceDataEnabled
+        }
+
         self = settings
     }
 }

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

@@ -51,4 +51,7 @@ enum WatchMessageKeys {
     static let maxProtein = "maxProtein"
     static let bolusIncrement = "bolusIncrement"
     static let confirmBolusFaster = "confirmBolusFaster"
+
+    // Notification Actions
+    static let snoozeDuration = "snoozeDuration"
 }

+ 24 - 5
Trio/Sources/Modules/CGMSettings/View/CGMRootView.swift

@@ -113,17 +113,36 @@ extension CGMSettings {
                         units: state.units,
                         type: .boolean,
                         label: String(localized: "Smooth Glucose Value"),
-                        miniHint: String(localized: "Smooth CGM readings using Savitzky-Golay filtering."),
+                        miniHint: String(localized: "Smooth CGM readings using exponential smoothing."),
                         verboseHint: VStack(alignment: .leading, spacing: 10) {
                             Text("Default: OFF").bold()
+
                             Text(
-                                "This filter looks at small groups of nearby readings and fits them to a simple mathematical curve. This process doesn't change the overall pattern of your glucose data but helps smooth out the \"noise\" or irregular fluctuations that could lead to false highs or lows."
+                                "This feature smooths your CGM readings to reduce noise and make them easier to read. It is based on a method used in AndroidAPS (AAPS). It uses two approaches: one that reacts quickly to recent changes, and one that looks at longer trends. These are combined to give a balanced result."
                             )
+
                             Text(
-                                "It's designed to keep the important trends in your data while minimizing those small, misleading variations, giving you and Trio a clearer sense of where your blood sugar is really headed. This type of filtering is useful in Trio, as it can help prevent over-corrections based on inaccurate glucose readings. This can help reduce the impact of sudden spikes or dips that might not reflect your true blood glucose levels."
+                                "Trio will always display values based on your actual (raw) CGM readings. Smoothing does not change your real values or alerts."
                             )
+
+                            Text("When this feature is enabled:")
+
+                            VStack(alignment: .leading) {
+                                Text(
+                                    "• The main chart and treatment chart show a light gray trend line for the smoothed values. The glucose dots always show your original CGM readings."
+                                )
+
+                                Text("• In Trio history, you will see the smoothed value next to the original reading.")
+
+                                Text("• When you long-press a chart, the pop-up will show both the original and smoothed values.")
+                            }
+
+                            Text(
+                                "It can handle small gaps in data and ignores sensor error values. It needs at least 4 readings within 12 minutes to work properly. Only CGM readings are smoothed—manual entries are not changed."
+                            )
+
                             Text(
-                                "Note: If enabled, the smoothed values you see in Trio may differ from what is shown in your CGM app."
+                                "This helps Trio make more stable dosing decisions by avoiding over-reactions to small or short-term changes. Important trends are kept, while unreliable fluctuations are filtered out."
                             )
                         }
                     )
@@ -181,7 +200,7 @@ extension CGMSettings {
                         hintDetent: $hintDetent,
                         shouldDisplayHint: $shouldDisplayHint,
                         hintLabel: hintLabel ?? "",
-                        hintText: AnyView(
+                        hintText: selectedVerboseHint ?? AnyView(
                             VStack(alignment: .leading, spacing: 10) {
                                 Text(
                                     "Current CGM Models Supported:"

+ 21 - 3
Trio/Sources/Modules/History/View/HistoryRootView.swift

@@ -469,10 +469,11 @@ extension History {
         private var glucoseList: some View {
             List {
                 HStack {
-                    Text("Values").foregroundStyle(.secondary)
+                    Text("Values")
                     Spacer()
-                    Text("Time").foregroundStyle(.secondary)
-                }
+                    Text("Time")
+                }.foregroundStyle(.secondary)
+
                 if !glucoseStored.isEmpty {
                     ForEach(glucoseStored) { glucose in
                         HStack {
@@ -485,6 +486,23 @@ extension History {
                                 Text("\(glucose.directionEnum?.symbol ?? "--")")
                             }
 
+                            if state.settingsManager.settings.smoothGlucose, !glucose.isManual,
+                               let smoothedGlucose = glucose.smoothedGlucose, smoothedGlucose != 0
+                            {
+                                let smoothedGlucoseForDisplay = state.units == .mgdL ? smoothedGlucose
+                                    .description : smoothedGlucose.decimalValue
+                                    .formattedAsMmolL
+
+                                (
+                                    Text("(") +
+                                        Text(Image(systemName: "sparkles")) +
+                                        Text(" ") +
+                                        Text("\(smoothedGlucoseForDisplay)") +
+                                        Text(")")
+                                ).foregroundStyle(.secondary)
+                                    .padding(.leading, 10)
+                            }
+
                             Spacer()
 
                             Text(Formatter.dateFormatter.string(from: glucose.date ?? Date()))

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

@@ -48,6 +48,7 @@ extension Home {
         var reservoir: Decimal?
         var pumpName = ""
         var pumpExpiresAtDate: Date?
+        var pumpActivatedAtDate: Date?
         var highTTraisesSens: Bool = false
         var lowTTlowersSens: Bool = false
         var isExerciseModeActive: Bool = false
@@ -76,6 +77,7 @@ extension Home {
         var displayXgridLines: Bool = false
         var displayYgridLines: Bool = false
         var thresholdLines: Bool = false
+        var bolusDisplayThreshold: BolusDisplayThreshold = .allUnits
         var hours: Int16 = 6
         var totalBolus: Decimal = 0
         var isLoopStatusPresented: Bool = false
@@ -344,6 +346,11 @@ extension Home {
                 .weakAssign(to: \.pumpExpiresAtDate, on: self)
                 .store(in: &lifetime)
 
+            apsManager.pumpActivatedAtDate
+                .receive(on: DispatchQueue.main)
+                .weakAssign(to: \.pumpActivatedAtDate, on: self)
+                .store(in: &lifetime)
+
             apsManager.lastError
                 .receive(on: DispatchQueue.main)
                 .map { [weak self] error in
@@ -404,6 +411,7 @@ extension Home {
             eA1cDisplayUnit = settingsManager.settings.eA1cDisplayUnit
             displayXgridLines = settingsManager.settings.xGridLines
             displayYgridLines = settingsManager.settings.yGridLines
+            bolusDisplayThreshold = settingsManager.settings.bolusDisplayThreshold
             thresholdLines = settingsManager.settings.rulerMarks
             showCarbsRequiredBadge = settingsManager.settings.showCarbsRequiredBadge
             forecastDisplayType = settingsManager.settings.forecastDisplayType
@@ -668,6 +676,7 @@ extension Home.StateModel:
         displayXgridLines = settingsManager.settings.xGridLines
         displayYgridLines = settingsManager.settings.yGridLines
         thresholdLines = settingsManager.settings.rulerMarks
+        bolusDisplayThreshold = settingsManager.settings.bolusDisplayThreshold
         showCarbsRequiredBadge = settingsManager.settings.showCarbsRequiredBadge
         forecastDisplayType = settingsManager.settings.forecastDisplayType
         cgmAvailable = (fetchGlucoseManager.cgmGlucoseSourceType != CGMType.none)

+ 16 - 2
Trio/Sources/Modules/Home/View/Chart/ChartElements/BasalChart.swift

@@ -177,6 +177,7 @@ extension MainChartView {
         let startOfDay = Calendar.current.startOfDay(for: beginDate)
         let profile = state.basalProfile
         var basalPoints: [BasalProfile] = []
+        var lastEntryBeforeRange: (amount: Double, date: Date)?
 
         // Iterate over the next three days, multiplying the time intervals
         for dayOffset in 0 ..< 3 {
@@ -185,8 +186,12 @@ extension MainChartView {
                 let basalTime = startOfDay.addingTimeInterval(entry.minutes.minutes.timeInterval + dayTimeOffset)
                 let basalTimeInterval = basalTime.timeIntervalSince1970
 
-                // Only append points within the timeBegin and timeEnd range
-                if basalTimeInterval >= timeBegin, basalTimeInterval < timeEnd {
+                if basalTimeInterval < timeBegin {
+                    // Track the last profile entry before the visible range
+                    if lastEntryBeforeRange == nil || basalTime > lastEntryBeforeRange!.date {
+                        lastEntryBeforeRange = (amount: Double(entry.rate), date: basalTime)
+                    }
+                } else if basalTimeInterval < timeEnd {
                     basalPoints.append(BasalProfile(
                         amount: Double(entry.rate),
                         isOverwritten: false,
@@ -196,6 +201,15 @@ extension MainChartView {
             }
         }
 
+        // Include the active profile entry at timeBegin so the line starts at the chart's left edge
+        if let lastBefore = lastEntryBeforeRange {
+            basalPoints.append(BasalProfile(
+                amount: lastBefore.amount,
+                isOverwritten: false,
+                startDate: beginDate
+            ))
+        }
+
         return basalPoints
     }
 

+ 27 - 37
Trio/Sources/Modules/Home/View/Chart/ChartElements/GlucoseChartView.swift

@@ -32,46 +32,36 @@ struct GlucoseChartView: ChartContent {
                 glucoseColorScheme: glucoseColorScheme
             )
 
-            if !isSmoothingEnabled {
-                PointMark(
-                    x: .value("Time", item.date ?? Date(), unit: .second),
-                    y: .value("Value", glucoseToDisplay)
-                )
-                .foregroundStyle(pointMarkColor)
-                .symbolSize(20)
-                .symbol {
-                    if item.isManual {
-                        Image(systemName: "drop.fill")
-                            .font(.caption2)
-                            .symbolRenderingMode(.monochrome)
-                            .bold()
-                            .foregroundStyle(.red)
-                    } else {
-                        Image(systemName: "circle.fill")
-                            .font(.system(size: 5))
-                            .bold()
-                            .foregroundStyle(pointMarkColor)
-                    }
+            PointMark(
+                x: .value("Time", item.date ?? Date(), unit: .second),
+                y: .value("Value", glucoseToDisplay)
+            )
+            .foregroundStyle(pointMarkColor)
+            .symbolSize(20)
+            .symbol {
+                if item.isManual {
+                    Image(systemName: "drop.fill")
+                        .font(.caption2)
+                        .symbolRenderingMode(.monochrome)
+                        .bold()
+                        .foregroundStyle(.red)
+                } else {
+                    Image(systemName: "circle.fill")
+                        .font(.system(size: 5))
+                        .bold()
+                        .foregroundStyle(pointMarkColor)
                 }
-            } else {
-                PointMark(
+            }
+
+            if isSmoothingEnabled, let smoothedGlucose = item.smoothedGlucose, smoothedGlucose != 0 {
+                let smoothedGlucoseForDisplay: Decimal = units == .mgdL ? smoothedGlucose.decimalValue : smoothedGlucose
+                    .decimalValue.asMmolL
+                LineMark(
                     x: .value("Time", item.date ?? Date(), unit: .second),
-                    y: .value("Value", glucoseToDisplay)
+                    y: .value("Value", smoothedGlucoseForDisplay),
+                    series: .value("Type", "Smoothed")
                 )
-                .symbol {
-                    if item.isManual {
-                        Image(systemName: "drop.fill")
-                            .font(.caption2)
-                            .symbolRenderingMode(.monochrome)
-                            .bold()
-                            .foregroundStyle(.red)
-                    } else {
-                        Image(systemName: "record.circle.fill")
-                            .font(.system(size: 8))
-                            .bold()
-                            .foregroundStyle(pointMarkColor)
-                    }
-                }
+                .foregroundStyle(Color.secondary)
             }
         }
     }

+ 6 - 3
Trio/Sources/Modules/Home/View/Chart/ChartElements/InsulinView.swift

@@ -6,6 +6,7 @@ struct InsulinView: ChartContent {
     let glucoseData: [GlucoseStored]
     let insulinData: [PumpEventStored]
     let units: GlucoseUnits
+    let bolusDisplayThreshold: BolusDisplayThreshold
 
     var body: some ChartContent {
         drawBoluses()
@@ -32,9 +33,11 @@ struct InsulinView: ChartContent {
                     Image(systemName: "arrowtriangle.down.fill").font(.system(size: size)).foregroundStyle(Color.insulin)
                 }
                 .annotation(position: .top) {
-                    Text(Formatter.bolusFormatter.string(from: amount) ?? "")
-                        .font(.caption2)
-                        .foregroundStyle(Color.primary)
+                    if amount as Decimal >= bolusDisplayThreshold.rawValue {
+                        Text(Formatter.bolusFormatter.string(from: amount) ?? "")
+                            .font(.caption2)
+                            .foregroundStyle(Color.primary)
+                    }
                 }
             }
         }

+ 14 - 3
Trio/Sources/Modules/Home/View/Chart/ChartElements/SelectionPopoverView.swift

@@ -11,6 +11,7 @@ struct SelectionPopoverView: ChartContent {
     let lowGlucose: Decimal
     let currentGlucoseTarget: Decimal
     let glucoseColorScheme: GlucoseColorScheme
+    let isSmoothingEnabled: Bool
 
     private var glucoseToDisplay: Decimal {
         units == .mgdL ? Decimal(selectedGlucose.glucose) : Decimal(selectedGlucose.glucose).asMmolL
@@ -33,7 +34,7 @@ struct SelectionPopoverView: ChartContent {
     var body: some ChartContent {
         RuleMark(x: .value("Selection", selectedGlucose.date ?? Date.now, unit: .minute))
             .foregroundStyle(Color.tabBar)
-            .offset(yStart: 70)
+            .offset(yStart: isSmoothingEnabled ? 90 : 70)
             .lineStyle(.init(lineWidth: 2))
             .annotation(
                 position: .top,
@@ -70,15 +71,25 @@ struct SelectionPopoverView: ChartContent {
             .font(.body).padding(.bottom, 2)
 
             HStack {
-                Text(glucoseToDisplay.description).bold() + Text(" \(units.rawValue)")
+                Text("CGM: ") + Text(glucoseToDisplay.description).bold() + Text(" \(units.rawValue)")
             }
             .foregroundStyle(pointMarkColor)
             .font(.body)
 
+            if isSmoothingEnabled, let smoothedGlucose = selectedGlucose.smoothedGlucose {
+                var smoothedGlucoseToDisplay: Decimal {
+                    units == .mgdL ? smoothedGlucose.decimalValue : smoothedGlucose.decimalValue.asMmolL
+                }
+                HStack {
+                    Image(systemName: "sparkles")
+                    Text(smoothedGlucoseToDisplay.description) + Text(" \(units.rawValue)")
+                }.font(.body)
+            }
+
             if let selectedIOBValue, let iob = selectedIOBValue.iob {
                 HStack {
                     Image(systemName: "syringe.fill").frame(width: 15)
-                    Text(Formatter.bolusFormatter.string(from: iob) ?? "")
+                    Text(Formatter.decimalFormatterWithTwoFractionDigits.string(from: iob) ?? "")
                         .bold()
                         + Text(String(localized: " U", comment: "Insulin unit"))
                 }

+ 4 - 2
Trio/Sources/Modules/Home/View/Chart/MainChartView.swift

@@ -149,7 +149,8 @@ extension MainChartView {
                 InsulinView(
                     glucoseData: state.glucoseFromPersistence,
                     insulinData: state.insulinFromPersistence,
-                    units: state.units
+                    units: state.units,
+                    bolusDisplayThreshold: state.bolusDisplayThreshold
                 )
 
                 CarbView(
@@ -181,7 +182,8 @@ extension MainChartView {
                         highGlucose: highGlucose,
                         lowGlucose: lowGlucose,
                         currentGlucoseTarget: currentGlucoseTarget,
-                        glucoseColorScheme: glucoseColorScheme
+                        glucoseColorScheme: glucoseColorScheme,
+                        isSmoothingEnabled: state.settingsManager.settings.smoothGlucose
                     )
                 }
             }

+ 45 - 25
Trio/Sources/Modules/Home/View/Header/PumpView.swift

@@ -5,11 +5,14 @@ struct PumpView: View {
     let reservoir: Decimal?
     let name: String
     let expiresAtDate: Date?
+    let activatedAtDate: Date?
     let timerDate: Date
     let pumpStatusHighlightMessage: String?
     let battery: [OpenAPS_Battery]
     @Environment(\.colorScheme) var colorScheme
 
+    let NORMAL_PATCH_AGE = TimeInterval.hours(80)
+
     private var batteryFormatter: NumberFormatter {
         let formatter = NumberFormatter()
         formatter.numberStyle = .percent
@@ -17,6 +20,7 @@ struct PumpView: View {
     }
 
     private var hourglassIcon: String {
+        if activatedAtDate != nil { return "hourglass.badge.plus" }
         guard let expiration = expiresAtDate else { return "hourglass" }
 
         let hoursRemaining = expiration.timeIntervalSince(timerDate) / 3600
@@ -96,34 +100,38 @@ struct PumpView: View {
                 }
 
                 if let date = expiresAtDate {
-                    HStack {
-                        Image(systemName: hourglassIcon)
-                            .font(.callout)
-                            .foregroundStyle(timerColor, Color.yellow)
-                            .symbolRenderingMode(.palette)
-
-                        let remainingTimeString = remainingTimeString(time: date.timeIntervalSince(timerDate))
-
-                        Text(remainingTimeString)
-                            .font(date.timeIntervalSince(timerDate) > 0 ? .callout : .subheadline)
-                            .fontWeight(.bold)
-                            .fontDesign(.rounded)
-                            .lineLimit(2)
-                            .multilineTextAlignment(.leading)
-                            .frame(
-                                // If the string is > 6 chars, i.e., exceeds "xd yh", limit width to 80 pts
-                                // This forces the "Replace pod" string to wrap to 2 lines.
-                                maxWidth: remainingTimeString.count > 6 ? 80 : .infinity,
-                                alignment: .leading
-                            )
-                    }
-                    // aligns the stopwatch icon exactly with the first pixel of the reservoir icon
-                    .padding(.leading, date.timeIntervalSince(timerDate) > 0 ? 12 : 0)
+                    PatchTimer(forDate: date)
                 }
             }
         }
     }
 
+    @ViewBuilder private func PatchTimer(forDate date: Date) -> some View {
+        HStack {
+            Image(systemName: hourglassIcon)
+                .font(.callout)
+                .foregroundStyle(timerColor, timerColorSecondary)
+                .symbolRenderingMode(.palette)
+
+            let remainingTimeString = remainingTimeString(time: date.timeIntervalSince(timerDate))
+
+            Text(remainingTimeString)
+                .font(date.timeIntervalSince(timerDate) > 0 ? .callout : .subheadline)
+                .fontWeight(.bold)
+                .fontDesign(.rounded)
+                .lineLimit(2)
+                .multilineTextAlignment(.leading)
+                .frame(
+                    // If the string is > 6 chars, i.e., exceeds "xd yh", limit width to 80 pts
+                    // This forces the "Replace pod" string to wrap to 2 lines.
+                    maxWidth: remainingTimeString.count > 6 ? 80 : .infinity,
+                    alignment: .leading
+                )
+        }
+        // aligns the stopwatch icon exactly with the first pixel of the reservoir icon
+        .padding(.leading, date.timeIntervalSince(timerDate) > 0 || activatedAtDate != nil ? 12 : 0)
+    }
+
     private func remainingTimeString(time: TimeInterval) -> String {
         guard time > 0 else {
             return String(localized: "Replace pod", comment: "View/Header when pod expired")
@@ -184,11 +192,15 @@ struct PumpView: View {
     }
 
     private var timerColor: Color {
-        guard let expisesAt = expiresAtDate else {
+        if let activatedAt = activatedAtDate {
+            return abs(activatedAt.timeIntervalSinceNow) > NORMAL_PATCH_AGE ? Color.yellow : Color.loopGreen
+        }
+
+        guard let expiresAt = expiresAtDate else {
             return .gray
         }
 
-        let time = expisesAt.timeIntervalSince(timerDate)
+        let time = expiresAt.timeIntervalSince(timerDate)
 
         switch time {
         case ...8.hours.timeInterval:
@@ -199,6 +211,14 @@ struct PumpView: View {
             return Color.loopGreen
         }
     }
+
+    private var timerColorSecondary: Color {
+        if activatedAtDate != nil {
+            return Color.gray
+        }
+
+        return Color.yellow
+    }
 }
 
 // #Preview("message") {

+ 2 - 1
Trio/Sources/Modules/Home/View/HomeRootView.swift

@@ -153,6 +153,7 @@ extension Home {
                 reservoir: state.reservoir,
                 name: state.pumpName,
                 expiresAtDate: state.pumpExpiresAtDate,
+                activatedAtDate: state.pumpActivatedAtDate,
                 timerDate: state.timerDate,
                 pumpStatusHighlightMessage: state.pumpStatusHighlightMessage,
                 battery: state.batteryFromPersistence
@@ -1003,7 +1004,6 @@ extension Home {
             }
             .navigationTitle("Home")
             .navigationBarHidden(true)
-            .ignoresSafeArea(.keyboard)
             .blur(radius: state.isLoopStatusPresented ? 3 : 0)
             .sheet(isPresented: $state.isLoopStatusPresented) {
                 LoopStatusView(state: state)
@@ -1017,6 +1017,7 @@ extension Home {
                 Button("Omnipod Eros") { state.addPump(.omnipod) }
                 Button("Omnipod DASH") { state.addPump(.omnipodBLE) }
                 Button("Dana(RS/-i)") { state.addPump(.dana) }
+                Button("Medtrum Nano") { state.addPump(.medtrum) }
                 Button("Pump Simulator") { state.addPump(.simulator) }
             } message: { Text("Select Pump Model") }
             .sheet(isPresented: $state.shouldDisplayPumpSetupSheet) {

+ 2 - 1
Trio/Sources/Modules/ISFEditor/View/ISFEditorRootView.swift

@@ -143,7 +143,8 @@ extension ISFEditor {
         private var isfChart: some View {
             Chart {
                 ForEach(Array(state.items.enumerated()), id: \.element.id) { index, item in
-                    let displayValue = state.rateValues[item.rateIndex]
+                    let displayValue = state.units == .mgdL ? state.rateValues[item.rateIndex] : state.rateValues[item.rateIndex]
+                        .asMmolL
 
                     let startDate = Calendar.current
                         .startOfDay(for: now)

+ 1 - 5
Trio/Sources/Modules/MealSettings/MealSettingsStateModel.swift

@@ -3,12 +3,11 @@ import SwiftUI
 extension MealSettings {
     final class StateModel: BaseStateModel<Provider> {
         @Published var units: GlucoseUnits = .mgdL
-        @Published var useFPUconversion: Bool = true
+        @Published var useFPUconversion: Bool = false
         @Published var maxCarbs: Decimal = 250
         @Published var maxFat: Decimal = 250
         @Published var maxProtein: Decimal = 250
         @Published var individualAdjustmentFactor: Decimal = 0.5
-        @Published var timeCap: Decimal = 8
         @Published var minuteInterval: Decimal = 30
         @Published var delay: Decimal = 60
         @Published var maxMealAbsorptionTime: Decimal = 6
@@ -27,9 +26,6 @@ extension MealSettings {
             // "Fat and Protein Delay"
             subscribeSetting(\.delay, on: $delay) { delay = $0 }
 
-            // "Maximum Duration"
-            subscribeSetting(\.timeCap, on: $timeCap) { timeCap = $0 }
-
             // "Spread Interval"
             subscribeSetting(\.minuteInterval, on: $minuteInterval) { minuteInterval = $0 }
 

+ 4 - 34
Trio/Sources/Modules/MealSettings/View/MealSettingsRootView.swift

@@ -203,7 +203,7 @@ extension MealSettings {
                     VStack(alignment: .leading, spacing: 10) {
                         Text("Default: 6 hours").bold()
                         Text(
-                            "Carb entries will be fully decayed by the number of hours specified as Max Meal Absorption Time. Meals that are high in fat and/or protein can have long lasting effects on BG levels. To allow such late meal effects to be considered by the carb decay model, a longer Max Meal Absorption Time than the default 6 hours can be set."
+                            "Carb entries will be fully decayed by the number of hours specified as Max Meal Absorption Time. Meals that are high in fat and/or protein can have long lasting effects on glucose levels. To allow such late meal effects to be considered by the carb decay model, a longer Max Meal Absorption Time than the default 6 hours can be set."
                         )
                         Text(
                             "If carb entries decay too slowly, it is possible to set a lower than default setting. But this should typically be adressed by tuning ISF and CR settings instead, which in combination determines the rate of carb decay."
@@ -258,7 +258,6 @@ extension MealSettings {
                                     "You can personalize the conversion calculation by adjusting the following settings that will appear when this option is enabled:"
                                 )
                                 Text("• Fat and Protein Delay")
-                                Text("• Maximum Duration")
                                 Text("• Spread Interval")
                                 Text("• Fat and Protein Percentage")
                             }
@@ -295,35 +294,6 @@ extension MealSettings {
                     )
 
                     SettingInputSection(
-                        decimalValue: $state.timeCap,
-                        booleanValue: $booleanPlaceholder,
-                        shouldDisplayHint: $shouldDisplayHint,
-                        selectedVerboseHint: Binding(
-                            get: { selectedVerboseHint },
-                            set: {
-                                selectedVerboseHint = $0.map { AnyView($0) }
-                                hintLabel = String(localized: "Maximum Duration")
-                            }
-                        ),
-                        units: state.units,
-                        type: .decimal("timeCap"),
-                        label: String(localized: "Maximum Duration"),
-                        miniHint: String(localized: "Set the maximum timeframe to extend FPUs."),
-                        verboseHint:
-                        VStack(alignment: .leading, spacing: 10) {
-                            Text("Default: 8 hours").bold()
-                            Text(
-                                "This sets the maximum length of time that Fat and Protein Carb Equivalents (FPUs) will be extended over from a single Fat and/or Protein bolus calcultor entry."
-                            )
-                            Text(
-                                "It is one factor used in combination with the Fat and Protein Delay, Spread Interval, and Fat and Protein Factor to create the FPU entries."
-                            )
-                            Text("Increasing this setting may result in more FPU entries with smaller carb values.")
-                            Text("Decreasing this setting may result in fewer FPU entries with larger carb values.")
-                        }
-                    )
-
-                    SettingInputSection(
                         decimalValue: $state.minuteInterval,
                         booleanValue: $booleanPlaceholder,
                         shouldDisplayHint: $shouldDisplayHint,
@@ -344,9 +314,9 @@ extension MealSettings {
                             Text(
                                 "This determines how many minutes will be between individual Fat-Protein Unit Carb Equivalent (FPU) entries from a single Fat and/or Protein bolus calculator entry."
                             )
-                            Text("The shorter the interval, the smoother the correlating dosing result.")
-                            Text("Increasing this setting may result in fewer FPU entries with larger carb values.")
-                            Text("Decreasing this setting may result in more FPU entries with smaller carb values.")
+                            Text(
+                                "Entries are capped at 33 grams each, with up to three entries, for a max total of 99 grams."
+                            )
                         }
                     )
 

+ 6 - 2
Trio/Sources/Modules/Onboarding/OnboardingStateModel.swift

@@ -3,6 +3,7 @@ import DanaKit
 import FirebaseCrashlytics
 import Foundation
 import LoopKit
+import MedtrumKit
 import MinimedKit
 import Observation
 import OmniBLE
@@ -44,7 +45,7 @@ extension Onboarding {
 
         // MARK: - Determine Initial Build State
 
-        /// Determines whether the app is in a fresh install state for Trio v0.3.0.
+        /// Determines whether the app is in a fresh install state for Trio (new vs. returning/updating user).
         ///
         /// This check is based on the assumption that a truly clean install will only contain
         /// the `logs/` directory and the `preferences.json` file in the app's Documents directory.
@@ -120,6 +121,8 @@ extension Onboarding {
                         defaultOption = .omnipodDash
                     } else if pumpManager is OmnipodPumpManager {
                         defaultOption = .omnipodEros
+                    } else if pumpManager is MedtrumPumpManager {
+                        defaultOption = .medtrum
                     } else if pumpManager is DanaKitPumpManager {
                         defaultOption = .dana
                     } else if pumpManager is MinimedPumpManager {
@@ -165,6 +168,8 @@ extension Onboarding {
                 return PickerSetting(value: 0.1, step: 0.05, min: 0, max: 30, type: .insulinUnitPerHour)
             case .omnipodEros:
                 return PickerSetting(value: 0.1, step: 0.05, min: 0.05, max: 30, type: .insulinUnitPerHour)
+            case .medtrum:
+                return PickerSetting(value: 0.1, step: 0.05, min: 0.05, max: 30, type: .insulinUnitPerHour)
             case .none:
                 // same as dash, as that is the fallback
                 return PickerSetting(value: 0.1, step: 0.05, min: 0, max: 30, type: .insulinUnitPerHour)
@@ -717,7 +722,6 @@ extension Onboarding {
                     .clamp(to: providedSettings.carbsRequiredThreshold)
                 settingsCopy.individualAdjustmentFactor = settingsCopy.individualAdjustmentFactor
                     .clamp(to: providedSettings.individualAdjustmentFactor)
-                settingsCopy.timeCap = settingsCopy.timeCap.clamp(to: providedSettings.timeCap)
                 settingsCopy.minuteInterval = settingsCopy.minuteInterval.clamp(to: providedSettings.minuteInterval)
                 settingsCopy.delay = settingsCopy.delay.clamp(to: providedSettings.delay)
                 settingsCopy.high = settingsCopy.high.clamp(to: providedSettings.high)

+ 2 - 1
Trio/Sources/Modules/Onboarding/View/OnboardingRootView.swift

@@ -597,7 +597,8 @@ struct OnboardingNavigationButtons: View {
                 case .dana,
                      .minimed:
                     currentAutosensSubstep = .rewindResetsAutosens
-                case .omnipodDash,
+                case .medtrum,
+                     .omnipodDash,
                      .omnipodEros:
                     currentAutosensSubstep = .autosensMax
                 }

+ 2 - 1
Trio/Sources/Modules/Onboarding/View/OnboardingSteps/AlgorithmSettings/AlgorithmSettingsSubstepView.swift

@@ -21,7 +21,8 @@ struct AlgorithmSettingsSubstepView<Substep: AlgorithmSubstepProtocol & RawRepre
         case .dana,
              .minimed:
             return false
-        case .omnipodDash,
+        case .medtrum,
+             .omnipodDash,
              .omnipodEros:
             return true
         }

+ 2 - 2
Trio/Sources/Modules/Onboarding/View/OnboardingView+AlgorithmUtil.swift

@@ -121,8 +121,8 @@ enum AlgorithmSettingsSubstep: Int, CaseIterable, Identifiable {
         case .maxSMBMinutes: return String(localized: "Max SMB Basal Minutes", comment: "Max SMB Basal Minutes")
         case .maxUAMMinutes: return String(localized: "Max UAM Basal Minutes", comment: "Max UAM Basal Minutes")
         case .maxDeltaGlucoseThreshold: return String(
-                localized: "Max. Allowed Glucose Rise for SMB",
-                comment: "Max. Allowed Glucose Rise for SMB, formerly Max Delta-BG Threshold"
+                localized: "Max Allowed Glucose Rise for SMB",
+                comment: "Max Allowed Glucose Rise for SMB, formerly Max Delta-BG Threshold"
             )
         case .highTempTargetRaisesSensitivity: return String(
                 localized: "High Temp Target Raises Sensitivity",

+ 3 - 0
Trio/Sources/Modules/Onboarding/View/OnboardingView+Util.swift

@@ -504,6 +504,7 @@ enum PumpOptionForOnboardingUnits: String, Equatable, CaseIterable, Identifiable
     case omnipodEros
     case omnipodDash
     case dana
+    case medtrum
 
     var id: String { rawValue }
 
@@ -517,6 +518,8 @@ enum PumpOptionForOnboardingUnits: String, Equatable, CaseIterable, Identifiable
             return "Omnipod DASH"
         case .dana:
             return "Dana (RS/-i)"
+        case .medtrum:
+            return "Medtrum Nano"
         }
     }
 }

+ 1 - 0
Trio/Sources/Modules/PumpConfig/PumpConfigDataFlow.swift

@@ -10,6 +10,7 @@ enum PumpConfig {
         case omnipod
         case omnipodBLE
         case dana
+        case medtrum
         case simulator
     }
 

+ 5 - 0
Trio/Sources/Modules/PumpConfig/View/PumpConfigRootView.swift

@@ -36,6 +36,9 @@ extension PumpConfig {
                                 } label: {
                                     HStack {
                                         Image(uiImage: pumpState.image ?? UIImage())
+                                            .resizable()
+                                            .scaledToFit()
+                                            .frame(maxWidth: 100)
                                         Text(pumpState.name)
                                     }
                                     .frame(maxWidth: .infinity, minHeight: 50, alignment: .center)
@@ -118,6 +121,7 @@ extension PumpConfig {
                                     Text("• Omnipod Eros")
                                     Text("• Omnipod DASH")
                                     Text("• Dana (RS/-i)")
+                                    Text("• Medtrum Nano (200u/300u)")
                                     Text("• Pump Simulator")
                                 }
                                 Text(
@@ -133,6 +137,7 @@ extension PumpConfig {
                     Button("Omnipod Eros") { state.addPump(.omnipod) }
                     Button("Omnipod DASH") { state.addPump(.omnipodBLE) }
                     Button("Dana(RS/-i)") { state.addPump(.dana) }
+                    Button("Medtrum Nano") { state.addPump(.medtrum) }
                     Button("Pump Simulator") { state.addPump(.simulator) }
                 } message: { Text("Select Pump Model") }
             }

+ 10 - 0
Trio/Sources/Modules/PumpConfig/View/PumpSetupView.swift

@@ -1,6 +1,7 @@
 import DanaKit
 import LoopKit
 import LoopKitUI
+import MedtrumKit
 import MinimedKit
 import MinimedKitUI
 import MockKit
@@ -68,6 +69,15 @@ extension PumpConfig {
                     prefersToSkipUserInteraction: false,
                     allowedInsulinTypes: [.apidra, .humalog, .novolog, .fiasp, .lyumjev]
                 )
+            case .medtrum:
+                setupViewController = MedtrumPumpManager.setupViewController(
+                    initialSettings: initialSettings,
+                    bluetoothProvider: bluetoothManager,
+                    colorPalette: .default,
+                    allowDebugFeatures: true,
+                    prefersToSkipUserInteraction: false,
+                    allowedInsulinTypes: [.apidra, .humalog, .novolog, .fiasp, .lyumjev]
+                )
             case .simulator:
                 setupViewController = MockPumpManager.setupViewController(
                     initialSettings: initialSettings,

+ 4 - 4
Trio/Sources/Modules/SMBSettings/View/SMBSettingsRootView.swift

@@ -331,16 +331,16 @@ extension SMBSettings {
                         set: {
                             selectedVerboseHint = $0.map { AnyView($0) }
                             hintLabel = String(
-                                localized: "Max. Allowed Glucose Rise for SMB",
-                                comment: "Max. Allowed Glucose Rise for SMB, formerly Max Delta-BG Threshold"
+                                localized: "Max Allowed Glucose Rise for SMB",
+                                comment: "Max Allowed Glucose Rise for SMB, formerly Max Delta-BG Threshold"
                             )
                         }
                     ),
                     units: state.units,
                     type: .decimal("maxDeltaBGthreshold"),
                     label: String(
-                        localized: "Max. Allowed Glucose Rise for SMB",
-                        comment: "Max. Allowed Glucose Rise for SMB, formerly Max Delta-BG Threshold"
+                        localized: "Max Allowed Glucose Rise for SMB",
+                        comment: "Max Allowed Glucose Rise for SMB, formerly Max Delta-BG Threshold"
                     ),
                     miniHint: String(localized: "Disables SMBs if last two glucose values differ by more than this percent."),
                     verboseHint:

+ 7 - 7
Trio/Sources/Modules/Settings/SettingItems.swift

@@ -114,13 +114,13 @@ enum SettingItems {
                 "Enable SMB With COB",
                 "Enable SMB With Temporary Target",
                 "Enable SMB After Carbs",
-                "Enable SMB With High BG",
-                "High BG Target",
+                "Enable SMB With High Glucose",
+                "High Glucose Target",
                 "Allow SMB With High Temporary Target",
                 "Enable UAM",
                 "Max SMB Basal Minutes",
                 "Max UAM SMB Basal Minutes",
-                "Max Delta-BG Threshold SMB"
+                "Max Allowed Glucose Rise for SMB"
             ],
             path: ["Algorithm", "Super Micro Bolus (SMB)"]
         ),
@@ -195,11 +195,10 @@ enum SettingItems {
                 "Max Meal Absorption Time",
                 "Max Fat",
                 "Max Protein",
-                "Display and Allow Fat and Protein Entries",
+                "Enable Fat and Protein Entries",
                 "Fat and Protein Delay",
-                "Maximum Duration (hours)",
-                "Spread Interval (minutes)",
-                "Fat and Protein Factor",
+                "Spread Interval",
+                "Fat and Protein Percentage",
                 "FPU"
             ],
             path: ["Features", "Meal Settings"]
@@ -230,6 +229,7 @@ enum SettingItems {
                 "Show Carbs Required Badge",
                 "Carbs Required Threshold",
                 "Forecast Display Type",
+                "Bolus Display Threshold",
                 "Cone",
                 "Lines",
                 "Dark Mode",

+ 1 - 8
Trio/Sources/Modules/SettingsExport/SettingsExportStateModel.swift

@@ -396,7 +396,7 @@ extension SettingsExport {
                 addSetting(
                     category: algorithmCategory,
                     subcategory: smbSubcategory,
-                    name: String(localized: "Max. Allowed Glucose Rise for SMB"),
+                    name: String(localized: "Max Allowed Glucose Rise for SMB"),
                     value: String(format: "%.0f", (preferences.maxDeltaBGthreshold as NSDecimalNumber).doubleValue * 100),
                     unit: "%"
                 )
@@ -704,13 +704,6 @@ extension SettingsExport {
                 addSetting(
                     category: featuresCategory,
                     subcategory: mealSettingsSubcategory,
-                    name: String(localized: "Maximum Duration"),
-                    value: String(describing: trioSettings.timeCap),
-                    unit: String(localized: "hours")
-                )
-                addSetting(
-                    category: featuresCategory,
-                    subcategory: mealSettingsSubcategory,
                     name: String(localized: "Spread Interval"),
                     value: String(describing: trioSettings.minuteInterval),
                     unit: String(localized: "minutes")

+ 31 - 2
Trio/Sources/Modules/Snooze/SnoozeStateModel.swift

@@ -4,12 +4,41 @@ import SwiftUI
 extension Snooze {
     @Observable final class StateModel: BaseStateModel<Provider> {
         @ObservationIgnored @Persisted(key: "UserNotificationsManager.snoozeUntilDate") var snoozeUntilDate: Date = .distantPast
-        @ObservationIgnored @Injected() var glucoseStogare: GlucoseStorage!
+        @ObservationIgnored @Injected() var glucoseStorage: GlucoseStorage!
+        @ObservationIgnored @Injected() var notificationsManager: UserNotificationsManager!
+        @ObservationIgnored @Injected() var broadcaster: Broadcaster!
 
         var alarm: GlucoseAlarm?
 
         override func subscribe() {
-            alarm = glucoseStogare.alarm
+            alarm = glucoseStorage.alarm
+            broadcaster.register(SnoozeObserver.self, observer: self)
         }
+
+        func unsubscribe() {
+            broadcaster.unregister(SnoozeObserver.self, observer: self)
+        }
+
+        // Add validation helper inside the class
+        private func validateSnoozeDuration(_ duration: TimeInterval) -> Bool {
+            // Only allow durations matching our defined actions
+            NotificationResponseAction.allCases
+                .map(\.duration)
+                .contains(duration)
+        }
+
+        @MainActor func applySnooze(_ duration: TimeInterval) async {
+            // Allow any duration chosen in the Snooze UI, while keeping validation for quick actions elsewhere.
+            snoozeUntilDate = duration > 0 ? Date().addingTimeInterval(duration) : .distantPast
+            alarm = glucoseStorage.alarm
+            await notificationsManager.applySnooze(for: duration)
+        }
+    }
+}
+
+extension Snooze.StateModel: SnoozeObserver {
+    func snoozeDidChange(_ untilDate: Date) {
+        snoozeUntilDate = untilDate
+        alarm = glucoseStorage.alarm
     }
 }

+ 26 - 30
Trio/Sources/Modules/Snooze/View/SnoozeRootView.swift

@@ -14,29 +14,12 @@ extension Snooze {
         @State private var snoozeDescription = "nothing to see here"
 
         private var pickerTimes: [TimeInterval] {
-            var arr: [TimeInterval] = []
-
-            let mins10 = 0.166_67
-            let mins20 = mins10 * 2
-            let mins30 = mins10 * 3
-            // let mins40 = mins10 * 4
-
-            for hr in 0 ..< 2 {
-                for min in [0.0, mins20, mins20 * 2] {
-                    arr.append(TimeInterval(hours: Double(hr) + min))
-                }
-            }
-            for hr in 2 ..< 4 {
-                for min in [0.0, mins30] {
-                    arr.append(TimeInterval(hours: Double(hr) + min))
-                }
-            }
-
-            for hr in 4 ... 8 {
-                arr.append(TimeInterval(hours: Double(hr)))
-            }
-
-            return arr
+            [
+                TimeInterval(minutes: 20), // 20 minutes
+                TimeInterval(hours: 1), // 1 hour
+                TimeInterval(hours: 3), // 3 hours
+                TimeInterval(hours: 6) // 6 hours
+            ]
         }
 
         private var formatter: DateComponentsFormatter {
@@ -53,7 +36,7 @@ extension Snooze {
         }
 
         private func formatInterval(_ interval: TimeInterval) -> String {
-            formatter.string(from: interval)!
+            formatter.string(from: interval) ?? ""
         }
 
         func getSnoozeDescription() -> String {
@@ -85,12 +68,16 @@ extension Snooze {
             VStack(alignment: .leading) {
                 Button {
                     let interval = pickerTimes[selectedInterval]
-                    let snoozeFor = formatter.string(from: interval)!
+                    let snoozeFor = formatInterval(interval)
                     let untilDate = Date() + interval
-                    state.snoozeUntilDate = untilDate < Date() ? .distantPast : untilDate
-                    debug(.default, "will snooze for \(snoozeFor) until \(dateFormatter.string(from: untilDate))")
-                    snoozeDescription = getSnoozeDescription()
-                    state.hideModal()
+
+                    Task { @MainActor [weak state] in
+                        guard let state = state else { return }
+                        await state.applySnooze(interval)
+                        debug(.default, "will snooze for \(snoozeFor) until \(dateFormatter.string(from: untilDate))")
+                        snoozeDescription = getSnoozeDescription()
+                        state.hideModal()
+                    }
                 } label: {
                     Text("Click to Snooze Alerts")
                         .padding()
@@ -120,11 +107,20 @@ extension Snooze {
             .scrollContentBackground(.hidden).background(appState.trioBackgroundColor(for: colorScheme))
             .navigationBarTitle("Snooze Alerts")
             .navigationBarTitleDisplayMode(.automatic)
-            .navigationBarItems(trailing: Button("Close", action: state.hideModal))
+            .toolbar {
+                ToolbarItem(placement: .topBarTrailing) {
+                    Button("Close") {
+                        state.hideModal()
+                    }
+                }
+            }
             .onAppear {
                 configureView()
                 snoozeDescription = getSnoozeDescription()
             }
+            .onDisappear {
+                state.unsubscribe()
+            }
         }
     }
 }

+ 1 - 1
Trio/Sources/Modules/Stat/View/ViewElements/Glucose/GlucoseSectorChart.swift

@@ -343,7 +343,7 @@ struct GlucoseSectorChart: View {
                         formatPercentage(Decimal(low) / total * 100)
                     ),
                     (
-                        String(localized: "Very Low (<\(Decimal(54).formatted(for: units))"),
+                        String(localized: "Very Low (<\(Decimal(54).formatted(for: units)))"),
                         formatPercentage(Decimal(veryLow) / total * 100)
                     ),
                     (String(localized: "Average"), average.formatted(for: units)),

+ 2 - 2
Trio/Sources/Modules/Stat/View/ViewElements/Insulin/BolusStatsView.swift

@@ -272,9 +272,9 @@ struct BolusStatsView: View {
                             AxisGridLine()
                         }
                     case .total:
-                        // Only show every other month
+                        // Show start of every month
                         let day = Calendar.current.component(.day, from: date)
-                        if day == 1 && Calendar.current.component(.month, from: date) % 2 == 1 {
+                        if day == 1 {
                             AxisValueLabel(format: StatChartUtils.dateFormat(for: selectedInterval), centered: true)
                                 .font(.footnote)
                             AxisGridLine()

+ 2 - 2
Trio/Sources/Modules/Stat/View/ViewElements/Insulin/TotalDailyDoseChart.swift

@@ -224,8 +224,8 @@ struct TotalDailyDoseChart: View {
                             AxisGridLine()
                         }
                     case .total:
-                        // Only show every other month
-                        if day == 1 && Calendar.current.component(.month, from: date) % 2 == 1 {
+                        // Show start of every month
+                        if day == 1 {
                             AxisValueLabel(format: StatChartUtils.dateFormat(for: selectedInterval), centered: true)
                                 .font(.footnote)
                             AxisGridLine()

+ 2 - 2
Trio/Sources/Modules/Stat/View/ViewElements/Meal/MealStatsView.swift

@@ -254,9 +254,9 @@ struct MealStatsView: View {
                             AxisGridLine()
                         }
                     case .total:
-                        // Only show every other month
+                        // Show start of every month
                         let day = Calendar.current.component(.day, from: date)
-                        if day == 1 && Calendar.current.component(.month, from: date) % 2 == 1 {
+                        if day == 1 {
                             AxisValueLabel(format: StatChartUtils.dateFormat(for: selectedInterval), centered: true)
                                 .font(.footnote)
                             AxisGridLine()

+ 39 - 17
Trio/Sources/Modules/Treatments/View/ForecastChart.swift

@@ -135,7 +135,7 @@ struct ForecastChart: View {
                     .lineStyle(.init(lineWidth: 2))
                     .annotation(
                         position: .top,
-                        overflowResolution: .init(x: .fit(to: .chart), y: .disabled)
+                        overflowResolution: .init(x: .fit(to: .chart), y: .fit(to: .chart))
                     ) {
                         selectionPopover
                     }
@@ -221,12 +221,22 @@ struct ForecastChart: View {
                     glucoseColorScheme: state.glucoseColorScheme
                 )
                 HStack {
-                    Text(state.units == .mgdL ? Decimal(sgv).description : Decimal(sgv).formattedAsMmolL)
+                    Text("CGM: ") + Text(state.units == .mgdL ? Decimal(sgv).description : Decimal(sgv).formattedAsMmolL)
                         .bold()
                         + Text(" \(state.units.rawValue)")
                 }.foregroundStyle(
                     Color(glucoseColor)
                 ).font(.footnote)
+
+                if state.isSmoothingEnabled, let smoothedGlucose = selectedGlucose?.smoothedGlucose {
+                    let smoothedGlucoseToDisplay: Decimal = state.units == .mgdL
+                        ? smoothedGlucose.decimalValue
+                        : smoothedGlucose.decimalValue.asMmolL
+                    HStack {
+                        Image(systemName: "sparkles")
+                        Text(smoothedGlucoseToDisplay.description) + Text(" \(state.units.rawValue)")
+                    }.font(.footnote)
+                }
             }
             .padding(7)
             .background {
@@ -259,25 +269,37 @@ struct ForecastChart: View {
                 glucoseColorScheme: state.glucoseColorScheme
             )
 
-            if !state.isSmoothingEnabled {
-                PointMark(
-                    x: .value("Time", item.date ?? Date(), unit: .second),
-                    y: .value("Value", glucoseToDisplay)
-                )
-                .foregroundStyle(pointMarkColor)
-                .symbolSize(18)
-            } else {
-                PointMark(
-                    x: .value("Time", item.date ?? Date(), unit: .second),
-                    y: .value("Value", glucoseToDisplay)
-                )
-                .symbol {
-                    Image(systemName: "record.circle.fill")
-                        .font(.system(size: 6))
+            PointMark(
+                x: .value("Time", item.date ?? Date(), unit: .second),
+                y: .value("Value", glucoseToDisplay)
+            )
+            .foregroundStyle(pointMarkColor)
+            .symbol {
+                if item.isManual {
+                    Image(systemName: "drop.fill")
+                        .font(.caption2)
+                        .symbolRenderingMode(.monochrome)
+                        .bold()
+                        .foregroundStyle(.red)
+                } else {
+                    Image(systemName: "circle.fill")
+                        .font(.system(size: 4))
                         .bold()
                         .foregroundStyle(pointMarkColor)
                 }
             }
+
+            if state.isSmoothingEnabled, let smoothedGlucose = item.smoothedGlucose, smoothedGlucose != 0 {
+                let smoothedGlucoseForDisplay: Decimal = state.units == .mgdL
+                    ? smoothedGlucose.decimalValue
+                    : smoothedGlucose.decimalValue.asMmolL
+                LineMark(
+                    x: .value("Time", item.date ?? Date(), unit: .second),
+                    y: .value("Value", smoothedGlucoseForDisplay),
+                    series: .value("Type", "Smoothed")
+                )
+                .foregroundStyle(Color.secondary)
+            }
         }
     }
 

+ 2 - 0
Trio/Sources/Modules/UserInterfaceSettings/UserInterfaceSettingsStateModel.swift

@@ -7,6 +7,7 @@ extension UserInterfaceSettings {
         @Published var xGridLines = false
         @Published var yGridLines: Bool = false
         @Published var rulerMarks: Bool = true
+        @Published var bolusDisplayThreshold: BolusDisplayThreshold = .allUnits
         @Published var forecastDisplayType: ForecastDisplayType = .cone
         @Published var showCarbsRequiredBadge: Bool = true
         @Published var carbsRequiredThreshold: Decimal = 0
@@ -23,6 +24,7 @@ extension UserInterfaceSettings {
             subscribeSetting(\.xGridLines, on: $xGridLines) { xGridLines = $0 }
             subscribeSetting(\.yGridLines, on: $yGridLines) { yGridLines = $0 }
             subscribeSetting(\.rulerMarks, on: $rulerMarks) { rulerMarks = $0 }
+            subscribeSetting(\.bolusDisplayThreshold, on: $bolusDisplayThreshold) { bolusDisplayThreshold = $0 }
 
             subscribeSetting(\.forecastDisplayType, on: $forecastDisplayType) { forecastDisplayType = $0 }
 

+ 42 - 0
Trio/Sources/Modules/UserInterfaceSettings/View/UserInterfaceSettingsRootView.swift

@@ -376,6 +376,48 @@ extension UserInterfaceSettings {
                     }.padding(.bottom)
                 }.listRowBackground(Color.chart)
 
+                Section {
+                    VStack {
+                        Picker(
+                            selection: $state.bolusDisplayThreshold,
+                            label: Text("Bolus Display Threshold")
+                        ) {
+                            ForEach(BolusDisplayThreshold.allCases) { selection in
+                                Text(selection.displayName).tag(selection)
+                            }
+                        }.padding(.top)
+
+                        HStack(alignment: .center) {
+                            Text(
+                                "Choose to hide small bolus amounts. See hint for more details."
+                            )
+                            .font(.footnote)
+                            .foregroundColor(.secondary)
+                            .lineLimit(nil)
+                            Spacer()
+                            Button(
+                                action: {
+                                    hintLabel = String(localized: "Bolus Display Threshold")
+                                    selectedVerboseHint =
+                                        AnyView(
+                                            VStack(alignment: .leading) {
+                                                Text(
+                                                    "This setting controls which bolus amount labels are shown on Trio’s main chart. Boluses appear as blue upside-down triangles, with a number showing the amount. Depending on the option you choose, only boluses at or above that amount will show a label. For example, if you choose ‘0.5 U and over’, only boluses of 0.5 U or more will show a label."
+                                                )
+                                            }
+                                        )
+                                    shouldDisplayHint.toggle()
+                                },
+                                label: {
+                                    HStack {
+                                        Image(systemName: "questionmark.circle")
+                                    }
+                                }
+                            ).buttonStyle(BorderlessButtonStyle())
+                        }.padding(.top)
+                    }.padding(.bottom)
+                }.listRowBackground(Color.chart)
+
                 Section(
                     header: Text("Trio Statistics"),
                     content: {

+ 274 - 0
Trio/Sources/Modules/WatchConfig/View/WatchConfigGarminAppConfigView.swift

@@ -0,0 +1,274 @@
+import SwiftUI
+
+struct WatchConfigGarminAppConfigView: View {
+    @ObservedObject var state: WatchConfig.StateModel
+
+    @State private var shouldDisplayHint1: Bool = false
+    @State private var shouldDisplayHint2: Bool = false
+    @State private var shouldDisplayHint3: Bool = false
+    @State private var shouldDisplayHint4: Bool = false
+    @State var hintDetent = PresentationDetent.large
+    @State private var shouldShowWatchfaceSwitchConfirmDialog: Bool = false
+
+    @Environment(\.colorScheme) var colorScheme
+    @Environment(AppState.self) var appState
+
+    var body: some View {
+        Form {
+            // MARK: - Watchface Selection Section
+
+            Section(
+                header: Text("Watchface Settings"),
+                content: {
+                    VStack {
+                        Picker(
+                            selection: $state.garminSettings.watchface,
+                            label: Text("Watchface Selection").multilineTextAlignment(.leading)
+                        ) {
+                            ForEach(GarminWatchface.allCases) { selection in
+                                Text(selection.displayName).tag(selection)
+                            }
+                        }
+                        .padding(.top)
+                        .onChange(of: state.garminSettings.watchface) { oldValue, newValue in
+                            if oldValue != newValue {
+                                state.handleWatchfaceChange()
+                                shouldShowWatchfaceSwitchConfirmDialog = true
+                            }
+                        }
+
+                        HStack(alignment: .center) {
+                            Text(
+                                "Choose which watchface to support."
+                            )
+                            .font(.footnote)
+                            .foregroundColor(.secondary)
+                            .lineLimit(nil)
+                            Spacer()
+                            Button(
+                                action: {
+                                    shouldDisplayHint1.toggle()
+                                },
+                                label: {
+                                    HStack {
+                                        Image(systemName: "questionmark.circle")
+                                    }
+                                }
+                            ).buttonStyle(BorderlessButtonStyle())
+                        }.padding(.top)
+                    }.padding(.bottom)
+
+                    VStack {
+                        // Inverted binding: "Disable" toggle controls "isEnabled" boolean
+                        // When toggle is ON → data transmission is DISABLED (isEnabled = false)
+                        // When toggle is OFF → data transmission is ENABLED (isEnabled = true)
+                        Toggle("Disable Watchface Data", isOn: Binding(
+                            get: { !state.garminSettings.isWatchfaceDataEnabled },
+                            set: { state.garminSettings.isWatchfaceDataEnabled = !$0 }
+                        ))
+
+                        HStack(alignment: .center) {
+                            Text(
+                                "Choose if you only want to use a datafield and no supported watchface!"
+                            )
+                            .font(.footnote)
+                            .foregroundColor(.secondary)
+                            .lineLimit(nil)
+                            Spacer()
+                            Button(
+                                action: {
+                                    shouldDisplayHint2.toggle()
+                                },
+                                label: {
+                                    HStack {
+                                        Image(systemName: "questionmark.circle")
+                                    }
+                                }
+                            ).buttonStyle(BorderlessButtonStyle())
+                        }.padding(.top)
+                    }.padding(.bottom)
+                }
+            ).listRowBackground(Color.chart)
+
+            // MARK: - Datafield Selection Section
+
+            Section(
+                header: Text("Datafield Settings"),
+                content: {
+                    VStack {
+                        Picker(
+                            selection: $state.garminSettings.datafield,
+                            label: Text("Datafield Selection").multilineTextAlignment(.leading)
+                        ) {
+                            ForEach(GarminDatafield.allCases) { selection in
+                                Text(selection.displayName).tag(selection)
+                            }
+                        }
+                        .padding(.top)
+
+                        HStack(alignment: .center) {
+                            Text(
+                                "Choose which datafield to support. Can be used independently of watchface selection."
+                            )
+                            .font(.footnote)
+                            .foregroundColor(.secondary)
+                            .lineLimit(nil)
+                            Spacer()
+                            Button(
+                                action: {
+                                    shouldDisplayHint4.toggle()
+                                },
+                                label: {
+                                    HStack {
+                                        Image(systemName: "questionmark.circle")
+                                    }
+                                }
+                            ).buttonStyle(BorderlessButtonStyle())
+                        }.padding(.top)
+                    }.padding(.bottom)
+                }
+            ).listRowBackground(Color.chart)
+
+            // MARK: - Data Field Selection Section
+
+            Section(
+                header: Text("Watch App Display Settings"),
+                content: {
+                    VStack {
+                        Picker(
+                            selection: $state.garminSettings.primaryAttributeChoice,
+                            label: Text("Data Choice 1").multilineTextAlignment(.leading)
+                        ) {
+                            ForEach(GarminPrimaryAttributeChoice.allCases) { selection in
+                                Text(selection.displayName).tag(selection)
+                            }
+                        }.padding(.top)
+                        HStack(alignment: .center) {
+                            Text(
+                                "Choose between displayed data types on Garmin device."
+                            )
+                            .font(.footnote)
+                            .foregroundColor(.secondary)
+                            .lineLimit(nil)
+                            Spacer()
+                            Button(
+                                action: {
+                                    shouldDisplayHint3.toggle()
+                                },
+                                label: {
+                                    HStack {
+                                        Image(systemName: "questionmark.circle")
+                                    }
+                                }
+                            ).buttonStyle(BorderlessButtonStyle())
+                        }.padding(.top)
+                    }.padding(.bottom)
+
+                    VStack {
+                        Picker(
+                            selection: $state.garminSettings.secondaryAttributeChoice,
+                            label: Text("Data Choice 2").multilineTextAlignment(.leading)
+                        ) {
+                            ForEach(GarminSecondaryAttributeChoice.allCases) { selection in
+                                Text(selection.displayName).tag(selection)
+                            }
+                        }.padding(.top)
+                        HStack(alignment: .center) {
+                            Text(
+                                "Choose between displayed data types on Garmin device."
+                            )
+                            .font(.footnote)
+                            .foregroundColor(.secondary)
+                            .lineLimit(nil)
+                            Spacer()
+                            Button(
+                                action: {
+                                    shouldDisplayHint3.toggle()
+                                },
+                                label: {
+                                    HStack {
+                                        Image(systemName: "questionmark.circle")
+                                    }
+                                }
+                            ).buttonStyle(BorderlessButtonStyle())
+                        }.padding(.top)
+                    }.padding(.bottom)
+                }
+            ).listRowBackground(Color.chart)
+        }
+        .listSectionSpacing(sectionSpacing)
+        .scrollContentBackground(.hidden)
+        .background(appState.trioBackgroundColor(for: colorScheme))
+
+        // MARK: - Help Sheets
+
+        .sheet(isPresented: $shouldDisplayHint1) {
+            SettingInputHintView(
+                hintDetent: $hintDetent,
+                shouldDisplayHint: $shouldDisplayHint1,
+                hintLabel: "Choose Garmin Watchface",
+                hintText: Text(
+                    "Choose which watchface on your Garmin device you wish to provide data for. You can independently select which datafield to use in the next section.\n\n" +
+                        "• Trio – The original Trio watchface, developed by Ivan Valkou.\n" +
+                        "• Swissalpine – Originally developed for AAPS, adapted to work with Trio.\n\n" +
+                        "You must use this configuration setting here BEFORE you switch the watchface on your Garmin device to another watchface.\n\n" +
+                        "⚠️ Changing the watchface will automatically disable data transmission. You will be prompted to resume data transmission after you have changed the watchface on your Garmin device."
+                ),
+                sheetTitle: String(localized: "Help", comment: "Help sheet title")
+            )
+        }
+        .sheet(isPresented: $shouldDisplayHint4) {
+            SettingInputHintView(
+                hintDetent: $hintDetent,
+                shouldDisplayHint: $shouldDisplayHint4,
+                hintLabel: "Choose Garmin Datafield",
+                hintText: Text(
+                    "Choose which datafield on your Garmin device you wish to provide data for. The datafield can be used independently from the watchface selection.\n\n" +
+                        "• Trio – The original Trio datafield, developed by Pierre.\n" +
+                        "• Swissalpine – Originally developed for AAPS, adapted to work with Trio.\n\n" +
+                        "Select 'None' if you don't want to use a datafield, or want to preserve battery while not exercising."
+                ),
+                sheetTitle: String(localized: "Help", comment: "Help sheet title")
+            )
+        }
+        .sheet(isPresented: $shouldDisplayHint2) {
+            SettingInputHintView(
+                hintDetent: $hintDetent,
+                shouldDisplayHint: $shouldDisplayHint2,
+                hintLabel: "Disable watchface data transmission",
+                hintText: Text(
+                    "Important: If you want to use a different watchface on your Garmin device that has no data requirement from this app, disable data transmission to the Garmin watchface app! Otherwise you will not be able to get current data once you re-enable the supported watchface that shows Trio data and you will have to re-install it on your Garmin device.\n\n" +
+                        "Note: When switching between supported watchfaces, data transmission is automatically disabled. You will be prompted to resume data transmission after you have changed the watchface on your Garmin device."
+                ),
+                sheetTitle: String(localized: "Help", comment: "Help sheet title")
+            )
+        }
+        .sheet(isPresented: $shouldDisplayHint3) {
+            SettingInputHintView(
+                hintDetent: $hintDetent,
+                shouldDisplayHint: $shouldDisplayHint3,
+                hintLabel: "Choose data support",
+                hintText: Text(
+                    "Choose which data types, along with Blood Glucose and IOB etc., you want to show on your Garmin device. That data type will be shown both on watchface and datafield.\n\n" +
+                        "Data Choice 1 options:\n" +
+                        "• COB – Carbs On Board\n" +
+                        "• ISF – Insulin Sensitivity Factor\n" +
+                        "• Sens Ratio – Sensitivity Ratio\n\n" +
+                        "Data Choice 2 options:\n" +
+                        "• Temp Basal Rate\n" +
+                        "• Eventual Glucose"
+                ),
+                sheetTitle: String(localized: "Help", comment: "Help sheet title")
+            )
+        }
+        .confirmationDialog("Watchface Changed", isPresented: $shouldShowWatchfaceSwitchConfirmDialog) {
+            Button("Resume Data Transmission") {
+                state.resumeDataTransmission()
+            }
+        } message: {
+            Text(
+                "Data transmission has been disabled. Now select the new watchface on your Garmin device and resume data transmission once done."
+            )
+        }
+    }
+}

+ 121 - 16
Trio/Sources/Modules/WatchConfig/View/WatchConfigGarminView.swift

@@ -1,37 +1,120 @@
+import ConnectIQ
 import SwiftUI
 
 struct WatchConfigGarminView: View {
     @ObservedObject var state: WatchConfig.StateModel
-
+    @State private var showDeviceList = false
     @State private var shouldDisplayHint: Bool = false
     @State var hintDetent = PresentationDetent.large
-    @State var selectedVerboseHint: AnyView?
-    @State var hintLabel: String?
-    @State private var decimalPlaceholder: Decimal = 0.0
-    @State private var booleanPlaceholder: Bool = false
 
     @Environment(\.colorScheme) var colorScheme
     @Environment(AppState.self) var appState
 
+    /// Handles deletion of devices from the device list
     private func onDelete(offsets: IndexSet) {
         state.devices.remove(atOffsets: offsets)
         state.deleteGarminDevice()
     }
 
+    #if targetEnvironment(simulator)
+        /// Adds a mock Garmin device for simulator UI testing
+        private func addMockDevice() {
+            let mockDevice = BaseGarminManager.MockIQDevice.createSimulated()
+            state.devices.append(mockDevice)
+            state.deleteGarminDevice()
+        }
+    #endif
+
     var body: some View {
+        Group {
+            if state.devices.isEmpty || showDeviceList {
+                // No devices connected OR user wants to see device list - show device list/add view
+                deviceListView
+            } else {
+                // Devices connected - go directly to configuration
+                WatchConfigGarminAppConfigView(state: state)
+                    .navigationTitle("Garmin App Settings")
+                    .navigationBarTitleDisplayMode(.automatic)
+                    .navigationBarBackButtonHidden(true)
+                    .toolbar {
+                        ToolbarItem(placement: .navigationBarLeading) {
+                            Button(action: {
+                                showDeviceList = true
+                            }) {
+                                HStack {
+                                    Image(systemName: "chevron.left")
+                                    Text("Garmin Devices")
+                                }
+                            }
+                        }
+                    }
+            }
+        }
+        .id(state.devices.count) // Force view refresh when device count changes
+        .onChange(of: state.devices.count) { _, newValue in
+            // If devices were deleted and now empty, ensure we show device list
+            if newValue == 0 {
+                showDeviceList = false
+            }
+        }
+    }
+
+    var deviceListView: some View {
         Form {
+            #if targetEnvironment(simulator)
+
+                // MARK: - Simulator Testing
+
+                Section(
+                    header: Text("Simulator Testing"),
+                    content: {
+                        VStack {
+                            if state.devices.isEmpty {
+                                Button {
+                                    // Add a mock device for UI testing
+                                    addMockDevice()
+                                } label: {
+                                    Text("Add Mock Garmin Watch")
+                                        .font(.title3)
+                                }
+                                .frame(maxWidth: .infinity, alignment: .center)
+                                .buttonStyle(.bordered)
+                            } else {
+                                Button {
+                                    state.devices.removeAll()
+                                    state.deleteGarminDevice()
+                                } label: {
+                                    Text("Remove All Devices")
+                                        .font(.title3)
+                                }
+                                .frame(maxWidth: .infinity, alignment: .center)
+                                .buttonStyle(.bordered)
+                                .tint(.red)
+                            }
+
+                            Text("Simulator only - for testing UI workflow")
+                                .font(.caption)
+                                .foregroundColor(.orange)
+                                .padding(.top, 5)
+                        }.padding(.vertical)
+                    }
+                ).listRowBackground(Color.orange.opacity(0.2))
+            #endif
+
+            // MARK: - Device Configuration Section
+
             Section(
                 header: Text("Garmin Configuration"),
-                content:
-                {
+                content: {
                     VStack {
                         Button {
                             state.selectGarminDevices()
                         } label: {
                             Text("Add Device")
-                                .font(.title3) }
-                            .frame(maxWidth: .infinity, alignment: .center)
-                            .buttonStyle(.bordered)
+                                .font(.title3)
+                        }
+                        .frame(maxWidth: .infinity, alignment: .center)
+                        .buttonStyle(.bordered)
 
                         HStack(alignment: .center) {
                             Text(
@@ -56,9 +139,11 @@ struct WatchConfigGarminView: View {
                 }
             ).listRowBackground(Color.chart)
 
+            // MARK: - Device List Section
+
             if !state.devices.isEmpty {
                 Section(
-                    header: Text("Garmin Watch"),
+                    header: Text("Connected Devices"),
                     content: {
                         List {
                             ForEach(state.devices, id: \.uuid) { device in
@@ -68,23 +153,43 @@ struct WatchConfigGarminView: View {
                         }
                     }
                 ).listRowBackground(Color.chart)
+
+                // MARK: - App Settings Navigation Section
+
+                Section(
+                    header: Text("Device App Settings"),
+                    content: {
+                        Button(action: {
+                            showDeviceList = false
+                        }) {
+                            HStack {
+                                Text("Configure Device Apps")
+                                Spacer()
+                                Image(systemName: "chevron.right")
+                                    .font(.caption)
+                                    .foregroundColor(.secondary)
+                            }
+                        }
+                        .foregroundColor(.primary)
+                    }
+                ).listRowBackground(Color.chart)
             }
         }
         .listSectionSpacing(sectionSpacing)
+        .navigationTitle("Garmin Devices")
+        .navigationBarTitleDisplayMode(.automatic)
+        .scrollContentBackground(.hidden)
+        .background(appState.trioBackgroundColor(for: colorScheme))
         .sheet(isPresented: $shouldDisplayHint) {
             SettingInputHintView(
                 hintDetent: $hintDetent,
                 shouldDisplayHint: $shouldDisplayHint,
                 hintLabel: "Add Device",
                 hintText: Text(
-                    "Add Garmin Device to Trio. Please look at the docs to see which devices are supported."
+                    "Add Garmin Device to Trio. This happens via Garmin Connect. If you have multiple phones with Garmin Connect and the same Garmin device, you will run into connectivity issue between watch and phone depending of proximity of the phones, which might also affect your watchface function."
                 ),
                 sheetTitle: String(localized: "Help", comment: "Help sheet title")
             )
         }
-        .navigationTitle("Garmin")
-        .navigationBarTitleDisplayMode(.automatic)
-        .scrollContentBackground(.hidden)
-        .background(appState.trioBackgroundColor(for: colorScheme))
     }
 }

+ 19 - 1
Trio/Sources/Modules/WatchConfig/WatchConfigStateModel.swift

@@ -1,3 +1,4 @@
+import Combine
 import ConnectIQ
 import SwiftUI
 
@@ -9,18 +10,23 @@ extension WatchConfig {
         @Published var devices: [IQDevice] = []
         @Published var confirmBolusFaster = false
 
+        /// Garmin watch settings containing all watch-related configuration
+        @Published var garminSettings = GarminWatchSettings()
+
         private(set) var preferences = Preferences()
 
         override func subscribe() {
             preferences = provider.preferences
-
             units = settingsManager.settings.units
 
+            // Subscribe to the entire garminSettings struct from TrioSettings
+            subscribeSetting(\.garminSettings, on: $garminSettings) { garminSettings = $0 }
             subscribeSetting(\.confirmBolusFaster, on: $confirmBolusFaster) { confirmBolusFaster = $0 }
 
             devices = garmin.devices
         }
 
+        /// Prompts the user to select Garmin devices and updates the device list
         func selectGarminDevices() {
             garmin.selectDevices()
                 .receive(on: DispatchQueue.main)
@@ -28,9 +34,21 @@ extension WatchConfig {
                 .store(in: &lifetime)
         }
 
+        /// Updates the Garmin manager with the current device list
         func deleteGarminDevice() {
             garmin.updateDeviceList(devices)
         }
+
+        /// Handles watchface selection changes by automatically disabling data transmission
+        /// to allow the user to switch watchfaces on their Garmin device without data conflicts
+        func handleWatchfaceChange() {
+            garminSettings.isWatchfaceDataEnabled = false
+        }
+
+        /// Resumes data transmission after user confirms they have switched watchface on their device
+        func resumeDataTransmission() {
+            garminSettings.isWatchfaceDataEnabled = true
+        }
     }
 }
 

+ 112 - 13
Trio/Sources/Services/UserNotifications/UserNotificationsManager.swift

@@ -11,6 +11,7 @@ import UserNotifications
 protocol UserNotificationsManager {
     func getNotificationSettings(completionHandler: @escaping (UNNotificationSettings) -> Void)
     func requestNotificationPermissions(completion: @escaping (Bool) -> Void)
+    @MainActor func applySnooze(for duration: TimeInterval) async
 }
 
 enum GlucoseSourceKey: String {
@@ -40,6 +41,12 @@ protocol pumpNotificationObserver {
     func pumpRemoveNotification()
 }
 
+// MARK: - SnoozeObserver Protocol
+
+protocol SnoozeObserver {
+    @MainActor func snoozeDidChange(_ untilDate: Date)
+}
+
 final class BaseUserNotificationsManager: NSObject, UserNotificationsManager, Injectable {
     enum Identifier: String {
         case glucoseNotification = "Trio.glucoseNotification"
@@ -61,6 +68,9 @@ final class BaseUserNotificationsManager: NSObject, UserNotificationsManager, In
     @Injected(as: FetchGlucoseManager.self) private var sourceInfoProvider: SourceInfoProvider!
 
     @Persisted(key: "UserNotificationsManager.snoozeUntilDate") private var snoozeUntilDate: Date = .distantPast
+    // The glucose notification observers below (Core Data saves and the storage publisher) can fire for the same
+    // reading, so we persist the last alert token to avoid enqueueing identical high/low notifications multiple times.
+    @Persisted(key: "UserNotificationsManager.lastGlucoseAlertToken") private var lastGlucoseAlertToken: String = ""
 
     private let notificationCenter = UNUserNotificationCenter.current()
     private var lifetime = Lifetime()
@@ -95,11 +105,28 @@ final class BaseUserNotificationsManager: NSObject, UserNotificationsManager, In
         Task {
             await sendGlucoseNotification()
         }
+        configureNotificationCategories()
         registerHandlers()
         registerSubscribers()
         subscribeOnLoop()
     }
 
+    private func configureNotificationCategories() {
+        notificationCenter.getNotificationCategories { [weak self] existingCategories in
+            guard let self else { return }
+
+            let glucoseCategory = NotificationCategoryFactory.createGlucoseCategory()
+
+            var categories = existingCategories
+            categories.update(with: glucoseCategory)
+            // UNUserNotificationCenter methods should be called on main thread
+            Task { @MainActor [weak self] in
+                guard let self else { return }
+                self.notificationCenter.setNotificationCategories(categories)
+            }
+        }
+    }
+
     private func subscribeOnLoop() {
         apsManager.lastLoopDateSubject
             .sink { [weak self] date in
@@ -271,6 +298,10 @@ final class BaseUserNotificationsManager: NSObject, UserNotificationsManager, In
                 try viewContext.existingObject(with: id) as? GlucoseStored
             }
 
+            if glucoseStorage.alarm == .none {
+                lastGlucoseAlertToken = ""
+            }
+
             guard let lastReading = glucoseObjects.first?.glucose,
                   let secondLastReading = glucoseObjects.dropFirst().first?.glucose,
                   let lastDirection = glucoseObjects.first?.directionEnum?.symbol else { return }
@@ -305,6 +336,15 @@ final class BaseUserNotificationsManager: NSObject, UserNotificationsManager, In
                 titles.append(String(localized: "(Snoozed)", comment: "(Snoozed)"))
                 notificationAlarm = false
             } else {
+                let token = alertToken(from: glucoseObjects.first)
+
+                if token == "unknown" {
+                    warning(.service, "Missing glucose token fields; skipping notification to avoid re-alerting")
+                    return
+                }
+                if notificationAlarm, token == lastGlucoseAlertToken {
+                    return
+                }
                 titles.append(body)
                 let content = UNMutableNotificationContent()
                 content.title = titles.joined(separator: " ")
@@ -313,6 +353,7 @@ final class BaseUserNotificationsManager: NSObject, UserNotificationsManager, In
                 if notificationAlarm {
                     content.sound = .default
                     content.userInfo[NotificationAction.key] = NotificationAction.snooze.rawValue
+                    content.categoryIdentifier = NotificationCategoryIdentifier.trioAlert.rawValue
                 }
 
                 addRequest(
@@ -323,6 +364,9 @@ final class BaseUserNotificationsManager: NSObject, UserNotificationsManager, In
                     messageSubtype: .glucose,
                     action: NotificationAction.snooze
                 )
+                if notificationAlarm {
+                    lastGlucoseAlertToken = token
+                }
             }
         } catch {
             debugPrint(
@@ -331,6 +375,23 @@ final class BaseUserNotificationsManager: NSObject, UserNotificationsManager, In
         }
     }
 
+    private func alertToken(from glucose: GlucoseStored?) -> String {
+        if let id = glucose?.id?.uuidString { return id }
+
+        if let date = glucose?.date {
+            let roundedMinute = Int((date.timeIntervalSince1970 / 60).rounded())
+            return "date-\(roundedMinute)"
+        }
+
+        // Stable fallback for Core Data objects:
+        if let glucose, !glucose.objectID.isTemporaryID {
+            return "objectID-\(glucose.objectID.uriRepresentation().absoluteString)"
+        }
+
+        // Stable “unknown” fallback: prevents repeated alarms when identifiers are missing
+        return "unknown"
+    }
+
     private func glucoseText(glucoseValue: Int, delta: Int?, direction: String?) -> String {
         let units = settingsManager.settings.units
         let glucoseText = glucoseFormatter
@@ -409,6 +470,19 @@ final class BaseUserNotificationsManager: NSObject, UserNotificationsManager, In
         }
     }
 
+    @MainActor func applySnooze(for duration: TimeInterval) async {
+        let untilDate = duration > 0 ? Date().addingTimeInterval(duration) : .distantPast
+        snoozeUntilDate = untilDate
+        lastGlucoseAlertToken = ""
+        // removeGlucoseNotifications() is safe to call here since we're @MainActor
+        removeGlucoseNotifications()
+
+        // Notify observers that snooze was applied
+        broadcaster.notify(SnoozeObserver.self, on: .main) { (observer: SnoozeObserver) in
+            observer.snoozeDidChange(untilDate)
+        }
+    }
+
     private func addRequest(
         identifier: Identifier,
         content: UNMutableNotificationContent,
@@ -571,6 +645,14 @@ extension BaseUserNotificationsManager: pumpNotificationObserver {
             self.notificationCenter.removePendingNotificationRequests(withIdentifiers: [identifier.rawValue])
         }
     }
+
+    /// Removes all glucose notifications (delivered and pending).
+    /// Must be called from the main thread. Safe to call from @MainActor contexts.
+    @MainActor private func removeGlucoseNotifications() {
+        let identifier = Identifier.glucoseNotification.rawValue
+        notificationCenter.removeDeliveredNotifications(withIdentifiers: [identifier])
+        notificationCenter.removePendingNotificationRequests(withIdentifiers: [identifier])
+    }
 }
 
 extension BaseUserNotificationsManager: DeterminationObserver {
@@ -595,29 +677,46 @@ extension BaseUserNotificationsManager: UNUserNotificationCenterDelegate {
         completionHandler([.banner, .badge, .sound, .list])
     }
 
+    /// UNUserNotificationCenterDelegate method called when user interacts with a notification.
+    /// This can be called off the main thread, so we ensure all work happens on @MainActor.
     func userNotificationCenter(
         _: UNUserNotificationCenter,
         didReceive response: UNNotificationResponse,
         withCompletionHandler completionHandler: @escaping () -> Void
     ) {
         defer { completionHandler() }
+
+        // Handle quick snooze actions (from notification action buttons)
+        if let quickAction = NotificationResponseAction(rawValue: response.actionIdentifier) {
+            Task { @MainActor [weak self] in
+                guard let self else { return }
+                await self.applySnooze(for: quickAction.duration)
+            }
+            return
+        }
+
+        // Handle other notification actions (e.g., tapping notification body)
         guard let actionRaw = response.notification.request.content.userInfo[NotificationAction.key] as? String,
               let action = NotificationAction(rawValue: actionRaw)
         else { return }
 
-        switch action {
-        case .snooze:
-            router.mainModalScreen.send(.snooze)
-        case .pumpConfig:
-            let messageCont = MessageContent(
-                content: response.notification.request.content.body,
-                type: MessageType.other,
-                subtype: .pump,
-                useAPN: false,
-                action: .pumpConfig
-            )
-            router.alertMessage.send(messageCont)
-        default: break
+        // Ensure UI operations happen on main thread using Task for consistency
+        Task { @MainActor [weak self] in
+            guard let self = self else { return }
+            switch action {
+            case .snooze:
+                self.router.mainModalScreen.send(.snooze)
+            case .pumpConfig:
+                let messageCont = MessageContent(
+                    content: response.notification.request.content.body,
+                    type: MessageType.other,
+                    subtype: .pump,
+                    useAPN: false,
+                    action: .pumpConfig
+                )
+                self.router.alertMessage.send(messageCont)
+            default: break
+            }
         }
     }
 }

+ 31 - 16
Trio/Sources/Services/WatchManager/AppleWatchManager.swift

@@ -25,6 +25,7 @@ final class BaseWatchManager: NSObject, WCSessionDelegate, Injectable, WatchMana
     @Injected() private var tempTargetStorage: TempTargetsStorage!
     @Injected() private var bolusCalculationManager: BolusCalculationManager!
     @Injected() private var iobService: IOBService!
+    @Injected() private var notificationsManager: UserNotificationsManager!
 
     private var units: GlucoseUnits = .mgdL
     private var glucoseColorScheme: GlucoseColorScheme = .staticColor
@@ -553,16 +554,18 @@ final class BaseWatchManager: NSObject, WCSessionDelegate, Injectable, WatchMana
     }
 
     func session(_: WCSession, didReceiveMessage message: [String: Any]) {
-        DispatchQueue.main.async { [weak self] in
-            if let logs = message["watchLogs"] as? String {
-                SimpleLogReporter.appendToWatchLog(logs)
-            }
+        // Handle logs first - doesn't need self, so it can run even during teardown
+        if let logs = message["watchLogs"] as? String {
+            SimpleLogReporter.appendToWatchLog(logs)
+        }
+
+        Task { @MainActor [weak self] in
+            guard let self else { return }
 
             if let requestWatchUpdate = message[WatchMessageKeys.requestWatchUpdate] as? String,
                requestWatchUpdate == WatchMessageKeys.watchState
             {
                 debug(.watchManager, "📱 Watch requested watch state data update.")
-                guard let self = self else { return }
                 // Skip if no watch is paired or app not installed
                 guard let session = self.session, session.isPaired, session.isReachable,
                       session.isWatchAppInstalled else { return }
@@ -573,19 +576,23 @@ final class BaseWatchManager: NSObject, WCSessionDelegate, Injectable, WatchMana
                 return
             }
 
-            if let bolusAmount = message[WatchMessageKeys.bolus] as? Double,
-               message[WatchMessageKeys.carbs] == nil,
-               message[WatchMessageKeys.date] == nil
+            if let snoozeMinutes = message[WatchMessageKeys.snoozeDuration] as? Int {
+                debug(.watchManager, "📱 Received snooze request from watch: \(snoozeMinutes) minutes")
+                await self.notificationsManager.applySnooze(for: TimeInterval(snoozeMinutes * 60))
+                return
+            } else if let bolusAmount = message[WatchMessageKeys.bolus] as? Double,
+                      message[WatchMessageKeys.carbs] == nil,
+                      message[WatchMessageKeys.date] == nil
             {
                 debug(.watchManager, "📱 Received bolus request from watch: \(bolusAmount)U")
-                self?.handleBolusRequest(Decimal(bolusAmount))
+                self.handleBolusRequest(Decimal(bolusAmount))
             } else if let carbsAmount = message[WatchMessageKeys.carbs] as? Int,
                       let timestamp = message[WatchMessageKeys.date] as? TimeInterval,
                       message[WatchMessageKeys.bolus] == nil
             {
                 let date = Date(timeIntervalSince1970: timestamp)
                 debug(.watchManager, "📱 Received carbs request from watch: \(carbsAmount)g at \(date)")
-                self?.handleCarbsRequest(carbsAmount, date)
+                self.handleCarbsRequest(carbsAmount, date)
             } else if let bolusAmount = message[WatchMessageKeys.bolus] as? Double,
                       let carbsAmount = message[WatchMessageKeys.carbs] as? Int,
                       let timestamp = message[WatchMessageKeys.date] as? TimeInterval
@@ -595,11 +602,11 @@ final class BaseWatchManager: NSObject, WCSessionDelegate, Injectable, WatchMana
                     .watchManager,
                     "📱 Received meal bolus combo request from watch: \(bolusAmount)U, \(carbsAmount)g at \(date)"
                 )
-                self?.handleCombinedRequest(bolusAmount: Decimal(bolusAmount), carbsAmount: Decimal(carbsAmount), date: date)
+                self.handleCombinedRequest(bolusAmount: Decimal(bolusAmount), carbsAmount: Decimal(carbsAmount), date: date)
             } else {
                 debug(.watchManager, "📱 Invalid or incomplete data received from watch. Received:  \(message)")
                 // Acknowledge failure
-                self?.sendAcknowledgment(
+                self.sendAcknowledgment(
                     toWatch: false,
                     message: "Error! Invalid or incomplete data received from watch.",
                     ackCode: .genericFailure
@@ -608,22 +615,22 @@ final class BaseWatchManager: NSObject, WCSessionDelegate, Injectable, WatchMana
 
             if message[WatchMessageKeys.cancelOverride] as? Bool == true {
                 debug(.watchManager, "📱 Received cancel override request from watch")
-                self?.handleCancelOverride()
+                self.handleCancelOverride()
             }
 
             if let presetName = message[WatchMessageKeys.activateOverride] as? String {
                 debug(.watchManager, "📱 Received activate override request from watch for preset: \(presetName)")
-                self?.handleActivateOverride(presetName)
+                self.handleActivateOverride(presetName)
             }
 
             if let presetName = message[WatchMessageKeys.activateTempTarget] as? String {
                 debug(.watchManager, "📱 Received activate temp target request from watch for preset: \(presetName)")
-                self?.handleActivateTempTarget(presetName)
+                self.handleActivateTempTarget(presetName)
             }
 
             if message[WatchMessageKeys.cancelTempTarget] as? Bool == true {
                 debug(.watchManager, "📱 Received cancel temp target request from watch")
-                self?.handleCancelTempTarget()
+                self.handleCancelTempTarget()
             }
 
             if message[WatchMessageKeys.requestBolusRecommendation] as? Bool == true {
@@ -684,6 +691,14 @@ final class BaseWatchManager: NSObject, WCSessionDelegate, Injectable, WatchMana
         if let logs = userInfo["watchLogs"] as? String {
             SimpleLogReporter.appendToWatchLog(logs)
         }
+
+        if let snoozeMinutes = userInfo[WatchMessageKeys.snoozeDuration] as? Int {
+            debug(.watchManager, "📱 Received snooze userInfo from watch: \(snoozeMinutes) minutes")
+            Task { @MainActor [weak self] in
+                guard let self else { return }
+                await self.notificationsManager.applySnooze(for: TimeInterval(snoozeMinutes * 60))
+            }
+        }
     }
 
     #if os(iOS)

+ 204 - 0
Trio/Sources/Services/WatchManager/FLOW_DIAGRAM.md

@@ -0,0 +1,204 @@
+# Garmin Update Flow - Visual Diagram
+
+## New Simplified Architecture
+
+```
+┌─────────────────────────────────────────────────────────────────┐
+│                        Loop Cycle Completes                      │
+└───────────────────┬─────────────────────────────────────────────┘
+                    │
+                    ├─────────────────────────────┐
+                    │                             │
+                    ↓                             ↓
+        ┌──────────────────────┐      ┌──────────────────────┐
+        │  Determination       │      │  IOB Update          │
+        │  CoreData Change     │      │  iobPublisher        │
+        └──────────┬───────────┘      └──────────┬───────────┘
+                   │                             │
+                   │  .send(data)                │  .send(data)
+                   ↓                             ↓
+        ┌──────────────────────────────────────────────────────┐
+        │         determinationSubject                          │
+        │         (PassthroughSubject<Data, Never>)            │
+        └──────────────────┬───────────────────────────────────┘
+                           │
+                           │  .throttle(for: .seconds(20),
+                           │            latest: false)
+                           ↓
+        ┌──────────────────────────────────────────────────────┐
+        │              Combine Throttle Logic                   │
+        │   .throttle(for: .seconds(20), latest: false)        │
+        │                                                       │
+        │  ┌────────────────────────────────────┐             │
+        │  │ Event 1 (t=0s)    → HOLD 📦        │             │
+        │  │   [Start 20s timer]                │             │
+        │  │ Event 2 (t=0.5s)  → DROP ❌        │             │
+        │  │ Event 3 (t=1s)    → DROP ❌        │             │
+        │  │ Event 4 (t=5s)    → DROP ❌        │             │
+        │  │ [t=20s: Timer fires]               │             │
+        │  │   → SEND Event 1 ✅                │             │
+        │  │                                    │             │
+        │  │ Event 5 (t=20.1s) → HOLD 📦        │             │
+        │  │   [Start new 20s timer]            │             │
+        │  │ Event 6 (t=23s)   → DROP ❌        │             │
+        │  │ [t=40.1s: Timer fires]             │             │
+        │  │   → SEND Event 5 ✅                │             │
+        │  └────────────────────────────────────┘             │
+        │                                                       │
+        │  Pattern: HOLD first → DROP rest → SEND after 20s  │
+        └──────────────────┬───────────────────────────────────┘
+                           │
+                           ↓
+        ┌──────────────────────────────────────────────────────┐
+        │         subscribeToDeterminationThrottle()            │
+        │                                                       │
+        │  • Check if recent watchface change (<25s)           │
+        │    - If yes: Don't cache (might be old format) ⚠️    │
+        │    - If no: Cache data ✅                            │
+        │  • Convert Data → JSON                               │
+        │  • Set lastImmediateSendTime                         │
+        │  • Log: "Sending determination/IOB" (if enabled)     │
+        └──────────────────┬───────────────────────────────────┘
+                           │
+                           ↓
+        ┌──────────────────────────────────────────────────────┐
+        │         broadcastStateToWatchApps()                   │
+        │                                                       │
+        │  ├─> Watchface App (5A643C13...)                     │
+        │  └─> Data Field App (71CF0982...)                    │
+        └───────────────────────────────────────────────────────┘
+```
+
+## Other Update Sources (Unchanged)
+
+```
+┌─────────────────────────────────────────────────────────────────┐
+│                    Glucose Update (Stale Loop)                   │
+│                    (Loop age > 8 minutes)                        │
+└───────────────────┬─────────────────────────────────────────────┘
+                    │
+                    │  Immediate send - no throttle
+                    ↓
+        ┌──────────────────────────────────────────────────────┐
+        │         sendWatchStateDataImmediately()               │
+        │                                                       │
+        │  • Convert Data → JSON                               │
+        │  • Set lastImmediateSendTime                         │
+        │  • broadcastStateToWatchApps()                       │
+        └───────────────────────────────────────────────────────┘
+
+
+┌─────────────────────────────────────────────────────────────────┐
+│              Status Request / Settings Changes                   │
+└───────────────────┬─────────────────────────────────────────────┘
+                    │
+                    │  30s throttle
+                    ↓
+        ┌──────────────────────────────────────────────────────┐
+        │         sendWatchStateDataWith30sThrottle()           │
+        │                                                       │
+        │  • Store pending data                                │
+        │  • Start/update 30s timer                            │
+        │  • Check lastImmediateSendTime before firing         │
+        │  • broadcastStateToWatchApps() after 30s             │
+        └───────────────────────────────────────────────────────┘
+```
+
+## Comparison: Old vs New
+
+### Old Architecture (Complex)
+```
+Determination ──> sendWatchStateDataImmediately() ──> Watch
+                      │
+                      └─> Set lastImmediateSendTime
+                      
+IOB ──> sendWatchStateDataWith30sThrottle() ──> Watch
+         │
+         └─> Check lastImmediateSendTime? ❌ Race condition!
+         └─> Start 30s timer
+         └─> Cancel if determination fired? ⚠️ Complex!
+```
+
+### New Architecture (Simple)
+```
+Determination ──┐
+                ├──> determinationSubject ──> .throttle(10s) ──> Watch
+IOB ───────────┘
+```
+
+## Timeline Example
+
+```
+Time    Event                          Action
+──────────────────────────────────────────────────────────────────
+0:00    Loop completes                 
+        ├─ Determination fires ─┐
+        └─ IOB fires ───────────┴──> determinationSubject.send()
+                                                │
+0:00                                   Throttle: SEND ✅
+                                       Log: "Sending determination/IOB"
+                                                │
+0:00-10s Multiple loop cycles         Throttle: DROP ALL ❌
+        (rapid determinations/IOB)              │
+                                                │
+10:01   Next loop completes            Throttle: SEND ✅
+        ├─ Determination fires ─┐      Log: "Sending determination/IOB"
+        └─ IOB fires ───────────┘
+                                                │
+15:00   Status request arrives         30s timer starts
+                                       (separate pipeline)
+                                                │
+20:01   Loop completes                 Throttle: SEND ✅
+        ├─ Determination fires ─┐      (30s timer cancelled - recent send)
+        └─ IOB fires ───────────┘
+```
+
+## Key Architectural Decisions
+
+### Why Combine Throttle Instead of Manual Timer?
+
+**Combine throttle:**
+✅ Built-in deduplication
+✅ Thread-safe by design
+✅ Predictable scheduler behavior
+✅ Less code to maintain
+✅ No race conditions
+
+**Manual timer:**
+❌ Complex lifecycle management
+❌ Race conditions between publishers
+❌ More code to test
+❌ Threading concerns
+❌ Easy to introduce bugs
+
+### Why 10 Seconds?
+
+1. **Loop cycle timing:** Typical loop = 5 minutes
+2. **Multiple events = same cycle:** Events within 10s are from same loop
+3. **Responsiveness:** 10s is imperceptible to users
+4. **Battery efficiency:** Reduces watch transmissions by ~80%
+
+### Why `latest: false`?
+
+| Setting | Behavior | Result |
+|---------|----------|--------|
+| `latest: false` | Keep **first** event, drop rest | Send immediately when loop completes ✅ |
+| `latest: true` | Drop events, send **last** one after throttle | 10 second delay every time ❌ |
+
+We want immediate response when data arrives, not delayed response.
+
+## Code Metrics
+
+### Lines of Code
+- **Old approach:** ~150 lines of throttling logic
+- **New approach:** ~60 lines of throttling logic
+- **Reduction:** 60% less code
+
+### Complexity
+- **Old approach:** 3 throttle mechanisms (immediate, 10s manual, 30s manual)
+- **New approach:** 2 throttle mechanisms (10s Combine, 30s manual)
+- **Timer objects:** Reduced from 2 to 1
+
+### Edge Cases Handled
+- **Old approach:** ~8 edge cases (race conditions, timer coordination, etc.)
+- **New approach:** ~3 edge cases (all handled by Combine)

Разница между файлами не показана из-за своего большого размера
+ 728 - 284
Trio/Sources/Services/WatchManager/GarminManager.swift


+ 0 - 2
Trio/Sources/Views/SettingInputSection.swift

@@ -99,8 +99,6 @@ struct SettingInputSection<VerboseHint: View>: View {
             return pickerSettingsProvider.settings.individualAdjustmentFactor
         case "delay":
             return pickerSettingsProvider.settings.delay
-        case "timeCap":
-            return pickerSettingsProvider.settings.timeCap
         case "minuteInterval":
             return pickerSettingsProvider.settings.minuteInterval
         case "high":

+ 234 - 0
TrioTests/CoreDataTests/CarbsStorageTests.swift

@@ -123,6 +123,240 @@ import Testing
         #expect(remainingEntries?.isEmpty == true, "Should have no entries after deletion")
     }
 
+    @Test(
+        "Store carb entry with fat/protein creates capped, spaced FPU entries (defaults: adjustment=0.5, delay=60m)"
+    ) func testStoreFatProteinCarbEntryCreatesFPUEntries() async throws {
+        let fpuID = UUID().uuidString
+        let baseDate = Date(timeIntervalSince1970: 1_700_000_000)
+
+        // Defaults:
+        // adjustment = 0.5, delay = 60
+        //
+        // fat=50g -> 450 kcal
+        // protein=100g -> 400 kcal
+        // kcal total = 850
+        // (kcal/10) = 85
+        // 85 * 0.5 = 42.5
+        // Int(42.5) = 42 equivalents -> two FPU entries: 21g each
+        let mealEntry = CarbsEntry(
+            id: UUID().uuidString,
+            createdAt: baseDate,
+            actualDate: baseDate,
+            carbs: 30,
+            fat: 50,
+            protein: 100,
+            note: "FPU deterministic default split test",
+            enteredBy: "Test",
+            isFPU: false,
+            fpuID: fpuID
+        )
+
+        try await storage.storeCarbs([mealEntry], areFetchedFromRemote: false)
+
+        let storedEntries = try await coreDataStack.fetchEntitiesAsync(
+            ofType: CarbEntryStored.self,
+            onContext: testContext,
+            predicate: NSPredicate(format: "fpuID == %@", fpuID),
+            key: "date",
+            ascending: true
+        ) as? [CarbEntryStored]
+
+        guard let storedEntries else {
+            throw TestError("Failed to fetch entries for fpuID")
+        }
+
+        #expect(!storedEntries.isEmpty, "Should have stored entries")
+
+        let originalCarbEntry = storedEntries.first(where: { $0.isFPU == false })
+        #expect(originalCarbEntry != nil, "Should have one non-FPU original entry")
+        #expect(originalCarbEntry?.carbs == 30, "Original carbs should match")
+        #expect(originalCarbEntry?.fat == 50, "Original fat should match")
+        #expect(originalCarbEntry?.protein == 100, "Original protein should match")
+
+        let fpuEntries = storedEntries.filter { $0.isFPU == true }
+        #expect(fpuEntries.count == 2, "Expected exactly one FPU entry under default settings")
+        #expect(Int(fpuEntries[0].carbs) == 21, "Expected 20g carb equivalents under default settings")
+
+        for fpuEntry in fpuEntries {
+            #expect(fpuEntry.fat == 0, "FPU fat must be 0")
+            #expect(fpuEntry.protein == 0, "FPU protein must be 0")
+            #expect(fpuEntry.carbs >= 10, "FPU carbs must be >= 10g")
+            #expect(fpuEntry.carbs <= 33, "FPU carbs must be <= 33g")
+            #expect(Double(fpuEntry.carbs).truncatingRemainder(dividingBy: 1) == 0, "FPU carbs must be whole grams")
+        }
+
+        let scheduledTotal = fpuEntries.reduce(0) { partialResult, fpuEntry in
+            partialResult + Int(fpuEntry.carbs)
+        }
+        #expect(scheduledTotal <= 99, "Scheduled FPU carbs must be capped at 99g")
+
+        // Timing: stable assertions
+        // - first FPU entry must be at least +60m after the *input* timestamp (createdAt/actualDate),
+        //   but storage may choose a different internal baseDate, so don't assert exact equality.
+        let fpuDates = fpuEntries.compactMap(\.date).sorted()
+        #expect(fpuDates.count == 2, "FPU entry should have a date")
+
+        let firstFpuDate = fpuDates[0]
+        #expect(
+            firstFpuDate >= baseDate.addingTimeInterval(60 * 60),
+            "First FPU entry should not be scheduled earlier than +60 minutes after the input timestamp"
+        )
+
+        #expect(
+            storedEntries.allSatisfy { $0.fpuID?.uuidString == fpuID },
+            "All entries should share the same fpuID"
+        )
+    }
+
+    @Test(
+        "Store very large fat/protein meal caps FPU equivalents at 99g and splits into 3×33g (defaults: adjustment=0.5, delay=60m)"
+    ) func testStoreVeryLargeFatProteinMealCapsAndSplits() async throws {
+        let fpuID = UUID().uuidString
+        let baseDate = Date(timeIntervalSince1970: 1_700_001_000)
+
+        // Defaults:
+        // adjustment = 0.5, delay = 60
+        //
+        // fat=200g -> 1800 kcal
+        // protein=200g -> 800 kcal
+        // kcal total = 2600
+        // (kcal/10) = 260
+        // 260 * 0.5 = 130
+        // Int(130) = 130 -> capped to 99 -> split into [33, 33, 33]
+        let heftyMealEntry = CarbsEntry(
+            id: UUID().uuidString,
+            createdAt: baseDate,
+            actualDate: baseDate,
+            carbs: 30,
+            fat: 200,
+            protein: 200,
+            note: "Hefty BBQ meal - cap test",
+            enteredBy: "Test",
+            isFPU: false,
+            fpuID: fpuID
+        )
+
+        try await storage.storeCarbs([heftyMealEntry], areFetchedFromRemote: false)
+
+        let storedEntries = try await coreDataStack.fetchEntitiesAsync(
+            ofType: CarbEntryStored.self,
+            onContext: testContext,
+            predicate: NSPredicate(format: "fpuID == %@", fpuID),
+            key: "date",
+            ascending: true
+        ) as? [CarbEntryStored]
+
+        guard let storedEntries else {
+            throw TestError("Failed to fetch entries for fpuID")
+        }
+
+        #expect(!storedEntries.isEmpty, "Should have stored entries")
+
+        let originalCarbEntry = storedEntries.first(where: { $0.isFPU == false })
+        #expect(originalCarbEntry != nil, "Should have one non-FPU original entry")
+        #expect(originalCarbEntry?.carbs == 30, "Original carbs should match")
+        #expect(originalCarbEntry?.fat == 200, "Original fat should match")
+        #expect(originalCarbEntry?.protein == 200, "Original protein should match")
+
+        let fpuEntries = storedEntries.filter { $0.isFPU == true }
+        #expect(fpuEntries.count == 3, "Capped large meal should create exactly 3 FPU entries")
+
+        let fpuGrams = fpuEntries.map { Int($0.carbs) }
+        #expect(fpuGrams == [33, 33, 33], "Expected capped split to be [33, 33, 33]")
+
+        let scheduledTotal = fpuEntries.reduce(0) { partialResult, fpuEntry in
+            partialResult + Int(fpuEntry.carbs)
+        }
+        #expect(scheduledTotal == 99, "Total scheduled FPU grams should be exactly 99g after cap")
+
+        for fpuEntry in fpuEntries {
+            #expect(fpuEntry.fat == 0, "FPU entry fat must be 0")
+            #expect(fpuEntry.protein == 0, "FPU entry protein must be 0")
+            #expect(fpuEntry.carbs >= 10, "FPU entry carbs must be >= 10g")
+            #expect(fpuEntry.carbs <= 33, "FPU entry carbs must be <= 33g")
+            #expect(Double(fpuEntry.carbs).truncatingRemainder(dividingBy: 1) == 0, "FPU carbs must be whole grams")
+        }
+
+        // Timing: stable assertions
+        let fpuDates = fpuEntries.compactMap(\.date).sorted()
+        #expect(fpuDates.count == 3, "All FPU entries should have a date")
+
+        let firstFpuDate = fpuDates[0]
+        #expect(
+            firstFpuDate >= baseDate.addingTimeInterval(60 * 60),
+            "First FPU entry should not be scheduled earlier than +60 minutes after the input timestamp"
+        )
+
+        for index in 1 ..< fpuDates.count {
+            let spacingSeconds = fpuDates[index].timeIntervalSince(fpuDates[index - 1])
+            #expect(Int(spacingSeconds) == 30 * 60, "FPU entries should be spaced +30 minutes apart")
+        }
+
+        #expect(
+            storedEntries.allSatisfy { $0.fpuID?.uuidString == fpuID },
+            "All entries should share the same fpuID"
+        )
+    }
+
+    @Test(
+        "Store small fat/protein meal drops FPU equivalents when total would be <10g (defaults: adjustment=0.5, delay=60m)"
+    ) func testStoreSmallFatProteinMealDropsFPUBelowMinimum() async throws {
+        let fpuID = UUID().uuidString
+        let baseDate = Date(timeIntervalSince1970: 1_700_002_000)
+
+        // Defaults:
+        // adjustment = 0.5
+        //
+        // fat=2g -> 18 kcal
+        // protein=2g -> 8 kcal
+        // kcal total = 26
+        // (kcal/10) = 2.6
+        // 2.6 * 0.5 = 1.3
+        // Int(1.3) = 1 (<10) -> should be dropped (no FPU entries)
+        let smallMealEntry = CarbsEntry(
+            id: UUID().uuidString,
+            createdAt: baseDate,
+            actualDate: baseDate,
+            carbs: 30,
+            fat: 2,
+            protein: 2,
+            note: "Tiny macros - min threshold test",
+            enteredBy: "Test",
+            isFPU: false,
+            fpuID: fpuID
+        )
+
+        try await storage.storeCarbs([smallMealEntry], areFetchedFromRemote: false)
+
+        let storedEntries = try await coreDataStack.fetchEntitiesAsync(
+            ofType: CarbEntryStored.self,
+            onContext: testContext,
+            predicate: NSPredicate(format: "fpuID == %@", fpuID),
+            key: "date",
+            ascending: true
+        ) as? [CarbEntryStored]
+
+        guard let storedEntries else {
+            throw TestError("Failed to fetch entries for fpuID")
+        }
+
+        #expect(!storedEntries.isEmpty, "Should have stored at least the original entry")
+
+        let originalCarbEntry = storedEntries.first(where: { $0.isFPU == false })
+        #expect(originalCarbEntry != nil, "Should have one non-FPU original entry")
+        #expect(originalCarbEntry?.carbs == 30, "Original carbs should match")
+        #expect(originalCarbEntry?.fat == 2, "Original fat should match")
+        #expect(originalCarbEntry?.protein == 2, "Original protein should match")
+
+        let fpuEntries = storedEntries.filter { $0.isFPU == true }
+        #expect(fpuEntries.isEmpty == true, "No FPU entries should be created when equivalents are <10g")
+
+        #expect(
+            storedEntries.allSatisfy { $0.fpuID?.uuidString == fpuID },
+            "All entries should share the same fpuID"
+        )
+    }
+
     @Test("Get carbs not yet uploaded to Nightscout") func testGetCarbsNotYetUploadedToNightscout() async throws {
         // Given
         let testEntry = CarbsEntry(

+ 333 - 0
TrioTests/GlucoseSmoothingTests.swift

@@ -0,0 +1,333 @@
+import CoreData
+import Foundation
+import LoopKitUI
+import Swinject
+import Testing
+
+@testable import Trio
+
+@Suite("Glucose Smoothing Tests", .serialized) struct GlucoseSmoothingTests: Injectable {
+    let resolver: Resolver
+    var coreDataStack: CoreDataStack!
+    var testContext: NSManagedObjectContext!
+    var fetchGlucoseManager: BaseFetchGlucoseManager!
+    var openAPS: OpenAPS!
+
+    init() async throws {
+        coreDataStack = try await CoreDataStack.createForTests()
+        testContext = coreDataStack.newTaskContext()
+
+        let assembler = Assembler([
+            StorageAssembly(),
+            ServiceAssembly(),
+            APSAssembly(),
+            NetworkAssembly(),
+            UIAssembly(),
+            SecurityAssembly(),
+            TestAssembly(testContext: testContext)
+        ])
+
+        resolver = assembler.resolver
+        injectServices(resolver)
+
+        fetchGlucoseManager = resolver.resolve(FetchGlucoseManager.self)! as? BaseFetchGlucoseManager
+
+        let fileStorage = resolver.resolve(FileStorage.self)!
+        openAPS = OpenAPS(storage: fileStorage, tddStorage: MockTDDStorage())
+    }
+
+    // MARK: - Exponential Smoothing Tests
+
+    @Test(
+        "Exponential smoothing writes smoothed glucose for CGM values when enough data exists"
+    ) func testExponentialSmoothingStoresSmoothedValues() async throws {
+        let glucoseValues: [Int16] = [100, 105, 110, 115, 120, 125]
+        await createGlucoseSequence(values: glucoseValues, interval: 5 * 60, isManual: false)
+
+        await fetchGlucoseManager.exponentialSmoothingGlucose(context: testContext)
+
+        let fetchedAscending = try await fetchAndSortGlucose()
+
+        // We expect at least the most recent few values to get smoothed values written.
+        // The Kotlin/port writes to data[i] for i in 0..<limit, where data is newest-first.
+        // With 6 values:
+        // - recordCount = 6
+        // - validWindowCount starts at 5, no gap => remains 5
+        // - smoothing produces blended.count == 5
+        // - apply limit = min(5, 6) = 5 => most recent 5 entries get smoothedGlucose
+        //
+        // In ascending order, "most recent 5" are indices 1...5. Oldest (index 0) is not guaranteed to be updated.
+        #expect(fetchedAscending.count == 6)
+
+        let smoothedValues = fetchedAscending.compactMap { $0.smoothedGlucose?.decimalValue }
+        #expect(smoothedValues.count >= 5, "Expected at least 5 smoothed values to be stored.")
+
+        for (i, value) in smoothedValues.enumerated() {
+            #expect(value >= 39, "Smoothed glucose at index \(i) should be clamped to at least 39, got \(value).")
+            #expect(
+                value == value.rounded(toPlaces: 0),
+                "Smoothed glucose at index \(i) should be rounded to an integer, got \(value)."
+            )
+        }
+    }
+
+    @Test("Exponential smoothing does not smooth manual glucose entries") func testExponentialSmoothingIgnoresManual() async throws {
+        // GIVEN: Mixed manual + CGM values
+        await createGlucoseSequence(values: [100, 105, 110, 115, 120].map(Int16.init), interval: 5 * 60, isManual: false)
+        await createGlucose(glucose: 130, smoothed: nil, isManual: true, date: Date().addingTimeInterval(6 * 5 * 60))
+
+        // WHEN
+        await fetchGlucoseManager.exponentialSmoothingGlucose(context: testContext)
+
+        // THEN
+        let allAscending = try await fetchAndSortGlucose()
+        let manual = allAscending.first(where: { $0.isManual })
+
+        #expect(manual != nil, "Expected a manual glucose entry.")
+        #expect(manual?.smoothedGlucose == nil, "Manual entries must not be smoothed/stored.")
+    }
+
+    @Test(
+        "Exponential smoothing clamps smoothed glucose to >= 39 and rounds to integer"
+    ) func testExponentialSmoothingClampAndRounding() async throws {
+        // GIVEN
+        let glucoseValues: [Int16] = [40, 39, 41, 42, 43, 44]
+        await createGlucoseSequence(values: glucoseValues, interval: 5 * 60, isManual: false)
+
+        // WHEN
+        await fetchGlucoseManager.exponentialSmoothingGlucose(context: testContext)
+
+        // THEN
+        let fetchedAscending = try await fetchAndSortGlucose()
+
+        let smoothedValues = fetchedAscending
+            .compactMap { $0.smoothedGlucose?.decimalValue }
+            .filter { $0 > 0 }
+
+        #expect(!smoothedValues.isEmpty, "Expected at least one smoothed glucose value to be stored.")
+
+        for (index, smoothed) in smoothedValues.enumerated() {
+            #expect(
+                smoothed >= 39,
+                "Smoothed glucose must be clamped to >= 39, got \(smoothed) at index \(index)."
+            )
+
+            #expect(
+                smoothed == smoothed.rounded(toPlaces: 0),
+                "Smoothed glucose must be an integer value, got \(smoothed) at index \(index)."
+            )
+        }
+    }
+
+    @Test(
+        "Exponential smoothing stops at gaps >= 12 minutes and only updates the most recent window"
+    ) func testExponentialSmoothingGapStopsWindow() async throws {
+        let now = Date()
+
+        var dates: [Date] = []
+        var values: [Int16] = []
+
+        // Older contiguous block (should remain untouched)
+        for i in 0 ..< 10 {
+            dates.append(now.addingTimeInterval(Double(i) * 5 * 60))
+            values.append(Int16(100 + i * 5))
+        }
+
+        // GAP (15 minutes)
+        let gapStart = now.addingTimeInterval(Double(10) * 5 * 60 + 15 * 60)
+
+        // Recent block (too small -> fallback applies only here)
+        for i in 0 ..< 3 {
+            dates.append(gapStart.addingTimeInterval(Double(i) * 5 * 60))
+            values.append(Int16(200 + i * 5))
+        }
+
+        await createGlucoseSequence(values: values, dates: dates, isManual: false)
+
+        await fetchGlucoseManager.exponentialSmoothingGlucose(context: testContext)
+
+        let ascending = try await fetchAndSortGlucose()
+        #expect(ascending.count == values.count)
+
+        // Split into:
+        // - older block (before gap)
+        // - recent block (after gap)
+        let olderBlock = ascending.prefix(10)
+        let recentBlock = ascending.suffix(3)
+
+        // --- ASSERT 1: Older values should NOT be overwritten ---
+        for (index, obj) in olderBlock.enumerated() {
+            #expect(
+                obj.smoothedGlucose == nil,
+                "Older value at index \(index) should remain untouched (no fallback overwrite)."
+            )
+        }
+
+        // --- ASSERT 2: Recent values should be filled by fallback ---
+        for (index, obj) in recentBlock.enumerated() {
+            guard let smoothed = obj.smoothedGlucose?.decimalValue else {
+                #expect(false, "Recent value at index \(index) should have smoothedGlucose set.")
+                continue
+            }
+
+            #expect(
+                smoothed >= 39,
+                "Fallback smoothed glucose must be clamped to >= 39, got \(smoothed)."
+            )
+
+            #expect(
+                smoothed == smoothed.rounded(toPlaces: 0),
+                "Fallback smoothed glucose must be rounded to integer, got \(smoothed)."
+            )
+        }
+    }
+
+    @Test(
+        "Exponential smoothing treats 38 mg/dL as xDrip error and clamps stored smoothed glucose"
+    ) func testExponentialSmoothingXDrip38StopsWindow() async throws {
+        // GIVEN
+        let values: [Int16] = [100, 105, 110, 38, 120, 125]
+        await createGlucoseSequence(values: values, interval: 5 * 60, isManual: false)
+
+        // WHEN
+        await fetchGlucoseManager.exponentialSmoothingGlucose(context: testContext)
+
+        // THEN
+        let ascending = try await fetchAndSortGlucose()
+        #expect(ascending.count == 6)
+
+        let smoothedValues = ascending
+            .compactMap { $0.smoothedGlucose?.decimalValue }
+            .filter { $0 > 0 }
+
+        #expect(
+            !smoothedValues.isEmpty,
+            "Expected at least one smoothed glucose value to be stored."
+        )
+
+        for (index, smoothed) in smoothedValues.enumerated() {
+            #expect(
+                smoothed >= 39,
+                "Smoothed glucose must be clamped to >= 39 even around xDrip 38, got \(smoothed) at index \(index)."
+            )
+            #expect(
+                smoothed == smoothed.rounded(toPlaces: 0),
+                "Smoothed glucose must be rounded to an integer, got \(smoothed) at index \(index)."
+            )
+        }
+    }
+
+    // MARK: - OpenAPS Glucose Selection Tests
+
+    @Test("Algorithm uses smoothed glucose when enabled") func testAlgorithmUsesSmoothedGlucose() async throws {
+        await createGlucose(glucose: 150, smoothed: 140, isManual: false, date: Date())
+
+        let algorithmInput = try await runFetchAndProcessGlucose(smoothGlucose: true)
+
+        #expect(algorithmInput.count == 1, "Expected to process one glucose entry.")
+        #expect(
+            algorithmInput.first?.glucose == 140,
+            "Algorithm should have used the smoothed glucose value (140), but used \(algorithmInput.first?.glucose ?? 0)."
+        )
+    }
+
+    @Test("Algorithm uses raw glucose when smoothing is disabled") func testAlgorithmUsesRawGlucose() async throws {
+        await createGlucose(glucose: 150, smoothed: 140, isManual: false, date: Date())
+
+        let algorithmInput = try await runFetchAndProcessGlucose(smoothGlucose: false)
+
+        #expect(algorithmInput.count == 1, "Expected to process one glucose entry.")
+        #expect(
+            algorithmInput.first?.glucose == 150,
+            "Algorithm should have used the raw glucose value (150), but used \(algorithmInput.first?.glucose ?? 0)."
+        )
+    }
+
+    @Test("Algorithm falls back to raw glucose if smoothed value is missing") func testAlgorithmFallbackToRawGlucose() async throws {
+        await createGlucose(glucose: 150, smoothed: nil, isManual: false, date: Date())
+
+        let algorithmInput = try await runFetchAndProcessGlucose(smoothGlucose: true)
+
+        #expect(algorithmInput.count == 1, "Expected to process one glucose entry.")
+        #expect(
+            algorithmInput.first?.glucose == 150,
+            "Algorithm should have fallen back to the raw glucose value (150), but used \(algorithmInput.first?.glucose ?? 0)."
+        )
+    }
+
+    @Test("Algorithm ignores smoothed value for manual glucose entries") func testAlgorithmIgnoresSmoothedManualGlucose() async throws {
+        await createGlucose(glucose: 150, smoothed: 140, isManual: true, date: Date())
+
+        let algorithmInput = try await runFetchAndProcessGlucose(smoothGlucose: true)
+
+        #expect(algorithmInput.count == 1, "Expected to process one glucose entry.")
+        #expect(
+            algorithmInput.first?.glucose == 150,
+            "Algorithm should have ignored smoothing for a manual entry and used the raw value (150), but used \(algorithmInput.first?.glucose ?? 0)."
+        )
+    }
+
+    // MARK: - Helpers
+
+    private func runFetchAndProcessGlucose(smoothGlucose: Bool) async throws -> [AlgorithmGlucose] {
+        let jsonString = try await openAPS.fetchAndProcessGlucose(
+            context: testContext,
+            shouldSmoothGlucose: smoothGlucose,
+            fetchLimit: 10
+        )
+
+        let data = jsonString.data(using: .utf8)!
+        let decoder = JSONDecoder()
+        decoder.dateDecodingStrategy = .custom { decoder in
+            let container = try decoder.singleValueContainer()
+            let dateDouble = try container.decode(Double.self)
+            return Date(timeIntervalSince1970: dateDouble / 1000)
+        }
+
+        return try decoder.decode([AlgorithmGlucose].self, from: data)
+    }
+
+    private func createGlucose(glucose: Int16, smoothed: Decimal?, isManual: Bool, date: Date) async {
+        await testContext.perform {
+            let object = GlucoseStored(context: self.testContext)
+            object.date = date
+            object.glucose = glucose
+            object.smoothedGlucose = smoothed as NSDecimalNumber?
+            object.isManual = isManual
+            object.id = UUID()
+            try! self.testContext.save()
+        }
+    }
+
+    private func createGlucoseSequence(values: [Int16], dates: [Date], isManual: Bool) async {
+        precondition(values.count == dates.count)
+
+        await testContext.perform {
+            for (i, value) in values.enumerated() {
+                let object = GlucoseStored(context: self.testContext)
+                object.date = dates[i]
+                object.glucose = value
+                object.smoothedGlucose = nil
+                object.isManual = isManual
+                object.id = UUID()
+            }
+            try! self.testContext.save()
+        }
+    }
+
+    private func createGlucoseSequence(values: [Int16], interval: TimeInterval, isManual: Bool) async {
+        let now = Date()
+        let dates = values.indices.map { now.addingTimeInterval(Double($0) * interval) }
+        await createGlucoseSequence(values: values, dates: dates, isManual: isManual)
+    }
+
+    private func fetchAndSortGlucose() async throws -> [GlucoseStored] {
+        try await coreDataStack.fetchEntitiesAsync(
+            ofType: GlucoseStored.self,
+            onContext: testContext,
+            predicate: .all,
+            key: "date",
+            ascending: true
+        ) as? [GlucoseStored] ?? []
+    }
+}

+ 2 - 2
TrioTests/JSONImporterTests.swift

@@ -92,7 +92,7 @@ class BundleReference {}
         ) as? [PumpEventStored] ?? []
 
         let objectIds = allReadings.map(\.objectID)
-        let parsedHistory = OpenAPS.loadAndMapPumpEvents(objectIds, from: context)
+        let parsedHistory = OpenAPS.loadAndMapPumpEvents(objectIds, orphanedResumes: [], from: context)
 
         var bolusTotal = 0.0
         var bolusCount = 0
@@ -172,7 +172,7 @@ class BundleReference {}
         ) as? [PumpEventStored] ?? []
 
         let objectIds = allReadings.map(\.objectID)
-        let parsedHistory = OpenAPS.loadAndMapPumpEvents(objectIds, from: context)
+        let parsedHistory = OpenAPS.loadAndMapPumpEvents(objectIds, orphanedResumes: [], from: context)
 
         #expect(parsedHistory.count == 1)
 

+ 15 - 0
TrioTests/Mocks/MockTDDStorage.swift

@@ -0,0 +1,15 @@
+import LoopKitUI
+@testable import Trio
+
+struct MockTDDStorage: TDDStorage {
+    func calculateTDD(
+        pumpManager _: any LoopKitUI.PumpManagerUI,
+        pumpHistory _: [Trio.PumpHistoryEvent],
+        basalProfile _: [Trio.BasalProfileEntry]
+    ) async throws -> Trio.TDDResult {
+        TDDResult(total: 0, bolus: 0, tempBasal: 0, scheduledBasal: 0, weightedAverage: 0, hoursOfData: 0)
+    }
+
+    func storeTDD(_: Trio.TDDResult) async { /* skip */ }
+    func hasSufficientTDD() async throws -> Bool { true }
+}

+ 2 - 1
scripts/swiftformat.sh

@@ -111,4 +111,5 @@ trailingClosures \
   OmniBLE, \
   MinimedKit, \
   TidepoolService, \
-  DanaKit 
+  DanaKit, \
+  MedtrumKit