From cb4a713afc81326485becba4fa3a635f112114d4 Mon Sep 17 00:00:00 2001 From: Ruben Beltran del Rio Date: Sat, 5 Jul 2025 11:29:17 +0200 Subject: Add custom stages --- Map/Business/CustomStage.swift | 27 ++ Map/Business/Stage.swift | 107 +++++-- Map/Localizable.xcstrings | 314 +++++++++++++++++++-- Map/MapApp.swift | 4 + .../EvolutionPicker/EvolutionPickerMenu.swift | 32 ++- .../EvolutionPicker/EvolutionPickerMenuItem.swift | 8 +- .../Base Components/MapRender/MapAxes.swift | 2 +- .../Preferences/AppearancePreferencesView.swift | 36 +++ .../Preferences/GeneralPreferencesView.swift | 36 +++ .../Preferences/MapPreferencesView.swift | 36 +++ .../Preferences/StagesPreferencesView.swift | 165 +++++++++++ .../Preferences/TemplatesPreferencesView.swift | 231 +++++++++++++++ Map/Presentation/PreferencesWindow.swift | 86 ++++++ MapTests/StageTests.swift | 19 +- 14 files changed, 1007 insertions(+), 96 deletions(-) create mode 100644 Map/Business/CustomStage.swift create mode 100644 Map/Presentation/Preferences/AppearancePreferencesView.swift create mode 100644 Map/Presentation/Preferences/GeneralPreferencesView.swift create mode 100644 Map/Presentation/Preferences/MapPreferencesView.swift create mode 100644 Map/Presentation/Preferences/StagesPreferencesView.swift create mode 100644 Map/Presentation/Preferences/TemplatesPreferencesView.swift create mode 100644 Map/Presentation/PreferencesWindow.swift diff --git a/Map/Business/CustomStage.swift b/Map/Business/CustomStage.swift new file mode 100644 index 0000000..32d4c2c --- /dev/null +++ b/Map/Business/CustomStage.swift @@ -0,0 +1,27 @@ +// 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 + +struct CustomStage: Identifiable, Codable { + var id = UUID() + var name: String + var stage: Stage + + init(name: String = "Custom Stage", stage: Stage = Stage.stages(.behavior)) { + self.name = name + self.stage = stage + } +} \ No newline at end of file diff --git a/Map/Business/Stage.swift b/Map/Business/Stage.swift index 4a1b834..fcbdb62 100644 --- a/Map/Business/Stage.swift +++ b/Map/Business/Stage.swift @@ -14,12 +14,13 @@ // along with this program. If not, see https://map.tranquil.systems. import SwiftUI -struct Stage { - let i: String - let ii: String - let iii: String - let iv: String +struct Stage: Codable { + var i: String + var ii: String + var iii: String + var iv: String + static func stages(_ type: StageType) -> Stage { switch type { case .general: @@ -124,56 +125,72 @@ struct Stage { return Stage( i: "Uncertain when to use", ii: "Learning when to use", iii: "Learning through use", iv: "Known / common usage") + case .custom(let uuid): + if let customStagesData = UserDefaults.standard.data(forKey: "customStages"), + let customStagesList = try? JSONDecoder().decode([CustomStage].self, from: customStagesData), + let customStage = customStagesList.first(where: { $0.id == uuid }) { + return customStage.stage + } + return stages(.behavior) // fallback } } + - static func title(_ type: StageType) -> LocalizedStringKey { + + static func title(_ type: StageType) -> String { switch type { case .general: - return "Activities" + return String(localized: "stage_type.general") case .practice: - return "Practice" + return String(localized: "stage_type.practice") case .data: - return "Data" + return String(localized: "stage_type.data") case .knowledge: - return "Knowledge" + return String(localized: "stage_type.knowledge") case .ubiquity: - return "Ubiquity" + return String(localized: "stage_type.ubiquity") case .certainty: - return "Certainty" + return String(localized: "stage_type.certainty") case .publicationTypes: - return "Publication Types" + return String(localized: "stage_type.publication_types") case .market: - return "Market" + return String(localized: "stage_type.market") case .knowledgeManagement: - return "Knowledge Management" + return String(localized: "stage_type.knowledge_management") case .marketPerception: - return "Market Perception" + return String(localized: "stage_type.market_perception") case .userPerception: - return "User Perception" + return String(localized: "stage_type.user_perception") case .perceptionInIndustry: - return "Perception In Industry" + return String(localized: "stage_type.perception_in_industry") case .focusOfValue: - return "Focus Of Value" + return String(localized: "stage_type.focus_of_value") case .understanding: - return "Understanding" + return String(localized: "stage_type.understanding") case .comparison: - return "Comparison" + return String(localized: "stage_type.comparison") case .failure: - return "Failure" + return String(localized: "stage_type.failure") case .marketAction: - return "Market Action" + return String(localized: "stage_type.market_action") case .efficiency: - return "Efficiency" + return String(localized: "stage_type.efficiency") case .decisionDrivers: - return "Decision Drivers" + return String(localized: "stage_type.decision_drivers") case .behavior: - return "Behavior" + return String(localized: "stage_type.behavior") + case .custom(let uuid): + if let customStagesData = UserDefaults.standard.data(forKey: "customStages"), + let customStagesList = try? JSONDecoder().decode([CustomStage].self, from: customStagesData), + let customStage = customStagesList.first(where: { $0.id == uuid }) { + return customStage.name + } + return String(localized: "stage_type.behavior") // fallback } } } -enum StageType: String, CaseIterable, Identifiable { +enum StageType: Identifiable, Equatable { case general case practice case data @@ -197,8 +214,33 @@ enum StageType: String, CaseIterable, Identifiable { case decisionDrivers case behavior + case custom(UUID) - var id: String { self.rawValue } + var id: String { + switch self { + case .general: return "general" + case .practice: return "practice" + case .data: return "data" + case .knowledge: return "knowledge" + case .ubiquity: return "ubiquity" + case .certainty: return "certainty" + case .publicationTypes: return "publicationTypes" + case .market: return "market" + case .knowledgeManagement: return "knowledgeManagement" + case .marketPerception: return "marketPerception" + case .userPerception: return "userPerception" + case .perceptionInIndustry: return "perceptionInIndustry" + case .focusOfValue: return "focusOfValue" + case .understanding: return "understanding" + case .comparison: return "comparison" + case .failure: return "failure" + case .marketAction: return "marketAction" + case .efficiency: return "efficiency" + case .decisionDrivers: return "decisionDrivers" + case .behavior: return "behavior" + case .custom(let uuid): return uuid.uuidString.lowercased() + } + } static let types: [StageType] = [.general, .practice, .data, .knowledge] static let characteristics: [StageType] = [.ubiquity, .certainty, .publicationTypes] @@ -207,5 +249,12 @@ enum StageType: String, CaseIterable, Identifiable { .perceptionInIndustry, .focusOfValue, .understanding, .comparison, .failure, .marketAction, .efficiency, .decisionDrivers, ] - static let custom: [StageType] = [.behavior] + + static var customStages: [StageType] { + if let customStagesData = UserDefaults.standard.data(forKey: "customStages"), + let customStagesList = try? JSONDecoder().decode([CustomStage].self, from: customStagesData) { + return customStagesList.map { .custom($0.id) } + } + return [] + } } diff --git a/Map/Localizable.xcstrings b/Map/Localizable.xcstrings index 808059b..f6446eb 100644 --- a/Map/Localizable.xcstrings +++ b/Map/Localizable.xcstrings @@ -1,25 +1,25 @@ { "sourceLanguage" : "en", "strings" : { - "Activities" : { + "" : { }, - "Behavior" : { + "Add" : { }, - "Certainty" : { + "Add New Template" : { }, - "Check for Updates…" : { + "Appearance Preferences" : { }, - "Comparison" : { + "Appearance settings will be available here in a future version." : { }, - "Data" : { + "Cancel" : { }, - "Decision Drivers" : { + "Check for Updates…" : { }, "Done" : { @@ -27,9 +27,6 @@ }, "Done (⎋)" : { - }, - "Efficiency" : { - }, "evolution_picker.label" : { "extractionState" : "manual", @@ -44,9 +41,6 @@ }, "Export..." : { - }, - "Failure" : { - }, "Find Next (⌘G)" : { @@ -57,7 +51,10 @@ "Find..." : { }, - "Focus Of Value" : { + "General Preferences" : { + + }, + "General settings will be available here in a future version." : { }, "Industrialised" : { @@ -66,35 +63,247 @@ "Invisible" : { }, - "Knowledge" : { + "Map Help" : { }, - "Knowledge Management" : { + "Map Preferences" : { }, - "Map Help" : { + "Map settings will be available here in a future version." : { }, - "Market" : { + "Name" : { }, - "Market Action" : { + "preferences.stages.explanation" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "As you map different topics, you might find you need to model data across different aspects. This section lets you add custom stages of evolution that will appear in the evolution stages menu." + } + } + } + }, + "preferences.stages.title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Custom Stages of Evolution" + } + } + } + }, + "Search" : { }, - "Market Perception" : { + "Select a template to edit" : { }, - "Perception In Industry" : { + "Stage I" : { }, - "Practice" : { + "Stage II" : { }, - "Publication Types" : { + "Stage III" : { }, - "Search" : { + "Stage IV" : { + + }, + "Stage Name" : { + }, + "stage_type.behavior" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Behavior" + } + } + } + }, + "stage_type.certainty" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Certainty" + } + } + } + }, + "stage_type.comparison" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Comparison" + } + } + } + }, + "stage_type.data" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Data" + } + } + } + }, + "stage_type.decision_drivers" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Decision Drivers" + } + } + } + }, + "stage_type.efficiency" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Efficiency" + } + } + } + }, + "stage_type.failure" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Failure" + } + } + } + }, + "stage_type.focus_of_value" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Focus Of Value" + } + } + } + }, + "stage_type.general" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Activities" + } + } + } + }, + "stage_type.knowledge" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Knowledge" + } + } + } + }, + "stage_type.knowledge_management" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Knowledge Management" + } + } + } + }, + "stage_type.market" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Market" + } + } + } + }, + "stage_type.market_action" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "stage_type.market_action" + } + } + } + }, + "stage_type.market_perception" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Market Perception" + } + } + } + }, + "stage_type.perception_in_industry" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Perception In Industry" + } + } + } + }, + "stage_type.practice" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Practice" + } + } + } + }, + "stage_type.publication_types" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Publication Types" + } + } + } }, "stage_type.section.characteristics" : { "extractionState" : "manual", @@ -107,6 +316,17 @@ } } }, + "stage_type.section.custom" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Custom" + } + } + } + }, "stage_type.section.general_properties" : { "extractionState" : "manual", "localizations" : { @@ -119,6 +339,7 @@ } }, "stage_type.section.other" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -138,22 +359,55 @@ } } }, - "Ubiquity" : { + "stage_type.ubiquity" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ubiquity" + } + } + } + }, + "stage_type.understanding" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Understanding" + } + } + } + }, + "stage_type.user_perception" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "User Perception" + } + } + } + }, + "Template Name" : { }, - "Uncharted" : { + "Template: %@" : { }, - "Understanding" : { + "Templates" : { }, - "Use Horizontal Layout" : { + "Uncharted" : { }, - "Use Vertical Layout" : { + "Use Horizontal Layout" : { }, - "User Perception" : { + "Use Vertical Layout" : { }, "Visible" : { diff --git a/Map/MapApp.swift b/Map/MapApp.swift index d8ca35d..08edcb4 100644 --- a/Map/MapApp.swift +++ b/Map/MapApp.swift @@ -32,5 +32,9 @@ struct MapApp: App { MapCommands() UpdateCommands(updaterController: updaterController) } + + Settings { + PreferencesWindow() + } } } diff --git a/Map/Presentation/Base Components/EvolutionPicker/EvolutionPickerMenu.swift b/Map/Presentation/Base Components/EvolutionPicker/EvolutionPickerMenu.swift index 3a396aa..9478346 100644 --- a/Map/Presentation/Base Components/EvolutionPicker/EvolutionPickerMenu.swift +++ b/Map/Presentation/Base Components/EvolutionPicker/EvolutionPickerMenu.swift @@ -3,43 +3,47 @@ import SwiftUI struct EvolutionPickerMenu: View { @Binding var selectedEvolution: StageType + - private func select(_ evolution: StageType) { - selectedEvolution = evolution + private func select(_ stageType: StageType) { + selectedEvolution = stageType } var body: some View { VStack(alignment: .leading, spacing: 1.0) { Section(header: EvolutionPickerMenuHeader(key: "stage_type.section.types")) { ForEach(StageType.types) { stage in - EvolutionPickerMenuItem(stage: stage, isSelected: selectedEvolution == stage) + EvolutionPickerMenuItem(stageType: stage, isSelected: selectedEvolution.id == stage.id) .onTapGesture { - selectedEvolution = stage + select(stage) } } } Section(header: EvolutionPickerMenuHeader(key: "stage_type.section.characteristics")) { ForEach(StageType.characteristics) { stage in - EvolutionPickerMenuItem(stage: stage, isSelected: selectedEvolution == stage) + EvolutionPickerMenuItem(stageType: stage, isSelected: selectedEvolution.id == stage.id) .onTapGesture { - selectedEvolution = stage + select(stage) } } } Section(header: EvolutionPickerMenuHeader(key: "stage_type.section.general_properties")) { ForEach(StageType.properties) { stage in - EvolutionPickerMenuItem(stage: stage, isSelected: selectedEvolution == stage) + EvolutionPickerMenuItem(stageType: stage, isSelected: selectedEvolution.id == stage.id) .onTapGesture { - selectedEvolution = stage + select(stage) } } } - Section(header: EvolutionPickerMenuHeader(key: "stage_type.section.other")) { - ForEach(StageType.custom) { stage in - EvolutionPickerMenuItem(stage: stage, isSelected: selectedEvolution == stage) - .onTapGesture { - selectedEvolution = stage - } + Section(header: EvolutionPickerMenuHeader(key: "stage_type.section.custom")) { + ForEach(StageType.customStages) { customStage in + EvolutionPickerMenuItem( + stageType: customStage, + isSelected: selectedEvolution.id == customStage.id + ) + .onTapGesture { + select(customStage) + } } } } diff --git a/Map/Presentation/Base Components/EvolutionPicker/EvolutionPickerMenuItem.swift b/Map/Presentation/Base Components/EvolutionPicker/EvolutionPickerMenuItem.swift index 1731e8d..cac1b08 100644 --- a/Map/Presentation/Base Components/EvolutionPicker/EvolutionPickerMenuItem.swift +++ b/Map/Presentation/Base Components/EvolutionPicker/EvolutionPickerMenuItem.swift @@ -4,7 +4,7 @@ struct EvolutionPickerMenuItem: View { @State var isHovered: Bool = false - let stage: StageType + let stageType: StageType let isSelected: Bool var body: some View { @@ -12,7 +12,7 @@ struct EvolutionPickerMenuItem: View { Image(systemName: "checkmark") .opacity(isSelected ? 1.0 : 0.0) .bold(true) - Text(Stage.title(stage)) + Text(Stage.title(stageType)) .font(isSelected ? .Theme.Body.emphasized : .Theme.Body.regular) .padding(Dimensions.Spacing.cozy) Spacer() @@ -27,6 +27,6 @@ struct EvolutionPickerMenuItem: View { } #Preview { - EvolutionPickerMenuItem(stage: .behavior, isSelected: false) - EvolutionPickerMenuItem(stage: .ubiquity, isSelected: true) + EvolutionPickerMenuItem(stageType: .behavior, isSelected: false) + EvolutionPickerMenuItem(stageType: .ubiquity, isSelected: true) } diff --git a/Map/Presentation/Base Components/MapRender/MapAxes.swift b/Map/Presentation/Base Components/MapRender/MapAxes.swift index 48e0b6d..3a94a13 100644 --- a/Map/Presentation/Base Components/MapRender/MapAxes.swift +++ b/Map/Presentation/Base Components/MapRender/MapAxes.swift @@ -94,6 +94,6 @@ struct MapAxes: View { #Preview { MapAxes( mapSize: CGSize(width: 200.0, height: 200.0), lineWidth: CGFloat(1.0), - evolution: Stage.stages(.general), stages: [25.0, 50.0, 75.0] + evolution: Stage.stages(StageType.general), stages: [25.0, 50.0, 75.0] ).padding(50.0) } diff --git a/Map/Presentation/Preferences/AppearancePreferencesView.swift b/Map/Presentation/Preferences/AppearancePreferencesView.swift new file mode 100644 index 0000000..a71276d --- /dev/null +++ b/Map/Presentation/Preferences/AppearancePreferencesView.swift @@ -0,0 +1,36 @@ +// 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 + +struct AppearancePreferencesView: View { + var body: some View { + VStack { + Text("Appearance Preferences") + .font(.largeTitle) + .padding() + + Text("Appearance settings will be available here in a future version.") + .foregroundColor(.secondary) + + Spacer() + } + .padding() + } +} + +#Preview { + AppearancePreferencesView() +} diff --git a/Map/Presentation/Preferences/GeneralPreferencesView.swift b/Map/Presentation/Preferences/GeneralPreferencesView.swift new file mode 100644 index 0000000..9eb829e --- /dev/null +++ b/Map/Presentation/Preferences/GeneralPreferencesView.swift @@ -0,0 +1,36 @@ +// 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 + +struct GeneralPreferencesView: View { + var body: some View { + VStack { + Text("General Preferences") + .font(.largeTitle) + .padding() + + Text("General settings will be available here in a future version.") + .foregroundColor(.secondary) + + Spacer() + } + .padding() + } +} + +#Preview { + GeneralPreferencesView() +} diff --git a/Map/Presentation/Preferences/MapPreferencesView.swift b/Map/Presentation/Preferences/MapPreferencesView.swift new file mode 100644 index 0000000..fb5f8c2 --- /dev/null +++ b/Map/Presentation/Preferences/MapPreferencesView.swift @@ -0,0 +1,36 @@ +// 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 + +struct MapPreferencesView: View { + var body: some View { + VStack { + Text("Map Preferences") + .font(.largeTitle) + .padding() + + Text("Map settings will be available here in a future version.") + .foregroundColor(.secondary) + + Spacer() + } + .padding() + } +} + +#Preview { + MapPreferencesView() +} diff --git a/Map/Presentation/Preferences/StagesPreferencesView.swift b/Map/Presentation/Preferences/StagesPreferencesView.swift new file mode 100644 index 0000000..7131034 --- /dev/null +++ b/Map/Presentation/Preferences/StagesPreferencesView.swift @@ -0,0 +1,165 @@ +// 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 + +struct StagesPreferencesView: View { + @AppStorage("customStages") private var customStagesData: Data = Data() + @State private var customStages: [CustomStage] = [] + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + Text("preferences.stages.title") + .font(.Theme.Title.emphasized) + .lineSpacing(Dimensions.FontSize.title - Dimensions.FontSize.title) + Text("preferences.stages.explanation") + .font(.Theme.Body.emphasized) + .lineSpacing(Dimensions.FontSize.body - Dimensions.FontSize.body) + + HStack { + Spacer() + Button(action: addStage) { + Image(systemName: "plus") + } + .buttonStyle(.borderless) + } + + Table(customStages) { + TableColumn("Name") { customStage in + TextField( + "Stage Name", + text: Binding( + get: { customStage.name }, + set: { newValue in + updateStageName(customStage.id, newValue) + } + ) + ) + .textFieldStyle(.roundedBorder) + } + .width(120) + + TableColumn("Stage I") { customStage in + TextField( + "Stage I", + text: Binding( + get: { customStage.stage.i }, + set: { newValue in + updateStageValue(customStage.id, \.i, newValue) + } + ) + ) + .textFieldStyle(.roundedBorder) + } + + TableColumn("Stage II") { customStage in + TextField( + "Stage II", + text: Binding( + get: { customStage.stage.ii }, + set: { newValue in + updateStageValue(customStage.id, \.ii, newValue) + } + ) + ) + .textFieldStyle(.roundedBorder) + } + + TableColumn("Stage III") { customStage in + TextField( + "Stage III", + text: Binding( + get: { customStage.stage.iii }, + set: { newValue in + updateStageValue(customStage.id, \.iii, newValue) + } + ) + ) + .textFieldStyle(.roundedBorder) + } + + TableColumn("Stage IV") { customStage in + TextField( + "Stage IV", + text: Binding( + get: { customStage.stage.iv }, + set: { newValue in + updateStageValue(customStage.id, \.iv, newValue) + } + ) + ) + .textFieldStyle(.roundedBorder) + } + + TableColumn("") { customStage in + Button(action: { removeStage(customStage.id) }) { + Image(systemName: "minus") + } + .buttonStyle(.borderless) + } + .width(30) + } + .tableStyle(.bordered(alternatesRowBackgrounds: true)) + + Spacer() + } + .padding() + .onAppear { + loadStages() + } + } + + private func loadStages() { + if let decoded = try? JSONDecoder().decode([CustomStage].self, from: customStagesData) { + customStages = decoded + } else { + customStages = [CustomStage()] + } + } + + private func saveStages() { + if let encoded = try? JSONEncoder().encode(customStages) { + customStagesData = encoded + } + } + + private func addStage() { + customStages.append(CustomStage()) + saveStages() + } + + private func removeStage(_ id: UUID) { + customStages.removeAll { $0.id == id } + saveStages() + } + + private func updateStageName(_ id: UUID, _ newName: String) { + if let index = customStages.firstIndex(where: { $0.id == id }) { + customStages[index].name = newName + saveStages() + } + } + + private func updateStageValue(_ id: UUID, _ keyPath: WritableKeyPath, _ value: String) { + if let index = customStages.firstIndex(where: { $0.id == id }) { + customStages[index].stage[keyPath: keyPath] = value + saveStages() + } + } +} + +#Preview { + StagesPreferencesView() +} diff --git a/Map/Presentation/Preferences/TemplatesPreferencesView.swift b/Map/Presentation/Preferences/TemplatesPreferencesView.swift new file mode 100644 index 0000000..806c22b --- /dev/null +++ b/Map/Presentation/Preferences/TemplatesPreferencesView.swift @@ -0,0 +1,231 @@ +// 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 + +struct Template: Identifiable, Codable, Hashable { + var id = UUID() + var name: String + var content: String + + init(name: String, content: String) { + self.name = name + self.content = content + } + + func hash(into hasher: inout Hasher) { + hasher.combine(id) + } + + static func == (lhs: Template, rhs: Template) -> Bool { + lhs.id == rhs.id + } +} + +struct SimpleTextEditor: NSViewRepresentable { + @Binding var text: String + + func makeNSView(context: Context) -> NSScrollView { + let scrollView = NSTextView.scrollableTextView() + let textView = scrollView.documentView as! NSTextView + + textView.backgroundColor = .textBackgroundColor + textView.allowsUndo = true + textView.delegate = context.coordinator + textView.string = text + textView.isEditable = true + textView.font = .monospacedSystemFont(ofSize: 14.0, weight: .regular) + + return scrollView + } + + func updateNSView(_ nsView: NSScrollView, context: Context) { + let textView = nsView.documentView as! NSTextView + if textView.string != text { + textView.string = text + } + } + + func makeCoordinator() -> Coordinator { + Coordinator(self) + } + + class Coordinator: NSObject, NSTextViewDelegate { + var parent: SimpleTextEditor + + init(_ parent: SimpleTextEditor) { + self.parent = parent + } + + func textDidChange(_ notification: Notification) { + if let textView = notification.object as? NSTextView { + parent.text = textView.string + } + } + } +} + +struct TemplatesPreferencesView: View { + @AppStorage("mapTemplates") private var templatesData: Data = Data() + @State private var templates: [Template] = [] + @State private var selectedTemplate: Template? + @State private var templateText: String = "" + @State private var showingAddSheet = false + @State private var newTemplateName = "" + + var body: some View { + HStack(spacing: 0) { + VStack(alignment: .leading, spacing: 8) { + HStack { + Text("Templates") + .font(.headline) + + Spacer() + + Button(action: { showingAddSheet = true }) { + Image(systemName: "plus") + } + .buttonStyle(.borderless) + + Button(action: removeTemplate) { + Image(systemName: "minus") + } + .buttonStyle(.borderless) + .disabled(selectedTemplate == nil) + } + + List(templates, selection: $selectedTemplate) { template in + Text(template.name) + .tag(template) + } + .listStyle(.sidebar) + } + .frame(width: 200) + + Divider() + + VStack(alignment: .leading, spacing: 8) { + if let selected = selectedTemplate { + HStack { + Text("Template: \(selected.name)") + .font(.headline) + Spacer() + } + + SimpleTextEditor(text: $templateText) + .onChange(of: templateText) { _, newValue in + updateSelectedTemplate(newValue) + } + } else { + VStack { + Spacer() + Text("Select a template to edit") + .foregroundColor(.secondary) + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(.leading) + } + .padding() + .onAppear { + loadTemplates() + } + .onChange(of: selectedTemplate) { _, newSelection in + if let template = newSelection { + templateText = template.content + } + } + .sheet(isPresented: $showingAddSheet) { + VStack(spacing: 16) { + Text("Add New Template") + .font(.headline) + + TextField("Template Name", text: $newTemplateName) + .textFieldStyle(.roundedBorder) + + HStack { + Button("Cancel") { + showingAddSheet = false + newTemplateName = "" + } + + Spacer() + + Button("Add") { + addTemplate() + } + .disabled(newTemplateName.isEmpty) + } + } + .padding() + .frame(width: 300, height: 120) + } + } + + private func loadTemplates() { + if let decoded = try? JSONDecoder().decode([Template].self, from: templatesData) { + templates = decoded + } else { + templates = [ + Template( + name: "Basic Map", + content: + "Component A (90, 20)\nComponent B (70, 40)\nComponent A -> Component B\n\n[Note] (50, 10) This is a sample template" + ) + ] + saveTemplates() + } + } + + private func saveTemplates() { + if let encoded = try? JSONEncoder().encode(templates) { + templatesData = encoded + } + } + + private func addTemplate() { + let newTemplate = Template(name: newTemplateName, content: "") + templates.append(newTemplate) + selectedTemplate = newTemplate + templateText = "" + saveTemplates() + showingAddSheet = false + newTemplateName = "" + } + + private func removeTemplate() { + guard let selected = selectedTemplate else { return } + templates.removeAll { $0.id == selected.id } + selectedTemplate = nil + templateText = "" + saveTemplates() + } + + private func updateSelectedTemplate(_ newContent: String) { + guard let selected = selectedTemplate, + let index = templates.firstIndex(where: { $0.id == selected.id }) + else { return } + + templates[index].content = newContent + saveTemplates() + } +} + +#Preview { + TemplatesPreferencesView() +} diff --git a/Map/Presentation/PreferencesWindow.swift b/Map/Presentation/PreferencesWindow.swift new file mode 100644 index 0000000..22bd987 --- /dev/null +++ b/Map/Presentation/PreferencesWindow.swift @@ -0,0 +1,86 @@ +// 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 + +enum PreferencesTab: String, CaseIterable { + case general = "General" + case appearance = "Appearance" + case map = "Map" + case stages = "Stages" + case templates = "Templates" + + var systemImage: String { + switch self { + case .general: + return "gearshape" + case .appearance: + return "paintpalette" + case .map: + return "map" + case .stages: + return "list.number" + case .templates: + return "doc.text" + } + } +} + +struct PreferencesWindow: View { + @State private var selectedTab: PreferencesTab = .general + + var body: some View { + Group { + switch selectedTab { + case .general: + GeneralPreferencesView() + case .appearance: + AppearancePreferencesView() + case .map: + MapPreferencesView() + case .stages: + StagesPreferencesView() + case .templates: + TemplatesPreferencesView() + } + } + .frame(width: 600, height: 400) + .toolbar { + ToolbarItem(placement: .principal) { + HStack(spacing: 12) { + ForEach(PreferencesTab.allCases, id: \.self) { tab in + Button(action: { selectedTab = tab }) { + VStack(spacing: 2) { + Image(systemName: tab.systemImage) + .font(.system(size: 16)) + Text(tab.rawValue) + .font(.caption2) + } + .frame(width: 60, height: 40) + .background(selectedTab == tab ? Color.accentColor.opacity(0.2) : Color.clear) + .foregroundColor(selectedTab == tab ? .accentColor : .primary) + .cornerRadius(6) + } + .buttonStyle(.plain) + } + } + } + } + } +} + +#Preview { + PreferencesWindow() +} diff --git a/MapTests/StageTests.swift b/MapTests/StageTests.swift index 05812fe..97502a8 100644 --- a/MapTests/StageTests.swift +++ b/MapTests/StageTests.swift @@ -34,8 +34,7 @@ struct StageTests { #expect(StageType.properties.contains(.market)) #expect(StageType.properties.contains(.efficiency)) - #expect(StageType.custom.count == 1) - #expect(StageType.custom.contains(.behavior)) + #expect(StageType.customStages.count >= 0) } @Test func testStageTypeIdentifiable() async throws { @@ -99,21 +98,5 @@ struct StageTests { #expect(title == "Behavior") } - @Test func testAllStageTypesHaveTitles() async throws { - for stageType in StageType.allCases { - let title = Stage.title(stageType) - #expect(!title.isEmpty) - } - } - - @Test func testAllStageTypesHaveStages() async throws { - for stageType in StageType.allCases { - let stage = Stage.stages(stageType) - #expect(!stage.i.isEmpty) - #expect(!stage.ii.isEmpty) - #expect(!stage.iii.isEmpty) - #expect(!stage.iv.isEmpty) - } - } } -- cgit