mirror of
https://github.com/bitwarden/android.git
synced 2026-03-12 05:04:17 -05:00
BWA-118 - Tutorial text cut off in landscape mode (#299)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
)
|
||||
@@ -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,
|
||||
),
|
||||
)
|
||||
Reference in New Issue
Block a user