LiveActivity+Helper.swift 8.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248
  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. // Helper function to decide how to pick the glucose color
  62. static func getDynamicGlucoseColor(
  63. glucoseValue: Decimal,
  64. highGlucoseColorValue: Decimal,
  65. lowGlucoseColorValue: Decimal,
  66. targetGlucose: Decimal,
  67. glucoseColorScheme: String
  68. ) -> Color {
  69. // Only use calculateHueBasedGlucoseColor if the setting is enabled in preferences
  70. if glucoseColorScheme == "dynamicColor" {
  71. return calculateHueBasedGlucoseColor(
  72. glucoseValue: glucoseValue,
  73. highGlucose: highGlucoseColorValue,
  74. lowGlucose: lowGlucoseColorValue,
  75. targetGlucose: targetGlucose
  76. )
  77. }
  78. // Otheriwse, use static (orange = high, red = low, green = range)
  79. else {
  80. if glucoseValue >= highGlucoseColorValue {
  81. return Color.orange
  82. } else if glucoseValue <= lowGlucoseColorValue {
  83. return Color.red
  84. } else {
  85. return Color.green
  86. }
  87. }
  88. }
  89. // Dynamic color - Define the hue values for the key points
  90. // We'll shift color gradually one glucose point at a time
  91. // We'll shift through the rainbow colors of ROY-G-BIV from low to high
  92. // Start at red for lowGlucose, green for targetGlucose, and violet for highGlucose
  93. private static func calculateHueBasedGlucoseColor(
  94. glucoseValue: Decimal,
  95. highGlucose: Decimal,
  96. lowGlucose: Decimal,
  97. targetGlucose: Decimal
  98. ) -> Color {
  99. let redHue: CGFloat = 0.0 / 360.0 // 0 degrees
  100. let greenHue: CGFloat = 120.0 / 360.0 // 120 degrees
  101. let purpleHue: CGFloat = 270.0 / 360.0 // 270 degrees
  102. // Calculate the hue based on the bgLevel
  103. var hue: CGFloat
  104. if glucoseValue <= lowGlucose {
  105. hue = redHue
  106. } else if glucoseValue >= highGlucose {
  107. hue = purpleHue
  108. } else if glucoseValue <= targetGlucose {
  109. // Interpolate between red and green
  110. let ratio = CGFloat(truncating: (glucoseValue - lowGlucose) / (targetGlucose - lowGlucose) as NSNumber)
  111. hue = redHue + ratio * (greenHue - redHue)
  112. } else {
  113. // Interpolate between green and purple
  114. let ratio = CGFloat(truncating: (glucoseValue - targetGlucose) / (highGlucose - targetGlucose) as NSNumber)
  115. hue = greenHue + ratio * (purpleHue - greenHue)
  116. }
  117. // Return the color with full saturation and brightness
  118. let color = Color(hue: hue, saturation: 0.6, brightness: 0.9)
  119. return color
  120. }
  121. }
  122. func bgAndTrend(
  123. context: ActivityViewContext<LiveActivityAttributes>,
  124. size: Size,
  125. glucoseColor: Color
  126. ) -> (some View, Int) {
  127. let hasStaticColorScheme = context.state.glucoseColorScheme == "staticColor"
  128. var characters = 0
  129. let bgText = context.state.bg
  130. characters += bgText.count
  131. // narrow mode is for the minimal dynamic island view
  132. // there is not enough space to show all three arrow there
  133. // and everything has to be squeezed together to some degree
  134. // only display the first arrow character and make it red in case there were more characters
  135. var directionText: String?
  136. if let direction = context.state.direction {
  137. if size == .compact || size == .minimal {
  138. directionText = String(direction[direction.startIndex ... direction.startIndex])
  139. } else {
  140. directionText = direction
  141. }
  142. characters += directionText!.count
  143. }
  144. let spacing: CGFloat
  145. switch size {
  146. case .minimal: spacing = -1
  147. case .compact: spacing = 0
  148. case .expanded: spacing = 3
  149. }
  150. let stack = HStack(spacing: spacing) {
  151. Text(bgText)
  152. .foregroundStyle(hasStaticColorScheme ? .primary : glucoseColor)
  153. .strikethrough(context.isStale, pattern: .solid, color: .red.opacity(0.6))
  154. if let direction = directionText {
  155. let text = Text(direction)
  156. switch size {
  157. case .minimal:
  158. let scaledText = text.scaleEffect(x: 0.7, y: 0.7, anchor: .leading)
  159. scaledText.foregroundStyle(hasStaticColorScheme ? .primary : glucoseColor)
  160. case .compact:
  161. text.scaleEffect(x: 0.8, y: 0.8, anchor: .leading).padding(.trailing, -3)
  162. case .expanded:
  163. text.scaleEffect(x: 0.7, y: 0.7, anchor: .leading).padding(.trailing, -5)
  164. }
  165. }
  166. }.foregroundStyle(hasStaticColorScheme ? .primary : glucoseColor)
  167. .strikethrough(context.isStale, pattern: .solid, color: .red.opacity(0.6))
  168. return (stack, characters)
  169. }
  170. private struct LiveActivityWatchOS: EnvironmentKey {
  171. // Value to add support for older iOS version (17 and lower) in order to keep using the ActivityFamily class
  172. static let defaultValue = false
  173. }
  174. public extension EnvironmentValues {
  175. var isWatchOS: Bool {
  176. get { self[LiveActivityWatchOS.self] }
  177. set { self[LiveActivityWatchOS.self] = newValue }
  178. }
  179. }
  180. @available(iOS 18, *) struct LiveActivityWatchOSModifier: ViewModifier {
  181. @Environment(\.activityFamily) var activityFamily
  182. func body(content: Content) -> some View {
  183. content.environment(\.isWatchOS, activityFamily == .small)
  184. }
  185. }
  186. extension View {
  187. @ViewBuilder func addIsWatchOS() -> some View {
  188. if #available(iOS 18, *) {
  189. modifier(LiveActivityWatchOSModifier())
  190. } else {
  191. self
  192. }
  193. }
  194. @ViewBuilder func addLiveActivityModifiers(isWatchOS: Bool) -> some View {
  195. modifier(LiveActivityModifiers(isWatchOS: isWatchOS))
  196. }
  197. }
  198. struct LiveActivityModifiers: ViewModifier {
  199. let isWatchOS: Bool
  200. func body(content: Content) -> some View {
  201. content
  202. .padding(.all, isWatchOS ? 10 : 14)
  203. .frame(minHeight: 0, maxHeight: .infinity)
  204. .privacySensitive()
  205. // Semantic BackgroundStyle and Color values work here. They adapt to the given interface style (light mode, dark
  206. // mode)
  207. // Semantic UIColors do NOT (as of iOS 17.1.1). Like UIColor.systemBackgroundColor (it does not adapt to changes of
  208. // the interface style)
  209. // The colorScheme environment variable does work here, but BackgroundStyle gives us this functionality for free
  210. .foregroundStyle(Color.primary)
  211. .background(BackgroundStyle.background.opacity(isWatchOS ? 1 : 0.4))
  212. .activityBackgroundTint(isWatchOS ? .black : Color.clear)
  213. }
  214. }