// // DateRangePicker.swift // SportsTime // // Extracted from TripCreationView - reusable date range picker component. // import SwiftUI struct DateRangePicker: View { @Binding var startDate: Date @Binding var endDate: Date @Environment(\.colorScheme) private var colorScheme @State private var displayedMonth: Date = Date() @State private var selectionState: SelectionState = .none enum SelectionState { case none case startSelected case complete } private let calendar = Calendar.current private let daysOfWeek = ["S", "M", "T", "W", "T", "F", "S"] private var monthYearString: String { let formatter = DateFormatter() formatter.dateFormat = "MMMM yyyy" return formatter.string(from: displayedMonth) } private var daysInMonth: [Date?] { guard let monthInterval = calendar.dateInterval(of: .month, for: displayedMonth), let monthFirstWeek = calendar.dateInterval(of: .weekOfMonth, for: monthInterval.start) else { return [] } var days: [Date?] = [] let startOfMonth = monthInterval.start let endOfMonth = calendar.date(byAdding: .day, value: -1, to: monthInterval.end)! // Get the first day of the week containing the first day of the month var currentDate = monthFirstWeek.start // Add days until we've covered the month while currentDate <= endOfMonth || days.count % 7 != 0 { if currentDate >= startOfMonth && currentDate <= endOfMonth { days.append(currentDate) } else if currentDate < startOfMonth { days.append(nil) // Placeholder for days before month starts } else if days.count % 7 != 0 { days.append(nil) // Placeholder to complete the last week } else { break } currentDate = calendar.date(byAdding: .day, value: 1, to: currentDate)! } return days } private var tripDuration: Int { let components = calendar.dateComponents([.day], from: startDate, to: endDate) return (components.day ?? 0) + 1 } var body: some View { VStack(spacing: Theme.Spacing.md) { // Selected range summary selectedRangeSummary // Month navigation monthNavigation // Days of week header daysOfWeekHeader // Calendar grid calendarGrid // Trip duration tripDurationBadge } .onAppear { // Initialize displayed month to show the start date's month displayedMonth = calendar.startOfDay(for: startDate) // If dates are already selected (endDate > startDate), show complete state if endDate > startDate { selectionState = .complete } } .onChange(of: startDate) { oldValue, newValue in // Navigate calendar to show the new month when startDate changes externally // (e.g., when user selects a game in GamePickerStep) let oldMonth = calendar.component(.month, from: oldValue) let newMonth = calendar.component(.month, from: newValue) let oldYear = calendar.component(.year, from: oldValue) let newYear = calendar.component(.year, from: newValue) if oldMonth != newMonth || oldYear != newYear { withAnimation(.easeInOut(duration: 0.2)) { displayedMonth = calendar.startOfDay(for: newValue) } } // Also update selection state to complete if we have a valid range if endDate > newValue { selectionState = .complete } } } private var selectedRangeSummary: some View { HStack(spacing: Theme.Spacing.md) { // Start date VStack(alignment: .leading, spacing: 4) { Text("START") .font(.caption2) .foregroundStyle(Theme.textMuted(colorScheme)) Text(startDate.formatted(.dateTime.month(.abbreviated).day().year())) .font(.body) .foregroundStyle(Theme.warmOrange) } .frame(maxWidth: .infinity, alignment: .leading) // Arrow Image(systemName: "arrow.right") .font(.subheadline) .foregroundStyle(Theme.textMuted(colorScheme)) // End date VStack(alignment: .trailing, spacing: 4) { Text("END") .font(.caption2) .foregroundStyle(Theme.textMuted(colorScheme)) Text(endDate.formatted(.dateTime.month(.abbreviated).day().year())) .font(.body) .foregroundStyle(Theme.warmOrange) } .frame(maxWidth: .infinity, alignment: .trailing) } .padding(Theme.Spacing.md) .background(Theme.cardBackgroundElevated(colorScheme)) .clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)) } private var monthNavigation: some View { HStack { Button { withAnimation(.easeInOut(duration: 0.2)) { displayedMonth = calendar.date(byAdding: .month, value: -1, to: displayedMonth) ?? displayedMonth } } label: { Image(systemName: "chevron.left") .font(.body) .foregroundStyle(Theme.warmOrange) .frame(width: 36, height: 36) .background(Theme.warmOrange.opacity(0.15)) .clipShape(Circle()) } Spacer() Text(monthYearString) .font(.headline) .foregroundStyle(Theme.textPrimary(colorScheme)) Spacer() Button { withAnimation(.easeInOut(duration: 0.2)) { displayedMonth = calendar.date(byAdding: .month, value: 1, to: displayedMonth) ?? displayedMonth } } label: { Image(systemName: "chevron.right") .font(.body) .foregroundStyle(Theme.warmOrange) .frame(width: 36, height: 36) .background(Theme.warmOrange.opacity(0.15)) .clipShape(Circle()) } } } private var daysOfWeekHeader: some View { HStack(spacing: 0) { ForEach(Array(daysOfWeek.enumerated()), id: \.offset) { _, day in Text(day) .font(.caption) .foregroundStyle(Theme.textMuted(colorScheme)) .frame(maxWidth: .infinity) } } } private var calendarGrid: some View { let columns = Array(repeating: GridItem(.flexible(), spacing: 4), count: 7) return LazyVGrid(columns: columns, spacing: 4) { ForEach(Array(daysInMonth.enumerated()), id: \.offset) { _, date in if let date = date { DayCell( date: date, isStart: calendar.isDate(date, inSameDayAs: startDate), isEnd: calendar.isDate(date, inSameDayAs: endDate), isInRange: isDateInRange(date), isToday: calendar.isDateInToday(date), onTap: { handleDateTap(date) } ) } else { Color.clear .frame(height: 40) } } } } private var tripDurationBadge: some View { HStack(spacing: Theme.Spacing.xs) { Image(systemName: "calendar.badge.clock") .foregroundStyle(Theme.warmOrange) Text("\(tripDuration) day\(tripDuration == 1 ? "" : "s")") .font(.subheadline) .foregroundStyle(Theme.textSecondary(colorScheme)) } .frame(maxWidth: .infinity, alignment: .center) .padding(.top, Theme.Spacing.xs) } private func isDateInRange(_ date: Date) -> Bool { let start = calendar.startOfDay(for: startDate) let end = calendar.startOfDay(for: endDate) let current = calendar.startOfDay(for: date) return current > start && current < end } private func handleDateTap(_ date: Date) { let today = calendar.startOfDay(for: Date()) let tappedDate = calendar.startOfDay(for: date) // Don't allow selecting dates in the past if tappedDate < today { return } switch selectionState { case .none, .complete: // First tap: set start date, reset end to same day startDate = date endDate = date selectionState = .startSelected case .startSelected: // Second tap: set end date (if after start) if date >= startDate { endDate = date } else { // If tapped date is before start, make it the new start endDate = startDate startDate = date } selectionState = .complete } } } // MARK: - Day Cell struct DayCell: View { let date: Date let isStart: Bool let isEnd: Bool let isInRange: Bool let isToday: Bool let onTap: () -> Void @Environment(\.colorScheme) private var colorScheme private let calendar = Calendar.current private var dayNumber: String { "\(calendar.component(.day, from: date))" } private var isPast: Bool { calendar.startOfDay(for: date) < calendar.startOfDay(for: Date()) } var body: some View { Button(action: onTap) { ZStack { // Range highlight background (stretches edge to edge) if isInRange || isStart || isEnd { HStack(spacing: 0) { Rectangle() .fill(Theme.warmOrange.opacity(0.15)) .frame(maxWidth: .infinity) .opacity(isStart && !isEnd ? 0 : 1) .offset(x: isStart ? 20 : 0) Rectangle() .fill(Theme.warmOrange.opacity(0.15)) .frame(maxWidth: .infinity) .opacity(isEnd && !isStart ? 0 : 1) .offset(x: isEnd ? -20 : 0) } .opacity(isStart && isEnd ? 0 : 1) // Hide when start == end } // Day circle ZStack { if isStart || isEnd { Circle() .fill(Theme.warmOrange) } else if isToday { Circle() .stroke(Theme.warmOrange, lineWidth: 2) } Text(dayNumber) .font(.system(size: 14, weight: (isStart || isEnd) ? .bold : .medium)) .foregroundStyle( isPast ? Theme.textMuted(colorScheme).opacity(0.5) : (isStart || isEnd) ? .white : isToday ? Theme.warmOrange : Theme.textPrimary(colorScheme) ) } .frame(width: 36, height: 36) } } .buttonStyle(.plain) .disabled(isPast) .frame(height: 40) } } // MARK: - Preview #Preview { DateRangePicker( startDate: .constant(Date()), endDate: .constant(Date().addingTimeInterval(86400 * 7)) ) .padding() }