import SwiftUI import UniformTypeIdentifiers enum SheetType { case export(Data) case importData } struct SettingsView: View { @EnvironmentObject var dataManager: ClimbingDataManager @State private var activeSheet: SheetType? var body: some View { List { SyncSection() .environmentObject(dataManager.syncService) DataManagementSection( activeSheet: $activeSheet ) AppInfoSection() } .navigationTitle("Settings") .toolbar { ToolbarItem(placement: .navigationBarTrailing) { if dataManager.isSyncing { HStack(spacing: 2) { ProgressView() .progressViewStyle(CircularProgressViewStyle(tint: .blue)) .scaleEffect(0.6) } .padding(.horizontal, 6) .padding(.vertical, 3) .background( Circle() .fill(.regularMaterial) ) .transition(.scale.combined(with: .opacity)) .animation( .easeInOut(duration: 0.2), value: dataManager.isSyncing ) } } } .sheet( item: Binding( get: { activeSheet }, set: { activeSheet = $0 } ) ) { sheetType in switch sheetType { case .export(let data): ExportDataView(data: data) case .importData: ImportDataView() } } } } extension SheetType: Identifiable { var id: String { switch self { case .export: return "export" case .importData: return "import" } } } struct DataManagementSection: View { @EnvironmentObject var dataManager: ClimbingDataManager @Binding var activeSheet: SheetType? @State private var showingResetAlert = false @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: { activeSheet = .importData }) { 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." ) } } private func exportDataAsync() { isExporting = true Task { let data = await MainActor.run { dataManager.exportData() } isExporting = false if let data = data { activeSheet = .export(data) } } } } 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("MountainsIcon") .resizable() .frame(width: 24, height: 24) 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) } } } } struct ExportDataView: View { let data: Data @Environment(\.dismiss) private var dismiss @State private var tempFileURL: URL? @State private var isCreatingFile = true var body: some View { NavigationView { VStack(spacing: 30) { if isCreatingFile { // Loading state - more prominent VStack(spacing: 20) { ProgressView() .scaleEffect(1.5) .tint(.blue) Text("Preparing Your Export") .font(.title2) .fontWeight(.semibold) Text("Creating ZIP file with your climbing data and images...") .font(.body) .foregroundColor(.secondary) .multilineTextAlignment(.center) .padding(.horizontal) } .frame(maxWidth: .infinity, maxHeight: .infinity) } else { // Ready state VStack(spacing: 20) { Image(systemName: "checkmark.circle.fill") .font(.system(size: 60)) .foregroundColor(.green) Text("Export Ready!") .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("MountainsIcon")) ) { 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) } } 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") DispatchQueue.main.async { self.isCreatingFile = false } return } let fileURL = documentsURL.appendingPathComponent(filename) // Write the ZIP data to the file try data.write(to: fileURL) DispatchQueue.main.async { self.tempFileURL = fileURL self.isCreatingFile = false } } catch { print("Failed to create export file: \(error)") DispatchQueue.main.async { self.isCreatingFile = false } } } } 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 SyncSection: View { @EnvironmentObject var syncService: SyncService @EnvironmentObject var dataManager: ClimbingDataManager @State private var showingSyncSettings = false @State private var showingDisconnectAlert = false var body: some View { Section("Sync") { // Sync Status HStack { Image( systemName: syncService.isConnected ? "checkmark.circle.fill" : syncService.isConfigured ? "exclamationmark.triangle.fill" : "exclamationmark.circle.fill" ) .foregroundColor( syncService.isConnected ? .green : syncService.isConfigured ? .orange : .red ) VStack(alignment: .leading) { Text("Sync Server") .font(.headline) Text( syncService.isConnected ? "Connected" : syncService.isConfigured ? "Configured - Not tested" : "Not configured" ) .font(.caption) .foregroundColor(.secondary) } Spacer() } // Configure Server Button(action: { showingSyncSettings = true }) { HStack { Image(systemName: "gear") .foregroundColor(.blue) Text("Configure Server") Spacer() Image(systemName: "chevron.right") .font(.caption) .foregroundColor(.secondary) } } .foregroundColor(.primary) if syncService.isConfigured { // Sync Now - only show if connected if syncService.isConnected { Button(action: { performSync() }) { HStack { if syncService.isSyncing { ProgressView() .scaleEffect(0.8) Text("Syncing...") .foregroundColor(.secondary) } else { Image(systemName: "arrow.triangle.2.circlepath") .foregroundColor(.green) Text("Sync Now") Spacer() if let lastSync = syncService.lastSyncTime { Text( RelativeDateTimeFormatter().localizedString( for: lastSync, relativeTo: Date()) ) .font(.caption) .foregroundColor(.secondary) } } } } .disabled(syncService.isSyncing) .foregroundColor(.primary) } // Auto-sync configuration - always visible for testing HStack { VStack(alignment: .leading) { Text("Auto-sync") Text("Sync automatically on app launch and data changes") .font(.caption) .foregroundColor(.secondary) } Spacer() Toggle( "", isOn: Binding( get: { syncService.isAutoSyncEnabled }, set: { syncService.isAutoSyncEnabled = $0 } ) ) .disabled(!syncService.isConnected) } .foregroundColor(.primary) // Disconnect option - only show if connected if syncService.isConnected { Button(action: { showingDisconnectAlert = true }) { HStack { Image(systemName: "power") .foregroundColor(.orange) Text("Disconnect") Spacer() } } .foregroundColor(.primary) } if let error = syncService.syncError { Text(error) .font(.caption) .foregroundColor(.red) .padding(.leading, 24) } } } .sheet(isPresented: $showingSyncSettings) { SyncSettingsView() .environmentObject(syncService) } .alert("Disconnect from Server", isPresented: $showingDisconnectAlert) { Button("Cancel", role: .cancel) {} Button("Disconnect", role: .destructive) { syncService.disconnect() } } message: { Text( "This will sign you out but keep your server settings. You'll need to test the connection again to sync." ) } } private func performSync() { Task { do { try await syncService.syncWithServer(dataManager: dataManager) } catch { print("Sync failed: \(error)") } } } } struct SyncSettingsView: View { @EnvironmentObject var syncService: SyncService @Environment(\.dismiss) private var dismiss @State private var serverURL: String = "" @State private var authToken: String = "" @State private var showingDisconnectAlert = false @State private var isTesting = false @State private var showingTestResult = false @State private var testResultMessage = "" var body: some View { NavigationView { Form { Section { TextField("Server URL", text: $serverURL) .textFieldStyle(.roundedBorder) .keyboardType(.URL) .autocapitalization(.none) .disableAutocorrection(true) .placeholder(when: serverURL.isEmpty) { Text("http://your-server:8080") .foregroundColor(.secondary) } TextField("Auth Token", text: $authToken) .textFieldStyle(.roundedBorder) .autocapitalization(.none) .disableAutocorrection(true) .placeholder(when: authToken.isEmpty) { Text("your-secret-token") .foregroundColor(.secondary) } } header: { Text("Server Configuration") } footer: { Text( "Enter your sync server URL and authentication token. You must test the connection before syncing is available." ) } Section { Button(action: { testConnection() }) { HStack { if isTesting { ProgressView() .scaleEffect(0.8) Text("Testing...") .foregroundColor(.secondary) } else { Image(systemName: "network") .foregroundColor(.blue) Text("Test Connection") Spacer() if syncService.isConnected { Image(systemName: "checkmark.circle.fill") .foregroundColor(.green) } } } } .disabled( isTesting || serverURL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || authToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ) .foregroundColor(.primary) } header: { Text("Connection") } footer: { Text("Test the connection to verify your server settings before saving.") } Section { Button("Disconnect from Server") { showingDisconnectAlert = true } .foregroundColor(.orange) Button("Clear Configuration") { syncService.clearConfiguration() serverURL = "" authToken = "" } .foregroundColor(.red) } footer: { Text( "Disconnect will sign you out but keep settings. Clear Configuration removes all sync settings." ) } } .navigationTitle("Sync Settings") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .navigationBarLeading) { Button("Cancel") { dismiss() } } ToolbarItem(placement: .navigationBarTrailing) { Button("Save") { let newURL = serverURL.trimmingCharacters(in: .whitespacesAndNewlines) let newToken = authToken.trimmingCharacters(in: .whitespacesAndNewlines) // Mark as disconnected if settings changed if newURL != syncService.serverURL || newToken != syncService.authToken { syncService.isConnected = false UserDefaults.standard.set(false, forKey: "sync_is_connected") } syncService.serverURL = newURL syncService.authToken = newToken dismiss() } .fontWeight(.semibold) } } } .onAppear { serverURL = syncService.serverURL authToken = syncService.authToken } .alert("Disconnect from Server", isPresented: $showingDisconnectAlert) { Button("Cancel", role: .cancel) {} Button("Disconnect", role: .destructive) { syncService.disconnect() dismiss() } } message: { Text( "This will sign you out but keep your server settings. You'll need to test the connection again to sync." ) } .alert("Connection Test", isPresented: $showingTestResult) { Button("OK") {} } message: { Text(testResultMessage) } } private func testConnection() { isTesting = true let testURL = serverURL.trimmingCharacters(in: .whitespacesAndNewlines) let testToken = authToken.trimmingCharacters(in: .whitespacesAndNewlines) // Store original values in case test fails let originalURL = syncService.serverURL let originalToken = syncService.authToken Task { do { // Temporarily set the values for testing syncService.serverURL = testURL syncService.authToken = testToken try await syncService.testConnection() await MainActor.run { isTesting = false testResultMessage = "Connection successful! You can now save and sync your data." showingTestResult = true } } catch { // Restore original values if test failed syncService.serverURL = originalURL syncService.authToken = originalToken await MainActor.run { isTesting = false testResultMessage = "Connection failed: \(error.localizedDescription)" showingTestResult = true } } } } } // Removed AutoSyncSettingsView - now using simple toggle in main settings extension View { func placeholder( when shouldShow: Bool, alignment: Alignment = .leading, @ViewBuilder placeholder: () -> Content ) -> some View { ZStack(alignment: alignment) { placeholder().opacity(shouldShow ? 1 : 0) self } } } 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("⚠️ 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 // Auto-close after successful import DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { dismiss() } } } catch { await MainActor.run { isImporting = false importError = "Import failed: \(error.localizedDescription)" } } } } } #Preview { SettingsView() .environmentObject(ClimbingDataManager.preview) }