重构排队设置与变更日志交互,补充 Overlay 与资源,并将多页面主操作按钮统一到 UIButton Configuration,同步更新相关单元测试。 Co-authored-by: Cursor <cursoragent@cursor.com>
462 lines
17 KiB
Swift
462 lines
17 KiB
Swift
//
|
|
// WildReportSupplementEvidenceViewController.swift
|
|
// suixinkan
|
|
//
|
|
|
|
import PhotosUI
|
|
import SnapKit
|
|
import UIKit
|
|
|
|
/// 补充证据页面,允许为已有举报追加说明、图片和视频证据。
|
|
final class WildReportSupplementEvidenceViewController: BaseViewController {
|
|
private let viewModel: WildReportSupplementEvidenceViewModel
|
|
private let api: any WildPhotographerReportServing
|
|
private let uploader: any WildReportAttachmentUploading
|
|
private let onSupplementSuccess: (() -> Void)?
|
|
|
|
private let scrollView = UIScrollView()
|
|
private let contentStack = UIStackView()
|
|
private let textView = UITextView()
|
|
private let placeholderLabel = UILabel()
|
|
private let countLabel = UILabel()
|
|
private let submitButton = UIButton(type: .system)
|
|
|
|
private var pickerTarget: WildReportPickerTarget = .image
|
|
private weak var activeInputView: UIView?
|
|
private var isShowingSubmitLoading = false
|
|
|
|
init(
|
|
record: WildReportRecord,
|
|
homeViewModel: WildPhotographerReportHomeViewModel,
|
|
api: (any WildPhotographerReportServing)? = nil,
|
|
uploader: (any WildReportAttachmentUploading)? = nil,
|
|
onSupplementSuccess: (() -> Void)? = nil
|
|
) {
|
|
self.viewModel = WildReportSupplementEvidenceViewModel(record: record, homeViewModel: homeViewModel)
|
|
self.api = api ?? NetworkServices.shared.wildPhotographerReportAPI
|
|
self.uploader = uploader ?? NetworkServices.shared.ossUploadService
|
|
self.onSupplementSuccess = onSupplementSuccess
|
|
super.init(nibName: nil, bundle: nil)
|
|
}
|
|
|
|
@available(*, unavailable)
|
|
required init?(coder: NSCoder) {
|
|
fatalError("init(coder:) has not been implemented")
|
|
}
|
|
|
|
deinit {
|
|
NotificationCenter.default.removeObserver(self)
|
|
}
|
|
|
|
override func setupNavigationBar() {
|
|
title = "补充证据"
|
|
}
|
|
|
|
override func setupUI() {
|
|
view.backgroundColor = UIColor(hex: 0xF8FAFF)
|
|
scrollView.keyboardDismissMode = .interactive
|
|
scrollView.showsVerticalScrollIndicator = false
|
|
contentStack.axis = .vertical
|
|
contentStack.spacing = 14
|
|
|
|
textView.font = .systemFont(ofSize: 15)
|
|
textView.textColor = AppColor.textPrimary
|
|
textView.backgroundColor = .clear
|
|
textView.textContainerInset = UIEdgeInsets(top: 10, left: 8, bottom: 34, right: 8)
|
|
textView.delegate = self
|
|
|
|
placeholderLabel.text = "请补充新的线索说明..."
|
|
placeholderLabel.font = .systemFont(ofSize: 15)
|
|
placeholderLabel.textColor = UIColor(hex: 0x9CA3AF)
|
|
|
|
countLabel.font = .systemFont(ofSize: 14)
|
|
countLabel.textColor = UIColor(hex: 0x6B7280)
|
|
countLabel.textAlignment = .right
|
|
|
|
submitButton.setTitle("提交补充证据", for: .normal)
|
|
submitButton.titleLabel?.font = .systemFont(ofSize: 18, weight: .bold)
|
|
submitButton.setTitleColor(.white, for: .normal)
|
|
submitButton.backgroundColor = AppColor.primary
|
|
submitButton.layer.cornerRadius = 14
|
|
submitButton.addTarget(self, action: #selector(submitTapped), for: .touchUpInside)
|
|
|
|
view.addSubview(scrollView)
|
|
scrollView.addSubview(contentStack)
|
|
view.addSubview(submitButton)
|
|
|
|
let dismissTap = UITapGestureRecognizer(target: self, action: #selector(dismissKeyboard))
|
|
dismissTap.cancelsTouchesInView = false
|
|
dismissTap.delegate = self
|
|
scrollView.addGestureRecognizer(dismissTap)
|
|
registerKeyboardNotifications()
|
|
rebuildContent()
|
|
}
|
|
|
|
override func setupConstraints() {
|
|
submitButton.snp.makeConstraints { make in
|
|
make.leading.trailing.equalTo(view.safeAreaLayoutGuide).inset(18)
|
|
make.bottom.equalTo(view.safeAreaLayoutGuide).inset(12)
|
|
make.height.equalTo(52)
|
|
}
|
|
scrollView.snp.makeConstraints { make in
|
|
make.top.leading.trailing.equalTo(view.safeAreaLayoutGuide)
|
|
make.bottom.equalTo(submitButton.snp.top).offset(-12)
|
|
}
|
|
contentStack.snp.makeConstraints { make in
|
|
make.edges.equalTo(scrollView.contentLayoutGuide).inset(UIEdgeInsets(top: 16, left: 18, bottom: 28, right: 18))
|
|
make.width.equalTo(scrollView.frameLayoutGuide).offset(-36)
|
|
}
|
|
}
|
|
|
|
override func bindActions() {
|
|
viewModel.onStateChange = { [weak self] in
|
|
Task { @MainActor in
|
|
guard let self else { return }
|
|
self.updateSubmitLoading()
|
|
if self.textView.isFirstResponder {
|
|
self.updateTextInputState()
|
|
self.updateSubmitButton()
|
|
} else {
|
|
self.rebuildContent()
|
|
}
|
|
}
|
|
}
|
|
viewModel.onShowMessage = { [weak self] message in
|
|
Task { @MainActor in self?.showToast(message) }
|
|
}
|
|
viewModel.onSubmitSuccess = { [weak self] in
|
|
Task { @MainActor in
|
|
guard let self else { return }
|
|
self.onSupplementSuccess?()
|
|
self.showToast("补充证据已提交")
|
|
self.navigationController?.popViewController(animated: true)
|
|
}
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
private func rebuildContent() {
|
|
contentStack.arrangedSubviews.forEach { view in
|
|
contentStack.removeArrangedSubview(view)
|
|
view.removeFromSuperview()
|
|
}
|
|
contentStack.addArrangedSubview(makeTextCard())
|
|
contentStack.addArrangedSubview(makeUploadCard(
|
|
icon: "photo.fill",
|
|
title: "上传图片",
|
|
subtitle: "支持 JPG、PNG 格式,最多 9 张",
|
|
attachments: viewModel.images,
|
|
maxCount: 9,
|
|
kind: .image
|
|
))
|
|
contentStack.addArrangedSubview(makeUploadCard(
|
|
icon: "video.fill",
|
|
title: "上传视频",
|
|
subtitle: "支持 MP4 格式,最多 3 段",
|
|
attachments: viewModel.videos,
|
|
maxCount: 3,
|
|
kind: .video
|
|
))
|
|
updateSubmitButton()
|
|
}
|
|
|
|
private func makeTextCard() -> UIView {
|
|
let card = cardView(spacing: 10)
|
|
card.stack.addArrangedSubview(WildReportUI.label("补充说明", font: .systemFont(ofSize: 17, weight: .bold), color: AppColor.textPrimary))
|
|
textView.text = viewModel.text
|
|
|
|
let inputContainer = UIView()
|
|
inputContainer.backgroundColor = UIColor(hex: 0xFBFCFE)
|
|
inputContainer.layer.cornerRadius = 12
|
|
inputContainer.layer.borderWidth = 1
|
|
inputContainer.layer.borderColor = UIColor(hex: 0xCDD5E1).cgColor
|
|
inputContainer.addSubview(textView)
|
|
inputContainer.addSubview(placeholderLabel)
|
|
inputContainer.addSubview(countLabel)
|
|
|
|
textView.snp.remakeConstraints { make in
|
|
make.edges.equalToSuperview()
|
|
make.height.greaterThanOrEqualTo(132)
|
|
}
|
|
placeholderLabel.snp.remakeConstraints { make in
|
|
make.top.equalToSuperview().offset(18)
|
|
make.leading.equalToSuperview().offset(14)
|
|
make.trailing.lessThanOrEqualToSuperview().inset(14)
|
|
}
|
|
countLabel.snp.remakeConstraints { make in
|
|
make.trailing.bottom.equalToSuperview().inset(12)
|
|
}
|
|
card.stack.addArrangedSubview(inputContainer)
|
|
updateTextInputState()
|
|
return card
|
|
}
|
|
|
|
private func makeUploadCard(
|
|
icon: String,
|
|
title: String,
|
|
subtitle: String,
|
|
attachments: [WildReportAttachment],
|
|
maxCount: Int,
|
|
kind: WildReportAttachmentKind
|
|
) -> UIView {
|
|
let card = cardView(spacing: 12)
|
|
let header = UIStackView()
|
|
header.axis = .horizontal
|
|
header.alignment = .center
|
|
header.spacing = 10
|
|
|
|
let iconView = UIImageView(image: UIImage(systemName: icon))
|
|
iconView.tintColor = AppColor.primary
|
|
iconView.contentMode = .scaleAspectFit
|
|
iconView.snp.makeConstraints { make in
|
|
make.size.equalTo(26)
|
|
}
|
|
|
|
let textStack = UIStackView()
|
|
textStack.axis = .vertical
|
|
textStack.spacing = 4
|
|
textStack.addArrangedSubview(WildReportUI.label(title, font: .systemFont(ofSize: 16, weight: .bold), color: AppColor.textPrimary))
|
|
textStack.addArrangedSubview(WildReportUI.label(subtitle, font: .systemFont(ofSize: 12), color: AppColor.textSecondary, lines: 0))
|
|
|
|
header.addArrangedSubview(iconView)
|
|
header.addArrangedSubview(textStack)
|
|
header.addArrangedSubview(UIView())
|
|
card.stack.addArrangedSubview(header)
|
|
|
|
let scroll = UIScrollView()
|
|
scroll.showsHorizontalScrollIndicator = false
|
|
let tiles = UIStackView()
|
|
tiles.axis = .horizontal
|
|
tiles.spacing = 10
|
|
scroll.addSubview(tiles)
|
|
tiles.snp.makeConstraints { make in
|
|
make.edges.equalTo(scroll.contentLayoutGuide).inset(UIEdgeInsets(top: 2, left: 0, bottom: 2, right: 0))
|
|
make.height.equalTo(scroll.frameLayoutGuide).offset(-4)
|
|
}
|
|
|
|
attachments.forEach { attachment in
|
|
tiles.addArrangedSubview(WildReportAttachmentTileView(
|
|
attachment: attachment,
|
|
tileSize: 72,
|
|
onPreview: { [weak self] in self?.previewAttachment(attachment) },
|
|
onRemove: { [weak self] in self?.viewModel.removeAttachment(attachment) }
|
|
))
|
|
}
|
|
|
|
if canAddAttachment(kind: kind, currentCount: attachments.count, maxCount: maxCount) {
|
|
tiles.addArrangedSubview(WildReportAddAttachmentTileView(
|
|
tileSize: 72,
|
|
borderColor: AppColor.primary,
|
|
iconColor: AppColor.primary
|
|
) { [weak self] in
|
|
self?.presentMediaPicker(for: WildReportPickerTarget(kind: kind))
|
|
})
|
|
}
|
|
|
|
scroll.snp.makeConstraints { make in
|
|
make.height.equalTo(76)
|
|
}
|
|
card.stack.addArrangedSubview(scroll)
|
|
return card
|
|
}
|
|
|
|
private func cardView(spacing: CGFloat) -> WildReportCardView {
|
|
let card = WildReportCardView(spacing: spacing, inset: 16)
|
|
card.layer.cornerRadius = 16
|
|
card.layer.borderColor = AppColor.cardOutline.cgColor
|
|
return card
|
|
}
|
|
|
|
private func updateTextInputState() {
|
|
placeholderLabel.isHidden = !viewModel.text.isEmpty
|
|
countLabel.text = "\(viewModel.text.count)/500"
|
|
}
|
|
|
|
private func updateSubmitButton() {
|
|
submitButton.setTitle("提交补充证据", for: .normal)
|
|
submitButton.isEnabled = !viewModel.isSubmitting
|
|
submitButton.alpha = viewModel.isSubmitting ? 0.78 : 1
|
|
}
|
|
|
|
@MainActor
|
|
private func updateSubmitLoading() {
|
|
if viewModel.isSubmitting {
|
|
guard !isShowingSubmitLoading else { return }
|
|
isShowingSubmitLoading = true
|
|
showLoading()
|
|
} else if isShowingSubmitLoading {
|
|
isShowingSubmitLoading = false
|
|
hideLoading()
|
|
}
|
|
}
|
|
|
|
private func canAddAttachment(kind: WildReportAttachmentKind, currentCount: Int, maxCount: Int) -> Bool {
|
|
let ownRemaining = max(0, maxCount - currentCount)
|
|
let evidenceRemaining = max(0, 9 - viewModel.images.count - viewModel.videos.count)
|
|
switch kind {
|
|
case .image:
|
|
return min(ownRemaining, evidenceRemaining) > 0
|
|
case .video:
|
|
return min(ownRemaining, evidenceRemaining) > 0
|
|
case .payment:
|
|
return false
|
|
}
|
|
}
|
|
|
|
private func presentMediaPicker(for target: WildReportPickerTarget) {
|
|
dismissKeyboard()
|
|
let remaining = remainingSelectionCount(for: target)
|
|
guard remaining > 0 else {
|
|
showToast(target == .video ? "补充视频最多上传3段" : "补充证据最多上传9项")
|
|
return
|
|
}
|
|
pickerTarget = target
|
|
var configuration = PHPickerConfiguration(photoLibrary: .shared())
|
|
configuration.selectionLimit = remaining
|
|
configuration.filter = target == .video ? .videos : .images
|
|
let picker = PHPickerViewController(configuration: configuration)
|
|
picker.delegate = self
|
|
present(picker, animated: true)
|
|
}
|
|
|
|
private func remainingSelectionCount(for target: WildReportPickerTarget) -> Int {
|
|
switch target {
|
|
case .image:
|
|
return max(0, 9 - viewModel.images.count - viewModel.videos.count)
|
|
case .video:
|
|
return max(0, min(3 - viewModel.videos.count, 9 - viewModel.images.count - viewModel.videos.count))
|
|
case .payment:
|
|
return 0
|
|
}
|
|
}
|
|
|
|
private func previewAttachment(_ attachment: WildReportAttachment) {
|
|
guard let item = MediaPreviewItem(wildReportAttachment: attachment) else {
|
|
showToast("当前附件暂无本地预览")
|
|
return
|
|
}
|
|
presentMediaPreview(items: [item], startIndex: 0)
|
|
}
|
|
|
|
@objc private func submitTapped() {
|
|
Task {
|
|
await viewModel.submit(
|
|
api: api,
|
|
uploader: uploader,
|
|
scenicId: AppStore.shared.session.currentScenicId
|
|
)
|
|
}
|
|
}
|
|
|
|
@objc private func dismissKeyboard() {
|
|
view.endEditing(true)
|
|
}
|
|
|
|
private func registerKeyboardNotifications() {
|
|
NotificationCenter.default.addObserver(
|
|
self,
|
|
selector: #selector(keyboardWillChangeFrame(_:)),
|
|
name: UIResponder.keyboardWillChangeFrameNotification,
|
|
object: nil
|
|
)
|
|
NotificationCenter.default.addObserver(
|
|
self,
|
|
selector: #selector(keyboardWillHide(_:)),
|
|
name: UIResponder.keyboardWillHideNotification,
|
|
object: nil
|
|
)
|
|
}
|
|
|
|
@objc private func keyboardWillChangeFrame(_ notification: Notification) {
|
|
guard let keyboardFrame = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect else { return }
|
|
let keyboardFrameInView = view.convert(keyboardFrame, from: nil)
|
|
let overlap = max(0, view.bounds.maxY - keyboardFrameInView.minY)
|
|
updateScrollInsets(bottom: overlap + 12, notification: notification)
|
|
scrollActiveInputIntoView(animated: true)
|
|
}
|
|
|
|
@objc private func keyboardWillHide(_ notification: Notification) {
|
|
updateScrollInsets(bottom: 0, notification: notification)
|
|
}
|
|
|
|
private func updateScrollInsets(bottom: CGFloat, notification: Notification) {
|
|
let duration = notification.userInfo?[UIResponder.keyboardAnimationDurationUserInfoKey] as? TimeInterval ?? 0.25
|
|
let curveRaw = notification.userInfo?[UIResponder.keyboardAnimationCurveUserInfoKey] as? UInt ?? UIView.AnimationOptions.curveEaseInOut.rawValue
|
|
let options = UIView.AnimationOptions(rawValue: curveRaw << 16)
|
|
UIView.animate(withDuration: duration, delay: 0, options: options) {
|
|
self.scrollView.contentInset.bottom = bottom
|
|
self.scrollView.verticalScrollIndicatorInsets.bottom = bottom
|
|
}
|
|
}
|
|
|
|
private func scrollActiveInputIntoView(animated: Bool) {
|
|
guard let activeInputView else { return }
|
|
view.layoutIfNeeded()
|
|
let rect = activeInputView.convert(activeInputView.bounds, to: scrollView).insetBy(dx: 0, dy: -24)
|
|
scrollView.scrollRectToVisible(rect, animated: animated)
|
|
}
|
|
}
|
|
|
|
extension WildReportSupplementEvidenceViewController: PHPickerViewControllerDelegate {
|
|
func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
|
|
picker.dismiss(animated: true)
|
|
guard !results.isEmpty else { return }
|
|
let target = pickerTarget
|
|
Task {
|
|
do {
|
|
let attachments = try await WildReportLocalMediaLoader.load(
|
|
results: results,
|
|
target: target,
|
|
titlePrefix: target == .video ? "补充视频" : "补充图片"
|
|
)
|
|
await MainActor.run {
|
|
switch target {
|
|
case .image:
|
|
viewModel.addImages(attachments)
|
|
case .video:
|
|
viewModel.addVideos(attachments)
|
|
case .payment:
|
|
break
|
|
}
|
|
}
|
|
} catch {
|
|
await MainActor.run { showToast(error.localizedDescription) }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
extension WildReportSupplementEvidenceViewController: UITextViewDelegate {
|
|
func textViewDidBeginEditing(_ textView: UITextView) {
|
|
activeInputView = textView
|
|
DispatchQueue.main.async { [weak self] in self?.scrollActiveInputIntoView(animated: true) }
|
|
}
|
|
|
|
func textViewDidChange(_ textView: UITextView) {
|
|
viewModel.updateText(textView.text)
|
|
if textView.text != viewModel.text {
|
|
textView.text = viewModel.text
|
|
}
|
|
updateTextInputState()
|
|
}
|
|
|
|
func textViewDidEndEditing(_ textView: UITextView) {
|
|
if activeInputView === textView {
|
|
activeInputView = nil
|
|
}
|
|
}
|
|
}
|
|
|
|
extension WildReportSupplementEvidenceViewController: UIGestureRecognizerDelegate {
|
|
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool {
|
|
var touchedView = touch.view
|
|
while let currentView = touchedView {
|
|
if currentView is UIControl || currentView is UITextView {
|
|
return false
|
|
}
|
|
touchedView = currentView.superview
|
|
}
|
|
return true
|
|
}
|
|
}
|