AlarmListView.swift 6.5 KB

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