Adds a Reports tab to the Insights view with date range selection, two report types (Quick Summary / Detailed), Foundation Models AI generation with batched concurrent processing, and clinical PDF export via WKWebView HTML rendering. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
347 lines
12 KiB
Swift
347 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())
|
|
}
|
|
.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())
|
|
}
|
|
.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)
|
|
.frame(height: 40)
|
|
}
|
|
}
|