Files
suixinkan_ios_new/suixinkan/Features/PunchPoint/ViewModels/PunchPointViewModels.swift
汉秋 0d4b63d273 Add PunchPoint and LocationReport modules with home routing.
Migrate check-in point management and location reporting from placeholders to full MVVM flows, including foreground location support and unit tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-24 11:08:41 +08:00

281 lines
9.2 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
//
// Created by Codex on 2026/6/24.
//
import Foundation
import Observation
/// ViewModel
@MainActor
@Observable
final class PunchPointListViewModel {
var selectedFilter: PunchPointFilter = .all
var items: [PunchPointItem] = []
var selectedDetail: PunchPointItem?
var errorMessage: String?
var isLoading = false
var isLoadingMore = false
var total = 0
private var page = 1
private let pageSize = 20
///
var hasMore: Bool {
items.count < total
}
///
func reload(scenicId: Int?, api: any PunchPointServing) async {
guard let scenicId else {
reset()
return
}
page = 1
isLoading = true
errorMessage = nil
defer { isLoading = false }
do {
let payload = try await api.punchPointList(
scenicId: scenicId,
status: selectedFilter.rawValue,
page: page,
pageSize: pageSize
)
items = payload.list
total = payload.total
} catch {
items = []
total = 0
errorMessage = error.localizedDescription
}
}
///
func loadMore(scenicId: Int?, api: any PunchPointServing) async {
guard let scenicId, hasMore, !isLoadingMore, !isLoading else { return }
isLoadingMore = true
let nextPage = page + 1
defer { isLoadingMore = false }
do {
let payload = try await api.punchPointList(
scenicId: scenicId,
status: selectedFilter.rawValue,
page: nextPage,
pageSize: pageSize
)
page = nextPage
items.append(contentsOf: payload.list)
total = payload.total
} catch {
errorMessage = error.localizedDescription
}
}
///
func selectFilter(_ filter: PunchPointFilter, scenicId: Int?, api: any PunchPointServing) async {
guard selectedFilter != filter else { return }
selectedFilter = filter
await reload(scenicId: scenicId, api: api)
}
///
func loadDetail(id: Int, api: any PunchPointServing) async {
errorMessage = nil
do {
selectedDetail = try await api.punchPointInfo(id: id)
} catch {
selectedDetail = items.first { $0.id == id }
errorMessage = error.localizedDescription
}
}
///
func delete(_ item: PunchPointItem, scenicId: Int?, api: any PunchPointServing) async -> Bool {
do {
try await api.deletePunchPoint(id: item.id)
await reload(scenicId: scenicId, api: api)
return true
} catch {
errorMessage = error.localizedDescription
return false
}
}
///
func reset() {
items = []
selectedDetail = nil
errorMessage = nil
total = 0
page = 1
isLoading = false
isLoadingMore = false
}
}
/// ViewModel OSS
@MainActor
@Observable
final class PunchPointEditorViewModel {
var name = ""
var description = ""
var address = ""
var latitudeText = ""
var longitudeText = ""
var scenicSpotText = ""
var remoteImages: [String] = []
var localImages: [PunchPointLocalImage] = []
var errorMessage: String?
var isSubmitting = false
let editingItem: PunchPointItem?
/// ViewModel
init(item: PunchPointItem? = nil) {
editingItem = item
if let item {
name = item.name
description = item.description
address = item.region?.address ?? ""
latitudeText = item.region.map { String($0.lat) } ?? ""
longitudeText = item.region.map { String($0.lot) } ?? ""
scenicSpotText = item.scenicSpotStr
remoteImages = item.guideImages
}
}
///
func applyLocation(latitude: Double, longitude: Double, address: String) {
latitudeText = String(format: "%.6f", latitude)
longitudeText = String(format: "%.6f", longitude)
self.address = address
if scenicSpotText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
scenicSpotText = address
}
}
///
func addLocalImages(_ images: [PunchPointLocalImage]) {
localImages.append(contentsOf: images)
}
///
func removeRemoteImage(_ url: String) {
remoteImages.removeAll { $0 == url }
}
///
func removeLocalImage(id: UUID) {
localImages.removeAll { $0.id == id }
}
/// OSS
func submit(
scenicId: Int?,
api: any PunchPointServing,
uploadService: any OSSUploadServing
) async -> Bool {
guard !isSubmitting else { return false }
guard let scenicId else {
errorMessage = "缺少当前景区"
return false
}
guard let region = makeRegion() else { return false }
let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedName.isEmpty else {
errorMessage = "请输入打卡点名称"
return false
}
guard !region.address.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
errorMessage = "请选择或填写打卡点地址"
return false
}
guard !remoteImages.isEmpty || !localImages.isEmpty else {
errorMessage = "请至少上传一张打卡点图片"
return false
}
isSubmitting = true
errorMessage = nil
defer { isSubmitting = false }
do {
let uploadedImages = try await uploadImages(scenicId: scenicId, uploadService: uploadService)
let allImages = remoteImages + uploadedImages
if let editingItem {
try await api.editPunchPoint(
EditPunchPointRequest(
id: editingItem.id,
scenicAreaId: "\(scenicId)",
name: trimmedName,
description: description.trimmingCharacters(in: .whitespacesAndNewlines),
region: region,
scenicSpotStr: scenicSpotText.trimmingCharacters(in: .whitespacesAndNewlines),
guideImages: allImages
)
)
} else {
try await api.addPunchPoint(
AddPunchPointRequest(
scenicAreaId: "\(scenicId)",
name: trimmedName,
description: description.trimmingCharacters(in: .whitespacesAndNewlines),
region: region,
scenicSpotStr: scenicSpotText.trimmingCharacters(in: .whitespacesAndNewlines),
guideImages: allImages
)
)
}
remoteImages = allImages
localImages = []
return true
} catch {
errorMessage = error.localizedDescription
return false
}
}
///
private func makeRegion() -> PunchPointRegion? {
let trimmedAddress = address.trimmingCharacters(in: .whitespacesAndNewlines)
guard let lat = Double(latitudeText.trimmingCharacters(in: .whitespacesAndNewlines)),
let lot = Double(longitudeText.trimmingCharacters(in: .whitespacesAndNewlines)) else {
errorMessage = "请选择打卡点坐标"
return nil
}
return PunchPointRegion(
lat: lat,
lot: lot,
address: trimmedAddress,
scenicSpotStr: scenicSpotText.trimmingCharacters(in: .whitespacesAndNewlines)
)
}
/// OSS URL
private func uploadImages(scenicId: Int, uploadService: any OSSUploadServing) async throws -> [String] {
var uploaded: [String] = []
for index in localImages.indices {
let local = localImages[index]
let url = try await uploadService.uploadPunchPointImage(
data: local.data,
fileName: local.fileName,
scenicId: scenicId
) { [weak self] progress in
Task { @MainActor in
self?.localImages[index].progress = progress
}
}
localImages[index].remoteURL = url
uploaded.append(url)
}
return uploaded
}
}