feat(keyboard): ship AI hint carousel, home library cards, and clipboard polish
Rotate AI idle suggestions with optional remote packs, move history/dictionary onto self-sizing Home preview cards, harden clipboard capture/prompting, and simplify keyboard chrome by dropping most liquid-glass shadows.
This commit is contained in:
@@ -1,8 +1,8 @@
|
||||
// AIKeyboardView.swift
|
||||
// OSGKeyboard · Keyboard Extension
|
||||
//
|
||||
// Temporary voice-to-AI surface. The latest answer remains visible while a
|
||||
// follow-up is running and is inserted only through the explicit Send action.
|
||||
// Product voice-to-AI conversation surface. The latest answer remains visible
|
||||
// while a follow-up runs and is inserted only through the explicit Send action.
|
||||
|
||||
import SwiftUI
|
||||
import OSGKeyboardShared
|
||||
@@ -14,13 +14,23 @@ struct AIKeyboardView: View {
|
||||
static let actionButtonHeight: CGFloat = 50
|
||||
static let actionButtonMaxWidth: CGFloat = 150
|
||||
static let statusHeight: CGFloat = 20
|
||||
static let carouselInterval: TimeInterval = 4
|
||||
}
|
||||
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||
@ObservedObject var state: KeyboardState
|
||||
@ObservedObject var typing: TypingSessionController
|
||||
/// A copy made while the keyboard is visible must reach the carousel
|
||||
/// immediately, not on the next rotation tick.
|
||||
@ObservedObject private var clipboardHistory = ClipboardHistoryStore.shared
|
||||
let onInsert: (String) -> Void
|
||||
|
||||
@State private var currentHint: AIHintCard?
|
||||
@State private var hintOpacity: Double = 1
|
||||
@State private var carouselBag = AIHintCarouselBag()
|
||||
@State private var poolCards: [AIHintCard] = []
|
||||
|
||||
private var palette: ThemePalette {
|
||||
colorScheme == .dark ? Palette.dark : Palette.light
|
||||
}
|
||||
@@ -37,6 +47,26 @@ struct AIKeyboardView: View {
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: resolvedHeight)
|
||||
.environment(\.themePalette, palette)
|
||||
.onAppear { resetCarousel() }
|
||||
.onChange(of: state.aiSession.phase) { _, phase in
|
||||
guard phase == .idle || phase == .failed else { return }
|
||||
resetCarousel()
|
||||
}
|
||||
.onChange(of: state.clipboardHistoryEnabled) { _, _ in resetCarousel() }
|
||||
.onChange(of: clipboardHistory.entries.first?.id) { _, _ in resetCarousel() }
|
||||
.onReceive(
|
||||
Timer.publish(every: Layout.carouselInterval, on: .main, in: .common).autoconnect()
|
||||
) { _ in
|
||||
guard showsPlaceholder else { return }
|
||||
// Reduce Motion stops the rotation, not the data: a card whose
|
||||
// clipboard window has closed must still leave the carousel.
|
||||
reloadHintPool(resetBag: false)
|
||||
if reduceMotion, let hint = currentHint,
|
||||
poolCards.contains(where: { $0.id == hint.id }) {
|
||||
return
|
||||
}
|
||||
showNextHint(animated: !reduceMotion)
|
||||
}
|
||||
}
|
||||
|
||||
private var resolvedHeight: CGFloat {
|
||||
@@ -69,7 +99,9 @@ struct AIKeyboardView: View {
|
||||
)
|
||||
}
|
||||
.padding(.horizontal, KeyboardTopBarMetrics.nestedHorizontalInset)
|
||||
} else if let suggestion = state.clipboardSuggestionText, !suggestion.isEmpty {
|
||||
} else if state.canShowClipboardEntry,
|
||||
let suggestion = state.clipboardSuggestionText,
|
||||
!suggestion.isEmpty {
|
||||
// Replaces logo + capsule tabs until dismissed.
|
||||
ClipboardSuggestionBar(
|
||||
text: suggestion,
|
||||
@@ -95,12 +127,7 @@ struct AIKeyboardView: View {
|
||||
private var answerArea: some View {
|
||||
ZStack(alignment: .bottom) {
|
||||
if showsPlaceholder {
|
||||
// Empty-state tip: geometric center of the answer plane.
|
||||
Text(ExtL10n.string("keyboard.ai.placeholder"))
|
||||
.font(TypeStyle.body)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, Spacing.md)
|
||||
hintCarousel
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else {
|
||||
ScrollViewReader { proxy in
|
||||
@@ -141,7 +168,36 @@ struct AIKeyboardView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// No draft/answer yet — show the centered mic guidance instead of a scroll body.
|
||||
private var hintCarousel: some View {
|
||||
Button {
|
||||
guard let hint = currentHint else { return }
|
||||
state.submitAIHint(hint)
|
||||
} label: {
|
||||
Text(currentHint?.displayText ?? ExtL10n.string("keyboard.ai.placeholder"))
|
||||
.font(TypeStyle.body)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
.multilineTextAlignment(.center)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
.padding(.horizontal, Spacing.md)
|
||||
.opacity(hintOpacity)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
// A busy session already owns the surface; the status line explains a
|
||||
// missing LLM. Both keep the hint from being a tap with no outcome.
|
||||
.disabled(currentHint == nil || !state.aiServiceAvailable || state.aiSession.isBusy)
|
||||
.accessibilityLabel(
|
||||
Text(
|
||||
currentHint.map {
|
||||
"\(ExtL10n.string("keyboard.ai.hintA11yPrefix"))\($0.displayText)"
|
||||
} ?? ExtL10n.string("keyboard.ai.placeholder")
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// No draft/answer yet — show the centered hint carousel instead of a scroll body.
|
||||
private var showsPlaceholder: Bool {
|
||||
let hasDraft = !(state.aiSession.draftAnswerText?.isEmpty ?? true)
|
||||
return !hasDraft && state.aiSession.answer == nil
|
||||
@@ -173,7 +229,7 @@ struct AIKeyboardView: View {
|
||||
private var aiMicrophoneButton: some View {
|
||||
Button(action: state.tapAIMic) {
|
||||
ZStack {
|
||||
Capsule().fill(palette.accent)
|
||||
Color.clear
|
||||
if state.aiSession.phase == .listening {
|
||||
Capsule()
|
||||
.stroke(Color.white.opacity(0.28), lineWidth: 1.5)
|
||||
@@ -187,6 +243,8 @@ struct AIKeyboardView: View {
|
||||
minHeight: Layout.actionButtonHeight,
|
||||
maxHeight: Layout.actionButtonHeight
|
||||
)
|
||||
// 实心填充、无外扩阴影:避免玻璃投影被键盘底边裁切。
|
||||
.background(palette.accent, in: Capsule())
|
||||
.contentShape(Capsule())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
@@ -228,11 +286,8 @@ struct AIKeyboardView: View {
|
||||
minHeight: Layout.actionButtonHeight,
|
||||
maxHeight: Layout.actionButtonHeight
|
||||
)
|
||||
.background(
|
||||
answerActionFill,
|
||||
in: Capsule()
|
||||
)
|
||||
.overlay(Capsule().stroke(answerActionBorder, lineWidth: 0.5))
|
||||
// 实心填充、无外扩阴影:避免玻璃投影被键盘底边裁切。
|
||||
.background(answerActionFill, in: Capsule())
|
||||
.contentShape(Capsule())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
@@ -269,11 +324,11 @@ struct AIKeyboardView: View {
|
||||
|
||||
private var answerActionFill: Color {
|
||||
guard state.aiSession.canPerformAnswerAction else {
|
||||
return palette.surfaceElevated
|
||||
return palette.surfaceElevated.opacity(0.55)
|
||||
}
|
||||
return state.aiSession.canSend
|
||||
? palette.accent
|
||||
: NativeKeyboardKeyColors.fill(for: colorScheme)
|
||||
: palette.surfaceElevated
|
||||
}
|
||||
|
||||
private var answerActionForeground: Color {
|
||||
@@ -285,13 +340,6 @@ struct AIKeyboardView: View {
|
||||
: NativeKeyboardKeyColors.text(for: colorScheme)
|
||||
}
|
||||
|
||||
private var answerActionBorder: Color {
|
||||
guard state.aiSession.canSend else {
|
||||
return palette.divider
|
||||
}
|
||||
return Color.black.opacity(colorScheme == .dark ? 0.10 : 0.08)
|
||||
}
|
||||
|
||||
private var microphoneDisabled: Bool {
|
||||
switch state.aiSession.phase {
|
||||
case .preparing, .recognizing, .generating:
|
||||
@@ -339,4 +387,42 @@ struct AIKeyboardView: View {
|
||||
? "keyboard.ai.stopA11y"
|
||||
: "keyboard.ai.startA11y"
|
||||
}
|
||||
|
||||
// MARK: - Carousel
|
||||
|
||||
/// Rebuild the pool and show a card right away, without a fade.
|
||||
private func resetCarousel() {
|
||||
reloadHintPool(resetBag: true)
|
||||
showNextHint(animated: false)
|
||||
}
|
||||
|
||||
private func reloadHintPool(resetBag: Bool) {
|
||||
let locale = AIHintLocaleResolver.packLocale()
|
||||
let pack = AIHintStore.resolvedPack(locale: locale)
|
||||
poolCards = AIHintPool.activeCards(
|
||||
pack: pack,
|
||||
clipboardHistoryEnabled: state.clipboardHistoryEnabled,
|
||||
newestClipboard: clipboardHistory.newestEntry
|
||||
)
|
||||
if resetBag {
|
||||
carouselBag.reset()
|
||||
}
|
||||
}
|
||||
|
||||
private func showNextHint(animated: Bool) {
|
||||
guard let next = carouselBag.next(from: poolCards) else {
|
||||
currentHint = nil
|
||||
return
|
||||
}
|
||||
if animated, !reduceMotion {
|
||||
withAnimation(Motion.soft) { hintOpacity = 0 }
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
|
||||
currentHint = next
|
||||
withAnimation(Motion.soft) { hintOpacity = 1 }
|
||||
}
|
||||
} else {
|
||||
currentHint = next
|
||||
hintOpacity = 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user