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.
343 lines
11 KiB
Swift
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) {}
|
|
}
|