[Mobile] 2.2.0 - Calendar View
This commit is contained in:
338
ios/Ascently/Views/CalendarView.swift
Normal file
338
ios/Ascently/Views/CalendarView.swift
Normal file
@@ -0,0 +1,338 @@
|
||||
import SwiftUI
|
||||
|
||||
struct CalendarView: View {
|
||||
@EnvironmentObject var dataManager: ClimbingDataManager
|
||||
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(.blue)
|
||||
}
|
||||
.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(.blue)
|
||||
}
|
||||
.frame(width: 44, height: 44)
|
||||
}
|
||||
|
||||
Button(action: {
|
||||
let today = Date()
|
||||
selectedMonth = today
|
||||
selectedDate = today
|
||||
}) {
|
||||
Text("Today")
|
||||
.font(.subheadline)
|
||||
.fontWeight(.semibold)
|
||||
.foregroundColor(.white)
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.vertical, 8)
|
||||
.background(Color.blue)
|
||||
.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
|
||||
|
||||
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
|
||||
? .white
|
||||
: isToday
|
||||
? .blue
|
||||
: !isInCurrentMonth
|
||||
? .secondary.opacity(0.3)
|
||||
: sessions.isEmpty ? .secondary : .primary
|
||||
)
|
||||
|
||||
if !sessions.isEmpty {
|
||||
Circle()
|
||||
.fill(isSelected ? .white : .blue)
|
||||
.frame(width: 4, height: 4)
|
||||
} else {
|
||||
Spacer()
|
||||
.frame(height: 4)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 50)
|
||||
.contentShape(Rectangle())
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 6)
|
||||
.fill(
|
||||
isSelected ? Color.blue : isToday ? Color.blue.opacity(0.1) : Color.clear
|
||||
)
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 6)
|
||||
.stroke(
|
||||
isToday && !isSelected ? Color.blue.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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user