mirror of
https://github.com/bitwarden/android.git
synced 2026-04-29 20:38:41 -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
|
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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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