fix: 3 review issues from the keyboard preview / onboarding flow

1) Preview chips weren't actually buttons.
   `modeChip` and `localeChip` in `KeyboardPreviewStub` were
   decorative HStacks — no `Button`, no action, no callback. The
   chevron-down glyph made them *look* like pickers, so a user
   tapping them got nothing. The screenshot the user shared
   ("润色 ▾" / "中文(简体) ▾") shows exactly that surface.

   Fix: wrap each chip in a `Button(action: ...)` with
   `.buttonStyle(.plain)`. The stub now takes `modeId`, `localeId`,
   `onModeCycle`, `onLocaleCycle` and the sheet's `cycleMode` /
   `cycleLocale` advance the config:
     - mode cycles [off → transcribe → polish] (mirrors Settings)
     - locale cycles [auto → zh-Hans → zh-Hant → en-US → ja-JP → ko-KR]
   Mid-recording locale switches call `asr.stop()` because ASR
   sessions are bound to the locale they were started with.
   `modeId == "off"` also stops any in-flight recording so the
   disc isn't recording into a mode that won't insert.

   The mode chip's icon also follows the mode (mic.slash /
   mic / wand) as a redundant visual cue, and both chips get
   accessibility labels (preview.modeChip.cycle /
   preview.localeChip.cycle) so VoiceOver users can use them.

2) Onboarding's "Next" stays enabled when local engine is picked
   but no API key is filled in. Root cause: `ProviderConfig.isConfigured`
   checks `!apiKey.isEmpty && !baseURL.isEmpty && !model.isEmpty` —
   it never asks whether the user *needs* a key. The local engine
   (on-device ASR) doesn't round-trip through the LLM, so an
   empty key on the local path is correct, not a configuration gap.

   Fix: short-circuit `isConfigured` to `true` when
   `engineMode == "local"`. The onboarding "Next" button is
   already disabled on the API page when `!isConfigured`; this
   just makes the gate respect the engine choice. New test
   `testIsConfiguredTrueForLocalEngineWithoutAPIKey` locks the
   behaviour in (local → true, cloud → false, flip back).

3) Add the two new accessibility keys to all four
   `Localizable.strings` files (en + zh-Hans, main app + ext)
   so VoiceOver and the cycle button labels resolve in both
   languages.

Build: BUILD SUCCEEDED.
Tests: 22/22 pass (1 new).

🤖 Generated with Claude Code
This commit is contained in:
Rocky
2026-06-18 20:17:30 +08:00
parent ffeb18c8d3
commit a803a27a88
10 changed files with 188 additions and 23 deletions
+39 -1
View File
@@ -143,8 +143,12 @@ struct KeyboardPreviewSheet: View {
phase: stubPhase,
level: asr.level,
transcript: stubTranscript,
modeId: config.modeId,
localeId: config.localeId,
onTap: cyclePhase,
openSettings: { showSettings = true }
openSettings: { showSettings = true },
onModeCycle: cycleMode,
onLocaleCycle: cycleLocale
)
}
}
@@ -165,6 +169,40 @@ struct KeyboardPreviewSheet: View {
}
}
/// Cycle the input mode on tap of the mode chip. The order mirrors
/// the Settings picker (`off` `transcribe` `polish` wrap)
/// so the user sees the same surface in both places.
///
/// Note: when the user picks `off` we *also* stop any in-flight
/// recording leaving the disc mid-recording in an "off" mode
/// would be a confusing state (the user is recording but the
/// keyboard says it won't insert anything).
private func cycleMode() {
let order = ["off", "transcribe", "polish"]
let current = order.firstIndex(of: config.modeId) ?? 0
let next = order[(current + 1) % order.count]
config.modeId = next
if next == "off" && asr.phase == .recording {
asr.stop()
}
}
/// Cycle the locale on tap of the locale chip. Same order as
/// `staticLocales` in `SettingsView.swift` so both surfaces stay
/// in sync. When the user picks a new locale we *also* stop any
/// in-flight recording ASR sessions are bound to the locale
/// they were started with, and continuing to feed buffers into a
/// stale session would produce garbage in the next `.final`.
private func cycleLocale() {
let order = ["auto", "zh-Hans", "zh-Hant", "en-US", "ja-JP", "ko-KR"]
let current = order.firstIndex(of: config.localeId) ?? 0
let next = order[(current + 1) % order.count]
config.localeId = next
if asr.phase == .recording {
asr.stop()
}
}
private func resolveLocale(_ id: String) -> Locale {
if id == "auto" { return .current }
return Locale(identifier: id)
+91 -20
View File
@@ -18,10 +18,26 @@ struct KeyboardPreviewStub: View {
let phase: Phase
let level: Double
let transcript: String
/// Current input mode id (`off` / `transcribe` / `polish`). Shown in
/// the mode chip in the top bar. The chip is wired to `onModeCycle`,
/// so tapping it actually advances the mode and the label updates
/// in real time the previous version was a static decoration and
/// the user couldn't tell the chip was even a button.
let modeId: String
/// Current locale id (`auto` / `zh-Hans` / `en-US` / ). Shown in
/// the locale chip. Same lifecycle as `modeId`.
let localeId: String
/// Called when the user taps the record disc. Use this to cycle states in the preview sheet.
var onTap: () -> Void = {}
/// Called when the user taps the settings gear icon.
var openSettings: () -> Void = {}
/// Cycle to the next mode in `[off, transcribe, polish]`. Owned by
/// the sheet so `ProviderConfig` (a shared model) stays the source
/// of truth the stub just renders whatever it's told.
var onModeCycle: () -> Void = {}
/// Cycle to the next locale in the supported list. Same ownership
/// story as `onModeCycle`.
var onLocaleCycle: () -> Void = {}
var body: some View {
ZStack(alignment: .top) {
@@ -59,29 +75,78 @@ struct KeyboardPreviewStub: View {
}
private var modeChip: some View {
HStack(spacing: 4) {
Image(systemName: "wand.and.stars")
Text("mode.polish")
Image(systemName: "chevron.down").font(.system(size: 8, weight: .bold))
Button(action: onModeCycle) {
HStack(spacing: 4) {
Image(systemName: modeIconName)
Text(modeChipLabel)
Image(systemName: "chevron.down").font(.system(size: 8, weight: .bold))
}
.font(TypeStyle.caption2)
.foregroundStyle(palette.textPrimary)
.padding(.horizontal, Spacing.xs + 2).padding(.vertical, 4)
.background(palette.surfaceElevated, in: Capsule())
.overlay(Capsule().stroke(palette.divider, lineWidth: 0.5))
}
.font(TypeStyle.caption2)
.foregroundStyle(palette.textPrimary)
.padding(.horizontal, Spacing.xs + 2).padding(.vertical, 4)
.background(palette.surfaceElevated, in: Capsule())
.overlay(Capsule().stroke(palette.divider, lineWidth: 0.5))
.buttonStyle(.plain)
.accessibilityLabel(Text("preview.modeChip.cycle"))
}
private var localeChip: some View {
HStack(spacing: 4) {
Image(systemName: "globe")
Text("locale.zh-Hans")
Image(systemName: "chevron.down").font(.system(size: 8, weight: .bold))
Button(action: onLocaleCycle) {
HStack(spacing: 4) {
Image(systemName: "globe")
Text(localeChipLabel)
Image(systemName: "chevron.down").font(.system(size: 8, weight: .bold))
}
.font(TypeStyle.caption2)
.foregroundStyle(palette.textPrimary)
.padding(.horizontal, Spacing.xs + 2).padding(.vertical, 4)
.background(palette.surfaceElevated, in: Capsule())
.overlay(Capsule().stroke(palette.divider, lineWidth: 0.5))
}
.buttonStyle(.plain)
.accessibilityLabel(Text("preview.localeChip.cycle"))
}
/// Display label for the mode chip. The mode ids are
/// `off` / `transcribe` / `polish`; we show a user-facing label
/// from `Localizable.strings` and fall back to the raw id if a
/// translation is missing (shouldn't happen, but cheaper than
/// crashing in the preview).
private var modeChipLabel: LocalizedStringKey {
switch modeId {
case "off": return "settings.mode.off"
case "transcribe": return "settings.mode.transcribe"
case "polish": return "settings.mode.polish"
default: return LocalizedStringKey(modeId)
}
}
/// Display label for the locale chip. Cycles through the static
/// locale list the Settings view also uses see `staticLocales` in
/// `SettingsView.swift`. We keep the list inline here so the
/// preview doesn't need a settings dependency.
private var localeChipLabel: LocalizedStringKey {
switch localeId {
case "auto": return "locale.auto"
case "zh-Hans": return "locale.zh-Hans"
case "zh-Hant": return "locale.zh-Hant"
case "en-US": return "locale.en-US"
case "ja-JP": return "locale.ja-JP"
case "ko-KR": return "locale.ko-KR"
default: return LocalizedStringKey(localeId)
}
}
/// Icon follows the mode so the user has a second visual cue
/// beyond the label off=slash, transcribe=mic, polish=wand.
private var modeIconName: String {
switch modeId {
case "off": return "mic.slash.fill"
case "transcribe": return "mic.fill"
case "polish": return "wand.and.stars"
default: return "wand.and.stars"
}
.font(TypeStyle.caption2)
.foregroundStyle(palette.textPrimary)
.padding(.horizontal, Spacing.xs + 2).padding(.vertical, 4)
.background(palette.surfaceElevated, in: Capsule())
.overlay(Capsule().stroke(palette.divider, lineWidth: 0.5))
}
private var statusBadge: some View {
@@ -267,7 +332,13 @@ struct KeyboardPreviewStub: View {
#if DEBUG
#Preview {
KeyboardPreviewStub(phase: .idle, level: 0, transcript: "")
.preferredColorScheme(.dark)
KeyboardPreviewStub(
phase: .idle,
level: 0,
transcript: "",
modeId: "polish",
localeId: "zh-Hans"
)
.preferredColorScheme(.dark)
}
#endif
+2
View File
@@ -109,6 +109,8 @@
"preview.placeholder" = "Type or tap to record";
"preview.clear" = "Clear text";
"preview.openSettingsA11y" = "Open OSGKeyboard settings";
"preview.modeChip.cycle" = "Cycle input mode";
"preview.localeChip.cycle" = "Cycle recognition language";
/* Keyboard (ext) */
"keyboard.placeholder.idle" = "Hold to talk";
@@ -109,6 +109,8 @@
"preview.placeholder" = "试着输入或按 disc 录音";
"preview.clear" = "清空";
"preview.openSettingsA11y" = "打开 OSGKeyboard 设置";
"preview.modeChip.cycle" = "切换输入模式";
"preview.localeChip.cycle" = "切换识别语言";
/* Keyboard (ext) */
"keyboard.placeholder.idle" = "按住说话";