Files
suixinkan_uikit/suixinkan/UI/Task/TaskAddViewController.swift

408 lines
16 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// 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 formCardView = UIView()
private let contentStack = UIStackView()
private let mediaSection = TaskAddMediaSectionView()
private let prioritySection = TaskPrioritySectionView()
private let orderTitleLabel = UILabel()
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 uploadTypeSheet: TaskUploadTypeSheetView?
private var pickingMediaType: Int = 2
private var detailsPlaceholder = "请输入任务描述(必填)"
override func setupNavigationBar() {
title = "添加任务"
}
override func setupUI() {
view.backgroundColor = AppColor.pageBackground
scrollView.backgroundColor = AppColor.pageBackground
scrollView.keyboardDismissMode = .interactive
formCardView.backgroundColor = .white
formCardView.layer.cornerRadius = AppRadius.lg
contentStack.axis = .vertical
contentStack.spacing = AppSpacing.md
orderTitleLabel.text = "关联订单(可选)"
orderTitleLabel.font = .app(.bodyMedium)
orderTitleLabel.textColor = AppColor.textPrimary
bottomBar.backgroundColor = .white
view.addSubview(scrollView)
view.addSubview(bottomBar)
scrollView.addSubview(formCardView)
formCardView.addSubview(contentStack)
contentStack.addArrangedSubview(mediaSection)
contentStack.addArrangedSubview(prioritySection)
contentStack.addArrangedSubview(orderTitleLabel)
contentStack.addArrangedSubview(orderButton)
contentStack.addArrangedSubview(nameField)
contentStack.addArrangedSubview(detailsField)
contentStack.setCustomSpacing(AppSpacing.xxs, after: orderTitleLabel)
bottomBar.addSubview(saveButton)
}
override func setupConstraints() {
bottomBar.snp.makeConstraints { make in
make.leading.trailing.bottom.equalToSuperview()
make.height.equalTo(80)
}
saveButton.snp.makeConstraints { make in
make.leading.trailing.equalToSuperview().inset(AppSpacing.md)
make.centerY.equalToSuperview()
}
scrollView.snp.makeConstraints { make in
make.top.leading.trailing.equalTo(view.safeAreaLayoutGuide)
make.bottom.equalTo(bottomBar.snp.top)
}
formCardView.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(AppSpacing.md)
make.width.equalTo(scrollView.snp.width).offset(-AppSpacing.md * 2)
}
contentStack.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(AppSpacing.md)
}
}
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)
applyViewModel()
}
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 viewModel.taskDetails.isEmpty, !detailsField.textView.isFirstResponder {
detailsField.textView.text = detailsPlaceholder
detailsField.textView.textColor = AppColor.textTertiary
} else if detailsField.textView.text != viewModel.taskDetails {
detailsField.textView.text = viewModel.taskDetails
detailsField.textView.textColor = AppColor.textPrimary
}
saveButton.isEnabled = true
saveButton.alpha = viewModel.isUploading ? 0.6 : 1
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() {
guard uploadTypeSheet == nil else { return }
let sheet = TaskUploadTypeSheetView(frame: view.bounds)
sheet.onChooseImage = { [weak self] in
self?.hideUploadTypeSheet()
self?.presentMediaPicker(isImage: true)
}
sheet.onChooseVideo = { [weak self] in
self?.hideUploadTypeSheet()
self?.presentMediaPicker(isImage: false)
}
sheet.onCancel = { [weak self] in
self?.hideUploadTypeSheet()
}
uploadTypeSheet = sheet
view.addSubview(sheet)
sheet.snp.makeConstraints { make in make.edges.equalToSuperview() }
sheet.show()
}
private func hideUploadTypeSheet() {
uploadTypeSheet?.dismiss { [weak self] in
self?.uploadTypeSheet?.removeFromSuperview()
self?.uploadTypeSheet = nil
}
}
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)
}
}
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)"))
}
}
}
}