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>
862 lines
31 KiB
Swift
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)
|
|
}
|
|
}
|
|
}
|