Files
OSGKeyboard/OSGKeyboard/Views/OnboardingView.swift
T
rockyandClaude Opus 5 0023741455 feat(app): add crash visibility, memory relief, and clipboard reply work
Squashes the working tree that had accumulated on cloud-agent. Two
strands of work, kept in one commit at the maintainer's request.

Crash and stability (this session):

- Subscribe to MetricKit so crash, hang, CPU, and disk-write diagnostics
  are recorded on device. Keyboard-extension memory kills are the most
  likely way a user loses the keyboard mid-sentence and iOS does not
  classify them as crashes, so they never reach App Store Connect.
- Upload those reports to POST /v1/analytics/diagnostics from internal
  builds only (local Debug and TestFlight), reusing the analytics
  installation identifier and skipping entirely when analytics are off.
  App Store builds keep everything on device.
- Shed the Chinese engine, English lexicon, and in-flight pipeline work
  at the 40 MiB and 48 MiB budget thresholds instead of waiting for a
  system memory warning that jetsam often preempts.
- Install audio taps with the input node's live format through one
  shared adaptive resampler, so an audio-route change mid-recording no
  longer raises an uncatchable format-mismatch exception. Covers the
  dictation preview and the Mac recorder, which still used the pattern
  the Flow capture path had already moved away from.
- Harden the librime bridge: never pass a nil string across a nonnull
  boundary when a user dictionary holds malformed UTF-8, null-check the
  engine API vector, and distrust a candidate count without its array.
- Guard the expanded candidate grid against a list that shrinks while
  the collection view is still laying out against the previous count.
- Resolve localized text without the App Group so the screen shown when
  that container is missing renders its message instead of crashing on
  launch, which is the one situation it exists to handle.
- Gate CI on the App Group and Keychain wiring that the keyboard
  extension hard-depends on, with a self-test proving the check fails.

Clipboard, reply, and semantics (pre-existing working tree):

- Unify the reply center, refresh keyboard AI features, and add native
  event and reminder exporters.
- Expand clipboard semantic routing with authored corpora and a
  product-blind holdout, plus the scripts that build and validate them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 17:31:29 -07:00

862 lines
31 KiB
Swift

// OnboardingView.swift
// OSGKeyboard · Main App
//
// Six-step onboarding:
// 1) Welcome
// 2) Microphone permission
// 3) Speech recognition permission
// 4) Enable keyboard + Allow Full Access
// 5) Engine / ASR setup
// 6) Polish LLM setup
import OSGKeyboardShared
import SwiftUI
import UIKit
private enum OnboardingPage: Int, CaseIterable {
case welcome = 0
case microphone
case speech
case keyboard
case api
case polish
static let count = 6
}
struct OnboardingView: View {
@Environment(\.themePalette) private var palette: ThemePalette
@Environment(\.scenePhase) private var scenePhase
@ObservedObject var config: ProviderConfig
@State private var micStatus = AppPermissions.micStatus
@State private var speechStatus = AppPermissions.speechStatus
@State private var keyboardReady = KeyboardSetupBridge.isReadyForOnboardingSkip
// No on-device model downloads remain, so the API setup
// page no longer needs a ModelManager / pendingDownload binding.
private var currentPage: OnboardingPage {
OnboardingPage(rawValue: config.onboardingPage) ?? .welcome
}
var body: some View {
GeometryReader { geo in
let gradientHeight = geo.size.height * 0.28 + geo.safeAreaInsets.top
ZStack(alignment: .top) {
palette.background.ignoresSafeArea()
VStack(spacing: 0) {
progressHeader
.padding(.top, Spacing.md)
.padding(.bottom, Spacing.sm)
Group {
switch currentPage {
case .welcome:
WelcomePage()
case .microphone:
MicPermissionPage(status: $micStatus, showsPreface: speechStatus != .granted)
case .speech:
SpeechPermissionPage(status: $speechStatus)
case .keyboard:
EnableKeyboardPage()
case .api:
APISetupPage(config: config)
case .polish:
PolishSetupPage(config: config)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.transition(.opacity.combined(with: .move(edge: .trailing)))
pageDots
.padding(.bottom, Spacing.md)
bottomBar
.padding(.horizontal, Spacing.lg)
.padding(.bottom, Spacing.lg)
}
onboardingHeaderGradient(height: gradientHeight)
.ignoresSafeArea(edges: .top)
.allowsHitTesting(false)
}
}
.onAppear {
if config.onboardingPage < 0 || config.onboardingPage >= OnboardingPage.count {
config.onboardingPage = 0
}
applyOnboardingDefaultsIfNeeded()
refreshPermissionStatuses()
snapToVisiblePageIfNeeded()
// No on-device ASR weights need warming — iOS
// `SpeechAnalyzer` ships with iOS 26 and is always ready.
}
.onChange(of: scenePhase) { _, phase in
if phase == .active { refreshPermissionStatuses() }
}
.onChange(of: micStatus) { previous, current in
guard currentPage == .microphone, current == .granted, previous != .granted else { return }
advanceAfterPermissionGrant()
}
.onChange(of: speechStatus) { previous, current in
guard currentPage == .speech, current == .granted, previous != .granted else { return }
advanceAfterPermissionGrant()
}
}
// MARK: - Navigation helpers
private func applyOnboardingDefaultsIfNeeded() {
guard !config.hasCompletedOnboarding, config.onboardingPage == 0 else { return }
// First-time users with no API key: default to local for a faster path.
// iOS SpeechAnalyzer is the only on-device ASR path.
if config.apiKey.isEmpty, config.engineMode == "cloud" {
config.engineMode = "local"
}
}
/// With iOS `SpeechAnalyzer` as the only local backend,
/// the "local engine ready" check is always true — there is nothing
/// for the user to download. Kept as a derived property so the
/// existing call sites (which feed the Done button state) compile
/// unchanged.
private var localModelNeedsAttention: Bool { false }
private func shouldShowPage(_ page: OnboardingPage) -> Bool {
switch page {
case .microphone: return micStatus != .granted
case .speech: return speechStatus != .granted
case .keyboard: return !keyboardReady
default: return true
}
}
private func nextVisiblePage(after page: Int) -> Int? {
guard page + 1 < OnboardingPage.count else { return nil }
for index in (page + 1)..<OnboardingPage.count {
guard let candidate = OnboardingPage(rawValue: index) else { continue }
if shouldShowPage(candidate) { return index }
}
return nil
}
private func previousVisiblePage(before page: Int) -> Int? {
guard page > 0 else { return nil }
for index in stride(from: page - 1, through: 0, by: -1) {
guard let candidate = OnboardingPage(rawValue: index) else { continue }
if shouldShowPage(candidate) { return index }
}
return nil
}
private func snapToVisiblePageIfNeeded() {
guard let page = OnboardingPage(rawValue: config.onboardingPage) else { return }
guard !shouldShowPage(page) else { return }
if let next = nextVisiblePage(after: config.onboardingPage) {
config.onboardingPage = next
} else if let previous = previousVisiblePage(before: config.onboardingPage) {
config.onboardingPage = previous
}
}
private func advanceAfterPermissionGrant() {
Task { @MainActor in
try? await Task.sleep(for: .milliseconds(400))
guard currentPage == .microphone || currentPage == .speech else { return }
refreshPermissionStatuses()
withAnimation(Motion.soft) {
if let next = nextVisiblePage(after: config.onboardingPage) {
config.onboardingPage = next
}
}
}
}
private func advancePage() {
refreshPermissionStatuses()
// Routing out of onboarding must NOT run inside an animation
// transaction. Animating OnboardingView → MainTabView (plus a nested
// onboardingPage reset and Flow activateOnForeground) can leave the
// last page frozen even when hasCompletedOnboarding is already true.
if isLastPage || nextVisiblePage(after: config.onboardingPage) == nil {
var transaction = Transaction()
transaction.disablesAnimations = true
withTransaction(transaction) {
config.hasCompletedOnboarding = true
}
} else if let next = nextVisiblePage(after: config.onboardingPage) {
withAnimation(Motion.soft) {
config.onboardingPage = next
}
}
}
private func onboardingHeaderGradient(height: CGFloat) -> some View {
LinearGradient(
colors: localModelNeedsAttention
? [
palette.warning.opacity(0.32),
palette.warning.opacity(0.12),
palette.background.opacity(0)
]
: [
palette.textTertiary.opacity(0.14),
palette.textTertiary.opacity(0.05),
palette.background.opacity(0)
],
startPoint: .top,
endPoint: .bottom
)
.frame(maxWidth: .infinity)
.frame(height: height)
.animation(Motion.soft, value: localModelNeedsAttention)
}
private func refreshPermissionStatuses() {
micStatus = AppPermissions.micStatus
speechStatus = AppPermissions.speechStatus
keyboardReady = KeyboardSetupBridge.isReadyForOnboardingSkip
}
private var progressHeader: some View {
Text(
String(
format: AppL10n.string("onboarding.progress"),
config.onboardingPage + 1,
OnboardingPage.count
)
)
.font(TypeStyle.caption)
.foregroundStyle(palette.textTertiary)
.frame(maxWidth: .infinity)
}
private var pageDots: some View {
HStack(spacing: 6) {
ForEach(0..<OnboardingPage.count, id: \.self) { pageIndex in
Capsule()
.fill(
pageIndex == config.onboardingPage
? palette.accent
: palette.textTertiary.opacity(0.28)
)
.frame(width: pageIndex == config.onboardingPage ? 18 : 6, height: 6)
.animation(Motion.quick, value: config.onboardingPage)
}
}
}
private var isLastPage: Bool { config.onboardingPage == OnboardingPage.polish.rawValue }
private var canAdvance: Bool {
switch currentPage {
case .welcome, .keyboard, .api:
return !isLastPage || onboardingCompleteReady
case .polish:
return onboardingCompleteReady
case .microphone:
return micStatus != .undetermined
case .speech:
return speechStatus != .undetermined
}
}
private var onboardingCompleteReady: Bool {
// Local ASR needs no cloud key; polish key is guided on Home / keyboard
// rather than blocking onboarding completion.
// Cloud engine: ASR (step 5) + polish LLM (step 6) must both be ready.
if config.isLocalEngine { return true }
return config.isConfigured
}
private var primaryActionTitle: String {
if isLastPage {
return AppL10n.string("common.done")
}
switch currentPage {
case .microphone where micStatus == .granted,
.speech where speechStatus == .granted:
return AppL10n.string("common.continue")
default:
return AppL10n.string("common.next")
}
}
@ViewBuilder
private var bottomBar: some View {
HStack(spacing: Spacing.sm) {
if let previous = previousVisiblePage(before: config.onboardingPage) {
Button {
withAnimation(Motion.soft) { config.onboardingPage = previous }
} label: {
Text(AppL10n.string("common.back"))
.font(TypeStyle.headline)
.frame(maxWidth: .infinity, minHeight: 50)
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
.foregroundStyle(palette.textPrimary)
}
.buttonStyle(.plain)
}
Button { advancePage() } label: {
Text(primaryActionTitle)
.font(TypeStyle.headline)
.frame(maxWidth: .infinity, minHeight: 50)
.background(
canAdvance ? palette.accent : palette.surfaceElevated,
in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
)
.foregroundStyle(canAdvance ? palette.textOnAccent : palette.textSecondary)
}
.buttonStyle(.plain)
.disabled(!canAdvance)
}
}
}
// MARK: - Shared onboarding chrome
private struct OnboardingHeroIcon: View {
@Environment(\.themePalette) private var palette: ThemePalette
let systemName: String
var circleSize: CGFloat = 88
var iconSize: CGFloat = 36
var body: some View {
ZStack {
Circle()
.fill(palette.accentMuted)
.frame(width: circleSize, height: circleSize)
Image(systemName: systemName)
.font(.system(size: iconSize, weight: .medium))
.foregroundStyle(palette.accent)
}
}
}
private enum OnboardingLayoutMetrics {
/// Matches welcome page: hero → title block, and title block → next block.
static let heroTextGap: CGFloat = Spacing.hero
}
/// Title + subtitle styling aligned with the welcome page.
private struct OnboardingTitleBlock: View {
@Environment(\.themePalette) private var palette: ThemePalette
let title: String
var subtitle: String?
var secondarySubtitle: String?
var body: some View {
VStack(spacing: Spacing.xs) {
Text(AppL10n.string(title))
.font(TypeStyle.title3)
.foregroundStyle(palette.textPrimary)
if let subtitle {
Text(AppL10n.string(subtitle))
.font(TypeStyle.body)
.foregroundStyle(palette.textSecondary)
}
if let secondarySubtitle {
Text(AppL10n.string(secondarySubtitle))
.font(TypeStyle.body)
.foregroundStyle(palette.textSecondary)
}
}
.multilineTextAlignment(.center)
.padding(.horizontal, Spacing.xl)
}
}
// MARK: - Welcome
private struct WelcomePage: View {
@State private var logoAppeared = false
var body: some View {
ZStack {
SonicParticleField(focalPoint: UnitPoint(x: 0.5, y: 0.42))
.frame(maxWidth: .infinity, maxHeight: .infinity)
.allowsHitTesting(true)
welcomeContent
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
private var welcomeContent: some View {
VStack(spacing: 0) {
Spacer()
Image("osglogo")
.resizable()
.scaledToFit()
.frame(maxWidth: 168, maxHeight: 48)
.opacity(logoAppeared ? 1 : 0)
.offset(y: logoAppeared ? 0 : 14)
.scaleEffect(logoAppeared ? 1 : 0.9)
.accessibilityHidden(true)
.onAppear {
withAnimation(.spring(response: 0.75, dampingFraction: 0.82)) {
logoAppeared = true
}
}
OnboardingTitleBlock(
title: "onboarding.welcome.tagline"
)
.opacity(logoAppeared ? 1 : 0)
.offset(y: logoAppeared ? 0 : 10)
.padding(.top, OnboardingLayoutMetrics.heroTextGap)
.animation(.spring(response: 0.8, dampingFraction: 0.85).delay(0.12), value: logoAppeared)
Spacer()
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.allowsHitTesting(false)
}
}
// MARK: - Permission pages
private struct MicPermissionPage: View {
@Environment(\.themePalette) private var palette: ThemePalette
@Binding var status: AppPermissions.MicStatus
var showsPreface: Bool = false
@State private var isRequesting = false
var body: some View {
PermissionPageLayout(
icon: "mic.fill",
title: "onboarding.permission.mic.title",
detail: "onboarding.permission.mic.body",
preface: showsPreface ? "onboarding.permission.preface" : nil,
status: statusLabel,
statusColor: statusColor,
primaryTitle: primaryButtonTitle,
primaryDisabled: isRequesting || (status == .granted),
onPrimary: { Task { await request() } },
deniedHint: status == .denied ? "onboarding.permission.mic.deniedHint" : nil
)
.onAppear { status = AppPermissions.micStatus }
.onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in
status = AppPermissions.micStatus
}
}
private var statusLabel: String {
switch status {
case .undetermined: return "onboarding.permission.status.undetermined"
case .granted: return "onboarding.permission.status.granted"
case .denied: return "onboarding.permission.status.denied"
}
}
private var statusColor: Color {
switch status {
case .granted: return palette.accent
case .denied: return palette.warning
case .undetermined: return palette.textTertiary
}
}
private var primaryButtonTitle: String {
switch status {
case .granted: return "onboarding.permission.status.granted"
case .denied: return "onboarding.permission.openSettings"
// App Store 5.1.1(iv): pre-permission CTA must not say "Allow".
case .undetermined: return "common.next"
}
}
private func request() async {
if status == .denied {
AppPermissions.openSystemSettings()
return
}
isRequesting = true
_ = await AppPermissions.requestMicrophone()
status = AppPermissions.micStatus
isRequesting = false
}
}
private struct SpeechPermissionPage: View {
@Environment(\.themePalette) private var palette: ThemePalette
@Binding var status: AppPermissions.SpeechStatus
@State private var isRequesting = false
var body: some View {
PermissionPageLayout(
icon: "waveform.badge.mic",
title: "onboarding.permission.speech.title",
detail: "onboarding.permission.speech.body",
status: statusLabel,
statusColor: statusColor,
primaryTitle: primaryButtonTitle,
primaryDisabled: isRequesting || status == .granted,
onPrimary: { Task { await request() } },
deniedHint: speechDenied ? "onboarding.permission.speech.deniedHint" : nil
)
.onAppear { status = AppPermissions.speechStatus }
.onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in
status = AppPermissions.speechStatus
}
}
private var speechDenied: Bool {
switch status {
case .denied, .restricted: return true
default: return false
}
}
private var statusLabel: String {
switch status {
case .undetermined: return "onboarding.permission.status.undetermined"
case .granted: return "onboarding.permission.status.granted"
case .denied, .restricted: return "onboarding.permission.status.denied"
}
}
private var statusColor: Color {
switch status {
case .granted: return palette.accent
case .denied, .restricted: return palette.warning
case .undetermined: return palette.textTertiary
}
}
private var primaryButtonTitle: String {
switch status {
case .granted: return "onboarding.permission.status.granted"
case .denied, .restricted: return "onboarding.permission.openSettings"
// App Store 5.1.1(iv): pre-permission CTA must not say "Allow".
case .undetermined: return "common.next"
}
}
private func request() async {
if speechDenied {
AppPermissions.openSystemSettings()
return
}
isRequesting = true
_ = await AppPermissions.requestSpeechRecognition()
status = AppPermissions.speechStatus
isRequesting = false
}
}
private struct PermissionPageLayout: View {
@Environment(\.themePalette) private var palette: ThemePalette
let icon: String
let title: String
let detail: String
var preface: String?
let status: String
let statusColor: Color
let primaryTitle: String
let primaryDisabled: Bool
let onPrimary: () -> Void
var secondaryTitle: String?
var onSecondary: (() -> Void)?
var deniedHint: String?
var body: some View {
ScrollView {
VStack(spacing: 0) {
Spacer(minLength: Spacing.lg)
if let preface {
Text(AppL10n.string(preface))
.font(TypeStyle.caption)
.foregroundStyle(palette.textSecondary)
.multilineTextAlignment(.center)
.padding(.horizontal, Spacing.lg)
.padding(.bottom, Spacing.lg)
}
OnboardingHeroIcon(systemName: icon, circleSize: 96, iconSize: 40)
OnboardingTitleBlock(title: title, subtitle: detail)
.padding(.top, OnboardingLayoutMetrics.heroTextGap)
HStack(spacing: 6) {
Circle().fill(statusColor).frame(width: 8, height: 8)
Text(AppL10n.string(status))
.font(TypeStyle.caption)
.foregroundStyle(palette.textSecondary)
}
.padding(.top, OnboardingLayoutMetrics.heroTextGap)
if let deniedHint {
Text(AppL10n.string(deniedHint))
.font(TypeStyle.caption2)
.foregroundStyle(palette.warning)
.multilineTextAlignment(.center)
.padding(.horizontal, Spacing.xl)
.padding(.top, Spacing.md)
}
VStack(spacing: Spacing.sm) {
Button(action: onPrimary) {
Text(AppL10n.string(primaryTitle))
.primaryButton()
}
.buttonStyle(.plain)
.disabled(primaryDisabled)
if let secondaryTitle, let onSecondary {
Button(action: onSecondary) {
Text(AppL10n.string(secondaryTitle))
.secondaryButton()
}
.buttonStyle(.plain)
}
}
.padding(.horizontal, Spacing.lg)
.padding(.top, Spacing.xl)
Spacer(minLength: Spacing.lg)
}
}
}
}
// MARK: - Enable keyboard
private struct EnableKeyboardPage: View {
@Environment(\.themePalette) private var palette: ThemePalette
@ObservedObject private var deployment = RimeDeploymentController.shared
var body: some View {
ScrollView {
VStack(spacing: 0) {
Spacer(minLength: Spacing.lg)
OnboardingHeroIcon(systemName: "keyboard.fill", circleSize: 96, iconSize: 40)
OnboardingTitleBlock(
title: "onboarding.enable.title",
subtitle: "onboarding.enable.fullAccessNote"
)
.padding(.top, OnboardingLayoutMetrics.heroTextGap)
VStack(alignment: .leading, spacing: Spacing.lg) {
step(num: 1, text: AppL10n.string("onboarding.enable.step1"))
step(num: 2, text: AppL10n.string("onboarding.enable.step2"))
switchKeyboardStep(num: 3)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, Spacing.xl)
.padding(.top, OnboardingLayoutMetrics.heroTextGap)
PrivacyInfoCard(
title: "settings.privacy.fullAccess.title",
bodyText: "settings.privacy.fullAccess.body"
)
.padding(.horizontal, Spacing.lg)
.padding(.top, Spacing.lg)
Button {
AppPermissions.openSystemSettings()
} label: {
Label(AppL10n.string("onboarding.enable.openSettings"), systemImage: "arrow.up.right.square")
.primaryButton()
}
.buttonStyle(.plain)
.padding(.horizontal, Spacing.lg)
.padding(.top, Spacing.xxxl)
resourceStatusRow
.padding(.horizontal, Spacing.lg)
.padding(.top, Spacing.md)
Spacer(minLength: Spacing.lg)
}
}
// Deploy while the user reads these steps and visits system settings.
// The keyboard is not in use yet, so this is the one moment where the
// host can afford the memory without risking the extension.
.onAppear {
deployment.deployNow(reason: "onboarding.enableKeyboard")
}
}
@ViewBuilder
private var resourceStatusRow: some View {
switch deployment.status {
case .deploying:
HStack(spacing: Spacing.xs) {
ProgressView().controlSize(.small)
Text(AppL10n.string("onboarding.enable.resources.preparing"))
.font(TypeStyle.caption)
.foregroundStyle(palette.textSecondary)
}
case .ready:
HStack(spacing: Spacing.xs) {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(palette.accent)
Text(AppL10n.string("onboarding.enable.resources.ready"))
.font(TypeStyle.caption)
.foregroundStyle(palette.textSecondary)
}
case .failed:
HStack(spacing: Spacing.xs) {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(palette.warning)
Text(AppL10n.string("onboarding.enable.resources.failed"))
.font(TypeStyle.caption)
.foregroundStyle(palette.textSecondary)
Button(AppL10n.string("onboarding.enable.resources.retry")) {
deployment.deployNow(force: true, reason: "onboarding.retry")
}
.font(TypeStyle.caption)
}
case .idle:
EmptyView()
}
}
private func step(num: Int, text: String) -> some View {
HStack(alignment: .top, spacing: Spacing.sm) {
stepLabel(num)
Text(text)
.font(TypeStyle.body)
.foregroundStyle(palette.textPrimary)
.multilineTextAlignment(.leading)
.frame(maxWidth: .infinity, alignment: .leading)
.fixedSize(horizontal: false, vertical: true)
}
}
private func switchKeyboardStep(num: Int) -> some View {
HStack(alignment: .top, spacing: Spacing.sm) {
stepLabel(num)
HStack(alignment: .firstTextBaseline, spacing: 3) {
Text(AppL10n.string("onboarding.enable.step3.prefix"))
Image(systemName: "globe")
.font(TypeStyle.body)
.foregroundStyle(palette.textPrimary)
.alignmentGuide(.firstTextBaseline) { dimensions in
dimensions[.bottom] - dimensions.height * 0.12
}
Text(AppL10n.string("onboarding.enable.step3.suffix"))
}
.font(TypeStyle.body)
.foregroundStyle(palette.textPrimary)
.multilineTextAlignment(.leading)
.frame(maxWidth: .infinity, alignment: .leading)
.fixedSize(horizontal: false, vertical: true)
}
}
private func stepLabel(_ num: Int) -> some View {
Text("\(num)")
.font(TypeStyle.body)
.foregroundStyle(palette.textPrimary)
.frame(width: 20, alignment: .leading)
}
}
// MARK: - Engine / ASR setup
private struct APISetupPage: View {
@Environment(\.themePalette) private var palette: ThemePalette
@ObservedObject var config: ProviderConfig
var body: some View {
ScrollView {
VStack(spacing: Spacing.lg) {
OnboardingHeroIcon(systemName: "cpu", circleSize: 72, iconSize: 30)
.padding(.top, Spacing.xl)
OnboardingTitleBlock(title: "onboarding.api.title")
.padding(.horizontal, Spacing.lg)
EnginePickerSection(config: config)
.padding(.horizontal, Spacing.lg)
if config.credentialSource == .byok {
VStack(spacing: 0) {
AppleASRPickerRow(config: config)
if config.engineMode == "cloud" {
Divider().background(palette.divider)
ProviderPickerSection(
config: config,
role: .asr,
showsSurface: false
)
Divider().background(palette.divider)
ASRSettingsCard(config: config, showsSurface: false)
}
}
.surfaceCard()
.padding(.horizontal, Spacing.lg)
} else {
Text(AppL10n.string("settings.aiService.credits.subtitle"))
.font(TypeStyle.body)
.foregroundStyle(palette.textSecondary)
.multilineTextAlignment(.center)
.padding(.horizontal, Spacing.xl)
}
}
.padding(.bottom, Spacing.xxxl)
}
}
}
// MARK: - Polish LLM setup
private struct PolishSetupPage: View {
@Environment(\.themePalette) private var palette: ThemePalette
@ObservedObject var config: ProviderConfig
var body: some View {
ScrollView {
VStack(spacing: Spacing.lg) {
OnboardingHeroIcon(systemName: "wand.and.stars", circleSize: 72, iconSize: 30)
.padding(.top, Spacing.xl)
OnboardingTitleBlock(
title: "onboarding.polish.title",
subtitle: "onboarding.polish.subtitle"
)
.padding(.horizontal, Spacing.lg)
if config.credentialSource == .managed {
Text(AppL10n.string("settings.aiService.credits.subtitle"))
.font(TypeStyle.caption)
.foregroundStyle(palette.textSecondary)
.multilineTextAlignment(.center)
.padding(.horizontal, Spacing.xl)
} else if config.isLocalEngine {
Text(AppL10n.string("onboarding.polish.localHint"))
.font(TypeStyle.caption)
.foregroundStyle(palette.textSecondary)
.multilineTextAlignment(.center)
.padding(.horizontal, Spacing.xl)
}
if config.credentialSource == .byok {
VStack(spacing: 0) {
ProviderPickerSection(config: config, role: .polish, showsSurface: false)
Divider().background(palette.divider)
APISettingsCard(config: config, showsSurface: false)
}
.surfaceCard()
.padding(.horizontal, Spacing.lg)
}
}
.padding(.bottom, Spacing.xxxl)
}
}
}