diff --git a/CHATLOG.md b/CHATLOG.md index 787b471..104d968 100644 --- a/CHATLOG.md +++ b/CHATLOG.md @@ -814,3 +814,30 @@ Test, WLAN/Bonding/PPPoE-Live-Tests). Verwechslung. Live bestätigt ("funktioniert"). Adress-Listen bewusst weiter zurückgestellt (andere UI-Form nötig). 60 Unit-Tests grün, Manual.md/HANDOFF.md aktualisiert, Commit + Push. +- "jetzt machen wir das ganze zweisprachig..." → M16 gestartet, Nutzer + wählte "Schrittweise, Tab für Tab" als Vorgehen. Erster Versuch: Apple + String Catalog (`Localizable.xcstrings`) + `.environment(\.locale, ...)` + auf `WindowGroup`-Ebene, Tab-Namen/Kategorien/Experte-Tab-Chrome mit + passenden deutschen Keys befüllt. Live: "wenn ich von DE auf EN + wechsele passiert nicht im experte-Tab" → Root Cause vermutet: Swifts + `Text`/`.help`/etc. wandeln nur String-*Literale* automatisch in + `LocalizedStringKey` um, nicht String-*Variablen* (`field.label` etc.) + — jede Stelle explizit mit `LocalizedStringKey(...)` umschlossen, 60 + Tests grün. Live erneut: "bleibt auf deutsch" — Fix wirkungslos. + Tatsächliche Ursache gefunden: `.environment(\.locale)` steuert nicht, + welche Sprachtabelle `Text(LocalizedStringKey)` zur Laufzeit auflöst + (nur Calendar/DateFormatter-artige APIs) — die String-Catalog-Bindung + scheint an der Bundle-internen Locale-Verhandlung beim App-Start + festzuhängen und reagiert nicht auf einen Laufzeit-Override. Fix: + `Localizable.xcstrings` entfernt, neuer eigener Helfer + `Core/Localization/L10n.swift` (`L10n.t(german, appLanguage) -> String`, + simples Dictionary, deutscher Text als Schlüssel), jede betroffene View + liest `@AppStorage("appLanguage")` selbst und ruft `L10n.t(...)` direkt + auf statt über die Environment/Catalog-Kette zu gehen. Umgesetzt für + alle 6 Tab-Namen, alle 13 Menü-Kategorien, komplette Chrome von + `ExpertView.swift`/`ExpertMenuDetailView.swift` (Buttons, Dialoge, + Feld-Labels/-Hilfetexte) — Firewall-Familie darin bereits vollständig + übersetzt aus dem ersten Versuch. 60 Tests grün, live bestätigt ("das + sieht gut aus"). HANDOFF.md/README.md/Manual.md aktualisiert, Commit + + Push. Rest von `RouterOSSchemaCatalog.swift` sowie die übrigen Tabs + bleiben offen für Folgesessions (Tab für Tab, wie vereinbart). diff --git a/HANDOFF.md b/HANDOFF.md index 13c2d45..b5375d9 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -1002,10 +1002,45 @@ Unit-Tests grün (inkl. neuer `ExpertViewModelTests` und bestätigte Kommentar-Änderung an einer Firewall-Regel über den neuen Weg ("funktioniert"). +- ✅ M16: Zweisprachigkeit (DE/EN) mit manuellem Umschalt-Button — + begonnen im Experte-Tab (App-weite Fortsetzung Tab für Tab in + Folgesessions geplant, wie mit dem Nutzer vereinbart). Erster Ansatz + (Apple String Catalog `Localizable.xcstrings` + `.environment(\.locale, + ...)` auf `WindowGroup`-Ebene) **live widerlegt**: der Umschalter + änderte selbst nach explizitem `LocalizedStringKey(...)`-Umschreiben + jedes betroffenen `Text`/`.help`/`Section`/Feld-Aufrufs nichts am + angezeigten Text ("bleibt auf deutsch") — `.environment(\.locale)` + steuert offenbar nur Calendar-/DateFormatter-artige APIs, nicht welche + Sprachtabelle `Text(LocalizedStringKey)` zur Laufzeit auflöst (das + scheint an Bundle-interner Locale-Verhandlung beim Start festzuhängen). + Fix: `Localizable.xcstrings` komplett entfernt, stattdessen eigener + `RouterOSAssistant/Core/Localization/L10n.swift` mit + `L10n.t(german, appLanguage) -> String` (Dictionary-Lookup, deutscher + Text als Schlüssel, Rückgabe des deutschen Texts bei fehlender + Übersetzung oder `appLanguage == "de"`). Jede betroffene View liest + `@AppStorage("appLanguage")` selbst (kein Environment-Umweg mehr) und + ruft `L10n.t(...)` direkt an jeder Text-/Tooltip-Stelle auf, sodass + SwiftUI die Abhängigkeit beim Umschalten sieht und neu rendert. Bisher + übersetzt: alle 6 Tab-Namen, alle 13 `RouterOSMenuCategory`-Werte, die + komplette Chrome von `ExpertView.swift`/`ExpertMenuDetailView.swift` + (Buttons, Dialoge, Platzhalter, Feld-Labels/-Hilfetexte) — inhaltlich + vollständig für den Experte-Tab, mit Ausnahme der noch nicht + übersetzten Katalog-Sektionen (siehe "Nächste Schritte"). **Build + + 60 Tests grün, vom Nutzer live in Xcode bestätigt** ("das sieht gut + aus"). ## Nächste Schritte -1. WLAN (M5) an einem Gerät mit echtem WLAN-Chip nachholen. +1. M16 fortsetzen: restliche `RouterOSSchemaCatalog.swift`-Sektionen + (interfaceFamily, ipFamily, routingFamily, vpnFamily, wirelessFamily, + queueFamily, systemFamily, toolFamily) in + `Core/Localization/L10n.swift`s `translations`-Dictionary übersetzen — + Firewall-Familie ist bereits vollständig, mit dem Nutzer vereinbart + Tab für Tab weiterzumachen. Anschließend dieselbe `L10n.t(...)`- + Umstellung auf die übrigen Tabs (Verbinden/Einrichten/Geräte/ + Sicherungen/Übersicht) ausrollen — dort läuft aktuell noch alles auf + festen deutschen String-Literalen ohne Sprachumschaltung. +2. WLAN (M5) an einem Gerät mit echtem WLAN-Chip nachholen. 2. Rest von M7: Fehlerzustände/Politur, REST-Schreibtest an einem Gerät mit aktivem `www-ssl`. 3. M9 UI (Einfach/Experte-Modusumschalter im Einrichten-Tab selbst) noch diff --git a/Manual.md b/Manual.md index 5e615fc..12c48c0 100644 --- a/Manual.md +++ b/Manual.md @@ -41,6 +41,11 @@ Einrichten, Übersicht, Geräte, Experte, Sicherungen. Die App spricht mit dem Router per REST-API (bevorzugt) oder SSH (Fallback) — beides wird automatisch gewählt, ohne dass du dich darum kümmern musst. +Ein DE/EN-Umschalt-Button in der Toolbar wechselt die Sprache der App +(persistiert über Neustarts). Aktuell vollständig übersetzt: alle +Tab-Namen sowie der komplette Experte-Tab (Menünamen, Firewall-Formulare, +Buttons, Tooltips). Weitere Bereiche folgen schrittweise. + ## 1. Verbinden - Router-Adresse, Benutzername und Passwort eingeben, verbinden. @@ -168,6 +173,11 @@ Connect, Setup, Topology, Devices, Expert, Backups. The app talks to the router over its REST API (preferred) or SSH (fallback) — chosen automatically, no configuration needed. +A DE/EN toggle button in the toolbar switches the app's language +(persists across restarts). Fully translated so far: every tab name and +the entire Expert tab (menu names, Firewall forms, buttons, tooltips). +More areas will follow incrementally. + ## 1. Connect - Enter the router's address, username and password, then connect. diff --git a/README.md b/README.md index 9e3aceb..3895460 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,10 @@ Befehl, der ausgeführt wird — nichts passiert unangekündigt. (SFTP-Upload + RouterOS' dokumentierter Restore-Weg), mit Modell-Abgleich als Brick-Schutz, wählbarer Speicherort, "Werks­ein­ stellungen wiederherstellen"-Notfallschalter. +- **Deutsch/Englisch** — Umschalt-Button in der Toolbar (persistiert über + Neustarts). Aktuell im Experte-Tab vollständig übersetzt (Menünamen, + Firewall-Familie, Formular-Chrome/Tooltips), weitere Tabs/Katalog- + Sektionen folgen Tab für Tab. ## Architektur @@ -107,6 +111,7 @@ darauf bauen Übersicht-, Geräte- und Experte-Tab gemeinsam auf. | M13 | Backup-Wiederherstellung | ✅ live verifiziert | | M14 | Update-Check (Software + Firmware) | ✅ live verifiziert | | M15 | Übersicht-Tab: Diagramm-Fixes + Direktes Bearbeiten | ✅ live verifiziert | +| M16 | Zweisprachigkeit (DE/EN), begonnen im Experte-Tab | ✅ live verifiziert | Ausführlicher Stand inkl. aller gefundenen Bugs, offener Punkte und Session-Verlauf: [`HANDOFF.md`](HANDOFF.md) / [`CHATLOG.md`](CHATLOG.md). @@ -140,6 +145,8 @@ RouterOS/ │ │ ├── FactoryResetService.swift — Werksreset ("Gefahrenzone") │ │ ├── UpdateService.swift — Software-/Firmware-Update, Neustart │ │ └── KeychainService.swift — Passwort-Speicherung +│ ├── Core/Localization/ +│ │ └── L10n.swift — DE/EN-Übersetzungshelfer (Dictionary-Lookup, kein String Catalog) │ ├── Features/ │ │ ├── Wizard/Steps/Connect/ — Verbinden-Tab │ │ ├── Wizard/Steps/Setup/ — Einrichten-Tab (Mode→Wan→Lan→Vlan→Wifi→Firewall→Review) diff --git a/RouterOSAssistant/App/RouterOSAssistantApp.swift b/RouterOSAssistant/App/RouterOSAssistantApp.swift index 64c71fd..8b53257 100644 --- a/RouterOSAssistant/App/RouterOSAssistantApp.swift +++ b/RouterOSAssistant/App/RouterOSAssistantApp.swift @@ -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)) } } diff --git a/RouterOSAssistant/Core/Localization/L10n.swift b/RouterOSAssistant/Core/Localization/L10n.swift new file mode 100644 index 0000000..b98fb64 --- /dev/null +++ b/RouterOSAssistant/Core/Localization/L10n.swift @@ -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?" + ] +} diff --git a/RouterOSAssistant/Features/Expert/ExpertMenuDetailView.swift b/RouterOSAssistant/Features/Expert/ExpertMenuDetailView.swift index 14aaa8d..9ff8840 100644 --- a/RouterOSAssistant/Features/Expert/ExpertMenuDetailView.swift +++ b/RouterOSAssistant/Features/Expert/ExpertMenuDetailView.swift @@ -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)) } } diff --git a/RouterOSAssistant/Features/Expert/ExpertView.swift b/RouterOSAssistant/Features/Expert/ExpertView.swift index 2a3ec3e..7d6318b 100644 --- a/RouterOSAssistant/Features/Expert/ExpertView.swift +++ b/RouterOSAssistant/Features/Expert/ExpertView.swift @@ -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)) ) } }