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:
@ -185,8 +185,8 @@ final class HomeViewController: BaseViewController {
|
||||
)
|
||||
}
|
||||
cell.cardView.onSubmitTask = { [weak self] in
|
||||
self?.showToast("功能开发中")
|
||||
}
|
||||
self?.navigationController?.pushViewController(TaskAddViewController(), animated: true)
|
||||
}
|
||||
cell.cardView.onToggleOnline = { [weak self] in
|
||||
self?.viewModel.showOnlineStatusSwitchDialog()
|
||||
}
|
||||
|
||||
195
suixinkan/UI/Profile/ProfileEditViewController.swift
Normal file
195
suixinkan/UI/Profile/ProfileEditViewController.swift
Normal file
@ -0,0 +1,195 @@
|
||||
//
|
||||
// ProfileEditViewController.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import PhotosUI
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
/// 资料编辑页,支持修改头像与昵称并保存到服务端。
|
||||
final class ProfileEditViewController: BaseViewController {
|
||||
|
||||
private let viewModel: ProfileEditViewModel
|
||||
private let profileAPI = NetworkServices.shared.profileAPI
|
||||
private let ossUploadService = NetworkServices.shared.ossUploadService
|
||||
|
||||
private let avatarContainer = UIView()
|
||||
private let avatarImageView = UIImageView()
|
||||
private let avatarHintLabel = UILabel()
|
||||
private let nicknameCard = UIView()
|
||||
private let nicknameTitleLabel = UILabel()
|
||||
private let nicknameField = UITextField()
|
||||
private let saveButton = AppButton(title: "保存", style: .primary)
|
||||
|
||||
/// 使用「我的」页当前展示的资料快照进入编辑。
|
||||
init(nickname: String, avatarURL: String) {
|
||||
viewModel = ProfileEditViewModel(nickname: nickname, avatarURL: avatarURL)
|
||||
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() {
|
||||
view.backgroundColor = AppColor.pageBackgroundSoft
|
||||
|
||||
avatarImageView.layer.cornerRadius = 48
|
||||
avatarImageView.clipsToBounds = true
|
||||
avatarImageView.contentMode = .scaleAspectFill
|
||||
avatarImageView.isUserInteractionEnabled = true
|
||||
|
||||
avatarHintLabel.text = "点击更换头像"
|
||||
avatarHintLabel.font = .systemFont(ofSize: 14)
|
||||
avatarHintLabel.textColor = AppColor.textTertiary
|
||||
avatarHintLabel.textAlignment = .center
|
||||
|
||||
avatarContainer.addSubview(avatarImageView)
|
||||
avatarContainer.addSubview(avatarHintLabel)
|
||||
view.addSubview(avatarContainer)
|
||||
|
||||
nicknameCard.backgroundColor = .white
|
||||
nicknameCard.layer.cornerRadius = AppRadius.sm
|
||||
view.addSubview(nicknameCard)
|
||||
|
||||
nicknameTitleLabel.text = "昵称"
|
||||
nicknameTitleLabel.font = .systemFont(ofSize: 14)
|
||||
nicknameTitleLabel.textColor = AppColor.textTertiary
|
||||
|
||||
nicknameField.font = .systemFont(ofSize: 16)
|
||||
nicknameField.textColor = AppColor.text333
|
||||
nicknameField.placeholder = "请输入昵称"
|
||||
nicknameField.clearButtonMode = .whileEditing
|
||||
nicknameField.returnKeyType = .done
|
||||
|
||||
nicknameCard.addSubview(nicknameTitleLabel)
|
||||
nicknameCard.addSubview(nicknameField)
|
||||
|
||||
view.addSubview(saveButton)
|
||||
}
|
||||
|
||||
override func setupConstraints() {
|
||||
avatarContainer.snp.makeConstraints { make in
|
||||
make.top.equalTo(view.safeAreaLayoutGuide).offset(32)
|
||||
make.centerX.equalToSuperview()
|
||||
}
|
||||
avatarImageView.snp.makeConstraints { make in
|
||||
make.top.centerX.equalToSuperview()
|
||||
make.width.height.equalTo(96)
|
||||
}
|
||||
avatarHintLabel.snp.makeConstraints { make in
|
||||
make.top.equalTo(avatarImageView.snp.bottom).offset(12)
|
||||
make.leading.trailing.bottom.equalToSuperview()
|
||||
}
|
||||
|
||||
nicknameCard.snp.makeConstraints { make in
|
||||
make.top.equalTo(avatarContainer.snp.bottom).offset(32)
|
||||
make.leading.trailing.equalToSuperview().inset(AppSpacing.screenHorizontalInset)
|
||||
}
|
||||
nicknameTitleLabel.snp.makeConstraints { make in
|
||||
make.top.leading.equalToSuperview().inset(16)
|
||||
}
|
||||
nicknameField.snp.makeConstraints { make in
|
||||
make.top.equalTo(nicknameTitleLabel.snp.bottom).offset(8)
|
||||
make.leading.trailing.bottom.equalToSuperview().inset(16)
|
||||
make.height.equalTo(44)
|
||||
}
|
||||
|
||||
saveButton.snp.makeConstraints { make in
|
||||
make.leading.trailing.equalToSuperview().inset(AppSpacing.screenHorizontalInset)
|
||||
make.bottom.equalTo(view.safeAreaLayoutGuide).inset(16)
|
||||
make.height.equalTo(48)
|
||||
}
|
||||
}
|
||||
|
||||
override func bindActions() {
|
||||
viewModel.onStateChange = { [weak self] in
|
||||
Task { @MainActor in self?.applyViewModel() }
|
||||
}
|
||||
|
||||
avatarImageView.addGestureRecognizer(
|
||||
UITapGestureRecognizer(target: self, action: #selector(pickAvatar))
|
||||
)
|
||||
nicknameField.addTarget(self, action: #selector(nicknameChanged), for: .editingChanged)
|
||||
saveButton.addTarget(self, action: #selector(saveTapped), for: .touchUpInside)
|
||||
|
||||
applyViewModel()
|
||||
}
|
||||
|
||||
private func applyViewModel() {
|
||||
nicknameField.text = viewModel.editingNickname
|
||||
|
||||
if let pendingData = viewModel.pendingAvatarData, let image = UIImage(data: pendingData) {
|
||||
avatarImageView.image = image
|
||||
} else {
|
||||
avatarImageView.loadRemoteImage(urlString: viewModel.displayAvatarURL)
|
||||
}
|
||||
|
||||
saveButton.isEnabled = !viewModel.isSaving
|
||||
avatarImageView.alpha = viewModel.isSaving ? 0.6 : 1
|
||||
nicknameField.isEnabled = !viewModel.isSaving
|
||||
}
|
||||
|
||||
@objc private func nicknameChanged() {
|
||||
viewModel.updateEditingNickname(nicknameField.text ?? "")
|
||||
}
|
||||
|
||||
@objc private func pickAvatar() {
|
||||
guard !viewModel.isSaving else { return }
|
||||
var configuration = PHPickerConfiguration(photoLibrary: .shared())
|
||||
configuration.filter = .images
|
||||
configuration.selectionLimit = 1
|
||||
let picker = PHPickerViewController(configuration: configuration)
|
||||
picker.delegate = self
|
||||
present(picker, animated: true)
|
||||
}
|
||||
|
||||
@objc private func saveTapped() {
|
||||
nicknameField.resignFirstResponder()
|
||||
Task { await saveProfile() }
|
||||
}
|
||||
|
||||
private func saveProfile() async {
|
||||
let scenicId = AppStore.shared.currentScenicId
|
||||
guard scenicId > 0 else {
|
||||
showToast("当前景区信息缺失")
|
||||
return
|
||||
}
|
||||
showLoading()
|
||||
defer { hideLoading() }
|
||||
do {
|
||||
try await viewModel.saveProfile(
|
||||
api: profileAPI,
|
||||
uploader: ossUploadService,
|
||||
scenicId: scenicId
|
||||
)
|
||||
showToast("保存成功")
|
||||
navigationController?.popViewController(animated: true)
|
||||
} catch {
|
||||
showToast(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension ProfileEditViewController: PHPickerViewControllerDelegate {
|
||||
func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
|
||||
picker.dismiss(animated: true)
|
||||
guard let provider = results.first?.itemProvider, provider.canLoadObject(ofClass: UIImage.self) else { return }
|
||||
provider.loadObject(ofClass: UIImage.self) { [weak self] object, _ in
|
||||
guard let self, let image = object as? UIImage, let data = image.jpegData(compressionQuality: 0.9) else { return }
|
||||
Task { @MainActor in
|
||||
do {
|
||||
try self.viewModel.prepareAvatarImage(data: data)
|
||||
} catch {
|
||||
self.showToast(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -3,7 +3,6 @@
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import PhotosUI
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
@ -12,7 +11,6 @@ final class ProfileViewController: BaseViewController {
|
||||
|
||||
private let viewModel = ProfileViewModel()
|
||||
private let profileAPI = NetworkServices.shared.profileAPI
|
||||
private let ossUploadService = NetworkServices.shared.ossUploadService
|
||||
|
||||
private let scrollView = UIScrollView()
|
||||
private let contentStack = UIStackView()
|
||||
@ -35,7 +33,7 @@ final class ProfileViewController: BaseViewController {
|
||||
private let logoutButton = UIButton(type: .system)
|
||||
|
||||
override func setupNavigationBar() {
|
||||
title = "个人信息"
|
||||
title = "我的"
|
||||
}
|
||||
|
||||
override func setupUI() {
|
||||
@ -87,18 +85,18 @@ final class ProfileViewController: BaseViewController {
|
||||
withdrawalRow.addTarget(self, action: #selector(withdrawalTapped), for: .touchUpInside)
|
||||
realNameRow.addTarget(self, action: #selector(realNameTapped), for: .touchUpInside)
|
||||
|
||||
nicknameField.addTarget(self, action: #selector(nicknameChanged), for: .editingChanged)
|
||||
|
||||
let avatarTap = UITapGestureRecognizer(target: self, action: #selector(pickAvatar))
|
||||
avatarImageView.isUserInteractionEnabled = true
|
||||
avatarImageView.addGestureRecognizer(avatarTap)
|
||||
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(handleAccountDidSwitch),
|
||||
name: NotificationName.accountDidSwitch,
|
||||
object: nil
|
||||
)
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(handleProfileDidUpdate),
|
||||
name: NotificationName.userProfileDidUpdate,
|
||||
object: nil
|
||||
)
|
||||
}
|
||||
|
||||
override func viewWillAppear(_ animated: Bool) {
|
||||
@ -191,19 +189,9 @@ final class ProfileViewController: BaseViewController {
|
||||
}
|
||||
|
||||
private func applyViewModel() {
|
||||
nicknameField.text = viewModel.isEditingProfile ? viewModel.editingNickname : viewModel.displayNickname
|
||||
nicknameField.isEnabled = viewModel.isEditingProfile
|
||||
nicknameField.text = viewModel.displayNickname
|
||||
uidLabel.text = "UID:\(viewModel.displayUID)"
|
||||
|
||||
if let pendingData = viewModel.pendingAvatarData, let image = UIImage(data: pendingData) {
|
||||
avatarImageView.image = image
|
||||
} else {
|
||||
avatarImageView.loadRemoteImage(urlString: viewModel.displayAvatarURL)
|
||||
}
|
||||
|
||||
editButton.setTitle(viewModel.isEditingProfile ? "完成" : "编辑", for: .normal)
|
||||
editButton.isEnabled = !viewModel.isSaving
|
||||
avatarImageView.alpha = viewModel.isEditingProfile ? 1 : 1
|
||||
avatarImageView.loadRemoteImage(urlString: viewModel.displayAvatarURL)
|
||||
|
||||
nameRow.configure(value: viewModel.displayRealName)
|
||||
accountRow.configure(
|
||||
@ -289,26 +277,17 @@ final class ProfileViewController: BaseViewController {
|
||||
Task { await reloadProfile(showGlobalLoading: true) }
|
||||
}
|
||||
|
||||
@objc private func nicknameChanged() {
|
||||
viewModel.updateEditingNickname(nicknameField.text ?? "")
|
||||
@objc private func handleProfileDidUpdate() {
|
||||
viewModel.applyLocalProfileUpdate()
|
||||
applyViewModel()
|
||||
}
|
||||
|
||||
@objc private func editTapped() {
|
||||
if viewModel.isEditingProfile {
|
||||
Task { await saveProfile() }
|
||||
} else {
|
||||
viewModel.beginEditing()
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func pickAvatar() {
|
||||
guard viewModel.isEditingProfile else { return }
|
||||
var configuration = PHPickerConfiguration(photoLibrary: .shared())
|
||||
configuration.filter = .images
|
||||
configuration.selectionLimit = 1
|
||||
let picker = PHPickerViewController(configuration: configuration)
|
||||
picker.delegate = self
|
||||
present(picker, animated: true)
|
||||
let editVC = ProfileEditViewController(
|
||||
nickname: viewModel.displayNickname,
|
||||
avatarURL: viewModel.displayAvatarURL
|
||||
)
|
||||
navigationController?.pushViewController(editVC, animated: true)
|
||||
}
|
||||
|
||||
@objc private func accountSwitchTapped() {
|
||||
@ -382,26 +361,6 @@ final class ProfileViewController: BaseViewController {
|
||||
}
|
||||
}
|
||||
|
||||
private func saveProfile() async {
|
||||
let scenicId = AppStore.shared.currentScenicId
|
||||
guard scenicId > 0 else {
|
||||
showToast("当前景区信息缺失")
|
||||
return
|
||||
}
|
||||
showLoading()
|
||||
defer { hideLoading() }
|
||||
do {
|
||||
try await viewModel.saveProfile(
|
||||
api: profileAPI,
|
||||
uploader: ossUploadService,
|
||||
scenicId: scenicId
|
||||
)
|
||||
showToast("保存成功")
|
||||
} catch {
|
||||
showToast(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
private func updatePassword(_ password: String) async {
|
||||
showLoading()
|
||||
defer { hideLoading() }
|
||||
@ -413,20 +372,3 @@ final class ProfileViewController: BaseViewController {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension ProfileViewController: PHPickerViewControllerDelegate {
|
||||
func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
|
||||
picker.dismiss(animated: true)
|
||||
guard let provider = results.first?.itemProvider, provider.canLoadObject(ofClass: UIImage.self) else { return }
|
||||
provider.loadObject(ofClass: UIImage.self) { [weak self] object, _ in
|
||||
guard let self, let image = object as? UIImage, let data = image.jpegData(compressionQuality: 0.9) else { return }
|
||||
Task { @MainActor in
|
||||
do {
|
||||
try self.viewModel.prepareAvatarImage(data: data)
|
||||
} catch {
|
||||
self.showToast(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
323
suixinkan/UI/Task/CloudStoragePickForTaskViewController.swift
Normal file
323
suixinkan/UI/Task/CloudStoragePickForTaskViewController.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
366
suixinkan/UI/Task/TaskAddViewController.swift
Normal file
366
suixinkan/UI/Task/TaskAddViewController.swift
Normal file
@ -0,0 +1,366 @@
|
||||
//
|
||||
// TaskAddViewController.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import PhotosUI
|
||||
import SnapKit
|
||||
import UIKit
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
/// 添加任务页,对齐 Android `TaskAddScreen`。
|
||||
final class TaskAddViewController: BaseViewController {
|
||||
|
||||
private let viewModel = TaskAddViewModel()
|
||||
private let taskAPI = NetworkServices.shared.taskAPI
|
||||
private let ossUploadService = NetworkServices.shared.ossUploadService
|
||||
|
||||
private let scrollView = UIScrollView()
|
||||
private let contentStack = UIStackView()
|
||||
private let mediaSection = TaskAddMediaSectionView()
|
||||
private let prioritySection = TaskPrioritySectionView()
|
||||
private let orderButton = TaskOrderSelectButton()
|
||||
private let nameField = TaskBorderTextField(placeholder: "请输入任务标题")
|
||||
private let detailsField = TaskBorderTextView(placeholder: "请输入任务描述(必填)")
|
||||
private let saveButton = AppButton(title: "保存")
|
||||
private let bottomBar = UIView()
|
||||
|
||||
private var remarkDialog: TaskFileRemarkDialogView?
|
||||
private var pickingMediaType: Int = 2
|
||||
private var detailsPlaceholder = "请输入任务描述(必填)"
|
||||
|
||||
override func setupNavigationBar() {
|
||||
title = "添加任务"
|
||||
}
|
||||
|
||||
override func setupUI() {
|
||||
contentStack.axis = .vertical
|
||||
contentStack.spacing = AppSpacing.sm
|
||||
|
||||
bottomBar.backgroundColor = .white
|
||||
view.addSubview(scrollView)
|
||||
view.addSubview(bottomBar)
|
||||
scrollView.addSubview(contentStack)
|
||||
|
||||
contentStack.addArrangedSubview(mediaSection)
|
||||
contentStack.addArrangedSubview(prioritySection)
|
||||
contentStack.addArrangedSubview(orderButton)
|
||||
contentStack.addArrangedSubview(nameField)
|
||||
contentStack.addArrangedSubview(detailsField)
|
||||
bottomBar.addSubview(saveButton)
|
||||
}
|
||||
|
||||
override func setupConstraints() {
|
||||
bottomBar.snp.makeConstraints { make in
|
||||
make.leading.trailing.bottom.equalToSuperview()
|
||||
}
|
||||
saveButton.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview().inset(AppSpacing.md)
|
||||
}
|
||||
scrollView.snp.makeConstraints { make in
|
||||
make.top.leading.trailing.equalTo(view.safeAreaLayoutGuide)
|
||||
make.bottom.equalTo(bottomBar.snp.top)
|
||||
}
|
||||
contentStack.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview().inset(AppSpacing.md)
|
||||
make.width.equalTo(scrollView.snp.width).offset(-AppSpacing.md * 2)
|
||||
}
|
||||
}
|
||||
|
||||
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) }
|
||||
}
|
||||
|
||||
mediaSection.onCloudImport = { [weak self] in self?.openCloudPicker() }
|
||||
mediaSection.onLocalImport = { [weak self] in self?.presentLocalImportSheet() }
|
||||
mediaSection.onFileTap = { [weak self] file in self?.viewModel.presentRemarkDialog(for: file.id) }
|
||||
mediaSection.onDeleteFile = { [weak self] file in self?.confirmDelete(file: file) }
|
||||
|
||||
prioritySection.onUrgentHourChange = { [weak self] hour in
|
||||
self?.viewModel.setUrgentHour(hour)
|
||||
}
|
||||
prioritySection.onCustomHourTextChange = { [weak self] text in
|
||||
self?.viewModel.updateCustomUrgentHourText(text)
|
||||
}
|
||||
|
||||
orderButton.addTarget(self, action: #selector(openOrderSelect), for: .touchUpInside)
|
||||
nameField.textField.addTarget(self, action: #selector(nameChanged), for: .editingChanged)
|
||||
detailsField.textView.delegate = self
|
||||
saveButton.addTarget(self, action: #selector(saveTapped), for: .touchUpInside)
|
||||
}
|
||||
|
||||
override func viewDidAppear(_ animated: Bool) {
|
||||
super.viewDidAppear(animated)
|
||||
if let fileID = viewModel.consumePendingRemarkFileID() {
|
||||
viewModel.presentRemarkDialog(for: fileID)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func applyViewModel() {
|
||||
mediaSection.apply(files: viewModel.selectedFiles)
|
||||
prioritySection.apply(urgentHour: viewModel.urgentHour)
|
||||
orderButton.apply(orderNumber: viewModel.selectedOrderNumber)
|
||||
if nameField.textField.text != viewModel.taskName {
|
||||
nameField.textField.text = viewModel.taskName
|
||||
}
|
||||
if detailsField.textView.textColor != AppColor.textTertiary,
|
||||
detailsField.textView.text != viewModel.taskDetails {
|
||||
detailsField.textView.text = viewModel.taskDetails
|
||||
}
|
||||
saveButton.isEnabled = !viewModel.isUploading
|
||||
|
||||
if viewModel.showRemarkDialog, let file = viewModel.remarkTargetFile {
|
||||
showRemarkDialog(file: file)
|
||||
} else {
|
||||
hideRemarkDialog()
|
||||
}
|
||||
|
||||
if viewModel.showSuccessDialog {
|
||||
presentSuccessDialog()
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func nameChanged() {
|
||||
viewModel.updateTaskName(nameField.textField.text ?? "")
|
||||
}
|
||||
|
||||
@objc private func openOrderSelect() {
|
||||
let controller = TaskOrderSelectViewController(initialOrderNumber: viewModel.selectedOrderNumber)
|
||||
controller.onConfirmed = { [weak self] orderNumber in
|
||||
self?.viewModel.setSelectedOrderNumber(orderNumber)
|
||||
}
|
||||
navigationController?.pushViewController(controller, animated: true)
|
||||
}
|
||||
|
||||
private func openCloudPicker() {
|
||||
let controller = CloudStoragePickForTaskViewController(
|
||||
importedFileIDs: viewModel.importedCloudFileIDs
|
||||
)
|
||||
controller.onConfirmed = { [weak self] files in
|
||||
self?.viewModel.addCloudFiles(files)
|
||||
if let fileID = self?.viewModel.consumePendingRemarkFileID() {
|
||||
self?.viewModel.presentRemarkDialog(for: fileID)
|
||||
}
|
||||
}
|
||||
navigationController?.pushViewController(controller, animated: true)
|
||||
}
|
||||
|
||||
private func presentLocalImportSheet() {
|
||||
let sheet = UIAlertController(title: nil, message: nil, preferredStyle: .actionSheet)
|
||||
sheet.addAction(UIAlertAction(title: "图片", style: .default) { [weak self] _ in
|
||||
self?.presentMediaPicker(isImage: true)
|
||||
})
|
||||
sheet.addAction(UIAlertAction(title: "视频", style: .default) { [weak self] _ in
|
||||
self?.presentMediaPicker(isImage: false)
|
||||
})
|
||||
sheet.addAction(UIAlertAction(title: "取消", style: .cancel))
|
||||
present(sheet, animated: true)
|
||||
}
|
||||
|
||||
private func presentMediaPicker(isImage: Bool) {
|
||||
pickingMediaType = isImage ? 2 : 1
|
||||
var configuration = PHPickerConfiguration(photoLibrary: .shared())
|
||||
configuration.filter = isImage ? .images : .videos
|
||||
configuration.selectionLimit = 50
|
||||
let picker = PHPickerViewController(configuration: configuration)
|
||||
picker.delegate = self
|
||||
present(picker, animated: true)
|
||||
}
|
||||
|
||||
private func confirmDelete(file: TaskFileItem) {
|
||||
let alert = UIAlertController(title: "删除确认", message: "确定要删除此文件吗?", preferredStyle: .alert)
|
||||
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
|
||||
alert.addAction(UIAlertAction(title: "确定", style: .default) { [weak self] _ in
|
||||
self?.viewModel.removeTaskFile(id: file.id)
|
||||
})
|
||||
present(alert, animated: true)
|
||||
}
|
||||
|
||||
@objc private func saveTapped() {
|
||||
showLoading()
|
||||
Task {
|
||||
await viewModel.validateAndSubmit(api: taskAPI)
|
||||
hideLoading()
|
||||
}
|
||||
}
|
||||
|
||||
private func presentSuccessDialog() {
|
||||
guard viewModel.showSuccessDialog else { return }
|
||||
let alert = UIAlertController(
|
||||
title: "添加成功",
|
||||
message: "任务已成功添加。是否继续添加新任务?",
|
||||
preferredStyle: .alert
|
||||
)
|
||||
alert.addAction(UIAlertAction(title: "继续添加", style: .default) { [weak self] _ in
|
||||
self?.viewModel.handleContinueAdding()
|
||||
})
|
||||
alert.addAction(UIAlertAction(title: "返回", style: .cancel) { [weak self] _ in
|
||||
self?.viewModel.dismissSuccessDialog()
|
||||
self?.navigationController?.popViewController(animated: true)
|
||||
})
|
||||
present(alert, animated: true)
|
||||
viewModel.dismissSuccessDialog()
|
||||
}
|
||||
|
||||
private func showRemarkDialog(file: TaskFileItem) {
|
||||
if remarkDialog == nil {
|
||||
let dialog = TaskFileRemarkDialogView(frame: view.bounds)
|
||||
dialog.onCancel = { [weak self] in
|
||||
self?.viewModel.hideRemarkDialog()
|
||||
}
|
||||
dialog.onConfirm = { [weak self] in
|
||||
guard let self, let dialog = self.remarkDialog else { return }
|
||||
self.viewModel.updateRemarkDialogText(dialog.inputText)
|
||||
self.viewModel.saveRemarkDialog()
|
||||
}
|
||||
remarkDialog = dialog
|
||||
view.addSubview(dialog)
|
||||
dialog.snp.makeConstraints { make in make.edges.equalToSuperview() }
|
||||
}
|
||||
remarkDialog?.apply(file: file, text: viewModel.remarkDialogText)
|
||||
}
|
||||
|
||||
private func hideRemarkDialog() {
|
||||
remarkDialog?.removeFromSuperview()
|
||||
remarkDialog = nil
|
||||
}
|
||||
|
||||
private func uploadPickedResults(_ results: [PHPickerResult], isImage: Bool) {
|
||||
let scenicId = AppStore.shared.currentScenicId
|
||||
guard scenicId > 0 else {
|
||||
showToast("请先选择景区")
|
||||
return
|
||||
}
|
||||
let totalCount = results.count
|
||||
for (index, result) in results.enumerated() {
|
||||
let uploadTaskId = UUID().uuidString.replacingOccurrences(of: "-", with: "")
|
||||
let placeholder = viewModel.addUploadPlaceholder(
|
||||
fileName: "upload_\(uploadTaskId).\(isImage ? "jpg" : "mp4")",
|
||||
fileType: isImage ? 2 : 1,
|
||||
uploadTaskId: uploadTaskId
|
||||
)
|
||||
Task {
|
||||
do {
|
||||
let payload = try await TaskMediaLoader.load(from: result, isImage: isImage)
|
||||
let fileURL = try await ossUploadService.uploadTaskFile(
|
||||
data: payload.data,
|
||||
fileName: payload.fileName,
|
||||
fileType: isImage ? 2 : 1,
|
||||
scenicId: scenicId
|
||||
) { [weak self] progress in
|
||||
Task { @MainActor in
|
||||
self?.viewModel.updateUploadProgress(uploadTaskId: uploadTaskId, progress: progress)
|
||||
}
|
||||
}
|
||||
try await taskAPI.registerUploadedFileURL(scenicId: scenicId, fileURL: fileURL, folderId: 0)
|
||||
await MainActor.run {
|
||||
self.viewModel.markUploadSucceeded(
|
||||
uploadTaskId: uploadTaskId,
|
||||
fileURL: fileURL,
|
||||
autoRemarkWhenSingle: totalCount == 1
|
||||
)
|
||||
if totalCount == 1, let fileID = self.viewModel.consumePendingRemarkFileID() {
|
||||
self.viewModel.presentRemarkDialog(for: fileID)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
self.viewModel.markUploadFailed(uploadTaskId: uploadTaskId)
|
||||
self.showToast(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
_ = placeholder
|
||||
_ = index
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension TaskAddViewController: PHPickerViewControllerDelegate {
|
||||
func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
|
||||
picker.dismiss(animated: true)
|
||||
guard !results.isEmpty else { return }
|
||||
uploadPickedResults(results, isImage: pickingMediaType == 2)
|
||||
}
|
||||
}
|
||||
|
||||
extension TaskAddViewController: UITextViewDelegate {
|
||||
func textViewDidBeginEditing(_ textView: UITextView) {
|
||||
if textView.textColor == AppColor.textTertiary {
|
||||
textView.text = ""
|
||||
textView.textColor = AppColor.textPrimary
|
||||
}
|
||||
}
|
||||
|
||||
func textViewDidEndEditing(_ textView: UITextView) {
|
||||
if textView.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
textView.text = detailsPlaceholder
|
||||
textView.textColor = AppColor.textTertiary
|
||||
viewModel.updateTaskDetails("")
|
||||
}
|
||||
}
|
||||
|
||||
func textViewDidChange(_ textView: UITextView) {
|
||||
guard textView.textColor != AppColor.textTertiary else { return }
|
||||
viewModel.updateTaskDetails(textView.text)
|
||||
}
|
||||
}
|
||||
|
||||
/// 从 PHPicker 结果加载上传数据。
|
||||
enum TaskMediaLoader {
|
||||
struct Payload {
|
||||
let data: Data
|
||||
let fileName: String
|
||||
}
|
||||
|
||||
static func load(from result: PHPickerResult, isImage: Bool) async throws -> Payload {
|
||||
let provider = result.itemProvider
|
||||
if isImage {
|
||||
return try await loadImage(from: provider)
|
||||
}
|
||||
return try await loadVideo(from: provider)
|
||||
}
|
||||
|
||||
private static func loadImage(from provider: NSItemProvider) async throws -> Payload {
|
||||
try await withCheckedThrowingContinuation { continuation in
|
||||
if provider.canLoadObject(ofClass: UIImage.self) {
|
||||
provider.loadObject(ofClass: UIImage.self) { object, error in
|
||||
if let error {
|
||||
continuation.resume(throwing: error)
|
||||
return
|
||||
}
|
||||
guard let image = object as? UIImage, let data = image.jpegData(compressionQuality: 0.9) else {
|
||||
continuation.resume(throwing: OSSUploadError.emptyFile)
|
||||
return
|
||||
}
|
||||
continuation.resume(returning: Payload(data: data, fileName: "image_\(UUID().uuidString).jpg"))
|
||||
}
|
||||
return
|
||||
}
|
||||
continuation.resume(throwing: OSSUploadError.unsupportedFileType)
|
||||
}
|
||||
}
|
||||
|
||||
private static func loadVideo(from provider: NSItemProvider) async throws -> Payload {
|
||||
let typeIdentifier = UTType.movie.identifier
|
||||
return try await withCheckedThrowingContinuation { continuation in
|
||||
provider.loadFileRepresentation(forTypeIdentifier: typeIdentifier) { url, error in
|
||||
if let error {
|
||||
continuation.resume(throwing: error)
|
||||
return
|
||||
}
|
||||
guard let url, let data = try? Data(contentsOf: url) else {
|
||||
continuation.resume(throwing: OSSUploadError.emptyFile)
|
||||
return
|
||||
}
|
||||
let ext = url.pathExtension.isEmpty ? "mp4" : url.pathExtension
|
||||
continuation.resume(returning: Payload(data: data, fileName: "video_\(UUID().uuidString).\(ext)"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
225
suixinkan/UI/Task/TaskOrderSelectViewController.swift
Normal file
225
suixinkan/UI/Task/TaskOrderSelectViewController.swift
Normal file
@ -0,0 +1,225 @@
|
||||
//
|
||||
// TaskOrderSelectViewController.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
/// 任务关联订单选择页,对齐 Android `OrderSelectScreen`。
|
||||
final class TaskOrderSelectViewController: BaseViewController {
|
||||
|
||||
var onConfirmed: ((String?) -> Void)?
|
||||
|
||||
private let viewModel: TaskOrderSelectViewModel
|
||||
private let taskAPI = NetworkServices.shared.taskAPI
|
||||
|
||||
private let tableView = UITableView(frame: .zero, style: .plain)
|
||||
private let confirmButton = AppButton(title: "确定")
|
||||
private let emptyLabel = UILabel()
|
||||
private let bottomBar = UIView()
|
||||
|
||||
init(initialOrderNumber: String? = nil) {
|
||||
viewModel = TaskOrderSelectViewModel(initialOrderNumber: initialOrderNumber)
|
||||
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() {
|
||||
tableView.backgroundColor = AppColor.pageBackground
|
||||
tableView.separatorStyle = .none
|
||||
tableView.register(TaskOrderCell.self, forCellReuseIdentifier: TaskOrderCell.reuseIdentifier)
|
||||
tableView.dataSource = self
|
||||
tableView.delegate = self
|
||||
|
||||
emptyLabel.text = "无有效订单"
|
||||
emptyLabel.font = .app(.body)
|
||||
emptyLabel.textColor = AppColor.textPrimary
|
||||
emptyLabel.textAlignment = .center
|
||||
emptyLabel.isHidden = true
|
||||
|
||||
bottomBar.backgroundColor = .white
|
||||
view.addSubview(tableView)
|
||||
view.addSubview(emptyLabel)
|
||||
view.addSubview(bottomBar)
|
||||
bottomBar.addSubview(confirmButton)
|
||||
}
|
||||
|
||||
override func setupConstraints() {
|
||||
bottomBar.snp.makeConstraints { make in
|
||||
make.leading.trailing.bottom.equalToSuperview()
|
||||
}
|
||||
confirmButton.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview().inset(AppSpacing.md)
|
||||
}
|
||||
tableView.snp.makeConstraints { make in
|
||||
make.top.leading.trailing.equalTo(view.safeAreaLayoutGuide)
|
||||
make.bottom.equalTo(bottomBar.snp.top)
|
||||
}
|
||||
emptyLabel.snp.makeConstraints { make in
|
||||
make.center.equalTo(tableView)
|
||||
}
|
||||
}
|
||||
|
||||
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.loadOrders(api: taskAPI) }
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func applyViewModel() {
|
||||
emptyLabel.isHidden = viewModel.isLoading || !viewModel.orders.isEmpty
|
||||
tableView.reloadData()
|
||||
if viewModel.isLoading {
|
||||
showLoading()
|
||||
} else {
|
||||
hideLoading()
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func confirmTapped() {
|
||||
onConfirmed?(viewModel.confirmedOrderNumber())
|
||||
navigationController?.popViewController(animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
extension TaskOrderSelectViewController: UITableViewDataSource, UITableViewDelegate {
|
||||
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
viewModel.orders.count
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
let cell = tableView.dequeueReusableCell(
|
||||
withIdentifier: TaskOrderCell.reuseIdentifier,
|
||||
for: indexPath
|
||||
) as! TaskOrderCell
|
||||
let order = viewModel.orders[indexPath.row]
|
||||
cell.apply(
|
||||
order: order,
|
||||
isSelected: viewModel.selectedOrderNumber == order.orderNumber
|
||||
)
|
||||
return cell
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
tableView.deselectRow(at: indexPath, animated: true)
|
||||
viewModel.toggleSelection(orderNumber: viewModel.orders[indexPath.row].orderNumber)
|
||||
}
|
||||
}
|
||||
|
||||
/// 订单选择卡片 cell。
|
||||
private final class TaskOrderCell: UITableViewCell {
|
||||
|
||||
static let reuseIdentifier = "TaskOrderCell"
|
||||
|
||||
private let cardView = UIView()
|
||||
private let projectRow = TaskInfoRowView(label: "项目名称:")
|
||||
private let statusRow = TaskInfoRowView(label: "订单状态:")
|
||||
private let numberRow = TaskInfoRowView(label: "订单编号:")
|
||||
private let payTimeRow = TaskInfoRowView(label: "付款时间:")
|
||||
|
||||
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||
selectionStyle = .none
|
||||
backgroundColor = .clear
|
||||
contentView.backgroundColor = .clear
|
||||
|
||||
cardView.backgroundColor = .white
|
||||
cardView.layer.cornerRadius = AppRadius.sm
|
||||
cardView.layer.borderWidth = 1
|
||||
cardView.layer.borderColor = AppColor.border.cgColor
|
||||
|
||||
contentView.addSubview(cardView)
|
||||
cardView.addSubview(projectRow)
|
||||
cardView.addSubview(statusRow)
|
||||
cardView.addSubview(numberRow)
|
||||
cardView.addSubview(payTimeRow)
|
||||
|
||||
cardView.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview().inset(UIEdgeInsets(top: 0, left: AppSpacing.md, bottom: AppSpacing.sm, right: AppSpacing.md))
|
||||
}
|
||||
projectRow.snp.makeConstraints { make in
|
||||
make.top.leading.trailing.equalToSuperview().inset(AppSpacing.md)
|
||||
}
|
||||
statusRow.snp.makeConstraints { make in
|
||||
make.top.equalTo(projectRow.snp.bottom).offset(AppSpacing.xs)
|
||||
make.leading.trailing.equalTo(projectRow)
|
||||
}
|
||||
numberRow.snp.makeConstraints { make in
|
||||
make.top.equalTo(statusRow.snp.bottom).offset(AppSpacing.xs)
|
||||
make.leading.trailing.equalTo(projectRow)
|
||||
}
|
||||
payTimeRow.snp.makeConstraints { make in
|
||||
make.top.equalTo(numberRow.snp.bottom).offset(AppSpacing.xs)
|
||||
make.leading.trailing.bottom.equalToSuperview().inset(AppSpacing.md)
|
||||
}
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
func apply(order: AvailableOrder, isSelected: Bool) {
|
||||
projectRow.apply(value: order.projectName.isEmpty ? "--" : order.projectName)
|
||||
statusRow.apply(value: order.orderStatusLabel.isEmpty ? "--" : order.orderStatusLabel)
|
||||
numberRow.apply(value: order.orderNumber)
|
||||
payTimeRow.apply(value: order.payTime.isEmpty ? "--" : order.payTime)
|
||||
cardView.layer.borderColor = isSelected ? AppColor.danger.cgColor : AppColor.border.cgColor
|
||||
cardView.layer.borderWidth = isSelected ? 2 : 1
|
||||
}
|
||||
}
|
||||
|
||||
/// 订单信息行。
|
||||
private final class TaskInfoRowView: UIView {
|
||||
private let labelView = UILabel()
|
||||
private let valueView = UILabel()
|
||||
|
||||
init(label: String) {
|
||||
super.init(frame: .zero)
|
||||
labelView.text = label
|
||||
labelView.font = .app(.body)
|
||||
labelView.textColor = AppColor.textSecondary
|
||||
valueView.font = .app(.body)
|
||||
valueView.textColor = AppColor.textPrimary
|
||||
valueView.numberOfLines = 0
|
||||
|
||||
addSubview(labelView)
|
||||
addSubview(valueView)
|
||||
labelView.snp.makeConstraints { make in
|
||||
make.leading.top.bottom.equalToSuperview()
|
||||
make.width.equalTo(80)
|
||||
}
|
||||
valueView.snp.makeConstraints { make in
|
||||
make.leading.equalTo(labelView.snp.trailing).offset(AppSpacing.xs)
|
||||
make.trailing.top.bottom.equalToSuperview()
|
||||
}
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
func apply(value: String) {
|
||||
valueView.text = value
|
||||
}
|
||||
}
|
||||
627
suixinkan/UI/Task/Views/TaskAddViews.swift
Normal file
627
suixinkan/UI/Task/Views/TaskAddViews.swift
Normal file
@ -0,0 +1,627 @@
|
||||
//
|
||||
// TaskAddViews.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import Kingfisher
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
/// 添加任务素材网格区。
|
||||
final class TaskAddMediaSectionView: UIView {
|
||||
|
||||
var onCloudImport: (() -> Void)?
|
||||
var onLocalImport: (() -> Void)?
|
||||
var onFileTap: ((TaskFileItem) -> Void)?
|
||||
var onDeleteFile: ((TaskFileItem) -> Void)?
|
||||
|
||||
private var files: [TaskFileItem] = []
|
||||
private var collectionHeightConstraint: Constraint?
|
||||
|
||||
private enum Section { case main }
|
||||
private enum Item: Hashable { case add; case file(String) }
|
||||
|
||||
private lazy var collectionView: UICollectionView = {
|
||||
let layout = 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 itemHeight = itemWidth / 1.77
|
||||
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
|
||||
return section
|
||||
}
|
||||
let view = UICollectionView(frame: .zero, collectionViewLayout: layout)
|
||||
view.backgroundColor = .clear
|
||||
view.isScrollEnabled = false
|
||||
return view
|
||||
}()
|
||||
|
||||
private lazy var dataSource = UICollectionViewDiffableDataSource<Section, Item>(collectionView: collectionView) {
|
||||
[weak self] collectionView, indexPath, item in
|
||||
switch item {
|
||||
case .add:
|
||||
let cell = collectionView.dequeueReusableCell(
|
||||
withReuseIdentifier: TaskAddAddMediaCell.reuseIdentifier,
|
||||
for: indexPath
|
||||
) as! TaskAddAddMediaCell
|
||||
return cell
|
||||
case .file(let id):
|
||||
let cell = collectionView.dequeueReusableCell(
|
||||
withReuseIdentifier: TaskAddMediaCell.reuseIdentifier,
|
||||
for: indexPath
|
||||
) as! TaskAddMediaCell
|
||||
if let file = self?.files.first(where: { $0.id == id }) {
|
||||
cell.apply(file: file)
|
||||
cell.onDelete = { self?.onDeleteFile?(file) }
|
||||
}
|
||||
return cell
|
||||
}
|
||||
}
|
||||
|
||||
private let titleLabel = UILabel()
|
||||
private let tipsLabel = UILabel()
|
||||
private let cloudButton = UIButton(type: .system)
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
backgroundColor = .white
|
||||
layer.cornerRadius = AppRadius.md
|
||||
|
||||
titleLabel.text = "添加图片"
|
||||
titleLabel.font = .app(.bodyMedium)
|
||||
titleLabel.textColor = AppColor.textPrimary
|
||||
|
||||
tipsLabel.text = "(点击图片或视频可添加备注)"
|
||||
tipsLabel.font = .app(.caption)
|
||||
tipsLabel.textColor = AppColor.textPrimary
|
||||
|
||||
cloudButton.setTitle("云盘导入", for: .normal)
|
||||
cloudButton.setTitleColor(AppColor.primary, for: .normal)
|
||||
cloudButton.titleLabel?.font = .app(.caption)
|
||||
cloudButton.addTarget(self, action: #selector(cloudTapped), for: .touchUpInside)
|
||||
|
||||
collectionView.register(TaskAddMediaCell.self, forCellWithReuseIdentifier: TaskAddMediaCell.reuseIdentifier)
|
||||
collectionView.register(TaskAddAddMediaCell.self, forCellWithReuseIdentifier: TaskAddAddMediaCell.reuseIdentifier)
|
||||
collectionView.delegate = self
|
||||
|
||||
addSubview(titleLabel)
|
||||
addSubview(tipsLabel)
|
||||
addSubview(cloudButton)
|
||||
addSubview(collectionView)
|
||||
|
||||
titleLabel.snp.makeConstraints { make in
|
||||
make.top.leading.equalToSuperview().offset(AppSpacing.md)
|
||||
}
|
||||
tipsLabel.snp.makeConstraints { make in
|
||||
make.leading.equalTo(titleLabel.snp.trailing).offset(AppSpacing.xxs)
|
||||
make.centerY.equalTo(titleLabel)
|
||||
}
|
||||
cloudButton.snp.makeConstraints { make in
|
||||
make.trailing.equalToSuperview().inset(AppSpacing.md)
|
||||
make.centerY.equalTo(titleLabel)
|
||||
}
|
||||
collectionView.snp.makeConstraints { make in
|
||||
make.top.equalTo(titleLabel.snp.bottom).offset(AppSpacing.sm)
|
||||
make.leading.trailing.equalToSuperview().inset(AppSpacing.md)
|
||||
make.bottom.equalToSuperview().inset(AppSpacing.md)
|
||||
collectionHeightConstraint = make.height.equalTo(88).constraint
|
||||
}
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
func apply(files: [TaskFileItem]) {
|
||||
self.files = files
|
||||
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
|
||||
snapshot.appendSections([.main])
|
||||
snapshot.appendItems(files.map { Item.file($0.id) } + [.add])
|
||||
dataSource.apply(snapshot, animatingDifferences: false)
|
||||
collectionView.layoutIfNeeded()
|
||||
let height = max(88, collectionView.collectionViewLayout.collectionViewContentSize.height)
|
||||
collectionHeightConstraint?.update(offset: height)
|
||||
}
|
||||
|
||||
@objc private func cloudTapped() {
|
||||
onCloudImport?()
|
||||
}
|
||||
}
|
||||
|
||||
extension TaskAddMediaSectionView: UICollectionViewDelegate {
|
||||
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
|
||||
guard let item = dataSource.itemIdentifier(for: indexPath) else { return }
|
||||
switch item {
|
||||
case .add:
|
||||
onLocalImport?()
|
||||
case .file(let id):
|
||||
guard let file = files.first(where: { $0.id == id }) else { return }
|
||||
onFileTap?(file)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 素材缩略图 cell。
|
||||
final class TaskAddMediaCell: UICollectionViewCell {
|
||||
|
||||
static let reuseIdentifier = "TaskAddMediaCell"
|
||||
|
||||
var onDelete: (() -> Void)?
|
||||
|
||||
private let imageView = UIImageView()
|
||||
private let overlayView = UIView()
|
||||
private let progressLabel = UILabel()
|
||||
private let deleteButton = UIButton(type: .system)
|
||||
private let playIconView = UIImageView()
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
contentView.backgroundColor = AppColor.inputBackground
|
||||
contentView.layer.cornerRadius = AppRadius.sm
|
||||
contentView.clipsToBounds = true
|
||||
|
||||
imageView.contentMode = .scaleAspectFill
|
||||
imageView.clipsToBounds = true
|
||||
|
||||
overlayView.backgroundColor = UIColor.black.withAlphaComponent(0.35)
|
||||
overlayView.isHidden = true
|
||||
|
||||
progressLabel.font = .app(.captionMedium)
|
||||
progressLabel.textColor = .white
|
||||
progressLabel.textAlignment = .center
|
||||
|
||||
deleteButton.setImage(UIImage(systemName: "xmark.circle.fill"), for: .normal)
|
||||
deleteButton.tintColor = .white
|
||||
deleteButton.addTarget(self, action: #selector(deleteTapped), for: .touchUpInside)
|
||||
|
||||
playIconView.image = UIImage(systemName: "play.circle.fill")
|
||||
playIconView.tintColor = .white
|
||||
playIconView.isHidden = true
|
||||
|
||||
contentView.addSubview(imageView)
|
||||
contentView.addSubview(overlayView)
|
||||
contentView.addSubview(playIconView)
|
||||
contentView.addSubview(progressLabel)
|
||||
contentView.addSubview(deleteButton)
|
||||
|
||||
imageView.snp.makeConstraints { make in make.edges.equalToSuperview() }
|
||||
overlayView.snp.makeConstraints { make in make.edges.equalToSuperview() }
|
||||
playIconView.snp.makeConstraints { make in
|
||||
make.center.equalToSuperview()
|
||||
make.width.height.equalTo(28)
|
||||
}
|
||||
progressLabel.snp.makeConstraints { make in make.center.equalToSuperview() }
|
||||
deleteButton.snp.makeConstraints { make in
|
||||
make.top.trailing.equalToSuperview().inset(AppSpacing.xxs)
|
||||
make.width.height.equalTo(22)
|
||||
}
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func prepareForReuse() {
|
||||
super.prepareForReuse()
|
||||
onDelete = nil
|
||||
imageView.kf.cancelDownloadTask()
|
||||
imageView.image = nil
|
||||
}
|
||||
|
||||
func apply(file: TaskFileItem) {
|
||||
let urlString = file.previewURLString
|
||||
if let url = URL(string: urlString), !urlString.isEmpty {
|
||||
imageView.kf.setImage(with: url)
|
||||
} else {
|
||||
imageView.image = UIImage(systemName: "photo")
|
||||
imageView.tintColor = AppColor.textTertiary
|
||||
}
|
||||
playIconView.isHidden = file.fileType != 1
|
||||
if file.isUploading {
|
||||
overlayView.isHidden = false
|
||||
progressLabel.text = "\(file.uploadProgress)%"
|
||||
} else {
|
||||
overlayView.isHidden = true
|
||||
progressLabel.text = nil
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func deleteTapped() {
|
||||
onDelete?()
|
||||
}
|
||||
}
|
||||
|
||||
/// 本地导入占位 cell。
|
||||
final class TaskAddAddMediaCell: UICollectionViewCell {
|
||||
|
||||
static let reuseIdentifier = "TaskAddAddMediaCell"
|
||||
|
||||
private let iconView = UIImageView()
|
||||
private let titleLabel = UILabel()
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
contentView.backgroundColor = AppColor.inputBackground
|
||||
contentView.layer.cornerRadius = AppRadius.sm
|
||||
|
||||
iconView.image = UIImage(systemName: "plus")
|
||||
iconView.tintColor = AppColor.textTabInactive
|
||||
|
||||
titleLabel.text = "本地导入"
|
||||
titleLabel.font = .app(.caption)
|
||||
titleLabel.textColor = AppColor.textTabInactive
|
||||
|
||||
contentView.addSubview(iconView)
|
||||
contentView.addSubview(titleLabel)
|
||||
iconView.snp.makeConstraints { make in
|
||||
make.centerX.equalToSuperview()
|
||||
make.centerY.equalToSuperview().offset(-8)
|
||||
make.width.height.equalTo(24)
|
||||
}
|
||||
titleLabel.snp.makeConstraints { make in
|
||||
make.top.equalTo(iconView.snp.bottom).offset(2)
|
||||
make.centerX.equalToSuperview()
|
||||
}
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
}
|
||||
|
||||
/// 任务优先级选择区。
|
||||
final class TaskPrioritySectionView: UIView {
|
||||
|
||||
var onUrgentHourChange: ((Int) -> Void)?
|
||||
var onCustomHourTextChange: ((String) -> Void)?
|
||||
|
||||
private let titleLabel = UILabel()
|
||||
private let noneButton = UIButton(type: .system)
|
||||
private let twoHourButton = UIButton(type: .system)
|
||||
private let customButton = UIButton(type: .system)
|
||||
private let customField = UITextField()
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
backgroundColor = .white
|
||||
layer.cornerRadius = AppRadius.md
|
||||
|
||||
titleLabel.text = "优先级"
|
||||
titleLabel.font = .app(.bodyMedium)
|
||||
titleLabel.textColor = AppColor.textPrimary
|
||||
|
||||
configureOptionButton(noneButton, title: "不加急")
|
||||
configureOptionButton(twoHourButton, title: "加急(2小时)")
|
||||
configureOptionButton(customButton, title: "自定义")
|
||||
|
||||
noneButton.addTarget(self, action: #selector(noneTapped), for: .touchUpInside)
|
||||
twoHourButton.addTarget(self, action: #selector(twoHourTapped), for: .touchUpInside)
|
||||
customButton.addTarget(self, action: #selector(customTapped), for: .touchUpInside)
|
||||
|
||||
customField.placeholder = "请输入小时数"
|
||||
customField.font = .app(.body)
|
||||
customField.textColor = AppColor.textPrimary
|
||||
customField.keyboardType = .numberPad
|
||||
customField.borderStyle = .roundedRect
|
||||
customField.isHidden = true
|
||||
customField.addTarget(self, action: #selector(customFieldChanged), for: .editingChanged)
|
||||
|
||||
let row = UIStackView(arrangedSubviews: [noneButton, twoHourButton, customButton])
|
||||
row.axis = .horizontal
|
||||
row.spacing = AppSpacing.xs
|
||||
row.distribution = .fillEqually
|
||||
|
||||
addSubview(titleLabel)
|
||||
addSubview(row)
|
||||
addSubview(customField)
|
||||
|
||||
titleLabel.snp.makeConstraints { make in
|
||||
make.top.leading.equalToSuperview().offset(AppSpacing.md)
|
||||
}
|
||||
row.snp.makeConstraints { make in
|
||||
make.top.equalTo(titleLabel.snp.bottom).offset(AppSpacing.sm)
|
||||
make.leading.trailing.equalToSuperview().inset(AppSpacing.md)
|
||||
make.height.equalTo(42)
|
||||
}
|
||||
customField.snp.makeConstraints { make in
|
||||
make.top.equalTo(row.snp.bottom).offset(AppSpacing.sm)
|
||||
make.leading.trailing.equalToSuperview().inset(AppSpacing.md)
|
||||
make.bottom.equalToSuperview().inset(AppSpacing.md)
|
||||
make.height.equalTo(40)
|
||||
}
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
func apply(urgentHour: Int) {
|
||||
let isCustom = urgentHour > 0 && urgentHour != 2
|
||||
styleOption(noneButton, selected: urgentHour == 0)
|
||||
styleOption(twoHourButton, selected: urgentHour == 2)
|
||||
styleOption(customButton, selected: isCustom)
|
||||
customField.isHidden = !isCustom
|
||||
if isCustom {
|
||||
customField.text = "\(urgentHour)"
|
||||
} else {
|
||||
customField.text = ""
|
||||
}
|
||||
}
|
||||
|
||||
private func configureOptionButton(_ button: UIButton, title: String) {
|
||||
button.setTitle(title, for: .normal)
|
||||
button.titleLabel?.font = .app(.caption)
|
||||
button.layer.cornerRadius = AppRadius.xs
|
||||
button.clipsToBounds = true
|
||||
}
|
||||
|
||||
private func styleOption(_ button: UIButton, selected: Bool) {
|
||||
button.backgroundColor = selected ? AppColor.primary : AppColor.inputBackground
|
||||
button.setTitleColor(selected ? .white : AppColor.textSecondary, for: .normal)
|
||||
}
|
||||
|
||||
@objc private func noneTapped() { onUrgentHourChange?(0) }
|
||||
@objc private func twoHourTapped() { onUrgentHourChange?(2) }
|
||||
@objc private func customTapped() {
|
||||
onUrgentHourChange?(1)
|
||||
onCustomHourTextChange?("1")
|
||||
}
|
||||
@objc private func customFieldChanged() {
|
||||
onCustomHourTextChange?(customField.text ?? "")
|
||||
}
|
||||
}
|
||||
|
||||
/// 关联订单选择按钮。
|
||||
final class TaskOrderSelectButton: UIControl {
|
||||
|
||||
private let titleLabel = UILabel()
|
||||
private let arrowView = UIImageView(image: UIImage(systemName: "chevron.down"))
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
backgroundColor = .white
|
||||
layer.cornerRadius = AppRadius.sm
|
||||
layer.borderWidth = 1
|
||||
layer.borderColor = AppColor.border.cgColor
|
||||
|
||||
titleLabel.font = .app(.bodyMedium)
|
||||
titleLabel.textColor = AppColor.textTabInactive
|
||||
titleLabel.text = "关联订单(可选)"
|
||||
|
||||
arrowView.tintColor = AppColor.textTabInactive
|
||||
|
||||
addSubview(titleLabel)
|
||||
addSubview(arrowView)
|
||||
titleLabel.snp.makeConstraints { make in
|
||||
make.leading.equalToSuperview().offset(AppSpacing.sm)
|
||||
make.centerY.equalToSuperview()
|
||||
}
|
||||
arrowView.snp.makeConstraints { make in
|
||||
make.trailing.equalToSuperview().inset(AppSpacing.sm)
|
||||
make.centerY.equalToSuperview()
|
||||
}
|
||||
snp.makeConstraints { make in make.height.equalTo(44) }
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
func apply(orderNumber: String?) {
|
||||
if let orderNumber, !orderNumber.isEmpty {
|
||||
titleLabel.text = orderNumber
|
||||
titleLabel.textColor = AppColor.textPrimary
|
||||
} else {
|
||||
titleLabel.text = "关联订单(可选)"
|
||||
titleLabel.textColor = AppColor.textTabInactive
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 任务素材备注弹窗。
|
||||
final class TaskFileRemarkDialogView: UIView {
|
||||
|
||||
var onCancel: (() -> Void)?
|
||||
var onConfirm: (() -> Void)?
|
||||
|
||||
private let containerView = UIView()
|
||||
private let titleLabel = UILabel()
|
||||
private let previewView = UIImageView()
|
||||
private let textView = UITextView()
|
||||
private let countLabel = UILabel()
|
||||
private let cancelButton = UIButton(type: .system)
|
||||
private let confirmButton = UIButton(type: .system)
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
backgroundColor = AppColor.overlayScrim
|
||||
|
||||
containerView.backgroundColor = .white
|
||||
containerView.layer.cornerRadius = AppRadius.md
|
||||
|
||||
titleLabel.text = "备注信息"
|
||||
titleLabel.font = .app(.title)
|
||||
|
||||
previewView.contentMode = .scaleAspectFill
|
||||
previewView.clipsToBounds = true
|
||||
previewView.layer.cornerRadius = AppRadius.sm
|
||||
previewView.backgroundColor = AppColor.inputBackground
|
||||
|
||||
textView.font = .app(.body)
|
||||
textView.layer.borderWidth = 1
|
||||
textView.layer.borderColor = AppColor.border.cgColor
|
||||
textView.layer.cornerRadius = AppRadius.sm
|
||||
textView.delegate = self
|
||||
|
||||
countLabel.font = .app(.caption)
|
||||
countLabel.textColor = AppColor.textTertiary
|
||||
countLabel.text = "0/50"
|
||||
|
||||
cancelButton.setTitle("取消", for: .normal)
|
||||
cancelButton.setTitleColor(AppColor.textSecondary, for: .normal)
|
||||
cancelButton.backgroundColor = AppColor.inputBackground
|
||||
cancelButton.layer.cornerRadius = AppRadius.xs
|
||||
cancelButton.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside)
|
||||
|
||||
confirmButton.setTitle("确定", for: .normal)
|
||||
confirmButton.setTitleColor(.white, for: .normal)
|
||||
confirmButton.backgroundColor = AppColor.primary
|
||||
confirmButton.layer.cornerRadius = AppRadius.xs
|
||||
confirmButton.addTarget(self, action: #selector(confirmTapped), for: .touchUpInside)
|
||||
|
||||
addSubview(containerView)
|
||||
containerView.addSubview(titleLabel)
|
||||
containerView.addSubview(previewView)
|
||||
containerView.addSubview(textView)
|
||||
containerView.addSubview(countLabel)
|
||||
containerView.addSubview(cancelButton)
|
||||
containerView.addSubview(confirmButton)
|
||||
|
||||
containerView.snp.makeConstraints { make in
|
||||
make.center.equalToSuperview()
|
||||
make.leading.trailing.equalToSuperview().inset(AppSpacing.lg)
|
||||
}
|
||||
titleLabel.snp.makeConstraints { make in
|
||||
make.top.leading.equalToSuperview().offset(AppSpacing.md)
|
||||
}
|
||||
previewView.snp.makeConstraints { make in
|
||||
make.top.equalTo(titleLabel.snp.bottom).offset(AppSpacing.sm)
|
||||
make.leading.trailing.equalToSuperview().inset(AppSpacing.md)
|
||||
make.height.equalTo(160)
|
||||
}
|
||||
textView.snp.makeConstraints { make in
|
||||
make.top.equalTo(previewView.snp.bottom).offset(AppSpacing.sm)
|
||||
make.leading.trailing.equalToSuperview().inset(AppSpacing.md)
|
||||
make.height.equalTo(80)
|
||||
}
|
||||
countLabel.snp.makeConstraints { make in
|
||||
make.top.equalTo(textView.snp.bottom).offset(AppSpacing.xxs)
|
||||
make.trailing.equalTo(textView)
|
||||
}
|
||||
cancelButton.snp.makeConstraints { make in
|
||||
make.top.equalTo(countLabel.snp.bottom).offset(AppSpacing.md)
|
||||
make.leading.bottom.equalToSuperview().inset(AppSpacing.md)
|
||||
make.height.equalTo(36)
|
||||
make.width.equalTo(72)
|
||||
}
|
||||
confirmButton.snp.makeConstraints { make in
|
||||
make.centerY.equalTo(cancelButton)
|
||||
make.trailing.equalToSuperview().inset(AppSpacing.md)
|
||||
make.height.equalTo(36)
|
||||
make.width.equalTo(72)
|
||||
}
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
func apply(file: TaskFileItem, text: String) {
|
||||
textView.text = text
|
||||
countLabel.text = "\(text.count)/50"
|
||||
let urlString = file.previewURLString
|
||||
if let url = URL(string: urlString), !urlString.isEmpty {
|
||||
previewView.kf.setImage(with: url)
|
||||
} else {
|
||||
previewView.image = UIImage(systemName: "photo")
|
||||
previewView.tintColor = AppColor.textTertiary
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func cancelTapped() { onCancel?() }
|
||||
@objc private func confirmTapped() { onConfirm?() }
|
||||
|
||||
/// 当前输入的备注文本。
|
||||
var inputText: String { textView.text ?? "" }
|
||||
}
|
||||
|
||||
extension TaskFileRemarkDialogView: UITextViewDelegate {
|
||||
func textViewDidChange(_ textView: UITextView) {
|
||||
if textView.text.count > 50 {
|
||||
textView.text = String(textView.text.prefix(50))
|
||||
}
|
||||
countLabel.text = "\(textView.text.count)/50"
|
||||
}
|
||||
}
|
||||
|
||||
/// 带边框的单行输入框。
|
||||
final class TaskBorderTextField: UIView {
|
||||
|
||||
let textField = UITextField()
|
||||
|
||||
init(placeholder: String) {
|
||||
super.init(frame: .zero)
|
||||
backgroundColor = .white
|
||||
layer.cornerRadius = AppRadius.sm
|
||||
layer.borderWidth = 1
|
||||
layer.borderColor = AppColor.border.cgColor
|
||||
|
||||
textField.placeholder = placeholder
|
||||
textField.font = .app(.body)
|
||||
textField.textColor = AppColor.textPrimary
|
||||
addSubview(textField)
|
||||
textField.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview().inset(UIEdgeInsets(top: 10, left: 12, bottom: 10, right: 12))
|
||||
}
|
||||
snp.makeConstraints { make in make.height.equalTo(44) }
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
}
|
||||
|
||||
/// 带边框的多行输入框。
|
||||
final class TaskBorderTextView: UIView {
|
||||
|
||||
let textView = UITextView()
|
||||
|
||||
init(placeholder: String) {
|
||||
super.init(frame: .zero)
|
||||
backgroundColor = .white
|
||||
layer.cornerRadius = AppRadius.sm
|
||||
layer.borderWidth = 1
|
||||
layer.borderColor = AppColor.border.cgColor
|
||||
|
||||
textView.font = .app(.body)
|
||||
textView.textColor = AppColor.textPrimary
|
||||
textView.text = placeholder
|
||||
textView.textColor = AppColor.textTertiary
|
||||
addSubview(textView)
|
||||
textView.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview().inset(AppSpacing.xs)
|
||||
}
|
||||
snp.makeConstraints { make in make.height.equalTo(80) }
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user