// // 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 showTimeoutDialog = false timeoutDialogTriggered = false 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) } /// 根据本地在线状态和倒计时判断是否需要弹出超时提醒。 func checkLocationTimeout(staffId: Int?) async { guard isOnline, reminderMinutes > 0, secondsUntilReport > 0 else { showTimeoutDialog = false return } evaluateReminderThreshold(remainingSeconds: secondsUntilReport, forceDialog: 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 showTimeoutDialog = false timeoutDialogTriggered = false 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 isOnline, 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 }() }