From 9287a1cd8cc65c1bfb256fda084be6983dcaa615 Mon Sep 17 00:00:00 2001 From: marionbarker Date: Sun, 15 Sep 2024 15:03:17 -0700 Subject: [PATCH] update la patch to 03d5489 --- .../archive/live_activity_06_92fb65f.patch | 2145 +++++++++++++++++ live_activity/live_activity.patch | 431 +++- 2 files changed, 2475 insertions(+), 101 deletions(-) create mode 100644 live_activity/archive/live_activity_06_92fb65f.patch diff --git a/live_activity/archive/live_activity_06_92fb65f.patch b/live_activity/archive/live_activity_06_92fb65f.patch new file mode 100644 index 0000000..846f71c --- /dev/null +++ b/live_activity/archive/live_activity_06_92fb65f.patch @@ -0,0 +1,2145 @@ +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 +--- /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..eb70f560 +--- /dev/null ++++ b/Loop/Loop Widget Extension/Live Activity/GlucoseLiveActivityConfiguration.swift +@@ -0,0 +1,294 @@ ++// ++// 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 { ++ 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/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..750dba00 +--- /dev/null ++++ b/Loop/Loop/Managers/Live Activity/GlucoseActivityManager.swift +@@ -0,0 +1,512 @@ ++// ++// LiveActivityManaer.swift ++// Loop ++// ++// Created by Bastiaan Verhaar on 24/06/2024. ++// Copyright © 2024 LoopKit Authors. All rights reserved. ++// ++ ++import LoopKitUI ++import LoopKit ++import LoopCore ++import Foundation ++import HealthKit ++import ActivityKit ++ ++extension Notification.Name { ++ static let LiveActivitySettingsChanged = Notification.Name(rawValue: "com.loopKit.notification.LiveActivitySettingsChanged") ++} ++ ++@available(iOS 16.2, *) ++class GlucoseActivityManager { ++ private let activityInfo = ActivityAuthorizationInfo() ++ private var activity: Activity? ++ private let healthStore = HKHealthStore() ++ ++ private let glucoseStore: GlucoseStoreProtocol ++ private let doseStore: DoseStoreProtocol ++ private var loopSettings: LoopSettings ++ ++ private var startDate: Date = Date.now ++ private var settings: LiveActivitySettings = UserDefaults.standard.liveActivity ?? LiveActivitySettings() ++ ++ private let cobFormatter: NumberFormatter = { ++ let numberFormatter = NumberFormatter() ++ numberFormatter.numberStyle = .none ++ return numberFormatter ++ }() ++ private let iobFormatter: NumberFormatter = { ++ let numberFormatter = NumberFormatter() ++ numberFormatter.numberStyle = .none ++ numberFormatter.maximumFractionDigits = 1 ++ numberFormatter.minimumFractionDigits = 1 ++ return numberFormatter ++ }() ++ private let timeFormatter: DateFormatter = { ++ let dateFormatter = DateFormatter() ++ dateFormatter.dateStyle = .none ++ dateFormatter.timeStyle = .short ++ ++ return dateFormatter ++ }() ++ ++ init?(glucoseStore: GlucoseStoreProtocol, doseStore: DoseStoreProtocol, loopSettings: LoopSettings) { ++ guard self.activityInfo.areActivitiesEnabled else { ++ print("ERROR: Live Activities are not enabled...") ++ return nil ++ } ++ ++ self.glucoseStore = glucoseStore ++ self.doseStore = doseStore ++ self.loopSettings = loopSettings ++ ++ // Ensure settings exist ++ if UserDefaults.standard.liveActivity == nil { ++ self.settings = LiveActivitySettings() ++ } ++ ++ let nc = NotificationCenter.default ++ nc.addObserver(self, selector: #selector(self.appMovedToForeground), name: UIApplication.willEnterForegroundNotification, object: nil) ++ nc.addObserver(self, selector: #selector(self.settingsChanged), name: .LiveActivitySettingsChanged, object: nil) ++ guard self.settings.enabled else { ++ return ++ } ++ ++ initEmptyActivity(settings: self.settings) ++ update() ++ ++ Task { ++ await self.endUnknownActivities() ++ } ++ } ++ ++ public func update(loopSettings: LoopSettings) { ++ self.loopSettings = loopSettings ++ update() ++ } ++ ++ private func update() { ++ Task { ++ if self.needsRecreation(), await UIApplication.shared.applicationState == .active { ++ // activity is no longer visible or old. End it and try to push the update again ++ print("INFO: Live Activities needs recreation") ++ await endActivity() ++ update() ++ return ++ } ++ ++ guard let unit = await self.healthStore.cachedPreferredUnits(for: .bloodGlucose) else { ++ print("ERROR: No unit found...") ++ return ++ } ++ ++ await self.endUnknownActivities() ++ ++ let statusContext = UserDefaults.appGroup?.statusExtensionContext ++ let glucoseFormatter = NumberFormatter.glucoseFormatter(for: unit) ++ ++ let glucoseSamples = self.getGlucoseSample(unit: unit) ++ guard let currentGlucose = glucoseSamples.last else { ++ print("ERROR: No glucose sample found...") ++ return ++ } ++ ++ let current = currentGlucose.quantity.doubleValue(for: unit) ++ ++ var delta: String = "+\(glucoseFormatter.string(from: Double(0)) ?? "")" ++ if glucoseSamples.count > 1 { ++ let prevSample = glucoseSamples[glucoseSamples.count - 2] ++ let deltaValue = current - (prevSample.quantity.doubleValue(for: unit)) ++ delta = "\(deltaValue < 0 ? "-" : "+")\(glucoseFormatter.string(from: abs(deltaValue)) ?? "??")" ++ } ++ ++ ++ let bottomRow = self.getBottomRow( ++ currentGlucose: current, ++ delta: delta, ++ statusContext: statusContext, ++ glucoseFormatter: glucoseFormatter ++ ) ++ ++ var predicatedGlucose: [Double] = [] ++ if let samples = statusContext?.predictedGlucose?.values, settings.addPredictiveLine { ++ predicatedGlucose = samples ++ } ++ ++ var endDateChart: Date? = nil ++ if predicatedGlucose.count == 0 { ++ endDateChart = glucoseSamples.last?.startDate ++ } else if let predictedGlucose = statusContext?.predictedGlucose { ++ endDateChart = predictedGlucose.startDate.addingTimeInterval(.hours(4)) ++ } ++ ++ guard let endDateChart = endDateChart else { ++ return ++ } ++ ++ var presetContext: Preset? = nil ++ if let override = self.loopSettings.preMealOverride ?? self.loopSettings.scheduleOverride, let start = glucoseSamples.first?.startDate { ++ presetContext = Preset( ++ title: override.getTitle(), ++ startDate: max(override.startDate, start), ++ endDate: override.duration.isInfinite ? endDateChart : min(override.actualEndDate, endDateChart), ++ minValue: override.settings.targetRange?.lowerBound.doubleValue(for: unit) ?? 0, ++ maxValue: override.settings.targetRange?.upperBound.doubleValue(for: unit) ?? 0 ++ ) ++ } ++ ++ var glucoseRanges: [GlucoseRangeValue] = [] ++ if let glucoseRangeSchedule = self.loopSettings.glucoseTargetRangeSchedule, let start = glucoseSamples.first?.startDate { ++ glucoseRanges = getGlucoseRanges( ++ glucoseRangeSchedule: glucoseRangeSchedule, ++ presetContext: presetContext, ++ start: start, ++ end: endDateChart, ++ unit: unit ++ ) ++ } ++ ++ let 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 != self.settings { ++ await self.activity?.end(nil, dismissalPolicy: .immediate) ++ self.activity = nil ++ ++ initEmptyActivity(settings: newSettings) ++ } ++ ++ self.settings = newSettings ++ update() ++ } ++ } ++ ++ @objc private func appMovedToForeground() { ++ guard self.settings.enabled else { ++ return ++ } ++ ++ guard let activity = self.activity else { ++ initEmptyActivity(settings: self.settings) ++ update() ++ return ++ } ++ ++ Task { ++ await activity.end(nil, dismissalPolicy: .immediate) ++ await self.endUnknownActivities() ++ self.activity = nil ++ ++ initEmptyActivity(settings: self.settings) ++ update() ++ } ++ } ++ ++ private func endUnknownActivities() async { ++ for unknownActivity in Activity.activities ++ .filter({ self.activity?.id != $0.id }) ++ { ++ await unknownActivity.end(nil, dismissalPolicy: .immediate) ++ } ++ } ++ ++ private func endActivity() async { ++ let dynamicState = self.activity?.content.state ++ ++ await self.activity?.end(nil, dismissalPolicy: .immediate) ++ for unknownActivity in Activity.activities { ++ await unknownActivity.end(nil, dismissalPolicy: .immediate) ++ } ++ ++ do { ++ if let dynamicState = dynamicState { ++ self.activity = try Activity.request( ++ attributes: GlucoseActivityAttributes( ++ mode: self.settings.mode, ++ addPredictiveLine: self.settings.addPredictiveLine, ++ useLimits: self.settings.useLimits, ++ upperLimitChartMmol: self.settings.upperLimitChartMmol, ++ lowerLimitChartMmol: self.settings.lowerLimitChartMmol, ++ upperLimitChartMg: self.settings.upperLimitChartMg, ++ lowerLimitChartMg: self.settings.lowerLimitChartMg ++ ), ++ content: .init(state: dynamicState, staleDate: nil), ++ pushType: .token ++ ) ++ } ++ self.startDate = Date.now ++ } catch { ++ print("ERROR: Error while ending live activity: \(error.localizedDescription)") ++ } ++ } ++ ++ private func needsRecreation() -> Bool { ++ if !self.settings.enabled { ++ return false ++ } ++ ++ switch activity?.activityState { ++ case .dismissed, ++ .ended, ++ .stale: ++ return true ++ case .active: ++ return -startDate.timeIntervalSinceNow > .hours(1) ++ default: ++ return true ++ } ++ } ++ ++ private func getInsulinOnBoard() -> String { ++ let updateGroup = DispatchGroup() ++ var iob = "??" ++ ++ updateGroup.enter() ++ self.doseStore.insulinOnBoard(at: Date.now) { result in ++ switch (result) { ++ case .failure: ++ break ++ case .success(let iobValue): ++ iob = self.iobFormatter.string(from: iobValue.value) ?? "??" ++ break ++ } ++ ++ updateGroup.leave() ++ } ++ ++ _ = updateGroup.wait(timeout: .distantFuture) ++ return iob ++ } ++ ++ private func getGlucoseSample(unit: HKUnit) -> [StoredGlucoseSample] { ++ let updateGroup = DispatchGroup() ++ var samples: [StoredGlucoseSample] = [] ++ ++ updateGroup.enter() ++ ++ // When in spacious mode, we want to show the predictive line ++ // In compact mode, we only want to show the history ++ let timeInterval: TimeInterval = self.settings.addPredictiveLine ? .hours(-2) : .hours(-6) ++ self.glucoseStore.getGlucoseSamples( ++ start: Date.now.addingTimeInterval(timeInterval), ++ end: Date.now ++ ) { result in ++ switch (result) { ++ case .failure: ++ break ++ case .success(let data): ++ samples = data ++ break ++ } ++ ++ updateGroup.leave() ++ } ++ ++ _ = updateGroup.wait(timeout: .distantFuture) ++ return samples ++ } ++ ++ private func getGlucoseRanges(glucoseRangeSchedule: GlucoseRangeSchedule, presetContext: Preset?, start: Date, end: Date, unit: HKUnit) -> [GlucoseRangeValue] { ++ var glucoseRanges: [GlucoseRangeValue] = [] ++ for item in glucoseRangeSchedule.quantityBetween(start: start, end: end) { ++ let minValue = item.value.lowerBound.doubleValue(for: unit) ++ let maxValue = item.value.upperBound.doubleValue(for: unit) ++ let startDate = max(item.startDate, start) ++ let endDate = min(item.endDate, end) ++ ++ if let presetContext = presetContext { ++ if presetContext.startDate > startDate, presetContext.endDate < endDate { ++ // A preset is active during this schedule ++ glucoseRanges.append(GlucoseRangeValue( ++ id: UUID(), ++ minValue: minValue, ++ maxValue: maxValue, ++ startDate: startDate, ++ endDate: presetContext.startDate ++ )) ++ glucoseRanges.append(GlucoseRangeValue( ++ id: UUID(), ++ minValue: minValue, ++ maxValue: maxValue, ++ startDate: presetContext.endDate, ++ endDate: endDate ++ )) ++ } else if presetContext.endDate > startDate, presetContext.endDate < endDate { ++ // Cut off the start of the glucose target ++ glucoseRanges.append(GlucoseRangeValue( ++ id: UUID(), ++ minValue: minValue, ++ maxValue: maxValue, ++ startDate: presetContext.endDate, ++ endDate: endDate ++ )) ++ } else if presetContext.startDate < endDate, presetContext.startDate > startDate { ++ // Cut off the end of the glucose target ++ glucoseRanges.append(GlucoseRangeValue( ++ id: UUID(), ++ minValue: minValue, ++ maxValue: maxValue, ++ startDate: startDate, ++ endDate: presetContext.startDate ++ )) ++ if presetContext.endDate == end { ++ break ++ } ++ } else { ++ // No overlap with target and override ++ glucoseRanges.append(GlucoseRangeValue( ++ id: UUID(), ++ minValue: minValue, ++ maxValue: maxValue, ++ startDate: startDate, ++ endDate: endDate ++ )) ++ } ++ } else { ++ glucoseRanges.append(GlucoseRangeValue( ++ id: UUID(), ++ minValue: minValue, ++ maxValue: maxValue, ++ startDate: startDate, ++ endDate: endDate ++ )) ++ } ++ } ++ ++ return glucoseRanges ++ } ++ ++ private func getBottomRow(currentGlucose: Double, delta: String, statusContext: StatusExtensionContext?, glucoseFormatter: NumberFormatter) -> [BottomRowItem] { ++ return self.settings.bottomRowConfiguration.map { type in ++ switch(type) { ++ case .iob: ++ return BottomRowItem.generic(label: type.name(), value: getInsulinOnBoard(), unit: "U") ++ ++ case .cob: ++ var cob: String = "0" ++ if let cobValue = statusContext?.carbsOnBoard { ++ cob = self.cobFormatter.string(from: cobValue) ?? "??" ++ } ++ return BottomRowItem.generic(label: type.name(), value: cob, unit: "g") ++ ++ case .basal: ++ guard let netBasalContext = statusContext?.netBasal else { ++ return BottomRowItem.basal(rate: 0, percentage: 0) ++ } ++ ++ return BottomRowItem.basal(rate: netBasalContext.rate, percentage: netBasalContext.percentage) ++ ++ case .currentBg: ++ return BottomRowItem.currentBg(label: type.name(), value: "\(glucoseFormatter.string(from: currentGlucose) ?? "??")", trend: statusContext?.glucoseDisplay?.trendType) ++ ++ case .eventualBg: ++ guard let eventual = statusContext?.predictedGlucose?.values.last else { ++ return BottomRowItem.generic(label: type.name(), value: "??", unit: "") ++ } ++ ++ return BottomRowItem.generic(label: type.name(), value: glucoseFormatter.string(from: eventual) ?? "??", unit: "") ++ ++ case .deltaBg: ++ return BottomRowItem.generic(label: type.name(), value: delta, unit: "") ++ ++ case .loopCircle: ++ return BottomRowItem.loopIcon() ++ ++ case .updatedAt: ++ return BottomRowItem.generic(label: type.name(), value: timeFormatter.string(from: Date.now), unit: "") ++ } ++ } ++ } ++ ++ private func initEmptyActivity(settings: LiveActivitySettings) { ++ do { ++ let dynamicState = GlucoseActivityAttributes.ContentState( ++ date: Date.now, ++ ended: true, ++ preset: nil, ++ glucoseRanges: [], ++ currentGlucose: 0, ++ trendType: nil, ++ delta: "", ++ isMmol: true, ++ isCloseLoop: false, ++ lastCompleted: nil, ++ bottomRow: [], ++ glucoseSamples: [], ++ predicatedGlucose: [], ++ predicatedStartDate: nil, ++ predicatedInterval: nil ++ ) ++ ++ 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 "" ++ } ++ } ++} +\ No newline at end of file +diff --git a/Loop/Loop/Managers/LoopDataManager.swift b/Loop/Loop/Managers/LoopDataManager.swift +index 2319f4ec..c5220cb0 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..9482bfd0 +--- /dev/null ++++ b/Loop/Loop/Views/LiveActivityManagementView.swift +@@ -0,0 +1,135 @@ ++// ++// 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 upperLimitChartMmol: Double ++ @State private var lowerLimitChartMmol: Double ++ @State private var upperLimitChartMg: Double ++ @State private var lowerLimitChartMg: Double ++ ++ init() { ++ let liveActivitySettings = UserDefaults.standard.liveActivity ?? LiveActivitySettings() ++ ++ self.enabled = liveActivitySettings.enabled ++ self.mode = liveActivitySettings.mode ++ self.addPredictiveLine = liveActivitySettings.addPredictiveLine ++ self.useLimits = liveActivitySettings.useLimits ++ self.upperLimitChartMmol = liveActivitySettings.upperLimitChartMmol ++ self.lowerLimitChartMmol = liveActivitySettings.lowerLimitChartMmol ++ self.upperLimitChartMg = liveActivitySettings.upperLimitChartMg ++ self.lowerLimitChartMg = liveActivitySettings.lowerLimitChartMg ++ } ++ ++ var body: some View { ++ 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() }) ++ } ++ ) ++ } ++ ++ Section { ++ 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)) ++ ++ if useLimits { ++ if self.displayGlucosePreference.unit == .millimolesPerLiter { ++ TextInput(label: "Upper limit", value: $upperLimitChartMmol) ++ .transition(.move(edge: useLimits ? .top : .bottom)) ++ TextInput(label: "Lower limit", value: $lowerLimitChartMmol) ++ .transition(.move(edge: useLimits ? .top : .bottom)) ++ } else { ++ TextInput(label: "Upper limit", value: $upperLimitChartMg) ++ .transition(.move(edge: useLimits ? .top : .bottom)) ++ TextInput(label: "Lower limit", value: $lowerLimitChartMg) ++ .transition(.move(edge: useLimits ? .top : .bottom)) ++ } ++ } ++ } ++ ++ Section { ++ NavigationLink( ++ destination: LiveActivityBottomRowManagerView(), ++ label: { Text(NSLocalizedString("Bottom row configuration", comment: "Title for Bottom row configuration")) } ++ ) ++ } ++ ++ ++ } ++ .animation(.easeInOut, value: UUID()) ++ .insetGroupedListStyle() ++ ++ Spacer() ++ Button(action: save) { ++ Text(NSLocalizedString("Save", comment: "")) ++ } ++ .buttonStyle(ActionButtonStyle()) ++ .padding([.bottom, .horizontal]) ++ } ++ .navigationBarTitle(Text(NSLocalizedString("Live activity", comment: "Live activity screen title"))) ++ } ++ ++ @ViewBuilder ++ private func TextInput(label: String, value: Binding) -> some View { ++ HStack { ++ Text(NSLocalizedString(label, comment: "no comment")) ++ Spacer() ++ TextField("", value: value, format: .number) ++ .multilineTextAlignment(.trailing) ++ Text(self.displayGlucosePreference.unit.localizedShortUnitString) ++ } ++ } ++ ++ private func save() { ++ var settings = UserDefaults.standard.liveActivity ?? LiveActivitySettings() ++ settings.enabled = 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) ++ } ++} +diff --git a/Loop/LoopCore/LiveActivitySettings.swift b/Loop/LoopCore/LiveActivitySettings.swift +new file mode 100644 +index 00000000..71807464 +--- /dev/null ++++ b/Loop/LoopCore/LiveActivitySettings.swift +@@ -0,0 +1,159 @@ ++// ++// LiveActivitySettings.swift ++// LoopCore ++// ++// Created by Bastiaan Verhaar on 04/07/2024. ++// Copyright © 2024 LoopKit Authors. All rights reserved. ++// ++ ++import Foundation ++ ++public enum BottomRowConfiguration: Codable { ++ case iob ++ case cob ++ case basal ++ case currentBg ++ case eventualBg ++ case deltaBg ++ case loopCircle ++ case updatedAt ++ ++ static let defaults: [BottomRowConfiguration] = [.currentBg, .iob, .cob, .updatedAt] ++ public static let all: [BottomRowConfiguration] = [.iob, .cob, .basal, .currentBg, .eventualBg, .deltaBg, .loopCircle, .updatedAt] ++ ++ public func name() -> String { ++ switch self { ++ case .iob: ++ return NSLocalizedString("IOB", comment: "") ++ case .cob: ++ return NSLocalizedString("COB", comment: "") ++ case .basal: ++ return NSLocalizedString("Basal", comment: "") ++ case .currentBg: ++ return NSLocalizedString("Current BG", comment: "") ++ case .eventualBg: ++ return NSLocalizedString("Event", comment: "") ++ case .deltaBg: ++ return NSLocalizedString("Delta", comment: "") ++ case .loopCircle: ++ return NSLocalizedString("Loop", comment: "") ++ case .updatedAt: ++ return NSLocalizedString("Updated", comment: "") ++ } ++ } ++ ++ public func description() -> String { ++ switch self { ++ case .iob: ++ return NSLocalizedString("Active Insulin", comment: "") ++ case .cob: ++ return NSLocalizedString("Active Carbohydrates", comment: "") ++ case .basal: ++ return NSLocalizedString("Basal", comment: "") ++ case .currentBg: ++ return NSLocalizedString("Current Glucose", comment: "") ++ case .eventualBg: ++ return NSLocalizedString("Eventually", comment: "") ++ case .deltaBg: ++ return NSLocalizedString("Delta", comment: "") ++ case .loopCircle: ++ return NSLocalizedString("Loop circle", comment: "") ++ case .updatedAt: ++ return NSLocalizedString("Updated at", comment: "") ++ } ++ } ++} ++ ++public enum LiveActivityMode: Codable, CustomStringConvertible { ++ case large ++ case small ++ ++ public static let all: [LiveActivityMode] = [.large, .small] ++ public var description: String { ++ NSLocalizedString("In which mode do you want to render the Live Activity", comment: "") ++ } ++ ++ public func name() -> String { ++ switch self { ++ case .large: ++ return NSLocalizedString("Large", comment: "") ++ case .small: ++ return NSLocalizedString("Small", comment: "") ++ } ++ } ++} ++ ++public struct LiveActivitySettings: Codable, Equatable { ++ public var enabled: Bool ++ public var mode: LiveActivityMode ++ public var addPredictiveLine: Bool ++ public var useLimits: Bool ++ public var upperLimitChartMmol: Double ++ public var lowerLimitChartMmol: Double ++ public var upperLimitChartMg: Double ++ public var lowerLimitChartMg: Double ++ public var bottomRowConfiguration: [BottomRowConfiguration] ++ ++ private enum CodingKeys: String, CodingKey { ++ case enabled ++ case mode ++ case addPredictiveLine ++ case bottomRowConfiguration ++ case useLimits ++ case upperLimitChartMmol ++ case lowerLimitChartMmol ++ case upperLimitChartMg ++ case lowerLimitChartMg ++ } ++ ++ private static let defaultUpperLimitMmol = Double(10) ++ private static let defaultLowerLimitMmol = Double(4) ++ private static let defaultUpperLimitMg = Double(180) ++ private static let defaultLowerLimitMg = Double(72) ++ ++ public init(from decoder:Decoder) throws { ++ let values = try decoder.container(keyedBy: CodingKeys.self) ++ ++ self.enabled = try values.decode(Bool.self, forKey: .enabled) ++ self.mode = try values.decodeIfPresent(LiveActivityMode.self, forKey: .mode) ?? .large ++ self.addPredictiveLine = try values.decode(Bool.self, forKey: .addPredictiveLine) ++ self.useLimits = try values.decodeIfPresent(Bool.self, forKey: .useLimits) ?? true ++ self.upperLimitChartMmol = try values.decode(Double?.self, forKey: .upperLimitChartMmol) ?? LiveActivitySettings.defaultUpperLimitMmol ++ self.lowerLimitChartMmol = try values.decode(Double?.self, forKey: .lowerLimitChartMmol) ?? LiveActivitySettings.defaultLowerLimitMmol ++ self.upperLimitChartMg = try values.decode(Double?.self, forKey: .upperLimitChartMg) ?? LiveActivitySettings.defaultUpperLimitMg ++ self.lowerLimitChartMg = try values.decode(Double?.self, forKey: .lowerLimitChartMg) ?? LiveActivitySettings.defaultLowerLimitMg ++ self.bottomRowConfiguration = try values.decode([BottomRowConfiguration].self, forKey: .bottomRowConfiguration) ++ } ++ ++ public init() { ++ self.enabled = true ++ self.mode = .large ++ self.addPredictiveLine = true ++ self.useLimits = true ++ self.upperLimitChartMmol = LiveActivitySettings.defaultUpperLimitMmol ++ self.lowerLimitChartMmol = LiveActivitySettings.defaultLowerLimitMmol ++ self.upperLimitChartMg = LiveActivitySettings.defaultUpperLimitMg ++ self.lowerLimitChartMg = LiveActivitySettings.defaultLowerLimitMg ++ self.bottomRowConfiguration = BottomRowConfiguration.defaults ++ } ++ ++ public static func == (lhs: LiveActivitySettings, rhs: LiveActivitySettings) -> Bool { ++ return lhs.addPredictiveLine == rhs.addPredictiveLine && ++ lhs.mode == rhs.mode && ++ lhs.useLimits == rhs.useLimits && ++ lhs.lowerLimitChartMmol == rhs.lowerLimitChartMmol && ++ lhs.upperLimitChartMmol == rhs.upperLimitChartMmol && ++ lhs.lowerLimitChartMg == rhs.lowerLimitChartMg && ++ lhs.upperLimitChartMg == rhs.upperLimitChartMg ++ } ++ ++ public static func != (lhs: LiveActivitySettings, rhs: LiveActivitySettings) -> Bool { ++ return lhs.addPredictiveLine != rhs.addPredictiveLine || ++ lhs.mode != rhs.mode || ++ lhs.useLimits != rhs.useLimits || ++ lhs.lowerLimitChartMmol != rhs.lowerLimitChartMmol || ++ lhs.upperLimitChartMmol != rhs.upperLimitChartMmol || ++ lhs.lowerLimitChartMg != rhs.lowerLimitChartMg || ++ lhs.upperLimitChartMg != rhs.upperLimitChartMg ++ } ++} +\ No newline at end of file +diff --git a/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 846f71c..f901044 100644 --- a/live_activity/live_activity.patch +++ b/live_activity/live_activity.patch @@ -1,4 +1,4 @@ -Submodule Loop 1aaee2d..92fb65f: +Submodule Loop 1aaee2d..03d5489: 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 @@ -97,10 +97,10 @@ index 00000000..915335c5 +} 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 +index 00000000..b65e02c9 --- /dev/null +++ b/Loop/Loop Widget Extension/Live Activity/ChartView.swift -@@ -0,0 +1,153 @@ +@@ -0,0 +1,159 @@ +// +// ChartValues.swift +// Loop Widget Extension @@ -118,8 +118,9 @@ index 00000000..237ddebd + private let predicatedData: [ChartValues] + private let glucoseRanges: [GlucoseRangeValue] + private let preset: Preset? ++ private let yAxisMarks: [Double] + -+ init(glucoseSamples: [GlucoseSampleAttributes], predicatedGlucose: [Double], predicatedStartDate: Date?, predicatedInterval: TimeInterval?, useLimits: Bool, lowerLimit: Double, upperLimit: Double, glucoseRanges: [GlucoseRangeValue], preset: Preset?) { ++ init(glucoseSamples: [GlucoseSampleAttributes], predicatedGlucose: [Double], predicatedStartDate: Date?, predicatedInterval: TimeInterval?, useLimits: Bool, lowerLimit: Double, upperLimit: Double, glucoseRanges: [GlucoseRangeValue], preset: Preset?, yAxisMarks: [Double]) { + self.glucoseSampleData = ChartValues.convert(data: glucoseSamples, useLimits: useLimits, lowerLimit: lowerLimit, upperLimit: upperLimit) + self.predicatedData = ChartValues.convert( + data: predicatedGlucose, @@ -131,13 +132,15 @@ index 00000000..237ddebd + ) + self.preset = preset + self.glucoseRanges = glucoseRanges ++ self.yAxisMarks = yAxisMarks + } + -+ init(glucoseSamples: [GlucoseSampleAttributes], useLimits: Bool, lowerLimit: Double, upperLimit: Double, glucoseRanges: [GlucoseRangeValue], preset: Preset?) { ++ init(glucoseSamples: [GlucoseSampleAttributes], useLimits: Bool, lowerLimit: Double, upperLimit: Double, glucoseRanges: [GlucoseRangeValue], preset: Preset?, yAxisMarks: [Double]) { + self.glucoseSampleData = ChartValues.convert(data: glucoseSamples, useLimits: useLimits, lowerLimit: lowerLimit, upperLimit: upperLimit) + self.predicatedData = [] + self.preset = preset + self.glucoseRanges = glucoseRanges ++ self.yAxisMarks = yAxisMarks + } + + var body: some View { @@ -190,7 +193,10 @@ index 00000000..237ddebd + plotContent.background(.cyan.opacity(0.15)) + } + .chartLegend(.hidden) -+ .chartYScale(domain: .automatic(includesZero: false)) ++ .chartYScale(domain: [yAxisMarks.first ?? 0, yAxisMarks.last ?? 0]) ++ .chartYAxis { ++ AxisMarks(values: yAxisMarks) ++ } + .chartYAxis { + AxisMarks(position: .leading) { _ in + AxisValueLabel().foregroundStyle(Color.primary) @@ -256,10 +262,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..eb70f560 +index 00000000..4d5ed5ef --- /dev/null +++ b/Loop/Loop Widget Extension/Live Activity/GlucoseLiveActivityConfiguration.swift -@@ -0,0 +1,294 @@ +@@ -0,0 +1,298 @@ +// +// LiveActivityConfiguration.swift +// Loop Widget Extension @@ -305,7 +311,8 @@ index 00000000..eb70f560 + lowerLimit: context.state.isMmol ? context.attributes.lowerLimitChartMmol : context.attributes.lowerLimitChartMg, + upperLimit: context.state.isMmol ? context.attributes.upperLimitChartMmol : context.attributes.upperLimitChartMg, + glucoseRanges: context.state.glucoseRanges, -+ preset: context.state.preset ++ preset: context.state.preset, ++ yAxisMarks: context.state.yAxisMarks + ) + .frame(height: 85) + } else { @@ -315,7 +322,8 @@ index 00000000..eb70f560 + lowerLimit: context.state.isMmol ? context.attributes.lowerLimitChartMmol : context.attributes.lowerLimitChartMg, + upperLimit: context.state.isMmol ? context.attributes.upperLimitChartMmol : context.attributes.upperLimitChartMg, + glucoseRanges: context.state.glucoseRanges, -+ preset: context.state.preset ++ preset: context.state.preset, ++ yAxisMarks: context.state.yAxisMarks + ) + .frame(height: 85) + } @@ -411,7 +419,8 @@ index 00000000..eb70f560 + lowerLimit: context.state.isMmol ? context.attributes.lowerLimitChartMmol : context.attributes.lowerLimitChartMg, + upperLimit: context.state.isMmol ? context.attributes.upperLimitChartMmol : context.attributes.upperLimitChartMg, + glucoseRanges: context.state.glucoseRanges, -+ preset: context.state.preset ++ preset: context.state.preset, ++ yAxisMarks: context.state.yAxisMarks + ) + .frame(height: 75) + } else { @@ -421,7 +430,8 @@ index 00000000..eb70f560 + lowerLimit: context.state.isMmol ? context.attributes.lowerLimitChartMmol : context.attributes.lowerLimitChartMg, + upperLimit: context.state.isMmol ? context.attributes.upperLimitChartMmol : context.attributes.upperLimitChartMg, + glucoseRanges: context.state.glucoseRanges, -+ preset: context.state.preset ++ preset: context.state.preset, ++ yAxisMarks: context.state.yAxisMarks + ) + .frame(height: 75) + } @@ -566,13 +576,15 @@ index 26f92edb..684bf073 100644 } } diff --git a/Loop/Loop.xcodeproj/project.pbxproj b/Loop/Loop.xcodeproj/project.pbxproj -index 11819516..606155f7 100644 +index 11819516..133e3dca 100644 --- a/Loop/Loop.xcodeproj/project.pbxproj +++ b/Loop/Loop.xcodeproj/project.pbxproj -@@ -401,6 +401,19 @@ +@@ -401,6 +401,21 @@ B4E96D5D248A82A2002DABAD /* StatusBarHUDView.xib in Resources */ = {isa = PBXBuildFile; fileRef = B4E96D5C248A82A2002DABAD /* StatusBarHUDView.xib */; }; B4F3D25124AF890C0095CE44 /* BluetoothStateManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4F3D25024AF890C0095CE44 /* BluetoothStateManager.swift */; }; B4FEEF7D24B8A71F00A8DF9B /* DeviceDataManager+DeviceStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4FEEF7C24B8A71F00A8DF9B /* DeviceDataManager+DeviceStatus.swift */; }; ++ B82181F62C93628300478A91 /* LiveActivityManagementViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B82181F52C93628300478A91 /* LiveActivityManagementViewModel.swift */; }; ++ B82182002C93716A00478A91 /* ChartAxisGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = B82181F92C936AD600478A91 /* ChartAxisGenerator.swift */; }; + B851FFC52C37221800D738C1 /* LiveActivityManagementView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B851FFC42C37221800D738C1 /* LiveActivityManagementView.swift */; }; + B851FFCA2C3731DE00D738C1 /* LiveActivitySettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = B851FFC62C37271E00D738C1 /* LiveActivitySettings.swift */; }; + B851FFCB2C3731DE00D738C1 /* LiveActivitySettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = B851FFC62C37271E00D738C1 /* LiveActivitySettings.swift */; }; @@ -589,10 +601,29 @@ index 11819516..606155f7 100644 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 @@ +@@ -729,6 +744,16 @@ + name = "Embed App Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; ++ B82181FF2C9370F800478A91 /* Embed Frameworks */ = { ++ isa = PBXCopyFilesBuildPhase; ++ buildActionMask = 2147483647; ++ dstPath = ""; ++ dstSubfolderSpec = 10; ++ files = ( ++ ); ++ name = "Embed Frameworks"; ++ runOnlyForDeploymentPostprocessing = 0; ++ }; + C1E3DC4828595FAA00CA19FF /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; +@@ -1325,6 +1350,19 @@ B4E96D5C248A82A2002DABAD /* StatusBarHUDView.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = StatusBarHUDView.xib; sourceTree = ""; }; B4F3D25024AF890C0095CE44 /* BluetoothStateManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BluetoothStateManager.swift; sourceTree = ""; }; B4FEEF7C24B8A71F00A8DF9B /* DeviceDataManager+DeviceStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DeviceDataManager+DeviceStatus.swift"; sourceTree = ""; }; ++ B82181F52C93628300478A91 /* LiveActivityManagementViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActivityManagementViewModel.swift; sourceTree = ""; }; ++ B82181F92C936AD600478A91 /* ChartAxisGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChartAxisGenerator.swift; sourceTree = ""; }; + B851FFC42C37221800D738C1 /* LiveActivityManagementView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActivityManagementView.swift; sourceTree = ""; }; + B851FFC62C37271E00D738C1 /* LiveActivitySettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActivitySettings.swift; sourceTree = ""; }; + B87539C82C2B06CE0085A975 /* LocalizedString.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalizedString.swift; sourceTree = ""; }; @@ -607,7 +638,7 @@ index 11819516..606155f7 100644 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 @@ +@@ -1727,6 +1765,7 @@ 1419606928D9554E00BA86E0 /* LoopKitUI.framework in Frameworks */, 1419606A28D955BC00BA86E0 /* MockKitUI.framework in Frameworks */, 1481F9BB28DA26F4004C5AEB /* LoopUI.framework in Frameworks */, @@ -615,7 +646,7 @@ index 11819516..606155f7 100644 1419606428D9550400BA86E0 /* LoopKitUI.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; -@@ -1836,6 +1861,7 @@ +@@ -1836,6 +1875,7 @@ 84AA81D12A4A2778000B658B /* Components */, 84AA81D92A4A2966000B658B /* Helpers */, 84AA81DE2A4A2B3D000B658B /* Timeline */, @@ -623,7 +654,7 @@ index 11819516..606155f7 100644 84AA81DF2A4A2B7A000B658B /* Widgets */, 84AA81D22A4A27A3000B658B /* LoopWidgets.swift */, ); -@@ -2163,6 +2189,7 @@ +@@ -2163,6 +2203,7 @@ 43DE92581C5479E4001FFDE1 /* PotentialCarbEntryUserInfo.swift */, 43C05CB721EBEA54006FB252 /* HKUnit.swift */, 434FF1E91CF26C29000DB779 /* IdentifiableClass.swift */, @@ -631,7 +662,7 @@ index 11819516..606155f7 100644 C19E96DD23D2733F003F79B0 /* LoopCompletionFreshness.swift */, 430B29892041F54A00BA9F93 /* NSUserDefaults.swift */, 431E73471FF95A900069B5F7 /* PersistenceController.swift */, -@@ -2170,10 +2197,10 @@ +@@ -2170,10 +2211,10 @@ 43D9FFD121EAE05D00AF44BF /* LoopCore.h */, 43D9FFD221EAE05D00AF44BF /* Info.plist */, 4B60626A287E286000BF8BBB /* Localizable.strings */, @@ -643,7 +674,7 @@ index 11819516..606155f7 100644 ); path = LoopCore; sourceTree = ""; -@@ -2276,6 +2303,8 @@ +@@ -2276,6 +2317,8 @@ C1AF062229426300002C1B19 /* ManualGlucoseEntryRow.swift */, DDC389FD2A2C4C830066E2E8 /* GlucoseBasedApplicationFactorSelectionView.swift */, DD3DBD282A33AFE9000F8B5B /* IntegralRetrospectiveCorrectionSelectionView.swift */, @@ -652,7 +683,7 @@ index 11819516..606155f7 100644 ); path = Views; sourceTree = ""; -@@ -2315,6 +2344,7 @@ +@@ -2315,6 +2358,7 @@ 1DA6499D2441266400F61E75 /* Alerts */, E95D37FF24EADE68005E2F50 /* Store Protocols */, E9B355232935906B0076AB04 /* Missed Meal Detection */, @@ -660,7 +691,7 @@ index 11819516..606155f7 100644 C1F2075B26D6F9B0007AB7EB /* AppExpirationAlerter.swift */, A96DAC2B2838F31200D94E38 /* SharedLogging.swift */, 7E69CFFB2A16A77E00203CBD /* ResetLoopManager.swift */, -@@ -2525,6 +2555,7 @@ +@@ -2525,6 +2569,7 @@ C11613472983096D00777E7C /* InfoPlist.strings */, 14B1736D28AEDA63006CCD7C /* LoopWidgetExtension.entitlements */, 14B1736628AED9EE006CCD7C /* Info.plist */, @@ -668,7 +699,7 @@ index 11819516..606155f7 100644 ); path = Bootstrap; sourceTree = ""; -@@ -2535,6 +2566,7 @@ +@@ -2535,6 +2580,7 @@ 84AA81DA2A4A2973000B658B /* Date.swift */, 84AA81D52A4A28AF000B658B /* WidgetBackground.swift */, 84D2879E2AC756C8007ED283 /* ContentMargin.swift */, @@ -676,7 +707,15 @@ index 11819516..606155f7 100644 ); path = Helpers; sourceTree = ""; -@@ -2663,6 +2695,7 @@ +@@ -2628,6 +2674,7 @@ + 1D49795724E7289700948F05 /* ServicesViewModel.swift */, + C174233B259BEB0F00399C9D /* ManualEntryDoseViewModel.swift */, + 1DB619AB270BAD3D006C9D07 /* VersionUpdateViewModel.swift */, ++ B82181F52C93628300478A91 /* LiveActivityManagementViewModel.swift */, + ); + path = "View Models"; + sourceTree = ""; +@@ -2663,6 +2710,7 @@ 968DCD53F724DE56FFE51920 /* Frameworks */ = { isa = PBXGroup; children = ( @@ -684,7 +723,7 @@ index 11819516..606155f7 100644 C159C82E286787EF00A86EC0 /* LoopKit.framework */, C159C8212867859800A86EC0 /* MockKitUI.framework */, C159C8192867857000A86EC0 /* LoopKitUI.framework */, -@@ -2760,6 +2793,25 @@ +@@ -2760,6 +2808,26 @@ path = LoopCore; sourceTree = ""; }; @@ -703,6 +742,7 @@ index 11819516..606155f7 100644 + children = ( + B8A937B72C29BA5900E38645 /* GlucoseActivityManager.swift */, + B8A937C02C29C29300E38645 /* GlucoseActivityAttributes.swift */, ++ B82181F92C936AD600478A91 /* ChartAxisGenerator.swift */, + ); + path = "Live Activity"; + sourceTree = ""; @@ -710,7 +750,24 @@ index 11819516..606155f7 100644 C13072B82A76AF0A009A7C58 /* live_capture */ = { isa = PBXGroup; children = ( -@@ -3623,12 +3675,15 @@ +@@ -2982,6 +3050,7 @@ + 14B1735828AED9EC006CCD7C /* Sources */, + 14B1735928AED9EC006CCD7C /* Frameworks */, + 14B1735A28AED9EC006CCD7C /* Resources */, ++ B82181FF2C9370F800478A91 /* Embed Frameworks */, + ); + buildRules = ( + ); +@@ -2989,6 +3058,8 @@ + 1481F9BE28DA26F4004C5AEB /* PBXTargetDependency */, + ); + name = "Loop Widget Extension"; ++ packageProductDependencies = ( ++ ); + productName = SmallStatusWidgetExtension; + productReference = 14B1735C28AED9EC006CCD7C /* Loop Widget Extension.appex */; + productType = "com.apple.product-type.app-extension"; +@@ -3623,12 +3694,15 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( @@ -726,7 +783,7 @@ index 11819516..606155f7 100644 84AA81E32A4A36FB000B658B /* SystemActionLink.swift in Sources */, 14B1737828AEDC6C006CCD7C /* NSTimeInterval.swift in Sources */, 14B1737928AEDC6C006CCD7C /* NSUserDefaults+StatusExtension.swift in Sources */, -@@ -3639,7 +3694,10 @@ +@@ -3639,7 +3713,10 @@ 14B1737E28AEDC6C006CCD7C /* FeatureFlags.swift in Sources */, 84AA81E72A4A4DEF000B658B /* PumpView.swift in Sources */, 14B1737F28AEDC6C006CCD7C /* PluginManager.swift in Sources */, @@ -737,7 +794,7 @@ index 11819516..606155f7 100644 84AA81DB2A4A2973000B658B /* Date.swift in Sources */, 14B1737228AEDBF6006CCD7C /* BasalView.swift in Sources */, 14B1737428AEDBF6006CCD7C /* GlucoseView.swift in Sources */, -@@ -3725,6 +3783,7 @@ +@@ -3725,6 +3802,7 @@ B4E202302661063E009421B5 /* AutomaticDosingStatus.swift in Sources */, C191D2A125B3ACAA00C26C0B /* DosingStrategySelectionView.swift in Sources */, A977A2F424ACFECF0059C207 /* CriticalEventLogExportManager.swift in Sources */, @@ -745,7 +802,7 @@ index 11819516..606155f7 100644 89CA2B32226C18B8004D9350 /* TestingScenariosTableViewController.swift in Sources */, 43E93FB71E469A5100EAB8DB /* HKUnit.swift in Sources */, 43C05CAF21EB2C24006FB252 /* NSBundle.swift in Sources */, -@@ -3732,6 +3791,7 @@ +@@ -3732,6 +3810,7 @@ A967D94C24F99B9300CDDF8A /* OutputStream.swift in Sources */, 1DB1065124467E18005542BD /* AlertManager.swift in Sources */, 1D9650C82523FBA100A1370B /* DeviceDataManager+BolusEntryViewModelDelegate.swift in Sources */, @@ -753,7 +810,15 @@ index 11819516..606155f7 100644 43C0944A1CACCC73001F6403 /* NotificationManager.swift in Sources */, 149A28E42A8A63A700052EDF /* FavoriteFoodDetailView.swift in Sources */, 1DDE274024AEA4F200796622 /* NotificationsCriticalAlertPermissionsView.swift in Sources */, -@@ -3818,6 +3878,7 @@ +@@ -3786,6 +3865,7 @@ + C1AF062329426300002C1B19 /* ManualGlucoseEntryRow.swift in Sources */, + C148CEE724FD91BD00711B3B /* DeliveryUncertaintyAlertManager.swift in Sources */, + DDC389FC2A2BC6670066E2E8 /* SettingsView+algorithmExperimentsSection.swift in Sources */, ++ B82181F62C93628300478A91 /* LiveActivityManagementViewModel.swift in Sources */, + 1D12D3B92548EFDD00B53E8B /* main.swift in Sources */, + 435400341C9F878D00D5819C /* SetBolusUserInfo.swift in Sources */, + A9DCF32A25B0FABF00C89088 /* LoopUIColorPalette+Default.swift in Sources */, +@@ -3818,11 +3898,13 @@ A97F250825E056D500F0EE19 /* OnboardingManager.swift in Sources */, 438D42F91D7C88BC003244B0 /* PredictionInputEffect.swift in Sources */, 892A5D692230C41D008961AB /* RangeReplaceableCollection.swift in Sources */, @@ -761,7 +826,13 @@ index 11819516..606155f7 100644 DDC389F62A2B61750066E2E8 /* ApplicationFactorStrategy.swift in Sources */, 4F70C2101DE8FAC5006380B7 /* ExtensionDataManager.swift in Sources */, 43DFB62320D4CAE7008A7BAE /* PumpManager.swift in Sources */, -@@ -3835,6 +3896,7 @@ + A9FB75F1252BE320004C7D3F /* BolusDosingDecision.swift in Sources */, + 892A5D59222F0A27008961AB /* Debug.swift in Sources */, ++ B82182002C93716A00478A91 /* ChartAxisGenerator.swift in Sources */, + 431A8C401EC6E8AB00823B9C /* CircleMaskView.swift in Sources */, + 1D05219D2469F1F5000EBBDE /* AlertStore.swift in Sources */, + 439897371CD2F80600223065 /* AnalyticsServicesManager.swift in Sources */, +@@ -3835,6 +3917,7 @@ 439706E622D2E84900C81566 /* PredictionSettingTableViewCell.swift in Sources */, 430D85891F44037000AF2D4F /* HUDViewTableViewCell.swift in Sources */, 43A51E211EB6DBDD000736CC /* LoopChartsTableViewController.swift in Sources */, @@ -769,7 +840,7 @@ index 11819516..606155f7 100644 8968B1122408B3520074BB48 /* UIFont.swift in Sources */, 1452F4A92A851C9400F8B9E4 /* AddEditFavoriteFoodViewModel.swift in Sources */, 438D42FB1D7D11A4003244B0 /* PredictionInputEffectTableViewCell.swift in Sources */, -@@ -3956,6 +4018,7 @@ +@@ -3956,6 +4039,7 @@ C17DDC9D28AC33A1005FBF4C /* PersistedProperty.swift in Sources */, 43C05CA921EB2B26006FB252 /* PersistenceController.swift in Sources */, A9CE912224CA032E00302A40 /* NSUserDefaults.swift in Sources */, @@ -777,7 +848,7 @@ index 11819516..606155f7 100644 43C05CAB21EB2B4A006FB252 /* NSBundle.swift in Sources */, 43C05CC721EC2ABC006FB252 /* IdentifiableClass.swift in Sources */, E9B3552B293591E70076AB04 /* MissedMealNotification.swift in Sources */, -@@ -3977,6 +4040,7 @@ +@@ -3977,6 +4061,7 @@ C17DDC9C28AC339E005FBF4C /* PersistedProperty.swift in Sources */, 43C05CA821EB2B26006FB252 /* PersistenceController.swift in Sources */, 43C05CAA21EB2B49006FB252 /* NSBundle.swift in Sources */, @@ -785,7 +856,7 @@ index 11819516..606155f7 100644 43C05CC821EC2ABC006FB252 /* IdentifiableClass.swift in Sources */, 43C05CAD21EB2BBF006FB252 /* NSUserDefaults.swift in Sources */, E9B3552A293591E70076AB04 /* MissedMealNotification.swift in Sources */, -@@ -4832,6 +4896,7 @@ +@@ -4832,6 +4917,7 @@ INFOPLIST_FILE = "Loop Widget Extension/Bootstrap/Info.plist"; INFOPLIST_KEY_CFBundleDisplayName = "Loop Widgets"; INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2022 LoopKit Authors. All rights reserved."; @@ -793,7 +864,7 @@ index 11819516..606155f7 100644 LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", -@@ -4880,6 +4945,7 @@ +@@ -4880,6 +4966,7 @@ INFOPLIST_FILE = "Loop Widget Extension/Bootstrap/Info.plist"; INFOPLIST_KEY_CFBundleDisplayName = "Loop Widgets"; INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2022 LoopKit Authors. All rights reserved."; @@ -801,7 +872,7 @@ index 11819516..606155f7 100644 LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", -@@ -5138,6 +5204,7 @@ +@@ -5138,6 +5225,7 @@ DEVELOPMENT_TEAM = "$(LOOP_DEVELOPMENT_TEAM)"; FRAMEWORK_SEARCH_PATHS = "$(inherited)"; INFOPLIST_FILE = Loop/Info.plist; @@ -809,7 +880,7 @@ index 11819516..606155f7 100644 LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", -@@ -5167,6 +5234,7 @@ +@@ -5167,6 +5255,7 @@ DEVELOPMENT_TEAM = "$(LOOP_DEVELOPMENT_TEAM)"; FRAMEWORK_SEARCH_PATHS = "$(inherited)"; INFOPLIST_FILE = Loop/Info.plist; @@ -817,7 +888,7 @@ index 11819516..606155f7 100644 LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", -@@ -5423,6 +5491,7 @@ +@@ -5423,6 +5512,7 @@ DEVELOPMENT_TEAM = "$(LOOP_DEVELOPMENT_TEAM)"; ENABLE_BITCODE = NO; INFOPLIST_FILE = "Loop Status Extension/Info.plist"; @@ -825,7 +896,7 @@ index 11819516..606155f7 100644 LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", -@@ -5449,6 +5518,7 @@ +@@ -5449,6 +5539,7 @@ DEVELOPMENT_TEAM = "$(LOOP_DEVELOPMENT_TEAM)"; ENABLE_BITCODE = NO; INFOPLIST_FILE = "Loop Status Extension/Info.plist"; @@ -833,7 +904,7 @@ index 11819516..606155f7 100644 LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", -@@ -5516,6 +5586,7 @@ +@@ -5516,6 +5607,7 @@ ENABLE_BITCODE = NO; FRAMEWORK_SEARCH_PATHS = "$(inherited)"; INFOPLIST_FILE = "Loop Intent Extension/Info.plist"; @@ -841,7 +912,7 @@ index 11819516..606155f7 100644 LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", -@@ -5543,6 +5614,7 @@ +@@ -5543,6 +5635,7 @@ ENABLE_BITCODE = NO; FRAMEWORK_SEARCH_PATHS = "$(inherited)"; INFOPLIST_FILE = "Loop Intent Extension/Info.plist"; @@ -877,12 +948,143 @@ index 50ba55d9..e6a2f9b9 100644 com.apple.developer.nfc.readersession.formats TAG +diff --git a/Loop/Loop/Managers/Live Activity/ChartAxisGenerator.swift b/Loop/Loop/Managers/Live Activity/ChartAxisGenerator.swift +new file mode 100644 +index 00000000..0fcc3ca8 +--- /dev/null ++++ b/Loop/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/Loop/Managers/Live Activity/GlucoseActivityAttributes.swift b/Loop/Loop/Managers/Live Activity/GlucoseActivityAttributes.swift new file mode 100644 -index 00000000..4efa8b76 +index 00000000..936de751 --- /dev/null +++ b/Loop/Loop/Managers/Live Activity/GlucoseActivityAttributes.swift -@@ -0,0 +1,150 @@ +@@ -0,0 +1,151 @@ +// +// LiveActivityAttributes.swift +// LoopUI @@ -922,6 +1124,7 @@ index 00000000..4efa8b76 + public let predicatedGlucose: [Double] + public let predicatedStartDate: Date? + public let predicatedInterval: TimeInterval? ++ public let yAxisMarks: [Double] + } + + public let mode: LiveActivityMode @@ -1035,10 +1238,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..750dba00 +index 00000000..3903ee8b --- /dev/null +++ b/Loop/Loop/Managers/Live Activity/GlucoseActivityManager.swift -@@ -0,0 +1,512 @@ +@@ -0,0 +1,520 @@ +// +// LiveActivityManaer.swift +// Loop @@ -1206,6 +1409,12 @@ index 00000000..750dba00 + 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, @@ -1226,7 +1435,8 @@ index 00000000..750dba00 + }, + predicatedGlucose: predicatedGlucose, + predicatedStartDate: statusContext?.predictedGlucose?.startDate, -+ predicatedInterval: statusContext?.predictedGlucose?.interval ++ predicatedInterval: statusContext?.predictedGlucose?.interval, ++ yAxisMarks: chartYAxis + ) + + await self.activity?.update(ActivityContent( @@ -1515,7 +1725,8 @@ index 00000000..750dba00 + glucoseSamples: [], + predicatedGlucose: [], + predicatedStartDate: nil, -+ predicatedInterval: nil ++ predicatedInterval: nil, ++ yAxisMarks: [] + ) + + self.activity = try Activity.request( @@ -1551,7 +1762,6 @@ index 00000000..750dba00 + } + } +} -\ No newline at end of file diff --git a/Loop/Loop/Managers/LoopDataManager.swift b/Loop/Loop/Managers/LoopDataManager.swift index 2319f4ec..c5220cb0 100644 --- a/Loop/Loop/Managers/LoopDataManager.swift @@ -1644,6 +1854,47 @@ index 2319f4ec..c5220cb0 100644 } // Invalidate cached effects affected by the override +diff --git a/Loop/Loop/View Models/LiveActivityManagementViewModel.swift b/Loop/Loop/View Models/LiveActivityManagementViewModel.swift +new file mode 100644 +index 00000000..46fc560d +--- /dev/null ++++ b/Loop/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/Loop/Views/AlertManagementView.swift b/Loop/Loop/Views/AlertManagementView.swift index e9a38e72..94e542a6 100644 --- a/Loop/Loop/Views/AlertManagementView.swift @@ -1796,10 +2047,10 @@ index 00000000..49e50caa +} diff --git a/Loop/Loop/Views/LiveActivityManagementView.swift b/Loop/Loop/Views/LiveActivityManagementView.swift new file mode 100644 -index 00000000..9482bfd0 +index 00000000..f7f875ca --- /dev/null +++ b/Loop/Loop/Views/LiveActivityManagementView.swift -@@ -0,0 +1,135 @@ +@@ -0,0 +1,113 @@ +// +// LiveActivityManagementView.swift +// Loop @@ -1815,48 +2066,26 @@ index 00000000..9482bfd0 + +struct LiveActivityManagementView: View { + @EnvironmentObject private var displayGlucosePreference: DisplayGlucosePreference -+ -+ @State private var enabled: Bool -+ @State private var mode: LiveActivityMode -+ @State var isEditingMode = false -+ @State private var addPredictiveLine: Bool -+ @State private var useLimits: Bool -+ @State private var upperLimitChartMmol: Double -+ @State private var lowerLimitChartMmol: Double -+ @State private var upperLimitChartMg: Double -+ @State private var lowerLimitChartMg: Double -+ -+ init() { -+ let liveActivitySettings = UserDefaults.standard.liveActivity ?? LiveActivitySettings() -+ -+ self.enabled = liveActivitySettings.enabled -+ self.mode = liveActivitySettings.mode -+ self.addPredictiveLine = liveActivitySettings.addPredictiveLine -+ self.useLimits = liveActivitySettings.useLimits -+ self.upperLimitChartMmol = liveActivitySettings.upperLimitChartMmol -+ self.lowerLimitChartMmol = liveActivitySettings.lowerLimitChartMmol -+ self.upperLimitChartMg = liveActivitySettings.upperLimitChartMg -+ self.lowerLimitChartMg = liveActivitySettings.lowerLimitChartMg -+ } ++ @StateObject private var viewModel = LiveActivityManagementViewModel() + + var body: some View { + VStack { + List { + Section { -+ Toggle(NSLocalizedString("Enabled", comment: "Title for enable live activity toggle"), isOn: $enabled) ++ Toggle(NSLocalizedString("Enabled", comment: "Title for enable live activity toggle"), isOn: $viewModel.enabled) + + ExpandableSetting( -+ isEditing: $isEditingMode, ++ isEditing: $viewModel.isEditingMode, + leadingValueContent: { + Text(NSLocalizedString("Mode", comment: "Title for mode live activity toggle")) -+ .foregroundStyle(isEditingMode ? .blue : .primary) ++ .foregroundStyle(viewModel.isEditingMode ? .blue : .primary) + }, + trailingValueContent: { -+ Text(self.mode.name()) -+ .foregroundStyle(isEditingMode ? .blue : .primary) ++ Text(viewModel.mode.name()) ++ .foregroundStyle(viewModel.isEditingMode ? .blue : .primary) + }, + expandedContent: { -+ ResizeablePicker(selection: self.$mode.animation(), ++ ResizeablePicker(selection: self.$viewModel.mode.animation(), + data: LiveActivityMode.all, + formatter: { $0.name() }) + } @@ -1864,25 +2093,25 @@ index 00000000..9482bfd0 + } + + Section { -+ if mode == .large { -+ Toggle(NSLocalizedString("Add predictive line", comment: "Title for predictive line toggle"), isOn: $addPredictiveLine) -+ .transition(.move(edge: mode == .large ? .top : .bottom)) ++ if viewModel.mode == .large { ++ Toggle(NSLocalizedString("Add predictive line", comment: "Title for predictive line toggle"), isOn: $viewModel.addPredictiveLine) ++ .transition(.move(edge: viewModel.mode == .large ? .top : .bottom)) + } + -+ Toggle(NSLocalizedString("Use BG coloring", comment: "Title for BG coloring"), isOn: $useLimits) -+ .transition(.move(edge: mode == .large ? .top : .bottom)) ++ Toggle(NSLocalizedString("Use BG coloring", comment: "Title for BG coloring"), isOn: $viewModel.useLimits) ++ .transition(.move(edge: viewModel.mode == .large ? .top : .bottom)) + -+ if useLimits { ++ if viewModel.useLimits { + if self.displayGlucosePreference.unit == .millimolesPerLiter { -+ TextInput(label: "Upper limit", value: $upperLimitChartMmol) -+ .transition(.move(edge: useLimits ? .top : .bottom)) -+ TextInput(label: "Lower limit", value: $lowerLimitChartMmol) -+ .transition(.move(edge: useLimits ? .top : .bottom)) ++ TextInput(label: "Upper limit", value: $viewModel.upperLimitChartMmol) ++ .transition(.move(edge: viewModel.useLimits ? .top : .bottom)) ++ TextInput(label: "Lower limit", value: $viewModel.lowerLimitChartMmol) ++ .transition(.move(edge: viewModel.useLimits ? .top : .bottom)) + } else { -+ TextInput(label: "Upper limit", value: $upperLimitChartMg) -+ .transition(.move(edge: useLimits ? .top : .bottom)) -+ TextInput(label: "Lower limit", value: $lowerLimitChartMg) -+ .transition(.move(edge: useLimits ? .top : .bottom)) ++ TextInput(label: "Upper limit", value: $viewModel.upperLimitChartMg) ++ .transition(.move(edge: viewModel.useLimits ? .top : .bottom)) ++ TextInput(label: "Lower limit", value: $viewModel.lowerLimitChartMg) ++ .transition(.move(edge: viewModel.useLimits ? .top : .bottom)) + } + } + } @@ -1922,14 +2151,14 @@ index 00000000..9482bfd0 + + private func save() { + var settings = UserDefaults.standard.liveActivity ?? LiveActivitySettings() -+ settings.enabled = self.enabled -+ settings.mode = self.mode -+ settings.addPredictiveLine = self.addPredictiveLine -+ settings.useLimits = self.useLimits -+ settings.upperLimitChartMmol = self.upperLimitChartMmol -+ settings.lowerLimitChartMmol = self.lowerLimitChartMmol -+ settings.upperLimitChartMg = self.upperLimitChartMg -+ settings.lowerLimitChartMg = self.lowerLimitChartMg ++ settings.enabled = viewModel.enabled ++ settings.mode = viewModel.mode ++ settings.addPredictiveLine = viewModel.addPredictiveLine ++ settings.useLimits = viewModel.useLimits ++ settings.upperLimitChartMmol = viewModel.upperLimitChartMmol ++ settings.lowerLimitChartMmol = viewModel.lowerLimitChartMmol ++ settings.upperLimitChartMg = viewModel.upperLimitChartMg ++ settings.lowerLimitChartMg = viewModel.lowerLimitChartMg + + UserDefaults.standard.liveActivity = settings + NotificationCenter.default.post(name: .LiveActivitySettingsChanged, object: settings)