diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Package.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Package.swift index f3b46225a..338097ebd 100644 --- a/packages/frontend/apps/ios/App/Packages/Intelligents/Package.swift +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Package.swift @@ -10,7 +10,7 @@ let package = Package( .iOS(.v16), ], products: [ - .library(name: "Intelligents", targets: ["Intelligents"]), + .library(name: "Intelligents", type: .dynamic, targets: ["Intelligents"]), ], dependencies: [ .package(path: "../AffineGraphQL"), @@ -29,8 +29,8 @@ let package = Package( .product(name: "Apollo", package: "apollo-ios"), .product(name: "OrderedCollections", package: "swift-collections"), ], resources: [ - .process("Resources/main.metal"), .process("Interface/View/InputBox/InputBox.xcassets"), + .process("Interface/Controller/AttachmentManagementController/AttachmentIcon.xcassets"), ]), ] ) diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Extension/UIColor.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Extension/UIColor.swift new file mode 100644 index 000000000..7d77698f7 --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Extension/UIColor.swift @@ -0,0 +1,19 @@ +// +// UIColor.swift +// Intelligents +// +// Created by 秋星桥 on 6/24/25. +// + +import UIKit + +private extension UIColor { + convenience init(hex: Int, alpha: CGFloat = 1.0) { + self.init( + red: CGFloat((hex & 0xFF0000) >> 16) / 255.0, + green: CGFloat((hex & 0x00FF00) >> 8) / 255.0, + blue: CGFloat(hex & 0x0000FF) / 255.0, + alpha: alpha + ) + } +} diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Extension/ViewPreview.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Extension/ViewPreview.swift new file mode 100644 index 000000000..b48f2b0d2 --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Extension/ViewPreview.swift @@ -0,0 +1,52 @@ +// +// ViewPreview.swift +// Intelligents +// +// Created by 秋星桥 on 6/24/25. +// + +import Foundation +import UIKit + +#if canImport(SwiftUI) && DEBUG + import SwiftUI + + struct UIViewControllerPreview: UIViewControllerRepresentable { + let viewController: ViewController + + init(_ builder: @escaping () -> ViewController) { + viewController = builder() + } + + func makeUIViewController(context _: Context) -> ViewController { + viewController + } + + func updateUIViewController(_: ViewController, context _: Context) {} + } +#endif + +#if canImport(SwiftUI) && DEBUG + import SwiftUI + + struct UIViewPreview: UIViewRepresentable { + let view: View + + init(_ builder: @escaping () -> View) { + view = builder() + } + + // MARK: UIViewRepresentable + + func makeUIView(context _: Context) -> UIView { + view + } + + func updateUIView(_ view: UIView, context _: Context) { + view.setContentCompressionResistancePriority(.fittingSizeLevel, for: .horizontal) + view.setContentCompressionResistancePriority(.fittingSizeLevel, for: .vertical) + view.setContentHuggingPriority(.defaultHigh, for: .horizontal) + view.setContentHuggingPriority(.defaultHigh, for: .vertical) + } + } +#endif diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/Controller/AttachmentManagementController/AttachmentIcon.xcassets/Contents.json b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/Controller/AttachmentManagementController/AttachmentIcon.xcassets/Contents.json new file mode 100644 index 000000000..73c00596a --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/Controller/AttachmentManagementController/AttachmentIcon.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/Controller/AttachmentManagementController/AttachmentIcon.xcassets/FileAttachment.imageset/Contents.json b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/Controller/AttachmentManagementController/AttachmentIcon.xcassets/FileAttachment.imageset/Contents.json new file mode 100644 index 000000000..6f5c8f9cd --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/Controller/AttachmentManagementController/AttachmentIcon.xcassets/FileAttachment.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "Page.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/Controller/AttachmentManagementController/AttachmentIcon.xcassets/FileAttachment.imageset/Page.pdf b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/Controller/AttachmentManagementController/AttachmentIcon.xcassets/FileAttachment.imageset/Page.pdf new file mode 100644 index 000000000..cb33cb731 Binary files /dev/null and b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/Controller/AttachmentManagementController/AttachmentIcon.xcassets/FileAttachment.imageset/Page.pdf differ diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/Controller/AttachmentManagementController/AttachmentIcon.xcassets/FileAttachment_json.imageset/AiFileClip attachment icon.pdf b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/Controller/AttachmentManagementController/AttachmentIcon.xcassets/FileAttachment_json.imageset/AiFileClip attachment icon.pdf new file mode 100644 index 000000000..1715e60b2 Binary files /dev/null and b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/Controller/AttachmentManagementController/AttachmentIcon.xcassets/FileAttachment_json.imageset/AiFileClip attachment icon.pdf differ diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/Controller/AttachmentManagementController/AttachmentIcon.xcassets/FileAttachment_json.imageset/Contents.json b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/Controller/AttachmentManagementController/AttachmentIcon.xcassets/FileAttachment_json.imageset/Contents.json new file mode 100644 index 000000000..bb48b846f --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/Controller/AttachmentManagementController/AttachmentIcon.xcassets/FileAttachment_json.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "AiFileClip attachment icon.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/Controller/AttachmentManagementController/AttachmentIcon.xcassets/FileAttachment_md.imageset/AiFileClip attachment icon.pdf b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/Controller/AttachmentManagementController/AttachmentIcon.xcassets/FileAttachment_md.imageset/AiFileClip attachment icon.pdf new file mode 100644 index 000000000..a0ab4c6cc Binary files /dev/null and b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/Controller/AttachmentManagementController/AttachmentIcon.xcassets/FileAttachment_md.imageset/AiFileClip attachment icon.pdf differ diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/Controller/AttachmentManagementController/AttachmentIcon.xcassets/FileAttachment_md.imageset/Contents.json b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/Controller/AttachmentManagementController/AttachmentIcon.xcassets/FileAttachment_md.imageset/Contents.json new file mode 100644 index 000000000..bb48b846f --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/Controller/AttachmentManagementController/AttachmentIcon.xcassets/FileAttachment_md.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "AiFileClip attachment icon.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/Controller/AttachmentManagementController/AttachmentIcon.xcassets/FileAttachment_pdf.imageset/AiFileClip attachment icon.pdf b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/Controller/AttachmentManagementController/AttachmentIcon.xcassets/FileAttachment_pdf.imageset/AiFileClip attachment icon.pdf new file mode 100644 index 000000000..e09f680d0 Binary files /dev/null and b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/Controller/AttachmentManagementController/AttachmentIcon.xcassets/FileAttachment_pdf.imageset/AiFileClip attachment icon.pdf differ diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/Controller/AttachmentManagementController/AttachmentIcon.xcassets/FileAttachment_pdf.imageset/Contents.json b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/Controller/AttachmentManagementController/AttachmentIcon.xcassets/FileAttachment_pdf.imageset/Contents.json new file mode 100644 index 000000000..bb48b846f --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/Controller/AttachmentManagementController/AttachmentIcon.xcassets/FileAttachment_pdf.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "AiFileClip attachment icon.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/Controller/AttachmentManagementController/AttachmentIcon.xcassets/FileAttachment_txt.imageset/AiFileClip attachment icon.pdf b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/Controller/AttachmentManagementController/AttachmentIcon.xcassets/FileAttachment_txt.imageset/AiFileClip attachment icon.pdf new file mode 100644 index 000000000..362f07534 Binary files /dev/null and b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/Controller/AttachmentManagementController/AttachmentIcon.xcassets/FileAttachment_txt.imageset/AiFileClip attachment icon.pdf differ diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/Controller/AttachmentManagementController/AttachmentIcon.xcassets/FileAttachment_txt.imageset/Contents.json b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/Controller/AttachmentManagementController/AttachmentIcon.xcassets/FileAttachment_txt.imageset/Contents.json new file mode 100644 index 000000000..bb48b846f --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/Controller/AttachmentManagementController/AttachmentIcon.xcassets/FileAttachment_txt.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "AiFileClip attachment icon.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/Controller/AttachmentManagementController/AttachmentManagementController.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/Controller/AttachmentManagementController/AttachmentManagementController.swift new file mode 100644 index 000000000..844720f1b --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/Controller/AttachmentManagementController/AttachmentManagementController.swift @@ -0,0 +1,306 @@ +// +// AttachmentManagementController.swift +// Intelligents +// +// Created by 秋星桥 on 6/25/25. +// + +import SnapKit +import Then +import UIKit + +protocol AttachmentManagementControllerDelegate: AnyObject { + func deleteFileAttachment(controller: AttachmentManagementController, _ attachment: FileAttachment) + func deleteDocumentAttachment(controller: AttachmentManagementController, _ attachment: DocumentAttachment) +} + +class AttachmentManagementController: UINavigationController { + private let _viewController: _AttachmentManagementController + init(delegate: AttachmentManagementControllerDelegate) { + let attachmentManagementController = _AttachmentManagementController(delegate: delegate) + _viewController = attachmentManagementController + super.init(rootViewController: attachmentManagementController) + _viewController.delegateController = self + navigationBar.isHidden = false + modalPresentationStyle = .formSheet + modalTransitionStyle = .coverVertical + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError() + } + + func set(documentAttachments attachments: [DocumentAttachment]) { + _ = _viewController.view // trigger view did load + _viewController.documentAttachments = attachments + } + + func set(fileAttachments attachments: [FileAttachment]) { + _ = _viewController.view // trigger view did load + _viewController.fileAttachments = attachments + } +} + +private class _AttachmentManagementController: UIViewController { + weak var delegateController: AttachmentManagementController? + weak var delegate: AttachmentManagementControllerDelegate? + private let tableView: UITableView = .init(frame: .zero, style: .plain) + private lazy var dataSource: UITableViewDiffableDataSource< + Section, + Item + > = .init(tableView: tableView) { [weak self] tableView, indexPath, item in + let cell = tableView.dequeueReusableCell(withIdentifier: "AttachmentCell", for: indexPath) as! AttachmentCell + cell.configure(with: item) + cell.onDelete = { [weak self] in + guard let delegateController = self?.delegateController else { return } + switch item.type { + case let .file(file): + self?.delegate?.deleteFileAttachment(controller: delegateController, file) + case let .document(doc): + self?.delegate?.deleteDocumentAttachment(controller: delegateController, doc) + } + } + return cell + } + + enum Section: Int, CaseIterable { + case files + case documents + } + + struct Item: Hashable { + let id: UUID + let title: String + let icon: UIImage? + let type: ItemType + enum ItemType: Hashable { + case file(FileAttachment) + case document(DocumentAttachment) + } + + static func == (lhs: Item, rhs: Item) -> Bool { + lhs.id == rhs.id + } + + func hash(into hasher: inout Hasher) { + hasher.combine(id) + } + } + + var fileAttachments: [FileAttachment] = [] { + didSet { + NSObject.cancelPreviousPerformRequests(withTarget: self) + perform(#selector(reloadDataSource), with: nil, afterDelay: 0) + } + } + + var documentAttachments: [DocumentAttachment] = [] { + didSet { + NSObject.cancelPreviousPerformRequests(withTarget: self) + perform(#selector(reloadDataSource), with: nil, afterDelay: 0) + } + } + + init(delegate: AttachmentManagementControllerDelegate) { + self.delegate = delegate + super.init(nibName: nil, bundle: nil) + title = "Attachments & Docs" + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError() + } + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .systemBackground + + navigationItem.title = "Attachments & Docs" + navigationItem.rightBarButtonItem = .init(systemItem: .done, primaryAction: .init { [weak self] _ in + self?.doneTapped() + }) + + tableView.backgroundColor = .clear + tableView.separatorStyle = .none + tableView.register(AttachmentCell.self, forCellReuseIdentifier: "AttachmentCell") + tableView.clipsToBounds = true + tableView.rowHeight = UITableView.automaticDimension + view.addSubview(tableView) + tableView.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + + applySnapshot() + } + + @objc private func doneTapped() { + dismiss(animated: true) + } + + @objc func reloadDataSource() { + applySnapshot() + if fileAttachments.isEmpty, documentAttachments.isEmpty { + dismiss(animated: true) + } + } + + private func applySnapshot() { + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.files, .documents]) + let fileItems = fileAttachments.map { file in + Item(id: file.id, title: file.name, icon: file.icon, type: .file(file)) + } + let docItems = documentAttachments.map { doc in + Item(id: doc.id, title: doc.title, icon: .init(named: "FileAttachment", in: .module, with: nil)!, type: .document(doc)) + } + snapshot.appendItems(fileItems, toSection: .files) + snapshot.appendItems(docItems, toSection: .documents) + dataSource.apply(snapshot, animatingDifferences: true) + } +} + +private class AttachmentCell: UITableViewCell { + let container = UIView().then { + $0.layer.cornerRadius = 4 + $0.layer.borderWidth = 0.5 + $0.layer.borderColor = UIColor.affineLayerBorder.cgColor + } + + let iconView = UIImageView().then { + $0.contentMode = .scaleAspectFit + $0.tintColor = .affineIconPrimary + } + + let titleLabel = UILabel().then { + $0.textColor = .label + $0.textAlignment = .left + $0.font = .preferredFont(forTextStyle: .body) + } + + let deleteButton = UIButton(type: .system).then { + $0.setImage(UIImage(systemName: "xmark"), for: .normal) + $0.tintColor = .affineIconPrimary + } + + var onDelete: (() -> Void)? + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + selectionStyle = .none + setupUI() + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError() + } + + private let inset: CGFloat = 10 + + private func setupUI() { + contentView.addSubview(container) + container.snp.makeConstraints { make in + make.top.bottom.equalToSuperview().inset(4) + make.left.right.equalToSuperview().inset(inset) + } + + container.addSubview(iconView) + container.addSubview(titleLabel) + container.addSubview(deleteButton) + iconView.snp.makeConstraints { make in + make.left.equalToSuperview().inset(inset) + make.centerY.equalToSuperview() + make.top.greaterThanOrEqualToSuperview().offset(inset) + make.bottom.lessThanOrEqualToSuperview().offset(-inset) + } + titleLabel.snp.makeConstraints { make in + make.left.equalTo(iconView.snp.right).offset(inset) + make.right.lessThanOrEqualTo(deleteButton.snp.left).offset(-inset) + make.centerY.equalToSuperview() + make.top.greaterThanOrEqualToSuperview().offset(inset) + make.bottom.lessThanOrEqualToSuperview().offset(-inset) + } + deleteButton.snp.makeConstraints { make in + make.right.equalToSuperview().offset(-inset) + make.centerY.equalToSuperview() + make.width.height.equalTo(12) + make.centerY.equalToSuperview() + make.top.greaterThanOrEqualToSuperview().offset(inset) + make.bottom.lessThanOrEqualToSuperview().offset(-inset) + } + deleteButton.addTarget(self, action: #selector(deleteTapped), for: .touchUpInside) + } + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + container.layer.borderColor = UIColor.affineLayerBorder.cgColor + } + + func configure(with item: _AttachmentManagementController.Item) { + iconView.image = item.icon + titleLabel.text = item.title + } + + override func prepareForReuse() { + super.prepareForReuse() + iconView.image = nil + titleLabel.text = nil + onDelete = nil + } + + @objc private func deleteTapped() { + onDelete?() + } +} + +private extension FileAttachment { + var icon: UIImage? { + switch url.pathExtension.lowercased() { + case "pdf": + .init(named: "FileAttachment_pdf", in: .module, with: nil)! + case "json": + .init(named: "FileAttachment_json", in: .module, with: nil)! + case "md": + .init(named: "FileAttachment_md", in: .module, with: nil)! + case "txt": + .init(named: "FileAttachment_txt", in: .module, with: nil)! + default: + .init(named: "FileAttachment", in: .module, with: nil)! + } + } +} + +#if canImport(SwiftUI) && DEBUG + import SwiftUI + + private class MockDelegate: AttachmentManagementControllerDelegate { + static let shared = MockDelegate() + func deleteFileAttachment(controller _: AttachmentManagementController, _: FileAttachment) {} + func deleteDocumentAttachment(controller _: AttachmentManagementController, _: DocumentAttachment) {} + } + + struct AttachmentManagementController_Previews: PreviewProvider { + static var previews: some View { + UIViewControllerPreview { + let vc = AttachmentManagementController(delegate: MockDelegate.shared) + let fileAttachments = [ + FileAttachment(url: .init(fileURLWithPath: "/p.pdf"), name: "File 1.pdf"), + FileAttachment(url: .init(fileURLWithPath: "/p.md"), name: "File 2.md"), + FileAttachment(url: .init(fileURLWithPath: "/p.txt"), name: "File 3.txt"), + FileAttachment(url: .init(fileURLWithPath: "/p.json"), name: "File 4.json"), + FileAttachment(url: .init(fileURLWithPath: "/p.xls"), name: "File 4.xls"), + ] + let documentAttachments = [ + DocumentAttachment(title: "Cloud Document A"), + DocumentAttachment(title: "Cloud Document B"), + ] + vc.set(fileAttachments: fileAttachments) + vc.set(documentAttachments: documentAttachments) + return vc + } + .edgesIgnoringSafeArea(.all) + } + } +#endif diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/Controller/MainViewController/MainViewController+Input.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/Controller/MainViewController/MainViewController+Input.swift index c1304562d..6f12f88fc 100644 --- a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/Controller/MainViewController/MainViewController+Input.swift +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/Controller/MainViewController/MainViewController+Input.swift @@ -114,9 +114,15 @@ extension MainViewController: UIDocumentPickerDelegate { try FileManager.default.copyItem(at: url, to: tempURL) // Add file attachment using the temporary URL - inputBox.addFileAttachment(tempURL) + try inputBox.addFileAttachment(tempURL) } catch { - print("Failed to copy file: \(error)") + let alert = UIAlertController( + title: "Error", + message: "Failed to process file: \(error.localizedDescription)", + preferredStyle: .alert + ) + alert.addAction(UIAlertAction(title: "OK", style: .default)) + present(alert, animated: true) } } } diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/DocumentPickerView/DocumentPickerView.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/DocumentPickerView/DocumentPickerView.swift new file mode 100644 index 000000000..17d9eb951 --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/DocumentPickerView/DocumentPickerView.swift @@ -0,0 +1,205 @@ +// +// DocumentPickerView.swift +// Intelligents +// +// Created by 秋星桥 on 6/24/25. +// + +import SnapKit +import Then +import UIKit + +protocol DocumentPickerViewDelegate: AnyObject { + func documentPickerView(_ view: DocumentPickerView, didSelectDocument document: DocumentItem) + func documentPickerView(_ view: DocumentPickerView, didSearchWithText text: String) +} + +struct DocumentItem { + let title: String + let icon: UIImage? +} + +class DocumentPickerView: UIView { + // MARK: - Properties + + weak var delegate: DocumentPickerViewDelegate? + + private var documents: [DocumentItem] = [] + + // MARK: - UI Components + + private lazy var containerView = UIView().then { + $0.backgroundColor = .white + $0.layer.cornerRadius = 10 + $0.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner] + $0.layer.shadowColor = UIColor.black.cgColor + $0.layer.shadowOffset = CGSize(width: 0, height: -3) + $0.layer.shadowRadius = 5 + $0.layer.shadowOpacity = 0.07 + } + + private lazy var searchContainerView = UIView().then { + $0.backgroundColor = .white + $0.layer.cornerRadius = 10 + $0.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner] + $0.layer.borderWidth = 0.5 + $0.layer.borderColor = UIColor(hex: 0xE6E6E6)?.cgColor + } + + private lazy var searchIconImageView = UIImageView().then { + $0.image = UIImage(systemName: "magnifyingglass") + $0.tintColor = UIColor(hex: 0x141414) + $0.contentMode = .scaleAspectFit + } + + private lazy var searchTextField = UITextField().then { + $0.placeholder = "Search documents..." + $0.font = .systemFont(ofSize: 17, weight: .regular) + $0.textColor = UIColor(hex: 0x141414) + $0.backgroundColor = .clear + $0.addTarget(self, action: #selector(searchTextChanged), for: .editingChanged) + } + + private lazy var tableView = UITableView().then { + $0.backgroundColor = .white + $0.separatorStyle = .none + $0.delegate = self + $0.dataSource = self + $0.register(DocumentTableViewCell.self, forCellReuseIdentifier: "DocumentCell") + } + + // MARK: - Initialization + + init() { + super.init(frame: .zero) + setupUI() + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Setup + + private func setupUI() { + backgroundColor = .systemBackground + + addSubview(containerView) + containerView.addSubview(searchContainerView) + containerView.addSubview(tableView) + + searchContainerView.addSubview(searchIconImageView) + searchContainerView.addSubview(searchTextField) + + setupConstraints() + } + + private func setupConstraints() { + containerView.snp.makeConstraints { make in + make.center.equalToSuperview() + make.width.equalTo(393) + make.height.lessThanOrEqualTo(500) + } + + searchContainerView.snp.makeConstraints { make in + make.top.leading.trailing.equalToSuperview() + make.bottom.equalTo(searchIconImageView.snp.bottom).offset(DocumentTableViewCell.cellInset) + make.top.equalTo(searchIconImageView.snp.top).offset(-DocumentTableViewCell.cellInset) + } + + searchIconImageView.snp.makeConstraints { make in + make.leading.equalToSuperview().offset(DocumentTableViewCell.cellInset) + make.centerY.equalToSuperview() + make.width.height.equalTo(DocumentTableViewCell.iconSize) + } + + searchTextField.snp.makeConstraints { make in + make.leading.equalTo(searchIconImageView.snp.trailing).offset(DocumentTableViewCell.spacing) + make.trailing.equalToSuperview().offset(-DocumentTableViewCell.cellInset) + make.centerY.equalToSuperview() + } + + tableView.snp.makeConstraints { make in + make.top.equalTo(searchContainerView.snp.bottom) + make.leading.trailing.bottom.equalToSuperview() + } + } + + // MARK: - Public Methods + + func updateDocuments(_ documents: [DocumentItem]) { + self.documents = documents + tableView.reloadData() + + let tableHeight = min(CGFloat(documents.count) * 37.11 + 44, 500) + containerView.snp.updateConstraints { make in + make.height.lessThanOrEqualTo(tableHeight) + } + } + + // MARK: - Actions + + @objc private func searchTextChanged() { + guard let text = searchTextField.text else { return } + delegate?.documentPickerView(self, didSearchWithText: text) + } +} + +// MARK: - UITableViewDataSource + +extension DocumentPickerView: UITableViewDataSource { + func tableView(_: UITableView, numberOfRowsInSection _: Int) -> Int { + documents.count + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell = tableView.dequeueReusableCell(withIdentifier: "DocumentCell", for: indexPath) as! DocumentTableViewCell + cell.configure(with: documents[indexPath.row]) + return cell + } +} + +// MARK: - UITableViewDelegate + +extension DocumentPickerView: UITableViewDelegate { + func tableView(_: UITableView, heightForRowAt _: IndexPath) -> CGFloat { + DocumentTableViewCell.cellInset * 2 + DocumentTableViewCell.iconSize + } + + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + tableView.deselectRow(at: indexPath, animated: true) + let document = documents[indexPath.row] + delegate?.documentPickerView(self, didSelectDocument: document) + } +} + +// MARK: - Preview + +#if canImport(SwiftUI) && DEBUG + import SwiftUI + + struct DocumentPickerView_Previews: PreviewProvider { + static var previews: some View { + UIViewPreview { + let view = DocumentPickerView() + + let mockDocuments = [ + DocumentItem(title: "Project Proposal.docx", icon: UIImage(systemName: "doc.text")), + DocumentItem(title: "Budget Analysis.xlsx", icon: UIImage(systemName: "tablecells")), + DocumentItem(title: "Meeting Notes.pdf", icon: UIImage(systemName: "doc.richtext")), + DocumentItem(title: "Design Guidelines.sketch", icon: UIImage(systemName: "paintbrush")), + DocumentItem(title: "Code Review.md", icon: UIImage(systemName: "doc.plaintext")), + DocumentItem(title: "User Research.pptx", icon: UIImage(systemName: "doc.on.doc")), + DocumentItem(title: "Technical Specification.docx", icon: UIImage(systemName: "doc.text")), + DocumentItem(title: "Database Schema.sql", icon: UIImage(systemName: "cylinder.split.1x2")), + ] + + view.updateDocuments(mockDocuments) + return view + } + .previewLayout(.fixed(width: 400, height: 600)) + .previewDisplayName("Document Picker") + } + } +#endif diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/DocumentPickerView/DocumentTableViewCell.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/DocumentPickerView/DocumentTableViewCell.swift new file mode 100644 index 000000000..56802bdbf --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/DocumentPickerView/DocumentTableViewCell.swift @@ -0,0 +1,62 @@ +// +// DocumentTableViewCell.swift +// Intelligents +// +// Created by 秋星桥 on 6/24/25. +// + +import SnapKit +import Then +import UIKit + +class DocumentTableViewCell: UITableViewCell { + static let cellInset: CGFloat = 16 + static let iconSize: CGFloat = 20 + static let spacing: CGFloat = 16 + + private lazy var iconImageView = UIImageView().then { + $0.contentMode = .scaleAspectFit + $0.tintColor = UIColor(hex: 0x141414) + } + + private lazy var titleLabel = UILabel().then { + $0.font = .systemFont(ofSize: 17, weight: .regular) + $0.textColor = UIColor(hex: 0x141414) + $0.textAlignment = .left + } + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + setupUI() + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setupUI() { + backgroundColor = .white + selectionStyle = .none + + contentView.addSubview(iconImageView) + contentView.addSubview(titleLabel) + + iconImageView.snp.makeConstraints { make in + make.leading.equalToSuperview().offset(Self.cellInset) + make.centerY.equalToSuperview() + make.width.height.equalTo(Self.iconSize) + } + + titleLabel.snp.makeConstraints { make in + make.leading.equalTo(iconImageView.snp.trailing).offset(Self.spacing) + make.trailing.equalToSuperview().offset(-Self.cellInset) + make.centerY.equalToSuperview() + } + } + + func configure(with document: DocumentItem) { + iconImageView.image = document.icon ?? UIImage(systemName: "doc.text") + titleLabel.text = document.title + } +} diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/FileAttachmentHeader/FileAttachmentHeaderView.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/FileAttachmentHeader/FileAttachmentHeaderView.swift new file mode 100644 index 000000000..da3455f41 --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/FileAttachmentHeader/FileAttachmentHeaderView.swift @@ -0,0 +1,171 @@ +import SnapKit +import Then +import UIKit + +protocol FileAttachmentHeaderViewDelegate: AnyObject { + func headerViewDidPickMore(_ headerView: FileAttachmentHeaderView) + func headerViewDidTapManagement(_ headerView: FileAttachmentHeaderView) +} + +final class FileAttachmentHeaderView: UIView { + // MARK: - Properties + + weak var delegate: FileAttachmentHeaderViewDelegate? + + // MARK: - UI Components + + private lazy var iconImageView = UIImageView().then { + $0.contentMode = .scaleAspectFit + $0.image = UIImage(systemName: "doc.fill") + $0.tintColor = UIColor.systemBlue + $0.isUserInteractionEnabled = true + let tap = UITapGestureRecognizer(target: self, action: #selector(iconTapped)) + $0.addGestureRecognizer(tap) + } + + private lazy var textStackView = UIStackView().then { + $0.axis = .vertical + $0.spacing = 2 + $0.alignment = .leading + $0.distribution = .equalSpacing + } + + private lazy var primaryLabel = UILabel().then { + $0.text = "" // 3 attachment, 1 AFFiNE docs + $0.font = UIFont.preferredFont(forTextStyle: .footnote).bold + $0.textColor = .label + $0.numberOfLines = 1 + } + + private lazy var secondaryLabel = UILabel().then { + $0.text = "Referenced for AI" + $0.font = UIFont.preferredFont(forTextStyle: .footnote) + $0.textColor = .affineTextSecondary + $0.numberOfLines = 1 + } + + private lazy var arrowButton = UIImageView().then { + $0.image = UIImage(systemName: "chevron.down") + $0.contentMode = .scaleAspectFit + $0.tintColor = .affineIconPrimary + } + + // MARK: - Initialization + + override init(frame: CGRect) { + super.init(frame: frame) + setupUI() + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Setup + + private func setupUI() { + backgroundColor = UIColor.white + layer.cornerRadius = 12 + layer.borderWidth = 0.5 + layer.borderColor = UIColor.systemGray5.cgColor + layer.shadowColor = UIColor.black.cgColor + layer.shadowOffset = CGSize(width: 0, height: 2) + layer.shadowRadius = 6 + layer.shadowOpacity = 0.04 + + let tapGesture = UITapGestureRecognizer(target: self, action: #selector(viewTapped)) + addGestureRecognizer(tapGesture) + + addSubviews() + setupConstraints() + setupStackView() + } + + private func addSubviews() { + addSubview(iconImageView) + addSubview(textStackView) + addSubview(arrowButton) + } + + private func setupConstraints() { + iconImageView.snp.makeConstraints { make in + make.leading.equalToSuperview().offset(12) + make.size.equalTo(24) + make.centerY.equalToSuperview() + make.top.greaterThanOrEqualToSuperview().inset(12) + make.bottom.lessThanOrEqualToSuperview().inset(12) + } + + textStackView.snp.makeConstraints { make in + make.leading.equalTo(iconImageView.snp.trailing).offset(12) + make.trailing.lessThanOrEqualTo(arrowButton.snp.leading).offset(-12) + make.centerY.equalToSuperview() + make.top.greaterThanOrEqualToSuperview().inset(12) + make.bottom.lessThanOrEqualToSuperview().inset(12) + } + + arrowButton.snp.makeConstraints { make in + make.trailing.equalToSuperview().offset(-12) + make.centerY.equalToSuperview() + make.width.equalTo(18) + make.height.equalTo(18) + } + } + + private func setupStackView() { + textStackView.addArrangedSubview(primaryLabel) + textStackView.addArrangedSubview(secondaryLabel) + } + + // MARK: - Actions + + @objc private func viewTapped() { + delegate?.headerViewDidTapManagement(self) + } + + @objc private func iconTapped() { + delegate?.headerViewDidPickMore(self) + } + + // MARK: - Public Methods + + func updateContent(attachmentCount: Int, docsCount: Int) { + primaryLabel.text = "\(attachmentCount) attachment, \(docsCount) AFFiNE docs" + } + + func setIconImage(_ image: UIImage?) { + iconImageView.image = image + } + + // MARK: - Trait Collection + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + + if traitCollection.hasDifferentColorAppearance(comparedTo: previousTraitCollection) { + layer.borderColor = UIColor.systemGray5.cgColor + } + } +} + +// MARK: - Preview + +#if canImport(SwiftUI) && DEBUG + import SwiftUI + + struct FileAttachmentHeaderView_Previews: PreviewProvider { + static var previews: some View { + UIViewPreview { + let view = FileAttachmentHeaderView() + view.updateContent(attachmentCount: 5, docsCount: 2) + view.snp.makeConstraints { make in + make.width.equalTo(400) + } + return view + } + .previewLayout(.fixed(width: 400, height: 100)) + .previewDisplayName("File Attachment Header") + } + } +#endif diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/ImageAttachmentBar/ImageAttachmentBar.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/ImageAttachmentBar/ImageAttachmentBar.swift new file mode 100644 index 000000000..8519b6cec --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/ImageAttachmentBar/ImageAttachmentBar.swift @@ -0,0 +1,156 @@ +// +// ImageAttachmentBar.swift +// Intelligents +// +// Created by 秋星桥 on 6/18/25. +// + +import SnapKit +import Then +import UIKit + +protocol ImageAttachmentBarDelegate: AnyObject { + func inputBoxImageBar(_ imageBar: ImageAttachmentBar, didRemoveImageWithId id: UUID) +} + +class ImageAttachmentBar: UICollectionView { + weak var imageBarDelegate: ImageAttachmentBarDelegate? + + enum Section { + case main + } + + private var attachments: [ImageAttachment] = [] + private let cellSpacing: CGFloat = 8 + private let constantHeight: CGFloat = 80 + + var myDataSource: UICollectionViewDiffableDataSource< + Section, + ImageAttachment + >! + + init(frame: CGRect = .zero) { + let layout = UICollectionViewFlowLayout() + layout.scrollDirection = .horizontal + layout.itemSize = CGSize(width: 80, height: 80) + layout.minimumInteritemSpacing = 8 + layout.minimumLineSpacing = 8 + layout.sectionInset = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 0) + + super.init(frame: frame, collectionViewLayout: layout) + showsHorizontalScrollIndicator = false + showsVerticalScrollIndicator = false + backgroundColor = .clear + + setupDataSource() + delegate = self + register(ImageCollectionViewCell.self, forCellWithReuseIdentifier: "ImageCell") + + snp.makeConstraints { make in + make.height.equalTo(constantHeight) + } + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError() + } + + func updateImageBarContent(_ attachments: [ImageAttachment]) { + self.attachments = attachments + applySnapshot() + } + + func clear() { + attachments.removeAll() + applySnapshot() + } + + private func setupDataSource() { + myDataSource = .init(collectionView: self) { [weak self] collectionView, indexPath, attachment in + let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "ImageCell", for: indexPath) as! ImageCollectionViewCell + + if let image = UIImage(data: attachment.imageData) { + cell.configure(with: image, attachmentId: attachment.id) { [weak self] attachmentId in + self?.imageBarDelegate?.inputBoxImageBar(self!, didRemoveImageWithId: attachmentId) + } + } + + return cell + } + } + + private func applySnapshot() { + var snapshot = NSDiffableDataSourceSnapshot< + Section, + ImageAttachment + >() + snapshot.appendSections([.main]) + snapshot.appendItems(attachments) + myDataSource.apply(snapshot, animatingDifferences: true) + } +} + +// MARK: - UICollectionViewDelegate + +extension ImageAttachmentBar: UICollectionViewDelegate {} + +// MARK: - Preview + +#if canImport(SwiftUI) && DEBUG + import SwiftUI + + struct InputBoxImageBar_Previews: PreviewProvider { + static var previews: some View { + UIViewPreview { + let imageBar = ImageAttachmentBar() + + let mockAttachments = [ + createMockImageAttachment(color: .red), + createMockImageAttachment(color: .blue), + createMockImageAttachment(color: .green), + createMockImageAttachment(color: .orange), + createMockImageAttachment(color: .purple), + ] + + imageBar.updateImageBarContent(mockAttachments) + return imageBar + } + .previewLayout(.fixed(width: 400, height: 100)) + .previewDisplayName("Image Bar with Multiple Images") + + UIViewPreview { + let imageBar = ImageAttachmentBar() + + let singleAttachment = [createMockImageAttachment(color: .systemBlue)] + imageBar.updateImageBarContent(singleAttachment) + return imageBar + } + .previewLayout(.fixed(width: 400, height: 100)) + .previewDisplayName("Image Bar with Single Image") + + UIViewPreview { + let imageBar = ImageAttachmentBar() + imageBar.updateImageBarContent([]) + return imageBar + } + .previewLayout(.fixed(width: 400, height: 100)) + .previewDisplayName("Empty Image Bar") + } + + private static func createMockImageAttachment(color: UIColor) -> ImageAttachment { + let size = CGSize(width: 100, height: 100) + let renderer = UIGraphicsImageRenderer(size: size) + let image = renderer.image { context in + color.setFill() + context.fill(CGRect(origin: .zero, size: size)) + + UIColor.white.withAlphaComponent(0.3).setFill() + let circleRect = CGRect(x: 25, y: 25, width: 50, height: 50) + context.cgContext.fillEllipse(in: circleRect) + } + + return ImageAttachment(image: image) + } + } +#endif diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/ImageAttachmentBar/ImageCollectionViewCell.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/ImageAttachmentBar/ImageCollectionViewCell.swift new file mode 100644 index 000000000..486e10a5a --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/ImageAttachmentBar/ImageCollectionViewCell.swift @@ -0,0 +1,61 @@ +// +// ImageCollectionViewCell.swift +// Intelligents +// +// Created by 秋星桥 on 6/25/25. +// + +import UIKit + +// MARK: - ImageCollectionViewCell + +class ImageCollectionViewCell: UICollectionViewCell { + private var attachmentId: UUID? + private var onRemove: ((UUID) -> Void)? + + private lazy var imageView = UIImageView().then { + $0.contentMode = .scaleAspectFill + $0.clipsToBounds = true + $0.layer.cornerRadius = 12 + $0.layer.cornerCurve = .continuous + $0.backgroundColor = .systemGray6 + } + + private lazy var removeButton = DeleteButtonView().then { + $0.onTapped = { [weak self] in + if let attachmentId = self?.attachmentId { + self?.onRemove?(attachmentId) + } + } + } + + override init(frame: CGRect) { + super.init(frame: frame) + setupViews() + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setupViews() { + contentView.addSubview(imageView) + contentView.addSubview(removeButton) + + imageView.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + + removeButton.snp.makeConstraints { make in + make.top.trailing.equalToSuperview().inset(6) + make.size.equalTo(18) + } + } + + func configure(with image: UIImage, attachmentId: UUID, onRemove: @escaping (UUID) -> Void) { + imageView.image = image + self.attachmentId = attachmentId + self.onRemove = onRemove + } +} diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/InputBox/InputBox.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/InputBox/InputBox.swift index 3b9745259..b8296e1ac 100644 --- a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/InputBox/InputBox.swift +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/InputBox/InputBox.swift @@ -43,7 +43,7 @@ class InputBox: UIView { $0.delegate = self } - lazy var imageBar = InputBoxImageBar().then { + lazy var imageBar = ImageAttachmentBar().then { $0.imageBarDelegate = self } @@ -57,6 +57,10 @@ class InputBox: UIView { $0.addArrangedSubview(functionBar) } + lazy var fileAttachmentHeader = FileAttachmentHeaderView().then { + $0.delegate = self + } + var textViewHeightConstraint: Constraint? let minTextViewHeight: CGFloat = 22 let maxTextViewHeight: CGFloat = 100 @@ -74,13 +78,15 @@ class InputBox: UIView { super.init(frame: frame) backgroundColor = .clear + addSubview(fileAttachmentHeader) addSubview(containerView) containerView.addSubview(mainStackView) containerView.addSubview(placeholderLabel) imageBar.isHidden = true containerView.snp.makeConstraints { make in - make.edges.equalToSuperview().inset(16) + make.left.bottom.right.equalToSuperview().inset(16) + make.top.greaterThanOrEqualToSuperview().offset(16) } mainStackView.snp.makeConstraints { make in @@ -100,6 +106,11 @@ class InputBox: UIView { make.top.equalTo(textView) } + // for initial status + fileAttachmentHeader.snp.makeConstraints { make in + make.edges.equalToSuperview().inset(32).priority(.high) + } + setupBindings() updatePlaceholderVisibility() updateColors() @@ -118,7 +129,6 @@ class InputBox: UIView { } func setupBindings() { - // 绑定 ViewModel 到 UI viewModel.$inputText .removeDuplicates() .sink { [weak self] text in @@ -158,8 +168,9 @@ class InputBox: UIView { } .store(in: &cancellables) - viewModel.$hasAttachments - .dropFirst() // for view setup + viewModel.$imageAttachments + .dropFirst() // for view setup to remove animation + .map { !$0.isEmpty /* -> hasAttachments */ } .removeDuplicates() .sink { [weak self] hasAttachments in performWithAnimation { @@ -169,12 +180,20 @@ class InputBox: UIView { } .store(in: &cancellables) - viewModel.$attachments + viewModel.$imageAttachments .removeDuplicates() .sink { [weak self] attachments in self?.updateImageBarContent(attachments) } .store(in: &cancellables) + + Publishers.CombineLatest(viewModel.$fileAttachments, viewModel.$documentAttachments) + .dropFirst() // for view setup to remove animation + .removeDuplicates { $0.0 == $1.0 && $0.1 == $1.1 } + .sink { [weak self] fileAttachments, documentAttachments in + self?.updateFileAttachmentHeader(fileCount: fileAttachments.count, documentCount: documentAttachments.count) + } + .store(in: &cancellables) } func updateTextViewHeight() { @@ -203,10 +222,34 @@ class InputBox: UIView { imageBar.isHidden = !hasAttachments } - func updateImageBarContent(_ attachments: [InputAttachment]) { + func updateImageBarContent(_ attachments: [ImageAttachment]) { imageBar.updateImageBarContent(attachments) } + func updateFileAttachmentHeader(fileCount: Int, documentCount: Int) { + let hasAttachments = fileCount > 0 || documentCount > 0 + + fileAttachmentHeader.snp.remakeConstraints { make in + if hasAttachments { + make.leading.trailing.equalToSuperview().inset(32) + make.bottom.equalTo(self.containerView.snp.top).offset(8) + make.top.equalToSuperview().offset(8) + } else { + make.edges.equalToSuperview().inset(32).priority(.high) + } + } + + performWithAnimation { + self.fileAttachmentHeader.isHidden = !hasAttachments + if hasAttachments { + self.fileAttachmentHeader.updateContent(attachmentCount: fileCount, docsCount: documentCount) + self.fileAttachmentHeader.setIconImage(UIImage(systemName: "doc")) + } + self.layoutIfNeeded() + self.superview?.layoutIfNeeded() + } + } + func updateColors() { containerView.layer.borderColor = UIColor.affineLayerBorder.cgColor } @@ -214,33 +257,36 @@ class InputBox: UIView { // MARK: - Public Methods public func addImageAttachment(_ image: UIImage) { - guard let imageData = image.jpegData(compressionQuality: 0.8) else { return } - - let attachment = InputAttachment( - type: .image, - data: imageData, - name: "image.jpg", - size: Int64(imageData.count) - ) + let attachment = ImageAttachment(image: image) performWithAnimation { [self] in - viewModel.addAttachment(attachment) + viewModel.addImageAttachment(attachment) layoutIfNeeded() } } - public func addFileAttachment(_ url: URL) { - guard let fileData = try? Data(contentsOf: url) else { return } + public func addFileAttachment(_ url: URL) throws { + // check less then 15mb + let fileSizeLimit: Int64 = 15 * 1024 * 1024 // 15 MB + let fileAttributes = try FileManager.default.attributesOfItem(atPath: url.path) + guard let fileSize = fileAttributes[.size] as? Int64, fileSize <= fileSizeLimit else { + throw NSError( + domain: "FileAttachmentErrorDomain", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "File size exceeds 15 MB limit."] + ) + } + let fileData = try Data(contentsOf: url) - let attachment = InputAttachment( - type: .file, + let attachment = FileAttachment( data: fileData, + url: url, name: url.lastPathComponent, size: Int64(fileData.count) ) performWithAnimation { [self] in - viewModel.addAttachment(attachment) + viewModel.addFileAttachment(attachment) layoutIfNeeded() } } @@ -249,39 +295,3 @@ class InputBox: UIView { viewModel.prepareSendData() } } - -// MARK: - InputBoxFunctionBarDelegate - -extension InputBox: InputBoxFunctionBarDelegate { - func functionBarDidTapTakePhoto(_: InputBoxFunctionBar) { - delegate?.inputBoxDidSelectTakePhoto(self) - } - - func functionBarDidTapPhotoLibrary(_: InputBoxFunctionBar) { - delegate?.inputBoxDidSelectPhotoLibrary(self) - } - - func functionBarDidTapAttachFiles(_: InputBoxFunctionBar) { - delegate?.inputBoxDidSelectAttachFiles(self) - } - - func functionBarDidTapEmbedDocs(_: InputBoxFunctionBar) { - delegate?.inputBoxDidSelectEmbedDocs(self) - } - - func functionBarDidTapTool(_: InputBoxFunctionBar) { - viewModel.toggleTool() - } - - func functionBarDidTapNetwork(_: InputBoxFunctionBar) { - viewModel.toggleNetwork() - } - - func functionBarDidTapDeepThinking(_: InputBoxFunctionBar) { - viewModel.toggleDeepThinking() - } - - func functionBarDidTapSend(_: InputBoxFunctionBar) { - delegate?.inputBoxDidSend(self) - } -} diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/InputBox/InputBoxDelegate.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/InputBox/InputBoxDelegate.swift index b89494913..85177d3f1 100644 --- a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/InputBox/InputBoxDelegate.swift +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/InputBox/InputBoxDelegate.swift @@ -5,6 +5,7 @@ // Created by 秋星桥 on 6/18/25. // +import SwifterSwift import UIKit protocol InputBoxDelegate: AnyObject { @@ -16,15 +17,76 @@ protocol InputBoxDelegate: AnyObject { func inputBoxTextDidChange(_ text: String) } -extension InputBox: InputBoxImageBarDelegate { - func inputBoxImageBar(_: InputBoxImageBar, didRemoveImageWithId id: InputAttachment.ID) { +extension InputBox: ImageAttachmentBarDelegate { + func inputBoxImageBar(_: ImageAttachmentBar, didRemoveImageWithId id: ImageAttachment.ID) { performWithAnimation { [self] in - viewModel.removeAttachment(withId: id) + viewModel.removeImageAttachment(withId: id) layoutIfNeeded() } } } +extension InputBox: FileAttachmentHeaderViewDelegate { + func headerViewDidPickMore(_: FileAttachmentHeaderView) { + delegate?.inputBoxDidSelectAttachFiles(self) + } + + func headerViewDidTapManagement(_: FileAttachmentHeaderView) { + let controller = AttachmentManagementController(delegate: self) + controller.set(fileAttachments: viewModel.fileAttachments) + controller.set(documentAttachments: viewModel.documentAttachments) + parentViewController?.present(controller, animated: true) + } +} + +extension InputBox: AttachmentManagementControllerDelegate { + func deleteFileAttachment(controller: AttachmentManagementController, _ attachment: FileAttachment) { + viewModel.removeFileAttachment(withId: attachment.id) + controller.set(fileAttachments: viewModel.fileAttachments) + layoutIfNeeded() + } + + func deleteDocumentAttachment(controller: AttachmentManagementController, _ attachment: DocumentAttachment) { + viewModel.removeDocumentAttachment(withId: attachment.id) + controller.set(documentAttachments: viewModel.documentAttachments) + layoutIfNeeded() + } +} + +extension InputBox: InputBoxFunctionBarDelegate { + func functionBarDidTapTakePhoto(_: InputBoxFunctionBar) { + delegate?.inputBoxDidSelectTakePhoto(self) + } + + func functionBarDidTapPhotoLibrary(_: InputBoxFunctionBar) { + delegate?.inputBoxDidSelectPhotoLibrary(self) + } + + func functionBarDidTapAttachFiles(_: InputBoxFunctionBar) { + delegate?.inputBoxDidSelectAttachFiles(self) + } + + func functionBarDidTapEmbedDocs(_: InputBoxFunctionBar) { + delegate?.inputBoxDidSelectEmbedDocs(self) + } + + func functionBarDidTapTool(_: InputBoxFunctionBar) { + viewModel.toggleTool() + } + + func functionBarDidTapNetwork(_: InputBoxFunctionBar) { + viewModel.toggleNetwork() + } + + func functionBarDidTapDeepThinking(_: InputBoxFunctionBar) { + viewModel.toggleDeepThinking() + } + + func functionBarDidTapSend(_: InputBoxFunctionBar) { + delegate?.inputBoxDidSend(self) + } +} + extension InputBox: UITextViewDelegate { func textViewDidChange(_ textView: UITextView) { viewModel.updateText(textView.text ?? "") diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/InputBox/InputBoxFunctionBar.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/InputBox/InputBoxFunctionBar.swift index db3771a26..1e97df556 100644 --- a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/InputBox/InputBoxFunctionBar.swift +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/InputBox/InputBoxFunctionBar.swift @@ -156,7 +156,7 @@ class InputBoxFunctionBar: UIView { } let attachFilesAction = UIAction( - title: "Attach Files (pdf, txt, csv)", + title: "Attach Files (.pdf, .txt, .csv)", image: UIImage.affineUpload ) { [weak self] _ in guard let self else { return } diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/InputBox/InputBoxImageBar.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/InputBox/InputBoxImageBar.swift deleted file mode 100644 index 8b1cdf357..000000000 --- a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/InputBox/InputBoxImageBar.swift +++ /dev/null @@ -1,180 +0,0 @@ -// -// InputBoxImageBar.swift -// Intelligents -// -// Created by 秋星桥 on 6/18/25. -// - -import SnapKit -import Then -import UIKit - -protocol InputBoxImageBarDelegate: AnyObject { - func inputBoxImageBar(_ imageBar: InputBoxImageBar, didRemoveImageWithId id: UUID) -} - -private class AttachmentViewModel { - let attachment: InputAttachment - let imageCell: InputBoxImageBar.ImageCell - - init(attachment: InputAttachment, imageCell: InputBoxImageBar.ImageCell) { - self.attachment = attachment - self.imageCell = imageCell - } -} - -class InputBoxImageBar: UIScrollView { - weak var imageBarDelegate: InputBoxImageBarDelegate? - - private var attachmentViewModels: [AttachmentViewModel] = [] - private let cellSpacing: CGFloat = 8 - private let constantHeight: CGFloat = 80 - - override init(frame: CGRect = .zero) { - super.init(frame: frame) - showsHorizontalScrollIndicator = false - showsVerticalScrollIndicator = false - - snp.makeConstraints { make in - make.height.equalTo(constantHeight) - } - } - - @available(*, unavailable) - required init?(coder _: NSCoder) { - fatalError() - } - - func updateImageBarContent(_ attachments: [InputAttachment]) { - let currentIds = Set(attachmentViewModels.map(\.attachment.id)) - let imageAttachments = attachments.filter { $0.type == .image } - let newIds = Set(imageAttachments.map(\.id)) - - // 移除不再存在的附件 - let idsToRemove = currentIds.subtracting(newIds) - for id in idsToRemove { - if let index = attachmentViewModels.firstIndex(where: { $0.attachment.id == id }) { - let viewModel = attachmentViewModels.remove(at: index) - viewModel.imageCell.removeFromSuperview() - } - } - - // 添加新的附件 - let idsToAdd = newIds.subtracting(currentIds) - var initialXOffset = attachmentViewModels.reduce(0) { $0 + $1.imageCell.frame.width + cellSpacing } - for attachment in imageAttachments { - if idsToAdd.contains(attachment.id), - let data = attachment.data, - let image = UIImage(data: data) - { - let imageCell = ImageCell( - // for animation to work - frame: .init(x: initialXOffset, y: 0, width: constantHeight, height: constantHeight), - image: image, - attachmentId: attachment.id - ) - initialXOffset += constantHeight + cellSpacing - imageCell.onRemove = { [weak self] cell in - self?.removeImageCell(cell) - } - imageCell.alpha = 0 - DispatchQueue.main.async { - performWithAnimation { imageCell.alpha = 1 } - } - - let viewModel = AttachmentViewModel(attachment: attachment, imageCell: imageCell) - attachmentViewModels.append(viewModel) - addSubview(imageCell) - } - } - - layoutImageCells() - } - - func removeImageCell(_ cell: ImageCell) { - if let index = attachmentViewModels.firstIndex(where: { $0.imageCell === cell }) { - let viewModel = attachmentViewModels.remove(at: index) - viewModel.imageCell.removeFromSuperviewWithExplodeEffect() - imageBarDelegate?.inputBoxImageBar(self, didRemoveImageWithId: cell.attachmentId) - layoutImageCells() - } - } - - func clear() { - for viewModel in attachmentViewModels { - viewModel.imageCell.removeFromSuperview() - } - attachmentViewModels.removeAll() - contentSize = .zero - } - - private func layoutImageCells() { - var xOffset: CGFloat = 0 - - for viewModel in attachmentViewModels { - viewModel.imageCell.frame = CGRect(x: xOffset, y: 0, width: constantHeight, height: constantHeight) - xOffset += constantHeight + cellSpacing - } - - // Update content size - let totalWidth = max(0, xOffset - cellSpacing) - contentSize = CGSize(width: totalWidth, height: constantHeight) - } -} - -extension InputBoxImageBar { - class ImageCell: UIView { - let attachmentId: UUID - var onRemove: ((ImageCell) -> Void)? - - private lazy var imageView = UIImageView(frame: bounds).then { - $0.contentMode = .scaleAspectFill - $0.clipsToBounds = true - $0.layer.cornerRadius = 12 - $0.layer.cornerCurve = .continuous - $0.backgroundColor = .systemGray6 - } - - private lazy var removeButton = DeleteButtonView(frame: removeButtonFrame).then { - $0.onTapped = { [weak self] in - self?.removeButtonTapped() - } - } - - init(frame: CGRect, image: UIImage, attachmentId: UUID) { - self.attachmentId = attachmentId - super.init(frame: frame) - addSubview(imageView) - addSubview(removeButton) - imageView.image = image - } - - var removeButtonFrame: CGRect { - let buttonSize: CGFloat = 18 - let buttonInset: CGFloat = 6 - return CGRect( - x: bounds.width - buttonSize - buttonInset, - y: buttonInset, - width: buttonSize, - height: buttonSize - ) - } - - override func layoutSubviews() { - super.layoutSubviews() - - imageView.frame = bounds - - removeButton.frame = removeButtonFrame - } - - @available(*, unavailable) - required init?(coder _: NSCoder) { - fatalError() - } - - @objc private func removeButtonTapped() { - onRemove?(self) - } - } -} diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/InputBox/InputBoxViewModel.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/InputBox/InputBoxViewModel.swift deleted file mode 100644 index 9a3a24912..000000000 --- a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/InputBox/InputBoxViewModel.swift +++ /dev/null @@ -1,164 +0,0 @@ -// -// InputBoxViewModel.swift -// Intelligents -// -// Created by AI Assistant on 6/17/25. -// - -import Combine -import Foundation - -// MARK: - Data Models - -public struct InputAttachment: Identifiable, Equatable, Hashable, Codable { - public var id: UUID = .init() - public var type: AttachmentType - public var data: Data? - public var url: URL? - public var name: String - public var size: Int64 - - public enum AttachmentType: String, Equatable, Hashable, Codable { - case image - case document - case file - } - - public init( - type: AttachmentType, - data: Data? = nil, - url: URL? = nil, - name: String, - size: Int64 = 0 - ) { - self.type = type - self.data = data - self.url = url - self.name = name - self.size = size - } -} - -public struct InputBoxData { - public var text: String - public var attachments: [InputAttachment] - public var isToolEnabled: Bool - public var isNetworkEnabled: Bool - public var isDeepThinkingEnabled: Bool - - public init( - text: String, - attachments: [InputAttachment], - isToolEnabled: Bool, - isNetworkEnabled: Bool, - isDeepThinkingEnabled: Bool - ) { - self.text = text - self.attachments = attachments - self.isToolEnabled = isToolEnabled - self.isNetworkEnabled = isNetworkEnabled - self.isDeepThinkingEnabled = isDeepThinkingEnabled - } -} - -// MARK: - View Model - -public class InputBoxViewModel: ObservableObject { - // MARK: - Published Properties - - @Published public var inputText: String = "" - @Published public var isToolEnabled: Bool = false - @Published public var isNetworkEnabled: Bool = false - @Published public var isDeepThinkingEnabled: Bool = false - @Published public var hasAttachments: Bool = false - @Published public var attachments: [InputAttachment] = [] - @Published public var canSend: Bool = false - - // MARK: - Private Properties - - private var cancellables = Set() - - // MARK: - Initialization - - public init() { - setupBindings() - } - - // MARK: - Private Methods - - private func setupBindings() { - // 监听文本变化,自动更新发送按钮状态 - $inputText - .map { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } - .assign(to: \.canSend, on: self) - .store(in: &cancellables) - - // 监听附件变化 - $attachments - .map { !$0.isEmpty } - .assign(to: \.hasAttachments, on: self) - .store(in: &cancellables) - } -} - -// MARK: - Text Management - -public extension InputBoxViewModel { - func updateText(_ text: String) { - inputText = text - } -} - -// MARK: - Feature Toggles - -public extension InputBoxViewModel { - func toggleTool() { - isToolEnabled.toggle() - } - - func toggleNetwork() { - isNetworkEnabled.toggle() - } - - func toggleDeepThinking() { - isDeepThinkingEnabled.toggle() - } -} - -// MARK: - Attachment Management - -public extension InputBoxViewModel { - func addAttachment(_ attachment: InputAttachment) { - attachments.append(attachment) - } - - func removeAttachment(withId id: UUID) { - attachments.removeAll { $0.id == id } - } - - func clearAttachments() { - attachments.removeAll() - } -} - -// MARK: - Send Management - -public extension InputBoxViewModel { - func prepareSendData() -> InputBoxData { - InputBoxData( - text: inputText.trimmingCharacters(in: .whitespacesAndNewlines), - attachments: attachments, - isToolEnabled: isToolEnabled, - isNetworkEnabled: isNetworkEnabled, - isDeepThinkingEnabled: isDeepThinkingEnabled - ) - } - - func resetInput() { - inputText = "" - attachments.removeAll() - isToolEnabled = false - isNetworkEnabled = false - isDeepThinkingEnabled = false - } -} diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/InputBox/ViewModel/DocumentAttachment.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/InputBox/ViewModel/DocumentAttachment.swift new file mode 100644 index 000000000..1f94bd79a --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/InputBox/ViewModel/DocumentAttachment.swift @@ -0,0 +1,15 @@ +// +// DocumentAttachment.swift +// Intelligents +// +// Created by 秋星桥 on 6/24/25. +// + +import Foundation + +public struct DocumentAttachment: Identifiable, Equatable, Hashable, Codable { + public var id: UUID = .init() + public var title: String = "" + public var workspaceID: String = "" + public var documentID: String = "" +} diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/InputBox/ViewModel/FileAttachment.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/InputBox/ViewModel/FileAttachment.swift new file mode 100644 index 000000000..72c8415a8 --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/InputBox/ViewModel/FileAttachment.swift @@ -0,0 +1,28 @@ +// +// FileAttachment.swift +// Intelligents +// +// Created by 秋星桥 on 6/24/25. +// + +import Foundation + +public struct FileAttachment: Identifiable, Equatable, Hashable, Codable { + public var id: UUID = .init() + public var data: Data? + public var url: URL + public var name: String + public var size: Int64 + + public init( + data: Data? = nil, + url: URL, + name: String, + size: Int64 = 0 + ) { + self.data = data + self.url = url + self.name = name + self.size = size + } +} diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/InputBox/ViewModel/ImageAttachment.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/InputBox/ViewModel/ImageAttachment.swift new file mode 100644 index 000000000..1a45095a9 --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/InputBox/ViewModel/ImageAttachment.swift @@ -0,0 +1,17 @@ +// +// ImageAttachment.swift +// Intelligents +// +// Created by 秋星桥 on 6/24/25. +// + +import UIKit + +public struct ImageAttachment: Identifiable, Equatable, Hashable, Codable { + public var id: UUID = .init() + public var imageData: Data + + public init(image: UIImage) { + imageData = image.jpegData(compressionQuality: 0.5) ?? Data() + } +} diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/InputBox/ViewModel/InputBoxViewModel.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/InputBox/ViewModel/InputBoxViewModel.swift new file mode 100644 index 000000000..fccd1c418 --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/InputBox/ViewModel/InputBoxViewModel.swift @@ -0,0 +1,159 @@ +// +// InputBoxViewModel.swift +// Intelligents +// +// Created by AI Assistant on 6/17/25. +// + +import Combine +import Foundation + +// MARK: - Data Models + +public struct InputBoxData { + public var text: String + public var imageAttachments: [ImageAttachment] + public var fileAttachments: [FileAttachment] = [] + public var documentAttachments: [DocumentAttachment] = [] + public var isToolEnabled: Bool + public var isNetworkEnabled: Bool + public var isDeepThinkingEnabled: Bool + + public init(text: String, imageAttachments: [ImageAttachment], fileAttachments: [FileAttachment], documentAttachments: [DocumentAttachment], isToolEnabled: Bool, isNetworkEnabled: Bool, isDeepThinkingEnabled: Bool) { + self.text = text + self.imageAttachments = imageAttachments + self.fileAttachments = fileAttachments + self.documentAttachments = documentAttachments + self.isToolEnabled = isToolEnabled + self.isNetworkEnabled = isNetworkEnabled + self.isDeepThinkingEnabled = isDeepThinkingEnabled + } +} + +// MARK: - View Model + +public class InputBoxViewModel: ObservableObject { + // MARK: - Published Properties + + @Published public var inputText: String = "" + @Published public var isToolEnabled: Bool = false + @Published public var isNetworkEnabled: Bool = false + @Published public var isDeepThinkingEnabled: Bool = false + @Published public var imageAttachments: [ImageAttachment] = [] + @Published public var fileAttachments: [FileAttachment] = [] + @Published public var documentAttachments: [DocumentAttachment] = [] + @Published public var canSend: Bool = false + + // MARK: - Private Properties + + private var cancellables = Set() + + // MARK: - Initialization + + public init() { + setupBindings() + } + + // MARK: - Private Methods + + private func setupBindings() { + Publishers.CombineLatest4($inputText, $imageAttachments, $fileAttachments, $documentAttachments) + .map { text, images, files, docs in + let hasText = !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + let hasAnyAttachments = !images.isEmpty || !files.isEmpty || !docs.isEmpty + return hasText || hasAnyAttachments + } + .assign(to: \.canSend, on: self) + .store(in: &cancellables) + } +} + +// MARK: - Text Management + +public extension InputBoxViewModel { + func updateText(_ text: String) { + inputText = text + } +} + +// MARK: - Feature Toggles + +public extension InputBoxViewModel { + func toggleTool() { + isToolEnabled.toggle() + } + + func toggleNetwork() { + isNetworkEnabled.toggle() + } + + func toggleDeepThinking() { + isDeepThinkingEnabled.toggle() + } +} + +// MARK: - Attachment Management + +public extension InputBoxViewModel { + func addImageAttachment(_ attachment: ImageAttachment) { + imageAttachments.append(attachment) + } + + func removeImageAttachment(withId id: UUID) { + imageAttachments.removeAll { $0.id == id } + } + + func clearImageAttachments() { + imageAttachments.removeAll() + } + + func addFileAttachment(_ attachment: FileAttachment) { + fileAttachments.append(attachment) + } + + func removeFileAttachment(withId id: UUID) { + fileAttachments.removeAll { $0.id == id } + } + + func clearFileAttachments() { + fileAttachments.removeAll() + } + + func addDocumentAttachment(_ attachment: DocumentAttachment) { + documentAttachments.append(attachment) + } + + func removeDocumentAttachment(withId id: UUID) { + documentAttachments.removeAll { $0.id == id } + } + + func clearDocumentAttachments() { + documentAttachments.removeAll() + } +} + +// MARK: - Send Management + +public extension InputBoxViewModel { + func prepareSendData() -> InputBoxData { + InputBoxData( + text: inputText.trimmingCharacters(in: .whitespacesAndNewlines), + imageAttachments: imageAttachments, + fileAttachments: fileAttachments, + documentAttachments: documentAttachments, + isToolEnabled: isToolEnabled, + isNetworkEnabled: isNetworkEnabled, + isDeepThinkingEnabled: isDeepThinkingEnabled + ) + } + + func resetInput() { + inputText = "" + imageAttachments.removeAll() + fileAttachments.removeAll() + documentAttachments.removeAll() + isToolEnabled = false + isNetworkEnabled = false + isDeepThinkingEnabled = false + } +}