持久化服务端 expired 过期时间戳以正确恢复倒计时,切换在线/离线与上报时展示定位 Loading,移除重复的成功 Alert,并将 Toast 调整为居中半透明样式。 Co-authored-by: Cursor <cursoragent@cursor.com>
381 lines
14 KiB
Swift
381 lines
14 KiB
Swift
//
|
||
// 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
|
||
}()
|
||
}
|