From f4975cb87620434a0dfddb2d43998072671be1be Mon Sep 17 00:00:00 2001 From: Laurent Date: Fri, 19 May 2023 15:16:35 +0200 Subject: [PATCH] Adds button to pause timers --- LeCountdown/AppDelegate.swift | 4 +- LeCountdown/Conductor.swift | 149 ++++++++++++++++++---- LeCountdown/Utils/Preferences.swift | 1 + LeCountdown/Views/LiveTimerListView.swift | 132 ++++++++++++++++--- 4 files changed, 242 insertions(+), 44 deletions(-) diff --git a/LeCountdown/AppDelegate.swift b/LeCountdown/AppDelegate.swift index e5de4eb..d46ff55 100644 --- a/LeCountdown/AppDelegate.swift +++ b/LeCountdown/AppDelegate.swift @@ -110,8 +110,8 @@ extension AppDelegate: UNUserNotificationCenterDelegate { print("willPresent notification") // completionHandler([.sound]) - let timerId = self._timerId(notificationId: notification.request.identifier) - Conductor.maestro.notifyUser(countdownId: timerId) +// let timerId = self._timerId(notificationId: notification.request.identifier) +// Conductor.maestro.notifyUser(countdownId: timerId) } fileprivate func _timerId(notificationId: String) -> TimerID { diff --git a/LeCountdown/Conductor.swift b/LeCountdown/Conductor.swift index 46f339f..4b2cd63 100644 --- a/LeCountdown/Conductor.swift +++ b/LeCountdown/Conductor.swift @@ -22,6 +22,13 @@ fileprivate enum Const: String { case cancellationSound = "MRKRSTPHR_synth_one_shot_bleep_G.wav" } +enum CountdownState { + case inprogress + case paused + case finished + case cancelled +} + class Conductor: ObservableObject { static let maestro: Conductor = Conductor() @@ -30,7 +37,10 @@ class Conductor: ObservableObject { fileprivate var _delayedSoundPlayers: [TimerID : DelaySoundPlayer] = [:] + fileprivate var beats: Timer? = nil + @UserDefault(PreferenceKey.countdowns.rawValue, defaultValue: [:]) static var savedCountdowns: [String : DateInterval] + @UserDefault(PreferenceKey.pausedCountdowns.rawValue, defaultValue: [:]) static var savedPausedCountdowns: [String : TimeInterval] @UserDefault(PreferenceKey.stopwatches.rawValue, defaultValue: [:]) static var savedStopwatches: [String : LiveStopWatch] @Published private (set) var liveTimers: [LiveTimer] = [] @@ -40,6 +50,11 @@ class Conductor: ObservableObject { init() { self.currentCountdowns = Conductor.savedCountdowns self.currentStopwatches = Conductor.savedStopwatches + self.pausedCountdowns = Conductor.savedPausedCountdowns + + self.beats = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true, block: { _ in + self._cleanupCountdowns() + }) } @Published var cancelledCountdowns: [String] = [] @@ -54,6 +69,15 @@ class Conductor: ObservableObject { } } + @Published var pausedCountdowns: [String : TimeInterval] = [:] { + didSet { + Conductor.savedPausedCountdowns = pausedCountdowns + withAnimation { + self._buildLiveTimers() + } + } + } + @Published var currentStopwatches: [String : LiveStopWatch] = [:] { didSet { Conductor.savedStopwatches = currentStopwatches @@ -66,8 +90,9 @@ class Conductor: ObservableObject { func removeLiveTimer(id: TimerID) { // Logger.log("removeLiveTimer") self.liveTimers.removeAll(where: { $0.id == id }) - self.currentStopwatches.removeValue(forKey: id) self.cancelledCountdowns.removeAll(where: { $0 == id }) + self.pausedCountdowns.removeValue(forKey: id) + self.currentStopwatches.removeValue(forKey: id) if let soundPlayer = self._delayedSoundPlayers[id] { FileLogger.log("Stop sound player: \(self._timerName(id))") soundPlayer.stop() @@ -112,10 +137,10 @@ class Conductor: ObservableObject { return self.cancelledCountdowns.contains(where: { $0 == countdown.stringId }) } - func notifyUser(countdownId: String) { - // self._playSound(timerId: countdownId) - self._endCountdown(countdownId: countdownId, cancel: false) - } +// func notifyUser(countdownId: String) { +// // self._playSound(timerId: countdownId) +// self._endCountdown(countdownId: countdownId, cancel: false) +// } fileprivate func _recordActivity(countdownId: String) { let context = PersistenceController.shared.container.viewContext @@ -140,28 +165,31 @@ class Conductor: ObservableObject { self._cleanupPreviousTimerIfNecessary(countdownId) do { - let start = Date() - let end = start.addingTimeInterval(countdown.duration) - FileLogger.log("schedule countdown \(self._timerName(countdownId)) at \(end)") + let end = try self._scheduleSoundPlayer(countdown: countdown, in: countdown.duration) - let sound = countdown.someSound - let soundPlayer = try DelaySoundPlayer(timerID: countdownId, sound: sound) - - FileLogger.log("a) self._delayedSoundPlayers count = \(self._delayedSoundPlayers.count)") - self._delayedSoundPlayers[countdownId] = soundPlayer - FileLogger.log("b) self._delayedSoundPlayers count = \(self._delayedSoundPlayers.count)") - - try soundPlayer.start(in: countdown.duration, - repeatCount: Int(countdown.repeatCount)) - - let dateInterval = DateInterval(start: start, end: end) - self.currentCountdowns[countdownId] = dateInterval +// let start = Date() +// let end = start.addingTimeInterval(countdown.duration) +// FileLogger.log("schedule countdown \(self._timerName(countdownId)) at \(end)") +// +// let sound = countdown.someSound +// let soundPlayer = try DelaySoundPlayer(timerID: countdownId, sound: sound) +// +// FileLogger.log("a) self._delayedSoundPlayers count = \(self._delayedSoundPlayers.count)") +// self._delayedSoundPlayers[countdownId] = soundPlayer +// FileLogger.log("b) self._delayedSoundPlayers count = \(self._delayedSoundPlayers.count)") +// +// try soundPlayer.start(in: countdown.duration, +// repeatCount: Int(countdown.repeatCount)) +// +// let dateInterval = DateInterval(start: start, end: end) +// self.currentCountdowns[countdownId] = dateInterval +// +// self._launchLiveActivity(timer: countdown, date: end) if Preferences.playConfirmationSound { self._playConfirmationSound(timer: countdown) } - self._launchLiveActivity(timer: countdown, date: end) - + handler(.success(end)) } catch { FileLogger.log("start error : \(error.localizedDescription)") @@ -171,6 +199,29 @@ class Conductor: ObservableObject { } } + fileprivate func _scheduleSoundPlayer(countdown: Countdown, in interval: TimeInterval) throws -> Date { + let start = Date() + let end = start.addingTimeInterval(interval) + + let countdownId = countdown.stringId + + FileLogger.log("schedule countdown \(self._timerName(countdownId)) at \(end)") + + let sound = countdown.someSound + let soundPlayer = try DelaySoundPlayer(timerID: countdownId, sound: sound) + self._delayedSoundPlayers[countdownId] = soundPlayer + + try soundPlayer.start(in: interval, + repeatCount: Int(countdown.repeatCount)) + + let dateInterval = DateInterval(start: start, end: end) + self.currentCountdowns[countdownId] = dateInterval + + self._launchLiveActivity(timer: countdown, date: end) + + return end + } + fileprivate func _cleanupPreviousTimerIfNecessary(_ timerId: TimerID) { self.removeLiveTimer(id: timerId) if let player = self._delayedSoundPlayers[timerId] { @@ -186,7 +237,8 @@ class Conductor: ObservableObject { self.cancelSoundPlayer(id: id) self.cancelledCountdowns.append(id) self._endCountdown(countdownId: id, cancel: true) - + self.pausedCountdowns.removeValue(forKey: id) + if Preferences.playCancellationSound { self._playCancellationSound() } @@ -194,6 +246,52 @@ class Conductor: ObservableObject { self._endLiveActivity(timerId: id) } + func countdownState(_ countdown: Countdown) -> CountdownState { + let id = countdown.stringId + if self.cancelledCountdowns.contains(id) { + return .cancelled + } else if self.pausedCountdowns[id] != nil { + return .paused + } else if let interval = self.currentCountdowns[id], interval.end > Date() { + return .inprogress + } else { + return .finished + } + } + +// func isCountdownPaused(_ countdown: Countdown) -> Bool { +// return self.pausedCountdowns[countdown.stringId] != nil +// } + + func remainingPausedCountdownTime(_ countdown: Countdown) -> TimeInterval? { + return self.pausedCountdowns[countdown.stringId] + } + + func pauseCountdown(id: TimerID) { + guard let interval = self.currentCountdowns[id] else { + return + } + + let remainingTime = interval.end.timeIntervalSince(Date()) + self.pausedCountdowns[id] = remainingTime + + // cancel stuff + CountdownScheduler.master.cancelCurrentNotifications(countdownId: id) + self.cancelSoundPlayer(id: id) + self._endLiveActivity(timerId: id) + } + + func resumeCountdown(id: TimerID) throws { + let context = PersistenceController.shared.container.viewContext + if let countdown: Countdown = context.object(stringId: id), + let remainingTime = self.pausedCountdowns[id] { + _ = try self._scheduleSoundPlayer(countdown: countdown, in: remainingTime) + self.pausedCountdowns.removeValue(forKey: id) + } else { + throw AppError.timerNotFound(id: id) + } + } + fileprivate func _endCountdown(countdownId: String, cancel: Bool) { DispatchQueue.main.async { #if DEBUG @@ -453,5 +551,10 @@ class Conductor: ObservableObject { fileprivate func _timerName(_ id: TimerID) -> String { return IntentDataProvider.main.timer(id: id)?.name ?? id } + + deinit { + self.beats?.invalidate() + self.beats = nil + } } diff --git a/LeCountdown/Utils/Preferences.swift b/LeCountdown/Utils/Preferences.swift index 393cfea..689397b 100644 --- a/LeCountdown/Utils/Preferences.swift +++ b/LeCountdown/Utils/Preferences.swift @@ -10,6 +10,7 @@ import Foundation enum PreferenceKey: String { case installDate case countdowns + case pausedCountdowns case stopwatches case playConfirmationSound case playCancellationSound diff --git a/LeCountdown/Views/LiveTimerListView.swift b/LeCountdown/Views/LiveTimerListView.swift index 0647c3b..6ba4576 100644 --- a/LeCountdown/Views/LiveTimerListView.swift +++ b/LeCountdown/Views/LiveTimerListView.swift @@ -167,28 +167,49 @@ struct LiveCountdownView: View { @State var countdown: Countdown var date: Date - @State var showConfirmationPopup: Bool = false + @State var showCancelConfirmationPopup: Bool = false + @State private var error: AppError? + @State private var isShowingError: Bool = false + var body: some View { TimelineView(.periodic(from: self.date, by: 0.01)) { context in - let cancelled = Conductor.maestro.isCountdownCancelled(self.countdown) + let state = Conductor.maestro.countdownState(self.countdown) + +// let remainingTime: TimeInterval? = Conductor.maestro.remainingPausedCountdownTime(self.countdown) +// let cancelled = Conductor.maestro.isCountdownCancelled(self.countdown) HStack { - let running = self.date > context.date +// let running = self.date > context.date VStack(alignment: .leading) { - if cancelled { - TimeView(text: NSLocalizedString("Cancelled", comment: "")) - } else if running { + switch state { + case .inprogress: TimeView(text: self._formattedDuration(date: context.date)) - } else { + case .paused: + if let remainingTime = Conductor.maestro.remainingPausedCountdownTime(self.countdown) { + TimeView(text: remainingTime.hourMinuteSecond) + } + case .finished: TimeView(text: self.date.formatted(date: .omitted, time: .shortened)) + case .cancelled: + TimeView(text: NSLocalizedString("Cancelled", comment: "")) } +// if cancelled { +// TimeView(text: NSLocalizedString("Cancelled", comment: "")) +// } else if let remainingTime { +// TimeView(text: remainingTime.hourMinuteSecond) +// } else if running { +// TimeView(text: self._formattedDuration(date: context.date)) +// } else { +// TimeView(text: self.date.formatted(date: .omitted, time: .shortened)) +// } + Text(self.countdown.displayName.uppercased()) .foregroundColor(self.colorScheme == .dark ? .white : .black) @@ -196,32 +217,84 @@ struct LiveCountdownView: View { Spacer() Group { - if cancelled { - Image(systemName: "xmark.circle") - .foregroundColor(.accentColor) - } else if !running { - GreenCheckmarkView() - } else { + + switch state { + case .inprogress: + Button { + self._pause() + } label: { + Image(systemName: "pause.circle") + .foregroundColor(.accentColor) + } + case .paused: + Button { + self._resume() + } label: { + Image(systemName: "play.circle") + .foregroundColor(.accentColor) + } + default: + EmptyView() + } + + +// if !cancelled && (self.date > context.date && remainingTime != nil) { // pause / resume +// if remainingTime != nil { +// Button { +// self._resume() +// } label: { +// Image(systemName: "play.circle") +// .foregroundColor(.accentColor) +// } +// } else { +// Button { +// self._pause() +// } label: { +// Image(systemName: "pause.circle") +// .foregroundColor(.accentColor) +// } +// } +// } + + switch state { + case .inprogress, .paused: Button { - self.showConfirmationPopup = true + self.showCancelConfirmationPopup = true } label: { Image(systemName: "xmark.circle.fill") .foregroundColor(.accentColor) } + case .finished: + GreenCheckmarkView() + case .cancelled: + Image(systemName: "xmark.circle").foregroundColor(.accentColor) } + +// if cancelled { // Cancelled image +// Image(systemName: "xmark.circle").foregroundColor(.accentColor) +// } else if !running && remainingTime == nil { // Ended +// GreenCheckmarkView() +// } else { // Cancel button +// Button { +// self.showCancelConfirmationPopup = true +// } label: { +// Image(systemName: "xmark.circle.fill") +// .foregroundColor(.accentColor) +// } +// } }.font(.system(size: actionButtonFontSize)) } } .contentShape(Rectangle()) .onTapGesture { - if Date() > self.date || Conductor.maestro.isCountdownCancelled(self.countdown) { + if Date() > self.date || Conductor.maestro.isCountdownCancelled(self.countdown) { self._dismiss() } } .frame(height: liveViewSize) .monospaced() - .fullScreenCover(isPresented: self.$showConfirmationPopup) { + .fullScreenCover(isPresented: self.$showCancelConfirmationPopup) { ZStack { Color.black.opacity(0.1) @@ -231,7 +304,7 @@ struct LiveCountdownView: View { Button(String(format: NSLocalizedString("Cancel %@", comment: ""), name)) { self._cancelCountdown() - self.showConfirmationPopup = false + self.showCancelConfirmationPopup = false } .monospaced() .padding() @@ -239,11 +312,19 @@ struct LiveCountdownView: View { .background(Color.accentColor) .cornerRadius(8.0) }.onTapGesture { - self.showConfirmationPopup = false + self.showCancelConfirmationPopup = false } .background(BackgroundBlurView()) } + .alert(isPresented: $isShowingError, error: error) { _ in + // buttons + } message: { error in + if let message = error.errorMessage { + Text(message) + } + } + } fileprivate func _actionHandler() { @@ -264,7 +345,7 @@ struct LiveCountdownView: View { fileprivate func _formattedDuration(date: Date) -> String { let duration = self.date.timeIntervalSince(date) - return duration.minuteSecond + return duration.hourMinuteSecond } fileprivate func _cancelCountdown() { @@ -273,6 +354,19 @@ struct LiveCountdownView: View { } } + fileprivate func _pause() { + Conductor.maestro.pauseCountdown(id: self.countdown.stringId) + } + + fileprivate func _resume() { + do { + try Conductor.maestro.resumeCountdown(id: self.countdown.stringId) + } catch { + Logger.error(error) + self.error = AppError.defaultError(error: error) + } + } + } struct LiveTimerView_Previews: PreviewProvider {