// // LiveActivity+Helper.swift // LiveActivityExtension // // Created by Cengiz Deniz on 17.10.24. // import ActivityKit import Charts import SwiftUI import WidgetKit 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 }() } extension Color { // Helper function to decide how to pick the glucose color static func getDynamicGlucoseColor( glucoseValue: Decimal, highGlucoseColorValue: Decimal, lowGlucoseColorValue: Decimal, targetGlucose: Decimal, glucoseColorScheme: String ) -> Color { // Only use calculateHueBasedGlucoseColor if the setting is enabled in preferences if glucoseColorScheme == "dynamicColor" { return calculateHueBasedGlucoseColor( glucoseValue: glucoseValue, highGlucose: highGlucoseColorValue, lowGlucose: lowGlucoseColorValue, targetGlucose: targetGlucose ) } // Otheriwse, use static (orange = high, red = low, green = range) else { if glucoseValue >= highGlucoseColorValue { return Color.orange } else if glucoseValue <= lowGlucoseColorValue { 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 private static 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 } } func bgAndTrend( context: ActivityViewContext, size: Size, glucoseColor: Color ) -> (some View, Int) { let hasStaticColorScheme = context.state.glucoseColorScheme == "staticColor" 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 and make it red in case there were more characters var directionText: String? if let direction = context.state.direction { if size == .compact || size == .minimal { 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) .foregroundStyle(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) } } }.foregroundStyle(hasStaticColorScheme ? .primary : glucoseColor) .strikethrough(context.isStale, pattern: .solid, color: .red.opacity(0.6)) return (stack, characters) } private struct LiveActivityWatchOS: EnvironmentKey { // Value to add support for older iOS version (17 and lower) in order to keep using the ActivityFamily class static let defaultValue = false } public extension EnvironmentValues { var isWatchOS: Bool { get { self[LiveActivityWatchOS.self] } set { self[LiveActivityWatchOS.self] = newValue } } } @available(iOS 18, *) struct LiveActivityWatchOSModifier: ViewModifier { @Environment(\.activityFamily) var activityFamily func body(content: Content) -> some View { content.environment(\.isWatchOS, activityFamily == .small) } } extension View { @ViewBuilder func addIsWatchOS() -> some View { if #available(iOS 18, *) { modifier(LiveActivityWatchOSModifier()) } else { self } } @ViewBuilder func addLiveActivityModifiers(isWatchOS: Bool) -> some View { modifier(LiveActivityModifiers(isWatchOS: isWatchOS)) } } struct LiveActivityModifiers: ViewModifier { let isWatchOS: Bool func body(content: Content) -> some View { content .padding(.all, isWatchOS ? 10 : 14) .frame(minHeight: 0, maxHeight: .infinity) .privacySensitive() // 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 variable does work here, but BackgroundStyle gives us this functionality for free .foregroundStyle(Color.primary) .background(BackgroundStyle.background.opacity(isWatchOS ? 1 : 0.4)) .activityBackgroundTint(isWatchOS ? .black : Color.clear) } }