feat(keyboard): add Skills tab and extract-todos Shortcut export

Ship a Skills catalog with drag-to-reorder chips and a companion Shortcut that receives extracted titles and writes them to Reminders.
This commit is contained in:
Rocky
2026-08-13 18:20:42 +08:00
parent ff99b7bff1
commit 148ff807f2
33 changed files with 1628 additions and 23 deletions
+341
View File
@@ -0,0 +1,341 @@
// AIAgentSkillsView.swift
// OSGKeyboard · Main App
//
// Catalog of AI Agent clipboard skills. Enabled cards (max 8) appear on
// the keyboard after a copy; long-press drag reorders that row. Export
// skills confirm a companion Shortcut before they can occupy a slot.
import SwiftUI
import UIKit
import OSGKeyboardShared
struct AIAgentSkillsView: View {
@Environment(\.themePalette) private var palette
@ObservedObject private var config = ProviderConfig.shared
@ObservedObject private var store = AIAgentSkillLayoutStore.shared
@State private var viewingSkill: AIClipboardSkill?
@State private var showFullAlert = false
private let columns = [
GridItem(.flexible(), spacing: Spacing.sm),
GridItem(.flexible(), spacing: Spacing.sm),
]
var body: some View {
NavigationStack {
ScrollView {
CardPageContent(spacing: Spacing.xl) {
if !config.clipboardHistoryEnabled {
clipboardHistoryBanner
}
enabledSection
if !store.availableSkills.isEmpty {
availableSection
}
}
.tabBarScrollBottomPadding()
}
.background(palette.background)
.navigationTitle("skills.title")
.navigationBarTitleDisplayMode(.large)
}
.sheet(item: $viewingSkill) { skill in
SkillDetailSheet(
store: store,
skill: skill,
onDismiss: { viewingSkill = nil },
onAddOrWarn: addOrWarn,
onConfirmInstall: confirmShortcutInstall
)
}
.alert(
AppL10n.string("skills.full.title", language: config.uiLanguage),
isPresented: $showFullAlert
) {
Button("common.done") { showFullAlert = false }
} message: {
Text(AppL10n.string("skills.full.message", language: config.uiLanguage))
}
.onAppear { store.reload() }
}
private var clipboardHistoryBanner: some View {
Text("skills.clipboardHistory.banner")
.font(TypeStyle.caption)
.foregroundStyle(palette.textSecondary)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(Spacing.md)
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.xl, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: Radius.xl, style: .continuous)
.stroke(palette.divider, lineWidth: 0.5)
)
}
private var enabledSection: some View {
CardSection(
title: AppL10n.format(
"skills.enabled.section",
language: config.uiLanguage,
store.enabledSkills.count,
AIAgentSkillLayout.maximumEnabled
)
) {
if store.enabledSkills.isEmpty {
Text("skills.enabled.empty")
.font(TypeStyle.caption)
.foregroundStyle(palette.textTertiary)
.frame(maxWidth: .infinity, alignment: .leading)
} else {
LazyVGrid(columns: columns, spacing: Spacing.sm) {
ForEach(store.enabledSkills) { skill in
skillCard(skill, showsEnabledBadge: true)
.onTapGesture { viewingSkill = skill }
.draggable(skill.id) {
skillCard(skill, showsEnabledBadge: true)
.frame(width: 160)
}
.dropDestination(for: String.self) { items, _ in
guard let dragged = items.first else { return false }
store.moveEnabled(id: dragged, onto: skill.id)
return true
}
}
}
}
}
}
private var availableSection: some View {
CardSection("skills.available.section") {
LazyVGrid(columns: columns, spacing: Spacing.sm) {
ForEach(store.availableSkills) { skill in
Button {
viewingSkill = skill
} label: {
skillCard(skill, showsEnabledBadge: false)
}
.buttonStyle(.plain)
}
}
}
}
private func skillCard(_ skill: AIClipboardSkill, showsEnabledBadge: Bool) -> some View {
VStack(alignment: .leading, spacing: Spacing.sm) {
HStack(spacing: Spacing.sm) {
Image(systemName: skill.systemImage)
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(palette.accent)
Spacer(minLength: 0)
if showsEnabledBadge {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 18, weight: .semibold))
.foregroundStyle(palette.accent)
}
}
Text(AppL10n.string(skill.cardTitleKey, language: config.uiLanguage))
.font(TypeStyle.body)
.foregroundStyle(palette.textPrimary)
.lineLimit(1)
Text(AppL10n.string(skill.descriptionKey, language: config.uiLanguage))
.font(TypeStyle.caption2)
.foregroundStyle(palette.textTertiary)
.lineLimit(2)
Spacer(minLength: 0)
}
.frame(maxWidth: .infinity, minHeight: 96, alignment: .leading)
.padding(Spacing.md)
.background(
showsEnabledBadge ? palette.accentMuted : palette.surface,
in: RoundedRectangle(cornerRadius: Radius.xl, style: .continuous)
)
.overlay(
RoundedRectangle(cornerRadius: Radius.xl, style: .continuous)
.stroke(
showsEnabledBadge ? palette.accent : palette.divider,
lineWidth: showsEnabledBadge ? 1.5 : 0.5
)
)
.clipShape(RoundedRectangle(cornerRadius: Radius.xl, style: .continuous))
.contentShape(RoundedRectangle(cornerRadius: Radius.xl, style: .continuous))
}
private func addOrWarn(_ skill: AIClipboardSkill) {
switch store.enable(skill.id) {
case .enabled, .alreadyEnabled:
viewingSkill = nil
case .full:
showFullAlert = true
case .needsShortcut, .unknown:
break
}
}
private func confirmShortcutInstall(_ skill: AIClipboardSkill) {
switch store.confirmShortcutAndEnable(skill.id) {
case .enabled, .alreadyEnabled:
viewingSkill = nil
case .full:
showFullAlert = true
case .needsShortcut, .unknown:
break
}
}
}
private struct SkillDetailSheet: View {
@Environment(\.themePalette) private var palette
@ObservedObject var store: AIAgentSkillLayoutStore
@ObservedObject private var config = ProviderConfig.shared
let skill: AIClipboardSkill
let onDismiss: () -> Void
let onAddOrWarn: (AIClipboardSkill) -> Void
let onConfirmInstall: (AIClipboardSkill) -> Void
/// Content stack height; nav chrome is added for the detent.
@State private var contentHeight: CGFloat = 240
private let navigationChrome: CGFloat = 56
var body: some View {
NavigationStack {
CardPageContent(
spacing: Spacing.md,
topPadding: Spacing.md,
bottomPadding: Spacing.lg
) {
header
explanation
skillActions
}
.background(palette.background)
.fixedSize(horizontal: false, vertical: true)
.onGeometryChange(for: CGFloat.self) { proxy in
proxy.size.height
} action: { newHeight in
let next = newHeight + navigationChrome
if abs(contentHeight - next) > 1 {
contentHeight = next
}
}
.navigationTitle("skills.detail.title")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("common.done", action: onDismiss)
}
}
}
.presentationDetents([.height(contentHeight)])
.presentationDragIndicator(.visible)
.presentationContentInteraction(.resizes)
.animation(.easeInOut(duration: 0.2), value: contentHeight)
}
private var explanation: some View {
Text(AppL10n.string(explanationKey, language: config.uiLanguage))
.font(TypeStyle.body)
.foregroundStyle(palette.textSecondary)
.lineLimit(1)
.frame(maxWidth: .infinity, minHeight: 80, maxHeight: 80, alignment: .topLeading)
}
private var explanationKey: String {
if skill.requiresShortcut, !store.layout.hasConfirmedShortcut(skill.id) {
return "skills.install.lead"
}
if skill.requiresShortcut, store.layout.isEnabled(skill.id) {
return "skills.action.turnOffHint"
}
return skill.descriptionKey
}
private var header: some View {
HStack(alignment: .center, spacing: Spacing.md) {
Image(systemName: skill.systemImage)
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(palette.accent)
.frame(width: 36, height: 36)
.background(palette.accentMuted, in: Circle())
VStack(alignment: .leading, spacing: 2) {
Text(AppL10n.string(skill.cardTitleKey, language: config.uiLanguage))
.font(TypeStyle.title3)
.foregroundStyle(palette.textPrimary)
if skill.isDefault {
Text("skills.badge.default")
.font(TypeStyle.caption2)
.foregroundStyle(palette.textSecondary)
}
}
Spacer(minLength: 0)
}
}
@ViewBuilder
private var skillActions: some View {
let enabled = store.layout.isEnabled(skill.id)
if skill.requiresShortcut {
if !store.layout.hasConfirmedShortcut(skill.id) {
shortcutInstallBlock
} else {
if enabled {
fullWidthButton("skills.action.turnOff", prominent: false) {
store.disable(skill.id)
onDismiss()
}
} else {
fullWidthButton("skills.action.addToKeyboard", prominent: true) {
onAddOrWarn(skill)
}
}
fullWidthButton("skills.action.reinstallShortcut", prominent: false) {
openShortcutInstall()
}
}
} else if enabled {
fullWidthButton("skills.action.turnOff", prominent: false) {
store.disable(skill.id)
onDismiss()
}
} else {
fullWidthButton("skills.action.addToKeyboard", prominent: true) {
onAddOrWarn(skill)
}
}
}
private var shortcutInstallBlock: some View {
VStack(alignment: .leading, spacing: Spacing.sm) {
fullWidthButton("skills.install.openShortcuts", prominent: true) {
openShortcutInstall()
}
fullWidthButton("skills.install.confirmAdded", prominent: false) {
onConfirmInstall(skill)
}
}
}
@ViewBuilder
private func fullWidthButton(
_ titleKey: LocalizedStringKey,
prominent: Bool,
action: @escaping () -> Void
) -> some View {
let label = Text(titleKey)
.frame(maxWidth: .infinity, minHeight: 38)
if prominent {
Button(action: action) { label }
.buttonStyle(.borderedProminent)
.tint(palette.accent)
} else {
Button(action: action) { label }
.buttonStyle(.bordered)
}
}
private func openShortcutInstall() {
AIAgentShortcutInstaller.openInstallPage(for: skill)
}
}