Files
OSGKeyboard/OSGKeyboard/Views/OnboardingView.swift
T
Rocky aeb28f4b36 fix: green accent + real iOS ASR in preview + bilingual audit
Three user-flagged fixes, scoped tightly to the files each affects.
The uncommitted Engine-mode wiring / locale picker / etc. from a
prior agent pass is intentionally not included in this commit.

1. Revert accent to brand green (#3AA05A).
   Last commit flipped `AccentColor` + `Palette.light.accent` to
   Apple system blue (#007AFF) on the assumption that "green CTA
   on a near-white surface looks wrong." Review pushed back:
   the brand *is* the green, and the system tint should match it
   so that NavStack Done buttons, Toggles, and our custom
   `primaryButton()` modifier all read as the same colour. Restored
   `#3AA05A` in both `AccentColor.colorset/Contents.json` and
   `Palette.light.accent` (plus the muted / glow variants).

2. Real iOS ASR in `KeyboardPreviewSheet`.
   The previous fix only swapped the static placeholder for a
   `TextField` and routed a hardcoded stub through it — review
   asked, fairly, "are you actually calling `SFSpeechRecognizer`?"
   Answer: no. This change makes the preview *run real ASR*:

   - `ASRService` (+ the iOS 26 `SpeechAnalyzer` path) moves from
     `OSGKeyboardExt/Services/` to `OSGKeyboardShared/Services/`,
     so the host app can import the same `ASRServiceFactory.make()`
     the keyboard extension uses.
   - `OSGKeyboardShared` gains `Speech.framework` and
     `AVFoundation.framework` as SDK dependencies in `project.yml`.
   - New `OSGKeyboard/Views/PreviewASRController.swift` (the
     extension's `AudioCaptureService` is app-extension-only, so
     the preview owns its own `AVAudioEngine` + `AVAudioSession`
     and downsamples to 16 kHz mono Float32 via `AVAudioConverter`).
   - `KeyboardPreviewSheet.cyclePhase()` now calls
     `asr.start(locale:)` / `asr.stop()` instead of toggling a
     `StubPhase`. The disc's level meter is driven by RMS from the
     actual audio tap; the transcript line under the chips shows
     the live `SFSpeechRecognizer` partial; the top textbox
     receives the `.final` transcript via `onChange(of: lastFinal)`.

3. Record disc in BOTH stubs now uses the green accent for idle.
   Was `Color(white: 0.22)` dark-gray, which read as "inert
   surface" rather than "tap me". The keyboard extension and the
   in-app preview now share the same brand-green disc gradient so
   the keyboard's primary CTA is the same colour in both modes.

4. Bilingual audit across every user-facing string.
   Every Text() in the main-app and extension views is now
   `中文 · English` or carries an English secondary line. Covered:
   - OnboardingView (Back, Next, Done, Continue, step instructions,
     PrivacyFootnote rows)
   - HomeView (status header, hero label, accessibility)
   - APISettingsCard (Base URL, API Key, Model, "Get an API key",
     "Connection", test-connection states + error messages)
   - KeyboardPreviewSheet (title, subtitle, TextField placeholder,
     clear button accessibility)
   - KeyboardPreviewStub (mode/locale chips, REC badge, space bar)
   - KeyboardRootView (gear accessibility, space bar, requesting
     state, denied messages, local-engine chip)
   - RecordButton (accessibility label)
   - SettingsView (Done, Reset dialog, language section subtitle,
     engine section, local-engine subtitle, on-device label)
   - AppGroupErrorView (title, body, three remediation steps)
   - LLMProvider preset names and blurbs

   Where the prior pattern was "Chinese headline + English footnote"
   (e.g. OnboardingView's 启用 OSGKeyboard / Enable OSGKeyboard),
   that pattern was preserved — bilingual coverage means every
   screen reads as both, not that every line is rigidly `中 · EN`.

Build: BUILD SUCCEEDED on iPhone 17 Pro / iOS 26 simulator.
Tests: 21/21 pass (no test changes).
Visual: light-mode home shot at /tmp/osgk_light_green.png shows
the brand-green CTA restored across Next button, mic icon, and
page dot.

🤖 Generated with Claude Code
2026-06-18 19:34:53 +08:00

267 lines
10 KiB
Swift

// OnboardingView.swift
// OSGKeyboard · Main App
//
// Three-step onboarding presented as a horizontal pager:
//
// 1) Welcome — what the app does, in one sentence
// 2) Enable — Settings → General → Keyboards → Add → Allow Full Access
// 3) Setup — pick a provider, paste a key
//
// Visual style: one large accent surface, generous whitespace, single CTA
// at the bottom. No tipsy animations, no cheerful illustrations — every
// pixel is doing one job.
import SwiftUI
import OSGKeyboardShared
struct OnboardingView: View {
@Environment(\.themePalette) private var palette: ThemePalette
@ObservedObject var config: ProviderConfig
@State private var page: Int = 0
var body: some View {
ZStack {
palette.background.ignoresSafeArea()
VStack(spacing: 0) {
TabView(selection: $page) {
WelcomePage().tag(0)
EnableKeyboardPage().tag(1)
APISetupPage(config: config).tag(2)
}
.tabViewStyle(.page(indexDisplayMode: .never))
pageDots
.padding(.bottom, Spacing.md)
bottomBar
.padding(.horizontal, Spacing.md)
.padding(.bottom, Spacing.lg)
}
}
}
private var pageDots: some View {
HStack(spacing: 6) {
ForEach(0..<3, id: \.self) { i in
Capsule()
.fill(i == page ? palette.accent : Color.white.opacity(0.18))
.frame(width: i == page ? 18 : 6, height: 6)
.animation(Motion.quick, value: page)
}
}
}
@ViewBuilder
private var bottomBar: some View {
HStack(spacing: Spacing.sm) {
if page > 0 {
Button { withAnimation(Motion.soft) { page -= 1 } } label: {
Text("返回 · Back")
.font(TypeStyle.headline)
.frame(maxWidth: .infinity, minHeight: 50)
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
.stroke(palette.dividerStrong, lineWidth: 0.5)
)
.foregroundStyle(palette.textPrimary)
}
.buttonStyle(.plain)
}
Button {
withAnimation(Motion.soft) {
if page < 2 { page += 1 }
}
} label: {
Text(page == 2 ? (config.isConfigured ? "完成 · Done" : "继续 · Continue") : "下一步 · Next")
.font(TypeStyle.headline)
.frame(maxWidth: .infinity, minHeight: 50)
.background(
(page == 2 && !config.isConfigured) ? palette.surfaceElevated : palette.accent,
in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
)
.foregroundStyle(
(page == 2 && !config.isConfigured) ? palette.textSecondary : palette.textOnAccent
)
}
.buttonStyle(.plain)
.disabled(page == 2 && !config.isConfigured)
}
}
}
// MARK: - Page 1: Welcome
private struct WelcomePage: View {
@Environment(\.themePalette) private var palette: ThemePalette
var body: some View {
VStack(spacing: Spacing.xl) {
Spacer()
ZStack {
Circle()
.fill(palette.accentMuted)
.frame(width: 180, height: 180)
.blur(radius: 30)
Image(systemName: "mic.circle.fill")
.font(.system(size: 96, weight: .light))
.foregroundStyle(palette.accent)
}
VStack(spacing: Spacing.sm) {
Text("OSGKeyboard")
.font(TypeStyle.title)
.foregroundStyle(palette.textPrimary)
Text("按住说话,松开即得润色文字。")
.font(TypeStyle.body)
.foregroundStyle(palette.textSecondary)
.multilineTextAlignment(.center)
Text("Hold to talk. Release for polished text, in any app.")
.font(TypeStyle.footnote)
.foregroundStyle(palette.textTertiary)
.multilineTextAlignment(.center)
}
.padding(.horizontal, Spacing.xl)
PrivacyFootnote()
.padding(.top, Spacing.lg)
Spacer()
}
}
}
private struct PrivacyFootnote: View {
@Environment(\.themePalette) private var palette: ThemePalette
var body: some View {
VStack(alignment: .leading, spacing: 8) {
footnoteRow(icon: "lock.fill",
title: "Audio stays on device · 音频不出本机",
body: "Transcribed locally with Apple's speech engine. · 由 Apple 端侧引擎转录")
footnoteRow(icon: "wifi",
title: "Only the polished text is sent · 仅发送润色后文字",
body: "Sent to your chosen LLM to add structure & punctuation. · 仅向所选 LLM 发送润色后文字")
footnoteRow(icon: "keyboard",
title: "Works everywhere · 处处可用",
body: "WeChat, Notes, Mail, ChatGPT, Claude, Cursor — anywhere a keyboard appears. · 微信、备忘录、邮件、ChatGPT、Claude、Cursor — 任何键盘出现的地方")
}
.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)
)
.padding(.horizontal, Spacing.md)
}
private func footnoteRow(icon: String, title: String, body: String) -> some View {
HStack(alignment: .top, spacing: Spacing.xs) {
Image(systemName: icon)
.font(.system(size: 14, weight: .medium))
.foregroundStyle(palette.accent)
.frame(width: 24, height: 24)
.background(palette.accentMuted, in: Circle())
VStack(alignment: .leading, spacing: 2) {
Text(title)
.font(TypeStyle.caption)
.foregroundStyle(palette.textPrimary)
Text(body)
.font(TypeStyle.caption2)
.foregroundStyle(palette.textSecondary)
}
}
}
}
// MARK: - Page 2: Enable keyboard
private struct EnableKeyboardPage: View {
@Environment(\.themePalette) private var palette: ThemePalette
var body: some View {
VStack(spacing: Spacing.xl) {
Spacer()
Image(systemName: "keyboard.fill")
.font(.system(size: 64, weight: .light))
.foregroundStyle(palette.accent)
VStack(spacing: Spacing.sm) {
Text("启用 OSGKeyboard")
.font(TypeStyle.title2)
.foregroundStyle(palette.textPrimary)
Text("Enable OSGKeyboard")
.font(TypeStyle.body)
.foregroundStyle(palette.textTertiary)
}
VStack(alignment: .leading, spacing: Spacing.sm) {
step(num: 1, text: "设置 → 通用 → 键盘 → 键盘")
step(num: 2, text: "点击「添加新键盘…」并选择 OSGKeyboard")
step(num: 3, text: "点击 OSGKeyboard 并启用「允许完全访问」")
step(num: 4, text: "Allow Full Access is required for microphone + LLM calls · 允许完全访问是麦克风和网络调用的前提")
}
.cardSurface()
.padding(.horizontal, Spacing.md)
Button {
if let url = URL(string: UIApplication.openSettingsURLString) {
UIApplication.shared.open(url)
}
} label: {
Label("打开 iOS 设置 · Open Settings", systemImage: "arrow.up.right.square")
.primaryButton()
}
.padding(.horizontal, Spacing.md)
Spacer()
}
}
private func step(num: Int, text: String) -> some View {
HStack(alignment: .top, spacing: Spacing.xs) {
Text("\(num)")
.font(TypeStyle.caption2)
.frame(width: 22, height: 22)
.background(palette.accent, in: Circle())
.foregroundStyle(palette.textOnAccent)
Text(text)
.font(TypeStyle.body)
.foregroundStyle(palette.textPrimary)
.fixedSize(horizontal: false, vertical: true)
}
}
}
// MARK: - Page 3: API setup
private struct APISetupPage: View {
@Environment(\.themePalette) private var palette: ThemePalette
@ObservedObject var config: ProviderConfig
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: Spacing.md) {
VStack(alignment: .leading, spacing: Spacing.xxs) {
Text("配置 AI 提供商")
.font(TypeStyle.title2)
.foregroundStyle(palette.textPrimary)
Text("Configure your AI provider")
.font(TypeStyle.body)
.foregroundStyle(palette.textTertiary)
Text("OSGKeyboard only calls the AI to polish your text. No audio leaves your device.")
.font(TypeStyle.footnote)
.foregroundStyle(palette.textSecondary)
.padding(.top, Spacing.xxs)
}
.padding(.horizontal, Spacing.md)
.padding(.top, Spacing.lg)
ProviderPickerSection(config: config)
.padding(.horizontal, Spacing.md)
APISettingsCard(config: config)
.padding(.horizontal, Spacing.md)
}
.padding(.bottom, Spacing.xxxl)
}
}
}