Files
suixinkan_uikit/suixinkan/UI/WildPhotographerReport/WildReportRiskMapViewController.swift
汉秋 efb3925257 完善首页权限申请流程与队列播报体验。
对齐 Android 无权限强制弹窗、角色下拉与申请页交互,补充 UIButton configuration 适配,并增强景区排队 TTS 与相关单元测试。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-14 13:46:45 +08:00

1419 lines
53 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.

//
// WildReportRiskMapViewController.swift
// suixinkan
//
import Kingfisher
import MapKit
import SnapKit
import UIKit
/// 线
final class WildReportRiskMapViewController: BaseViewController {
private static let cachedNavigationAppKey = "wildReportRiskMap.cachedNavigationApp"
private let viewModel: WildReportRiskMapViewModel
private let api: any WildPhotographerReportServing
private let locationProvider: any LocationProviding
private let scenicIdProvider: () -> Int
private let scenicNameProvider: () -> String
private let rootStack = UIStackView()
private let mapContainerView = UIView()
private let mapView = MKMapView()
private let scenicCapsule = UIView()
private let scenicNameLabel = UILabel()
private let mapControls = LocationReportMapControlStack()
private let legendBar = UIStackView()
private let detailContainerView = UIView()
private let scrollView = UIScrollView()
private let contentStack = UIStackView()
private var annotationMap: [String: WildReportMapMarker] = [:]
private var isShowingRiskMapLoading = false
init(
api: any WildPhotographerReportServing = NetworkServices.shared.wildPhotographerReportAPI,
locationProvider: any LocationProviding = LocationProvider.shared,
scenicIdProvider: @escaping () -> Int = { AppStore.shared.session.currentScenicId },
scenicNameProvider: @escaping () -> String = { AppStore.shared.session.currentScenicName }
) {
self.viewModel = WildReportRiskMapViewModel()
self.api = api
self.locationProvider = locationProvider
self.scenicIdProvider = scenicIdProvider
self.scenicNameProvider = scenicNameProvider
super.init(nibName: nil, bundle: nil)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
Task {
await viewModel.loadInitial(
api: api,
locationProvider: locationProvider,
scenicId: scenicIdProvider(),
scenicName: scenicNameProvider()
)
}
}
override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
if isMovingFromParent || isBeingDismissed || navigationController?.isBeingDismissed == true {
setRiskMapLoadingVisible(false)
}
}
override func setupNavigationBar() {
title = "附近风险地图"
}
override func setupUI() {
view.backgroundColor = UIColor(hex: 0xF8FAFF)
rootStack.axis = .vertical
rootStack.spacing = 0
view.addSubview(rootStack)
mapContainerView.backgroundColor = UIColor(hex: 0xE8F3FF)
mapContainerView.clipsToBounds = true
rootStack.addArrangedSubview(mapContainerView)
mapView.delegate = self
mapView.showsCompass = false
mapView.showsScale = true
mapView.pointOfInterestFilter = .excludingAll
mapContainerView.addSubview(mapView)
configureScenicCapsule()
configureMapControls()
configureLegendBar()
configureDetailArea()
refreshContent(animated: false)
}
override func setupConstraints() {
rootStack.snp.makeConstraints { make in
make.edges.equalTo(view.safeAreaLayoutGuide)
}
mapContainerView.snp.makeConstraints { make in
make.height.equalTo(340)
}
mapView.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
scenicCapsule.snp.makeConstraints { make in
make.top.leading.equalToSuperview().inset(12)
make.height.equalTo(34)
}
mapControls.snp.makeConstraints { make in
make.top.trailing.equalToSuperview().inset(12)
}
detailContainerView.snp.makeConstraints { make in
make.height.greaterThanOrEqualTo(180)
}
}
override func bindActions() {
viewModel.onStateChange = { [weak self] in
Task { @MainActor in
self?.refreshContent(animated: true)
}
}
}
private func configureScenicCapsule() {
scenicCapsule.backgroundColor = UIColor.white.withAlphaComponent(0.94)
scenicCapsule.layer.cornerRadius = 17
scenicCapsule.layer.shadowColor = UIColor.black.cgColor
scenicCapsule.layer.shadowOpacity = 0.08
scenicCapsule.layer.shadowRadius = 8
scenicCapsule.layer.shadowOffset = CGSize(width: 0, height: 4)
let icon = UIImageView(image: UIImage(systemName: "mountain.2.fill"))
icon.tintColor = AppColor.primary
icon.contentMode = .scaleAspectFit
scenicNameLabel.font = .systemFont(ofSize: 13, weight: .semibold)
scenicNameLabel.textColor = UIColor(hex: 0x195591)
mapContainerView.addSubview(scenicCapsule)
scenicCapsule.addSubview(icon)
scenicCapsule.addSubview(scenicNameLabel)
icon.snp.makeConstraints { make in
make.leading.equalToSuperview().offset(12)
make.centerY.equalToSuperview()
make.size.equalTo(16)
}
scenicNameLabel.snp.makeConstraints { make in
make.leading.equalTo(icon.snp.trailing).offset(6)
make.trailing.equalToSuperview().inset(12)
make.centerY.equalToSuperview()
}
}
private func configureMapControls() {
mapControls.layer.shadowColor = UIColor.black.cgColor
mapControls.layer.shadowOpacity = 0.1
mapControls.layer.shadowRadius = 8
mapControls.layer.shadowOffset = CGSize(width: 0, height: 4)
mapControls.onZoomIn = { [weak self] in
self?.zoomMap(by: 0.5)
}
mapControls.onZoomOut = { [weak self] in
self?.zoomMap(by: 2)
}
mapControls.onRelocate = { [weak self] in
self?.focusCurrentLocation()
}
mapContainerView.addSubview(mapControls)
}
private func configureLegendBar() {
legendBar.axis = .horizontal
legendBar.alignment = .center
legendBar.distribution = .fillEqually
legendBar.spacing = 6
legendBar.backgroundColor = .white
legendBar.layoutMargins = UIEdgeInsets(top: 12, left: 12, bottom: 12, right: 12)
legendBar.isLayoutMarginsRelativeArrangement = true
rootStack.addArrangedSubview(legendBar)
rebuildLegend()
let divider = UIView()
divider.backgroundColor = AppColor.cardOutline
rootStack.addArrangedSubview(divider)
divider.snp.makeConstraints { make in
make.height.equalTo(1 / UIScreen.main.scale)
}
}
private func configureDetailArea() {
detailContainerView.backgroundColor = UIColor(hex: 0xF8FAFF)
rootStack.addArrangedSubview(detailContainerView)
detailContainerView.addSubview(scrollView)
scrollView.alwaysBounceVertical = true
scrollView.addSubview(contentStack)
contentStack.axis = .vertical
contentStack.spacing = 14
scrollView.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
contentStack.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(16)
make.width.equalTo(scrollView.snp.width).offset(-32)
}
}
@MainActor
private func refreshContent(animated: Bool) {
setRiskMapLoadingVisible(viewModel.isLoading)
scenicNameLabel.text = viewModel.scenicAreaName
updateLocateButtonState()
syncMapAnnotations()
mapView.setRegion(viewModel.region, animated: animated)
updateAnnotationSelection()
rebuildLegend()
rebuildPanel()
}
@MainActor
private func setRiskMapLoadingVisible(_ isVisible: Bool) {
guard isShowingRiskMapLoading != isVisible else { return }
isShowingRiskMapLoading = isVisible
isVisible ? showLoading() : hideLoading()
}
@MainActor
private func updateLocateButtonState() {
let hasCurrentLocation = viewModel.markers.contains { $0.kind == .selfLocation }
mapControls.setRelocateEnabled(hasCurrentLocation)
}
private func syncMapAnnotations() {
let oldAnnotations = mapView.annotations.compactMap { $0 as? WildReportMapAnnotation }
mapView.removeAnnotations(oldAnnotations)
annotationMap.removeAll()
let annotations = viewModel.markers.map { marker in
annotationMap[marker.id] = marker
return WildReportMapAnnotation(marker: marker)
}
mapView.addAnnotations(annotations)
}
private func updateAnnotationSelection() {
for annotation in mapView.annotations {
guard let annotation = annotation as? WildReportMapAnnotation,
let view = mapView.view(for: annotation) as? WildReportPulseMarkerAnnotationView
else { continue }
view.apply(marker: annotation.marker, selected: annotation.marker.id == viewModel.selectedMarkerID)
}
}
private func rebuildLegend() {
legendBar.arrangedSubviews.forEach { view in
legendBar.removeArrangedSubview(view)
view.removeFromSuperview()
}
[
WildReportMapMarkerKind.selfLocation,
.wildPhotographer,
.processingClue,
.peerPhotographer
].forEach { kind in
legendBar.addArrangedSubview(WildReportRiskLegendView(kind: kind))
}
}
@MainActor
private func rebuildPanel() {
contentStack.arrangedSubviews.forEach { view in
contentStack.removeArrangedSubview(view)
view.removeFromSuperview()
}
if let message = viewModel.errorMessage, !viewModel.isLoading {
contentStack.addArrangedSubview(makeStateCard(icon: "exclamationmark.triangle.fill", text: message))
}
guard let marker = viewModel.selectedMarker,
case .activeClue(let clue) = marker.detail
else {
contentStack.addArrangedSubview(makeStateCard(icon: "hand.tap.fill", text: "点击地图标记查看详情"))
return
}
let card = makeRadarResultCard(clue: clue)
contentStack.addArrangedSubview(card)
}
private func makeRadarResultCard(clue: WildReportRadarActiveClue) -> UIView {
let card = WildReportCardView(spacing: 14, inset: 16)
card.layer.cornerRadius = 16
card.stack.spacing = 14
card.stack.addArrangedSubview(makeRadarHeader(clue: clue))
if clue.type == .blue, let nearest = clue.nearestAbnormalText {
card.stack.addArrangedSubview(makeCompareText(nearest))
}
if clue.type == .green {
addPhotographerProfile(for: clue, to: card.stack)
}
if isReportClue(clue) {
card.stack.addArrangedSubview(makeReporterInfo(clue: clue))
addReportContents(for: clue, to: card.stack)
addRadarImages(for: clue, to: card.stack)
if clue.processingStarted, let record = clue.record {
card.stack.addArrangedSubview(makeProcessingPanel(clue: clue, record: record))
}
}
if clue.type == .green, let summary = clue.summary {
card.stack.addArrangedSubview(makeCompareText(summary))
}
if clue.type == .green, let detail = clue.detail {
card.stack.addArrangedSubview(makeDetailSection(detail))
}
if !clue.completed {
card.stack.addArrangedSubview(makeRadarActionButtons(for: clue))
}
return card
}
private func makeRadarHeader(clue: WildReportRadarActiveClue) -> UIView {
let container = UIStackView()
container.axis = .vertical
container.spacing = 6
let row = UIStackView()
row.axis = .horizontal
row.alignment = .top
row.spacing = 12
let iconWrap = UIView()
iconWrap.backgroundColor = headerIconColor(for: clue)
iconWrap.layer.cornerRadius = 12
let icon = UIImageView(image: UIImage(systemName: "location.fill"))
icon.tintColor = .white
icon.contentMode = .scaleAspectFit
iconWrap.addSubview(icon)
icon.snp.makeConstraints { make in
make.center.equalToSuperview()
make.size.equalTo(20)
}
iconWrap.snp.makeConstraints { make in
make.size.equalTo(44)
}
let textStack = UIStackView()
textStack.axis = .vertical
textStack.spacing = 6
var reportMetaText: String?
if isReportClue(clue) {
let title = WildReportUI.label("线索 ID\(clue.id)", font: .systemFont(ofSize: 16, weight: .bold), color: AppColor.textPrimary, lines: 2)
textStack.addArrangedSubview(title)
var metaParts: [String] = []
if let distance = clue.distanceText ?? clue.distance {
metaParts.append("距离:距离您\(distance)")
}
if let updatedAt = clue.updatedAt {
metaParts.append("时间:\(updatedAt)")
}
reportMetaText = metaParts.isEmpty ? nil : metaParts.joined(separator: " ")
} else {
let title = WildReportUI.label(clue.displayTitle, font: .systemFont(ofSize: 16, weight: .bold), color: AppColor.textPrimary, lines: 2)
textStack.addArrangedSubview(title)
if clue.type == .green {
let metaText = [clue.distance.map { "距蓝点 \($0)" }, clue.direction, clue.updatedAt].compactMap { $0 }.joined(separator: " ")
if !metaText.isEmpty {
textStack.addArrangedSubview(WildReportUI.label(metaText, font: .systemFont(ofSize: 12), color: AppColor.textSecondary))
}
}
}
let badge = WildReportRiskStatusBadge(text: clue.statusText, color: statusColor(for: clue))
row.addArrangedSubview(iconWrap)
row.addArrangedSubview(textStack)
row.addArrangedSubview(badge)
textStack.setContentHuggingPriority(.defaultLow, for: .horizontal)
badge.setContentHuggingPriority(.required, for: .horizontal)
container.addArrangedSubview(row)
if let reportMetaText {
let metaRow = UIStackView()
metaRow.axis = .horizontal
metaRow.spacing = 0
let spacer = UIView()
spacer.snp.makeConstraints { make in
make.width.equalTo(56)
}
let metaLabel = WildReportUI.label(reportMetaText, font: .systemFont(ofSize: 12), color: AppColor.textSecondary)
metaLabel.textAlignment = .right
metaLabel.setContentCompressionResistancePriority(.required, for: .horizontal)
metaRow.addArrangedSubview(spacer)
metaRow.addArrangedSubview(metaLabel)
container.addArrangedSubview(metaRow)
}
return container
}
private func addPhotographerProfile(for clue: WildReportRadarActiveClue, to stack: UIStackView) {
let row = UIStackView()
row.axis = .horizontal
row.alignment = .center
row.spacing = 12
let avatarWrap = UIView()
avatarWrap.backgroundColor = AppColor.success.withAlphaComponent(0.12)
avatarWrap.layer.cornerRadius = 28
let avatar = UIImageView(image: UIImage(systemName: clue.avatar ?? "person.crop.circle.fill"))
avatar.tintColor = AppColor.success
avatar.contentMode = .scaleAspectFit
avatarWrap.addSubview(avatar)
avatar.snp.makeConstraints { make in
make.center.equalToSuperview()
make.size.equalTo(42)
}
avatarWrap.snp.makeConstraints { make in
make.size.equalTo(56)
}
let textStack = UIStackView()
textStack.axis = .vertical
textStack.spacing = 4
if let name = clue.name {
textStack.addArrangedSubview(WildReportUI.label(name, font: .systemFont(ofSize: 16, weight: .bold), color: AppColor.textPrimary))
}
if let storeName = clue.storeName {
textStack.addArrangedSubview(WildReportUI.label(storeName, font: .systemFont(ofSize: 13, weight: .semibold), color: AppColor.success))
}
row.addArrangedSubview(avatarWrap)
row.addArrangedSubview(textStack)
stack.addArrangedSubview(row)
}
private func makeReporterInfo(clue: WildReportRadarActiveClue) -> UIView {
let box = UIView()
box.backgroundColor = UIColor(hex: 0xF9FAFB)
box.layer.cornerRadius = 10
let inner = UIStackView()
inner.axis = .vertical
inner.spacing = 10
box.addSubview(inner)
inner.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(12)
}
inner.addArrangedSubview(makeInfoLabelRow(label: "举报人", value: clue.reporterName ?? "-"))
inner.addArrangedSubview(makePhoneRow(
label: "举报人手机号",
phone: clue.reporterPhone,
displayPhone: clue.maskedReporterPhone ?? clue.reporterPhone
))
return box
}
private func addReportContents(for clue: WildReportRadarActiveClue, to stack: UIStackView) {
guard !clue.reportContents.isEmpty else { return }
let section = UIStackView()
section.axis = .vertical
section.spacing = 10
for item in clue.reportContents {
let itemStack = UIStackView()
itemStack.axis = .vertical
itemStack.spacing = 4
if !item.time.isEmpty {
itemStack.addArrangedSubview(WildReportUI.label(item.time, font: .systemFont(ofSize: 11, weight: .semibold), color: AppColor.textSecondary))
}
itemStack.addArrangedSubview(makeLineSpacedLabel(item.content, font: .systemFont(ofSize: 13), color: AppColor.textPrimary))
section.addArrangedSubview(itemStack)
}
stack.addArrangedSubview(section)
}
private func addRadarImages(for clue: WildReportRadarActiveClue, to stack: UIStackView) {
guard !clue.images.isEmpty else { return }
let section = UIStackView()
section.axis = .vertical
section.spacing = 10
section.addArrangedSubview(WildReportUI.label("现场图片/视频", font: .systemFont(ofSize: 15, weight: .bold), color: AppColor.textPrimary))
let scroll = UIScrollView()
scroll.showsHorizontalScrollIndicator = false
let row = UIStackView()
row.axis = .horizontal
row.spacing = 10
scroll.addSubview(row)
row.snp.makeConstraints { make in
make.edges.equalToSuperview()
make.height.equalTo(108)
}
for url in clue.images {
let tile = WildReportRiskImageTile(url: url, size: 108)
tile.onTap = { [weak self] in self?.presentImagePreview(url: url) }
row.addArrangedSubview(tile)
}
section.addArrangedSubview(scroll)
scroll.snp.makeConstraints { make in
make.height.equalTo(108)
}
stack.addArrangedSubview(section)
}
private func makeProcessingPanel(clue: WildReportRadarActiveClue, record: WildReportRadarProcessRecord) -> UIView {
let box = UIView()
box.backgroundColor = UIColor(hex: 0xFFFBEB)
box.layer.cornerRadius = 10
let inner = UIStackView()
inner.axis = .vertical
inner.spacing = 10
box.addSubview(inner)
inner.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(12)
}
inner.addArrangedSubview(makeInfoLabelRow(label: "处理人", value: record.handlerName))
inner.addArrangedSubview(makeInfoLabelRow(
label: "状态",
value: clue.completed ? "已处理" : "正在处理中",
valueColor: clue.completed ? AppColor.success : UIColor(hex: 0xF59E0B)
))
inner.addArrangedSubview(makePhoneRow(label: "联系电话", phone: record.phone, displayPhone: record.maskedPhone))
let progressStack = UIStackView()
progressStack.axis = .vertical
progressStack.spacing = 10
progressStack.addArrangedSubview(WildReportUI.label("处理进度", font: .systemFont(ofSize: 14, weight: .bold), color: AppColor.textPrimary))
for item in clue.processingTimeline {
progressStack.addArrangedSubview(makeTimelineRow(item))
}
inner.addArrangedSubview(progressStack)
return box
}
private func makeTimelineRow(_ item: WildReportRadarProcessingTimelineItem) -> UIView {
let row = UIStackView()
row.axis = .horizontal
row.alignment = .top
row.spacing = 10
let dot = UIView()
dot.backgroundColor = timelineDotColor(item.status)
dot.layer.cornerRadius = 5
dot.snp.makeConstraints { make in
make.size.equalTo(10)
}
let dotContainer = UIView()
dotContainer.addSubview(dot)
dot.snp.makeConstraints { make in
make.top.equalToSuperview().offset(4)
make.centerX.equalToSuperview()
}
dotContainer.snp.makeConstraints { make in
make.width.equalTo(10)
}
let textStack = UIStackView()
textStack.axis = .vertical
textStack.spacing = 2
textStack.addArrangedSubview(WildReportUI.label(item.title, font: .systemFont(ofSize: 13, weight: .semibold), color: AppColor.textPrimary))
textStack.addArrangedSubview(WildReportUI.label(item.time, font: .systemFont(ofSize: 12), color: AppColor.textSecondary))
row.addArrangedSubview(dotContainer)
row.addArrangedSubview(textStack)
return row
}
private func makeRadarActionButtons(for clue: WildReportRadarActiveClue) -> UIView {
let row = UIStackView()
row.axis = .horizontal
row.spacing = 10
row.distribution = .fillEqually
let navButton = makeRadarButton(title: clue.actionText, imageName: "map.fill", color: AppColor.primary)
navButton.addTarget(self, action: #selector(navigateTapped), for: .touchUpInside)
row.addArrangedSubview(navButton)
if isReportClue(clue), !clue.completed {
let shareButton = makeRadarButton(title: "分享", imageName: "square.and.arrow.up", color: AppColor.success)
shareButton.addTarget(self, action: #selector(shareTapped), for: .touchUpInside)
row.addArrangedSubview(shareButton)
}
[navButton].forEach { button in
button.snp.makeConstraints { make in make.height.equalTo(44) }
}
return row
}
private func makeRadarButton(title: String, imageName: String, color: UIColor) -> UIButton {
let button = UIButton(type: .system)
button.titleLabel?.font = .systemFont(ofSize: 15, weight: .bold)
button.setTitle(title, for: .normal)
button.setImage(UIImage(systemName: imageName), for: .normal)
button.tintColor = .white
button.setTitleColor(.white, for: .normal)
button.backgroundColor = color
button.layer.cornerRadius = 12
button.setConfigurationContentInsets(
NSDirectionalEdgeInsets(top: 0, leading: 12, bottom: 0, trailing: 12)
)
button.configuration?.imagePlacement = .leading
button.configuration?.imagePadding = 3
button.snp.makeConstraints { make in make.height.equalTo(44) }
return button
}
private func makeCompareText(_ text: String) -> UIView {
let label = makeLineSpacedLabel(text, font: .systemFont(ofSize: 13), color: AppColor.textSecondary)
label.backgroundColor = UIColor(hex: 0xF3F4F6)
label.layer.cornerRadius = 10
label.clipsToBounds = true
label.textInsets = UIEdgeInsets(top: 12, left: 12, bottom: 12, right: 12)
return label
}
private func makeDetailSection(_ detail: String) -> UIView {
let row = UIStackView()
row.axis = .horizontal
row.alignment = .top
row.spacing = 8
let icon = UIImageView(image: UIImage(systemName: "doc.text.fill"))
icon.tintColor = AppColor.primary
icon.contentMode = .scaleAspectFit
icon.snp.makeConstraints { make in
make.size.equalTo(16)
}
row.addArrangedSubview(icon)
row.addArrangedSubview(makeLineSpacedLabel(detail, font: .systemFont(ofSize: 13), color: AppColor.textPrimary))
return row
}
private func makeInfoLabelRow(label: String, value: String, valueColor: UIColor = AppColor.textPrimary) -> UIView {
let row = UIStackView()
row.axis = .horizontal
row.alignment = .center
row.spacing = 12
let titleLabel = WildReportUI.label(label, font: .systemFont(ofSize: 13), color: AppColor.textSecondary)
let valueLabel = WildReportUI.label(value, font: .systemFont(ofSize: 14, weight: .semibold), color: valueColor, lines: 0)
valueLabel.textAlignment = .right
row.addArrangedSubview(titleLabel)
row.addArrangedSubview(valueLabel)
titleLabel.setContentHuggingPriority(.required, for: .horizontal)
valueLabel.setContentHuggingPriority(.defaultLow, for: .horizontal)
return row
}
private func makePhoneRow(label: String, phone: String?, displayPhone: String?) -> UIView {
let row = UIStackView()
row.axis = .horizontal
row.alignment = .center
row.spacing = 12
let titleLabel = WildReportUI.label(label, font: .systemFont(ofSize: 13), color: AppColor.textSecondary)
row.addArrangedSubview(titleLabel)
row.addArrangedSubview(UIView())
guard let displayPhone, !displayPhone.isEmpty else {
row.addArrangedSubview(WildReportUI.label("-", font: .systemFont(ofSize: 14, weight: .semibold), color: AppColor.textPrimary))
return row
}
let button = UIButton(type: .system)
button.titleLabel?.font = .systemFont(ofSize: 14, weight: .semibold)
button.setTitle(displayPhone, for: .normal)
button.setImage(UIImage(systemName: "phone.fill"), for: .normal)
button.tintColor = AppColor.primary
button.setTitleColor(AppColor.primary, for: .normal)
button.semanticContentAttribute = .forceRightToLeft
button.imageEdgeInsets = UIEdgeInsets(top: 0, left: 4, bottom: 0, right: -4)
if let phone {
button.addAction(UIAction { [weak self] _ in
self?.callPhone(phone)
}, for: .touchUpInside)
}
row.addArrangedSubview(button)
button.setContentHuggingPriority(.required, for: .horizontal)
return row
}
private func makeLineSpacedLabel(_ text: String, font: UIFont, color: UIColor) -> WildReportRiskInsetLabel {
let label = WildReportRiskInsetLabel()
label.font = font
label.textColor = color
label.numberOfLines = 0
let paragraph = NSMutableParagraphStyle()
paragraph.lineSpacing = 4
label.attributedText = NSAttributedString(
string: text,
attributes: [.paragraphStyle: paragraph, .font: font, .foregroundColor: color]
)
return label
}
private func isReportClue(_ clue: WildReportRadarActiveClue) -> Bool {
clue.type == .red || clue.type == .yellow
}
private func headerIconColor(for clue: WildReportRadarActiveClue) -> UIColor {
if isReportClue(clue), clue.processingStarted, !clue.completed {
return UIColor(hex: 0xF59E0B)
}
switch clue.type {
case .blue: return AppColor.primary
case .green: return AppColor.success
case .red: return AppColor.danger
case .yellow: return UIColor(hex: 0xF59E0B)
}
}
private func statusColor(for clue: WildReportRadarActiveClue) -> UIColor {
if clue.completed { return AppColor.success }
if clue.processingStarted, !clue.completed { return UIColor(hex: 0xF59E0B) }
switch clue.type {
case .blue: return AppColor.primary
case .green: return AppColor.success
case .red: return AppColor.danger
case .yellow: return UIColor(hex: 0xF59E0B)
}
}
private func timelineDotColor(_ status: String) -> UIColor {
switch status {
case "done": return AppColor.success
case "current": return UIColor(hex: 0xF59E0B)
default: return UIColor(hex: 0xD1D5DB)
}
}
private func callPhone(_ phone: String) {
guard phone.contains("*") == false,
let url = URL(string: "tel://\(phone)")
else { return }
UIApplication.shared.open(url)
}
private func addInfoRows(for clue: WildReportRadarActiveClue, to stack: UIStackView) {
if let name = clue.name {
stack.addArrangedSubview(WildReportInfoRowView(title: clue.type == .green ? "摄影师" : "点位", value: name))
}
if let store = clue.storeName {
stack.addArrangedSubview(WildReportInfoRowView(title: "门店", value: store))
}
if let distance = clue.distanceText ?? clue.distance {
stack.addArrangedSubview(WildReportInfoRowView(title: "距离", value: distance))
}
if let updatedAt = clue.updatedAt {
stack.addArrangedSubview(WildReportInfoRowView(title: "时间", value: updatedAt))
}
if let reporter = clue.reporterName {
stack.addArrangedSubview(WildReportInfoRowView(title: "举报人", value: reporter))
}
if let phone = clue.maskedReporterPhone ?? clue.reporterPhone {
stack.addArrangedSubview(WildReportInfoRowView(title: "联系电话", value: phone))
}
}
private func addDescriptionBlocks(for clue: WildReportRadarActiveClue, to stack: UIStackView) {
if let summary = clue.summary {
stack.addArrangedSubview(makeBodyLabel(summary))
}
if let detail = clue.detail, detail != clue.summary {
stack.addArrangedSubview(makeBodyLabel(detail))
}
for content in clue.reportContents.prefix(3) {
let time = content.time.isEmpty ? "" : "\(content.time) "
stack.addArrangedSubview(makeBodyLabel("\(time)\(content.content)"))
}
}
private func addMediaRow(for clue: WildReportRadarActiveClue, to stack: UIStackView) {
guard !clue.images.isEmpty else { return }
let scroll = UIScrollView()
scroll.showsHorizontalScrollIndicator = false
let row = UIStackView()
row.axis = .horizontal
row.spacing = 10
scroll.addSubview(row)
row.snp.makeConstraints { make in
make.edges.equalToSuperview()
make.height.equalTo(72)
}
for url in clue.images.prefix(9) {
let tile = WildReportRiskImageTile(url: url)
tile.onTap = { [weak self] in self?.presentImagePreview(url: url) }
row.addArrangedSubview(tile)
}
stack.addArrangedSubview(scroll)
scroll.snp.makeConstraints { make in
make.height.equalTo(72)
}
}
private func addProcessingBlock(for clue: WildReportRadarActiveClue, to stack: UIStackView) {
guard let record = clue.record else { return }
let box = UIView()
box.backgroundColor = UIColor(hex: 0xF8FAFF)
box.layer.cornerRadius = 12
box.layer.borderWidth = 1
box.layer.borderColor = AppColor.cardOutline.cgColor
let inner = UIStackView()
inner.axis = .vertical
inner.spacing = 8
box.addSubview(inner)
inner.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(12)
}
inner.addArrangedSubview(WildReportInfoRowView(title: "处理人", value: record.handlerName))
inner.addArrangedSubview(makeBodyLabel(record.processResult))
stack.addArrangedSubview(box)
}
private func makeButtonRow(for clue: WildReportRadarActiveClue) -> UIView {
let row = UIStackView()
row.axis = .horizontal
row.spacing = 10
row.distribution = .fillEqually
let navButton = WildReportUI.iconButton(
title: clue.type == .blue ? "刷新定位" : "导航到此",
imageName: clue.type == .blue ? "location.fill" : "map.fill",
style: .primary
)
navButton.addTarget(self, action: #selector(navigateTapped), for: .touchUpInside)
let shareButton = WildReportUI.iconButton(title: "分享", imageName: "square.and.arrow.up", style: .secondary)
shareButton.addTarget(self, action: #selector(shareTapped), for: .touchUpInside)
[navButton, shareButton].forEach { button in
button.snp.makeConstraints { make in make.height.equalTo(44) }
row.addArrangedSubview(button)
}
return row
}
private func makeInlineLoading(text: String) -> UIView {
let row = UIStackView()
row.axis = .horizontal
row.alignment = .center
row.spacing = 8
let indicator = UIActivityIndicatorView(style: .medium)
indicator.startAnimating()
row.addArrangedSubview(indicator)
row.addArrangedSubview(WildReportUI.label(text, font: .systemFont(ofSize: 13), color: AppColor.textSecondary))
return row
}
private func makeStateCard(icon: String, text: String) -> UIView {
let card = WildReportCardView(spacing: 12, inset: 18)
card.stack.alignment = .center
let image = UIImageView(image: UIImage(systemName: icon))
image.tintColor = AppColor.primary
image.contentMode = .scaleAspectFit
image.snp.makeConstraints { make in
make.size.equalTo(34)
}
let label = WildReportUI.label(text, font: .systemFont(ofSize: 15, weight: .semibold), color: AppColor.textSecondary, lines: 0)
label.textAlignment = .center
card.stack.addArrangedSubview(image)
card.stack.addArrangedSubview(label)
return card
}
private func makeHighlight(text: String, color: UIColor) -> UIView {
let label = WildReportUI.label(text, font: .systemFont(ofSize: 14, weight: .semibold), color: color, lines: 0)
label.backgroundColor = color.withAlphaComponent(0.08)
label.layer.cornerRadius = 10
label.clipsToBounds = true
label.textAlignment = .center
label.snp.makeConstraints { make in
make.height.greaterThanOrEqualTo(34)
}
return label
}
private func makeBodyLabel(_ text: String) -> UILabel {
WildReportUI.label(text, font: .systemFont(ofSize: 14), color: AppColor.textSecondary, lines: 0)
}
private func iconName(for kind: WildReportMapMarkerKind) -> String {
switch kind {
case .selfLocation: return "location.fill"
case .wildPhotographer: return "exclamationmark"
case .processingClue: return "clock.fill"
case .peerPhotographer: return "camera.fill"
}
}
private func presentImagePreview(url: String) {
presentImagePreview(imageURLs: [url], startIndex: 0)
}
@objc private func navigateTapped() {
guard let marker = viewModel.selectedMarker,
case .activeClue(let clue) = marker.detail
else { return }
if clue.type == .blue {
Task {
await viewModel.refreshLocation(
api: api,
locationProvider: locationProvider,
scenicId: scenicIdProvider(),
scenicName: scenicNameProvider()
)
}
return
}
guard let target = viewModel.navigateToSelectedMarker() else { return }
if let cachedOption = cachedNavigationOption(for: target) {
openNavigation(cachedOption, cacheSelection: false)
return
}
presentNavigationOptions(for: target)
}
@objc private func shareTapped() {
showLoading()
Task { [weak self] in
guard let self else { return }
let payload = await self.viewModel.shareSelectedClue(api: self.api)
await MainActor.run {
self.hideLoading()
guard let payload else { return }
WeChatShareService.shareMiniProgram(payload, previewImage: UIImage(named: "report_share_cover")) { [weak self] result in
self?.showShareResult(result)
}
}
}
}
private func presentNavigationOptions(for target: WildReportMapMarker) {
let options = availableNavigationOptions(for: target)
let alert = UIAlertController(title: "选择导航软件", message: target.title, preferredStyle: .actionSheet)
options.forEach { option in
alert.addAction(UIAlertAction(title: option.title, style: .default) { [weak self] _ in
self?.openNavigation(option, cacheSelection: true)
})
}
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
if let popover = alert.popoverPresentationController {
popover.sourceView = view
popover.sourceRect = CGRect(x: view.bounds.midX, y: view.bounds.maxY - 80, width: 1, height: 1)
}
present(alert, animated: true)
}
private func availableNavigationOptions(for target: WildReportMapMarker) -> [WildReportNavigationOption] {
WildReportNavigationApp.allCases.compactMap { app in
guard let option = navigationOption(for: app, target: target) else { return nil }
guard isNavigationOptionAvailable(option) else { return nil }
return option
}
}
private func cachedNavigationOption(for target: WildReportMapMarker) -> WildReportNavigationOption? {
guard let rawValue = UserDefaults.standard.string(forKey: Self.cachedNavigationAppKey),
let app = WildReportNavigationApp(rawValue: rawValue),
let option = navigationOption(for: app, target: target),
isNavigationOptionAvailable(option)
else {
UserDefaults.standard.removeObject(forKey: Self.cachedNavigationAppKey)
return nil
}
return option
}
private func navigationOption(for app: WildReportNavigationApp, target: WildReportMapMarker) -> WildReportNavigationOption? {
WildReportNavigationOption(
app: app,
title: app.title,
url: navigationURL(for: app, target: target),
requiresAvailabilityCheck: app.requiresAvailabilityCheck
)
}
private func isNavigationOptionAvailable(_ option: WildReportNavigationOption) -> Bool {
guard let url = option.url else { return false }
return !option.requiresAvailabilityCheck || UIApplication.shared.canOpenURL(url)
}
private func openNavigation(_ option: WildReportNavigationOption, cacheSelection: Bool) {
guard let url = option.url else { return }
UIApplication.shared.open(url) { [weak self] success in
guard success else {
UserDefaults.standard.removeObject(forKey: Self.cachedNavigationAppKey)
Task { @MainActor in
self?.showToast("无法打开\(option.title)")
}
return
}
guard cacheSelection else { return }
Task { @MainActor in
UserDefaults.standard.set(option.app.rawValue, forKey: Self.cachedNavigationAppKey)
}
}
}
private func navigationURL(for app: WildReportNavigationApp, target: WildReportMapMarker) -> URL? {
let destLat = target.coordinate.latitude
let destLon = target.coordinate.longitude
let name = encodedNavigationText(target.title)
switch app {
case .apple:
return URL(string: "http://maps.apple.com/?daddr=\(destLat),\(destLon)&dirflg=d")
case .amap:
return URL(string: "iosamap://path?sourceApplication=suixinkan&dlat=\(destLat)&dlon=\(destLon)&dname=\(name)&dev=0&t=0")
case .baidu:
return URL(string: "baidumap://map/direction?destination=latlng:\(destLat),\(destLon)|name:\(name)&mode=driving&coord_type=wgs84")
case .tencent:
return URL(string: "qqmap://map/routeplan?type=drive&tocoord=\(destLat),\(destLon)&to=\(name)&referer=suixinkan")
case .google:
return URL(string: "comgooglemaps://?daddr=\(destLat),\(destLon)&directionsmode=driving")
}
}
private func encodedNavigationText(_ text: String) -> String {
var allowed = CharacterSet.urlQueryAllowed
allowed.remove(charactersIn: "&=?")
return text.addingPercentEncoding(withAllowedCharacters: allowed) ?? text
}
@MainActor
private func showShareResult(_ 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 func zoomMap(by multiplier: CLLocationDegrees) {
let visibleRegion = mapView.region
let span = MKCoordinateSpan(
latitudeDelta: min(max(visibleRegion.span.latitudeDelta * multiplier, 0.001), 180),
longitudeDelta: min(max(visibleRegion.span.longitudeDelta * multiplier, 0.001), 180)
)
mapView.setRegion(MKCoordinateRegion(center: visibleRegion.center, span: span), animated: true)
}
private func focusCurrentLocation() {
viewModel.focusCurrentLocation()
}
}
///
private struct WildReportNavigationOption {
let app: WildReportNavigationApp
let title: String
let url: URL?
let requiresAvailabilityCheck: Bool
}
///
private enum WildReportNavigationApp: String, CaseIterable {
case apple
case amap
case baidu
case tencent
case google
var title: String {
switch self {
case .apple:
return "Apple 地图"
case .amap:
return "高德地图"
case .baidu:
return "百度地图"
case .tencent:
return "腾讯地图"
case .google:
return "Google Maps"
}
}
var requiresAvailabilityCheck: Bool {
self != .apple
}
}
extension WildReportRiskMapViewController: MKMapViewDelegate {
func mapView(_ mapView: MKMapView, viewFor annotation: MKAnnotation) -> MKAnnotationView? {
guard let annotation = annotation as? WildReportMapAnnotation else { return nil }
let identifier = "WildReportPulseMarkerAnnotationView"
let view = (mapView.dequeueReusableAnnotationView(withIdentifier: identifier) as? WildReportPulseMarkerAnnotationView)
?? WildReportPulseMarkerAnnotationView(annotation: annotation, reuseIdentifier: identifier)
view.annotation = annotation
view.apply(marker: annotation.marker, selected: annotation.marker.id == viewModel.selectedMarkerID)
return view
}
func mapView(_ mapView: MKMapView, didSelect annotation: MKAnnotation) {
guard let annotation = annotation as? WildReportMapAnnotation else { return }
Task {
await viewModel.selectMarker(
annotation.marker,
api: api,
scenicId: scenicIdProvider()
)
}
}
}
/// MKAnnotation
final class WildReportMapAnnotation: NSObject, MKAnnotation {
let marker: WildReportMapMarker
var coordinate: CLLocationCoordinate2D { marker.coordinate }
var title: String? { marker.title }
init(marker: WildReportMapMarker) {
self.marker = marker
super.init()
}
}
///
private final class WildReportPulseMarkerAnnotationView: MKAnnotationView {
private let titleBadge = WildReportMapMarkerTitleBadge()
private let markerContainer = UIView()
private let pulseRings = (0..<3).map { _ in UIView() }
private let selectedRing = UIView()
private let dot = UIView()
private var showsTitleBadge = false
override init(annotation: MKAnnotation?, reuseIdentifier: String?) {
super.init(annotation: annotation, reuseIdentifier: reuseIdentifier)
frame = CGRect(x: 0, y: 0, width: 72, height: 96)
centerOffset = .zero
canShowCallout = false
setupUI()
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func apply(marker: WildReportMapMarker, selected: Bool) {
let color = marker.kind.color
updateTitleBadgeVisibility(marker.kind == .selfLocation)
titleBadge.text = marker.title
pulseRings.forEach { ring in
ring.layer.borderColor = color.withAlphaComponent(0.32).cgColor
}
selectedRing.layer.borderColor = color.withAlphaComponent(0.45).cgColor
selectedRing.isHidden = !selected
dot.backgroundColor = color
startPulseIfNeeded()
}
private func setupUI() {
backgroundColor = .clear
titleBadge.isHidden = true
addSubview(titleBadge)
addSubview(markerContainer)
pulseRings.forEach(markerContainer.addSubview)
markerContainer.addSubview(selectedRing)
markerContainer.addSubview(dot)
pulseRings.forEach { view in
view.backgroundColor = .clear
view.layer.borderWidth = 2
view.layer.cornerRadius = 10
view.isUserInteractionEnabled = false
}
selectedRing.layer.cornerRadius = 14
selectedRing.layer.borderWidth = 2
dot.layer.cornerRadius = 7
dot.layer.borderWidth = 2
dot.layer.borderColor = UIColor.white.cgColor
titleBadge.snp.makeConstraints { make in
make.top.centerX.equalToSuperview()
make.height.equalTo(24)
}
markerContainer.snp.makeConstraints { make in
make.center.equalToSuperview()
make.size.equalTo(56)
}
pulseRings.forEach { ring in
ring.snp.makeConstraints { make in
make.center.equalToSuperview()
make.size.equalTo(20)
}
}
selectedRing.snp.makeConstraints { make in
make.center.equalToSuperview()
make.size.equalTo(28)
}
dot.snp.makeConstraints { make in
make.center.equalToSuperview()
make.size.equalTo(14)
}
}
private func updateTitleBadgeVisibility(_ isVisible: Bool) {
guard showsTitleBadge != isVisible else { return }
showsTitleBadge = isVisible
titleBadge.isHidden = !isVisible
markerContainer.snp.remakeConstraints { make in
if isVisible {
make.top.equalTo(titleBadge.snp.bottom).offset(4)
make.centerX.equalToSuperview()
} else {
make.center.equalToSuperview()
}
make.size.equalTo(56)
}
}
private func startPulseIfNeeded() {
guard pulseRings.first?.layer.animation(forKey: "riskMapPulseOpacity") == nil else { return }
let endSizes: [CGFloat] = [28, 40, 52]
for (index, ring) in pulseRings.enumerated() {
ring.layer.opacity = 0.72
let scale = CABasicAnimation(keyPath: "transform.scale")
scale.fromValue = 1
scale.toValue = endSizes[index] / 20
let opacity = CABasicAnimation(keyPath: "opacity")
opacity.fromValue = 0.72
opacity.toValue = 0
let animation = CAAnimationGroup()
animation.animations = [scale, opacity]
animation.timingFunction = CAMediaTimingFunction(name: .easeOut)
animation.duration = 2
animation.repeatCount = .infinity
animation.isRemovedOnCompletion = false
ring.layer.add(animation, forKey: "riskMapPulseOpacity")
}
}
}
///
private final class WildReportMapMarkerTitleBadge: UIView {
var text: String? {
get { label.text }
set { label.text = newValue }
}
private let label = UILabel()
override init(frame: CGRect) {
super.init(frame: frame)
setupUI()
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private func setupUI() {
backgroundColor = .white
layer.cornerRadius = 12
layer.shadowColor = UIColor.black.cgColor
layer.shadowOpacity = 0.08
layer.shadowRadius = 4
layer.shadowOffset = CGSize(width: 0, height: 2)
label.font = .systemFont(ofSize: 11, weight: .semibold)
label.textColor = AppColor.textPrimary
addSubview(label)
label.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(UIEdgeInsets(top: 4, left: 8, bottom: 4, right: 8))
}
}
}
///
private final class WildReportRiskLegendView: UIView {
init(kind: WildReportMapMarkerKind) {
super.init(frame: .zero)
let row = UIStackView()
row.axis = .horizontal
row.alignment = .center
row.spacing = 5
let dot = UIView()
dot.backgroundColor = kind.color
dot.layer.cornerRadius = 4
dot.snp.makeConstraints { make in
make.size.equalTo(8)
}
let label = WildReportUI.label(kind.title, font: .systemFont(ofSize: 11, weight: .medium), color: AppColor.textSecondary, lines: 1)
label.adjustsFontSizeToFitWidth = true
label.minimumScaleFactor = 0.82
row.addArrangedSubview(dot)
row.addArrangedSubview(label)
addSubview(row)
row.snp.makeConstraints { make in
make.center.equalToSuperview()
make.leading.greaterThanOrEqualToSuperview()
make.trailing.lessThanOrEqualToSuperview()
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
///
private final class WildReportRiskStatusBadge: UILabel {
private let contentInsets = UIEdgeInsets(top: 0, left: 10, bottom: 0, right: 10)
init(text: String, color: UIColor) {
super.init(frame: .zero)
self.text = text
textColor = color
font = .systemFont(ofSize: 12, weight: .semibold)
textAlignment = .center
backgroundColor = color.withAlphaComponent(0.12)
layer.cornerRadius = 12
clipsToBounds = true
numberOfLines = 1
adjustsFontSizeToFitWidth = true
minimumScaleFactor = 0.8
setContentHuggingPriority(.required, for: .horizontal)
setContentCompressionResistancePriority(.required, for: .horizontal)
snp.makeConstraints { make in
make.height.equalTo(24)
make.width.greaterThanOrEqualTo(52)
}
}
override var intrinsicContentSize: CGSize {
let size = super.intrinsicContentSize
return CGSize(
width: size.width + contentInsets.left + contentInsets.right,
height: max(24, size.height + contentInsets.top + contentInsets.bottom)
)
}
override func drawText(in rect: CGRect) {
super.drawText(in: rect.inset(by: contentInsets))
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
///
private final class WildReportRiskInsetLabel: UILabel {
var textInsets = UIEdgeInsets.zero {
didSet { invalidateIntrinsicContentSize() }
}
override func drawText(in rect: CGRect) {
super.drawText(in: rect.inset(by: textInsets))
}
override var intrinsicContentSize: CGSize {
let size = super.intrinsicContentSize
return CGSize(
width: size.width + textInsets.left + textInsets.right,
height: size.height + textInsets.top + textInsets.bottom
)
}
}
///
private final class WildReportRiskImageTile: UIView {
private let imageView = UIImageView()
var onTap: (() -> Void)?
init(url: String, size: CGFloat = 72) {
super.init(frame: .zero)
backgroundColor = AppColor.pageBackgroundSoft
layer.cornerRadius = 10
layer.masksToBounds = true
imageView.contentMode = .scaleAspectFill
addSubview(imageView)
imageView.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
if let imageURL = URL(string: url) {
imageView.kf.setImage(with: imageURL, placeholder: UIImage(systemName: "photo.fill"))
} else {
imageView.image = UIImage(systemName: "photo.fill")
imageView.tintColor = AppColor.textTertiary
}
addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(tapped)))
snp.makeConstraints { make in
make.size.equalTo(size)
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
@objc private func tapped() {
onTap?()
}
}