Remove repeat and add repeatCount to AbstractSoundTimer

release
Laurent 3 years ago
parent b9fde1b0c2
commit d58966bfbd
  1. 14
      LeCountdown.xcodeproj/project.pbxproj
  2. 7
      LeCountdown/AppDelegate.swift
  3. 1
      LeCountdown/Conductor.swift
  4. 28
      LeCountdown/CountdownScheduler.swift
  5. 31
      LeCountdown/LeCountdownApp.swift
  6. 4
      LeCountdown/Model/Generation/AbstractSoundTimer+CoreDataProperties.swift
  7. 2
      LeCountdown/Model/LeCountdown.xcdatamodeld/.xccurrentversion
  8. 33
      LeCountdown/Model/LeCountdown.xcdatamodeld/LeCountdown.0.4.xcdatamodel/contents
  9. 52
      LeCountdown/Utils/TextToSpeechRecorder.swift
  10. 6
      LeCountdown/Views/Alarm/AlarmFormView.swift
  11. 6
      LeCountdown/Views/Alarm/NewAlarmView.swift
  12. 0
      LeCountdown/Views/Components/ImageSelectionView.swift
  13. 30
      LeCountdown/Views/Components/SoundImageFormView.swift
  14. 3
      LeCountdown/Views/ContentView.swift
  15. 11
      LeCountdown/Views/Countdown/CountdownFormView.swift
  16. 8
      LeCountdown/Views/Countdown/NewCountdownView.swift
  17. 5
      LeCountdown/Views/LiveTimerListView.swift

@ -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 = "<group>"; };
C4060DF4297AE9A7003FAB80 /* TimeInterval+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TimeInterval+Extensions.swift"; sourceTree = "<group>"; };
C4060DF6297AFEF2003FAB80 /* NewCountdownView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewCountdownView.swift; sourceTree = "<group>"; };
C40FDB612992985C0042A390 /* TextToSpeechRecorder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextToSpeechRecorder.swift; sourceTree = "<group>"; };
C40FDB672993D5E80042A390 /* LeCountdown.0.4.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = LeCountdown.0.4.xcdatamodel; sourceTree = "<group>"; };
C418A14F298428CB00C22230 /* LeCountdown.0.1.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = LeCountdown.0.1.xcdatamodel; sourceTree = "<group>"; };
C438C7C02980228B00BF3EF9 /* CountdownScheduler.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CountdownScheduler.swift; sourceTree = "<group>"; };
C438C7C4298024E900BF3EF9 /* NSManagedContext+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSManagedContext+Extensions.swift"; sourceTree = "<group>"; };
@ -437,6 +440,7 @@
C438C81029829EAF00BF3EF9 /* PropertyWrappers.swift */,
C4060DF4297AE9A7003FAB80 /* TimeInterval+Extensions.swift */,
C4742B5E2984205000D5D950 /* ViewModifiers.swift */,
C40FDB612992985C0042A390 /* TextToSpeechRecorder.swift */,
);
path = Utils;
sourceTree = "<group>";
@ -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 = "<group>";
@ -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 = "<group>";
versionGroupType = wrapper.xcdatamodel;

@ -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) {

@ -123,6 +123,7 @@ class Conductor: ObservableObject {
func cancelCountdown(id: String) {
CountdownScheduler.master.cancelCurrentNotifications(countdownId: id)
self.stopSoundIfPossible()
self._endCountdown(countdownId: id, cancel: true)
}

@ -12,13 +12,20 @@ class CountdownScheduler {
static let master = CountdownScheduler()
static let notificationIdSeparator: String = "||"
func scheduleIfPossible(countdown: Countdown, handler: @escaping (Result<Date?, Error>) -> 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..<notificationCount {
let offset = Double(i) * 10.0 // every 30 seconds
self._createNotification(countdown: countdown, offset: offset, content: content, handler: handler)
}
}
@ -53,7 +60,10 @@ class CountdownScheduler {
let duration = countdown.duration
let trigger = UNTimeIntervalNotificationTrigger(timeInterval: duration + offset, repeats: false)
let request = UNNotificationRequest(identifier: countdown.objectID.uriRepresentation().absoluteString,
let identifier: String = [countdown.objectID.uriRepresentation().absoluteString, "\(offset)"].joined(separator: CountdownScheduler.notificationIdSeparator)
let request = UNNotificationRequest(identifier: identifier,
content: content,
trigger: trigger)
UNUserNotificationCenter.current().add(request) { error in

@ -8,6 +8,7 @@
import SwiftUI
import CoreData
import BackgroundTasks
import AVFoundation
enum BGTaskIdentifier : String {
case refresh = "com.staxriver.lecountdown.refresh"
@ -18,7 +19,7 @@ struct LeCountdownApp: App {
let persistenceController = PersistenceController.shared
@State private var tabSelection = 1
@State private var tabSelection: Int = 1
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
@ -70,16 +71,18 @@ struct LeCountdownApp: App {
fileprivate func _onAppear() {
self._registerBackgroundRefreshes()
// Task {
// for s in Sound.allCases {
// do {
// let d = try await s.duration()
// print("\(s) duration = \(d)")
// } catch {
// print("error = \(error)")
// }
// }
// }
let voices = AVSpeechSynthesisVoice.speechVoices()
let grouped = Dictionary(grouping: voices, by: { $0.language })
for language in grouped.keys {
if let lvoices = grouped[language] {
print("language = \(language)")
for voice in lvoices {
print("name = \(voice.name), gender = \(voice.gender)")
}
print("========")
}
}
}
fileprivate func _registerBackgroundRefreshes() {
@ -111,11 +114,11 @@ struct LeCountdownApp: App {
switch timer {
case is Countdown:
tabSelection = 1
self.tabSelection = 1
case is Stopwatch:
tabSelection = 2
self.tabSelection = 2
case is Alarm:
tabSelection = 3
self.tabSelection = 3
default:
print("url not managed, object is \(timer)")
break

@ -2,7 +2,7 @@
// AbstractSoundTimer+CoreDataProperties.swift
// LeCountdown
//
// Created by Laurent Morvillier on 01/02/2023.
// Created by Laurent Morvillier on 08/02/2023.
//
//
@ -16,7 +16,7 @@ extension AbstractSoundTimer {
return NSFetchRequest<AbstractSoundTimer>(entityName: "AbstractSoundTimer")
}
@NSManaged public var repeatCount: Int16
@NSManaged public var sound: Int16
@NSManaged public var repeats: Bool
}

@ -3,6 +3,6 @@
<plist version="1.0">
<dict>
<key>_XCCurrentVersionName</key>
<string>LeCountdown.0.3.xcdatamodel</string>
<string>LeCountdown.0.4.xcdatamodel</string>
</dict>
</plist>

@ -0,0 +1,33 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="21513" systemVersion="22A400" minimumToolsVersion="Automatic" sourceLanguage="Swift" usedWithCloudKit="YES" userDefinedModelVersionIdentifier="">
<entity name="AbstractSoundTimer" representedClassName="AbstractSoundTimer" isAbstract="YES" parentEntity="AbstractTimer" syncable="YES">
<attribute name="repeatCount" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="sound" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/>
</entity>
<entity name="AbstractTimer" representedClassName="AbstractTimer" isAbstract="YES" syncable="YES">
<attribute name="image" optional="YES" attributeType="String"/>
<attribute name="order" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/>
<relationship name="activity" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Activity" inverseName="timers" inverseEntity="Activity"/>
</entity>
<entity name="Activity" representedClassName="Activity" syncable="YES">
<attribute name="name" attributeType="String" defaultValueString=""/>
<relationship name="records" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Record" inverseName="activity" inverseEntity="Record"/>
<relationship name="timers" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="AbstractTimer" inverseName="activity" inverseEntity="AbstractTimer"/>
</entity>
<entity name="Alarm" representedClassName="Alarm" parentEntity="AbstractSoundTimer" syncable="YES">
<attribute name="fireDate" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
</entity>
<entity name="Countdown" representedClassName="Countdown" parentEntity="AbstractSoundTimer" syncable="YES">
<attribute name="duration" attributeType="Double" defaultValueString="0" usesScalarValueType="YES"/>
</entity>
<entity name="Record" representedClassName="Record" syncable="YES">
<attribute name="end" attributeType="Date" defaultDateTimeInterval="696425400" usesScalarValueType="NO"/>
<attribute name="start" attributeType="Date" defaultDateTimeInterval="696425400" usesScalarValueType="NO"/>
<relationship name="activity" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Activity" inverseName="records" inverseEntity="Activity"/>
</entity>
<entity name="Stopwatch" representedClassName="Stopwatch" parentEntity="AbstractTimer" syncable="YES">
<attribute name="end" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="sound" optional="YES" attributeType="Integer 16" usesScalarValueType="YES"/>
<attribute name="start" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
</entity>
</model>

@ -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<TextToSpeechFile, Error>) -> 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))
}
}
}
}
}

@ -13,13 +13,13 @@ struct AlarmFormView: View {
var imageBinding: Binding<CoolPic>
var soundBinding: Binding<Sound>
var repeatsBinding: Binding<Bool>
var repeatCountBinding: Binding<Int16>
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))
}
}

@ -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 {

@ -11,7 +11,7 @@ struct SoundImageFormView : View {
var imageBinding: Binding<CoolPic>
var soundBinding: Binding<Sound>
var repeatsBinding: Binding<Bool>? = nil
var repeatCountBinding: Binding<Int16>? = nil
var optionalSound: Binding<Bool>? = 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))
}
}
}

@ -154,6 +154,9 @@ struct ContentView<T : AbstractTimer>: View {
fileprivate func _performActionIfPossible(url: URL) {
// hide new window if launching a timer
self.boringContext.isShowingNewData = false
print("_performActionIfPossible")
let urlString = url.absoluteString

@ -16,7 +16,7 @@ struct CountdownFormView : View {
var imageBinding: Binding<CoolPic>
var soundBinding: Binding<Sound>
var repeatsBinding: Binding<Bool>
var repeatCountBinding: Binding<Int16>
var textFieldIsFocused: FocusState<Bool>.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)
}
}

@ -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 {

@ -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)
}

Loading…
Cancel
Save