parent
74691184dc
commit
b8293b3674
@ -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) |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
@ -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) |
||||||
|
} |
||||||
|
|
||||||
|
} |
||||||
Loading…
Reference in new issue