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:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" = "按住说话";
|
||||
|
||||
Reference in New Issue
Block a user