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>
382 lines
13 KiB
Swift
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)
|
|
}
|
|
}
|