diff --git a/Note/ClipInboxViewController.swift b/Note/ClipInboxViewController.swift new file mode 100644 index 0000000..f437421 --- /dev/null +++ b/Note/ClipInboxViewController.swift @@ -0,0 +1,216 @@ +// +// ClipInboxViewController.swift +// Note +// +// Created by Kimi Code CLI on 2026/7/15. +// + +import UIKit + +class ClipInboxViewController: UIViewController { + + private var tableView: UITableView! + private var emptyLabel: UILabel! + private var clips: [Clip] = [] + + override func viewDidLoad() { + super.viewDidLoad() + title = "收集箱" + navigationItem.largeTitleDisplayMode = .never + view.backgroundColor = .systemGroupedBackground + + setupNavigationBar() + setupTableView() + setupEmptyLabel() + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + loadClips() + } + + private func setupNavigationBar() { + let clearButton = UIBarButtonItem( + title: "清空", + style: .plain, + target: self, + action: #selector(didTapClearButton) + ) + clearButton.tintColor = .systemRed + navigationItem.rightBarButtonItem = clearButton + } + + private func setupTableView() { + tableView = UITableView(frame: .zero, style: .insetGrouped) + tableView.translatesAutoresizingMaskIntoConstraints = false + tableView.delegate = self + tableView.dataSource = self + tableView.register(UITableViewCell.self, forCellReuseIdentifier: "clipCell") + tableView.backgroundColor = .systemGroupedBackground + view.addSubview(tableView) + + NSLayoutConstraint.activate([ + tableView.topAnchor.constraint(equalTo: view.topAnchor), + tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor) + ]) + } + + private func setupEmptyLabel() { + emptyLabel = UILabel() + emptyLabel.translatesAutoresizingMaskIntoConstraints = false + emptyLabel.text = "还没有保存的文案\n复制内容后运行快捷指令即可收藏" + emptyLabel.numberOfLines = 0 + emptyLabel.textAlignment = .center + emptyLabel.textColor = .secondaryLabel + emptyLabel.font = UIFont.preferredFont(forTextStyle: .body) + emptyLabel.isHidden = true + view.addSubview(emptyLabel) + + NSLayoutConstraint.activate([ + emptyLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor), + emptyLabel.centerYAnchor.constraint(equalTo: view.centerYAnchor), + emptyLabel.leadingAnchor.constraint(equalTo: view.readableContentGuide.leadingAnchor), + emptyLabel.trailingAnchor.constraint(equalTo: view.readableContentGuide.trailingAnchor) + ]) + } + + private func loadClips() { + Task { + do { + clips = try await ClipStore.shared.loadAll() + await MainActor.run { + tableView.reloadData() + updateEmptyState() + } + } catch { + await MainActor.run { + showError(message: "加载失败: \(error.localizedDescription)") + } + } + } + } + + private func updateEmptyState() { + let isEmpty = clips.isEmpty + emptyLabel.isHidden = !isEmpty + tableView.isHidden = isEmpty + navigationItem.rightBarButtonItem?.isEnabled = !isEmpty + } + + @objc private func didTapClearButton() { + let alert = UIAlertController( + title: "清空收集箱", + message: "确定要删除所有保存的文案吗?此操作不可撤销。", + preferredStyle: .alert + ) + alert.addAction(UIAlertAction(title: "取消", style: .cancel)) + alert.addAction(UIAlertAction(title: "清空", style: .destructive) { [weak self] _ in + self?.clearAllClips() + }) + present(alert, animated: true) + } + + private func clearAllClips() { + Task { + do { + try await ClipStore.shared.clearAll() + clips = [] + await MainActor.run { + tableView.reloadData() + updateEmptyState() + } + } catch { + await MainActor.run { + showError(message: "清空失败: \(error.localizedDescription)") + } + } + } + } + + private func deleteClip(at indexPath: IndexPath) { + let clip = clips[indexPath.row] + Task { + do { + try await ClipStore.shared.delete(id: clip.id) + clips.remove(at: indexPath.row) + await MainActor.run { + tableView.deleteRows(at: [indexPath], with: .automatic) + updateEmptyState() + } + } catch { + await MainActor.run { + showError(message: "删除失败: \(error.localizedDescription)") + } + } + } + } + + private func openClip(_ clip: Clip) { + let viewController = FormatNoteViewController() + viewController.initialText = clip.text + navigationController?.pushViewController(viewController, animated: true) + } + + private func showError(message: String) { + let alert = UIAlertController(title: "提示", message: message, preferredStyle: .alert) + alert.addAction(UIAlertAction(title: "确定", style: .default)) + present(alert, animated: true) + } +} + +extension ClipInboxViewController: UITableViewDataSource { + + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return clips.count + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell = tableView.dequeueReusableCell(withIdentifier: "clipCell", for: indexPath) + let clip = clips[indexPath.row] + + var config = cell.defaultContentConfiguration() + config.text = previewText(for: clip.text) + config.secondaryText = formattedDate(clip.createdAt) + config.secondaryTextProperties.color = .secondaryLabel + cell.contentConfiguration = config + cell.accessoryType = .disclosureIndicator + + return cell + } + + private func previewText(for text: String) -> String { + let lines = text.components(separatedBy: .newlines) + let firstTwoLines = Array(lines.prefix(2)) + let preview = firstTwoLines.joined(separator: " ").trimmingCharacters(in: .whitespaces) + if preview.isEmpty { + return "(无内容)" + } + return preview + } + + private func formattedDate(_ date: Date) -> String { + let formatter = RelativeDateTimeFormatter() + formatter.unitsStyle = .short + return formatter.localizedString(for: date, relativeTo: Date()) + } +} + +extension ClipInboxViewController: UITableViewDelegate { + + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + tableView.deselectRow(at: indexPath, animated: true) + let clip = clips[indexPath.row] + openClip(clip) + } + + func tableView(_ tableView: UITableView, + trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? { + let deleteAction = UIContextualAction(style: .destructive, title: "删除") { [weak self] _, _, completion in + self?.deleteClip(at: indexPath) + completion(true) + } + return UISwipeActionsConfiguration(actions: [deleteAction]) + } +} diff --git a/Note/ClipStore.swift b/Note/ClipStore.swift new file mode 100644 index 0000000..1dcc617 --- /dev/null +++ b/Note/ClipStore.swift @@ -0,0 +1,92 @@ +// +// ClipStore.swift +// Note +// +// Created by Kimi Code CLI on 2026/7/15. +// + +import Foundation + +struct Clip: Codable, Identifiable, Equatable, Sendable { + let id: UUID + var text: String + let createdAt: Date + var source: String? + + nonisolated init(id: UUID = UUID(), text: String, createdAt: Date = Date(), source: String? = nil) { + self.id = id + self.text = text + self.createdAt = createdAt + self.source = source + } +} + +actor ClipStore { + + static let shared = ClipStore() + + private let appGroupIdentifier = "group.com.fishestlife.note" + private let fileName = "clips.json" + private let subdirectory = "SharedFiles/Clips" + + private var cachedClips: [Clip]? + + private var fileURL: URL { + let fileManager = FileManager.default + guard let containerURL = fileManager.containerURL(forSecurityApplicationGroupIdentifier: appGroupIdentifier) else { + fatalError("无法访问 App Group 容器: \(appGroupIdentifier)") + } + let directoryURL = containerURL.appendingPathComponent(subdirectory, isDirectory: true) + try? fileManager.createDirectory(at: directoryURL, withIntermediateDirectories: true) + return directoryURL.appendingPathComponent(fileName) + } + + private init() {} + + func save(text: String, source: String? = nil) async throws { + var clips = try await loadAll() + let clip = Clip(text: text, source: source) + clips.insert(clip, at: 0) + try await persist(clips) + } + + func loadAll() async throws -> [Clip] { + if let cached = cachedClips { + return cached + } + + let url = fileURL + let fileManager = FileManager.default + guard fileManager.fileExists(atPath: url.path) else { + cachedClips = [] + return [] + } + + let data = try Data(contentsOf: url) + let clips = try JSONDecoder().decode([Clip].self, from: data) + cachedClips = clips + return clips + } + + func delete(id: UUID) async throws { + var clips = try await loadAll() + clips.removeAll { $0.id == id } + try await persist(clips) + } + + func clearAll() async throws { + try await persist([]) + } + + func count() async throws -> Int { + let clips = try await loadAll() + return clips.count + } + + private func persist(_ clips: [Clip]) async throws { + let url = fileURL + let data = try JSONEncoder().encode(clips) + try data.write(to: url, options: .atomic) + cachedClips = clips + } +} diff --git a/Note/SaveClipIntent.swift b/Note/SaveClipIntent.swift new file mode 100644 index 0000000..94a016e --- /dev/null +++ b/Note/SaveClipIntent.swift @@ -0,0 +1,37 @@ +// +// SaveClipIntent.swift +// Note +// +// Created by Kimi Code CLI on 2026/7/15. +// + +import AppIntents + +struct SaveClipIntent: AppIntent { + + static var title: LocalizedStringResource = "保存剪贴板到 Note" + static var description: IntentDescription? = "将一段文本保存到 Note App 的收集箱,稍后统一处理。" + + @Parameter(title: "文案", description: "要保存的文本内容", requestValueDialog: "请提供要保存的文案") + var text: String + + @Parameter(title: "来源", description: "文案来源,例如应用名称", default: "快捷指令") + var source: String? + + init() {} + + init(text: String, source: String? = nil) { + self.text = text + self.source = source + } + + func perform() async throws -> some IntentResult { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + return .result(dialog: "文案为空,未保存。") + } + + try await ClipStore.shared.save(text: trimmed, source: source) + return .result(dialog: "已保存到收集箱。") + } +} diff --git a/Note/ViewController.swift b/Note/ViewController.swift index f48c2d1..25af822 100644 --- a/Note/ViewController.swift +++ b/Note/ViewController.swift @@ -17,8 +17,9 @@ class ViewController: UIViewController { let title: String } - private let menuItems: [[MenuItem]] = [ + private var menuItems: [[MenuItem]] = [ [ + MenuItem(icon: "tray.full", iconColor: .systemIndigo, title: "收集箱"), MenuItem(icon: "note.text", iconColor: .systemYellow, title: "打开备忘录"), MenuItem(icon: "doc.text", iconColor: .systemBlue, title: "格式化日志"), MenuItem(icon: "text.alignleft", iconColor: .systemGreen, title: "格式化笔记"), @@ -31,6 +32,11 @@ class ViewController: UIViewController { setupUI() } + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + updateInboxBadge() + } + private func setupUI() { title = "首页" navigationController?.navigationBar.prefersLargeTitles = true @@ -87,6 +93,9 @@ extension ViewController: UITableViewDelegate { let item = menuItems[indexPath.section][indexPath.row] switch item.title { + case "收集箱": + let viewController = ClipInboxViewController() + navigationController?.pushViewController(viewController, animated: true) case "打开备忘录": openSystemNotesApp() case "格式化日志": @@ -102,6 +111,37 @@ extension ViewController: UITableViewDelegate { } } + private func updateInboxBadge() { + Task { + guard let count = try? await ClipStore.shared.count(), count > 0 else { + await MainActor.run { + self.updateInboxTitle("收集箱") + } + return + } + await MainActor.run { + self.updateInboxTitle("收集箱(\(count))") + } + } + } + + private func updateInboxTitle(_ title: String) { + for (sectionIndex, section) in menuItems.enumerated() { + for (rowIndex, item) in section.enumerated() { + if item.title.hasPrefix("收集箱") { + menuItems[sectionIndex][rowIndex] = MenuItem( + icon: item.icon, + iconColor: item.iconColor, + title: title + ) + let indexPath = IndexPath(row: rowIndex, section: sectionIndex) + tableView.reloadRows(at: [indexPath], with: .none) + return + } + } + } + } + private func openSystemNotesApp() { openURLIfPossible("mobilenotes://") }