import SwiftUI import OnyxDesign import MetricsProvider import OnyxHelperProtocol /// Die Lüftersteuerung in den Einstellungen. /// /// Bewusst hier und nicht im Notch-Panel: ein Regler, der die Kühlung des /// Rechners verstellt, gehört nicht dorthin, wo man mit dem Mauszeiger /// versehentlich hinkommt. struct FanSettingsView: View { @Bindable var control: FanControl var body: some View { SettingsPage { switch control.installState { case .notInstalled, .failed: installSection case .requiresApproval: approvalSection case .installed: controlSection // Das Ladelimit hängt am selben Helfer wie die Lüfter — beides // ist Hardwaresteuerung über den SMC, und beides ist erst // erreichbar, wenn der Helfer läuft. OnyxFormCard(String(localized: "charge.section")) { ChargeLimitControl(control: control, roomy: true) .padding(.horizontal, OnyxForm.rowPadding) .padding(.bottom, OnyxForm.rowPadding) } } } .onAppear { control.refreshInstallState() control.start() } .onDisappear { control.stop() } } // MARK: - Einrichtung private var installSection: some View { OnyxFormCard(String(localized: "fans.group.setup"), hint: String(localized: "fans.intro")) { // Das Risiko benennen, bevor jemand zustimmt — nicht danach. OnyxFormRow(String(localized: "fans.warning"), hint: String(localized: "fans.safety"), symbol: "exclamationmark.triangle.fill", tone: .warning) if case .failed(let message) = control.installState { OnyxFormRow(message, symbol: "xmark.octagon", tone: .critical) } OnyxFormRow(String(localized: "fans.install")) { Button("fans.install") { control.install() } } } } private var approvalSection: some View { OnyxFormCard(String(localized: "fans.group.setup")) { OnyxFormRow(String(localized: "fans.approval.needed"), symbol: "hand.raised") { Button("fans.approval.open") { control.openApprovalSettings() } } OnyxFormRow(String(localized: "fans.action.recheck")) { Button("fans.action.recheck") { control.refreshInstallState() } } } } // MARK: - Steuerung @ViewBuilder private var controlSection: some View { if let report = control.report { OnyxFormCard(String(localized: "fans.group.control")) { if let hottest = report.hottestCelsius { OnyxFormRow(String(localized: "fans.hottest"), symbol: "thermometer") { Text("\(Int(hottest))°").monospacedDigit() .foregroundStyle(hottest >= FanSafety.criticalCelsius ? AnyShapeStyle(Onyx.Color.critical) : AnyShapeStyle(.secondary)) } } ForEach(report.fans, id: \.index) { fan in FanRow(fan: fan, control: control) } } OnyxFormCard(String(localized: "fans.group.danger"), hint: String(localized: "fans.safety.active")) { OnyxFormRow(String(localized: "fans.uninstall")) { Button("settings.action.remove", role: .destructive) { Task { await control.uninstall() } } } } } else { OnyxFormCard(String(localized: "fans.group.control")) { OnyxFormRow(String(localized: "fans.connecting")) { ProgressView().controlSize(.small) } if let error = control.lastError { OnyxFormRow(error, symbol: "xmark.octagon", tone: .critical) } } } } } private struct FanRow: View { let fan: FanStatus let control: FanControl @State private var target: Double = 0 @State private var editing = false var body: some View { VStack(alignment: .leading, spacing: Onyx.Space.sm) { HStack { Label(name, systemImage: "fan") Spacer() Text("\(Int(fan.currentRPM)) U/min").monospacedDigit().foregroundStyle(.secondary) } Picker("", selection: Binding( get: { fan.isManual }, set: { manual in if manual { control.setTarget(index: fan.index, rpm: max(target, fan.limits.minimum)) } else { control.setAutomatic(index: fan.index) } })) { Text("fans.mode.auto").tag(false) Text("fans.mode.manual").tag(true) } .pickerStyle(.segmented) .labelsHidden() if fan.isManual, fan.limits.isUsable { HStack { Text("\(Int(fan.limits.minimum))").font(.caption2).foregroundStyle(.secondary) Slider(value: $target, in: fan.limits.minimum...fan.limits.maximum, onEditingChanged: { active in editing = active // Erst beim Loslassen setzen: jede Bewegung des // Reglers wäre sonst ein SMC-Schreibvorgang. if !active { control.setTarget(index: fan.index, rpm: target) } }) Text("\(Int(fan.limits.maximum))").font(.caption2).foregroundStyle(.secondary) } Text("\(Int(target)) U/min").font(.caption).monospacedDigit() } // Ein erzwungener Rückfall wird erklärt, nicht verschwiegen. if let reason = fan.automaticReason, reason != "noRequest" { Label(explanation(for: reason), systemImage: "shield.lefthalf.filled") .font(.caption) .foregroundStyle(Onyx.Color.warning) .fixedSize(horizontal: false, vertical: true) } } // Die Karte polstert nur `OnyxFormRow`; dieser Block ist ein eigener. .padding(.horizontal, OnyxForm.rowPadding) .padding(.vertical, Onyx.Space.row) .onAppear { if !editing { target = max(fan.targetRPM, fan.limits.minimum) } } .onChange(of: fan.targetRPM) { _, new in if !editing { target = new } } } private var name: String { fan.index == 0 ? String(localized: "fans.left") : String(localized: "fans.right") } private func explanation(for reason: String) -> LocalizedStringKey { switch reason { case "watchdog": "fans.reason.watchdog" case "temperature": "fans.reason.temperature" case "noTemperature": "fans.reason.noTemperature" case "unknownLimits": "fans.reason.unknownLimits" default: "fans.reason.other" } } }