|
|
|
|
@ -29,6 +29,11 @@ enum CountdownState { |
|
|
|
|
case cancelled |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
struct CountdownSpan { |
|
|
|
|
var interval: DateInterval |
|
|
|
|
var name: String? |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
class Conductor: ObservableObject { |
|
|
|
|
|
|
|
|
|
static let maestro: Conductor = Conductor() |
|
|
|
|
@ -87,25 +92,11 @@ class Conductor: ObservableObject { |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
func removeLiveTimer(id: TimerID) { |
|
|
|
|
// Logger.log("removeLiveTimer") |
|
|
|
|
self.liveTimers.removeAll(where: { $0.id == id }) |
|
|
|
|
self.cancelledCountdowns.removeAll(where: { $0 == id }) |
|
|
|
|
self.currentStopwatches.removeValue(forKey: id) |
|
|
|
|
self.pausedCountdowns.removeValue(forKey: id) |
|
|
|
|
if let soundPlayer = self._delayedSoundPlayers[id] { |
|
|
|
|
FileLogger.log("Stop sound player: \(self._timerName(id))") |
|
|
|
|
soundPlayer.stop() |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
fileprivate func _cleanupLiveTimers() { |
|
|
|
|
self.liveTimers.removeAll() |
|
|
|
|
} |
|
|
|
|
static let notificationIdSeparator: String = "||" |
|
|
|
|
|
|
|
|
|
fileprivate func _buildLiveTimers() { |
|
|
|
|
|
|
|
|
|
let liveCountdowns = self.currentCountdowns.map { |
|
|
|
|
let liveCountdowns: [LiveTimer] = self.currentCountdowns.map { |
|
|
|
|
return LiveTimer(id: $0, date: $1.end) |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
@ -133,6 +124,22 @@ class Conductor: ObservableObject { |
|
|
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
func removeLiveTimer(id: TimerID) { |
|
|
|
|
// Logger.log("removeLiveTimer") |
|
|
|
|
self.liveTimers.removeAll(where: { $0.id == id }) |
|
|
|
|
self.cancelledCountdowns.removeAll(where: { $0 == id }) |
|
|
|
|
self.currentStopwatches.removeValue(forKey: id) |
|
|
|
|
self.pausedCountdowns.removeValue(forKey: id) |
|
|
|
|
if let soundPlayer = self._delayedSoundPlayers[id] { |
|
|
|
|
FileLogger.log("Stop sound player: \(self._timerName(id))") |
|
|
|
|
soundPlayer.stop() |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
fileprivate func _cleanupLiveTimers() { |
|
|
|
|
self.liveTimers.removeAll() |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
func isCountdownCancelled(_ countdown: Countdown) -> Bool { |
|
|
|
|
return self.cancelledCountdowns.contains(where: { $0 == countdown.stringId }) |
|
|
|
|
} |
|
|
|
|
@ -154,15 +161,33 @@ class Conductor: ObservableObject { |
|
|
|
|
|
|
|
|
|
func startCountdown(countdown: Countdown, handler: @escaping (Result<Date?, Error>) -> Void) { |
|
|
|
|
|
|
|
|
|
self.cancelCurrentNotifications(countdownId: countdown.stringId) |
|
|
|
|
|
|
|
|
|
let countdownId = countdown.stringId |
|
|
|
|
self._cleanupPreviousTimerIfNecessary(countdownId) |
|
|
|
|
|
|
|
|
|
do { |
|
|
|
|
let end = try self._scheduleSoundPlayer(countdown: countdown, in: countdown.duration) |
|
|
|
|
|
|
|
|
|
var totalDuration = 0.0 |
|
|
|
|
|
|
|
|
|
for _ in 0...countdown.repeatCount { |
|
|
|
|
for range in countdown.sortedRanges() { |
|
|
|
|
// TODO: est-ce qu'on schedule tout ou en séquence ? |
|
|
|
|
totalDuration += range.duration |
|
|
|
|
let end = try self._scheduleSoundPlayer(countdown: countdown, range: range, in: totalDuration) |
|
|
|
|
self._scheduleCountdownNotification(countdown: countdown, in: totalDuration, handler: handler) |
|
|
|
|
|
|
|
|
|
let dateInterval = DateInterval(start: Date(), end: end) |
|
|
|
|
self.currentCountdowns[countdownId] = dateInterval |
|
|
|
|
|
|
|
|
|
self._launchLiveActivity(timer: countdown, date: end) |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
if Preferences.playConfirmationSound { |
|
|
|
|
self._playConfirmationSound(timer: countdown) |
|
|
|
|
} |
|
|
|
|
handler(.success(end)) |
|
|
|
|
handler(.success(Date(timeIntervalSinceNow: totalDuration))) |
|
|
|
|
} catch { |
|
|
|
|
FileLogger.log("start error : \(error.localizedDescription)") |
|
|
|
|
Logger.error(error) |
|
|
|
|
@ -171,26 +196,21 @@ class Conductor: ObservableObject { |
|
|
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
fileprivate func _scheduleSoundPlayer(countdown: Countdown, in interval: TimeInterval) throws -> Date { |
|
|
|
|
fileprivate func _scheduleSoundPlayer(countdown: Countdown, range: TimeRange, 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)") |
|
|
|
|
FileLogger.log("schedule countdown \(range.name ?? "''") at \(end)") |
|
|
|
|
|
|
|
|
|
let sound = countdown.someSound |
|
|
|
|
let sound = range.someSound ?? countdown.someSound ?? Sound.default |
|
|
|
|
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 |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
@ -205,7 +225,7 @@ class Conductor: ObservableObject { |
|
|
|
|
func cancelCountdown(id: TimerID) { |
|
|
|
|
|
|
|
|
|
FileLogger.log("Cancel \(self._timerName(id))") |
|
|
|
|
CountdownScheduler.master.cancelCurrentNotifications(countdownId: id) |
|
|
|
|
self.cancelCurrentNotifications(countdownId: id) |
|
|
|
|
self.currentCountdowns.removeValue(forKey: id) |
|
|
|
|
|
|
|
|
|
self.removeLiveTimer(id: id) |
|
|
|
|
@ -251,7 +271,7 @@ class Conductor: ObservableObject { |
|
|
|
|
self.pausedCountdowns[id] = remainingTime |
|
|
|
|
|
|
|
|
|
// cancel stuff |
|
|
|
|
CountdownScheduler.master.cancelCurrentNotifications(countdownId: id) |
|
|
|
|
self.cancelCurrentNotifications(countdownId: id) |
|
|
|
|
self.cancelSoundPlayer(id: id) |
|
|
|
|
self._endLiveActivity(timerId: id) |
|
|
|
|
} |
|
|
|
|
@ -325,7 +345,7 @@ class Conductor: ObservableObject { |
|
|
|
|
if let countdown: Countdown = context.object(stringId: countdownId) { |
|
|
|
|
|
|
|
|
|
do { |
|
|
|
|
let sound: Sound = countdown.someSound |
|
|
|
|
let sound: Sound = countdown.someSound ?? Sound.default |
|
|
|
|
let soundPlayer = try DelaySoundPlayer(timerID: countdownId, sound: sound) |
|
|
|
|
self._delayedSoundPlayers[countdownId] = soundPlayer |
|
|
|
|
try soundPlayer.restore(for: interval.end, repeatCount: Int(countdown.repeatCount)) |
|
|
|
|
@ -361,6 +381,60 @@ class Conductor: ObservableObject { |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
// MARK: - Notifications |
|
|
|
|
|
|
|
|
|
fileprivate func _scheduleCountdownNotification(countdown: Countdown, in duration: TimeInterval, handler: @escaping (Result<Date?, Error>) -> Void) { |
|
|
|
|
let content = UNMutableNotificationContent() |
|
|
|
|
content.title = NSLocalizedString("It's time!", comment: "") |
|
|
|
|
|
|
|
|
|
// let duration = countdown.duration |
|
|
|
|
let body: String |
|
|
|
|
if let name = countdown.activity?.name { |
|
|
|
|
let timesup = NSLocalizedString("Time's up for %@!", comment: "") |
|
|
|
|
body = String(format: timesup, name) |
|
|
|
|
} else { |
|
|
|
|
let timesup = NSLocalizedString("Your %@ countdown is over!", comment: "") |
|
|
|
|
body = String(format: timesup, duration.hourMinuteSecond) |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
content.body = body |
|
|
|
|
|
|
|
|
|
self._createNotification(countdown: countdown, in: duration, content: content, handler: handler) |
|
|
|
|
|
|
|
|
|
// content.sound = UNNotificationSound.criticalSoundNamed(UNNotificationSoundName(rawValue: sound), withAudioVolume: 1.0) |
|
|
|
|
// content.interruptionLevel = .critical |
|
|
|
|
content.relevanceScore = 1.0 |
|
|
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
fileprivate func _createNotification(countdown: Countdown, in duration: TimeInterval, offset: TimeInterval = 0.0, content: UNMutableNotificationContent, handler: @escaping (Result<Date?, Error>) -> Void) { |
|
|
|
|
|
|
|
|
|
// let duration = countdown.duration |
|
|
|
|
let trigger = UNTimeIntervalNotificationTrigger(timeInterval: duration + offset, repeats: false) |
|
|
|
|
|
|
|
|
|
let identifier: String = [countdown.objectID.uriRepresentation().absoluteString, "\(offset)"].joined(separator: Conductor.notificationIdSeparator) |
|
|
|
|
|
|
|
|
|
let request = UNNotificationRequest(identifier: identifier, |
|
|
|
|
content: content, |
|
|
|
|
trigger: trigger) |
|
|
|
|
UNUserNotificationCenter.current().add(request) { error in |
|
|
|
|
DispatchQueue.main.async { |
|
|
|
|
if let error { |
|
|
|
|
handler(.failure(error)) |
|
|
|
|
print("Scheduling error = \(error)") |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
func cancelCurrentNotifications(countdownId: String) { |
|
|
|
|
UNUserNotificationCenter.current().getPendingNotificationRequests { requests in |
|
|
|
|
let ids = requests.map { $0.identifier }.filter { $0.hasPrefix(countdownId) } |
|
|
|
|
UNUserNotificationCenter.current().removePendingNotificationRequests(withIdentifiers: ids) |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
// MARK: - Sound |
|
|
|
|
|
|
|
|
|
fileprivate func _playSound(timerId: String) { |
|
|
|
|
@ -464,16 +538,16 @@ class Conductor: ObservableObject { |
|
|
|
|
// interaction.donate() |
|
|
|
|
// } |
|
|
|
|
|
|
|
|
|
fileprivate func _scheduleAppRefresh(countdown: Countdown) { |
|
|
|
|
let request = BGAppRefreshTaskRequest(identifier: BGTaskIdentifier.refresh.rawValue) |
|
|
|
|
request.earliestBeginDate = Date(timeIntervalSinceNow: countdown.duration) |
|
|
|
|
do { |
|
|
|
|
try BGTaskScheduler.shared.submit(request) |
|
|
|
|
print("request submitted with date: \(String(describing: request.earliestBeginDate))") |
|
|
|
|
} catch { |
|
|
|
|
Logger.error(error) |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
// fileprivate func _scheduleAppRefresh(countdown: Countdown) { |
|
|
|
|
// let request = BGAppRefreshTaskRequest(identifier: BGTaskIdentifier.refresh.rawValue) |
|
|
|
|
// request.earliestBeginDate = Date(timeIntervalSinceNow: countdown.duration) |
|
|
|
|
// do { |
|
|
|
|
// try BGTaskScheduler.shared.submit(request) |
|
|
|
|
// print("request submitted with date: \(String(describing: request.earliestBeginDate))") |
|
|
|
|
// } catch { |
|
|
|
|
// Logger.error(error) |
|
|
|
|
// } |
|
|
|
|
// } |
|
|
|
|
|
|
|
|
|
// MARK: - Live Activity |
|
|
|
|
|
|
|
|
|
|