feat: connect wild report risk map

This commit is contained in:
2026-07-08 17:52:29 +08:00
parent fd5dc2bbe6
commit 939295d74a
6 changed files with 1829 additions and 135 deletions

View File

@ -14,6 +14,10 @@ protocol WildPhotographerReportServing {
func submitReport(_ request: WildReportSubmitRequest) async throws
///
func supplementReport(_ request: WildReportSupplementRequest) async throws
///
func riskMap(_ request: WildReportRiskMapRequest) async throws -> WildReportRiskMapResponse
///
func markerDetail(_ request: WildReportMarkerDetailRequest) async throws -> WildReportMarkerDetailResponse
///
func reportList(_ request: WildReportListRequest) async throws -> WildReportListResponse
///
@ -59,6 +63,28 @@ final class WildPhotographerReportAPI: WildPhotographerReportServing {
)
}
///
func riskMap(_ request: WildReportRiskMapRequest) async throws -> WildReportRiskMapResponse {
try await client.send(
APIRequest(
method: .post,
path: "/api/yf-handset-app/photog/report/risk-map",
body: request
)
)
}
///
func markerDetail(_ request: WildReportMarkerDetailRequest) async throws -> WildReportMarkerDetailResponse {
try await client.send(
APIRequest(
method: .post,
path: "/api/yf-handset-app/photog/report/marker-detail",
body: request
)
)
}
///
func reportList(_ request: WildReportListRequest) async throws -> WildReportListResponse {
try await client.send(
@ -89,6 +115,36 @@ struct WildReportSupplementRequest: Encodable, Equatable {
let evidences: [WildReportEvidenceRequest]?
}
///
struct WildReportRiskMapRequest: Encodable, Equatable {
let scenicId: Int
let latitude: Double?
let longitude: Double?
enum CodingKeys: String, CodingKey {
case scenicId = "scenic_id"
case latitude
case longitude
}
}
///
struct WildReportMarkerDetailRequest: Encodable, Equatable {
let scenicId: Int
let markerType: String
let id: Int
let latitude: Double?
let longitude: Double?
enum CodingKeys: String, CodingKey {
case scenicId = "scenic_id"
case markerType = "marker_type"
case id
case latitude
case longitude
}
}
///
struct WildReportSubmitRequest: Encodable, Equatable {
let scenicId: Int

View File

@ -868,6 +868,415 @@ enum WildReportRadarMarkerType: String, Hashable {
case blue
case green
case red
case yellow
}
///
enum WildReportRiskMarkerType: Hashable {
case blue
case green
case red
case yellow
case unknown(String)
init(rawValue: String) {
switch rawValue.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() {
case "blue", "self", "self_location":
self = .blue
case "green":
self = .green
case "red":
self = .red
case "yellow":
self = .yellow
default:
self = .unknown(rawValue)
}
}
var rawValue: String {
switch self {
case .blue: return "blue"
case .green: return "green"
case .red: return "red"
case .yellow: return "yellow"
case .unknown(let value): return value
}
}
var markerKind: WildReportMapMarkerKind {
switch self {
case .blue:
return .selfLocation
case .green:
return .peerPhotographer
case .yellow:
return .processingClue
case .red, .unknown:
return .wildPhotographer
}
}
var radarType: WildReportRadarMarkerType {
switch self {
case .blue:
return .blue
case .green:
return .green
case .yellow:
return .yellow
case .red, .unknown:
return .red
}
}
}
///
struct WildReportDecodedHandleStatus: Decodable, Equatable, Hashable {
let intValue: Int?
let textValue: String?
init(intValue: Int? = nil, textValue: String? = nil) {
self.intValue = intValue
self.textValue = textValue
}
init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
if let value = try? container.decode(Int.self) {
intValue = value
textValue = nil
} else if let value = try? container.decode(String.self) {
intValue = Int(value)
textValue = value
} else {
intValue = nil
textValue = nil
}
}
var displayText: String {
if let text = textValue?.trimmingCharacters(in: .whitespacesAndNewlines), !text.isEmpty {
switch text {
case "0": return WildReportStatus.pending.rawValue
case "1": return WildReportStatus.processing.rawValue
case "2": return WildReportStatus.processed.rawValue
case "3": return WildReportStatus.rejected.rawValue
default: return text
}
}
return WildReportStatus(handleStatus: intValue ?? 0).rawValue
}
}
///
struct WildReportRiskMapResponse: Decodable, Equatable {
let scenicId: Int
let scenicName: String
let legend: [WildReportRiskLegendItem]
let greenMarkerTip: String
let markers: [WildReportRiskMapMarkerDTO]
enum CodingKeys: String, CodingKey {
case scenicId = "scenic_id"
case scenicName = "scenic_name"
case legend
case greenMarkerTip = "green_marker_tip"
case markers
}
init(
scenicId: Int,
scenicName: String,
legend: [WildReportRiskLegendItem] = [],
greenMarkerTip: String = "",
markers: [WildReportRiskMapMarkerDTO] = []
) {
self.scenicId = scenicId
self.scenicName = scenicName
self.legend = legend
self.greenMarkerTip = greenMarkerTip
self.markers = markers
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
scenicId = try container.decodeIfPresent(Int.self, forKey: .scenicId) ?? 0
scenicName = try container.decodeIfPresent(String.self, forKey: .scenicName) ?? ""
legend = try container.decodeIfPresent([WildReportRiskLegendItem].self, forKey: .legend) ?? []
greenMarkerTip = try container.decodeIfPresent(String.self, forKey: .greenMarkerTip) ?? ""
markers = try container.decodeIfPresent([WildReportRiskMapMarkerDTO].self, forKey: .markers) ?? []
}
}
///
struct WildReportRiskLegendItem: Decodable, Equatable, Hashable {
let markerType: String?
let title: String?
let color: String?
enum CodingKeys: String, CodingKey {
case markerType = "marker_type"
case type
case title
case text
case name
case color
}
init(markerType: String? = nil, title: String? = nil, color: String? = nil) {
self.markerType = markerType
self.title = title
self.color = color
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
markerType = try container.decodeIfPresent(String.self, forKey: .markerType)
?? (try container.decodeIfPresent(String.self, forKey: .type))
title = try container.decodeIfPresent(String.self, forKey: .title)
?? (try container.decodeIfPresent(String.self, forKey: .text))
?? (try container.decodeIfPresent(String.self, forKey: .name))
color = try container.decodeIfPresent(String.self, forKey: .color)
}
}
///
struct WildReportRiskMapMarkerDTO: Decodable, Equatable, Hashable {
let markerType: String
let id: Int
let latitude: String
let longitude: String
let locationName: String
let title: String
let reportTypeText: String
let handleStatus: WildReportDecodedHandleStatus?
let handleStatusText: String?
let distanceM: Double
let createdAt: String
let tag: String?
let name: String?
let avatar: String?
let storeName: String?
let online: Bool?
let greenMarkerTip: String?
let activityText: String?
enum CodingKeys: String, CodingKey {
case markerType = "marker_type"
case id
case latitude
case longitude
case locationName = "location_name"
case title
case reportTypeText = "report_type_text"
case handleStatus = "handle_status"
case handleStatusText = "handle_status_text"
case distanceM = "distance_m"
case createdAt = "created_at"
case tag
case name
case avatar
case storeName = "store_name"
case online
case greenMarkerTip = "green_marker_tip"
case activityText = "activity_text"
}
init(
markerType: String,
id: Int,
latitude: String,
longitude: String,
locationName: String,
title: String,
reportTypeText: String = "",
handleStatus: WildReportDecodedHandleStatus? = nil,
handleStatusText: String? = nil,
distanceM: Double = 0,
createdAt: String = "",
tag: String? = nil,
name: String? = nil,
avatar: String? = nil,
storeName: String? = nil,
online: Bool? = nil,
greenMarkerTip: String? = nil,
activityText: String? = nil
) {
self.markerType = markerType
self.id = id
self.latitude = latitude
self.longitude = longitude
self.locationName = locationName
self.title = title
self.reportTypeText = reportTypeText
self.handleStatus = handleStatus
self.handleStatusText = handleStatusText
self.distanceM = distanceM
self.createdAt = createdAt
self.tag = tag
self.name = name
self.avatar = avatar
self.storeName = storeName
self.online = online
self.greenMarkerTip = greenMarkerTip
self.activityText = activityText
}
init(from decoder: Decoder) throws {
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
latitude = try container.decodeIfPresent(String.self, forKey: .latitude) ?? ""
longitude = try container.decodeIfPresent(String.self, forKey: .longitude) ?? ""
locationName = try container.decodeIfPresent(String.self, forKey: .locationName) ?? ""
title = try container.decodeIfPresent(String.self, forKey: .title) ?? ""
reportTypeText = try container.decodeIfPresent(String.self, forKey: .reportTypeText) ?? ""
handleStatus = try container.decodeIfPresent(WildReportDecodedHandleStatus.self, forKey: .handleStatus)
handleStatusText = try container.decodeIfPresent(String.self, forKey: .handleStatusText)
distanceM = try container.decodeIfPresent(Double.self, forKey: .distanceM) ?? 0
createdAt = try container.decodeIfPresent(String.self, forKey: .createdAt) ?? ""
tag = try container.decodeIfPresent(String.self, forKey: .tag)
name = try container.decodeIfPresent(String.self, forKey: .name)
avatar = try container.decodeIfPresent(String.self, forKey: .avatar)
storeName = try container.decodeIfPresent(String.self, forKey: .storeName)
online = try container.decodeIfPresent(Bool.self, forKey: .online)
greenMarkerTip = try container.decodeIfPresent(String.self, forKey: .greenMarkerTip)
activityText = try container.decodeIfPresent(String.self, forKey: .activityText)
}
}
///
struct WildReportMarkerDetailResponse: Decodable, Equatable, Hashable {
let markerType: String
let id: Int
let title: String
let tag: String
let name: String
let avatar: String
let storeName: String
let online: Bool
let locationName: String
let latitude: String
let longitude: String
let distanceM: Double
let greenMarkerTip: String
let activityText: String
let reportTypeText: String
let handleStatus: WildReportDecodedHandleStatus?
let handleStatusText: String
let desc: String
let reporterName: String
let reporterPhone: String
let createdAt: String
let reportContents: [WildReportDetailContent]
let evidences: [WildReportDetailEvidence]
enum CodingKeys: String, CodingKey {
case markerType = "marker_type"
case id
case title
case tag
case name
case avatar
case storeName = "store_name"
case online
case locationName = "location_name"
case latitude
case longitude
case distanceM = "distance_m"
case greenMarkerTip = "green_marker_tip"
case activityText = "activity_text"
case reportTypeText = "report_type_text"
case handleStatus = "handle_status"
case handleStatusText = "handle_status_text"
case desc
case reporterName = "reporter_name"
case reporterPhone = "reporter_phone"
case contactPhone = "contact_phone"
case createdAt = "created_at"
case reportContents = "report_contents"
case evidences
}
init(
markerType: String,
id: Int,
title: String = "",
tag: String = "",
name: String = "",
avatar: String = "",
storeName: String = "",
online: Bool = false,
locationName: String = "",
latitude: String = "",
longitude: String = "",
distanceM: Double = 0,
greenMarkerTip: String = "",
activityText: String = "",
reportTypeText: String = "",
handleStatus: WildReportDecodedHandleStatus? = nil,
handleStatusText: String = "",
desc: String = "",
reporterName: String = "",
reporterPhone: String = "",
createdAt: String = "",
reportContents: [WildReportDetailContent] = [],
evidences: [WildReportDetailEvidence] = []
) {
self.markerType = markerType
self.id = id
self.title = title
self.tag = tag
self.name = name
self.avatar = avatar
self.storeName = storeName
self.online = online
self.locationName = locationName
self.latitude = latitude
self.longitude = longitude
self.distanceM = distanceM
self.greenMarkerTip = greenMarkerTip
self.activityText = activityText
self.reportTypeText = reportTypeText
self.handleStatus = handleStatus
self.handleStatusText = handleStatusText
self.desc = desc
self.reporterName = reporterName
self.reporterPhone = reporterPhone
self.createdAt = createdAt
self.reportContents = reportContents
self.evidences = evidences
}
init(from decoder: Decoder) throws {
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
title = try container.decodeIfPresent(String.self, forKey: .title) ?? ""
tag = try container.decodeIfPresent(String.self, forKey: .tag) ?? ""
name = try container.decodeIfPresent(String.self, forKey: .name) ?? ""
avatar = try container.decodeIfPresent(String.self, forKey: .avatar) ?? ""
storeName = try container.decodeIfPresent(String.self, forKey: .storeName) ?? ""
online = try container.decodeIfPresent(Bool.self, forKey: .online) ?? false
locationName = try container.decodeIfPresent(String.self, forKey: .locationName) ?? ""
latitude = try container.decodeIfPresent(String.self, forKey: .latitude) ?? ""
longitude = try container.decodeIfPresent(String.self, forKey: .longitude) ?? ""
distanceM = try container.decodeIfPresent(Double.self, forKey: .distanceM) ?? 0
greenMarkerTip = try container.decodeIfPresent(String.self, forKey: .greenMarkerTip) ?? ""
activityText = try container.decodeIfPresent(String.self, forKey: .activityText) ?? ""
reportTypeText = try container.decodeIfPresent(String.self, forKey: .reportTypeText) ?? ""
handleStatus = try container.decodeIfPresent(WildReportDecodedHandleStatus.self, forKey: .handleStatus)
handleStatusText = try container.decodeIfPresent(String.self, forKey: .handleStatusText) ?? ""
desc = try container.decodeIfPresent(String.self, forKey: .desc) ?? ""
reporterName = try container.decodeIfPresent(String.self, forKey: .reporterName) ?? ""
reporterPhone = try container.decodeIfPresent(String.self, forKey: .reporterPhone)
?? (try container.decodeIfPresent(String.self, forKey: .contactPhone))
?? ""
createdAt = try container.decodeIfPresent(String.self, forKey: .createdAt) ?? ""
reportContents = try container.decodeIfPresent([WildReportDetailContent].self, forKey: .reportContents) ?? []
evidences = try container.decodeIfPresent([WildReportDetailEvidence].self, forKey: .evidences) ?? []
}
}
/// 线

View File

@ -231,6 +231,8 @@ enum WildPhotographerReportMockStore {
return .selfLocation
case .green:
return .peerPhotographer
case .yellow:
return .processingClue
case .red:
if clue.processingStarted && !clue.completed {
return .processingClue

View File

@ -1141,20 +1141,31 @@ final class WildReportSupplementEvidenceViewModel {
/// ViewModel
final class WildReportRiskMapViewModel {
private let fallbackMarkers: [WildReportMapMarker]
private var loadedDetailMarkerIDs: Set<String> = []
private(set) var region: MKCoordinateRegion
private(set) var markers: [WildReportMapMarker]
let scenicAreaName: String
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?
private(set) var greenMarkerTip = ""
private(set) var legendItems: [WildReportRiskLegendItem] = []
private(set) var currentCoordinate: CLLocationCoordinate2D?
var onStateChange: (() -> Void)?
var onShowMessage: ((String) -> Void)?
init(record: WildReportRecord, riskPoints: [WildReportRiskPoint]) {
let markers = WildPhotographerReportMockStore.buildMarkers(record: record, riskPoints: riskPoints)
self.fallbackMarkers = markers
self.markers = markers
self.region = WildPhotographerReportMockStore.region(for: markers)
self.scenicAreaName = "那拉提景区"
let scenicName = AppStore.shared.currentScenicName.trimmingCharacters(in: .whitespacesAndNewlines)
self.scenicAreaName = scenicName.isEmpty ? "那拉提景区" : scenicName
self.selectedMarkerID = markers.first(where: { $0.kind == .selfLocation })?.id
}
@ -1169,40 +1180,86 @@ final class WildReportRiskMapViewModel {
return clue
}
///
func selectMarker(_ marker: WildReportMapMarker) {
selectedMarkerID = marker.id
notifyStateChange()
///
func loadInitial(
api: any WildPhotographerReportServing,
locationProvider: any LocationProviding,
scenicId: Int? = nil,
scenicName: String? = nil
) async {
await loadRiskMap(
api: api,
locationProvider: locationProvider,
scenicId: scenicId,
scenicName: scenicName,
selectCurrentLocation: selectedMarkerID == nil || selectedMarker?.kind == .selfLocation
)
}
///
func navigateToSelectedMarker() {
guard let marker = selectedMarker,
case .activeClue(let clue) = marker.detail else { return }
if clue.type == .blue {
refreshLocation()
return
}
showToast("已切换到该点位")
///
func selectMarker(
_ marker: WildReportMapMarker,
api: (any WildPhotographerReportServing)? = nil,
scenicId: Int? = nil
) async {
selectedMarkerID = marker.id
region = MKCoordinateRegion(
center: marker.coordinate,
span: MKCoordinateSpan(latitudeDelta: 0.018, longitudeDelta: 0.018)
)
notifyStateChange()
guard marker.kind != .selfLocation,
!loadedDetailMarkerIDs.contains(marker.id),
let api,
let request = markerDetailRequest(for: marker, scenicId: scenicId)
else { return }
isLoadingDetail = true
notifyStateChange()
do {
let detail = try await api.markerDetail(request)
mergeDetail(detail, intoMarkerID: marker.id)
loadedDetailMarkerIDs.insert(marker.id)
} catch {
showToast(error.localizedDescription)
}
isLoadingDetail = false
notifyStateChange()
}
///
@discardableResult
func navigateToSelectedMarker() -> WildReportMapMarker? {
guard let marker = selectedMarker,
case .activeClue(let clue) = marker.detail else { return nil }
if clue.type == .blue {
return nil
}
region = MKCoordinateRegion(
center: marker.coordinate,
span: MKCoordinateSpan(latitudeDelta: 0.018, longitudeDelta: 0.018)
)
notifyStateChange()
return marker
}
///
func refreshLocation() {
guard let blueMarker = markers.first(where: { $0.kind == .selfLocation }) else { return }
selectedMarkerID = blueMarker.id
region = MKCoordinateRegion(
center: blueMarker.coordinate,
span: MKCoordinateSpan(latitudeDelta: 0.018, longitudeDelta: 0.018)
func refreshLocation(
api: any WildPhotographerReportServing,
locationProvider: any LocationProviding,
scenicId: Int? = nil,
scenicName: String? = nil
) async {
await loadRiskMap(
api: api,
locationProvider: locationProvider,
scenicId: scenicId,
scenicName: scenicName,
selectCurrentLocation: true
)
showToast("已定位到当前位置")
notifyStateChange()
}
/// 线
@ -1224,7 +1281,301 @@ final class WildReportRiskMapViewModel {
notifyStateChange()
}
private func loadRiskMap(
api: any WildPhotographerReportServing,
locationProvider: any LocationProviding,
scenicId: Int?,
scenicName: String?,
selectCurrentLocation: Bool
) async {
let resolvedScenicId = scenicId ?? AppStore.shared.currentScenicId
guard resolvedScenicId >= 1 else {
errorMessage = "请先选择景区后再查看风险地图"
showToast(errorMessage ?? "")
return
}
isLoading = true
errorMessage = nil
notifyStateChange()
let coordinate = try? await locationProvider.requestCoordinate()
currentCoordinate = coordinate
do {
let response = try await api.riskMap(WildReportRiskMapRequest(
scenicId: resolvedScenicId,
latitude: coordinate?.latitude,
longitude: coordinate?.longitude
))
scenicAreaName = response.scenicName.nonEmpty
?? scenicName?.nonEmpty
?? AppStore.shared.currentScenicName.nonEmpty
?? scenicAreaName
greenMarkerTip = response.greenMarkerTip
legendItems = response.legend
markers = buildMarkers(response: response, currentCoordinate: coordinate)
if markers.isEmpty {
markers = fallbackMarkers
}
region = WildPhotographerReportMockStore.region(for: markers)
if selectCurrentLocation, let current = markers.first(where: { $0.kind == .selfLocation }) {
selectedMarkerID = current.id
} else if selectedMarker == nil {
selectedMarkerID = markers.first?.id
}
if coordinate != nil {
showToast("已更新附近风险点位")
}
} catch {
errorMessage = error.localizedDescription
if markers.isEmpty {
markers = fallbackMarkers
region = WildPhotographerReportMockStore.region(for: markers)
selectedMarkerID = markers.first(where: { $0.kind == .selfLocation })?.id ?? markers.first?.id
}
showToast(error.localizedDescription)
}
isLoading = false
notifyStateChange()
}
private func buildMarkers(
response: WildReportRiskMapResponse,
currentCoordinate: CLLocationCoordinate2D?
) -> [WildReportMapMarker] {
let remoteMarkers = response.markers.compactMap {
marker(from: $0, greenMarkerTip: response.greenMarkerTip)
}
var result: [WildReportMapMarker] = []
if let currentCoordinate {
result.append(currentLocationMarker(
coordinate: currentCoordinate,
nearestAbnormalText: nearestAbnormalText(for: remoteMarkers)
))
}
result.append(contentsOf: remoteMarkers)
return result
}
private func currentLocationMarker(
coordinate: CLLocationCoordinate2D,
nearestAbnormalText: String
) -> WildReportMapMarker {
WildReportMapMarker(
id: "blue-current",
title: "我的位置",
coordinate: coordinate,
kind: .selfLocation,
detail: .activeClue(WildReportRadarActiveClue(
id: "blue-current",
type: .blue,
displayTitle: "当前位置",
statusText: "当前位置",
actionText: "刷新定位",
nearestAbnormalText: nearestAbnormalText,
distance: "0米",
direction: "当前位置",
updatedAt: "实时定位",
name: scenicAreaName,
storeName: nil,
avatar: nil,
summary: "蓝色标记为当前位置,可对比周边摄影师风险点位距离。",
detail: "点击刷新定位可重新计算周边风险点位距离。",
distanceText: "0米",
reporterName: nil,
reporterPhone: nil,
maskedReporterPhone: nil,
reportContents: [],
images: [],
processingStarted: false,
completed: false,
record: nil,
processingTimeline: [],
completionPhoto: nil
))
)
}
private func marker(
from dto: WildReportRiskMapMarkerDTO,
greenMarkerTip: String
) -> WildReportMapMarker? {
guard let latitude = Double(dto.latitude),
let longitude = Double(dto.longitude),
dto.id > 0
else { return nil }
let markerType = WildReportRiskMarkerType(rawValue: dto.markerType)
let statusText = dto.handleStatusText?.nonEmpty
?? dto.handleStatus?.displayText
?? defaultStatusText(for: markerType, dto: dto)
let title = dto.title.nonEmpty ?? dto.locationName.nonEmpty ?? defaultTitle(for: markerType, id: dto.id)
let clue = WildReportRadarActiveClue(
id: String(dto.id),
type: markerType.radarType,
displayTitle: title,
statusText: statusText,
actionText: markerType == .blue ? "刷新定位" : "导航到此",
nearestAbnormalText: nil,
distance: formattedDistance(dto.distanceM),
direction: nil,
updatedAt: dto.createdAt.nonEmpty,
name: dto.name?.nonEmpty ?? dto.locationName.nonEmpty,
storeName: dto.storeName?.nonEmpty,
avatar: dto.avatar?.nonEmpty,
summary: markerType == .green ? (dto.greenMarkerTip?.nonEmpty ?? greenMarkerTip.nonEmpty) : dto.reportTypeText.nonEmpty,
detail: dto.activityText?.nonEmpty,
distanceText: formattedDistance(dto.distanceM),
reporterName: nil,
reporterPhone: nil,
maskedReporterPhone: nil,
reportContents: dto.reportTypeText.nonEmpty.map {
[WildReportRadarReportContent(time: dto.createdAt.nonEmpty ?? "", content: $0)]
} ?? [],
images: [],
processingStarted: markerType == .yellow || statusText == WildReportStatus.processing.rawValue,
completed: statusText == WildReportStatus.processed.rawValue,
record: nil,
processingTimeline: [],
completionPhoto: nil
)
return WildReportMapMarker(
id: "\(markerType.rawValue)-\(dto.id)",
title: title,
coordinate: CLLocationCoordinate2D(latitude: latitude, longitude: longitude),
kind: markerType.markerKind,
detail: .activeClue(clue)
)
}
private func markerDetailRequest(
for marker: WildReportMapMarker,
scenicId: Int?
) -> WildReportMarkerDetailRequest? {
let resolvedScenicId = scenicId ?? AppStore.shared.currentScenicId
guard resolvedScenicId >= 1 else { return nil }
let parts = marker.id.split(separator: "-", maxSplits: 1).map(String.init)
guard parts.count == 2, let id = Int(parts[1]), id >= 1 else { return nil }
return WildReportMarkerDetailRequest(
scenicId: resolvedScenicId,
markerType: parts[0],
id: id,
latitude: currentCoordinate?.latitude,
longitude: currentCoordinate?.longitude
)
}
private func mergeDetail(_ detail: WildReportMarkerDetailResponse, intoMarkerID markerID: String) {
guard let index = markers.firstIndex(where: { $0.id == markerID }),
case .activeClue(let oldClue) = markers[index].detail
else { return }
let markerType = WildReportRiskMarkerType(rawValue: detail.markerType.nonEmpty ?? oldClue.type.rawValue)
let statusText = detail.handleStatusText.nonEmpty
?? detail.handleStatus?.displayText
?? oldClue.statusText
let reportContents = !detail.reportContents.isEmpty
? detail.reportContents.map { WildReportRadarReportContent(time: $0.time, content: $0.desc) }
: (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),
type: markerType.radarType,
displayTitle: detail.title.nonEmpty ?? oldClue.displayTitle,
statusText: statusText,
actionText: oldClue.actionText,
nearestAbnormalText: oldClue.nearestAbnormalText,
distance: formattedDistance(detail.distanceM),
direction: oldClue.direction,
updatedAt: detail.createdAt.nonEmpty ?? oldClue.updatedAt,
name: detail.name.nonEmpty ?? oldClue.name,
storeName: detail.storeName.nonEmpty ?? oldClue.storeName,
avatar: detail.avatar.nonEmpty ?? oldClue.avatar,
summary: detail.greenMarkerTip.nonEmpty ?? detail.reportTypeText.nonEmpty ?? oldClue.summary,
detail: detail.activityText.nonEmpty ?? detail.desc.nonEmpty ?? oldClue.detail,
distanceText: formattedDistance(detail.distanceM) ?? oldClue.distanceText,
reporterName: detail.reporterName.nonEmpty ?? oldClue.reporterName,
reporterPhone: detail.reporterPhone.nonEmpty ?? oldClue.reporterPhone,
maskedReporterPhone: detail.reporterPhone.nonEmpty.map(Self.maskPhone) ?? oldClue.maskedReporterPhone,
reportContents: reportContents,
images: imageURLs.isEmpty ? oldClue.images : imageURLs,
processingStarted: markerType == .yellow || statusText == WildReportStatus.processing.rawValue,
completed: statusText == WildReportStatus.processed.rawValue,
record: oldClue.record,
processingTimeline: oldClue.processingTimeline,
completionPhoto: oldClue.completionPhoto
)
markers[index] = WildReportMapMarker(
id: markers[index].id,
title: detail.title.nonEmpty ?? markers[index].title,
coordinate: markers[index].coordinate,
kind: markerType.markerKind,
detail: .activeClue(clue)
)
}
private func nearestAbnormalText(for markers: [WildReportMapMarker]) -> String {
let distances = markers.compactMap { marker -> Double? in
guard case .activeClue(let clue) = marker.detail,
clue.type == .red || clue.type == .yellow,
let distance = clue.distanceText ?? clue.distance
else { return nil }
if distance.hasSuffix("公里") {
return Double(distance.replacingOccurrences(of: "公里", with: "")).map { $0 * 1000 }
}
return Double(distance.replacingOccurrences(of: "", with: ""))
}
guard let minDistance = distances.min() else { return "周边暂无异常点位" }
return "距离最近的异常点位\(formattedDistance(minDistance) ?? "0米")"
}
private func defaultStatusText(for markerType: WildReportRiskMarkerType, dto: WildReportRiskMapMarkerDTO) -> String {
switch markerType {
case .green:
return dto.online == false ? "离线" : "在线"
case .yellow:
return "处理中线索"
case .red, .unknown:
return dto.reportTypeText.nonEmpty ?? "疑似打野"
case .blue:
return "当前位置"
}
}
private func defaultTitle(for markerType: WildReportRiskMarkerType, id: Int) -> String {
switch markerType {
case .green:
return "景区内摄影师"
case .yellow, .red, .unknown:
return "线索 ID\(id)"
case .blue:
return "我的位置"
}
}
private func formattedDistance(_ meters: Double) -> String? {
guard meters > 0 else { return nil }
if meters < 1000 {
return "\(Int(meters.rounded()))"
}
return String(format: "%.1f公里", meters / 1000)
}
private static func maskPhone(_ phone: String) -> String {
guard phone.count == 11, phone.allSatisfy(\.isNumber) else { return phone }
return "\(phone.prefix(3))****\(phone.suffix(4))"
}
private func notifyStateChange() {
onStateChange?()
}
}
private extension String {
var nonEmpty: String? {
let text = trimmingCharacters(in: .whitespacesAndNewlines)
return text.isEmpty ? nil : text
}
}

View File

@ -3,6 +3,7 @@
// suixinkan
//
import Kingfisher
import MapKit
import SnapKit
import UIKit
@ -10,12 +11,36 @@ 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 panel = WildReportCardView(spacing: AppSpacing.sm)
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]) {
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)
}
@ -24,39 +49,67 @@ final class WildReportRiskMapViewController: BaseViewController {
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 = AppColor.pageBackgroundSoft
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
view.addSubview(mapView)
view.addSubview(panel)
addLegend()
applyMap()
rebuildPanel()
mapView.pointOfInterestFilter = .excludingAll
mapContainerView.addSubview(mapView)
configureScenicCapsule()
configureLegendBar()
configureDetailArea()
refreshContent(animated: false)
}
override func setupConstraints() {
mapView.snp.makeConstraints { make in
make.top.leading.trailing.equalTo(view.safeAreaLayoutGuide)
rootStack.snp.makeConstraints { make in
make.edges.equalTo(view.safeAreaLayoutGuide)
}
mapContainerView.snp.makeConstraints { make in
make.height.equalTo(340)
}
panel.snp.makeConstraints { make in
make.top.equalTo(mapView.snp.bottom).offset(AppSpacing.sm)
make.leading.trailing.equalToSuperview().inset(AppSpacing.md)
make.bottom.lessThanOrEqualTo(view.safeAreaLayoutGuide).inset(AppSpacing.sm)
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?.mapView.setRegion(self?.viewModel.region ?? MKCoordinateRegion(), animated: true)
self?.rebuildPanel()
self?.refreshContent(animated: true)
}
}
viewModel.onShowMessage = { [weak self] message in
@ -64,127 +117,377 @@ final class WildReportRiskMapViewController: BaseViewController {
}
}
private func applyMap() {
mapView.setRegion(viewModel.region, animated: false)
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
let annotation = WildReportMapAnnotation(marker: marker)
return annotation
return WildReportMapAnnotation(marker: marker)
}
mapView.addAnnotations(annotations)
}
private func addLegend() {
let legend = UIStackView()
legend.axis = .horizontal
legend.distribution = .fillEqually
legend.backgroundColor = UIColor.white.withAlphaComponent(0.94)
legend.layer.cornerRadius = AppRadius.md
legend.clipsToBounds = true
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
let row = UIStackView()
row.axis = .horizontal
row.alignment = .center
row.spacing = 4
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: .app(.caption), color: AppColor.textSecondary)
row.addArrangedSubview(dot)
row.addArrangedSubview(label)
legend.addArrangedSubview(row)
}
view.addSubview(legend)
legend.snp.makeConstraints { make in
make.leading.trailing.equalToSuperview().inset(AppSpacing.md)
make.top.equalTo(view.safeAreaLayoutGuide).offset(AppSpacing.sm)
make.height.equalTo(36)
legendBar.addArrangedSubview(WildReportRiskLegendView(kind: kind))
}
}
@MainActor
private func rebuildPanel() {
panel.stack.arrangedSubviews.forEach { view in
panel.stack.removeArrangedSubview(view)
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 {
panel.stack.addArrangedSubview(WildReportUI.label("点击地图标记查看详情", font: .app(.body), color: AppColor.textSecondary))
case .activeClue(let clue) = marker.detail
else {
contentStack.addArrangedSubview(makeStateCard(icon: "hand.tap.fill", text: "点击地图标记查看详情"))
return
}
let header = UIStackView()
header.axis = .horizontal
header.alignment = .center
let title = WildReportUI.label(clue.displayTitle, font: .app(.title), color: AppColor.textPrimary, lines: 0)
let badge = UILabel()
badge.text = clue.statusText
badge.font = .app(.captionMedium)
badge.textColor = marker.kind.color
badge.textAlignment = .center
badge.backgroundColor = marker.kind.color.withAlphaComponent(0.12)
badge.layer.cornerRadius = 12
badge.clipsToBounds = true
badge.snp.makeConstraints { make in
make.width.greaterThanOrEqualTo(74)
make.height.equalTo(24)
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: "正在加载标记详情..."))
}
header.addArrangedSubview(title)
header.addArrangedSubview(UIView())
header.addArrangedSubview(badge)
panel.stack.addArrangedSubview(header)
if let nearest = clue.nearestAbnormalText {
panel.stack.addArrangedSubview(WildReportUI.label(nearest, font: .app(.bodyMedium), color: AppColor.danger, lines: 0))
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 {
panel.stack.addArrangedSubview(WildReportInfoRowView(title: "点位", value: name))
stack.addArrangedSubview(WildReportInfoRowView(title: clue.type == .green ? "摄影师" : "点位", value: name))
}
if let store = clue.storeName {
panel.stack.addArrangedSubview(WildReportInfoRowView(title: "门店", value: store))
stack.addArrangedSubview(WildReportInfoRowView(title: "门店", value: store))
}
if let distance = clue.distanceText ?? clue.distance {
panel.stack.addArrangedSubview(WildReportInfoRowView(title: "距离", value: distance))
stack.addArrangedSubview(WildReportInfoRowView(title: "距离", value: distance))
}
if let updatedAt = clue.updatedAt {
panel.stack.addArrangedSubview(WildReportInfoRowView(title: "时间", value: updatedAt))
stack.addArrangedSubview(WildReportInfoRowView(title: "时间", value: updatedAt))
}
if let summary = clue.summary ?? clue.detail {
panel.stack.addArrangedSubview(WildReportUI.label(summary, font: .app(.body), color: AppColor.textSecondary, lines: 0))
if let reporter = clue.reporterName {
stack.addArrangedSubview(WildReportInfoRowView(title: "举报人", value: reporter))
}
clue.reportContents.prefix(2).forEach { content in
panel.stack.addArrangedSubview(WildReportUI.label("\(content.time) \(content.content)", font: .app(.caption), color: AppColor.textSecondary, lines: 0))
}
if let record = clue.record {
panel.stack.addArrangedSubview(WildReportInfoRowView(title: "处理人", value: record.handlerName))
panel.stack.addArrangedSubview(WildReportUI.label(record.processResult, font: .app(.body), color: AppColor.textSecondary, lines: 0))
if let phone = clue.maskedReporterPhone ?? clue.reporterPhone {
stack.addArrangedSubview(WildReportInfoRowView(title: "联系电话", value: phone))
}
}
let buttonRow = UIStackView()
buttonRow.axis = .horizontal
buttonRow.spacing = AppSpacing.sm
buttonRow.distribution = .fillEqually
let navButton = WildReportUI.iconButton(title: clue.actionText, imageName: "location.fill", style: .secondary)
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(42) }
buttonRow.addArrangedSubview(button)
button.snp.makeConstraints { make in make.height.equalTo(44) }
row.addArrangedSubview(button)
}
panel.stack.addArrangedSubview(buttonRow)
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() {
viewModel.navigateToSelectedMarker()
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() {
@ -195,31 +498,22 @@ final class WildReportRiskMapViewController: BaseViewController {
extension WildReportRiskMapViewController: MKMapViewDelegate {
func mapView(_ mapView: MKMapView, viewFor annotation: MKAnnotation) -> MKAnnotationView? {
guard let annotation = annotation as? WildReportMapAnnotation else { return nil }
let identifier = "WildReportMapAnnotationView"
let view = mapView.dequeueReusableAnnotationView(withIdentifier: identifier)
?? MKMarkerAnnotationView(annotation: annotation, reuseIdentifier: identifier)
let identifier = "WildReportPulseMarkerAnnotationView"
let view = (mapView.dequeueReusableAnnotationView(withIdentifier: identifier) as? WildReportPulseMarkerAnnotationView)
?? WildReportPulseMarkerAnnotationView(annotation: annotation, reuseIdentifier: identifier)
view.annotation = annotation
if let markerView = view as? MKMarkerAnnotationView {
markerView.markerTintColor = annotation.marker.kind.color
markerView.glyphImage = UIImage(systemName: glyphName(for: annotation.marker.kind))
markerView.glyphTintColor = .white
markerView.canShowCallout = false
markerView.animatesWhenAdded = true
}
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 }
viewModel.selectMarker(annotation.marker)
}
private func glyphName(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"
Task {
await viewModel.selectMarker(
annotation.marker,
api: api,
scenicId: scenicIdProvider()
)
}
}
}
@ -235,3 +529,228 @@ final class WildReportMapAnnotation: NSObject, MKAnnotation {
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)
}
}