完善任务流程、键盘适配与页面交互
This commit is contained in:
@ -3,6 +3,7 @@
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import AVFoundation
|
||||
import PhotosUI
|
||||
import SnapKit
|
||||
import UIKit
|
||||
@ -108,6 +109,9 @@ final class TaskAddViewController: BaseViewController {
|
||||
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)
|
||||
@ -181,12 +185,14 @@ final class TaskAddViewController: BaseViewController {
|
||||
guard uploadTypeSheet == nil else { return }
|
||||
let sheet = TaskUploadTypeSheetView(frame: view.bounds)
|
||||
sheet.onChooseImage = { [weak self] in
|
||||
self?.hideUploadTypeSheet()
|
||||
self?.presentMediaPicker(isImage: true)
|
||||
self?.hideUploadTypeSheet {
|
||||
self?.presentLocalSourcePicker(isImage: true)
|
||||
}
|
||||
}
|
||||
sheet.onChooseVideo = { [weak self] in
|
||||
self?.hideUploadTypeSheet()
|
||||
self?.presentMediaPicker(isImage: false)
|
||||
self?.hideUploadTypeSheet {
|
||||
self?.presentLocalSourcePicker(isImage: false)
|
||||
}
|
||||
}
|
||||
sheet.onCancel = { [weak self] in
|
||||
self?.hideUploadTypeSheet()
|
||||
@ -197,13 +203,29 @@ final class TaskAddViewController: BaseViewController {
|
||||
sheet.show()
|
||||
}
|
||||
|
||||
private func hideUploadTypeSheet() {
|
||||
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())
|
||||
@ -214,6 +236,18 @@ final class TaskAddViewController: BaseViewController {
|
||||
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))
|
||||
@ -279,47 +313,97 @@ final class TaskAddViewController: BaseViewController {
|
||||
return
|
||||
}
|
||||
let totalCount = results.count
|
||||
for (index, result) in results.enumerated() {
|
||||
for result in results {
|
||||
let uploadTaskId = UUID().uuidString.replacingOccurrences(of: "-", with: "")
|
||||
let placeholder = viewModel.addUploadPlaceholder(
|
||||
fileName: "upload_\(uploadTaskId).\(isImage ? "jpg" : "mp4")",
|
||||
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)
|
||||
let fileURL = try await ossUploadService.uploadTaskFile(
|
||||
data: payload.data,
|
||||
fileName: payload.fileName,
|
||||
await uploadPayload(
|
||||
payload,
|
||||
fileType: isImage ? 2 : 1,
|
||||
uploadTaskId: uploadTaskId,
|
||||
totalCount: totalCount,
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func uploadCapturedPayload(_ payload: TaskMediaLoader.Payload, fileType: Int) {
|
||||
let scenicId = AppStore.shared.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 {
|
||||
@ -330,6 +414,32 @@ extension TaskAddViewController: PHPickerViewControllerDelegate {
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
@ -354,9 +464,24 @@ extension TaskAddViewController: UITextViewDelegate {
|
||||
|
||||
/// 从 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 {
|
||||
@ -369,21 +494,24 @@ enum TaskMediaLoader {
|
||||
|
||||
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"))
|
||||
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
|
||||
}
|
||||
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))
|
||||
}
|
||||
continuation.resume(throwing: OSSUploadError.unsupportedFileType)
|
||||
}
|
||||
}
|
||||
|
||||
@ -400,8 +528,49 @@ enum TaskMediaLoader {
|
||||
return
|
||||
}
|
||||
let ext = url.pathExtension.isEmpty ? "mp4" : url.pathExtension
|
||||
continuation.resume(returning: Payload(data: data, fileName: "video_\(UUID().uuidString).\(ext)"))
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user