Adds filters to computations

release
Laurent 3 years ago
parent 55138d429f
commit a7ec081bd9
  1. 28
      LeCountdown.xcodeproj/project.pbxproj
  2. 2
      LeCountdown/Conductor.swift
  3. 65
      LeCountdown/Model/NSManagedContext+Extensions.swift
  4. 12
      LeCountdown/Model/Persistence.swift
  5. 85
      LeCountdown/Stats/Context+Calculations.swift
  6. 56
      LeCountdown/Stats/Filter.swift
  7. 78
      LeCountdown/Stats/Stat.swift
  8. 2
      LeCountdown/Utils/TimeInterval+Extensions.swift
  9. 2
      LeCountdown/Views/Components/GreenCheckmarkView.swift
  10. 40
      LeCountdown/Views/ContentView.swift
  11. 200
      LeCountdown/Views/LiveTimerListView.swift
  12. 42
      LeCountdown/Views/Stats/ActivitiesView.swift
  13. 60
      LeCountdown/Views/Stats/ActivityView.swift
  14. 25
      LeCountdown/Views/Stats/RecordsView.swift
  15. 76
      LeCountdown/Views/Stats/StatsView.swift

@ -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 = "<group>"; };
C4BA2B5A299FFAB000CB4FBA /* Logger.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Logger.swift; sourceTree = "<group>"; };
C4BA2B5E299FFC8300CB4FBA /* StoreView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreView.swift; sourceTree = "<group>"; };
C4BA2B6029A3C02400CB4FBA /* ActivityView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActivityView.swift; sourceTree = "<group>"; };
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>"; };
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>"; };
@ -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 = "<group>";
};
C4BA2B6629A3C49200CB4FBA /* Stats */ = {
isa = PBXGroup;
children = (
C4BA2B6729A3C4AC00CB4FBA /* Context+Calculations.swift */,
C4BA2B6429A3C37D00CB4FBA /* Filter.swift */,
C4BA2B6229A3C34600CB4FBA /* Stat.swift */,
);
path = Stats;
sourceTree = "<group>";
};
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 */,

@ -141,7 +141,7 @@ class Conductor: ObservableObject {
// self._endLiveActivity(countdownId: countdownId)
// }
self.removeLiveTimer(id: countdownId)
// self.removeLiveTimer(id: countdownId)
}
}

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

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

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

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

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

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

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

@ -67,6 +67,8 @@ struct ContentView<T : AbstractTimer>: 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<T : AbstractTimer>: 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<T : AbstractTimer>: 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<T : AbstractTimer>: View {
.onAppear {
// self._buildItemsList()
self._askPermissions()
self.showLiveTimersSheet = !conductor.liveTimers.isEmpty
}
.onOpenURL { url in
self._performActionIfPossible(url: url)

@ -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..<self._columnCount()).map { _ in GridItem(spacing: 20.0) }
}
}

@ -9,6 +9,8 @@ import SwiftUI
struct ActivitiesView: View {
@Environment(\.managedObjectContext) private var viewContext
@FetchRequest(
sortDescriptors: [NSSortDescriptor(keyPath: \Record.start, ascending: false)],
animation: .default)
@ -25,32 +27,22 @@ struct ActivitiesView: View {
if self.records.isEmpty {
Text("You don't have any recorded activity yet")
} else {
ScrollView {
LazyVStack {
ForEach(self.activities) { activity in
NavigationLink {
HStack {
StatsView(activity: activity).frame(width: 200)
.background(.red)
.foregroundColor(.white)
RecordsView(activity: activity)
.frame(maxWidth: .infinity)
.background(.green)
}
} label: {
HStack {
Text(activity.name ?? "no activity")
.foregroundColor(.black).font(.title)
Spacer()
Text(activity.recordCount).font(.system(.title, weight: .bold))
Image(systemName: "chevron.right").font(.body).foregroundColor(.gray)
}
}
List {
ForEach(self.activities) { activity in
NavigationLink {
ActivityView(activity: activity)
} label: {
HStack {
Text(activity.name ?? "no activity")
.foregroundColor(.black).font(.title)
Spacer()
Text(activity.recordCount)
.font(.system(.title, weight: .bold))
.foregroundColor(.accentColor)
}.padding(.vertical, 4.0)
}
Spacer()
}.padding(.horizontal)
}
}
}
}

@ -0,0 +1,60 @@
//
// ActivityView.swift
// LeCountdown
//
// Created by Laurent Morvillier on 20/02/2023.
//
import SwiftUI
fileprivate 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: View {
@Environment(\.managedObjectContext) private var viewContext
var activity: Activity
@State private var selectedTimeFrame: TimeFrame = .all
var body: some View {
VStack {
Picker("", selection: $selectedTimeFrame) {
ForEach(TimeFrame.allCases) { timeFrame in
Text(timeFrame.localizedString)
}
}.pickerStyle(.segmented)
StatsView(activity: self.activity)
.environment(\.managedObjectContext, viewContext)
.background(.red)
.foregroundColor(.white)
RecordsView(activity: self.activity)
.environment(\.managedObjectContext, viewContext)
.frame(maxWidth: .infinity)
}
}
}
struct ActivityView_Previews: PreviewProvider {
static var previews: some View {
ActivityView(activity: Activity.fake(context: PersistenceController.preview.container.viewContext))
}
}

@ -9,7 +9,16 @@ import SwiftUI
struct RecordsView: View {
@Environment(\.managedObjectContext) private var viewContext
var activity: Activity
var request: FetchRequest<Record>
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 {

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

Loading…
Cancel
Save