接入首页位置上报与在线倒计时,并优化上报交互体验。
持久化服务端 expired 过期时间戳以正确恢复倒计时,切换在线/离线与上报时展示定位 Loading,移除重复的成功 Alert,并将 Toast 调整为居中半透明样式。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
380
suixinkan/Features/Home/ViewModels/HomeLocationViewModel.swift
Normal file
380
suixinkan/Features/Home/ViewModels/HomeLocationViewModel.swift
Normal file
@ -0,0 +1,380 @@
|
||||
//
|
||||
// HomeLocationViewModel.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/29.
|
||||
//
|
||||
|
||||
import Combine
|
||||
import CoreLocation
|
||||
import Foundation
|
||||
|
||||
/// 首页位置上报 ViewModel,负责在线状态、倒计时、GPS 上报和超时提醒。
|
||||
@MainActor
|
||||
final class HomeLocationViewModel: ObservableObject {
|
||||
@Published private(set) var isOnline = false
|
||||
@Published private(set) var secondsUntilReport = 0
|
||||
@Published private(set) var reminderMinutes = 0
|
||||
@Published private(set) var isReporting = false
|
||||
@Published var showTimeoutDialog = false
|
||||
@Published var showReminderDialog = false
|
||||
@Published var reminderDialogMessage = ""
|
||||
@Published private(set) var reportTimeText = ""
|
||||
|
||||
private let api: any LocationReportServing
|
||||
private let store: LocationStateStore
|
||||
private var locationProvider: ForegroundLocationProvider?
|
||||
|
||||
private var lastReportAnchorTime: TimeInterval?
|
||||
private var expireTimestamp: Int?
|
||||
private var lastOperationTime: TimeInterval = 0
|
||||
private var timeoutDialogTriggered = false
|
||||
private var reminderTriggered = false
|
||||
private var hasRestoredState = false
|
||||
|
||||
private let operationIntervalSeconds = 30
|
||||
|
||||
/// 倒计时展示文案(H:MM:SS)。
|
||||
var countdownDisplay: String {
|
||||
let hours = secondsUntilReport / 3600
|
||||
let minutes = (secondsUntilReport % 3600) / 60
|
||||
let seconds = secondsUntilReport % 60
|
||||
return "\(hours):\(String(format: "%02d", minutes)):\(String(format: "%02d", seconds))"
|
||||
}
|
||||
|
||||
/// 初始化首页位置上报 ViewModel。
|
||||
nonisolated init(
|
||||
api: any LocationReportServing,
|
||||
store: LocationStateStore = LocationStateStore()
|
||||
) {
|
||||
self.api = api
|
||||
self.store = store
|
||||
}
|
||||
|
||||
/// 从本地存储恢复在线状态和倒计时。
|
||||
func restoreState() {
|
||||
guard !hasRestoredState else { return }
|
||||
hasRestoredState = true
|
||||
|
||||
reminderMinutes = store.loadReminderMinutes()
|
||||
guard store.loadOnlineStatus() else {
|
||||
isOnline = false
|
||||
secondsUntilReport = 0
|
||||
expireTimestamp = nil
|
||||
return
|
||||
}
|
||||
|
||||
let persistedExpireTime = store.loadExpireTime()
|
||||
if persistedExpireTime > 0 {
|
||||
let remaining = store.remainingSeconds(untilExpireTime: persistedExpireTime)
|
||||
guard remaining > 0 else {
|
||||
setOfflineLocally(clearPersisted: true)
|
||||
reminderDialogMessage = "距离上次上报已超过两小时,请立即重新上报位置。"
|
||||
showReminderDialog = true
|
||||
return
|
||||
}
|
||||
|
||||
isOnline = true
|
||||
expireTimestamp = persistedExpireTime
|
||||
secondsUntilReport = remaining
|
||||
lastReportAnchorTime = store.loadLastReportTime()
|
||||
evaluateReminderThreshold(remainingSeconds: remaining, forceDialog: false)
|
||||
return
|
||||
}
|
||||
|
||||
let lastTime = store.loadLastReportTime()
|
||||
guard lastTime > 0 else {
|
||||
setOfflineLocally()
|
||||
return
|
||||
}
|
||||
|
||||
let remaining = store.remainingSeconds(since: lastTime)
|
||||
guard remaining > 0 else {
|
||||
setOfflineLocally(clearPersisted: true)
|
||||
reminderDialogMessage = "距离上次上报已超过两小时,请立即重新上报位置。"
|
||||
showReminderDialog = true
|
||||
return
|
||||
}
|
||||
|
||||
isOnline = true
|
||||
secondsUntilReport = remaining
|
||||
lastReportAnchorTime = lastTime
|
||||
evaluateReminderThreshold(remainingSeconds: remaining, forceDialog: false)
|
||||
}
|
||||
|
||||
/// 每秒驱动倒计时,并在到期时自动离线。
|
||||
func tick() {
|
||||
guard isOnline, secondsUntilReport > 0 else { return }
|
||||
secondsUntilReport -= 1
|
||||
|
||||
if secondsUntilReport <= 0 {
|
||||
setOfflineLocally(clearPersisted: true)
|
||||
reminderDialogMessage = "距离上次上报已超过两小时,请立即重新上报位置。"
|
||||
showReminderDialog = true
|
||||
return
|
||||
}
|
||||
|
||||
evaluateReminderThreshold(remainingSeconds: secondsUntilReport, forceDialog: false)
|
||||
}
|
||||
|
||||
/// 更新提前提醒分钟数并持久化。
|
||||
func updateReminderMinutes(_ minutes: Int) {
|
||||
reminderMinutes = minutes
|
||||
store.saveReminderMinutes(minutes)
|
||||
reminderTriggered = false
|
||||
timeoutDialogTriggered = false
|
||||
|
||||
if let expireTime = expireTimestamp {
|
||||
let remaining = store.remainingSeconds(untilExpireTime: expireTime)
|
||||
evaluateReminderThreshold(remainingSeconds: remaining, forceDialog: false)
|
||||
} else if let anchor = lastReportAnchorTime {
|
||||
let remaining = store.remainingSeconds(since: anchor)
|
||||
evaluateReminderThreshold(remainingSeconds: remaining, forceDialog: false)
|
||||
}
|
||||
}
|
||||
|
||||
/// 确认切换在线状态:上线自动立即上报,下线提交离线上报。
|
||||
func confirmOnlineToggle(staffId: Int?, scenicId: Int?, toast: ToastCenter) async {
|
||||
guard enforceOperationInterval(for: .immediate, toast: toast) else { return }
|
||||
guard validateScope(staffId: staffId, scenicId: scenicId, toast: toast) else { return }
|
||||
|
||||
if isOnline {
|
||||
setOfflineLocally(clearPersisted: false)
|
||||
store.saveOnlineStatus(false)
|
||||
await reportLocation(type: .offlineReport, staffId: staffId!, scenicId: scenicId!, toast: toast)
|
||||
} else {
|
||||
store.saveOnlineStatus(true)
|
||||
isOnline = true
|
||||
await reportLocation(type: .immediate, staffId: staffId!, scenicId: scenicId!, toast: toast)
|
||||
}
|
||||
}
|
||||
|
||||
/// 首页立即上报:定位后提交 type=1。
|
||||
func startImmediateReport(staffId: Int?, scenicId: Int?, toast: ToastCenter) async {
|
||||
guard enforceOperationInterval(for: .immediate, toast: toast) else { return }
|
||||
guard validateScope(staffId: staffId, scenicId: scenicId, toast: toast) else { return }
|
||||
await reportLocation(type: .immediate, staffId: staffId!, scenicId: scenicId!, toast: toast)
|
||||
}
|
||||
|
||||
/// 调用 detail 接口判断是否需要弹出超时提醒。
|
||||
func checkLocationTimeout(staffId: Int?) async {
|
||||
guard reminderMinutes > 0 else {
|
||||
showTimeoutDialog = false
|
||||
return
|
||||
}
|
||||
guard let staffId, staffId > 0 else {
|
||||
showTimeoutDialog = false
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
let detail = try await api.locationDetail(staffId: staffId)
|
||||
if detail.expireTime <= 0 {
|
||||
showTimeoutDialog = true
|
||||
return
|
||||
}
|
||||
|
||||
let currentMillis = Date().timeIntervalSince1970 * 1000
|
||||
let expireMillis = Double(detail.expireTime) * 1000
|
||||
let thresholdMillis = expireMillis - Double(reminderMinutes * 60 * 1000)
|
||||
showTimeoutDialog = currentMillis >= thresholdMillis
|
||||
} catch {
|
||||
showTimeoutDialog = true
|
||||
}
|
||||
}
|
||||
|
||||
/// 关闭超时对话框并将用户置为离线。
|
||||
func dismissTimeoutDialog() {
|
||||
showTimeoutDialog = false
|
||||
timeoutDialogTriggered = false
|
||||
setOfflineLocally(clearPersisted: true)
|
||||
}
|
||||
|
||||
/// 关闭提醒对话框。
|
||||
func dismissReminderDialog() {
|
||||
showReminderDialog = false
|
||||
}
|
||||
|
||||
/// 详情页上报成功后同步全局倒计时。
|
||||
func handleReportSuccess(expired: Int, type: LocationReportType) {
|
||||
guard type != .offlineReport else { return }
|
||||
beginOnlineCountdown(expired: expired)
|
||||
}
|
||||
|
||||
/// 提交指定坐标的上报,供详情页在已有坐标时复用。
|
||||
func reportCoordinate(
|
||||
latitude: Double,
|
||||
longitude: Double,
|
||||
address: String,
|
||||
type: LocationReportType,
|
||||
staffId: Int?,
|
||||
scenicId: Int?,
|
||||
toast: ToastCenter
|
||||
) async -> Bool {
|
||||
guard !isReporting else { return false }
|
||||
if type != .offlineReport {
|
||||
guard enforceOperationInterval(for: type, toast: toast) else { return false }
|
||||
}
|
||||
guard validateScope(staffId: staffId, scenicId: scenicId, toast: toast) else { return false }
|
||||
|
||||
isReporting = true
|
||||
defer { isReporting = false }
|
||||
|
||||
do {
|
||||
let response = try await api.reportLocation(
|
||||
staffId: staffId!,
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
address: address,
|
||||
type: type,
|
||||
scenicId: scenicId!
|
||||
)
|
||||
lastOperationTime = Date().timeIntervalSince1970 * 1000
|
||||
if type != .offlineReport {
|
||||
beginOnlineCountdown(expired: response.expired)
|
||||
}
|
||||
showTimeoutDialog = false
|
||||
timeoutDialogTriggered = false
|
||||
reminderTriggered = false
|
||||
return true
|
||||
} catch {
|
||||
toast.show("位置上报失败:\(error.localizedDescription)")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/// 定位并上报指定类型。
|
||||
private func reportLocation(
|
||||
type: LocationReportType,
|
||||
staffId: Int,
|
||||
scenicId: Int,
|
||||
toast: ToastCenter
|
||||
) async {
|
||||
guard hasLocationPermission() else {
|
||||
toast.show("需要位置权限才能上报位置")
|
||||
return
|
||||
}
|
||||
|
||||
isReporting = true
|
||||
defer { isReporting = false }
|
||||
|
||||
do {
|
||||
let location = try await activeLocationProvider().requestCurrentLocation()
|
||||
let response = try await api.reportLocation(
|
||||
staffId: staffId,
|
||||
latitude: location.latitude,
|
||||
longitude: location.longitude,
|
||||
address: location.address,
|
||||
type: type,
|
||||
scenicId: scenicId
|
||||
)
|
||||
lastOperationTime = Date().timeIntervalSince1970 * 1000
|
||||
if type != .offlineReport {
|
||||
beginOnlineCountdown(expired: response.expired)
|
||||
toast.show("位置上报成功")
|
||||
} else {
|
||||
toast.show("已切换到离线状态")
|
||||
}
|
||||
showTimeoutDialog = false
|
||||
timeoutDialogTriggered = false
|
||||
reminderTriggered = false
|
||||
} catch {
|
||||
toast.show(type == .offlineReport ? "离线上报失败:\(error.localizedDescription)" : "位置上报失败:\(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取或创建前台定位 Provider。
|
||||
private func activeLocationProvider() -> ForegroundLocationProvider {
|
||||
if let locationProvider {
|
||||
return locationProvider
|
||||
}
|
||||
let provider = ForegroundLocationProvider()
|
||||
locationProvider = provider
|
||||
return provider
|
||||
}
|
||||
|
||||
/// 上报成功后根据服务端 expired 启动在线倒计时。
|
||||
private func beginOnlineCountdown(expired: Int) {
|
||||
let now = Date().timeIntervalSince1970 * 1000
|
||||
let expireTime = store.normalizedExpireTimestamp(from: expired)
|
||||
lastReportAnchorTime = now
|
||||
expireTimestamp = expireTime
|
||||
store.saveLastReportTime(now)
|
||||
store.saveExpireTime(expireTime)
|
||||
store.saveOnlineStatus(true)
|
||||
isOnline = true
|
||||
secondsUntilReport = store.remainingSeconds(untilExpireTime: expireTime)
|
||||
reportTimeText = Self.timeFormatter.string(from: Date())
|
||||
}
|
||||
|
||||
/// 本地置离线并可选清除持久化。
|
||||
private func setOfflineLocally(clearPersisted: Bool = true) {
|
||||
isOnline = false
|
||||
secondsUntilReport = 0
|
||||
lastReportAnchorTime = nil
|
||||
expireTimestamp = nil
|
||||
if clearPersisted {
|
||||
store.saveOnlineStatus(false)
|
||||
store.clearLastReportTime()
|
||||
store.clearExpireTime()
|
||||
}
|
||||
}
|
||||
|
||||
/// 校验 staffId 和 scenicId。
|
||||
private func validateScope(staffId: Int?, scenicId: Int?, toast: ToastCenter) -> Bool {
|
||||
guard let scenicId, scenicId > 0 else {
|
||||
toast.show("请先选择景区")
|
||||
return false
|
||||
}
|
||||
guard let staffId, staffId > 0 else {
|
||||
toast.show("获取用户ID失败")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/// 限制 30 秒内重复上报或切换(离任 type=3)。
|
||||
private func enforceOperationInterval(for type: LocationReportType, toast: ToastCenter) -> Bool {
|
||||
guard type != .offlineReport else { return true }
|
||||
let now = Date().timeIntervalSince1970 * 1000
|
||||
let elapsed = now - lastOperationTime
|
||||
guard lastOperationTime > 0, elapsed < Double(operationIntervalSeconds * 1000) else { return true }
|
||||
let remaining = Int((Double(operationIntervalSeconds * 1000) - elapsed) / 1000)
|
||||
toast.show("操作过于频繁,请\(max(remaining, 1))秒后再试")
|
||||
return false
|
||||
}
|
||||
|
||||
/// 判断是否到达提前提醒阈值。
|
||||
private func evaluateReminderThreshold(remainingSeconds: Int, forceDialog: Bool) {
|
||||
guard reminderMinutes > 0 else { return }
|
||||
let thresholdSeconds = reminderMinutes * 60
|
||||
guard remainingSeconds <= thresholdSeconds else { return }
|
||||
|
||||
if !reminderTriggered {
|
||||
reminderTriggered = true
|
||||
reminderDialogMessage = "倒计时即将结束,请尽快重新上报位置。"
|
||||
showReminderDialog = true
|
||||
}
|
||||
|
||||
if !timeoutDialogTriggered || forceDialog {
|
||||
timeoutDialogTriggered = true
|
||||
showTimeoutDialog = true
|
||||
}
|
||||
}
|
||||
|
||||
/// 检查是否已授予前台定位权限。
|
||||
private func hasLocationPermission() -> Bool {
|
||||
switch CLLocationManager.authorizationStatus() {
|
||||
case .authorizedAlways, .authorizedWhenInUse:
|
||||
true
|
||||
default:
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private static let timeFormatter: DateFormatter = {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "HH:mm"
|
||||
return formatter
|
||||
}()
|
||||
}
|
||||
Reference in New Issue
Block a user