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

1412 lines
54 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 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(scrollView)
scrollView.addSubview(stack)
reloadContent(animated: false)
}
override func setupConstraints() {
scrollView.snp.makeConstraints { make in
make.edges.equalTo(view.safeAreaLayoutGuide)
}
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(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() {
stack.arrangedSubviews.forEach { view in
stack.removeArrangedSubview(view)
view.removeFromSuperview()
}
filterTabBar.apply(selectedFilter: viewModel.selectedFilter)
stack.addArrangedSubview(filterTabBar)
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?.viewModel.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),
animated: true
)
}
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.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 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.snp.makeConstraints { make in
make.height.equalTo(22)
}
return label
}
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 scrollView = UIScrollView()
private let stack = UIStackView()
private let bottomBar = UIView()
private let bottomButtonRow = UIStackView()
init(record: WildReportRecord, homeViewModel: WildPhotographerReportHomeViewModel) {
self.viewModel = WildPhotographerReportDetailViewModel(record: record, homeViewModel: homeViewModel)
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 = 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 bindActions() {
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))
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())
}
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(WildReportDetailInfoRowView(title: "处理人", value: info.handlerName))
card.stack.addArrangedSubview(makeInsetDivider())
card.stack.addArrangedSubview(WildReportDetailInfoRowView(title: "处理人电话", value: info.maskedHandlerPhone, trailingIconName: "phone.fill"))
card.stack.addArrangedSubview(makeInsetDivider())
card.stack.addArrangedSubview(WildReportDetailInfoRowView(title: "处理时间", value: info.processingAt))
card.stack.addArrangedSubview(makeInsetDivider())
card.stack.addArrangedSubview(WildReportDetailInfoRowView(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("\(items.count - 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(makeSectionHeader(
iconName: "photo.on.rectangle.angled",
title: "举报人材料",
subtitle: "\(viewModel.imageCountText)图片 \(viewModel.videoCountText)视频",
chevron: true,
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 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(makeSectionHeader(
iconName: "photo.on.rectangle.angled",
title: "处理凭证",
subtitle: info.completionMediaSummary,
chevron: false,
action: nil
))
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(makeSectionHeader(
iconName: "mappin.circle.fill",
title: "位置",
subtitle: viewModel.detailAddress,
chevron: true,
action: #selector(locationTapped)
))
let mapPreview = WildReportDetailMapPreviewView(locationText: viewModel.detailAddress)
section.addArrangedSubview(mapPreview)
mapPreview.snp.makeConstraints { make in
make.height.equalTo(98)
}
return section
}
private func makeSectionHeader(iconName: String, title: String, subtitle: String, 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))
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 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)
}
textStack.snp.makeConstraints { make in
make.leading.equalTo(iconBox.snp.trailing).offset(12)
make.centerY.equalToSuperview()
make.trailing.equalTo(chevronView.snp.leading).offset(-10)
}
control.snp.makeConstraints { make in
make.height.greaterThanOrEqualTo(50)
}
return control
}
private func makeScenicPill() -> 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
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)
return pill
}
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() { viewModel.shareReport() }
@objc private func previewTapped() { viewModel.showMediaPreview(kind: "图片/视频") }
@objc private func locationTapped() { viewModel.showLocationPreview() }
@objc private func reporterMediaTapped(_ recognizer: UITapGestureRecognizer) {
guard let tile = recognizer.view as? WildReportDetailMediaThumbnailView else { return }
viewModel.showMediaPreview(kind: tile.previewKindText)
}
@objc private func completionMediaTapped(_ recognizer: UITapGestureRecognizer) {
guard let tile = recognizer.view as? WildReportDetailMediaThumbnailView else { return }
viewModel.showMediaPreview(kind: tile.previewKindText)
}
@objc private func supplementTapped() {
navigationController?.pushViewController(
WildReportSupplementEvidenceViewController(reportID: viewModel.record.id, homeViewModel: homeViewModel),
animated: true
)
}
@objc private func mapTapped() {
navigationController?.pushViewController(
WildReportRiskMapViewController(record: viewModel.record, riskPoints: homeViewModel.riskPoints),
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.textAlignment = multiline ? .left : .right
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 WildReportDetailMediaThumbnailView: UIView {
let previewKindText: String
private let gradient = CAGradientLayer()
private let iconView = 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) {
switch reporterItem {
case .image(let index):
previewKindText = "\(index + 1)张举报图片"
super.init(frame: .zero)
configure(kind: .image(index: index), footerText: "图片 \(index + 1)")
case .video(let index):
previewKindText = "\(index + 1)段举报视频"
super.init(frame: .zero)
configure(kind: .video(index: index, duration: "00:32"), footerText: "视频 \(index + 1)")
}
}
init(completionItem: WildReportCompletionMediaItem) {
switch completionItem.kind {
case .image:
previewKindText = "处理凭证图片"
super.init(frame: .zero)
configure(kind: .image(index: 0), footerText: "凭证")
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) {
clipsToBounds = true
layer.cornerRadius = 14
gradient.colors = kind.gradientColors
gradient.startPoint = CGPoint(x: 0, y: 0)
gradient.endPoint = CGPoint(x: 1, y: 1)
layer.insertSublayer(gradient, at: 0)
iconView.image = UIImage(systemName: kind.iconName)
iconView.tintColor = UIColor.white.withAlphaComponent(0.92)
iconView.contentMode = .scaleAspectFit
addSubview(iconView)
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
addSubview(footerLabel)
iconView.snp.makeConstraints { make in
make.center.equalToSuperview()
make.size.equalTo(36)
}
footerLabel.snp.makeConstraints { make in
make.leading.bottom.equalToSuperview().inset(8)
}
if case .video(_, let duration) = kind {
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: 10, weight: .bold)
durationLabel.textColor = .white
durationLabel.backgroundColor = UIColor.black.withAlphaComponent(0.34)
durationLabel.layer.cornerRadius = 8
durationLabel.clipsToBounds = true
addSubview(durationLabel)
playView.snp.makeConstraints { make in
make.center.equalToSuperview()
make.size.equalTo(34)
}
durationLabel.snp.makeConstraints { make in
make.trailing.top.equalToSuperview().inset(8)
}
}
}
///
private enum Kind {
case image(index: Int)
case video(index: Int, duration: String)
var iconName: String {
switch self {
case .image(let index):
return index.isMultiple(of: 2) ? "person.crop.rectangle.fill" : "camera.fill"
case .video:
return "video.fill"
}
}
var gradientColors: [CGColor] {
switch self {
case .image(let index):
return index.isMultiple(of: 2)
? [UIColor(hex: 0x75C7FF).cgColor, UIColor(hex: 0x0073FF).cgColor]
: [UIColor(hex: 0xA6E3B8).cgColor, UIColor(hex: 0x10B981).cgColor]
case .video:
return [UIColor(hex: 0x8CA8FF).cgColor, UIColor(hex: 0x4F46E5).cgColor]
}
}
}
}
///
private final class WildReportDetailMapPreviewView: UIView {
private let locationLabel = UILabel()
private let pinView = UIImageView(image: UIImage(systemName: "mappin.circle.fill"))
init(locationText: String) {
super.init(frame: .zero)
backgroundColor = UIColor(hex: 0xEAF7EA)
layer.cornerRadius = 14
clipsToBounds = true
locationLabel.text = locationText
locationLabel.font = .systemFont(ofSize: 12, weight: .bold)
locationLabel.textColor = AppColor.textPrimary
locationLabel.backgroundColor = UIColor.white.withAlphaComponent(0.86)
locationLabel.layer.cornerRadius = 10
locationLabel.clipsToBounds = true
locationLabel.textAlignment = .center
pinView.tintColor = AppColor.danger
pinView.contentMode = .scaleAspectFit
addSubview(locationLabel)
addSubview(pinView)
locationLabel.snp.makeConstraints { make in
make.leading.equalToSuperview().offset(12)
make.bottom.equalToSuperview().inset(10)
make.trailing.lessThanOrEqualToSuperview().inset(12)
make.height.equalTo(24)
}
pinView.snp.makeConstraints { make in
make.centerX.equalToSuperview().offset(34)
make.centerY.equalToSuperview().offset(-2)
make.size.equalTo(32)
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func draw(_ rect: CGRect) {
super.draw(rect)
guard let context = UIGraphicsGetCurrentContext() else { return }
context.setFillColor(UIColor(hex: 0xD9F1D8).cgColor)
context.fill(CGRect(x: rect.width * 0.56, y: 0, width: rect.width * 0.44, height: rect.height))
let road = UIBezierPath()
road.move(to: CGPoint(x: 0, y: rect.height * 0.62))
road.addCurve(
to: CGPoint(x: rect.width, y: rect.height * 0.24),
controlPoint1: CGPoint(x: rect.width * 0.28, y: rect.height * 0.26),
controlPoint2: CGPoint(x: rect.width * 0.64, y: rect.height * 0.88)
)
UIColor.white.withAlphaComponent(0.86).setStroke()
road.lineWidth = 10
road.stroke()
UIColor(hex: 0xBFDDBD).setStroke()
road.lineWidth = 2
road.stroke()
let path = UIBezierPath()
path.move(to: CGPoint(x: rect.width * 0.16, y: rect.height * 0.24))
path.addCurve(
to: CGPoint(x: rect.width * 0.76, y: rect.height * 0.70),
controlPoint1: CGPoint(x: rect.width * 0.38, y: rect.height * 0.34),
controlPoint2: CGPoint(x: rect.width * 0.48, y: rect.height * 0.76)
)
AppColor.primary.setStroke()
path.lineWidth = 2
path.setLineDash([6, 5], count: 2, phase: 0)
path.stroke()
}
}
/// 线
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()
}
}
}