From 9b759281ccbeaf8d9a2182ed9991c2c5840b6aad Mon Sep 17 00:00:00 2001 From: Rocky Date: Thu, 25 Jun 2026 13:52:13 +0800 Subject: [PATCH] feat(translation-v2): refactor translationEnabled into computed + onboarding row visibility --- OSGKeyboard/Views/OnboardingView.swift | 37 ++++-- OSGKeyboard/Views/SettingsView.swift | 4 +- OSGKeyboard/Views/TranslationPickerRow.swift | 116 ++++++++---------- OSGKeyboard/en.lproj/Localizable.strings | 1 - OSGKeyboard/zh-Hans.lproj/Localizable.strings | 1 - OSGKeyboardExt/KeyboardViewController.swift | 23 ++-- .../Services/AppGroupPersistor.swift | 32 +++-- OSGKeyboardExt/Views/TranslationChip.swift | 61 +++++---- OSGKeyboardShared/Models/ProviderConfig.swift | 62 +++++++--- .../Services/AppGroupStore.swift | 47 ++++--- .../Services/KeyboardState.swift | 22 ++-- 11 files changed, 226 insertions(+), 180 deletions(-) diff --git a/OSGKeyboard/Views/OnboardingView.swift b/OSGKeyboard/Views/OnboardingView.swift index 3c8b783..48bd139 100644 --- a/OSGKeyboard/Views/OnboardingView.swift +++ b/OSGKeyboard/Views/OnboardingView.swift @@ -734,6 +734,18 @@ private struct APISetupPage: View { .padding(.horizontal, Spacing.lg) APISettingsCard(config: config) .padding(.horizontal, Spacing.lg) + // v0.2.1 follow-up: translation row lives on the + // onboarding engine page so first-time users can + // pick a target language before they ever see the + // keyboard. Wrapped in the section's surface card + // so it sits flush with the engine / provider cards + // above; visibility gated by `isTranslationRowVisible` + // so the local engine only shows it when cloud + // polish is also enabled. + if config.isTranslationRowVisible { + translationSection + .padding(.horizontal, Spacing.lg) + } } else { // v0.2.0: local engine is iOS `SpeechAnalyzer` only. // Surface the cloud-polish toggle and a one-line @@ -755,22 +767,25 @@ private struct APISetupPage: View { ) } .padding(.horizontal, Spacing.lg) + // v0.2.1 follow-up: same conditional for the local + // branch — the row only renders when the engine + // can actually run the cloud translate-and-polish + // step (i.e. cloud polish is opted in). + if config.isTranslationRowVisible { + translationSection + .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) } } + /// v0.2.1 follow-up: extracted so both engine branches can render + /// the same surface card + picker. `TranslationPickerRow` itself + /// reads `ProviderConfig.translationTargetLocaleId` directly, so + /// picking a locale in onboarding flows through to the keyboard + /// extension on the next `load()` cycle. private var translationSection: some View { VStack(alignment: .leading, spacing: SettingsListMetrics.sectionLabelSpacing) { Text("settings.translation.title") @@ -779,7 +794,7 @@ private struct APISetupPage: View { .textCase(.uppercase) .frame(maxWidth: .infinity, alignment: .leading) VStack(spacing: 0) { - TranslationPickerRow(config: config) + TranslationPickerRow(config: config, isVisible: true) } .background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous)) .overlay( diff --git a/OSGKeyboard/Views/SettingsView.swift b/OSGKeyboard/Views/SettingsView.swift index 30304e0..ba7c7b0 100644 --- a/OSGKeyboard/Views/SettingsView.swift +++ b/OSGKeyboard/Views/SettingsView.swift @@ -145,7 +145,9 @@ struct SettingsView: View { LocalModelsGroup(config: config) } Divider().background(palette.divider) - TranslationPickerRow(config: config) + if config.isTranslationRowVisible { + TranslationPickerRow(config: config, isVisible: true) + } } .background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous)) .overlay( diff --git a/OSGKeyboard/Views/TranslationPickerRow.swift b/OSGKeyboard/Views/TranslationPickerRow.swift index 16914e5..e953b67 100644 --- a/OSGKeyboard/Views/TranslationPickerRow.swift +++ b/OSGKeyboard/Views/TranslationPickerRow.swift @@ -6,18 +6,20 @@ // default) or one of the 10 target languages, all from a single // `Menu`. // -// Mapping to persisted state: -// • "不翻译" → translationEnabled = false -// • any specific locale → translationEnabled = true, translationTargetLocaleId = +// v0.2.1 follow-up: row is rendered through an `isVisible` parameter +// so callers (`SettingsView`, `OnboardingView`) can drop the row +// entirely when the engine can't run the cloud translate-and-polish +// step (`ProviderConfig.isTranslationRowVisible`). The "needs cloud" +// inline hint was deleted along with the previous Bool toggle — the +// user only sees the row when the engine can act on the choice. // -// 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. +// Mapping to persisted state: +// • "不翻译" → translationTargetLocaleId = "off" +// • any specific locale → translationTargetLocaleId = +// +// The pipeline (`PolishingService`) still rejects `.translate` on the +// local engine when cloud polish is off, which +// `KeyboardViewController` surfaces as a 2.4s toast. Belt + suspenders. import SwiftUI import OSGKeyboardShared @@ -26,57 +28,54 @@ struct TranslationPickerRow: View { @Environment(\.themePalette) private var palette: ThemePalette @ObservedObject var config: ProviderConfig + /// Visibility flag — when `false` the row renders as `EmptyView` + /// (callers can also wrap the call site in `if` for symmetry, but + /// having the guard here means a forgotten `if` still produces a + /// safe no-op rather than a leaked dead row). + var isVisible: Bool = true + var body: some View { - HStack { - Text("settings.translation.title") - .font(TypeStyle.body) - .foregroundStyle(palette.textPrimary) - Spacer() - Menu { - ForEach(TranslationLanguageCatalog.all) { language in - Button { - apply(language) - } label: { - if currentSelectionId == language.id { - Label(displayLabel(for: language), systemImage: "checkmark") - } else { - Text(displayLabel(for: language)) + if isVisible { + HStack { + Text("settings.translation.title") + .font(TypeStyle.body) + .foregroundStyle(palette.textPrimary) + Spacer() + Menu { + ForEach(TranslationLanguageCatalog.all) { language in + Button { + apply(language) + } label: { + if currentSelectionId == language.id { + Label(displayLabel(for: language), systemImage: "checkmark") + } else { + Text(displayLabel(for: language)) + } } } - } - } label: { - HStack(spacing: 6) { - 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) + } label: { + HStack(spacing: 6) { + Text(currentLabel) + .font(TypeStyle.body) + .foregroundStyle(currentIsOff ? palette.textSecondary : palette.textPrimary) + Image(systemName: "chevron.up.chevron.down") + .font(.system(size: 11, weight: .bold)) + .foregroundStyle(palette.textTertiary) } - Image(systemName: "chevron.up.chevron.down") - .font(.system(size: 11, weight: .bold)) - .foregroundStyle(palette.textTertiary) } } + .padding(.horizontal, Spacing.md) + .frame(minHeight: SettingsListMetrics.singleLineMinHeight) } - .padding(.horizontal, Spacing.md) - .frame(minHeight: SettingsListMetrics.singleLineMinHeight) } // 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`). + /// Currently selected id — the picker always reads + /// `translationTargetLocaleId` directly (the previous + /// `translationEnabled` boolean is now derived from it). private var currentSelectionId: String { - config.translationEnabled - ? config.translationTargetLocaleId - : TranslationLanguageCatalog.offLocaleId + config.translationTargetLocaleId } private var currentLabel: String { @@ -94,20 +93,11 @@ struct TranslationPickerRow: View { return language.nativeName } - private var pickerForeground: Color { - 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. + /// Translates a picker choice into a single persisted field. + /// "不翻译" writes `offLocaleId`; any concrete locale writes its + /// id. `ProviderConfig.translationEnabled` is derived from the + /// resulting value, so callers don't need to flip a separate Bool. 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 27fd635..94fa21e 100644 --- a/OSGKeyboard/en.lproj/Localizable.strings +++ b/OSGKeyboard/en.lproj/Localizable.strings @@ -111,7 +111,6 @@ // v0.2.1: translation feature "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 bb70274..921c35a 100644 --- a/OSGKeyboard/zh-Hans.lproj/Localizable.strings +++ b/OSGKeyboard/zh-Hans.lproj/Localizable.strings @@ -111,7 +111,6 @@ // v0.2.1: 翻译功能 "settings.translation.title" = "翻译"; "settings.translation.off" = "不翻译"; -"settings.translation.hint.needsCloud" = "需云端"; "settings.languageModels.title" = "语言与模型"; "settings.localModels.title" = "本地模型"; "settings.localModels.speechRole" = "语音识别"; diff --git a/OSGKeyboardExt/KeyboardViewController.swift b/OSGKeyboardExt/KeyboardViewController.swift index 9ef159b..9be2634 100644 --- a/OSGKeyboardExt/KeyboardViewController.swift +++ b/OSGKeyboardExt/KeyboardViewController.swift @@ -138,7 +138,8 @@ public final class KeyboardViewController: UIInputViewController { state.setLocale = { [weak self] l in self?.persistLocale(l) } state.setEngineMode = { [weak self] m in self?.persistEngineMode(m) } state.setLocalASRBackend = { [weak self] b in self?.persistLocalASRBackend(b) } - state.setTranslationEnabled = { [weak self] enabled in self?.persistTranslationEnabled(enabled) } + // v0.2.1 follow-up: removed `setTranslationEnabled` — the chip + // / picker only writes the locale id now; `enabled` is derived. state.setTranslationTargetLocaleId = { [weak self] id in self?.persistTranslationTargetLocaleId(id) } state.insertNewline = { [weak self] in self?.textDocumentProxy.insertText("\n") } state.insertSpace = { [weak self] in self?.textDocumentProxy.insertText(" ") } @@ -645,20 +646,12 @@ public final class KeyboardViewController: UIInputViewController { // MARK: - Translation persistence - /// v0.2.1: persist translation toggle. When the user turns the - /// feature on while the local engine is active we still write the - /// value — `isTranslationEffective` will return `false` until they - /// switch to cloud, but the chip on the keyboard reflects their - /// intent immediately so they get feedback. - private func persistTranslationEnabled(_ enabled: Bool) { - state.translationEnabled = enabled - persistor.persist(translationEnabled: enabled) - } - - /// v0.2.1: persist translation target locale id. Resolved via - /// `TranslationLanguageCatalog.resolve` so a stale persisted value - /// (e.g. a removed locale id from an older build) still finds the - /// right entry instead of crashing the picker. + /// v0.2.1 follow-up: persist translation target locale id. Resolved + /// via `TranslationLanguageCatalog.resolve` so a stale persisted + /// value (e.g. a removed locale id from an older build) still finds + /// the right entry instead of crashing the picker. Translation's + /// "on/off" state is now derived from this id (== `offLocaleId` + /// means off), so there's no separate toggle to persist. private func persistTranslationTargetLocaleId(_ id: String) { let resolved = TranslationLanguageCatalog.resolve(id).id state.translationTargetLocaleId = resolved diff --git a/OSGKeyboardExt/Services/AppGroupPersistor.swift b/OSGKeyboardExt/Services/AppGroupPersistor.swift index 8eadc83..6d39e82 100644 --- a/OSGKeyboardExt/Services/AppGroupPersistor.swift +++ b/OSGKeyboardExt/Services/AppGroupPersistor.swift @@ -35,11 +35,10 @@ public struct AppGroupPersistor { state.mode = .polish state.engineMode = store.engineMode state.localASRBackend = store.localASRBackend - // v0.2.1: translation toggle + target locale. Read once at - // hydration; `refreshRuntimeFlags` keeps them in sync while the - // keyboard stays open so a Settings change shows up without a - // re-present cycle. - state.translationEnabled = store.translationEnabled + // v0.2.1 follow-up: only the target locale is persisted — + // `translationEnabled` is derived from it. Hydrate once at + // startup; `refreshRuntimeFlags` keeps the chip in sync while + // the keyboard stays open. state.translationTargetLocaleId = store.translationTargetLocaleId // v0.2.0: iOS `SpeechAnalyzer` is always ready; mirror that // into the State flags so downstream consumers see the same @@ -81,10 +80,8 @@ public struct AppGroupPersistor { let store = AppGroupStore() state.engineMode = store.engineMode state.localASRBackend = store.localASRBackend - // v0.2.1: keep translation state in sync with the host app so the - // chip on the keyboard reflects the latest value without a re- - // present cycle. - state.translationEnabled = store.translationEnabled + // v0.2.1 follow-up: same as `load` — only the locale is + // persisted, `enabled` is derived. state.translationTargetLocaleId = store.translationTargetLocaleId // v0.2.0: iOS `SpeechAnalyzer` is always ready. Keep these // toggles here so the keyboard UI doesn't flicker if the host @@ -117,14 +114,15 @@ public struct AppGroupPersistor { AppGroupStore().setLocalASRBackend(localASRBackend) } - /// v0.2.1: persist translation toggle. Wired through the - /// `KeyboardViewController.setTranslation` action hook. - public func persist(translationEnabled: Bool) { - guard AppGroup.isAvailable else { return } - AppGroupStore().setTranslationEnabled(translationEnabled) - } - - /// v0.2.1: persist translation target locale id (e.g. `"en"`). + /// v0.2.1: persist translation target locale id (e.g. `"en"`, + /// `"ja"`, or `TranslationLanguageCatalog.offLocaleId`). The + /// chip / picker call this through `KeyboardState.setTranslationTargetLocaleId`. + /// + /// v0.2.1 follow-up: removed `persist(translationEnabled:)` — the + /// enabled state is derived from the locale id, so callers only + /// need to write the locale. Keeping the legacy Bool overload + /// around would have implied that there's a separate on/off + /// switch to persist, which is no longer the model. public func persist(translationTargetLocaleId: String) { guard AppGroup.isAvailable else { return } AppGroupStore().setTranslationTargetLocaleId(translationTargetLocaleId) diff --git a/OSGKeyboardExt/Views/TranslationChip.swift b/OSGKeyboardExt/Views/TranslationChip.swift index 4cf08c7..b1cab40 100644 --- a/OSGKeyboardExt/Views/TranslationChip.swift +++ b/OSGKeyboardExt/Views/TranslationChip.swift @@ -5,10 +5,17 @@ // top bar. Doubles as both the on/off switch and the target-language // picker — same Menu pattern as `LocaleChip` so muscle memory transfers. // +// v0.2.1 follow-up: removed the explicit on/off toggle entry. The +// chip is now a pure picker over the 11 catalog rows (off + 10 +// locales); selecting "不翻译" turns translation off, selecting any +// locale turns it on with that target. `translationEnabled` is +// derived from the locale id so the chip / pipeline read the same +// source of truth. +// // Visual states: -// • disabled → dim outline, "翻译" label -// • enabled + cloud → accent fill, "→ EN" / "→ 日本語" style label -// • enabled + local engine→ warning fill + "翻译需云端" hint (effectively +// • off → dim outline, "翻译" label +// • on + cloud → accent fill, "→ EN" / "→ 日本語" style label +// • on + local engine→ warning fill + "翻译需云端" hint (effectively // inert; the pipeline rejects the mode and the controller surfaces // the error toast) // @@ -26,28 +33,19 @@ struct TranslationChip: View { var body: some View { Menu { - // Toggle entry sits at the top so the user can flip the feature - // without picking a language first. - Button { - state.setTranslationEnabled(!state.translationEnabled) - } label: { - if state.translationEnabled { - Label(ExtL10n.string("keyboard.translation.disable"), systemImage: "checkmark") - } else { - Text(ExtL10n.string("keyboard.translation.enable")) - } - } - if state.translationEnabled { - Divider() - ForEach(TranslationLanguageCatalog.all) { language in - Button { - state.setTranslationTargetLocaleId(language.id) - } label: { - if language.id == state.translationTargetLocaleId { - Label(language.nativeName, systemImage: "checkmark") - } else { - Text(language.nativeName) - } + // v0.2.1 follow-up: pure picker over the full catalog, + // including `offLocaleId` at the top so "turn off" is one + // tap from any enabled state. Picking a row writes + // `translationTargetLocaleId`; `translationEnabled` is + // derived from it. + ForEach(TranslationLanguageCatalog.all) { language in + Button { + state.setTranslationTargetLocaleId(language.id) + } label: { + if language.id == currentSelectionId { + Label(displayLabel(for: language), systemImage: "checkmark") + } else { + Text(displayLabel(for: language)) } } } @@ -80,6 +78,19 @@ struct TranslationChip: View { .overlay(Capsule().stroke(stroke(enabled: enabled, isLocal: isLocal), lineWidth: 0.5)) } + /// Active selection id — the chip derives "on" from a non-off + /// locale id, so reading `translationTargetLocaleId` is enough. + private var currentSelectionId: String { + state.translationTargetLocaleId + } + + private func displayLabel(for language: TranslationLanguage) -> String { + if language.id == TranslationLanguageCatalog.offLocaleId { + return ExtL10n.string("keyboard.translation.off") + } + return language.nativeName + } + private func chipLabel(target: TranslationLanguage, enabled: Bool, isLocal: Bool) -> String { // Local-engine + on shows the constraint hint instead of the // target label so the user knows why nothing's happening. diff --git a/OSGKeyboardShared/Models/ProviderConfig.swift b/OSGKeyboardShared/Models/ProviderConfig.swift index f85eb88..98c7c2c 100644 --- a/OSGKeyboardShared/Models/ProviderConfig.swift +++ b/OSGKeyboardShared/Models/ProviderConfig.swift @@ -39,11 +39,17 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable { // in the local engine. Default `false` — keeps the local engine // truly local unless the user explicitly opts in. static let localModeCloudPolishEnabled = "config.localModeCloudPolishEnabled" - // v0.2.1: optional translation step after ASR. When enabled, the + // v0.2.1: optional translation step after ASR. The // post-ASR transcript is routed through the same LLM with a // translate-and-polish prompt targeting `translationTargetLocaleId`. // Mutually exclusive with the local-only promise — see `TranslationPolicy`. - static let translationEnabled = "config.translationEnabled" + // + // v0.2.1 follow-up: `config.translationEnabled` was *removed* + // as a persisted key — translation is now derived from + // `translationTargetLocaleId` (== offLocaleId means "off"). The + // store still tolerates legacy reads of the old key so users + // who upgraded from a build that wrote it don't see a flash of + // "on" state during init, but new writes never touch the key. static let translationTargetLocaleId = "config.translationTargetLocaleId" } @@ -122,16 +128,23 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable { didSet { defaults.set(uiLanguage.rawValue, forKey: Key.uiLanguage) } } /// v0.2.1: whether to translate the transcript into - /// `translationTargetLocaleId` before insertion. Persisted in the App - /// Group so the keyboard extension can honour it (and so the chip on - /// the keyboard reflects the user's choice without a host-app round- - /// trip). Default `false` — translation is opt-in. - @Published public var translationEnabled: Bool { - didSet { defaults.set(translationEnabled, forKey: Key.translationEnabled) } + /// `translationTargetLocaleId` before insertion. **Derived** — + /// translation is on iff the user has selected a target locale + /// (i.e. the persisted id is anything other than + /// `TranslationLanguageCatalog.offLocaleId`). Default off. + /// + /// This used to be a stored `@Published var ... { didSet }` but the + /// chip / picker now writes the locale directly; collapsing the + /// pair into one field removes the "two writes out of sync" bug + /// surface entirely. + public var translationEnabled: Bool { + translationTargetLocaleId != TranslationLanguageCatalog.offLocaleId } /// v0.2.1: BCP-47-ish target language id (e.g. `en`, `ja`, `ko`) the - /// translate-and-polish prompt should produce. Default `"en"`. - /// Persisted in the App Group for the same reason as `translationEnabled`. + /// translate-and-polish prompt should produce. Default `"off"` — + /// translation is opt-in. Persisted in the App Group so the keyboard + /// extension can honour it (and so the chip on the keyboard reflects + /// the user's choice without a host-app round-trip). @Published public var translationTargetLocaleId: String { didSet { defaults.set(translationTargetLocaleId, forKey: Key.translationTargetLocaleId) } } @@ -145,6 +158,17 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable { translationEnabled && engineMode == "cloud" } + /// v0.2.1: row visibility predicate. Translation is shown only when + /// the engine can actually run the cloud translate-and-polish step: + /// - cloud engine: always visible + /// - local engine: visible only when cloud polish is also enabled + /// (otherwise translation is silently inert — the local engine + /// rejects `.translate` upstream, so we'd be advertising a + /// feature that can't run). + public var isTranslationRowVisible: Bool { + (engineMode == "cloud") || (engineMode == "local" && localModeCloudPolishEnabled) + } + public var isConfigured: Bool { // Local engine (on-device ASR only) doesn't need an API key, // base URL, or model — the LLM round-trip is skipped entirely. @@ -222,15 +246,15 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable { self.uiLanguage = AppUILanguage.fromStored( resolvedDefaults.string(forKey: Key.uiLanguage) ) - // v0.2.1: translation toggle + target locale. Both default in a - // backwards-compatible way so existing installs keep their old - // behaviour (`false`/English) without prompting. - if resolvedDefaults.object(forKey: Key.translationEnabled) == nil { - self.translationEnabled = false - } else { - self.translationEnabled = resolvedDefaults.bool(forKey: Key.translationEnabled) - } - self.translationTargetLocaleId = resolvedDefaults.string(forKey: Key.translationTargetLocaleId) ?? "en" + // v0.2.1 follow-up: `translationEnabled` is now derived from + // `translationTargetLocaleId` — no separate init read. + // Default the locale id to `offLocaleId` so existing installs + // that never picked a target language stay in the "off" state + // (the previous build's default of `"en"` would silently turn + // translation on for every upgraded user; off is the safe + // conservative default that matches the picker / chip UX). + self.translationTargetLocaleId = resolvedDefaults.string(forKey: Key.translationTargetLocaleId) + ?? TranslationLanguageCatalog.offLocaleId // Cloud no longer exposes off/transcribe; migrate legacy values. if self.engineMode == "cloud", self.modeId != "polish" { diff --git a/OSGKeyboardShared/Services/AppGroupStore.swift b/OSGKeyboardShared/Services/AppGroupStore.swift index 0f5918e..635a463 100644 --- a/OSGKeyboardShared/Services/AppGroupStore.swift +++ b/OSGKeyboardShared/Services/AppGroupStore.swift @@ -39,8 +39,11 @@ public struct AppGroupStore: @unchecked Sendable { static let uiLanguage = "config.uiLanguage" // v0.2.0: opt-in cloud polish step after local-mode ASR. static let localModeCloudPolishEnabled = "config.localModeCloudPolishEnabled" - // v0.2.1: translation toggle + target locale id (e.g. "en"). - static let translationEnabled = "config.translationEnabled" + // v0.2.1 follow-up: `config.translationEnabled` was *removed* as a + // persisted key — translation is derived from the target locale + // id. New code should only write/read `translationTargetLocaleId`; + // the `translationEnabled` Bool accessor below is kept as a + // computed shim for source compatibility. static let translationTargetLocaleId = "config.translationTargetLocaleId" } @@ -107,20 +110,21 @@ public struct AppGroupStore: @unchecked Sendable { AppUILanguage.fromStored(defaults.string(forKey: Key.uiLanguage)) } - /// v0.2.1: whether the keyboard should translate the post-ASR transcript - /// before inserting it. Honored only when `engineMode == "cloud"` — see - /// `ProviderConfig.isTranslationEffective` for the effective predicate. + /// v0.2.1 follow-up: derived — translation is on iff a target locale + /// has been selected. The `translationTargetLocaleId` getter below + /// is the source of truth; this property exists for backwards + /// compatibility with call sites that read `store.translationEnabled`. public var translationEnabled: Bool { - guard defaults.object(forKey: Key.translationEnabled) != nil else { - return false - } - return defaults.bool(forKey: Key.translationEnabled) + translationTargetLocaleId != TranslationLanguageCatalog.offLocaleId } /// v0.2.1: target locale id the translate-and-polish prompt should - /// produce (e.g. `"en"`). Defaults to `"en"` when nothing is stored. + /// produce (e.g. `"en"`, `"ja"`). Defaults to `offLocaleId` ("off") + /// when nothing is stored, matching the picker / chip UX where the + /// user has to actively pick a language to turn translation on. public var translationTargetLocaleId: String { - defaults.string(forKey: Key.translationTargetLocaleId) ?? "en" + defaults.string(forKey: Key.translationTargetLocaleId) + ?? TranslationLanguageCatalog.offLocaleId } // MARK: - Writes @@ -145,15 +149,24 @@ public struct AppGroupStore: @unchecked Sendable { defaults.set(language.rawValue, forKey: Key.uiLanguage) } - /// v0.2.1: persist translation toggle. The keyboard extension reads - /// this on every `load()` and `refreshRuntimeFlags()` so the chip - /// reflects the latest value without a host-app round-trip. + /// v0.2.1 follow-up: kept for source compatibility with callers that + /// still pass a Bool (e.g. older tests, any leftover bridge code). + /// `enabled == true` selects `defaultLocaleId` ("en") as a sensible + /// on-ramp target; `enabled == false` resets to `offLocaleId`. + /// The keyboard chip / pipeline now write the locale id directly + /// via `setTranslationTargetLocaleId`, which is the preferred path. public func setTranslationEnabled(_ enabled: Bool) { - defaults.set(enabled, forKey: Key.translationEnabled) + defaults.set( + enabled ? TranslationLanguageCatalog.defaultLocaleId : TranslationLanguageCatalog.offLocaleId, + forKey: Key.translationTargetLocaleId + ) } - /// v0.2.1: persist target locale id (e.g. `"en"`, `"ja"`). Same - /// read cadence as `setTranslationEnabled`. + /// v0.2.1: persist target locale id (e.g. `"en"`, `"ja"`, or + /// `TranslationLanguageCatalog.offLocaleId`). The keyboard + /// extension reads this on every `load()` and `refreshRuntimeFlags()` + /// so the chip reflects the latest value without a host-app + /// round-trip. public func setTranslationTargetLocaleId(_ id: String) { defaults.set(id, forKey: Key.translationTargetLocaleId) } diff --git a/OSGKeyboardShared/Services/KeyboardState.swift b/OSGKeyboardShared/Services/KeyboardState.swift index a49d897..99c5365 100644 --- a/OSGKeyboardShared/Services/KeyboardState.swift +++ b/OSGKeyboardShared/Services/KeyboardState.swift @@ -87,13 +87,17 @@ public final class KeyboardState: ObservableObject { /// CoreML local engine. Always `false` now — there are no weights /// for the host app to preload. @Published public var localModelsLoaded: Bool = false - /// v0.2.1: translation toggle mirrored from `ProviderConfig`. The - /// pipeline asks `isTranslationEffective` before honouring it — - /// the local engine ignores translation regardless of this flag. - @Published public var translationEnabled: Bool = false + /// v0.2.1 follow-up: derived — translation is on iff a target + /// locale has been selected (mirrors `ProviderConfig.translationEnabled` + /// so the chip / pipeline read the same source of truth). + public var translationEnabled: Bool { + translationTargetLocaleId != TranslationLanguageCatalog.offLocaleId + } /// v0.2.1: target locale id the translate-and-polish prompt should /// produce (e.g. `"en"`, `"ja"`). Mirrored from `ProviderConfig`. - @Published public var translationTargetLocaleId: String = TranslationLanguageCatalog.defaultLocaleId + /// Defaults to `offLocaleId` so the keyboard boots in the "off" + /// state on first install. + @Published public var translationTargetLocaleId: String = TranslationLanguageCatalog.offLocaleId /// v0.2.1: effective predicate — translation is honoured only on /// the cloud engine. The keyboard's chip / picker read this so the /// UI can show a "需要云端" hint when the toggle is on while the @@ -115,11 +119,9 @@ public final class KeyboardState: ObservableObject { public var setLocale: (String) -> Void = { _ in } public var setEngineMode: (String) -> Void = { _ in } public var setLocalASRBackend: (LocalASRBackend) -> Void = { _ in } - /// v0.2.1: persist translation toggle. Wired in - /// `KeyboardViewController.installStateActions`. - public var setTranslationEnabled: (Bool) -> Void = { _ in } - /// v0.2.1: persist translation target locale id. Same wiring as - /// `setTranslationEnabled`. + /// v0.2.1 follow-up: only the locale picker remains — `enabled` + /// is derived from the locale id, so there's no separate toggle to + /// persist. Wired in `KeyboardViewController.installStateActions`. public var setTranslationTargetLocaleId: (String) -> Void = { _ in } public var insertNewline: () -> Void = {} public var insertSpace: () -> Void = {}