chore: made attachment header & management sheet (#12922)
<!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **New Features** - Introduced file, image, and document attachment support in the input box, including new UI components for managing and previewing attachments. - Added a searchable document picker view and a file attachment header with interactive management options. - Enabled an attachment management controller for viewing and deleting attachments. - Improved image attachment bar with horizontal scrolling and removal functionality. - Enhanced error handling for file attachments, providing user-facing alerts. - **Improvements** - Updated attachment menus for clearer file type indications. - Streamlined attachment handling logic and UI updates for a smoother user experience. - **Bug Fixes** - Addressed error notification by replacing console logging with user alerts when file attachment issues occur. - **Refactor** - Replaced and reorganized the input box view model and attachment bar for better modularity and maintainability. - **Chores** - Updated asset catalogs to include new attachment icons for various file types. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
parent
aa4874a55c
commit
c37df9fb94
@ -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"),
|
||||
]),
|
||||
]
|
||||
)
|
||||
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
@ -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<ViewController: UIViewController>: 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<View: UIView>: 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
|
@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
@ -0,0 +1,12 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "Page.pdf",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
Binary file not shown.
Binary file not shown.
@ -0,0 +1,12 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "AiFileClip attachment icon.pdf",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
Binary file not shown.
@ -0,0 +1,12 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "AiFileClip attachment icon.pdf",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
Binary file not shown.
@ -0,0 +1,12 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "AiFileClip attachment icon.pdf",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
Binary file not shown.
@ -0,0 +1,12 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "AiFileClip attachment icon.pdf",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
@ -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<Section, Item>()
|
||||
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
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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
|
@ -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
|
||||
}
|
||||
}
|
@ -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
|
@ -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
|
@ -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
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
@ -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 ?? "")
|
||||
|
@ -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 }
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
@ -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<AnyCancellable>()
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
@ -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 = ""
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
@ -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()
|
||||
}
|
||||
}
|
@ -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<AnyCancellable>()
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user