Files
suixinkan_ios_new/suixinkan/Features/Home/ViewModels/HomeLocationViewModel.swift
汉秋 560b52fcc8 接入首页位置上报与在线倒计时,并优化上报交互体验。
持久化服务端 expired 过期时间戳以正确恢复倒计时,切换在线/离线与上报时展示定位 Loading,移除重复的成功 Alert,并将 Toast 调整为居中半透明样式。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-29 15:59:59 +08:00

381 lines
14 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.

//
// 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
}()
}