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

817 lines
30 KiB
Swift
Raw 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.

//
// WildPhotographerReportSubmitViews
// suixinkan
//
// Created by Codex on 2026/7/1.
//
import MapKit
import SwiftUI
import UIKit
struct WildPhotographerReportSubmitView: View {
@StateObject private var viewModel: WildPhotographerReportSubmitViewModel
private let homeViewModel: WildPhotographerReportHomeViewModel
@Environment(\.dismiss) private var dismiss
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
@EnvironmentObject private var toastCenter: ToastCenter
@State private var shouldReturnToHome = false
init(homeViewModel: WildPhotographerReportHomeViewModel) {
self.homeViewModel = homeViewModel
_viewModel = StateObject(wrappedValue: WildPhotographerReportSubmitViewModel(homeViewModel: homeViewModel))
}
var body: some View {
ZStack(alignment: .bottom) {
Group {
switch viewModel.pageState {
case .normal:
formContent
case .loading:
loadingContent
case .empty:
stateContent(
title: "暂无定位信息",
systemImage: "location.slash",
message: "请先确认举报位置后再提交举报。",
actionTitle: "恢复演示数据"
) {
withAnimation(.easeInOut(duration: 0.2)) {
viewModel.restoreDemoDataFromEmpty()
}
}
case .error:
stateContent(
title: "提交页加载失败",
systemImage: "wifi.exclamationmark",
message: "证据上传组件暂时不可用,请稍后重试。",
actionTitle: "重新加载"
) {
withAnimation(.easeInOut(duration: 0.2)) {
viewModel.retryLoadAfterError()
}
}
}
}
.animation(.easeInOut(duration: 0.22), value: viewModel.pageState)
}
.navigationTitle("提交举报")
.navigationBarTitleDisplayMode(.inline)
#if DEBUG
.toolbar {
WildReportStateToolbar(selection: $viewModel.pageState)
}
#endif
.background(Color(hex: 0xF8FAFF).ignoresSafeArea())
.onChange(of: viewModel.validationMessage) { message in
guard let message else { return }
toastCenter.show(message)
viewModel.validationMessage = nil
}
.background(successNavigationLink)
.onAppear {
viewModel.loadInitialLocationIfNeeded()
}
.onChange(of: viewModel.description) { newValue in
viewModel.updateDescription(newValue)
}
.onChange(of: viewModel.submitResult) { newValue in
guard newValue == nil, shouldReturnToHome else { return }
shouldReturnToHome = false
dismiss()
}
}
private var successNavigationLink: some View {
NavigationLink(
isActive: Binding(
get: { viewModel.submitResult != nil },
set: { isActive in
if !isActive { viewModel.clearSubmitResult() }
}
)
) {
if let result = viewModel.submitResult {
WildPhotographerReportSuccessView(
result: result,
homeViewModel: homeViewModel,
onReturnHome: {
viewModel.clearSubmitResult()
shouldReturnToHome = true
}
)
} else {
EmptyView()
}
} label: {
EmptyView()
}
.hidden()
}
private var formContent: some View {
ScrollView {
VStack(spacing: 12) {
realNameBanner
formStepCard(index: 1, title: "举报类型") {
reportTypeSelector
}
evidenceFormCard
formStepCard(index: 3, title: "举报说明") {
descriptionEditor
}
formStepCard(index: 4, title: "举报位置") {
locationCard
}
formStepCard(index: 5, title: "摄影师微信号/手机号", optional: true) {
TextField("请输入摄影师微信号或手机号(选填)", text: $viewModel.contact)
.keyboardType(.default)
.appInputFieldStyle(cornerRadius: 10, minHeight: 48)
}
formStepCard(index: 6, title: "线下微信/支付宝支付截图", optional: true) {
paymentUploadRow
}
submitButton
}
.frame(maxWidth: contentMaxWidth)
.padding(.horizontal, 18)
.padding(.top, 14)
.padding(.bottom, 26)
}
.scrollDismissesKeyboard(.interactively)
}
private var realNameBanner: some View {
HStack(alignment: .top, spacing: 12) {
Image(systemName: "checkmark.shield.fill")
.font(.system(size: 22, weight: .semibold))
.foregroundStyle(AppDesign.primary)
.frame(width: 34, height: 34)
.background(AppDesign.primary.opacity(0.10), in: Circle())
(Text("实名登录")
.foregroundColor(AppDesign.primary)
.fontWeight(.bold)
+ Text("后提交,")
.foregroundColor(AppDesign.textPrimary)
+ Text("景区公安")
.foregroundColor(AppDesign.primary)
.fontWeight(.bold)
+ Text("将接收并处理")
.foregroundColor(AppDesign.textPrimary))
.font(.system(size: 16, weight: .medium))
.lineSpacing(4)
.fixedSize(horizontal: false, vertical: true)
Spacer(minLength: 0)
}
.padding(16)
.background(Color(hex: 0xF6FAFF), in: RoundedRectangle(cornerRadius: 16))
.overlay(
RoundedRectangle(cornerRadius: 16)
.stroke(Color(hex: 0xBFD7FF), lineWidth: 1)
)
}
private var evidenceFormCard: some View {
formStepCard(index: 2, title: "上传现场证据") {
evidenceUploadBlock(
icon: "photo.fill",
title: "上传图片",
subtitle: "支持 JPG、PNG 格式,最多 9 张",
attachments: $viewModel.images,
maxCount: 9,
kind: .image
)
Divider()
.padding(.vertical, 2)
evidenceUploadBlock(
icon: "video.fill",
title: "上传视频",
subtitle: "支持 MP4 格式,时长不超过 60 秒",
attachments: $viewModel.videos,
maxCount: 3,
kind: .video
)
}
}
private var reportTypeSelector: some View {
// ViewModel
LazyVGrid(
columns: Array(repeating: GridItem(.flexible(), spacing: 8), count: 3),
spacing: 8
) {
ForEach(WildReportType.allCases) { type in
reportTypeOption(type)
}
}
}
private func reportTypeOption(_ type: WildReportType) -> some View {
let isSelected = viewModel.selectedReportType == type
return Button {
withAnimation(.easeInOut(duration: 0.2)) {
viewModel.selectedReportType = type
}
} label: {
VStack(spacing: 6) {
Image(systemName: type.iconName)
.font(.system(size: 18, weight: .semibold))
Text(type.rawValue)
.font(.system(size: 13, weight: .bold))
.lineLimit(1)
.minimumScaleFactor(0.8)
}
.foregroundStyle(isSelected ? .white : AppDesign.textPrimary)
.frame(maxWidth: .infinity)
.frame(height: 72)
.background(
isSelected
? AppDesign.primary
: Color(hex: 0xF6FAFF),
in: RoundedRectangle(cornerRadius: 12)
)
.overlay(
RoundedRectangle(cornerRadius: 12)
.stroke(isSelected ? AppDesign.primary : Color(hex: 0xC8DDFF), lineWidth: 1)
)
}
.buttonStyle(.plain)
.accessibilityLabel("举报类型\(type.rawValue)")
}
private func formStepCard<Content: View>(
index: Int,
title: String,
optional: Bool = false,
@ViewBuilder content: () -> Content
) -> some View {
VStack(alignment: .leading, spacing: 14) {
formSectionTitle(index: index, title: title, optional: optional)
content()
}
.padding(16)
.background(.white, in: RoundedRectangle(cornerRadius: 16))
.overlay(
RoundedRectangle(cornerRadius: 16)
.stroke(AppDesign.border, lineWidth: 1)
)
}
private func evidenceUploadBlock(
icon: String,
title: String,
subtitle: String,
attachments: Binding<[WildReportAttachment]>,
maxCount: Int,
kind: WildReportAttachmentKind
) -> some View {
VStack(alignment: .leading, spacing: 12) {
HStack(spacing: 12) {
Image(systemName: icon)
.font(.system(size: 22, weight: .semibold))
.foregroundStyle(AppDesign.primary)
.frame(width: 42, height: 42)
.background(AppDesign.primarySoft, in: RoundedRectangle(cornerRadius: 12))
VStack(alignment: .leading, spacing: 3) {
Text(title)
.font(.system(size: 16, weight: .bold))
.foregroundStyle(AppDesign.textPrimary)
Text(subtitle)
.font(.system(size: 13))
.foregroundStyle(AppDesign.textSecondary)
}
Spacer(minLength: 0)
}
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 10) {
ForEach(attachments.wrappedValue) { item in
attachmentTile(item) {
attachments.wrappedValue.removeAll { $0.id == item.id }
}
}
ForEach(0..<max(0, min(3, maxCount - attachments.wrappedValue.count)), id: \.self) { _ in
addAttachmentTile {
guard attachments.wrappedValue.count < maxCount else { return }
attachments.wrappedValue.append(
WildReportAttachment(
kind: kind,
title: "\(title)\(attachments.wrappedValue.count + 1)"
)
)
}
}
}
.padding(.vertical, 2)
}
}
}
private var descriptionEditor: some View {
ZStack(alignment: .topLeading) {
TextEditor(text: $viewModel.description)
.font(.system(size: 15))
.foregroundStyle(AppDesign.textPrimary)
.padding(.horizontal, 8)
.padding(.vertical, 10)
.frame(minHeight: 112)
.scrollContentBackground(.hidden)
if viewModel.description.isEmpty {
Text("请描述摄影师的位置、特征、行为...")
.font(.system(size: 15))
.foregroundStyle(Color(hex: 0x9CA3AF))
.padding(.horizontal, 14)
.padding(.vertical, 18)
.allowsHitTesting(false)
}
Text("\(viewModel.description.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)
)
}
private var locationCard: some View {
HStack(spacing: 12) {
Image(systemName: "mappin.circle.fill")
.font(.system(size: 30, weight: .semibold))
.foregroundStyle(AppDesign.primary)
.frame(width: 42, height: 42)
.background(AppDesign.primary.opacity(0.10), in: Circle())
VStack(alignment: .leading, spacing: 5) {
Text(viewModel.locationTitleText)
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(AppDesign.textPrimary)
.lineLimit(2)
.minimumScaleFactor(0.78)
Text(viewModel.locationSubtitleText)
.font(.system(size: 12))
.foregroundStyle(AppDesign.textSecondary)
}
Spacer(minLength: 8)
Button("更新定位") {
viewModel.refreshCurrentLocation()
}
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(AppDesign.primary)
.frame(width: 82, height: 34)
.background(.white, in: Capsule())
.overlay(
Capsule()
.stroke(AppDesign.primary, lineWidth: 1)
)
.disabled(viewModel.isUpdatingLocation)
.opacity(viewModel.isUpdatingLocation ? 0.6 : 1)
}
.padding(12)
.background(Color(hex: 0xF6FAFF), in: RoundedRectangle(cornerRadius: 12))
.overlay(
RoundedRectangle(cornerRadius: 12)
.stroke(Color(hex: 0xC8DDFF), lineWidth: 1)
)
}
private var paymentUploadRow: some View {
HStack(spacing: 12) {
Image(systemName: "creditcard.fill")
.font(.system(size: 24, weight: .semibold))
.foregroundStyle(AppDesign.primary)
.frame(width: 44, height: 44)
.background(AppDesign.primarySoft, in: RoundedRectangle(cornerRadius: 12))
VStack(alignment: .leading, spacing: 4) {
Text("上传线下微信/支付宝支付截图(选填)")
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(AppDesign.textPrimary)
Text("支持 JPG、PNG 格式,最多 3 张")
.font(.system(size: 12))
.foregroundStyle(AppDesign.textSecondary)
}
Spacer()
if let first = viewModel.paymentImages.first {
attachmentTile(first) {
viewModel.paymentImages.removeAll { $0.id == first.id }
}
} else {
addAttachmentTile {
viewModel.paymentImages.append(WildReportAttachment(kind: .payment, title: "支付截图1"))
}
}
}
.padding(12)
.background(Color(hex: 0xFBFCFE), in: RoundedRectangle(cornerRadius: 12))
.overlay(
RoundedRectangle(cornerRadius: 12)
.stroke(AppDesign.inputBorder, lineWidth: 1)
)
}
private var submitButton: some View {
Button {
viewModel.submitReport()
} label: {
Text("提交举报")
.font(.system(size: 22, weight: .bold))
.foregroundStyle(.white)
.frame(maxWidth: .infinity)
.frame(height: 56)
.background(AppDesign.primary, in: RoundedRectangle(cornerRadius: 14))
.shadow(color: AppDesign.primary.opacity(0.22), radius: 12, y: 6)
}
.buttonStyle(.plain)
.padding(.top, 4)
}
private var loadingContent: some View {
ScrollView {
VStack(spacing: 14) {
RoundedRectangle(cornerRadius: 14)
.fill(Color(hex: 0xE8EEF7))
.frame(height: 64)
RoundedRectangle(cornerRadius: 18)
.fill(Color(hex: 0xE8EEF7))
.frame(height: 600)
}
.frame(maxWidth: contentMaxWidth)
.padding(.horizontal, 16)
.padding(.top, 16)
.redacted(reason: .placeholder)
}
}
private func stateContent(
title: String,
systemImage: String,
message: String,
actionTitle: String,
action: @escaping () -> Void
) -> some View {
VStack(spacing: 16) {
AppContentUnavailableView(title, systemImage: systemImage, description: Text(message))
Button(actionTitle, action: action)
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(.white)
.frame(width: 132, height: 42)
.background(AppDesign.primary, in: RoundedRectangle(cornerRadius: 10))
}
.frame(maxWidth: contentMaxWidth)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding(.horizontal, 20)
.background(Color(hex: 0xF8FAFF))
}
private func formSectionTitle(index: Int, title: String, optional: Bool = false) -> some View {
HStack(spacing: 8) {
Capsule()
.fill(AppDesign.primary)
.frame(width: 4, height: 20)
Text("\(index). \(title)")
.font(.system(size: 17, weight: .bold))
.foregroundStyle(AppDesign.textPrimary)
if optional {
Text("(选填)")
.font(.system(size: 14))
.foregroundStyle(AppDesign.textSecondary)
}
}
}
private func attachmentTile(_ item: WildReportAttachment, onRemove: @escaping () -> Void) -> some View {
ZStack(alignment: .topTrailing) {
ZStack {
LinearGradient(
colors: item.kind == .video ? [Color(hex: 0xB7D8FF), Color(hex: 0x4C8FEF)] : [Color(hex: 0xCFE6FF), Color(hex: 0x7EB4FF)],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
Image(systemName: item.kind == .video ? "play.circle.fill" : "mountain.2.fill")
.font(.system(size: item.kind == .video ? 30 : 26, weight: .semibold))
.foregroundStyle(.white.opacity(0.95))
}
.frame(width: 58, height: 58)
.clipShape(RoundedRectangle(cornerRadius: 10))
Button(action: onRemove) {
Image(systemName: "xmark.circle.fill")
.font(.system(size: 18, weight: .bold))
.foregroundStyle(Color(hex: 0x8D93A1), .white)
}
.buttonStyle(.plain)
.offset(x: 6, y: -6)
}
.accessibilityLabel("移除\(item.title)")
}
private func addAttachmentTile(action: @escaping () -> Void) -> some View {
Button(action: action) {
Image(systemName: "plus")
.font(.system(size: 30, weight: .light))
.foregroundStyle(Color(hex: 0x8D93A1))
.frame(width: 58, height: 58)
.background(.white, in: RoundedRectangle(cornerRadius: 10))
.overlay(
RoundedRectangle(cornerRadius: 10)
.stroke(
Color(hex: 0xCDD5E1),
style: StrokeStyle(lineWidth: 1, dash: [5, 4])
)
)
}
.buttonStyle(.plain)
.accessibilityLabel("添加证据")
}
private var contentMaxWidth: CGFloat {
horizontalSizeClass == .regular ? 760 : .infinity
}
}
struct WildPhotographerReportSuccessView: View {
let homeViewModel: WildPhotographerReportHomeViewModel
let onReturnHome: () -> Void
@StateObject private var viewModel: WildPhotographerReportSuccessViewModel
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
@EnvironmentObject private var toastCenter: ToastCenter
init(
result: WildReportSubmitResult,
homeViewModel: WildPhotographerReportHomeViewModel,
onReturnHome: @escaping () -> Void
) {
self.homeViewModel = homeViewModel
self.onReturnHome = onReturnHome
_viewModel = StateObject(wrappedValue: WildPhotographerReportSuccessViewModel(result: result))
}
var body: some View {
ScrollView {
VStack(spacing: 12) {
heroSection
progressCard
detailsCard
actionButtons
}
.frame(maxWidth: contentMaxWidth)
.padding(.horizontal, 18)
.padding(.top, 14)
.padding(.bottom, 28)
}
.navigationTitle("举报成功")
.navigationBarTitleDisplayMode(.inline)
.background(Color(hex: 0xF8FAFF).ignoresSafeArea())
}
private var heroSection: some View {
VStack(spacing: 14) {
ZStack {
Circle()
.fill(AppDesign.primary.opacity(0.12))
.frame(width: 78, height: 78)
Circle()
.fill(AppDesign.primary)
.frame(width: 58, height: 58)
Image(systemName: "checkmark")
.font(.system(size: 28, weight: .bold))
.foregroundStyle(.white)
}
Text("举报已提交")
.font(.system(size: 24, weight: .heavy))
.foregroundStyle(AppDesign.textPrimary)
Text("景区公安已收到线索,将尽快前往现场核实处理")
.font(.system(size: 14))
.foregroundStyle(AppDesign.textSecondary)
.multilineTextAlignment(.center)
.lineSpacing(4)
.padding(.horizontal, 16)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 24)
.padding(.horizontal, 16)
.background(
LinearGradient(
colors: [.white, Color(hex: 0xEEF6FF)],
startPoint: .topLeading,
endPoint: .bottomTrailing
),
in: RoundedRectangle(cornerRadius: 18)
)
.overlay(
RoundedRectangle(cornerRadius: 18)
.stroke(Color(hex: 0xBFD7FF), lineWidth: 1)
)
}
private var progressCard: some View {
VStack(spacing: 14) {
HStack(alignment: .top, spacing: 0) {
ForEach(Array(viewModel.progressSteps.enumerated()), id: \.offset) { index, step in
VStack(spacing: 8) {
progressNode(isCompleted: index == 0, isCurrent: false)
Text(step)
.font(.system(size: 11, weight: index == 0 ? .semibold : .regular))
.foregroundStyle(index == 0 ? AppDesign.primary : Color(hex: 0x9CA3AF))
.multilineTextAlignment(.center)
.lineLimit(2)
.frame(maxWidth: .infinity)
}
if index < viewModel.progressSteps.count - 1 {
progressConnector(isCompleted: index == 0)
.padding(.top, 11)
}
}
}
}
.padding(.horizontal, 12)
.padding(.vertical, 16)
.background(.white, in: RoundedRectangle(cornerRadius: 16))
.overlay(
RoundedRectangle(cornerRadius: 16)
.stroke(AppDesign.border, lineWidth: 1)
)
}
private func progressNode(isCompleted: Bool, isCurrent: Bool) -> some View {
ZStack {
Circle()
.stroke(isCompleted ? AppDesign.primary : Color(hex: 0xD1D5DB), lineWidth: 2)
.frame(width: 24, height: 24)
if isCompleted {
Circle()
.fill(AppDesign.primary)
.frame(width: isCurrent ? 24 : 20, height: isCurrent ? 24 : 20)
if isCurrent {
Circle()
.fill(.white)
.frame(width: 8, height: 8)
} else {
Image(systemName: "checkmark")
.font(.system(size: 10, weight: .bold))
.foregroundStyle(.white)
}
}
}
}
private func progressConnector(isCompleted: Bool) -> some View {
Group {
if isCompleted {
Rectangle()
.fill(AppDesign.primary)
.frame(height: 2)
} else {
HStack(spacing: 3) {
ForEach(0..<5, id: \.self) { _ in
Circle()
.fill(Color(hex: 0xD1D5DB))
.frame(width: 3, height: 3)
}
}
}
}
.frame(maxWidth: .infinity)
}
private var detailsCard: some View {
VStack(spacing: 0) {
detailInfoRow(
icon: "doc.text.fill",
title: "举报编号",
value: viewModel.result.record.id,
valueColor: AppDesign.primary,
showsCopy: true
)
Divider().padding(.leading, 56)
detailInfoRow(
icon: "tag.fill",
title: "举报类型",
value: viewModel.result.record.reportType.rawValue
)
Divider().padding(.leading, 56)
detailInfoRow(
icon: "mappin.circle.fill",
title: "举报位置",
value: viewModel.result.record.location
)
Divider().padding(.leading, 56)
detailInfoRow(
icon: "photo.on.rectangle.angled",
title: "已上传",
value: viewModel.uploadSummary
)
}
.background(.white, in: RoundedRectangle(cornerRadius: 16))
.overlay(
RoundedRectangle(cornerRadius: 16)
.stroke(AppDesign.border, lineWidth: 1)
)
}
private func detailInfoRow(
icon: String,
title: String,
value: String,
valueColor: Color = AppDesign.textPrimary,
showsCopy: Bool = false
) -> some View {
HStack(spacing: 14) {
Image(systemName: icon)
.font(.system(size: 22, weight: .semibold))
.foregroundStyle(AppDesign.primary)
.frame(width: 42, height: 42)
.background(AppDesign.primarySoft, in: RoundedRectangle(cornerRadius: 10))
VStack(alignment: .leading, spacing: 4) {
Text(title)
.font(.system(size: 13))
.foregroundStyle(AppDesign.textSecondary)
Text(value)
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(valueColor)
.lineLimit(2)
}
Spacer(minLength: 8)
if showsCopy {
Button {
_ = viewModel.copyReportID()
toastCenter.show("举报编号已复制")
} label: {
Image(systemName: "doc.on.doc")
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(AppDesign.primary)
.frame(width: 34, height: 34)
.background(AppDesign.primarySoft, in: Circle())
}
.buttonStyle(.plain)
.accessibilityLabel("复制举报编号")
}
}
.padding(.horizontal, 16)
.padding(.vertical, 14)
}
private var actionButtons: some View {
VStack(spacing: 12) {
NavigationLink {
WildPhotographerReportDetailView(record: viewModel.result.record, homeViewModel: homeViewModel)
} label: {
Text("查看处理进度")
.font(.system(size: 20, weight: .bold))
.foregroundStyle(.white)
.frame(maxWidth: .infinity)
.frame(height: 54)
.background(AppDesign.primary, in: RoundedRectangle(cornerRadius: 14))
}
.buttonStyle(.plain)
Button(action: onReturnHome) {
Text("返回首页")
.font(.system(size: 20, weight: .bold))
.foregroundStyle(AppDesign.primary)
.frame(maxWidth: .infinity)
.frame(height: 54)
.background(.white, in: RoundedRectangle(cornerRadius: 14))
.overlay(
RoundedRectangle(cornerRadius: 14)
.stroke(AppDesign.primary, lineWidth: 1.2)
)
}
.buttonStyle(.plain)
}
}
private var contentMaxWidth: CGFloat {
horizontalSizeClass == .regular ? 760 : .infinity
}
}