9f308fadd2
Rotate AI idle suggestions with optional remote packs, move history/dictionary onto self-sizing Home preview cards, harden clipboard capture/prompting, and simplify keyboard chrome by dropping most liquid-glass shadows.
712 lines
27 KiB
Swift
712 lines
27 KiB
Swift
// MacOnboardingView.swift
|
|
// OSGKeyboard · Mac
|
|
//
|
|
// A short first-run setup for the macOS app. It is intentionally separate
|
|
// from iOS onboarding because Mac needs Accessibility and local-model setup.
|
|
// The current local runtime defaults to Qwen3 MLX with Apple Speech fallback;
|
|
// Sherpa identifiers and install records are retained only for migration compatibility.
|
|
//
|
|
// Visual language mirrors the iOS onboarding: an ambient top gradient, a
|
|
// glowing hero icon, a large title block, and elongated capsule progress
|
|
// dots — all carried by whitespace and a single accent colour.
|
|
|
|
import AppKit
|
|
import AVFoundation
|
|
import SwiftUI
|
|
|
|
enum MacOnboardingState {
|
|
static let storageKey = "mac.hasCompletedOnboarding"
|
|
}
|
|
|
|
private enum MacOnboardingStep: Int, CaseIterable {
|
|
case welcome
|
|
case microphone
|
|
case accessibility
|
|
case engine
|
|
case cloudAPI
|
|
case localModel
|
|
|
|
var systemImage: String {
|
|
switch self {
|
|
case .welcome: return "sparkles"
|
|
case .microphone: return "mic.fill"
|
|
case .accessibility: return "accessibility"
|
|
case .engine: return "switch.2"
|
|
case .cloudAPI: return "key.fill"
|
|
case .localModel: return "arrow.down.circle.fill"
|
|
}
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
private final class MacOnboardingViewModel: ObservableObject {
|
|
@Published var step: MacOnboardingStep = .welcome
|
|
@Published var micStatus = AVCaptureDevice.authorizationStatus(for: .audio)
|
|
@Published var accessibilityTrusted = MacTextInsertionService.isAccessibilityTrusted
|
|
@Published var catalog: LocalASRCatalogDocument?
|
|
@Published var installProgress = LocalASRModelInstallProgress.idle
|
|
@Published var isInstalling = false
|
|
@Published var statusMessage = ""
|
|
|
|
private let manager = LocalASRModelManager.shared
|
|
private var progressPollTask: Task<Void, Never>?
|
|
|
|
deinit {
|
|
progressPollTask?.cancel()
|
|
}
|
|
|
|
var defaultModel: LocalASRModelDefinition? {
|
|
guard let catalog else { return nil }
|
|
return catalog.models.first { $0.id == catalog.defaultModelId }
|
|
}
|
|
|
|
var isDefaultModelInstalled: Bool {
|
|
guard let defaultModel else { return false }
|
|
return MacLocalASRService.isModelInstalled(defaultModel)
|
|
}
|
|
|
|
func reload() {
|
|
catalog = try? LocalASRModelCatalog.loadBundled()
|
|
micStatus = AVCaptureDevice.authorizationStatus(for: .audio)
|
|
accessibilityTrusted = MacTextInsertionService.isAccessibilityTrusted
|
|
}
|
|
|
|
func requestMicrophone() {
|
|
AVCaptureDevice.requestAccess(for: .audio) { [weak self] _ in
|
|
Task { @MainActor in
|
|
self?.micStatus = AVCaptureDevice.authorizationStatus(for: .audio)
|
|
}
|
|
}
|
|
}
|
|
|
|
func openAccessibilitySettings() {
|
|
_ = MacTextInsertionService.requestAccessibilityIfNeeded()
|
|
if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility") {
|
|
NSWorkspace.shared.open(url)
|
|
}
|
|
refreshAccessibilitySoon()
|
|
}
|
|
|
|
func refreshAccessibilitySoon() {
|
|
accessibilityTrusted = MacTextInsertionService.isAccessibilityTrusted
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in
|
|
self?.accessibilityTrusted = MacTextInsertionService.isAccessibilityTrusted
|
|
}
|
|
}
|
|
|
|
func installDefaultModel() {
|
|
guard let catalog, let model = defaultModel, !isInstalling else { return }
|
|
statusMessage = ""
|
|
isInstalling = true
|
|
startProgressPolling()
|
|
Task {
|
|
do {
|
|
try await manager.installModel(model, catalog: catalog)
|
|
installProgress = await manager.currentProgress()
|
|
selectInstalledModel(model.id, catalog: catalog)
|
|
statusMessage = MacL10n.string("mac.onboarding.model.done")
|
|
} catch {
|
|
installProgress = await manager.currentProgress()
|
|
statusMessage = error.localizedDescription
|
|
}
|
|
isInstalling = false
|
|
stopProgressPolling()
|
|
reload()
|
|
}
|
|
}
|
|
|
|
func progressLabel(language: AppUILanguage) -> String {
|
|
let phase: String
|
|
switch installProgress.phase {
|
|
case .idle: return installProgress.message
|
|
case .downloading: phase = MacL10n.string("mac.localASR.phase.downloading", language: language)
|
|
case .paused: phase = MacL10n.string("mac.localASR.phase.paused", language: language)
|
|
case .extracting: phase = MacL10n.string("mac.localASR.phase.extracting", language: language)
|
|
case .validating: phase = MacL10n.string("mac.localASR.phase.validating", language: language)
|
|
case .finalizing: phase = MacL10n.string("mac.localASR.phase.finalizing", language: language)
|
|
case .failed: phase = MacL10n.string("mac.localASR.phase.failed", language: language)
|
|
case .completed: phase = MacL10n.string("mac.localASR.phase.completed", language: language)
|
|
}
|
|
guard !installProgress.message.isEmpty else { return phase }
|
|
return "\(phase) · \(installProgress.message)"
|
|
}
|
|
|
|
private func selectInstalledModel(_ modelId: String, catalog: LocalASRCatalogDocument) {
|
|
MacLocalASRPreferences.selectedModelId = modelId
|
|
var manifest = LocalASRInstalledManifestIO.load(defaultModelId: catalog.defaultModelId)
|
|
manifest.selectedModelId = modelId
|
|
manifest.updatedAt = Date()
|
|
try? LocalASRInstalledManifestIO.save(manifest)
|
|
}
|
|
|
|
private func startProgressPolling() {
|
|
progressPollTask?.cancel()
|
|
progressPollTask = Task { [weak self] in
|
|
while !Task.isCancelled {
|
|
let current = await LocalASRModelManager.shared.currentProgress()
|
|
await MainActor.run { self?.installProgress = current }
|
|
try? await Task.sleep(nanoseconds: 120_000_000)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func stopProgressPolling() {
|
|
progressPollTask?.cancel()
|
|
progressPollTask = nil
|
|
}
|
|
}
|
|
|
|
// MARK: - Root
|
|
|
|
struct MacOnboardingView: View {
|
|
@ObservedObject var viewModel: MacDictationViewModel
|
|
@Binding var hasCompletedOnboarding: Bool
|
|
|
|
@Environment(\.themePalette) private var palette
|
|
@Environment(\.colorScheme) private var colorScheme
|
|
@StateObject private var model = MacOnboardingViewModel()
|
|
@State private var contentAppeared = false
|
|
|
|
private var lang: AppUILanguage { viewModel.config.uiLanguage }
|
|
|
|
private var visibleSteps: [MacOnboardingStep] {
|
|
if viewModel.config.engineMode == "cloud" {
|
|
return [.welcome, .microphone, .accessibility, .engine, .cloudAPI]
|
|
}
|
|
return [.welcome, .microphone, .accessibility, .engine, .localModel]
|
|
}
|
|
|
|
var body: some View {
|
|
GeometryReader { geo in
|
|
ZStack(alignment: .top) {
|
|
background(height: geo.size.height)
|
|
|
|
VStack(spacing: 0) {
|
|
Spacer(minLength: Spacing.xl)
|
|
|
|
hero
|
|
.id(model.step)
|
|
.transition(stepTransition)
|
|
|
|
Spacer(minLength: Spacing.lg)
|
|
|
|
progressDots
|
|
.padding(.bottom, Spacing.lg)
|
|
|
|
bottomBar
|
|
.padding(.horizontal, Spacing.xxxl)
|
|
.padding(.bottom, Spacing.xxl)
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
}
|
|
.frame(minWidth: MacMetrics.windowMinWidth, minHeight: MacMetrics.windowMinHeight)
|
|
.onAppear {
|
|
applyDefaults()
|
|
model.reload()
|
|
withAnimation(.spring(response: 0.7, dampingFraction: 0.85)) {
|
|
contentAppeared = true
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: Background
|
|
|
|
private func background(height: CGFloat) -> some View {
|
|
ZStack(alignment: .top) {
|
|
palette.background.ignoresSafeArea()
|
|
|
|
LinearGradient(
|
|
colors: [
|
|
palette.accent.opacity(0.12),
|
|
palette.accent.opacity(0.03),
|
|
palette.background.opacity(0)
|
|
],
|
|
startPoint: .top,
|
|
endPoint: .bottom
|
|
)
|
|
.frame(height: height * 0.42)
|
|
.ignoresSafeArea(edges: .top)
|
|
.allowsHitTesting(false)
|
|
}
|
|
}
|
|
|
|
// MARK: Hero + content
|
|
|
|
private var hero: some View {
|
|
VStack(spacing: Spacing.lg) {
|
|
heroIcon
|
|
|
|
VStack(spacing: Spacing.sm) {
|
|
Text(title)
|
|
.font(TypeStyle.title2)
|
|
.foregroundStyle(palette.textPrimary)
|
|
.multilineTextAlignment(.center)
|
|
|
|
Text(subtitle)
|
|
.font(TypeStyle.body)
|
|
.foregroundStyle(palette.textSecondary)
|
|
.multilineTextAlignment(.center)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
.frame(maxWidth: 460)
|
|
}
|
|
|
|
stepContent
|
|
.frame(maxWidth: 460)
|
|
.padding(.top, Spacing.xs)
|
|
}
|
|
.padding(.horizontal, Spacing.xxl)
|
|
.opacity(contentAppeared ? 1 : 0)
|
|
.offset(y: contentAppeared ? 0 : 12)
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var heroIcon: some View {
|
|
if model.step == .welcome {
|
|
ZStack {
|
|
SonicParticleField()
|
|
.frame(width: 300, height: 300)
|
|
|
|
Image("OSGBrandMark")
|
|
.renderingMode(.template)
|
|
.resizable()
|
|
.scaledToFit()
|
|
.frame(width: 128, height: 128)
|
|
.foregroundStyle(colorScheme == .dark ? Color.white : palette.accent)
|
|
.accessibilityLabel("OSGKeyboard")
|
|
.allowsHitTesting(false)
|
|
}
|
|
.frame(width: 300, height: 300)
|
|
} else {
|
|
ZStack {
|
|
Circle()
|
|
.fill(palette.accentGlow)
|
|
.frame(width: 116, height: 116)
|
|
.blur(radius: 26)
|
|
|
|
Circle()
|
|
.fill(palette.accentMuted)
|
|
.frame(width: 92, height: 92)
|
|
.overlay(Circle().stroke(palette.accent.opacity(0.25), lineWidth: 1))
|
|
|
|
Image(systemName: model.step.systemImage)
|
|
.font(.system(size: 40, weight: .semibold))
|
|
.foregroundStyle(palette.accent)
|
|
.symbolRenderingMode(.hierarchical)
|
|
}
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var stepContent: some View {
|
|
switch model.step {
|
|
case .welcome:
|
|
featureList
|
|
case .microphone:
|
|
permissionCard(
|
|
isGranted: model.micStatus == .authorized,
|
|
grantedText: MacL10n.string("mac.onboarding.microphone.granted", language: lang),
|
|
neededText: MacL10n.string("mac.onboarding.microphone.needed", language: lang)
|
|
)
|
|
case .accessibility:
|
|
permissionCard(
|
|
isGranted: model.accessibilityTrusted,
|
|
grantedText: MacL10n.string("mac.onboarding.accessibility.granted", language: lang),
|
|
neededText: MacL10n.string("mac.onboarding.accessibility.needed", language: lang)
|
|
)
|
|
case .engine:
|
|
enginePicker
|
|
case .cloudAPI:
|
|
cloudAPIFields
|
|
case .localModel:
|
|
localModelPanel
|
|
}
|
|
}
|
|
|
|
private var featureList: some View {
|
|
VStack(spacing: Spacing.sm) {
|
|
featureRow("lock.shield.fill", MacL10n.string("mac.onboarding.welcome.privacy", language: lang))
|
|
featureRow("option", MacL10n.string("mac.onboarding.welcome.hotkey", language: lang))
|
|
featureRow("cpu", MacL10n.string("mac.onboarding.welcome.local", language: lang))
|
|
}
|
|
}
|
|
|
|
private func featureRow(_ icon: String, _ text: String) -> some View {
|
|
HStack(spacing: Spacing.md) {
|
|
Image(systemName: icon)
|
|
.font(.system(size: 15, weight: .semibold))
|
|
.foregroundStyle(palette.accent)
|
|
.frame(width: 26, height: 26)
|
|
.background(palette.accentMuted, in: RoundedRectangle(cornerRadius: 8, style: .continuous))
|
|
|
|
Text(text)
|
|
.font(TypeStyle.footnote)
|
|
.foregroundStyle(palette.textPrimary)
|
|
.multilineTextAlignment(.leading)
|
|
|
|
Spacer(minLength: 0)
|
|
}
|
|
.padding(.vertical, Spacing.xs)
|
|
.padding(.horizontal, Spacing.md)
|
|
.frame(maxWidth: .infinity)
|
|
.background(cardShape.fill(palette.surface))
|
|
.overlay(cardShape.stroke(palette.divider, lineWidth: 0.5))
|
|
}
|
|
|
|
private func permissionCard(isGranted: Bool, grantedText: String, neededText: String) -> some View {
|
|
HStack(spacing: Spacing.sm) {
|
|
Image(systemName: isGranted ? "checkmark.seal.fill" : "exclamationmark.circle.fill")
|
|
.font(.system(size: 20, weight: .semibold))
|
|
.foregroundStyle(isGranted ? palette.accent : palette.warning)
|
|
|
|
Text(isGranted ? grantedText : neededText)
|
|
.font(TypeStyle.bodyEmph)
|
|
.foregroundStyle(palette.textPrimary)
|
|
|
|
Spacer(minLength: 0)
|
|
}
|
|
.padding(Spacing.md)
|
|
.frame(maxWidth: .infinity)
|
|
.background(cardShape.fill(palette.surface))
|
|
.overlay(cardShape.stroke((isGranted ? palette.accent : palette.warning).opacity(0.25), lineWidth: 1))
|
|
}
|
|
|
|
private var enginePicker: some View {
|
|
VStack(spacing: Spacing.sm) {
|
|
engineRow(
|
|
title: MacL10n.string("mac.settings.localEngine", language: lang),
|
|
subtitle: MacL10n.string("mac.onboarding.engine.localDesc", language: lang),
|
|
systemImage: "cpu",
|
|
selected: viewModel.config.engineMode == "local"
|
|
) { setEngine("local") }
|
|
|
|
engineRow(
|
|
title: MacL10n.string("mac.settings.cloudEngine", language: lang),
|
|
subtitle: MacL10n.string("mac.onboarding.engine.cloudDesc", language: lang),
|
|
systemImage: "cloud.fill",
|
|
selected: viewModel.config.engineMode == "cloud"
|
|
) { setEngine("cloud") }
|
|
}
|
|
}
|
|
|
|
private func engineRow(
|
|
title: String,
|
|
subtitle: String,
|
|
systemImage: String,
|
|
selected: Bool,
|
|
action: @escaping () -> Void
|
|
) -> some View {
|
|
Button(action: action) {
|
|
HStack(spacing: Spacing.md) {
|
|
Image(systemName: systemImage)
|
|
.font(.system(size: 18, weight: .medium))
|
|
.foregroundStyle(selected ? palette.accent : palette.textTertiary)
|
|
.frame(width: 30)
|
|
|
|
VStack(alignment: .leading, spacing: 3) {
|
|
Text(title)
|
|
.font(TypeStyle.bodyEmph)
|
|
.foregroundStyle(palette.textPrimary)
|
|
Text(subtitle)
|
|
.font(TypeStyle.caption)
|
|
.foregroundStyle(palette.textSecondary)
|
|
.multilineTextAlignment(.leading)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
}
|
|
|
|
Spacer(minLength: Spacing.sm)
|
|
|
|
Image(systemName: selected ? "checkmark.circle.fill" : "circle")
|
|
.font(.system(size: 18))
|
|
.foregroundStyle(selected ? palette.accent : palette.textTertiary.opacity(0.6))
|
|
}
|
|
.padding(Spacing.md)
|
|
.frame(maxWidth: .infinity)
|
|
.background(cardShape.fill(selected ? palette.accentMuted : palette.surface))
|
|
.overlay(cardShape.stroke(selected ? palette.accent.opacity(0.5) : palette.divider, lineWidth: selected ? 1 : 0.5))
|
|
.contentShape(Rectangle())
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
|
|
private var cloudAPIFields: some View {
|
|
VStack(alignment: .leading, spacing: Spacing.md) {
|
|
MacInlinePicker(
|
|
selection: providerBinding,
|
|
options: viewModel.selectableProviders.map {
|
|
MacInlinePickerOption(
|
|
value: $0.id,
|
|
label: ProviderDisplayName.name(for: $0.id, language: lang)
|
|
)
|
|
}
|
|
)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
|
|
SecureField(text: $viewModel.config.apiKey, prompt: Text(verbatim: "sk-...")) {
|
|
Text(MacL10n.string("mac.settings.apiKey", language: lang))
|
|
}
|
|
.labelsHidden()
|
|
.macFieldStyle()
|
|
|
|
TextField(text: $viewModel.config.model, prompt: Text(verbatim: "")) {
|
|
Text(MacL10n.string("mac.settings.model", language: lang))
|
|
}
|
|
.labelsHidden()
|
|
.macFieldStyle()
|
|
|
|
Label(MacL10n.string("mac.onboarding.cloud.skipHint", language: lang), systemImage: "info.circle")
|
|
.font(TypeStyle.caption)
|
|
.foregroundStyle(palette.textSecondary)
|
|
}
|
|
.padding(Spacing.md)
|
|
.frame(maxWidth: .infinity)
|
|
.background(cardShape.fill(palette.surface))
|
|
.overlay(cardShape.stroke(palette.divider, lineWidth: 0.5))
|
|
}
|
|
|
|
private var localModelPanel: some View {
|
|
VStack(alignment: .leading, spacing: Spacing.md) {
|
|
HStack(spacing: Spacing.sm) {
|
|
Image(systemName: model.isDefaultModelInstalled ? "checkmark.circle.fill" : "shippingbox.fill")
|
|
.font(.system(size: 20, weight: .medium))
|
|
.foregroundStyle(model.isDefaultModelInstalled ? palette.accent : palette.textTertiary)
|
|
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(model.defaultModel?.displayName ?? MacL10n.string("mac.localASR.catalogMissing", language: lang))
|
|
.font(TypeStyle.bodyEmph)
|
|
.foregroundStyle(palette.textPrimary)
|
|
Text(localModelSubtitle)
|
|
.font(TypeStyle.caption)
|
|
.foregroundStyle(palette.textSecondary)
|
|
}
|
|
|
|
Spacer(minLength: Spacing.sm)
|
|
|
|
if !model.isDefaultModelInstalled, !model.isInstalling {
|
|
Button(MacL10n.string("mac.onboarding.model.download", language: lang)) {
|
|
model.installDefaultModel()
|
|
}
|
|
.buttonStyle(.borderedProminent)
|
|
.tint(palette.accent)
|
|
.disabled(model.defaultModel == nil)
|
|
}
|
|
}
|
|
|
|
if model.isInstalling || model.installProgress.phase != .idle {
|
|
ProgressView(value: model.installProgress.fraction)
|
|
.tint(palette.accent)
|
|
Text(model.progressLabel(language: lang))
|
|
.font(TypeStyle.caption)
|
|
.foregroundStyle(palette.textSecondary)
|
|
}
|
|
|
|
if !model.statusMessage.isEmpty {
|
|
Text(model.statusMessage)
|
|
.font(TypeStyle.caption)
|
|
.foregroundStyle(model.isDefaultModelInstalled ? palette.accent : palette.warning)
|
|
}
|
|
|
|
Label(MacL10n.string("mac.onboarding.model.skipHint", language: lang), systemImage: "info.circle")
|
|
.font(TypeStyle.caption)
|
|
.foregroundStyle(palette.textSecondary)
|
|
}
|
|
.padding(Spacing.md)
|
|
.frame(maxWidth: .infinity)
|
|
.background(cardShape.fill(palette.surface))
|
|
.overlay(cardShape.stroke(palette.divider, lineWidth: 0.5))
|
|
}
|
|
|
|
// MARK: Progress dots
|
|
|
|
private var progressDots: some View {
|
|
HStack(spacing: 6) {
|
|
ForEach(Array(visibleSteps.enumerated()), id: \.offset) { index, _ in
|
|
Capsule()
|
|
.fill(index == currentStepIndex ? palette.accent : palette.textTertiary.opacity(0.28))
|
|
.frame(width: index == currentStepIndex ? 22 : 6, height: 6)
|
|
}
|
|
}
|
|
.animation(Motion.quick, value: currentStepIndex)
|
|
}
|
|
|
|
// MARK: Bottom bar
|
|
|
|
private var bottomBar: some View {
|
|
HStack(spacing: Spacing.sm) {
|
|
if canGoBack {
|
|
secondaryButton(MacL10n.string("mac.onboarding.back", language: lang)) { goBack() }
|
|
}
|
|
|
|
if canSkipCurrentStep {
|
|
secondaryButton(MacL10n.string("mac.onboarding.skipForNow", language: lang)) {
|
|
if isLastStep { finish() } else { goForward() }
|
|
}
|
|
}
|
|
|
|
Spacer(minLength: 0)
|
|
|
|
primaryButton(primaryButtonTitle, disabled: model.isInstalling && model.step == .localModel) {
|
|
primaryAction()
|
|
}
|
|
}
|
|
.frame(maxWidth: 520)
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
|
|
private func primaryButton(_ titleText: String, disabled: Bool, action: @escaping () -> Void) -> some View {
|
|
Button(action: action) {
|
|
Text(titleText)
|
|
.font(TypeStyle.headline)
|
|
.foregroundStyle(disabled ? palette.textSecondary : palette.textOnAccent)
|
|
.padding(.horizontal, Spacing.xxl)
|
|
.frame(minWidth: 150, minHeight: 44)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
|
|
.fill(disabled ? palette.surfaceElevated : palette.accent)
|
|
)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.disabled(disabled)
|
|
}
|
|
|
|
private func secondaryButton(_ titleText: String, action: @escaping () -> Void) -> some View {
|
|
Button(action: action) {
|
|
Text(titleText)
|
|
.font(TypeStyle.bodyEmph)
|
|
.foregroundStyle(palette.textSecondary)
|
|
.padding(.horizontal, Spacing.lg)
|
|
.frame(minHeight: 44)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
|
|
.stroke(palette.dividerStrong, lineWidth: 0.5)
|
|
)
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
|
|
// MARK: Copy
|
|
|
|
private var title: String {
|
|
switch model.step {
|
|
case .welcome: return MacL10n.string("mac.onboarding.welcome.title", language: lang)
|
|
case .microphone: return MacL10n.string("mac.onboarding.microphone.title", language: lang)
|
|
case .accessibility: return MacL10n.string("mac.onboarding.accessibility.title", language: lang)
|
|
case .engine: return MacL10n.string("mac.onboarding.engine.title", language: lang)
|
|
case .cloudAPI: return MacL10n.string("mac.onboarding.cloud.title", language: lang)
|
|
case .localModel: return MacL10n.string("mac.onboarding.model.title", language: lang)
|
|
}
|
|
}
|
|
|
|
private var subtitle: String {
|
|
switch model.step {
|
|
case .welcome: return MacL10n.string("mac.onboarding.welcome.subtitle", language: lang)
|
|
case .microphone: return MacL10n.string("mac.onboarding.microphone.subtitle", language: lang)
|
|
case .accessibility: return MacL10n.string("mac.onboarding.accessibility.subtitle", language: lang)
|
|
case .engine: return MacL10n.string("mac.onboarding.engine.subtitle", language: lang)
|
|
case .cloudAPI: return MacL10n.string("mac.onboarding.cloud.subtitle", language: lang)
|
|
case .localModel: return MacL10n.string("mac.onboarding.model.subtitle", language: lang)
|
|
}
|
|
}
|
|
|
|
private var primaryButtonTitle: String {
|
|
switch model.step {
|
|
case .microphone where model.micStatus != .authorized:
|
|
return MacL10n.string("mac.onboarding.microphone.allow", language: lang)
|
|
case .accessibility where !model.accessibilityTrusted:
|
|
return MacL10n.string("mac.onboarding.accessibility.open", language: lang)
|
|
case .cloudAPI:
|
|
return MacL10n.string("mac.onboarding.finish", language: lang)
|
|
case .localModel:
|
|
return MacL10n.string(model.isDefaultModelInstalled ? "mac.onboarding.finish" : "mac.onboarding.skipForNow", language: lang)
|
|
default:
|
|
return isLastStep ? MacL10n.string("mac.onboarding.finish", language: lang) : MacL10n.string("mac.onboarding.next", language: lang)
|
|
}
|
|
}
|
|
|
|
private var localModelSubtitle: String {
|
|
if model.isDefaultModelInstalled {
|
|
return MacL10n.string("mac.localASR.installed", language: lang)
|
|
}
|
|
guard let model = model.defaultModel else { return "" }
|
|
return ByteCountFormatter.string(fromByteCount: Int64(model.sizeBytes), countStyle: .file)
|
|
}
|
|
|
|
private var providerBinding: Binding<String> {
|
|
Binding(
|
|
get: { viewModel.config.providerId },
|
|
set: { newId in
|
|
guard let provider = viewModel.selectableProviders.first(where: { $0.id == newId }) else { return }
|
|
viewModel.selectProvider(provider)
|
|
}
|
|
)
|
|
}
|
|
|
|
// MARK: Derived
|
|
|
|
private var cardShape: RoundedRectangle {
|
|
RoundedRectangle(cornerRadius: Radius.medium, style: .continuous)
|
|
}
|
|
|
|
private var stepTransition: AnyTransition {
|
|
.asymmetric(
|
|
insertion: .opacity.combined(with: .offset(y: 10)),
|
|
removal: .opacity.combined(with: .offset(y: -10))
|
|
)
|
|
}
|
|
|
|
private var currentStepIndex: Int {
|
|
visibleSteps.firstIndex(of: model.step) ?? 0
|
|
}
|
|
|
|
private var canGoBack: Bool {
|
|
currentStepIndex > 0 && !model.isInstalling
|
|
}
|
|
|
|
private var canSkipCurrentStep: Bool {
|
|
model.step != .welcome && !model.isInstalling
|
|
}
|
|
|
|
private var isLastStep: Bool {
|
|
currentStepIndex == visibleSteps.count - 1
|
|
}
|
|
|
|
// MARK: Actions
|
|
|
|
private func setEngine(_ mode: String) {
|
|
withAnimation(Motion.quick) { viewModel.setEngineMode(mode) }
|
|
}
|
|
|
|
private func primaryAction() {
|
|
switch model.step {
|
|
case .microphone where model.micStatus != .authorized:
|
|
model.requestMicrophone()
|
|
case .accessibility where !model.accessibilityTrusted:
|
|
model.openAccessibilitySettings()
|
|
default:
|
|
if isLastStep { finish() } else { goForward() }
|
|
}
|
|
}
|
|
|
|
private func goForward() {
|
|
let nextIndex = min(currentStepIndex + 1, visibleSteps.count - 1)
|
|
withAnimation(Motion.soft) { model.step = visibleSteps[nextIndex] }
|
|
}
|
|
|
|
private func goBack() {
|
|
let previousIndex = max(currentStepIndex - 1, 0)
|
|
withAnimation(Motion.soft) { model.step = visibleSteps[previousIndex] }
|
|
}
|
|
|
|
private func finish() {
|
|
viewModel.selectedSection = .dashboard
|
|
hasCompletedOnboarding = true
|
|
}
|
|
|
|
private func applyDefaults() {
|
|
guard !hasCompletedOnboarding else { return }
|
|
if viewModel.config.apiKey.isEmpty, viewModel.config.engineMode == "cloud" {
|
|
viewModel.setEngineMode("local")
|
|
}
|
|
}
|
|
}
|