// // WildPhotographerReportMapViews // suixinkan // // Created by Codex on 2026/7/1. // import MapKit import SwiftUI import UIKit struct WildReportRiskMapView: View { let record: WildReportRecord let riskPoints: [WildReportRiskPoint] @StateObject private var viewModel: WildReportRiskMapViewModel @Environment(\.horizontalSizeClass) private var horizontalSizeClass init(record: WildReportRecord, riskPoints: [WildReportRiskPoint]) { self.record = record self.riskPoints = riskPoints _viewModel = StateObject(wrappedValue: WildReportRiskMapViewModel(record: record, riskPoints: riskPoints)) } var body: some View { VStack(spacing: 0) { mapSection legendBar Divider() bottomPanel } .navigationTitle("附近风险地图") .navigationBarTitleDisplayMode(.inline) .background(Color(hex: 0xF8FAFF).ignoresSafeArea()) .fullScreenCover(item: $viewModel.previewMedia) { media in WildReportMapMediaPreviewView(media: media) { viewModel.dismissMediaPreview() } } .fullScreenCover(item: $viewModel.previewImageURL) { item in WildReportMapURLImagePreviewView(imageURL: item.url) { viewModel.dismissImagePreview() } } .overlay(alignment: .bottom) { if let toast = viewModel.toastMessage { Text(toast) .font(.system(size: 14, weight: .medium)) .foregroundStyle(.white) .padding(.horizontal, 16) .padding(.vertical, 10) .background(Color.black.opacity(0.78), in: Capsule()) .padding(.bottom, 24) .transition(.move(edge: .bottom).combined(with: .opacity)) .onAppear { DispatchQueue.main.asyncAfter(deadline: .now() + 1.8) { viewModel.clearToast() } } } } } private var mapSection: some View { ZStack(alignment: .topLeading) { Map( coordinateRegion: $viewModel.region, annotationItems: viewModel.markers ) { marker in MapAnnotation(coordinate: marker.coordinate) { Button { viewModel.selectMarker(marker) } label: { WildReportMapMarkerView( marker: marker, isSelected: viewModel.selectedMarkerID == marker.id ) } .buttonStyle(.plain) } } HStack(spacing: 6) { Image(systemName: "mountain.2.fill") .font(.system(size: 13, weight: .semibold)) Text(viewModel.scenicAreaName) .font(.system(size: 13, weight: .semibold)) } .foregroundStyle(AppDesign.primary) .padding(.horizontal, 10) .frame(height: 30) .background(.white.opacity(0.94), in: Capsule()) .padding(12) } .frame(height: horizontalSizeClass == .regular ? 420 : 340) } private var legendBar: some View { HStack(spacing: 0) { legendItem(color: AppDesign.primary, title: "我的位置") legendItem(color: AppDesign.danger, title: "疑似打野") legendItem(color: Color(hex: 0xF59E0B), title: "处理中线索") legendItem(color: AppDesign.success, title: "内部摄影师") } .padding(.horizontal, 12) .padding(.vertical, 12) .background(.white) } @ViewBuilder private var bottomPanel: some View { ScrollView(.vertical, showsIndicators: true) { VStack(spacing: 0) { if let marker = viewModel.selectedMarker { if case .activeClue(let clue) = marker.detail { WildReportRadarResultCardView( clue: clue, onPreviewImage: { url in viewModel.showImagePreview(url) }, onNavigate: { viewModel.navigateToSelectedMarker() }, onShare: { viewModel.shareSelectedClue() } ) } } else { Text("点击地图标记查看详情") .font(.system(size: 14)) .foregroundStyle(AppDesign.textSecondary) .frame(maxWidth: .infinity) .padding(.vertical, 40) } } .frame(maxWidth: .infinity, alignment: .top) } .frame(minHeight: 0, maxHeight: .infinity) .background(Color(hex: 0xF8FAFF)) } private func legendItem(color: Color, title: String) -> some View { HStack(spacing: 6) { WildReportMapDot(color: color, size: 10) Text(title) .font(.system(size: 11, weight: .medium)) .foregroundStyle(AppDesign.textPrimary) .lineLimit(1) .minimumScaleFactor(0.8) } .frame(maxWidth: .infinity) } } private struct WildReportRadarResultCardView: View { let clue: WildReportRadarActiveClue let onPreviewImage: (String) -> Void let onNavigate: () -> Void let onShare: () -> Void var body: some View { VStack(alignment: .leading, spacing: 14) { headerSection if clue.type == .blue, let nearestAbnormalText = clue.nearestAbnormalText { compareText(nearestAbnormalText) } if clue.type == .green { photographerProfile } if clue.type == .red { reporterInfo } if clue.type == .red, !clue.reportContents.isEmpty { reportContentsSection } if clue.type == .green, let summary = clue.summary { compareText(summary) } if clue.type == .green, let detail = clue.detail { detailSection(detail) } if clue.type == .red, !clue.images.isEmpty { imagesSection } if clue.type == .red, clue.processingStarted, let record = clue.record { processingPanel(record: record) } if clue.completed, let completionPhoto = clue.completionPhoto { completionProofSection(completionPhoto) } if !clue.completed { actionButtons } } .padding(16) .frame(maxWidth: .infinity, alignment: .leading) .background(.white, in: RoundedRectangle(cornerRadius: 16)) .overlay( RoundedRectangle(cornerRadius: 16) .stroke(AppDesign.border, lineWidth: 1) ) .padding(16) } private var headerSection: some View { HStack(alignment: .top, spacing: 12) { Image(systemName: "location.fill") .font(.system(size: 20, weight: .semibold)) .foregroundStyle(.white) .frame(width: 44, height: 44) .background(headerIconColor, in: RoundedRectangle(cornerRadius: 12)) VStack(alignment: .leading, spacing: 6) { if clue.type == .red { Text("线索 ID:\(clue.id)") .font(.system(size: 16, weight: .bold)) .foregroundStyle(AppDesign.textPrimary) HStack(spacing: 12) { if let distanceText = clue.distanceText { Text("距离:距离您\(distanceText)") } if let updatedAt = clue.updatedAt { Text("时间:\(updatedAt)") } } .font(.system(size: 12)) .foregroundStyle(AppDesign.textSecondary) } else { Text(clue.displayTitle) .font(.system(size: 16, weight: .bold)) .foregroundStyle(AppDesign.textPrimary) if clue.type == .green { HStack(spacing: 12) { if let distance = clue.distance { Text("距蓝点 \(distance)") } if let direction = clue.direction { Text(direction) } if let updatedAt = clue.updatedAt { Text(updatedAt) } } .font(.system(size: 12)) .foregroundStyle(AppDesign.textSecondary) } } } Spacer(minLength: 8) Text(clue.statusText) .font(.system(size: 11, weight: .semibold)) .foregroundStyle(statusColor) .padding(.horizontal, 8) .frame(height: 24) .background(statusColor.opacity(0.12), in: Capsule()) } } private var photographerProfile: some View { HStack(spacing: 12) { Image(systemName: clue.avatar ?? "person.crop.circle.fill") .font(.system(size: 42, weight: .semibold)) .foregroundStyle(AppDesign.success) .frame(width: 56, height: 56) .background(AppDesign.success.opacity(0.12), in: Circle()) VStack(alignment: .leading, spacing: 4) { if let name = clue.name { Text(name) .font(.system(size: 16, weight: .bold)) .foregroundStyle(AppDesign.textPrimary) } if let storeName = clue.storeName { Text(storeName) .font(.system(size: 13, weight: .semibold)) .foregroundStyle(AppDesign.success) } } } } private var reporterInfo: some View { VStack(spacing: 10) { infoLabelRow(label: "举报人", value: clue.reporterName ?? "-") HStack { Text("举报人手机号") .font(.system(size: 13)) .foregroundStyle(AppDesign.textSecondary) Spacer() if let phone = clue.reporterPhone, let masked = clue.maskedReporterPhone { Button { callPhone(phone) } label: { HStack(spacing: 6) { Text(masked) .font(.system(size: 14, weight: .semibold)) .foregroundStyle(AppDesign.primary) Image(systemName: "phone.fill") .font(.system(size: 13, weight: .semibold)) .foregroundStyle(AppDesign.primary) } } .buttonStyle(.plain) } } } .padding(12) .background(Color(hex: 0xF9FAFB), in: RoundedRectangle(cornerRadius: 10)) } private var reportContentsSection: some View { VStack(alignment: .leading, spacing: 10) { ForEach(Array(clue.reportContents.enumerated()), id: \.offset) { _, item in VStack(alignment: .leading, spacing: 4) { Text(item.time) .font(.system(size: 11, weight: .semibold)) .foregroundStyle(AppDesign.textSecondary) Text(item.content) .font(.system(size: 13)) .foregroundStyle(AppDesign.textPrimary) .lineSpacing(4) } } } } private var imagesSection: some View { VStack(alignment: .leading, spacing: 10) { Text("现场图片/视频") .font(.system(size: 15, weight: .bold)) .foregroundStyle(AppDesign.textPrimary) ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 10) { ForEach(clue.images, id: \.self) { url in Button { onPreviewImage(url) } label: { AsyncImage(url: URL(string: url)) { phase in switch phase { case .success(let image): image .resizable() .scaledToFill() default: RoundedRectangle(cornerRadius: 10) .fill(Color(hex: 0xE5E7EB)) .overlay { ProgressView() } } } .frame(width: 108, height: 108) .clipShape(RoundedRectangle(cornerRadius: 10)) } .buttonStyle(.plain) } } } } } private func processingPanel(record: WildReportRadarProcessRecord) -> some View { VStack(spacing: 10) { infoLabelRow(label: "处理人", value: record.handlerName) infoLabelRow( label: "状态", value: clue.completed ? "已处理" : "正在处理中", valueColor: clue.completed ? AppDesign.success : Color(hex: 0xF59E0B) ) HStack { Text("联系电话") .font(.system(size: 13)) .foregroundStyle(AppDesign.textSecondary) Spacer() Button { callPhone(record.phone) } label: { HStack(spacing: 6) { Text(record.maskedPhone) .font(.system(size: 14, weight: .semibold)) .foregroundStyle(AppDesign.primary) Image(systemName: "phone.fill") .font(.system(size: 13, weight: .semibold)) .foregroundStyle(AppDesign.primary) } } .buttonStyle(.plain) } VStack(alignment: .leading, spacing: 10) { Text("处理进度") .font(.system(size: 14, weight: .bold)) .foregroundStyle(AppDesign.textPrimary) ForEach(Array(clue.processingTimeline.enumerated()), id: \.offset) { _, item in HStack(alignment: .top, spacing: 10) { Circle() .fill(timelineDotColor(item.status)) .frame(width: 10, height: 10) .padding(.top, 4) VStack(alignment: .leading, spacing: 2) { Text(item.title) .font(.system(size: 13, weight: .semibold)) .foregroundStyle(AppDesign.textPrimary) Text(item.time) .font(.system(size: 12)) .foregroundStyle(AppDesign.textSecondary) } } } } .padding(.top, 4) } .padding(12) .background(Color(hex: 0xFFFBEB), in: RoundedRectangle(cornerRadius: 10)) } private func completionProofSection(_ photoURL: String) -> some View { Button { onPreviewImage(photoURL) } label: { HStack(spacing: 12) { AsyncImage(url: URL(string: photoURL)) { phase in switch phase { case .success(let image): image .resizable() .scaledToFill() default: RoundedRectangle(cornerRadius: 10) .fill(Color(hex: 0xE5E7EB)) } } .frame(width: 72, height: 72) .clipShape(RoundedRectangle(cornerRadius: 10)) VStack(alignment: .leading, spacing: 4) { Text("处理照片已上传") .font(.system(size: 14, weight: .bold)) .foregroundStyle(AppDesign.textPrimary) Text("点击可查看本次处理凭证") .font(.system(size: 12)) .foregroundStyle(AppDesign.textSecondary) } Spacer() } } .buttonStyle(.plain) } private var actionButtons: some View { HStack(spacing: 10) { Button(action: onNavigate) { HStack(spacing: 6) { Image(systemName: "map.fill") Text(clue.actionText) } .font(.system(size: 15, weight: .bold)) .foregroundStyle(.white) .frame(maxWidth: .infinity) .frame(height: 44) .background(AppDesign.primary, in: RoundedRectangle(cornerRadius: 12)) } .buttonStyle(.plain) if clue.type == .red, !clue.completed { Button(action: onShare) { HStack(spacing: 6) { Image(systemName: "square.and.arrow.up") Text("分享") } .font(.system(size: 15, weight: .bold)) .foregroundStyle(.white) .frame(maxWidth: .infinity) .frame(height: 44) .background(AppDesign.success, in: RoundedRectangle(cornerRadius: 12)) } .buttonStyle(.plain) } } } private func compareText(_ text: String) -> some View { Text(text) .font(.system(size: 13)) .foregroundStyle(AppDesign.textSecondary) .lineSpacing(4) .padding(12) .frame(maxWidth: .infinity, alignment: .leading) .background(Color(hex: 0xF3F4F6), in: RoundedRectangle(cornerRadius: 10)) } private func detailSection(_ detail: String) -> some View { HStack(alignment: .top, spacing: 8) { Image(systemName: "doc.text.fill") .font(.system(size: 16, weight: .semibold)) .foregroundStyle(AppDesign.primary) .padding(.top, 2) Text(detail) .font(.system(size: 13)) .foregroundStyle(AppDesign.textPrimary) .lineSpacing(4) } } private func infoLabelRow(label: String, value: String, valueColor: Color = AppDesign.textPrimary) -> some View { HStack { Text(label) .font(.system(size: 13)) .foregroundStyle(AppDesign.textSecondary) Spacer() Text(value) .font(.system(size: 14, weight: .semibold)) .foregroundStyle(valueColor) } } private var headerIconColor: Color { if clue.type == .red, clue.processingStarted, !clue.completed { return Color(hex: 0xF59E0B) } switch clue.type { case .blue: return AppDesign.primary case .green: return AppDesign.success case .red: return AppDesign.danger } } private var statusColor: Color { if clue.completed { return AppDesign.success } if clue.processingStarted && !clue.completed { return Color(hex: 0xF59E0B) } switch clue.type { case .blue: return AppDesign.primary case .green: return AppDesign.success case .red: return AppDesign.danger } } private func timelineDotColor(_ status: String) -> Color { switch status { case "done": return AppDesign.success case "current": return Color(hex: 0xF59E0B) default: return Color(hex: 0xD1D5DB) } } private func callPhone(_ phone: String) { guard let url = URL(string: "tel://\(phone)") else { return } UIApplication.shared.open(url) } } struct WildReportMapURLImagePreviewView: View { let imageURL: String let onClose: () -> Void var body: some View { ZStack { Color.black.ignoresSafeArea() VStack(spacing: 18) { Spacer() AsyncImage(url: URL(string: imageURL)) { phase in switch phase { case .success(let image): image .resizable() .scaledToFit() .frame(maxWidth: 320, maxHeight: 320) .clipShape(RoundedRectangle(cornerRadius: 18)) default: ProgressView() .tint(.white) } } Text("演示环境图片预览") .font(.system(size: 13)) .foregroundStyle(.white.opacity(0.72)) Spacer() Button(action: onClose) { Text("关闭") .font(.system(size: 17, weight: .bold)) .foregroundStyle(.white) .frame(width: 132, height: 44) .background(.white.opacity(0.18), in: Capsule()) } .buttonStyle(.plain) .padding(.bottom, 36) } } } } private struct WildReportMapMediaPreviewView: View { let media: WildReportMapMediaItem let onClose: () -> Void var body: some View { ZStack { Color.black.ignoresSafeArea() VStack(spacing: 18) { Spacer() ZStack { RoundedRectangle(cornerRadius: 18) .fill( LinearGradient( colors: [media.tint.opacity(0.45), media.tint.opacity(0.18)], startPoint: .topLeading, endPoint: .bottomTrailing ) ) .frame(width: 280, height: 280) Image(systemName: media.systemImage) .font(.system(size: 72, weight: .semibold)) .foregroundStyle(.white) if media.isVideo { Image(systemName: "play.circle.fill") .font(.system(size: 56, weight: .semibold)) .foregroundStyle(.white.opacity(0.92)) } } Text(media.title) .font(.system(size: 16, weight: .semibold)) .foregroundStyle(.white) Text(media.isVideo ? "演示环境视频预览" : "演示环境图片预览") .font(.system(size: 13)) .foregroundStyle(.white.opacity(0.72)) Spacer() Button(action: onClose) { Text("关闭") .font(.system(size: 17, weight: .bold)) .foregroundStyle(.white) .frame(width: 132, height: 44) .background(.white.opacity(0.18), in: Capsule()) } .buttonStyle(.plain) .padding(.bottom, 36) } } } } private struct WildReportMapMarkerView: View { let marker: WildReportMapMarker let isSelected: Bool private let dotSize: CGFloat = 14 var body: some View { VStack(spacing: 4) { if marker.kind == .selfLocation { Text(marker.title) .font(.system(size: 11, weight: .semibold)) .foregroundStyle(AppDesign.textPrimary) .padding(.horizontal, 8) .padding(.vertical, 4) .background(.white, in: Capsule()) .shadow(color: .black.opacity(0.08), radius: 4, y: 2) } ZStack { WildReportMapPulseRings(color: markerColor) if isSelected { Circle() .stroke(markerColor.opacity(0.45), lineWidth: 2) .frame(width: 28, height: 28) } Circle() .fill(markerColor) .frame(width: dotSize, height: dotSize) .overlay( Circle() .stroke(.white, lineWidth: 2) ) } .frame(width: 56, height: 56) } .padding(4) } private var markerColor: Color { switch marker.kind { case .selfLocation: return AppDesign.primary case .wildPhotographer: return AppDesign.danger case .processingClue: return Color(hex: 0xF59E0B) case .peerPhotographer: return AppDesign.success } } } private struct WildReportMapPulseRings: View { let color: Color @State private var animatePulse = false var body: some View { ZStack { ForEach(0..<3, id: \.self) { index in Circle() .stroke(color.opacity(0.32), lineWidth: 2) .frame( width: animatePulse ? dotSize + 14 + CGFloat(index * 12) : dotSize + 6, height: animatePulse ? dotSize + 14 + CGFloat(index * 12) : dotSize + 6 ) .opacity(animatePulse ? 0 : 0.72) } } .onAppear { withAnimation(.easeOut(duration: 2).repeatForever(autoreverses: false)) { animatePulse = true } } } private let dotSize: CGFloat = 14 } private struct WildReportMapDot: View { let color: Color let size: CGFloat var body: some View { Circle() .fill(color) .frame(width: size, height: size) .overlay( Circle() .stroke(.white, lineWidth: 2) ) } } #Preview("举报摄影师") { NavigationStack { WildPhotographerReportHomeView() } } #Preview("提交举报") { NavigationStack { WildPhotographerReportSubmitView(homeViewModel: WildPhotographerReportHomeViewModel()) } } #Preview("举报成功") { NavigationStack { WildPhotographerReportSuccessView( result: WildReportSubmitResult( record: WildPhotographerReportMockStore.seedReports[0], imageCount: 3, videoCount: 1 ), homeViewModel: WildPhotographerReportHomeViewModel(), onReturnHome: {} ) } } #Preview("举报详情") { NavigationStack { WildPhotographerReportDetailView( record: WildPhotographerReportMockStore.seedReports[0], homeViewModel: WildPhotographerReportHomeViewModel() ) } } #Preview("附近风险地图") { NavigationStack { WildReportRiskMapView( record: WildPhotographerReportMockStore.seedReports[0], riskPoints: WildPhotographerReportMockStore.seedRiskPoints ) } }