Skip to content

Commit cdaa672

Browse files
authored
Add custom alarm sounds (#699)
* Add custom alarm sounds Users can now import their own audio for alarm tones via the tone picker (document picker, `.audio` UTType) or by dropping files into the app's Documents folder from the Files app. Imported files are copied into `Documents/CustomSounds/`, validated with AVAudioPlayer, and capped at 2 MB / 30 s. SoundFile is now an enum with `.builtin(String)` and `.custom(UUID)` cases. Built-in values encode as a bare string so existing stored alarms keep loading; custom values encode as a keyed object. Static aliases (`.indeed`, `.wrongAnswer`, …) preserve all existing call sites. AlarmSound.setSoundFile now resolves built-in via the bundle and custom via CustomSoundStore, and falls back to the default built-in with a log when a file is missing (deleted custom sound, etc.) instead of crashing. * Harden custom alarm sound imports Validate files dropped into Documents in place before moving them into the store, applying the same size, duration, and format checks as the in-app importer. This prevents a user's file from being deleted when it fails the audio check and stops oversized files from bypassing the caps. Reset the alarm's sound to the fallback built-in when the selected custom sound is deleted, and avoid rewriting the index when nothing changed. * Prevent duplicate custom sound when importing from shared folder Move the picked file into the store instead of copying when it already lives in the app's shared Documents folder, so absorbDroppedFiles() doesn't re-ingest the leftover original as a second entry. Validate before the file operation so a rejected import never deletes the user's original.
1 parent ac18d68 commit cdaa672

6 files changed

Lines changed: 594 additions & 130 deletions

File tree

LoopFollow.xcodeproj/project.pbxproj

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,7 @@
261261
DDCC3A5A2DDC988F006F1C10 /* CarbSample.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCC3A592DDC988F006F1C10 /* CarbSample.swift */; };
262262
DDCC3A5B2DDE2000006F1C10 /* PumpBatteryAlarmEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCC3A5C2DDE2000006F1C10 /* PumpBatteryAlarmEditor.swift */; };
263263
DDCF9A802D85FD0B004DF4DD /* Alarm.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCF9A7F2D85FD09004DF4DD /* Alarm.swift */; };
264+
DDCF9AA02E99000000000001 /* CustomSoundStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCF9AA02E99000000000002 /* CustomSoundStore.swift */; };
264265
DDCF9A822D85FD15004DF4DD /* AlarmType.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCF9A812D85FD14004DF4DD /* AlarmType.swift */; };
265266
DDCF9A882D85FD33004DF4DD /* AlarmData.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCF9A872D85FD33004DF4DD /* AlarmData.swift */; };
266267
DDCF9A8C2D86005E004DF4DD /* AlarmManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCF9A8B2D86005E004DF4DD /* AlarmManager.swift */; };
@@ -727,6 +728,7 @@
727728
DDCC3ABF2DDE10B0006F1C10 /* Testing.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Testing.framework; path = Platforms/iPhoneOS.platform/Developer/Library/Frameworks/Testing.framework; sourceTree = DEVELOPER_DIR; };
728729
DDCC3AD62DDE1790006F1C10 /* Tests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = Tests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
729730
DDCF9A7F2D85FD09004DF4DD /* Alarm.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Alarm.swift; sourceTree = "<group>"; };
731+
DDCF9AA02E99000000000002 /* CustomSoundStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomSoundStore.swift; sourceTree = "<group>"; };
730732
DDCF9A812D85FD14004DF4DD /* AlarmType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlarmType.swift; sourceTree = "<group>"; };
731733
DDCF9A872D85FD33004DF4DD /* AlarmData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlarmData.swift; sourceTree = "<group>"; };
732734
DDCF9A8B2D86005E004DF4DD /* AlarmManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlarmManager.swift; sourceTree = "<group>"; };
@@ -1434,6 +1436,7 @@
14341436
DDCF9A872D85FD33004DF4DD /* AlarmData.swift */,
14351437
DDCF9A7F2D85FD09004DF4DD /* Alarm.swift */,
14361438
DDBD19952DFB44B0005C2D69 /* Alarm+byPriorityThenSpec.swift */,
1439+
DDCF9AA02E99000000000002 /* CustomSoundStore.swift */,
14371440
);
14381441
path = Alarm;
14391442
sourceTree = "<group>";
@@ -2468,6 +2471,7 @@
24682471
DD0650F52DCF303F004D3B41 /* AlarmStepperSection.swift in Sources */,
24692472
379BECAA2F6588300069DC62 /* RestartLiveActivityIntent.swift in Sources */,
24702473
DDCF9A802D85FD0B004DF4DD /* Alarm.swift in Sources */,
2474+
DDCF9AA02E99000000000001 /* CustomSoundStore.swift in Sources */,
24712475
DD7F4C132DD51FD500D449E9 /* TempTargetEndCondition.swift in Sources */,
24722476
DD16AF0D2C98485400FB655A /* SecureStorageValue.swift in Sources */,
24732477
DDC7E5CF2DC77C2000EB1127 /* SnoozerViewModel.swift in Sources */,

LoopFollow/Alarm/Alarm.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -280,7 +280,7 @@ struct Alarm: Identifiable, Codable, Equatable {
280280
AlarmManager.shared.sendNotification(title: type.rawValue, actionTitle: snoozeDuration == 0 ? "Acknowledge" : "Snooze")
281281

282282
if playSound {
283-
AlarmSound.setSoundFile(str: soundFile.rawValue)
283+
AlarmSound.setSoundFile(soundFile)
284284
// Only use delay if repeating is enabled, otherwise delay doesn't make sense
285285
let delay = shouldRepeat ? soundDelay : 0
286286
AlarmSound.play(repeating: shouldRepeat, delay: delay)

LoopFollow/Alarm/AlarmEditing/Components/AlarmAudioSection.swift

Lines changed: 97 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// AlarmAudioSection.swift
33

44
import SwiftUI
5+
import UniformTypeIdentifiers
56

67
struct AlarmAudioSection: View {
78
@Binding var alarm: Alarm
@@ -111,27 +112,37 @@ private struct TonePickerSheet: View {
111112
@Binding var selected: SoundFile
112113
@Environment(\.dismiss) private var dismiss
113114

115+
@State private var customSounds: [CustomSound] = []
116+
@State private var showingImporter = false
117+
@State private var importError: String?
118+
114119
var body: some View {
115120
NavigationView {
116121
ScrollViewReader { proxy in
117122
List {
118-
ForEach(SoundFile.allCases) { tone in
123+
Section {
124+
ForEach(customSounds) { sound in
125+
toneRow(tone: .custom(sound.id), label: sound.displayName)
126+
.id(SoundFile.custom(sound.id))
127+
}
128+
.onDelete(perform: deleteCustomSounds)
129+
119130
Button {
120-
selected = tone
121-
AlarmSound.setSoundFile(str: tone.rawValue)
122-
AlarmSound.stop()
123-
AlarmSound.playTest()
131+
showingImporter = true
124132
} label: {
125-
HStack {
126-
Text(tone.displayName)
127-
if tone == selected {
128-
Spacer()
129-
Image(systemName: "checkmark")
130-
.foregroundColor(.accentColor)
131-
}
132-
}
133+
Label("Import Sound…", systemImage: "plus.circle")
134+
}
135+
} header: {
136+
Text("Custom")
137+
} footer: {
138+
Text("Custom sounds stay on this device and aren't included in settings export.")
139+
}
140+
141+
Section(header: Text("Built-in")) {
142+
ForEach(SoundFile.allBuiltins) { tone in
143+
toneRow(tone: tone, label: tone.displayName)
144+
.id(tone)
133145
}
134-
.id(tone)
135146
}
136147
}
137148
.navigationTitle("Choose Tone")
@@ -144,9 +155,81 @@ private struct TonePickerSheet: View {
144155
}
145156
}
146157
.onAppear {
158+
reloadCustomSounds()
147159
proxy.scrollTo(selected, anchor: .center)
148160
}
161+
.fileImporter(
162+
isPresented: $showingImporter,
163+
allowedContentTypes: [.audio],
164+
allowsMultipleSelection: false
165+
) { result in
166+
handleImport(result)
167+
}
168+
.alert(
169+
"Import Failed",
170+
isPresented: Binding(
171+
get: { importError != nil },
172+
set: { if !$0 { importError = nil } }
173+
),
174+
actions: { Button("OK", role: .cancel) { importError = nil } },
175+
message: { Text(importError ?? "") }
176+
)
177+
}
178+
}
179+
}
180+
181+
@ViewBuilder
182+
private func toneRow(tone: SoundFile, label: String) -> some View {
183+
Button {
184+
selected = tone
185+
AlarmSound.setSoundFile(tone)
186+
AlarmSound.stop()
187+
AlarmSound.playTest()
188+
} label: {
189+
HStack {
190+
Text(label)
191+
if tone == selected {
192+
Spacer()
193+
Image(systemName: "checkmark")
194+
.foregroundColor(.accentColor)
195+
}
196+
}
197+
}
198+
}
199+
200+
private func reloadCustomSounds() {
201+
customSounds = CustomSoundStore.shared.list()
202+
}
203+
204+
private func deleteCustomSounds(at offsets: IndexSet) {
205+
let deleted = offsets.map { customSounds[$0] }
206+
for sound in deleted {
207+
CustomSoundStore.shared.delete(sound.id)
208+
}
209+
// If the alarm was pointing at a sound we just deleted, fall back to a valid
210+
// built-in so the picker and the alarm don't keep a dangling reference.
211+
if deleted.contains(where: { SoundFile.custom($0.id) == selected }) {
212+
selected = .fallback
213+
}
214+
reloadCustomSounds()
215+
}
216+
217+
private func handleImport(_ result: Result<[URL], Error>) {
218+
switch result {
219+
case let .success(urls):
220+
guard let url = urls.first else { return }
221+
do {
222+
let imported = try CustomSoundStore.shared.importFile(at: url)
223+
reloadCustomSounds()
224+
selected = .custom(imported.id)
225+
AlarmSound.setSoundFile(.custom(imported.id))
226+
AlarmSound.stop()
227+
AlarmSound.playTest()
228+
} catch {
229+
importError = error.localizedDescription
149230
}
231+
case let .failure(error):
232+
importError = error.localizedDescription
150233
}
151234
}
152235
}

0 commit comments

Comments
 (0)