AlarmListView.swift 6.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197
  1. // LoopFollow
  2. // AlarmListView.swift
  3. // Created by Jonas Björkert on 2025-04-26.
  4. import SwiftUI
  5. struct AddAlarmSheet: View {
  6. let onSelect: (AlarmType) -> Void
  7. @Environment(\.dismiss) private var dismiss
  8. private let columns = [
  9. GridItem(.adaptive(minimum: 110), spacing: 16),
  10. ]
  11. var body: some View {
  12. NavigationStack {
  13. ScrollView {
  14. LazyVGrid(columns: columns, spacing: 16) {
  15. ForEach(AlarmType.Group.allCases, id: \.self) { group in
  16. if AlarmType.allCases.contains(where: { $0.group == group }) {
  17. Section(header: Text(group.rawValue)
  18. .font(.headline)
  19. .frame(maxWidth: .infinity, alignment: .leading)
  20. .padding(.horizontal, 4)
  21. ) {
  22. ForEach(AlarmType.allCases.filter { $0.group == group }, id: \.self) { type in
  23. AlarmTile(type: type) {
  24. onSelect(type)
  25. }
  26. }
  27. }
  28. }
  29. }
  30. }
  31. .padding()
  32. }
  33. .navigationTitle("Add Alarm")
  34. .toolbar {
  35. ToolbarItem(placement: .cancellationAction) {
  36. Button("Cancel") { dismiss() }
  37. }
  38. }
  39. }
  40. .preferredColorScheme(Storage.shared.forceDarkMode.value ? .dark : nil)
  41. }
  42. }
  43. private struct AlarmTile: View {
  44. let type: AlarmType
  45. let action: () -> Void
  46. var body: some View {
  47. Button(action: action) {
  48. VStack(spacing: 8) {
  49. Image(systemName: type.icon)
  50. .font(.title2)
  51. .foregroundColor(.accentColor)
  52. Text(type.rawValue)
  53. .font(.subheadline)
  54. .multilineTextAlignment(.center)
  55. .lineLimit(2)
  56. Text(type.blurb)
  57. .font(.caption2)
  58. .foregroundColor(.secondary)
  59. .multilineTextAlignment(.center)
  60. .lineLimit(2)
  61. }
  62. .padding()
  63. .frame(maxWidth: .infinity, minHeight: 110)
  64. .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 12))
  65. }
  66. .buttonStyle(.plain)
  67. }
  68. }
  69. private enum SheetInfo: Identifiable {
  70. case picker
  71. case editor(id: UUID, isNew: Bool)
  72. var id: UUID {
  73. switch self {
  74. case .picker:
  75. return UUID(uuidString: "00000000-0000-0000-0000-000000000000")!
  76. case let .editor(id, _):
  77. return id
  78. }
  79. }
  80. }
  81. struct AlarmListView: View {
  82. @ObservedObject private var store = Storage.shared.alarms
  83. @Environment(\.dismiss) private var dismiss
  84. @State private var sheetInfo: SheetInfo?
  85. @State private var deleteAfterDismiss: UUID?
  86. var body: some View {
  87. NavigationStack {
  88. List {
  89. ForEach(store.value) { alarm in
  90. NavigationLink {
  91. AlarmEditor(alarm: binding(for: alarm))
  92. } label: {
  93. HStack(spacing: 12) {
  94. ZStack {
  95. Image(systemName: alarm.type.icon)
  96. .font(.title3)
  97. .symbolRenderingMode(.hierarchical)
  98. .foregroundStyle(alarm.isEnabled ? Color.accentColor : Color.secondary)
  99. .opacity(iconOpacity(for: alarm))
  100. if let until = alarm.snoozedUntil, until > Date() {
  101. Image(systemName: "zzz")
  102. .font(.title3)
  103. .foregroundStyle(Color.secondary)
  104. .shadow(color: .black.opacity(1), radius: 2, x: 0, y: 0)
  105. .blendMode(.screen)
  106. .offset(x: 6, y: 6)
  107. }
  108. }
  109. .frame(width: 26, height: 26)
  110. Text(alarm.name)
  111. .frame(maxWidth: .infinity, alignment: .leading)
  112. }
  113. }
  114. }
  115. .onDelete { store.value.remove(atOffsets: $0) }
  116. }
  117. .navigationTitle("Alarms")
  118. .toolbar {
  119. ToolbarItem(placement: .navigationBarLeading) {
  120. Button("Done") { dismiss() }
  121. }
  122. ToolbarItem(placement: .navigationBarTrailing) {
  123. Button { sheetInfo = .picker } label: { Image(systemName: "plus") }
  124. }
  125. }
  126. .sheet(item: $sheetInfo,
  127. onDismiss: handleSheetDismiss)
  128. { info in
  129. sheetContent(for: info)
  130. }
  131. }
  132. .preferredColorScheme(Storage.shared.forceDarkMode.value ? .dark : nil)
  133. }
  134. private func handleSheetDismiss() {
  135. if let id = deleteAfterDismiss,
  136. let idx = store.value.firstIndex(where: { $0.id == id })
  137. {
  138. store.value.remove(at: idx)
  139. }
  140. deleteAfterDismiss = nil
  141. }
  142. @ViewBuilder
  143. private func sheetContent(for info: SheetInfo) -> some View {
  144. switch info {
  145. case .picker:
  146. AddAlarmSheet { type in
  147. let new = Alarm(type: type)
  148. store.value.append(new)
  149. sheetInfo = .editor(id: new.id, isNew: true)
  150. }
  151. case let .editor(id, isNew):
  152. if let idx = store.value.firstIndex(where: { $0.id == id }) {
  153. AlarmEditor(
  154. alarm: $store.value[idx],
  155. isNew: isNew,
  156. onDone: { sheetInfo = nil },
  157. onCancel: {
  158. deleteAfterDismiss = id
  159. sheetInfo = nil
  160. }
  161. )
  162. } else {
  163. Text("Alarm not found").padding()
  164. }
  165. }
  166. }
  167. private func binding(for alarm: Alarm) -> Binding<Alarm> {
  168. guard let idx = store.value.firstIndex(where: { $0.id == alarm.id }) else {
  169. fatalError("Alarm not found")
  170. }
  171. return $store.value[idx]
  172. }
  173. private func iconOpacity(for alarm: Alarm) -> Double {
  174. if !alarm.isEnabled { return 0.35 }
  175. if let until = alarm.snoozedUntil, until > Date() { return 0.55 }
  176. return 1.0
  177. }
  178. }