fix: unify location report state
This commit is contained in:
@ -8,6 +8,8 @@ import Foundation
|
|||||||
/// 首页在线状态与 2 小时倒计时存储,对齐 Android `LocationStateRepository`。
|
/// 首页在线状态与 2 小时倒计时存储,对齐 Android `LocationStateRepository`。
|
||||||
final class HomeLocationStateStore {
|
final class HomeLocationStateStore {
|
||||||
|
|
||||||
|
static let shared = HomeLocationStateStore(store: .shared)
|
||||||
|
|
||||||
static let onlineDurationMillis: Int64 = 2 * 60 * 60 * 1000
|
static let onlineDurationMillis: Int64 = 2 * 60 * 60 * 1000
|
||||||
|
|
||||||
private(set) var isOnline = false
|
private(set) var isOnline = false
|
||||||
@ -15,11 +17,10 @@ final class HomeLocationStateStore {
|
|||||||
private(set) var nextReportCountdownSeconds: Int64 = 0
|
private(set) var nextReportCountdownSeconds: Int64 = 0
|
||||||
private(set) var reminderMinutes = 0
|
private(set) var reminderMinutes = 0
|
||||||
|
|
||||||
var onStateChange: (() -> Void)?
|
|
||||||
|
|
||||||
private let store: AppStore
|
private let store: AppStore
|
||||||
private var countdownTask: Task<Void, Never>?
|
private var countdownTask: Task<Void, Never>?
|
||||||
private var anchorTimestamp: Int64?
|
private var anchorTimestamp: Int64?
|
||||||
|
private var observers: [ObjectIdentifier: StateObserver] = [:]
|
||||||
|
|
||||||
init(store: AppStore = .shared) {
|
init(store: AppStore = .shared) {
|
||||||
self.store = store
|
self.store = store
|
||||||
@ -27,11 +28,38 @@ final class HomeLocationStateStore {
|
|||||||
isOnline = store.onlineStatus
|
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() {
|
func restoreStateIfNeeded() {
|
||||||
guard store.onlineStatus else { return }
|
reminderMinutes = store.locationReminderMinutes
|
||||||
|
guard store.onlineStatus else {
|
||||||
|
isOnline = false
|
||||||
|
stopCountdown()
|
||||||
|
notifyChange()
|
||||||
|
return
|
||||||
|
}
|
||||||
let lastTime = store.lastLocationReportTime
|
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 now = Int64(Date().timeIntervalSince1970 * 1000)
|
||||||
let elapsed = now - lastTime
|
let elapsed = now - lastTime
|
||||||
@ -147,6 +175,17 @@ final class HomeLocationStateStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func notifyChange() {
|
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(
|
init(
|
||||||
appStore: AppStore = .shared,
|
appStore: AppStore = .shared,
|
||||||
locationStateStore: HomeLocationStateStore = HomeLocationStateStore(),
|
locationStateStore: HomeLocationStateStore = .shared,
|
||||||
commonMenuStore: HomeCommonMenuStore = HomeCommonMenuStore(),
|
commonMenuStore: HomeCommonMenuStore = HomeCommonMenuStore(),
|
||||||
locationProvider: any LocationProviding = LocationProvider.shared
|
locationProvider: any LocationProviding = LocationProvider.shared
|
||||||
) {
|
) {
|
||||||
@ -50,11 +50,15 @@ final class HomeViewModel {
|
|||||||
locationProvider: locationProvider
|
locationProvider: locationProvider
|
||||||
)
|
)
|
||||||
self.commonMenuStore = commonMenuStore
|
self.commonMenuStore = commonMenuStore
|
||||||
self.locationStateStore.onStateChange = { [weak self] in
|
self.locationStateStore.observe(self) { [weak self] in
|
||||||
self?.notifyStateChange()
|
self?.notifyStateChange()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
locationStateStore.removeObserver(self)
|
||||||
|
}
|
||||||
|
|
||||||
var isOnline: Bool { locationStateStore.isOnline }
|
var isOnline: Bool { locationStateStore.isOnline }
|
||||||
var countdownDisplayText: String { locationStateStore.countdownDisplayText }
|
var countdownDisplayText: String { locationStateStore.countdownDisplayText }
|
||||||
var reminderMinutes: Int { locationStateStore.reminderMinutes }
|
var reminderMinutes: Int { locationStateStore.reminderMinutes }
|
||||||
@ -73,6 +77,7 @@ final class HomeViewModel {
|
|||||||
|
|
||||||
/// 按需重新拉取权限。
|
/// 按需重新拉取权限。
|
||||||
func reloadIfNeeded(api: HomeAPI) async {
|
func reloadIfNeeded(api: HomeAPI) async {
|
||||||
|
locationStateStore.restoreStateIfNeeded()
|
||||||
guard needsPermissionReload else {
|
guard needsPermissionReload else {
|
||||||
refreshLocalDisplayState()
|
refreshLocalDisplayState()
|
||||||
rebuildCommonMenus()
|
rebuildCommonMenus()
|
||||||
|
|||||||
@ -29,7 +29,7 @@ final class LocationReportViewModel {
|
|||||||
private let locationProvider: any LocationProviding
|
private let locationProvider: any LocationProviding
|
||||||
|
|
||||||
init(
|
init(
|
||||||
locationStateStore: HomeLocationStateStore = HomeLocationStateStore(),
|
locationStateStore: HomeLocationStateStore = .shared,
|
||||||
locationProvider: any LocationProviding = LocationProvider.shared
|
locationProvider: any LocationProviding = LocationProvider.shared
|
||||||
) {
|
) {
|
||||||
self.locationStateStore = locationStateStore
|
self.locationStateStore = locationStateStore
|
||||||
@ -38,11 +38,15 @@ final class LocationReportViewModel {
|
|||||||
locationStateStore: locationStateStore,
|
locationStateStore: locationStateStore,
|
||||||
locationProvider: locationProvider
|
locationProvider: locationProvider
|
||||||
)
|
)
|
||||||
self.locationStateStore.onStateChange = { [weak self] in
|
self.locationStateStore.observe(self) { [weak self] in
|
||||||
self?.notifyStateChange()
|
self?.notifyStateChange()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
locationStateStore.removeObserver(self)
|
||||||
|
}
|
||||||
|
|
||||||
var isOnline: Bool { locationStateStore.isOnline }
|
var isOnline: Bool { locationStateStore.isOnline }
|
||||||
var countdownDisplayText: String { locationStateStore.countdownDisplayTextSingleHour }
|
var countdownDisplayText: String { locationStateStore.countdownDisplayTextSingleHour }
|
||||||
var reminderMinutes: Int { locationStateStore.reminderMinutes }
|
var reminderMinutes: Int { locationStateStore.reminderMinutes }
|
||||||
|
|||||||
@ -10,9 +10,10 @@ import XCTest
|
|||||||
final class HomeLocationStateStoreTests: XCTestCase {
|
final class HomeLocationStateStoreTests: XCTestCase {
|
||||||
|
|
||||||
private var appStore: AppStore!
|
private var appStore: AppStore!
|
||||||
|
private var defaults: UserDefaults!
|
||||||
|
|
||||||
override func setUp() {
|
override func setUp() {
|
||||||
let defaults = UserDefaults(suiteName: "HomeLocationStateStoreTests")!
|
defaults = UserDefaults(suiteName: "HomeLocationStateStoreTests")!
|
||||||
defaults.removePersistentDomain(forName: "HomeLocationStateStoreTests")
|
defaults.removePersistentDomain(forName: "HomeLocationStateStoreTests")
|
||||||
appStore = AppStore(defaults: defaults)
|
appStore = AppStore(defaults: defaults)
|
||||||
appStore.userId = "1001"
|
appStore.userId = "1001"
|
||||||
@ -63,4 +64,88 @@ final class HomeLocationStateStoreTests: XCTestCase {
|
|||||||
// verify helper with manual expectation on threshold logic.
|
// verify helper with manual expectation on threshold logic.
|
||||||
XCTAssertFalse(stateStore.shouldTriggerTimeoutReminder())
|
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