feat(translation-polish): dual-engine translation UX + provider picker filter + topbar cleanup
- Add LLMProvider.isUserSelectable (default true) and filter ProviderPickerSection on it; next pass can hide non-user presets (e.g. a future DeepSeek key-preset) without changing call sites. - KeyboardRootView: hide TranslationChip when off (matches user's mental model of an opt-in feature), drop the 'warming' branch (Qwen3 download UX was removed with the backend in v0.2.0), unify chip pill height to minHeight 28 + vertical 6 for visual rhythm across all topbar chips. - TranslationChip: drop isLocal warning path — both engines now run the translate-and-polish step (local routes through DeepSeek via ProviderConfig.localModeProviderId). - OnboardingView: cloud engine branch now wraps the translation row in the same surface card chrome as the local branch. - Strings: drop keyboard.models.warming (no longer referenced). DeepSeek key pre-fill deferred to a follow-up.
This commit is contained in:
@@ -742,6 +742,11 @@ private struct APISetupPage: View {
|
|||||||
// step through DeepSeek, so the constraint is gone).
|
// step through DeepSeek, so the constraint is gone).
|
||||||
// Wrapped in the same surface card chrome as the
|
// Wrapped in the same surface card chrome as the
|
||||||
// APISettingsCard above for visual symmetry.
|
// APISettingsCard above for visual symmetry.
|
||||||
|
//
|
||||||
|
// v0.2.1 final review (topbar cleanup pass): the
|
||||||
|
// surface card chrome is owned by `translationSection`
|
||||||
|
// itself, so both branches get it for free — no
|
||||||
|
// per-branch duplication.
|
||||||
if config.isTranslationRowVisible {
|
if config.isTranslationRowVisible {
|
||||||
translationSection
|
translationSection
|
||||||
.padding(.horizontal, Spacing.lg)
|
.padding(.horizontal, Spacing.lg)
|
||||||
|
|||||||
@@ -10,15 +10,20 @@ struct ProviderPickerSection: View {
|
|||||||
@ObservedObject var config: ProviderConfig
|
@ObservedObject var config: ProviderConfig
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
// v0.2.1 follow-up: filter out presets marked as
|
||||||
|
// `isUserSelectable == false` so a future "DeepSeek key
|
||||||
|
// pre-fill" preset (or similar) can ship in `presets` without
|
||||||
|
// showing up in the picker.
|
||||||
|
let visiblePresets = LLMProvider.presets.filter { $0.isUserSelectable }
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
ForEach(Array(LLMProvider.presets.enumerated()), id: \.element.id) { index, provider in
|
ForEach(Array(visiblePresets.enumerated()), id: \.element.id) { index, provider in
|
||||||
Button {
|
Button {
|
||||||
select(provider)
|
select(provider)
|
||||||
} label: {
|
} label: {
|
||||||
row(provider, selected: provider.id == config.providerId)
|
row(provider, selected: provider.id == config.providerId)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
if index < LLMProvider.presets.count - 1 {
|
if index < visiblePresets.count - 1 {
|
||||||
Divider().background(palette.divider)
|
Divider().background(palette.divider)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -80,7 +80,13 @@ public struct KeyboardRootView: View {
|
|||||||
// and doubles as both the on/off switch and the target-
|
// and doubles as both the on/off switch and the target-
|
||||||
// language picker (Menu pattern matches LocaleChip so the
|
// language picker (Menu pattern matches LocaleChip so the
|
||||||
// top bar stays visually consistent).
|
// top bar stays visually consistent).
|
||||||
|
// v0.2.1 final review: only render the chip when translation
|
||||||
|
// is actually on. Off-by-default keeps the top bar compact
|
||||||
|
// for users who don't need translation; the menu still lives
|
||||||
|
// in onboarding so the feature is discoverable.
|
||||||
|
if state.translationEnabled {
|
||||||
TranslationChip(state: state)
|
TranslationChip(state: state)
|
||||||
|
}
|
||||||
Spacer(minLength: 0)
|
Spacer(minLength: 0)
|
||||||
StatusBadge(phase: state.phase, onDeviceSupported: state.onDeviceSupported)
|
StatusBadge(phase: state.phase, onDeviceSupported: state.onDeviceSupported)
|
||||||
Button(action: state.openSettings) {
|
Button(action: state.openSettings) {
|
||||||
@@ -231,13 +237,6 @@ private struct TranscriptLine: View {
|
|||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.accessibilityHint(ExtL10n.text("keyboard.models.downloadHint"))
|
.accessibilityHint(ExtL10n.text("keyboard.models.downloadHint"))
|
||||||
} else if isLocalEngine, localModelsReady, !localModelsLoaded {
|
|
||||||
HStack(spacing: 6) {
|
|
||||||
ProgressView().controlSize(.mini).tint(palette.textSecondary)
|
|
||||||
ExtL10n.text("keyboard.models.warming")
|
|
||||||
.font(TypeStyle.caption)
|
|
||||||
.foregroundStyle(palette.textSecondary)
|
|
||||||
}
|
|
||||||
} else if flowSessionActive {
|
} else if flowSessionActive {
|
||||||
ExtL10n.text("keyboard.placeholder.idle")
|
ExtL10n.text("keyboard.placeholder.idle")
|
||||||
.font(TypeStyle.caption)
|
.font(TypeStyle.caption)
|
||||||
@@ -418,7 +417,7 @@ private struct StatusBadge: View {
|
|||||||
.foregroundStyle(palette.textSecondary)
|
.foregroundStyle(palette.textSecondary)
|
||||||
}
|
}
|
||||||
.padding(.horizontal, Spacing.xs)
|
.padding(.horizontal, Spacing.xs)
|
||||||
.padding(.vertical, 3)
|
.padding(.vertical, 4)
|
||||||
.background(palette.surface, in: Capsule())
|
.background(palette.surface, in: Capsule())
|
||||||
.overlay(Capsule().stroke(palette.divider, lineWidth: 0.5))
|
.overlay(Capsule().stroke(palette.divider, lineWidth: 0.5))
|
||||||
}
|
}
|
||||||
@@ -437,8 +436,8 @@ private struct CloudEngineChip: View {
|
|||||||
.font(TypeStyle.caption2)
|
.font(TypeStyle.caption2)
|
||||||
.foregroundStyle(palette.accent)
|
.foregroundStyle(palette.accent)
|
||||||
.padding(.horizontal, Spacing.xs + 2)
|
.padding(.horizontal, Spacing.xs + 2)
|
||||||
.padding(.vertical, 5)
|
.padding(.vertical, 6)
|
||||||
.frame(minHeight: 26)
|
.frame(minHeight: 28)
|
||||||
.background(palette.accent.opacity(0.15), in: Capsule())
|
.background(palette.accent.opacity(0.15), in: Capsule())
|
||||||
.overlay(Capsule().stroke(palette.accent.opacity(0.35), lineWidth: 0.5))
|
.overlay(Capsule().stroke(palette.accent.opacity(0.35), lineWidth: 0.5))
|
||||||
}
|
}
|
||||||
@@ -457,8 +456,8 @@ private struct LocalEngineChip: View {
|
|||||||
.font(TypeStyle.caption2)
|
.font(TypeStyle.caption2)
|
||||||
.foregroundStyle(palette.accent)
|
.foregroundStyle(palette.accent)
|
||||||
.padding(.horizontal, Spacing.xs + 2)
|
.padding(.horizontal, Spacing.xs + 2)
|
||||||
.padding(.vertical, 5)
|
.padding(.vertical, 6)
|
||||||
.frame(minHeight: 26)
|
.frame(minHeight: 28)
|
||||||
.background(palette.accent.opacity(0.15), in: Capsule())
|
.background(palette.accent.opacity(0.15), in: Capsule())
|
||||||
.overlay(Capsule().stroke(palette.accent.opacity(0.35), lineWidth: 0.5))
|
.overlay(Capsule().stroke(palette.accent.opacity(0.35), lineWidth: 0.5))
|
||||||
}
|
}
|
||||||
@@ -504,8 +503,8 @@ private struct LocaleChip: View {
|
|||||||
.font(TypeStyle.caption2)
|
.font(TypeStyle.caption2)
|
||||||
.foregroundStyle(palette.textPrimary)
|
.foregroundStyle(palette.textPrimary)
|
||||||
.padding(.horizontal, Spacing.xs + 2)
|
.padding(.horizontal, Spacing.xs + 2)
|
||||||
.padding(.vertical, 5)
|
.padding(.vertical, 6)
|
||||||
.frame(minHeight: 26)
|
.frame(minHeight: 28)
|
||||||
.background(palette.surfaceElevated, in: Capsule())
|
.background(palette.surfaceElevated, in: Capsule())
|
||||||
.overlay(Capsule().stroke(palette.divider, lineWidth: 0.5))
|
.overlay(Capsule().stroke(palette.divider, lineWidth: 0.5))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
// • on (any engine) → accent fill, "→ EN" / "→ 日本語" style label
|
// • on (any engine) → accent fill, "→ EN" / "→ 日本語" style label
|
||||||
//
|
//
|
||||||
// Stays in the same visual family as `CloudEngineChip` / `LocaleChip`
|
// Stays in the same visual family as `CloudEngineChip` / `LocaleChip`
|
||||||
// (Capsule + 26 pt min height + 5 pt vertical padding) so the top bar
|
// (Capsule + 28 pt min height + 6 pt vertical padding) so the top bar
|
||||||
// doesn't grow when translation is enabled.
|
// doesn't grow when translation is enabled.
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
@@ -63,22 +63,21 @@ struct TranslationChip: View {
|
|||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var label: some View {
|
private var label: some View {
|
||||||
let target = TranslationLanguageCatalog.resolve(state.translationTargetLocaleId)
|
let target = TranslationLanguageCatalog.resolve(state.translationTargetLocaleId)
|
||||||
let isLocal = state.isLocalEngine
|
|
||||||
let enabled = state.translationEnabled
|
let enabled = state.translationEnabled
|
||||||
|
|
||||||
HStack(spacing: 4) {
|
HStack(spacing: 4) {
|
||||||
Image(systemName: enabled ? "character.bubble" : "character.bubble.fill")
|
Image(systemName: enabled ? "character.bubble" : "character.bubble.fill")
|
||||||
Text(chipLabel(target: target, enabled: enabled, isLocal: isLocal))
|
Text(chipLabel(target: target, enabled: enabled))
|
||||||
Image(systemName: "chevron.down")
|
Image(systemName: "chevron.down")
|
||||||
.font(.system(size: 8, weight: .bold))
|
.font(.system(size: 8, weight: .bold))
|
||||||
}
|
}
|
||||||
.font(TypeStyle.caption2)
|
.font(TypeStyle.caption2)
|
||||||
.foregroundStyle(foreground(enabled: enabled, isLocal: isLocal))
|
.foregroundStyle(foreground(enabled: enabled))
|
||||||
.padding(.horizontal, Spacing.xs + 2)
|
.padding(.horizontal, Spacing.xs + 2)
|
||||||
.padding(.vertical, 5)
|
.padding(.vertical, 6)
|
||||||
.frame(minHeight: 26)
|
.frame(minHeight: 28)
|
||||||
.background(background(enabled: enabled, isLocal: isLocal), in: Capsule())
|
.background(background(enabled: enabled), in: Capsule())
|
||||||
.overlay(Capsule().stroke(stroke(enabled: enabled, isLocal: isLocal), lineWidth: 0.5))
|
.overlay(Capsule().stroke(stroke(enabled: enabled), lineWidth: 0.5))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Active selection id — the chip derives "on" from a non-off
|
/// Active selection id — the chip derives "on" from a non-off
|
||||||
@@ -94,11 +93,7 @@ struct TranslationChip: View {
|
|||||||
return language.nativeName
|
return language.nativeName
|
||||||
}
|
}
|
||||||
|
|
||||||
private func chipLabel(target: TranslationLanguage, enabled: Bool, isLocal: Bool) -> String {
|
private func chipLabel(target: TranslationLanguage, enabled: Bool) -> String {
|
||||||
// v0.2.1 follow-up: with the local engine now routing the
|
|
||||||
// polish / translate step through DeepSeek, the chip shows
|
|
||||||
// the same "→EN"-style label on both engines. There's no
|
|
||||||
// "needs cloud" hint path anymore.
|
|
||||||
if !enabled {
|
if !enabled {
|
||||||
return ExtL10n.string("keyboard.translation.off")
|
return ExtL10n.string("keyboard.translation.off")
|
||||||
}
|
}
|
||||||
@@ -125,24 +120,17 @@ struct TranslationChip: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func foreground(enabled: Bool, isLocal: Bool) -> Color {
|
private func foreground(enabled: Bool) -> Color {
|
||||||
// v0.2.1 follow-up: the chip no longer needs a "warning" path
|
|
||||||
// for local + on — both engines share the accent treatment
|
|
||||||
// now. `isLocal` is kept in the signature so callers don't
|
|
||||||
// need to change; it's intentionally unused below.
|
|
||||||
_ = isLocal
|
|
||||||
if enabled { return palette.accent }
|
if enabled { return palette.accent }
|
||||||
return palette.textPrimary
|
return palette.textPrimary
|
||||||
}
|
}
|
||||||
|
|
||||||
private func background(enabled: Bool, isLocal: Bool) -> Color {
|
private func background(enabled: Bool) -> Color {
|
||||||
_ = isLocal
|
|
||||||
if enabled { return palette.accent.opacity(0.15) }
|
if enabled { return palette.accent.opacity(0.15) }
|
||||||
return palette.surfaceElevated
|
return palette.surfaceElevated
|
||||||
}
|
}
|
||||||
|
|
||||||
private func stroke(enabled: Bool, isLocal: Bool) -> Color {
|
private func stroke(enabled: Bool) -> Color {
|
||||||
_ = isLocal
|
|
||||||
if enabled { return palette.accent.opacity(0.35) }
|
if enabled { return palette.accent.opacity(0.35) }
|
||||||
return palette.divider
|
return palette.divider
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -121,7 +121,6 @@
|
|||||||
"keyboard.placeholder.cloudBadge" = "Cloud";
|
"keyboard.placeholder.cloudBadge" = "Cloud";
|
||||||
"keyboard.models.notDownloaded" = "On-device models not downloaded";
|
"keyboard.models.notDownloaded" = "On-device models not downloaded";
|
||||||
"keyboard.models.downloadHint" = "Open OSGKeyboard to download models";
|
"keyboard.models.downloadHint" = "Open OSGKeyboard to download models";
|
||||||
"keyboard.models.warming" = "Loading models…";
|
|
||||||
"keyboard.rec" = "REC";
|
"keyboard.rec" = "REC";
|
||||||
"keyboard.space" = "Space";
|
"keyboard.space" = "Space";
|
||||||
"keyboard.denied.mic" = "Mic denied";
|
"keyboard.denied.mic" = "Mic denied";
|
||||||
|
|||||||
@@ -121,7 +121,6 @@
|
|||||||
"keyboard.placeholder.cloudBadge" = "云端";
|
"keyboard.placeholder.cloudBadge" = "云端";
|
||||||
"keyboard.models.notDownloaded" = "本地模型尚未下载";
|
"keyboard.models.notDownloaded" = "本地模型尚未下载";
|
||||||
"keyboard.models.downloadHint" = "打开 OSGKeyboard 下载模型";
|
"keyboard.models.downloadHint" = "打开 OSGKeyboard 下载模型";
|
||||||
"keyboard.models.warming" = "正在加载模型…";
|
|
||||||
"keyboard.rec" = "REC";
|
"keyboard.rec" = "REC";
|
||||||
"keyboard.space" = "空格";
|
"keyboard.space" = "空格";
|
||||||
"keyboard.denied.mic" = "麦克风被拒绝";
|
"keyboard.denied.mic" = "麦克风被拒绝";
|
||||||
|
|||||||
@@ -14,6 +14,12 @@ public struct LLMProvider: Identifiable, Codable, Hashable, Sendable {
|
|||||||
public let apiKeyURL: URL?
|
public let apiKeyURL: URL?
|
||||||
/// Optional short blurb shown under the provider name in the picker.
|
/// Optional short blurb shown under the provider name in the picker.
|
||||||
public let blurb: String?
|
public let blurb: String?
|
||||||
|
/// Whether this preset should appear in user-facing provider pickers
|
||||||
|
/// (settings / onboarding). Defaults to `true` so the existing
|
||||||
|
/// `presets` array keeps its public surface area; future passes can
|
||||||
|
/// mark e.g. a DeepSeek key-pre-fill preset as `false` to hide it
|
||||||
|
/// from the picker without touching call sites.
|
||||||
|
public let isUserSelectable: Bool
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
id: String,
|
id: String,
|
||||||
@@ -21,7 +27,8 @@ public struct LLMProvider: Identifiable, Codable, Hashable, Sendable {
|
|||||||
defaultBaseURL: String,
|
defaultBaseURL: String,
|
||||||
defaultModel: String,
|
defaultModel: String,
|
||||||
apiKeyURL: URL? = nil,
|
apiKeyURL: URL? = nil,
|
||||||
blurb: String? = nil
|
blurb: String? = nil,
|
||||||
|
isUserSelectable: Bool = true
|
||||||
) {
|
) {
|
||||||
self.id = id
|
self.id = id
|
||||||
self.name = name
|
self.name = name
|
||||||
@@ -29,6 +36,7 @@ public struct LLMProvider: Identifiable, Codable, Hashable, Sendable {
|
|||||||
self.defaultModel = defaultModel
|
self.defaultModel = defaultModel
|
||||||
self.apiKeyURL = apiKeyURL
|
self.apiKeyURL = apiKeyURL
|
||||||
self.blurb = blurb
|
self.blurb = blurb
|
||||||
|
self.isUserSelectable = isUserSelectable
|
||||||
}
|
}
|
||||||
|
|
||||||
public static let presets: [LLMProvider] = [
|
public static let presets: [LLMProvider] = [
|
||||||
|
|||||||
Reference in New Issue
Block a user