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

1639 lines
61 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 AVFoundation
import PhotosUI
import QuickLook
import SnapKit
import UIKit
import UniformTypeIdentifiers
///
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 var previewURL: URL?
private weak var activeInputView: UIView?
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 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(viewModel.isSubmitting ? viewModel.submitProgressText : "提交举报", 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.isEmpty ? WildReportType.fallbackOptions : viewModel.reportTypes
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: type == viewModel.selectedReportType)
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 url = attachment.localFileURL else {
showToast("当前附件暂无本地预览")
return
}
previewURL = url
let preview = QLPreviewController()
preview.dataSource = self
present(preview, animated: true)
}
@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) }
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: QLPreviewControllerDataSource {
func numberOfPreviewItems(in controller: QLPreviewController) -> Int {
previewURL == nil ? 0 : 1
}
func previewController(_ controller: QLPreviewController, previewItemAt index: Int) -> QLPreviewItem {
previewURL! as NSURL
}
}
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 enum WildReportPickerTarget: Equatable {
case image
case video
case payment
init(kind: WildReportAttachmentKind) {
switch kind {
case .image: self = .image
case .video: self = .video
case .payment: self = .payment
}
}
var attachmentKind: WildReportAttachmentKind {
switch self {
case .image: return .image
case .video: return .video
case .payment: return .payment
}
}
var titlePrefix: String {
switch self {
case .image: return "现场图片"
case .video: return "现场视频"
case .payment: return "支付截图"
}
}
var limitMessage: String {
switch self {
case .image: return "现场证据最多上传9项"
case .video: return "现场视频最多上传3段"
case .payment: return "支付截图最多上传3张"
}
}
}
/// PHPicker /
private enum WildReportLocalMediaLoader {
static func load(results: [PHPickerResult], target: WildReportPickerTarget) async throws -> [WildReportAttachment] {
var attachments: [WildReportAttachment] = []
for result in results {
let attachment = target == .video
? try await loadVideo(from: result.itemProvider, target: target)
: try await loadImage(from: result.itemProvider, target: target)
attachments.append(attachment)
}
return attachments
}
private static func loadImage(from provider: NSItemProvider, target: WildReportPickerTarget) async throws -> WildReportAttachment {
try await withCheckedThrowingContinuation { continuation in
guard provider.canLoadObject(ofClass: UIImage.self) else {
continuation.resume(throwing: OSSUploadError.unsupportedFileType)
return
}
provider.loadObject(ofClass: UIImage.self) { object, error in
if let error {
continuation.resume(throwing: error)
return
}
guard let image = object as? UIImage,
let data = image.jpegData(compressionQuality: 0.9) else {
continuation.resume(throwing: OSSUploadError.emptyFile)
return
}
do {
let fileName = "report_image_\(UUID().uuidString).jpg"
let url = try write(data: data, fileName: fileName)
continuation.resume(returning: WildReportAttachment(
kind: target.attachmentKind,
title: target.titlePrefix,
localFileURL: url,
fileName: fileName
))
} catch {
continuation.resume(throwing: error)
}
}
}
}
private static func loadVideo(from provider: NSItemProvider, target: WildReportPickerTarget) async throws -> WildReportAttachment {
try await withCheckedThrowingContinuation { continuation in
provider.loadFileRepresentation(forTypeIdentifier: UTType.movie.identifier) { url, error in
if let error {
continuation.resume(throwing: error)
return
}
guard let url else {
continuation.resume(throwing: OSSUploadError.emptyFile)
return
}
do {
let ext = url.pathExtension.isEmpty ? "mp4" : url.pathExtension
let fileName = "report_video_\(UUID().uuidString).\(ext)"
let targetURL = try temporaryFileURL(fileName: fileName)
if FileManager.default.fileExists(atPath: targetURL.path) {
try FileManager.default.removeItem(at: targetURL)
}
try FileManager.default.copyItem(at: url, to: targetURL)
continuation.resume(returning: WildReportAttachment(
kind: target.attachmentKind,
title: target.titlePrefix,
localFileURL: targetURL,
fileName: fileName
))
} catch {
continuation.resume(throwing: error)
}
}
}
}
private static func write(data: Data, fileName: String) throws -> URL {
let url = try temporaryFileURL(fileName: fileName)
try data.write(to: url, options: .atomic)
return url
}
private static func temporaryFileURL(fileName: String) throws -> URL {
let directory = FileManager.default.temporaryDirectory.appendingPathComponent("wild_report_media", isDirectory: true)
if !FileManager.default.fileExists(atPath: directory.path) {
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
}
return directory.appendingPathComponent(fileName)
}
}
///
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
}
}
/// tile
private final class WildReportAttachmentTileView: UIControl {
private let gradientLayer = CAGradientLayer()
private let previewAction: () -> Void
private let removeAction: () -> Void
init(
attachment: WildReportAttachment,
onPreview: @escaping () -> Void,
onRemove: @escaping () -> Void
) {
self.previewAction = onPreview
self.removeAction = onRemove
super.init(frame: .zero)
layer.cornerRadius = 10
clipsToBounds = false
gradientLayer.cornerRadius = 10
let colors: [UIColor] = attachment.kind == .video
? [UIColor(hex: 0xB7D8FF), UIColor(hex: 0x4C8FEF)]
: [UIColor(hex: 0xCFE6FF), UIColor(hex: 0x7EB4FF)]
gradientLayer.colors = colors.map(\.cgColor)
gradientLayer.startPoint = CGPoint(x: 0, y: 0)
gradientLayer.endPoint = CGPoint(x: 1, y: 1)
layer.insertSublayer(gradientLayer, at: 0)
let thumbnailView = UIImageView()
thumbnailView.image = WildReportAttachmentThumbnailFactory.thumbnail(for: attachment)
thumbnailView.contentMode = .scaleAspectFill
thumbnailView.clipsToBounds = true
thumbnailView.layer.cornerRadius = 10
thumbnailView.isUserInteractionEnabled = false
thumbnailView.isHidden = thumbnailView.image == nil
addSubview(thumbnailView)
thumbnailView.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
let icon = UIImageView(image: UIImage(systemName: attachment.kind == .video ? "play.circle.fill" : "mountain.2.fill"))
icon.tintColor = UIColor.white.withAlphaComponent(0.95)
icon.contentMode = .scaleAspectFit
icon.isHidden = attachment.kind != .video && thumbnailView.image != nil
addSubview(icon)
icon.snp.makeConstraints { make in
make.center.equalToSuperview()
make.size.equalTo(attachment.kind == .video ? 30 : 26)
}
let removeButton = UIButton(type: .system)
removeButton.setImage(UIImage(systemName: "xmark.circle.fill"), for: .normal)
removeButton.setPreferredSymbolConfiguration(UIImage.SymbolConfiguration(pointSize: 17, weight: .semibold), forImageIn: .normal)
removeButton.tintColor = UIColor(hex: 0x8D93A1)
removeButton.backgroundColor = .white
removeButton.layer.cornerRadius = 14
removeButton.layer.shadowColor = UIColor.black.cgColor
removeButton.layer.shadowOpacity = 0.10
removeButton.layer.shadowRadius = 4
removeButton.layer.shadowOffset = CGSize(width: 0, height: 2)
removeButton.addTarget(self, action: #selector(removeTapped), for: .touchUpInside)
addSubview(removeButton)
removeButton.snp.makeConstraints { make in
make.top.trailing.equalToSuperview().inset(-2)
make.size.equalTo(34)
}
addTarget(self, action: #selector(previewTapped), for: .touchUpInside)
snp.makeConstraints { make in
make.size.equalTo(58)
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func layoutSubviews() {
super.layoutSubviews()
gradientLayer.frame = bounds
}
@objc private func previewTapped() {
previewAction()
}
@objc private func removeTapped() {
removeAction()
}
}
///
private enum WildReportAttachmentThumbnailFactory {
static func thumbnail(for attachment: WildReportAttachment) -> UIImage? {
guard let url = attachment.localFileURL else { return nil }
switch attachment.kind {
case .image, .payment:
return UIImage(contentsOfFile: url.path)
case .video:
return videoThumbnail(url: url)
}
}
private static func videoThumbnail(url: URL) -> UIImage? {
let asset = AVURLAsset(url: url)
let generator = AVAssetImageGenerator(asset: asset)
generator.appliesPreferredTrackTransform = true
generator.maximumSize = CGSize(width: 160, height: 160)
do {
let cgImage = try generator.copyCGImage(at: CMTime(seconds: 0.1, preferredTimescale: 600), actualTime: nil)
return UIImage(cgImage: cgImage)
} catch {
return nil
}
}
}
/// tile线
private final class WildReportAddAttachmentTileView: UIControl {
private let tapAction: () -> Void
private let borderLayer = CAShapeLayer()
init(action: @escaping () -> Void) {
self.tapAction = action
super.init(frame: .zero)
backgroundColor = .white
layer.cornerRadius = 10
borderLayer.strokeColor = UIColor(hex: 0xCDD5E1).cgColor
borderLayer.fillColor = UIColor.clear.cgColor
borderLayer.lineWidth = 1
borderLayer.lineDashPattern = [5, 4]
layer.addSublayer(borderLayer)
let icon = UIImageView(image: UIImage(systemName: "plus"))
icon.tintColor = UIColor(hex: 0x8D93A1)
icon.contentMode = .scaleAspectFit
addSubview(icon)
icon.snp.makeConstraints { make in
make.center.equalToSuperview()
make.size.equalTo(30)
}
addTarget(self, action: #selector(tapped), for: .touchUpInside)
snp.makeConstraints { make in
make.size.equalTo(58)
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func layoutSubviews() {
super.layoutSubviews()
borderLayer.path = UIBezierPath(roundedRect: bounds, cornerRadius: 10).cgPath
}
@objc private func tapped() {
tapAction()
}
}
///
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 stack = UIStackView()
private let steps: [String]
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.spacing = 0
addSubview(stack)
for (index, step) in steps.enumerated() {
let item = WildReportSuccessProgressItemView(title: step, isCompleted: index == 0)
stack.addArrangedSubview(item)
item.snp.makeConstraints { make in
make.width.equalTo(74)
}
if index < steps.count - 1 {
let connector = WildReportSuccessProgressConnectorView(isCompleted: index == 0)
stack.addArrangedSubview(connector)
}
}
}
private func setupConstraints() {
stack.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(UIEdgeInsets(top: 16, left: 12, bottom: 16, right: 12))
}
}
}
///
private final class WildReportSuccessProgressItemView: UIView {
private let nodeView: WildReportSuccessProgressNodeView
private let titleLabel = UILabel()
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?()
}
}