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>
208 lines
7.0 KiB
Swift
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() }
|
|
}
|
|
}
|