Files
RouterOS/RouterOSAssistant/Core/Models/AppPreferences.swift
T
KayandClaude Sonnet 5 221b914b05 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>
2026-09-17 13:38:22 +02:00

237 lines
9.8 KiB
Swift

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
}
}
}