M16: Zweisprachigkeit (DE/EN) im Experte-Tab, eigener L10n-Helfer statt String Catalog

.environment(\.locale) + Localizable.xcstrings schaltete Text(LocalizedStringKey)
live nachweislich nicht um. Ersetzt durch Core/Localization/L10n.swift
(Dictionary-Lookup je AppStorage("appLanguage")), Umschalt-Button in der Toolbar.
Vollständig übersetzt: alle Tab-Namen, alle Menü-Kategorien, kompletter
Experte-Tab inkl. Firewall-Formulare. 60 Tests grün, live bestätigt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YDmUd93KxsYGr2kLTotWnG
This commit is contained in:
Kay
2026-09-15 15:54:26 +02:00
co-authored by Claude Sonnet 5
parent 2e507aa9ef
commit 0d16d05cb3
8 changed files with 220 additions and 51 deletions
@@ -3,23 +3,40 @@ import SwiftUI
@main
struct RouterOSAssistantApp: App {
@StateObject private var connectionService = ConnectionService()
/// Manual language override independent of the system locale, per the user's explicit
/// request for an in-app toggle button. `.environment(\.locale, ...)` does NOT make
/// `Text(LocalizedStringKey)` re-resolve against Localizable.xcstrings at runtime (confirmed
/// live: toggling had no visible effect), so views read this same AppStorage key directly and
/// translate via `L10n.t(_:_:)` instead.
@AppStorage("appLanguage") private var appLanguage: String = "de"
var body: some Scene {
WindowGroup {
TabView {
ConnectView(connectionService: connectionService)
.tabItem { Label("Verbinden", systemImage: "network") }
.tabItem { Label(L10n.t("Verbinden", appLanguage), systemImage: "network") }
SetupView(connectionService: connectionService)
.tabItem { Label("Einrichten", systemImage: "checklist") }
.tabItem { Label(L10n.t("Einrichten", appLanguage), systemImage: "checklist") }
OverviewView(connectionService: connectionService)
.tabItem { Label("Übersicht", systemImage: "point.3.connected.trianglepath.dotted") }
.tabItem { Label(L10n.t("Übersicht", appLanguage), systemImage: "point.3.connected.trianglepath.dotted") }
DevicesView(connectionService: connectionService)
.tabItem { Label("Geräte", systemImage: "laptopcomputer.and.iphone") }
.tabItem { Label(L10n.t("Geräte", appLanguage), systemImage: "laptopcomputer.and.iphone") }
ExpertView(connectionService: connectionService)
.tabItem { Label("Experte", systemImage: "wrench.and.screwdriver") }
.tabItem { Label(L10n.t("Experte", appLanguage), systemImage: "wrench.and.screwdriver") }
BackupListView(connectionService: connectionService)
.tabItem { Label("Sicherungen", systemImage: "clock.arrow.circlepath") }
.tabItem { Label(L10n.t("Sicherungen", appLanguage), systemImage: "clock.arrow.circlepath") }
}
.toolbar {
ToolbarItem {
Button {
appLanguage = (appLanguage == "de") ? "en" : "de"
} label: {
Text(appLanguage == "de" ? "EN" : "DE")
}
.help(appLanguage == "de" ? "Switch to English" : "Auf Deutsch umschalten")
}
}
}
.environment(\.locale, Locale(identifier: appLanguage))
}
}
@@ -0,0 +1,69 @@
import Foundation
/// Manual in-app language switch. Not built on `.environment(\.locale)` + a String Catalog:
/// that combination only redirects Calendar/DateFormatter-style locale-aware APIs confirmed
/// live that toggling the environment override had no effect on `Text(LocalizedStringKey)`
/// content, which resolves against the bundle's own preferred-localizations negotiation fixed
/// at launch, not a runtime environment value. This does the substitution directly: `L10n.t`
/// looks up an English string in a plain dictionary keyed by the German source text, returning
/// the German text itself when untranslated or when `language == "de"`. Every call site passes
/// its own observed `appLanguage` so SwiftUI's diffing sees the dependency and re-renders on toggle.
enum L10n {
static func t(_ german: String, _ language: String) -> String {
guard language == "en" else { return german }
return translations[german] ?? german
}
private static let translations: [String: String] = [
"Verbinden": "Connect",
"Einrichten": "Setup",
"Übersicht": "Topology",
"Geräte": "Devices",
"Experte": "Expert",
"Sicherungen": "Backups",
"Firewall: Filter-Regeln": "Firewall: Filter Rules",
"Firewall: NAT (Portweiterleitung etc.)": "Firewall: NAT (port forwarding etc.)",
"Firewall: Mangle (Markierung/QoS-Vorbereitung)": "Firewall: Mangle (marking/QoS prep)",
"Firewall: Raw (vor Connection-Tracking)": "Firewall: Raw (before connection tracking)",
"Firewall: Adress-Listen": "Firewall: Address Lists",
"Interfaces (Bridge, VLAN, VPN-Tunnel...)": "Interfaces (bridge, VLAN, VPN tunnel...)",
"IP-Adressierung & Dienste": "IP Addressing & Services",
"Routing": "Routing",
"VPN-Server/Clients": "VPN Servers/Clients",
"WLAN / CAPsMAN": "Wi-Fi / CAPsMAN",
"Queues / Bandbreiten-Steuerung": "Queues / Bandwidth Control",
"System": "System",
"Werkzeuge & Überwachung": "Tools & Monitoring",
"Nicht verbunden": "Not connected",
"Verbinde dich zuerst im Tab \"Verbinden\" mit deinem Router.": "Connect to your router first in the \"Connect\" tab.",
"Eigener Menüpfad": "Custom Menu Path",
"Jeder RouterOS-Menüpfad ist erreichbar, auch wenn er oben nicht gelistet ist — die Felder erscheinen dann generisch (Schlüssel/Wert), ohne kuratierte Erklärung.":
"Any RouterOS menu path is reachable, even if it's not listed above — fields then appear generically (key/value), without a curated explanation.",
"Menüpfad, z.B. /ip firewall filter": "Menu path, e.g. /ip firewall filter",
"Der RouterOS-CLI-Pfad, z.B. \"/interface wireguard\" oder \"/routing ospf instance\".":
"The RouterOS CLI path, e.g. \"/interface wireguard\" or \"/routing ospf instance\".",
"REST-Pfad, z.B. ip/firewall/filter": "REST path, e.g. ip/firewall/filter",
"Derselbe Pfad in REST-API-Schreibweise: führende \"/\" weg, Leerzeichen zu \"/\".":
"The same path in REST API notation: drop the leading \"/\", turn spaces into \"/\".",
"Öffnen": "Open",
"Kein Menü ausgewählt": "No menu selected",
"Wähle links einen RouterOS-Bereich aus.": "Select a RouterOS area on the left.",
"Eintrag löschen": "Delete entry",
"Einstellungen": "Settings",
"Einträge": "Entries",
"Neu hinzufügen": "Add new",
"Eintrag wirklich löschen?": "Really delete this entry?",
"Löschen": "Delete",
"Abbrechen": "Cancel",
"Weitere Parameter (frei)": "Additional Parameters (free-form)",
"Für alles, was oben nicht als eigenes Feld aufgeführt ist — RouterOS-Parametername genau wie in der Dokumentation.":
"For anything not listed as its own field above — use the exact RouterOS parameter name as in the documentation.",
"Parameter": "Parameter",
"Wert": "Value",
"Parameter hinzufügen": "Add parameter",
"Wird ausgeführt": "About to run",
"Anlegen": "Create",
"Speichern": "Save",
"Jetzt am Router anwenden?": "Apply on the router now?"
]
}
@@ -3,19 +3,20 @@ import SwiftUI
struct ExpertMenuDetailView: View {
@ObservedObject var viewModel: ExpertViewModel
let schema: RouterOSMenuSchema
@AppStorage("appLanguage") private var appLanguage: String = "de"
var body: some View {
Form {
Section {
Text(schema.displayName)
Text(L10n.t(schema.displayName, appLanguage))
.font(.title2)
.bold()
Text(schema.category.rawValue)
Text(L10n.t(schema.category.rawValue, appLanguage))
.font(.caption)
.foregroundStyle(.secondary)
Text(schema.explanation)
Text(L10n.t(schema.explanation, appLanguage))
if let warning = schema.warning {
Label(warning, systemImage: "exclamationmark.triangle")
Label(L10n.t(warning, appLanguage), systemImage: "exclamationmark.triangle")
.font(.caption)
.foregroundStyle(.orange)
}
@@ -27,7 +28,7 @@ struct ExpertMenuDetailView: View {
} else if let error = viewModel.loadError {
Text(error).foregroundStyle(.red)
} else if viewModel.items.isEmpty {
Text("Keine Einträge unter \(schema.menuPath).").foregroundStyle(.secondary)
Text(L10n.t("Keine Einträge unter", appLanguage) + " \(schema.menuPath).").foregroundStyle(.secondary)
} else {
ForEach(viewModel.items) { item in
HStack {
@@ -47,7 +48,7 @@ struct ExpertMenuDetailView: View {
Image(systemName: "trash")
}
.buttonStyle(.plain)
.help("Eintrag löschen")
.help(L10n.t("Eintrag löschen", appLanguage))
}
}
.contentShape(Rectangle())
@@ -55,16 +56,16 @@ struct ExpertMenuDetailView: View {
}
} header: {
HStack {
Text(schema.isSingleton ? "Einstellungen" : "Einträge")
Text(L10n.t(schema.isSingleton ? "Einstellungen" : "Einträge", appLanguage))
Spacer()
if !schema.isSingleton {
Button("Neu hinzufügen") { viewModel.startNewItem() }
Button(L10n.t("Neu hinzufügen", appLanguage)) { viewModel.startNewItem() }
}
}
}
}
.formStyle(.grouped)
.navigationTitle(schema.displayName)
.navigationTitle(LocalizedStringKey(L10n.t(schema.displayName, appLanguage)))
.task(id: schema.id) { await viewModel.reloadItems() }
.sheet(item: $viewModel.editingItem) { item in
ExpertItemEditView(
@@ -74,17 +75,17 @@ struct ExpertMenuDetailView: View {
)
}
.confirmationDialog(
"Eintrag wirklich löschen?",
L10n.t("Eintrag wirklich löschen?", appLanguage),
isPresented: Binding(
get: { viewModel.pendingRemoval != nil },
set: { if !$0 { viewModel.pendingRemoval = nil } }
),
presenting: viewModel.pendingRemoval
) { item in
Button("Löschen", role: .destructive) {
Button(L10n.t("Löschen", appLanguage), role: .destructive) {
Task { await viewModel.confirmRemoval(item) }
}
Button("Abbrechen", role: .cancel) {}
Button(L10n.t("Abbrechen", appLanguage), role: .cancel) {}
} message: { item in
Text(rowLabel(for: item))
}
@@ -108,6 +109,7 @@ struct ExpertItemEditView: View {
let isNew: Bool
@Environment(\.dismiss) private var dismiss
@State private var showApplyConfirmation = false
@AppStorage("appLanguage") private var appLanguage: String = "de"
var body: some View {
Form {
@@ -115,14 +117,14 @@ struct ExpertItemEditView: View {
fieldEditor(for: field)
}
Section("Weitere Parameter (frei)") {
Text("Für alles, was oben nicht als eigenes Feld aufgeführt ist — RouterOS-Parametername genau wie in der Dokumentation.")
Section(LocalizedStringKey(L10n.t("Weitere Parameter (frei)", appLanguage))) {
Text(L10n.t("Für alles, was oben nicht als eigenes Feld aufgeführt ist — RouterOS-Parametername genau wie in der Dokumentation.", appLanguage))
.font(.caption)
.foregroundStyle(.secondary)
ForEach($viewModel.extraFields) { $extra in
HStack {
TextField("Parameter", text: $extra.key)
TextField("Wert", text: $extra.value)
TextField(L10n.t("Parameter", appLanguage), text: $extra.key)
TextField(L10n.t("Wert", appLanguage), text: $extra.value)
Button(role: .destructive) {
viewModel.extraFields.removeAll { $0.id == extra.id }
} label: {
@@ -130,13 +132,13 @@ struct ExpertItemEditView: View {
}
}
}
Button("Parameter hinzufügen") {
Button(L10n.t("Parameter hinzufügen", appLanguage)) {
viewModel.extraFields.append(.init())
}
}
if let command = viewModel.pendingCommand {
Section("Wird ausgeführt") {
Section(LocalizedStringKey(L10n.t("Wird ausgeführt", appLanguage))) {
Text(command.cliLine)
.font(.system(.caption, design: .monospaced))
.foregroundStyle(.secondary)
@@ -149,12 +151,12 @@ struct ExpertItemEditView: View {
Section {
HStack {
Button("Abbrechen") {
Button(L10n.t("Abbrechen", appLanguage)) {
viewModel.cancelEditing()
dismiss()
}
Spacer()
Button(isNew ? "Anlegen" : "Speichern") {
Button(L10n.t(isNew ? "Anlegen" : "Speichern", appLanguage)) {
showApplyConfirmation = true
}
.disabled(viewModel.isApplying || viewModel.pendingCommand == nil)
@@ -164,17 +166,17 @@ struct ExpertItemEditView: View {
.formStyle(.grouped)
.frame(minWidth: 420, minHeight: 480)
.confirmationDialog(
"Jetzt am Router anwenden?",
L10n.t("Jetzt am Router anwenden?", appLanguage),
isPresented: $showApplyConfirmation,
titleVisibility: .visible
) {
Button(isNew ? "Anlegen" : "Speichern") {
Button(L10n.t(isNew ? "Anlegen" : "Speichern", appLanguage)) {
Task {
await viewModel.saveEditingItem()
if viewModel.applyError == nil { dismiss() }
}
}
Button("Abbrechen", role: .cancel) {}
Button(L10n.t("Abbrechen", appLanguage), role: .cancel) {}
} message: {
if let command = viewModel.pendingCommand {
Text(command.cliLine)
@@ -188,24 +190,25 @@ struct ExpertItemEditView: View {
get: { viewModel.formValues[field.key] ?? "" },
set: { viewModel.formValues[field.key] = $0 }
)
let label = L10n.t(field.label, appLanguage)
Group {
switch field.kind {
case .text, .int:
TextField(field.label, text: binding)
TextField(label, text: binding)
case .bool:
Toggle(field.label, isOn: Binding(
Toggle(label, isOn: Binding(
get: { binding.wrappedValue == "yes" || binding.wrappedValue == "true" },
set: { binding.wrappedValue = $0 ? "yes" : "no" }
))
case .enumPick(let options):
Picker(field.label, selection: binding) {
Picker(label, selection: binding) {
Text("").tag("")
ForEach(options, id: \.self) { option in
Text(option).tag(option)
}
}
case .interfacePick:
Picker(field.label, selection: binding) {
Picker(label, selection: binding) {
Text("").tag("")
ForEach(viewModel.liveInterfaceNames, id: \.self) { name in
Text(name).tag(name)
@@ -213,7 +216,7 @@ struct ExpertItemEditView: View {
}
case .menuItemPick(let menuPath, _, _):
let options = viewModel.crossReferenceOptions[menuPath] ?? []
Picker(field.label, selection: binding) {
Picker(label, selection: binding) {
Text("").tag("")
ForEach(options, id: \.self) { option in
Text(option).tag(option)
@@ -221,13 +224,13 @@ struct ExpertItemEditView: View {
}
case .duration:
HStack {
Text(field.label)
Text(label)
Spacer()
DurationFieldEditor(value: binding)
}
}
}
.help(field.help.isEmpty ? field.label : field.help)
.help(L10n.t(field.help.isEmpty ? field.label : field.help, appLanguage))
}
}
@@ -3,6 +3,7 @@ import SwiftUI
struct ExpertView: View {
@ObservedObject var connectionService: ConnectionService
@StateObject private var viewModel: ExpertViewModel
@AppStorage("appLanguage") private var appLanguage: String = "de"
init(connectionService: ConnectionService) {
self.connectionService = connectionService
@@ -13,9 +14,9 @@ struct ExpertView: View {
Group {
if connectionService.credentials == nil {
ContentUnavailableView(
"Nicht verbunden",
LocalizedStringKey(L10n.t("Nicht verbunden", appLanguage)),
systemImage: "network.slash",
description: Text("Verbinde dich zuerst im Tab \"Verbinden\" mit deinem Router.")
description: Text(L10n.t("Verbinde dich zuerst im Tab \"Verbinden\" mit deinem Router.", appLanguage))
)
} else {
NavigationSplitView {
@@ -23,13 +24,13 @@ struct ExpertView: View {
ForEach(RouterOSMenuCategory.allCases) { category in
let schemas = RouterOSSchemaCatalog.schemas(in: category)
if !schemas.isEmpty {
Section(category.rawValue) {
Section(LocalizedStringKey(L10n.t(category.rawValue, appLanguage))) {
ForEach(schemas) { schema in
let isSelected = viewModel.selectedSchema?.menuPath == schema.menuPath
Button {
viewModel.open(schema)
} label: {
Text(schema.displayName)
Text(L10n.t(schema.displayName, appLanguage))
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.vertical, 4)
.padding(.horizontal, 6)
@@ -45,19 +46,19 @@ struct ExpertView: View {
}
}
Section("Eigener Menüpfad") {
Text("Jeder RouterOS-Menüpfad ist erreichbar, auch wenn er oben nicht gelistet ist — die Felder erscheinen dann generisch (Schlüssel/Wert), ohne kuratierte Erklärung.")
Section(LocalizedStringKey(L10n.t("Eigener Menüpfad", appLanguage))) {
Text(L10n.t("Jeder RouterOS-Menüpfad ist erreichbar, auch wenn er oben nicht gelistet ist — die Felder erscheinen dann generisch (Schlüssel/Wert), ohne kuratierte Erklärung.", appLanguage))
.font(.caption)
.foregroundStyle(.secondary)
TextField("Menüpfad, z.B. /ip firewall filter", text: $viewModel.customMenuPath)
.help("Der RouterOS-CLI-Pfad, z.B. \"/interface wireguard\" oder \"/routing ospf instance\".")
TextField("REST-Pfad, z.B. ip/firewall/filter", text: $viewModel.customRestPath)
.help("Derselbe Pfad in REST-API-Schreibweise: führende \"/\" weg, Leerzeichen zu \"/\".")
Button("Öffnen") { viewModel.openCustomPath() }
TextField(L10n.t("Menüpfad, z.B. /ip firewall filter", appLanguage), text: $viewModel.customMenuPath)
.help(L10n.t("Der RouterOS-CLI-Pfad, z.B. \"/interface wireguard\" oder \"/routing ospf instance\".", appLanguage))
TextField(L10n.t("REST-Pfad, z.B. ip/firewall/filter", appLanguage), text: $viewModel.customRestPath)
.help(L10n.t("Derselbe Pfad in REST-API-Schreibweise: führende \"/\" weg, Leerzeichen zu \"/\".", appLanguage))
Button(L10n.t("Öffnen", appLanguage)) { viewModel.openCustomPath() }
.disabled(viewModel.customMenuPath.isEmpty || viewModel.customRestPath.isEmpty)
}
}
.navigationTitle("Experte")
.navigationTitle(LocalizedStringKey(L10n.t("Experte", appLanguage)))
} detail: {
if let schema = viewModel.selectedSchema {
ExpertMenuDetailView(
@@ -66,9 +67,9 @@ struct ExpertView: View {
)
} else {
ContentUnavailableView(
"Kein Menü ausgewählt",
LocalizedStringKey(L10n.t("Kein Menü ausgewählt", appLanguage)),
systemImage: "list.bullet.rectangle",
description: Text("Wähle links einen RouterOS-Bereich aus.")
description: Text(L10n.t("Wähle links einen RouterOS-Bereich aus.", appLanguage))
)
}
}