// Copyright (C) 2024 Rubén Beltrán del Río // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // You should have received a copy of the GNU General Public License // along with this program. If not, see https://map.tranquil.systems. import SwiftUI import UniformTypeIdentifiers struct GeneralPreferencesView: View { @State private var showingResetAlert = false @State private var showingExportDialog = false @State private var showingImportDialog = false @State private var showingImportError = false @State private var importErrorMessage = "" @State private var showingExportFailedAlert = false @State private var exportShouldReset = false @AppStorage("appearanceStyle") private var appearanceStyle: String = UserPreferences.Defaults .appearanceStyle @AppStorage("quickLookPreviewStyle", store: UserDefaults(suiteName: "group.systems.tranquil.Map")) private var quickLookPreviewStyle: String = UserPreferences.Defaults.quickLookPreviewStyle var body: some View { VStack(alignment: .center, spacing: Dimensions.Spacing.loose) { // Appearance section HStack(alignment: .firstTextBaseline, spacing: Dimensions.Spacing.regular) { Text("preferences.appearance.title") .font(.Theme.Body.emphasized) .frame(maxWidth: 250, alignment: .trailing) Picker("", selection: $appearanceStyle) { ForEach(AppearanceStyle.allCases, id: \.rawValue) { style in Text(style.localizedName) .tag(style.rawValue) } } .pickerStyle(.radioGroup) .frame(maxWidth: 250, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading) } Divider() // QuickLook section HStack(alignment: .firstTextBaseline, spacing: Dimensions.Spacing.regular) { Text("preferences.quick_look.title") .font(.Theme.Body.emphasized) .frame(maxWidth: 250, alignment: .trailing) VStack(alignment: .leading, spacing: Dimensions.Spacing.regular) { Text("preferences.quick_look.preview_style.title") .font(.Theme.Body.regular) Picker("", selection: $quickLookPreviewStyle) { ForEach(QuickLookPreviewStyle.allCases, id: \.rawValue) { style in Text(style.localizedName) .tag(style.rawValue) } } .pickerStyle(.menu) .frame(maxWidth: 250) .padding(.leading, -Dimensions.Spacing.regular) } .frame(maxWidth: .infinity, alignment: .leading) } Divider() HStack(alignment: .firstTextBaseline, spacing: Dimensions.Spacing.regular) { Text("preferences.general.preferences.title") .font(.Theme.Body.emphasized) .frame(maxWidth: 250, alignment: .trailing) VStack(alignment: .leading, spacing: Dimensions.Spacing.regular) { HStack(spacing: Dimensions.Spacing.cozy) { Button( action: { exportShouldReset = false showingExportDialog = true }, label: { Text("preferences.general.preferences.export") .font(.Theme.Body.regular) } ) .buttonStyle(.bordered) Button( action: { showingImportDialog = true }, label: { Text("preferences.general.preferences.import") .font(.Theme.Body.regular) } ) .buttonStyle(.bordered) } Button( action: { showingResetAlert = true }, label: { Text("preferences.general.preferences.reset") .font(.Theme.Body.regular) .foregroundColor(Color.Theme.UI.secondary) } ) .buttonStyle(.borderless) } .frame(maxWidth: .infinity, alignment: .leading) } Spacer() } .padding(.vertical, Dimensions.Spacing.loose) .padding(.horizontal, Dimensions.Spacing.indulgent) .alert("preferences.general.reset.title", isPresented: $showingResetAlert) { Button("preferences.general.reset.cancel", role: .cancel) {} Button("preferences.general.reset.without_export", role: .destructive) { resetPreferences() } Button("preferences.general.reset.export_and_reset") { exportShouldReset = true showingExportDialog = true } } message: { Text("preferences.general.reset.message") } .alert("preferences.general.import.error.title", isPresented: $showingImportError) { Button("preferences.general.import.error.ok") {} } message: { Text(importErrorMessage) } .alert("preferences.general.export.failed.title", isPresented: $showingExportFailedAlert) { Button("preferences.general.export.failed.ok") {} } message: { Text("preferences.general.export.failed.message") } .fileExporter( isPresented: $showingExportDialog, document: PreferencesDocument(), contentType: .json, defaultFilename: String(localized: "preferences.general.export.filename") ) { result in handleExportResult(result) } .fileImporter( isPresented: $showingImportDialog, allowedContentTypes: [.json], allowsMultipleSelection: false ) { result in handleImport(result) } } private func resetPreferences() { var preferences = UserPreferences() preferences.resetToDefaults() } private func handleExportResult(_ result: Result) { switch result { case .success: // Export successful if exportShouldReset { // Reset preferences if this was an export-and-reset operation resetPreferences() } // Reset the flag exportShouldReset = false case .failure(let error): if exportShouldReset { // Export failed during export-and-reset - show alert and don't reset showingExportFailedAlert = true exportShouldReset = false } else { // Regular export failed - just log it print("Export error: \(error)") } } } private func handleImport(_ result: Result<[URL], Error>) { switch result { case .success(let urls): guard let url = urls.first else { return } // Start accessing the security-scoped resource guard url.startAccessingSecurityScopedResource() else { importErrorMessage = String(localized: "preferences.general.import.error.file_read") showingImportError = true return } // Ensure we stop accessing the resource when done defer { url.stopAccessingSecurityScopedResource() } do { let jsonData = try Data(contentsOf: url) let jsonString = String(data: jsonData, encoding: .utf8) ?? "" var preferences = UserPreferences.loadFromUserDefaults() if preferences.updateFromJSON(jsonString) { // Import successful } else { importErrorMessage = String(localized: "preferences.general.import.error.invalid_format") showingImportError = true } } catch { importErrorMessage = String(localized: "preferences.general.import.error.file_read") showingImportError = true } case .failure(let error): importErrorMessage = error.localizedDescription showingImportError = true } } } // Document wrapper for file export struct PreferencesDocument: FileDocument { static var readableContentTypes: [UTType] { [.json] } var preferences: UserPreferences init() { self.preferences = UserPreferences.loadFromUserDefaults() } init(configuration: ReadConfiguration) throws { self.preferences = UserPreferences() } func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper { let jsonString = preferences.toJSON() ?? "{}" let data = jsonString.data(using: .utf8) ?? Data() return FileWrapper(regularFileWithContents: data) } } #Preview { GeneralPreferencesView() }