1088 lines
39 KiB
Swift
1088 lines
39 KiB
Swift
//
|
||
// TaskAddViews.swift
|
||
// suixinkan
|
||
//
|
||
|
||
import AVFoundation
|
||
import Kingfisher
|
||
import SnapKit
|
||
import UIKit
|
||
|
||
/// 添加任务素材网格区。
|
||
final class TaskAddMediaSectionView: UIView {
|
||
|
||
var onCloudImport: (() -> Void)?
|
||
var onLocalImport: (() -> Void)?
|
||
var onFileTap: ((TaskFileItem) -> Void)?
|
||
var onDeleteFile: ((TaskFileItem) -> Void)?
|
||
|
||
private var files: [TaskFileItem] = []
|
||
private var collectionHeightConstraint: Constraint?
|
||
private let gridColumns = 3
|
||
private let gridSpacing: CGFloat = 8
|
||
|
||
private enum Section { case main }
|
||
private enum Item: Hashable { case add; case file(String) }
|
||
|
||
private lazy var collectionView: UICollectionView = {
|
||
let layout = UICollectionViewCompositionalLayout { [weak self] _, environment in
|
||
let columns = self?.gridColumns ?? 3
|
||
let spacing = self?.gridSpacing ?? 8
|
||
let availableWidth = environment.container.effectiveContentSize.width
|
||
let totalSpacing = spacing * CGFloat(columns - 1)
|
||
let itemWidth = max(0, (availableWidth - totalSpacing) / CGFloat(columns))
|
||
let itemHeight = itemWidth / 1.77
|
||
let itemSize = NSCollectionLayoutSize(
|
||
widthDimension: .absolute(itemWidth),
|
||
heightDimension: .absolute(itemHeight)
|
||
)
|
||
let item = NSCollectionLayoutItem(layoutSize: itemSize)
|
||
let groupSize = NSCollectionLayoutSize(
|
||
widthDimension: .fractionalWidth(1),
|
||
heightDimension: .absolute(itemHeight)
|
||
)
|
||
let group = NSCollectionLayoutGroup.horizontal(
|
||
layoutSize: groupSize,
|
||
repeatingSubitem: item,
|
||
count: columns
|
||
)
|
||
group.interItemSpacing = .fixed(spacing)
|
||
let section = NSCollectionLayoutSection(group: group)
|
||
section.interGroupSpacing = spacing
|
||
return section
|
||
}
|
||
let view = UICollectionView(frame: .zero, collectionViewLayout: layout)
|
||
view.backgroundColor = .clear
|
||
view.isScrollEnabled = false
|
||
return view
|
||
}()
|
||
|
||
private lazy var dataSource = UICollectionViewDiffableDataSource<Section, Item>(collectionView: collectionView) {
|
||
[weak self] collectionView, indexPath, item in
|
||
switch item {
|
||
case .add:
|
||
let cell = collectionView.dequeueReusableCell(
|
||
withReuseIdentifier: TaskAddAddMediaCell.reuseIdentifier,
|
||
for: indexPath
|
||
) as! TaskAddAddMediaCell
|
||
return cell
|
||
case .file(let id):
|
||
let cell = collectionView.dequeueReusableCell(
|
||
withReuseIdentifier: TaskAddMediaCell.reuseIdentifier,
|
||
for: indexPath
|
||
) as! TaskAddMediaCell
|
||
if let file = self?.files.first(where: { $0.id == id }) {
|
||
cell.apply(file: file)
|
||
cell.onDelete = { self?.onDeleteFile?(file) }
|
||
}
|
||
return cell
|
||
}
|
||
}
|
||
|
||
private let titleLabel = UILabel()
|
||
private let tipsLabel = UILabel()
|
||
private let cloudButton = UIButton(type: .system)
|
||
|
||
override init(frame: CGRect) {
|
||
super.init(frame: frame)
|
||
backgroundColor = .clear
|
||
|
||
titleLabel.text = "添加图片"
|
||
titleLabel.font = .app(.bodyMedium)
|
||
titleLabel.textColor = AppColor.textPrimary
|
||
|
||
tipsLabel.text = "(点击图片或视频可添加备注)"
|
||
tipsLabel.font = .app(.caption)
|
||
tipsLabel.textColor = AppColor.textPrimary
|
||
tipsLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
||
|
||
cloudButton.setTitle("云盘导入", for: .normal)
|
||
cloudButton.setTitleColor(AppColor.primary, for: .normal)
|
||
cloudButton.titleLabel?.font = .app(.caption)
|
||
cloudButton.addTarget(self, action: #selector(cloudTapped), for: .touchUpInside)
|
||
cloudButton.setContentCompressionResistancePriority(.required, for: .horizontal)
|
||
|
||
collectionView.register(TaskAddMediaCell.self, forCellWithReuseIdentifier: TaskAddMediaCell.reuseIdentifier)
|
||
collectionView.register(TaskAddAddMediaCell.self, forCellWithReuseIdentifier: TaskAddAddMediaCell.reuseIdentifier)
|
||
collectionView.delegate = self
|
||
|
||
addSubview(titleLabel)
|
||
addSubview(tipsLabel)
|
||
addSubview(cloudButton)
|
||
addSubview(collectionView)
|
||
|
||
titleLabel.snp.makeConstraints { make in
|
||
make.top.leading.equalToSuperview()
|
||
}
|
||
tipsLabel.snp.makeConstraints { make in
|
||
make.leading.equalTo(titleLabel.snp.trailing).offset(AppSpacing.xxs)
|
||
make.centerY.equalTo(titleLabel)
|
||
make.trailing.lessThanOrEqualTo(cloudButton.snp.leading).offset(-AppSpacing.xs)
|
||
}
|
||
cloudButton.snp.makeConstraints { make in
|
||
make.trailing.equalToSuperview()
|
||
make.centerY.equalTo(titleLabel)
|
||
}
|
||
collectionView.snp.makeConstraints { make in
|
||
make.top.equalTo(titleLabel.snp.bottom).offset(AppSpacing.sm)
|
||
make.leading.trailing.bottom.equalToSuperview()
|
||
collectionHeightConstraint = make.height.equalTo(88).constraint
|
||
}
|
||
}
|
||
|
||
@available(*, unavailable)
|
||
required init?(coder: NSCoder) {
|
||
fatalError("init(coder:) has not been implemented")
|
||
}
|
||
|
||
func apply(files: [TaskFileItem]) {
|
||
self.files = files
|
||
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)
|
||
}
|
||
|
||
override func layoutSubviews() {
|
||
super.layoutSubviews()
|
||
updateCollectionHeight(itemCount: files.count + 1)
|
||
}
|
||
|
||
private func updateCollectionHeight(itemCount: Int) {
|
||
let availableWidth = collectionView.bounds.width
|
||
guard availableWidth > 0 else { return }
|
||
let itemWidth = max(0, (availableWidth - gridSpacing * CGFloat(gridColumns - 1)) / CGFloat(gridColumns))
|
||
let itemHeight = itemWidth / 1.77
|
||
let rows = max(1, Int(ceil(Double(itemCount) / Double(gridColumns))))
|
||
let height = itemHeight * CGFloat(rows) + gridSpacing * CGFloat(rows - 1)
|
||
collectionHeightConstraint?.update(offset: max(88, height))
|
||
}
|
||
|
||
@objc private func cloudTapped() {
|
||
onCloudImport?()
|
||
}
|
||
}
|
||
|
||
extension TaskAddMediaSectionView: UICollectionViewDelegate {
|
||
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
|
||
guard let item = dataSource.itemIdentifier(for: indexPath) else { return }
|
||
switch item {
|
||
case .add:
|
||
onLocalImport?()
|
||
case .file(let id):
|
||
guard let file = files.first(where: { $0.id == id }) else { return }
|
||
onFileTap?(file)
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 圆形上传进度视图,对齐 Android 上传素材 cell 的进度表现。
|
||
final class TaskCircularProgressView: UIView {
|
||
|
||
private let trackLayer = CAShapeLayer()
|
||
private let progressLayer = CAShapeLayer()
|
||
|
||
override init(frame: CGRect) {
|
||
super.init(frame: frame)
|
||
isHidden = true
|
||
layer.addSublayer(trackLayer)
|
||
layer.addSublayer(progressLayer)
|
||
trackLayer.fillColor = UIColor.clear.cgColor
|
||
trackLayer.strokeColor = UIColor.white.withAlphaComponent(0.35).cgColor
|
||
trackLayer.lineWidth = 3
|
||
progressLayer.fillColor = UIColor.clear.cgColor
|
||
progressLayer.strokeColor = UIColor.white.cgColor
|
||
progressLayer.lineCap = .round
|
||
progressLayer.lineWidth = 3
|
||
progressLayer.strokeEnd = 0
|
||
}
|
||
|
||
@available(*, unavailable)
|
||
required init?(coder: NSCoder) {
|
||
fatalError("init(coder:) has not been implemented")
|
||
}
|
||
|
||
override func layoutSubviews() {
|
||
super.layoutSubviews()
|
||
let radius = min(bounds.width, bounds.height) / 2 - progressLayer.lineWidth / 2
|
||
let path = UIBezierPath(
|
||
arcCenter: CGPoint(x: bounds.midX, y: bounds.midY),
|
||
radius: radius,
|
||
startAngle: -.pi / 2,
|
||
endAngle: .pi * 1.5,
|
||
clockwise: true
|
||
)
|
||
trackLayer.frame = bounds
|
||
progressLayer.frame = bounds
|
||
trackLayer.path = path.cgPath
|
||
progressLayer.path = path.cgPath
|
||
}
|
||
|
||
/// 设置 0...1 的上传进度。
|
||
func setProgress(_ progress: CGFloat) {
|
||
progressLayer.strokeEnd = min(1, max(0, progress))
|
||
}
|
||
}
|
||
|
||
/// 素材缩略图 cell。
|
||
final class TaskAddMediaCell: UICollectionViewCell {
|
||
|
||
static let reuseIdentifier = "TaskAddMediaCell"
|
||
|
||
var onDelete: (() -> Void)?
|
||
|
||
private let imageView = UIImageView()
|
||
private let overlayView = UIView()
|
||
private let progressRingView = TaskCircularProgressView()
|
||
private let progressLabel = UILabel()
|
||
private let deleteButton = UIButton(type: .system)
|
||
private let playIconView = UIImageView()
|
||
private let sourceBadgeLabel = UILabel()
|
||
|
||
override init(frame: CGRect) {
|
||
super.init(frame: frame)
|
||
contentView.backgroundColor = AppColor.inputBackground
|
||
contentView.layer.cornerRadius = AppRadius.sm
|
||
contentView.clipsToBounds = true
|
||
|
||
imageView.contentMode = .scaleAspectFill
|
||
imageView.clipsToBounds = true
|
||
|
||
overlayView.backgroundColor = UIColor.black.withAlphaComponent(0.2)
|
||
overlayView.isHidden = true
|
||
|
||
progressLabel.font = .app(.captionMedium)
|
||
progressLabel.textColor = .white
|
||
progressLabel.textAlignment = .center
|
||
|
||
deleteButton.setImage(UIImage(systemName: "xmark.circle.fill"), for: .normal)
|
||
deleteButton.tintColor = .white
|
||
deleteButton.addTarget(self, action: #selector(deleteTapped), for: .touchUpInside)
|
||
|
||
playIconView.image = UIImage(systemName: "play.circle.fill")
|
||
playIconView.tintColor = .white
|
||
playIconView.isHidden = true
|
||
|
||
sourceBadgeLabel.font = .app(.caption)
|
||
sourceBadgeLabel.textColor = .white
|
||
sourceBadgeLabel.textAlignment = .center
|
||
sourceBadgeLabel.backgroundColor = UIColor.black.withAlphaComponent(0.5)
|
||
sourceBadgeLabel.layer.cornerRadius = AppRadius.xs
|
||
sourceBadgeLabel.clipsToBounds = true
|
||
sourceBadgeLabel.isHidden = true
|
||
|
||
contentView.addSubview(imageView)
|
||
contentView.addSubview(overlayView)
|
||
contentView.addSubview(sourceBadgeLabel)
|
||
contentView.addSubview(playIconView)
|
||
contentView.addSubview(progressRingView)
|
||
contentView.addSubview(progressLabel)
|
||
contentView.addSubview(deleteButton)
|
||
|
||
imageView.snp.makeConstraints { make in make.edges.equalToSuperview() }
|
||
overlayView.snp.makeConstraints { make in make.edges.equalToSuperview() }
|
||
sourceBadgeLabel.snp.makeConstraints { make in
|
||
make.leading.bottom.equalToSuperview().inset(AppSpacing.xxs)
|
||
make.height.equalTo(18)
|
||
make.width.greaterThanOrEqualTo(34)
|
||
}
|
||
playIconView.snp.makeConstraints { make in
|
||
make.center.equalToSuperview()
|
||
make.width.height.equalTo(24)
|
||
}
|
||
progressRingView.snp.makeConstraints { make in
|
||
make.centerX.equalToSuperview()
|
||
make.centerY.equalToSuperview().offset(-10)
|
||
make.width.height.equalTo(28)
|
||
}
|
||
progressLabel.snp.makeConstraints { make in
|
||
make.top.equalTo(progressRingView.snp.bottom).offset(2)
|
||
make.centerX.equalToSuperview()
|
||
}
|
||
deleteButton.snp.makeConstraints { make in
|
||
make.top.trailing.equalToSuperview().inset(AppSpacing.xxs)
|
||
make.width.height.equalTo(20)
|
||
}
|
||
}
|
||
|
||
@available(*, unavailable)
|
||
required init?(coder: NSCoder) {
|
||
fatalError("init(coder:) has not been implemented")
|
||
}
|
||
|
||
override func prepareForReuse() {
|
||
super.prepareForReuse()
|
||
onDelete = nil
|
||
imageView.kf.cancelDownloadTask()
|
||
imageView.image = nil
|
||
progressRingView.setProgress(0)
|
||
}
|
||
|
||
func apply(file: TaskFileItem) {
|
||
let urlString = file.previewURLString
|
||
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")
|
||
imageView.tintColor = AppColor.textTertiary
|
||
}
|
||
playIconView.isHidden = file.fileType != 1 || file.isUploading
|
||
deleteButton.isHidden = file.isUploading
|
||
sourceBadgeLabel.isHidden = file.isUploading
|
||
sourceBadgeLabel.text = file.sourceBadgeText
|
||
if file.isUploading {
|
||
overlayView.isHidden = false
|
||
progressRingView.isHidden = false
|
||
progressRingView.setProgress(CGFloat(file.uploadProgress) / 100)
|
||
progressLabel.text = "\(file.uploadProgress)%"
|
||
} else {
|
||
overlayView.isHidden = true
|
||
progressRingView.isHidden = true
|
||
progressLabel.text = nil
|
||
}
|
||
}
|
||
|
||
@objc private func deleteTapped() {
|
||
onDelete?()
|
||
}
|
||
}
|
||
|
||
/// 本地导入占位 cell。
|
||
final class TaskAddAddMediaCell: UICollectionViewCell {
|
||
|
||
static let reuseIdentifier = "TaskAddAddMediaCell"
|
||
|
||
private let iconView = UIImageView()
|
||
private let titleLabel = UILabel()
|
||
|
||
override init(frame: CGRect) {
|
||
super.init(frame: frame)
|
||
contentView.backgroundColor = AppColor.inputBackground
|
||
contentView.layer.cornerRadius = AppRadius.sm
|
||
|
||
iconView.image = UIImage(systemName: "plus")
|
||
iconView.tintColor = AppColor.textTabInactive
|
||
|
||
titleLabel.text = "本地导入"
|
||
titleLabel.font = .app(.caption)
|
||
titleLabel.textColor = AppColor.textTabInactive
|
||
|
||
contentView.addSubview(iconView)
|
||
contentView.addSubview(titleLabel)
|
||
iconView.snp.makeConstraints { make in
|
||
make.centerX.equalToSuperview()
|
||
make.centerY.equalToSuperview().offset(-8)
|
||
make.width.height.equalTo(24)
|
||
}
|
||
titleLabel.snp.makeConstraints { make in
|
||
make.top.equalTo(iconView.snp.bottom).offset(2)
|
||
make.centerX.equalToSuperview()
|
||
}
|
||
}
|
||
|
||
@available(*, unavailable)
|
||
required init?(coder: NSCoder) {
|
||
fatalError("init(coder:) has not been implemented")
|
||
}
|
||
}
|
||
|
||
/// 任务优先级选择区。
|
||
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)
|
||
private let twoHourButton = UIButton(type: .system)
|
||
private let customButton = UIButton(type: .system)
|
||
private let customField = UITextField()
|
||
private let customConfirmButton = UIButton(type: .system)
|
||
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)
|
||
backgroundColor = .clear
|
||
|
||
titleLabel.text = "优先级"
|
||
titleLabel.font = .app(.bodyMedium)
|
||
titleLabel.textColor = AppColor.textPrimary
|
||
|
||
configureOptionButton(noneButton, title: "不加急")
|
||
configureOptionButton(twoHourButton, title: "加急(2小时)")
|
||
configureOptionButton(customButton, title: "自定义")
|
||
|
||
noneButton.addTarget(self, action: #selector(noneTapped), for: .touchUpInside)
|
||
twoHourButton.addTarget(self, action: #selector(twoHourTapped), for: .touchUpInside)
|
||
customButton.addTarget(self, action: #selector(customTapped), for: .touchUpInside)
|
||
|
||
customField.placeholder = "请输入小时数"
|
||
customField.font = .app(.body)
|
||
customField.textColor = AppColor.textPrimary
|
||
customField.keyboardType = .numberPad
|
||
customField.backgroundColor = .white
|
||
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)
|
||
|
||
customConfirmButton.setTitle("确定", for: .normal)
|
||
customConfirmButton.setTitleColor(.white, for: .normal)
|
||
customConfirmButton.titleLabel?.font = .app(.bodyMedium)
|
||
customConfirmButton.backgroundColor = AppColor.primary
|
||
customConfirmButton.layer.cornerRadius = AppRadius.xs
|
||
customConfirmButton.addTarget(self, action: #selector(customConfirmTapped), for: .touchUpInside)
|
||
|
||
optionRow.addArrangedSubview(noneButton)
|
||
optionRow.addArrangedSubview(twoHourButton)
|
||
optionRow.addArrangedSubview(customButton)
|
||
optionRow.axis = .horizontal
|
||
optionRow.spacing = AppSpacing.xs
|
||
optionRow.distribution = .fillEqually
|
||
|
||
customRow.addArrangedSubview(customField)
|
||
customRow.addArrangedSubview(customConfirmButton)
|
||
customRow.axis = .horizontal
|
||
customRow.spacing = AppSpacing.xs
|
||
customRow.alignment = .fill
|
||
customRow.isHidden = true
|
||
|
||
stackView.addArrangedSubview(titleLabel)
|
||
stackView.addArrangedSubview(optionRow)
|
||
stackView.addArrangedSubview(customRow)
|
||
stackView.axis = .vertical
|
||
stackView.spacing = AppSpacing.xs
|
||
|
||
addSubview(stackView)
|
||
|
||
stackView.snp.makeConstraints { make in
|
||
make.edges.equalToSuperview()
|
||
}
|
||
optionRow.snp.makeConstraints { make in
|
||
make.height.equalTo(42)
|
||
}
|
||
customField.snp.makeConstraints { make in
|
||
make.height.equalTo(46)
|
||
}
|
||
customConfirmButton.snp.makeConstraints { make in
|
||
make.width.equalTo(72)
|
||
}
|
||
}
|
||
|
||
@available(*, unavailable)
|
||
required init?(coder: NSCoder) {
|
||
fatalError("init(coder:) has not been implemented")
|
||
}
|
||
|
||
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 {
|
||
if !wasCustom || urgentHour != lastUrgentHour {
|
||
customHourText = "\(urgentHour)"
|
||
}
|
||
customField.text = customHourText
|
||
} else {
|
||
customHourText = ""
|
||
customField.text = ""
|
||
}
|
||
lastUrgentHour = urgentHour
|
||
}
|
||
|
||
private func configureOptionButton(_ button: UIButton, title: String) {
|
||
button.setTitle(title, for: .normal)
|
||
button.titleLabel?.font = .app(.caption)
|
||
button.layer.cornerRadius = AppRadius.xs
|
||
button.clipsToBounds = true
|
||
}
|
||
|
||
private func styleOption(_ button: UIButton, selected: Bool) {
|
||
button.backgroundColor = selected ? AppColor.primary : AppColor.inputBackground
|
||
button.setTitleColor(selected ? .white : AppColor.textSecondary, for: .normal)
|
||
}
|
||
|
||
@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() {
|
||
customHourText = customField.text ?? ""
|
||
onCustomHourTextChange?(customHourText)
|
||
}
|
||
@objc private func customConfirmTapped() {
|
||
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)
|
||
}
|
||
}
|
||
|
||
/// 关联订单选择按钮。
|
||
final class TaskOrderSelectButton: UIControl {
|
||
|
||
private let titleLabel = UILabel()
|
||
private let arrowView = UIImageView(image: UIImage(systemName: "chevron.down"))
|
||
|
||
override init(frame: CGRect) {
|
||
super.init(frame: frame)
|
||
backgroundColor = .white
|
||
layer.cornerRadius = AppRadius.sm
|
||
layer.borderWidth = 1
|
||
layer.borderColor = AppColor.border.cgColor
|
||
|
||
titleLabel.font = .app(.bodyMedium)
|
||
titleLabel.textColor = AppColor.textTabInactive
|
||
titleLabel.text = "关联订单(可选)"
|
||
|
||
arrowView.tintColor = AppColor.textTabInactive
|
||
|
||
addSubview(titleLabel)
|
||
addSubview(arrowView)
|
||
titleLabel.snp.makeConstraints { make in
|
||
make.leading.equalToSuperview().offset(AppSpacing.sm)
|
||
make.centerY.equalToSuperview()
|
||
}
|
||
arrowView.snp.makeConstraints { make in
|
||
make.trailing.equalToSuperview().inset(AppSpacing.sm)
|
||
make.centerY.equalToSuperview()
|
||
}
|
||
snp.makeConstraints { make in make.height.equalTo(44) }
|
||
}
|
||
|
||
@available(*, unavailable)
|
||
required init?(coder: NSCoder) {
|
||
fatalError("init(coder:) has not been implemented")
|
||
}
|
||
|
||
func apply(orderNumber: String?) {
|
||
if let orderNumber, !orderNumber.isEmpty {
|
||
titleLabel.text = orderNumber
|
||
titleLabel.textColor = AppColor.textPrimary
|
||
} else {
|
||
titleLabel.text = "关联订单(可选)"
|
||
titleLabel.textColor = AppColor.textTabInactive
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 任务素材备注弹窗。
|
||
final class TaskFileRemarkDialogView: KeyboardAvoidingDialogView {
|
||
|
||
var onCancel: (() -> Void)?
|
||
var onConfirm: (() -> Void)?
|
||
|
||
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()
|
||
private let countLabel = UILabel()
|
||
private let cancelButton = UIButton(type: .system)
|
||
private let confirmButton = UIButton(type: .system)
|
||
private let buttonRow = UIStackView()
|
||
|
||
override init(frame: CGRect) {
|
||
super.init(frame: frame)
|
||
backgroundColor = AppColor.overlayScrim
|
||
keyboardAvoidingContentView = containerView
|
||
|
||
containerView.backgroundColor = .white
|
||
containerView.layer.cornerRadius = AppRadius.lg
|
||
|
||
titleLabel.text = "请填写剪辑备注"
|
||
titleLabel.font = .app(.title)
|
||
titleLabel.textColor = AppColor.textPrimary
|
||
|
||
previewView.contentMode = .scaleAspectFill
|
||
previewView.clipsToBounds = true
|
||
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
|
||
|
||
textView.font = .app(.body)
|
||
textView.textColor = AppColor.textPrimary
|
||
textView.layer.borderWidth = 1
|
||
textView.layer.borderColor = AppColor.border.cgColor
|
||
textView.layer.cornerRadius = AppRadius.sm
|
||
textView.delegate = self
|
||
textView.textContainerInset = UIEdgeInsets(top: AppSpacing.sm, left: AppSpacing.xs, bottom: AppSpacing.sm, right: AppSpacing.xs)
|
||
|
||
placeholderLabel.text = "请输入备注(最多50字)"
|
||
placeholderLabel.font = .app(.body)
|
||
placeholderLabel.textColor = AppColor.textTertiary
|
||
|
||
countLabel.font = .app(.caption)
|
||
countLabel.textColor = AppColor.textTertiary
|
||
countLabel.text = "0/50"
|
||
|
||
cancelButton.setTitle("取消", for: .normal)
|
||
cancelButton.setTitleColor(AppColor.textSecondary, for: .normal)
|
||
cancelButton.backgroundColor = AppColor.inputBackground
|
||
cancelButton.layer.cornerRadius = AppRadius.xs
|
||
cancelButton.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside)
|
||
|
||
confirmButton.setTitle("确定", for: .normal)
|
||
confirmButton.setTitleColor(.white, for: .normal)
|
||
confirmButton.backgroundColor = AppColor.primary
|
||
confirmButton.layer.cornerRadius = AppRadius.xs
|
||
confirmButton.addTarget(self, action: #selector(confirmTapped), for: .touchUpInside)
|
||
|
||
buttonRow.axis = .horizontal
|
||
buttonRow.spacing = AppSpacing.xs
|
||
buttonRow.alignment = .center
|
||
buttonRow.distribution = .fill
|
||
buttonRow.addArrangedSubview(cancelButton)
|
||
buttonRow.addArrangedSubview(confirmButton)
|
||
|
||
addSubview(containerView)
|
||
containerView.addSubview(titleLabel)
|
||
containerView.addSubview(previewContainerView)
|
||
previewContainerView.addSubview(previewView)
|
||
previewContainerView.addSubview(videoView)
|
||
containerView.addSubview(remarkTitleLabel)
|
||
containerView.addSubview(countLabel)
|
||
containerView.addSubview(textView)
|
||
textView.addSubview(placeholderLabel)
|
||
containerView.addSubview(buttonRow)
|
||
|
||
containerView.snp.makeConstraints { make in
|
||
make.centerX.equalToSuperview()
|
||
keyboardAvoidingCenterYConstraint = make.centerY.equalToSuperview().constraint
|
||
make.leading.trailing.equalToSuperview().inset(AppSpacing.lg)
|
||
}
|
||
titleLabel.snp.makeConstraints { make in
|
||
make.top.leading.equalToSuperview().offset(AppSpacing.md)
|
||
}
|
||
previewContainerView.snp.makeConstraints { make in
|
||
make.top.equalTo(titleLabel.snp.bottom).offset(AppSpacing.sm)
|
||
make.leading.trailing.equalToSuperview().inset(AppSpacing.md)
|
||
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(previewContainerView.snp.bottom).offset(AppSpacing.md)
|
||
make.leading.equalToSuperview().offset(AppSpacing.md)
|
||
}
|
||
countLabel.snp.makeConstraints { make in
|
||
make.centerY.equalTo(remarkTitleLabel)
|
||
make.trailing.equalToSuperview().inset(AppSpacing.md)
|
||
}
|
||
textView.snp.makeConstraints { make in
|
||
make.top.equalTo(remarkTitleLabel.snp.bottom).offset(AppSpacing.xs)
|
||
make.leading.trailing.equalToSuperview().inset(AppSpacing.md)
|
||
make.height.equalTo(80)
|
||
}
|
||
placeholderLabel.snp.makeConstraints { make in
|
||
make.top.equalToSuperview().offset(AppSpacing.sm)
|
||
make.leading.equalToSuperview().offset(AppSpacing.sm + 3)
|
||
make.trailing.lessThanOrEqualToSuperview().inset(AppSpacing.sm)
|
||
}
|
||
buttonRow.snp.makeConstraints { make in
|
||
make.top.equalTo(textView.snp.bottom).offset(AppSpacing.md)
|
||
make.trailing.bottom.equalToSuperview().inset(AppSpacing.md)
|
||
}
|
||
cancelButton.snp.makeConstraints { make in
|
||
make.height.equalTo(36)
|
||
make.width.equalTo(72)
|
||
}
|
||
confirmButton.snp.makeConstraints { make in
|
||
make.height.equalTo(36)
|
||
make.width.equalTo(72)
|
||
}
|
||
}
|
||
|
||
@available(*, unavailable)
|
||
required init?(coder: NSCoder) {
|
||
fatalError("init(coder:) has not been implemented")
|
||
}
|
||
|
||
func apply(file: TaskFileItem, text: String) {
|
||
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 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")
|
||
previewView.tintColor = AppColor.textTertiary
|
||
}
|
||
}
|
||
|
||
@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 {
|
||
textView.text = String(textView.text.prefix(50))
|
||
}
|
||
placeholderLabel.isHidden = !textView.text.isEmpty
|
||
countLabel.text = "\(textView.text.count)/50"
|
||
}
|
||
}
|
||
|
||
/// 带边框的单行输入框。
|
||
final class TaskBorderTextField: UIView {
|
||
|
||
let textField = UITextField()
|
||
|
||
init(placeholder: String) {
|
||
super.init(frame: .zero)
|
||
backgroundColor = .white
|
||
layer.cornerRadius = AppRadius.sm
|
||
layer.borderWidth = 1
|
||
layer.borderColor = AppColor.border.cgColor
|
||
|
||
textField.placeholder = placeholder
|
||
textField.font = .app(.body)
|
||
textField.textColor = AppColor.textPrimary
|
||
addSubview(textField)
|
||
textField.snp.makeConstraints { make in
|
||
make.edges.equalToSuperview().inset(UIEdgeInsets(top: 10, left: 12, bottom: 10, right: 12))
|
||
}
|
||
snp.makeConstraints { make in make.height.equalTo(44) }
|
||
}
|
||
|
||
@available(*, unavailable)
|
||
required init?(coder: NSCoder) {
|
||
fatalError("init(coder:) has not been implemented")
|
||
}
|
||
}
|
||
|
||
/// 带边框的多行输入框。
|
||
final class TaskBorderTextView: UIView {
|
||
|
||
let textView = UITextView()
|
||
|
||
init(placeholder: String) {
|
||
super.init(frame: .zero)
|
||
backgroundColor = .white
|
||
layer.cornerRadius = AppRadius.sm
|
||
layer.borderWidth = 1
|
||
layer.borderColor = AppColor.border.cgColor
|
||
|
||
textView.font = .app(.body)
|
||
textView.textColor = AppColor.textPrimary
|
||
textView.text = placeholder
|
||
textView.textColor = AppColor.textTertiary
|
||
addSubview(textView)
|
||
textView.snp.makeConstraints { make in
|
||
make.edges.equalToSuperview().inset(AppSpacing.xs)
|
||
}
|
||
snp.makeConstraints { make in make.height.equalTo(80) }
|
||
}
|
||
|
||
@available(*, unavailable)
|
||
required init?(coder: NSCoder) {
|
||
fatalError("init(coder:) has not been implemented")
|
||
}
|
||
}
|
||
|
||
/// 任务上传类型底部弹层,对齐 Android `AddFileBottomModalForTask`。
|
||
final class TaskUploadTypeSheetView: UIView {
|
||
|
||
var onChooseImage: (() -> Void)?
|
||
var onChooseVideo: (() -> Void)?
|
||
var onCancel: (() -> Void)?
|
||
|
||
private let dimmingView = UIView()
|
||
private let panelView = UIView()
|
||
private let titleLabel = UILabel()
|
||
private let imageItem = TaskUploadTypeItemView(
|
||
title: "上传图片",
|
||
image: UIImage(named: "icon_cloud_storage_add_photo") ?? UIImage(systemName: "photo")
|
||
)
|
||
private let videoItem = TaskUploadTypeItemView(
|
||
title: "上传视频",
|
||
image: UIImage(named: "icon_cloud_storage_add_video") ?? UIImage(systemName: "video")
|
||
)
|
||
private let dividerView = UIView()
|
||
private let cancelButton = UIButton(type: .system)
|
||
private var panelBottomConstraint: Constraint?
|
||
|
||
override init(frame: CGRect) {
|
||
super.init(frame: frame)
|
||
backgroundColor = .clear
|
||
|
||
dimmingView.backgroundColor = AppColor.overlayScrim
|
||
dimmingView.alpha = 0
|
||
let tap = UITapGestureRecognizer(target: self, action: #selector(cancelTapped))
|
||
dimmingView.addGestureRecognizer(tap)
|
||
|
||
panelView.backgroundColor = .white
|
||
panelView.layer.cornerRadius = AppRadius.sheetTop
|
||
panelView.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner]
|
||
panelView.clipsToBounds = true
|
||
|
||
titleLabel.text = "选择上传类型"
|
||
titleLabel.font = .app(.bodyMedium)
|
||
titleLabel.textColor = AppColor.textPrimary
|
||
titleLabel.textAlignment = .center
|
||
|
||
imageItem.addTarget(self, action: #selector(imageTapped), for: .touchUpInside)
|
||
videoItem.addTarget(self, action: #selector(videoTapped), for: .touchUpInside)
|
||
|
||
dividerView.backgroundColor = AppColor.border
|
||
|
||
cancelButton.setTitle("取消", for: .normal)
|
||
cancelButton.titleLabel?.font = .app(.bodyMedium)
|
||
cancelButton.setTitleColor(UIColor(hex: 0x4B5563), for: .normal)
|
||
cancelButton.backgroundColor = AppColor.inputBackground
|
||
cancelButton.layer.cornerRadius = AppRadius.lg
|
||
cancelButton.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside)
|
||
|
||
addSubview(dimmingView)
|
||
addSubview(panelView)
|
||
panelView.addSubview(titleLabel)
|
||
panelView.addSubview(imageItem)
|
||
panelView.addSubview(videoItem)
|
||
panelView.addSubview(dividerView)
|
||
panelView.addSubview(cancelButton)
|
||
|
||
dimmingView.snp.makeConstraints { make in make.edges.equalToSuperview() }
|
||
panelView.snp.makeConstraints { make in
|
||
make.leading.trailing.equalToSuperview()
|
||
panelBottomConstraint = make.bottom.equalToSuperview().offset(260).constraint
|
||
}
|
||
titleLabel.snp.makeConstraints { make in
|
||
make.top.equalToSuperview().offset(AppSpacing.md)
|
||
make.leading.trailing.equalToSuperview().inset(AppSpacing.md)
|
||
}
|
||
imageItem.snp.makeConstraints { make in
|
||
make.top.equalTo(titleLabel.snp.bottom).offset(AppSpacing.md)
|
||
make.leading.trailing.equalToSuperview().inset(AppSpacing.md)
|
||
make.height.equalTo(58)
|
||
}
|
||
videoItem.snp.makeConstraints { make in
|
||
make.top.equalTo(imageItem.snp.bottom).offset(AppSpacing.sm)
|
||
make.leading.trailing.equalTo(imageItem)
|
||
make.height.equalTo(imageItem)
|
||
}
|
||
dividerView.snp.makeConstraints { make in
|
||
make.top.equalTo(videoItem.snp.bottom).offset(AppSpacing.md)
|
||
make.leading.trailing.equalToSuperview()
|
||
make.height.equalTo(0.5)
|
||
}
|
||
cancelButton.snp.makeConstraints { make in
|
||
make.top.equalTo(dividerView.snp.bottom).offset(AppSpacing.md)
|
||
make.leading.trailing.equalToSuperview().inset(AppSpacing.md)
|
||
make.height.equalTo(48)
|
||
make.bottom.equalTo(safeAreaLayoutGuide).inset(AppSpacing.md)
|
||
}
|
||
}
|
||
|
||
@available(*, unavailable)
|
||
required init?(coder: NSCoder) {
|
||
fatalError("init(coder:) has not been implemented")
|
||
}
|
||
|
||
/// 展示底部弹层。
|
||
func show() {
|
||
layoutIfNeeded()
|
||
panelBottomConstraint?.update(offset: 0)
|
||
UIView.animate(withDuration: 0.22, delay: 0, options: [.curveEaseOut]) {
|
||
self.dimmingView.alpha = 1
|
||
self.layoutIfNeeded()
|
||
}
|
||
}
|
||
|
||
/// 关闭底部弹层。
|
||
func dismiss(completion: (() -> Void)? = nil) {
|
||
panelBottomConstraint?.update(offset: panelView.bounds.height)
|
||
UIView.animate(withDuration: 0.18, delay: 0, options: [.curveEaseIn]) {
|
||
self.dimmingView.alpha = 0
|
||
self.layoutIfNeeded()
|
||
} completion: { _ in
|
||
completion?()
|
||
}
|
||
}
|
||
|
||
@objc private func imageTapped() {
|
||
onChooseImage?()
|
||
}
|
||
|
||
@objc private func videoTapped() {
|
||
onChooseVideo?()
|
||
}
|
||
|
||
@objc private func cancelTapped() {
|
||
onCancel?()
|
||
}
|
||
}
|
||
|
||
/// 上传类型弹层内的单个选项。
|
||
final class TaskUploadTypeItemView: UIControl {
|
||
|
||
private let iconView = UIImageView()
|
||
private let titleLabel = UILabel()
|
||
|
||
init(title: String, image: UIImage?) {
|
||
super.init(frame: .zero)
|
||
backgroundColor = .white
|
||
layer.cornerRadius = AppRadius.sm
|
||
layer.borderWidth = 1
|
||
layer.borderColor = AppColor.inputBackground.cgColor
|
||
|
||
iconView.image = image
|
||
iconView.contentMode = .scaleAspectFit
|
||
iconView.tintColor = AppColor.primary
|
||
|
||
titleLabel.text = title
|
||
titleLabel.font = .app(.bodyMedium)
|
||
titleLabel.textColor = .black
|
||
|
||
addSubview(iconView)
|
||
addSubview(titleLabel)
|
||
|
||
iconView.snp.makeConstraints { make in
|
||
make.leading.equalToSuperview().offset(AppSpacing.md)
|
||
make.centerY.equalToSuperview()
|
||
make.width.height.equalTo(32)
|
||
}
|
||
titleLabel.snp.makeConstraints { make in
|
||
make.leading.equalTo(iconView.snp.trailing).offset(AppSpacing.sm)
|
||
make.centerY.equalToSuperview()
|
||
make.trailing.lessThanOrEqualToSuperview().inset(AppSpacing.md)
|
||
}
|
||
}
|
||
|
||
@available(*, unavailable)
|
||
required init?(coder: NSCoder) {
|
||
fatalError("init(coder:) has not been implemented")
|
||
}
|
||
}
|