Skip to content

Commit c969eb7

Browse files
frettclaude
andcommitted
Add personalization toggle to LessonsLayout
Mirrors the personalization toggle added to ToolsLayout — adds Mode, isPersonalizationEnabled, and eventSink to LessonsPresenter.UiState, reads the remote config flag, and shows a segmented button toggle at the top of the lessons list when personalization is enabled. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 76aeb6e commit c969eb7

4 files changed

Lines changed: 120 additions & 10 deletions

File tree

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

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

33
import androidx.compose.foundation.layout.Column
44
import androidx.compose.foundation.layout.PaddingValues
5+
import androidx.compose.foundation.layout.fillMaxWidth
56
import androidx.compose.foundation.layout.padding
7+
import androidx.compose.foundation.layout.wrapContentWidth
68
import androidx.compose.foundation.lazy.LazyColumn
79
import androidx.compose.foundation.lazy.items
810
import androidx.compose.material3.HorizontalDivider
911
import androidx.compose.material3.MaterialTheme
12+
import androidx.compose.material3.SegmentedButton
13+
import androidx.compose.material3.SegmentedButtonDefaults
14+
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
1015
import androidx.compose.material3.Text
1116
import androidx.compose.runtime.Composable
17+
import androidx.compose.ui.Alignment
1218
import androidx.compose.ui.Modifier
1319
import androidx.compose.ui.res.stringResource
1420
import androidx.compose.ui.tooling.preview.Preview
@@ -17,13 +23,25 @@ import com.slack.circuit.codegen.annotations.CircuitInject
1723
import dagger.hilt.components.SingletonComponent
1824
import org.cru.godtools.R
1925
import org.cru.godtools.base.ui.circuit.screen.dashboard.page.LessonsScreen
26+
import org.cru.godtools.ui.dashboard.lessons.LessonsPresenter.UiEvent
2027
import org.cru.godtools.ui.dashboard.lessons.LessonsPresenter.UiState
2128
import org.cru.godtools.ui.tools.LessonToolCard
2229

2330
@Composable
2431
@CircuitInject(LessonsScreen::class, SingletonComponent::class)
2532
internal fun LessonsLayout(state: UiState, modifier: Modifier = Modifier) {
26-
LazyColumn(contentPadding = PaddingValues(16.dp), modifier = modifier) {
33+
LazyColumn(contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 16.dp), modifier = modifier) {
34+
if (state.isPersonalizationEnabled) {
35+
item("mode-toggle", "mode-toggle") {
36+
PersonalizationToggle(
37+
state,
38+
modifier = Modifier
39+
.fillMaxWidth()
40+
.wrapContentWidth(Alignment.CenterHorizontally)
41+
)
42+
}
43+
}
44+
2745
item("header", "header") {
2846
LessonsHeader()
2947
HorizontalDivider(modifier = Modifier.padding(vertical = 12.dp))
@@ -43,6 +61,27 @@ internal fun LessonsLayout(state: UiState, modifier: Modifier = Modifier) {
4361
}
4462
}
4563

64+
@Composable
65+
private fun PersonalizationToggle(state: UiState, modifier: Modifier = Modifier) {
66+
SingleChoiceSegmentedButtonRow(modifier = modifier) {
67+
SegmentedButton(
68+
selected = state.mode == UiState.Mode.PERSONALIZATION,
69+
onClick = { state.eventSink(UiEvent.ChangeMode(UiState.Mode.PERSONALIZATION)) },
70+
shape = SegmentedButtonDefaults.itemShape(0, 2),
71+
) {
72+
Text(stringResource(R.string.dashboard_lessons_toggle_personalized))
73+
}
74+
75+
SegmentedButton(
76+
selected = state.mode == UiState.Mode.ALL_LESSONS,
77+
onClick = { state.eventSink(UiEvent.ChangeMode(UiState.Mode.ALL_LESSONS)) },
78+
shape = SegmentedButtonDefaults.itemShape(1, 2),
79+
) {
80+
Text(stringResource(R.string.dashboard_lessons_toggle_all))
81+
}
82+
}
83+
}
84+
4685
@Composable
4786
@Preview(showBackground = true)
4887
private fun LessonsHeader() = Column {

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

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,9 @@ import androidx.compose.runtime.remember
1111
import androidx.compose.runtime.saveable.rememberSaveable
1212
import androidx.compose.runtime.setValue
1313
import androidx.compose.runtime.snapshotFlow
14+
import com.google.firebase.remoteconfig.FirebaseRemoteConfig
1415
import com.slack.circuit.codegen.annotations.CircuitInject
16+
import com.slack.circuit.runtime.CircuitUiEvent
1517
import com.slack.circuit.runtime.CircuitUiState
1618
import com.slack.circuit.runtime.Navigator
1719
import com.slack.circuit.runtime.presenter.Presenter
@@ -36,6 +38,7 @@ import org.ccci.gto.android.common.dagger.coroutines.DispatcherType.Type.IO
3638
import org.cru.godtools.analytics.model.OpenAnalyticsActionEvent
3739
import org.cru.godtools.analytics.model.OpenAnalyticsActionEvent.Companion.ACTION_OPEN_LESSON
3840
import org.cru.godtools.analytics.model.OpenAnalyticsActionEvent.Companion.SOURCE_LESSONS
41+
import org.cru.godtools.base.CONFIG_UI_DASHBOARD_PERSONALIZATION_ENABLED
3942
import org.cru.godtools.base.Settings
4043
import org.cru.godtools.base.ui.circuit.screen.dashboard.page.LessonsScreen
4144
import org.cru.godtools.db.repository.LanguagesRepository
@@ -55,29 +58,52 @@ class LessonsPresenter @AssistedInject constructor(
5558
private val context: Context,
5659
private val eventBus: EventBus,
5760
private val languagesRepository: LanguagesRepository,
61+
private val remoteConfig: FirebaseRemoteConfig,
5862
private val settings: Settings,
5963
private val toolCardPresenter: ToolCardPresenter,
6064
private val toolsRepository: ToolsRepository,
6165
private val translationsRepository: TranslationsRepository,
6266
@param:DispatcherType(IO) private val ioDispatcher: CoroutineDispatcher,
6367
@Assisted private val navigator: Navigator,
6468
) : Presenter<UiState> {
65-
// region UiState
69+
// region UiState / UiEvent
6670
data class UiState(
71+
val mode: Mode = Mode.ALL_LESSONS,
72+
val isPersonalizationEnabled: Boolean = false,
6773
val languageFilter: FilterMenu.UiState<Language> = FilterMenu.UiState(),
6874
val lessons: List<ToolCardPresenter.UiState> = emptyList(),
69-
) : CircuitUiState
70-
// endregion UiState
75+
val eventSink: (UiEvent) -> Unit = {},
76+
) : CircuitUiState {
77+
enum class Mode { PERSONALIZATION, ALL_LESSONS }
78+
}
79+
80+
sealed interface UiEvent : CircuitUiEvent {
81+
data class ChangeMode(val mode: UiState.Mode) : UiEvent
82+
}
83+
// endregion UiState / UiEvent
7184

7285
@Composable
7386
override fun present(): UiState {
87+
val isPersonalizationEnabled = rememberSaveable {
88+
remoteConfig.getBoolean(CONFIG_UI_DASHBOARD_PERSONALIZATION_ENABLED)
89+
}
90+
var mode by rememberSaveable {
91+
mutableStateOf(if (isPersonalizationEnabled) UiState.Mode.PERSONALIZATION else UiState.Mode.ALL_LESSONS)
92+
}
93+
7494
val appLanguage by settings.appLanguageFlow.collectAsState()
7595
val languageFilter = rememberLanguagesFilter()
7696

7797
return UiState(
98+
mode = mode,
99+
isPersonalizationEnabled = isPersonalizationEnabled,
78100
languageFilter = languageFilter,
79101
lessons = rememberLessons(languageFilter.selectedItem?.code ?: appLanguage),
80-
)
102+
) {
103+
when (it) {
104+
is UiEvent.ChangeMode -> mode = it.mode
105+
}
106+
}
81107
}
82108

83109
@Composable

app/src/main/res/values/strings_dashboard.xml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ An online version can be found at https://knowgod.com/</string>
3737
<!-- Lessons -->
3838
<eat-comment />
3939
<string name="nav_lessons">Lessons</string>
40+
<string name="dashboard_lessons_toggle_personalized">Personalized</string>
41+
<string name="dashboard_lessons_toggle_all">All Lessons</string>
4042
<string name="dashboard_lessons_header_title">Lessons made for you</string>
4143
<string name="dashboard_lessons_header_description">Practical ideas to build relationships and start meaningful conversations.</string>
4244
<string name="dashboard_lessons_section_filter_label">Lessons in:</string>

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

Lines changed: 48 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import androidx.test.core.app.ApplicationProvider
99
import androidx.test.ext.junit.runners.AndroidJUnit4
1010
import app.cash.turbine.ReceiveTurbine
1111
import app.cash.turbine.Turbine
12+
import com.google.firebase.remoteconfig.FirebaseRemoteConfig
1213
import com.slack.circuit.backstack.SaveableBackStack
1314
import com.slack.circuit.foundation.Circuit
1415
import com.slack.circuit.foundation.CircuitCompositionLocals
@@ -37,6 +38,7 @@ import org.ccci.gto.android.common.util.content.equalsIntent
3738
import org.cru.godtools.analytics.model.OpenAnalyticsActionEvent
3839
import org.cru.godtools.analytics.model.OpenAnalyticsActionEvent.Companion.ACTION_OPEN_LESSON
3940
import org.cru.godtools.analytics.model.OpenAnalyticsActionEvent.Companion.SOURCE_LESSONS
41+
import org.cru.godtools.base.CONFIG_UI_DASHBOARD_PERSONALIZATION_ENABLED
4042
import org.cru.godtools.base.Settings
4143
import org.cru.godtools.base.ui.circuit.screen.dashboard.page.LessonsScreen
4244
import org.cru.godtools.db.repository.LanguagesRepository
@@ -48,6 +50,8 @@ import org.cru.godtools.model.Translation
4850
import org.cru.godtools.model.randomTool
4951
import org.cru.godtools.model.randomTranslation
5052
import org.cru.godtools.ui.dashboard.filters.FilterMenu
53+
import org.cru.godtools.ui.dashboard.lessons.LessonsPresenter.UiEvent
54+
import org.cru.godtools.ui.dashboard.lessons.LessonsPresenter.UiState
5155
import org.cru.godtools.ui.tools.FakeToolCardPresenter
5256
import org.cru.godtools.ui.tools.ToolCardPresenter.ToolCardEvent
5357
import org.cru.godtools.util.createToolIntent
@@ -65,11 +69,14 @@ class LessonsPresenterTest {
6569
private val enLessonsFlow = MutableStateFlow(emptyList<Tool>())
6670
private val languagesFlow = MutableStateFlow(emptyList<Language>())
6771
private val translationsFlow = MutableStateFlow(emptyList<Translation>())
72+
private var isPersonalizationEnabled = true
6873

6974
private val testScope = TestScope()
70-
7175
private val context: Context = ApplicationProvider.getApplicationContext()
7276
private val eventBus: EventBus = mockk(relaxUnitFun = true)
77+
private val remoteConfig: FirebaseRemoteConfig = mockk {
78+
every { getBoolean(CONFIG_UI_DASHBOARD_PERSONALIZATION_ENABLED) } answers { isPersonalizationEnabled }
79+
}
7380
private val languagesRepository: LanguagesRepository = mockk {
7481
every { findLanguageFlow(any()) } answers { flowOf(Language(firstArg())) }
7582
every { getLanguagesFlow() } returns languagesFlow
@@ -94,6 +101,7 @@ class LessonsPresenterTest {
94101
context = context,
95102
eventBus = eventBus,
96103
languagesRepository = languagesRepository,
104+
remoteConfig = remoteConfig,
97105
settings = settings,
98106
toolCardPresenter = FakeToolCardPresenter(),
99107
toolsRepository = toolsRepository,
@@ -110,12 +118,12 @@ class LessonsPresenterTest {
110118

111119
// This logic is based on the Sample AnsweringNavigatorTest in the circuit library.
112120
// see: https://github.com/slackhq/circuit/blob/main/circuit-foundation/src/jvmTest/kotlin/com/slack/circuit/foundation/AnsweringNavigatorTest.kt
113-
private fun testPresenterWithStateRestoration(): ReceiveTurbine<LessonsPresenter.UiState> {
114-
val presenterState = Turbine<LessonsPresenter.UiState>()
121+
private fun testPresenterWithStateRestoration(): ReceiveTurbine<UiState> {
122+
val presenterState = Turbine<UiState>()
115123

116124
val circuit = Circuit.Builder()
117-
.addPresenter<LessonsScreen, LessonsPresenter.UiState> { s, n, _ -> presenter }
118-
.addUi<LessonsScreen, LessonsPresenter.UiState> { state, _ -> SideEffect { presenterState.add(state) } }
125+
.addPresenter<LessonsScreen, UiState> { _, _, _ -> presenter }
126+
.addUi<LessonsScreen, UiState> { state, _ -> SideEffect { presenterState.add(state) } }
119127
.build()
120128

121129
stateRestorationTester.setContent {
@@ -140,6 +148,41 @@ class LessonsPresenterTest {
140148
navigator.assertResetRootIsEmpty()
141149
}
142150

151+
// region State.mode
152+
@Test
153+
fun `State - mode - personalization disabled`() = testScope.runTest {
154+
isPersonalizationEnabled = false
155+
presenter.test {
156+
assertEquals(UiState.Mode.ALL_LESSONS, expectMostRecentItem().mode)
157+
}
158+
}
159+
160+
@Test
161+
fun `State - mode - personalization enabled`() = testScope.runTest {
162+
presenter.test {
163+
val state = awaitItem()
164+
assertEquals(UiState.Mode.PERSONALIZATION, state.mode)
165+
166+
state.eventSink(UiEvent.ChangeMode(UiState.Mode.ALL_LESSONS))
167+
assertEquals(UiState.Mode.ALL_LESSONS, awaitItem().mode)
168+
}
169+
}
170+
171+
@Test
172+
fun `State - mode - persisted through state save & restore`() = testScope.runTest {
173+
testPresenterWithStateRestoration().test {
174+
val state = expectMostRecentItem()
175+
assertEquals(UiState.Mode.PERSONALIZATION, state.mode)
176+
state.eventSink(UiEvent.ChangeMode(UiState.Mode.ALL_LESSONS))
177+
composeTestRule.waitForIdle()
178+
assertEquals(UiState.Mode.ALL_LESSONS, expectMostRecentItem().mode)
179+
180+
stateRestorationTester.emulateSavedInstanceStateRestore()
181+
assertEquals(UiState.Mode.ALL_LESSONS, awaitItem().mode)
182+
}
183+
}
184+
// endregion State.mode
185+
143186
// region State.languageFilter.selectedItem
144187
@Test
145188
fun `State - languageFilter - selectedItem - default to app language`() = testScope.runTest {

0 commit comments

Comments
 (0)