Files
suixinkan_uikit/suixinkan/UI/WildPhotographerReport/WildPhotographerReportListViewController.swift
2026-07-08 14:02:44 +08:00

1078 lines
43 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 segmentedControl = UISegmentedControl(items: WildReportListFilter.allCases.map(\.rawValue))
private let tableView = UITableView(frame: .zero, style: .plain)
init(homeViewModel: WildPhotographerReportHomeViewModel) {
self.viewModel = WildPhotographerReportListViewModel(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
segmentedControl.selectedSegmentIndex = 0
segmentedControl.addTarget(self, action: #selector(filterChanged), for: .valueChanged)
tableView.backgroundColor = .clear
tableView.separatorStyle = .none
tableView.delegate = self
tableView.dataSource = self
tableView.register(WildReportListCell.self, forCellReuseIdentifier: WildReportListCell.reuseIdentifier)
tableView.contentInset = UIEdgeInsets(top: AppSpacing.sm, left: 0, bottom: AppSpacing.lg, right: 0)
view.addSubview(segmentedControl)
view.addSubview(tableView)
}
override func setupConstraints() {
segmentedControl.snp.makeConstraints { make in
make.top.equalTo(view.safeAreaLayoutGuide).offset(AppSpacing.sm)
make.leading.trailing.equalToSuperview().inset(AppSpacing.md)
make.height.equalTo(36)
}
tableView.snp.makeConstraints { make in
make.top.equalTo(segmentedControl.snp.bottom).offset(AppSpacing.xs)
make.leading.trailing.bottom.equalTo(view.safeAreaLayoutGuide)
}
}
override func bindActions() {
viewModel.onStateChange = { [weak self] in
Task { @MainActor in self?.tableView.reloadData() }
}
viewModel.onShowMessage = { [weak self] message in
Task { @MainActor in self?.showToast(message) }
}
}
@objc private func filterChanged() {
let filter = WildReportListFilter.allCases[segmentedControl.selectedSegmentIndex]
viewModel.selectFilter(filter)
}
}
extension WildPhotographerReportListViewController: UITableViewDataSource, UITableViewDelegate {
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
let count = viewModel.filteredReports.count
tableView.backgroundView = count == 0 ? WildReportEmptyView(text: "暂无相关举报记录") : nil
return count
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: WildReportListCell.reuseIdentifier, for: indexPath) as! WildReportListCell
let record = viewModel.filteredReports[indexPath.row]
cell.apply(record: record)
cell.onShare = { [weak self] in self?.viewModel.shareReport(record) }
return cell
}
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true)
let record = viewModel.filteredReports[indexPath.row]
navigationController?.pushViewController(
WildPhotographerReportDetailViewController(record: record, homeViewModel: viewModel.homeViewModel),
animated: true
)
}
func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
UITableView.automaticDimension
}
}
/// Cell
final class WildReportListCell: UITableViewCell {
static let reuseIdentifier = "WildReportListCell"
private let card = WildReportCardView(spacing: AppSpacing.sm)
private let titleLabel = UILabel()
private let statusView = WildReportStatusView()
private let typeLabel = UILabel()
private let locationLabel = UILabel()
private let timeLabel = UILabel()
private let descLabel = UILabel()
private let materialLabel = UILabel()
var onShare: (() -> Void)?
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
selectionStyle = .none
backgroundColor = .clear
contentView.addSubview(card)
card.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(UIEdgeInsets(top: AppSpacing.xs, left: AppSpacing.md, bottom: AppSpacing.xs, right: AppSpacing.md))
}
let header = UIStackView()
header.axis = .horizontal
header.alignment = .center
header.spacing = AppSpacing.sm
titleLabel.font = .app(.title)
titleLabel.textColor = AppColor.textPrimary
header.addArrangedSubview(titleLabel)
header.addArrangedSubview(UIView())
statusView.snp.makeConstraints { make in
make.width.equalTo(64)
make.height.equalTo(24)
}
header.addArrangedSubview(statusView)
[typeLabel, locationLabel, timeLabel, descLabel, materialLabel].forEach { label in
label.font = .app(.body)
label.textColor = AppColor.textSecondary
label.numberOfLines = 0
}
let shareButton = UIButton(type: .system)
shareButton.setTitle("分享", for: .normal)
shareButton.setImage(UIImage(systemName: "square.and.arrow.up"), for: .normal)
shareButton.titleLabel?.font = .app(.captionMedium)
shareButton.addTarget(self, action: #selector(shareTapped), for: .touchUpInside)
card.stack.addArrangedSubview(header)
card.stack.addArrangedSubview(typeLabel)
card.stack.addArrangedSubview(locationLabel)
card.stack.addArrangedSubview(timeLabel)
card.stack.addArrangedSubview(descLabel)
card.stack.addArrangedSubview(materialLabel)
card.stack.addArrangedSubview(shareButton)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func apply(record: WildReportRecord) {
titleLabel.text = record.title
statusView.apply(status: record.status)
typeLabel.text = "举报类型:\(record.reportType.rawValue)"
locationLabel.text = "位置:\(record.location)"
timeLabel.text = "提交时间:\(record.submitTime)"
descLabel.text = record.description
materialLabel.text = "材料:\(record.imageCount)张图片 \(record.videoCount)段视频"
}
@objc private func shareTapped() {
onShare?()
}
}
///
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.rawValue))
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()
}
}
}