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<GlucoseActivityAttributes>) -> 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<GlucoseActivityAttributes>) -> 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<GlucoseActivityAttributes>) -> 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<GlucoseActivityAttributes>) -> 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.xcconfig b/Loop.xcconfig index 9e1bb856ae..7827f574d5 100644 --- a/Loop.xcconfig +++ b/Loop.xcconfig @@ -43,7 +43,7 @@ LOOP_PROVISIONING_PROFILE_SPECIFIER_WATCHAPP_RELEASE = LOOP_PROVISIONING_PROFILE_SPECIFIER_WATCHAPP_EXTENSION_RELEASE = // Min iOS Version [DEFAULT] -IPHONEOS_DEPLOYMENT_TARGET = 15.1 +IPHONEOS_DEPLOYMENT_TARGET = 16.2 // Base string for opening app via URL [DEFAULT] URL_SCHEME_NAME = $(MAIN_APP_DISPLAY_NAME) diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index 1181951609..069408d7cc 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -401,6 +401,23 @@ 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 */; }; + B813C5682C9C30B400306DAF /* QuickStatsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B813C5672C9C30B400306DAF /* QuickStatsViewModel.swift */; }; + B813C56C2C9C30DC00306DAF /* QuickStatsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B813C56B2C9C30DC00306DAF /* QuickStatsView.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 /* LiveActivityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8A937B72C29BA5900E38645 /* LiveActivityManager.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 +746,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 +1352,21 @@ B4E96D5C248A82A2002DABAD /* StatusBarHUDView.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = StatusBarHUDView.xib; sourceTree = "<group>"; }; B4F3D25024AF890C0095CE44 /* BluetoothStateManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BluetoothStateManager.swift; sourceTree = "<group>"; }; B4FEEF7C24B8A71F00A8DF9B /* DeviceDataManager+DeviceStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DeviceDataManager+DeviceStatus.swift"; sourceTree = "<group>"; }; + B813C5672C9C30B400306DAF /* QuickStatsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickStatsViewModel.swift; sourceTree = "<group>"; }; + B813C56B2C9C30DC00306DAF /* QuickStatsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickStatsView.swift; sourceTree = "<group>"; }; + B82181F52C93628300478A91 /* LiveActivityManagementViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActivityManagementViewModel.swift; sourceTree = "<group>"; }; + B82181F92C936AD600478A91 /* ChartAxisGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChartAxisGenerator.swift; sourceTree = "<group>"; }; + B851FFC42C37221800D738C1 /* LiveActivityManagementView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActivityManagementView.swift; sourceTree = "<group>"; }; + B851FFC62C37271E00D738C1 /* LiveActivitySettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActivitySettings.swift; sourceTree = "<group>"; }; + B87539C82C2B06CE0085A975 /* LocalizedString.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalizedString.swift; sourceTree = "<group>"; }; + B87539CA2C2B08430085A975 /* Bootstrap.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Bootstrap.swift; sourceTree = "<group>"; }; + B87539CC2C2B46950085A975 /* ChartView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChartView.swift; sourceTree = "<group>"; }; + B87539CE2C2DCB770085A975 /* BasalViewActivity.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BasalViewActivity.swift; sourceTree = "<group>"; }; + B87D411C2C28A69600120877 /* GlucoseLiveActivityConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseLiveActivityConfiguration.swift; sourceTree = "<group>"; }; + B87D411E2C28A85F00120877 /* ActivityKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = ActivityKit.framework; path = System/Library/Frameworks/ActivityKit.framework; sourceTree = SDKROOT; }; + B8A937B72C29BA5900E38645 /* LiveActivityManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActivityManager.swift; sourceTree = "<group>"; }; + B8A937C02C29C29300E38645 /* GlucoseActivityAttributes.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GlucoseActivityAttributes.swift; sourceTree = "<group>"; }; + B8FD0B512C39CA3E003FB72B /* LiveActivityBottomRowManagerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActivityBottomRowManagerView.swift; sourceTree = "<group>"; }; C1004DEF2981F5B700B8CF94 /* da */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = da; path = da.lproj/InfoPlist.strings; sourceTree = "<group>"; }; C1004DF12981F5B700B8CF94 /* da */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = da; path = da.lproj/InfoPlist.strings; sourceTree = "<group>"; }; C1004DF42981F5B700B8CF94 /* da */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = da; path = da.lproj/Localizable.strings; sourceTree = "<group>"; }; @@ -1727,6 +1769,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 +1879,7 @@ 84AA81D12A4A2778000B658B /* Components */, 84AA81D92A4A2966000B658B /* Helpers */, 84AA81DE2A4A2B3D000B658B /* Timeline */, + B87D41192C28A61900120877 /* Live Activity */, 84AA81DF2A4A2B7A000B658B /* Widgets */, 84AA81D22A4A27A3000B658B /* LoopWidgets.swift */, ); @@ -2163,6 +2207,7 @@ 43DE92581C5479E4001FFDE1 /* PotentialCarbEntryUserInfo.swift */, 43C05CB721EBEA54006FB252 /* HKUnit.swift */, 434FF1E91CF26C29000DB779 /* IdentifiableClass.swift */, + E9C00EEF24C620EF00628F35 /* LoopSettings.swift */, C19E96DD23D2733F003F79B0 /* LoopCompletionFreshness.swift */, 430B29892041F54A00BA9F93 /* NSUserDefaults.swift */, 431E73471FF95A900069B5F7 /* PersistenceController.swift */, @@ -2170,10 +2215,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 = "<group>"; @@ -2276,6 +2321,8 @@ C1AF062229426300002C1B19 /* ManualGlucoseEntryRow.swift */, DDC389FD2A2C4C830066E2E8 /* GlucoseBasedApplicationFactorSelectionView.swift */, DD3DBD282A33AFE9000F8B5B /* IntegralRetrospectiveCorrectionSelectionView.swift */, + B851FFC42C37221800D738C1 /* LiveActivityManagementView.swift */, + B8FD0B512C39CA3E003FB72B /* LiveActivityBottomRowManagerView.swift */, ); path = Views; sourceTree = "<group>"; @@ -2315,6 +2362,7 @@ 1DA6499D2441266400F61E75 /* Alerts */, E95D37FF24EADE68005E2F50 /* Store Protocols */, E9B355232935906B0076AB04 /* Missed Meal Detection */, + B8A937C52C29C44600E38645 /* Live Activity */, C1F2075B26D6F9B0007AB7EB /* AppExpirationAlerter.swift */, A96DAC2B2838F31200D94E38 /* SharedLogging.swift */, 7E69CFFB2A16A77E00203CBD /* ResetLoopManager.swift */, @@ -2525,6 +2573,7 @@ C11613472983096D00777E7C /* InfoPlist.strings */, 14B1736D28AEDA63006CCD7C /* LoopWidgetExtension.entitlements */, 14B1736628AED9EE006CCD7C /* Info.plist */, + B87539CA2C2B08430085A975 /* Bootstrap.swift */, ); path = Bootstrap; sourceTree = "<group>"; @@ -2535,6 +2584,7 @@ 84AA81DA2A4A2973000B658B /* Date.swift */, 84AA81D52A4A28AF000B658B /* WidgetBackground.swift */, 84D2879E2AC756C8007ED283 /* ContentMargin.swift */, + B87539C82C2B06CE0085A975 /* LocalizedString.swift */, ); path = Helpers; sourceTree = "<group>"; @@ -2628,6 +2678,7 @@ 1D49795724E7289700948F05 /* ServicesViewModel.swift */, C174233B259BEB0F00399C9D /* ManualEntryDoseViewModel.swift */, 1DB619AB270BAD3D006C9D07 /* VersionUpdateViewModel.swift */, + B82181F52C93628300478A91 /* LiveActivityManagementViewModel.swift */, ); path = "View Models"; sourceTree = "<group>"; @@ -2663,6 +2714,7 @@ 968DCD53F724DE56FFE51920 /* Frameworks */ = { isa = PBXGroup; children = ( + B87D411E2C28A85F00120877 /* ActivityKit.framework */, C159C82E286787EF00A86EC0 /* LoopKit.framework */, C159C8212867859800A86EC0 /* MockKitUI.framework */, C159C8192867857000A86EC0 /* LoopKitUI.framework */, @@ -2760,6 +2812,26 @@ path = LoopCore; sourceTree = "<group>"; }; + B87D41192C28A61900120877 /* Live Activity */ = { + isa = PBXGroup; + children = ( + B87D411C2C28A69600120877 /* GlucoseLiveActivityConfiguration.swift */, + B87539CC2C2B46950085A975 /* ChartView.swift */, + B87539CE2C2DCB770085A975 /* BasalViewActivity.swift */, + ); + path = "Live Activity"; + sourceTree = "<group>"; + }; + B8A937C52C29C44600E38645 /* Live Activity */ = { + isa = PBXGroup; + children = ( + B8A937B72C29BA5900E38645 /* LiveActivityManager.swift */, + B8A937C02C29C29300E38645 /* GlucoseActivityAttributes.swift */, + B82181F92C936AD600478A91 /* ChartAxisGenerator.swift */, + ); + path = "Live Activity"; + sourceTree = "<group>"; + }; C13072B82A76AF0A009A7C58 /* live_capture */ = { isa = PBXGroup; children = ( @@ -2982,6 +3054,7 @@ 14B1735828AED9EC006CCD7C /* Sources */, 14B1735928AED9EC006CCD7C /* Frameworks */, 14B1735A28AED9EC006CCD7C /* Resources */, + B82181FF2C9370F800478A91 /* Embed Frameworks */, ); buildRules = ( ); @@ -2989,6 +3062,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 +3698,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 +3717,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 +3806,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 +3814,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 +3869,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 +3902,13 @@ A97F250825E056D500F0EE19 /* OnboardingManager.swift in Sources */, 438D42F91D7C88BC003244B0 /* PredictionInputEffect.swift in Sources */, 892A5D692230C41D008961AB /* RangeReplaceableCollection.swift in Sources */, + B8A937B82C29BA5900E38645 /* LiveActivityManager.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 +3921,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 +4043,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 +4065,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 */, @@ -4993,7 +5082,6 @@ GCC_WARN_UNUSED_LABEL = YES; GCC_WARN_UNUSED_PARAMETER = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 15.1; LOCALIZED_STRING_MACRO_NAMES = ( NSLocalizedString, CFLocalizedString, @@ -5103,7 +5191,6 @@ GCC_WARN_UNUSED_LABEL = YES; GCC_WARN_UNUSED_PARAMETER = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 15.1; LOCALIZED_STRING_MACRO_NAMES = ( NSLocalizedString, CFLocalizedString, 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 @@ <string>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.</string> <key>NSSiriUsageDescription</key> <string>Loop uses Siri to allow you to enact presets with your voice.</string> + <key>NSSupportsLiveActivities</key> + <true/> + <key>NSSupportsLiveActivitiesFrequentUpdates</key> + <true/> <key>NSUserActivityTypes</key> <array> <string>EnableOverridePresetIntent</string> 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 @@ <true/> <key>com.apple.developer.healthkit.access</key> <array/> + <key>com.apple.developer.healthkit.background-delivery</key> + <true/> <key>com.apple.developer.nfc.readersession.formats</key> <array> <string>TAG</string> 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/LiveActivityManager.swift b/Loop/Managers/Live Activity/LiveActivityManager.swift new file mode 100644 index 0000000000..30c6920fae --- /dev/null +++ b/Loop/Managers/Live Activity/LiveActivityManager.swift @@ -0,0 +1,516 @@ +// +// 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 LiveActivityManager { + private let activityInfo = ActivityAuthorizationInfo() + private var activity: Activity<GlucoseActivityAttributes>? + 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 + await endActivity() + } + + guard let unit = await self.healthStore.cachedPreferredUnits(for: .bloodGlucose) else { + print("ERROR: No unit found...") + return + } + + let isMmol = unit == HKUnit.millimolesPerLiter + 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: isMmol, + 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<GlucoseActivityAttributes>.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<GlucoseActivityAttributes>.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..36c228c738 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: LiveActivityManager? deinit { for observer in notificationObservers { @@ -124,6 +126,12 @@ final class LoopDataManager { self.automaticDosingStatus = automaticDosingStatus self.trustedTimeOffset = trustedTimeOffset + + self.liveActivityManager = LiveActivityManager( + 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..85a45b017f --- /dev/null +++ b/Loop/Views/LiveActivityBottomRowManagerView.swift @@ -0,0 +1,133 @@ +// +// 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<PresentationMode> + + // The maximum items in the bottom row + private let maxSize = 4 + + @State var showAdd: Bool = false + @State var configuration: [BottomRowConfiguration] + @State private var previousConfiguration: [BottomRowConfiguration] + @State private var isDirty = false + + init() { + configuration = (UserDefaults.standard.liveActivity ?? LiveActivitySettings()).bottomRowConfiguration + previousConfiguration = (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) + + isDirty = configuration != previousConfiguration + } + } + 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) + isDirty = configuration != previousConfiguration + } + Text(item.wrappedValue.description()) + + Spacer() + editBars + } + } + .onMove(perform: onReorder) + .deleteDisabled(true) + + Section { + Button(action: onSave) { + Text(NSLocalizedString("Save", comment: "")) + } + .disabled(!isDirty) + .buttonStyle(ActionButtonStyle()) + .listRowInsets(EdgeInsets()) + } + } + .onAppear { + configuration = (UserDefaults.standard.liveActivity ?? LiveActivitySettings()).bottomRowConfiguration + previousConfiguration = (UserDefaults.standard.liveActivity ?? LiveActivitySettings()).bottomRowConfiguration + } + .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) + isDirty = configuration != previousConfiguration + } + } + + 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..bdf87dc553 --- /dev/null +++ b/Loop/Views/LiveActivityManagementView.swift @@ -0,0 +1,140 @@ +// +// 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() + @State private var previousViewModel = LiveActivityManagementViewModel() + + @State private var isDirty = false + + var body: some View { + VStack { + List { + Section { + Toggle(NSLocalizedString("Enabled", comment: "Title for enable live activity toggle"), isOn: $viewModel.enabled) + .onChange(of: viewModel.enabled) { _ in + self.isDirty = previousViewModel.enabled != 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() }) + } + ) + .onChange(of: viewModel.mode) { _ in + self.isDirty = previousViewModel.mode != viewModel.mode + } + } + + 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)) + .onChange(of: viewModel.addPredictiveLine) { _ in + self.isDirty = previousViewModel.addPredictiveLine != viewModel.addPredictiveLine + } + } + + Toggle(NSLocalizedString("Use BG coloring", comment: "Title for BG coloring"), isOn: $viewModel.useLimits) + .transition(.move(edge: viewModel.mode == .large ? .top : .bottom)) + .onChange(of: viewModel.useLimits) { _ in + self.isDirty = previousViewModel.useLimits != viewModel.useLimits + } + + if self.displayGlucosePreference.unit == .millimolesPerLiter { + TextInput(label: "Upper limit", value: $viewModel.upperLimitChartMmol) + .transition(.move(edge: viewModel.useLimits ? .top : .bottom)) + .onChange(of: viewModel.upperLimitChartMmol) { _ in + self.isDirty = previousViewModel.upperLimitChartMmol != viewModel.upperLimitChartMmol + } + TextInput(label: "Lower limit", value: $viewModel.lowerLimitChartMmol) + .transition(.move(edge: viewModel.useLimits ? .top : .bottom)) + .onChange(of: viewModel.lowerLimitChartMmol) { _ in + self.isDirty = previousViewModel.lowerLimitChartMmol != viewModel.lowerLimitChartMmol + } + } else { + TextInput(label: "Upper limit", value: $viewModel.upperLimitChartMg) + .transition(.move(edge: viewModel.useLimits ? .top : .bottom)) + .onChange(of: viewModel.upperLimitChartMg) { _ in + self.isDirty = previousViewModel.upperLimitChartMg != viewModel.upperLimitChartMg + } + TextInput(label: "Lower limit", value: $viewModel.lowerLimitChartMg) + .transition(.move(edge: viewModel.useLimits ? .top : .bottom)) + .onChange(of: viewModel.lowerLimitChartMg) { _ in + self.isDirty = previousViewModel.lowerLimitChartMg != viewModel.lowerLimitChartMg + } + } + } + + 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()) + .disabled(!isDirty) + .padding([.bottom, .horizontal]) + } + .navigationBarTitle(Text(NSLocalizedString("Live activity", comment: "Live activity screen title"))) + } + + @ViewBuilder + private func TextInput(label: String, value: Binding<Double>) -> 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) + + self.isDirty = false + previousViewModel = LiveActivityManagementViewModel() + } +} diff --git a/LoopCore/LiveActivitySettings.swift b/LoopCore/LiveActivitySettings.swift new file mode 100644 index 0000000000..d0f892f7e8 --- /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.decode(Bool.self, forKey: .useLimits) + 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 + } +} 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")