updates to GlucoseChartType to configure new Siri intent type from the existing swiftui glucose chart

- extend and improve the current swiftui glucose chart
- add siri glucose chart type
- add conditional handlers for frame, aspectRatio and padding etc
- tidy up ConstantsGlucoseChartSwiftUI file ro improve readability and remove unnecessary constants
This commit is contained in:
Paul Plant 2024-04-20 13:00:28 +02:00
parent 2b327ac413
commit 6e0f8c8ad5
10 changed files with 289 additions and 362 deletions

View File

@ -55,6 +55,7 @@ extension XDripWidget.EntryView {
.font(.caption)
.foregroundStyle(.colorTertiary)
}
.padding(.top, 10)
}
.widgetBackground(backgroundView: Color.black)
}

View File

@ -56,6 +56,7 @@ extension XDripWidget.EntryView {
.font(.caption)
.foregroundStyle(.colorTertiary)
}
.padding(.top, 6)
}
.widgetBackground(backgroundView: Color.black)
}

View File

@ -50,6 +50,7 @@ extension XDripWidget.EntryView {
.font(.caption)
.foregroundStyle(.colorTertiary)
}
.padding(.top, 6)
}
.widgetBackground(backgroundView: Color.black)
}

View File

@ -178,7 +178,6 @@
D4E499AB277B43E3000F8CBA /* TreatmentCollection.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4E499AA277B43E3000F8CBA /* TreatmentCollection.swift */; };
D4E499AD277B4CE7000F8CBA /* DateOnly.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4E499AC277B4CE7000F8CBA /* DateOnly.swift */; };
D4FD899727772F9100689788 /* TreatmentEntryAccessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4FD899627772F9100689788 /* TreatmentEntryAccessor.swift */; };
E4B8C71E2B3EBEE3006375E2 /* GlucoseIntentResponseView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E4B8C71D2B3EBEE3006375E2 /* GlucoseIntentResponseView.swift */; };
E4C006212B3DE8EC00D59303 /* GlucoseIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = E4C006202B3DE8EC00D59303 /* GlucoseIntent.swift */; };
F227BF192B9DF76D00CEEAAD /* SettingsViewContactImageSettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F227BF182B9DF76D00CEEAAD /* SettingsViewContactImageSettingsViewModel.swift */; };
F227BF1C2B9E426B00CEEAAD /* ContactImageManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = F227BF1B2B9E426B00CEEAAD /* ContactImageManager.swift */; };
@ -992,7 +991,6 @@
D4E499AA277B43E3000F8CBA /* TreatmentCollection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TreatmentCollection.swift; sourceTree = "<group>"; };
D4E499AC277B4CE7000F8CBA /* DateOnly.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateOnly.swift; sourceTree = "<group>"; };
D4FD899627772F9100689788 /* TreatmentEntryAccessor.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TreatmentEntryAccessor.swift; sourceTree = "<group>"; };
E4B8C71D2B3EBEE3006375E2 /* GlucoseIntentResponseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseIntentResponseView.swift; sourceTree = "<group>"; };
E4C006202B3DE8EC00D59303 /* GlucoseIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseIntent.swift; sourceTree = "<group>"; };
E4D530622B418FF80018C6A4 /* AppShortcuts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppShortcuts.swift; sourceTree = "<group>"; };
F227BF182B9DF76D00CEEAAD /* SettingsViewContactImageSettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewContactImageSettingsViewModel.swift; sourceTree = "<group>"; };
@ -2051,7 +2049,6 @@
children = (
E4D530622B418FF80018C6A4 /* AppShortcuts.swift */,
E4C006202B3DE8EC00D59303 /* GlucoseIntent.swift */,
E4B8C71D2B3EBEE3006375E2 /* GlucoseIntentResponseView.swift */,
);
name = AppIntents;
sourceTree = "<group>";
@ -4340,7 +4337,6 @@
47B136DB2BC9BB55001E7ED3 /* ContactImageView.swift in Sources */,
F830993023C928E0005741DF /* WatlaaBluetoothTransmitterDelegate.swift in Sources */,
F8025C0A21D94FD700ECF0C0 /* CBManagerState.swift in Sources */,
E4B8C71E2B3EBEE3006375E2 /* GlucoseIntentResponseView.swift in Sources */,
47ADD2DF27FAF8630025E2F4 /* ChartPointsScatterDownTrianglesLayer.swift in Sources */,
F8F9722723A5915900C3F17D /* CGMBluconTransmitter.swift in Sources */,
F8E3A2A923D906C200E5E98A /* CalendarManager.swift in Sources */,

View File

@ -10,136 +10,115 @@ import SwiftUI
enum ConstantsGlucoseChartSwiftUI {
static let mmollToMgdl = 18.01801801801802
static let mgDlToMmoll = 0.0555
// ------------------------------------------
// ----- SwiftUI Glucose Chart --------------
// ------------------------------------------
// default chart properties for all chart types
static let yAxisLineSize: Double = 0.8
static let yAxisLabelOffsetX: CGFloat = 0
static let yAxisLabelOffsetY: CGFloat = 0
/// application name, appears in licenseInfo as title
static let applicationName: String = {
guard let dictionary = Bundle.main.infoDictionary else {return "unknown"}
guard let version = dictionary["CFBundleDisplayName"] as? String else {return "unknown"}
return version
}()
static let yAxisLowHighLineColor = Color(white: 0.7)
static let yAxisUrgentLowHighLineColor = Color(white: 0.6)
// live activity
static let xAxisGridLineColor = Color(white: 0.5)
static let xAxisLabelOffsetX: CGFloat = -12
static let xAxisLabelOffsetY: CGFloat = -2
static let xAxisIntervalBetweenValues: Int = 1
static let xAxisLabelFirstClippingInMinutes: Double = 8 * 60
static let xAxisLabelLastClippingInMinutes: Double = 12 * 60
static let cornerRadius: CGFloat = 2
// ------------------------------------------
// ----- Live Activities --------------------
// ------------------------------------------
// live activity (normal)
static let viewWidthLiveActivityNormal: CGFloat = 180
static let viewHeightLiveActivityNormal: CGFloat = 70
static let hoursToShowLiveActivityNormal: Double = 3
static let intervalBetweenXAxisValuesLiveActivityNormal: Int = 1
static let glucoseCircleDiameterLiveActivityNormal: Double = 36
// live activity (large)
static let viewWidthLiveActivityLarge: CGFloat = 340
static let viewHeightLiveActivityLarge: CGFloat = 90
static let hoursToShowLiveActivityLarge: Double = 8
static let intervalBetweenXAxisValuesLiveActivityLarge: Int = 1
static let glucoseCircleDiameterLiveActivityLarge: Double = 24
static let lowHighLineColorLiveActivity = Color(white: 0.6)
static let urgentLowHighLineLiveActivity = Color(white: 0.4)
static let xAxisGridLineColorLiveActivity = Color(white: 0.4)
static let relativeYAxisLineSizeLiveActivity: Double = 1
static let xAxisLabelOffsetLiveActivity: Double = -10
// dynamic island bottom (expanded)
static let viewWidthDynamicIsland: CGFloat = 330
static let viewHeightDynamicIsland: CGFloat = 70
static let lowHighLineColorDynamicIsland = Color(white: 0.6)
static let urgentLowHighLineColorDynamicIsland = Color(white: 0.4)
static let xAxisGridLineColorDynamicIsland = Color(white: 0.4)
static let hoursToShowDynamicIsland: Double = 12
static let intervalBetweenXAxisValuesDynamicIsland: Int = 2
static let glucoseCircleDiameterDynamicIsland: Double = 14
static let relativeYAxisLineSizeDynamicIsland: Double = 0.8
static let xAxisLabelOffsetDynamicIsland: Double = -10
// ------------------------------------------
// ----- Watch app --------------------------
// ------------------------------------------
// watch chart
static let viewWidthWatchApp: CGFloat = 190
static let viewHeightWatchApp: CGFloat = 90
static let hoursToShowWatchApp: Double = 4
static let glucoseCircleDiameterWatchApp: Double = 20
// watch chart sizes for smaller watches
static let viewWidthWatchAppSmall: CGFloat = 155
static let viewHeightWatchAppSmall: CGFloat = 75
static let lowHighLineColorWatchApp = Color(white: 0.6)
static let urgentLowHighLineColorWatchApp = Color(white: 0.4)
static let xAxisGridLineColorWatchApp = Color(white: 0.3)
static let hoursToShowWatchApp: Double = 4
static let intervalBetweenXAxisValuesWatchApp: Int = 1
static let glucoseCircleDiameterWatchApp: Double = 20
static let relativeYAxisLineSizeWatchApp: Double = 0.8
static let xAxisLabelOffsetWatchApp: Double = -10
// ------------------------------------------
// ----- Watch Widgets/Complications --------
// ------------------------------------------
// watch complication .accessoryRectangular chart
static let viewWidthWatchAccessoryRectangular: CGFloat = 180
static let viewHeightWatchAccessoryRectangular: CGFloat = 55
static let hoursToShowWatchAccessoryRectangular: Double = 5
static let glucoseCircleDiameterWatchAccessoryRectangular: Double = 14
// watch complication sizes for smaller watches
static let viewWidthWatchAccessoryRectangularSmall: CGFloat = 160
static let viewHeightWatchAccessoryRectangularSmall: CGFloat = 45
static let lowHighLineColorWatchAccessoryRectangular = Color(white: 0.7)
static let urgentLowHighLineColorWatchAccessoryRectangular = Color(white: 0.5)
static let xAxisGridLineColorWatchAccessoryRectangular = Color(white: 0.4)
static let hoursToShowWatchAccessoryRectangular: Double = 5
static let intervalBetweenXAxisValuesWatchAccessoryRectangular: Int = 1
static let glucoseCircleDiameterWatchAccessoryRectangular: Double = 14
static let relativeYAxisLineSizeWatchAccessoryRectangular: Double = 0.8
static let xAxisLabelOffsetWatchAccessoryRectangular: Double = -10
// ------------------------------------------
// ----- iOS Widgets ------------------------
// ------------------------------------------
// widget systemSmall chart
static let viewWidthWidgetSystemSmall: CGFloat = 120
static let viewHeightWidgetSystemSmall: CGFloat = 80
static let lowHighLineColorWidgetSystemSmall = Color(white: 0.6)
static let urgentLowHighLineColorWidgetSystemSmall = Color(white: 0.4)
static let xAxisGridLineColorWidgetSystemSmall = Color(white: 0.3)
static let hoursToShowWidgetSystemSmall: Double = 3
static let intervalBetweenXAxisValuesWidgetSystemSmall: Int = 1
static let glucoseCircleDiameterWidgetSystemSmall: Double = 20
static let relativeYAxisLineSizeWidgetSystemSmall: Double = 0.8
static let xAxisLabelOffsetWidgetSystemSmall: Double = -10
// widget systemMedium chart
static let viewWidthWidgetSystemMedium: CGFloat = 300
static let viewHeightWidgetSystemMedium: CGFloat = 80
static let lowHighLineColorWidgetSystemMedium = Color(white: 0.6)
static let urgentLowHighLineColorWidgetSystemMedium = Color(white: 0.4)
static let xAxisGridLineColorWidgetSystemMedium = Color(white: 0.3)
static let hoursToShowWidgetSystemMedium: Double = 8
static let intervalBetweenXAxisValuesWidgetSystemMedium: Int = 1
static let glucoseCircleDiameterWidgetSystemMedium: Double = 14
static let relativeYAxisLineSizeWidgetSystemMedium: Double = 0.8
static let xAxisLabelOffsetWidgetSystemMedium: Double = -10
// widget systemLarge chart
static let viewWidthWidgetSystemLarge: CGFloat = 300
static let viewHeightWidgetSystemLarge: CGFloat = 260
static let lowHighLineColorWidgetSystemLarge = Color(white: 0.6)
static let urgentLowHighLineColorWidgetSystemLarge = Color(white: 0.4)
static let xAxisGridLineColorWidgetSystemLarge = Color(white: 0.3)
static let viewHeightWidgetSystemLarge: CGFloat = 250
static let hoursToShowWidgetSystemLarge: Double = 4
static let intervalBetweenXAxisValuesWidgetSystemLarge: Int = 1
static let glucoseCircleDiameterWidgetSystemLarge: Double = 30
static let relativeYAxisLineSizeWidgetSystemLarge: Double = 0.8
static let xAxisLabelOffsetWidgetSystemLarge: Double = -10
// widget (lock screen) .accessoryRectangular chart
static let viewWidthWidgetAccessoryRectangular: CGFloat = 130
static let viewHeightWidgetAccessoryRectangular: CGFloat = 40
static let lowHighLineColorWidgetAccessoryRectangular = Color(white: 0.7)
static let urgentLowHighLineColorWidgetAccessoryRectangular = Color(white: 0.5)
static let xAxisGridLineColorWidgetAccessoryRectangular = Color(white: 0.4)
static let hoursToShowWidgetAccessoryRectangular: Double = 4
static let intervalBetweenXAxisValuesWidgetAccessoryRectangular: Int = 1
static let glucoseCircleDiameterWidgetAccessoryRectangular: Double = 14
static let relativeYAxisLineSizeWidgetAccessoryRectangular: Double = 0.8
static let xAxisLabelOffsetWidgetAccessoryRectangular: Double = -10
// ------------------------------------------
// ----- Siri Intent Chart ------------------
// ------------------------------------------
// siri glucose intent response chart
static let viewWidthWidgetSiriGlucoseIntent: CGFloat = 320
static let viewHeightWidgetSiriGlucoseIntent: CGFloat = 150
static let hoursToShowWidgetSiriGlucoseIntent: Double = 4
static let glucoseCircleDiameterSiriGlucoseIntent: Double = 20
static let cornerRadiusSiriGlucoseIntent: Double = 0
static let paddingSiriGlucoseIntent: Double = 10
static let backgroundColorSiriGlucoseIntent: Color = .black // Color(red: 0.18, green: 0.18, blue: 0.18) originally from gshaviv
}

View File

@ -20,4 +20,18 @@ extension View {
return background(backgroundView)
}
}
// https://www.avanderlee.com/swiftui/conditional-view-modifier/
/// Applies the given transform if the given condition evaluates to `true`.
/// - Parameters:
/// - condition: The condition to evaluate.
/// - transform: The transform to apply to the source `View`.
/// - Returns: Either the original `View` or the modified `View` if the condition is `true`.
@ViewBuilder func `if`<Content: View>(_ condition: @autoclosure () -> Bool, transform: (Self) -> Content) -> some View {
if condition() {
transform(self)
} else {
self
}
}
}

View File

@ -27,28 +27,53 @@ struct GlucoseIntent: AppIntent {
@MainActor
func perform() async throws -> some ReturnsValue<Double> & ProvidesDialog & ShowsSnippetView {
let coreDataManager = await CoreDataManager.create(for: ConstantsCoreData.modelName)
let bgReadingsAccessor = BgReadingsAccessor(coreDataManager: coreDataManager)
let bgReadings = bgReadingsAccessor.getLatestBgReadings(limit: nil, fromDate: Date(timeIntervalSinceNow: -14400), forSensor: nil, ignoreRawData: true, ignoreCalculatedValue: false).sorted { $0.timeStamp < $1.timeStamp }
guard let mostRecent = bgReadings.last else {
throw IntentError.message("No glucose data")
}
let value = UserDefaults.standard.bloodGlucoseUnitIsMgDl ? mostRecent.calculatedValue : (mostRecent.calculatedValue * ConstantsBloodGlucose.mgDlToMmoll)
let valueString = UserDefaults.standard.bloodGlucoseUnitIsMgDl ? value.formatted(.number.precision(.fractionLength(0))) : value.formatted(.number.precision(.fractionLength(1)))
let value = mostRecent.calculatedValue.mgdlToMmol(mgdl: UserDefaults.standard.bloodGlucoseUnitIsMgDl) // UserDefaults.standard.bloodGlucoseUnitIsMgDl ? mostRecent.calculatedValue : (mostRecent.calculatedValue * ConstantsBloodGlucose.mgDlToMmoll)
let valueString = value.bgValuetoString(mgdl: UserDefaults.standard.bloodGlucoseUnitIsMgDl) // UserDefaults.standard.bloodGlucoseUnitIsMgDl ? value.formatted(.number.precision(.fractionLength(0))) : value.formatted(.number.precision(.fractionLength(1)))
let trendDescription: LocalizedStringResource = switch mostRecent.slopeTrend() {
case .droppingFast: "dropping fast"
case .dropping: "dropping"
case .moderatelyDropping: "moderately dropping"
case .slowlyDropping: "slowly dropping"
case .stable: "stable"
case .moderatelyRising: "moderately rising"
case .slowlyRising: "slowly rising"
case .rising: "rising"
case .risingFast: "rising fast"
}
var bgReadingValues: [Double] = []
var bgReadingDates: [Date] = []
for bgReading in bgReadings {
bgReadingValues.append(bgReading.calculatedValue)
bgReadingDates.append(bgReading.timeStamp)
}
var dialogString: IntentDialog = "Your blood glucose is currently \(valueString) and \(trendDescription)"
let minutesAgo = (bgReadingDates.last?.timeIntervalSinceNow ?? 999 ) / 60
let minutesAgoString = abs(Int(minutesAgo))
if minutesAgo < -30 {
dialogString = "Sorry, there are no recent blood glucose values."
} else if minutesAgo < -7 {
dialogString = "\(minutesAgoString) minutes ago your blood glucose was \(valueString) "
}
return .result(
value: value,
dialog: "Your blood glucose level is \(valueString) and is \(trendDescription)",
view: GlucoseIntentResponseView(readings: bgReadings)
dialog: dialogString,
view: GlucoseChartView(glucoseChartType: .siriGlucoseIntent, bgReadingValues: bgReadingValues, bgReadingDates: bgReadingDates, isMgDl: UserDefaults.standard.bloodGlucoseUnitIsMgDl, urgentLowLimitInMgDl: UserDefaults.standard.urgentLowMarkValue, lowLimitInMgDl: UserDefaults.standard.lowMarkValue, highLimitInMgDl: UserDefaults.standard.highMarkValue, urgentHighLimitInMgDl: UserDefaults.standard.urgentHighMarkValue, liveActivitySize: nil, hoursToShowScalingHours: nil, glucoseCircleDiameterScalingHours: nil, overrideChartHeight: nil, overrideChartWidth: nil)
)
}
}
@ -81,9 +106,9 @@ enum IntentError: Error, CustomLocalizedStringResourceConvertible {
private enum Trend {
case droppingFast
case dropping
case moderatelyDropping
case slowlyDropping
case stable
case moderatelyRising
case slowlyRising
case rising
case risingFast
}
@ -91,13 +116,20 @@ private enum Trend {
private extension BgReading {
func slopeTrend() -> Trend {
switch calculatedValueSlope * 60000 {
case ..<(-2): .droppingFast
case -2 ..< -1: .dropping
case -1 ..< -0.5: .moderatelyDropping
case -0.5 ..< 0.5: .stable
case 0.5 ..< 1: .moderatelyRising
case 1 ..< 2: .rising
default: .risingFast
case ..<(-2):
.droppingFast
case -2 ..< -1:
.dropping
case -1 ..< -0.5:
.slowlyDropping
case -0.5 ..< 0.5:
.stable
case 0.5 ..< 1:
.slowlyRising
case 1 ..< 2:
.rising
default:
.risingFast
}
}
}

View File

@ -1,137 +0,0 @@
//
// GlucoseIntentResponseView.swift
// xdrip
//
// Created by Guy Shaviv on 29/12/2023.
// Copyright © 2023 Johan Degraeve. All rights reserved.
//
import Charts
import SwiftUI
@available(iOS 16, *)
struct GlucoseIntentResponseView: View {
let readings: [BgReading]
func symbolColor(value: Double) -> Color {
switch value {
case ..<UserDefaults.standard.urgentLowMarkValue:
.red
case UserDefaults.standard.urgentLowMarkValue..<UserDefaults.standard.lowMarkValue:
.yellow
case UserDefaults.standard.highMarkValue..<UserDefaults.standard.urgentHighMarkValue:
.yellow
case UserDefaults.standard.urgentHighMarkValue...:
.red
default:
.green
}
}
var body: some View {
let domain = min(readings.map(\.valueInUserUnits).min() ?? Double.greatestFiniteMagnitude, 70 * unitFactor) ... max(readings.map(\.valueInUserUnits).max() ?? -Double.greatestFiniteMagnitude, 180 * unitFactor)
Chart {
if domain.contains(UserDefaults.standard.urgentLowMarkValueInUserChosenUnit) {
RuleMark(y: .value("", UserDefaults.standard.urgentLowMarkValueInUserChosenUnit))
.lineStyle(StrokeStyle(lineWidth: 1, dash: [1, 3]))
.foregroundStyle(.red)
}
if domain.contains(UserDefaults.standard.urgentLowMarkValueInUserChosenUnit) {
RuleMark(y: .value("", UserDefaults.standard.urgentLowMarkValueInUserChosenUnit))
.lineStyle(StrokeStyle(lineWidth: 1, dash: [1, 3]))
.foregroundStyle(.red)
}
if domain.contains(UserDefaults.standard.lowMarkValueInUserChosenUnit) {
RuleMark(y: .value("", UserDefaults.standard.lowMarkValueInUserChosenUnit))
.lineStyle(StrokeStyle(lineWidth: 2, dash: [2, 4]))
.foregroundStyle(.yellow)
}
if domain.contains(UserDefaults.standard.highMarkValueInUserChosenUnit) {
RuleMark(y: .value("", UserDefaults.standard.highMarkValueInUserChosenUnit))
.lineStyle(StrokeStyle(lineWidth: 2, dash: [2, 4]))
.foregroundStyle(.yellow)
}
if domain.contains(UserDefaults.standard.targetMarkValueInUserChosenUnit) {
RuleMark(y: .value("", UserDefaults.standard.targetMarkValueInUserChosenUnit))
.lineStyle(StrokeStyle(lineWidth: 2, dash: [8, 8]))
.foregroundStyle(.green)
}
ForEach(readings, id: \.timeStamp) { reading in
PointMark(x: .value("Time", reading.timeStamp),
y: .value("BG", reading.valueInUserUnits))
.symbol(Circle())
.foregroundStyle(symbolColor(value: reading.calculatedValue))
}
}
.chartXAxis {
AxisMarks {
if let v = $0.as(Date.self) {
AxisValueLabel {
Text(v.formatted(.dateTime.hour()))
.foregroundStyle(Color.white)
}
AxisGridLine()
.foregroundStyle(Color.gray)
}
}
}
.chartYAxis {
AxisMarks(values: axisTicks(for: domain)) { value in
if let v = value.as(Double.self) {
AxisValueLabel {
Text(v.formatted(.number.precision(.significantDigits(UserDefaults.standard.bloodGlucoseUnitIsMgDl ? 0 : 1))))
.font(.caption2)
}
if !userValues.contains(v) {
AxisValueLabel()
.foregroundStyle(Color.white)
AxisGridLine()
.foregroundStyle(Color.white)
}
}
}
}
.chartYScale(domain: domain)
.aspectRatio(1.5, contentMode: .fit)
.padding()
.background {
Color(red: 0.18, green: 0.18, blue: 0.19) // Match: this will match how Siri presents the response dialog in dark mode
}
}
}
extension BgReading {
var valueInUserUnits: Double {
calculatedValue * unitFactor
}
}
private var unitFactor: Double {
UserDefaults.standard.bloodGlucoseUnitIsMgDl ? 1 : ConstantsBloodGlucose.mgDlToMmoll
}
private var userValues = [UserDefaults.standard.urgentLowMarkValueInUserChosenUnit,
UserDefaults.standard.lowMarkValueInUserChosenUnit,
UserDefaults.standard.targetMarkValueInUserChosenUnit,
UserDefaults.standard.highMarkValueInUserChosenUnit,
UserDefaults.standard.urgentHighMarkValueInUserChosenUnit]
private func axisTicks(for domain: ClosedRange<Double>) -> [Double] {
var values = [70, 180, 250].map { $0 * unitFactor }.filter { domain.contains($0) }
for v in userValues where domain.contains(v) {
if values.filter({ abs(v - $0) < 9.9 }).isEmpty {
values.append(v)
}
}
return values
}
extension [Double] {
var range: ClosedRange<Double> {
(self.min() ?? -Double.greatestFiniteMagnitude) ... (self.max() ?? Double.greatestFiniteMagnitude)
}
}

View File

@ -25,6 +25,7 @@ public enum GlucoseChartType: Int, CaseIterable {
case widgetSystemMedium = 5
case widgetSystemLarge = 6
case widgetAccessoryRectangular = 7
case siriGlucoseIntent = 8
var description: String {
switch self {
@ -44,9 +45,12 @@ public enum GlucoseChartType: Int, CaseIterable {
return "Widget Chart .systemLarge"
case .widgetAccessoryRectangular:
return "Widget Chart .accessoryRectangular"
case .siriGlucoseIntent:
return "Siri Glucose Intent Chart"
}
}
// MARK: - general chart properties
func viewSize(liveActivitySize: LiveActivitySize) -> (width: CGFloat, height: CGFloat) {
switch self {
@ -68,9 +72,11 @@ public enum GlucoseChartType: Int, CaseIterable {
case .widgetSystemMedium:
return (ConstantsGlucoseChartSwiftUI.viewWidthWidgetSystemMedium, ConstantsGlucoseChartSwiftUI.viewHeightWidgetSystemMedium)
case .widgetSystemLarge:
return (ConstantsGlucoseChartSwiftUI.viewWidthWidgetSystemMedium, ConstantsGlucoseChartSwiftUI.viewHeightWidgetSystemLarge)
return (ConstantsGlucoseChartSwiftUI.viewWidthWidgetSystemLarge, ConstantsGlucoseChartSwiftUI.viewHeightWidgetSystemLarge)
case .widgetAccessoryRectangular:
return (ConstantsGlucoseChartSwiftUI.viewWidthWidgetAccessoryRectangular, ConstantsGlucoseChartSwiftUI.viewHeightWidgetAccessoryRectangular)
case .siriGlucoseIntent:
return (ConstantsGlucoseChartSwiftUI.viewWidthWidgetSiriGlucoseIntent, ConstantsGlucoseChartSwiftUI.viewHeightWidgetSiriGlucoseIntent)
}
}
@ -97,33 +103,13 @@ public enum GlucoseChartType: Int, CaseIterable {
return ConstantsGlucoseChartSwiftUI.hoursToShowWidgetSystemLarge
case .widgetAccessoryRectangular:
return ConstantsGlucoseChartSwiftUI.hoursToShowWidgetAccessoryRectangular
case .siriGlucoseIntent:
return ConstantsGlucoseChartSwiftUI.hoursToShowWidgetSiriGlucoseIntent
}
}
func intervalBetweenAxisValues(liveActivitySize: LiveActivitySize) -> Int {
switch self {
case .liveActivity:
switch liveActivitySize {
case .large:
return ConstantsGlucoseChartSwiftUI.intervalBetweenXAxisValuesLiveActivityLarge
default:
return ConstantsGlucoseChartSwiftUI.intervalBetweenXAxisValuesLiveActivityNormal
}
case .dynamicIsland:
return ConstantsGlucoseChartSwiftUI.intervalBetweenXAxisValuesDynamicIsland
case .watchApp:
return ConstantsGlucoseChartSwiftUI.intervalBetweenXAxisValuesWatchApp
case .watchAccessoryRectangular:
return ConstantsGlucoseChartSwiftUI.intervalBetweenXAxisValuesWatchAccessoryRectangular
case .widgetSystemSmall:
return ConstantsGlucoseChartSwiftUI.intervalBetweenXAxisValuesWidgetSystemSmall
case .widgetSystemMedium:
return ConstantsGlucoseChartSwiftUI.intervalBetweenXAxisValuesWidgetSystemMedium
case .widgetSystemLarge:
return ConstantsGlucoseChartSwiftUI.intervalBetweenXAxisValuesWidgetSystemLarge
case .widgetAccessoryRectangular:
return ConstantsGlucoseChartSwiftUI.intervalBetweenXAxisValuesWidgetAccessoryRectangular
}
return ConstantsGlucoseChartSwiftUI.xAxisIntervalBetweenValues
}
func glucoseCircleDiameter(liveActivitySize: LiveActivitySize) -> Double {
@ -149,112 +135,120 @@ public enum GlucoseChartType: Int, CaseIterable {
return ConstantsGlucoseChartSwiftUI.glucoseCircleDiameterWidgetSystemLarge
case .widgetAccessoryRectangular:
return ConstantsGlucoseChartSwiftUI.glucoseCircleDiameterWidgetAccessoryRectangular
case .siriGlucoseIntent:
return ConstantsGlucoseChartSwiftUI.glucoseCircleDiameterSiriGlucoseIntent
}
}
var lowHighLineColor: Color {
func backgroundColor() -> Color {
switch self {
case .liveActivity:
return ConstantsGlucoseChartSwiftUI.lowHighLineColorLiveActivity
case .dynamicIsland:
return ConstantsGlucoseChartSwiftUI.lowHighLineColorDynamicIsland
case .watchApp:
return ConstantsGlucoseChartSwiftUI.lowHighLineColorWatchApp
case .watchAccessoryRectangular:
return ConstantsGlucoseChartSwiftUI.lowHighLineColorWatchAccessoryRectangular
case .widgetSystemSmall:
return ConstantsGlucoseChartSwiftUI.lowHighLineColorWidgetSystemSmall
case .widgetSystemMedium:
return ConstantsGlucoseChartSwiftUI.lowHighLineColorWidgetSystemMedium
case .widgetSystemLarge:
return ConstantsGlucoseChartSwiftUI.lowHighLineColorWidgetSystemLarge
case .widgetAccessoryRectangular:
return ConstantsGlucoseChartSwiftUI.lowHighLineColorWidgetAccessoryRectangular
case .siriGlucoseIntent:
return ConstantsGlucoseChartSwiftUI.backgroundColorSiriGlucoseIntent
default:
return .black
}
}
var urgentLowHighLineColor: Color {
func frame() -> Bool {
switch self {
case .liveActivity:
return ConstantsGlucoseChartSwiftUI.urgentLowHighLineLiveActivity
case .dynamicIsland:
return ConstantsGlucoseChartSwiftUI.urgentLowHighLineColorDynamicIsland
case .watchApp:
return ConstantsGlucoseChartSwiftUI.urgentLowHighLineColorWatchApp
case .watchAccessoryRectangular:
return ConstantsGlucoseChartSwiftUI.urgentLowHighLineColorWatchAccessoryRectangular
case .widgetSystemSmall:
return ConstantsGlucoseChartSwiftUI.urgentLowHighLineColorWidgetSystemSmall
case .widgetSystemMedium:
return ConstantsGlucoseChartSwiftUI.urgentLowHighLineColorWidgetSystemMedium
case .widgetSystemLarge:
return ConstantsGlucoseChartSwiftUI.urgentLowHighLineColorWidgetSystemLarge
case .widgetAccessoryRectangular:
return ConstantsGlucoseChartSwiftUI.urgentLowHighLineColorWidgetAccessoryRectangular
case .siriGlucoseIntent:
return false
default:
return true
}
}
var relativeYAxisLineSize: Double {
func aspectRatio() -> (enable: Bool, aspectRatio: CGFloat, contentMode: ContentMode) {
switch self {
case .liveActivity:
return ConstantsGlucoseChartSwiftUI.relativeYAxisLineSizeLiveActivity
case .dynamicIsland:
return ConstantsGlucoseChartSwiftUI.relativeYAxisLineSizeDynamicIsland
case .watchApp:
return ConstantsGlucoseChartSwiftUI.relativeYAxisLineSizeWatchApp
case .watchAccessoryRectangular:
return ConstantsGlucoseChartSwiftUI.relativeYAxisLineSizeWatchAccessoryRectangular
case .widgetSystemSmall:
return ConstantsGlucoseChartSwiftUI.relativeYAxisLineSizeWidgetSystemSmall
case .widgetSystemMedium:
return ConstantsGlucoseChartSwiftUI.relativeYAxisLineSizeWidgetSystemMedium
case .widgetSystemLarge:
return ConstantsGlucoseChartSwiftUI.relativeYAxisLineSizeWidgetSystemLarge
case .widgetAccessoryRectangular:
return ConstantsGlucoseChartSwiftUI.relativeYAxisLineSizeWidgetAccessoryRectangular
case .siriGlucoseIntent:
return (true, 1.5, .fit)
default:
return (false, 1, .fill) // use anything here after false as it won't be used
}
}
var xAxisLabelOffset: Double {
func cornerRadius() -> Double {
switch self {
case .liveActivity:
return ConstantsGlucoseChartSwiftUI.xAxisLabelOffsetLiveActivity
case .dynamicIsland:
return ConstantsGlucoseChartSwiftUI.xAxisLabelOffsetDynamicIsland
case .watchApp:
return ConstantsGlucoseChartSwiftUI.xAxisLabelOffsetWatchApp
case .watchAccessoryRectangular:
return ConstantsGlucoseChartSwiftUI.xAxisLabelOffsetWatchAccessoryRectangular
case .widgetSystemSmall:
return ConstantsGlucoseChartSwiftUI.xAxisLabelOffsetWidgetSystemSmall
case .widgetSystemMedium:
return ConstantsGlucoseChartSwiftUI.xAxisLabelOffsetWidgetSystemMedium
case .widgetSystemLarge:
return ConstantsGlucoseChartSwiftUI.xAxisLabelOffsetWidgetSystemLarge
case .widgetAccessoryRectangular:
return ConstantsGlucoseChartSwiftUI.xAxisLabelOffsetWidgetAccessoryRectangular
case .siriGlucoseIntent:
return ConstantsGlucoseChartSwiftUI.cornerRadiusSiriGlucoseIntent
default:
return ConstantsGlucoseChartSwiftUI.cornerRadius
}
}
var xAxisGridLineColor: Color {
func padding() -> (enable: Bool, padding: CGFloat) {
switch self {
case .liveActivity:
return ConstantsGlucoseChartSwiftUI.xAxisGridLineColorLiveActivity
case .dynamicIsland:
return ConstantsGlucoseChartSwiftUI.xAxisGridLineColorDynamicIsland
case .watchApp:
return ConstantsGlucoseChartSwiftUI.xAxisGridLineColorWatchApp
case .watchAccessoryRectangular:
return ConstantsGlucoseChartSwiftUI.xAxisGridLineColorWatchAccessoryRectangular
case .widgetSystemSmall:
return ConstantsGlucoseChartSwiftUI.xAxisGridLineColorWidgetSystemSmall
case .widgetSystemMedium:
return ConstantsGlucoseChartSwiftUI.xAxisGridLineColorWidgetSystemMedium
case .widgetSystemLarge:
return ConstantsGlucoseChartSwiftUI.xAxisGridLineColorWidgetSystemLarge
case .widgetAccessoryRectangular:
return ConstantsGlucoseChartSwiftUI.xAxisGridLineColorWidgetAccessoryRectangular
case .siriGlucoseIntent:
return (true, ConstantsGlucoseChartSwiftUI.paddingSiriGlucoseIntent)
default:
return (false, 0)
}
}
// MARK: - x axis properties
func xAxisShowLabels() -> Bool {
switch self {
case .siriGlucoseIntent, .widgetSystemLarge:
return true
default:
return false
}
}
func xAxisLabelEveryHours() -> Int {
switch self {
default:
return 1
}
}
func xAxisLabelOffsetX() -> CGFloat {
switch self {
case .siriGlucoseIntent, .widgetSystemLarge:
return ConstantsGlucoseChartSwiftUI.xAxisLabelOffsetX
default:
return 0
}
}
func xAxisLabelOffsetY() -> CGFloat {
switch self {
case .siriGlucoseIntent, .widgetSystemLarge:
return ConstantsGlucoseChartSwiftUI.xAxisLabelOffsetY
default:
return 0
}
}
// MARK: - y axis properties
func yAxisShowLabels() -> Visibility {
switch self {
case .siriGlucoseIntent, .widgetSystemLarge:
return .automatic
default:
return .hidden
}
}
func yAxisLabelOffsetX() -> CGFloat {
switch self {
case .siriGlucoseIntent, .widgetSystemLarge:
return ConstantsGlucoseChartSwiftUI.yAxisLabelOffsetX
default:
return 0
}
}
func yAxisLabelOffsetY() -> CGFloat {
switch self {
case .siriGlucoseIntent, .widgetSystemLarge:
return ConstantsGlucoseChartSwiftUI.yAxisLabelOffsetY
default:
return 0
}
}
}

View File

@ -16,7 +16,7 @@ struct GlucoseChartView: View {
var bgReadingValues: [Double]
var bgReadingDates: [Date]
let glucoseChartType: GlucoseChartType
let chartType: GlucoseChartType // shortened to chartType to make reading easier below
let isMgDl: Bool
let urgentLowLimitInMgDl: Double
let lowLimitInMgDl: Double
@ -30,7 +30,7 @@ struct GlucoseChartView: View {
init(glucoseChartType: GlucoseChartType, bgReadingValues: [Double]?, bgReadingDates: [Date]?, isMgDl: Bool, urgentLowLimitInMgDl: Double, lowLimitInMgDl: Double, highLimitInMgDl: Double, urgentHighLimitInMgDl: Double, liveActivitySize: LiveActivitySize?, hoursToShowScalingHours: Double?, glucoseCircleDiameterScalingHours: Double?, overrideChartHeight: Double?, overrideChartWidth: Double?) {
self.glucoseChartType = glucoseChartType
self.chartType = glucoseChartType
self.isMgDl = isMgDl
self.urgentLowLimitInMgDl = urgentLowLimitInMgDl
self.lowLimitInMgDl = lowLimitInMgDl
@ -40,15 +40,16 @@ struct GlucoseChartView: View {
// here we want to automatically set the hoursToShow based upon the chart type, but some chart instances might need
// this to be overriden such as for zooming in/out of the chart (i.e. the Watch App)
self.hoursToShow = hoursToShowScalingHours ?? glucoseChartType.hoursToShow(liveActivitySize: self.liveActivitySize)
self.hoursToShow = hoursToShowScalingHours ?? chartType.hoursToShow(liveActivitySize: self.liveActivitySize)
self.chartHeight = overrideChartHeight ?? glucoseChartType.viewSize(liveActivitySize: self.liveActivitySize).height
self.chartWidth = overrideChartWidth ?? glucoseChartType.viewSize(liveActivitySize: self.liveActivitySize).width
self.chartHeight = overrideChartHeight ?? chartType.viewSize(liveActivitySize: self.liveActivitySize).height
self.chartWidth = overrideChartWidth ?? chartType.viewSize(liveActivitySize: self.liveActivitySize).width
// apply a scale to the glucoseCircleDiameter if an override value is passed
self.glucoseCircleDiameter = glucoseChartType.glucoseCircleDiameter(liveActivitySize: self.liveActivitySize) * ((glucoseCircleDiameterScalingHours ?? self.hoursToShow) / self.hoursToShow)
self.glucoseCircleDiameter = chartType.glucoseCircleDiameter(liveActivitySize: self.liveActivitySize) * ((glucoseCircleDiameterScalingHours ?? self.hoursToShow) / self.hoursToShow)
// as all widget instances are passed 12 hours of bg values, we must initialize this instance to use only the amount of hours of value required by the glucoseChartType passed
// as all widget instances are passed 12 hours of bg values, we must initialize this instance to use only the amount of hours of value required by the chartType passed
self.bgReadingValues = []
self.bgReadingDates = []
@ -88,7 +89,7 @@ struct GlucoseChartView: View {
let mappingArray = Array(1...amountOfFullHours)
/// set the stride count interval to make sure we don't add too many labels to the x-axis if the user wants to view >6 hours
let intervalBetweenAxisValues: Int = glucoseChartType.intervalBetweenAxisValues(liveActivitySize: liveActivitySize)
let intervalBetweenAxisValues: Int = chartType.intervalBetweenAxisValues(liveActivitySize: liveActivitySize)
/// first, for each int in mappingArray, we create a Date, starting with the lower hour + 1 hour - we will create 5 in this example, starting with hour 08 (7 + 3600 seconds)
let startDateLower = Date(timeIntervalSinceReferenceDate:
@ -104,32 +105,33 @@ struct GlucoseChartView: View {
var body: some View {
let domain = (min((bgReadingValues.min() ?? 40), urgentLowLimitInMgDl) - 6) ... (max((bgReadingValues.max() ?? urgentHighLimitInMgDl), urgentHighLimitInMgDl) + 6)
let yAxisLineSize = ConstantsGlucoseChartSwiftUI.yAxisLineSize
Chart {
if domain.contains(urgentLowLimitInMgDl) {
RuleMark(y: .value("", urgentLowLimitInMgDl))
.lineStyle(StrokeStyle(lineWidth: 1 * glucoseChartType.relativeYAxisLineSize, dash: [2 * glucoseChartType.relativeYAxisLineSize, 6 * glucoseChartType.relativeYAxisLineSize]))
.foregroundStyle(glucoseChartType.urgentLowHighLineColor)
.lineStyle(StrokeStyle(lineWidth: yAxisLineSize, dash: [2 * yAxisLineSize, 6 * yAxisLineSize]))
.foregroundStyle(ConstantsGlucoseChartSwiftUI.yAxisUrgentLowHighLineColor)
}
if domain.contains(urgentHighLimitInMgDl) {
RuleMark(y: .value("", urgentHighLimitInMgDl))
.lineStyle(StrokeStyle(lineWidth: 1 * glucoseChartType.relativeYAxisLineSize, dash: [2 * glucoseChartType.relativeYAxisLineSize, 6 * glucoseChartType.relativeYAxisLineSize]))
.foregroundStyle(glucoseChartType.urgentLowHighLineColor)
.lineStyle(StrokeStyle(lineWidth: yAxisLineSize, dash: [2 * yAxisLineSize, 6 * yAxisLineSize]))
.foregroundStyle(ConstantsGlucoseChartSwiftUI.yAxisUrgentLowHighLineColor)
}
if domain.contains(lowLimitInMgDl) {
RuleMark(y: .value("", lowLimitInMgDl))
.lineStyle(StrokeStyle(lineWidth: 1 * glucoseChartType.relativeYAxisLineSize, dash: [4 * glucoseChartType.relativeYAxisLineSize, 3 * glucoseChartType.relativeYAxisLineSize]))
.foregroundStyle(glucoseChartType.lowHighLineColor)
.lineStyle(StrokeStyle(lineWidth: yAxisLineSize, dash: [4 * yAxisLineSize, 3 * yAxisLineSize]))
.foregroundStyle(ConstantsGlucoseChartSwiftUI.yAxisLowHighLineColor)
}
if domain.contains(highLimitInMgDl) {
RuleMark(y: .value("", highLimitInMgDl))
.lineStyle(StrokeStyle(lineWidth: 1 * glucoseChartType.relativeYAxisLineSize, dash: [4 * glucoseChartType.relativeYAxisLineSize, 3 * glucoseChartType.relativeYAxisLineSize]))
.foregroundStyle(glucoseChartType.lowHighLineColor)
.lineStyle(StrokeStyle(lineWidth: yAxisLineSize, dash: [4 * yAxisLineSize, 3 * yAxisLineSize]))
.foregroundStyle(ConstantsGlucoseChartSwiftUI.yAxisLowHighLineColor)
}
// add a phantom glucose point at the beginning of the timeline to fix the start point in case there are no glucose values at that time (for instances after starting a new sensor)
@ -157,15 +159,59 @@ struct GlucoseChartView: View {
.foregroundStyle(.clear)
}
.chartXAxis {
AxisMarks(values: .automatic(desiredCount: Int(hoursToShow))) {
if $0.as(Date.self) != nil {
AxisMarks(values: .stride(by: .hour, count: chartType.xAxisLabelEveryHours())) {
if let value = $0.as(Date.self) {
if chartType.xAxisShowLabels() {
AxisValueLabel {
let shouldHideLabel = abs(Date().distance(to: value)) < ConstantsGlucoseChartSwiftUI.xAxisLabelFirstClippingInMinutes || abs(Date().addingTimeInterval(-hoursToShow * 3600).distance(to: value)) < ConstantsGlucoseChartSwiftUI.xAxisLabelLastClippingInMinutes ? true : false
Text(!shouldHideLabel ? value.formatted(.dateTime.hour()) : "")
.foregroundStyle(Color(.colorSecondary))
.font(.footnote)
.offset(x: chartType.xAxisLabelOffsetX(), y: chartType.xAxisLabelOffsetY())
}
}
AxisGridLine()
.foregroundStyle(glucoseChartType.xAxisGridLineColor)
.foregroundStyle(ConstantsGlucoseChartSwiftUI.xAxisGridLineColor)
}
}
}
.chartYAxis(.hidden)
.chartYAxis {
AxisMarks(values: [lowLimitInMgDl, highLimitInMgDl]) {
if let value = $0.as(Double.self) {
AxisValueLabel {
Text(value.mgdlToMmolAndToString(mgdl: isMgDl))
.foregroundStyle(Color(.colorPrimary))
.font(.footnote)
.offset(x: chartType.yAxisLabelOffsetX(), y: chartType.yAxisLabelOffsetY())
}
}
}
AxisMarks(values: [urgentLowLimitInMgDl, urgentHighLimitInMgDl]) {
if let value = $0.as(Double.self) {
AxisValueLabel {
Text(value.mgdlToMmolAndToString(mgdl: isMgDl))
.foregroundStyle(Color(.colorSecondary))
.font(.footnote)
.offset(x: chartType.yAxisLabelOffsetX(), y: chartType.yAxisLabelOffsetY())
}
}
}
}
.if({ return chartType.frame() ? true : false }()) { view in
view.frame(width: chartWidth, height: chartHeight)
}
.if({ return chartType.aspectRatio().enable ? true : false }()) { view in
view.aspectRatio(chartType.aspectRatio().aspectRatio, contentMode: chartType.aspectRatio().contentMode)
}
.if({ return chartType.padding().enable ? true : false }()) { view in
view.padding(chartType.padding().padding)
}
.chartYAxis(chartType.yAxisShowLabels())
.chartYScale(domain: domain)
.frame(width: chartWidth, height: chartHeight)
.background(chartType.backgroundColor())
.clipShape(RoundedRectangle(cornerRadius: chartType.cornerRadius()))
}
}