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:
Lakr 2025-06-25 13:30:00 +08:00 committed by GitHub
parent aa4874a55c
commit c37df9fb94
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
30 changed files with 1460 additions and 409 deletions

View File

@ -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"),
]),
]
)

View File

@ -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
)
}
}

View File

@ -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

View File

@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "Page.pdf",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "AiFileClip attachment icon.pdf",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "AiFileClip attachment icon.pdf",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "AiFileClip attachment icon.pdf",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "AiFileClip attachment icon.pdf",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -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

View File

@ -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)
}
}
}

View File

@ -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

View File

@ -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
}
}

View File

@ -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

View File

@ -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

View File

@ -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
}
}

View File

@ -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)
}
}

View File

@ -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 ?? "")

View File

@ -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 }

View File

@ -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)
}
}
}

View File

@ -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
}
}

View File

@ -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 = ""
}

View File

@ -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
}
}

View File

@ -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()
}
}

View File

@ -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
}
}