import HealthKit 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 { NavigationStack { List { SyncSection() .environmentObject(dataManager.syncService) HealthKitSection() .environmentObject(dataManager.healthKitService) DataManagementSection( activeSheet: $activeSheet ) AppInfoSection() } .navigationTitle("Settings") .navigationBarTitleDisplayMode(.automatic) .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 @State private var isDeletingImages = false @State private var showingDeleteImagesAlert = 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) // Delete All Images Button(action: { showingDeleteImagesAlert = true }) { HStack { if isDeletingImages { ProgressView() .scaleEffect(0.8) Text("Deleting Images...") .foregroundColor(.secondary) } else { Image(systemName: "trash") .foregroundColor(.red) Text("Delete All Images") } Spacer() } } .disabled(isDeletingImages) .foregroundColor(.red) // 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." ) } .alert("Delete All Images", isPresented: $showingDeleteImagesAlert) { Button("Cancel", role: .cancel) {} Button("Delete", role: .destructive) { deleteAllImages() } } message: { Text( "This will permanently delete ALL image files from your device.\n\nProblems will keep their references but the actual image files will be removed. This cannot be undone.\n\nConsider exporting your data first if you want to keep your images." ) } } private func exportDataAsync() { isExporting = true Task { let data = await MainActor.run { dataManager.exportData() } isExporting = false if let data = data { activeSheet = .export(data) } } } private func deleteAllImages() { isDeletingImages = true Task { await MainActor.run { deleteAllImageFiles() isDeletingImages = false dataManager.successMessage = "All images deleted successfully!" } } } private func deleteAllImageFiles() { let imageManager = ImageManager.shared let fileManager = FileManager.default // Delete all images from the images directory let imagesDir = imageManager.imagesDirectory do { let imageFiles = try fileManager.contentsOfDirectory( at: imagesDir, includingPropertiesForKeys: nil) var deletedCount = 0 for imageFile in imageFiles { do { try fileManager.removeItem(at: imageFile) deletedCount += 1 } catch { print("Failed to delete image: \(imageFile.lastPathComponent)") } } print("Deleted \(deletedCount) image files") } catch { print("Failed to access images directory: \(error)") } // Delete all images from backup directory let backupDir = imageManager.backupDirectory do { let backupFiles = try fileManager.contentsOfDirectory( at: backupDir, includingPropertiesForKeys: nil) for backupFile in backupFiles { try? fileManager.removeItem(at: backupFile) } } catch { print("Failed to access backup directory: \(error)") } // Clear image paths from all problems let updatedProblems = dataManager.problems.map { problem in problem.updated(imagePaths: []) } for problem in updatedProblems { dataManager.updateProblem(problem) } } } 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("AppLogo") .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 { NavigationStack { 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("AppLogo")) ) { 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 { NavigationStack { 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 { NavigationStack { 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)" } } } } } struct HealthKitSection: View { @EnvironmentObject var healthKitService: HealthKitService @State private var showingAuthorizationError = false @State private var isRequestingAuthorization = false var body: some View { Section { if !HKHealthStore.isHealthDataAvailable() { HStack { Image(systemName: "heart.slash") .foregroundColor(.secondary) Text("Apple Health not available") .foregroundColor(.secondary) } } else { Toggle( isOn: Binding( get: { healthKitService.isEnabled }, set: { newValue in if newValue && !healthKitService.isAuthorized { isRequestingAuthorization = true Task { do { try await healthKitService.requestAuthorization() await MainActor.run { healthKitService.setEnabled(true) isRequestingAuthorization = false } } catch { await MainActor.run { showingAuthorizationError = true isRequestingAuthorization = false } } } } else if newValue { healthKitService.setEnabled(true) } else { healthKitService.setEnabled(false) } } ) ) { HStack { Image(systemName: "heart.fill") .foregroundColor(.red) Text("Apple Health Integration") } } .disabled(isRequestingAuthorization) if healthKitService.isEnabled { VStack(alignment: .leading, spacing: 4) { Text( "Climbing sessions will be recorded as workouts in Apple Health" ) .font(.caption) .foregroundColor(.secondary) } } } } header: { Text("Health") } footer: { if healthKitService.isEnabled { Text( "Each climbing session will automatically be saved to Apple Health as a \"Climbing\" workout with the session duration." ) } } .alert("Authorization Required", isPresented: $showingAuthorizationError) { Button("OK", role: .cancel) {} } message: { Text( "Please grant access to Apple Health in Settings to enable this feature." ) } } } #Preview { SettingsView() .environmentObject(ClimbingDataManager.preview) }