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:
+1
-1
@@ -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 附加风格,并在保存前强制检查。
|
- **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
|
### 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
|
### 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 时可以重新体验四个页面,并避免把页面已完成冲突误报为弱网;同时精简各环节标题,加入语音朗读示例,并简化完成页。
|
- **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 时可以重新体验四个页面,并避免把页面已完成冲突误报为弱网;同时精简各环节标题,加入语音朗读示例,并简化完成页。
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
// AIAgentSkillsView.swift
|
// AIAgentSkillsView.swift
|
||||||
// OSGKeyboard · Main App
|
// OSGKeyboard · Main App
|
||||||
//
|
//
|
||||||
// Catalog of AI Agent clipboard skills. Enabled cards appear on
|
// Catalog of installed and available AI Agent clipboard skills. Selecting a
|
||||||
// the keyboard after a copy; long-press drag rearranges that row live,
|
// row opens its detail page; installation state is managed there. Export
|
||||||
// like Home Screen icons. Export skills confirm a companion Shortcut
|
// skills confirm a companion Shortcut before installation completes.
|
||||||
// before they can occupy a slot. Users can add custom export skills.
|
|
||||||
|
|
||||||
|
import Foundation
|
||||||
import OSGKeyboardShared
|
import OSGKeyboardShared
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import UIKit
|
|
||||||
|
|
||||||
struct AIAgentSkillsView: View {
|
struct AIAgentSkillsView: View {
|
||||||
@Environment(\.themePalette) private var palette
|
@Environment(\.themePalette) private var palette
|
||||||
@@ -16,15 +15,11 @@ struct AIAgentSkillsView: View {
|
|||||||
@ObservedObject private var config = ProviderConfig.shared
|
@ObservedObject private var config = ProviderConfig.shared
|
||||||
@ObservedObject private var store = AIAgentSkillLayoutStore.shared
|
@ObservedObject private var store = AIAgentSkillLayoutStore.shared
|
||||||
|
|
||||||
@State private var viewingSkill: AIClipboardSkill?
|
|
||||||
@State private var editingDraft: SkillEditorDraft?
|
@State private var editingDraft: SkillEditorDraft?
|
||||||
@State private var pasteAccessVerified = AppPermissions.hasVerifiedPasteAccess
|
@State private var pasteAccessVerified = AppPermissions.hasVerifiedPasteAccess
|
||||||
@State private var pasteAccessNeedsRecovery = false
|
@State private var pasteAccessNeedsRecovery = false
|
||||||
@State private var showPasteNoTextAlert = false
|
@State private var showPasteNoTextAlert = false
|
||||||
@State private var showPasteAccessSuccess = 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 {
|
private var skillCardShape: RoundedRectangle {
|
||||||
RoundedRectangle(cornerRadius: Radius.xl, style: .continuous)
|
RoundedRectangle(cornerRadius: Radius.xl, style: .continuous)
|
||||||
}
|
}
|
||||||
@@ -37,14 +32,13 @@ struct AIAgentSkillsView: View {
|
|||||||
clipboardAccessGuide
|
clipboardAccessGuide
|
||||||
.transition(.opacity.combined(with: .move(edge: .top)))
|
.transition(.opacity.combined(with: .move(edge: .top)))
|
||||||
}
|
}
|
||||||
enabledSection
|
installedSection
|
||||||
if !store.availableSkills.isEmpty {
|
if !store.availableSkills.isEmpty {
|
||||||
availableSection
|
uninstalledSection
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.tabBarScrollBottomPadding()
|
.tabBarScrollBottomPadding()
|
||||||
}
|
}
|
||||||
.scrollDisabled(isReordering)
|
|
||||||
.background(palette.background)
|
.background(palette.background)
|
||||||
.navigationTitle("skills.title")
|
.navigationTitle("skills.title")
|
||||||
.navigationBarTitleDisplayMode(.large)
|
.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
|
.sheet(item: $editingDraft) { draft in
|
||||||
SkillEditorSheet(
|
SkillEditorSheet(
|
||||||
draft: draft,
|
draft: draft,
|
||||||
@@ -298,32 +283,25 @@ struct AIAgentSkillsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var enabledSection: some View {
|
private var installedSection: some View {
|
||||||
CardSection(
|
CardSection(
|
||||||
title: AppL10n.format(
|
title: AppL10n.format(
|
||||||
"skills.enabled.section",
|
"skills.installed.section",
|
||||||
language: config.uiLanguage,
|
language: config.uiLanguage,
|
||||||
store.enabledSkills.count
|
store.enabledSkills.count
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
if store.enabledSkills.isEmpty {
|
if store.enabledSkills.isEmpty {
|
||||||
Text("skills.enabled.empty")
|
Text("skills.installed.empty")
|
||||||
.font(TypeStyle.caption)
|
.font(TypeStyle.caption)
|
||||||
.foregroundStyle(palette.textTertiary)
|
.foregroundStyle(palette.textTertiary)
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
} else {
|
} else {
|
||||||
EnabledSkillsReorderGrid(
|
LazyVStack(spacing: 0) {
|
||||||
skills: store.enabledSkills,
|
ForEach(store.enabledSkills) { skill in
|
||||||
isReordering: $isReordering,
|
skillListItem(skill)
|
||||||
onTap: handleCardTap,
|
|
||||||
onEdit: handleEdit,
|
|
||||||
onMoveToIndex: { id, index in
|
|
||||||
store.moveEnabled(id: id, toIndex: index)
|
|
||||||
},
|
|
||||||
card: { skill in
|
|
||||||
skillCardVisual(skill, isEnabled: true)
|
|
||||||
}
|
}
|
||||||
)
|
}
|
||||||
.background(palette.surface, in: skillCardShape)
|
.background(palette.surface, in: skillCardShape)
|
||||||
.overlay(skillCardShape.stroke(palette.divider, lineWidth: 0.5))
|
.overlay(skillCardShape.stroke(palette.divider, lineWidth: 0.5))
|
||||||
.clipShape(skillCardShape)
|
.clipShape(skillCardShape)
|
||||||
@@ -331,11 +309,11 @@ struct AIAgentSkillsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var availableSection: some View {
|
private var uninstalledSection: some View {
|
||||||
CardSection("skills.available.section") {
|
CardSection("skills.uninstalled.section") {
|
||||||
LazyVStack(spacing: 0) {
|
LazyVStack(spacing: 0) {
|
||||||
ForEach(store.availableSkills) { skill in
|
ForEach(store.availableSkills) { skill in
|
||||||
skillCardInteractive(skill, isEnabled: false)
|
skillListItem(skill)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.background(palette.surface, in: skillCardShape)
|
.background(palette.surface, in: skillCardShape)
|
||||||
@@ -344,28 +322,22 @@ struct AIAgentSkillsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func skillCardInteractive(_ skill: AIClipboardSkill, isEnabled: Bool) -> some View {
|
private func skillListItem(_ skill: AIClipboardSkill) -> some View {
|
||||||
ZStack(alignment: .topTrailing) {
|
NavigationLink {
|
||||||
Button {
|
SkillDetailView(
|
||||||
handleCardTap(skill)
|
store: store,
|
||||||
} label: {
|
skill: skill,
|
||||||
skillCardVisual(skill, isEnabled: isEnabled)
|
onInstall: addOrWarn,
|
||||||
}
|
onConfirmInstall: confirmShortcutInstall,
|
||||||
.buttonStyle(.plain)
|
onEdit: {
|
||||||
|
guard let user = store.userSkill(id: skill.id) else { return }
|
||||||
Button {
|
editingDraft = .from(user)
|
||||||
handleEdit(skill)
|
}
|
||||||
} label: {
|
)
|
||||||
Color.clear
|
} label: {
|
||||||
.frame(
|
skillListRow(skill)
|
||||||
width: CatalogCardChrome.editHitSize,
|
|
||||||
height: CatalogCardChrome.editHitSize
|
|
||||||
)
|
|
||||||
.contentShape(Rectangle())
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
.accessibilityLabel(Text("skills.edit"))
|
|
||||||
}
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
.contextMenu {
|
.contextMenu {
|
||||||
if skill.isUserCreated {
|
if skill.isUserCreated {
|
||||||
Button("common.delete", role: .destructive) {
|
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) {
|
HStack(spacing: Spacing.md) {
|
||||||
Image(systemName: skill.systemImage)
|
Image(systemName: skill.systemImage)
|
||||||
.font(.system(size: 16, weight: .semibold))
|
.font(.system(size: 16, weight: .semibold))
|
||||||
@@ -398,12 +370,6 @@ struct AIAgentSkillsView: View {
|
|||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.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")
|
Image(systemName: "chevron.right")
|
||||||
.font(.system(size: 12, weight: .semibold))
|
.font(.system(size: 12, weight: .semibold))
|
||||||
.foregroundStyle(palette.textTertiary)
|
.foregroundStyle(palette.textTertiary)
|
||||||
@@ -439,30 +405,10 @@ struct AIAgentSkillsView: View {
|
|||||||
return AppL10n.string(skill.descriptionKey, language: config.uiLanguage)
|
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) {
|
private func addOrWarn(_ skill: AIClipboardSkill) {
|
||||||
switch store.enable(skill.id) {
|
switch store.enable(skill.id) {
|
||||||
case .enabled, .alreadyEnabled:
|
case .enabled, .alreadyEnabled:
|
||||||
viewingSkill = nil
|
break
|
||||||
case .needsShortcut, .unknown:
|
case .needsShortcut, .unknown:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -471,7 +417,7 @@ struct AIAgentSkillsView: View {
|
|||||||
private func confirmShortcutInstall(_ skill: AIClipboardSkill) {
|
private func confirmShortcutInstall(_ skill: AIClipboardSkill) {
|
||||||
switch store.confirmShortcutAndEnable(skill.id) {
|
switch store.confirmShortcutAndEnable(skill.id) {
|
||||||
case .enabled, .alreadyEnabled:
|
case .enabled, .alreadyEnabled:
|
||||||
viewingSkill = nil
|
break
|
||||||
case .needsShortcut, .unknown:
|
case .needsShortcut, .unknown:
|
||||||
break
|
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 {
|
private struct SkillEditorDraft: Identifiable, Equatable {
|
||||||
let id: String
|
let id: String
|
||||||
let isNew: Bool
|
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
|
@Environment(\.themePalette) private var palette
|
||||||
@ObservedObject var store: AIAgentSkillLayoutStore
|
@ObservedObject var store: AIAgentSkillLayoutStore
|
||||||
@ObservedObject private var config = ProviderConfig.shared
|
@ObservedObject private var config = ProviderConfig.shared
|
||||||
|
|
||||||
let skill: AIClipboardSkill
|
let skill: AIClipboardSkill
|
||||||
let onDismiss: () -> Void
|
let onInstall: (AIClipboardSkill) -> Void
|
||||||
let onAddOrWarn: (AIClipboardSkill) -> Void
|
|
||||||
let onConfirmInstall: (AIClipboardSkill) -> Void
|
let onConfirmInstall: (AIClipboardSkill) -> Void
|
||||||
|
let onEdit: () -> Void
|
||||||
/// Inner stack height; nav chrome + home indicator are added for the detent.
|
|
||||||
@State private var contentHeight: CGFloat = 280
|
|
||||||
private let navigationChrome: CGFloat = 72
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
ScrollView {
|
||||||
ScrollView {
|
CardPageContent(
|
||||||
CardPageContent(
|
spacing: Spacing.md,
|
||||||
spacing: Spacing.md,
|
topPadding: Spacing.md,
|
||||||
topPadding: Spacing.md,
|
bottomPadding: Spacing.xl
|
||||||
bottomPadding: Spacing.xl
|
) {
|
||||||
) {
|
header
|
||||||
header
|
Text(skillDescription)
|
||||||
Text(skillDescription)
|
.font(TypeStyle.body)
|
||||||
.font(TypeStyle.body)
|
.foregroundStyle(palette.textSecondary)
|
||||||
.foregroundStyle(palette.textSecondary)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
promptBlock
|
||||||
promptBlock
|
thinkingRow
|
||||||
thinkingRow
|
skillActions
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.scrollBounceBehavior(.basedOnSize)
|
}
|
||||||
.background(palette.background)
|
.background(palette.background)
|
||||||
.navigationTitle("skills.detail.title")
|
.navigationTitle("skills.detail.title")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .confirmationAction) {
|
if skill.isUserCreated {
|
||||||
Button("common.done", action: onDismiss)
|
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 {
|
private var skillDescription: String {
|
||||||
@@ -796,9 +540,6 @@ private struct SkillDetailSheet: View {
|
|||||||
if skill.requiresShortcut, !store.layout.hasConfirmedShortcut(skill.id) {
|
if skill.requiresShortcut, !store.layout.hasConfirmedShortcut(skill.id) {
|
||||||
return AppL10n.string("skills.install.lead", language: config.uiLanguage)
|
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)
|
return AppL10n.string(skill.descriptionKey, language: config.uiLanguage)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -862,37 +603,40 @@ private struct SkillDetailSheet: View {
|
|||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var skillActions: some View {
|
private var skillActions: some View {
|
||||||
let enabled = store.layout.isEnabled(skill.id)
|
|
||||||
if skill.requiresShortcut {
|
if skill.requiresShortcut {
|
||||||
if !store.layout.hasConfirmedShortcut(skill.id) {
|
if !store.layout.hasConfirmedShortcut(skill.id) {
|
||||||
shortcutInstallBlock
|
shortcutInstallBlock
|
||||||
} else {
|
} else {
|
||||||
if enabled {
|
if isInstalled {
|
||||||
fullWidthButton("skills.action.turnOff", prominent: false) {
|
fullWidthButton("skills.action.uninstall", prominent: false) {
|
||||||
store.disable(skill.id)
|
store.disable(skill.id)
|
||||||
onDismiss()
|
dismiss()
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
fullWidthButton("skills.action.addToKeyboard", prominent: true) {
|
fullWidthButton("skills.action.install", prominent: true) {
|
||||||
onAddOrWarn(skill)
|
onInstall(skill)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fullWidthButton("skills.action.reinstallShortcut", prominent: false) {
|
fullWidthButton("skills.action.reinstallShortcut", prominent: false) {
|
||||||
openShortcutInstall()
|
openShortcutInstall()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if enabled {
|
} else if isInstalled {
|
||||||
fullWidthButton("skills.action.turnOff", prominent: false) {
|
fullWidthButton("skills.action.uninstall", prominent: false) {
|
||||||
store.disable(skill.id)
|
store.disable(skill.id)
|
||||||
onDismiss()
|
dismiss()
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
fullWidthButton("skills.action.addToKeyboard", prominent: true) {
|
fullWidthButton("skills.action.install", prominent: true) {
|
||||||
onAddOrWarn(skill)
|
onInstall(skill)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var isInstalled: Bool {
|
||||||
|
store.layout.isEnabled(skill.id)
|
||||||
|
}
|
||||||
|
|
||||||
private var shortcutInstallBlock: some View {
|
private var shortcutInstallBlock: some View {
|
||||||
VStack(alignment: .leading, spacing: Spacing.sm) {
|
VStack(alignment: .leading, spacing: Spacing.sm) {
|
||||||
fullWidthButton("skills.install.openShortcuts", prominent: true) {
|
fullWidthButton("skills.install.openShortcuts", prominent: true) {
|
||||||
@@ -901,6 +645,12 @@ private struct SkillDetailSheet: View {
|
|||||||
fullWidthButton("skills.install.confirmAdded", prominent: false) {
|
fullWidthButton("skills.install.confirmAdded", prominent: false) {
|
||||||
onConfirmInstall(skill)
|
onConfirmInstall(skill)
|
||||||
}
|
}
|
||||||
|
if store.layout.isEnabled(skill.id) {
|
||||||
|
fullWidthButton("skills.action.uninstall", prominent: false) {
|
||||||
|
store.disable(skill.id)
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -543,9 +543,9 @@
|
|||||||
|
|
||||||
/* AI Agent skills */
|
/* AI Agent skills */
|
||||||
"skills.title" = "Skills";
|
"skills.title" = "Skills";
|
||||||
"skills.enabled.section" = "In use (%d)";
|
"skills.installed.section" = "Installed (%d)";
|
||||||
"skills.enabled.empty" = "No skills on the keyboard. Turn one on from the list below.";
|
"skills.installed.empty" = "No installed skills.";
|
||||||
"skills.available.section" = "Available";
|
"skills.uninstalled.section" = "Not installed";
|
||||||
"skills.clipboard.guide.title" = "Enable clipboard skills";
|
"skills.clipboard.guide.title" = "Enable clipboard skills";
|
||||||
"skills.clipboard.guide.body" = "Copy text to summarize, translate, or extract actions from it.";
|
"skills.clipboard.guide.body" = "Copy text to summarize, translate, or extract actions from it.";
|
||||||
"skills.clipboard.guide.enableHistory" = "Turn On Clipboard History";
|
"skills.clipboard.guide.enableHistory" = "Turn On Clipboard History";
|
||||||
@@ -559,9 +559,8 @@
|
|||||||
"skills.clipboard.guide.openSystemSettings" = "Open iOS Settings";
|
"skills.clipboard.guide.openSystemSettings" = "Open iOS Settings";
|
||||||
"skills.detail.title" = "Skill";
|
"skills.detail.title" = "Skill";
|
||||||
"skills.badge.default" = "Default skill";
|
"skills.badge.default" = "Default skill";
|
||||||
"skills.action.turnOff" = "Turn off";
|
"skills.action.install" = "Install";
|
||||||
"skills.action.turnOffHint" = "Removes it from the keyboard only.";
|
"skills.action.uninstall" = "Uninstall";
|
||||||
"skills.action.addToKeyboard" = "Add to keyboard";
|
|
||||||
"skills.action.reinstallShortcut" = "Reinstall Shortcut";
|
"skills.action.reinstallShortcut" = "Reinstall Shortcut";
|
||||||
"skills.reply.name" = "Reply";
|
"skills.reply.name" = "Reply";
|
||||||
"skills.reply.description" = "Draft a polite reply from the copied text, ready to insert.";
|
"skills.reply.description" = "Draft a polite reply from the copied text, ready to insert.";
|
||||||
|
|||||||
@@ -542,9 +542,9 @@
|
|||||||
|
|
||||||
/* AI Agent 技能 */
|
/* AI Agent 技能 */
|
||||||
"skills.title" = "技能";
|
"skills.title" = "技能";
|
||||||
"skills.enabled.section" = "使用中(%d)";
|
"skills.installed.section" = "已安装(%d)";
|
||||||
"skills.enabled.empty" = "键盘上还没有技能。从下方列表打开一个即可。";
|
"skills.installed.empty" = "暂无已安装技能。";
|
||||||
"skills.available.section" = "可添加";
|
"skills.uninstalled.section" = "未安装";
|
||||||
"skills.clipboard.guide.title" = "启用剪贴板技能";
|
"skills.clipboard.guide.title" = "启用剪贴板技能";
|
||||||
"skills.clipboard.guide.body" = "复制文字后,可直接总结、翻译或提取事项。";
|
"skills.clipboard.guide.body" = "复制文字后,可直接总结、翻译或提取事项。";
|
||||||
"skills.clipboard.guide.enableHistory" = "开启历史记录";
|
"skills.clipboard.guide.enableHistory" = "开启历史记录";
|
||||||
@@ -558,9 +558,8 @@
|
|||||||
"skills.clipboard.guide.openSystemSettings" = "打开系统设置";
|
"skills.clipboard.guide.openSystemSettings" = "打开系统设置";
|
||||||
"skills.detail.title" = "技能";
|
"skills.detail.title" = "技能";
|
||||||
"skills.badge.default" = "默认技能";
|
"skills.badge.default" = "默认技能";
|
||||||
"skills.action.turnOff" = "关闭";
|
"skills.action.install" = "安装";
|
||||||
"skills.action.turnOffHint" = "只从键盘拿掉,不会删除捷径。";
|
"skills.action.uninstall" = "卸载";
|
||||||
"skills.action.addToKeyboard" = "添加到键盘";
|
|
||||||
"skills.action.reinstallShortcut" = "重新安装捷径";
|
"skills.action.reinstallShortcut" = "重新安装捷径";
|
||||||
"skills.reply.name" = "回复";
|
"skills.reply.name" = "回复";
|
||||||
"skills.reply.description" = "根据复制的内容起草一段礼貌回复,可插入当前输入框。";
|
"skills.reply.description" = "根据复制的内容起草一段礼貌回复,可插入当前输入框。";
|
||||||
|
|||||||
Reference in New Issue
Block a user