refactor: remove legacy trip creation flow, extract shared components
- Delete TripCreationView.swift and TripCreationViewModel.swift (unused) - Extract TripOptionsView to standalone file - Extract DateRangePicker and DayCell to standalone file - Extract LocationSearchSheet and CityInputType to standalone file - Fix TripWizardView to pass games dictionary to TripOptionsView - Remove debug print statements from TripDetailView Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,328 @@
|
||||
//
|
||||
// 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
Reference in New Issue
Block a user