1855 lines
72 KiB
Swift
1855 lines
72 KiB
Swift
//
|
||
// 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()
|
||
}
|
||
}
|
||
}
|