AlarmAudioSection.swift 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236
  1. // LoopFollow
  2. // AlarmAudioSection.swift
  3. import SwiftUI
  4. import UniformTypeIdentifiers
  5. struct AlarmAudioSection: View {
  6. @Binding var alarm: Alarm
  7. var hideRepeat: Bool = false
  8. @State private var showingTonePicker = false
  9. var body: some View {
  10. Section(header: Text("Alert Sound")) {
  11. Button {
  12. showingTonePicker = true
  13. } label: {
  14. HStack {
  15. Text("Tone")
  16. Spacer()
  17. Text(alarm.soundFile.displayName)
  18. .foregroundColor(.secondary)
  19. Image(systemName: "chevron.right")
  20. .foregroundColor(.secondary)
  21. }
  22. }
  23. .buttonStyle(.plain)
  24. .sheet(isPresented: $showingTonePicker) {
  25. TonePickerSheet(selected: $alarm.soundFile)
  26. }
  27. AlarmEnumMenuPicker(
  28. title: String(localized: "Play"),
  29. selection: $alarm.playSoundOption,
  30. allowed: PlaySoundOption.allowed(for: alarm.activeOption)
  31. )
  32. if !hideRepeat {
  33. AlarmEnumMenuPicker(
  34. title: String(localized: "Repeat"),
  35. selection: $alarm.repeatSoundOption,
  36. allowed: RepeatSoundOption.allowed(for: alarm.activeOption)
  37. )
  38. }
  39. Stepper(
  40. value: $alarm.soundDelay,
  41. in: 0 ... 60,
  42. step: 5
  43. ) {
  44. HStack {
  45. Text("Delay Between Sounds")
  46. Spacer()
  47. if alarm.soundDelay == 0 {
  48. Text("Off")
  49. .foregroundColor(.secondary)
  50. } else {
  51. Text("\(alarm.soundDelay) sec")
  52. .foregroundColor(.secondary)
  53. }
  54. }
  55. }
  56. }.onChange(of: alarm.activeOption) { newActive in
  57. let playAllowed = PlaySoundOption.allowed(for: newActive)
  58. if !playAllowed.contains(alarm.playSoundOption) {
  59. alarm.playSoundOption = playAllowed.last!
  60. }
  61. let repeatAllowed = RepeatSoundOption.allowed(for: newActive)
  62. if !repeatAllowed.contains(alarm.repeatSoundOption) {
  63. alarm.repeatSoundOption = repeatAllowed.last!
  64. }
  65. }
  66. }
  67. }
  68. struct AlarmEnumMenuPicker<E: CaseIterable & Hashable & DayNightDisplayable>: View {
  69. let title: String
  70. @Binding var selection: E
  71. var allowed: [E]
  72. var body: some View {
  73. HStack {
  74. Text(title)
  75. Spacer()
  76. Picker("", selection: $selection) {
  77. ForEach(allowed, id: \.self) { opt in
  78. Text(opt.displayName).tag(opt)
  79. }
  80. }
  81. // if the current selection became invalid, snap to the first allowed
  82. .onAppear { validate() }
  83. .onChange(of: allowed) { _ in validate() }
  84. }
  85. }
  86. private func validate() {
  87. if !allowed.contains(selection), let first = allowed.first {
  88. selection = first
  89. }
  90. }
  91. }
  92. extension AlarmEnumMenuPicker where E: CaseIterable {
  93. init(title: String, selection: Binding<E>) {
  94. self.title = title
  95. _selection = selection
  96. allowed = Array(E.allCases)
  97. }
  98. }
  99. private struct TonePickerSheet: View {
  100. @Binding var selected: SoundFile
  101. @Environment(\.dismiss) private var dismiss
  102. @State private var customSounds: [CustomSound] = []
  103. @State private var showingImporter = false
  104. @State private var importError: String?
  105. var body: some View {
  106. NavigationView {
  107. ScrollViewReader { proxy in
  108. List {
  109. Section {
  110. ForEach(customSounds) { sound in
  111. toneRow(tone: .custom(sound.id), label: sound.displayName)
  112. .id(SoundFile.custom(sound.id))
  113. }
  114. .onDelete(perform: deleteCustomSounds)
  115. Button {
  116. showingImporter = true
  117. } label: {
  118. Label("Import Sound…", systemImage: "plus.circle")
  119. }
  120. } header: {
  121. Text("Custom")
  122. } footer: {
  123. Text("Custom sounds stay on this device and aren't included in settings export.")
  124. }
  125. Section(header: Text("Built-in")) {
  126. ForEach(SoundFile.allBuiltins) { tone in
  127. toneRow(tone: tone, label: tone.displayName)
  128. .id(tone)
  129. }
  130. }
  131. }
  132. .navigationTitle("Choose Tone")
  133. .toolbar {
  134. ToolbarItem(placement: .confirmationAction) {
  135. Button("Done") {
  136. AlarmSound.stop()
  137. dismiss()
  138. }
  139. }
  140. }
  141. .onAppear {
  142. reloadCustomSounds()
  143. proxy.scrollTo(selected, anchor: .center)
  144. }
  145. .fileImporter(
  146. isPresented: $showingImporter,
  147. allowedContentTypes: [.audio],
  148. allowsMultipleSelection: false
  149. ) { result in
  150. handleImport(result)
  151. }
  152. .alert(
  153. "Import Failed",
  154. isPresented: Binding(
  155. get: { importError != nil },
  156. set: { if !$0 { importError = nil } }
  157. ),
  158. actions: { Button("OK", role: .cancel) { importError = nil } },
  159. message: { Text(importError ?? "") }
  160. )
  161. }
  162. }
  163. }
  164. @ViewBuilder
  165. private func toneRow(tone: SoundFile, label: String) -> some View {
  166. Button {
  167. selected = tone
  168. AlarmSound.setSoundFile(tone)
  169. AlarmSound.stop()
  170. AlarmSound.playTest()
  171. } label: {
  172. HStack {
  173. Text(label)
  174. if tone == selected {
  175. Spacer()
  176. Image(systemName: "checkmark")
  177. .foregroundColor(.accentColor)
  178. }
  179. }
  180. }
  181. }
  182. private func reloadCustomSounds() {
  183. customSounds = CustomSoundStore.shared.list()
  184. }
  185. private func deleteCustomSounds(at offsets: IndexSet) {
  186. let deleted = offsets.map { customSounds[$0] }
  187. for sound in deleted {
  188. CustomSoundStore.shared.delete(sound.id)
  189. }
  190. // If the alarm was pointing at a sound we just deleted, fall back to a valid
  191. // built-in so the picker and the alarm don't keep a dangling reference.
  192. if deleted.contains(where: { SoundFile.custom($0.id) == selected }) {
  193. selected = .fallback
  194. }
  195. reloadCustomSounds()
  196. }
  197. private func handleImport(_ result: Result<[URL], Error>) {
  198. switch result {
  199. case let .success(urls):
  200. guard let url = urls.first else { return }
  201. do {
  202. let imported = try CustomSoundStore.shared.importFile(at: url)
  203. reloadCustomSounds()
  204. selected = .custom(imported.id)
  205. AlarmSound.setSoundFile(.custom(imported.id))
  206. AlarmSound.stop()
  207. AlarmSound.playTest()
  208. } catch {
  209. importError = error.localizedDescription
  210. }
  211. case let .failure(error):
  212. importError = error.localizedDescription
  213. }
  214. }
  215. }