diff --git a/LeCountdown.xcodeproj/project.pbxproj b/LeCountdown.xcodeproj/project.pbxproj index 2a26ca8..f56161f 100644 --- a/LeCountdown.xcodeproj/project.pbxproj +++ b/LeCountdown.xcodeproj/project.pbxproj @@ -42,6 +42,8 @@ C42E96FD29E5B06D005B1B8C /* ActivityCalendarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C42E96FC29E5B06D005B1B8C /* ActivityCalendarView.swift */; }; C42E96FE29E5B5CD005B1B8C /* Filter.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4BA2B6429A3C37D00CB4FBA /* Filter.swift */; }; C42E970229E6B32B005B1B8C /* CalendarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C42E970129E6B32B005B1B8C /* CalendarView.swift */; }; + C42E970529E6E4F7005B1B8C /* ActivityView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C42E970429E6E4F7005B1B8C /* ActivityView.swift */; }; + C42E970729E6EDF5005B1B8C /* StatisticsSubView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C42E970629E6EDF5005B1B8C /* StatisticsSubView.swift */; }; C438C7C12980228B00BF3EF9 /* CountdownScheduler.swift in Sources */ = {isa = PBXBuildFile; fileRef = C438C7C02980228B00BF3EF9 /* CountdownScheduler.swift */; }; C438C7C5298024E900BF3EF9 /* NSManagedContext+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = C438C7C4298024E900BF3EF9 /* NSManagedContext+Extensions.swift */; }; C438C7C929803CA000BF3EF9 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = C438C7C829803CA000BF3EF9 /* AppDelegate.swift */; }; @@ -197,7 +199,7 @@ C4BA2B3A299F838000CB4FBA /* Model+SharedExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4BA2B39299F838000CB4FBA /* Model+SharedExtensions.swift */; }; 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 /* GraphsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4BA2B3D299FC86800CB4FBA /* GraphsView.swift */; }; + C4BA2B3E299FC86800CB4FBA /* StatisticsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4BA2B3D299FC86800CB4FBA /* StatisticsView.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 */; }; @@ -210,7 +212,7 @@ 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 */; }; + C4BA2B6129A3C02400CB4FBA /* ActivityStatsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4BA2B6029A3C02400CB4FBA /* ActivityStatsView.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 */; }; @@ -368,6 +370,8 @@ C42E96FA29E59E72005B1B8C /* BackgroundBlurView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundBlurView.swift; sourceTree = ""; }; C42E96FC29E5B06D005B1B8C /* ActivityCalendarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActivityCalendarView.swift; sourceTree = ""; }; C42E970129E6B32B005B1B8C /* CalendarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarView.swift; sourceTree = ""; }; + C42E970429E6E4F7005B1B8C /* ActivityView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActivityView.swift; sourceTree = ""; }; + C42E970629E6EDF5005B1B8C /* StatisticsSubView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatisticsSubView.swift; sourceTree = ""; }; C438C7C02980228B00BF3EF9 /* CountdownScheduler.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CountdownScheduler.swift; sourceTree = ""; }; C438C7C4298024E900BF3EF9 /* NSManagedContext+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSManagedContext+Extensions.swift"; sourceTree = ""; }; C438C7C829803CA000BF3EF9 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; @@ -454,7 +458,7 @@ C4BA2B30299F759700CB4FBA /* DefaultView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DefaultView.swift; sourceTree = ""; }; C4BA2B35299F82FB00CB4FBA /* Fakes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Fakes.swift; sourceTree = ""; }; C4BA2B39299F838000CB4FBA /* Model+SharedExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Model+SharedExtensions.swift"; sourceTree = ""; }; - C4BA2B3D299FC86800CB4FBA /* GraphsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GraphsView.swift; sourceTree = ""; }; + C4BA2B3D299FC86800CB4FBA /* StatisticsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatisticsView.swift; sourceTree = ""; }; C4BA2B42299FCB2B00CB4FBA /* RecordsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordsView.swift; sourceTree = ""; }; C4BA2B46299FCD8B00CB4FBA /* LeCountdown.0.6.1.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = LeCountdown.0.6.1.xcdatamodel; sourceTree = ""; }; C4BA2B47299FCE0C00CB4FBA /* IntervalGroup+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "IntervalGroup+CoreDataProperties.swift"; sourceTree = ""; }; @@ -462,7 +466,7 @@ 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 = ""; }; + C4BA2B6029A3C02400CB4FBA /* ActivityStatsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActivityStatsView.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 = ""; }; @@ -834,9 +838,11 @@ children = ( C438C80E29828B8600BF3EF9 /* ActivitiesView.swift */, C42E96FC29E5B06D005B1B8C /* ActivityCalendarView.swift */, - C4BA2B6029A3C02400CB4FBA /* ActivityView.swift */, - C4BA2B3D299FC86800CB4FBA /* GraphsView.swift */, + C4BA2B6029A3C02400CB4FBA /* ActivityStatsView.swift */, + C42E970429E6E4F7005B1B8C /* ActivityView.swift */, C4BA2B42299FCB2B00CB4FBA /* RecordsView.swift */, + C42E970629E6EDF5005B1B8C /* StatisticsSubView.swift */, + C4BA2B3D299FC86800CB4FBA /* StatisticsView.swift */, ); path = Stats; sourceTree = ""; @@ -1224,6 +1230,7 @@ C4BA2B5F299FFC8400CB4FBA /* StoreView.swift in Sources */, C4BA2B7929A65C1400CB4FBA /* UIDevice+Extensions.swift in Sources */, C4F8B17E298AC234005C86A5 /* Alarm+CoreDataClass.swift in Sources */, + C42E970729E6EDF5005B1B8C /* StatisticsSubView.swift in Sources */, C438C81129829EAF00BF3EF9 /* PropertyWrappers.swift in Sources */, C4F8B1BF298ACA0B005C86A5 /* StopwatchDialView.swift in Sources */, C438C807298195E600BF3EF9 /* Model+Extensions.swift in Sources */, @@ -1238,7 +1245,7 @@ C4E5D66629B73AED008E7465 /* StartTimerIntent.swift in Sources */, C4BA2AD62993F62700CB4FBA /* SoundSelectionView.swift in Sources */, C498E5A5299152B400E90DE0 /* GreenCheckmarkView.swift in Sources */, - C4BA2B3E299FC86800CB4FBA /* GraphsView.swift in Sources */, + C4BA2B3E299FC86800CB4FBA /* StatisticsView.swift in Sources */, C4F8B1B8298AC81D005C86A5 /* CountdownDialView.swift in Sources */, C473C2F929A8DC0A0056B38A /* LaunchWidgetAttributes.swift in Sources */, C445FA922987CC8A0054D761 /* Sound.swift in Sources */, @@ -1272,6 +1279,7 @@ C4BA2ADB299549BC00CB4FBA /* TimerModel.swift in Sources */, C4BA2B6529A3C37D00CB4FBA /* Filter.swift in Sources */, C4F8B16B298AA240005C86A5 /* NewStopwatchView.swift in Sources */, + C42E970529E6E4F7005B1B8C /* ActivityView.swift in Sources */, C4BA2AF22996A11900CB4FBA /* CustomSound+CoreDataProperties.swift in Sources */, C4060DF5297AE9A7003FAB80 /* TimeInterval+Extensions.swift in Sources */, C4F8B166298A9ABB005C86A5 /* SoundFormView.swift in Sources */, @@ -1298,7 +1306,7 @@ C438C7C5298024E900BF3EF9 /* NSManagedContext+Extensions.swift in Sources */, C4F8B15F298961A7005C86A5 /* ReorderableForEach.swift in Sources */, C4A16D9529C4B06400143D5E /* StatePlayer.swift in Sources */, - C4BA2B6129A3C02400CB4FBA /* ActivityView.swift in Sources */, + C4BA2B6129A3C02400CB4FBA /* ActivityStatsView.swift in Sources */, C4BA2B6A29A4BE1800CB4FBA /* Date+Extensions.swift in Sources */, C4BA2B10299BE61E00CB4FBA /* Interval+CoreDataProperties.swift in Sources */, C4F8B1AC298AC3A0005C86A5 /* Alarm+CoreDataProperties.swift in Sources */, diff --git a/LeCountdown/Conductor.swift b/LeCountdown/Conductor.swift index c8c9290..57659d8 100644 --- a/LeCountdown/Conductor.swift +++ b/LeCountdown/Conductor.swift @@ -119,7 +119,7 @@ class Conductor: ObservableObject { fileprivate func _recordActivity(countdownId: String) { let context = PersistenceController.shared.container.viewContext - if let countdown = context.object(stringId: countdownId) as? Countdown, + if let countdown: Countdown = context.object(stringId: countdownId), let dateInterval = self.currentCountdowns[countdownId] { do { try CoreDataRequests.recordActivity(timer: countdown, dateInterval: dateInterval) @@ -239,7 +239,7 @@ class Conductor: ObservableObject { if !self._delayedSoundPlayers.contains(where: { $0.key == countdownId }) { let context = PersistenceController.shared.container.viewContext - if let countdown = context.object(stringId: countdownId) as? Countdown { + if let countdown: Countdown = context.object(stringId: countdownId) { do { let sound: Sound = countdown.someSound diff --git a/LeCountdown/Model/CoreDataRequests.swift b/LeCountdown/Model/CoreDataRequests.swift index 384754b..cc11ea7 100644 --- a/LeCountdown/Model/CoreDataRequests.swift +++ b/LeCountdown/Model/CoreDataRequests.swift @@ -58,6 +58,45 @@ class CoreDataRequests { try context.save() } + static func years(context: NSManagedObjectContext, activity: Activity) throws -> [Int] { + + let predicate: NSPredicate = NSPredicate(format: "activity = %@", activity) + let distinct = try context.distinct(entityName: "Record", attributes: ["year"], predicate: predicate) + if let distinctYears = distinct as? [[String : Int]] { + return distinctYears.compactMap { + if let year = $0["year"] { + return year + } else { + Logger.w("issue with dictionary \($0)") + return nil + } + } + } else { + Logger.w("Could not cast \(distinct) as [Int]") + return [] + } + + } + + static func months(context: NSManagedObjectContext, activity: Activity, year: Int) throws -> [Int] { + + let predicate: NSPredicate = NSPredicate(format: "activity = %@ AND year = %i", activity, year) + let distinct = try context.distinct(entityName: "Record", attributes: ["month"], predicate: predicate) + if let distinctMonths = distinct as? [[String : Int]] { + return distinctMonths.compactMap { + if let month = $0["month"] { + return month + } else { + Logger.w("issue with dictionary \($0)") + return nil + } + } + } else { + Logger.w("Could not cast \(distinct) as [Int]") + return [] + } + } + static func months(context: NSManagedObjectContext, activity: Activity) throws -> [Month] { let predicate: NSPredicate = NSPredicate(format: "activity = %@", activity) diff --git a/LeCountdown/Model/LiveTimer.swift b/LeCountdown/Model/LiveTimer.swift index a4ec3ab..cdf517f 100644 --- a/LeCountdown/Model/LiveTimer.swift +++ b/LeCountdown/Model/LiveTimer.swift @@ -18,10 +18,11 @@ struct LiveTimer: Identifiable, Comparable { } func timer(context: NSManagedObjectContext) -> AbstractTimer? { - return context.object(stringId: self.id) as? AbstractTimer + return context.object(stringId: self.id) } var ended: Bool { return self.date < Date() } + } diff --git a/LeCountdown/Model/NSManagedContext+Extensions.swift b/LeCountdown/Model/NSManagedContext+Extensions.swift index 92ed365..0bee685 100644 --- a/LeCountdown/Model/NSManagedContext+Extensions.swift +++ b/LeCountdown/Model/NSManagedContext+Extensions.swift @@ -10,10 +10,10 @@ import CoreData extension NSManagedObjectContext { - func object(stringId: String) -> NSManagedObject? { + func object(stringId: String) -> T? { guard let url = URL(string: stringId) else { return nil } guard let objectId = PersistenceController.shared.container.persistentStoreCoordinator.managedObjectID(forURIRepresentation: url) else { return nil } - return self.object(with: objectId) + return self.object(with: objectId) as? T } func distinct(entityName: String, attributes: [String], predicate: NSPredicate? = nil) throws -> [Any] { diff --git a/LeCountdown/Stats/Filter.swift b/LeCountdown/Stats/Filter.swift index 9784245..b005df7 100644 --- a/LeCountdown/Stats/Filter.swift +++ b/LeCountdown/Stats/Filter.swift @@ -28,7 +28,7 @@ enum Filter: Identifiable, Hashable { switch self { case .none: return NSLocalizedString("All", comment: "") case .year(let year): return "\(year)" - case .month(let month): return month.localizedString + case .month(let month): return month.localizedString.capitalized } } diff --git a/LeCountdown/Stats/Stat.swift b/LeCountdown/Stats/Stat.swift index 253233d..a61057c 100644 --- a/LeCountdown/Stats/Stat.swift +++ b/LeCountdown/Stats/Stat.swift @@ -111,7 +111,7 @@ struct StatValue: Identifiable { let identifier: String switch timeFrame { - case .all: identifier = point.date.formattedYear +// case .all: identifier = point.date.formattedYear case .year: identifier = point.date.formattedMonth case .month: identifier = point.date.formattedDay } diff --git a/LeCountdown/Views/LiveTimerListView.swift b/LeCountdown/Views/LiveTimerListView.swift index edb74bf..2118968 100644 --- a/LeCountdown/Views/LiveTimerListView.swift +++ b/LeCountdown/Views/LiveTimerListView.swift @@ -227,7 +227,6 @@ struct LiveCountdownView: View { .foregroundColor(.white) .background(Color.accentColor) .cornerRadius(8.0) - .font(.title) }.onTapGesture { self.showConfirmationPopup = false } diff --git a/LeCountdown/Views/Stats/ActivitiesView.swift b/LeCountdown/Views/Stats/ActivitiesView.swift index f3d022d..c7aca1e 100644 --- a/LeCountdown/Views/Stats/ActivitiesView.swift +++ b/LeCountdown/Views/Stats/ActivitiesView.swift @@ -32,7 +32,7 @@ struct ActivitiesView: View { ForEach(self.activities) { activity in NavigationLink { - ActivityCalendarView(activity: activity) + ActivityView(activity: activity) } label: { HStack { Text(activity.name ?? "no activity") diff --git a/LeCountdown/Views/Stats/ActivityCalendarView.swift b/LeCountdown/Views/Stats/ActivityCalendarView.swift index d2c6b34..8f720eb 100644 --- a/LeCountdown/Views/Stats/ActivityCalendarView.swift +++ b/LeCountdown/Views/Stats/ActivityCalendarView.swift @@ -67,7 +67,7 @@ struct ActivityCalendarView: View { } } } - .navigationTitle(activity.name ?? "Activity") + .navigationTitle(self.activity.name ?? "no name") .onAppear { self._load() } @@ -94,17 +94,8 @@ struct ActivityCalendarView: View { struct ActivityCalendarView_Previews: PreviewProvider { - static var activity: Activity { - let context = PersistenceController.preview.container.viewContext - do { - return try context.fetch(Activity.fetchRequest()).first ?? Activity.fake(context: context) - } catch { - return Activity.fake(context: context) - } - } - static var previews: some View { - ActivityCalendarView(activity: self.activity) + ActivityCalendarView(activity: ActivityView_Previews.activity) .environment(\.managedObjectContext, PersistenceController.preview.container.viewContext) } diff --git a/LeCountdown/Views/Stats/ActivityStatsView.swift b/LeCountdown/Views/Stats/ActivityStatsView.swift new file mode 100644 index 0000000..0916c0d --- /dev/null +++ b/LeCountdown/Views/Stats/ActivityStatsView.swift @@ -0,0 +1,141 @@ +// +// ActivityView.swift +// LeCountdown +// +// Created by Laurent Morvillier on 20/02/2023. +// + +import SwiftUI +import CoreData + +struct ActivityStatsView: View { + + @Environment(\.managedObjectContext) private var viewContext + + let activity: Activity + var filter: Filter? = nil + + @State var subFilters: [Filter] = [] + + var body: some View { + + List { + Section { + StatisticsView(activity: self.activity, filter: self.filter) { statValues in + ForEach(statValues) { statValue in + StatView(statValue: statValue) + } + }.environment(\.managedObjectContext, viewContext) + } + + // Picker("Time", selection: $selectedTimeFrame) { + // ForEach(TimeFrame.allCases) { timeFrame in + // Text(timeFrame.localizedString).tag(timeFrame) + // } + // } + // .pickerStyle(.segmented) + // .padding(.horizontal) + + + StatisticsSubView(activity: self.activity, subFilters: self.subFilters) + .environment(\.managedObjectContext, viewContext) + + } + .navigationTitle(self.filter?.localizedString ?? self.activity.name ?? "no name") + .onAppear { + self._load() + } + + } + + fileprivate func _load() { + + do { + switch self.filter { + case nil: + let years = try CoreDataRequests.years(context: self.viewContext, activity: self.activity) + self.subFilters = years.map { Filter.year($0) } + case .some(let filter): + switch filter { + case .year(let year): + let months = try CoreDataRequests.months(context: self.viewContext, activity: self.activity, year: year) + self.subFilters = months.map { Filter.month(Month(month: $0, year: year)) } + default: + break + } + } + } catch { + Logger.error(error) + } + } + +} + +enum TimeFrame: Int, Identifiable, CaseIterable { + + var id: Int { return self.rawValue } + +// case all + case year + case month + + var localizedString: String { + switch self { +// case .all: return NSLocalizedString("All", comment: "") + case .year: return NSLocalizedString("Year", comment: "") + case .month: return NSLocalizedString("Month", comment: "") + } + } + + func filters(context: NSManagedObjectContext, activity: Activity) -> [Filter] { + + let predicate = NSPredicate(format: "activity = %@", activity) + + do { + switch self { +// case .all: +// return [.none] + case .year: + let distinct = try context.distinct(entityName: "Record", attributes: ["year"], predicate: predicate) + if let yearsMap = distinct as? [[String : Int]] { + let years = yearsMap.compactMap { $0.first?.value } + return years.map { Filter.year($0) } + } else { + Logger.w("Could not cast \(distinct) as [Int]") + } + case .month: + + let monthes = try CoreDataRequests.months(context: context, activity: activity) + return monthes.map { Filter.month($0) } + +// let distinct = try context.distinct(entityName: "Record", attributes: ["year", "month"], predicate: predicate) +// if let distinctMonths = distinct as? [[String : Int]] { +// +// let months = distinctMonths.compactMap { +// if let month = $0["month"], +// let year = $0["year"] { +// return Month(month: month, year: year) +// } else { +// Logger.w("issue with dictionary \($0)") +// return nil +// } +// } +// return months.map { Filter.month($0) } +// } else { +// Logger.w("Could not cast \(distinct) as [Int]") +// } + } + } catch { + Logger.error(error) + } + return [] + } + +} + +struct ActivityStatsView_Previews: PreviewProvider { + static var previews: some View { + ActivityStatsView(activity: Activity.fake(context: PersistenceController.preview.container.viewContext)) + .environment(\.managedObjectContext, PersistenceController.preview.container.viewContext) + } +} diff --git a/LeCountdown/Views/Stats/ActivityView.swift b/LeCountdown/Views/Stats/ActivityView.swift index 195696f..d2bc6dc 100644 --- a/LeCountdown/Views/Stats/ActivityView.swift +++ b/LeCountdown/Views/Stats/ActivityView.swift @@ -2,112 +2,49 @@ // ActivityView.swift // LeCountdown // -// Created by Laurent Morvillier on 20/02/2023. +// Created by Laurent Morvillier on 12/04/2023. // import SwiftUI -import CoreData struct ActivityView: View { - + @Environment(\.managedObjectContext) private var viewContext let activity: Activity - - @State var selectedTimeFrame: TimeFrame = .all - + var body: some View { - VStack { - Picker("Time", selection: $selectedTimeFrame) { - ForEach(TimeFrame.allCases) { timeFrame in - Text(timeFrame.localizedString).tag(timeFrame) + TabView { + ActivityCalendarView(activity: self.activity).tabItem { + Label("Calendar", systemImage: "calendar") + }.environment(\.managedObjectContext, viewContext) + ActivityStatsView(activity: self.activity).tabItem { + Label("Stats", systemImage: "function") + }.environment(\.managedObjectContext, viewContext) + RecordsView(activity: self.activity) + .tabItem { + Label("Stats", systemImage: "list.bullet") } - } - .pickerStyle(.segmented) - .padding(.horizontal) - - List { - - GraphsView(activity: self.activity, - timeFrame: self.selectedTimeFrame) .environment(\.managedObjectContext, viewContext) - - let filters = self.selectedTimeFrame.filters(context: viewContext, activity: activity) - RecordsView(activity: self.activity, filters: filters) - .environment(\.managedObjectContext, viewContext) - } - - } + }.navigationTitle(self.activity.name ?? "no name") } } -enum TimeFrame: Int, Identifiable, CaseIterable { - - var id: Int { return self.rawValue } - - case all - case year - case month - - var localizedString: String { - switch self { - case .all: return NSLocalizedString("All", comment: "") - case .year: return NSLocalizedString("Year", comment: "") - case .month: return NSLocalizedString("Month", comment: "") - } - } +struct ActivityView_Previews: PreviewProvider { - func filters(context: NSManagedObjectContext, activity: Activity) -> [Filter] { - - let predicate = NSPredicate(format: "activity = %@", activity) - + static var activity: Activity { + let context = PersistenceController.preview.container.viewContext do { - switch self { - case .all: - return [.none] - case .year: - let distinct = try context.distinct(entityName: "Record", attributes: ["year"], predicate: predicate) - if let yearsMap = distinct as? [[String : Int]] { - let years = yearsMap.compactMap { $0.first?.value } - return years.map { Filter.year($0) } - } else { - Logger.w("Could not cast \(distinct) as [Int]") - } - case .month: - - let monthes = try CoreDataRequests.months(context: context, activity: activity) - return monthes.map { Filter.month($0) } - -// let distinct = try context.distinct(entityName: "Record", attributes: ["year", "month"], predicate: predicate) -// if let distinctMonths = distinct as? [[String : Int]] { -// -// let months = distinctMonths.compactMap { -// if let month = $0["month"], -// let year = $0["year"] { -// return Month(month: month, year: year) -// } else { -// Logger.w("issue with dictionary \($0)") -// return nil -// } -// } -// return months.map { Filter.month($0) } -// } else { -// Logger.w("Could not cast \(distinct) as [Int]") -// } - } + return try context.fetch(Activity.fetchRequest()).first ?? Activity.fake(context: context) } catch { - Logger.error(error) + return Activity.fake(context: context) } - return [] } - -} -struct ActivityView_Previews: PreviewProvider { static var previews: some View { - ActivityView(activity: Activity.fake(context: PersistenceController.preview.container.viewContext), selectedTimeFrame: .all) + ActivityView(activity: self.activity) .environment(\.managedObjectContext, PersistenceController.preview.container.viewContext) } } diff --git a/LeCountdown/Views/Stats/RecordsView.swift b/LeCountdown/Views/Stats/RecordsView.swift index f2f7980..835540d 100644 --- a/LeCountdown/Views/Stats/RecordsView.swift +++ b/LeCountdown/Views/Stats/RecordsView.swift @@ -8,46 +8,49 @@ import SwiftUI import CoreData -class RecordsModel: ObservableObject { - - @Published var filters: [Filter] = [] - - func loadFilters(activity: Activity, timeFrame: TimeFrame, context: NSManagedObjectContext) { - self.filters = timeFrame.filters(context: context, activity: activity) - } - -} - struct RecordsView: View { @Environment(\.managedObjectContext) private var viewContext - let activity: Activity - let filters: [Filter] + var activity: Activity + + @StateObject var model: RecordsSectionModel = RecordsSectionModel() var body: some View { - ForEach(self.filters) { filter in - RecordsSectionView(activity: self.activity, filter: filter) - .environment(\.managedObjectContext, viewContext) + + List { + ForEach(self.model.records) { record in + HStack { + Text(record.formattedDay) + Spacer() + Text(record.duration.minuteSecond) + } + }.onDelete(perform: _deleteItem) + }.onAppear { + self.model.loadRecords(activity: self.activity, + context: viewContext) } } + fileprivate func _deleteItem(_ indexSet: IndexSet) { + self.model.deleteItem(indexSet, context: self.viewContext) + } + } class RecordsSectionModel: ObservableObject { @Published var records: [Record] = [] - func loadRecords(activity: Activity, filter: Filter, context: NSManagedObjectContext) { - let records = self._retrieveRecords(activity: activity, filter: filter, context: context) + func loadRecords(activity: Activity, context: NSManagedObjectContext) { + let records = self._retrieveRecords(activity: activity, context: context) self.records = records } - fileprivate func _retrieveRecords(activity: Activity, filter: Filter, context: NSManagedObjectContext) -> [Record] { + fileprivate func _retrieveRecords(activity: Activity, context: NSManagedObjectContext) -> [Record] { do { let request = Record.fetchRequest() - let activityPredicate = NSPredicate(format: "activity = %@", activity) - request.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [activityPredicate, filter.predicate]) + request.predicate = NSPredicate(format: "activity = %@", activity) request.sortDescriptors = [NSSortDescriptor(key: "start", ascending: false)] return try context.fetch(request) } catch { @@ -73,44 +76,42 @@ class RecordsSectionModel: ObservableObject { } -struct RecordsSectionView: View { - - @Environment(\.managedObjectContext) private var viewContext - - var activity: Activity - @State var filter: Filter - - @StateObject var model: RecordsSectionModel = RecordsSectionModel() - - var body: some View { - - Section(filter.localizedString) { - ForEach(self.model.records) { record in - HStack { - Text(record.formattedDay) - Spacer() - Text(record.duration.minuteSecond) - } - }.onDelete(perform: _deleteItem) - }.onAppear { - self.model.loadRecords(activity: self.activity, - filter: self.filter, - context: viewContext) - } - - } - - fileprivate func _deleteItem(_ indexSet: IndexSet) { - self.model.deleteItem(indexSet, context: self.viewContext) - } - -} +//struct RecordsSectionView: View { +// +// @Environment(\.managedObjectContext) private var viewContext +// +// var activity: Activity +//// @State var filter: Filter +// +// @StateObject var model: RecordsSectionModel = RecordsSectionModel() +// +// var body: some View { +// +// Section { +// ForEach(self.model.records) { record in +// HStack { +// Text(record.formattedDay) +// Spacer() +// Text(record.duration.minuteSecond) +// } +// }.onDelete(perform: _deleteItem) +// }.onAppear { +// self.model.loadRecords(activity: self.activity, +// context: viewContext) +// } +// +// } +// +// fileprivate func _deleteItem(_ indexSet: IndexSet) { +// self.model.deleteItem(indexSet, context: self.viewContext) +// } +// +//} struct RecordsView_Previews: PreviewProvider { static var previews: some View { RecordsView(activity: - Activity.fake(context: PersistenceController.preview.container.viewContext), - filters: [Filter.none]) + Activity.fake(context: PersistenceController.preview.container.viewContext)) .environment(\.managedObjectContext, PersistenceController.preview.container.viewContext) } } diff --git a/LeCountdown/Views/Stats/StatisticsSubView.swift b/LeCountdown/Views/Stats/StatisticsSubView.swift new file mode 100644 index 0000000..276bb66 --- /dev/null +++ b/LeCountdown/Views/Stats/StatisticsSubView.swift @@ -0,0 +1,42 @@ +// +// StatisticsSubView.swift +// LeCountdown +// +// Created by Laurent Morvillier on 12/04/2023. +// + +import SwiftUI + +struct StatisticsSubView: View { + + @Environment(\.managedObjectContext) private var viewContext + + let activity: Activity + var subFilters: [Filter] = [] + + var body: some View { + Section { + ForEach(self.subFilters) { filter in + + NavigationLink { + ActivityStatsView(activity: self.activity, filter: filter) + } label: { + StatisticsView(activity: self.activity, filter: filter) { statValues in + HStack { + if let count = statValues.first(where: { $0.stat == .count }) { + LabeledContent(filter.localizedString, value: count.formattedValue) + } + } + } + } + } + } + } + +} + +struct StatisticsSubView_Previews: PreviewProvider { + static var previews: some View { + StatisticsSubView(activity: ActivityView_Previews.activity) + } +} diff --git a/LeCountdown/Views/Stats/GraphsView.swift b/LeCountdown/Views/Stats/StatisticsView.swift similarity index 60% rename from LeCountdown/Views/Stats/GraphsView.swift rename to LeCountdown/Views/Stats/StatisticsView.swift index 6d72255..1a65078 100644 --- a/LeCountdown/Views/Stats/GraphsView.swift +++ b/LeCountdown/Views/Stats/StatisticsView.swift @@ -41,36 +41,31 @@ class StatModel: ObservableObject { } -struct GraphsView: View { +struct StatisticsView: View where ContentView: View { var activity: Activity - var timeFrame: TimeFrame var filter: Filter? = nil + + let content: ([StatValue]) -> ContentView @StateObject var model: StatModel = StatModel() var body: some View { - Section { - VStack() { - - if self.model.isComputing { - ProgressView() - .progressViewStyle(CircularProgressViewStyle()) - } else { - VStack(alignment: .leading) { - ForEach(self.model.statValues) { statValue in - GraphView(statValue: statValue, timeFrame: self.timeFrame) - // .padding(.vertical) - } - } + VStack() { + + if self.model.isComputing { + ProgressView() + .progressViewStyle(CircularProgressViewStyle()) + } else { + VStack(alignment: .leading) { + self.content(self.model.statValues) } } } .onAppear() { self.model.compute(activity: self.activity, filter: self.filter) } - .navigationTitle(self.activity.name ?? "") } } @@ -99,30 +94,27 @@ struct GraphView: View { VStack(alignment: .leading) { StatView(statValue: self.statValue) -// HStack { -// Text(self.statValue.stat.localizedName.uppercased()) -// Text(self.statValue.formattedValue) -// } - Chart(self.statValue.chartPoint(timeFrame: self.timeFrame)) { point in - - let stat: Stat = self.statValue.stat - - switch stat { - case .count, .totalDuration: - BarMark(x: .value("date", point.index), - y: .value("value", point.value.doubleValue)) - default: - LineMark(x: .value("date", point.index), - y: .value("value", point.value.doubleValue)) - } - }.chartXAxis(.hidden) - .chartYAxis { - AxisMarks() { value in - AxisValueLabel { - Text(self.statValue.stat.format(axisValue: value)) - } - } - }.frame(height: 200.0) + +// Chart(self.statValue.chartPoint(timeFrame: self.timeFrame)) { point in +// +// let stat: Stat = self.statValue.stat +// +// switch stat { +// case .count, .totalDuration: +// BarMark(x: .value("date", point.index), +// y: .value("value", point.value.doubleValue)) +// default: +// LineMark(x: .value("date", point.index), +// y: .value("value", point.value.doubleValue)) +// } +// }.chartXAxis(.hidden) +// .chartYAxis { +// AxisMarks() { value in +// AxisValueLabel { +// Text(self.statValue.stat.format(axisValue: value)) +// } +// } +// }.frame(height: 200.0) } } @@ -150,7 +142,9 @@ struct StatGraphView_Previews: PreviewProvider { Point(date: Date(timeIntervalSince1970: 100000), value: 1)] static var previews: some View { - GraphView(statValue: StatValue(stat: .count, value: NSDecimalNumber(integerLiteral: 3), points: points), timeFrame: .all) + GraphView(statValue: StatValue(stat: .count, + value: NSDecimalNumber(integerLiteral: 3), + points: points), timeFrame: .month) } } @@ -162,7 +156,12 @@ struct StatView_Previews: PreviewProvider { struct StatsView_Previews: PreviewProvider { static var previews: some View { - GraphsView(activity: Activity.fake(context: PersistenceController.preview.container.viewContext), - timeFrame: .all) + StatisticsView(activity: Activity.fake(context: PersistenceController.preview.container.viewContext)) { values in + VStack { + ForEach(values) { value in + StatView(statValue: value) + } + } + } } } diff --git a/LeCountdown/Widget/IntentDataProvider.swift b/LeCountdown/Widget/IntentDataProvider.swift index f13e2cd..f56b93e 100644 --- a/LeCountdown/Widget/IntentDataProvider.swift +++ b/LeCountdown/Widget/IntentDataProvider.swift @@ -24,7 +24,7 @@ class IntentDataProvider { func timer(id: String) -> AbstractTimer? { let context = PersistenceController.shared.container.viewContext - return context.object(stringId: id) as? AbstractTimer + return context.object(stringId: id) } } diff --git a/LeCountdown/fr.lproj/Localizable.strings b/LeCountdown/fr.lproj/Localizable.strings index 7cf9e0a..e450788 100644 --- a/LeCountdown/fr.lproj/Localizable.strings +++ b/LeCountdown/fr.lproj/Localizable.strings @@ -264,3 +264,4 @@ "Hours" = "Heures"; "Default Volume" = "Volume par défaut"; "Cancel %@" = "Annuler %@"; +"Calendar" = "Calendrier";