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

1365 lines
51 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.

//
// WildPhotographerReportSubmitViewController.swift
// suixinkan
//
import PhotosUI
import SnapKit
import UIKit
///
final class WildPhotographerReportSubmitViewController: BaseViewController {
private let viewModel: WildPhotographerReportSubmitViewModel
private let homeViewModel: WildPhotographerReportHomeViewModel
private let api: any WildPhotographerReportServing
private let uploader: any WildReportAttachmentUploading
private let scrollView = UIScrollView()
private let contentStack = UIStackView()
private let descriptionView = UITextView()
private let contactField = UITextField()
private let submitButton = UIButton(type: .system)
private var pickerTarget: WildReportPickerTarget = .image
private weak var activeInputView: UIView?
private var isShowingSubmitLoading = false
init(
homeViewModel: WildPhotographerReportHomeViewModel,
locationProvider: any LocationProviding = LocationProvider.shared,
api: any WildPhotographerReportServing = NetworkServices.shared.wildPhotographerReportAPI,
uploader: any WildReportAttachmentUploading = NetworkServices.shared.ossUploadService
) {
self.homeViewModel = homeViewModel
self.api = api
self.uploader = uploader
self.viewModel = WildPhotographerReportSubmitViewModel(
homeViewModel: homeViewModel,
locationProvider: locationProvider
)
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 = AppColor.pageBackgroundSoft
scrollView.keyboardDismissMode = .onDrag
scrollView.showsVerticalScrollIndicator = false
contentStack.axis = .vertical
contentStack.spacing = AppSpacing.sm
descriptionView.font = .systemFont(ofSize: 15)
descriptionView.textColor = AppColor.textPrimary
descriptionView.backgroundColor = .clear
descriptionView.textContainerInset = UIEdgeInsets(top: 10, left: 8, bottom: 34, right: 8)
descriptionView.delegate = self
contactField.placeholder = "请输入摄影师微信号或手机号(选填)"
contactField.font = .app(.body)
contactField.textColor = AppColor.textPrimary
contactField.backgroundColor = UIColor(hex: 0xFBFCFE)
contactField.layer.cornerRadius = 10
contactField.layer.borderWidth = 1
contactField.layer.borderColor = UIColor(hex: 0xCDD5E1).cgColor
contactField.leftView = UIView(frame: CGRect(x: 0, y: 0, width: 12, height: 1))
contactField.leftViewMode = .always
contactField.delegate = self
contactField.addTarget(self, action: #selector(contactChanged), for: .editingChanged)
submitButton.setTitle("提交举报", for: .normal)
submitButton.titleLabel?.font = .systemFont(ofSize: 22, weight: .bold)
submitButton.setTitleColor(.white, for: .normal)
submitButton.backgroundColor = AppColor.primary
submitButton.layer.cornerRadius = 14
submitButton.layer.shadowColor = AppColor.primary.cgColor
submitButton.layer.shadowOpacity = 0.22
submitButton.layer.shadowRadius = 12
submitButton.layer.shadowOffset = CGSize(width: 0, height: 6)
submitButton.addTarget(self, action: #selector(submitTapped), for: .touchUpInside)
view.addSubview(scrollView)
scrollView.addSubview(contentStack)
let dismissTap = UITapGestureRecognizer(target: self, action: #selector(dismissKeyboard))
dismissTap.cancelsTouchesInView = false
dismissTap.delegate = self
scrollView.addGestureRecognizer(dismissTap)
registerKeyboardNotifications()
rebuildContent()
}
override func setupConstraints() {
scrollView.snp.makeConstraints { make in
make.top.leading.trailing.equalTo(view.safeAreaLayoutGuide)
make.bottom.equalToSuperview()
}
contentStack.snp.makeConstraints { make in
make.edges.equalTo(scrollView.contentLayoutGuide).inset(UIEdgeInsets(top: 14, left: 18, bottom: 26, 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()
self.rebuildContent()
}
}
viewModel.onShowMessage = { [weak self] message in
Task { @MainActor in self?.showToast(message) }
}
viewModel.onSubmitSuccess = { [weak self] result in
Task { @MainActor in
guard let self else { return }
self.navigationController?.pushViewController(
WildPhotographerReportSuccessViewController(
result: result,
homeViewModel: self.homeViewModel
),
animated: true
)
}
}
}
override func viewDidLoad() {
super.viewDidLoad()
viewModel.loadInitialLocationIfNeeded()
Task {
await viewModel.loadReportTypes(api: api)
}
}
@MainActor
private func rebuildContent() {
contentStack.arrangedSubviews.forEach { view in
contentStack.removeArrangedSubview(view)
view.removeFromSuperview()
}
contentStack.addArrangedSubview(makeRealNameBanner())
contentStack.addArrangedSubview(makeTypeCard())
contentStack.addArrangedSubview(makeEvidenceCard())
contentStack.addArrangedSubview(makeDescriptionCard())
contentStack.addArrangedSubview(makeLocationCard())
contentStack.addArrangedSubview(makeContactCard())
let paymentCard = makePaymentCard()
contentStack.addArrangedSubview(paymentCard)
contentStack.setCustomSpacing(16, after: paymentCard)
contentStack.addArrangedSubview(submitButton)
submitButton.setTitle("提交举报", for: .normal)
submitButton.isEnabled = !viewModel.isSubmitting
submitButton.alpha = viewModel.isSubmitting ? 0.78 : 1
submitButton.snp.remakeConstraints { make in
make.height.equalTo(56)
}
if activeInputView?.isFirstResponder == true {
scrollActiveInputIntoView(animated: false)
}
}
private func makeRealNameBanner() -> UIView {
let banner = UIView()
banner.backgroundColor = UIColor(hex: 0xF6FAFF)
banner.layer.cornerRadius = 16
banner.layer.borderWidth = 1
banner.layer.borderColor = UIColor(hex: 0xBFD7FF).cgColor
let iconWrap = UIView()
iconWrap.backgroundColor = AppColor.primary.withAlphaComponent(0.10)
iconWrap.layer.cornerRadius = 17
let icon = UIImageView(image: UIImage(systemName: "checkmark.shield.fill"))
icon.tintColor = AppColor.primary
iconWrap.addSubview(icon)
icon.snp.makeConstraints { make in
make.center.equalToSuperview()
make.size.equalTo(22)
}
let message = UILabel()
message.numberOfLines = 0
message.attributedText = realNameBannerText()
banner.addSubview(iconWrap)
banner.addSubview(message)
iconWrap.snp.makeConstraints { make in
make.top.leading.equalToSuperview().offset(16)
make.size.equalTo(34)
make.bottom.lessThanOrEqualToSuperview().inset(16)
}
message.snp.makeConstraints { make in
make.top.trailing.bottom.equalToSuperview().inset(16)
make.leading.equalTo(iconWrap.snp.trailing).offset(12)
}
return banner
}
private func makeTypeCard() -> UIView {
let card = sectionCard(index: 1, title: "举报类型")
if viewModel.isLoadingReportTypes {
let loading = WildReportUI.label("正在同步举报类型...", font: .systemFont(ofSize: 13), color: AppColor.textSecondary)
card.stack.addArrangedSubview(loading)
}
let grid = UIStackView()
grid.axis = .vertical
grid.spacing = 8
let types = viewModel.reportTypes
if types.isEmpty, !viewModel.isLoadingReportTypes {
let empty = WildReportUI.label("暂无可选举报类型,请稍后重试", font: .systemFont(ofSize: 13), color: AppColor.textSecondary)
card.stack.addArrangedSubview(empty)
return card
}
stride(from: 0, to: types.count, by: 3).forEach { startIndex in
let rowTypes = Array(types[startIndex ..< min(startIndex + 3, types.count)])
let row = UIStackView()
row.axis = .horizontal
row.spacing = 8
row.distribution = .fillEqually
rowTypes.forEach { type in
let button = WildReportTypeOptionButton()
button.apply(type: type, isSelected: viewModel.selectedReportType == type)
button.addAction(UIAction { [weak self] _ in self?.viewModel.selectReportType(type) }, for: .touchUpInside)
button.snp.makeConstraints { make in make.height.equalTo(72) }
row.addArrangedSubview(button)
}
for _ in rowTypes.count..<3 {
row.addArrangedSubview(UIView())
}
grid.addArrangedSubview(row)
}
card.stack.addArrangedSubview(grid)
return card
}
private func makeEvidenceCard() -> UIView {
let card = sectionCard(index: 2, title: "上传现场证据")
card.stack.addArrangedSubview(makeEvidenceUploadBlock(
icon: "photo.fill",
title: "上传图片",
subtitle: "支持 JPG、PNG 格式,最多 9 张",
attachments: viewModel.images,
maxCount: 9,
kind: .image
))
card.stack.addArrangedSubview(makeDivider())
card.stack.addArrangedSubview(makeEvidenceUploadBlock(
icon: "video.fill",
title: "上传视频",
subtitle: "支持 MP4 格式,时长不超过 60 秒",
attachments: viewModel.videos,
maxCount: 3,
kind: .video
))
return card
}
private func makeDescriptionCard() -> UIView {
let card = sectionCard(index: 3, title: "举报说明")
descriptionView.text = viewModel.description
let inputContainer = UIView()
inputContainer.backgroundColor = UIColor(hex: 0xFBFCFE)
inputContainer.layer.cornerRadius = 12
inputContainer.layer.borderWidth = 1
inputContainer.layer.borderColor = UIColor(hex: 0xCDD5E1).cgColor
let placeholder = UILabel()
placeholder.text = "请描述摄影师的位置、特征、行为..."
placeholder.font = .systemFont(ofSize: 15)
placeholder.textColor = UIColor(hex: 0x9CA3AF)
placeholder.isHidden = !viewModel.description.isEmpty
let count = WildReportUI.label("\(viewModel.description.count)/500", font: .systemFont(ofSize: 14), color: UIColor(hex: 0x6B7280))
count.textAlignment = .right
inputContainer.addSubview(descriptionView)
inputContainer.addSubview(placeholder)
inputContainer.addSubview(count)
descriptionView.snp.makeConstraints { make in
make.edges.equalToSuperview()
make.height.equalTo(132)
}
placeholder.snp.makeConstraints { make in
make.top.equalToSuperview().offset(18)
make.leading.equalToSuperview().offset(14)
make.trailing.lessThanOrEqualToSuperview().inset(14)
}
count.snp.makeConstraints { make in
make.trailing.bottom.equalToSuperview().inset(12)
}
card.stack.addArrangedSubview(inputContainer)
return card
}
private func makeLocationCard() -> UIView {
let card = sectionCard(index: 4, title: "举报位置")
let container = UIView()
container.backgroundColor = UIColor(hex: 0xF6FAFF)
container.layer.cornerRadius = 12
container.layer.borderWidth = 1
container.layer.borderColor = UIColor(hex: 0xC8DDFF).cgColor
let iconWrap = UIView()
iconWrap.backgroundColor = AppColor.primary.withAlphaComponent(0.10)
iconWrap.layer.cornerRadius = 21
let icon = UIImageView(image: UIImage(systemName: "mappin.circle.fill"))
icon.tintColor = AppColor.primary
iconWrap.addSubview(icon)
icon.snp.makeConstraints { make in
make.center.equalToSuperview()
make.size.equalTo(30)
}
let textStack = UIStackView()
textStack.axis = .vertical
textStack.spacing = 5
let title = WildReportUI.label(viewModel.locationTitleText, font: .systemFont(ofSize: 15, weight: .semibold), color: AppColor.textPrimary, lines: 2)
title.adjustsFontSizeToFitWidth = true
title.minimumScaleFactor = 0.78
let subtitle = WildReportUI.label(viewModel.locationSubtitleText, font: .systemFont(ofSize: 12), color: AppColor.textSecondary, lines: 0)
textStack.addArrangedSubview(title)
textStack.addArrangedSubview(subtitle)
let button = UIButton(type: .system)
button.setTitle(viewModel.isUpdatingLocation ? "定位中" : "更新定位", for: .normal)
button.titleLabel?.font = .systemFont(ofSize: 13, weight: .semibold)
button.setTitleColor(AppColor.primary, for: .normal)
button.backgroundColor = .white
button.layer.cornerRadius = 17
button.layer.borderWidth = 1
button.layer.borderColor = AppColor.primary.cgColor
button.isEnabled = !viewModel.isUpdatingLocation
button.alpha = viewModel.isUpdatingLocation ? 0.6 : 1
button.addTarget(self, action: #selector(refreshLocationTapped), for: .touchUpInside)
container.addSubview(iconWrap)
container.addSubview(textStack)
container.addSubview(button)
iconWrap.snp.makeConstraints { make in
make.leading.top.bottom.equalToSuperview().inset(12)
make.size.equalTo(42)
}
button.snp.makeConstraints { make in
make.trailing.equalToSuperview().inset(12)
make.centerY.equalToSuperview()
make.width.equalTo(82)
make.height.equalTo(34)
}
textStack.snp.makeConstraints { make in
make.leading.equalTo(iconWrap.snp.trailing).offset(12)
make.trailing.equalTo(button.snp.leading).offset(-8)
make.centerY.equalToSuperview()
make.top.greaterThanOrEqualToSuperview().offset(12)
make.bottom.lessThanOrEqualToSuperview().inset(12)
}
card.stack.addArrangedSubview(container)
return card
}
private func makeContactCard() -> UIView {
let card = sectionCard(index: 5, title: "摄影师微信号/手机号", optional: true)
contactField.text = viewModel.contact
card.stack.addArrangedSubview(contactField)
contactField.snp.makeConstraints { make in make.height.equalTo(48) }
return card
}
private func makePaymentCard() -> UIView {
let card = sectionCard(index: 6, title: "线下微信、支付宝截图", optional: true)
card.stack.addArrangedSubview(makeEvidenceUploadBlock(
icon: "creditcard.fill",
title: "上传支付截图",
subtitle: "支持 JPG、PNG 格式,最多 3 张",
attachments: viewModel.paymentImages,
maxCount: 3,
kind: .payment
))
return card
}
private func sectionCard(index: Int, title: String, optional: Bool = false) -> WildReportCardView {
let card = WildReportCardView(spacing: 18, inset: 16)
card.layer.cornerRadius = 16
card.stack.addArrangedSubview(WildReportSectionTitleView(index: index, title: title, optional: optional))
return card
}
private func makeEvidenceUploadBlock(
icon: String,
title: String,
subtitle: String,
attachments: [WildReportAttachment],
maxCount: Int,
kind: WildReportAttachmentKind
) -> UIView {
let block = UIStackView()
block.axis = .vertical
block.spacing = 12
let header = UIStackView()
header.axis = .horizontal
header.alignment = .center
header.spacing = 12
let iconWrap = UIView()
iconWrap.backgroundColor = AppColor.primaryLight
iconWrap.layer.cornerRadius = 12
let image = UIImageView(image: UIImage(systemName: icon))
image.tintColor = AppColor.primary
iconWrap.addSubview(image)
image.snp.makeConstraints { make in
make.center.equalToSuperview()
make.size.equalTo(22)
}
iconWrap.snp.makeConstraints { make in
make.size.equalTo(42)
}
let textStack = UIStackView()
textStack.axis = .vertical
textStack.spacing = 3
textStack.addArrangedSubview(WildReportUI.label(title, font: .systemFont(ofSize: 16, weight: .bold), color: AppColor.textPrimary))
textStack.addArrangedSubview(WildReportUI.label(subtitle, font: .systemFont(ofSize: 13), color: AppColor.textSecondary, lines: 0))
header.addArrangedSubview(iconWrap)
header.addArrangedSubview(textStack)
header.addArrangedSubview(UIView())
block.addArrangedSubview(header)
let scroll = UIScrollView()
scroll.keyboardDismissMode = .onDrag
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,
onPreview: { [weak self] in self?.previewAttachment(attachment) },
onRemove: { [weak self] in self?.viewModel.removeAttachment(attachment) }
))
}
let addCount = availableAddTileCount(kind: kind, attachments: attachments, maxCount: maxCount)
for _ in 0..<addCount {
let addTile = WildReportAddAttachmentTileView { [weak self] in
self?.presentMediaPicker(for: WildReportPickerTarget(kind: kind))
}
tiles.addArrangedSubview(addTile)
}
scroll.snp.makeConstraints { make in
make.height.equalTo(62)
}
block.addArrangedSubview(scroll)
return block
}
private func makeDivider() -> UIView {
let divider = UIView()
divider.backgroundColor = AppColor.border
divider.snp.makeConstraints { make in
make.height.equalTo(1)
}
return divider
}
private func realNameBannerText() -> NSAttributedString {
let text = NSMutableAttributedString()
[
("实名登录", AppColor.primary, UIFont.systemFont(ofSize: 16, weight: .bold)),
("后提交,", AppColor.textPrimary, UIFont.systemFont(ofSize: 16, weight: .medium)),
("景区公安", AppColor.primary, UIFont.systemFont(ofSize: 16, weight: .bold)),
("将接收并处理", AppColor.textPrimary, UIFont.systemFont(ofSize: 16, weight: .medium))
].forEach { value, color, font in
text.append(NSAttributedString(string: value, attributes: [
.foregroundColor: color,
.font: font
]))
}
let paragraph = NSMutableParagraphStyle()
paragraph.lineSpacing = 4
text.addAttribute(.paragraphStyle, value: paragraph, range: NSRange(location: 0, length: text.length))
return text
}
private func presentMediaPicker(for target: WildReportPickerTarget) {
dismissKeyboard()
let remaining = remainingSelectionCount(for: target)
guard remaining > 0 else {
showToast(target.limitMessage)
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 max(0, 3 - viewModel.paymentImages.count)
}
}
private func availableAddTileCount(
kind: WildReportAttachmentKind,
attachments: [WildReportAttachment],
maxCount: Int
) -> Int {
let ownRemaining = max(0, maxCount - attachments.count)
let evidenceRemaining = max(0, 9 - viewModel.images.count - viewModel.videos.count)
switch kind {
case .image:
return min(ownRemaining, evidenceRemaining) > 0 ? 1 : 0
case .video:
return min(ownRemaining, evidenceRemaining) > 0 ? 1 : 0
case .payment:
return ownRemaining > 0 ? 1 : 0
}
}
private func previewAttachment(_ attachment: WildReportAttachment) {
guard let item = MediaPreviewItem(wildReportAttachment: attachment) else {
showToast("当前附件暂无本地预览")
return
}
presentMediaPreview(items: [item], startIndex: 0)
}
@objc private func refreshLocationTapped() { viewModel.refreshCurrentLocation() }
@objc private func submitTapped() {
Task {
await viewModel.submitReport(
api: api,
uploader: uploader,
scenicId: AppStore.shared.currentScenicId,
scenicName: AppStore.shared.currentScenicName
)
}
}
@objc private func contactChanged() { viewModel.updateContact(contactField.text ?? "") }
@objc private func dismissKeyboard() { view.endEditing(true) }
@MainActor
private func updateSubmitLoading() {
if viewModel.isSubmitting {
guard !isShowingSubmitLoading else { return }
isShowingSubmitLoading = true
showLoading()
} else if isShowingSubmitLoading {
isShowingSubmitLoading = false
hideLoading()
}
}
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 + 16, 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 WildPhotographerReportSubmitViewController: 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)
await MainActor.run {
switch target {
case .image:
viewModel.addImages(attachments)
case .video:
viewModel.addVideos(attachments)
case .payment:
viewModel.addPaymentImages(attachments)
}
}
} catch {
await MainActor.run { showToast(error.localizedDescription) }
}
}
}
}
extension WildPhotographerReportSubmitViewController: UITextViewDelegate {
func textViewDidBeginEditing(_ textView: UITextView) {
activeInputView = textView
DispatchQueue.main.async { [weak self] in self?.scrollActiveInputIntoView(animated: true) }
}
func textViewDidChange(_ textView: UITextView) {
viewModel.updateDescription(textView.text)
}
func textViewDidEndEditing(_ textView: UITextView) {
if activeInputView === textView {
activeInputView = nil
}
}
}
extension WildPhotographerReportSubmitViewController: UITextFieldDelegate {
func textFieldDidBeginEditing(_ textField: UITextField) {
activeInputView = textField
DispatchQueue.main.async { [weak self] in self?.scrollActiveInputIntoView(animated: true) }
}
func textFieldDidEndEditing(_ textField: UITextField) {
if activeInputView === textField {
activeInputView = nil
}
}
}
extension WildPhotographerReportSubmitViewController: UIGestureRecognizerDelegate {
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool {
var touchedView = touch.view
while let currentView = touchedView {
if currentView is UIControl || currentView is UITextView || currentView is UITextField {
return false
}
touchedView = currentView.superview
}
return true
}
}
///
private final class WildReportSectionTitleView: UIView {
init(index: Int, title: String, optional: Bool) {
super.init(frame: .zero)
let bar = UIView()
bar.backgroundColor = AppColor.primary
bar.layer.cornerRadius = 2
let label = UILabel()
label.font = .systemFont(ofSize: 17, weight: .bold)
label.textColor = AppColor.textPrimary
label.text = "\(index). \(title)"
let optionalLabel = UILabel()
optionalLabel.font = .systemFont(ofSize: 14)
optionalLabel.textColor = AppColor.textSecondary
optionalLabel.text = "(选填)"
optionalLabel.isHidden = !optional
addSubview(bar)
addSubview(label)
addSubview(optionalLabel)
bar.snp.makeConstraints { make in
make.leading.equalToSuperview()
make.centerY.equalToSuperview()
make.width.equalTo(4)
make.height.equalTo(20)
}
label.snp.makeConstraints { make in
make.leading.equalTo(bar.snp.trailing).offset(8)
make.top.bottom.equalToSuperview()
}
optionalLabel.snp.makeConstraints { make in
make.leading.equalTo(label.snp.trailing)
make.centerY.equalTo(label)
make.trailing.lessThanOrEqualToSuperview()
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
///
private final class WildReportTypeOptionButton: UIControl {
private let titleLabel = UILabel()
override init(frame: CGRect) {
super.init(frame: frame)
layer.cornerRadius = 12
layer.borderWidth = 1
titleLabel.font = .systemFont(ofSize: 15, weight: .bold)
titleLabel.textAlignment = .center
titleLabel.adjustsFontSizeToFitWidth = true
titleLabel.minimumScaleFactor = 0.8
titleLabel.numberOfLines = 2
titleLabel.lineBreakMode = .byTruncatingTail
titleLabel.isUserInteractionEnabled = false
addSubview(titleLabel)
titleLabel.snp.makeConstraints { make in
make.centerY.equalToSuperview()
make.leading.trailing.equalToSuperview().inset(6)
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func apply(type: WildReportType, isSelected: Bool) {
titleLabel.text = type.label
backgroundColor = isSelected ? AppColor.primary : UIColor(hex: 0xF6FAFF)
layer.borderColor = (isSelected ? AppColor.primary : UIColor(hex: 0xC8DDFF)).cgColor
titleLabel.textColor = isSelected ? .white : AppColor.textPrimary
}
}
///
final class WildPhotographerReportSuccessViewController: BaseViewController {
private let viewModel: WildPhotographerReportSuccessViewModel
private let homeViewModel: WildPhotographerReportHomeViewModel
private let scrollView = UIScrollView()
private let stack = UIStackView()
init(result: WildReportSubmitResult, homeViewModel: WildPhotographerReportHomeViewModel) {
self.viewModel = WildPhotographerReportSuccessViewModel(result: result)
self.homeViewModel = homeViewModel
super.init(nibName: nil, bundle: nil)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func setupNavigationBar() {
title = "举报成功"
}
override func setupUI() {
view.backgroundColor = UIColor(hex: 0xF8FAFF)
scrollView.showsVerticalScrollIndicator = false
stack.axis = .vertical
stack.spacing = 12
view.addSubview(scrollView)
scrollView.addSubview(stack)
stack.addArrangedSubview(WildReportSuccessHeroView())
stack.addArrangedSubview(WildReportSuccessProgressView(steps: viewModel.progressSteps))
let detailCard = WildReportSuccessDetailCardView(
reportID: viewModel.result.record.id,
reportType: viewModel.result.record.reportType.label,
location: viewModel.result.record.location,
uploadSummary: viewModel.uploadSummary
)
detailCard.onCopyReportID = { [weak self] in
guard let self else { return }
_ = viewModel.copyReportID()
showToast("举报编号已复制")
}
stack.addArrangedSubview(detailCard)
let detailButton = makeActionButton(title: "查看处理进度", style: .primary)
detailButton.addTarget(self, action: #selector(detailTapped), for: .touchUpInside)
let homeButton = makeActionButton(title: "返回首页", style: .secondary)
homeButton.addTarget(self, action: #selector(homeTapped), for: .touchUpInside)
stack.addArrangedSubview(detailButton)
stack.addArrangedSubview(homeButton)
}
override func setupConstraints() {
scrollView.snp.makeConstraints { make in
make.edges.equalTo(view.safeAreaLayoutGuide)
}
stack.snp.makeConstraints { make in
make.top.equalToSuperview().offset(14)
make.leading.trailing.equalTo(scrollView.frameLayoutGuide).inset(18)
make.bottom.equalToSuperview().inset(28)
make.width.equalTo(scrollView.frameLayoutGuide).offset(-36)
}
}
private func makeActionButton(title: String, style: WildReportSuccessActionButtonStyle) -> UIButton {
let button = UIButton(type: .system)
button.titleLabel?.font = .systemFont(ofSize: 20, weight: .bold)
button.setTitle(title, for: .normal)
button.layer.cornerRadius = 14
button.layer.borderWidth = style == .secondary ? 1.2 : 0
button.layer.borderColor = AppColor.primary.cgColor
button.backgroundColor = style == .primary ? AppColor.primary : .white
button.setTitleColor(style == .primary ? .white : AppColor.primary, for: .normal)
button.snp.makeConstraints { make in
make.height.equalTo(54)
}
return button
}
@objc private func detailTapped() {
navigationController?.pushViewController(
WildPhotographerReportDetailViewController(record: viewModel.result.record, homeViewModel: homeViewModel),
animated: true
)
}
@objc private func homeTapped() {
navigationController?.popToViewController(
navigationController?.viewControllers.first(where: { $0 is WildPhotographerReportHomeViewController }) ?? self,
animated: true
)
}
}
///
private enum WildReportSuccessActionButtonStyle {
case primary
case secondary
}
///
private final class WildReportSuccessHeroView: UIView {
private let gradientLayer = CAGradientLayer()
private let stack = UIStackView()
override init(frame: CGRect) {
super.init(frame: frame)
setupUI()
setupConstraints()
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func layoutSubviews() {
super.layoutSubviews()
gradientLayer.frame = bounds
gradientLayer.cornerRadius = 18
}
private func setupUI() {
layer.cornerRadius = 18
layer.borderWidth = 1
layer.borderColor = UIColor(hex: 0xBFD7FF).cgColor
layer.masksToBounds = true
gradientLayer.colors = [
UIColor.white.cgColor,
UIColor(hex: 0xEEF6FF).cgColor
]
gradientLayer.startPoint = CGPoint(x: 0, y: 0)
gradientLayer.endPoint = CGPoint(x: 1, y: 1)
layer.insertSublayer(gradientLayer, at: 0)
stack.axis = .vertical
stack.alignment = .center
stack.spacing = 14
let iconView = WildReportSuccessCheckmarkView()
let titleLabel = WildReportUI.label("举报已提交", font: .systemFont(ofSize: 24, weight: .heavy), color: AppColor.textPrimary)
let descriptionLabel = WildReportUI.label(
"景区公安已收到线索,将尽快前往现场核实处理",
font: .systemFont(ofSize: 14),
color: AppColor.textSecondary,
lines: 0
)
descriptionLabel.textAlignment = .center
addSubview(stack)
stack.addArrangedSubview(iconView)
stack.addArrangedSubview(titleLabel)
stack.addArrangedSubview(descriptionLabel)
iconView.snp.makeConstraints { make in
make.size.equalTo(78)
}
descriptionLabel.snp.makeConstraints { make in
make.leading.trailing.equalToSuperview().inset(16)
}
}
private func setupConstraints() {
stack.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(UIEdgeInsets(top: 24, left: 16, bottom: 24, right: 16))
}
}
}
///
private final class WildReportSuccessCheckmarkView: UIView {
private let outerCircle = UIView()
private let innerCircle = UIView()
private let iconView = UIImageView(image: UIImage(systemName: "checkmark"))
override init(frame: CGRect) {
super.init(frame: frame)
setupUI()
setupConstraints()
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private func setupUI() {
outerCircle.backgroundColor = AppColor.primary.withAlphaComponent(0.12)
outerCircle.layer.cornerRadius = 39
innerCircle.backgroundColor = AppColor.primary
innerCircle.layer.cornerRadius = 29
iconView.tintColor = .white
iconView.contentMode = .scaleAspectFit
iconView.preferredSymbolConfiguration = UIImage.SymbolConfiguration(pointSize: 28, weight: .bold)
addSubview(outerCircle)
addSubview(innerCircle)
addSubview(iconView)
}
private func setupConstraints() {
outerCircle.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
innerCircle.snp.makeConstraints { make in
make.center.equalToSuperview()
make.size.equalTo(58)
}
iconView.snp.makeConstraints { make in
make.center.equalToSuperview()
make.size.equalTo(28)
}
}
}
///
private final class WildReportSuccessProgressView: UIView {
private let contentView = UIView()
private let stack = UIStackView()
private let steps: [String]
private var itemViews: [WildReportSuccessProgressItemView] = []
init(steps: [String]) {
self.steps = steps
super.init(frame: .zero)
setupUI()
setupConstraints()
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private func setupUI() {
backgroundColor = .white
layer.cornerRadius = 16
layer.borderWidth = 1
layer.borderColor = AppColor.border.cgColor
stack.axis = .horizontal
stack.alignment = .top
stack.distribution = .fillEqually
stack.spacing = 0
addSubview(contentView)
contentView.addSubview(stack)
for (index, step) in steps.enumerated() {
let item = WildReportSuccessProgressItemView(title: step, isCompleted: index == 0)
itemViews.append(item)
stack.addArrangedSubview(item)
}
for index in steps.indices.dropLast() {
if index < steps.count - 1 {
let connector = WildReportSuccessProgressConnectorView(isCompleted: index == 0)
contentView.insertSubview(connector, belowSubview: stack)
connector.snp.makeConstraints { make in
make.leading.equalTo(itemViews[index].connectorAnchorView.snp.centerX).offset(12)
make.trailing.equalTo(itemViews[index + 1].connectorAnchorView.snp.centerX).offset(-12)
make.centerY.equalTo(itemViews[index].connectorAnchorView)
make.height.equalTo(24)
}
}
}
}
private func setupConstraints() {
contentView.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(UIEdgeInsets(top: 16, left: 12, bottom: 16, right: 12))
}
stack.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
}
}
///
private final class WildReportSuccessProgressItemView: UIView {
private let nodeView: WildReportSuccessProgressNodeView
private let titleLabel = UILabel()
var connectorAnchorView: UIView { nodeView }
init(title: String, isCompleted: Bool) {
self.nodeView = WildReportSuccessProgressNodeView(isCompleted: isCompleted)
super.init(frame: .zero)
setupUI(title: title, isCompleted: isCompleted)
setupConstraints()
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private func setupUI(title: String, isCompleted: Bool) {
titleLabel.text = title
titleLabel.font = .systemFont(ofSize: 11, weight: isCompleted ? .semibold : .regular)
titleLabel.textColor = isCompleted ? AppColor.primary : UIColor(hex: 0x9CA3AF)
titleLabel.textAlignment = .center
titleLabel.numberOfLines = 2
addSubview(nodeView)
addSubview(titleLabel)
}
private func setupConstraints() {
nodeView.snp.makeConstraints { make in
make.top.centerX.equalToSuperview()
make.size.equalTo(24)
}
titleLabel.snp.makeConstraints { make in
make.top.equalTo(nodeView.snp.bottom).offset(8)
make.leading.trailing.bottom.equalToSuperview()
}
}
}
///
private final class WildReportSuccessProgressNodeView: UIView {
private let innerCircle = UIView()
private let checkmarkView = UIImageView(image: UIImage(systemName: "checkmark"))
private let isCompleted: Bool
init(isCompleted: Bool) {
self.isCompleted = isCompleted
super.init(frame: .zero)
setupUI()
setupConstraints()
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private func setupUI() {
layer.cornerRadius = 12
layer.borderWidth = 2
layer.borderColor = (isCompleted ? AppColor.primary : UIColor(hex: 0xD1D5DB)).cgColor
innerCircle.backgroundColor = isCompleted ? AppColor.primary : .clear
innerCircle.layer.cornerRadius = 10
checkmarkView.tintColor = .white
checkmarkView.contentMode = .scaleAspectFit
checkmarkView.preferredSymbolConfiguration = UIImage.SymbolConfiguration(pointSize: 10, weight: .bold)
checkmarkView.isHidden = !isCompleted
addSubview(innerCircle)
addSubview(checkmarkView)
}
private func setupConstraints() {
innerCircle.snp.makeConstraints { make in
make.center.equalToSuperview()
make.size.equalTo(20)
}
checkmarkView.snp.makeConstraints { make in
make.center.equalToSuperview()
make.size.equalTo(10)
}
}
}
/// 线
private final class WildReportSuccessProgressConnectorView: UIView {
private let isCompleted: Bool
private let solidLine = UIView()
private let dotsStack = UIStackView()
init(isCompleted: Bool) {
self.isCompleted = isCompleted
super.init(frame: .zero)
setupUI()
setupConstraints()
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private func setupUI() {
if isCompleted {
solidLine.backgroundColor = AppColor.primary
addSubview(solidLine)
} else {
dotsStack.axis = .horizontal
dotsStack.alignment = .center
dotsStack.distribution = .equalSpacing
dotsStack.spacing = 3
addSubview(dotsStack)
for _ in 0..<5 {
let dot = UIView()
dot.backgroundColor = UIColor(hex: 0xD1D5DB)
dot.layer.cornerRadius = 1.5
dot.snp.makeConstraints { make in
make.size.equalTo(3)
}
dotsStack.addArrangedSubview(dot)
}
}
}
private func setupConstraints() {
snp.makeConstraints { make in
make.height.equalTo(24)
}
if isCompleted {
solidLine.snp.makeConstraints { make in
make.leading.trailing.equalToSuperview()
make.centerY.equalToSuperview()
make.height.equalTo(2)
}
} else {
dotsStack.snp.makeConstraints { make in
make.centerY.equalToSuperview()
make.leading.trailing.equalToSuperview()
}
}
}
}
///
private final class WildReportSuccessDetailCardView: UIView {
var onCopyReportID: (() -> Void)?
private let stack = UIStackView()
init(reportID: String, reportType: String, location: String, uploadSummary: String) {
super.init(frame: .zero)
setupUI(reportID: reportID, reportType: reportType, location: location, uploadSummary: uploadSummary)
setupConstraints()
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private func setupUI(reportID: String, reportType: String, location: String, uploadSummary: String) {
backgroundColor = .white
layer.cornerRadius = 16
layer.borderWidth = 1
layer.borderColor = AppColor.border.cgColor
stack.axis = .vertical
stack.spacing = 0
addSubview(stack)
appendRow(icon: "doc.text.fill", title: "举报编号", value: reportID, valueColor: AppColor.primary, showsCopy: true)
appendDivider()
appendRow(icon: "tag.fill", title: "举报类型", value: reportType)
appendDivider()
appendRow(icon: "mappin.circle.fill", title: "举报位置", value: location)
appendDivider()
appendRow(icon: "photo.on.rectangle.angled", title: "已上传", value: uploadSummary)
}
private func setupConstraints() {
stack.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
}
private func appendRow(icon: String, title: String, value: String, valueColor: UIColor = AppColor.textPrimary, showsCopy: Bool = false) {
let row = WildReportSuccessDetailRowView(
icon: icon,
title: title,
value: value,
valueColor: valueColor,
showsCopy: showsCopy
)
row.onCopy = { [weak self] in self?.onCopyReportID?() }
stack.addArrangedSubview(row)
}
private func appendDivider() {
let container = UIView()
let divider = UIView()
divider.backgroundColor = AppColor.border
container.addSubview(divider)
divider.snp.makeConstraints { make in
make.top.bottom.trailing.equalToSuperview()
make.leading.equalToSuperview().offset(56)
make.height.equalTo(1)
}
stack.addArrangedSubview(container)
}
}
///
private final class WildReportSuccessDetailRowView: UIView {
var onCopy: (() -> Void)?
private let iconContainer = UIView()
private let iconView = UIImageView()
private let titleLabel = UILabel()
private let valueLabel = UILabel()
private let copyButton = UIButton(type: .system)
private let showsCopy: Bool
init(icon: String, title: String, value: String, valueColor: UIColor, showsCopy: Bool) {
self.showsCopy = showsCopy
super.init(frame: .zero)
setupUI(icon: icon, title: title, value: value, valueColor: valueColor)
setupConstraints()
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private func setupUI(icon: String, title: String, value: String, valueColor: UIColor) {
iconContainer.backgroundColor = AppColor.primaryLight
iconContainer.layer.cornerRadius = 10
iconView.image = UIImage(systemName: icon)
iconView.tintColor = AppColor.primary
iconView.contentMode = .scaleAspectFit
iconView.preferredSymbolConfiguration = UIImage.SymbolConfiguration(pointSize: 22, weight: .semibold)
titleLabel.text = title
titleLabel.font = .systemFont(ofSize: 13)
titleLabel.textColor = AppColor.textSecondary
valueLabel.text = value
valueLabel.font = .systemFont(ofSize: 16, weight: .semibold)
valueLabel.textColor = valueColor
valueLabel.numberOfLines = 2
valueLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
addSubview(iconContainer)
iconContainer.addSubview(iconView)
addSubview(titleLabel)
addSubview(valueLabel)
guard showsCopy else { return }
copyButton.setImage(UIImage(systemName: "doc.on.doc"), for: .normal)
copyButton.tintColor = AppColor.primary
copyButton.backgroundColor = AppColor.primaryLight
copyButton.layer.cornerRadius = 17
copyButton.accessibilityLabel = "复制举报编号"
copyButton.addTarget(self, action: #selector(copyTapped), for: .touchUpInside)
addSubview(copyButton)
}
private func setupConstraints() {
iconContainer.snp.makeConstraints { make in
make.leading.equalToSuperview().offset(16)
make.centerY.equalToSuperview()
make.size.equalTo(42)
make.top.greaterThanOrEqualToSuperview().offset(14)
make.bottom.lessThanOrEqualToSuperview().inset(14)
}
iconView.snp.makeConstraints { make in
make.center.equalToSuperview()
make.size.equalTo(22)
}
titleLabel.snp.makeConstraints { make in
make.top.equalToSuperview().offset(14)
make.leading.equalTo(iconContainer.snp.trailing).offset(14)
make.trailing.lessThanOrEqualToSuperview().inset(16)
}
valueLabel.snp.makeConstraints { make in
make.top.equalTo(titleLabel.snp.bottom).offset(4)
make.leading.equalTo(titleLabel)
make.bottom.equalToSuperview().inset(14)
if showsCopy {
make.trailing.lessThanOrEqualTo(copyButton.snp.leading).offset(-8)
} else {
make.trailing.equalToSuperview().inset(16)
}
}
guard showsCopy else { return }
copyButton.snp.makeConstraints { make in
make.trailing.equalToSuperview().inset(16)
make.centerY.equalToSuperview()
make.size.equalTo(34)
}
}
@objc private func copyTapped() {
onCopy?()
}
}