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:
381
Shared/Views/InsightsView/ReportsView.swift
Normal file
381
Shared/Views/InsightsView/ReportsView.swift
Normal file
@@ -0,0 +1,381 @@
|
||||
//
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user