Two bugs fixed in "By Games" trip planning mode: 1. Calendar navigation: DateRangePicker now navigates to the selected game's month when startDate changes externally, instead of staying on the current month. 2. Date range calculation: Fixed race condition where date range was calculated before games were loaded. Now updateDateRangeForSelectedGames() is called after loadSummaryGames() completes. 3. Bonus games: planTrip() now uses the UI-selected 7-day date range instead of overriding it with just the anchor game dates. This allows ScenarioBPlanner to find additional games within the trip window. Added regression tests to verify gameFirst mode includes bonus games. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
348 lines
12 KiB
Swift
348 lines
12 KiB
Swift
//
|
|
// 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()
|
|
}
|