对齐 Android 无权限强制弹窗、角色下拉与申请页交互,补充 UIButton configuration 适配,并增强景区排队 TTS 与相关单元测试。 Co-authored-by: Cursor <cursoragent@cursor.com>
1419 lines
53 KiB
Swift
1419 lines
53 KiB
Swift
//
|
||
// 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?()
|
||
}
|
||
}
|