// // 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)")) } } } }