diff --git a/LoopFollow/Alarm/Alarm.swift b/LoopFollow/Alarm/Alarm.swift index 08dfc2cb9..33b43848e 100644 --- a/LoopFollow/Alarm/Alarm.swift +++ b/LoopFollow/Alarm/Alarm.swift @@ -90,9 +90,83 @@ struct Alarm: Identifiable, Codable, Equatable { /// When the sound should repeat var repeatSoundOption: RepeatSoundOption = .always + /// Delay in seconds between repeated alarm sounds (0 = no delay, only applies when repeating) + var soundDelay: Int = 0 + /// When is the alarm active var activeOption: ActiveOption = .always + // MARK: - Codable (Custom implementation for backward compatibility) + + enum CodingKeys: String, CodingKey { + case id, type, name, isEnabled, snoozedUntil + case aboveBG, belowBG, threshold, predictiveMinutes, delta + case persistentMinutes, monitoringWindow, soundFile + case snoozeDuration, playSoundOption, repeatSoundOption + case soundDelay, activeOption + case missedBolusPrebolusWindow, missedBolusIgnoreSmallBolusUnits + case missedBolusIgnoreUnderGrams, missedBolusIgnoreUnderBG + case bolusCountThreshold, bolusWindowMinutes + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + id = try container.decode(UUID.self, forKey: .id) + type = try container.decode(AlarmType.self, forKey: .type) + name = try container.decode(String.self, forKey: .name) + isEnabled = try container.decodeIfPresent(Bool.self, forKey: .isEnabled) ?? true + snoozedUntil = try container.decodeIfPresent(Date.self, forKey: .snoozedUntil) + aboveBG = try container.decodeIfPresent(Double.self, forKey: .aboveBG) + belowBG = try container.decodeIfPresent(Double.self, forKey: .belowBG) + threshold = try container.decodeIfPresent(Double.self, forKey: .threshold) + predictiveMinutes = try container.decodeIfPresent(Int.self, forKey: .predictiveMinutes) + delta = try container.decodeIfPresent(Double.self, forKey: .delta) + persistentMinutes = try container.decodeIfPresent(Int.self, forKey: .persistentMinutes) + monitoringWindow = try container.decodeIfPresent(Int.self, forKey: .monitoringWindow) + soundFile = try container.decode(SoundFile.self, forKey: .soundFile) + snoozeDuration = try container.decodeIfPresent(Int.self, forKey: .snoozeDuration) ?? 5 + playSoundOption = try container.decodeIfPresent(PlaySoundOption.self, forKey: .playSoundOption) ?? .always + repeatSoundOption = try container.decodeIfPresent(RepeatSoundOption.self, forKey: .repeatSoundOption) ?? .always + // Handle backward compatibility: default to 0 if soundDelay is missing + soundDelay = try container.decodeIfPresent(Int.self, forKey: .soundDelay) ?? 0 + activeOption = try container.decodeIfPresent(ActiveOption.self, forKey: .activeOption) ?? .always + missedBolusPrebolusWindow = try container.decodeIfPresent(Int.self, forKey: .missedBolusPrebolusWindow) + missedBolusIgnoreSmallBolusUnits = try container.decodeIfPresent(Double.self, forKey: .missedBolusIgnoreSmallBolusUnits) + missedBolusIgnoreUnderGrams = try container.decodeIfPresent(Double.self, forKey: .missedBolusIgnoreUnderGrams) + missedBolusIgnoreUnderBG = try container.decodeIfPresent(Double.self, forKey: .missedBolusIgnoreUnderBG) + bolusCountThreshold = try container.decodeIfPresent(Int.self, forKey: .bolusCountThreshold) + bolusWindowMinutes = try container.decodeIfPresent(Int.self, forKey: .bolusWindowMinutes) + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(id, forKey: .id) + try container.encode(type, forKey: .type) + try container.encode(name, forKey: .name) + try container.encode(isEnabled, forKey: .isEnabled) + try container.encodeIfPresent(snoozedUntil, forKey: .snoozedUntil) + try container.encodeIfPresent(aboveBG, forKey: .aboveBG) + try container.encodeIfPresent(belowBG, forKey: .belowBG) + try container.encodeIfPresent(threshold, forKey: .threshold) + try container.encodeIfPresent(predictiveMinutes, forKey: .predictiveMinutes) + try container.encodeIfPresent(delta, forKey: .delta) + try container.encodeIfPresent(persistentMinutes, forKey: .persistentMinutes) + try container.encodeIfPresent(monitoringWindow, forKey: .monitoringWindow) + try container.encode(soundFile, forKey: .soundFile) + try container.encode(snoozeDuration, forKey: .snoozeDuration) + try container.encode(playSoundOption, forKey: .playSoundOption) + try container.encode(repeatSoundOption, forKey: .repeatSoundOption) + try container.encode(soundDelay, forKey: .soundDelay) + try container.encode(activeOption, forKey: .activeOption) + try container.encodeIfPresent(missedBolusPrebolusWindow, forKey: .missedBolusPrebolusWindow) + try container.encodeIfPresent(missedBolusIgnoreSmallBolusUnits, forKey: .missedBolusIgnoreSmallBolusUnits) + try container.encodeIfPresent(missedBolusIgnoreUnderGrams, forKey: .missedBolusIgnoreUnderGrams) + try container.encodeIfPresent(missedBolusIgnoreUnderBG, forKey: .missedBolusIgnoreUnderBG) + try container.encodeIfPresent(bolusCountThreshold, forKey: .bolusCountThreshold) + try container.encodeIfPresent(bolusWindowMinutes, forKey: .bolusWindowMinutes) + } + // ───────────────────────────────────────────────────────────── // Missed‑Bolus‑specific settings // ───────────────────────────────────────────────────────────── @@ -180,7 +254,9 @@ struct Alarm: Identifiable, Codable, Equatable { if playSound { AlarmSound.setSoundFile(str: soundFile.rawValue) - AlarmSound.play(repeating: shouldRepeat) + // Only use delay if repeating is enabled, otherwise delay doesn't make sense + let delay = shouldRepeat ? soundDelay : 0 + AlarmSound.play(repeating: shouldRepeat, delay: delay) } } diff --git a/LoopFollow/Alarm/AlarmEditing/Components/AlarmAudioSection.swift b/LoopFollow/Alarm/AlarmEditing/Components/AlarmAudioSection.swift index 81689d5e0..5d5beb1bd 100644 --- a/LoopFollow/Alarm/AlarmEditing/Components/AlarmAudioSection.swift +++ b/LoopFollow/Alarm/AlarmEditing/Components/AlarmAudioSection.swift @@ -40,6 +40,24 @@ struct AlarmAudioSection: View { 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) { diff --git a/LoopFollow/Controllers/AlarmSound.swift b/LoopFollow/Controllers/AlarmSound.swift index 13657d2bd..6bb8cc5f2 100644 --- a/LoopFollow/Controllers/AlarmSound.swift +++ b/LoopFollow/Controllers/AlarmSound.swift @@ -33,6 +33,8 @@ class AlarmSound { fileprivate static var alarmPlayingForTimer = Timer() fileprivate static let alarmPlayingForInterval = 290 + fileprivate static var repeatDelay: TimeInterval = 0 + fileprivate func startAlarmPlayingForTimer(time: TimeInterval) { AlarmSound.alarmPlayingForTimer = Timer.scheduledTimer(timeInterval: time, target: self, @@ -70,6 +72,7 @@ class AlarmSound { static func stop() { Observable.shared.alarmSoundPlaying.value = false + repeatDelay = 0 audioPlayer?.stop() audioPlayer = nil @@ -108,13 +111,17 @@ class AlarmSound { } } - static func play(repeating: Bool) { + static func play(repeating: Bool, delay: Int = 0) { guard !isPlaying else { return } enableAudio() + // If repeating with delay, we'll handle it manually via the delegate + // Only set repeatDelay if both repeating and delay > 0 + repeatDelay = (repeating && delay > 0) ? TimeInterval(delay) : 0 + do { audioPlayer = try AVAudioPlayer(contentsOf: soundURL) audioPlayer!.delegate = audioPlayerDelegate @@ -122,7 +129,9 @@ class AlarmSound { try AVAudioSession.sharedInstance().setCategory(AVAudioSession.Category(rawValue: convertFromAVAudioSessionCategory(AVAudioSession.Category.playback))) try AVAudioSession.sharedInstance().setActive(true) - audioPlayer!.numberOfLoops = repeating ? -1 : 0 + // Only use numberOfLoops if we're not using delay-based repeating + // When repeatDelay > 0, we play once and then use the delegate to schedule the next play with delay + audioPlayer!.numberOfLoops = (repeating && repeatDelay == 0) ? -1 : 0 // Store existing volume if systemOutputVolumeBeforeOverride == nil { @@ -133,12 +142,16 @@ class AlarmSound { LogManager.shared.log(category: .alarm, message: "AlarmSound - audio player failed preparing to play") } + // First sound plays immediately - delay only applies between repeated sounds if audioPlayer!.play() { if !isPlaying { LogManager.shared.log(category: .alarm, message: "AlarmSound - not playing after calling play") LogManager.shared.log(category: .alarm, message: "AlarmSound - rate value: \(audioPlayer!.rate)") } else { Observable.shared.alarmSoundPlaying.value = true + if repeatDelay > 0 { + LogManager.shared.log(category: .alarm, message: "AlarmSound - first sound playing immediately, delay (\(repeatDelay)s) will apply between repeats") + } } } else { LogManager.shared.log(category: .alarm, message: "AlarmSound - audio player failed to play") @@ -152,6 +165,45 @@ class AlarmSound { } } + fileprivate static func playNextWithDelay() { + guard repeatDelay > 0 else { + return + } + + DispatchQueue.main.asyncAfter(deadline: .now() + repeatDelay) { + // Check if we should still be playing (user might have stopped it) + guard repeatDelay > 0 else { + return + } + + // Clean up the previous player + audioPlayer?.stop() + audioPlayer = nil + + do { + audioPlayer = try AVAudioPlayer(contentsOf: soundURL) + audioPlayer!.delegate = audioPlayerDelegate + + try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default, options: []) + try AVAudioSession.sharedInstance().setActive(true) + + audioPlayer!.numberOfLoops = 0 + + if !audioPlayer!.prepareToPlay() { + LogManager.shared.log(category: .alarm, message: "AlarmSound - audio player failed preparing to play (delayed repeat)") + } + + if audioPlayer!.play() { + Observable.shared.alarmSoundPlaying.value = true + } else { + LogManager.shared.log(category: .alarm, message: "AlarmSound - audio player failed to play (delayed repeat)") + } + } catch { + LogManager.shared.log(category: .alarm, message: "AlarmSound - unable to play sound (delayed repeat); error: \(error)") + } + } + } + static func playTerminated() { guard !isPlaying else { return @@ -222,7 +274,13 @@ class AudioPlayerDelegate: NSObject, AVAudioPlayerDelegate { /* audioPlayerDidFinishPlaying:successfully: is called when a sound has finished playing. This method is NOT called if the player is stopped due to an interruption. */ func audioPlayerDidFinishPlaying(_: AVAudioPlayer, successfully flag: Bool) { LogManager.shared.log(category: .alarm, message: "AlarmRule - audioPlayerDidFinishPlaying (\(flag))", isDebug: true) - Observable.shared.alarmSoundPlaying.value = false + + // If we're repeating with delay, schedule the next play + if AlarmSound.repeatDelay > 0 { + AlarmSound.playNextWithDelay() + } else { + Observable.shared.alarmSoundPlaying.value = false + } } /* if an error occurs while decoding it will be reported to the delegate. */