Files
Reflect/Shared/Views/InsightsView/ReportDateRangePicker.swift
Trey T ed8205cd88 Complete accessibility identifier coverage across all 152 project files
Exhaustive file-by-file audit of every Swift file in the project (iOS app,
Watch app, Widget extension). Every interactive UI element — buttons, toggles,
pickers, links, menus, tap gestures, text editors, color pickers, photo
pickers — now has an accessibilityIdentifier for XCUITest automation.

46 files changed across Shared/, Onboarding/, Watch App/, and Widget targets.
Added ~100 new ID definitions covering settings debug controls, export/photo
views, sharing templates, customization subviews, onboarding flows, tip
modals, widget voting buttons, and watch mood buttons.
2026-03-26 08:34:56 -05:00

350 lines
12 KiB
Swift

//
// 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)
}
}