完善任务流程、键盘适配与页面交互

This commit is contained in:
2026-07-10 15:56:15 +08:00
parent f88a85a807
commit ab5220e460
189 changed files with 16779 additions and 1038 deletions

View File

@ -18,10 +18,16 @@ final class CloudStoragePickForTaskViewController: BaseViewController {
private let searchField = UITextField()
private let pathScrollView = UIScrollView()
private let pathStack = UIStackView()
private let filterBar = UIStackView()
private let sortButton = UIButton(type: .system)
private let typeButton = UIButton(type: .system)
private let displayModeButton = UIButton(type: .system)
private let refreshControl = UIRefreshControl()
private var collectionView: UICollectionView!
private var dataSource: UICollectionViewDiffableDataSource<Int, Int>!
private let confirmButton = AppButton(title: "确定")
private let bottomBar = UIView()
private var isGridMode = true
///
init(importedFileIDs: Set<Int> = [], allowedFileType: Int? = nil) {
@ -35,7 +41,7 @@ final class CloudStoragePickForTaskViewController: BaseViewController {
}
override func setupNavigationBar() {
title = "云盘导入"
title = "相册云盘"
}
override func setupUI() {
@ -54,10 +60,26 @@ final class CloudStoragePickForTaskViewController: BaseViewController {
make.height.equalToSuperview()
}
configureMenuButton(sortButton, imageName: "arrow.up.arrow.down")
configureMenuButton(typeButton, imageName: "line.3.horizontal.decrease")
displayModeButton.tintColor = AppColor.textSecondary
displayModeButton.backgroundColor = AppColor.inputBackground
displayModeButton.layer.cornerRadius = AppRadius.xs
displayModeButton.addTarget(self, action: #selector(displayModeTapped), for: .touchUpInside)
filterBar.axis = .horizontal
filterBar.spacing = AppSpacing.sm
filterBar.distribution = .fill
filterBar.addArrangedSubview(sortButton)
filterBar.addArrangedSubview(typeButton)
filterBar.addArrangedSubview(displayModeButton)
collectionView = UICollectionView(frame: .zero, collectionViewLayout: makeLayout())
collectionView.backgroundColor = AppColor.pageBackground
collectionView.register(CloudPickCell.self, forCellWithReuseIdentifier: CloudPickCell.reuseIdentifier)
collectionView.delegate = self
collectionView.refreshControl = refreshControl
refreshControl.addTarget(self, action: #selector(refreshTriggered), for: .valueChanged)
dataSource = UICollectionViewDiffableDataSource<Int, Int>(collectionView: collectionView) {
[weak self] collectionView, indexPath, fileID in
@ -69,7 +91,8 @@ final class CloudStoragePickForTaskViewController: BaseViewController {
cell.apply(
file: file,
isSelected: self?.viewModel.isSelected(fileID) == true,
isImported: self?.viewModel.isImported(fileID) == true
isImported: self?.viewModel.isImported(fileID) == true,
isGridMode: self?.isGridMode == true
)
}
return cell
@ -78,6 +101,7 @@ final class CloudStoragePickForTaskViewController: BaseViewController {
bottomBar.backgroundColor = .white
view.addSubview(searchField)
view.addSubview(pathScrollView)
view.addSubview(filterBar)
view.addSubview(collectionView)
view.addSubview(bottomBar)
bottomBar.addSubview(confirmButton)
@ -94,6 +118,13 @@ final class CloudStoragePickForTaskViewController: BaseViewController {
make.leading.trailing.equalToSuperview().inset(AppSpacing.md)
make.height.equalTo(28)
}
filterBar.snp.makeConstraints { make in
make.top.equalTo(pathScrollView.snp.bottom).offset(AppSpacing.sm)
make.leading.trailing.equalToSuperview().inset(AppSpacing.md)
make.height.equalTo(36)
}
sortButton.snp.makeConstraints { make in make.width.equalTo(typeButton) }
displayModeButton.snp.makeConstraints { make in make.width.equalTo(36) }
bottomBar.snp.makeConstraints { make in
make.leading.trailing.bottom.equalToSuperview()
}
@ -101,7 +132,7 @@ final class CloudStoragePickForTaskViewController: BaseViewController {
make.edges.equalToSuperview().inset(AppSpacing.md)
}
collectionView.snp.makeConstraints { make in
make.top.equalTo(pathScrollView.snp.bottom).offset(AppSpacing.sm)
make.top.equalTo(filterBar.snp.bottom).offset(AppSpacing.md)
make.leading.trailing.equalToSuperview()
make.bottom.equalTo(bottomBar.snp.top)
}
@ -125,15 +156,24 @@ final class CloudStoragePickForTaskViewController: BaseViewController {
@MainActor
private func applyViewModel() {
rebuildPathButtons()
rebuildFilterMenus()
var snapshot = NSDiffableDataSourceSnapshot<Int, Int>()
snapshot.appendSections([0])
snapshot.appendItems(viewModel.files.map(\.id))
let existingItems = Set(dataSource.snapshot().itemIdentifiers)
let itemsToReconfigure = snapshot.itemIdentifiers.filter(existingItems.contains)
snapshot.reconfigureItems(itemsToReconfigure)
dataSource.apply(snapshot, animatingDifferences: false)
let selectedCount = viewModel.selectedFileList.count
confirmButton.setTitle(selectedCount > 0 ? "确认选择(\(selectedCount))" : "确认选择", for: .normal)
if viewModel.isLoading && viewModel.files.isEmpty {
showLoading()
} else {
hideLoading()
}
if !viewModel.isRefreshing {
refreshControl.endRefreshing()
}
}
private func rebuildPathButtons() {
@ -160,26 +200,81 @@ final class CloudStoragePickForTaskViewController: BaseViewController {
}
@objc private func confirmTapped() {
guard !viewModel.selectedFileList.isEmpty else {
showToast("请至少选择一个文件")
return
}
onConfirmed?(viewModel.selectedFileList)
navigationController?.popViewController(animated: true)
}
@objc private func refreshTriggered() {
Task { await viewModel.refresh(api: taskAPI) }
}
@objc private func displayModeTapped() {
isGridMode.toggle()
collectionView.setCollectionViewLayout(makeLayout(), animated: true)
applyViewModel()
}
private func configureMenuButton(_ button: UIButton, imageName: String) {
button.tintColor = AppColor.textSecondary
button.backgroundColor = AppColor.inputBackground
button.layer.cornerRadius = AppRadius.xs
button.contentHorizontalAlignment = .leading
button.titleLabel?.font = .app(.body)
button.setTitleColor(AppColor.textPrimary, for: .normal)
button.setImage(UIImage(systemName: imageName), for: .normal)
button.semanticContentAttribute = .forceRightToLeft
button.showsMenuAsPrimaryAction = true
}
private func rebuildFilterMenus() {
let sortItems = [(1, "创建时间顺序"), (2, "创建时间倒序")]
sortButton.setTitle(sortItems.first(where: { $0.0 == viewModel.sortType })?.1 ?? "排序", for: .normal)
sortButton.menu = UIMenu(children: sortItems.map { value, title in
UIAction(title: title, state: value == viewModel.sortType ? .on : .off) { [weak self] _ in
guard let self else { return }
self.viewModel.updateSortType(value)
Task { await self.viewModel.refresh(api: self.taskAPI) }
}
})
let typeItems = [(0, "全部"), (2, "图片"), (1, "视频"), (99, "文件夹")]
typeButton.setTitle(typeItems.first(where: { $0.0 == viewModel.filterType })?.1 ?? "全部", for: .normal)
typeButton.menu = UIMenu(children: typeItems.map { value, title in
UIAction(title: title, state: value == viewModel.filterType ? .on : .off) { [weak self] _ in
guard let self else { return }
self.viewModel.updateFilterType(value)
Task { await self.viewModel.refresh(api: self.taskAPI) }
}
})
displayModeButton.setImage(
UIImage(systemName: isGridMode ? "list.bullet" : "square.grid.2x2"),
for: .normal
)
displayModeButton.accessibilityLabel = isGridMode ? "列表显示" : "宫格显示"
}
private func makeLayout() -> UICollectionViewCompositionalLayout {
UICollectionViewCompositionalLayout { _, environment in
let columns = 3
let isGridMode = isGridMode
return UICollectionViewCompositionalLayout { _, environment in
let columns = isGridMode ? 2 : 1
let spacing: CGFloat = 8
let inset = AppSpacing.md * 2
let availableWidth = environment.container.effectiveContentSize.width - inset
let totalSpacing = spacing * CGFloat(columns - 1)
let itemWidth = max(0, (availableWidth - totalSpacing) / CGFloat(columns))
let itemHeight = isGridMode ? itemWidth + 38 : 72
let itemSize = NSCollectionLayoutSize(
widthDimension: .absolute(itemWidth),
heightDimension: .absolute(itemWidth)
heightDimension: .absolute(itemHeight)
)
let item = NSCollectionLayoutItem(layoutSize: itemSize)
let groupSize = NSCollectionLayoutSize(
widthDimension: .fractionalWidth(1),
heightDimension: .absolute(itemWidth)
heightDimension: .absolute(itemHeight)
)
let group = NSCollectionLayoutGroup.horizontal(
layoutSize: groupSize,
@ -297,12 +392,37 @@ private final class CloudPickCell: UICollectionViewCell {
contentView.alpha = 1
}
func apply(file: CloudFile, isSelected: Bool, isImported: Bool) {
func apply(file: CloudFile, isSelected: Bool, isImported: Bool, isGridMode: Bool) {
nameLabel.text = file.name
contentView.layer.borderWidth = isSelected ? 2 : 0
contentView.layer.borderColor = AppColor.primary.cgColor
badgeView.isHidden = !isSelected
if isGridMode {
imageView.snp.remakeConstraints { make in
make.top.leading.trailing.equalToSuperview()
make.height.equalTo(imageView.snp.width)
}
nameLabel.textAlignment = .center
nameLabel.numberOfLines = 2
nameLabel.snp.remakeConstraints { make in
make.top.equalTo(imageView.snp.bottom).offset(AppSpacing.xxs)
make.leading.trailing.bottom.equalToSuperview().inset(AppSpacing.xxs)
}
} else {
imageView.snp.remakeConstraints { make in
make.top.bottom.leading.equalToSuperview()
make.width.equalTo(imageView.snp.height)
}
nameLabel.textAlignment = .left
nameLabel.numberOfLines = 2
nameLabel.snp.remakeConstraints { make in
make.leading.equalTo(imageView.snp.trailing).offset(AppSpacing.sm)
make.trailing.equalToSuperview().inset(AppSpacing.sm)
make.centerY.equalToSuperview()
}
}
if file.isFolder {
contentView.alpha = 1
imageView.image = nil

View File

@ -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))
}
}

View File

@ -3,6 +3,7 @@
// suixinkan
//
import AVFoundation
import Kingfisher
import SnapKit
import UIKit
@ -139,6 +140,9 @@ final class TaskAddMediaSectionView: UIView {
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
snapshot.appendSections([.main])
snapshot.appendItems(files.map { Item.file($0.id) } + [.add])
let existingItems = Set(dataSource.snapshot().itemIdentifiers)
let itemsToReconfigure = snapshot.itemIdentifiers.filter(existingItems.contains)
snapshot.reconfigureItems(itemsToReconfigure)
dataSource.apply(snapshot, animatingDifferences: false)
updateCollectionHeight(itemCount: files.count + 1)
}
@ -320,7 +324,10 @@ final class TaskAddMediaCell: UICollectionViewCell {
func apply(file: TaskFileItem) {
let urlString = file.previewURLString
if let url = URL(string: urlString), !urlString.isEmpty {
if let localPreviewData = file.localPreviewData,
let localImage = UIImage(data: localPreviewData) {
imageView.image = localImage
} else if let url = URL(string: urlString), !urlString.isEmpty {
imageView.kf.setImage(with: url)
} else {
imageView.image = UIImage(systemName: "photo")
@ -391,6 +398,7 @@ final class TaskPrioritySectionView: UIView {
var onUrgentHourChange: ((Int) -> Void)?
var onCustomHourTextChange: ((String) -> Void)?
var onShowMessage: ((String) -> Void)?
private let titleLabel = UILabel()
private let noneButton = UIButton(type: .system)
@ -401,6 +409,8 @@ final class TaskPrioritySectionView: UIView {
private let optionRow = UIStackView()
private let customRow = UIStackView()
private let stackView = UIStackView()
private var customHourText = ""
private var lastUrgentHour = 0
override init(frame: CGRect) {
super.init(frame: frame)
@ -426,6 +436,7 @@ final class TaskPrioritySectionView: UIView {
customField.layer.cornerRadius = AppRadius.sm
customField.layer.borderWidth = 1
customField.layer.borderColor = AppColor.border.cgColor
customField.delegate = self
customField.leftView = UIView(frame: CGRect(x: 0, y: 0, width: AppSpacing.sm, height: 1))
customField.leftViewMode = .always
customField.addTarget(self, action: #selector(customFieldChanged), for: .editingChanged)
@ -480,15 +491,21 @@ final class TaskPrioritySectionView: UIView {
func apply(urgentHour: Int) {
let isCustom = urgentHour > 0 && urgentHour != 2
let wasCustom = lastUrgentHour > 0 && lastUrgentHour != 2
styleOption(noneButton, selected: urgentHour == 0)
styleOption(twoHourButton, selected: urgentHour == 2)
styleOption(customButton, selected: isCustom)
customRow.isHidden = !isCustom
if isCustom {
customField.text = "\(urgentHour)"
if !wasCustom || urgentHour != lastUrgentHour {
customHourText = "\(urgentHour)"
}
customField.text = customHourText
} else {
customHourText = ""
customField.text = ""
}
lastUrgentHour = urgentHour
}
private func configureOptionButton(_ button: UIButton, title: String) {
@ -506,14 +523,31 @@ final class TaskPrioritySectionView: UIView {
@objc private func noneTapped() { onUrgentHourChange?(0) }
@objc private func twoHourTapped() { onUrgentHourChange?(2) }
@objc private func customTapped() {
guard lastUrgentHour <= 0 || lastUrgentHour == 2 else { return }
onUrgentHourChange?(1)
onCustomHourTextChange?("1")
}
@objc private func customFieldChanged() {
onCustomHourTextChange?(customField.text ?? "")
customHourText = customField.text ?? ""
onCustomHourTextChange?(customHourText)
}
@objc private func customConfirmTapped() {
onCustomHourTextChange?(customField.text ?? "")
guard let hour = Int(customHourText), hour > 0 else {
onShowMessage?("请输入有效的正整数")
return
}
onCustomHourTextChange?(customHourText)
onShowMessage?("加急\(hour)小时")
}
}
extension TaskPrioritySectionView: UITextFieldDelegate {
func textField(
_ textField: UITextField,
shouldChangeCharactersIn range: NSRange,
replacementString string: String
) -> Bool {
string.isEmpty || string.allSatisfy(\.isNumber)
}
}
@ -573,7 +607,9 @@ final class TaskFileRemarkDialogView: KeyboardAvoidingDialogView {
private let containerView = UIView()
private let titleLabel = UILabel()
private let previewContainerView = UIView()
private let previewView = UIImageView()
private let videoView = TaskRemarkVideoView()
private let remarkTitleLabel = UILabel()
private let textView = UITextView()
private let placeholderLabel = UILabel()
@ -599,6 +635,10 @@ final class TaskFileRemarkDialogView: KeyboardAvoidingDialogView {
previewView.layer.cornerRadius = AppRadius.lg
previewView.backgroundColor = AppColor.inputBackground
previewContainerView.backgroundColor = AppColor.inputBackground
previewContainerView.layer.cornerRadius = AppRadius.lg
previewContainerView.clipsToBounds = true
remarkTitleLabel.text = "备注信息"
remarkTitleLabel.font = .app(.caption)
remarkTitleLabel.textColor = AppColor.textPrimary
@ -640,7 +680,9 @@ final class TaskFileRemarkDialogView: KeyboardAvoidingDialogView {
addSubview(containerView)
containerView.addSubview(titleLabel)
containerView.addSubview(previewView)
containerView.addSubview(previewContainerView)
previewContainerView.addSubview(previewView)
previewContainerView.addSubview(videoView)
containerView.addSubview(remarkTitleLabel)
containerView.addSubview(countLabel)
containerView.addSubview(textView)
@ -655,13 +697,15 @@ final class TaskFileRemarkDialogView: KeyboardAvoidingDialogView {
titleLabel.snp.makeConstraints { make in
make.top.leading.equalToSuperview().offset(AppSpacing.md)
}
previewView.snp.makeConstraints { make in
previewContainerView.snp.makeConstraints { make in
make.top.equalTo(titleLabel.snp.bottom).offset(AppSpacing.sm)
make.leading.trailing.equalToSuperview().inset(AppSpacing.md)
make.height.equalTo(previewView.snp.width)
make.height.equalTo(previewContainerView.snp.width)
}
previewView.snp.makeConstraints { make in make.edges.equalToSuperview() }
videoView.snp.makeConstraints { make in make.edges.equalToSuperview() }
remarkTitleLabel.snp.makeConstraints { make in
make.top.equalTo(previewView.snp.bottom).offset(AppSpacing.md)
make.top.equalTo(previewContainerView.snp.bottom).offset(AppSpacing.md)
make.leading.equalToSuperview().offset(AppSpacing.md)
}
countLabel.snp.makeConstraints { make in
@ -701,8 +745,23 @@ final class TaskFileRemarkDialogView: KeyboardAvoidingDialogView {
textView.text = text
countLabel.text = "\(text.count)/50"
placeholderLabel.isHidden = !text.isEmpty
if file.fileType == 1,
let url = URL(string: file.uploadFileUrl ?? file.cloudFile?.fileUrl ?? ""),
!url.absoluteString.isEmpty {
previewView.isHidden = true
videoView.isHidden = false
videoView.configure(url: url)
return
}
videoView.stop()
videoView.isHidden = true
previewView.isHidden = false
let urlString = file.previewURLString
if let url = URL(string: urlString), !urlString.isEmpty {
if let localPreviewData = file.localPreviewData,
let localImage = UIImage(data: localPreviewData) {
previewView.image = localImage
} else if let url = URL(string: urlString), !urlString.isEmpty {
previewView.kf.setImage(with: url)
} else {
previewView.image = UIImage(systemName: "photo")
@ -710,13 +769,82 @@ final class TaskFileRemarkDialogView: KeyboardAvoidingDialogView {
}
}
@objc private func cancelTapped() { onCancel?() }
@objc private func confirmTapped() { onConfirm?() }
@objc private func cancelTapped() {
videoView.stop()
onCancel?()
}
@objc private func confirmTapped() {
videoView.stop()
onConfirm?()
}
///
var inputText: String { textView.text ?? "" }
}
///
final class TaskRemarkVideoView: UIView {
private let playerLayer = AVPlayerLayer()
private let playButton = UIButton(type: .system)
private var player: AVPlayer?
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = .black
layer.addSublayer(playerLayer)
playButton.setImage(UIImage(systemName: "play.circle.fill"), for: .normal)
playButton.tintColor = .white
playButton.addTarget(self, action: #selector(togglePlayback), for: .touchUpInside)
addSubview(playButton)
playButton.snp.makeConstraints { make in
make.center.equalToSuperview()
make.width.height.equalTo(52)
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func layoutSubviews() {
super.layoutSubviews()
playerLayer.frame = bounds
}
///
func configure(url: URL) {
stop()
let player = AVPlayer(url: url)
self.player = player
playerLayer.player = player
playerLayer.videoGravity = .resizeAspect
playButton.isHidden = false
}
///
func stop() {
player?.pause()
player = nil
playerLayer.player = nil
playButton.isHidden = false
}
@objc private func togglePlayback() {
guard let player else { return }
if player.timeControlStatus == .playing {
player.pause()
playButton.isHidden = false
} else {
player.play()
playButton.isHidden = true
}
}
}
extension TaskFileRemarkDialogView: UITextViewDelegate {
func textViewDidChange(_ textView: UITextView) {
if textView.text.count > 50 {