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

Merge branch 'dev' of github.com:nightscout/Trio-dev into oref-swift

Deniz Cengiz 1 месяц назад
Родитель
Сommit
60c95c9eb1
38 измененных файлов с 596 добавлено и 291 удалено
  1. 3 0
      .github/FUNDING.yml
  2. 1 1
      .github/workflows/add_identifiers.yml
  3. 1 1
      .github/workflows/auto_version_dev.yml
  4. 4 4
      .github/workflows/build_trio.yml
  5. 2 2
      .github/workflows/create_certs.yml
  6. 2 2
      .github/workflows/stale_issues.yml
  7. 3 3
      .github/workflows/unit_tests.yml
  8. 1 1
      .github/workflows/validate_secrets.yml
  9. 1 1
      Config.xcconfig
  10. 2 2
      Model/TrioCoreDataPersistentContainer.xcdatamodeld/TrioCoreDataPersistentContainer.xcdatamodel/contents
  11. 66 0
      Trio Watch App Extension/Helper/WatchNotificationHandler.swift
  12. 5 0
      Trio Watch App Extension/TrioWatchApp.swift
  13. 10 0
      Trio.xcodeproj/project.pbxproj
  14. 2 2
      Trio/Resources/json/defaults/freeaps/freeaps_settings.json
  15. 38 33
      Trio/Sources/APS/FetchGlucoseManager.swift
  16. 1 1
      Trio/Sources/APS/OpenAPS/OpenAPS.swift
  17. 6 3
      Trio/Sources/APS/Storage/PumpHistoryStorage.swift
  18. 116 80
      Trio/Sources/Localizations/Main/Localizable.xcstrings
  19. 1 1
      Trio/Sources/Models/BolusDisplayThreshold.swift
  20. 0 1
      Trio/Sources/Models/DecimalPickerSettings.swift
  21. 64 0
      Trio/Sources/Models/NotificationIdentifiers.swift
  22. 5 6
      Trio/Sources/Models/TrioSettings.swift
  23. 3 0
      Trio/Sources/Models/WatchMessageKeys.swift
  24. 1 5
      Trio/Sources/Modules/MealSettings/MealSettingsStateModel.swift
  25. 4 34
      Trio/Sources/Modules/MealSettings/View/MealSettingsRootView.swift
  26. 0 1
      Trio/Sources/Modules/Onboarding/OnboardingStateModel.swift
  27. 3 4
      Trio/Sources/Modules/Settings/SettingItems.swift
  28. 0 7
      Trio/Sources/Modules/SettingsExport/SettingsExportStateModel.swift
  29. 31 2
      Trio/Sources/Modules/Snooze/SnoozeStateModel.swift
  30. 26 30
      Trio/Sources/Modules/Snooze/View/SnoozeRootView.swift
  31. 1 1
      Trio/Sources/Modules/Stat/View/ViewElements/Glucose/GlucoseSectorChart.swift
  32. 2 2
      Trio/Sources/Modules/Stat/View/ViewElements/Insulin/BolusStatsView.swift
  33. 2 2
      Trio/Sources/Modules/Stat/View/ViewElements/Insulin/TotalDailyDoseChart.swift
  34. 2 2
      Trio/Sources/Modules/Stat/View/ViewElements/Meal/MealStatsView.swift
  35. 112 13
      Trio/Sources/Services/UserNotifications/UserNotificationsManager.swift
  36. 31 16
      Trio/Sources/Services/WatchManager/AppleWatchManager.swift
  37. 0 2
      Trio/Sources/Views/SettingInputSection.swift
  38. 44 26
      TrioTests/GlucoseSmoothingTests.swift

+ 3 - 0
.github/FUNDING.yml

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

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

@@ -16,7 +16,7 @@ jobs:
     steps:
     steps:
       # Checks-out the repo
       # Checks-out the repo
       - name: Checkout Repo
       - name: Checkout Repo
-        uses: actions/checkout@v4
+        uses: actions/checkout@v5
 
 
       # Patch Fastlane Match to not print tables
       # Patch Fastlane Match to not print tables
       - name: Patch Match Tables
       - name: Patch Match Tables

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

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

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

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

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

@@ -28,7 +28,7 @@ jobs:
     steps:
     steps:
       # Checks-out the repo
       # Checks-out the repo
       - name: Checkout Repo
       - name: Checkout Repo
-        uses: actions/checkout@v4
+        uses: actions/checkout@v5
 
 
       # Patch Fastlane Match to not print tables
       # Patch Fastlane Match to not print tables
       - name: Patch Match Tables
       - name: Patch Match Tables
@@ -97,7 +97,7 @@ jobs:
           run: echo "new_certificate_needed=${{ needs.create_certs.outputs.new_certificate_needed }}"
           run: echo "new_certificate_needed=${{ needs.create_certs.outputs.new_certificate_needed }}"
 
 
         - name: Checkout repository
         - name: Checkout repository
-          uses: actions/checkout@v4
+          uses: actions/checkout@v5
 
 
         - name: Install dependencies
         - name: Install dependencies
           run: bundle install
           run: bundle install

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

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

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

@@ -31,14 +31,14 @@ jobs:
         run: sudo xcode-select -s /Applications/Xcode_26.2.app/Contents/Developer
         run: sudo xcode-select -s /Applications/Xcode_26.2.app/Contents/Developer
 
 
       - name: Checkout code
       - name: Checkout code
-        uses: actions/checkout@v4
+        uses: actions/checkout@v5
         with:
         with:
           fetch-depth: 1
           fetch-depth: 1
           submodules: recursive
           submodules: recursive
 
 
       - name: Restore cache
       - name: Restore cache
         id: cache-restore
         id: cache-restore
-        uses: actions/cache/restore@v4
+        uses: actions/cache/restore@v5
         with:
         with:
           path: |
           path: |
             /Users/runner/Library/Developer/Xcode/DerivedData
             /Users/runner/Library/Developer/Xcode/DerivedData
@@ -94,7 +94,7 @@ jobs:
           
           
       - name: Save cache
       - name: Save cache
         if: steps.cache-restore.outputs.cache-hit != 'true'
         if: steps.cache-restore.outputs.cache-hit != 'true'
-        uses: actions/cache/save@v4
+        uses: actions/cache/save@v5
         with:
         with:
           path: |
           path: |
             /Users/runner/Library/Developer/Xcode/DerivedData
             /Users/runner/Library/Developer/Xcode/DerivedData

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

@@ -116,7 +116,7 @@ jobs:
       TEAMID: ${{ secrets.TEAMID }}
       TEAMID: ${{ secrets.TEAMID }}
     steps:
     steps:
       - name: Checkout Repo
       - name: Checkout Repo
-        uses: actions/checkout@v4
+        uses: actions/checkout@v5
 
 
       - name: Install Project Dependencies
       - name: Install Project Dependencies
         run: bundle install
         run: bundle install

+ 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
 // The developers set the version numbers, please leave them alone
 APP_VERSION = 0.6.0
 APP_VERSION = 0.6.0
-APP_DEV_VERSION = 0.6.0.60
+APP_DEV_VERSION = 0.6.0.70
 APP_BUILD_NUMBER = 1
 APP_BUILD_NUMBER = 1
 COPYRIGHT_NOTICE =
 COPYRIGHT_NOTICE =
 
 

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

@@ -1,5 +1,5 @@
 <?xml version="1.0" encoding="UTF-8" standalone="yes"?>
 <?xml version="1.0" encoding="UTF-8" standalone="yes"?>
-<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="23788.4" 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">
     <entity name="BolusStored" representedClassName="BolusStored" syncable="YES">
         <attribute name="amount" optional="YES" attributeType="Decimal" defaultValueString="0"/>
         <attribute name="amount" optional="YES" attributeType="Decimal" defaultValueString="0"/>
         <attribute name="isExternal" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
         <attribute name="isExternal" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
@@ -78,7 +78,7 @@
         <attribute name="isUploadedToHealth" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
         <attribute name="isUploadedToHealth" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
         <attribute name="isUploadedToNS" 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="isUploadedToTidepool" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
-        <attribute name="smoothedGlucose" optional="YES" attributeType="Decimal" defaultValueString="0.0"/>
+        <attribute name="smoothedGlucose" optional="YES" attributeType="Decimal"/>
         <fetchIndex name="byDate">
         <fetchIndex name="byDate">
             <fetchIndexElement property="date" type="Binary" order="ascending"/>
             <fetchIndexElement property="date" type="Binary" order="ascending"/>
         </fetchIndex>
         </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 SwiftUI
+import UserNotifications
 
 
 @main struct TrioWatchApp: App {
 @main struct TrioWatchApp: App {
     @Environment(\.scenePhase) private var scenePhase
     @Environment(\.scenePhase) private var scenePhase
 
 
+    init() {
+        WatchNotificationHandler.shared.configure()
+    }
+
     var body: some Scene {
     var body: some Scene {
         WindowGroup {
         WindowGroup {
             TrioMainWatchView()
             TrioMainWatchView()

+ 10 - 0
Trio.xcodeproj/project.pbxproj

@@ -556,6 +556,9 @@
 		C2AA6CF72E1A734A00BF6C16 /* SettingsExportProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2AA6CF32E1A734A00BF6C16 /* SettingsExportProvider.swift */; };
 		C2AA6CF72E1A734A00BF6C16 /* SettingsExportProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2AA6CF32E1A734A00BF6C16 /* SettingsExportProvider.swift */; };
 		C2AA6CF82E1A734A00BF6C16 /* SettingsExportDataFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2AA6CF22E1A734A00BF6C16 /* SettingsExportDataFlow.swift */; };
 		C2AA6CF82E1A734A00BF6C16 /* SettingsExportDataFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2AA6CF22E1A734A00BF6C16 /* SettingsExportDataFlow.swift */; };
 		C2AA6CF92E1A734A00BF6C16 /* SettingsExportStateModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2AA6CF42E1A734A00BF6C16 /* SettingsExportStateModel.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 */; };
 		C967DACD3B1E638F8B43BE06 /* ManualTempBasalStateModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CFCFE0781F9074C2917890E8 /* ManualTempBasalStateModel.swift */; };
 		CA370FC152BC98B3D1832968 /* BasalProfileEditorRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF8BCB0C37DEB5EC377B9612 /* BasalProfileEditorRootView.swift */; };
 		CA370FC152BC98B3D1832968 /* BasalProfileEditorRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF8BCB0C37DEB5EC377B9612 /* BasalProfileEditorRootView.swift */; };
 		CC6C406E2ACDD69E009B8058 /* RawFetchedProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC6C406D2ACDD69E009B8058 /* RawFetchedProfile.swift */; };
 		CC6C406E2ACDD69E009B8058 /* RawFetchedProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC6C406D2ACDD69E009B8058 /* RawFetchedProfile.swift */; };
@@ -1504,6 +1507,8 @@
 		C2AA6CF22E1A734A00BF6C16 /* SettingsExportDataFlow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsExportDataFlow.swift; sourceTree = "<group>"; };
 		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>"; };
 		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>"; };
 		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>"; };
 		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>"; };
 		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>"; };
 		CC6C406D2ACDD69E009B8058 /* RawFetchedProfile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RawFetchedProfile.swift; sourceTree = "<group>"; };
@@ -2659,6 +2664,7 @@
 				DD6B7CB32C7B71F700B75029 /* ForecastDisplayType.swift */,
 				DD6B7CB32C7B71F700B75029 /* ForecastDisplayType.swift */,
 				DD9ECB692CA99F6C00AA7C45 /* CommandPayload.swift */,
 				DD9ECB692CA99F6C00AA7C45 /* CommandPayload.swift */,
 				DDD6D4D22CDE90720029439A /* EstimatedA1cDisplayUnit.swift */,
 				DDD6D4D22CDE90720029439A /* EstimatedA1cDisplayUnit.swift */,
+				C2BA6B982F758E7600348E6A /* NotificationIdentifiers.swift */,
 			);
 			);
 			path = Models;
 			path = Models;
 			sourceTree = "<group>";
 			sourceTree = "<group>";
@@ -3846,6 +3852,7 @@
 				DD09D5C62D29EB26000D82C9 /* Helper+Enums.swift */,
 				DD09D5C62D29EB26000D82C9 /* Helper+Enums.swift */,
 				DD3A3CE82D29C97800AE478E /* Helper+ButtonStyles.swift */,
 				DD3A3CE82D29C97800AE478E /* Helper+ButtonStyles.swift */,
 				DD3A3CE62D29C93F00AE478E /* Helper+Extensions.swift */,
 				DD3A3CE62D29C93F00AE478E /* Helper+Extensions.swift */,
+				C2BA6B962F758E7500348E6A /* WatchNotificationHandler.swift */,
 			);
 			);
 			path = Helper;
 			path = Helper;
 			sourceTree = "<group>";
 			sourceTree = "<group>";
@@ -5028,6 +5035,7 @@
 				53F2382465BF74DB1A967C8B /* PumpConfigProvider.swift in Sources */,
 				53F2382465BF74DB1A967C8B /* PumpConfigProvider.swift in Sources */,
 				5D16287A969E64D18CE40E44 /* PumpConfigStateModel.swift in Sources */,
 				5D16287A969E64D18CE40E44 /* PumpConfigStateModel.swift in Sources */,
 				DDF6902C2DA028D3008BF16C /* DiagnosticsStepView.swift in Sources */,
 				DDF6902C2DA028D3008BF16C /* DiagnosticsStepView.swift in Sources */,
+				C2BA6B992F758E7600348E6A /* NotificationIdentifiers.swift in Sources */,
 				19D466AA29AA3099004D5F33 /* MealSettingsRootView.swift in Sources */,
 				19D466AA29AA3099004D5F33 /* MealSettingsRootView.swift in Sources */,
 				CEF1ED6B2D58FB5800FAF41E /* CGMOptions.swift in Sources */,
 				CEF1ED6B2D58FB5800FAF41E /* CGMOptions.swift in Sources */,
 				E974172296125A5AE99E634C /* PumpConfigRootView.swift in Sources */,
 				E974172296125A5AE99E634C /* PumpConfigRootView.swift in Sources */,
@@ -5397,6 +5405,7 @@
 				BDA25F202D26D5FE00035F34 /* CarbsInputView.swift in Sources */,
 				BDA25F202D26D5FE00035F34 /* CarbsInputView.swift in Sources */,
 				BDA25F1C2D26BD0700035F34 /* TrendShape.swift in Sources */,
 				BDA25F1C2D26BD0700035F34 /* TrendShape.swift in Sources */,
 				BDFF7A892D25F97D0016C40C /* TrioWatchApp.swift in Sources */,
 				BDFF7A892D25F97D0016C40C /* TrioWatchApp.swift in Sources */,
+				C2BA6B972F758E7500348E6A /* WatchNotificationHandler.swift in Sources */,
 				DD09D5C72D29EB2F000D82C9 /* Helper+Enums.swift in Sources */,
 				DD09D5C72D29EB2F000D82C9 /* Helper+Enums.swift in Sources */,
 				BD54A9742D281AEF00F9C1EE /* TempTargetPresetWatch.swift in Sources */,
 				BD54A9742D281AEF00F9C1EE /* TempTargetPresetWatch.swift in Sources */,
 				BD54A95C2D2808A300F9C1EE /* OverridePresetWatch.swift in Sources */,
 				BD54A95C2D2808A300F9C1EE /* OverridePresetWatch.swift in Sources */,
@@ -5407,6 +5416,7 @@
 				DD3A3CE72D29C93F00AE478E /* Helper+Extensions.swift in Sources */,
 				DD3A3CE72D29C93F00AE478E /* Helper+Extensions.swift in Sources */,
 				DD246F062D2836AA0027DDE0 /* GlucoseTrendView.swift in Sources */,
 				DD246F062D2836AA0027DDE0 /* GlucoseTrendView.swift in Sources */,
 				BD432CA22D2F4E4000D1EB79 /* WatchMessageKeys.swift in Sources */,
 				BD432CA22D2F4E4000D1EB79 /* WatchMessageKeys.swift in Sources */,
+				C2BA6B9A2F7593C300348E6A /* NotificationIdentifiers.swift in Sources */,
 				DD8262CB2D289297009F6F62 /* BolusConfirmationView.swift in Sources */,
 				DD8262CB2D289297009F6F62 /* BolusConfirmationView.swift in Sources */,
 				BD54A9712D281A8100F9C1EE /* TempTargetPresetsView.swift in Sources */,
 				BD54A9712D281A8100F9C1EE /* TempTargetPresetsView.swift in Sources */,
 				DD3A3CE92D29C97800AE478E /* Helper+ButtonStyles.swift in Sources */,
 				DD3A3CE92D29C97800AE478E /* Helper+ButtonStyles.swift in Sources */,

+ 2 - 2
Trio/Resources/json/defaults/freeaps/freeaps_settings.json

@@ -19,9 +19,8 @@
   "highGlucose" : 270,
   "highGlucose" : 270,
   "carbsRequiredThreshold" : 10,
   "carbsRequiredThreshold" : 10,
   "showCarbsRequiredBadge" : true,
   "showCarbsRequiredBadge" : true,
-  "useFPUconversion" : true,
+  "useFPUconversion" : false,
   "individualAdjustmentFactor" : 0.5,
   "individualAdjustmentFactor" : 0.5,
-  "timeCap" : 8,
   "minuteInterval" : 30,
   "minuteInterval" : 30,
   "delay" : 60,
   "delay" : 60,
   "useAppleHealth" : false,
   "useAppleHealth" : false,
@@ -34,6 +33,7 @@
   "xGridLines" : true,
   "xGridLines" : true,
   "yGridLines" : true,
   "yGridLines" : true,
   "rulerMarks" : true,
   "rulerMarks" : true,
+  "bolusDisplayThreshold": 0.01,
   "forecastDisplayType": "cone",
   "forecastDisplayType": "cone",
   "maxCarbs": 250,
   "maxCarbs": 250,
   "maxFat": 250,
   "maxFat": 250,

+ 38 - 33
Trio/Sources/APS/FetchGlucoseManager.swift

@@ -240,37 +240,6 @@ final class BaseFetchGlucoseManager: FetchGlucoseManager, Injectable {
         return Manager.init(rawState: rawState)
         return Manager.init(rawState: rawState)
     }
     }
 
 
-    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)
-    }
-
     private func glucoseStoreAndHeartDecision(syncDate: Date, glucose: [BloodGlucose]) async throws {
     private func glucoseStoreAndHeartDecision(syncDate: Date, glucose: [BloodGlucose]) async throws {
         // calibration add if required only for sensor
         // calibration add if required only for sensor
         let newGlucose = overcalibrate(entries: glucose)
         let newGlucose = overcalibrate(entries: glucose)
@@ -394,6 +363,37 @@ extension BaseFetchGlucoseManager: SettingsObserver {
 }
 }
 
 
 extension BaseFetchGlucoseManager {
 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.
     /// CoreData-friendly AAPS exponential smoothing + storage.
     /// - Important: Only stores `smoothedGlucose`. UI/alerts should still use `glucose`.
     /// - Important: Only stores `smoothedGlucose`. UI/alerts should still use `glucose`.
     ///
     ///
@@ -474,12 +474,17 @@ extension BaseFetchGlucoseManager {
             }
             }
         }
         }
 
 
-        // If insufficient valid readings: copy raw into smoothed (clamped) for all passed entries.
+        // 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 {
         guard validWindowCount >= minimumWindowSize else {
-            for object in data {
+            let recentWindow = data.suffix(validWindowCount)
+
+            for object in recentWindow {
                 let raw = Decimal(Int(object.glucose))
                 let raw = Decimal(Int(object.glucose))
                 object.smoothedGlucose = max(raw, minimumSmoothedGlucose) as NSDecimalNumber
                 object.smoothedGlucose = max(raw, minimumSmoothedGlucose) as NSDecimalNumber
             }
             }
+
             return
             return
         }
         }
 
 

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

@@ -130,7 +130,7 @@ final class OpenAPS {
 
 
             return glucoseResults.map { glucose -> AlgorithmGlucose in
             return glucoseResults.map { glucose -> AlgorithmGlucose in
                 let glucoseValue: Int16
                 let glucoseValue: Int16
-                if shouldSmoothGlucose, !glucose.isManual, let smoothedGlucose = glucose.smoothedGlucose {
+                if shouldSmoothGlucose, !glucose.isManual, let smoothedGlucose = glucose.smoothedGlucose, smoothedGlucose != 0 {
                     glucoseValue = smoothedGlucose.rounding(accordingToBehavior: roundingBehavior).int16Value
                     glucoseValue = smoothedGlucose.rounding(accordingToBehavior: roundingBehavior).int16Value
                 } else {
                 } else {
                     glucoseValue = glucose.glucose
                     glucoseValue = glucose.glucose

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

@@ -291,13 +291,16 @@ final class BasePumpHistoryStorage: PumpHistoryStorage, Injectable {
     }
     }
 
 
     func determineBolusEventType(for event: PumpEventStored) -> PumpEventStored.EventType {
     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
             return .smb
         }
         }
-        if event.bolus!.isExternal {
+        if bolus.isExternal {
             return .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] {
     func getPumpHistoryNotYetUploadedToNightscout() async throws -> [NightscoutTreatment] {

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


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

@@ -1,6 +1,6 @@
 import Foundation
 import Foundation
 
 
-enum BolusDisplayThreshold: Decimal, CaseIterable, Encodable, Identifiable {
+enum BolusDisplayThreshold: Decimal, JSON, CaseIterable, Identifiable, Codable, Hashable {
     public var id: Decimal { rawValue }
     public var id: Decimal { rawValue }
     case oneUnit = 1
     case oneUnit = 1
     case halfUnit = 0.5
     case halfUnit = 0.5

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

@@ -133,7 +133,6 @@ struct DecimalPickerSettings {
     var updateInterval = PickerSetting(value: 20, step: 5, min: 1, max: 60, type: PickerSetting.PickerSettingType.minute)
     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 delay = PickerSetting(value: 60, step: 5, min: 15, max: 120, type: PickerSetting.PickerSettingType.minute)
     var minuteInterval = PickerSetting(value: 30, step: 5, min: 30, max: 60, type: PickerSetting.PickerSettingType.minute)
     var minuteInterval = PickerSetting(value: 30, step: 5, min: 30, max: 60, type: PickerSetting.PickerSettingType.minute)
-    var timeCap = PickerSetting(value: 8, step: 1, min: 5, max: 12, type: PickerSetting.PickerSettingType.hour)
     var hours = PickerSetting(value: 6, step: 0.5, min: 2, max: 24, type: PickerSetting.PickerSettingType.hour)
     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 dia = PickerSetting(value: 10, step: 0.5, min: 5, max: 10, type: PickerSetting.PickerSettingType.hour)
     var maxBolus = PickerSetting(value: 10, step: 0.5, min: 0.5, max: 30, type: PickerSetting.PickerSettingType.insulinUnit)
     var maxBolus = PickerSetting(value: 10, step: 0.5, min: 0.5, max: 30, type: PickerSetting.PickerSettingType.insulinUnit)

+ 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: []
+        )
+    }
+}

+ 5 - 6
Trio/Sources/Models/TrioSettings.swift

@@ -40,9 +40,8 @@ struct TrioSettings: JSON, Equatable {
     var highGlucose: Decimal = 270
     var highGlucose: Decimal = 270
     var carbsRequiredThreshold: Decimal = 10
     var carbsRequiredThreshold: Decimal = 10
     var showCarbsRequiredBadge: Bool = true
     var showCarbsRequiredBadge: Bool = true
-    var useFPUconversion: Bool = true
+    var useFPUconversion: Bool = false
     var individualAdjustmentFactor: Decimal = 0.5
     var individualAdjustmentFactor: Decimal = 0.5
-    var timeCap: Decimal = 8
     var minuteInterval: Decimal = 30
     var minuteInterval: Decimal = 30
     var delay: Decimal = 60
     var delay: Decimal = 60
     var useAppleHealth: Bool = false
     var useAppleHealth: Bool = false
@@ -169,10 +168,6 @@ extension TrioSettings: Decodable {
             settings.overrideFactor = overrideFactor
             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) {
         if let minuteInterval = try? container.decode(Decimal.self, forKey: .minuteInterval) {
             settings.minuteInterval = minuteInterval
             settings.minuteInterval = minuteInterval
         }
         }
@@ -255,6 +250,10 @@ extension TrioSettings: Decodable {
             settings.rulerMarks = 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) {
         if let forecastDisplayType = try? container.decode(ForecastDisplayType.self, forKey: .forecastDisplayType) {
             settings.forecastDisplayType = forecastDisplayType
             settings.forecastDisplayType = forecastDisplayType
         }
         }

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

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

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

@@ -3,12 +3,11 @@ import SwiftUI
 extension MealSettings {
 extension MealSettings {
     final class StateModel: BaseStateModel<Provider> {
     final class StateModel: BaseStateModel<Provider> {
         @Published var units: GlucoseUnits = .mgdL
         @Published var units: GlucoseUnits = .mgdL
-        @Published var useFPUconversion: Bool = true
+        @Published var useFPUconversion: Bool = false
         @Published var maxCarbs: Decimal = 250
         @Published var maxCarbs: Decimal = 250
         @Published var maxFat: Decimal = 250
         @Published var maxFat: Decimal = 250
         @Published var maxProtein: Decimal = 250
         @Published var maxProtein: Decimal = 250
         @Published var individualAdjustmentFactor: Decimal = 0.5
         @Published var individualAdjustmentFactor: Decimal = 0.5
-        @Published var timeCap: Decimal = 8
         @Published var minuteInterval: Decimal = 30
         @Published var minuteInterval: Decimal = 30
         @Published var delay: Decimal = 60
         @Published var delay: Decimal = 60
         @Published var maxMealAbsorptionTime: Decimal = 6
         @Published var maxMealAbsorptionTime: Decimal = 6
@@ -27,9 +26,6 @@ extension MealSettings {
             // "Fat and Protein Delay"
             // "Fat and Protein Delay"
             subscribeSetting(\.delay, on: $delay) { delay = $0 }
             subscribeSetting(\.delay, on: $delay) { delay = $0 }
 
 
-            // "Maximum Duration"
-            subscribeSetting(\.timeCap, on: $timeCap) { timeCap = $0 }
-
             // "Spread Interval"
             // "Spread Interval"
             subscribeSetting(\.minuteInterval, on: $minuteInterval) { minuteInterval = $0 }
             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) {
                     VStack(alignment: .leading, spacing: 10) {
                         Text("Default: 6 hours").bold()
                         Text("Default: 6 hours").bold()
                         Text(
                         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(
                         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."
                             "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:"
                                     "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("• Fat and Protein Delay")
-                                Text("• Maximum Duration")
                                 Text("• Spread Interval")
                                 Text("• Spread Interval")
                                 Text("• Fat and Protein Percentage")
                                 Text("• Fat and Protein Percentage")
                             }
                             }
@@ -295,35 +294,6 @@ extension MealSettings {
                     )
                     )
 
 
                     SettingInputSection(
                     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,
                         decimalValue: $state.minuteInterval,
                         booleanValue: $booleanPlaceholder,
                         booleanValue: $booleanPlaceholder,
                         shouldDisplayHint: $shouldDisplayHint,
                         shouldDisplayHint: $shouldDisplayHint,
@@ -344,9 +314,9 @@ extension MealSettings {
                             Text(
                             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."
                                 "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."
+                            )
                         }
                         }
                     )
                     )
 
 

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

@@ -717,7 +717,6 @@ extension Onboarding {
                     .clamp(to: providedSettings.carbsRequiredThreshold)
                     .clamp(to: providedSettings.carbsRequiredThreshold)
                 settingsCopy.individualAdjustmentFactor = settingsCopy.individualAdjustmentFactor
                 settingsCopy.individualAdjustmentFactor = settingsCopy.individualAdjustmentFactor
                     .clamp(to: providedSettings.individualAdjustmentFactor)
                     .clamp(to: providedSettings.individualAdjustmentFactor)
-                settingsCopy.timeCap = settingsCopy.timeCap.clamp(to: providedSettings.timeCap)
                 settingsCopy.minuteInterval = settingsCopy.minuteInterval.clamp(to: providedSettings.minuteInterval)
                 settingsCopy.minuteInterval = settingsCopy.minuteInterval.clamp(to: providedSettings.minuteInterval)
                 settingsCopy.delay = settingsCopy.delay.clamp(to: providedSettings.delay)
                 settingsCopy.delay = settingsCopy.delay.clamp(to: providedSettings.delay)
                 settingsCopy.high = settingsCopy.high.clamp(to: providedSettings.high)
                 settingsCopy.high = settingsCopy.high.clamp(to: providedSettings.high)

+ 3 - 4
Trio/Sources/Modules/Settings/SettingItems.swift

@@ -195,11 +195,10 @@ enum SettingItems {
                 "Max Meal Absorption Time",
                 "Max Meal Absorption Time",
                 "Max Fat",
                 "Max Fat",
                 "Max Protein",
                 "Max Protein",
-                "Display and Allow Fat and Protein Entries",
+                "Enable Fat and Protein Entries",
                 "Fat and Protein Delay",
                 "Fat and Protein Delay",
-                "Maximum Duration (hours)",
-                "Spread Interval (minutes)",
-                "Fat and Protein Factor",
+                "Spread Interval",
+                "Fat and Protein Percentage",
                 "FPU"
                 "FPU"
             ],
             ],
             path: ["Features", "Meal Settings"]
             path: ["Features", "Meal Settings"]

+ 0 - 7
Trio/Sources/Modules/SettingsExport/SettingsExportStateModel.swift

@@ -704,13 +704,6 @@ extension SettingsExport {
                 addSetting(
                 addSetting(
                     category: featuresCategory,
                     category: featuresCategory,
                     subcategory: mealSettingsSubcategory,
                     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"),
                     name: String(localized: "Spread Interval"),
                     value: String(describing: trioSettings.minuteInterval),
                     value: String(describing: trioSettings.minuteInterval),
                     unit: String(localized: "minutes")
                     unit: String(localized: "minutes")

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

@@ -4,12 +4,41 @@ import SwiftUI
 extension Snooze {
 extension Snooze {
     @Observable final class StateModel: BaseStateModel<Provider> {
     @Observable final class StateModel: BaseStateModel<Provider> {
         @ObservationIgnored @Persisted(key: "UserNotificationsManager.snoozeUntilDate") var snoozeUntilDate: Date = .distantPast
         @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?
         var alarm: GlucoseAlarm?
 
 
         override func subscribe() {
         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"
         @State private var snoozeDescription = "nothing to see here"
 
 
         private var pickerTimes: [TimeInterval] {
         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 {
         private var formatter: DateComponentsFormatter {
@@ -53,7 +36,7 @@ extension Snooze {
         }
         }
 
 
         private func formatInterval(_ interval: TimeInterval) -> String {
         private func formatInterval(_ interval: TimeInterval) -> String {
-            formatter.string(from: interval)!
+            formatter.string(from: interval) ?? ""
         }
         }
 
 
         func getSnoozeDescription() -> String {
         func getSnoozeDescription() -> String {
@@ -85,12 +68,16 @@ extension Snooze {
             VStack(alignment: .leading) {
             VStack(alignment: .leading) {
                 Button {
                 Button {
                     let interval = pickerTimes[selectedInterval]
                     let interval = pickerTimes[selectedInterval]
-                    let snoozeFor = formatter.string(from: interval)!
+                    let snoozeFor = formatInterval(interval)
                     let untilDate = Date() + 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: {
                 } label: {
                     Text("Click to Snooze Alerts")
                     Text("Click to Snooze Alerts")
                         .padding()
                         .padding()
@@ -120,11 +107,20 @@ extension Snooze {
             .scrollContentBackground(.hidden).background(appState.trioBackgroundColor(for: colorScheme))
             .scrollContentBackground(.hidden).background(appState.trioBackgroundColor(for: colorScheme))
             .navigationBarTitle("Snooze Alerts")
             .navigationBarTitle("Snooze Alerts")
             .navigationBarTitleDisplayMode(.automatic)
             .navigationBarTitleDisplayMode(.automatic)
-            .navigationBarItems(trailing: Button("Close", action: state.hideModal))
+            .toolbar {
+                ToolbarItem(placement: .topBarTrailing) {
+                    Button("Close") {
+                        state.hideModal()
+                    }
+                }
+            }
             .onAppear {
             .onAppear {
                 configureView()
                 configureView()
                 snoozeDescription = getSnoozeDescription()
                 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)
                         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)
                         formatPercentage(Decimal(veryLow) / total * 100)
                     ),
                     ),
                     (String(localized: "Average"), average.formatted(for: units)),
                     (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()
                             AxisGridLine()
                         }
                         }
                     case .total:
                     case .total:
-                        // Only show every other month
+                        // Show start of every month
                         let day = Calendar.current.component(.day, from: date)
                         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)
                             AxisValueLabel(format: StatChartUtils.dateFormat(for: selectedInterval), centered: true)
                                 .font(.footnote)
                                 .font(.footnote)
                             AxisGridLine()
                             AxisGridLine()

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

@@ -224,8 +224,8 @@ struct TotalDailyDoseChart: View {
                             AxisGridLine()
                             AxisGridLine()
                         }
                         }
                     case .total:
                     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)
                             AxisValueLabel(format: StatChartUtils.dateFormat(for: selectedInterval), centered: true)
                                 .font(.footnote)
                                 .font(.footnote)
                             AxisGridLine()
                             AxisGridLine()

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

@@ -254,9 +254,9 @@ struct MealStatsView: View {
                             AxisGridLine()
                             AxisGridLine()
                         }
                         }
                     case .total:
                     case .total:
-                        // Only show every other month
+                        // Show start of every month
                         let day = Calendar.current.component(.day, from: date)
                         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)
                             AxisValueLabel(format: StatChartUtils.dateFormat(for: selectedInterval), centered: true)
                                 .font(.footnote)
                                 .font(.footnote)
                             AxisGridLine()
                             AxisGridLine()

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

@@ -11,6 +11,7 @@ import UserNotifications
 protocol UserNotificationsManager {
 protocol UserNotificationsManager {
     func getNotificationSettings(completionHandler: @escaping (UNNotificationSettings) -> Void)
     func getNotificationSettings(completionHandler: @escaping (UNNotificationSettings) -> Void)
     func requestNotificationPermissions(completion: @escaping (Bool) -> Void)
     func requestNotificationPermissions(completion: @escaping (Bool) -> Void)
+    @MainActor func applySnooze(for duration: TimeInterval) async
 }
 }
 
 
 enum GlucoseSourceKey: String {
 enum GlucoseSourceKey: String {
@@ -40,6 +41,12 @@ protocol pumpNotificationObserver {
     func pumpRemoveNotification()
     func pumpRemoveNotification()
 }
 }
 
 
+// MARK: - SnoozeObserver Protocol
+
+protocol SnoozeObserver {
+    @MainActor func snoozeDidChange(_ untilDate: Date)
+}
+
 final class BaseUserNotificationsManager: NSObject, UserNotificationsManager, Injectable {
 final class BaseUserNotificationsManager: NSObject, UserNotificationsManager, Injectable {
     enum Identifier: String {
     enum Identifier: String {
         case glucoseNotification = "Trio.glucoseNotification"
         case glucoseNotification = "Trio.glucoseNotification"
@@ -61,6 +68,9 @@ final class BaseUserNotificationsManager: NSObject, UserNotificationsManager, In
     @Injected(as: FetchGlucoseManager.self) private var sourceInfoProvider: SourceInfoProvider!
     @Injected(as: FetchGlucoseManager.self) private var sourceInfoProvider: SourceInfoProvider!
 
 
     @Persisted(key: "UserNotificationsManager.snoozeUntilDate") private var snoozeUntilDate: Date = .distantPast
     @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 let notificationCenter = UNUserNotificationCenter.current()
     private var lifetime = Lifetime()
     private var lifetime = Lifetime()
@@ -95,11 +105,28 @@ final class BaseUserNotificationsManager: NSObject, UserNotificationsManager, In
         Task {
         Task {
             await sendGlucoseNotification()
             await sendGlucoseNotification()
         }
         }
+        configureNotificationCategories()
         registerHandlers()
         registerHandlers()
         registerSubscribers()
         registerSubscribers()
         subscribeOnLoop()
         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() {
     private func subscribeOnLoop() {
         apsManager.lastLoopDateSubject
         apsManager.lastLoopDateSubject
             .sink { [weak self] date in
             .sink { [weak self] date in
@@ -271,6 +298,10 @@ final class BaseUserNotificationsManager: NSObject, UserNotificationsManager, In
                 try viewContext.existingObject(with: id) as? GlucoseStored
                 try viewContext.existingObject(with: id) as? GlucoseStored
             }
             }
 
 
+            if glucoseStorage.alarm == .none {
+                lastGlucoseAlertToken = ""
+            }
+
             guard let lastReading = glucoseObjects.first?.glucose,
             guard let lastReading = glucoseObjects.first?.glucose,
                   let secondLastReading = glucoseObjects.dropFirst().first?.glucose,
                   let secondLastReading = glucoseObjects.dropFirst().first?.glucose,
                   let lastDirection = glucoseObjects.first?.directionEnum?.symbol else { return }
                   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)"))
                 titles.append(String(localized: "(Snoozed)", comment: "(Snoozed)"))
                 notificationAlarm = false
                 notificationAlarm = false
             } else {
             } 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)
                 titles.append(body)
                 let content = UNMutableNotificationContent()
                 let content = UNMutableNotificationContent()
                 content.title = titles.joined(separator: " ")
                 content.title = titles.joined(separator: " ")
@@ -313,6 +353,7 @@ final class BaseUserNotificationsManager: NSObject, UserNotificationsManager, In
                 if notificationAlarm {
                 if notificationAlarm {
                     content.sound = .default
                     content.sound = .default
                     content.userInfo[NotificationAction.key] = NotificationAction.snooze.rawValue
                     content.userInfo[NotificationAction.key] = NotificationAction.snooze.rawValue
+                    content.categoryIdentifier = NotificationCategoryIdentifier.trioAlert.rawValue
                 }
                 }
 
 
                 addRequest(
                 addRequest(
@@ -323,6 +364,9 @@ final class BaseUserNotificationsManager: NSObject, UserNotificationsManager, In
                     messageSubtype: .glucose,
                     messageSubtype: .glucose,
                     action: NotificationAction.snooze
                     action: NotificationAction.snooze
                 )
                 )
+                if notificationAlarm {
+                    lastGlucoseAlertToken = token
+                }
             }
             }
         } catch {
         } catch {
             debugPrint(
             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 {
     private func glucoseText(glucoseValue: Int, delta: Int?, direction: String?) -> String {
         let units = settingsManager.settings.units
         let units = settingsManager.settings.units
         let glucoseText = glucoseFormatter
         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(
     private func addRequest(
         identifier: Identifier,
         identifier: Identifier,
         content: UNMutableNotificationContent,
         content: UNMutableNotificationContent,
@@ -571,6 +645,14 @@ extension BaseUserNotificationsManager: pumpNotificationObserver {
             self.notificationCenter.removePendingNotificationRequests(withIdentifiers: [identifier.rawValue])
             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 {
 extension BaseUserNotificationsManager: DeterminationObserver {
@@ -595,29 +677,46 @@ extension BaseUserNotificationsManager: UNUserNotificationCenterDelegate {
         completionHandler([.banner, .badge, .sound, .list])
         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(
     func userNotificationCenter(
         _: UNUserNotificationCenter,
         _: UNUserNotificationCenter,
         didReceive response: UNNotificationResponse,
         didReceive response: UNNotificationResponse,
         withCompletionHandler completionHandler: @escaping () -> Void
         withCompletionHandler completionHandler: @escaping () -> Void
     ) {
     ) {
         defer { completionHandler() }
         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,
         guard let actionRaw = response.notification.request.content.userInfo[NotificationAction.key] as? String,
               let action = NotificationAction(rawValue: actionRaw)
               let action = NotificationAction(rawValue: actionRaw)
         else { return }
         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 tempTargetStorage: TempTargetsStorage!
     @Injected() private var bolusCalculationManager: BolusCalculationManager!
     @Injected() private var bolusCalculationManager: BolusCalculationManager!
     @Injected() private var iobService: IOBService!
     @Injected() private var iobService: IOBService!
+    @Injected() private var notificationsManager: UserNotificationsManager!
 
 
     private var units: GlucoseUnits = .mgdL
     private var units: GlucoseUnits = .mgdL
     private var glucoseColorScheme: GlucoseColorScheme = .staticColor
     private var glucoseColorScheme: GlucoseColorScheme = .staticColor
@@ -553,16 +554,18 @@ final class BaseWatchManager: NSObject, WCSessionDelegate, Injectable, WatchMana
     }
     }
 
 
     func session(_: WCSession, didReceiveMessage message: [String: Any]) {
     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,
             if let requestWatchUpdate = message[WatchMessageKeys.requestWatchUpdate] as? String,
                requestWatchUpdate == WatchMessageKeys.watchState
                requestWatchUpdate == WatchMessageKeys.watchState
             {
             {
                 debug(.watchManager, "📱 Watch requested watch state data update.")
                 debug(.watchManager, "📱 Watch requested watch state data update.")
-                guard let self = self else { return }
                 // Skip if no watch is paired or app not installed
                 // Skip if no watch is paired or app not installed
                 guard let session = self.session, session.isPaired, session.isReachable,
                 guard let session = self.session, session.isPaired, session.isReachable,
                       session.isWatchAppInstalled else { return }
                       session.isWatchAppInstalled else { return }
@@ -573,19 +576,23 @@ final class BaseWatchManager: NSObject, WCSessionDelegate, Injectable, WatchMana
                 return
                 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")
                 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,
             } else if let carbsAmount = message[WatchMessageKeys.carbs] as? Int,
                       let timestamp = message[WatchMessageKeys.date] as? TimeInterval,
                       let timestamp = message[WatchMessageKeys.date] as? TimeInterval,
                       message[WatchMessageKeys.bolus] == nil
                       message[WatchMessageKeys.bolus] == nil
             {
             {
                 let date = Date(timeIntervalSince1970: timestamp)
                 let date = Date(timeIntervalSince1970: timestamp)
                 debug(.watchManager, "📱 Received carbs request from watch: \(carbsAmount)g at \(date)")
                 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,
             } else if let bolusAmount = message[WatchMessageKeys.bolus] as? Double,
                       let carbsAmount = message[WatchMessageKeys.carbs] as? Int,
                       let carbsAmount = message[WatchMessageKeys.carbs] as? Int,
                       let timestamp = message[WatchMessageKeys.date] as? TimeInterval
                       let timestamp = message[WatchMessageKeys.date] as? TimeInterval
@@ -595,11 +602,11 @@ final class BaseWatchManager: NSObject, WCSessionDelegate, Injectable, WatchMana
                     .watchManager,
                     .watchManager,
                     "📱 Received meal bolus combo request from watch: \(bolusAmount)U, \(carbsAmount)g at \(date)"
                     "📱 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 {
             } else {
                 debug(.watchManager, "📱 Invalid or incomplete data received from watch. Received:  \(message)")
                 debug(.watchManager, "📱 Invalid or incomplete data received from watch. Received:  \(message)")
                 // Acknowledge failure
                 // Acknowledge failure
-                self?.sendAcknowledgment(
+                self.sendAcknowledgment(
                     toWatch: false,
                     toWatch: false,
                     message: "Error! Invalid or incomplete data received from watch.",
                     message: "Error! Invalid or incomplete data received from watch.",
                     ackCode: .genericFailure
                     ackCode: .genericFailure
@@ -608,22 +615,22 @@ final class BaseWatchManager: NSObject, WCSessionDelegate, Injectable, WatchMana
 
 
             if message[WatchMessageKeys.cancelOverride] as? Bool == true {
             if message[WatchMessageKeys.cancelOverride] as? Bool == true {
                 debug(.watchManager, "📱 Received cancel override request from watch")
                 debug(.watchManager, "📱 Received cancel override request from watch")
-                self?.handleCancelOverride()
+                self.handleCancelOverride()
             }
             }
 
 
             if let presetName = message[WatchMessageKeys.activateOverride] as? String {
             if let presetName = message[WatchMessageKeys.activateOverride] as? String {
                 debug(.watchManager, "📱 Received activate override request from watch for preset: \(presetName)")
                 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 {
             if let presetName = message[WatchMessageKeys.activateTempTarget] as? String {
                 debug(.watchManager, "📱 Received activate temp target request from watch for preset: \(presetName)")
                 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 {
             if message[WatchMessageKeys.cancelTempTarget] as? Bool == true {
                 debug(.watchManager, "📱 Received cancel temp target request from watch")
                 debug(.watchManager, "📱 Received cancel temp target request from watch")
-                self?.handleCancelTempTarget()
+                self.handleCancelTempTarget()
             }
             }
 
 
             if message[WatchMessageKeys.requestBolusRecommendation] as? Bool == true {
             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 {
         if let logs = userInfo["watchLogs"] as? String {
             SimpleLogReporter.appendToWatchLog(logs)
             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)
     #if os(iOS)

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

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

+ 44 - 26
TrioTests/GlucoseSmoothingTests.swift

@@ -120,46 +120,64 @@ import Testing
     }
     }
 
 
     @Test(
     @Test(
-        "Exponential smoothing stops window at gaps >= 12 minutes; fallback fills smoothed glucose"
-    ) func testExponentialSmoothingGapStopsWindow() async throws {
-        // GIVEN:
+        "Exponential smoothing stops at gaps >= 12 minutes and only updates the most recent window"
+    )  func testExponentialSmoothingGapStopsWindow() async throws {
         let now = Date()
         let now = Date()
-        let dates: [Date] = [
-            now.addingTimeInterval(0), // oldest
-            now.addingTimeInterval(5 * 60),
-            now.addingTimeInterval(10 * 60),
-            now.addingTimeInterval(25 * 60), // gap of 15 minutes
-            now.addingTimeInterval(30 * 60),
-            now.addingTimeInterval(35 * 60) // newest
-        ]
-        let values: [Int16] = [100, 105, 110, 115, 120, 125]
+
+        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 createGlucoseSequence(values: values, dates: dates, isManual: false)
 
 
-        // WHEN
         await fetchGlucoseManager.exponentialSmoothingGlucose(context: testContext)
         await fetchGlucoseManager.exponentialSmoothingGlucose(context: testContext)
 
 
-        // THEN
         let ascending = try await fetchAndSortGlucose()
         let ascending = try await fetchAndSortGlucose()
-        #expect(ascending.count == 6)
+        #expect(ascending.count == values.count)
 
 
-        let smoothedValues = ascending
-            .filter { !$0.isManual }
-            .compactMap { $0.smoothedGlucose?.decimalValue }
-            .filter { $0 > 0 }
+        // Split into:
+        // - older block (before gap)
+        // - recent block (after gap)
+        let olderBlock = ascending.prefix(10)
+        let recentBlock = ascending.suffix(3)
 
 
-        #expect(
-            smoothedValues.count == 6,
-            "Fallback path should fill smoothedGlucose for all CGM entries when the gap reduces the window below minimum size."
-        )
+        // --- 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
+            }
 
 
-        for (index, smoothed) in smoothedValues.enumerated() {
             #expect(
             #expect(
                 smoothed >= 39,
                 smoothed >= 39,
-                "Fallback smoothed glucose must be clamped to >= 39, got \(smoothed) at index \(index)."
+                "Fallback smoothed glucose must be clamped to >= 39, got \(smoothed)."
             )
             )
+
             #expect(
             #expect(
                 smoothed == smoothed.rounded(toPlaces: 0),
                 smoothed == smoothed.rounded(toPlaces: 0),
-                "Fallback smoothed glucose must be rounded to an integer, got \(smoothed) at index \(index)."
+                "Fallback smoothed glucose must be rounded to integer, got \(smoothed)."
             )
             )
         }
         }
     }
     }