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/BasalViewActivity.swift b/Loop Widget Extension/Live Activity/BasalViewActivity.swift new file mode 100644 index 0000000000..915335c5fb --- /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/ChartView.swift b/Loop Widget Extension/Live Activity/ChartView.swift new file mode 100644 index 0000000000..b65e02c98e --- /dev/null +++ b/Loop Widget Extension/Live Activity/ChartView.swift @@ -0,0 +1,159 @@ +// +// 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 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?, yAxisMarks: [Double]) { + 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.preset = preset + self.glucoseRanges = glucoseRanges + self.yAxisMarks = yAxisMarks + } + + 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 { + ZStack(alignment: Alignment(horizontal: .trailing, vertical: .top)){ + Chart { + if let preset = self.preset, predicatedData.count > 0, preset.endDate > Date.now.addingTimeInterval(.hours(-6)) { + RectangleMark( + xStart: .value("Start", preset.startDate), + 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, + "Low": .red, + "Default": .blue + ]) + .chartPlotStyle { plotContent in + plotContent.background(.cyan.opacity(0.15)) + } + .chartLegend(.hidden) + .chartYScale(domain: [yAxisMarks.first ?? 0, yAxisMarks.last ?? 0]) + .chartYAxis { + AxisMarks(values: yAxisMarks) + } + .chartYAxis { + AxisMarks(position: .leading) { _ in + AxisValueLabel().foregroundStyle(Color.primary) + AxisGridLine(stroke: .init(lineWidth: 0.1, dash: [2, 3])) + .foregroundStyle(Color.primary) + } + } + .chartXAxis { + AxisMarks(position: .automatic, values: .stride(by: .hour)) { _ in + AxisValueLabel(format: .dateTime.hour(.twoDigits(amPM: .narrow)), anchor: .top) + .foregroundStyle(Color.primary) + AxisGridLine(stroke: .init(lineWidth: 0.1, dash: [2, 3])) + .foregroundStyle(Color.primary) + } + } + + if let preset = self.preset, preset.endDate > Date.now { + Text(preset.title) + .font(.footnote) + .padding(.trailing, 5) + .padding(.top, 2) + } + } + } +} + +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, useLimits: Bool, lowerLimit: Double, upperLimit: Double) -> [ChartValues] { + let twoHours = Date.now.addingTimeInterval(.hours(4)) + + 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: !useLimits ? "Default" : item < lowerLimit ? "Low" : item > upperLimit ? "High" : "Good" + ) + } + } + + 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: !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 new file mode 100644 index 0000000000..4d5ed5ef3e --- /dev/null +++ b/Loop Widget Extension/Live Activity/GlucoseLiveActivityConfiguration.swift @@ -0,0 +1,298 @@ +// +// 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 +import HealthKit + +@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. + ZStack { + VStack { + 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, + yAxisMarks: context.state.yAxisMarks + ) + .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, + yAxisMarks: context.state.yAxisMarks + ) + .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) + + case .currentBg: + bottomItemCurrentBG( + value: item.value, + trend: item.trend, + context: context + ) + + case .loopCircle: + bottomItemLoopCircle(context: context) + } + + if index != endIndex { + bottomSpacer(border: true) + } + } + + bottomSpacer(border: false) + } + } + if context.state.ended { + VStack { + Spacer() + HStack { + Spacer() + Text(NSLocalizedString("Open the app to update the widget", comment: "No comment")) + Spacer() + } + Spacer() + } + .background(.ultraThinMaterial.opacity(0.8)) + .padding(.all, -15) + } + } + .privacySensitive() + .padding(.all, 15) + .background(BackgroundStyle.background.opacity(0.4)) + .activityBackgroundTint(Color.clear) + } dynamicIsland: { context in + let glucoseFormatter = NumberFormatter.glucoseFormatter(for: context.state.isMmol ? HKUnit.millimolesPerLiter : HKUnit.milligramsPerDeciliter) + + return DynamicIsland { + DynamicIslandExpandedRegion(.leading) { + HStack(alignment: .center) { + loopIcon(context) + .frame(width: 40, height: 40, alignment: .trailing) + Spacer() + Text("\(glucoseFormatter.string(from: context.state.currentGlucose) ?? "??")\(getArrowImage(context.state.trendType))") + .foregroundStyle(getGlucoseColor(context.state.currentGlucose, context: context)) + .font(.headline) + .fontWeight(.heavy) + } + } + DynamicIslandExpandedRegion(.trailing) { + 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 { + 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, + yAxisMarks: context.state.yAxisMarks + ) + .frame(height: 75) + } 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, + yAxisMarks: context.state.yAxisMarks + ) + .frame(height: 75) + } + } + } compactLeading: { + Text("\(glucoseFormatter.string(from: context.state.currentGlucose) ?? "??")\(getArrowImage(context.state.trendType))") + .foregroundStyle(getGlucoseColor(context.state.currentGlucose, context: context)) + .minimumScaleFactor(0.1) + } compactTrailing: { + Text(context.state.delta) + .foregroundStyle(Color(white: 0.9)) + .minimumScaleFactor(0.1) + } minimal: { + Text(glucoseFormatter.string(from: context.state.currentGlucose) ?? "??") + .foregroundStyle(getGlucoseColor(context.state.currentGlucose, context: context)) + .minimumScaleFactor(0.1) + } + } + } + + @ViewBuilder + 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) + .foregroundStyle(.primary) + .fontWeight(.heavy) + .font(Font.body.leading(.tight)) + Text(title) + .font(.subheadline) + } + } + + @ViewBuilder + private func bottomItemCurrentBG(value: String, trend: GlucoseTrend?, context: ActivityViewContext) -> some View { + VStack(alignment: .center) { + HStack { + Text(value + getArrowImage(trend)) + .font(.title) + .foregroundStyle(!context.attributes.useLimits ? .primary : getGlucoseColor(context.state.currentGlucose, context: context)) + .fontWeight(.heavy) + .font(Font.body.leading(.tight)) + } + } + } + + @ViewBuilder + private func bottomItemLoopCircle(context: ActivityViewContext) -> some View { + VStack(alignment: .center) { + loopIcon(context) + } + } + + @ViewBuilder + private func bottomSpacer(border: Bool) -> some View { + Spacer() + if (border) { + Divider() + .background(.secondary) + Spacer() + } + + } + + private func getArrowImage(_ trendType: GlucoseTrend?) -> String { + switch trendType { + case .upUpUp: + return "\u{2191}\u{2191}" // ↑↑ + case .upUp: + return "\u{2191}" // ↑ + case .up: + return "\u{2197}" // ↗ + case .flat: + return "\u{2192}" // → + case .down: + return "\u{2198}" // ↘ + case .downDown: + return "\u{2193}" // ↓ + case .downDownDown: + return "\u{2193}\u{2193}" // ↓↓ + case .none: + return "" + } + } + + 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 + } + } + + 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 + { + return .red + } + + if + context.state.isMmol && value > context.attributes.upperLimitChartMmol || + !context.state.isMmol && value > context.attributes.upperLimitChartMg + { + return .orange + } + + return .green + } +} diff --git a/Loop Widget Extension/LoopWidgets.swift b/Loop Widget Extension/LoopWidgets.swift index 26f92edb45..684bf07355 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() + GlucoseLiveActivityConfiguration() } } diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index 1181951609..133e3dca84 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -401,6 +401,21 @@ 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 */; }; + 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 */; }; + B87539C92C2B06CE0085A975 /* LocalizedString.swift in Sources */ = {isa = PBXBuildFile; fileRef = B87539C82C2B06CE0085A975 /* LocalizedString.swift */; }; + 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 */; }; + 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 */; }; + 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 */; }; @@ -729,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; @@ -1325,6 +1350,19 @@ 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 = ""; }; + 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 = ""; }; + 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 = ""; }; + 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 = ""; }; + 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 = ""; }; @@ -1727,6 +1765,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 +1875,7 @@ 84AA81D12A4A2778000B658B /* Components */, 84AA81D92A4A2966000B658B /* Helpers */, 84AA81DE2A4A2B3D000B658B /* Timeline */, + B87D41192C28A61900120877 /* Live Activity */, 84AA81DF2A4A2B7A000B658B /* Widgets */, 84AA81D22A4A27A3000B658B /* LoopWidgets.swift */, ); @@ -2163,6 +2203,7 @@ 43DE92581C5479E4001FFDE1 /* PotentialCarbEntryUserInfo.swift */, 43C05CB721EBEA54006FB252 /* HKUnit.swift */, 434FF1E91CF26C29000DB779 /* IdentifiableClass.swift */, + E9C00EEF24C620EF00628F35 /* LoopSettings.swift */, C19E96DD23D2733F003F79B0 /* LoopCompletionFreshness.swift */, 430B29892041F54A00BA9F93 /* NSUserDefaults.swift */, 431E73471FF95A900069B5F7 /* PersistenceController.swift */, @@ -2170,10 +2211,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 = ""; @@ -2276,6 +2317,8 @@ C1AF062229426300002C1B19 /* ManualGlucoseEntryRow.swift */, DDC389FD2A2C4C830066E2E8 /* GlucoseBasedApplicationFactorSelectionView.swift */, DD3DBD282A33AFE9000F8B5B /* IntegralRetrospectiveCorrectionSelectionView.swift */, + B851FFC42C37221800D738C1 /* LiveActivityManagementView.swift */, + B8FD0B512C39CA3E003FB72B /* LiveActivityBottomRowManagerView.swift */, ); path = Views; sourceTree = ""; @@ -2315,6 +2358,7 @@ 1DA6499D2441266400F61E75 /* Alerts */, E95D37FF24EADE68005E2F50 /* Store Protocols */, E9B355232935906B0076AB04 /* Missed Meal Detection */, + B8A937C52C29C44600E38645 /* Live Activity */, C1F2075B26D6F9B0007AB7EB /* AppExpirationAlerter.swift */, A96DAC2B2838F31200D94E38 /* SharedLogging.swift */, 7E69CFFB2A16A77E00203CBD /* ResetLoopManager.swift */, @@ -2525,6 +2569,7 @@ C11613472983096D00777E7C /* InfoPlist.strings */, 14B1736D28AEDA63006CCD7C /* LoopWidgetExtension.entitlements */, 14B1736628AED9EE006CCD7C /* Info.plist */, + B87539CA2C2B08430085A975 /* Bootstrap.swift */, ); path = Bootstrap; sourceTree = ""; @@ -2535,6 +2580,7 @@ 84AA81DA2A4A2973000B658B /* Date.swift */, 84AA81D52A4A28AF000B658B /* WidgetBackground.swift */, 84D2879E2AC756C8007ED283 /* ContentMargin.swift */, + B87539C82C2B06CE0085A975 /* LocalizedString.swift */, ); path = Helpers; sourceTree = ""; @@ -2628,6 +2674,7 @@ 1D49795724E7289700948F05 /* ServicesViewModel.swift */, C174233B259BEB0F00399C9D /* ManualEntryDoseViewModel.swift */, 1DB619AB270BAD3D006C9D07 /* VersionUpdateViewModel.swift */, + B82181F52C93628300478A91 /* LiveActivityManagementViewModel.swift */, ); path = "View Models"; sourceTree = ""; @@ -2663,6 +2710,7 @@ 968DCD53F724DE56FFE51920 /* Frameworks */ = { isa = PBXGroup; children = ( + B87D411E2C28A85F00120877 /* ActivityKit.framework */, C159C82E286787EF00A86EC0 /* LoopKit.framework */, C159C8212867859800A86EC0 /* MockKitUI.framework */, C159C8192867857000A86EC0 /* LoopKitUI.framework */, @@ -2760,6 +2808,26 @@ path = LoopCore; sourceTree = ""; }; + B87D41192C28A61900120877 /* Live Activity */ = { + isa = PBXGroup; + children = ( + B87D411C2C28A69600120877 /* GlucoseLiveActivityConfiguration.swift */, + B87539CC2C2B46950085A975 /* ChartView.swift */, + B87539CE2C2DCB770085A975 /* BasalViewActivity.swift */, + ); + path = "Live Activity"; + sourceTree = ""; + }; + B8A937C52C29C44600E38645 /* Live Activity */ = { + isa = PBXGroup; + children = ( + B8A937B72C29BA5900E38645 /* GlucoseActivityManager.swift */, + B8A937C02C29C29300E38645 /* GlucoseActivityAttributes.swift */, + B82181F92C936AD600478A91 /* ChartAxisGenerator.swift */, + ); + path = "Live Activity"; + sourceTree = ""; + }; C13072B82A76AF0A009A7C58 /* live_capture */ = { isa = PBXGroup; children = ( @@ -2982,6 +3050,7 @@ 14B1735828AED9EC006CCD7C /* Sources */, 14B1735928AED9EC006CCD7C /* Frameworks */, 14B1735A28AED9EC006CCD7C /* Resources */, + B82181FF2C9370F800478A91 /* Embed Frameworks */, ); buildRules = ( ); @@ -2989,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"; @@ -3623,12 +3694,15 @@ 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 */, 84AA81D62A4A28AF000B658B /* WidgetBackground.swift in Sources */, 84AA81D82A4A2910000B658B /* StatusWidgetTimelimeEntry.swift in Sources */, 84AA81D32A4A27A3000B658B /* LoopWidgets.swift in Sources */, + B87539CF2C2DCB770085A975 /* BasalViewActivity.swift in Sources */, + B87539CD2C2B46950085A975 /* ChartView.swift in Sources */, 84AA81E32A4A36FB000B658B /* SystemActionLink.swift in Sources */, 14B1737828AEDC6C006CCD7C /* NSTimeInterval.swift in Sources */, 14B1737928AEDC6C006CCD7C /* NSUserDefaults+StatusExtension.swift in Sources */, @@ -3639,7 +3713,10 @@ 14B1737E28AEDC6C006CCD7C /* FeatureFlags.swift in Sources */, 84AA81E72A4A4DEF000B658B /* PumpView.swift in Sources */, 14B1737F28AEDC6C006CCD7C /* PluginManager.swift in Sources */, + B87D411D2C28A69600120877 /* GlucoseLiveActivityConfiguration.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 */, @@ -3725,6 +3802,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 */, @@ -3732,6 +3810,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 */, @@ -3786,6 +3865,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 */, @@ -3818,11 +3898,13 @@ 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 */, 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 */, @@ -3835,6 +3917,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 */, @@ -3956,6 +4039,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 */, @@ -3977,6 +4061,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 */, @@ -4832,6 +4917,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 +4966,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 +5225,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 +5255,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 +5512,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 +5539,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 +5607,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 +5635,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..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 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/ChartAxisGenerator.swift b/Loop/Managers/Live Activity/ChartAxisGenerator.swift new file mode 100644 index 0000000000..0fcc3ca80d --- /dev/null +++ b/Loop/Managers/Live Activity/ChartAxisGenerator.swift @@ -0,0 +1,125 @@ +// +// ChartAxisGenerator.swift +// Loop +// +// Created by Bastiaan Verhaar on 12/09/2024. +// Copyright © 2024 LoopKit Authors. All rights reserved. +// + +import Foundation +import HealthKit +import SwiftCharts +import UIKit + +struct ChartAxisGenerator { + 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 addPaddingSegmentIfEdge = false + private static let axisLabelSettings = ChartLabelSettings(font: .systemFont(ofSize: 14), fontColor: UIColor.secondaryLabel) + + // This logic is copied/ported from generateYAxisValuesUsingLinearSegmentStep + static func getYAxis(points: [Double], isMmol: Bool) -> [Double] { + let unit: HKUnit = isMmol ? .millimolesPerLiter : .milligramsPerDeciliter + + let glucoseDisplayRange = [ + 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 first = sortedChartPoints.first, let lastPar = sortedChartPoints.last else { + print("Trying to generate Y axis without datapoints, returning empty array") + return [] + } + + 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 = 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)) + /// 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 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: glucoseMinimum) < predictedGlucoseSoftBoundsMinimum + } +} + +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 new file mode 100644 index 0000000000..936de751c5 --- /dev/null +++ b/Loop/Managers/Live Activity/GlucoseActivityAttributes.swift @@ -0,0 +1,151 @@ +// +// LiveActivityAttributes.swift +// LoopUI +// +// Created by Bastiaan Verhaar on 23/06/2024. +// Copyright © 2024 LoopKit Authors. All rights reserved. +// + +import ActivityKit +import Foundation +import LoopKit +import LoopCore + +public struct GlucoseActivityAttributes: ActivityAttributes { + public struct ContentState: Codable, Hashable { + // 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 + 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 predicatedGlucose: [Double] + public let predicatedStartDate: Date? + public let predicatedInterval: TimeInterval? + public let yAxisMarks: [Double] + } + + public let mode: LiveActivityMode + public let addPredictiveLine: Bool + public let useLimits: Bool + public let upperLimitChartMmol: Double + public let lowerLimitChartMmol: Double + public let upperLimitChartMg: Double + public let lowerLimitChartMg: Double +} + +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 +} + +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 + case basal + case currentBg + case loopCircle + } + + public let type: BottomRowType + + // Generic properties + public let label: String + public let value: String + public let unit: String + + public let trend: GlucoseTrend? + + // Basal properties + public let rate: Double + public let percentage: Double + + 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 + } + + 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 + ) + } + + 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 + ) + } +} + +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 new file mode 100644 index 0000000000..3903ee8b44 --- /dev/null +++ b/Loop/Managers/Live Activity/GlucoseActivityManager.swift @@ -0,0 +1,520 @@ +// +// LiveActivityManaer.swift +// Loop +// +// Created by Bastiaan Verhaar on 24/06/2024. +// Copyright © 2024 LoopKit Authors. All rights reserved. +// + +import LoopKitUI +import LoopKit +import LoopCore +import Foundation +import HealthKit +import ActivityKit + +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 let healthStore = HKHealthStore() + + private let glucoseStore: GlucoseStoreProtocol + private let doseStore: DoseStoreProtocol + private var loopSettings: LoopSettings + + private var startDate: Date = Date.now + private var settings: LiveActivitySettings = UserDefaults.standard.liveActivity ?? LiveActivitySettings() + + 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 + }() + private let timeFormatter: DateFormatter = { + let dateFormatter = DateFormatter() + dateFormatter.dateStyle = .none + dateFormatter.timeStyle = .short + + return dateFormatter + }() + + 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.loopSettings = loopSettings + + // Ensure settings exist + if UserDefaults.standard.liveActivity == nil { + self.settings = LiveActivitySettings() + } + + 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 + } + + initEmptyActivity(settings: self.settings) + update() + + Task { + await self.endUnknownActivities() + } + } + + 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 + 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 + } + + await self.endUnknownActivities() + + let statusContext = UserDefaults.appGroup?.statusExtensionContext + let glucoseFormatter = NumberFormatter.glucoseFormatter(for: unit) + + let glucoseSamples = self.getGlucoseSample(unit: unit) + guard let currentGlucose = glucoseSamples.last else { + print("ERROR: No glucose sample found...") + return + } + + let current = currentGlucose.quantity.doubleValue(for: unit) + + var delta: String = "+\(glucoseFormatter.string(from: Double(0)) ?? "")" + 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 bottomRow = self.getBottomRow( + currentGlucose: current, + delta: delta, + statusContext: statusContext, + glucoseFormatter: glucoseFormatter + ) + + var predicatedGlucose: [Double] = [] + 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)) + } + + guard let endDateChart = endDateChart else { + return + } + + var presetContext: Preset? = nil + if let override = self.loopSettings.preMealOverride ?? self.loopSettings.scheduleOverride, let start = glucoseSamples.first?.startDate { + presetContext = Preset( + title: override.getTitle(), + startDate: max(override.startDate, start), + 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 + ) + } + + var glucoseRanges: [GlucoseRangeValue] = [] + if let glucoseRangeSchedule = self.loopSettings.glucoseTargetRangeSchedule, let start = glucoseSamples.first?.startDate { + glucoseRanges = getGlucoseRanges( + glucoseRangeSchedule: glucoseRangeSchedule, + presetContext: presetContext, + start: start, + end: endDateChart, + 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, + ended: false, + preset: presetContext, + glucoseRanges: glucoseRanges, + currentGlucose: current, + trendType: statusContext?.glucoseDisplay?.trendType, + delta: delta, + isMmol: unit == HKUnit.millimolesPerLiter, + isCloseLoop: statusContext?.isClosedLoop ?? false, + lastCompleted: statusContext?.lastLoopCompleted, + bottomRow: bottomRow, + // 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, + yAxisMarks: chartYAxis + ) + + await self.activity?.update(ActivityContent( + state: state, + staleDate: Date.now.addingTimeInterval(.hours(1)) + )) + } + } + + @objc private func settingsChanged() { + 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 + + return + } else if newSettings.enabled && self.activity == nil { + initEmptyActivity(settings: newSettings) + + } else if newSettings != self.settings { + await self.activity?.end(nil, dismissalPolicy: .immediate) + self.activity = nil + + initEmptyActivity(settings: newSettings) + } + + self.settings = newSettings + update() + } + } + + @objc private func appMovedToForeground() { + guard self.settings.enabled else { + return + } + + guard let activity = self.activity else { + initEmptyActivity(settings: self.settings) + update() + 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 }) + { + 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 { + 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, + lowerLimitChartMmol: self.settings.lowerLimitChartMmol, + upperLimitChartMg: self.settings.upperLimitChartMg, + lowerLimitChartMg: self.settings.lowerLimitChartMg + ), + content: .init(state: dynamicState, staleDate: nil), + pushType: .token + ) + } + self.startDate = Date.now + } catch { + print("ERROR: Error while ending live activity: \(error.localizedDescription)") + } + } + + private func needsRecreation() -> Bool { + if !self.settings.enabled { + return false + } + + switch activity?.activityState { + case .dismissed, + .ended, + .stale: + return true + case .active: + return -startDate.timeIntervalSinceNow > .hours(1) + default: + return true + } + } + + 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) -> [StoredGlucoseSample] { + let updateGroup = DispatchGroup() + var samples: [StoredGlucoseSample] = [] + + updateGroup.enter() + + // 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 + break + } + + updateGroup.leave() + } + + _ = updateGroup.wait(timeout: .distantFuture) + 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) { + case .iob: + 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.generic(label: type.name(), value: cob, unit: "g") + + case .basal: + guard let netBasalContext = statusContext?.netBasal else { + return BottomRowItem.basal(rate: 0, percentage: 0) + } + + return BottomRowItem.basal(rate: netBasalContext.rate, percentage: netBasalContext.percentage) + + case .currentBg: + 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.generic(label: type.name(), value: "??", unit: "") + } + + return BottomRowItem.generic(label: type.name(), value: glucoseFormatter.string(from: eventual) ?? "??", unit: "") + + case .deltaBg: + return BottomRowItem.generic(label: type.name(), value: delta, unit: "") + + case .loopCircle: + return BottomRowItem.loopIcon() + + case .updatedAt: + return BottomRowItem.generic(label: type.name(), value: timeFormatter.string(from: Date.now), unit: "") + } + } + } + + private func initEmptyActivity(settings: LiveActivitySettings) { + do { + let dynamicState = GlucoseActivityAttributes.ContentState( + date: Date.now, + ended: true, + preset: nil, + glucoseRanges: [], + currentGlucose: 0, + trendType: nil, + delta: "", + isMmol: true, + isCloseLoop: false, + lastCompleted: nil, + bottomRow: [], + glucoseSamples: [], + predicatedGlucose: [], + predicatedStartDate: nil, + predicatedInterval: nil, + yAxisMarks: [] + ) + + self.activity = try Activity.request( + attributes: GlucoseActivityAttributes( + mode: settings.mode, + addPredictiveLine: settings.addPredictiveLine, + useLimits: settings.useLimits, + upperLimitChartMmol: settings.upperLimitChartMmol, + lowerLimitChartMmol: settings.lowerLimitChartMmol, + upperLimitChartMg: settings.upperLimitChartMg, + lowerLimitChartMg: settings.lowerLimitChartMg + ), + content: .init(state: dynamicState, staleDate: nil), + pushType: .token + ) + } catch { + print("ERROR: Error while creating empty live activity: \(error.localizedDescription)") + } + } +} + +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 2319f4eceb..c5220cb0c5 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,12 @@ final class LoopDataManager { self.automaticDosingStatus = automaticDosingStatus self.trustedTimeOffset = trustedTimeOffset + + self.liveActivityManager = GlucoseActivityManager( + glucoseStore: self.glucoseStore, + doseStore: self.doseStore, + loopSettings: self.settings + ) overrideIntentObserver = UserDefaults.appGroup?.observe(\.intentExtensionOverrideToSet, options: [.new], changeHandler: {[weak self] (defaults, change) in guard let name = change.newValue??.lowercased(), let appGroup = UserDefaults.appGroup else { @@ -144,12 +152,14 @@ final class LoopDataManager { } } } + settings.scheduleOverride = preset.createOverride(enactTrigger: .remote("Siri")) if let observers = self?.presetActivationObservers { for observer in observers { observer.presetActivated(context: .preset(preset), duration: preset.duration) } } + self?.liveActivityManager?.update(loopSettings: settings) } // Remove the override from UserDefaults so we don't set it multiple times appGroup.intentExtensionOverrideToSet = nil @@ -167,6 +177,7 @@ final class LoopDataManager { ) { (note) -> Void in self.dataAccessQueue.async { self.logger.default("Received notification of carb entries changing") + self.liveActivityManager?.update(loopSettings: self.settings) self.carbEffect = nil self.carbsOnBoard = nil @@ -182,7 +193,8 @@ final class LoopDataManager { ) { (note) in self.dataAccessQueue.async { self.logger.default("Received notification of glucose samples changing") - + self.liveActivityManager?.update(loopSettings: self.settings) + self.glucoseMomentumEffect = nil self.remoteRecommendationNeedsUpdating = true @@ -196,6 +208,7 @@ final class LoopDataManager { ) { (note) in self.dataAccessQueue.async { self.logger.default("Received notification of dosing changing") + self.liveActivityManager?.update(loopSettings: self.settings) self.clearCachedInsulinEffects() self.remoteRecommendationNeedsUpdating = true @@ -247,6 +260,8 @@ 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 + + self.liveActivityManager?.update(loopSettings: newValue) } if newValue.scheduleOverride != oldValue.scheduleOverride { @@ -256,12 +271,14 @@ final class LoopDataManager { for observer in self.presetActivationObservers { observer.presetDeactivated(context: oldPreset.context) } - + 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(loopSettings: newValue) } // Invalidate cached effects affected by the override 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/AlertManagementView.swift b/Loop/Views/AlertManagementView.swift index e9a38e72a0..94e542a6ab 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 @@ -157,6 +159,11 @@ struct AlertManagementView: View { } } } + + NavigationLink(destination: LiveActivityManagementView()) + { + Text(NSLocalizedString("Live activity", comment: "Alert Permissions live activity")) + } } } 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 new file mode 100644 index 0000000000..f7f875caac --- /dev/null +++ b/Loop/Views/LiveActivityManagementView.swift @@ -0,0 +1,113 @@ +// +// LiveActivityManagementView.swift +// Loop +// +// Created by Bastiaan Verhaar on 04/07/2024. +// Copyright © 2024 LoopKit Authors. All rights reserved. +// + +import SwiftUI +import LoopKitUI +import LoopCore +import HealthKit + +struct LiveActivityManagementView: View { + @EnvironmentObject private var displayGlucosePreference: DisplayGlucosePreference + @StateObject private var viewModel = LiveActivityManagementViewModel() + + var body: some View { + VStack { + List { + Section { + Toggle(NSLocalizedString("Enabled", comment: "Title for enable live activity toggle"), isOn: $viewModel.enabled) + + ExpandableSetting( + isEditing: $viewModel.isEditingMode, + leadingValueContent: { + Text(NSLocalizedString("Mode", comment: "Title for mode live activity toggle")) + .foregroundStyle(viewModel.isEditingMode ? .blue : .primary) + }, + trailingValueContent: { + Text(viewModel.mode.name()) + .foregroundStyle(viewModel.isEditingMode ? .blue : .primary) + }, + expandedContent: { + ResizeablePicker(selection: self.$viewModel.mode.animation(), + data: LiveActivityMode.all, + formatter: { $0.name() }) + } + ) + } + + Section { + 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: $viewModel.useLimits) + .transition(.move(edge: viewModel.mode == .large ? .top : .bottom)) + + if viewModel.useLimits { + if self.displayGlucosePreference.unit == .millimolesPerLiter { + 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: $viewModel.upperLimitChartMg) + .transition(.move(edge: viewModel.useLimits ? .top : .bottom)) + TextInput(label: "Lower limit", value: $viewModel.lowerLimitChartMg) + .transition(.move(edge: viewModel.useLimits ? .top : .bottom)) + } + } + } + + 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"))) + } + + @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.displayGlucosePreference.unit.localizedShortUnitString) + } + } + + private func save() { + var settings = UserDefaults.standard.liveActivity ?? LiveActivitySettings() + 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) + } +} diff --git a/LoopCore/LiveActivitySettings.swift b/LoopCore/LiveActivitySettings.swift new file mode 100644 index 0000000000..71807464d4 --- /dev/null +++ b/LoopCore/LiveActivitySettings.swift @@ -0,0 +1,159 @@ +// +// 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 loopCircle + case updatedAt + + static let defaults: [BottomRowConfiguration] = [.currentBg, .iob, .cob, .updatedAt] + public static let all: [BottomRowConfiguration] = [.iob, .cob, .basal, .currentBg, .eventualBg, .deltaBg, .loopCircle, .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 .loopCircle: + return NSLocalizedString("Loop", 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 .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, Equatable { + public var enabled: Bool + public var mode: LiveActivityMode + public var addPredictiveLine: Bool + public var useLimits: 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 mode + case addPredictiveLine + case bottomRowConfiguration + case useLimits + 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(180) + private static let defaultLowerLimitMg = Double(72) + + public init(from decoder:Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + + 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() { + self.enabled = true + self.mode = .large + self.addPredictiveLine = true + self.useLimits = true + self.upperLimitChartMmol = LiveActivitySettings.defaultUpperLimitMmol + self.lowerLimitChartMmol = LiveActivitySettings.defaultLowerLimitMmol + self.upperLimitChartMg = LiveActivitySettings.defaultUpperLimitMg + 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 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")