993 lines
37 KiB
Swift
993 lines
37 KiB
Swift
//
|
|
// WildPhotographerReportDetailViews
|
|
// suixinkan
|
|
//
|
|
// Created by Codex on 2026/7/1.
|
|
//
|
|
|
|
import MapKit
|
|
import SwiftUI
|
|
import UIKit
|
|
|
|
struct WildPhotographerReportDetailView: View {
|
|
@StateObject private var viewModel: WildPhotographerReportDetailViewModel
|
|
@ObservedObject var homeViewModel: WildPhotographerReportHomeViewModel
|
|
@Environment(\.dismiss) private var dismiss
|
|
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
|
@EnvironmentObject private var toastCenter: ToastCenter
|
|
@State private var previewImageURL: String?
|
|
|
|
init(record: WildReportRecord, homeViewModel: WildPhotographerReportHomeViewModel) {
|
|
self.homeViewModel = homeViewModel
|
|
_viewModel = StateObject(
|
|
wrappedValue: WildPhotographerReportDetailViewModel(record: record, homeViewModel: homeViewModel)
|
|
)
|
|
}
|
|
|
|
private var supplementaryItems: [WildReportSupplementaryEvidence] {
|
|
homeViewModel.supplementaryEvidences(for: viewModel.record.id)
|
|
}
|
|
|
|
var body: some View {
|
|
ScrollView {
|
|
VStack(spacing: 12) {
|
|
summaryCard
|
|
reportInfoCard
|
|
if viewModel.showHandlerInfo, let handlerInfo = viewModel.handlerInfo {
|
|
handlerInfoCard(handlerInfo)
|
|
}
|
|
evidenceCard
|
|
if !supplementaryItems.isEmpty {
|
|
supplementaryEvidenceCard
|
|
}
|
|
timelineCard
|
|
}
|
|
.frame(maxWidth: contentMaxWidth)
|
|
.padding(.horizontal, 18)
|
|
.padding(.top, 14)
|
|
.padding(.bottom, 120)
|
|
}
|
|
.navigationTitle("举报详情")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.background(Color(hex: 0xF8FAFF).ignoresSafeArea())
|
|
.safeAreaInset(edge: .bottom) {
|
|
bottomActionBar
|
|
}
|
|
.onChange(of: viewModel.actionMessage) { message in
|
|
guard let message else { return }
|
|
toastCenter.show(message)
|
|
viewModel.actionMessage = nil
|
|
}
|
|
.fullScreenCover(item: Binding(
|
|
get: { previewImageURL.map { WildReportPreviewImageURL(url: $0) } },
|
|
set: { previewImageURL = $0?.url }
|
|
)) { item in
|
|
WildReportMapURLImagePreviewView(imageURL: item.url) {
|
|
previewImageURL = nil
|
|
}
|
|
}
|
|
}
|
|
|
|
private var summaryCard: some View {
|
|
VStack(alignment: .leading, spacing: 16) {
|
|
HStack(alignment: .top, spacing: 14) {
|
|
Image(systemName: "exclamationmark.shield.fill")
|
|
.font(.system(size: 30, weight: .semibold))
|
|
.foregroundStyle(.white)
|
|
.frame(width: 62, height: 62)
|
|
.background(AppDesign.primary, in: RoundedRectangle(cornerRadius: 16))
|
|
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Text(viewModel.heroTitle)
|
|
.font(.system(size: 23, weight: .heavy))
|
|
.foregroundStyle(AppDesign.textPrimary)
|
|
.lineLimit(2)
|
|
|
|
HStack(spacing: 6) {
|
|
Image(systemName: "mappin.circle.fill")
|
|
.font(.system(size: 12, weight: .semibold))
|
|
Text(viewModel.scenicAreaName)
|
|
.font(.system(size: 14, weight: .bold))
|
|
}
|
|
.foregroundStyle(AppDesign.primary)
|
|
.padding(.horizontal, 10)
|
|
.frame(height: 27)
|
|
.background(AppDesign.primarySoft, in: Capsule())
|
|
}
|
|
|
|
Spacer(minLength: 0)
|
|
|
|
Button {
|
|
viewModel.shareReport()
|
|
} label: {
|
|
VStack(spacing: 4) {
|
|
Image(systemName: "square.and.arrow.up")
|
|
.font(.system(size: 17, weight: .semibold))
|
|
Text("分享")
|
|
.font(.system(size: 11, weight: .bold))
|
|
}
|
|
.foregroundStyle(AppDesign.primary)
|
|
.frame(width: 48, height: 48)
|
|
.background(AppDesign.primarySoft, in: RoundedRectangle(cornerRadius: 14))
|
|
}
|
|
.buttonStyle(.plain)
|
|
.accessibilityLabel("分享举报详情")
|
|
}
|
|
|
|
Divider()
|
|
|
|
HStack(spacing: 8) {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text("举报编号")
|
|
.font(.system(size: 14))
|
|
.foregroundStyle(AppDesign.textSecondary)
|
|
Text(viewModel.record.id)
|
|
.font(.system(size: 19, weight: .semibold))
|
|
.foregroundStyle(AppDesign.textPrimary)
|
|
}
|
|
|
|
Spacer()
|
|
|
|
Text(viewModel.displayStatusText)
|
|
.font(.system(size: 16, weight: .bold))
|
|
.foregroundStyle(viewModel.displayStatusColor)
|
|
.padding(.horizontal, 14)
|
|
.frame(height: 38)
|
|
.background(viewModel.displayStatusColor.opacity(0.12), in: Capsule())
|
|
}
|
|
}
|
|
.padding(18)
|
|
.background(.white, in: RoundedRectangle(cornerRadius: 18))
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 18)
|
|
.stroke(AppDesign.border, lineWidth: 1)
|
|
)
|
|
}
|
|
|
|
private var reportInfoCard: some View {
|
|
VStack(spacing: 0) {
|
|
reportInfoRow(title: "举报类型", value: viewModel.record.reportType.rawValue)
|
|
Divider().padding(.leading, 18)
|
|
reportInfoRow(title: "举报说明", value: viewModel.descriptionText)
|
|
Divider().padding(.leading, 18)
|
|
reportInfoRow(title: "微信号/手机号", value: viewModel.contactText)
|
|
}
|
|
.background(.white, in: RoundedRectangle(cornerRadius: 18))
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 18)
|
|
.stroke(AppDesign.border, lineWidth: 1)
|
|
)
|
|
}
|
|
|
|
private func reportInfoRow(title: String, value: String) -> some View {
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
Text(title)
|
|
.font(.system(size: 13))
|
|
.foregroundStyle(AppDesign.textSecondary)
|
|
Text(value)
|
|
.font(.system(size: 15, weight: .medium))
|
|
.foregroundStyle(AppDesign.textPrimary)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
.padding(.horizontal, 18)
|
|
.padding(.vertical, 14)
|
|
}
|
|
|
|
private func handlerInfoCard(_ info: WildReportHandlerInfo) -> some View {
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
Text("处理信息")
|
|
.font(.system(size: 18, weight: .bold))
|
|
.foregroundStyle(AppDesign.textPrimary)
|
|
.padding(.horizontal, 18)
|
|
.padding(.top, 16)
|
|
.padding(.bottom, 12)
|
|
|
|
handlerInfoRow(label: "处理人", value: info.handlerName)
|
|
Divider().padding(.leading, 18)
|
|
handlerInfoPhoneRow(info: info)
|
|
Divider().padding(.leading, 18)
|
|
handlerInfoRow(label: "处理时间", value: info.processingAt)
|
|
Divider().padding(.leading, 18)
|
|
handlerInfoRow(label: "处理完成时间", value: info.processedAt)
|
|
Divider().padding(.leading, 18)
|
|
handlerInfoMultilineRow(label: "处理意见", value: info.processOpinion)
|
|
|
|
if !info.completionMedia.isEmpty {
|
|
Divider().padding(.leading, 18)
|
|
handlerCompletionMediaSection(info: info)
|
|
}
|
|
}
|
|
.background(.white, in: RoundedRectangle(cornerRadius: 18))
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 18)
|
|
.stroke(AppDesign.border, lineWidth: 1)
|
|
)
|
|
}
|
|
|
|
private func handlerInfoRow(label: String, value: String) -> some View {
|
|
HStack {
|
|
Text(label)
|
|
.font(.system(size: 13))
|
|
.foregroundStyle(AppDesign.textSecondary)
|
|
Spacer()
|
|
Text(value)
|
|
.font(.system(size: 14, weight: .semibold))
|
|
.foregroundStyle(AppDesign.textPrimary)
|
|
}
|
|
.padding(.horizontal, 18)
|
|
.padding(.vertical, 14)
|
|
}
|
|
|
|
private func handlerInfoMultilineRow(label: String, value: String) -> some View {
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
Text(label)
|
|
.font(.system(size: 13))
|
|
.foregroundStyle(AppDesign.textSecondary)
|
|
Text(value)
|
|
.font(.system(size: 14, weight: .medium))
|
|
.foregroundStyle(AppDesign.textPrimary)
|
|
.lineSpacing(4)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
.padding(.horizontal, 18)
|
|
.padding(.vertical, 14)
|
|
}
|
|
|
|
private func handlerInfoPhoneRow(info: WildReportHandlerInfo) -> some View {
|
|
HStack {
|
|
Text("处理人电话")
|
|
.font(.system(size: 13))
|
|
.foregroundStyle(AppDesign.textSecondary)
|
|
Spacer()
|
|
Button {
|
|
guard let url = URL(string: "tel://\(info.handlerPhone)") else { return }
|
|
UIApplication.shared.open(url)
|
|
} label: {
|
|
HStack(spacing: 6) {
|
|
Text(info.maskedHandlerPhone)
|
|
.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(.horizontal, 18)
|
|
.padding(.vertical, 14)
|
|
}
|
|
|
|
private func handlerCompletionMediaSection(info: WildReportHandlerInfo) -> some View {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
HStack {
|
|
HStack(spacing: 8) {
|
|
Image(systemName: "photo.on.rectangle.angled")
|
|
.font(.system(size: 16, weight: .semibold))
|
|
.foregroundStyle(AppDesign.primary)
|
|
Text("处理凭证")
|
|
.font(.system(size: 15, weight: .bold))
|
|
.foregroundStyle(AppDesign.textPrimary)
|
|
}
|
|
Spacer()
|
|
Text(info.completionMediaSummary)
|
|
.font(.system(size: 13, weight: .medium))
|
|
.foregroundStyle(AppDesign.textSecondary)
|
|
}
|
|
.padding(.horizontal, 18)
|
|
.padding(.top, 14)
|
|
|
|
ScrollView(.horizontal, showsIndicators: false) {
|
|
HStack(spacing: 10) {
|
|
ForEach(info.completionMedia) { item in
|
|
Button {
|
|
if item.kind == .image {
|
|
previewImageURL = item.coverURL
|
|
} else {
|
|
viewModel.showMediaPreview(kind: "处理凭证视频")
|
|
}
|
|
} label: {
|
|
handlerCompletionMediaThumbnail(item)
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|
|
.padding(.horizontal, 18)
|
|
}
|
|
.padding(.bottom, 16)
|
|
}
|
|
}
|
|
|
|
private func handlerCompletionMediaThumbnail(_ item: WildReportCompletionMediaItem) -> some View {
|
|
ZStack {
|
|
AsyncImage(url: URL(string: item.coverURL)) { 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))
|
|
|
|
if item.kind == .video {
|
|
Color.black.opacity(0.28)
|
|
.clipShape(RoundedRectangle(cornerRadius: 10))
|
|
|
|
Image(systemName: "play.circle.fill")
|
|
.font(.system(size: 28, weight: .semibold))
|
|
.foregroundStyle(.white)
|
|
|
|
if let duration = item.duration, !duration.isEmpty {
|
|
VStack {
|
|
Spacer()
|
|
HStack {
|
|
Spacer()
|
|
Text(duration)
|
|
.font(.system(size: 11, weight: .semibold))
|
|
.foregroundStyle(.white)
|
|
.padding(.horizontal, 6)
|
|
.padding(.vertical, 3)
|
|
.background(.black.opacity(0.45), in: Capsule())
|
|
.padding(6)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.frame(width: 108, height: 108)
|
|
}
|
|
|
|
private var supplementaryEvidenceCard: some View {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
Text("补充证据记录")
|
|
.font(.system(size: 18, weight: .bold))
|
|
.foregroundStyle(AppDesign.textPrimary)
|
|
.padding(.horizontal, 18)
|
|
.padding(.top, 16)
|
|
|
|
ForEach(Array(supplementaryItems.enumerated()), id: \.element.id) { index, item in
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
HStack {
|
|
Text(item.submitTime)
|
|
.font(.system(size: 13, weight: .semibold))
|
|
.foregroundStyle(AppDesign.primary)
|
|
Spacer()
|
|
Text("第\(supplementaryItems.count - index)次补充")
|
|
.font(.system(size: 12))
|
|
.foregroundStyle(AppDesign.textSecondary)
|
|
}
|
|
Text(item.summaryText)
|
|
.font(.system(size: 14))
|
|
.foregroundStyle(AppDesign.textPrimary)
|
|
.lineSpacing(4)
|
|
}
|
|
.padding(.horizontal, 18)
|
|
.padding(.vertical, 12)
|
|
|
|
if index < supplementaryItems.count - 1 {
|
|
Divider().padding(.leading, 18)
|
|
}
|
|
}
|
|
.padding(.bottom, 8)
|
|
}
|
|
.background(.white, in: RoundedRectangle(cornerRadius: 18))
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 18)
|
|
.stroke(AppDesign.border, lineWidth: 1)
|
|
)
|
|
}
|
|
|
|
private var evidenceCard: some View {
|
|
VStack(spacing: 0) {
|
|
if !viewModel.reporterMediaItems.isEmpty {
|
|
reporterMaterialsSection
|
|
Divider().padding(.horizontal, 18)
|
|
}
|
|
|
|
evidenceSectionHeader(icon: "mappin.circle.fill", title: "位置", value: viewModel.detailAddress) {
|
|
viewModel.showLocationPreview()
|
|
}
|
|
locationPreview
|
|
}
|
|
.padding(.vertical, 2)
|
|
.background(.white, in: RoundedRectangle(cornerRadius: 18))
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 18)
|
|
.stroke(AppDesign.border, lineWidth: 1)
|
|
)
|
|
}
|
|
|
|
private var reporterMaterialsSection: some View {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
Button {
|
|
viewModel.showMediaPreview(kind: "举报人材料")
|
|
} label: {
|
|
HStack {
|
|
HStack(spacing: 8) {
|
|
Image(systemName: "photo.on.rectangle.angled")
|
|
.font(.system(size: 16, weight: .semibold))
|
|
.foregroundStyle(AppDesign.primary)
|
|
Text("举报人材料")
|
|
.font(.system(size: 15, weight: .bold))
|
|
.foregroundStyle(AppDesign.textPrimary)
|
|
}
|
|
Spacer()
|
|
Text(viewModel.reporterMediaSummary)
|
|
.font(.system(size: 13, weight: .medium))
|
|
.foregroundStyle(AppDesign.textSecondary)
|
|
Image(systemName: "chevron.right")
|
|
.font(.system(size: 14, weight: .semibold))
|
|
.foregroundStyle(Color(hex: 0xB6BECA))
|
|
}
|
|
}
|
|
.buttonStyle(.plain)
|
|
.padding(.horizontal, 18)
|
|
.padding(.top, 14)
|
|
|
|
ScrollView(.horizontal, showsIndicators: false) {
|
|
HStack(spacing: 10) {
|
|
ForEach(viewModel.reporterMediaItems) { item in
|
|
Button {
|
|
viewModel.showReporterMediaPreview(item)
|
|
} label: {
|
|
reporterMediaThumbnail(item)
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|
|
.padding(.horizontal, 18)
|
|
}
|
|
.padding(.bottom, 16)
|
|
}
|
|
}
|
|
|
|
private func reporterMediaThumbnail(_ item: WildReportReporterMediaItem) -> some View {
|
|
Group {
|
|
switch item {
|
|
case .image(let index):
|
|
WildReportEvidenceImagePreview(index: index)
|
|
case .video:
|
|
WildReportEvidenceVideoPreview()
|
|
}
|
|
}
|
|
.frame(width: 108, height: 108)
|
|
.clipShape(RoundedRectangle(cornerRadius: 10))
|
|
}
|
|
|
|
private func evidenceSectionHeader(icon: String, title: String, value: String, action: @escaping () -> Void) -> some View {
|
|
Button(action: action) {
|
|
HStack(spacing: 14) {
|
|
Image(systemName: icon)
|
|
.font(.system(size: 22, weight: .semibold))
|
|
.foregroundStyle(AppDesign.primary)
|
|
.frame(width: 50, height: 50)
|
|
.background(AppDesign.primarySoft, in: RoundedRectangle(cornerRadius: 12))
|
|
|
|
Text(title)
|
|
.font(.system(size: 20, weight: .bold))
|
|
.foregroundStyle(AppDesign.textPrimary)
|
|
|
|
Spacer()
|
|
|
|
Text(value)
|
|
.font(.system(size: 18, weight: .medium))
|
|
.foregroundStyle(AppDesign.textSecondary)
|
|
.lineLimit(1)
|
|
|
|
Image(systemName: "chevron.right")
|
|
.font(.system(size: 16, weight: .semibold))
|
|
.foregroundStyle(Color(hex: 0xB6BECA))
|
|
}
|
|
.padding(.horizontal, 18)
|
|
.padding(.top, 14)
|
|
.padding(.bottom, 10)
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
|
|
private var locationPreview: some View {
|
|
WildReportEvidenceMapPreview(
|
|
scenicName: viewModel.scenicAreaName,
|
|
detailAddress: viewModel.detailAddress
|
|
)
|
|
.frame(height: 98)
|
|
.clipShape(RoundedRectangle(cornerRadius: 10))
|
|
.padding(.horizontal, 18)
|
|
.padding(.bottom, 16)
|
|
}
|
|
|
|
private var timelineCard: some View {
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
ForEach(Array(viewModel.progressSteps.enumerated()), id: \.element.id) { index, step in
|
|
timelineRow(step, isLast: index == viewModel.progressSteps.count - 1)
|
|
}
|
|
}
|
|
.padding(.horizontal, 18)
|
|
.padding(.vertical, 18)
|
|
.background(.white, in: RoundedRectangle(cornerRadius: 18))
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 18)
|
|
.stroke(AppDesign.border, lineWidth: 1)
|
|
)
|
|
}
|
|
|
|
private func timelineRow(_ step: WildReportProgressStep, isLast: Bool) -> some View {
|
|
HStack(alignment: .top, spacing: 12) {
|
|
VStack(spacing: 0) {
|
|
timelineNode(for: step.state)
|
|
if !isLast {
|
|
Rectangle()
|
|
.fill(step.state == .pending ? Color(hex: 0xD1D5DB).opacity(0.8) : AppDesign.primary)
|
|
.frame(width: step.state == .pending ? 1 : 2)
|
|
.frame(maxHeight: .infinity)
|
|
.padding(.vertical, 4)
|
|
}
|
|
}
|
|
.frame(width: 24)
|
|
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text(step.title)
|
|
.font(.system(size: 16, weight: step.state == .current ? .bold : .semibold))
|
|
.foregroundStyle(step.state == .pending ? Color(hex: 0x9CA3AF) : AppDesign.textPrimary)
|
|
if step.state != .pending && !step.timeText.isEmpty {
|
|
Text(step.timeText)
|
|
.font(.system(size: 13))
|
|
.foregroundStyle(step.state == .current ? AppDesign.primary : AppDesign.textSecondary)
|
|
}
|
|
}
|
|
.padding(.bottom, isLast ? 0 : 18)
|
|
|
|
Spacer(minLength: 0)
|
|
}
|
|
}
|
|
|
|
private func timelineNode(for state: WildReportProgressStepState) -> some View {
|
|
ZStack {
|
|
switch state {
|
|
case .completed:
|
|
Circle()
|
|
.fill(AppDesign.primary)
|
|
.frame(width: 24, height: 24)
|
|
Image(systemName: "checkmark")
|
|
.font(.system(size: 11, weight: .bold))
|
|
.foregroundStyle(.white)
|
|
case .current:
|
|
Circle()
|
|
.stroke(AppDesign.primary, lineWidth: 2)
|
|
.frame(width: 24, height: 24)
|
|
Circle()
|
|
.fill(AppDesign.primary)
|
|
.frame(width: 10, height: 10)
|
|
case .pending:
|
|
Circle()
|
|
.stroke(Color(hex: 0xD1D5DB), lineWidth: 2)
|
|
.frame(width: 24, height: 24)
|
|
}
|
|
}
|
|
}
|
|
|
|
private var bottomActionBar: some View {
|
|
HStack(spacing: 12) {
|
|
NavigationLink {
|
|
WildReportSupplementEvidenceView(
|
|
reportID: viewModel.record.id,
|
|
homeViewModel: homeViewModel
|
|
)
|
|
} label: {
|
|
HStack(spacing: 8) {
|
|
Image(systemName: "doc.badge.plus")
|
|
.font(.system(size: 18, weight: .semibold))
|
|
Text("补充证据")
|
|
.font(.system(size: 17, weight: .bold))
|
|
}
|
|
.foregroundStyle(AppDesign.primary)
|
|
.frame(maxWidth: .infinity)
|
|
.frame(height: 52)
|
|
.background(.white, in: RoundedRectangle(cornerRadius: 14))
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 14)
|
|
.stroke(AppDesign.primary, lineWidth: 1.2)
|
|
)
|
|
}
|
|
.buttonStyle(.plain)
|
|
|
|
NavigationLink {
|
|
WildReportRiskMapView(
|
|
record: viewModel.record,
|
|
riskPoints: homeViewModel.riskPoints
|
|
)
|
|
} label: {
|
|
HStack(spacing: 8) {
|
|
Image(systemName: "map.fill")
|
|
.font(.system(size: 18, weight: .semibold))
|
|
Text("查看附近风险地图")
|
|
.font(.system(size: 17, weight: .bold))
|
|
.lineLimit(1)
|
|
.minimumScaleFactor(0.8)
|
|
}
|
|
.foregroundStyle(.white)
|
|
.frame(maxWidth: .infinity)
|
|
.frame(height: 52)
|
|
.background(AppDesign.primary, in: RoundedRectangle(cornerRadius: 14))
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.padding(.top, 10)
|
|
.padding(.bottom, 12)
|
|
.background(.white.opacity(0.96))
|
|
.overlay(alignment: .top) {
|
|
Rectangle()
|
|
.fill(Color.black.opacity(0.06))
|
|
.frame(height: 0.5)
|
|
}
|
|
}
|
|
|
|
private var contentMaxWidth: CGFloat {
|
|
horizontalSizeClass == .regular ? 760 : .infinity
|
|
}
|
|
}
|
|
|
|
private struct WildReportEvidenceImagePreview: View {
|
|
let index: Int
|
|
|
|
private var gradientColors: [Color] {
|
|
switch index {
|
|
case 1:
|
|
return [Color(hex: 0xD7E8F8), Color(hex: 0x7E9CB4)]
|
|
case 2:
|
|
return [Color(hex: 0xBFD9EE), Color(hex: 0x435B70)]
|
|
default:
|
|
return [Color(hex: 0xC7DFEC), Color(hex: 0x2D3D4B)]
|
|
}
|
|
}
|
|
|
|
var body: some View {
|
|
ZStack(alignment: .bottomTrailing) {
|
|
LinearGradient(
|
|
colors: gradientColors,
|
|
startPoint: .topLeading,
|
|
endPoint: .bottomTrailing
|
|
)
|
|
|
|
Image(systemName: "mountain.2.fill")
|
|
.font(.system(size: 52, weight: .regular))
|
|
.foregroundStyle(.white.opacity(0.32))
|
|
.offset(x: -16, y: -8)
|
|
|
|
Image(systemName: "camera.fill")
|
|
.font(.system(size: 22, weight: .semibold))
|
|
.foregroundStyle(.white.opacity(0.9))
|
|
.offset(x: -10, y: -18)
|
|
|
|
Image(systemName: "person.fill")
|
|
.font(.system(size: 44, weight: .semibold))
|
|
.foregroundStyle(Color(hex: 0x101827).opacity(0.78))
|
|
.offset(x: -24 + CGFloat(index * 12), y: 10)
|
|
}
|
|
.clipped()
|
|
}
|
|
}
|
|
|
|
private struct WildReportEvidenceVideoPreview: View {
|
|
var body: some View {
|
|
ZStack {
|
|
WildReportEvidenceImagePreview(index: 2)
|
|
|
|
Circle()
|
|
.stroke(.white, lineWidth: 2.5)
|
|
.frame(width: 42, height: 42)
|
|
.background(.black.opacity(0.08), in: Circle())
|
|
|
|
Image(systemName: "play.fill")
|
|
.font(.system(size: 18, weight: .bold))
|
|
.foregroundStyle(.white)
|
|
.offset(x: 2)
|
|
|
|
Text("00:32")
|
|
.font(.system(size: 14, weight: .semibold))
|
|
.foregroundStyle(.white)
|
|
.shadow(color: .black.opacity(0.25), radius: 4, y: 2)
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomTrailing)
|
|
.padding(10)
|
|
}
|
|
.clipped()
|
|
}
|
|
}
|
|
|
|
private struct WildReportEvidenceMapPreview: View {
|
|
let scenicName: String
|
|
let detailAddress: String
|
|
|
|
var body: some View {
|
|
GeometryReader { proxy in
|
|
ZStack {
|
|
LinearGradient(
|
|
colors: [Color(hex: 0xE1F4D8), Color(hex: 0xBDE6C7)],
|
|
startPoint: .topLeading,
|
|
endPoint: .bottomTrailing
|
|
)
|
|
|
|
mapPath(in: proxy.size)
|
|
.stroke(
|
|
Color(hex: 0x65A6D7).opacity(0.75),
|
|
style: StrokeStyle(lineWidth: 1.4, dash: [5, 5])
|
|
)
|
|
|
|
mapPathSecondary(in: proxy.size)
|
|
.stroke(
|
|
Color.white.opacity(0.88),
|
|
style: StrokeStyle(lineWidth: 5, lineCap: .round)
|
|
)
|
|
mapPathSecondary(in: proxy.size)
|
|
.stroke(Color(hex: 0x99C59C), lineWidth: 1)
|
|
|
|
Text(detailAddress.replacingOccurrences(of: "附近", with: ""))
|
|
.font(.system(size: 13, weight: .semibold))
|
|
.foregroundStyle(Color(hex: 0x64748B))
|
|
.position(x: proxy.size.width * 0.47, y: proxy.size.height * 0.32)
|
|
|
|
Text(scenicName)
|
|
.font(.system(size: 13, weight: .semibold))
|
|
.foregroundStyle(Color(hex: 0x418C48))
|
|
.position(x: proxy.size.width * 0.78, y: proxy.size.height * 0.58)
|
|
|
|
ZStack {
|
|
Circle()
|
|
.fill(Color(hex: 0xEF4444).opacity(0.18))
|
|
.frame(width: 64, height: 64)
|
|
Circle()
|
|
.fill(Color(hex: 0xEF4444).opacity(0.22))
|
|
.frame(width: 44, height: 44)
|
|
Image(systemName: "mappin.circle.fill")
|
|
.font(.system(size: 36, weight: .semibold))
|
|
.foregroundStyle(Color(hex: 0xEF4444), .white)
|
|
}
|
|
.position(x: proxy.size.width * 0.53, y: proxy.size.height * 0.54)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func mapPath(in size: CGSize) -> Path {
|
|
var path = Path()
|
|
path.move(to: CGPoint(x: size.width * 0.05, y: size.height * 0.32))
|
|
path.addCurve(
|
|
to: CGPoint(x: size.width * 0.95, y: size.height * 0.22),
|
|
control1: CGPoint(x: size.width * 0.28, y: size.height * 0.62),
|
|
control2: CGPoint(x: size.width * 0.56, y: size.height * 0.02)
|
|
)
|
|
return path
|
|
}
|
|
|
|
private func mapPathSecondary(in size: CGSize) -> Path {
|
|
var path = Path()
|
|
path.move(to: CGPoint(x: size.width * 0.08, y: size.height * 0.72))
|
|
path.addCurve(
|
|
to: CGPoint(x: size.width * 0.88, y: size.height * 0.78),
|
|
control1: CGPoint(x: size.width * 0.30, y: size.height * 0.42),
|
|
control2: CGPoint(x: size.width * 0.56, y: size.height * 0.92)
|
|
)
|
|
return path
|
|
}
|
|
}
|
|
|
|
struct WildReportSupplementEvidenceView: View {
|
|
let reportID: String
|
|
@ObservedObject var homeViewModel: WildPhotographerReportHomeViewModel
|
|
|
|
@StateObject private var viewModel: WildReportSupplementEvidenceViewModel
|
|
@Environment(\.dismiss) private var dismiss
|
|
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
|
@EnvironmentObject private var toastCenter: ToastCenter
|
|
|
|
init(reportID: String, homeViewModel: WildPhotographerReportHomeViewModel) {
|
|
self.reportID = reportID
|
|
self.homeViewModel = homeViewModel
|
|
_viewModel = StateObject(
|
|
wrappedValue: WildReportSupplementEvidenceViewModel(
|
|
reportID: reportID,
|
|
homeViewModel: homeViewModel
|
|
)
|
|
)
|
|
}
|
|
|
|
var body: some View {
|
|
ScrollView {
|
|
VStack(spacing: 14) {
|
|
supplementTextCard
|
|
supplementUploadCard(
|
|
icon: "photo.fill",
|
|
title: "上传图片",
|
|
subtitle: "支持 JPG、PNG 格式,最多 9 张",
|
|
attachments: $viewModel.images,
|
|
maxCount: 9,
|
|
onAdd: viewModel.addImage
|
|
)
|
|
supplementUploadCard(
|
|
icon: "video.fill",
|
|
title: "上传视频",
|
|
subtitle: "支持 MP4 格式,最多 3 段",
|
|
attachments: $viewModel.videos,
|
|
maxCount: 3,
|
|
onAdd: viewModel.addVideo
|
|
)
|
|
submitButton
|
|
}
|
|
.frame(maxWidth: contentMaxWidth)
|
|
.padding(.horizontal, 18)
|
|
.padding(.top, 16)
|
|
.padding(.bottom, 28)
|
|
}
|
|
.navigationTitle("补充证据")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.background(Color(hex: 0xF8FAFF).ignoresSafeArea())
|
|
.onChange(of: viewModel.validationMessage) { message in
|
|
guard let message else { return }
|
|
toastCenter.show(message)
|
|
viewModel.validationMessage = nil
|
|
}
|
|
.onChange(of: viewModel.didSubmit) { didSubmit in
|
|
guard didSubmit else { return }
|
|
dismiss()
|
|
}
|
|
.onChange(of: viewModel.text) { newValue in
|
|
viewModel.updateText(newValue)
|
|
}
|
|
}
|
|
|
|
private var supplementTextCard: some View {
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
Text("补充说明")
|
|
.font(.system(size: 17, weight: .bold))
|
|
.foregroundStyle(AppDesign.textPrimary)
|
|
|
|
ZStack(alignment: .topLeading) {
|
|
TextEditor(text: $viewModel.text)
|
|
.font(.system(size: 15))
|
|
.foregroundStyle(AppDesign.textPrimary)
|
|
.padding(.horizontal, 8)
|
|
.padding(.vertical, 10)
|
|
.frame(minHeight: 112)
|
|
.scrollContentBackground(.hidden)
|
|
|
|
if viewModel.text.isEmpty {
|
|
Text("请补充新的线索说明...")
|
|
.font(.system(size: 15))
|
|
.foregroundStyle(Color(hex: 0x9CA3AF))
|
|
.padding(.horizontal, 14)
|
|
.padding(.vertical, 18)
|
|
.allowsHitTesting(false)
|
|
}
|
|
|
|
Text("\(viewModel.text.count)/500")
|
|
.font(.system(size: 14))
|
|
.foregroundStyle(Color(hex: 0x6B7280))
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomTrailing)
|
|
.padding(.trailing, 12)
|
|
.padding(.bottom, 12)
|
|
.allowsHitTesting(false)
|
|
}
|
|
.background(Color(hex: 0xFBFCFE), in: RoundedRectangle(cornerRadius: 12))
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 12)
|
|
.stroke(AppDesign.inputBorder, lineWidth: 1)
|
|
)
|
|
}
|
|
.padding(16)
|
|
.background(.white, in: RoundedRectangle(cornerRadius: 16))
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 16)
|
|
.stroke(AppDesign.border, lineWidth: 1)
|
|
)
|
|
}
|
|
|
|
private func supplementUploadCard(
|
|
icon: String,
|
|
title: String,
|
|
subtitle: String,
|
|
attachments: Binding<[WildReportAttachment]>,
|
|
maxCount: Int,
|
|
onAdd: @escaping () -> Void
|
|
) -> some View {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
HStack(spacing: 10) {
|
|
Image(systemName: icon)
|
|
.font(.system(size: 22, weight: .semibold))
|
|
.foregroundStyle(AppDesign.primary)
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text(title)
|
|
.font(.system(size: 16, weight: .bold))
|
|
.foregroundStyle(AppDesign.textPrimary)
|
|
Text(subtitle)
|
|
.font(.system(size: 12))
|
|
.foregroundStyle(AppDesign.textSecondary)
|
|
}
|
|
}
|
|
|
|
ScrollView(.horizontal, showsIndicators: false) {
|
|
HStack(spacing: 10) {
|
|
ForEach(attachments.wrappedValue) { attachment in
|
|
supplementAttachmentTile(attachment.title) {
|
|
attachments.wrappedValue.removeAll { $0.id == attachment.id }
|
|
}
|
|
}
|
|
|
|
if attachments.wrappedValue.count < maxCount {
|
|
supplementAddTile(action: onAdd)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.padding(16)
|
|
.background(.white, in: RoundedRectangle(cornerRadius: 16))
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 16)
|
|
.stroke(AppDesign.border, lineWidth: 1)
|
|
)
|
|
}
|
|
|
|
private func supplementAttachmentTile(_ title: String, onRemove: @escaping () -> Void) -> some View {
|
|
ZStack(alignment: .topTrailing) {
|
|
RoundedRectangle(cornerRadius: 10)
|
|
.fill(AppDesign.primarySoft)
|
|
.frame(width: 72, height: 72)
|
|
.overlay {
|
|
Text(title)
|
|
.font(.system(size: 11, weight: .semibold))
|
|
.foregroundStyle(AppDesign.primary)
|
|
.multilineTextAlignment(.center)
|
|
.padding(6)
|
|
}
|
|
|
|
Button(action: onRemove) {
|
|
Image(systemName: "xmark.circle.fill")
|
|
.font(.system(size: 18))
|
|
.foregroundStyle(Color(hex: 0x6B7280))
|
|
.background(Circle().fill(.white))
|
|
}
|
|
.offset(x: 6, y: -6)
|
|
}
|
|
}
|
|
|
|
private func supplementAddTile(action: @escaping () -> Void) -> some View {
|
|
Button(action: action) {
|
|
RoundedRectangle(cornerRadius: 10)
|
|
.stroke(AppDesign.primary, style: StrokeStyle(lineWidth: 1.2, dash: [4, 4]))
|
|
.frame(width: 72, height: 72)
|
|
.overlay {
|
|
Image(systemName: "plus")
|
|
.font(.system(size: 22, weight: .semibold))
|
|
.foregroundStyle(AppDesign.primary)
|
|
}
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
|
|
private var submitButton: some View {
|
|
Button {
|
|
viewModel.submit()
|
|
} label: {
|
|
Text("提交补充证据")
|
|
.font(.system(size: 18, weight: .bold))
|
|
.foregroundStyle(.white)
|
|
.frame(maxWidth: .infinity)
|
|
.frame(height: 52)
|
|
.background(AppDesign.primary, in: RoundedRectangle(cornerRadius: 14))
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
|
|
private var contentMaxWidth: CGFloat {
|
|
horizontalSizeClass == .regular ? 760 : .infinity
|
|
}
|
|
}
|
|
|