feat(translation-v2): refactor translationEnabled into computed + onboarding row visibility
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 = <id>
|
||||
// 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 = <id>
|
||||
//
|
||||
// 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
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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" = "语音识别";
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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" {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 = {}
|
||||
|
||||
Reference in New Issue
Block a user