feat: iOS-style localization + onboarding engine picker + UX fixes

Five user-flagged issues addressed in this commit. Some of the
uncommitted files belong to a prior agent pass and are included as-is
so this is a clean working tree.

1. Remove globe (nextKeyboard) button from keyboard bottom bar.
   iOS already provides a globe key in the system keyboard strip
   for next-keyboard switching, so the in-extension one was
   redundant. The bar is now: ⌫ (delete) [space] ↩ (return).
   Affected: OSGKeyboardExt/Views/KeyboardRootView.swift and
   OSGKeyboard/Views/KeyboardPreviewStub.swift (preview mirrors).

2. Fix TabView with .page style auto-jumping on TextField focus.
   SwiftUI's `.tabViewStyle(.page(...))` wraps content in a
   UIPageViewController, which has a long-standing iOS 18 bug
   where the keyboard-showing layout reflow on a TextField focus
   is misread as a horizontal swipe — the page jumps back to
   step 1 the moment the user starts typing. Replaced the
   TabView with a ZStack + conditional view + transition. We give
   up swipe-to-page, but Back/Next buttons + page dots are the
   canonical onboarding affordance and the user is one tap from
   the next page anyway.

3. Onboarding APISetupPage now offers Engine choice (Local vs
   Cloud), matching the in-app Settings page. First-run users can
   pick the on-device engine and skip the API-key setup entirely.
   Extracted the engine section from SettingsView into a shared
   `EnginePickerSection` component used by both. Cloud path
   keeps the provider + API fields; Local path shows a
   "no API key needed" confirmation card.

4. APISettingsCard test-connection error messages are now built
   with NSLocalizedString + String.localizedStringWithFormat (for
   the interpolated HTTP status / reply preview). Status badge
   labels are also NSLocalizedString-backed.

5. iOS-standard localization.
   - New `en.lproj/Localizable.strings` and `zh-Hans.lproj/
     Localizable.strings` (in both the main app and the keyboard
     extension bundles — each .appex has its own bundle).
   - `project.yml` sets `CFBundleDevelopmentRegion: en` and
     `CFBundleLocalizations: [en, zh-Hans]`; the two .lproj dirs
     are added as resources.
   - Every `Text("...")` / `Button("...")` / `Label("...")` /
     `accessibilityLabel(Text("..."))` in user-facing views was
     rewritten to use `Text("key")` (SwiftUI auto-resolves
     string-literal `LocalizedStringKey`s against the strings
     file) or `NSLocalizedString("key", comment: "")` for
     interpolated / dynamic values. The hardcoded
     "中 · EN" / "EN · 中" pattern is gone.
   - Two helper signatures that took `String` for the title/body
     of a row (`footnoteRow`, `sectionHeader`) are now
     `LocalizedStringKey` so the row labels are looked up.
   - `Label("key", systemImage: "...")` and
     `.confirmationDialog(LocalizedStringKey("key"), ...)` and
     `.navigationTitle(LocalizedStringKey("key"))` are used where
     `String` would just print the key.
   - Preview-onboarding `APISetupPage` updated to use the same
     engine picker as Settings (issue 3), with a section that
     only renders the provider + API fields when the user picks
     Cloud.

Verified on iOS 26 simulator with system language set to both
zh-Hans (default) and en: the same screen renders "按住说话,松开
即得润色文字。" / "下一步" in Chinese, and "Hold to talk. Release
for polished text, in any app." / "Next" in English — no
"中 · EN" doubling, no missing keys.

Build: BUILD SUCCEEDED.
Tests: 21/21 pass.

🤖 Generated with Claude Code
This commit is contained in:
Rocky
2026-06-18 19:57:45 +08:00
parent e8a031075b
commit b1635d5d35
23 changed files with 978 additions and 209 deletions
+14 -13
View File
@@ -96,7 +96,7 @@ public struct KeyboardRootView: View {
.overlay(Circle().stroke(palette.divider, lineWidth: 0.5))
}
.buttonStyle(.plain)
.accessibilityLabel(Text("打开设置 · Open OSGKeyboard settings"))
.accessibilityLabel(Text("home.action.openSettingsA11y"))
}
.padding(.horizontal, Spacing.md)
}
@@ -128,16 +128,18 @@ public struct KeyboardRootView: View {
// MARK: - Bottom bar
private var bottomBar: some View {
// The globe / "next keyboard" button used to live here so the
// user could long-press to call up the system keyboard picker.
// In practice the user already has a "globe" key in the iOS
// system keyboard strip (every iOS keyboard does), so an
// in-extension globe is redundant and crowds the bar.
// Bottom bar: (delete) [ space ] (return)
HStack(spacing: Spacing.xxs) {
ToolbarIconButton(systemName: "globe", label: "nextKeyboard") {
state.tapMic()
}
ToolbarIconButton(systemName: "delete.left", label: "delete") {
state.deleteBackward()
}
Spacer(minLength: 0)
Button(action: state.insertSpace) {
Text("空格 · Space")
Text("common.space")
.font(TypeStyle.body)
.foregroundStyle(palette.textPrimary)
.frame(maxWidth: .infinity, minHeight: 42)
@@ -148,8 +150,7 @@ public struct KeyboardRootView: View {
)
}
.buttonStyle(.plain)
.accessibilityLabel(Text("空格 · Space"))
Spacer(minLength: 0)
.accessibilityLabel(Text("common.space"))
ToolbarIconButton(systemName: "return", label: "newline") {
state.insertNewline()
}
@@ -210,13 +211,13 @@ private struct TranscriptLine: View {
ZStack {
switch phase {
case .idle:
Text("按住说话 · Hold to talk")
Text("keyboard.placeholder.idle")
.font(TypeStyle.caption)
.foregroundStyle(palette.textTertiary)
case .requestingPermissions:
HStack(spacing: 6) {
ProgressView().controlSize(.mini).tint(palette.textSecondary)
Text("准备中… · Preparing")
Text("keyboard.placeholder.preparing")
.font(TypeStyle.caption)
.foregroundStyle(palette.textSecondary)
}
@@ -230,7 +231,7 @@ private struct TranscriptLine: View {
case .processing:
HStack(spacing: 6) {
ProgressView().controlSize(.mini).tint(palette.accent)
Text("处理中 · Processing")
Text("keyboard.placeholder.processing")
.font(TypeStyle.caption)
.foregroundStyle(palette.textSecondary)
}
@@ -255,7 +256,7 @@ private struct TranscriptLine: View {
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityHint(Text("打开 OSGKeyboard 设置,可授予麦克风 / 语音识别权限。Opens the OSGKeyboard settings page where you can grant microphone or speech recognition access."))
.accessibilityHint(Text("keyboard.deniedHint"))
}
}
.frame(maxWidth: .infinity)
@@ -360,7 +361,7 @@ private struct LocalEngineChip: View {
var body: some View {
HStack(spacing: 4) {
Image(systemName: "iphone.badge.checkmark")
Text("本地 · On-device")
Text("keyboard.placeholder.localBadge")
}
.font(TypeStyle.caption2)
.foregroundStyle(palette.accent)