e5a83843db
Add resilient usage analytics, OOBE gateway flows, clipboard semantic ranking, purchase recovery, style learning, and managed current-information search.
1591 lines
58 KiB
Swift
1591 lines
58 KiB
Swift
// OnboardingExperienceView.swift
|
|
// OSGKeyboard · Main App
|
|
//
|
|
// A short, outcome-led first run:
|
|
// 1) Explain the product and its data boundaries.
|
|
// 2) Request the two permissions needed by Flow.
|
|
// 3) Guide keyboard installation with a visual system-settings preview.
|
|
// 4) Activate and verify the real keyboard in a host text field.
|
|
// 5) Teach four real features without requiring an account or API key.
|
|
// 6) Offer optional sign-in for the existing signup reward.
|
|
// 7) Celebrate the verified onboarding outcomes.
|
|
|
|
import OSGKeyboardShared
|
|
import SwiftUI
|
|
import UIKit
|
|
|
|
private enum OnboardingExperienceStep: Int, CaseIterable {
|
|
case introduction = 0
|
|
case permissions = 1
|
|
case keyboard = 2
|
|
// Keep existing persisted raw values stable while inserting this step
|
|
// between keyboard setup and practice.
|
|
case keyboardSwitch = 5
|
|
case practice = 3
|
|
case complete = 4
|
|
// Appended so all existing persisted values keep their original meaning.
|
|
case loginReward = 6
|
|
}
|
|
|
|
private extension ManagedGatewayOOBEFeature {
|
|
var progressKey: LocalizedStringKey {
|
|
switch self {
|
|
case .voiceInput:
|
|
return "onboarding.experience.practice.progress.voice"
|
|
case .clipboardTranslate:
|
|
return "onboarding.experience.practice.progress.translate"
|
|
case .clipboardReply:
|
|
return "onboarding.experience.practice.progress.reply"
|
|
case .askAI:
|
|
return "onboarding.experience.practice.progress.askAI"
|
|
}
|
|
}
|
|
|
|
var titleKey: LocalizedStringKey {
|
|
switch self {
|
|
case .voiceInput:
|
|
return "onboarding.experience.practice.speakTitle"
|
|
case .clipboardTranslate:
|
|
return "onboarding.experience.practice.translateTitle"
|
|
case .clipboardReply:
|
|
return "onboarding.experience.practice.replyTitle"
|
|
case .askAI:
|
|
return "onboarding.experience.practice.askAITitle"
|
|
}
|
|
}
|
|
|
|
var subtitleKey: LocalizedStringKey {
|
|
switch self {
|
|
case .voiceInput:
|
|
return "onboarding.experience.practice.speakSubtitle"
|
|
case .clipboardTranslate:
|
|
return "onboarding.experience.practice.translateSubtitle"
|
|
case .clipboardReply:
|
|
return "onboarding.experience.practice.replySubtitle"
|
|
case .askAI:
|
|
return "onboarding.experience.practice.askAISubtitle"
|
|
}
|
|
}
|
|
|
|
var next: ManagedGatewayOOBEFeature? {
|
|
switch self {
|
|
case .voiceInput:
|
|
return .clipboardTranslate
|
|
case .clipboardTranslate:
|
|
return .clipboardReply
|
|
case .clipboardReply:
|
|
return .askAI
|
|
case .askAI:
|
|
return nil
|
|
}
|
|
}
|
|
}
|
|
|
|
struct OnboardingExperienceView: View {
|
|
@Environment(\.scenePhase) private var scenePhase
|
|
@Environment(\.themePalette) private var palette
|
|
@EnvironmentObject private var flowManager: FlowSessionManager
|
|
@EnvironmentObject private var accountSession: AccountSessionCoordinator
|
|
|
|
@ObservedObject var config: ProviderConfig
|
|
@ObservedObject private var deployment = RimeDeploymentController.shared
|
|
private let oobeClient = OOBEClientInfrastructure.shared
|
|
|
|
@State private var micStatus = AppPermissions.micStatus
|
|
@State private var speechStatus = AppPermissions.speechStatus
|
|
@State private var keyboardAppeared = KeyboardSetupBridge.hasAppeared
|
|
@State private var keyboardReady = KeyboardSetupBridge.isReadyForOnboardingSkip
|
|
@State private var hasOpenedKeyboardSettings = false
|
|
@State private var isRequestingPermissions = false
|
|
@State private var keyboardSwitchText = ""
|
|
@State private var keyboardVerificationTimedOut = false
|
|
@State private var practiceText = ""
|
|
@State private var practiceStartedAt: Date?
|
|
@State private var isPreparingManagedPractice = false
|
|
@State private var managedPracticeReady = false
|
|
@State private var managedPracticeFailed = false
|
|
@State private var practiceFeature: ManagedGatewayOOBEFeature = .voiceInput
|
|
@State private var practiceSessionID: UUID?
|
|
@State private var completedPracticeFeatures: Set<ManagedGatewayOOBEFeature> = []
|
|
@State private var didCopyPracticeSample = false
|
|
@State private var didRefreshLoginReward = false
|
|
@FocusState private var keyboardSwitchFieldFocused: Bool
|
|
@FocusState private var practiceFieldFocused: Bool
|
|
|
|
private static let migrationKey = "onboarding.experience.v2.migrated"
|
|
private static let completedPracticeFeaturesKey =
|
|
"onboarding.experience.oobe.completedFeatures.v1"
|
|
|
|
private var currentStep: OnboardingExperienceStep {
|
|
OnboardingExperienceStep(rawValue: config.onboardingPage) ?? .introduction
|
|
}
|
|
|
|
var body: some View {
|
|
ZStack {
|
|
palette.background.ignoresSafeArea()
|
|
ambientBackground
|
|
|
|
VStack(spacing: 0) {
|
|
page
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
.id(currentStep)
|
|
.transition(.opacity.combined(with: .move(edge: .trailing)))
|
|
|
|
bottomAction
|
|
.padding(.horizontal, Spacing.lg)
|
|
.padding(.bottom, Spacing.lg)
|
|
}
|
|
}
|
|
.onAppear {
|
|
migrateLegacyProgressIfNeeded()
|
|
applyPrivacySafeDefaultsIfNeeded()
|
|
refreshState()
|
|
if currentStep == .practice {
|
|
beginPractice()
|
|
} else if currentStep == .loginReward {
|
|
refreshLoginRewardIfNeeded()
|
|
}
|
|
}
|
|
.onDisappear {
|
|
endPractice()
|
|
}
|
|
.onChange(of: scenePhase) { _, phase in
|
|
guard phase == .active else { return }
|
|
refreshState()
|
|
if currentStep == .keyboard, hasOpenedKeyboardSettings {
|
|
goForward()
|
|
return
|
|
}
|
|
if currentStep == .keyboardSwitch, !keyboardReady {
|
|
focusKeyboardSwitchField()
|
|
}
|
|
if currentStep == .practice {
|
|
beginPracticeIfNeeded()
|
|
}
|
|
}
|
|
.onChange(of: currentStep) { previous, current in
|
|
if previous == .keyboardSwitch {
|
|
keyboardSwitchFieldFocused = false
|
|
}
|
|
if previous == .practice {
|
|
endPractice()
|
|
}
|
|
if current == .keyboardSwitch {
|
|
keyboardVerificationTimedOut = false
|
|
focusKeyboardSwitchField()
|
|
}
|
|
if current == .practice {
|
|
beginPractice()
|
|
}
|
|
if current == .loginReward {
|
|
refreshLoginRewardIfNeeded()
|
|
}
|
|
}
|
|
.onChange(of: accountSession.isSignedIn) { _, isSignedIn in
|
|
guard isSignedIn, currentStep == .loginReward else { return }
|
|
didRefreshLoginReward = false
|
|
refreshLoginRewardIfNeeded()
|
|
}
|
|
.onChange(of: config.hasAcknowledgedCloudSharing) { _, acknowledged in
|
|
guard acknowledged, currentStep == .practice else { return }
|
|
prepareManagedPractice()
|
|
}
|
|
.task(id: currentStep) {
|
|
switch currentStep {
|
|
case .keyboardSwitch:
|
|
await monitorKeyboardVerification()
|
|
case .practice:
|
|
await monitorPractice()
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var page: some View {
|
|
switch currentStep {
|
|
case .introduction:
|
|
introductionPage
|
|
case .permissions:
|
|
permissionsPage
|
|
case .keyboard:
|
|
keyboardPage
|
|
case .keyboardSwitch:
|
|
keyboardSwitchPage
|
|
case .practice:
|
|
practicePage
|
|
case .loginReward:
|
|
loginRewardPage
|
|
case .complete:
|
|
completePage
|
|
}
|
|
}
|
|
|
|
private var ambientBackground: some View {
|
|
GeometryReader { geometry in
|
|
LinearGradient(
|
|
colors: [
|
|
palette.accent.opacity(0.10),
|
|
palette.accent.opacity(0.025),
|
|
palette.background.opacity(0)
|
|
],
|
|
startPoint: .top,
|
|
endPoint: .bottom
|
|
)
|
|
.frame(height: geometry.size.height * 0.46)
|
|
.allowsHitTesting(false)
|
|
}
|
|
.ignoresSafeArea(edges: .top)
|
|
}
|
|
|
|
// MARK: - Introduction
|
|
|
|
private var introductionPage: some View {
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
Spacer(minLength: Spacing.xxl)
|
|
|
|
Image("osglogo")
|
|
.resizable()
|
|
.scaledToFit()
|
|
.frame(maxWidth: 174, maxHeight: 50)
|
|
.accessibilityHidden(true)
|
|
|
|
Text("onboarding.experience.intro.eyebrow")
|
|
.font(TypeStyle.caption)
|
|
.tracking(1.2)
|
|
.foregroundStyle(palette.accent)
|
|
.padding(.horizontal, Spacing.sm)
|
|
.frame(height: 28)
|
|
.background(palette.accentMuted, in: Capsule())
|
|
.padding(.top, Spacing.hero)
|
|
|
|
Text("onboarding.experience.intro.title")
|
|
.font(TypeStyle.largeTitle)
|
|
.foregroundStyle(palette.textPrimary)
|
|
.padding(.top, Spacing.md)
|
|
|
|
Text("onboarding.experience.intro.subtitle")
|
|
.font(TypeStyle.body)
|
|
.foregroundStyle(palette.textSecondary)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
.padding(.top, Spacing.sm)
|
|
|
|
VStack(spacing: Spacing.sm) {
|
|
promiseRow(
|
|
icon: "waveform",
|
|
title: "onboarding.experience.privacy.local.title",
|
|
detail: "onboarding.experience.privacy.local.body"
|
|
)
|
|
promiseRow(
|
|
icon: "network",
|
|
title: "onboarding.experience.privacy.cloud.title",
|
|
detail: "onboarding.experience.privacy.cloud.body"
|
|
)
|
|
promiseRow(
|
|
icon: "keyboard",
|
|
title: "onboarding.experience.privacy.anywhere.title",
|
|
detail: "onboarding.experience.privacy.anywhere.body"
|
|
)
|
|
}
|
|
.padding(.top, Spacing.xxl)
|
|
|
|
if let privacyURL = LegalLinks.privacyPolicyURL {
|
|
Link(destination: privacyURL) {
|
|
Text("legal.privacyPolicy")
|
|
.font(TypeStyle.caption)
|
|
.foregroundStyle(palette.textSecondary)
|
|
.underline()
|
|
}
|
|
.padding(.top, Spacing.lg)
|
|
}
|
|
}
|
|
.padding(.horizontal, Spacing.xl)
|
|
.padding(.bottom, Spacing.xl)
|
|
.frame(maxWidth: 560, alignment: .leading)
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
}
|
|
|
|
private func promiseRow(
|
|
icon: String,
|
|
title: LocalizedStringKey,
|
|
detail: LocalizedStringKey
|
|
) -> some View {
|
|
HStack(alignment: .top, spacing: Spacing.md) {
|
|
Image(systemName: icon)
|
|
.font(.system(size: 19, weight: .semibold))
|
|
.foregroundStyle(palette.accent)
|
|
.frame(width: 42, height: 42)
|
|
.background(palette.accentMuted, in: RoundedRectangle(cornerRadius: Radius.medium))
|
|
|
|
VStack(alignment: .leading, spacing: 3) {
|
|
Text(title)
|
|
.font(TypeStyle.bodyEmph)
|
|
.foregroundStyle(palette.textPrimary)
|
|
Text(detail)
|
|
.font(TypeStyle.footnote)
|
|
.foregroundStyle(palette.textSecondary)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
}
|
|
|
|
Spacer(minLength: 0)
|
|
}
|
|
.padding(Spacing.md)
|
|
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
|
|
.stroke(palette.divider, lineWidth: 0.5)
|
|
)
|
|
}
|
|
|
|
// MARK: - Permissions
|
|
|
|
private var permissionsPage: some View {
|
|
OnboardingExperienceShell(
|
|
title: "onboarding.experience.permissions.title",
|
|
subtitle: "onboarding.experience.permissions.subtitle",
|
|
onBack: goBack
|
|
) {
|
|
VStack(spacing: Spacing.sm) {
|
|
permissionRow(
|
|
icon: "mic.fill",
|
|
title: "onboarding.permission.mic.title",
|
|
detail: "onboarding.permission.mic.body",
|
|
granted: micStatus == .granted,
|
|
denied: micStatus == .denied
|
|
)
|
|
permissionRow(
|
|
icon: "waveform.badge.mic",
|
|
title: "onboarding.permission.speech.title",
|
|
detail: "onboarding.permission.speech.body",
|
|
granted: speechStatus == .granted,
|
|
denied: speechPermissionDenied
|
|
)
|
|
}
|
|
.padding(.top, Spacing.xxl)
|
|
|
|
Text("onboarding.experience.permissions.systemHint")
|
|
.font(TypeStyle.caption)
|
|
.foregroundStyle(palette.textTertiary)
|
|
.multilineTextAlignment(.center)
|
|
.padding(.horizontal, Spacing.lg)
|
|
.padding(.top, Spacing.lg)
|
|
|
|
if !permissionsReady {
|
|
Button("onboarding.experience.permissions.later") {
|
|
goForward()
|
|
}
|
|
.font(TypeStyle.footnote)
|
|
.foregroundStyle(palette.textSecondary)
|
|
.padding(.top, Spacing.xl)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func permissionRow(
|
|
icon: String,
|
|
title: LocalizedStringKey,
|
|
detail: LocalizedStringKey,
|
|
granted: Bool,
|
|
denied: Bool
|
|
) -> some View {
|
|
HStack(spacing: Spacing.md) {
|
|
Image(systemName: icon)
|
|
.font(.system(size: 20, weight: .semibold))
|
|
.foregroundStyle(palette.accent)
|
|
.frame(width: 42, height: 42)
|
|
.background(palette.accentMuted, in: RoundedRectangle(cornerRadius: Radius.medium))
|
|
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(title)
|
|
.font(TypeStyle.bodyEmph)
|
|
.foregroundStyle(palette.textPrimary)
|
|
Text(detail)
|
|
.font(TypeStyle.caption)
|
|
.foregroundStyle(palette.textSecondary)
|
|
}
|
|
|
|
Spacer(minLength: Spacing.sm)
|
|
|
|
Image(systemName: granted ? "checkmark.circle.fill" : (denied ? "exclamationmark.circle.fill" : "circle"))
|
|
.font(.system(size: 20, weight: .semibold))
|
|
.foregroundStyle(granted ? palette.accent : (denied ? palette.warning : palette.textTertiary))
|
|
.accessibilityHidden(true)
|
|
}
|
|
.padding(Spacing.md)
|
|
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
|
|
.stroke(palette.divider, lineWidth: 0.5)
|
|
)
|
|
}
|
|
|
|
// MARK: - Keyboard setup
|
|
|
|
private var keyboardPage: some View {
|
|
OnboardingExperienceShell(
|
|
title: "onboarding.experience.keyboard.title",
|
|
subtitle: "onboarding.experience.keyboard.subtitle",
|
|
onBack: goBack
|
|
) {
|
|
KeyboardSettingsPreview(isReady: keyboardReady)
|
|
.padding(.top, Spacing.xxl)
|
|
|
|
HStack(spacing: Spacing.xs) {
|
|
Image(systemName: keyboardReady ? "checkmark.circle.fill" : "info.circle")
|
|
.foregroundStyle(keyboardReady ? palette.accent : palette.textSecondary)
|
|
Text(
|
|
keyboardReady
|
|
? "onboarding.experience.keyboard.ready"
|
|
: "onboarding.experience.keyboard.fullAccess"
|
|
)
|
|
.font(TypeStyle.caption)
|
|
.foregroundStyle(palette.textSecondary)
|
|
}
|
|
.padding(.top, Spacing.lg)
|
|
|
|
if hasOpenedKeyboardSettings, !keyboardReady {
|
|
Button("onboarding.experience.keyboard.openAgain") {
|
|
openKeyboardSettings()
|
|
}
|
|
.font(TypeStyle.footnote)
|
|
.foregroundStyle(palette.textSecondary)
|
|
.padding(.top, Spacing.lg)
|
|
}
|
|
|
|
resourceStatus
|
|
.padding(.top, Spacing.md)
|
|
}
|
|
.onAppear {
|
|
deployment.deployNow(reason: "onboarding.experience.keyboard")
|
|
}
|
|
}
|
|
|
|
// MARK: - Keyboard activation and verification
|
|
|
|
private var keyboardSwitchPage: some View {
|
|
OnboardingExperienceShell(
|
|
title: "onboarding.experience.keyboardSwitch.title",
|
|
subtitle: "onboarding.experience.keyboardSwitch.subtitle",
|
|
onBack: goBack
|
|
) {
|
|
VStack(spacing: Spacing.md) {
|
|
Image(systemName: "globe")
|
|
.font(.system(size: 34, weight: .semibold))
|
|
.foregroundStyle(palette.accent)
|
|
.accessibilityHidden(true)
|
|
|
|
Text("onboarding.experience.keyboardSwitch.instruction")
|
|
.font(TypeStyle.bodyEmph)
|
|
.foregroundStyle(palette.textPrimary)
|
|
.multilineTextAlignment(.center)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.padding(Spacing.lg)
|
|
.background(
|
|
palette.surface,
|
|
in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
|
|
)
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
|
|
.stroke(palette.divider, lineWidth: 0.5)
|
|
)
|
|
.padding(.top, Spacing.xxl)
|
|
|
|
TextField(
|
|
"onboarding.experience.keyboardSwitch.placeholder",
|
|
text: $keyboardSwitchText,
|
|
axis: .vertical
|
|
)
|
|
.font(TypeStyle.body)
|
|
.foregroundStyle(palette.textPrimary)
|
|
.focused($keyboardSwitchFieldFocused)
|
|
.textInputAutocapitalization(.never)
|
|
.autocorrectionDisabled()
|
|
.submitLabel(.done)
|
|
.padding(Spacing.md)
|
|
.frame(maxWidth: .infinity, minHeight: 72, alignment: .topLeading)
|
|
.background(
|
|
palette.surface,
|
|
in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
|
|
)
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
|
|
.stroke(
|
|
keyboardReady ? palette.accent : palette.dividerStrong,
|
|
lineWidth: keyboardReady ? 1.5 : 0.5
|
|
)
|
|
)
|
|
.accessibilityIdentifier("onboarding.keyboardSwitch.textField")
|
|
.padding(.top, Spacing.lg)
|
|
|
|
HStack(spacing: Spacing.xs) {
|
|
if keyboardReady {
|
|
Image(systemName: "checkmark.circle.fill")
|
|
.foregroundStyle(palette.accent)
|
|
} else if keyboardAppeared || keyboardVerificationTimedOut {
|
|
Image(systemName: "exclamationmark.circle.fill")
|
|
.foregroundStyle(palette.warning)
|
|
} else {
|
|
ProgressView()
|
|
.tint(palette.accent)
|
|
}
|
|
|
|
Text(keyboardVerificationStatus)
|
|
.font(TypeStyle.caption)
|
|
.foregroundStyle(palette.textSecondary)
|
|
}
|
|
.padding(.top, Spacing.md)
|
|
|
|
if !keyboardReady, keyboardAppeared || keyboardVerificationTimedOut {
|
|
Button("onboarding.experience.keyboardSwitch.checkSettings") {
|
|
openKeyboardSettings()
|
|
}
|
|
.font(TypeStyle.footnote)
|
|
.foregroundStyle(palette.accent)
|
|
.padding(.top, Spacing.sm)
|
|
}
|
|
}
|
|
}
|
|
|
|
private var keyboardVerificationStatus: LocalizedStringKey {
|
|
if keyboardReady {
|
|
return "onboarding.experience.keyboardSwitch.ready"
|
|
}
|
|
if keyboardAppeared {
|
|
return "onboarding.experience.keyboardSwitch.fullAccessMissing"
|
|
}
|
|
if keyboardVerificationTimedOut {
|
|
return "onboarding.experience.keyboardSwitch.timeout"
|
|
}
|
|
return "onboarding.experience.keyboardSwitch.waiting"
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var resourceStatus: some View {
|
|
switch deployment.status {
|
|
case .deploying:
|
|
Label("onboarding.enable.resources.preparing", systemImage: "hourglass")
|
|
.font(TypeStyle.caption2)
|
|
.foregroundStyle(palette.textTertiary)
|
|
case .ready:
|
|
EmptyView()
|
|
case .failed:
|
|
Button {
|
|
deployment.deployNow(force: true, reason: "onboarding.experience.retry")
|
|
} label: {
|
|
Label("onboarding.enable.resources.retry", systemImage: "arrow.clockwise")
|
|
.font(TypeStyle.caption)
|
|
}
|
|
.foregroundStyle(palette.warning)
|
|
case .idle:
|
|
EmptyView()
|
|
}
|
|
}
|
|
|
|
// MARK: - Real keyboard practice
|
|
|
|
private var practicePage: some View {
|
|
VStack(spacing: 0) {
|
|
HStack {
|
|
Button(action: goBack) {
|
|
Image(systemName: "chevron.left")
|
|
.font(.system(size: 17, weight: .semibold))
|
|
.foregroundStyle(palette.textPrimary)
|
|
.frame(width: 44, height: 44)
|
|
.contentShape(Rectangle())
|
|
}
|
|
.buttonStyle(.plain)
|
|
.accessibilityLabel(Text("common.back"))
|
|
|
|
Spacer()
|
|
}
|
|
.padding(.horizontal, Spacing.sm)
|
|
|
|
VStack(alignment: .leading, spacing: Spacing.sm) {
|
|
Text(practiceTitle)
|
|
.font(TypeStyle.title)
|
|
.foregroundStyle(palette.textPrimary)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
|
|
Text(practiceSubtitle)
|
|
.font(TypeStyle.body)
|
|
.foregroundStyle(palette.textSecondary)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.padding(.horizontal, Spacing.xl)
|
|
.padding(.top, Spacing.sm)
|
|
|
|
practiceContent
|
|
.padding(.horizontal, Spacing.lg)
|
|
.padding(.top, Spacing.lg)
|
|
.padding(.bottom, Spacing.sm)
|
|
.frame(maxHeight: .infinity, alignment: .top)
|
|
|
|
if managedPracticeReady, keyboardAppeared, !keyboardReady {
|
|
Button {
|
|
openKeyboardSettings()
|
|
} label: {
|
|
Label(
|
|
"onboarding.experience.practice.fullAccessAction",
|
|
systemImage: "arrow.up.right.square"
|
|
)
|
|
.font(TypeStyle.caption)
|
|
.foregroundStyle(palette.warning)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.padding(.bottom, Spacing.sm)
|
|
}
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var practiceContent: some View {
|
|
if !config.hasAcknowledgedCloudSharing {
|
|
VStack(spacing: Spacing.md) {
|
|
Image(systemName: "sparkles")
|
|
.font(.system(size: 34, weight: .semibold))
|
|
.foregroundStyle(palette.accent)
|
|
|
|
Text("onboarding.experience.practice.cloudBody")
|
|
.font(TypeStyle.caption)
|
|
.foregroundStyle(palette.textSecondary)
|
|
.multilineTextAlignment(.center)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
|
|
Button {
|
|
config.hasAcknowledgedCloudSharing = true
|
|
} label: {
|
|
Text("onboarding.experience.practice.cloudAction")
|
|
.primaryButton()
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
.practiceSetupCard(palette: palette)
|
|
} else if isPreparingManagedPractice {
|
|
VStack(spacing: Spacing.md) {
|
|
ProgressView()
|
|
.tint(palette.accent)
|
|
Text("onboarding.experience.practice.preparingCredits")
|
|
.font(TypeStyle.caption)
|
|
.foregroundStyle(palette.textSecondary)
|
|
}
|
|
.practiceSetupCard(palette: palette)
|
|
} else if managedPracticeFailed {
|
|
VStack(spacing: Spacing.md) {
|
|
Image(systemName: "arrow.clockwise.circle")
|
|
.font(.system(size: 34, weight: .semibold))
|
|
.foregroundStyle(palette.warning)
|
|
Text("onboarding.experience.practice.creditsFailed")
|
|
.font(TypeStyle.caption)
|
|
.foregroundStyle(palette.textSecondary)
|
|
.multilineTextAlignment(.center)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
Button("onboarding.experience.practice.retry") {
|
|
prepareManagedPractice()
|
|
}
|
|
.font(TypeStyle.bodyEmph)
|
|
.foregroundStyle(palette.accent)
|
|
Button("onboarding.experience.practice.skip") {
|
|
goToLoginReward()
|
|
}
|
|
.font(TypeStyle.footnote)
|
|
.foregroundStyle(palette.textSecondary)
|
|
}
|
|
.practiceSetupCard(palette: palette)
|
|
} else {
|
|
VStack(spacing: Spacing.md) {
|
|
if practiceFeature == .voiceInput {
|
|
practiceVoiceSample
|
|
} else if practiceFeature == .clipboardTranslate
|
|
|| practiceFeature == .clipboardReply {
|
|
practiceClipboardSample
|
|
}
|
|
practiceEditor
|
|
HStack(spacing: Spacing.xs) {
|
|
if managedPracticeReady {
|
|
ProgressView()
|
|
.controlSize(.small)
|
|
.tint(palette.accent)
|
|
}
|
|
Text("onboarding.experience.practice.waiting")
|
|
.font(TypeStyle.caption)
|
|
.foregroundStyle(palette.textSecondary)
|
|
}
|
|
|
|
Button("onboarding.experience.practice.skip") {
|
|
goToLoginReward()
|
|
}
|
|
.font(TypeStyle.caption)
|
|
.foregroundStyle(palette.textTertiary)
|
|
}
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var accountOperationError: some View {
|
|
if let key = accountSession.operationErrorKey {
|
|
Text(LocalizedStringKey(key))
|
|
.font(TypeStyle.caption2)
|
|
.foregroundStyle(palette.warning)
|
|
.multilineTextAlignment(.center)
|
|
}
|
|
}
|
|
|
|
private var practiceEditor: some View {
|
|
VStack(spacing: 0) {
|
|
HStack(spacing: Spacing.xs) {
|
|
Image(systemName: practiceFeature == .askAI ? "sparkles" : "message.fill")
|
|
.foregroundStyle(palette.accent)
|
|
Text(practiceFeature.progressKey)
|
|
.font(TypeStyle.caption)
|
|
.foregroundStyle(palette.textSecondary)
|
|
Spacer()
|
|
}
|
|
.padding(.horizontal, Spacing.md)
|
|
.frame(height: 42)
|
|
|
|
Divider().overlay(palette.divider)
|
|
|
|
TextEditor(text: $practiceText)
|
|
.font(TypeStyle.body)
|
|
.foregroundStyle(palette.textPrimary)
|
|
.scrollContentBackground(.hidden)
|
|
.focused($practiceFieldFocused)
|
|
.padding(Spacing.sm)
|
|
.accessibilityIdentifier("onboarding.practice.textEditor")
|
|
}
|
|
.frame(maxWidth: .infinity, minHeight: 172, maxHeight: 240)
|
|
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
|
|
.stroke(keyboardReady ? palette.accent.opacity(0.42) : palette.divider, lineWidth: 1)
|
|
)
|
|
}
|
|
|
|
private var practiceVoiceSample: some View {
|
|
VStack(alignment: .leading, spacing: Spacing.xs) {
|
|
Text("onboarding.experience.practice.readAloud")
|
|
.font(TypeStyle.caption)
|
|
.foregroundStyle(palette.textSecondary)
|
|
|
|
Text("onboarding.experience.practice.voiceSample")
|
|
.font(TypeStyle.bodyEmph)
|
|
.foregroundStyle(palette.textPrimary)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.padding(Spacing.md)
|
|
.background(palette.accentMuted, in: RoundedRectangle(cornerRadius: Radius.large))
|
|
.accessibilityIdentifier("onboarding.practice.voiceSample")
|
|
}
|
|
|
|
private var practiceClipboardSample: some View {
|
|
VStack(alignment: .leading, spacing: Spacing.md) {
|
|
Text("onboarding.experience.practice.sample")
|
|
.font(TypeStyle.bodyEmph)
|
|
.foregroundStyle(palette.textPrimary)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
|
|
Button {
|
|
copyPracticeSample()
|
|
} label: {
|
|
Label {
|
|
Text(
|
|
didCopyPracticeSample
|
|
? "onboarding.experience.practice.copied"
|
|
: "onboarding.experience.practice.copyAction"
|
|
)
|
|
} icon: {
|
|
Image(
|
|
systemName: didCopyPracticeSample
|
|
? "checkmark.circle.fill"
|
|
: "doc.on.doc"
|
|
)
|
|
}
|
|
.font(TypeStyle.footnote)
|
|
.foregroundStyle(didCopyPracticeSample ? palette.accent : palette.textPrimary)
|
|
.frame(maxWidth: .infinity)
|
|
.frame(height: 42)
|
|
.background(
|
|
didCopyPracticeSample ? palette.accentMuted : palette.surfaceElevated,
|
|
in: RoundedRectangle(cornerRadius: Radius.medium, style: .continuous)
|
|
)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.accessibilityIdentifier("onboarding.practice.copySample")
|
|
}
|
|
.padding(Spacing.md)
|
|
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
|
|
.stroke(palette.divider, lineWidth: 0.5)
|
|
)
|
|
}
|
|
|
|
private var practiceTitle: LocalizedStringKey {
|
|
if !permissionsReady {
|
|
return "onboarding.experience.practice.permissionsTitle"
|
|
}
|
|
if !config.hasAcknowledgedCloudSharing {
|
|
return "onboarding.experience.practice.cloudTitle"
|
|
}
|
|
if isPreparingManagedPractice || managedPracticeFailed {
|
|
return "onboarding.experience.practice.creditsTitle"
|
|
}
|
|
if !keyboardAppeared {
|
|
return "onboarding.experience.practice.switchTitle"
|
|
}
|
|
if !keyboardReady {
|
|
return "onboarding.experience.practice.fullAccessTitle"
|
|
}
|
|
return practiceFeature.titleKey
|
|
}
|
|
|
|
private var practiceSubtitle: LocalizedStringKey {
|
|
if !permissionsReady {
|
|
return "onboarding.experience.practice.permissionsSubtitle"
|
|
}
|
|
if !config.hasAcknowledgedCloudSharing {
|
|
return "onboarding.experience.practice.cloudSubtitle"
|
|
}
|
|
if isPreparingManagedPractice || managedPracticeFailed {
|
|
return "onboarding.experience.practice.creditsSubtitle"
|
|
}
|
|
if !keyboardAppeared {
|
|
return "onboarding.experience.practice.switchSubtitle"
|
|
}
|
|
if !keyboardReady {
|
|
return "onboarding.experience.practice.fullAccessSubtitle"
|
|
}
|
|
return practiceFeature.subtitleKey
|
|
}
|
|
|
|
// MARK: - Optional account reward
|
|
|
|
private var loginRewardPage: some View {
|
|
OnboardingExperienceShell(
|
|
title: "onboarding.experience.login.title",
|
|
subtitle: "onboarding.experience.login.subtitle",
|
|
onBack: goBack
|
|
) {
|
|
VStack(spacing: Spacing.lg) {
|
|
Image(systemName: accountSession.isSignedIn ? "checkmark.seal.fill" : "gift.fill")
|
|
.font(.system(size: 42, weight: .semibold))
|
|
.foregroundStyle(palette.accent)
|
|
.accessibilityHidden(true)
|
|
|
|
Text(
|
|
accountSession.isSignedIn
|
|
? "onboarding.experience.login.signedIn"
|
|
: "onboarding.experience.login.body"
|
|
)
|
|
.font(TypeStyle.body)
|
|
.foregroundStyle(palette.textSecondary)
|
|
.multilineTextAlignment(.center)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
|
|
if accountSession.isSignedIn {
|
|
if let balance = loginRewardBalance {
|
|
Text("onboarding.experience.login.balance \(balance)")
|
|
.font(TypeStyle.title)
|
|
.foregroundStyle(palette.textPrimary)
|
|
} else {
|
|
ProgressView()
|
|
.tint(palette.accent)
|
|
}
|
|
} else {
|
|
AccountAppleAuthorizationButton(
|
|
purpose: .signIn,
|
|
onSignedIn: {
|
|
didRefreshLoginReward = false
|
|
refreshLoginRewardIfNeeded()
|
|
}
|
|
)
|
|
.disabled(accountSession.operation != nil)
|
|
|
|
accountOperationError
|
|
}
|
|
|
|
Button("onboarding.experience.login.skip") {
|
|
goToComplete()
|
|
}
|
|
.font(TypeStyle.footnote)
|
|
.foregroundStyle(palette.textSecondary)
|
|
.opacity(accountSession.isSignedIn ? 0 : 1)
|
|
.disabled(accountSession.isSignedIn)
|
|
.accessibilityHidden(accountSession.isSignedIn)
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.padding(Spacing.xl)
|
|
.background(
|
|
palette.surface,
|
|
in: RoundedRectangle(cornerRadius: Radius.xl, style: .continuous)
|
|
)
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: Radius.xl, style: .continuous)
|
|
.stroke(palette.divider, lineWidth: 0.5)
|
|
)
|
|
.padding(.top, Spacing.xxl)
|
|
.accessibilityIdentifier("onboarding.loginReward.card")
|
|
}
|
|
}
|
|
|
|
private var loginRewardBalance: Int64? {
|
|
guard case let .loaded(snapshot) = accountSession.snapshotPhase else {
|
|
return nil
|
|
}
|
|
return snapshot.credits.balance
|
|
}
|
|
|
|
// MARK: - Complete
|
|
|
|
private var completePage: some View {
|
|
ScrollView {
|
|
VStack(spacing: 0) {
|
|
Spacer(minLength: Spacing.xxl)
|
|
|
|
ZStack {
|
|
Circle()
|
|
.fill(palette.accentMuted)
|
|
.frame(width: 108, height: 108)
|
|
Image(systemName: completedAllPracticeFeatures ? "checkmark" : "sparkles")
|
|
.font(.system(size: 44, weight: .semibold))
|
|
.foregroundStyle(palette.accent)
|
|
}
|
|
|
|
Text(
|
|
completedAllPracticeFeatures
|
|
? "onboarding.experience.complete.verifiedTitle"
|
|
: "onboarding.experience.complete.title"
|
|
)
|
|
.font(TypeStyle.largeTitle)
|
|
.foregroundStyle(palette.textPrimary)
|
|
.multilineTextAlignment(.center)
|
|
.padding(.top, Spacing.xxl)
|
|
|
|
Text("onboarding.experience.complete.subtitle")
|
|
.font(TypeStyle.body)
|
|
.foregroundStyle(palette.textSecondary)
|
|
.multilineTextAlignment(.center)
|
|
.padding(.top, Spacing.sm)
|
|
|
|
LazyVGrid(
|
|
columns: [
|
|
GridItem(.flexible(), spacing: Spacing.xs),
|
|
GridItem(.flexible(), spacing: Spacing.xs)
|
|
],
|
|
spacing: Spacing.xs
|
|
) {
|
|
capability("mic.fill", "onboarding.experience.complete.dictate")
|
|
capability("sparkles", "onboarding.experience.complete.agent")
|
|
capability("translate", "onboarding.experience.complete.translate")
|
|
capability("arrowshape.turn.up.left.fill", "onboarding.experience.complete.reply")
|
|
}
|
|
.padding(.top, Spacing.xxl)
|
|
}
|
|
.padding(.horizontal, Spacing.xl)
|
|
.padding(.bottom, Spacing.xl)
|
|
.frame(maxWidth: 560)
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
}
|
|
|
|
private func capability(_ icon: String, _ title: LocalizedStringKey) -> some View {
|
|
VStack(spacing: Spacing.xs) {
|
|
Image(systemName: icon)
|
|
.font(.system(size: 20, weight: .semibold))
|
|
.foregroundStyle(palette.accent)
|
|
Text(title)
|
|
.font(TypeStyle.caption)
|
|
.foregroundStyle(palette.textPrimary)
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.vertical, Spacing.md)
|
|
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.medium))
|
|
}
|
|
|
|
// MARK: - Bottom action
|
|
|
|
private var bottomAction: some View {
|
|
Button(action: performPrimaryAction) {
|
|
HStack(spacing: Spacing.xs) {
|
|
if isRequestingPermissions || (currentStep == .keyboardSwitch && !keyboardReady) {
|
|
ProgressView()
|
|
.tint(palette.textOnAccent)
|
|
}
|
|
Text(primaryActionTitle)
|
|
}
|
|
.primaryButton()
|
|
}
|
|
.buttonStyle(.plain)
|
|
.disabled(primaryActionDisabled)
|
|
.opacity(primaryActionDisabled ? 0.55 : 1)
|
|
}
|
|
|
|
private var primaryActionDisabled: Bool {
|
|
if isRequestingPermissions || (currentStep == .keyboardSwitch && !keyboardReady) {
|
|
return true
|
|
}
|
|
if currentStep == .practice {
|
|
return !completedAllPracticeFeatures
|
|
}
|
|
if currentStep == .loginReward, accountSession.isSignedIn {
|
|
return loginRewardBalance == nil
|
|
}
|
|
return false
|
|
}
|
|
|
|
private var primaryActionTitle: LocalizedStringKey {
|
|
switch currentStep {
|
|
case .introduction:
|
|
return "onboarding.experience.intro.action"
|
|
case .permissions:
|
|
if permissionsReady { return "common.continue" }
|
|
if micStatus == .denied || speechPermissionDenied {
|
|
return "onboarding.permission.openSettings"
|
|
}
|
|
return "common.continue"
|
|
case .keyboard:
|
|
if keyboardReady { return "common.continue" }
|
|
return hasOpenedKeyboardSettings
|
|
? "common.continue"
|
|
: "onboarding.enable.openSettings"
|
|
case .keyboardSwitch:
|
|
return keyboardReady
|
|
? "common.continue"
|
|
: "onboarding.experience.keyboardSwitch.waitingAction"
|
|
case .practice:
|
|
return "common.continue"
|
|
case .loginReward:
|
|
return accountSession.isSignedIn
|
|
? "common.continue"
|
|
: "onboarding.experience.login.skip"
|
|
case .complete:
|
|
return "onboarding.experience.complete.action"
|
|
}
|
|
}
|
|
|
|
private func performPrimaryAction() {
|
|
switch currentStep {
|
|
case .introduction:
|
|
goForward()
|
|
case .permissions:
|
|
handlePermissionAction()
|
|
case .keyboard:
|
|
if keyboardReady || hasOpenedKeyboardSettings {
|
|
goForward()
|
|
} else {
|
|
openKeyboardSettings()
|
|
}
|
|
case .keyboardSwitch:
|
|
guard keyboardReady else { return }
|
|
goForward()
|
|
case .practice:
|
|
guard completedAllPracticeFeatures else { return }
|
|
goToLoginReward()
|
|
case .loginReward:
|
|
goToComplete()
|
|
case .complete:
|
|
finishOnboarding()
|
|
}
|
|
}
|
|
|
|
// MARK: - State and actions
|
|
|
|
private var permissionsReady: Bool {
|
|
micStatus == .granted && speechStatus == .granted
|
|
}
|
|
|
|
private var speechPermissionDenied: Bool {
|
|
speechStatus == .denied || speechStatus == .restricted
|
|
}
|
|
|
|
private func handlePermissionAction() {
|
|
if micStatus == .denied || speechPermissionDenied {
|
|
AppPermissions.openSystemSettings()
|
|
return
|
|
}
|
|
if permissionsReady {
|
|
goForward()
|
|
return
|
|
}
|
|
|
|
isRequestingPermissions = true
|
|
Task { @MainActor in
|
|
await AppPermissions.requestFlowPermissionsIfNeeded()
|
|
refreshState()
|
|
isRequestingPermissions = false
|
|
if permissionsReady {
|
|
goForward()
|
|
}
|
|
}
|
|
}
|
|
|
|
private func openKeyboardSettings() {
|
|
hasOpenedKeyboardSettings = true
|
|
AppPermissions.openSystemSettings()
|
|
}
|
|
|
|
private func beginPracticeIfNeeded() {
|
|
if practiceStartedAt == nil {
|
|
beginPractice()
|
|
} else if managedPracticeReady, permissionsReady {
|
|
flowManager.activateOnForeground(reason: "onboarding.practice.resume")
|
|
focusPracticeField()
|
|
} else if config.hasAcknowledgedCloudSharing {
|
|
prepareManagedPractice()
|
|
}
|
|
}
|
|
|
|
private func beginPractice() {
|
|
refreshState()
|
|
practiceStartedAt = nil
|
|
completedPracticeFeatures = loadCompletedPracticeFeatures()
|
|
practiceFeature = firstIncompletePracticeFeature ?? .askAI
|
|
practiceSessionID = nil
|
|
didCopyPracticeSample = false
|
|
managedPracticeReady = false
|
|
managedPracticeFailed = false
|
|
KeyboardSetupBridge.setOnboardingPracticeActive(false)
|
|
if completedAllPracticeFeatures {
|
|
goToLoginReward()
|
|
} else if config.hasAcknowledgedCloudSharing {
|
|
prepareManagedPractice()
|
|
}
|
|
}
|
|
|
|
private func prepareManagedPractice() {
|
|
guard currentStep == .practice,
|
|
config.hasAcknowledgedCloudSharing,
|
|
!isPreparingManagedPractice else { return }
|
|
isPreparingManagedPractice = true
|
|
managedPracticeFailed = false
|
|
managedPracticeReady = false
|
|
KeyboardSetupBridge.setOnboardingPracticeActive(false)
|
|
|
|
// OOBE keeps audio on-device. Only the transcript is sent to the
|
|
// managed polish gateway, where the request is tagged and audited.
|
|
config.engineMode = "local"
|
|
config.modeId = "polish"
|
|
|
|
Task { @MainActor in
|
|
let session: OOBEPracticeSession
|
|
do {
|
|
session = try await oobeClient.beginPractice(feature: practiceFeature)
|
|
} catch {
|
|
guard currentStep == .practice else { return }
|
|
isPreparingManagedPractice = false
|
|
managedPracticeFailed = true
|
|
return
|
|
}
|
|
guard currentStep == .practice else { return }
|
|
isPreparingManagedPractice = false
|
|
config.credentialSource = .managed
|
|
managedPracticeReady = true
|
|
practiceStartedAt = Date()
|
|
practiceSessionID = session.sessionID
|
|
if permissionsReady {
|
|
flowManager.activateOnForeground(reason: "onboarding.practice.managed")
|
|
}
|
|
focusPracticeField()
|
|
}
|
|
}
|
|
|
|
private func focusKeyboardSwitchField() {
|
|
Task { @MainActor in
|
|
try? await Task.sleep(for: .milliseconds(350))
|
|
guard currentStep == .keyboardSwitch, !keyboardReady else { return }
|
|
keyboardSwitchFieldFocused = true
|
|
}
|
|
}
|
|
|
|
private func focusPracticeField() {
|
|
Task { @MainActor in
|
|
try? await Task.sleep(for: .milliseconds(350))
|
|
guard currentStep == .practice else { return }
|
|
practiceFieldFocused = true
|
|
}
|
|
}
|
|
|
|
private func endPractice() {
|
|
practiceFieldFocused = false
|
|
isPreparingManagedPractice = false
|
|
managedPracticeReady = false
|
|
practiceSessionID = nil
|
|
KeyboardSetupBridge.setOnboardingPracticeActive(false)
|
|
Task {
|
|
await oobeClient.endPractice()
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
private func monitorKeyboardVerification() async {
|
|
let startedAt = Date()
|
|
refreshState()
|
|
guard !keyboardReady else { return }
|
|
focusKeyboardSwitchField()
|
|
|
|
while !Task.isCancelled, currentStep == .keyboardSwitch {
|
|
refreshState()
|
|
if keyboardReady {
|
|
keyboardVerificationTimedOut = false
|
|
keyboardSwitchFieldFocused = false
|
|
UINotificationFeedbackGenerator().notificationOccurred(.success)
|
|
return
|
|
}
|
|
if !keyboardVerificationTimedOut,
|
|
Date().timeIntervalSince(startedAt) >= 12 {
|
|
keyboardVerificationTimedOut = true
|
|
}
|
|
try? await Task.sleep(for: .milliseconds(350))
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
private func monitorPractice() async {
|
|
while !Task.isCancelled, currentStep == .practice {
|
|
refreshState()
|
|
if let sessionID = practiceSessionID,
|
|
KeyboardSetupBridge.oobePracticeCompletion(
|
|
sessionID: sessionID,
|
|
feature: practiceFeature
|
|
) != nil {
|
|
let completedFeature = practiceFeature
|
|
completedPracticeFeatures.insert(completedFeature)
|
|
persistCompletedPracticeFeatures()
|
|
UINotificationFeedbackGenerator().notificationOccurred(.success)
|
|
try? await Task.sleep(for: .milliseconds(650))
|
|
guard !Task.isCancelled, currentStep == .practice else { return }
|
|
if let next = completedFeature.next {
|
|
advancePractice(to: next)
|
|
} else {
|
|
goToLoginReward()
|
|
return
|
|
}
|
|
}
|
|
try? await Task.sleep(for: .milliseconds(350))
|
|
}
|
|
}
|
|
|
|
private func advancePractice(to feature: ManagedGatewayOOBEFeature) {
|
|
guard let sessionID = practiceSessionID,
|
|
KeyboardSetupBridge.updateOOBEExpectedFeature(
|
|
feature,
|
|
sessionID: sessionID
|
|
) != nil else {
|
|
managedPracticeReady = false
|
|
managedPracticeFailed = true
|
|
return
|
|
}
|
|
withAnimation(Motion.soft) {
|
|
practiceFeature = feature
|
|
didCopyPracticeSample = false
|
|
}
|
|
focusPracticeField()
|
|
}
|
|
|
|
private func copyPracticeSample() {
|
|
guard let sessionID = practiceSessionID else { return }
|
|
let sample = AppL10n.string("onboarding.experience.practice.sample")
|
|
guard KeyboardSetupBridge.seedOOBEClipboardMaterial(
|
|
sample,
|
|
sessionID: sessionID
|
|
) != nil else {
|
|
managedPracticeFailed = true
|
|
return
|
|
}
|
|
// This is a direct response to the user's button tap. The keyboard
|
|
// still reads only the session-bound sample from App Group storage.
|
|
UIPasteboard.general.string = sample
|
|
didCopyPracticeSample = true
|
|
UINotificationFeedbackGenerator().notificationOccurred(.success)
|
|
focusPracticeField()
|
|
}
|
|
|
|
private var completedAllPracticeFeatures: Bool {
|
|
Set(ManagedGatewayOOBEFeature.allCases).isSubset(of: completedPracticeFeatures)
|
|
}
|
|
|
|
private var firstIncompletePracticeFeature: ManagedGatewayOOBEFeature? {
|
|
ManagedGatewayOOBEFeature.allCases.first {
|
|
!completedPracticeFeatures.contains($0)
|
|
}
|
|
}
|
|
|
|
private func loadCompletedPracticeFeatures() -> Set<ManagedGatewayOOBEFeature> {
|
|
let values = UserDefaults.standard.stringArray(
|
|
forKey: Self.completedPracticeFeaturesKey
|
|
) ?? []
|
|
return Set(values.compactMap(ManagedGatewayOOBEFeature.init(rawValue:)))
|
|
}
|
|
|
|
private func persistCompletedPracticeFeatures() {
|
|
UserDefaults.standard.set(
|
|
completedPracticeFeatures.map(\.rawValue).sorted(),
|
|
forKey: Self.completedPracticeFeaturesKey
|
|
)
|
|
}
|
|
|
|
private func refreshLoginRewardIfNeeded() {
|
|
guard currentStep == .loginReward,
|
|
accountSession.isSignedIn,
|
|
!didRefreshLoginReward else { return }
|
|
didRefreshLoginReward = true
|
|
Task { @MainActor in
|
|
await oobeClient.endPractice()
|
|
if await accountSession.prepareManagedGateway() {
|
|
config.credentialSource = .managed
|
|
}
|
|
await accountSession.refreshAccountData(force: true)
|
|
}
|
|
}
|
|
|
|
private func refreshState() {
|
|
micStatus = AppPermissions.micStatus
|
|
speechStatus = AppPermissions.speechStatus
|
|
keyboardAppeared = KeyboardSetupBridge.hasAppeared
|
|
keyboardReady = KeyboardSetupBridge.isReadyForOnboardingSkip
|
|
}
|
|
|
|
private func goForward() {
|
|
let next: OnboardingExperienceStep
|
|
switch currentStep {
|
|
case .introduction:
|
|
next = .permissions
|
|
case .permissions:
|
|
next = .keyboard
|
|
case .keyboard:
|
|
next = keyboardReady ? .practice : .keyboardSwitch
|
|
case .keyboardSwitch:
|
|
next = .practice
|
|
case .practice:
|
|
next = .loginReward
|
|
case .loginReward, .complete:
|
|
next = .complete
|
|
}
|
|
if next == .practice {
|
|
resetPracticeRun()
|
|
}
|
|
withAnimation(Motion.soft) {
|
|
config.onboardingPage = next.rawValue
|
|
}
|
|
}
|
|
|
|
private func goBack() {
|
|
let previous: OnboardingExperienceStep
|
|
switch currentStep {
|
|
case .introduction, .permissions:
|
|
previous = .introduction
|
|
case .keyboard:
|
|
previous = .permissions
|
|
case .keyboardSwitch:
|
|
previous = .keyboard
|
|
case .practice:
|
|
previous = .keyboardSwitch
|
|
case .loginReward:
|
|
previous = .practice
|
|
case .complete:
|
|
previous = .loginReward
|
|
}
|
|
if previous == .practice {
|
|
resetPracticeRun()
|
|
}
|
|
withAnimation(Motion.soft) {
|
|
config.onboardingPage = previous.rawValue
|
|
}
|
|
}
|
|
|
|
private func goToComplete() {
|
|
config.engineMode = "local"
|
|
if !accountSession.isSignedIn {
|
|
// Skipping the optional account offer leaves a fully usable local
|
|
// voice keyboard instead of a managed mode with no credential.
|
|
config.credentialSource = .byok
|
|
}
|
|
withAnimation(Motion.soft) {
|
|
config.onboardingPage = OnboardingExperienceStep.complete.rawValue
|
|
}
|
|
}
|
|
|
|
private func goToLoginReward() {
|
|
endPractice()
|
|
withAnimation(Motion.soft) {
|
|
config.onboardingPage = OnboardingExperienceStep.loginReward.rawValue
|
|
}
|
|
}
|
|
|
|
private func finishOnboarding() {
|
|
endPractice()
|
|
UserDefaults.standard.removeObject(forKey: Self.completedPracticeFeaturesKey)
|
|
var transaction = Transaction()
|
|
transaction.disablesAnimations = true
|
|
withTransaction(transaction) {
|
|
config.hasCompletedOnboarding = true
|
|
}
|
|
}
|
|
|
|
private func resetPracticeRun() {
|
|
UserDefaults.standard.removeObject(forKey: Self.completedPracticeFeaturesKey)
|
|
completedPracticeFeatures.removeAll()
|
|
practiceFeature = .voiceInput
|
|
practiceText = ""
|
|
practiceStartedAt = nil
|
|
didCopyPracticeSample = false
|
|
}
|
|
|
|
private func applyPrivacySafeDefaultsIfNeeded() {
|
|
guard !config.hasCompletedOnboarding, config.onboardingPage == 0 else { return }
|
|
// First-run voice should work without asking users to understand an
|
|
// ASR provider or supply a cloud key.
|
|
if config.apiKey.isEmpty, config.engineMode == "cloud" {
|
|
config.engineMode = "local"
|
|
}
|
|
}
|
|
|
|
private func migrateLegacyProgressIfNeeded() {
|
|
let defaults = UserDefaults.standard
|
|
guard !defaults.bool(forKey: Self.migrationKey) else {
|
|
if OnboardingExperienceStep(rawValue: config.onboardingPage) == nil {
|
|
config.onboardingPage = 0
|
|
}
|
|
return
|
|
}
|
|
|
|
if !config.hasCompletedOnboarding {
|
|
switch config.onboardingPage {
|
|
case 1, 2:
|
|
config.onboardingPage = OnboardingExperienceStep.permissions.rawValue
|
|
case 3:
|
|
config.onboardingPage = OnboardingExperienceStep.keyboard.rawValue
|
|
case 4, 5:
|
|
config.onboardingPage = OnboardingExperienceStep.introduction.rawValue
|
|
default:
|
|
config.onboardingPage = OnboardingExperienceStep.introduction.rawValue
|
|
}
|
|
}
|
|
defaults.set(true, forKey: Self.migrationKey)
|
|
}
|
|
}
|
|
|
|
private extension View {
|
|
func practiceSetupCard(palette: ThemePalette) -> some View {
|
|
frame(maxWidth: .infinity, minHeight: 172)
|
|
.padding(Spacing.lg)
|
|
.background(
|
|
palette.surface,
|
|
in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
|
|
)
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
|
|
.stroke(palette.divider, lineWidth: 0.5)
|
|
)
|
|
}
|
|
}
|
|
|
|
private struct OnboardingExperienceShell<Content: View>: View {
|
|
@Environment(\.themePalette) private var palette
|
|
|
|
let title: LocalizedStringKey
|
|
let subtitle: LocalizedStringKey
|
|
let onBack: () -> Void
|
|
@ViewBuilder let content: Content
|
|
|
|
var body: some View {
|
|
ScrollView {
|
|
VStack(spacing: 0) {
|
|
HStack {
|
|
Button(action: onBack) {
|
|
Image(systemName: "chevron.left")
|
|
.font(.system(size: 17, weight: .semibold))
|
|
.foregroundStyle(palette.textPrimary)
|
|
.frame(width: 44, height: 44)
|
|
.contentShape(Rectangle())
|
|
}
|
|
.buttonStyle(.plain)
|
|
.accessibilityLabel(Text("common.back"))
|
|
Spacer()
|
|
}
|
|
|
|
VStack(spacing: Spacing.sm) {
|
|
Text(title)
|
|
.font(TypeStyle.largeTitle)
|
|
.foregroundStyle(palette.textPrimary)
|
|
Text(subtitle)
|
|
.font(TypeStyle.body)
|
|
.foregroundStyle(palette.textSecondary)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
}
|
|
.multilineTextAlignment(.center)
|
|
.padding(.top, Spacing.xl)
|
|
|
|
content
|
|
|
|
Spacer(minLength: Spacing.xl)
|
|
}
|
|
.padding(.horizontal, Spacing.xl)
|
|
.frame(maxWidth: 560)
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct KeyboardSettingsPreview: View {
|
|
@Environment(\.themePalette) private var palette
|
|
|
|
let isReady: Bool
|
|
|
|
var body: some View {
|
|
VStack(spacing: 0) {
|
|
HStack {
|
|
Text("onboarding.experience.keyboard.previewTitle")
|
|
.font(TypeStyle.caption)
|
|
.foregroundStyle(palette.textSecondary)
|
|
Spacer()
|
|
}
|
|
.padding(.horizontal, Spacing.md)
|
|
.frame(height: 42)
|
|
|
|
Divider().overlay(palette.divider)
|
|
|
|
HStack(spacing: Spacing.md) {
|
|
Image("OSGBrandMark")
|
|
.renderingMode(.template)
|
|
.resizable()
|
|
.scaledToFit()
|
|
.foregroundStyle(palette.textPrimary)
|
|
.frame(width: 28, height: 28)
|
|
|
|
Text("OSGKeyboard")
|
|
.font(TypeStyle.bodyEmph)
|
|
.foregroundStyle(palette.textPrimary)
|
|
|
|
Spacer()
|
|
|
|
ZStack(alignment: isReady ? .trailing : .leading) {
|
|
Capsule()
|
|
.fill(isReady ? palette.accent : palette.surfaceElevated)
|
|
.frame(width: 48, height: 28)
|
|
Circle()
|
|
.fill(isReady ? palette.textOnAccent : palette.textTertiary)
|
|
.frame(width: 22, height: 22)
|
|
.padding(3)
|
|
}
|
|
.animation(Motion.quick, value: isReady)
|
|
.accessibilityHidden(true)
|
|
}
|
|
.padding(Spacing.md)
|
|
}
|
|
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.xl, style: .continuous))
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: Radius.xl, style: .continuous)
|
|
.stroke(palette.dividerStrong, lineWidth: 0.5)
|
|
)
|
|
}
|
|
}
|