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