804 lines
28 KiB
Swift
804 lines
28 KiB
Swift
//
|
||
// 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
|
||
)
|
||
}
|
||
}
|