BWA-118 - Tutorial text cut off in landscape mode (#299)

This commit is contained in:
Phil Cappelli
2024-12-11 10:13:51 -05:00
committed by GitHub
parent 5b0b3b6c70
commit 2f678ba32e
4 changed files with 533 additions and 241 deletions

View File

@@ -1,8 +1,9 @@
package com.bitwarden.authenticator.ui.platform.feature.tutorial
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.animation.animateColorAsState
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -11,51 +12,56 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.PagerState
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitwarden.authenticator.R
import com.bitwarden.authenticator.ui.platform.base.util.EventsEffect
import com.bitwarden.authenticator.ui.platform.base.util.standardHorizontalMargin
import com.bitwarden.authenticator.ui.platform.components.button.BitwardenFilledTonalButton
import com.bitwarden.authenticator.ui.platform.components.button.BitwardenTextButton
import com.bitwarden.authenticator.ui.platform.components.scaffold.BitwardenScaffold
import com.bitwarden.authenticator.ui.platform.components.util.rememberVectorPainter
import com.bitwarden.authenticator.ui.platform.util.isPortrait
private const val INTRO_PAGE = 0
private const val QR_SCANNER_PAGE = 1
private const val UNIQUE_CODES_PAGE = 2
private const val PAGE_COUNT = 3
/**
* The custom horizontal margin that is specific to this screen.
*/
private val LANDSCAPE_HORIZONTAL_MARGIN: Dp = 128.dp
/**
* Top level composable for the tutorial screen.
*/
@Suppress("LongMethod")
@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class)
@Composable
fun TutorialScreen(
viewModel: TutorialViewModel = hiltViewModel(),
onTutorialFinished: () -> Unit,
) {
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
val pagerState = rememberPagerState(pageCount = { PAGE_COUNT })
val pagerState = rememberPagerState(pageCount = { state.pages.size })
EventsEffect(viewModel = viewModel) { event ->
when (event) {
@@ -63,184 +69,202 @@ fun TutorialScreen(
onTutorialFinished()
}
TutorialEvent.NavigateToQrScannerSlide -> {
pagerState.animateScrollToPage(page = QR_SCANNER_PAGE)
}
TutorialEvent.NavigateToUniqueCodesSlide -> {
pagerState.animateScrollToPage(page = UNIQUE_CODES_PAGE)
is TutorialEvent.UpdatePager -> {
pagerState.animateScrollToPage(event.index)
}
}
}
BitwardenScaffold(
modifier = Modifier.fillMaxSize(),
) { innerPadding ->
Column(
modifier = Modifier
.padding(innerPadding),
verticalArrangement = Arrangement.Center,
) {
HorizontalPager(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 16.dp)
.weight(1f),
state = pagerState,
userScrollEnabled = true,
) { page ->
viewModel.trySendAction(
TutorialAction.TutorialPageChange(pagerState.targetPage),
) {
TutorialScreenContent(
state = state,
pagerState = pagerState,
onPagerSwipe = remember(viewModel) {
{ viewModel.trySendAction(TutorialAction.PagerSwipe(it)) }
},
onDotClick = remember(viewModel) {
{ viewModel.trySendAction(TutorialAction.DotClick(it)) }
},
continueClick = remember(viewModel) {
{ viewModel.trySendAction(TutorialAction.ContinueClick(it)) }
},
skipClick = remember(viewModel) {
{ viewModel.trySendAction(TutorialAction.SkipClick) }
},
modifier = Modifier.fillMaxWidth(),
)
}
}
@Composable
private fun TutorialScreenContent(
state: TutorialState,
pagerState: PagerState,
onPagerSwipe: (Int) -> Unit,
onDotClick: (Int) -> Unit,
continueClick: (Int) -> Unit,
skipClick: () -> Unit,
modifier: Modifier = Modifier,
) {
LaunchedEffect(pagerState.currentPage) {
onPagerSwipe(pagerState.currentPage)
}
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = modifier,
) {
Spacer(modifier = Modifier.weight(1f))
HorizontalPager(state = pagerState) { index ->
if (LocalConfiguration.current.isPortrait) {
TutorialScreenPortrait(
state = state.pages[index],
modifier = Modifier.standardHorizontalMargin(),
)
} else {
TutorialScreenLandscape(
state = state.pages[index],
modifier = Modifier
.standardHorizontalMargin(landscape = LANDSCAPE_HORIZONTAL_MARGIN),
)
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
when (page) {
INTRO_PAGE -> VerificationCodesContent()
QR_SCANNER_PAGE -> TutorialQrScannerScreen()
UNIQUE_CODES_PAGE -> UniqueCodesContent()
}
}
}
}
LazyColumn(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.Bottom,
) {
item {
Row(
modifier = Modifier
.height(50.dp)
.fillMaxWidth(),
horizontalArrangement = Arrangement.Center,
) {
repeat(PAGE_COUNT) {
val color = if (pagerState.currentPage == it) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.secondary
}
Box(
modifier = Modifier
.padding(8.dp)
.background(color, CircleShape)
.size(10.dp),
)
}
}
}
Spacer(modifier = Modifier.weight(1f))
item {
BitwardenFilledTonalButton(
modifier = Modifier.fillMaxWidth(),
label = state.continueButtonText(),
onClick = remember(viewModel) {
{
viewModel.trySendAction(
TutorialAction.ContinueClick,
)
}
},
)
}
IndicatorDots(
selectedIndexProvider = { state.index },
totalCount = state.pages.size,
onDotClick = onDotClick,
modifier = Modifier
.padding(bottom = 12.dp)
.height(44.dp),
)
item {
val alpha = remember(state) {
if (state.isLastPage) {
0f
} else {
1f
}
}
BitwardenTextButton(
modifier = Modifier
.fillMaxWidth()
.alpha(alpha),
isEnabled = !state.isLastPage,
label = stringResource(id = R.string.skip),
onClick = remember(viewModel) {
{
viewModel.trySendAction(TutorialAction.SkipClick)
}
},
)
}
}
BitwardenFilledTonalButton(
label = state.actionButtonText,
onClick = { continueClick(state.index) },
modifier = Modifier
.standardHorizontalMargin(landscape = LANDSCAPE_HORIZONTAL_MARGIN)
.fillMaxWidth(),
)
BitwardenTextButton(
isEnabled = !state.isLastPage,
label = stringResource(id = R.string.skip),
onClick = skipClick,
modifier = Modifier
.standardHorizontalMargin(landscape = LANDSCAPE_HORIZONTAL_MARGIN)
.fillMaxWidth()
.alpha(if (state.isLastPage) 0f else 1f)
.padding(bottom = 12.dp),
)
Spacer(modifier = Modifier.navigationBarsPadding())
}
}
@Composable
private fun TutorialScreenPortrait(
state: TutorialState.TutorialSlide,
modifier: Modifier = Modifier,
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = modifier,
) {
Image(
painter = rememberVectorPainter(id = state.image),
contentDescription = null,
modifier = Modifier.size(200.dp),
)
Text(
text = stringResource(id = state.title),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.headlineMedium,
modifier = Modifier
.padding(
top = 48.dp,
bottom = 16.dp,
),
)
Text(
text = stringResource(id = state.message),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.bodyLarge,
)
}
}
@Composable
private fun TutorialScreenLandscape(
state: TutorialState.TutorialSlide,
modifier: Modifier = Modifier,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = modifier,
) {
Image(
painter = rememberVectorPainter(id = state.image),
contentDescription = null,
modifier = Modifier
.size(132.dp),
)
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.padding(start = 40.dp),
) {
Text(
text = stringResource(id = state.title),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.headlineMedium,
modifier = Modifier.padding(bottom = 16.dp),
)
Text(
text = stringResource(id = state.message),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.bodyLarge,
)
}
}
}
@Composable
private fun VerificationCodesContent() {
Image(
painter = painterResource(R.drawable.ic_tutorial_verification_codes),
contentDescription = stringResource(
id = R.string.secure_your_accounts_with_bitwarden_authenticator,
),
)
Spacer(Modifier.height(24.dp))
Text(
style = MaterialTheme.typography.headlineSmall,
textAlign = TextAlign.Center,
text = stringResource(R.string.secure_your_accounts_with_bitwarden_authenticator),
)
Spacer(Modifier.height(8.dp))
Text(
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center,
text = stringResource(R.string.get_verification_codes_for_all_your_accounts),
)
}
private fun IndicatorDots(
selectedIndexProvider: () -> Int,
totalCount: Int,
onDotClick: (Int) -> Unit,
modifier: Modifier = Modifier,
) {
LazyRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
modifier = modifier,
) {
items(totalCount) { index ->
val color = animateColorAsState(
targetValue = MaterialTheme.colorScheme.primary.copy(
alpha = if (index == selectedIndexProvider()) 1.0f else 0.3f,
),
label = "dotColor",
)
@Composable
private fun TutorialQrScannerScreen() {
Image(
painter = painterResource(id = R.drawable.ic_tutorial_qr_scanner),
contentDescription = stringResource(id = R.string.scan_qr_code),
)
Spacer(Modifier.height(24.dp))
Text(
style = MaterialTheme.typography.headlineSmall,
textAlign = TextAlign.Center,
text = stringResource(
R.string.use_your_device_camera_to_scan_codes,
),
)
Spacer(Modifier.height(8.dp))
Text(
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center,
text = stringResource(
R.string.scan_the_qr_code_in_your_2_step_verification_settings_for_any_account,
),
)
}
@Suppress("MaxLineLength")
@Composable
private fun UniqueCodesContent() {
Image(
painter = painterResource(id = R.drawable.ic_tutorial_2fa),
contentDescription = stringResource(id = R.string.unique_codes),
)
Spacer(Modifier.height(24.dp))
Text(
style = MaterialTheme.typography.headlineSmall,
textAlign = TextAlign.Center,
text = stringResource(R.string.sign_in_using_unique_codes),
)
Spacer(Modifier.height(8.dp))
Text(
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center,
text = stringResource(
R.string.when_using_2_step_verification_youll_enter_your_username_and_password_and_a_code_generated_in_this_app,
),
)
Box(
modifier = Modifier
.size(8.dp)
.clip(CircleShape)
.background(color.value)
.clickable { onDotClick(index) },
)
}
}
}
@Preview

View File

@@ -3,8 +3,6 @@ package com.bitwarden.authenticator.ui.platform.feature.tutorial
import android.os.Parcelable
import com.bitwarden.authenticator.R
import com.bitwarden.authenticator.ui.platform.base.BaseViewModel
import com.bitwarden.authenticator.ui.platform.base.util.Text
import com.bitwarden.authenticator.ui.platform.base.util.asText
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.update
import kotlinx.parcelize.Parcelize
@@ -16,43 +14,40 @@ import javax.inject.Inject
@HiltViewModel
class TutorialViewModel @Inject constructor() :
BaseViewModel<TutorialState, TutorialEvent, TutorialAction>(
initialState = TutorialState.IntroSlide,
initialState = TutorialState(
index = 0,
pages = listOf(
TutorialState.TutorialSlide.IntroSlide,
TutorialState.TutorialSlide.QrScannerSlide,
TutorialState.TutorialSlide.UniqueCodesSlide,
),
),
) {
override fun handleAction(action: TutorialAction) {
when (action) {
TutorialAction.ContinueClick -> {
handleContinueClick()
}
TutorialAction.SkipClick -> {
handleSkipClick()
}
is TutorialAction.TutorialPageChange -> {
handleTutorialPageChange(action.targetPage)
}
is TutorialAction.PagerSwipe -> handlePagerSwipe(action)
is TutorialAction.DotClick -> handleDotClick(action)
is TutorialAction.ContinueClick -> handleContinueClick(action)
TutorialAction.SkipClick -> handleSkipClick()
}
}
private fun handleTutorialPageChange(page: Int) {
when (page) {
0 -> mutableStateFlow.update { TutorialState.IntroSlide }
1 -> mutableStateFlow.update { TutorialState.QrScannerSlide }
2 -> mutableStateFlow.update { TutorialState.UniqueCodesSlide }
}
private fun handlePagerSwipe(action: TutorialAction.PagerSwipe) {
mutableStateFlow.update { it.copy(index = action.index) }
}
private fun handleContinueClick() {
val currentPage = mutableStateFlow.value
val event = when (currentPage) {
TutorialState.IntroSlide -> TutorialEvent.NavigateToQrScannerSlide
TutorialState.QrScannerSlide -> TutorialEvent.NavigateToUniqueCodesSlide
TutorialState.UniqueCodesSlide -> {
TutorialEvent.NavigateToAuthenticator
}
private fun handleDotClick(action: TutorialAction.DotClick) {
mutableStateFlow.update { it.copy(index = action.index) }
sendEvent(TutorialEvent.UpdatePager(index = action.index))
}
private fun handleContinueClick(action: TutorialAction.ContinueClick) {
if (mutableStateFlow.value.isLastPage) {
sendEvent(TutorialEvent.NavigateToAuthenticator)
} else {
mutableStateFlow.update { it.copy(index = action.index + 1) }
sendEvent(TutorialEvent.UpdatePager(index = action.index + 1))
}
sendEvent(event)
}
private fun handleSkipClick() {
@@ -64,57 +59,78 @@ class TutorialViewModel @Inject constructor() :
* Models state for the Tutorial screen.
*/
@Parcelize
sealed class TutorialState(
val continueButtonText: Text,
val isLastPage: Boolean,
data class TutorialState(
val index: Int,
val pages: List<TutorialSlide>,
) : Parcelable {
/**
* Provides the text for the action button based on the current page index.
* - Displays "Continue" if the user is not on the last page.
* - Displays "Get Started" if the user is on the last page.
*/
val actionButtonText: String
get() = if (index != pages.lastIndex) "Continue" else "Get Started"
/**
* Tutorial should display the introduction slide.
* Indicates whether the current slide is the last in the pages array.
*/
@Parcelize
data object IntroSlide : TutorialState(
continueButtonText = R.string.continue_button.asText(),
isLastPage = false,
)
val isLastPage: Boolean
get() = index == pages.lastIndex
/**
* Tutorial should display the QR code scanner description slide.
* A sealed class to represent the different slides the user can view on the tutorial screen.
*/
@Parcelize
data object QrScannerSlide : TutorialState(
continueButtonText = R.string.continue_button.asText(),
isLastPage = false,
)
@Suppress("MaxLineLength")
sealed class TutorialSlide : Parcelable {
abstract val image: Int
abstract val title: Int
abstract val message: Int
/**
* Tutorial should display the 2FA code description slide.
*/
@Parcelize
data object UniqueCodesSlide : TutorialState(
continueButtonText = R.string.get_started.asText(),
isLastPage = true,
)
/**
* Tutorial should display the introduction slide.
*/
@Parcelize
data object IntroSlide : TutorialSlide() {
override val image: Int get() = R.drawable.ic_tutorial_verification_codes
override val title: Int get() = R.string.secure_your_accounts_with_bitwarden_authenticator
override val message: Int get() = R.string.get_verification_codes_for_all_your_accounts
}
/**
* Tutorial should display the QR code scanner description slide.
*/
@Parcelize
data object QrScannerSlide : TutorialSlide() {
override val image: Int get() = R.drawable.ic_tutorial_qr_scanner
override val title: Int get() = R.string.use_your_device_camera_to_scan_codes
override val message: Int get() = R.string.scan_the_qr_code_in_your_2_step_verification_settings_for_any_account
}
/**
* Tutorial should display the 2FA code description slide.
*/
@Parcelize
data object UniqueCodesSlide : TutorialSlide() {
override val image: Int get() = R.drawable.ic_tutorial_2fa
override val title: Int get() = R.string.sign_in_using_unique_codes
override val message: Int get() = R.string.when_using_2_step_verification_youll_enter_your_username_and_password_and_a_code_generated_in_this_app
}
}
}
/**
* Represents a set of events related to the tutorial screen.
*/
sealed class TutorialEvent {
/**
* Updates the current index of the pager.
*/
data class UpdatePager(val index: Int) : TutorialEvent()
/**
* Navigate to the authenticator tutorial slide.
*/
data object NavigateToAuthenticator : TutorialEvent()
/**
* Navigate to the QR Code scanner tutorial slide.
*/
data object NavigateToQrScannerSlide : TutorialEvent()
/**
* Navigate to the unique codes tutorial slide.
*/
data object NavigateToUniqueCodesSlide : TutorialEvent()
}
/**
@@ -122,16 +138,19 @@ sealed class TutorialEvent {
*/
sealed class TutorialAction {
/**
* The user has manually changed the tutorial page by swiping.
* Swipe the pager to the given [index].
*/
data class TutorialPageChange(
val targetPage: Int,
) : TutorialAction()
data class PagerSwipe(val index: Int) : TutorialAction()
/**
* The user clicked the continue button on the introduction slide.
* Click one of the page indicator dots at the given [index].
*/
data object ContinueClick : TutorialAction()
data class DotClick(val index: Int) : TutorialAction()
/**
* The user clicked the continue button at the given [index].
*/
data class ContinueClick(val index: Int) : TutorialAction()
/**
* The user clicked the skip button on one of the tutorial slides.

View File

@@ -0,0 +1,130 @@
package com.bitwarden.authenticator.ui.authenticator.feature.tutorial
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import com.bitwarden.authenticator.data.platform.repository.util.bufferedMutableSharedFlow
import com.bitwarden.authenticator.ui.platform.base.BaseComposeTest
import com.bitwarden.authenticator.ui.platform.feature.tutorial.TutorialAction
import com.bitwarden.authenticator.ui.platform.feature.tutorial.TutorialEvent
import com.bitwarden.authenticator.ui.platform.feature.tutorial.TutorialScreen
import com.bitwarden.authenticator.ui.platform.feature.tutorial.TutorialState
import com.bitwarden.authenticator.ui.platform.feature.tutorial.TutorialViewModel
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import org.junit.Before
import org.junit.Test
import org.junit.jupiter.api.Assertions.assertTrue
class TutorialScreenTest : BaseComposeTest() {
private var onTutorialFinishedCalled = false
private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE)
private val mutableEventFlow = bufferedMutableSharedFlow<TutorialEvent>()
private val viewModel = mockk<TutorialViewModel>(relaxed = true) {
every { stateFlow } returns mutableStateFlow
every { eventFlow } returns mutableEventFlow
}
@Before
fun setUp() {
composeTestRule.setContent {
TutorialScreen(
viewModel = viewModel,
onTutorialFinished = { onTutorialFinishedCalled = true },
)
}
}
@Test
fun `pages should display and update according to state`() {
composeTestRule
.onNodeWithText("Secure your accounts with Bitwarden Authenticator")
.assertExists()
.assertIsDisplayed()
mutableEventFlow.tryEmit(TutorialEvent.UpdatePager(index = 1))
composeTestRule
.onNodeWithText("Secure your accounts with Bitwarden Authenticator")
.assertDoesNotExist()
composeTestRule
.onNodeWithText("Use your device camera to scan codes")
.assertExists()
.assertIsDisplayed()
mutableStateFlow.update {
it.copy(pages = listOf(TutorialState.TutorialSlide.UniqueCodesSlide))
}
composeTestRule
.onNodeWithText("Sign in using unique codes")
.assertExists()
.assertIsDisplayed()
}
@Test
fun `Primary action button should say Continue when not at the end of the slides`() {
composeTestRule
.onNodeWithText("Continue")
.assertExists()
.assertIsDisplayed()
}
@Test
fun `Primary action button should say Get started when at the end of the slides`() {
mutableStateFlow.update {
it.copy(pages = listOf(TutorialState.TutorialSlide.UniqueCodesSlide))
}
composeTestRule
.onNodeWithText("Get Started")
.assertExists()
.assertIsDisplayed()
}
@Test
fun `NavigateToAuthenticator event should call onTutorialFinished`() {
mutableEventFlow.tryEmit(TutorialEvent.NavigateToAuthenticator)
assertTrue(onTutorialFinishedCalled)
}
@Test
fun `continue button click should send ContinueClick action`() {
composeTestRule
.onNodeWithText("Continue")
.performClick()
verify {
viewModel.trySendAction(TutorialAction.ContinueClick(mutableStateFlow.value.index))
}
}
@Test
fun `get started button click should send ContinueClick action`() {
mutableStateFlow.update {
it.copy(pages = listOf(TutorialState.TutorialSlide.UniqueCodesSlide))
}
composeTestRule
.onNodeWithText("Get Started")
.performClick()
verify {
viewModel.trySendAction(TutorialAction.ContinueClick(mutableStateFlow.value.index))
}
}
@Test
fun `skip button click should send SkipClick action`() {
composeTestRule
.onNodeWithText("Skip")
.performClick()
verify { viewModel.trySendAction(TutorialAction.SkipClick) }
}
}
private val DEFAULT_STATE = TutorialState(
index = 0,
pages = listOf(
TutorialState.TutorialSlide.IntroSlide,
TutorialState.TutorialSlide.QrScannerSlide,
),
)

View File

@@ -0,0 +1,119 @@
package com.bitwarden.authenticator.ui.authenticator.feature.tutorial
import app.cash.turbine.test
import com.bitwarden.authenticator.ui.platform.base.BaseViewModelTest
import com.bitwarden.authenticator.ui.platform.feature.tutorial.TutorialAction
import com.bitwarden.authenticator.ui.platform.feature.tutorial.TutorialEvent
import com.bitwarden.authenticator.ui.platform.feature.tutorial.TutorialState
import com.bitwarden.authenticator.ui.platform.feature.tutorial.TutorialViewModel
import org.junit.jupiter.api.Assertions.assertEquals
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
class TutorialViewModelTest : BaseViewModelTest() {
private lateinit var viewModel: TutorialViewModel
@BeforeEach
fun setUp() {
viewModel = TutorialViewModel()
}
@Test
fun `initial state should be correct`() = runTest {
viewModel.stateFlow.test {
assertEquals(
DEFAULT_STATE,
awaitItem(),
)
}
}
@Test
fun `PagerSwipe should update state`() = runTest {
val newIndex = 2
viewModel.trySendAction(TutorialAction.PagerSwipe(index = newIndex))
viewModel.stateFlow.test {
assertEquals(
DEFAULT_STATE.copy(index = newIndex),
awaitItem(),
)
}
}
@Test
fun `DotClick should update state and emit UpdatePager`() = runTest {
val newIndex = 2
viewModel.trySendAction(TutorialAction.DotClick(index = newIndex))
viewModel.stateFlow.test {
assertEquals(
DEFAULT_STATE.copy(index = newIndex),
awaitItem(),
)
}
viewModel.eventFlow.test {
assertEquals(
TutorialEvent.UpdatePager(index = newIndex),
awaitItem(),
)
}
}
@Test
fun `ContinueClick should emit NavigateToAuthenticator when at the end of pages`() = runTest {
// Step 1: Verify state updates for index 0 -> 1
viewModel.trySendAction(TutorialAction.ContinueClick(0))
viewModel.stateFlow.test {
assertEquals(
DEFAULT_STATE.copy(index = 1),
awaitItem(),
)
}
// Step 2: Verify state updates for index 1 -> 2
viewModel.trySendAction(TutorialAction.ContinueClick(1))
viewModel.stateFlow.test {
assertEquals(
DEFAULT_STATE.copy(index = 2),
awaitItem(),
)
}
// Step 3: Clean up any residual events before asserting event emission
viewModel.eventFlow.test {
cancelAndConsumeRemainingEvents() // Clear all old events
}
// Step 4: Verify event emission when reaching the end of the pages
viewModel.trySendAction(TutorialAction.ContinueClick(2))
viewModel.eventFlow.test {
assertEquals(
TutorialEvent.NavigateToAuthenticator,
awaitItem(),
)
}
}
@Test
fun `SkipClick should emit NavigateToAuthenticator`() = runTest {
viewModel.trySendAction(TutorialAction.SkipClick)
viewModel.eventFlow.test {
assertEquals(
TutorialEvent.NavigateToAuthenticator,
awaitItem(),
)
}
}
}
private val DEFAULT_STATE = TutorialState(
index = 0,
pages = listOf(
TutorialState.TutorialSlide.IntroSlide,
TutorialState.TutorialSlide.QrScannerSlide,
TutorialState.TutorialSlide.UniqueCodesSlide,
),
)