AlarmsStepView.swift 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261
  1. // LoopFollow
  2. // AlarmsStepView.swift
  3. import HealthKit
  4. import SwiftUI
  5. /// The alarms phase: one recommended alarm per page, each with a toggle and a few
  6. /// of its most useful settings. The number of pages depends on the data source
  7. /// (Dexcom-only followers don't see loop/pump alarms), which the phase reports via
  8. /// `phaseProgress` so the overall progress bar stays accurate.
  9. struct AlarmsStepView: View {
  10. @ObservedObject var viewModel: OnboardingViewModel
  11. /// Page index into `offeredIndices`.
  12. @State private var index = 0
  13. /// Indices into `viewModel.seedAlarms` that are offered for this data source.
  14. private var offeredIndices: [Int] {
  15. viewModel.seedAlarms.indices.filter { viewModel.isOffered(viewModel.seedAlarms[$0]) }
  16. }
  17. var body: some View {
  18. VStack(spacing: 0) {
  19. if let seedIndex = offeredIndices[safe: index] {
  20. alarmPage(seedIndex: seedIndex)
  21. }
  22. OnboardingNavFooter(
  23. continueEnabled: true,
  24. showBack: true,
  25. onBack: goBack,
  26. onContinue: goForward
  27. )
  28. }
  29. .onAppear(perform: reportProgress)
  30. .onChange(of: index) { _ in reportProgress() }
  31. }
  32. private func reportProgress() {
  33. viewModel.phaseProgress = .init(page: index, count: offeredIndices.count)
  34. }
  35. private func goForward() {
  36. if index < offeredIndices.count - 1 {
  37. withAnimation(.easeInOut(duration: 0.25)) { index += 1 }
  38. } else {
  39. viewModel.advance()
  40. }
  41. }
  42. private func goBack() {
  43. if index > 0 {
  44. withAnimation(.easeInOut(duration: 0.25)) { index -= 1 }
  45. } else {
  46. viewModel.goBack()
  47. }
  48. }
  49. // MARK: - Page
  50. private func alarmPage(seedIndex: Int) -> some View {
  51. let seed = $viewModel.seedAlarms[seedIndex]
  52. let display = viewModel.seedAlarms[seedIndex]
  53. return Form {
  54. Section {
  55. EmptyView()
  56. } header: {
  57. VStack(spacing: 8) {
  58. Image(systemName: display.icon)
  59. .font(.system(size: 40, weight: .semibold))
  60. .foregroundStyle(Color.accentColor)
  61. Text(display.title)
  62. .font(.title2.weight(.bold))
  63. Text(display.detail)
  64. .font(.subheadline)
  65. .foregroundColor(.secondary)
  66. .multilineTextAlignment(.center)
  67. }
  68. .frame(maxWidth: .infinity)
  69. .textCase(nil)
  70. .padding(.bottom, 8)
  71. }
  72. .listRowInsets(EdgeInsets())
  73. .listRowBackground(Color.clear)
  74. Section {
  75. Toggle("Enable this alarm", isOn: seed.isEnabled)
  76. if seed.wrappedValue.isEnabled {
  77. controls(for: seed)
  78. }
  79. } footer: {
  80. if seed.wrappedValue.isEnabled, let text = explanation(for: seed.wrappedValue) {
  81. Text(text)
  82. }
  83. }
  84. }
  85. }
  86. // MARK: - Per-alarm controls
  87. @ViewBuilder
  88. private func controls(for seed: Binding<OnboardingViewModel.SeedAlarm>) -> some View {
  89. switch seed.wrappedValue.type {
  90. case .low:
  91. bgPicker(seed, title: "Alert below", range: 40 ... 150, keyPath: \.belowBG)
  92. intStepper(seed, label: "Warn early by", range: 0 ... 30, step: 5, unit: "min", keyPath: \.predictiveMinutes)
  93. case .high:
  94. bgPicker(seed, title: "Alert above", range: 120 ... 350, keyPath: \.aboveBG)
  95. intStepper(seed, label: "Only after high for", range: 0 ... 60, step: 5, unit: "min", keyPath: \.persistentMinutes)
  96. case .fastDrop:
  97. bgPicker(seed, title: "Drop per reading", range: 3 ... 54, keyPath: \.delta)
  98. intStepper(seed, label: "Readings in a row", range: 1 ... 3, step: 1, unit: "", keyPath: \.monitoringWindow)
  99. case .missedReading:
  100. doubleStepper(seed, label: "No reading for", range: 11 ... 121, step: 5, unit: "min", keyPath: \.threshold)
  101. case .notLooping:
  102. doubleStepper(seed, label: "No loop for", range: 16 ... 61, step: 5, unit: "min", keyPath: \.threshold)
  103. case .battery:
  104. doubleStepper(seed, label: "At or below", range: 0 ... 100, step: 5, unit: "%", keyPath: \.threshold)
  105. case .iob:
  106. doubleStepper(seed, label: "Alert above", range: 0 ... 30, step: 1, unit: "U", keyPath: \.threshold)
  107. case .cob:
  108. doubleStepper(seed, label: "Alert above", range: 0 ... 200, step: 5, unit: "g", keyPath: \.threshold)
  109. case .sensorChange:
  110. doubleStepper(seed, label: "Remind after", range: 1 ... 15, step: 1, unit: "days", keyPath: \.threshold)
  111. activePicker(seed)
  112. case .pumpChange:
  113. doubleStepper(seed, label: "Remind after", range: 1 ... 7, step: 1, unit: "days", keyPath: \.threshold)
  114. activePicker(seed)
  115. case .pump:
  116. doubleStepper(seed, label: "Alert below", range: 0 ... 100, step: 5, unit: "U", keyPath: \.threshold)
  117. default:
  118. EmptyView()
  119. }
  120. }
  121. private func bgPicker(
  122. _ seed: Binding<OnboardingViewModel.SeedAlarm>,
  123. title: String,
  124. range: ClosedRange<Double>,
  125. keyPath: WritableKeyPath<Alarm, Double?>
  126. ) -> some View {
  127. BGPicker(title: title, range: range, value: doubleBinding(seed, keyPath: keyPath))
  128. }
  129. private func doubleStepper(
  130. _ seed: Binding<OnboardingViewModel.SeedAlarm>,
  131. label: String,
  132. range: ClosedRange<Double>,
  133. step: Double,
  134. unit: String,
  135. keyPath: WritableKeyPath<Alarm, Double?>
  136. ) -> some View {
  137. let value = doubleBinding(seed, keyPath: keyPath)
  138. return Stepper(value: value, in: range, step: step) {
  139. labelRow(label, value: "\(formatted(value.wrappedValue)) \(unit)")
  140. }
  141. }
  142. private func intStepper(
  143. _ seed: Binding<OnboardingViewModel.SeedAlarm>,
  144. label: String,
  145. range: ClosedRange<Int>,
  146. step: Int,
  147. unit: String,
  148. keyPath: WritableKeyPath<Alarm, Int?>
  149. ) -> some View {
  150. let value = intBinding(seed, keyPath: keyPath)
  151. let text = unit.isEmpty ? "\(value.wrappedValue)" : "\(value.wrappedValue) \(unit)"
  152. return Stepper(value: value, in: range, step: step) {
  153. labelRow(label, value: text)
  154. }
  155. }
  156. /// Plain-language summary of what a recommended alarm will do, shown as the
  157. /// section footer. Fast drop earns one because its per-reading-times-window
  158. /// behaviour isn't obvious from the two controls alone.
  159. private func explanation(for seed: OnboardingViewModel.SeedAlarm) -> String? {
  160. switch seed.type {
  161. case .fastDrop:
  162. let alarm = seed.alarm
  163. let fallback = Alarm(type: .fastDrop)
  164. let delta = alarm.delta ?? fallback.delta ?? 0
  165. let window = alarm.monitoringWindow ?? fallback.monitoringWindow ?? 0
  166. guard delta > 0, window > 0 else { return nil }
  167. let unit = Localizer.getPreferredUnit().localizedShortUnitString
  168. let perReading = Localizer.formatQuantity(delta)
  169. if window == 1 {
  170. return "Warns when glucose falls by at least \(perReading) \(unit) between two readings."
  171. }
  172. let total = Localizer.formatQuantity(delta * Double(window))
  173. let minutes = window * 5
  174. return "Warns when glucose falls by at least \(perReading) \(unit) on each of \(window) readings in a row — about \(total) \(unit) over roughly \(minutes) minutes."
  175. default:
  176. return nil
  177. }
  178. }
  179. /// Day/night picker for reminder alarms (sensor/pump change), so they don't
  180. /// fire overnight by default. Reuses the same menu picker as the full editor.
  181. private func activePicker(_ seed: Binding<OnboardingViewModel.SeedAlarm>) -> some View {
  182. let binding = Binding<ActiveOption>(
  183. get: { seed.wrappedValue.alarm.activeOption },
  184. set: { seed.wrappedValue.alarm.activeOption = $0 }
  185. )
  186. return AlarmEnumMenuPicker(title: "Active during", selection: binding)
  187. }
  188. private func labelRow(_ label: String, value: String) -> some View {
  189. HStack {
  190. Text(label)
  191. Spacer()
  192. Text(value).foregroundColor(.secondary)
  193. }
  194. }
  195. private func formatted(_ value: Double) -> String {
  196. value == value.rounded() ? String(Int(value)) : String(format: "%.1f", value)
  197. }
  198. private func doubleBinding(
  199. _ seed: Binding<OnboardingViewModel.SeedAlarm>,
  200. keyPath: WritableKeyPath<Alarm, Double?>
  201. ) -> Binding<Double> {
  202. Binding(
  203. get: { seed.wrappedValue.alarm[keyPath: keyPath] ?? typeDefault(seed, keyPath) },
  204. set: { seed.wrappedValue.alarm[keyPath: keyPath] = $0 }
  205. )
  206. }
  207. private func intBinding(
  208. _ seed: Binding<OnboardingViewModel.SeedAlarm>,
  209. keyPath: WritableKeyPath<Alarm, Int?>
  210. ) -> Binding<Int> {
  211. Binding(
  212. get: { seed.wrappedValue.alarm[keyPath: keyPath] ?? typeDefault(seed, keyPath) },
  213. set: { seed.wrappedValue.alarm[keyPath: keyPath] = $0 }
  214. )
  215. }
  216. /// Fallback value for a control, read from a fresh `Alarm(type:)` so the
  217. /// onboarding controls share the one source of truth for per-type defaults
  218. /// and can't drift from them. Only a safety net — seeded alarms already carry
  219. /// these values, so the fallback is rarely exercised.
  220. private func typeDefault(_ seed: Binding<OnboardingViewModel.SeedAlarm>, _ keyPath: KeyPath<Alarm, Double?>) -> Double {
  221. Alarm(type: seed.wrappedValue.type)[keyPath: keyPath] ?? 0
  222. }
  223. private func typeDefault(_ seed: Binding<OnboardingViewModel.SeedAlarm>, _ keyPath: KeyPath<Alarm, Int?>) -> Int {
  224. Alarm(type: seed.wrappedValue.type)[keyPath: keyPath] ?? 0
  225. }
  226. }
  227. private extension Array {
  228. subscript(safe index: Int) -> Element? {
  229. indices.contains(index) ? self[index] : nil
  230. }
  231. }