LAAppGroupSettings.swift 6.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224
  1. // LoopFollow
  2. // LAAppGroupSettings.swift
  3. import Foundation
  4. // MARK: - Slot option enum
  5. /// One displayable metric that can occupy a slot in the Live Activity 2×2 grid.
  6. ///
  7. /// - `.none` is the empty/blank state — leaves the slot visually empty.
  8. /// - Optional cases (isOptional == true) may display "—" for Dexcom-only users
  9. /// whose setup does not provide that metric.
  10. /// - All values are read from GlucoseSnapshot at render time inside the widget
  11. /// extension; no additional App Group reads are required per slot.
  12. enum LiveActivitySlotOption: String, CaseIterable, Codable {
  13. // Core glucose
  14. case none
  15. case delta
  16. case projectedBG
  17. case minMax
  18. // Loop metrics
  19. case iob
  20. case cob
  21. case recBolus
  22. case autosens
  23. case tdd
  24. // Pump / device
  25. case basal
  26. case pump
  27. case pumpBattery
  28. case battery
  29. case target
  30. case isf
  31. case carbRatio
  32. // Ages
  33. case sage
  34. case cage
  35. case iage
  36. // Other
  37. case carbsToday
  38. case override
  39. case profile
  40. /// Human-readable label shown in the slot picker in Settings.
  41. var displayName: String {
  42. switch self {
  43. case .none: "Empty"
  44. case .delta: "Delta"
  45. case .projectedBG: "Projected BG"
  46. case .minMax: "Min/Max"
  47. case .iob: "IOB"
  48. case .cob: "COB"
  49. case .recBolus: "Rec. Bolus"
  50. case .autosens: "Autosens"
  51. case .tdd: "TDD"
  52. case .basal: "Basal"
  53. case .pump: "Pump"
  54. case .pumpBattery: "Pump Battery"
  55. case .battery: "Battery"
  56. case .target: "Target"
  57. case .isf: "ISF"
  58. case .carbRatio: "CR"
  59. case .sage: "SAGE"
  60. case .cage: "CAGE"
  61. case .iage: "IAGE"
  62. case .carbsToday: "Carbs today"
  63. case .override: "Override"
  64. case .profile: "Profile"
  65. }
  66. }
  67. /// Short label used inside the MetricBlock on the Live Activity card.
  68. var gridLabel: String {
  69. switch self {
  70. case .none: ""
  71. case .delta: "Delta"
  72. case .projectedBG: "Proj"
  73. case .minMax: "Min/Max"
  74. case .iob: "IOB"
  75. case .cob: "COB"
  76. case .recBolus: "Rec."
  77. case .autosens: "Sens"
  78. case .tdd: "TDD"
  79. case .basal: "Basal"
  80. case .pump: "Pump"
  81. case .pumpBattery: "Pump%"
  82. case .battery: "Bat."
  83. case .target: "Target"
  84. case .isf: "ISF"
  85. case .carbRatio: "CR"
  86. case .sage: "SAGE"
  87. case .cage: "CAGE"
  88. case .iage: "IAGE"
  89. case .carbsToday: "Carbs"
  90. case .override: "Ovrd"
  91. case .profile: "Prof"
  92. }
  93. }
  94. /// True when the value is a glucose measurement and should be followed by
  95. /// the user's preferred unit label (mg/dL or mmol/L) in compact displays.
  96. var isGlucoseUnit: Bool {
  97. switch self {
  98. case .projectedBG, .delta, .minMax, .target, .isf: return true
  99. default: return false
  100. }
  101. }
  102. /// True when the underlying value may be nil (e.g. Dexcom-only users who have
  103. /// no Loop data). The widget renders "—" in those cases.
  104. var isOptional: Bool {
  105. switch self {
  106. case .none, .delta: false
  107. default: true
  108. }
  109. }
  110. }
  111. // MARK: - Default slot assignments
  112. enum LiveActivitySlotDefaults {
  113. /// Top-left slot
  114. static let slot1: LiveActivitySlotOption = .iob
  115. /// Bottom-left slot
  116. static let slot2: LiveActivitySlotOption = .cob
  117. /// Top-right slot
  118. static let slot3: LiveActivitySlotOption = .projectedBG
  119. /// Bottom-right slot — intentionally empty until the user configures it
  120. static let slot4: LiveActivitySlotOption = .none
  121. /// Small widget (CarPlay / Watch Smart Stack) right slot
  122. static let smallWidgetSlot: LiveActivitySlotOption = .projectedBG
  123. static var all: [LiveActivitySlotOption] {
  124. [slot1, slot2, slot3, slot4]
  125. }
  126. }
  127. // MARK: - App Group settings
  128. /// Minimal App Group settings needed by the Live Activity UI.
  129. ///
  130. /// We keep this separate from Storage.shared to avoid target-coupling and
  131. /// ensure the widget extension reads the same values as the app.
  132. enum LAAppGroupSettings {
  133. private enum Keys {
  134. static let lowLineMgdl = "la.lowLine.mgdl"
  135. static let highLineMgdl = "la.highLine.mgdl"
  136. static let slots = "la.slots"
  137. static let smallWidgetSlot = "la.smallWidgetSlot"
  138. static let displayName = "la.displayName"
  139. static let showDisplayName = "la.showDisplayName"
  140. }
  141. private static var defaults: UserDefaults? {
  142. UserDefaults(suiteName: AppGroupID.current())
  143. }
  144. // MARK: - Thresholds (Write)
  145. static func setThresholds(lowMgdl: Double, highMgdl: Double) {
  146. defaults?.set(lowMgdl, forKey: Keys.lowLineMgdl)
  147. defaults?.set(highMgdl, forKey: Keys.highLineMgdl)
  148. }
  149. // MARK: - Thresholds (Read)
  150. static func thresholdsMgdl(fallbackLow: Double = 70, fallbackHigh: Double = 180) -> (low: Double, high: Double) {
  151. let low = defaults?.object(forKey: Keys.lowLineMgdl) as? Double ?? fallbackLow
  152. let high = defaults?.object(forKey: Keys.highLineMgdl) as? Double ?? fallbackHigh
  153. return (low, high)
  154. }
  155. // MARK: - Slot configuration (Write)
  156. /// Persists a 4-slot configuration to the App Group container.
  157. /// - Parameter slots: Array of exactly 4 `LiveActivitySlotOption` values;
  158. /// extra elements are ignored, missing elements are filled with `.none`.
  159. static func setSlots(_ slots: [LiveActivitySlotOption]) {
  160. let raw = slots.prefix(4).map(\.rawValue)
  161. defaults?.set(raw, forKey: Keys.slots)
  162. }
  163. // MARK: - Slot configuration (Read)
  164. /// Returns the current 4-slot configuration, falling back to defaults
  165. /// if no configuration has been saved yet.
  166. static func slots() -> [LiveActivitySlotOption] {
  167. guard let raw = defaults?.stringArray(forKey: Keys.slots), raw.count == 4 else {
  168. return LiveActivitySlotDefaults.all
  169. }
  170. return raw.map { LiveActivitySlotOption(rawValue: $0) ?? .none }
  171. }
  172. // MARK: - Small widget slot (Write)
  173. static func setSmallWidgetSlot(_ slot: LiveActivitySlotOption) {
  174. defaults?.set(slot.rawValue, forKey: Keys.smallWidgetSlot)
  175. }
  176. // MARK: - Small widget slot (Read)
  177. static func smallWidgetSlot() -> LiveActivitySlotOption {
  178. guard let raw = defaults?.string(forKey: Keys.smallWidgetSlot) else {
  179. return LiveActivitySlotDefaults.smallWidgetSlot
  180. }
  181. return LiveActivitySlotOption(rawValue: raw) ?? LiveActivitySlotDefaults.smallWidgetSlot
  182. }
  183. // MARK: - Display Name
  184. static func setDisplayName(_ name: String, show: Bool) {
  185. defaults?.set(name, forKey: Keys.displayName)
  186. defaults?.set(show, forKey: Keys.showDisplayName)
  187. }
  188. static func displayName() -> String {
  189. defaults?.string(forKey: Keys.displayName) ?? "LoopFollow"
  190. }
  191. static func showDisplayName() -> Bool {
  192. defaults?.bool(forKey: Keys.showDisplayName) ?? false
  193. }
  194. }