Files
Reflect/Shared/Views/ExportView.swift
Trey T ed8205cd88 Complete accessibility identifier coverage across all 152 project files
Exhaustive file-by-file audit of every Swift file in the project (iOS app,
Watch app, Widget extension). Every interactive UI element — buttons, toggles,
pickers, links, menus, tap gestures, text editors, color pickers, photo
pickers — now has an accessibilityIdentifier for XCUITest automation.

46 files changed across Shared/, Onboarding/, Watch App/, and Widget targets.
Added ~100 new ID definitions covering settings debug controls, export/photo
views, sharing templates, customization subviews, onboarding flows, tip
modals, widget voting buttons, and watch mood buttons.
2026-03-26 08:34:56 -05:00

343 lines
11 KiB
Swift

//
// ExportView.swift
// Reflect
//
// Export mood data to CSV or PDF formats.
//
import SwiftUI
struct ExportView: View {
@Environment(\.dismiss) private var dismiss
@AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system
private var textColor: Color { theme.currentTheme.labelColor }
@State private var selectedFormat: ExportFormat = .csv
@State private var selectedRange: DateRange = .allTime
@State private var isExporting = false
@State private var exportedURL: URL?
@State private var showShareSheet = false
@State private var showError = false
@State private var errorMessage = ""
private let entries: [MoodEntryModel]
enum ExportFormat: String, CaseIterable {
case csv = "CSV"
case pdf = "PDF"
var icon: String {
switch self {
case .csv: return "tablecells"
case .pdf: return "doc.richtext"
}
}
var description: String {
switch self {
case .csv: return "Spreadsheet format for data analysis"
case .pdf: return "Formatted report with insights"
}
}
}
enum DateRange: String, CaseIterable {
case lastWeek = "Last 7 Days"
case lastMonth = "Last 30 Days"
case last3Months = "Last 3 Months"
case lastYear = "Last Year"
case allTime = "All Time"
var days: Int? {
switch self {
case .lastWeek: return 7
case .lastMonth: return 30
case .last3Months: return 90
case .lastYear: return 365
case .allTime: return nil
}
}
}
init(entries: [MoodEntryModel]) {
self.entries = entries
}
private var filteredEntries: [MoodEntryModel] {
guard let days = selectedRange.days else {
return entries
}
let cutoffDate = Calendar.current.date(byAdding: .day, value: -days, to: Date()) ?? Date()
return entries.filter { $0.forDate >= cutoffDate }
}
private var validEntries: [MoodEntryModel] {
filteredEntries.filter { ![.missing, .placeholder].contains($0.mood) }
}
var body: some View {
NavigationStack {
ScrollView {
VStack(spacing: 24) {
// Preview stats
statsCard
// Format selection
formatSelection
// Date range selection
dateRangeSelection
// Export button
exportButton
}
.padding()
}
.background(Color(.systemGroupedBackground))
.navigationTitle("Export Data")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") {
dismiss()
}
.accessibilityIdentifier(AccessibilityID.Export.cancelButton)
}
}
.sheet(isPresented: $showShareSheet) {
if let url = exportedURL {
ExportShareSheet(items: [url])
}
}
.alert("Export Failed", isPresented: $showError) {
Button("OK", role: .cancel) { }
.accessibilityIdentifier(AccessibilityID.Export.alertOKButton)
} message: {
Text(errorMessage)
}
}
}
private var statsCard: some View {
VStack(spacing: 16) {
HStack {
Image(systemName: "chart.bar.doc.horizontal")
.font(.title2)
.foregroundColor(.accentColor)
Text("Export Preview")
.font(.headline)
.foregroundColor(textColor)
Spacer()
}
Divider()
HStack(spacing: 32) {
VStack(spacing: 4) {
Text("\(validEntries.count)")
.font(.title)
.fontWeight(.bold)
.foregroundColor(textColor)
Text("Entries")
.font(.caption)
.foregroundStyle(.secondary)
}
VStack(spacing: 4) {
Text(dateRangeText)
.font(.title3)
.fontWeight(.semibold)
.foregroundColor(textColor)
Text("Date Range")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
}
}
.padding()
.background(
RoundedRectangle(cornerRadius: 16)
.fill(Color(.systemBackground))
)
}
private var dateRangeText: String {
guard !validEntries.isEmpty else { return "No data" }
let sorted = validEntries.sorted { $0.forDate < $1.forDate }
guard let first = sorted.first, let last = sorted.last else { return "No data" }
let formatter = DateFormatter()
formatter.dateFormat = "MMM d"
if Calendar.current.isDate(first.forDate, equalTo: last.forDate, toGranularity: .day) {
return formatter.string(from: first.forDate)
}
return "\(formatter.string(from: first.forDate)) - \(formatter.string(from: last.forDate))"
}
private var formatSelection: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Format")
.font(.headline)
.foregroundColor(textColor)
ForEach(ExportFormat.allCases, id: \.self) { format in
Button {
selectedFormat = format
} label: {
HStack(spacing: 16) {
Image(systemName: format.icon)
.font(.title2)
.frame(width: 44, height: 44)
.background(selectedFormat == format ? Color.accentColor.opacity(0.15) : Color(.systemGray5))
.foregroundColor(selectedFormat == format ? .accentColor : .gray)
.clipShape(Circle())
VStack(alignment: .leading, spacing: 2) {
Text(format.rawValue)
.font(.headline)
.foregroundColor(textColor)
Text(format.description)
.font(.subheadline)
.foregroundStyle(.secondary)
}
Spacer()
if selectedFormat == format {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.accentColor)
}
}
.padding()
.background(
RoundedRectangle(cornerRadius: 14)
.fill(Color(.systemBackground))
.overlay(
RoundedRectangle(cornerRadius: 14)
.stroke(selectedFormat == format ? Color.accentColor : Color.clear, lineWidth: 2)
)
)
}
.buttonStyle(.plain)
.accessibilityIdentifier(AccessibilityID.Export.formatButton(format.rawValue))
}
}
}
private var dateRangeSelection: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Date Range")
.font(.headline)
.foregroundColor(textColor)
VStack(spacing: 0) {
ForEach(DateRange.allCases, id: \.self) { range in
Button {
selectedRange = range
} label: {
HStack {
Text(range.rawValue)
.foregroundColor(textColor)
Spacer()
if selectedRange == range {
Image(systemName: "checkmark")
.foregroundColor(.accentColor)
}
}
.padding()
.background(Color(.systemBackground))
}
.buttonStyle(.plain)
.accessibilityIdentifier(AccessibilityID.Export.rangeButton(range.rawValue))
if range != DateRange.allCases.last {
Divider()
.padding(.leading)
}
}
}
.clipShape(RoundedRectangle(cornerRadius: 14))
}
}
private var exportButton: some View {
Button {
performExport()
} label: {
HStack(spacing: 8) {
if isExporting {
ProgressView()
.tint(.white)
} else {
Image(systemName: "square.and.arrow.up")
}
Text(isExporting ? "Exporting..." : "Export \(selectedFormat.rawValue)")
.fontWeight(.semibold)
}
.frame(maxWidth: .infinity)
.padding()
.background(validEntries.isEmpty ? Color.gray : Color.accentColor)
.foregroundColor(.white)
.clipShape(RoundedRectangle(cornerRadius: 14))
}
.disabled(isExporting || validEntries.isEmpty)
.accessibilityIdentifier(AccessibilityID.Export.exportButton)
.padding(.top, 8)
}
private func performExport() {
isExporting = true
Task {
let url: URL?
switch selectedFormat {
case .csv:
url = ExportService.shared.exportCSV(entries: validEntries)
case .pdf:
url = ExportService.shared.exportPDF(entries: validEntries, title: "Reflect Mood Report")
}
await MainActor.run {
isExporting = false
if let url = url {
exportedURL = url
showShareSheet = true
} else {
errorMessage = "Failed to create export file. Please try again."
showError = true
}
}
}
}
}
// MARK: - Export Share Sheet
struct ExportShareSheet: UIViewControllerRepresentable {
let items: [Any]
func makeUIViewController(context: Context) -> UIActivityViewController {
UIActivityViewController(activityItems: items, applicationActivities: nil)
}
func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {}
}