Files
Reflect/Shared/Views/InsightsView/ReportsView.swift
Trey t 19b4c8b05b 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>
2026-03-11 10:13:54 -05:00

382 lines
13 KiB
Swift

//
// ReportsView.swift
// Reflect
//
// AI-powered mood report generation with date range selection and PDF export.
//
import SwiftUI
struct ReportsView: View {
@StateObject private var viewModel = ReportsViewModel()
@EnvironmentObject var iapManager: IAPManager
@AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system
@Environment(\.colorScheme) private var colorScheme
@State private var showSubscriptionStore = false
private var textColor: Color { theme.currentTheme.labelColor }
var body: some View {
ZStack {
ScrollView {
VStack(spacing: 20) {
// Report type selector
reportTypeSelector
// Date range picker
ReportDateRangePicker(
startDate: $viewModel.startDate,
endDate: $viewModel.endDate
)
.padding(.horizontal)
// Entry count / validation
entryValidationCard
// AI unavailable warning
if !viewModel.isAIAvailable {
aiUnavailableCard
}
// Generate button
generateButton
// Report ready card
if viewModel.generationState == .completed {
reportReadyCard
}
// Error message
if case .failed(let message) = viewModel.generationState {
errorCard(message: message)
}
}
.padding(.vertical)
.padding(.bottom, 100)
}
.disabled(iapManager.shouldShowPaywall)
// Generating overlay
if viewModel.generationState == .generating {
ReportGeneratingView(
progress: viewModel.progressValue,
message: viewModel.progressMessage,
onCancel: { viewModel.cancelGeneration() }
)
}
// Paywall overlay
if iapManager.shouldShowPaywall {
paywallOverlay
}
}
.sheet(isPresented: $viewModel.showShareSheet) {
if let url = viewModel.exportedPDFURL {
ExportShareSheet(items: [url])
}
}
.sheet(isPresented: $showSubscriptionStore) {
ReflectSubscriptionStoreView(source: "reports_gate")
}
.confirmationDialog(
String(localized: "Privacy Notice"),
isPresented: $viewModel.showPrivacyConfirmation,
titleVisibility: .visible
) {
Button(String(localized: "Share Report")) {
viewModel.exportPDF()
}
Button(String(localized: "Cancel"), role: .cancel) {}
} message: {
Text("This report contains your personal mood data and journal notes. Only share it with people you trust.")
}
.onAppear {
AnalyticsManager.shared.trackScreen(.reports)
}
}
// MARK: - Report Type Selector
private var reportTypeSelector: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Report Type")
.font(.headline)
.foregroundColor(textColor)
.padding(.horizontal)
ForEach(ReportType.allCases, id: \.self) { type in
Button {
viewModel.reportType = type
} label: {
HStack(spacing: 16) {
Image(systemName: type.icon)
.font(.title2)
.frame(width: 44, height: 44)
.background(viewModel.reportType == type ? Color.accentColor.opacity(0.15) : Color(.systemGray5))
.foregroundColor(viewModel.reportType == type ? .accentColor : .gray)
.clipShape(Circle())
VStack(alignment: .leading, spacing: 2) {
Text(type.rawValue)
.font(.headline)
.foregroundColor(textColor)
Text(type.description)
.font(.subheadline)
.foregroundStyle(.secondary)
}
Spacer()
if viewModel.reportType == type {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.accentColor)
}
}
.padding()
.background(
RoundedRectangle(cornerRadius: 14)
.fill(colorScheme == .dark ? Color(.systemGray6) : .white)
.overlay(
RoundedRectangle(cornerRadius: 14)
.stroke(viewModel.reportType == type ? Color.accentColor : Color.clear, lineWidth: 2)
)
)
}
.buttonStyle(.plain)
.accessibilityIdentifier(
type == .quickSummary ? AccessibilityID.Reports.quickSummaryButton : AccessibilityID.Reports.detailedReportButton
)
.padding(.horizontal)
}
}
}
// MARK: - Entry Validation Card
private var entryValidationCard: some View {
HStack(spacing: 12) {
Image(systemName: viewModel.validEntryCount >= 3 ? "checkmark.circle.fill" : "exclamationmark.triangle.fill")
.foregroundColor(viewModel.validEntryCount >= 3 ? .green : .orange)
VStack(alignment: .leading, spacing: 2) {
Text("\(viewModel.validEntryCount) mood entries in range")
.font(.subheadline.weight(.medium))
.foregroundColor(textColor)
if viewModel.validEntryCount < 3 {
Text("At least 3 entries required to generate a report")
.font(.caption)
.foregroundStyle(.secondary)
}
}
Spacer()
}
.padding()
.background(
RoundedRectangle(cornerRadius: 14)
.fill(colorScheme == .dark ? Color(.systemGray6) : .white)
)
.padding(.horizontal)
.accessibilityIdentifier(AccessibilityID.Reports.minimumEntriesWarning)
}
// MARK: - AI Unavailable Card
private var aiUnavailableCard: some View {
HStack(spacing: 12) {
Image(systemName: "brain.head.profile")
.foregroundColor(.orange)
VStack(alignment: .leading, spacing: 2) {
Text("Apple Intelligence Required")
.font(.subheadline.weight(.medium))
.foregroundColor(textColor)
Text("AI report generation requires Apple Intelligence to be enabled in Settings.")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
}
.padding()
.background(
RoundedRectangle(cornerRadius: 14)
.fill(Color.orange.opacity(0.1))
)
.padding(.horizontal)
}
// MARK: - Generate Button
private var generateButton: some View {
Button {
viewModel.generateReport()
} label: {
HStack(spacing: 8) {
Image(systemName: "sparkles")
Text("Generate Report")
.fontWeight(.semibold)
}
.frame(maxWidth: .infinity)
.padding()
.background(viewModel.canGenerate ? Color.accentColor : Color.gray)
.foregroundColor(.white)
.clipShape(RoundedRectangle(cornerRadius: 14))
}
.disabled(!viewModel.canGenerate)
.padding(.horizontal)
.accessibilityIdentifier(AccessibilityID.Reports.generateButton)
}
// MARK: - Report Ready Card
private var reportReadyCard: some View {
VStack(spacing: 16) {
HStack(spacing: 12) {
Image(systemName: "checkmark.circle.fill")
.font(.title2)
.foregroundColor(.green)
VStack(alignment: .leading, spacing: 2) {
Text("Report Ready")
.font(.headline)
.foregroundColor(textColor)
Text("\(viewModel.reportType.rawValue) with \(viewModel.validEntryCount) entries")
.font(.subheadline)
.foregroundStyle(.secondary)
}
Spacer()
}
Button {
viewModel.showPrivacyConfirmation = true
} label: {
HStack(spacing: 8) {
Image(systemName: "square.and.arrow.up")
Text("Export PDF")
.fontWeight(.semibold)
}
.frame(maxWidth: .infinity)
.padding()
.background(Color.accentColor)
.foregroundColor(.white)
.clipShape(RoundedRectangle(cornerRadius: 14))
}
.accessibilityIdentifier(AccessibilityID.Reports.exportButton)
}
.padding()
.background(
RoundedRectangle(cornerRadius: 14)
.fill(colorScheme == .dark ? Color(.systemGray6) : .white)
)
.padding(.horizontal)
}
// MARK: - Error Card
private func errorCard(message: String) -> some View {
HStack(spacing: 12) {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundColor(.red)
VStack(alignment: .leading, spacing: 2) {
Text("Generation Failed")
.font(.subheadline.weight(.medium))
.foregroundColor(textColor)
Text(message)
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Button("Retry") {
viewModel.generateReport()
}
.font(.subheadline.weight(.medium))
}
.padding()
.background(
RoundedRectangle(cornerRadius: 14)
.fill(Color.red.opacity(0.1))
)
.padding(.horizontal)
}
// MARK: - Paywall Overlay
private var paywallOverlay: some View {
VStack(spacing: 24) {
Spacer()
ZStack {
Circle()
.fill(
LinearGradient(
colors: [.purple.opacity(0.2), .blue.opacity(0.2)],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.frame(width: 100, height: 100)
Image(systemName: "doc.text.magnifyingglass")
.font(.largeTitle)
.foregroundStyle(
LinearGradient(
colors: [.purple, .blue],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
}
VStack(spacing: 12) {
Text("Unlock AI Reports")
.font(.title2.weight(.bold))
.foregroundColor(textColor)
.multilineTextAlignment(.center)
Text("Generate clinical-quality mood reports to share with your therapist or track your progress over time.")
.font(.body)
.foregroundColor(textColor.opacity(0.7))
.multilineTextAlignment(.center)
.padding(.horizontal, 32)
}
Button {
showSubscriptionStore = true
} label: {
HStack {
Image(systemName: "sparkles")
Text("Unlock Reports")
}
.font(.headline.weight(.bold))
.foregroundColor(.white)
.frame(maxWidth: .infinity)
.padding(.vertical, 16)
.background(
LinearGradient(
colors: [.purple, .blue],
startPoint: .leading,
endPoint: .trailing
)
)
.clipShape(RoundedRectangle(cornerRadius: 14))
}
.padding(.horizontal, 24)
Spacer()
}
.background(theme.currentTheme.bg)
}
}