Adds activities and record

release
Laurent 3 years ago
parent 7430a3842c
commit 5ea3c3f824
  1. 4
      LaunchIntents/IntentHandler.swift
  2. 20
      LeCountdown.xcodeproj/project.pbxproj
  3. 5
      LeCountdown/AppDelegate.swift
  4. 53
      LeCountdown/CountdownScheduler.swift
  5. 7
      LeCountdown/LeCountdownApp.swift
  6. 46
      LeCountdown/Model/CoreDataRequests.swift
  7. 12
      LeCountdown/Model/LeCountdown.xcdatamodeld/LeCountdown.xcdatamodel/contents
  8. 14
      LeCountdown/Model/Model+Extensions.swift
  9. 13
      LeCountdown/Model/Persistence.swift
  10. 36
      LeCountdown/Utils/PropertyWrappers.swift
  11. 15
      LeCountdown/Views/ContentView.swift
  12. 16
      LeCountdown/Views/NewCountdownView.swift
  13. 40
      LeCountdown/Views/RecordsView.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
}

@ -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 = "<group>"; };
C438C80329813B2500BF3EF9 /* LaunchIntents.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = LaunchIntents.entitlements; sourceTree = "<group>"; };
C438C80429813B3100BF3EF9 /* LeCountdown.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = LeCountdown.entitlements; sourceTree = "<group>"; };
C438C806298195E600BF3EF9 /* Countdown+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Countdown+Extensions.swift"; sourceTree = "<group>"; };
C438C806298195E600BF3EF9 /* Model+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Model+Extensions.swift"; sourceTree = "<group>"; };
C438C80C2982847300BF3EF9 /* CoreDataRequests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoreDataRequests.swift; sourceTree = "<group>"; };
C438C80E29828B8600BF3EF9 /* RecordsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordsView.swift; sourceTree = "<group>"; };
C438C81029829EAF00BF3EF9 /* PropertyWrappers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PropertyWrappers.swift; sourceTree = "<group>"; };
/* 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 = "<group>";
@ -305,6 +313,7 @@
children = (
C4060DC1297AE73B003FAB80 /* ContentView.swift */,
C4060DF6297AFEF2003FAB80 /* NewCountdownView.swift */,
C438C80E29828B8600BF3EF9 /* RecordsView.swift */,
);
path = Views;
sourceTree = "<group>";
@ -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 */,

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

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

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

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

@ -1,10 +1,20 @@
<?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="Activity" representedClassName="Activity" syncable="YES" codeGenerationType="class">
<attribute name="name" attributeType="String" defaultValueString=""/>
<relationship name="countdowns" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Countdown" inverseName="activity" inverseEntity="Countdown"/>
<relationship name="records" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Record" inverseName="activity" inverseEntity="Record"/>
</entity>
<entity name="Countdown" representedClassName="Countdown" syncable="YES" codeGenerationType="class">
<attribute name="duration" attributeType="Double" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="image" optional="YES" attributeType="String"/>
<attribute name="name" optional="YES" attributeType="String"/>
<attribute name="order" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="sound" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<relationship name="activity" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Activity" inverseName="countdowns" inverseEntity="Activity"/>
</entity>
<entity name="Record" representedClassName="Record" syncable="YES" codeGenerationType="class">
<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>
</model>

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

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

@ -0,0 +1,36 @@
//
// PropertyWrappers.swift
// LeCountdown
//
// Created by Laurent Morvillier on 26/01/2023.
//
import Foundation
@propertyWrapper
struct UserDefault<T: Codable> {
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)
}
}
}
}

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

@ -29,21 +29,21 @@ struct CountdownFormView : View {
var minutesBinding: Binding<String>
var nameBinding: Binding<String>
@FocusState private var textFieldIsFocused: Bool
var textFieldIsFocused: FocusState<Bool>.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()

@ -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<Record>
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)
}
}
Loading…
Cancel
Save