diff --git a/LeCountdown.xcodeproj/project.pbxproj b/LeCountdown.xcodeproj/project.pbxproj index c2d9dd1..e3d591e 100644 --- a/LeCountdown.xcodeproj/project.pbxproj +++ b/LeCountdown.xcodeproj/project.pbxproj @@ -18,6 +18,7 @@ C4060DE3297AE73D003FAB80 /* LeCountdownUITestsLaunchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4060DE2297AE73D003FAB80 /* LeCountdownUITestsLaunchTests.swift */; }; C4060DF5297AE9A7003FAB80 /* TimeInterval+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4060DF4297AE9A7003FAB80 /* TimeInterval+Extensions.swift */; }; C4060DF7297AFEF2003FAB80 /* NewCountdownView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4060DF6297AFEF2003FAB80 /* NewCountdownView.swift */; }; + C40FDB622992985C0042A390 /* TextToSpeechRecorder.swift in Sources */ = {isa = PBXBuildFile; fileRef = C40FDB612992985C0042A390 /* TextToSpeechRecorder.swift */; }; C438C7C12980228B00BF3EF9 /* CountdownScheduler.swift in Sources */ = {isa = PBXBuildFile; fileRef = C438C7C02980228B00BF3EF9 /* CountdownScheduler.swift */; }; C438C7C5298024E900BF3EF9 /* NSManagedContext+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = C438C7C4298024E900BF3EF9 /* NSManagedContext+Extensions.swift */; }; C438C7C929803CA000BF3EF9 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = C438C7C829803CA000BF3EF9 /* AppDelegate.swift */; }; @@ -192,6 +193,8 @@ C4060DE2297AE73D003FAB80 /* LeCountdownUITestsLaunchTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LeCountdownUITestsLaunchTests.swift; sourceTree = ""; }; C4060DF4297AE9A7003FAB80 /* TimeInterval+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TimeInterval+Extensions.swift"; sourceTree = ""; }; C4060DF6297AFEF2003FAB80 /* NewCountdownView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewCountdownView.swift; sourceTree = ""; }; + C40FDB612992985C0042A390 /* TextToSpeechRecorder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextToSpeechRecorder.swift; sourceTree = ""; }; + C40FDB672993D5E80042A390 /* LeCountdown.0.4.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = LeCountdown.0.4.xcdatamodel; sourceTree = ""; }; C418A14F298428CB00C22230 /* LeCountdown.0.1.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = LeCountdown.0.1.xcdatamodel; sourceTree = ""; }; C438C7C02980228B00BF3EF9 /* CountdownScheduler.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CountdownScheduler.swift; sourceTree = ""; }; C438C7C4298024E900BF3EF9 /* NSManagedContext+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSManagedContext+Extensions.swift"; sourceTree = ""; }; @@ -437,6 +440,7 @@ C438C81029829EAF00BF3EF9 /* PropertyWrappers.swift */, C4060DF4297AE9A7003FAB80 /* TimeInterval+Extensions.swift */, C4742B5E2984205000D5D950 /* ViewModifiers.swift */, + C40FDB612992985C0042A390 /* TextToSpeechRecorder.swift */, ); path = Utils; sourceTree = ""; @@ -453,8 +457,6 @@ C498E59E298D4DEA00E90DE0 /* LiveTimerListView.swift */, C438C80E29828B8600BF3EF9 /* RecordsView.swift */, C498E5A2298D720600E90DE0 /* TestView.swift */, - C4742B5A298414B000D5D950 /* ImageSelectionView.swift */, - C4F8B165298A9ABB005C86A5 /* SoundImageFormView.swift */, C4F8B15E298961A7005C86A5 /* ReorderableForEach.swift */, ); path = Views; @@ -532,8 +534,10 @@ C4F8B1D3298BF686005C86A5 /* Components */ = { isa = PBXGroup; children = ( - C4F8B1D1298BF646005C86A5 /* PermissionAlertView.swift */, + C4742B5A298414B000D5D950 /* ImageSelectionView.swift */, C498E5A4299152B400E90DE0 /* GreenCheckmarkView.swift */, + C4F8B1D1298BF646005C86A5 /* PermissionAlertView.swift */, + C4F8B165298A9ABB005C86A5 /* SoundImageFormView.swift */, ); path = Components; sourceTree = ""; @@ -772,6 +776,7 @@ C4F8B184298AC234005C86A5 /* AbstractTimer+CoreDataClass.swift in Sources */, C4F8B17A298AC234005C86A5 /* Countdown+CoreDataClass.swift in Sources */, C4F8B164298A9A92005C86A5 /* AlarmFormView.swift in Sources */, + C40FDB622992985C0042A390 /* TextToSpeechRecorder.swift in Sources */, C4060DF7297AFEF2003FAB80 /* NewCountdownView.swift in Sources */, C4060DCC297AE73D003FAB80 /* LeCountdown.xcdatamodeld in Sources */, C4F8B17C298AC234005C86A5 /* Record+CoreDataClass.swift in Sources */, @@ -1323,12 +1328,13 @@ C4060DCA297AE73D003FAB80 /* LeCountdown.xcdatamodeld */ = { isa = XCVersionGroup; children = ( + C40FDB672993D5E80042A390 /* LeCountdown.0.4.xcdatamodel */, C4F8B160298A90E8005C86A5 /* LeCountdown.0.3.xcdatamodel */, C445FA902987C0CF0054D761 /* LeCountdown.0.2.xcdatamodel */, C418A14F298428CB00C22230 /* LeCountdown.0.1.xcdatamodel */, C4060DCB297AE73D003FAB80 /* LeCountdown.xcdatamodel */, ); - currentVersion = C4F8B160298A90E8005C86A5 /* LeCountdown.0.3.xcdatamodel */; + currentVersion = C40FDB672993D5E80042A390 /* LeCountdown.0.4.xcdatamodel */; path = LeCountdown.xcdatamodeld; sourceTree = ""; versionGroupType = wrapper.xcdatamodel; diff --git a/LeCountdown/AppDelegate.swift b/LeCountdown/AppDelegate.swift index ed80bac..bf81cbe 100644 --- a/LeCountdown/AppDelegate.swift +++ b/LeCountdown/AppDelegate.swift @@ -24,8 +24,13 @@ extension AppDelegate: UNUserNotificationCenterDelegate { func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse) async { print("didReceive notification") + let notificationId = response.notification.request.identifier + let components = notificationId.components(separatedBy: CountdownScheduler.notificationIdSeparator) - Conductor.maestro.stopSoundIfPossible() + if components.count == 2 { + Conductor.maestro.cancelCountdown(id: components[0]) + } + } func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) { diff --git a/LeCountdown/Conductor.swift b/LeCountdown/Conductor.swift index 2835b2a..1d3f6bd 100644 --- a/LeCountdown/Conductor.swift +++ b/LeCountdown/Conductor.swift @@ -123,6 +123,7 @@ class Conductor: ObservableObject { func cancelCountdown(id: String) { CountdownScheduler.master.cancelCurrentNotifications(countdownId: id) + self.stopSoundIfPossible() self._endCountdown(countdownId: id, cancel: true) } diff --git a/LeCountdown/CountdownScheduler.swift b/LeCountdown/CountdownScheduler.swift index 9a7dec1..65683df 100644 --- a/LeCountdown/CountdownScheduler.swift +++ b/LeCountdown/CountdownScheduler.swift @@ -12,13 +12,20 @@ class CountdownScheduler { static let master = CountdownScheduler() + static let notificationIdSeparator: String = "||" + func scheduleIfPossible(countdown: Countdown, handler: @escaping (Result) -> Void) { self.cancelCurrentNotifications(countdownId: countdown.stringId) self._scheduleCountdownNotification(countdown: countdown, handler: handler) } func cancelCurrentNotifications(countdownId: String) { - UNUserNotificationCenter.current().removePendingNotificationRequests(withIdentifiers: [countdownId]) + + UNUserNotificationCenter.current().getPendingNotificationRequests { requests in + let ids = requests.map { $0.identifier }.filter { $0.hasPrefix(countdownId) } + UNUserNotificationCenter.current().removePendingNotificationRequests(withIdentifiers: ids) + } + // Conductor.maestro.cancelCountdown(id: countdownId) } @@ -38,14 +45,14 @@ class CountdownScheduler { content.sound = UNNotificationSound(named: UNNotificationSoundName(rawValue: countdown.soundName)) content.interruptionLevel = .critical - self._createNotification(countdown: countdown, content: content, handler: handler) + let notificationCount = 1 + countdown.repeatCount -// if countdown.repeats { -// for i in 1...50 { -// let offset = Double(i) * (countdown.coolSound.duration + 1.0) -//// self._createNotification(countdown: countdown, offset: offset, content: content, handler: handler) -// } -// } +// self._createNotification(countdown: countdown, content: content, handler: handler) + + for i in 0..(entityName: "AbstractSoundTimer") } + @NSManaged public var repeatCount: Int16 @NSManaged public var sound: Int16 - @NSManaged public var repeats: Bool } diff --git a/LeCountdown/Model/LeCountdown.xcdatamodeld/.xccurrentversion b/LeCountdown/Model/LeCountdown.xcdatamodeld/.xccurrentversion index 538b004..bb066f0 100644 --- a/LeCountdown/Model/LeCountdown.xcdatamodeld/.xccurrentversion +++ b/LeCountdown/Model/LeCountdown.xcdatamodeld/.xccurrentversion @@ -3,6 +3,6 @@ _XCCurrentVersionName - LeCountdown.0.3.xcdatamodel + LeCountdown.0.4.xcdatamodel diff --git a/LeCountdown/Model/LeCountdown.xcdatamodeld/LeCountdown.0.4.xcdatamodel/contents b/LeCountdown/Model/LeCountdown.xcdatamodeld/LeCountdown.0.4.xcdatamodel/contents new file mode 100644 index 0000000..6ec8484 --- /dev/null +++ b/LeCountdown/Model/LeCountdown.xcdatamodeld/LeCountdown.0.4.xcdatamodel/contents @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/LeCountdown/Utils/TextToSpeechRecorder.swift b/LeCountdown/Utils/TextToSpeechRecorder.swift new file mode 100644 index 0000000..7738071 --- /dev/null +++ b/LeCountdown/Utils/TextToSpeechRecorder.swift @@ -0,0 +1,52 @@ +// +// TextToSpeechRecorder.swift +// LeCountdown +// +// Created by Laurent Morvillier on 07/02/2023. +// + +import Foundation +import AVFoundation + +struct TextToSpeechFile: Codable { + var speech: String + var fileName: String +} + +class TextToSpeechRecorder { + + static func record(speech: String, handler: @escaping (Result) -> Void) { + let synthesizer = AVSpeechSynthesizer() + let utterance = AVSpeechUtterance(string: speech) + + utterance.voice = AVSpeechSynthesisVoice() + var output: AVAudioFile? + + synthesizer.write(utterance) { buffer in + guard let pcmBuffer = buffer as? AVAudioPCMBuffer else { + fatalError("unknown buffer type: \(buffer)") + } + let fileName = "\(UUID().uuidString).caf" + if pcmBuffer.frameLength == 0 { + handler(.success(TextToSpeechFile(speech: speech, fileName: fileName))) + // done + } else { + // append buffer to file + do { + if output == nil { + + output = try AVAudioFile( + forWriting: URL(fileURLWithPath: fileName), + settings: pcmBuffer.format.settings, + commonFormat: .pcmFormatInt16, + interleaved: false) + } + try output?.write(from: pcmBuffer) + } catch { + handler(.failure(error)) + } + } + } + } + +} diff --git a/LeCountdown/Views/Alarm/AlarmFormView.swift b/LeCountdown/Views/Alarm/AlarmFormView.swift index 3120153..c6a7ce8 100644 --- a/LeCountdown/Views/Alarm/AlarmFormView.swift +++ b/LeCountdown/Views/Alarm/AlarmFormView.swift @@ -13,13 +13,13 @@ struct AlarmFormView: View { var imageBinding: Binding var soundBinding: Binding - var repeatsBinding: Binding + var repeatCountBinding: Binding var body: some View { Form { DatePicker("Time", selection: dateBinding, displayedComponents: .hourAndMinute) - SoundImageFormView(imageBinding: imageBinding, soundBinding: soundBinding, repeatsBinding: nil) + SoundImageFormView(imageBinding: imageBinding, soundBinding: soundBinding, repeatCountBinding: self.repeatCountBinding) } } } @@ -27,6 +27,6 @@ struct AlarmFormView: View { struct AlarmFormView_Previews: PreviewProvider { static var previews: some View { AlarmFormView(dateBinding: .constant(Date()), - imageBinding: .constant(.pic1), soundBinding: .constant(.trainhorn), repeatsBinding: .constant(true)) + imageBinding: .constant(.pic1), soundBinding: .constant(.trainhorn), repeatCountBinding: .constant(2)) } } diff --git a/LeCountdown/Views/Alarm/NewAlarmView.swift b/LeCountdown/Views/Alarm/NewAlarmView.swift index c435059..cd1625f 100644 --- a/LeCountdown/Views/Alarm/NewAlarmView.swift +++ b/LeCountdown/Views/Alarm/NewAlarmView.swift @@ -37,7 +37,7 @@ struct AlarmEditView: View { @State var nameString: String = "" @State var sound: Sound = .trainhorn - @State var soundRepeats: Bool = true + @State var soundRepeatCount: Int16 = 0 @State var image: CoolPic = .pic1 @State var deleteConfirmationShown: Bool = false @@ -68,7 +68,7 @@ struct AlarmEditView: View { AlarmFormView(dateBinding: self.$time, imageBinding: self.$image, soundBinding: self.$sound, - repeatsBinding: self.$soundRepeats) + repeatCountBinding: self.$soundRepeatCount) .onAppear { self._onAppear() } @@ -178,7 +178,7 @@ struct AlarmEditView: View { a.fireDate = self.time a.image = self.image.rawValue a.sound = Int16(self.sound.rawValue) - a.repeats = true + a.repeatCount = self.soundRepeatCount if !self.nameString.isEmpty { diff --git a/LeCountdown/Views/ImageSelectionView.swift b/LeCountdown/Views/Components/ImageSelectionView.swift similarity index 100% rename from LeCountdown/Views/ImageSelectionView.swift rename to LeCountdown/Views/Components/ImageSelectionView.swift diff --git a/LeCountdown/Views/SoundImageFormView.swift b/LeCountdown/Views/Components/SoundImageFormView.swift similarity index 72% rename from LeCountdown/Views/SoundImageFormView.swift rename to LeCountdown/Views/Components/SoundImageFormView.swift index 2060bee..ae52992 100644 --- a/LeCountdown/Views/SoundImageFormView.swift +++ b/LeCountdown/Views/Components/SoundImageFormView.swift @@ -11,7 +11,7 @@ struct SoundImageFormView : View { var imageBinding: Binding var soundBinding: Binding - var repeatsBinding: Binding? = nil + var repeatCountBinding: Binding? = nil var optionalSound: Binding? = nil @State var imageSelectionSheetShown: Bool = false @@ -46,8 +46,24 @@ struct SoundImageFormView : View { } } -// if self.repeatsBinding != nil { -// Toggle("Sound repeats", isOn: repeatsBinding!) + Picker("Repeat Count", selection: self.repeatCountBinding!) { + ForEach(0..<6) { + let count = Int16($0) + Text("\(count)").tag(count) + } + + } + +// if self.repeatCountBinding != nil { +// Picker(selection: self.repeatCountBinding!) { +// ForEach(0..<6) { count in +// Text("\(count)").tag(count) +// } +// } label: { +// Text("Repeat count") +// } +// +//// Toggle("Sound repeats", isOn: repeatsBinding!) // } } @@ -79,11 +95,13 @@ struct SoundImageFormView : View { } struct SoundImageFormView_Previews: PreviewProvider { + static var previews: some View { Form { - SoundImageFormView(imageBinding: .constant(.pic1), - soundBinding: .constant(.trainhorn), - repeatsBinding: .constant(true)) + SoundImageFormView( + imageBinding: .constant(.pic1), + soundBinding: .constant(.forestStream), + repeatCountBinding: .constant(2)) } } } diff --git a/LeCountdown/Views/ContentView.swift b/LeCountdown/Views/ContentView.swift index 30832f9..ccfbe3a 100644 --- a/LeCountdown/Views/ContentView.swift +++ b/LeCountdown/Views/ContentView.swift @@ -154,6 +154,9 @@ struct ContentView: View { fileprivate func _performActionIfPossible(url: URL) { + // hide new window if launching a timer + self.boringContext.isShowingNewData = false + print("_performActionIfPossible") let urlString = url.absoluteString diff --git a/LeCountdown/Views/Countdown/CountdownFormView.swift b/LeCountdown/Views/Countdown/CountdownFormView.swift index 120e58c..332ff9b 100644 --- a/LeCountdown/Views/Countdown/CountdownFormView.swift +++ b/LeCountdown/Views/Countdown/CountdownFormView.swift @@ -16,7 +16,7 @@ struct CountdownFormView : View { var imageBinding: Binding var soundBinding: Binding - var repeatsBinding: Binding + var repeatCountBinding: Binding var textFieldIsFocused: FocusState.Binding @@ -35,9 +35,10 @@ struct CountdownFormView : View { .focused(textFieldIsFocused) } - SoundImageFormView(imageBinding: imageBinding, - soundBinding: soundBinding, - repeatsBinding: repeatsBinding) + SoundImageFormView( + imageBinding: imageBinding, + soundBinding: soundBinding, + repeatCountBinding: repeatCountBinding) } } @@ -50,6 +51,6 @@ struct CountdownFormView_Previews: PreviewProvider { static var previews: some View { CountdownFormView(secondsBinding: .constant(""), minutesBinding: .constant(""), - nameBinding: .constant(""), imageBinding: .constant(.pic3), soundBinding: .constant(.trainhorn), repeatsBinding: .constant(true), textFieldIsFocused: $textFieldIsFocused) + nameBinding: .constant(""), imageBinding: .constant(.pic3), soundBinding: .constant(.trainhorn), repeatCountBinding: .constant(2), textFieldIsFocused: $textFieldIsFocused) } } diff --git a/LeCountdown/Views/Countdown/NewCountdownView.swift b/LeCountdown/Views/Countdown/NewCountdownView.swift index 94abca9..5ea5024 100644 --- a/LeCountdown/Views/Countdown/NewCountdownView.swift +++ b/LeCountdown/Views/Countdown/NewCountdownView.swift @@ -37,7 +37,7 @@ struct CountdownEditView : View { @State var nameString: String = "" @State var sound: Sound = .trainhorn - @State var soundRepeats: Bool = true + @State var soundRepeatCount: Int16 = 0 @State var image: CoolPic = .pic1 @State var deleteConfirmationShown: Bool = false @@ -71,7 +71,7 @@ struct CountdownEditView : View { nameBinding: $nameString, imageBinding: $image, soundBinding: $sound, - repeatsBinding: $soundRepeats, + repeatCountBinding: $soundRepeatCount, textFieldIsFocused: $textFieldIsFocused) .onAppear { self._onAppear() @@ -160,7 +160,7 @@ struct CountdownEditView : View { if let sound = Sound(rawValue: Int(countdown.sound)) { self.sound = sound } - self.soundRepeats = countdown.repeats + self.soundRepeatCount = countdown.repeatCount if let image = countdown.image, let coolpic = CoolPic(rawValue: image) { self.image = coolpic @@ -201,7 +201,7 @@ struct CountdownEditView : View { cd.image = self.image.rawValue cd.sound = Int16(self.sound.rawValue) - cd.repeats = self.soundRepeats + cd.repeatCount = self.soundRepeatCount if !self.nameString.isEmpty { diff --git a/LeCountdown/Views/LiveTimerListView.swift b/LeCountdown/Views/LiveTimerListView.swift index e9c60d6..4cfcf10 100644 --- a/LeCountdown/Views/LiveTimerListView.swift +++ b/LeCountdown/Views/LiveTimerListView.swift @@ -130,7 +130,9 @@ struct LiveCountdownView: View { GreenCheckmarkView() } } - }.onTapGesture { + } + .contentShape(Rectangle()) // make the onTap react everywhere + .onTapGesture { withAnimation { self._dismiss() } @@ -143,6 +145,7 @@ struct LiveCountdownView: View { } fileprivate func _dismiss() { + conductor.cancelCountdown(id: self.countdown.stringId) conductor.removeLiveTimer(id: self.countdown.stringId) }