From 6ebaedd641b4eb25556d19cb17e2651a46a5d5d6 Mon Sep 17 00:00:00 2001 From: Laurent Date: Tue, 24 Jan 2023 17:36:54 +0100 Subject: [PATCH] Adds working countdowns --- LeCountdown.xcodeproj/project.pbxproj | 12 +++ LeCountdown/AppDelegate.swift | 35 +++++++ LeCountdown/ContentView.swift | 77 ++++++++++++--- LeCountdown/CountdownScheduler.swift | 95 +++++++++++++++++++ LeCountdown/LeCountdownApp.swift | 3 + LeCountdown/NSManagedContext+Extensions.swift | 27 ++++++ LeCountdown/NewCountdownView.swift | 52 ++++++++-- 7 files changed, 281 insertions(+), 20 deletions(-) create mode 100644 LeCountdown/AppDelegate.swift create mode 100644 LeCountdown/CountdownScheduler.swift create mode 100644 LeCountdown/NSManagedContext+Extensions.swift diff --git a/LeCountdown.xcodeproj/project.pbxproj b/LeCountdown.xcodeproj/project.pbxproj index 2cfff0e..0707caa 100644 --- a/LeCountdown.xcodeproj/project.pbxproj +++ b/LeCountdown.xcodeproj/project.pbxproj @@ -18,6 +18,9 @@ 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 */; }; + 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 */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -53,6 +56,9 @@ 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 = ""; }; + 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 = ""; }; + C438C7C829803CA000BF3EF9 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -106,12 +112,15 @@ C4060DBF297AE73B003FAB80 /* LeCountdownApp.swift */, C4060DC1297AE73B003FAB80 /* ContentView.swift */, C4060DF6297AFEF2003FAB80 /* NewCountdownView.swift */, + C438C7C02980228B00BF3EF9 /* CountdownScheduler.swift */, C4060DC3297AE73D003FAB80 /* Assets.xcassets */, C4060DC8297AE73D003FAB80 /* Persistence.swift */, + C438C7C4298024E900BF3EF9 /* NSManagedContext+Extensions.swift */, C4060DF4297AE9A7003FAB80 /* TimeInterval+Extensions.swift */, C4060DCD297AE73D003FAB80 /* Info.plist */, C4060DCA297AE73D003FAB80 /* LeCountdown.xcdatamodeld */, C4060DC5297AE73D003FAB80 /* Preview Content */, + C438C7C829803CA000BF3EF9 /* AppDelegate.swift */, ); path = LeCountdown; sourceTree = ""; @@ -273,9 +282,12 @@ files = ( C4060DC9297AE73D003FAB80 /* Persistence.swift in Sources */, C4060DC2297AE73B003FAB80 /* ContentView.swift in Sources */, + C438C7C12980228B00BF3EF9 /* CountdownScheduler.swift in Sources */, + C438C7C929803CA000BF3EF9 /* AppDelegate.swift in Sources */, C4060DF5297AE9A7003FAB80 /* TimeInterval+Extensions.swift in Sources */, C4060DF7297AFEF2003FAB80 /* NewCountdownView.swift in Sources */, C4060DCC297AE73D003FAB80 /* LeCountdown.xcdatamodeld in Sources */, + C438C7C5298024E900BF3EF9 /* NSManagedContext+Extensions.swift in Sources */, C4060DC0297AE73B003FAB80 /* LeCountdownApp.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/LeCountdown/AppDelegate.swift b/LeCountdown/AppDelegate.swift new file mode 100644 index 0000000..89a07e4 --- /dev/null +++ b/LeCountdown/AppDelegate.swift @@ -0,0 +1,35 @@ +// +// AppDelegate.swift +// LeCountdown +// +// Created by Laurent Morvillier on 24/01/2023. +// + +import Foundation +import UIKit + +class AppDelegate : NSObject, UIApplicationDelegate { + + + func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool { + + UNUserNotificationCenter.current().delegate = self + + return true + } + +} + +extension AppDelegate: UNUserNotificationCenterDelegate { + + func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse) async { + print("didReceive response") + } + + func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) { + print("willPresent notification") + completionHandler([.banner, .sound]) + AppEnvironment.sun.clearNotificationDate() + } + +} diff --git a/LeCountdown/ContentView.swift b/LeCountdown/ContentView.swift index b2da19e..0dbca6f 100644 --- a/LeCountdown/ContentView.swift +++ b/LeCountdown/ContentView.swift @@ -20,6 +20,9 @@ struct ContentView: View { @State private var isShowingNewCountdown = false + @State var error: Error? + @State var showAlert: Bool = false + private var columns: [GridItem] = [ GridItem(spacing: 10.0), GridItem(spacing: 10.0), @@ -34,23 +37,38 @@ struct ContentView: View { spacing: itemSpacing ) { - ForEach(countdowns) { item in - NavigationLink { - Text("Item at \(item.order)") - } label: { - Text(item.duration.minuteSecond) - .aspectRatio(contentMode: .fill) - .frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity) - .aspectRatio(1, contentMode: .fit) - .background(Color(red: 0.9, green: 0.95, blue: 1.0)) - .cornerRadius(40.0) - }.onAppear { + ForEach(countdowns) { countdown in + ZStack(alignment: .topTrailing) { + + Button { + self._launchCountdown(countdown) + } label: { + Text(countdown.duration.minuteSecond) + .aspectRatio(contentMode: .fill) + .frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity) + .aspectRatio(1, contentMode: .fit) + .background(Color(red: 0.9, green: 0.95, blue: 1.0)) + .cornerRadius(40.0) + } + NavigationLink { + CountdownEditView(countdown: countdown, isPresented: $isShowingNewCountdown) + .environment(\.managedObjectContext, viewContext) + } label: { + Image(systemName: "pencil").font(.system(size: 30)) + .padding(12.0) + .overlay(Circle().stroke(.blue, lineWidth: 4) + ) + } + } } }.padding(itemSpacing) .navigationTitle("Youpi") + .alert(error?.localizedDescription ?? "missing error", isPresented: $showAlert) { + Button("OK", role: .none) { } + } .sheet(isPresented: self.$isShowingNewCountdown, content: { NewCountdownView(isPresented: $isShowingNewCountdown) .environment(\.managedObjectContext, viewContext) }) @@ -63,10 +81,47 @@ struct ContentView: View { } } } + .onAppear { + self._askPermissions() + } + .onOpenURL { url in + print("open URL = \(url)") + self._startCountdownIfPossible(url: url) + } } } + fileprivate func _startCountdownIfPossible(url: URL) { + + var urlString = url.absoluteString + urlString.trimPrefix("start://countdown/") + if let countdown = viewContext.object(stringId: urlString) as? Countdown { + self._launchCountdown(countdown) + } else { + print("countdown not found with id = \(urlString)") + } + + } + + fileprivate func _launchCountdown(_ countdown: Countdown) { + CountdownScheduler.master.scheduleIfPossible(duration: countdown.duration) { result in + switch result { + case .success(_): + break + case .failure(let failure): + self.error = failure + self.showAlert = true + } + } + } + + fileprivate func _askPermissions() { + UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound]) { success, error in + print("requestAuthorization > success = \(success), error = \(String(describing: error))") + } + } + } private let itemFormatter: DateFormatter = { diff --git a/LeCountdown/CountdownScheduler.swift b/LeCountdown/CountdownScheduler.swift new file mode 100644 index 0000000..86ab29b --- /dev/null +++ b/LeCountdown/CountdownScheduler.swift @@ -0,0 +1,95 @@ +// +// CountdownScheduler.swift +// Countdown +// +// Created by Laurent Morvillier on 15/01/2023. +// + +import Foundation +import UserNotifications + +class CountdownScheduler { + + static let master = CountdownScheduler() + + func scheduleIfPossible(duration: Double, handler: @escaping (Result) -> Void) { + + UNUserNotificationCenter.current().getPendingNotificationRequests { requests in + self.cancel() + self._scheduleCountdownNotification(duration: duration, handler: handler) + } + + } + + func cancel() { + UNUserNotificationCenter.current().removeAllPendingNotificationRequests() + AppEnvironment.sun.clearNotificationDate() + } + + fileprivate func _scheduleCountdownNotification(duration: Double, handler: @escaping (Result) -> Void) { + let content = UNMutableNotificationContent() + content.title = NSLocalizedString("It's time!", comment: "") + + let minutes = duration / 60.0 + + let minutesLabel = minutes > 1 ? NSLocalizedString("minutes", comment: "") : NSLocalizedString("minute", comment: "") + let isOrAre = minutes > 1 ? NSLocalizedString("are", comment: "") : NSLocalizedString("is", comment: "") + + content.body = NSLocalizedString("The \(minutes) \(minutesLabel) \(isOrAre) over!", comment: "") + content.sound = UNNotificationSound.defaultCritical + + let trigger = UNTimeIntervalNotificationTrigger(timeInterval: duration, repeats: false) + let request = UNNotificationRequest(identifier: "com.staxriver.countdown", content: content, trigger: trigger) + UNUserNotificationCenter.current().add(request) { error in + if let error { + handler(.failure(error)) + print("Scheduling error = \(error)") + } else { + if let date = trigger.nextTriggerDate() { + AppEnvironment.sun.saveNotificationDate(date) + handler(.success(trigger.nextTriggerDate())) + } else { + let backupDate = Date().addingTimeInterval(duration) + AppEnvironment.sun.saveNotificationDate(backupDate) + } + } + } + + print("SCHEDULED @ \(Date())") + + } + +} + + +class AppEnvironment : ObservableObject { + + static let sun: AppEnvironment = AppEnvironment() + + init() { + self.notificationDate = UserDefaults.standard.value(forKey: Key.date.rawValue) as? Date + } + + @Published var notificationDate: Date? = nil { + didSet { + UserDefaults.standard.set(notificationDate, forKey: Key.date.rawValue) + } + } + + enum Key : String { + case date + } + + var hasNotificationDate: Bool { + return self.notificationDate != nil + } + + func saveNotificationDate(_ date: Date) { + self.notificationDate = date + } + + func clearNotificationDate() { + self.notificationDate = nil + } + +} diff --git a/LeCountdown/LeCountdownApp.swift b/LeCountdown/LeCountdownApp.swift index 4caf81a..2516789 100644 --- a/LeCountdown/LeCountdownApp.swift +++ b/LeCountdown/LeCountdownApp.swift @@ -12,10 +12,13 @@ struct LeCountdownApp: App { let persistenceController = PersistenceController.shared + @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate + var body: some Scene { WindowGroup { ContentView() .environment(\.managedObjectContext, persistenceController.container.viewContext) } } + } diff --git a/LeCountdown/NSManagedContext+Extensions.swift b/LeCountdown/NSManagedContext+Extensions.swift new file mode 100644 index 0000000..a1d02b1 --- /dev/null +++ b/LeCountdown/NSManagedContext+Extensions.swift @@ -0,0 +1,27 @@ +// +// NSManagedContext+Extensions.swift +// LeCountdown +// +// Created by Laurent Morvillier on 24/01/2023. +// + +import Foundation +import CoreData + +extension NSManagedObjectContext { + + func object(stringId: String) -> NSManagedObject? { + guard let url = URL(string: stringId) else { return nil } + guard let objectId = PersistenceController.shared.container.persistentStoreCoordinator.managedObjectID(forURIRepresentation: url) else { return nil } + return self.object(with: objectId) + } + +} + +extension NSManagedObject { + + var isTemporary: Bool { + return self.objectID.isTemporaryID + } + +} diff --git a/LeCountdown/NewCountdownView.swift b/LeCountdown/NewCountdownView.swift index eb14505..48eb172 100644 --- a/LeCountdown/NewCountdownView.swift +++ b/LeCountdown/NewCountdownView.swift @@ -8,12 +8,26 @@ import SwiftUI import CoreData -struct NewCountdownView: View { +struct NewCountdownView : View { @Environment(\.managedObjectContext) private var viewContext + + @Binding var isPresented: Bool - + var body: some View { + CountdownEditView(countdown: Countdown(context: viewContext), isPresented: $isPresented) + .environment(\.managedObjectContext, viewContext) + } +} + +struct CountdownEditView : View { + + @Environment(\.managedObjectContext) private var viewContext + @Environment(\.dismiss) private var dismiss + + var countdown: Countdown + @Binding var isPresented: Bool @State var secondsString: String = "" @@ -48,9 +62,11 @@ struct NewCountdownView: View { Text(error?.localizedDescription ?? "error") }) .toolbar { - ToolbarItem(placement: .navigationBarLeading) { - Button("Cancel") { - self._cancel() + if self.countdown.isTemporary { + ToolbarItem(placement: .navigationBarLeading) { + Button("Cancel") { + self._cancel() + } } } ToolbarItem(placement: .navigationBarTrailing) { @@ -82,23 +98,41 @@ struct NewCountdownView: View { } fileprivate func _cancel() { + viewContext.rollback() self.isPresented = false } fileprivate func _save() { - let countdown = Countdown(context: self.viewContext) + let temporary = self.countdown.isTemporary + countdown.duration = self._minutes * 60.0 + self._seconds - countdown.order = Int16(self.countdowns.count) + if temporary { + countdown.order = Int16(self.countdowns.count) + } + + self._saveContext() + if temporary { + self.isPresented = false + } else { + dismiss() + } + + } + + fileprivate func _delete() { + viewContext.delete(self.countdown) + self._saveContext() + } + + fileprivate func _saveContext() { do { try viewContext.save() } catch { self.errorShown = true self.error = error } - - self.isPresented = false } }