aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorRuben Beltran del Rio <git@r.bdr.sh>2025-07-08 21:21:08 +0200
committerRuben Beltran del Rio <git@r.bdr.sh>2025-07-08 21:21:08 +0200
commitc21a68c807dcb84b4ce910ee0f73238a8019ffe9 (patch)
treeae1e5e0fc7a846daf74623c6cd6270612ff0e56f
parent8f6da65a680cdd30ec541b3b226137004a1e4681 (diff)
Allow export/import of preferences
-rw-r--r--Map/Data/UserPreferences.swift322
-rw-r--r--Map/Localizable.xcstrings209
-rw-r--r--Map/Presentation/Commands/MapCommands.swift8
-rw-r--r--Map/Presentation/Complex Components/MapRender/MapRenderView.swift17
-rw-r--r--Map/Presentation/MapEditor.swift13
-rw-r--r--Map/Presentation/Preferences/EditorPreferencesView.swift11
-rw-r--r--Map/Presentation/Preferences/GeneralPreferencesView.swift187
-rw-r--r--Map/Presentation/Preferences/MapPreferencesView.swift19
-rw-r--r--Map/Presentation/Preferences/StagesPreferencesView.swift7
-rw-r--r--Map/Presentation/Preferences/TemplatesPreferencesView.swift34
-rw-r--r--Map/Presentation/PreferencesWindow.swift16
-rw-r--r--MapTests/UserPreferencesTests.swift327
-rw-r--r--WISHLIST.md2
13 files changed, 1088 insertions, 84 deletions
diff --git a/Map/Data/UserPreferences.swift b/Map/Data/UserPreferences.swift
new file mode 100644
index 0000000..cf9dd7e
--- /dev/null
+++ b/Map/Data/UserPreferences.swift
@@ -0,0 +1,322 @@
+// 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 Foundation
+
+// MARK: - JSON Export/Import Structures
+struct UserPreferencesJSON: Codable {
+ // MARK: - Map Preferences
+ var showMapBackground: Bool?
+ var useCustomFont: Bool?
+ var customFontName: String?
+ var defaultExportFormat: String?
+ var useSmartLabelPositioning: Bool?
+
+ // MARK: - Editor Preferences
+ var useSmartEditor: Bool?
+ var useCustomEditorFont: Bool?
+ var customEditorFontName: String?
+ var editorFontSize: Double?
+
+ // MARK: - Templates (as actual objects)
+ var mapTemplates: [Template]?
+
+ // MARK: - Stages (as actual objects)
+ var customStages: [CustomStage]?
+
+ // MARK: - UI Preferences
+ var viewStyle: String?
+ var zoom: Double?
+}
+
+struct UserPreferences: Codable {
+
+ // MARK: - Map Preferences
+ var showMapBackground: Bool
+ var useCustomFont: Bool
+ var customFontName: String
+ var defaultExportFormat: String
+ var useSmartLabelPositioning: Bool
+
+ // MARK: - Editor Preferences
+ var useSmartEditor: Bool
+ var useCustomEditorFont: Bool
+ var customEditorFontName: String
+ var editorFontSize: Double
+
+ // MARK: - Templates
+ var mapTemplates: Data
+
+ // MARK: - Stages
+ var customStages: Data
+
+ // MARK: - UI Preferences
+ var viewStyle: String
+ var zoom: Double
+
+ // MARK: - Default Values
+ struct Defaults {
+ static let showMapBackground = true
+ static let useCustomFont = false
+ static let customFontName = "Helvetica"
+ static let defaultExportFormat = "png"
+ static let useSmartLabelPositioning = true
+
+ static let useSmartEditor = false
+ static let useCustomEditorFont = false
+ static let customEditorFontName = "Menlo"
+ static let editorFontSize = 14.0
+
+ static let viewStyle = "horizontal"
+ static let zoom = 1.0
+
+ // Computed properties for complex defaults
+ static var mapTemplates: Data {
+ let defaultTemplate = Template(
+ name: String(localized: "map_template.default.title"),
+ content: String(localized: "map_template.default.content"),
+ isDefault: true
+ )
+
+ let emptyTemplate = Template(
+ name: String(localized: "map_template.empty.title"),
+ content: "",
+ isDefault: false
+ )
+
+ let templates = [defaultTemplate, emptyTemplate]
+ return (try? JSONEncoder().encode(templates)) ?? Data()
+ }
+
+ static var customStages: Data {
+ let defaultStage = CustomStage.cynefinDefault()
+ let stages = [defaultStage]
+ return (try? JSONEncoder().encode(stages)) ?? Data()
+ }
+ }
+
+ // MARK: - Initialization
+ init() {
+ self.showMapBackground = Defaults.showMapBackground
+ self.useCustomFont = Defaults.useCustomFont
+ self.customFontName = Defaults.customFontName
+ self.defaultExportFormat = Defaults.defaultExportFormat
+ self.useSmartLabelPositioning = Defaults.useSmartLabelPositioning
+
+ self.useSmartEditor = Defaults.useSmartEditor
+ self.useCustomEditorFont = Defaults.useCustomEditorFont
+ self.customEditorFontName = Defaults.customEditorFontName
+ self.editorFontSize = Defaults.editorFontSize
+
+ self.mapTemplates = Defaults.mapTemplates
+ self.customStages = Defaults.customStages
+
+ self.viewStyle = Defaults.viewStyle
+ self.zoom = Defaults.zoom
+ }
+
+ // MARK: - JSON Methods
+ func toJSON() -> String? {
+ let encoder = JSONEncoder()
+ encoder.outputFormatting = .prettyPrinted
+
+ do {
+ // Convert to JSON-friendly format
+ let jsonPrefs = UserPreferencesJSON(
+ showMapBackground: showMapBackground,
+ useCustomFont: useCustomFont,
+ customFontName: customFontName,
+ defaultExportFormat: defaultExportFormat,
+ useSmartLabelPositioning: useSmartLabelPositioning,
+ useSmartEditor: useSmartEditor,
+ useCustomEditorFont: useCustomEditorFont,
+ customEditorFontName: customEditorFontName,
+ editorFontSize: editorFontSize,
+ mapTemplates: templatesFromData(mapTemplates),
+ customStages: stagesFromData(customStages),
+ viewStyle: viewStyle,
+ zoom: zoom
+ )
+
+ let data = try encoder.encode(jsonPrefs)
+ return String(data: data, encoding: .utf8)
+ } catch {
+ return nil
+ }
+ }
+
+ mutating func updateFromJSON(_ json: String) -> Bool {
+ guard let data = json.data(using: .utf8) else { return false }
+ do {
+ let decoder = JSONDecoder()
+ let jsonPrefs = try decoder.decode(UserPreferencesJSON.self, from: data)
+
+ // Convert from JSON format back to UserPreferences
+ // Only update values that are present in the JSON (partial updates)
+ if let value = jsonPrefs.showMapBackground {
+ self.showMapBackground = value
+ }
+ if let value = jsonPrefs.useCustomFont {
+ self.useCustomFont = value
+ }
+ if let value = jsonPrefs.customFontName {
+ self.customFontName = value
+ }
+ if let value = jsonPrefs.defaultExportFormat {
+ self.defaultExportFormat = value
+ }
+ if let value = jsonPrefs.useSmartLabelPositioning {
+ self.useSmartLabelPositioning = value
+ }
+ if let value = jsonPrefs.useSmartEditor {
+ self.useSmartEditor = value
+ }
+ if let value = jsonPrefs.useCustomEditorFont {
+ self.useCustomEditorFont = value
+ }
+ if let value = jsonPrefs.customEditorFontName {
+ self.customEditorFontName = value
+ }
+ if let value = jsonPrefs.editorFontSize {
+ self.editorFontSize = value
+ }
+ if let value = jsonPrefs.mapTemplates {
+ self.mapTemplates = dataFromTemplates(value)
+ }
+ if let value = jsonPrefs.customStages {
+ self.customStages = dataFromStages(value)
+ }
+ if let value = jsonPrefs.viewStyle {
+ self.viewStyle = value
+ }
+ if let value = jsonPrefs.zoom {
+ self.zoom = value
+ }
+
+ apply()
+ return true
+ } catch {
+ print("JSON import error: \(error)")
+ return false
+ }
+ }
+
+ // MARK: - Helper Methods for Data/Object Conversion
+ private func templatesFromData(_ data: Data) -> [Template] {
+ guard let templates = try? JSONDecoder().decode([Template].self, from: data) else {
+ return []
+ }
+ return templates
+ }
+
+ private func dataFromTemplates(_ templates: [Template]) -> Data {
+ guard let data = try? JSONEncoder().encode(templates) else {
+ return Data()
+ }
+ return data
+ }
+
+ private func stagesFromData(_ data: Data) -> [CustomStage] {
+ guard let stages = try? JSONDecoder().decode([CustomStage].self, from: data) else {
+ return []
+ }
+ return stages
+ }
+
+ private func dataFromStages(_ stages: [CustomStage]) -> Data {
+ guard let data = try? JSONEncoder().encode(stages) else {
+ return Data()
+ }
+ return data
+ }
+
+ // MARK: - Reset to Defaults
+ mutating func resetToDefaults() {
+ self = UserPreferences()
+ apply()
+ }
+
+ // MARK: - Apply to UserDefaults
+ private func apply() {
+ let defaults = UserDefaults.standard
+
+ // Map Preferences
+ defaults.set(showMapBackground, forKey: "showMapBackground")
+ defaults.set(useCustomFont, forKey: "useCustomFont")
+ defaults.set(customFontName, forKey: "customFontName")
+ defaults.set(defaultExportFormat, forKey: "defaultExportFormat")
+ defaults.set(useSmartLabelPositioning, forKey: "useSmartLabelPositioning")
+
+ // Editor Preferences
+ defaults.set(useSmartEditor, forKey: "useSmartEditor")
+ defaults.set(useCustomEditorFont, forKey: "useCustomEditorFont")
+ defaults.set(customEditorFontName, forKey: "customEditorFontName")
+ defaults.set(editorFontSize, forKey: "editorFontSize")
+
+ // Templates
+ defaults.set(mapTemplates, forKey: "mapTemplates")
+
+ // Stages
+ defaults.set(customStages, forKey: "customStages")
+
+ // UI Preferences
+ defaults.set(viewStyle, forKey: "viewStyle")
+ defaults.set(zoom, forKey: "zoom")
+ }
+
+ // MARK: - Load from UserDefaults
+ static func loadFromUserDefaults() -> UserPreferences {
+ let defaults = UserDefaults.standard
+
+ var preferences = UserPreferences()
+
+ // Map Preferences
+ preferences.showMapBackground =
+ defaults.object(forKey: "showMapBackground") as? Bool ?? Defaults.showMapBackground
+ preferences.useCustomFont =
+ defaults.object(forKey: "useCustomFont") as? Bool ?? Defaults.useCustomFont
+ preferences.customFontName =
+ defaults.object(forKey: "customFontName") as? String ?? Defaults.customFontName
+ preferences.defaultExportFormat =
+ defaults.object(forKey: "defaultExportFormat") as? String ?? Defaults.defaultExportFormat
+ preferences.useSmartLabelPositioning =
+ defaults.object(forKey: "useSmartLabelPositioning") as? Bool
+ ?? Defaults.useSmartLabelPositioning
+
+ // Editor Preferences
+ preferences.useSmartEditor =
+ defaults.object(forKey: "useSmartEditor") as? Bool ?? Defaults.useSmartEditor
+ preferences.useCustomEditorFont =
+ defaults.object(forKey: "useCustomEditorFont") as? Bool ?? Defaults.useCustomEditorFont
+ preferences.customEditorFontName =
+ defaults.object(forKey: "customEditorFontName") as? String ?? Defaults.customEditorFontName
+ preferences.editorFontSize =
+ defaults.object(forKey: "editorFontSize") as? Double ?? Defaults.editorFontSize
+
+ // Templates
+ preferences.mapTemplates =
+ defaults.object(forKey: "mapTemplates") as? Data ?? Defaults.mapTemplates
+
+ // Stages
+ preferences.customStages =
+ defaults.object(forKey: "customStages") as? Data ?? Defaults.customStages
+
+ // UI Preferences
+ preferences.viewStyle = defaults.object(forKey: "viewStyle") as? String ?? Defaults.viewStyle
+ preferences.zoom = defaults.object(forKey: "zoom") as? Double ?? Defaults.zoom
+
+ return preferences
+ }
+}
diff --git a/Map/Localizable.xcstrings b/Map/Localizable.xcstrings
index d7cc56a..8f9ddce 100644
--- a/Map/Localizable.xcstrings
+++ b/Map/Localizable.xcstrings
@@ -219,15 +219,6 @@
}
}
},
- "General Preferences" : {
-
- },
- "General settings will be available here in a future version." : {
-
- },
- "Key" : {
- "extractionState" : "manual"
- },
"map_editor.zoom_in" : {
"extractionState" : "manual",
"localizations" : {
@@ -256,7 +247,7 @@
"en" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Anchor (95, 80)\nUsers (90, 20)\nWalking App (70, 20)\nWalking App Database (50, 60)\nUser Interface (80, 50)\nWeb Server (60, 30)\nCRM (30, 70)\n\nUsers -> Walking App\nUsers -> User Interface\nWalking App -> Walking App Database\nWalking App -> Web Server\nUser Interface -> Web Server\nWeb Server -> CRM\n\n[Note] (80, 5) Social walking app for everyone\n\n[Group] Client Side, Walking App, User Interface\n\n[Evolution] Walking App +5\n\n[Inertia] Unreliable Data"
+ "value" : "Anchor (95, 80)\nUsers (90, 20)\nWalking App (70, 20)\nWalking App Database (50, 60)\nUser Interface (80, 50)\nWeb Server (60, 30)\nCRM (30, 70)\n\nUsers -> Walking App\nUsers -> User Interface\nWalking App -> Walking App Database\nWalking App -> Web Server\nUser Interface -> Web Server\nWeb Server -> CRM\n\n[Note] (80, 5) Social walking app for everyone\n\n[Group] Client Side, Walking App, User Interface\n\n[Evolution] Walking App +5\n\n[Inertia] CRM"
}
}
}
@@ -393,6 +384,193 @@
}
}
},
+ "preferences.general.export.failed.message" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Could not save the preferences file. Preferences were not reset."
+ }
+ }
+ }
+ },
+ "preferences.general.export.failed.ok" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "OK"
+ }
+ }
+ }
+ },
+ "preferences.general.export.failed.title" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Export Failed"
+ }
+ }
+ }
+ },
+ "preferences.general.export.filename" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "map-preferences"
+ }
+ }
+ }
+ },
+ "preferences.general.import.error.file_read" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Could not read the preferences file."
+ }
+ }
+ }
+ },
+ "preferences.general.import.error.invalid_format" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "The preferences file format is invalid."
+ }
+ }
+ }
+ },
+ "preferences.general.import.error.ok" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "OK"
+ }
+ }
+ }
+ },
+ "preferences.general.import.error.title" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Import Error"
+ }
+ }
+ }
+ },
+ "preferences.general.preferences.export" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Export to JSON"
+ }
+ }
+ }
+ },
+ "preferences.general.preferences.import" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Import from JSON"
+ }
+ }
+ }
+ },
+ "preferences.general.preferences.reset" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Reset All Preferences to Defaults"
+ }
+ }
+ }
+ },
+ "preferences.general.preferences.title" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Preferences:"
+ }
+ }
+ }
+ },
+ "preferences.general.reset.cancel" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Cancel"
+ }
+ }
+ }
+ },
+ "preferences.general.reset.export_and_reset" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Export and Reset"
+ }
+ }
+ }
+ },
+ "preferences.general.reset.message" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "This will delete any custom evolution stages or templates that you have added."
+ }
+ }
+ }
+ },
+ "preferences.general.reset.title" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Are you sure you want to reset preferences to defaults?"
+ }
+ }
+ }
+ },
+ "preferences.general.reset.without_export" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Reset without Exporting"
+ }
+ }
+ }
+ },
"preferences.map.export.default_format" : {
"extractionState" : "manual",
"localizations" : {
@@ -448,6 +626,17 @@
}
}
},
+ "preferences.map.map_style.use_smart_label_positioning" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Use smart label positioning"
+ }
+ }
+ }
+ },
"preferences.menu.editor" : {
"extractionState" : "manual",
"localizations" : {
diff --git a/Map/Presentation/Commands/MapCommands.swift b/Map/Presentation/Commands/MapCommands.swift
index 2d9809d..a298956 100644
--- a/Map/Presentation/Commands/MapCommands.swift
+++ b/Map/Presentation/Commands/MapCommands.swift
@@ -17,9 +17,11 @@ import UniformTypeIdentifiers
struct MapCommands: Commands {
- @AppStorage("viewStyle") var viewStyle: ViewStyle = .horizontal
- @AppStorage("zoom") var zoom = 1.0
- @AppStorage("mapTemplates") private var templatesData: Data = Data()
+ @AppStorage("viewStyle") var viewStyle: ViewStyle =
+ ViewStyle(rawValue: UserPreferences.Defaults.viewStyle) ?? .horizontal
+ @AppStorage("zoom") var zoom = UserPreferences.Defaults.zoom
+ @AppStorage("mapTemplates") private var templatesData: Data = UserPreferences.Defaults
+ .mapTemplates
@FocusedBinding(\.document) var document: MapDocument?
@FocusedValue(\.fileURL) var url: URL?
@FocusedBinding(\.selectedEvolution) var selectedEvolution: StageType?
diff --git a/Map/Presentation/Complex Components/MapRender/MapRenderView.swift b/Map/Presentation/Complex Components/MapRender/MapRenderView.swift
index 9e1f4a2..72a765d 100644
--- a/Map/Presentation/Complex Components/MapRender/MapRenderView.swift
+++ b/Map/Presentation/Complex Components/MapRender/MapRenderView.swift
@@ -21,9 +21,13 @@ struct MapRenderView: View {
@Binding var document: MapDocument
@Binding var evolution: StageType
- @AppStorage("showMapBackground") var showMapBackground: Bool = true
- @AppStorage("useCustomFont") var useCustomFont: Bool = false
- @AppStorage("customFontName") var customFontName: String = "Helvetica"
+ @AppStorage("showMapBackground") var showMapBackground: Bool = UserPreferences.Defaults
+ .showMapBackground
+ @AppStorage("useCustomFont") var useCustomFont: Bool = UserPreferences.Defaults.useCustomFont
+ @AppStorage("customFontName") var customFontName: String = UserPreferences.Defaults.customFontName
+ @AppStorage("useSmartLabelPositioning") private var useSmartLabelPositioning = UserPreferences
+ .Defaults.useSmartLabelPositioning
+
var stage: Stage {
Stage.stages(evolution)
}
@@ -53,6 +57,8 @@ struct MapRenderView: View {
let positions = await Task.detached(priority: .userInitiated) {
var positions: [Int: CGPoint] = [:]
+ guard await useSmartLabelPositioning else { return positions }
+
let axisLines = [
Line(start: CGPoint(x: 0, y: 0), end: CGPoint(x: 0, y: mapSize.height)),
Line(
@@ -133,7 +139,10 @@ struct MapRenderView: View {
.onAppear {
parseMapInBackground()
}
- .onChange(of: document.text) { _, _ in
+ .onChange(of: document.text) {
+ parseMapInBackground()
+ }
+ .onChange(of: useSmartLabelPositioning) {
parseMapInBackground()
}
.onDisappear {
diff --git a/Map/Presentation/MapEditor.swift b/Map/Presentation/MapEditor.swift
index 73302df..11545e2 100644
--- a/Map/Presentation/MapEditor.swift
+++ b/Map/Presentation/MapEditor.swift
@@ -22,13 +22,16 @@ struct MapEditor: View {
private let changeDebouncer: Debouncer = Debouncer(seconds: 0.05)
- @AppStorage("viewStyle") var viewStyle: ViewStyle = .horizontal
- @AppStorage("useCustomEditorFont") var useCustomEditorFont: Bool = false
- @AppStorage("customEditorFontName") var customEditorFontName: String = "Menlo"
- @AppStorage("editorFontSize") var editorFontSize: Double = 14
+ @AppStorage("viewStyle") var viewStyle: ViewStyle =
+ ViewStyle(rawValue: UserPreferences.Defaults.viewStyle) ?? .horizontal
+ @AppStorage("useCustomEditorFont") var useCustomEditorFont: Bool = UserPreferences.Defaults
+ .useCustomEditorFont
+ @AppStorage("customEditorFontName") var customEditorFontName: String = UserPreferences.Defaults
+ .customEditorFontName
+ @AppStorage("editorFontSize") var editorFontSize: Double = UserPreferences.Defaults.editorFontSize
let zoomRange = Constants.kMinZoom...Constants.kMaxZoom
- @AppStorage("zoom") var zoom = 1.0
+ @AppStorage("zoom") var zoom = UserPreferences.Defaults.zoom
@State var lastZoom = 1.0
@State var searchTerm = ""
@State var selectedTerm = 0
diff --git a/Map/Presentation/Preferences/EditorPreferencesView.swift b/Map/Presentation/Preferences/EditorPreferencesView.swift
index cf584ff..91b332f 100644
--- a/Map/Presentation/Preferences/EditorPreferencesView.swift
+++ b/Map/Presentation/Preferences/EditorPreferencesView.swift
@@ -17,10 +17,13 @@ import SwiftUI
struct EditorPreferencesView: View {
- @AppStorage("useSmartEditor") private var useSmartEditor = false
- @AppStorage("useCustomEditorFont") private var useCustomEditorFont = false
- @AppStorage("customEditorFontName") private var customEditorFontName = "Menlo"
- @AppStorage("editorFontSize") private var editorFontSize: Double = 14
+ @AppStorage("useSmartEditor") private var useSmartEditor = UserPreferences.Defaults.useSmartEditor
+ @AppStorage("useCustomEditorFont") private var useCustomEditorFont = UserPreferences.Defaults
+ .useCustomEditorFont
+ @AppStorage("customEditorFontName") private var customEditorFontName = UserPreferences.Defaults
+ .customEditorFontName
+ @AppStorage("editorFontSize") private var editorFontSize: Double = UserPreferences.Defaults
+ .editorFontSize
var monospacedFonts: [String] {
let fontManager = NSFontManager.shared
diff --git a/Map/Presentation/Preferences/GeneralPreferencesView.swift b/Map/Presentation/Preferences/GeneralPreferencesView.swift
index 9eb829e..a70d608 100644
--- a/Map/Presentation/Preferences/GeneralPreferencesView.swift
+++ b/Map/Presentation/Preferences/GeneralPreferencesView.swift
@@ -14,20 +14,193 @@
// 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
+
var body: some View {
- VStack {
- Text("General Preferences")
- .font(.largeTitle)
- .padding()
+ VStack(alignment: .center, spacing: Dimensions.Spacing.loose) {
+ 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)
- Text("General settings will be available here in a future version.")
- .foregroundColor(.secondary)
+ 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.jasperRed)
+ }
+ )
+ .buttonStyle(.borderless)
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
Spacer()
}
- .padding()
+ .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<URL, Error>) {
+ 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)
}
}
diff --git a/Map/Presentation/Preferences/MapPreferencesView.swift b/Map/Presentation/Preferences/MapPreferencesView.swift
index f6c909c..71e9d6c 100644
--- a/Map/Presentation/Preferences/MapPreferencesView.swift
+++ b/Map/Presentation/Preferences/MapPreferencesView.swift
@@ -16,10 +16,14 @@
import SwiftUI
struct MapPreferencesView: View {
- @AppStorage("showMapBackground") private var showBackground = true
- @AppStorage("useCustomFont") private var useCustomFont = false
- @AppStorage("customFontName") private var customFontName = "Helvetica"
- @AppStorage("defaultExportFormat") private var defaultExportFormat = "png"
+ @AppStorage("showMapBackground") private var showBackground = UserPreferences.Defaults
+ .showMapBackground
+ @AppStorage("useCustomFont") private var useCustomFont = UserPreferences.Defaults.useCustomFont
+ @AppStorage("customFontName") private var customFontName = UserPreferences.Defaults.customFontName
+ @AppStorage("defaultExportFormat") private var defaultExportFormat = UserPreferences.Defaults
+ .defaultExportFormat
+ @AppStorage("useSmartLabelPositioning") private var useSmartLabelPositioning = UserPreferences
+ .Defaults.useSmartLabelPositioning
var body: some View {
VStack(alignment: .center, spacing: Dimensions.Spacing.loose) {
@@ -37,6 +41,13 @@ struct MapPreferencesView: View {
})
Toggle(
+ isOn: $useSmartLabelPositioning,
+ label: {
+ Text("preferences.map.map_style.use_smart_label_positioning")
+ .font(.Theme.Body.regular)
+ })
+
+ Toggle(
isOn: $useCustomFont,
label: {
Text("preferences.map.map_style.use_custom_font")
diff --git a/Map/Presentation/Preferences/StagesPreferencesView.swift b/Map/Presentation/Preferences/StagesPreferencesView.swift
index 6d2154c..542a7fe 100644
--- a/Map/Presentation/Preferences/StagesPreferencesView.swift
+++ b/Map/Presentation/Preferences/StagesPreferencesView.swift
@@ -16,7 +16,8 @@
import SwiftUI
struct StagesPreferencesView: View {
- @AppStorage("customStages") private var customStagesData: Data = Data()
+ @AppStorage("customStages") private var customStagesData: Data = UserPreferences.Defaults
+ .customStages
@State private var customStages: [CustomStage] = []
@State private var showingHelpPopover = false
@@ -128,10 +129,6 @@ struct StagesPreferencesView: View {
private func loadStages() {
if let decoded = try? JSONDecoder().decode([CustomStage].self, from: customStagesData) {
customStages = decoded
- } else {
- // First use - create default Cynefin stage
- customStages = [CustomStage.cynefinDefault()]
- saveStages() // Save the default to UserDefaults
}
}
diff --git a/Map/Presentation/Preferences/TemplatesPreferencesView.swift b/Map/Presentation/Preferences/TemplatesPreferencesView.swift
index 5de2428..375ed68 100644
--- a/Map/Presentation/Preferences/TemplatesPreferencesView.swift
+++ b/Map/Presentation/Preferences/TemplatesPreferencesView.swift
@@ -37,7 +37,8 @@ struct Template: Identifiable, Codable, Hashable {
}
struct TemplatesPreferencesView: View {
- @AppStorage("mapTemplates") private var templatesData: Data = Data()
+ @AppStorage("mapTemplates") private var templatesData: Data = UserPreferences.Defaults
+ .mapTemplates
@State private var selectedTemplate: Template?
@State private var showingAddSheet = false
@State private var newTemplateName = ""
@@ -183,12 +184,6 @@ struct TemplatesPreferencesView: View {
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
.onAppear {
- // Initialize default templates if needed
- if templates.wrappedValue.isEmpty
- || !templates.wrappedValue.contains(where: { $0.isDefault })
- {
- createDefaultTemplates()
- }
// Set first template as selected if none is selected
if selectedTemplate == nil && !templates.wrappedValue.isEmpty {
selectedTemplate = templates.wrappedValue.first
@@ -244,31 +239,6 @@ struct TemplatesPreferencesView: View {
.padding(.horizontal, Dimensions.Spacing.indulgent)
}
- private func createDefaultTemplates() {
- let defaultTemplate = Template(
- name: String(localized: "map_template.default.title"),
- content: String(localized: "map_template.default.content"),
- isDefault: true
- )
-
- let emptyTemplate = Template(
- name: String(localized: "map_template.empty.title"),
- content: "",
- isDefault: false
- )
-
- if templates.wrappedValue.isEmpty {
- templates.wrappedValue = [defaultTemplate, emptyTemplate]
- } else {
- // Add default template if none exists
- if !templates.wrappedValue.contains(where: { $0.isDefault }) {
- var currentTemplates = templates.wrappedValue
- currentTemplates.insert(defaultTemplate, at: 0)
- templates.wrappedValue = currentTemplates
- }
- }
- }
-
private func addTemplate() {
let newTemplate = Template(
name: newTemplateName.isEmpty
diff --git a/Map/Presentation/PreferencesWindow.swift b/Map/Presentation/PreferencesWindow.swift
index 06153ed..fa5bd4c 100644
--- a/Map/Presentation/PreferencesWindow.swift
+++ b/Map/Presentation/PreferencesWindow.swift
@@ -17,7 +17,7 @@ import SwiftUI
enum PreferencesTab: CaseIterable {
- // case general
+ case general
case editor
case map
case stages
@@ -25,8 +25,8 @@ enum PreferencesTab: CaseIterable {
var localizedStringKey: LocalizedStringKey {
switch self {
- // case .general:
- // return "preferences.menu.general"
+ case .general:
+ return "preferences.menu.general"
case .editor:
return "preferences.menu.editor"
case .map:
@@ -40,8 +40,8 @@ enum PreferencesTab: CaseIterable {
var systemImage: String {
switch self {
- // case .general:
- // return "gearshape"
+ case .general:
+ return "gearshape"
case .editor:
return "text.word.spacing"
case .map:
@@ -55,13 +55,13 @@ enum PreferencesTab: CaseIterable {
}
struct PreferencesWindow: View {
- @State private var selectedTab: PreferencesTab = .editor
+ @State private var selectedTab: PreferencesTab = .general
var body: some View {
Group {
switch selectedTab {
- // case .general:
- // GeneralPreferencesView()
+ case .general:
+ GeneralPreferencesView()
case .editor:
EditorPreferencesView()
case .map:
diff --git a/MapTests/UserPreferencesTests.swift b/MapTests/UserPreferencesTests.swift
new file mode 100644
index 0000000..70ea210
--- /dev/null
+++ b/MapTests/UserPreferencesTests.swift
@@ -0,0 +1,327 @@
+// 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 Foundation
+import Testing
+
+@testable import Map
+
+struct UserPreferencesTests {
+
+ @Test func testUserPreferencesJSONSerialization() async throws {
+ // Create test data
+ let testTemplate = Template(
+ name: "Test Template",
+ content: "Test content\nNode (50, 50)",
+ isDefault: true
+ )
+
+ let testStage = CustomStage(
+ name: "Test Stage",
+ stage: Stage(i: "Stage One", ii: "Stage Two", iii: "Stage Three", iv: "Stage Four")
+ )
+
+ // Create UserPreferences with test data
+ var preferences = UserPreferences()
+ preferences.showMapBackground = false
+ preferences.useCustomFont = true
+ preferences.customFontName = "Arial"
+ preferences.defaultExportFormat = "jpeg"
+ preferences.useSmartLabelPositioning = false
+ preferences.useSmartEditor = true
+ preferences.useCustomEditorFont = true
+ preferences.customEditorFontName = "Monaco"
+ preferences.editorFontSize = 16.0
+ preferences.viewStyle = "vertical"
+ preferences.zoom = 1.5
+
+ // Convert templates and stages to Data
+ let templatesData = try JSONEncoder().encode([testTemplate])
+ let stagesData = try JSONEncoder().encode([testStage])
+ preferences.mapTemplates = templatesData
+ preferences.customStages = stagesData
+
+ // Test JSON export
+ guard let jsonString = preferences.toJSON() else {
+ throw TestError("Failed to export preferences to JSON")
+ }
+
+ // Verify JSON contains expected structure
+ #expect(jsonString.contains("\"showMapBackground\" : false"))
+ #expect(jsonString.contains("\"useCustomFont\" : true"))
+ #expect(jsonString.contains("\"customFontName\" : \"Arial\""))
+ #expect(jsonString.contains("\"defaultExportFormat\" : \"jpeg\""))
+ #expect(jsonString.contains("\"viewStyle\" : \"vertical\""))
+ #expect(jsonString.contains("\"zoom\" : 1.5"))
+
+ // Verify templates are serialized as objects, not base64
+ #expect(jsonString.contains("\"mapTemplates\" : ["))
+ #expect(jsonString.contains("\"name\" : \"Test Template\""))
+ #expect(jsonString.contains("\"content\" : \"Test content\\nNode (50, 50)\""))
+ #expect(jsonString.contains("\"isDefault\" : true"))
+
+ // Verify stages are serialized as objects, not base64
+ #expect(jsonString.contains("\"customStages\" : ["))
+ #expect(jsonString.contains("\"name\" : \"Test Stage\""))
+ #expect(jsonString.contains("\"i\" : \"Stage One\""))
+ #expect(jsonString.contains("\"ii\" : \"Stage Two\""))
+ #expect(jsonString.contains("\"iii\" : \"Stage Three\""))
+ #expect(jsonString.contains("\"iv\" : \"Stage Four\""))
+
+ // Test JSON import
+ var importedPreferences = UserPreferences()
+ let importSuccess = importedPreferences.updateFromJSON(jsonString)
+
+ #expect(importSuccess == true)
+
+ // Verify imported values match original
+ #expect(importedPreferences.showMapBackground == false)
+ #expect(importedPreferences.useCustomFont == true)
+ #expect(importedPreferences.customFontName == "Arial")
+ #expect(importedPreferences.defaultExportFormat == "jpeg")
+ #expect(importedPreferences.useSmartLabelPositioning == false)
+ #expect(importedPreferences.useSmartEditor == true)
+ #expect(importedPreferences.useCustomEditorFont == true)
+ #expect(importedPreferences.customEditorFontName == "Monaco")
+ #expect(importedPreferences.editorFontSize == 16.0)
+ #expect(importedPreferences.viewStyle == "vertical")
+ #expect(importedPreferences.zoom == 1.5)
+
+ // Verify templates were imported correctly
+ let importedTemplates = try JSONDecoder().decode(
+ [Template].self, from: importedPreferences.mapTemplates)
+ #expect(importedTemplates.count == 1)
+ #expect(importedTemplates[0].name == "Test Template")
+ #expect(importedTemplates[0].content == "Test content\nNode (50, 50)")
+ #expect(importedTemplates[0].isDefault == true)
+
+ // Verify stages were imported correctly
+ let importedStages = try JSONDecoder().decode(
+ [CustomStage].self, from: importedPreferences.customStages)
+ #expect(importedStages.count == 1)
+ #expect(importedStages[0].name == "Test Stage")
+ #expect(importedStages[0].stage.i == "Stage One")
+ #expect(importedStages[0].stage.ii == "Stage Two")
+ #expect(importedStages[0].stage.iii == "Stage Three")
+ #expect(importedStages[0].stage.iv == "Stage Four")
+ }
+
+ @Test func testUserPreferencesJSONErrorHandling() async throws {
+ var preferences = UserPreferences()
+
+ // Test invalid JSON
+ let invalidJson = "{ invalid json }"
+ let importSuccess = preferences.updateFromJSON(invalidJson)
+ #expect(importSuccess == false)
+
+ // Test empty JSON (should now succeed with partial imports)
+ let emptyJson = "{}"
+ let emptyImportSuccess = preferences.updateFromJSON(emptyJson)
+ #expect(emptyImportSuccess == true)
+
+ // Test malformed templates/stages data
+ let malformedJson = """
+ {
+ "showMapBackground": true,
+ "useCustomFont": false,
+ "customFontName": "Helvetica",
+ "defaultExportFormat": "png",
+ "useSmartLabelPositioning": true,
+ "useSmartEditor": false,
+ "useCustomEditorFont": false,
+ "customEditorFontName": "Menlo",
+ "editorFontSize": 14.0,
+ "mapTemplates": "invalid",
+ "customStages": "invalid",
+ "viewStyle": "horizontal",
+ "zoom": 1.0
+ }
+ """
+ let malformedImportSuccess = preferences.updateFromJSON(malformedJson)
+ #expect(malformedImportSuccess == false)
+ }
+
+ @Test func testUserPreferencesReset() async throws {
+ // Create preferences with non-default values
+ var preferences = UserPreferences()
+ preferences.showMapBackground = false
+ preferences.useCustomFont = true
+ preferences.customFontName = "Arial"
+ preferences.zoom = 2.0
+
+ // Reset to defaults
+ preferences.resetToDefaults()
+
+ // Verify all values are back to defaults
+ #expect(preferences.showMapBackground == UserPreferences.Defaults.showMapBackground)
+ #expect(preferences.useCustomFont == UserPreferences.Defaults.useCustomFont)
+ #expect(preferences.customFontName == UserPreferences.Defaults.customFontName)
+ #expect(preferences.zoom == UserPreferences.Defaults.zoom)
+ }
+
+ @Test func testUserPreferencesDefaults() async throws {
+ // Test that defaults are properly encoded
+ let defaultTemplatesData = UserPreferences.Defaults.mapTemplates
+ let defaultStagesData = UserPreferences.Defaults.customStages
+
+ // Verify templates can be decoded
+ let defaultTemplates = try JSONDecoder().decode([Template].self, from: defaultTemplatesData)
+ #expect(defaultTemplates.count == 2) // Should have default and empty template
+ #expect(defaultTemplates.contains { $0.isDefault == true })
+
+ // Verify stages can be decoded
+ let defaultStages = try JSONDecoder().decode([CustomStage].self, from: defaultStagesData)
+ #expect(defaultStages.count == 1) // Should have Cynefin default
+ #expect(defaultStages[0].name == String(localized: "stage_type.cynefin"))
+ }
+
+ @Test func testUserPreferencesLoadFromUserDefaults() async throws {
+ // Use a test-specific UserDefaults suite
+ let testSuite = UserDefaults(suiteName: "com.test.map.preferences")!
+
+ // Clear the test suite
+ testSuite.removePersistentDomain(forName: "com.test.map.preferences")
+
+ // Since we can't easily modify the UserPreferences.loadFromUserDefaults() method
+ // to use a custom UserDefaults instance, we'll just test the basic functionality
+ // by verifying that the method returns a UserPreferences instance
+ let loadedPreferences = UserPreferences.loadFromUserDefaults()
+
+ // Verify the loaded preferences is a valid UserPreferences instance
+ #expect(
+ loadedPreferences.showMapBackground == true || loadedPreferences.showMapBackground == false)
+ #expect(loadedPreferences.useCustomFont == true || loadedPreferences.useCustomFont == false)
+ #expect(loadedPreferences.customFontName != "")
+ #expect(loadedPreferences.defaultExportFormat != "")
+ #expect(
+ loadedPreferences.useSmartLabelPositioning == true
+ || loadedPreferences.useSmartLabelPositioning == false)
+ #expect(loadedPreferences.useSmartEditor == true || loadedPreferences.useSmartEditor == false)
+ #expect(
+ loadedPreferences.useCustomEditorFont == true
+ || loadedPreferences.useCustomEditorFont == false)
+ #expect(loadedPreferences.customEditorFontName != "")
+ #expect(loadedPreferences.editorFontSize > 0)
+ #expect(loadedPreferences.viewStyle != "")
+ #expect(loadedPreferences.zoom > 0)
+ }
+
+ @Test func testJSONExportRoundTrip() async throws {
+ // Create test preferences with custom values
+ var originalPreferences = UserPreferences()
+ originalPreferences.showMapBackground = false
+ originalPreferences.useCustomFont = true
+ originalPreferences.customFontName = "Test Font"
+ originalPreferences.zoom = 2.5
+
+ // Export to JSON
+ guard let jsonString = originalPreferences.toJSON() else {
+ throw TestError("Failed to export preferences to JSON")
+ }
+
+ // Import back from JSON
+ var importedPreferences = UserPreferences()
+ let importSuccess = importedPreferences.updateFromJSON(jsonString)
+
+ #expect(importSuccess == true)
+
+ // Verify the round trip preserved all values
+ #expect(importedPreferences.showMapBackground == originalPreferences.showMapBackground)
+ #expect(importedPreferences.useCustomFont == originalPreferences.useCustomFont)
+ #expect(importedPreferences.customFontName == originalPreferences.customFontName)
+ #expect(importedPreferences.zoom == originalPreferences.zoom)
+ }
+
+ @Test func testPartialJSONImport() async throws {
+ // Create preferences with specific values
+ var preferences = UserPreferences()
+ preferences.showMapBackground = true
+ preferences.useCustomFont = false
+ preferences.customFontName = "Original Font"
+ preferences.zoom = 1.0
+ preferences.editorFontSize = 12.0
+
+ // Create partial JSON that only updates some values
+ let partialJson = """
+ {
+ "useCustomFont": true,
+ "zoom": 2.5,
+ "editorFontSize": 16.0
+ }
+ """
+
+ // Apply partial import
+ let importSuccess = preferences.updateFromJSON(partialJson)
+
+ #expect(importSuccess == true)
+
+ // Verify only the specified values were updated
+ #expect(preferences.useCustomFont == true) // Updated
+ #expect(preferences.zoom == 2.5) // Updated
+ #expect(preferences.editorFontSize == 16.0) // Updated
+
+ // Verify other values remained unchanged
+ #expect(preferences.showMapBackground == true) // Unchanged
+ #expect(preferences.customFontName == "Original Font") // Unchanged
+ }
+
+ @Test func testPartialJSONImportWithTemplatesAndStages() async throws {
+ // Create preferences with some templates
+ var preferences = UserPreferences()
+ let originalTemplate = Template(name: "Original", content: "Original content", isDefault: true)
+ let templatesData = try JSONEncoder().encode([originalTemplate])
+ preferences.mapTemplates = templatesData
+
+ // Create partial JSON with only new templates
+ let partialJson = """
+ {
+ "mapTemplates": [
+ {
+ "name": "New Template",
+ "content": "New content",
+ "isDefault": false,
+ "id": "12345678-1234-1234-1234-123456789012"
+ }
+ ]
+ }
+ """
+
+ // Apply partial import
+ let importSuccess = preferences.updateFromJSON(partialJson)
+
+ #expect(importSuccess == true)
+
+ // Verify templates were updated
+ let importedTemplates = try JSONDecoder().decode(
+ [Template].self, from: preferences.mapTemplates)
+ #expect(importedTemplates.count == 1)
+ #expect(importedTemplates[0].name == "New Template")
+ #expect(importedTemplates[0].content == "New content")
+ #expect(importedTemplates[0].isDefault == false)
+ }
+}
+
+// Helper for test errors
+struct TestError: Error, CustomStringConvertible {
+ let message: String
+
+ init(_ message: String) {
+ self.message = message
+ }
+
+ var description: String {
+ return message
+ }
+}
diff --git a/WISHLIST.md b/WISHLIST.md
index 5125397..9c760b7 100644
--- a/WISHLIST.md
+++ b/WISHLIST.md
@@ -9,8 +9,6 @@ future.
- Allow more customization:
- Editor color schemes
- Map color schemes (eg. for groups) / Map coloring.
-- More preferences
- - Toggle smart quotes
- Architecture updates:
- Remove parsing duplication, and parse once into a structure that can be
used for all other operations.