// // TaskAddViewController.swift // suixinkan // import AVFoundation 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) } prioritySection.onShowMessage = { [weak self] message in self?.showToast(message) } 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?.presentLocalSourcePicker(isImage: true) } } sheet.onChooseVideo = { [weak self] in self?.hideUploadTypeSheet { self?.presentLocalSourcePicker(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(completion: (() -> Void)? = nil) { uploadTypeSheet?.dismiss { [weak self] in self?.uploadTypeSheet?.removeFromSuperview() self?.uploadTypeSheet = nil completion?() } } private func presentLocalSourcePicker(isImage: Bool) { pickingMediaType = isImage ? 2 : 1 let sheet = UIAlertController(title: nil, message: nil, preferredStyle: .actionSheet) if UIImagePickerController.isSourceTypeAvailable(.camera) { sheet.addAction(UIAlertAction(title: isImage ? "拍照" : "拍摄视频", style: .default) { [weak self] _ in self?.presentCamera(isImage: isImage) }) } sheet.addAction(UIAlertAction(title: "从相册选择", style: .default) { [weak self] _ in self?.presentMediaPicker(isImage: isImage) }) 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 presentCamera(isImage: Bool) { let picker = UIImagePickerController() picker.sourceType = .camera picker.delegate = self picker.mediaTypes = [isImage ? UTType.image.identifier : UTType.movie.identifier] picker.cameraCaptureMode = isImage ? .photo : .video if !isImage { picker.videoQuality = .typeHigh } 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.session.currentScenicId guard scenicId > 0 else { showToast("请先选择景区") return } let totalCount = results.count for result in results { let uploadTaskId = UUID().uuidString.replacingOccurrences(of: "-", with: "") viewModel.addUploadPlaceholder( fileName: TaskMediaLoader.suggestedFileName( from: result.itemProvider, fallback: "upload_\(uploadTaskId).\(isImage ? "jpg" : "mp4")" ), fileType: isImage ? 2 : 1, uploadTaskId: uploadTaskId ) Task { do { let payload = try await TaskMediaLoader.load(from: result, isImage: isImage) await uploadPayload( payload, fileType: isImage ? 2 : 1, uploadTaskId: uploadTaskId, totalCount: totalCount, scenicId: scenicId ) } catch { await MainActor.run { self.viewModel.markUploadFailed(uploadTaskId: uploadTaskId) self.showToast(error.localizedDescription) } } } } } private func uploadCapturedPayload(_ payload: TaskMediaLoader.Payload, fileType: Int) { let scenicId = AppStore.shared.session.currentScenicId guard scenicId > 0 else { showToast("请先选择景区") return } let uploadTaskId = UUID().uuidString.replacingOccurrences(of: "-", with: "") viewModel.addUploadPlaceholder( fileName: payload.fileName, fileType: fileType, uploadTaskId: uploadTaskId ) Task { await uploadPayload( payload, fileType: fileType, uploadTaskId: uploadTaskId, totalCount: 1, scenicId: scenicId ) } } private func uploadPayload( _ payload: TaskMediaLoader.Payload, fileType: Int, uploadTaskId: String, totalCount: Int, scenicId: Int ) async { viewModel.prepareUpload( uploadTaskId: uploadTaskId, fileName: payload.fileName, localPreviewData: payload.previewData ) do { let fileURL = try await ossUploadService.uploadTaskFile( data: payload.data, fileName: payload.fileName, fileType: fileType, scenicId: scenicId ) { [weak self] progress in Task { @MainActor in self?.viewModel.updateUploadProgress(uploadTaskId: uploadTaskId, progress: progress) } } viewModel.markUploadSucceeded( uploadTaskId: uploadTaskId, fileURL: fileURL, fileName: payload.fileName, localPreviewData: payload.previewData, autoRemarkWhenSingle: totalCount == 1 ) if totalCount == 1, let fileID = viewModel.consumePendingRemarkFileID() { viewModel.presentRemarkDialog(for: fileID) } } catch { viewModel.markUploadFailed(uploadTaskId: uploadTaskId) showToast(error.localizedDescription) } } } 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: UIImagePickerControllerDelegate, UINavigationControllerDelegate { func imagePickerControllerDidCancel(_ picker: UIImagePickerController) { picker.dismiss(animated: true) } func imagePickerController( _ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any] ) { picker.dismiss(animated: true) if pickingMediaType == 1, let url = info[.mediaURL] as? URL, let payload = try? TaskMediaLoader.videoPayload(from: url) { uploadCapturedPayload(payload, fileType: 1) return } if let image = info[.originalImage] as? UIImage, let payload = TaskMediaLoader.imagePayload( from: image, fileName: "image_\(Int(Date().timeIntervalSince1970)).jpg" ) { uploadCapturedPayload(payload, fileType: 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 let previewData: Data? } /// 优先使用系统相册提供的原文件名。 static func suggestedFileName(from provider: NSItemProvider, fallback: String) -> String { guard let suggestedName = provider.suggestedName?.trimmingCharacters(in: .whitespacesAndNewlines), !suggestedName.isEmpty else { return fallback } if !URL(fileURLWithPath: suggestedName).pathExtension.isEmpty { return suggestedName } let fallbackExtension = URL(fileURLWithPath: fallback).pathExtension return fallbackExtension.isEmpty ? suggestedName : "\(suggestedName).\(fallbackExtension)" } 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 let identifier = provider.registeredTypeIdentifiers.first { UTType($0)?.conforms(to: .image) == true } ?? UTType.image.identifier provider.loadFileRepresentation(forTypeIdentifier: identifier) { url, error in if let error { continuation.resume(throwing: error) return } guard let url, let data = try? Data(contentsOf: url), !data.isEmpty else { continuation.resume(throwing: OSSUploadError.emptyFile) return } let ext = url.pathExtension.isEmpty ? "jpg" : url.pathExtension let fallback = "image_\(UUID().uuidString).\(ext)" let fileName = suggestedFileName(from: provider, fallback: fallback) let previewData = UIImage(data: data).flatMap(makePreviewData(from:)) continuation.resume(returning: Payload(data: data, fileName: fileName, previewData: previewData)) } } } 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 let fallback = "video_\(UUID().uuidString).\(ext)" let fileName = suggestedFileName(from: provider, fallback: fallback) continuation.resume(returning: Payload( data: data, fileName: fileName, previewData: makeVideoPreviewData(from: url) )) } } } /// 把相机照片转换为上传素材。 static func imagePayload(from image: UIImage, fileName: String) -> Payload? { guard let data = image.jpegData(compressionQuality: 0.9) else { return nil } return Payload(data: data, fileName: fileName, previewData: makePreviewData(from: image)) } /// 读取相机拍摄的视频及缩略图。 static func videoPayload(from url: URL) throws -> Payload { let data = try Data(contentsOf: url) guard !data.isEmpty else { throw OSSUploadError.emptyFile } let ext = url.pathExtension.isEmpty ? "mov" : url.pathExtension let fileName = url.lastPathComponent.isEmpty ? "video_\(UUID().uuidString).\(ext)" : url.lastPathComponent return Payload(data: data, fileName: fileName, previewData: makeVideoPreviewData(from: url)) } private static func makePreviewData(from image: UIImage) -> Data? { let maxLength: CGFloat = 600 let scale = min(1, maxLength / max(image.size.width, image.size.height)) let size = CGSize(width: image.size.width * scale, height: image.size.height * scale) let renderer = UIGraphicsImageRenderer(size: size) let preview = renderer.image { _ in image.draw(in: CGRect(origin: .zero, size: size)) } return preview.jpegData(compressionQuality: 0.72) } private static func makeVideoPreviewData(from url: URL) -> Data? { let generator = AVAssetImageGenerator(asset: AVAsset(url: url)) generator.appliesPreferredTrackTransform = true guard let image = try? generator.copyCGImage(at: .zero, actualTime: nil) else { return nil } return makePreviewData(from: UIImage(cgImage: image)) } }