GlucoseAlertsRootView.swift 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310
  1. import SwiftUI
  2. import Swinject
  3. private enum AlertSheet: Identifiable {
  4. case picker
  5. case editor(GlucoseAlert, isNew: Bool)
  6. var id: String {
  7. switch self {
  8. case .picker: return "picker"
  9. case let .editor(alert, _): return alert.id.uuidString
  10. }
  11. }
  12. }
  13. extension GlucoseAlerts {
  14. struct RootView: BaseView {
  15. let resolver: Resolver
  16. @StateObject var state = StateModel()
  17. @StateObject private var store = GlucoseAlertsStore.shared
  18. @State private var sheet: AlertSheet?
  19. @State private var pendingNewType: GlucoseAlertType?
  20. @Environment(\.colorScheme) var colorScheme
  21. @Environment(AppState.self) var appState
  22. @State private var shouldDisplayHint: Bool = false
  23. @State var hintDetent = PresentationDetent.large
  24. @State var selectedVerboseHint: AnyView?
  25. @State var hintLabel: String?
  26. @State private var decimalPlaceholder: Decimal = 0.0
  27. @State private var booleanPlaceholder: Bool = false
  28. @State private var displayPickerLowGlucose: Bool = false
  29. @State private var displayPickerHighGlucose: Bool = false
  30. var body: some View {
  31. List {
  32. if !enabledAlerts.isEmpty {
  33. Section(header: Text("Enabled")) {
  34. ForEach(enabledAlerts) { alarm in
  35. row(for: alarm)
  36. }
  37. }.listRowBackground(Color.chart)
  38. }
  39. if !disabledAlerts.isEmpty {
  40. Section(header: Text("Disabled")) {
  41. ForEach(disabledAlerts) { alarm in
  42. row(for: alarm).opacity(0.6)
  43. }
  44. }.listRowBackground(Color.chart)
  45. }
  46. if !cgmHandledAlerts.isEmpty {
  47. Section(
  48. header: Text("Handled by CGM App"),
  49. footer: cgmHandledFooter
  50. ) {
  51. ForEach(cgmHandledAlerts) { alarm in
  52. row(for: alarm).opacity(0.6)
  53. }
  54. }.listRowBackground(Color.chart)
  55. }
  56. // FIXME: make this into a nice setting with mini and verbose hint
  57. Section {
  58. Text("Day & Night Windows")
  59. .navigationLink(to: .alarmWindows, from: self)
  60. }.listRowBackground(Color.chart)
  61. Section(footer: Text(
  62. "On by default when using a CGM in Trio which requires another app (e.g. Dexcom G6 / One, Dexcom G7 / One+, or xDrip4iOS). Turn off if you've disabled those and want Trio to alert you instead."
  63. )) {
  64. Toggle(isOn: Binding(
  65. get: { !store.configuration.forceTrioAlertsWhenCGMProvidesOwn },
  66. set: { store.configuration.forceTrioAlertsWhenCGMProvidesOwn = !$0 }
  67. )) {
  68. Text("Use CGM App Alerts")
  69. }
  70. }.listRowBackground(Color.chart)
  71. SettingInputSection(
  72. decimalValue: $decimalPlaceholder,
  73. booleanValue: $state.glucoseBadge,
  74. shouldDisplayHint: $shouldDisplayHint,
  75. selectedVerboseHint: Binding(
  76. get: { selectedVerboseHint },
  77. set: {
  78. selectedVerboseHint = $0.map { AnyView($0) }
  79. hintLabel = String(localized: "Show Glucose App Badge")
  80. }
  81. ),
  82. units: state.units,
  83. type: .boolean,
  84. label: String(localized: "Show Glucose App Badge"),
  85. miniHint: String(localized: "Show your current glucose on Trio app icon."),
  86. verboseHint: VStack(alignment: .leading, spacing: 10) {
  87. Text("Default: OFF").bold()
  88. Text(
  89. "This will add your current glucose on the top right of your Trio icon as a red notification badge. Changing setting takes effect on next Glucose reading."
  90. )
  91. },
  92. headerText: String(localized: "Glucose App Badge")
  93. )
  94. }
  95. .scrollContentBackground(.hidden)
  96. .background(appState.trioBackgroundColor(for: colorScheme))
  97. .navigationTitle("Glucose Alarms")
  98. .navigationBarTitleDisplayMode(.inline)
  99. .toolbar {
  100. ToolbarItem(placement: .primaryAction) {
  101. Button { sheet = .picker } label: { Image(systemName: "plus") }
  102. }
  103. }
  104. .sheet(item: $sheet, onDismiss: handleSheetDismiss) { which in
  105. switch which {
  106. case .picker:
  107. AddGlucoseAlertSheet(store: store) { type in
  108. pendingNewType = type
  109. sheet = nil
  110. }
  111. case let .editor(alarm, isNew):
  112. GlucoseAlertEditorView(
  113. store: store,
  114. initial: alarm,
  115. isNew: isNew,
  116. units: state.units,
  117. onDone: { sheet = nil },
  118. onCancel: { sheet = nil }
  119. )
  120. }
  121. }
  122. .sheet(isPresented: $shouldDisplayHint) {
  123. SettingInputHintView(
  124. hintDetent: $hintDetent,
  125. shouldDisplayHint: $shouldDisplayHint,
  126. hintLabel: hintLabel ?? "",
  127. hintText: selectedVerboseHint ?? AnyView(EmptyView()),
  128. sheetTitle: String(localized: "Help", comment: "Help sheet title")
  129. )
  130. }
  131. .onAppear {
  132. configureView()
  133. state.refreshCGMOwnership()
  134. }
  135. }
  136. private func handleSheetDismiss() {
  137. guard let type = pendingNewType else { return }
  138. pendingNewType = nil
  139. DispatchQueue.main.async {
  140. var seed = GlucoseAlert(type: type)
  141. // Default new alarm to the first available window so it
  142. // doesn't overlap with whatever is already configured.
  143. let available = store.availableActiveOptions(forNewAlarmOfType: type)
  144. if let first = ActiveOption.allCases.first(where: available.contains) {
  145. seed.activeOption = first
  146. }
  147. sheet = .editor(seed, isNew: true)
  148. }
  149. }
  150. // MARK: - Sorted lists
  151. /// Mirror of `GlucoseAlertCoordinator.shouldRespect(alarm:)`'s
  152. /// CGM-ownership branch: when "Use CGM App Alerts" is ON and the
  153. /// active CGM provides its own glucose alerts, the coordinator
  154. /// silences reading-driven types. The view surfaces this by moving
  155. /// those alarms into a dedicated section.
  156. private var isCGMSuppressionActive: Bool {
  157. !store.configuration.forceTrioAlertsWhenCGMProvidesOwn && state.cgmProvidesOwnAlerts
  158. }
  159. private var cgmHandledAlerts: [GlucoseAlert] {
  160. guard isCGMSuppressionActive else { return [] }
  161. return store.alerts
  162. .filter { $0.isEnabled && $0.type.isReadingDriven }
  163. .sorted { lhs, rhs in
  164. lhs.type.priority < rhs.type.priority
  165. }
  166. }
  167. /// Footer for the "Handled by CGM App" section. Names the specific
  168. /// companion app, and renders its name as a deep link when a URL
  169. /// scheme is known for that app (see CGMManagerAlertOwnership).
  170. @ViewBuilder private var cgmHandledFooter: some View {
  171. if let info = state.cgmAppInfo {
  172. Text(handledFooterMarkdown(for: info))
  173. } else {
  174. Text(
  175. "These alarms are silenced because the CGM app handles them. To have Trio notify you instead, turn off \"Use CGM App Alerts\" below."
  176. )
  177. }
  178. }
  179. private func handledFooterMarkdown(for info: CGMManagerAlertOwnership.OwningApp) -> AttributedString {
  180. let body = String(
  181. format: String(
  182. localized:
  183. "These alarms are silenced because the %@ app handles CGM alerts. To have Trio notify you instead, turn off \"Use CGM App Alerts\" below."
  184. ),
  185. "{{NAME}}"
  186. )
  187. var result = AttributedString(body)
  188. if let range = result.range(of: "{{NAME}}") {
  189. var name = AttributedString(info.name)
  190. if let url = info.deepLink {
  191. name.link = url
  192. }
  193. result.replaceSubrange(range, with: name)
  194. }
  195. return result
  196. }
  197. private var enabledAlerts: [GlucoseAlert] {
  198. let handled = Set(cgmHandledAlerts.map(\.id))
  199. return store.alerts
  200. .filter(\.isEnabled)
  201. .filter { !handled.contains($0.id) }
  202. .sorted { lhs, rhs in
  203. lhs.type.priority < rhs.type.priority
  204. }
  205. }
  206. private var disabledAlerts: [GlucoseAlert] {
  207. store.alerts
  208. .filter { !$0.isEnabled }
  209. .sorted { lhs, rhs in
  210. lhs.type.priority < rhs.type.priority
  211. }
  212. }
  213. // MARK: - Row
  214. @ViewBuilder private func row(for alarm: GlucoseAlert) -> some View {
  215. Button {
  216. sheet = .editor(alarm, isNew: false)
  217. } label: {
  218. HStack(spacing: 12) {
  219. AlarmWindowIcon(option: alarm.activeOption)
  220. .font(.title3)
  221. VStack(alignment: .leading, spacing: 2) {
  222. Text(alarm.name)
  223. .foregroundColor(.primary)
  224. Text(summary(for: alarm))
  225. .font(.footnote)
  226. .foregroundColor(.secondary)
  227. soundSummary(for: alarm)
  228. }
  229. Spacer()
  230. Image(systemName: "chevron.right")
  231. .font(.footnote)
  232. .foregroundColor(.secondary)
  233. }
  234. }
  235. .swipeActions(edge: .trailing, allowsFullSwipe: true) {
  236. if store.canDelete(alarm) {
  237. Button(role: .destructive) {
  238. store.remove(alarm)
  239. } label: {
  240. Label("Delete", systemImage: "trash")
  241. }
  242. }
  243. }
  244. }
  245. private func summary(for alarm: GlucoseAlert) -> String {
  246. let comparator: String = {
  247. switch alarm.type {
  248. case .high: return String(localized: "above")
  249. default: return String(localized: "below")
  250. }
  251. }()
  252. let threshold = "\(alarm.thresholdMgDL.formatted(for: state.units)) \(state.units.rawValue)"
  253. let window = AlarmEnumDescription.description(for: alarm.activeOption)
  254. return "\(comparator.localizedCapitalized) \(threshold) • \(window)"
  255. }
  256. private func soundSummary(for alarm: GlucoseAlert) -> some View {
  257. var icon = "speaker.fill"
  258. var label = String(localized: "Sound on")
  259. if !alarm.playsSound {
  260. icon = "speaker.slash.fill"
  261. label = String(localized: "Sound off")
  262. } else if alarm.overridesSilenceAndDND {
  263. icon = "speaker.wave.3.fill"
  264. label = String(localized: "Override Silence & Focus")
  265. }
  266. return HStack(spacing: 4) {
  267. Image(systemName: icon)
  268. Text(label)
  269. }
  270. .font(.footnote)
  271. .foregroundColor(.secondary)
  272. }
  273. }
  274. }
  275. private enum AlarmEnumDescription {
  276. static func description(for option: ActiveOption) -> String {
  277. switch option {
  278. case .always: return String(localized: "Day & Night")
  279. case .day: return String(localized: "Day only")
  280. case .night: return String(localized: "Night only")
  281. }
  282. }
  283. }