diff --git a/live_activity/archive/live_activity_05_87602ff.patch b/live_activity/archive/live_activity_05_87602ff.patch new file mode 100644 index 0000000..14679d8 --- /dev/null +++ b/live_activity/archive/live_activity_05_87602ff.patch @@ -0,0 +1,2122 @@ +diff --git a/Loop/Loop Widget Extension/Bootstrap/Bootstrap.swift b/Loop/Loop Widget Extension/Bootstrap/Bootstrap.swift +new file mode 100644 +index 00000000..00823471 +--- /dev/null ++++ b/Loop/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/Loop Widget Extension/Helpers/LocalizedString.swift b/Loop/Loop Widget Extension/Helpers/LocalizedString.swift +new file mode 100644 +index 00000000..15818175 +--- /dev/null ++++ b/Loop/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/Loop Widget Extension/Live Activity/BasalViewActivity.swift b/Loop/Loop Widget Extension/Live Activity/BasalViewActivity.swift +new file mode 100644 +index 00000000..915335c5 +--- /dev/null ++++ b/Loop/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/Loop Widget Extension/Live Activity/ChartView.swift b/Loop/Loop Widget Extension/Live Activity/ChartView.swift +new file mode 100644 +index 00000000..237ddebd +--- /dev/null ++++ b/Loop/Loop Widget Extension/Live Activity/ChartView.swift +@@ -0,0 +1,153 @@ ++// ++// 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? ++ ++ init(glucoseSamples: [GlucoseSampleAttributes], predicatedGlucose: [Double], predicatedStartDate: Date?, predicatedInterval: TimeInterval?, useLimits: Bool, lowerLimit: Double, upperLimit: Double, glucoseRanges: [GlucoseRangeValue], preset: Preset?) { ++ self.glucoseSampleData = ChartValues.convert(data: glucoseSamples, 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 ++ } ++ ++ init(glucoseSamples: [GlucoseSampleAttributes], useLimits: Bool, lowerLimit: Double, upperLimit: Double, glucoseRanges: [GlucoseRangeValue], preset: Preset?) { ++ self.glucoseSampleData = ChartValues.convert(data: glucoseSamples, useLimits: useLimits, lowerLimit: lowerLimit, upperLimit: upperLimit) ++ self.predicatedData = [] ++ self.preset = preset ++ self.glucoseRanges = glucoseRanges ++ } ++ ++ 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: .automatic(includesZero: false)) ++ .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/Loop Widget Extension/Live Activity/GlucoseLiveActivityConfiguration.swift b/Loop/Loop Widget Extension/Live Activity/GlucoseLiveActivityConfiguration.swift +new file mode 100644 +index 00000000..2baeb90b +--- /dev/null ++++ b/Loop/Loop Widget Extension/Live Activity/GlucoseLiveActivityConfiguration.swift +@@ -0,0 +1,290 @@ ++// ++// 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 ++ ) ++ .frame(height: 85) ++ } else { ++ ChartView( ++ glucoseSamples: context.state.glucoseSamples, ++ useLimits: context.attributes.useLimits, ++ lowerLimit: context.state.isMmol ? context.attributes.lowerLimitChartMmol : context.attributes.lowerLimitChartMg, ++ upperLimit: context.state.isMmol ? context.attributes.upperLimitChartMmol : context.attributes.upperLimitChartMg, ++ glucoseRanges: context.state.glucoseRanges, ++ preset: context.state.preset ++ ) ++ .frame(height: 85) ++ } ++ } ++ } ++ ++ 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 ++ ) ++ .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 ++ ) ++ .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 { ++ 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/Loop Widget Extension/LoopWidgets.swift b/Loop/Loop Widget Extension/LoopWidgets.swift +index 26f92edb..684bf073 100644 +--- a/Loop/Loop Widget Extension/LoopWidgets.swift ++++ b/Loop/Loop Widget Extension/LoopWidgets.swift +@@ -14,5 +14,6 @@ struct LoopWidgets: WidgetBundle { + @WidgetBundleBuilder + var body: some Widget { + SystemStatusWidget() ++ GlucoseLiveActivityConfiguration() + } + } +diff --git a/Loop/Loop.xcodeproj/project.pbxproj b/Loop/Loop.xcodeproj/project.pbxproj +index 11819516..606155f7 100644 +--- a/Loop/Loop.xcodeproj/project.pbxproj ++++ b/Loop/Loop.xcodeproj/project.pbxproj +@@ -401,6 +401,19 @@ + B4E96D5D248A82A2002DABAD /* StatusBarHUDView.xib in Resources */ = {isa = PBXBuildFile; fileRef = B4E96D5C248A82A2002DABAD /* StatusBarHUDView.xib */; }; + B4F3D25124AF890C0095CE44 /* BluetoothStateManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4F3D25024AF890C0095CE44 /* BluetoothStateManager.swift */; }; + B4FEEF7D24B8A71F00A8DF9B /* DeviceDataManager+DeviceStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4FEEF7C24B8A71F00A8DF9B /* DeviceDataManager+DeviceStatus.swift */; }; ++ B851FFC52C37221800D738C1 /* LiveActivityManagementView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B851FFC42C37221800D738C1 /* LiveActivityManagementView.swift */; }; ++ B851FFCA2C3731DE00D738C1 /* LiveActivitySettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = B851FFC62C37271E00D738C1 /* LiveActivitySettings.swift */; }; ++ B851FFCB2C3731DE00D738C1 /* LiveActivitySettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = B851FFC62C37271E00D738C1 /* LiveActivitySettings.swift */; }; ++ B87539C92C2B06CE0085A975 /* LocalizedString.swift in Sources */ = {isa = PBXBuildFile; fileRef = B87539C82C2B06CE0085A975 /* LocalizedString.swift */; }; ++ B87539CB2C2B08430085A975 /* Bootstrap.swift in Sources */ = {isa = PBXBuildFile; fileRef = B87539CA2C2B08430085A975 /* Bootstrap.swift */; }; ++ B87539CD2C2B46950085A975 /* 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 */; }; +@@ -1325,6 +1338,17 @@ + B4E96D5C248A82A2002DABAD /* StatusBarHUDView.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = StatusBarHUDView.xib; sourceTree = ""; }; + B4F3D25024AF890C0095CE44 /* BluetoothStateManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BluetoothStateManager.swift; sourceTree = ""; }; + B4FEEF7C24B8A71F00A8DF9B /* DeviceDataManager+DeviceStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DeviceDataManager+DeviceStatus.swift"; sourceTree = ""; }; ++ B851FFC42C37221800D738C1 /* LiveActivityManagementView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActivityManagementView.swift; sourceTree = ""; }; ++ B851FFC62C37271E00D738C1 /* LiveActivitySettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActivitySettings.swift; sourceTree = ""; }; ++ B87539C82C2B06CE0085A975 /* LocalizedString.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalizedString.swift; sourceTree = ""; }; ++ B87539CA2C2B08430085A975 /* Bootstrap.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Bootstrap.swift; sourceTree = ""; }; ++ B87539CC2C2B46950085A975 /* 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 +1751,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 +1861,7 @@ + 84AA81D12A4A2778000B658B /* Components */, + 84AA81D92A4A2966000B658B /* Helpers */, + 84AA81DE2A4A2B3D000B658B /* Timeline */, ++ B87D41192C28A61900120877 /* Live Activity */, + 84AA81DF2A4A2B7A000B658B /* Widgets */, + 84AA81D22A4A27A3000B658B /* LoopWidgets.swift */, + ); +@@ -2163,6 +2189,7 @@ + 43DE92581C5479E4001FFDE1 /* PotentialCarbEntryUserInfo.swift */, + 43C05CB721EBEA54006FB252 /* HKUnit.swift */, + 434FF1E91CF26C29000DB779 /* IdentifiableClass.swift */, ++ E9C00EEF24C620EF00628F35 /* LoopSettings.swift */, + C19E96DD23D2733F003F79B0 /* LoopCompletionFreshness.swift */, + 430B29892041F54A00BA9F93 /* NSUserDefaults.swift */, + 431E73471FF95A900069B5F7 /* PersistenceController.swift */, +@@ -2170,10 +2197,10 @@ + 43D9FFD121EAE05D00AF44BF /* LoopCore.h */, + 43D9FFD221EAE05D00AF44BF /* Info.plist */, + 4B60626A287E286000BF8BBB /* Localizable.strings */, +- E9C00EEF24C620EF00628F35 /* LoopSettings.swift */, + C16575742539FD60004AE16E /* LoopCoreConstants.swift */, + E9B3551B292844010076AB04 /* MissedMealNotification.swift */, + C1D0B62F2986D4D90098D215 /* LocalizedString.swift */, ++ B851FFC62C37271E00D738C1 /* LiveActivitySettings.swift */, + ); + path = LoopCore; + sourceTree = ""; +@@ -2276,6 +2303,8 @@ + C1AF062229426300002C1B19 /* ManualGlucoseEntryRow.swift */, + DDC389FD2A2C4C830066E2E8 /* GlucoseBasedApplicationFactorSelectionView.swift */, + DD3DBD282A33AFE9000F8B5B /* IntegralRetrospectiveCorrectionSelectionView.swift */, ++ B851FFC42C37221800D738C1 /* LiveActivityManagementView.swift */, ++ B8FD0B512C39CA3E003FB72B /* LiveActivityBottomRowManagerView.swift */, + ); + path = Views; + sourceTree = ""; +@@ -2315,6 +2344,7 @@ + 1DA6499D2441266400F61E75 /* Alerts */, + E95D37FF24EADE68005E2F50 /* Store Protocols */, + E9B355232935906B0076AB04 /* Missed Meal Detection */, ++ B8A937C52C29C44600E38645 /* Live Activity */, + C1F2075B26D6F9B0007AB7EB /* AppExpirationAlerter.swift */, + A96DAC2B2838F31200D94E38 /* SharedLogging.swift */, + 7E69CFFB2A16A77E00203CBD /* ResetLoopManager.swift */, +@@ -2525,6 +2555,7 @@ + C11613472983096D00777E7C /* InfoPlist.strings */, + 14B1736D28AEDA63006CCD7C /* LoopWidgetExtension.entitlements */, + 14B1736628AED9EE006CCD7C /* Info.plist */, ++ B87539CA2C2B08430085A975 /* Bootstrap.swift */, + ); + path = Bootstrap; + sourceTree = ""; +@@ -2535,6 +2566,7 @@ + 84AA81DA2A4A2973000B658B /* Date.swift */, + 84AA81D52A4A28AF000B658B /* WidgetBackground.swift */, + 84D2879E2AC756C8007ED283 /* ContentMargin.swift */, ++ B87539C82C2B06CE0085A975 /* LocalizedString.swift */, + ); + path = Helpers; + sourceTree = ""; +@@ -2663,6 +2695,7 @@ + 968DCD53F724DE56FFE51920 /* Frameworks */ = { + isa = PBXGroup; + children = ( ++ B87D411E2C28A85F00120877 /* ActivityKit.framework */, + C159C82E286787EF00A86EC0 /* LoopKit.framework */, + C159C8212867859800A86EC0 /* MockKitUI.framework */, + C159C8192867857000A86EC0 /* LoopKitUI.framework */, +@@ -2760,6 +2793,25 @@ + 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 */, ++ ); ++ path = "Live Activity"; ++ sourceTree = ""; ++ }; + C13072B82A76AF0A009A7C58 /* live_capture */ = { + isa = PBXGroup; + children = ( +@@ -3623,12 +3675,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 +3694,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 +3783,7 @@ + B4E202302661063E009421B5 /* AutomaticDosingStatus.swift in Sources */, + C191D2A125B3ACAA00C26C0B /* DosingStrategySelectionView.swift in Sources */, + A977A2F424ACFECF0059C207 /* CriticalEventLogExportManager.swift in Sources */, ++ B8FD0B522C39CA3E003FB72B /* LiveActivityBottomRowManagerView.swift in Sources */, + 89CA2B32226C18B8004D9350 /* TestingScenariosTableViewController.swift in Sources */, + 43E93FB71E469A5100EAB8DB /* HKUnit.swift in Sources */, + 43C05CAF21EB2C24006FB252 /* NSBundle.swift in Sources */, +@@ -3732,6 +3791,7 @@ + A967D94C24F99B9300CDDF8A /* OutputStream.swift in Sources */, + 1DB1065124467E18005542BD /* AlertManager.swift in Sources */, + 1D9650C82523FBA100A1370B /* DeviceDataManager+BolusEntryViewModelDelegate.swift in Sources */, ++ B851FFC52C37221800D738C1 /* LiveActivityManagementView.swift in Sources */, + 43C0944A1CACCC73001F6403 /* NotificationManager.swift in Sources */, + 149A28E42A8A63A700052EDF /* FavoriteFoodDetailView.swift in Sources */, + 1DDE274024AEA4F200796622 /* NotificationsCriticalAlertPermissionsView.swift in Sources */, +@@ -3818,6 +3878,7 @@ + A97F250825E056D500F0EE19 /* OnboardingManager.swift in Sources */, + 438D42F91D7C88BC003244B0 /* PredictionInputEffect.swift in Sources */, + 892A5D692230C41D008961AB /* RangeReplaceableCollection.swift in Sources */, ++ B8A937B82C29BA5900E38645 /* GlucoseActivityManager.swift in Sources */, + DDC389F62A2B61750066E2E8 /* ApplicationFactorStrategy.swift in Sources */, + 4F70C2101DE8FAC5006380B7 /* ExtensionDataManager.swift in Sources */, + 43DFB62320D4CAE7008A7BAE /* PumpManager.swift in Sources */, +@@ -3835,6 +3896,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 +4018,7 @@ + C17DDC9D28AC33A1005FBF4C /* PersistedProperty.swift in Sources */, + 43C05CA921EB2B26006FB252 /* PersistenceController.swift in Sources */, + A9CE912224CA032E00302A40 /* NSUserDefaults.swift in Sources */, ++ B851FFCB2C3731DE00D738C1 /* LiveActivitySettings.swift in Sources */, + 43C05CAB21EB2B4A006FB252 /* NSBundle.swift in Sources */, + 43C05CC721EC2ABC006FB252 /* IdentifiableClass.swift in Sources */, + E9B3552B293591E70076AB04 /* MissedMealNotification.swift in Sources */, +@@ -3977,6 +4040,7 @@ + C17DDC9C28AC339E005FBF4C /* PersistedProperty.swift in Sources */, + 43C05CA821EB2B26006FB252 /* PersistenceController.swift in Sources */, + 43C05CAA21EB2B49006FB252 /* NSBundle.swift in Sources */, ++ B851FFCA2C3731DE00D738C1 /* LiveActivitySettings.swift in Sources */, + 43C05CC821EC2ABC006FB252 /* IdentifiableClass.swift in Sources */, + 43C05CAD21EB2BBF006FB252 /* NSUserDefaults.swift in Sources */, + E9B3552A293591E70076AB04 /* MissedMealNotification.swift in Sources */, +@@ -4832,6 +4896,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 +4945,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 +5204,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 +5234,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 +5491,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 +5518,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 +5586,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 +5614,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/Loop/Info.plist b/Loop/Loop/Info.plist +index ddad5426..f9f4ca84 100644 +--- a/Loop/Loop/Info.plist ++++ b/Loop/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/Loop.entitlements b/Loop/Loop/Loop.entitlements +index 50ba55d9..e6a2f9b9 100644 +--- a/Loop/Loop/Loop.entitlements ++++ b/Loop/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/Loop/Managers/Live Activity/GlucoseActivityAttributes.swift b/Loop/Loop/Managers/Live Activity/GlucoseActivityAttributes.swift +new file mode 100644 +index 00000000..4efa8b76 +--- /dev/null ++++ b/Loop/Loop/Managers/Live Activity/GlucoseActivityAttributes.swift +@@ -0,0 +1,150 @@ ++// ++// 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 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/Loop/Managers/Live Activity/GlucoseActivityManager.swift b/Loop/Loop/Managers/Live Activity/GlucoseActivityManager.swift +new file mode 100644 +index 00000000..79635198 +--- /dev/null ++++ b/Loop/Loop/Managers/Live Activity/GlucoseActivityManager.swift +@@ -0,0 +1,515 @@ ++// ++// 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(Date.now + override.duration.timeInterval, endDateChart), ++ minValue: override.settings.targetRange?.lowerBound.doubleValue(for: unit) ?? 0, ++ maxValue: override.settings.targetRange?.upperBound.doubleValue(for: unit) ?? 0 ++ ) ++ } ++ ++ var glucoseRanges: [GlucoseRangeValue] = [] ++ if let glucoseRangeSchedule = self.loopSettings.glucoseTargetRangeSchedule, let start = glucoseSamples.first?.startDate { ++ glucoseRanges = getGlucoseRanges( ++ glucoseRangeSchedule: glucoseRangeSchedule, ++ presetContext: presetContext, ++ start: start, ++ end: endDateChart, ++ unit: unit ++ ) ++ } ++ ++ 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 ++ ) ++ ++ 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.mode != self.settings.mode || ++ newSettings.addPredictiveLine != self.settings.addPredictiveLine || ++ newSettings.useLimits != self.settings.useLimits || ++ newSettings.lowerLimitChartMmol != self.settings.lowerLimitChartMmol || ++ newSettings.upperLimitChartMmol != self.settings.upperLimitChartMmol || ++ newSettings.lowerLimitChartMg != self.settings.lowerLimitChartMg || ++ newSettings.upperLimitChartMg != self.settings.upperLimitChartMg ++ { ++ await self.activity?.end(nil, dismissalPolicy: .immediate) ++ self.activity = nil ++ ++ initEmptyActivity(settings: newSettings) ++ } ++ ++ self.settings = newSettings ++ update() ++ } ++ } ++ ++ @objc private func appMovedToForeground() { ++ guard let activity = self.activity else { ++ print("ERROR: appMovedToForeground: No Live activity found...") ++ 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 ++ ) ++ ++ 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/Loop/Managers/LoopDataManager.swift b/Loop/Loop/Managers/LoopDataManager.swift +index 2319f4ec..8a728813 100644 +--- a/Loop/Loop/Managers/LoopDataManager.swift ++++ b/Loop/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/Loop/Views/AlertManagementView.swift b/Loop/Loop/Views/AlertManagementView.swift +index e9a38e72..94e542a6 100644 +--- a/Loop/Loop/Views/AlertManagementView.swift ++++ b/Loop/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/Loop/Views/LiveActivityBottomRowManagerView.swift b/Loop/Loop/Views/LiveActivityBottomRowManagerView.swift +new file mode 100644 +index 00000000..49e50caa +--- /dev/null ++++ b/Loop/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/Loop/Views/LiveActivityManagementView.swift b/Loop/Loop/Views/LiveActivityManagementView.swift +new file mode 100644 +index 00000000..0a691a99 +--- /dev/null ++++ b/Loop/Loop/Views/LiveActivityManagementView.swift +@@ -0,0 +1,137 @@ ++// ++// 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 ++ ++ @State private var enabled: Bool ++ @State private var mode: LiveActivityMode ++ @State var isEditingMode = false ++ @State private var addPredictiveLine: Bool ++ @State private var useLimits: Bool ++ @State private var upperLimitMmol: Double ++ @State private var lowerLimitMmol: Double ++ @State private var upperLimitMg: Double ++ @State private var lowerLimitMg: Double ++ ++ init() { ++ let liveActivitySettings = UserDefaults.standard.liveActivity ?? LiveActivitySettings() ++ ++ self.enabled = liveActivitySettings.enabled ++ self.mode = liveActivitySettings.mode ++ self.addPredictiveLine = liveActivitySettings.addPredictiveLine ++ self.useLimits = liveActivitySettings.useLimits ++ self.upperLimitMmol = liveActivitySettings.upperLimitChartMmol ++ self.lowerLimitMmol = liveActivitySettings.lowerLimitChartMmol ++ self.upperLimitMg = liveActivitySettings.upperLimitChartMg ++ self.lowerLimitMg = liveActivitySettings.lowerLimitChartMg ++ } ++ ++ var body: some View { ++ List { ++ Section { ++ Toggle(NSLocalizedString("Enabled", comment: "Title for enable live activity toggle"), isOn: $enabled) ++ .onChange(of: enabled) { newValue in ++ self.mutate { settings in ++ settings.enabled = newValue ++ } ++ } ++ ++ ExpandableSetting( ++ isEditing: $isEditingMode, ++ leadingValueContent: { ++ Text(NSLocalizedString("Mode", comment: "Title for mode live activity toggle")) ++ .foregroundStyle(isEditingMode ? .blue : .primary) ++ }, ++ trailingValueContent: { ++ Text(self.mode.name()) ++ .foregroundStyle(isEditingMode ? .blue : .primary) ++ }, ++ expandedContent: { ++ ResizeablePicker(selection: self.$mode.animation(), ++ data: LiveActivityMode.all, ++ formatter: { $0.name() }) ++ } ++ ) ++ .onChange(of: self.mode) { newValue in ++ self.mutate { settings in ++ settings.mode = newValue ++ } ++ } ++ } ++ ++ if mode == .large { ++ Section { ++ Toggle(NSLocalizedString("Add predictive line", comment: "Title for predictive line toggle"), isOn: $addPredictiveLine) ++ .transition(.move(edge: mode == .large ? .top : .bottom)) ++ .onChange(of: addPredictiveLine) { newValue in ++ self.mutate { settings in ++ settings.addPredictiveLine = newValue ++ } ++ } ++ Toggle(NSLocalizedString("Use BG coloring", comment: "Title for BG coloring"), isOn: $useLimits) ++ .transition(.move(edge: mode == .large ? .top : .bottom)) ++ .onChange(of: useLimits) { newValue in ++ self.mutate { settings in ++ settings.useLimits = newValue ++ } ++ } ++ ++ if useLimits { ++ if self.displayGlucosePreference.unit == .millimolesPerLiter { ++ TextInput(label: "Upper limit chart", value: $upperLimitMmol) ++ .transition(.move(edge: useLimits ? .top : .bottom)) ++ TextInput(label: "Lower limit chart", value: $lowerLimitMmol) ++ .transition(.move(edge: useLimits ? .top : .bottom)) ++ } else { ++ TextInput(label: "Upper limit chart", value: $upperLimitMg) ++ .transition(.move(edge: useLimits ? .top : .bottom)) ++ TextInput(label: "Lower limit chart", value: $lowerLimitMg) ++ .transition(.move(edge: useLimits ? .top : .bottom)) ++ } ++ } ++ } ++ } ++ ++ Section { ++ NavigationLink( ++ destination: LiveActivityBottomRowManagerView(), ++ label: { Text(NSLocalizedString("Bottom row configuration", comment: "Title for Bottom row configuration")) } ++ ) ++ } ++ } ++ .animation(.easeInOut, value: UUID()) ++ .insetGroupedListStyle() ++ .navigationBarTitle(Text(NSLocalizedString("Live activity", comment: "Live activity screen title"))) ++ } ++ ++ @ViewBuilder ++ private func TextInput(label: String, value: Binding) -> some View { ++ HStack { ++ Text(NSLocalizedString(label, comment: "no comment")) ++ Spacer() ++ TextField("", value: value, format: .number) ++ .multilineTextAlignment(.trailing) ++ Text(self.displayGlucosePreference.unit.localizedShortUnitString) ++ } ++ } ++ ++ private func mutate(_ updater: (inout LiveActivitySettings) -> Void) { ++ var settings = UserDefaults.standard.liveActivity ?? LiveActivitySettings() ++ ++ updater(&settings) ++ ++ UserDefaults.standard.liveActivity = settings ++ NotificationCenter.default.post(name: .LiveActivitySettingsChanged, object: settings) ++ } ++} +diff --git a/Loop/LoopCore/LiveActivitySettings.swift b/Loop/LoopCore/LiveActivitySettings.swift +new file mode 100644 +index 00000000..02a3fbb2 +--- /dev/null ++++ b/Loop/LoopCore/LiveActivitySettings.swift +@@ -0,0 +1,138 @@ ++// ++// 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 { ++ 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) ++ enabled = try values.decode(Bool.self, forKey: .enabled) ++ mode = try values.decodeIfPresent(LiveActivityMode.self, forKey: .mode) ?? .large ++ addPredictiveLine = try values.decode(Bool.self, forKey: .addPredictiveLine) ++ useLimits = try values.decodeIfPresent(Bool.self, forKey: .useLimits) ?? true ++ upperLimitChartMmol = try values.decode(Double?.self, forKey: .upperLimitChartMmol) ?? LiveActivitySettings.defaultUpperLimitMmol ++ lowerLimitChartMmol = try values.decode(Double?.self, forKey: .lowerLimitChartMmol) ?? LiveActivitySettings.defaultLowerLimitMmol ++ upperLimitChartMg = try values.decode(Double?.self, forKey: .upperLimitChartMg) ?? LiveActivitySettings.defaultUpperLimitMg ++ lowerLimitChartMg = try values.decode(Double?.self, forKey: .lowerLimitChartMg) ?? LiveActivitySettings.defaultLowerLimitMg ++ bottomRowConfiguration = try values.decode([BottomRowConfiguration].self, forKey: .bottomRowConfiguration) ++ } ++ ++ 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 ++ } ++} +diff --git a/Loop/LoopCore/NSUserDefaults.swift b/Loop/LoopCore/NSUserDefaults.swift +index 93fa7e17..dacf2ecd 100644 +--- a/Loop/LoopCore/NSUserDefaults.swift ++++ b/Loop/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") diff --git a/live_activity/live_activity.patch b/live_activity/live_activity.patch index 14679d8..846f71c 100644 --- a/live_activity/live_activity.patch +++ b/live_activity/live_activity.patch @@ -1,3 +1,4 @@ +Submodule Loop 1aaee2d..92fb65f: diff --git a/Loop/Loop Widget Extension/Bootstrap/Bootstrap.swift b/Loop/Loop Widget Extension/Bootstrap/Bootstrap.swift new file mode 100644 index 00000000..00823471 @@ -255,10 +256,10 @@ index 00000000..237ddebd +} diff --git a/Loop/Loop Widget Extension/Live Activity/GlucoseLiveActivityConfiguration.swift b/Loop/Loop Widget Extension/Live Activity/GlucoseLiveActivityConfiguration.swift new file mode 100644 -index 00000000..2baeb90b +index 00000000..eb70f560 --- /dev/null +++ b/Loop/Loop Widget Extension/Live Activity/GlucoseLiveActivityConfiguration.swift -@@ -0,0 +1,290 @@ +@@ -0,0 +1,294 @@ +// +// LiveActivityConfiguration.swift +// Loop Widget Extension @@ -532,6 +533,10 @@ index 00000000..2baeb90b + } + + 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 @@ -1030,10 +1035,10 @@ index 00000000..4efa8b76 +} diff --git a/Loop/Loop/Managers/Live Activity/GlucoseActivityManager.swift b/Loop/Loop/Managers/Live Activity/GlucoseActivityManager.swift new file mode 100644 -index 00000000..79635198 +index 00000000..750dba00 --- /dev/null +++ b/Loop/Loop/Managers/Live Activity/GlucoseActivityManager.swift -@@ -0,0 +1,515 @@ +@@ -0,0 +1,512 @@ +// +// LiveActivityManaer.swift +// Loop @@ -1185,7 +1190,7 @@ index 00000000..79635198 + presetContext = Preset( + title: override.getTitle(), + startDate: max(override.startDate, start), -+ endDate: override.duration.isInfinite ? endDateChart : min(Date.now + override.duration.timeInterval, endDateChart), ++ endDate: override.duration.isInfinite ? endDateChart : min(override.actualEndDate, endDateChart), + minValue: override.settings.targetRange?.lowerBound.doubleValue(for: unit) ?? 0, + maxValue: override.settings.targetRange?.upperBound.doubleValue(for: unit) ?? 0 + ) @@ -1244,15 +1249,7 @@ index 00000000..79635198 + } else if newSettings.enabled && self.activity == nil { + initEmptyActivity(settings: newSettings) + -+ } else if -+ newSettings.mode != self.settings.mode || -+ newSettings.addPredictiveLine != self.settings.addPredictiveLine || -+ newSettings.useLimits != self.settings.useLimits || -+ newSettings.lowerLimitChartMmol != self.settings.lowerLimitChartMmol || -+ newSettings.upperLimitChartMmol != self.settings.upperLimitChartMmol || -+ newSettings.lowerLimitChartMg != self.settings.lowerLimitChartMg || -+ newSettings.upperLimitChartMg != self.settings.upperLimitChartMg -+ { ++ } else if newSettings != self.settings { + await self.activity?.end(nil, dismissalPolicy: .immediate) + self.activity = nil + @@ -1265,8 +1262,13 @@ index 00000000..79635198 + } + + @objc private func appMovedToForeground() { ++ guard self.settings.enabled else { ++ return ++ } ++ + guard let activity = self.activity else { -+ print("ERROR: appMovedToForeground: No Live activity found...") ++ initEmptyActivity(settings: self.settings) ++ update() + return + } + @@ -1549,8 +1551,9 @@ index 00000000..79635198 + } + } +} +\ No newline at end of file diff --git a/Loop/Loop/Managers/LoopDataManager.swift b/Loop/Loop/Managers/LoopDataManager.swift -index 2319f4ec..8a728813 100644 +index 2319f4ec..c5220cb0 100644 --- a/Loop/Loop/Managers/LoopDataManager.swift +++ b/Loop/Loop/Managers/LoopDataManager.swift @@ -68,6 +68,8 @@ final class LoopDataManager { @@ -1793,10 +1796,10 @@ index 00000000..49e50caa +} diff --git a/Loop/Loop/Views/LiveActivityManagementView.swift b/Loop/Loop/Views/LiveActivityManagementView.swift new file mode 100644 -index 00000000..0a691a99 +index 00000000..9482bfd0 --- /dev/null +++ b/Loop/Loop/Views/LiveActivityManagementView.swift -@@ -0,0 +1,137 @@ +@@ -0,0 +1,135 @@ +// +// LiveActivityManagementView.swift +// Loop @@ -1818,10 +1821,10 @@ index 00000000..0a691a99 + @State var isEditingMode = false + @State private var addPredictiveLine: Bool + @State private var useLimits: Bool -+ @State private var upperLimitMmol: Double -+ @State private var lowerLimitMmol: Double -+ @State private var upperLimitMg: Double -+ @State private var lowerLimitMg: Double ++ @State private var upperLimitChartMmol: Double ++ @State private var lowerLimitChartMmol: Double ++ @State private var upperLimitChartMg: Double ++ @State private var lowerLimitChartMg: Double + + init() { + let liveActivitySettings = UserDefaults.standard.liveActivity ?? LiveActivitySettings() @@ -1830,87 +1833,79 @@ index 00000000..0a691a99 + self.mode = liveActivitySettings.mode + self.addPredictiveLine = liveActivitySettings.addPredictiveLine + self.useLimits = liveActivitySettings.useLimits -+ self.upperLimitMmol = liveActivitySettings.upperLimitChartMmol -+ self.lowerLimitMmol = liveActivitySettings.lowerLimitChartMmol -+ self.upperLimitMg = liveActivitySettings.upperLimitChartMg -+ self.lowerLimitMg = liveActivitySettings.lowerLimitChartMg ++ self.upperLimitChartMmol = liveActivitySettings.upperLimitChartMmol ++ self.lowerLimitChartMmol = liveActivitySettings.lowerLimitChartMmol ++ self.upperLimitChartMg = liveActivitySettings.upperLimitChartMg ++ self.lowerLimitChartMg = liveActivitySettings.lowerLimitChartMg + } + + var body: some View { -+ List { -+ Section { -+ Toggle(NSLocalizedString("Enabled", comment: "Title for enable live activity toggle"), isOn: $enabled) -+ .onChange(of: enabled) { newValue in -+ self.mutate { settings in -+ settings.enabled = newValue ++ VStack { ++ List { ++ Section { ++ Toggle(NSLocalizedString("Enabled", comment: "Title for enable live activity toggle"), isOn: $enabled) ++ ++ ExpandableSetting( ++ isEditing: $isEditingMode, ++ leadingValueContent: { ++ Text(NSLocalizedString("Mode", comment: "Title for mode live activity toggle")) ++ .foregroundStyle(isEditingMode ? .blue : .primary) ++ }, ++ trailingValueContent: { ++ Text(self.mode.name()) ++ .foregroundStyle(isEditingMode ? .blue : .primary) ++ }, ++ expandedContent: { ++ ResizeablePicker(selection: self.$mode.animation(), ++ data: LiveActivityMode.all, ++ formatter: { $0.name() }) + } -+ } -+ -+ ExpandableSetting( -+ isEditing: $isEditingMode, -+ leadingValueContent: { -+ Text(NSLocalizedString("Mode", comment: "Title for mode live activity toggle")) -+ .foregroundStyle(isEditingMode ? .blue : .primary) -+ }, -+ trailingValueContent: { -+ Text(self.mode.name()) -+ .foregroundStyle(isEditingMode ? .blue : .primary) -+ }, -+ expandedContent: { -+ ResizeablePicker(selection: self.$mode.animation(), -+ data: LiveActivityMode.all, -+ formatter: { $0.name() }) -+ } -+ ) -+ .onChange(of: self.mode) { newValue in -+ self.mutate { settings in -+ settings.mode = newValue -+ } ++ ) + } -+ } -+ -+ if mode == .large { ++ + Section { -+ Toggle(NSLocalizedString("Add predictive line", comment: "Title for predictive line toggle"), isOn: $addPredictiveLine) -+ .transition(.move(edge: mode == .large ? .top : .bottom)) -+ .onChange(of: addPredictiveLine) { newValue in -+ self.mutate { settings in -+ settings.addPredictiveLine = newValue -+ } -+ } ++ if mode == .large { ++ Toggle(NSLocalizedString("Add predictive line", comment: "Title for predictive line toggle"), isOn: $addPredictiveLine) ++ .transition(.move(edge: mode == .large ? .top : .bottom)) ++ } ++ + Toggle(NSLocalizedString("Use BG coloring", comment: "Title for BG coloring"), isOn: $useLimits) + .transition(.move(edge: mode == .large ? .top : .bottom)) -+ .onChange(of: useLimits) { newValue in -+ self.mutate { settings in -+ settings.useLimits = newValue -+ } -+ } + + if useLimits { + if self.displayGlucosePreference.unit == .millimolesPerLiter { -+ TextInput(label: "Upper limit chart", value: $upperLimitMmol) ++ TextInput(label: "Upper limit", value: $upperLimitChartMmol) + .transition(.move(edge: useLimits ? .top : .bottom)) -+ TextInput(label: "Lower limit chart", value: $lowerLimitMmol) ++ TextInput(label: "Lower limit", value: $lowerLimitChartMmol) + .transition(.move(edge: useLimits ? .top : .bottom)) + } else { -+ TextInput(label: "Upper limit chart", value: $upperLimitMg) ++ TextInput(label: "Upper limit", value: $upperLimitChartMg) + .transition(.move(edge: useLimits ? .top : .bottom)) -+ TextInput(label: "Lower limit chart", value: $lowerLimitMg) ++ TextInput(label: "Lower limit", value: $lowerLimitChartMg) + .transition(.move(edge: useLimits ? .top : .bottom)) + } + } + } -+ } + -+ Section { -+ NavigationLink( -+ destination: LiveActivityBottomRowManagerView(), -+ label: { Text(NSLocalizedString("Bottom row configuration", comment: "Title for Bottom row configuration")) } -+ ) ++ Section { ++ NavigationLink( ++ destination: LiveActivityBottomRowManagerView(), ++ label: { Text(NSLocalizedString("Bottom row configuration", comment: "Title for Bottom row configuration")) } ++ ) ++ } ++ ++ + } -+ } + .animation(.easeInOut, value: UUID()) + .insetGroupedListStyle() ++ ++ Spacer() ++ Button(action: save) { ++ Text(NSLocalizedString("Save", comment: "")) ++ } ++ .buttonStyle(ActionButtonStyle()) ++ .padding([.bottom, .horizontal]) ++ } + .navigationBarTitle(Text(NSLocalizedString("Live activity", comment: "Live activity screen title"))) + } + @@ -1925,10 +1920,16 @@ index 00000000..0a691a99 + } + } + -+ private func mutate(_ updater: (inout LiveActivitySettings) -> Void) { ++ private func save() { + var settings = UserDefaults.standard.liveActivity ?? LiveActivitySettings() -+ -+ updater(&settings) ++ settings.enabled = self.enabled ++ settings.mode = self.mode ++ settings.addPredictiveLine = self.addPredictiveLine ++ settings.useLimits = self.useLimits ++ settings.upperLimitChartMmol = self.upperLimitChartMmol ++ settings.lowerLimitChartMmol = self.lowerLimitChartMmol ++ settings.upperLimitChartMg = self.upperLimitChartMg ++ settings.lowerLimitChartMg = self.lowerLimitChartMg + + UserDefaults.standard.liveActivity = settings + NotificationCenter.default.post(name: .LiveActivitySettingsChanged, object: settings) @@ -1936,10 +1937,10 @@ index 00000000..0a691a99 +} diff --git a/Loop/LoopCore/LiveActivitySettings.swift b/Loop/LoopCore/LiveActivitySettings.swift new file mode 100644 -index 00000000..02a3fbb2 +index 00000000..71807464 --- /dev/null +++ b/Loop/LoopCore/LiveActivitySettings.swift -@@ -0,0 +1,138 @@ +@@ -0,0 +1,159 @@ +// +// LiveActivitySettings.swift +// LoopCore @@ -2025,7 +2026,7 @@ index 00000000..02a3fbb2 + } +} + -+public struct LiveActivitySettings: Codable { ++public struct LiveActivitySettings: Codable, Equatable { + public var enabled: Bool + public var mode: LiveActivityMode + public var addPredictiveLine: Bool @@ -2055,15 +2056,16 @@ index 00000000..02a3fbb2 + + public init(from decoder:Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) -+ enabled = try values.decode(Bool.self, forKey: .enabled) -+ mode = try values.decodeIfPresent(LiveActivityMode.self, forKey: .mode) ?? .large -+ addPredictiveLine = try values.decode(Bool.self, forKey: .addPredictiveLine) -+ useLimits = try values.decodeIfPresent(Bool.self, forKey: .useLimits) ?? true -+ upperLimitChartMmol = try values.decode(Double?.self, forKey: .upperLimitChartMmol) ?? LiveActivitySettings.defaultUpperLimitMmol -+ lowerLimitChartMmol = try values.decode(Double?.self, forKey: .lowerLimitChartMmol) ?? LiveActivitySettings.defaultLowerLimitMmol -+ upperLimitChartMg = try values.decode(Double?.self, forKey: .upperLimitChartMg) ?? LiveActivitySettings.defaultUpperLimitMg -+ lowerLimitChartMg = try values.decode(Double?.self, forKey: .lowerLimitChartMg) ?? LiveActivitySettings.defaultLowerLimitMg -+ bottomRowConfiguration = try values.decode([BottomRowConfiguration].self, forKey: .bottomRowConfiguration) ++ ++ self.enabled = try values.decode(Bool.self, forKey: .enabled) ++ self.mode = try values.decodeIfPresent(LiveActivityMode.self, forKey: .mode) ?? .large ++ self.addPredictiveLine = try values.decode(Bool.self, forKey: .addPredictiveLine) ++ self.useLimits = try values.decodeIfPresent(Bool.self, forKey: .useLimits) ?? true ++ self.upperLimitChartMmol = try values.decode(Double?.self, forKey: .upperLimitChartMmol) ?? LiveActivitySettings.defaultUpperLimitMmol ++ self.lowerLimitChartMmol = try values.decode(Double?.self, forKey: .lowerLimitChartMmol) ?? LiveActivitySettings.defaultLowerLimitMmol ++ self.upperLimitChartMg = try values.decode(Double?.self, forKey: .upperLimitChartMg) ?? LiveActivitySettings.defaultUpperLimitMg ++ self.lowerLimitChartMg = try values.decode(Double?.self, forKey: .lowerLimitChartMg) ?? LiveActivitySettings.defaultLowerLimitMg ++ self.bottomRowConfiguration = try values.decode([BottomRowConfiguration].self, forKey: .bottomRowConfiguration) + } + + public init() { @@ -2077,7 +2079,28 @@ index 00000000..02a3fbb2 + 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/Loop/LoopCore/NSUserDefaults.swift b/Loop/LoopCore/NSUserDefaults.swift index 93fa7e17..dacf2ecd 100644 --- a/Loop/LoopCore/NSUserDefaults.swift