Kaynağa Gözat

Merge branch 'dev' of github.com:nightscout/Trio-dev into onboarding-adjustments

Deniz Cengiz 1 yıl önce
ebeveyn
işleme
44609dd16e
25 değiştirilmiş dosya ile 787 ekleme ve 151 silme
  1. 168 77
      Model/JSONImporter.swift
  2. 12 0
      Trio.xcodeproj/project.pbxproj
  3. 57 0
      Trio/Sources/Application/TrioApp.swift
  4. 45 3
      Trio/Sources/Localizations/Main/Localizable.xcstrings
  5. 5 1
      Trio/Sources/Models/PumpHistoryEvent.swift
  6. 1 0
      Trio/Sources/Modules/CGMSettings/CGMSettingsStateModel.swift
  7. 46 38
      Trio/Sources/Modules/CGMSettings/View/CGMRootView.swift
  8. 1 0
      Trio/Sources/Modules/Home/HomeStateModel.swift
  9. 0 1
      Trio/Sources/Modules/Home/View/Header/CurrentGlucoseView.swift
  10. 0 1
      Trio/Sources/Modules/Home/View/Header/PumpView.swift
  11. 18 12
      Trio/Sources/Modules/Home/View/HomeRootView.swift
  12. 2 2
      Trio/Sources/Modules/Main/View/MainLoadingView.swift
  13. 131 0
      Trio/Sources/Modules/Main/View/MainMigrationErrorView.swift
  14. 1 1
      Trio/Sources/Modules/Onboarding/View/OnboardingSteps/CompletedStepView.swift
  15. 1 1
      Trio/Sources/Modules/Onboarding/View/OnboardingSteps/StartupGuide/StartupReturningUserStepView.swift
  16. 1 0
      Trio/Sources/Modules/PumpConfig/PumpConfigStateModel.swift
  17. 8 2
      Trio/Sources/Modules/PumpConfig/View/PumpConfigRootView.swift
  18. 1 0
      Trio/Sources/Modules/Treatments/TreatmentsStateModel.swift
  19. 15 3
      Trio/Sources/Router/Screen.swift
  20. 14 3
      Trio/Sources/Services/BolusCalculator/BolusCalculationManager.swift
  21. 24 1
      Trio/Sources/Services/WatchManager/AppleWatchManager.swift
  22. 35 0
      Trio/Sources/Views/BluetoothRequiredView.swift
  23. 157 5
      TrioTests/BolusCalculatorTests/BolusCalculatorTests.swift
  24. 9 0
      TrioTests/JSONImporterData/pumphistory-with-external.json
  25. 35 0
      TrioTests/JSONImporterTests.swift

+ 168 - 77
Model/JSONImporter.swift

@@ -56,74 +56,42 @@ class JSONImporter {
         return try decoder.decode(T.self, from: data)
     }
 
-    /// Retrieves the set of dates for all glucose values currently stored in CoreData.
+    /// Fetches a set of unique `Date` values for a specific `NSManagedObject` type from Core Data.
     ///
-    /// - Parameters: the start and end dates to fetch glucose values, inclusive
-    /// - Returns: A set of dates corresponding to existing glucose readings.
-    /// - Throws: An error if the fetch operation fails.
-    private func fetchGlucoseDates(start: Date, end: Date) async throws -> Set<Date> {
-        let allReadings = try await coreDataStack.fetchEntitiesAsync(
-            ofType: GlucoseStored.self,
-            onContext: context,
-            predicate: .predicateForDateBetween(start: start, end: end),
-            key: "date",
-            ascending: false
-        ) as? [GlucoseStored] ?? []
-
-        return Set(allReadings.compactMap(\.date))
-    }
-
-    /// Retrieves the set of timestamps for all pump events currently stored in CoreData.
+    /// This helper function is used to retrieve all existing date-like values (e.g., `date`, `timestamp`, `deliverAt`)
+    /// from a given entity type within a specified time range. It wraps the fetch and transformation
+    /// in a `context.perform` block to ensure thread safety when used on private background contexts.
     ///
-    /// - Parameters: the start and end dates to fetch pump events, inclusive
-    /// - Returns: A set of dates corresponding to existing pump events.
-    /// - Throws: An error if the fetch operation fails.
-    private func fetchPumpTimestamps(start: Date, end: Date) async throws -> Set<Date> {
-        let allPumpEvents = try await coreDataStack.fetchEntitiesAsync(
-            ofType: PumpEventStored.self,
-            onContext: context,
-            predicate: .predicateForTimestampBetween(start: start, end: end),
-            key: "timestamp",
-            ascending: false
-        ) as? [PumpEventStored] ?? []
-
-        return Set(allPumpEvents.compactMap(\.timestamp))
-    }
-
-    /// Retrieves the set of timestamps for all carb entries currently stored in CoreData.
+    /// - Parameters:
+    ///   - type: The `NSManagedObject` subclass to fetch (e.g., `GlucoseStored.self`, `PumpEventStored.self`)
+    ///   - predicate: A preconstructed predicate that filters the entity by date/timestamp range.
+    ///   - sortKey: The string name of the date-like field used to sort the fetch results. **This must match the key used in Core Data.**
+    ///   - dateKeyPath: A key path pointing to the `Date?` property on the entity used to extract the actual date value from each record.
     ///
-    /// - Parameters: the start and end dates to fetch carb entries, inclusive
-    /// - Returns: A set of dates corresponding to existing carb entries.
-    /// - Throws: An error if the fetch operation fails.
-    private func fetchCarbEntryDates(start: Date, end: Date) async throws -> Set<Date> {
-        let allCarbEntryDates = try await coreDataStack.fetchEntitiesAsync(
-            ofType: CarbEntryStored.self,
+    /// - Returns: A `Set<Date>` containing all non-nil date values from the fetched entities.
+    /// - Throws: `CoreDataError.fetchError` if casting the fetched objects fails, or if the fetch itself fails.
+
+    private func fetchDates<T: NSManagedObject>(
+        ofType type: T.Type,
+        predicate: NSPredicate,
+        sortKey: String,
+        dateKeyPath: KeyPath<T, Date?>
+    ) async throws -> Set<Date> {
+        let fetched = try await coreDataStack.fetchEntitiesAsync(
+            ofType: type,
             onContext: context,
-            predicate: .predicateForDateBetween(start: start, end: end),
-            key: "date",
+            predicate: predicate,
+            key: sortKey,
             ascending: false
-        ) as? [CarbEntryStored] ?? []
+        )
 
-        return Set(allCarbEntryDates.compactMap(\.date))
-    }
-
-    /// Retrieves the set of dates for all oref determinations currently stored in CoreData.
-    ///
-    /// - Parameters:
-    ///   - start: the start to fetch from; inclusive
-    ///   - end: the end date to fetch to; inclusive
-    /// - Returns: A set of dates corresponding to existing determinations.
-    /// - Throws: An error if the fetch operation fails.
-    private func fetchDeterminationDates(start: Date, end: Date) async throws -> Set<Date> {
-        let determinations = try await coreDataStack.fetchEntitiesAsync(
-            ofType: OrefDetermination.self,
-            onContext: context,
-            predicate: .predicateForDeliverAtBetween(start: start, end: end),
-            key: "deliverAt",
-            ascending: false
-        ) as? [OrefDetermination] ?? []
+        return try await context.perform {
+            guard let typed = fetched as? [T] else {
+                throw CoreDataError.fetchError(function: #function, file: #file)
+            }
 
-        return Set(determinations.compactMap(\.deliverAt))
+            return Set(typed.compactMap { $0[keyPath: dateKeyPath] })
+        }
     }
 
     /// Imports glucose history from a JSON file into CoreData.
@@ -141,7 +109,12 @@ class JSONImporter {
     func importGlucoseHistory(url: URL, now: Date) async throws {
         let twentyFourHoursAgo = now - 24.hours.timeInterval
         let glucoseHistoryFull: [BloodGlucose] = try readJsonFile(url: url)
-        let existingDates = try await fetchGlucoseDates(start: twentyFourHoursAgo, end: now)
+        let existingDates = try await fetchDates(
+            ofType: GlucoseStored.self,
+            predicate: .predicateForDateBetween(start: twentyFourHoursAgo, end: now),
+            sortKey: "date",
+            dateKeyPath: \.date
+        )
 
         // only import glucose values from the last 24 hours that don't exist
         let glucoseHistory = glucoseHistoryFull
@@ -232,7 +205,12 @@ class JSONImporter {
     func importPumpHistory(url: URL, now: Date) async throws {
         let twentyFourHoursAgo = now - 24.hours.timeInterval
         let pumpHistoryRaw: [PumpHistoryEvent] = try readJsonFile(url: url)
-        let existingTimestamps = try await fetchPumpTimestamps(start: twentyFourHoursAgo, end: now)
+        let existingTimestamps = try await fetchDates(
+            ofType: PumpEventStored.self,
+            predicate: .predicateForTimestampBetween(start: twentyFourHoursAgo, end: now),
+            sortKey: "timestamp",
+            dateKeyPath: \.timestamp
+        )
         let pumpHistoryFiltered = pumpHistoryRaw
             .filter { $0.timestamp >= twentyFourHoursAgo && $0.timestamp <= now && !existingTimestamps.contains($0.timestamp) }
 
@@ -272,7 +250,12 @@ class JSONImporter {
     func importCarbHistory(url: URL, now: Date) async throws {
         let twentyFourHoursAgo = now - 24.hours.timeInterval
         let carbHistoryFull: [CarbsEntry] = try readJsonFile(url: url)
-        let existingDates = try await fetchCarbEntryDates(start: twentyFourHoursAgo, end: now)
+        let existingDates = try await fetchDates(
+            ofType: CarbEntryStored.self,
+            predicate: .predicateForDateBetween(start: twentyFourHoursAgo, end: now),
+            sortKey: "date",
+            dateKeyPath: \.date
+        )
 
         // Only import carb entries from the last 24 hours that do not exist yet in Core Data
         // Only import "true" carb entries; ignore all FPU entries (aka carb equivalents)
@@ -314,7 +297,12 @@ class JSONImporter {
         let twentyFourHoursAgo = now - 24.hours.timeInterval
         let enactedDetermination: Determination = try readJsonFile(url: enactedUrl)
         let suggestedDetermination: Determination = try readJsonFile(url: suggestedUrl)
-        let existingDates = try await fetchDeterminationDates(start: twentyFourHoursAgo, end: now)
+        let existingDates = try await fetchDates(
+            ofType: OrefDetermination.self,
+            predicate: .predicateForDeliverAtBetween(start: twentyFourHoursAgo, end: now),
+            sortKey: "deliverAt",
+            dateKeyPath: \.deliverAt
+        )
 
         /// Helper function to check if entries are from within the last 24 hours that do not yet exist in Core Data
         func checkDeterminationDate(_ date: Date) -> Bool {
@@ -396,7 +384,7 @@ extension PumpHistoryEvent {
             let bolusEntry = BolusStored(context: context)
             bolusEntry.amount = NSDecimalNumber(decimal: amount)
             bolusEntry.isSMB = isSMB ?? false
-            bolusEntry.isExternal = isExternal ?? false
+            bolusEntry.isExternal = isExternal ?? isExternalInsulin ?? false
             pumpEntry.bolus = bolusEntry
         } else if type == .tempBasal {
             guard let rate = rate, let duration = duration else {
@@ -486,18 +474,14 @@ extension CarbsEntry: Codable {
         carbEntry.id = id
             .flatMap({ UUID(uuidString: $0) }) ?? UUID() /// The `CodingKey` of `id` is `_id`, so this fine to use here
         carbEntry.date = actualDate ?? createdAt
-        carbEntry.carbs = Double(truncating: NSDecimalNumber(decimal: carbs))
-        carbEntry.fat = Double(truncating: NSDecimalNumber(decimal: fat ?? 0))
-        carbEntry.protein = Double(truncating: NSDecimalNumber(decimal: protein ?? 0))
+        carbEntry.carbs = Double(truncating: NSDecimalNumber(decimal: carbs.rounded(toPlaces: 0)))
+        carbEntry.fat = Double(truncating: NSDecimalNumber(decimal: fat?.rounded(toPlaces: 0) ?? 0))
+        carbEntry.protein = Double(truncating: NSDecimalNumber(decimal: protein?.rounded(toPlaces: 0) ?? 0))
         carbEntry.note = note ?? ""
         carbEntry.isFPU = false
         carbEntry.isUploadedToNS = true
         carbEntry.isUploadedToHealth = true
         carbEntry.isUploadedToTidepool = true
-
-        if fat != nil, protein != nil, let fpuId = fpuID {
-            carbEntry.fpuID = UUID(uuidString: fpuId)
-        }
     }
 }
 
@@ -741,8 +725,115 @@ extension Determination: Codable {
 }
 
 extension JSONImporter {
-    func importGlucoseHistoryIfNeeded() async {}
-    func importPumpHistoryIfNeeded() async {}
-    func importCarbHistoryIfNeeded() async {}
-    func importDeterminationIfNeeded() async {}
+    private func openAPSFileURL(_ relativePath: String) -> URL {
+        FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
+            .appendingPathComponent(relativePath)
+    }
+
+    func importGlucoseHistoryIfNeeded() async throws {
+        debug(.coreData, "Checking for glucose history JSON file...")
+
+        let url = openAPSFileURL(OpenAPS.Monitor.glucose)
+        let suffix = "migrated.json"
+
+        guard FileManager.default.fileExists(atPath: url.path) else {
+            debug(.coreData, "❌ No JSON file to import at \(url.path)")
+            return
+        }
+
+        debug(.coreData, "Glucose history JSON file found, proceeding with import of glucose history...")
+
+        try await importGlucoseHistory(url: url, now: Date())
+
+        debug(.coreData, "Glucose history JSON file imported successfully, moving to \(suffix)")
+
+        try FileManager.default.moveItem(
+            at: url,
+            to: url.deletingPathExtension().appendingPathExtension(suffix)
+        )
+
+        debug(.coreData, "Import of glucose history completed successfully.")
+    }
+
+    func importPumpHistoryIfNeeded() async throws {
+        debug(.coreData, "Checking for pump history JSON file...")
+
+        let url = openAPSFileURL(OpenAPS.Monitor.pumpHistory)
+        let suffix = "migrated.json"
+
+        guard FileManager.default.fileExists(atPath: url.path) else {
+            debug(.coreData, "❌ No JSON file to import at \(url.path)")
+            return
+        }
+
+        debug(.coreData, "Pump history JSON file found, proceeding with import of glucose history...")
+
+        try await importPumpHistory(url: url, now: Date())
+
+        debug(.coreData, "Pump history JSON file imported successfully, moving to \(suffix)")
+
+        try FileManager.default.moveItem(
+            at: url,
+            to: url.deletingPathExtension().appendingPathExtension(suffix)
+        )
+
+        debug(.coreData, "Import of pump history completed successfully.")
+    }
+
+    func importCarbHistoryIfNeeded() async throws {
+        debug(.coreData, "Checking for carb history JSON file...")
+
+        let url = openAPSFileURL(OpenAPS.Monitor.carbHistory)
+        let suffix = "migrated.json"
+
+        guard FileManager.default.fileExists(atPath: url.path) else {
+            debug(.coreData, "❌ No JSON file to import at \(url.path)")
+            return
+        }
+
+        debug(.coreData, "Carb history JSON file found, proceeding with import of glucose history...")
+
+        try await importCarbHistory(url: url, now: Date())
+
+        debug(.coreData, "Carb history JSON file imported successfully, moving to \(suffix)")
+
+        try FileManager.default.moveItem(
+            at: url,
+            to: url.deletingPathExtension().appendingPathExtension(suffix)
+        )
+
+        debug(.coreData, "Import of carb history completed successfully.")
+    }
+
+    func importDeterminationIfNeeded() async throws {
+        debug(.coreData, "Checking for determination JSON files...")
+
+        let enactedPath = OpenAPS.Enact.enacted // "enact/enacted.json"
+        let suggestedPath = OpenAPS.Enact.suggested // "enact/suggested.json"
+        let suffix = "migrated.json"
+
+        let enactedURL = openAPSFileURL(enactedPath)
+        let suggestedURL = openAPSFileURL(suggestedPath)
+
+        guard FileManager.default.fileExists(atPath: enactedURL.path),
+              FileManager.default.fileExists(atPath: suggestedURL.path)
+        else {
+            debug(.coreData, "❌ No JSON file to import at \(enactedURL.path) and/or \(suggestedURL.path)")
+            return
+        }
+
+        debug(.coreData, "Determination JSON files found, proceeding with import...")
+
+        try await importOrefDetermination(enactedUrl: enactedURL, suggestedUrl: suggestedURL, now: Date())
+
+        debug(.coreData, "Determination JSON file(s) imported successfully, moving to \(suffix)")
+
+        try FileManager.default.moveItem(at: enactedURL, to: enactedURL.deletingPathExtension().appendingPathExtension(suffix))
+        try FileManager.default.moveItem(
+            at: suggestedURL,
+            to: suggestedURL.deletingPathExtension().appendingPathExtension(suffix)
+        )
+
+        debug(.coreData, "Import of determination data completed successfully.")
+    }
 }

+ 12 - 0
Trio.xcodeproj/project.pbxproj

@@ -249,6 +249,7 @@
 		3B997DD32DC02AEF006B6BB2 /* glucose.json in Resources */ = {isa = PBXBuildFile; fileRef = 3B997DD12DC02AEF006B6BB2 /* glucose.json */; };
 		3BAD36B22D7CDC1A00CC298D /* MainLoadingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BAD36B12D7CDC1400CC298D /* MainLoadingView.swift */; };
 		3BAD36CC2D7D420E00CC298D /* CoreDataInitializationCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BAD36CB2D7D420500CC298D /* CoreDataInitializationCoordinator.swift */; };
+		3BCA5F7C2DC7B16400A7EAC7 /* pumphistory-with-external.json in Resources */ = {isa = PBXBuildFile; fileRef = 3BCA5F7B2DC7B15400A7EAC7 /* pumphistory-with-external.json */; };
 		3BD6CE262DC24CFD00FA0472 /* pumphistory-24h-zoned.json in Resources */ = {isa = PBXBuildFile; fileRef = 3BD6CE252DC24CFD00FA0472 /* pumphistory-24h-zoned.json */; };
 		3BD9687C2D8DDD4600899469 /* SlideButton in Frameworks */ = {isa = PBXBuildFile; productRef = 3BD9687B2D8DDD4600899469 /* SlideButton */; };
 		3BD9687F2D8DDD8800899469 /* CryptoSwift in Frameworks */ = {isa = PBXBuildFile; productRef = 3BD9687E2D8DDD8800899469 /* CryptoSwift */; };
@@ -557,6 +558,7 @@
 		DD3A3CE72D29C93F00AE478E /* Helper+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD3A3CE62D29C93F00AE478E /* Helper+Extensions.swift */; };
 		DD3A3CE92D29C97800AE478E /* Helper+ButtonStyles.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD3A3CE82D29C97800AE478E /* Helper+ButtonStyles.swift */; };
 		DD3C47B32DC5608A003DD20D /* newerSuggested.json in Resources */ = {isa = PBXBuildFile; fileRef = DD3C47B22DC5608A003DD20D /* newerSuggested.json */; };
+		DD3C47B52DC57E06003DD20D /* MainMigrationErrorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD3C47B42DC57E06003DD20D /* MainMigrationErrorView.swift */; };
 		DD3F1F832D9DC78800DCE7B3 /* UnitSelectionStepView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD3F1F822D9DC78300DCE7B3 /* UnitSelectionStepView.swift */; };
 		DD3F1F852D9DD84000DCE7B3 /* DeliveryLimitsStepView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD3F1F842D9DD83B00DCE7B3 /* DeliveryLimitsStepView.swift */; };
 		DD3F1F892D9E078D00DCE7B3 /* TherapySettingEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD3F1F882D9E078300DCE7B3 /* TherapySettingEditorView.swift */; };
@@ -579,6 +581,7 @@
 		DD5DC9F92CF3DAA900AB8703 /* RadioButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD5DC9F82CF3DAA900AB8703 /* RadioButton.swift */; };
 		DD5DC9FB2CF3E1B100AB8703 /* AdjustmentsStateModel+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD5DC9FA2CF3E1AA00AB8703 /* AdjustmentsStateModel+Helpers.swift */; };
 		DD68889D2C386E17006E3C44 /* NightscoutExercise.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD68889C2C386E17006E3C44 /* NightscoutExercise.swift */; };
+		DD6A4E082DBD95F1008C4B26 /* BluetoothRequiredView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD6A4E072DBD95F1008C4B26 /* BluetoothRequiredView.swift */; };
 		DD6A4E7E2DBEBF0F008C4B26 /* StartupReturningUserStepView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD6A4E7D2DBEBF0F008C4B26 /* StartupReturningUserStepView.swift */; };
 		DD6A4E802DBEC3EE008C4B26 /* StartupForceCloseWarningStepView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD6A4E7F2DBEC3EE008C4B26 /* StartupForceCloseWarningStepView.swift */; };
 		DD6A4E842DBEDD39008C4B26 /* AlgorithmSettingsImportantNotesStepView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD6A4E832DBEDD39008C4B26 /* AlgorithmSettingsImportantNotesStepView.swift */; };
@@ -1055,6 +1058,7 @@
 		3B997DD12DC02AEF006B6BB2 /* glucose.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = glucose.json; 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>"; };
+		3BCA5F7B2DC7B15400A7EAC7 /* pumphistory-with-external.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "pumphistory-with-external.json"; sourceTree = "<group>"; };
 		3BD6CE252DC24CFD00FA0472 /* pumphistory-24h-zoned.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "pumphistory-24h-zoned.json"; 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>"; };
@@ -1369,6 +1373,7 @@
 		DD3A3CE62D29C93F00AE478E /* Helper+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Helper+Extensions.swift"; sourceTree = "<group>"; };
 		DD3A3CE82D29C97800AE478E /* Helper+ButtonStyles.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Helper+ButtonStyles.swift"; sourceTree = "<group>"; };
 		DD3C47B22DC5608A003DD20D /* newerSuggested.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = newerSuggested.json; sourceTree = "<group>"; };
+		DD3C47B42DC57E06003DD20D /* MainMigrationErrorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainMigrationErrorView.swift; sourceTree = "<group>"; };
 		DD3F1F822D9DC78300DCE7B3 /* UnitSelectionStepView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnitSelectionStepView.swift; sourceTree = "<group>"; };
 		DD3F1F842D9DD83B00DCE7B3 /* DeliveryLimitsStepView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeliveryLimitsStepView.swift; sourceTree = "<group>"; };
 		DD3F1F882D9E078300DCE7B3 /* TherapySettingEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TherapySettingEditorView.swift; sourceTree = "<group>"; };
@@ -1388,6 +1393,7 @@
 		DD5DC9F82CF3DAA900AB8703 /* RadioButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RadioButton.swift; sourceTree = "<group>"; };
 		DD5DC9FA2CF3E1AA00AB8703 /* AdjustmentsStateModel+Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AdjustmentsStateModel+Helpers.swift"; sourceTree = "<group>"; };
 		DD68889C2C386E17006E3C44 /* NightscoutExercise.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NightscoutExercise.swift; sourceTree = "<group>"; };
+		DD6A4E072DBD95F1008C4B26 /* BluetoothRequiredView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BluetoothRequiredView.swift; sourceTree = "<group>"; };
 		DD6A4E7D2DBEBF0F008C4B26 /* StartupReturningUserStepView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StartupReturningUserStepView.swift; sourceTree = "<group>"; };
 		DD6A4E7F2DBEC3EE008C4B26 /* StartupForceCloseWarningStepView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StartupForceCloseWarningStepView.swift; sourceTree = "<group>"; };
 		DD6A4E832DBEDD39008C4B26 /* AlgorithmSettingsImportantNotesStepView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlgorithmSettingsImportantNotesStepView.swift; sourceTree = "<group>"; };
@@ -1938,6 +1944,7 @@
 		3811DE1F25C9D48300A708ED /* View */ = {
 			isa = PBXGroup;
 			children = (
+				DD3C47B42DC57E06003DD20D /* MainMigrationErrorView.swift */,
 				3BAD36B12D7CDC1400CC298D /* MainLoadingView.swift */,
 				3811DE2025C9D48300A708ED /* MainRootView.swift */,
 			);
@@ -2240,6 +2247,7 @@
 		3883582E25EEAFC000E024B2 /* Views */ = {
 			isa = PBXGroup;
 			children = (
+				DD6A4E072DBD95F1008C4B26 /* BluetoothRequiredView.swift */,
 				3811DE5925C9D4D500A708ED /* ViewModifiers.swift */,
 				3883581B25EE79BB00E024B2 /* TextFieldWithToolBar.swift */,
 				383420D825FFEB3F002D46C1 /* Popup.swift */,
@@ -2564,6 +2572,7 @@
 		3B997DD22DC02AEF006B6BB2 /* JSONImporterData */ = {
 			isa = PBXGroup;
 			children = (
+				3BCA5F7B2DC7B15400A7EAC7 /* pumphistory-with-external.json */,
 				DD3C47B22DC5608A003DD20D /* newerSuggested.json */,
 				DDD78AD72DC421B500AC63F3 /* enacted.json */,
 				DDD78AD82DC421B500AC63F3 /* suggested.json */,
@@ -3931,6 +3940,7 @@
 				3B997DD32DC02AEF006B6BB2 /* glucose.json in Resources */,
 				DDD78AD92DC421B500AC63F3 /* enacted.json in Resources */,
 				DD3C47B32DC5608A003DD20D /* newerSuggested.json in Resources */,
+				3BCA5F7C2DC7B16400A7EAC7 /* pumphistory-with-external.json in Resources */,
 				DDD78ADA2DC421B500AC63F3 /* suggested.json in Resources */,
 			);
 			runOnlyForDeploymentPostprocessing = 0;
@@ -4352,6 +4362,7 @@
 				DD1745442C55C60E00211FAC /* AutosensSettingsDataFlow.swift in Sources */,
 				BD249DA72D42FE4600412DEB /* Calendar+GlucoseStatsChart.swift in Sources */,
 				BDCAF2382C639F35002DC907 /* SettingItems.swift in Sources */,
+				DD6A4E082DBD95F1008C4B26 /* BluetoothRequiredView.swift in Sources */,
 				58D08B342C8DF9A700AA37D3 /* CobIobChart.swift in Sources */,
 				642F76A05A4FF530463A9FD0 /* NightscoutConfigRootView.swift in Sources */,
 				BD249D9B2D42FCDB00412DEB /* LoopChartSetup.swift in Sources */,
@@ -4602,6 +4613,7 @@
 				CE7CA3502A064973004BE681 /* CancelTempPresetIntent.swift in Sources */,
 				6B1F539F9FF75646D1606066 /* SnoozeDataFlow.swift in Sources */,
 				6FFAE524D1D9C262F2407CAE /* SnoozeProvider.swift in Sources */,
+				DD3C47B52DC57E06003DD20D /* MainMigrationErrorView.swift in Sources */,
 				BD4ED4FD2CF9D5E8000EDC9C /* AppState.swift in Sources */,
 				DDF847DD2C5C28720049BB3B /* LiveActivitySettingsDataFlow.swift in Sources */,
 				8194B80890CDD6A3C13B0FEE /* SnoozeStateModel.swift in Sources */,

+ 57 - 0
Trio/Sources/Application/TrioApp.swift

@@ -24,6 +24,7 @@ extension Notification.Name {
     class InitState {
         var complete = false
         var error = false
+        var migrationErrors: [String] = []
     }
 
     // We use both InitState and @State variables to track coreDataStack
@@ -36,6 +37,7 @@ extension Notification.Name {
     @State private var showLoadingView = true
     @State private var showLoadingError = false
     @State private var showOnboardingCompletedSplash = false
+    @State private var showMigrationError: Bool = false
 
     // Dependencies Assembler
     // contain all dependencies Assemblies
@@ -129,6 +131,9 @@ extension Notification.Name {
             do {
                 try await coreDataStack.initializeStack()
 
+                // TODO: possibly wrap this in a UserDefault / TinyStorage flag check, so we do not even attempt to fetch files unnecessary, but early exit the import
+                await performJsonToCoreDataMigrationIfNeeded()
+
                 await Task { @MainActor in
                     // Only load services after successful Core Data initialization
                     loadServices()
@@ -165,6 +170,45 @@ extension Notification.Name {
         }
     }
 
+    @MainActor private func performJsonToCoreDataMigrationIfNeeded() async {
+        let importer = JSONImporter(context: coreDataStack.newTaskContext(), coreDataStack: coreDataStack)
+        var importErrors: [String] = []
+
+        do {
+            try await importer.importGlucoseHistoryIfNeeded()
+        } catch {
+            importErrors
+                .append(String(localized: "Failed to import glucose history."))
+            debug(.coreData, "❌ Failed to import JSON-based Glucose History: \(error)")
+        }
+
+        do {
+            try await importer.importPumpHistoryIfNeeded()
+        } catch {
+            importErrors.append(String(localized: "Failed to import pump history."))
+            debug(.coreData, "❌ Failed to import JSON-based Pump History: \(error)")
+        }
+
+        do {
+            try await importer.importCarbHistoryIfNeeded()
+        } catch {
+            importErrors.append(String(localized: "Failed to import algorithm data."))
+            debug(.coreData, "❌ Failed to import JSON-based Carb History: \(error)")
+        }
+
+        do {
+            try await importer.importDeterminationIfNeeded()
+        } catch {
+            importErrors
+                .append(
+                    String(localized: "Migration of JSON-based OpenAPS Determination Data failed: \(error.localizedDescription)")
+                )
+            debug(.coreData, "❌ Failed to import JSON-based OpenAPS Determination Data: \(error)")
+        }
+
+        initState.migrationErrors = importErrors
+    }
+
     /// Clears any legacy (Trio 0.2.x) delivered and pending notifications related to non-looping alerts.
     /// It targets the following notifications:
     /// - `noLoopFirstNotification`: The first notification for non-looping alerts.
@@ -213,6 +257,9 @@ extension Notification.Name {
                                 Task { @MainActor in
                                     try? await Task.sleep(for: .seconds(1.8))
                                     self.showLoadingView = false
+                                    if self.initState.migrationErrors.isNotEmpty {
+                                        self.showMigrationError = true
+                                    }
                                 }
                             }
                             if self.initState.error {
@@ -223,11 +270,21 @@ extension Notification.Name {
                             Task { @MainActor in
                                 try? await Task.sleep(for: .seconds(1.8))
                                 self.showLoadingView = false
+                                if self.initState.migrationErrors.isNotEmpty {
+                                    self.showMigrationError = true
+                                }
                             }
                         }
                         .onReceive(Foundation.NotificationCenter.default.publisher(for: .initializationError)) { _ in
                             self.showLoadingError = true
                         }
+                } else if showMigrationError { // FIXME: display of this is not yet working, despite migration errors
+                    Main.MainMigrationErrorView(migrationErrors: self.initState.migrationErrors, onConfirm: {
+                        Task { @MainActor in
+                            showMigrationError = false
+                            initState.migrationErrors = []
+                        }
+                    })
                 } else if showOnboardingCompletedSplash {
                     LogoBurstSplash(isActive: $showOnboardingCompletedSplash) {
                         Main.RootView(resolver: resolver)

+ 45 - 3
Trio/Sources/Localizations/Main/Localizable.xcstrings

@@ -47933,6 +47933,9 @@
         }
       }
     },
+    "Bluetooth Required" : {
+
+    },
     "Bluetooth State restored (APS restarted?). Found %d peripherals, and connected to %@ with identifier %@" : {
       "comment" : "Restored state message",
       "extractionState" : "manual",
@@ -98711,6 +98714,15 @@
         }
       }
     },
+    "Failed to import algorithm data." : {
+
+    },
+    "Failed to import glucose history." : {
+
+    },
+    "Failed to import pump history." : {
+
+    },
     "Failed to Suspend Insulin Delivery" : {
       "comment" : "Alert title for suspend error",
       "extractionState" : "manual",
@@ -115734,6 +115746,9 @@
         }
       }
     },
+    "I understand! Proceed" : {
+
+    },
     "Identify and fix bugs and crashes" : {
       "localizations" : {
         "bg" : {
@@ -136327,6 +136342,9 @@
         }
       }
     },
+    "Manually backdate some recent carbs or insulin you’ve entered in the last 6 to 8 hours." : {
+
+    },
     "Manually entered blood glucose, such as a fingerstick test." : {
       "localizations" : {
         "bg" : {
@@ -142876,6 +142894,9 @@
         }
       }
     },
+    "Migration of JSON-based OpenAPS Determination Data failed: %@" : {
+
+    },
     "min" : {
       "comment" : "Minutes abbreviation\nShort form for minutes",
       "localizations" : {
@@ -157366,6 +157387,9 @@
         }
       }
     },
+    "Oops! Some data didn’t make it over." : {
+
+    },
     "Open %@" : {
       "localizations" : {
         "bg" : {
@@ -192914,6 +192938,9 @@
         }
       }
     },
+    "Stay in open loop (no automated dosing) for a bit to help Trio catch up to keep you safe" : {
+
+    },
     "Steps" : {
       "extractionState" : "manual",
       "localizations" : {
@@ -197453,6 +197480,9 @@
         }
       }
     },
+    "Tap to Enable Bluetooth in iOS Settings" : {
+
+    },
     "tapped save schedules" : {
       "extractionState" : "manual",
       "localizations" : {
@@ -209772,6 +209802,9 @@
         }
       }
     },
+    "This means Trio may not have complete information about how much active insulin or carbs were still on board when you switched over." : {
+
+    },
     "This might be an intermittent problem, but please check that your transmitter is tightly secured over your sensor" : {
       "comment" : "This might be an intermittent problem, but please check that your transmitter is tightly secured over your sensor",
       "extractionState" : "manual",
@@ -216134,6 +216167,9 @@
         }
       }
     },
+    "To stay safe, we recommend:" : {
+
+    },
     "Today" : {
       "extractionState" : "manual",
       "localizations" : {
@@ -221201,6 +221237,9 @@
         }
       }
     },
+    "Trio is still fully functional and will adapt quickly — but your awareness right now helps it keep you safer." : {
+
+    },
     "Trio lets you create automations using iOS Shortcuts. Go to the Shortcuts app to create new automations." : {
       "localizations" : {
         "bg" : {
@@ -236440,6 +236479,9 @@
     "While onboarding, Trio continues to operate with your prior settings." : {
 
     },
+    "While upgrading Trio to the new version, we ran into an issue transferring some of your historical data." : {
+
+    },
     "Why does Trio collect this data?" : {
       "localizations" : {
         "bg" : {
@@ -239795,6 +239837,9 @@
         }
       }
     },
+    "Your last 24 hr of treatment data (pump events, carb entries, glucose trace, etc.) are migrated." : {
+
+    },
     "Your phone or app is not enabled for NFC communications, which is needed to pair to libre2 sensors" : {
       "extractionState" : "manual",
       "localizations" : {
@@ -240418,9 +240463,6 @@
         }
       }
     },
-    "Your treatment data (pump events, carb entries, glucose trace, etc.) are not migrated." : {
-
-    },
     "ZT" : {
       "localizations" : {
         "bg" : {

+ 5 - 1
Trio/Sources/Models/PumpHistoryEvent.swift

@@ -16,6 +16,7 @@ struct PumpHistoryEvent: JSON, Equatable, Identifiable {
     let note: String?
     let isSMB: Bool?
     let isExternal: Bool?
+    let isExternalInsulin: Bool?
 
     init(
         id: String,
@@ -31,7 +32,8 @@ struct PumpHistoryEvent: JSON, Equatable, Identifiable {
         proteinInput: Int? = nil,
         note: String? = nil,
         isSMB: Bool? = nil,
-        isExternal: Bool? = nil
+        isExternal: Bool? = nil,
+        isExternalInsulin: Bool? = nil
     ) {
         self.id = id
         self.type = type
@@ -47,6 +49,7 @@ struct PumpHistoryEvent: JSON, Equatable, Identifiable {
         self.note = note
         self.isSMB = isSMB
         self.isExternal = isExternal
+        self.isExternalInsulin = isExternalInsulin
     }
 }
 
@@ -101,6 +104,7 @@ extension PumpHistoryEvent {
         case note
         case isSMB
         case isExternal
+        case isExternalInsulin
     }
 }
 

+ 1 - 0
Trio/Sources/Modules/CGMSettings/CGMSettingsStateModel.swift

@@ -42,6 +42,7 @@ extension CGMSettings {
         @Injected() var pluginCGMManager: PluginManager!
         @Injected() var broadcaster: Broadcaster!
         @Injected() var nightscoutManager: NightscoutManager!
+        @Injected() var bluetoothManager: BluetoothStateManager!
 
         @Published var units: GlucoseUnits = .mgdL
         @Published var shouldDisplayCGMSetupSheet: Bool = false

+ 46 - 38
Trio/Sources/Modules/CGMSettings/View/CGMRootView.swift

@@ -6,8 +6,8 @@ extension CGMSettings {
     struct RootView: BaseView {
         let resolver: Resolver
         let displayClose: Bool
+        let bluetoothManager: BluetoothStateManager
         @StateObject var state = StateModel()
-
         @State private var shouldDisplayHint: Bool = false
         @State var hintDetent = PresentationDetent.large
         @State var selectedVerboseHint: AnyView?
@@ -35,48 +35,56 @@ extension CGMSettings {
                     Section(
                         header: Text("CGM Integration to Trio"),
                         content: {
-                            let cgmState = state.cgmCurrent
-                            if cgmState.type != .none {
-                                Button {
-                                    state.shouldDisplayCGMSetupSheet = true
-                                } label: {
-                                    HStack {
-                                        Image(systemName: "sensor.tag.radiowaves.forward.fill")
-                                        Text(cgmState.displayName)
-                                    }
-                                    .frame(maxWidth: .infinity, minHeight: 50, alignment: .center)
-                                    .font(.title2)
-                                }.padding()
+                            if bluetoothManager.bluetoothAuthorization != .authorized {
+                                HStack {
+                                    Spacer()
+                                    BluetoothRequiredView()
+                                    Spacer()
+                                }
                             } else {
-                                VStack {
+                                let cgmState = state.cgmCurrent
+                                if cgmState.type != .none {
                                     Button {
-                                        showCGMSelection.toggle()
+                                        state.shouldDisplayCGMSetupSheet = true
                                     } label: {
-                                        Text("Add CGM")
-                                            .font(.title3) }
-                                        .frame(maxWidth: .infinity, alignment: .center)
-                                        .buttonStyle(.bordered)
+                                        HStack {
+                                            Image(systemName: "sensor.tag.radiowaves.forward.fill")
+                                            Text(cgmState.displayName)
+                                        }
+                                        .frame(maxWidth: .infinity, minHeight: 50, alignment: .center)
+                                        .font(.title2)
+                                    }.padding()
+                                } else {
+                                    VStack {
+                                        Button {
+                                            showCGMSelection.toggle()
+                                        } label: {
+                                            Text("Add CGM")
+                                                .font(.title3) }
+                                            .frame(maxWidth: .infinity, alignment: .center)
+                                            .buttonStyle(.bordered)
 
-                                    HStack(alignment: .center) {
-                                        Text(
-                                            "Pair your CGM with Trio. See hint for compatible devices."
-                                        )
-                                        .font(.footnote)
-                                        .foregroundColor(.secondary)
-                                        .lineLimit(nil)
-                                        Spacer()
-                                        Button(
-                                            action: {
-                                                shouldDisplayHint.toggle()
-                                            },
-                                            label: {
-                                                HStack {
-                                                    Image(systemName: "questionmark.circle")
+                                        HStack(alignment: .center) {
+                                            Text(
+                                                "Pair your CGM with Trio. See hint for compatible devices."
+                                            )
+                                            .font(.footnote)
+                                            .foregroundColor(.secondary)
+                                            .lineLimit(nil)
+                                            Spacer()
+                                            Button(
+                                                action: {
+                                                    shouldDisplayHint.toggle()
+                                                },
+                                                label: {
+                                                    HStack {
+                                                        Image(systemName: "questionmark.circle")
+                                                    }
                                                 }
-                                            }
-                                        ).buttonStyle(BorderlessButtonStyle())
-                                    }.padding(.top)
-                                }.padding(.vertical)
+                                            ).buttonStyle(BorderlessButtonStyle())
+                                        }.padding(.top)
+                                    }.padding(.vertical)
+                                }
                             }
                         }
                     )

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

@@ -19,6 +19,7 @@ extension Home {
         @ObservationIgnored @Injected() var carbsStorage: CarbsStorage!
         @ObservationIgnored @Injected() var tempTargetStorage: TempTargetsStorage!
         @ObservationIgnored @Injected() var overrideStorage: OverrideStorage!
+        @ObservationIgnored @Injected() var bluetoothManager: BluetoothStateManager!
 
         var cgmStateModel: CGMSettings.StateModel {
             CGMSettings.StateModel.shared

+ 0 - 1
Trio/Sources/Modules/Home/View/Header/CurrentGlucoseView.swift

@@ -11,7 +11,6 @@ struct CurrentGlucoseView: View {
     var currentGlucoseTarget: Decimal
     let glucoseColorScheme: GlucoseColorScheme
     let glucose: [GlucoseStored] // This contains the last two glucose values, no matter if its manual or a cgm reading
-
     @State private var rotationDegrees: Double = 0.0
     @State private var angularGradient = AngularGradient(colors: [
         Color(red: 0.7215686275, green: 0.3411764706, blue: 1),

+ 0 - 1
Trio/Sources/Modules/Home/View/Header/PumpView.swift

@@ -8,7 +8,6 @@ struct PumpView: View {
     let timerDate: Date
     let pumpStatusHighlightMessage: String?
     let battery: [OpenAPS_Battery]
-
     @Environment(\.colorScheme) var colorScheme
 
     private var batteryFormatter: NumberFormatter {

+ 18 - 12
Trio/Sources/Modules/Home/View/HomeRootView.swift

@@ -844,20 +844,26 @@ extension Home {
         @ViewBuilder func mainViewElements(_ geo: GeometryProxy) -> some View {
             VStack(spacing: 0) {
                 ZStack {
-                    /// glucose bobble
-                    glucoseView
+                    if let apsManager = state.apsManager, let bluetoothManager = apsManager.bluetoothManager,
+                       bluetoothManager.bluetoothAuthorization != .authorized
+                    {
+                        BluetoothRequiredView()
+                    } else {
+                        /// right panel with loop status and evBG
+                        HStack {
+                            Spacer()
+                            rightHeaderPanel(geo)
+                        }.padding(.trailing, 20)
 
-                    /// right panel with loop status and evBG
-                    HStack {
-                        Spacer()
-                        rightHeaderPanel(geo)
-                    }.padding(.trailing, 20)
+                        /// glucose bobble
+                        glucoseView
 
-                    /// left panel with pump related info
-                    HStack {
-                        pumpView
-                        Spacer()
-                    }.padding(.leading, 20)
+                        /// left panel with pump related info
+                        HStack {
+                            pumpView
+                            Spacer()
+                        }.padding(.leading, 20)
+                    }
                 }
                 .padding(.top, 10)
                 .safeAreaInset(edge: .top, spacing: 0) {

+ 2 - 2
Trio/Sources/Modules/Main/View/MainLoadingView.swift

@@ -70,9 +70,9 @@ extension Main {
                 .font(.title3).bold()
                 .background(
                     Capsule()
-                        .fill(Color.tabBar)
+                        .fill(Color.blue)
                 )
-                .foregroundColor(.white)
+                .foregroundColor(Color.white)
                 .shadow(color: Color.black.opacity(0.1), radius: 5, x: 0, y: 2)
             }
         }

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

@@ -0,0 +1,131 @@
+//
+//  MainMigrationErrorView.swift
+//  Trio
+//
+//  Created by Cengiz Deniz on 21.04.25.
+//
+import SwiftUI
+
+extension Main {
+    struct MainMigrationErrorView: View {
+        let migrationErrors: [String]
+        let onConfirm: () -> Void
+
+        private let versionNumber = Bundle.main.releaseVersionNumber ?? String(localized: "Unknown")
+
+        var body: some View {
+            ZStack(alignment: .bottom) {
+                LinearGradient(
+                    gradient: Gradient(colors: [Color.bgDarkBlue, Color.bgDarkerDarkBlue]),
+                    startPoint: .top,
+                    endPoint: .bottom
+                )
+                .ignoresSafeArea()
+
+                ScrollView {
+                    VStack {
+                        Spacer().frame(maxHeight: 20)
+
+                        Image(.trioCircledNoBackground)
+                            .resizable()
+                            .scaledToFit()
+                            .frame(width: 80, height: 80)
+                            .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)
+
+                        Spacer().frame(maxHeight: 20)
+
+                        VStack(alignment: .leading, spacing: 20) {
+                            Text("Oops! Some data didn’t make it over.").font(.title3).bold()
+
+                            Text(
+                                "While upgrading Trio to the new version, we ran into an issue transferring some of your historical data."
+                            )
+                            .multilineTextAlignment(.leading)
+
+                            VStack(alignment: .leading, spacing: 10) {
+                                ForEach(migrationErrors, id: \.self) { message in
+                                    BulletPoint(message)
+                                }
+                            }
+
+                            Text(
+                                "This means Trio may not have complete information about how much active insulin or carbs were still on board when you switched over."
+                            )
+                            .bold()
+
+                            VStack(alignment: .leading, spacing: 10) {
+                                HStack(alignment: .top, spacing: 10) {
+                                    Image(systemName: "exclamationmark.triangle.fill")
+                                        .foregroundStyle(Color.bgDarkBlue, Color.orange)
+                                        .symbolRenderingMode(.palette)
+                                    Text("To stay safe, we recommend:").foregroundStyle(Color.orange)
+                                }.bold()
+
+                                VStack(alignment: .leading, spacing: 10) {
+                                    BulletPoint(
+                                        String(
+                                            localized: "Manually backdate some recent carbs or insulin you’ve entered in the last 6 to 8 hours."
+                                        )
+                                    )
+                                    BulletPoint(
+                                        String(
+                                            localized: "Stay in open loop (no automated dosing) for a bit to help Trio catch up to keep you safe"
+                                        )
+                                    )
+                                }
+                            }
+                            .frame(maxWidth: .infinity)
+                            .padding()
+                            .background(Color.clear)
+                            .overlay(
+                                RoundedRectangle(cornerRadius: 10)
+                                    .stroke(Color.orange, lineWidth: 2)
+                            )
+                            .cornerRadius(10)
+
+                            Text(
+                                "Trio is still fully functional and will adapt quickly — but your awareness right now helps it keep you safer."
+                            )
+                            .multilineTextAlignment(.leading)
+                            .padding(.bottom)
+                        }
+                        .padding(.horizontal, 24)
+                        .foregroundStyle(.white)
+                    }
+                }
+                .padding(.bottom, 80)
+
+                Button(action: onConfirm) {
+                    Text("I understand! Proceed")
+                        .frame(width: UIScreen.main.bounds.width - 60, height: 50)
+                        .background(
+                            Capsule()
+                                .fill(Color.blue)
+                        )
+                        .foregroundColor(Color.white)
+                }.padding(.bottom)
+            }
+        }
+    }
+}
+
+struct MainMigrationErrorView_Previews: PreviewProvider {
+    static var previews: some View {
+        Group {
+            Main.MainMigrationErrorView(
+                migrationErrors: [
+                    "Failed to import glucose history.",
+                    "Failed to import pump history.",
+                    "Failed to import carb history.",
+                    "Failed to import algorithm data."
+                ],
+                onConfirm: { print("Proceed") }
+            )
+        }
+    }
+}

+ 1 - 1
Trio/Sources/Modules/Onboarding/View/OnboardingSteps/CompletedStepView.swift

@@ -29,7 +29,7 @@ struct CompletedStepView: View {
                     completedItemsView(
                         stepIndex: index + 1,
                         title: chapter.title,
-                        description: chapter.completedDescription,
+                        description: isChapterCompleted(chapter) ? chapter.completedDescription : chapter.overviewDescription,
                         isCompleted: isChapterCompleted(chapter)
                     )
 

+ 1 - 1
Trio/Sources/Modules/Onboarding/View/OnboardingSteps/StartupGuide/StartupReturningUserStepView.swift

@@ -25,7 +25,7 @@ struct StartupReturningUserStepView: View {
                     Text("Important").foregroundStyle(Color.orange)
                 }.bold()
 
-                Text("Your treatment data (pump events, carb entries, glucose trace, etc.) are not migrated.")
+                Text("Your last 24 hr of treatment data (pump events, carb entries, glucose trace, etc.) are migrated.")
 
                 Divider().overlay(Color.orange)
 

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

@@ -10,6 +10,7 @@ extension PumpConfig {
         @Published var pumpState: PumpDisplayState?
         private(set) var initialSettings: PumpInitialSettings = .default
         @Published var alertNotAck: Bool = false
+        @Injected() var bluetoothManager: BluetoothStateManager!
 
         override func subscribe() {
             provider.pumpDisplayState

+ 8 - 2
Trio/Sources/Modules/PumpConfig/View/PumpConfigRootView.swift

@@ -5,8 +5,8 @@ extension PumpConfig {
     struct RootView: BaseView {
         let resolver: Resolver
         let displayClose: Bool
+        let bluetoothManager: BluetoothStateManager
         @StateObject var state = StateModel()
-
         @State private var shouldDisplayHint: Bool = false
         @State var hintDetent = PresentationDetent.large
         @State var selectedVerboseHint: AnyView?
@@ -24,7 +24,13 @@ extension PumpConfig {
                     Section(
                         header: Text("Pump Integration to Trio"),
                         content: {
-                            if let pumpState = state.pumpState {
+                            if bluetoothManager.bluetoothAuthorization != .authorized {
+                                HStack {
+                                    Spacer()
+                                    BluetoothRequiredView()
+                                    Spacer()
+                                }
+                            } else if let pumpState = state.pumpState {
                                 Button {
                                     state.setupPump = true
                                 } label: {

+ 1 - 0
Trio/Sources/Modules/Treatments/TreatmentsStateModel.swift

@@ -380,6 +380,7 @@ extension Treatments {
                 carbs: carbs,
                 useFattyMealCorrection: useFattyMealCorrectionFactor,
                 useSuperBolus: useSuperBolus,
+                lastLoopDate: apsManager.lastLoopDate,
                 minPredBG: localMinPredBG
             )
 

+ 15 - 3
Trio/Sources/Router/Screen.swift

@@ -71,9 +71,17 @@ extension Screen {
         case .tidepoolConfig:
             TidepoolStartView(resolver: resolver, state: Settings.StateModel())
         case .pumpConfig:
-            PumpConfig.RootView(resolver: resolver, displayClose: false)
+            PumpConfig.RootView(
+                resolver: resolver,
+                displayClose: false,
+                bluetoothManager: resolver.resolve(BluetoothStateManager.self)!
+            )
         case .pumpConfigDirect:
-            PumpConfig.RootView(resolver: resolver, displayClose: true)
+            PumpConfig.RootView(
+                resolver: resolver,
+                displayClose: true,
+                bluetoothManager: resolver.resolve(BluetoothStateManager.self)!
+            )
         case .basalProfileEditor:
             BasalProfileEditor.RootView(resolver: resolver)
         case .isfEditor:
@@ -89,7 +97,11 @@ extension Screen {
         case .dataTable:
             DataTable.RootView(resolver: resolver)
         case .cgm:
-            CGMSettings.RootView(resolver: resolver, displayClose: false)
+            CGMSettings.RootView(
+                resolver: resolver,
+                displayClose: false,
+                bluetoothManager: resolver.resolve(BluetoothStateManager.self)!
+            )
         case .healthkit:
             AppleHealthKit.RootView(resolver: resolver)
         case .glucoseNotificationSettings:

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

@@ -4,7 +4,13 @@ import Swinject
 
 protocol BolusCalculationManager {
     func calculateInsulin(input: CalculationInput) async -> CalculationResult
-    func handleBolusCalculation(carbs: Decimal, useFattyMealCorrection: Bool, useSuperBolus: Bool, minPredBG: Decimal?) async
+    func handleBolusCalculation(
+        carbs: Decimal,
+        useFattyMealCorrection: Bool,
+        useSuperBolus: Bool,
+        lastLoopDate: Date,
+        minPredBG: Decimal?
+    ) async
         -> CalculationResult
 }
 
@@ -282,6 +288,7 @@ final class BaseBolusCalculationManager: BolusCalculationManager, Injectable {
         carbs: Decimal,
         useFattyMealCorrection: Bool,
         useSuperBolus: Bool,
+        lastLoopDate: Date,
         minPredBG: Decimal?
     ) async throws -> CalculationInput {
         do {
@@ -348,7 +355,8 @@ final class BaseBolusCalculationManager: BolusCalculationManager, Injectable {
                 maxBolus: maxBolus,
                 maxIOB: maxIOB,
                 maxCOB: maxCOB,
-                minPredBG: minPredBG ?? bolusVars.minPredBG
+                minPredBG: minPredBG ?? bolusVars.minPredBG,
+                lastLoopDate: lastLoopDate
             )
         } catch {
             debug(
@@ -429,7 +437,7 @@ final class BaseBolusCalculationManager: BolusCalculationManager, Injectable {
 
         // the final result for recommended insulin amount
         var insulinCalculated: Decimal
-        let isLoopStale = Date().timeIntervalSince(apsManager.lastLoopDate) > 15 * 60
+        let isLoopStale = Date().timeIntervalSince(input.lastLoopDate) > 15 * 60
         debug(.default, "Loop stale: \(isLoopStale), currentBG: \(input.currentBG), minPredBG: \(input.minPredBG)")
 
         // don't recommend insulin when current glucose or minPredBG is < 54 or last sucessful loop was over 15 minutes ago
@@ -480,6 +488,7 @@ final class BaseBolusCalculationManager: BolusCalculationManager, Injectable {
         carbs: Decimal,
         useFattyMealCorrection: Bool,
         useSuperBolus: Bool,
+        lastLoopDate: Date,
         minPredBG: Decimal? = nil
     ) async -> CalculationResult {
         do {
@@ -487,6 +496,7 @@ final class BaseBolusCalculationManager: BolusCalculationManager, Injectable {
                 carbs: carbs,
                 useFattyMealCorrection: useFattyMealCorrection,
                 useSuperBolus: useSuperBolus,
+                lastLoopDate: lastLoopDate,
                 minPredBG: minPredBG
             )
             let result = await calculateInsulin(input: input)
@@ -534,6 +544,7 @@ struct CalculationInput: Sendable {
     let maxIOB: Decimal // Maximum allowed IOB to be used for rec. bolus calculation
     let maxCOB: Decimal // Maximum allowed COB to be used for rec. bolus calculation
     let minPredBG: Decimal // Minimum Predicted Glucose determined by Oref
+    let lastLoopDate: Date // Date at which loop last completed successfully
 }
 
 /// Results of the bolus calculation

+ 24 - 1
Trio/Sources/Services/WatchManager/AppleWatchManager.swift

@@ -591,15 +591,38 @@ final class BaseWatchManager: NSObject, WCSessionDelegate, Injectable, WatchMana
             if message[WatchMessageKeys.requestBolusRecommendation] as? Bool == true {
                 let carbs = message[WatchMessageKeys.carbs] as? Int ?? 0
 
+                var minPredBG: Decimal = 54
+
                 Task { [weak self] in
                     guard let self = self else { return }
 
+                    do {
+                        // Fetch determination data
+                        let determinationIds = try await determinationStorage.fetchLastDeterminationObjectID(
+                            predicate: NSPredicate.predicateFor30MinAgoForDetermination
+                        )
+                        let determinationObjects: [OrefDetermination] = try await CoreDataStack.shared.getNSManagedObject(
+                            with: determinationIds,
+                            context: backgroundContext
+                        )
+
+                        await MainActor.run {
+                            minPredBG = determinationObjects.first?.minPredBGFromReason ?? 54
+                        }
+
+                    } catch let error as CoreDataError {
+                        debug(.default, "Core Data error: \(error.localizedDescription)")
+                    } catch {
+                        debug(.default, "Unexpected error: \(error.localizedDescription)")
+                    }
+
                     // Get recommendation from BolusCalculationManager
                     let result = await bolusCalculationManager.handleBolusCalculation(
                         carbs: Decimal(carbs),
                         useFattyMealCorrection: false,
                         useSuperBolus: false,
-                        minPredBG: 60 // TODO:
+                        lastLoopDate: apsManager.lastLoopDate,
+                        minPredBG: minPredBG
                     )
 
                     // Send recommendation back to watch

+ 35 - 0
Trio/Sources/Views/BluetoothRequiredView.swift

@@ -0,0 +1,35 @@
+//
+//  BluetoothRequiredView.swift
+//  Trio
+//
+//  Created by Cengiz Deniz on 27.04.25.
+//
+import SwiftUI
+
+public struct BluetoothRequiredView: View {
+    public var body: some View {
+        VStack(alignment: .center, spacing: 12) {
+            HStack {
+                Image("logo.bluetooth.capsule.portrait.fill")
+                    .foregroundStyle(Color.red)
+                Text("Bluetooth Required")
+            }
+            .font(.headline.bold())
+            .padding(.vertical, 6)
+            .padding(.horizontal, 12)
+            .overlay(
+                Capsule()
+                    .stroke(Color.red.opacity(0.75), lineWidth: 2)
+            )
+
+            Text("Tap to Enable Bluetooth in iOS Settings")
+                .font(.subheadline.bold())
+                .foregroundStyle(Color.primary.opacity(0.8))
+        }
+        .onTapGesture {
+            if let url = URL(string: UIApplication.openSettingsURLString) {
+                UIApplication.shared.open(url)
+            }
+        }
+    }
+}

+ 157 - 5
TrioTests/BolusCalculatorTests/BolusCalculatorTests.swift

@@ -3,6 +3,13 @@ import Testing
 
 @testable import Trio
 
+/// ⚠️ NOTE:
+/// If tests in this suite are failing unexpectedly (e.g. sudden unexplainable mismatches for decimal places for calculated values),
+/// try running the test suite on a clean simulator.
+///
+/// You can reset the simulator from the menu: **Device > Erase All Content and Settings**
+/// or by launching with `-com.apple.CoreData.SQLDebug 1` for more insight into the issue.
+///
 @Suite("Bolus Calculator Tests") struct BolusCalculatorTests: Injectable {
     @Injected() var calculator: BolusCalculationManager!
     @Injected() var settingsManager: SettingsManager!
@@ -60,7 +67,8 @@ import Testing
             maxBolus: maxBolus,
             maxIOB: maxIOB,
             maxCOB: maxCOB,
-            minPredBG: minPredBG
+            minPredBG: minPredBG,
+            lastLoopDate: Date()
         )
 
         // STEP 3: Calculate insulin
@@ -173,7 +181,8 @@ import Testing
             maxBolus: maxBolus,
             maxIOB: maxIOB,
             maxCOB: maxCOB,
-            minPredBG: minPredBG
+            minPredBG: minPredBG,
+            lastLoopDate: Date()
         )
 
         // STEP 3: Calculate insulin with fatty meal enabled
@@ -198,7 +207,8 @@ import Testing
             maxBolus: maxBolus,
             maxIOB: maxIOB,
             maxCOB: maxCOB,
-            minPredBG: minPredBG
+            minPredBG: minPredBG,
+            lastLoopDate: Date()
         )
         let standardResult = await calculator.calculateInsulin(input: standardInput)
 
@@ -262,7 +272,8 @@ import Testing
             maxBolus: maxBolus,
             maxIOB: maxIOB,
             maxCOB: maxCOB,
-            minPredBG: minPredBG
+            minPredBG: minPredBG,
+            lastLoopDate: Date()
         )
 
         // STEP 3: Calculate insulin with super bolus enabled
@@ -287,7 +298,8 @@ import Testing
             maxBolus: maxBolus,
             maxIOB: maxIOB,
             maxCOB: maxCOB,
-            minPredBG: minPredBG
+            minPredBG: minPredBG,
+            lastLoopDate: Date()
         )
         let standardResult = await calculator.calculateInsulin(input: standardInput)
 
@@ -330,6 +342,145 @@ import Testing
         )
     }
 
+    @Test("Calculate insulin with low glucose forecast (minPredBG < 54)") func testMinPredBGGuardBolusCalculation() async throws {
+        // STEP 1: Setup test scenario
+        // We need to provide a CalculationInput struct
+        let carbs: Decimal = 80
+        let currentBG: Decimal = 180 // 80 points above target, should result in 2U correction
+        let deltaBG: Decimal = 5 // Rising trend, should add small correction
+        let target: Decimal = 100
+        let isf: Decimal = 40
+        let carbRatio: Decimal = 10 // Should result in 8U for carbs
+        let iob: Decimal = 1.0 // Should subtract from final result
+        let cob: Int16 = 20
+        let useFattyMealCorrectionFactor: Bool = false
+        let useSuperBolus: Bool = false
+        let fattyMealFactor: Decimal = 0.8
+        let sweetMealFactor: Decimal = 2
+        let basal: Decimal = 1.5 // Will be added to insulin calculation when super bolus is enabled
+        let fraction: Decimal = 0.8
+        let maxBolus: Decimal = 10
+        let maxIOB: Decimal = 15.0
+        let maxCOB: Decimal = 120.0
+        let minPredBG: Decimal = 45.0 // Severe Hypo forecasted
+
+        // STEP 2: Create calculation input with severe hypo forecasted minPredBG
+        let input = CalculationInput(
+            carbs: carbs,
+            currentBG: currentBG,
+            deltaBG: deltaBG,
+            target: target,
+            isf: isf,
+            carbRatio: carbRatio,
+            iob: iob,
+            cob: cob,
+            useFattyMealCorrectionFactor: useFattyMealCorrectionFactor,
+            fattyMealFactor: fattyMealFactor,
+            useSuperBolus: useSuperBolus,
+            sweetMealFactor: sweetMealFactor,
+            basal: basal,
+            fraction: fraction,
+            maxBolus: maxBolus,
+            maxIOB: maxIOB,
+            maxCOB: maxCOB,
+            minPredBG: minPredBG,
+            lastLoopDate: Date()
+        )
+
+        // STEP 3: Calculate insulin with super bolus enabled
+        let minPredBGResult = await calculator.calculateInsulin(input: input)
+
+        // STEP 4: Calculate insulin with super bolus disabled for comparison
+        let standardInput = CalculationInput(
+            carbs: carbs,
+            currentBG: currentBG,
+            deltaBG: deltaBG,
+            target: target,
+            isf: isf,
+            carbRatio: carbRatio,
+            iob: iob,
+            cob: cob,
+            useFattyMealCorrectionFactor: useFattyMealCorrectionFactor,
+            fattyMealFactor: fattyMealFactor,
+            useSuperBolus: false, // Disabled for comparison
+            sweetMealFactor: sweetMealFactor,
+            basal: basal,
+            fraction: fraction,
+            maxBolus: maxBolus,
+            maxIOB: maxIOB,
+            maxCOB: maxCOB,
+            minPredBG: 80,
+            lastLoopDate: Date()
+        )
+        let standardResult = await calculator.calculateInsulin(input: standardInput)
+
+        // STEP 5: Verify results
+        #expect(minPredBGResult.insulinCalculated == 0, "Severe Hypo forecasted; insulin calculated set to 0 U for safety!")
+
+        #expect(
+            standardResult.insulinCalculated > minPredBGResult.insulinCalculated,
+            """
+            Super bolus calculation incorrect
+            Expected super bolus calculation to be higher than standard
+            MinPred <54 bolus: \(minPredBGResult.insulinCalculated) U
+            Standard: \(standardResult.insulinCalculated) U
+            Difference: \(standardResult.insulinCalculated - minPredBGResult.insulinCalculated) U
+            """
+        )
+    }
+
+    @Test("Calculate insulin with stale loop (longer than 15min ago)") func testStaleLoopBolusCalculation() async throws {
+        // STEP 1: Setup test scenario
+        // We need to provide a CalculationInput struct
+        let carbs: Decimal = 80
+        let currentBG: Decimal = 180 // 80 points above target, should result in 2U correction
+        let deltaBG: Decimal = 5 // Rising trend, should add small correction
+        let target: Decimal = 100
+        let isf: Decimal = 40
+        let carbRatio: Decimal = 10 // Should result in 8U for carbs
+        let iob: Decimal = 1.0 // Should subtract from final result
+        let cob: Int16 = 20
+        let useFattyMealCorrectionFactor: Bool = false
+        let useSuperBolus: Bool = false
+        let fattyMealFactor: Decimal = 0.8
+        let sweetMealFactor: Decimal = 2
+        let basal: Decimal = 1.5 // Will be added to insulin calculation when super bolus is enabled
+        let fraction: Decimal = 0.8
+        let maxBolus: Decimal = 10
+        let maxIOB: Decimal = 15.0
+        let maxCOB: Decimal = 120.0
+        let minPredBG: Decimal = 80
+
+        // STEP 2: Create calculation input with severe hypo forecasted minPredBG
+        let input = CalculationInput(
+            carbs: carbs,
+            currentBG: currentBG,
+            deltaBG: deltaBG,
+            target: target,
+            isf: isf,
+            carbRatio: carbRatio,
+            iob: iob,
+            cob: cob,
+            useFattyMealCorrectionFactor: useFattyMealCorrectionFactor,
+            fattyMealFactor: fattyMealFactor,
+            useSuperBolus: useSuperBolus,
+            sweetMealFactor: sweetMealFactor,
+            basal: basal,
+            fraction: fraction,
+            maxBolus: maxBolus,
+            maxIOB: maxIOB,
+            maxCOB: maxCOB,
+            minPredBG: minPredBG,
+            lastLoopDate: Date().addingTimeInterval(TimeInterval(-15 * 60)) // 15min ago
+        )
+
+        // STEP 3: Calculate insulin with super bolus enabled
+        let result = await calculator.calculateInsulin(input: input)
+
+        // STEP 4: Verify results
+        #expect(result.insulinCalculated == 0, "Loop is stale; insulin calculated set to 0 U for safety!")
+    }
+
     @Test("Calculate insulin with zero carbs") func testZeroCarbsCalculation() async throws {
         // Given
         let carbs: Decimal = 0
@@ -339,6 +490,7 @@ import Testing
             carbs: carbs,
             useFattyMealCorrection: false,
             useSuperBolus: false,
+            lastLoopDate: Date(),
             minPredBG: nil
         )
 

+ 9 - 0
TrioTests/JSONImporterData/pumphistory-with-external.json

@@ -0,0 +1,9 @@
+[
+    {
+      "amount" : 0.88,
+      "timestamp" : "2025-05-04T04:37:43.654Z",
+      "isExternalInsulin" : true,
+      "id" : "98134B68-6B46-4814-96C1-68A745173D03",
+      "_type" : "Bolus"
+    }
+]

+ 35 - 0
TrioTests/JSONImporterTests.swift

@@ -155,6 +155,41 @@ class BundleReference {}
         #expect(allReadings.isEmpty)
     }
 
+    @Test("Import pump history with external insulin") func testImportPumpHistoryWithExternalInsulin() async throws {
+        let testBundle = Bundle(for: BundleReference.self)
+        let path = testBundle.path(forResource: "pumphistory-with-external", ofType: "json")!
+        let url = URL(filePath: path)
+
+        let now = Date("2025-05-04T04:37:44.654Z")!
+        try await importer.importPumpHistory(url: url, now: now)
+
+        let allReadings = try await coreDataStack.fetchEntitiesAsync(
+            ofType: PumpEventStored.self,
+            onContext: context,
+            predicate: NSPredicate(format: "TRUEPREDICATE"),
+            key: "timestamp",
+            ascending: false
+        ) as? [PumpEventStored] ?? []
+
+        let objectIds = allReadings.map(\.objectID)
+        let parsedHistory = OpenAPS.loadAndMapPumpEvents(objectIds, from: context)
+
+        #expect(parsedHistory.count == 1)
+
+        let bolus: BolusDTO? = {
+            switch parsedHistory.first! {
+            case let .bolus(bolus):
+                return bolus
+            default:
+                return nil
+            }
+        }()
+
+        #expect(bolus != nil)
+        #expect(bolus!.isExternal)
+        #expect(bolus!.amount.isApproximatelyEqual(to: 0.88, epsilon: 0.01))
+    }
+
     @Test("Import carb history with value checks") func testImportCarbHistoryDetails() async throws {
         let testBundle = Bundle(for: BundleReference.self)
         let path = testBundle.path(forResource: "carbhistory", ofType: "json")!