757 lines
28 KiB
Swift
757 lines
28 KiB
Swift
//
|
||
// WildReportRiskMapViewController.swift
|
||
// suixinkan
|
||
//
|
||
|
||
import Kingfisher
|
||
import MapKit
|
||
import SnapKit
|
||
import UIKit
|
||
|
||
/// 附近风险地图页面,展示当前位置、疑似打野、处理中线索和内部摄影师标记。
|
||
final class WildReportRiskMapViewController: BaseViewController {
|
||
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 legendBar = UIStackView()
|
||
private let detailContainerView = UIView()
|
||
private let scrollView = UIScrollView()
|
||
private let contentStack = UIStackView()
|
||
private let loadingIndicator = UIActivityIndicatorView(style: .medium)
|
||
private var annotationMap: [String: WildReportMapMarker] = [:]
|
||
|
||
init(
|
||
record: WildReportRecord,
|
||
riskPoints: [WildReportRiskPoint],
|
||
api: any WildPhotographerReportServing = NetworkServices.shared.wildPhotographerReportAPI,
|
||
locationProvider: any LocationProviding = LocationProvider.shared,
|
||
scenicIdProvider: @escaping () -> Int = { AppStore.shared.currentScenicId },
|
||
scenicNameProvider: @escaping () -> String = { AppStore.shared.currentScenicName }
|
||
) {
|
||
self.viewModel = WildReportRiskMapViewModel(record: record, riskPoints: riskPoints)
|
||
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 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()
|
||
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)
|
||
}
|
||
detailContainerView.snp.makeConstraints { make in
|
||
make.height.greaterThanOrEqualTo(180)
|
||
}
|
||
}
|
||
|
||
override func bindActions() {
|
||
viewModel.onStateChange = { [weak self] in
|
||
Task { @MainActor in
|
||
self?.refreshContent(animated: true)
|
||
}
|
||
}
|
||
viewModel.onShowMessage = { [weak self] message in
|
||
Task { @MainActor in self?.showToast(message) }
|
||
}
|
||
}
|
||
|
||
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 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(UIEdgeInsets(top: 14, left: 18, bottom: 18, right: 18))
|
||
make.width.equalTo(scrollView.snp.width).offset(-36)
|
||
}
|
||
}
|
||
|
||
@MainActor
|
||
private func refreshContent(animated: Bool) {
|
||
scenicNameLabel.text = viewModel.scenicAreaName
|
||
syncMapAnnotations()
|
||
mapView.setRegion(viewModel.region, animated: animated)
|
||
updateAnnotationSelection()
|
||
rebuildLegend()
|
||
rebuildPanel()
|
||
}
|
||
|
||
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 viewModel.isLoading {
|
||
contentStack.addArrangedSubview(makeLoadingView(text: "正在加载风险点位..."))
|
||
}
|
||
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 = WildReportCardView(spacing: 16, inset: 16)
|
||
card.layer.cornerRadius = 16
|
||
card.stack.addArrangedSubview(makeHeader(marker: marker, clue: clue))
|
||
if viewModel.isLoadingDetail {
|
||
card.stack.addArrangedSubview(makeInlineLoading(text: "正在加载标记详情..."))
|
||
}
|
||
if let nearest = clue.nearestAbnormalText {
|
||
card.stack.addArrangedSubview(makeHighlight(text: nearest, color: AppColor.danger))
|
||
}
|
||
addInfoRows(for: clue, to: card.stack)
|
||
addDescriptionBlocks(for: clue, to: card.stack)
|
||
addMediaRow(for: clue, to: card.stack)
|
||
addProcessingBlock(for: clue, to: card.stack)
|
||
card.stack.addArrangedSubview(makeButtonRow(for: clue))
|
||
contentStack.addArrangedSubview(card)
|
||
}
|
||
|
||
private func makeHeader(marker: WildReportMapMarker, clue: WildReportRadarActiveClue) -> UIView {
|
||
let row = UIStackView()
|
||
row.axis = .horizontal
|
||
row.alignment = .center
|
||
row.spacing = 12
|
||
|
||
let iconWrap = UIView()
|
||
iconWrap.backgroundColor = marker.kind.color.withAlphaComponent(0.14)
|
||
iconWrap.layer.cornerRadius = 22
|
||
let icon = UIImageView(image: UIImage(systemName: iconName(for: marker.kind)))
|
||
icon.tintColor = marker.kind.color
|
||
icon.contentMode = .scaleAspectFit
|
||
iconWrap.addSubview(icon)
|
||
icon.snp.makeConstraints { make in
|
||
make.center.equalToSuperview()
|
||
make.size.equalTo(22)
|
||
}
|
||
iconWrap.snp.makeConstraints { make in
|
||
make.size.equalTo(44)
|
||
}
|
||
|
||
let textStack = UIStackView()
|
||
textStack.axis = .vertical
|
||
textStack.spacing = 4
|
||
let title = WildReportUI.label(clue.displayTitle, font: .systemFont(ofSize: 17, weight: .bold), color: AppColor.textPrimary, lines: 2)
|
||
let metaText = [clue.distanceText ?? clue.distance, clue.updatedAt].compactMap { $0 }.joined(separator: " · ")
|
||
let meta = WildReportUI.label(metaText.isEmpty ? marker.title : metaText, font: .systemFont(ofSize: 12), color: AppColor.textSecondary, lines: 1)
|
||
textStack.addArrangedSubview(title)
|
||
textStack.addArrangedSubview(meta)
|
||
|
||
let badge = WildReportRiskStatusBadge(text: clue.statusText, color: marker.kind.color)
|
||
row.addArrangedSubview(iconWrap)
|
||
row.addArrangedSubview(textStack)
|
||
row.addArrangedSubview(badge)
|
||
textStack.setContentHuggingPriority(.defaultLow, for: .horizontal)
|
||
badge.setContentHuggingPriority(.required, for: .horizontal)
|
||
return row
|
||
}
|
||
|
||
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 makeLoadingView(text: String) -> UIView {
|
||
let card = WildReportCardView(spacing: 12, inset: 16)
|
||
card.stack.alignment = .center
|
||
loadingIndicator.startAnimating()
|
||
card.stack.addArrangedSubview(loadingIndicator)
|
||
card.stack.addArrangedSubview(WildReportUI.label(text, font: .systemFont(ofSize: 14), color: AppColor.textSecondary))
|
||
return card
|
||
}
|
||
|
||
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) {
|
||
present(WildReportRiskImagePreviewController(url: url), animated: true)
|
||
}
|
||
|
||
@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 }
|
||
let item = MKMapItem(placemark: MKPlacemark(coordinate: target.coordinate))
|
||
item.name = target.title
|
||
item.openInMaps(launchOptions: [
|
||
MKLaunchOptionsDirectionsModeKey: MKLaunchOptionsDirectionsModeDriving
|
||
])
|
||
}
|
||
|
||
@objc private func shareTapped() {
|
||
viewModel.shareSelectedClue()
|
||
}
|
||
}
|
||
|
||
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 pulseOuter = UIView()
|
||
private let pulseInner = UIView()
|
||
private let selectedRing = UIView()
|
||
private let dot = UIView()
|
||
private let label = UILabel()
|
||
|
||
override init(annotation: MKAnnotation?, reuseIdentifier: String?) {
|
||
super.init(annotation: annotation, reuseIdentifier: reuseIdentifier)
|
||
frame = CGRect(x: 0, y: 0, width: 56, height: 56)
|
||
centerOffset = CGPoint(x: 0, y: -12)
|
||
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
|
||
pulseOuter.layer.borderColor = color.withAlphaComponent(0.18).cgColor
|
||
pulseInner.layer.borderColor = color.withAlphaComponent(0.28).cgColor
|
||
selectedRing.layer.borderColor = color.cgColor
|
||
selectedRing.isHidden = !selected
|
||
dot.backgroundColor = color
|
||
label.isHidden = marker.kind != .selfLocation
|
||
label.text = "我"
|
||
}
|
||
|
||
private func setupUI() {
|
||
backgroundColor = .clear
|
||
[pulseOuter, pulseInner, selectedRing, dot, label].forEach(addSubview)
|
||
[pulseOuter, pulseInner, selectedRing].forEach { view in
|
||
view.backgroundColor = .clear
|
||
view.layer.borderWidth = 2
|
||
view.isUserInteractionEnabled = false
|
||
}
|
||
pulseOuter.layer.cornerRadius = 20
|
||
pulseInner.layer.cornerRadius = 14
|
||
selectedRing.layer.cornerRadius = 14
|
||
dot.layer.cornerRadius = 7
|
||
dot.layer.borderWidth = 2
|
||
dot.layer.borderColor = UIColor.white.cgColor
|
||
|
||
label.font = .systemFont(ofSize: 10, weight: .bold)
|
||
label.textColor = .white
|
||
label.textAlignment = .center
|
||
|
||
pulseOuter.snp.makeConstraints { make in
|
||
make.center.equalToSuperview()
|
||
make.size.equalTo(40)
|
||
}
|
||
pulseInner.snp.makeConstraints { make in
|
||
make.center.equalToSuperview()
|
||
make.size.equalTo(28)
|
||
}
|
||
selectedRing.snp.makeConstraints { make in
|
||
make.center.equalToSuperview()
|
||
make.size.equalTo(28)
|
||
}
|
||
dot.snp.makeConstraints { make in
|
||
make.center.equalToSuperview()
|
||
make.size.equalTo(14)
|
||
}
|
||
label.snp.makeConstraints { make in
|
||
make.center.equalTo(dot)
|
||
make.size.equalTo(14)
|
||
}
|
||
startPulse()
|
||
}
|
||
|
||
private func startPulse() {
|
||
let animation = CABasicAnimation(keyPath: "opacity")
|
||
animation.fromValue = 0.85
|
||
animation.toValue = 0.2
|
||
animation.duration = 1.2
|
||
animation.autoreverses = true
|
||
animation.repeatCount = .infinity
|
||
pulseOuter.layer.add(animation, forKey: "opacityPulse")
|
||
}
|
||
}
|
||
|
||
/// 风险地图图例项视图。
|
||
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 {
|
||
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
|
||
snp.makeConstraints { make in
|
||
make.height.equalTo(24)
|
||
make.width.greaterThanOrEqualTo(64)
|
||
make.width.lessThanOrEqualTo(96)
|
||
}
|
||
}
|
||
|
||
@available(*, unavailable)
|
||
required init?(coder: NSCoder) {
|
||
fatalError("init(coder:) has not been implemented")
|
||
}
|
||
}
|
||
|
||
/// 风险地图举报图片缩略图。
|
||
private final class WildReportRiskImageTile: UIView {
|
||
private let imageView = UIImageView()
|
||
var onTap: (() -> Void)?
|
||
|
||
init(url: String) {
|
||
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(72)
|
||
}
|
||
}
|
||
|
||
@available(*, unavailable)
|
||
required init?(coder: NSCoder) {
|
||
fatalError("init(coder:) has not been implemented")
|
||
}
|
||
|
||
@objc private func tapped() {
|
||
onTap?()
|
||
}
|
||
}
|
||
|
||
/// 风险地图图片预览页面。
|
||
private final class WildReportRiskImagePreviewController: UIViewController {
|
||
private let url: String
|
||
private let imageView = UIImageView()
|
||
|
||
init(url: String) {
|
||
self.url = url
|
||
super.init(nibName: nil, bundle: nil)
|
||
modalPresentationStyle = .fullScreen
|
||
}
|
||
|
||
@available(*, unavailable)
|
||
required init?(coder: NSCoder) {
|
||
fatalError("init(coder:) has not been implemented")
|
||
}
|
||
|
||
override func viewDidLoad() {
|
||
super.viewDidLoad()
|
||
view.backgroundColor = .black
|
||
imageView.contentMode = .scaleAspectFit
|
||
view.addSubview(imageView)
|
||
imageView.snp.makeConstraints { make in
|
||
make.edges.equalTo(view.safeAreaLayoutGuide)
|
||
}
|
||
if let imageURL = URL(string: url) {
|
||
imageView.kf.setImage(with: imageURL)
|
||
}
|
||
let closeButton = UIButton(type: .system)
|
||
closeButton.setImage(UIImage(systemName: "xmark.circle.fill"), for: .normal)
|
||
closeButton.tintColor = .white
|
||
closeButton.addTarget(self, action: #selector(closeTapped), for: .touchUpInside)
|
||
view.addSubview(closeButton)
|
||
closeButton.snp.makeConstraints { make in
|
||
make.top.trailing.equalTo(view.safeAreaLayoutGuide).inset(18)
|
||
make.size.equalTo(36)
|
||
}
|
||
}
|
||
|
||
@objc private func closeTapped() {
|
||
dismiss(animated: true)
|
||
}
|
||
}
|