From f917bb39b5fe07ae3b9f873eff4a0e20488e59e8 Mon Sep 17 00:00:00 2001 From: bastiaanv Date: Tue, 25 Jun 2024 16:59:05 +0200 Subject: [PATCH 01/33] start: Start live activity project --- .../Bootstrap/Bootstrap.swift | 11 ++ .../Helpers/LocalizedString.swift | 21 +++ .../LiveActivityConfiguration.swift | 122 ++++++++++++ Loop Widget Extension/LoopWidgets.swift | 1 + Loop.xcodeproj/project.pbxproj | 50 +++++ Loop/Info.plist | 5 + Loop/Loop.entitlements | 2 + .../GlucoseActivityAttributes.swift | 48 +++++ .../GlucoseActivityManager.swift | 174 ++++++++++++++++++ Loop/Managers/LoopDataManager.swift | 7 +- 10 files changed, 440 insertions(+), 1 deletion(-) create mode 100644 Loop Widget Extension/Bootstrap/Bootstrap.swift create mode 100644 Loop Widget Extension/Helpers/LocalizedString.swift create mode 100644 Loop Widget Extension/Live Activity/LiveActivityConfiguration.swift create mode 100644 Loop/Managers/Live Activity/GlucoseActivityAttributes.swift create mode 100644 Loop/Managers/Live Activity/GlucoseActivityManager.swift diff --git a/Loop Widget Extension/Bootstrap/Bootstrap.swift b/Loop Widget Extension/Bootstrap/Bootstrap.swift new file mode 100644 index 0000000000..00823471c1 --- /dev/null +++ b/Loop Widget Extension/Bootstrap/Bootstrap.swift @@ -0,0 +1,11 @@ +// +// Bootstrap.swift +// Loop Widget Extension +// +// Created by Bastiaan Verhaar on 25/06/2024. +// Copyright © 2024 LoopKit Authors. All rights reserved. +// + +import Foundation + +class Bootstrap{} diff --git a/Loop Widget Extension/Helpers/LocalizedString.swift b/Loop Widget Extension/Helpers/LocalizedString.swift new file mode 100644 index 0000000000..158181755d --- /dev/null +++ b/Loop Widget Extension/Helpers/LocalizedString.swift @@ -0,0 +1,21 @@ +// +// LocalizedString.swift +// Loop Widget Extension +// +// Created by Bastiaan Verhaar on 25/06/2024. +// Copyright © 2024 LoopKit Authors. All rights reserved. +// + +import Foundation + +private class FrameworkBundle { + static let main = Bundle(for: Bootstrap.self) +} + +func LocalizedString(_ key: String, tableName: String? = nil, value: String? = nil, comment: String) -> String { + if let value = value { + return NSLocalizedString(key, tableName: tableName, bundle: FrameworkBundle.main, value: value, comment: comment) + } else { + return NSLocalizedString(key, tableName: tableName, bundle: FrameworkBundle.main, comment: comment) + } +} diff --git a/Loop Widget Extension/Live Activity/LiveActivityConfiguration.swift b/Loop Widget Extension/Live Activity/LiveActivityConfiguration.swift new file mode 100644 index 0000000000..56a70b9df2 --- /dev/null +++ b/Loop Widget Extension/Live Activity/LiveActivityConfiguration.swift @@ -0,0 +1,122 @@ +// +// LiveActivityConfiguration.swift +// Loop Widget Extension +// +// Created by Bastiaan Verhaar on 23/06/2024. +// Copyright © 2024 LoopKit Authors. All rights reserved. +// + +import ActivityKit +import LoopKit +import SwiftUI +import LoopCore +import WidgetKit + +@available(iOS 16.2, *) +struct LiveActivityConfiguration: Widget { + var body: some WidgetConfiguration { + ActivityConfiguration(for: GlucoseActivityAttributes.self) { context in + // Create the presentation that appears on the Lock Screen and as a + // banner on the Home Screen of devices that don't support the Dynamic Island. + HStack { + glucoseView(context) + Spacer() + pumpView(context) + } + .privacySensitive() + .padding(.all, 15) + .background(BackgroundStyle.background.opacity(0.4)) + .activityBackgroundTint(Color.clear) + } dynamicIsland: { _ in + DynamicIsland { + DynamicIslandExpandedRegion(.leading) { + HStack{} + } + DynamicIslandExpandedRegion(.trailing) { + HStack{} + } + DynamicIslandExpandedRegion(.bottom) { + HStack{} + } + } compactLeading: { + // Create the compact leading presentation. + HStack{} + } compactTrailing: { + // Create the compact trailing presentation. + HStack{} + } minimal: { + // Create the minimal presentation. + HStack{} + } + } + } + + @ViewBuilder + private func glucoseView(_ context: ActivityViewContext) -> some View { + HStack { + Circle() + .trim(from: context.state.isCloseLoop ? 0 : 0.2, to: 1) + .stroke(getLoopColor(context.state.lastCompleted), lineWidth: 4) + .rotationEffect(Angle(degrees: -126)) + .frame(width: 18, height: 18) + + Text("\(context.state.glucose) \(context.state.unit)") + .font(.body) + Text(context.state.delta) + .font(.body) + .foregroundStyle(Color(UIColor.secondaryLabel)) + } + } + + @ViewBuilder + private func pumpView(_ context: ActivityViewContext) -> some View { + HStack(spacing: 10) { + if let pumpHighlight = context.state.pumpHighlight { + HStack { + Image(systemName: pumpHighlight.imageName) + .foregroundColor(pumpHighlight.state == .critical ? .critical : .warning) + Text(pumpHighlight.localizedMessage) + .fontWeight(.heavy) + } + } + else if let netBasal = context.state.netBasal { + BasalView(netBasal: + NetBasalContext( + rate: netBasal.rate, + percentage: netBasal.percentage, + start: netBasal.start, + end: netBasal.end + ), + isOld: false + ) + + VStack { + Text(LocalizedString("Eventual", comment: "No comment")) + .font(.footnote) + .foregroundColor(Color(UIColor.secondaryLabel)) + + Text("\(context.state.eventualGlucose) \(context.state.unit)") + .font(.subheadline) + .fontWeight(.heavy) + } + } + + } + } + + func getLoopColor(_ age: Date?) -> Color { + var freshness: LoopCompletionFreshness = .stale + if let age = age { + freshness = LoopCompletionFreshness(age: abs(min(0, age.timeIntervalSinceNow))) + } + + switch freshness { + case .fresh: + return Color("fresh") + case .aging: + return Color("warning") + case .stale: + return Color.red + } + } +} diff --git a/Loop Widget Extension/LoopWidgets.swift b/Loop Widget Extension/LoopWidgets.swift index 26f92edb45..ee3d811eed 100644 --- a/Loop Widget Extension/LoopWidgets.swift +++ b/Loop Widget Extension/LoopWidgets.swift @@ -14,5 +14,6 @@ struct LoopWidgets: WidgetBundle { @WidgetBundleBuilder var body: some Widget { SystemStatusWidget() + LiveActivityConfiguration() } } diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index 1181951609..07388b8d8f 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -401,6 +401,13 @@ B4E96D5D248A82A2002DABAD /* StatusBarHUDView.xib in Resources */ = {isa = PBXBuildFile; fileRef = B4E96D5C248A82A2002DABAD /* StatusBarHUDView.xib */; }; B4F3D25124AF890C0095CE44 /* BluetoothStateManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4F3D25024AF890C0095CE44 /* BluetoothStateManager.swift */; }; B4FEEF7D24B8A71F00A8DF9B /* DeviceDataManager+DeviceStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4FEEF7C24B8A71F00A8DF9B /* DeviceDataManager+DeviceStatus.swift */; }; + B87539C92C2B06CE0085A975 /* LocalizedString.swift in Sources */ = {isa = PBXBuildFile; fileRef = B87539C82C2B06CE0085A975 /* LocalizedString.swift */; }; + B87539CB2C2B08430085A975 /* Bootstrap.swift in Sources */ = {isa = PBXBuildFile; fileRef = B87539CA2C2B08430085A975 /* Bootstrap.swift */; }; + B87D411D2C28A69600120877 /* LiveActivityConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = B87D411C2C28A69600120877 /* LiveActivityConfiguration.swift */; }; + B87D411F2C28A85F00120877 /* ActivityKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = B87D411E2C28A85F00120877 /* ActivityKit.framework */; }; + B8A937B82C29BA5900E38645 /* GlucoseActivityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8A937B72C29BA5900E38645 /* GlucoseActivityManager.swift */; }; + B8A937C32C29C3B400E38645 /* GlucoseActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8A937C02C29C29300E38645 /* GlucoseActivityAttributes.swift */; }; + B8A937C42C29C43B00E38645 /* GlucoseActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8A937C02C29C29300E38645 /* GlucoseActivityAttributes.swift */; }; C1004DF22981F5B700B8CF94 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = C1004DF02981F5B700B8CF94 /* InfoPlist.strings */; }; C1004DF52981F5B700B8CF94 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = C1004DF32981F5B700B8CF94 /* Localizable.strings */; }; C1004DF82981F5B700B8CF94 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = C1004DF62981F5B700B8CF94 /* InfoPlist.strings */; }; @@ -1325,6 +1332,12 @@ B4E96D5C248A82A2002DABAD /* StatusBarHUDView.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = StatusBarHUDView.xib; sourceTree = ""; }; B4F3D25024AF890C0095CE44 /* BluetoothStateManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BluetoothStateManager.swift; sourceTree = ""; }; B4FEEF7C24B8A71F00A8DF9B /* DeviceDataManager+DeviceStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DeviceDataManager+DeviceStatus.swift"; sourceTree = ""; }; + B87539C82C2B06CE0085A975 /* LocalizedString.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalizedString.swift; sourceTree = ""; }; + B87539CA2C2B08430085A975 /* Bootstrap.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Bootstrap.swift; sourceTree = ""; }; + B87D411C2C28A69600120877 /* LiveActivityConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActivityConfiguration.swift; sourceTree = ""; }; + B87D411E2C28A85F00120877 /* ActivityKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = ActivityKit.framework; path = System/Library/Frameworks/ActivityKit.framework; sourceTree = SDKROOT; }; + B8A937B72C29BA5900E38645 /* GlucoseActivityManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseActivityManager.swift; sourceTree = ""; }; + B8A937C02C29C29300E38645 /* GlucoseActivityAttributes.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GlucoseActivityAttributes.swift; sourceTree = ""; }; C1004DEF2981F5B700B8CF94 /* da */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = da; path = da.lproj/InfoPlist.strings; sourceTree = ""; }; C1004DF12981F5B700B8CF94 /* da */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = da; path = da.lproj/InfoPlist.strings; sourceTree = ""; }; C1004DF42981F5B700B8CF94 /* da */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = da; path = da.lproj/Localizable.strings; sourceTree = ""; }; @@ -1727,6 +1740,7 @@ 1419606928D9554E00BA86E0 /* LoopKitUI.framework in Frameworks */, 1419606A28D955BC00BA86E0 /* MockKitUI.framework in Frameworks */, 1481F9BB28DA26F4004C5AEB /* LoopUI.framework in Frameworks */, + B87D411F2C28A85F00120877 /* ActivityKit.framework in Frameworks */, 1419606428D9550400BA86E0 /* LoopKitUI.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -1836,6 +1850,7 @@ 84AA81D12A4A2778000B658B /* Components */, 84AA81D92A4A2966000B658B /* Helpers */, 84AA81DE2A4A2B3D000B658B /* Timeline */, + B87D41192C28A61900120877 /* Live Activity */, 84AA81DF2A4A2B7A000B658B /* Widgets */, 84AA81D22A4A27A3000B658B /* LoopWidgets.swift */, ); @@ -2315,6 +2330,7 @@ 1DA6499D2441266400F61E75 /* Alerts */, E95D37FF24EADE68005E2F50 /* Store Protocols */, E9B355232935906B0076AB04 /* Missed Meal Detection */, + B8A937C52C29C44600E38645 /* Live Activity */, C1F2075B26D6F9B0007AB7EB /* AppExpirationAlerter.swift */, A96DAC2B2838F31200D94E38 /* SharedLogging.swift */, 7E69CFFB2A16A77E00203CBD /* ResetLoopManager.swift */, @@ -2525,6 +2541,7 @@ C11613472983096D00777E7C /* InfoPlist.strings */, 14B1736D28AEDA63006CCD7C /* LoopWidgetExtension.entitlements */, 14B1736628AED9EE006CCD7C /* Info.plist */, + B87539CA2C2B08430085A975 /* Bootstrap.swift */, ); path = Bootstrap; sourceTree = ""; @@ -2535,6 +2552,7 @@ 84AA81DA2A4A2973000B658B /* Date.swift */, 84AA81D52A4A28AF000B658B /* WidgetBackground.swift */, 84D2879E2AC756C8007ED283 /* ContentMargin.swift */, + B87539C82C2B06CE0085A975 /* LocalizedString.swift */, ); path = Helpers; sourceTree = ""; @@ -2663,6 +2681,7 @@ 968DCD53F724DE56FFE51920 /* Frameworks */ = { isa = PBXGroup; children = ( + B87D411E2C28A85F00120877 /* ActivityKit.framework */, C159C82E286787EF00A86EC0 /* LoopKit.framework */, C159C8212867859800A86EC0 /* MockKitUI.framework */, C159C8192867857000A86EC0 /* LoopKitUI.framework */, @@ -2760,6 +2779,23 @@ path = LoopCore; sourceTree = ""; }; + B87D41192C28A61900120877 /* Live Activity */ = { + isa = PBXGroup; + children = ( + B87D411C2C28A69600120877 /* LiveActivityConfiguration.swift */, + ); + path = "Live Activity"; + sourceTree = ""; + }; + B8A937C52C29C44600E38645 /* Live Activity */ = { + isa = PBXGroup; + children = ( + B8A937B72C29BA5900E38645 /* GlucoseActivityManager.swift */, + B8A937C02C29C29300E38645 /* GlucoseActivityAttributes.swift */, + ); + path = "Live Activity"; + sourceTree = ""; + }; C13072B82A76AF0A009A7C58 /* live_capture */ = { isa = PBXGroup; children = ( @@ -3623,6 +3659,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + B87539CB2C2B08430085A975 /* Bootstrap.swift in Sources */, 14B1738128AEDC70006CCD7C /* StatusExtensionContext.swift in Sources */, 14B1737628AEDC6C006CCD7C /* HKUnit.swift in Sources */, 14B1737728AEDC6C006CCD7C /* NSBundle.swift in Sources */, @@ -3639,7 +3676,10 @@ 14B1737E28AEDC6C006CCD7C /* FeatureFlags.swift in Sources */, 84AA81E72A4A4DEF000B658B /* PumpView.swift in Sources */, 14B1737F28AEDC6C006CCD7C /* PluginManager.swift in Sources */, + B87D411D2C28A69600120877 /* LiveActivityConfiguration.swift in Sources */, + B87539C92C2B06CE0085A975 /* LocalizedString.swift in Sources */, 14B1738028AEDC6C006CCD7C /* Debug.swift in Sources */, + B8A937C42C29C43B00E38645 /* GlucoseActivityAttributes.swift in Sources */, 84AA81DB2A4A2973000B658B /* Date.swift in Sources */, 14B1737228AEDBF6006CCD7C /* BasalView.swift in Sources */, 14B1737428AEDBF6006CCD7C /* GlucoseView.swift in Sources */, @@ -3818,6 +3858,7 @@ A97F250825E056D500F0EE19 /* OnboardingManager.swift in Sources */, 438D42F91D7C88BC003244B0 /* PredictionInputEffect.swift in Sources */, 892A5D692230C41D008961AB /* RangeReplaceableCollection.swift in Sources */, + B8A937B82C29BA5900E38645 /* GlucoseActivityManager.swift in Sources */, DDC389F62A2B61750066E2E8 /* ApplicationFactorStrategy.swift in Sources */, 4F70C2101DE8FAC5006380B7 /* ExtensionDataManager.swift in Sources */, 43DFB62320D4CAE7008A7BAE /* PumpManager.swift in Sources */, @@ -3835,6 +3876,7 @@ 439706E622D2E84900C81566 /* PredictionSettingTableViewCell.swift in Sources */, 430D85891F44037000AF2D4F /* HUDViewTableViewCell.swift in Sources */, 43A51E211EB6DBDD000736CC /* LoopChartsTableViewController.swift in Sources */, + B8A937C32C29C3B400E38645 /* GlucoseActivityAttributes.swift in Sources */, 8968B1122408B3520074BB48 /* UIFont.swift in Sources */, 1452F4A92A851C9400F8B9E4 /* AddEditFavoriteFoodViewModel.swift in Sources */, 438D42FB1D7D11A4003244B0 /* PredictionInputEffectTableViewCell.swift in Sources */, @@ -4832,6 +4874,7 @@ INFOPLIST_FILE = "Loop Widget Extension/Bootstrap/Info.plist"; INFOPLIST_KEY_CFBundleDisplayName = "Loop Widgets"; INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2022 LoopKit Authors. All rights reserved."; + IPHONEOS_DEPLOYMENT_TARGET = 16.2; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -4880,6 +4923,7 @@ INFOPLIST_FILE = "Loop Widget Extension/Bootstrap/Info.plist"; INFOPLIST_KEY_CFBundleDisplayName = "Loop Widgets"; INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2022 LoopKit Authors. All rights reserved."; + IPHONEOS_DEPLOYMENT_TARGET = 16.2; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -5138,6 +5182,7 @@ DEVELOPMENT_TEAM = "$(LOOP_DEVELOPMENT_TEAM)"; FRAMEWORK_SEARCH_PATHS = "$(inherited)"; INFOPLIST_FILE = Loop/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 16.2; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -5167,6 +5212,7 @@ DEVELOPMENT_TEAM = "$(LOOP_DEVELOPMENT_TEAM)"; FRAMEWORK_SEARCH_PATHS = "$(inherited)"; INFOPLIST_FILE = Loop/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 16.2; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -5423,6 +5469,7 @@ DEVELOPMENT_TEAM = "$(LOOP_DEVELOPMENT_TEAM)"; ENABLE_BITCODE = NO; INFOPLIST_FILE = "Loop Status Extension/Info.plist"; + IPHONEOS_DEPLOYMENT_TARGET = 16.2; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -5449,6 +5496,7 @@ DEVELOPMENT_TEAM = "$(LOOP_DEVELOPMENT_TEAM)"; ENABLE_BITCODE = NO; INFOPLIST_FILE = "Loop Status Extension/Info.plist"; + IPHONEOS_DEPLOYMENT_TARGET = 16.2; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -5516,6 +5564,7 @@ ENABLE_BITCODE = NO; FRAMEWORK_SEARCH_PATHS = "$(inherited)"; INFOPLIST_FILE = "Loop Intent Extension/Info.plist"; + IPHONEOS_DEPLOYMENT_TARGET = 16.2; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -5543,6 +5592,7 @@ ENABLE_BITCODE = NO; FRAMEWORK_SEARCH_PATHS = "$(inherited)"; INFOPLIST_FILE = "Loop Intent Extension/Info.plist"; + IPHONEOS_DEPLOYMENT_TARGET = 16.2; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", diff --git a/Loop/Info.plist b/Loop/Info.plist index ddad5426ac..c8f200700a 100644 --- a/Loop/Info.plist +++ b/Loop/Info.plist @@ -80,6 +80,7 @@ UIBackgroundModes + audio bluetooth-central processing remote-notification @@ -118,5 +119,9 @@ UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight + NSSupportsLiveActivities + + NSSupportsLiveActivitiesFrequentUpdates + diff --git a/Loop/Loop.entitlements b/Loop/Loop.entitlements index 50ba55d9e5..e6a2f9b9f0 100644 --- a/Loop/Loop.entitlements +++ b/Loop/Loop.entitlements @@ -8,6 +8,8 @@ com.apple.developer.healthkit.access + com.apple.developer.healthkit.background-delivery + com.apple.developer.nfc.readersession.formats TAG diff --git a/Loop/Managers/Live Activity/GlucoseActivityAttributes.swift b/Loop/Managers/Live Activity/GlucoseActivityAttributes.swift new file mode 100644 index 0000000000..3ec487dcbb --- /dev/null +++ b/Loop/Managers/Live Activity/GlucoseActivityAttributes.swift @@ -0,0 +1,48 @@ +// +// LiveActivityAttributes.swift +// LoopUI +// +// Created by Bastiaan Verhaar on 23/06/2024. +// Copyright © 2024 LoopKit Authors. All rights reserved. +// + +import ActivityKit +import Foundation +import LoopKit + +public struct GlucoseActivityAttributes: ActivityAttributes { + public struct ContentState: Codable, Hashable { + // Meta data + public let date: Date + + // Glucose view + public let glucose: String + public let delta: String + public let unit: String + public let isCloseLoop: Bool + public let lastCompleted: Date? + + // Pump view + public let pumpHighlight: PumpHighlightAttributes? + public let netBasal: NetBasalAttributes? + public let eventualGlucose: String + + // Graph view + public let predicatedGlucose: [Double] + public let predicatedStartDate: Date? + public let predicatedInterval: TimeInterval? + } +} + +public struct PumpHighlightAttributes: Codable, Hashable { + public let localizedMessage: String + public let imageName: String + public let state: DeviceStatusHighlightState +} + +public struct NetBasalAttributes: Codable, Hashable { + public let rate: Double + public let percentage: Double + public let start: Date + public let end: Date? +} diff --git a/Loop/Managers/Live Activity/GlucoseActivityManager.swift b/Loop/Managers/Live Activity/GlucoseActivityManager.swift new file mode 100644 index 0000000000..a417f56028 --- /dev/null +++ b/Loop/Managers/Live Activity/GlucoseActivityManager.swift @@ -0,0 +1,174 @@ +// +// LiveActivityManaer.swift +// Loop +// +// Created by Bastiaan Verhaar on 24/06/2024. +// Copyright © 2024 LoopKit Authors. All rights reserved. +// + +import LoopKitUI +import LoopKit +import Foundation +import HealthKit +import ActivityKit +import UIKit + +@available(iOS 16.2, *) +class GlucoseActivityManager { + private let activityInfo = ActivityAuthorizationInfo() + private var activity: Activity + private let healthStore = HKHealthStore() + + private var prevGlucoseSample: GlucoseSampleValue? + private var startDate: Date = Date.now + + init?() { + guard self.activityInfo.areActivitiesEnabled else { + print("ERROR: Activities are not enabled... :(") + return nil + } + + do { + let lastCompleted: Date? = nil + let pumpHighlight: PumpHighlightAttributes? = nil + let netBasal: NetBasalAttributes? = nil + + let state = GlucoseActivityAttributes() + let dynamicState = GlucoseActivityAttributes.ContentState( + date: Date.now, + glucose: "--", + delta: "", + unit: "", + isCloseLoop: false, + lastCompleted: lastCompleted, + pumpHighlight: pumpHighlight, + netBasal: netBasal, + eventualGlucose: "", + predicatedGlucose: [], + predicatedStartDate: nil, + predicatedInterval: nil + ) + + self.activity = try Activity.request( + attributes: state, + content: .init(state: dynamicState, staleDate: nil), + pushType: .token + ) + + Task { + await self.endUnknownActivities() + } + } catch { + print("ERROR: \(error.localizedDescription) :(") + return nil + } + } + + public func update(glucose: GlucoseSampleValue?) { + Task { + if self.needsRecreation(), await UIApplication.shared.applicationState == .active { + // activity is no longer visible or old. End it and try to push the update again + await endActivity() + update(glucose: glucose) + return + } + + guard let glucose = glucose, let unit = await healthStore.cachedPreferredUnits(for: .bloodGlucose) else { + return + } + + await self.endUnknownActivities() + + let statusContext = UserDefaults.appGroup?.statusExtensionContext + let glucoseFormatter = NumberFormatter.glucoseFormatter(for: unit) + + let current = glucose.quantity.doubleValue(for: unit) + var delta: String = "+ \(glucoseFormatter.string(from: Double(0)) ?? "")" + if let prevSample = self.prevGlucoseSample { + let deltaValue = current - (prevSample.quantity.doubleValue(for: unit)) + delta = "\(deltaValue < 0 ? "-" : "+") \(glucoseFormatter.string(from: abs(deltaValue)) ?? "")" + } + + var pumpHighlight: PumpHighlightAttributes? = nil + if let pumpStatusHightlight = statusContext?.pumpStatusHighlightContext { + pumpHighlight = PumpHighlightAttributes( + localizedMessage: pumpStatusHightlight.localizedMessage, + imageName: pumpStatusHightlight.imageName, + state: pumpStatusHightlight.state) + } + + var netBasal: NetBasalAttributes? = nil + if let netBasalContext = statusContext?.netBasal { + netBasal = NetBasalAttributes( + rate: netBasalContext.rate, + percentage: netBasalContext.percentage, + start: netBasalContext.start, + end: netBasalContext.end + ) + } + + let state = GlucoseActivityAttributes.ContentState( + date: glucose.startDate, + glucose: glucoseFormatter.string(from: current) ?? "??", + delta: delta, + unit: unit.localizedShortUnitString, + isCloseLoop: statusContext?.isClosedLoop ?? false, + lastCompleted: statusContext?.lastLoopCompleted, + pumpHighlight: pumpHighlight, + netBasal: netBasal, + eventualGlucose: glucoseFormatter.string(from: statusContext?.predictedGlucose?.values.last ?? 0) ?? "??", + predicatedGlucose: statusContext?.predictedGlucose?.values ?? [], + predicatedStartDate: statusContext?.predictedGlucose?.startDate, + predicatedInterval: statusContext?.predictedGlucose?.interval + ) + + await self.activity.update(ActivityContent( + state: state, + staleDate: min(state.date, Date.now).addingTimeInterval(TimeInterval(6 * 60)) + )) + + self.prevGlucoseSample = glucose + } + } + + private func endUnknownActivities() async { + for unknownActivity in Activity.activities + .filter({ self.activity.id != $0.id }) + { + await unknownActivity.end(nil, dismissalPolicy: .immediate) + } + } + + private func endActivity() async { + let dynamicState = self.activity.content.state + + await self.activity.end(nil, dismissalPolicy: .immediate) + for unknownActivity in Activity.activities { + await unknownActivity.end(nil, dismissalPolicy: .immediate) + } + + do { + self.activity = try Activity.request( + attributes: GlucoseActivityAttributes(), + content: .init(state: dynamicState, staleDate: nil), + pushType: .token + ) + self.startDate = Date.now + } catch {} + + } + + private func needsRecreation() -> Bool { + switch activity.activityState { + case .dismissed, + .ended, + .stale: + return true + case .active: + return -startDate.timeIntervalSinceNow > + TimeInterval(60 * 60) + default: + return true + } + } +} diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index 2319f4eceb..112e086207 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -68,6 +68,8 @@ final class LoopDataManager { private var timeBasedDoseApplicationFactor: Double = 1.0 private var insulinOnBoard: InsulinValue? + + private var liveActivityManager: GlucoseActivityManager? deinit { for observer in notificationObservers { @@ -124,6 +126,8 @@ final class LoopDataManager { self.automaticDosingStatus = automaticDosingStatus self.trustedTimeOffset = trustedTimeOffset + + self.liveActivityManager = GlucoseActivityManager() overrideIntentObserver = UserDefaults.appGroup?.observe(\.intentExtensionOverrideToSet, options: [.new], changeHandler: {[weak self] (defaults, change) in guard let name = change.newValue??.lowercased(), let appGroup = UserDefaults.appGroup else { @@ -182,7 +186,8 @@ final class LoopDataManager { ) { (note) in self.dataAccessQueue.async { self.logger.default("Received notification of glucose samples changing") - + + self.liveActivityManager?.update(glucose: self.glucoseStore.latestGlucose) self.glucoseMomentumEffect = nil self.remoteRecommendationNeedsUpdating = true From 74d437f1c5feabfe142278a7d5f34717cb42f698 Mon Sep 17 00:00:00 2001 From: bastiaanv Date: Mon, 1 Jul 2024 22:03:37 +0200 Subject: [PATCH 02/33] style: Rework live activity --- .../Live Activity/BasalViewActivity.swift | 46 +++++ .../Live Activity/ChartValues.swift | 43 ++++ ...lucoseChartLiveActivityConfiguration.swift | 78 ++++++++ .../GlucoseLiveActivityConfiguration.swift | 184 ++++++++++++++++++ .../LiveActivityConfiguration.swift | 122 ------------ Loop Widget Extension/LoopWidgets.swift | 3 +- Loop.xcodeproj/project.pbxproj | 20 +- .../GlucoseActivityAttributes.swift | 19 +- .../GlucoseActivityManager.swift | 144 ++++++++++++-- Loop/Managers/LoopDataManager.swift | 4 +- 10 files changed, 511 insertions(+), 152 deletions(-) create mode 100644 Loop Widget Extension/Live Activity/BasalViewActivity.swift create mode 100644 Loop Widget Extension/Live Activity/ChartValues.swift create mode 100644 Loop Widget Extension/Live Activity/GlucoseChartLiveActivityConfiguration.swift create mode 100644 Loop Widget Extension/Live Activity/GlucoseLiveActivityConfiguration.swift delete mode 100644 Loop Widget Extension/Live Activity/LiveActivityConfiguration.swift diff --git a/Loop Widget Extension/Live Activity/BasalViewActivity.swift b/Loop Widget Extension/Live Activity/BasalViewActivity.swift new file mode 100644 index 0000000000..73607edff8 --- /dev/null +++ b/Loop Widget Extension/Live Activity/BasalViewActivity.swift @@ -0,0 +1,46 @@ +// +// BasalView.swift +// Loop +// +// Created by Noah Brauner on 8/15/22. +// Copyright © 2022 LoopKit Authors. All rights reserved. +// + +import SwiftUI + +struct BasalViewActivity: View { + let percent: Double + let rate: Double + + var body: some View { + VStack(spacing: 1) { + BasalRateView(percent: percent) + .overlay( + BasalRateView(percent: percent) + .stroke(Color("insulin"), lineWidth: 2) + ) + .foregroundColor(Color("insulin").opacity(0.5)) + .frame(width: 44, height: 22) + + if let rateString = decimalFormatter.string(from: NSNumber(value: rate)) { + Text("\(rateString) U") + .font(.subheadline) + } + else { + Text("-U") + .font(.subheadline) + } + } + } + + private let decimalFormatter: NumberFormatter = { + let formatter = NumberFormatter() + formatter.numberStyle = .decimal + formatter.minimumFractionDigits = 1 + formatter.minimumIntegerDigits = 1 + formatter.positiveFormat = "+0.0##" + formatter.negativeFormat = "-0.0##" + + return formatter + }() +} diff --git a/Loop Widget Extension/Live Activity/ChartValues.swift b/Loop Widget Extension/Live Activity/ChartValues.swift new file mode 100644 index 0000000000..8fca70deaf --- /dev/null +++ b/Loop Widget Extension/Live Activity/ChartValues.swift @@ -0,0 +1,43 @@ +// +// ChartValues.swift +// Loop Widget Extension +// +// Created by Bastiaan Verhaar on 25/06/2024. +// Copyright © 2024 LoopKit Authors. All rights reserved. +// + +import Foundation + +struct ChartValues: Identifiable { + public let id: UUID + public let x: Date + public let y: Double + + init(x: Date, y: Double) { + self.id = UUID() + self.x = x + self.y = y + } + + static func convert(data: [Double], startDate: Date, interval: TimeInterval) -> [ChartValues] { + let twoHours = Date.now.addingTimeInterval(.hours(2)) + + return data.enumerated().filter { (index, item) in + return startDate.addingTimeInterval(interval * Double(index)) < twoHours + }.map { (index, item) in + return ChartValues( + x: startDate.addingTimeInterval(interval * Double(index)), + y: item + ) + } + } + + static func convert(data: [GlucoseSampleAttributes]) -> [ChartValues] { + return data.map { item in + return ChartValues( + x: item.x, + y: item.y + ) + } + } +} diff --git a/Loop Widget Extension/Live Activity/GlucoseChartLiveActivityConfiguration.swift b/Loop Widget Extension/Live Activity/GlucoseChartLiveActivityConfiguration.swift new file mode 100644 index 0000000000..16d4f3bc4f --- /dev/null +++ b/Loop Widget Extension/Live Activity/GlucoseChartLiveActivityConfiguration.swift @@ -0,0 +1,78 @@ +// +// ChartView.swift +// Loop Widget Extension +// +// Created by Bastiaan Verhaar on 27/06/2024. +// Copyright © 2024 LoopKit Authors. All rights reserved. +// + +import SwiftUI +import Charts +import WidgetKit + +struct GlucoseChartLiveActivityConfiguration: Widget { + var body: some WidgetConfiguration { + ActivityConfiguration(for: GlucoseChartActivityAttributes.self) { context in + // Create the presentation that appears on the Lock Screen and as a + // banner on the Home Screen of devices that don't support the Dynamic Island. + HStack { + chartView(context) + } + .privacySensitive() + .padding(.all, 15) + .background(BackgroundStyle.background.opacity(0.4)) + .activityBackgroundTint(Color.clear) + } dynamicIsland: { _ in + DynamicIsland { + DynamicIslandExpandedRegion(.leading) { + HStack{} + } + DynamicIslandExpandedRegion(.trailing) { + HStack{} + } + DynamicIslandExpandedRegion(.bottom) { + HStack{} + } + } compactLeading: { + // Create the compact leading presentation. + HStack{} + } compactTrailing: { + // Create the compact trailing presentation. + HStack{} + } minimal: { + // Create the minimal presentation. + HStack{} + } + } + } + + @ViewBuilder + private func chartView(_ context: ActivityViewContext) -> some View { + let glucoseSampleData = ChartValues.convert(data: context.state.glucoseSamples) + let predicatedData = ChartValues.convert( + data: context.state.predicatedGlucose, + startDate: context.state.predicatedStartDate ?? Date.now, + interval: context.state.predicatedInterval ?? .minutes(5) + ) + + let lowerBound = min(4, glucoseSampleData.min { $0.y < $1.y }?.y ?? 0, predicatedData.min { $0.y < $1.y }?.y ?? 0) + let upperBound = max(10, glucoseSampleData.max { $0.y < $1.y }?.y ?? 0, predicatedData.max { $0.y < $1.y }?.y ?? 0) + + Chart { + ForEach(glucoseSampleData) { item in + PointMark (x: .value("Date", item.x), + y: .value("Glucose level", item.y) + ) + .symbolSize(20) + } + + ForEach(predicatedData) { item in + LineMark (x: .value("Date", item.x), + y: .value("Glucose level", item.y) + ) + .lineStyle(StrokeStyle(lineWidth: 3, dash: [2, 3])) + } + } + .chartYScale(domain: [lowerBound, upperBound]) + } +} diff --git a/Loop Widget Extension/Live Activity/GlucoseLiveActivityConfiguration.swift b/Loop Widget Extension/Live Activity/GlucoseLiveActivityConfiguration.swift new file mode 100644 index 0000000000..bfe8a2abaf --- /dev/null +++ b/Loop Widget Extension/Live Activity/GlucoseLiveActivityConfiguration.swift @@ -0,0 +1,184 @@ +// +// LiveActivityConfiguration.swift +// Loop Widget Extension +// +// Created by Bastiaan Verhaar on 23/06/2024. +// Copyright © 2024 LoopKit Authors. All rights reserved. +// + +import ActivityKit +import LoopKit +import SwiftUI +import LoopCore +import WidgetKit +import Charts + +@available(iOS 16.2, *) +struct GlucoseLiveActivityConfiguration: Widget { + private let timeFormatter: DateFormatter = { + let dateFormatter = DateFormatter() + dateFormatter.dateStyle = .none + dateFormatter.timeStyle = .short + + return dateFormatter + }() + + var body: some WidgetConfiguration { + ActivityConfiguration(for: GlucoseActivityAttributes.self) { context in + // Create the presentation that appears on the Lock Screen and as a + // banner on the Home Screen of devices that don't support the Dynamic Island. + VStack { + HStack { + glucoseView(context) + Spacer() + metaView(context) + } + + Spacer() + + HStack { + bottomSpacer(border: false) + bottomItem( + value: context.state.iob, + unit: LocalizedString("U", comment: "No comment"), + title: LocalizedString("IOB", comment: "No comment") + ) + bottomSpacer(border: true) + bottomItem( + value: context.state.cob, + unit: LocalizedString("g", comment: "No comment"), + title: LocalizedString("COB", comment: "No comment") + ) + bottomSpacer(border: true) + basalView(context) + bottomSpacer(border: false) + } + } + .privacySensitive() + .padding(.all, 15) + .background(BackgroundStyle.background.opacity(0.4)) + .activityBackgroundTint(Color.clear) + } dynamicIsland: { _ in + DynamicIsland { + DynamicIslandExpandedRegion(.leading) { + HStack{} + } + DynamicIslandExpandedRegion(.trailing) { + HStack{} + } + DynamicIslandExpandedRegion(.bottom) { + HStack{} + } + } compactLeading: { + // Create the compact leading presentation. + HStack{} + } compactTrailing: { + // Create the compact trailing presentation. + HStack{} + } minimal: { + // Create the minimal presentation. + HStack{} + } + } + } + + @ViewBuilder + private func glucoseView(_ context: ActivityViewContext) -> some View { + HStack { + Circle() + .trim(from: context.state.isCloseLoop ? 0 : 0.2, to: 1) + .stroke(getLoopColor(context.state.lastCompleted), lineWidth: 8) + .rotationEffect(Angle(degrees: -126)) + .frame(width: 36, height: 36) + + Text("\(context.state.glucose)") + .font(.title) + .fontWeight(.heavy) + .padding(.leading, 16) + + if let trendImageName = getArrowImage(context.state.trendType) { + Image(systemName: trendImageName) + .font(.system(size: 24)) + } + } + } + + private func metaView(_ context: ActivityViewContext) -> some View { + VStack(alignment: .trailing) { + Text("\(timeFormatter.string(from: context.state.date))") + .font(.subheadline) + + Text("\(context.state.delta)") + .font(.subheadline) + } + } + + @ViewBuilder + private func bottomItem(value: String, unit: String, title: String) -> some View { + VStack(alignment: .center) { + Text("\(value)\(unit)") + .font(.headline) + .fontWeight(.heavy) + Text(title) + .font(.subheadline) + } + } + + @ViewBuilder + private func basalView(_ context: ActivityViewContext) -> some View { + let netBasal = context.state.netBasal + + BasalViewActivity(percent: netBasal?.percentage ?? 0, rate: netBasal?.rate ?? 0) + } + + @ViewBuilder + private func bottomSpacer(border: Bool) -> some View { + Spacer() + if (border) { + Divider() + .background(.secondary) + .padding(.vertical, 10) + Spacer() + } + + } + + private func getLoopColor(_ age: Date?) -> Color { + var freshness: LoopCompletionFreshness = .stale + if let age = age { + freshness = LoopCompletionFreshness(age: abs(min(0, age.timeIntervalSinceNow))) + } + + switch freshness { + case .fresh: + return Color("fresh") + case .aging: + return Color("warning") + case .stale: + return Color.red + } + } + + private func getArrowImage(_ trendType: GlucoseTrend?) -> String? { + switch trendType { + case .upUpUp: +// return "arrow.double.up" -> This one isn't available anymore + return "arrow.up" + case .upUp: + return "arrow.up" + case .up: + return "arrow.up.right" + case .flat: + return "arrow.right" + case .down: + return "arrow.down.right" + case .downDown: + return "arrow.down" + case .downDownDown: +// return "arrow.double.down.circle" -> This one isn't available anymore + return "arrow.down" + case .none: + return nil + } + } +} diff --git a/Loop Widget Extension/Live Activity/LiveActivityConfiguration.swift b/Loop Widget Extension/Live Activity/LiveActivityConfiguration.swift deleted file mode 100644 index 56a70b9df2..0000000000 --- a/Loop Widget Extension/Live Activity/LiveActivityConfiguration.swift +++ /dev/null @@ -1,122 +0,0 @@ -// -// LiveActivityConfiguration.swift -// Loop Widget Extension -// -// Created by Bastiaan Verhaar on 23/06/2024. -// Copyright © 2024 LoopKit Authors. All rights reserved. -// - -import ActivityKit -import LoopKit -import SwiftUI -import LoopCore -import WidgetKit - -@available(iOS 16.2, *) -struct LiveActivityConfiguration: Widget { - var body: some WidgetConfiguration { - ActivityConfiguration(for: GlucoseActivityAttributes.self) { context in - // Create the presentation that appears on the Lock Screen and as a - // banner on the Home Screen of devices that don't support the Dynamic Island. - HStack { - glucoseView(context) - Spacer() - pumpView(context) - } - .privacySensitive() - .padding(.all, 15) - .background(BackgroundStyle.background.opacity(0.4)) - .activityBackgroundTint(Color.clear) - } dynamicIsland: { _ in - DynamicIsland { - DynamicIslandExpandedRegion(.leading) { - HStack{} - } - DynamicIslandExpandedRegion(.trailing) { - HStack{} - } - DynamicIslandExpandedRegion(.bottom) { - HStack{} - } - } compactLeading: { - // Create the compact leading presentation. - HStack{} - } compactTrailing: { - // Create the compact trailing presentation. - HStack{} - } minimal: { - // Create the minimal presentation. - HStack{} - } - } - } - - @ViewBuilder - private func glucoseView(_ context: ActivityViewContext) -> some View { - HStack { - Circle() - .trim(from: context.state.isCloseLoop ? 0 : 0.2, to: 1) - .stroke(getLoopColor(context.state.lastCompleted), lineWidth: 4) - .rotationEffect(Angle(degrees: -126)) - .frame(width: 18, height: 18) - - Text("\(context.state.glucose) \(context.state.unit)") - .font(.body) - Text(context.state.delta) - .font(.body) - .foregroundStyle(Color(UIColor.secondaryLabel)) - } - } - - @ViewBuilder - private func pumpView(_ context: ActivityViewContext) -> some View { - HStack(spacing: 10) { - if let pumpHighlight = context.state.pumpHighlight { - HStack { - Image(systemName: pumpHighlight.imageName) - .foregroundColor(pumpHighlight.state == .critical ? .critical : .warning) - Text(pumpHighlight.localizedMessage) - .fontWeight(.heavy) - } - } - else if let netBasal = context.state.netBasal { - BasalView(netBasal: - NetBasalContext( - rate: netBasal.rate, - percentage: netBasal.percentage, - start: netBasal.start, - end: netBasal.end - ), - isOld: false - ) - - VStack { - Text(LocalizedString("Eventual", comment: "No comment")) - .font(.footnote) - .foregroundColor(Color(UIColor.secondaryLabel)) - - Text("\(context.state.eventualGlucose) \(context.state.unit)") - .font(.subheadline) - .fontWeight(.heavy) - } - } - - } - } - - func getLoopColor(_ age: Date?) -> Color { - var freshness: LoopCompletionFreshness = .stale - if let age = age { - freshness = LoopCompletionFreshness(age: abs(min(0, age.timeIntervalSinceNow))) - } - - switch freshness { - case .fresh: - return Color("fresh") - case .aging: - return Color("warning") - case .stale: - return Color.red - } - } -} diff --git a/Loop Widget Extension/LoopWidgets.swift b/Loop Widget Extension/LoopWidgets.swift index ee3d811eed..0b00c85237 100644 --- a/Loop Widget Extension/LoopWidgets.swift +++ b/Loop Widget Extension/LoopWidgets.swift @@ -14,6 +14,7 @@ struct LoopWidgets: WidgetBundle { @WidgetBundleBuilder var body: some Widget { SystemStatusWidget() - LiveActivityConfiguration() + GlucoseLiveActivityConfiguration() + GlucoseChartLiveActivityConfiguration() } } diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index 07388b8d8f..20f7c5ad7d 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -403,7 +403,10 @@ B4FEEF7D24B8A71F00A8DF9B /* DeviceDataManager+DeviceStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4FEEF7C24B8A71F00A8DF9B /* DeviceDataManager+DeviceStatus.swift */; }; B87539C92C2B06CE0085A975 /* LocalizedString.swift in Sources */ = {isa = PBXBuildFile; fileRef = B87539C82C2B06CE0085A975 /* LocalizedString.swift */; }; B87539CB2C2B08430085A975 /* Bootstrap.swift in Sources */ = {isa = PBXBuildFile; fileRef = B87539CA2C2B08430085A975 /* Bootstrap.swift */; }; - B87D411D2C28A69600120877 /* LiveActivityConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = B87D411C2C28A69600120877 /* LiveActivityConfiguration.swift */; }; + B87539CD2C2B46950085A975 /* ChartValues.swift in Sources */ = {isa = PBXBuildFile; fileRef = B87539CC2C2B46950085A975 /* ChartValues.swift */; }; + B87539CF2C2DCB770085A975 /* BasalViewActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = B87539CE2C2DCB770085A975 /* BasalViewActivity.swift */; }; + B87539D12C2DDE760085A975 /* GlucoseChartLiveActivityConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = B87539D02C2DDE760085A975 /* GlucoseChartLiveActivityConfiguration.swift */; }; + B87D411D2C28A69600120877 /* GlucoseLiveActivityConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = B87D411C2C28A69600120877 /* GlucoseLiveActivityConfiguration.swift */; }; B87D411F2C28A85F00120877 /* ActivityKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = B87D411E2C28A85F00120877 /* ActivityKit.framework */; }; B8A937B82C29BA5900E38645 /* GlucoseActivityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8A937B72C29BA5900E38645 /* GlucoseActivityManager.swift */; }; B8A937C32C29C3B400E38645 /* GlucoseActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8A937C02C29C29300E38645 /* GlucoseActivityAttributes.swift */; }; @@ -1334,7 +1337,10 @@ B4FEEF7C24B8A71F00A8DF9B /* DeviceDataManager+DeviceStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DeviceDataManager+DeviceStatus.swift"; sourceTree = ""; }; B87539C82C2B06CE0085A975 /* LocalizedString.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalizedString.swift; sourceTree = ""; }; B87539CA2C2B08430085A975 /* Bootstrap.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Bootstrap.swift; sourceTree = ""; }; - B87D411C2C28A69600120877 /* LiveActivityConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActivityConfiguration.swift; sourceTree = ""; }; + B87539CC2C2B46950085A975 /* ChartValues.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChartValues.swift; sourceTree = ""; }; + B87539CE2C2DCB770085A975 /* BasalViewActivity.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BasalViewActivity.swift; sourceTree = ""; }; + B87539D02C2DDE760085A975 /* GlucoseChartLiveActivityConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseChartLiveActivityConfiguration.swift; sourceTree = ""; }; + B87D411C2C28A69600120877 /* GlucoseLiveActivityConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseLiveActivityConfiguration.swift; sourceTree = ""; }; B87D411E2C28A85F00120877 /* ActivityKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = ActivityKit.framework; path = System/Library/Frameworks/ActivityKit.framework; sourceTree = SDKROOT; }; B8A937B72C29BA5900E38645 /* GlucoseActivityManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseActivityManager.swift; sourceTree = ""; }; B8A937C02C29C29300E38645 /* GlucoseActivityAttributes.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GlucoseActivityAttributes.swift; sourceTree = ""; }; @@ -2782,7 +2788,10 @@ B87D41192C28A61900120877 /* Live Activity */ = { isa = PBXGroup; children = ( - B87D411C2C28A69600120877 /* LiveActivityConfiguration.swift */, + B87D411C2C28A69600120877 /* GlucoseLiveActivityConfiguration.swift */, + B87539D02C2DDE760085A975 /* GlucoseChartLiveActivityConfiguration.swift */, + B87539CC2C2B46950085A975 /* ChartValues.swift */, + B87539CE2C2DCB770085A975 /* BasalViewActivity.swift */, ); path = "Live Activity"; sourceTree = ""; @@ -3666,6 +3675,9 @@ 84AA81D62A4A28AF000B658B /* WidgetBackground.swift in Sources */, 84AA81D82A4A2910000B658B /* StatusWidgetTimelimeEntry.swift in Sources */, 84AA81D32A4A27A3000B658B /* LoopWidgets.swift in Sources */, + B87539CF2C2DCB770085A975 /* BasalViewActivity.swift in Sources */, + B87539CD2C2B46950085A975 /* ChartValues.swift in Sources */, + B87539D12C2DDE760085A975 /* GlucoseChartLiveActivityConfiguration.swift in Sources */, 84AA81E32A4A36FB000B658B /* SystemActionLink.swift in Sources */, 14B1737828AEDC6C006CCD7C /* NSTimeInterval.swift in Sources */, 14B1737928AEDC6C006CCD7C /* NSUserDefaults+StatusExtension.swift in Sources */, @@ -3676,7 +3688,7 @@ 14B1737E28AEDC6C006CCD7C /* FeatureFlags.swift in Sources */, 84AA81E72A4A4DEF000B658B /* PumpView.swift in Sources */, 14B1737F28AEDC6C006CCD7C /* PluginManager.swift in Sources */, - B87D411D2C28A69600120877 /* LiveActivityConfiguration.swift in Sources */, + B87D411D2C28A69600120877 /* GlucoseLiveActivityConfiguration.swift in Sources */, B87539C92C2B06CE0085A975 /* LocalizedString.swift in Sources */, 14B1738028AEDC6C006CCD7C /* Debug.swift in Sources */, B8A937C42C29C43B00E38645 /* GlucoseActivityAttributes.swift in Sources */, diff --git a/Loop/Managers/Live Activity/GlucoseActivityAttributes.swift b/Loop/Managers/Live Activity/GlucoseActivityAttributes.swift index 3ec487dcbb..f0d9cc3a11 100644 --- a/Loop/Managers/Live Activity/GlucoseActivityAttributes.swift +++ b/Loop/Managers/Live Activity/GlucoseActivityAttributes.swift @@ -17,8 +17,10 @@ public struct GlucoseActivityAttributes: ActivityAttributes { // Glucose view public let glucose: String + public let trendType: GlucoseTrend? public let delta: String - public let unit: String + public let cob: String + public let iob: String public let isCloseLoop: Bool public let lastCompleted: Date? @@ -26,11 +28,15 @@ public struct GlucoseActivityAttributes: ActivityAttributes { public let pumpHighlight: PumpHighlightAttributes? public let netBasal: NetBasalAttributes? public let eventualGlucose: String - - // Graph view + } +} + +public struct GlucoseChartActivityAttributes: ActivityAttributes { + public struct ContentState: Codable, Hashable { public let predicatedGlucose: [Double] public let predicatedStartDate: Date? public let predicatedInterval: TimeInterval? + public let glucoseSamples: [GlucoseSampleAttributes] } } @@ -43,6 +49,9 @@ public struct PumpHighlightAttributes: Codable, Hashable { public struct NetBasalAttributes: Codable, Hashable { public let rate: Double public let percentage: Double - public let start: Date - public let end: Date? +} + +public struct GlucoseSampleAttributes: Codable, Hashable { + public let x: Date + public let y: Double } diff --git a/Loop/Managers/Live Activity/GlucoseActivityManager.swift b/Loop/Managers/Live Activity/GlucoseActivityManager.swift index a417f56028..3e6682ccfd 100644 --- a/Loop/Managers/Live Activity/GlucoseActivityManager.swift +++ b/Loop/Managers/Live Activity/GlucoseActivityManager.swift @@ -17,44 +17,77 @@ import UIKit class GlucoseActivityManager { private let activityInfo = ActivityAuthorizationInfo() private var activity: Activity + private var activityChart: Activity? private let healthStore = HKHealthStore() + private let glucoseStore: GlucoseStoreProtocol + private let doseStore: DoseStoreProtocol + + private var lastGlucoseSample: GlucoseSampleValue? private var prevGlucoseSample: GlucoseSampleValue? + private var startDate: Date = Date.now - init?() { + private let cobFormatter: NumberFormatter = { + let numberFormatter = NumberFormatter() + numberFormatter.numberStyle = .none + return numberFormatter + }() + private let iobFormatter: NumberFormatter = { + let numberFormatter = NumberFormatter() + numberFormatter.numberStyle = .none + numberFormatter.maximumFractionDigits = 1 + numberFormatter.minimumFractionDigits = 1 + return numberFormatter + }() + + init?(glucoseStore: GlucoseStoreProtocol, doseStore: DoseStoreProtocol) { guard self.activityInfo.areActivitiesEnabled else { print("ERROR: Activities are not enabled... :(") return nil } + self.glucoseStore = glucoseStore + self.doseStore = doseStore + do { let lastCompleted: Date? = nil let pumpHighlight: PumpHighlightAttributes? = nil let netBasal: NetBasalAttributes? = nil - let state = GlucoseActivityAttributes() let dynamicState = GlucoseActivityAttributes.ContentState( date: Date.now, glucose: "--", + trendType: nil, delta: "", - unit: "", + cob: "0", + iob: "0", isCloseLoop: false, lastCompleted: lastCompleted, pumpHighlight: pumpHighlight, netBasal: netBasal, - eventualGlucose: "", - predicatedGlucose: [], - predicatedStartDate: nil, - predicatedInterval: nil + eventualGlucose: "" ) self.activity = try Activity.request( - attributes: state, + attributes: GlucoseActivityAttributes(), content: .init(state: dynamicState, staleDate: nil), pushType: .token ) + let dynamicChartState = GlucoseChartActivityAttributes.ContentState( + predicatedGlucose: [], + predicatedStartDate: nil, + predicatedInterval: nil, + glucoseSamples: [] + ) + + self.activityChart = try Activity.request( + attributes: GlucoseChartActivityAttributes(), + content: .init(state: dynamicChartState, staleDate: nil), + pushType: .token + ) + Task { await self.endUnknownActivities() } @@ -64,6 +97,10 @@ class GlucoseActivityManager { } } + public func update() { + self.update(glucose: self.lastGlucoseSample) + } + public func update(glucose: GlucoseSampleValue?) { Task { if self.needsRecreation(), await UIApplication.shared.applicationState == .active { @@ -83,10 +120,12 @@ class GlucoseActivityManager { let glucoseFormatter = NumberFormatter.glucoseFormatter(for: unit) let current = glucose.quantity.doubleValue(for: unit) - var delta: String = "+ \(glucoseFormatter.string(from: Double(0)) ?? "")" + self.lastGlucoseSample = glucose + + var delta: String = "+\(glucoseFormatter.string(from: Double(0)) ?? "")" if let prevSample = self.prevGlucoseSample { let deltaValue = current - (prevSample.quantity.doubleValue(for: unit)) - delta = "\(deltaValue < 0 ? "-" : "+") \(glucoseFormatter.string(from: abs(deltaValue)) ?? "")" + delta = "\(deltaValue < 0 ? "-" : "+")\(glucoseFormatter.string(from: abs(deltaValue)) ?? "??")" } var pumpHighlight: PumpHighlightAttributes? = nil @@ -101,25 +140,35 @@ class GlucoseActivityManager { if let netBasalContext = statusContext?.netBasal { netBasal = NetBasalAttributes( rate: netBasalContext.rate, - percentage: netBasalContext.percentage, - start: netBasalContext.start, - end: netBasalContext.end + percentage: netBasalContext.percentage ) } + var predicatedGlucose: [Double] = [] + if let samples = statusContext?.predictedGlucose?.values { + predicatedGlucose = samples + } + + var cob: String = "0" + if let cobValue = statusContext?.carbsOnBoard { + cob = self.cobFormatter.string(from: cobValue) ?? "??" + } + + let glucoseSamples = await self.getGlucoseSample(unit: unit) + let iob = await self.getInsulinOnBoard() + let state = GlucoseActivityAttributes.ContentState( date: glucose.startDate, glucose: glucoseFormatter.string(from: current) ?? "??", + trendType: statusContext?.glucoseDisplay?.trendType, delta: delta, - unit: unit.localizedShortUnitString, + cob: cob, + iob: iob, isCloseLoop: statusContext?.isClosedLoop ?? false, lastCompleted: statusContext?.lastLoopCompleted, pumpHighlight: pumpHighlight, netBasal: netBasal, - eventualGlucose: glucoseFormatter.string(from: statusContext?.predictedGlucose?.values.last ?? 0) ?? "??", - predicatedGlucose: statusContext?.predictedGlucose?.values ?? [], - predicatedStartDate: statusContext?.predictedGlucose?.startDate, - predicatedInterval: statusContext?.predictedGlucose?.interval + eventualGlucose: glucoseFormatter.string(from: statusContext?.predictedGlucose?.values.last ?? 0) ?? "??" ) await self.activity.update(ActivityContent( @@ -127,13 +176,27 @@ class GlucoseActivityManager { staleDate: min(state.date, Date.now).addingTimeInterval(TimeInterval(6 * 60)) )) + if let activityChart = self.activityChart { + let stateChart = GlucoseChartActivityAttributes.ContentState( + predicatedGlucose: predicatedGlucose, + predicatedStartDate: statusContext?.predictedGlucose?.startDate, + predicatedInterval: statusContext?.predictedGlucose?.interval, + glucoseSamples: glucoseSamples + ) + + await activityChart.update(ActivityContent( + state: stateChart, + staleDate: min(state.date, Date.now).addingTimeInterval(TimeInterval(6 * 60)) + )) + } + self.prevGlucoseSample = glucose } } private func endUnknownActivities() async { for unknownActivity in Activity.activities - .filter({ self.activity.id != $0.id }) + .filter({ self.activity.id != $0.id && self.activityChart?.id != $0.id }) { await unknownActivity.end(nil, dismissalPolicy: .immediate) } @@ -141,8 +204,10 @@ class GlucoseActivityManager { private func endActivity() async { let dynamicState = self.activity.content.state + let dynamicChartState = self.activityChart?.content.state await self.activity.end(nil, dismissalPolicy: .immediate) + await self.activityChart?.end(nil, dismissalPolicy: .immediate) for unknownActivity in Activity.activities { await unknownActivity.end(nil, dismissalPolicy: .immediate) } @@ -153,6 +218,15 @@ class GlucoseActivityManager { content: .init(state: dynamicState, staleDate: nil), pushType: .token ) + + + if let dynamicChartState = dynamicChartState { + self.activityChart = try Activity.request( + attributes: GlucoseChartActivityAttributes(), + content: .init(state: dynamicChartState, staleDate: nil), + pushType: .token + ) + } self.startDate = Date.now } catch {} @@ -171,4 +245,36 @@ class GlucoseActivityManager { return true } } + + private func getInsulinOnBoard() async -> String { + return await withCheckedContinuation { continuation in + self.doseStore.insulinOnBoard(at: Date.now) { result in + switch (result) { + case .failure: + continuation.resume(returning: "??") + break + case .success(let iob): + continuation.resume(returning: self.iobFormatter.string(from: iob.value) ?? "??") + break + } + } + } + } + + private func getGlucoseSample(unit: HKUnit) async -> [GlucoseSampleAttributes] { + return await withCheckedContinuation { continuation in + self.glucoseStore.getGlucoseSamples(start: Date.now.addingTimeInterval(.hours(-1)), end: Date.now) { result in + switch (result) { + case .failure: + continuation.resume(returning: []) + break + case .success(let data): + continuation.resume(returning: data.map { item in + return GlucoseSampleAttributes(x: item.startDate, y: item.quantity.doubleValue(for: unit)) + }) + return + } + } + } + } } diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index 112e086207..7e3e682df2 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -127,7 +127,7 @@ final class LoopDataManager { self.trustedTimeOffset = trustedTimeOffset - self.liveActivityManager = GlucoseActivityManager() + self.liveActivityManager = GlucoseActivityManager(glucoseStore: self.glucoseStore, doseStore: self.doseStore) overrideIntentObserver = UserDefaults.appGroup?.observe(\.intentExtensionOverrideToSet, options: [.new], changeHandler: {[weak self] (defaults, change) in guard let name = change.newValue??.lowercased(), let appGroup = UserDefaults.appGroup else { @@ -171,6 +171,7 @@ final class LoopDataManager { ) { (note) -> Void in self.dataAccessQueue.async { self.logger.default("Received notification of carb entries changing") + self.liveActivityManager?.update() self.carbEffect = nil self.carbsOnBoard = nil @@ -201,6 +202,7 @@ final class LoopDataManager { ) { (note) in self.dataAccessQueue.async { self.logger.default("Received notification of dosing changing") + self.liveActivityManager?.update() self.clearCachedInsulinEffects() self.remoteRecommendationNeedsUpdating = true From 8a8cdd8dc6fc2527638578fb38f3896954217eb7 Mon Sep 17 00:00:00 2001 From: bastiaanv Date: Thu, 4 Jul 2024 22:26:34 +0200 Subject: [PATCH 03/33] wip: Wip live activity --- .../Live Activity/BasalViewActivity.swift | 2 +- .../Live Activity/ChartValues.swift | 43 --- .../Live Activity/ChartView.swift | 122 +++++++ ...lucoseChartLiveActivityConfiguration.swift | 37 +- .../GlucoseLiveActivityConfiguration.swift | 155 +++++---- Loop.xcodeproj/project.pbxproj | 20 +- .../GlucoseActivityAttributes.swift | 66 +++- .../GlucoseActivityManager.swift | 322 +++++++++++------- Loop/Views/AlertManagementView.swift | 5 + Loop/Views/LiveActivityManagementView.swift | 48 +++ LoopCore/LiveActivitySettings.swift | 55 +++ LoopCore/NSUserDefaults.swift | 24 ++ 12 files changed, 631 insertions(+), 268 deletions(-) delete mode 100644 Loop Widget Extension/Live Activity/ChartValues.swift create mode 100644 Loop Widget Extension/Live Activity/ChartView.swift create mode 100644 Loop/Views/LiveActivityManagementView.swift create mode 100644 LoopCore/LiveActivitySettings.swift diff --git a/Loop Widget Extension/Live Activity/BasalViewActivity.swift b/Loop Widget Extension/Live Activity/BasalViewActivity.swift index 73607edff8..915335c5fb 100644 --- a/Loop Widget Extension/Live Activity/BasalViewActivity.swift +++ b/Loop Widget Extension/Live Activity/BasalViewActivity.swift @@ -23,7 +23,7 @@ struct BasalViewActivity: View { .frame(width: 44, height: 22) if let rateString = decimalFormatter.string(from: NSNumber(value: rate)) { - Text("\(rateString) U") + Text("\(rateString)U") .font(.subheadline) } else { diff --git a/Loop Widget Extension/Live Activity/ChartValues.swift b/Loop Widget Extension/Live Activity/ChartValues.swift deleted file mode 100644 index 8fca70deaf..0000000000 --- a/Loop Widget Extension/Live Activity/ChartValues.swift +++ /dev/null @@ -1,43 +0,0 @@ -// -// ChartValues.swift -// Loop Widget Extension -// -// Created by Bastiaan Verhaar on 25/06/2024. -// Copyright © 2024 LoopKit Authors. All rights reserved. -// - -import Foundation - -struct ChartValues: Identifiable { - public let id: UUID - public let x: Date - public let y: Double - - init(x: Date, y: Double) { - self.id = UUID() - self.x = x - self.y = y - } - - static func convert(data: [Double], startDate: Date, interval: TimeInterval) -> [ChartValues] { - let twoHours = Date.now.addingTimeInterval(.hours(2)) - - return data.enumerated().filter { (index, item) in - return startDate.addingTimeInterval(interval * Double(index)) < twoHours - }.map { (index, item) in - return ChartValues( - x: startDate.addingTimeInterval(interval * Double(index)), - y: item - ) - } - } - - static func convert(data: [GlucoseSampleAttributes]) -> [ChartValues] { - return data.map { item in - return ChartValues( - x: item.x, - y: item.y - ) - } - } -} diff --git a/Loop Widget Extension/Live Activity/ChartView.swift b/Loop Widget Extension/Live Activity/ChartView.swift new file mode 100644 index 0000000000..281b0beea7 --- /dev/null +++ b/Loop Widget Extension/Live Activity/ChartView.swift @@ -0,0 +1,122 @@ +// +// ChartValues.swift +// Loop Widget Extension +// +// Created by Bastiaan Verhaar on 25/06/2024. +// Copyright © 2024 LoopKit Authors. All rights reserved. +// + +import Foundation +import SwiftUI +import Charts + +struct ChartView: View { + private let glucoseSampleData: [ChartValues] + private let predicatedData: [ChartValues] + private let lowerBoundY: Double + private let upperBoundY: Double + + private let high = Double(10) + private let low = Double(4) + + init(glucoseSamples: [GlucoseSampleAttributes], predicatedGlucose: [Double], predicatedStartDate: Date?, predicatedInterval: TimeInterval?) { + self.glucoseSampleData = ChartValues.convert(data: glucoseSamples, lowerLimit: low, upperLimit: high) + self.predicatedData = ChartValues.convert( + data: predicatedGlucose, + startDate: predicatedStartDate ?? Date.now, + interval: predicatedInterval ?? .minutes(5), + lowerLimit: low, + upperLimit: high + ) + + self.lowerBoundY = min(4, glucoseSampleData.min { $0.y < $1.y }?.y ?? 0, predicatedData.min { $0.y < $1.y }?.y ?? 0) + self.upperBoundY = max(10, glucoseSampleData.max { $0.y < $1.y }?.y ?? 0, predicatedData.max { $0.y < $1.y }?.y ?? 0) + } + + init(glucoseSamples: [GlucoseSampleAttributes]) { + self.glucoseSampleData = ChartValues.convert(data: glucoseSamples, lowerLimit: low, upperLimit: high) + self.predicatedData = [] + + self.lowerBoundY = min(4, glucoseSampleData.min { $0.y < $1.y }?.y ?? 0) + self.upperBoundY = max(10, glucoseSampleData.max { $0.y < $1.y }?.y ?? 0) + } + + var body: some View { + Chart { + ForEach(glucoseSampleData) { item in + PointMark (x: .value("Date", item.x), + y: .value("Glucose level", item.y) + ) + .symbolSize(20) + .foregroundStyle(by: .value("Color", item.color)) + } + + ForEach(predicatedData) { item in + LineMark (x: .value("Date", item.x), + y: .value("Glucose level", item.y) + ) + .lineStyle(StrokeStyle(lineWidth: 3, dash: [2, 3])) + } + } + .chartYScale(domain: [lowerBoundY, upperBoundY]) + .chartForegroundStyleScale([ + "Good": .green, + "High": .orange, + "Low": .red + ]) + .chartLegend(.hidden) + .chartYAxis { + AxisMarks(position: .trailing) { _ in + AxisValueLabel().foregroundStyle(Color.primary) + AxisGridLine(stroke: .init(lineWidth: 0.1, dash: [2, 3])) + .foregroundStyle(Color.primary) + } + } + .chartXAxis { + AxisMarks(position: .automatic) { _ in + AxisValueLabel(format: .dateTime.hour(.defaultDigits(amPM: .narrow)), anchor: .top) + .foregroundStyle(Color.primary) + AxisGridLine(stroke: .init(lineWidth: 0.1, dash: [2, 3])) + .foregroundStyle(Color.primary) + } + } + } +} + +struct ChartValues: Identifiable { + public let id: UUID + public let x: Date + public let y: Double + public let color: String + + init(x: Date, y: Double, color: String) { + self.id = UUID() + self.x = x + self.y = y + self.color = color + } + + static func convert(data: [Double], startDate: Date, interval: TimeInterval, lowerLimit: Double, upperLimit: Double) -> [ChartValues] { + let twoHours = Date.now.addingTimeInterval(.hours(2)) + + return data.enumerated().filter { (index, item) in + return startDate.addingTimeInterval(interval * Double(index)) < twoHours + }.map { (index, item) in + return ChartValues( + x: startDate.addingTimeInterval(interval * Double(index)), + y: item, + color: item < lowerLimit ? "Low" : item > upperLimit ? "High" : "Good" + ) + } + } + + static func convert(data: [GlucoseSampleAttributes], lowerLimit: Double, upperLimit: Double) -> [ChartValues] { + return data.map { item in + return ChartValues( + x: item.x, + y: item.y, + color: item.y < lowerLimit ? "Low" : item.y > upperLimit ? "High" : "Good" + ) + } + } +} diff --git a/Loop Widget Extension/Live Activity/GlucoseChartLiveActivityConfiguration.swift b/Loop Widget Extension/Live Activity/GlucoseChartLiveActivityConfiguration.swift index 16d4f3bc4f..2f5342e153 100644 --- a/Loop Widget Extension/Live Activity/GlucoseChartLiveActivityConfiguration.swift +++ b/Loop Widget Extension/Live Activity/GlucoseChartLiveActivityConfiguration.swift @@ -16,7 +16,12 @@ struct GlucoseChartLiveActivityConfiguration: Widget { // Create the presentation that appears on the Lock Screen and as a // banner on the Home Screen of devices that don't support the Dynamic Island. HStack { - chartView(context) + ChartView( + glucoseSamples: context.state.glucoseSamples, + predicatedGlucose: context.state.predicatedGlucose, + predicatedStartDate: context.state.predicatedStartDate, + predicatedInterval: context.state.predicatedInterval + ) } .privacySensitive() .padding(.all, 15) @@ -45,34 +50,4 @@ struct GlucoseChartLiveActivityConfiguration: Widget { } } } - - @ViewBuilder - private func chartView(_ context: ActivityViewContext) -> some View { - let glucoseSampleData = ChartValues.convert(data: context.state.glucoseSamples) - let predicatedData = ChartValues.convert( - data: context.state.predicatedGlucose, - startDate: context.state.predicatedStartDate ?? Date.now, - interval: context.state.predicatedInterval ?? .minutes(5) - ) - - let lowerBound = min(4, glucoseSampleData.min { $0.y < $1.y }?.y ?? 0, predicatedData.min { $0.y < $1.y }?.y ?? 0) - let upperBound = max(10, glucoseSampleData.max { $0.y < $1.y }?.y ?? 0, predicatedData.max { $0.y < $1.y }?.y ?? 0) - - Chart { - ForEach(glucoseSampleData) { item in - PointMark (x: .value("Date", item.x), - y: .value("Glucose level", item.y) - ) - .symbolSize(20) - } - - ForEach(predicatedData) { item in - LineMark (x: .value("Date", item.x), - y: .value("Glucose level", item.y) - ) - .lineStyle(StrokeStyle(lineWidth: 3, dash: [2, 3])) - } - } - .chartYScale(domain: [lowerBound, upperBound]) - } } diff --git a/Loop Widget Extension/Live Activity/GlucoseLiveActivityConfiguration.swift b/Loop Widget Extension/Live Activity/GlucoseLiveActivityConfiguration.swift index bfe8a2abaf..5f69432687 100644 --- a/Loop Widget Extension/Live Activity/GlucoseLiveActivityConfiguration.swift +++ b/Loop Widget Extension/Live Activity/GlucoseLiveActivityConfiguration.swift @@ -28,29 +28,44 @@ struct GlucoseLiveActivityConfiguration: Widget { // Create the presentation that appears on the Lock Screen and as a // banner on the Home Screen of devices that don't support the Dynamic Island. VStack { - HStack { - glucoseView(context) - Spacer() - metaView(context) + switch (context.attributes.mode) { + case .spacious: + topRowSpaciousView(context) + case .compact: + topRowCompactView(context) } Spacer() HStack { bottomSpacer(border: false) - bottomItem( - value: context.state.iob, - unit: LocalizedString("U", comment: "No comment"), - title: LocalizedString("IOB", comment: "No comment") - ) - bottomSpacer(border: true) - bottomItem( - value: context.state.cob, - unit: LocalizedString("g", comment: "No comment"), - title: LocalizedString("COB", comment: "No comment") - ) - bottomSpacer(border: true) - basalView(context) + + let endIndex = context.state.bottomRow.endIndex - 1 + ForEach(Array(context.state.bottomRow.enumerated()), id: \.element) { (index, item) in + switch (item.type) { + case .generic: + bottomItemGeneric( + title: LocalizedString(item.label, comment: "No comment"), + value: item.value, + unit: LocalizedString(item.unit, comment: "No comment") + ) + + case .basal: + BasalViewActivity(percent: item.percentage, rate: item.rate) + + case .currentBg: + bottomItemCurrentBG( + title: LocalizedString(item.label, comment: "No comment"), + value: item.value, + trend: item.trend + ) + } + + if index != endIndex { + bottomSpacer(border: true) + } + } + bottomSpacer(border: false) } } @@ -83,38 +98,53 @@ struct GlucoseLiveActivityConfiguration: Widget { } @ViewBuilder - private func glucoseView(_ context: ActivityViewContext) -> some View { + private func topRowSpaciousView(_ context: ActivityViewContext) -> some View { HStack { - Circle() - .trim(from: context.state.isCloseLoop ? 0 : 0.2, to: 1) - .stroke(getLoopColor(context.state.lastCompleted), lineWidth: 8) - .rotationEffect(Angle(degrees: -126)) - .frame(width: 36, height: 36) + HStack { + loopIcon(context) + + Text("\(context.state.glucose)") + .font(.title) + .fontWeight(.heavy) + .padding(.leading, 16) + + if let trendImageName = getArrowImage(context.state.trendType) { + Image(systemName: trendImageName) + .font(.system(size: 24)) + } + } - Text("\(context.state.glucose)") - .font(.title) - .fontWeight(.heavy) - .padding(.leading, 16) + Spacer() - if let trendImageName = getArrowImage(context.state.trendType) { - Image(systemName: trendImageName) - .font(.system(size: 24)) + VStack(alignment: .trailing) { + Text("\(timeFormatter.string(from: context.state.date))") + .font(.subheadline) + + Text("\(context.state.delta)") + .font(.subheadline) } } } - private func metaView(_ context: ActivityViewContext) -> some View { - VStack(alignment: .trailing) { - Text("\(timeFormatter.string(from: context.state.date))") - .font(.subheadline) - - Text("\(context.state.delta)") - .font(.subheadline) + @ViewBuilder + private func topRowCompactView(_ context: ActivityViewContext) -> some View { + HStack(spacing: 20) { + loopIcon(context) + ChartView(glucoseSamples: context.state.glucoseSamples) } } @ViewBuilder - private func bottomItem(value: String, unit: String, title: String) -> some View { + private func loopIcon(_ context: ActivityViewContext) -> some View { + Circle() + .trim(from: context.state.isCloseLoop ? 0 : 0.2, to: 1) + .stroke(getLoopColor(context.state.lastCompleted), lineWidth: 8) + .rotationEffect(Angle(degrees: -126)) + .frame(width: 36, height: 36) + } + + @ViewBuilder + private func bottomItemGeneric(title: String, value: String, unit: String) -> some View { VStack(alignment: .center) { Text("\(value)\(unit)") .font(.headline) @@ -125,10 +155,19 @@ struct GlucoseLiveActivityConfiguration: Widget { } @ViewBuilder - private func basalView(_ context: ActivityViewContext) -> some View { - let netBasal = context.state.netBasal - - BasalViewActivity(percent: netBasal?.percentage ?? 0, rate: netBasal?.rate ?? 0) + private func bottomItemCurrentBG(title: String, value: String, trend: GlucoseTrend?) -> some View { + VStack(alignment: .center) { + HStack { + Text(value) + .font(.title) + .fontWeight(.heavy) + + if let trend = trend, let trendImageName = getArrowImage(trend) { + Image(systemName: trendImageName) + .font(.system(size: 24)) + } + } + } } @ViewBuilder @@ -143,22 +182,6 @@ struct GlucoseLiveActivityConfiguration: Widget { } - private func getLoopColor(_ age: Date?) -> Color { - var freshness: LoopCompletionFreshness = .stale - if let age = age { - freshness = LoopCompletionFreshness(age: abs(min(0, age.timeIntervalSinceNow))) - } - - switch freshness { - case .fresh: - return Color("fresh") - case .aging: - return Color("warning") - case .stale: - return Color.red - } - } - private func getArrowImage(_ trendType: GlucoseTrend?) -> String? { switch trendType { case .upUpUp: @@ -181,4 +204,20 @@ struct GlucoseLiveActivityConfiguration: Widget { return nil } } + + private func getLoopColor(_ age: Date?) -> Color { + var freshness: LoopCompletionFreshness = .stale + if let age = age { + freshness = LoopCompletionFreshness(age: abs(min(0, age.timeIntervalSinceNow))) + } + + switch freshness { + case .fresh: + return Color("fresh") + case .aging: + return Color("warning") + case .stale: + return .red + } + } } diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index 20f7c5ad7d..d0e794f379 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -401,9 +401,12 @@ B4E96D5D248A82A2002DABAD /* StatusBarHUDView.xib in Resources */ = {isa = PBXBuildFile; fileRef = B4E96D5C248A82A2002DABAD /* StatusBarHUDView.xib */; }; B4F3D25124AF890C0095CE44 /* BluetoothStateManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4F3D25024AF890C0095CE44 /* BluetoothStateManager.swift */; }; B4FEEF7D24B8A71F00A8DF9B /* DeviceDataManager+DeviceStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4FEEF7C24B8A71F00A8DF9B /* DeviceDataManager+DeviceStatus.swift */; }; + B851FFC52C37221800D738C1 /* LiveActivityManagementView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B851FFC42C37221800D738C1 /* LiveActivityManagementView.swift */; }; + B851FFCA2C3731DE00D738C1 /* LiveActivitySettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = B851FFC62C37271E00D738C1 /* LiveActivitySettings.swift */; }; + B851FFCB2C3731DE00D738C1 /* LiveActivitySettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = B851FFC62C37271E00D738C1 /* LiveActivitySettings.swift */; }; B87539C92C2B06CE0085A975 /* LocalizedString.swift in Sources */ = {isa = PBXBuildFile; fileRef = B87539C82C2B06CE0085A975 /* LocalizedString.swift */; }; B87539CB2C2B08430085A975 /* Bootstrap.swift in Sources */ = {isa = PBXBuildFile; fileRef = B87539CA2C2B08430085A975 /* Bootstrap.swift */; }; - B87539CD2C2B46950085A975 /* ChartValues.swift in Sources */ = {isa = PBXBuildFile; fileRef = B87539CC2C2B46950085A975 /* ChartValues.swift */; }; + B87539CD2C2B46950085A975 /* ChartView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B87539CC2C2B46950085A975 /* ChartView.swift */; }; B87539CF2C2DCB770085A975 /* BasalViewActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = B87539CE2C2DCB770085A975 /* BasalViewActivity.swift */; }; B87539D12C2DDE760085A975 /* GlucoseChartLiveActivityConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = B87539D02C2DDE760085A975 /* GlucoseChartLiveActivityConfiguration.swift */; }; B87D411D2C28A69600120877 /* GlucoseLiveActivityConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = B87D411C2C28A69600120877 /* GlucoseLiveActivityConfiguration.swift */; }; @@ -1335,9 +1338,11 @@ B4E96D5C248A82A2002DABAD /* StatusBarHUDView.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = StatusBarHUDView.xib; sourceTree = ""; }; B4F3D25024AF890C0095CE44 /* BluetoothStateManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BluetoothStateManager.swift; sourceTree = ""; }; B4FEEF7C24B8A71F00A8DF9B /* DeviceDataManager+DeviceStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DeviceDataManager+DeviceStatus.swift"; sourceTree = ""; }; + B851FFC42C37221800D738C1 /* LiveActivityManagementView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActivityManagementView.swift; sourceTree = ""; }; + B851FFC62C37271E00D738C1 /* LiveActivitySettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActivitySettings.swift; sourceTree = ""; }; B87539C82C2B06CE0085A975 /* LocalizedString.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalizedString.swift; sourceTree = ""; }; B87539CA2C2B08430085A975 /* Bootstrap.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Bootstrap.swift; sourceTree = ""; }; - B87539CC2C2B46950085A975 /* ChartValues.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChartValues.swift; sourceTree = ""; }; + B87539CC2C2B46950085A975 /* ChartView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChartView.swift; sourceTree = ""; }; B87539CE2C2DCB770085A975 /* BasalViewActivity.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BasalViewActivity.swift; sourceTree = ""; }; B87539D02C2DDE760085A975 /* GlucoseChartLiveActivityConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseChartLiveActivityConfiguration.swift; sourceTree = ""; }; B87D411C2C28A69600120877 /* GlucoseLiveActivityConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseLiveActivityConfiguration.swift; sourceTree = ""; }; @@ -2184,6 +2189,7 @@ 43DE92581C5479E4001FFDE1 /* PotentialCarbEntryUserInfo.swift */, 43C05CB721EBEA54006FB252 /* HKUnit.swift */, 434FF1E91CF26C29000DB779 /* IdentifiableClass.swift */, + E9C00EEF24C620EF00628F35 /* LoopSettings.swift */, C19E96DD23D2733F003F79B0 /* LoopCompletionFreshness.swift */, 430B29892041F54A00BA9F93 /* NSUserDefaults.swift */, 431E73471FF95A900069B5F7 /* PersistenceController.swift */, @@ -2191,10 +2197,10 @@ 43D9FFD121EAE05D00AF44BF /* LoopCore.h */, 43D9FFD221EAE05D00AF44BF /* Info.plist */, 4B60626A287E286000BF8BBB /* Localizable.strings */, - E9C00EEF24C620EF00628F35 /* LoopSettings.swift */, C16575742539FD60004AE16E /* LoopCoreConstants.swift */, E9B3551B292844010076AB04 /* MissedMealNotification.swift */, C1D0B62F2986D4D90098D215 /* LocalizedString.swift */, + B851FFC62C37271E00D738C1 /* LiveActivitySettings.swift */, ); path = LoopCore; sourceTree = ""; @@ -2297,6 +2303,7 @@ C1AF062229426300002C1B19 /* ManualGlucoseEntryRow.swift */, DDC389FD2A2C4C830066E2E8 /* GlucoseBasedApplicationFactorSelectionView.swift */, DD3DBD282A33AFE9000F8B5B /* IntegralRetrospectiveCorrectionSelectionView.swift */, + B851FFC42C37221800D738C1 /* LiveActivityManagementView.swift */, ); path = Views; sourceTree = ""; @@ -2790,7 +2797,7 @@ children = ( B87D411C2C28A69600120877 /* GlucoseLiveActivityConfiguration.swift */, B87539D02C2DDE760085A975 /* GlucoseChartLiveActivityConfiguration.swift */, - B87539CC2C2B46950085A975 /* ChartValues.swift */, + B87539CC2C2B46950085A975 /* ChartView.swift */, B87539CE2C2DCB770085A975 /* BasalViewActivity.swift */, ); path = "Live Activity"; @@ -3676,7 +3683,7 @@ 84AA81D82A4A2910000B658B /* StatusWidgetTimelimeEntry.swift in Sources */, 84AA81D32A4A27A3000B658B /* LoopWidgets.swift in Sources */, B87539CF2C2DCB770085A975 /* BasalViewActivity.swift in Sources */, - B87539CD2C2B46950085A975 /* ChartValues.swift in Sources */, + B87539CD2C2B46950085A975 /* ChartView.swift in Sources */, B87539D12C2DDE760085A975 /* GlucoseChartLiveActivityConfiguration.swift in Sources */, 84AA81E32A4A36FB000B658B /* SystemActionLink.swift in Sources */, 14B1737828AEDC6C006CCD7C /* NSTimeInterval.swift in Sources */, @@ -3784,6 +3791,7 @@ A967D94C24F99B9300CDDF8A /* OutputStream.swift in Sources */, 1DB1065124467E18005542BD /* AlertManager.swift in Sources */, 1D9650C82523FBA100A1370B /* DeviceDataManager+BolusEntryViewModelDelegate.swift in Sources */, + B851FFC52C37221800D738C1 /* LiveActivityManagementView.swift in Sources */, 43C0944A1CACCC73001F6403 /* NotificationManager.swift in Sources */, 149A28E42A8A63A700052EDF /* FavoriteFoodDetailView.swift in Sources */, 1DDE274024AEA4F200796622 /* NotificationsCriticalAlertPermissionsView.swift in Sources */, @@ -4010,6 +4018,7 @@ C17DDC9D28AC33A1005FBF4C /* PersistedProperty.swift in Sources */, 43C05CA921EB2B26006FB252 /* PersistenceController.swift in Sources */, A9CE912224CA032E00302A40 /* NSUserDefaults.swift in Sources */, + B851FFCB2C3731DE00D738C1 /* LiveActivitySettings.swift in Sources */, 43C05CAB21EB2B4A006FB252 /* NSBundle.swift in Sources */, 43C05CC721EC2ABC006FB252 /* IdentifiableClass.swift in Sources */, E9B3552B293591E70076AB04 /* MissedMealNotification.swift in Sources */, @@ -4031,6 +4040,7 @@ C17DDC9C28AC339E005FBF4C /* PersistedProperty.swift in Sources */, 43C05CA821EB2B26006FB252 /* PersistenceController.swift in Sources */, 43C05CAA21EB2B49006FB252 /* NSBundle.swift in Sources */, + B851FFCA2C3731DE00D738C1 /* LiveActivitySettings.swift in Sources */, 43C05CC821EC2ABC006FB252 /* IdentifiableClass.swift in Sources */, 43C05CAD21EB2BBF006FB252 /* NSUserDefaults.swift in Sources */, E9B3552A293591E70076AB04 /* MissedMealNotification.swift in Sources */, diff --git a/Loop/Managers/Live Activity/GlucoseActivityAttributes.swift b/Loop/Managers/Live Activity/GlucoseActivityAttributes.swift index f0d9cc3a11..cc285ff1c6 100644 --- a/Loop/Managers/Live Activity/GlucoseActivityAttributes.swift +++ b/Loop/Managers/Live Activity/GlucoseActivityAttributes.swift @@ -9,6 +9,7 @@ import ActivityKit import Foundation import LoopKit +import LoopCore public struct GlucoseActivityAttributes: ActivityAttributes { public struct ContentState: Codable, Hashable { @@ -19,16 +20,14 @@ public struct GlucoseActivityAttributes: ActivityAttributes { public let glucose: String public let trendType: GlucoseTrend? public let delta: String - public let cob: String - public let iob: String public let isCloseLoop: Bool public let lastCompleted: Date? - // Pump view - public let pumpHighlight: PumpHighlightAttributes? - public let netBasal: NetBasalAttributes? - public let eventualGlucose: String + public let bottomRow: [BottomRowItem] + public let glucoseSamples: [GlucoseSampleAttributes] } + + public let mode: GlucoseActivityMode } public struct GlucoseChartActivityAttributes: ActivityAttributes { @@ -40,15 +39,58 @@ public struct GlucoseChartActivityAttributes: ActivityAttributes { } } -public struct PumpHighlightAttributes: Codable, Hashable { - public let localizedMessage: String - public let imageName: String - public let state: DeviceStatusHighlightState -} +public struct BottomRowItem: Codable, Hashable { + public enum BottomRowType: Codable, Hashable { + case generic + case basal + case currentBg + } + + public let type: BottomRowType + + // Generic properties + public let label: String + public let value: String + public let unit: String + + public let trend: GlucoseTrend? -public struct NetBasalAttributes: Codable, Hashable { + // Basal properties public let rate: Double public let percentage: Double + + init(label: String, value: String, unit: String) { + self.type = .generic + self.label = label + self.value = value + self.unit = unit + + self.trend = nil + self.rate = 0 + self.percentage = 0 + } + + init(label: String, value: String, trend: GlucoseTrend?) { + self.type = .currentBg + self.label = label + self.value = value + self.trend = trend + + self.unit = "" + self.rate = 0 + self.percentage = 0 + } + + init(rate: Double, percentage: Double) { + self.type = .basal + self.rate = rate + self.percentage = percentage + + self.label = "" + self.value = "" + self.unit = "" + self.trend = nil + } } public struct GlucoseSampleAttributes: Codable, Hashable { diff --git a/Loop/Managers/Live Activity/GlucoseActivityManager.swift b/Loop/Managers/Live Activity/GlucoseActivityManager.swift index 3e6682ccfd..bbff405083 100644 --- a/Loop/Managers/Live Activity/GlucoseActivityManager.swift +++ b/Loop/Managers/Live Activity/GlucoseActivityManager.swift @@ -8,15 +8,19 @@ import LoopKitUI import LoopKit +import LoopCore import Foundation import HealthKit import ActivityKit -import UIKit + +extension Notification.Name { + static let LiveActivitySettingsChanged = Notification.Name(rawValue: "com.loopKit.notification.LiveActivitySettingsChanged") +} @available(iOS 16.2, *) class GlucoseActivityManager { private let activityInfo = ActivityAuthorizationInfo() - private var activity: Activity + private var activity: Activity? private var activityChart: Activity? private let healthStore = HKHealthStore() @@ -27,6 +31,7 @@ class GlucoseActivityManager { private var prevGlucoseSample: GlucoseSampleValue? private var startDate: Date = Date.now + private var settings: LiveActivitySettings = UserDefaults.standard.liveActivity ?? LiveActivitySettings() private let cobFormatter: NumberFormatter = { let numberFormatter = NumberFormatter() @@ -40,6 +45,13 @@ class GlucoseActivityManager { numberFormatter.minimumFractionDigits = 1 return numberFormatter }() + private let timeFormatter: DateFormatter = { + let dateFormatter = DateFormatter() + dateFormatter.dateStyle = .none + dateFormatter.timeStyle = .short + + return dateFormatter + }() init?(glucoseStore: GlucoseStoreProtocol, doseStore: DoseStoreProtocol) { guard self.activityInfo.areActivitiesEnabled else { @@ -50,50 +62,21 @@ class GlucoseActivityManager { self.glucoseStore = glucoseStore self.doseStore = doseStore - do { - let lastCompleted: Date? = nil - let pumpHighlight: PumpHighlightAttributes? = nil - let netBasal: NetBasalAttributes? = nil - - let dynamicState = GlucoseActivityAttributes.ContentState( - date: Date.now, - glucose: "--", - trendType: nil, - delta: "", - cob: "0", - iob: "0", - isCloseLoop: false, - lastCompleted: lastCompleted, - pumpHighlight: pumpHighlight, - netBasal: netBasal, - eventualGlucose: "" - ) - - self.activity = try Activity.request( - attributes: GlucoseActivityAttributes(), - content: .init(state: dynamicState, staleDate: nil), - pushType: .token - ) - - let dynamicChartState = GlucoseChartActivityAttributes.ContentState( - predicatedGlucose: [], - predicatedStartDate: nil, - predicatedInterval: nil, - glucoseSamples: [] - ) - - self.activityChart = try Activity.request( - attributes: GlucoseChartActivityAttributes(), - content: .init(state: dynamicChartState, staleDate: nil), - pushType: .token - ) - - Task { - await self.endUnknownActivities() - } - } catch { - print("ERROR: \(error.localizedDescription) :(") - return nil + // Ensure settings exist + if UserDefaults.standard.liveActivity == nil { + self.settings = LiveActivitySettings() + } + + NotificationCenter.default.addObserver(self, selector: #selector(self.settingsChanged), name: .LiveActivitySettingsChanged, object: nil) + guard self.settings.enabled else { + return + } + + initEmptyActivity() + initEmptyChartActivity() + + Task { + await self.endUnknownActivities() } } @@ -103,14 +86,14 @@ class GlucoseActivityManager { public func update(glucose: GlucoseSampleValue?) { Task { - if self.needsRecreation(), await UIApplication.shared.applicationState == .active { + if self.needsRecreation() { // activity is no longer visible or old. End it and try to push the update again await endActivity() update(glucose: glucose) return } - guard let glucose = glucose, let unit = await healthStore.cachedPreferredUnits(for: .bloodGlucose) else { + guard let glucose = glucose, let unit = await self.healthStore.cachedPreferredUnits(for: .bloodGlucose) else { return } @@ -128,55 +111,36 @@ class GlucoseActivityManager { delta = "\(deltaValue < 0 ? "-" : "+")\(glucoseFormatter.string(from: abs(deltaValue)) ?? "??")" } - var pumpHighlight: PumpHighlightAttributes? = nil - if let pumpStatusHightlight = statusContext?.pumpStatusHighlightContext { - pumpHighlight = PumpHighlightAttributes( - localizedMessage: pumpStatusHightlight.localizedMessage, - imageName: pumpStatusHightlight.imageName, - state: pumpStatusHightlight.state) - } - - var netBasal: NetBasalAttributes? = nil - if let netBasalContext = statusContext?.netBasal { - netBasal = NetBasalAttributes( - rate: netBasalContext.rate, - percentage: netBasalContext.percentage - ) - } - - var predicatedGlucose: [Double] = [] - if let samples = statusContext?.predictedGlucose?.values { - predicatedGlucose = samples - } - - var cob: String = "0" - if let cobValue = statusContext?.carbsOnBoard { - cob = self.cobFormatter.string(from: cobValue) ?? "??" - } - - let glucoseSamples = await self.getGlucoseSample(unit: unit) - let iob = await self.getInsulinOnBoard() + let glucoseSamples = self.getGlucoseSample(unit: unit) + let bottomRow = self.getBottomRow( + currentGlucose: current, + delta: delta, + statusContext: statusContext, + glucoseFormatter: glucoseFormatter + ) let state = GlucoseActivityAttributes.ContentState( date: glucose.startDate, glucose: glucoseFormatter.string(from: current) ?? "??", trendType: statusContext?.glucoseDisplay?.trendType, delta: delta, - cob: cob, - iob: iob, isCloseLoop: statusContext?.isClosedLoop ?? false, lastCompleted: statusContext?.lastLoopCompleted, - pumpHighlight: pumpHighlight, - netBasal: netBasal, - eventualGlucose: glucoseFormatter.string(from: statusContext?.predictedGlucose?.values.last ?? 0) ?? "??" + bottomRow: bottomRow, + glucoseSamples: glucoseSamples ) - await self.activity.update(ActivityContent( + await self.activity?.update(ActivityContent( state: state, - staleDate: min(state.date, Date.now).addingTimeInterval(TimeInterval(6 * 60)) + staleDate: min(state.date, Date.now).addingTimeInterval(.minutes(12)) )) if let activityChart = self.activityChart { + var predicatedGlucose: [Double] = [] + if let samples = statusContext?.predictedGlucose?.values { + predicatedGlucose = samples + } + let stateChart = GlucoseChartActivityAttributes.ContentState( predicatedGlucose: predicatedGlucose, predicatedStartDate: statusContext?.predictedGlucose?.startDate, @@ -186,7 +150,7 @@ class GlucoseActivityManager { await activityChart.update(ActivityContent( state: stateChart, - staleDate: min(state.date, Date.now).addingTimeInterval(TimeInterval(6 * 60)) + staleDate: min(state.date, Date.now).addingTimeInterval(.minutes(12)) )) } @@ -194,30 +158,58 @@ class GlucoseActivityManager { } } + @objc private func settingsChanged() { + let newSettings = UserDefaults.standard.liveActivity ?? LiveActivitySettings() + + // Update live activity if needed + if !newSettings.enabled, let activity = self.activity { + Task { + await activity.end(nil, dismissalPolicy: .immediate) + self.activity = nil + } + } else if newSettings.enabled && self.activity == nil { + initEmptyActivity() + } + + if !newSettings.enabled, let activityChart = self.activityChart { + Task { + await activityChart.end(nil, dismissalPolicy: .immediate) + self.activityChart = nil + } + } else if newSettings.enabled && self.activityChart == nil { + initEmptyChartActivity() + } + + update() + self.settings = newSettings + } + private func endUnknownActivities() async { for unknownActivity in Activity.activities - .filter({ self.activity.id != $0.id && self.activityChart?.id != $0.id }) + .filter({ self.activity?.id != $0.id && self.activityChart?.id != $0.id }) { await unknownActivity.end(nil, dismissalPolicy: .immediate) } } private func endActivity() async { - let dynamicState = self.activity.content.state + let dynamicState = self.activity?.content.state let dynamicChartState = self.activityChart?.content.state - await self.activity.end(nil, dismissalPolicy: .immediate) + await self.activity?.end(nil, dismissalPolicy: .immediate) await self.activityChart?.end(nil, dismissalPolicy: .immediate) for unknownActivity in Activity.activities { await unknownActivity.end(nil, dismissalPolicy: .immediate) } do { - self.activity = try Activity.request( - attributes: GlucoseActivityAttributes(), - content: .init(state: dynamicState, staleDate: nil), - pushType: .token - ) + if let dynamicState = dynamicState { + self.activity = try Activity.request( + attributes: GlucoseActivityAttributes(mode: self.settings.mode), + content: .init(state: dynamicState, staleDate: nil), + pushType: .token + ) + } if let dynamicChartState = dynamicChartState { @@ -233,48 +225,142 @@ class GlucoseActivityManager { } private func needsRecreation() -> Bool { - switch activity.activityState { + if !self.settings.enabled { + return false + } + + switch activity?.activityState { case .dismissed, .ended, .stale: return true case .active: - return -startDate.timeIntervalSinceNow > - TimeInterval(60 * 60) + return -startDate.timeIntervalSinceNow > .hours(1) default: return true } } - private func getInsulinOnBoard() async -> String { - return await withCheckedContinuation { continuation in - self.doseStore.insulinOnBoard(at: Date.now) { result in - switch (result) { - case .failure: - continuation.resume(returning: "??") - break - case .success(let iob): - continuation.resume(returning: self.iobFormatter.string(from: iob.value) ?? "??") - break - } + private func getInsulinOnBoard() -> String { + let updateGroup = DispatchGroup() + var iob = "??" + + updateGroup.enter() + self.doseStore.insulinOnBoard(at: Date.now) { result in + switch (result) { + case .failure: + break + case .success(let iobValue): + iob = self.iobFormatter.string(from: iobValue.value) ?? "??" + break } + + updateGroup.leave() } + + _ = updateGroup.wait(timeout: .distantFuture) + return iob } - private func getGlucoseSample(unit: HKUnit) async -> [GlucoseSampleAttributes] { - return await withCheckedContinuation { continuation in - self.glucoseStore.getGlucoseSamples(start: Date.now.addingTimeInterval(.hours(-1)), end: Date.now) { result in - switch (result) { - case .failure: - continuation.resume(returning: []) - break - case .success(let data): - continuation.resume(returning: data.map { item in - return GlucoseSampleAttributes(x: item.startDate, y: item.quantity.doubleValue(for: unit)) - }) - return + private func getGlucoseSample(unit: HKUnit) -> [GlucoseSampleAttributes] { + let updateGroup = DispatchGroup() + var samples: [GlucoseSampleAttributes] = [] + + updateGroup.enter() + self.glucoseStore.getGlucoseSamples(start: Date.now.addingTimeInterval(.hours(-1)), end: Date.now) { result in + switch (result) { + case .failure: + break + case .success(let data): + samples = data.suffix(30).map { item in + return GlucoseSampleAttributes(x: item.startDate, y: item.quantity.doubleValue(for: unit)) } + break } + + updateGroup.leave() } + + _ = updateGroup.wait(timeout: .distantFuture) + return samples + } + + private func getBottomRow(currentGlucose: Double, delta: String, statusContext: StatusExtensionContext?, glucoseFormatter: NumberFormatter) -> [BottomRowItem] { + return self.settings.bottomRowConfiguration.map { type in + switch(type) { + case .iob: + return BottomRowItem(label: "IOB", value: getInsulinOnBoard(), unit: "U") + + case .cob: + var cob: String = "0" + if let cobValue = statusContext?.carbsOnBoard { + cob = self.cobFormatter.string(from: cobValue) ?? "??" + } + return BottomRowItem(label: "COB", value: cob, unit: "g") + + case .basal: + guard let netBasalContext = statusContext?.netBasal else { + return BottomRowItem(rate: 0, percentage: 0) + } + + return BottomRowItem(rate: netBasalContext.rate, percentage: netBasalContext.percentage) + + case .currentBg: + return BottomRowItem(label: "Current", value: "\(glucoseFormatter.string(from: currentGlucose) ?? "??")", trend: statusContext?.glucoseDisplay?.trendType) + + case .eventualBg: + guard let eventual = statusContext?.predictedGlucose?.values.last else { + return BottomRowItem(label: "Event.", value: "??", unit: "") + } + + return BottomRowItem(label: "Event.", value: glucoseFormatter.string(from: eventual) ?? "??", unit: "") + + case .deltaBg: + return BottomRowItem(label: "Delta", value: delta, unit: "") + + case .updatedAt: + return BottomRowItem(label: "Updated", value: timeFormatter.string(from: Date.now), unit: "") + } + } + } + + private func initEmptyActivity() { + do { + let dynamicState = GlucoseActivityAttributes.ContentState( + date: Date.now, + glucose: "--", + trendType: nil, + delta: "", + isCloseLoop: false, + lastCompleted: nil, + bottomRow: [], + glucoseSamples: [] + ) + + self.activity = try Activity.request( + attributes: GlucoseActivityAttributes(mode: self.settings.mode), + content: .init(state: dynamicState, staleDate: nil), + pushType: .token + ) + } catch {} + } + + private func initEmptyChartActivity() { + do { + if self.settings.mode == .spacious { + let dynamicChartState = GlucoseChartActivityAttributes.ContentState( + predicatedGlucose: [], + predicatedStartDate: nil, + predicatedInterval: nil, + glucoseSamples: [] + ) + + self.activityChart = try Activity.request( + attributes: GlucoseChartActivityAttributes(), + content: .init(state: dynamicChartState, staleDate: nil), + pushType: .token + ) + } + } catch {} } } diff --git a/Loop/Views/AlertManagementView.swift b/Loop/Views/AlertManagementView.swift index e9a38e72a0..9620cc8cf8 100644 --- a/Loop/Views/AlertManagementView.swift +++ b/Loop/Views/AlertManagementView.swift @@ -157,6 +157,11 @@ struct AlertManagementView: View { } } } + + NavigationLink(destination: LiveActivityManagementView()) + { + Text(NSLocalizedString("Live activity", comment: "Alert Permissions live activity")) + } } } diff --git a/Loop/Views/LiveActivityManagementView.swift b/Loop/Views/LiveActivityManagementView.swift new file mode 100644 index 0000000000..5a8fa241c9 --- /dev/null +++ b/Loop/Views/LiveActivityManagementView.swift @@ -0,0 +1,48 @@ +// +// LiveActivityManagementView.swift +// Loop +// +// Created by Bastiaan Verhaar on 04/07/2024. +// Copyright © 2024 LoopKit Authors. All rights reserved. +// + +import SwiftUI +import LoopCore + +struct LiveActivityManagementView: View { + + private var enabled: Binding { + Binding( + get: { UserDefaults.standard.liveActivity?.enabled ?? false }, + set: { newValue in + mutate { settings in + settings.enabled = newValue + } + } + ) + } + + var body: some View { + List { + Toggle(NSLocalizedString("Enabled", comment: "Title for missed meal notifications toggle"), isOn: enabled) + } + .insetGroupedListStyle() + .navigationBarTitle(Text(NSLocalizedString("Live activity", comment: "Live activity screen title"))) + } + + func mutate(_ updater: (inout LiveActivitySettings) -> Void) { + var settings = UserDefaults.standard.liveActivity + if settings == nil { + settings = LiveActivitySettings() + } + + updater(&settings!) + + UserDefaults.standard.liveActivity = settings + NotificationCenter.default.post(name: .LiveActivitySettingsChanged, object: settings) + } +} + +#Preview { + LiveActivityManagementView() +} diff --git a/LoopCore/LiveActivitySettings.swift b/LoopCore/LiveActivitySettings.swift new file mode 100644 index 0000000000..2c6a348c0c --- /dev/null +++ b/LoopCore/LiveActivitySettings.swift @@ -0,0 +1,55 @@ +// +// LiveActivitySettings.swift +// LoopCore +// +// Created by Bastiaan Verhaar on 04/07/2024. +// Copyright © 2024 LoopKit Authors. All rights reserved. +// + +import Foundation + +public enum BottomRowConfiguration: Codable { + case iob + case cob + case basal + case currentBg + case eventualBg + case deltaBg + case updatedAt + + static let defaults: [BottomRowConfiguration] = [.iob, .cob, .basal, .eventualBg] //[.currentBg, .iob, .cob, .updatedAt] +} + +public enum GlucoseActivityMode: Codable, Hashable { + case compact + case spacious +} + +public struct LiveActivitySettings: Codable { + public var enabled: Bool + + public var mode: GlucoseActivityMode + public var bottomRowConfiguration: [BottomRowConfiguration] + + private enum CodingKeys: String, CodingKey { + case enabled + case mode + case bottomRowConfiguration + } + + public init(from decoder:Decoder) throws { +// let values = try decoder.container(keyedBy: CodingKeys.self) +// enabled = try values.decode(Bool.self, forKey: .enabled) +// mode = try values.decode(GlucoseActivityMode.self, forKey: .mode) +// bottomRowConfiguration = try values.decode([BottomRowConfiguration].self, forKey: .bottomRowConfiguration) + self.enabled = true + self.mode = .spacious + self.bottomRowConfiguration = BottomRowConfiguration.defaults + } + + public init() { + self.enabled = true + self.mode = .spacious + self.bottomRowConfiguration = BottomRowConfiguration.defaults + } +} diff --git a/LoopCore/NSUserDefaults.swift b/LoopCore/NSUserDefaults.swift index 93fa7e17d6..dacf2ecdc7 100644 --- a/LoopCore/NSUserDefaults.swift +++ b/LoopCore/NSUserDefaults.swift @@ -23,6 +23,7 @@ extension UserDefaults { case allowSimulators = "com.loopkit.Loop.allowSimulators" case LastMissedMealNotification = "com.loopkit.Loop.lastMissedMealNotification" case userRequestedLoopReset = "com.loopkit.Loop.userRequestedLoopReset" + case liveActivity = "com.loopkit.Loop.liveActivity" } public static let appGroup = UserDefaults(suiteName: Bundle.main.appGroupSuiteName) @@ -165,6 +166,29 @@ extension UserDefaults { setValue(newValue, forKey: Key.userRequestedLoopReset.rawValue) } } + + public var liveActivity: LiveActivitySettings? { + get { + let decoder = JSONDecoder() + guard let data = object(forKey: Key.liveActivity.rawValue) as? Data else { + return nil + } + return try? decoder.decode(LiveActivitySettings.self, from: data) + } + set { + do { + if let newValue = newValue { + let encoder = JSONEncoder() + let data = try encoder.encode(newValue) + set(data, forKey: Key.liveActivity.rawValue) + } else { + set(nil, forKey: Key.liveActivity.rawValue) + } + } catch { + assertionFailure("Unable to encode MissedMealNotification") + } + } + } public func removeLegacyLoopSettings() { removeObject(forKey: "com.loudnate.Naterade.BasalRateSchedule") From 6301811c0b4dce3082c57e865a8c569a7da1b474 Mon Sep 17 00:00:00 2001 From: bastiaanv Date: Fri, 5 Jul 2024 23:06:14 +0200 Subject: [PATCH 04/33] wip: Update live activity --- .../Live Activity/ChartView.swift | 9 +- ...lucoseChartLiveActivityConfiguration.swift | 53 -------- .../GlucoseLiveActivityConfiguration.swift | 120 +++++++---------- Loop Widget Extension/LoopWidgets.swift | 1 - Loop.xcodeproj/project.pbxproj | 4 - .../GlucoseActivityAttributes.swift | 20 +-- .../GlucoseActivityManager.swift | 127 +++++++----------- LoopCore/LiveActivitySettings.swift | 16 +-- 8 files changed, 118 insertions(+), 232 deletions(-) delete mode 100644 Loop Widget Extension/Live Activity/GlucoseChartLiveActivityConfiguration.swift diff --git a/Loop Widget Extension/Live Activity/ChartView.swift b/Loop Widget Extension/Live Activity/ChartView.swift index 281b0beea7..cdad906dbf 100644 --- a/Loop Widget Extension/Live Activity/ChartView.swift +++ b/Loop Widget Extension/Live Activity/ChartView.swift @@ -58,13 +58,16 @@ struct ChartView: View { .lineStyle(StrokeStyle(lineWidth: 3, dash: [2, 3])) } } - .chartYScale(domain: [lowerBoundY, upperBoundY]) .chartForegroundStyleScale([ "Good": .green, "High": .orange, "Low": .red ]) + .chartPlotStyle { plotContent in + plotContent.background(.cyan.opacity(0.15)) + } .chartLegend(.hidden) + .chartYScale(domain: .automatic(includesZero: false)) .chartYAxis { AxisMarks(position: .trailing) { _ in AxisValueLabel().foregroundStyle(Color.primary) @@ -73,8 +76,8 @@ struct ChartView: View { } } .chartXAxis { - AxisMarks(position: .automatic) { _ in - AxisValueLabel(format: .dateTime.hour(.defaultDigits(amPM: .narrow)), anchor: .top) + AxisMarks(position: .automatic, values: .stride(by: .hour)) { _ in + AxisValueLabel(format: .dateTime.hour(.twoDigits(amPM: .narrow)).minute(.twoDigits), anchor: .top) .foregroundStyle(Color.primary) AxisGridLine(stroke: .init(lineWidth: 0.1, dash: [2, 3])) .foregroundStyle(Color.primary) diff --git a/Loop Widget Extension/Live Activity/GlucoseChartLiveActivityConfiguration.swift b/Loop Widget Extension/Live Activity/GlucoseChartLiveActivityConfiguration.swift deleted file mode 100644 index 2f5342e153..0000000000 --- a/Loop Widget Extension/Live Activity/GlucoseChartLiveActivityConfiguration.swift +++ /dev/null @@ -1,53 +0,0 @@ -// -// ChartView.swift -// Loop Widget Extension -// -// Created by Bastiaan Verhaar on 27/06/2024. -// Copyright © 2024 LoopKit Authors. All rights reserved. -// - -import SwiftUI -import Charts -import WidgetKit - -struct GlucoseChartLiveActivityConfiguration: Widget { - var body: some WidgetConfiguration { - ActivityConfiguration(for: GlucoseChartActivityAttributes.self) { context in - // Create the presentation that appears on the Lock Screen and as a - // banner on the Home Screen of devices that don't support the Dynamic Island. - HStack { - ChartView( - glucoseSamples: context.state.glucoseSamples, - predicatedGlucose: context.state.predicatedGlucose, - predicatedStartDate: context.state.predicatedStartDate, - predicatedInterval: context.state.predicatedInterval - ) - } - .privacySensitive() - .padding(.all, 15) - .background(BackgroundStyle.background.opacity(0.4)) - .activityBackgroundTint(Color.clear) - } dynamicIsland: { _ in - DynamicIsland { - DynamicIslandExpandedRegion(.leading) { - HStack{} - } - DynamicIslandExpandedRegion(.trailing) { - HStack{} - } - DynamicIslandExpandedRegion(.bottom) { - HStack{} - } - } compactLeading: { - // Create the compact leading presentation. - HStack{} - } compactTrailing: { - // Create the compact trailing presentation. - HStack{} - } minimal: { - // Create the minimal presentation. - HStack{} - } - } - } -} diff --git a/Loop Widget Extension/Live Activity/GlucoseLiveActivityConfiguration.swift b/Loop Widget Extension/Live Activity/GlucoseLiveActivityConfiguration.swift index 5f69432687..4b08fba404 100644 --- a/Loop Widget Extension/Live Activity/GlucoseLiveActivityConfiguration.swift +++ b/Loop Widget Extension/Live Activity/GlucoseLiveActivityConfiguration.swift @@ -12,6 +12,7 @@ import SwiftUI import LoopCore import WidgetKit import Charts +import HealthKit @available(iOS 16.2, *) struct GlucoseLiveActivityConfiguration: Widget { @@ -28,15 +29,22 @@ struct GlucoseLiveActivityConfiguration: Widget { // Create the presentation that appears on the Lock Screen and as a // banner on the Home Screen of devices that don't support the Dynamic Island. VStack { - switch (context.attributes.mode) { - case .spacious: - topRowSpaciousView(context) - case .compact: - topRowCompactView(context) + HStack(spacing: 15) { + loopIcon(context) + if context.attributes.addPredictiveLine { + ChartView( + glucoseSamples: context.state.glucoseSamples, + predicatedGlucose: context.state.predicatedGlucose, + predicatedStartDate: context.state.predicatedStartDate, + predicatedInterval: context.state.predicatedInterval + ) + .frame(height: 85) + } else { + ChartView(glucoseSamples: context.state.glucoseSamples) + .frame(height: 85) + } } - Spacer() - HStack { bottomSpacer(border: false) @@ -73,8 +81,10 @@ struct GlucoseLiveActivityConfiguration: Widget { .padding(.all, 15) .background(BackgroundStyle.background.opacity(0.4)) .activityBackgroundTint(Color.clear) - } dynamicIsland: { _ in - DynamicIsland { + } dynamicIsland: { context in + let glucoseFormatter = NumberFormatter.glucoseFormatter(for: context.state.isMmol ? HKUnit.millimolesPerLiter : HKUnit.milligramsPerDeciliter) + + return DynamicIsland { DynamicIslandExpandedRegion(.leading) { HStack{} } @@ -85,55 +95,21 @@ struct GlucoseLiveActivityConfiguration: Widget { HStack{} } } compactLeading: { - // Create the compact leading presentation. - HStack{} + Text("\(glucoseFormatter.string(from: context.state.currentGlucose) ?? "??")\(getArrowImage(context.state.trendType))") + .foregroundStyle(getGlucoseColor(context.state.currentGlucose)) + .minimumScaleFactor(0.1) } compactTrailing: { - // Create the compact trailing presentation. - HStack{} + Text(context.state.delta) + .foregroundStyle(Color(white: 0.9)) + .minimumScaleFactor(0.1) } minimal: { - // Create the minimal presentation. - HStack{} - } - } - } - - @ViewBuilder - private func topRowSpaciousView(_ context: ActivityViewContext) -> some View { - HStack { - HStack { - loopIcon(context) - - Text("\(context.state.glucose)") - .font(.title) - .fontWeight(.heavy) - .padding(.leading, 16) - - if let trendImageName = getArrowImage(context.state.trendType) { - Image(systemName: trendImageName) - .font(.system(size: 24)) - } - } - - Spacer() - - VStack(alignment: .trailing) { - Text("\(timeFormatter.string(from: context.state.date))") - .font(.subheadline) - - Text("\(context.state.delta)") - .font(.subheadline) + Text(glucoseFormatter.string(from: context.state.currentGlucose) ?? "??") + .foregroundStyle(getGlucoseColor(context.state.currentGlucose)) + .minimumScaleFactor(0.1) } } } - @ViewBuilder - private func topRowCompactView(_ context: ActivityViewContext) -> some View { - HStack(spacing: 20) { - loopIcon(context) - ChartView(glucoseSamples: context.state.glucoseSamples) - } - } - @ViewBuilder private func loopIcon(_ context: ActivityViewContext) -> some View { Circle() @@ -158,14 +134,9 @@ struct GlucoseLiveActivityConfiguration: Widget { private func bottomItemCurrentBG(title: String, value: String, trend: GlucoseTrend?) -> some View { VStack(alignment: .center) { HStack { - Text(value) + Text(value + getArrowImage(trend)) .font(.title) .fontWeight(.heavy) - - if let trend = trend, let trendImageName = getArrowImage(trend) { - Image(systemName: trendImageName) - .font(.system(size: 24)) - } } } } @@ -176,32 +147,29 @@ struct GlucoseLiveActivityConfiguration: Widget { if (border) { Divider() .background(.secondary) - .padding(.vertical, 10) Spacer() } } - private func getArrowImage(_ trendType: GlucoseTrend?) -> String? { + private func getArrowImage(_ trendType: GlucoseTrend?) -> String { switch trendType { case .upUpUp: -// return "arrow.double.up" -> This one isn't available anymore - return "arrow.up" + return "\u{2191}\u{2191}" // ↑↑ case .upUp: - return "arrow.up" + return "\u{2191}" // ↑ case .up: - return "arrow.up.right" + return "\u{2197}" // ↗ case .flat: - return "arrow.right" + return "\u{2192}" // → case .down: - return "arrow.down.right" + return "\u{2198}" // ↘ case .downDown: - return "arrow.down" + return "\u{2193}" // ↓ case .downDownDown: -// return "arrow.double.down.circle" -> This one isn't available anymore - return "arrow.down" + return "\u{2193}\u{2193}" // ↓↓ case .none: - return nil + return "" } } @@ -220,4 +188,16 @@ struct GlucoseLiveActivityConfiguration: Widget { return .red } } + + private func getGlucoseColor(_ value: Double) -> Color { + if value < 4 { + return .red + } + + if value > 10 { + return .orange + } + + return .green + } } diff --git a/Loop Widget Extension/LoopWidgets.swift b/Loop Widget Extension/LoopWidgets.swift index 0b00c85237..684bf07355 100644 --- a/Loop Widget Extension/LoopWidgets.swift +++ b/Loop Widget Extension/LoopWidgets.swift @@ -15,6 +15,5 @@ struct LoopWidgets: WidgetBundle { var body: some Widget { SystemStatusWidget() GlucoseLiveActivityConfiguration() - GlucoseChartLiveActivityConfiguration() } } diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index d0e794f379..91156524a9 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -408,7 +408,6 @@ B87539CB2C2B08430085A975 /* Bootstrap.swift in Sources */ = {isa = PBXBuildFile; fileRef = B87539CA2C2B08430085A975 /* Bootstrap.swift */; }; B87539CD2C2B46950085A975 /* ChartView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B87539CC2C2B46950085A975 /* ChartView.swift */; }; B87539CF2C2DCB770085A975 /* BasalViewActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = B87539CE2C2DCB770085A975 /* BasalViewActivity.swift */; }; - B87539D12C2DDE760085A975 /* GlucoseChartLiveActivityConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = B87539D02C2DDE760085A975 /* GlucoseChartLiveActivityConfiguration.swift */; }; B87D411D2C28A69600120877 /* GlucoseLiveActivityConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = B87D411C2C28A69600120877 /* GlucoseLiveActivityConfiguration.swift */; }; B87D411F2C28A85F00120877 /* ActivityKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = B87D411E2C28A85F00120877 /* ActivityKit.framework */; }; B8A937B82C29BA5900E38645 /* GlucoseActivityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8A937B72C29BA5900E38645 /* GlucoseActivityManager.swift */; }; @@ -1344,7 +1343,6 @@ B87539CA2C2B08430085A975 /* Bootstrap.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Bootstrap.swift; sourceTree = ""; }; B87539CC2C2B46950085A975 /* ChartView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChartView.swift; sourceTree = ""; }; B87539CE2C2DCB770085A975 /* BasalViewActivity.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BasalViewActivity.swift; sourceTree = ""; }; - B87539D02C2DDE760085A975 /* GlucoseChartLiveActivityConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseChartLiveActivityConfiguration.swift; sourceTree = ""; }; B87D411C2C28A69600120877 /* GlucoseLiveActivityConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseLiveActivityConfiguration.swift; sourceTree = ""; }; B87D411E2C28A85F00120877 /* ActivityKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = ActivityKit.framework; path = System/Library/Frameworks/ActivityKit.framework; sourceTree = SDKROOT; }; B8A937B72C29BA5900E38645 /* GlucoseActivityManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseActivityManager.swift; sourceTree = ""; }; @@ -2796,7 +2794,6 @@ isa = PBXGroup; children = ( B87D411C2C28A69600120877 /* GlucoseLiveActivityConfiguration.swift */, - B87539D02C2DDE760085A975 /* GlucoseChartLiveActivityConfiguration.swift */, B87539CC2C2B46950085A975 /* ChartView.swift */, B87539CE2C2DCB770085A975 /* BasalViewActivity.swift */, ); @@ -3684,7 +3681,6 @@ 84AA81D32A4A27A3000B658B /* LoopWidgets.swift in Sources */, B87539CF2C2DCB770085A975 /* BasalViewActivity.swift in Sources */, B87539CD2C2B46950085A975 /* ChartView.swift in Sources */, - B87539D12C2DDE760085A975 /* GlucoseChartLiveActivityConfiguration.swift in Sources */, 84AA81E32A4A36FB000B658B /* SystemActionLink.swift in Sources */, 14B1737828AEDC6C006CCD7C /* NSTimeInterval.swift in Sources */, 14B1737928AEDC6C006CCD7C /* NSUserDefaults+StatusExtension.swift in Sources */, diff --git a/Loop/Managers/Live Activity/GlucoseActivityAttributes.swift b/Loop/Managers/Live Activity/GlucoseActivityAttributes.swift index cc285ff1c6..a29a488b1f 100644 --- a/Loop/Managers/Live Activity/GlucoseActivityAttributes.swift +++ b/Loop/Managers/Live Activity/GlucoseActivityAttributes.swift @@ -16,27 +16,27 @@ public struct GlucoseActivityAttributes: ActivityAttributes { // Meta data public let date: Date - // Glucose view - public let glucose: String + // Dynamic island data + public let currentGlucose: Double public let trendType: GlucoseTrend? public let delta: String + public let isMmol: Bool + + // Loop circle public let isCloseLoop: Bool public let lastCompleted: Date? + // Bottom row public let bottomRow: [BottomRowItem] + + // Chart view public let glucoseSamples: [GlucoseSampleAttributes] - } - - public let mode: GlucoseActivityMode -} - -public struct GlucoseChartActivityAttributes: ActivityAttributes { - public struct ContentState: Codable, Hashable { public let predicatedGlucose: [Double] public let predicatedStartDate: Date? public let predicatedInterval: TimeInterval? - public let glucoseSamples: [GlucoseSampleAttributes] } + + public let addPredictiveLine: Bool } public struct BottomRowItem: Codable, Hashable { diff --git a/Loop/Managers/Live Activity/GlucoseActivityManager.swift b/Loop/Managers/Live Activity/GlucoseActivityManager.swift index bbff405083..559e962736 100644 --- a/Loop/Managers/Live Activity/GlucoseActivityManager.swift +++ b/Loop/Managers/Live Activity/GlucoseActivityManager.swift @@ -21,7 +21,6 @@ extension Notification.Name { class GlucoseActivityManager { private let activityInfo = ActivityAuthorizationInfo() private var activity: Activity? - private var activityChart: Activity? private let healthStore = HKHealthStore() private let glucoseStore: GlucoseStoreProtocol @@ -73,7 +72,6 @@ class GlucoseActivityManager { } initEmptyActivity() - initEmptyChartActivity() Task { await self.endUnknownActivities() @@ -86,7 +84,7 @@ class GlucoseActivityManager { public func update(glucose: GlucoseSampleValue?) { Task { - if self.needsRecreation() { + if self.needsRecreation(), await UIApplication.shared.applicationState == .active { // activity is no longer visible or old. End it and try to push the update again await endActivity() update(glucose: glucose) @@ -118,75 +116,63 @@ class GlucoseActivityManager { statusContext: statusContext, glucoseFormatter: glucoseFormatter ) + + var predicatedGlucose: [Double] = [] + if let samples = statusContext?.predictedGlucose?.values { + predicatedGlucose = samples + } let state = GlucoseActivityAttributes.ContentState( date: glucose.startDate, - glucose: glucoseFormatter.string(from: current) ?? "??", + currentGlucose: current, trendType: statusContext?.glucoseDisplay?.trendType, delta: delta, + isMmol: unit == HKUnit.millimolesPerLiter, isCloseLoop: statusContext?.isClosedLoop ?? false, lastCompleted: statusContext?.lastLoopCompleted, bottomRow: bottomRow, - glucoseSamples: glucoseSamples + glucoseSamples: glucoseSamples, + predicatedGlucose: predicatedGlucose, + predicatedStartDate: statusContext?.predictedGlucose?.startDate, + predicatedInterval: statusContext?.predictedGlucose?.interval ) await self.activity?.update(ActivityContent( state: state, - staleDate: min(state.date, Date.now).addingTimeInterval(.minutes(12)) + staleDate: Date.now.addingTimeInterval(60) )) - if let activityChart = self.activityChart { - var predicatedGlucose: [Double] = [] - if let samples = statusContext?.predictedGlucose?.values { - predicatedGlucose = samples - } - - let stateChart = GlucoseChartActivityAttributes.ContentState( - predicatedGlucose: predicatedGlucose, - predicatedStartDate: statusContext?.predictedGlucose?.startDate, - predicatedInterval: statusContext?.predictedGlucose?.interval, - glucoseSamples: glucoseSamples - ) - - await activityChart.update(ActivityContent( - state: stateChart, - staleDate: min(state.date, Date.now).addingTimeInterval(.minutes(12)) - )) - } - self.prevGlucoseSample = glucose } } @objc private func settingsChanged() { - let newSettings = UserDefaults.standard.liveActivity ?? LiveActivitySettings() - - // Update live activity if needed - if !newSettings.enabled, let activity = self.activity { - Task { + Task { + let newSettings = UserDefaults.standard.liveActivity ?? LiveActivitySettings() + + // Update live activity if needed + if !newSettings.enabled, let activity = self.activity { await activity.end(nil, dismissalPolicy: .immediate) self.activity = nil + } else if newSettings.enabled && self.activity == nil { + initEmptyActivity() } - } else if newSettings.enabled && self.activity == nil { - initEmptyActivity() - } - - if !newSettings.enabled, let activityChart = self.activityChart { - Task { - await activityChart.end(nil, dismissalPolicy: .immediate) - self.activityChart = nil + + if newSettings.addPredictiveLine != self.settings.addPredictiveLine { + await self.activity?.end(nil, dismissalPolicy: .immediate) + self.activity = nil + + initEmptyActivity() } - } else if newSettings.enabled && self.activityChart == nil { - initEmptyChartActivity() + + update() + self.settings = newSettings } - - update() - self.settings = newSettings } private func endUnknownActivities() async { for unknownActivity in Activity.activities - .filter({ self.activity?.id != $0.id && self.activityChart?.id != $0.id }) + .filter({ self.activity?.id != $0.id }) { await unknownActivity.end(nil, dismissalPolicy: .immediate) } @@ -194,10 +180,8 @@ class GlucoseActivityManager { private func endActivity() async { let dynamicState = self.activity?.content.state - let dynamicChartState = self.activityChart?.content.state await self.activity?.end(nil, dismissalPolicy: .immediate) - await self.activityChart?.end(nil, dismissalPolicy: .immediate) for unknownActivity in Activity.activities { await unknownActivity.end(nil, dismissalPolicy: .immediate) } @@ -205,20 +189,11 @@ class GlucoseActivityManager { do { if let dynamicState = dynamicState { self.activity = try Activity.request( - attributes: GlucoseActivityAttributes(mode: self.settings.mode), + attributes: GlucoseActivityAttributes(addPredictiveLine: self.settings.addPredictiveLine), content: .init(state: dynamicState, staleDate: nil), pushType: .token ) } - - - if let dynamicChartState = dynamicChartState { - self.activityChart = try Activity.request( - attributes: GlucoseChartActivityAttributes(), - content: .init(state: dynamicChartState, staleDate: nil), - pushType: .token - ) - } self.startDate = Date.now } catch {} @@ -267,12 +242,19 @@ class GlucoseActivityManager { var samples: [GlucoseSampleAttributes] = [] updateGroup.enter() - self.glucoseStore.getGlucoseSamples(start: Date.now.addingTimeInterval(.hours(-1)), end: Date.now) { result in + + // When in spacious mode, we want to show the predictive line + // In compact mode, we only want to show the history + let timeInterval: TimeInterval = self.settings.addPredictiveLine ? .hours(-2) : .hours(-6) + self.glucoseStore.getGlucoseSamples( + start: Date.now.addingTimeInterval(timeInterval), + end: Date.now + ) { result in switch (result) { case .failure: break case .success(let data): - samples = data.suffix(30).map { item in + samples = data.suffix(100).map { item in return GlucoseSampleAttributes(x: item.startDate, y: item.quantity.doubleValue(for: unit)) } break @@ -328,39 +310,24 @@ class GlucoseActivityManager { do { let dynamicState = GlucoseActivityAttributes.ContentState( date: Date.now, - glucose: "--", + currentGlucose: 0, trendType: nil, delta: "", + isMmol: true, isCloseLoop: false, lastCompleted: nil, bottomRow: [], - glucoseSamples: [] + glucoseSamples: [], + predicatedGlucose: [], + predicatedStartDate: nil, + predicatedInterval: nil ) self.activity = try Activity.request( - attributes: GlucoseActivityAttributes(mode: self.settings.mode), + attributes: GlucoseActivityAttributes(addPredictiveLine: self.settings.addPredictiveLine), content: .init(state: dynamicState, staleDate: nil), pushType: .token ) } catch {} } - - private func initEmptyChartActivity() { - do { - if self.settings.mode == .spacious { - let dynamicChartState = GlucoseChartActivityAttributes.ContentState( - predicatedGlucose: [], - predicatedStartDate: nil, - predicatedInterval: nil, - glucoseSamples: [] - ) - - self.activityChart = try Activity.request( - attributes: GlucoseChartActivityAttributes(), - content: .init(state: dynamicChartState, staleDate: nil), - pushType: .token - ) - } - } catch {} - } } diff --git a/LoopCore/LiveActivitySettings.swift b/LoopCore/LiveActivitySettings.swift index 2c6a348c0c..ffca265806 100644 --- a/LoopCore/LiveActivitySettings.swift +++ b/LoopCore/LiveActivitySettings.swift @@ -17,23 +17,17 @@ public enum BottomRowConfiguration: Codable { case deltaBg case updatedAt - static let defaults: [BottomRowConfiguration] = [.iob, .cob, .basal, .eventualBg] //[.currentBg, .iob, .cob, .updatedAt] -} - -public enum GlucoseActivityMode: Codable, Hashable { - case compact - case spacious + static let defaults: [BottomRowConfiguration] = [.currentBg, .iob, .cob, .updatedAt]//[.iob, .cob, .basal, .eventualBg] //[.currentBg, .iob, .cob, .updatedAt] } public struct LiveActivitySettings: Codable { public var enabled: Bool - - public var mode: GlucoseActivityMode + public var addPredictiveLine: Bool public var bottomRowConfiguration: [BottomRowConfiguration] private enum CodingKeys: String, CodingKey { case enabled - case mode + case addPredictiveLine case bottomRowConfiguration } @@ -43,13 +37,13 @@ public struct LiveActivitySettings: Codable { // mode = try values.decode(GlucoseActivityMode.self, forKey: .mode) // bottomRowConfiguration = try values.decode([BottomRowConfiguration].self, forKey: .bottomRowConfiguration) self.enabled = true - self.mode = .spacious + self.addPredictiveLine = true self.bottomRowConfiguration = BottomRowConfiguration.defaults } public init() { self.enabled = true - self.mode = .spacious + self.addPredictiveLine = true self.bottomRowConfiguration = BottomRowConfiguration.defaults } } From e7fe0a8e6671872b70dd8a4f83710b4d8456cab9 Mon Sep 17 00:00:00 2001 From: bastiaanv Date: Sat, 6 Jul 2024 21:46:03 +0200 Subject: [PATCH 05/33] wip: Continue live activity. TODO's expanded view di, localization --- .../Live Activity/ChartView.swift | 2 +- .../GlucoseLiveActivityConfiguration.swift | 5 +- Loop.xcodeproj/project.pbxproj | 4 + .../GlucoseActivityManager.swift | 33 ++--- .../LiveActivityBottomRowManagerView.swift | 117 ++++++++++++++++++ Loop/Views/LiveActivityManagementView.swift | 46 ++++--- LoopCore/LiveActivitySettings.swift | 52 ++++++-- 7 files changed, 211 insertions(+), 48 deletions(-) create mode 100644 Loop/Views/LiveActivityBottomRowManagerView.swift diff --git a/Loop Widget Extension/Live Activity/ChartView.swift b/Loop Widget Extension/Live Activity/ChartView.swift index cdad906dbf..9936a6a452 100644 --- a/Loop Widget Extension/Live Activity/ChartView.swift +++ b/Loop Widget Extension/Live Activity/ChartView.swift @@ -100,7 +100,7 @@ struct ChartValues: Identifiable { } static func convert(data: [Double], startDate: Date, interval: TimeInterval, lowerLimit: Double, upperLimit: Double) -> [ChartValues] { - let twoHours = Date.now.addingTimeInterval(.hours(2)) + let twoHours = Date.now.addingTimeInterval(.hours(4)) return data.enumerated().filter { (index, item) in return startDate.addingTimeInterval(interval * Double(index)) < twoHours diff --git a/Loop Widget Extension/Live Activity/GlucoseLiveActivityConfiguration.swift b/Loop Widget Extension/Live Activity/GlucoseLiveActivityConfiguration.swift index 4b08fba404..86deaa0f9e 100644 --- a/Loop Widget Extension/Live Activity/GlucoseLiveActivityConfiguration.swift +++ b/Loop Widget Extension/Live Activity/GlucoseLiveActivityConfiguration.swift @@ -53,7 +53,7 @@ struct GlucoseLiveActivityConfiguration: Widget { switch (item.type) { case .generic: bottomItemGeneric( - title: LocalizedString(item.label, comment: "No comment"), + title: item.label, value: item.value, unit: LocalizedString(item.unit, comment: "No comment") ) @@ -63,7 +63,6 @@ struct GlucoseLiveActivityConfiguration: Widget { case .currentBg: bottomItemCurrentBG( - title: LocalizedString(item.label, comment: "No comment"), value: item.value, trend: item.trend ) @@ -131,7 +130,7 @@ struct GlucoseLiveActivityConfiguration: Widget { } @ViewBuilder - private func bottomItemCurrentBG(title: String, value: String, trend: GlucoseTrend?) -> some View { + private func bottomItemCurrentBG(value: String, trend: GlucoseTrend?) -> some View { VStack(alignment: .center) { HStack { Text(value + getArrowImage(trend)) diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index 91156524a9..606155f7d1 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -413,6 +413,7 @@ B8A937B82C29BA5900E38645 /* GlucoseActivityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8A937B72C29BA5900E38645 /* GlucoseActivityManager.swift */; }; B8A937C32C29C3B400E38645 /* GlucoseActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8A937C02C29C29300E38645 /* GlucoseActivityAttributes.swift */; }; B8A937C42C29C43B00E38645 /* GlucoseActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8A937C02C29C29300E38645 /* GlucoseActivityAttributes.swift */; }; + B8FD0B522C39CA3E003FB72B /* LiveActivityBottomRowManagerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8FD0B512C39CA3E003FB72B /* LiveActivityBottomRowManagerView.swift */; }; C1004DF22981F5B700B8CF94 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = C1004DF02981F5B700B8CF94 /* InfoPlist.strings */; }; C1004DF52981F5B700B8CF94 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = C1004DF32981F5B700B8CF94 /* Localizable.strings */; }; C1004DF82981F5B700B8CF94 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = C1004DF62981F5B700B8CF94 /* InfoPlist.strings */; }; @@ -1347,6 +1348,7 @@ B87D411E2C28A85F00120877 /* ActivityKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = ActivityKit.framework; path = System/Library/Frameworks/ActivityKit.framework; sourceTree = SDKROOT; }; B8A937B72C29BA5900E38645 /* GlucoseActivityManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseActivityManager.swift; sourceTree = ""; }; B8A937C02C29C29300E38645 /* GlucoseActivityAttributes.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GlucoseActivityAttributes.swift; sourceTree = ""; }; + B8FD0B512C39CA3E003FB72B /* LiveActivityBottomRowManagerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActivityBottomRowManagerView.swift; sourceTree = ""; }; C1004DEF2981F5B700B8CF94 /* da */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = da; path = da.lproj/InfoPlist.strings; sourceTree = ""; }; C1004DF12981F5B700B8CF94 /* da */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = da; path = da.lproj/InfoPlist.strings; sourceTree = ""; }; C1004DF42981F5B700B8CF94 /* da */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = da; path = da.lproj/Localizable.strings; sourceTree = ""; }; @@ -2302,6 +2304,7 @@ DDC389FD2A2C4C830066E2E8 /* GlucoseBasedApplicationFactorSelectionView.swift */, DD3DBD282A33AFE9000F8B5B /* IntegralRetrospectiveCorrectionSelectionView.swift */, B851FFC42C37221800D738C1 /* LiveActivityManagementView.swift */, + B8FD0B512C39CA3E003FB72B /* LiveActivityBottomRowManagerView.swift */, ); path = Views; sourceTree = ""; @@ -3780,6 +3783,7 @@ B4E202302661063E009421B5 /* AutomaticDosingStatus.swift in Sources */, C191D2A125B3ACAA00C26C0B /* DosingStrategySelectionView.swift in Sources */, A977A2F424ACFECF0059C207 /* CriticalEventLogExportManager.swift in Sources */, + B8FD0B522C39CA3E003FB72B /* LiveActivityBottomRowManagerView.swift in Sources */, 89CA2B32226C18B8004D9350 /* TestingScenariosTableViewController.swift in Sources */, 43E93FB71E469A5100EAB8DB /* HKUnit.swift in Sources */, 43C05CAF21EB2C24006FB252 /* NSBundle.swift in Sources */, diff --git a/Loop/Managers/Live Activity/GlucoseActivityManager.swift b/Loop/Managers/Live Activity/GlucoseActivityManager.swift index 559e962736..b0bc16cd99 100644 --- a/Loop/Managers/Live Activity/GlucoseActivityManager.swift +++ b/Loop/Managers/Live Activity/GlucoseActivityManager.swift @@ -71,7 +71,7 @@ class GlucoseActivityManager { return } - initEmptyActivity() + initEmptyActivity(settings: self.settings) Task { await self.endUnknownActivities() @@ -154,19 +154,20 @@ class GlucoseActivityManager { if !newSettings.enabled, let activity = self.activity { await activity.end(nil, dismissalPolicy: .immediate) self.activity = nil + + return } else if newSettings.enabled && self.activity == nil { - initEmptyActivity() - } - - if newSettings.addPredictiveLine != self.settings.addPredictiveLine { + initEmptyActivity(settings: newSettings) + + } else if newSettings.addPredictiveLine != self.settings.addPredictiveLine { await self.activity?.end(nil, dismissalPolicy: .immediate) self.activity = nil - initEmptyActivity() + initEmptyActivity(settings: newSettings) } - update() self.settings = newSettings + update() } } @@ -271,14 +272,14 @@ class GlucoseActivityManager { return self.settings.bottomRowConfiguration.map { type in switch(type) { case .iob: - return BottomRowItem(label: "IOB", value: getInsulinOnBoard(), unit: "U") + return BottomRowItem(label: type.name(), value: getInsulinOnBoard(), unit: "U") case .cob: var cob: String = "0" if let cobValue = statusContext?.carbsOnBoard { cob = self.cobFormatter.string(from: cobValue) ?? "??" } - return BottomRowItem(label: "COB", value: cob, unit: "g") + return BottomRowItem(label: type.name(), value: cob, unit: "g") case .basal: guard let netBasalContext = statusContext?.netBasal else { @@ -288,25 +289,25 @@ class GlucoseActivityManager { return BottomRowItem(rate: netBasalContext.rate, percentage: netBasalContext.percentage) case .currentBg: - return BottomRowItem(label: "Current", value: "\(glucoseFormatter.string(from: currentGlucose) ?? "??")", trend: statusContext?.glucoseDisplay?.trendType) + return BottomRowItem(label: type.name(), value: "\(glucoseFormatter.string(from: currentGlucose) ?? "??")", trend: statusContext?.glucoseDisplay?.trendType) case .eventualBg: guard let eventual = statusContext?.predictedGlucose?.values.last else { - return BottomRowItem(label: "Event.", value: "??", unit: "") + return BottomRowItem(label: type.name(), value: "??", unit: "") } - return BottomRowItem(label: "Event.", value: glucoseFormatter.string(from: eventual) ?? "??", unit: "") + return BottomRowItem(label: type.name(), value: glucoseFormatter.string(from: eventual) ?? "??", unit: "") case .deltaBg: - return BottomRowItem(label: "Delta", value: delta, unit: "") + return BottomRowItem(label: type.name(), value: delta, unit: "") case .updatedAt: - return BottomRowItem(label: "Updated", value: timeFormatter.string(from: Date.now), unit: "") + return BottomRowItem(label: type.name(), value: timeFormatter.string(from: Date.now), unit: "") } } } - private func initEmptyActivity() { + private func initEmptyActivity(settings: LiveActivitySettings) { do { let dynamicState = GlucoseActivityAttributes.ContentState( date: Date.now, @@ -324,7 +325,7 @@ class GlucoseActivityManager { ) self.activity = try Activity.request( - attributes: GlucoseActivityAttributes(addPredictiveLine: self.settings.addPredictiveLine), + attributes: GlucoseActivityAttributes(addPredictiveLine: settings.addPredictiveLine), content: .init(state: dynamicState, staleDate: nil), pushType: .token ) diff --git a/Loop/Views/LiveActivityBottomRowManagerView.swift b/Loop/Views/LiveActivityBottomRowManagerView.swift new file mode 100644 index 0000000000..49e50caa67 --- /dev/null +++ b/Loop/Views/LiveActivityBottomRowManagerView.swift @@ -0,0 +1,117 @@ +// +// LiveActivityBottomRowManagerView.swift +// Loop +// +// Created by Bastiaan Verhaar on 06/07/2024. +// Copyright © 2024 LoopKit Authors. All rights reserved. +// + +import LoopKitUI +import LoopCore +import SwiftUI + +struct LiveActivityBottomRowManagerView: View { + @Environment(\.presentationMode) var presentationMode: Binding + + // The maximum items in the bottom row + private let maxSize = 4 + + @State var showAdd: Bool = false + @State var configuration: [BottomRowConfiguration] = (UserDefaults.standard.liveActivity ?? LiveActivitySettings()).bottomRowConfiguration + + var addItem: ActionSheet { + var buttons: [ActionSheet.Button] = BottomRowConfiguration.all.map { item in + ActionSheet.Button.default(Text(item.description())) { + configuration.append(item) + } + } + buttons.append(.cancel(Text(NSLocalizedString("Cancel", comment: "Button text to cancel")))) + + return ActionSheet(title: Text(NSLocalizedString("Add item to bottom row", comment: "Title for Add item")), buttons: buttons) + } + + var body: some View { + List { + ForEach($configuration, id: \.self) { item in + HStack { + deleteButton + .onTapGesture { + onDelete(item.wrappedValue) + } + Text(item.wrappedValue.description()) + + Spacer() + editBars + } + } + .onMove(perform: onReorder) + .deleteDisabled(true) + + Section { + Button(action: onSave) { + Text(NSLocalizedString("Save", comment: "")) + } + .buttonStyle(ActionButtonStyle()) + .listRowInsets(EdgeInsets()) + } + } + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button( + action: { showAdd = true }, + label: { Image(systemName: "plus") } + ) + .disabled(configuration.count >= self.maxSize) + } + } + .actionSheet(isPresented: $showAdd, content: { addItem }) + .insetGroupedListStyle() + .navigationBarTitle(Text(NSLocalizedString("Bottom row", comment: "Live activity Bottom row configuration title"))) + } + + @ViewBuilder + private var deleteButton: some View { + ZStack { + Color.red + .clipShape(RoundedRectangle(cornerRadius: 12.5)) + .frame(width: 20, height: 20) + + Image(systemName: "minus") + .foregroundColor(.white) + } + .contentShape(Rectangle()) + } + + @ViewBuilder + private var editBars: some View { + Image(systemName: "line.3.horizontal") + .foregroundColor(Color(UIColor.tertiaryLabel)) + .font(.title2) + } + + private func onSave() { + var settings = UserDefaults.standard.liveActivity ?? LiveActivitySettings() + settings.bottomRowConfiguration = configuration + + UserDefaults.standard.liveActivity = settings + NotificationCenter.default.post(name: .LiveActivitySettingsChanged, object: settings) + + self.presentationMode.wrappedValue.dismiss() + } + + func onReorder(from: IndexSet, to: Int) { + withAnimation { + configuration.move(fromOffsets: from, toOffset: to) + } + } + + func onDelete(_ item: BottomRowConfiguration) { + withAnimation { + _ = configuration.remove(item) + } + } +} + +#Preview { + LiveActivityBottomRowManagerView() +} diff --git a/Loop/Views/LiveActivityManagementView.swift b/Loop/Views/LiveActivityManagementView.swift index 5a8fa241c9..1acce54547 100644 --- a/Loop/Views/LiveActivityManagementView.swift +++ b/Loop/Views/LiveActivityManagementView.swift @@ -7,40 +7,46 @@ // import SwiftUI +import LoopKitUI import LoopCore struct LiveActivityManagementView: View { - - private var enabled: Binding { + private var enabled: Binding = Binding( - get: { UserDefaults.standard.liveActivity?.enabled ?? false }, + get: { (UserDefaults.standard.liveActivity ?? LiveActivitySettings()).enabled }, set: { newValue in - mutate { settings in - settings.enabled = newValue - } + var settings = UserDefaults.standard.liveActivity ?? LiveActivitySettings() + settings.enabled = newValue + + UserDefaults.standard.liveActivity = settings + NotificationCenter.default.post(name: .LiveActivitySettingsChanged, object: settings) } ) - } + private var addPredictiveLine: Binding = + Binding( + get: { (UserDefaults.standard.liveActivity ?? LiveActivitySettings()).addPredictiveLine }, + set: { newValue in + var settings = UserDefaults.standard.liveActivity ?? LiveActivitySettings() + settings.addPredictiveLine = newValue + + UserDefaults.standard.liveActivity = settings + NotificationCenter.default.post(name: .LiveActivitySettingsChanged, object: settings) + } + ) + var body: some View { List { - Toggle(NSLocalizedString("Enabled", comment: "Title for missed meal notifications toggle"), isOn: enabled) + Toggle(NSLocalizedString("Enabled", comment: "Title for enable live activity toggle"), isOn: enabled) + Toggle(NSLocalizedString("Add predictive line", comment: "Title for predictive line toggle"), isOn: addPredictiveLine) + NavigationLink( + destination: LiveActivityBottomRowManagerView(), + label: { Text(NSLocalizedString("Bottom row configuration", comment: "Title for Bottom row configuration")) } + ) } .insetGroupedListStyle() .navigationBarTitle(Text(NSLocalizedString("Live activity", comment: "Live activity screen title"))) } - - func mutate(_ updater: (inout LiveActivitySettings) -> Void) { - var settings = UserDefaults.standard.liveActivity - if settings == nil { - settings = LiveActivitySettings() - } - - updater(&settings!) - - UserDefaults.standard.liveActivity = settings - NotificationCenter.default.post(name: .LiveActivitySettingsChanged, object: settings) - } } #Preview { diff --git a/LoopCore/LiveActivitySettings.swift b/LoopCore/LiveActivitySettings.swift index ffca265806..63436f92ed 100644 --- a/LoopCore/LiveActivitySettings.swift +++ b/LoopCore/LiveActivitySettings.swift @@ -17,7 +17,46 @@ public enum BottomRowConfiguration: Codable { case deltaBg case updatedAt - static let defaults: [BottomRowConfiguration] = [.currentBg, .iob, .cob, .updatedAt]//[.iob, .cob, .basal, .eventualBg] //[.currentBg, .iob, .cob, .updatedAt] + static let defaults: [BottomRowConfiguration] = [.currentBg, .iob, .cob, .updatedAt] + public static let all: [BottomRowConfiguration] = [.iob, .cob, .basal, .currentBg, .eventualBg, .deltaBg, .updatedAt] + + public func name() -> String { + switch self { + case .iob: + return NSLocalizedString("IOB", comment: "") + case .cob: + return NSLocalizedString("COB", comment: "") + case .basal: + return NSLocalizedString("Basal", comment: "") + case .currentBg: + return NSLocalizedString("Current BG", comment: "") + case .eventualBg: + return NSLocalizedString("Event", comment: "") + case .deltaBg: + return NSLocalizedString("Delta", comment: "") + case .updatedAt: + return NSLocalizedString("Updated", comment: "") + } + } + + public func description() -> String { + switch self { + case .iob: + return NSLocalizedString("Active Insulin", comment: "") + case .cob: + return NSLocalizedString("Active Carbohydrates", comment: "") + case .basal: + return NSLocalizedString("Basal", comment: "") + case .currentBg: + return NSLocalizedString("Current Glucose", comment: "") + case .eventualBg: + return NSLocalizedString("Eventually", comment: "") + case .deltaBg: + return NSLocalizedString("Delta", comment: "") + case .updatedAt: + return NSLocalizedString("Updated at", comment: "") + } + } } public struct LiveActivitySettings: Codable { @@ -32,13 +71,10 @@ public struct LiveActivitySettings: Codable { } public init(from decoder:Decoder) throws { -// let values = try decoder.container(keyedBy: CodingKeys.self) -// enabled = try values.decode(Bool.self, forKey: .enabled) -// mode = try values.decode(GlucoseActivityMode.self, forKey: .mode) -// bottomRowConfiguration = try values.decode([BottomRowConfiguration].self, forKey: .bottomRowConfiguration) - self.enabled = true - self.addPredictiveLine = true - self.bottomRowConfiguration = BottomRowConfiguration.defaults + let values = try decoder.container(keyedBy: CodingKeys.self) + enabled = try values.decode(Bool.self, forKey: .enabled) + addPredictiveLine = try values.decode(Bool.self, forKey: .addPredictiveLine) + bottomRowConfiguration = try values.decode([BottomRowConfiguration].self, forKey: .bottomRowConfiguration) } public init() { From 1a851665c7dcd25e4ff897c3d42b0ccb29e09cf3 Mon Sep 17 00:00:00 2001 From: bastiaanv Date: Sun, 7 Jul 2024 18:49:32 +0200 Subject: [PATCH 06/33] fix: Allow configurable chart limits --- .../Live Activity/ChartView.swift | 23 ++--- .../GlucoseLiveActivityConfiguration.swift | 10 +- .../GlucoseActivityAttributes.swift | 4 + .../GlucoseActivityManager.swift | 24 ++++- Loop/Views/AlertManagementView.swift | 13 ++- Loop/Views/LiveActivityManagementView.swift | 99 ++++++++++++++++++- LoopCore/LiveActivitySettings.swift | 21 ++++ 7 files changed, 166 insertions(+), 28 deletions(-) diff --git a/Loop Widget Extension/Live Activity/ChartView.swift b/Loop Widget Extension/Live Activity/ChartView.swift index 9936a6a452..72314b160e 100644 --- a/Loop Widget Extension/Live Activity/ChartView.swift +++ b/Loop Widget Extension/Live Activity/ChartView.swift @@ -13,32 +13,21 @@ import Charts struct ChartView: View { private let glucoseSampleData: [ChartValues] private let predicatedData: [ChartValues] - private let lowerBoundY: Double - private let upperBoundY: Double - private let high = Double(10) - private let low = Double(4) - - init(glucoseSamples: [GlucoseSampleAttributes], predicatedGlucose: [Double], predicatedStartDate: Date?, predicatedInterval: TimeInterval?) { - self.glucoseSampleData = ChartValues.convert(data: glucoseSamples, lowerLimit: low, upperLimit: high) + init(glucoseSamples: [GlucoseSampleAttributes], predicatedGlucose: [Double], predicatedStartDate: Date?, predicatedInterval: TimeInterval?, lowerLimit: Double, upperLimit: Double) { + self.glucoseSampleData = ChartValues.convert(data: glucoseSamples, lowerLimit: lowerLimit, upperLimit: upperLimit) self.predicatedData = ChartValues.convert( data: predicatedGlucose, startDate: predicatedStartDate ?? Date.now, interval: predicatedInterval ?? .minutes(5), - lowerLimit: low, - upperLimit: high + lowerLimit: lowerLimit, + upperLimit: upperLimit ) - - self.lowerBoundY = min(4, glucoseSampleData.min { $0.y < $1.y }?.y ?? 0, predicatedData.min { $0.y < $1.y }?.y ?? 0) - self.upperBoundY = max(10, glucoseSampleData.max { $0.y < $1.y }?.y ?? 0, predicatedData.max { $0.y < $1.y }?.y ?? 0) } - init(glucoseSamples: [GlucoseSampleAttributes]) { - self.glucoseSampleData = ChartValues.convert(data: glucoseSamples, lowerLimit: low, upperLimit: high) + init(glucoseSamples: [GlucoseSampleAttributes], lowerLimit: Double, upperLimit: Double) { + self.glucoseSampleData = ChartValues.convert(data: glucoseSamples, lowerLimit: lowerLimit, upperLimit: upperLimit) self.predicatedData = [] - - self.lowerBoundY = min(4, glucoseSampleData.min { $0.y < $1.y }?.y ?? 0) - self.upperBoundY = max(10, glucoseSampleData.max { $0.y < $1.y }?.y ?? 0) } var body: some View { diff --git a/Loop Widget Extension/Live Activity/GlucoseLiveActivityConfiguration.swift b/Loop Widget Extension/Live Activity/GlucoseLiveActivityConfiguration.swift index 86deaa0f9e..fe8cd16389 100644 --- a/Loop Widget Extension/Live Activity/GlucoseLiveActivityConfiguration.swift +++ b/Loop Widget Extension/Live Activity/GlucoseLiveActivityConfiguration.swift @@ -36,11 +36,17 @@ struct GlucoseLiveActivityConfiguration: Widget { glucoseSamples: context.state.glucoseSamples, predicatedGlucose: context.state.predicatedGlucose, predicatedStartDate: context.state.predicatedStartDate, - predicatedInterval: context.state.predicatedInterval + predicatedInterval: context.state.predicatedInterval, + lowerLimit: context.state.isMmol ? context.attributes.lowerLimitChartMmol : context.attributes.lowerLimitChartMg, + upperLimit: context.state.isMmol ? context.attributes.upperLimitChartMmol : context.attributes.upperLimitChartMg ) .frame(height: 85) } else { - ChartView(glucoseSamples: context.state.glucoseSamples) + ChartView( + glucoseSamples: context.state.glucoseSamples, + lowerLimit: context.state.isMmol ? context.attributes.lowerLimitChartMmol : context.attributes.lowerLimitChartMg, + upperLimit: context.state.isMmol ? context.attributes.upperLimitChartMmol : context.attributes.upperLimitChartMg + ) .frame(height: 85) } } diff --git a/Loop/Managers/Live Activity/GlucoseActivityAttributes.swift b/Loop/Managers/Live Activity/GlucoseActivityAttributes.swift index a29a488b1f..e46abe334a 100644 --- a/Loop/Managers/Live Activity/GlucoseActivityAttributes.swift +++ b/Loop/Managers/Live Activity/GlucoseActivityAttributes.swift @@ -37,6 +37,10 @@ public struct GlucoseActivityAttributes: ActivityAttributes { } public let addPredictiveLine: Bool + public let upperLimitChartMmol: Double + public let lowerLimitChartMmol: Double + public let upperLimitChartMg: Double + public let lowerLimitChartMg: Double } public struct BottomRowItem: Codable, Hashable { diff --git a/Loop/Managers/Live Activity/GlucoseActivityManager.swift b/Loop/Managers/Live Activity/GlucoseActivityManager.swift index b0bc16cd99..4716bf13ea 100644 --- a/Loop/Managers/Live Activity/GlucoseActivityManager.swift +++ b/Loop/Managers/Live Activity/GlucoseActivityManager.swift @@ -159,7 +159,13 @@ class GlucoseActivityManager { } else if newSettings.enabled && self.activity == nil { initEmptyActivity(settings: newSettings) - } else if newSettings.addPredictiveLine != self.settings.addPredictiveLine { + } else if + newSettings.addPredictiveLine != self.settings.addPredictiveLine || + newSettings.lowerLimitChartMmol != self.settings.lowerLimitChartMmol || + newSettings.upperLimitChartMmol != self.settings.upperLimitChartMmol || + newSettings.lowerLimitChartMg != self.settings.lowerLimitChartMg || + newSettings.upperLimitChartMg != self.settings.upperLimitChartMg + { await self.activity?.end(nil, dismissalPolicy: .immediate) self.activity = nil @@ -190,7 +196,13 @@ class GlucoseActivityManager { do { if let dynamicState = dynamicState { self.activity = try Activity.request( - attributes: GlucoseActivityAttributes(addPredictiveLine: self.settings.addPredictiveLine), + attributes: GlucoseActivityAttributes( + addPredictiveLine: self.settings.addPredictiveLine, + upperLimitChartMmol: self.settings.upperLimitChartMmol, + lowerLimitChartMmol: self.settings.lowerLimitChartMmol, + upperLimitChartMg: self.settings.upperLimitChartMg, + lowerLimitChartMg: self.settings.lowerLimitChartMg + ), content: .init(state: dynamicState, staleDate: nil), pushType: .token ) @@ -325,7 +337,13 @@ class GlucoseActivityManager { ) self.activity = try Activity.request( - attributes: GlucoseActivityAttributes(addPredictiveLine: settings.addPredictiveLine), + attributes: GlucoseActivityAttributes( + addPredictiveLine: settings.addPredictiveLine, + upperLimitChartMmol: self.settings.upperLimitChartMmol, + lowerLimitChartMmol: self.settings.lowerLimitChartMmol, + upperLimitChartMg: self.settings.upperLimitChartMg, + lowerLimitChartMg: self.settings.lowerLimitChartMg + ), content: .init(state: dynamicState, staleDate: nil), pushType: .token ) diff --git a/Loop/Views/AlertManagementView.swift b/Loop/Views/AlertManagementView.swift index 9620cc8cf8..139f04ddff 100644 --- a/Loop/Views/AlertManagementView.swift +++ b/Loop/Views/AlertManagementView.swift @@ -7,8 +7,10 @@ // import SwiftUI +import LoopCore import LoopKit import LoopKitUI +import HealthKit struct AlertManagementView: View { @Environment(\.appName) private var appName @@ -19,6 +21,7 @@ struct AlertManagementView: View { @State private var showMuteAlertOptions: Bool = false @State private var showHowMuteAlertWork: Bool = false + @State private var unit: HKUnit = HKUnit.millimolesPerLiter private var formatter: DateComponentsFormatter = { let formatter = DateComponentsFormatter() @@ -58,6 +61,14 @@ struct AlertManagementView: View { public init(checker: AlertPermissionsChecker, alertMuter: AlertMuter = AlertMuter()) { self.checker = checker self.alertMuter = alertMuter + + self.setUnit() + } + + private func setUnit() { + Task { + self.unit = await HKHealthStore().cachedPreferredUnits(for: .bloodGlucose) ?? HKUnit.millimolesPerLiter + } } var body: some View { @@ -158,7 +169,7 @@ struct AlertManagementView: View { } } - NavigationLink(destination: LiveActivityManagementView()) + NavigationLink(destination: LiveActivityManagementView(unit: self.unit)) { Text(NSLocalizedString("Live activity", comment: "Alert Permissions live activity")) } diff --git a/Loop/Views/LiveActivityManagementView.swift b/Loop/Views/LiveActivityManagementView.swift index 1acce54547..0f4d2dd421 100644 --- a/Loop/Views/LiveActivityManagementView.swift +++ b/Loop/Views/LiveActivityManagementView.swift @@ -9,8 +9,11 @@ import SwiftUI import LoopKitUI import LoopCore +import HealthKit struct LiveActivityManagementView: View { + let unit: HKUnit + private var enabled: Binding = Binding( get: { (UserDefaults.standard.liveActivity ?? LiveActivitySettings()).enabled }, @@ -35,20 +38,106 @@ struct LiveActivityManagementView: View { } ) + private var upperLimitMmol: Binding = + Binding( + get: { (UserDefaults.standard.liveActivity ?? LiveActivitySettings()).upperLimitChartMmol }, + set: { newValue in + var settings = UserDefaults.standard.liveActivity ?? LiveActivitySettings() + settings.upperLimitChartMmol = newValue + + UserDefaults.standard.liveActivity = settings + NotificationCenter.default.post(name: .LiveActivitySettingsChanged, object: settings) + + } + ) + + private var lowerLimitMmol: Binding = + Binding( + get: { (UserDefaults.standard.liveActivity ?? LiveActivitySettings()).lowerLimitChartMmol }, + set: { newValue in + var settings = UserDefaults.standard.liveActivity ?? LiveActivitySettings() + settings.lowerLimitChartMmol = newValue + + UserDefaults.standard.liveActivity = settings + NotificationCenter.default.post(name: .LiveActivitySettingsChanged, object: settings) + + } + ) + + private var upperLimitMg: Binding = + Binding( + get: { (UserDefaults.standard.liveActivity ?? LiveActivitySettings()).upperLimitChartMg }, + set: { newValue in + var settings = UserDefaults.standard.liveActivity ?? LiveActivitySettings() + settings.upperLimitChartMg = newValue + + UserDefaults.standard.liveActivity = settings + NotificationCenter.default.post(name: .LiveActivitySettingsChanged, object: settings) + + } + ) + + private var lowerLimitMg: Binding = + Binding( + get: { (UserDefaults.standard.liveActivity ?? LiveActivitySettings()).lowerLimitChartMg }, + set: { newValue in + var settings = UserDefaults.standard.liveActivity ?? LiveActivitySettings() + settings.lowerLimitChartMg = newValue + + UserDefaults.standard.liveActivity = settings + NotificationCenter.default.post(name: .LiveActivitySettingsChanged, object: settings) + + } + ) + + public init(unit: HKUnit) { + self.unit = unit + } + var body: some View { List { Toggle(NSLocalizedString("Enabled", comment: "Title for enable live activity toggle"), isOn: enabled) Toggle(NSLocalizedString("Add predictive line", comment: "Title for predictive line toggle"), isOn: addPredictiveLine) - NavigationLink( - destination: LiveActivityBottomRowManagerView(), - label: { Text(NSLocalizedString("Bottom row configuration", comment: "Title for Bottom row configuration")) } - ) + if self.unit == .millimolesPerLiter { + TextInput(label: "Upper limit chart", value: upperLimitMmol) + TextInput(label: "Lower limit chart", value: lowerLimitMmol) + } else { + TextInput(label: "Upper limit chart", value: upperLimitMg) + TextInput(label: "Lower limit chart", value: lowerLimitMg) + } + + Section { + NavigationLink( + destination: LiveActivityBottomRowManagerView(), + label: { Text(NSLocalizedString("Bottom row configuration", comment: "Title for Bottom row configuration")) } + ) + } } .insetGroupedListStyle() .navigationBarTitle(Text(NSLocalizedString("Live activity", comment: "Live activity screen title"))) } + + @ViewBuilder + private func TextInput(label: String, value: Binding) -> some View { + HStack { + Text(NSLocalizedString(label, comment: "no comment")) + Spacer() + TextField("", value: value, format: .number) + .multilineTextAlignment(.trailing) + Text(self.unit.localizedShortUnitString) + } + } + + private func mutate(_ updater: (inout LiveActivitySettings) -> Void) { + var settings = UserDefaults.standard.liveActivity ?? LiveActivitySettings() + + updater(&settings) + + UserDefaults.standard.liveActivity = settings + NotificationCenter.default.post(name: .LiveActivitySettingsChanged, object: settings) + } } #Preview { - LiveActivityManagementView() + LiveActivityManagementView(unit: HKUnit.millimolesPerLiter) } diff --git a/LoopCore/LiveActivitySettings.swift b/LoopCore/LiveActivitySettings.swift index 63436f92ed..89bff86e02 100644 --- a/LoopCore/LiveActivitySettings.swift +++ b/LoopCore/LiveActivitySettings.swift @@ -62,24 +62,45 @@ public enum BottomRowConfiguration: Codable { public struct LiveActivitySettings: Codable { public var enabled: Bool public var addPredictiveLine: Bool + public var upperLimitChartMmol: Double + public var lowerLimitChartMmol: Double + public var upperLimitChartMg: Double + public var lowerLimitChartMg: Double public var bottomRowConfiguration: [BottomRowConfiguration] private enum CodingKeys: String, CodingKey { case enabled case addPredictiveLine case bottomRowConfiguration + case upperLimitChartMmol + case lowerLimitChartMmol + case upperLimitChartMg + case lowerLimitChartMg } + private static let defaultUpperLimitMmol = Double(10) + private static let defaultLowerLimitMmol = Double(4) + private static let defaultUpperLimitMg = Double(72) + private static let defaultLowerLimitMg = Double(180) + public init(from decoder:Decoder) throws { let values = try decoder.container(keyedBy: CodingKeys.self) enabled = try values.decode(Bool.self, forKey: .enabled) addPredictiveLine = try values.decode(Bool.self, forKey: .addPredictiveLine) + upperLimitChartMmol = try values.decode(Double?.self, forKey: .upperLimitChartMmol) ?? LiveActivitySettings.defaultUpperLimitMmol + lowerLimitChartMmol = try values.decode(Double?.self, forKey: .lowerLimitChartMmol) ?? LiveActivitySettings.defaultLowerLimitMmol + upperLimitChartMg = try values.decode(Double?.self, forKey: .upperLimitChartMg) ?? LiveActivitySettings.defaultUpperLimitMg + lowerLimitChartMg = try values.decode(Double?.self, forKey: .lowerLimitChartMg) ?? LiveActivitySettings.defaultLowerLimitMg bottomRowConfiguration = try values.decode([BottomRowConfiguration].self, forKey: .bottomRowConfiguration) } public init() { self.enabled = true self.addPredictiveLine = true + self.upperLimitChartMmol = LiveActivitySettings.defaultUpperLimitMmol + self.lowerLimitChartMmol = LiveActivitySettings.defaultLowerLimitMmol + self.upperLimitChartMg = LiveActivitySettings.defaultUpperLimitMg + self.lowerLimitChartMg = LiveActivitySettings.defaultLowerLimitMg self.bottomRowConfiguration = BottomRowConfiguration.defaults } } From a836f7cc7ef351753d08b6e51b224f19f759adaa Mon Sep 17 00:00:00 2001 From: bastiaanv Date: Sun, 7 Jul 2024 20:31:33 +0200 Subject: [PATCH 07/33] feat: Add stale state, minor chart fixes & fix delta --- .../Live Activity/ChartView.swift | 2 +- .../GlucoseLiveActivityConfiguration.swift | 139 ++++++++++++------ .../GlucoseActivityManager.swift | 4 +- Loop/Managers/LoopDataManager.swift | 2 +- 4 files changed, 98 insertions(+), 49 deletions(-) diff --git a/Loop Widget Extension/Live Activity/ChartView.swift b/Loop Widget Extension/Live Activity/ChartView.swift index 72314b160e..274c986d9e 100644 --- a/Loop Widget Extension/Live Activity/ChartView.swift +++ b/Loop Widget Extension/Live Activity/ChartView.swift @@ -66,7 +66,7 @@ struct ChartView: View { } .chartXAxis { AxisMarks(position: .automatic, values: .stride(by: .hour)) { _ in - AxisValueLabel(format: .dateTime.hour(.twoDigits(amPM: .narrow)).minute(.twoDigits), anchor: .top) + AxisValueLabel(format: .dateTime.hour(.twoDigits(amPM: .narrow)), anchor: .top) .foregroundStyle(Color.primary) AxisGridLine(stroke: .init(lineWidth: 0.1, dash: [2, 3])) .foregroundStyle(Color.primary) diff --git a/Loop Widget Extension/Live Activity/GlucoseLiveActivityConfiguration.swift b/Loop Widget Extension/Live Activity/GlucoseLiveActivityConfiguration.swift index fe8cd16389..7d9696b617 100644 --- a/Loop Widget Extension/Live Activity/GlucoseLiveActivityConfiguration.swift +++ b/Loop Widget Extension/Live Activity/GlucoseLiveActivityConfiguration.swift @@ -28,58 +28,74 @@ struct GlucoseLiveActivityConfiguration: Widget { ActivityConfiguration(for: GlucoseActivityAttributes.self) { context in // Create the presentation that appears on the Lock Screen and as a // banner on the Home Screen of devices that don't support the Dynamic Island. - VStack { - HStack(spacing: 15) { - loopIcon(context) - if context.attributes.addPredictiveLine { - ChartView( - glucoseSamples: context.state.glucoseSamples, - predicatedGlucose: context.state.predicatedGlucose, - predicatedStartDate: context.state.predicatedStartDate, - predicatedInterval: context.state.predicatedInterval, - lowerLimit: context.state.isMmol ? context.attributes.lowerLimitChartMmol : context.attributes.lowerLimitChartMg, - upperLimit: context.state.isMmol ? context.attributes.upperLimitChartMmol : context.attributes.upperLimitChartMg - ) + ZStack { + VStack { + HStack(spacing: 15) { + loopIcon(context) + if context.attributes.addPredictiveLine { + ChartView( + glucoseSamples: context.state.glucoseSamples, + predicatedGlucose: context.state.predicatedGlucose, + predicatedStartDate: context.state.predicatedStartDate, + predicatedInterval: context.state.predicatedInterval, + lowerLimit: context.state.isMmol ? context.attributes.lowerLimitChartMmol : context.attributes.lowerLimitChartMg, + upperLimit: context.state.isMmol ? context.attributes.upperLimitChartMmol : context.attributes.upperLimitChartMg + ) .frame(height: 85) - } else { - ChartView( - glucoseSamples: context.state.glucoseSamples, - lowerLimit: context.state.isMmol ? context.attributes.lowerLimitChartMmol : context.attributes.lowerLimitChartMg, - upperLimit: context.state.isMmol ? context.attributes.upperLimitChartMmol : context.attributes.upperLimitChartMg - ) + } else { + ChartView( + glucoseSamples: context.state.glucoseSamples, + lowerLimit: context.state.isMmol ? context.attributes.lowerLimitChartMmol : context.attributes.lowerLimitChartMg, + upperLimit: context.state.isMmol ? context.attributes.upperLimitChartMmol : context.attributes.upperLimitChartMg + ) .frame(height: 85) + } } - } - - HStack { - bottomSpacer(border: false) - let endIndex = context.state.bottomRow.endIndex - 1 - ForEach(Array(context.state.bottomRow.enumerated()), id: \.element) { (index, item) in - switch (item.type) { - case .generic: - bottomItemGeneric( - title: item.label, - value: item.value, - unit: LocalizedString(item.unit, comment: "No comment") - ) - - case .basal: - BasalViewActivity(percent: item.percentage, rate: item.rate) + HStack { + bottomSpacer(border: false) + + let endIndex = context.state.bottomRow.endIndex - 1 + ForEach(Array(context.state.bottomRow.enumerated()), id: \.element) { (index, item) in + switch (item.type) { + case .generic: + bottomItemGeneric( + title: item.label, + value: item.value, + unit: LocalizedString(item.unit, comment: "No comment") + ) + + case .basal: + BasalViewActivity(percent: item.percentage, rate: item.rate) + + case .currentBg: + bottomItemCurrentBG( + value: item.value, + trend: item.trend, + currentGlucose: context.state.currentGlucose + ) + } - case .currentBg: - bottomItemCurrentBG( - value: item.value, - trend: item.trend - ) + if index != endIndex { + bottomSpacer(border: true) + } } - if index != endIndex { - bottomSpacer(border: true) + bottomSpacer(border: false) + } + } + if context.isStale { + VStack { + Spacer() + HStack { + Spacer() + Text(NSLocalizedString("Open the app to update the widget", comment: "No comment")) + Spacer() } + Spacer() } - - bottomSpacer(border: false) + .background(.ultraThinMaterial.opacity(0.8)) + .padding(.all, -15) } } .privacySensitive() @@ -91,13 +107,41 @@ struct GlucoseLiveActivityConfiguration: Widget { return DynamicIsland { DynamicIslandExpandedRegion(.leading) { - HStack{} + HStack(alignment: .center) { + loopIcon(context) + .frame(width: 40, height: 40, alignment: .trailing) + VStack(alignment: .trailing) { + Text("\(glucoseFormatter.string(from: context.state.currentGlucose) ?? "??")\(getArrowImage(context.state.trendType))") + .foregroundStyle(getGlucoseColor(context.state.currentGlucose)) + .font(.subheadline) + Text(context.state.delta) + .foregroundStyle(Color(white: 0.9)) + .font(.subheadline) + } + } } DynamicIslandExpandedRegion(.trailing) { HStack{} } DynamicIslandExpandedRegion(.bottom) { - HStack{} + if context.attributes.addPredictiveLine { + ChartView( + glucoseSamples: context.state.glucoseSamples, + predicatedGlucose: context.state.predicatedGlucose, + predicatedStartDate: context.state.predicatedStartDate, + predicatedInterval: context.state.predicatedInterval, + lowerLimit: context.state.isMmol ? context.attributes.lowerLimitChartMmol : context.attributes.lowerLimitChartMg, + upperLimit: context.state.isMmol ? context.attributes.upperLimitChartMmol : context.attributes.upperLimitChartMg + ) + .frame(height: 80) + } else { + ChartView( + glucoseSamples: context.state.glucoseSamples, + lowerLimit: context.state.isMmol ? context.attributes.lowerLimitChartMmol : context.attributes.lowerLimitChartMg, + upperLimit: context.state.isMmol ? context.attributes.upperLimitChartMmol : context.attributes.upperLimitChartMg + ) + .frame(height: 80) + } } } compactLeading: { Text("\(glucoseFormatter.string(from: context.state.currentGlucose) ?? "??")\(getArrowImage(context.state.trendType))") @@ -130,18 +174,21 @@ struct GlucoseLiveActivityConfiguration: Widget { Text("\(value)\(unit)") .font(.headline) .fontWeight(.heavy) + .font(Font.body.leading(.tight)) Text(title) .font(.subheadline) } } @ViewBuilder - private func bottomItemCurrentBG(value: String, trend: GlucoseTrend?) -> some View { + private func bottomItemCurrentBG(value: String, trend: GlucoseTrend?, currentGlucose: Double) -> some View { VStack(alignment: .center) { HStack { Text(value + getArrowImage(trend)) .font(.title) + .foregroundStyle(getGlucoseColor(currentGlucose)) .fontWeight(.heavy) + .font(Font.body.leading(.tight)) } } } diff --git a/Loop/Managers/Live Activity/GlucoseActivityManager.swift b/Loop/Managers/Live Activity/GlucoseActivityManager.swift index 4716bf13ea..1993fae89d 100644 --- a/Loop/Managers/Live Activity/GlucoseActivityManager.swift +++ b/Loop/Managers/Live Activity/GlucoseActivityManager.swift @@ -142,7 +142,9 @@ class GlucoseActivityManager { staleDate: Date.now.addingTimeInterval(60) )) - self.prevGlucoseSample = glucose + if prevGlucoseSample == nil || prevGlucoseSample!.startDate.timeIntervalSince(glucose.startDate) < .minutes(-4.5) { + self.prevGlucoseSample = glucose + } } } diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index 7e3e682df2..c8a3cdd68b 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -187,8 +187,8 @@ final class LoopDataManager { ) { (note) in self.dataAccessQueue.async { self.logger.default("Received notification of glucose samples changing") - self.liveActivityManager?.update(glucose: self.glucoseStore.latestGlucose) + self.glucoseMomentumEffect = nil self.remoteRecommendationNeedsUpdating = true From 1d0b0693eee9465453af20fff1d05c2baa4bf5dc Mon Sep 17 00:00:00 2001 From: bastiaanv Date: Sun, 7 Jul 2024 20:46:22 +0200 Subject: [PATCH 08/33] fix: Minor fixes --- .../GlucoseLiveActivityConfiguration.swift | 24 ++++++++++++------- .../GlucoseActivityManager.swift | 2 +- 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/Loop Widget Extension/Live Activity/GlucoseLiveActivityConfiguration.swift b/Loop Widget Extension/Live Activity/GlucoseLiveActivityConfiguration.swift index 7d9696b617..20e3aa5af5 100644 --- a/Loop Widget Extension/Live Activity/GlucoseLiveActivityConfiguration.swift +++ b/Loop Widget Extension/Live Activity/GlucoseLiveActivityConfiguration.swift @@ -72,7 +72,7 @@ struct GlucoseLiveActivityConfiguration: Widget { bottomItemCurrentBG( value: item.value, trend: item.trend, - currentGlucose: context.state.currentGlucose + context: context ) } @@ -112,7 +112,7 @@ struct GlucoseLiveActivityConfiguration: Widget { .frame(width: 40, height: 40, alignment: .trailing) VStack(alignment: .trailing) { Text("\(glucoseFormatter.string(from: context.state.currentGlucose) ?? "??")\(getArrowImage(context.state.trendType))") - .foregroundStyle(getGlucoseColor(context.state.currentGlucose)) + .foregroundStyle(getGlucoseColor(context.state.currentGlucose, context: context)) .font(.subheadline) Text(context.state.delta) .foregroundStyle(Color(white: 0.9)) @@ -145,7 +145,7 @@ struct GlucoseLiveActivityConfiguration: Widget { } } compactLeading: { Text("\(glucoseFormatter.string(from: context.state.currentGlucose) ?? "??")\(getArrowImage(context.state.trendType))") - .foregroundStyle(getGlucoseColor(context.state.currentGlucose)) + .foregroundStyle(getGlucoseColor(context.state.currentGlucose, context: context)) .minimumScaleFactor(0.1) } compactTrailing: { Text(context.state.delta) @@ -153,7 +153,7 @@ struct GlucoseLiveActivityConfiguration: Widget { .minimumScaleFactor(0.1) } minimal: { Text(glucoseFormatter.string(from: context.state.currentGlucose) ?? "??") - .foregroundStyle(getGlucoseColor(context.state.currentGlucose)) + .foregroundStyle(getGlucoseColor(context.state.currentGlucose, context: context)) .minimumScaleFactor(0.1) } } @@ -181,12 +181,12 @@ struct GlucoseLiveActivityConfiguration: Widget { } @ViewBuilder - private func bottomItemCurrentBG(value: String, trend: GlucoseTrend?, currentGlucose: Double) -> some View { + private func bottomItemCurrentBG(value: String, trend: GlucoseTrend?, context: ActivityViewContext) -> some View { VStack(alignment: .center) { HStack { Text(value + getArrowImage(trend)) .font(.title) - .foregroundStyle(getGlucoseColor(currentGlucose)) + .foregroundStyle(getGlucoseColor(context.state.currentGlucose, context: context)) .fontWeight(.heavy) .font(Font.body.leading(.tight)) } @@ -241,12 +241,18 @@ struct GlucoseLiveActivityConfiguration: Widget { } } - private func getGlucoseColor(_ value: Double) -> Color { - if value < 4 { + private func getGlucoseColor(_ value: Double, context: ActivityViewContext) -> Color { + if + context.state.isMmol && value < context.attributes.lowerLimitChartMmol || + !context.state.isMmol && value < context.attributes.lowerLimitChartMg + { return .red } - if value > 10 { + if + context.state.isMmol && value > context.attributes.upperLimitChartMmol || + !context.state.isMmol && value > context.attributes.upperLimitChartMg + { return .orange } diff --git a/Loop/Managers/Live Activity/GlucoseActivityManager.swift b/Loop/Managers/Live Activity/GlucoseActivityManager.swift index 1993fae89d..c457b15ef4 100644 --- a/Loop/Managers/Live Activity/GlucoseActivityManager.swift +++ b/Loop/Managers/Live Activity/GlucoseActivityManager.swift @@ -139,7 +139,7 @@ class GlucoseActivityManager { await self.activity?.update(ActivityContent( state: state, - staleDate: Date.now.addingTimeInterval(60) + staleDate: Date.now.addingTimeInterval(.hours(1)) )) if prevGlucoseSample == nil || prevGlucoseSample!.startDate.timeIntervalSince(glucose.startDate) < .minutes(-4.5) { From 91795ab4d13f3d9ee809ec67ac0d03edb8fc5600 Mon Sep 17 00:00:00 2001 From: bastiaanv Date: Tue, 9 Jul 2024 20:11:45 +0200 Subject: [PATCH 09/33] fix: Minor hotfixes --- .../GlucoseLiveActivityConfiguration.swift | 2 +- .../Live Activity/GlucoseActivityAttributes.swift | 1 + .../Live Activity/GlucoseActivityManager.swift | 2 ++ Loop/Views/AlertManagementView.swift | 11 +---------- Loop/Views/LiveActivityManagementView.swift | 14 +++----------- LoopCore/LiveActivitySettings.swift | 4 ++-- 6 files changed, 10 insertions(+), 24 deletions(-) diff --git a/Loop Widget Extension/Live Activity/GlucoseLiveActivityConfiguration.swift b/Loop Widget Extension/Live Activity/GlucoseLiveActivityConfiguration.swift index 20e3aa5af5..b927b67631 100644 --- a/Loop Widget Extension/Live Activity/GlucoseLiveActivityConfiguration.swift +++ b/Loop Widget Extension/Live Activity/GlucoseLiveActivityConfiguration.swift @@ -84,7 +84,7 @@ struct GlucoseLiveActivityConfiguration: Widget { bottomSpacer(border: false) } } - if context.isStale { + if context.state.ended { VStack { Spacer() HStack { diff --git a/Loop/Managers/Live Activity/GlucoseActivityAttributes.swift b/Loop/Managers/Live Activity/GlucoseActivityAttributes.swift index e46abe334a..d47534abfb 100644 --- a/Loop/Managers/Live Activity/GlucoseActivityAttributes.swift +++ b/Loop/Managers/Live Activity/GlucoseActivityAttributes.swift @@ -15,6 +15,7 @@ public struct GlucoseActivityAttributes: ActivityAttributes { public struct ContentState: Codable, Hashable { // Meta data public let date: Date + public let ended: Bool // Dynamic island data public let currentGlucose: Double diff --git a/Loop/Managers/Live Activity/GlucoseActivityManager.swift b/Loop/Managers/Live Activity/GlucoseActivityManager.swift index c457b15ef4..4bb55b9f7c 100644 --- a/Loop/Managers/Live Activity/GlucoseActivityManager.swift +++ b/Loop/Managers/Live Activity/GlucoseActivityManager.swift @@ -124,6 +124,7 @@ class GlucoseActivityManager { let state = GlucoseActivityAttributes.ContentState( date: glucose.startDate, + ended: false, currentGlucose: current, trendType: statusContext?.glucoseDisplay?.trendType, delta: delta, @@ -325,6 +326,7 @@ class GlucoseActivityManager { do { let dynamicState = GlucoseActivityAttributes.ContentState( date: Date.now, + ended: true, currentGlucose: 0, trendType: nil, delta: "", diff --git a/Loop/Views/AlertManagementView.swift b/Loop/Views/AlertManagementView.swift index 139f04ddff..94e542a6ab 100644 --- a/Loop/Views/AlertManagementView.swift +++ b/Loop/Views/AlertManagementView.swift @@ -21,7 +21,6 @@ struct AlertManagementView: View { @State private var showMuteAlertOptions: Bool = false @State private var showHowMuteAlertWork: Bool = false - @State private var unit: HKUnit = HKUnit.millimolesPerLiter private var formatter: DateComponentsFormatter = { let formatter = DateComponentsFormatter() @@ -61,14 +60,6 @@ struct AlertManagementView: View { public init(checker: AlertPermissionsChecker, alertMuter: AlertMuter = AlertMuter()) { self.checker = checker self.alertMuter = alertMuter - - self.setUnit() - } - - private func setUnit() { - Task { - self.unit = await HKHealthStore().cachedPreferredUnits(for: .bloodGlucose) ?? HKUnit.millimolesPerLiter - } } var body: some View { @@ -169,7 +160,7 @@ struct AlertManagementView: View { } } - NavigationLink(destination: LiveActivityManagementView(unit: self.unit)) + NavigationLink(destination: LiveActivityManagementView()) { Text(NSLocalizedString("Live activity", comment: "Alert Permissions live activity")) } diff --git a/Loop/Views/LiveActivityManagementView.swift b/Loop/Views/LiveActivityManagementView.swift index 0f4d2dd421..6e8a6c4d71 100644 --- a/Loop/Views/LiveActivityManagementView.swift +++ b/Loop/Views/LiveActivityManagementView.swift @@ -12,7 +12,7 @@ import LoopCore import HealthKit struct LiveActivityManagementView: View { - let unit: HKUnit + @EnvironmentObject private var displayGlucosePreference: DisplayGlucosePreference private var enabled: Binding = Binding( @@ -90,15 +90,11 @@ struct LiveActivityManagementView: View { } ) - public init(unit: HKUnit) { - self.unit = unit - } - var body: some View { List { Toggle(NSLocalizedString("Enabled", comment: "Title for enable live activity toggle"), isOn: enabled) Toggle(NSLocalizedString("Add predictive line", comment: "Title for predictive line toggle"), isOn: addPredictiveLine) - if self.unit == .millimolesPerLiter { + if self.displayGlucosePreference.unit == .millimolesPerLiter { TextInput(label: "Upper limit chart", value: upperLimitMmol) TextInput(label: "Lower limit chart", value: lowerLimitMmol) } else { @@ -124,7 +120,7 @@ struct LiveActivityManagementView: View { Spacer() TextField("", value: value, format: .number) .multilineTextAlignment(.trailing) - Text(self.unit.localizedShortUnitString) + Text(self.displayGlucosePreference.unit.localizedShortUnitString) } } @@ -137,7 +133,3 @@ struct LiveActivityManagementView: View { NotificationCenter.default.post(name: .LiveActivitySettingsChanged, object: settings) } } - -#Preview { - LiveActivityManagementView(unit: HKUnit.millimolesPerLiter) -} diff --git a/LoopCore/LiveActivitySettings.swift b/LoopCore/LiveActivitySettings.swift index 89bff86e02..0218db4206 100644 --- a/LoopCore/LiveActivitySettings.swift +++ b/LoopCore/LiveActivitySettings.swift @@ -80,8 +80,8 @@ public struct LiveActivitySettings: Codable { private static let defaultUpperLimitMmol = Double(10) private static let defaultLowerLimitMmol = Double(4) - private static let defaultUpperLimitMg = Double(72) - private static let defaultLowerLimitMg = Double(180) + private static let defaultUpperLimitMg = Double(180) + private static let defaultLowerLimitMg = Double(72) public init(from decoder:Decoder) throws { let values = try decoder.container(keyedBy: CodingKeys.self) From ec50153e4023a8e1f19f57b893417c20ed3be3b5 Mon Sep 17 00:00:00 2001 From: bastiaanv Date: Tue, 9 Jul 2024 20:18:39 +0200 Subject: [PATCH 10/33] chore: Cleanup --- Loop/Managers/Live Activity/GlucoseActivityManager.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/Loop/Managers/Live Activity/GlucoseActivityManager.swift b/Loop/Managers/Live Activity/GlucoseActivityManager.swift index 4bb55b9f7c..204c6751ae 100644 --- a/Loop/Managers/Live Activity/GlucoseActivityManager.swift +++ b/Loop/Managers/Live Activity/GlucoseActivityManager.swift @@ -54,7 +54,6 @@ class GlucoseActivityManager { init?(glucoseStore: GlucoseStoreProtocol, doseStore: DoseStoreProtocol) { guard self.activityInfo.areActivitiesEnabled else { - print("ERROR: Activities are not enabled... :(") return nil } From 85406a51bce3640ff03e829bc0cb2a872a3f0163 Mon Sep 17 00:00:00 2001 From: bastiaanv Date: Tue, 9 Jul 2024 21:02:47 +0200 Subject: [PATCH 11/33] style: Minor styling fixes --- .../GlucoseLiveActivityConfiguration.swift | 19 ++++++++++++------- .../GlucoseActivityManager.swift | 3 ++- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/Loop Widget Extension/Live Activity/GlucoseLiveActivityConfiguration.swift b/Loop Widget Extension/Live Activity/GlucoseLiveActivityConfiguration.swift index b927b67631..ae9a1b9e49 100644 --- a/Loop Widget Extension/Live Activity/GlucoseLiveActivityConfiguration.swift +++ b/Loop Widget Extension/Live Activity/GlucoseLiveActivityConfiguration.swift @@ -113,15 +113,20 @@ struct GlucoseLiveActivityConfiguration: Widget { VStack(alignment: .trailing) { Text("\(glucoseFormatter.string(from: context.state.currentGlucose) ?? "??")\(getArrowImage(context.state.trendType))") .foregroundStyle(getGlucoseColor(context.state.currentGlucose, context: context)) - .font(.subheadline) - Text(context.state.delta) - .foregroundStyle(Color(white: 0.9)) - .font(.subheadline) + .font(.title2) + .fontWeight(.heavy) } } } DynamicIslandExpandedRegion(.trailing) { - HStack{} + HStack{ + Text(context.state.delta) + .foregroundStyle(Color(white: 0.9)) + .font(.headline) + Text(context.state.isMmol ? HKUnit.millimolesPerLiter.localizedShortUnitString : HKUnit.milligramsPerDeciliter.localizedShortUnitString) + .foregroundStyle(Color(white: 0.7)) + .font(.subheadline) + } } DynamicIslandExpandedRegion(.bottom) { if context.attributes.addPredictiveLine { @@ -133,14 +138,14 @@ struct GlucoseLiveActivityConfiguration: Widget { lowerLimit: context.state.isMmol ? context.attributes.lowerLimitChartMmol : context.attributes.lowerLimitChartMg, upperLimit: context.state.isMmol ? context.attributes.upperLimitChartMmol : context.attributes.upperLimitChartMg ) - .frame(height: 80) + .frame(height: 75) } else { ChartView( glucoseSamples: context.state.glucoseSamples, lowerLimit: context.state.isMmol ? context.attributes.lowerLimitChartMmol : context.attributes.lowerLimitChartMg, upperLimit: context.state.isMmol ? context.attributes.upperLimitChartMmol : context.attributes.upperLimitChartMg ) - .frame(height: 80) + .frame(height: 75) } } } compactLeading: { diff --git a/Loop/Managers/Live Activity/GlucoseActivityManager.swift b/Loop/Managers/Live Activity/GlucoseActivityManager.swift index 204c6751ae..1dcb13cc6b 100644 --- a/Loop/Managers/Live Activity/GlucoseActivityManager.swift +++ b/Loop/Managers/Live Activity/GlucoseActivityManager.swift @@ -142,7 +142,8 @@ class GlucoseActivityManager { staleDate: Date.now.addingTimeInterval(.hours(1)) )) - if prevGlucoseSample == nil || prevGlucoseSample!.startDate.timeIntervalSince(glucose.startDate) < .minutes(-4.5) { + if + prevGlucoseSample == nil || prevGlucoseSample!.startDate.timeIntervalSince(glucose.startDate) < .minutes(-4.5) { self.prevGlucoseSample = glucose } } From aa439e4084baf4705359953a4a1a95c19d7525a8 Mon Sep 17 00:00:00 2001 From: bastiaanv Date: Thu, 11 Jul 2024 19:56:42 +0200 Subject: [PATCH 12/33] fix: fix delta calculations & optimize booting sequence of LA --- .../GlucoseActivityManager.swift | 64 +++++++++++-------- Loop/Managers/LoopDataManager.swift | 2 +- 2 files changed, 39 insertions(+), 27 deletions(-) diff --git a/Loop/Managers/Live Activity/GlucoseActivityManager.swift b/Loop/Managers/Live Activity/GlucoseActivityManager.swift index 1dcb13cc6b..5ab92e8f23 100644 --- a/Loop/Managers/Live Activity/GlucoseActivityManager.swift +++ b/Loop/Managers/Live Activity/GlucoseActivityManager.swift @@ -26,9 +26,6 @@ class GlucoseActivityManager { private let glucoseStore: GlucoseStoreProtocol private let doseStore: DoseStoreProtocol - private var lastGlucoseSample: GlucoseSampleValue? - private var prevGlucoseSample: GlucoseSampleValue? - private var startDate: Date = Date.now private var settings: LiveActivitySettings = UserDefaults.standard.liveActivity ?? LiveActivitySettings() @@ -65,7 +62,9 @@ class GlucoseActivityManager { self.settings = LiveActivitySettings() } - NotificationCenter.default.addObserver(self, selector: #selector(self.settingsChanged), name: .LiveActivitySettingsChanged, object: nil) + let nc = NotificationCenter.default + nc.addObserver(self, selector: #selector(self.appMovedToForeground), name: UIApplication.willEnterForegroundNotification, object: nil) + nc.addObserver(self, selector: #selector(self.settingsChanged), name: .LiveActivitySettingsChanged, object: nil) guard self.settings.enabled else { return } @@ -78,19 +77,15 @@ class GlucoseActivityManager { } public func update() { - self.update(glucose: self.lastGlucoseSample) - } - - public func update(glucose: GlucoseSampleValue?) { Task { if self.needsRecreation(), await UIApplication.shared.applicationState == .active { // activity is no longer visible or old. End it and try to push the update again await endActivity() - update(glucose: glucose) + update() return } - guard let glucose = glucose, let unit = await self.healthStore.cachedPreferredUnits(for: .bloodGlucose) else { + guard let unit = await self.healthStore.cachedPreferredUnits(for: .bloodGlucose) else { return } @@ -99,16 +94,21 @@ class GlucoseActivityManager { let statusContext = UserDefaults.appGroup?.statusExtensionContext let glucoseFormatter = NumberFormatter.glucoseFormatter(for: unit) - let current = glucose.quantity.doubleValue(for: unit) - self.lastGlucoseSample = glucose + let glucoseSamples = self.getGlucoseSample(unit: unit) + guard let currentGlucose = glucoseSamples.last else { + return + } + + let current = currentGlucose.quantity.doubleValue(for: unit) var delta: String = "+\(glucoseFormatter.string(from: Double(0)) ?? "")" - if let prevSample = self.prevGlucoseSample { + if glucoseSamples.count > 1 { + let prevSample = glucoseSamples[glucoseSamples.count - 2] let deltaValue = current - (prevSample.quantity.doubleValue(for: unit)) delta = "\(deltaValue < 0 ? "-" : "+")\(glucoseFormatter.string(from: abs(deltaValue)) ?? "??")" } - let glucoseSamples = self.getGlucoseSample(unit: unit) + let bottomRow = self.getBottomRow( currentGlucose: current, delta: delta, @@ -122,7 +122,7 @@ class GlucoseActivityManager { } let state = GlucoseActivityAttributes.ContentState( - date: glucose.startDate, + date: currentGlucose.startDate, ended: false, currentGlucose: current, trendType: statusContext?.glucoseDisplay?.trendType, @@ -131,7 +131,11 @@ class GlucoseActivityManager { isCloseLoop: statusContext?.isClosedLoop ?? false, lastCompleted: statusContext?.lastLoopCompleted, bottomRow: bottomRow, - glucoseSamples: glucoseSamples, + // In order to prevent maxSize errors, only allow the last 100 samples to be sent + // Will most likely not be an issue, might be an issue for debugging/CGM simulator with 5sec interval + glucoseSamples: glucoseSamples.suffix(100).map { item in + return GlucoseSampleAttributes(x: item.startDate, y: item.quantity.doubleValue(for: unit)) + }, predicatedGlucose: predicatedGlucose, predicatedStartDate: statusContext?.predictedGlucose?.startDate, predicatedInterval: statusContext?.predictedGlucose?.interval @@ -141,11 +145,6 @@ class GlucoseActivityManager { state: state, staleDate: Date.now.addingTimeInterval(.hours(1)) )) - - if - prevGlucoseSample == nil || prevGlucoseSample!.startDate.timeIntervalSince(glucose.startDate) < .minutes(-4.5) { - self.prevGlucoseSample = glucose - } } } @@ -180,6 +179,21 @@ class GlucoseActivityManager { } } + @objc private func appMovedToForeground() { + guard let activity = self.activity else { + return + } + + Task { + await activity.end(nil, dismissalPolicy: .immediate) + await self.endUnknownActivities() + self.activity = nil + + initEmptyActivity(settings: self.settings) + update() + } + } + private func endUnknownActivities() async { for unknownActivity in Activity.activities .filter({ self.activity?.id != $0.id }) @@ -253,9 +267,9 @@ class GlucoseActivityManager { return iob } - private func getGlucoseSample(unit: HKUnit) -> [GlucoseSampleAttributes] { + private func getGlucoseSample(unit: HKUnit) -> [StoredGlucoseSample] { let updateGroup = DispatchGroup() - var samples: [GlucoseSampleAttributes] = [] + var samples: [StoredGlucoseSample] = [] updateGroup.enter() @@ -270,9 +284,7 @@ class GlucoseActivityManager { case .failure: break case .success(let data): - samples = data.suffix(100).map { item in - return GlucoseSampleAttributes(x: item.startDate, y: item.quantity.doubleValue(for: unit)) - } + samples = data break } diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index c8a3cdd68b..2bd6cf68a8 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -187,7 +187,7 @@ final class LoopDataManager { ) { (note) in self.dataAccessQueue.async { self.logger.default("Received notification of glucose samples changing") - self.liveActivityManager?.update(glucose: self.glucoseStore.latestGlucose) + self.liveActivityManager?.update() self.glucoseMomentumEffect = nil self.remoteRecommendationNeedsUpdating = true From 44e915d5c45b9976ff971015bc54576562332286 Mon Sep 17 00:00:00 2001 From: bastiaanv Date: Mon, 15 Jul 2024 12:34:44 +0200 Subject: [PATCH 13/33] fix: minor fix & cleanup --- Loop/Info.plist | 9 ++++----- Loop/Managers/Live Activity/GlucoseActivityManager.swift | 2 +- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/Loop/Info.plist b/Loop/Info.plist index c8f200700a..f9f4ca84a9 100644 --- a/Loop/Info.plist +++ b/Loop/Info.plist @@ -71,6 +71,10 @@ Carbohydrate meal data entered in the app and on the watch is stored in the Health database. Glucose data retrieved from the CGM is stored securely in HealthKit. NSSiriUsageDescription Loop uses Siri to allow you to enact presets with your voice. + NSSupportsLiveActivities + + NSSupportsLiveActivitiesFrequentUpdates + NSUserActivityTypes EnableOverridePresetIntent @@ -80,7 +84,6 @@ UIBackgroundModes - audio bluetooth-central processing remote-notification @@ -119,9 +122,5 @@ UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight - NSSupportsLiveActivities - - NSSupportsLiveActivitiesFrequentUpdates - diff --git a/Loop/Managers/Live Activity/GlucoseActivityManager.swift b/Loop/Managers/Live Activity/GlucoseActivityManager.swift index 5ab92e8f23..d9cf083a6b 100644 --- a/Loop/Managers/Live Activity/GlucoseActivityManager.swift +++ b/Loop/Managers/Live Activity/GlucoseActivityManager.swift @@ -117,7 +117,7 @@ class GlucoseActivityManager { ) var predicatedGlucose: [Double] = [] - if let samples = statusContext?.predictedGlucose?.values { + if let samples = statusContext?.predictedGlucose?.values, settings.addPredictiveLine { predicatedGlucose = samples } From 69a235a84e1b14eae4f616f9aaa058ad0f47f8db Mon Sep 17 00:00:00 2001 From: bastiaanv Date: Mon, 15 Jul 2024 19:07:14 +0200 Subject: [PATCH 14/33] style: Fix dynamic island expanded view --- .../GlucoseLiveActivityConfiguration.swift | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/Loop Widget Extension/Live Activity/GlucoseLiveActivityConfiguration.swift b/Loop Widget Extension/Live Activity/GlucoseLiveActivityConfiguration.swift index ae9a1b9e49..f1b24aeccb 100644 --- a/Loop Widget Extension/Live Activity/GlucoseLiveActivityConfiguration.swift +++ b/Loop Widget Extension/Live Activity/GlucoseLiveActivityConfiguration.swift @@ -110,12 +110,11 @@ struct GlucoseLiveActivityConfiguration: Widget { HStack(alignment: .center) { loopIcon(context) .frame(width: 40, height: 40, alignment: .trailing) - VStack(alignment: .trailing) { - Text("\(glucoseFormatter.string(from: context.state.currentGlucose) ?? "??")\(getArrowImage(context.state.trendType))") - .foregroundStyle(getGlucoseColor(context.state.currentGlucose, context: context)) - .font(.title2) - .fontWeight(.heavy) - } + Spacer() + Text("\(glucoseFormatter.string(from: context.state.currentGlucose) ?? "??")\(getArrowImage(context.state.trendType))") + .foregroundStyle(getGlucoseColor(context.state.currentGlucose, context: context)) + .font(.title2) + .fontWeight(.heavy) } } DynamicIslandExpandedRegion(.trailing) { From 325d7a529f3585fe46137fecd13038a0329ca838 Mon Sep 17 00:00:00 2001 From: "bastiaan.verhaar" Date: Thu, 25 Jul 2024 16:01:48 +0200 Subject: [PATCH 15/33] fix: Fix truncating text in expanded Dynamic Island --- .../Live Activity/GlucoseLiveActivityConfiguration.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Loop Widget Extension/Live Activity/GlucoseLiveActivityConfiguration.swift b/Loop Widget Extension/Live Activity/GlucoseLiveActivityConfiguration.swift index f1b24aeccb..a66fa0b2cd 100644 --- a/Loop Widget Extension/Live Activity/GlucoseLiveActivityConfiguration.swift +++ b/Loop Widget Extension/Live Activity/GlucoseLiveActivityConfiguration.swift @@ -113,7 +113,7 @@ struct GlucoseLiveActivityConfiguration: Widget { Spacer() Text("\(glucoseFormatter.string(from: context.state.currentGlucose) ?? "??")\(getArrowImage(context.state.trendType))") .foregroundStyle(getGlucoseColor(context.state.currentGlucose, context: context)) - .font(.title2) + .font(.headline) .fontWeight(.heavy) } } From c68d7439b6e65e6c8e832b7ed0194b19deacfd9d Mon Sep 17 00:00:00 2001 From: "bastiaan.verhaar" Date: Thu, 25 Jul 2024 20:25:35 +0200 Subject: [PATCH 16/33] feat: Add glucose targets & preset targets to Live Activity --- .../Live Activity/ChartView.swift | 73 ++++++++++++++----- .../GlucoseLiveActivityConfiguration.swift | 16 +++- .../GlucoseActivityAttributes.swift | 17 +++++ .../GlucoseActivityManager.swift | 66 ++++++++++++++++- Loop/Managers/LoopDataManager.swift | 15 +++- 5 files changed, 161 insertions(+), 26 deletions(-) diff --git a/Loop Widget Extension/Live Activity/ChartView.swift b/Loop Widget Extension/Live Activity/ChartView.swift index 274c986d9e..2d48d1ddc0 100644 --- a/Loop Widget Extension/Live Activity/ChartView.swift +++ b/Loop Widget Extension/Live Activity/ChartView.swift @@ -13,8 +13,10 @@ import Charts struct ChartView: View { private let glucoseSampleData: [ChartValues] private let predicatedData: [ChartValues] + private let glucoseRanges: [GlucoseRangeValue] + private let preset: Preset? - init(glucoseSamples: [GlucoseSampleAttributes], predicatedGlucose: [Double], predicatedStartDate: Date?, predicatedInterval: TimeInterval?, lowerLimit: Double, upperLimit: Double) { + init(glucoseSamples: [GlucoseSampleAttributes], predicatedGlucose: [Double], predicatedStartDate: Date?, predicatedInterval: TimeInterval?, lowerLimit: Double, upperLimit: Double, glucoseRanges: [GlucoseRangeValue], preset: Preset?) { self.glucoseSampleData = ChartValues.convert(data: glucoseSamples, lowerLimit: lowerLimit, upperLimit: upperLimit) self.predicatedData = ChartValues.convert( data: predicatedGlucose, @@ -23,30 +25,57 @@ struct ChartView: View { lowerLimit: lowerLimit, upperLimit: upperLimit ) + self.preset = preset + self.glucoseRanges = glucoseRanges } - init(glucoseSamples: [GlucoseSampleAttributes], lowerLimit: Double, upperLimit: Double) { + init(glucoseSamples: [GlucoseSampleAttributes], lowerLimit: Double, upperLimit: Double, glucoseRanges: [GlucoseRangeValue], preset: Preset?) { self.glucoseSampleData = ChartValues.convert(data: glucoseSamples, lowerLimit: lowerLimit, upperLimit: upperLimit) self.predicatedData = [] + self.preset = preset + self.glucoseRanges = glucoseRanges } var body: some View { - Chart { - ForEach(glucoseSampleData) { item in - PointMark (x: .value("Date", item.x), - y: .value("Glucose level", item.y) - ) - .symbolSize(20) - .foregroundStyle(by: .value("Color", item.color)) - } - - ForEach(predicatedData) { item in - LineMark (x: .value("Date", item.x), - y: .value("Glucose level", item.y) - ) - .lineStyle(StrokeStyle(lineWidth: 3, dash: [2, 3])) + ZStack(alignment: Alignment(horizontal: .trailing, vertical: .top)){ + Chart { + if let preset = self.preset, predicatedData.count > 0 { + RectangleMark( + xStart: .value("Start", Date.now), + xEnd: .value("End", preset.endDate), + yStart: .value("Preset override", preset.minValue), + yEnd: .value("Preset override", preset.maxValue) + ) + .foregroundStyle(.primary) + .opacity(0.6) + } + + ForEach(glucoseRanges) { item in + RectangleMark( + xStart: .value("Start", item.startDate), + xEnd: .value("End", item.endDate), + yStart: .value("Glucose range", item.minValue), + yEnd: .value("Glucose range", item.maxValue) + ) + .foregroundStyle(.primary) + .opacity(0.3) + } + + ForEach(glucoseSampleData) { item in + PointMark (x: .value("Date", item.x), + y: .value("Glucose level", item.y) + ) + .symbolSize(20) + .foregroundStyle(by: .value("Color", item.color)) + } + + ForEach(predicatedData) { item in + LineMark (x: .value("Date", item.x), + y: .value("Glucose level", item.y) + ) + .lineStyle(StrokeStyle(lineWidth: 3, dash: [2, 3])) + } } - } .chartForegroundStyleScale([ "Good": .green, "High": .orange, @@ -58,7 +87,7 @@ struct ChartView: View { .chartLegend(.hidden) .chartYScale(domain: .automatic(includesZero: false)) .chartYAxis { - AxisMarks(position: .trailing) { _ in + AxisMarks(position: .leading) { _ in AxisValueLabel().foregroundStyle(Color.primary) AxisGridLine(stroke: .init(lineWidth: 0.1, dash: [2, 3])) .foregroundStyle(Color.primary) @@ -72,6 +101,14 @@ struct ChartView: View { .foregroundStyle(Color.primary) } } + + if let preset = self.preset { + Text(preset.title) + .font(.footnote) + .padding(.trailing, 5) + .padding(.top, 5) + } + } } } diff --git a/Loop Widget Extension/Live Activity/GlucoseLiveActivityConfiguration.swift b/Loop Widget Extension/Live Activity/GlucoseLiveActivityConfiguration.swift index a66fa0b2cd..3e6fce0217 100644 --- a/Loop Widget Extension/Live Activity/GlucoseLiveActivityConfiguration.swift +++ b/Loop Widget Extension/Live Activity/GlucoseLiveActivityConfiguration.swift @@ -39,14 +39,18 @@ struct GlucoseLiveActivityConfiguration: Widget { predicatedStartDate: context.state.predicatedStartDate, predicatedInterval: context.state.predicatedInterval, lowerLimit: context.state.isMmol ? context.attributes.lowerLimitChartMmol : context.attributes.lowerLimitChartMg, - upperLimit: context.state.isMmol ? context.attributes.upperLimitChartMmol : context.attributes.upperLimitChartMg + upperLimit: context.state.isMmol ? context.attributes.upperLimitChartMmol : context.attributes.upperLimitChartMg, + glucoseRanges: context.state.glucoseRanges, + preset: context.state.preset ) .frame(height: 85) } else { ChartView( glucoseSamples: context.state.glucoseSamples, lowerLimit: context.state.isMmol ? context.attributes.lowerLimitChartMmol : context.attributes.lowerLimitChartMg, - upperLimit: context.state.isMmol ? context.attributes.upperLimitChartMmol : context.attributes.upperLimitChartMg + upperLimit: context.state.isMmol ? context.attributes.upperLimitChartMmol : context.attributes.upperLimitChartMg, + glucoseRanges: context.state.glucoseRanges, + preset: context.state.preset ) .frame(height: 85) } @@ -135,14 +139,18 @@ struct GlucoseLiveActivityConfiguration: Widget { predicatedStartDate: context.state.predicatedStartDate, predicatedInterval: context.state.predicatedInterval, lowerLimit: context.state.isMmol ? context.attributes.lowerLimitChartMmol : context.attributes.lowerLimitChartMg, - upperLimit: context.state.isMmol ? context.attributes.upperLimitChartMmol : context.attributes.upperLimitChartMg + upperLimit: context.state.isMmol ? context.attributes.upperLimitChartMmol : context.attributes.upperLimitChartMg, + glucoseRanges: context.state.glucoseRanges, + preset: context.state.preset ) .frame(height: 75) } else { ChartView( glucoseSamples: context.state.glucoseSamples, lowerLimit: context.state.isMmol ? context.attributes.lowerLimitChartMmol : context.attributes.lowerLimitChartMg, - upperLimit: context.state.isMmol ? context.attributes.upperLimitChartMmol : context.attributes.upperLimitChartMg + upperLimit: context.state.isMmol ? context.attributes.upperLimitChartMmol : context.attributes.upperLimitChartMg, + glucoseRanges: context.state.glucoseRanges, + preset: context.state.preset ) .frame(height: 75) } diff --git a/Loop/Managers/Live Activity/GlucoseActivityAttributes.swift b/Loop/Managers/Live Activity/GlucoseActivityAttributes.swift index d47534abfb..c6d161bf4b 100644 --- a/Loop/Managers/Live Activity/GlucoseActivityAttributes.swift +++ b/Loop/Managers/Live Activity/GlucoseActivityAttributes.swift @@ -16,6 +16,8 @@ public struct GlucoseActivityAttributes: ActivityAttributes { // Meta data public let date: Date public let ended: Bool + public let preset: Preset? + public let glucoseRanges: [GlucoseRangeValue] // Dynamic island data public let currentGlucose: Double @@ -44,6 +46,21 @@ public struct GlucoseActivityAttributes: ActivityAttributes { public let lowerLimitChartMg: Double } +public struct Preset: Codable, Hashable { + public let title: String + public let endDate: Date + public let minValue: Double + public let maxValue: Double +} + +public struct GlucoseRangeValue: Identifiable, Codable, Hashable { + public let id: UUID + public let minValue: Double + public let maxValue: Double + public let startDate: Date + public let endDate: Date +} + public struct BottomRowItem: Codable, Hashable { public enum BottomRowType: Codable, Hashable { case generic diff --git a/Loop/Managers/Live Activity/GlucoseActivityManager.swift b/Loop/Managers/Live Activity/GlucoseActivityManager.swift index d9cf083a6b..af89d87d5c 100644 --- a/Loop/Managers/Live Activity/GlucoseActivityManager.swift +++ b/Loop/Managers/Live Activity/GlucoseActivityManager.swift @@ -25,6 +25,8 @@ class GlucoseActivityManager { private let glucoseStore: GlucoseStoreProtocol private let doseStore: DoseStoreProtocol + private let glucoseRangeSchedule: GlucoseRangeSchedule? + private var preset: TemporaryScheduleOverride? private var startDate: Date = Date.now private var settings: LiveActivitySettings = UserDefaults.standard.liveActivity ?? LiveActivitySettings() @@ -49,13 +51,15 @@ class GlucoseActivityManager { return dateFormatter }() - init?(glucoseStore: GlucoseStoreProtocol, doseStore: DoseStoreProtocol) { + init?(glucoseStore: GlucoseStoreProtocol, doseStore: DoseStoreProtocol, glucoseRangeSchedule: GlucoseRangeSchedule?, preset: TemporaryScheduleOverride?) { guard self.activityInfo.areActivitiesEnabled else { return nil } self.glucoseStore = glucoseStore self.doseStore = doseStore + self.glucoseRangeSchedule = glucoseRangeSchedule + self.preset = preset // Ensure settings exist if UserDefaults.standard.liveActivity == nil { @@ -120,10 +124,42 @@ class GlucoseActivityManager { if let samples = statusContext?.predictedGlucose?.values, settings.addPredictiveLine { predicatedGlucose = samples } + + var endDateChart: Date? = nil + if predicatedGlucose.count == 0 { + endDateChart = glucoseSamples.last?.startDate + } else if let predictedGlucose = statusContext?.predictedGlucose { + endDateChart = predictedGlucose.startDate.addingTimeInterval(.hours(4)) + } + + var presetContext: Preset? = nil + if let preset = self.preset, let endDateChart = endDateChart { + presetContext = Preset( + title: preset.getTitle(), + endDate: preset.duration.isInfinite ? endDateChart : min(Date.now + preset.duration.timeInterval, endDateChart), + minValue: preset.settings.targetRange?.lowerBound.doubleValue(for: unit) ?? 0, + maxValue: preset.settings.targetRange?.upperBound.doubleValue(for: unit) ?? 0 + ) + } + + var glucoseRanges: [GlucoseRangeValue] = [] + if let glucoseRangeSchedule = self.glucoseRangeSchedule, let start = glucoseSamples.first?.startDate, let end = endDateChart { + for item in glucoseRangeSchedule.quantityBetween(start: start, end: end) { + glucoseRanges.append(GlucoseRangeValue( + id: UUID(), + minValue: item.value.lowerBound.doubleValue(for: unit), + maxValue: item.value.upperBound.doubleValue(for: unit), + startDate: max(item.startDate, start), + endDate: min(item.endDate, end) + )) + } + } let state = GlucoseActivityAttributes.ContentState( date: currentGlucose.startDate, ended: false, + preset: presetContext, + glucoseRanges: glucoseRanges, currentGlucose: current, trendType: statusContext?.glucoseDisplay?.trendType, delta: delta, @@ -226,7 +262,6 @@ class GlucoseActivityManager { } self.startDate = Date.now } catch {} - } private func needsRecreation() -> Bool { @@ -339,6 +374,8 @@ class GlucoseActivityManager { let dynamicState = GlucoseActivityAttributes.ContentState( date: Date.now, ended: true, + preset: nil, + glucoseRanges: [], currentGlucose: 0, trendType: nil, delta: "", @@ -365,4 +402,29 @@ class GlucoseActivityManager { ) } catch {} } + + func presetActivated(preset: TemporaryScheduleOverride) { + self.preset = preset + self.update() + } + + func presetDeactivated() { + self.preset = nil + self.update() + } +} + +extension TemporaryScheduleOverride { + func getTitle() -> String { + switch (self.context) { + case .preset(let preset): + return "\(preset.symbol) \(preset.name)" + case .custom: + return NSLocalizedString("Custom preset", comment: "The title of the cell indicating a generic custom preset is enabled") + case .preMeal: + return NSLocalizedString(" Pre-meal Preset", comment: "Status row title for premeal override enabled (leading space is to separate from symbol)") + case .legacyWorkout: + return "" + } + } } diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index 2bd6cf68a8..795d2bd48d 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -127,7 +127,12 @@ final class LoopDataManager { self.trustedTimeOffset = trustedTimeOffset - self.liveActivityManager = GlucoseActivityManager(glucoseStore: self.glucoseStore, doseStore: self.doseStore) + self.liveActivityManager = GlucoseActivityManager( + glucoseStore: self.glucoseStore, + doseStore: self.doseStore, + glucoseRangeSchedule: settings.glucoseTargetRangeSchedule, + preset: self.settings.scheduleOverride + ) overrideIntentObserver = UserDefaults.appGroup?.observe(\.intentExtensionOverrideToSet, options: [.new], changeHandler: {[weak self] (defaults, change) in guard let name = change.newValue??.lowercased(), let appGroup = UserDefaults.appGroup else { @@ -147,6 +152,7 @@ final class LoopDataManager { observer.presetDeactivated(context: oldPreset.context) } } + self?.liveActivityManager?.presetDeactivated() } settings.scheduleOverride = preset.createOverride(enactTrigger: .remote("Siri")) if let observers = self?.presetActivationObservers { @@ -154,6 +160,9 @@ final class LoopDataManager { observer.presetActivated(context: .preset(preset), duration: preset.duration) } } + if let override = settings.scheduleOverride { + self?.liveActivityManager?.presetActivated(preset: override) + } } // Remove the override from UserDefaults so we don't set it multiple times appGroup.intentExtensionOverrideToSet = nil @@ -263,12 +272,14 @@ final class LoopDataManager { for observer in self.presetActivationObservers { observer.presetDeactivated(context: oldPreset.context) } - + self.liveActivityManager?.presetDeactivated() } if let newPreset = newValue.scheduleOverride { for observer in self.presetActivationObservers { observer.presetActivated(context: newPreset.context, duration: newPreset.duration) } + + self.liveActivityManager?.presetActivated(preset: newPreset) } // Invalidate cached effects affected by the override From 3a2ab67f80e0d11b5d8fa6ebc0fc713d80232798 Mon Sep 17 00:00:00 2001 From: bastiaanv Date: Sat, 27 Jul 2024 22:30:43 +0200 Subject: [PATCH 17/33] fix: Fix preMeal override & remove glucoseTarget while override is active --- .../Live Activity/ChartView.swift | 8 +-- .../GlucoseActivityAttributes.swift | 1 + .../GlucoseActivityManager.swift | 58 ++++++++++++++++--- Loop/Managers/LoopDataManager.swift | 8 ++- 4 files changed, 62 insertions(+), 13 deletions(-) diff --git a/Loop Widget Extension/Live Activity/ChartView.swift b/Loop Widget Extension/Live Activity/ChartView.swift index 2d48d1ddc0..4480c5b299 100644 --- a/Loop Widget Extension/Live Activity/ChartView.swift +++ b/Loop Widget Extension/Live Activity/ChartView.swift @@ -39,9 +39,9 @@ struct ChartView: View { var body: some View { ZStack(alignment: Alignment(horizontal: .trailing, vertical: .top)){ Chart { - if let preset = self.preset, predicatedData.count > 0 { + if let preset = self.preset, predicatedData.count > 0, preset.endDate > Date.now.addingTimeInterval(.hours(-6)) { RectangleMark( - xStart: .value("Start", Date.now), + xStart: .value("Start", preset.startDate), xEnd: .value("End", preset.endDate), yStart: .value("Preset override", preset.minValue), yEnd: .value("Preset override", preset.maxValue) @@ -102,11 +102,11 @@ struct ChartView: View { } } - if let preset = self.preset { + if let preset = self.preset, preset.endDate > Date.now { Text(preset.title) .font(.footnote) .padding(.trailing, 5) - .padding(.top, 5) + .padding(.top, 2) } } } diff --git a/Loop/Managers/Live Activity/GlucoseActivityAttributes.swift b/Loop/Managers/Live Activity/GlucoseActivityAttributes.swift index c6d161bf4b..90329be9ef 100644 --- a/Loop/Managers/Live Activity/GlucoseActivityAttributes.swift +++ b/Loop/Managers/Live Activity/GlucoseActivityAttributes.swift @@ -48,6 +48,7 @@ public struct GlucoseActivityAttributes: ActivityAttributes { public struct Preset: Codable, Hashable { public let title: String + public let startDate: Date public let endDate: Date public let minValue: Double public let maxValue: Double diff --git a/Loop/Managers/Live Activity/GlucoseActivityManager.swift b/Loop/Managers/Live Activity/GlucoseActivityManager.swift index af89d87d5c..38ca1ab7ee 100644 --- a/Loop/Managers/Live Activity/GlucoseActivityManager.swift +++ b/Loop/Managers/Live Activity/GlucoseActivityManager.swift @@ -27,6 +27,7 @@ class GlucoseActivityManager { private let doseStore: DoseStoreProtocol private let glucoseRangeSchedule: GlucoseRangeSchedule? private var preset: TemporaryScheduleOverride? + private var presetStartDate: Date? private var startDate: Date = Date.now private var settings: LiveActivitySettings = UserDefaults.standard.liveActivity ?? LiveActivitySettings() @@ -60,6 +61,7 @@ class GlucoseActivityManager { self.doseStore = doseStore self.glucoseRangeSchedule = glucoseRangeSchedule self.preset = preset + self.presetStartDate = preset != nil ? Date.now : nil // Ensure settings exist if UserDefaults.standard.liveActivity == nil { @@ -74,6 +76,7 @@ class GlucoseActivityManager { } initEmptyActivity(settings: self.settings) + update() Task { await self.endUnknownActivities() @@ -133,9 +136,13 @@ class GlucoseActivityManager { } var presetContext: Preset? = nil - if let preset = self.preset, let endDateChart = endDateChart { + if let preset = self.preset, + let endDateChart = endDateChart, + let startDate = presetStartDate + { presetContext = Preset( title: preset.getTitle(), + startDate: startDate, endDate: preset.duration.isInfinite ? endDateChart : min(Date.now + preset.duration.timeInterval, endDateChart), minValue: preset.settings.targetRange?.lowerBound.doubleValue(for: unit) ?? 0, maxValue: preset.settings.targetRange?.upperBound.doubleValue(for: unit) ?? 0 @@ -145,13 +152,47 @@ class GlucoseActivityManager { var glucoseRanges: [GlucoseRangeValue] = [] if let glucoseRangeSchedule = self.glucoseRangeSchedule, let start = glucoseSamples.first?.startDate, let end = endDateChart { for item in glucoseRangeSchedule.quantityBetween(start: start, end: end) { - glucoseRanges.append(GlucoseRangeValue( - id: UUID(), - minValue: item.value.lowerBound.doubleValue(for: unit), - maxValue: item.value.upperBound.doubleValue(for: unit), - startDate: max(item.startDate, start), - endDate: min(item.endDate, end) - )) + let minValue = item.value.lowerBound.doubleValue(for: unit) + let maxValue = item.value.upperBound.doubleValue(for: unit) + let startDate = max(item.startDate, start) + let endDate = min(item.endDate, end) + + if let presetContext = presetContext { + if presetContext.startDate > startDate, presetContext.endDate < endDate { + // A preset is active during this schedule + glucoseRanges.append(GlucoseRangeValue( + id: UUID(), + minValue: minValue, + maxValue: maxValue, + startDate: startDate, + endDate: presetContext.startDate + )) + glucoseRanges.append(GlucoseRangeValue( + id: UUID(), + minValue: minValue, + maxValue: maxValue, + startDate: presetContext.endDate, + endDate: endDate + )) + } else { + // No preset is active in this schedule + glucoseRanges.append(GlucoseRangeValue( + id: UUID(), + minValue: minValue, + maxValue: maxValue, + startDate: startDate, + endDate: endDate + )) + } + } else { + glucoseRanges.append(GlucoseRangeValue( + id: UUID(), + minValue: minValue, + maxValue: maxValue, + startDate: startDate, + endDate: endDate + )) + } } } @@ -405,6 +446,7 @@ class GlucoseActivityManager { func presetActivated(preset: TemporaryScheduleOverride) { self.preset = preset + self.presetStartDate = Date.now self.update() } diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index 795d2bd48d..39a2fc4e74 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -131,7 +131,7 @@ final class LoopDataManager { glucoseStore: self.glucoseStore, doseStore: self.doseStore, glucoseRangeSchedule: settings.glucoseTargetRangeSchedule, - preset: self.settings.scheduleOverride + preset: self.settings.scheduleOverride ?? self.settings.preMealOverride ) overrideIntentObserver = UserDefaults.appGroup?.observe(\.intentExtensionOverrideToSet, options: [.new], changeHandler: {[weak self] (defaults, change) in @@ -263,6 +263,12 @@ final class LoopDataManager { if newValue.preMealOverride != oldValue.preMealOverride { // The prediction isn't actually invalid, but a target range change requires recomputing recommended doses predictedGlucose = nil + + if let preMeal = newValue.preMealOverride { + self.liveActivityManager?.presetActivated(preset: preMeal) + } else { + self.liveActivityManager?.presetDeactivated() + } } if newValue.scheduleOverride != oldValue.scheduleOverride { From 95f6f130e3d80eb34c09785f0f3b4efa4fa304fd Mon Sep 17 00:00:00 2001 From: "bastiaan.verhaar" Date: Wed, 31 Jul 2024 19:45:24 +0200 Subject: [PATCH 18/33] wip --- .../GlucoseActivityManager.swift | 51 ++++++++++--------- Loop/Managers/LoopDataManager.swift | 13 ++--- Loop/Views/LiveActivityManagementView.swift | 12 +++++ 3 files changed, 43 insertions(+), 33 deletions(-) diff --git a/Loop/Managers/Live Activity/GlucoseActivityManager.swift b/Loop/Managers/Live Activity/GlucoseActivityManager.swift index af89d87d5c..c2b747f917 100644 --- a/Loop/Managers/Live Activity/GlucoseActivityManager.swift +++ b/Loop/Managers/Live Activity/GlucoseActivityManager.swift @@ -25,8 +25,7 @@ class GlucoseActivityManager { private let glucoseStore: GlucoseStoreProtocol private let doseStore: DoseStoreProtocol - private let glucoseRangeSchedule: GlucoseRangeSchedule? - private var preset: TemporaryScheduleOverride? + private let loopSettings: LoopSettings private var startDate: Date = Date.now private var settings: LiveActivitySettings = UserDefaults.standard.liveActivity ?? LiveActivitySettings() @@ -51,15 +50,15 @@ class GlucoseActivityManager { return dateFormatter }() - init?(glucoseStore: GlucoseStoreProtocol, doseStore: DoseStoreProtocol, glucoseRangeSchedule: GlucoseRangeSchedule?, preset: TemporaryScheduleOverride?) { + init?(glucoseStore: GlucoseStoreProtocol, doseStore: DoseStoreProtocol, loopSettings: LoopSettings) { guard self.activityInfo.areActivitiesEnabled else { + print("ERROR: Live Activities are not enabled...") return nil } self.glucoseStore = glucoseStore self.doseStore = doseStore - self.glucoseRangeSchedule = glucoseRangeSchedule - self.preset = preset + self.loopSettings = loopSettings // Ensure settings exist if UserDefaults.standard.liveActivity == nil { @@ -84,12 +83,14 @@ class GlucoseActivityManager { Task { if self.needsRecreation(), await UIApplication.shared.applicationState == .active { // activity is no longer visible or old. End it and try to push the update again + print("INFO: Live Activities needs recreation") await endActivity() update() return } guard let unit = await self.healthStore.cachedPreferredUnits(for: .bloodGlucose) else { + print("ERROR: No unit found...") return } @@ -100,6 +101,7 @@ class GlucoseActivityManager { let glucoseSamples = self.getGlucoseSample(unit: unit) guard let currentGlucose = glucoseSamples.last else { + print("ERROR: No glucose sample found...") return } @@ -132,25 +134,29 @@ class GlucoseActivityManager { endDateChart = predictedGlucose.startDate.addingTimeInterval(.hours(4)) } + guard let endDateChart = endDateChart else { + return + } + var presetContext: Preset? = nil - if let preset = self.preset, let endDateChart = endDateChart { + if let override = self.loopSettings.preMealOverride ?? loopSettings.scheduleOverride { presetContext = Preset( - title: preset.getTitle(), - endDate: preset.duration.isInfinite ? endDateChart : min(Date.now + preset.duration.timeInterval, endDateChart), - minValue: preset.settings.targetRange?.lowerBound.doubleValue(for: unit) ?? 0, - maxValue: preset.settings.targetRange?.upperBound.doubleValue(for: unit) ?? 0 + title: override.getTitle(), + endDate: override.duration.isInfinite ? endDateChart : min(Date.now + override.duration.timeInterval, endDateChart), + minValue: override.settings.targetRange?.lowerBound.doubleValue(for: unit) ?? 0, + maxValue: override.settings.targetRange?.upperBound.doubleValue(for: unit) ?? 0 ) } var glucoseRanges: [GlucoseRangeValue] = [] - if let glucoseRangeSchedule = self.glucoseRangeSchedule, let start = glucoseSamples.first?.startDate, let end = endDateChart { - for item in glucoseRangeSchedule.quantityBetween(start: start, end: end) { + if let glucoseRangeSchedule = self.loopSettings.glucoseTargetRangeSchedule, let start = glucoseSamples.first?.startDate { + for item in glucoseRangeSchedule.quantityBetween(start: start, end: endDateChart) { glucoseRanges.append(GlucoseRangeValue( id: UUID(), minValue: item.value.lowerBound.doubleValue(for: unit), maxValue: item.value.upperBound.doubleValue(for: unit), startDate: max(item.startDate, start), - endDate: min(item.endDate, end) + endDate: min(item.endDate, endDateChart) )) } } @@ -217,6 +223,7 @@ class GlucoseActivityManager { @objc private func appMovedToForeground() { guard let activity = self.activity else { + print("ERROR: appMovedToForeground: No Live activity found...") return } @@ -261,7 +268,9 @@ class GlucoseActivityManager { ) } self.startDate = Date.now - } catch {} + } catch { + print("ERROR: Error while ending live activity: \(error.localizedDescription)") + } } private func needsRecreation() -> Bool { @@ -400,17 +409,9 @@ class GlucoseActivityManager { content: .init(state: dynamicState, staleDate: nil), pushType: .token ) - } catch {} - } - - func presetActivated(preset: TemporaryScheduleOverride) { - self.preset = preset - self.update() - } - - func presetDeactivated() { - self.preset = nil - self.update() + } catch { + print("ERROR: Error while creating empty live activity: \(error.localizedDescription)") + } } } diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index 795d2bd48d..0946c6cdc2 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -130,8 +130,7 @@ final class LoopDataManager { self.liveActivityManager = GlucoseActivityManager( glucoseStore: self.glucoseStore, doseStore: self.doseStore, - glucoseRangeSchedule: settings.glucoseTargetRangeSchedule, - preset: self.settings.scheduleOverride + loopSettings: self.settings ) overrideIntentObserver = UserDefaults.appGroup?.observe(\.intentExtensionOverrideToSet, options: [.new], changeHandler: {[weak self] (defaults, change) in @@ -152,17 +151,15 @@ final class LoopDataManager { observer.presetDeactivated(context: oldPreset.context) } } - self?.liveActivityManager?.presetDeactivated() } + settings.scheduleOverride = preset.createOverride(enactTrigger: .remote("Siri")) if let observers = self?.presetActivationObservers { for observer in observers { observer.presetActivated(context: .preset(preset), duration: preset.duration) } } - if let override = settings.scheduleOverride { - self?.liveActivityManager?.presetActivated(preset: override) - } + self?.liveActivityManager?.update() } // Remove the override from UserDefaults so we don't set it multiple times appGroup.intentExtensionOverrideToSet = nil @@ -272,14 +269,14 @@ final class LoopDataManager { for observer in self.presetActivationObservers { observer.presetDeactivated(context: oldPreset.context) } - self.liveActivityManager?.presetDeactivated() + self.liveActivityManager?.update() } if let newPreset = newValue.scheduleOverride { for observer in self.presetActivationObservers { observer.presetActivated(context: newPreset.context, duration: newPreset.duration) } - self.liveActivityManager?.presetActivated(preset: newPreset) + self.liveActivityManager?.update() } // Invalidate cached effects affected by the override diff --git a/Loop/Views/LiveActivityManagementView.swift b/Loop/Views/LiveActivityManagementView.swift index 6e8a6c4d71..690b0506dd 100644 --- a/Loop/Views/LiveActivityManagementView.swift +++ b/Loop/Views/LiveActivityManagementView.swift @@ -14,6 +14,8 @@ import HealthKit struct LiveActivityManagementView: View { @EnvironmentObject private var displayGlucosePreference: DisplayGlucosePreference + @State private var isSharePresented: Bool = false + private var enabled: Binding = Binding( get: { (UserDefaults.standard.liveActivity ?? LiveActivitySettings()).enabled }, @@ -107,6 +109,12 @@ struct LiveActivityManagementView: View { destination: LiveActivityBottomRowManagerView(), label: { Text(NSLocalizedString("Bottom row configuration", comment: "Title for Bottom row configuration")) } ) + Button("Share logs") { + self.isSharePresented = true + } + .sheet(isPresented: $isSharePresented, onDismiss: { }, content: { + LAActivityViewController(activityItems: getLogs()) + }) } } .insetGroupedListStyle() @@ -132,4 +140,8 @@ struct LiveActivityManagementView: View { UserDefaults.standard.liveActivity = settings NotificationCenter.default.post(name: .LiveActivitySettingsChanged, object: settings) } + + private func getLogs() -> [URL] { + return LALogger(category: "View").getDebugLogs() + } } From 985a1e4395d74560fbbe6a118a1825fed9d65fe6 Mon Sep 17 00:00:00 2001 From: "bastiaan.verhaar" Date: Wed, 31 Jul 2024 20:47:58 +0200 Subject: [PATCH 19/33] fix: Restruct preset code --- Loop.xcodeproj/project.pbxproj | 2 - .../GlucoseActivityManager.swift | 77 ++++++++++++++----- Loop/Managers/LoopDataManager.swift | 14 ++-- 3 files changed, 64 insertions(+), 29 deletions(-) diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index fbd391a419..606155f7d1 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -71,7 +71,6 @@ 1DDE273E24AEA4B000796622 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DE09BA824A3E23F009EE9F9 /* SettingsView.swift */; }; 1DDE274024AEA4F200796622 /* NotificationsCriticalAlertPermissionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DA46B5F2492E2E300D71A63 /* NotificationsCriticalAlertPermissionsView.swift */; }; 1DFE9E172447B6270082C280 /* UserNotificationAlertSchedulerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DFE9E162447B6270082C280 /* UserNotificationAlertSchedulerTests.swift */; }; - 3E292ED82C5AB0FC009F3136 /* LiveActivityManagementView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B851FFC42C37221800D738C1 /* LiveActivityManagementView.swift */; }; 43027F0F1DFE0EC900C51989 /* HKUnit.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F526D5E1DF2459000A04910 /* HKUnit.swift */; }; 4302F4E11D4E9C8900F0FCAF /* TextFieldTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4302F4E01D4E9C8900F0FCAF /* TextFieldTableViewController.swift */; }; 4302F4E31D4EA54200F0FCAF /* InsulinDeliveryTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4302F4E21D4EA54200F0FCAF /* InsulinDeliveryTableViewController.swift */; }; @@ -3963,7 +3962,6 @@ 89F9119424358E4500ECCAF3 /* CarbAbsorptionTime.swift in Sources */, 895788B1242E69A2002CB114 /* Color.swift in Sources */, 89E08FC2242E73DC000D719B /* CarbAmountPositionKey.swift in Sources */, - 3E292ED82C5AB0FC009F3136 /* LiveActivityManagementView.swift in Sources */, 4F11D3C420DD881A006E072C /* WatchHistoricalGlucose.swift in Sources */, E98A55F724EEE1E10008715D /* OnOffSelectionView.swift in Sources */, 89E08FCA242E7714000D719B /* UIFont.swift in Sources */, diff --git a/Loop/Managers/Live Activity/GlucoseActivityManager.swift b/Loop/Managers/Live Activity/GlucoseActivityManager.swift index 74850f4d25..8f1d122dc5 100644 --- a/Loop/Managers/Live Activity/GlucoseActivityManager.swift +++ b/Loop/Managers/Live Activity/GlucoseActivityManager.swift @@ -25,7 +25,7 @@ class GlucoseActivityManager { private let glucoseStore: GlucoseStoreProtocol private let doseStore: DoseStoreProtocol - private let loopSettings: LoopSettings + private var loopSettings: LoopSettings private var startDate: Date = Date.now private var settings: LiveActivitySettings = UserDefaults.standard.liveActivity ?? LiveActivitySettings() @@ -80,7 +80,12 @@ class GlucoseActivityManager { } } - public func update() { + public func update(loopSettings: LoopSettings) { + self.loopSettings = loopSettings + update() + } + + private func update() { Task { if self.needsRecreation(), await UIApplication.shared.applicationState == .active { // activity is no longer visible or old. End it and try to push the update again @@ -140,10 +145,10 @@ class GlucoseActivityManager { } var presetContext: Preset? = nil - if let override = self.loopSettings.preMealOverride ?? loopSettings.scheduleOverride { + if let override = self.loopSettings.preMealOverride ?? self.loopSettings.scheduleOverride, let start = glucoseSamples.first?.startDate { presetContext = Preset( title: override.getTitle(), - startDate: override.startDate, + startDate: max(override.startDate, start), endDate: override.duration.isInfinite ? endDateChart : min(Date.now + override.duration.timeInterval, endDateChart), minValue: override.settings.targetRange?.lowerBound.doubleValue(for: unit) ?? 0, maxValue: override.settings.targetRange?.upperBound.doubleValue(for: unit) ?? 0 @@ -158,22 +163,54 @@ class GlucoseActivityManager { let startDate = max(item.startDate, start) let endDate = min(item.endDate, endDateChart) - if let presetContext = presetContext, presetContext.startDate > startDate, presetContext.endDate < endDate { - // A preset is active during this schedule - glucoseRanges.append(GlucoseRangeValue( - id: UUID(), - minValue: minValue, - maxValue: maxValue, - startDate: startDate, - endDate: presetContext.startDate - )) - glucoseRanges.append(GlucoseRangeValue( - id: UUID(), - minValue: minValue, - maxValue: maxValue, - startDate: presetContext.endDate, - endDate: endDate - )) + if let presetContext = presetContext { + if presetContext.startDate > startDate, presetContext.endDate < endDate { + // A preset is active during this schedule + glucoseRanges.append(GlucoseRangeValue( + id: UUID(), + minValue: minValue, + maxValue: maxValue, + startDate: startDate, + endDate: presetContext.startDate + )) + glucoseRanges.append(GlucoseRangeValue( + id: UUID(), + minValue: minValue, + maxValue: maxValue, + startDate: presetContext.endDate, + endDate: endDate + )) + } else if presetContext.endDate > startDate, presetContext.endDate < endDate { + // Cut off the start of the glucose target + glucoseRanges.append(GlucoseRangeValue( + id: UUID(), + minValue: minValue, + maxValue: maxValue, + startDate: presetContext.endDate, + endDate: endDate + )) + } else if presetContext.startDate < endDate, presetContext.startDate > startDate { + // Cut off the end of the glucose target + glucoseRanges.append(GlucoseRangeValue( + id: UUID(), + minValue: minValue, + maxValue: maxValue, + startDate: startDate, + endDate: presetContext.startDate + )) + if presetContext.endDate == endDateChart { + break + } + } else { + // No overlap with target and override + glucoseRanges.append(GlucoseRangeValue( + id: UUID(), + minValue: minValue, + maxValue: maxValue, + startDate: startDate, + endDate: endDate + )) + } } else { glucoseRanges.append(GlucoseRangeValue( id: UUID(), diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index 957174f9b3..c5220cb0c5 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -159,7 +159,7 @@ final class LoopDataManager { observer.presetActivated(context: .preset(preset), duration: preset.duration) } } - self?.liveActivityManager?.update() + self?.liveActivityManager?.update(loopSettings: settings) } // Remove the override from UserDefaults so we don't set it multiple times appGroup.intentExtensionOverrideToSet = nil @@ -177,7 +177,7 @@ final class LoopDataManager { ) { (note) -> Void in self.dataAccessQueue.async { self.logger.default("Received notification of carb entries changing") - self.liveActivityManager?.update() + self.liveActivityManager?.update(loopSettings: self.settings) self.carbEffect = nil self.carbsOnBoard = nil @@ -193,7 +193,7 @@ final class LoopDataManager { ) { (note) in self.dataAccessQueue.async { self.logger.default("Received notification of glucose samples changing") - self.liveActivityManager?.update() + self.liveActivityManager?.update(loopSettings: self.settings) self.glucoseMomentumEffect = nil self.remoteRecommendationNeedsUpdating = true @@ -208,7 +208,7 @@ final class LoopDataManager { ) { (note) in self.dataAccessQueue.async { self.logger.default("Received notification of dosing changing") - self.liveActivityManager?.update() + self.liveActivityManager?.update(loopSettings: self.settings) self.clearCachedInsulinEffects() self.remoteRecommendationNeedsUpdating = true @@ -261,7 +261,7 @@ final class LoopDataManager { // The prediction isn't actually invalid, but a target range change requires recomputing recommended doses predictedGlucose = nil - self.liveActivityManager?.update() + self.liveActivityManager?.update(loopSettings: newValue) } if newValue.scheduleOverride != oldValue.scheduleOverride { @@ -271,14 +271,14 @@ final class LoopDataManager { for observer in self.presetActivationObservers { observer.presetDeactivated(context: oldPreset.context) } - self.liveActivityManager?.update() + self.liveActivityManager?.update(loopSettings: newValue) } if let newPreset = newValue.scheduleOverride { for observer in self.presetActivationObservers { observer.presetActivated(context: newPreset.context, duration: newPreset.duration) } - self.liveActivityManager?.update() + self.liveActivityManager?.update(loopSettings: newValue) } // Invalidate cached effects affected by the override From b72ab227c29514c2b723eb6d8529f09871ed5e6d Mon Sep 17 00:00:00 2001 From: bastiaanv Date: Thu, 1 Aug 2024 20:31:39 +0200 Subject: [PATCH 20/33] feat: Allow to disable colored chart --- .../Live Activity/ChartView.swift | 13 +- .../GlucoseLiveActivityConfiguration.swift | 4 + .../GlucoseActivityAttributes.swift | 1 + .../GlucoseActivityManager.swift | 11 +- Loop/Views/LiveActivityManagementView.swift | 135 +++++++----------- LoopCore/LiveActivitySettings.swift | 4 + 6 files changed, 78 insertions(+), 90 deletions(-) diff --git a/Loop Widget Extension/Live Activity/ChartView.swift b/Loop Widget Extension/Live Activity/ChartView.swift index 4480c5b299..eab8b4433e 100644 --- a/Loop Widget Extension/Live Activity/ChartView.swift +++ b/Loop Widget Extension/Live Activity/ChartView.swift @@ -14,9 +14,10 @@ struct ChartView: View { private let glucoseSampleData: [ChartValues] private let predicatedData: [ChartValues] private let glucoseRanges: [GlucoseRangeValue] + private let useLimits: Bool private let preset: Preset? - init(glucoseSamples: [GlucoseSampleAttributes], predicatedGlucose: [Double], predicatedStartDate: Date?, predicatedInterval: TimeInterval?, lowerLimit: Double, upperLimit: Double, glucoseRanges: [GlucoseRangeValue], preset: Preset?) { + init(glucoseSamples: [GlucoseSampleAttributes], predicatedGlucose: [Double], predicatedStartDate: Date?, predicatedInterval: TimeInterval?, useLimits: Bool, lowerLimit: Double, upperLimit: Double, glucoseRanges: [GlucoseRangeValue], preset: Preset?) { self.glucoseSampleData = ChartValues.convert(data: glucoseSamples, lowerLimit: lowerLimit, upperLimit: upperLimit) self.predicatedData = ChartValues.convert( data: predicatedGlucose, @@ -25,13 +26,15 @@ struct ChartView: View { lowerLimit: lowerLimit, upperLimit: upperLimit ) + self.useLimits = useLimits self.preset = preset self.glucoseRanges = glucoseRanges } - init(glucoseSamples: [GlucoseSampleAttributes], lowerLimit: Double, upperLimit: Double, glucoseRanges: [GlucoseRangeValue], preset: Preset?) { + init(glucoseSamples: [GlucoseSampleAttributes], useLimits: Bool, lowerLimit: Double, upperLimit: Double, glucoseRanges: [GlucoseRangeValue], preset: Preset?) { self.glucoseSampleData = ChartValues.convert(data: glucoseSamples, lowerLimit: lowerLimit, upperLimit: upperLimit) self.predicatedData = [] + self.useLimits = useLimits self.preset = preset self.glucoseRanges = glucoseRanges } @@ -76,10 +79,14 @@ struct ChartView: View { .lineStyle(StrokeStyle(lineWidth: 3, dash: [2, 3])) } } - .chartForegroundStyleScale([ + .chartForegroundStyleScale(useLimits ? [ "Good": .green, "High": .orange, "Low": .red + ] : [ + "Good": .primary, + "High": .primary, + "Low": .primary ]) .chartPlotStyle { plotContent in plotContent.background(.cyan.opacity(0.15)) diff --git a/Loop Widget Extension/Live Activity/GlucoseLiveActivityConfiguration.swift b/Loop Widget Extension/Live Activity/GlucoseLiveActivityConfiguration.swift index 3e6fce0217..0a13cd37f7 100644 --- a/Loop Widget Extension/Live Activity/GlucoseLiveActivityConfiguration.swift +++ b/Loop Widget Extension/Live Activity/GlucoseLiveActivityConfiguration.swift @@ -38,6 +38,7 @@ struct GlucoseLiveActivityConfiguration: Widget { predicatedGlucose: context.state.predicatedGlucose, predicatedStartDate: context.state.predicatedStartDate, predicatedInterval: context.state.predicatedInterval, + useLimits: context.attributes.useLimits, lowerLimit: context.state.isMmol ? context.attributes.lowerLimitChartMmol : context.attributes.lowerLimitChartMg, upperLimit: context.state.isMmol ? context.attributes.upperLimitChartMmol : context.attributes.upperLimitChartMg, glucoseRanges: context.state.glucoseRanges, @@ -47,6 +48,7 @@ struct GlucoseLiveActivityConfiguration: Widget { } else { ChartView( glucoseSamples: context.state.glucoseSamples, + useLimits: context.attributes.useLimits, lowerLimit: context.state.isMmol ? context.attributes.lowerLimitChartMmol : context.attributes.lowerLimitChartMg, upperLimit: context.state.isMmol ? context.attributes.upperLimitChartMmol : context.attributes.upperLimitChartMg, glucoseRanges: context.state.glucoseRanges, @@ -138,6 +140,7 @@ struct GlucoseLiveActivityConfiguration: Widget { predicatedGlucose: context.state.predicatedGlucose, predicatedStartDate: context.state.predicatedStartDate, predicatedInterval: context.state.predicatedInterval, + useLimits: context.attributes.useLimits, lowerLimit: context.state.isMmol ? context.attributes.lowerLimitChartMmol : context.attributes.lowerLimitChartMg, upperLimit: context.state.isMmol ? context.attributes.upperLimitChartMmol : context.attributes.upperLimitChartMg, glucoseRanges: context.state.glucoseRanges, @@ -147,6 +150,7 @@ struct GlucoseLiveActivityConfiguration: Widget { } else { ChartView( glucoseSamples: context.state.glucoseSamples, + useLimits: context.attributes.useLimits, lowerLimit: context.state.isMmol ? context.attributes.lowerLimitChartMmol : context.attributes.lowerLimitChartMg, upperLimit: context.state.isMmol ? context.attributes.upperLimitChartMmol : context.attributes.upperLimitChartMg, glucoseRanges: context.state.glucoseRanges, diff --git a/Loop/Managers/Live Activity/GlucoseActivityAttributes.swift b/Loop/Managers/Live Activity/GlucoseActivityAttributes.swift index 90329be9ef..293eeb65ad 100644 --- a/Loop/Managers/Live Activity/GlucoseActivityAttributes.swift +++ b/Loop/Managers/Live Activity/GlucoseActivityAttributes.swift @@ -40,6 +40,7 @@ public struct GlucoseActivityAttributes: ActivityAttributes { } public let addPredictiveLine: Bool + public let useLimits: Bool public let upperLimitChartMmol: Double public let lowerLimitChartMmol: Double public let upperLimitChartMg: Double diff --git a/Loop/Managers/Live Activity/GlucoseActivityManager.swift b/Loop/Managers/Live Activity/GlucoseActivityManager.swift index 8f1d122dc5..d3dadbb274 100644 --- a/Loop/Managers/Live Activity/GlucoseActivityManager.swift +++ b/Loop/Managers/Live Activity/GlucoseActivityManager.swift @@ -267,6 +267,7 @@ class GlucoseActivityManager { } else if newSettings.addPredictiveLine != self.settings.addPredictiveLine || + newSettings.useLimits != self.settings.useLimits || newSettings.lowerLimitChartMmol != self.settings.lowerLimitChartMmol || newSettings.upperLimitChartMmol != self.settings.upperLimitChartMmol || newSettings.lowerLimitChartMg != self.settings.lowerLimitChartMg || @@ -320,6 +321,7 @@ class GlucoseActivityManager { self.activity = try Activity.request( attributes: GlucoseActivityAttributes( addPredictiveLine: self.settings.addPredictiveLine, + useLimits: self.settings.useLimits, upperLimitChartMmol: self.settings.upperLimitChartMmol, lowerLimitChartMmol: self.settings.lowerLimitChartMmol, upperLimitChartMg: self.settings.upperLimitChartMg, @@ -463,10 +465,11 @@ class GlucoseActivityManager { self.activity = try Activity.request( attributes: GlucoseActivityAttributes( addPredictiveLine: settings.addPredictiveLine, - upperLimitChartMmol: self.settings.upperLimitChartMmol, - lowerLimitChartMmol: self.settings.lowerLimitChartMmol, - upperLimitChartMg: self.settings.upperLimitChartMg, - lowerLimitChartMg: self.settings.lowerLimitChartMg + useLimits: settings.useLimits, + upperLimitChartMmol: settings.upperLimitChartMmol, + lowerLimitChartMmol: settings.lowerLimitChartMmol, + upperLimitChartMg: settings.upperLimitChartMg, + lowerLimitChartMg: settings.lowerLimitChartMg ), content: .init(state: dynamicState, staleDate: nil), pushType: .token diff --git a/Loop/Views/LiveActivityManagementView.swift b/Loop/Views/LiveActivityManagementView.swift index 6e8a6c4d71..aeb926ac80 100644 --- a/Loop/Views/LiveActivityManagementView.swift +++ b/Loop/Views/LiveActivityManagementView.swift @@ -14,92 +14,60 @@ import HealthKit struct LiveActivityManagementView: View { @EnvironmentObject private var displayGlucosePreference: DisplayGlucosePreference - private var enabled: Binding = - Binding( - get: { (UserDefaults.standard.liveActivity ?? LiveActivitySettings()).enabled }, - set: { newValue in - var settings = UserDefaults.standard.liveActivity ?? LiveActivitySettings() - settings.enabled = newValue - - UserDefaults.standard.liveActivity = settings - NotificationCenter.default.post(name: .LiveActivitySettingsChanged, object: settings) - } - ) - - private var addPredictiveLine: Binding = - Binding( - get: { (UserDefaults.standard.liveActivity ?? LiveActivitySettings()).addPredictiveLine }, - set: { newValue in - var settings = UserDefaults.standard.liveActivity ?? LiveActivitySettings() - settings.addPredictiveLine = newValue - - UserDefaults.standard.liveActivity = settings - NotificationCenter.default.post(name: .LiveActivitySettingsChanged, object: settings) - } - ) - - private var upperLimitMmol: Binding = - Binding( - get: { (UserDefaults.standard.liveActivity ?? LiveActivitySettings()).upperLimitChartMmol }, - set: { newValue in - var settings = UserDefaults.standard.liveActivity ?? LiveActivitySettings() - settings.upperLimitChartMmol = newValue - - UserDefaults.standard.liveActivity = settings - NotificationCenter.default.post(name: .LiveActivitySettingsChanged, object: settings) - - } - ) - - private var lowerLimitMmol: Binding = - Binding( - get: { (UserDefaults.standard.liveActivity ?? LiveActivitySettings()).lowerLimitChartMmol }, - set: { newValue in - var settings = UserDefaults.standard.liveActivity ?? LiveActivitySettings() - settings.lowerLimitChartMmol = newValue - - UserDefaults.standard.liveActivity = settings - NotificationCenter.default.post(name: .LiveActivitySettingsChanged, object: settings) - - } - ) - - private var upperLimitMg: Binding = - Binding( - get: { (UserDefaults.standard.liveActivity ?? LiveActivitySettings()).upperLimitChartMg }, - set: { newValue in - var settings = UserDefaults.standard.liveActivity ?? LiveActivitySettings() - settings.upperLimitChartMg = newValue - - UserDefaults.standard.liveActivity = settings - NotificationCenter.default.post(name: .LiveActivitySettingsChanged, object: settings) - - } - ) - - private var lowerLimitMg: Binding = - Binding( - get: { (UserDefaults.standard.liveActivity ?? LiveActivitySettings()).lowerLimitChartMg }, - set: { newValue in - var settings = UserDefaults.standard.liveActivity ?? LiveActivitySettings() - settings.lowerLimitChartMg = newValue - - UserDefaults.standard.liveActivity = settings - NotificationCenter.default.post(name: .LiveActivitySettingsChanged, object: settings) - - } - ) + @State private var enabled: Bool + @State private var addPredictiveLine: Bool + @State private var useLimits: Bool + @State private var upperLimitMmol: Double + @State private var lowerLimitMmol: Double + @State private var upperLimitMg: Double + @State private var lowerLimitMg: Double + init() { + let liveActivitySettings = UserDefaults.standard.liveActivity ?? LiveActivitySettings() + + self.enabled = liveActivitySettings.enabled + self.addPredictiveLine = liveActivitySettings.addPredictiveLine + self.useLimits = liveActivitySettings.useLimits + self.upperLimitMmol = liveActivitySettings.upperLimitChartMmol + self.lowerLimitMmol = liveActivitySettings.lowerLimitChartMmol + self.upperLimitMg = liveActivitySettings.upperLimitChartMg + self.lowerLimitMg = liveActivitySettings.lowerLimitChartMg + } + var body: some View { List { - Toggle(NSLocalizedString("Enabled", comment: "Title for enable live activity toggle"), isOn: enabled) - Toggle(NSLocalizedString("Add predictive line", comment: "Title for predictive line toggle"), isOn: addPredictiveLine) - if self.displayGlucosePreference.unit == .millimolesPerLiter { - TextInput(label: "Upper limit chart", value: upperLimitMmol) - TextInput(label: "Lower limit chart", value: lowerLimitMmol) - } else { - TextInput(label: "Upper limit chart", value: upperLimitMg) - TextInput(label: "Lower limit chart", value: lowerLimitMg) + Toggle(NSLocalizedString("Enabled", comment: "Title for enable live activity toggle"), isOn: $enabled) + .onChange(of: enabled) { newValue in + self.mutate { settings in + settings.enabled = newValue + } + } + + Toggle(NSLocalizedString("Add predictive line", comment: "Title for predictive line toggle"), isOn: $addPredictiveLine) + .onChange(of: addPredictiveLine) { newValue in + self.mutate { settings in + settings.addPredictiveLine = newValue + } + } + Toggle(NSLocalizedString("Add predictive line", comment: "Title for predictive line toggle"), isOn: $useLimits) + .onChange(of: useLimits) { newValue in + self.mutate { settings in + settings.useLimits = newValue + } + } + + if useLimits { + if self.displayGlucosePreference.unit == .millimolesPerLiter { + TextInput(label: "Upper limit chart", value: $upperLimitMmol) + .transition(.move(edge: useLimits ? .top : .bottom)) + TextInput(label: "Lower limit chart", value: $lowerLimitMmol) + .transition(.move(edge: useLimits ? .top : .bottom)) + } else { + TextInput(label: "Upper limit chart", value: $upperLimitMg) + .transition(.move(edge: useLimits ? .top : .bottom)) + TextInput(label: "Lower limit chart", value: $lowerLimitMg) + .transition(.move(edge: useLimits ? .top : .bottom)) + } } Section { @@ -109,6 +77,7 @@ struct LiveActivityManagementView: View { ) } } + .animation(.easeInOut, value: UUID()) .insetGroupedListStyle() .navigationBarTitle(Text(NSLocalizedString("Live activity", comment: "Live activity screen title"))) } diff --git a/LoopCore/LiveActivitySettings.swift b/LoopCore/LiveActivitySettings.swift index 0218db4206..313537b8c6 100644 --- a/LoopCore/LiveActivitySettings.swift +++ b/LoopCore/LiveActivitySettings.swift @@ -62,6 +62,7 @@ public enum BottomRowConfiguration: Codable { public struct LiveActivitySettings: Codable { public var enabled: Bool public var addPredictiveLine: Bool + public var useLimits: Bool public var upperLimitChartMmol: Double public var lowerLimitChartMmol: Double public var upperLimitChartMg: Double @@ -72,6 +73,7 @@ public struct LiveActivitySettings: Codable { case enabled case addPredictiveLine case bottomRowConfiguration + case useLimits case upperLimitChartMmol case lowerLimitChartMmol case upperLimitChartMg @@ -87,6 +89,7 @@ public struct LiveActivitySettings: Codable { let values = try decoder.container(keyedBy: CodingKeys.self) enabled = try values.decode(Bool.self, forKey: .enabled) addPredictiveLine = try values.decode(Bool.self, forKey: .addPredictiveLine) + useLimits = try values.decodeIfPresent(Bool.self, forKey: .useLimits) ?? true upperLimitChartMmol = try values.decode(Double?.self, forKey: .upperLimitChartMmol) ?? LiveActivitySettings.defaultUpperLimitMmol lowerLimitChartMmol = try values.decode(Double?.self, forKey: .lowerLimitChartMmol) ?? LiveActivitySettings.defaultLowerLimitMmol upperLimitChartMg = try values.decode(Double?.self, forKey: .upperLimitChartMg) ?? LiveActivitySettings.defaultUpperLimitMg @@ -97,6 +100,7 @@ public struct LiveActivitySettings: Codable { public init() { self.enabled = true self.addPredictiveLine = true + useLimits = true self.upperLimitChartMmol = LiveActivitySettings.defaultUpperLimitMmol self.lowerLimitChartMmol = LiveActivitySettings.defaultLowerLimitMmol self.upperLimitChartMg = LiveActivitySettings.defaultUpperLimitMg From ae359a58b5b25b468916b433a25c5dfe9938bce9 Mon Sep 17 00:00:00 2001 From: bastiaanv Date: Thu, 1 Aug 2024 20:42:50 +0200 Subject: [PATCH 21/33] fix: Minor fixes for BG coloring --- .../Live Activity/ChartView.swift | 25 ++++++++----------- .../GlucoseLiveActivityConfiguration.swift | 3 ++- Loop/Views/LiveActivityManagementView.swift | 2 +- 3 files changed, 13 insertions(+), 17 deletions(-) diff --git a/Loop Widget Extension/Live Activity/ChartView.swift b/Loop Widget Extension/Live Activity/ChartView.swift index eab8b4433e..237ddebd82 100644 --- a/Loop Widget Extension/Live Activity/ChartView.swift +++ b/Loop Widget Extension/Live Activity/ChartView.swift @@ -14,27 +14,25 @@ struct ChartView: View { private let glucoseSampleData: [ChartValues] private let predicatedData: [ChartValues] private let glucoseRanges: [GlucoseRangeValue] - private let useLimits: Bool private let preset: Preset? init(glucoseSamples: [GlucoseSampleAttributes], predicatedGlucose: [Double], predicatedStartDate: Date?, predicatedInterval: TimeInterval?, useLimits: Bool, lowerLimit: Double, upperLimit: Double, glucoseRanges: [GlucoseRangeValue], preset: Preset?) { - self.glucoseSampleData = ChartValues.convert(data: glucoseSamples, lowerLimit: lowerLimit, upperLimit: upperLimit) + self.glucoseSampleData = ChartValues.convert(data: glucoseSamples, useLimits: useLimits, lowerLimit: lowerLimit, upperLimit: upperLimit) self.predicatedData = ChartValues.convert( data: predicatedGlucose, startDate: predicatedStartDate ?? Date.now, interval: predicatedInterval ?? .minutes(5), + useLimits: useLimits, lowerLimit: lowerLimit, upperLimit: upperLimit ) - self.useLimits = useLimits self.preset = preset self.glucoseRanges = glucoseRanges } init(glucoseSamples: [GlucoseSampleAttributes], useLimits: Bool, lowerLimit: Double, upperLimit: Double, glucoseRanges: [GlucoseRangeValue], preset: Preset?) { - self.glucoseSampleData = ChartValues.convert(data: glucoseSamples, lowerLimit: lowerLimit, upperLimit: upperLimit) + self.glucoseSampleData = ChartValues.convert(data: glucoseSamples, useLimits: useLimits, lowerLimit: lowerLimit, upperLimit: upperLimit) self.predicatedData = [] - self.useLimits = useLimits self.preset = preset self.glucoseRanges = glucoseRanges } @@ -79,14 +77,11 @@ struct ChartView: View { .lineStyle(StrokeStyle(lineWidth: 3, dash: [2, 3])) } } - .chartForegroundStyleScale(useLimits ? [ + .chartForegroundStyleScale([ "Good": .green, "High": .orange, - "Low": .red - ] : [ - "Good": .primary, - "High": .primary, - "Low": .primary + "Low": .red, + "Default": .blue ]) .chartPlotStyle { plotContent in plotContent.background(.cyan.opacity(0.15)) @@ -132,7 +127,7 @@ struct ChartValues: Identifiable { self.color = color } - static func convert(data: [Double], startDate: Date, interval: TimeInterval, lowerLimit: Double, upperLimit: Double) -> [ChartValues] { + static func convert(data: [Double], startDate: Date, interval: TimeInterval, useLimits: Bool, lowerLimit: Double, upperLimit: Double) -> [ChartValues] { let twoHours = Date.now.addingTimeInterval(.hours(4)) return data.enumerated().filter { (index, item) in @@ -141,17 +136,17 @@ struct ChartValues: Identifiable { return ChartValues( x: startDate.addingTimeInterval(interval * Double(index)), y: item, - color: item < lowerLimit ? "Low" : item > upperLimit ? "High" : "Good" + color: !useLimits ? "Default" : item < lowerLimit ? "Low" : item > upperLimit ? "High" : "Good" ) } } - static func convert(data: [GlucoseSampleAttributes], lowerLimit: Double, upperLimit: Double) -> [ChartValues] { + static func convert(data: [GlucoseSampleAttributes], useLimits: Bool, lowerLimit: Double, upperLimit: Double) -> [ChartValues] { return data.map { item in return ChartValues( x: item.x, y: item.y, - color: item.y < lowerLimit ? "Low" : item.y > upperLimit ? "High" : "Good" + color: !useLimits ? "Default" : item.y < lowerLimit ? "Low" : item.y > upperLimit ? "High" : "Good" ) } } diff --git a/Loop Widget Extension/Live Activity/GlucoseLiveActivityConfiguration.swift b/Loop Widget Extension/Live Activity/GlucoseLiveActivityConfiguration.swift index 0a13cd37f7..a645678632 100644 --- a/Loop Widget Extension/Live Activity/GlucoseLiveActivityConfiguration.swift +++ b/Loop Widget Extension/Live Activity/GlucoseLiveActivityConfiguration.swift @@ -189,6 +189,7 @@ struct GlucoseLiveActivityConfiguration: Widget { VStack(alignment: .center) { Text("\(value)\(unit)") .font(.headline) + .foregroundStyle(.primary) .fontWeight(.heavy) .font(Font.body.leading(.tight)) Text(title) @@ -202,7 +203,7 @@ struct GlucoseLiveActivityConfiguration: Widget { HStack { Text(value + getArrowImage(trend)) .font(.title) - .foregroundStyle(getGlucoseColor(context.state.currentGlucose, context: context)) + .foregroundStyle(!context.attributes.useLimits ? .primary : getGlucoseColor(context.state.currentGlucose, context: context)) .fontWeight(.heavy) .font(Font.body.leading(.tight)) } diff --git a/Loop/Views/LiveActivityManagementView.swift b/Loop/Views/LiveActivityManagementView.swift index aeb926ac80..b76a1f7878 100644 --- a/Loop/Views/LiveActivityManagementView.swift +++ b/Loop/Views/LiveActivityManagementView.swift @@ -49,7 +49,7 @@ struct LiveActivityManagementView: View { settings.addPredictiveLine = newValue } } - Toggle(NSLocalizedString("Add predictive line", comment: "Title for predictive line toggle"), isOn: $useLimits) + Toggle(NSLocalizedString("Use BG coloring", comment: "Title for cBG coloring"), isOn: $useLimits) .onChange(of: useLimits) { newValue in self.mutate { settings in settings.useLimits = newValue From 61d66c0ec660442b43447dd3a7e8447e5c9eea7c Mon Sep 17 00:00:00 2001 From: bastiaanv Date: Sun, 4 Aug 2024 11:42:57 +0200 Subject: [PATCH 22/33] feat: Add carbohydrate history --- Loop/Managers/LoopDataManager.swift | 2 +- .../CarbAbsorptionViewController.swift | 31 ++++++++++++++++--- LoopCore/LoopCoreConstants.swift | 2 +- 3 files changed, 29 insertions(+), 6 deletions(-) diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index c5220cb0c5..8a72881394 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -1009,7 +1009,7 @@ extension LoopDataManager { let retrospectiveStart = lastGlucoseDate.addingTimeInterval(-type(of: retrospectiveCorrection).retrospectionInterval) - let earliestEffectDate = Date(timeInterval: .hours(-24), since: now()) + let earliestEffectDate = Date(timeInterval: .days(-2), since: now()) let nextCounteractionEffectDate = insulinCounteractionEffects.last?.endDate ?? earliestEffectDate let insulinEffectStartDate = nextCounteractionEffectDate.addingTimeInterval(.minutes(-5)) diff --git a/Loop/View Controllers/CarbAbsorptionViewController.swift b/Loop/View Controllers/CarbAbsorptionViewController.swift index fc770192e9..45e031a7b5 100644 --- a/Loop/View Controllers/CarbAbsorptionViewController.swift +++ b/Loop/View Controllers/CarbAbsorptionViewController.swift @@ -139,7 +139,7 @@ final class CarbAbsorptionViewController: LoopChartsTableViewController, Identif charts.updateEndDate(chartStartDate.addingTimeInterval(.hours(totalHours+1))) // When there is no data, this allows presenting current hour + 1 let midnight = Calendar.current.startOfDay(for: Date()) - let listStart = min(midnight, chartStartDate, Date(timeIntervalSinceNow: -deviceManager.carbStore.maximumAbsorptionTimeInterval)) + let previousMidnight = midnight - .days(1) let reloadGroup = DispatchGroup() let shouldUpdateGlucose = currentContext.contains(.glucose) @@ -157,12 +157,20 @@ final class CarbAbsorptionViewController: LoopChartsTableViewController, Identif if shouldUpdateGlucose || shouldUpdateCarbs { let allInsulinCounteractionEffects = state.insulinCounteractionEffects insulinCounteractionEffects = allInsulinCounteractionEffects.filterDateRange(chartStartDate, nil) + + let earliestCounteractionEffect = allInsulinCounteractionEffects.first?.startDate ?? Date() + // Show carb entries as far back as previous midnight, or only as far back as counteraction effects are available + let boundOnCarbList = max(previousMidnight, earliestCounteractionEffect) + // If counteraction effects are missing, at least show all the entries for today and those on the chart + let displayListStart = min(boundOnCarbList, midnight, chartStartDate) + // To estimate dynamic carb absorption for the entry at the start of the list, we need to fetch samples that might still be absorbing + let fetchEntriesStart = displayListStart.addingTimeInterval(-self.deviceManager.carbStore.maximumAbsorptionTimeInterval) reloadGroup.enter() - self.deviceManager.carbStore.getCarbStatus(start: listStart, end: nil, effectVelocities: allInsulinCounteractionEffects) { (result) in + self.deviceManager.carbStore.getCarbStatus(start: fetchEntriesStart, end: nil, effectVelocities: allInsulinCounteractionEffects) { (result) in switch result { case .success(let status): - carbStatuses = status + carbStatuses = status.filterDateRange(displayListStart, nil) carbsOnBoard = status.getClampedCarbsOnBoard() case .failure(let error): self.log.error("CarbStore failed to get carbStatus: %{public}@", String(describing: error)) @@ -286,6 +294,14 @@ final class CarbAbsorptionViewController: LoopChartsTableViewController, Identif formatter.timeStyle = .short return formatter }() + + private lazy var relativeTimeFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateStyle = .medium + formatter.doesRelativeDateFormatting = true + formatter.timeStyle = .short + return formatter + }() override func numberOfSections(in tableView: UITableView) -> Int { return Section.count @@ -343,7 +359,14 @@ final class CarbAbsorptionViewController: LoopChartsTableViewController, Identif } // Entry time - let startTime = timeFormatter.string(from: status.entry.startDate) + let startTime: String + // Indicate if an entry is from the previous day to avoid potential confusion + let midnight = Calendar.current.startOfDay(for: Date()) + if status.entry.startDate < midnight { + startTime = relativeTimeFormatter.string(from: status.entry.startDate) + } else { + startTime = timeFormatter.string(from: status.entry.startDate) + } if let absorptionTime = status.entry.absorptionTime, let duration = absorptionFormatter.string(from: absorptionTime) { diff --git a/LoopCore/LoopCoreConstants.swift b/LoopCore/LoopCoreConstants.swift index d56f2ab9b6..42e9dfa384 100644 --- a/LoopCore/LoopCoreConstants.swift +++ b/LoopCore/LoopCoreConstants.swift @@ -16,7 +16,7 @@ public enum LoopCoreConstants { /// The amount of time in the future a glucose value should be considered valid public static let futureGlucoseDataInterval = TimeInterval(minutes: 5) - public static let defaultCarbAbsorptionTimes: CarbStore.DefaultAbsorptionTimes = (fast: .minutes(30), medium: .hours(3), slow: .hours(5)) + public static let defaultCarbAbsorptionTimes: CarbStore.DefaultAbsorptionTimes = (fast: .hours(2), medium: .hours(3), slow: .hours(5)) /// How much historical glucose to include in a dosing decision /// Somewhat arbitrary, but typical maximum visible in bolus glucose preview From 0beeddc8cc94790a2409d1eb115daa1498f70233 Mon Sep 17 00:00:00 2001 From: bastiaanv Date: Thu, 8 Aug 2024 16:52:39 +0200 Subject: [PATCH 23/33] feat: Add small mode & minor rework bottom row --- .../GlucoseLiveActivityConfiguration.swift | 62 ++++++++------ .../GlucoseActivityAttributes.swift | 81 ++++++++++++------ .../GlucoseActivityManager.swift | 24 ++++-- Loop/Views/LiveActivityManagementView.swift | 85 +++++++++++++------ LoopCore/LiveActivitySettings.swift | 32 ++++++- 5 files changed, 195 insertions(+), 89 deletions(-) diff --git a/Loop Widget Extension/Live Activity/GlucoseLiveActivityConfiguration.swift b/Loop Widget Extension/Live Activity/GlucoseLiveActivityConfiguration.swift index a645678632..2baeb90be7 100644 --- a/Loop Widget Extension/Live Activity/GlucoseLiveActivityConfiguration.swift +++ b/Loop Widget Extension/Live Activity/GlucoseLiveActivityConfiguration.swift @@ -30,31 +30,33 @@ struct GlucoseLiveActivityConfiguration: Widget { // banner on the Home Screen of devices that don't support the Dynamic Island. ZStack { VStack { - HStack(spacing: 15) { - loopIcon(context) - if context.attributes.addPredictiveLine { - ChartView( - glucoseSamples: context.state.glucoseSamples, - predicatedGlucose: context.state.predicatedGlucose, - predicatedStartDate: context.state.predicatedStartDate, - predicatedInterval: context.state.predicatedInterval, - useLimits: context.attributes.useLimits, - lowerLimit: context.state.isMmol ? context.attributes.lowerLimitChartMmol : context.attributes.lowerLimitChartMg, - upperLimit: context.state.isMmol ? context.attributes.upperLimitChartMmol : context.attributes.upperLimitChartMg, - glucoseRanges: context.state.glucoseRanges, - preset: context.state.preset - ) - .frame(height: 85) - } else { - ChartView( - glucoseSamples: context.state.glucoseSamples, - useLimits: context.attributes.useLimits, - lowerLimit: context.state.isMmol ? context.attributes.lowerLimitChartMmol : context.attributes.lowerLimitChartMg, - upperLimit: context.state.isMmol ? context.attributes.upperLimitChartMmol : context.attributes.upperLimitChartMg, - glucoseRanges: context.state.glucoseRanges, - preset: context.state.preset - ) - .frame(height: 85) + if context.attributes.mode == .large { + HStack(spacing: 15) { + loopIcon(context) + if context.attributes.addPredictiveLine { + ChartView( + glucoseSamples: context.state.glucoseSamples, + predicatedGlucose: context.state.predicatedGlucose, + predicatedStartDate: context.state.predicatedStartDate, + predicatedInterval: context.state.predicatedInterval, + useLimits: context.attributes.useLimits, + lowerLimit: context.state.isMmol ? context.attributes.lowerLimitChartMmol : context.attributes.lowerLimitChartMg, + upperLimit: context.state.isMmol ? context.attributes.upperLimitChartMmol : context.attributes.upperLimitChartMg, + glucoseRanges: context.state.glucoseRanges, + preset: context.state.preset + ) + .frame(height: 85) + } else { + ChartView( + glucoseSamples: context.state.glucoseSamples, + useLimits: context.attributes.useLimits, + lowerLimit: context.state.isMmol ? context.attributes.lowerLimitChartMmol : context.attributes.lowerLimitChartMg, + upperLimit: context.state.isMmol ? context.attributes.upperLimitChartMmol : context.attributes.upperLimitChartMg, + glucoseRanges: context.state.glucoseRanges, + preset: context.state.preset + ) + .frame(height: 85) + } } } @@ -80,6 +82,9 @@ struct GlucoseLiveActivityConfiguration: Widget { trend: item.trend, context: context ) + + case .loopCircle: + bottomItemLoopCircle(context: context) } if index != endIndex { @@ -210,6 +215,13 @@ struct GlucoseLiveActivityConfiguration: Widget { } } + @ViewBuilder + private func bottomItemLoopCircle(context: ActivityViewContext) -> some View { + VStack(alignment: .center) { + loopIcon(context) + } + } + @ViewBuilder private func bottomSpacer(border: Bool) -> some View { Spacer() diff --git a/Loop/Managers/Live Activity/GlucoseActivityAttributes.swift b/Loop/Managers/Live Activity/GlucoseActivityAttributes.swift index 293eeb65ad..4efa8b761a 100644 --- a/Loop/Managers/Live Activity/GlucoseActivityAttributes.swift +++ b/Loop/Managers/Live Activity/GlucoseActivityAttributes.swift @@ -39,6 +39,7 @@ public struct GlucoseActivityAttributes: ActivityAttributes { public let predicatedInterval: TimeInterval? } + public let mode: LiveActivityMode public let addPredictiveLine: Bool public let useLimits: Bool public let upperLimitChartMmol: Double @@ -68,6 +69,7 @@ public struct BottomRowItem: Codable, Hashable { case generic case basal case currentBg + case loopCircle } public let type: BottomRowType @@ -83,37 +85,62 @@ public struct BottomRowItem: Codable, Hashable { public let rate: Double public let percentage: Double - init(label: String, value: String, unit: String) { - self.type = .generic - self.label = label - self.value = value - self.unit = unit - - self.trend = nil - self.rate = 0 - self.percentage = 0 + private init(type: BottomRowType, label: String?, value: String?, unit: String?, trend: GlucoseTrend?, rate: Double?, percentage: Double?) { + self.type = type + self.label = label ?? "" + self.value = value ?? "" + self.trend = trend + self.unit = unit ?? "" + self.rate = rate ?? 0 + self.percentage = percentage ?? 0 } - init(label: String, value: String, trend: GlucoseTrend?) { - self.type = .currentBg - self.label = label - self.value = value - self.trend = trend - - self.unit = "" - self.rate = 0 - self.percentage = 0 + static func generic(label: String, value: String, unit: String) -> BottomRowItem { + return BottomRowItem( + type: .generic, + label: label, + value: value, + unit: unit, + trend: nil, + rate: nil, + percentage: nil + ) } - init(rate: Double, percentage: Double) { - self.type = .basal - self.rate = rate - self.percentage = percentage - - self.label = "" - self.value = "" - self.unit = "" - self.trend = nil + static func basal(rate: Double, percentage: Double) -> BottomRowItem { + return BottomRowItem( + type: .basal, + label: nil, + value: nil, + unit: nil, + trend: nil, + rate: rate, + percentage: percentage + ) + } + + static func currentBg(label: String, value: String, trend: GlucoseTrend?) -> BottomRowItem { + return BottomRowItem( + type: .currentBg, + label: label, + value: value, + unit: nil, + trend: trend, + rate: nil, + percentage: nil + ) + } + + static func loopIcon() -> BottomRowItem { + return BottomRowItem( + type: .loopCircle, + label: nil, + value: nil, + unit: nil, + trend: nil, + rate: nil, + percentage: nil + ) } } diff --git a/Loop/Managers/Live Activity/GlucoseActivityManager.swift b/Loop/Managers/Live Activity/GlucoseActivityManager.swift index d3dadbb274..b8cf20b0bb 100644 --- a/Loop/Managers/Live Activity/GlucoseActivityManager.swift +++ b/Loop/Managers/Live Activity/GlucoseActivityManager.swift @@ -266,6 +266,7 @@ class GlucoseActivityManager { initEmptyActivity(settings: newSettings) } else if + newSettings.mode != self.settings.mode || newSettings.addPredictiveLine != self.settings.addPredictiveLine || newSettings.useLimits != self.settings.useLimits || newSettings.lowerLimitChartMmol != self.settings.lowerLimitChartMmol || @@ -320,6 +321,7 @@ class GlucoseActivityManager { if let dynamicState = dynamicState { self.activity = try Activity.request( attributes: GlucoseActivityAttributes( + mode: self.settings.mode, addPredictiveLine: self.settings.addPredictiveLine, useLimits: self.settings.useLimits, upperLimitChartMmol: self.settings.upperLimitChartMmol, @@ -407,37 +409,40 @@ class GlucoseActivityManager { return self.settings.bottomRowConfiguration.map { type in switch(type) { case .iob: - return BottomRowItem(label: type.name(), value: getInsulinOnBoard(), unit: "U") + return BottomRowItem.generic(label: type.name(), value: getInsulinOnBoard(), unit: "U") case .cob: var cob: String = "0" if let cobValue = statusContext?.carbsOnBoard { cob = self.cobFormatter.string(from: cobValue) ?? "??" } - return BottomRowItem(label: type.name(), value: cob, unit: "g") + return BottomRowItem.generic(label: type.name(), value: cob, unit: "g") case .basal: guard let netBasalContext = statusContext?.netBasal else { - return BottomRowItem(rate: 0, percentage: 0) + return BottomRowItem.basal(rate: 0, percentage: 0) } - return BottomRowItem(rate: netBasalContext.rate, percentage: netBasalContext.percentage) + return BottomRowItem.basal(rate: netBasalContext.rate, percentage: netBasalContext.percentage) case .currentBg: - return BottomRowItem(label: type.name(), value: "\(glucoseFormatter.string(from: currentGlucose) ?? "??")", trend: statusContext?.glucoseDisplay?.trendType) + return BottomRowItem.currentBg(label: type.name(), value: "\(glucoseFormatter.string(from: currentGlucose) ?? "??")", trend: statusContext?.glucoseDisplay?.trendType) case .eventualBg: guard let eventual = statusContext?.predictedGlucose?.values.last else { - return BottomRowItem(label: type.name(), value: "??", unit: "") + return BottomRowItem.generic(label: type.name(), value: "??", unit: "") } - return BottomRowItem(label: type.name(), value: glucoseFormatter.string(from: eventual) ?? "??", unit: "") + return BottomRowItem.generic(label: type.name(), value: glucoseFormatter.string(from: eventual) ?? "??", unit: "") case .deltaBg: - return BottomRowItem(label: type.name(), value: delta, unit: "") + return BottomRowItem.generic(label: type.name(), value: delta, unit: "") + + case .loopCircle: + return BottomRowItem.loopIcon() case .updatedAt: - return BottomRowItem(label: type.name(), value: timeFormatter.string(from: Date.now), unit: "") + return BottomRowItem.generic(label: type.name(), value: timeFormatter.string(from: Date.now), unit: "") } } } @@ -464,6 +469,7 @@ class GlucoseActivityManager { self.activity = try Activity.request( attributes: GlucoseActivityAttributes( + mode: settings.mode, addPredictiveLine: settings.addPredictiveLine, useLimits: settings.useLimits, upperLimitChartMmol: settings.upperLimitChartMmol, diff --git a/Loop/Views/LiveActivityManagementView.swift b/Loop/Views/LiveActivityManagementView.swift index b76a1f7878..0a691a9916 100644 --- a/Loop/Views/LiveActivityManagementView.swift +++ b/Loop/Views/LiveActivityManagementView.swift @@ -15,6 +15,8 @@ struct LiveActivityManagementView: View { @EnvironmentObject private var displayGlucosePreference: DisplayGlucosePreference @State private var enabled: Bool + @State private var mode: LiveActivityMode + @State var isEditingMode = false @State private var addPredictiveLine: Bool @State private var useLimits: Bool @State private var upperLimitMmol: Double @@ -26,6 +28,7 @@ struct LiveActivityManagementView: View { let liveActivitySettings = UserDefaults.standard.liveActivity ?? LiveActivitySettings() self.enabled = liveActivitySettings.enabled + self.mode = liveActivitySettings.mode self.addPredictiveLine = liveActivitySettings.addPredictiveLine self.useLimits = liveActivitySettings.useLimits self.upperLimitMmol = liveActivitySettings.upperLimitChartMmol @@ -36,40 +39,70 @@ struct LiveActivityManagementView: View { var body: some View { List { - Toggle(NSLocalizedString("Enabled", comment: "Title for enable live activity toggle"), isOn: $enabled) - .onChange(of: enabled) { newValue in - self.mutate { settings in - settings.enabled = newValue + Section { + Toggle(NSLocalizedString("Enabled", comment: "Title for enable live activity toggle"), isOn: $enabled) + .onChange(of: enabled) { newValue in + self.mutate { settings in + settings.enabled = newValue + } } - } - - Toggle(NSLocalizedString("Add predictive line", comment: "Title for predictive line toggle"), isOn: $addPredictiveLine) - .onChange(of: addPredictiveLine) { newValue in - self.mutate { settings in - settings.addPredictiveLine = newValue + + ExpandableSetting( + isEditing: $isEditingMode, + leadingValueContent: { + Text(NSLocalizedString("Mode", comment: "Title for mode live activity toggle")) + .foregroundStyle(isEditingMode ? .blue : .primary) + }, + trailingValueContent: { + Text(self.mode.name()) + .foregroundStyle(isEditingMode ? .blue : .primary) + }, + expandedContent: { + ResizeablePicker(selection: self.$mode.animation(), + data: LiveActivityMode.all, + formatter: { $0.name() }) } - } - Toggle(NSLocalizedString("Use BG coloring", comment: "Title for cBG coloring"), isOn: $useLimits) - .onChange(of: useLimits) { newValue in + ) + .onChange(of: self.mode) { newValue in self.mutate { settings in - settings.useLimits = newValue + settings.mode = newValue } } + } - if useLimits { - if self.displayGlucosePreference.unit == .millimolesPerLiter { - TextInput(label: "Upper limit chart", value: $upperLimitMmol) - .transition(.move(edge: useLimits ? .top : .bottom)) - TextInput(label: "Lower limit chart", value: $lowerLimitMmol) - .transition(.move(edge: useLimits ? .top : .bottom)) - } else { - TextInput(label: "Upper limit chart", value: $upperLimitMg) - .transition(.move(edge: useLimits ? .top : .bottom)) - TextInput(label: "Lower limit chart", value: $lowerLimitMg) - .transition(.move(edge: useLimits ? .top : .bottom)) + if mode == .large { + Section { + Toggle(NSLocalizedString("Add predictive line", comment: "Title for predictive line toggle"), isOn: $addPredictiveLine) + .transition(.move(edge: mode == .large ? .top : .bottom)) + .onChange(of: addPredictiveLine) { newValue in + self.mutate { settings in + settings.addPredictiveLine = newValue + } + } + Toggle(NSLocalizedString("Use BG coloring", comment: "Title for BG coloring"), isOn: $useLimits) + .transition(.move(edge: mode == .large ? .top : .bottom)) + .onChange(of: useLimits) { newValue in + self.mutate { settings in + settings.useLimits = newValue + } + } + + if useLimits { + if self.displayGlucosePreference.unit == .millimolesPerLiter { + TextInput(label: "Upper limit chart", value: $upperLimitMmol) + .transition(.move(edge: useLimits ? .top : .bottom)) + TextInput(label: "Lower limit chart", value: $lowerLimitMmol) + .transition(.move(edge: useLimits ? .top : .bottom)) + } else { + TextInput(label: "Upper limit chart", value: $upperLimitMg) + .transition(.move(edge: useLimits ? .top : .bottom)) + TextInput(label: "Lower limit chart", value: $lowerLimitMg) + .transition(.move(edge: useLimits ? .top : .bottom)) + } + } } } - + Section { NavigationLink( destination: LiveActivityBottomRowManagerView(), diff --git a/LoopCore/LiveActivitySettings.swift b/LoopCore/LiveActivitySettings.swift index 313537b8c6..02a3fbb264 100644 --- a/LoopCore/LiveActivitySettings.swift +++ b/LoopCore/LiveActivitySettings.swift @@ -15,10 +15,11 @@ public enum BottomRowConfiguration: Codable { case currentBg case eventualBg case deltaBg + case loopCircle case updatedAt static let defaults: [BottomRowConfiguration] = [.currentBg, .iob, .cob, .updatedAt] - public static let all: [BottomRowConfiguration] = [.iob, .cob, .basal, .currentBg, .eventualBg, .deltaBg, .updatedAt] + public static let all: [BottomRowConfiguration] = [.iob, .cob, .basal, .currentBg, .eventualBg, .deltaBg, .loopCircle, .updatedAt] public func name() -> String { switch self { @@ -34,6 +35,8 @@ public enum BottomRowConfiguration: Codable { return NSLocalizedString("Event", comment: "") case .deltaBg: return NSLocalizedString("Delta", comment: "") + case .loopCircle: + return NSLocalizedString("Loop", comment: "") case .updatedAt: return NSLocalizedString("Updated", comment: "") } @@ -53,14 +56,36 @@ public enum BottomRowConfiguration: Codable { return NSLocalizedString("Eventually", comment: "") case .deltaBg: return NSLocalizedString("Delta", comment: "") + case .loopCircle: + return NSLocalizedString("Loop circle", comment: "") case .updatedAt: return NSLocalizedString("Updated at", comment: "") } } } +public enum LiveActivityMode: Codable, CustomStringConvertible { + case large + case small + + public static let all: [LiveActivityMode] = [.large, .small] + public var description: String { + NSLocalizedString("In which mode do you want to render the Live Activity", comment: "") + } + + public func name() -> String { + switch self { + case .large: + return NSLocalizedString("Large", comment: "") + case .small: + return NSLocalizedString("Small", comment: "") + } + } +} + public struct LiveActivitySettings: Codable { public var enabled: Bool + public var mode: LiveActivityMode public var addPredictiveLine: Bool public var useLimits: Bool public var upperLimitChartMmol: Double @@ -71,6 +96,7 @@ public struct LiveActivitySettings: Codable { private enum CodingKeys: String, CodingKey { case enabled + case mode case addPredictiveLine case bottomRowConfiguration case useLimits @@ -88,6 +114,7 @@ public struct LiveActivitySettings: Codable { public init(from decoder:Decoder) throws { let values = try decoder.container(keyedBy: CodingKeys.self) enabled = try values.decode(Bool.self, forKey: .enabled) + mode = try values.decodeIfPresent(LiveActivityMode.self, forKey: .mode) ?? .large addPredictiveLine = try values.decode(Bool.self, forKey: .addPredictiveLine) useLimits = try values.decodeIfPresent(Bool.self, forKey: .useLimits) ?? true upperLimitChartMmol = try values.decode(Double?.self, forKey: .upperLimitChartMmol) ?? LiveActivitySettings.defaultUpperLimitMmol @@ -99,8 +126,9 @@ public struct LiveActivitySettings: Codable { public init() { self.enabled = true + self.mode = .large self.addPredictiveLine = true - useLimits = true + self.useLimits = true self.upperLimitChartMmol = LiveActivitySettings.defaultUpperLimitMmol self.lowerLimitChartMmol = LiveActivitySettings.defaultLowerLimitMmol self.upperLimitChartMg = LiveActivitySettings.defaultUpperLimitMg From 7aaa57bb483dbe51c1162ee01d7715268348bb22 Mon Sep 17 00:00:00 2001 From: bastiaanv Date: Thu, 8 Aug 2024 17:06:28 +0200 Subject: [PATCH 24/33] chore: Minor code cleanup --- .../GlucoseActivityManager.swift | 143 ++++++++++-------- 1 file changed, 78 insertions(+), 65 deletions(-) diff --git a/Loop/Managers/Live Activity/GlucoseActivityManager.swift b/Loop/Managers/Live Activity/GlucoseActivityManager.swift index b8cf20b0bb..79635198e9 100644 --- a/Loop/Managers/Live Activity/GlucoseActivityManager.swift +++ b/Loop/Managers/Live Activity/GlucoseActivityManager.swift @@ -14,7 +14,7 @@ import HealthKit import ActivityKit extension Notification.Name { - static let LiveActivitySettingsChanged = Notification.Name(rawValue: "com.loopKit.notification.LiveActivitySettingsChanged") + static let LiveActivitySettingsChanged = Notification.Name(rawValue: "com.loopKit.notification.LiveActivitySettingsChanged") } @available(iOS 16.2, *) @@ -157,70 +157,13 @@ class GlucoseActivityManager { var glucoseRanges: [GlucoseRangeValue] = [] if let glucoseRangeSchedule = self.loopSettings.glucoseTargetRangeSchedule, let start = glucoseSamples.first?.startDate { - for item in glucoseRangeSchedule.quantityBetween(start: start, end: endDateChart) { - let minValue = item.value.lowerBound.doubleValue(for: unit) - let maxValue = item.value.upperBound.doubleValue(for: unit) - let startDate = max(item.startDate, start) - let endDate = min(item.endDate, endDateChart) - - if let presetContext = presetContext { - if presetContext.startDate > startDate, presetContext.endDate < endDate { - // A preset is active during this schedule - glucoseRanges.append(GlucoseRangeValue( - id: UUID(), - minValue: minValue, - maxValue: maxValue, - startDate: startDate, - endDate: presetContext.startDate - )) - glucoseRanges.append(GlucoseRangeValue( - id: UUID(), - minValue: minValue, - maxValue: maxValue, - startDate: presetContext.endDate, - endDate: endDate - )) - } else if presetContext.endDate > startDate, presetContext.endDate < endDate { - // Cut off the start of the glucose target - glucoseRanges.append(GlucoseRangeValue( - id: UUID(), - minValue: minValue, - maxValue: maxValue, - startDate: presetContext.endDate, - endDate: endDate - )) - } else if presetContext.startDate < endDate, presetContext.startDate > startDate { - // Cut off the end of the glucose target - glucoseRanges.append(GlucoseRangeValue( - id: UUID(), - minValue: minValue, - maxValue: maxValue, - startDate: startDate, - endDate: presetContext.startDate - )) - if presetContext.endDate == endDateChart { - break - } - } else { - // No overlap with target and override - glucoseRanges.append(GlucoseRangeValue( - id: UUID(), - minValue: minValue, - maxValue: maxValue, - startDate: startDate, - endDate: endDate - )) - } - } else { - glucoseRanges.append(GlucoseRangeValue( - id: UUID(), - minValue: minValue, - maxValue: maxValue, - startDate: startDate, - endDate: endDate - )) - } - } + glucoseRanges = getGlucoseRanges( + glucoseRangeSchedule: glucoseRangeSchedule, + presetContext: presetContext, + start: start, + end: endDateChart, + unit: unit + ) } let state = GlucoseActivityAttributes.ContentState( @@ -405,6 +348,76 @@ class GlucoseActivityManager { return samples } + private func getGlucoseRanges(glucoseRangeSchedule: GlucoseRangeSchedule, presetContext: Preset?, start: Date, end: Date, unit: HKUnit) -> [GlucoseRangeValue] { + var glucoseRanges: [GlucoseRangeValue] = [] + for item in glucoseRangeSchedule.quantityBetween(start: start, end: end) { + let minValue = item.value.lowerBound.doubleValue(for: unit) + let maxValue = item.value.upperBound.doubleValue(for: unit) + let startDate = max(item.startDate, start) + let endDate = min(item.endDate, end) + + if let presetContext = presetContext { + if presetContext.startDate > startDate, presetContext.endDate < endDate { + // A preset is active during this schedule + glucoseRanges.append(GlucoseRangeValue( + id: UUID(), + minValue: minValue, + maxValue: maxValue, + startDate: startDate, + endDate: presetContext.startDate + )) + glucoseRanges.append(GlucoseRangeValue( + id: UUID(), + minValue: minValue, + maxValue: maxValue, + startDate: presetContext.endDate, + endDate: endDate + )) + } else if presetContext.endDate > startDate, presetContext.endDate < endDate { + // Cut off the start of the glucose target + glucoseRanges.append(GlucoseRangeValue( + id: UUID(), + minValue: minValue, + maxValue: maxValue, + startDate: presetContext.endDate, + endDate: endDate + )) + } else if presetContext.startDate < endDate, presetContext.startDate > startDate { + // Cut off the end of the glucose target + glucoseRanges.append(GlucoseRangeValue( + id: UUID(), + minValue: minValue, + maxValue: maxValue, + startDate: startDate, + endDate: presetContext.startDate + )) + if presetContext.endDate == end { + break + } + } else { + // No overlap with target and override + glucoseRanges.append(GlucoseRangeValue( + id: UUID(), + minValue: minValue, + maxValue: maxValue, + startDate: startDate, + endDate: endDate + )) + } + } else { + glucoseRanges.append(GlucoseRangeValue( + id: UUID(), + minValue: minValue, + maxValue: maxValue, + startDate: startDate, + endDate: endDate + )) + } + } + + return glucoseRanges + } + private func getBottomRow(currentGlucose: Double, delta: String, statusContext: StatusExtensionContext?, glucoseFormatter: NumberFormatter) -> [BottomRowItem] { return self.settings.bottomRowConfiguration.map { type in switch(type) { From 87602ff370ddeafc8cf27b8b3c67b3814d83fcac Mon Sep 17 00:00:00 2001 From: bastiaanv Date: Fri, 9 Aug 2024 10:21:15 +0200 Subject: [PATCH 25/33] revert: Revert unrelated commit for LA --- Loop/Managers/LoopDataManager.swift | 2 +- .../CarbAbsorptionViewController.swift | 31 +++---------------- LoopCore/LoopCoreConstants.swift | 2 +- 3 files changed, 6 insertions(+), 29 deletions(-) diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index 8a72881394..c5220cb0c5 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -1009,7 +1009,7 @@ extension LoopDataManager { let retrospectiveStart = lastGlucoseDate.addingTimeInterval(-type(of: retrospectiveCorrection).retrospectionInterval) - let earliestEffectDate = Date(timeInterval: .days(-2), since: now()) + let earliestEffectDate = Date(timeInterval: .hours(-24), since: now()) let nextCounteractionEffectDate = insulinCounteractionEffects.last?.endDate ?? earliestEffectDate let insulinEffectStartDate = nextCounteractionEffectDate.addingTimeInterval(.minutes(-5)) diff --git a/Loop/View Controllers/CarbAbsorptionViewController.swift b/Loop/View Controllers/CarbAbsorptionViewController.swift index 45e031a7b5..fc770192e9 100644 --- a/Loop/View Controllers/CarbAbsorptionViewController.swift +++ b/Loop/View Controllers/CarbAbsorptionViewController.swift @@ -139,7 +139,7 @@ final class CarbAbsorptionViewController: LoopChartsTableViewController, Identif charts.updateEndDate(chartStartDate.addingTimeInterval(.hours(totalHours+1))) // When there is no data, this allows presenting current hour + 1 let midnight = Calendar.current.startOfDay(for: Date()) - let previousMidnight = midnight - .days(1) + let listStart = min(midnight, chartStartDate, Date(timeIntervalSinceNow: -deviceManager.carbStore.maximumAbsorptionTimeInterval)) let reloadGroup = DispatchGroup() let shouldUpdateGlucose = currentContext.contains(.glucose) @@ -157,20 +157,12 @@ final class CarbAbsorptionViewController: LoopChartsTableViewController, Identif if shouldUpdateGlucose || shouldUpdateCarbs { let allInsulinCounteractionEffects = state.insulinCounteractionEffects insulinCounteractionEffects = allInsulinCounteractionEffects.filterDateRange(chartStartDate, nil) - - let earliestCounteractionEffect = allInsulinCounteractionEffects.first?.startDate ?? Date() - // Show carb entries as far back as previous midnight, or only as far back as counteraction effects are available - let boundOnCarbList = max(previousMidnight, earliestCounteractionEffect) - // If counteraction effects are missing, at least show all the entries for today and those on the chart - let displayListStart = min(boundOnCarbList, midnight, chartStartDate) - // To estimate dynamic carb absorption for the entry at the start of the list, we need to fetch samples that might still be absorbing - let fetchEntriesStart = displayListStart.addingTimeInterval(-self.deviceManager.carbStore.maximumAbsorptionTimeInterval) reloadGroup.enter() - self.deviceManager.carbStore.getCarbStatus(start: fetchEntriesStart, end: nil, effectVelocities: allInsulinCounteractionEffects) { (result) in + self.deviceManager.carbStore.getCarbStatus(start: listStart, end: nil, effectVelocities: allInsulinCounteractionEffects) { (result) in switch result { case .success(let status): - carbStatuses = status.filterDateRange(displayListStart, nil) + carbStatuses = status carbsOnBoard = status.getClampedCarbsOnBoard() case .failure(let error): self.log.error("CarbStore failed to get carbStatus: %{public}@", String(describing: error)) @@ -294,14 +286,6 @@ final class CarbAbsorptionViewController: LoopChartsTableViewController, Identif formatter.timeStyle = .short return formatter }() - - private lazy var relativeTimeFormatter: DateFormatter = { - let formatter = DateFormatter() - formatter.dateStyle = .medium - formatter.doesRelativeDateFormatting = true - formatter.timeStyle = .short - return formatter - }() override func numberOfSections(in tableView: UITableView) -> Int { return Section.count @@ -359,14 +343,7 @@ final class CarbAbsorptionViewController: LoopChartsTableViewController, Identif } // Entry time - let startTime: String - // Indicate if an entry is from the previous day to avoid potential confusion - let midnight = Calendar.current.startOfDay(for: Date()) - if status.entry.startDate < midnight { - startTime = relativeTimeFormatter.string(from: status.entry.startDate) - } else { - startTime = timeFormatter.string(from: status.entry.startDate) - } + let startTime = timeFormatter.string(from: status.entry.startDate) if let absorptionTime = status.entry.absorptionTime, let duration = absorptionFormatter.string(from: absorptionTime) { diff --git a/LoopCore/LoopCoreConstants.swift b/LoopCore/LoopCoreConstants.swift index 42e9dfa384..d56f2ab9b6 100644 --- a/LoopCore/LoopCoreConstants.swift +++ b/LoopCore/LoopCoreConstants.swift @@ -16,7 +16,7 @@ public enum LoopCoreConstants { /// The amount of time in the future a glucose value should be considered valid public static let futureGlucoseDataInterval = TimeInterval(minutes: 5) - public static let defaultCarbAbsorptionTimes: CarbStore.DefaultAbsorptionTimes = (fast: .hours(2), medium: .hours(3), slow: .hours(5)) + public static let defaultCarbAbsorptionTimes: CarbStore.DefaultAbsorptionTimes = (fast: .minutes(30), medium: .hours(3), slow: .hours(5)) /// How much historical glucose to include in a dosing decision /// Somewhat arbitrary, but typical maximum visible in bolus glucose preview From 8dc97d985ed085eb8a6f5cec5da30e84d7173610 Mon Sep 17 00:00:00 2001 From: bastiaanv Date: Wed, 14 Aug 2024 20:47:17 +0200 Subject: [PATCH 26/33] fix: Process feedback --- .../GlucoseActivityManager.swift | 21 ++++----- Loop/Views/LiveActivityManagementView.swift | 22 +++++++++- LoopCore/LiveActivitySettings.swift | 43 ++++++++++++++----- 3 files changed, 62 insertions(+), 24 deletions(-) diff --git a/Loop/Managers/Live Activity/GlucoseActivityManager.swift b/Loop/Managers/Live Activity/GlucoseActivityManager.swift index 79635198e9..750dba002b 100644 --- a/Loop/Managers/Live Activity/GlucoseActivityManager.swift +++ b/Loop/Managers/Live Activity/GlucoseActivityManager.swift @@ -149,7 +149,7 @@ class GlucoseActivityManager { presetContext = Preset( title: override.getTitle(), startDate: max(override.startDate, start), - endDate: override.duration.isInfinite ? endDateChart : min(Date.now + override.duration.timeInterval, endDateChart), + endDate: override.duration.isInfinite ? endDateChart : min(override.actualEndDate, endDateChart), minValue: override.settings.targetRange?.lowerBound.doubleValue(for: unit) ?? 0, maxValue: override.settings.targetRange?.upperBound.doubleValue(for: unit) ?? 0 ) @@ -208,15 +208,7 @@ class GlucoseActivityManager { } else if newSettings.enabled && self.activity == nil { initEmptyActivity(settings: newSettings) - } else if - newSettings.mode != self.settings.mode || - newSettings.addPredictiveLine != self.settings.addPredictiveLine || - newSettings.useLimits != self.settings.useLimits || - newSettings.lowerLimitChartMmol != self.settings.lowerLimitChartMmol || - newSettings.upperLimitChartMmol != self.settings.upperLimitChartMmol || - newSettings.lowerLimitChartMg != self.settings.lowerLimitChartMg || - newSettings.upperLimitChartMg != self.settings.upperLimitChartMg - { + } else if newSettings != self.settings { await self.activity?.end(nil, dismissalPolicy: .immediate) self.activity = nil @@ -229,8 +221,13 @@ class GlucoseActivityManager { } @objc private func appMovedToForeground() { + guard self.settings.enabled else { + return + } + guard let activity = self.activity else { - print("ERROR: appMovedToForeground: No Live activity found...") + initEmptyActivity(settings: self.settings) + update() return } @@ -512,4 +509,4 @@ extension TemporaryScheduleOverride { return "" } } -} +} \ No newline at end of file diff --git a/Loop/Views/LiveActivityManagementView.swift b/Loop/Views/LiveActivityManagementView.swift index 0a691a9916..73cfb4bcba 100644 --- a/Loop/Views/LiveActivityManagementView.swift +++ b/Loop/Views/LiveActivityManagementView.swift @@ -91,13 +91,33 @@ struct LiveActivityManagementView: View { if self.displayGlucosePreference.unit == .millimolesPerLiter { TextInput(label: "Upper limit chart", value: $upperLimitMmol) .transition(.move(edge: useLimits ? .top : .bottom)) + .onChange(of: upperLimitMmol) { newValue in + self.mutate { settings in + settings.upperLimitChartMmol = newValue + } + } TextInput(label: "Lower limit chart", value: $lowerLimitMmol) .transition(.move(edge: useLimits ? .top : .bottom)) + .onChange(of: lowerLimitMmol) { newValue in + self.mutate { settings in + settings.lowerLimitChartMmol = newValue + } + } } else { TextInput(label: "Upper limit chart", value: $upperLimitMg) .transition(.move(edge: useLimits ? .top : .bottom)) + .onChange(of: upperLimitMg) { newValue in + self.mutate { settings in + settings.upperLimitChartMg = newValue + } + } TextInput(label: "Lower limit chart", value: $lowerLimitMg) .transition(.move(edge: useLimits ? .top : .bottom)) + .onChange(of: lowerLimitMg) { newValue in + self.mutate { settings in + settings.lowerLimitChartMg = newValue + } + } } } } @@ -134,4 +154,4 @@ struct LiveActivityManagementView: View { UserDefaults.standard.liveActivity = settings NotificationCenter.default.post(name: .LiveActivitySettingsChanged, object: settings) } -} +} \ No newline at end of file diff --git a/LoopCore/LiveActivitySettings.swift b/LoopCore/LiveActivitySettings.swift index 02a3fbb264..71807464d4 100644 --- a/LoopCore/LiveActivitySettings.swift +++ b/LoopCore/LiveActivitySettings.swift @@ -83,7 +83,7 @@ public enum LiveActivityMode: Codable, CustomStringConvertible { } } -public struct LiveActivitySettings: Codable { +public struct LiveActivitySettings: Codable, Equatable { public var enabled: Bool public var mode: LiveActivityMode public var addPredictiveLine: Bool @@ -113,15 +113,16 @@ public struct LiveActivitySettings: Codable { public init(from decoder:Decoder) throws { let values = try decoder.container(keyedBy: CodingKeys.self) - enabled = try values.decode(Bool.self, forKey: .enabled) - mode = try values.decodeIfPresent(LiveActivityMode.self, forKey: .mode) ?? .large - addPredictiveLine = try values.decode(Bool.self, forKey: .addPredictiveLine) - useLimits = try values.decodeIfPresent(Bool.self, forKey: .useLimits) ?? true - upperLimitChartMmol = try values.decode(Double?.self, forKey: .upperLimitChartMmol) ?? LiveActivitySettings.defaultUpperLimitMmol - lowerLimitChartMmol = try values.decode(Double?.self, forKey: .lowerLimitChartMmol) ?? LiveActivitySettings.defaultLowerLimitMmol - upperLimitChartMg = try values.decode(Double?.self, forKey: .upperLimitChartMg) ?? LiveActivitySettings.defaultUpperLimitMg - lowerLimitChartMg = try values.decode(Double?.self, forKey: .lowerLimitChartMg) ?? LiveActivitySettings.defaultLowerLimitMg - bottomRowConfiguration = try values.decode([BottomRowConfiguration].self, forKey: .bottomRowConfiguration) + + self.enabled = try values.decode(Bool.self, forKey: .enabled) + self.mode = try values.decodeIfPresent(LiveActivityMode.self, forKey: .mode) ?? .large + self.addPredictiveLine = try values.decode(Bool.self, forKey: .addPredictiveLine) + self.useLimits = try values.decodeIfPresent(Bool.self, forKey: .useLimits) ?? true + self.upperLimitChartMmol = try values.decode(Double?.self, forKey: .upperLimitChartMmol) ?? LiveActivitySettings.defaultUpperLimitMmol + self.lowerLimitChartMmol = try values.decode(Double?.self, forKey: .lowerLimitChartMmol) ?? LiveActivitySettings.defaultLowerLimitMmol + self.upperLimitChartMg = try values.decode(Double?.self, forKey: .upperLimitChartMg) ?? LiveActivitySettings.defaultUpperLimitMg + self.lowerLimitChartMg = try values.decode(Double?.self, forKey: .lowerLimitChartMg) ?? LiveActivitySettings.defaultLowerLimitMg + self.bottomRowConfiguration = try values.decode([BottomRowConfiguration].self, forKey: .bottomRowConfiguration) } public init() { @@ -135,4 +136,24 @@ public struct LiveActivitySettings: Codable { self.lowerLimitChartMg = LiveActivitySettings.defaultLowerLimitMg self.bottomRowConfiguration = BottomRowConfiguration.defaults } -} + + public static func == (lhs: LiveActivitySettings, rhs: LiveActivitySettings) -> Bool { + return lhs.addPredictiveLine == rhs.addPredictiveLine && + lhs.mode == rhs.mode && + lhs.useLimits == rhs.useLimits && + lhs.lowerLimitChartMmol == rhs.lowerLimitChartMmol && + lhs.upperLimitChartMmol == rhs.upperLimitChartMmol && + lhs.lowerLimitChartMg == rhs.lowerLimitChartMg && + lhs.upperLimitChartMg == rhs.upperLimitChartMg + } + + public static func != (lhs: LiveActivitySettings, rhs: LiveActivitySettings) -> Bool { + return lhs.addPredictiveLine != rhs.addPredictiveLine || + lhs.mode != rhs.mode || + lhs.useLimits != rhs.useLimits || + lhs.lowerLimitChartMmol != rhs.lowerLimitChartMmol || + lhs.upperLimitChartMmol != rhs.upperLimitChartMmol || + lhs.lowerLimitChartMg != rhs.lowerLimitChartMg || + lhs.upperLimitChartMg != rhs.upperLimitChartMg + } +} \ No newline at end of file From 81a5ef3c9ee09bd279374dadc7f05e339f324a11 Mon Sep 17 00:00:00 2001 From: bastiaanv Date: Thu, 15 Aug 2024 17:08:19 +0200 Subject: [PATCH 27/33] style: Add save button --- Loop/Views/LiveActivityManagementView.swift | 150 +++++++++----------- 1 file changed, 64 insertions(+), 86 deletions(-) diff --git a/Loop/Views/LiveActivityManagementView.swift b/Loop/Views/LiveActivityManagementView.swift index 73cfb4bcba..9482bfd0e7 100644 --- a/Loop/Views/LiveActivityManagementView.swift +++ b/Loop/Views/LiveActivityManagementView.swift @@ -19,10 +19,10 @@ struct LiveActivityManagementView: View { @State var isEditingMode = false @State private var addPredictiveLine: Bool @State private var useLimits: Bool - @State private var upperLimitMmol: Double - @State private var lowerLimitMmol: Double - @State private var upperLimitMg: Double - @State private var lowerLimitMg: Double + @State private var upperLimitChartMmol: Double + @State private var lowerLimitChartMmol: Double + @State private var upperLimitChartMg: Double + @State private var lowerLimitChartMg: Double init() { let liveActivitySettings = UserDefaults.standard.liveActivity ?? LiveActivitySettings() @@ -31,107 +31,79 @@ struct LiveActivityManagementView: View { self.mode = liveActivitySettings.mode self.addPredictiveLine = liveActivitySettings.addPredictiveLine self.useLimits = liveActivitySettings.useLimits - self.upperLimitMmol = liveActivitySettings.upperLimitChartMmol - self.lowerLimitMmol = liveActivitySettings.lowerLimitChartMmol - self.upperLimitMg = liveActivitySettings.upperLimitChartMg - self.lowerLimitMg = liveActivitySettings.lowerLimitChartMg + self.upperLimitChartMmol = liveActivitySettings.upperLimitChartMmol + self.lowerLimitChartMmol = liveActivitySettings.lowerLimitChartMmol + self.upperLimitChartMg = liveActivitySettings.upperLimitChartMg + self.lowerLimitChartMg = liveActivitySettings.lowerLimitChartMg } var body: some View { - List { - Section { - Toggle(NSLocalizedString("Enabled", comment: "Title for enable live activity toggle"), isOn: $enabled) - .onChange(of: enabled) { newValue in - self.mutate { settings in - settings.enabled = newValue + VStack { + List { + Section { + Toggle(NSLocalizedString("Enabled", comment: "Title for enable live activity toggle"), isOn: $enabled) + + ExpandableSetting( + isEditing: $isEditingMode, + leadingValueContent: { + Text(NSLocalizedString("Mode", comment: "Title for mode live activity toggle")) + .foregroundStyle(isEditingMode ? .blue : .primary) + }, + trailingValueContent: { + Text(self.mode.name()) + .foregroundStyle(isEditingMode ? .blue : .primary) + }, + expandedContent: { + ResizeablePicker(selection: self.$mode.animation(), + data: LiveActivityMode.all, + formatter: { $0.name() }) } - } - - ExpandableSetting( - isEditing: $isEditingMode, - leadingValueContent: { - Text(NSLocalizedString("Mode", comment: "Title for mode live activity toggle")) - .foregroundStyle(isEditingMode ? .blue : .primary) - }, - trailingValueContent: { - Text(self.mode.name()) - .foregroundStyle(isEditingMode ? .blue : .primary) - }, - expandedContent: { - ResizeablePicker(selection: self.$mode.animation(), - data: LiveActivityMode.all, - formatter: { $0.name() }) - } - ) - .onChange(of: self.mode) { newValue in - self.mutate { settings in - settings.mode = newValue - } + ) } - } - - if mode == .large { + Section { - Toggle(NSLocalizedString("Add predictive line", comment: "Title for predictive line toggle"), isOn: $addPredictiveLine) - .transition(.move(edge: mode == .large ? .top : .bottom)) - .onChange(of: addPredictiveLine) { newValue in - self.mutate { settings in - settings.addPredictiveLine = newValue - } - } + if mode == .large { + Toggle(NSLocalizedString("Add predictive line", comment: "Title for predictive line toggle"), isOn: $addPredictiveLine) + .transition(.move(edge: mode == .large ? .top : .bottom)) + } + Toggle(NSLocalizedString("Use BG coloring", comment: "Title for BG coloring"), isOn: $useLimits) .transition(.move(edge: mode == .large ? .top : .bottom)) - .onChange(of: useLimits) { newValue in - self.mutate { settings in - settings.useLimits = newValue - } - } if useLimits { if self.displayGlucosePreference.unit == .millimolesPerLiter { - TextInput(label: "Upper limit chart", value: $upperLimitMmol) + TextInput(label: "Upper limit", value: $upperLimitChartMmol) .transition(.move(edge: useLimits ? .top : .bottom)) - .onChange(of: upperLimitMmol) { newValue in - self.mutate { settings in - settings.upperLimitChartMmol = newValue - } - } - TextInput(label: "Lower limit chart", value: $lowerLimitMmol) + TextInput(label: "Lower limit", value: $lowerLimitChartMmol) .transition(.move(edge: useLimits ? .top : .bottom)) - .onChange(of: lowerLimitMmol) { newValue in - self.mutate { settings in - settings.lowerLimitChartMmol = newValue - } - } } else { - TextInput(label: "Upper limit chart", value: $upperLimitMg) + TextInput(label: "Upper limit", value: $upperLimitChartMg) .transition(.move(edge: useLimits ? .top : .bottom)) - .onChange(of: upperLimitMg) { newValue in - self.mutate { settings in - settings.upperLimitChartMg = newValue - } - } - TextInput(label: "Lower limit chart", value: $lowerLimitMg) + TextInput(label: "Lower limit", value: $lowerLimitChartMg) .transition(.move(edge: useLimits ? .top : .bottom)) - .onChange(of: lowerLimitMg) { newValue in - self.mutate { settings in - settings.lowerLimitChartMg = newValue - } - } } } } - } - Section { - NavigationLink( - destination: LiveActivityBottomRowManagerView(), - label: { Text(NSLocalizedString("Bottom row configuration", comment: "Title for Bottom row configuration")) } - ) + Section { + NavigationLink( + destination: LiveActivityBottomRowManagerView(), + label: { Text(NSLocalizedString("Bottom row configuration", comment: "Title for Bottom row configuration")) } + ) + } + + } - } .animation(.easeInOut, value: UUID()) .insetGroupedListStyle() + + Spacer() + Button(action: save) { + Text(NSLocalizedString("Save", comment: "")) + } + .buttonStyle(ActionButtonStyle()) + .padding([.bottom, .horizontal]) + } .navigationBarTitle(Text(NSLocalizedString("Live activity", comment: "Live activity screen title"))) } @@ -146,12 +118,18 @@ struct LiveActivityManagementView: View { } } - private func mutate(_ updater: (inout LiveActivitySettings) -> Void) { + private func save() { var settings = UserDefaults.standard.liveActivity ?? LiveActivitySettings() - - updater(&settings) + settings.enabled = self.enabled + settings.mode = self.mode + settings.addPredictiveLine = self.addPredictiveLine + settings.useLimits = self.useLimits + settings.upperLimitChartMmol = self.upperLimitChartMmol + settings.lowerLimitChartMmol = self.lowerLimitChartMmol + settings.upperLimitChartMg = self.upperLimitChartMg + settings.lowerLimitChartMg = self.lowerLimitChartMg UserDefaults.standard.liveActivity = settings NotificationCenter.default.post(name: .LiveActivitySettingsChanged, object: settings) } -} \ No newline at end of file +} From 92fb65f03bce0d0cf4e6b443adfcb50d6442143c Mon Sep 17 00:00:00 2001 From: bastiaanv Date: Thu, 15 Aug 2024 17:12:31 +0200 Subject: [PATCH 28/33] style: Minor bg coloring fix --- .../Live Activity/GlucoseLiveActivityConfiguration.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Loop Widget Extension/Live Activity/GlucoseLiveActivityConfiguration.swift b/Loop Widget Extension/Live Activity/GlucoseLiveActivityConfiguration.swift index 2baeb90be7..eb70f560cd 100644 --- a/Loop Widget Extension/Live Activity/GlucoseLiveActivityConfiguration.swift +++ b/Loop Widget Extension/Live Activity/GlucoseLiveActivityConfiguration.swift @@ -271,6 +271,10 @@ struct GlucoseLiveActivityConfiguration: Widget { } private func getGlucoseColor(_ value: Double, context: ActivityViewContext) -> Color { + guard context.attributes.useLimits else { + return .primary + } + if context.state.isMmol && value < context.attributes.lowerLimitChartMmol || !context.state.isMmol && value < context.attributes.lowerLimitChartMg From 078b605caf955f4a78e5c214fa24dde2dd6b316c Mon Sep 17 00:00:00 2001 From: bastiaanv Date: Thu, 12 Sep 2024 20:02:15 +0200 Subject: [PATCH 29/33] fix: Fix save with routing back and forth --- Loop.xcodeproj/project.pbxproj | 4 + .../LiveActivityManagementViewModel.swift | 35 ++++++++ Loop/Views/LiveActivityManagementView.swift | 80 +++++++------------ 3 files changed, 68 insertions(+), 51 deletions(-) create mode 100644 Loop/View Models/LiveActivityManagementViewModel.swift diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index 606155f7d1..5648566e60 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -401,6 +401,7 @@ B4E96D5D248A82A2002DABAD /* StatusBarHUDView.xib in Resources */ = {isa = PBXBuildFile; fileRef = B4E96D5C248A82A2002DABAD /* StatusBarHUDView.xib */; }; B4F3D25124AF890C0095CE44 /* BluetoothStateManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4F3D25024AF890C0095CE44 /* BluetoothStateManager.swift */; }; B4FEEF7D24B8A71F00A8DF9B /* DeviceDataManager+DeviceStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4FEEF7C24B8A71F00A8DF9B /* DeviceDataManager+DeviceStatus.swift */; }; + B82181F62C93628300478A91 /* LiveActivityManagementViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B82181F52C93628300478A91 /* LiveActivityManagementViewModel.swift */; }; B851FFC52C37221800D738C1 /* LiveActivityManagementView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B851FFC42C37221800D738C1 /* LiveActivityManagementView.swift */; }; B851FFCA2C3731DE00D738C1 /* LiveActivitySettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = B851FFC62C37271E00D738C1 /* LiveActivitySettings.swift */; }; B851FFCB2C3731DE00D738C1 /* LiveActivitySettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = B851FFC62C37271E00D738C1 /* LiveActivitySettings.swift */; }; @@ -1338,6 +1339,7 @@ B4E96D5C248A82A2002DABAD /* StatusBarHUDView.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = StatusBarHUDView.xib; sourceTree = ""; }; B4F3D25024AF890C0095CE44 /* BluetoothStateManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BluetoothStateManager.swift; sourceTree = ""; }; B4FEEF7C24B8A71F00A8DF9B /* DeviceDataManager+DeviceStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DeviceDataManager+DeviceStatus.swift"; sourceTree = ""; }; + B82181F52C93628300478A91 /* LiveActivityManagementViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActivityManagementViewModel.swift; sourceTree = ""; }; B851FFC42C37221800D738C1 /* LiveActivityManagementView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActivityManagementView.swift; sourceTree = ""; }; B851FFC62C37271E00D738C1 /* LiveActivitySettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActivitySettings.swift; sourceTree = ""; }; B87539C82C2B06CE0085A975 /* LocalizedString.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalizedString.swift; sourceTree = ""; }; @@ -2660,6 +2662,7 @@ 1D49795724E7289700948F05 /* ServicesViewModel.swift */, C174233B259BEB0F00399C9D /* ManualEntryDoseViewModel.swift */, 1DB619AB270BAD3D006C9D07 /* VersionUpdateViewModel.swift */, + B82181F52C93628300478A91 /* LiveActivityManagementViewModel.swift */, ); path = "View Models"; sourceTree = ""; @@ -3846,6 +3849,7 @@ C1AF062329426300002C1B19 /* ManualGlucoseEntryRow.swift in Sources */, C148CEE724FD91BD00711B3B /* DeliveryUncertaintyAlertManager.swift in Sources */, DDC389FC2A2BC6670066E2E8 /* SettingsView+algorithmExperimentsSection.swift in Sources */, + B82181F62C93628300478A91 /* LiveActivityManagementViewModel.swift in Sources */, 1D12D3B92548EFDD00B53E8B /* main.swift in Sources */, 435400341C9F878D00D5819C /* SetBolusUserInfo.swift in Sources */, A9DCF32A25B0FABF00C89088 /* LoopUIColorPalette+Default.swift in Sources */, diff --git a/Loop/View Models/LiveActivityManagementViewModel.swift b/Loop/View Models/LiveActivityManagementViewModel.swift new file mode 100644 index 0000000000..46fc560d6c --- /dev/null +++ b/Loop/View Models/LiveActivityManagementViewModel.swift @@ -0,0 +1,35 @@ +// +// LiveActivityManagementViewModel.swift +// Loop +// +// Created by Bastiaan Verhaar on 12/09/2024. +// Copyright © 2024 LoopKit Authors. All rights reserved. +// + +import Foundation +import LoopCore + +class LiveActivityManagementViewModel : ObservableObject { + @Published var enabled: Bool + @Published var mode: LiveActivityMode + @Published var isEditingMode: Bool = false + @Published var addPredictiveLine: Bool + @Published var useLimits: Bool + @Published var upperLimitChartMmol: Double + @Published var lowerLimitChartMmol: Double + @Published var upperLimitChartMg: Double + @Published var lowerLimitChartMg: Double + + init() { + let liveActivitySettings = UserDefaults.standard.liveActivity ?? LiveActivitySettings() + + self.enabled = liveActivitySettings.enabled + self.mode = liveActivitySettings.mode + self.addPredictiveLine = liveActivitySettings.addPredictiveLine + self.useLimits = liveActivitySettings.useLimits + self.upperLimitChartMmol = liveActivitySettings.upperLimitChartMmol + self.lowerLimitChartMmol = liveActivitySettings.lowerLimitChartMmol + self.upperLimitChartMg = liveActivitySettings.upperLimitChartMg + self.lowerLimitChartMg = liveActivitySettings.lowerLimitChartMg + } +} diff --git a/Loop/Views/LiveActivityManagementView.swift b/Loop/Views/LiveActivityManagementView.swift index 9482bfd0e7..f7f875caac 100644 --- a/Loop/Views/LiveActivityManagementView.swift +++ b/Loop/Views/LiveActivityManagementView.swift @@ -13,48 +13,26 @@ import HealthKit struct LiveActivityManagementView: View { @EnvironmentObject private var displayGlucosePreference: DisplayGlucosePreference - - @State private var enabled: Bool - @State private var mode: LiveActivityMode - @State var isEditingMode = false - @State private var addPredictiveLine: Bool - @State private var useLimits: Bool - @State private var upperLimitChartMmol: Double - @State private var lowerLimitChartMmol: Double - @State private var upperLimitChartMg: Double - @State private var lowerLimitChartMg: Double - - init() { - let liveActivitySettings = UserDefaults.standard.liveActivity ?? LiveActivitySettings() - - self.enabled = liveActivitySettings.enabled - self.mode = liveActivitySettings.mode - self.addPredictiveLine = liveActivitySettings.addPredictiveLine - self.useLimits = liveActivitySettings.useLimits - self.upperLimitChartMmol = liveActivitySettings.upperLimitChartMmol - self.lowerLimitChartMmol = liveActivitySettings.lowerLimitChartMmol - self.upperLimitChartMg = liveActivitySettings.upperLimitChartMg - self.lowerLimitChartMg = liveActivitySettings.lowerLimitChartMg - } + @StateObject private var viewModel = LiveActivityManagementViewModel() var body: some View { VStack { List { Section { - Toggle(NSLocalizedString("Enabled", comment: "Title for enable live activity toggle"), isOn: $enabled) + Toggle(NSLocalizedString("Enabled", comment: "Title for enable live activity toggle"), isOn: $viewModel.enabled) ExpandableSetting( - isEditing: $isEditingMode, + isEditing: $viewModel.isEditingMode, leadingValueContent: { Text(NSLocalizedString("Mode", comment: "Title for mode live activity toggle")) - .foregroundStyle(isEditingMode ? .blue : .primary) + .foregroundStyle(viewModel.isEditingMode ? .blue : .primary) }, trailingValueContent: { - Text(self.mode.name()) - .foregroundStyle(isEditingMode ? .blue : .primary) + Text(viewModel.mode.name()) + .foregroundStyle(viewModel.isEditingMode ? .blue : .primary) }, expandedContent: { - ResizeablePicker(selection: self.$mode.animation(), + ResizeablePicker(selection: self.$viewModel.mode.animation(), data: LiveActivityMode.all, formatter: { $0.name() }) } @@ -62,25 +40,25 @@ struct LiveActivityManagementView: View { } Section { - if mode == .large { - Toggle(NSLocalizedString("Add predictive line", comment: "Title for predictive line toggle"), isOn: $addPredictiveLine) - .transition(.move(edge: mode == .large ? .top : .bottom)) + if viewModel.mode == .large { + Toggle(NSLocalizedString("Add predictive line", comment: "Title for predictive line toggle"), isOn: $viewModel.addPredictiveLine) + .transition(.move(edge: viewModel.mode == .large ? .top : .bottom)) } - Toggle(NSLocalizedString("Use BG coloring", comment: "Title for BG coloring"), isOn: $useLimits) - .transition(.move(edge: mode == .large ? .top : .bottom)) + Toggle(NSLocalizedString("Use BG coloring", comment: "Title for BG coloring"), isOn: $viewModel.useLimits) + .transition(.move(edge: viewModel.mode == .large ? .top : .bottom)) - if useLimits { + if viewModel.useLimits { if self.displayGlucosePreference.unit == .millimolesPerLiter { - TextInput(label: "Upper limit", value: $upperLimitChartMmol) - .transition(.move(edge: useLimits ? .top : .bottom)) - TextInput(label: "Lower limit", value: $lowerLimitChartMmol) - .transition(.move(edge: useLimits ? .top : .bottom)) + TextInput(label: "Upper limit", value: $viewModel.upperLimitChartMmol) + .transition(.move(edge: viewModel.useLimits ? .top : .bottom)) + TextInput(label: "Lower limit", value: $viewModel.lowerLimitChartMmol) + .transition(.move(edge: viewModel.useLimits ? .top : .bottom)) } else { - TextInput(label: "Upper limit", value: $upperLimitChartMg) - .transition(.move(edge: useLimits ? .top : .bottom)) - TextInput(label: "Lower limit", value: $lowerLimitChartMg) - .transition(.move(edge: useLimits ? .top : .bottom)) + TextInput(label: "Upper limit", value: $viewModel.upperLimitChartMg) + .transition(.move(edge: viewModel.useLimits ? .top : .bottom)) + TextInput(label: "Lower limit", value: $viewModel.lowerLimitChartMg) + .transition(.move(edge: viewModel.useLimits ? .top : .bottom)) } } } @@ -120,14 +98,14 @@ struct LiveActivityManagementView: View { private func save() { var settings = UserDefaults.standard.liveActivity ?? LiveActivitySettings() - settings.enabled = self.enabled - settings.mode = self.mode - settings.addPredictiveLine = self.addPredictiveLine - settings.useLimits = self.useLimits - settings.upperLimitChartMmol = self.upperLimitChartMmol - settings.lowerLimitChartMmol = self.lowerLimitChartMmol - settings.upperLimitChartMg = self.upperLimitChartMg - settings.lowerLimitChartMg = self.lowerLimitChartMg + settings.enabled = viewModel.enabled + settings.mode = viewModel.mode + settings.addPredictiveLine = viewModel.addPredictiveLine + settings.useLimits = viewModel.useLimits + settings.upperLimitChartMmol = viewModel.upperLimitChartMmol + settings.lowerLimitChartMmol = viewModel.lowerLimitChartMmol + settings.upperLimitChartMg = viewModel.upperLimitChartMg + settings.lowerLimitChartMg = viewModel.lowerLimitChartMg UserDefaults.standard.liveActivity = settings NotificationCenter.default.post(name: .LiveActivitySettingsChanged, object: settings) From 9e88542f7ef58b9deb5da34de6d8bd85006e5108 Mon Sep 17 00:00:00 2001 From: bastiaanv Date: Thu, 12 Sep 2024 21:21:38 +0200 Subject: [PATCH 30/33] style: Removed auto scaling chart --- .../Live Activity/ChartView.swift | 11 +- .../GlucoseLiveActivityConfiguration.swift | 12 +- Loop.xcodeproj/project.pbxproj | 17 +++ .../Live Activity/ChartAxisGenerator.swift | 134 ++++++++++++++++++ .../GlucoseActivityAttributes.swift | 1 + .../GlucoseActivityManager.swift | 14 +- 6 files changed, 179 insertions(+), 10 deletions(-) create mode 100644 Loop/Managers/Live Activity/ChartAxisGenerator.swift diff --git a/Loop Widget Extension/Live Activity/ChartView.swift b/Loop Widget Extension/Live Activity/ChartView.swift index 237ddebd82..b8e3a7c8ed 100644 --- a/Loop Widget Extension/Live Activity/ChartView.swift +++ b/Loop Widget Extension/Live Activity/ChartView.swift @@ -15,8 +15,9 @@ struct ChartView: View { private let predicatedData: [ChartValues] private let glucoseRanges: [GlucoseRangeValue] private let preset: Preset? + private let yAxisMarks: [Double] - init(glucoseSamples: [GlucoseSampleAttributes], predicatedGlucose: [Double], predicatedStartDate: Date?, predicatedInterval: TimeInterval?, useLimits: Bool, lowerLimit: Double, upperLimit: Double, glucoseRanges: [GlucoseRangeValue], preset: Preset?) { + init(glucoseSamples: [GlucoseSampleAttributes], predicatedGlucose: [Double], predicatedStartDate: Date?, predicatedInterval: TimeInterval?, useLimits: Bool, lowerLimit: Double, upperLimit: Double, glucoseRanges: [GlucoseRangeValue], preset: Preset?, yAxisMarks: [Double]) { self.glucoseSampleData = ChartValues.convert(data: glucoseSamples, useLimits: useLimits, lowerLimit: lowerLimit, upperLimit: upperLimit) self.predicatedData = ChartValues.convert( data: predicatedGlucose, @@ -28,13 +29,15 @@ struct ChartView: View { ) self.preset = preset self.glucoseRanges = glucoseRanges + self.yAxisMarks = yAxisMarks } - init(glucoseSamples: [GlucoseSampleAttributes], useLimits: Bool, lowerLimit: Double, upperLimit: Double, glucoseRanges: [GlucoseRangeValue], preset: Preset?) { + init(glucoseSamples: [GlucoseSampleAttributes], useLimits: Bool, lowerLimit: Double, upperLimit: Double, glucoseRanges: [GlucoseRangeValue], preset: Preset?, yAxisMarks: [Double]) { self.glucoseSampleData = ChartValues.convert(data: glucoseSamples, useLimits: useLimits, lowerLimit: lowerLimit, upperLimit: upperLimit) self.predicatedData = [] self.preset = preset self.glucoseRanges = glucoseRanges + self.yAxisMarks = yAxisMarks } var body: some View { @@ -87,7 +90,9 @@ struct ChartView: View { plotContent.background(.cyan.opacity(0.15)) } .chartLegend(.hidden) - .chartYScale(domain: .automatic(includesZero: false)) + .chartYAxis { + AxisMarks(values: yAxisMarks) + } .chartYAxis { AxisMarks(position: .leading) { _ in AxisValueLabel().foregroundStyle(Color.primary) diff --git a/Loop Widget Extension/Live Activity/GlucoseLiveActivityConfiguration.swift b/Loop Widget Extension/Live Activity/GlucoseLiveActivityConfiguration.swift index eb70f560cd..4d5ed5ef3e 100644 --- a/Loop Widget Extension/Live Activity/GlucoseLiveActivityConfiguration.swift +++ b/Loop Widget Extension/Live Activity/GlucoseLiveActivityConfiguration.swift @@ -43,7 +43,8 @@ struct GlucoseLiveActivityConfiguration: Widget { lowerLimit: context.state.isMmol ? context.attributes.lowerLimitChartMmol : context.attributes.lowerLimitChartMg, upperLimit: context.state.isMmol ? context.attributes.upperLimitChartMmol : context.attributes.upperLimitChartMg, glucoseRanges: context.state.glucoseRanges, - preset: context.state.preset + preset: context.state.preset, + yAxisMarks: context.state.yAxisMarks ) .frame(height: 85) } else { @@ -53,7 +54,8 @@ struct GlucoseLiveActivityConfiguration: Widget { lowerLimit: context.state.isMmol ? context.attributes.lowerLimitChartMmol : context.attributes.lowerLimitChartMg, upperLimit: context.state.isMmol ? context.attributes.upperLimitChartMmol : context.attributes.upperLimitChartMg, glucoseRanges: context.state.glucoseRanges, - preset: context.state.preset + preset: context.state.preset, + yAxisMarks: context.state.yAxisMarks ) .frame(height: 85) } @@ -149,7 +151,8 @@ struct GlucoseLiveActivityConfiguration: Widget { lowerLimit: context.state.isMmol ? context.attributes.lowerLimitChartMmol : context.attributes.lowerLimitChartMg, upperLimit: context.state.isMmol ? context.attributes.upperLimitChartMmol : context.attributes.upperLimitChartMg, glucoseRanges: context.state.glucoseRanges, - preset: context.state.preset + preset: context.state.preset, + yAxisMarks: context.state.yAxisMarks ) .frame(height: 75) } else { @@ -159,7 +162,8 @@ struct GlucoseLiveActivityConfiguration: Widget { lowerLimit: context.state.isMmol ? context.attributes.lowerLimitChartMmol : context.attributes.lowerLimitChartMg, upperLimit: context.state.isMmol ? context.attributes.upperLimitChartMmol : context.attributes.upperLimitChartMg, glucoseRanges: context.state.glucoseRanges, - preset: context.state.preset + preset: context.state.preset, + yAxisMarks: context.state.yAxisMarks ) .frame(height: 75) } diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index 5648566e60..133e3dca84 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -402,6 +402,7 @@ B4F3D25124AF890C0095CE44 /* BluetoothStateManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4F3D25024AF890C0095CE44 /* BluetoothStateManager.swift */; }; B4FEEF7D24B8A71F00A8DF9B /* DeviceDataManager+DeviceStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4FEEF7C24B8A71F00A8DF9B /* DeviceDataManager+DeviceStatus.swift */; }; B82181F62C93628300478A91 /* LiveActivityManagementViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B82181F52C93628300478A91 /* LiveActivityManagementViewModel.swift */; }; + B82182002C93716A00478A91 /* ChartAxisGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = B82181F92C936AD600478A91 /* ChartAxisGenerator.swift */; }; B851FFC52C37221800D738C1 /* LiveActivityManagementView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B851FFC42C37221800D738C1 /* LiveActivityManagementView.swift */; }; B851FFCA2C3731DE00D738C1 /* LiveActivitySettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = B851FFC62C37271E00D738C1 /* LiveActivitySettings.swift */; }; B851FFCB2C3731DE00D738C1 /* LiveActivitySettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = B851FFC62C37271E00D738C1 /* LiveActivitySettings.swift */; }; @@ -743,6 +744,16 @@ name = "Embed App Extensions"; runOnlyForDeploymentPostprocessing = 0; }; + B82181FF2C9370F800478A91 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; C1E3DC4828595FAA00CA19FF /* Embed Frameworks */ = { isa = PBXCopyFilesBuildPhase; buildActionMask = 2147483647; @@ -1340,6 +1351,7 @@ B4F3D25024AF890C0095CE44 /* BluetoothStateManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BluetoothStateManager.swift; sourceTree = ""; }; B4FEEF7C24B8A71F00A8DF9B /* DeviceDataManager+DeviceStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DeviceDataManager+DeviceStatus.swift"; sourceTree = ""; }; B82181F52C93628300478A91 /* LiveActivityManagementViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActivityManagementViewModel.swift; sourceTree = ""; }; + B82181F92C936AD600478A91 /* ChartAxisGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChartAxisGenerator.swift; sourceTree = ""; }; B851FFC42C37221800D738C1 /* LiveActivityManagementView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActivityManagementView.swift; sourceTree = ""; }; B851FFC62C37271E00D738C1 /* LiveActivitySettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActivitySettings.swift; sourceTree = ""; }; B87539C82C2B06CE0085A975 /* LocalizedString.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalizedString.swift; sourceTree = ""; }; @@ -2811,6 +2823,7 @@ children = ( B8A937B72C29BA5900E38645 /* GlucoseActivityManager.swift */, B8A937C02C29C29300E38645 /* GlucoseActivityAttributes.swift */, + B82181F92C936AD600478A91 /* ChartAxisGenerator.swift */, ); path = "Live Activity"; sourceTree = ""; @@ -3037,6 +3050,7 @@ 14B1735828AED9EC006CCD7C /* Sources */, 14B1735928AED9EC006CCD7C /* Frameworks */, 14B1735A28AED9EC006CCD7C /* Resources */, + B82181FF2C9370F800478A91 /* Embed Frameworks */, ); buildRules = ( ); @@ -3044,6 +3058,8 @@ 1481F9BE28DA26F4004C5AEB /* PBXTargetDependency */, ); name = "Loop Widget Extension"; + packageProductDependencies = ( + ); productName = SmallStatusWidgetExtension; productReference = 14B1735C28AED9EC006CCD7C /* Loop Widget Extension.appex */; productType = "com.apple.product-type.app-extension"; @@ -3888,6 +3904,7 @@ 43DFB62320D4CAE7008A7BAE /* PumpManager.swift in Sources */, A9FB75F1252BE320004C7D3F /* BolusDosingDecision.swift in Sources */, 892A5D59222F0A27008961AB /* Debug.swift in Sources */, + B82182002C93716A00478A91 /* ChartAxisGenerator.swift in Sources */, 431A8C401EC6E8AB00823B9C /* CircleMaskView.swift in Sources */, 1D05219D2469F1F5000EBBDE /* AlertStore.swift in Sources */, 439897371CD2F80600223065 /* AnalyticsServicesManager.swift in Sources */, diff --git a/Loop/Managers/Live Activity/ChartAxisGenerator.swift b/Loop/Managers/Live Activity/ChartAxisGenerator.swift new file mode 100644 index 0000000000..068f35fc9f --- /dev/null +++ b/Loop/Managers/Live Activity/ChartAxisGenerator.swift @@ -0,0 +1,134 @@ +// +// ChartAxisGenerator.swift +// Loop +// +// Created by Bastiaan Verhaar on 12/09/2024. +// Copyright © 2024 LoopKit Authors. All rights reserved. +// + +import Foundation +import LoopKitUI +import SwiftCharts +import HealthKit + +struct ChartAxisGenerator { + private static let axisLabelSettings = ChartLabelSettings(font: .systemFont(ofSize: 14), fontColor: UIColor.secondaryLabel) + + private static let minSegmentCount: Double = 2 + private static let yAxisStepSizeMGDLOverride: Double? = FeatureFlags.predictedGlucoseChartClampEnabled ? 40 : nil + private static let addPaddingSegmentIfEdge = false + private static let predictedGlucoseSoftBoundsMinimum: HKQuantity? = FeatureFlags.predictedGlucoseChartClampEnabled ? HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 40) : nil + + // Logic is copied/ported from generateYAxisValuesUsingLinearSegmentStep + public static func getYAxis(points: [Double], isMmol: Bool) -> [Double] { + let multiple: Double = !isMmol ? (yAxisStepSizeMGDLOverride ?? 25) : 1 + + var range: ClosedRange + if FeatureFlags.predictedGlucoseChartClampEnabled { + range = LoopConstants.glucoseChartDefaultDisplayBoundClamped + } else { + range = LoopConstants.glucoseChartDefaultDisplayBound + } + + let unit: HKUnit = isMmol ? .millimolesPerLiter : .milligramsPerDeciliter + let glucoseDisplayRange: [Double] = [ + range.lowerBound.doubleValue(for: unit), + range.upperBound.doubleValue(for: unit) + ] + + let actualPoints = points + glucoseDisplayRange + let sortedChartPoints = actualPoints.sorted {(obj1, obj2) in + return obj1 < obj2 + } + + guard let firstChartPoint = sortedChartPoints.first, let lastChartPoint = sortedChartPoints.last else { + print("Trying to generate Y axis without datapoints, returning empty array") + return [] + } + + let first = firstChartPoint + let lastPar = lastChartPoint + + let maxSegmentCount: Double = glucoseValueBelowSoftBoundsMinimum(first, unit) ? 5 : 4 + + guard lastPar >=~ first else {fatalError("Invalid range generating axis values")} + + let last = needsIncreaseByOne(lastPar, first) ? lastPar + 1 : lastPar + + /// The first axis value will be less than or equal to the first scalar value, aligned with the desired multiple + var firstValue = first - (first.truncatingRemainder(dividingBy: multiple)) + /// The last axis value will be greater than or equal to the last scalar value, aligned with the desired multiple + let remainder = last.truncatingRemainder(dividingBy: multiple) + var lastValue = remainder == 0 ? last : last + (multiple - remainder) + var segmentSize = multiple + + /// If there should be a padding segment added when a scalar value falls on the first or last axis value, adjust the first and last axis values + if firstValue =~ first && addPaddingSegmentIfEdge { + firstValue = firstValue - segmentSize + } + + // do not allow the first label to be displayed as -0 + while firstValue < 0 && firstValue.rounded() == -0 { + firstValue = firstValue - segmentSize + } + + if lastValue =~ last && addPaddingSegmentIfEdge { + lastValue = lastValue + segmentSize + } + + let distance = lastValue - firstValue + var currentMultiple = multiple + var segmentCount = distance / currentMultiple + var potentialSegmentValues = stride(from: firstValue, to: lastValue, by: currentMultiple) + + /// Find the optimal number of segments and segment width + /// If the number of segments is greater than desired, make each segment wider + /// ensure no label of -0 will be displayed on the axis + while segmentCount > maxSegmentCount || + !potentialSegmentValues.filter({ $0 < 0 && $0.rounded() == -0 }).isEmpty + { + currentMultiple += multiple + segmentCount = distance / currentMultiple + potentialSegmentValues = stride(from: firstValue, to: lastValue, by: currentMultiple) + } + segmentCount = ceil(segmentCount) + + /// Increase the number of segments until there are enough as desired + while segmentCount < minSegmentCount { + segmentCount += 1 + } + segmentSize = currentMultiple + + /// Generate axis values from the first value, segment size and number of segments + let offset = firstValue + return (0...Int(segmentCount)).map {segment in + var scalar = offset + (Double(segment) * segmentSize) + // a value that could be displayed as 0 should truly be 0 to have the zero-line drawn correctly. + if scalar != 0, + scalar.rounded() == 0 + { + scalar = 0 + } + return ChartAxisValueDouble(scalar, labelSettings: axisLabelSettings).scalar + } + } + + private static func glucoseValueBelowSoftBoundsMinimum(_ minimumValue: Double, _ unit: HKUnit) -> Bool { + guard let predictedGlucoseSoftBoundsMinimum = predictedGlucoseSoftBoundsMinimum else + { + return false + } + + return HKQuantity(unit: unit, doubleValue: minimumValue) < predictedGlucoseSoftBoundsMinimum + } + + private static func needsIncreaseByOne(_ a: Double, _ b: Double) -> Bool { + return fabs(a - b) < Double.ulpOfOne + } +} + +fileprivate extension Double { + static func >=~ (a: Double, b: Double) -> Bool { + return a =~ b || a > b + } +} diff --git a/Loop/Managers/Live Activity/GlucoseActivityAttributes.swift b/Loop/Managers/Live Activity/GlucoseActivityAttributes.swift index 4efa8b761a..936de751c5 100644 --- a/Loop/Managers/Live Activity/GlucoseActivityAttributes.swift +++ b/Loop/Managers/Live Activity/GlucoseActivityAttributes.swift @@ -37,6 +37,7 @@ public struct GlucoseActivityAttributes: ActivityAttributes { public let predicatedGlucose: [Double] public let predicatedStartDate: Date? public let predicatedInterval: TimeInterval? + public let yAxisMarks: [Double] } public let mode: LiveActivityMode diff --git a/Loop/Managers/Live Activity/GlucoseActivityManager.swift b/Loop/Managers/Live Activity/GlucoseActivityManager.swift index 750dba002b..3903ee8b44 100644 --- a/Loop/Managers/Live Activity/GlucoseActivityManager.swift +++ b/Loop/Managers/Live Activity/GlucoseActivityManager.swift @@ -165,6 +165,12 @@ class GlucoseActivityManager { unit: unit ) } + + let yAxisPoints = glucoseSamples.map{ item in item.quantity.doubleValue(for: unit) } + predicatedGlucose + let chartYAxis = ChartAxisGenerator.getYAxis( + points: yAxisPoints, + isMmol: unit == HKUnit.millimolesPerLiter + ) let state = GlucoseActivityAttributes.ContentState( date: currentGlucose.startDate, @@ -185,7 +191,8 @@ class GlucoseActivityManager { }, predicatedGlucose: predicatedGlucose, predicatedStartDate: statusContext?.predictedGlucose?.startDate, - predicatedInterval: statusContext?.predictedGlucose?.interval + predicatedInterval: statusContext?.predictedGlucose?.interval, + yAxisMarks: chartYAxis ) await self.activity?.update(ActivityContent( @@ -474,7 +481,8 @@ class GlucoseActivityManager { glucoseSamples: [], predicatedGlucose: [], predicatedStartDate: nil, - predicatedInterval: nil + predicatedInterval: nil, + yAxisMarks: [] ) self.activity = try Activity.request( @@ -509,4 +517,4 @@ extension TemporaryScheduleOverride { return "" } } -} \ No newline at end of file +} From fad3e9f12c5fe3cffc93a1696769eafb1f2c62cd Mon Sep 17 00:00:00 2001 From: bastiaanv Date: Thu, 12 Sep 2024 22:10:38 +0200 Subject: [PATCH 31/33] optimize --- .../Live Activity/ChartAxisGenerator.swift | 49 ++++++++----------- 1 file changed, 20 insertions(+), 29 deletions(-) diff --git a/Loop/Managers/Live Activity/ChartAxisGenerator.swift b/Loop/Managers/Live Activity/ChartAxisGenerator.swift index 068f35fc9f..c444ada4f1 100644 --- a/Loop/Managers/Live Activity/ChartAxisGenerator.swift +++ b/Loop/Managers/Live Activity/ChartAxisGenerator.swift @@ -7,53 +7,44 @@ // import Foundation -import LoopKitUI -import SwiftCharts import HealthKit +import SwiftCharts +import UIKit struct ChartAxisGenerator { - private static let axisLabelSettings = ChartLabelSettings(font: .systemFont(ofSize: 14), fontColor: UIColor.secondaryLabel) + private static let yAxisStepSizeMGDLOverride: Double? = FeatureFlags.predictedGlucoseChartClampEnabled ? 40 : nil + private static let range = FeatureFlags.predictedGlucoseChartClampEnabled ? LoopConstants.glucoseChartDefaultDisplayBoundClamped : LoopConstants.glucoseChartDefaultDisplayBound + private static let predictedGlucoseSoftBoundsMinimum = FeatureFlags.predictedGlucoseChartClampEnabled ? HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 40) : nil private static let minSegmentCount: Double = 2 - private static let yAxisStepSizeMGDLOverride: Double? = FeatureFlags.predictedGlucoseChartClampEnabled ? 40 : nil private static let addPaddingSegmentIfEdge = false - private static let predictedGlucoseSoftBoundsMinimum: HKQuantity? = FeatureFlags.predictedGlucoseChartClampEnabled ? HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 40) : nil + private static let axisLabelSettings = ChartLabelSettings(font: .systemFont(ofSize: 14), fontColor: UIColor.secondaryLabel) - // Logic is copied/ported from generateYAxisValuesUsingLinearSegmentStep - public static func getYAxis(points: [Double], isMmol: Bool) -> [Double] { - let multiple: Double = !isMmol ? (yAxisStepSizeMGDLOverride ?? 25) : 1 - - var range: ClosedRange - if FeatureFlags.predictedGlucoseChartClampEnabled { - range = LoopConstants.glucoseChartDefaultDisplayBoundClamped - } else { - range = LoopConstants.glucoseChartDefaultDisplayBound - } - + // This logic is copied/ported from generateYAxisValuesUsingLinearSegmentStep + static func getYAxis(points: [Double], isMmol: Bool) -> [Double] { let unit: HKUnit = isMmol ? .millimolesPerLiter : .milligramsPerDeciliter - let glucoseDisplayRange: [Double] = [ + + let glucoseDisplayRange = [ range.lowerBound.doubleValue(for: unit), range.upperBound.doubleValue(for: unit) ] let actualPoints = points + glucoseDisplayRange - let sortedChartPoints = actualPoints.sorted {(obj1, obj2) in + let sortedChartPoints = points.sorted {(obj1, obj2) in return obj1 < obj2 } - guard let firstChartPoint = sortedChartPoints.first, let lastChartPoint = sortedChartPoints.last else { + guard let first = sortedChartPoints.first, let lastPar = sortedChartPoints.last else { print("Trying to generate Y axis without datapoints, returning empty array") return [] } - let first = firstChartPoint - let lastPar = lastChartPoint - let maxSegmentCount: Double = glucoseValueBelowSoftBoundsMinimum(first, unit) ? 5 : 4 guard lastPar >=~ first else {fatalError("Invalid range generating axis values")} + let multiple: Double = !isMmol ? (yAxisStepSizeMGDLOverride ?? 25) : 1 - let last = needsIncreaseByOne(lastPar, first) ? lastPar + 1 : lastPar + let last = needsToAddOne(lastPar, first) ? lastPar + 1 : lastPar /// The first axis value will be less than or equal to the first scalar value, aligned with the desired multiple var firstValue = first - (first.truncatingRemainder(dividingBy: multiple)) @@ -113,17 +104,17 @@ struct ChartAxisGenerator { } } - private static func glucoseValueBelowSoftBoundsMinimum(_ minimumValue: Double, _ unit: HKUnit) -> Bool { + private static func needsToAddOne(_ a: Double, _ b: Double) -> Bool { + return fabs(a - b) < Double.ulpOfOne + } + + private static func glucoseValueBelowSoftBoundsMinimum(_ glucoseMinimum: Double, _ unit: HKUnit) -> Bool { guard let predictedGlucoseSoftBoundsMinimum = predictedGlucoseSoftBoundsMinimum else { return false } - return HKQuantity(unit: unit, doubleValue: minimumValue) < predictedGlucoseSoftBoundsMinimum - } - - private static func needsIncreaseByOne(_ a: Double, _ b: Double) -> Bool { - return fabs(a - b) < Double.ulpOfOne + return HKQuantity(unit: unit, doubleValue: glucoseMinimum) < predictedGlucoseSoftBoundsMinimum } } From 88ba91c739d72f7b66b34cbb98bda8fdafe558a1 Mon Sep 17 00:00:00 2001 From: bastiaanv Date: Thu, 12 Sep 2024 22:18:52 +0200 Subject: [PATCH 32/33] hotfix --- Loop/Managers/Live Activity/ChartAxisGenerator.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Loop/Managers/Live Activity/ChartAxisGenerator.swift b/Loop/Managers/Live Activity/ChartAxisGenerator.swift index c444ada4f1..0fcc3ca80d 100644 --- a/Loop/Managers/Live Activity/ChartAxisGenerator.swift +++ b/Loop/Managers/Live Activity/ChartAxisGenerator.swift @@ -30,7 +30,7 @@ struct ChartAxisGenerator { ] let actualPoints = points + glucoseDisplayRange - let sortedChartPoints = points.sorted {(obj1, obj2) in + let sortedChartPoints = actualPoints.sorted {(obj1, obj2) in return obj1 < obj2 } From 03d5489bda06f0d383e9623d59f8755f7a9b7476 Mon Sep 17 00:00:00 2001 From: Bastiaan Verhaar <3987804+bastiaanv@users.noreply.github.com> Date: Fri, 13 Sep 2024 07:25:57 +0200 Subject: [PATCH 33/33] hotfix: yAxis scale --- Loop Widget Extension/Live Activity/ChartView.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Loop Widget Extension/Live Activity/ChartView.swift b/Loop Widget Extension/Live Activity/ChartView.swift index b8e3a7c8ed..b65e02c98e 100644 --- a/Loop Widget Extension/Live Activity/ChartView.swift +++ b/Loop Widget Extension/Live Activity/ChartView.swift @@ -90,6 +90,7 @@ struct ChartView: View { plotContent.background(.cyan.opacity(0.15)) } .chartLegend(.hidden) + .chartYScale(domain: [yAxisMarks.first ?? 0, yAxisMarks.last ?? 0]) .chartYAxis { AxisMarks(values: yAxisMarks) }