完善任务流程、键盘适配与页面交互
This commit is contained in:
@ -6,7 +6,7 @@
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
/// 自定义居中弹窗的键盘避让基类,负责在键盘弹出时同步移动内容卡片。
|
||||
/// 自定义居中弹窗的兼容基类,键盘避让由全局 `IQKeyboardManager` 统一处理。
|
||||
class KeyboardAvoidingDialogView: UIView {
|
||||
|
||||
/// 需要避让键盘的弹窗内容视图,通常是白色卡片容器。
|
||||
@ -18,8 +18,6 @@ class KeyboardAvoidingDialogView: UIView {
|
||||
/// 内容视图与屏幕顶部、键盘顶部之间保留的最小距离。
|
||||
var keyboardAvoidingMinimumSpacing: CGFloat = AppSpacing.md
|
||||
|
||||
private var keyboardObservers: [NSObjectProtocol] = []
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
}
|
||||
@ -29,77 +27,4 @@ class KeyboardAvoidingDialogView: UIView {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
deinit {
|
||||
unregisterKeyboardObservers()
|
||||
}
|
||||
|
||||
override func didMoveToSuperview() {
|
||||
super.didMoveToSuperview()
|
||||
if superview == nil {
|
||||
unregisterKeyboardObservers()
|
||||
} else {
|
||||
registerKeyboardObservers()
|
||||
}
|
||||
}
|
||||
|
||||
private func registerKeyboardObservers() {
|
||||
unregisterKeyboardObservers()
|
||||
let center = NotificationCenter.default
|
||||
keyboardObservers = [
|
||||
center.addObserver(
|
||||
forName: UIResponder.keyboardWillChangeFrameNotification,
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { [weak self] notification in
|
||||
self?.handleKeyboardNotification(notification)
|
||||
},
|
||||
center.addObserver(
|
||||
forName: UIResponder.keyboardWillHideNotification,
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { [weak self] notification in
|
||||
self?.handleKeyboardNotification(notification)
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
private func unregisterKeyboardObservers() {
|
||||
keyboardObservers.forEach(NotificationCenter.default.removeObserver)
|
||||
keyboardObservers.removeAll()
|
||||
}
|
||||
|
||||
private func handleKeyboardNotification(_ notification: Notification) {
|
||||
layoutIfNeeded()
|
||||
guard let userInfo = notification.userInfo else { return }
|
||||
|
||||
let duration = (userInfo[UIResponder.keyboardAnimationDurationUserInfoKey] as? NSNumber)?.doubleValue ?? 0.25
|
||||
let curveRaw = (userInfo[UIResponder.keyboardAnimationCurveUserInfoKey] as? NSNumber)?.uintValue
|
||||
?? UInt(UIView.AnimationCurve.easeInOut.rawValue)
|
||||
let options = UIView.AnimationOptions(rawValue: curveRaw << 16)
|
||||
|
||||
keyboardAvoidingCenterYConstraint?.update(offset: keyboardAvoidanceOffset(from: userInfo))
|
||||
UIView.animate(withDuration: duration, delay: 0, options: options) {
|
||||
self.layoutIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
private func keyboardAvoidanceOffset(from userInfo: [AnyHashable: Any]) -> CGFloat {
|
||||
guard
|
||||
let contentView = keyboardAvoidingContentView,
|
||||
let keyboardFrameValue = userInfo[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue
|
||||
else {
|
||||
return 0
|
||||
}
|
||||
|
||||
let keyboardFrame = convert(keyboardFrameValue.cgRectValue, from: nil)
|
||||
guard keyboardFrame.minY < bounds.height else { return 0 }
|
||||
|
||||
let contentHeight = contentView.bounds.height
|
||||
let baseMinY = bounds.midY - contentHeight / 2
|
||||
let baseMaxY = bounds.midY + contentHeight / 2
|
||||
let desiredBottom = keyboardFrame.minY - keyboardAvoidingMinimumSpacing
|
||||
let neededShift = max(0, baseMaxY - desiredBottom)
|
||||
let maxShift = max(0, baseMinY - keyboardAvoidingMinimumSpacing)
|
||||
return -min(neededShift, maxShift)
|
||||
}
|
||||
}
|
||||
|
||||
@ -65,7 +65,8 @@ final class AllFunctionsViewController: BaseViewController {
|
||||
|
||||
collectionView.backgroundColor = .clear
|
||||
collectionView.alwaysBounceVertical = true
|
||||
collectionView.allowsSelection = false
|
||||
collectionView.allowsSelection = true
|
||||
collectionView.delegate = self
|
||||
collectionView.contentInset = UIEdgeInsets(
|
||||
top: AppSpacing.xs,
|
||||
left: 0,
|
||||
@ -131,20 +132,12 @@ final class AllFunctionsViewController: BaseViewController {
|
||||
private func makeDataSource() -> UICollectionViewDiffableDataSource<AllFunctionSection, AllFunctionListItem> {
|
||||
let dataSource = UICollectionViewDiffableDataSource<AllFunctionSection, AllFunctionListItem>(
|
||||
collectionView: collectionView
|
||||
) { [weak self] collectionView, indexPath, item in
|
||||
) { collectionView, indexPath, item in
|
||||
let cell = collectionView.dequeueReusableCell(
|
||||
withReuseIdentifier: AllFunctionMenuCell.reuseIdentifier,
|
||||
for: indexPath
|
||||
) as! AllFunctionMenuCell
|
||||
cell.apply(menu: item.menu, actionStyle: item.actionStyle)
|
||||
cell.onActionTap = {
|
||||
switch item.section {
|
||||
case .common:
|
||||
self?.viewModel.removeFromCommon(item.menu)
|
||||
case .more:
|
||||
self?.viewModel.addToCommon(item.menu)
|
||||
}
|
||||
}
|
||||
return cell
|
||||
}
|
||||
|
||||
@ -214,6 +207,21 @@ final class AllFunctionsViewController: BaseViewController {
|
||||
}
|
||||
}
|
||||
|
||||
extension AllFunctionsViewController: UICollectionViewDelegate {
|
||||
|
||||
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
|
||||
guard let item = dataSource.itemIdentifier(for: indexPath) else { return }
|
||||
collectionView.deselectItem(at: indexPath, animated: true)
|
||||
|
||||
switch item.section {
|
||||
case .common:
|
||||
viewModel.removeFromCommon(item.menu)
|
||||
case .more:
|
||||
viewModel.addToCommon(item.menu)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 全部功能页分区标题。
|
||||
private final class AllFunctionSectionHeaderView: UICollectionReusableView {
|
||||
|
||||
|
||||
@ -6,24 +6,29 @@
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
/// 全部功能页菜单卡片,支持右上角添加或移除操作。
|
||||
/// 全部功能页菜单卡片,整张卡片响应选择,右上角图标仅展示添加或移除状态。
|
||||
final class AllFunctionMenuCell: UICollectionViewCell {
|
||||
|
||||
static let reuseIdentifier = "AllFunctionMenuCell"
|
||||
|
||||
/// 卡片右上角展示的操作状态。
|
||||
enum ActionStyle {
|
||||
case none
|
||||
case add
|
||||
case remove
|
||||
}
|
||||
|
||||
var onActionTap: (() -> Void)?
|
||||
|
||||
private let cardView = UIView()
|
||||
private let contentStackView = UIStackView()
|
||||
private let iconView = UIImageView()
|
||||
private let titleLabel = UILabel()
|
||||
private let actionButton = UIButton(type: .system)
|
||||
private let actionImageView = UIImageView()
|
||||
|
||||
override var isHighlighted: Bool {
|
||||
didSet {
|
||||
cardView.alpha = isHighlighted ? 0.72 : 1
|
||||
}
|
||||
}
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
@ -35,26 +40,29 @@ final class AllFunctionMenuCell: UICollectionViewCell {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func prepareForReuse() {
|
||||
super.prepareForReuse()
|
||||
onActionTap = nil
|
||||
}
|
||||
|
||||
/// 更新菜单内容、操作状态及整张卡片的无障碍语义。
|
||||
func apply(menu: HomeMenuItem, actionStyle: ActionStyle) {
|
||||
iconView.image = UIImage(systemName: menu.iconName)
|
||||
titleLabel.text = menu.title
|
||||
accessibilityLabel = menu.title
|
||||
|
||||
switch actionStyle {
|
||||
case .none:
|
||||
actionButton.isHidden = true
|
||||
actionImageView.isHidden = true
|
||||
accessibilityTraits = []
|
||||
accessibilityHint = nil
|
||||
case .add:
|
||||
actionButton.isHidden = false
|
||||
actionButton.setImage(UIImage(systemName: "plus.circle.fill"), for: .normal)
|
||||
actionButton.tintColor = AppColor.primary
|
||||
actionImageView.isHidden = false
|
||||
actionImageView.image = UIImage(systemName: "plus.circle.fill")
|
||||
actionImageView.tintColor = AppColor.primary
|
||||
accessibilityTraits = .button
|
||||
accessibilityHint = "添加到常用应用"
|
||||
case .remove:
|
||||
actionButton.isHidden = false
|
||||
actionButton.setImage(UIImage(systemName: "minus.circle.fill"), for: .normal)
|
||||
actionButton.tintColor = AppColor.danger
|
||||
actionImageView.isHidden = false
|
||||
actionImageView.image = UIImage(systemName: "minus.circle.fill")
|
||||
actionImageView.tintColor = AppColor.danger
|
||||
accessibilityTraits = .button
|
||||
accessibilityHint = "移出常用应用"
|
||||
}
|
||||
}
|
||||
|
||||
@ -78,11 +86,13 @@ final class AllFunctionMenuCell: UICollectionViewCell {
|
||||
titleLabel.textAlignment = .center
|
||||
titleLabel.numberOfLines = 2
|
||||
|
||||
actionButton.addTarget(self, action: #selector(actionTapped), for: .touchUpInside)
|
||||
isAccessibilityElement = true
|
||||
actionImageView.contentMode = .scaleAspectFit
|
||||
actionImageView.isUserInteractionEnabled = false
|
||||
|
||||
contentView.addSubview(cardView)
|
||||
cardView.addSubview(contentStackView)
|
||||
cardView.addSubview(actionButton)
|
||||
cardView.addSubview(actionImageView)
|
||||
contentStackView.addArrangedSubview(iconView)
|
||||
contentStackView.addArrangedSubview(titleLabel)
|
||||
|
||||
@ -96,13 +106,9 @@ final class AllFunctionMenuCell: UICollectionViewCell {
|
||||
iconView.snp.makeConstraints { make in
|
||||
make.width.height.equalTo(26)
|
||||
}
|
||||
actionButton.snp.makeConstraints { make in
|
||||
actionImageView.snp.makeConstraints { make in
|
||||
make.top.trailing.equalToSuperview().inset(AppSpacing.xxs)
|
||||
make.width.height.equalTo(20)
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func actionTapped() {
|
||||
onActionTap?()
|
||||
}
|
||||
}
|
||||
|
||||
@ -13,12 +13,14 @@ final class LocationReportMapControlStack: UIView {
|
||||
var onZoomIn: (() -> Void)?
|
||||
var onZoomOut: (() -> Void)?
|
||||
var onRelocate: (() -> Void)?
|
||||
private weak var relocateButton: UIButton?
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
let zoomIn = makeButton(symbol: "plus", action: #selector(zoomInTapped))
|
||||
let zoomOut = makeButton(symbol: "minus", action: #selector(zoomOutTapped))
|
||||
let relocate = makeButton(symbol: "location.fill", action: #selector(relocateTapped))
|
||||
relocateButton = relocate
|
||||
|
||||
let zoomStack = UIStackView(arrangedSubviews: [zoomIn, zoomOut])
|
||||
zoomStack.axis = .vertical
|
||||
@ -44,6 +46,12 @@ final class LocationReportMapControlStack: UIView {
|
||||
}
|
||||
}
|
||||
|
||||
/// 更新定位按钮是否可用。
|
||||
func setRelocateEnabled(_ isEnabled: Bool) {
|
||||
relocateButton?.isEnabled = isEnabled
|
||||
relocateButton?.alpha = isEnabled ? 1 : 0.46
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -21,7 +21,7 @@ final class WildReportRiskMapViewController: BaseViewController {
|
||||
private let mapView = MKMapView()
|
||||
private let scenicCapsule = UIView()
|
||||
private let scenicNameLabel = UILabel()
|
||||
private let locateButton = UIButton(type: .system)
|
||||
private let mapControls = LocationReportMapControlStack()
|
||||
private let legendBar = UIStackView()
|
||||
private let detailContainerView = UIView()
|
||||
private let scrollView = UIScrollView()
|
||||
@ -88,7 +88,7 @@ final class WildReportRiskMapViewController: BaseViewController {
|
||||
mapContainerView.addSubview(mapView)
|
||||
|
||||
configureScenicCapsule()
|
||||
configureLocateButton()
|
||||
configureMapControls()
|
||||
configureLegendBar()
|
||||
configureDetailArea()
|
||||
refreshContent(animated: false)
|
||||
@ -108,9 +108,8 @@ final class WildReportRiskMapViewController: BaseViewController {
|
||||
make.top.leading.equalToSuperview().inset(12)
|
||||
make.height.equalTo(34)
|
||||
}
|
||||
locateButton.snp.makeConstraints { make in
|
||||
mapControls.snp.makeConstraints { make in
|
||||
make.top.trailing.equalToSuperview().inset(12)
|
||||
make.size.equalTo(44)
|
||||
}
|
||||
detailContainerView.snp.makeConstraints { make in
|
||||
make.height.greaterThanOrEqualTo(180)
|
||||
@ -154,18 +153,21 @@ final class WildReportRiskMapViewController: BaseViewController {
|
||||
}
|
||||
}
|
||||
|
||||
private func configureLocateButton() {
|
||||
locateButton.backgroundColor = UIColor.white.withAlphaComponent(0.96)
|
||||
locateButton.tintColor = AppColor.primary
|
||||
locateButton.setImage(UIImage(systemName: "location.fill"), for: .normal)
|
||||
locateButton.layer.cornerRadius = 22
|
||||
locateButton.layer.shadowColor = UIColor.black.cgColor
|
||||
locateButton.layer.shadowOpacity = 0.1
|
||||
locateButton.layer.shadowRadius = 8
|
||||
locateButton.layer.shadowOffset = CGSize(width: 0, height: 4)
|
||||
locateButton.accessibilityLabel = "回到我的位置"
|
||||
locateButton.addTarget(self, action: #selector(locateButtonTapped), for: .touchUpInside)
|
||||
mapContainerView.addSubview(locateButton)
|
||||
private func configureMapControls() {
|
||||
mapControls.layer.shadowColor = UIColor.black.cgColor
|
||||
mapControls.layer.shadowOpacity = 0.1
|
||||
mapControls.layer.shadowRadius = 8
|
||||
mapControls.layer.shadowOffset = CGSize(width: 0, height: 4)
|
||||
mapControls.onZoomIn = { [weak self] in
|
||||
self?.zoomMap(by: 0.5)
|
||||
}
|
||||
mapControls.onZoomOut = { [weak self] in
|
||||
self?.zoomMap(by: 2)
|
||||
}
|
||||
mapControls.onRelocate = { [weak self] in
|
||||
self?.focusCurrentLocation()
|
||||
}
|
||||
mapContainerView.addSubview(mapControls)
|
||||
}
|
||||
|
||||
private func configureLegendBar() {
|
||||
@ -226,8 +228,7 @@ final class WildReportRiskMapViewController: BaseViewController {
|
||||
@MainActor
|
||||
private func updateLocateButtonState() {
|
||||
let hasCurrentLocation = viewModel.markers.contains { $0.kind == .selfLocation }
|
||||
locateButton.isEnabled = hasCurrentLocation
|
||||
locateButton.alpha = hasCurrentLocation ? 1 : 0.46
|
||||
mapControls.setRelocateEnabled(hasCurrentLocation)
|
||||
}
|
||||
|
||||
private func syncMapAnnotations() {
|
||||
@ -325,6 +326,10 @@ final class WildReportRiskMapViewController: BaseViewController {
|
||||
}
|
||||
|
||||
private func makeRadarHeader(clue: WildReportRadarActiveClue) -> UIView {
|
||||
let container = UIStackView()
|
||||
container.axis = .vertical
|
||||
container.spacing = 6
|
||||
|
||||
let row = UIStackView()
|
||||
row.axis = .horizontal
|
||||
row.alignment = .top
|
||||
@ -348,21 +353,18 @@ final class WildReportRiskMapViewController: BaseViewController {
|
||||
let textStack = UIStackView()
|
||||
textStack.axis = .vertical
|
||||
textStack.spacing = 6
|
||||
var reportMetaText: String?
|
||||
if isReportClue(clue) {
|
||||
let title = WildReportUI.label("线索 ID:\(clue.id)", font: .systemFont(ofSize: 16, weight: .bold), color: AppColor.textPrimary, lines: 2)
|
||||
textStack.addArrangedSubview(title)
|
||||
let metaRow = UIStackView()
|
||||
metaRow.axis = .horizontal
|
||||
metaRow.spacing = 12
|
||||
var metaParts: [String] = []
|
||||
if let distance = clue.distanceText ?? clue.distance {
|
||||
metaRow.addArrangedSubview(WildReportUI.label("距离:距离您\(distance)", font: .systemFont(ofSize: 12), color: AppColor.textSecondary))
|
||||
metaParts.append("距离:距离您\(distance)")
|
||||
}
|
||||
if let updatedAt = clue.updatedAt {
|
||||
metaRow.addArrangedSubview(WildReportUI.label("时间:\(updatedAt)", font: .systemFont(ofSize: 12), color: AppColor.textSecondary))
|
||||
}
|
||||
if !metaRow.arrangedSubviews.isEmpty {
|
||||
textStack.addArrangedSubview(metaRow)
|
||||
metaParts.append("时间:\(updatedAt)")
|
||||
}
|
||||
reportMetaText = metaParts.isEmpty ? nil : metaParts.joined(separator: " ")
|
||||
} else {
|
||||
let title = WildReportUI.label(clue.displayTitle, font: .systemFont(ofSize: 16, weight: .bold), color: AppColor.textPrimary, lines: 2)
|
||||
textStack.addArrangedSubview(title)
|
||||
@ -380,7 +382,24 @@ final class WildReportRiskMapViewController: BaseViewController {
|
||||
row.addArrangedSubview(badge)
|
||||
textStack.setContentHuggingPriority(.defaultLow, for: .horizontal)
|
||||
badge.setContentHuggingPriority(.required, for: .horizontal)
|
||||
return row
|
||||
container.addArrangedSubview(row)
|
||||
|
||||
if let reportMetaText {
|
||||
let metaRow = UIStackView()
|
||||
metaRow.axis = .horizontal
|
||||
metaRow.spacing = 0
|
||||
let spacer = UIView()
|
||||
spacer.snp.makeConstraints { make in
|
||||
make.width.equalTo(56)
|
||||
}
|
||||
let metaLabel = WildReportUI.label(reportMetaText, font: .systemFont(ofSize: 12), color: AppColor.textSecondary)
|
||||
metaLabel.textAlignment = .right
|
||||
metaLabel.setContentCompressionResistancePriority(.required, for: .horizontal)
|
||||
metaRow.addArrangedSubview(spacer)
|
||||
metaRow.addArrangedSubview(metaLabel)
|
||||
container.addArrangedSubview(metaRow)
|
||||
}
|
||||
return container
|
||||
}
|
||||
|
||||
private func addPhotographerProfile(for clue: WildReportRadarActiveClue, to stack: UIStackView) {
|
||||
@ -888,22 +907,134 @@ final class WildReportRiskMapViewController: BaseViewController {
|
||||
return
|
||||
}
|
||||
guard let target = viewModel.navigateToSelectedMarker() else { return }
|
||||
let item = MKMapItem(placemark: MKPlacemark(coordinate: target.coordinate))
|
||||
item.name = target.title
|
||||
item.openInMaps(launchOptions: [
|
||||
MKLaunchOptionsDirectionsModeKey: MKLaunchOptionsDirectionsModeDriving
|
||||
])
|
||||
presentNavigationOptions(for: target)
|
||||
}
|
||||
|
||||
@objc private func shareTapped() {
|
||||
viewModel.shareSelectedClue()
|
||||
showLoading()
|
||||
Task { [weak self] in
|
||||
guard let self else { return }
|
||||
let payload = await self.viewModel.shareSelectedClue(api: self.api)
|
||||
await MainActor.run {
|
||||
self.hideLoading()
|
||||
guard let payload else { return }
|
||||
WeChatShareService.shareMiniProgram(payload, previewImage: UIImage(named: "report_share_cover")) { [weak self] result in
|
||||
self?.showShareResult(result)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func locateButtonTapped() {
|
||||
private func presentNavigationOptions(for target: WildReportMapMarker) {
|
||||
let options = availableNavigationOptions(for: target)
|
||||
let alert = UIAlertController(title: "选择导航软件", message: target.title, preferredStyle: .actionSheet)
|
||||
options.forEach { option in
|
||||
alert.addAction(UIAlertAction(title: option.title, style: .default) { [weak self] _ in
|
||||
self?.openNavigation(option)
|
||||
})
|
||||
}
|
||||
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
|
||||
if let popover = alert.popoverPresentationController {
|
||||
popover.sourceView = view
|
||||
popover.sourceRect = CGRect(x: view.bounds.midX, y: view.bounds.maxY - 80, width: 1, height: 1)
|
||||
}
|
||||
present(alert, animated: true)
|
||||
}
|
||||
|
||||
private func availableNavigationOptions(for target: WildReportMapMarker) -> [WildReportNavigationOption] {
|
||||
[
|
||||
WildReportNavigationOption(title: "Apple 地图", url: navigationURL(for: .apple, target: target)),
|
||||
WildReportNavigationOption(title: "高德地图", url: navigationURL(for: .amap, target: target)),
|
||||
WildReportNavigationOption(title: "百度地图", url: navigationURL(for: .baidu, target: target)),
|
||||
WildReportNavigationOption(title: "腾讯地图", url: navigationURL(for: .tencent, target: target)),
|
||||
WildReportNavigationOption(title: "Google Maps", url: navigationURL(for: .google, target: target))
|
||||
].compactMap { option in
|
||||
guard option.url != nil else { return nil }
|
||||
return option
|
||||
}
|
||||
}
|
||||
|
||||
private func openNavigation(_ option: WildReportNavigationOption) {
|
||||
guard let url = option.url else { return }
|
||||
UIApplication.shared.open(url) { [weak self] success in
|
||||
guard !success else { return }
|
||||
Task { @MainActor in
|
||||
self?.showToast("未安装\(option.title)或无法打开")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func navigationURL(for app: WildReportNavigationApp, target: WildReportMapMarker) -> URL? {
|
||||
let latitude = target.coordinate.latitude
|
||||
let longitude = target.coordinate.longitude
|
||||
let name = encodedNavigationText(target.title)
|
||||
switch app {
|
||||
case .apple:
|
||||
return URL(string: "http://maps.apple.com/?daddr=\(latitude),\(longitude)&dirflg=d&q=\(name)")
|
||||
case .amap:
|
||||
return URL(string: "iosamap://path?sourceApplication=suixinkan&dlat=\(latitude)&dlon=\(longitude)&dname=\(name)&dev=0&t=0")
|
||||
case .baidu:
|
||||
return URL(string: "baidumap://map/direction?destination=latlng:\(latitude),\(longitude)|name:\(name)&mode=driving&coord_type=wgs84")
|
||||
case .tencent:
|
||||
return URL(string: "qqmap://map/routeplan?type=drive&tocoord=\(latitude),\(longitude)&to=\(name)&referer=suixinkan")
|
||||
case .google:
|
||||
return URL(string: "comgooglemaps://?daddr=\(latitude),\(longitude)&directionsmode=driving")
|
||||
}
|
||||
}
|
||||
|
||||
private func encodedNavigationText(_ text: String) -> String {
|
||||
var allowed = CharacterSet.urlQueryAllowed
|
||||
allowed.remove(charactersIn: "&=?")
|
||||
return text.addingPercentEncoding(withAllowedCharacters: allowed) ?? text
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func showShareResult(_ result: WeChatShareResult) {
|
||||
switch result {
|
||||
case .success:
|
||||
showToast("分享成功")
|
||||
case .cancelled:
|
||||
showToast("已取消分享")
|
||||
case .notInstalled:
|
||||
showToast("请先安装微信")
|
||||
case .unsupported:
|
||||
showToast("当前微信版本不支持分享")
|
||||
case .invalidPayload(let message):
|
||||
showToast(message)
|
||||
case .sendFailed:
|
||||
showToast("微信分享发送失败")
|
||||
case .unknown:
|
||||
showToast("微信分享失败")
|
||||
}
|
||||
}
|
||||
|
||||
private func zoomMap(by multiplier: CLLocationDegrees) {
|
||||
let visibleRegion = mapView.region
|
||||
let span = MKCoordinateSpan(
|
||||
latitudeDelta: min(max(visibleRegion.span.latitudeDelta * multiplier, 0.001), 180),
|
||||
longitudeDelta: min(max(visibleRegion.span.longitudeDelta * multiplier, 0.001), 180)
|
||||
)
|
||||
mapView.setRegion(MKCoordinateRegion(center: visibleRegion.center, span: span), animated: true)
|
||||
}
|
||||
|
||||
private func focusCurrentLocation() {
|
||||
viewModel.focusCurrentLocation()
|
||||
}
|
||||
}
|
||||
|
||||
private struct WildReportNavigationOption {
|
||||
let title: String
|
||||
let url: URL?
|
||||
}
|
||||
|
||||
private enum WildReportNavigationApp {
|
||||
case apple
|
||||
case amap
|
||||
case baidu
|
||||
case tencent
|
||||
case google
|
||||
}
|
||||
|
||||
extension WildReportRiskMapViewController: MKMapViewDelegate {
|
||||
func mapView(_ mapView: MKMapView, viewFor annotation: MKAnnotation) -> MKAnnotationView? {
|
||||
guard let annotation = annotation as? WildReportMapAnnotation else { return nil }
|
||||
@ -1130,6 +1261,8 @@ private final class WildReportRiskLegendView: UIView {
|
||||
|
||||
/// 风险地图状态胶囊。
|
||||
private final class WildReportRiskStatusBadge: UILabel {
|
||||
private let contentInsets = UIEdgeInsets(top: 0, left: 10, bottom: 0, right: 10)
|
||||
|
||||
init(text: String, color: UIColor) {
|
||||
super.init(frame: .zero)
|
||||
self.text = text
|
||||
@ -1142,13 +1275,26 @@ private final class WildReportRiskStatusBadge: UILabel {
|
||||
numberOfLines = 1
|
||||
adjustsFontSizeToFitWidth = true
|
||||
minimumScaleFactor = 0.8
|
||||
setContentHuggingPriority(.required, for: .horizontal)
|
||||
setContentCompressionResistancePriority(.required, for: .horizontal)
|
||||
snp.makeConstraints { make in
|
||||
make.height.equalTo(24)
|
||||
make.width.greaterThanOrEqualTo(64)
|
||||
make.width.lessThanOrEqualTo(96)
|
||||
make.width.greaterThanOrEqualTo(52)
|
||||
}
|
||||
}
|
||||
|
||||
override var intrinsicContentSize: CGSize {
|
||||
let size = super.intrinsicContentSize
|
||||
return CGSize(
|
||||
width: size.width + contentInsets.left + contentInsets.right,
|
||||
height: max(24, size.height + contentInsets.top + contentInsets.bottom)
|
||||
)
|
||||
}
|
||||
|
||||
override func drawText(in rect: CGRect) {
|
||||
super.drawText(in: rect.inset(by: contentInsets))
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
|
||||
Reference in New Issue
Block a user