Files
onyx/Onyx/OnboardingView.swift
Scarriffle 2aa60eedf9 Farben einstellbar — und erst mal überhaupt eine Farbe
Das Grün, das überall auftauchte, war die System-Akzentfarbe: `.tint` und
`.accentColor` werden ohne eigene Angabe dorthin durchgereicht, und das
betraf alle Popovers und das ganze Einstellungsfenster. Die Notch-Widgets
dagegen benutzten Onyx' eigenes Blau. Zwei Farben, ungewollt.

Jetzt eine, und die ist einstellbar. Ein neuer Reiter „Farben": Akzentfarbe
mit sechs Vorschlägen und einem Knopf, der die Systemfarbe übernimmt, dazu
das Paar für Herunter- und Hochladen samt Tauschknopf.

Die Signalfarben bleiben fest und stehen nur zur Ansicht dort. Gelb heißt
Warnung, Rot heißt kritisch, Grün heißt in Ordnung — bei Temperatur, Akku,
Lüfter und Fehlern. Wer sie umstellen kann, kann ihre Bedeutung zerstören;
ein rotes „alles gut" liest niemand richtig. Sichtbar sind sie trotzdem,
sonst sucht man die Warnfarbe im Reiter und findet sie nirgends.

Umgesetzt über die Tokens statt über vierzig Fundstellen: `Onyx.Color.accent`
ist keine Konstante mehr, sondern liest bei jedem Zugriff aus `OnyxTheme`.
Damit merkt SwiftUI die Abhängigkeit und zeichnet neu, sobald die Farbe sich
ändert. Dazu ein `.tint` an den vier Wurzeln — Panel, Popovers, Einstellungen,
Einrichtung —, damit auch Haken, Regler und Auswahlfelder mitziehen.

Nebenbei die Beschriftung: „Je Programm" heißt jetzt „Programme". Großgesetzt
las sich das als englisches „J-E".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 23:24:40 +02:00

208 lines
7.0 KiB
Swift

import SwiftUI
import OnyxDesign
import AppKit
import CalendarProvider
import WeatherProvider
import AudioProvider
/// Der erste Start.
///
/// Onyx braucht vier Dinge vom System, und jedes für etwas anderes. Sie alle
/// beim Start hintereinander abzufragen wäre der übliche Weg und der falsche:
/// wer vier Dialoge sieht, ohne zu wissen wofür, klickt viermal Nicht
/// erlauben" und macOS fragt danach nie wieder. Deshalb steht hier zu jedem
/// Punkt, wofür er gut ist, und jeder wird einzeln und auf Klick angefragt.
///
/// Nichts davon ist Pflicht. Ohne Kalenderzugriff fehlt ein Widget, nicht die
/// App.
struct OnboardingView: View {
let calendarModel: CalendarModel
let weatherModel: WeatherModel
let mixer: AudioMixer
let fanControl: FanControl
let launchAtLogin: LaunchAtLogin
let onFinish: () -> Void
@State private var calendarGranted = false
var body: some View {
VStack(alignment: .leading, spacing: 0) {
header
Divider()
ScrollView {
VStack(alignment: .leading, spacing: 0) {
OnboardingRow(
symbol: "calendar",
title: "onboarding.calendar",
detail: "onboarding.calendar.why",
state: calendarState) {
calendarGranted = await calendarModel.requestAccessNow()
} openSettings: {
open("Privacy_Calendars")
}
OnboardingRow(
symbol: "location",
title: "onboarding.location",
detail: "onboarding.location.why",
state: locationState) {
weatherModel.requestLocationAuthorization()
} openSettings: {
open("Privacy_LocationServices")
}
OnboardingRow(
symbol: "slider.horizontal.3",
title: "onboarding.audio",
detail: "onboarding.audio.why",
state: mixer.isEnabled ? .granted : .undetermined) {
mixer.enable()
} openSettings: {
open("Privacy_Microphone")
}
OnboardingRow(
symbol: "fan",
title: "onboarding.helper",
detail: "onboarding.helper.why",
state: helperState) {
fanControl.install()
} openSettings: {
fanControl.openApprovalSettings()
}
}
.padding(.horizontal, 22)
.padding(.vertical, 6)
}
Divider()
footer
}
.frame(width: 520, height: 520)
.task {
calendarGranted = calendarModel.hasAccess
fanControl.refreshInstallState()
launchAtLogin.refresh()
}
}
// MARK: - Kopf und Fuß
private var header: some View {
HStack(spacing: 14) {
Image(nsImage: NSApp.applicationIconImage)
.resizable().frame(width: 52, height: 52)
VStack(alignment: .leading, spacing: 3) {
Text("onboarding.title").font(.title2).fontWeight(.semibold)
Text("onboarding.subtitle")
.font(.callout).foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
Spacer(minLength: 0)
}
.padding(22)
}
private var footer: some View {
HStack {
Toggle(isOn: Binding(
get: { launchAtLogin.isEnabled },
set: { _ = launchAtLogin.set($0) })) {
Text("onboarding.launchAtLogin")
}
.toggleStyle(.checkbox)
.disabled(launchAtLogin.needsSystemSettings)
if launchAtLogin.needsSystemSettings {
Button("onboarding.openSettings") { launchAtLogin.openSystemSettings() }
.buttonStyle(.link)
}
Spacer()
Button("onboarding.done", action: onFinish)
.keyboardShortcut(.defaultAction)
}
.padding(22)
}
// MARK: - Zustände
private var calendarState: OnboardingRow.State {
if calendarGranted { return .granted }
return calendarModel.authorization == .denied ? .denied : .undetermined
}
private var locationState: OnboardingRow.State {
switch weatherModel.locationAuthorization {
case .granted: .granted
case .denied: .denied
case .undetermined: .undetermined
}
}
private var helperState: OnboardingRow.State {
switch fanControl.installState {
case .installed: .granted
// Nicht `.denied`: hier hilft tatsächlich der Gang in die
// Systemeinstellungen, und der Knopf soll das auch sagen.
case .requiresApproval: .denied
case .notInstalled, .failed: .undetermined
}
}
private func open(_ pane: String) {
guard let url = URL(
string: "x-apple.systempreferences:com.apple.preference.security?\(pane)")
else { return }
NSWorkspace.shared.open(url)
}
}
/// Ein Punkt im Onboarding: wofür, wie es steht, und was von hier weiterführt.
private struct OnboardingRow: View {
enum State { case granted, denied, undetermined }
let symbol: String
let title: LocalizedStringKey
let detail: LocalizedStringKey
let state: State
let request: () async -> Void
let openSettings: () -> Void
var body: some View {
HStack(alignment: .top, spacing: 14) {
Image(systemName: symbol)
.font(.system(size: 15))
.foregroundStyle(state == .granted ? Onyx.Color.positive : .secondary)
.frame(width: 22, height: 22)
VStack(alignment: .leading, spacing: 2) {
Text(title).fontWeight(.medium)
Text(detail)
.font(.callout).foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
Spacer(minLength: 8)
switch state {
case .granted:
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(Onyx.Color.positive)
.font(.system(size: 16))
case .undetermined:
Button("onboarding.allow") { Task { await request() } }
case .denied:
// Nach einer Ablehnung fragt macOS nie wieder ein
// Erlauben-Knopf wäre hier eine Lüge.
Button("onboarding.openSettings", action: openSettings)
}
}
.padding(.vertical, 12)
.overlay(alignment: .bottom) { Divider() }
}
}