LiveActivity+Helper.swift 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253
  1. //
  2. // LiveActivity+Helper.swift
  3. // LiveActivityExtension
  4. //
  5. // Created by Cengiz Deniz on 17.10.24.
  6. //
  7. import ActivityKit
  8. import Charts
  9. import SwiftUI
  10. import WidgetKit
  11. enum Size {
  12. case minimal
  13. case compact
  14. case expanded
  15. }
  16. enum GlucoseUnits: String, Equatable {
  17. case mgdL = "mg/dL"
  18. case mmolL = "mmol/L"
  19. static let exchangeRate: Decimal = 0.0555
  20. }
  21. enum GlucoseColorScheme: String, Equatable {
  22. case staticColor
  23. case dynamicColor
  24. }
  25. func rounded(_ value: Decimal, scale: Int, roundingMode: NSDecimalNumber.RoundingMode) -> Decimal {
  26. var result = Decimal()
  27. var toRound = value
  28. NSDecimalRound(&result, &toRound, scale, roundingMode)
  29. return result
  30. }
  31. extension Int {
  32. var asMmolL: Decimal {
  33. rounded(Decimal(self) * GlucoseUnits.exchangeRate, scale: 1, roundingMode: .plain)
  34. }
  35. var formattedAsMmolL: String {
  36. NumberFormatter.glucoseFormatter.string(from: asMmolL as NSDecimalNumber) ?? "\(asMmolL)"
  37. }
  38. }
  39. extension Decimal {
  40. var asMmolL: Decimal {
  41. rounded(self * GlucoseUnits.exchangeRate, scale: 1, roundingMode: .plain)
  42. }
  43. var asMgdL: Decimal {
  44. rounded(self / GlucoseUnits.exchangeRate, scale: 0, roundingMode: .plain)
  45. }
  46. var formattedAsMmolL: String {
  47. NumberFormatter.glucoseFormatter.string(from: asMmolL as NSDecimalNumber) ?? "\(asMmolL)"
  48. }
  49. }
  50. extension NumberFormatter {
  51. static let glucoseFormatter: NumberFormatter = {
  52. let formatter = NumberFormatter()
  53. formatter.locale = Locale.current
  54. formatter.numberStyle = .decimal
  55. formatter.minimumFractionDigits = 1
  56. formatter.maximumFractionDigits = 1
  57. return formatter
  58. }()
  59. }
  60. extension Color {
  61. // Static Glucose Color Scheme band colors — needs to be kept in sync with DynamicGlucoseColor.swift
  62. static let staticLow = Color(hue: 0.0 / 360.0, saturation: 0.6, brightness: 0.9)
  63. static let staticInRange = Color(hue: 120.0 / 360.0, saturation: 0.6, brightness: 0.9)
  64. static let staticHigh = Color(hue: 270.0 / 360.0, saturation: 0.6, brightness: 0.9)
  65. // Helper function to decide how to pick the glucose color
  66. static func getDynamicGlucoseColor(
  67. glucoseValue: Decimal,
  68. highGlucoseColorValue: Decimal,
  69. lowGlucoseColorValue: Decimal,
  70. targetGlucose: Decimal,
  71. glucoseColorScheme: String
  72. ) -> Color {
  73. // Only use calculateHueBasedGlucoseColor if the setting is enabled in preferences
  74. if glucoseColorScheme == "dynamicColor" {
  75. return calculateHueBasedGlucoseColor(
  76. glucoseValue: glucoseValue,
  77. highGlucose: highGlucoseColorValue,
  78. lowGlucose: lowGlucoseColorValue,
  79. targetGlucose: targetGlucose
  80. )
  81. }
  82. // Otherwise, use static colors
  83. else {
  84. if glucoseValue >= highGlucoseColorValue {
  85. return Color.staticHigh
  86. } else if glucoseValue <= lowGlucoseColorValue {
  87. return Color.staticLow
  88. } else {
  89. return Color.staticInRange
  90. }
  91. }
  92. }
  93. // Dynamic color - Define the hue values for the key points
  94. // We'll shift color gradually one glucose point at a time
  95. // We'll shift through the rainbow colors of ROY-G-BIV from low to high
  96. // Start at red for lowGlucose, green for targetGlucose, and violet for highGlucose
  97. private static func calculateHueBasedGlucoseColor(
  98. glucoseValue: Decimal,
  99. highGlucose: Decimal,
  100. lowGlucose: Decimal,
  101. targetGlucose: Decimal
  102. ) -> Color {
  103. let redHue: CGFloat = 0.0 / 360.0 // 0 degrees
  104. let greenHue: CGFloat = 120.0 / 360.0 // 120 degrees
  105. let purpleHue: CGFloat = 270.0 / 360.0 // 270 degrees
  106. // Calculate the hue based on the bgLevel
  107. var hue: CGFloat
  108. if glucoseValue <= lowGlucose {
  109. hue = redHue
  110. } else if glucoseValue >= highGlucose {
  111. hue = purpleHue
  112. } else if glucoseValue <= targetGlucose {
  113. // Interpolate between red and green
  114. let ratio = CGFloat(truncating: (glucoseValue - lowGlucose) / (targetGlucose - lowGlucose) as NSNumber)
  115. hue = redHue + ratio * (greenHue - redHue)
  116. } else {
  117. // Interpolate between green and purple
  118. let ratio = CGFloat(truncating: (glucoseValue - targetGlucose) / (highGlucose - targetGlucose) as NSNumber)
  119. hue = greenHue + ratio * (purpleHue - greenHue)
  120. }
  121. // Return the color with full saturation and brightness
  122. let color = Color(hue: hue, saturation: 0.6, brightness: 0.9)
  123. return color
  124. }
  125. }
  126. func bgAndTrend(
  127. context: ActivityViewContext<LiveActivityAttributes>,
  128. size: Size,
  129. glucoseColor: Color
  130. ) -> (some View, Int) {
  131. let hasStaticColorScheme = context.state.glucoseColorScheme == "staticColor"
  132. var characters = 0
  133. let bgText = context.state.bg
  134. characters += bgText.count
  135. // narrow mode is for the minimal dynamic island view
  136. // there is not enough space to show all three arrow there
  137. // and everything has to be squeezed together to some degree
  138. // only display the first arrow character and make it red in case there were more characters
  139. var directionText: String?
  140. if let direction = context.state.direction {
  141. if size == .compact || size == .minimal {
  142. directionText = String(direction[direction.startIndex ... direction.startIndex])
  143. } else {
  144. directionText = direction
  145. }
  146. characters += directionText!.count
  147. }
  148. let spacing: CGFloat
  149. switch size {
  150. case .minimal: spacing = -1
  151. case .compact: spacing = 0
  152. case .expanded: spacing = 3
  153. }
  154. let stack = HStack(spacing: spacing) {
  155. Text(bgText)
  156. .foregroundStyle(hasStaticColorScheme ? .primary : glucoseColor)
  157. .strikethrough(context.isStale, pattern: .solid, color: .red.opacity(0.6))
  158. if let direction = directionText {
  159. let text = Text(direction)
  160. switch size {
  161. case .minimal:
  162. let scaledText = text.scaleEffect(x: 0.7, y: 0.7, anchor: .leading)
  163. scaledText.foregroundStyle(hasStaticColorScheme ? .primary : glucoseColor)
  164. case .compact:
  165. text.scaleEffect(x: 0.8, y: 0.8, anchor: .leading).padding(.trailing, -3)
  166. case .expanded:
  167. text.scaleEffect(x: 0.7, y: 0.7, anchor: .leading).padding(.trailing, -5)
  168. }
  169. }
  170. }.foregroundStyle(hasStaticColorScheme ? .primary : glucoseColor)
  171. .strikethrough(context.isStale, pattern: .solid, color: .red.opacity(0.6))
  172. return (stack, characters)
  173. }
  174. private struct LiveActivityWatchOS: EnvironmentKey {
  175. // Value to add support for older iOS version (17 and lower) in order to keep using the ActivityFamily class
  176. static let defaultValue = false
  177. }
  178. public extension EnvironmentValues {
  179. var isWatchOS: Bool {
  180. get { self[LiveActivityWatchOS.self] }
  181. set { self[LiveActivityWatchOS.self] = newValue }
  182. }
  183. }
  184. @available(iOS 18, *) struct LiveActivityWatchOSModifier: ViewModifier {
  185. @Environment(\.activityFamily) var activityFamily
  186. func body(content: Content) -> some View {
  187. content.environment(\.isWatchOS, activityFamily == .small)
  188. }
  189. }
  190. extension View {
  191. @ViewBuilder func addIsWatchOS() -> some View {
  192. if #available(iOS 18, *) {
  193. modifier(LiveActivityWatchOSModifier())
  194. } else {
  195. self
  196. }
  197. }
  198. @ViewBuilder func addLiveActivityModifiers(isWatchOS: Bool) -> some View {
  199. modifier(LiveActivityModifiers(isWatchOS: isWatchOS))
  200. }
  201. }
  202. struct LiveActivityModifiers: ViewModifier {
  203. let isWatchOS: Bool
  204. func body(content: Content) -> some View {
  205. content
  206. .padding(.all, isWatchOS ? 10 : 14)
  207. .frame(minHeight: 0, maxHeight: .infinity)
  208. .privacySensitive()
  209. // Semantic BackgroundStyle and Color values work here. They adapt to the given interface style (light mode, dark
  210. // mode)
  211. // Semantic UIColors do NOT (as of iOS 17.1.1). Like UIColor.systemBackgroundColor (it does not adapt to changes of
  212. // the interface style)
  213. // The colorScheme environment variable does work here, but BackgroundStyle gives us this functionality for free
  214. .foregroundStyle(Color.primary)
  215. .background(BackgroundStyle.background.opacity(isWatchOS ? 1 : 0.4))
  216. .activityBackgroundTint(isWatchOS ? .black : Color.clear)
  217. }
  218. }