From 5ea3c3f824e1d19eb2473d7dd41e43d324166019 Mon Sep 17 00:00:00 2001 From: Laurent Date: Thu, 26 Jan 2023 12:53:32 +0100 Subject: [PATCH] Adds activities and record --- LaunchIntents/IntentHandler.swift | 4 +- LeCountdown.xcodeproj/project.pbxproj | 20 +++++-- LeCountdown/AppDelegate.swift | 5 +- LeCountdown/CountdownScheduler.swift | 53 +++++++++++++------ LeCountdown/LeCountdownApp.swift | 7 +++ LeCountdown/Model/CoreDataRequests.swift | 46 ++++++++++++++++ .../LeCountdown.xcdatamodel/contents | 12 ++++- ...xtensions.swift => Model+Extensions.swift} | 14 ++++- LeCountdown/Model/Persistence.swift | 13 ++++- LeCountdown/Utils/PropertyWrappers.swift | 36 +++++++++++++ LeCountdown/Views/ContentView.swift | 15 ++++-- LeCountdown/Views/NewCountdownView.swift | 16 +++--- LeCountdown/Views/RecordsView.swift | 40 ++++++++++++++ 13 files changed, 241 insertions(+), 40 deletions(-) create mode 100644 LeCountdown/Model/CoreDataRequests.swift rename LeCountdown/Model/{Countdown+Extensions.swift => Model+Extensions.swift} (72%) create mode 100644 LeCountdown/Utils/PropertyWrappers.swift create mode 100644 LeCountdown/Views/RecordsView.swift diff --git a/LaunchIntents/IntentHandler.swift b/LaunchIntents/IntentHandler.swift index 64a4941..f43679c 100644 --- a/LaunchIntents/IntentHandler.swift +++ b/LaunchIntents/IntentHandler.swift @@ -36,14 +36,14 @@ class IntentHandler: INExtension, SelectCountdownIntentHandling { let displayName: String let formattedDuration = countdown.duration.minuteSecond - if let name = countdown.name, !name.isEmpty { + if let name = countdown.activity?.name, !name.isEmpty { displayName = "\(name) (\(formattedDuration))" } else { displayName = formattedDuration } let cp = CountdownProperties(identifier: countdown.objectID.uriRepresentation().absoluteString, display: displayName) - cp.name = countdown.name + cp.name = countdown.activity?.name cp.duration = NSNumber(value: countdown.duration) return cp } diff --git a/LeCountdown.xcodeproj/project.pbxproj b/LeCountdown.xcodeproj/project.pbxproj index 4d283db..2583fd8 100644 --- a/LeCountdown.xcodeproj/project.pbxproj +++ b/LeCountdown.xcodeproj/project.pbxproj @@ -41,7 +41,10 @@ C438C8012981327600BF3EF9 /* Persistence.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4060DC8297AE73D003FAB80 /* Persistence.swift */; }; C438C802298132B900BF3EF9 /* LeCountdown.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = C4060DCA297AE73D003FAB80 /* LeCountdown.xcdatamodeld */; }; C438C80529813FB400BF3EF9 /* TimeInterval+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4060DF4297AE9A7003FAB80 /* TimeInterval+Extensions.swift */; }; - C438C807298195E600BF3EF9 /* Countdown+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = C438C806298195E600BF3EF9 /* Countdown+Extensions.swift */; }; + C438C807298195E600BF3EF9 /* Model+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = C438C806298195E600BF3EF9 /* Model+Extensions.swift */; }; + C438C80D2982847300BF3EF9 /* CoreDataRequests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C438C80C2982847300BF3EF9 /* CoreDataRequests.swift */; }; + C438C80F29828B8600BF3EF9 /* RecordsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C438C80E29828B8600BF3EF9 /* RecordsView.swift */; }; + C438C81129829EAF00BF3EF9 /* PropertyWrappers.swift in Sources */ = {isa = PBXBuildFile; fileRef = C438C81029829EAF00BF3EF9 /* PropertyWrappers.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -126,7 +129,10 @@ C438C7FE2981300500BF3EF9 /* IntentDataProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntentDataProvider.swift; sourceTree = ""; }; C438C80329813B2500BF3EF9 /* LaunchIntents.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = LaunchIntents.entitlements; sourceTree = ""; }; C438C80429813B3100BF3EF9 /* LeCountdown.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = LeCountdown.entitlements; sourceTree = ""; }; - C438C806298195E600BF3EF9 /* Countdown+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Countdown+Extensions.swift"; sourceTree = ""; }; + C438C806298195E600BF3EF9 /* Model+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Model+Extensions.swift"; sourceTree = ""; }; + C438C80C2982847300BF3EF9 /* CoreDataRequests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoreDataRequests.swift; sourceTree = ""; }; + C438C80E29828B8600BF3EF9 /* RecordsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordsView.swift; sourceTree = ""; }; + C438C81029829EAF00BF3EF9 /* PropertyWrappers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PropertyWrappers.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -285,7 +291,8 @@ isa = PBXGroup; children = ( C4060DCA297AE73D003FAB80 /* LeCountdown.xcdatamodeld */, - C438C806298195E600BF3EF9 /* Countdown+Extensions.swift */, + C438C80C2982847300BF3EF9 /* CoreDataRequests.swift */, + C438C806298195E600BF3EF9 /* Model+Extensions.swift */, C438C7C4298024E900BF3EF9 /* NSManagedContext+Extensions.swift */, C4060DC8297AE73D003FAB80 /* Persistence.swift */, ); @@ -296,6 +303,7 @@ isa = PBXGroup; children = ( C4060DF4297AE9A7003FAB80 /* TimeInterval+Extensions.swift */, + C438C81029829EAF00BF3EF9 /* PropertyWrappers.swift */, ); path = Utils; sourceTree = ""; @@ -305,6 +313,7 @@ children = ( C4060DC1297AE73B003FAB80 /* ContentView.swift */, C4060DF6297AFEF2003FAB80 /* NewCountdownView.swift */, + C438C80E29828B8600BF3EF9 /* RecordsView.swift */, ); path = Views; sourceTree = ""; @@ -500,7 +509,10 @@ buildActionMask = 2147483647; files = ( C4060DC9297AE73D003FAB80 /* Persistence.swift in Sources */, - C438C807298195E600BF3EF9 /* Countdown+Extensions.swift in Sources */, + C438C80F29828B8600BF3EF9 /* RecordsView.swift in Sources */, + C438C80D2982847300BF3EF9 /* CoreDataRequests.swift in Sources */, + C438C81129829EAF00BF3EF9 /* PropertyWrappers.swift in Sources */, + C438C807298195E600BF3EF9 /* Model+Extensions.swift in Sources */, C438C7FF2981300500BF3EF9 /* IntentDataProvider.swift in Sources */, C4060DC2297AE73B003FAB80 /* ContentView.swift in Sources */, C438C7C12980228B00BF3EF9 /* CountdownScheduler.swift in Sources */, diff --git a/LeCountdown/AppDelegate.swift b/LeCountdown/AppDelegate.swift index 2c9f5e8..4496097 100644 --- a/LeCountdown/AppDelegate.swift +++ b/LeCountdown/AppDelegate.swift @@ -10,14 +10,13 @@ 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 { @@ -30,7 +29,7 @@ extension AppDelegate: UNUserNotificationCenterDelegate { print("willPresent notification") completionHandler([.banner, .sound]) - AppEnvironment.sun.clearNotificationDate(countdownId: notification.request.identifier) + AppEnvironment.sun.endCountdown(countdownId: notification.request.identifier) } } diff --git a/LeCountdown/CountdownScheduler.swift b/LeCountdown/CountdownScheduler.swift index e87a290..b070b4e 100644 --- a/LeCountdown/CountdownScheduler.swift +++ b/LeCountdown/CountdownScheduler.swift @@ -19,7 +19,7 @@ class CountdownScheduler { func cancelCurrentNotifications(countdown: Countdown) { UNUserNotificationCenter.current().removePendingNotificationRequests(withIdentifiers: [countdown.stringId]) - AppEnvironment.sun.clearNotificationDate(countdownId: countdown.stringId) + AppEnvironment.sun.endCountdown(countdownId: countdown.stringId) } fileprivate func _scheduleCountdownNotification(countdown: Countdown, handler: @escaping (Result) -> Void) { @@ -44,11 +44,11 @@ class CountdownScheduler { print("Scheduling error = \(error)") } else { if let date = trigger.nextTriggerDate() { - AppEnvironment.sun.saveNotificationDate(date, countdown: countdown) + AppEnvironment.sun.startCountdown(date, countdown: countdown) handler(.success(trigger.nextTriggerDate())) } else { let backupDate = Date().addingTimeInterval(duration) - AppEnvironment.sun.saveNotificationDate(backupDate, countdown: countdown) + AppEnvironment.sun.startCountdown(backupDate, countdown: countdown) } } } @@ -60,37 +60,56 @@ class CountdownScheduler { } - class AppEnvironment : ObservableObject { static let sun: AppEnvironment = AppEnvironment() + @UserDefault(Key.dates.rawValue, defaultValue: [:]) static var savedDates: [String : DateInterval] + init() { - if let dates = UserDefaults.standard.value(forKey: Key.dates.rawValue) as? [String : Date] { - self.notificationDates = dates - } + self.notificationDates = AppEnvironment.savedDates } - @Published var notificationDates: [String : Date] = [:] { + @Published var notificationDates: [String : DateInterval] = [:] { didSet { - UserDefaults.standard.set(notificationDates, forKey: Key.dates.rawValue) + AppEnvironment.savedDates = notificationDates } } enum Key : String { case dates } - -// var hasNotificationDate: Bool { -// return self.notificationDates != nil -// } - - func saveNotificationDate(_ date: Date, countdown: Countdown) { - self.notificationDates[countdown.stringId] = date + + func startCountdown(_ date: Date, countdown: Countdown) { + let dateInterval = DateInterval(start: Date(), end: date) + self.notificationDates[countdown.stringId] = dateInterval } - func clearNotificationDate(countdownId: String) { + func endCountdown(countdownId: String) { + self._recordActivityIfPossible(countdownId: countdownId) self.notificationDates.removeValue(forKey: countdownId) } + func cleanup() { + let now = Date() + for (key, value) in self.notificationDates { + if value.end < now { + self.endCountdown(countdownId: key) + } + } + } + + fileprivate func _recordActivityIfPossible(countdownId: String) { + let context = PersistenceController.shared.container.viewContext + if let countdown = context.object(stringId: countdownId) as? Countdown, + let dateInterval = self.notificationDates[countdownId] { + do { + try CoreDataRequests.recordActivity(countdown: countdown, dateInterval: dateInterval) + } catch { + print("Could not record activity = \(error)") + // TODO: show error to user + } + } + } + } diff --git a/LeCountdown/LeCountdownApp.swift b/LeCountdown/LeCountdownApp.swift index 9b2655a..e05da99 100644 --- a/LeCountdown/LeCountdownApp.swift +++ b/LeCountdown/LeCountdownApp.swift @@ -19,7 +19,14 @@ struct LeCountdownApp: App { ContentView() .environmentObject(AppEnvironment.sun) .environment(\.managedObjectContext, persistenceController.container.viewContext) + .onReceive(NotificationCenter.default.publisher(for: UIApplication.willEnterForegroundNotification)) { _ in + self._willEnterForegroundNotification() + } } } + fileprivate func _willEnterForegroundNotification() { + AppEnvironment.sun.cleanup() + } + } diff --git a/LeCountdown/Model/CoreDataRequests.swift b/LeCountdown/Model/CoreDataRequests.swift new file mode 100644 index 0000000..b7f448c --- /dev/null +++ b/LeCountdown/Model/CoreDataRequests.swift @@ -0,0 +1,46 @@ +// +// CoreDataRequests.swift +// LeCountdown +// +// Created by Laurent Morvillier on 26/01/2023. +// + +import Foundation + +class CoreDataRequests { + + static func getOrCreateActivity(name: String) -> Activity { + + let context = PersistenceController.shared.container.viewContext + let request = Activity.fetchRequest() + request.predicate = NSPredicate(format: "name like %@", name) + + do { + let results = try context.fetch(request) + if let activity = results.first { + return activity + } + } catch { + print("error = \(error)") + } + let activity = Activity(context: context) + activity.name = name + return activity + } + + static func recordActivity(countdown: Countdown, dateInterval: DateInterval) throws { + + guard let activity = countdown.activity else { + return + } + + let context = PersistenceController.shared.container.viewContext + let record = Record(context: context) + record.start = dateInterval.start + record.end = dateInterval.end + record.activity = activity + + try context.save() + } + +} diff --git a/LeCountdown/Model/LeCountdown.xcdatamodeld/LeCountdown.xcdatamodel/contents b/LeCountdown/Model/LeCountdown.xcdatamodeld/LeCountdown.xcdatamodel/contents index 22703c4..ea0ef8c 100644 --- a/LeCountdown/Model/LeCountdown.xcdatamodeld/LeCountdown.xcdatamodel/contents +++ b/LeCountdown/Model/LeCountdown.xcdatamodeld/LeCountdown.xcdatamodel/contents @@ -1,10 +1,20 @@ + + + + + - + + + + + + \ No newline at end of file diff --git a/LeCountdown/Model/Countdown+Extensions.swift b/LeCountdown/Model/Model+Extensions.swift similarity index 72% rename from LeCountdown/Model/Countdown+Extensions.swift rename to LeCountdown/Model/Model+Extensions.swift index 4e3de59..b574f16 100644 --- a/LeCountdown/Model/Countdown+Extensions.swift +++ b/LeCountdown/Model/Model+Extensions.swift @@ -11,7 +11,7 @@ import SwiftUI extension Countdown { var endDate: Date? { - return AppEnvironment.sun.notificationDates[self.stringId] + return AppEnvironment.sun.notificationDates[self.stringId]?.end } var isLive: Bool { @@ -27,3 +27,15 @@ extension Countdown { } } + +extension Record { + + var details: String { + if let start, let end { + return "\(start) - \(end)" + } else { + return "no details" + } + } + +} diff --git a/LeCountdown/Model/Persistence.swift b/LeCountdown/Model/Persistence.swift index 854fdee..5268c71 100644 --- a/LeCountdown/Model/Persistence.swift +++ b/LeCountdown/Model/Persistence.swift @@ -14,11 +14,22 @@ struct PersistenceController { let result = PersistenceController(inMemory: true) let viewContext = result.container.viewContext + + let activity = Activity(context: viewContext) + activity.name = "Tea" for i in 0..<3 { let countdown = Countdown(context: viewContext) countdown.order = Int16(i) - countdown.name = "Tea" + countdown.activity = activity } + + for i in 0..<3 { + let record = Record(context: viewContext) + record.start = Date() + record.end = Date() + record.activity = activity + } + do { try viewContext.save() } catch { diff --git a/LeCountdown/Utils/PropertyWrappers.swift b/LeCountdown/Utils/PropertyWrappers.swift new file mode 100644 index 0000000..d66e05c --- /dev/null +++ b/LeCountdown/Utils/PropertyWrappers.swift @@ -0,0 +1,36 @@ +// +// PropertyWrappers.swift +// LeCountdown +// +// Created by Laurent Morvillier on 26/01/2023. +// + +import Foundation + +@propertyWrapper +struct UserDefault { + let key: String + let defaultValue: T + + init(_ key: String, defaultValue: T) { + self.key = key + self.defaultValue = defaultValue + } + + var wrappedValue: T { + get { + + if let data = UserDefaults.standard.object(forKey: key) as? Data, + let user = try? JSONDecoder().decode(T.self, from: data) { + return user + + } + return defaultValue + } + set { + if let encoded = try? JSONEncoder().encode(newValue) { + UserDefaults.standard.set(encoded, forKey: key) + } + } + } +} diff --git a/LeCountdown/Views/ContentView.swift b/LeCountdown/Views/ContentView.swift index abf6052..a7abe4e 100644 --- a/LeCountdown/Views/ContentView.swift +++ b/LeCountdown/Views/ContentView.swift @@ -16,13 +16,13 @@ struct CountdownLiveView: View { var body: some View { VStack { - Text(countdown.name ?? "") - if let date = environment.notificationDates[countdown.stringId] { - Text(date, style: .timer) + Text(countdown.activity?.name ?? "") + if let dateInterval = environment.notificationDates[countdown.stringId] { + Text(dateInterval.end, style: .timer) Button { CountdownScheduler.master.cancelCurrentNotifications(countdown: countdown) } label: { - Text("Cancel").buttonStyle(.bordered).tint(.blue) + Text("Cancel").buttonStyle(.bordered).tint(.red) } } else { @@ -110,6 +110,13 @@ struct ContentView: View { Image(systemName: "plus") } } + ToolbarItem(placement: .navigationBarTrailing) { + NavigationLink { + RecordsView() + } label: { + Image(systemName: "chart.bar.fill") + } + } } .onAppear { self._askPermissions() diff --git a/LeCountdown/Views/NewCountdownView.swift b/LeCountdown/Views/NewCountdownView.swift index 861f4dc..4e8117b 100644 --- a/LeCountdown/Views/NewCountdownView.swift +++ b/LeCountdown/Views/NewCountdownView.swift @@ -29,21 +29,21 @@ struct CountdownFormView : View { var minutesBinding: Binding var nameBinding: Binding - @FocusState private var textFieldIsFocused: Bool + var textFieldIsFocused: FocusState.Binding var body: some View { Form { Section(header: Text("Duration")) { TextField("minutes", text: minutesBinding) .keyboardType(.numberPad) - .focused($textFieldIsFocused) + .focused(textFieldIsFocused) TextField("seconds", text: secondsBinding) .keyboardType(.numberPad) - .focused($textFieldIsFocused) + .focused(textFieldIsFocused) } Section(header: Text("Name for tracking the activity")) { TextField("name", text: nameBinding) - .focused($textFieldIsFocused) + .focused(textFieldIsFocused) } Section(header: Text("Properties")) { @@ -85,7 +85,8 @@ struct CountdownEditView : View { CountdownFormView(secondsBinding: $secondsString, minutesBinding: $minutesString, - nameBinding: $nameString) + nameBinding: $nameString, + textFieldIsFocused: $textFieldIsFocused) .onAppear { self._onAppear() } @@ -154,7 +155,7 @@ struct CountdownEditView : View { self.secondsString = self._numberFormatter.string(from: NSNumber(value: seconds)) ?? "" } - if let name = countdown.name, !name.isEmpty { + if let name = countdown.activity?.name, !name.isEmpty { self.nameString = name } } @@ -190,7 +191,8 @@ struct CountdownEditView : View { cd.order = Int16(self.countdowns.count) } if !self.nameString.isEmpty { - cd.name = self.nameString + cd.activity = CoreDataRequests.getOrCreateActivity(name: self.nameString) + // TODO: would you like to rename or create a new activity? } self._saveContext() diff --git a/LeCountdown/Views/RecordsView.swift b/LeCountdown/Views/RecordsView.swift new file mode 100644 index 0000000..ab67808 --- /dev/null +++ b/LeCountdown/Views/RecordsView.swift @@ -0,0 +1,40 @@ +// +// RecordsView.swift +// LeCountdown +// +// Created by Laurent Morvillier on 26/01/2023. +// + +import SwiftUI + +struct RecordsView: View { + + @FetchRequest( + sortDescriptors: [NSSortDescriptor(keyPath: \Record.start, ascending: false)], + animation: .default) + private var records: FetchedResults + + var body: some View { + + if records.isEmpty { + Text("You don't have any recorded activity yet") + } else { + List { + ForEach(records) { record in + HStack { + Text(record.activity?.name ?? "no activity") + Spacer() + Text(record.details) + } + } + } + } + + } +} + +struct RecordsView_Previews: PreviewProvider { + static var previews: some View { + RecordsView().environment(\.managedObjectContext, PersistenceController.preview.container.viewContext) + } +}