Przeglądaj źródła

Merge branch 'dev' of github.com:nightscout/Trio-dev into data-migration

Deniz Cengiz 1 rok temu
rodzic
commit
f81a6ff7c8
38 zmienionych plików z 22646 dodań i 2528 usunięć
  1. 1 1
      .github/ISSUE_TEMPLATE/feature-request.md
  2. 0 1
      .github/workflows/add_identifiers.yml
  3. 11 10
      .github/workflows/build_trio.yml
  4. 30 30
      .github/workflows/create_certs.yml
  5. 0 198
      FreeAPS/Sources/Application/FreeAPSApp.swift
  6. 40 36
      Gemfile.lock
  7. 2 3
      Model/Helper/PumpEvent+helper.swift
  8. 13 0
      Trio Watch App Extension/Helper/Helper+Enums.swift
  9. 9 0
      Trio Watch App Extension/TrioWatchApp.swift
  10. 3 3
      Trio Watch App Extension/Views/AcknowledgementPendingView.swift
  11. 0 10
      Trio Watch App Extension/Views/BolusConfirmationView.swift
  12. 0 9
      Trio Watch App Extension/Views/BolusInputView.swift
  13. 86 86
      Trio Watch App Extension/Views/BolusProgressOverlay.swift
  14. 1 13
      Trio Watch App Extension/Views/TrioMainWatchView.swift
  15. 150 0
      Trio Watch App Extension/WatchLogger.swift
  16. 143 45
      Trio Watch App Extension/WatchState+Requests.swift
  17. 140 165
      Trio Watch App Extension/WatchState.swift
  18. 39 0
      Trio Watch App Extension/WatchStateSnapshot.swift
  19. 12 0
      Trio.xcodeproj/project.pbxproj
  20. 1 1
      Trio.xcworkspace/xcshareddata/swiftpm/Package.resolved
  21. 9 3
      Trio/Sources/APS/APSManager.swift
  22. 2 3
      Trio/Sources/APS/Storage/OverrideStorage.swift
  23. 1 1
      Trio/Sources/APS/Storage/PumpHistoryStorage.swift
  24. 21454 1720
      Trio/Sources/Localizations/Main/Localizable.xcstrings
  25. 40 0
      Trio/Sources/Logger/IssueReporter/SimpleLogReporter.swift
  26. 2 0
      Trio/Sources/Models/WatchMessageKeys.swift
  27. 1 1
      Trio/Sources/Models/WatchState.swift
  28. 39 0
      Trio/Sources/Models/WatchStateSnapshot.swift
  29. 13 13
      Trio/Sources/Modules/AutosensSettings/View/AutosensSettingsRootView.swift
  30. 53 0
      Trio/Sources/Modules/Onboarding/OnboardingStateModel.swift
  31. 22 8
      Trio/Sources/Modules/Onboarding/View/OnboardingRootView.swift
  32. 13 1
      Trio/Sources/Modules/Onboarding/View/OnboardingSteps/AlgorithmSettings/AlgorithmSettingsSubstepView.swift
  33. 6 8
      Trio/Sources/Modules/Onboarding/View/OnboardingSteps/UnitSelectionStepView.swift
  34. 1 1
      Trio/Sources/Modules/Onboarding/View/OnboardingView+AlgorithmUtil.swift
  35. 1 1
      Trio/Sources/Modules/SMBSettings/View/SMBSettingsRootView.swift
  36. 243 156
      Trio/Sources/Services/WatchManager/AppleWatchManager.swift
  37. 1 1
      TrioTests/BolusCalculatorTests/BolusCalculatorTests.swift
  38. 64 0
      TrioTests/CoreDataTests/PumpHistoryStorageTests.swift

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

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

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

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

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

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

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

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

+ 0 - 198
FreeAPS/Sources/Application/FreeAPSApp.swift

@@ -1,198 +0,0 @@
-import ActivityKit
-import BackgroundTasks
-import CoreData
-import Foundation
-import SwiftUI
-import Swinject
-
-@main struct FreeAPSApp: App {
-    @Environment(\.scenePhase) var scenePhase
-
-    @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
-
-    // Read the color scheme preference from UserDefaults; defaults to system default setting
-    @AppStorage("colorSchemePreference") private var colorSchemePreference: ColorSchemeOption = .systemDefault
-
-    let coreDataStack = CoreDataStack.shared
-
-    // Dependencies Assembler
-    // contain all dependencies Assemblies
-    // TODO: Remove static key after update "Use Dependencies" logic
-    private static var assembler = Assembler([
-        StorageAssembly(),
-        ServiceAssembly(),
-        APSAssembly(),
-        NetworkAssembly(),
-        UIAssembly(),
-        SecurityAssembly()
-    ], parent: nil, defaultObjectScope: .container)
-
-    var resolver: Resolver {
-        FreeAPSApp.assembler.resolver
-    }
-
-    // Temp static var
-    // Use to backward compatibility with old Dependencies logic on Logger
-    // TODO: Remove var after update "Use Dependencies" logic in Logger
-    static var resolver: Resolver {
-        FreeAPSApp.assembler.resolver
-    }
-
-    private func loadServices() {
-        resolver.resolve(AppearanceManager.self)!.setupGlobalAppearance()
-        _ = resolver.resolve(DeviceDataManager.self)!
-        _ = resolver.resolve(APSManager.self)!
-        _ = resolver.resolve(FetchGlucoseManager.self)!
-        _ = resolver.resolve(FetchTreatmentsManager.self)!
-        _ = resolver.resolve(FetchAnnouncementsManager.self)!
-        _ = resolver.resolve(CalendarManager.self)!
-        _ = resolver.resolve(UserNotificationsManager.self)!
-        _ = resolver.resolve(WatchManager.self)!
-        _ = resolver.resolve(HealthKitManager.self)!
-        _ = resolver.resolve(BluetoothStateManager.self)!
-        _ = resolver.resolve(PluginManager.self)!
-        if #available(iOS 16.2, *) {
-            _ = resolver.resolve(LiveActivityBridge.self)!
-        }
-    }
-
-    init() {
-        debug(
-            .default,
-            "Trio Started: v\(Bundle.main.releaseVersionNumber ?? "")(\(Bundle.main.buildVersionNumber ?? "")) [buildDate: \(String(describing: BuildDetails.default.buildDate()))] [buildExpires: \(String(describing: BuildDetails.default.calculateExpirationDate()))]"
-        )
-        loadServices()
-
-        // Clear the persistentHistory and the NSManagedObjects that are older than 90 days every time the app starts
-        cleanupOldData()
-
-        migrateDataFromJSON()
-    }
-
-    func migrateDataFromJSON() {
-        Task {
-            let importer = JSONImporter(context: coreDataStack.newTaskContext())
-            async let importPumpHistory: () = importer.importPumpHistoryIfNeeded()
-            async let importCarbHistory: () = importer.importCarbHistoryIfNeeded()
-            async let importGlucoseHistory: () = importer.importGlucoseHistoryIfNeeded()
-            async let importDeterminationHistory: () = importer.importDeterminationHistoryIfNeeded()
-
-            await importPumpHistory
-            await importCarbHistory
-            await importGlucoseHistory
-            await importDeterminationHistory
-        }
-    }
-
-    var body: some Scene {
-        WindowGroup {
-            Main.RootView(resolver: resolver)
-                .preferredColorScheme(colorScheme(for: colorSchemePreference) ?? nil)
-                .environment(\.managedObjectContext, coreDataStack.persistentContainer.viewContext)
-                .environmentObject(Icons())
-                .onOpenURL(perform: handleURL)
-        }
-        .onChange(of: scenePhase) { _, newScenePhase in
-            debug(.default, "APPLICATION PHASE: \(newScenePhase)")
-
-            /// If the App goes to the background we should ensure that all the changes are saved from the viewContext to the Persistent Container
-            if newScenePhase == .background {
-                coreDataStack.save()
-            }
-        }
-        .backgroundTask(.appRefresh("com.openiaps.cleanup")) {
-            await scheduleDatabaseCleaning()
-            await cleanupOldData()
-        }
-    }
-
-    private func colorScheme(for colorScheme: ColorSchemeOption) -> ColorScheme? {
-        switch colorScheme {
-        case .systemDefault:
-            return nil // Uses the system theme.
-        case .light:
-            return .light
-        case .dark:
-            return .dark
-        }
-    }
-
-    func scheduleDatabaseCleaning() {
-        let request = BGAppRefreshTaskRequest(identifier: "com.openiaps.cleanup")
-        request.earliestBeginDate = .now.addingTimeInterval(7 * 24 * 60 * 60) // 7 days
-        do {
-            try BGTaskScheduler.shared.submit(request)
-            debugPrint("Task scheduled successfully")
-        } catch {
-            debugPrint("Failed to schedule tasks")
-        }
-    }
-
-    private func cleanupOldData() {
-        Task {
-            async let cleanupTokens: () = coreDataStack.cleanupPersistentHistoryTokens(before: Date.oneWeekAgo)
-            async let purgeData: () = purgeOldNSManagedObjects()
-
-            await cleanupTokens
-            try await purgeData
-        }
-    }
-
-    private func purgeOldNSManagedObjects() async throws {
-        async let glucoseDeletion: () = coreDataStack.batchDeleteOlderThan(GlucoseStored.self, dateKey: "date", days: 90)
-        async let pumpEventDeletion: () = coreDataStack.batchDeleteOlderThan(PumpEventStored.self, dateKey: "timestamp", days: 90)
-        async let bolusDeletion: () = coreDataStack.batchDeleteOlderThan(
-            parentType: PumpEventStored.self,
-            childType: BolusStored.self,
-            dateKey: "timestamp",
-            days: 90,
-            relationshipKey: "pumpEvent"
-        )
-        async let tempBasalDeletion: () = coreDataStack.batchDeleteOlderThan(
-            parentType: PumpEventStored.self,
-            childType: TempBasalStored.self,
-            dateKey: "timestamp",
-            days: 90,
-            relationshipKey: "pumpEvent"
-        )
-        async let determinationDeletion: () = coreDataStack
-            .batchDeleteOlderThan(OrefDetermination.self, dateKey: "deliverAt", days: 90)
-        async let batteryDeletion: () = coreDataStack.batchDeleteOlderThan(OpenAPS_Battery.self, dateKey: "date", days: 90)
-        async let carbEntryDeletion: () = coreDataStack.batchDeleteOlderThan(CarbEntryStored.self, dateKey: "date", days: 90)
-        async let forecastDeletion: () = coreDataStack.batchDeleteOlderThan(Forecast.self, dateKey: "date", days: 2)
-        async let forecastValueDeletion: () = coreDataStack.batchDeleteOlderThan(
-            parentType: Forecast.self,
-            childType: ForecastValue.self,
-            dateKey: "date",
-            days: 2,
-            relationshipKey: "forecast"
-        )
-        async let overrideDeletion: () = coreDataStack
-            .batchDeleteOlderThan(OverrideStored.self, dateKey: "date", days: 3, isPresetKey: "isPreset")
-        async let overrideRunDeletion: () = coreDataStack
-            .batchDeleteOlderThan(OverrideRunStored.self, dateKey: "startDate", days: 3)
-
-        // Await each task to ensure they are all completed
-        try await glucoseDeletion
-        try await pumpEventDeletion
-        try await bolusDeletion
-        try await tempBasalDeletion
-        try await determinationDeletion
-        try await batteryDeletion
-        try await carbEntryDeletion
-        try await forecastDeletion
-        try await forecastValueDeletion
-        try await overrideDeletion
-        try await overrideRunDeletion
-    }
-
-    private func handleURL(_ url: URL) {
-        let components = URLComponents(url: url, resolvingAgainstBaseURL: false)
-
-        switch components?.host {
-        case "device-select-resp":
-            resolver.resolve(NotificationCenter.self)!.post(name: .openFromGarminConnect, object: url)
-        default: break
-        }
-    }
-}

+ 40 - 36
Gemfile.lock

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

+ 12 - 0
Trio.xcodeproj/project.pbxproj

@@ -662,6 +662,9 @@
 		DDF847E62C5D66490049BB3B /* AddMealPresetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDF847E52C5D66490049BB3B /* AddMealPresetView.swift */; };
 		DDF847E82C5DABA30049BB3B /* WatchConfigAppleWatchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDF847E72C5DABA30049BB3B /* WatchConfigAppleWatchView.swift */; };
 		DDF847EA2C5DABAC0049BB3B /* WatchConfigGarminView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDF847E92C5DABAC0049BB3B /* WatchConfigGarminView.swift */; };
+		DDFF204A2DB29EF500AB8A96 /* WatchLogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDFF20492DB29EF500AB8A96 /* WatchLogger.swift */; };
+		DDFF204E2DB2C00B00AB8A96 /* WatchStateSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDFF204D2DB2C00B00AB8A96 /* WatchStateSnapshot.swift */; };
+		DDFF20502DB2C11900AB8A96 /* WatchStateSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDFF204F2DB2C11900AB8A96 /* WatchStateSnapshot.swift */; };
 		DDFF202F2DB1D14500AB8A96 /* NotificationPermissionStepView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDFF202E2DB1D14500AB8A96 /* NotificationPermissionStepView.swift */; };
 		DDFF20312DB1D15500AB8A96 /* BluetoothPermissionStepView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDFF20302DB1D15500AB8A96 /* BluetoothPermissionStepView.swift */; };
 		E00EEC0327368630002FF094 /* ServiceAssembly.swift in Sources */ = {isa = PBXBuildFile; fileRef = E00EEBFD27368630002FF094 /* ServiceAssembly.swift */; };
@@ -1463,6 +1466,9 @@
 		DDF847E52C5D66490049BB3B /* AddMealPresetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddMealPresetView.swift; sourceTree = "<group>"; };
 		DDF847E72C5DABA30049BB3B /* WatchConfigAppleWatchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchConfigAppleWatchView.swift; sourceTree = "<group>"; };
 		DDF847E92C5DABAC0049BB3B /* WatchConfigGarminView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchConfigGarminView.swift; sourceTree = "<group>"; };
+		DDFF20492DB29EF500AB8A96 /* WatchLogger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchLogger.swift; sourceTree = "<group>"; };
+		DDFF204D2DB2C00B00AB8A96 /* WatchStateSnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchStateSnapshot.swift; sourceTree = "<group>"; };
+		DDFF204F2DB2C11900AB8A96 /* WatchStateSnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchStateSnapshot.swift; sourceTree = "<group>"; };
 		DDFF202E2DB1D14500AB8A96 /* NotificationPermissionStepView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationPermissionStepView.swift; sourceTree = "<group>"; };
 		DDFF20302DB1D15500AB8A96 /* BluetoothPermissionStepView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BluetoothPermissionStepView.swift; sourceTree = "<group>"; };
 		E00EEBFD27368630002FF094 /* ServiceAssembly.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ServiceAssembly.swift; sourceTree = "<group>"; };
@@ -2290,6 +2296,7 @@
 		388E5A5925B6F0250019842D /* Models */ = {
 			isa = PBXGroup;
 			children = (
+				DDFF204F2DB2C11900AB8A96 /* WatchStateSnapshot.swift */,
 				DDEBB05B2D89E9050032305D /* TimeInRangeType.swift */,
 				3B2F77852D7E52ED005ED9FA /* TDD.swift */,
 				DD4FFF322D458EE600B6CFF9 /* GarminWatchState.swift */,
@@ -2931,6 +2938,8 @@
 		BDFF7A9C2D25FA730016C40C /* Trio Watch App Extension */ = {
 			isa = PBXGroup;
 			children = (
+				DDFF204D2DB2C00B00AB8A96 /* WatchStateSnapshot.swift */,
+				DDFF20492DB29EF500AB8A96 /* WatchLogger.swift */,
 				BDA25EE52D260D5800035F34 /* WatchState.swift */,
 				BDAE3FFF2D372BA8009C12B1 /* WatchState+Requests.swift */,
 				DD3A3CEC2D29CFBA00AE478E /* Helper */,
@@ -4070,6 +4079,7 @@
 				388E595C25AD948C0019842D /* TrioApp.swift in Sources */,
 				38FEF3FC2737E53800574A46 /* MainStateModel.swift in Sources */,
 				DD1745352C55AE7E00211FAC /* TargetBehavoirRootView.swift in Sources */,
+				DDFF20502DB2C11900AB8A96 /* WatchStateSnapshot.swift in Sources */,
 				5887527C2BD986E1008B081D /* OpenAPSBattery.swift in Sources */,
 				38569348270B5DFB0002C50D /* GlucoseSource.swift in Sources */,
 				CEE9A6582BBB418300EB5194 /* CalibrationsStateModel.swift in Sources */,
@@ -4597,6 +4607,7 @@
 				BD54A95C2D2808A300F9C1EE /* OverridePresetWatch.swift in Sources */,
 				BD54A9592D27FB7800F9C1EE /* OverridePresetsView.swift in Sources */,
 				BDA25F1E2D26D5DD00035F34 /* GlucoseChartView.swift in Sources */,
+				DDFF204E2DB2C00B00AB8A96 /* WatchStateSnapshot.swift in Sources */,
 				DD6F63CC2D27F615007D94CF /* TreatmentMenuView.swift in Sources */,
 				DD3A3CE72D29C93F00AE478E /* Helper+Extensions.swift in Sources */,
 				DD246F062D2836AA0027DDE0 /* GlucoseTrendView.swift in Sources */,
@@ -4608,6 +4619,7 @@
 				BDA25EE62D260D5E00035F34 /* WatchState.swift in Sources */,
 				BD04ECCE2D29952A008C5FEB /* BolusProgressOverlay.swift in Sources */,
 				DD09D5C92D29F3D0000D82C9 /* AcknowledgementPendingView.swift in Sources */,
+				DDFF204A2DB29EF500AB8A96 /* WatchLogger.swift in Sources */,
 			);
 			runOnlyForDeploymentPostprocessing = 0;
 		};

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

@@ -1,5 +1,5 @@
 {
-  "originHash" : "b10fee57248e5d754951672d55dd1e425fadd3089d06858aed6f0f5206be7e5c",
+  "originHash" : "89074a88ed67a58ecd7534519854c5a0928a4046d7c8a6123a7d70f27bf8b44d",
   "pins" : [
     {
       "identity" : "abseil-cpp-binary",

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

@@ -514,7 +514,10 @@ final class BaseAPSManager: APSManager, Injectable {
             return
         }
 
-        guard let pump = pumpManager else { return }
+        guard let pump = pumpManager else {
+            callback?(false, String(localized: "Error! Failed to enact bolus.", comment: "Error message for enacting a bolus"))
+            return
+        }
 
         let roundedAmount = pump.roundToSupportedBolusVolume(units: amount)
 
@@ -542,7 +545,7 @@ final class BaseAPSManager: APSManager, Injectable {
             }
             callback?(
                 false,
-                String(localized: "Error! Failed to enact bolus.", comment: "Error message for failing to enact a bolus")
+                String(localized: "Error! Bolus failed with error: \(error.localizedDescription)")
             )
         }
     }
@@ -559,7 +562,10 @@ final class BaseAPSManager: APSManager, Injectable {
             processError(APSError.pumpError(error))
             callback?(
                 false,
-                String(localized: "Error! Bolus cancellation failed.", comment: "Error message for canceling a bolus")
+                String(
+                    localized: "Error! Bolus cancellation failed with error: \(error.localizedDescription)",
+                    comment: "Error message for canceling a bolus"
+                )
             )
         }
         bolusReporter?.removeObserver(self)

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

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

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

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

Plik diff jest za duży
+ 21454 - 1720
Trio/Sources/Localizations/Main/Localizable.xcstrings


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

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

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

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

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

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

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

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

+ 13 - 13
Trio/Sources/Modules/AutosensSettings/View/AutosensSettingsRootView.swift

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

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

@@ -107,9 +107,20 @@ extension Onboarding {
 
         var hasReadAlgorithmSetupInformation: Bool = false
 
+        // Autosens Settings
         var autosensMin: Decimal = 0.7
         var autosensMax: Decimal = 1.2
         var rewindResetsAutosens: Bool = true
+
+        var filteredAutosensSettingsSubsteps: [AutosensSettingsSubstep] {
+            if pumpOptionForOnboardingUnits == .minimed || pumpOptionForOnboardingUnits == .dana {
+                return AutosensSettingsSubstep.allCases
+            } else {
+                return [AutosensSettingsSubstep.autosensMin, AutosensSettingsSubstep.autosensMax]
+            }
+        }
+
+        // SMB Settings
         var enableSMBAlways: Bool = false
         var enableSMBWithCOB: Bool = false
         var enableSMBWithTempTarget: Bool = false
@@ -121,6 +132,8 @@ extension Onboarding {
         var maxSMBMinutes: Decimal = 30
         var maxUAMMinutes: Decimal = 30
         var maxDeltaGlucoseThreshold: Decimal = 0.2
+
+        // Target Behavior
         var highTempTargetRaisesSensitivity: Bool = false
         var lowTempTargetLowersSensitivity: Bool = false
         var sensitivityRaisesTarget: Bool = false
@@ -182,6 +195,46 @@ extension Onboarding {
             return formatter
         }
 
+        /// Remaps therapy items affected by a glucose unit change (mg/dL vs mmol/L).
+        ///
+        /// This function updates glucose target and insulin sensitivity (ISF) items to use the closest valid index
+        /// from the newly available rate arrays, preserving the original value intent.
+        ///
+        /// Call this after the user changes the unit selection.
+        ///
+        /// See also: `UnitSelectionStepView` `.onChange()` handlers.
+        func remapTherapyItemsForChangedUnits() {
+            // Targets
+            targetItems = targetItems.map { item in
+                let newLowIndex = closestIndex(for: targetRateValues[item.lowIndex], in: targetRateValues)
+                let newTimeIndex = closestIndex(for: targetTimeValues[item.timeIndex], in: targetTimeValues)
+                return TargetsEditor.Item(lowIndex: newLowIndex, highIndex: newLowIndex, timeIndex: newTimeIndex)
+            }
+
+            // ISF
+            isfItems = isfItems.map { item in
+                let newRateIndex = closestIndex(for: isfRateValues[item.rateIndex], in: isfRateValues)
+                let newTimeIndex = closestIndex(for: isfTimeValues[item.timeIndex], in: isfTimeValues)
+                return ISFEditor.Item(rateIndex: newRateIndex, timeIndex: newTimeIndex)
+            }
+        }
+
+        /// Remaps therapy items affected by a pump model change.
+        ///
+        /// This function updates basal profile items to use the closest valid index
+        /// from the updated basal rate and time arrays, preserving the user's settings.
+        ///
+        /// Call this after the user selects a new pump model.
+        ///
+        /// See also: `UnitSelectionStepView` `.onChange()` handlers.
+        func remapTherapyItemsForChangedPumpModel() {
+            basalProfileItems = basalProfileItems.map { item in
+                let newRateIndex = closestIndex(for: basalProfileRateValues[item.rateIndex], in: basalProfileRateValues)
+                let newTimeIndex = closestIndex(for: basalProfileTimeValues[item.timeIndex], in: basalProfileTimeValues)
+                return BasalProfileEditor.Item(rateIndex: newRateIndex, timeIndex: newTimeIndex)
+            }
+        }
+
         // MARK: - Fetch existing therapy settings from file
 
         /// Loads existing therapy settings from the provider and maps them into UI editor items.

+ 22 - 8
Trio/Sources/Modules/Onboarding/View/OnboardingRootView.swift

@@ -80,7 +80,7 @@ extension Onboarding {
                                 stepsWithSubsteps: [
                                     .nightscout: NightscoutSubstep.allCases.count,
                                     .deliveryLimits: DeliveryLimitSubstep.allCases.count,
-                                    .autosensSettings: AutosensSettingsSubstep.allCases.count,
+                                    .autosensSettings: state.filteredAutosensSettingsSubsteps.count,
                                     .smbSettings: SMBSettingsSubstep.allCases.count,
                                     .targetBehavior: TargetBehaviorSubstep.allCases.count
                                 ],
@@ -431,11 +431,14 @@ struct OnboardingNavigationButtons: View {
             }
 
         case .autosensSettings:
-            if let previous = AutosensSettingsSubstep(rawValue: currentAutosensSubstep.rawValue - 1) {
-                currentAutosensSubstep = previous
+            let steps = state.filteredAutosensSettingsSubsteps
+            if let current = steps.firstIndex(of: currentAutosensSubstep),
+               current > 0
+            {
+                currentAutosensSubstep = steps[current - 1]
             } else if let previousStep = currentStep.previous {
                 currentStep = previousStep
-                currentAutosensSubstep = .autosensMin
+                currentAutosensSubstep = steps.first ?? .autosensMin
             }
 
         case .smbSettings:
@@ -451,7 +454,15 @@ struct OnboardingNavigationButtons: View {
             } else if let previousStep = currentStep.previous {
                 currentStep = previousStep
                 currentSMBSubstep = .enableSMBAlways
-                currentAutosensSubstep = .rewindResetsAutosens
+
+                switch state.pumpOptionForOnboardingUnits {
+                case .dana,
+                     .minimed:
+                    currentAutosensSubstep = .rewindResetsAutosens
+                case .omnipodDash,
+                     .omnipodEros:
+                    currentAutosensSubstep = .autosensMax
+                }
             }
 
         case .targetBehavior:
@@ -501,11 +512,14 @@ struct OnboardingNavigationButtons: View {
             }
 
         case .autosensSettings:
-            if let next = AutosensSettingsSubstep(rawValue: currentAutosensSubstep.rawValue + 1) {
-                currentAutosensSubstep = next
+            let steps = state.filteredAutosensSettingsSubsteps
+            if let current = steps.firstIndex(of: currentAutosensSubstep),
+               current + 1 < steps.count
+            {
+                currentAutosensSubstep = steps[current + 1]
             } else if let nextStep = currentStep.next {
                 currentStep = nextStep
-                currentAutosensSubstep = .autosensMin
+                currentAutosensSubstep = steps.first ?? .autosensMin
             }
 
         case .smbSettings:

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

@@ -16,6 +16,17 @@ struct AlgorithmSettingsSubstepView<Substep: AlgorithmSubstepProtocol & RawRepre
 
     private let settingsProvider = PickerSettingsProvider.shared
 
+    private var shouldDisableRewindResetsAutosens: Bool {
+        switch state.pumpOptionForOnboardingUnits {
+        case .dana,
+             .minimed:
+            return false
+        case .omnipodDash,
+             .omnipodEros:
+            return true
+        }
+    }
+
     var body: some View {
         VStack(alignment: .leading, spacing: 16) {
             Text(substep.title)
@@ -55,7 +66,8 @@ struct AlgorithmSettingsSubstepView<Substep: AlgorithmSubstepProtocol & RawRepre
                         setting: nil,
                         decimalValue: $decimalPlaceholder,
                         booleanValue: $state.rewindResetsAutosens,
-                        type: OnboardingInputSectionType.boolean
+                        type: OnboardingInputSectionType.boolean,
+                        disabled: shouldDisableRewindResetsAutosens
                     )
                 case .enableSMBAlways:
                     algorithmSettingsInput(

+ 6 - 8
Trio/Sources/Modules/Onboarding/View/OnboardingSteps/UnitSelectionStepView.swift

@@ -31,14 +31,12 @@ struct UnitSelectionStepView: View {
                     }
                 }
                 .onChange(of: state.pumpOptionForOnboardingUnits, { _, newValue in
-                    // Reset therapy settings and related values when pump model changes
-                    state.targetItems = []
-                    state.basalProfileItems = []
-                    state.carbRatioItems = []
-                    state.isfItems = []
-
-                    // Conditionally set rewind setting, if pump model is MDT
-                    state.rewindResetsAutosens = newValue == .minimed
+                    state.remapTherapyItemsForChangedPumpModel()
+                    // Conditionally set rewind setting, if pump model is Medtronic (.minimed) or Dana (i/RS)
+                    state.rewindResetsAutosens = (newValue == .minimed || newValue == .dana)
+                })
+                .onChange(of: state.units, { _, _ in
+                    state.remapTherapyItemsForChangedUnits()
                 })
             }
             .padding()

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

@@ -191,7 +191,7 @@ enum AlgorithmSettingsSubstep: Int, CaseIterable, Identifiable {
         case .rewindResetsAutosens:
             return VStack(alignment: .leading, spacing: 5) {
                 Text("Default: ON").bold().foregroundStyle(Color.primary)
-                Text("Medtronic Users Only").bold()
+                Text("Medtronic and Dana Users Only").bold()
                 VStack(alignment: .leading, spacing: 8) {
                     Text(
                         "This feature resets the Autosens Ratio to neutral when you rewind your pump on the assumption that this corresponds to a site change."

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

@@ -315,7 +315,7 @@ extension SMBSettings {
                         }
                         VStack(alignment: .leading, spacing: 10) {
                             Text(
-                                "Warning: Increasing this value above 60 minutes may impact Trio's ability to effectively zero temp and prevent lows."
+                                "Warning: Increasing this value above 90 minutes may impact Trio's ability to effectively zero temp and prevent lows."
                             ).bold()
                             Text("Note: UAM SMBs must be enabled to use this limit.")
                         }

+ 243 - 156
Trio/Sources/Services/WatchManager/AppleWatchManager.swift

@@ -77,7 +77,6 @@ final class BaseWatchManager: NSObject, WCSessionDelegate, Injectable, WatchMana
             .store(in: &subscriptions)
 
         registerHandlers()
-        subscribeToBolusProgress()
     }
 
     private func registerHandlers() {
@@ -396,30 +395,9 @@ final class BaseWatchManager: NSObject, WCSessionDelegate, Injectable, WatchMana
 
     // MARK: - Send to Watch
 
-    /// Sends the state of type WatchState to the connected Watch
-    /// - Parameter state: Current WatchState containing glucose data to be sent
-    @MainActor func sendDataToWatch(_ state: WatchState) async {
-        guard let session = session else { return }
-
-        guard session.isPaired else {
-            debug(.watchManager, "⌚️❌ No Watch is paired")
-            return
-        }
-
-        guard session.isWatchAppInstalled else {
-            debug(.watchManager, "⌚️❌ Trio Watch app is")
-            return
-        }
-
-        guard session.activationState == .activated else {
-            let activationStateString = "\(session.activationState)"
-            debug(.watchManager, "⌚️ Watch session activationState = \(activationStateString). Reactivating...")
-            session.activate()
-            return
-        }
-
-        let message: [String: Any] = [
-            WatchMessageKeys.date: Date().timeIntervalSince1970,
+    func watchStateToDictionary(from state: WatchState) -> [String: Any] {
+        [
+            WatchMessageKeys.date: state.date.timeIntervalSince1970,
             WatchMessageKeys.currentGlucose: state.currentGlucose ?? "--",
             WatchMessageKeys.currentGlucoseColorString: state.currentGlucoseColorString ?? "#ffffff",
             WatchMessageKeys.trend: state.trend ?? "",
@@ -453,8 +431,41 @@ final class BaseWatchManager: NSObject, WCSessionDelegate, Injectable, WatchMana
             WatchMessageKeys.maxFat: state.maxFat,
             WatchMessageKeys.maxProtein: state.maxProtein,
             WatchMessageKeys.bolusIncrement: state.bolusIncrement,
-            WatchMessageKeys.confirmBolusFaster: state.confirmBolusFaster
+            WatchMessageKeys.confirmBolusFaster: state.confirmBolusFaster,
+            WatchMessageKeys.units: state.units.rawValue
         ]
+    }
+
+    /// Sends the state of type WatchState to the connected Watch
+    /// - Parameter state: Current WatchState containing glucose data to be sent
+    @MainActor func sendDataToWatch(_ state: WatchState) async {
+        guard let session = session else { return }
+
+        guard session.isPaired else {
+            debug(.watchManager, "⌚️❌ No Watch is paired")
+            return
+        }
+
+        guard session.isWatchAppInstalled else {
+            debug(.watchManager, "⌚️❌ Trio Watch app is")
+            return
+        }
+
+        guard session.activationState == .activated else {
+            let activationStateString = "\(session.activationState)"
+            debug(.watchManager, "⌚️ Watch session activationState = \(activationStateString). Reactivating...")
+            session.activate()
+            return
+        }
+
+        // Skip if we already sent this state or older
+        let lastSent = WatchStateSnapshot.loadLatestDateFromDisk()
+        guard lastSent < state.date else {
+            debug(.watchManager, "🕐 Skipping push — newer or equal state already sent")
+            return
+        }
+
+        let message: [String: Any] = watchStateToDictionary(from: state)
 
         // if session is reachable, it means watch App is in the foreground -> send watchState as message
         // if session is not reachable, it means it's in background -> send watchState as userInfo
@@ -462,12 +473,15 @@ final class BaseWatchManager: NSObject, WCSessionDelegate, Injectable, WatchMana
             session.sendMessage([WatchMessageKeys.watchState: message], replyHandler: nil) { error in
                 debug(.watchManager, "❌ Error sending watch state: \(error.localizedDescription)")
             }
+            WatchStateSnapshot.saveLatestDateToDisk(state.date)
         } else {
+            WatchStateSnapshot.saveLatestDateToDisk(state.date)
             session.transferUserInfo([WatchMessageKeys.watchState: message])
+            debug(.watchManager, "📤 Transferred new WatchState snapshot via userInfo")
         }
     }
 
-    func sendAcknowledgment(toWatch success: Bool, message: String = "") {
+    func sendAcknowledgment(toWatch success: Bool, message: String = "", ackCode: AcknowledgmentCode) {
         guard let session = session, session.isReachable else {
             debug(.watchManager, "⌚️ Watch not reachable for acknowledgment")
             return
@@ -475,7 +489,8 @@ final class BaseWatchManager: NSObject, WCSessionDelegate, Injectable, WatchMana
 
         let ackMessage: [String: Any] = [
             WatchMessageKeys.acknowledged: success,
-            WatchMessageKeys.message: message
+            WatchMessageKeys.message: message,
+            WatchMessageKeys.ackCode: ackCode.rawValue
         ]
 
         session.sendMessage(ackMessage, replyHandler: nil) { error in
@@ -503,7 +518,10 @@ final class BaseWatchManager: NSObject, WCSessionDelegate, Injectable, WatchMana
 
     func session(_: WCSession, didReceiveMessage message: [String: Any]) {
         DispatchQueue.main.async { [weak self] in
-            // Check Watch State Update Request first
+            if let logs = message["watchLogs"] as? String {
+                SimpleLogReporter.appendToWatchLog(logs)
+            }
+
             if let requestWatchUpdate = message[WatchMessageKeys.requestWatchUpdate] as? String,
                requestWatchUpdate == WatchMessageKeys.watchState
             {
@@ -542,6 +560,12 @@ final class BaseWatchManager: NSObject, WCSessionDelegate, Injectable, WatchMana
                 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(
+                    toWatch: false,
+                    message: "Error! Invalid or incomplete data received from watch.",
+                    ackCode: .genericFailure
+                )
             }
 
             if message[WatchMessageKeys.cancelOverride] as? Bool == true {
@@ -564,20 +588,6 @@ final class BaseWatchManager: NSObject, WCSessionDelegate, Injectable, WatchMana
                 self?.handleCancelTempTarget()
             }
 
-            // Handle bolus cancellation
-            if message[WatchMessageKeys.cancelBolus] as? Bool == true {
-                Task {
-                    await self?.apsManager.cancelBolus { [self] success, message in
-                        // Acknowledge success or error of bolus
-                        self?.sendAcknowledgment(toWatch: success, message: message)
-                    }
-                    debug(.watchManager, "📱 Bolus cancelled from watch")
-
-                    // perform determine basal sync, otherwise you could end up with too much IOB when opening the calculator again
-                    try await self?.apsManager.determineBasalSync()
-                }
-            }
-
             if message[WatchMessageKeys.requestBolusRecommendation] as? Bool == true {
                 let carbs = message[WatchMessageKeys.carbs] as? Int ?? 0
 
@@ -598,7 +608,7 @@ final class BaseWatchManager: NSObject, WCSessionDelegate, Injectable, WatchMana
                     ]
 
                     if let session = self.session, session.isReachable {
-                        print("📱 Sending recommendedBolus: \(result.insulinCalculated)")
+                        debug(.watchManager, "📱 Sending recommendedBolus: \(result.insulinCalculated)")
                         session.sendMessage(recommendationMessage, replyHandler: nil)
                     }
                 }
@@ -607,6 +617,12 @@ final class BaseWatchManager: NSObject, WCSessionDelegate, Injectable, WatchMana
         }
     }
 
+    func session(_: WCSession, didReceiveUserInfo userInfo: [String: Any] = [:]) {
+        if let logs = userInfo["watchLogs"] as? String {
+            SimpleLogReporter.appendToWatchLog(logs)
+        }
+    }
+
     #if os(iOS)
         func sessionDidBecomeInactive(_: WCSession) {}
         func sessionDidDeactivate(_ session: WCSession) {
@@ -637,7 +653,11 @@ final class BaseWatchManager: NSObject, WCSessionDelegate, Injectable, WatchMana
         Task {
             await apsManager.enactBolus(amount: Double(amount), isSMB: false) { success, message in
                 // Acknowledge success or error of bolus
-                self.sendAcknowledgment(toWatch: success, message: message)
+                self.sendAcknowledgment(
+                    toWatch: success,
+                    message: message,
+                    ackCode: success == true ? .genericSuccess : .genericFailure
+                )
             }
             debug(.watchManager, "📱 Enacted bolus via APS Manager: \(amount)U")
         }
@@ -661,7 +681,15 @@ final class BaseWatchManager: NSObject, WCSessionDelegate, Injectable, WatchMana
                 carbEntry.isUploadedToNS = false
 
                 do {
-                    guard context.hasChanges else { return }
+                    guard context.hasChanges else {
+                        // Acknowledge failure
+                        self.sendAcknowledgment(
+                            toWatch: false,
+                            message: "Error! Something went wrong when processing your request.",
+                            ackCode: .genericFailure
+                        )
+                        return
+                    }
                     try context.save()
                     debug(.watchManager, "📱 Saved carbs from watch: \(amount)g at \(date)")
 
@@ -671,13 +699,14 @@ final class BaseWatchManager: NSObject, WCSessionDelegate, Injectable, WatchMana
                         message: String(
                             localized: "Carbs logged successfully.",
                             comment: "Success message sent to watch when carbs are logged successfully"
-                        )
+                        ),
+                        ackCode: .carbsLogged
                     )
                 } catch {
                     debug(.watchManager, "❌ Error saving carbs: \(error.localizedDescription)")
 
                     // Acknowledge failure
-                    self.sendAcknowledgment(toWatch: false, message: "Error logging carbs")
+                    self.sendAcknowledgment(toWatch: false, message: "Error logging carbs", ackCode: .genericFailure)
                 }
             }
         }
@@ -696,7 +725,8 @@ final class BaseWatchManager: NSObject, WCSessionDelegate, Injectable, WatchMana
                 // Notify Watch: "Saving carbs..."
                 self.sendAcknowledgment(
                     toWatch: true,
-                    message: String(localized: "Saving Carbs...", comment: "Successful message sent to watch when saving carbs")
+                    message: String(localized: "Saving Carbs...", comment: "Successful message sent to watch when saving carbs"),
+                    ackCode: .savingCarbs
                 )
 
                 // Save carbs entry in Core Data
@@ -709,7 +739,15 @@ final class BaseWatchManager: NSObject, WCSessionDelegate, Injectable, WatchMana
                     carbEntry.isFPU = false // set this to false to ensure watch-entered carbs are displayed in main chart
                     carbEntry.isUploadedToNS = false
 
-                    guard context.hasChanges else { return }
+                    guard context.hasChanges else {
+                        // Acknowledge failure
+                        self.sendAcknowledgment(
+                            toWatch: false,
+                            message: "Error! Something went wrong when processing your request.",
+                            ackCode: .genericFailure
+                        )
+                        return
+                    }
                     try context.save()
                     debug(.watchManager, "📱 Saved carbs from watch: \(carbsAmount) g at \(date)")
                 }
@@ -720,14 +758,19 @@ final class BaseWatchManager: NSObject, WCSessionDelegate, Injectable, WatchMana
                     message: String(
                         localized: "Enacting bolus...",
                         comment: "Successful message sent to watch when enacting bolus"
-                    )
+                    ),
+                    ackCode: .enactingBolus
                 )
 
                 // Enact bolus via APS Manager
                 let bolusDouble = NSDecimalNumber(decimal: bolusAmount).doubleValue
                 await apsManager.enactBolus(amount: bolusDouble, isSMB: false) { success, message in
                     // Acknowledge success or error of bolus
-                    self.sendAcknowledgment(toWatch: success, message: message)
+                    self.sendAcknowledgment(
+                        toWatch: success,
+                        message: message,
+                        ackCode: success == true ? .genericSuccess : .genericFailure
+                    )
                 }
                 debug(.watchManager, "📱 Enacted bolus from watch via APS Manager: \(bolusDouble) U")
                 // Notify Watch: "Carbs and bolus logged successfully"
@@ -736,12 +779,13 @@ final class BaseWatchManager: NSObject, WCSessionDelegate, Injectable, WatchMana
                     message: String(
                         localized: "Carbs and Bolus logged successfully.",
                         comment: "Successful message sent to watch when logging carbs and bolus"
-                    )
+                    ),
+                    ackCode: .comboComplete
                 )
 
             } catch {
                 debug(.watchManager, "❌ Error processing combined request: \(error.localizedDescription)")
-                sendAcknowledgment(toWatch: false, message: "Failed to log carbs and bolus")
+                sendAcknowledgment(toWatch: false, message: "Failed to log carbs and bolus", ackCode: .genericFailure)
             }
         }
     }
@@ -760,7 +804,15 @@ final class BaseWatchManager: NSObject, WCSessionDelegate, Injectable, WatchMana
                         activeOverride.enabled = false
 
                         do {
-                            guard context.hasChanges else { return }
+                            guard context.hasChanges else {
+                                // Acknowledge failure
+                                self.sendAcknowledgment(
+                                    toWatch: false,
+                                    message: "Error! Something went wrong when processing your request.",
+                                    ackCode: .genericFailure
+                                )
+                                return
+                            }
                             try context.save()
                             debug(.watchManager, "📱 Successfully stopped override")
 
@@ -771,14 +823,26 @@ final class BaseWatchManager: NSObject, WCSessionDelegate, Injectable, WatchMana
                             )
 
                             // Acknowledge cancellation success
-                            self.sendAcknowledgment(toWatch: true, message: "Stopped Override successfully.")
+                            self.sendAcknowledgment(
+                                toWatch: true,
+                                message: "Stopped Override successfully.",
+                                ackCode: .overrideStopped
+                            )
                         } catch {
                             debug(.watchManager, "❌ Error cancelling override: \(error.localizedDescription)")
                             // Acknowledge cancellation error
-                            self.sendAcknowledgment(toWatch: false, message: "Error stopping Override.")
+                            self.sendAcknowledgment(toWatch: false, message: "Error stopping Override.", ackCode: .genericFailure)
                         }
                     }
                 }
+            } else {
+                debug(.watchManager, "❌ No active override found.")
+                self.sendAcknowledgment(
+                    toWatch: false,
+                    message: "No active override found.",
+                    ackCode: .genericFailure
+                )
+                return
             }
         }
     }
@@ -787,49 +851,91 @@ final class BaseWatchManager: NSObject, WCSessionDelegate, Injectable, WatchMana
         Task {
             let context = CoreDataStack.shared.newTaskContext()
 
+            debug(.watchManager, "📱 Fetching all override presets...")
+
             // Fetch all presets to find the one to activate
             let presetIds = try await overrideStorage.fetchForOverridePresets()
             let presets: [OverrideStored] = try await CoreDataStack.shared
                 .getNSManagedObject(with: presetIds, context: context)
 
-            // Check for active override
-            if let activeOverrideId = try await overrideStorage.fetchLatestActiveOverride() {
-                let activeOverride = await context.perform {
-                    context.object(with: activeOverrideId) as? OverrideStored
-                }
+            debug(.watchManager, "📱 Checking for active override...")
 
-                // Deactivate if exists
-                if let override = activeOverride {
-                    await context.perform {
-                        override.enabled = false
+            do {
+                // Check for active override
+                if let activeOverrideId = try await overrideStorage.fetchLatestActiveOverride() {
+                    let activeOverride = await context.perform {
+                        context.object(with: activeOverrideId) as? OverrideStored
                     }
+
+                    // Deactivate, if necessary
+                    if let override = activeOverride {
+                        await context.perform {
+                            override.enabled = false
+                        }
+                    }
+                } else {
+                    debug(.watchManager, "📱 Currently no override is active... proceeding to activate override: \(presetName)")
                 }
+            } catch {
+                debug(.watchManager, "❌ Error while checking for active override: \(error.localizedDescription)")
+                self.sendAcknowledgment(
+                    toWatch: false,
+                    message: "Failed to load active override.",
+                    ackCode: .genericFailure
+                )
+                return
             }
 
             // Activate the selected preset
             await context.perform {
-                if let presetToActivate = presets.first(where: { $0.name == presetName }) {
-                    presetToActivate.enabled = true
-                    presetToActivate.date = Date()
+                guard let presetToActivate = presets
+                    .first(where: { $0.name?.trimmingCharacters(in: .whitespacesAndNewlines) == presetName })
+                else {
+                    debug(.watchManager, "❌ No matching preset found for name: \"\(presetName)\" in \(presets.map(\.name))")
+                    self.sendAcknowledgment(
+                        toWatch: false,
+                        message: "Preset not found: \(presetName)",
+                        ackCode: .genericFailure
+                    )
+                    return
+                }
 
-                    do {
-                        guard context.hasChanges else { return }
-                        try context.save()
-                        debug(.watchManager, "📱 Successfully activated override: \(presetName)")
+                presetToActivate.enabled = true
+                presetToActivate.date = Date()
 
-                        // Send notification to update Adjustments UI
-                        Foundation.NotificationCenter.default.post(
-                            name: .didUpdateOverrideConfiguration,
-                            object: nil
+                do {
+                    guard context.hasChanges else {
+                        // Acknowledge failure
+                        self.sendAcknowledgment(
+                            toWatch: false,
+                            message: "Error! Something went wrong when processing your request.",
+                            ackCode: .genericFailure
                         )
-
-                        // Acknowledge activation success
-                        self.sendAcknowledgment(toWatch: true, message: "Started Override \"\(presetName)\" successfully.")
-                    } catch {
-                        debug(.watchManager, "❌ Error activating override: \(error.localizedDescription)")
-                        // Acknowledge activation error
-                        self.sendAcknowledgment(toWatch: false, message: "Error activating Override \"\(presetName)\".")
+                        return
                     }
+                    try context.save()
+                    debug(.watchManager, "📱 Successfully activated override: \(presetName)")
+
+                    // Send notification to update Adjustments UI
+                    Foundation.NotificationCenter.default.post(
+                        name: .didUpdateOverrideConfiguration,
+                        object: nil
+                    )
+
+                    // Acknowledge activation success
+                    self.sendAcknowledgment(
+                        toWatch: true,
+                        message: "Started Override \"\(presetName)\" successfully.",
+                        ackCode: .overrideStarted
+                    )
+                } catch {
+                    debug(.watchManager, "❌ Error activating override: \(error.localizedDescription)")
+                    // Acknowledge activation error
+                    self.sendAcknowledgment(
+                        toWatch: false,
+                        message: "Error activating Override \"\(presetName)\".",
+                        ackCode: .genericFailure
+                    )
                 }
             }
         }
@@ -865,7 +971,15 @@ final class BaseWatchManager: NSObject, WCSessionDelegate, Injectable, WatchMana
                     presetToActivate.date = Date()
 
                     do {
-                        guard context.hasChanges else { return }
+                        guard context.hasChanges else {
+                            // Acknowledge failure
+                            self.sendAcknowledgment(
+                                toWatch: false,
+                                message: "Error! Something went wrong when processing your request.",
+                                ackCode: .genericFailure
+                            )
+                            return
+                        }
                         try context.save()
                         debug(.watchManager, "📱 Successfully activated temp target: \(presetName)")
 
@@ -897,11 +1011,19 @@ final class BaseWatchManager: NSObject, WCSessionDelegate, Injectable, WatchMana
                         )
 
                         // Acknowledge activation success
-                        self.sendAcknowledgment(toWatch: true, message: "Started Temp Target \"\(presetName)\" successfully.")
+                        self.sendAcknowledgment(
+                            toWatch: true,
+                            message: "Started Temp Target \"\(presetName)\" successfully.",
+                            ackCode: .tempTargetStarted
+                        )
                     } catch {
                         debug(.watchManager, "❌ Error activating temp target: \(error.localizedDescription)")
                         // Acknowledge activation error
-                        self.sendAcknowledgment(toWatch: false, message: "Error activating Temp Target \"\(presetName)\".")
+                        self.sendAcknowledgment(
+                            toWatch: false,
+                            message: "Error activating Temp Target \"\(presetName)\".",
+                            ackCode: .genericFailure
+                        )
                     }
                 }
             }
@@ -922,7 +1044,15 @@ final class BaseWatchManager: NSObject, WCSessionDelegate, Injectable, WatchMana
                         activeTempTarget.enabled = false
 
                         do {
-                            guard context.hasChanges else { return }
+                            guard context.hasChanges else {
+                                // Acknowledge failure
+                                self.sendAcknowledgment(
+                                    toWatch: false,
+                                    message: "Error! Something went wrong when processing your request.",
+                                    ackCode: .genericFailure
+                                )
+                                return
+                            }
                             try context.save()
                             debug(.watchManager, "📱 Successfully cancelled temp target")
 
@@ -936,83 +1066,25 @@ final class BaseWatchManager: NSObject, WCSessionDelegate, Injectable, WatchMana
                             )
 
                             // Acknowledge cancellation success
-                            self.sendAcknowledgment(toWatch: true, message: "Stopped Temp Target successfully.")
+                            self.sendAcknowledgment(
+                                toWatch: true,
+                                message: "Stopped Temp Target successfully.",
+                                ackCode: .tempTargetStopped
+                            )
                         } catch {
                             debug(.watchManager, "❌ Error stopping temp target: \(error.localizedDescription)")
                             // Acknowledge cancellation error
-                            self.sendAcknowledgment(toWatch: false, message: "Error stopping Temp Target.")
+                            self.sendAcknowledgment(
+                                toWatch: false,
+                                message: "Error stopping Temp Target.",
+                                ackCode: .genericFailure
+                            )
                         }
                     }
                 }
             }
         }
     }
-
-    /// Subscribes to bolus progress updates and sends progress or cancellation messages to the Watch
-    private func subscribeToBolusProgress() {
-        var wasBolusActive = false
-
-        apsManager.bolusProgress
-            .receive(on: DispatchQueue.main)
-            .sink { [weak self] progress in
-                if let progress = progress {
-                    wasBolusActive = true
-                    Task {
-                        await self?.sendBolusProgressToWatch(progress: progress)
-                    }
-                } else if wasBolusActive {
-                    // Only if a bolus was previously active and now nil is received,
-                    // the bolus was cancelled
-                    wasBolusActive = false
-                    self?.activeBolusAmount = 0.0
-
-                    debug(.watchManager, "📱 Bolus cancelled from phone")
-                    self?.sendBolusCanceledMessageToWatch()
-                }
-            }
-            .store(in: &subscriptions)
-    }
-
-    /// Sends bolus progress updates to the Watch
-    /// - Parameter progress: The current bolus progress as a Decimal
-    private func sendBolusProgressToWatch(progress: Decimal?) async {
-        guard let session = session, let progress = progress, let pumpManager = apsManager.pumpManager else { return }
-
-        let message: [String: Any] = [
-            WatchMessageKeys.bolusProgressTimestamp: Date().timeIntervalSince1970,
-            WatchMessageKeys.bolusProgress: Double(truncating: progress as NSNumber),
-            WatchMessageKeys.activeBolusAmount: activeBolusAmount,
-            WatchMessageKeys.deliveredAmount: pumpManager
-                .roundToSupportedBolusVolume(units: activeBolusAmount * Double(truncating: progress as NSNumber))
-        ]
-        // If the session is not yet activated, try to activate
-        if session.activationState != .activated {
-            session.activate()
-            // Then, queue data for eventual delivery in the background
-            session.transferUserInfo(message)
-            return
-        }
-
-        // If we reach here, session should be .activated
-        if session.isReachable {
-            // Real-time ephemeral
-            session.sendMessage(message, replyHandler: nil) { error in
-                debug(.watchManager, "❌ Error sending bolus progress: \(error.localizedDescription)")
-            }
-        } else {
-            // Fallback to be double safe: queue userInfo for eventual delivery
-            session.transferUserInfo(message)
-        }
-    }
-
-    private func sendBolusCanceledMessageToWatch() {
-        if let session = session, session.isReachable {
-            let message: [String: Any] = [WatchMessageKeys.bolusCanceled: true]
-            session.sendMessage(message, replyHandler: nil) { error in
-                debug(.watchManager, "❌ Error sending bolus cancellation to watch: \(error.localizedDescription)")
-            }
-        }
-    }
 }
 
 // TODO: - is there a better approach than setting up the watch state every time a setting has changed?
@@ -1087,3 +1159,18 @@ extension BaseWatchManager {
         return nil
     }
 }
+
+extension BaseWatchManager {
+    enum AcknowledgmentCode: String, Codable {
+        case savingCarbs = "saving_carbs"
+        case enactingBolus = "enacting_bolus"
+        case comboComplete = "combo_complete"
+        case carbsLogged = "carbs_logged"
+        case overrideStarted = "override_started"
+        case overrideStopped = "override_stopped"
+        case tempTargetStarted = "temp_target_started"
+        case tempTargetStopped = "temp_target_stopped"
+        case genericSuccess = "success"
+        case genericFailure = "failure"
+    }
+}

+ 1 - 1
TrioTests/BolusCalculatorTests/BolusCalculatorTests.swift

@@ -340,7 +340,7 @@ import Testing
             carbs: carbs,
             useFattyMealCorrection: false,
             useSuperBolus: false,
-            minPredBG: minPredBG,
+            minPredBG: minPredBG
         )
 
         // Then

+ 64 - 0
TrioTests/CoreDataTests/PumpHistoryStorageTests.swift

@@ -286,4 +286,68 @@ import Testing
         #expect(fetchedEvent?.isUploadedToHealth == false, "Should not be uploaded to Health")
         #expect(fetchedEvent?.isUploadedToTidepool == false, "Should not be uploaded to Tidepool")
     }
+
+    @Test("Test duplicates in PumpHistoryStorage") func testDuplicatePumpEvents() async throws {
+        // Given
+        let date = Date()
+        let twoHoursAgo = date - 2.hours.timeInterval
+        let oneMinuteAgo = date - 1.minutes.timeInterval
+
+        // Get initial entries to compare to final entries later
+        let initialEntries = try await testContext.perform {
+            try testContext.fetch(PumpEventStored.fetchRequest())
+        }
+
+        // Create two suspend events and two resume events
+        let events: [LoopKit.NewPumpEvent] = [
+            LoopKit.NewPumpEvent(
+                date: twoHoursAgo,
+                dose: nil,
+                raw: Data(),
+                title: "Test Suspend",
+                type: .suspend
+            ),
+            LoopKit.NewPumpEvent(
+                date: twoHoursAgo,
+                dose: nil,
+                raw: Data(),
+                title: "Test Suspend",
+                type: .suspend
+            ),
+            LoopKit.NewPumpEvent(
+                date: oneMinuteAgo,
+                dose: nil,
+                raw: Data(),
+                title: "Test Resume",
+                type: .resume
+            ),
+            LoopKit.NewPumpEvent(
+                date: oneMinuteAgo,
+                dose: nil,
+                raw: Data(),
+                title: "Test Resume",
+                type: .resume
+            )
+        ]
+
+        // When
+        // Store in our in-memory PumphistoryStorage
+        try await storage.storePumpEvents(events)
+
+        // Then
+        // Fetch all events after storing
+        let finalEntriesUnsorted = try await testContext.perform {
+            try testContext.fetch(PumpEventStored.fetchRequest())
+        }
+        let finalEntries = finalEntriesUnsorted.sorted { $0.timestamp! < $1.timestamp! }
+
+        // Verify there were no initial entries
+        #expect(initialEntries.isEmpty, "There should be no initial entries")
+
+        // Verify count increased by 2
+        #expect(finalEntries.count == initialEntries.count + 2, "Should have added 2 new events")
+
+        #expect(finalEntries.first?.type == PumpEvent.pumpSuspend.rawValue)
+        #expect(finalEntries.last?.type == PumpEvent.pumpResume.rawValue)
+    }
 }