341 lines
12 KiB
Swift
341 lines
12 KiB
Swift
import SwiftUI
|
|
|
|
struct CalendarView: View {
|
|
@EnvironmentObject var dataManager: ClimbingDataManager
|
|
@EnvironmentObject var themeManager: ThemeManager
|
|
let sessions: [ClimbSession]
|
|
@Binding var selectedMonth: Date
|
|
@Binding var selectedDate: Date?
|
|
let onNavigateToSession: (UUID) -> Void
|
|
|
|
var calendar: Calendar {
|
|
Calendar.current
|
|
}
|
|
|
|
var monthYearString: String {
|
|
let formatter = DateFormatter()
|
|
formatter.dateFormat = "MMMM yyyy"
|
|
return formatter.string(from: selectedMonth)
|
|
}
|
|
|
|
var sessionsByDate: [Date: [ClimbSession]] {
|
|
Dictionary(grouping: sessions) { session in
|
|
calendar.startOfDay(for: session.date)
|
|
}
|
|
}
|
|
|
|
var daysInMonth: [Date?] {
|
|
guard let monthInterval = calendar.dateInterval(of: .month, for: selectedMonth),
|
|
calendar.dateInterval(of: .weekOfMonth, for: monthInterval.start) != nil
|
|
else {
|
|
return []
|
|
}
|
|
|
|
let days = calendar.generateDates(
|
|
inside: monthInterval,
|
|
matching: DateComponents(hour: 0, minute: 0, second: 0)
|
|
)
|
|
|
|
let firstDayOfMonth = days.first ?? monthInterval.start
|
|
let firstWeekday = calendar.component(.weekday, from: firstDayOfMonth)
|
|
let offset = firstWeekday - 1
|
|
|
|
var paddedDays: [Date?] = Array(repeating: nil, count: offset)
|
|
paddedDays.append(contentsOf: days.map { $0 as Date? })
|
|
|
|
let remainder = paddedDays.count % 7
|
|
if remainder != 0 {
|
|
paddedDays.append(contentsOf: Array(repeating: nil, count: 7 - remainder))
|
|
}
|
|
|
|
return paddedDays
|
|
}
|
|
|
|
var body: some View {
|
|
ScrollView {
|
|
VStack(spacing: 0) {
|
|
if let activeSession = dataManager.activeSession,
|
|
let gym = dataManager.gym(withId: activeSession.gymId)
|
|
{
|
|
ActiveSessionBanner(session: activeSession, gym: gym)
|
|
.padding(.horizontal, 16)
|
|
.padding(.top, 8)
|
|
.padding(.bottom, 16)
|
|
}
|
|
|
|
VStack(spacing: 8) {
|
|
HStack {
|
|
Button(action: { changeMonth(by: -1) }) {
|
|
Image(systemName: "chevron.left")
|
|
.font(.title2)
|
|
.fontWeight(.semibold)
|
|
.foregroundColor(themeManager.accentColor)
|
|
}
|
|
.frame(width: 44, height: 44)
|
|
|
|
Spacer()
|
|
|
|
Text(monthYearString)
|
|
.font(.title3)
|
|
.fontWeight(.semibold)
|
|
|
|
Spacer()
|
|
|
|
Button(action: { changeMonth(by: 1) }) {
|
|
Image(systemName: "chevron.right")
|
|
.font(.title2)
|
|
.fontWeight(.semibold)
|
|
.foregroundColor(themeManager.accentColor)
|
|
}
|
|
.frame(width: 44, height: 44)
|
|
}
|
|
|
|
Button(action: {
|
|
let today = Date()
|
|
selectedMonth = today
|
|
selectedDate = today
|
|
}) {
|
|
Text("Today")
|
|
.font(.subheadline)
|
|
.fontWeight(.semibold)
|
|
.foregroundColor(themeManager.contrastingTextColor)
|
|
.padding(.horizontal, 20)
|
|
.padding(.vertical, 8)
|
|
.background(themeManager.accentColor)
|
|
.clipShape(Capsule())
|
|
}
|
|
}
|
|
.padding(.vertical, 16)
|
|
.padding(.horizontal)
|
|
|
|
HStack(spacing: 0) {
|
|
ForEach(["S", "M", "T", "W", "T", "F", "S"], id: \.self) { day in
|
|
Text(day)
|
|
.font(.caption2)
|
|
.fontWeight(.semibold)
|
|
.foregroundColor(.secondary)
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
}
|
|
.padding(.horizontal)
|
|
.padding(.bottom, 8)
|
|
|
|
LazyVGrid(
|
|
columns: Array(repeating: GridItem(.flexible(), spacing: 4), count: 7),
|
|
spacing: 4
|
|
) {
|
|
ForEach(daysInMonth.indices, id: \.self) { index in
|
|
if let date = daysInMonth[index] {
|
|
CalendarDayCell(
|
|
date: date,
|
|
sessions: sessionsByDate[calendar.startOfDay(for: date)] ?? [],
|
|
isSelected: selectedDate.map {
|
|
calendar.isDate($0, inSameDayAs: date)
|
|
}
|
|
?? false,
|
|
isToday: calendar.isDateInToday(date),
|
|
isInCurrentMonth: calendar.isDate(
|
|
date, equalTo: selectedMonth, toGranularity: .month)
|
|
) {
|
|
if !sessionsByDate[calendar.startOfDay(for: date), default: []]
|
|
.isEmpty
|
|
{
|
|
if selectedDate.map({ calendar.isDate($0, inSameDayAs: date) })
|
|
?? false
|
|
{
|
|
selectedDate = nil
|
|
} else {
|
|
selectedDate = date
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
Color.clear
|
|
.aspectRatio(1, contentMode: .fit)
|
|
}
|
|
}
|
|
}
|
|
.padding(.horizontal)
|
|
|
|
if let selected = selectedDate,
|
|
let sessionsOnDate = sessionsByDate[calendar.startOfDay(for: selected)],
|
|
!sessionsOnDate.isEmpty
|
|
{
|
|
Divider()
|
|
.padding(.vertical, 16)
|
|
.padding(.horizontal)
|
|
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
Text("Sessions on \(formatSelectedDate(selected))")
|
|
.font(.headline)
|
|
.fontWeight(.semibold)
|
|
.padding(.horizontal)
|
|
|
|
VStack(spacing: 12) {
|
|
ForEach(sessionsOnDate) { session in
|
|
SessionCard(
|
|
session: session,
|
|
onTap: {
|
|
onNavigateToSession(session.id)
|
|
}
|
|
)
|
|
.padding(.horizontal)
|
|
}
|
|
}
|
|
}
|
|
.padding(.bottom, 16)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func changeMonth(by value: Int) {
|
|
if let newMonth = calendar.date(byAdding: .month, value: value, to: selectedMonth) {
|
|
selectedMonth = newMonth
|
|
selectedDate = nil
|
|
}
|
|
}
|
|
|
|
func formatSelectedDate(_ date: Date) -> String {
|
|
let formatter = DateFormatter()
|
|
formatter.dateFormat = "MMMM d, yyyy"
|
|
return formatter.string(from: date)
|
|
}
|
|
}
|
|
|
|
struct CalendarDayCell: View {
|
|
let date: Date
|
|
let sessions: [ClimbSession]
|
|
let isSelected: Bool
|
|
let isToday: Bool
|
|
let isInCurrentMonth: Bool
|
|
let onTap: () -> Void
|
|
@EnvironmentObject var themeManager: ThemeManager
|
|
|
|
var dayNumber: String {
|
|
let formatter = DateFormatter()
|
|
formatter.dateFormat = "d"
|
|
return formatter.string(from: date)
|
|
}
|
|
|
|
var body: some View {
|
|
Button(action: onTap) {
|
|
VStack(spacing: 6) {
|
|
Text(dayNumber)
|
|
.font(.system(size: 17))
|
|
.fontWeight(sessions.isEmpty ? .regular : .medium)
|
|
.foregroundColor(
|
|
isSelected
|
|
? themeManager.contrastingTextColor
|
|
: isToday
|
|
? themeManager.accentColor
|
|
: !isInCurrentMonth
|
|
? .secondary.opacity(0.3)
|
|
: sessions.isEmpty ? .secondary : .primary
|
|
)
|
|
|
|
if !sessions.isEmpty {
|
|
Circle()
|
|
.fill(isSelected ? themeManager.contrastingTextColor : themeManager.accentColor)
|
|
.frame(width: 4, height: 4)
|
|
} else {
|
|
Spacer()
|
|
.frame(height: 4)
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.frame(height: 50)
|
|
.contentShape(Rectangle())
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 6)
|
|
.fill(
|
|
isSelected ? themeManager.accentColor : isToday ? themeManager.accentColor.opacity(0.1) : Color.clear
|
|
)
|
|
)
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 6)
|
|
.stroke(
|
|
isToday && !isSelected ? themeManager.accentColor.opacity(0.3) : Color.clear, lineWidth: 1
|
|
)
|
|
)
|
|
}
|
|
.buttonStyle(PlainButtonStyle())
|
|
.disabled(sessions.isEmpty)
|
|
}
|
|
}
|
|
|
|
struct SessionCard: View {
|
|
@EnvironmentObject var dataManager: ClimbingDataManager
|
|
let session: ClimbSession
|
|
let onTap: () -> Void
|
|
|
|
var gym: Gym? {
|
|
dataManager.gym(withId: session.gymId)
|
|
}
|
|
|
|
var body: some View {
|
|
HStack(spacing: 12) {
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
Text(gym?.name ?? "Unknown Gym")
|
|
.font(.body)
|
|
.fontWeight(.semibold)
|
|
.foregroundColor(.primary)
|
|
|
|
if let duration = session.duration {
|
|
Text("Duration: \(duration) minutes")
|
|
.font(.subheadline)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
|
|
if let notes = session.notes, !notes.isEmpty {
|
|
Text(notes)
|
|
.font(.subheadline)
|
|
.foregroundColor(.secondary)
|
|
.lineLimit(2)
|
|
}
|
|
}
|
|
|
|
Spacer()
|
|
|
|
Image(systemName: "chevron.right")
|
|
.font(.body)
|
|
.fontWeight(.semibold)
|
|
.foregroundColor(Color(.tertiaryLabel))
|
|
}
|
|
.padding(16)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 10)
|
|
.fill(Color(.secondarySystemGroupedBackground))
|
|
)
|
|
.onTapGesture {
|
|
onTap()
|
|
}
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|