Add bricks for filter and calculations

release
Laurent 3 years ago
parent 77e037ca18
commit f62d3f23e7
  1. 22
      LeCountdown.xcodeproj/project.pbxproj
  2. 18
      LeCountdown/LeCountdownApp.swift
  3. 6
      LeCountdown/Model/Generation/Record+CoreDataProperties.swift
  4. 2
      LeCountdown/Model/LeCountdown.xcdatamodeld/.xccurrentversion
  5. 51
      LeCountdown/Model/LeCountdown.xcdatamodeld/LeCountdown.0.6.2.xcdatamodel/contents
  6. 32
      LeCountdown/Model/Model+Extensions.swift
  7. 21
      LeCountdown/Model/NSManagedContext+Extensions.swift
  8. 3
      LeCountdown/Stats/Context+Calculations.swift
  9. 51
      LeCountdown/Stats/Filter.swift
  10. 13
      LeCountdown/Stats/Stat.swift
  11. 23
      LeCountdown/Utils/Date+Extensions.swift
  12. 0
      LeCountdown/Views/Reusable/GreenCheckmarkView.swift
  13. 0
      LeCountdown/Views/Reusable/ImageSelectionView.swift
  14. 0
      LeCountdown/Views/Reusable/PermissionAlertView.swift
  15. 0
      LeCountdown/Views/Reusable/ReorderableForEach.swift
  16. 0
      LeCountdown/Views/Reusable/SoundImageFormView.swift
  17. 0
      LeCountdown/Views/Reusable/SoundSelectionView.swift
  18. 0
      LeCountdown/Views/Reusable/TimerModel.swift
  19. 0
      LeCountdown/Views/Reusable/View+Extension.swift
  20. 0
      LeCountdown/Views/Reusable/ViewModifiers.swift
  21. 50
      LeCountdown/Views/Stats/ActivityView.swift
  22. 60
      LeCountdown/Views/Stats/RecordsView.swift
  23. 15
      LeCountdown/Views/Stats/StatsView.swift

@ -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 = "<group>"; };
C4BA2B6429A3C37D00CB4FBA /* Filter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Filter.swift; sourceTree = "<group>"; };
C4BA2B6729A3C4AC00CB4FBA /* Context+Calculations.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Context+Calculations.swift"; sourceTree = "<group>"; };
C4BA2B6929A4BE1800CB4FBA /* Date+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Date+Extensions.swift"; sourceTree = "<group>"; };
C4BA2B6B29A4C47100CB4FBA /* LeCountdown.0.6.2.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = LeCountdown.0.6.2.xcdatamodel; sourceTree = "<group>"; };
C4F8B15629891271005C86A5 /* Conductor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Conductor.swift; sourceTree = "<group>"; };
C4F8B15829891528005C86A5 /* forest_stream.mp3 */ = {isa = PBXFileReference; lastKnownFileType = audio.mp3; path = forest_stream.mp3; sourceTree = "<group>"; };
C4F8B15E298961A7005C86A5 /* ReorderableForEach.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReorderableForEach.swift; sourceTree = "<group>"; };
@ -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 = "<group>";
@ -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 = "<group>";
@ -679,18 +681,20 @@
path = Stopwatch;
sourceTree = "<group>";
};
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 = "<group>";
};
/* 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 = "<group>";
versionGroupType = wrapper.xcdatamodel;

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

@ -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<Record>(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?
}

@ -3,6 +3,6 @@
<plist version="1.0">
<dict>
<key>_XCCurrentVersionName</key>
<string>LeCountdown.0.6.1.xcdatamodel</string>
<string>LeCountdown.0.6.2.xcdatamodel</string>
</dict>
</plist>

@ -0,0 +1,51 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="21513" systemVersion="22A400" minimumToolsVersion="Automatic" sourceLanguage="Swift" usedWithCloudKit="YES" userDefinedModelVersionIdentifier="">
<entity name="AbstractSoundTimer" representedClassName="AbstractSoundTimer" isAbstract="YES" parentEntity="AbstractTimer" syncable="YES">
<attribute name="repeatCount" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="soundList" optional="YES" attributeType="String"/>
</entity>
<entity name="AbstractTimer" representedClassName="AbstractTimer" isAbstract="YES" syncable="YES">
<attribute name="image" optional="YES" attributeType="String"/>
<attribute name="order" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/>
<relationship name="activity" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Activity" inverseName="timers" inverseEntity="Activity"/>
</entity>
<entity name="Activity" representedClassName="Activity" syncable="YES">
<attribute name="name" attributeType="String" defaultValueString=""/>
<relationship name="records" optional="YES" toMany="YES" deletionRule="Cascade" destinationEntity="Record" inverseName="activity" inverseEntity="Record"/>
<relationship name="timers" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="AbstractTimer" inverseName="activity" inverseEntity="AbstractTimer"/>
</entity>
<entity name="Alarm" representedClassName="Alarm" parentEntity="AbstractSoundTimer" syncable="YES">
<attribute name="fireDate" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
</entity>
<entity name="Countdown" representedClassName="Countdown" parentEntity="AbstractSoundTimer" syncable="YES">
<attribute name="duration" attributeType="Double" defaultValueString="0" usesScalarValueType="YES"/>
<relationship name="group" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="IntervalGroup" inverseName="countdown" inverseEntity="IntervalGroup"/>
</entity>
<entity name="CustomSound" representedClassName="CustomSound" syncable="YES">
<attribute name="file" optional="YES" attributeType="String"/>
<attribute name="text" optional="YES" attributeType="String"/>
</entity>
<entity name="Interval" representedClassName="Interval" syncable="YES">
<attribute name="duration" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
<attribute name="soundList" optional="YES" attributeType="String"/>
<relationship name="group" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="IntervalGroup" inverseName="intervals" inverseEntity="IntervalGroup"/>
</entity>
<entity name="IntervalGroup" representedClassName="IntervalGroup" syncable="YES">
<attribute name="repeatCount" optional="YES" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/>
<relationship name="countdown" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Countdown" inverseName="group" inverseEntity="Countdown"/>
<relationship name="intervals" optional="YES" toMany="YES" deletionRule="Cascade" destinationEntity="Interval" inverseName="group" inverseEntity="Interval"/>
</entity>
<entity name="Record" representedClassName="Record" syncable="YES">
<attribute name="duration" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
<attribute name="end" attributeType="Date" defaultDateTimeInterval="696425400" usesScalarValueType="NO"/>
<attribute name="month" optional="YES" attributeType="Integer 16" usesScalarValueType="YES"/>
<attribute name="start" attributeType="Date" defaultDateTimeInterval="696425400" usesScalarValueType="NO"/>
<attribute name="year" optional="YES" attributeType="Integer 16" usesScalarValueType="YES"/>
<relationship name="activity" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Activity" inverseName="records" inverseEntity="Activity"/>
</entity>
<entity name="Stopwatch" representedClassName="Stopwatch" parentEntity="AbstractSoundTimer" syncable="YES">
<attribute name="end" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="sound" optional="YES" attributeType="Integer 16" usesScalarValueType="YES"/>
<attribute name="start" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
</entity>
</model>

@ -21,22 +21,11 @@ extension AbstractSoundTimer {
}
return []
}
// var playlists: [Playlist] {
// if let playlistList {
// return playlistList.enumItems()
// }
// return []
// }
func setSounds(_ sounds: Set<Sound>) {
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

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

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

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

@ -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<Record, NSNumber> {
// 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 {

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

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

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

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

Loading…
Cancel
Save