import ActivityKit import Charts import Foundation import SwiftUI import WidgetKit private enum Size { case minimal case compact case expanded } enum GlucoseUnits: String, Equatable { case mgdL = "mg/dL" case mmolL = "mmol/L" static let exchangeRate: Decimal = 0.0555 } enum GlucoseColorScheme: String, Equatable { case staticColor case dynamicColor } func rounded(_ value: Decimal, scale: Int, roundingMode: NSDecimalNumber.RoundingMode) -> Decimal { var result = Decimal() var toRound = value NSDecimalRound(&result, &toRound, scale, roundingMode) return result } extension Int { var asMmolL: Decimal { rounded(Decimal(self) * GlucoseUnits.exchangeRate, scale: 1, roundingMode: .plain) } var formattedAsMmolL: String { NumberFormatter.glucoseFormatter.string(from: asMmolL as NSDecimalNumber) ?? "\(asMmolL)" } } extension Decimal { var asMmolL: Decimal { rounded(self * GlucoseUnits.exchangeRate, scale: 1, roundingMode: .plain) } var asMgdL: Decimal { rounded(self / GlucoseUnits.exchangeRate, scale: 0, roundingMode: .plain) } var formattedAsMmolL: String { NumberFormatter.glucoseFormatter.string(from: asMmolL as NSDecimalNumber) ?? "\(asMmolL)" } } extension NumberFormatter { static let glucoseFormatter: NumberFormatter = { let formatter = NumberFormatter() formatter.locale = Locale.current formatter.numberStyle = .decimal formatter.minimumFractionDigits = 1 formatter.maximumFractionDigits = 1 return formatter }() } struct LiveActivity: Widget { // Helper function to decide how to pick the glucose color func getDynamicGlucoseColor( glucoseValue: Decimal, highGlucoseColorValue: Decimal, lowGlucoseColorValue: Decimal, targetGlucose: Decimal, glucoseColorScheme: String, offset: Decimal ) -> Color { // Convert Decimal to Int for high and low glucose values let lowGlucose = lowGlucoseColorValue - offset let highGlucose = highGlucoseColorValue + (offset * 1.75) let targetGlucose = targetGlucose // Only use calculateHueBasedGlucoseColor if the setting is enabled in preferences if glucoseColorScheme == "dynamicColor" { return calculateHueBasedGlucoseColor( glucoseValue: glucoseValue, highGlucose: highGlucose, lowGlucose: lowGlucose, targetGlucose: targetGlucose ) } // Otheriwse, use static (orange = high, red = low, green = range) else { if glucoseValue > highGlucose { return Color.orange } else if glucoseValue < lowGlucose { return Color.red } else { return Color.green } } } // Dynamic color - Define the hue values for the key points // We'll shift color gradually one glucose point at a time // We'll shift through the rainbow colors of ROY-G-BIV from low to high // Start at red for lowGlucose, green for targetGlucose, and violet for highGlucose func calculateHueBasedGlucoseColor( glucoseValue: Decimal, highGlucose: Decimal, lowGlucose: Decimal, targetGlucose: Decimal ) -> Color { let redHue: CGFloat = 0.0 / 360.0 // 0 degrees let greenHue: CGFloat = 120.0 / 360.0 // 120 degrees let purpleHue: CGFloat = 270.0 / 360.0 // 270 degrees // Calculate the hue based on the bgLevel var hue: CGFloat if glucoseValue <= lowGlucose { hue = redHue } else if glucoseValue >= highGlucose { hue = purpleHue } else if glucoseValue <= targetGlucose { // Interpolate between red and green let ratio = CGFloat(truncating: (glucoseValue - lowGlucose) / (targetGlucose - lowGlucose) as NSNumber) hue = redHue + ratio * (greenHue - redHue) } else { // Interpolate between green and purple let ratio = CGFloat(truncating: (glucoseValue - targetGlucose) / (highGlucose - targetGlucose) as NSNumber) hue = greenHue + ratio * (purpleHue - greenHue) } // Return the color with full saturation and brightness let color = Color(hue: hue, saturation: 0.6, brightness: 0.9) return color } private let dateFormatter: DateFormatter = { var f = DateFormatter() f.dateStyle = .none f.timeStyle = .short return f }() private var bolusFormatter: NumberFormatter { let formatter = NumberFormatter() formatter.numberStyle = .decimal formatter.maximumFractionDigits = 2 formatter.decimalSeparator = "." return formatter } private var carbsFormatter: NumberFormatter { let formatter = NumberFormatter() formatter.numberStyle = .decimal formatter.maximumFractionDigits = 0 return formatter } @ViewBuilder private func changeLabel(context: ActivityViewContext) -> some View { if !context.state.change.isEmpty { Text(context.state.change).foregroundStyle(.primary.opacity(0.5)).font(.headline) .strikethrough(context.isStale, pattern: .solid, color: .red.opacity(0.6)) } else { Text("--") } } @ViewBuilder func mealLabel( context: ActivityViewContext, additionalState: LiveActivityAttributes.ContentAdditionalState ) -> some View { HStack { VStack(alignment: .leading, spacing: 1, content: { HStack { Image(systemName: "fork.knife") .font(.title3) .foregroundColor(.yellow) } HStack { Image(systemName: "syringe.fill") .font(.title3) .foregroundColor(.blue) } }) VStack(alignment: .trailing, spacing: 1, content: { HStack { Text( carbsFormatter.string(from: additionalState.cob as NSNumber) ?? "--" ).fontWeight(.bold).font(.headline).strikethrough(context.isStale, pattern: .solid, color: .red.opacity(0.6)) Text(NSLocalizedString(" g", comment: "grams of carbs")).foregroundStyle(.secondary).font(.footnote) } HStack { Text( bolusFormatter.string(from: additionalState.iob as NSNumber) ?? "--" ).font(.headline).fontWeight(.bold).strikethrough(context.isStale, pattern: .solid, color: .red.opacity(0.6)) Text(NSLocalizedString(" U", comment: "Unit in number of units delivered (keep the space character!)")) .foregroundStyle(.secondary).font(.footnote) } }) VStack(alignment: .trailing, spacing: 1, content: { if additionalState.isOverrideActive { Image(systemName: "person.crop.circle.fill.badge.checkmark") .font(.title3) } }) } } @ViewBuilder func trend(context: ActivityViewContext) -> some View { if context.isStale { Text("--") } else { if let trendSystemImage = context.state.direction { Image(systemName: trendSystemImage) } } } private func expiredLabel() -> some View { Text("Live Activity Expired. Open Trio to Refresh") .minimumScaleFactor(0.01) } private func updatedLabel(context: ActivityViewContext) -> Text { let text = Text("Updated: \(dateFormatter.string(from: context.state.date))") .font(.caption2) if context.isStale { // foregroundStyle is not available in , additionalState: LiveActivityAttributes.ContentAdditionalState ) -> some View { HStack(alignment: .center) { Text(context.state.bg) .fontWeight(.bold) .font(.largeTitle) .strikethrough(context.isStale, pattern: .solid, color: .red.opacity(0.6)) Text(additionalState.unit).foregroundStyle(.secondary).font(.subheadline).offset(x: -5, y: 5) } } private func bgAndTrend( context: ActivityViewContext, size: Size, hasStaticColorScheme: Bool, glucoseColor: Color ) -> (some View, Int) { var characters = 0 let bgText = context.state.bg characters += bgText.count // narrow mode is for the minimal dynamic island view // there is not enough space to show all three arrow there // and everything has to be squeezed together to some degree // only display the first arrow character var directionText: String? if let direction = context.state.direction { if size == .compact { directionText = String(direction[direction.startIndex ... direction.startIndex]) } else { directionText = direction } characters += directionText!.count } let spacing: CGFloat switch size { case .minimal: spacing = -1 case .compact: spacing = 0 case .expanded: spacing = 3 } let stack = HStack(spacing: spacing) { Text(bgText) .foregroundColor(hasStaticColorScheme ? .primary : glucoseColor) .strikethrough(context.isStale, pattern: .solid, color: .red.opacity(0.6)) if let direction = directionText { let text = Text(direction) switch size { case .minimal: let scaledText = text.scaleEffect(x: 0.7, y: 0.7, anchor: .leading) scaledText.foregroundStyle(hasStaticColorScheme ? .primary : glucoseColor) case .compact: text.scaleEffect(x: 0.8, y: 0.8, anchor: .leading).padding(.trailing, -3) case .expanded: text.scaleEffect(x: 0.7, y: 0.7, anchor: .leading).padding(.trailing, -5) } } } .foregroundColor(context.isStale ? Color.primary.opacity(0.5) : (hasStaticColorScheme ? .primary : glucoseColor)) return (stack, characters) } @ViewBuilder func trendArrow( context: ActivityViewContext, additionalState: LiveActivityAttributes.ContentAdditionalState ) -> some View { let gradient = LinearGradient(colors: [ Color(red: 0.6235294118, green: 0.4235294118, blue: 0.9803921569), Color(red: 0.4862745098, green: 0.5450980392, blue: 0.9529411765), Color(red: 0.3411764706, green: 0.6666666667, blue: 0.9254901961), Color(red: 0.262745098, green: 0.7333333333, blue: 0.9137254902) ], startPoint: .leading, endPoint: .trailing) if !context.isStale { Image(systemName: "arrow.right") .font(.title) .rotationEffect(.degrees(additionalState.rotationDegrees)) .foregroundStyle(gradient) } } @ViewBuilder func chart( context: ActivityViewContext, additionalState: LiveActivityAttributes.ContentAdditionalState ) -> some View { if context.isStale { Text("No data available") } else { // Determine scale let min = min(additionalState.chart.min() ?? 45, 40) - 20 let max = max(additionalState.chart.max() ?? 270, 300) + 50 let yAxisRuleMarkMin = additionalState.unit == "mg/dL" ? context.state.lowGlucose : context.state.lowGlucose .asMmolL let yAxisRuleMarkMax = additionalState.unit == "mg/dL" ? context.state.highGlucose : context.state.highGlucose .asMmolL // TODO: grab target from proper targets, do not hard code. let highColor = getDynamicGlucoseColor( glucoseValue: yAxisRuleMarkMax, highGlucoseColorValue: yAxisRuleMarkMax, lowGlucoseColorValue: yAxisRuleMarkMin, targetGlucose: additionalState.unit == "mg/dL" ? Decimal(90) : Decimal(90).asMmolL, glucoseColorScheme: context.state.glucoseColorScheme, offset: additionalState.unit == "mg/dL" ? Decimal(20) : Decimal(20).asMmolL ) // TODO: grab target from proper targets, do not hard code. let lowColor = getDynamicGlucoseColor( glucoseValue: yAxisRuleMarkMin, highGlucoseColorValue: yAxisRuleMarkMax, lowGlucoseColorValue: yAxisRuleMarkMin, targetGlucose: additionalState.unit == "mg/dL" ? Decimal(90) : Decimal(90).asMmolL, glucoseColorScheme: context.state.glucoseColorScheme, offset: additionalState.unit == "mg/dL" ? Decimal(20) : Decimal(20).asMmolL ) Chart { RuleMark(y: .value("High", yAxisRuleMarkMax)) .foregroundStyle(highColor) .lineStyle(.init(lineWidth: 0.5, dash: [5])) RuleMark(y: .value("Low", yAxisRuleMarkMin)) .foregroundStyle(lowColor) .lineStyle(.init(lineWidth: 0.5, dash: [5])) ForEach(additionalState.chart.indices, id: \.self) { index in let currentValue = additionalState.chart[index] let displayValue = additionalState.unit == "mg/dL" ? currentValue : currentValue.asMmolL // TODO: grab target from proper targets, do not hard code. let pointMarkColor = self.getDynamicGlucoseColor( glucoseValue: currentValue, highGlucoseColorValue: context.state.highGlucose, lowGlucoseColorValue: context.state.lowGlucose, targetGlucose: 90, glucoseColorScheme: context.state.glucoseColorScheme, offset: 20 ) let chartDate = additionalState.chartDate[index] ?? Date() let pointMark = PointMark( x: .value("Time", chartDate), y: .value("Value", displayValue) ).symbolSize(15) pointMark.foregroundStyle(pointMarkColor) } } .chartYAxis { AxisMarks(position: .trailing) { _ in AxisGridLine(stroke: .init(lineWidth: 0.2, dash: [2, 3])).foregroundStyle(Color.white) AxisValueLabel().foregroundStyle(.secondary).font(.footnote) } } .chartYScale(domain: additionalState.unit == "mg/dL" ? min ... max : min.asMmolL ... max.asMmolL) .chartXAxis { AxisMarks(position: .automatic) { _ in AxisGridLine(stroke: .init(lineWidth: 0.2, dash: [2, 3])).foregroundStyle(Color.white) } } } } @ViewBuilder func content(context: ActivityViewContext) -> some View { let hasStaticColorScheme = context.state.glucoseColorScheme == "staticColor" // TODO: grab target from proper targets, do not hard code. let glucoseColor = getDynamicGlucoseColor( glucoseValue: Decimal(string: context.state.bg) ?? 100, highGlucoseColorValue: context.state.detailedViewState?.unit == "mg/dL" ? context.state.highGlucose : context.state .highGlucose.asMmolL, lowGlucoseColorValue: context.state.detailedViewState?.unit == "mg/dL" ? context.state.lowGlucose : context.state .lowGlucose.asMmolL, targetGlucose: context.state.detailedViewState?.unit == "mg/dL" ? 90 : 90.asMmolL, glucoseColorScheme: context.state.glucoseColorScheme, offset: context.state.detailedViewState?.unit == "mg/dL" ? 20 : 20.asMmolL ) if let detailedViewState = context.state.detailedViewState { HStack(spacing: 12) { chart(context: context, additionalState: detailedViewState) .frame(maxWidth: UIScreen.main.bounds.width / 1.8) VStack(alignment: .leading) { Spacer() bgLabel(context: context, additionalState: detailedViewState) HStack { changeLabel(context: context) trendArrow(context: context, additionalState: detailedViewState) } mealLabel(context: context, additionalState: detailedViewState).padding(.bottom, 8) updatedLabel(context: context).padding(.bottom, 10) } } .privacySensitive() .padding(.all, 14) .imageScale(.small) .foregroundColor(Color.white) .activityBackgroundTint(Color.black.opacity(0.8)) } else { Group { if context.state.isInitialState { // add vertical and horizontal spacers around the label to ensure that the live activity view gets filled completely HStack { Spacer() VStack { Spacer() expiredLabel() Spacer() } Spacer() } } else { HStack(spacing: 3) { bgAndTrend( context: context, size: .expanded, hasStaticColorScheme: hasStaticColorScheme, glucoseColor: glucoseColor ).0.font(.title) Spacer() VStack(alignment: .trailing, spacing: 5) { changeLabel(context: context).font(.title3) .foregroundStyle(hasStaticColorScheme ? .primary : glucoseColor) updatedLabel(context: context).font(.caption) .foregroundStyle( hasStaticColorScheme ? .primary .opacity(0.7) : glucoseColor ) } } } } .privacySensitive() .padding(.all, 15) // Semantic BackgroundStyle and Color values work here. They adapt to the given interface style (light mode, dark mode) // Semantic UIColors do NOT (as of iOS 17.1.1). Like UIColor.systemBackgroundColor (it does not adapt to changes of the interface style) // The colorScheme environment varaible that is usually used to detect dark mode does NOT work here (it reports false values) .foregroundStyle(Color.primary) .background(BackgroundStyle.background.opacity(0.4)) .activityBackgroundTint(Color.clear) } } func dynamicIsland(context: ActivityViewContext) -> DynamicIsland { let glucoseValueForColor = context.state.bg let highGlucose = context.state.highGlucose let lowGlucose = context.state.lowGlucose let hasStaticColorScheme = context.state.glucoseColorScheme == "staticColor" // TODO: grab target from proper targets, do not hard code. let glucoseColor = getDynamicGlucoseColor( glucoseValue: Decimal(string: glucoseValueForColor) ?? 100, highGlucoseColorValue: context.state.detailedViewState?.unit == "mg/dL" ? highGlucose : highGlucose.asMmolL, lowGlucoseColorValue: context.state.detailedViewState?.unit == "mg/dL" ? lowGlucose : lowGlucose.asMmolL, targetGlucose: context.state.detailedViewState?.unit == "mg/dL" ? 90 : 90.asMmolL, glucoseColorScheme: context.state.glucoseColorScheme, offset: context.state.detailedViewState?.unit == "mg/dL" ? 20 : 20.asMmolL ) return DynamicIsland { DynamicIslandExpandedRegion(.leading) { bgAndTrend( context: context, size: .expanded, hasStaticColorScheme: hasStaticColorScheme, glucoseColor: glucoseColor ).0.font(.title2).padding(.leading, 5) } DynamicIslandExpandedRegion(.trailing) { changeLabel(context: context).font(.title2).padding(.trailing, 5) .foregroundStyle(hasStaticColorScheme ? .primary : glucoseColor) } DynamicIslandExpandedRegion(.bottom) { if context.state.isInitialState { expiredLabel() } else if let detailedViewState = context.state.detailedViewState { chart(context: context, additionalState: detailedViewState) } else { Group { updatedLabel(context: context).font(.caption).foregroundStyle(Color.secondary) } .frame( maxHeight: .infinity, alignment: .bottom ) } } DynamicIslandExpandedRegion(.center) { if context.state.detailedViewState != nil { updatedLabel(context: context).font(.caption).foregroundStyle(Color.secondary) } } } compactLeading: { bgAndTrend(context: context, size: .compact, hasStaticColorScheme: hasStaticColorScheme, glucoseColor: glucoseColor).0 .padding(.leading, 4) } compactTrailing: { changeLabel(context: context).padding(.trailing, 4).foregroundStyle(hasStaticColorScheme ? .primary : glucoseColor) } minimal: { let (_label, characterCount) = bgAndTrend( context: context, size: .minimal, hasStaticColorScheme: hasStaticColorScheme, glucoseColor: glucoseColor ) let label = _label.padding(.leading, 7).padding(.trailing, 3) if characterCount < 4 { label } else if characterCount < 5 { label.fontWidth(.condensed) } else { label.fontWidth(.compressed) } } .widgetURL(URL(string: "Trio://")) .keylineTint(hasStaticColorScheme ? Color.purple : glucoseColor) .contentMargins(.horizontal, 0, for: .minimal) .contentMargins(.trailing, 0, for: .compactLeading) .contentMargins(.leading, 0, for: .compactTrailing) } var body: some WidgetConfiguration { ActivityConfiguration(for: LiveActivityAttributes.self, content: self.content, dynamicIsland: self.dynamicIsland) } } private extension LiveActivityAttributes { static var preview: LiveActivityAttributes { LiveActivityAttributes(startDate: Date()) } } private extension LiveActivityAttributes.ContentState { // 0 is the widest digit. Use this to get an upper bound on text width. // Use mmol/l notation with decimal point as well for the same reason, it uses up to 4 characters, while mg/dl uses up to 3 static var testWide: LiveActivityAttributes.ContentState { LiveActivityAttributes.ContentState( bg: 00.0.description, direction: "→", change: "+0.0", date: Date(), highGlucose: 180, lowGlucose: 70, glucoseColorScheme: "staticColor", detailedViewState: nil, isInitialState: false ) } static var testVeryWide: LiveActivityAttributes.ContentState { LiveActivityAttributes.ContentState( bg: "00.0", direction: "↑↑", change: "+0.0", date: Date(), highGlucose: 180, lowGlucose: 70, glucoseColorScheme: "staticColor", detailedViewState: nil, isInitialState: false ) } static var testSuperWide: LiveActivityAttributes.ContentState { LiveActivityAttributes.ContentState( bg: "00.0", direction: "↑↑↑", change: "+0.0", date: Date(), highGlucose: 180, lowGlucose: 70, glucoseColorScheme: "staticColor", detailedViewState: nil, isInitialState: false ) } // 2 characters for BG, 1 character for change is the minimum that will be shown static var testNarrow: LiveActivityAttributes.ContentState { LiveActivityAttributes.ContentState( bg: "00", direction: "↑", change: "+0", date: Date(), highGlucose: 180, lowGlucose: 70, glucoseColorScheme: "staticColor", detailedViewState: nil, isInitialState: false ) } static var testMedium: LiveActivityAttributes.ContentState { LiveActivityAttributes.ContentState( bg: "000", direction: "↗︎", change: "+00", date: Date(), highGlucose: 180, lowGlucose: 70, glucoseColorScheme: "staticColor", detailedViewState: nil, isInitialState: false ) } static var testExpired: LiveActivityAttributes.ContentState { LiveActivityAttributes.ContentState( bg: "--", direction: nil, change: "--", date: Date().addingTimeInterval(-60 * 60), highGlucose: 180, lowGlucose: 70, glucoseColorScheme: "staticColor", detailedViewState: nil, isInitialState: true ) } } @available(iOS 17.0, iOSApplicationExtension 17.0, *) #Preview("Notification", as: .content, using: LiveActivityAttributes.preview) { LiveActivity() } contentStates: { LiveActivityAttributes.ContentState.testSuperWide LiveActivityAttributes.ContentState.testVeryWide LiveActivityAttributes.ContentState.testWide LiveActivityAttributes.ContentState.testMedium LiveActivityAttributes.ContentState.testNarrow LiveActivityAttributes.ContentState.testExpired }