AlarmListView.swift 7.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222
  1. // LoopFollow
  2. // AlarmListView.swift
  3. import SwiftUI
  4. private enum SheetInfo: Identifiable {
  5. case picker
  6. case editor(id: UUID, isNew: Bool)
  7. var id: UUID {
  8. switch self {
  9. case .picker:
  10. return UUID(uuidString: "00000000-0000-0000-0000-000000000000")!
  11. case let .editor(id, _):
  12. return id
  13. }
  14. }
  15. }
  16. struct AlarmListView: View {
  17. @ObservedObject private var store = Storage.shared.alarms
  18. @State private var sheetInfo: SheetInfo?
  19. @State private var deleteAfterDismiss: UUID?
  20. @State private var selectedAlarm: Alarm?
  21. @State private var searchText = ""
  22. // MARK: - Search
  23. private func matches(_ alarm: Alarm) -> Bool {
  24. let query = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
  25. guard !query.isEmpty else { return true }
  26. return alarm.name.localizedCaseInsensitiveContains(query)
  27. || alarm.type.rawValue.localizedCaseInsensitiveContains(query)
  28. }
  29. private var hasResults: Bool {
  30. !snoozedAlarms.isEmpty || !activeAlarms.isEmpty || !inactiveAlarms.isEmpty
  31. }
  32. // Snapshot of "now" used to categorize snoozed vs. active alarms. SwiftUI does
  33. // not re-render when the wall clock passes a snooze's expiry, so we refresh this
  34. // whenever the screen appears or the app returns to the foreground.
  35. @State private var now = Date()
  36. @Environment(\.scenePhase) private var scenePhase
  37. // MARK: - Categorized Alarms
  38. private var snoozedAlarms: [Alarm] {
  39. store.value.filter { $0.snoozedUntil ?? .distantPast > now && $0.isEnabled && matches($0) }
  40. .sorted(by: Alarm.byPriorityThenSpec)
  41. }
  42. private var activeAlarms: [Alarm] {
  43. store.value.filter { $0.isEnabled && ($0.snoozedUntil ?? .distantPast <= now) && matches($0) }
  44. .sorted(by: Alarm.byPriorityThenSpec)
  45. }
  46. private var inactiveAlarms: [Alarm] {
  47. store.value.filter { !$0.isEnabled && matches($0) }
  48. .sorted(by: Alarm.byPriorityThenSpec)
  49. }
  50. // MARK: - Formatters
  51. private var timeFormatter: DateFormatter {
  52. let formatter = DateFormatter()
  53. formatter.dateStyle = .none
  54. formatter.timeStyle = .short
  55. return formatter
  56. }
  57. // MARK: - Body
  58. var body: some View {
  59. List {
  60. // --- SNOOZED ALARMS SECTION ---
  61. if !snoozedAlarms.isEmpty {
  62. Section(header: Text("Snoozed")) {
  63. ForEach(snoozedAlarms) { alarm in
  64. alarmRow(for: alarm)
  65. }
  66. }
  67. }
  68. // --- ACTIVE ALARMS SECTION ---
  69. if !activeAlarms.isEmpty {
  70. Section(header: Text("Active")) {
  71. ForEach(activeAlarms) { alarm in
  72. alarmRow(for: alarm)
  73. }
  74. }
  75. }
  76. // --- INACTIVE ALARMS SECTION ---
  77. if !inactiveAlarms.isEmpty {
  78. Section(header: Text("Inactive")) {
  79. ForEach(inactiveAlarms) { alarm in
  80. alarmRow(for: alarm)
  81. .opacity(0.6)
  82. }
  83. }
  84. }
  85. }
  86. .overlay {
  87. if !hasResults, !searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
  88. VStack(spacing: 8) {
  89. Image(systemName: "magnifyingglass")
  90. .font(.largeTitle)
  91. .foregroundColor(.secondary)
  92. Text("No Results")
  93. .font(.headline)
  94. Text("No alarms match “\(searchText)”.")
  95. .font(.subheadline)
  96. .foregroundColor(.secondary)
  97. .multilineTextAlignment(.center)
  98. }
  99. .padding(.horizontal)
  100. }
  101. }
  102. .searchable(text: $searchText, prompt: "Search alarms")
  103. .sheet(item: $sheetInfo, onDismiss: handleSheetDismiss) { info in
  104. sheetContent(for: info)
  105. }
  106. .navigationBarTitle("Alarms", displayMode: .inline)
  107. .toolbar {
  108. ToolbarItem(placement: .primaryAction) {
  109. Button { sheetInfo = .picker } label: { Image(systemName: "plus") }
  110. }
  111. }
  112. .onAppear { now = Date() }
  113. .onChange(of: scenePhase) { phase in
  114. if phase == .active { now = Date() }
  115. }
  116. .preferredColorScheme(Storage.shared.appearanceMode.value.colorScheme)
  117. }
  118. // MARK: - Views
  119. @ViewBuilder
  120. private func alarmRow(for alarm: Alarm) -> some View {
  121. Button {
  122. selectedAlarm = alarm
  123. sheetInfo = .editor(id: alarm.id, isNew: false)
  124. } label: {
  125. HStack(spacing: 12) {
  126. Glyph(
  127. symbol: alarm.type.icon,
  128. tint: .primary
  129. )
  130. VStack(alignment: .leading, spacing: 2) {
  131. Text(alarm.name)
  132. .foregroundColor(.primary)
  133. if let until = alarm.snoozedUntil, until > now {
  134. HStack(spacing: 4) {
  135. Image(systemName: "zzz")
  136. .font(.caption2)
  137. Text("Snoozed until \(until, formatter: timeFormatter)")
  138. .font(.caption)
  139. }
  140. .foregroundColor(.secondary)
  141. }
  142. }
  143. Spacer()
  144. Image(systemName: "chevron.right")
  145. .font(.footnote.weight(.semibold))
  146. .foregroundColor(.secondary)
  147. }
  148. }
  149. .swipeActions {
  150. Button(role: .destructive) {
  151. store.value.removeAll { $0.id == alarm.id }
  152. } label: {
  153. Label("Delete", systemImage: "trash.fill")
  154. }
  155. }
  156. }
  157. // MARK: - Sheet Management
  158. private func handleSheetDismiss() {
  159. if let id = deleteAfterDismiss,
  160. let idx = store.value.firstIndex(where: { $0.id == id })
  161. {
  162. store.value.remove(at: idx)
  163. }
  164. deleteAfterDismiss = nil
  165. }
  166. @ViewBuilder
  167. private func sheetContent(for info: SheetInfo) -> some View {
  168. switch info {
  169. case .picker:
  170. AddAlarmSheet { type in
  171. let new = Alarm(type: type)
  172. store.value.append(new)
  173. sheetInfo = .editor(id: new.id, isNew: true)
  174. }
  175. case let .editor(id, isNew):
  176. if let idx = store.value.firstIndex(where: { $0.id == id }) {
  177. AlarmEditor(
  178. alarm: $store.value[idx],
  179. isNew: isNew,
  180. onDone: { sheetInfo = nil },
  181. onCancel: {
  182. if isNew { deleteAfterDismiss = id }
  183. sheetInfo = nil
  184. },
  185. onDelete: {
  186. deleteAfterDismiss = id
  187. sheetInfo = nil
  188. }
  189. )
  190. } else {
  191. Text("Alarm not found").padding()
  192. }
  193. }
  194. }
  195. }