Skip to content

Commit 1536e64

Browse files
tjohnson009frett
andauthored
GT-3017 Onboarding Localization (#4405)
* Add pop result to app language screen to track result * Modify popResult names and add for presenter * Add pop result to navigator.pop in UiEvents * Modify tests to reflect newly added pop result * Add result interface for pop results to send to presenter * Receive newly added pop results in UiEvents * Sync pager to presenter page state via LaunchedEffect and disable swipe on welcome * Add answering navigators and Next event for language/country flow in presenter * Add initial state and Next event tests to OnboardingPresenterTest * Change event passed to prevent block from continue onboarding * Change test to pass with the previous dismiss select language event blocking onboarding * Change tense of the result type for consistency in country settings and app language screens * Update pagerState from UiState * Remove currentPage param from UiState and derive it from newly added pagerState * Change test to derive current page from pagerState * Fix import to match new file structure * Fix import order and change import to reflect file structure * Add hasSeenCountrySettings flag; Route onboarding based on language and countrySettings flag * Change onboarding to use eventSink for every nextPage action * Remove duplicate logic for navigateing pages; Simplify UiEvent.Next event * Apply suggestion from @frett --------- Co-authored-by: Daniel Frett <frett@users.noreply.github.com>
1 parent bacd9fb commit 1536e64

8 files changed

Lines changed: 116 additions & 31 deletions

File tree

app/src/main/kotlin/org/cru/godtools/ui/onboarding/OnboardingLayout.kt

Lines changed: 5 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,11 @@
11
package org.cru.godtools.ui.onboarding
22

3-
import androidx.compose.animation.core.Spring
4-
import androidx.compose.animation.core.spring
53
import androidx.compose.foundation.layout.consumeWindowInsets
64
import androidx.compose.foundation.layout.fillMaxSize
75
import androidx.compose.foundation.layout.fillMaxWidth
86
import androidx.compose.foundation.layout.navigationBarsPadding
97
import androidx.compose.foundation.layout.padding
108
import androidx.compose.foundation.pager.HorizontalPager
11-
import androidx.compose.foundation.pager.rememberPagerState
129
import androidx.compose.material3.MaterialTheme
1310
import androidx.compose.material3.Scaffold
1411
import androidx.compose.runtime.Composable
@@ -20,7 +17,6 @@ import androidx.compose.runtime.rememberUpdatedState
2017
import androidx.compose.ui.Modifier
2118
import com.slack.circuit.codegen.annotations.CircuitInject
2219
import dagger.hilt.components.SingletonComponent
23-
import kotlinx.coroutines.launch
2420
import org.ccci.gto.android.common.androidx.compose.material3.ui.appbar.AppBarAction
2521
import org.ccci.gto.android.common.androidx.compose.material3.ui.appbar.AppBarActionButton
2622
import org.cru.godtools.R
@@ -39,7 +35,7 @@ fun OnboardingLayout(state: OnboardingPresenter.UiState, modifier: Modifier = Mo
3935
val eventSink by rememberUpdatedState(state.eventSink)
4036

4137
val coroutineScope = rememberCoroutineScope()
42-
val pagerState = rememberPagerState(pageCount = { 4 })
38+
val pagerState = state.pagerState
4339

4440
RecordAnalyticsScreen(OnboardingAnalyticsScreenEvent(pagerState.currentPage))
4541

@@ -74,35 +70,27 @@ fun OnboardingLayout(state: OnboardingPresenter.UiState, modifier: Modifier = Mo
7470
HorizontalPager(
7571
key = { it },
7672
state = pagerState,
73+
userScrollEnabled = state.userScrollEnabled,
7774
modifier = Modifier
7875
.padding(insets)
7976
.consumeWindowInsets(insets)
8077
.fillMaxSize()
8178
) {
82-
val nextPage: () -> Unit = {
83-
coroutineScope.launch {
84-
pagerState.animateScrollToPage(
85-
it + 1,
86-
animationSpec = spring(stiffness = Spring.StiffnessMediumLow)
87-
)
88-
}
89-
}
90-
9179
when (it) {
9280
0 -> OnboardingWelcomePageLayout(
93-
nextPage = nextPage,
81+
nextPage = { eventSink(UiEvent.Next) },
9482
eventSink = eventSink,
9583
)
9684

9785
1 -> OnboardingPageLayout(
9886
OnboardingPage.CONVERSATIONS,
99-
nextPage = nextPage,
87+
nextPage = { eventSink(UiEvent.Next) },
10088
eventSink = eventSink,
10189
)
10290

10391
2 -> OnboardingPageLayout(
10492
OnboardingPage.PREPARE,
105-
nextPage = nextPage,
93+
nextPage = { eventSink(UiEvent.Next) },
10694
eventSink = eventSink,
10795
)
10896

app/src/main/kotlin/org/cru/godtools/ui/onboarding/OnboardingPresenter.kt

Lines changed: 54 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,18 @@
11
package org.cru.godtools.ui.onboarding
22

3+
import androidx.compose.animation.core.Spring
4+
import androidx.compose.animation.core.spring
5+
import androidx.compose.foundation.pager.PagerState
6+
import androidx.compose.foundation.pager.rememberPagerState
37
import androidx.compose.runtime.Composable
48
import androidx.compose.runtime.LaunchedEffect
9+
import androidx.compose.runtime.getValue
10+
import androidx.compose.runtime.mutableStateOf
11+
import androidx.compose.runtime.rememberCoroutineScope
12+
import androidx.compose.runtime.saveable.rememberSaveable
13+
import androidx.compose.runtime.setValue
514
import com.slack.circuit.codegen.annotations.CircuitInject
15+
import com.slack.circuit.foundation.rememberAnsweringNavigator
616
import com.slack.circuit.runtime.CircuitUiEvent
717
import com.slack.circuit.runtime.CircuitUiState
818
import com.slack.circuit.runtime.Navigator
@@ -11,34 +21,74 @@ import dagger.assisted.Assisted
1121
import dagger.assisted.AssistedFactory
1222
import dagger.assisted.AssistedInject
1323
import dagger.hilt.components.SingletonComponent
24+
import kotlinx.coroutines.launch
1425
import org.cru.godtools.base.Settings
1526
import org.cru.godtools.base.Settings.Companion.FEATURE_TUTORIAL_ONBOARDING
1627
import org.cru.godtools.base.ui.circuit.screen.AppLanguageScreen
1728
import org.cru.godtools.shared.analytics.TutorialAnalyticsActionNames
1829
import org.cru.godtools.tutorial.analytics.model.TutorialAnalyticsActionEvent
1930
import org.cru.godtools.ui.onboarding.OnboardingPresenter.UiState
31+
import org.cru.godtools.ui.settings.country.CountrySettingsScreen
2032
import org.greenrobot.eventbus.EventBus
2133

2234
class OnboardingPresenter @AssistedInject constructor(
2335
private val eventBus: EventBus,
2436
private val settings: Settings,
2537
@Assisted private val navigator: Navigator,
2638
) : Presenter<UiState> {
27-
data class UiState(val eventSink: (UiEvent) -> Unit = {}) : CircuitUiState
39+
data class UiState(
40+
val pagerState: PagerState,
41+
val userScrollEnabled: Boolean = false,
42+
val eventSink: (UiEvent) -> Unit = {}
43+
) : CircuitUiState
2844

2945
sealed interface UiEvent : CircuitUiEvent {
3046
data object ChangeLanguage : UiEvent
47+
data object Next : UiEvent
3148
data object Skip : UiEvent
3249
data object Finish : UiEvent
3350
}
3451

52+
private suspend fun PagerState.navigateToPage(page: Int = currentPage + 1) {
53+
animateScrollToPage(page, animationSpec = spring(stiffness = Spring.StiffnessMediumLow))
54+
}
55+
3556
@Composable
3657
override fun present(): UiState {
58+
var languageWasSet by rememberSaveable { mutableStateOf(false) }
59+
var hasSeenCountrySettings by rememberSaveable { mutableStateOf(false) }
60+
val pagerState = rememberPagerState(pageCount = { 4 })
61+
val scope = rememberCoroutineScope()
62+
val countrySettingsNavigator = rememberAnsweringNavigator<CountrySettingsScreen.Result>(navigator) {
63+
hasSeenCountrySettings = true
64+
scope.launch {
65+
pagerState.navigateToPage(1)
66+
}
67+
}
68+
val languageButtonNavigator = rememberAnsweringNavigator<AppLanguageScreen.Result>(navigator) { result ->
69+
if (result is AppLanguageScreen.Result.LanguageSelected) {
70+
languageWasSet = true
71+
}
72+
}
73+
val nextButtonNavigator = rememberAnsweringNavigator<AppLanguageScreen.Result>(navigator) { result ->
74+
if (result is AppLanguageScreen.Result.LanguageSelected) {
75+
languageWasSet = true
76+
countrySettingsNavigator.goTo(CountrySettingsScreen)
77+
}
78+
}
3779
LaunchedEffect(Unit) { settings.setFeatureDiscovered(FEATURE_TUTORIAL_ONBOARDING) }
38-
39-
return UiState { event ->
80+
return UiState(
81+
pagerState = pagerState,
82+
userScrollEnabled = (languageWasSet && hasSeenCountrySettings) || pagerState.currentPage > 0
83+
) { event ->
4084
when (event) {
41-
UiEvent.ChangeLanguage -> navigator.goTo(AppLanguageScreen)
85+
UiEvent.ChangeLanguage -> languageButtonNavigator.goTo(AppLanguageScreen)
86+
87+
UiEvent.Next -> when {
88+
!languageWasSet -> nextButtonNavigator.goTo(AppLanguageScreen)
89+
!hasSeenCountrySettings -> countrySettingsNavigator.goTo(CountrySettingsScreen)
90+
else -> scope.launch { pagerState.navigateToPage() }
91+
}
4292

4393
UiEvent.Skip -> {
4494
eventBus.post(TutorialAnalyticsActionEvent(TutorialAnalyticsActionNames.ONBOARDING_SKIP))

app/src/main/kotlin/org/cru/godtools/ui/settings/country/CountrySettingsPresenter.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,12 +72,12 @@ class CountrySettingsPresenter @AssistedInject constructor(
7272
countryCode = settingCountryCode,
7373
eventSink = { event ->
7474
when (event) {
75-
UiEvent.NavigateBack -> navigator.pop()
75+
UiEvent.NavigateBack -> navigator.pop(CountrySettingsScreen.Result.Dismissed)
7676

7777
is UiEvent.SelectCountry -> {
7878
scope.launch {
7979
settings.updateCountrySetting(isoCode = event.isoCode)
80-
navigator.pop()
80+
navigator.pop(CountrySettingsScreen.Result.CountrySelected)
8181
}
8282
}
8383
}
Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
11
package org.cru.godtools.ui.settings.country
22

3+
import com.slack.circuit.runtime.screen.PopResult
34
import com.slack.circuit.runtime.screen.Screen
45
import kotlinx.parcelize.Parcelize
56

67
@Parcelize
7-
data object CountrySettingsScreen : Screen
8+
data object CountrySettingsScreen : Screen {
9+
sealed interface Result : PopResult {
10+
@Parcelize data object CountrySelected : Result
11+
@Parcelize data object Dismissed : Result
12+
}
13+
}

app/src/main/kotlin/org/cru/godtools/ui/settings/language/app/AppLanguagePresenter.kt

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -57,11 +57,11 @@ class AppLanguagePresenter @AssistedInject constructor(
5757
val eventSink: (UiEvent) -> Unit = remember {
5858
{
5959
when (it) {
60-
UiEvent.NavigateBack -> navigator.pop()
60+
UiEvent.NavigateBack -> navigator.pop(AppLanguageScreen.Result.Dismissed)
6161

6262
is UiEvent.SelectLanguage -> {
6363
if (it.language == appLocale) {
64-
navigator.pop()
64+
navigator.pop(AppLanguageScreen.Result.LanguageSelected)
6565
} else {
6666
confirmLanguage = it.language
6767
}
@@ -70,7 +70,7 @@ class AppLanguagePresenter @AssistedInject constructor(
7070
is UiEvent.ConfirmLanguage -> {
7171
settings.appLanguage = it.language
7272
confirmLanguage = null
73-
navigator.pop()
73+
navigator.pop(AppLanguageScreen.Result.LanguageSelected)
7474
}
7575

7676
UiEvent.DismissConfirmDialog -> confirmLanguage = null

app/src/test/kotlin/org/cru/godtools/ui/onboarding/OnboardingPresenterTest.kt

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import io.mockk.verify
99
import kotlin.test.AfterTest
1010
import kotlin.test.Test
1111
import kotlin.test.assertEquals
12+
import kotlin.test.assertFalse
1213
import kotlinx.coroutines.test.runTest
1314
import org.ccci.gto.android.common.androidx.compose.ui.platform.AndroidUiDispatcherUtil
1415
import org.cru.godtools.base.Settings
@@ -39,6 +40,24 @@ class OnboardingPresenterTest {
3940
navigator.assertPopIsEmpty()
4041
}
4142

43+
// region Initial State
44+
@Test
45+
fun `Initial State - currentPage is 0`() = runTest {
46+
createPresenter().test {
47+
val state = awaitItem()
48+
assertEquals(0, state.pagerState.currentPage)
49+
}
50+
}
51+
52+
@Test
53+
fun `Initial State - userScrollEnabled is false`() = runTest {
54+
createPresenter().test {
55+
val state = awaitItem()
56+
assertFalse(state.userScrollEnabled)
57+
}
58+
}
59+
// endregion Initial State
60+
4261
// region Feature Discovery
4362
@Test
4463
fun `Feature Discovery - sets feature discovered`() = runTest {
@@ -70,6 +89,22 @@ class OnboardingPresenterTest {
7089
}
7190
// endregion UiEvent.Skip
7291

92+
// region UiEvent.Next
93+
@Test
94+
fun `UiEvent - Next - no language set - navigates to AppLanguageScreen`() = runTest {
95+
createPresenter().test {
96+
awaitItem().eventSink(OnboardingPresenter.UiEvent.Next)
97+
assertEquals(AppLanguageScreen, navigator.awaitNextScreen())
98+
}
99+
}
100+
101+
@Test
102+
fun `UiEvent - Next - language already set - navigates to CountrySettingsScreen`() = runTest {
103+
// TODO: Testing the full answering navigator callback chain requires integration testing.
104+
// The FakeNavigator doesn't trigger rememberAnsweringNavigator callbacks in unit tests.
105+
}
106+
// endregion UiEvent.Next
107+
73108
// region UiEvent.Finish
74109
@Test
75110
fun `UiEvent - Finish`() = runTest {

app/src/test/kotlin/org/cru/godtools/ui/settings/language/app/AppLanguagePresenterTest.kt

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,7 @@ class AppLanguagePresenterTest {
113113
fun `Event - NavigateBack`() = runTest {
114114
presenter.test {
115115
awaitItem().eventSink(UiEvent.NavigateBack)
116-
navigator.awaitPop()
116+
assertEquals(AppLanguageScreen.Result.Dismissed, navigator.awaitPop().result)
117117
}
118118
}
119119
// endregion Event.NavigateBack
@@ -132,7 +132,7 @@ class AppLanguagePresenterTest {
132132
fun `Event - SelectLanguage - Selected app language`() = runTest {
133133
presenter.test {
134134
awaitItem().eventSink(UiEvent.SelectLanguage(Locale.ENGLISH))
135-
navigator.awaitPop()
135+
assertEquals(AppLanguageScreen.Result.LanguageSelected, navigator.awaitPop().result)
136136
}
137137
}
138138
// endregion Event.SelectLanguage
@@ -149,7 +149,7 @@ class AppLanguagePresenterTest {
149149
eventSink(UiEvent.ConfirmLanguage(selectedLanguage))
150150
}
151151

152-
navigator.awaitPop()
152+
assertEquals(AppLanguageScreen.Result.LanguageSelected, navigator.awaitPop().result)
153153
assertNull(expectMostRecentItem().selectedLanguage)
154154
assertEquals(Locale.FRENCH, appLocaleState.value)
155155
}
Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
11
package org.cru.godtools.base.ui.circuit.screen
22

3+
import com.slack.circuit.runtime.screen.PopResult
34
import com.slack.circuit.runtime.screen.Screen
45
import kotlinx.parcelize.Parcelize
56

67
@Parcelize
7-
data object AppLanguageScreen : Screen
8+
data object AppLanguageScreen : Screen {
9+
sealed interface Result : PopResult {
10+
@Parcelize data object LanguageSelected : Result
11+
@Parcelize data object Dismissed : Result
12+
}
13+
}

0 commit comments

Comments
 (0)