diff --git a/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/tutorial/TutorialScreen.kt b/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/tutorial/TutorialScreen.kt index 5b793a088..698a13910 100644 --- a/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/tutorial/TutorialScreen.kt +++ b/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/tutorial/TutorialScreen.kt @@ -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), - ) - Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, - ) { - when (page) { - INTRO_PAGE -> VerificationCodesContent() - - QR_SCANNER_PAGE -> TutorialQrScannerScreen() - - UNIQUE_CODES_PAGE -> UniqueCodesContent() - } - } - } + ) { + 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(), + ) + } +} - 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), - ) - } - } - } - - item { - BitwardenFilledTonalButton( - modifier = Modifier.fillMaxWidth(), - label = state.continueButtonText(), - onClick = remember(viewModel) { - { - viewModel.trySendAction( - TutorialAction.ContinueClick, - ) - } - }, - ) - } - - 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) - } - }, - ) - } +@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), + ) } } + + Spacer(modifier = Modifier.weight(1f)) + + IndicatorDots( + selectedIndexProvider = { state.index }, + totalCount = state.pages.size, + onDotClick = onDotClick, + modifier = Modifier + .padding(bottom = 12.dp) + .height(44.dp), + ) + + 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 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 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 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, - ), - ) +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, + ) + } + } } -@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, - ), - ) +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", + ) + + Box( + modifier = Modifier + .size(8.dp) + .clip(CircleShape) + .background(color.value) + .clickable { onDotClick(index) }, + ) + } + } } @Preview diff --git a/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/tutorial/TutorialViewModel.kt b/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/tutorial/TutorialViewModel.kt index 1373eb668..37ce7c62c 100644 --- a/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/tutorial/TutorialViewModel.kt +++ b/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/tutorial/TutorialViewModel.kt @@ -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( - 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 handleDotClick(action: TutorialAction.DotClick) { + mutableStateFlow.update { it.copy(index = action.index) } + sendEvent(TutorialEvent.UpdatePager(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 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,37 +59,63 @@ 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, ) : Parcelable { - /** - * Tutorial should display the introduction slide. + * 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. */ - @Parcelize - data object IntroSlide : TutorialState( - continueButtonText = R.string.continue_button.asText(), - isLastPage = false, - ) + val actionButtonText: String + get() = if (index != pages.lastIndex) "Continue" else "Get Started" /** - * Tutorial should display the QR code scanner description slide. + * Indicates whether the current slide is the last in the pages array. */ - @Parcelize - data object QrScannerSlide : TutorialState( - continueButtonText = R.string.continue_button.asText(), - isLastPage = false, - ) + val isLastPage: Boolean + get() = index == pages.lastIndex /** - * Tutorial should display the 2FA code description slide. + * A sealed class to represent the different slides the user can view on the tutorial screen. */ - @Parcelize - data object UniqueCodesSlide : TutorialState( - continueButtonText = R.string.get_started.asText(), - isLastPage = true, - ) + @Suppress("MaxLineLength") + sealed class TutorialSlide : Parcelable { + abstract val image: Int + abstract val title: Int + abstract val message: Int + + /** + * 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 + } + } } /** @@ -102,19 +123,14 @@ sealed class TutorialState( */ sealed class TutorialEvent { /** - * Navigate to the authenticator tutorial slide. - */ - data object NavigateToAuthenticator : TutorialEvent() - - /** - * Navigate to the QR Code scanner tutorial slide. + * Updates the current index of the pager. */ - data object NavigateToQrScannerSlide : TutorialEvent() + data class UpdatePager(val index: Int) : TutorialEvent() /** - * Navigate to the unique codes tutorial slide. + * Navigate to the authenticator tutorial slide. */ - data object NavigateToUniqueCodesSlide : TutorialEvent() + data object NavigateToAuthenticator : 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 PagerSwipe(val index: Int) : TutorialAction() + + /** + * Click one of the page indicator dots at the given [index]. */ - data class TutorialPageChange( - val targetPage: Int, - ) : TutorialAction() + data class DotClick(val index: Int) : TutorialAction() /** - * The user clicked the continue button on the introduction slide. + * The user clicked the continue button at the given [index]. */ - data object ContinueClick : TutorialAction() + data class ContinueClick(val index: Int) : TutorialAction() /** * The user clicked the skip button on one of the tutorial slides. diff --git a/app/src/test/java/com/bitwarden/authenticator/ui/authenticator/feature/tutorial/TutorialScreenTest.kt b/app/src/test/java/com/bitwarden/authenticator/ui/authenticator/feature/tutorial/TutorialScreenTest.kt new file mode 100644 index 000000000..3cf921212 --- /dev/null +++ b/app/src/test/java/com/bitwarden/authenticator/ui/authenticator/feature/tutorial/TutorialScreenTest.kt @@ -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() + private val viewModel = mockk(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, + ), +) diff --git a/app/src/test/java/com/bitwarden/authenticator/ui/authenticator/feature/tutorial/TutorialViewModelTest.kt b/app/src/test/java/com/bitwarden/authenticator/ui/authenticator/feature/tutorial/TutorialViewModelTest.kt new file mode 100644 index 000000000..6a1401905 --- /dev/null +++ b/app/src/test/java/com/bitwarden/authenticator/ui/authenticator/feature/tutorial/TutorialViewModelTest.kt @@ -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, + ), +)