Refine skill catalog installation flow

Move skill installation into dedicated detail pages and keep installed-state rendering compatible with SwiftUI result builders.
This commit is contained in:
Rocky
2026-08-22 16:41:04 +08:00
parent e5a83843db
commit 386ba140f7
4 changed files with 91 additions and 343 deletions
+1 -1
View File
@@ -13,7 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Learned speaking styles**: unlock user-initiated style generation after 5,000 effective dictation characters, prioritize recurring native speech and explicit user edits, use historical polish prompts only to subtract AI-added style, and require review before saving. / **学习说话风格**:累积 5,000 个有效听写字符后,可优先根据反复出现的原生口述与用户明确编辑生成个人风格,历史润色 Prompt 仅用于排除 AI 附加风格,并在保存前强制检查。
### Changed
- **Skill catalog layout**: replace the two-column skill cards with compact full-width list rows while preserving enable, detail, and long-press reorder interactions. / **技能目录布局**:将双列技能卡片改为紧凑的通栏列表,同时保留启用、详情与长按排序交互
- **Skill catalog layout**: replace the two-column cards with scroll-safe installed and uninstalled lists; selecting a row now opens a full detail page where installation, Shortcut setup, and custom-skill editing are managed. / **技能目录布局**:将双列卡片改为可稳定滚动的已安装与未安装列表;点击列表项进入完整详情页,并统一管理安装、捷径配置与自定义技能编辑
### Fixed
- **Repeatable OOBE practice**: scope each free feature to one use per short-lived guided session, so returning to OOBE starts a fresh four-page experience without misreporting consumed-page conflicts as weak network; also simplify lesson titles, add a voice sample to read aloud, and streamline the completion page. / **可重复的 OOBE 体验**:将每项免费功能限制为每个短期引导会话使用一次,让用户重新进入 OOBE 时可以重新体验四个页面,并避免把页面已完成冲突误报为弱网;同时精简各环节标题,加入语音朗读示例,并简化完成页。
+80 -330
View File
@@ -1,14 +1,13 @@
// AIAgentSkillsView.swift
// OSGKeyboard · Main App
//
// Catalog of AI Agent clipboard skills. Enabled cards appear on
// the keyboard after a copy; long-press drag rearranges that row live,
// like Home Screen icons. Export skills confirm a companion Shortcut
// before they can occupy a slot. Users can add custom export skills.
// Catalog of installed and available AI Agent clipboard skills. Selecting a
// row opens its detail page; installation state is managed there. Export
// skills confirm a companion Shortcut before installation completes.
import Foundation
import OSGKeyboardShared
import SwiftUI
import UIKit
struct AIAgentSkillsView: View {
@Environment(\.themePalette) private var palette
@@ -16,15 +15,11 @@ struct AIAgentSkillsView: View {
@ObservedObject private var config = ProviderConfig.shared
@ObservedObject private var store = AIAgentSkillLayoutStore.shared
@State private var viewingSkill: AIClipboardSkill?
@State private var editingDraft: SkillEditorDraft?
@State private var pasteAccessVerified = AppPermissions.hasVerifiedPasteAccess
@State private var pasteAccessNeedsRecovery = false
@State private var showPasteNoTextAlert = false
@State private var showPasteAccessSuccess = false
/// True while a skill card is lifted; locks the page scroll like SpringBoard.
@State private var isReordering = false
private var skillCardShape: RoundedRectangle {
RoundedRectangle(cornerRadius: Radius.xl, style: .continuous)
}
@@ -37,14 +32,13 @@ struct AIAgentSkillsView: View {
clipboardAccessGuide
.transition(.opacity.combined(with: .move(edge: .top)))
}
enabledSection
installedSection
if !store.availableSkills.isEmpty {
availableSection
uninstalledSection
}
}
.tabBarScrollBottomPadding()
}
.scrollDisabled(isReordering)
.background(palette.background)
.navigationTitle("skills.title")
.navigationBarTitleDisplayMode(.large)
@@ -59,15 +53,6 @@ struct AIAgentSkillsView: View {
}
}
}
.sheet(item: $viewingSkill) { skill in
SkillDetailSheet(
store: store,
skill: skill,
onDismiss: { viewingSkill = nil },
onAddOrWarn: addOrWarn,
onConfirmInstall: confirmShortcutInstall
)
}
.sheet(item: $editingDraft) { draft in
SkillEditorSheet(
draft: draft,
@@ -298,32 +283,25 @@ struct AIAgentSkillsView: View {
}
}
private var enabledSection: some View {
private var installedSection: some View {
CardSection(
title: AppL10n.format(
"skills.enabled.section",
"skills.installed.section",
language: config.uiLanguage,
store.enabledSkills.count
)
) {
if store.enabledSkills.isEmpty {
Text("skills.enabled.empty")
Text("skills.installed.empty")
.font(TypeStyle.caption)
.foregroundStyle(palette.textTertiary)
.frame(maxWidth: .infinity, alignment: .leading)
} else {
EnabledSkillsReorderGrid(
skills: store.enabledSkills,
isReordering: $isReordering,
onTap: handleCardTap,
onEdit: handleEdit,
onMoveToIndex: { id, index in
store.moveEnabled(id: id, toIndex: index)
},
card: { skill in
skillCardVisual(skill, isEnabled: true)
LazyVStack(spacing: 0) {
ForEach(store.enabledSkills) { skill in
skillListItem(skill)
}
)
}
.background(palette.surface, in: skillCardShape)
.overlay(skillCardShape.stroke(palette.divider, lineWidth: 0.5))
.clipShape(skillCardShape)
@@ -331,11 +309,11 @@ struct AIAgentSkillsView: View {
}
}
private var availableSection: some View {
CardSection("skills.available.section") {
private var uninstalledSection: some View {
CardSection("skills.uninstalled.section") {
LazyVStack(spacing: 0) {
ForEach(store.availableSkills) { skill in
skillCardInteractive(skill, isEnabled: false)
skillListItem(skill)
}
}
.background(palette.surface, in: skillCardShape)
@@ -344,28 +322,22 @@ struct AIAgentSkillsView: View {
}
}
private func skillCardInteractive(_ skill: AIClipboardSkill, isEnabled: Bool) -> some View {
ZStack(alignment: .topTrailing) {
Button {
handleCardTap(skill)
} label: {
skillCardVisual(skill, isEnabled: isEnabled)
}
.buttonStyle(.plain)
Button {
handleEdit(skill)
} label: {
Color.clear
.frame(
width: CatalogCardChrome.editHitSize,
height: CatalogCardChrome.editHitSize
)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityLabel(Text("skills.edit"))
private func skillListItem(_ skill: AIClipboardSkill) -> some View {
NavigationLink {
SkillDetailView(
store: store,
skill: skill,
onInstall: addOrWarn,
onConfirmInstall: confirmShortcutInstall,
onEdit: {
guard let user = store.userSkill(id: skill.id) else { return }
editingDraft = .from(user)
}
)
} label: {
skillListRow(skill)
}
.buttonStyle(.plain)
.contextMenu {
if skill.isUserCreated {
Button("common.delete", role: .destructive) {
@@ -375,7 +347,7 @@ struct AIAgentSkillsView: View {
}
}
private func skillCardVisual(_ skill: AIClipboardSkill, isEnabled: Bool) -> some View {
private func skillListRow(_ skill: AIClipboardSkill) -> some View {
HStack(spacing: Spacing.md) {
Image(systemName: skill.systemImage)
.font(.system(size: 16, weight: .semibold))
@@ -398,12 +370,6 @@ struct AIAgentSkillsView: View {
}
.frame(maxWidth: .infinity, alignment: .leading)
if isEnabled {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(palette.accent)
}
Image(systemName: "chevron.right")
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(palette.textTertiary)
@@ -439,30 +405,10 @@ struct AIAgentSkillsView: View {
return AppL10n.string(skill.descriptionKey, language: config.uiLanguage)
}
private func handleCardTap(_ skill: AIClipboardSkill) {
if store.layout.isEnabled(skill.id) {
store.disable(skill.id)
return
}
if skill.requiresShortcut, !store.layout.hasConfirmedShortcut(skill.id) {
viewingSkill = skill
return
}
addOrWarn(skill)
}
private func handleEdit(_ skill: AIClipboardSkill) {
if skill.isUserCreated, let user = store.userSkill(id: skill.id) {
editingDraft = .from(user)
} else {
viewingSkill = skill
}
}
private func addOrWarn(_ skill: AIClipboardSkill) {
switch store.enable(skill.id) {
case .enabled, .alreadyEnabled:
viewingSkill = nil
break
case .needsShortcut, .unknown:
break
}
@@ -471,7 +417,7 @@ struct AIAgentSkillsView: View {
private func confirmShortcutInstall(_ skill: AIClipboardSkill) {
switch store.confirmShortcutAndEnable(skill.id) {
case .enabled, .alreadyEnabled:
viewingSkill = nil
break
case .needsShortcut, .unknown:
break
}
@@ -505,185 +451,6 @@ struct AIAgentSkillsView: View {
}
}
/// List-style reorder: long-press lifts the row, other rows slide into the
/// gap as the finger crosses midpoints, and drop settles in place.
private struct EnabledSkillsReorderGrid<Card: View>: View {
let skills: [AIClipboardSkill]
@Binding var isReordering: Bool
let onTap: (AIClipboardSkill) -> Void
let onEdit: (AIClipboardSkill) -> Void
let onMoveToIndex: (String, Int) -> Void
let card: (AIClipboardSkill) -> Card
@State private var draggingID: String?
@State private var dragTranslation: CGSize = .zero
@State private var originFrame: CGRect = .zero
@State private var cellFrames: [String: CGRect] = [:]
@State private var ignoreTap = false
private static var spaceName: String { "enabledSkillsGrid" }
private let columns = [
GridItem(.flexible(), spacing: 0)
]
var body: some View {
ZStack(alignment: .topLeading) {
LazyVGrid(columns: columns, spacing: 0) {
ForEach(skills) { skill in
gridCell(skill)
}
}
.animation(
.interactiveSpring(response: 0.28, dampingFraction: 0.86),
value: skills.map(\.id)
)
if let draggingID, let skill = skills.first(where: { $0.id == draggingID }) {
card(skill)
.frame(width: originFrame.width, height: originFrame.height)
.scaleEffect(1.07)
.shadow(color: .black.opacity(0.28), radius: 16, y: 10)
.offset(
x: originFrame.minX + dragTranslation.width,
y: originFrame.minY + dragTranslation.height
)
.allowsHitTesting(false)
}
}
.coordinateSpace(.named(Self.spaceName))
.onPreferenceChange(SkillCellFrameKey.self) { cellFrames = $0 }
}
private func gridCell(_ skill: AIClipboardSkill) -> some View {
ZStack(alignment: .topTrailing) {
Button {
guard draggingID == nil, !ignoreTap else { return }
onTap(skill)
} label: {
card(skill)
.opacity(draggingID == skill.id ? 0 : 1)
}
.buttonStyle(.plain)
Button {
guard draggingID == nil, !ignoreTap else { return }
onEdit(skill)
} label: {
Color.clear
.frame(
width: CatalogCardChrome.editHitSize,
height: CatalogCardChrome.editHitSize
)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityLabel(Text("skills.edit"))
.opacity(draggingID == skill.id ? 0 : 1)
}
.background {
GeometryReader { proxy in
Color.clear.preference(
key: SkillCellFrameKey.self,
value: [skill.id: proxy.frame(in: .named(Self.spaceName))]
)
}
}
.simultaneousGesture(reorderGesture(for: skill))
}
private func reorderGesture(for skill: AIClipboardSkill) -> some Gesture {
LongPressGesture(minimumDuration: 0.22)
.sequenced(
before: DragGesture(
minimumDistance: 0,
coordinateSpace: .named(Self.spaceName)
)
)
.onChanged { value in
switch value {
case .second(_, let drag):
liftIfNeeded(skill)
guard let drag else { return }
dragTranslation = drag.translation
moveSlotIfNeeded(at: drag.location)
default:
break
}
}
.onEnded { _ in
endDrag()
}
}
private func liftIfNeeded(_ skill: AIClipboardSkill) {
guard draggingID == nil else { return }
originFrame = cellFrames[skill.id] ?? .zero
draggingID = skill.id
isReordering = true
UIImpactFeedbackGenerator(style: .medium).impactOccurred(intensity: 0.9)
}
private func moveSlotIfNeeded(at point: CGPoint) {
guard let draggingID else { return }
guard let target = nearestIndex(to: point) else { return }
guard skills[target].id != draggingID else { return }
UISelectionFeedbackGenerator().selectionChanged()
onMoveToIndex(draggingID, target)
}
private func nearestIndex(to point: CGPoint) -> Int? {
guard !skills.isEmpty else { return nil }
var bestIndex: Int?
var bestDistance = CGFloat.greatestFiniteMagnitude
for (index, skill) in skills.enumerated() {
guard let frame = cellFrames[skill.id] else { continue }
let dx = point.x - frame.midX
let dy = point.y - frame.midY
let distance = dx * dx + dy * dy
if distance < bestDistance {
bestDistance = distance
bestIndex = index
}
}
return bestIndex
}
private func endDrag() {
let wasDragging = draggingID != nil
if let draggingID, let slot = cellFrames[draggingID], originFrame.width > 0 {
withAnimation(.snappy(duration: 0.2)) {
dragTranslation = CGSize(
width: slot.minX - originFrame.minX,
height: slot.minY - originFrame.minY
)
} completion: {
clearDragState()
}
} else {
clearDragState()
}
guard wasDragging else { return }
ignoreTap = true
DispatchQueue.main.async {
ignoreTap = false
}
}
private func clearDragState() {
draggingID = nil
dragTranslation = .zero
isReordering = false
}
}
private struct SkillCellFrameKey: PreferenceKey {
static let defaultValue: [String: CGRect] = [:]
static func reduce(value: inout [String: CGRect], nextValue: () -> [String: CGRect]) {
value.merge(nextValue(), uniquingKeysWith: { _, new in new })
}
}
private struct SkillEditorDraft: Identifiable, Equatable {
let id: String
let isNew: Bool
@@ -724,67 +491,44 @@ private struct SkillEditorDraft: Identifiable, Equatable {
}
}
private struct SkillDetailSheet: View {
private struct SkillDetailView: View {
@Environment(\.dismiss) private var dismiss
@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 onInstall: (AIClipboardSkill) -> Void
let onConfirmInstall: (AIClipboardSkill) -> Void
/// Inner stack height; nav chrome + home indicator are added for the detent.
@State private var contentHeight: CGFloat = 280
private let navigationChrome: CGFloat = 72
let onEdit: () -> Void
var body: some View {
NavigationStack {
ScrollView {
CardPageContent(
spacing: Spacing.md,
topPadding: Spacing.md,
bottomPadding: Spacing.xl
) {
header
Text(skillDescription)
.font(TypeStyle.body)
.foregroundStyle(palette.textSecondary)
.frame(maxWidth: .infinity, alignment: .leading)
promptBlock
thinkingRow
skillActions
}
.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
}
}
ScrollView {
CardPageContent(
spacing: Spacing.md,
topPadding: Spacing.md,
bottomPadding: Spacing.xl
) {
header
Text(skillDescription)
.font(TypeStyle.body)
.foregroundStyle(palette.textSecondary)
.frame(maxWidth: .infinity, alignment: .leading)
promptBlock
thinkingRow
skillActions
}
.scrollBounceBehavior(.basedOnSize)
.background(palette.background)
.navigationTitle("skills.detail.title")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("common.done", action: onDismiss)
}
.background(palette.background)
.navigationTitle("skills.detail.title")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
if skill.isUserCreated {
ToolbarItem(placement: .topBarTrailing) {
Button("skills.edit", action: onEdit)
}
}
}
.presentationDetents([.height(clampedSheetHeight)])
.presentationDragIndicator(.visible)
.presentationContentInteraction(.resizes)
.animation(.easeInOut(duration: 0.2), value: clampedSheetHeight)
}
private var clampedSheetHeight: CGFloat {
let screen = UIScreen.main.bounds.height
let maximum = screen * 0.92
return min(max(contentHeight, 280), maximum)
}
private var skillDescription: String {
@@ -796,9 +540,6 @@ private struct SkillDetailSheet: View {
if skill.requiresShortcut, !store.layout.hasConfirmedShortcut(skill.id) {
return AppL10n.string("skills.install.lead", language: config.uiLanguage)
}
if skill.requiresShortcut, store.layout.isEnabled(skill.id) {
return AppL10n.string("skills.action.turnOffHint", language: config.uiLanguage)
}
return AppL10n.string(skill.descriptionKey, language: config.uiLanguage)
}
@@ -862,37 +603,40 @@ private struct SkillDetailSheet: View {
@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) {
if isInstalled {
fullWidthButton("skills.action.uninstall", prominent: false) {
store.disable(skill.id)
onDismiss()
dismiss()
}
} else {
fullWidthButton("skills.action.addToKeyboard", prominent: true) {
onAddOrWarn(skill)
fullWidthButton("skills.action.install", prominent: true) {
onInstall(skill)
}
}
fullWidthButton("skills.action.reinstallShortcut", prominent: false) {
openShortcutInstall()
}
}
} else if enabled {
fullWidthButton("skills.action.turnOff", prominent: false) {
} else if isInstalled {
fullWidthButton("skills.action.uninstall", prominent: false) {
store.disable(skill.id)
onDismiss()
dismiss()
}
} else {
fullWidthButton("skills.action.addToKeyboard", prominent: true) {
onAddOrWarn(skill)
fullWidthButton("skills.action.install", prominent: true) {
onInstall(skill)
}
}
}
private var isInstalled: Bool {
store.layout.isEnabled(skill.id)
}
private var shortcutInstallBlock: some View {
VStack(alignment: .leading, spacing: Spacing.sm) {
fullWidthButton("skills.install.openShortcuts", prominent: true) {
@@ -901,6 +645,12 @@ private struct SkillDetailSheet: View {
fullWidthButton("skills.install.confirmAdded", prominent: false) {
onConfirmInstall(skill)
}
if store.layout.isEnabled(skill.id) {
fullWidthButton("skills.action.uninstall", prominent: false) {
store.disable(skill.id)
dismiss()
}
}
}
}
+5 -6
View File
@@ -543,9 +543,9 @@
/* AI Agent skills */
"skills.title" = "Skills";
"skills.enabled.section" = "In use (%d)";
"skills.enabled.empty" = "No skills on the keyboard. Turn one on from the list below.";
"skills.available.section" = "Available";
"skills.installed.section" = "Installed (%d)";
"skills.installed.empty" = "No installed skills.";
"skills.uninstalled.section" = "Not installed";
"skills.clipboard.guide.title" = "Enable clipboard skills";
"skills.clipboard.guide.body" = "Copy text to summarize, translate, or extract actions from it.";
"skills.clipboard.guide.enableHistory" = "Turn On Clipboard History";
@@ -559,9 +559,8 @@
"skills.clipboard.guide.openSystemSettings" = "Open iOS Settings";
"skills.detail.title" = "Skill";
"skills.badge.default" = "Default skill";
"skills.action.turnOff" = "Turn off";
"skills.action.turnOffHint" = "Removes it from the keyboard only.";
"skills.action.addToKeyboard" = "Add to keyboard";
"skills.action.install" = "Install";
"skills.action.uninstall" = "Uninstall";
"skills.action.reinstallShortcut" = "Reinstall Shortcut";
"skills.reply.name" = "Reply";
"skills.reply.description" = "Draft a polite reply from the copied text, ready to insert.";
@@ -542,9 +542,9 @@
/* AI Agent 技能 */
"skills.title" = "技能";
"skills.enabled.section" = "使用中%d";
"skills.enabled.empty" = "键盘上还没有技能。从下方列表打开一个即可。";
"skills.available.section" = "可添加";
"skills.installed.section" = "已安装%d";
"skills.installed.empty" = "暂无已安装技能。";
"skills.uninstalled.section" = "未安装";
"skills.clipboard.guide.title" = "启用剪贴板技能";
"skills.clipboard.guide.body" = "复制文字后,可直接总结、翻译或提取事项。";
"skills.clipboard.guide.enableHistory" = "开启历史记录";
@@ -558,9 +558,8 @@
"skills.clipboard.guide.openSystemSettings" = "打开系统设置";
"skills.detail.title" = "技能";
"skills.badge.default" = "默认技能";
"skills.action.turnOff" = "关闭";
"skills.action.turnOffHint" = "只从键盘拿掉,不会删除捷径。";
"skills.action.addToKeyboard" = "添加到键盘";
"skills.action.install" = "安装";
"skills.action.uninstall" = "卸载";
"skills.action.reinstallShortcut" = "重新安装捷径";
"skills.reply.name" = "回复";
"skills.reply.description" = "根据复制的内容起草一段礼貌回复,可插入当前输入框。";