// // SettingsView.swift // OpenClimb // // Created by OpenClimb on 2025-01-17. // import SwiftUI import UniformTypeIdentifiers struct SettingsView: View { @EnvironmentObject var dataManager: ClimbingDataManager @State private var showingResetAlert = false @State private var showingExportSheet = false @State private var showingImportSheet = false @State private var exportData: Data? var body: some View { NavigationView { List { DataManagementSection() AppInfoSection() } .navigationTitle("Settings") } } } struct DataManagementSection: View { @EnvironmentObject var dataManager: ClimbingDataManager @State private var showingResetAlert = false @State private var showingExportSheet = false @State private var showingImportSheet = false @State private var exportData: Data? @State private var isExporting = false var body: some View { Section("Data Management") { // Export Data Button(action: { exportDataAsync() }) { HStack { if isExporting { ProgressView() .scaleEffect(0.8) Text("Exporting...") .foregroundColor(.secondary) } else { Image(systemName: "square.and.arrow.up") .foregroundColor(.blue) Text("Export Data") } Spacer() } } .disabled(isExporting) .foregroundColor(.primary) // Import Data Button(action: { showingImportSheet = true }) { HStack { Image(systemName: "square.and.arrow.down") .foregroundColor(.green) Text("Import Data") Spacer() } } .foregroundColor(.primary) // Reset All Data Button(action: { showingResetAlert = true }) { HStack { Image(systemName: "trash") .foregroundColor(.red) Text("Reset All Data") Spacer() } } .foregroundColor(.red) } .alert("Reset All Data", isPresented: $showingResetAlert) { Button("Cancel", role: .cancel) {} Button("Reset", role: .destructive) { dataManager.resetAllData() } } message: { Text( "Are you sure you want to reset all data? This will permanently delete:\n\n• All gyms and their information\n• All problems and their images\n• All climbing sessions\n• All attempts and progress data\n\nThis action cannot be undone. Consider exporting your data first." ) } .sheet(isPresented: $showingExportSheet) { if let data = exportData { ExportDataView(data: data) } else { Text("No export data available") } } .sheet(isPresented: $showingImportSheet) { ImportDataView() } } private func exportDataAsync() { isExporting = true Task { let data = await withCheckedContinuation { continuation in DispatchQueue.global(qos: .userInitiated).async { let result = dataManager.exportData() continuation.resume(returning: result) } } await MainActor.run { isExporting = false if let data = data { exportData = data showingExportSheet = true } else { // Error message should already be set by dataManager exportData = nil } } } } } struct AppInfoSection: View { private var appVersion: String { Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "Unknown" } private var buildNumber: String { Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "Unknown" } var body: some View { Section("App Information") { HStack { Image(systemName: "mountain.2.fill") .foregroundColor(.blue) VStack(alignment: .leading) { Text("OpenClimb") .font(.headline) Text("Track your climbing progress") .font(.caption) .foregroundColor(.secondary) } Spacer() } HStack { Image(systemName: "info.circle") .foregroundColor(.blue) Text("Version") Spacer() Text("\(appVersion) (\(buildNumber))") .foregroundColor(.secondary) } HStack { Image(systemName: "person.fill") .foregroundColor(.blue) Text("Developer") Spacer() Text("OpenClimb Team") .foregroundColor(.secondary) } } } } struct ExportDataView: View { let data: Data @Environment(\.dismiss) private var dismiss @State private var tempFileURL: URL? var body: some View { NavigationView { VStack(spacing: 20) { Image(systemName: "square.and.arrow.up") .font(.system(size: 60)) .foregroundColor(.blue) Text("Export Data") .font(.title) .fontWeight(.bold) Text( "Your climbing data has been prepared for export. Use the share button below to save or send your data." ) .multilineTextAlignment(.center) .padding(.horizontal) if let fileURL = tempFileURL { ShareLink( item: fileURL, preview: SharePreview( "OpenClimb Data Export", image: Image(systemName: "mountain.2.fill")) ) { Label("Share Data", systemImage: "square.and.arrow.up") .font(.headline) .foregroundColor(.white) .frame(maxWidth: .infinity) .padding() .background( RoundedRectangle(cornerRadius: 12) .fill(.blue) ) } .padding(.horizontal) .buttonStyle(.plain) } else { Button(action: {}) { Label("Preparing Export...", systemImage: "hourglass") .font(.headline) .foregroundColor(.white) .frame(maxWidth: .infinity) .padding() .background( RoundedRectangle(cornerRadius: 12) .fill(.gray) ) } .disabled(true) .padding(.horizontal) } Spacer() } .padding() .navigationTitle("Export") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .navigationBarTrailing) { Button("Done") { dismiss() } } } .onAppear { if tempFileURL == nil { createTempFile() } } .onDisappear { // Delay cleanup to ensure sharing is complete DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { cleanupTempFile() } } } } private func createTempFile() { DispatchQueue.global(qos: .userInitiated).async { do { let formatter = ISO8601DateFormatter() formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] let isoString = formatter.string(from: Date()) let timestamp = isoString.replacingOccurrences(of: ":", with: "-") .replacingOccurrences(of: ".", with: "-") let filename = "openclimb_export_\(timestamp).zip" guard let documentsURL = FileManager.default.urls( for: .documentDirectory, in: .userDomainMask ).first else { print("Could not access Documents directory") return } let fileURL = documentsURL.appendingPathComponent(filename) // Write the ZIP data to the file try data.write(to: fileURL) DispatchQueue.main.async { self.tempFileURL = fileURL } } catch { print("Failed to create export file: \(error)") } } } private func cleanupTempFile() { if let fileURL = tempFileURL { // Clean up after a delay to ensure sharing is complete DispatchQueue.main.asyncAfter(deadline: .now() + 5.0) { try? FileManager.default.removeItem(at: fileURL) print("Cleaned up export file: \(fileURL.lastPathComponent)") } } } } struct ImportDataView: View { @EnvironmentObject var dataManager: ClimbingDataManager @Environment(\.dismiss) private var dismiss @State private var isImporting = false @State private var importError: String? @State private var showingDocumentPicker = false var body: some View { NavigationView { VStack(spacing: 20) { Image(systemName: "square.and.arrow.down") .font(.system(size: 60)) .foregroundColor(.green) Text("Import Data") .font(.title) .fontWeight(.bold) VStack(spacing: 12) { Text("Import climbing data from a previously exported ZIP file.") .multilineTextAlignment(.center) Text( "Fully compatible with Android exports - identical ZIP format with images." ) .font(.subheadline) .foregroundColor(.blue) .multilineTextAlignment(.center) Text("⚠️ Warning: This will replace all current data!") .font(.subheadline) .foregroundColor(.red) .multilineTextAlignment(.center) } .padding(.horizontal) Button(action: { showingDocumentPicker = true }) { if isImporting { HStack { ProgressView() .scaleEffect(0.8) Text("Importing...") } } else { Label("Select ZIP File to Import", systemImage: "folder.badge.plus") } } .font(.headline) .foregroundColor(.white) .frame(maxWidth: .infinity) .padding() .background( RoundedRectangle(cornerRadius: 12) .fill(isImporting ? .gray : .green) ) .padding(.horizontal) .disabled(isImporting) if let error = importError { Text(error) .foregroundColor(.red) .padding() .background( RoundedRectangle(cornerRadius: 8) .fill(.red.opacity(0.1)) ) } Spacer() } .padding() .navigationTitle("Import Data") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .navigationBarTrailing) { Button("Cancel") { dismiss() } } } .fileImporter( isPresented: $showingDocumentPicker, allowedContentTypes: [.zip, .archive], allowsMultipleSelection: false ) { result in switch result { case .success(let urls): if let url = urls.first { importData(from: url) } case .failure(let error): importError = "Failed to select file: \(error.localizedDescription)" } } } } private func importData(from url: URL) { isImporting = true importError = nil Task { do { // Access the security-scoped resource guard url.startAccessingSecurityScopedResource() else { await MainActor.run { isImporting = false importError = "Failed to access selected file" } return } defer { url.stopAccessingSecurityScopedResource() } let data = try Data(contentsOf: url) try dataManager.importData(from: data) await MainActor.run { isImporting = false dismiss() } } catch { await MainActor.run { isImporting = false importError = "Import failed: \(error.localizedDescription)" } } } } } #Preview { SettingsView() .environmentObject(ClimbingDataManager.preview) }