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.
414 lines
14 KiB
Swift
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)
|
|
}
|
|
}
|