From a7ec081bd904b10107d5ab17ef6c2d4ec82349cd Mon Sep 17 00:00:00 2001 From: Laurent Date: Mon, 20 Feb 2023 16:42:32 +0100 Subject: [PATCH] Adds filters to computations --- LeCountdown.xcodeproj/project.pbxproj | 28 ++- LeCountdown/Conductor.swift | 2 +- .../Model/NSManagedContext+Extensions.swift | 65 ------ LeCountdown/Model/Persistence.swift | 12 +- LeCountdown/Stats/Context+Calculations.swift | 85 ++++++++ LeCountdown/Stats/Filter.swift | 56 +++++ LeCountdown/Stats/Stat.swift | 78 +++++++ .../Utils/TimeInterval+Extensions.swift | 2 +- .../Views/Components/GreenCheckmarkView.swift | 2 - LeCountdown/Views/ContentView.swift | 40 ++-- LeCountdown/Views/LiveTimerListView.swift | 200 ++++++++++++------ LeCountdown/Views/Stats/ActivitiesView.swift | 42 ++-- LeCountdown/Views/Stats/ActivityView.swift | 60 ++++++ LeCountdown/Views/Stats/RecordsView.swift | 25 ++- LeCountdown/Views/Stats/StatsView.swift | 76 +------ 15 files changed, 503 insertions(+), 270 deletions(-) create mode 100644 LeCountdown/Stats/Context+Calculations.swift create mode 100644 LeCountdown/Stats/Filter.swift create mode 100644 LeCountdown/Stats/Stat.swift create mode 100644 LeCountdown/Views/Stats/ActivityView.swift diff --git a/LeCountdown.xcodeproj/project.pbxproj b/LeCountdown.xcodeproj/project.pbxproj index 7019710..d4caed2 100644 --- a/LeCountdown.xcodeproj/project.pbxproj +++ b/LeCountdown.xcodeproj/project.pbxproj @@ -110,8 +110,6 @@ C4BA2B3B299F838000CB4FBA /* Model+SharedExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4BA2B39299F838000CB4FBA /* Model+SharedExtensions.swift */; }; C4BA2B3C299F838000CB4FBA /* Model+SharedExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4BA2B39299F838000CB4FBA /* Model+SharedExtensions.swift */; }; C4BA2B3E299FC86800CB4FBA /* StatsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4BA2B3D299FC86800CB4FBA /* StatsView.swift */; }; - C4BA2B3F299FC86800CB4FBA /* StatsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4BA2B3D299FC86800CB4FBA /* StatsView.swift */; }; - C4BA2B40299FC86800CB4FBA /* StatsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4BA2B3D299FC86800CB4FBA /* StatsView.swift */; }; C4BA2B43299FCB2B00CB4FBA /* RecordsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4BA2B42299FCB2B00CB4FBA /* RecordsView.swift */; }; C4BA2B49299FCE0C00CB4FBA /* IntervalGroup+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4BA2B47299FCE0C00CB4FBA /* IntervalGroup+CoreDataProperties.swift */; }; C4BA2B4A299FCE0C00CB4FBA /* IntervalGroup+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4BA2B47299FCE0C00CB4FBA /* IntervalGroup+CoreDataProperties.swift */; }; @@ -124,6 +122,10 @@ C4BA2B5C299FFAB000CB4FBA /* Logger.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4BA2B5A299FFAB000CB4FBA /* Logger.swift */; }; C4BA2B5D299FFAB000CB4FBA /* Logger.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4BA2B5A299FFAB000CB4FBA /* Logger.swift */; }; C4BA2B5F299FFC8400CB4FBA /* StoreView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4BA2B5E299FFC8300CB4FBA /* StoreView.swift */; }; + C4BA2B6129A3C02400CB4FBA /* ActivityView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4BA2B6029A3C02400CB4FBA /* ActivityView.swift */; }; + C4BA2B6329A3C34600CB4FBA /* Stat.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4BA2B6229A3C34600CB4FBA /* Stat.swift */; }; + C4BA2B6529A3C37D00CB4FBA /* Filter.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4BA2B6429A3C37D00CB4FBA /* Filter.swift */; }; + C4BA2B6829A3C4AC00CB4FBA /* Context+Calculations.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4BA2B6729A3C4AC00CB4FBA /* Context+Calculations.swift */; }; C4F8B1532987FE6F005C86A5 /* LaunchWidgetLiveActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = C438C7D72981216200BF3EF9 /* LaunchWidgetLiveActivity.swift */; }; C4F8B15729891271005C86A5 /* Conductor.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4F8B15629891271005C86A5 /* Conductor.swift */; }; C4F8B15929891528005C86A5 /* forest_stream.mp3 in Resources */ = {isa = PBXBuildFile; fileRef = C4F8B15829891528005C86A5 /* forest_stream.mp3 */; }; @@ -314,6 +316,10 @@ C4BA2B56299FFA4F00CB4FBA /* AppGuard.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppGuard.swift; sourceTree = ""; }; C4BA2B5A299FFAB000CB4FBA /* Logger.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Logger.swift; sourceTree = ""; }; C4BA2B5E299FFC8300CB4FBA /* StoreView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreView.swift; sourceTree = ""; }; + C4BA2B6029A3C02400CB4FBA /* ActivityView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActivityView.swift; sourceTree = ""; }; + C4BA2B6229A3C34600CB4FBA /* Stat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Stat.swift; sourceTree = ""; }; + C4BA2B6429A3C37D00CB4FBA /* Filter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Filter.swift; sourceTree = ""; }; + C4BA2B6729A3C4AC00CB4FBA /* Context+Calculations.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Context+Calculations.swift"; sourceTree = ""; }; C4F8B15629891271005C86A5 /* Conductor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Conductor.swift; sourceTree = ""; }; C4F8B15829891528005C86A5 /* forest_stream.mp3 */ = {isa = PBXFileReference; lastKnownFileType = audio.mp3; path = forest_stream.mp3; sourceTree = ""; }; C4F8B15E298961A7005C86A5 /* ReorderableForEach.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReorderableForEach.swift; sourceTree = ""; }; @@ -421,6 +427,7 @@ C438C8092981DDF800BF3EF9 /* Model */, C445FA8D2987B82E0054D761 /* Sound */, C438C80A2981DE1A00BF3EF9 /* Utils */, + C4BA2B6629A3C49200CB4FBA /* Stats */, C438C8082981DDD200BF3EF9 /* Widget */, C445FA962987D0CF0054D761 /* Sound_Assets */, C4BA2B55299FFA3700CB4FBA /* Subscription */, @@ -589,6 +596,7 @@ isa = PBXGroup; children = ( C438C80E29828B8600BF3EF9 /* ActivitiesView.swift */, + C4BA2B6029A3C02400CB4FBA /* ActivityView.swift */, C4BA2B42299FCB2B00CB4FBA /* RecordsView.swift */, C4BA2B3D299FC86800CB4FBA /* StatsView.swift */, ); @@ -604,6 +612,16 @@ path = Subscription; sourceTree = ""; }; + C4BA2B6629A3C49200CB4FBA /* Stats */ = { + isa = PBXGroup; + children = ( + C4BA2B6729A3C4AC00CB4FBA /* Context+Calculations.swift */, + C4BA2B6429A3C37D00CB4FBA /* Filter.swift */, + C4BA2B6229A3C34600CB4FBA /* Stat.swift */, + ); + path = Stats; + sourceTree = ""; + }; C4F8B188298AC248005C86A5 /* Generation */ = { isa = PBXGroup; children = ( @@ -879,6 +897,7 @@ C4060DC9297AE73D003FAB80 /* Persistence.swift in Sources */, C4BA2B36299F82FB00CB4FBA /* Fakes.swift in Sources */, C498E5A1298D543900E90DE0 /* LiveTimer.swift in Sources */, + C4BA2B6329A3C34600CB4FBA /* Stat.swift in Sources */, C438C80F29828B8600BF3EF9 /* ActivitiesView.swift in Sources */, C4F8B187298AC234005C86A5 /* Activity+CoreDataProperties.swift in Sources */, C438C80D2982847300BF3EF9 /* CoreDataRequests.swift in Sources */, @@ -893,6 +912,7 @@ C438C81129829EAF00BF3EF9 /* PropertyWrappers.swift in Sources */, C4F8B1BF298ACA0B005C86A5 /* StopwatchDialView.swift in Sources */, C438C807298195E600BF3EF9 /* Model+Extensions.swift in Sources */, + C4BA2B6829A3C4AC00CB4FBA /* Context+Calculations.swift in Sources */, C4BA2B04299A42EF00CB4FBA /* NewDataView.swift in Sources */, C4BA2B4C299FCE0C00CB4FBA /* Stopwatch+CoreDataProperties.swift in Sources */, C438C7FF2981300500BF3EF9 /* IntentDataProvider.swift in Sources */, @@ -923,6 +943,7 @@ C4F8B1532987FE6F005C86A5 /* LaunchWidgetLiveActivity.swift in Sources */, C4F8B182298AC234005C86A5 /* Stopwatch+CoreDataClass.swift in Sources */, C4BA2ADB299549BC00CB4FBA /* TimerModel.swift in Sources */, + C4BA2B6529A3C37D00CB4FBA /* Filter.swift in Sources */, C4F8B16B298AA240005C86A5 /* NewStopwatchView.swift in Sources */, C4BA2AF22996A11900CB4FBA /* CustomSound+CoreDataProperties.swift in Sources */, C4060DF5297AE9A7003FAB80 /* TimeInterval+Extensions.swift in Sources */, @@ -944,6 +965,7 @@ C4742B5B298414B000D5D950 /* ImageSelectionView.swift in Sources */, C438C7C5298024E900BF3EF9 /* NSManagedContext+Extensions.swift in Sources */, C4F8B15F298961A7005C86A5 /* ReorderableForEach.swift in Sources */, + C4BA2B6129A3C02400CB4FBA /* ActivityView.swift in Sources */, C4BA2B10299BE61E00CB4FBA /* Interval+CoreDataProperties.swift in Sources */, C4F8B1AC298AC3A0005C86A5 /* Alarm+CoreDataProperties.swift in Sources */, C498E59F298D4DEA00E90DE0 /* LiveTimerListView.swift in Sources */, @@ -987,7 +1009,6 @@ C4BA2AF72996A4EF00CB4FBA /* CustomSound+CoreDataClass.swift in Sources */, C4F8B1AD298AC451005C86A5 /* AbstractSoundTimer+CoreDataClass.swift in Sources */, C445FA87298448730054D761 /* CoolPic.swift in Sources */, - C4BA2B3F299FC86800CB4FBA /* StatsView.swift in Sources */, C438C8162982BE1E00BF3EF9 /* LeCountdown.xcdatamodeld in Sources */, C4BA2B4A299FCE0C00CB4FBA /* IntervalGroup+CoreDataProperties.swift in Sources */, C4BA2B4D299FCE0C00CB4FBA /* Stopwatch+CoreDataProperties.swift in Sources */, @@ -1039,7 +1060,6 @@ C438C81A2982BFF100BF3EF9 /* NSManagedContext+Extensions.swift in Sources */, C4F8B19D298AC288005C86A5 /* AbstractTimer+CoreDataClass.swift in Sources */, C4BA2B3C299F838000CB4FBA /* Model+SharedExtensions.swift in Sources */, - C4BA2B40299FC86800CB4FBA /* StatsView.swift in Sources */, C4BA2AF82996A4F000CB4FBA /* CustomSound+CoreDataProperties.swift in Sources */, C4F8B199298AC288005C86A5 /* Record+CoreDataClass.swift in Sources */, C438C8012981327600BF3EF9 /* Persistence.swift in Sources */, diff --git a/LeCountdown/Conductor.swift b/LeCountdown/Conductor.swift index 2c8f1c7..3f2235c 100644 --- a/LeCountdown/Conductor.swift +++ b/LeCountdown/Conductor.swift @@ -141,7 +141,7 @@ class Conductor: ObservableObject { // self._endLiveActivity(countdownId: countdownId) // } - self.removeLiveTimer(id: countdownId) +// self.removeLiveTimer(id: countdownId) } } diff --git a/LeCountdown/Model/NSManagedContext+Extensions.swift b/LeCountdown/Model/NSManagedContext+Extensions.swift index 1460363..d09c8c5 100644 --- a/LeCountdown/Model/NSManagedContext+Extensions.swift +++ b/LeCountdown/Model/NSManagedContext+Extensions.swift @@ -16,71 +16,6 @@ extension NSManagedObjectContext { return self.object(with: objectId) } - func compute(activity: Activity, stats: [Stat]) throws -> [StatValue] { - - // Step 1: - // - Create the summing expression on the amount attribute. - // - Name the expression result as 'amountTotal'. - // - Assign the expression result data type as a Double. - - var expressions: [NSExpressionDescription] = [] - - for stat in stats { - let expression = NSExpressionDescription() - expression.expression = NSExpression(forFunction: stat.function, arguments:[NSExpression(forKeyPath: stat.field)]) - expression.name = stat.stringIdentifier; - expression.expressionResultType = NSAttributeType.doubleAttributeType - - expressions.append(expression) - } - - // Step 2: - // - Create the fetch request for the Movement entity. - // - Indicate that the fetched properties are those that were - // described in `expression`. - // - Indicate that the result type is a dictionary. - - let fetchRequest = NSFetchRequest(entityName: "Record") - fetchRequest.predicate = NSPredicate(format: "activity = %@", activity) - fetchRequest.propertiesToFetch = expressions - fetchRequest.resultType = NSFetchRequestResultType.dictionaryResultType - - // Step 3: - // - Execute the fetch request which returns an array. - // - There will only be one result. Get the first array - // element and assign to 'resultMap'. - // - The summed amount value is in the dictionary as - // 'amountTotal'. This will be summed value. - - var statValues: [StatValue] = [] - - let results = try self.fetch(fetchRequest) - if let resultMap = results.first as? [String : Any] { - - for stat in stats { - - if let result = resultMap[stat.stringIdentifier] { - - var value: NSDecimalNumber? = nil - switch result { - case let double as Double: - value = NSDecimalNumber(value: double) - case let integer as Int: - value = NSDecimalNumber(integerLiteral: integer) - default: - print("unmanaged value type = \(String(describing: result))") - break - } - - if let value { - let sv = StatValue(stat: stat, value: value) - statValues.append(sv) - } - } - } - } - return statValues - } } extension NSManagedObject { diff --git a/LeCountdown/Model/Persistence.swift b/LeCountdown/Model/Persistence.swift index fb7be47..d1730ca 100644 --- a/LeCountdown/Model/Persistence.swift +++ b/LeCountdown/Model/Persistence.swift @@ -17,6 +17,13 @@ struct PersistenceController { let activity = Activity(context: viewContext) activity.name = "Tea" + let activity2 = Activity(context: viewContext) + activity2.name = "Running" + let activity3 = Activity(context: viewContext) + activity3.name = "Nap" + + let activities = [activity, activity2, activity3] + for i in 0..<3 { let countdown = Countdown(context: viewContext) countdown.order = Int16(i) @@ -24,13 +31,14 @@ struct PersistenceController { countdown.image = CoolPic.pic1.rawValue } - for i in 0..<3 { + for i in 0..<14 { let record = Record(context: viewContext) record.start = Date() record.end = Date() - record.activity = activity + record.activity = activities.randomElement() } + do { try viewContext.save() } catch { diff --git a/LeCountdown/Stats/Context+Calculations.swift b/LeCountdown/Stats/Context+Calculations.swift new file mode 100644 index 0000000..e6151d3 --- /dev/null +++ b/LeCountdown/Stats/Context+Calculations.swift @@ -0,0 +1,85 @@ +// +// Context+Calculations.swift +// LeCountdown +// +// Created by Laurent Morvillier on 20/02/2023. +// + +import Foundation +import CoreData + +extension NSManagedObjectContext { + + func compute(activity: Activity, stats: [Stat], filter: Filter?) throws -> [StatValue] { + + // Step 1: + // - Create the summing expression on the amount attribute. + // - Name the expression result as 'amountTotal'. + // - Assign the expression result data type as a Double. + + var expressions: [NSExpressionDescription] = [] + + for stat in stats { + let expression = NSExpressionDescription() + expression.expression = NSExpression(forFunction: stat.function, arguments:[NSExpression(forKeyPath: stat.field)]) + expression.name = stat.stringIdentifier; + expression.expressionResultType = NSAttributeType.doubleAttributeType + + expressions.append(expression) + } + + // Step 2: + // - Create the fetch request for the Movement entity. + // - Indicate that the fetched properties are those that were + // described in `expression`. + // - Indicate that the result type is a dictionary. + + let fetchRequest = NSFetchRequest(entityName: "Record") + + var predicates: [NSPredicate] = [] + predicates.append(NSPredicate(format: "activity = %@", activity)) + if let filter { + predicates.append(filter.predicate) + } + fetchRequest.predicate = NSCompoundPredicate.init(andPredicateWithSubpredicates: predicates) + + fetchRequest.propertiesToFetch = expressions + fetchRequest.resultType = NSFetchRequestResultType.dictionaryResultType + + // Step 3: + // - Execute the fetch request which returns an array. + // - There will only be one result. Get the first array + // element and assign to 'resultMap'. + // - The summed amount value is in the dictionary as + // 'amountTotal'. This will be summed value. + + var statValues: [StatValue] = [] + + let results = try self.fetch(fetchRequest) + if let resultMap = results.first as? [String : Any] { + + for stat in stats { + + if let result = resultMap[stat.stringIdentifier] { + + var value: NSDecimalNumber? = nil + switch result { + case let double as Double: + value = NSDecimalNumber(value: double) + case let integer as Int: + value = NSDecimalNumber(integerLiteral: integer) + default: + print("unmanaged value type = \(String(describing: result))") + break + } + + if let value { + let sv = StatValue(stat: stat, value: value) + statValues.append(sv) + } + } + } + } + return statValues + } +} diff --git a/LeCountdown/Stats/Filter.swift b/LeCountdown/Stats/Filter.swift new file mode 100644 index 0000000..31d4598 --- /dev/null +++ b/LeCountdown/Stats/Filter.swift @@ -0,0 +1,56 @@ +// +// Filter.swift +// LeCountdown +// +// Created by Laurent Morvillier on 20/02/2023. +// + +import Foundation + +fileprivate extension Int { + + var startOfYear: NSDate { + let components: DateComponents = DateComponents(year: self) + if let date = Calendar.current.date(from: components) { + return date as NSDate + } + return NSDate() + } + +} + +struct Month { + var month: Int + var year: Int + + var start: NSDate { + let components: DateComponents = DateComponents(year: self.year, month: self.month) + if let date = Calendar.current.date(from: components) { + return date as NSDate + } + return NSDate() + } + + var end: NSDate { + let start = self.start as Date + if let end = Calendar.current.date(byAdding: .month, value: 1, to: start) { + return end as NSDate + } + return NSDate() + } +} + +enum Filter { + case year(_ year: Int) + case month(_ month: Month) + + var predicate: NSPredicate { + switch self { + case .year(let year): + return NSPredicate(format: "start >= %@ AND end < %@", year.startOfYear, (year + 1).startOfYear) + case .month(let month): + return NSPredicate(format: "start >= %@ AND end < %@", month.start, month.end) + } + } + +} diff --git a/LeCountdown/Stats/Stat.swift b/LeCountdown/Stats/Stat.swift new file mode 100644 index 0000000..742e74e --- /dev/null +++ b/LeCountdown/Stats/Stat.swift @@ -0,0 +1,78 @@ +// +// Stat.swift +// LeCountdown +// +// Created by Laurent Morvillier on 20/02/2023. +// + +import Foundation +import CoreData + +enum Stat: Int, CaseIterable { + case count + case totalDuration + case averageDuration + + var localizedName: String { + switch self { + case .count: return NSLocalizedString("Count", comment: "") + case .totalDuration: return NSLocalizedString("Duration", comment: "") + case .averageDuration: return NSLocalizedString("Average duration", comment: "") + } + } + + var field: String { + switch self { + case .count: + return "duration" + case .totalDuration: + return "duration" + case .averageDuration: + return "duration" + } + } + + var function: String { + switch self { + case .count: + return "count:" + case .totalDuration: + return "sum:" + case .averageDuration: + return "average:" + } + } + + var stringIdentifier: String { + return self.function + self.field + } + + var type: NSAttributeType { + switch self { + case .count: + return .integer32AttributeType + default: + return .doubleAttributeType + } + } +} + +struct StatValue: Identifiable { + + var id: Int { return self.stat.rawValue } + + var stat: Stat + var value: NSDecimalNumber + + var formattedValue: String { + + switch self.stat { + case .averageDuration, .totalDuration: + return self.value.doubleValue.minuteSecond + default: + let formatter: NumberFormatter = NumberFormatter() + return formatter.string(from: self.value) ?? "--" + } + + } +} diff --git a/LeCountdown/Utils/TimeInterval+Extensions.swift b/LeCountdown/Utils/TimeInterval+Extensions.swift index 43823fe..7267ebd 100644 --- a/LeCountdown/Utils/TimeInterval+Extensions.swift +++ b/LeCountdown/Utils/TimeInterval+Extensions.swift @@ -12,7 +12,7 @@ extension TimeInterval { var hourMinuteSecondHS: String { let h = self.hour if h > 1 { - return String(format:"%d:%02d:%02d.%02d", hour, minute, second, hundredth) + return String(format:"%d:%02d:%02d", hour, minute, second) } else { return String(format:"%02d:%02d.%02d", minute, second, hundredth) } diff --git a/LeCountdown/Views/Components/GreenCheckmarkView.swift b/LeCountdown/Views/Components/GreenCheckmarkView.swift index e386613..6f41f92 100644 --- a/LeCountdown/Views/Components/GreenCheckmarkView.swift +++ b/LeCountdown/Views/Components/GreenCheckmarkView.swift @@ -11,8 +11,6 @@ struct GreenCheckmarkView: View { var body: some View { Image(systemName: "checkmark.circle.fill") .foregroundColor(.green) - .font(.title) - .frame(minWidth: 0.0, maxWidth: 60.0, minHeight: 0.0, maxHeight: .infinity) } } diff --git a/LeCountdown/Views/ContentView.swift b/LeCountdown/Views/ContentView.swift index dceb474..7d70ea5 100644 --- a/LeCountdown/Views/ContentView.swift +++ b/LeCountdown/Views/ContentView.swift @@ -67,6 +67,8 @@ struct ContentView: View { // var coreDataPublisher: NotificationCenter.Publisher { NotificationCenter.default // .publisher(for: .NSManagedObjectContextDidSave, object: viewContext) } + @State private var showLiveTimersSheet = false + @State private var isEditing: Bool = false fileprivate let itemSpacing: CGFloat = 10.0 @@ -97,31 +99,6 @@ struct ContentView: View { self._reorder(from: from, to: to) } -// if !self.isEditing { -// -// -// } -// else { -// -// ReorderableForEach(items: self.model.spots) { spot in -// -// if let timer = spot.timer { -// DialView(timer: timer, isEditingBinding: self.$isEditing, frameSize: width) -// .environment(\.managedObjectContext, viewContext) -// .environmentObject(Conductor.maestro) -// .environmentObject(boringContext) -// -// } else { -// -// Color(white: 0.9) -// .frame(width: width, height: 80.0) -// .cornerRadius(20.0) -// } -// -// } moveAction: { from, to in -// self._reorderSpots(from: from, to: to) -// } -// } } }.padding(.horizontal, itemSpacing) @@ -129,9 +106,14 @@ struct ContentView: View { LiveTimerListView() .environment(\.managedObjectContext, viewContext) .environmentObject(conductor) - .background(Color(white: 0.9)) - .padding(.bottom, 40.0) - .cornerRadius(16.0, corners: [.topRight, .topLeft]) + .foregroundColor(.white) + .background(Color(white: 0.1)) + .cornerRadius(32.0, corners: [.topRight, .topLeft]) + + + +// .padding(.bottom, 40.0) +// .cornerRadius(16.0, corners: [.topRight, .topLeft]) } } } @@ -180,6 +162,8 @@ struct ContentView: View { .onAppear { // self._buildItemsList() self._askPermissions() + + self.showLiveTimersSheet = !conductor.liveTimers.isEmpty } .onOpenURL { url in self._performActionIfPossible(url: url) diff --git a/LeCountdown/Views/LiveTimerListView.swift b/LeCountdown/Views/LiveTimerListView.swift index 4cfcf10..a4d587b 100644 --- a/LeCountdown/Views/LiveTimerListView.swift +++ b/LeCountdown/Views/LiveTimerListView.swift @@ -21,6 +21,30 @@ class LiveStopwatchModel: ObservableObject { } } +struct SeparatorView: View { + + var body: some View { + Spacer() + .frame(minWidth: 0.0, maxWidth: .infinity, minHeight: 1.0, maxHeight: 1.0) + .background(.gray) + } + +} + +fileprivate let liveViewSize: CGFloat = 70.0 +fileprivate let timerFontSize: CGFloat = 32.0 + +struct TimeView: View { + + var text: String + + var body: some View { + Text(self.text) + .font(.system(size: timerFontSize, weight: .medium)) + } + +} + struct LiveStopwatchView: View { @Environment(\.managedObjectContext) private var viewContext @@ -35,53 +59,56 @@ struct LiveStopwatchView: View { let running = (self.model.endDate == nil) - HStack { - - Text(stopwatch.displayName.uppercased()).padding() - - Spacer() + VStack(alignment: .trailing) { if running { TimelineView(.periodic(from: self.date, by: 0.01)) { context in - Text(self._formattedDuration(date: context.date)) - .font(.title2) - .padding(.trailing) - .minimumScaleFactor(0.1) + + TimeView(text: self._formattedDuration(date: context.date)) } } else { let duration = self.model.endDate?.timeIntervalSince(self.date) ?? 0.0 - Text(duration.hourMinuteSecondHS) - .font(.title2) - .padding(.trailing) - .minimumScaleFactor(0.1) + TimeView(text: duration.hourMinuteSecondHS) } - if running { - - Button { - self.model.stop(stopwatch) - } label: { - - Image(systemName: "stop.circle.fill") - .font(.title) - .foregroundColor(.white) - .cornerRadius(8.0) - .frame(minWidth: 0.0, maxWidth: 60.0, minHeight: 0.0, maxHeight: .infinity) - }.background(.red) - - } else { - GreenCheckmarkView() +// if running { +// +//// Button { +//// self.model.stop(stopwatch) +//// } label: { +//// +//// Image(systemName: "stop.circle.fill") +//// .font(.title) +//// .foregroundColor(.white) +//// .cornerRadius(8.0) +//// .frame(minWidth: 0.0, maxWidth: 60.0, minHeight: 0.0, maxHeight: .infinity) +//// }.background(.red) +// +// } else { +// GreenCheckmarkView() +// } +// SeparatorView() + HStack { + if !running { + GreenCheckmarkView() + } + Text(stopwatch.displayName.uppercased()) } + }.onTapGesture { withAnimation { - self._dismiss() + if self.model.endDate == nil { + self.model.stop(stopwatch) + } else { + self._dismiss() + } } } - .frame(height: 55.0) - .foregroundColor(.white) + .frame(height: liveViewSize) +// .foregroundColor(.white) .monospaced() - .background(Color(white: 0.2)) - .cornerRadius(16.0) +// .background(Color(white: 0.2)) +// .cornerRadius(16.0) } fileprivate func _dismiss() { @@ -103,45 +130,53 @@ struct LiveCountdownView: View { var date: Date var body: some View { - HStack { - Text(self.countdown.displayName.uppercased()).padding() - Spacer() - TimelineView(.periodic(from: self.date, by: 0.01)) { context in + // Spacer() + TimelineView(.periodic(from: self.date, by: 0.01)) { context in + VStack(alignment: .trailing) { + + let running = self.date > context.date - if self.date > context.date { + if running { HStack { - Text(self._formattedDuration(date: context.date)) - .font(.title2) - .minimumScaleFactor(0.1) - .padding(.trailing) - Button { - self._cancelCountdown() - } label: { - Image(systemName: "xmark.circle.fill") - .font(.title) - .foregroundColor(.white) - .cornerRadius(8.0) - .frame(minWidth: 0.0, maxWidth: 60.0, minHeight: 0.0, maxHeight: .infinity) - }.background(.red) + TimeView(text: self._formattedDuration(date: context.date)) + // .padding(.trailing) + // Button { + // self._cancelCountdown() + // } label: { + // Image(systemName: "xmark.circle.fill") + // .font(.title) + // .foregroundColor(.white) + // .cornerRadius(8.0) + // .frame(minWidth: 0.0, maxWidth: 60.0, minHeight: 0.0, maxHeight: .infinity) + // }.background(.red) } } else { - GreenCheckmarkView() + TimeView(text: self.date.formatted(date: .omitted, time: .shortened)) } +// SeparatorView() + HStack { + if !running { + GreenCheckmarkView() + } + Text(self.countdown.displayName.uppercased()).padding(.top, 2.0) + } + } } .contentShape(Rectangle()) // make the onTap react everywhere .onTapGesture { withAnimation { - self._dismiss() + self._cancelCountdown() +// self._dismiss() } } - .frame(height: 55.0) - .foregroundColor(.white) + .frame(height: liveViewSize) +// .foregroundColor(.white) .monospaced() - .background(Color(white: 0.2)) - .cornerRadius(16.0) +// .background(Color(white: 0.2)) +// .cornerRadius(16.0) } fileprivate func _dismiss() { @@ -165,28 +200,55 @@ struct LiveTimerListView: View { @Environment(\.managedObjectContext) private var viewContext @EnvironmentObject var conductor: Conductor + #if os(iOS) + @Environment(\.horizontalSizeClass) private var horizontalSizeClass + #endif + var body: some View { - LazyVStack { - ForEach(conductor.liveTimers) { liveTimer in + + ScrollView { + + LazyVGrid( + columns: self._columns(), + spacing: 0.0 + ) { - if let timer: AbstractTimer = liveTimer.timer(context: self.viewContext) { + ForEach(conductor.liveTimers) { liveTimer in - switch timer { - case let cd as Countdown: - LiveCountdownView(countdown: cd, date: liveTimer.date) - case let sw as Stopwatch: - LiveStopwatchView(stopwatch: sw, date: liveTimer.date) - default: - Text("unmanaged timer: \(timer)") + if let timer: AbstractTimer = liveTimer.timer(context: self.viewContext) { + + switch timer { + case let cd as Countdown: + LiveCountdownView(countdown: cd, date: liveTimer.date) + case let sw as Stopwatch: + LiveStopwatchView(stopwatch: sw, date: liveTimer.date) + default: + Text("unmanaged timer: \(timer)") + } + } } - } - }.padding(8.0) + }.padding() } + fileprivate func _columnCount() -> Int { + #if os(iOS) + if horizontalSizeClass == .compact { + return 2 + } else { + return 3 + } + #else + return 3 + #endif + } + + fileprivate func _columns() -> [GridItem] { + return (0.. + + init(activity: Activity) { + self.activity = activity + let predicate = NSPredicate(format: "activity = %@", activity) + self.request = FetchRequest(sortDescriptors: [NSSortDescriptor(key: "start", ascending: false)], predicate: predicate) + } var body: some View { @@ -27,12 +36,26 @@ struct RecordsView: View { Text(duration.minuteSecond) } } - } + }.onDelete(perform: _deleteItem) } } } + fileprivate func _deleteItem(_ indexSet: IndexSet) { + + for index in indexSet { + let item = self.request.wrappedValue[index] + viewContext.delete(item) + + do { + try viewContext.save() + } catch { + Logger.error(error) + } + } + } + } struct RecordsView_Previews: PreviewProvider { diff --git a/LeCountdown/Views/Stats/StatsView.swift b/LeCountdown/Views/Stats/StatsView.swift index ce3786e..57b4c1f 100644 --- a/LeCountdown/Views/Stats/StatsView.swift +++ b/LeCountdown/Views/Stats/StatsView.swift @@ -8,75 +8,6 @@ import SwiftUI import CoreData -enum Stat: Int, CaseIterable { - case count - case totalDuration - case averageDuration - - var localizedName: String { - switch self { - case .count: return NSLocalizedString("Count", comment: "") - case .totalDuration: return NSLocalizedString("Duration", comment: "") - case .averageDuration: return NSLocalizedString("Average duration", comment: "") - } - } - - var field: String { - switch self { - case .count: - return "duration" - case .totalDuration: - return "duration" - case .averageDuration: - return "duration" - } - } - - var function: String { - switch self { - case .count: - return "count:" - case .totalDuration: - return "sum:" - case .averageDuration: - return "average:" - } - } - - var stringIdentifier: String { - return self.function + self.field - } - - var type: NSAttributeType { - switch self { - case .count: - return .integer32AttributeType - default: - return .doubleAttributeType - } - } -} - -struct StatValue: Identifiable { - - var id: Int { return self.stat.rawValue } - - var stat: Stat - var value: NSDecimalNumber - - var formattedValue: String { - - switch self.stat { - case .averageDuration, .totalDuration: - return self.value.doubleValue.minuteSecond - default: - let formatter: NumberFormatter = NumberFormatter() - return formatter.string(from: self.value) ?? "--" - } - - } -} - class StatModel: ObservableObject { @Published var statValues: [StatValue] = [] @@ -85,7 +16,7 @@ class StatModel: ObservableObject { @Published var error: Error? = nil - func compute(activity: Activity) { + func compute(activity: Activity, filter: Filter?) { self.isComputing = true @@ -94,7 +25,7 @@ class StatModel: ObservableObject { var values: [StatValue] = [] do { - values = try context.compute(activity: activity, stats: Stat.allCases) + values = try context.compute(activity: activity, stats: Stat.allCases, filter: filter) } catch { Logger.error(error) self.error = error @@ -114,6 +45,7 @@ class StatModel: ObservableObject { struct StatsView: View { var activity: Activity + var filter: Filter? = nil @StateObject var model: StatModel = StatModel() @@ -134,7 +66,7 @@ struct StatsView: View { Spacer() } .onAppear() { - self.model.compute(activity: self.activity) + self.model.compute(activity: self.activity, filter: self.filter) } .navigationTitle(self.activity.name ?? "") }