Files
suixinkan_uikit/suixinkan/UI/WildPhotographerReport/WildReportSupplementEvidenceViewController.swift

451 lines
17 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// 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?
init(
record: WildReportRecord,
homeViewModel: WildPhotographerReportHomeViewModel,
api: any WildPhotographerReportServing = NetworkServices.shared.wildPhotographerReportAPI,
uploader: any WildReportAttachmentUploading = NetworkServices.shared.ossUploadService,
onSupplementSuccess: (() -> Void)? = nil
) {
self.viewModel = WildReportSupplementEvidenceViewModel(record: record, homeViewModel: homeViewModel)
self.api = api
self.uploader = uploader
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 }
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() {
let title = viewModel.isSubmitting && !viewModel.submitProgressText.isEmpty
? viewModel.submitProgressText
: "提交补充证据"
submitButton.setTitle(title, for: .normal)
submitButton.isEnabled = !viewModel.isSubmitting
submitButton.alpha = viewModel.isSubmitting ? 0.78 : 1
}
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.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
}
}