From 386ba140f7992a6eabdbb9b5435c1181f1d1fc67 Mon Sep 17 00:00:00 2001 From: Rocky <72559939+hkgood@users.noreply.github.com> Date: Sat, 22 Aug 2026 16:41:04 +0800 Subject: [PATCH] Refine skill catalog installation flow Move skill installation into dedicated detail pages and keep installed-state rendering compatible with SwiftUI result builders. --- CHANGELOG.md | 2 +- OSGKeyboard/Views/AIAgentSkillsView.swift | 410 ++++-------------- OSGKeyboard/en.lproj/Localizable.strings | 11 +- OSGKeyboard/zh-Hans.lproj/Localizable.strings | 11 +- 4 files changed, 91 insertions(+), 343 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 91f1cff..97144ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 时可以重新体验四个页面,并避免把页面已完成冲突误报为弱网;同时精简各环节标题,加入语音朗读示例,并简化完成页。 diff --git a/OSGKeyboard/Views/AIAgentSkillsView.swift b/OSGKeyboard/Views/AIAgentSkillsView.swift index 69c39c9..b57d033 100644 --- a/OSGKeyboard/Views/AIAgentSkillsView.swift +++ b/OSGKeyboard/Views/AIAgentSkillsView.swift @@ -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: 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() + } + } } } diff --git a/OSGKeyboard/en.lproj/Localizable.strings b/OSGKeyboard/en.lproj/Localizable.strings index 2e6bb3d..a9a5dda 100644 --- a/OSGKeyboard/en.lproj/Localizable.strings +++ b/OSGKeyboard/en.lproj/Localizable.strings @@ -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."; diff --git a/OSGKeyboard/zh-Hans.lproj/Localizable.strings b/OSGKeyboard/zh-Hans.lproj/Localizable.strings index 80cdbfd..f98e82b 100644 --- a/OSGKeyboard/zh-Hans.lproj/Localizable.strings +++ b/OSGKeyboard/zh-Hans.lproj/Localizable.strings @@ -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" = "根据复制的内容起草一段礼貌回复,可插入当前输入框。";