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:
@ -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:
|
||||
"正在定位中,请稍候"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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?()
|
||||
}
|
||||
}
|
||||
@ -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?()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user