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

Merge branch 'nightscout:dev' into expire-date-in-profile

Jonas Björkert 1 год назад
Родитель
Сommit
68fda79814
52 измененных файлов с 863 добавлено и 584 удалено
  1. 1 1
      G7SensorKit
  2. 1 1
      LibreTransmitter
  3. 43 0
      Model/CoreDataInitializationCoordinator.swift
  4. 100 32
      Model/CoreDataStack.swift
  5. 1 1
      OmniKit
  6. 1 1
      RileyLinkKit
  7. 20 12
      Trio.xcodeproj/project.pbxproj
  8. BIN
      Trio/Resources/Assets.xcassets/app_icon_images/trioCircledNoBackground.imageset/ComplicationIcon.png
  9. 12 0
      Trio/Resources/Assets.xcassets/app_icon_images/trioCircledNoBackground.imageset/Contents.json
  10. BIN
      Trio/Resources/Assets.xcassets/app_icons/trioCircledNoBackground.imageset/ComplicationIcon.png
  11. 12 0
      Trio/Resources/Assets.xcassets/app_icons/trioCircledNoBackground.imageset/Contents.json
  12. 6 1
      Trio/Resources/Info.plist
  13. 0 1
      Trio/Resources/json/defaults/freeaps/freeaps_settings.json
  14. 3 1
      Trio/Sources/APS/CGM/PluginSource.swift
  15. 9 6
      Trio/Sources/APS/FetchGlucoseManager.swift
  16. 3 3
      Trio/Sources/Application/AppDelegate.swift
  17. 94 24
      Trio/Sources/Application/TrioApp.swift
  18. 0 56
      Trio/Sources/Helpers/CustomDatePicker.swift
  19. 1 1
      Trio/Sources/Helpers/CustomProgressView.swift
  20. 21 0
      Trio/Sources/Helpers/TherapySettingsUtil.swift
  21. 19 1
      Trio/Sources/Localizations/Main/Localizable.xcstrings
  22. 1 0
      Trio/Sources/Models/Icons.swift
  23. 0 5
      Trio/Sources/Models/TrioSettings.swift
  24. 0 4
      Trio/Sources/Modules/Base/BaseStateModel.swift
  25. 6 2
      Trio/Sources/Modules/CGMSettings/CGMSettingsStateModel.swift
  26. 20 1
      Trio/Sources/Modules/Calibrations/View/CalibrationsRootView.swift
  27. 11 3
      Trio/Sources/Modules/DataTable/View/CarbEntryEditorView.swift
  28. 10 6
      Trio/Sources/Modules/DataTable/View/DataTableRootView.swift
  29. 0 2
      Trio/Sources/Modules/GlucoseNotificationSettings/GlucoseNotificationSettingsStateModel.swift
  30. 0 22
      Trio/Sources/Modules/GlucoseNotificationSettings/View/GlucoseNotificationSettingsRootView.swift
  31. 9 8
      Trio/Sources/Modules/Home/HomeStateModel.swift
  32. 41 16
      Trio/Sources/Modules/Home/View/Chart/ChartElements/GlucoseChartView.swift
  33. 15 13
      Trio/Sources/Modules/Home/View/Header/LoopStatusView.swift
  34. 91 0
      Trio/Sources/Modules/Main/View/MainLoadingView.swift
  35. 3 2
      Trio/Sources/Modules/ManualTempBasal/View/ManualTempBasalRootView.swift
  36. 0 1
      Trio/Sources/Modules/Settings/SettingItems.swift
  37. 0 1
      Trio/Sources/Modules/Snooze/View/SnoozeRootView.swift
  38. 3 22
      Trio/Sources/Modules/Treatments/TreatmentsStateModel.swift
  39. 13 3
      Trio/Sources/Modules/Treatments/View/MealPreset/AddMealPresetView.swift
  40. 8 2
      Trio/Sources/Modules/Treatments/View/TreatmentsRootView.swift
  41. 3 22
      Trio/Sources/Services/BolusCalculator/BolusCalculationManager.swift
  42. 9 14
      Trio/Sources/Services/ContactImage/ContactImageManager.swift
  43. 3 6
      Trio/Sources/Services/Network/TidepoolManager.swift
  44. 0 26
      Trio/Sources/Services/UserNotifications/UserNotificationsManager.swift
  45. 3 6
      Trio/Sources/Services/WatchManager/AppleWatchManager.swift
  46. 227 221
      Trio/Sources/Views/TextFieldWithToolBar.swift
  47. 8 7
      TrioTests/CoreDataTests/CarbsStorageTests.swift
  48. 4 3
      TrioTests/CoreDataTests/DeterminationStorageTests.swift
  49. 10 9
      TrioTests/CoreDataTests/GlucoseStorageTests.swift
  50. 7 6
      TrioTests/CoreDataTests/OverrideStorageTests.swift
  51. 4 3
      TrioTests/CoreDataTests/PumpHistoryStorageTests.swift
  52. 7 6
      TrioTests/CoreDataTests/TempTargetStorageTests.swift

+ 1 - 1
G7SensorKit

@@ -1 +1 @@
-Subproject commit 205054e7537723c2aec58d807634b4853f687244
+Subproject commit bdfcfe83fbb9fab515a2456a4be9991420ed44bb

+ 1 - 1
LibreTransmitter

@@ -1 +1 @@
-Subproject commit a230b91a3d30c7b0d4ffbd240234b34cbaf354b1
+Subproject commit 044cf70bd79813d47048291b740a599e1ab4ab40

+ 43 - 0
Model/CoreDataInitializationCoordinator.swift

@@ -0,0 +1,43 @@
+/// This actor provides us with logic to handle cases when a caller
+/// tries to initialize a coreDataStack that is already initialized.
+actor CoreDataInitializationCoordinator {
+    private var isInitialized = false
+    private var initializationTask: Task<Void, Error>?
+
+    /// Ensures that initialization only happens once and manages multiple concurrent initialization requests.
+    /// This actor provides synchronization for the CoreDataStack initialization process.
+    ///
+    /// - Parameters:
+    ///   - initialization: A closure that performs the actual initialization work.
+    /// - Throws: Any error that might occur during initialization.
+    /// - Returns: Void once initialization is complete.
+    func ensureInitialized(perform initialization: @escaping () async throws -> Void) async throws {
+        // If already initialized, return immediately
+        if isInitialized {
+            return
+        }
+
+        // If initialization is in progress, await the existing task
+        if let existingTask = initializationTask {
+            try await existingTask.value
+            return
+        }
+
+        // Start a new initialization task
+        let newTask = Task {
+            do {
+                try await initialization()
+                isInitialized = true
+            } catch {
+                // Clear task reference on failure
+                initializationTask = nil
+                throw error
+            }
+            // Clear task reference on success
+            initializationTask = nil
+        }
+
+        initializationTask = newTask
+        try await newTask.value
+    }
+}

+ 100 - 32
Model/CoreDataStack.swift

@@ -11,6 +11,9 @@ class CoreDataStack: ObservableObject {
 
     let persistentContainer: NSPersistentContainer
 
+    private let maxRetries = 3
+    private let initializationCoordinator = CoreDataInitializationCoordinator()
+
     private init(inMemory: Bool = false) {
         self.inMemory = inMemory
 
@@ -41,29 +44,12 @@ class CoreDataStack: ObservableObject {
         description.shouldMigrateStoreAutomatically = true
         description.shouldInferMappingModelAutomatically = true
 
-        persistentContainer.loadPersistentStores { _, error in
-            if let error = error as NSError? {
-                fatalError("Unresolved Error \(DebuggingIdentifiers.failed) \(error), \(error.userInfo)")
-            }
-        }
-
         persistentContainer.viewContext.automaticallyMergesChangesFromParent = false
         persistentContainer.viewContext.name = "viewContext"
         /// - Tag: viewContextmergePolicy
         persistentContainer.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
         persistentContainer.viewContext.undoManager = nil
         persistentContainer.viewContext.shouldDeleteInaccessibleFaults = true
-
-        // Observe Core Data remote change notifications on the queue where the changes were made
-        notificationToken = Foundation.NotificationCenter.default.addObserver(
-            forName: .NSPersistentStoreRemoteChange,
-            object: nil,
-            queue: nil
-        ) { _ in
-            Task {
-                await self.fetchPersistentHistory()
-            }
-        }
     }
 
     deinit {
@@ -84,19 +70,18 @@ class CoreDataStack: ObservableObject {
     }
 
     // Factory method for tests
-    static func createForTests() -> CoreDataStack {
-        CoreDataStack(inMemory: true)
+    static func createForTests() async throws -> CoreDataStack {
+        let stack = CoreDataStack(inMemory: true)
+        try await stack.initializeStack()
+        return stack
     }
 
     // Used for Canvas Preview
-    static var preview: CoreDataStack = {
+    static func preview() async throws -> CoreDataStack {
         let stack = CoreDataStack(inMemory: true)
-        let context = stack.persistentContainer.viewContext
-
-        let pumpHistory = PumpEventStored.makePreviewEvents(count: 10, provider: stack)
-
+        try await stack.initializeStack()
         return stack
-    }()
+    }
 
     // Shared managed object model
     static var managedObjectModel: NSManagedObjectModel = {
@@ -119,7 +104,7 @@ class CoreDataStack: ObservableObject {
         let taskContext = persistentContainer.newBackgroundContext()
 
         /// ensure that the background contexts stay in sync with the main context
-        taskContext.automaticallyMergesChangesFromParent = false
+        taskContext.automaticallyMergesChangesFromParent = true
         taskContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
         taskContext.undoManager = nil
         return taskContext
@@ -183,13 +168,96 @@ class CoreDataStack: ObservableObject {
         }
     }
 
-    func initializeStack() throws {
-        // Force initialization of persistent container
-        let container = persistentContainer
+    private func setupPersistentStoreChangeNotifications() {
+        // Observe Core Data remote change notifications on the queue where the changes were made
+        notificationToken = Foundation.NotificationCenter.default.addObserver(
+            forName: .NSPersistentStoreRemoteChange,
+            object: nil,
+            queue: nil
+        ) { _ in
+            Task {
+                await self.fetchPersistentHistory()
+            }
+        }
+
+        debug(.coreData, "Set up persistent store change notifications")
+    }
+
+    /// Loads the persistent stores asynchronously.
+    ///
+    /// Converts the synchronous NSPersistentContainer loading process into an async/await compatible
+    /// function using a continuation.
+    ///
+    /// - Throws: Any errors encountered during the loading of persistent stores.
+    /// - Returns: Void once stores are loaded successfully
+    private func loadPersistentStores() async throws {
+        try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
+            persistentContainer.loadPersistentStores { storeDescription, error in
+                if let error = error {
+                    warning(.coreData, "Failed to load persistent stores: \(error.localizedDescription)")
+                    continuation.resume(throwing: error)
+                } else {
+                    debug(.coreData, "Successfully loaded persistent store: \(storeDescription.url?.absoluteString ?? "unknown")")
+                    continuation.resume(returning: ())
+                }
+            }
+        }
+    }
+
+    /// Public entry point for initializing the CoreData stack.
+    ///
+    /// Uses the initialization coordinator to ensure initialization happens only once,
+    /// even with concurrent calls. Subsequent calls will wait for the original initialization
+    /// to complete.
+    ///
+    /// - Throws: Any errors that occur during initialization.
+    /// - Returns: Void once initialization is complete.
+    func initializeStack() async throws {
+        try await initializationCoordinator.ensureInitialized {
+            try await self.initializeStack(retryCount: 0)
+        }
+    }
+
+    /// Private implementation of the initialization process with retry capability.
+    ///
+    /// Handles the actual initialization work including store loading, verification,
+    /// notification setup, and error handling with retry logic.
+    ///
+    /// - Parameter retryCount: The current retry attempt number, starting at 0.
+    /// - Throws: CoreDataError or any other error if initialization fails after all retries.
+    /// - Returns: Void when initialization completes successfully.
+    private func initializeStack(retryCount: Int) async throws {
+        do {
+            // Load stores asynchronously
+            try await loadPersistentStores()
+
+            // Verify the store is loaded
+            guard persistentContainer.persistentStoreCoordinator.persistentStores.isEmpty == false else {
+                let error = CoreDataError.storeNotInitializedError(function: #function, file: #file)
+                throw error
+            }
+
+            setupPersistentStoreChangeNotifications()
 
-        // Verify the store is loaded
-        guard container.persistentStoreCoordinator.persistentStores.isEmpty == false else {
-            throw CoreDataError.storeNotInitializedError(function: #function, file: #file)
+            debug(.coreData, "Core Data stack initialized successfully")
+
+        } catch {
+            debug(.coreData, "Failed to initialize Core Data stack: \(error.localizedDescription)")
+
+            // If we still have retries left, try again after a delay
+            if retryCount < maxRetries {
+                debug(.coreData, "Retrying initialization (\(retryCount + 1)/\(maxRetries))")
+
+                // Wait before retrying
+                try await Task.sleep(for: .seconds(1))
+
+                // Retry the initialization
+                try await initializeStack(retryCount: retryCount + 1)
+            } else {
+                // We've exhausted our retries
+                debug(.coreData, "Core Data initialization failed after \(maxRetries) attempts")
+                throw error
+            }
         }
     }
 }

+ 1 - 1
OmniKit

@@ -1 +1 @@
-Subproject commit 39915b05fe46b5d73eca52e156dd7efd11193ee8
+Subproject commit 92948a7684ec382714becc53c643a1617597bb37

+ 1 - 1
RileyLinkKit

@@ -1 +1 @@
-Subproject commit a0e419da314d0ad42b41ccb04af73cd1fbf41257
+Subproject commit eddfd4f00bbf0d78035dc31e6f7715e72252c566

+ 20 - 12
Trio.xcodeproj/project.pbxproj

@@ -202,14 +202,16 @@
 		38FEF3FC2737E53800574A46 /* MainStateModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38FEF3FB2737E53800574A46 /* MainStateModel.swift */; };
 		38FEF3FE2738083E00574A46 /* CGMSettingsProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38FEF3FD2738083E00574A46 /* CGMSettingsProvider.swift */; };
 		38FEF413273B317A00574A46 /* HKUnit.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38FEF412273B317A00574A46 /* HKUnit.swift */; };
+		3B2F77862D7E52ED005ED9FA /* TDD.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B2F77852D7E52ED005ED9FA /* TDD.swift */; };
+		3B2F77882D7E5387005ED9FA /* CurrentTDDSetup.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B2F77872D7E5387005ED9FA /* CurrentTDDSetup.swift */; };
+		3BAD36B22D7CDC1A00CC298D /* MainLoadingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BAD36B12D7CDC1400CC298D /* MainLoadingView.swift */; };
+		3BAD36CC2D7D420E00CC298D /* CoreDataInitializationCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BAD36CB2D7D420500CC298D /* CoreDataInitializationCoordinator.swift */; };
 		45252C95D220E796FDB3B022 /* ConfigEditorDataFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F8A87AA037BD079BA3528BA /* ConfigEditorDataFlow.swift */; };
 		45717281F743594AA9D87191 /* ConfigEditorRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 920DDB21E5D0EB813197500D /* ConfigEditorRootView.swift */; };
 		491D6FBD2D56741C00C49F67 /* TempTargetStored+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 491D6FBC2D56741C00C49F67 /* TempTargetStored+CoreDataProperties.swift */; };
 		491D6FBE2D56741C00C49F67 /* TempTargetRunStored+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 491D6FB92D56741C00C49F67 /* TempTargetRunStored+CoreDataClass.swift */; };
 		491D6FBF2D56741C00C49F67 /* TempTargetRunStored+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 491D6FBA2D56741C00C49F67 /* TempTargetRunStored+CoreDataProperties.swift */; };
 		491D6FC02D56741C00C49F67 /* TempTargetStored+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 491D6FBB2D56741C00C49F67 /* TempTargetStored+CoreDataClass.swift */; };
-		49249B1C2D46E45E000F4866 /* CurrentTDDSetup.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49249B1B2D46E45E000F4866 /* CurrentTDDSetup.swift */; };
-		49249B382D46E76A000F4866 /* TDD.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49249B372D46E76A000F4866 /* TDD.swift */; };
 		49B9B57F2D5768D2009C6B59 /* AdjustmentStored+Helper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49B9B57E2D5768D2009C6B59 /* AdjustmentStored+Helper.swift */; };
 		5075C1608E6249A51495C422 /* TargetsEditorProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BDEA2DC60EDE0A3CA54DC73 /* TargetsEditorProvider.swift */; };
 		53F2382465BF74DB1A967C8B /* PumpConfigProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8630D58BDAD6D9C650B9B39 /* PumpConfigProvider.swift */; };
@@ -304,7 +306,6 @@
 		BD249D9D2D42FCF500412DEB /* MealStatsSetup.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD249D9C2D42FCF300412DEB /* MealStatsSetup.swift */; };
 		BD249D9F2D42FD0600412DEB /* StackedChartSetup.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD249D9E2D42FD0200412DEB /* StackedChartSetup.swift */; };
 		BD249DA12D42FD1200412DEB /* TDDSetup.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD249DA02D42FD1000412DEB /* TDDSetup.swift */; };
-		BD249DA52D42FD9700412DEB /* CustomDatePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD249DA42D42FD9500412DEB /* CustomDatePicker.swift */; };
 		BD249DA72D42FE4600412DEB /* Calendar+GlucoseStatsChart.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD249DA62D42FE3800412DEB /* Calendar+GlucoseStatsChart.swift */; };
 		BD2B464E0745FBE7B79913F4 /* NightscoutConfigProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BF768BD6264FF7D71D66767 /* NightscoutConfigProvider.swift */; };
 		BD2FF1A02AE29D43005D1C5D /* CheckboxToggleStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD2FF19F2AE29D43005D1C5D /* CheckboxToggleStyle.swift */; };
@@ -540,6 +541,7 @@
 		DDAA29852D2D1D9E006546A1 /* AdjustmentsRootView+TempTargets.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDAA29842D2D1D98006546A1 /* AdjustmentsRootView+TempTargets.swift */; };
 		DDB37CC52D05048F00D99BF4 /* ContactImageStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDB37CC42D05048F00D99BF4 /* ContactImageStorage.swift */; };
 		DDB37CC72D05127500D99BF4 /* FontExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDB37CC62D05127500D99BF4 /* FontExtensions.swift */; };
+		DDCAE8332D78D4A800B1BB51 /* TherapySettingsUtil.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCAE8322D78D49C00B1BB51 /* TherapySettingsUtil.swift */; };
 		DDCE790F2D6F97FC000A4D7A /* SubmodulesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCE790E2D6F97F7000A4D7A /* SubmodulesView.swift */; };
 		DDCEBF5B2CC1B76400DF4C36 /* LiveActivity+Helper.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCEBF5A2CC1B76400DF4C36 /* LiveActivity+Helper.swift */; };
 		DDD163122C4C689900CD525A /* AdjustmentsStateModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDD163112C4C689900CD525A /* AdjustmentsStateModel.swift */; };
@@ -921,6 +923,10 @@
 		38FEF3FB2737E53800574A46 /* MainStateModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainStateModel.swift; sourceTree = "<group>"; };
 		38FEF3FD2738083E00574A46 /* CGMSettingsProvider.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CGMSettingsProvider.swift; sourceTree = "<group>"; };
 		38FEF412273B317A00574A46 /* HKUnit.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HKUnit.swift; sourceTree = "<group>"; };
+		3B2F77852D7E52ED005ED9FA /* TDD.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TDD.swift; sourceTree = "<group>"; };
+		3B2F77872D7E5387005ED9FA /* CurrentTDDSetup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CurrentTDDSetup.swift; sourceTree = "<group>"; };
+		3BAD36B12D7CDC1400CC298D /* MainLoadingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainLoadingView.swift; sourceTree = "<group>"; };
+		3BAD36CB2D7D420500CC298D /* CoreDataInitializationCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoreDataInitializationCoordinator.swift; sourceTree = "<group>"; };
 		3BDEA2DC60EDE0A3CA54DC73 /* TargetsEditorProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TargetsEditorProvider.swift; sourceTree = "<group>"; };
 		3BF768BD6264FF7D71D66767 /* NightscoutConfigProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NightscoutConfigProvider.swift; sourceTree = "<group>"; };
 		3F60E97100041040446F44E7 /* PumpConfigStateModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PumpConfigStateModel.swift; sourceTree = "<group>"; };
@@ -931,8 +937,6 @@
 		491D6FBA2D56741C00C49F67 /* TempTargetRunStored+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TempTargetRunStored+CoreDataProperties.swift"; sourceTree = "<group>"; };
 		491D6FBB2D56741C00C49F67 /* TempTargetStored+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TempTargetStored+CoreDataClass.swift"; sourceTree = "<group>"; };
 		491D6FBC2D56741C00C49F67 /* TempTargetStored+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TempTargetStored+CoreDataProperties.swift"; sourceTree = "<group>"; };
-		49249B1B2D46E45E000F4866 /* CurrentTDDSetup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CurrentTDDSetup.swift; sourceTree = "<group>"; };
-		49249B372D46E76A000F4866 /* TDD.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TDD.swift; sourceTree = "<group>"; };
 		49B9B57E2D5768D2009C6B59 /* AdjustmentStored+Helper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AdjustmentStored+Helper.swift"; sourceTree = "<group>"; };
 		4DD795BA46B193644D48138C /* TargetsEditorRootView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TargetsEditorRootView.swift; sourceTree = "<group>"; };
 		505E09DC17A0C3D0AF4B66FE /* ISFEditorStateModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ISFEditorStateModel.swift; sourceTree = "<group>"; };
@@ -1028,7 +1032,6 @@
 		BD249D9C2D42FCF300412DEB /* MealStatsSetup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MealStatsSetup.swift; sourceTree = "<group>"; };
 		BD249D9E2D42FD0200412DEB /* StackedChartSetup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StackedChartSetup.swift; sourceTree = "<group>"; };
 		BD249DA02D42FD1000412DEB /* TDDSetup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TDDSetup.swift; sourceTree = "<group>"; };
-		BD249DA42D42FD9500412DEB /* CustomDatePicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomDatePicker.swift; sourceTree = "<group>"; };
 		BD249DA62D42FE3800412DEB /* Calendar+GlucoseStatsChart.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Calendar+GlucoseStatsChart.swift"; sourceTree = "<group>"; };
 		BD2FF19F2AE29D43005D1C5D /* CheckboxToggleStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckboxToggleStyle.swift; sourceTree = "<group>"; };
 		BD3CC0712B0B89D50013189E /* MainChartView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainChartView.swift; sourceTree = "<group>"; };
@@ -1267,6 +1270,7 @@
 		DDB37CC32D05044D00D99BF4 /* ContactTrickEntryStored+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ContactTrickEntryStored+CoreDataProperties.swift"; sourceTree = "<group>"; };
 		DDB37CC42D05048F00D99BF4 /* ContactImageStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactImageStorage.swift; sourceTree = "<group>"; };
 		DDB37CC62D05127500D99BF4 /* FontExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FontExtensions.swift; sourceTree = "<group>"; };
+		DDCAE8322D78D49C00B1BB51 /* TherapySettingsUtil.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TherapySettingsUtil.swift; sourceTree = "<group>"; };
 		DDCE790E2D6F97F7000A4D7A /* SubmodulesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubmodulesView.swift; sourceTree = "<group>"; };
 		DDCEBF5A2CC1B76400DF4C36 /* LiveActivity+Helper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "LiveActivity+Helper.swift"; sourceTree = "<group>"; };
 		DDD163112C4C689900CD525A /* AdjustmentsStateModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdjustmentsStateModel.swift; sourceTree = "<group>"; };
@@ -1739,6 +1743,7 @@
 		3811DE1F25C9D48300A708ED /* View */ = {
 			isa = PBXGroup;
 			children = (
+				3BAD36B12D7CDC1400CC298D /* MainLoadingView.swift */,
 				3811DE2025C9D48300A708ED /* MainRootView.swift */,
 			);
 			path = View;
@@ -2092,7 +2097,7 @@
 		388E5A5925B6F0250019842D /* Models */ = {
 			isa = PBXGroup;
 			children = (
-				49249B372D46E76A000F4866 /* TDD.swift */,
+				3B2F77852D7E52ED005ED9FA /* TDD.swift */,
 				DD4FFF322D458EE600B6CFF9 /* GarminWatchState.swift */,
 				DD3078692D42F94000DE0490 /* GarminDevice.swift */,
 				DD3078672D42F5CE00DE0490 /* WatchGlucoseObject.swift */,
@@ -2154,8 +2159,8 @@
 		388E5A5A25B6F05F0019842D /* Helpers */ = {
 			isa = PBXGroup;
 			children = (
+				DDCAE8322D78D49C00B1BB51 /* TherapySettingsUtil.swift */,
 				BD249DA62D42FE3800412DEB /* Calendar+GlucoseStatsChart.swift */,
-				BD249DA42D42FD9500412DEB /* CustomDatePicker.swift */,
 				DD73FA0E2D74F57300D19D1E /* BackgroundTask+Helper.swift */,
 				CEF1ED6A2D58FB4600FAF41E /* CGMOptions.swift */,
 				C2A0A42E2CE0312C003B98E8 /* ConstantValues.swift */,
@@ -2415,13 +2420,13 @@
 		58645B972CA2D16A008AFCE7 /* HomeStateModel+Setup */ = {
 			isa = PBXGroup;
 			children = (
+				3B2F77872D7E5387005ED9FA /* CurrentTDDSetup.swift */,
 				BD4E1A7B2D3686D400D21626 /* StartEndMarkerSetup.swift */,
 				BD4E1A792D3681AD00D21626 /* GlucoseTargetSetup.swift */,
 				BDA6CC872CAF219800F942F9 /* TempTargetSetup.swift */,
 				58645B982CA2D1A4008AFCE7 /* GlucoseSetup.swift */,
 				58645B9A2CA2D24F008AFCE7 /* CarbSetup.swift */,
 				58645B9C2CA2D275008AFCE7 /* DeterminationSetup.swift */,
-				49249B1B2D46E45E000F4866 /* CurrentTDDSetup.swift */,
 				58645B9E2CA2D2BE008AFCE7 /* PumpHistorySetup.swift */,
 				58645BA02CA2D2F8008AFCE7 /* OverrideSetup.swift */,
 				58645BA22CA2D325008AFCE7 /* BatterySetup.swift */,
@@ -2434,6 +2439,7 @@
 		587A54C82BCDCE0F009D38E2 /* Model */ = {
 			isa = PBXGroup;
 			children = (
+				3BAD36CB2D7D420500CC298D /* CoreDataInitializationCoordinator.swift */,
 				BDF34F8F2C10CF8C00D51995 /* CoreDataStack.swift */,
 				BD4064D02C4ED26900582F43 /* CoreDataObserver.swift */,
 				DDD1631D2C4C6F6900CD525A /* TrioCoreDataPersistentContainer.xcdatamodeld */,
@@ -3708,7 +3714,6 @@
 				38AEE75225F022080013F05B /* SettingsManager.swift in Sources */,
 				3894873A2614928B004DF424 /* DispatchTimer.swift in Sources */,
 				3895E4C625B9E00D00214B37 /* Preferences.swift in Sources */,
-				BD249DA52D42FD9700412DEB /* CustomDatePicker.swift in Sources */,
 				CE94598429E9E3E60047C9C6 /* WatchConfigStateModel.swift in Sources */,
 				DD6B7CB92C7BAC6900B75029 /* NightscoutImportResultView.swift in Sources */,
 				38DF1786276A73D400B3528F /* TagCloudView.swift in Sources */,
@@ -3746,6 +3751,7 @@
 				3811DEB125C9D88300A708ED /* Keychain.swift in Sources */,
 				DD17453E2C55BFB600211FAC /* AlgorithmAdvancedSettingsStateModel.swift in Sources */,
 				CE95BF572BA5F5FE00DC3DE3 /* PluginManager.swift in Sources */,
+				3BAD36B22D7CDC1A00CC298D /* MainLoadingView.swift in Sources */,
 				382C133725F13A1E00715CE1 /* InsulinSensitivities.swift in Sources */,
 				19D466A529AA2BD4004D5F33 /* MealSettingsProvider.swift in Sources */,
 				DD5DC9F72CF3DA9300AB8703 /* TargetPicker.swift in Sources */,
@@ -3754,6 +3760,7 @@
 				3811DE4125C9D4A100A708ED /* SettingsRootView.swift in Sources */,
 				38192E04261B82FA0094D973 /* ReachabilityManager.swift in Sources */,
 				38E44539274E411700EC9A94 /* Disk+UIImage.swift in Sources */,
+				3BAD36CC2D7D420E00CC298D /* CoreDataInitializationCoordinator.swift in Sources */,
 				388E595C25AD948C0019842D /* TrioApp.swift in Sources */,
 				38FEF3FC2737E53800574A46 /* MainStateModel.swift in Sources */,
 				DD1745352C55AE7E00211FAC /* TargetBehavoirRootView.swift in Sources */,
@@ -4002,6 +4009,7 @@
 				DD9ECB712CA9A0BA00AA7C45 /* RemoteControlConfigProvider.swift in Sources */,
 				63E890B4D951EAA91C071D5C /* BasalProfileEditorStateModel.swift in Sources */,
 				38FEF3FA2737E42000574A46 /* BaseStateModel.swift in Sources */,
+				DDCAE8332D78D4A800B1BB51 /* TherapySettingsUtil.swift in Sources */,
 				BDA25EFD2D261C0000035F34 /* WatchState.swift in Sources */,
 				CC6C406E2ACDD69E009B8058 /* RawFetchedProfile.swift in Sources */,
 				385CEA8225F23DFD002D6D5B /* NightscoutStatus.swift in Sources */,
@@ -4020,6 +4028,7 @@
 				DDF847DF2C5C28780049BB3B /* LiveActivitySettingsProvider.swift in Sources */,
 				DDB37CC52D05048F00D99BF4 /* ContactImageStorage.swift in Sources */,
 				BD54A95B2D28087C00F9C1EE /* OverridePresetWatch.swift in Sources */,
+				3B2F77882D7E5387005ED9FA /* CurrentTDDSetup.swift in Sources */,
 				DBA5254DBB2586C98F61220C /* ISFEditorProvider.swift in Sources */,
 				BDF34EBE2C0A31D100D51995 /* CustomNotification.swift in Sources */,
 				BDC2EA472C3045AD00E5BBD0 /* Override.swift in Sources */,
@@ -4045,7 +4054,6 @@
 				38E44538274E411700EC9A94 /* Disk+[Data].swift in Sources */,
 				98641AF4F92123DA668AB931 /* CarbRatioEditorRootView.swift in Sources */,
 				BDF34F902C10CF8C00D51995 /* CoreDataStack.swift in Sources */,
-				49249B1C2D46E45E000F4866 /* CurrentTDDSetup.swift in Sources */,
 				CEE9A65C2BBB41C800EB5194 /* CalibrationService.swift in Sources */,
 				110AEDED2C51A0AE00615CC9 /* ShortcutsConfigProvider.swift in Sources */,
 				38E4453D274E411700EC9A94 /* Disk+Errors.swift in Sources */,
@@ -4063,7 +4071,6 @@
 				9702FF92A09C53942F20D7EA /* TargetsEditorRootView.swift in Sources */,
 				1967DFBE29D052C200759F30 /* Icons.swift in Sources */,
 				DDD163182C4C694000CD525A /* AdjustmentsRootView.swift in Sources */,
-				49249B382D46E76A000F4866 /* TDD.swift in Sources */,
 				389ECE052601144100D86C4F /* ConcurrentMap.swift in Sources */,
 				110AEDEC2C51A0AE00615CC9 /* ShortcutsConfigDataFlow.swift in Sources */,
 				CE7CA3562A064973004BE681 /* StateIntentRequest.swift in Sources */,
@@ -4123,6 +4130,7 @@
 				38E44534274E411700EC9A94 /* Disk+InternalHelpers.swift in Sources */,
 				38A00B2325FC2B55006BC0B0 /* LRUCache.swift in Sources */,
 				DDD163122C4C689900CD525A /* AdjustmentsStateModel.swift in Sources */,
+				3B2F77862D7E52ED005ED9FA /* TDD.swift in Sources */,
 				DD1745132C54169400211FAC /* DevicesView.swift in Sources */,
 				7F7B756BE8543965D9FDF1A2 /* DataTableDataFlow.swift in Sources */,
 				1D845DF2E3324130E1D95E67 /* DataTableProvider.swift in Sources */,

BIN
Trio/Resources/Assets.xcassets/app_icon_images/trioCircledNoBackground.imageset/ComplicationIcon.png


+ 12 - 0
Trio/Resources/Assets.xcassets/app_icon_images/trioCircledNoBackground.imageset/Contents.json

@@ -0,0 +1,12 @@
+{
+  "images" : [
+    {
+      "filename" : "ComplicationIcon.png",
+      "idiom" : "universal"
+    }
+  ],
+  "info" : {
+    "author" : "xcode",
+    "version" : 1
+  }
+}

BIN
Trio/Resources/Assets.xcassets/app_icons/trioCircledNoBackground.imageset/ComplicationIcon.png


+ 12 - 0
Trio/Resources/Assets.xcassets/app_icons/trioCircledNoBackground.imageset/Contents.json

@@ -0,0 +1,12 @@
+{
+  "images" : [
+    {
+      "filename" : "ComplicationIcon.png",
+      "idiom" : "universal"
+    }
+  ],
+  "info" : {
+    "author" : "xcode",
+    "version" : 1
+  }
+}

+ 6 - 1
Trio/Resources/Info.plist

@@ -107,7 +107,12 @@
 	<key>UIFileSharingEnabled</key>
 	<true/>
 	<key>UILaunchScreen</key>
-	<dict/>
+	<dict>
+		<key>UIColorName</key>
+		<string>Background_DarkBlue</string>
+		<key>UIImageName</key>
+		<string>trioCircledNoBackground</string>
+	</dict>
 	<key>UIRequiredDeviceCapabilities</key>
 	<array>
 		<string>armv7</string>

+ 0 - 1
Trio/Resources/json/defaults/freeaps/freeaps_settings.json

@@ -14,7 +14,6 @@
   "displayCalendarIOBandCOB" : false,
   "glucoseBadge" : false,
   "glucoseNotificationsAlways" : false,
-  "useAlarmSound" : false,
   "addSourceInfoToGlucoseNotifications" : false,
   "lowGlucose" : 72,
   "highGlucose" : 270,

+ 3 - 1
Trio/Sources/APS/CGM/PluginSource.swift

@@ -111,7 +111,9 @@ extension PluginSource: CGMManagerDelegate {
             dispatchPrecondition(condition: .onQueue(self.processQueue))
 
             debug(.deviceManager, " CGM Manager with identifier \(manager.pluginIdentifier) wants deletion")
-            self.glucoseManager?.deleteGlucoseSource()
+            Task {
+                await self.glucoseManager?.deleteGlucoseSource()
+            }
         }
     }
 

+ 9 - 6
Trio/Sources/APS/FetchGlucoseManager.swift

@@ -9,7 +9,7 @@ import UIKit
 
 protocol FetchGlucoseManager: SourceInfoProvider {
     func updateGlucoseSource(cgmGlucoseSourceType: CGMType, cgmGlucosePluginId: String, newManager: CGMManagerUI?)
-    func deleteGlucoseSource()
+    func deleteGlucoseSource() async
     func removeCalibrations()
     var glucoseSource: GlucoseSource! { get }
     var cgmManager: CGMManagerUI? { get }
@@ -119,12 +119,15 @@ final class BaseFetchGlucoseManager: FetchGlucoseManager, Injectable {
         calibrationService.removeAllCalibrations()
     }
 
-    func deleteGlucoseSource() {
+    @MainActor func deleteGlucoseSource() async {
         cgmManager = nil
+        glucoseSource = nil
         updateGlucoseSource(
-            cgmGlucoseSourceType: CGMType.none,
-            cgmGlucosePluginId: ""
+            cgmGlucoseSourceType: cgmDefaultModel.type,
+            cgmGlucosePluginId: cgmDefaultModel.id
         )
+        settingsManager.settings.cgm = cgmDefaultModel.type
+        settingsManager.settings.cgmPluginIdentifier = cgmDefaultModel.id
     }
 
     func saveConfigManager() {
@@ -164,9 +167,9 @@ final class BaseFetchGlucoseManager: FetchGlucoseManager, Injectable {
         debug(.apsManager, "plugin : \(String(describing: cgmManager?.pluginIdentifier))")
 
         if let manager = newManager {
-            removeCalibrations()
             cgmManager = manager
-            glucoseSource = nil
+            removeCalibrations()
+//            glucoseSource = nil
         } else if self.cgmGlucoseSourceType == .plugin, cgmManager == nil, let rawCGMManager = rawCGMManager {
             cgmManager = cgmManagerFromRawValue(rawCGMManager)
             updateManagerUnits(cgmManager)

+ 3 - 3
Trio/Sources/Application/AppDelegate.swift

@@ -4,11 +4,11 @@ import UserNotifications
 
 class AppDelegate: NSObject, UIApplicationDelegate, ObservableObject, UNUserNotificationCenterDelegate {
     func application(
-        _ application: UIApplication,
+        _: UIApplication,
         didFinishLaunchingWithOptions _: [UIApplication.LaunchOptionsKey: Any]?
     ) -> Bool {
-        application.registerForRemoteNotifications()
-        return true
+        // application.registerForRemoteNotifications()
+        true
     }
 
     func application(

+ 94 - 24
Trio/Sources/Application/TrioApp.swift

@@ -5,6 +5,11 @@ import Foundation
 import SwiftUI
 import Swinject
 
+extension Notification.Name {
+    static let initializationCompleted = Notification.Name("initializationCompleted")
+    static let initializationError = Notification.Name("initializationError")
+}
+
 @main struct TrioApp: App {
     @Environment(\.scenePhase) var scenePhase
 
@@ -13,9 +18,21 @@ import Swinject
     // Read the color scheme preference from UserDefaults; defaults to system default setting
     @AppStorage("colorSchemePreference") private var colorSchemePreference: ColorSchemeOption = .systemDefault
 
-    let coreDataStack: CoreDataStack
+    let coreDataStack = CoreDataStack.shared
+    class InitState {
+        var complete = false
+        var error = false
+    }
+
+    // We use both InitState and @State variables to track coreDataStack
+    // initialization. We need both to handle the cases when the coreDataStack
+    // finishes before the UI and when it finishes after. SwiftUI doesn't have
+    // clean mechanisms for handling background thread updates, thus this solution.
+    let initState = InitState()
 
     @State private var appState = AppState()
+    @State private var showLoadingView = true
+    @State private var showLoadingError = false
 
     // Dependencies Assembler
     // contain all dependencies Assemblies
@@ -72,25 +89,43 @@ import Swinject
             "Trio Started: v\(Bundle.main.releaseVersionNumber ?? "")(\(Bundle.main.buildVersionNumber ?? "")) [buildDate: \(String(describing: BuildDetails.shared.buildDate()))] [buildExpires: \(String(describing: BuildDetails.shared.calculateExpirationDate()))] [submodules: \(submodulesInfo)]"
         )
 
-        // Setup up the Core Data Stack
-        coreDataStack = CoreDataStack.shared
+        // Fix bug in iOS 18 related to the translucent tab bar
+        configureTabBarAppearance()
 
-        // Explicitly initialize Core Data Stack
-        do {
-            try coreDataStack.initializeStack()
+        deferredInitialization()
+    }
 
-            // Only load services after successful Core Data initialization
-            loadServices()
+    /// Handles the deferred initialization of core components.
+    ///
+    /// Performs CoreDataStack initialization asynchronously and notifies the UI
+    /// of completion or errors via notifications.
+    private func deferredInitialization() {
+        Task {
+            do {
+                try await coreDataStack.initializeStack()
 
-            // Fix bug in iOS 18 related to the translucent tab bar
-            configureTabBarAppearance()
+                await MainActor.run {
+                    // Only load services after successful Core Data initialization
+                    loadServices()
 
-            // Clear the persistentHistory and the NSManagedObjects that are older than 90 days every time the app starts
-            cleanupOldData()
-        } catch {
-            debug(.coreData, "\(DebuggingIdentifiers.failed) Failed to initialize Core Data Stack: \(error.localizedDescription)")
+                    // Clear the persistentHistory and the NSManagedObjects that are older than 90 days every time the app starts
+                    cleanupOldData()
 
-            fatalError("Core Data Stack initialization failed")
+                    self.initState.complete = true
+                    Foundation.NotificationCenter.default.post(name: .initializationCompleted, object: nil)
+                    UIApplication.shared.registerForRemoteNotifications()
+                }
+            } catch {
+                debug(
+                    .coreData,
+                    "\(DebuggingIdentifiers.failed) Failed to initialize Core Data Stack: \(error.localizedDescription)"
+                )
+
+                await MainActor.run {
+                    self.initState.error = true
+                    Foundation.NotificationCenter.default.post(name: .initializationError, object: nil)
+                }
+            }
         }
 
         Task {
@@ -102,14 +137,49 @@ import Swinject
         }
     }
 
+    /// Attempts to initialize the CoreDataStack again after a previous failure.
+    ///
+    /// Resets error states and triggers the initialization process from the beginning. Called in response
+    /// to a UI "retry" button press from the Main.LoadingView
+    private func retryCoreDataInitialization() {
+        showLoadingError = false
+        initState.error = false
+        deferredInitialization()
+    }
+
     var body: some Scene {
         WindowGroup {
-            Main.RootView(resolver: resolver)
-                .preferredColorScheme(colorScheme(for: colorSchemePreference) ?? nil)
-                .environment(\.managedObjectContext, coreDataStack.persistentContainer.viewContext)
-                .environment(appState)
-                .environmentObject(Icons())
-                .onOpenURL(perform: handleURL)
+            if self.showLoadingView {
+                Main.LoadingView(showError: $showLoadingError, retry: retryCoreDataInitialization)
+                    .onAppear {
+                        if self.initState.complete {
+                            Task { @MainActor in
+                                try? await Task.sleep(for: .seconds(1.8))
+                                self.showLoadingView = false
+                            }
+                        }
+                        if self.initState.error {
+                            self.showLoadingError = true
+                        }
+                    }
+                    .onReceive(Foundation.NotificationCenter.default.publisher(for: .initializationCompleted)) { _ in
+                        Task { @MainActor in
+                            try? await Task.sleep(for: .seconds(1.8))
+                            self.showLoadingView = false
+                        }
+                    }
+                    .onReceive(Foundation.NotificationCenter.default.publisher(for: .initializationError)) { _ in
+                        self.showLoadingError = true
+                    }
+
+            } else {
+                Main.RootView(resolver: resolver)
+                    .preferredColorScheme(colorScheme(for: colorSchemePreference) ?? nil)
+                    .environment(\.managedObjectContext, coreDataStack.persistentContainer.viewContext)
+                    .environment(appState)
+                    .environmentObject(Icons())
+                    .onOpenURL(perform: handleURL)
+            }
         }
         .onChange(of: scenePhase) { _, newScenePhase in
             debug(.default, "APPLICATION PHASE: \(newScenePhase)")
@@ -125,9 +195,9 @@ import Swinject
                 {
                     AppVersionChecker.shared.checkAndNotifyVersionStatus(in: rootVC)
                 }
-
-                // Check if we need to perform a database cleaning
-                performCleanupIfNecessary()
+                if initState.complete {
+                    performCleanupIfNecessary()
+                }
             }
         }
     }

+ 0 - 56
Trio/Sources/Helpers/CustomDatePicker.swift

@@ -1,56 +0,0 @@
-import SwiftUI
-
-struct CustomDatePicker: UIViewRepresentable {
-    @Binding var selection: Date
-
-    // Coordinator to handle date changes
-    class Coordinator: NSObject {
-        var parent: CustomDatePicker
-
-        init(_ parent: CustomDatePicker) {
-            self.parent = parent
-        }
-
-        @objc func dateChanged(_ sender: UIDatePicker) {
-            let calendar = Calendar.current
-            // Set the time of the selected date to 23:59:59 for any selected date
-            if let adjustedDate = calendar.date(bySettingHour: 23, minute: 59, second: 59, of: sender.date) {
-                parent.selection = adjustedDate
-            } else {
-                parent.selection = sender.date // Fallback in case something goes wrong
-            }
-        }
-    }
-
-    func makeUIView(context: Context) -> UIDatePicker {
-        let datePicker = UIDatePicker()
-        datePicker.datePickerMode = .date
-
-        // Calculate yesterday's date at 23:59:59
-        let today = Date()
-        let calendar = Calendar.current
-        if let yesterday = calendar.date(byAdding: .day, value: -1, to: today),
-           let adjustedYesterday = calendar.date(bySettingHour: 23, minute: 59, second: 59, of: yesterday)
-        {
-            datePicker.maximumDate = adjustedYesterday // Set maximum date to yesterday at 23:59:59
-            datePicker.date = adjustedYesterday // Set default date to yesterday at 23:59:59
-        }
-
-        // Set up the date change action
-        datePicker.addTarget(context.coordinator, action: #selector(Coordinator.dateChanged(_:)), for: .valueChanged)
-
-        return datePicker
-    }
-
-    func updateUIView(_ uiView: UIDatePicker, context _: Context) {
-        // Ensure the displayed date is also adjusted to 23:59:59
-        let calendar = Calendar.current
-        if let adjustedDate = calendar.date(bySettingHour: 23, minute: 59, second: 59, of: selection) {
-            uiView.date = adjustedDate
-        }
-    }
-
-    func makeCoordinator() -> Coordinator {
-        Coordinator(self)
-    }
-}

+ 1 - 1
Trio/Sources/Helpers/CustomProgressView.swift

@@ -32,7 +32,7 @@ struct CustomProgressView: View {
                         .frame(width: 80, height: 3)
                         .offset(x: self.animate ? 180 : -180, y: 0)
                         .animation(
-                            Animation.linear(duration: 2)
+                            Animation.linear(duration: 1)
                                 .repeatForever(autoreverses: false), value: UUID()
                         )
                 )

+ 21 - 0
Trio/Sources/Helpers/TherapySettingsUtil.swift

@@ -0,0 +1,21 @@
+import Foundation
+
+enum TherapySettingsUtil {
+    /// Parses a time string of therapy setting entry into a `Date` object using either "HH:mm:ss" or "HH:mm" formats.
+    /// This function ensures compatibility with time strings that may include or exclude seconds.
+    ///
+    /// - Parameter timeString: A string representing the time in "HH:mm:ss" or "HH:mm" format.
+    /// - Returns: A `Date` object set to today’s date with the extracted time, or `nil` if parsing fails.
+    static func parseTime(_ timeString: String) -> Date? {
+        let formats = ["HH:mm:ss", "HH:mm"]
+        for format in formats {
+            let formatter = DateFormatter()
+            formatter.dateFormat = format
+            formatter.timeZone = TimeZone.current
+            if let date = formatter.date(from: timeString) {
+                return date
+            }
+        }
+        return nil
+    }
+}

+ 19 - 1
Trio/Sources/Localizations/Main/Localizable.xcstrings

@@ -26497,6 +26497,7 @@
       }
     },
     "Alarm with every Trio notification." : {
+      "extractionState" : "stale",
       "localizations" : {
         "bg" : {
           "stringUnit" : {
@@ -88601,6 +88602,9 @@
         }
       }
     },
+    "Getting everything ready for you..." : {
+
+    },
     "Give Apple Health Write Permissions" : {
       "localizations" : {
         "bg" : {
@@ -129260,6 +129264,9 @@
         }
       }
     },
+    "Oops, there was an issue!" : {
+
+    },
     "Open %@" : {
       "localizations" : {
         "bg" : {
@@ -134127,6 +134134,7 @@
       }
     },
     "Play Alarm Sound" : {
+      "extractionState" : "stale",
       "localizations" : {
         "bg" : {
           "stringUnit" : {
@@ -142722,6 +142730,9 @@
         }
       }
     },
+    "Retry" : {
+
+    },
     "Return to Normal" : {
       "extractionState" : "manual",
       "localizations" : {
@@ -157170,6 +157181,9 @@
         }
       }
     },
+    "Something went wrong while loading your data. Please try again in a few moments." : {
+
+    },
     "Source of the glucose reading will be added to the notification." : {
       "localizations" : {
         "bg" : {
@@ -171518,7 +171532,7 @@
         }
       }
     },
-    "This feature cannot be enabled unless Autosens Max > 100%." : {
+    "This feature cannot be enabled unless Algorithm Settings > Autosens > Autosens Max is set higher than 100%." : {
 
     },
     "This feature ensures more accurate insulin adjustments when carb entries are missing or incorrect." : {
@@ -175908,6 +175922,7 @@
       }
     },
     "This will cause a sound to be triggered by Trio notifications for Carbs Required, and Glucose Low/High Alarms." : {
+      "extractionState" : "stale",
       "localizations" : {
         "bg" : {
           "stringUnit" : {
@@ -183129,6 +183144,9 @@
     "Trio Up-Time Chart" : {
 
     },
+    "Trio v%@" : {
+
+    },
     "Trio v%@ (%@)" : {
       "localizations" : {
         "bg" : {

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

@@ -7,6 +7,7 @@ enum Icon_: String, CaseIterable, Identifiable {
     case trioWhiteShadow
     case trioColorBG
     case trioWhite
+    case trioCircledNoBackground
     case trio3D
     case wilford = "diabeetus"
     case catWithPod

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

@@ -35,7 +35,6 @@ struct TrioSettings: JSON, Equatable {
     var notificationsCarb: Bool = true
     var notificationsAlgorithm: Bool = true
     var glucoseNotificationsOption: GlucoseNotificationsOption = .onlyAlarmLimits
-    var useAlarmSound: Bool = false
     var addSourceInfoToGlucoseNotifications: Bool = false
     var lowGlucose: Decimal = 72
     var highGlucose: Decimal = 270
@@ -202,10 +201,6 @@ extension TrioSettings: Decodable {
             settings.glucoseNotificationsOption = glucoseNotificationsOption
         }
 
-        if let useAlarmSound = try? container.decode(Bool.self, forKey: .useAlarmSound) {
-            settings.useAlarmSound = useAlarmSound
-        }
-
         if let addSourceInfoToGlucoseNotifications = try? container.decode(
             Bool.self,
             forKey: .addSourceInfoToGlucoseNotifications

+ 0 - 4
Trio/Sources/Modules/Base/BaseStateModel.swift

@@ -11,10 +11,6 @@ protocol StateModel: ObservableObject {
     func view(for screen: Screen) -> AnyView
 }
 
-protocol CGMStateModel: StateModel {
-    var cgmCurrent: CGMType { get }
-}
-
 class BaseStateModel<Provider>: StateModel, Injectable where Provider: Trio.Provider {
     @Injected() var router: Router!
     @Injected() var settingsManager: SettingsManager!

+ 6 - 2
Trio/Sources/Modules/CGMSettings/CGMSettingsStateModel.swift

@@ -136,7 +136,9 @@ extension CGMSettings {
         func deleteCGM() {
             fetchGlucoseManager.performOnCGMManagerQueue {
                 // Call plugin functionality on the manager queue (or at least attempt to)
-                self.fetchGlucoseManager?.deleteGlucoseSource()
+                Task {
+                    await self.fetchGlucoseManager?.deleteGlucoseSource()
+                }
 
                 // UI updates go back to Main
                 DispatchQueue.main.async {
@@ -155,7 +157,9 @@ extension CGMSettings.StateModel: CompletionDelegate {
             cgmCurrent = cgmDefaultModel
             settingsManager.settings.cgm = cgmDefaultModel.type
             settingsManager.settings.cgmPluginIdentifier = cgmDefaultModel.id
-            fetchGlucoseManager.deleteGlucoseSource()
+            Task {
+                await fetchGlucoseManager.deleteGlucoseSource()
+            }
             shouldDisplayCGMSetupSheet = false
         } else {
             settingsManager.settings.cgm = cgmCurrent.type

+ 20 - 1
Trio/Sources/Modules/Calibrations/View/CalibrationsRootView.swift

@@ -16,6 +16,21 @@ extension Calibrations {
             return formatter
         }
 
+        private var manualGlucoseFormatter: NumberFormatter {
+            let formatter = NumberFormatter()
+            formatter.numberStyle = .decimal
+            if state.units == .mgdL {
+                formatter.maximumIntegerDigits = 3
+                formatter.maximumFractionDigits = 0
+            } else {
+                formatter.maximumIntegerDigits = 2
+                formatter.minimumFractionDigits = 0
+                formatter.maximumFractionDigits = 1
+            }
+            formatter.roundingMode = .halfUp
+            return formatter
+        }
+
         private var dateFormatter: DateFormatter {
             let formatter = DateFormatter()
             formatter.timeStyle = .short
@@ -30,7 +45,11 @@ extension Calibrations {
                         HStack {
                             Text("Meter glucose")
                             Spacer()
-                            TextFieldWithToolBar(text: $state.newCalibration, placeholder: "0", numberFormatter: formatter)
+                            TextFieldWithToolBar(
+                                text: $state.newCalibration,
+                                placeholder: "0",
+                                numberFormatter: manualGlucoseFormatter
+                            )
                             Text(state.units.rawValue).foregroundColor(.secondary)
                         }
                         Button {

+ 11 - 3
Trio/Sources/Modules/DataTable/View/CarbEntryEditorView.swift

@@ -40,6 +40,14 @@ struct CarbEntryEditorView: View {
         _editedDate = State(initialValue: Date())
     }
 
+    private var mealFormatter: NumberFormatter {
+        let formatter = NumberFormatter()
+        formatter.numberStyle = .decimal
+        formatter.maximumIntegerDigits = 3
+        formatter.maximumFractionDigits = 0
+        return formatter
+    }
+
     private var carbLimitExceeded: Bool {
         editedCarbs > state.settingsManager.settings.maxCarbs
     }
@@ -130,7 +138,7 @@ struct CarbEntryEditorView: View {
                             text: $editedCarbs,
                             placeholder: "0",
                             keyboardType: .numberPad,
-                            numberFormatter: Formatter.decimalFormatterWithOneFractionDigit
+                            numberFormatter: mealFormatter
                         )
                         Text("g").foregroundStyle(.secondary)
                     }
@@ -142,7 +150,7 @@ struct CarbEntryEditorView: View {
                                 text: $editedProtein,
                                 placeholder: "0",
                                 keyboardType: .numberPad,
-                                numberFormatter: Formatter.decimalFormatterWithOneFractionDigit
+                                numberFormatter: mealFormatter
                             )
                             Text("g").foregroundStyle(.secondary)
                         }
@@ -153,7 +161,7 @@ struct CarbEntryEditorView: View {
                                 text: $editedFat,
                                 placeholder: "0",
                                 keyboardType: .numberPad,
-                                numberFormatter: Formatter.decimalFormatterWithOneFractionDigit
+                                numberFormatter: mealFormatter
                             )
                             Text("g").foregroundStyle(.secondary)
                         }

+ 10 - 6
Trio/Sources/Modules/DataTable/View/DataTableRootView.swift

@@ -61,8 +61,11 @@ extension DataTable {
         private var manualGlucoseFormatter: NumberFormatter {
             let formatter = NumberFormatter()
             formatter.numberStyle = .decimal
-            formatter.maximumFractionDigits = 0
-            if state.units == .mmolL {
+            if state.units == .mgdL {
+                formatter.maximumIntegerDigits = 3
+                formatter.maximumFractionDigits = 0
+            } else {
+                formatter.maximumIntegerDigits = 2
                 formatter.minimumFractionDigits = 0
                 formatter.maximumFractionDigits = 1
             }
@@ -421,8 +424,8 @@ extension DataTable {
         }
 
         @ViewBuilder private func addGlucoseView() -> some View {
-            let limitLow: Decimal = state.units == .mmolL ? 0.8 : 14
-            let limitHigh: Decimal = state.units == .mmolL ? 40 : 720
+            let limitLow: Decimal = state.units == .mgdL ? Decimal(14) : 14.asMmolL
+            let limitHigh: Decimal = state.units == .mgdL ? Decimal(720) : 720.asMmolL
 
             NavigationView {
                 VStack {
@@ -433,8 +436,9 @@ extension DataTable {
                                 TextFieldWithToolBar(
                                     text: $state.manualGlucose,
                                     placeholder: " ... ",
-                                    shouldBecomeFirstResponder: true,
-                                    numberFormatter: manualGlucoseFormatter
+                                    keyboardType: state.units == .mgdL ? .numberPad : .decimalPad,
+                                    numberFormatter: manualGlucoseFormatter,
+                                    initialFocus: true
                                 )
                                 Text(state.units.rawValue).foregroundStyle(.secondary)
                             }

+ 0 - 2
Trio/Sources/Modules/GlucoseNotificationSettings/GlucoseNotificationSettingsStateModel.swift

@@ -4,7 +4,6 @@ extension GlucoseNotificationSettings {
     final class StateModel: BaseStateModel<Provider> {
         @Published var glucoseBadge = false
         @Published var glucoseNotificationsOption: GlucoseNotificationsOption = .onlyAlarmLimits
-        @Published var useAlarmSound = false
         @Published var addSourceInfoToGlucoseNotifications = false
         @Published var lowGlucose: Decimal = 0
         @Published var highGlucose: Decimal = 0
@@ -27,7 +26,6 @@ extension GlucoseNotificationSettings {
 
             subscribeSetting(\.glucoseBadge, on: $glucoseBadge) { glucoseBadge = $0 }
             subscribeSetting(\.glucoseNotificationsOption, on: $glucoseNotificationsOption) { glucoseNotificationsOption = $0 }
-            subscribeSetting(\.useAlarmSound, on: $useAlarmSound) { useAlarmSound = $0 }
             subscribeSetting(\.addSourceInfoToGlucoseNotifications, on: $addSourceInfoToGlucoseNotifications) {
                 addSourceInfoToGlucoseNotifications = $0 }
 

+ 0 - 22
Trio/Sources/Modules/GlucoseNotificationSettings/View/GlucoseNotificationSettingsRootView.swift

@@ -42,28 +42,6 @@ extension GlucoseNotificationSettings {
             List {
                 SettingInputSection(
                     decimalValue: $decimalPlaceholder,
-                    booleanValue: $state.useAlarmSound,
-                    shouldDisplayHint: $shouldDisplayHint,
-                    selectedVerboseHint: Binding(
-                        get: { selectedVerboseHint },
-                        set: {
-                            selectedVerboseHint = $0.map { AnyView($0) }
-                            hintLabel = String(localized: "Play Alarm Sound")
-                        }
-                    ),
-                    units: state.units,
-                    type: .boolean,
-                    label: String(localized: "Play Alarm Sound"),
-                    miniHint: String(localized: "Alarm with every Trio notification."),
-                    verboseHint: VStack(alignment: .leading, spacing: 10) {
-                        Text("Default: OFF").bold()
-                        Text(
-                            "This will cause a sound to be triggered by Trio notifications for Carbs Required, and Glucose Low/High Alarms."
-                        )
-                    }
-                )
-                SettingInputSection(
-                    decimalValue: $decimalPlaceholder,
                     booleanValue: $state.notificationsPump,
                     shouldDisplayHint: $shouldDisplayHint,
                     selectedVerboseHint: Binding(

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

@@ -463,7 +463,9 @@ extension Home {
         func deleteCGM() {
             fetchGlucoseManager.performOnCGMManagerQueue {
                 // Call plugin functionality on the manager queue (or at least attempt to)
-                self.fetchGlucoseManager?.deleteGlucoseSource()
+                Task {
+                    await self.fetchGlucoseManager?.deleteGlucoseSource()
+                }
 
                 // UI updates go back to Main
                 DispatchQueue.main.async {
@@ -558,15 +560,12 @@ extension Home {
         private func getCurrentGlucoseTarget() async {
             let now = Date()
             let calendar = Calendar.current
-            let dateFormatter = DateFormatter()
-            dateFormatter.dateFormat = "HH:mm"
-            dateFormatter.timeZone = TimeZone.current
 
             let entries: [(start: String, value: Decimal)] = bgTargets.targets.map { ($0.start, $0.low) }
 
             for (index, entry) in entries.enumerated() {
-                guard let entryTime = dateFormatter.date(from: entry.start) else {
-                    print("Invalid entry start time: \(entry.start)")
+                guard let entryTime = TherapySettingsUtil.parseTime(entry.start) else {
+                    debug(.default, "Invalid entry start time: \(entry.start)")
                     continue
                 }
 
@@ -580,7 +579,7 @@ extension Home {
 
                 let entryEndTime: Date
                 if index < entries.count - 1,
-                   let nextEntryTime = dateFormatter.date(from: entries[index + 1].start)
+                   let nextEntryTime = TherapySettingsUtil.parseTime(entries[index + 1].start)
                 {
                     let nextEntryComponents = calendar.dateComponents([.hour, .minute, .second], from: nextEntryTime)
                     entryEndTime = calendar.date(
@@ -701,7 +700,9 @@ extension Home.StateModel: CompletionDelegate {
                 cgmCurrent = cgmDefaultModel
                 settingsManager.settings.cgm = cgmDefaultModel.type
                 settingsManager.settings.cgmPluginIdentifier = cgmDefaultModel.id
-                fetchGlucoseManager.deleteGlucoseSource()
+                Task {
+                    await fetchGlucoseManager.deleteGlucoseSource()
+                }
             } else {
                 debug(.service, "CGMSetupCompletionNotifying: CGM Setup Completed")
 

+ 41 - 16
Trio/Sources/Modules/Home/View/Chart/ChartElements/GlucoseChartView.swift

@@ -78,23 +78,48 @@ struct GlucoseChartView: ChartContent {
 }
 
 #Preview {
-    let previewStack = CoreDataStack.preview
-    return NavigationView {
-        VStack {
-            Chart {
-                GlucoseChartView(
-                    glucoseData: GlucoseStored.makePreviewGlucose(count: 24, provider: previewStack),
-                    units: .mgdL,
-                    highGlucose: 180,
-                    lowGlucose: 70,
-                    currentGlucoseTarget: 100,
-                    isSmoothingEnabled: false,
-                    glucoseColorScheme: .dynamicColor
-                )
+    struct PreviewWrapper: View {
+        @State private var previewStack: CoreDataStack? = nil
+        @State private var glucoseData: [GlucoseStored] = []
+        @State private var isLoading = true
+
+        var body: some View {
+            NavigationView {
+                Group {
+                    if isLoading {
+                        ProgressView("Loading data...")
+                    } else {
+                        VStack {
+                            Chart {
+                                GlucoseChartView(
+                                    glucoseData: glucoseData,
+                                    units: .mgdL,
+                                    highGlucose: 180,
+                                    lowGlucose: 70,
+                                    currentGlucoseTarget: 100,
+                                    isSmoothingEnabled: false,
+                                    glucoseColorScheme: .dynamicColor
+                                )
+                            }
+                            .frame(height: 200)
+                            .padding()
+                        }
+                    }
+                }
+                .navigationTitle("Glucose Chart")
+                .task {
+                    // Use the preview stack that's initialized asynchronously in CoreDataStack
+                    previewStack = try? await CoreDataStack.preview()
+
+                    // Now you can safely create preview data
+                    if let stack = previewStack {
+                        glucoseData = GlucoseStored.makePreviewGlucose(count: 24, provider: stack)
+                        isLoading = false
+                    }
+                }
             }
-            .frame(height: 200)
-            .padding()
         }
-        .navigationTitle("Glucose Chart")
     }
+
+    return PreviewWrapper()
 }

+ 15 - 13
Trio/Sources/Modules/Home/View/Header/LoopStatusView.swift

@@ -41,16 +41,6 @@ struct LoopStatusView: View {
                         }
                     )
                 }.padding(.top, 20)
-//                Text("Current Loop Status").bold().padding(.top, 20)
-//
-//                Text(statusTitle)
-//                    .font(.headline)
-//                    .bold()
-//                    .padding(.horizontal, 12)
-//                    .padding(.vertical, 6)
-//                    .foregroundColor(statusBadgeTextColor)
-//                    .background(statusBadgeColor)
-//                    .clipShape(Capsule())
 
                 if let errorMessage = state.errorMessage, let date = state.errorDate {
                     Group {
@@ -85,10 +75,8 @@ struct LoopStatusView: View {
                         .multilineTextAlignment(.leading)
                         .fixedSize(horizontal: false, vertical: true)
 
-                        let tags = !state.isSmoothingEnabled ? determination.reasonParts : determination
-                            .reasonParts + ["Smoothing: On"]
                         TagCloudView(
-                            tags: tags,
+                            tags: getComputedTags(determination),
                             shouldParseToMmolL: state.units == .mmolL
                         )
 
@@ -274,6 +262,20 @@ struct LoopStatusView: View {
 
         return updatedConclusion.capitalizingFirstLetter()
     }
+
+    private func getComputedTags(_ determination: OrefDetermination) -> [String] {
+        var tags: [String] = determination.reasonParts
+
+        if state.isSmoothingEnabled {
+            tags.append("Smoothing: On")
+        }
+
+        if let currentTDD = state.fetchedTDDs.first?.totalDailyDose, currentTDD != 0 {
+            tags.append("TDD: \(currentTDD)")
+        }
+
+        return tags
+    }
 }
 
 struct ContentSizeKey: PreferenceKey {

+ 91 - 0
Trio/Sources/Modules/Main/View/MainLoadingView.swift

@@ -0,0 +1,91 @@
+import SwiftUI
+
+extension Main {
+    struct LoadingView: View {
+        @Binding var showError: Bool
+        let retry: () -> Void
+
+        private let versionNumber = Bundle.main.releaseVersionNumber ?? String(localized: "Unknown")
+
+        var body: some View {
+            ZStack {
+                LinearGradient(
+                    gradient: Gradient(colors: [Color.bgDarkBlue, Color.bgDarkerDarkBlue]),
+                    startPoint: .top,
+                    endPoint: .bottom
+                )
+                .ignoresSafeArea()
+
+                VStack {
+                    Spacer().frame(maxHeight: 92)
+
+                    Image(.trioCircledNoBackground)
+                        .resizable()
+                        .scaledToFit()
+                        .frame(width: 92, height: 92)
+                        .shadow(color: Color.white.opacity(0.1), radius: 5, x: 0, y: 0)
+
+                    Text("Trio v\(versionNumber)")
+                        .fontWeight(.heavy)
+                        .foregroundStyle(Color(red: 148 / 255, green: 102 / 255, blue: 234 / 255))
+                        .padding(.vertical)
+
+                    if showError {
+                        Spacer().frame(maxHeight: 60)
+
+                        VStack(alignment: .leading, spacing: 12) {
+                            Text("Oops, there was an issue!").font(.title3).bold()
+
+                            Text("Something went wrong while loading your data. Please try again in a few moments.")
+                                .foregroundStyle(.white)
+                        }
+                        .padding(.horizontal, 24)
+                        .foregroundStyle(.white)
+
+                        Spacer()
+
+                        RetryButton(action: retry).padding(.bottom, 60)
+                    } else {
+                        Spacer().frame(maxHeight: 100)
+
+                        CustomProgressView(text: String(localized: "Getting everything ready for you...")).foregroundStyle(.white)
+
+                        Spacer()
+                    }
+                }
+            }
+        }
+    }
+
+    struct RetryButton: View {
+        var action: () -> Void
+
+        var body: some View {
+            Button(action: action) {
+                HStack(spacing: 8) {
+                    Image(systemName: "arrow.clockwise")
+                    Text("Retry")
+                }
+                .frame(width: UIScreen.main.bounds.width - 60, height: 50)
+                .font(.title3).bold()
+                .background(
+                    Capsule()
+                        .fill(Color.tabBar)
+                )
+                .foregroundColor(.white)
+                .shadow(color: Color.black.opacity(0.1), radius: 5, x: 0, y: 2)
+            }
+        }
+    }
+}
+
+struct LoadingView_Previews: PreviewProvider {
+    static var previews: some View {
+        Group {
+            Main.LoadingView(showError: .constant(false), retry: {})
+                .previewDisplayName("Loading")
+            Main.LoadingView(showError: .constant(true), retry: {})
+                .previewDisplayName("Error")
+        }
+    }
+}

+ 3 - 2
Trio/Sources/Modules/ManualTempBasal/View/ManualTempBasalRootView.swift

@@ -12,6 +12,7 @@ extension ManualTempBasal {
         private var formatter: NumberFormatter {
             let formatter = NumberFormatter()
             formatter.numberStyle = .decimal
+            formatter.maximumIntegerDigits = 2
             formatter.maximumFractionDigits = 2
             return formatter
         }
@@ -25,8 +26,8 @@ extension ManualTempBasal {
                         TextFieldWithToolBar(
                             text: $state.rate,
                             placeholder: "0",
-                            shouldBecomeFirstResponder: true,
-                            numberFormatter: formatter
+                            numberFormatter: formatter,
+                            initialFocus: true
                         )
                         Text("U/hr").foregroundColor(.secondary)
                     }

+ 0 - 1
Trio/Sources/Modules/Settings/SettingItems.swift

@@ -230,7 +230,6 @@ enum SettingItems {
             title: "Trio Notifications",
             view: .glucoseNotificationSettings,
             searchContents: [
-                "Play Alarm Sound",
                 "Always Notify Pump",
                 "Always Notify CGM",
                 "Always Notify Carb",

+ 0 - 1
Trio/Sources/Modules/Snooze/View/SnoozeRootView.swift

@@ -90,7 +90,6 @@ extension Snooze {
                     state.snoozeUntilDate = untilDate < Date() ? .distantPast : untilDate
                     debug(.default, "will snooze for \(snoozeFor) until \(dateFormatter.string(from: untilDate))")
                     snoozeDescription = getSnoozeDescription()
-                    BaseUserNotificationsManager.stopSound()
                     state.hideModal()
                 } label: {
                     Text("Click to Snooze Alerts")

+ 3 - 22
Trio/Sources/Modules/Treatments/TreatmentsStateModel.swift

@@ -286,11 +286,6 @@ extension Treatments {
         private func getCurrentSettingValue(for type: SettingType) async {
             let now = Date()
             let calendar = Calendar.current
-            let dateFormatter = DateFormatter()
-            dateFormatter.timeZone = TimeZone.current
-
-            let regexWithSeconds = #"^\d{2}:\d{2}:\d{2}$"#
-
             let entries: [(start: String, value: Decimal)]
 
             switch type {
@@ -309,15 +304,8 @@ extension Treatments {
             }
 
             for (index, entry) in entries.enumerated() {
-                // Dynamically set the format based on whether it matches the regex
-                if entry.start.range(of: regexWithSeconds, options: .regularExpression) != nil {
-                    dateFormatter.dateFormat = "HH:mm:ss"
-                } else {
-                    dateFormatter.dateFormat = "HH:mm"
-                }
-
-                guard let entryTime = dateFormatter.date(from: entry.start) else {
-                    print("Invalid entry start time: \(entry.start)")
+                guard let entryTime = TherapySettingsUtil.parseTime(entry.start) else {
+                    debug(.default, "Invalid entry start time: \(entry.start)")
                     continue
                 }
 
@@ -331,14 +319,7 @@ extension Treatments {
 
                 let entryEndTime: Date
                 if index < entries.count - 1 {
-                    // Dynamically set the format again for the next element
-                    if entries[index + 1].start.range(of: regexWithSeconds, options: .regularExpression) != nil {
-                        dateFormatter.dateFormat = "HH:mm:ss"
-                    } else {
-                        dateFormatter.dateFormat = "HH:mm"
-                    }
-
-                    if let nextEntryTime = dateFormatter.date(from: entries[index + 1].start) {
+                    if let nextEntryTime = TherapySettingsUtil.parseTime(entries[index + 1].start) {
                         let nextEntryComponents = calendar.dateComponents([.hour, .minute, .second], from: nextEntryTime)
                         entryEndTime = calendar.date(
                             bySettingHour: nextEntryComponents.hour!,

+ 13 - 3
Trio/Sources/Modules/Treatments/View/MealPreset/AddMealPresetView.swift

@@ -18,15 +18,24 @@ struct AddMealPresetView: View {
     private var mealFormatter: NumberFormatter {
         let formatter = NumberFormatter()
         formatter.numberStyle = .decimal
-        formatter.maximumFractionDigits = 1
+        formatter.maximumIntegerDigits = 3
+        formatter.maximumFractionDigits = 0
         return formatter
     }
 
+    private var isFormValid: Bool {
+        !dish.isEmpty && (presetCarbs > 0 || presetProtein > 0 || presetFat > 0)
+    }
+
     var body: some View {
         NavigationStack {
             Form {
                 Section {
-                    TextField("Name Of Dish", text: $dish)
+                    TextFieldWithToolBarString(
+                        text: $dish,
+                        placeholder: String(localized: "Name Of Dish"),
+                        maxLength: 25
+                    )
                 } header: {
                     Text("New Preset")
                 }
@@ -107,8 +116,9 @@ struct AddMealPresetView: View {
                 .foregroundStyle(Color.white)
                 .frame(maxWidth: .infinity, alignment: .center)
         }
-        .listRowBackground(Color(.systemBlue))
+        .listRowBackground(isFormValid ? Color(.systemBlue) : Color(.systemGray))
         .shadow(radius: 3)
         .clipShape(RoundedRectangle(cornerRadius: 8))
+        .disabled(!isFormValid)
     }
 }

+ 8 - 2
Trio/Sources/Modules/Treatments/View/TreatmentsRootView.swift

@@ -36,6 +36,7 @@ extension Treatments {
         private var formatter: NumberFormatter {
             let formatter = NumberFormatter()
             formatter.numberStyle = .decimal
+            formatter.maximumIntegerDigits = 2
             formatter.maximumFractionDigits = 2
             return formatter
         }
@@ -43,7 +44,8 @@ extension Treatments {
         private var mealFormatter: NumberFormatter {
             let formatter = NumberFormatter()
             formatter.numberStyle = .decimal
-            formatter.maximumFractionDigits = 1
+            formatter.maximumIntegerDigits = 3
+            formatter.maximumFractionDigits = 0
             return formatter
         }
 
@@ -51,8 +53,12 @@ extension Treatments {
             let formatter = NumberFormatter()
             formatter.numberStyle = .decimal
             if state.units == .mmolL {
+                formatter.maximumIntegerDigits = 2
                 formatter.maximumFractionDigits = 1
-            } else { formatter.maximumFractionDigits = 0 }
+            } else {
+                formatter.maximumIntegerDigits = 3
+                formatter.maximumFractionDigits = 0
+            }
             return formatter
         }
 

+ 3 - 22
Trio/Sources/Services/BolusCalculator/BolusCalculationManager.swift

@@ -71,11 +71,6 @@ final class BaseBolusCalculationManager: BolusCalculationManager, Injectable {
     private func getCurrentSettingValue(for type: SettingType) async -> Decimal {
         let now = Date()
         let calendar = Calendar.current
-        let dateFormatter = DateFormatter()
-        dateFormatter.timeZone = TimeZone.current
-
-        let regexWithSeconds = #"^\d{2}:\d{2}:\d{2}$"#
-
         let entries: [(start: String, value: Decimal)]
 
         switch type {
@@ -94,15 +89,8 @@ final class BaseBolusCalculationManager: BolusCalculationManager, Injectable {
         }
 
         for (index, entry) in entries.enumerated() {
-            // Dynamically set the format based on whether it matches the regex
-            if entry.start.range(of: regexWithSeconds, options: .regularExpression) != nil {
-                dateFormatter.dateFormat = "HH:mm:ss"
-            } else {
-                dateFormatter.dateFormat = "HH:mm"
-            }
-
-            guard let entryTime = dateFormatter.date(from: entry.start) else {
-                print("Invalid entry start time: \(entry.start)")
+            guard let entryTime = TherapySettingsUtil.parseTime(entry.start) else {
+                debug(.default, "Invalid entry start time: \(entry.start)")
                 continue
             }
 
@@ -116,14 +104,7 @@ final class BaseBolusCalculationManager: BolusCalculationManager, Injectable {
 
             let entryEndTime: Date
             if index < entries.count - 1 {
-                // Dynamically set the format again for the next element
-                if entries[index + 1].start.range(of: regexWithSeconds, options: .regularExpression) != nil {
-                    dateFormatter.dateFormat = "HH:mm:ss"
-                } else {
-                    dateFormatter.dateFormat = "HH:mm"
-                }
-
-                if let nextEntryTime = dateFormatter.date(from: entries[index + 1].start) {
+                if let nextEntryTime = TherapySettingsUtil.parseTime(entries[index + 1].start) {
                     let nextEntryComponents = calendar.dateComponents([.hour, .minute, .second], from: nextEntryTime)
                     entryEndTime = calendar.date(
                         bySettingHour: nextEntryComponents.hour!,

+ 9 - 14
Trio/Sources/Services/ContactImage/ContactImageManager.swift

@@ -129,33 +129,29 @@ final class BaseContactImageManager: NSObject, ContactImageManager, Injectable {
     private func getCurrentGlucoseTarget() async -> Decimal? {
         let now = Date()
         let calendar = Calendar.current
-        let dateFormatter = DateFormatter()
-        dateFormatter.dateFormat = "HH:mm"
-        dateFormatter.timeZone = TimeZone.current
 
         let bgTargets = await fileStorage.retrieveAsync(OpenAPS.Settings.bgTargets, as: BGTargets.self)
             ?? BGTargets(from: OpenAPS.defaults(for: OpenAPS.Settings.bgTargets))
             ?? BGTargets(units: .mgdL, userPreferredUnits: .mgdL, targets: [])
-        let entries: [(start: String, value: Decimal)] = bgTargets.targets.map { ($0.start, $0.low) }
+        let entries: [(start: String, value: Decimal)] = bgTargets.targets
+            .map { ($0.start.trimmingCharacters(in: .whitespacesAndNewlines), $0.low) }
 
         for (index, entry) in entries.enumerated() {
-            guard let entryTime = dateFormatter.date(from: entry.start) else {
-                print("Invalid entry start time: \(entry.start)")
+            guard let entryTime = TherapySettingsUtil.parseTime(entry.start) else {
+                debug(.default, "Invalid entry start time: \(entry.start)")
                 continue
             }
 
             let entryComponents = calendar.dateComponents([.hour, .minute, .second], from: entryTime)
-            let entryStartTime = calendar.date(
+            guard let entryStartTime = calendar.date(
                 bySettingHour: entryComponents.hour!,
                 minute: entryComponents.minute!,
                 second: entryComponents.second!,
                 of: now
-            )!
+            ) else { continue }
 
             let entryEndTime: Date
-            if index < entries.count - 1,
-               let nextEntryTime = dateFormatter.date(from: entries[index + 1].start)
-            {
+            if index < entries.count - 1, let nextEntryTime = TherapySettingsUtil.parseTime(entries[index + 1].start) {
                 let nextEntryComponents = calendar.dateComponents([.hour, .minute, .second], from: nextEntryTime)
                 entryEndTime = calendar.date(
                     bySettingHour: nextEntryComponents.hour!,
@@ -239,12 +235,11 @@ final class BaseContactImageManager: NSObject, ContactImageManager, Injectable {
             let isDynamicColorScheme = settingsManager.settings.glucoseColorScheme == .dynamicColor
             let highGlucoseColorValue = isDynamicColorScheme ? hardCodedHigh : settingsManager.settings.highGlucose
             let lowGlucoseColorValue = isDynamicColorScheme ? hardCodedLow : settingsManager.settings.lowGlucose
+            let fetchedTarget = await getCurrentGlucoseTarget() // ⚠️ this value is mg/dL
 
             state.highGlucoseColorValue = units == .mgdL ? highGlucoseColorValue : highGlucoseColorValue.asMmolL
             state.lowGlucoseColorValue = units == .mgdL ? lowGlucoseColorValue : lowGlucoseColorValue.asMmolL
-            state
-                .targetGlucose = await getCurrentGlucoseTarget() ??
-                (settingsManager.settings.units == .mgdL ? Decimal(100) : 100.asMmolL)
+            state.targetGlucose = units == .mgdL ? fetchedTarget ?? Decimal(100) : fetchedTarget?.asMmolL ?? 100.asMmolL
             state.glucoseColorScheme = settingsManager.settings.glucoseColorScheme
 
             // Notify delegate about state update on main thread

+ 3 - 6
Trio/Sources/Services/Network/TidepoolManager.swift

@@ -540,9 +540,6 @@ extension BaseTidepoolManager {
     private func getCurrentBasalRate() -> BasalProfileEntry? {
         let now = Date()
         let calendar = Calendar.current
-        let dateFormatter = DateFormatter()
-        dateFormatter.dateFormat = "HH:mm"
-        dateFormatter.timeZone = TimeZone.current
 
         let basalEntries = storage.retrieve(OpenAPS.Settings.basalProfile, as: [BasalProfileEntry].self)
             ?? [BasalProfileEntry](from: OpenAPS.defaults(for: OpenAPS.Settings.basalProfile))
@@ -551,8 +548,8 @@ extension BaseTidepoolManager {
         var currentRate: BasalProfileEntry = basalEntries[0]
 
         for (index, entry) in basalEntries.enumerated() {
-            guard let entryTime = dateFormatter.date(from: entry.start) else {
-                print("Invalid entry start time: \(entry.start)")
+            guard let entryTime = TherapySettingsUtil.parseTime(entry.start) else {
+                debug(.default, "Invalid entry start time: \(entry.start)")
                 continue
             }
 
@@ -566,7 +563,7 @@ extension BaseTidepoolManager {
 
             let entryEndTime: Date
             if index < basalEntries.count - 1,
-               let nextEntryTime = dateFormatter.date(from: basalEntries[index + 1].start)
+               let nextEntryTime = TherapySettingsUtil.parseTime(basalEntries[index + 1].start)
             {
                 let nextEntryComponents = calendar.dateComponents([.hour, .minute, .second], from: nextEntryTime)
                 entryEndTime = calendar.date(

+ 0 - 26
Trio/Sources/Services/UserNotifications/UserNotificationsManager.swift

@@ -169,7 +169,6 @@ final class BaseUserNotificationsManager: NSObject, UserNotificationsManager, In
             return
         }
         content.sound = .default
-        playSoundIfNeeded()
 
         titles.append(String(format: String(localized: "Carbs required: %d g", comment: "Carbs required"), carbs))
 
@@ -309,7 +308,6 @@ final class BaseUserNotificationsManager: NSObject, UserNotificationsManager, In
                 content.body = body
 
                 if notificationAlarm {
-                    playSoundIfNeeded()
                     content.sound = .default
                     content.userInfo[NotificationAction.key] = NotificationAction.snooze.rawValue
                 }
@@ -452,30 +450,6 @@ final class BaseUserNotificationsManager: NSObject, UserNotificationsManager, In
         }
     }
 
-    private func playSoundIfNeeded() {
-        guard settingsManager.settings.useAlarmSound, snoozeUntilDate < Date() else { return }
-        Self.stopPlaying = false
-        playSound()
-    }
-
-    static let soundID: UInt32 = 1336
-    private static var stopPlaying = false
-
-    private func playSound(times: Int = 1) {
-        guard times > 0, !Self.stopPlaying else {
-            return
-        }
-
-        AudioServicesPlaySystemSoundWithCompletion(Self.soundID) {
-            self.playSound(times: times - 1)
-        }
-    }
-
-    static func stopSound() {
-        stopPlaying = true
-        AudioServicesDisposeSystemSoundID(soundID)
-    }
-
     private var glucoseFormatter: NumberFormatter {
         let formatter = NumberFormatter()
         formatter.numberStyle = .decimal

+ 3 - 6
Trio/Sources/Services/WatchManager/AppleWatchManager.swift

@@ -1043,9 +1043,6 @@ extension BaseWatchManager {
     private func getCurrentGlucoseTarget() async -> Decimal? {
         let now = Date()
         let calendar = Calendar.current
-        let dateFormatter = DateFormatter()
-        dateFormatter.dateFormat = "HH:mm"
-        dateFormatter.timeZone = TimeZone.current
 
         let bgTargets = await fileStorage.retrieveAsync(OpenAPS.Settings.bgTargets, as: BGTargets.self)
             ?? BGTargets(from: OpenAPS.defaults(for: OpenAPS.Settings.bgTargets))
@@ -1053,8 +1050,8 @@ extension BaseWatchManager {
         let entries: [(start: String, value: Decimal)] = bgTargets.targets.map { ($0.start, $0.low) }
 
         for (index, entry) in entries.enumerated() {
-            guard let entryTime = dateFormatter.date(from: entry.start) else {
-                print("Invalid entry start time: \(entry.start)")
+            guard let entryTime = TherapySettingsUtil.parseTime(entry.start) else {
+                debug(.default, "Invalid entry start time: \(entry.start)")
                 continue
             }
 
@@ -1068,7 +1065,7 @@ extension BaseWatchManager {
 
             let entryEndTime: Date
             if index < entries.count - 1,
-               let nextEntryTime = dateFormatter.date(from: entries[index + 1].start)
+               let nextEntryTime = TherapySettingsUtil.parseTime(entries[index + 1].start)
             {
                 let nextEntryComponents = calendar.dateComponents([.hour, .minute, .second], from: nextEntryTime)
                 entryEndTime = calendar.date(

+ 227 - 221
Trio/Sources/Views/TextFieldWithToolBar.swift

@@ -1,292 +1,298 @@
 import SwiftUI
 import UIKit
 
-public struct TextFieldWithToolBar: UIViewRepresentable {
+public struct TextFieldWithToolBar: View {
     @Binding var text: Decimal
     var placeholder: String
-    var textColor: UIColor
-    var textAlignment: NSTextAlignment
+    var textColor: Color
+    var textAlignment: TextAlignment
     var keyboardType: UIKeyboardType
-    var autocapitalizationType: UITextAutocapitalizationType
-    var autocorrectionType: UITextAutocorrectionType
-    var shouldBecomeFirstResponder: Bool
     var maxLength: Int?
+    var maxValue: Decimal?
     var isDismissible: Bool
     var textFieldDidBeginEditing: (() -> Void)?
+    var textDidChange: ((Decimal) -> Void)?
     var numberFormatter: NumberFormatter
     var allowDecimalSeparator: Bool
     var showArrows: Bool
     var previousTextField: (() -> Void)?
     var nextTextField: (() -> Void)?
+    var initialFocus: Bool
+
+    @FocusState private var isFocused: Bool
+    @State private var localText: String = ""
+    // State flag to track if the field was intentionally cleared to zero
+    @State private var isZeroCleared: Bool = false
 
     public init(
         text: Binding<Decimal>,
         placeholder: String,
-        textColor: UIColor = .label,
-        textAlignment: NSTextAlignment = .right,
+        textColor: Color = .primary,
+        textAlignment: TextAlignment = .trailing,
         keyboardType: UIKeyboardType = .decimalPad,
-        autocapitalizationType: UITextAutocapitalizationType = .none,
-        autocorrectionType: UITextAutocorrectionType = .no,
-        shouldBecomeFirstResponder: Bool = false,
         maxLength: Int? = nil,
+        maxValue: Decimal? = nil,
         isDismissible: Bool = true,
         textFieldDidBeginEditing: (() -> Void)? = nil,
+        textDidChange: ((Decimal) -> Void)? = nil,
         numberFormatter: NumberFormatter,
         allowDecimalSeparator: Bool = true,
         showArrows: Bool = false,
         previousTextField: (() -> Void)? = nil,
-        nextTextField: (() -> Void)? = nil
+        nextTextField: (() -> Void)? = nil,
+        initialFocus: Bool = false
     ) {
         _text = text
         self.placeholder = placeholder
         self.textColor = textColor
         self.textAlignment = textAlignment
         self.keyboardType = keyboardType
-        self.autocapitalizationType = autocapitalizationType
-        self.autocorrectionType = autocorrectionType
-        self.shouldBecomeFirstResponder = shouldBecomeFirstResponder
         self.maxLength = maxLength
+        self.maxValue = maxValue
         self.isDismissible = isDismissible
         self.textFieldDidBeginEditing = textFieldDidBeginEditing
+        self.textDidChange = textDidChange
         self.numberFormatter = numberFormatter
         self.numberFormatter.numberStyle = .decimal
         self.allowDecimalSeparator = allowDecimalSeparator
         self.showArrows = showArrows
         self.previousTextField = previousTextField
         self.nextTextField = nextTextField
+        self.initialFocus = initialFocus
     }
 
-    public func makeUIView(context: Context) -> UITextField {
-        let textField = UITextField()
-        context.coordinator.textField = textField
-        textField.inputAccessoryView = isDismissible ? createToolbar(for: textField, context: context) : nil
-        textField.addTarget(context.coordinator, action: #selector(Coordinator.editingDidBegin), for: .editingDidBegin)
-        textField.delegate = context.coordinator
-        if text == 0 { /// show no value initially, i.e. empty String
-            textField.text = ""
-        } else {
-            textField.text = numberFormatter.string(for: text)
-        }
-        textField.placeholder = placeholder
-        return textField
-    }
-
-    /// Creates and configures a toolbar for the text field with navigation and action buttons.
-    /// - Parameters:
-    ///   - _: The text field for which the toolbar is being created (unused parameter).
-    ///   - context: The SwiftUI context that contains the coordinator for handling button actions.
-    /// - Returns: A configured UIToolbar with appropriate buttons based on the view's configuration.
-    private func createToolbar(for _: UITextField, context: Context) -> UIToolbar {
-        let toolbar = UIToolbar()
-        var items: [UIBarButtonItem] = []
-
-        // Add navigation arrows if enabled
-        if showArrows {
-            // Add clear button
-            items.append(
-                UIBarButtonItem(
-                    image: UIImage(systemName: "trash"),
-                    style: .plain,
-                    target: context.coordinator,
-                    action: #selector(Coordinator.clearText)
-                )
-            )
-
-            if previousTextField != nil {
-                let previousButton = UIBarButtonItem(
-                    image: UIImage(systemName: "chevron.up"),
-                    style: .plain,
-                    target: context.coordinator,
-                    action: #selector(Coordinator.previousTextField)
-                )
-                items.append(previousButton)
+    public var body: some View {
+        TextField(placeholder, text: $localText)
+            .focused($isFocused)
+            .multilineTextAlignment(textAlignment)
+            .foregroundColor(textColor)
+            .keyboardType(keyboardType)
+            .toolbar {
+                if isFocused {
+                    ToolbarItemGroup(placement: .keyboard) {
+                        Button(action: {
+                            localText = ""
+                            text = 0
+                            isZeroCleared = true // Mark as cleared to prevent showing "0"
+                            textDidChange?(0)
+                        }) {
+                            Image(systemName: "trash")
+                        }
+
+                        if showArrows {
+                            Button(action: { previousTextField?() }) {
+                                Image(systemName: "chevron.up")
+                            }
+                            Button(action: { nextTextField?() }) {
+                                Image(systemName: "chevron.down")
+                            }
+                        }
+
+                        Spacer()
+
+                        if isDismissible {
+                            Button(action: { isFocused = false }) {
+                                Image(systemName: "keyboard.chevron.compact.down")
+                            }
+                        }
+                    }
+                }
             }
-
-            if nextTextField != nil {
-                let nextButton = UIBarButtonItem(
-                    image: UIImage(systemName: "chevron.down"),
-                    style: .plain,
-                    target: context.coordinator,
-                    action: #selector(Coordinator.nextTextField)
-                )
-                items.append(nextButton)
+            .onChange(of: isFocused) { _, newValue in
+                if newValue {
+                    textFieldDidBeginEditing?()
+                    // When gaining focus: if the value is zero and was previously cleared,
+                    // keep the text field empty to show placeholder instead of "0"
+                    if isZeroCleared, text == 0 {
+                        localText = ""
+                    }
+                } else {
+                    // When losing focus: handle formatting and validation
+                    if localText.isEmpty {
+                        // If field is empty, maintain zero value but mark as cleared
+                        // so we can show placeholder instead of "0"
+                        text = 0
+                        isZeroCleared = true
+                    } else if let decimal = Decimal(string: localText, locale: numberFormatter.locale) {
+                        if decimal != 0 {
+                            // For non-zero values, format normally and update binding
+                            text = decimal
+                            localText = numberFormatter.string(from: decimal as NSNumber) ?? ""
+                            isZeroCleared = false
+                        } else {
+                            // If user explicitly entered zero, store the value but
+                            // keep display empty to show placeholder
+                            text = 0
+                            localText = ""
+                            isZeroCleared = true
+                        }
+                    }
+                }
             }
-        }
-
-        // Add flexible space
-        items.append(UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil))
-
-        // Add done button
-        items.append(
-            UIBarButtonItem(
-                barButtonSystemItem: .done,
-                target: UIApplication.shared,
-                action: #selector(UIApplication.endEditing)
-            )
-        )
-
-        toolbar.items = items
-        toolbar.sizeToFit()
-
-        return toolbar
-    }
-
-    public func updateUIView(_ textField: UITextField, context: Context) {
-        if text != 0 {
-            let newText = numberFormatter.string(for: text) ?? ""
-            if textField.text != newText {
-                textField.text = newText
+            .onChange(of: localText) { oldValue, newValue in
+                // Reset zero-cleared state as soon as user starts typing anything
+                if !newValue.isEmpty {
+                    isZeroCleared = false
+                }
+
+                // Special handling for backspace operations to maintain decimal format
+                if oldValue.count == newValue.count + 1 {
+                    let decimalSeparator = numberFormatter.decimalSeparator ?? "."
+
+                    // Special case: When backspacing to leave only a decimal point
+                    // e.g., "10.1" -> "10." - Keep decimal separator without adding trailing zero
+                    if newValue.hasSuffix(decimalSeparator) {
+                        if let decimal = Decimal(string: newValue + "0", locale: numberFormatter.locale) {
+                            text = decimal
+                            textDidChange?(decimal)
+                        }
+                        return
+                    }
+
+                    // Special case: When backspacing the last digit after a decimal point
+                    // e.g., "10.0" -> "10." - Ensure we keep proper decimal format
+                    if oldValue.contains(decimalSeparator), newValue.contains(decimalSeparator) {
+                        let oldParts = oldValue.components(separatedBy: decimalSeparator)
+                        let newParts = newValue.components(separatedBy: decimalSeparator)
+
+                        // Check if we've removed the last digit after decimal point
+                        if oldParts.count > 1, newParts.count > 1,
+                           oldParts[1].count == 1, newParts[1].isEmpty
+                        {
+                            // Keep proper decimal format by adding trailing zero
+                            localText = newParts[0] + decimalSeparator + "0"
+
+                            if let decimal = Decimal(string: localText, locale: numberFormatter.locale) {
+                                text = decimal
+                                textDidChange?(decimal)
+                            }
+                            return
+                        }
+                    }
+                }
+
+                // Process normal text input changes
+                handleTextChange(oldValue, newValue)
             }
-        }
-
-        textField.textColor = textColor
-        textField.textAlignment = textAlignment
-        textField.keyboardType = keyboardType
-        textField.autocapitalizationType = autocapitalizationType
-        textField.autocorrectionType = autocorrectionType
-
-        if shouldBecomeFirstResponder, !context.coordinator.didBecomeFirstResponder {
-            if textField.window != nil, textField.becomeFirstResponder() {
-                context.coordinator.didBecomeFirstResponder = true
+            .onChange(of: text) { oldValue, newValue in
+                // Handle external changes to the text binding
+                // (changes not initiated by typing, like programmatic changes)
+                if oldValue != newValue,
+                   Decimal(string: localText, locale: numberFormatter.locale) != newValue
+                {
+                    if newValue == 0, isZeroCleared {
+                        // If value is zero and field was cleared, keep display empty to show placeholder
+                        localText = ""
+                    } else {
+                        // Otherwise format and display the new value
+                        localText = numberFormatter.string(from: newValue as NSNumber) ?? ""
+                        isZeroCleared = false
+                    }
+                }
+            }
+            .onAppear {
+                if text != 0 {
+                    // Initialize with formatted non-zero value
+                    localText = numberFormatter.string(from: text as NSNumber) ?? ""
+                    isZeroCleared = false
+                } else {
+                    // For zero values, start with empty field to show placeholder
+                    localText = ""
+                    isZeroCleared = true
+                }
+                // Set initial focus if requested
+                isFocused = initialFocus
             }
-        } else if !shouldBecomeFirstResponder, context.coordinator.didBecomeFirstResponder {
-            context.coordinator.didBecomeFirstResponder = false
-        }
-    }
-
-    public func makeCoordinator() -> Coordinator {
-        Coordinator(self, maxLength: maxLength)
     }
 
-    public final class Coordinator: NSObject {
-        var parent: TextFieldWithToolBar
-        var textField: UITextField?
-        let maxLength: Int?
-        var didBecomeFirstResponder = false
-        let decimalFormatter: NumberFormatter
-
-        init(_ parent: TextFieldWithToolBar, maxLength: Int?) {
-            self.parent = parent
-            self.maxLength = maxLength
-            decimalFormatter = NumberFormatter()
-            decimalFormatter.locale = Locale.current
-            decimalFormatter.numberStyle = .decimal
+    private func handleTextChange(_ oldValue: String, _ newValue: String) {
+        // Handle empty input (clear operation)
+        if newValue.isEmpty {
+            text = 0
+            isZeroCleared = true
+            textDidChange?(0)
+            return
         }
 
-        @objc fileprivate func clearText() {
-            parent.text = 0
-            textField?.text = ""
+        // Remove leading zeros except for decimal values (e.g., "0.5")
+        // This prevents inputs like "01", "0123", etc. but allows "0.5"
+        if newValue.count > 1 && newValue.hasPrefix("0") && !newValue.hasPrefix("0" + (numberFormatter.decimalSeparator ?? ".")) {
+            localText = String(newValue.dropFirst())
+            return
         }
 
-        @objc fileprivate func editingDidBegin(_ textField: UITextField) {
-            DispatchQueue.main.async {
-                textField.moveCursorToEnd()
-            }
-        }
+        let currentDecimalSeparator = numberFormatter.decimalSeparator ?? "."
 
-        @objc fileprivate func previousTextField() {
-            parent.previousTextField?()
+        // Ensure there's only one decimal separator
+        let decimalSeparatorCount = newValue.filter { String($0) == currentDecimalSeparator }.count
+        if decimalSeparatorCount > 1 {
+            // Reject input with multiple decimal separators
+            localText = oldValue
+            return
         }
 
-        @objc fileprivate func nextTextField() {
-            parent.nextTextField?()
+        // Handle localization by converting to the correct decimal separator
+        var processedText = newValue
+        if newValue.contains("."), currentDecimalSeparator != "." {
+            processedText = newValue.replacingOccurrences(of: ".", with: currentDecimalSeparator)
+        } else if newValue.contains(","), currentDecimalSeparator != "," {
+            processedText = newValue.replacingOccurrences(of: ",", with: currentDecimalSeparator)
         }
 
-        // Helper method to calculate the number of decimal places in a string
-        fileprivate func calculateDecimalPlaces(in string: String) -> Int {
-            guard let decimalSeparator = decimalFormatter.decimalSeparator else { return 0 }
-            if let range = string.range(of: decimalSeparator) {
-                let decimalPart = string[range.upperBound...]
-                return decimalPart.count
-            }
-            return 0
+        // Automatically add leading zero when starting with decimal separator
+        // For example ".5" becomes "0.5"
+        if processedText.hasPrefix(currentDecimalSeparator) {
+            processedText = "0" + processedText
         }
 
-        // Helper method to check if the cursor is after the decimal separator
-        fileprivate func isCursorAfterDecimal(in textField: UITextField, range: NSRange) -> Bool {
-            guard let text = textField.text, let decimalSeparator = decimalFormatter.decimalSeparator else { return false }
-            if let decimalSeparatorRange = text.range(of: decimalSeparator) {
-                let decimalSeparatorPosition = text.distance(from: text.startIndex, to: decimalSeparatorRange.lowerBound)
-                return range.location > decimalSeparatorPosition
-            }
-            return false
-        }
-    }
-}
+        // Validate against number formatter digit limits
+        let components = processedText.components(separatedBy: currentDecimalSeparator)
 
-extension TextFieldWithToolBar.Coordinator: UITextFieldDelegate {
-    public func textFieldDidEndEditing(_ textField: UITextField) {
-        if let text = textField.text,
-           let decimal = Decimal(string: text, locale: parent.numberFormatter.locale)
-        {
-            // Format the number properly when editing ends
-            textField.text = parent.numberFormatter.string(from: decimal as NSNumber)
-            parent.text = decimal
+        // Process the integer part (before decimal)
+        var integerPart = components[0].filter { $0.isNumber }
+        // Remove leading zeros for accurate digit counting
+        while integerPart.hasPrefix("0") && integerPart.count > 1 {
+            integerPart.removeFirst()
         }
-    }
+        let integerDigits = integerPart.count
 
-    public func textField(
-        _ textField: UITextField,
-        shouldChangeCharactersIn range: NSRange,
-        replacementString string: String
-    ) -> Bool {
-        // Check if the input is a number or the decimal separator
-        let isNumber = CharacterSet.decimalDigits.isSuperset(of: CharacterSet(charactersIn: string))
-
-        // Get the current locale's decimal separator
-        let currentDecimalSeparator = parent.numberFormatter.decimalSeparator ?? "."
-
-        // Check if input is a decimal separator (either . or ,)
-        let isInputDecimalSeparator = string == "." || string == ","
-
-        // Only allow the decimal separator configured in the locale
-        if isInputDecimalSeparator {
-            // If it's not the correct decimal separator for this locale, reject it
-            if string != currentDecimalSeparator {
-                return false
-            }
-            // Check if the field already contains a decimal separator
-            if textField.text?.contains(currentDecimalSeparator) == true {
-                return false
-            }
-        }
+        // Count fraction digits (after decimal separator)
+        let fractionDigits = components.count > 1 ? components[1].filter { $0.isNumber }.count : 0
 
-        // Only proceed if the input is a valid number or the correct decimal separator
-        if isNumber || (string == currentDecimalSeparator && parent.allowDecimalSeparator),
-           let currentText = textField.text as NSString?
+        // Validate against the formatter's digit limits
+        if integerDigits > numberFormatter.maximumIntegerDigits ||
+            (allowDecimalSeparator && fractionDigits > numberFormatter.maximumFractionDigits)
         {
-            // Calculate the new text length
-            let newLength = currentText.length + string.count - range.length
-
-            // Check max length if specified
-            if let maxLength = parent.maxLength, newLength > maxLength {
-                return false
-            }
-
-            // Create the new text string
-            let newText = currentText.replacingCharacters(in: range, with: string)
-
-            // If text starts with decimal separator, add leading zero
-            if newText.hasPrefix(currentDecimalSeparator) {
-                textField.text = "0" + newText
-                parent.text = Decimal(string: textField.text ?? "0") ?? 0
-                return false
-            }
+            // Reject input that exceeds digit limits
+            localText = oldValue
+            return
+        }
 
-            // Update the binding
-            if let decimal = Decimal(string: newText, locale: parent.numberFormatter.locale) {
-                parent.text = decimal
+        // Parse and validate the decimal value
+        if let decimal = Decimal(string: processedText, locale: numberFormatter.locale) {
+            if let maxValue = maxValue, decimal > maxValue {
+                // Cap at maximum allowed value
+                text = maxValue
+                localText = numberFormatter.string(from: maxValue as NSNumber) ?? ""
+                isZeroCleared = false
+            } else {
+                // Accept valid input and update binding
+                text = decimal
+
+                // Update zero-cleared state based on the value
+                isZeroCleared = (decimal == 0) && localText.isEmpty
+
+                textDidChange?(decimal)
+
+                // If we had to process/modify the input, update the displayed text
+                if processedText != newValue {
+                    localText = processedText
+                }
             }
-
-            return true
+        } else {
+            // Reject invalid decimal inputs
+            localText = oldValue
         }
-
-        // Allow the change if it's a valid number or the correct decimal separator
-        return isNumber || (string == currentDecimalSeparator && parent.allowDecimalSeparator)
-    }
-
-    public func textFieldDidBeginEditing(_: UITextField) {
-        parent.textFieldDidBeginEditing?()
     }
 }
 

+ 8 - 7
TrioTests/CoreDataTests/CarbsStorageTests.swift

@@ -8,11 +8,12 @@ import Testing
 @Suite("CarbsStorage Tests", .serialized) struct CarbsStorageTests: Injectable {
     @Injected() var storage: CarbsStorage!
     let resolver: Resolver
-    let coreDataStack = CoreDataStack.createForTests()
-    let testContext: NSManagedObjectContext
+    var coreDataStack: CoreDataStack!
+    var testContext: NSManagedObjectContext!
 
-    init() {
+    init() async throws {
         // Create test context
+        coreDataStack = try await CoreDataStack.createForTests()
         testContext = coreDataStack.newTaskContext()
 
         // Create assembler with test assembly
@@ -55,7 +56,7 @@ import Testing
 
         // When
         try await storage.storeCarbs(testEntries, areFetchedFromRemote: false)
-        let recentEntries = try await CoreDataStack.shared.fetchEntitiesAsync(
+        let recentEntries = try await coreDataStack.fetchEntitiesAsync(
             ofType: CarbEntryStored.self,
             onContext: testContext,
             predicate: NSPredicate(format: "TRUEPREDICATE"),
@@ -95,7 +96,7 @@ import Testing
         try await storage.storeCarbs([testEntry], areFetchedFromRemote: false)
 
         // Get the stored entry's ObjectID
-        let storedEntries = try await CoreDataStack.shared.fetchEntitiesAsync(
+        let storedEntries = try await coreDataStack.fetchEntitiesAsync(
             ofType: CarbEntryStored.self,
             onContext: testContext,
             predicate: NSPredicate(format: "carbs == 30"),
@@ -111,7 +112,7 @@ import Testing
         await storage.deleteCarbsEntryStored(objectID)
 
         // Then - verify deletion
-        let remainingEntries = try await CoreDataStack.shared.fetchEntitiesAsync(
+        let remainingEntries = try await coreDataStack.fetchEntitiesAsync(
             ofType: CarbEntryStored.self,
             onContext: testContext,
             predicate: NSPredicate(format: "carbs == 30"),
@@ -166,7 +167,7 @@ import Testing
         try await storage.storeCarbs([testEntry], areFetchedFromRemote: false)
 
         // First verify all stored entries
-        let allStoredEntries = try await CoreDataStack.shared.fetchEntitiesAsync(
+        let allStoredEntries = try await coreDataStack.fetchEntitiesAsync(
             ofType: CarbEntryStored.self,
             onContext: testContext,
             predicate: NSPredicate(format: "fpuID == %@", fpuID),

+ 4 - 3
TrioTests/CoreDataTests/DeterminationStorageTests.swift

@@ -8,12 +8,13 @@ import Testing
 @Suite("Determination Storage Tests", .serialized) struct DeterminationStorageTests: Injectable {
     @Injected() var storage: DeterminationStorage!
     let resolver: Resolver
-    let coreDataStack = CoreDataStack.createForTests()
-    let testContext: NSManagedObjectContext
+    var coreDataStack: CoreDataStack!
+    var testContext: NSManagedObjectContext!
 
-    init() {
+    init() async throws {
         // Create test context
         // As we are only using this single test context to initialize our in-memory DeterminationStorage we need to perform the Unit Tests serialized
+        coreDataStack = try await CoreDataStack.createForTests()
         testContext = coreDataStack.newTaskContext()
 
         // Create assembler with test assembly

+ 10 - 9
TrioTests/CoreDataTests/GlucoseStorageTests.swift

@@ -8,12 +8,13 @@ import Testing
 @Suite("GlucoseStorage Tests", .serialized) struct GlucoseStorageTests: Injectable {
     @Injected() var storage: GlucoseStorage!
     let resolver: Resolver
-    let coreDataStack = CoreDataStack.createForTests()
-    let testContext: NSManagedObjectContext
+    var coreDataStack: CoreDataStack!
+    var testContext: NSManagedObjectContext!
 
-    init() {
+    init() async throws {
         // Create test context
         // As we are only using this single test context to initialize our in-memory DeterminationStorage we need to perform the Unit Tests serialized
+        coreDataStack = try await CoreDataStack.createForTests()
         testContext = coreDataStack.newTaskContext()
 
         // Create assembler with test assembly
@@ -49,7 +50,7 @@ import Testing
         try await storage.storeGlucose(testGlucose)
 
         // Then verify stored entries
-        let storedEntries = try await CoreDataStack.shared.fetchEntitiesAsync(
+        let storedEntries = try await coreDataStack.fetchEntitiesAsync(
             ofType: GlucoseStored.self,
             onContext: testContext,
             predicate: NSPredicate(format: "glucose == 126"),
@@ -71,7 +72,7 @@ import Testing
         try await storage.storeGlucose(testGlucose)
 
         // Get the stored entry's ObjectID
-        let storedEntries = try await CoreDataStack.shared.fetchEntitiesAsync(
+        let storedEntries = try await coreDataStack.fetchEntitiesAsync(
             ofType: GlucoseStored.self,
             onContext: testContext,
             predicate: NSPredicate(format: "glucose == 140"),
@@ -89,7 +90,7 @@ import Testing
         await storage.deleteGlucose(objectID)
 
         // Then verify deletion
-        let remainingEntries = try await CoreDataStack.shared.fetchEntitiesAsync(
+        let remainingEntries = try await coreDataStack.fetchEntitiesAsync(
             ofType: GlucoseStored.self,
             onContext: testContext,
             predicate: NSPredicate(format: "glucose == 140"),
@@ -144,7 +145,7 @@ import Testing
 
         // When - Test low glucose
         try await storage.storeGlucose(lowGlucose)
-        var storedEntries = try await CoreDataStack.shared.fetchEntitiesAsync(
+        var storedEntries = try await coreDataStack.fetchEntitiesAsync(
             ofType: GlucoseStored.self,
             onContext: testContext,
             predicate: NSPredicate(format: "glucose == 55"),
@@ -158,7 +159,7 @@ import Testing
 
         // When - Test high glucose
         try await storage.storeGlucose(highGlucose)
-        storedEntries = try await CoreDataStack.shared.fetchEntitiesAsync(
+        storedEntries = try await coreDataStack.fetchEntitiesAsync(
             ofType: GlucoseStored.self,
             onContext: testContext,
             predicate: NSPredicate(format: "glucose == 271"),
@@ -172,7 +173,7 @@ import Testing
 
         // When - Test normal glucose
         try await storage.storeGlucose(normalGlucose)
-        storedEntries = try await CoreDataStack.shared.fetchEntitiesAsync(
+        storedEntries = try await coreDataStack.fetchEntitiesAsync(
             ofType: GlucoseStored.self,
             onContext: testContext,
             predicate: NSPredicate(format: "glucose == 100"),

+ 7 - 6
TrioTests/CoreDataTests/OverrideStorageTests.swift

@@ -8,12 +8,13 @@ import Testing
 @Suite("Override Storage Tests", .serialized) struct OverrideStorageTests: Injectable {
     @Injected() var storage: OverrideStorage!
     let resolver: Resolver
-    let coreDataStack = CoreDataStack.createForTests()
-    let testContext: NSManagedObjectContext
+    var coreDataStack: CoreDataStack!
+    var testContext: NSManagedObjectContext!
 
-    init() {
+    init() async throws {
         // Create test context
         // As we are only using this single test context to initialize our in-memory DeterminationStorage we need to perform the Unit Tests serialized
+        coreDataStack = try await CoreDataStack.createForTests()
         testContext = coreDataStack.newTaskContext()
 
         // Create assembler with test assembly
@@ -68,7 +69,7 @@ import Testing
         try await storage.storeOverride(override: testOverride)
 
         // Then verify stored entries
-        let storedEntries = try await CoreDataStack.shared.fetchEntitiesAsync(
+        let storedEntries = try await coreDataStack.fetchEntitiesAsync(
             ofType: OverrideStored.self,
             onContext: testContext,
             predicate: NSPredicate(format: "name == %@", "Test Override"),
@@ -158,7 +159,7 @@ import Testing
         try await storage.storeOverride(override: testPreset)
 
         // Get the stored preset's ObjectID
-        let storedEntries = try await CoreDataStack.shared.fetchEntitiesAsync(
+        let storedEntries = try await coreDataStack.fetchEntitiesAsync(
             ofType: OverrideStored.self,
             onContext: testContext,
             predicate: NSPredicate(format: "name == %@", "Delete Test"),
@@ -174,7 +175,7 @@ import Testing
         await storage.deleteOverridePreset(objectID)
 
         // Then verify deletion
-        let remainingEntries = try await CoreDataStack.shared.fetchEntitiesAsync(
+        let remainingEntries = try await coreDataStack.fetchEntitiesAsync(
             ofType: OverrideStored.self,
             onContext: testContext,
             predicate: NSPredicate(format: "name == %@", "Delete Test"),

+ 4 - 3
TrioTests/CoreDataTests/PumpHistoryStorageTests.swift

@@ -9,12 +9,13 @@ import Testing
 @Suite("PumpHistoryStorage Tests", .serialized) struct PumpHistoryStorageTests: Injectable {
     @Injected() var storage: PumpHistoryStorage!
     let resolver: Resolver
-    let coreDataStack = CoreDataStack.createForTests()
-    let testContext: NSManagedObjectContext
+    var coreDataStack: CoreDataStack!
+    var testContext: NSManagedObjectContext!
     typealias PumpEvent = PumpEventStored.EventType
 
-    init() {
+    init() async throws {
         // Create test context
+        coreDataStack = try await CoreDataStack.createForTests()
         testContext = coreDataStack.newTaskContext()
 
         // Create assembler with test assembly

+ 7 - 6
TrioTests/CoreDataTests/TempTargetStorageTests.swift

@@ -8,11 +8,12 @@ import Testing
 @Suite("TempTargetStorage Tests", .serialized) struct TempTargetsStorageTests: Injectable {
     @Injected() var storage: TempTargetsStorage!
     let resolver: Resolver
-    let coreDataStack = CoreDataStack.createForTests()
-    let testContext: NSManagedObjectContext
+    var coreDataStack: CoreDataStack!
+    var testContext: NSManagedObjectContext!
 
-    init() {
+    init() async throws {
         // Create test context
+        coreDataStack = try await CoreDataStack.createForTests()
         testContext = coreDataStack.newTaskContext()
 
         // Create assembler with test assembly
@@ -58,7 +59,7 @@ import Testing
         try await storage.storeTempTarget(tempTarget: testTarget)
 
         // Then verify stored entries
-        let storedEntries = try await CoreDataStack.shared.fetchEntitiesAsync(
+        let storedEntries = try await coreDataStack.fetchEntitiesAsync(
             ofType: TempTargetStored.self,
             onContext: testContext,
             predicate: NSPredicate(format: "name == %@", "Test Target"),
@@ -91,7 +92,7 @@ import Testing
         try await storage.storeTempTarget(tempTarget: testTarget)
 
         // Get the stored target's ObjectID
-        let storedEntries = try await CoreDataStack.shared.fetchEntitiesAsync(
+        let storedEntries = try await coreDataStack.fetchEntitiesAsync(
             ofType: TempTargetStored.self,
             onContext: testContext,
             predicate: NSPredicate(format: "name == %@", "Delete Test"),
@@ -107,7 +108,7 @@ import Testing
         await storage.deleteTempTargetPreset(objectID)
 
         // Then verify deletion
-        let remainingEntries = try await CoreDataStack.shared.fetchEntitiesAsync(
+        let remainingEntries = try await coreDataStack.fetchEntitiesAsync(
             ofType: TempTargetStored.self,
             onContext: testContext,
             predicate: NSPredicate(format: "name == %@", "Delete Test"),