feat(translation-ui): tighten settings/onboarding UX around the translation feature

UI refinements on top of the translation pipeline (feature/translation@HEAD):

1. Onboarding engine page now hosts a translation row.
   APISetupPage renders the same TranslationPickerRow used in the
   language tab, so first-time users can pick a target language
   before they ever see the keyboard. Same persisted bindings; same
   'needs cloud' hint when the local engine is active.

2. Local engine hides the provider / API card unconditionally.
   Removed the 'local + cloud polish on → show API fields' branch
   from SettingsView. Provider/base URL/API key/model controls have
   no use in local mode (translation is cloud-only anyway), and
   exposing them invited users to fill in a DeepSeek key they
   can't use.

3. 'Cloud polish after ASR' toggle loses its long subtitle.
   The descriptive copy in LocalEngineSettingsRows.cloudPolishRow
   was a wall of text that explained things visible elsewhere in
   Settings. Title + switch is enough; the CloudPolishDisclosureBanner
   (rendered by EnginePickerSection when cloud is active) already
   covers the 'this sends text to your API' disclosure.

4. Translation row becomes a single dropdown with a 'Don't
   translate' default.
   TranslationPickerRow replaced with a one-row Menu picker:
   '不翻译 / English / 中文 (简体) / 中文 (繁體) / 日本語 / 한국어 /
   Français / Deutsch / Español / Русский / Português'.
   '不翻译' maps to translationEnabled=false; any locale maps to
   translationEnabled=true + translationTargetLocaleId=<id>.
   TranslationLanguageCatalog gains an 'off' sentinel so the picker's
   single binding stays a plain String.

5. Language tab reorder.
   SettingsView.languageAndModelsSection: ASR locale ('识别语言')
   now sits above the local-models block; translation row sits at
   the bottom. The reading order follows the pipeline direction
   (input → post-processing → post-post-processing).

Localization:
  - 'settings.translation.title' → '翻译' / 'Translation'
  - new 'settings.translation.off' / 'settings.translation.hint.needsCloud'
  - dropped unused subtitle / target-language keys

xcodebuild scheme=OSGKeyboard config=Debug destination=iPhone 17
Simulator: BUILD SUCCEEDED (0 warning, 0 error).
This commit is contained in:
zhongshu
2026-06-25 13:23:54 +08:00
parent deddb49d56
commit 4d92999347
7 changed files with 156 additions and 117 deletions
+12 -16
View File
@@ -57,25 +57,21 @@ struct LocalModelsGroup: View {
/// itself is always live (the user can flip it without having a /// itself is always live (the user can flip it without having a
/// key yet), but the polish call short-circuits with an Alert if /// key yet), but the polish call short-circuits with an Alert if
/// the Keychain is empty when it fires. /// the Keychain is empty when it fires.
///
/// v0.2.1: dropped the long descriptive subtitle it explained
/// things the user could read about elsewhere in Settings
/// (provider / API key section) and made the row visually heavy.
/// Title + switch is enough; details live in `CloudPolishDisclosureBanner`.
private var cloudPolishRow: some View { private var cloudPolishRow: some View {
VStack(alignment: .leading, spacing: Spacing.xs) { Toggle(isOn: $config.localModeCloudPolishEnabled) {
Toggle(isOn: $config.localModeCloudPolishEnabled) { Text("settings.localModels.cloudPolish.title")
VStack(alignment: .leading, spacing: 2) { .font(TypeStyle.body)
Text("settings.localModels.cloudPolish.title") .foregroundStyle(palette.textPrimary)
.font(TypeStyle.body)
.foregroundStyle(palette.textPrimary)
Text("settings.localModels.cloudPolish.subtitle")
.font(TypeStyle.caption2)
.foregroundStyle(palette.textTertiary)
.fixedSize(horizontal: false, vertical: true)
}
}
.toggleStyle(.switch)
.tint(palette.accent)
} }
.toggleStyle(.switch)
.tint(palette.accent)
.padding(.horizontal, Spacing.md) .padding(.horizontal, Spacing.md)
.padding(.vertical, Spacing.sm) .frame(minHeight: SettingsListMetrics.singleLineMinHeight)
.frame(minHeight: SettingsListMetrics.doubleLineMinHeight)
} }
// MARK: Helpers // MARK: Helpers
+28
View File
@@ -756,8 +756,36 @@ private struct APISetupPage: View {
} }
.padding(.horizontal, Spacing.lg) .padding(.horizontal, Spacing.lg)
} }
// v0.2.1: translation row lives on the onboarding engine
// page so first-time users can pick a target language
// before they ever see the keyboard. We render the same
// `TranslationPickerRow` used in the language tab same
// persisted bindings, same "" hint when local is
// active wrapped in the section's surface card so it
// sits flush with the engine / provider cards above.
translationSection
.padding(.horizontal, Spacing.lg)
} }
.padding(.bottom, Spacing.xxxl) .padding(.bottom, Spacing.xxxl)
} }
} }
private var translationSection: some View {
VStack(alignment: .leading, spacing: SettingsListMetrics.sectionLabelSpacing) {
Text("settings.translation.title")
.font(TypeStyle.caption2)
.foregroundStyle(palette.textSecondary)
.textCase(.uppercase)
.frame(maxWidth: .infinity, alignment: .leading)
VStack(spacing: 0) {
TranslationPickerRow(config: config)
}
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
.stroke(palette.divider, lineWidth: 0.5)
)
}
}
} }
+17 -17
View File
@@ -62,17 +62,16 @@ struct SettingsView: View {
VStack(spacing: Spacing.md) { VStack(spacing: Spacing.md) {
appLanguageSection appLanguageSection
engineSection engineSection
// v0.2.1: hide provider/api card when the
// local engine is active regardless of the
// cloud-polish toggle. Local mode is
// contractually ASR-only, so provider/model/
// base URL/API key controls have no use
// and exposing them invites the user to fill
// out a DeepSeek key they can't use.
if config.engineMode == "cloud" { if config.engineMode == "cloud" {
providerSection providerSection
apiSection apiSection
} else if config.localModeCloudPolishEnabled {
// v0.2.0: local engine + cloud polish on.
// Surface the provider / API key fields so
// the user can fill in their DeepSeek key.
// We hide them when the toggle is off so the
// local engine stays genuinely local.
providerSection
apiSection
} }
languageAndModelsSection languageAndModelsSection
if config.engineMode == "cloud" { if config.engineMode == "cloud" {
@@ -128,10 +127,12 @@ struct SettingsView: View {
VStack(alignment: .leading, spacing: SettingsListMetrics.sectionLabelSpacing) { VStack(alignment: .leading, spacing: SettingsListMetrics.sectionLabelSpacing) {
sectionHeader("settings.language.title") sectionHeader("settings.language.title")
VStack(spacing: 0) { VStack(spacing: 0) {
if config.engineMode == "local" { // v0.2.1: language tab reorder ASR locale ("")
LocalModelsGroup(config: config) // now sits above the cloud-polish toggle / local models
Divider().background(palette.divider) // block so the row that maps to microphone input comes
} // first, the row that maps to post-processing comes
// second, and translation (post-post-processing) sits at
// the bottom.
LocalePickerRow( LocalePickerRow(
locales: effectiveLocales, locales: effectiveLocales,
selection: Binding( selection: Binding(
@@ -139,11 +140,10 @@ struct SettingsView: View {
set: { config.localeId = $0 } set: { config.localeId = $0 }
) )
) )
// v0.2.1: translation toggle + target-language picker. if config.engineMode == "local" {
// Sits at the bottom of the language section so the user Divider().background(palette.divider)
// finds it next to the ASR locale it complements. Reuses LocalModelsGroup(config: config)
// `config.translationEnabled` / `config.translationTargetLocaleId` }
// bindings no new state, no new persistence path.
Divider().background(palette.divider) Divider().background(palette.divider)
TranslationPickerRow(config: config) TranslationPickerRow(config: config)
} }
+68 -70
View File
@@ -1,22 +1,23 @@
// TranslationPickerRow.swift // TranslationPickerRow.swift
// OSGKeyboard · Main App // OSGKeyboard · Main App
// //
// List row that hosts the translation toggle + target-language picker. // Single-row "" picker replaces the previous two-row toggle +
// Lives at the bottom of the language tab in Settings (see // target-locale dropdown. Lets the user pick "" (off, the
// `SettingsView.languageAndModelsSection`) so it sits right next to // default) or one of the 10 target languages, all from a single
// the existing ASR locale picker same picker family, same row // `Menu`.
// metrics.
// //
// Layout: // Mapping to persisted state:
// First row switch (label on the left, switch on the right) // "" translationEnabled = false
// When on a second row with a Menu picker for the target // any specific locale translationEnabled = true, translationTargetLocaleId = <id>
// language. Disabled when the local engine is active so the user
// immediately sees why the picker is greyed out (instead of picking
// a target that the pipeline silently ignores).
// //
// Reuses the host app's `ProviderConfig` `translationEnabled` / // The local engine constraint ("translation is cloud-only") is handled
// `translationTargetLocaleId` bindings no new state, no new // in two places that read this row:
// persistence path. // The picker greys out specific locales (and prepends a ""
// hint) when `config.isLocalEngine` the user can still pick
// something but it won't fire end-to-end until they switch engines.
// The pipeline (`PolishingService`) rejects `.translate` on local
// engine with `translationNotAvailable`, which `KeyboardViewController`
// surfaces as a 2.4s toast. Belt + suspenders.
import SwiftUI import SwiftUI
import OSGKeyboardShared import OSGKeyboardShared
@@ -26,90 +27,87 @@ struct TranslationPickerRow: View {
@ObservedObject var config: ProviderConfig @ObservedObject var config: ProviderConfig
var body: some View { var body: some View {
VStack(spacing: 0) {
toggleRow
if config.translationEnabled {
Divider().background(palette.divider)
targetRow
}
}
}
private var toggleRow: some View {
HStack { HStack {
VStack(alignment: .leading, spacing: 2) { Text("settings.translation.title")
Text("settings.translation.title")
.font(TypeStyle.body)
.foregroundStyle(palette.textPrimary)
Text(subtitleKey)
.font(TypeStyle.caption2)
.foregroundStyle(palette.textSecondary)
}
Spacer()
Toggle(
"",
isOn: Binding(
get: { config.translationEnabled },
set: { config.translationEnabled = $0 }
)
)
.labelsHidden()
.tint(palette.accent)
}
.padding(.horizontal, Spacing.md)
.frame(minHeight: SettingsListMetrics.singleLineMinHeight)
}
/// Subtitle explains why the toggle is functionally inert when the
/// local engine is on. We still let the user flip the toggle in
/// that case so their preference is saved the moment they switch
/// back to cloud, translation just works.
private var subtitleKey: LocalizedStringKey {
config.isLocalEngine
? "settings.translation.subtitle.needsCloud"
: "settings.translation.subtitle"
}
private var targetRow: some View {
HStack {
Text("settings.translation.target")
.font(TypeStyle.body) .font(TypeStyle.body)
.foregroundStyle(palette.textPrimary) .foregroundStyle(palette.textPrimary)
Spacer() Spacer()
Menu { Menu {
ForEach(TranslationLanguageCatalog.all) { language in ForEach(TranslationLanguageCatalog.all) { language in
Button { Button {
config.translationTargetLocaleId = language.id apply(language)
} label: { } label: {
if language.id == config.translationTargetLocaleId { if currentSelectionId == language.id {
Label(language.nativeName, systemImage: "checkmark") Label(displayLabel(for: language), systemImage: "checkmark")
} else { } else {
Text(language.nativeName) Text(displayLabel(for: language))
} }
} }
} }
} label: { } label: {
HStack(spacing: 6) { HStack(spacing: 6) {
Text(currentTargetName) Text(currentLabel)
.font(TypeStyle.body) .font(TypeStyle.body)
.foregroundStyle(pickerForeground) .foregroundStyle(pickerForeground)
if config.isLocalEngine && !currentIsOff {
// Inline hint so the user knows the picker
// selection is being held but won't fire on the
// local engine.
Text("settings.translation.hint.needsCloud")
.font(TypeStyle.caption2)
.foregroundStyle(palette.warning)
}
Image(systemName: "chevron.up.chevron.down") Image(systemName: "chevron.up.chevron.down")
.font(.system(size: 11, weight: .bold)) .font(.system(size: 11, weight: .bold))
.foregroundStyle(palette.textTertiary) .foregroundStyle(palette.textTertiary)
} }
} }
.disabled(config.isLocalEngine)
.opacity(config.isLocalEngine ? 0.5 : 1.0)
} }
.padding(.horizontal, Spacing.md) .padding(.horizontal, Spacing.md)
.frame(minHeight: SettingsListMetrics.singleLineMinHeight) .frame(minHeight: SettingsListMetrics.singleLineMinHeight)
} }
private var currentTargetName: String { // MARK: - Selection plumbing
TranslationLanguageCatalog.resolve(config.translationTargetLocaleId).nativeName
/// Currently selected id derived from `translationEnabled`. We
/// route everything through the `translationEnabled` boolean so the
/// picker stays in lock-step with the rest of the system (chip,
/// pipeline, `isTranslationEffective`).
private var currentSelectionId: String {
config.translationEnabled
? config.translationTargetLocaleId
: TranslationLanguageCatalog.offLocaleId
}
private var currentLabel: String {
displayLabel(for: TranslationLanguageCatalog.resolve(currentSelectionId))
}
private var currentIsOff: Bool {
TranslationLanguageCatalog.isOff(currentSelectionId)
}
private func displayLabel(for language: TranslationLanguage) -> String {
if language.id == TranslationLanguageCatalog.offLocaleId {
return AppL10n.string("settings.translation.off")
}
return language.nativeName
} }
private var pickerForeground: Color { private var pickerForeground: Color {
config.isLocalEngine ? palette.textTertiary : palette.textSecondary currentIsOff ? palette.textSecondary : palette.textPrimary
}
/// Translates a picker choice into the underlying
/// `translationEnabled` + `translationTargetLocaleId` pair. Picking
/// "" clears the toggle; any locale flips it on and stores
/// the id.
private func apply(_ language: TranslationLanguage) {
if language.id == TranslationLanguageCatalog.offLocaleId {
config.translationEnabled = false
return
}
config.translationEnabled = true
config.translationTargetLocaleId = language.id
} }
} }
+3 -4
View File
@@ -109,10 +109,9 @@
"settings.api.title" = "API"; "settings.api.title" = "API";
"settings.language.title" = "Language"; "settings.language.title" = "Language";
// v0.2.1: translation feature // v0.2.1: translation feature
"settings.translation.title" = "Translate after dictation"; "settings.translation.title" = "Translation";
"settings.translation.subtitle" = "Send the transcript to your LLM with a translate-and-polish prompt before inserting."; "settings.translation.off" = "Don't translate";
"settings.translation.subtitle.needsCloud" = "Requires the cloud engine — currently disabled."; "settings.translation.hint.needsCloud" = "Needs cloud";
"settings.translation.target" = "Target language";
"settings.languageModels.title" = "Language & models"; "settings.languageModels.title" = "Language & models";
"settings.localModels.title" = "On-device models"; "settings.localModels.title" = "On-device models";
"settings.localModels.speechRole" = "Speech"; "settings.localModels.speechRole" = "Speech";
@@ -109,10 +109,9 @@
"settings.api.title" = "接口"; "settings.api.title" = "接口";
"settings.language.title" = "语言"; "settings.language.title" = "语言";
// v0.2.1: 翻译功能 // v0.2.1: 翻译功能
"settings.translation.title" = "语音转文字后翻译"; "settings.translation.title" = "翻译";
"settings.translation.subtitle" = "将转写文本发送给已配置的 LLM,使用翻译+润色 prompt 处理后插入。"; "settings.translation.off" = "不翻译";
"settings.translation.subtitle.needsCloud" = "需开启云端引擎,当前为本地模式暂不生效。"; "settings.translation.hint.needsCloud" = "需云端";
"settings.translation.target" = "目标语言";
"settings.languageModels.title" = "语言与模型"; "settings.languageModels.title" = "语言与模型";
"settings.localModels.title" = "本地模型"; "settings.localModels.title" = "本地模型";
"settings.localModels.speechRole" = "语音识别"; "settings.localModels.speechRole" = "语音识别";
@@ -28,12 +28,22 @@ public struct TranslationLanguage: Identifiable, Hashable, Sendable {
} }
public enum TranslationLanguageCatalog { public enum TranslationLanguageCatalog {
/// Default target language id used on fresh installs. /// Sentinel id for "don't translate" the default selection in the
/// picker. Picked over an `Optional<TranslationLanguage>` so the
/// single-row `Picker` binding stays a plain `String` (and the same
/// code path also works for the `TranslationChip` Menu).
public static let offLocaleId = "off"
/// Default target language id used on fresh installs when translation
/// is enabled. The picker still defaults to `offLocaleId` this is
/// only the language we'd fall back to if a stale "on" state is
/// recovered without a remembered target.
public static let defaultLocaleId = "en" public static let defaultLocaleId = "en"
/// Curated set. Order matters the picker / chip render top-to- /// Curated set. Order matters the picker / chip render top-to-
/// bottom, and `defaultLocaleId` is the default selection. /// bottom, with `offLocaleId` ("") at the very top so the
/// "turn off" action is one tap away from any enabled state.
public static let all: [TranslationLanguage] = [ public static let all: [TranslationLanguage] = [
TranslationLanguage(id: offLocaleId, promptLanguageName: "", nativeName: ""),
TranslationLanguage(id: "en", promptLanguageName: "English", nativeName: "English"), TranslationLanguage(id: "en", promptLanguageName: "English", nativeName: "English"),
TranslationLanguage(id: "zh-Hans", promptLanguageName: "Simplified Chinese", nativeName: "简体中文"), TranslationLanguage(id: "zh-Hans", promptLanguageName: "Simplified Chinese", nativeName: "简体中文"),
TranslationLanguage(id: "zh-Hant", promptLanguageName: "Traditional Chinese", nativeName: "繁體中文"), TranslationLanguage(id: "zh-Hant", promptLanguageName: "Traditional Chinese", nativeName: "繁體中文"),
@@ -46,14 +56,23 @@ public enum TranslationLanguageCatalog {
TranslationLanguage(id: "pt", promptLanguageName: "Portuguese", nativeName: "Português"), TranslationLanguage(id: "pt", promptLanguageName: "Portuguese", nativeName: "Português"),
] ]
/// True when the given id is the "off" sentinel. Used by the picker
/// to flip `translationEnabled` and by the pipeline to skip the
/// translate prompt.
public static func isOff(_ id: String) -> Bool {
id == offLocaleId
}
/// Resolve a stored locale id to its catalog entry. Falls back to /// Resolve a stored locale id to its catalog entry. Falls back to
/// `defaultLocaleId` when the id is missing or unknown matches the /// `offLocaleId` (the picker default) when the id is missing or
/// pattern used elsewhere (e.g. `ASRLocaleLabels`) so the keyboard /// unknown matches the pattern used elsewhere (e.g.
/// never crashes on a stale persisted value. /// `ASRLocaleLabels`) so the keyboard never crashes on a stale
/// persisted value, and the picker lands on the safe "off" state
/// instead of an arbitrary language.
public static func resolve(_ id: String) -> TranslationLanguage { public static func resolve(_ id: String) -> TranslationLanguage {
if let match = all.first(where: { $0.id == id }) { if let match = all.first(where: { $0.id == id }) {
return match return match
} }
return all.first { $0.id == defaultLocaleId } ?? all[0] return all.first { $0.id == offLocaleId } ?? all[0]
} }
} }