Files
OSGKeyboard/OSGKeyboardExt/Views/TranslationChip.swift
T
rocky deddb49d56 feat(translation): add post-ASR translation mode for cloud engine
Adds an opt-in translation pipeline that reuses the existing
PolishingService + LLMClient + AppGroupStore chain. Translation is
implemented as a new PolishMode (.translate(targetLocaleId:)); all
existing call sites are unchanged.

Settings:
- New TranslationPickerRow in the language tab (Toggle + 10-locale
  picker: en/zh-Hans/zh-Hant/ja/ko/fr/de/es/ru/pt), persisted to the
  App Group so the keyboard extension can read it during live dictation.
- 5 new strings per language (en + zh-Hans).

Keyboard:
- New TranslationChip on the top bar to the right of LocaleChip;
  same Menu pattern, lets users toggle or quickly switch target
  language without leaving the keyboard.
- PolishingService dispatches .translate with a parameterised prompt
  (en/zh variants selected by provider id); PolishingService.error
  gains a translationNotAvailable case so local-engine users get a
  clear inline warning when the toggle is on but cloud is off.
- 6 new strings per language (en + zh-Hans) for the chip + banner.

Local engine policy:
- Translation is cloud-only by design (local engine stays ASR-only
  to honour the no-roundtrip promise). Chip shows a 'cloud required'
  state and raw transcript still inserts on failure — no data loss.

Build:
- OSGKeyboardShared adds TranslationLanguage enum (10 locales) and
  TranslationPrompt factory.
- 4 new files, 9 modified. xcodebuild scheme=OSGKeyboard
  config=Debug destination=iPhone 17 Simulator: BUILD SUCCEEDED
  (0 warning, 0 error).

Also pins DEVELOPMENT_TEAM in project.yml for TestFlight uploads
(3 targets; Team X329MZU23S).
2026-06-25 12:45:44 +08:00

132 lines
5.2 KiB
Swift

// TranslationChip.swift
// OSGKeyboard · Keyboard Extension
//
// Compact chip rendered to the right of `LocaleChip` on the keyboard
// top bar. Doubles as both the on/off switch and the target-language
// picker — same Menu pattern as `LocaleChip` so muscle memory transfers.
//
// Visual states:
// • disabled → dim outline, "翻译" label
// • enabled + cloud → accent fill, "→ EN" / "→ 日本語" style label
// • enabled + local engine→ warning fill + "翻译需云端" hint (effectively
// inert; the pipeline rejects the mode and the controller surfaces
// the error toast)
//
// Stays in the same visual family as `CloudEngineChip` / `LocaleChip`
// (Capsule + 26 pt min height + 5 pt vertical padding) so the top bar
// doesn't grow when translation is enabled.
import SwiftUI
import OSGKeyboardShared
struct TranslationChip: View {
@Environment(\.themePalette) private var palette: ThemePalette
@ObservedObject var state: KeyboardViewController.State
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)
}
}
}
}
} label: {
label
}
.menuStyle(.button)
.accessibilityLabel(ExtL10n.text("keyboard.translation.a11y"))
.accessibilityHint(ExtL10n.text("keyboard.translation.a11yHint"))
}
@ViewBuilder
private var label: some View {
let target = TranslationLanguageCatalog.resolve(state.translationTargetLocaleId)
let isLocal = state.isLocalEngine
let enabled = state.translationEnabled
HStack(spacing: 4) {
Image(systemName: enabled ? "character.bubble" : "character.bubble.fill")
Text(chipLabel(target: target, enabled: enabled, isLocal: isLocal))
Image(systemName: "chevron.down")
.font(.system(size: 8, weight: .bold))
}
.font(TypeStyle.caption2)
.foregroundStyle(foreground(enabled: enabled, isLocal: isLocal))
.padding(.horizontal, Spacing.xs + 2)
.padding(.vertical, 5)
.frame(minHeight: 26)
.background(background(enabled: enabled, isLocal: isLocal), in: Capsule())
.overlay(Capsule().stroke(stroke(enabled: enabled, isLocal: isLocal), lineWidth: 0.5))
}
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.
if enabled, isLocal {
return ExtL10n.string("keyboard.translation.needsCloudShort")
}
if !enabled {
return ExtL10n.string("keyboard.translation.off")
}
// Short form: "→EN" / "→日" style. Falls back to the prompt
// language name for languages without a chip-style abbreviation
// (e.g. French → "FR" via the 2-letter prefix).
let short = shortLabel(for: target)
return "→\(short)"
}
private func shortLabel(for target: TranslationLanguage) -> String {
switch target.id {
case "en": return "EN"
case "zh-Hans": return "中"
case "zh-Hant": return "繁"
case "ja": return "日"
case "ko": return "韩"
case "fr": return "FR"
case "de": return "DE"
case "es": return "ES"
case "ru": return "RU"
case "pt": return "PT"
default: return target.promptLanguageName
}
}
private func foreground(enabled: Bool, isLocal: Bool) -> Color {
if enabled, isLocal { return palette.warning }
if enabled { return palette.accent }
return palette.textPrimary
}
private func background(enabled: Bool, isLocal: Bool) -> Color {
if enabled, isLocal { return palette.warning.opacity(0.15) }
if enabled { return palette.accent.opacity(0.15) }
return palette.surfaceElevated
}
private func stroke(enabled: Bool, isLocal: Bool) -> Color {
if enabled, isLocal { return palette.warning.opacity(0.35) }
if enabled { return palette.accent.opacity(0.35) }
return palette.divider
}
}