| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222 |
- // LoopFollow
- // AlarmListView.swift
- import SwiftUI
- private enum SheetInfo: Identifiable {
- case picker
- case editor(id: UUID, isNew: Bool)
- var id: UUID {
- switch self {
- case .picker:
- return UUID(uuidString: "00000000-0000-0000-0000-000000000000")!
- case let .editor(id, _):
- return id
- }
- }
- }
- struct AlarmListView: View {
- @ObservedObject private var store = Storage.shared.alarms
- @State private var sheetInfo: SheetInfo?
- @State private var deleteAfterDismiss: UUID?
- @State private var selectedAlarm: Alarm?
- @State private var searchText = ""
- // MARK: - Search
- private func matches(_ alarm: Alarm) -> Bool {
- let query = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
- guard !query.isEmpty else { return true }
- return alarm.name.localizedCaseInsensitiveContains(query)
- || alarm.type.rawValue.localizedCaseInsensitiveContains(query)
- }
- private var hasResults: Bool {
- !snoozedAlarms.isEmpty || !activeAlarms.isEmpty || !inactiveAlarms.isEmpty
- }
- // Snapshot of "now" used to categorize snoozed vs. active alarms. SwiftUI does
- // not re-render when the wall clock passes a snooze's expiry, so we refresh this
- // whenever the screen appears or the app returns to the foreground.
- @State private var now = Date()
- @Environment(\.scenePhase) private var scenePhase
- // MARK: - Categorized Alarms
- private var snoozedAlarms: [Alarm] {
- store.value.filter { $0.snoozedUntil ?? .distantPast > now && $0.isEnabled && matches($0) }
- .sorted(by: Alarm.byPriorityThenSpec)
- }
- private var activeAlarms: [Alarm] {
- store.value.filter { $0.isEnabled && ($0.snoozedUntil ?? .distantPast <= now) && matches($0) }
- .sorted(by: Alarm.byPriorityThenSpec)
- }
- private var inactiveAlarms: [Alarm] {
- store.value.filter { !$0.isEnabled && matches($0) }
- .sorted(by: Alarm.byPriorityThenSpec)
- }
- // MARK: - Formatters
- private var timeFormatter: DateFormatter {
- let formatter = DateFormatter()
- formatter.dateStyle = .none
- formatter.timeStyle = .short
- return formatter
- }
- // MARK: - Body
- var body: some View {
- List {
- // --- SNOOZED ALARMS SECTION ---
- if !snoozedAlarms.isEmpty {
- Section(header: Text("Snoozed")) {
- ForEach(snoozedAlarms) { alarm in
- alarmRow(for: alarm)
- }
- }
- }
- // --- ACTIVE ALARMS SECTION ---
- if !activeAlarms.isEmpty {
- Section(header: Text("Active")) {
- ForEach(activeAlarms) { alarm in
- alarmRow(for: alarm)
- }
- }
- }
- // --- INACTIVE ALARMS SECTION ---
- if !inactiveAlarms.isEmpty {
- Section(header: Text("Inactive")) {
- ForEach(inactiveAlarms) { alarm in
- alarmRow(for: alarm)
- .opacity(0.6)
- }
- }
- }
- }
- .overlay {
- if !hasResults, !searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
- VStack(spacing: 8) {
- Image(systemName: "magnifyingglass")
- .font(.largeTitle)
- .foregroundColor(.secondary)
- Text("No Results")
- .font(.headline)
- Text("No alarms match “\(searchText)”.")
- .font(.subheadline)
- .foregroundColor(.secondary)
- .multilineTextAlignment(.center)
- }
- .padding(.horizontal)
- }
- }
- .searchable(text: $searchText, prompt: "Search alarms")
- .sheet(item: $sheetInfo, onDismiss: handleSheetDismiss) { info in
- sheetContent(for: info)
- }
- .navigationBarTitle("Alarms", displayMode: .inline)
- .toolbar {
- ToolbarItem(placement: .primaryAction) {
- Button { sheetInfo = .picker } label: { Image(systemName: "plus") }
- }
- }
- .onAppear { now = Date() }
- .onChange(of: scenePhase) { phase in
- if phase == .active { now = Date() }
- }
- .preferredColorScheme(Storage.shared.appearanceMode.value.colorScheme)
- }
- // MARK: - Views
- @ViewBuilder
- private func alarmRow(for alarm: Alarm) -> some View {
- Button {
- selectedAlarm = alarm
- sheetInfo = .editor(id: alarm.id, isNew: false)
- } label: {
- HStack(spacing: 12) {
- Glyph(
- symbol: alarm.type.icon,
- tint: .primary
- )
- VStack(alignment: .leading, spacing: 2) {
- Text(alarm.name)
- .foregroundColor(.primary)
- if let until = alarm.snoozedUntil, until > now {
- HStack(spacing: 4) {
- Image(systemName: "zzz")
- .font(.caption2)
- Text("Snoozed until \(until, formatter: timeFormatter)")
- .font(.caption)
- }
- .foregroundColor(.secondary)
- }
- }
- Spacer()
- Image(systemName: "chevron.right")
- .font(.footnote.weight(.semibold))
- .foregroundColor(.secondary)
- }
- }
- .swipeActions {
- Button(role: .destructive) {
- store.value.removeAll { $0.id == alarm.id }
- } label: {
- Label("Delete", systemImage: "trash.fill")
- }
- }
- }
- // MARK: - Sheet Management
- private func handleSheetDismiss() {
- if let id = deleteAfterDismiss,
- let idx = store.value.firstIndex(where: { $0.id == id })
- {
- store.value.remove(at: idx)
- }
- deleteAfterDismiss = nil
- }
- @ViewBuilder
- private func sheetContent(for info: SheetInfo) -> some View {
- switch info {
- case .picker:
- AddAlarmSheet { type in
- let new = Alarm(type: type)
- store.value.append(new)
- sheetInfo = .editor(id: new.id, isNew: true)
- }
- case let .editor(id, isNew):
- if let idx = store.value.firstIndex(where: { $0.id == id }) {
- AlarmEditor(
- alarm: $store.value[idx],
- isNew: isNew,
- onDone: { sheetInfo = nil },
- onCancel: {
- if isNew { deleteAfterDismiss = id }
- sheetInfo = nil
- },
- onDelete: {
- deleteAfterDismiss = id
- sheetInfo = nil
- }
- )
- } else {
- Text("Alarm not found").padding()
- }
- }
- }
- }
|