Files
Reflect/Shared/Views/ExportView.swift
Trey t 0442eab1f8 Rebrand entire project from Feels to Reflect
Complete rename across all bundle IDs, App Groups, CloudKit containers,
StoreKit product IDs, data store filenames, URL schemes, logger subsystems,
Swift identifiers, user-facing strings (7 languages), file names, directory
names, Xcode project, schemes, assets, and documentation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 11:47:16 -06:00

338 lines
10 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()
}
}
}
.sheet(isPresented: $showShareSheet) {
if let url = exportedURL {
ExportShareSheet(items: [url])
}
}
.alert("Export Failed", isPresented: $showError) {
Button("OK", role: .cancel) { }
} 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)
}
}
}
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)
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)
.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) {}
}