- Add VoiceOver labels, hints, and element grouping across all 60+ views - Add Reduce Motion support (Theme.Animation.prefersReducedMotion) to all animations - Replace fixed font sizes with semantic Dynamic Type styles - Hide decorative elements from VoiceOver with .accessibilityHidden(true) - Add .minimumHitTarget() modifier ensuring 44pt touch targets - Add AccessibilityAnnouncer utility for VoiceOver announcements - Improve color contrast values in Theme.swift for WCAG AA compliance - Extract CloudKitContainerConfig for explicit container identity - Remove PostHog debug console log from AnalyticsManager Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
384 lines
14 KiB
Swift
384 lines
14 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
|
|
@Environment(\.isDemoMode) private var isDemoMode
|
|
|
|
@State private var displayedMonth: Date = Date()
|
|
@State private var selectionState: SelectionState = .none
|
|
@State private var hasAppliedDemoSelection = false
|
|
|
|
enum SelectionState {
|
|
case none
|
|
case startSelected
|
|
case complete
|
|
}
|
|
|
|
private let calendar = Calendar.current
|
|
private let daysOfWeek = ["S", "M", "T", "W", "T", "F", "S"]
|
|
private let daysOfWeekFull = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]
|
|
|
|
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
|
|
}
|
|
|
|
// Demo mode: auto-select dates
|
|
if isDemoMode && !hasAppliedDemoSelection {
|
|
hasAppliedDemoSelection = true
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + DemoConfig.selectionDelay) {
|
|
Theme.Animation.withMotion(.easeInOut(duration: 0.3)) {
|
|
// Navigate to demo month
|
|
displayedMonth = DemoConfig.demoStartDate
|
|
}
|
|
}
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + DemoConfig.selectionDelay + 0.5) {
|
|
Theme.Animation.withMotion(.easeInOut(duration: 0.3)) {
|
|
startDate = DemoConfig.demoStartDate
|
|
endDate = DemoConfig.demoEndDate
|
|
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 {
|
|
Theme.Animation.withMotion(.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))
|
|
.accessibilityHidden(true)
|
|
|
|
// 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 {
|
|
Theme.Animation.withMotion(.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(minWidth: 44, minHeight: 44)
|
|
.background(Theme.warmOrange.opacity(0.15))
|
|
.clipShape(Circle())
|
|
}
|
|
.accessibilityLabel("Previous month")
|
|
.accessibilityIdentifier("wizard.dates.previousMonth")
|
|
|
|
Spacer()
|
|
|
|
Text(monthYearString)
|
|
.font(.headline)
|
|
.foregroundStyle(Theme.textPrimary(colorScheme))
|
|
.accessibilityIdentifier("wizard.dates.monthLabel")
|
|
|
|
Spacer()
|
|
|
|
Button {
|
|
Theme.Animation.withMotion(.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(minWidth: 44, minHeight: 44)
|
|
.background(Theme.warmOrange.opacity(0.15))
|
|
.clipShape(Circle())
|
|
}
|
|
.accessibilityLabel("Next month")
|
|
.accessibilityIdentifier("wizard.dates.nextMonth")
|
|
}
|
|
}
|
|
|
|
private var daysOfWeekHeader: some View {
|
|
HStack(spacing: 0) {
|
|
ForEach(Array(daysOfWeek.enumerated()), id: \.offset) { index, day in
|
|
Text(day)
|
|
.font(.caption)
|
|
.foregroundStyle(Theme.textMuted(colorScheme))
|
|
.frame(maxWidth: .infinity)
|
|
.accessibilityLabel(daysOfWeekFull[index])
|
|
}
|
|
}
|
|
}
|
|
|
|
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)
|
|
.accessibilityHidden(true)
|
|
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())
|
|
}
|
|
|
|
private var accessibilityId: String {
|
|
let formatter = DateFormatter()
|
|
formatter.dateFormat = "yyyy-MM-dd"
|
|
return "wizard.dates.day.\(formatter.string(from: 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(.subheadline)
|
|
.foregroundStyle(
|
|
isPast ? Theme.textMuted(colorScheme).opacity(0.5) :
|
|
(isStart || isEnd) ? .white :
|
|
isToday ? Theme.warmOrange :
|
|
Theme.textPrimary(colorScheme)
|
|
)
|
|
}
|
|
.frame(width: 36, height: 36)
|
|
}
|
|
}
|
|
.accessibilityIdentifier(accessibilityId)
|
|
.buttonStyle(.plain)
|
|
.disabled(isPast)
|
|
.frame(height: 40)
|
|
}
|
|
}
|
|
|
|
// MARK: - Preview
|
|
|
|
#Preview {
|
|
DateRangePicker(
|
|
startDate: .constant(Date()),
|
|
endDate: .constant(Date().addingTimeInterval(86400 * 7))
|
|
)
|
|
.padding()
|
|
}
|