aeb28f4b36
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
267 lines
10 KiB
Swift
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)
|
|
}
|
|
}
|
|
}
|