From f62d3f23e7dd7b1332c5c9c97d6cbf90b3696f9f Mon Sep 17 00:00:00 2001 From: Laurent Date: Tue, 21 Feb 2023 11:49:46 +0100 Subject: [PATCH] Add bricks for filter and calculations --- LeCountdown.xcodeproj/project.pbxproj | 22 ++++--- LeCountdown/LeCountdownApp.swift | 18 ++++++ .../Record+CoreDataProperties.swift | 6 +- .../.xccurrentversion | 2 +- .../LeCountdown.0.6.2.xcdatamodel/contents | 51 ++++++++++++++++ LeCountdown/Model/Model+Extensions.swift | 32 ++++------ .../Model/NSManagedContext+Extensions.swift | 21 +++++++ LeCountdown/Stats/Context+Calculations.swift | 3 +- LeCountdown/Stats/Filter.swift | 51 ++++++++++++---- LeCountdown/Stats/Stat.swift | 13 ++++ LeCountdown/Utils/Date+Extensions.swift | 23 +++++++ .../GreenCheckmarkView.swift | 0 .../ImageSelectionView.swift | 0 .../PermissionAlertView.swift | 0 .../{ => Reusable}/ReorderableForEach.swift | 0 .../SoundImageFormView.swift | 0 .../SoundSelectionView.swift | 0 .../{Components => Reusable}/TimerModel.swift | 0 .../View+Extension.swift | 0 .../Reusable}/ViewModifiers.swift | 0 LeCountdown/Views/Stats/ActivityView.swift | 50 +++++++++++++--- LeCountdown/Views/Stats/RecordsView.swift | 60 ++++++++++++++++--- LeCountdown/Views/Stats/StatsView.swift | 15 +++-- 23 files changed, 302 insertions(+), 65 deletions(-) create mode 100644 LeCountdown/Model/LeCountdown.xcdatamodeld/LeCountdown.0.6.2.xcdatamodel/contents create mode 100644 LeCountdown/Utils/Date+Extensions.swift rename LeCountdown/Views/{Components => Reusable}/GreenCheckmarkView.swift (100%) rename LeCountdown/Views/{Components => Reusable}/ImageSelectionView.swift (100%) rename LeCountdown/Views/{Components => Reusable}/PermissionAlertView.swift (100%) rename LeCountdown/Views/{ => Reusable}/ReorderableForEach.swift (100%) rename LeCountdown/Views/{Components => Reusable}/SoundImageFormView.swift (100%) rename LeCountdown/Views/{Components => Reusable}/SoundSelectionView.swift (100%) rename LeCountdown/Views/{Components => Reusable}/TimerModel.swift (100%) rename LeCountdown/Views/{Components => Reusable}/View+Extension.swift (100%) rename LeCountdown/{Utils => Views/Reusable}/ViewModifiers.swift (100%) diff --git a/LeCountdown.xcodeproj/project.pbxproj b/LeCountdown.xcodeproj/project.pbxproj index d4caed2..9829b0e 100644 --- a/LeCountdown.xcodeproj/project.pbxproj +++ b/LeCountdown.xcodeproj/project.pbxproj @@ -126,6 +126,7 @@ 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 */; }; + C4BA2B6A29A4BE1800CB4FBA /* Date+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4BA2B6929A4BE1800CB4FBA /* Date+Extensions.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 */; }; @@ -320,6 +321,8 @@ 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 = ""; }; + C4BA2B6929A4BE1800CB4FBA /* Date+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Date+Extensions.swift"; sourceTree = ""; }; + C4BA2B6B29A4C47100CB4FBA /* LeCountdown.0.6.2.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = LeCountdown.0.6.2.xcdatamodel; 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 = ""; }; @@ -426,8 +429,8 @@ C438C80B2981DE2E00BF3EF9 /* Views */, C438C8092981DDF800BF3EF9 /* Model */, C445FA8D2987B82E0054D761 /* Sound */, - C438C80A2981DE1A00BF3EF9 /* Utils */, C4BA2B6629A3C49200CB4FBA /* Stats */, + C438C80A2981DE1A00BF3EF9 /* Utils */, C438C8082981DDD200BF3EF9 /* Widget */, C445FA962987D0CF0054D761 /* Sound_Assets */, C4BA2B55299FFA3700CB4FBA /* Subscription */, @@ -529,12 +532,12 @@ children = ( C4BA2AFC299A3A3700CB4FBA /* AppleMusicPlayer.swift */, C4742B5629840F6400D5D950 /* CoolPic.swift */, + C4BA2B6929A4BE1800CB4FBA /* Date+Extensions.swift */, C4BA2B5A299FFAB000CB4FBA /* Logger.swift */, C4BA2B2C299E2DEE00CB4FBA /* Preferences.swift */, C438C81029829EAF00BF3EF9 /* PropertyWrappers.swift */, C40FDB612992985C0042A390 /* TextToSpeechRecorder.swift */, C4060DF4297AE9A7003FAB80 /* TimeInterval+Extensions.swift */, - C4742B5E2984205000D5D950 /* ViewModifiers.swift */, ); path = Utils; sourceTree = ""; @@ -542,10 +545,10 @@ C438C80B2981DE2E00BF3EF9 /* Views */ = { isa = PBXGroup; children = ( + C4F8B1D3298BF686005C86A5 /* Reusable */, C4F8B1BA298AC83F005C86A5 /* Alarm */, C4F8B1B9298AC830005C86A5 /* Countdown */, C4F8B1BB298AC848005C86A5 /* Stopwatch */, - C4F8B1D3298BF686005C86A5 /* Components */, C4BA2B41299FCB0100CB4FBA /* Stats */, C4060DC1297AE73B003FAB80 /* ContentView.swift */, C4F8B1CF298BF2E2005C86A5 /* DialView.swift */, @@ -554,7 +557,6 @@ C4BA2B03299A42EF00CB4FBA /* NewDataView.swift */, C4BA2B05299A8F8D00CB4FBA /* PresetsView.swift */, C498E5A2298D720600E90DE0 /* TestView.swift */, - C4F8B15E298961A7005C86A5 /* ReorderableForEach.swift */, ); path = Views; sourceTree = ""; @@ -679,18 +681,20 @@ path = Stopwatch; sourceTree = ""; }; - C4F8B1D3298BF686005C86A5 /* Components */ = { + C4F8B1D3298BF686005C86A5 /* Reusable */ = { isa = PBXGroup; children = ( - C4742B5A298414B000D5D950 /* ImageSelectionView.swift */, C498E5A4299152B400E90DE0 /* GreenCheckmarkView.swift */, + C4742B5A298414B000D5D950 /* ImageSelectionView.swift */, C4F8B1D1298BF646005C86A5 /* PermissionAlertView.swift */, + C4F8B15E298961A7005C86A5 /* ReorderableForEach.swift */, C4F8B165298A9ABB005C86A5 /* SoundImageFormView.swift */, C4BA2AD52993F62700CB4FBA /* SoundSelectionView.swift */, C4BA2ADA299549BC00CB4FBA /* TimerModel.swift */, C4BA2B2E299E69A000CB4FBA /* View+Extension.swift */, + C4742B5E2984205000D5D950 /* ViewModifiers.swift */, ); - path = Components; + path = Reusable; sourceTree = ""; }; /* End PBXGroup section */ @@ -966,6 +970,7 @@ C438C7C5298024E900BF3EF9 /* NSManagedContext+Extensions.swift in Sources */, C4F8B15F298961A7005C86A5 /* ReorderableForEach.swift in Sources */, C4BA2B6129A3C02400CB4FBA /* ActivityView.swift in Sources */, + C4BA2B6A29A4BE1800CB4FBA /* Date+Extensions.swift in Sources */, C4BA2B10299BE61E00CB4FBA /* Interval+CoreDataProperties.swift in Sources */, C4F8B1AC298AC3A0005C86A5 /* Alarm+CoreDataProperties.swift in Sources */, C498E59F298D4DEA00E90DE0 /* LiveTimerListView.swift in Sources */, @@ -1526,6 +1531,7 @@ C4060DCA297AE73D003FAB80 /* LeCountdown.xcdatamodeld */ = { isa = XCVersionGroup; children = ( + C4BA2B6B29A4C47100CB4FBA /* LeCountdown.0.6.2.xcdatamodel */, C4BA2B46299FCD8B00CB4FBA /* LeCountdown.0.6.1.xcdatamodel */, C4BA2B07299BDAE000CB4FBA /* LeCountdown.0.6.xcdatamodel */, C4BA2AEB2996A09600CB4FBA /* LeCountdown.0.5.1.xcdatamodel */, @@ -1536,7 +1542,7 @@ C418A14F298428CB00C22230 /* LeCountdown.0.1.xcdatamodel */, C4060DCB297AE73D003FAB80 /* LeCountdown.xcdatamodel */, ); - currentVersion = C4BA2B46299FCD8B00CB4FBA /* LeCountdown.0.6.1.xcdatamodel */; + currentVersion = C4BA2B6B29A4C47100CB4FBA /* LeCountdown.0.6.2.xcdatamodel */; path = LeCountdown.xcdatamodeld; sourceTree = ""; versionGroupType = wrapper.xcdatamodel; diff --git a/LeCountdown/LeCountdownApp.swift b/LeCountdown/LeCountdownApp.swift index 404c0ef..ee3bb52 100644 --- a/LeCountdown/LeCountdownApp.swift +++ b/LeCountdown/LeCountdownApp.swift @@ -79,6 +79,8 @@ struct LeCountdownApp: App { Sound.computeSoundDurationsIfNecessary() + self._patch() + // let voices = AVSpeechSynthesisVoice.speechVoices() // let grouped = Dictionary(grouping: voices, by: { $0.language }) // for language in grouped.keys { @@ -114,4 +116,20 @@ struct LeCountdownApp: App { } + + fileprivate func _patch() { + + let context = PersistenceController.shared.container.viewContext + do { + let records = try context.fetch(Record.fetchRequest()) + for record in records { + record.preCompute() + } + try context.save() + } catch { + Logger.error(error) + } + + } + } diff --git a/LeCountdown/Model/Generation/Record+CoreDataProperties.swift b/LeCountdown/Model/Generation/Record+CoreDataProperties.swift index d3238e0..61fb1d6 100644 --- a/LeCountdown/Model/Generation/Record+CoreDataProperties.swift +++ b/LeCountdown/Model/Generation/Record+CoreDataProperties.swift @@ -2,7 +2,7 @@ // Record+CoreDataProperties.swift // LeCountdown // -// Created by Laurent Morvillier on 17/02/2023. +// Created by Laurent Morvillier on 21/02/2023. // // @@ -16,9 +16,11 @@ extension Record { return NSFetchRequest(entityName: "Record") } + @NSManaged public var duration: Double @NSManaged public var end: Date? @NSManaged public var start: Date? - @NSManaged public var duration: Double + @NSManaged public var year: Int16 + @NSManaged public var month: Int16 @NSManaged public var activity: Activity? } diff --git a/LeCountdown/Model/LeCountdown.xcdatamodeld/.xccurrentversion b/LeCountdown/Model/LeCountdown.xcdatamodeld/.xccurrentversion index 8caf740..51d83d9 100644 --- a/LeCountdown/Model/LeCountdown.xcdatamodeld/.xccurrentversion +++ b/LeCountdown/Model/LeCountdown.xcdatamodeld/.xccurrentversion @@ -3,6 +3,6 @@ _XCCurrentVersionName - LeCountdown.0.6.1.xcdatamodel + LeCountdown.0.6.2.xcdatamodel diff --git a/LeCountdown/Model/LeCountdown.xcdatamodeld/LeCountdown.0.6.2.xcdatamodel/contents b/LeCountdown/Model/LeCountdown.xcdatamodeld/LeCountdown.0.6.2.xcdatamodel/contents new file mode 100644 index 0000000..5ac205b --- /dev/null +++ b/LeCountdown/Model/LeCountdown.xcdatamodeld/LeCountdown.0.6.2.xcdatamodel/contents @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/LeCountdown/Model/Model+Extensions.swift b/LeCountdown/Model/Model+Extensions.swift index d345508..e8e3c81 100644 --- a/LeCountdown/Model/Model+Extensions.swift +++ b/LeCountdown/Model/Model+Extensions.swift @@ -21,22 +21,11 @@ extension AbstractSoundTimer { } return [] } - -// var playlists: [Playlist] { -// if let playlistList { -// return playlistList.enumItems() -// } -// return [] -// } - + func setSounds(_ sounds: Set) { self.soundList = sounds.stringRepresentation } -// func setPlaylists(_ playlists: [Playlist]) { -// self.playlistList = playlists.stringRepresentation -// } - var coolSound: Sound { var allSounds: [Sound] = [] allSounds.append(contentsOf: self.sounds) @@ -58,7 +47,7 @@ extension Stopwatch { static func fake(context: NSManagedObjectContext) -> Stopwatch { let stopwatch = Stopwatch(context: context) let activity = Activity(context: context) - activity.name = "Run" + activity.name = "Running" stopwatch.activity = activity return stopwatch } @@ -74,15 +63,19 @@ extension Record { switch key { case "start", "end": - self.computeDuration() + self.preCompute() default: break } } - func computeDuration() { - if let start, let end { - self.duration = end.timeIntervalSince(start) + func preCompute() { + if let start { + self.year = Int16(start.year) + self.month = Int16(start.month) + if let end { + self.duration = end.timeIntervalSince(start) + } } } @@ -103,11 +96,12 @@ extension Record { func point(stat: Stat) -> Point? { if let start { + let day = start.startOfDay switch stat { case .count: - return Point(date: start, value: NSNumber(value: 1)) + return Point(date: day, value: NSNumber(value: 1)) case .totalDuration, .averageDuration: - return Point(date: start, value: NSNumber(value: self.duration)) + return Point(date: day, value: NSNumber(value: self.duration / 3600.0)) } } return nil diff --git a/LeCountdown/Model/NSManagedContext+Extensions.swift b/LeCountdown/Model/NSManagedContext+Extensions.swift index d09c8c5..eb01af0 100644 --- a/LeCountdown/Model/NSManagedContext+Extensions.swift +++ b/LeCountdown/Model/NSManagedContext+Extensions.swift @@ -15,7 +15,28 @@ extension NSManagedObjectContext { guard let objectId = PersistenceController.shared.container.persistentStoreCoordinator.managedObjectID(forURIRepresentation: url) else { return nil } return self.object(with: objectId) } + + func distinct(entityName: String, attributes: [String]) throws -> [Any] { + + let request = NSFetchRequest(entityName: entityName) + + request.returnsDistinctResults = true + request.resultType = .dictionaryResultType + if let entity = request.entity { + let entityProperties = entity.propertiesByName + var properties = [NSPropertyDescription]() + for attribute in attributes { + if let entityDescription = entityProperties[attribute] { + properties.append(entityDescription) + } + } + request.propertiesToFetch = properties + } + return try self.fetch(request) + + } + } extension NSManagedObject { diff --git a/LeCountdown/Stats/Context+Calculations.swift b/LeCountdown/Stats/Context+Calculations.swift index eefe617..4090fa8 100644 --- a/LeCountdown/Stats/Context+Calculations.swift +++ b/LeCountdown/Stats/Context+Calculations.swift @@ -80,9 +80,10 @@ extension NSManagedObjectContext { let request = Record.fetchRequest() if let filter { request.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [basePredicate, filter.predicate]) + request.sortDescriptors = [NSSortDescriptor(key: "start", ascending: true)] } let records: [Record] = try self.fetch(request) - let points = records.compactMap { $0.point(stat:stat) } + let points: [Point] = records.compactMap { $0.point(stat:stat) } let sv = StatValue(stat: stat, value: value, records: points) statValues.append(sv) diff --git a/LeCountdown/Stats/Filter.swift b/LeCountdown/Stats/Filter.swift index 31d4598..c5a5744 100644 --- a/LeCountdown/Stats/Filter.swift +++ b/LeCountdown/Stats/Filter.swift @@ -7,6 +7,38 @@ import Foundation +enum Filter: Identifiable, Hashable { + + 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) + } + } + + var localizedString: String { + switch self { + case .year(let year): return year.formatted() + case .month(let month): return month.localizedString + } + } + + var id: String { localizedString } + + static func == (lhs: Filter, rhs: Filter) -> Bool { + return lhs.id == rhs.id + } + + func hash(into hasher: inout Hasher) { + hasher.combine(self.id) + } +} + fileprivate extension Int { var startOfYear: NSDate { @@ -20,6 +52,7 @@ fileprivate extension Int { } struct Month { + var month: Int var year: Int @@ -38,19 +71,15 @@ struct Month { } 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) + fileprivate static let _dateFormatter = DateFormatter() + + var localizedString: String { + let components = DateComponents(year: self.year, month: self.month) + if let date = Calendar.current.date(from: components) { + return Month._dateFormatter.string(from: date) } + return "invalid date" } } diff --git a/LeCountdown/Stats/Stat.swift b/LeCountdown/Stats/Stat.swift index d8ece52..2e5ae93 100644 --- a/LeCountdown/Stats/Stat.swift +++ b/LeCountdown/Stats/Stat.swift @@ -56,6 +56,15 @@ enum Stat: Int, CaseIterable { } } + var calendarYUnit: Calendar.Component? { + switch self { + case .totalDuration, .averageDuration: + return .hour + default: + return nil + } + } + // var keyPath: KeyPath { // switch self { // case .count: return \Record.count @@ -71,6 +80,10 @@ struct Point: Identifiable { var id: Date { date } + var dateValue: Date { + return Date(timeIntervalSince1970: value.doubleValue) + } + } struct StatValue: Identifiable { diff --git a/LeCountdown/Utils/Date+Extensions.swift b/LeCountdown/Utils/Date+Extensions.swift new file mode 100644 index 0000000..508bcf9 --- /dev/null +++ b/LeCountdown/Utils/Date+Extensions.swift @@ -0,0 +1,23 @@ +// +// Date+Extensions.swift +// LeCountdown +// +// Created by Laurent Morvillier on 21/02/2023. +// + +import Foundation + +extension Date { + + var startOfDay: Date { + return Calendar.current.startOfDay(for: self) + } + + func get(_ component: Calendar.Component, calendar: Calendar = Calendar.current) -> Int { + return calendar.component(component, from: self) + } + + var year: Int { self.get(.year) } + var month: Int { self.get(.month) } + +} diff --git a/LeCountdown/Views/Components/GreenCheckmarkView.swift b/LeCountdown/Views/Reusable/GreenCheckmarkView.swift similarity index 100% rename from LeCountdown/Views/Components/GreenCheckmarkView.swift rename to LeCountdown/Views/Reusable/GreenCheckmarkView.swift diff --git a/LeCountdown/Views/Components/ImageSelectionView.swift b/LeCountdown/Views/Reusable/ImageSelectionView.swift similarity index 100% rename from LeCountdown/Views/Components/ImageSelectionView.swift rename to LeCountdown/Views/Reusable/ImageSelectionView.swift diff --git a/LeCountdown/Views/Components/PermissionAlertView.swift b/LeCountdown/Views/Reusable/PermissionAlertView.swift similarity index 100% rename from LeCountdown/Views/Components/PermissionAlertView.swift rename to LeCountdown/Views/Reusable/PermissionAlertView.swift diff --git a/LeCountdown/Views/ReorderableForEach.swift b/LeCountdown/Views/Reusable/ReorderableForEach.swift similarity index 100% rename from LeCountdown/Views/ReorderableForEach.swift rename to LeCountdown/Views/Reusable/ReorderableForEach.swift diff --git a/LeCountdown/Views/Components/SoundImageFormView.swift b/LeCountdown/Views/Reusable/SoundImageFormView.swift similarity index 100% rename from LeCountdown/Views/Components/SoundImageFormView.swift rename to LeCountdown/Views/Reusable/SoundImageFormView.swift diff --git a/LeCountdown/Views/Components/SoundSelectionView.swift b/LeCountdown/Views/Reusable/SoundSelectionView.swift similarity index 100% rename from LeCountdown/Views/Components/SoundSelectionView.swift rename to LeCountdown/Views/Reusable/SoundSelectionView.swift diff --git a/LeCountdown/Views/Components/TimerModel.swift b/LeCountdown/Views/Reusable/TimerModel.swift similarity index 100% rename from LeCountdown/Views/Components/TimerModel.swift rename to LeCountdown/Views/Reusable/TimerModel.swift diff --git a/LeCountdown/Views/Components/View+Extension.swift b/LeCountdown/Views/Reusable/View+Extension.swift similarity index 100% rename from LeCountdown/Views/Components/View+Extension.swift rename to LeCountdown/Views/Reusable/View+Extension.swift diff --git a/LeCountdown/Utils/ViewModifiers.swift b/LeCountdown/Views/Reusable/ViewModifiers.swift similarity index 100% rename from LeCountdown/Utils/ViewModifiers.swift rename to LeCountdown/Views/Reusable/ViewModifiers.swift diff --git a/LeCountdown/Views/Stats/ActivityView.swift b/LeCountdown/Views/Stats/ActivityView.swift index 202fa8e..c63adf1 100644 --- a/LeCountdown/Views/Stats/ActivityView.swift +++ b/LeCountdown/Views/Stats/ActivityView.swift @@ -6,8 +6,9 @@ // import SwiftUI +import CoreData -fileprivate enum TimeFrame: Int, Identifiable, CaseIterable { +enum TimeFrame: Int, Identifiable, CaseIterable { var id: Int { return self.rawValue } @@ -22,6 +23,29 @@ fileprivate enum TimeFrame: Int, Identifiable, CaseIterable { case .month: return NSLocalizedString("Month", comment: "") } } + + func filters(context: NSManagedObjectContext) -> [Filter] { + + do { + switch self { + case .all: + return [] + case .year: + if let years = try context.distinct(entityName: "Record", attributes: ["year"]) as? [Int] { + return years.map { Filter.year($0) } + } + case .month: + if let months = try context.distinct(entityName: "Record", attributes: ["year", "month"]) as? [Int] { + return months.map { Filter.month(Month(month: $0, year: 1)) } + } + } + } catch { + Logger.error(error) + } + return [] + + } + } struct ActivityView: View { @@ -37,17 +61,27 @@ struct ActivityView: View { VStack { Picker("", selection: $selectedTimeFrame) { ForEach(TimeFrame.allCases) { timeFrame in - Text(timeFrame.localizedString) + Text(timeFrame.localizedString).tag(timeFrame) + } + } + .pickerStyle(.segmented) + + List { + + Section { + StatsView(activity: self.activity, timeFrame: self.selectedTimeFrame) + .environment(\.managedObjectContext, viewContext) + } + + Section { + RecordsView(activity: self.activity, timeFrame: self.selectedTimeFrame) + .environment(\.managedObjectContext, viewContext) } - }.pickerStyle(.segmented) + } - StatsView(activity: self.activity) - .environment(\.managedObjectContext, viewContext) - RecordsView(activity: self.activity) - .environment(\.managedObjectContext, viewContext) -// .frame(maxWidth: .infinity) } + } } diff --git a/LeCountdown/Views/Stats/RecordsView.swift b/LeCountdown/Views/Stats/RecordsView.swift index afe68fc..3217d0e 100644 --- a/LeCountdown/Views/Stats/RecordsView.swift +++ b/LeCountdown/Views/Stats/RecordsView.swift @@ -6,29 +6,66 @@ // import SwiftUI +import CoreData + +class RecordsModel: ObservableObject { + + @Published var filters: [Filter] = [] + @Published var recordsByFilter: [Filter : [Record]] = [:] + + func loadFilters(activity: Activity, timeFrame: TimeFrame, context: NSManagedObjectContext) { + + if self.recordsByFilter.isEmpty { + self.filters = timeFrame.filters(context: context) + for filter in filters { + let records = self._retrieveRecords(activity: activity, filter: filter, context: context) + self.recordsByFilter[filter] = records + } + } + } + + fileprivate func _retrieveRecords(activity: Activity, filter: Filter, context: NSManagedObjectContext) -> [Record] { + do { + let request = Record.fetchRequest() + let activityPredicate = NSPredicate(format: "activity = %@", activity) + request.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [activityPredicate, filter.predicate]) + request.sortDescriptors = [NSSortDescriptor(key: "start", ascending: true)] + return try context.fetch(request) + } catch { + Logger.log(error) + return [] + } + } + + func records(by filter: Filter) -> [Record] { + return self.recordsByFilter[filter] ?? [] + } + +} struct RecordsView: View { @Environment(\.managedObjectContext) private var viewContext var activity: Activity + var timeFrame: TimeFrame var request: FetchRequest - init(activity: Activity) { + @StateObject private var model: RecordsModel = RecordsModel() + + init(activity: Activity, timeFrame: TimeFrame) { self.activity = activity + self.timeFrame = timeFrame let predicate = NSPredicate(format: "activity = %@", activity) self.request = FetchRequest(sortDescriptors: [NSSortDescriptor(key: "start", ascending: false)], predicate: predicate) } var body: some View { - List { + ForEach(self.model.filters) { filter in - if let records = self.activity.records as? Set { - - let array: [Record] = Array(records) - - ForEach(array) { record in + Section(filter.localizedString) { + ForEach(self.model.records(by: filter)) { record in HStack { Text(record.formattedDay) Spacer() @@ -36,8 +73,13 @@ struct RecordsView: View { Text(duration.minuteSecond) } } - }.onDelete(perform: _deleteItem) + } } + + } + .onDelete(perform: _deleteItem) + .onAppear { + self.model.loadFilters(activity: self.activity, timeFrame: self.timeFrame, context: self.viewContext) } } @@ -61,6 +103,6 @@ struct RecordsView: View { struct RecordsView_Previews: PreviewProvider { static var previews: some View { RecordsView(activity: - Activity.fake(context: PersistenceController.preview.container.viewContext)) + Activity.fake(context: PersistenceController.preview.container.viewContext), timeFrame: .all) } } diff --git a/LeCountdown/Views/Stats/StatsView.swift b/LeCountdown/Views/Stats/StatsView.swift index 594438c..774cb2f 100644 --- a/LeCountdown/Views/Stats/StatsView.swift +++ b/LeCountdown/Views/Stats/StatsView.swift @@ -46,6 +46,7 @@ class StatModel: ObservableObject { struct StatsView: View { var activity: Activity + var timeFrame: TimeFrame var filter: Filter? = nil @StateObject var model: StatModel = StatModel() @@ -97,22 +98,23 @@ struct StatGraphView: View { VStack { HStack { Text(self.statValue.stat.localizedName.uppercased()) -// .font(.footnote) + // .font(.footnote) Text(self.statValue.formattedValue) -// .font(.system(.title, weight: .bold)) + // .font(.system(.title, weight: .bold)) } Chart(self.statValue.records) { point in let stat: Stat = self.statValue.stat + switch stat { case .count, .totalDuration: - BarMark(x: .value("name", point.date, unit: .month), + BarMark(x: .value("date", point.date, unit: .day), y: .value("value", point.value.doubleValue)) default: - LineMark(x: .value("name", point.date, unit: .month), + LineMark(x: .value("date", point.date, unit: .day), y: .value("value", point.value.doubleValue)) } - } + }.frame(height: 300.0) } } @@ -126,6 +128,7 @@ struct StatView_Previews: PreviewProvider { struct StatsView_Previews: PreviewProvider { static var previews: some View { - StatsView(activity: Activity.fake(context: PersistenceController.preview.container.viewContext)) + StatsView(activity: Activity.fake(context: PersistenceController.preview.container.viewContext), + timeFrame: .all) } }