DeviceAlarmsRootView.swift 9.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227
  1. import SwiftUI
  2. import Swinject
  3. private enum DeviceAlarmSheet: Identifiable {
  4. case picker
  5. case editor(DeviceAlertSeverityConfig, isNew: Bool)
  6. case help(DeviceAlertSeverity)
  7. var id: String {
  8. switch self {
  9. case .picker: return "picker"
  10. case let .editor(config, _): return config.id.uuidString
  11. case let .help(severity): return "helpSheet_" + severity.id
  12. }
  13. }
  14. }
  15. extension DeviceAlarms {
  16. struct RootView: BaseView {
  17. let resolver: Resolver
  18. @StateObject var state = StateModel()
  19. @StateObject private var store = DeviceAlertsStore.shared
  20. @State private var sheet: DeviceAlarmSheet?
  21. @State private var pendingNewSeverity: DeviceAlertSeverity?
  22. @State private var shouldDisplayHint: Bool = false
  23. @State var hintDetent = PresentationDetent.large
  24. @State var selectedVerboseHint: AnyView?
  25. @Environment(\.colorScheme) var colorScheme
  26. @Environment(AppState.self) var appState
  27. var body: some View {
  28. List {
  29. ForEach(DeviceAlertSeverity.allCases) { severity in
  30. Section {
  31. VStack(alignment: .leading, spacing: 5) {
  32. HStack {
  33. Image(systemName: severityIcon(for: severity))
  34. .foregroundStyle(severityTint(for: severity))
  35. Text(severity.displayName)
  36. Spacer()
  37. }.font(.headline)
  38. HStack(alignment: .center) {
  39. Text(severity.blurb)
  40. .font(.footnote)
  41. .foregroundColor(.secondary)
  42. .lineLimit(nil)
  43. .fixedSize(horizontal: false, vertical: true)
  44. Spacer()
  45. Button(action: {
  46. sheet = .help(severity)
  47. }) {
  48. HStack {
  49. Image(systemName: "questionmark.circle")
  50. }
  51. }
  52. .buttonStyle(BorderlessButtonStyle())
  53. }.padding(.vertical, 5)
  54. }
  55. ForEach(store.configs(in: severity)) { config in
  56. row(for: config)
  57. .opacity(config.isEnabled ? 1 : 0.5)
  58. }
  59. }.listRowBackground(Color.chart)
  60. }
  61. Section {
  62. Text("Day & Night Windows")
  63. .foregroundStyle(Color.accentColor)
  64. .navigationLink(to: .alarmWindows, from: self)
  65. }.listRowBackground(Color.chart)
  66. }
  67. .scrollContentBackground(.hidden)
  68. .background(appState.trioBackgroundColor(for: colorScheme))
  69. .navigationTitle("Device Alarms")
  70. .navigationBarTitleDisplayMode(.inline)
  71. .toolbar {
  72. ToolbarItem(placement: .primaryAction) {
  73. Button { sheet = .picker } label: { Image(systemName: "plus") }
  74. }
  75. }
  76. .sheet(item: $sheet, onDismiss: handleSheetDismiss) { which in
  77. switch which {
  78. case .picker:
  79. AddDeviceAlarmSheet { severity in
  80. pendingNewSeverity = severity
  81. sheet = nil
  82. }
  83. case let .editor(config, isNew):
  84. DeviceAlarmEditorView(
  85. store: store,
  86. initial: config,
  87. isNew: isNew,
  88. onDone: { sheet = nil },
  89. onCancel: { sheet = nil }
  90. )
  91. case let .help(severity):
  92. SettingInputHintView(
  93. hintDetent: $hintDetent,
  94. shouldDisplayHint: Binding(
  95. get: { sheet != nil },
  96. set: { if !$0 { sheet = nil } }
  97. ),
  98. hintLabel: String(
  99. localized: "\(severity.displayName) Device Alerts",
  100. comment: "Device Alerts help sheet label; text reads: '<severity level> Device Alerts'."
  101. ),
  102. hintText: selectedVerboseHint ?? AnyView(
  103. VStack(alignment: .leading, spacing: 10) {
  104. Text(severity.hintText)
  105. }
  106. ),
  107. sheetTitle: String(localized: "Help", comment: "Help sheet title")
  108. )
  109. }
  110. }
  111. .onAppear(perform: configureView)
  112. }
  113. private func handleSheetDismiss() {
  114. guard let severity = pendingNewSeverity else { return }
  115. pendingNewSeverity = nil
  116. DispatchQueue.main.async {
  117. let new = DeviceAlertSeverityConfig(
  118. severity: severity,
  119. activeOption: nextAvailableOption(for: severity)
  120. )
  121. sheet = .editor(new, isNew: true)
  122. }
  123. }
  124. // MARK: - Row
  125. @ViewBuilder private func row(for config: DeviceAlertSeverityConfig) -> some View {
  126. Button {
  127. sheet = .editor(config, isNew: false)
  128. } label: {
  129. HStack(spacing: 12) {
  130. AlarmWindowIcon(option: config.activeOption)
  131. .font(.title3)
  132. VStack(alignment: .leading, spacing: 2) {
  133. Text(windowLabel(for: config.activeOption))
  134. .foregroundColor(.primary)
  135. soundSummary(for: config)
  136. }
  137. Spacer()
  138. Image(systemName: "chevron.right")
  139. .font(.footnote)
  140. .foregroundColor(.secondary)
  141. }
  142. }
  143. .swipeActions(edge: .trailing, allowsFullSwipe: true) {
  144. if store.canDelete(config) {
  145. Button(role: .destructive) {
  146. store.remove(config)
  147. } label: {
  148. Label("Delete", systemImage: "trash")
  149. }
  150. }
  151. }
  152. }
  153. private func soundSummary(for config: DeviceAlertSeverityConfig) -> some View {
  154. // Show the sound and override facts independently. Previously
  155. // the override badge was hidden when sound was off, but "sound
  156. // off + override on" is a valid combo (silent + haptic that
  157. // breaks through Focus / Sleep) and the user needs to see it.
  158. HStack(spacing: 6) {
  159. HStack(spacing: 4) {
  160. Image(systemName: config.playsSound ? "speaker.wave.2.fill" : "speaker.slash.fill")
  161. Text(config.playsSound ? "Sound on" : "Sound off")
  162. }
  163. if config.overridesSilenceAndDND {
  164. Text("·")
  165. HStack(spacing: 4) {
  166. Image(systemName: "bell.badge.fill")
  167. Text("Overrides Focus")
  168. }
  169. }
  170. }
  171. .font(.footnote)
  172. .foregroundColor(.secondary)
  173. }
  174. // MARK: - Helpers
  175. private func windowLabel(for option: ActiveOption) -> String {
  176. switch option {
  177. case .always: return String(localized: "Day & Night")
  178. case .day: return String(localized: "Day only")
  179. case .night: return String(localized: "Night only")
  180. }
  181. }
  182. private func severityIcon(for severity: DeviceAlertSeverity) -> String {
  183. switch severity {
  184. case .critical: return "exclamationmark.triangle.fill"
  185. case .timeSensitive: return "bell.badge.fill"
  186. case .normal: return "bell.fill"
  187. }
  188. }
  189. private func severityTint(for severity: DeviceAlertSeverity) -> Color {
  190. switch severity {
  191. case .critical: return .red
  192. case .timeSensitive: return .orange
  193. case .normal: return .accentColor
  194. }
  195. }
  196. /// Suggest an `ActiveOption` not yet used in this severity tier so the
  197. /// editor opens on a meaningful new variant instead of duplicating
  198. /// the existing `.always` row.
  199. private func nextAvailableOption(for severity: DeviceAlertSeverity) -> ActiveOption {
  200. let used = Set(store.configs(in: severity).map(\.activeOption))
  201. for candidate in [ActiveOption.day, .night, .always] where !used.contains(candidate) {
  202. return candidate
  203. }
  204. return .day
  205. }
  206. }
  207. }