fix: unify location report state
This commit is contained in:
@ -8,6 +8,8 @@ import Foundation
|
||||
/// 首页在线状态与 2 小时倒计时存储,对齐 Android `LocationStateRepository`。
|
||||
final class HomeLocationStateStore {
|
||||
|
||||
static let shared = HomeLocationStateStore(store: .shared)
|
||||
|
||||
static let onlineDurationMillis: Int64 = 2 * 60 * 60 * 1000
|
||||
|
||||
private(set) var isOnline = false
|
||||
@ -15,11 +17,10 @@ final class HomeLocationStateStore {
|
||||
private(set) var nextReportCountdownSeconds: Int64 = 0
|
||||
private(set) var reminderMinutes = 0
|
||||
|
||||
var onStateChange: (() -> Void)?
|
||||
|
||||
private let store: AppStore
|
||||
private var countdownTask: Task<Void, Never>?
|
||||
private var anchorTimestamp: Int64?
|
||||
private var observers: [ObjectIdentifier: StateObserver] = [:]
|
||||
|
||||
init(store: AppStore = .shared) {
|
||||
self.store = store
|
||||
@ -27,11 +28,38 @@ final class HomeLocationStateStore {
|
||||
isOnline = store.onlineStatus
|
||||
}
|
||||
|
||||
deinit {
|
||||
countdownTask?.cancel()
|
||||
}
|
||||
|
||||
/// 监听状态变化,允许首页与独立位置上报页同时刷新。
|
||||
func observe(_ owner: AnyObject, handler: @escaping () -> Void) {
|
||||
observers[ObjectIdentifier(owner)] = StateObserver(owner: owner, handler: handler)
|
||||
}
|
||||
|
||||
/// 移除指定监听者。
|
||||
func removeObserver(_ owner: AnyObject) {
|
||||
observers.removeValue(forKey: ObjectIdentifier(owner))
|
||||
}
|
||||
|
||||
/// 启动时恢复在线倒计时。
|
||||
func restoreStateIfNeeded() {
|
||||
guard store.onlineStatus else { return }
|
||||
reminderMinutes = store.locationReminderMinutes
|
||||
guard store.onlineStatus else {
|
||||
isOnline = false
|
||||
stopCountdown()
|
||||
notifyChange()
|
||||
return
|
||||
}
|
||||
let lastTime = store.lastLocationReportTime
|
||||
guard lastTime > 0 else { return }
|
||||
guard lastTime > 0 else {
|
||||
isOnline = false
|
||||
store.onlineStatus = false
|
||||
store.clearLastLocationReportTime()
|
||||
stopCountdown()
|
||||
notifyChange()
|
||||
return
|
||||
}
|
||||
|
||||
let now = Int64(Date().timeIntervalSince1970 * 1000)
|
||||
let elapsed = now - lastTime
|
||||
@ -147,6 +175,17 @@ final class HomeLocationStateStore {
|
||||
}
|
||||
|
||||
private func notifyChange() {
|
||||
onStateChange?()
|
||||
observers = observers.filter { $0.value.owner != nil }
|
||||
observers.values.forEach { $0.handler() }
|
||||
}
|
||||
}
|
||||
|
||||
private final class StateObserver {
|
||||
weak var owner: AnyObject?
|
||||
let handler: () -> Void
|
||||
|
||||
init(owner: AnyObject, handler: @escaping () -> Void) {
|
||||
self.owner = owner
|
||||
self.handler = handler
|
||||
}
|
||||
}
|
||||
|
||||
@ -37,7 +37,7 @@ final class HomeViewModel {
|
||||
|
||||
init(
|
||||
appStore: AppStore = .shared,
|
||||
locationStateStore: HomeLocationStateStore = HomeLocationStateStore(),
|
||||
locationStateStore: HomeLocationStateStore = .shared,
|
||||
commonMenuStore: HomeCommonMenuStore = HomeCommonMenuStore(),
|
||||
locationProvider: any LocationProviding = LocationProvider.shared
|
||||
) {
|
||||
@ -50,11 +50,15 @@ final class HomeViewModel {
|
||||
locationProvider: locationProvider
|
||||
)
|
||||
self.commonMenuStore = commonMenuStore
|
||||
self.locationStateStore.onStateChange = { [weak self] in
|
||||
self.locationStateStore.observe(self) { [weak self] in
|
||||
self?.notifyStateChange()
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
locationStateStore.removeObserver(self)
|
||||
}
|
||||
|
||||
var isOnline: Bool { locationStateStore.isOnline }
|
||||
var countdownDisplayText: String { locationStateStore.countdownDisplayText }
|
||||
var reminderMinutes: Int { locationStateStore.reminderMinutes }
|
||||
@ -73,6 +77,7 @@ final class HomeViewModel {
|
||||
|
||||
/// 按需重新拉取权限。
|
||||
func reloadIfNeeded(api: HomeAPI) async {
|
||||
locationStateStore.restoreStateIfNeeded()
|
||||
guard needsPermissionReload else {
|
||||
refreshLocalDisplayState()
|
||||
rebuildCommonMenus()
|
||||
|
||||
@ -29,7 +29,7 @@ final class LocationReportViewModel {
|
||||
private let locationProvider: any LocationProviding
|
||||
|
||||
init(
|
||||
locationStateStore: HomeLocationStateStore = HomeLocationStateStore(),
|
||||
locationStateStore: HomeLocationStateStore = .shared,
|
||||
locationProvider: any LocationProviding = LocationProvider.shared
|
||||
) {
|
||||
self.locationStateStore = locationStateStore
|
||||
@ -38,11 +38,15 @@ final class LocationReportViewModel {
|
||||
locationStateStore: locationStateStore,
|
||||
locationProvider: locationProvider
|
||||
)
|
||||
self.locationStateStore.onStateChange = { [weak self] in
|
||||
self.locationStateStore.observe(self) { [weak self] in
|
||||
self?.notifyStateChange()
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
locationStateStore.removeObserver(self)
|
||||
}
|
||||
|
||||
var isOnline: Bool { locationStateStore.isOnline }
|
||||
var countdownDisplayText: String { locationStateStore.countdownDisplayTextSingleHour }
|
||||
var reminderMinutes: Int { locationStateStore.reminderMinutes }
|
||||
|
||||
@ -10,9 +10,10 @@ import XCTest
|
||||
final class HomeLocationStateStoreTests: XCTestCase {
|
||||
|
||||
private var appStore: AppStore!
|
||||
private var defaults: UserDefaults!
|
||||
|
||||
override func setUp() {
|
||||
let defaults = UserDefaults(suiteName: "HomeLocationStateStoreTests")!
|
||||
defaults = UserDefaults(suiteName: "HomeLocationStateStoreTests")!
|
||||
defaults.removePersistentDomain(forName: "HomeLocationStateStoreTests")
|
||||
appStore = AppStore(defaults: defaults)
|
||||
appStore.userId = "1001"
|
||||
@ -63,4 +64,88 @@ final class HomeLocationStateStoreTests: XCTestCase {
|
||||
// verify helper with manual expectation on threshold logic.
|
||||
XCTAssertFalse(stateStore.shouldTriggerTimeoutReminder())
|
||||
}
|
||||
|
||||
func testMultipleObserversReceiveStateChanges() {
|
||||
let stateStore = HomeLocationStateStore(store: appStore)
|
||||
let firstOwner = StateObserverOwner()
|
||||
let secondOwner = StateObserverOwner()
|
||||
var firstChangeCount = 0
|
||||
var secondChangeCount = 0
|
||||
|
||||
stateStore.observe(firstOwner) { firstChangeCount += 1 }
|
||||
stateStore.observe(secondOwner) { secondChangeCount += 1 }
|
||||
stateStore.updateReminderMinutes(5)
|
||||
|
||||
XCTAssertEqual(firstChangeCount, 1)
|
||||
XCTAssertEqual(secondChangeCount, 1)
|
||||
|
||||
stateStore.removeObserver(firstOwner)
|
||||
stateStore.updateReminderMinutes(10)
|
||||
|
||||
XCTAssertEqual(firstChangeCount, 1)
|
||||
XCTAssertEqual(secondChangeCount, 2)
|
||||
}
|
||||
|
||||
func testViewModelsShareReminderMinutesWhenUsingSameStateStore() {
|
||||
let stateStore = HomeLocationStateStore(store: appStore)
|
||||
let homeViewModel = HomeViewModel(appStore: appStore, locationStateStore: stateStore)
|
||||
let reportViewModel = LocationReportViewModel(
|
||||
locationStateStore: stateStore,
|
||||
locationProvider: MockLocationProvider()
|
||||
)
|
||||
|
||||
homeViewModel.updateReminderMinutes(15)
|
||||
|
||||
XCTAssertEqual(reportViewModel.reminderMinutes, 15)
|
||||
|
||||
reportViewModel.updateReminderMinutes(5)
|
||||
|
||||
XCTAssertEqual(homeViewModel.reminderMinutes, 5)
|
||||
}
|
||||
|
||||
func testViewModelsShareOnlineStateWhenUsingSameStateStore() {
|
||||
let stateStore = HomeLocationStateStore(store: appStore)
|
||||
let homeViewModel = HomeViewModel(appStore: appStore, locationStateStore: stateStore)
|
||||
let reportViewModel = LocationReportViewModel(
|
||||
locationStateStore: stateStore,
|
||||
locationProvider: MockLocationProvider()
|
||||
)
|
||||
|
||||
stateStore.startLocationReport(at: Int64(Date().timeIntervalSince1970 * 1000))
|
||||
|
||||
XCTAssertTrue(homeViewModel.isOnline)
|
||||
XCTAssertTrue(reportViewModel.isOnline)
|
||||
XCTAssertGreaterThan(stateStore.nextReportCountdownSeconds, 0)
|
||||
|
||||
stateStore.updateOnlineStatus(false)
|
||||
|
||||
XCTAssertFalse(homeViewModel.isOnline)
|
||||
XCTAssertFalse(reportViewModel.isOnline)
|
||||
}
|
||||
|
||||
func testRestoreClearsOnlineStateWhenStoredLastReportTimeIsMissing() {
|
||||
appStore.onlineStatus = true
|
||||
appStore.lastLocationReportTime = 0
|
||||
|
||||
let stateStore = HomeLocationStateStore(store: appStore)
|
||||
stateStore.restoreStateIfNeeded()
|
||||
|
||||
XCTAssertFalse(stateStore.isOnline)
|
||||
XCTAssertFalse(appStore.onlineStatus)
|
||||
XCTAssertEqual(appStore.lastLocationReportTime, 0)
|
||||
XCTAssertEqual(stateStore.nextReportCountdownSeconds, 0)
|
||||
}
|
||||
|
||||
func testRestoreClearsRuntimeStateWhenPersistedOffline() {
|
||||
let stateStore = HomeLocationStateStore(store: appStore)
|
||||
stateStore.startLocationReport(at: Int64(Date().timeIntervalSince1970 * 1000))
|
||||
appStore.onlineStatus = false
|
||||
|
||||
stateStore.restoreStateIfNeeded()
|
||||
|
||||
XCTAssertFalse(stateStore.isOnline)
|
||||
XCTAssertEqual(stateStore.nextReportCountdownSeconds, 0)
|
||||
}
|
||||
}
|
||||
|
||||
private final class StateObserverOwner {}
|
||||
|
||||
Reference in New Issue
Block a user