From 4d92999347d35f47deae7823024a5fe176cacf9f Mon Sep 17 00:00:00 2001 From: zhongshu Date: Thu, 25 Jun 2026 13:23:54 +0800 Subject: [PATCH] feat(translation-ui): tighten settings/onboarding UX around the translation feature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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=. 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). --- .../Views/LocalEngineSettingsRows.swift | 28 ++-- OSGKeyboard/Views/OnboardingView.swift | 28 ++++ OSGKeyboard/Views/SettingsView.swift | 34 ++--- OSGKeyboard/Views/TranslationPickerRow.swift | 138 +++++++++--------- OSGKeyboard/en.lproj/Localizable.strings | 7 +- OSGKeyboard/zh-Hans.lproj/Localizable.strings | 7 +- .../Models/TranslationLanguage.swift | 31 +++- 7 files changed, 156 insertions(+), 117 deletions(-) diff --git a/OSGKeyboard/Views/LocalEngineSettingsRows.swift b/OSGKeyboard/Views/LocalEngineSettingsRows.swift index 6de8120..f5632e1 100644 --- a/OSGKeyboard/Views/LocalEngineSettingsRows.swift +++ b/OSGKeyboard/Views/LocalEngineSettingsRows.swift @@ -57,25 +57,21 @@ struct LocalModelsGroup: View { /// itself is always live (the user can flip it without having a /// key yet), but the polish call short-circuits with an Alert if /// 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 { - VStack(alignment: .leading, spacing: Spacing.xs) { - Toggle(isOn: $config.localModeCloudPolishEnabled) { - VStack(alignment: .leading, spacing: 2) { - Text("settings.localModels.cloudPolish.title") - .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) + Toggle(isOn: $config.localModeCloudPolishEnabled) { + Text("settings.localModels.cloudPolish.title") + .font(TypeStyle.body) + .foregroundStyle(palette.textPrimary) } + .toggleStyle(.switch) + .tint(palette.accent) .padding(.horizontal, Spacing.md) - .padding(.vertical, Spacing.sm) - .frame(minHeight: SettingsListMetrics.doubleLineMinHeight) + .frame(minHeight: SettingsListMetrics.singleLineMinHeight) } // MARK: Helpers diff --git a/OSGKeyboard/Views/OnboardingView.swift b/OSGKeyboard/Views/OnboardingView.swift index 4352e65..3c8b783 100644 --- a/OSGKeyboard/Views/OnboardingView.swift +++ b/OSGKeyboard/Views/OnboardingView.swift @@ -756,8 +756,36 @@ private struct APISetupPage: View { } .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) } } + + 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) + ) + } + } } diff --git a/OSGKeyboard/Views/SettingsView.swift b/OSGKeyboard/Views/SettingsView.swift index d208b93..30304e0 100644 --- a/OSGKeyboard/Views/SettingsView.swift +++ b/OSGKeyboard/Views/SettingsView.swift @@ -62,17 +62,16 @@ struct SettingsView: View { VStack(spacing: Spacing.md) { appLanguageSection 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" { providerSection 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 if config.engineMode == "cloud" { @@ -128,10 +127,12 @@ struct SettingsView: View { VStack(alignment: .leading, spacing: SettingsListMetrics.sectionLabelSpacing) { sectionHeader("settings.language.title") VStack(spacing: 0) { - if config.engineMode == "local" { - LocalModelsGroup(config: config) - Divider().background(palette.divider) - } + // v0.2.1: language tab reorder — ASR locale ("识别语言") + // now sits above the cloud-polish toggle / local models + // 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( locales: effectiveLocales, selection: Binding( @@ -139,11 +140,10 @@ struct SettingsView: View { set: { config.localeId = $0 } ) ) - // v0.2.1: translation toggle + target-language picker. - // Sits at the bottom of the language section so the user - // finds it next to the ASR locale it complements. Reuses - // `config.translationEnabled` / `config.translationTargetLocaleId` - // bindings — no new state, no new persistence path. + if config.engineMode == "local" { + Divider().background(palette.divider) + LocalModelsGroup(config: config) + } Divider().background(palette.divider) TranslationPickerRow(config: config) } diff --git a/OSGKeyboard/Views/TranslationPickerRow.swift b/OSGKeyboard/Views/TranslationPickerRow.swift index d503d57..16914e5 100644 --- a/OSGKeyboard/Views/TranslationPickerRow.swift +++ b/OSGKeyboard/Views/TranslationPickerRow.swift @@ -1,22 +1,23 @@ // TranslationPickerRow.swift // OSGKeyboard · Main App // -// List row that hosts the translation toggle + target-language picker. -// Lives at the bottom of the language tab in Settings (see -// `SettingsView.languageAndModelsSection`) so it sits right next to -// the existing ASR locale picker — same picker family, same row -// metrics. +// Single-row "翻译" picker — replaces the previous two-row toggle + +// target-locale dropdown. Lets the user pick "不翻译" (off, the +// default) or one of the 10 target languages, all from a single +// `Menu`. // -// Layout: -// • First row → switch (label on the left, switch on the right) -// • When on → a second row with a Menu picker for the target -// 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). +// Mapping to persisted state: +// • "不翻译" → translationEnabled = false +// • any specific locale → translationEnabled = true, translationTargetLocaleId = // -// Reuses the host app's `ProviderConfig` `translationEnabled` / -// `translationTargetLocaleId` bindings — no new state, no new -// persistence path. +// The local engine constraint ("translation is cloud-only") is handled +// in two places that read this row: +// • 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 OSGKeyboardShared @@ -26,90 +27,87 @@ struct TranslationPickerRow: View { @ObservedObject var config: ProviderConfig var body: some View { - VStack(spacing: 0) { - toggleRow - if config.translationEnabled { - Divider().background(palette.divider) - targetRow - } - } - } - - private var toggleRow: some View { HStack { - VStack(alignment: .leading, spacing: 2) { - 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") + Text("settings.translation.title") .font(TypeStyle.body) .foregroundStyle(palette.textPrimary) Spacer() Menu { ForEach(TranslationLanguageCatalog.all) { language in Button { - config.translationTargetLocaleId = language.id + apply(language) } label: { - if language.id == config.translationTargetLocaleId { - Label(language.nativeName, systemImage: "checkmark") + if currentSelectionId == language.id { + Label(displayLabel(for: language), systemImage: "checkmark") } else { - Text(language.nativeName) + Text(displayLabel(for: language)) } } } } label: { HStack(spacing: 6) { - Text(currentTargetName) + Text(currentLabel) .font(TypeStyle.body) .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") .font(.system(size: 11, weight: .bold)) .foregroundStyle(palette.textTertiary) } } - .disabled(config.isLocalEngine) - .opacity(config.isLocalEngine ? 0.5 : 1.0) } .padding(.horizontal, Spacing.md) .frame(minHeight: SettingsListMetrics.singleLineMinHeight) } - private var currentTargetName: String { - TranslationLanguageCatalog.resolve(config.translationTargetLocaleId).nativeName + // MARK: - Selection plumbing + + /// 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 { - 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 } } \ No newline at end of file diff --git a/OSGKeyboard/en.lproj/Localizable.strings b/OSGKeyboard/en.lproj/Localizable.strings index 8834ae7..27fd635 100644 --- a/OSGKeyboard/en.lproj/Localizable.strings +++ b/OSGKeyboard/en.lproj/Localizable.strings @@ -109,10 +109,9 @@ "settings.api.title" = "API"; "settings.language.title" = "Language"; // v0.2.1: translation feature -"settings.translation.title" = "Translate after dictation"; -"settings.translation.subtitle" = "Send the transcript to your LLM with a translate-and-polish prompt before inserting."; -"settings.translation.subtitle.needsCloud" = "Requires the cloud engine — currently disabled."; -"settings.translation.target" = "Target language"; +"settings.translation.title" = "Translation"; +"settings.translation.off" = "Don't translate"; +"settings.translation.hint.needsCloud" = "Needs cloud"; "settings.languageModels.title" = "Language & models"; "settings.localModels.title" = "On-device models"; "settings.localModels.speechRole" = "Speech"; diff --git a/OSGKeyboard/zh-Hans.lproj/Localizable.strings b/OSGKeyboard/zh-Hans.lproj/Localizable.strings index ef69f14..bb70274 100644 --- a/OSGKeyboard/zh-Hans.lproj/Localizable.strings +++ b/OSGKeyboard/zh-Hans.lproj/Localizable.strings @@ -109,10 +109,9 @@ "settings.api.title" = "接口"; "settings.language.title" = "语言"; // v0.2.1: 翻译功能 -"settings.translation.title" = "语音转文字后翻译"; -"settings.translation.subtitle" = "将转写文本发送给已配置的 LLM,使用翻译+润色 prompt 处理后插入。"; -"settings.translation.subtitle.needsCloud" = "需开启云端引擎,当前为本地模式暂不生效。"; -"settings.translation.target" = "目标语言"; +"settings.translation.title" = "翻译"; +"settings.translation.off" = "不翻译"; +"settings.translation.hint.needsCloud" = "需云端"; "settings.languageModels.title" = "语言与模型"; "settings.localModels.title" = "本地模型"; "settings.localModels.speechRole" = "语音识别"; diff --git a/OSGKeyboardShared/Models/TranslationLanguage.swift b/OSGKeyboardShared/Models/TranslationLanguage.swift index 1380c21..6768415 100644 --- a/OSGKeyboardShared/Models/TranslationLanguage.swift +++ b/OSGKeyboardShared/Models/TranslationLanguage.swift @@ -28,12 +28,22 @@ public struct TranslationLanguage: Identifiable, Hashable, Sendable { } 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` 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" /// 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] = [ + TranslationLanguage(id: offLocaleId, promptLanguageName: "", nativeName: ""), TranslationLanguage(id: "en", promptLanguageName: "English", nativeName: "English"), TranslationLanguage(id: "zh-Hans", promptLanguageName: "Simplified 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"), ] + /// 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 - /// `defaultLocaleId` when the id is missing or unknown — matches the - /// pattern used elsewhere (e.g. `ASRLocaleLabels`) so the keyboard - /// never crashes on a stale persisted value. + /// `offLocaleId` (the picker default) when the id is missing or + /// unknown — matches the pattern used elsewhere (e.g. + /// `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 { if let match = all.first(where: { $0.id == id }) { return match } - return all.first { $0.id == defaultLocaleId } ?? all[0] + return all.first { $0.id == offLocaleId } ?? all[0] } } \ No newline at end of file