feat(translation-v2): refactor translationEnabled into computed + onboarding row visibility

This commit is contained in:
2026-06-25 13:52:13 +08:00
parent 4d92999347
commit 9b759281cc
11 changed files with 226 additions and 180 deletions
+43 -19
View File
@@ -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" {
+30 -17
View File
@@ -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)
}
+12 -10
View File
@@ -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 = {}