Files
suixinkan_uikit/suixinkan/Features/WildPhotographerReport/ViewModels/WildPhotographerReportViewModels.swift

686 lines
23 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.

//
// WildPhotographerReportViewModels.swift
// suixinkan
//
import Foundation
import MapKit
import UIKit
/// ViewModel Mock
final class WildPhotographerReportHomeViewModel {
private(set) var pageState: WildReportPageState = .normal
private(set) var reports: [WildReportRecord] = []
private(set) var riskPoints: [WildReportRiskPoint] = []
private(set) var supplementaryEvidenceMap: [String: [WildReportSupplementaryEvidence]] = [:]
var onStateChange: (() -> Void)?
private var nextSequence = 0
private var nextSupplementSequence = 0
init() {
resetDemoData(notify: false)
}
var emptyTitle: String { "暂无举报服务" }
var emptyMessage: String { "当前景区暂未开放举报摄影师演示入口。" }
///
func restoreDemoData() {
resetDemoData(notify: false)
pageState = .normal
notifyStateChange()
}
///
func retryLoadAfterError() {
pageState = .loading
notifyStateChange()
Task {
try? await Task.sleep(nanoseconds: 450_000_000)
pageState = .normal
notifyStateChange()
}
}
///
func setPageState(_ state: WildReportPageState) {
pageState = state
notifyStateChange()
}
/// Mock
@discardableResult
func submitReport(
reportType: WildReportType,
description: String,
location: String,
contact: String?,
imageCount: Int,
videoCount: Int
) -> WildReportRecord {
let trimmedDescription = description.trimmingCharacters(in: .whitespacesAndNewlines)
let parsedContact = WildPhotographerReportMockStore.parseContact(contact)
let record = WildReportRecord(
id: nextReportID(),
title: "摄影师举报",
reportType: reportType,
status: .pending,
location: location,
submitTime: WildPhotographerReportMockStore.submitTimeFormatter.string(from: Date()),
description: trimmedDescription,
wechatID: parsedContact.wechat,
phoneNumber: parsedContact.phone,
imageCount: imageCount,
videoCount: videoCount,
handlerInfo: nil
)
reports.insert(record, at: 0)
notifyStateChange()
return record
}
///
func supplementaryEvidences(for reportID: String) -> [WildReportSupplementaryEvidence] {
supplementaryEvidenceMap[reportID] ?? []
}
///
func appendSupplementaryEvidence(
reportID: String,
text: String,
imageCount: Int,
videoCount: Int
) {
let item = WildReportSupplementaryEvidence(
id: nextSupplementID(),
submitTime: WildPhotographerReportMockStore.submitTimeFormatter.string(from: Date()),
text: text.trimmingCharacters(in: .whitespacesAndNewlines),
imageCount: imageCount,
videoCount: videoCount
)
var items = supplementaryEvidenceMap[reportID] ?? []
items.insert(item, at: 0)
supplementaryEvidenceMap[reportID] = items
notifyStateChange()
}
/// Mock
func resetDemoData(notify: Bool = true) {
reports = WildPhotographerReportMockStore.seedReports
riskPoints = WildPhotographerReportMockStore.seedRiskPoints
supplementaryEvidenceMap = WildPhotographerReportMockStore.seedSupplementaryEvidences
nextSequence = WildPhotographerReportMockStore.maxSequence(from: WildPhotographerReportMockStore.seedReports) + 1
nextSupplementSequence = 100
if notify { notifyStateChange() }
}
private func nextSupplementID() -> String {
defer { nextSupplementSequence += 1 }
return String(format: "SE%03d", nextSupplementSequence)
}
private func nextReportID() -> String {
let datePart = WildPhotographerReportMockStore.reportDateFormatter.string(from: Date())
defer { nextSequence += 1 }
return String(format: "JB\(datePart)%03d", nextSequence)
}
private func notifyStateChange() {
onStateChange?()
}
}
/// ViewModel
final class WildPhotographerReportSubmitViewModel {
private(set) var pageState: WildReportPageState = .normal
private(set) var images: [WildReportAttachment]
private(set) var videos: [WildReportAttachment]
private(set) var paymentImages: [WildReportAttachment] = []
private(set) var selectedReportType: WildReportType = .suspectedWild
private(set) var description = ""
private(set) var contact = ""
private(set) var reportLocation = ""
private(set) var isUpdatingLocation = false
private(set) var locationConfirmed = false
private(set) var submitResult: WildReportSubmitResult?
private(set) var validationMessage: String?
var onStateChange: (() -> Void)?
var onShowMessage: ((String) -> Void)?
var onSubmitSuccess: ((WildReportSubmitResult) -> Void)?
private let homeViewModel: WildPhotographerReportHomeViewModel
private let locationProvider: any LocationProviding
private var hasRequestedInitialLocation = false
var locationTitleText: String {
if reportLocation.isEmpty || isUpdatingLocation {
return "正在获取当前位置..."
}
return "当前定位:\(reportLocation)"
}
var locationSubtitleText: String {
if isUpdatingLocation {
return "正在更新定位,请稍候"
}
if locationConfirmed {
return "已获取最新定位,如不准确请点击更新定位"
}
return "如位置不准确,请点击更新定位"
}
init(
homeViewModel: WildPhotographerReportHomeViewModel,
locationProvider: any LocationProviding = LocationProvider.shared
) {
self.homeViewModel = homeViewModel
self.locationProvider = locationProvider
self.images = WildPhotographerReportMockStore.initialImages
self.videos = WildPhotographerReportMockStore.initialVideos
}
///
func loadInitialLocationIfNeeded() {
guard !hasRequestedInitialLocation else { return }
hasRequestedInitialLocation = true
refreshCurrentLocation()
}
///
func selectReportType(_ type: WildReportType) {
selectedReportType = type
notifyStateChange()
}
/// 500
func updateDescription(_ newValue: String) {
description = String(newValue.prefix(500))
notifyStateChange()
}
///
func updateContact(_ value: String) {
contact = String(value.prefix(80))
notifyStateChange()
}
///
func addImage() {
images.append(WildReportAttachment(kind: .image, title: "现场图片\(images.count + 1)"))
notifyStateChange()
}
///
func addVideo() {
videos.append(WildReportAttachment(kind: .video, title: "现场视频\(videos.count + 1)"))
notifyStateChange()
}
///
func addPaymentImage() {
guard paymentImages.count < 3 else {
onShowMessage?("支付截图最多上传3张")
return
}
paymentImages.append(WildReportAttachment(kind: .payment, title: "支付截图\(paymentImages.count + 1)"))
notifyStateChange()
}
///
func removeAttachment(_ attachment: WildReportAttachment) {
images.removeAll { $0.id == attachment.id }
videos.removeAll { $0.id == attachment.id }
paymentImages.removeAll { $0.id == attachment.id }
notifyStateChange()
}
/// 使 Provider
func refreshCurrentLocation() {
guard !isUpdatingLocation else { return }
isUpdatingLocation = true
locationConfirmed = false
reportLocation = "正在获取当前位置..."
notifyStateChange()
Task {
do {
let snapshot = try await locationProvider.requestSnapshot()
let address = snapshot.address.isEmpty
? String(format: "%.5f, %.5f", snapshot.latitude, snapshot.longitude)
: snapshot.address
applyResolvedLocation(address)
} catch {
applyResolvedLocation(WildPhotographerReportMockStore.fallbackLocation)
}
}
}
///
func clearSubmitResult() {
submitResult = nil
notifyStateChange()
}
///
func submitReport() {
validationMessage = nil
guard !images.isEmpty || !videos.isEmpty else {
setValidationMessage("请至少上传一项现场证据。")
return
}
guard !description.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
setValidationMessage("请填写举报说明。")
return
}
guard !isUpdatingLocation else {
setValidationMessage("正在获取定位,请稍后再提交。")
return
}
guard locationConfirmed, !reportLocation.isEmpty, reportLocation != "正在获取当前位置..." else {
setValidationMessage("请先更新举报位置后再提交举报。")
return
}
let record = homeViewModel.submitReport(
reportType: selectedReportType,
description: description,
location: reportLocation,
contact: contact.isEmpty ? nil : contact,
imageCount: images.count,
videoCount: videos.count
)
let result = WildReportSubmitResult(
record: record,
imageCount: images.count,
videoCount: videos.count
)
submitResult = result
notifyStateChange()
onSubmitSuccess?(result)
}
///
func resetForm() {
images = WildPhotographerReportMockStore.initialImages
videos = WildPhotographerReportMockStore.initialVideos
paymentImages = []
selectedReportType = .suspectedWild
description = ""
contact = ""
reportLocation = ""
locationConfirmed = false
isUpdatingLocation = false
hasRequestedInitialLocation = false
submitResult = nil
validationMessage = nil
notifyStateChange()
}
func applyResolvedLocation(_ location: String) {
reportLocation = location
locationConfirmed = true
isUpdatingLocation = false
notifyStateChange()
}
private func setValidationMessage(_ message: String) {
validationMessage = message
notifyStateChange()
onShowMessage?(message)
}
private func notifyStateChange() {
onStateChange?()
}
}
/// ViewModel
final class WildPhotographerReportSuccessViewModel {
let result: WildReportSubmitResult
let progressSteps = ["已提交", "现场核查中", "处理结果反馈"]
init(result: WildReportSubmitResult) {
self.result = result
}
var uploadSummary: String {
var parts: [String] = []
if result.imageCount > 0 { parts.append("\(result.imageCount)张图片") }
if result.videoCount > 0 { parts.append("\(result.videoCount)段视频") }
return parts.isEmpty ? "暂无" : parts.joined(separator: " ")
}
///
@MainActor
func copyReportID() -> String {
UIPasteboard.general.string = result.record.id
return result.record.id
}
}
/// ViewModel
final class WildPhotographerReportDetailViewModel {
let record: WildReportRecord
private let homeViewModel: WildPhotographerReportHomeViewModel?
private(set) var actionMessage: String?
var onStateChange: (() -> Void)?
var onShowMessage: ((String) -> Void)?
init(record: WildReportRecord, homeViewModel: WildPhotographerReportHomeViewModel? = nil) {
self.record = record
self.homeViewModel = homeViewModel
}
var heroTitle: String { record.title.isEmpty ? "摄影师举报" : record.title }
var scenicAreaName: String { WildReportLocationParser.split(record.location).scenic }
var detailAddress: String {
let detail = WildReportLocationParser.split(record.location).detail
return detail.contains("附近") ? detail : "\(detail)附近"
}
var displayStatusText: String { record.status.rawValue }
var descriptionText: String { record.description }
var contactText: String { record.contactDisplayText }
var supplementaryEvidences: [WildReportSupplementaryEvidence] {
homeViewModel?.supplementaryEvidences(for: record.id) ?? []
}
var reporterMediaSummary: String {
switch (record.imageCount, record.videoCount) {
case (0, 0): return "0项"
case (_, 0): return "\(record.imageCount)"
case (0, _): return "\(record.videoCount)"
default: return "\(record.imageCount)\(record.videoCount)"
}
}
var progressSteps: [WildReportProgressStep] { Self.buildProgressSteps(for: record) }
var showHandlerInfo: Bool { record.status == .processed && record.handlerInfo != nil }
var handlerInfo: WildReportHandlerInfo? { record.handlerInfo }
///
func showMediaPreview(kind: String) {
showActionMessage("演示环境暂不支持预览\(kind),可在提交页查看上传内容。")
}
///
func showLocationPreview() {
showActionMessage("举报位置:\(record.location)")
}
///
func shareReport() {
showActionMessage("已生成举报 \(record.id) 的分享信息,可发送给景区处理人员查看进度。")
}
private func showActionMessage(_ message: String) {
actionMessage = message
onShowMessage?(message)
notifyStateChange()
}
private static func buildProgressSteps(for record: WildReportRecord) -> [WildReportProgressStep] {
let baseDate = submitDate(from: record.submitTime) ?? Date()
let expectedFeedback = formatDate(baseDate.addingTimeInterval(8 * 3_600))
switch record.status {
case .pending, .processing:
return [
makeStep(id: "1", title: "已提交", date: baseDate, state: .completed),
makeStep(id: "2", title: "已派工作人员前往", date: baseDate.addingTimeInterval(22 * 60), state: .completed),
makeStep(id: "3", title: "现场核查中", date: baseDate.addingTimeInterval(57 * 60), state: .current),
makeStep(id: "4", title: "待结果反馈", date: nil, state: .pending, pendingText: "预计 \(expectedFeedback)")
]
case .processed:
return [
makeStep(id: "1", title: "已提交", date: baseDate, state: .completed),
makeStep(id: "2", title: "已派工作人员前往", date: baseDate.addingTimeInterval(22 * 60), state: .completed),
makeStep(id: "3", title: "现场核查中", date: baseDate.addingTimeInterval(57 * 60), state: .completed),
makeStep(id: "4", title: "已反馈核查结果", date: baseDate.addingTimeInterval(3 * 3_600), state: .completed)
]
}
}
private static func makeStep(
id: String,
title: String,
date: Date?,
state: WildReportProgressStepState,
pendingText: String? = nil
) -> WildReportProgressStep {
WildReportProgressStep(
id: id,
title: title,
timeText: pendingText ?? (date.map(formatDate) ?? ""),
state: state
)
}
private static func submitDate(from text: String) -> Date? {
WildPhotographerReportMockStore.submitTimeFormatter.date(from: text)
}
private static func formatDate(_ date: Date) -> String {
WildPhotographerReportMockStore.submitTimeFormatter.string(from: date)
}
private func notifyStateChange() {
onStateChange?()
}
}
/// ViewModel
final class WildPhotographerReportListViewModel {
private(set) var selectedFilter: WildReportListFilter = .all
private(set) var shareMessage: String?
let homeViewModel: WildPhotographerReportHomeViewModel
var onStateChange: (() -> Void)?
var onShowMessage: ((String) -> Void)?
init(homeViewModel: WildPhotographerReportHomeViewModel) {
self.homeViewModel = homeViewModel
}
var filteredReports: [WildReportRecord] {
let reports = homeViewModel.reports
switch selectedFilter {
case .all: return reports
case .pending: return reports.filter { $0.status == .pending }
case .processing: return reports.filter { $0.status == .processing }
case .processed: return reports.filter { $0.status == .processed }
}
}
var footerTip: String { "温馨提示:为保障处理效率,请如实举报并提供有效证据" }
///
func selectFilter(_ filter: WildReportListFilter) {
selectedFilter = filter
notifyStateChange()
}
///
func shareReport(_ record: WildReportRecord) {
shareMessage = "已生成举报 \(record.id) 的分享信息,可发送给景区处理人员查看进度。"
onShowMessage?(shareMessage ?? "")
notifyStateChange()
}
private func notifyStateChange() {
onStateChange?()
}
}
/// ViewModel
final class WildReportSupplementEvidenceViewModel {
private(set) var text = ""
private(set) var images: [WildReportAttachment] = []
private(set) var videos: [WildReportAttachment] = []
private(set) var validationMessage: String?
private(set) var didSubmit = false
let reportID: String
var onStateChange: (() -> Void)?
var onShowMessage: ((String) -> Void)?
var onSubmitSuccess: (() -> Void)?
private let homeViewModel: WildPhotographerReportHomeViewModel
init(reportID: String, homeViewModel: WildPhotographerReportHomeViewModel) {
self.reportID = reportID
self.homeViewModel = homeViewModel
}
/// 500
func updateText(_ newValue: String) {
text = String(newValue.prefix(500))
notifyStateChange()
}
///
func addImage() {
guard images.count < 9 else {
onShowMessage?("补充图片最多上传9张")
return
}
images.append(WildReportAttachment(kind: .image, title: "补充图片\(images.count + 1)"))
notifyStateChange()
}
///
func addVideo() {
guard videos.count < 3 else {
onShowMessage?("补充视频最多上传3段")
return
}
videos.append(WildReportAttachment(kind: .video, title: "补充视频\(videos.count + 1)"))
notifyStateChange()
}
///
func removeAttachment(_ attachment: WildReportAttachment) {
images.removeAll { $0.id == attachment.id }
videos.removeAll { $0.id == attachment.id }
notifyStateChange()
}
///
func submit() {
validationMessage = nil
let trimmedText = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedText.isEmpty || !images.isEmpty || !videos.isEmpty else {
validationMessage = "请至少填写补充说明或上传一项证据。"
onShowMessage?(validationMessage ?? "")
notifyStateChange()
return
}
homeViewModel.appendSupplementaryEvidence(
reportID: reportID,
text: trimmedText,
imageCount: images.count,
videoCount: videos.count
)
didSubmit = true
notifyStateChange()
onSubmitSuccess?()
}
private func notifyStateChange() {
onStateChange?()
}
}
/// ViewModel
final class WildReportRiskMapViewModel {
private(set) var region: MKCoordinateRegion
private(set) var markers: [WildReportMapMarker]
let scenicAreaName: String
private(set) var selectedMarkerID: String?
private(set) var toastMessage: String?
var onStateChange: (() -> Void)?
var onShowMessage: ((String) -> Void)?
init(record: WildReportRecord, riskPoints: [WildReportRiskPoint]) {
let markers = WildPhotographerReportMockStore.buildMarkers(record: record, riskPoints: riskPoints)
self.markers = markers
self.region = WildPhotographerReportMockStore.region(for: markers)
self.scenicAreaName = "那拉提景区"
self.selectedMarkerID = markers.first(where: { $0.kind == .selfLocation })?.id
}
var selectedMarker: WildReportMapMarker? {
guard let selectedMarkerID else { return nil }
return markers.first { $0.id == selectedMarkerID }
}
var selectedClue: WildReportRadarActiveClue? {
guard let marker = selectedMarker,
case .activeClue(let clue) = marker.detail else { return nil }
return clue
}
///
func selectMarker(_ marker: WildReportMapMarker) {
selectedMarkerID = marker.id
notifyStateChange()
}
///
func navigateToSelectedMarker() {
guard let marker = selectedMarker,
case .activeClue(let clue) = marker.detail else { return }
if clue.type == .blue {
refreshLocation()
return
}
showToast("已切换到该点位")
region = MKCoordinateRegion(
center: marker.coordinate,
span: MKCoordinateSpan(latitudeDelta: 0.018, longitudeDelta: 0.018)
)
notifyStateChange()
}
///
func refreshLocation() {
guard let blueMarker = markers.first(where: { $0.kind == .selfLocation }) else { return }
selectedMarkerID = blueMarker.id
region = MKCoordinateRegion(
center: blueMarker.coordinate,
span: MKCoordinateSpan(latitudeDelta: 0.018, longitudeDelta: 0.018)
)
showToast("已定位到当前位置")
notifyStateChange()
}
/// 线
func shareSelectedClue() {
guard let clue = selectedClue else { return }
let title = clue.type == .red ? "线索 ID\(clue.id)" : clue.displayTitle
showToast("已生成\(title)的分享信息,可发送给他人查看。")
}
///
func showImagePreview(_ url: String) {
_ = url
showToast("演示环境暂不支持预览图片")
}
private func showToast(_ message: String) {
toastMessage = message
onShowMessage?(message)
notifyStateChange()
}
private func notifyStateChange() {
onStateChange?()
}
}