M29: App-Einstellungen (Settings-Fenster ⌘,)
Neues natives macOS-Settings-Fenster mit Sprache, Auto-Update-Check,
Farbschema (Standard/Kontrastreich), echter Textgrößen-Skalierung,
Bedienelement-Größe und LAN-Scanner-Refreshraten/Sparkline-Einstellungen.
- AppPreferences.swift: zentrale AppStorage-Keys, ColorTheme/AppTextSize/
UIDensity
- SettingsView.swift: 3-Tab-Settings-Scene
- .environment(\.dynamicTypeSize) erwies sich auf macOS als wirkungslos
(per ImageRenderer-Snapshot bewiesen) — durch eigenen appFontScale-
Mechanismus ersetzt, ~110 .font(...)-Aufrufe app-weit umgestellt
- Farbschema auf Wunsch auch auf Experte-Tab-Sidebar ausgeweitet
- Docs aktualisiert: README/HANDOFF/CHATLOG/Manual.md+PDF, found.md
Live bestätigt nach mehreren Nachbesserungsrunden ("passt, lassen wir so").
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -9,6 +9,12 @@ struct RouterOSAssistantApp: App {
|
||||
/// 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"
|
||||
@AppStorage(AppPreferences.textSizeKey) private var textSizeRaw: String = AppTextSize.standard.rawValue
|
||||
@AppStorage(AppPreferences.uiDensityKey) private var uiDensityRaw: String = UIDensity.standard.rawValue
|
||||
|
||||
init() {
|
||||
AppPreferences.registerDefaults()
|
||||
}
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
@@ -38,7 +44,20 @@ struct RouterOSAssistantApp: App {
|
||||
.help(appLanguage == "de" ? "Switch to English" : "Auf Deutsch umschalten")
|
||||
}
|
||||
}
|
||||
.controlSize((UIDensity(rawValue: uiDensityRaw) ?? .standard).controlSize)
|
||||
.environment(\.appFontScale, (AppTextSize(rawValue: textSizeRaw) ?? .standard).scale)
|
||||
// Fallback for every `Text`/`TextField`/`Label` that has no explicit `.font(...)` of
|
||||
// its own (confirmed live: the Experte tab's whole sidebar list and every Parameter/
|
||||
// Wert `TextField` have none — `.appFont(...)` only touches call sites that already
|
||||
// had a `.font(...)` to replace, so unstyled text stayed fixed-size). `.environment
|
||||
// (\.font, ...)` only ever supplies a *default*: anywhere `.appFont(...)` already sets
|
||||
// an explicit font, that still wins.
|
||||
.environment(\.font, .system(size: 13 * ((AppTextSize(rawValue: textSizeRaw) ?? .standard).scale)))
|
||||
}
|
||||
.environment(\.locale, Locale(identifier: appLanguage))
|
||||
|
||||
Settings {
|
||||
SettingsView()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -934,6 +934,37 @@ enum L10n {
|
||||
"Dies löscht ALLE aktuellen Einstellungen restlos (auch die Werks-Grundkonfiguration, nicht nur deine eigenen Änderungen) und ersetzt sie durch den Inhalt der Sicherung. Der Router startet neu, diese App verliert danach die Verbindung.":
|
||||
"This completely deletes ALL current settings (including the factory baseline configuration, not just your own changes) and replaces them with the backup's content. The router reboots, and this app loses the connection afterwards.",
|
||||
"Falls die Wiederherstellung fehlschlägt, bleibt der Router leer stehen, nicht auf Werkseinstellungen zurückgefallen.":
|
||||
"If the restore fails, the router is left blank, not reverted to factory defaults."
|
||||
"If the restore fails, the router is left blank, not reverted to factory defaults.",
|
||||
|
||||
// MARK: - Settings window (⌘,)
|
||||
"Allgemein": "General",
|
||||
"Darstellung": "Appearance",
|
||||
"Sprache": "Language",
|
||||
"Entspricht dem 🇩🇪/🇬🇧-Knopf in der Toolbar — beide steuern dieselbe Einstellung.":
|
||||
"Same as the 🇩🇪/🇬🇧 button in the toolbar — both control the same setting.",
|
||||
"RouterOS-Updates": "RouterOS Updates",
|
||||
"Beim Verbinden automatisch nach Updates suchen": "Automatically check for updates when connecting",
|
||||
"Prüft nach jedem erfolgreichen Verbinden automatisch, ob eine neuere RouterOS-Version verfügbar ist — entspricht dem Button \"Nach Updates suchen\" im Tab \"Verbinden\".":
|
||||
"Automatically checks after every successful connection whether a newer RouterOS version is available — same as the \"Check for updates\" button in the \"Connect\" tab.",
|
||||
"Farbschema": "Color Theme",
|
||||
"Standard": "Standard",
|
||||
"Kontrastreich": "High Contrast",
|
||||
"Gilt für die Diagrammfarben in der Übersicht und die Status-Farben im LAN-Scanner (Traffic aktiv, Fest/Dynamisch, offener/geschlossener Port).":
|
||||
"Applies to the diagram colors in the Topology tab and the status colors in the LAN Scanner (traffic active, static/dynamic, open/closed port).",
|
||||
"Textgröße": "Text Size",
|
||||
"Klein": "Small",
|
||||
"Groß": "Large",
|
||||
"Sehr groß": "Extra Large",
|
||||
"Bedienelemente": "Controls",
|
||||
"Kompakt": "Compact",
|
||||
"Komfortabel": "Comfortable",
|
||||
"Größe von Buttons, Feldern und Abständen in der ganzen App (\"Responsiveness\").":
|
||||
"Size of buttons, fields, and spacing throughout the app (\"responsiveness\").",
|
||||
"LAN-Scanner: Traffic-Anzeige": "LAN Scanner: Traffic Display",
|
||||
"Aktualisierungsrate": "Refresh Rate",
|
||||
"Sparkline-Zeitfenster": "Sparkline Time Window",
|
||||
"Sparkline-Breite": "Sparkline Width",
|
||||
"Höhere Aktualisierungsraten zeigen den Traffic-Verlauf feiner, kosten aber mehr CPU bei vielen gleichzeitig aktiven Ports.":
|
||||
"Higher refresh rates show the traffic trend more finely, but cost more CPU when many ports are active at once."
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Central catalog for the "Settings" window (⌘,) — every value here is read/written through
|
||||
/// `@AppStorage`/`UserDefaults.standard` under the raw string keys named on each property below,
|
||||
/// so a view (`@AppStorage`, live-updating) and a background `Task` loop (`UserDefaults.standard`
|
||||
/// reads each tick, no observation needed) can both stay in sync with the same preference without
|
||||
/// plumbing it through init parameters. `registerDefaults()` is called once at app launch so a
|
||||
/// fresh install has real values before any Settings screen has ever been opened, matching the
|
||||
/// literal fallback each `@AppStorage`/`UserDefaults` read site also carries — the two must be
|
||||
/// kept in sync by hand since `@AppStorage` requires its default inline.
|
||||
enum AppPreferences {
|
||||
static let colorThemeKey = "colorTheme"
|
||||
static let textSizeKey = "textSize"
|
||||
static let uiDensityKey = "uiDensity"
|
||||
static let autoCheckUpdatesOnConnectKey = "autoCheckUpdatesOnConnect"
|
||||
static let lanScannerPollIntervalSecondsKey = "lanScannerPollIntervalSeconds"
|
||||
static let lanScannerSparklineWindowSecondsKey = "lanScannerSparklineWindowSeconds"
|
||||
static let lanScannerSparklineWidthKey = "lanScannerSparklineWidth"
|
||||
|
||||
static func registerDefaults() {
|
||||
UserDefaults.standard.register(defaults: [
|
||||
colorThemeKey: ColorTheme.standard.rawValue,
|
||||
textSizeKey: AppTextSize.standard.rawValue,
|
||||
uiDensityKey: UIDensity.standard.rawValue,
|
||||
autoCheckUpdatesOnConnectKey: false,
|
||||
lanScannerPollIntervalSecondsKey: 0.1,
|
||||
lanScannerSparklineWindowSecondsKey: 30.0,
|
||||
lanScannerSparklineWidthKey: 200.0,
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
/// Two predefined palettes for the Übersicht diagram's node/edge colors and the LAN-Scanner's
|
||||
/// status dots (traffic-active, static/dynamic lease, open/closed port) — a free per-category
|
||||
/// color picker was explicitly ruled out in favor of this, so every color lives here rather than
|
||||
/// scattered across the views that used to hardcode `Color.blue`/`.red`/etc. directly.
|
||||
enum ColorTheme: String, CaseIterable, Identifiable {
|
||||
case standard
|
||||
case highContrast
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
func color(for category: OverviewNode.Category) -> Color {
|
||||
switch self {
|
||||
case .standard:
|
||||
switch category {
|
||||
case .interface: return .blue
|
||||
case .ipAddress: return .teal
|
||||
case .service: return .purple
|
||||
case .route: return .orange
|
||||
case .firewall: return .red
|
||||
}
|
||||
case .highContrast:
|
||||
switch category {
|
||||
case .interface: return .yellow
|
||||
case .ipAddress: return .green
|
||||
case .service: return .pink
|
||||
case .route: return .cyan
|
||||
case .firewall: return .indigo
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func color(for kind: OverviewEdgeKind) -> Color {
|
||||
switch self {
|
||||
case .standard:
|
||||
switch kind {
|
||||
case .vlan: return .indigo
|
||||
case .bridgePort: return .cyan
|
||||
case .wireguardPeer: return .pink
|
||||
case .ipAddress: return .teal
|
||||
case .dhcp: return .purple
|
||||
case .route: return .orange
|
||||
case .firewallInterface: return .red
|
||||
case .addressList: return .yellow
|
||||
}
|
||||
case .highContrast:
|
||||
switch kind {
|
||||
case .vlan: return .yellow
|
||||
case .bridgePort: return .brown
|
||||
case .wireguardPeer: return .mint
|
||||
case .ipAddress: return .green
|
||||
case .dhcp: return .cyan
|
||||
case .route: return .indigo
|
||||
case .firewallInterface: return .pink
|
||||
case .addressList: return .blue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Experte-tab sidebar: one dot per `RouterOSMenuCategory` (13 cases — far more granular than
|
||||
/// the 5-case `OverviewNode.Category`), reusing that same 5-color palette by bucketing every
|
||||
/// firewall sub-menu under `.firewall` and everything without an obvious 1:1 match (VPN,
|
||||
/// Wi-Fi/CAPsMAN, Queues, System, Tools) under `.service` — keeps "Firewall is red" etc.
|
||||
/// consistent with the Übersicht diagram instead of inventing a second, unrelated palette.
|
||||
func color(for menuCategory: RouterOSMenuCategory) -> Color {
|
||||
switch menuCategory {
|
||||
case .firewallFilter, .firewallNat, .firewallMangle, .firewallRaw, .firewallAddressLists:
|
||||
return color(for: OverviewNode.Category.firewall)
|
||||
case .interfaces:
|
||||
return color(for: OverviewNode.Category.interface)
|
||||
case .ipAddressing:
|
||||
return color(for: OverviewNode.Category.ipAddress)
|
||||
case .routing:
|
||||
return color(for: OverviewNode.Category.route)
|
||||
case .vpn, .wireless, .queues, .system, .tools:
|
||||
return color(for: OverviewNode.Category.service)
|
||||
}
|
||||
}
|
||||
|
||||
/// LAN-Scanner: port-header "traffic active" dot, and `DeviceRow`'s static/dynamic lease dot.
|
||||
/// First draft used `.mint`/`.yellow`/`.pink` here — too close to their standard counterparts
|
||||
/// (green/orange/red) to read as a real change at small dot/icon size, per live feedback.
|
||||
/// Now a genuinely different hue, not just a lighter shade of the same one.
|
||||
var trafficActive: Color { self == .standard ? .green : .blue }
|
||||
var staticLease: Color { self == .standard ? .orange : .purple }
|
||||
var dynamicLease: Color { self == .standard ? .green : .blue }
|
||||
var portOpen: Color { self == .standard ? .red : .orange }
|
||||
var portClosed: Color { self == .standard ? .green : .blue }
|
||||
}
|
||||
|
||||
/// Global text scale factor, read via `.environment(\.appFontScale, ...)` at the window root and
|
||||
/// applied by every `.appFont(...)` call site (see below).
|
||||
///
|
||||
/// NOT built on `.environment(\.dynamicTypeSize, ...)` — that was the first attempt, and it does
|
||||
/// nothing on macOS: confirmed empirically (an `ImageRenderer` snapshot of the same `Text` at
|
||||
/// `.xSmall` vs `.accessibility3` produced pixel-identical output). Unlike iOS, macOS's SwiftUI
|
||||
/// text styles (`.headline`, `.caption`, ...) resolve to fixed point sizes that don't respond to
|
||||
/// a Dynamic Type category at all, so that environment key is a no-op here. `.appFont(_:)` instead
|
||||
/// gives each text style an explicit base point size (macOS's own approximate defaults) that this
|
||||
/// scale factor actually multiplies.
|
||||
enum AppTextSize: String, CaseIterable, Identifiable {
|
||||
case small
|
||||
case standard
|
||||
case large
|
||||
case extraLarge
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var scale: CGFloat {
|
||||
switch self {
|
||||
case .small: return 0.85
|
||||
case .standard: return 1.0
|
||||
case .large: return 1.2
|
||||
case .extraLarge: return 1.4
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct AppFontScaleKey: EnvironmentKey {
|
||||
static let defaultValue: CGFloat = 1.0
|
||||
}
|
||||
|
||||
extension EnvironmentValues {
|
||||
var appFontScale: CGFloat {
|
||||
get { self[AppFontScaleKey.self] }
|
||||
set { self[AppFontScaleKey.self] = newValue }
|
||||
}
|
||||
}
|
||||
|
||||
/// Named, scalable stand-ins for the semantic `Font.TextStyle` cases actually used in this app —
|
||||
/// each carries the same base point size macOS itself uses for that style, so `.standard` (scale
|
||||
/// 1.0) renders identically to the plain `Font.TextStyle` it replaces.
|
||||
enum AppFontStyle {
|
||||
case title2, title3, headline, body, callout, subheadline, caption, caption2
|
||||
/// An explicit point size/weight/design not covered by a named style above (rare — only where
|
||||
/// the code already used `.font(.system(size:...))` directly).
|
||||
case fixed(CGFloat, weight: Font.Weight = .regular, design: Font.Design = .default)
|
||||
|
||||
fileprivate var baseSize: CGFloat {
|
||||
switch self {
|
||||
case .title2: return 17
|
||||
case .title3: return 15
|
||||
case .headline: return 13
|
||||
case .body: return 13
|
||||
case .callout: return 12
|
||||
case .subheadline: return 11
|
||||
case .caption: return 10
|
||||
case .caption2: return 9
|
||||
case .fixed(let size, _, _): return size
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate var baseWeight: Font.Weight {
|
||||
if case .headline = self { return .semibold }
|
||||
if case .fixed(_, let weight, _) = self { return weight }
|
||||
return .regular
|
||||
}
|
||||
|
||||
fileprivate var design: Font.Design {
|
||||
if case .fixed(_, _, let design) = self { return design }
|
||||
return .default
|
||||
}
|
||||
}
|
||||
|
||||
private struct AppFontModifier: ViewModifier {
|
||||
@Environment(\.appFontScale) private var scale
|
||||
let style: AppFontStyle
|
||||
let weight: Font.Weight?
|
||||
let bold: Bool
|
||||
let design: Font.Design?
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
let resolvedWeight = bold ? .bold : (weight ?? style.baseWeight)
|
||||
content.font(.system(size: style.baseSize * scale, weight: resolvedWeight, design: design ?? style.design))
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
/// Text-only scaling for the Settings "Textgröße" preference — see `AppTextSize`'s doc comment
|
||||
/// for why this exists instead of `.environment(\.dynamicTypeSize, ...)`. `bold`/`weight`/
|
||||
/// `design` mirror the `.bold()`/`.weight(_:)`/`.monospaced()` chaining the replaced
|
||||
/// `.font(...)` calls used.
|
||||
func appFont(_ style: AppFontStyle, weight: Font.Weight? = nil, bold: Bool = false, design: Font.Design? = nil) -> some View {
|
||||
modifier(AppFontModifier(style: style, weight: weight, bold: bold, design: design))
|
||||
}
|
||||
}
|
||||
|
||||
/// Global control/spacing density ("Responsiveness" from the found.md feature idea, made
|
||||
/// concrete as SwiftUI's own `.controlSize`, applied once at the window root) — compact fits more
|
||||
/// on screen on a small display, comfortable gives buttons/fields more room to hit on a large one.
|
||||
enum UIDensity: String, CaseIterable, Identifiable {
|
||||
case compact
|
||||
case standard
|
||||
case comfortable
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var controlSize: ControlSize {
|
||||
switch self {
|
||||
case .compact: return .small
|
||||
case .standard: return .regular
|
||||
case .comfortable: return .large
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -100,10 +100,10 @@ struct BackupListView: View {
|
||||
VStack(alignment: .leading) {
|
||||
Text(backup.host).bold()
|
||||
Text(backup.createdAt.formatted(date: .abbreviated, time: .standard))
|
||||
.font(.caption)
|
||||
.appFont(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
if let model = BackupService.backupModel(from: backup.fileURL) {
|
||||
Text(model).font(.caption2).foregroundStyle(.secondary)
|
||||
Text(model).appFont(.caption2).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
@@ -125,10 +125,10 @@ struct BackupListView: View {
|
||||
.safeAreaInset(edge: .bottom) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(L10n.t("Speicherort", appLanguage))
|
||||
.font(.caption)
|
||||
.appFont(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(viewModel.backupDirectoryPath)
|
||||
.font(.caption)
|
||||
.appFont(.caption)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
.help(viewModel.backupDirectoryPath)
|
||||
@@ -143,10 +143,10 @@ struct BackupListView: View {
|
||||
.padding(.vertical, 4)
|
||||
|
||||
Text(L10n.t("Gefahrenzone", appLanguage))
|
||||
.font(.caption.bold())
|
||||
.appFont(.caption, bold: true)
|
||||
.foregroundStyle(.red)
|
||||
Text(L10n.t("Setzt den Router komplett auf die vom Hersteller mitgelieferte Standardkonfiguration zurück — alle bisherigen Änderungen (Internet, Heimnetz, VLANs, WLAN, Firewall) gehen verloren. Der Router startet danach neu.", appLanguage))
|
||||
.font(.caption)
|
||||
.appFont(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Button(role: .destructive) {
|
||||
showFactoryResetConfirmation = true
|
||||
@@ -168,14 +168,14 @@ struct BackupListView: View {
|
||||
L10n.t("Befehl gesendet — der Router startet jetzt neu (kann 1-2 Minuten dauern). Verbinde dich danach im Tab \"Verbinden\" erneut.", appLanguage),
|
||||
systemImage: "arrow.clockwise"
|
||||
)
|
||||
.font(.caption)
|
||||
.appFont(.caption)
|
||||
}
|
||||
if viewModel.didSendRestore {
|
||||
Label(
|
||||
L10n.t("Wiederherstellung gesendet — der Router startet jetzt neu (kann 1-2 Minuten dauern). Verbinde dich danach im Tab \"Verbinden\" erneut.", appLanguage),
|
||||
systemImage: "arrow.clockwise"
|
||||
)
|
||||
.font(.caption)
|
||||
.appFont(.caption)
|
||||
}
|
||||
}
|
||||
.padding(8)
|
||||
|
||||
@@ -20,6 +20,9 @@ struct DevicesView: View {
|
||||
/// Same reasoning as `showStaticConfirmation` — independent of `pendingStaticRemoval`.
|
||||
@State private var showRemovalConfirmation = false
|
||||
@AppStorage("appLanguage") private var appLanguage: String = "de"
|
||||
@AppStorage(AppPreferences.colorThemeKey) private var colorThemeRaw: String = ColorTheme.standard.rawValue
|
||||
@AppStorage(AppPreferences.lanScannerSparklineWidthKey) private var sparklineWidth: Double = 200
|
||||
private var colorTheme: ColorTheme { ColorTheme(rawValue: colorThemeRaw) ?? .standard }
|
||||
|
||||
init(connectionService: ConnectionService) {
|
||||
self.connectionService = connectionService
|
||||
@@ -53,12 +56,12 @@ struct DevicesView: View {
|
||||
ForEach(viewModel.portGroups) { group in
|
||||
Section {
|
||||
if group.devices.isEmpty {
|
||||
Text(L10n.t("Keine Geräte", appLanguage)).font(.caption).foregroundStyle(.secondary)
|
||||
Text(L10n.t("Keine Geräte", appLanguage)).appFont(.caption).foregroundStyle(.secondary)
|
||||
} else {
|
||||
DeviceColumnHeader(appLanguage: appLanguage)
|
||||
ForEach(group.devices) { device in
|
||||
HStack(spacing: 4) {
|
||||
DeviceRow(device: device, appLanguage: appLanguage)
|
||||
DeviceRow(device: device, appLanguage: appLanguage, theme: colorTheme)
|
||||
Spacer()
|
||||
// Per explicit request: right-click alone wasn't
|
||||
// discoverable ("nicht eindeutig erkennbar oder
|
||||
@@ -82,13 +85,13 @@ struct DevicesView: View {
|
||||
Text("\(group.title) (\(group.devices.count))")
|
||||
if let traffic = viewModel.portTraffic[group.id] {
|
||||
Spacer()
|
||||
TrafficSparkline(history: viewModel.portTrafficHistory[group.id] ?? [])
|
||||
TrafficSparkline(history: viewModel.portTrafficHistory[group.id] ?? [], width: sparklineWidth)
|
||||
Label(
|
||||
Self.formatTraffic(traffic),
|
||||
systemImage: traffic.isActive ? "arrow.up.arrow.down.circle.fill" : "arrow.up.arrow.down.circle"
|
||||
)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(traffic.isActive ? .green : .secondary)
|
||||
.appFont(.caption2)
|
||||
.foregroundStyle(traffic.isActive ? colorTheme.trafficActive : .secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -101,7 +104,7 @@ struct DevicesView: View {
|
||||
if viewModel.isRunningNetworkTool {
|
||||
HStack(spacing: 6) {
|
||||
ProgressView().controlSize(.small)
|
||||
Text(L10n.t("Führe Netzwerk-Test aus…", appLanguage)).font(.caption)
|
||||
Text(L10n.t("Führe Netzwerk-Test aus…", appLanguage)).appFont(.caption)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
@@ -152,7 +155,7 @@ struct DevicesView: View {
|
||||
showStaticConfirmation: $showStaticConfirmation,
|
||||
showRemovalConfirmation: $showRemovalConfirmation
|
||||
)
|
||||
.withDeviceDetailSheets(viewModel: viewModel, appLanguage: appLanguage, rawFieldsDevice: $rawFieldsDevice)
|
||||
.withDeviceDetailSheets(viewModel: viewModel, appLanguage: appLanguage, theme: colorTheme, rawFieldsDevice: $rawFieldsDevice)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -303,7 +306,7 @@ private extension View {
|
||||
}
|
||||
}
|
||||
|
||||
func withDeviceDetailSheets(viewModel: DevicesViewModel, appLanguage: String, rawFieldsDevice: Binding<LanDevice?>) -> some View {
|
||||
func withDeviceDetailSheets(viewModel: DevicesViewModel, appLanguage: String, theme: ColorTheme, rawFieldsDevice: Binding<LanDevice?>) -> some View {
|
||||
self
|
||||
.sheet(item: rawFieldsDevice) { device in
|
||||
RawFieldsSheet(device: device, appLanguage: appLanguage) { rawFieldsDevice.wrappedValue = nil }
|
||||
@@ -318,7 +321,7 @@ private extension View {
|
||||
get: { viewModel.portScanResult },
|
||||
set: { if $0 == nil { viewModel.portScanResult = nil } }
|
||||
)) { result in
|
||||
PortScanResultSheet(result: result, appLanguage: appLanguage) { viewModel.portScanResult = nil }
|
||||
PortScanResultSheet(result: result, appLanguage: appLanguage, theme: theme) { viewModel.portScanResult = nil }
|
||||
}
|
||||
.alert(
|
||||
L10n.t("Netzwerk-Test fehlgeschlagen", appLanguage),
|
||||
@@ -343,6 +346,7 @@ private extension View {
|
||||
/// layout doesn't jump around during the first couple of poll ticks after opening the tab.
|
||||
private struct TrafficSparkline: View {
|
||||
let history: [TrafficSample]
|
||||
let width: Double
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
@@ -362,7 +366,7 @@ private struct TrafficSparkline: View {
|
||||
Color.clear
|
||||
}
|
||||
}
|
||||
.frame(width: 200, height: 16)
|
||||
.frame(width: width, height: 16)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -395,6 +399,7 @@ private struct DeviceColumnHeader: View {
|
||||
private struct DeviceRow: View {
|
||||
let device: LanDevice
|
||||
let appLanguage: String
|
||||
let theme: ColorTheme
|
||||
|
||||
/// Three distinct states, not two — a device with no matching DHCP lease at all is neither
|
||||
/// "Fest" (a real static reservation) nor "Dynamisch" (a real active lease); conflating it
|
||||
@@ -407,7 +412,7 @@ private struct DeviceRow: View {
|
||||
|
||||
private var statusColor: Color {
|
||||
guard device.hasLease else { return .secondary }
|
||||
return device.isStatic ? .orange : .green
|
||||
return device.isStatic ? theme.staticLease : theme.dynamicLease
|
||||
}
|
||||
|
||||
private var statusText: String {
|
||||
@@ -454,7 +459,7 @@ private struct RawFieldsSheet: View {
|
||||
VStack(spacing: 0) {
|
||||
HStack {
|
||||
Text(L10n.t("Rohdaten:", appLanguage) + " \(device.macAddress)")
|
||||
.font(.headline)
|
||||
.appFont(.headline)
|
||||
Spacer()
|
||||
Button {
|
||||
onClose()
|
||||
@@ -471,15 +476,15 @@ private struct RawFieldsSheet: View {
|
||||
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text(L10n.t("Alle Felder, die RouterOS für diesen Eintrag zurückgegeben hat — hilfreich, falls Status/Port hier falsch aussieht.", appLanguage))
|
||||
.font(.caption)
|
||||
.appFont(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
ForEach(device.rawFields, id: \.key) { pair in
|
||||
HStack(alignment: .top) {
|
||||
Text(pair.key).font(.caption.monospaced()).foregroundStyle(.secondary)
|
||||
Text(pair.key).appFont(.caption, design: .monospaced).foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
Text(pair.value).font(.caption.monospaced()).multilineTextAlignment(.trailing)
|
||||
Text(pair.value).appFont(.caption, design: .monospaced).multilineTextAlignment(.trailing)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -502,7 +507,7 @@ private struct NetworkToolResultSheet: View {
|
||||
VStack(spacing: 0) {
|
||||
HStack {
|
||||
Text(result.title)
|
||||
.font(.headline)
|
||||
.appFont(.headline)
|
||||
Spacer()
|
||||
Button {
|
||||
onClose()
|
||||
@@ -519,11 +524,11 @@ private struct NetworkToolResultSheet: View {
|
||||
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text(L10n.t("Ausgeführt vom Router aus (eigene SSH-Verbindung) — testet die Erreichbarkeit vom Router zu diesem Gerät, nicht von diesem Mac.", appLanguage))
|
||||
.font(.caption)
|
||||
.appFont(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
ScrollView {
|
||||
Text(result.output.isEmpty ? L10n.t("(keine Ausgabe)", appLanguage) : result.output)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.appFont(.caption, design: .monospaced)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
@@ -541,13 +546,14 @@ private struct NetworkToolResultSheet: View {
|
||||
private struct PortScanResultSheet: View {
|
||||
let result: PortScanResult
|
||||
let appLanguage: String
|
||||
let theme: ColorTheme
|
||||
let onClose: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
HStack {
|
||||
Text(L10n.t("Port-Scan:", appLanguage) + " \(result.deviceLabel)")
|
||||
.font(.headline)
|
||||
.appFont(.headline)
|
||||
Spacer()
|
||||
Button {
|
||||
onClose()
|
||||
@@ -564,13 +570,13 @@ private struct PortScanResultSheet: View {
|
||||
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text(L10n.t("TCP-Verbindungsversuch auf gängige Ports, ausgeführt von diesem Mac aus (nicht vom Router) — rot = offen, grün = geschlossen (Gerät antwortet, aber nichts lauscht dort), grau = keine Antwort (Firewall, Gerät aus, oder Port gefiltert).", appLanguage))
|
||||
.font(.caption)
|
||||
.appFont(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
ScrollView {
|
||||
VStack(spacing: 0) {
|
||||
ForEach(Array(result.entries.enumerated()), id: \.element.id) { index, entry in
|
||||
if index > 0 { Divider() }
|
||||
PortScanRow(entry: entry, appLanguage: appLanguage)
|
||||
PortScanRow(entry: entry, appLanguage: appLanguage, theme: theme)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -584,11 +590,12 @@ private struct PortScanResultSheet: View {
|
||||
private struct PortScanRow: View {
|
||||
let entry: PortScanResult.Entry
|
||||
let appLanguage: String
|
||||
let theme: ColorTheme
|
||||
|
||||
private var color: Color {
|
||||
switch entry.status {
|
||||
case .open: return .red
|
||||
case .closed: return .green
|
||||
case .open: return theme.portOpen
|
||||
case .closed: return theme.portClosed
|
||||
case .unreachable: return .secondary
|
||||
}
|
||||
}
|
||||
@@ -605,15 +612,15 @@ private struct PortScanRow: View {
|
||||
HStack {
|
||||
Circle().fill(color).frame(width: 8, height: 8)
|
||||
Text("\(entry.port)")
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.appFont(.caption, design: .monospaced)
|
||||
.frame(width: 50, alignment: .leading)
|
||||
Text(entry.serviceName ?? "-")
|
||||
.font(.caption)
|
||||
.appFont(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 140, alignment: .leading)
|
||||
Spacer()
|
||||
Text(statusText)
|
||||
.font(.caption)
|
||||
.appFont(.caption)
|
||||
.foregroundStyle(color)
|
||||
}
|
||||
.padding(.vertical, 3)
|
||||
|
||||
@@ -61,10 +61,14 @@ final class DevicesViewModel: ObservableObject {
|
||||
self.networkToolsService = networkToolsService
|
||||
}
|
||||
|
||||
/// 0.1s cadence (faster than `ConnectViewModel.startTrafficPolling`'s 3s, per explicit
|
||||
/// request — this tab's sparkline needs a finer-grained trend, not just the link dot) —
|
||||
/// re-reads `portGroups` every tick so a newly-appearing port (e.g. a VLAN interface added
|
||||
/// via the Setup wizard while this tab is open) gets picked up without restarting the poll.
|
||||
/// Cadence and sparkline window are both live Settings values (`AppPreferences`, defaults
|
||||
/// 0.1s/30s) — read fresh from `UserDefaults` each tick/append rather than captured once, so
|
||||
/// a change in the Settings window while this tab is already polling takes effect on the next
|
||||
/// tick instead of needing the tab reopened. Deliberately faster by default than
|
||||
/// `ConnectViewModel.startTrafficPolling`'s fixed 3s (a separate, unrelated poll — this tab's
|
||||
/// sparkline needs a finer-grained trend, not just the link dot). Re-reads `portGroups` every
|
||||
/// tick so a newly-appearing port (e.g. a VLAN interface added via the Setup wizard while this
|
||||
/// tab is open) gets picked up without restarting the poll.
|
||||
func startTrafficPolling(credentials: RouterOSCredentials) {
|
||||
stopTrafficPolling()
|
||||
trafficPollingTask = Task {
|
||||
@@ -77,13 +81,15 @@ final class DevicesViewModel: ObservableObject {
|
||||
appendTrafficHistory(traffic, at: Date())
|
||||
}
|
||||
}
|
||||
try? await Task.sleep(for: .milliseconds(100))
|
||||
let intervalSeconds = UserDefaults.standard.object(forKey: AppPreferences.lanScannerPollIntervalSecondsKey) as? Double ?? 0.1
|
||||
try? await Task.sleep(for: .milliseconds(max(1, Int(intervalSeconds * 1000))))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func appendTrafficHistory(_ traffic: [String: InterfaceTraffic], at timestamp: Date) {
|
||||
let cutoff = timestamp.addingTimeInterval(-30)
|
||||
let windowSeconds = UserDefaults.standard.object(forKey: AppPreferences.lanScannerSparklineWindowSecondsKey) as? Double ?? 30
|
||||
let cutoff = timestamp.addingTimeInterval(-windowSeconds)
|
||||
for (name, sample) in traffic {
|
||||
var history = portTrafficHistory[name] ?? []
|
||||
history.append(TrafficSample(timestamp: timestamp, totalBitsPerSecond: sample.rxBitsPerSecond + sample.txBitsPerSecond))
|
||||
|
||||
@@ -9,15 +9,15 @@ struct ExpertMenuDetailView: View {
|
||||
Form {
|
||||
Section {
|
||||
Text(L10n.t(schema.displayName, appLanguage))
|
||||
.font(.title2)
|
||||
.appFont(.title2)
|
||||
.bold()
|
||||
Text(L10n.t(schema.category.rawValue, appLanguage))
|
||||
.font(.caption)
|
||||
.appFont(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(L10n.t(schema.explanation, appLanguage))
|
||||
if let warning = schema.warning {
|
||||
Label(L10n.t(warning, appLanguage), systemImage: "exclamationmark.triangle")
|
||||
.font(.caption)
|
||||
.appFont(.caption)
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
}
|
||||
@@ -128,7 +128,7 @@ struct ExpertItemEditView: View {
|
||||
// directly below, per explicit request to keep every popup's close button consistent.
|
||||
VStack(spacing: 0) {
|
||||
HStack {
|
||||
Text(L10n.t(schema.displayName, appLanguage)).font(.headline)
|
||||
Text(L10n.t(schema.displayName, appLanguage)).appFont(.headline)
|
||||
Spacer()
|
||||
Button {
|
||||
viewModel.cancelEditing()
|
||||
@@ -151,7 +151,7 @@ struct ExpertItemEditView: View {
|
||||
|
||||
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)
|
||||
.appFont(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
// Zwei Spalten statt einer Zeile pro Parameter — bei Menüs mit vielen ungekuratierten
|
||||
// RouterOS-Feldern (z.B. "Alle Interfaces (generisch)", wo Ethernet-Ports gut 30+
|
||||
@@ -202,7 +202,7 @@ struct ExpertItemEditView: View {
|
||||
if let command = viewModel.pendingCommand {
|
||||
Section(LocalizedStringKey(L10n.t("Wird ausgeführt", appLanguage))) {
|
||||
Text(command.cliLine)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.appFont(.caption, design: .monospaced)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
@@ -245,7 +245,7 @@ struct ExpertItemEditView: View {
|
||||
|
||||
private func extraColumnHeader(_ key: String) -> some View {
|
||||
Text(L10n.t(key, appLanguage))
|
||||
.font(.caption)
|
||||
.appFont(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
@@ -360,7 +360,7 @@ private struct DurationFieldEditor: View {
|
||||
.frame(minWidth: 26)
|
||||
}
|
||||
Text(unit)
|
||||
.font(.caption2)
|
||||
.appFont(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ struct ExpertView: View {
|
||||
@ObservedObject var connectionService: ConnectionService
|
||||
@StateObject private var viewModel: ExpertViewModel
|
||||
@AppStorage("appLanguage") private var appLanguage: String = "de"
|
||||
@AppStorage(AppPreferences.colorThemeKey) private var colorThemeRaw: String = ColorTheme.standard.rawValue
|
||||
private var colorTheme: ColorTheme { ColorTheme(rawValue: colorThemeRaw) ?? .standard }
|
||||
/// A category's presence in this set means "collapsed". Starts with every category
|
||||
/// collapsed (Nutzerwunsch: "standardmäßig sollten beim Öffnen des Experte-Tab alle
|
||||
/// Sektion zugeklappt sein") — click a header to fold its menu list open again.
|
||||
@@ -51,7 +53,7 @@ struct ExpertView: View {
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
sectionHeader(L10n.t(category.rawValue, appLanguage), isExpanded: isExpanded) {
|
||||
sectionHeader(L10n.t(category.rawValue, appLanguage), isExpanded: isExpanded, categoryColor: colorTheme.color(for: category)) {
|
||||
if isExpanded {
|
||||
collapsedCategories.insert(category)
|
||||
} else {
|
||||
@@ -65,7 +67,7 @@ struct ExpertView: View {
|
||||
Section {
|
||||
if isCustomPathExpanded {
|
||||
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)
|
||||
.appFont(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
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))
|
||||
@@ -113,15 +115,18 @@ struct ExpertView: View {
|
||||
/// nicht erst beim Hovern"). The whole header is a plain `Button` toggling `onToggle`, with
|
||||
/// the section's own content shown/hidden by the caller via a plain `if isExpanded` instead
|
||||
/// of the `Section(isExpanded:)` API, since that API owns its own (hover-only) chevron.
|
||||
private func sectionHeader(_ title: String, isExpanded: Bool, onToggle: @escaping () -> Void) -> some View {
|
||||
private func sectionHeader(_ title: String, isExpanded: Bool, categoryColor: Color? = nil, onToggle: @escaping () -> Void) -> some View {
|
||||
Button(action: onToggle) {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption.bold())
|
||||
.appFont(.caption, bold: true)
|
||||
.foregroundStyle(.secondary)
|
||||
.rotationEffect(.degrees(isExpanded ? 90 : 0))
|
||||
if let categoryColor {
|
||||
Circle().fill(categoryColor).frame(width: 8, height: 8)
|
||||
}
|
||||
Text(title)
|
||||
.font(.title3.bold())
|
||||
.appFont(.title3, bold: true)
|
||||
.foregroundStyle(.primary)
|
||||
}
|
||||
.padding(.vertical, 6)
|
||||
|
||||
@@ -13,6 +13,8 @@ struct OverviewView: View {
|
||||
/// instead of duplicating either here.
|
||||
@StateObject private var expertViewModel: ExpertViewModel
|
||||
@AppStorage("appLanguage") private var appLanguage: String = "de"
|
||||
@AppStorage(AppPreferences.colorThemeKey) private var colorThemeRaw: String = ColorTheme.standard.rawValue
|
||||
private var colorTheme: ColorTheme { ColorTheme(rawValue: colorThemeRaw) ?? .standard }
|
||||
@State private var scale: CGFloat = 1.0
|
||||
@State private var hoveredNodeID: String?
|
||||
@State private var hoveredEdge: OverviewEdge?
|
||||
@@ -232,6 +234,7 @@ struct OverviewView: View {
|
||||
graph: viewModel.graph,
|
||||
positions: effectivePositions,
|
||||
highlightedNodeIDs: focusChainIDs ?? highlightedNodeIDs,
|
||||
theme: colorTheme,
|
||||
hoveredEdge: $hoveredEdge,
|
||||
hoverPoint: $hoverPoint,
|
||||
selectedEdge: $selectedEdge,
|
||||
@@ -244,7 +247,7 @@ struct OverviewView: View {
|
||||
|
||||
ForEach(Array(OverviewLayout.columnOrder.enumerated()), id: \.offset) { index, category in
|
||||
Text(L10n.t(category.rawValue, appLanguage))
|
||||
.font(.headline)
|
||||
.appFont(.headline)
|
||||
.foregroundStyle(.secondary)
|
||||
.position(
|
||||
x: OverviewLayout.leftInset + CGFloat(index) * OverviewLayout.columnWidth + OverviewLayout.nodeWidth / 2,
|
||||
@@ -258,7 +261,8 @@ struct OverviewView: View {
|
||||
NodeCardView(
|
||||
node: node,
|
||||
isSelected: node.id == viewModel.selectedNodeID,
|
||||
isHovered: node.id == hoveredNodeID
|
||||
isHovered: node.id == hoveredNodeID,
|
||||
theme: colorTheme
|
||||
)
|
||||
.frame(width: OverviewLayout.nodeWidth, height: OverviewLayout.nodeHeight)
|
||||
.position(point)
|
||||
@@ -289,7 +293,7 @@ struct OverviewView: View {
|
||||
}
|
||||
|
||||
if let edge = hoveredEdge {
|
||||
EdgeTooltipView(edge: edge, graph: viewModel.graph, appLanguage: appLanguage)
|
||||
EdgeTooltipView(edge: edge, graph: viewModel.graph, appLanguage: appLanguage, theme: colorTheme)
|
||||
.position(
|
||||
x: min(max(hoverPoint.x + 90, 90), layout.canvasSize.width - 90),
|
||||
y: max(hoverPoint.y - 26, 16)
|
||||
@@ -332,7 +336,7 @@ struct OverviewView: View {
|
||||
private func focusPanel(subGraph: OverviewGraph, subLayout: OverviewLayoutResult) -> some View {
|
||||
VStack(spacing: 0) {
|
||||
HStack {
|
||||
Text(L10n.t("Fokus", appLanguage)).font(.headline)
|
||||
Text(L10n.t("Fokus", appLanguage)).appFont(.headline)
|
||||
Spacer()
|
||||
Button {
|
||||
viewModel.selectedNodeID = nil
|
||||
@@ -356,6 +360,7 @@ struct OverviewView: View {
|
||||
graph: subGraph,
|
||||
positions: subLayout.positions,
|
||||
highlightedNodeIDs: Set(subGraph.nodes.map(\.id)),
|
||||
theme: colorTheme,
|
||||
hoveredEdge: $panelHoveredEdge,
|
||||
hoverPoint: $panelHoverPoint,
|
||||
selectedEdge: $selectedEdge,
|
||||
@@ -371,7 +376,8 @@ struct OverviewView: View {
|
||||
NodeCardView(
|
||||
node: node,
|
||||
isSelected: node.id == viewModel.selectedNodeID,
|
||||
isHovered: node.id == hoveredNodeID
|
||||
isHovered: node.id == hoveredNodeID,
|
||||
theme: colorTheme
|
||||
)
|
||||
.frame(width: OverviewLayout.nodeWidth, height: OverviewLayout.nodeHeight)
|
||||
.position(point)
|
||||
@@ -386,7 +392,7 @@ struct OverviewView: View {
|
||||
}
|
||||
|
||||
if let edge = panelHoveredEdge {
|
||||
EdgeTooltipView(edge: edge, graph: subGraph, appLanguage: appLanguage)
|
||||
EdgeTooltipView(edge: edge, graph: subGraph, appLanguage: appLanguage, theme: colorTheme)
|
||||
.position(
|
||||
x: min(max(panelHoverPoint.x + 90, 90), subLayout.canvasSize.width - 90),
|
||||
y: max(panelHoverPoint.y - 26, 16)
|
||||
@@ -406,22 +412,22 @@ struct OverviewView: View {
|
||||
@ViewBuilder
|
||||
private var detailPanel: some View {
|
||||
if let edge = selectedEdge {
|
||||
EdgeDetailView(edge: edge, graph: viewModel.graph, appLanguage: appLanguage) { nodeID in
|
||||
EdgeDetailView(edge: edge, graph: viewModel.graph, appLanguage: appLanguage, theme: colorTheme) { nodeID in
|
||||
selectedEdge = nil
|
||||
viewModel.selectedNodeID = nodeID
|
||||
}
|
||||
} else if let id = viewModel.selectedNodeID, let node = viewModel.graph.nodes.first(where: { $0.id == id }) {
|
||||
NodeDetailView(node: node, graph: viewModel.graph, appLanguage: appLanguage, onEdit: editNode) { nodeID in
|
||||
NodeDetailView(node: node, graph: viewModel.graph, appLanguage: appLanguage, theme: colorTheme, onEdit: editNode) { nodeID in
|
||||
selectedEdge = nil
|
||||
viewModel.selectedNodeID = nodeID
|
||||
}
|
||||
} else {
|
||||
LegendView(appLanguage: appLanguage)
|
||||
LegendView(appLanguage: appLanguage, theme: colorTheme)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Draws every edge as a bezier curve, colored by its `kind` (see `OverviewStyle.color(for:)`)
|
||||
/// Draws every edge as a bezier curve, colored by its `kind` (see `colorTheme.color(for:)`)
|
||||
/// so the different dependency types are visually separated even with nothing selected. Hovering
|
||||
/// or clicking a node pushes its own edges to full strength and fades every other edge down to
|
||||
/// a hint, so "what connects to this" reads instantly in a busy diagram. Also tracks the mouse
|
||||
@@ -431,6 +437,7 @@ private struct EdgesCanvas: View {
|
||||
let graph: OverviewGraph
|
||||
let positions: [String: CGPoint]
|
||||
let highlightedNodeIDs: Set<String>
|
||||
let theme: ColorTheme
|
||||
@Binding var hoveredEdge: OverviewEdge?
|
||||
@Binding var hoverPoint: CGPoint
|
||||
@Binding var selectedEdge: OverviewEdge?
|
||||
@@ -537,7 +544,7 @@ private struct EdgesCanvas: View {
|
||||
var path = Path()
|
||||
path.move(to: geometry.start)
|
||||
path.addCurve(to: geometry.end, control1: geometry.control1, control2: geometry.control2)
|
||||
let baseColor = OverviewStyle.color(for: edge.kind)
|
||||
let baseColor = theme.color(for: edge.kind)
|
||||
context.stroke(
|
||||
path,
|
||||
with: .color(baseColor.opacity(isHovered ? 1.0 : (isDimmed ? 0.1 : (isConnected ? 1.0 : 0.6)))),
|
||||
@@ -640,6 +647,7 @@ private struct EdgeTooltipView: View {
|
||||
let edge: OverviewEdge
|
||||
let graph: OverviewGraph
|
||||
let appLanguage: String
|
||||
let theme: ColorTheme
|
||||
|
||||
private var fromTitle: String { graph.nodes.first(where: { $0.id == edge.from })?.title ?? edge.from }
|
||||
private var toTitle: String { graph.nodes.first(where: { $0.id == edge.to })?.title ?? edge.to }
|
||||
@@ -647,12 +655,12 @@ private struct EdgeTooltipView: View {
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(L10n.t(edge.kind.rawValue, appLanguage))
|
||||
.font(.caption.bold())
|
||||
.appFont(.caption, bold: true)
|
||||
Text("\(fromTitle) → \(toTitle)")
|
||||
.font(.caption2)
|
||||
.appFont(.caption2)
|
||||
if let label = edge.label {
|
||||
Text(label)
|
||||
.font(.caption2)
|
||||
.appFont(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
@@ -660,7 +668,7 @@ private struct EdgeTooltipView: View {
|
||||
.background(RoundedRectangle(cornerRadius: 6).fill(.regularMaterial))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 6)
|
||||
.stroke(OverviewStyle.color(for: edge.kind), lineWidth: 1)
|
||||
.stroke(theme.color(for: edge.kind), lineWidth: 1)
|
||||
)
|
||||
.fixedSize()
|
||||
}
|
||||
@@ -674,6 +682,7 @@ private struct EdgeDetailView: View {
|
||||
let edge: OverviewEdge
|
||||
let graph: OverviewGraph
|
||||
let appLanguage: String
|
||||
let theme: ColorTheme
|
||||
let onJumpToNode: (String) -> Void
|
||||
|
||||
private var fromNode: OverviewNode? { graph.nodes.first(where: { $0.id == edge.from }) }
|
||||
@@ -686,27 +695,27 @@ private struct EdgeDetailView: View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack {
|
||||
Image(systemName: "arrow.triangle.branch")
|
||||
.foregroundStyle(OverviewStyle.color(for: edge.kind))
|
||||
Text(L10n.t(edge.kind.rawValue, appLanguage)).font(.title3.bold())
|
||||
.foregroundStyle(theme.color(for: edge.kind))
|
||||
Text(L10n.t(edge.kind.rawValue, appLanguage)).appFont(.title3, bold: true)
|
||||
}
|
||||
Text(L10n.t("Verbindung", appLanguage)).font(.caption).foregroundStyle(.secondary)
|
||||
Text(L10n.t("Verbindung", appLanguage)).appFont(.caption).foregroundStyle(.secondary)
|
||||
|
||||
Divider()
|
||||
|
||||
Text(L10n.t(OverviewStyle.explanation(for: edge.kind), appLanguage))
|
||||
.font(.callout)
|
||||
.appFont(.callout)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
if let label = edge.label {
|
||||
Text(label)
|
||||
.font(.callout)
|
||||
.appFont(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
Text(L10n.t("Verbunden", appLanguage)).font(.subheadline.bold())
|
||||
Text(L10n.t("Verbunden", appLanguage)).appFont(.subheadline, bold: true)
|
||||
|
||||
endpointRow(title: L10n.t("Von", appLanguage), node: fromNode, fallbackTitle: fromTitle, id: edge.from)
|
||||
endpointRow(title: L10n.t("Nach", appLanguage), node: toNode, fallbackTitle: toTitle, id: edge.to)
|
||||
@@ -722,18 +731,18 @@ private struct EdgeDetailView: View {
|
||||
} label: {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(title).font(.caption).foregroundStyle(.secondary)
|
||||
Text(title).appFont(.caption).foregroundStyle(.secondary)
|
||||
HStack(spacing: 4) {
|
||||
if let node {
|
||||
Image(systemName: OverviewStyle.icon(for: node.kind))
|
||||
.foregroundStyle(OverviewStyle.color(for: node.category))
|
||||
.foregroundStyle(theme.color(for: node.category))
|
||||
}
|
||||
Text(fallbackTitle).font(.callout.bold())
|
||||
Text(fallbackTitle).appFont(.callout, bold: true)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption)
|
||||
.appFont(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(8)
|
||||
@@ -747,12 +756,13 @@ private struct NodeCardView: View {
|
||||
let node: OverviewNode
|
||||
let isSelected: Bool
|
||||
let isHovered: Bool
|
||||
let theme: ColorTheme
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 2) {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: OverviewStyle.icon(for: node.kind))
|
||||
.foregroundStyle(OverviewStyle.color(for: node.category))
|
||||
.foregroundStyle(theme.color(for: node.category))
|
||||
Text(node.title)
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.lineLimit(1)
|
||||
@@ -770,7 +780,7 @@ private struct NodeCardView: View {
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.stroke(
|
||||
isSelected || isHovered ? Color.accentColor : OverviewStyle.color(for: node.category).opacity(0.5),
|
||||
isSelected || isHovered ? Color.accentColor : theme.color(for: node.category).opacity(0.5),
|
||||
lineWidth: isSelected || isHovered ? 2 : 1
|
||||
)
|
||||
)
|
||||
@@ -785,6 +795,7 @@ private struct NodeDetailView: View {
|
||||
let node: OverviewNode
|
||||
let graph: OverviewGraph
|
||||
let appLanguage: String
|
||||
let theme: ColorTheme
|
||||
let onEdit: (OverviewNode) -> Void
|
||||
let onSelectNode: (String) -> Void
|
||||
|
||||
@@ -793,8 +804,8 @@ private struct NodeDetailView: View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack {
|
||||
Image(systemName: OverviewStyle.icon(for: node.kind))
|
||||
.foregroundStyle(OverviewStyle.color(for: node.category))
|
||||
Text(node.title).font(.title3.bold())
|
||||
.foregroundStyle(theme.color(for: node.category))
|
||||
Text(node.title).appFont(.title3, bold: true)
|
||||
Spacer()
|
||||
if node.editTarget != nil {
|
||||
Button {
|
||||
@@ -806,7 +817,7 @@ private struct NodeDetailView: View {
|
||||
}
|
||||
}
|
||||
Text(L10n.t(node.category.rawValue, appLanguage))
|
||||
.font(.caption)
|
||||
.appFont(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
if !node.detail.isEmpty {
|
||||
@@ -814,9 +825,9 @@ private struct NodeDetailView: View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
ForEach(node.detail, id: \.key) { pair in
|
||||
HStack(alignment: .top) {
|
||||
Text(pair.key).font(.caption.monospaced()).foregroundStyle(.secondary)
|
||||
Text(pair.key).appFont(.caption, design: .monospaced).foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
Text(pair.value).font(.caption.monospaced()).multilineTextAlignment(.trailing)
|
||||
Text(pair.value).appFont(.caption, design: .monospaced).multilineTextAlignment(.trailing)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -825,7 +836,7 @@ private struct NodeDetailView: View {
|
||||
let connected = graph.edges.filter { $0.from == node.id || $0.to == node.id }
|
||||
if !connected.isEmpty {
|
||||
Divider()
|
||||
Text(L10n.t("Verbindungen", appLanguage)).font(.subheadline.bold())
|
||||
Text(L10n.t("Verbindungen", appLanguage)).appFont(.subheadline, bold: true)
|
||||
ForEach(connected) { edge in
|
||||
let otherID = edge.from == node.id ? edge.to : edge.from
|
||||
let otherTitle = graph.nodes.first(where: { $0.id == otherID })?.title ?? otherID
|
||||
@@ -836,8 +847,8 @@ private struct NodeDetailView: View {
|
||||
edge.label.map { "\(otherTitle) (\($0))" } ?? otherTitle,
|
||||
systemImage: edge.from == node.id ? "arrow.right" : "arrow.left"
|
||||
)
|
||||
.foregroundStyle(OverviewStyle.color(for: edge.kind))
|
||||
.font(.caption)
|
||||
.foregroundStyle(theme.color(for: edge.kind))
|
||||
.appFont(.caption)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.help(L10n.t(OverviewStyle.explanation(for: edge.kind), appLanguage))
|
||||
@@ -851,45 +862,46 @@ private struct NodeDetailView: View {
|
||||
|
||||
private struct LegendView: View {
|
||||
let appLanguage: String
|
||||
let theme: ColorTheme
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
Text(L10n.t("Übersicht", appLanguage)).font(.title3.bold())
|
||||
Text(L10n.t("Übersicht", appLanguage)).appFont(.title3, bold: true)
|
||||
Text(L10n.t("Zeigt den kompletten IST-Zustand des verbundenen Routers als Diagramm: Interfaces links, darauf aufbauend IP-Adressen, DHCP/Pools, Routen und rechts die Firewall/NAT-Regeln, die auf sie zugreifen. Linien zeigen echte Abhängigkeiten (z.B. \"DHCP-Server nutzt diesen Pool\"), keine Vermutungen.", appLanguage))
|
||||
.font(.caption)
|
||||
.appFont(.caption)
|
||||
|
||||
Divider()
|
||||
Text(L10n.t("Auf ein Feld klicken zeigt hier rechts Details und alle Verbindungen.", appLanguage))
|
||||
.font(.caption)
|
||||
.appFont(.caption)
|
||||
|
||||
Divider()
|
||||
Text(L10n.t("Nicht im Diagramm (aber im Experte-Tab erreichbar):", appLanguage)).font(.caption.bold())
|
||||
Text(L10n.t("Nicht im Diagramm (aber im Experte-Tab erreichbar):", appLanguage)).appFont(.caption, bold: true)
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
ForEach(OverviewGraph.unmappedAreas, id: \.self) { area in
|
||||
Text("· \(L10n.t(area, appLanguage))")
|
||||
.font(.caption2)
|
||||
.appFont(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Divider()
|
||||
Text(L10n.t("Spalten", appLanguage)).font(.caption.bold())
|
||||
Text(L10n.t("Spalten", appLanguage)).appFont(.caption, bold: true)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
ForEach(OverviewNode.Category.allCases) { category in
|
||||
Label(L10n.t(category.rawValue, appLanguage), systemImage: "square.fill")
|
||||
.foregroundStyle(OverviewStyle.color(for: category))
|
||||
.font(.caption)
|
||||
.foregroundStyle(theme.color(for: category))
|
||||
.appFont(.caption)
|
||||
}
|
||||
}
|
||||
|
||||
Divider()
|
||||
Text(L10n.t("Verbindungsarten", appLanguage)).font(.caption.bold())
|
||||
Text(L10n.t("Verbindungsarten", appLanguage)).appFont(.caption, bold: true)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
ForEach(OverviewEdgeKind.allCases) { kind in
|
||||
Label(L10n.t(kind.rawValue, appLanguage), systemImage: "minus")
|
||||
.foregroundStyle(OverviewStyle.color(for: kind))
|
||||
.font(.caption)
|
||||
.foregroundStyle(theme.color(for: kind))
|
||||
.appFont(.caption)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -898,30 +910,9 @@ private struct LegendView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// Colors moved to `ColorTheme` (`AppPreferences.swift`) so they respond to the Settings window's
|
||||
/// "Farbschema" picker — this enum now only holds the parts that don't vary by theme.
|
||||
enum OverviewStyle {
|
||||
static func color(for category: OverviewNode.Category) -> Color {
|
||||
switch category {
|
||||
case .interface: return .blue
|
||||
case .ipAddress: return .teal
|
||||
case .service: return .purple
|
||||
case .route: return .orange
|
||||
case .firewall: return .red
|
||||
}
|
||||
}
|
||||
|
||||
static func color(for kind: OverviewEdgeKind) -> Color {
|
||||
switch kind {
|
||||
case .vlan: return .indigo
|
||||
case .bridgePort: return .cyan
|
||||
case .wireguardPeer: return .pink
|
||||
case .ipAddress: return .teal
|
||||
case .dhcp: return .purple
|
||||
case .route: return .orange
|
||||
case .firewallInterface: return .red
|
||||
case .addressList: return .yellow
|
||||
}
|
||||
}
|
||||
|
||||
/// Plain-language explanation of what this connection kind actually means — shown in
|
||||
/// `EdgeDetailView` so clicking a line answers "why is this connected" for someone who
|
||||
/// doesn't already know RouterOS' internal terminology.
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import SwiftUI
|
||||
|
||||
/// The app's native Settings window (⌘,, wired up as a `Settings { }` scene in
|
||||
/// `RouterOSAssistantApp`) — every control here is a plain `@AppStorage` binding under a key
|
||||
/// catalogued in `AppPreferences`, so there's no view model: nothing here is anything other than
|
||||
/// persisted preference state, and each tab's changes take effect live (no "Apply"/restart)
|
||||
/// since every consumer (`OverviewView`, `DevicesView`, `DevicesViewModel`, ...) either observes
|
||||
/// the same `@AppStorage` key itself or re-reads `UserDefaults.standard` on its own next tick.
|
||||
struct SettingsView: View {
|
||||
@AppStorage("appLanguage") private var appLanguage: String = "de"
|
||||
|
||||
var body: some View {
|
||||
TabView {
|
||||
GeneralSettingsView()
|
||||
.tabItem { Label(L10n.t("Allgemein", appLanguage), systemImage: "gearshape") }
|
||||
AppearanceSettingsView()
|
||||
.tabItem { Label(L10n.t("Darstellung", appLanguage), systemImage: "paintpalette") }
|
||||
NetworkSettingsView()
|
||||
.tabItem { Label(L10n.t("Netzwerk", appLanguage), systemImage: "network") }
|
||||
}
|
||||
.frame(width: 480)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
|
||||
private struct GeneralSettingsView: View {
|
||||
@AppStorage("appLanguage") private var appLanguage: String = "de"
|
||||
@AppStorage(AppPreferences.autoCheckUpdatesOnConnectKey) private var autoCheckUpdatesOnConnect: Bool = false
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section(L10n.t("Sprache", appLanguage)) {
|
||||
Picker(L10n.t("Sprache", appLanguage), selection: $appLanguage) {
|
||||
Text("Deutsch").tag("de")
|
||||
Text("English").tag("en")
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.labelsHidden()
|
||||
Text(L10n.t("Entspricht dem 🇩🇪/🇬🇧-Knopf in der Toolbar — beide steuern dieselbe Einstellung.", appLanguage))
|
||||
.appFont(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Section(L10n.t("RouterOS-Updates", appLanguage)) {
|
||||
Toggle(L10n.t("Beim Verbinden automatisch nach Updates suchen", appLanguage), isOn: $autoCheckUpdatesOnConnect)
|
||||
Text(L10n.t("Prüft nach jedem erfolgreichen Verbinden automatisch, ob eine neuere RouterOS-Version verfügbar ist — entspricht dem Button \"Nach Updates suchen\" im Tab \"Verbinden\".", appLanguage))
|
||||
.appFont(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
|
||||
private struct AppearanceSettingsView: View {
|
||||
@AppStorage("appLanguage") private var appLanguage: String = "de"
|
||||
@AppStorage(AppPreferences.colorThemeKey) private var colorThemeRaw: String = ColorTheme.standard.rawValue
|
||||
@AppStorage(AppPreferences.textSizeKey) private var textSizeRaw: String = AppTextSize.standard.rawValue
|
||||
@AppStorage(AppPreferences.uiDensityKey) private var uiDensityRaw: String = UIDensity.standard.rawValue
|
||||
|
||||
private var colorTheme: Binding<ColorTheme> {
|
||||
Binding(get: { ColorTheme(rawValue: colorThemeRaw) ?? .standard }, set: { colorThemeRaw = $0.rawValue })
|
||||
}
|
||||
private var textSize: Binding<AppTextSize> {
|
||||
Binding(get: { AppTextSize(rawValue: textSizeRaw) ?? .standard }, set: { textSizeRaw = $0.rawValue })
|
||||
}
|
||||
private var uiDensity: Binding<UIDensity> {
|
||||
Binding(get: { UIDensity(rawValue: uiDensityRaw) ?? .standard }, set: { uiDensityRaw = $0.rawValue })
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section(L10n.t("Farbschema", appLanguage)) {
|
||||
Picker(L10n.t("Farbschema", appLanguage), selection: colorTheme) {
|
||||
Text(L10n.t("Standard", appLanguage)).tag(ColorTheme.standard)
|
||||
Text(L10n.t("Kontrastreich", appLanguage)).tag(ColorTheme.highContrast)
|
||||
}
|
||||
.pickerStyle(.radioGroup)
|
||||
Text(L10n.t("Gilt für die Diagrammfarben in der Übersicht und die Status-Farben im LAN-Scanner (Traffic aktiv, Fest/Dynamisch, offener/geschlossener Port).", appLanguage))
|
||||
.appFont(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Section(L10n.t("Textgröße", appLanguage)) {
|
||||
Picker(L10n.t("Textgröße", appLanguage), selection: textSize) {
|
||||
Text(L10n.t("Klein", appLanguage)).tag(AppTextSize.small)
|
||||
Text(L10n.t("Standard", appLanguage)).tag(AppTextSize.standard)
|
||||
Text(L10n.t("Groß", appLanguage)).tag(AppTextSize.large)
|
||||
Text(L10n.t("Sehr groß", appLanguage)).tag(AppTextSize.extraLarge)
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.labelsHidden()
|
||||
}
|
||||
Section(L10n.t("Bedienelemente", appLanguage)) {
|
||||
Picker(L10n.t("Bedienelemente", appLanguage), selection: uiDensity) {
|
||||
Text(L10n.t("Kompakt", appLanguage)).tag(UIDensity.compact)
|
||||
Text(L10n.t("Standard", appLanguage)).tag(UIDensity.standard)
|
||||
Text(L10n.t("Komfortabel", appLanguage)).tag(UIDensity.comfortable)
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.labelsHidden()
|
||||
Text(L10n.t("Größe von Buttons, Feldern und Abständen in der ganzen App (\"Responsiveness\").", appLanguage))
|
||||
.appFont(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
|
||||
private struct NetworkSettingsView: View {
|
||||
@AppStorage("appLanguage") private var appLanguage: String = "de"
|
||||
@AppStorage(AppPreferences.lanScannerPollIntervalSecondsKey) private var pollInterval: Double = 0.1
|
||||
@AppStorage(AppPreferences.lanScannerSparklineWindowSecondsKey) private var sparklineWindow: Double = 30
|
||||
@AppStorage(AppPreferences.lanScannerSparklineWidthKey) private var sparklineWidth: Double = 200
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section(L10n.t("LAN-Scanner: Traffic-Anzeige", appLanguage)) {
|
||||
Picker(L10n.t("Aktualisierungsrate", appLanguage), selection: $pollInterval) {
|
||||
Text("0,1 s").tag(0.1)
|
||||
Text("0,5 s").tag(0.5)
|
||||
Text("1 s").tag(1.0)
|
||||
Text("3 s").tag(3.0)
|
||||
}
|
||||
Picker(L10n.t("Sparkline-Zeitfenster", appLanguage), selection: $sparklineWindow) {
|
||||
Text("10 s").tag(10.0)
|
||||
Text("30 s").tag(30.0)
|
||||
Text("60 s").tag(60.0)
|
||||
}
|
||||
Picker(L10n.t("Sparkline-Breite", appLanguage), selection: $sparklineWidth) {
|
||||
Text("100 pt").tag(100.0)
|
||||
Text("150 pt").tag(150.0)
|
||||
Text("200 pt").tag(200.0)
|
||||
Text("300 pt").tag(300.0)
|
||||
}
|
||||
Text(L10n.t("Höhere Aktualisierungsraten zeigen den Traffic-Verlauf feiner, kosten aber mehr CPU bei vielen gleichzeitig aktiven Ports.", appLanguage))
|
||||
.appFont(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
SettingsView()
|
||||
}
|
||||
@@ -240,7 +240,7 @@ struct ConnectView: View {
|
||||
L10n.t("Zuletzt gesichert:", appLanguage) + " \(lastBackup.createdAt.formatted(date: .abbreviated, time: .standard))",
|
||||
systemImage: "checkmark"
|
||||
)
|
||||
.font(.caption)
|
||||
.appFont(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
@@ -290,11 +290,11 @@ struct ConnectView: View {
|
||||
.disabled(viewModel.isUpgradingFirmware)
|
||||
} else {
|
||||
Label(L10n.t("Firmware aktuell", appLanguage), systemImage: "checkmark.circle")
|
||||
.font(.caption)
|
||||
.appFont(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
if let message = viewModel.firmwareUpgradeResultMessage {
|
||||
Text(message).font(.caption).foregroundStyle(.secondary)
|
||||
Text(message).appFont(.caption).foregroundStyle(.secondary)
|
||||
Button {
|
||||
showRebootConfirmation = true
|
||||
} label: {
|
||||
@@ -347,7 +347,7 @@ struct ConnectView: View {
|
||||
L10n.t("Update-Befehl gesendet — der Router lädt herunter, installiert und startet danach automatisch neu.", appLanguage),
|
||||
systemImage: "arrow.clockwise"
|
||||
)
|
||||
.font(.caption)
|
||||
.appFont(.caption)
|
||||
}
|
||||
}
|
||||
Section("Interfaces") {
|
||||
@@ -359,7 +359,7 @@ struct ConnectView: View {
|
||||
)
|
||||
VStack(alignment: .leading) {
|
||||
Text(interface.name).bold()
|
||||
Text(interface.type).font(.caption).foregroundStyle(.secondary)
|
||||
Text(interface.type).appFont(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -574,12 +574,12 @@ private struct SavedRouterRow: View {
|
||||
Text(router.name).bold()
|
||||
if !router.location.isEmpty {
|
||||
Label(router.location, systemImage: "mappin.and.ellipse")
|
||||
.font(.caption)
|
||||
.appFont(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
Text("\(router.username)@\(router.host)")
|
||||
.font(.caption)
|
||||
.appFont(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
// Nutzerwunsch (2026-09-16): Seriennummer mit hinterlegen, damit zwei
|
||||
// physisch unterschiedliche Router mit identischem Host+Benutzername (z.B.
|
||||
@@ -588,7 +588,7 @@ private struct SavedRouterRow: View {
|
||||
// SavedRoutersStore.recordSuccessfulConnection.
|
||||
if let serialNumber = router.serialNumber {
|
||||
Text("SN: \(serialNumber)")
|
||||
.font(.caption2)
|
||||
.appFont(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,6 +88,12 @@ final class ConnectViewModel: ObservableObject {
|
||||
savedRouters = savedRoutersStore.recordSuccessfulConnection(
|
||||
host: host, username: username, defaultName: defaultName, serialNumber: serialNumber
|
||||
)
|
||||
// Settings toggle (default off) — reuses the same manual "Nach Updates suchen"
|
||||
// path the button in this tab triggers, just fired automatically once a
|
||||
// connection succeeds instead of waiting for a click.
|
||||
if UserDefaults.standard.bool(forKey: AppPreferences.autoCheckUpdatesOnConnectKey) {
|
||||
checkForUpdates(for: credentials)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ struct FirewallStepView: View {
|
||||
Text(L10n.t("Firewall-Grundschutz ist im einfachen Modus immer aktiv.", appLanguage))
|
||||
}
|
||||
Text(L10n.t("Richtet einen Standard-Schutz ein: Internetfreigabe (NAT) für dein Heimnetz, und blockiert unaufgeforderte Zugriffe aus dem Internet auf deinen Router und deine Geräte. Bestehende, selbst eingerichtete Regeln bleiben erhalten — die neuen Regeln werden vorangestellt.", appLanguage))
|
||||
.font(.caption)
|
||||
.appFont(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
@@ -33,11 +33,11 @@ struct FirewallStepView: View {
|
||||
LabeledContent(L10n.t("NAT-Regeln", appLanguage), value: "\(counts.natRuleCount)")
|
||||
if counts.filterRuleCount > 0 || counts.natRuleCount > 0 {
|
||||
Text(L10n.t("Dein Router hat bereits eigene Firewall-Regeln. Die neuen Regeln werden vorangestellt, bestehende bleiben erhalten — prüfe nach dem Anwenden trotzdem die Reihenfolge, z.B. über Winbox oder \"/ip firewall filter print\".", appLanguage))
|
||||
.font(.caption)
|
||||
.appFont(.caption)
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
} else if let error = viewModel.firewallRuleCountsError {
|
||||
Text(error).font(.caption).foregroundStyle(.red)
|
||||
Text(error).appFont(.caption).foregroundStyle(.red)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -46,7 +46,7 @@ struct FirewallStepView: View {
|
||||
Section(L10n.t("Isolierte Netzwerke", appLanguage)) {
|
||||
Text(viewModel.isolatedNetworkNames.joined(separator: ", "))
|
||||
Text(L10n.t("Diese Netzwerke werden vom Datenverkehr aller anderen konfigurierten Netzwerke abgeschottet, sobald der Firewall-Grundschutz aktiv ist.", appLanguage))
|
||||
.font(.caption)
|
||||
.appFont(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ struct LanStepView: View {
|
||||
if viewModel.isCheckingPortConflict.contains(config.id) {
|
||||
HStack {
|
||||
ProgressView().controlSize(.small)
|
||||
Text(L10n.t("Prüfe, ob der Port frei ist…", appLanguage)).font(.caption).foregroundStyle(.secondary)
|
||||
Text(L10n.t("Prüfe, ob der Port frei ist…", appLanguage)).appFont(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
} else if let conflict = viewModel.lanPortConflicts[config.id] {
|
||||
PortConflictWarningView(
|
||||
@@ -117,19 +117,19 @@ private struct PortConflictWarningView: View {
|
||||
var body: some View {
|
||||
if isAcknowledged {
|
||||
Label("\(conflict.interfaceName) " + L10n.t("wird beim Anwenden freigemacht", appLanguage), systemImage: "checkmark.shield")
|
||||
.font(.caption)
|
||||
.appFont(.caption)
|
||||
.foregroundStyle(.orange)
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Label(L10n.t("Port", appLanguage) + " \(conflict.interfaceName) " + L10n.t("ist nicht frei", appLanguage), systemImage: "exclamationmark.triangle.fill")
|
||||
.font(.subheadline.bold())
|
||||
.appFont(.subheadline, bold: true)
|
||||
.foregroundStyle(.red)
|
||||
ForEach(Array(conflict.reasons.enumerated()), id: \.offset) { _, reason in
|
||||
Text("• \(reason.description)")
|
||||
.font(.caption)
|
||||
.appFont(.caption)
|
||||
}
|
||||
Text(L10n.t("Wähle oben einen anderen, freien Port — oder mache diesen jetzt frei. Die bestehende Konfiguration wird dabei entfernt.", appLanguage))
|
||||
.font(.caption)
|
||||
.appFont(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Button(L10n.t("Port jetzt freimachen…", appLanguage), role: .destructive) {
|
||||
showConsequencesConfirmation = true
|
||||
|
||||
@@ -19,7 +19,7 @@ struct ModeStepView: View {
|
||||
case .simple:
|
||||
Text(L10n.t("Grundeinrichtung: Internetverbindung (WAN), ein Heimnetzwerk (LAN) mit DHCP, WLAN und ein fest aktivierter Firewall-Grundschutz.", appLanguage))
|
||||
Text(L10n.t("Kein VLAN, keine mehreren getrennten Netzwerke, keine Netzwerk-Isolation — für später jederzeit über den Expertenmodus nachrüstbar.", appLanguage))
|
||||
.font(.caption)
|
||||
.appFont(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
case .expert:
|
||||
Text(L10n.t("Voller Zugriff: mehrere LAN-Interfaces mit je eigenem DHCP-Server, VLANs, Netzwerk-Isolation zwischen Netzwerken, und alle Firewall-Optionen.", appLanguage))
|
||||
|
||||
@@ -15,7 +15,7 @@ struct ReviewApplyView: View {
|
||||
Text(command.summary)
|
||||
if showCliDetails {
|
||||
Text(command.cliLine)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.appFont(.caption, design: .monospaced)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
@@ -25,14 +25,14 @@ struct ReviewApplyView: View {
|
||||
|
||||
Section {
|
||||
Text(L10n.t("Vor dem Anwenden wird automatisch eine Sicherung der aktuellen Konfiguration erstellt (Tab \"Sicherungen\"). Ein garantiertes automatisches Zurückrollen bei Verbindungsabbruch gibt es nicht — bei Problemen die Sicherung im Tab \"Sicherungen\" verwenden oder den Router lokal (Ethernet/Konsole) wiederherstellen.", appLanguage))
|
||||
.font(.caption)
|
||||
.appFont(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
if !viewModel.applyLog.isEmpty {
|
||||
Section(L10n.t("Ablauf", appLanguage)) {
|
||||
ForEach(viewModel.applyLog, id: \.self) { line in
|
||||
Text(line).font(.caption)
|
||||
Text(line).appFont(.caption)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ struct VlanStepView: View {
|
||||
)
|
||||
.help(L10n.t("Erstellt ein zusätzliches Netzwerk mit eigenem Adressbereich und eigenem DHCP-Server.", appLanguage))
|
||||
Text(L10n.t("Ein VLAN ist ein zusätzliches Netzwerk mit eigenem Adressbereich — z.B. für Gäste oder smarte Geräte. Ob es vom Hauptnetzwerk abgeschottet ist, legst du unten pro Netzwerk über \"Von anderen Netzwerken isolieren\" fest. Wenn du unsicher bist, ob du das brauchst, überspring diesen Schritt einfach.", appLanguage))
|
||||
.font(.caption)
|
||||
.appFont(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ struct WanStepView: View {
|
||||
SecureField(L10n.t("Passwort", appLanguage), text: $viewModel.wanConfig.pppoePassword)
|
||||
.help(L10n.t("Das zum Benutzernamen gehörende Passwort von deinem Internetanbieter.", appLanguage))
|
||||
Text(L10n.t("Diese Zugangsdaten bekommst du von deinem Internetanbieter (z.B. Telekom, Vodafone).", appLanguage))
|
||||
.font(.caption)
|
||||
.appFont(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ struct WifiStepView: View {
|
||||
+ "\(viewModel.unsupportedWifiInterfaces.map(\.name).joined(separator: ", "))"
|
||||
+ L10n.t("), den diese App noch nicht unterstützt. WLAN muss dafür vorerst manuell über Winbox eingerichtet werden.", appLanguage)
|
||||
)
|
||||
.font(.caption)
|
||||
.appFont(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user