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

1855 lines
72 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.

//
// WildPhotographerReportListViewController.swift
// suixinkan
//
import Kingfisher
import MAMapKit
import SnapKit
import UIKit
///
final class WildPhotographerReportListViewController: BaseViewController {
private let viewModel: WildPhotographerReportListViewModel
private let api: any WildPhotographerReportServing
private let scrollView = UIScrollView()
private let stack = UIStackView()
private let filterTabBar = WildReportFilterTabBarView()
init(
homeViewModel: WildPhotographerReportHomeViewModel,
api: any WildPhotographerReportServing = NetworkServices.shared.wildPhotographerReportAPI
) {
self.viewModel = WildPhotographerReportListViewModel(homeViewModel: homeViewModel)
self.api = api
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 = AppColor.pageBackgroundSoft
scrollView.showsVerticalScrollIndicator = false
scrollView.alwaysBounceVertical = true
scrollView.delegate = self
stack.axis = .vertical
stack.spacing = 14
filterTabBar.onSelect = { [weak self] filter in
guard let self else { return }
Task {
await self.viewModel.selectFilter(
filter,
api: self.api,
scenicId: self.currentScenicId
)
}
}
view.addSubview(filterTabBar)
view.addSubview(scrollView)
scrollView.addSubview(stack)
reloadContent(animated: false)
}
override func setupConstraints() {
filterTabBar.snp.makeConstraints { make in
make.top.equalTo(view.safeAreaLayoutGuide).offset(14)
make.leading.trailing.equalToSuperview().inset(18)
}
scrollView.snp.makeConstraints { make in
make.top.equalTo(filterTabBar.snp.bottom).offset(14)
make.leading.trailing.bottom.equalTo(view.safeAreaLayoutGuide)
}
stack.snp.makeConstraints { make in
make.top.equalTo(scrollView.contentLayoutGuide)
make.leading.equalTo(scrollView.contentLayoutGuide).offset(18)
make.trailing.equalTo(scrollView.contentLayoutGuide).inset(18)
make.bottom.equalTo(scrollView.contentLayoutGuide).inset(24)
make.width.equalTo(scrollView.frameLayoutGuide).offset(-36)
}
}
override func viewDidLoad() {
super.viewDidLoad()
Task {
await viewModel.loadInitial(api: api, scenicId: currentScenicId)
}
}
override func bindActions() {
viewModel.onStateChange = { [weak self] in
Task { @MainActor in self?.reloadContent(animated: true) }
}
viewModel.onShowMessage = { [weak self] message in
Task { @MainActor in self?.showToast(message) }
}
}
@MainActor
private func reloadContent(animated: Bool) {
let changes: () -> Void = { [weak self] in
self?.rebuildContent()
}
guard animated else {
changes()
return
}
UIView.transition(
with: stack,
duration: 0.22,
options: [.transitionCrossDissolve, .allowUserInteraction],
animations: changes
)
}
private func rebuildContent() {
filterTabBar.apply(selectedFilter: viewModel.selectedFilter)
stack.arrangedSubviews.forEach { view in
stack.removeArrangedSubview(view)
view.removeFromSuperview()
}
let reports = viewModel.filteredReports
if reports.isEmpty {
stack.addArrangedSubview(viewModel.isLoading ? WildReportListLoadingView(text: "正在加载举报记录...") : WildReportListEmptyView())
} else {
reports.forEach { record in
let card = WildReportListCardView(record: record)
card.onOpen = { [weak self] in self?.openDetail(record) }
card.onShare = { [weak self] in self?.shareReport(record) }
stack.addArrangedSubview(card)
}
}
let footer = WildReportUI.label(viewModel.footerDisplayText, font: .app(.caption), color: UIColor(hex: 0x9CA3AF), lines: 0)
footer.textAlignment = .center
let footerWrap = UIView()
footerWrap.addSubview(footer)
footer.snp.makeConstraints { make in
make.top.equalToSuperview().offset(8)
make.leading.trailing.bottom.equalToSuperview()
}
stack.addArrangedSubview(footerWrap)
}
private func openDetail(_ record: WildReportRecord) {
navigationController?.pushViewController(
WildPhotographerReportDetailViewController(record: record, homeViewModel: viewModel.homeViewModel, api: api),
animated: true
)
}
private func shareReport(_ record: WildReportRecord) {
showLoading()
Task { [weak self] in
guard let self else { return }
let payload = await self.viewModel.shareReport(record, api: self.api)
await MainActor.run {
self.hideLoading()
guard let payload else { return }
WeChatShareService.shareMiniProgram(payload) { [weak self] result in
self?.showWildReportShareResult(result)
}
}
}
}
private var currentScenicId: Int? {
let scenicId = AppStore.shared.currentScenicId
return scenicId > 0 ? scenicId : nil
}
}
extension WildPhotographerReportListViewController: UIScrollViewDelegate {
func scrollViewDidScroll(_ scrollView: UIScrollView) {
let threshold: CGFloat = 80
let visibleBottom = scrollView.contentOffset.y + scrollView.bounds.height
let triggerY = scrollView.contentSize.height - threshold
guard visibleBottom >= triggerY, scrollView.contentSize.height > scrollView.bounds.height else { return }
Task {
await viewModel.loadMoreIfNeeded(api: api, scenicId: currentScenicId)
}
}
}
/// 使
private final class WildReportFilterTabBarView: UIView {
var onSelect: ((WildReportListFilter) -> Void)?
private let stack = UIStackView()
private var buttons: [WildReportListFilter: UIButton] = [:]
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = .white
layer.cornerRadius = 12
layer.borderWidth = 1
layer.borderColor = AppColor.cardOutline.cgColor
stack.axis = .horizontal
stack.spacing = 0
stack.distribution = .fillEqually
addSubview(stack)
stack.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(4)
}
WildReportListFilter.allCases.forEach { filter in
let button = UIButton(type: .system)
button.setTitle(filter.rawValue, for: .normal)
button.titleLabel?.adjustsFontSizeToFitWidth = true
button.titleLabel?.minimumScaleFactor = 0.85
button.layer.cornerRadius = 8
button.addTarget(self, action: #selector(filterTapped(_:)), for: .touchUpInside)
buttons[filter] = button
stack.addArrangedSubview(button)
}
snp.makeConstraints { make in
make.height.equalTo(48)
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func apply(selectedFilter: WildReportListFilter) {
buttons.forEach { filter, button in
let selected = filter == selectedFilter
button.backgroundColor = selected ? UIColor(hex: 0xEAF3FF) : .clear
button.setTitleColor(selected ? AppColor.primary : UIColor(hex: 0x6B7280), for: .normal)
button.titleLabel?.font = .systemFont(ofSize: 13, weight: selected ? .bold : .medium)
}
}
@objc private func filterTapped(_ sender: UIButton) {
guard let filter = buttons.first(where: { $0.value == sender })?.key else { return }
onSelect?(filter)
}
}
///
private final class WildReportListCardView: UIControl, UIGestureRecognizerDelegate {
var onOpen: (() -> Void)?
var onShare: (() -> Void)?
private let record: WildReportRecord
private let scenicName: String
private let detailAddress: String
private let statusColor: UIColor
init(record: WildReportRecord) {
self.record = record
let location = WildReportLocationParser.split(record.location)
self.scenicName = location.scenic
self.detailAddress = location.detail
self.statusColor = record.status.displayColor
super.init(frame: .zero)
setupUI()
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override var isHighlighted: Bool {
didSet {
alpha = isHighlighted ? 0.82 : 1
}
}
private func setupUI() {
backgroundColor = .white
layer.cornerRadius = 16
layer.borderWidth = 1
layer.borderColor = AppColor.cardOutline.cgColor
let tapRecognizer = UITapGestureRecognizer(target: self, action: #selector(openTapped))
tapRecognizer.delegate = self
tapRecognizer.cancelsTouchesInView = false
addGestureRecognizer(tapRecognizer)
let contentStack = UIStackView()
contentStack.axis = .vertical
contentStack.spacing = 13
addSubview(contentStack)
contentStack.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(15)
}
contentStack.addArrangedSubview(makeHeader())
contentStack.addArrangedSubview(makeMetaRows())
contentStack.addArrangedSubview(makeLocationRow())
}
private func makeHeader() -> UIView {
let row = UIStackView()
row.axis = .horizontal
row.alignment = .top
row.spacing = 12
let iconBox = UIView()
iconBox.backgroundColor = statusColor.withAlphaComponent(0.12)
iconBox.layer.cornerRadius = 12
let icon = UIImageView(image: UIImage(systemName: "camera.fill"))
icon.tintColor = statusColor
icon.contentMode = .scaleAspectFit
iconBox.addSubview(icon)
icon.snp.makeConstraints { make in
make.center.equalToSuperview()
make.size.equalTo(20)
}
iconBox.snp.makeConstraints { make in
make.size.equalTo(44)
}
row.addArrangedSubview(iconBox)
let textStack = UIStackView()
textStack.axis = .vertical
textStack.spacing = 8
let titleStatusRow = UIStackView()
titleStatusRow.axis = .horizontal
titleStatusRow.alignment = .top
titleStatusRow.spacing = 8
let titleLabel = WildReportUI.label(record.title, font: .app(.title), color: AppColor.textPrimary, lines: 2)
titleLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
titleStatusRow.addArrangedSubview(titleLabel)
titleStatusRow.addArrangedSubview(makeStatusAndShareGroup())
textStack.addArrangedSubview(titleStatusRow)
textStack.addArrangedSubview(makeScenicPill())
row.addArrangedSubview(textStack)
return row
}
private func makeStatusAndShareGroup() -> UIView {
let group = UIStackView()
group.axis = .horizontal
group.alignment = .center
group.spacing = 6
group.setContentHuggingPriority(.required, for: .horizontal)
let status = PaddingLabel(insets: UIEdgeInsets(top: 0, left: 8, bottom: 0, right: 8))
status.text = record.status.rawValue
status.font = .systemFont(ofSize: 11, weight: .semibold)
status.textColor = statusColor
status.textAlignment = .center
status.backgroundColor = statusColor.withAlphaComponent(0.12)
status.layer.cornerRadius = 12
status.clipsToBounds = true
status.setContentHuggingPriority(.required, for: .horizontal)
status.setContentCompressionResistancePriority(.required, for: .horizontal)
status.snp.makeConstraints { make in
make.height.equalTo(24)
}
let shareButton = UIButton(type: .system)
shareButton.backgroundColor = AppColor.primaryLight
shareButton.layer.cornerRadius = 14
shareButton.tintColor = AppColor.primary
shareButton.setImage(UIImage(systemName: "square.and.arrow.up"), for: .normal)
shareButton.accessibilityLabel = "分享举报"
shareButton.addTarget(self, action: #selector(shareTapped), for: .touchUpInside)
shareButton.snp.makeConstraints { make in
make.size.equalTo(28)
}
group.addArrangedSubview(status)
group.addArrangedSubview(shareButton)
return group
}
private func makeScenicPill() -> UIView {
let wrapper = UIView()
let label = PaddingLabel(insets: UIEdgeInsets(top: 0, left: 8, bottom: 0, right: 8))
label.text = scenicName
label.font = .systemFont(ofSize: 12, weight: .semibold)
label.textColor = AppColor.primary
label.backgroundColor = AppColor.primary.withAlphaComponent(0.10)
label.layer.cornerRadius = 11
label.clipsToBounds = true
label.setContentHuggingPriority(.required, for: .horizontal)
label.setContentCompressionResistancePriority(.required, for: .horizontal)
wrapper.addSubview(label)
label.snp.makeConstraints { make in
make.top.bottom.leading.equalToSuperview()
make.trailing.lessThanOrEqualToSuperview()
make.height.equalTo(22)
}
return wrapper
}
private func makeMetaRows() -> UIView {
let stack = UIStackView()
stack.axis = .vertical
stack.spacing = 8
stack.addArrangedSubview(WildReportListMetaRowView(title: "举报编号", value: record.id))
stack.addArrangedSubview(WildReportListMetaRowView(title: "举报时间", value: record.submitTime))
return stack
}
private func makeLocationRow() -> UIView {
let row = UIStackView()
row.axis = .horizontal
row.alignment = .center
row.spacing = 6
row.layoutMargins = UIEdgeInsets(top: 2, left: 0, bottom: 0, right: 0)
row.isLayoutMarginsRelativeArrangement = true
let icon = UIImageView(image: UIImage(systemName: "mappin.and.ellipse"))
icon.tintColor = UIColor(hex: 0x9CA3AF)
icon.contentMode = .scaleAspectFit
icon.snp.makeConstraints { make in
make.size.equalTo(14)
}
let addressLabel = WildReportUI.label(detailAddress, font: .app(.caption), color: AppColor.textSecondary)
addressLabel.lineBreakMode = .byTruncatingTail
let chevron = UIImageView(image: UIImage(systemName: "chevron.right"))
chevron.tintColor = UIColor(hex: 0xC4CAD4)
chevron.contentMode = .scaleAspectFit
chevron.snp.makeConstraints { make in
make.size.equalTo(13)
}
row.addArrangedSubview(icon)
row.addArrangedSubview(addressLabel)
row.addArrangedSubview(UIView())
row.addArrangedSubview(chevron)
return row
}
@objc private func openTapped() {
onOpen?()
}
@objc private func shareTapped() {
onShare?()
}
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool {
var currentView: UIView? = touch.view
while let view = currentView {
if view is UIButton {
return false
}
currentView = view.superview
}
return true
}
}
///
private final class WildReportListMetaRowView: UIView {
init(title: String, value: String) {
super.init(frame: .zero)
let titleLabel = WildReportUI.label(title, font: .app(.caption), color: UIColor(hex: 0x9CA3AF))
let valueLabel = WildReportUI.label(value, font: .systemFont(ofSize: 13, weight: .medium), color: AppColor.textPrimary)
valueLabel.textAlignment = .right
valueLabel.adjustsFontSizeToFitWidth = true
valueLabel.minimumScaleFactor = 0.8
addSubview(titleLabel)
addSubview(valueLabel)
titleLabel.snp.makeConstraints { make in
make.leading.top.bottom.equalToSuperview()
}
valueLabel.snp.makeConstraints { make in
make.leading.greaterThanOrEqualTo(titleLabel.snp.trailing).offset(12)
make.trailing.top.bottom.equalToSuperview()
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
///
private final class WildReportListEmptyView: UIView {
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = .white
layer.cornerRadius = 16
let stack = UIStackView()
stack.axis = .vertical
stack.alignment = .center
stack.spacing = 10
let icon = UIImageView(image: UIImage(systemName: "doc.text.magnifyingglass"))
icon.tintColor = UIColor(hex: 0xB6BECA)
icon.contentMode = .scaleAspectFit
icon.snp.makeConstraints { make in
make.size.equalTo(36)
}
let label = WildReportUI.label("暂无相关举报记录", font: .systemFont(ofSize: 15, weight: .semibold), color: AppColor.textSecondary)
stack.addArrangedSubview(icon)
stack.addArrangedSubview(label)
addSubview(stack)
stack.snp.makeConstraints { make in
make.top.bottom.equalToSuperview().inset(48)
make.leading.trailing.equalToSuperview().inset(16)
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
///
private final class WildReportListLoadingView: UIView {
init(text: String) {
super.init(frame: .zero)
backgroundColor = .white
layer.cornerRadius = 16
let indicator = UIActivityIndicatorView(style: .medium)
indicator.color = AppColor.primary
indicator.startAnimating()
let label = WildReportUI.label(text, font: .systemFont(ofSize: 15, weight: .semibold), color: AppColor.textSecondary)
let stack = UIStackView(arrangedSubviews: [indicator, label])
stack.axis = .vertical
stack.alignment = .center
stack.spacing = 12
addSubview(stack)
stack.snp.makeConstraints { make in
make.top.bottom.equalToSuperview().inset(48)
make.leading.trailing.equalToSuperview().inset(16)
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
///
final class WildPhotographerReportDetailViewController: BaseViewController {
private let viewModel: WildPhotographerReportDetailViewModel
private let homeViewModel: WildPhotographerReportHomeViewModel
private let api: any WildPhotographerReportServing
private let scrollView = UIScrollView()
private let stack = UIStackView()
private let bottomBar = UIView()
private let bottomButtonRow = UIStackView()
init(
record: WildReportRecord,
homeViewModel: WildPhotographerReportHomeViewModel,
api: any WildPhotographerReportServing = NetworkServices.shared.wildPhotographerReportAPI
) {
self.viewModel = WildPhotographerReportDetailViewModel(record: record, homeViewModel: homeViewModel)
self.homeViewModel = homeViewModel
self.api = api
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 = AppColor.pageBackgroundSoft
scrollView.showsVerticalScrollIndicator = false
scrollView.alwaysBounceVertical = true
stack.axis = .vertical
stack.spacing = 12
bottomBar.backgroundColor = UIColor.white.withAlphaComponent(0.96)
bottomButtonRow.axis = .horizontal
bottomButtonRow.alignment = .fill
bottomButtonRow.distribution = .fillEqually
bottomButtonRow.spacing = 12
let supplementButton = makeBottomButton(title: "补充证据", iconName: "doc.badge.plus", isPrimary: false)
supplementButton.addTarget(self, action: #selector(supplementTapped), for: .touchUpInside)
let mapButton = makeBottomButton(title: "查看附近风险地图", iconName: "map.fill", isPrimary: true)
mapButton.addTarget(self, action: #selector(mapTapped), for: .touchUpInside)
bottomButtonRow.addArrangedSubview(supplementButton)
bottomButtonRow.addArrangedSubview(mapButton)
view.addSubview(scrollView)
view.addSubview(bottomBar)
scrollView.addSubview(stack)
bottomBar.addSubview(bottomButtonRow)
let topBorder = UIView()
topBorder.backgroundColor = AppColor.cardOutline
bottomBar.addSubview(topBorder)
topBorder.snp.makeConstraints { make in
make.top.leading.trailing.equalToSuperview()
make.height.equalTo(0.5)
}
rebuildContent()
}
override func setupConstraints() {
scrollView.snp.makeConstraints { make in
make.top.leading.trailing.equalTo(view.safeAreaLayoutGuide)
make.bottom.equalToSuperview()
}
stack.snp.makeConstraints { make in
make.top.equalTo(scrollView.contentLayoutGuide).offset(14)
make.leading.equalTo(scrollView.contentLayoutGuide).offset(18)
make.trailing.equalTo(scrollView.contentLayoutGuide).inset(18)
make.bottom.equalTo(scrollView.contentLayoutGuide).inset(120)
make.width.equalTo(scrollView.frameLayoutGuide).offset(-36)
}
bottomBar.snp.makeConstraints { make in
make.leading.trailing.bottom.equalToSuperview()
}
bottomButtonRow.snp.makeConstraints { make in
make.top.equalToSuperview().offset(10)
make.leading.trailing.equalToSuperview().inset(16)
make.bottom.equalTo(view.safeAreaLayoutGuide).inset(12)
make.height.equalTo(52)
}
}
override func viewDidLoad() {
super.viewDidLoad()
Task {
await viewModel.loadDetailIfNeeded(api: api)
}
}
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) }
}
homeViewModel.onStateChange = { [weak self] in
Task { @MainActor in self?.rebuildContent() }
}
}
@MainActor
private func rebuildContent() {
stack.arrangedSubviews.forEach { view in
stack.removeArrangedSubview(view)
view.removeFromSuperview()
}
stack.addArrangedSubview(makeSummaryCard())
stack.addArrangedSubview(makeReportInfoCard())
if viewModel.showHandlerInfo, let info = viewModel.handlerInfo {
stack.addArrangedSubview(makeHandlerCard(info: info))
}
stack.addArrangedSubview(makeEvidenceCard())
if !viewModel.supplementaryEvidences.isEmpty {
stack.addArrangedSubview(makeSupplementCard())
}
stack.addArrangedSubview(makeTimelineCard())
}
private func makeSummaryCard() -> UIView {
let card = WildReportCardView(spacing: 20, inset: 18)
card.layer.cornerRadius = 18
let topRow = UIStackView()
topRow.axis = .horizontal
topRow.alignment = .center
topRow.spacing = 14
let iconBox = UIView()
iconBox.backgroundColor = AppColor.primary
iconBox.layer.cornerRadius = 16
let icon = UIImageView(image: UIImage(systemName: "exclamationmark.shield.fill"))
icon.tintColor = .white
icon.contentMode = .scaleAspectFit
iconBox.addSubview(icon)
icon.snp.makeConstraints { make in
make.center.equalToSuperview()
make.size.equalTo(30)
}
iconBox.snp.makeConstraints { make in
make.size.equalTo(62)
}
let titleStack = UIStackView()
titleStack.axis = .vertical
titleStack.spacing = 8
let titleLabel = WildReportUI.label(viewModel.heroTitle, font: .systemFont(ofSize: 23, weight: .heavy), color: AppColor.textPrimary, lines: 1)
titleLabel.adjustsFontSizeToFitWidth = true
titleLabel.minimumScaleFactor = 0.82
titleStack.addArrangedSubview(titleLabel)
titleStack.addArrangedSubview(makeScenicPill())
let shareButton = WildReportDetailIconTextButton(title: "分享", iconName: "square.and.arrow.up")
shareButton.addTarget(self, action: #selector(shareTapped), for: .touchUpInside)
topRow.addArrangedSubview(iconBox)
topRow.addArrangedSubview(titleStack)
topRow.addArrangedSubview(shareButton)
titleStack.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
shareButton.snp.makeConstraints { make in
make.size.equalTo(48)
}
card.stack.addArrangedSubview(topRow)
card.stack.addArrangedSubview(makeDivider())
let bottomRow = UIStackView()
bottomRow.axis = .horizontal
bottomRow.alignment = .center
bottomRow.spacing = 12
let idStack = UIStackView()
idStack.axis = .vertical
idStack.spacing = 4
idStack.addArrangedSubview(WildReportUI.label("举报编号", font: .app(.body), color: AppColor.textSecondary))
idStack.addArrangedSubview(WildReportUI.label(viewModel.record.id, font: .systemFont(ofSize: 19, weight: .semibold), color: AppColor.textPrimary))
let statusLabel = PaddingLabel(insets: UIEdgeInsets(top: 0, left: 14, bottom: 0, right: 14))
statusLabel.text = viewModel.displayStatusText
statusLabel.font = .systemFont(ofSize: 16, weight: .bold)
statusLabel.textColor = viewModel.record.status.displayColor
statusLabel.backgroundColor = viewModel.record.status.displayColor.withAlphaComponent(0.12)
statusLabel.textAlignment = .center
statusLabel.layer.cornerRadius = 19
statusLabel.clipsToBounds = true
statusLabel.snp.makeConstraints { make in
make.height.equalTo(38)
}
bottomRow.addArrangedSubview(idStack)
bottomRow.addArrangedSubview(UIView())
bottomRow.addArrangedSubview(statusLabel)
card.stack.addArrangedSubview(bottomRow)
return card
}
private func makeReportInfoCard() -> UIView {
let card = WildReportCardView(spacing: 0, inset: 0)
card.layer.cornerRadius = 18
card.stack.addArrangedSubview(WildReportDetailInfoRowView(title: "举报类型", value: viewModel.record.reportType.label))
card.stack.addArrangedSubview(makeInsetDivider())
card.stack.addArrangedSubview(WildReportDetailInfoRowView(title: "举报说明", value: viewModel.descriptionText, multiline: true))
card.stack.addArrangedSubview(makeInsetDivider())
card.stack.addArrangedSubview(WildReportDetailInfoRowView(title: "微信号/手机号", value: viewModel.contactText))
if !viewModel.handleRemarkText.isEmpty, !viewModel.showHandlerInfo {
card.stack.addArrangedSubview(makeInsetDivider())
card.stack.addArrangedSubview(WildReportDetailInfoRowView(title: "处理备注", value: viewModel.handleRemarkText, multiline: true))
}
return card
}
private func makeEvidenceCard() -> UIView {
let card = WildReportCardView(spacing: 16, inset: 18)
card.layer.cornerRadius = 18
if !viewModel.reporterMediaItems.isEmpty {
card.stack.addArrangedSubview(makeReporterMaterialsSection())
card.stack.addArrangedSubview(makeDivider())
}
if !viewModel.paymentScreenshotItems.isEmpty {
card.stack.addArrangedSubview(makePaymentScreenshotSection())
card.stack.addArrangedSubview(makeDivider())
}
card.stack.addArrangedSubview(makeLocationSection())
return card
}
private func makeHandlerCard(info: WildReportHandlerInfo) -> UIView {
let card = WildReportCardView(spacing: 0, inset: 0)
card.layer.cornerRadius = 18
let title = WildReportUI.label("处理信息", font: .systemFont(ofSize: 18, weight: .bold), color: AppColor.textPrimary)
let titleWrap = UIView()
titleWrap.addSubview(title)
title.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(UIEdgeInsets(top: 18, left: 18, bottom: 10, right: 18))
}
card.stack.addArrangedSubview(titleWrap)
card.stack.addArrangedSubview(WildReportHandlerInfoRowView(title: "处理人", value: info.handlerName))
card.stack.addArrangedSubview(makeInsetDivider())
let phoneRow = WildReportHandlerInfoRowView(title: "处理人电话", value: info.maskedHandlerPhone, trailingIconName: "phone.fill")
phoneRow.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(handlerPhoneTapped)))
phoneRow.accessibilityTraits.insert(.button)
card.stack.addArrangedSubview(phoneRow)
card.stack.addArrangedSubview(makeInsetDivider())
card.stack.addArrangedSubview(WildReportHandlerInfoRowView(title: "处理时间", value: info.processingAt))
card.stack.addArrangedSubview(makeInsetDivider())
card.stack.addArrangedSubview(WildReportHandlerInfoRowView(title: "处理完成时间", value: info.processedAt))
card.stack.addArrangedSubview(makeInsetDivider())
card.stack.addArrangedSubview(WildReportDetailInfoRowView(title: "处理意见", value: info.processOpinion, multiline: true))
if !info.completionMedia.isEmpty {
card.stack.addArrangedSubview(makeInsetDivider())
card.stack.addArrangedSubview(makeCompletionMediaSection(info: info))
}
return card
}
private func makeSupplementCard() -> UIView {
let card = WildReportCardView(spacing: 14, inset: 18)
card.layer.cornerRadius = 18
card.stack.addArrangedSubview(WildReportUI.label("补充证据记录", font: .systemFont(ofSize: 18, weight: .bold), color: AppColor.textPrimary))
let items = viewModel.supplementaryEvidences
items.enumerated().forEach { index, item in
let itemStack = UIStackView()
itemStack.axis = .vertical
itemStack.spacing = 8
let header = UIStackView()
header.axis = .horizontal
header.alignment = .center
header.addArrangedSubview(WildReportUI.label(item.submitTime, font: .app(.captionMedium), color: AppColor.textSecondary))
header.addArrangedSubview(UIView())
header.addArrangedSubview(WildReportUI.label(
WildReportSupplementDisplayFormatter.sequenceTitle(forDisplayIndex: index),
font: .app(.captionMedium),
color: AppColor.primary
))
itemStack.addArrangedSubview(header)
itemStack.addArrangedSubview(WildReportUI.label(item.summaryText, font: .app(.bodyMedium), color: AppColor.textPrimary, lines: 0))
card.stack.addArrangedSubview(itemStack)
if index < items.count - 1 {
card.stack.addArrangedSubview(makeDivider())
}
}
return card
}
private func makeTimelineCard() -> UIView {
let card = WildReportCardView(spacing: 14, inset: 18)
card.layer.cornerRadius = 18
card.stack.addArrangedSubview(WildReportUI.label("处理进度", font: .systemFont(ofSize: 18, weight: .bold), color: AppColor.textPrimary))
viewModel.progressSteps.enumerated().forEach { index, step in
card.stack.addArrangedSubview(WildReportDetailTimelineRowView(step: step, isLast: index == viewModel.progressSteps.count - 1))
}
return card
}
private func makeReporterMaterialsSection() -> UIView {
let section = UIStackView()
section.axis = .vertical
section.spacing = 12
section.addArrangedSubview(makeCompactMediaSectionHeader(
iconName: "photo.on.rectangle.angled",
title: "举报人材料",
summary: viewModel.reporterMediaSummary,
action: #selector(previewTapped)
))
let mediaScroll = UIScrollView()
mediaScroll.showsHorizontalScrollIndicator = false
let mediaStack = UIStackView()
mediaStack.axis = .horizontal
mediaStack.spacing = 10
mediaScroll.addSubview(mediaStack)
mediaStack.snp.makeConstraints { make in
make.edges.equalToSuperview()
make.height.equalTo(mediaScroll.frameLayoutGuide)
}
viewModel.reporterMediaItems.forEach { item in
let tile = WildReportDetailMediaThumbnailView(reporterItem: item)
tile.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(reporterMediaTapped(_:))))
tile.snp.makeConstraints { make in
make.size.equalTo(108)
}
mediaStack.addArrangedSubview(tile)
}
mediaScroll.snp.makeConstraints { make in
make.height.equalTo(108)
}
section.addArrangedSubview(mediaScroll)
return section
}
private func makeCompactMediaSectionHeader(
iconName: String,
title: String,
summary: String,
action: Selector
) -> UIView {
let control = UIControl()
control.addTarget(self, action: action, for: .touchUpInside)
let row = UIStackView()
row.axis = .horizontal
row.alignment = .center
row.spacing = 8
let icon = UIImageView(image: UIImage(systemName: iconName))
icon.tintColor = AppColor.primary
icon.contentMode = .scaleAspectFit
icon.snp.makeConstraints { make in
make.size.equalTo(16)
}
let titleLabel = WildReportUI.label(title, font: .systemFont(ofSize: 15, weight: .bold), color: AppColor.textPrimary)
let spacer = UIView()
let summaryLabel = WildReportUI.label(summary, font: .systemFont(ofSize: 13, weight: .medium), color: AppColor.textSecondary)
summaryLabel.setContentHuggingPriority(.required, for: .horizontal)
summaryLabel.setContentCompressionResistancePriority(.required, for: .horizontal)
let chevron = UIImageView(image: UIImage(systemName: "chevron.right"))
chevron.tintColor = UIColor(hex: 0xB6BECA)
chevron.contentMode = .scaleAspectFit
chevron.snp.makeConstraints { make in
make.size.equalTo(14)
}
row.addArrangedSubview(icon)
row.addArrangedSubview(titleLabel)
row.addArrangedSubview(spacer)
row.addArrangedSubview(summaryLabel)
row.addArrangedSubview(chevron)
control.addSubview(row)
row.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
control.snp.makeConstraints { make in
make.height.equalTo(22)
}
return control
}
private func makePaymentScreenshotSection() -> UIView {
let section = UIStackView()
section.axis = .vertical
section.spacing = 12
section.addArrangedSubview(makeCompactMediaSectionHeader(
iconName: "creditcard.fill",
title: "支付截图",
summary: viewModel.paymentScreenshotSummary,
action: #selector(paymentPreviewTapped)
))
let mediaScroll = UIScrollView()
mediaScroll.showsHorizontalScrollIndicator = false
let mediaStack = UIStackView()
mediaStack.axis = .horizontal
mediaStack.spacing = 10
mediaScroll.addSubview(mediaStack)
mediaStack.snp.makeConstraints { make in
make.edges.equalToSuperview()
make.height.equalTo(mediaScroll.frameLayoutGuide)
}
viewModel.paymentScreenshotItems.forEach { item in
let tile = WildReportDetailMediaThumbnailView(reporterItem: item)
tile.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(paymentMediaTapped(_:))))
tile.snp.makeConstraints { make in
make.size.equalTo(108)
}
mediaStack.addArrangedSubview(tile)
}
mediaScroll.snp.makeConstraints { make in
make.height.equalTo(108)
}
section.addArrangedSubview(mediaScroll)
return section
}
private func makeCompletionMediaSection(info: WildReportHandlerInfo) -> UIView {
let section = UIStackView()
section.axis = .vertical
section.spacing = 12
section.layoutMargins = UIEdgeInsets(top: 14, left: 18, bottom: 16, right: 18)
section.isLayoutMarginsRelativeArrangement = true
section.addArrangedSubview(makeCompactMediaSectionHeader(
iconName: "photo.on.rectangle.angled",
title: "处理凭证",
summary: info.completionMediaSummary,
action: #selector(completionPreviewTapped)
))
let mediaScroll = UIScrollView()
mediaScroll.showsHorizontalScrollIndicator = false
let mediaStack = UIStackView()
mediaStack.axis = .horizontal
mediaStack.spacing = 10
mediaScroll.addSubview(mediaStack)
mediaStack.snp.makeConstraints { make in
make.edges.equalToSuperview()
make.height.equalTo(mediaScroll.frameLayoutGuide)
}
info.completionMedia.forEach { item in
let tile = WildReportDetailMediaThumbnailView(completionItem: item)
tile.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(completionMediaTapped(_:))))
tile.snp.makeConstraints { make in
make.size.equalTo(108)
}
mediaStack.addArrangedSubview(tile)
}
mediaScroll.snp.makeConstraints { make in
make.height.equalTo(108)
}
section.addArrangedSubview(mediaScroll)
return section
}
private func makeLocationSection() -> UIView {
let section = UIStackView()
section.axis = .vertical
section.spacing = 12
section.addArrangedSubview(makeLocationHeader())
let mapPreview = WildReportDetailMapPreviewView(
scenicName: viewModel.scenicAreaName,
detailAddress: viewModel.detailAddress,
coordinate: viewModel.mapCoordinate
)
section.addArrangedSubview(mapPreview)
mapPreview.snp.makeConstraints { make in
make.height.equalTo(98)
}
return section
}
private func makeLocationHeader() -> UIView {
let control = UIControl()
control.addTarget(self, action: #selector(locationTapped), for: .touchUpInside)
let row = UIStackView()
row.axis = .horizontal
row.alignment = .center
row.spacing = 14
let iconBox = UIView()
iconBox.backgroundColor = AppColor.primaryLight
iconBox.layer.cornerRadius = 12
let icon = UIImageView(image: UIImage(systemName: "mappin.circle.fill"))
icon.tintColor = AppColor.primary
icon.contentMode = .scaleAspectFit
iconBox.addSubview(icon)
iconBox.snp.makeConstraints { make in
make.size.equalTo(50)
}
icon.snp.makeConstraints { make in
make.center.equalToSuperview()
make.size.equalTo(22)
}
let titleLabel = WildReportUI.label("位置", font: .systemFont(ofSize: 20, weight: .bold), color: AppColor.textPrimary)
let spacer = UIView()
let valueLabel = WildReportUI.label(viewModel.detailAddress, font: .systemFont(ofSize: 18, weight: .medium), color: AppColor.textSecondary)
valueLabel.lineBreakMode = .byTruncatingTail
valueLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
let chevron = UIImageView(image: UIImage(systemName: "chevron.right"))
chevron.tintColor = UIColor(hex: 0xB6BECA)
chevron.contentMode = .scaleAspectFit
chevron.snp.makeConstraints { make in
make.size.equalTo(16)
}
row.addArrangedSubview(iconBox)
row.addArrangedSubview(titleLabel)
row.addArrangedSubview(spacer)
row.addArrangedSubview(valueLabel)
row.addArrangedSubview(chevron)
control.addSubview(row)
row.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
control.snp.makeConstraints { make in
make.height.equalTo(50)
}
return control
}
private func makeSectionHeader(
iconName: String,
title: String,
subtitle: String,
trailingText: String? = nil,
chevron: Bool,
action: Selector?
) -> UIView {
let control = UIControl()
if let action {
control.addTarget(self, action: action, for: .touchUpInside)
}
let iconBox = UIView()
iconBox.backgroundColor = AppColor.primaryLight
iconBox.layer.cornerRadius = 14
let icon = UIImageView(image: UIImage(systemName: iconName))
icon.tintColor = AppColor.primary
icon.contentMode = .scaleAspectFit
iconBox.addSubview(icon)
control.addSubview(iconBox)
let textStack = UIStackView()
textStack.axis = .vertical
textStack.spacing = 4
textStack.addArrangedSubview(WildReportUI.label(title, font: .systemFont(ofSize: 20, weight: .bold), color: AppColor.textPrimary))
if !subtitle.isEmpty {
let subtitleLabel = WildReportUI.label(subtitle, font: .systemFont(ofSize: 18, weight: .medium), color: AppColor.textSecondary)
subtitleLabel.adjustsFontSizeToFitWidth = true
subtitleLabel.minimumScaleFactor = 0.8
textStack.addArrangedSubview(subtitleLabel)
}
control.addSubview(textStack)
let trailingLabel = WildReportUI.label(trailingText ?? "", font: .systemFont(ofSize: 15, weight: .semibold), color: AppColor.textSecondary)
trailingLabel.textAlignment = .right
trailingLabel.setContentHuggingPriority(.required, for: .horizontal)
trailingLabel.setContentCompressionResistancePriority(.required, for: .horizontal)
trailingLabel.isHidden = trailingText?.isEmpty != false
control.addSubview(trailingLabel)
let chevronView = UIImageView(image: UIImage(systemName: "chevron.right"))
chevronView.tintColor = AppColor.textTertiary
chevronView.isHidden = !chevron
control.addSubview(chevronView)
iconBox.snp.makeConstraints { make in
make.leading.top.bottom.equalToSuperview()
make.size.equalTo(50)
}
icon.snp.makeConstraints { make in
make.center.equalToSuperview()
make.size.equalTo(24)
}
chevronView.snp.makeConstraints { make in
make.trailing.centerY.equalToSuperview()
make.size.equalTo(18)
}
trailingLabel.snp.makeConstraints { make in
make.centerY.equalToSuperview()
make.trailing.equalTo(chevronView.snp.leading).offset(-8)
}
textStack.snp.makeConstraints { make in
make.leading.equalTo(iconBox.snp.trailing).offset(12)
make.centerY.equalToSuperview()
if trailingText?.isEmpty == false {
make.trailing.lessThanOrEqualTo(trailingLabel.snp.leading).offset(-10)
} else {
make.trailing.equalTo(chevronView.snp.leading).offset(-10)
}
}
control.snp.makeConstraints { make in
make.height.greaterThanOrEqualTo(50)
}
return control
}
private func makeScenicPill() -> UIView {
let wrapper = UIView()
let pill = UIStackView()
pill.axis = .horizontal
pill.alignment = .center
pill.spacing = 4
pill.layoutMargins = UIEdgeInsets(top: 4, left: 8, bottom: 4, right: 10)
pill.isLayoutMarginsRelativeArrangement = true
pill.backgroundColor = AppColor.primaryLight
pill.layer.cornerRadius = 13
pill.clipsToBounds = true
pill.setContentHuggingPriority(.required, for: .horizontal)
pill.setContentCompressionResistancePriority(.required, for: .horizontal)
let icon = UIImageView(image: UIImage(systemName: "mappin.circle.fill"))
icon.tintColor = AppColor.primary
icon.snp.makeConstraints { make in
make.size.equalTo(14)
}
let label = WildReportUI.label(viewModel.scenicAreaName, font: .app(.captionMedium), color: AppColor.primary)
pill.addArrangedSubview(icon)
pill.addArrangedSubview(label)
wrapper.addSubview(pill)
pill.snp.makeConstraints { make in
make.top.bottom.leading.equalToSuperview()
make.trailing.lessThanOrEqualToSuperview()
}
return wrapper
}
private func makeBottomButton(title: String, iconName: String, isPrimary: Bool) -> UIButton {
let button = UIButton(type: .system)
button.backgroundColor = isPrimary ? AppColor.primary : .white
button.layer.cornerRadius = 14
button.layer.borderWidth = isPrimary ? 0 : 1.2
button.layer.borderColor = AppColor.primary.cgColor
button.tintColor = isPrimary ? .white : AppColor.primary
button.setTitleColor(isPrimary ? .white : AppColor.primary, for: .normal)
button.setTitle(title, for: .normal)
button.setImage(UIImage(systemName: iconName), for: .normal)
button.titleLabel?.font = .systemFont(ofSize: 16, weight: .bold)
button.titleLabel?.adjustsFontSizeToFitWidth = true
button.titleLabel?.minimumScaleFactor = 0.82
button.imageEdgeInsets = UIEdgeInsets(top: 0, left: -4, bottom: 0, right: 4)
button.contentEdgeInsets = UIEdgeInsets(top: 0, left: 10, bottom: 0, right: 10)
return button
}
private func makeDivider() -> UIView {
let divider = UIView()
divider.backgroundColor = AppColor.cardOutline
divider.snp.makeConstraints { make in
make.height.equalTo(1)
}
return divider
}
private func makeInsetDivider() -> UIView {
let wrapper = UIView()
let divider = UIView()
divider.backgroundColor = AppColor.cardOutline
wrapper.addSubview(divider)
wrapper.snp.makeConstraints { make in
make.height.equalTo(1)
}
divider.snp.makeConstraints { make in
make.leading.trailing.equalToSuperview().inset(18)
make.top.bottom.equalToSuperview()
}
return wrapper
}
@objc private func shareTapped() {
showLoading()
Task { [weak self] in
guard let self else { return }
let payload = await self.viewModel.shareReport(api: self.api)
await MainActor.run {
self.hideLoading()
guard let payload else { return }
WeChatShareService.shareMiniProgram(payload) { [weak self] result in
self?.showWildReportShareResult(result)
}
}
}
}
@objc private func previewTapped() { presentReporterMediaPreview(selectedKey: nil) }
@objc private func paymentPreviewTapped() { presentPaymentMediaPreview(selectedKey: nil) }
@objc private func completionPreviewTapped() { presentCompletionMediaPreview(selectedKey: nil) }
@objc private func locationTapped() { viewModel.showLocationPreview() }
@objc private func handlerPhoneTapped() {
guard let phone = viewModel.handlerInfo?.handlerPhone.trimmingCharacters(in: .whitespacesAndNewlines),
!phone.isEmpty,
let url = URL(string: "tel://\(phone)") else {
showToast("暂无处理人电话")
return
}
UIApplication.shared.open(url)
}
@objc private func reporterMediaTapped(_ recognizer: UITapGestureRecognizer) {
guard let tile = recognizer.view as? WildReportDetailMediaThumbnailView else { return }
presentReporterMediaPreview(selectedKey: tile.previewKey)
}
@objc private func paymentMediaTapped(_ recognizer: UITapGestureRecognizer) {
guard let tile = recognizer.view as? WildReportDetailMediaThumbnailView else { return }
presentPaymentMediaPreview(selectedKey: tile.previewKey)
}
@objc private func completionMediaTapped(_ recognizer: UITapGestureRecognizer) {
guard let tile = recognizer.view as? WildReportDetailMediaThumbnailView else { return }
presentCompletionMediaPreview(selectedKey: tile.previewKey)
}
private func presentReporterMediaPreview(selectedKey: String?) {
presentMediaPreview(entries: previewEntries(for: viewModel.reporterMediaItems), selectedKey: selectedKey, emptyMessage: "当前举报材料暂无可预览文件")
}
private func presentPaymentMediaPreview(selectedKey: String?) {
presentMediaPreview(entries: previewEntries(for: viewModel.paymentScreenshotItems), selectedKey: selectedKey, emptyMessage: "当前支付截图暂无可预览文件")
}
private func presentCompletionMediaPreview(selectedKey: String?) {
let entries = viewModel.handlerInfo?.completionMedia.compactMap { item -> (key: String, item: MediaPreviewItem)? in
guard let key = item.previewKey,
let previewItem = MediaPreviewItem(wildReportCompletionItem: item)
else { return nil }
return (key, previewItem)
} ?? []
presentMediaPreview(entries: entries, selectedKey: selectedKey, emptyMessage: "当前处理凭证暂无可预览文件")
}
private func previewEntries(for mediaItems: [WildReportReporterMediaItem]) -> [(key: String, item: MediaPreviewItem)] {
mediaItems.compactMap { mediaItem in
guard let key = mediaItem.previewKey,
let previewItem = MediaPreviewItem(wildReportReporterItem: mediaItem)
else { return nil }
return (key, previewItem)
}
}
private func presentMediaPreview(
entries: [(key: String, item: MediaPreviewItem)],
selectedKey: String?,
emptyMessage: String
) {
guard !entries.isEmpty else {
showToast(emptyMessage)
return
}
let startIndex = selectedKey.flatMap { key in entries.firstIndex { $0.key == key } } ?? 0
presentMediaPreview(items: entries.map(\.item), startIndex: startIndex)
}
@objc private func supplementTapped() {
navigationController?.pushViewController(
WildReportSupplementEvidenceViewController(
record: viewModel.record,
homeViewModel: homeViewModel,
api: api,
onSupplementSuccess: { [weak self] in
guard let self else { return }
Task {
await self.viewModel.loadDetailIfNeeded(api: self.api, force: true)
}
}
),
animated: true
)
}
@objc private func mapTapped() {
navigationController?.pushViewController(
WildReportRiskMapViewController(),
animated: true
)
}
}
/// Summary
private final class WildReportDetailIconTextButton: UIControl {
private let iconView = UIImageView()
private let titleLabel = UILabel()
init(title: String, iconName: String) {
super.init(frame: .zero)
backgroundColor = AppColor.primaryLight
layer.cornerRadius = 14
iconView.image = UIImage(systemName: iconName)
iconView.tintColor = AppColor.primary
iconView.contentMode = .scaleAspectFit
titleLabel.text = title
titleLabel.font = .systemFont(ofSize: 10, weight: .bold)
titleLabel.textColor = AppColor.primary
titleLabel.textAlignment = .center
addSubview(iconView)
addSubview(titleLabel)
iconView.snp.makeConstraints { make in
make.top.equalToSuperview().offset(8)
make.centerX.equalToSuperview()
make.size.equalTo(18)
}
titleLabel.snp.makeConstraints { make in
make.top.equalTo(iconView.snp.bottom).offset(2)
make.leading.trailing.equalToSuperview().inset(4)
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
///
private final class WildReportDetailInfoRowView: UIView {
init(title: String, value: String, multiline: Bool = false, trailingIconName: String? = nil) {
super.init(frame: .zero)
let titleLabel = WildReportUI.label(title, font: .app(.captionMedium), color: AppColor.textSecondary)
let valueLabel = WildReportUI.label(value, font: .app(.subtitle), color: AppColor.textPrimary, lines: multiline ? 0 : 1)
valueLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
addSubview(titleLabel)
addSubview(valueLabel)
if let trailingIconName {
let icon = UIImageView(image: UIImage(systemName: trailingIconName))
icon.tintColor = AppColor.primary
icon.contentMode = .scaleAspectFit
addSubview(icon)
icon.snp.makeConstraints { make in
make.trailing.equalToSuperview().inset(18)
make.centerY.equalTo(valueLabel)
make.size.equalTo(16)
}
valueLabel.snp.makeConstraints { make in
make.trailing.equalTo(icon.snp.leading).offset(-6)
}
}
titleLabel.snp.makeConstraints { make in
make.top.leading.trailing.equalToSuperview().inset(UIEdgeInsets(top: 14, left: 18, bottom: 0, right: 18))
}
valueLabel.snp.makeConstraints { make in
make.top.equalTo(titleLabel.snp.bottom).offset(7)
make.leading.equalToSuperview().offset(18)
if trailingIconName == nil {
make.trailing.equalToSuperview().inset(18)
}
make.bottom.equalToSuperview().inset(14)
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
///
private final class WildReportHandlerInfoRowView: UIView {
init(title: String, value: String, trailingIconName: String? = nil) {
super.init(frame: .zero)
let titleLabel = WildReportUI.label(title, font: .app(.captionMedium), color: AppColor.textSecondary)
titleLabel.setContentHuggingPriority(.required, for: .horizontal)
titleLabel.setContentCompressionResistancePriority(.required, for: .horizontal)
let valueLabel = WildReportUI.label(value, font: .app(.subtitle), color: AppColor.textPrimary, lines: 1)
valueLabel.textAlignment = .right
valueLabel.lineBreakMode = .byTruncatingTail
valueLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
addSubview(titleLabel)
addSubview(valueLabel)
if let trailingIconName {
let icon = UIImageView(image: UIImage(systemName: trailingIconName))
icon.tintColor = AppColor.primary
icon.contentMode = .scaleAspectFit
addSubview(icon)
icon.snp.makeConstraints { make in
make.trailing.equalToSuperview().inset(18)
make.centerY.equalToSuperview()
make.size.equalTo(16)
}
valueLabel.snp.makeConstraints { make in
make.trailing.equalTo(icon.snp.leading).offset(-6)
}
}
titleLabel.snp.makeConstraints { make in
make.leading.equalToSuperview().offset(18)
make.centerY.equalToSuperview()
}
valueLabel.snp.makeConstraints { make in
make.leading.greaterThanOrEqualTo(titleLabel.snp.trailing).offset(14)
make.centerY.equalToSuperview()
if trailingIconName == nil {
make.trailing.equalToSuperview().inset(18)
}
}
snp.makeConstraints { make in
make.height.equalTo(48)
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
private extension MediaPreviewItem {
init?(wildReportReporterItem item: WildReportReporterMediaItem) {
guard let urlText = item.previewKey else { return nil }
switch item {
case .image:
guard let previewItem = MediaPreviewItem.remoteImage(urlText) else { return nil }
self = previewItem
case .video:
guard let previewItem = MediaPreviewItem.remoteVideo(urlText) else { return nil }
self = previewItem
}
}
init?(wildReportCompletionItem item: WildReportCompletionMediaItem) {
guard let urlText = item.previewKey else { return nil }
switch item.kind {
case .image:
guard let previewItem = MediaPreviewItem.remoteImage(urlText) else { return nil }
self = previewItem
case .video:
guard let previewItem = MediaPreviewItem.remoteVideo(urlText) else { return nil }
self = previewItem
}
}
}
private extension BaseViewController {
@MainActor
func showWildReportShareResult(_ result: WeChatShareResult) {
switch result {
case .success:
showToast("分享成功")
case .cancelled:
showToast("已取消分享")
case .notInstalled:
showToast("请先安装微信")
case .unsupported:
showToast("当前微信版本不支持分享")
case .invalidPayload(let message):
showToast(message)
case .sendFailed:
showToast("微信分享发送失败")
case .unknown:
showToast("微信分享失败")
}
}
}
private extension WildReportReporterMediaItem {
var previewKey: String? {
guard let text = fileURL?.trimmingCharacters(in: .whitespacesAndNewlines), !text.isEmpty else { return nil }
return text
}
}
private extension WildReportCompletionMediaItem {
var previewKey: String? {
let text = coverURL.trimmingCharacters(in: .whitespacesAndNewlines)
return text.isEmpty ? nil : text
}
}
///
private final class WildReportDetailMediaThumbnailView: UIView {
let previewKindText: String
let previewKey: String?
private let gradient = CAGradientLayer()
private let remoteImageView = UIImageView()
private let footerLabel = PaddingLabel(insets: UIEdgeInsets(top: 3, left: 8, bottom: 3, right: 8))
private let playView = UIImageView(image: UIImage(systemName: "play.circle.fill"))
init(reporterItem: WildReportReporterMediaItem) {
previewKey = reporterItem.previewKey
switch reporterItem {
case .image(let index, let url, let fileName, let fileTypeText):
previewKindText = fileName?.isEmpty == false ? fileName! : "\(index + 1)张举报图片"
super.init(frame: .zero)
configure(kind: .image(index: index), footerText: fileTypeText ?? "图片 \(index + 1)", imageURL: url, showsFooter: false)
case .video(let index, _, let fileName, let fileTypeText):
previewKindText = fileName?.isEmpty == false ? fileName! : "\(index + 1)段举报视频"
super.init(frame: .zero)
configure(kind: .video(index: index, duration: fileTypeText ?? "00:32"), footerText: fileTypeText ?? "视频 \(index + 1)", showsFooter: false)
}
}
init(completionItem: WildReportCompletionMediaItem) {
previewKey = completionItem.previewKey
switch completionItem.kind {
case .image:
previewKindText = "处理凭证图片"
super.init(frame: .zero)
configure(kind: .image(index: 0), footerText: "凭证", imageURL: completionItem.coverURL)
case .video:
previewKindText = "处理凭证视频"
super.init(frame: .zero)
configure(kind: .video(index: 0, duration: completionItem.duration ?? "00:32"), footerText: completionItem.duration ?? "视频")
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func layoutSubviews() {
super.layoutSubviews()
gradient.frame = bounds
}
private func configure(kind: Kind, footerText: String, imageURL: String? = nil, showsFooter: Bool = true) {
clipsToBounds = true
layer.cornerRadius = 10
gradient.colors = kind.gradientColors
gradient.startPoint = CGPoint(x: 0, y: 0)
gradient.endPoint = CGPoint(x: 1, y: 1)
layer.insertSublayer(gradient, at: 0)
if let imageURL, let url = URL(string: imageURL) {
remoteImageView.contentMode = .scaleAspectFill
remoteImageView.clipsToBounds = true
remoteImageView.kf.setImage(with: url, placeholder: UIImage(systemName: "photo"))
addSubview(remoteImageView)
remoteImageView.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
}
if imageURL == nil {
addPlaceholderArtwork(for: kind)
}
footerLabel.text = footerText
footerLabel.font = .systemFont(ofSize: 11, weight: .bold)
footerLabel.textColor = .white
footerLabel.backgroundColor = UIColor.black.withAlphaComponent(0.24)
footerLabel.layer.cornerRadius = 9
footerLabel.clipsToBounds = true
footerLabel.isHidden = !showsFooter
addSubview(footerLabel)
footerLabel.snp.makeConstraints { make in
make.leading.bottom.equalToSuperview().inset(8)
}
if case .video(_, let duration) = kind {
let playCircle = UIView()
playCircle.backgroundColor = UIColor.black.withAlphaComponent(0.08)
playCircle.layer.cornerRadius = 21
playCircle.layer.borderWidth = 2.5
playCircle.layer.borderColor = UIColor.white.cgColor
addSubview(playCircle)
playView.image = UIImage(systemName: "play.fill")
playView.tintColor = .white
addSubview(playView)
let durationLabel = PaddingLabel(insets: UIEdgeInsets(top: 2, left: 6, bottom: 2, right: 6))
durationLabel.text = duration
durationLabel.font = .systemFont(ofSize: 14, weight: .semibold)
durationLabel.textColor = .white
durationLabel.backgroundColor = .clear
durationLabel.layer.shadowColor = UIColor.black.cgColor
durationLabel.layer.shadowOpacity = 0.25
durationLabel.layer.shadowRadius = 4
durationLabel.layer.shadowOffset = CGSize(width: 0, height: 2)
addSubview(durationLabel)
playCircle.snp.makeConstraints { make in
make.center.equalToSuperview()
make.size.equalTo(42)
}
playView.snp.makeConstraints { make in
make.centerX.equalToSuperview().offset(2)
make.centerY.equalToSuperview()
make.size.equalTo(18)
}
durationLabel.snp.makeConstraints { make in
make.trailing.bottom.equalToSuperview().inset(10)
}
}
}
private func addPlaceholderArtwork(for kind: Kind) {
let index = min(kind.artworkIndex, 2)
let mountain = UIImageView(image: UIImage(systemName: "mountain.2.fill"))
mountain.tintColor = UIColor.white.withAlphaComponent(0.32)
mountain.contentMode = .scaleAspectFit
addSubview(mountain)
let camera = UIImageView(image: UIImage(systemName: "camera.fill"))
camera.tintColor = UIColor.white.withAlphaComponent(0.90)
camera.contentMode = .scaleAspectFit
addSubview(camera)
let person = UIImageView(image: UIImage(systemName: "person.fill"))
person.tintColor = UIColor(hex: 0x101827).withAlphaComponent(0.78)
person.contentMode = .scaleAspectFit
addSubview(person)
mountain.snp.makeConstraints { make in
make.width.height.equalTo(52)
make.centerX.equalToSuperview().offset(-16)
make.centerY.equalToSuperview().offset(-8)
}
camera.snp.makeConstraints { make in
make.width.height.equalTo(22)
make.trailing.equalToSuperview().inset(10)
make.centerY.equalToSuperview().offset(-18)
}
person.snp.makeConstraints { make in
make.width.height.equalTo(44)
make.trailing.equalToSuperview().inset(24 - index * 12)
make.bottom.equalToSuperview().offset(10)
}
}
///
private enum Kind {
case image(index: Int)
case video(index: Int, duration: String)
var artworkIndex: Int {
switch self {
case .image(let index), .video(let index, _):
return index
}
}
var gradientColors: [CGColor] {
switch self {
case .image(let index):
switch index {
case 1:
return [UIColor(hex: 0xD7E8F8).cgColor, UIColor(hex: 0x7E9CB4).cgColor]
case 2:
return [UIColor(hex: 0xBFD9EE).cgColor, UIColor(hex: 0x435B70).cgColor]
default:
return [UIColor(hex: 0xC7DFEC).cgColor, UIColor(hex: 0x2D3D4B).cgColor]
}
case .video:
return [UIColor(hex: 0xBFD9EE).cgColor, UIColor(hex: 0x435B70).cgColor]
}
}
}
}
///
private final class WildReportDetailMapPreviewView: UIView {
private let mapView: MAMapView
init(scenicName: String, detailAddress: String, coordinate: CLLocationCoordinate2D?) {
_ = AMapBootstrap.configureIfNeeded()
mapView = MAMapView(frame: .zero)
super.init(frame: .zero)
backgroundColor = AppColor.pageBackgroundSoft
layer.cornerRadius = 10
clipsToBounds = true
mapView.showsCompass = false
mapView.showsScale = false
mapView.isScrollEnabled = false
mapView.isZoomEnabled = false
mapView.isRotateEnabled = false
mapView.isRotateCameraEnabled = false
mapView.zoomLevel = 16
addSubview(mapView)
mapView.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
guard let coordinate else { return }
let annotation = MAPointAnnotation()
annotation.coordinate = coordinate
annotation.title = scenicName
annotation.subtitle = detailAddress
mapView.addAnnotation(annotation)
mapView.setCenter(coordinate, animated: false)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
/// 线
private final class WildReportDetailTimelineRowView: UIView {
init(step: WildReportProgressStep, isLast: Bool) {
super.init(frame: .zero)
let leftColumn = UIView()
let node = WildReportDetailTimelineNodeView(state: step.state)
let line = UIView()
line.backgroundColor = step.state == .pending ? AppColor.cardOutline : AppColor.primary.withAlphaComponent(0.45)
line.isHidden = isLast
leftColumn.addSubview(line)
leftColumn.addSubview(node)
addSubview(leftColumn)
let textStack = UIStackView()
textStack.axis = .vertical
textStack.spacing = 5
let isPending = step.state == .pending
let title = WildReportUI.label(
step.title,
font: .systemFont(ofSize: 16, weight: step.state == .current ? .bold : .semibold),
color: isPending ? AppColor.textTertiary : AppColor.textPrimary
)
textStack.addArrangedSubview(title)
if !isPending || !step.timeText.isEmpty {
textStack.addArrangedSubview(WildReportUI.label(
step.timeText,
font: .app(.captionMedium),
color: step.state == .current ? AppColor.primary : AppColor.textSecondary,
lines: 0
))
}
addSubview(textStack)
leftColumn.snp.makeConstraints { make in
make.leading.top.bottom.equalToSuperview()
make.width.equalTo(24)
}
node.snp.makeConstraints { make in
make.top.centerX.equalToSuperview()
make.size.equalTo(24)
}
line.snp.makeConstraints { make in
make.top.equalTo(node.snp.bottom).offset(4)
make.centerX.equalTo(node)
make.bottom.equalToSuperview().inset(-8)
make.width.equalTo(2)
}
textStack.snp.makeConstraints { make in
make.top.equalToSuperview().offset(1)
make.leading.equalTo(leftColumn.snp.trailing).offset(12)
make.trailing.equalToSuperview()
make.bottom.equalToSuperview().inset(16)
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
/// 线
private final class WildReportDetailTimelineNodeView: UIView {
private let state: WildReportProgressStepState
init(state: WildReportProgressStepState) {
self.state = state
super.init(frame: .zero)
backgroundColor = .clear
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func draw(_ rect: CGRect) {
let circle = UIBezierPath(ovalIn: rect.insetBy(dx: 1.5, dy: 1.5))
switch state {
case .completed:
AppColor.primary.setFill()
circle.fill()
let check = UIBezierPath()
check.move(to: CGPoint(x: rect.width * 0.30, y: rect.height * 0.52))
check.addLine(to: CGPoint(x: rect.width * 0.45, y: rect.height * 0.66))
check.addLine(to: CGPoint(x: rect.width * 0.72, y: rect.height * 0.36))
UIColor.white.setStroke()
check.lineWidth = 2.2
check.lineCapStyle = .round
check.lineJoinStyle = .round
check.stroke()
case .current:
AppColor.primary.setStroke()
circle.lineWidth = 2
circle.stroke()
AppColor.primary.setFill()
UIBezierPath(ovalIn: rect.insetBy(dx: 7, dy: 7)).fill()
case .pending:
AppColor.cardOutline.setStroke()
circle.lineWidth = 2
circle.stroke()
}
}
}