Add AI mood report feature with PDF export for therapist sharing
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>
This commit is contained in:
346
Shared/Views/InsightsView/ReportDateRangePicker.swift
Normal file
346
Shared/Views/InsightsView/ReportDateRangePicker.swift
Normal file
@@ -0,0 +1,346 @@
|
||||
//
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user