Files
suixinkan_ios_new/suixinkan/Features/WildPhotographerReport/Views/WildPhotographerReportMapViews.swift

804 lines
28 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// 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
)
}
}