fix: unify location report state

This commit is contained in:
2026-07-07 16:24:26 +08:00
parent 9378748a4d
commit 97060c2a32
4 changed files with 143 additions and 10 deletions

View File

@ -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
} }
} }

View File

@ -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()

View File

@ -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 }

View File

@ -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 {}