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
+8 -15
View File
@@ -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
+15 -17
View File
@@ -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)
+36 -25
View File
@@ -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.