重构排队设置与变更日志交互,补充 Overlay 与资源,并将多页面主操作按钮统一到 UIButton Configuration,同步更新相关单元测试。 Co-authored-by: Cursor <cursoragent@cursor.com>
579 lines
23 KiB
Swift
579 lines
23 KiB
Swift
//
|
|
// 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.suggestedName,
|
|
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 suggestedFileName: String?, fallback: String) -> String {
|
|
guard let suggestedName = suggestedFileName?.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 {
|
|
let suggestedName = provider.suggestedName
|
|
return 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: suggestedName, 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
|
|
let suggestedName = provider.suggestedName
|
|
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: suggestedName, 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))
|
|
}
|
|
}
|