feat(keyboard): unify assistant voice and AI workflows

Merge dictation and AI controls into one assistant surface, preserve safe insertion and clipboard actions, and align settings and tests with the new flow.
This commit is contained in:
Rocky
2026-08-16 12:07:06 +08:00
parent 46f6d818b8
commit fb97ec2937
45 changed files with 1969 additions and 2143 deletions
+43 -26
View File
@@ -25,8 +25,8 @@ enum KeyboardTopBarMetrics {
static let nestedHorizontalInset: CGFloat = horizontalInset - KeyboardChromeLayout.horizontalInset
static let logoHeight: CGFloat = 16
static let logoWidth: CGFloat = logoHeight * 952 / 291
/// Equal hit width for AI / Voice / Chinese / English input tabs.
static let inputTabWidth: CGFloat = 42
/// Equal hit width for Assistant / Chinese / English input tabs.
static let inputTabWidth: CGFloat = 48
/// Shared footprint for top-trailing chips (clipboard, cancel/X, translation).
static let trailingChipSize: CGFloat = 34
static let trailingChipIconSize: CGFloat = 15
@@ -63,6 +63,7 @@ struct KeyboardCancelButton: View {
let action: () -> Void
let accessibilityLabel: Text
let accessibilityHint: Text
var accessibilityIdentifier = "keyboard.cancel"
var body: some View {
// Same 34×34 chip as KeyboardClipboardMenuButton / translation.
@@ -82,21 +83,20 @@ struct KeyboardCancelButton: View {
.contentShape(Circle())
}
.buttonStyle(.plain)
.accessibilityIdentifier(accessibilityIdentifier)
.accessibilityLabel(accessibilityLabel)
.accessibilityHint(accessibilityHint)
}
}
private enum KeyboardInputTab: CaseIterable {
case ai
case voice
case assistant
case chinese
case english
var title: String {
switch self {
case .ai: return ExtL10n.string("keyboard.tab.ai")
case .voice: return ExtL10n.string("keyboard.tab.voice")
case .assistant: return ExtL10n.string("keyboard.tab.ai")
case .chinese: return ExtL10n.string("keyboard.tab.chinese")
case .english: return ExtL10n.string("keyboard.tab.english")
}
@@ -117,9 +117,17 @@ struct KeyboardTopControls: View {
var body: some View {
ZStack {
inputTabSwitcher
if state.canShowClipboardEntry {
HStack {
Spacer(minLength: 0)
HStack(spacing: 6) {
Spacer(minLength: 0)
if state.surface != .typing {
KeyboardTranslationMenuButton(
palette: palette,
targetLocaleId: state.translationTargetLocaleId,
onSelect: state.setTranslationTargetLocaleId
)
.equatable()
}
if state.canShowClipboardEntry {
KeyboardClipboardMenuButton(
palette: palette,
action: state.openClipboardPanel
@@ -156,8 +164,9 @@ struct KeyboardTopControls: View {
tabLabel(tab, selected: selected)
}
.buttonStyle(TopControlPressStyle(pressedFill: pressedFill))
.disabled(tab != .voice && !state.canEnterTypingSurface)
.disabled(tab != .assistant && !state.canEnterTypingSurface)
.opacity(tabOpacity(tab))
.accessibilityIdentifier("assistant.tab.\(tabIdentifier(tab))")
.accessibilityLabel(accessibilityLabel(for: tab))
.accessibilityAddTraits(selected ? .isSelected : [])
}
@@ -187,11 +196,8 @@ struct KeyboardTopControls: View {
@ViewBuilder
private func tabContent(_ tab: KeyboardInputTab, selected: Bool) -> some View {
if tab == .ai {
Image(systemName: "sparkle")
.font(.system(size: 15, weight: selected ? .semibold : .medium))
} else if tab == .voice {
Image(systemName: "waveform.mid")
if tab == .assistant {
Image(systemName: "sparkles")
.font(.system(size: 15, weight: selected ? .semibold : .medium))
} else {
Text(tab.title)
@@ -200,13 +206,21 @@ struct KeyboardTopControls: View {
}
private func tabOpacity(_ tab: KeyboardInputTab) -> Double {
guard tab != .voice, !state.canEnterTypingSurface else { return 1 }
guard tab != .assistant, !state.canEnterTypingSurface else { return 1 }
if case .recording = state.phase {
return 0
}
return 0.42
}
private func tabIdentifier(_ tab: KeyboardInputTab) -> String {
switch tab {
case .assistant: return "assistant"
case .chinese: return "chinese"
case .english: return "english"
}
}
private var pressedFill: Color {
colorScheme == .dark ? Color(white: 0.22) : Color(white: 0.84)
}
@@ -219,10 +233,8 @@ struct KeyboardTopControls: View {
private func isSelected(_ tab: KeyboardInputTab) -> Bool {
switch tab {
case .ai:
return state.surface == .ai
case .voice:
return state.surface == .voice
case .assistant:
return state.surface == .voice || state.surface == .ai
case .chinese:
return state.surface == .typing && typing.language == .chinese
case .english:
@@ -232,9 +244,7 @@ struct KeyboardTopControls: View {
private func select(_ tab: KeyboardInputTab) {
switch tab {
case .ai:
state.setSurface(.ai)
case .voice:
case .assistant:
state.setSurface(.voice)
case .chinese:
applyTypingOutput(typing.setLanguage(.chinese))
@@ -262,8 +272,7 @@ struct KeyboardTopControls: View {
private func accessibilityLabel(for tab: KeyboardInputTab) -> String {
switch tab {
case .ai: return ExtL10n.string("keyboard.tab.ai.a11y")
case .voice: return ExtL10n.string("keyboard.tab.voice.a11y")
case .assistant: return ExtL10n.string("keyboard.tab.ai.a11y")
case .chinese: return ExtL10n.string("keyboard.tab.chinese.a11y")
case .english: return ExtL10n.string("keyboard.tab.english.a11y")
}
@@ -271,6 +280,8 @@ struct KeyboardTopControls: View {
}
struct KeyboardTranslationMenuButton: View, Equatable {
@Environment(\.colorScheme) private var colorScheme
let palette: ThemePalette
let targetLocaleId: String
let onSelect: (String) -> Void
@@ -310,10 +321,16 @@ struct KeyboardTranslationMenuButton: View, Equatable {
: palette.textSecondary
)
}
.frame(
width: KeyboardTopBarMetrics.trailingChipSize,
height: KeyboardTopBarMetrics.trailingChipSize
)
.background(NativeKeyboardKeyColors.fill(for: colorScheme), in: Circle())
.overlay(Circle().stroke(palette.divider, lineWidth: 0.5))
.contentShape(Circle())
.glassEffect(.regular.interactive(), in: Circle())
}
.menuStyle(.button)
.accessibilityIdentifier("assistant.translation")
.accessibilityLabel(Text(SharedL10n.string("keyboard.translation.a11y")))
.accessibilityHint(Text(SharedL10n.string("keyboard.translation.a11yHint")))
}