diff --git a/suixinkan/Features/Home/Services/HomeLocationStateStore.swift b/suixinkan/Features/Home/Services/HomeLocationStateStore.swift index 32b69ea..a08920a 100644 --- a/suixinkan/Features/Home/Services/HomeLocationStateStore.swift +++ b/suixinkan/Features/Home/Services/HomeLocationStateStore.swift @@ -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? 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 } } diff --git a/suixinkan/Features/Home/ViewModels/HomeViewModel.swift b/suixinkan/Features/Home/ViewModels/HomeViewModel.swift index 7b9397b..d0e7042 100644 --- a/suixinkan/Features/Home/ViewModels/HomeViewModel.swift +++ b/suixinkan/Features/Home/ViewModels/HomeViewModel.swift @@ -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() diff --git a/suixinkan/Features/LocationReport/ViewModels/LocationReportViewModel.swift b/suixinkan/Features/LocationReport/ViewModels/LocationReportViewModel.swift index 358c572..cdd5189 100644 --- a/suixinkan/Features/LocationReport/ViewModels/LocationReportViewModel.swift +++ b/suixinkan/Features/LocationReport/ViewModels/LocationReportViewModel.swift @@ -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 } diff --git a/suixinkanTests/HomeLocationStateStoreTests.swift b/suixinkanTests/HomeLocationStateStoreTests.swift index 881d11a..90a30c1 100644 --- a/suixinkanTests/HomeLocationStateStoreTests.swift +++ b/suixinkanTests/HomeLocationStateStoreTests.swift @@ -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 {}