Add location report map page and history list aligned with Android.

Extract shared LocationReportService, wire location_report menu routing, and add unit tests for reporting cooldown and history pagination.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-07 11:34:54 +08:00
parent 5a2b7b6097
commit d2879ad7e2
17 changed files with 1975 additions and 152 deletions

View File

@ -0,0 +1,207 @@
//
// LocationReportService.swift
// suixinkan
//
import Foundation
///
struct LocationReportSuccessResult: Equatable {
let reportTimeText: String
let locationInfoText: String
let shouldShowSuccessDialog: Bool
}
/// Android `HomeViewModel.reportLocation`
final class LocationReportService {
static let operationIntervalMillis: Int64 = 30_000
private(set) var lastOnlineStatusSwitchTime: Int64 = 0
private(set) var lastLocationReportTime: Int64 = 0
private(set) var isReporting = false
private let appStore: AppStore
private let locationStateStore: HomeLocationStateStore
private let locationProvider: HomeLocationProvider
init(
appStore: AppStore = .shared,
locationStateStore: HomeLocationStateStore,
locationProvider: HomeLocationProvider = HomeLocationProvider()
) {
self.appStore = appStore
self.locationStateStore = locationStateStore
self.locationProvider = locationProvider
}
/// 线/线
func switchOnlineStatus(api: HomeAPI) async throws -> LocationReportSuccessResult? {
let now = currentTimestampMillis()
if now - lastOnlineStatusSwitchTime < Self.operationIntervalMillis {
let remaining = Int((Self.operationIntervalMillis - (now - lastOnlineStatusSwitchTime)) / 1000)
throw LocationReportServiceError.tooFrequent(remainingSeconds: remaining)
}
guard appStore.currentScenicId > 0 else {
throw LocationReportServiceError.missingScenic
}
let goingOnline = !locationStateStore.isOnline
if goingOnline {
_ = try await locationProvider.requestSnapshot()
locationStateStore.updateOnlineStatus(true)
lastOnlineStatusSwitchTime = now
return try await reportWithCoordinates(
api: api,
latitude: nil,
longitude: nil,
address: nil,
type: 1,
showSuccessToast: true
)
}
locationStateStore.updateOnlineStatus(false)
lastOnlineStatusSwitchTime = now
_ = try await reportWithCoordinates(
api: api,
latitude: nil,
longitude: nil,
address: nil,
type: 3,
showSuccessToast: false
)
return nil
}
/// GPS
func manualReport(api: HomeAPI) async throws -> LocationReportSuccessResult {
let now = currentTimestampMillis()
if now - lastLocationReportTime < Self.operationIntervalMillis {
let remaining = Int((Self.operationIntervalMillis - (now - lastLocationReportTime)) / 1000)
throw LocationReportServiceError.tooFrequent(remainingSeconds: remaining)
}
return try await reportWithCoordinates(
api: api,
latitude: nil,
longitude: nil,
address: nil,
type: 1,
showSuccessToast: true
)
}
/// type=2
func reportWithCoordinates(
api: HomeAPI,
latitude: Double?,
longitude: Double?,
address: String?,
type: Int,
showSuccessToast: Bool = true
) async throws -> LocationReportSuccessResult {
if type != 3 {
let now = currentTimestampMillis()
if now - lastLocationReportTime < Self.operationIntervalMillis {
let remaining = Int((Self.operationIntervalMillis - (now - lastLocationReportTime)) / 1000)
throw LocationReportServiceError.tooFrequent(remainingSeconds: remaining)
}
}
guard appStore.currentScenicId > 0 else {
throw LocationReportServiceError.missingScenic
}
let staffId = appStore.userId.trimmingCharacters(in: .whitespacesAndNewlines)
guard !staffId.isEmpty else {
throw LocationReportServiceError.missingStaffId
}
isReporting = true
defer { isReporting = false }
let snapshot: HomeLocationSnapshot
if let latitude, let longitude {
let resolvedAddress: String
if let address, !address.isEmpty {
resolvedAddress = address
} else {
resolvedAddress = await locationProvider.reverseGeocode(
latitude: latitude,
longitude: longitude
)
}
snapshot = HomeLocationSnapshot(
latitude: latitude,
longitude: longitude,
address: resolvedAddress
)
} else {
snapshot = try await locationProvider.requestSnapshot()
}
let locationText = snapshot.address.isEmpty
? String(format: "%.5f, %.5f", snapshot.latitude, snapshot.longitude)
: snapshot.address
_ = try await api.reportLocation(
staffId: staffId,
latitude: snapshot.latitude,
longitude: snapshot.longitude,
address: snapshot.address,
type: type,
scenicId: String(appStore.currentScenicId)
)
lastLocationReportTime = currentTimestampMillis()
if type != 3 {
locationStateStore.startLocationReport()
let reportTime = formattedReportTime()
if showSuccessToast {
// Toast handled by caller via onShowMessage if needed
}
return LocationReportSuccessResult(
reportTimeText: reportTime,
locationInfoText: locationText,
shouldShowSuccessDialog: true
)
}
return LocationReportSuccessResult(
reportTimeText: "",
locationInfoText: locationText,
shouldShowSuccessDialog: false
)
}
private func formattedReportTime() -> String {
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "zh_CN")
formatter.dateFormat = "HH:mm"
return formatter.string(from: Date())
}
private func currentTimestampMillis() -> Int64 {
Int64(Date().timeIntervalSince1970 * 1000)
}
}
///
enum LocationReportServiceError: LocalizedError, Equatable {
case tooFrequent(remainingSeconds: Int)
case missingScenic
case missingStaffId
case locating
var errorDescription: String? {
switch self {
case .tooFrequent(let remaining):
"操作过于频繁,请\(remaining)秒后再试"
case .missingScenic:
"请先选择景区"
case .missingStaffId:
"获取用户ID失败"
case .locating:
"正在定位中,请稍候"
}
}
}

View File

@ -0,0 +1,124 @@
//
// LocationReportHistoryViewModel.swift
// suixinkan
//
import Foundation
/// ViewModel Android `LocationReportHistoryViewModel`
final class LocationReportHistoryViewModel {
private(set) var reportList: [LocationReportHistoryItem] = []
private(set) var selectedFilter: LocationReportFilterType = .all
private(set) var isRefreshing = false
private(set) var canLoadMore = false
private(set) var startDate: Date?
private(set) var endDate: Date?
var onStateChange: (() -> Void)?
var onShowMessage: ((String) -> Void)?
private let appStore: AppStore
private var currentPage = 1
private let pageSize = 10
private var isLoading = false
init(appStore: AppStore = .shared) {
self.appStore = appStore
}
var startDateText: String { formattedDate(startDate) }
var endDateText: String { formattedDate(endDate) }
var hasDateFilter: Bool { startDate != nil || endDate != nil }
///
func loadReportList(api: HomeAPI, refresh: Bool = false) async {
guard !isLoading || refresh else { return }
let staffId = appStore.userId.trimmingCharacters(in: .whitespacesAndNewlines)
guard !staffId.isEmpty else {
onShowMessage?("获取用户ID失败")
return
}
if refresh {
currentPage = 1
isRefreshing = true
notifyStateChange()
}
isLoading = true
defer {
isLoading = false
isRefreshing = false
notifyStateChange()
}
do {
let response = try await api.locationReportList(
staffId: staffId,
page: currentPage,
pageSize: pageSize,
type: selectedFilter.typeCode,
startDate: apiDateString(startDate),
endDate: apiDateString(endDate)
)
if refresh || currentPage == 1 {
reportList = response.list
} else {
reportList.append(contentsOf: response.list)
}
currentPage += 1
canLoadMore = reportList.count < response.total
notifyStateChange()
} catch is CancellationError {
return
} catch {
onShowMessage?(error.localizedDescription)
}
}
func selectFilter(_ filter: LocationReportFilterType, api: HomeAPI) async {
selectedFilter = filter
currentPage = 1
await loadReportList(api: api, refresh: true)
}
func setDateRange(start: Date?, end: Date?, api: HomeAPI) async {
startDate = start
endDate = end
currentPage = 1
await loadReportList(api: api, refresh: true)
}
func clearDateRange(api: HomeAPI) async {
startDate = nil
endDate = nil
currentPage = 1
await loadReportList(api: api, refresh: true)
}
func loadMoreIfNeeded(currentIndex: Int, api: HomeAPI) async {
guard canLoadMore, !isLoading else { return }
guard currentIndex >= reportList.count - 2 else { return }
await loadReportList(api: api, refresh: false)
}
private func apiDateString(_ date: Date?) -> String? {
guard let date else { return nil }
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "zh_CN")
formatter.dateFormat = "yyyy-MM-dd"
return formatter.string(from: date)
}
private func formattedDate(_ date: Date?) -> String {
guard let date else { return "请选择" }
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "zh_CN")
formatter.dateFormat = "yyyy-MM-dd"
return formatter.string(from: date)
}
private func notifyStateChange() {
onStateChange?()
}
}

View File

@ -0,0 +1,197 @@
//
// LocationReportViewModel.swift
// suixinkan
//
import Foundation
/// ViewModel Android `LocationReportViewModel` +
final class LocationReportViewModel {
private(set) var currentLatitude: Double?
private(set) var currentLongitude: Double?
private(set) var currentAddress: String?
private(set) var isLocating = false
private(set) var isMarking = false
private(set) var markerLatitude: Double?
private(set) var markerLongitude: Double?
private(set) var markerAddress: String?
private(set) var showOnlineStatusDialog = false
private(set) var showLocationReportSuccessDialog = false
private(set) var reportTimeText = ""
let locationStateStore: HomeLocationStateStore
let locationReportService: LocationReportService
var onStateChange: (() -> Void)?
var onShowMessage: ((String) -> Void)?
private let locationProvider: HomeLocationProvider
init(
locationStateStore: HomeLocationStateStore = HomeLocationStateStore(),
locationProvider: HomeLocationProvider = HomeLocationProvider()
) {
self.locationStateStore = locationStateStore
self.locationProvider = locationProvider
self.locationReportService = LocationReportService(
locationStateStore: locationStateStore,
locationProvider: locationProvider
)
self.locationStateStore.onStateChange = { [weak self] in
self?.notifyStateChange()
}
}
var isOnline: Bool { locationStateStore.isOnline }
var countdownDisplayText: String { locationStateStore.countdownDisplayTextSingleHour }
var reminderMinutes: Int { locationStateStore.reminderMinutes }
var isReporting: Bool { locationReportService.isReporting }
/// 线
func restoreStateIfNeeded() {
locationStateStore.restoreStateIfNeeded()
notifyStateChange()
}
///
func startLocation() {
guard !isLocating else { return }
isLocating = true
notifyStateChange()
Task {
defer {
isLocating = false
notifyStateChange()
}
do {
let snapshot = try await locationProvider.requestSnapshot()
currentLatitude = snapshot.latitude
currentLongitude = snapshot.longitude
currentAddress = snapshot.address.isEmpty
? String(format: "%.5f, %.5f", snapshot.latitude, snapshot.longitude)
: snapshot.address
notifyStateChange()
} catch {
onShowMessage?((error as? LocalizedError)?.errorDescription ?? error.localizedDescription)
}
}
}
func toggleMarking() {
isMarking.toggle()
if !isMarking {
markerLatitude = nil
markerLongitude = nil
markerAddress = nil
}
notifyStateChange()
}
///
func setMarkerLocation(latitude: Double, longitude: Double) {
guard isMarking else { return }
markerLatitude = latitude
markerLongitude = longitude
markerAddress = nil
notifyStateChange()
Task {
let address = await locationProvider.reverseGeocode(latitude: latitude, longitude: longitude)
markerAddress = address.isEmpty
? String(format: "%.5f, %.5f", latitude, longitude)
: address
notifyStateChange()
}
}
func showOnlineStatusSwitchDialog() {
showOnlineStatusDialog = true
notifyStateChange()
}
func hideOnlineStatusSwitchDialog() {
showOnlineStatusDialog = false
notifyStateChange()
}
func hideLocationReportSuccessDialog() {
showLocationReportSuccessDialog = false
notifyStateChange()
}
func updateReminderMinutes(_ minutes: Int) {
locationStateStore.updateReminderMinutes(minutes)
notifyStateChange()
}
func switchOnlineStatus(api: HomeAPI) async {
hideOnlineStatusSwitchDialog()
do {
let goingOnline = !isOnline
if let result = try await locationReportService.switchOnlineStatus(api: api) {
applyReportSuccess(result)
onShowMessage?(goingOnline ? "已切换到在线状态,正在上报位置" : "已切换到离线状态")
} else {
onShowMessage?("已切换到离线状态")
}
} catch let error as LocationReportServiceError {
onShowMessage?(error.localizedDescription)
} catch {
onShowMessage?(error.localizedDescription)
}
}
/// type=2 type=1
func reportLocation(api: HomeAPI) async {
if isMarking {
guard let lat = markerLatitude, let lng = markerLongitude else {
onShowMessage?("请先在地图上选择标记点")
return
}
await reportCoordinates(api: api, latitude: lat, longitude: lng, address: markerAddress, type: 2)
return
}
guard let lat = currentLatitude, let lng = currentLongitude else {
onShowMessage?("正在定位中,请稍候")
return
}
await reportCoordinates(api: api, latitude: lat, longitude: lng, address: currentAddress, type: 1)
}
private func reportCoordinates(
api: HomeAPI,
latitude: Double,
longitude: Double,
address: String?,
type: Int
) async {
do {
let result = try await locationReportService.reportWithCoordinates(
api: api,
latitude: latitude,
longitude: longitude,
address: address,
type: type
)
applyReportSuccess(result)
onShowMessage?("位置上报成功")
} catch let error as LocationReportServiceError {
onShowMessage?(error.localizedDescription)
} catch {
onShowMessage?(error.localizedDescription)
}
}
private func applyReportSuccess(_ result: LocationReportSuccessResult) {
if result.shouldShowSuccessDialog {
reportTimeText = result.reportTimeText
showLocationReportSuccessDialog = true
}
notifyStateChange()
}
private func notifyStateChange() {
onStateChange?()
}
}