Adds working countdowns

release
Laurent 3 years ago
parent 86825d667f
commit 6ebaedd641
  1. 12
      LeCountdown.xcodeproj/project.pbxproj
  2. 35
      LeCountdown/AppDelegate.swift
  3. 77
      LeCountdown/ContentView.swift
  4. 95
      LeCountdown/CountdownScheduler.swift
  5. 3
      LeCountdown/LeCountdownApp.swift
  6. 27
      LeCountdown/NSManagedContext+Extensions.swift
  7. 52
      LeCountdown/NewCountdownView.swift

@ -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 = "<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>"; };
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>"; };
C438C7C829803CA000BF3EF9 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
/* 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 = "<group>";
@ -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;

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

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

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

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

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

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

Loading…
Cancel
Save