Forráskód Böngészése

loop view upgrade

Ivan Valkou 5 éve
szülő
commit
b959f04dbc

FreeAPS/Resources/Assets.xcassets/LoopGrey.colorset/Contents.json → FreeAPS/Resources/Assets.xcassets/LoopGray.colorset/Contents.json


+ 20 - 1
FreeAPS/Sources/APS/APSManager.swift

@@ -11,6 +11,7 @@ protocol APSManager {
     func enactBolus(amount: Double)
     var pumpManager: PumpManagerUI? { get set }
     var pumpDisplayState: CurrentValueSubject<PumpDisplayState?, Never> { get }
+    var isLooping: CurrentValueSubject<Bool, Never> { get }
     func enactTempBasal(rate: Double, duration: TimeInterval)
     func makeProfiles() -> AnyPublisher<Bool, Never>
 }
@@ -38,6 +39,8 @@ final class BaseAPSManager: APSManager, Injectable {
         set { deviceDataManager.pumpManager = newValue }
     }
 
+    let isLooping = CurrentValueSubject<Bool, Never>(false)
+
     var pumpDisplayState: CurrentValueSubject<PumpDisplayState?, Never> {
         deviceDataManager.pumpDisplayState
     }
@@ -83,6 +86,7 @@ final class BaseAPSManager: APSManager, Injectable {
     }
 
     private func loop() {
+        isLooping.send(true)
         Publishers.CombineLatest3(
             nightscout.fetchGlucose(),
             nightscout.fetchCarbs(),
@@ -98,7 +102,11 @@ final class BaseAPSManager: APSManager, Injectable {
                 self.nightscout.uploadStatus()
                 if self.settings.closedLoop {
                     self.enactSuggested()
+                } else {
+                    self.isLooping.send(false)
                 }
+            } else {
+                self.isLooping.send(false)
             }
         }.store(in: &lifetime)
     }
@@ -318,9 +326,13 @@ final class BaseAPSManager: APSManager, Injectable {
     }
 
     private func enactSuggested() {
-        guard let suggested = try? storage.retrieve(OpenAPS.Enact.suggested, as: Suggestion.self) else { return }
+        guard let suggested = try? storage.retrieve(OpenAPS.Enact.suggested, as: Suggestion.self) else {
+            isLooping.send(false)
+            return
+        }
 
         guard let pump = pumpManager, verifyStatus() else {
+            isLooping.send(false)
             return
         }
 
@@ -355,6 +367,7 @@ final class BaseAPSManager: APSManager, Injectable {
                 } else {
                     self?.reportEnacted(suggestion: suggested, received: true)
                 }
+                self?.isLooping.send(false)
             } receiveValue: {
                 debug(.apsManager, "Loop succeeded")
             }.store(in: &lifetime)
@@ -366,6 +379,12 @@ final class BaseAPSManager: APSManager, Injectable {
             enacted.timestamp = Date()
             enacted.recieved = received
             try? storage.save(enacted, as: OpenAPS.Enact.enacted)
+            debug(.apsManager, "Suggestion enacted")
+            DispatchQueue.main.async {
+                self.broadcaster.notify(EnactedSuggestionObserver.self, on: .main) {
+                    $0.enactedSuggestionDidUpdate(enacted)
+                }
+            }
             nightscout.uploadStatus()
         }
     }

+ 4 - 0
FreeAPS/Sources/Models/Suggestion.swift

@@ -61,3 +61,7 @@ extension Predictions {
 protocol SuggestionObserver {
     func suggestionDidUpdate(_ suggestion: Suggestion)
 }
+
+protocol EnactedSuggestionObserver {
+    func enactedSuggestionDidUpdate(_ suggestion: Suggestion)
+}

+ 1 - 0
FreeAPS/Sources/Modules/Home/HomeDataFlow.swift

@@ -6,6 +6,7 @@ enum Home {
 
 protocol HomeProvider: Provider {
     var suggestion: Suggestion? { get }
+    var enactedSuggestion: Suggestion? { get }
     func fetchAndLoop()
     func filteredGlucose(hours: Int) -> [BloodGlucose]
     func pumpHistory(hours: Int) -> [PumpHistoryEvent]

+ 4 - 0
FreeAPS/Sources/Modules/Home/HomeProvider.swift

@@ -13,6 +13,10 @@ extension Home {
             try? storage.retrieve(OpenAPS.Enact.suggested, as: Suggestion.self)
         }
 
+        var enactedSuggestion: Suggestion? {
+            try? storage.retrieve(OpenAPS.Enact.enacted, as: Suggestion.self)
+        }
+
         func fetchAndLoop() {
             apsManager.fetchAndLoop()
         }

+ 43 - 2
FreeAPS/Sources/Modules/Home/HomeViewModel.swift

@@ -5,11 +5,13 @@ extension Home {
     class ViewModel<Provider>: BaseViewModel<Provider>, ObservableObject where Provider: HomeProvider {
         @Injected() var broadcaster: Broadcaster!
         @Injected() var settingsManager: SettingsManager!
-
+        @Injected() var apsManager: APSManager!
+        private let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
         private(set) var filteredHours = 24
 
         @Published var glucose: [BloodGlucose] = []
         @Published var suggestion: Suggestion?
+        @Published var enactedSuggestion: Suggestion?
         @Published var recentGlucose: BloodGlucose?
         @Published var glucoseDelta: Int?
         @Published var tempBasals: [PumpHistoryEvent] = []
@@ -18,6 +20,10 @@ extension Home {
         @Published var basalProfile: [BasalProfileEntry] = []
         @Published var tempTargets: [TempTarget] = []
         @Published var carbs: [CarbsEntry] = []
+        @Published var timerDate = Date()
+        @Published var closedLoop = false
+        @Published var isLooping = false
+        @Published var statusTitle = ""
 
         @Published var allowManualTemp = false
         private(set) var units: GlucoseUnits = .mmolL
@@ -32,8 +38,11 @@ extension Home {
             setupCarbs()
 
             suggestion = provider.suggestion
+            enactedSuggestion = provider.enactedSuggestion
             units = settingsManager.settings.units
             allowManualTemp = !settingsManager.settings.closedLoop
+            closedLoop = settingsManager.settings.closedLoop
+            setStatusTitle()
 
             broadcaster.register(GlucoseObserver.self, observer: self)
             broadcaster.register(SuggestionObserver.self, observer: self)
@@ -43,6 +52,15 @@ extension Home {
             broadcaster.register(BasalProfileObserver.self, observer: self)
             broadcaster.register(TempTargetsObserver.self, observer: self)
             broadcaster.register(CarbsObserver.self, observer: self)
+            broadcaster.register(EnactedSuggestionObserver.self, observer: self)
+
+            timer.assign(to: \.timerDate, on: self)
+                .store(in: &lifetime)
+
+            apsManager.isLooping
+                .receive(on: DispatchQueue.main)
+                .assign(to: \.isLooping, on: self)
+                .store(in: &lifetime)
         }
 
         func addCarbs() {
@@ -124,6 +142,21 @@ extension Home {
                 self.carbs = self.provider.carbs(hours: self.filteredHours)
             }
         }
+
+        private func setStatusTitle() {
+            guard let suggestion = suggestion else {
+                statusTitle = "No suggestion"
+                return
+            }
+
+            if closedLoop,
+               enactedSuggestion?.deliverAt == suggestion.deliverAt || (suggestion.rate == nil && suggestion.units == nil)
+            {
+                statusTitle = "Enacted"
+            } else {
+                statusTitle = "Suggested"
+            }
+        }
     }
 }
 
@@ -135,7 +168,8 @@ extension Home.ViewModel:
     PumpSettingsObserver,
     BasalProfileObserver,
     TempTargetsObserver,
-    CarbsObserver
+    CarbsObserver,
+    EnactedSuggestionObserver
 {
     func glucoseDidUpdate(_: [BloodGlucose]) {
         setupGlucose()
@@ -143,10 +177,12 @@ extension Home.ViewModel:
 
     func suggestionDidUpdate(_ suggestion: Suggestion) {
         self.suggestion = suggestion
+        setStatusTitle()
     }
 
     func settingsDidChange(_ settings: FreeAPSSettings) {
         allowManualTemp = !settings.closedLoop
+        closedLoop = settingsManager.settings.closedLoop
     }
 
     func pumpHistoryDidUpdate(_: [PumpHistoryEvent]) {
@@ -169,4 +205,9 @@ extension Home.ViewModel:
     func carbsDidUpdate(_: [CarbsEntry]) {
         setupCarbs()
     }
+
+    func enactedSuggestionDidUpdate(_ suggestion: Suggestion) {
+        enactedSuggestion = suggestion
+        setStatusTitle()
+    }
 }

+ 94 - 74
FreeAPS/Sources/Modules/Home/View/Chart/MainChartView.swift

@@ -16,6 +16,7 @@ struct DotInfo {
 
 struct MainChartView: View {
     private enum Config {
+        static let endID = "End"
         static let screenHours = 5
         static let basalHeight: CGFloat = 60
         static let topYPadding: CGFloat = 20
@@ -95,64 +96,74 @@ struct MainChartView: View {
     var body: some View {
         GeometryReader { geo in
             ZStack(alignment: .leading) {
-                // Y grid
-                Path { path in
-                    let range = glucoseYRange(fullSize: geo.size)
-                    let step = (range.maxY - range.minY) / CGFloat(Config.yLinesCount)
-                    for line in 0 ... Config.yLinesCount {
-                        path.move(to: CGPoint(x: 0, y: range.minY + CGFloat(line) * step))
-                        path.addLine(to: CGPoint(x: geo.size.width, y: range.minY + CGFloat(line) * step))
-                    }
-                }.stroke(Color.secondary, lineWidth: 0.2)
-
-                ScrollView(.horizontal, showsIndicators: false) {
-                    ScrollViewReader { scroll in
-                        ZStack(alignment: .top) {
-                            tempTargetsView(fullSize: geo.size)
-                            basalView(fullSize: geo.size)
-                            mainView(fullSize: geo.size).id("End")
-                                .onChange(of: glucose) { _ in
-                                    scroll.scrollTo("End", anchor: .trailing)
-                                }
-                                .onChange(of: suggestion) { _ in
-                                    scroll.scrollTo("End", anchor: .trailing)
-                                }
-                                .onChange(of: tempBasals) { _ in
-                                    scroll.scrollTo("End", anchor: .trailing)
-                                }
-                                .onAppear {
-                                    // add trigger to the end of main queue
-                                    DispatchQueue.main.async {
-                                        scroll.scrollTo("End", anchor: .trailing)
-                                        didAppearTrigger = true
-                                    }
-                                }
+                yGridView(fullSize: geo.size)
+                mainScrollView(fullSize: geo.size)
+                glucoseLabelsView(fullSize: geo.size)
+            }
+        }
+    }
+
+    private func mainScrollView(fullSize: CGSize) -> some View {
+        ScrollView(.horizontal, showsIndicators: false) {
+            ScrollViewReader { scroll in
+                ZStack(alignment: .top) {
+                    tempTargetsView(fullSize: fullSize)
+                    basalView(fullSize: fullSize)
+
+                    mainView(fullSize: fullSize).id(Config.endID)
+                        .onChange(of: glucose) { _ in
+                            scroll.scrollTo(Config.endID, anchor: .trailing)
+                        }
+                        .onChange(of: suggestion) { _ in
+                            scroll.scrollTo(Config.endID, anchor: .trailing)
+                        }
+                        .onChange(of: tempBasals) { _ in
+                            scroll.scrollTo(Config.endID, anchor: .trailing)
+                        }
+                        .onAppear {
+                            // add trigger to the end of main queue
+                            DispatchQueue.main.async {
+                                scroll.scrollTo(Config.endID, anchor: .trailing)
+                                didAppearTrigger = true
+                            }
                         }
-                    }
-                }
-                // Y glucose labels
-                ForEach(0 ..< Config.yLinesCount + 1) { line -> AnyView in
-                    let range = glucoseYRange(fullSize: geo.size)
-                    let yStep = (range.maxY - range.minY) / CGFloat(Config.yLinesCount)
-                    let valueStep = Double(range.maxValue - range.minValue) / Double(Config.yLinesCount)
-                    let value = round(Double(range.maxValue) - Double(line) * valueStep) *
-                        (units == .mmolL ? Double(GlucoseUnits.exchangeRate) : 1)
-
-                    return Text(glucoseFormatter.string(from: value as NSNumber)!)
-                        .position(CGPoint(x: geo.size.width - 12, y: range.minY + CGFloat(line) * yStep))
-                        .font(.caption2)
-                        .asAny()
                 }
             }
         }
     }
 
+    private func yGridView(fullSize: CGSize) -> some View {
+        Path { path in
+            let range = glucoseYRange(fullSize: fullSize)
+            let step = (range.maxY - range.minY) / CGFloat(Config.yLinesCount)
+            for line in 0 ... Config.yLinesCount {
+                path.move(to: CGPoint(x: 0, y: range.minY + CGFloat(line) * step))
+                path.addLine(to: CGPoint(x: fullSize.width, y: range.minY + CGFloat(line) * step))
+            }
+        }.stroke(Color.secondary, lineWidth: 0.2)
+    }
+
+    private func glucoseLabelsView(fullSize: CGSize) -> some View {
+        ForEach(0 ..< Config.yLinesCount + 1) { line -> AnyView in
+            let range = glucoseYRange(fullSize: fullSize)
+            let yStep = (range.maxY - range.minY) / CGFloat(Config.yLinesCount)
+            let valueStep = Double(range.maxValue - range.minValue) / Double(Config.yLinesCount)
+            let value = round(Double(range.maxValue) - Double(line) * valueStep) *
+                (units == .mmolL ? Double(GlucoseUnits.exchangeRate) : 1)
+
+            return Text(glucoseFormatter.string(from: value as NSNumber)!)
+                .position(CGPoint(x: fullSize.width - 12, y: range.minY + CGFloat(line) * yStep))
+                .font(.caption2)
+                .asAny()
+        }
+    }
+
     private func basalView(fullSize: CGSize) -> some View {
         ZStack {
             tempBasalPath.fill(Color.blue)
             tempBasalPath.stroke(Color.blue, lineWidth: 1)
             regularBasalPath.stroke(Color.yellow, lineWidth: 1)
-            Text(lastBasalRateString)
+            Text(lastBasalRateString())
                 .foregroundColor(.blue)
                 .font(.caption2)
                 .position(CGPoint(x: lastBasalPoint(fullSize: fullSize).x + 30, y: Config.basalHeight / 2))
@@ -179,41 +190,48 @@ struct MainChartView: View {
         Group {
             VStack {
                 ZStack {
-                    // X grid
-                    Path { path in
-                        for hour in 0 ..< hours + hours {
-                            let x = firstHourPosition(viewWidth: fullSize.width) +
-                                oneSecondStep(viewWidth: fullSize.width) *
-                                CGFloat(hour) * CGFloat(1.hours.timeInterval)
-                            path.move(to: CGPoint(x: x, y: 0))
-                            path.addLine(to: CGPoint(x: x, y: fullSize.height - 20))
-                        }
-                    }
-                    .stroke(Color.secondary, lineWidth: 0.2)
+                    xGridView(fullSize: fullSize)
                     carbsView(fullSize: fullSize)
                     bolusView(fullSize: fullSize)
                     glucoseView(fullSize: fullSize)
                     predictionsView(fullSize: fullSize)
                 }
-                ZStack {
-                    // X time labels
-                    ForEach(0 ..< hours + hours) { hour in
-                        Text(dateDormatter.string(from: firstHourDate().addingTimeInterval(hour.hours.timeInterval)))
-                            .font(.caption)
-                            .position(
-                                x: firstHourPosition(viewWidth: fullSize.width) +
-                                    oneSecondStep(viewWidth: fullSize.width) *
-                                    CGFloat(hour) * CGFloat(1.hours.timeInterval),
-                                y: 10.0
-                            )
-                            .foregroundColor(.secondary)
-                    }
-                }.frame(maxHeight: 20)
+                timeLabelsView(fullSize: fullSize)
             }
         }
         .frame(width: fullGlucoseWidth(viewWidth: fullSize.width) + additionalWidth(viewWidth: fullSize.width))
     }
 
+    private func xGridView(fullSize: CGSize) -> some View {
+        Path { path in
+            for hour in 0 ..< hours + hours {
+                let x = firstHourPosition(viewWidth: fullSize.width) +
+                    oneSecondStep(viewWidth: fullSize.width) *
+                    CGFloat(hour) * CGFloat(1.hours.timeInterval)
+                path.move(to: CGPoint(x: x, y: 0))
+                path.addLine(to: CGPoint(x: x, y: fullSize.height - 20))
+            }
+        }
+        .stroke(Color.secondary, lineWidth: 0.2)
+    }
+
+    private func timeLabelsView(fullSize: CGSize) -> some View {
+        ZStack {
+            // X time labels
+            ForEach(0 ..< hours + hours) { hour in
+                Text(dateDormatter.string(from: firstHourDate().addingTimeInterval(hour.hours.timeInterval)))
+                    .font(.caption)
+                    .position(
+                        x: firstHourPosition(viewWidth: fullSize.width) +
+                            oneSecondStep(viewWidth: fullSize.width) *
+                            CGFloat(hour) * CGFloat(1.hours.timeInterval),
+                        y: 10.0
+                    )
+                    .foregroundColor(.secondary)
+            }
+        }.frame(maxHeight: 20)
+    }
+
     private func glucoseView(fullSize: CGSize) -> some View {
         Path { path in
             for rect in glucoseDots {
@@ -334,9 +352,11 @@ struct MainChartView: View {
             calculateCarbsDots(fullSize: fullSize)
         }
     }
+}
 
-    // MARK: - Calculations
+// MARK: - Calculations
 
+extension MainChartView {
     private func calculateGlucoseDots(fullSize: CGSize) {
         calculationQueue.async {
             let dots = glucose.concurrentMap { value -> CGRect in
@@ -587,7 +607,7 @@ struct MainChartView: View {
         return CGPoint(x: x, y: y)
     }
 
-    private var lastBasalRateString: String {
+    private func lastBasalRateString() -> String {
         let lastBasal = Array(tempBasals.suffix(2))
         guard lastBasal.count == 2 else {
             return ""

+ 60 - 8
FreeAPS/Sources/Modules/Home/View/Header/LoopView.swift

@@ -3,7 +3,15 @@ import SwiftUI
 import UIKit
 
 struct LoopView: View {
+    private enum Config {
+        static let lag: TimeInterval = 30
+    }
+
     @Binding var suggestion: Suggestion?
+    @Binding var enactedSuggestion: Suggestion?
+    @Binding var closedLoop: Bool
+    @Binding var timerDate: Date
+    @Binding var isLooping: Bool
 
     private var dateFormatter: DateFormatter {
         let formatter = DateFormatter()
@@ -11,23 +19,35 @@ struct LoopView: View {
         return formatter
     }
 
+    private let rect = CGRect(x: 0, y: 0, width: 38, height: 38)
     var body: some View {
-        VStack {
-            Circle().strokeBorder(color, lineWidth: 6).frame(width: 38, height: 38)
+        VStack(alignment: .center) {
+            ZStack {
+                Circle()
+                    .strokeBorder(color, lineWidth: 6)
+                    .frame(width: rect.width, height: rect.height)
+                    .mask(mask(in: rect).fill(style: FillStyle(eoFill: true)))
+                if isLooping {
+                    ProgressView()
+                }
+            }
+
             Spacer()
-            if let date = suggestion?.deliverAt {
-                Text(dateFormatter.string(from: date)).font(.caption)
+            if isLooping {
+                Text("looping").font(.caption2)
+            } else if let date = actualSuggestion?.timestamp {
+                Text("\(Int((timerDate.timeIntervalSince(date) - Config.lag) / 60) + 1) min").font(.caption)
             } else {
                 Text("--").font(.caption)
             }
         }
     }
 
-    var color: Color {
-        guard let lastDate = suggestion?.deliverAt else {
-            return Color(UIColor(named: "LoopGrey")!)
+    private var color: Color {
+        guard let lastDate = actualSuggestion?.timestamp else {
+            return Color(UIColor(named: "LoopGray")!)
         }
-        let delta = Date().timeIntervalSince(lastDate)
+        let delta = timerDate.timeIntervalSince(lastDate) - Config.lag
 
         if delta <= 5.minutes.timeInterval {
             return Color(UIColor(named: "LoopGreen")!)
@@ -37,4 +57,36 @@ struct LoopView: View {
             return Color(UIColor(named: "LoopRed")!)
         }
     }
+
+    func mask(in rect: CGRect) -> Path {
+        var path = Rectangle().path(in: rect)
+        if !closedLoop {
+            path.addPath(Rectangle().path(in: CGRect(x: rect.minX, y: rect.midY - 5, width: rect.width, height: 10)))
+        }
+        return path
+    }
+
+    private var actualSuggestion: Suggestion? {
+        if closedLoop, suggestion?.rate != nil || suggestion?.units != nil {
+            return enactedSuggestion
+        } else {
+            return suggestion
+        }
+    }
+}
+
+extension View {
+    func animateForever(
+        using animation: Animation = Animation.easeInOut(duration: 1),
+        autoreverses: Bool = false,
+        _ action: @escaping () -> Void
+    ) -> some View {
+        let repeated = animation.repeatForever(autoreverses: autoreverses)
+
+        return onAppear {
+            withAnimation(repeated) {
+                action()
+            }
+        }
+    }
 }

+ 24 - 12
FreeAPS/Sources/Modules/Home/View/HomeRootView.swift

@@ -4,7 +4,7 @@ import SwiftUI
 extension Home {
     struct RootView: BaseView {
         @EnvironmentObject var viewModel: ViewModel<Provider>
-        @State var isPopupPresented = false
+        @State var isStatusPopupPresented = false
 
         private var numberFormatter: NumberFormatter {
             let formatter = NumberFormatter()
@@ -34,8 +34,14 @@ extension Home {
                     units: viewModel.units
                 )
                 .padding(.horizontal)
-                LoopView(suggestion: $viewModel.suggestion).onTapGesture {
-                    isPopupPresented = true
+                LoopView(
+                    suggestion: $viewModel.suggestion,
+                    enactedSuggestion: $viewModel.enactedSuggestion,
+                    closedLoop: $viewModel.closedLoop,
+                    timerDate: $viewModel.timerDate,
+                    isLooping: $viewModel.isLooping
+                ).onTapGesture {
+                    isStatusPopupPresented = true
                 }.onLongPressGesture {
                     viewModel.runLoop()
                 }
@@ -100,15 +106,21 @@ extension Home {
             .navigationTitle("Home")
             .navigationBarHidden(true)
             .ignoresSafeArea(.keyboard)
-            .popup(isPresented: isPopupPresented, alignment: .top, direction: .top) {
-                Text(viewModel.suggestion?.reason ?? "No sugestion found").font(.caption).padding().foregroundColor(.white)
-                    .background(
-                        RoundedRectangle(cornerRadius: 8, style: .continuous)
-                            .fill(Color(UIColor.darkGray))
-                    )
-                    .onTapGesture {
-                        isPopupPresented = false
-                    }
+            .popup(isPresented: isStatusPopupPresented, alignment: .top, direction: .top) {
+                VStack(alignment: .leading) {
+                    Text(viewModel.statusTitle).foregroundColor(.white)
+                        .padding(.bottom, 4)
+                    Text(viewModel.suggestion?.reason ?? "No sugestion found").font(.caption).foregroundColor(.white)
+                }
+                .padding()
+
+                .background(
+                    RoundedRectangle(cornerRadius: 8, style: .continuous)
+                        .fill(Color(UIColor.darkGray))
+                )
+                .onTapGesture {
+                    isStatusPopupPresented = false
+                }
             }
         }
     }