Files
onyx/Onyx/OnboardingView.swift
Scarriffle c94c6166f5 Der Farbwechsel kommt jetzt auch an
Farbton und Symbolfarbe standen beim **Erzeugen** der Fenster, also außerhalb
jeder View. Damit wird der Wert genau einmal gelesen und SwiftUI erfährt nie,
dass er sich geändert hat: die Reiter, Knöpfe und Symbole blieben in der
Farbe stehen, die beim Öffnen galt, während der Farbwähler daneben schon die
neue zeigte.

Jetzt stehen beide im Rumpf der jeweiligen View — Panel, Einstellungen,
Einrichtung. Dort registriert SwiftUI den Zugriff und zeichnet neu.

Bei den Popovers blieb es beim Erzeugen: die entstehen bei jedem Öffnen neu,
lesen also ohnehin den aktuellen Wert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 14:18:06 +02:00

211 lines
7.1 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)
// Im Rumpf gelesen, damit ein Farbwechsel ankommt.
.tint(Onyx.Color.accent)
.labelStyle(.onyx)
.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() }
}
}