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

Merge pull request #100 from polscm32/tcd

Add popup to treatment view, Fetch Request optimization and performance fix
Deniz Cengiz 1 год назад
Родитель
Сommit
70ec51f5e7

+ 78 - 0
FreeAPS.xcodeproj/xcshareddata/xcschemes/FreeAPS.xcscheme

@@ -0,0 +1,78 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<Scheme
+   LastUpgradeVersion = "1600"
+   version = "1.7">
+   <BuildAction
+      parallelizeBuildables = "YES"
+      buildImplicitDependencies = "YES"
+      buildArchitectures = "Automatic">
+      <BuildActionEntries>
+         <BuildActionEntry
+            buildForTesting = "YES"
+            buildForRunning = "YES"
+            buildForProfiling = "YES"
+            buildForArchiving = "YES"
+            buildForAnalyzing = "YES">
+            <BuildableReference
+               BuildableIdentifier = "primary"
+               BlueprintIdentifier = "388E595725AD948C0019842D"
+               BuildableName = "FreeAPS.app"
+               BlueprintName = "FreeAPS"
+               ReferencedContainer = "container:FreeAPS.xcodeproj">
+            </BuildableReference>
+         </BuildActionEntry>
+      </BuildActionEntries>
+   </BuildAction>
+   <TestAction
+      buildConfiguration = "Debug"
+      selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
+      selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
+      shouldUseLaunchSchemeArgsEnv = "YES"
+      shouldAutocreateTestPlan = "YES">
+   </TestAction>
+   <LaunchAction
+      buildConfiguration = "Debug"
+      selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
+      selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
+      launchStyle = "0"
+      useCustomWorkingDirectory = "NO"
+      ignoresPersistentStateOnLaunch = "NO"
+      debugDocumentVersioning = "YES"
+      debugServiceExtension = "internal"
+      allowLocationSimulation = "YES">
+      <BuildableProductRunnable
+         runnableDebuggingMode = "0">
+         <BuildableReference
+            BuildableIdentifier = "primary"
+            BlueprintIdentifier = "388E595725AD948C0019842D"
+            BuildableName = "FreeAPS.app"
+            BlueprintName = "FreeAPS"
+            ReferencedContainer = "container:FreeAPS.xcodeproj">
+         </BuildableReference>
+      </BuildableProductRunnable>
+   </LaunchAction>
+   <ProfileAction
+      buildConfiguration = "Release"
+      shouldUseLaunchSchemeArgsEnv = "YES"
+      savedToolIdentifier = ""
+      useCustomWorkingDirectory = "NO"
+      debugDocumentVersioning = "YES">
+      <BuildableProductRunnable
+         runnableDebuggingMode = "0">
+         <BuildableReference
+            BuildableIdentifier = "primary"
+            BlueprintIdentifier = "388E595725AD948C0019842D"
+            BuildableName = "FreeAPS.app"
+            BlueprintName = "FreeAPS"
+            ReferencedContainer = "container:FreeAPS.xcodeproj">
+         </BuildableReference>
+      </BuildableProductRunnable>
+   </ProfileAction>
+   <AnalyzeAction
+      buildConfiguration = "Debug">
+   </AnalyzeAction>
+   <ArchiveAction
+      buildConfiguration = "Release"
+      revealArchiveInOrganizer = "YES">
+   </ArchiveAction>
+</Scheme>

+ 4 - 0
FreeAPS/Sources/Logger/Logger.swift

@@ -113,6 +113,7 @@ final class Logger {
     static let apsManager = Logger(category: .apsManager, reporter: baseReporter)
     static let nightscout = Logger(category: .nightscout, reporter: baseReporter)
     static let remoteControl = Logger(category: .remoteControl, reporter: baseReporter)
+    static let bolusState = Logger(category: .bolusState, reporter: baseReporter)
 
     enum Category: String {
         case `default`
@@ -123,6 +124,7 @@ final class Logger {
         case apsManager
         case nightscout
         case remoteControl
+        case bolusState
 
         var name: String {
             rawValue.capitalizingFirstLetter()
@@ -138,6 +140,7 @@ final class Logger {
             case .apsManager: return .apsManager
             case .nightscout: return .nightscout
             case .remoteControl: return .remoteControl
+            case .bolusState: return .bolusState
             }
         }
 
@@ -146,6 +149,7 @@ final class Logger {
             switch self {
             case .default: return OSLog.default
             case .apsManager,
+                 .bolusState,
                  .businessLogic,
                  .deviceManager,
                  .nightscout,

+ 44 - 2
FreeAPS/Sources/Modules/Bolus/BolusStateModel.swift

@@ -119,12 +119,24 @@ extension Bolus {
         let glucoseFetchContext = CoreDataStack.shared.newTaskContext()
         let determinationFetchContext = CoreDataStack.shared.newTaskContext()
 
+        var isActive: Bool = false
+
         private var coreDataPublisher: AnyPublisher<Set<NSManagedObject>, Never>?
         private var subscriptions = Set<AnyCancellable>()
 
         typealias PumpEvent = PumpEventStored.EventType
 
+        func unsubscribe() {
+            subscriptions.forEach { $0.cancel() }
+            subscriptions.removeAll()
+        }
+
         override func subscribe() {
+            guard isActive else {
+                return
+            }
+
+            debug(.bolusState, "subscribe fired")
             coreDataPublisher =
                 changedObjectsOnManagedObjectContextDidSavePublisher()
                     .receive(on: DispatchQueue.global(qos: .background))
@@ -135,7 +147,19 @@ extension Bolus {
             setupBolusStateConcurrently()
         }
 
+        deinit {
+            // Unregister from broadcaster
+            broadcaster.unregister(DeterminationObserver.self, observer: self)
+            broadcaster.unregister(BolusFailureObserver.self, observer: self)
+
+            // Cancel Combine subscriptions
+            unsubscribe()
+
+            debug(.bolusState, "Bolus.StateModel deinitialized")
+        }
+
         private func setupBolusStateConcurrently() {
+            debug(.bolusState, "setupBolusStateConcurrently fired")
             Task {
                 await withTaskGroup(of: Void.self) { group in
                     group.addTask {
@@ -329,6 +353,7 @@ extension Bolus {
 
         /// Calculate insulin recommendation
         func calculateInsulin() -> Decimal {
+            debug(.bolusState, "calculateInsulin fired")
             let isfForCalculation = isf
 
             // insulin needed for the current blood glucose
@@ -386,6 +411,7 @@ extension Bolus {
 
         func invokeTreatmentsTask() {
             Task {
+                debug(.bolusState, "invokeTreatmentsTask fired")
                 await MainActor.run {
                     self.addButtonPressed = true
                 }
@@ -425,6 +451,7 @@ extension Bolus {
         // MARK: - Insulin
 
         private func handleInsulin(isExternal: Bool) async throws {
+            debug(.bolusState, "handleInsulin fired")
             if !isExternal {
                 await addPumpInsulin()
             } else {
@@ -573,7 +600,13 @@ extension Bolus {
 
 extension Bolus.StateModel: DeterminationObserver, BolusFailureObserver {
     func determinationDidUpdate(_: Determination) {
+        guard isActive else {
+            debug(.bolusState, "skipping determinationDidUpdate; view not active")
+            return
+        }
+
         DispatchQueue.main.async {
+            debug(.bolusState, "determinationDidUpdate fired")
             self.waitForSuggestion = false
             if self.addButtonPressed {
                 self.hideModal()
@@ -583,6 +616,7 @@ extension Bolus.StateModel: DeterminationObserver, BolusFailureObserver {
 
     func bolusDidFail() {
         DispatchQueue.main.async {
+            debug(.bolusState, "bolusDidFail fired")
             self.waitForSuggestion = false
             if self.addButtonPressed {
                 self.hideModal()
@@ -597,7 +631,8 @@ extension Bolus.StateModel {
             guard let self = self else { return }
             Task {
                 await self.setupDeterminationsArray()
-                await self.updateForecasts()
+                let forecastData = await self.mapForecastsForChart()
+                await self.updateForecasts(with: forecastData)
             }
         }.store(in: &subscriptions)
 
@@ -749,11 +784,18 @@ extension Bolus.StateModel {
 
 extension Bolus.StateModel {
     @MainActor func updateForecasts(with forecastData: Determination? = nil) async {
+        guard isActive else {
+            return
+                debug(.bolusState, "updateForecasts not fired")
+        }
+
+        debug(.bolusState, "updateForecasts fired")
         if let forecastData = forecastData {
             simulatedDetermination = forecastData
         } else {
             simulatedDetermination = await Task { [self] in
-                await apsManager.simulateDetermineBasal(carbs: carbs, iob: amount)
+                debug(.bolusState, "calling simulateDetermineBasal to get forecast data")
+                return await apsManager.simulateDetermineBasal(carbs: carbs, iob: amount)
             }.value
         }
 

+ 2 - 0
FreeAPS/Sources/Modules/Bolus/View/BolusRootView.swift

@@ -344,10 +344,12 @@ extension Bolus {
             })
             .onAppear {
                 configureView {
+                    state.isActive = true
                     state.insulinCalculated = state.calculateInsulin()
                 }
             }
             .onDisappear {
+                state.isActive = false
                 state.addButtonPressed = false
             }
             .sheet(isPresented: $state.showInfo) {

+ 86 - 0
FreeAPS/Sources/Modules/Bolus/View/ForecastChart.swift

@@ -9,6 +9,8 @@ struct ForecastChart: View {
 
     @State private var startMarker = Date(timeIntervalSinceNow: -4 * 60 * 60)
 
+    @State var selection: Date? = nil
+
     private var endMarker: Date {
         state
             .forecastDisplayType == .lines ? Date(timeIntervalSinceNow: TimeInterval(hours: 3)) :
@@ -32,6 +34,12 @@ struct ForecastChart: View {
         return formatter
     }
 
+    private var selectedGlucose: GlucoseStored? {
+        guard let selection = selection else { return nil }
+        let range = selection.addingTimeInterval(-150) ... selection.addingTimeInterval(150)
+        return state.glucoseFromPersistence.first { $0.date.map(range.contains) ?? false }
+    }
+
     var body: some View {
         VStack {
             forecastChartLabels
@@ -114,7 +122,43 @@ struct ForecastChart: View {
             } else {
                 drawForecastsCone()
             }
+
+            if let selectedGlucose {
+                RuleMark(x: .value("Selection", selectedGlucose.date ?? Date.now, unit: .minute))
+                    .foregroundStyle(Color.tabBar)
+                    .lineStyle(.init(lineWidth: 2))
+                    .annotation(
+                        position: .top,
+                        overflowResolution: .init(x: .fit(to: .chart), y: .disabled)
+                    ) {
+                        selectionPopover
+                    }
+
+                PointMark(
+                    x: .value("Time", selectedGlucose.date ?? Date.now, unit: .minute),
+                    y: .value("Value", selectedGlucose.glucose)
+                )
+                .zIndex(-1)
+                .symbolSize(CGSize(width: 15, height: 15))
+                .foregroundStyle(
+                    Decimal(selectedGlucose.glucose) > state.highGlucose ? Color.orange
+                        .opacity(0.8) :
+                        (
+                            Decimal(selectedGlucose.glucose) < state.lowGlucose ? Color.red.opacity(0.8) : Color.green
+                                .opacity(0.8)
+                        )
+                )
+
+                PointMark(
+                    x: .value("Time", selectedGlucose.date ?? Date.now, unit: .minute),
+                    y: .value("Value", selectedGlucose.glucose)
+                )
+                .zIndex(-1)
+                .symbolSize(CGSize(width: 6, height: 6))
+                .foregroundStyle(Color.primary)
+            }
         }
+        .chartXSelection(value: $selection)
         .chartXAxis { forecastChartXAxis }
         .chartXScale(domain: startMarker ... endMarker)
         .chartYAxis { forecastChartYAxis }
@@ -122,6 +166,48 @@ struct ForecastChart: View {
         .backport.chartForegroundStyleScale(state: state)
     }
 
+    @ViewBuilder var selectionPopover: some View {
+        if let sgv = selectedGlucose?.glucose {
+            VStack(alignment: .leading) {
+                HStack {
+                    Image(systemName: "clock")
+                    Text(selectedGlucose?.date?.formatted(.dateTime.hour().minute(.twoDigits)) ?? "")
+                        .font(.footnote).bold()
+                }.font(.footnote).padding(.bottom, 5)
+
+                // TODO: workaround for now: set low value to 55, to have dynamic color shades between 55 and user-set low (approx. 70); same for high glucose
+                let hardCodedLow = Decimal(55)
+                let hardCodedHigh = Decimal(220)
+                let isDynamicColorScheme = state.glucoseColorScheme == .dynamicColor
+
+                let glucoseColor = FreeAPS.getDynamicGlucoseColor(
+                    glucoseValue: Decimal(sgv),
+                    highGlucoseColorValue: isDynamicColorScheme ? hardCodedHigh : state.highGlucose,
+                    lowGlucoseColorValue: isDynamicColorScheme ? hardCodedLow : state.lowGlucose,
+                    targetGlucose: state.currentBGTarget,
+                    glucoseColorScheme: state.glucoseColorScheme
+                )
+                HStack {
+                    Text(state.units == .mgdL ? Decimal(sgv).description : Decimal(sgv).formattedAsMmolL)
+                        .bold()
+                        + Text(" \(state.units.rawValue)")
+                }.foregroundStyle(
+                    Color(glucoseColor)
+                ).font(.footnote)
+            }
+            .padding(7)
+            .background {
+                RoundedRectangle(cornerRadius: 4)
+                    .fill(Color.chart.opacity(0.85))
+                    .shadow(color: Color.secondary, radius: 2)
+                    .overlay(
+                        RoundedRectangle(cornerRadius: 4)
+                            .stroke(Color.secondary, lineWidth: 2)
+                    )
+            }
+        }
+    }
+
     private func drawGlucose() -> some ChartContent {
         ForEach(state.glucoseFromPersistence) { item in
             let glucoseToDisplay = state.units == .mgdL ? Decimal(item.glucose) : Decimal(item.glucose).asMmolL

+ 2 - 1
FreeAPS/Sources/Modules/Home/HomeStateModel+Setup/CarbSetup.swift

@@ -16,7 +16,8 @@ extension Home.StateModel {
             onContext: carbsFetchContext,
             predicate: NSPredicate.carbsForChart,
             key: "date",
-            ascending: false
+            ascending: false,
+            batchSize: 5
         )
 
         return await carbsFetchContext.perform {

+ 2 - 1
FreeAPS/Sources/Modules/Home/HomeStateModel+Setup/GlucoseSetup.swift

@@ -16,7 +16,8 @@ extension Home.StateModel {
             onContext: glucoseFetchContext,
             predicate: NSPredicate.glucose,
             key: "date",
-            ascending: true
+            ascending: true,
+            batchSize: 50
         )
 
         return await glucoseFetchContext.perform {

+ 2 - 1
FreeAPS/Sources/Modules/Home/HomeStateModel+Setup/PumpHistorySetup.swift

@@ -16,7 +16,8 @@ extension Home.StateModel {
             onContext: pumpHistoryFetchContext,
             predicate: NSPredicate.pumpHistoryLast24h,
             key: "timestamp",
-            ascending: true
+            ascending: true,
+            batchSize: 30
         )
 
         return await pumpHistoryFetchContext.perform {

+ 28 - 45
FreeAPS/Sources/Services/LiveActivity/LiveActivityBridge.swift

@@ -15,18 +15,17 @@ import UIKit
              .ended,
              .stale:
             return true
-        case .active: break
+        case .active:
+            break
         @unknown default:
             return true
         }
-
-        return -startDate.timeIntervalSinceNow >
-            TimeInterval(60 * 60)
+        return -startDate.timeIntervalSinceNow > TimeInterval(60 * 60)
     }
 }
 
-@available(iOS 16.2, *) final class LiveActivityBridge: Injectable, ObservableObject, SettingsObserver
-{
+@available(iOS 16.2, *)
+final class LiveActivityBridge: Injectable, ObservableObject, SettingsObserver {
     @Injected() private var settingsManager: SettingsManager!
     @Injected() private var broadcaster: Broadcaster!
     @Injected() private var storage: FileStorage!
@@ -90,8 +89,6 @@ import UIKit
         )
     }
 
-    // TODO: - use a delegate or a custom notification here instead
-
     func settingsDidChange(_: FreeAPSSettings) {
         Task {
             await updateContentState(determination)
@@ -99,7 +96,6 @@ import UIKit
     }
 
     private func registerHandler() {
-        // Since we are only using this info to show if an Override is active or not in the Live Activity it is enough to observe only the 'OverrideStored' Entity
         coreDataPublisher?.filterByEntityName("OverrideStored").sink { [weak self] _ in
             guard let self = self else { return }
             self.overridesDidUpdate()
@@ -141,15 +137,16 @@ import UIKit
 
     @objc private func handleLiveActivityOrderChange() {
         Task {
-            self.widgetItems = UserDefaults.standard
-                .loadLiveActivityOrderFromUserDefaults() ?? LiveActivityAttributes.LiveActivityItem.defaultItems
+            self.widgetItems = UserDefaults.standard.loadLiveActivityOrderFromUserDefaults() ?? LiveActivityAttributes
+                .LiveActivityItem.defaultItems
             await self.updateLiveActivityOrder()
         }
     }
 
     @MainActor private func updateContentState<T>(_ update: T) async {
-        guard let latestGlucose = latestGlucose else { return }
-
+        guard let latestGlucose = latestGlucose else {
+            return
+        }
         var content: LiveActivityAttributes.ContentState?
 
         if let determination = update as? DeterminationData {
@@ -189,10 +186,7 @@ import UIKit
 
     private func setupGlucoseArray() {
         Task { @MainActor in
-            // Fetch and map glucose to GlucoseData struct
             self.glucoseFromPersistence = await fetchAndMapGlucose()
-
-            // Push the update to the Live Activity
             glucoseDidUpdate(glucoseFromPersistence ?? [])
         }
     }
@@ -209,14 +203,9 @@ import UIKit
         }
     }
 
-    /// creates and tries to present a new activity update from the current GlucoseStorage values if live activities are enabled in settings
-    /// Ends existing live activities if live activities are not enabled in settings
     @MainActor private func forceActivityUpdate() {
-        // just before app resigns active, show a new activity
-        // only do this if there is no current activity or the current activity is older than 1h
         if settings.useLiveActivity {
-            if currentActivity?.needsRecreation() ?? true
-            {
+            if currentActivity?.needsRecreation() ?? true {
                 glucoseDidUpdate(glucoseFromPersistence ?? [])
             }
         } else {
@@ -226,9 +215,7 @@ import UIKit
         }
     }
 
-    /// attempts to present this live activity state, creating a new activity if none exists yet
     @MainActor private func pushUpdate(_ state: LiveActivityAttributes.ContentState) async {
-//        // End all activities that are not the current one
         for unknownActivity in Activity<LiveActivityAttributes>.activities
             .filter({ self.currentActivity?.activity.id != $0.id })
         {
@@ -248,26 +235,23 @@ import UIKit
             }
         } else {
             do {
-                // always push a non-stale content as the first update
-                // pushing a stale content as the frst content results in the activity not being shown at all
-                // apparently this initial state is also what is shown after the live activity expires (after 8h)
                 let expired = ActivityContent(
-                    state: LiveActivityAttributes.ContentState(
-                        bg: "--",
-                        direction: nil,
-                        change: "--",
-                        date: Date.now,
-                        highGlucose: settings.high,
-                        lowGlucose: settings.low,
-                        target: determination?.target ?? 100 as Decimal,
-                        glucoseColorScheme: settings.glucoseColorScheme.rawValue,
-                        detailedViewState: nil,
-                        isInitialState: true
-                    ),
+                    state: LiveActivityAttributes
+                        .ContentState(
+                            bg: "--",
+                            direction: nil,
+                            change: "--",
+                            date: Date.now,
+                            highGlucose: settings.high,
+                            lowGlucose: settings.low,
+                            target: determination?.target ?? 100 as Decimal,
+                            glucoseColorScheme: settings.glucoseColorScheme.rawValue,
+                            detailedViewState: nil,
+                            isInitialState: true
+                        ),
                     staleDate: Date.now.addingTimeInterval(60)
                 )
 
-                // Request a new activity
                 let activity = try Activity.request(
                     attributes: LiveActivityAttributes(startDate: Date.now),
                     content: expired,
@@ -275,22 +259,22 @@ import UIKit
                 )
                 currentActivity = ActiveActivity(activity: activity, startDate: Date.now)
 
-                // then show the actual content
                 await pushUpdate(state)
             } catch {
-                print("Activity creation error: \(error)")
+                debug(
+                    .default,
+                    "\(#file): Error creating new activity: \(error)"
+                )
             }
         }
     }
 
-    /// ends all live activities immediateny
     private func endActivity() async {
         if let currentActivity {
             await currentActivity.activity.end(nil, dismissalPolicy: .immediate)
             self.currentActivity = nil
         }
 
-        // end any other activities
         for unknownActivity in Activity<LiveActivityAttributes>.activities {
             await unknownActivity.end(nil, dismissalPolicy: .immediate)
         }
@@ -309,7 +293,6 @@ extension LiveActivityBridge {
             return
         }
 
-        // backfill latest glucose if contained in this update
         if glucose.count > 1 {
             latestGlucose = glucose.dropFirst().first
         }