From 9b278e6cb0ae5d7f305b5ea6c86c960e06f06037 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Thu, 23 Jul 2026 21:27:27 +0530 Subject: [PATCH] fix(simkl): refresh watch state after manual sync --- .../settings/TrackingProviderCards.kt | 26 +++--- .../app/features/simkl/SimklSyncRepository.kt | 14 ++- .../TrackingProviderRefreshCoordinator.kt | 14 +++ .../WatchProgressSourceCoordinator.kt | 16 ++++ .../features/simkl/SimklRefreshPolicyTest.kt | 38 ++++++++ .../TrackingProviderRefreshCoordinatorTest.kt | 87 +++++++++++++++++++ 6 files changed, 181 insertions(+), 14 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/TrackingProviderRefreshCoordinator.kt create mode 100644 composeApp/src/commonTest/kotlin/com/nuvio/app/features/watchprogress/TrackingProviderRefreshCoordinatorTest.kt diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TrackingProviderCards.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TrackingProviderCards.kt index de086fbd5..51114188a 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TrackingProviderCards.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TrackingProviderCards.kt @@ -57,6 +57,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.nuvio.app.core.ui.NuvioLoadingIndicator import com.nuvio.app.core.ui.NuvioTokens import com.nuvio.app.core.ui.nuvio +import com.nuvio.app.features.profiles.ProfileRepository import com.nuvio.app.features.simkl.SimklAuthError import com.nuvio.app.features.simkl.SimklAuthRepository import com.nuvio.app.features.simkl.SimklAuthUiState @@ -64,12 +65,14 @@ import com.nuvio.app.features.simkl.SimklBrandAsset import com.nuvio.app.features.simkl.SimklConnectionMode import com.nuvio.app.features.simkl.SimklSyncRepository import com.nuvio.app.features.simkl.simklBrandPainter +import com.nuvio.app.features.tracking.TrackingProviderId import com.nuvio.app.features.tracking.TrackingRefreshIntent import com.nuvio.app.features.trakt.TraktAuthRepository import com.nuvio.app.features.trakt.TraktAuthUiState import com.nuvio.app.features.trakt.TraktBrandAsset import com.nuvio.app.features.trakt.TraktConnectionMode import com.nuvio.app.features.trakt.traktBrandPainter +import com.nuvio.app.features.watchprogress.WatchProgressSourceCoordinator import kotlinx.coroutines.launch import nuvio.composeapp.generated.resources.Res import nuvio.composeapp.generated.resources.action_cancel @@ -156,6 +159,17 @@ internal fun TrackingProviderCards( }.collectAsStateWithLifecycle() val scope = rememberCoroutineScope() var showSyncInfo by rememberSaveable { mutableStateOf(false) } + val onSimklSyncRequested: () -> Unit = { + scope.launch { + WatchProgressSourceCoordinator.refreshProviderAndActiveSource( + profileId = ProfileRepository.activeProfileId, + providerId = TrackingProviderId.SIMKL, + refreshProvider = { + SimklSyncRepository.refresh(TrackingRefreshIntent.USER_INITIATED) + }, + ) + } + } BoxWithConstraints(modifier = Modifier.fillMaxWidth()) { val useTwoColumns = maxWidth >= 600.dp @@ -176,11 +190,7 @@ internal fun TrackingProviderCards( uiState = simklUiState, isSyncing = syncState.isLoading, syncErrorMessage = syncState.errorMessage, - onSyncRequested = { - scope.launch { - SimklSyncRepository.refresh(TrackingRefreshIntent.USER_INITIATED) - } - }, + onSyncRequested = onSimklSyncRequested, onInfoRequested = { showSyncInfo = true }, modifier = Modifier .weight(1f) @@ -200,11 +210,7 @@ internal fun TrackingProviderCards( uiState = simklUiState, isSyncing = syncState.isLoading, syncErrorMessage = syncState.errorMessage, - onSyncRequested = { - scope.launch { - SimklSyncRepository.refresh(TrackingRefreshIntent.USER_INITIATED) - } - }, + onSyncRequested = onSimklSyncRequested, onInfoRequested = { showSyncInfo = true }, modifier = Modifier.fillMaxWidth(), ) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklSyncRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklSyncRepository.kt index 0045f606d..59772d6ce 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklSyncRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklSyncRepository.kt @@ -73,23 +73,23 @@ object SimklSyncRepository : TrackingProfileStore { scope.launch { refresh(intent, origin) } } - suspend fun refresh(intent: TrackingRefreshIntent) { + suspend fun refresh(intent: TrackingRefreshIntent): Boolean = refresh(intent, SimklRefreshOrigin.MANUAL_SYNC) - } internal suspend fun refresh( intent: TrackingRefreshIntent, origin: SimklRefreshOrigin, - ) { + ): Boolean { ensureLoaded() val requestId = refreshRequestSequence.incrementAndGet() val requestedGeneration = profileGeneration + val requestedProfileId = ProfileRepository.activeProfileId val before = _state.value SimklWatchDiagnostics.logRefreshRequest( requestId = requestId, origin = origin, intent = intent, - profileId = ProfileRepository.activeProfileId, + profileId = requestedProfileId, profileGeneration = requestedGeneration, authenticated = SimklAuthRepository.isAuthenticated.value, snapshot = before.snapshot, @@ -132,6 +132,12 @@ object SimklSyncRepository : TrackingProfileStore { before = before, after = _state.value, ) + val completed = _state.value + return requestedGeneration == profileGeneration && + requestedProfileId == ProfileRepository.activeProfileId && + SimklAuthRepository.isAuthenticated.value && + completed.hasLoaded && + completed.errorMessage == null } private suspend fun refreshSnapshot(generation: Long) { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/TrackingProviderRefreshCoordinator.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/TrackingProviderRefreshCoordinator.kt new file mode 100644 index 000000000..f886610ea --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/TrackingProviderRefreshCoordinator.kt @@ -0,0 +1,14 @@ +package com.nuvio.app.features.watchprogress + +import com.nuvio.app.features.tracking.TrackingProviderId + +internal suspend fun coordinateTrackingProviderRefresh( + providerId: TrackingProviderId, + refreshProvider: suspend () -> Boolean, + activeProviderId: () -> TrackingProviderId?, + refreshActiveReadModels: suspend () -> Boolean, +): Boolean { + if (!refreshProvider()) return false + if (activeProviderId() != providerId) return true + return refreshActiveReadModels() +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressSourceCoordinator.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressSourceCoordinator.kt index 224a1746c..bd14273cd 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressSourceCoordinator.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressSourceCoordinator.kt @@ -309,6 +309,22 @@ object WatchProgressSourceCoordinator { } } + suspend fun refreshProviderAndActiveSource( + profileId: Int, + providerId: TrackingProviderId, + refreshProvider: suspend () -> Boolean, + ): Boolean = coordinateTrackingProviderRefresh( + providerId = providerId, + refreshProvider = refreshProvider, + activeProviderId = { + ensureSourceStateLoaded() + currentContext(profileId).effectiveSource.providerId + }, + refreshActiveReadModels = { + refreshActiveSource(profileId = profileId, force = false).succeeded + }, + ) + private fun ensureSourceStateLoadedForGeneration(expectedGeneration: Long) { synchronized(startLock) { ensureCoordinatorGeneration(expectedGeneration) diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklRefreshPolicyTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklRefreshPolicyTest.kt index 11e595884..6a03d0a24 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklRefreshPolicyTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklRefreshPolicyTest.kt @@ -152,6 +152,44 @@ class SimklRefreshPolicyTest { assertEquals(1, executions) } + @Test + fun `manual sync followed by read model refresh executes once`() = runBlocking { + val gate = SimklRefreshGate() + var lastCheckedAtEpochMs: Long? = null + var executions = 0 + + val outcomes = listOf( + TrackingRefreshIntent.USER_INITIATED, + TrackingRefreshIntent.AUTOMATIC, + TrackingRefreshIntent.AUTOMATIC, + ).map { intent -> + gate.runIfNeeded( + profileGeneration = 7L, + shouldRun = { + shouldRunSimklRefresh( + intent = intent, + lastCheckedAtEpochMs = lastCheckedAtEpochMs, + nowEpochMs = 1_000L, + hasError = false, + ) + }, + ) { + executions += 1 + lastCheckedAtEpochMs = 1_000L + } + } + + assertEquals( + listOf( + SimklRefreshGateOutcome.EXECUTED, + SimklRefreshGateOutcome.FRESHNESS_SKIPPED, + SimklRefreshGateOutcome.FRESHNESS_SKIPPED, + ), + outcomes, + ) + assertEquals(1, executions) + } + @Test fun `mutation invalidation still refreshes after startup check`() = runBlocking { val gate = SimklRefreshGate() diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watchprogress/TrackingProviderRefreshCoordinatorTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watchprogress/TrackingProviderRefreshCoordinatorTest.kt new file mode 100644 index 000000000..7ffb9bcba --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watchprogress/TrackingProviderRefreshCoordinatorTest.kt @@ -0,0 +1,87 @@ +package com.nuvio.app.features.watchprogress + +import com.nuvio.app.features.tracking.TrackingProviderId +import kotlinx.coroutines.runBlocking +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class TrackingProviderRefreshCoordinatorTest { + @Test + fun `successful active provider refresh republishes read models in order`() = runBlocking { + val events = mutableListOf() + + val result = coordinateTrackingProviderRefresh( + providerId = TrackingProviderId.SIMKL, + refreshProvider = { + events += "provider" + true + }, + activeProviderId = { + events += "source" + TrackingProviderId.SIMKL + }, + refreshActiveReadModels = { + events += "read-models" + true + }, + ) + + assertTrue(result) + assertEquals(listOf("provider", "source", "read-models"), events) + } + + @Test + fun `inactive provider refresh does not refresh another source`() = runBlocking { + var readModelRefreshes = 0 + + val result = coordinateTrackingProviderRefresh( + providerId = TrackingProviderId.SIMKL, + refreshProvider = { true }, + activeProviderId = { TrackingProviderId.TRAKT }, + refreshActiveReadModels = { + readModelRefreshes += 1 + true + }, + ) + + assertTrue(result) + assertEquals(0, readModelRefreshes) + } + + @Test + fun `failed provider refresh does not publish read models`() = runBlocking { + var sourceReads = 0 + var readModelRefreshes = 0 + + val result = coordinateTrackingProviderRefresh( + providerId = TrackingProviderId.SIMKL, + refreshProvider = { false }, + activeProviderId = { + sourceReads += 1 + TrackingProviderId.SIMKL + }, + refreshActiveReadModels = { + readModelRefreshes += 1 + true + }, + ) + + assertFalse(result) + assertEquals(0, sourceReads) + assertEquals(0, readModelRefreshes) + } + + @Test + fun `read model refresh failure is surfaced`() = runBlocking { + val result = coordinateTrackingProviderRefresh( + providerId = TrackingProviderId.SIMKL, + refreshProvider = { true }, + activeProviderId = { TrackingProviderId.SIMKL }, + refreshActiveReadModels = { false }, + ) + + assertFalse(result) + } +}