Files
OSGKeyboard/OSGKeyboardExt/Views/LastInputEditView.swift
T
Rocky fb97ec2937 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.
2026-08-16 12:07:06 +08:00

275 lines
9.6 KiB
Swift

// LastInputEditView.swift
// OSGKeyboard · Keyboard Extension
import SwiftUI
import OSGKeyboardShared
struct LastInputEditView: View {
private enum Layout {
static let primaryButtonHeight = KeyboardChromeLayout.assistantActionCapsuleHeight
static let primaryButtonWidth: CGFloat = primaryButtonHeight * 3
}
@Environment(\.colorScheme) private var colorScheme
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@ObservedObject var state: KeyboardState
@State private var selectedPage: Int? = 0
private var palette: ThemePalette {
colorScheme == .dark ? Palette.dark : Palette.light
}
var body: some View {
VStack(spacing: 0) {
topBar.frame(height: KeyboardTopBarMetrics.height)
VStack(spacing: 0) {
ZStack(alignment: .bottom) {
pages
.frame(maxWidth: .infinity, maxHeight: .infinity)
VStack(spacing: 0) {
statusLine.frame(height: 18)
pageIndicator.frame(height: 12)
}
.allowsHitTesting(false)
}
.frame(height: 174)
.contentShape(Rectangle())
.simultaneousGesture(reviewSwipeGesture)
primaryRow.frame(height: 55)
}
.frame(maxWidth: KeyboardChromeLayout.voiceContentMaxWidth)
}
.padding(.vertical, 4)
.padding(.horizontal, KeyboardChromeLayout.horizontalInset)
.frame(maxWidth: .infinity)
.frame(height: KeyboardChromeLayout.totalHeight)
.environment(\.themePalette, palette)
.onChange(of: state.editSession) { _, newValue in
guard let review = newValue.review else {
selectedPage = 0
return
}
// Set page before the pager remounts (see `.id` on `pages`) so the
// fresh ScrollView opens on「编辑后」instead of flipping the dots
// while still showing「原文」.
selectedPage = 1
if !reduceMotion {
// Re-assert after layout; spring is only for subsequent swipes.
Task { @MainActor in
await Task.yield()
guard state.editSession.review?.utteranceID == review.utteranceID else { return }
selectedPage = 1
}
}
}
}
private var topBar: some View {
HStack {
KeyboardBrandLogo(action: state.openSettings)
Spacer(minLength: 0)
KeyboardCancelButton(
action: state.closeEditMode,
accessibilityLabel: ExtL10n.text("keyboard.edit.close"),
accessibilityHint: ExtL10n.text("keyboard.edit.closeHint")
)
}
.padding(.horizontal, KeyboardTopBarMetrics.nestedHorizontalInset)
}
@ViewBuilder
private var pages: some View {
if let source = state.editSession.source {
EditTextPager(
originalTitle: ExtL10n.string("keyboard.edit.page.original"),
originalText: source.reference.displayText,
editedTitle: ExtL10n.string("keyboard.edit.page.edited"),
editedText: state.editSession.review?.resultText,
contentBottomInset: 30,
selectedPage: $selectedPage
)
// Remount when review text arrives so scrollPosition can open on page 1.
.id(state.editSession.review?.utteranceID.uuidString ?? "edit-source")
}
}
private var statusLine: some View {
Text(editTranscript)
.font(TypeStyle.caption)
.foregroundStyle(isFailure ? palette.warning : palette.textPrimary)
.lineLimit(1)
.truncationMode(.head)
.frame(maxWidth: .infinity)
}
private var pageIndicator: some View {
HStack(spacing: 5) {
Circle()
.fill(selectedPage != 1 ? palette.textPrimary : palette.textTertiary.opacity(0.45))
.frame(width: 5, height: 5)
Circle()
.fill(selectedPage == 1 ? palette.textPrimary : palette.textTertiary.opacity(0.45))
.frame(width: 5, height: 5)
}
.opacity(state.editSession.review == nil ? 0 : 1)
.accessibilityHidden(true)
}
private var primaryRow: some View {
HStack(spacing: Spacing.sm) {
helperText(leftHelper)
Button(action: primaryAction) {
ZStack {
Color.clear
if case .listening = state.editSession {
Capsule()
.stroke(Color.white.opacity(0.28), lineWidth: 1.5)
.scaleEffect(1 + min(max(state.level, 0), 1) * 0.08)
.animation(Motion.soft, value: state.level)
}
primaryIcon
}
.frame(
width: Layout.primaryButtonWidth,
height: Layout.primaryButtonHeight
)
// 实心填充、无外扩阴影:避免玻璃投影被键盘底边裁切。
.background(palette.accent, in: Capsule())
.contentShape(Capsule())
}
.buttonStyle(.plain)
.disabled(primaryDisabled)
.accessibilityIdentifier(primaryAccessibilityIdentifier)
.accessibilityLabel(Text(primaryAccessibilityLabel))
helperText(rightHelper)
}
}
private func helperText(_ value: String) -> some View {
Text(value)
.font(TypeStyle.caption)
.foregroundStyle(palette.textSecondary.opacity(0.55))
.multilineTextAlignment(.center)
.lineLimit(1)
.minimumScaleFactor(0.75)
.frame(maxWidth: .infinity)
.contentShape(Rectangle())
.simultaneousGesture(reviewSwipeGesture)
}
@ViewBuilder
private var primaryIcon: some View {
switch state.editSession {
case .preparing, .processing, .applying, .appending:
ProgressView().tint(.white)
case .review:
Image(systemName: "checkmark")
.font(.system(size: 21, weight: .bold))
.foregroundStyle(.white)
case .listening:
WaveformView(
level: state.level,
barCount: 7,
color: .white,
active: true
)
.frame(width: 35, height: 22)
.clipped()
default:
Image(systemName: "mic.fill")
.font(.system(size: 21, weight: .semibold))
.foregroundStyle(.white)
}
}
private var primaryDisabled: Bool {
switch state.editSession {
case .preparing, .processing, .applying, .appending:
return true
default:
return false
}
}
private func primaryAction() {
switch state.editSession {
case .listening:
state.stopEditListening()
case .review:
state.confirmEditResult()
case .failed:
state.beginEditLastInput()
default:
break
}
}
private var editTranscript: String {
if case .failed(_, let message) = state.editSession {
return message
}
return state.lastTranscript
}
private var isFailure: Bool {
if case .failed = state.editSession { return true }
return false
}
private var leftHelper: String {
state.editSession.review == nil
? ExtL10n.string("keyboard.edit.helper.speak")
: ExtL10n.string("keyboard.edit.helper.compare")
}
private var rightHelper: String {
if state.editSession.review != nil {
return state.editCanReplaceOriginal
? ExtL10n.string("keyboard.edit.helper.apply")
: ExtL10n.string("keyboard.edit.helper.append")
}
return ExtL10n.string("keyboard.edit.helper.finish")
}
private var primaryAccessibilityLabel: String {
if state.editSession.review != nil {
return state.editCanReplaceOriginal
? ExtL10n.string("keyboard.edit.apply")
: ExtL10n.string("keyboard.edit.append")
}
return ExtL10n.string("keyboard.edit.stop")
}
private var primaryAccessibilityIdentifier: String {
switch state.editSession {
case .listening:
return "assistant.edit.stop"
case .review:
return "assistant.edit.confirm"
default:
return "assistant.edit.primary"
}
}
private var reviewSwipeGesture: some Gesture {
DragGesture(minimumDistance: 12)
.onEnded { value in
guard state.editSession.review != nil else { return }
let translation = value.predictedEndTranslation
guard abs(translation.width) > abs(translation.height) * 1.2,
abs(translation.width) >= 28 else {
return
}
let targetPage = translation.width < 0 ? 1 : 0
guard selectedPage != targetPage else { return }
if reduceMotion {
selectedPage = targetPage
} else {
withAnimation(.interactiveSpring(response: 0.34, dampingFraction: 0.86)) {
selectedPage = targetPage
}
}
}
}
}