Files
Reflect/Shared/Views/InsightsView/ReportsView.swift
Trey T e7648ddd8a Add missing accessibility identifiers to all interactive UI elements
Audit found ~50+ interactive elements (buttons, toggles, pickers, alerts,
links) missing accessibility identifiers across 13 view files. Added
centralized ID definitions and applied them to every entry detail button,
guided reflection control, settings toggle, paywall unlock button,
subscription/IAP button, lock screen control, and photo action dialog.
2026-03-26 07:59:52 -05:00

414 lines
14 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) {
if viewModel.isAIAvailable {
// Report type selector (AI only)
reportTypeSelector
}
// Date range picker
ReportDateRangePicker(
startDate: $viewModel.startDate,
endDate: $viewModel.endDate
)
.padding(.horizontal)
// Entry count / validation
entryValidationCard
if viewModel.isAIAvailable {
// Generate button
generateButton
// Report ready card
if viewModel.generationState == .completed {
reportReadyCard
}
// Error message
if case .failed(let message) = viewModel.generationState {
errorCard(message: message)
}
} else {
// Non-AI export path
dataExportCard
exportDataButton
}
}
.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")) {
if viewModel.isAIAvailable {
viewModel.exportPDF()
} else {
viewModel.exportDataPDF()
}
}
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: - Data Export Card (No AI)
private var dataExportCard: some View {
HStack(spacing: 12) {
Image(systemName: "chart.bar.doc.horizontal")
.foregroundColor(.accentColor)
VStack(alignment: .leading, spacing: 2) {
Text(String(localized: "Export Your Data"))
.font(.subheadline.weight(.medium))
.foregroundColor(textColor)
Text(String(localized: "AI reports require Apple Intelligence. You can still export your mood entries as a visual PDF report with charts and statistics."))
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
}
.padding()
.background(
RoundedRectangle(cornerRadius: 14)
.fill(colorScheme == .dark ? Color(.systemGray6) : .white)
)
.padding(.horizontal)
}
// MARK: - Export Data Button
private var exportDataButton: some View {
Button {
viewModel.showPrivacyConfirmation = true
} label: {
HStack(spacing: 8) {
Image(systemName: "square.and.arrow.up")
Text("Export PDF")
.fontWeight(.semibold)
}
.frame(maxWidth: .infinity)
.padding()
.background(viewModel.canExportData ? Color.accentColor : Color.gray)
.foregroundColor(.white)
.clipShape(RoundedRectangle(cornerRadius: 14))
}
.disabled(!viewModel.canExportData)
.padding(.horizontal)
.accessibilityIdentifier(AccessibilityID.Reports.exportDataButton)
}
// 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()
}
.padding()
.background(
RoundedRectangle(cornerRadius: 14)
.fill(colorScheme == .dark ? Color(.systemGray6) : .white)
)
.padding(.horizontal)
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))
}
.padding(.horizontal)
.accessibilityIdentifier(AccessibilityID.Reports.exportButton)
}
}
// 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))
.accessibilityIdentifier(AccessibilityID.Reports.retryButton)
}
.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)
.accessibilityIdentifier(AccessibilityID.Paywall.reportsUnlockButton)
Spacer()
}
.background(theme.currentTheme.bg)
}
}