Files
suixinkan_uikit/suixinkan/Features/PunchPoint/ViewModels/PunchPointViewModels.swift
汉秋 005349f8e6 模块化 AppStore 并完善素材管理与个人空间设置。
将会话、权限、位置、收款与排队存储拆分为独立模块,同步更新各 ViewModel 与单元测试,并补充素材管理 UI 与个人空间设置交互。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-13 11:01:08 +08:00

482 lines
16 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.

//
// PunchPointViewModels.swift
// suixinkan
//
import CoreLocation
import Foundation
/// 便 ViewModel
@MainActor
protocol PunchPointImageUploading {
/// OSS URL
func uploadPunchPointImage(
data: Data,
fileName: String,
scenicId: Int,
onProgress: @escaping (Int) -> Void
) async throws -> String
}
/// ViewModel
final class PunchPointListViewModel {
private(set) var items: [PunchPointItem] = []
private(set) var filterType: PunchPointFilterType = .all
private(set) var isLoading = false
private(set) var isRefreshing = false
private(set) var canLoadMore = false
var onStateChange: (() -> Void)?
var onShowMessage: ((String) -> Void)?
var onShowQRCode: ((String) -> Void)?
private var currentPage = 1
private var totalCount = 0
private let pageSize = 10
private let currentScenicIdProvider: () -> Int
/// ViewModel
init(currentScenicIdProvider: @escaping () -> Int = { AppStore.shared.session.currentScenicId }) {
self.currentScenicIdProvider = currentScenicIdProvider
}
///
func loadInitial(api: any PunchPointAPIProtocol) async {
await load(reset: true, showLoading: true, api: api)
}
///
func refresh(api: any PunchPointAPIProtocol) async {
isRefreshing = true
notifyStateChange()
await load(reset: true, showLoading: false, api: api)
}
///
func selectFilter(_ type: PunchPointFilterType, api: any PunchPointAPIProtocol) async {
guard filterType != type else { return }
filterType = type
notifyStateChange()
await load(reset: true, showLoading: false, api: api)
}
///
func loadMoreIfNeeded(lastVisibleIndex: Int, api: any PunchPointAPIProtocol) async {
guard lastVisibleIndex >= items.count - 3 else { return }
await load(reset: false, showLoading: false, api: api)
}
///
func showQRCode(for item: PunchPointItem) {
guard item.status == 1 || item.status == 2 else {
onShowMessage?("未审核通过不可查看二维码")
return
}
guard let url = item.mpQrcode?.trimmingCharacters(in: .whitespacesAndNewlines), !url.isEmpty else {
onShowMessage?("暂无二维码")
return
}
onShowQRCode?(url)
}
///
func delete(item: PunchPointItem, api: any PunchPointAPIProtocol) async {
do {
try await api.delete(id: item.id)
items.removeAll { $0.id == item.id }
totalCount = max(0, totalCount - 1)
canLoadMore = items.count < totalCount
notifyStateChange()
} catch is CancellationError {
return
} catch {
onShowMessage?(error.localizedDescription.isEmpty ? "删除失败" : "删除失败:\(error.localizedDescription)")
}
}
private func load(reset: Bool, showLoading: Bool, api: any PunchPointAPIProtocol) async {
if reset {
currentPage = 1
canLoadMore = false
} else {
guard canLoadMore, !isLoading, !isRefreshing else { return }
currentPage += 1
}
let scenicId = currentScenicIdProvider()
guard scenicId > 0 else {
onShowMessage?("请先选择景区")
if reset {
items = []
canLoadMore = false
totalCount = 0
}
notifyStateChange()
return
}
isLoading = showLoading
notifyStateChange()
defer {
isLoading = false
isRefreshing = false
notifyStateChange()
}
do {
let response = try await api.list(
scenicAreaId: String(scenicId),
status: filterType.apiStatus,
page: currentPage,
pageSize: pageSize
)
totalCount = response.total
items = reset ? response.list : items + response.list
canLoadMore = response.list.count >= pageSize && items.count < totalCount
} catch is CancellationError {
return
} catch {
if !reset, currentPage > 1 {
currentPage -= 1
}
if reset {
items = []
totalCount = 0
canLoadMore = false
}
onShowMessage?(error.localizedDescription.isEmpty ? "获取打卡点列表失败" : "获取打卡点列表失败:\(error.localizedDescription)")
}
}
private func notifyStateChange() {
onStateChange?()
}
}
/// ViewModel
final class PunchPointDetailViewModel {
private(set) var detail: PunchPointDetail?
private(set) var isLoading = false
var onStateChange: (() -> Void)?
var onShowMessage: ((String) -> Void)?
let punchPointId: Int64
/// ViewModel
init(punchPointId: Int64) {
self.punchPointId = punchPointId
}
///
func load(api: any PunchPointAPIProtocol) async {
guard punchPointId > 0 else {
onShowMessage?("打卡点信息异常")
return
}
isLoading = true
notifyStateChange()
defer {
isLoading = false
notifyStateChange()
}
do {
detail = try await api.info(id: punchPointId)
} catch is CancellationError {
return
} catch {
onShowMessage?(error.localizedDescription.isEmpty ? "获取打卡点详情失败" : error.localizedDescription)
}
}
private func notifyStateChange() {
onStateChange?()
}
}
/// ViewModel
final class PunchPointFormViewModel {
///
enum Mode: Equatable {
case create
case edit(id: Int64)
}
private(set) var name = ""
private(set) var address = ""
private(set) var description = ""
private(set) var coordinate: CLLocationCoordinate2D?
private(set) var images: [PunchPointImageState] = []
private(set) var uploadDialogState: PunchPointUploadDialogState?
private(set) var isSubmitting = false
private(set) var isLocating = false
var onStateChange: (() -> Void)?
var onShowMessage: ((String) -> Void)?
var onSubmitSuccess: (() -> Void)?
var onCoordinateChange: ((CLLocationCoordinate2D) -> Void)?
let mode: Mode
private let currentScenicIdProvider: () -> Int
private let locationProvider: any LocationProviding
/// ViewModel
init(
mode: Mode = .create,
currentScenicIdProvider: @escaping () -> Int = { AppStore.shared.session.currentScenicId },
locationProvider: any LocationProviding = LocationProvider.shared
) {
self.mode = mode
self.currentScenicIdProvider = currentScenicIdProvider
self.locationProvider = locationProvider
}
///
var coordinatesText: String {
guard let coordinate else { return "" }
return "\(coordinate.latitude),\(coordinate.longitude)"
}
/// 使
func initialize(with detail: PunchPointDetail) {
name = detail.name
address = detail.displayAddress
description = detail.description ?? ""
coordinate = detail.coordinate
images = detail.guideImages.map(PunchPointImageState.remote(url:))
if let coordinate {
onCoordinateChange?(coordinate)
}
notifyStateChange()
}
///
func updateName(_ text: String) {
name = text
notifyStateChange()
}
///
func updateAddress(_ text: String) {
address = text
notifyStateChange()
}
/// 50
func updateDescription(_ text: String) {
description = String(text.prefix(50))
notifyStateChange()
}
///
func autoLocation() async {
await locate(updateMap: true, missingPermissionMessage: "定位权限未授予,无法自动定位")
}
///
func locateToCurrent() async {
await locate(updateMap: true, missingPermissionMessage: "定位权限未授予,请在设置中开启")
}
///
func selectCoordinate(_ coordinate: CLLocationCoordinate2D) async {
self.coordinate = coordinate
notifyStateChange()
onCoordinateChange?(coordinate)
let resolved = await locationProvider.reverseGeocode(
latitude: coordinate.latitude,
longitude: coordinate.longitude
)
if !resolved.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
address = resolved
notifyStateChange()
}
}
///
func addLocalImages(_ newImages: [PunchPointImageState], uploader: any PunchPointImageUploading) async {
let remaining = 9 - images.count
guard remaining > 0 else {
onShowMessage?("最多只能选择9张图片")
return
}
let accepted = Array(newImages.prefix(remaining))
guard !accepted.isEmpty else { return }
let start = images.count
images.append(contentsOf: accepted)
notifyStateChange()
for offset in accepted.indices {
await uploadImage(at: start + offset, title: "正在上传(\(offset + 1)/\(accepted.count))", uploader: uploader)
}
uploadDialogState = nil
notifyStateChange()
}
///
func deleteImage(at index: Int) {
guard images.indices.contains(index), !images[index].isUploading else { return }
images.remove(at: index)
notifyStateChange()
}
///
func retryUpload(at index: Int, uploader: any PunchPointImageUploading) async {
guard images.indices.contains(index) else { return }
images[index].errorMessage = nil
notifyStateChange()
await uploadImage(at: index, title: "正在上传(1/1)", uploader: uploader)
uploadDialogState = nil
notifyStateChange()
}
///
func submit(api: any PunchPointAPIProtocol) async {
guard !name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
onShowMessage?("请输入打卡点名称")
return
}
guard let coordinate else {
onShowMessage?("请选择打卡点坐标")
return
}
guard !address.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
onShowMessage?("请输入打卡点地址")
return
}
guard !images.isEmpty else {
onShowMessage?("请上传至少一张图片")
return
}
guard !images.contains(where: { $0.isUploading }) else {
onShowMessage?("图片正在上传,请稍后提交")
return
}
guard !images.contains(where: { !$0.isUploaded }) else {
onShowMessage?("请先完成图片上传")
return
}
let scenicId = currentScenicIdProvider()
guard scenicId > 0 else {
onShowMessage?("请先选择景区")
return
}
let request = PunchPointSaveRequest(
id: editId,
scenicAreaId: String(scenicId),
name: name,
description: description.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : description,
region: PunchPointSaveRegion(
lot: coordinate.longitude,
lat: coordinate.latitude,
address: address
),
scenicSpotString: address,
guideImages: images.map(\.uploadedURL)
)
isSubmitting = true
notifyStateChange()
defer {
isSubmitting = false
notifyStateChange()
}
do {
switch mode {
case .create:
try await api.add(request)
case .edit:
try await api.edit(request)
}
onShowMessage?("提交成功")
onSubmitSuccess?()
} catch is CancellationError {
return
} catch {
onShowMessage?(error.localizedDescription.isEmpty ? "提交失败" : "提交失败\(error.localizedDescription)")
}
}
private var editId: Int64? {
if case .edit(let id) = mode { return id }
return nil
}
private func locate(updateMap: Bool, missingPermissionMessage: String) async {
isLocating = true
notifyStateChange()
defer {
isLocating = false
notifyStateChange()
}
do {
let snapshot = try await locationProvider.requestSnapshot()
let coordinate = CLLocationCoordinate2D(latitude: snapshot.latitude, longitude: snapshot.longitude)
self.coordinate = coordinate
if !snapshot.address.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
address = snapshot.address
}
if updateMap {
onCoordinateChange?(coordinate)
}
} catch LocationProviderError.permissionDenied {
onShowMessage?(missingPermissionMessage)
} catch is CancellationError {
return
} catch {
onShowMessage?(error.localizedDescription.isEmpty ? "定位失败" : error.localizedDescription)
}
}
private func uploadImage(at index: Int, title: String, uploader: any PunchPointImageUploading) async {
guard images.indices.contains(index) else { return }
let scenicId = currentScenicIdProvider()
guard scenicId > 0 else {
onShowMessage?("请先选择景区")
return
}
guard !images[index].isUploaded else { return }
images[index].isUploading = true
images[index].uploadProgress = 0
images[index].errorMessage = nil
uploadDialogState = PunchPointUploadDialogState(title: title, progress: 0)
notifyStateChange()
do {
let item = images[index]
let url = try await uploader.uploadPunchPointImage(
data: item.data,
fileName: item.fileName,
scenicId: scenicId,
onProgress: { [weak self] progress in
guard let self else { return }
Task { @MainActor in
guard self.images.indices.contains(index) else { return }
self.images[index].uploadProgress = progress
self.images[index].isUploading = progress < 100
self.uploadDialogState = PunchPointUploadDialogState(title: title, progress: progress)
self.notifyStateChange()
}
}
)
guard images.indices.contains(index) else { return }
images[index].uploadedURL = url
images[index].previewURL = url
images[index].isUploading = false
images[index].uploadProgress = 100
} catch is CancellationError {
return
} catch {
guard images.indices.contains(index) else { return }
images[index].isUploading = false
images[index].uploadProgress = 0
images[index].errorMessage = error.localizedDescription.isEmpty ? "上传失败" : error.localizedDescription
onShowMessage?(error.localizedDescription.isEmpty ? "图片上传失败,请重试" : error.localizedDescription)
}
}
private func notifyStateChange() {
onStateChange?()
}
}