// // ReportDateRangePicker.swift // Reflect // // Calendar-based date range picker for AI mood reports. // Ported from SportsTime DateRangePicker with Reflect theming. // import SwiftUI struct ReportDateRangePicker: View { @Binding var startDate: Date @Binding var endDate: Date @AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system @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 var textColor: Color { theme.currentTheme.labelColor } 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 guard let endOfMonth = calendar.date(byAdding: .day, value: -1, to: monthInterval.end) else { return [] } var currentDate = monthFirstWeek.start while currentDate <= endOfMonth || days.count % 7 != 0 { if currentDate >= startOfMonth && currentDate <= endOfMonth { days.append(currentDate) } else if currentDate < startOfMonth { days.append(nil) } else if days.count % 7 != 0 { days.append(nil) } else { break } guard let nextDate = calendar.date(byAdding: .day, value: 1, to: currentDate) else { break } currentDate = nextDate } return days } private var selectedDayCount: Int { let components = calendar.dateComponents([.day], from: startDate, to: endDate) return (components.day ?? 0) + 1 } var body: some View { VStack(spacing: 16) { selectedRangeSummary monthNavigation daysOfWeekHeader calendarGrid dayCountBadge } .accessibilityIdentifier(AccessibilityID.Reports.dateRangePicker) .onAppear { displayedMonth = calendar.startOfDay(for: startDate) if endDate > startDate { selectionState = .complete } } } // MARK: - Selected Range Summary private var selectedRangeSummary: some View { HStack(spacing: 16) { VStack(alignment: .leading, spacing: 4) { Text("START") .font(.caption2) .foregroundStyle(textColor.opacity(0.5)) Text(startDate.formatted(.dateTime.month(.abbreviated).day().year())) .font(.body) .foregroundColor(.accentColor) } .frame(maxWidth: .infinity, alignment: .leading) Image(systemName: "arrow.right") .font(.subheadline) .foregroundStyle(textColor.opacity(0.5)) .accessibilityHidden(true) VStack(alignment: .trailing, spacing: 4) { Text("END") .font(.caption2) .foregroundStyle(textColor.opacity(0.5)) Text(endDate.formatted(.dateTime.month(.abbreviated).day().year())) .font(.body) .foregroundColor(.accentColor) } .frame(maxWidth: .infinity, alignment: .trailing) } .padding() .background( RoundedRectangle(cornerRadius: 12) .fill(colorScheme == .dark ? Color(.systemGray6) : .white) ) } // MARK: - Month Navigation private var monthNavigation: some View { HStack { Button { if UIAccessibility.isReduceMotionEnabled { displayedMonth = calendar.date(byAdding: .month, value: -1, to: displayedMonth) ?? displayedMonth } else { withAnimation(.easeInOut(duration: 0.2)) { displayedMonth = calendar.date(byAdding: .month, value: -1, to: displayedMonth) ?? displayedMonth } } } label: { Image(systemName: "chevron.left") .font(.body) .foregroundColor(.accentColor) .frame(minWidth: 44, minHeight: 44) .background(Color.accentColor.opacity(0.15)) .clipShape(Circle()) } .accessibilityIdentifier(AccessibilityID.Reports.previousMonthButton) .accessibilityLabel("Previous month") Spacer() Text(monthYearString) .font(.headline) .foregroundStyle(textColor) Spacer() Button { if UIAccessibility.isReduceMotionEnabled { displayedMonth = calendar.date(byAdding: .month, value: 1, to: displayedMonth) ?? displayedMonth } else { withAnimation(.easeInOut(duration: 0.2)) { displayedMonth = calendar.date(byAdding: .month, value: 1, to: displayedMonth) ?? displayedMonth } } } label: { Image(systemName: "chevron.right") .font(.body) .foregroundColor(.accentColor) .frame(minWidth: 44, minHeight: 44) .background(Color.accentColor.opacity(0.15)) .clipShape(Circle()) } .accessibilityIdentifier(AccessibilityID.Reports.nextMonthButton) .accessibilityLabel("Next month") .disabled(isDisplayingCurrentMonth) } } private var isDisplayingCurrentMonth: Bool { let now = Date() return calendar.component(.month, from: displayedMonth) == calendar.component(.month, from: now) && calendar.component(.year, from: displayedMonth) == calendar.component(.year, from: now) } // MARK: - Days of Week Header private var daysOfWeekHeader: some View { HStack(spacing: 0) { ForEach(Array(daysOfWeek.enumerated()), id: \.offset) { index, day in Text(day) .font(.caption) .foregroundStyle(textColor.opacity(0.5)) .frame(maxWidth: .infinity) .accessibilityLabel(daysOfWeekFull[index]) } } } // MARK: - Calendar Grid 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 { ReportDayCell( date: date, isStart: calendar.isDate(date, inSameDayAs: startDate), isEnd: calendar.isDate(date, inSameDayAs: endDate), isInRange: isDateInRange(date), isToday: calendar.isDateInToday(date), isFuture: isFutureDate(date), textColor: textColor, onTap: { handleDateTap(date) } ) } else { Color.clear .frame(height: 40) } } } } // MARK: - Day Count Badge private var dayCountBadge: some View { HStack(spacing: 4) { Image(systemName: "calendar.badge.clock") .foregroundColor(.accentColor) .accessibilityHidden(true) Text("\(selectedDayCount) day\(selectedDayCount == 1 ? "" : "s") selected") .font(.subheadline) .foregroundStyle(textColor.opacity(0.6)) } .frame(maxWidth: .infinity, alignment: .center) .padding(.top, 4) } // MARK: - Helpers 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 isFutureDate(_ date: Date) -> Bool { calendar.startOfDay(for: date) > calendar.startOfDay(for: Date()) } private func handleDateTap(_ date: Date) { let tappedDate = calendar.startOfDay(for: date) let today = calendar.startOfDay(for: Date()) // Don't allow selecting dates after today if tappedDate > today { return } switch selectionState { case .none, .complete: startDate = date endDate = date selectionState = .startSelected case .startSelected: if date >= startDate { endDate = date } else { endDate = startDate startDate = date } selectionState = .complete } } } // MARK: - Report Day Cell private struct ReportDayCell: View { let date: Date let isStart: Bool let isEnd: Bool let isInRange: Bool let isToday: Bool let isFuture: Bool let textColor: Color let onTap: () -> Void @Environment(\.colorScheme) private var colorScheme private let calendar = Calendar.current private var dayNumber: String { "\(calendar.component(.day, from: date))" } var body: some View { Button(action: onTap) { ZStack { // Range highlight background if isInRange || isStart || isEnd { HStack(spacing: 0) { Rectangle() .fill(Color.accentColor.opacity(0.15)) .frame(maxWidth: .infinity) .opacity(isStart && !isEnd ? 0 : 1) .offset(x: isStart ? 20 : 0) Rectangle() .fill(Color.accentColor.opacity(0.15)) .frame(maxWidth: .infinity) .opacity(isEnd && !isStart ? 0 : 1) .offset(x: isEnd ? -20 : 0) } .opacity(isStart && isEnd ? 0 : 1) } // Day circle ZStack { if isStart || isEnd { Circle() .fill(Color.accentColor) } else if isToday { Circle() .stroke(Color.accentColor, lineWidth: 2) } Text(dayNumber) .font(.subheadline) .foregroundStyle( isFuture ? textColor.opacity(0.25) : (isStart || isEnd) ? .white : isToday ? .accentColor : textColor ) } .frame(width: 36, height: 36) } } .buttonStyle(.plain) .disabled(isFuture) .accessibilityIdentifier(AccessibilityID.Reports.dayCell(dateString: dayNumber)) .frame(height: 40) } }