对齐 Android 实名认证流程与审核页、钱包提现/积分兑换界面,优化打卡点列表与详情交互,并补充相关资源、主题 token 与单元测试。 Co-authored-by: Cursor <cursoragent@cursor.com>
447 lines
18 KiB
Swift
447 lines
18 KiB
Swift
//
|
|
// 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 let filterBar = UIStackView()
|
|
private let sortButton = UIButton(type: .system)
|
|
private let typeButton = UIButton(type: .system)
|
|
private let displayModeButton = UIButton(type: .system)
|
|
private let refreshControl = UIRefreshControl()
|
|
private var collectionView: UICollectionView!
|
|
private var dataSource: UICollectionViewDiffableDataSource<Int, Int>!
|
|
private let confirmButton = AppButton(title: "确定")
|
|
private let bottomBar = UIView()
|
|
private var isGridMode = true
|
|
|
|
/// 初始化云盘多选页,可按云盘文件类型限制选择范围。
|
|
init(importedFileIDs: Set<Int> = [], allowedFileType: Int? = nil) {
|
|
viewModel = CloudStoragePickForTaskViewModel(importedFileIDs: importedFileIDs, allowedFileType: allowedFileType)
|
|
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()
|
|
}
|
|
|
|
configureMenuButton(sortButton, imageName: "arrow.up.arrow.down")
|
|
configureMenuButton(typeButton, imageName: "line.3.horizontal.decrease")
|
|
displayModeButton.tintColor = AppColor.textSecondary
|
|
displayModeButton.backgroundColor = AppColor.inputBackground
|
|
displayModeButton.layer.cornerRadius = AppRadius.xs
|
|
displayModeButton.addTarget(self, action: #selector(displayModeTapped), for: .touchUpInside)
|
|
|
|
filterBar.axis = .horizontal
|
|
filterBar.spacing = AppSpacing.sm
|
|
filterBar.distribution = .fill
|
|
filterBar.addArrangedSubview(sortButton)
|
|
filterBar.addArrangedSubview(typeButton)
|
|
filterBar.addArrangedSubview(displayModeButton)
|
|
|
|
collectionView = UICollectionView(frame: .zero, collectionViewLayout: makeLayout())
|
|
collectionView.backgroundColor = AppColor.pageBackground
|
|
collectionView.register(CloudPickCell.self, forCellWithReuseIdentifier: CloudPickCell.reuseIdentifier)
|
|
collectionView.delegate = self
|
|
collectionView.refreshControl = refreshControl
|
|
refreshControl.addTarget(self, action: #selector(refreshTriggered), for: .valueChanged)
|
|
|
|
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,
|
|
isGridMode: self?.isGridMode == true
|
|
)
|
|
}
|
|
return cell
|
|
}
|
|
|
|
bottomBar.backgroundColor = .white
|
|
view.addSubview(searchField)
|
|
view.addSubview(pathScrollView)
|
|
view.addSubview(filterBar)
|
|
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)
|
|
}
|
|
filterBar.snp.makeConstraints { make in
|
|
make.top.equalTo(pathScrollView.snp.bottom).offset(AppSpacing.sm)
|
|
make.leading.trailing.equalToSuperview().inset(AppSpacing.md)
|
|
make.height.equalTo(36)
|
|
}
|
|
sortButton.snp.makeConstraints { make in make.width.equalTo(typeButton) }
|
|
displayModeButton.snp.makeConstraints { make in make.width.equalTo(36) }
|
|
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(filterBar.snp.bottom).offset(AppSpacing.md)
|
|
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()
|
|
rebuildFilterMenus()
|
|
var snapshot = NSDiffableDataSourceSnapshot<Int, Int>()
|
|
snapshot.appendSections([0])
|
|
snapshot.appendItems(viewModel.files.map(\.id))
|
|
let existingItems = Set(dataSource.snapshot().itemIdentifiers)
|
|
let itemsToReconfigure = snapshot.itemIdentifiers.filter(existingItems.contains)
|
|
snapshot.reconfigureItems(itemsToReconfigure)
|
|
dataSource.apply(snapshot, animatingDifferences: false)
|
|
let selectedCount = viewModel.selectedFileList.count
|
|
confirmButton.setTitle(selectedCount > 0 ? "确认选择(\(selectedCount))" : "确认选择", for: .normal)
|
|
if viewModel.isLoading && viewModel.files.isEmpty {
|
|
showLoading()
|
|
} else {
|
|
hideLoading()
|
|
}
|
|
if !viewModel.isRefreshing {
|
|
refreshControl.endRefreshing()
|
|
}
|
|
}
|
|
|
|
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() {
|
|
guard !viewModel.selectedFileList.isEmpty else {
|
|
showToast("请至少选择一个文件")
|
|
return
|
|
}
|
|
onConfirmed?(viewModel.selectedFileList)
|
|
navigationController?.popViewController(animated: true)
|
|
}
|
|
|
|
@objc private func refreshTriggered() {
|
|
Task { await viewModel.refresh(api: taskAPI) }
|
|
}
|
|
|
|
@objc private func displayModeTapped() {
|
|
isGridMode.toggle()
|
|
collectionView.setCollectionViewLayout(makeLayout(), animated: true)
|
|
applyViewModel()
|
|
}
|
|
|
|
private func configureMenuButton(_ button: UIButton, imageName: String) {
|
|
button.tintColor = AppColor.textSecondary
|
|
button.backgroundColor = AppColor.inputBackground
|
|
button.layer.cornerRadius = AppRadius.xs
|
|
button.contentHorizontalAlignment = .leading
|
|
button.titleLabel?.font = .app(.body)
|
|
button.setTitleColor(AppColor.textPrimary, for: .normal)
|
|
button.setImage(UIImage(systemName: imageName), for: .normal)
|
|
button.semanticContentAttribute = .forceRightToLeft
|
|
button.showsMenuAsPrimaryAction = true
|
|
}
|
|
|
|
private func rebuildFilterMenus() {
|
|
let sortItems = [(1, "创建时间顺序"), (2, "创建时间倒序")]
|
|
sortButton.setTitle(sortItems.first(where: { $0.0 == viewModel.sortType })?.1 ?? "排序", for: .normal)
|
|
sortButton.menu = UIMenu(children: sortItems.map { value, title in
|
|
UIAction(title: title, state: value == viewModel.sortType ? .on : .off) { [weak self] _ in
|
|
guard let self else { return }
|
|
self.viewModel.updateSortType(value)
|
|
Task { await self.viewModel.refresh(api: self.taskAPI) }
|
|
}
|
|
})
|
|
|
|
let typeItems = [(0, "全部"), (2, "图片"), (1, "视频"), (99, "文件夹")]
|
|
typeButton.setTitle(typeItems.first(where: { $0.0 == viewModel.filterType })?.1 ?? "全部", for: .normal)
|
|
typeButton.menu = UIMenu(children: typeItems.map { value, title in
|
|
UIAction(title: title, state: value == viewModel.filterType ? .on : .off) { [weak self] _ in
|
|
guard let self else { return }
|
|
self.viewModel.updateFilterType(value)
|
|
Task { await self.viewModel.refresh(api: self.taskAPI) }
|
|
}
|
|
})
|
|
displayModeButton.setImage(
|
|
UIImage(systemName: isGridMode ? "list.bullet" : "square.grid.2x2"),
|
|
for: .normal
|
|
)
|
|
displayModeButton.accessibilityLabel = isGridMode ? "列表显示" : "宫格显示"
|
|
}
|
|
|
|
private func makeLayout() -> UICollectionViewCompositionalLayout {
|
|
let isGridMode = isGridMode
|
|
return UICollectionViewCompositionalLayout { _, environment in
|
|
let columns = isGridMode ? 2 : 1
|
|
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 itemHeight = isGridMode ? itemWidth + 38 : 72
|
|
let itemSize = NSCollectionLayoutSize(
|
|
widthDimension: .absolute(itemWidth),
|
|
heightDimension: .absolute(itemHeight)
|
|
)
|
|
let item = NSCollectionLayoutItem(layoutSize: itemSize)
|
|
let groupSize = NSCollectionLayoutSize(
|
|
widthDimension: .fractionalWidth(1),
|
|
heightDimension: .absolute(itemHeight)
|
|
)
|
|
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 selectionIconView = UIImageView(image: UIImage(systemName: "checkmark.circle.fill"))
|
|
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
|
|
|
|
selectionIconView.tintColor = AppColor.primary
|
|
selectionIconView.contentMode = .scaleAspectFit
|
|
selectionIconView.isHidden = true
|
|
|
|
contentView.addSubview(imageView)
|
|
contentView.addSubview(folderIconView)
|
|
contentView.addSubview(nameLabel)
|
|
contentView.addSubview(selectionIconView)
|
|
|
|
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)
|
|
}
|
|
selectionIconView.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
|
|
selectionIconView.isHidden = true
|
|
contentView.alpha = 1
|
|
}
|
|
|
|
func apply(file: CloudFile, isSelected: Bool, isImported: Bool, isGridMode: Bool) {
|
|
nameLabel.text = file.name
|
|
contentView.layer.borderWidth = isSelected ? 2 : 0
|
|
contentView.layer.borderColor = AppColor.primary.cgColor
|
|
selectionIconView.isHidden = !isSelected
|
|
|
|
if isGridMode {
|
|
imageView.snp.remakeConstraints { make in
|
|
make.top.leading.trailing.equalToSuperview()
|
|
make.height.equalTo(imageView.snp.width)
|
|
}
|
|
nameLabel.textAlignment = .center
|
|
nameLabel.numberOfLines = 2
|
|
nameLabel.snp.remakeConstraints { make in
|
|
make.top.equalTo(imageView.snp.bottom).offset(AppSpacing.xxs)
|
|
make.leading.trailing.bottom.equalToSuperview().inset(AppSpacing.xxs)
|
|
}
|
|
} else {
|
|
imageView.snp.remakeConstraints { make in
|
|
make.top.bottom.leading.equalToSuperview()
|
|
make.width.equalTo(imageView.snp.height)
|
|
}
|
|
nameLabel.textAlignment = .left
|
|
nameLabel.numberOfLines = 2
|
|
nameLabel.snp.remakeConstraints { make in
|
|
make.leading.equalTo(imageView.snp.trailing).offset(AppSpacing.sm)
|
|
make.trailing.equalToSuperview().inset(AppSpacing.sm)
|
|
make.centerY.equalToSuperview()
|
|
}
|
|
}
|
|
|
|
if file.isFolder {
|
|
contentView.alpha = 1
|
|
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
|
|
}
|
|
}
|
|
}
|