498f407585
Introduce optional Apple account-backed credits with scoped gateway access while preserving local and BYOK paths. Refresh assistant behavior, tests, privacy disclosures, docs, and the website for the 2.0 experience.
275 lines
9.6 KiB
Swift
275 lines
9.6 KiB
Swift
// LastInputEditView.swift
|
|
// OSGKeyboard · Keyboard Extension
|
|
|
|
import OSGKeyboardShared
|
|
import SwiftUI
|
|
|
|
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
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|