| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236 |
- // LoopFollow
- // AlarmAudioSection.swift
- import SwiftUI
- import UniformTypeIdentifiers
- struct AlarmAudioSection: View {
- @Binding var alarm: Alarm
- var hideRepeat: Bool = false
- @State private var showingTonePicker = false
- var body: some View {
- Section(header: Text("Alert Sound")) {
- Button {
- showingTonePicker = true
- } label: {
- HStack {
- Text("Tone")
- Spacer()
- Text(alarm.soundFile.displayName)
- .foregroundColor(.secondary)
- Image(systemName: "chevron.right")
- .foregroundColor(.secondary)
- }
- }
- .buttonStyle(.plain)
- .sheet(isPresented: $showingTonePicker) {
- TonePickerSheet(selected: $alarm.soundFile)
- }
- AlarmEnumMenuPicker(
- title: String(localized: "Play"),
- selection: $alarm.playSoundOption,
- allowed: PlaySoundOption.allowed(for: alarm.activeOption)
- )
- if !hideRepeat {
- AlarmEnumMenuPicker(
- title: String(localized: "Repeat"),
- selection: $alarm.repeatSoundOption,
- allowed: RepeatSoundOption.allowed(for: alarm.activeOption)
- )
- }
- Stepper(
- value: $alarm.soundDelay,
- in: 0 ... 60,
- step: 5
- ) {
- HStack {
- Text("Delay Between Sounds")
- Spacer()
- if alarm.soundDelay == 0 {
- Text("Off")
- .foregroundColor(.secondary)
- } else {
- Text("\(alarm.soundDelay) sec")
- .foregroundColor(.secondary)
- }
- }
- }
- }.onChange(of: alarm.activeOption) { newActive in
- let playAllowed = PlaySoundOption.allowed(for: newActive)
- if !playAllowed.contains(alarm.playSoundOption) {
- alarm.playSoundOption = playAllowed.last!
- }
- let repeatAllowed = RepeatSoundOption.allowed(for: newActive)
- if !repeatAllowed.contains(alarm.repeatSoundOption) {
- alarm.repeatSoundOption = repeatAllowed.last!
- }
- }
- }
- }
- struct AlarmEnumMenuPicker<E: CaseIterable & Hashable & DayNightDisplayable>: View {
- let title: String
- @Binding var selection: E
- var allowed: [E]
- var body: some View {
- HStack {
- Text(title)
- Spacer()
- Picker("", selection: $selection) {
- ForEach(allowed, id: \.self) { opt in
- Text(opt.displayName).tag(opt)
- }
- }
- // if the current selection became invalid, snap to the first allowed
- .onAppear { validate() }
- .onChange(of: allowed) { _ in validate() }
- }
- }
- private func validate() {
- if !allowed.contains(selection), let first = allowed.first {
- selection = first
- }
- }
- }
- extension AlarmEnumMenuPicker where E: CaseIterable {
- init(title: String, selection: Binding<E>) {
- self.title = title
- _selection = selection
- allowed = Array(E.allCases)
- }
- }
- private struct TonePickerSheet: View {
- @Binding var selected: SoundFile
- @Environment(\.dismiss) private var dismiss
- @State private var customSounds: [CustomSound] = []
- @State private var showingImporter = false
- @State private var importError: String?
- var body: some View {
- NavigationView {
- ScrollViewReader { proxy in
- List {
- Section {
- ForEach(customSounds) { sound in
- toneRow(tone: .custom(sound.id), label: sound.displayName)
- .id(SoundFile.custom(sound.id))
- }
- .onDelete(perform: deleteCustomSounds)
- Button {
- showingImporter = true
- } label: {
- Label("Import Sound…", systemImage: "plus.circle")
- }
- } header: {
- Text("Custom")
- } footer: {
- Text("Custom sounds stay on this device and aren't included in settings export.")
- }
- Section(header: Text("Built-in")) {
- ForEach(SoundFile.allBuiltins) { tone in
- toneRow(tone: tone, label: tone.displayName)
- .id(tone)
- }
- }
- }
- .navigationTitle("Choose Tone")
- .toolbar {
- ToolbarItem(placement: .confirmationAction) {
- Button("Done") {
- AlarmSound.stop()
- dismiss()
- }
- }
- }
- .onAppear {
- reloadCustomSounds()
- proxy.scrollTo(selected, anchor: .center)
- }
- .fileImporter(
- isPresented: $showingImporter,
- allowedContentTypes: [.audio],
- allowsMultipleSelection: false
- ) { result in
- handleImport(result)
- }
- .alert(
- "Import Failed",
- isPresented: Binding(
- get: { importError != nil },
- set: { if !$0 { importError = nil } }
- ),
- actions: { Button("OK", role: .cancel) { importError = nil } },
- message: { Text(importError ?? "") }
- )
- }
- }
- }
- @ViewBuilder
- private func toneRow(tone: SoundFile, label: String) -> some View {
- Button {
- selected = tone
- AlarmSound.setSoundFile(tone)
- AlarmSound.stop()
- AlarmSound.playTest()
- } label: {
- HStack {
- Text(label)
- if tone == selected {
- Spacer()
- Image(systemName: "checkmark")
- .foregroundColor(.accentColor)
- }
- }
- }
- }
- private func reloadCustomSounds() {
- customSounds = CustomSoundStore.shared.list()
- }
- private func deleteCustomSounds(at offsets: IndexSet) {
- let deleted = offsets.map { customSounds[$0] }
- for sound in deleted {
- CustomSoundStore.shared.delete(sound.id)
- }
- // If the alarm was pointing at a sound we just deleted, fall back to a valid
- // built-in so the picker and the alarm don't keep a dangling reference.
- if deleted.contains(where: { SoundFile.custom($0.id) == selected }) {
- selected = .fallback
- }
- reloadCustomSounds()
- }
- private func handleImport(_ result: Result<[URL], Error>) {
- switch result {
- case let .success(urls):
- guard let url = urls.first else { return }
- do {
- let imported = try CustomSoundStore.shared.importFile(at: url)
- reloadCustomSounds()
- selected = .custom(imported.id)
- AlarmSound.setSoundFile(.custom(imported.id))
- AlarmSound.stop()
- AlarmSound.playTest()
- } catch {
- importError = error.localizedDescription
- }
- case let .failure(error):
- importError = error.localizedDescription
- }
- }
- }
|