完善风险地图定位与标记详情

This commit is contained in:
2026-07-09 12:47:47 +08:00
parent 42aca73588
commit 2970f1514b
5 changed files with 697 additions and 113 deletions

View File

@ -87,9 +87,9 @@ final class WildPhotographerReportAPI: WildPhotographerReportServing {
func markerDetail(_ request: WildReportMarkerDetailRequest) async throws -> WildReportMarkerDetailResponse {
try await client.send(
APIRequest(
method: .post,
method: .get,
path: "/api/yf-handset-app/photog/report/marker-detail",
body: request
queryItems: request.queryItems
)
)
}
@ -148,7 +148,7 @@ struct WildReportRiskMapRequest: Encodable, Equatable {
}
}
///
///
struct WildReportMarkerDetailRequest: Encodable, Equatable {
let scenicId: Int
let markerType: String
@ -156,6 +156,21 @@ struct WildReportMarkerDetailRequest: Encodable, Equatable {
let latitude: Double?
let longitude: Double?
var queryItems: [URLQueryItem] {
var items = [
URLQueryItem(name: "scenic_id", value: String(scenicId)),
URLQueryItem(name: "marker_type", value: markerType),
URLQueryItem(name: "id", value: String(id))
]
if let latitude {
items.append(URLQueryItem(name: "latitude", value: String(latitude)))
}
if let longitude {
items.append(URLQueryItem(name: "longitude", value: String(longitude)))
}
return items
}
enum CodingKeys: String, CodingKey {
case scenicId = "scenic_id"
case markerType = "marker_type"

View File

@ -1185,7 +1185,9 @@ struct WildReportRiskMapMarkerDTO: Decodable, Equatable, Hashable {
struct WildReportMarkerDetailResponse: Decodable, Equatable, Hashable {
let markerType: String
let id: Int
let clueId: String
let title: String
let statusTag: String
let tag: String
let name: String
let avatar: String
@ -1210,7 +1212,9 @@ struct WildReportMarkerDetailResponse: Decodable, Equatable, Hashable {
enum CodingKeys: String, CodingKey {
case markerType = "marker_type"
case id
case clueId = "clue_id"
case title
case statusTag = "status_tag"
case tag
case name
case avatar
@ -1237,7 +1241,9 @@ struct WildReportMarkerDetailResponse: Decodable, Equatable, Hashable {
init(
markerType: String,
id: Int,
clueId: String = "",
title: String = "",
statusTag: String = "",
tag: String = "",
name: String = "",
avatar: String = "",
@ -1261,7 +1267,9 @@ struct WildReportMarkerDetailResponse: Decodable, Equatable, Hashable {
) {
self.markerType = markerType
self.id = id
self.clueId = clueId
self.title = title
self.statusTag = statusTag
self.tag = tag
self.name = name
self.avatar = avatar
@ -1288,7 +1296,9 @@ struct WildReportMarkerDetailResponse: Decodable, Equatable, Hashable {
let container = try decoder.container(keyedBy: CodingKeys.self)
markerType = try container.decodeIfPresent(String.self, forKey: .markerType) ?? ""
id = try container.decodeIfPresent(Int.self, forKey: .id) ?? 0
clueId = try container.decodeIfPresent(String.self, forKey: .clueId) ?? ""
title = try container.decodeIfPresent(String.self, forKey: .title) ?? ""
statusTag = try container.decodeIfPresent(String.self, forKey: .statusTag) ?? ""
tag = try container.decodeIfPresent(String.self, forKey: .tag) ?? ""
name = try container.decodeIfPresent(String.self, forKey: .name) ?? ""
avatar = try container.decodeIfPresent(String.self, forKey: .avatar) ?? ""

View File

@ -1018,7 +1018,6 @@ final class WildReportRiskMapViewModel {
private(set) var markers: [WildReportMapMarker]
private(set) var scenicAreaName: String
private(set) var selectedMarkerID: String?
private(set) var toastMessage: String?
private(set) var isLoading = false
private(set) var isLoadingDetail = false
private(set) var errorMessage: String?
@ -1027,7 +1026,6 @@ final class WildReportRiskMapViewModel {
private(set) var currentCoordinate: CLLocationCoordinate2D?
var onStateChange: (() -> Void)?
var onShowMessage: ((String) -> Void)?
init() {
self.markers = []
@ -1090,7 +1088,7 @@ final class WildReportRiskMapViewModel {
mergeDetail(detail, intoMarkerID: marker.id)
loadedDetailMarkerIDs.insert(marker.id)
} catch {
showToast(error.localizedDescription)
errorMessage = error.localizedDescription
}
isLoadingDetail = false
notifyStateChange()
@ -1114,6 +1112,19 @@ final class WildReportRiskMapViewModel {
return marker
}
///
@discardableResult
func focusCurrentLocation() -> Bool {
guard let marker = markers.first(where: { $0.kind == .selfLocation }) else { return false }
selectedMarkerID = marker.id
region = MKCoordinateRegion(
center: marker.coordinate,
span: MKCoordinateSpan(latitudeDelta: 0.018, longitudeDelta: 0.018)
)
notifyStateChange()
return true
}
///
func refreshLocation(
api: any WildPhotographerReportServing,
@ -1132,21 +1143,12 @@ final class WildReportRiskMapViewModel {
/// 线
func shareSelectedClue() {
guard let clue = selectedClue else { return }
let title = clue.type == .red ? "线索 ID\(clue.id)" : clue.displayTitle
showToast("已生成\(title)的分享信息,可发送给他人查看。")
_ = selectedClue
}
///
func showImagePreview(_ url: String) {
_ = url
showToast("暂不支持预览图片")
}
private func showToast(_ message: String) {
toastMessage = message
onShowMessage?(message)
notifyStateChange()
}
private func loadRiskMap(
@ -1159,7 +1161,7 @@ final class WildReportRiskMapViewModel {
let resolvedScenicId = scenicId ?? AppStore.shared.currentScenicId
guard resolvedScenicId >= 1 else {
errorMessage = "请先选择景区后再查看风险地图"
showToast(errorMessage ?? "")
notifyStateChange()
return
}
@ -1170,8 +1172,7 @@ final class WildReportRiskMapViewModel {
scenicId: resolvedScenicId,
scenicName: scenicName,
coordinate: coordinate,
selectCurrentLocation: selectCurrentLocation,
showsLocationRefreshToast: coordinate != nil
selectCurrentLocation: selectCurrentLocation
)
}
@ -1184,8 +1185,7 @@ final class WildReportRiskMapViewModel {
scenicId: Int,
scenicName: String?,
coordinate: CLLocationCoordinate2D?,
selectCurrentLocation: Bool,
showsLocationRefreshToast: Bool
selectCurrentLocation: Bool
) async {
isLoading = true
errorMessage = nil
@ -1210,12 +1210,8 @@ final class WildReportRiskMapViewModel {
} else if selectedMarker == nil {
selectedMarkerID = markers.first?.id
}
if showsLocationRefreshToast {
showToast("已更新附近风险点位")
}
} catch {
errorMessage = error.localizedDescription
showToast(error.localizedDescription)
}
isLoading = false
@ -1354,7 +1350,8 @@ final class WildReportRiskMapViewModel {
else { return }
let markerType = WildReportRiskMarkerType(rawValue: detail.markerType.nonEmpty ?? oldClue.type.rawValue)
let statusText = detail.handleStatusText.nonEmpty
let statusText = detail.statusTag.nonEmpty
?? detail.handleStatusText.nonEmpty
?? detail.handleStatus?.displayText
?? oldClue.statusText
let reportContents = !detail.reportContents.isEmpty
@ -1362,7 +1359,7 @@ final class WildReportRiskMapViewModel {
: (detail.desc.nonEmpty.map { [WildReportRadarReportContent(time: detail.createdAt, content: $0)] } ?? oldClue.reportContents)
let imageURLs = detail.evidences.filter(\.isImage).map(\.fileURL)
let clue = WildReportRadarActiveClue(
id: String(detail.id > 0 ? detail.id : Int(oldClue.id) ?? 0),
id: detail.clueId.nonEmpty ?? String(detail.id > 0 ? detail.id : Int(oldClue.id) ?? 0),
type: markerType.radarType,
displayTitle: detail.title.nonEmpty ?? oldClue.displayTitle,
statusText: statusText,

View File

@ -21,6 +21,7 @@ final class WildReportRiskMapViewController: BaseViewController {
private let mapView = MKMapView()
private let scenicCapsule = UIView()
private let scenicNameLabel = UILabel()
private let locateButton = UIButton(type: .system)
private let legendBar = UIStackView()
private let detailContainerView = UIView()
private let scrollView = UIScrollView()
@ -87,6 +88,7 @@ final class WildReportRiskMapViewController: BaseViewController {
mapContainerView.addSubview(mapView)
configureScenicCapsule()
configureLocateButton()
configureLegendBar()
configureDetailArea()
refreshContent(animated: false)
@ -106,6 +108,10 @@ final class WildReportRiskMapViewController: BaseViewController {
make.top.leading.equalToSuperview().inset(12)
make.height.equalTo(34)
}
locateButton.snp.makeConstraints { make in
make.top.trailing.equalToSuperview().inset(12)
make.size.equalTo(44)
}
detailContainerView.snp.makeConstraints { make in
make.height.greaterThanOrEqualTo(180)
}
@ -117,9 +123,6 @@ final class WildReportRiskMapViewController: BaseViewController {
self?.refreshContent(animated: true)
}
}
viewModel.onShowMessage = { [weak self] message in
Task { @MainActor in self?.showToast(message) }
}
}
private func configureScenicCapsule() {
@ -151,6 +154,20 @@ final class WildReportRiskMapViewController: BaseViewController {
}
}
private func configureLocateButton() {
locateButton.backgroundColor = UIColor.white.withAlphaComponent(0.96)
locateButton.tintColor = AppColor.primary
locateButton.setImage(UIImage(systemName: "location.fill"), for: .normal)
locateButton.layer.cornerRadius = 22
locateButton.layer.shadowColor = UIColor.black.cgColor
locateButton.layer.shadowOpacity = 0.1
locateButton.layer.shadowRadius = 8
locateButton.layer.shadowOffset = CGSize(width: 0, height: 4)
locateButton.accessibilityLabel = "回到我的位置"
locateButton.addTarget(self, action: #selector(locateButtonTapped), for: .touchUpInside)
mapContainerView.addSubview(locateButton)
}
private func configureLegendBar() {
legendBar.axis = .horizontal
legendBar.alignment = .center
@ -182,8 +199,8 @@ final class WildReportRiskMapViewController: BaseViewController {
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)
make.edges.equalToSuperview().inset(16)
make.width.equalTo(scrollView.snp.width).offset(-32)
}
}
@ -191,6 +208,7 @@ final class WildReportRiskMapViewController: BaseViewController {
private func refreshContent(animated: Bool) {
setRiskMapLoadingVisible(viewModel.isLoading)
scenicNameLabel.text = viewModel.scenicAreaName
updateLocateButtonState()
syncMapAnnotations()
mapView.setRegion(viewModel.region, animated: animated)
updateAnnotationSelection()
@ -205,6 +223,13 @@ final class WildReportRiskMapViewController: BaseViewController {
isVisible ? showLoading() : hideLoading()
}
@MainActor
private func updateLocateButtonState() {
let hasCurrentLocation = viewModel.markers.contains { $0.kind == .selfLocation }
locateButton.isEnabled = hasCurrentLocation
locateButton.alpha = hasCurrentLocation ? 1 : 0.46
}
private func syncMapAnnotations() {
let oldAnnotations = mapView.annotations.compactMap { $0 as? WildReportMapAnnotation }
mapView.removeAnnotations(oldAnnotations)
@ -258,39 +283,63 @@ final class WildReportRiskMapViewController: BaseViewController {
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))
let card = makeRadarResultCard(clue: clue)
contentStack.addArrangedSubview(card)
}
private func makeHeader(marker: WildReportMapMarker, clue: WildReportRadarActiveClue) -> UIView {
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 row = UIStackView()
row.axis = .horizontal
row.alignment = .center
row.alignment = .top
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
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(22)
make.size.equalTo(20)
}
iconWrap.snp.makeConstraints { make in
make.size.equalTo(44)
@ -298,14 +347,34 @@ final class WildReportRiskMapViewController: BaseViewController {
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)
textStack.spacing = 6
if isReportClue(clue) {
let title = WildReportUI.label("线索 ID\(clue.id)", font: .systemFont(ofSize: 16, weight: .bold), color: AppColor.textPrimary, lines: 2)
textStack.addArrangedSubview(title)
let metaRow = UIStackView()
metaRow.axis = .horizontal
metaRow.spacing = 12
if let distance = clue.distanceText ?? clue.distance {
metaRow.addArrangedSubview(WildReportUI.label("距离:距离您\(distance)", font: .systemFont(ofSize: 12), color: AppColor.textSecondary))
}
if let updatedAt = clue.updatedAt {
metaRow.addArrangedSubview(WildReportUI.label("时间:\(updatedAt)", font: .systemFont(ofSize: 12), color: AppColor.textSecondary))
}
if !metaRow.arrangedSubviews.isEmpty {
textStack.addArrangedSubview(metaRow)
}
} 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: marker.kind.color)
let badge = WildReportRiskStatusBadge(text: clue.statusText, color: statusColor(for: clue))
row.addArrangedSubview(iconWrap)
row.addArrangedSubview(textStack)
row.addArrangedSubview(badge)
@ -314,6 +383,341 @@ final class WildReportRiskMapViewController: BaseViewController {
return row
}
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.imageEdgeInsets = UIEdgeInsets(top: 0, left: -3, bottom: 0, right: 3)
button.contentEdgeInsets = UIEdgeInsets(top: 0, left: 12, bottom: 0, right: 12)
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))
@ -494,6 +898,10 @@ final class WildReportRiskMapViewController: BaseViewController {
@objc private func shareTapped() {
viewModel.shareSelectedClue()
}
@objc private func locateButtonTapped() {
viewModel.focusCurrentLocation()
}
}
extension WildReportRiskMapViewController: MKMapViewDelegate {
@ -533,16 +941,17 @@ final class WildReportMapAnnotation: NSObject, MKAnnotation {
///
private final class WildReportPulseMarkerAnnotationView: MKAnnotationView {
private let pulseOuter = UIView()
private let pulseInner = UIView()
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 let label = UILabel()
private var showsTitleBadge = false
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)
frame = CGRect(x: 0, y: 0, width: 72, height: 96)
centerOffset = .zero
canShowCallout = false
setupUI()
}
@ -554,41 +963,51 @@ private final class WildReportPulseMarkerAnnotationView: MKAnnotationView {
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
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
label.isHidden = marker.kind != .selfLocation
label.text = ""
startPulseIfNeeded()
}
private func setupUI() {
backgroundColor = .clear
[pulseOuter, pulseInner, selectedRing, dot, label].forEach(addSubview)
[pulseOuter, pulseInner, selectedRing].forEach { view in
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
}
pulseOuter.layer.cornerRadius = 20
pulseInner.layer.cornerRadius = 14
selectedRing.layer.cornerRadius = 14
selectedRing.layer.borderWidth = 2
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)
titleBadge.snp.makeConstraints { make in
make.top.centerX.equalToSuperview()
make.height.equalTo(24)
}
pulseInner.snp.makeConstraints { make in
markerContainer.snp.makeConstraints { make in
make.center.equalToSuperview()
make.size.equalTo(28)
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()
@ -598,21 +1017,81 @@ private final class WildReportPulseMarkerAnnotationView: MKAnnotationView {
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 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))
}
}
}
@ -676,12 +1155,31 @@ private final class WildReportRiskStatusBadge: UILabel {
}
}
///
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) {
init(url: String, size: CGFloat = 72) {
super.init(frame: .zero)
backgroundColor = AppColor.pageBackgroundSoft
layer.cornerRadius = 10
@ -699,7 +1197,7 @@ private final class WildReportRiskImageTile: UIView {
}
addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(tapped)))
snp.makeConstraints { make in
make.size.equalTo(72)
make.size.equalTo(size)
}
}