// // 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 = 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 } 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.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 } }