Calendar view for activity

main
Laurent 3 years ago
parent 74691184dc
commit b8293b3674
  1. 20
      LeCountdown.xcodeproj/project.pbxproj
  2. 22
      LeCountdown/Conductor.swift
  3. 9
      LeCountdown/LeCountdownApp.swift
  4. 38
      LeCountdown/Model/CoreDataRequests.swift
  5. 19
      LeCountdown/Model/NSManagedContext+Extensions.swift
  6. 14
      LeCountdown/Model/Persistence.swift
  7. 4
      LeCountdown/Stats/Filter.swift
  8. 14
      LeCountdown/Utils/Date+Extensions.swift
  9. 132
      LeCountdown/Views/Reusable/CalendarView.swift
  10. 17
      LeCountdown/Views/Stats/ActivitiesView.swift
  11. 111
      LeCountdown/Views/Stats/ActivityCalendarView.swift
  12. 36
      LeCountdown/Views/Stats/ActivityView.swift

@ -39,6 +39,9 @@
C415D3FB29C37A460037B215 /* QP01 0096 Wetland lake early morning.wav in Resources */ = {isa = PBXBuildFile; fileRef = C415D3FA29C37A460037B215 /* QP01 0096 Wetland lake early morning.wav */; };
C415D3FD29C37AA40037B215 /* QP01 0118 Riparian Zone thrush.wav in Resources */ = {isa = PBXBuildFile; fileRef = C415D3FC29C37AA40037B215 /* QP01 0118 Riparian Zone thrush.wav */; };
C42E96FB29E59E72005B1B8C /* BackgroundBlurView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C42E96FA29E59E72005B1B8C /* BackgroundBlurView.swift */; };
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 */; };
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 */; };
@ -363,6 +366,8 @@
C415D3FC29C37AA40037B215 /* QP01 0118 Riparian Zone thrush.wav */ = {isa = PBXFileReference; lastKnownFileType = audio.wav; path = "QP01 0118 Riparian Zone thrush.wav"; sourceTree = "<group>"; };
C418A14F298428CB00C22230 /* LeCountdown.0.1.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = LeCountdown.0.1.xcdatamodel; sourceTree = "<group>"; };
C42E96FA29E59E72005B1B8C /* BackgroundBlurView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundBlurView.swift; sourceTree = "<group>"; };
C42E96FC29E5B06D005B1B8C /* ActivityCalendarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActivityCalendarView.swift; sourceTree = "<group>"; };
C42E970129E6B32B005B1B8C /* CalendarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarView.swift; sourceTree = "<group>"; };
C438C7C02980228B00BF3EF9 /* CountdownScheduler.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CountdownScheduler.swift; sourceTree = "<group>"; };
C438C7C4298024E900BF3EF9 /* NSManagedContext+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSManagedContext+Extensions.swift"; sourceTree = "<group>"; };
C438C7C829803CA000BF3EF9 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
@ -663,6 +668,13 @@
path = Nature;
sourceTree = "<group>";
};
C42E970329E6DDF1005B1B8C /* Utils */ = {
isa = PBXGroup;
children = (
);
path = Utils;
sourceTree = "<group>";
};
C438C7CF2981216200BF3EF9 /* Frameworks */ = {
isa = PBXGroup;
children = (
@ -751,6 +763,7 @@
C438C80B2981DE2E00BF3EF9 /* Views */ = {
isa = PBXGroup;
children = (
C42E970329E6DDF1005B1B8C /* Utils */,
C4F8B1D3298BF686005C86A5 /* Reusable */,
C4F8B1BA298AC83F005C86A5 /* Alarm */,
C4F8B1B9298AC830005C86A5 /* Countdown */,
@ -820,9 +833,10 @@
isa = PBXGroup;
children = (
C438C80E29828B8600BF3EF9 /* ActivitiesView.swift */,
C42E96FC29E5B06D005B1B8C /* ActivityCalendarView.swift */,
C4BA2B6029A3C02400CB4FBA /* ActivityView.swift */,
C4BA2B42299FCB2B00CB4FBA /* RecordsView.swift */,
C4BA2B3D299FC86800CB4FBA /* GraphsView.swift */,
C4BA2B42299FCB2B00CB4FBA /* RecordsView.swift */,
);
path = Stats;
sourceTree = "<group>";
@ -919,6 +933,7 @@
isa = PBXGroup;
children = (
C42E96FA29E59E72005B1B8C /* BackgroundBlurView.swift */,
C42E970129E6B32B005B1B8C /* CalendarView.swift */,
C498E5A4299152B400E90DE0 /* GreenCheckmarkView.swift */,
C4742B5A298414B000D5D950 /* ImageSelectionView.swift */,
C415D3E129C0C0C20037B215 /* MailView.swift */,
@ -1247,6 +1262,7 @@
C4F8B1A7298AC2FC005C86A5 /* AbstractSoundTimer+CoreDataClass.swift in Sources */,
C438C7C929803CA000BF3EF9 /* AppDelegate.swift in Sources */,
C4556F6F29E40BED00DEB40B /* FileUtils.swift in Sources */,
C42E96FD29E5B06D005B1B8C /* ActivityCalendarView.swift in Sources */,
C4BA2B2D299E2DEE00CB4FBA /* Preferences.swift in Sources */,
C4F8B185298AC234005C86A5 /* AbstractTimer+CoreDataProperties.swift in Sources */,
C4F8B169298AA236005C86A5 /* StopwatchFormView.swift in Sources */,
@ -1262,6 +1278,7 @@
C4F8B17D298AC234005C86A5 /* Record+CoreDataProperties.swift in Sources */,
C4F8B184298AC234005C86A5 /* AbstractTimer+CoreDataClass.swift in Sources */,
C4F8B17A298AC234005C86A5 /* Countdown+CoreDataClass.swift in Sources */,
C42E970229E6B32B005B1B8C /* CalendarView.swift in Sources */,
C4BA2B0F299BE61E00CB4FBA /* Interval+CoreDataClass.swift in Sources */,
C4F8B164298A9A92005C86A5 /* AlarmFormView.swift in Sources */,
C40FDB622992985C0042A390 /* TextToSpeechRecorder.swift in Sources */,
@ -1391,6 +1408,7 @@
C438C802298132B900BF3EF9 /* LeCountdown.xcdatamodeld in Sources */,
C4F8B19B298AC288005C86A5 /* Stopwatch+CoreDataClass.swift in Sources */,
C4F8B1A0298AC288005C86A5 /* Activity+CoreDataProperties.swift in Sources */,
C42E96FE29E5B5CD005B1B8C /* Filter.swift in Sources */,
C438C81A2982BFF100BF3EF9 /* NSManagedContext+Extensions.swift in Sources */,
C4F8B19D298AC288005C86A5 /* AbstractTimer+CoreDataClass.swift in Sources */,
C4BA2B3C299F838000CB4FBA /* Model+SharedExtensions.swift in Sources */,

@ -137,8 +137,9 @@ class Conductor: ObservableObject {
DispatchQueue.main.async {
do {
let date = Date(timeIntervalSinceNow: countdown.duration)
FileLogger.log("schedule countdown \(countdown.stringId) at \(date)")
let start = Date()
let end = start.addingTimeInterval(countdown.duration)
FileLogger.log("schedule countdown \(countdown.stringId) at \(end)")
self.removeLiveTimer(id: countdown.stringId)
@ -152,15 +153,15 @@ class Conductor: ObservableObject {
try soundPlayer.start(in: countdown.duration,
repeatCount: Int(countdown.repeatCount))
let dateInterval = DateInterval(start: Date(), end: date)
let dateInterval = DateInterval(start: start, end: end)
self.currentCountdowns[countdown.stringId] = dateInterval
if Preferences.playConfirmationSound {
self._playConfirmationSound(timer: countdown)
}
self._launchLiveActivity(timer: countdown, date: date)
self._launchLiveActivity(timer: countdown, date: end)
handler(.success(date))
handler(.success(end))
} catch {
FileLogger.log("start error : \(error.localizedDescription)")
Logger.error(error)
@ -186,9 +187,11 @@ class Conductor: ObservableObject {
fileprivate func _endCountdown(countdownId: String, cancel: Bool) {
DispatchQueue.main.async {
#if DEBUG
if !cancel {
self._recordActivity(countdownId: countdownId)
}
#endif
self.currentCountdowns.removeValue(forKey: countdownId)
}
}
@ -197,11 +200,7 @@ class Conductor: ObservableObject {
func startStopwatch(_ stopwatch: Stopwatch) {
DispatchQueue.main.async {
let lsw = LiveStopWatch(start: Date())
// if let liveTimer = liveTimers.first(where: { $0.id == stopwatch.stringId }) {
// liveTimer.
// }
let lsw: LiveStopWatch = LiveStopWatch(start: Date())
self.currentStopwatches[stopwatch.stringId] = lsw
@ -211,9 +210,6 @@ class Conductor: ObservableObject {
self._endLiveActivity(timerId: stopwatch.stringId)
self._launchLiveActivity(timer: stopwatch, date: lsw.start)
// self._createTimerIntent(stopwatch)
}
}

@ -27,6 +27,15 @@ struct LeCountdownApp: App {
Logger.log("path = \(Bundle.main.bundlePath)")
do {
let records = try persistenceController.container.viewContext.fetch(Record.fetchRequest())
for record in records {
Logger.log("duration = \(record.duration)")
}
} catch {
}
self._registerBackgroundRefreshes()
// if !ProcessInfo.processInfo.isiOSAppOnMac {

@ -58,4 +58,42 @@ class CoreDataRequests {
try context.save()
}
static func months(context: NSManagedObjectContext, activity: Activity) throws -> [Month] {
let predicate: NSPredicate = NSPredicate(format: "activity = %@", activity)
let distinct = try context.distinct(entityName: "Record", attributes: ["year", "month"], predicate: predicate)
if let distinctMonths = distinct as? [[String : Int]] {
return 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
}
}
} else {
Logger.w("Could not cast \(distinct) as [Int]")
return []
}
}
static func oldestDate(context: NSManagedObjectContext, activity: Activity) -> Date? {
let request = Record.fetchRequest()
request.sortDescriptors = [NSSortDescriptor(key: "start", ascending: true)]
request.predicate = NSPredicate(format: "activity = %@", activity)
do {
let records: [Record] = try context.fetch(request)
return records.first?.start
} catch {
Logger.error(error)
}
return nil
}
}

@ -17,7 +17,7 @@ extension NSManagedObjectContext {
}
func distinct(entityName: String, attributes: [String], predicate: NSPredicate? = nil) throws -> [Any] {
let request = NSFetchRequest<NSFetchRequestResult>(entityName: entityName)
request.entity = NSEntityDescription.entity(forEntityName: entityName, in: self)
@ -47,6 +47,19 @@ extension NSManagedObjectContext {
}
}
// public func fetch<T>(_ request: NSFetchRequest<T>) throws -> [T] where T : NSFetchRequestResult
func fetch<T>(entityName: String, predicate: NSPredicate) -> [T] where T : NSFetchRequestResult {
let request = NSFetchRequest<T>(entityName: entityName)
request.predicate = predicate
do {
return try self.fetch(request)
} catch {
return []
}
}
}
extension NSManagedObject {
@ -58,5 +71,9 @@ extension NSManagedObject {
var stringId: String {
return self.objectID.uriRepresentation().absoluteString
}
static var entityName: String {
return self.entity().managedObjectClassName
}
}

@ -31,10 +31,18 @@ struct PersistenceController {
countdown.image = CoolPic.pic1.rawValue
}
for i in 0..<14 {
for i in 0..<20 {
let record = Record(context: viewContext)
record.start = Date()
record.end = Date()
let randomMonth = (0...10 * 31).randomElement() ?? 3
let monthTimeInterval = Double(randomMonth) * 3600 * 24
let start = Date().addingTimeInterval(-monthTimeInterval)
let duration = Double((1...10).randomElement() ?? 5) * 60.0
let end = start.addingTimeInterval(duration)
record.start = start
record.end = end
record.activity = activities.randomElement()
}

@ -55,11 +55,13 @@ fileprivate extension Int {
}
struct Month {
struct Month: Identifiable {
var month: Int
var year: Int
var id: Int { return self.year * 1000 + month }
var start: NSDate {
let components: DateComponents = DateComponents(year: self.year, month: self.month)
if let date = Calendar.current.date(from: components) {

@ -7,6 +7,12 @@
import Foundation
extension Date: Identifiable {
public var id: TimeInterval { return self.timeIntervalSince1970 }
}
extension Date {
static let monthYearFormatter = {
@ -33,6 +39,12 @@ extension Date {
return Calendar.current.startOfDay(for: self)
}
var startOfMonth: Date {
let calendar = Calendar(identifier: .gregorian)
let components = calendar.dateComponents([.year, .month], from: self)
return calendar.date(from: components)!
}
func get(_ component: Calendar.Component, calendar: Calendar = Calendar.current) -> Int {
return calendar.component(component, from: self)
}
@ -41,7 +53,7 @@ extension Date {
var month: Int { self.get(.month) }
var formattedYear: String { return "\(self.year)" }
var formattedMonth: String { return Date.monthYearFormatter.string(from: self) }
var formattedMonth: String { return Date.monthYearFormatter.string(from: self).capitalized }
var formattedDay: String { return Date.dayFormatter.string(from: self) }
var formattedDateTime: String { return Date.dateTimeFormatter.string(from: self) }

@ -0,0 +1,132 @@
//
// CalendarView.swift
// LeCountdown
//
// Created by Laurent Morvillier on 12/04/2023.
//
import SwiftUI
fileprivate extension DateFormatter {
static var month: DateFormatter {
let formatter = DateFormatter()
formatter.dateFormat = "MMMM"
return formatter
}
static var monthAndYear: DateFormatter {
let formatter = DateFormatter()
formatter.dateFormat = "MMMM yyyy"
return formatter
}
}
fileprivate extension Calendar {
func generateDates(
inside interval: DateInterval,
matching components: DateComponents
) -> [Date] {
var dates: [Date] = []
dates.append(interval.start)
enumerateDates(
startingAfter: interval.start,
matching: components,
matchingPolicy: .nextTime
) { date, _, stop in
if let date = date {
if date < interval.end {
dates.append(date)
} else {
stop = true
}
}
}
return dates
}
}
struct CalendarView<DateView>: View where DateView: View {
let calendar: Calendar = Calendar.current
let interval: DateInterval
let showHeaders: Bool
let content: (Date) -> DateView
init(
interval: DateInterval,
showHeaders: Bool = true,
@ViewBuilder content: @escaping (Date) -> DateView
) {
self.interval = interval
self.showHeaders = showHeaders
self.content = content
self.months = calendar.generateDates(
inside: interval,
matching: DateComponents(day: 1, hour: 0, minute: 0, second: 0)
)
for month in self.months {
if showHeaders {
self.monthHeaders += [month.formattedMonth]
}
self.monthToDays[month] = self.days(for: month)
}
}
var body: some View {
LazyVGrid(columns: Array(repeating: GridItem(), count: 7)) {
ForEach(Array(months.enumerated().reversed()), id: \.offset) { (index,month) in
Section(header: self.headerView(for: monthHeaders[index])) {
if let days = self.monthToDays[month] {
ForEach(days, id: \.self) { date in
if calendar.isDate(date, equalTo: month, toGranularity: .month) {
content(date).id(date)
} else {
content(date).hidden()
}
}
}
}
}
}
}
private var months = [Date]()
private var monthHeaders = [String]()
private var monthToDays = [Date: [Date]]()
// Important: dont add padding or any frame modifications to the headerView or you will get performance issues
private func headerView(for month:String) -> some View {
Text(month).font(.title)
}
private func header(for month: Date) -> String {
let component = calendar.component(.month, from: month)
let formatter = component == 1 ? DateFormatter.monthAndYear : .month
return formatter.string(from: month)
}
private func days(for month: Date) -> [Date] {
guard
let monthInterval = calendar.dateInterval(of: .month, for: month),
let monthFirstWeek = calendar.dateInterval(of: .weekOfMonth, for: monthInterval.start),
let monthLastWeek = calendar.dateInterval(of: .weekOfMonth, for: monthInterval.end)
else { return [] }
return calendar.generateDates(
inside: DateInterval(start: monthFirstWeek.start, end: monthLastWeek.end),
matching: DateComponents(hour: 0, minute: 0, second: 0)
)
}
}
struct CalendarView_Previews: PreviewProvider {
static var previews: some View {
CalendarView(interval: DateInterval(start: Date(timeIntervalSinceNow: -2 * 31 * 24 * 3600), end: Date())) { date in
Text(date.get(.day).formatted())
.padding(8)
}
}
}

@ -31,14 +31,19 @@ struct ActivitiesView: View {
List {
ForEach(self.activities) { activity in
HStack {
Text(activity.name ?? "no activity")
Spacer()
Text(activity.recordCount)
.font(.system(.body, weight: .bold))
.foregroundColor(.accentColor)
NavigationLink {
ActivityCalendarView(activity: activity)
} label: {
HStack {
Text(activity.name ?? "no activity")
Spacer()
Text(activity.recordCount)
.font(.system(.body, weight: .bold))
.foregroundColor(.accentColor)
}
}
// NavigationLink {
// ActivityView(activity: activity)
// .environment(\.managedObjectContext, viewContext)

@ -0,0 +1,111 @@
//
// TestActivityView.swift
// LeCountdown
//
// Created by Laurent Morvillier on 11/04/2023.
//
import SwiftUI
struct MonthView: View {
var date: Date
fileprivate var days: [String] = Calendar.current.shortWeekdaySymbols
init(date: Date) {
self.date = date
// put Sunday at the end
self.days.append(self.days.removeFirst())
}
var body: some View {
Text(self.date.formattedMonth).font(.title)
HStack {
ForEach(0..<7) { day in
Text(self.days[day]).frame(maxWidth: .infinity)
}
}
}
}
struct ActivityCalendarView: View {
@Environment(\.calendar) var calendar
var activity: Activity
@State var monthlyDates: [Date] = []
@State var oldestDate: Date? = nil
fileprivate let today = Date()
@Environment(\.managedObjectContext) private var viewContext
@State var recordsDate: [Date] = []
var body: some View {
ScrollView {
if let oldestDate {
CalendarView(interval: DateInterval(start: oldestDate, end: today)) { date in
ZStack {
if self._hasRecord(date: date) {
Circle().fill(Color.green).brightness(0.5)
}
Text(date.get(.day).formatted())
}.padding(6.0)
}
}
}
.navigationTitle(activity.name ?? "Activity")
.onAppear {
self._load()
}
}
fileprivate func _load() {
self.oldestDate = CoreDataRequests.oldestDate(context: self.viewContext, activity: self.activity)
let predicate = NSPredicate(format: "activity = %@", self.activity)
let records: [Record] = self.viewContext.fetch(entityName: Record.entityName, predicate: predicate)
self.recordsDate = records.compactMap { $0.start }
}
fileprivate func _hasRecord(date: Date) -> Bool {
self.recordsDate.contains { rd in
self.calendar.isDate(rd, inSameDayAs: date)
}
}
}
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)
.environment(\.managedObjectContext, PersistenceController.preview.container.viewContext)
}
}

@ -76,22 +76,26 @@ enum TimeFrame: Int, Identifiable, CaseIterable {
Logger.w("Could not cast \(distinct) as [Int]")
}
case .month:
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]")
}
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)

Loading…
Cancel
Save