e5a83843db
Add resilient usage analytics, OOBE gateway flows, clipboard semantic ranking, purchase recovery, style learning, and managed current-information search.
1179 lines
43 KiB
Swift
1179 lines
43 KiB
Swift
// AIKeyboardView.swift
|
|
// OSGKeyboard · Keyboard Extension
|
|
//
|
|
// Unified assistant surface: tap for ordinary dictation, hold for AI, and use
|
|
// the same idle layout for hotwords, clipboard skills, editing, and Send.
|
|
|
|
import OSGKeyboardShared
|
|
import SwiftUI
|
|
|
|
struct AIKeyboardView: View {
|
|
private enum Layout {
|
|
static let topBarHeight: CGFloat = 44
|
|
static let primaryHeight: CGFloat = 60
|
|
static let primaryWidth: CGFloat = 156
|
|
static let compactPrimaryHeight: CGFloat = 56
|
|
static let compactPrimaryWidth: CGFloat = 148
|
|
static let secondaryHeight: CGFloat = 52
|
|
static let fieldActionWidth: CGFloat = 132
|
|
static let compactIPadFieldActionWidth: CGFloat = 112
|
|
static let circleSize: CGFloat = 48
|
|
static let compactIPadCircleSize: CGFloat = 44
|
|
static let sideButtonEdgeInset: CGFloat = 8
|
|
static let actionClusterMaxWidth: CGFloat = 430
|
|
static let primaryToSecondaryGap: CGFloat = 25
|
|
static let hotwordHeight: CGFloat = 28
|
|
static let hotwordMaxWidth: CGFloat = 180
|
|
static let skillTipMaxWidth: CGFloat = 300
|
|
/// The 30 pt tab sits inside a 44 pt top bar. Half of its bottom inset
|
|
/// belongs visually to the label-to-tab gap, so compensate by 3.5 pt.
|
|
static let contextVisualOffset: CGFloat = -3.5
|
|
static let carouselInterval: TimeInterval = 4
|
|
static let skillButtonSize: CGFloat = 48
|
|
static let skillCellWidth: CGFloat = 60
|
|
static let skillCellSpacing: CGFloat = 10
|
|
static let pageDotSize: CGFloat = 5
|
|
static let skillPaginationBottomInset: CGFloat = 6
|
|
}
|
|
|
|
#if DEBUG
|
|
/// Layout preview for `--ai-skills-demo`. Nil keeps production clipboard-window gating.
|
|
static var debugPreviewSkills: [AIClipboardSkill]?
|
|
/// Keeps the deterministic UI harness on the tappable idle hint.
|
|
static var debugSkipsLongPressCoach = false
|
|
/// Prevents deterministic feedback previews from expiring mid-assertion.
|
|
static var debugKeepsSkillTip = false
|
|
#endif
|
|
|
|
@Environment(\.colorScheme) private var colorScheme
|
|
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
|
@ObservedObject var state: KeyboardState
|
|
@ObservedObject var typing: TypingSessionController
|
|
@ObservedObject private var clipboardHistory = ClipboardHistoryStore.shared
|
|
@ObservedObject private var semanticRanking = ClipboardSemanticRankingStore.shared
|
|
let onInsert: (String) -> Void
|
|
|
|
@AppStorage("keyboard.assistant.longPressCoachCount")
|
|
private var longPressCoachCount = 0
|
|
@State private var showsLongPressCoach = false
|
|
@State private var currentHint: AIHintCard?
|
|
@State private var hintOpacity: Double = 1
|
|
@State private var carouselBag = AIHintCarouselBag()
|
|
@State private var poolCards: [AIHintCard] = []
|
|
@State private var selectedSkillPage = 0
|
|
@State private var dismissedClipboardEntryID: UUID?
|
|
@State private var debugSkillsDismissed = false
|
|
@State private var micLongPressConsumed = false
|
|
@State private var micIsHoldingForAI = false
|
|
@State private var fieldActionConfirmationVisible = false
|
|
|
|
private var palette: ThemePalette {
|
|
colorScheme == .dark ? Palette.dark : Palette.light
|
|
}
|
|
|
|
var body: some View {
|
|
Group {
|
|
if state.editSession.isActive {
|
|
LastInputEditView(state: state)
|
|
} else if state.aiSession.canInsert {
|
|
pendingAnswerSurface
|
|
} else {
|
|
assistantSurface
|
|
}
|
|
}
|
|
.overlay(alignment: .topLeading) {
|
|
Color.clear
|
|
.frame(width: 1, height: 1)
|
|
.accessibilityElement()
|
|
.accessibilityIdentifier("assistant.surface")
|
|
.accessibilityLabel(ExtL10n.text("keyboard.tab.ai"))
|
|
.allowsHitTesting(false)
|
|
}
|
|
.environment(\.themePalette, palette)
|
|
.onAppear {
|
|
resetCarousel()
|
|
if shouldShowLongPressCoach, longPressCoachCount < 3 {
|
|
showsLongPressCoach = true
|
|
longPressCoachCount += 1
|
|
}
|
|
}
|
|
.onChange(of: state.aiSession.phase) { _, phase in
|
|
if phase == .idle || phase == .failed || phase == .inserted || phase == .sent {
|
|
resetCarousel()
|
|
}
|
|
}
|
|
.onChange(of: state.clipboardHistoryEnabled) { _, _ in resetCarousel() }
|
|
.onChange(of: clipboardHistory.entries.first?.id) { _, _ in
|
|
dismissedClipboardEntryID = nil
|
|
debugSkillsDismissed = false
|
|
selectedSkillPage = 0
|
|
resetCarousel()
|
|
}
|
|
.onChange(of: state.enabledClipboardSkills) { _, _ in
|
|
selectedSkillPage = 0
|
|
resetCarousel()
|
|
}
|
|
.onChange(of: semanticRanking.snapshot) { _, _ in
|
|
selectedSkillPage = 0
|
|
}
|
|
.onChange(of: state.skillTipText) { _, tip in
|
|
guard let tip, !tip.isEmpty else { return }
|
|
#if DEBUG
|
|
guard !Self.debugKeepsSkillTip else { return }
|
|
#endif
|
|
Task { @MainActor in
|
|
try? await Task.sleep(for: .milliseconds(2_800))
|
|
if state.skillTipText == tip {
|
|
state.skillTipText = nil
|
|
}
|
|
}
|
|
}
|
|
.animation(Motion.soft, value: state.skillTipText)
|
|
.onReceive(
|
|
Timer.publish(every: Layout.carouselInterval, on: .main, in: .common).autoconnect()
|
|
) { _ in
|
|
guard assistantIsResting else { return }
|
|
reloadHintPool(resetBag: false)
|
|
guard !showsClipboardSkills, !showsLongPressCoach else { return }
|
|
if reduceMotion, let hint = currentHint,
|
|
poolCards.contains(where: { $0.id == hint.id }) {
|
|
return
|
|
}
|
|
showNextHint(animated: !reduceMotion)
|
|
}
|
|
}
|
|
|
|
private var shouldShowLongPressCoach: Bool {
|
|
#if DEBUG
|
|
return !Self.debugSkipsLongPressCoach
|
|
#else
|
|
return true
|
|
#endif
|
|
}
|
|
|
|
private var assistantSurface: some View {
|
|
VStack(spacing: 0) {
|
|
topBar.frame(height: Layout.topBarHeight)
|
|
// This flexible slot is exactly the gap between the top tabs and
|
|
// microphone. Centering its content guarantees equal whitespace
|
|
// above and below labels on both phone and iPad heights.
|
|
contextArea
|
|
.frame(maxHeight: .infinity)
|
|
.offset(y: Layout.contextVisualOffset)
|
|
primaryActionRow.frame(height: Layout.primaryHeight)
|
|
Color.clear.frame(height: Layout.primaryToSecondaryGap)
|
|
secondaryActionRow.frame(height: Layout.secondaryHeight)
|
|
}
|
|
.padding(.vertical, 4)
|
|
.padding(.horizontal, KeyboardChromeLayout.horizontalInset)
|
|
.frame(maxWidth: KeyboardChromeLayout.voiceContentMaxWidth)
|
|
.frame(maxWidth: .infinity)
|
|
.frame(height: resolvedHeight)
|
|
}
|
|
|
|
private var pendingAnswerSurface: some View {
|
|
VStack(spacing: 0) {
|
|
topBar.frame(height: Layout.topBarHeight)
|
|
ScrollView(.vertical) {
|
|
Text(state.aiSession.answer?.text ?? "")
|
|
.font(TypeStyle.body)
|
|
.foregroundStyle(palette.textPrimary)
|
|
.frame(maxWidth: .infinity, alignment: .topLeading)
|
|
.padding(.horizontal, Spacing.md)
|
|
.padding(.vertical, Spacing.sm)
|
|
}
|
|
.scrollIndicators(.visible)
|
|
|
|
Button(action: state.confirmPendingAIAnswer) {
|
|
Image(systemName: "plus")
|
|
.font(.system(size: 20, weight: .semibold))
|
|
.foregroundStyle(.white)
|
|
.frame(width: Layout.fieldActionWidth, height: 44)
|
|
.background(palette.accent, in: Capsule())
|
|
}
|
|
.buttonStyle(.plain)
|
|
.accessibilityIdentifier("assistant.pending.insert")
|
|
.frame(height: 55)
|
|
.accessibilityLabel(ExtL10n.text("keyboard.assistant.insertPending"))
|
|
}
|
|
.padding(.vertical, 4)
|
|
.padding(.horizontal, KeyboardChromeLayout.horizontalInset)
|
|
.frame(maxWidth: KeyboardChromeLayout.voiceContentMaxWidth)
|
|
.frame(maxWidth: .infinity)
|
|
.frame(height: resolvedHeight)
|
|
}
|
|
|
|
private var resolvedHeight: CGFloat {
|
|
TypingSurfaceMetrics.contentHeight(
|
|
isIPad: state.usesIPadLayoutMetrics,
|
|
width: state.layoutWidth
|
|
)
|
|
}
|
|
|
|
// MARK: - Top bar
|
|
|
|
@ViewBuilder
|
|
private var topBar: some View {
|
|
if state.aiSession.canInsert {
|
|
cancelTopBar(
|
|
action: state.discardPendingAIAnswer,
|
|
labelKey: "keyboard.assistant.discardPending",
|
|
hintKey: "keyboard.assistant.discardPendingHint"
|
|
)
|
|
} else if state.canCancelAIInput {
|
|
cancelTopBar(
|
|
action: state.cancelAIInput,
|
|
labelKey: "keyboard.ai.cancel",
|
|
hintKey: "keyboard.ai.cancelHint"
|
|
)
|
|
} else if state.canCancelVoiceInput {
|
|
cancelTopBar(
|
|
action: state.cancelVoiceInput,
|
|
labelKey: "keyboard.voice.cancel",
|
|
hintKey: "keyboard.voice.cancelHint"
|
|
)
|
|
} else if shouldShowClipboardSuggestion {
|
|
ClipboardSuggestionBar(
|
|
text: state.clipboardSuggestionText ?? "",
|
|
onInsert: insertClipboardSuggestion,
|
|
onDismiss: dismissClipboardPresentation
|
|
)
|
|
.padding(.horizontal, KeyboardTopBarMetrics.nestedHorizontalInset)
|
|
} else if showsClipboardSkills {
|
|
cancelTopBar(
|
|
action: dismissClipboardPresentation,
|
|
labelKey: "keyboard.assistant.dismissClipboard",
|
|
hintKey: "keyboard.assistant.dismissClipboardHint"
|
|
)
|
|
} else {
|
|
ZStack {
|
|
KeyboardTopControls(
|
|
state: state,
|
|
typing: typing,
|
|
palette: palette,
|
|
onInsert: onInsert
|
|
)
|
|
HStack {
|
|
KeyboardBrandLogo(action: state.openSettings)
|
|
Spacer(minLength: 0)
|
|
}
|
|
}
|
|
.padding(.horizontal, KeyboardTopBarMetrics.nestedHorizontalInset)
|
|
}
|
|
}
|
|
|
|
private func cancelTopBar(
|
|
action: @escaping () -> Void,
|
|
labelKey: String,
|
|
hintKey: String
|
|
) -> some View {
|
|
HStack(spacing: Spacing.xs) {
|
|
KeyboardBrandLogo(action: state.openSettings)
|
|
Spacer(minLength: 0)
|
|
KeyboardCancelButton(
|
|
action: action,
|
|
accessibilityLabel: ExtL10n.text(labelKey),
|
|
accessibilityHint: ExtL10n.text(hintKey),
|
|
accessibilityIdentifier: cancelIdentifier(for: labelKey)
|
|
)
|
|
}
|
|
.padding(.horizontal, KeyboardTopBarMetrics.nestedHorizontalInset)
|
|
}
|
|
|
|
private func cancelIdentifier(for labelKey: String) -> String {
|
|
switch labelKey {
|
|
case "keyboard.assistant.dismissClipboard":
|
|
return "assistant.clipboard.dismiss"
|
|
case "keyboard.assistant.discardPending":
|
|
return "assistant.pending.discard"
|
|
default:
|
|
return "assistant.cancel"
|
|
}
|
|
}
|
|
|
|
// MARK: - Context
|
|
|
|
@ViewBuilder
|
|
private var contextArea: some View {
|
|
ZStack {
|
|
if showsOOBEClipboardSkill, activeStatus == nil {
|
|
clipboardSkillPager
|
|
} else if let onboardingPracticeHint, activeStatus == nil {
|
|
Text(onboardingPracticeHint)
|
|
.font(.system(size: 13, weight: .semibold))
|
|
.foregroundStyle(palette.textPrimary)
|
|
.lineLimit(1)
|
|
.padding(.horizontal, 14)
|
|
.frame(height: Layout.hotwordHeight)
|
|
.background(palette.accentMuted, in: Capsule())
|
|
} else if let tip = state.skillTipText, !tip.isEmpty {
|
|
IntrinsicWidthCap(maxWidth: Layout.skillTipMaxWidth) {
|
|
Text(tip)
|
|
.font(TypeStyle.body)
|
|
.foregroundStyle(palette.textPrimary)
|
|
.lineLimit(2)
|
|
.multilineTextAlignment(.center)
|
|
.truncationMode(.tail)
|
|
.padding(.horizontal, 14)
|
|
.padding(.vertical, 7)
|
|
.glassEffect(.regular, in: Capsule())
|
|
}
|
|
.accessibilityIdentifier("assistant.skillTip")
|
|
} else if showsClipboardSkills {
|
|
clipboardSkillPager
|
|
} else if let status = activeStatus {
|
|
statusText(status.text, color: status.color)
|
|
} else if showsLongPressCoach {
|
|
Text(ExtL10n.string("keyboard.assistant.longPressCoach"))
|
|
.font(.system(size: 13, weight: .medium))
|
|
.foregroundStyle(palette.textSecondary)
|
|
.lineLimit(1)
|
|
.padding(.horizontal, 14)
|
|
.frame(height: Layout.hotwordHeight)
|
|
.overlay(Capsule().stroke(palette.dividerStrong, lineWidth: 1))
|
|
} else {
|
|
hintCarousel
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.horizontal, Spacing.md)
|
|
}
|
|
|
|
private func statusText(_ text: String, color: Color) -> some View {
|
|
Text(text)
|
|
.font(TypeStyle.body)
|
|
.foregroundStyle(color)
|
|
.lineLimit(2)
|
|
.multilineTextAlignment(.center)
|
|
.truncationMode(.tail)
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
|
|
private var activeStatus: (text: String, color: Color)? {
|
|
if let error = state.aiSession.errorMessage,
|
|
!error.isEmpty,
|
|
!ordinaryPipelineIsActive {
|
|
return (error, palette.warning)
|
|
}
|
|
switch state.aiSession.phase {
|
|
case .preparing:
|
|
return (ExtL10n.string("keyboard.placeholder.preparing"), palette.textSecondary)
|
|
case .listening:
|
|
return (
|
|
state.aiSession.transcript.isEmpty
|
|
? ExtL10n.string("keyboard.ai.listening")
|
|
: state.aiSession.transcript,
|
|
palette.textPrimary
|
|
)
|
|
case .recognizing:
|
|
return (
|
|
state.aiSession.transcript.isEmpty
|
|
? ExtL10n.string("keyboard.ai.recognizing")
|
|
: state.aiSession.transcript,
|
|
palette.textSecondary
|
|
)
|
|
case .generating:
|
|
return (aiGeneratingStatus, palette.textSecondary)
|
|
case .inactive, .idle, .ready, .awaitingSend, .inserted, .sent, .failed:
|
|
break
|
|
}
|
|
|
|
switch state.phase {
|
|
case .idle:
|
|
if !state.micDisabledHint.isEmpty {
|
|
return (state.micDisabledHint, palette.warning)
|
|
}
|
|
return nil
|
|
case .requestingPermissions:
|
|
return (
|
|
state.lastTranscript.isEmpty
|
|
? ExtL10n.string("keyboard.placeholder.preparing")
|
|
: state.lastTranscript,
|
|
palette.textSecondary
|
|
)
|
|
case .recording:
|
|
return (state.lastTranscript.isEmpty ? " " : state.lastTranscript, palette.textPrimary)
|
|
case .processing:
|
|
return (
|
|
state.lastTranscript.isEmpty
|
|
? ExtL10n.string("keyboard.placeholder.processing")
|
|
: state.lastTranscript,
|
|
palette.textSecondary
|
|
)
|
|
case .error(_, let message):
|
|
return (message ?? ExtL10n.string("keyboard.placeholder.error"), palette.warning)
|
|
case .denied(let reason):
|
|
let key = reason == .mic ? "keyboard.denied.mic" : "keyboard.denied.speech"
|
|
return (ExtL10n.string(key), palette.warning)
|
|
}
|
|
}
|
|
|
|
private var aiGeneratingStatus: String {
|
|
switch state.pendingClipboardSkillID {
|
|
case AIClipboardSkillCatalog.extractTodosID:
|
|
return ExtL10n.string("keyboard.ai.skill.extracting")
|
|
case AIClipboardSkillCatalog.extractEventsID:
|
|
return ExtL10n.string("keyboard.ai.skill.extractingEvents")
|
|
case AIClipboardSkillCatalog.navigateID:
|
|
return ExtL10n.string("keyboard.ai.skill.extractingAddress")
|
|
case AIClipboardSkillCatalog.saveToNotesID:
|
|
return ExtL10n.string("keyboard.ai.skill.namingNote")
|
|
default:
|
|
if !state.aiSession.transcript.isEmpty,
|
|
!AIClipboardPrompt.isInternalPrompt(state.aiSession.transcript) {
|
|
return state.aiSession.transcript
|
|
}
|
|
return ExtL10n.string("keyboard.ai.thinking")
|
|
}
|
|
}
|
|
|
|
private var hintCarousel: some View {
|
|
IntrinsicWidthCap(maxWidth: Layout.hotwordMaxWidth) {
|
|
Button {
|
|
guard let hint = currentHint else { return }
|
|
state.submitAIHint(hint)
|
|
} label: {
|
|
HStack(spacing: 6) {
|
|
if let hint = currentHint {
|
|
Image(systemName: hint.visualKind.systemImage)
|
|
.font(.system(size: 13, weight: .semibold))
|
|
}
|
|
Text(currentHint.map(\.resolvedDisplayText)
|
|
?? ExtL10n.string("keyboard.ai.placeholder"))
|
|
.font(.system(size: 13, weight: .medium))
|
|
.lineLimit(1)
|
|
.truncationMode(.tail)
|
|
}
|
|
.foregroundStyle(palette.textSecondary)
|
|
.padding(.horizontal, 12)
|
|
.frame(maxWidth: .infinity)
|
|
.frame(height: Layout.hotwordHeight)
|
|
.contentShape(Capsule())
|
|
.overlay(Capsule().stroke(palette.dividerStrong, lineWidth: 1))
|
|
}
|
|
.buttonStyle(.plain)
|
|
.contentShape(Capsule())
|
|
.disabled(currentHint == nil || !state.aiServiceAvailable || !assistantIsResting)
|
|
.accessibilityIdentifier("assistant.hint")
|
|
.accessibilityLabel(
|
|
Text(currentHint.map(\.resolvedDisplayText)
|
|
?? ExtL10n.string("keyboard.ai.placeholder"))
|
|
)
|
|
}
|
|
.opacity(hintOpacity)
|
|
.contentShape(Capsule())
|
|
}
|
|
|
|
// MARK: - Clipboard skills
|
|
|
|
private var clipboardSkillPager: some View {
|
|
GeometryReader { proxy in
|
|
let skillsPerPage = proxy.size.width < 352 ? 4 : 5
|
|
let pages = skillPages(from: visibleClipboardSkills, count: skillsPerPage)
|
|
VStack(spacing: 5) {
|
|
TabView(selection: $selectedSkillPage) {
|
|
ForEach(Array(pages.enumerated()), id: \.offset) { page, skills in
|
|
HStack(spacing: Layout.skillCellSpacing) {
|
|
ForEach(skills) { skill in
|
|
skillChip(skill)
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.tag(page)
|
|
}
|
|
}
|
|
.tabViewStyle(.page(indexDisplayMode: .never))
|
|
.accessibilityIdentifier("assistant.skills.pager")
|
|
|
|
HStack(spacing: Layout.pageDotSize) {
|
|
ForEach(pages.indices, id: \.self) { page in
|
|
Circle()
|
|
.fill(page == selectedSkillPage
|
|
? palette.textPrimary
|
|
: palette.textTertiary.opacity(0.45))
|
|
.frame(width: Layout.pageDotSize, height: Layout.pageDotSize)
|
|
}
|
|
}
|
|
.opacity(pages.count > 1 ? 1 : 0)
|
|
.accessibilityHidden(true)
|
|
}
|
|
.padding(.bottom, Layout.skillPaginationBottomInset)
|
|
}
|
|
}
|
|
|
|
private func skillPages(
|
|
from skills: [AIClipboardSkill],
|
|
count: Int
|
|
) -> [[AIClipboardSkill]] {
|
|
stride(from: 0, to: skills.count, by: count).map { start in
|
|
Array(skills[start ..< min(start + count, skills.count)])
|
|
}
|
|
}
|
|
|
|
private func skillChip(_ skill: AIClipboardSkill) -> some View {
|
|
Button {
|
|
state.submitAIClipboardSkill(skill)
|
|
} label: {
|
|
VStack(spacing: 6) {
|
|
Image(systemName: skill.systemImage)
|
|
.font(.system(size: 18, weight: .semibold))
|
|
.foregroundStyle(palette.textPrimary.opacity(0.85))
|
|
.frame(width: Layout.skillButtonSize, height: Layout.skillButtonSize)
|
|
.glassEffect(.regular.interactive(), in: Circle())
|
|
Text(clipboardSkillTitle(skill))
|
|
.font(TypeStyle.caption)
|
|
.foregroundStyle(palette.textSecondary)
|
|
.lineLimit(1)
|
|
.minimumScaleFactor(0.75)
|
|
}
|
|
.frame(width: Layout.skillCellWidth)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.disabled(!state.aiServiceAvailable || state.aiSession.isBusy)
|
|
.accessibilityIdentifier("assistant.skill.\(skill.id)")
|
|
.accessibilityLabel(Text(clipboardSkillTitle(skill)))
|
|
}
|
|
|
|
private func clipboardSkillTitle(_ skill: AIClipboardSkill) -> String {
|
|
if skill.id == AIClipboardSkillCatalog.translateID {
|
|
return AIClipboardSkillCatalog.translateButtonTitle(
|
|
translationTargetLocaleId: state.translationTargetLocaleId,
|
|
uiLanguage: state.uiLanguage
|
|
)
|
|
}
|
|
if let custom = skill.customName?.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
!custom.isEmpty {
|
|
return custom
|
|
}
|
|
return ExtL10n.string(skill.titleKey)
|
|
}
|
|
|
|
private var showsClipboardSkills: Bool {
|
|
#if DEBUG
|
|
if let preview = Self.debugPreviewSkills,
|
|
!preview.isEmpty,
|
|
!debugSkillsDismissed {
|
|
return assistantIsResting
|
|
}
|
|
#endif
|
|
if showsOOBEClipboardSkill {
|
|
return assistantIsResting
|
|
}
|
|
guard assistantIsResting, state.clipboardHistoryEnabled else { return false }
|
|
guard !visibleClipboardSkills.isEmpty,
|
|
let newest = clipboardHistory.newestEntry,
|
|
newest.id != dismissedClipboardEntryID else {
|
|
return false
|
|
}
|
|
return AIHintPool.isClipboardSkillWindowActive(
|
|
clipboardHistoryEnabled: true,
|
|
newestClipboard: newest
|
|
)
|
|
}
|
|
|
|
private var visibleClipboardSkills: [AIClipboardSkill] {
|
|
#if DEBUG
|
|
if let preview = Self.debugPreviewSkills, !preview.isEmpty {
|
|
return preview
|
|
}
|
|
#endif
|
|
if let expectedSkillID = oobeExpectedSkillID {
|
|
return AIClipboardSkillCatalog.catalog.filter { $0.id == expectedSkillID }
|
|
}
|
|
guard let newest = clipboardHistory.newestEntry,
|
|
let snapshot = semanticRanking.snapshot,
|
|
snapshot.entryID == newest.id else {
|
|
return state.enabledClipboardSkills
|
|
}
|
|
return ClipboardSkillSemanticRanker.ranked(
|
|
skills: state.enabledClipboardSkills,
|
|
sourceText: newest.text,
|
|
analysis: snapshot.analysis,
|
|
uiLanguage: state.uiLanguage
|
|
)
|
|
}
|
|
|
|
private var showsOOBEClipboardSkill: Bool {
|
|
oobeExpectedSkillID != nil
|
|
}
|
|
|
|
private var oobeExpectedSkillID: String? {
|
|
switch state.oobePracticeSession?.expectedFeature {
|
|
case .clipboardTranslate:
|
|
return AIClipboardSkillCatalog.translateID
|
|
case .clipboardReply:
|
|
return AIClipboardSkillCatalog.replyID
|
|
case .voiceInput, .askAI, nil:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
private var onboardingPracticeHint: String? {
|
|
switch state.oobePracticeSession?.expectedFeature {
|
|
case .voiceInput:
|
|
return ExtL10n.string("keyboard.onboarding.practice.mic")
|
|
case .clipboardTranslate:
|
|
return ExtL10n.string("keyboard.onboarding.practice.translate")
|
|
case .clipboardReply:
|
|
return ExtL10n.string("keyboard.onboarding.practice.reply")
|
|
case .askAI:
|
|
return ExtL10n.string("keyboard.onboarding.practice.askAI")
|
|
case nil:
|
|
return state.isOnboardingPracticeActive
|
|
? ExtL10n.string("keyboard.onboarding.practice.mic")
|
|
: nil
|
|
}
|
|
}
|
|
|
|
// MARK: - Primary actions
|
|
|
|
private var primaryActionRow: some View {
|
|
ZStack {
|
|
HStack {
|
|
primarySideButton(leading: true)
|
|
.offset(x: Layout.sideButtonEdgeInset)
|
|
Spacer(minLength: 0)
|
|
primarySideButton(leading: false)
|
|
.offset(x: -Layout.sideButtonEdgeInset)
|
|
}
|
|
.opacity(sideButtonsVisible ? 1 : 0)
|
|
.allowsHitTesting(sideButtonsVisible)
|
|
.accessibilityHidden(!sideButtonsVisible)
|
|
|
|
microphoneButton
|
|
}
|
|
.frame(maxWidth: Layout.actionClusterMaxWidth)
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func primarySideButton(leading: Bool) -> some View {
|
|
let swapped = state.handednessPreference.swapsActionKeys
|
|
let showsSpace = leading ? swapped : !swapped
|
|
if showsSpace {
|
|
circleActionButton(
|
|
systemName: "space",
|
|
label: ExtL10n.string("keyboard.assistant.space"),
|
|
action: state.insertSpace
|
|
)
|
|
.accessibilityIdentifier("assistant.space")
|
|
} else {
|
|
repeatingDeleteButton
|
|
}
|
|
}
|
|
|
|
private var microphoneButton: some View {
|
|
ZStack {
|
|
Capsule()
|
|
.fill(.clear)
|
|
.glassEffect(
|
|
.regular.tint(microphoneTint).interactive(),
|
|
in: Capsule()
|
|
)
|
|
microphoneContent
|
|
}
|
|
.frame(width: primaryButtonWidth, height: primaryButtonHeight)
|
|
.contentShape(Capsule())
|
|
.scaleEffect(microphoneIsPressable ? 1 : 0.99)
|
|
.onLongPressGesture(
|
|
minimumDuration: 0.45,
|
|
maximumDistance: 120,
|
|
pressing: handleMicrophonePressing,
|
|
perform: beginAIMicrophone
|
|
)
|
|
// Processing is an explicit waiting state: the capsule stays visible
|
|
// for feedback but must not react to taps or Liquid Glass presses.
|
|
.allowsHitTesting(microphoneIsPressable)
|
|
.accessibilityElement()
|
|
.accessibilityAddTraits(.isButton)
|
|
.accessibilityIdentifier("assistant.mic.\(microphoneStateIdentifier)")
|
|
.accessibilityLabel(ExtL10n.text("keyboard.assistant.micA11y"))
|
|
.accessibilityHint(ExtL10n.text("keyboard.assistant.micHint"))
|
|
.accessibilityAction(named: ExtL10n.text("keyboard.assistant.dictationAction")) {
|
|
guard microphoneIsPressable else { return }
|
|
state.tapMic()
|
|
}
|
|
.accessibilityAction(named: ExtL10n.text("keyboard.assistant.aiAction")) {
|
|
guard assistantIsResting else { return }
|
|
state.tapAIMic()
|
|
}
|
|
.disabled(!microphoneIsPressable)
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var microphoneContent: some View {
|
|
if state.assistantInsertionSucceeded {
|
|
Image(systemName: "checkmark")
|
|
.font(.system(size: 27, weight: .bold))
|
|
.foregroundStyle(.white)
|
|
} else {
|
|
switch state.aiSession.phase {
|
|
case .listening:
|
|
waveform(color: .white)
|
|
case .preparing, .recognizing, .generating:
|
|
ProgressView().tint(.white)
|
|
case .inactive, .idle, .ready, .awaitingSend, .inserted, .sent, .failed:
|
|
ordinaryMicrophoneContent
|
|
}
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var ordinaryMicrophoneContent: some View {
|
|
switch state.phase {
|
|
case .recording:
|
|
waveform(color: Color(red: 1, green: 0.78, blue: 0.78))
|
|
case .requestingPermissions, .processing:
|
|
ProgressView().tint(.white)
|
|
case .error:
|
|
Image(systemName: "exclamationmark.triangle.fill")
|
|
.font(.system(size: 25, weight: .semibold))
|
|
.foregroundStyle(.white)
|
|
case .idle, .denied:
|
|
Image(systemName: "mic.fill")
|
|
.font(.system(size: 28, weight: .semibold))
|
|
.foregroundStyle(.white)
|
|
.symbolEffect(.breathe, isActive: micIsHoldingForAI)
|
|
}
|
|
}
|
|
|
|
private func waveform(color: Color) -> some View {
|
|
WaveformView(
|
|
level: state.level,
|
|
barCount: 7,
|
|
color: color,
|
|
active: true
|
|
)
|
|
.frame(width: 38, height: 26)
|
|
.clipped()
|
|
}
|
|
|
|
private var microphoneTint: Color {
|
|
if state.assistantInsertionSucceeded {
|
|
return palette.accent
|
|
}
|
|
if micIsHoldingForAI {
|
|
return palette.aiTeal
|
|
}
|
|
switch state.aiSession.phase {
|
|
case .listening:
|
|
return palette.aiTeal
|
|
case .preparing, .recognizing, .generating:
|
|
// Match ordinary dictation's non-interactive waiting appearance,
|
|
// including hint-card requests that start directly in generation.
|
|
return palette.recordRed.opacity(0.72)
|
|
case .inactive, .idle, .ready, .awaitingSend, .inserted, .sent, .failed:
|
|
break
|
|
}
|
|
switch state.phase {
|
|
case .recording:
|
|
return palette.recordRed
|
|
case .requestingPermissions, .processing:
|
|
return palette.recordRed.opacity(0.72)
|
|
case .error, .denied:
|
|
return palette.warning
|
|
case .idle:
|
|
return palette.accent
|
|
}
|
|
}
|
|
|
|
private var microphoneStateIdentifier: String {
|
|
switch state.aiSession.phase {
|
|
case .preparing:
|
|
return "aiPreparing"
|
|
case .listening:
|
|
return "aiListening"
|
|
case .recognizing:
|
|
return "aiRecognizing"
|
|
case .generating:
|
|
return "aiGenerating"
|
|
case .inactive, .idle, .ready, .awaitingSend, .inserted, .sent, .failed:
|
|
break
|
|
}
|
|
switch state.phase {
|
|
case .requestingPermissions:
|
|
return "dictationPreparing"
|
|
case .recording:
|
|
return "dictationRecording"
|
|
case .processing:
|
|
return "dictationProcessing"
|
|
case .error:
|
|
return "error"
|
|
case .denied:
|
|
return "denied"
|
|
case .idle:
|
|
return state.assistantInsertionSucceeded ? "success" : "idle"
|
|
}
|
|
}
|
|
|
|
private var primaryButtonWidth: CGFloat {
|
|
state.layoutWidth > 0 && state.layoutWidth < 350
|
|
? Layout.compactPrimaryWidth
|
|
: Layout.primaryWidth
|
|
}
|
|
|
|
private var primaryButtonHeight: CGFloat {
|
|
state.layoutWidth > 0 && state.layoutWidth < 350
|
|
? Layout.compactPrimaryHeight
|
|
: Layout.primaryHeight
|
|
}
|
|
|
|
private func handleMicrophonePressing(_ pressing: Bool) {
|
|
if pressing {
|
|
micLongPressConsumed = false
|
|
guard microphoneIsPressable else { return }
|
|
state.setMicTouchActive(true)
|
|
if assistantIsResting {
|
|
withAnimation(.easeOut(duration: 0.12)) {
|
|
micIsHoldingForAI = true
|
|
}
|
|
}
|
|
return
|
|
}
|
|
state.setMicTouchActive(false)
|
|
withAnimation(.easeOut(duration: 0.12)) {
|
|
micIsHoldingForAI = false
|
|
}
|
|
if micLongPressConsumed {
|
|
micLongPressConsumed = false
|
|
return
|
|
}
|
|
handleMicrophoneTap()
|
|
}
|
|
|
|
private func beginAIMicrophone() {
|
|
guard assistantIsResting else { return }
|
|
micLongPressConsumed = true
|
|
showsLongPressCoach = false
|
|
KeyboardHapticFeedback.play(
|
|
role: .action,
|
|
intensity: state.keyboardHapticIntensity
|
|
)
|
|
state.tapAIMic()
|
|
}
|
|
|
|
private func handleMicrophoneTap() {
|
|
guard microphoneIsPressable else { return }
|
|
if state.aiSession.phase == .listening {
|
|
state.tapAIMic()
|
|
} else {
|
|
state.tapMic()
|
|
}
|
|
}
|
|
|
|
private var microphoneIsPressable: Bool {
|
|
if state.aiSession.isBusy {
|
|
return state.aiSession.phase == .listening
|
|
}
|
|
switch state.phase {
|
|
case .processing, .requestingPermissions:
|
|
return false
|
|
case .idle, .recording, .error, .denied:
|
|
return !state.micDisabled
|
|
}
|
|
}
|
|
|
|
// MARK: - Secondary actions
|
|
|
|
private var secondaryActionRow: some View {
|
|
ZStack {
|
|
HStack(spacing: 0) {
|
|
lowerActionCluster(leading: true)
|
|
Spacer(minLength: 0)
|
|
lowerActionCluster(leading: false)
|
|
}
|
|
.opacity(sideButtonsVisible ? 1 : 0)
|
|
.allowsHitTesting(sideButtonsVisible)
|
|
.accessibilityHidden(!sideButtonsVisible)
|
|
|
|
fieldActionButton
|
|
}
|
|
.frame(maxWidth: Layout.actionClusterMaxWidth)
|
|
.frame(maxWidth: .infinity)
|
|
.opacity(activePipeline ? 0 : 1)
|
|
.allowsHitTesting(!activePipeline)
|
|
.accessibilityHidden(activePipeline)
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func lowerActionCluster(leading: Bool) -> some View {
|
|
let button = lowerSideButton(leading: leading)
|
|
.offset(x: leading
|
|
? Layout.sideButtonEdgeInset
|
|
: -Layout.sideButtonEdgeInset)
|
|
if state.showsSystemGlobeKey {
|
|
if leading {
|
|
HStack(spacing: 8) {
|
|
SystemGlobeKey(
|
|
state: state,
|
|
width: compactIPadLayout ? 40 : 44,
|
|
height: lowerCircleSize
|
|
)
|
|
button
|
|
}
|
|
} else {
|
|
HStack(spacing: 8) {
|
|
button
|
|
Color.clear.frame(width: compactIPadLayout ? 40 : 44)
|
|
}
|
|
}
|
|
} else {
|
|
button
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func lowerSideButton(leading: Bool) -> some View {
|
|
let swapped = state.handednessPreference.swapsActionKeys
|
|
let showsEdit = leading ? swapped : !swapped
|
|
if showsEdit {
|
|
circleActionButton(
|
|
systemName: "pencil.line",
|
|
label: ExtL10n.string("keyboard.edit.apply"),
|
|
disabled: !state.editAvailable,
|
|
onPressingChanged: state.setMicTouchActive,
|
|
action: state.beginEditLastInput
|
|
)
|
|
.accessibilityIdentifier("assistant.edit")
|
|
.opacity(state.editAvailable ? 1 : 0)
|
|
.accessibilityHidden(!state.editAvailable)
|
|
} else {
|
|
circleActionButton(
|
|
systemName: "arrow.uturn.backward",
|
|
label: ExtL10n.string("keyboard.undoA11y"),
|
|
disabled: !state.undoAvailable,
|
|
action: state.undoLastInsertion
|
|
)
|
|
.accessibilityIdentifier("assistant.undo")
|
|
.opacity(state.undoAvailable ? 1 : 0)
|
|
.accessibilityHidden(!state.undoAvailable)
|
|
}
|
|
}
|
|
|
|
private var fieldActionButton: some View {
|
|
Button(action: performFieldAction) {
|
|
Image(systemName: fieldActionSystemImage)
|
|
.font(.system(size: 20, weight: .semibold))
|
|
.foregroundStyle(fieldActionButtonForeground)
|
|
.frame(
|
|
width: fieldActionButtonWidth,
|
|
height: KeyboardChromeLayout.assistantActionCapsuleHeight
|
|
)
|
|
.background(fieldActionButtonFill, in: Capsule())
|
|
.contentShape(Capsule())
|
|
}
|
|
.buttonStyle(.plain)
|
|
.disabled(!state.assistantActionAvailable)
|
|
.accessibilityIdentifier(
|
|
"assistant.action.\(state.returnKeyRole.assistantActionIdentifier)"
|
|
)
|
|
.accessibilityLabel(ExtL10n.text(state.returnKeyRole.titleKey))
|
|
}
|
|
|
|
private var fieldActionSystemImage: String {
|
|
fieldActionConfirmationVisible
|
|
? "checkmark"
|
|
: state.returnKeyRole.assistantActionSystemImage
|
|
}
|
|
|
|
private var fieldActionButtonFill: Color {
|
|
state.assistantActionAvailable
|
|
? NativeKeyboardKeyColors.fill(for: colorScheme)
|
|
: NativeKeyboardKeyColors.pressedFill(for: colorScheme)
|
|
}
|
|
|
|
private var fieldActionButtonForeground: Color {
|
|
state.assistantActionAvailable
|
|
? NativeKeyboardKeyColors.text(for: colorScheme)
|
|
: NativeKeyboardKeyColors.text(for: colorScheme).opacity(0.58)
|
|
}
|
|
|
|
private func performFieldAction() {
|
|
guard state.assistantActionAvailable else { return }
|
|
state.performAssistantFieldAction()
|
|
fieldActionConfirmationVisible = true
|
|
Task { @MainActor in
|
|
try? await Task.sleep(for: .milliseconds(550))
|
|
fieldActionConfirmationVisible = false
|
|
}
|
|
}
|
|
|
|
private var fieldActionButtonWidth: CGFloat {
|
|
compactIPadLayout
|
|
? Layout.compactIPadFieldActionWidth
|
|
: Layout.fieldActionWidth
|
|
}
|
|
|
|
private var lowerCircleSize: CGFloat {
|
|
compactIPadLayout ? Layout.compactIPadCircleSize : Layout.circleSize
|
|
}
|
|
|
|
private var compactIPadLayout: Bool {
|
|
state.usesIPadLayoutMetrics && state.layoutWidth > 0 && state.layoutWidth < 420
|
|
}
|
|
|
|
private func circleActionButton(
|
|
systemName: String,
|
|
label: String,
|
|
disabled: Bool = false,
|
|
onPressingChanged: @escaping (Bool) -> Void = { _ in },
|
|
action: @escaping () -> Void
|
|
) -> some View {
|
|
RectangularToolbarButton(
|
|
systemName: systemName,
|
|
label: label,
|
|
disabled: disabled,
|
|
usesLiquidGlass: true,
|
|
usesCircleGlass: true,
|
|
hapticIntensity: state.keyboardHapticIntensity,
|
|
onPressingChanged: onPressingChanged,
|
|
action: action
|
|
)
|
|
.frame(width: lowerCircleSize, height: lowerCircleSize)
|
|
}
|
|
|
|
private var repeatingDeleteButton: some View {
|
|
RepeatingPressButton(
|
|
disabled: false,
|
|
hapticIntensity: state.keyboardHapticIntensity,
|
|
action: state.deleteBackward
|
|
) { isPressed in
|
|
ZStack {
|
|
Color.clear
|
|
Image(systemName: "delete.left")
|
|
.font(.system(size: 17, weight: .semibold))
|
|
.foregroundStyle(NativeKeyboardKeyColors.text(for: colorScheme))
|
|
}
|
|
.frame(width: lowerCircleSize, height: lowerCircleSize)
|
|
.glassEffect(.regular.interactive(), in: Circle())
|
|
.scaleEffect(isPressed ? 0.97 : 1)
|
|
.animation(.easeOut(duration: 0.08), value: isPressed)
|
|
}
|
|
.accessibilityIdentifier("assistant.delete")
|
|
.accessibilityLabel(Text(ExtL10n.string("keyboard.assistant.delete")))
|
|
}
|
|
|
|
// MARK: - Visibility and clipboard
|
|
|
|
private var assistantIsResting: Bool {
|
|
guard !state.aiSession.isBusy, !state.aiSession.canInsert else { return false }
|
|
switch state.phase {
|
|
case .idle, .error, .denied:
|
|
return true
|
|
case .requestingPermissions, .recording, .processing:
|
|
return false
|
|
}
|
|
}
|
|
|
|
private var activePipeline: Bool {
|
|
if state.aiSession.isBusy { return true }
|
|
return ordinaryPipelineIsActive
|
|
}
|
|
|
|
private var ordinaryPipelineIsActive: Bool {
|
|
switch state.phase {
|
|
case .requestingPermissions, .recording, .processing:
|
|
return true
|
|
case .idle, .error, .denied:
|
|
return false
|
|
}
|
|
}
|
|
|
|
private var sideButtonsVisible: Bool {
|
|
assistantIsResting
|
|
}
|
|
|
|
private var shouldShowClipboardSuggestion: Bool {
|
|
guard state.canShowClipboardEntry else { return false }
|
|
guard let text = state.clipboardSuggestionText, !text.isEmpty else { return false }
|
|
return true
|
|
}
|
|
|
|
private func insertClipboardSuggestion() {
|
|
dismissedClipboardEntryID = clipboardHistory.newestEntry?.id
|
|
if let text = state.clipboardSuggestionText {
|
|
state.insertClipboardText(text)
|
|
}
|
|
}
|
|
|
|
private func dismissClipboardPresentation() {
|
|
#if DEBUG
|
|
if Self.debugPreviewSkills != nil {
|
|
debugSkillsDismissed = true
|
|
}
|
|
#endif
|
|
dismissedClipboardEntryID = clipboardHistory.newestEntry?.id
|
|
state.dismissClipboardSuggestion()
|
|
}
|
|
|
|
// MARK: - Carousel
|
|
|
|
private func resetCarousel() {
|
|
reloadHintPool(resetBag: true)
|
|
guard !showsClipboardSkills else { return }
|
|
showNextHint(animated: false)
|
|
}
|
|
|
|
private func reloadHintPool(resetBag: Bool) {
|
|
let locale = AIHintLocaleResolver.packLocale()
|
|
let pack = AIHintStore.resolvedPack(locale: locale)
|
|
poolCards = AIHintPool.activeCards(pack: pack)
|
|
if resetBag {
|
|
carouselBag.reset()
|
|
}
|
|
}
|
|
|
|
private func showNextHint(animated: Bool) {
|
|
guard let next = carouselBag.next(from: poolCards) else {
|
|
currentHint = nil
|
|
return
|
|
}
|
|
if animated, !reduceMotion {
|
|
withAnimation(.easeOut(duration: 0.18)) { hintOpacity = 0 }
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.18) {
|
|
currentHint = next
|
|
withAnimation(.easeIn(duration: 0.18)) { hintOpacity = 1 }
|
|
}
|
|
} else {
|
|
currentHint = next
|
|
hintOpacity = 1
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Uses a subview's intrinsic width until it reaches a visual cap. Unlike
|
|
/// `frame(maxWidth:)`, this layout does not expand short labels to the cap,
|
|
/// so each capsule and its complete hit target follow the actual content.
|
|
private struct IntrinsicWidthCap: Layout {
|
|
let maxWidth: CGFloat
|
|
|
|
func sizeThatFits(
|
|
proposal: ProposedViewSize,
|
|
subviews: Subviews,
|
|
cache: inout ()
|
|
) -> CGSize {
|
|
guard let subview = subviews.first else { return .zero }
|
|
let ideal = subview.sizeThatFits(.unspecified)
|
|
let availableWidth = min(proposal.width ?? maxWidth, maxWidth)
|
|
let width = min(ideal.width, availableWidth)
|
|
let fitted = subview.sizeThatFits(
|
|
ProposedViewSize(width: width, height: proposal.height)
|
|
)
|
|
return CGSize(width: width, height: fitted.height)
|
|
}
|
|
|
|
func placeSubviews(
|
|
in bounds: CGRect,
|
|
proposal: ProposedViewSize,
|
|
subviews: Subviews,
|
|
cache: inout ()
|
|
) {
|
|
guard let subview = subviews.first else { return }
|
|
subview.place(
|
|
at: CGPoint(x: bounds.midX, y: bounds.midY),
|
|
anchor: .center,
|
|
proposal: ProposedViewSize(width: bounds.width, height: bounds.height)
|
|
)
|
|
}
|
|
}
|