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:
@@ -124,6 +124,7 @@ struct ClipboardEnableGuideView: View {
|
||||
struct ClipboardHistoryPanelView: View {
|
||||
@Environment(\.themePalette) private var palette
|
||||
@ObservedObject var history: ClipboardHistoryStore
|
||||
@State private var showClearConfirmation = false
|
||||
|
||||
let onClose: () -> Void
|
||||
let onClear: () -> Void
|
||||
@@ -132,57 +133,120 @@ struct ClipboardHistoryPanelView: View {
|
||||
let pastePermissionHint: String?
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
ClipboardPanelHeader(onClose: onClose) {
|
||||
Button(action: onClear) {
|
||||
Image(systemName: "trash")
|
||||
.font(.system(size: KeyboardTopBarMetrics.trailingChipIconSize, weight: .medium))
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.frame(
|
||||
width: KeyboardTopBarMetrics.trailingChipSize,
|
||||
height: KeyboardTopBarMetrics.trailingChipSize
|
||||
)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(history.entries.isEmpty)
|
||||
.opacity(history.entries.isEmpty ? 0.35 : 1)
|
||||
}
|
||||
|
||||
if let pastePermissionHint, !pastePermissionHint.isEmpty {
|
||||
Text(pastePermissionHint)
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(palette.warning)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.bottom, 6)
|
||||
}
|
||||
|
||||
if history.entries.isEmpty {
|
||||
ExtL10n.text("keyboard.clipboard.panel.empty")
|
||||
.font(.system(size: 14))
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else {
|
||||
ScrollView {
|
||||
LazyVStack(alignment: .leading, spacing: 10) {
|
||||
ForEach(history.entries) { entry in
|
||||
ClipboardHistoryRow(
|
||||
entry: entry,
|
||||
onInsert: { onInsert(entry.text) },
|
||||
onInsertToken: { onInsert($0) },
|
||||
onDelete: { onDelete(entry.id) }
|
||||
)
|
||||
}
|
||||
ZStack {
|
||||
VStack(spacing: 0) {
|
||||
ClipboardPanelHeader(onClose: onClose) {
|
||||
Button {
|
||||
showClearConfirmation = true
|
||||
} label: {
|
||||
Image(systemName: "trash")
|
||||
.font(.system(
|
||||
size: KeyboardTopBarMetrics.trailingChipIconSize,
|
||||
weight: .medium
|
||||
))
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
// HIG minimum hit target; icon stays visually small and centered.
|
||||
.frame(width: 44, height: 44)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.bottom, 12)
|
||||
.buttonStyle(.plain)
|
||||
.disabled(history.entries.isEmpty)
|
||||
.opacity(history.entries.isEmpty ? 0.35 : 1)
|
||||
}
|
||||
|
||||
if let pastePermissionHint, !pastePermissionHint.isEmpty {
|
||||
Text(pastePermissionHint)
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(palette.warning)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.bottom, 6)
|
||||
}
|
||||
|
||||
if history.entries.isEmpty {
|
||||
ExtL10n.text("keyboard.clipboard.panel.empty")
|
||||
.font(.system(size: 14))
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else {
|
||||
ScrollView {
|
||||
LazyVStack(alignment: .leading, spacing: 10) {
|
||||
ForEach(history.entries) { entry in
|
||||
ClipboardHistoryRow(
|
||||
entry: entry,
|
||||
onInsert: { onInsert(entry.text) },
|
||||
onInsertToken: { onInsert($0) },
|
||||
onDelete: { onDelete(entry.id) }
|
||||
)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.bottom, 12)
|
||||
}
|
||||
}
|
||||
}
|
||||
.allowsHitTesting(!showClearConfirmation)
|
||||
|
||||
if showClearConfirmation {
|
||||
clearConfirmationOverlay
|
||||
.transition(.scale(scale: 0.96).combined(with: .opacity))
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
// Transparent — let the system keyboard chrome show through.
|
||||
.background(Color.clear)
|
||||
.animation(.easeOut(duration: 0.16), value: showClearConfirmation)
|
||||
}
|
||||
|
||||
private var clearConfirmationOverlay: some View {
|
||||
VStack(spacing: 12) {
|
||||
Image(systemName: "trash")
|
||||
.font(.system(size: 19, weight: .medium))
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.frame(width: 38, height: 38)
|
||||
.background(palette.surface.opacity(0.35), in: Circle())
|
||||
|
||||
ExtL10n.text("keyboard.clipboard.clear.title")
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
HStack(spacing: 10) {
|
||||
Button {
|
||||
showClearConfirmation = false
|
||||
} label: {
|
||||
ExtL10n.text("common.cancel")
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 36)
|
||||
}
|
||||
.buttonStyle(.glass)
|
||||
.buttonBorderShape(.capsule)
|
||||
|
||||
Button {
|
||||
// Dismiss the popup before publishing an empty history
|
||||
// so the keyboard never retains stale row content.
|
||||
showClearConfirmation = false
|
||||
onClear()
|
||||
} label: {
|
||||
ExtL10n.text("keyboard.clipboard.clear.confirm")
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 36)
|
||||
}
|
||||
.buttonStyle(.glassProminent)
|
||||
.buttonBorderShape(.capsule)
|
||||
.tint(palette.accent)
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
.frame(maxWidth: 300)
|
||||
.glassEffect(
|
||||
.regular,
|
||||
in: RoundedRectangle(cornerRadius: 18, style: .continuous)
|
||||
)
|
||||
.padding(.horizontal, 24)
|
||||
.accessibilityElement(children: .contain)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -277,23 +341,25 @@ struct KeyboardClipboardMenuButton: View, Equatable {
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
// Neutral chip — mirrors the translation button's off state.
|
||||
// SF Symbol "clipboard" sits optically low; nudge up so it centres
|
||||
// in the 34pt chip the same way "xmark" does.
|
||||
Image(systemName: "clipboard")
|
||||
.font(.system(size: KeyboardTopBarMetrics.trailingChipIconSize, weight: .medium))
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.foregroundStyle(palette.textPrimary.opacity(0.72))
|
||||
.offset(y: -0.5)
|
||||
.frame(
|
||||
width: KeyboardTopBarMetrics.trailingChipSize,
|
||||
height: KeyboardTopBarMetrics.trailingChipSize
|
||||
)
|
||||
.background(buttonFill, in: Circle())
|
||||
.overlay(Circle().stroke(palette.divider, lineWidth: 0.5))
|
||||
// Match KeyboardCancelButton: opaque key fill + hairline, no glass.
|
||||
.background(NativeKeyboardKeyColors.fill(for: colorScheme), in: Circle())
|
||||
.overlay(
|
||||
Circle().stroke(palette.divider, lineWidth: 0.5)
|
||||
)
|
||||
.contentShape(Circle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(ExtL10n.text("keyboard.clipboard.a11y"))
|
||||
.accessibilityHint(ExtL10n.text("keyboard.clipboard.a11yHint"))
|
||||
}
|
||||
|
||||
private var buttonFill: Color {
|
||||
colorScheme == .dark ? Color(white: 0.30) : .white
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user