Add submit-task flow aligned with Android and extract profile edit page.

Implement task creation with cloud/local media, order linking, and OSS upload; wire home entry and move profile nickname/avatar editing to ProfileEditViewController.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-07 10:12:20 +08:00
parent 715393e78c
commit 3c4ca7eefb
23 changed files with 3186 additions and 170 deletions

View File

@ -0,0 +1,323 @@
//
// CloudStoragePickForTaskViewController.swift
// suixinkan
//
import Kingfisher
import SnapKit
import UIKit
/// Android `CloudStorageListForTaskScreen`
final class CloudStoragePickForTaskViewController: BaseViewController {
var onConfirmed: (([CloudFile]) -> Void)?
private let viewModel: CloudStoragePickForTaskViewModel
private let taskAPI = NetworkServices.shared.taskAPI
private let searchField = UITextField()
private let pathScrollView = UIScrollView()
private let pathStack = UIStackView()
private var collectionView: UICollectionView!
private var dataSource: UICollectionViewDiffableDataSource<Int, Int>!
private let confirmButton = AppButton(title: "确定")
private let bottomBar = UIView()
init(importedFileIDs: Set<Int> = []) {
viewModel = CloudStoragePickForTaskViewModel(importedFileIDs: importedFileIDs)
super.init(nibName: nil, bundle: nil)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func setupNavigationBar() {
title = "云盘导入"
}
override func setupUI() {
searchField.placeholder = "搜索文件"
searchField.borderStyle = .roundedRect
searchField.font = .app(.body)
searchField.returnKeyType = .search
searchField.delegate = self
pathStack.axis = .horizontal
pathStack.spacing = AppSpacing.xs
pathScrollView.showsHorizontalScrollIndicator = false
pathScrollView.addSubview(pathStack)
pathStack.snp.makeConstraints { make in
make.edges.equalToSuperview()
make.height.equalToSuperview()
}
collectionView = UICollectionView(frame: .zero, collectionViewLayout: makeLayout())
collectionView.backgroundColor = AppColor.pageBackground
collectionView.register(CloudPickCell.self, forCellWithReuseIdentifier: CloudPickCell.reuseIdentifier)
collectionView.delegate = self
dataSource = UICollectionViewDiffableDataSource<Int, Int>(collectionView: collectionView) {
[weak self] collectionView, indexPath, fileID in
let cell = collectionView.dequeueReusableCell(
withReuseIdentifier: CloudPickCell.reuseIdentifier,
for: indexPath
) as! CloudPickCell
if let file = self?.viewModel.files.first(where: { $0.id == fileID }) {
cell.apply(
file: file,
isSelected: self?.viewModel.isSelected(fileID) == true,
isImported: self?.viewModel.isImported(fileID) == true
)
}
return cell
}
bottomBar.backgroundColor = .white
view.addSubview(searchField)
view.addSubview(pathScrollView)
view.addSubview(collectionView)
view.addSubview(bottomBar)
bottomBar.addSubview(confirmButton)
}
override func setupConstraints() {
searchField.snp.makeConstraints { make in
make.top.equalTo(view.safeAreaLayoutGuide).offset(AppSpacing.sm)
make.leading.trailing.equalToSuperview().inset(AppSpacing.md)
make.height.equalTo(40)
}
pathScrollView.snp.makeConstraints { make in
make.top.equalTo(searchField.snp.bottom).offset(AppSpacing.sm)
make.leading.trailing.equalToSuperview().inset(AppSpacing.md)
make.height.equalTo(28)
}
bottomBar.snp.makeConstraints { make in
make.leading.trailing.bottom.equalToSuperview()
}
confirmButton.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(AppSpacing.md)
}
collectionView.snp.makeConstraints { make in
make.top.equalTo(pathScrollView.snp.bottom).offset(AppSpacing.sm)
make.leading.trailing.equalToSuperview()
make.bottom.equalTo(bottomBar.snp.top)
}
}
override func bindActions() {
viewModel.onStateChange = { [weak self] in
Task { @MainActor in self?.applyViewModel() }
}
viewModel.onShowMessage = { [weak self] message in
Task { @MainActor in self?.showToast(message) }
}
confirmButton.addTarget(self, action: #selector(confirmTapped), for: .touchUpInside)
}
override func viewDidLoad() {
super.viewDidLoad()
Task { await viewModel.refresh(api: taskAPI) }
}
@MainActor
private func applyViewModel() {
rebuildPathButtons()
var snapshot = NSDiffableDataSourceSnapshot<Int, Int>()
snapshot.appendSections([0])
snapshot.appendItems(viewModel.files.map(\.id))
dataSource.apply(snapshot, animatingDifferences: false)
if viewModel.isLoading && viewModel.files.isEmpty {
showLoading()
} else {
hideLoading()
}
}
private func rebuildPathButtons() {
pathStack.arrangedSubviews.forEach { $0.removeFromSuperview() }
for (index, item) in viewModel.pathStack.enumerated() {
let button = UIButton(type: .system)
button.setTitle(item.name, for: .normal)
button.titleLabel?.font = .app(.caption)
button.tag = index
button.addTarget(self, action: #selector(pathTapped(_:)), for: .touchUpInside)
pathStack.addArrangedSubview(button)
if index < viewModel.pathStack.count - 1 {
let arrow = UILabel()
arrow.text = ">"
arrow.font = .app(.caption)
arrow.textColor = AppColor.textTertiary
pathStack.addArrangedSubview(arrow)
}
}
}
@objc private func pathTapped(_ sender: UIButton) {
Task { await viewModel.navigateToPathIndex(sender.tag, api: taskAPI) }
}
@objc private func confirmTapped() {
onConfirmed?(viewModel.selectedFileList)
navigationController?.popViewController(animated: true)
}
private func makeLayout() -> UICollectionViewCompositionalLayout {
UICollectionViewCompositionalLayout { _, environment in
let columns = 3
let spacing: CGFloat = 8
let inset = AppSpacing.md * 2
let availableWidth = environment.container.effectiveContentSize.width - inset
let totalSpacing = spacing * CGFloat(columns - 1)
let itemWidth = max(0, (availableWidth - totalSpacing) / CGFloat(columns))
let itemSize = NSCollectionLayoutSize(
widthDimension: .absolute(itemWidth),
heightDimension: .absolute(itemWidth)
)
let item = NSCollectionLayoutItem(layoutSize: itemSize)
let groupSize = NSCollectionLayoutSize(
widthDimension: .fractionalWidth(1),
heightDimension: .absolute(itemWidth)
)
let group = NSCollectionLayoutGroup.horizontal(
layoutSize: groupSize,
repeatingSubitem: item,
count: columns
)
group.interItemSpacing = .fixed(spacing)
let section = NSCollectionLayoutSection(group: group)
section.interGroupSpacing = spacing
section.contentInsets = NSDirectionalEdgeInsets(
top: 0,
leading: AppSpacing.md,
bottom: AppSpacing.md,
trailing: AppSpacing.md
)
return section
}
}
}
extension CloudStoragePickForTaskViewController: UICollectionViewDelegate {
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
guard let fileID = dataSource.itemIdentifier(for: indexPath),
let file = viewModel.files.first(where: { $0.id == fileID }) else {
return
}
Task { await viewModel.handleItemTap(file, api: taskAPI) }
}
func scrollViewDidScroll(_ scrollView: UIScrollView) {
let offsetY = scrollView.contentOffset.y
let contentHeight = scrollView.contentSize.height
let frameHeight = scrollView.frame.size.height
if offsetY > contentHeight - frameHeight - 120 {
Task { await viewModel.loadMore(api: taskAPI) }
}
}
}
extension CloudStoragePickForTaskViewController: UITextFieldDelegate {
func textFieldShouldReturn(_ textField: UITextField) -> Bool {
viewModel.updateSearchText(textField.text ?? "")
textField.resignFirstResponder()
Task { await viewModel.refresh(api: taskAPI) }
return true
}
}
/// cell
private final class CloudPickCell: UICollectionViewCell {
static let reuseIdentifier = "CloudPickCell"
private let imageView = UIImageView()
private let nameLabel = UILabel()
private let badgeView = UIView()
private let folderIconView = UIImageView()
override init(frame: CGRect) {
super.init(frame: frame)
contentView.backgroundColor = .white
contentView.layer.cornerRadius = AppRadius.sm
contentView.clipsToBounds = true
imageView.contentMode = .scaleAspectFill
imageView.clipsToBounds = true
folderIconView.image = UIImage(systemName: "folder.fill")
folderIconView.tintColor = AppColor.primary
folderIconView.contentMode = .scaleAspectFit
nameLabel.font = .app(.caption)
nameLabel.textColor = AppColor.textPrimary
nameLabel.numberOfLines = 2
nameLabel.textAlignment = .center
badgeView.backgroundColor = AppColor.primary
badgeView.layer.cornerRadius = 10
badgeView.isHidden = true
contentView.addSubview(imageView)
contentView.addSubview(folderIconView)
contentView.addSubview(nameLabel)
contentView.addSubview(badgeView)
imageView.snp.makeConstraints { make in
make.top.leading.trailing.equalToSuperview()
make.height.equalTo(contentView.snp.width)
}
folderIconView.snp.makeConstraints { make in
make.center.equalTo(imageView)
make.width.height.equalTo(36)
}
nameLabel.snp.makeConstraints { make in
make.top.equalTo(imageView.snp.bottom).offset(AppSpacing.xxs)
make.leading.trailing.bottom.equalToSuperview().inset(AppSpacing.xxs)
}
badgeView.snp.makeConstraints { make in
make.top.trailing.equalToSuperview().inset(AppSpacing.xxs)
make.width.height.equalTo(20)
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func prepareForReuse() {
super.prepareForReuse()
imageView.kf.cancelDownloadTask()
imageView.image = nil
folderIconView.isHidden = true
badgeView.isHidden = true
}
func apply(file: CloudFile, isSelected: Bool, isImported: Bool) {
nameLabel.text = file.name
contentView.layer.borderWidth = isSelected ? 2 : 0
contentView.layer.borderColor = AppColor.primary.cgColor
badgeView.isHidden = !isSelected
if file.isFolder {
imageView.image = nil
folderIconView.isHidden = false
return
}
folderIconView.isHidden = true
let preview = file.coverUrl.isEmpty ? file.fileUrl : file.coverUrl
if let url = URL(string: preview), !preview.isEmpty {
imageView.kf.setImage(with: url)
} else {
imageView.image = UIImage(systemName: file.isVideo ? "video" : "photo")
imageView.tintColor = AppColor.textTertiary
}
if isImported {
contentView.alpha = 0.5
} else {
contentView.alpha = 1
}
}
}