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
+27
View File
@@ -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).
+36 -1
View File
@@ -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
+10
View File
@@ -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.
+7
View File
@@ -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)
@@ -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))
)
}
}