Skip to content

Commit b431c27

Browse files
frettclaude
andcommitted
Sync personalized tool order in LessonsPresenter
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 53454df commit b431c27

2 files changed

Lines changed: 98 additions & 1 deletion

File tree

app/src/main/kotlin/org/cru/godtools/ui/dashboard/lessons/LessonsPresenter.kt

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package org.cru.godtools.ui.dashboard.lessons
22

33
import android.content.Context
44
import androidx.compose.runtime.Composable
5+
import androidx.compose.runtime.DisposableEffect
56
import androidx.compose.runtime.LaunchedEffect
67
import androidx.compose.runtime.collectAsState
78
import androidx.compose.runtime.getValue
@@ -13,6 +14,7 @@ import androidx.compose.runtime.setValue
1314
import androidx.compose.runtime.snapshotFlow
1415
import com.google.firebase.remoteconfig.FirebaseRemoteConfig
1516
import com.slack.circuit.codegen.annotations.CircuitInject
17+
import com.slack.circuit.runtime.CircuitContext
1618
import com.slack.circuit.runtime.CircuitUiEvent
1719
import com.slack.circuit.runtime.CircuitUiState
1820
import com.slack.circuit.runtime.Navigator
@@ -30,11 +32,13 @@ import kotlinx.coroutines.CoroutineDispatcher
3032
import kotlinx.coroutines.ExperimentalCoroutinesApi
3133
import kotlinx.coroutines.flow.combine
3234
import kotlinx.coroutines.flow.distinctUntilChanged
35+
import kotlinx.coroutines.flow.first
3336
import kotlinx.coroutines.flow.flatMapLatest
3437
import kotlinx.coroutines.flow.flowOn
3538
import kotlinx.coroutines.flow.map
3639
import org.ccci.gto.android.common.dagger.coroutines.DispatcherType
3740
import org.ccci.gto.android.common.dagger.coroutines.DispatcherType.Type.IO
41+
import org.ccci.gto.android.common.sync.SyncTracker
3842
import org.cru.godtools.analytics.model.OpenAnalyticsActionEvent
3943
import org.cru.godtools.analytics.model.OpenAnalyticsActionEvent.Companion.ACTION_OPEN_LESSON
4044
import org.cru.godtools.analytics.model.OpenAnalyticsActionEvent.Companion.SOURCE_LESSONS
@@ -46,6 +50,8 @@ import org.cru.godtools.db.repository.ToolsRepository
4650
import org.cru.godtools.db.repository.TranslationsRepository
4751
import org.cru.godtools.model.Language
4852
import org.cru.godtools.model.Language.Companion.filterByDisplayAndNativeName
53+
import org.cru.godtools.sync.GodToolsSyncService
54+
import org.cru.godtools.ui.dashboard.SyncTaskRegistry.Companion.syncTaskRegistry
4955
import org.cru.godtools.ui.dashboard.filters.FilterMenu
5056
import org.cru.godtools.ui.dashboard.lessons.LessonsPresenter.UiState
5157
import org.cru.godtools.ui.tools.ToolCardPresenter
@@ -61,10 +67,12 @@ class LessonsPresenter @AssistedInject internal constructor(
6167
private val lessonsFlowProducer: LessonsFlowProducer,
6268
private val remoteConfig: FirebaseRemoteConfig,
6369
private val settings: Settings,
70+
private val syncService: GodToolsSyncService,
6471
private val toolCardPresenter: ToolCardPresenter,
6572
private val toolsRepository: ToolsRepository,
6673
private val translationsRepository: TranslationsRepository,
6774
@param:DispatcherType(IO) private val ioDispatcher: CoroutineDispatcher,
75+
@Assisted private val circuitContext: CircuitContext,
6876
@Assisted private val navigator: Navigator,
6977
) : Presenter<UiState> {
7078
// region UiState / UiEvent
@@ -96,6 +104,8 @@ class LessonsPresenter @AssistedInject internal constructor(
96104
val appLanguage by settings.appLanguageFlow.collectAsState()
97105
val languageFilter = rememberLanguagesFilter()
98106

107+
RegisterSyncTask(languageFilter.selectedItem?.code ?: appLanguage)
108+
99109
return UiState(
100110
mode = mode,
101111
isPersonalizationEnabled = isPersonalizationEnabled,
@@ -200,9 +210,24 @@ class LessonsPresenter @AssistedInject internal constructor(
200210
}
201211
}
202212

213+
@Composable
214+
private fun RegisterSyncTask(locale: Locale) {
215+
val syncRegistry = circuitContext.syncTaskRegistry
216+
DisposableEffect(syncRegistry, locale) {
217+
if (syncRegistry == null) return@DisposableEffect onDispose { }
218+
val id = syncRegistry.registerSyncTask { force -> syncData(locale, force) }
219+
onDispose { syncRegistry.unregisterSyncTask(id) }
220+
}
221+
}
222+
223+
private fun SyncTracker.syncData(locale: Locale, force: Boolean = false) = launchSync {
224+
val country = settings.getCountrySettingFlow().first()
225+
syncService.syncToolOrder(locale, country, force)
226+
}
227+
203228
@AssistedFactory
204229
@CircuitInject(LessonsScreen::class, SingletonComponent::class)
205230
interface Factory {
206-
fun create(navigator: Navigator): LessonsPresenter
231+
fun create(circuitContext: CircuitContext, navigator: Navigator): LessonsPresenter
207232
}
208233
}

app/src/test/kotlin/org/cru/godtools/ui/dashboard/lessons/LessonsPresenterTest.kt

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,14 @@ import com.slack.circuit.backstack.SaveableBackStack
1414
import com.slack.circuit.foundation.Circuit
1515
import com.slack.circuit.foundation.CircuitCompositionLocals
1616
import com.slack.circuit.foundation.NavigableCircuitContent
17+
import com.slack.circuit.runtime.CircuitContext
18+
import com.slack.circuit.runtime.InternalCircuitApi
1719
import com.slack.circuit.test.FakeNavigator
1820
import com.slack.circuit.test.test
1921
import com.slack.circuitx.android.IntentScreen
22+
import io.mockk.coEvery
23+
import io.mockk.coVerify
24+
import io.mockk.coVerifyAll
2025
import io.mockk.every
2126
import io.mockk.mockk
2227
import io.mockk.verify
@@ -28,13 +33,16 @@ import kotlin.test.assertIs
2833
import kotlin.test.assertNotNull
2934
import kotlin.test.assertTrue
3035
import kotlinx.coroutines.ExperimentalCoroutinesApi
36+
import kotlinx.coroutines.channels.Channel
3137
import kotlinx.coroutines.flow.MutableStateFlow
3238
import kotlinx.coroutines.flow.flowOf
3339
import kotlinx.coroutines.test.TestScope
3440
import kotlinx.coroutines.test.UnconfinedTestDispatcher
3541
import kotlinx.coroutines.test.runTest
3642
import org.ccci.gto.android.common.androidx.compose.ui.platform.AndroidUiDispatcherUtil
43+
import org.ccci.gto.android.common.sync.SyncTracker
3744
import org.ccci.gto.android.common.util.content.equalsIntent
45+
import org.ccci.gto.support.turbine.awaitItemMatching
3846
import org.cru.godtools.analytics.model.OpenAnalyticsActionEvent
3947
import org.cru.godtools.analytics.model.OpenAnalyticsActionEvent.Companion.ACTION_OPEN_LESSON
4048
import org.cru.godtools.analytics.model.OpenAnalyticsActionEvent.Companion.SOURCE_LESSONS
@@ -49,6 +57,9 @@ import org.cru.godtools.model.Tool
4957
import org.cru.godtools.model.Translation
5058
import org.cru.godtools.model.randomTool
5159
import org.cru.godtools.model.randomTranslation
60+
import org.cru.godtools.sync.GodToolsSyncService
61+
import org.cru.godtools.ui.dashboard.SyncTaskRegistry
62+
import org.cru.godtools.ui.dashboard.SyncTaskRegistry.Companion.syncTaskRegistry
5263
import org.cru.godtools.ui.dashboard.filters.FilterMenu
5364
import org.cru.godtools.ui.dashboard.lessons.LessonsPresenter.UiEvent
5465
import org.cru.godtools.ui.dashboard.lessons.LessonsPresenter.UiState
@@ -65,13 +76,19 @@ import org.robolectric.annotation.Config
6576
@OptIn(ExperimentalCoroutinesApi::class)
6677
class LessonsPresenterTest {
6778
private val appLangFlow = MutableStateFlow(Locale.ENGLISH)
79+
private val countryFlow = MutableStateFlow<String?>("US")
6880
private val lessonsFlow = MutableStateFlow(emptyList<Tool>())
6981
private val enLessonsFlow = MutableStateFlow(emptyList<Tool>())
7082
private val languagesFlow = MutableStateFlow(emptyList<Language>())
7183
private val translationsFlow = MutableStateFlow(emptyList<Translation>())
84+
private val toolOrderSync = Channel<Boolean>()
7285
private var isPersonalizationEnabled = true
7386

7487
private val testScope = TestScope()
88+
@OptIn(InternalCircuitApi::class)
89+
private val circuitContext = CircuitContext(null).apply {
90+
syncTaskRegistry = SyncTaskRegistry(SyncTracker(testScope.backgroundScope))
91+
}
7592
private val context: Context = ApplicationProvider.getApplicationContext()
7693
private val eventBus: EventBus = mockk(relaxUnitFun = true)
7794
private val remoteConfig: FirebaseRemoteConfig = mockk {
@@ -83,6 +100,10 @@ class LessonsPresenterTest {
83100
}
84101
private val settings: Settings = mockk {
85102
every { appLanguageFlow } returns appLangFlow
103+
every { getCountrySettingFlow() } returns countryFlow
104+
}
105+
private val syncService: GodToolsSyncService = mockk {
106+
coEvery { syncToolOrder(any(), any(), any()) } coAnswers { toolOrderSync.receive() }
86107
}
87108
private val lessonsFlowProducer: LessonsFlowProducer = mockk {
88109
every { getFlow(any(), any()) } returns flowOf(emptyList())
@@ -105,10 +126,12 @@ class LessonsPresenterTest {
105126
lessonsFlowProducer = lessonsFlowProducer,
106127
remoteConfig = remoteConfig,
107128
settings = settings,
129+
syncService = syncService,
108130
toolCardPresenter = FakeToolCardPresenter(),
109131
toolsRepository = toolsRepository,
110132
translationsRepository = translationsRepository,
111133
ioDispatcher = UnconfinedTestDispatcher(testScope.testScheduler),
134+
circuitContext = circuitContext,
112135
navigator = navigator,
113136
)
114137

@@ -409,4 +432,53 @@ class LessonsPresenterTest {
409432
verify { eventBus.post(OpenAnalyticsActionEvent(ACTION_OPEN_LESSON, "lesson2", SOURCE_LESSONS)) }
410433
}
411434
// endregion State.lessons
435+
436+
// region SideEffect - RegisterSyncTask
437+
@Test
438+
fun `SideEffect - RegisterSyncTask - Triggers initial sync`() = testScope.runTest {
439+
presenter.test {
440+
awaitItem()
441+
toolOrderSync.send(true)
442+
coVerifyAll { syncService.syncToolOrder(Locale.ENGLISH, "US", false) }
443+
}
444+
}
445+
446+
@Test
447+
fun `SideEffect - RegisterSyncTask - uses locale from language filter`() = testScope.runTest {
448+
appLangFlow.value = Locale.FRENCH
449+
presenter.test {
450+
awaitItem()
451+
toolOrderSync.send(true)
452+
coVerifyAll { syncService.syncToolOrder(Locale.FRENCH, "US", false) }
453+
}
454+
}
455+
456+
@Test
457+
fun `SideEffect - RegisterSyncTask - re-syncs when locale changes`() = testScope.runTest {
458+
presenter.test {
459+
val initialState = awaitItem()
460+
toolOrderSync.send(true)
461+
coVerify { syncService.syncToolOrder(Locale.ENGLISH, "US", false) }
462+
463+
initialState.languageFilter.eventSink(FilterMenu.Event.SelectItem(Language(Locale.FRENCH)))
464+
awaitItemMatching { it.languageFilter.selectedItem?.code == Locale.FRENCH }
465+
toolOrderSync.send(true)
466+
coVerify { syncService.syncToolOrder(Locale.FRENCH, "US", false) }
467+
cancelAndIgnoreRemainingEvents()
468+
}
469+
}
470+
471+
@Test
472+
fun `SideEffect - RegisterSyncTask - passes force on triggered sync`() = testScope.runTest {
473+
presenter.test {
474+
awaitItem()
475+
toolOrderSync.send(true)
476+
coVerify { syncService.syncToolOrder(Locale.ENGLISH, "US", false) }
477+
478+
circuitContext.syncTaskRegistry!!.triggerSyncTasks(force = true)
479+
toolOrderSync.send(true)
480+
coVerify { syncService.syncToolOrder(Locale.ENGLISH, "US", true) }
481+
}
482+
}
483+
// endregion SideEffect - RegisterSyncTask
412484
}

0 commit comments

Comments
 (0)