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 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.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column 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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size 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.HorizontalPager
import androidx.compose.foundation.pager.PagerState
import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha 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.res.stringResource
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitwarden.authenticator.R import com.bitwarden.authenticator.R
import com.bitwarden.authenticator.ui.platform.base.util.EventsEffect 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.BitwardenFilledTonalButton
import com.bitwarden.authenticator.ui.platform.components.button.BitwardenTextButton 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.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 * The custom horizontal margin that is specific to this screen.
private const val UNIQUE_CODES_PAGE = 2 */
private const val PAGE_COUNT = 3 private val LANDSCAPE_HORIZONTAL_MARGIN: Dp = 128.dp
/** /**
* Top level composable for the tutorial screen. * Top level composable for the tutorial screen.
*/ */
@Suppress("LongMethod")
@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class)
@Composable @Composable
fun TutorialScreen( fun TutorialScreen(
viewModel: TutorialViewModel = hiltViewModel(), viewModel: TutorialViewModel = hiltViewModel(),
onTutorialFinished: () -> Unit, onTutorialFinished: () -> Unit,
) { ) {
val state by viewModel.stateFlow.collectAsStateWithLifecycle() val state by viewModel.stateFlow.collectAsStateWithLifecycle()
val pagerState = rememberPagerState(pageCount = { PAGE_COUNT }) val pagerState = rememberPagerState(pageCount = { state.pages.size })
EventsEffect(viewModel = viewModel) { event -> EventsEffect(viewModel = viewModel) { event ->
when (event) { when (event) {
@@ -63,184 +69,202 @@ fun TutorialScreen(
onTutorialFinished() onTutorialFinished()
} }
TutorialEvent.NavigateToQrScannerSlide -> { is TutorialEvent.UpdatePager -> {
pagerState.animateScrollToPage(page = QR_SCANNER_PAGE) pagerState.animateScrollToPage(event.index)
}
TutorialEvent.NavigateToUniqueCodesSlide -> {
pagerState.animateScrollToPage(page = UNIQUE_CODES_PAGE)
} }
} }
} }
BitwardenScaffold( BitwardenScaffold(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
) { innerPadding -> ) {
Column( TutorialScreenContent(
modifier = Modifier state = state,
.padding(innerPadding), pagerState = pagerState,
verticalArrangement = Arrangement.Center, onPagerSwipe = remember(viewModel) {
) { { viewModel.trySendAction(TutorialAction.PagerSwipe(it)) }
HorizontalPager( },
modifier = Modifier onDotClick = remember(viewModel) {
.fillMaxSize() { viewModel.trySendAction(TutorialAction.DotClick(it)) }
.padding(horizontal = 16.dp) },
.weight(1f), continueClick = remember(viewModel) {
state = pagerState, { viewModel.trySendAction(TutorialAction.ContinueClick(it)) }
userScrollEnabled = true, },
) { page -> skipClick = remember(viewModel) {
viewModel.trySendAction( { viewModel.trySendAction(TutorialAction.SkipClick) }
TutorialAction.TutorialPageChange(pagerState.targetPage), },
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( Spacer(modifier = Modifier.weight(1f))
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 { IndicatorDots(
BitwardenFilledTonalButton( selectedIndexProvider = { state.index },
modifier = Modifier.fillMaxWidth(), totalCount = state.pages.size,
label = state.continueButtonText(), onDotClick = onDotClick,
onClick = remember(viewModel) { modifier = Modifier
{ .padding(bottom = 12.dp)
viewModel.trySendAction( .height(44.dp),
TutorialAction.ContinueClick, )
)
}
},
)
}
item { BitwardenFilledTonalButton(
val alpha = remember(state) { label = state.actionButtonText,
if (state.isLastPage) { onClick = { continueClick(state.index) },
0f modifier = Modifier
} else { .standardHorizontalMargin(landscape = LANDSCAPE_HORIZONTAL_MARGIN)
1f .fillMaxWidth(),
} )
}
BitwardenTextButton( BitwardenTextButton(
modifier = Modifier isEnabled = !state.isLastPage,
.fillMaxWidth() label = stringResource(id = R.string.skip),
.alpha(alpha), onClick = skipClick,
isEnabled = !state.isLastPage, modifier = Modifier
label = stringResource(id = R.string.skip), .standardHorizontalMargin(landscape = LANDSCAPE_HORIZONTAL_MARGIN)
onClick = remember(viewModel) { .fillMaxWidth()
{ .alpha(if (state.isLastPage) 0f else 1f)
viewModel.trySendAction(TutorialAction.SkipClick) .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 @Composable
private fun VerificationCodesContent() { private fun IndicatorDots(
Image( selectedIndexProvider: () -> Int,
painter = painterResource(R.drawable.ic_tutorial_verification_codes), totalCount: Int,
contentDescription = stringResource( onDotClick: (Int) -> Unit,
id = R.string.secure_your_accounts_with_bitwarden_authenticator, modifier: Modifier = Modifier,
), ) {
) LazyRow(
Spacer(Modifier.height(24.dp)) horizontalArrangement = Arrangement.spacedBy(8.dp),
Text( verticalAlignment = Alignment.CenterVertically,
style = MaterialTheme.typography.headlineSmall, modifier = modifier,
textAlign = TextAlign.Center, ) {
text = stringResource(R.string.secure_your_accounts_with_bitwarden_authenticator), items(totalCount) { index ->
) val color = animateColorAsState(
Spacer(Modifier.height(8.dp)) targetValue = MaterialTheme.colorScheme.primary.copy(
Text( alpha = if (index == selectedIndexProvider()) 1.0f else 0.3f,
style = MaterialTheme.typography.bodyLarge, ),
textAlign = TextAlign.Center, label = "dotColor",
text = stringResource(R.string.get_verification_codes_for_all_your_accounts), )
)
}
@Composable Box(
private fun TutorialQrScannerScreen() { modifier = Modifier
Image( .size(8.dp)
painter = painterResource(id = R.drawable.ic_tutorial_qr_scanner), .clip(CircleShape)
contentDescription = stringResource(id = R.string.scan_qr_code), .background(color.value)
) .clickable { onDotClick(index) },
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,
),
)
} }
@Preview @Preview

View File

@@ -3,8 +3,6 @@ package com.bitwarden.authenticator.ui.platform.feature.tutorial
import android.os.Parcelable import android.os.Parcelable
import com.bitwarden.authenticator.R import com.bitwarden.authenticator.R
import com.bitwarden.authenticator.ui.platform.base.BaseViewModel 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 dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
@@ -16,43 +14,40 @@ import javax.inject.Inject
@HiltViewModel @HiltViewModel
class TutorialViewModel @Inject constructor() : class TutorialViewModel @Inject constructor() :
BaseViewModel<TutorialState, TutorialEvent, TutorialAction>( 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) { override fun handleAction(action: TutorialAction) {
when (action) { when (action) {
TutorialAction.ContinueClick -> { is TutorialAction.PagerSwipe -> handlePagerSwipe(action)
handleContinueClick() is TutorialAction.DotClick -> handleDotClick(action)
} is TutorialAction.ContinueClick -> handleContinueClick(action)
TutorialAction.SkipClick -> handleSkipClick()
TutorialAction.SkipClick -> {
handleSkipClick()
}
is TutorialAction.TutorialPageChange -> {
handleTutorialPageChange(action.targetPage)
}
} }
} }
private fun handleTutorialPageChange(page: Int) { private fun handlePagerSwipe(action: TutorialAction.PagerSwipe) {
when (page) { mutableStateFlow.update { it.copy(index = action.index) }
0 -> mutableStateFlow.update { TutorialState.IntroSlide }
1 -> mutableStateFlow.update { TutorialState.QrScannerSlide }
2 -> mutableStateFlow.update { TutorialState.UniqueCodesSlide }
}
} }
private fun handleContinueClick() { private fun handleDotClick(action: TutorialAction.DotClick) {
val currentPage = mutableStateFlow.value mutableStateFlow.update { it.copy(index = action.index) }
val event = when (currentPage) { sendEvent(TutorialEvent.UpdatePager(index = action.index))
TutorialState.IntroSlide -> TutorialEvent.NavigateToQrScannerSlide }
TutorialState.QrScannerSlide -> TutorialEvent.NavigateToUniqueCodesSlide
TutorialState.UniqueCodesSlide -> { private fun handleContinueClick(action: TutorialAction.ContinueClick) {
TutorialEvent.NavigateToAuthenticator 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() { private fun handleSkipClick() {
@@ -64,57 +59,78 @@ class TutorialViewModel @Inject constructor() :
* Models state for the Tutorial screen. * Models state for the Tutorial screen.
*/ */
@Parcelize @Parcelize
sealed class TutorialState( data class TutorialState(
val continueButtonText: Text, val index: Int,
val isLastPage: Boolean, val pages: List<TutorialSlide>,
) : Parcelable { ) : 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 val isLastPage: Boolean
data object IntroSlide : TutorialState( get() = index == pages.lastIndex
continueButtonText = R.string.continue_button.asText(),
isLastPage = false,
)
/** /**
* 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 @Suppress("MaxLineLength")
data object QrScannerSlide : TutorialState( sealed class TutorialSlide : Parcelable {
continueButtonText = R.string.continue_button.asText(), abstract val image: Int
isLastPage = false, abstract val title: Int
) abstract val message: Int
/** /**
* Tutorial should display the 2FA code description slide. * Tutorial should display the introduction slide.
*/ */
@Parcelize @Parcelize
data object UniqueCodesSlide : TutorialState( data object IntroSlide : TutorialSlide() {
continueButtonText = R.string.get_started.asText(), override val image: Int get() = R.drawable.ic_tutorial_verification_codes
isLastPage = true, 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. * Represents a set of events related to the tutorial screen.
*/ */
sealed class TutorialEvent { sealed class TutorialEvent {
/**
* Updates the current index of the pager.
*/
data class UpdatePager(val index: Int) : TutorialEvent()
/** /**
* Navigate to the authenticator tutorial slide. * Navigate to the authenticator tutorial slide.
*/ */
data object NavigateToAuthenticator : TutorialEvent() 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 { sealed class TutorialAction {
/** /**
* The user has manually changed the tutorial page by swiping. * Swipe the pager to the given [index].
*/ */
data class TutorialPageChange( data class PagerSwipe(val index: Int) : TutorialAction()
val targetPage: 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. * 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,
),
)