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:
+27
@@ -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
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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, "Werksein
|
||||
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))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user