Преглед изворни кода

Merge branch 'nightscout:dev' into core-data-sync-trio

Bastiaan Verhaar пре 1 година
родитељ
комит
aa84b2b939

+ 57 - 73
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)
-        }
     }
 }
 

+ 8 - 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 */; };
@@ -580,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 */; };
@@ -1056,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>"; };
@@ -1390,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>"; };
@@ -2243,6 +2247,7 @@
 		3883582E25EEAFC000E024B2 /* Views */ = {
 			isa = PBXGroup;
 			children = (
+				DD6A4E072DBD95F1008C4B26 /* BluetoothRequiredView.swift */,
 				3811DE5925C9D4D500A708ED /* ViewModifiers.swift */,
 				3883581B25EE79BB00E024B2 /* TextFieldWithToolBar.swift */,
 				383420D825FFEB3F002D46C1 /* Popup.swift */,
@@ -2567,6 +2572,7 @@
 		3B997DD22DC02AEF006B6BB2 /* JSONImporterData */ = {
 			isa = PBXGroup;
 			children = (
+				3BCA5F7B2DC7B15400A7EAC7 /* pumphistory-with-external.json */,
 				DD3C47B22DC5608A003DD20D /* newerSuggested.json */,
 				DDD78AD72DC421B500AC63F3 /* enacted.json */,
 				DDD78AD82DC421B500AC63F3 /* suggested.json */,
@@ -3926,6 +3932,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;
@@ -4347,6 +4354,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 */,

+ 6 - 0
Trio/Sources/Localizations/Main/Localizable.xcstrings

@@ -47908,6 +47908,9 @@
         }
       }
     },
+    "Bluetooth Required" : {
+
+    },
     "Bluetooth State restored (APS restarted?). Found %d peripherals, and connected to %@ with identifier %@" : {
       "comment" : "Restored state message",
       "extractionState" : "manual",
@@ -197406,6 +197409,9 @@
         }
       }
     },
+    "Tap to Enable Bluetooth in iOS Settings" : {
+
+    },
     "tapped save schedules" : {
       "extractionState" : "manual",
       "localizations" : {

+ 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) {

+ 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")!