[PM-6702] 5# Check your email screen (#3621)

This commit is contained in:
André Bispo 2024-08-15 18:25:45 +01:00 committed by GitHub
parent eab94dde79
commit 244d259804
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 626 additions and 6 deletions

View file

@ -6,6 +6,8 @@ import androidx.navigation.NavHostController
import androidx.navigation.NavOptions
import androidx.navigation.navOptions
import androidx.navigation.navigation
import com.x8bit.bitwarden.ui.auth.feature.checkemail.checkEmailDestination
import com.x8bit.bitwarden.ui.auth.feature.checkemail.navigateToCheckEmail
import com.x8bit.bitwarden.ui.auth.feature.createaccount.createAccountDestination
import com.x8bit.bitwarden.ui.auth.feature.createaccount.navigateToCreateAccount
import com.x8bit.bitwarden.ui.auth.feature.enterprisesignon.enterpriseSignOnDestination
@ -66,10 +68,15 @@ fun NavGraphBuilder.authGraph(
// TODO PR-3622 ADD NAVIGATION TO COMPLETE REGISTRATION
},
onNavigateToCheckEmail = { emailAddress ->
// TODO PR-3621 ADD NAVIGATION TO CHECK EMAIL
navController.navigateToCheckEmail(emailAddress)
},
onNavigateToEnvironment = { navController.navigateToEnvironment() },
)
checkEmailDestination(
onNavigateBack = { navController.popBackStack() },
onNavigateBackToLanding = {
navController.popBackStack(route = LANDING_ROUTE, inclusive = false)
},)
enterpriseSignOnDestination(
onNavigateBack = { navController.popBackStack() },
onNavigateToSetPassword = { navController.navigateToSetPassword() },

View file

@ -0,0 +1,52 @@
package com.x8bit.bitwarden.ui.auth.feature.checkemail
import androidx.lifecycle.SavedStateHandle
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavOptions
import androidx.navigation.NavType
import androidx.navigation.navArgument
import com.x8bit.bitwarden.data.platform.annotation.OmitFromCoverage
import com.x8bit.bitwarden.ui.platform.base.util.composableWithSlideTransitions
private const val EMAIL: String = "email"
private const val CHECK_EMAIL_ROUTE: String = "check_email/{$EMAIL}"
/**
* Navigate to the check email screen.
*/
fun NavController.navigateToCheckEmail(emailAddress: String, navOptions: NavOptions? = null) {
this.navigate("check_email/$emailAddress", navOptions)
}
/**
* Class to retrieve check email arguments from the [SavedStateHandle].
*/
@OmitFromCoverage
data class CheckEmailArgs(
val emailAddress: String,
) {
constructor(savedStateHandle: SavedStateHandle) : this(
emailAddress = checkNotNull(savedStateHandle.get<String>(EMAIL)),
)
}
/**
* Add the check email screen to the nav graph.
*/
fun NavGraphBuilder.checkEmailDestination(
onNavigateBack: () -> Unit,
onNavigateBackToLanding: () -> Unit,
) {
composableWithSlideTransitions(
route = CHECK_EMAIL_ROUTE,
arguments = listOf(
navArgument(EMAIL) { type = NavType.StringType },
),
) {
CheckEmailScreen(
onNavigateBack = onNavigateBack,
onNavigateBackToLanding = onNavigateBackToLanding,
)
}
}

View file

@ -0,0 +1,206 @@
package com.x8bit.bitwarden.ui.auth.feature.checkemail
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Column
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.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.ClickableText
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect
import com.x8bit.bitwarden.ui.platform.base.util.createAnnotatedString
import com.x8bit.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar
import com.x8bit.bitwarden.ui.platform.components.button.BitwardenFilledButton
import com.x8bit.bitwarden.ui.platform.components.scaffold.BitwardenScaffold
import com.x8bit.bitwarden.ui.platform.components.util.rememberVectorPainter
import com.x8bit.bitwarden.ui.platform.composition.LocalIntentManager
import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager
/**
* Constant string to be used in string annotation tag field
*/
private const val TAG_URL = "URL"
/**
* Top level composable for the check email screen.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Suppress("LongMethod")
@Composable
fun CheckEmailScreen(
onNavigateBack: () -> Unit,
onNavigateBackToLanding: () -> Unit,
intentManager: IntentManager = LocalIntentManager.current,
viewModel: CheckEmailViewModel = hiltViewModel(),
) {
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
EventsEffect(viewModel) { event ->
when (event) {
is CheckEmailEvent.NavigateBack -> {
onNavigateBack.invoke()
}
is CheckEmailEvent.NavigateToEmailApp -> {
intentManager.startDefaultEmailApplication()
}
is CheckEmailEvent.NavigateBackToLanding -> {
onNavigateBackToLanding.invoke()
}
}
}
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
BitwardenScaffold(
modifier = Modifier
.fillMaxSize()
.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
BitwardenTopAppBar(
title = stringResource(id = R.string.create_account),
scrollBehavior = scrollBehavior,
navigationIcon = rememberVectorPainter(id = R.drawable.ic_close),
navigationIconContentDescription = stringResource(id = R.string.close),
onNavigationIconClick = remember(viewModel) {
{ viewModel.trySendAction(CheckEmailAction.CloseClick) }
},
)
},
) { innerPadding ->
Column(
modifier = Modifier
.padding(innerPadding)
.imePadding()
.fillMaxSize()
.verticalScroll(rememberScrollState()),
) {
Spacer(modifier = Modifier.height(32.dp))
Image(
painter = rememberVectorPainter(id = R.drawable.email_check),
colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.primary),
contentDescription = null,
contentScale = ContentScale.FillHeight,
modifier = Modifier
.padding(horizontal = 16.dp)
.height(112.dp)
.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(32.dp))
Text(
text = stringResource(id = R.string.check_your_email),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier
.padding(horizontal = 24.dp)
.wrapContentHeight()
.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(16.dp))
@Suppress("MaxLineLength")
val descriptionAnnotatedString = createAnnotatedString(
mainString = stringResource(
id = R.string.follow_the_instructions_in_the_email_sent_to_x_to_continue_creating_your_account,
state.email,
),
highlights = listOf(state.email),
highlightStyle = SpanStyle(
color = MaterialTheme.colorScheme.onSurface,
fontSize = MaterialTheme.typography.bodyMedium.fontSize,
fontWeight = FontWeight.Bold,
),
tag = "EMAIL",
)
Text(
text = descriptionAnnotatedString,
textAlign = TextAlign.Center,
modifier = Modifier
.padding(horizontal = 24.dp)
.fillMaxWidth()
.wrapContentHeight(),
)
Spacer(modifier = Modifier.height(32.dp))
BitwardenFilledButton(
label = stringResource(id = R.string.open_email_app),
onClick = remember(viewModel) {
{ viewModel.trySendAction(CheckEmailAction.OpenEmailClick) }
},
modifier = Modifier
.testTag("OpenEmailApp")
.padding(horizontal = 16.dp)
.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(32.dp))
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
val goBackAnnotatedString = createAnnotatedString(
mainString = stringResource(
id = R.string.no_email_go_back_to_edit_your_email_address,
),
highlights = listOf(stringResource(id = R.string.go_back)),
tag = TAG_URL,
)
ClickableText(
text = goBackAnnotatedString,
onClick = {
goBackAnnotatedString
.getStringAnnotations(TAG_URL, it, it)
.firstOrNull()?.let {
viewModel.trySendAction(CheckEmailAction.CloseClick)
}
},
)
Spacer(modifier = Modifier.height(32.dp))
val logInAnnotatedString = createAnnotatedString(
mainString = stringResource(
id = R.string.or_log_in_you_may_already_have_an_account,
),
highlights = listOf(stringResource(id = R.string.log_in)),
tag = TAG_URL,
)
ClickableText(
text = logInAnnotatedString,
onClick = {
logInAnnotatedString
.getStringAnnotations(TAG_URL, it, it)
.firstOrNull()?.let {
viewModel.trySendAction(CheckEmailAction.LoginClick)
}
},
)
}
Spacer(modifier = Modifier.navigationBarsPadding())
}
}
}

View file

@ -0,0 +1,90 @@
package com.x8bit.bitwarden.ui.auth.feature.checkemail
import android.os.Parcelable
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.parcelize.Parcelize
import javax.inject.Inject
private const val KEY_STATE = "state"
/**
* Models logic for the check email screen.
*/
@HiltViewModel
class CheckEmailViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
) : BaseViewModel<CheckEmailState, CheckEmailEvent, CheckEmailAction>(
initialState = savedStateHandle[KEY_STATE]
?: CheckEmailState(
email = CheckEmailArgs(savedStateHandle).emailAddress,
),
) {
init {
// As state updates, write to saved state handle:
stateFlow
.onEach { savedStateHandle[KEY_STATE] = it }
.launchIn(viewModelScope)
}
override fun handleAction(action: CheckEmailAction) {
when (action) {
CheckEmailAction.CloseClick -> sendEvent(CheckEmailEvent.NavigateBack)
CheckEmailAction.LoginClick -> sendEvent(CheckEmailEvent.NavigateBackToLanding)
CheckEmailAction.OpenEmailClick -> sendEvent(CheckEmailEvent.NavigateToEmailApp)
}
}
}
/**
* UI state for the check email screen.
*/
@Parcelize
data class CheckEmailState(
val email: String,
) : Parcelable
/**
* Models events for the check email screen.
*/
sealed class CheckEmailEvent {
/**
* Navigate back to previous screen.
*/
data object NavigateBack : CheckEmailEvent()
/**
* Navigate to email app.
*/
data object NavigateToEmailApp : CheckEmailEvent()
/**
* Navigate to landing screen.
*/
data object NavigateBackToLanding : CheckEmailEvent()
}
/**
* Models actions for the check email screen.
*/
sealed class CheckEmailAction {
/**
* User clicked close.
*/
data object CloseClick : CheckEmailAction()
/**
* User clicked log in.
*/
data object LoginClick : CheckEmailAction()
/**
* User clicked open email.
*/
data object OpenEmailClick : CheckEmailAction()
}

View file

@ -133,6 +133,11 @@ fun @receiver:StringRes Int.asText(vararg args: Any): Text = ResArgsText(this, a
fun createAnnotatedString(
mainString: String,
highlights: List<String>,
highlightStyle: SpanStyle = SpanStyle(
color = MaterialTheme.colorScheme.primary,
fontSize = MaterialTheme.typography.bodyMedium.fontSize,
fontWeight = FontWeight.Bold,
),
tag: String,
): AnnotatedString {
return buildAnnotatedString {
@ -149,11 +154,7 @@ fun createAnnotatedString(
val startIndexUnsubscribe = mainString.indexOf(highlightString, ignoreCase = true)
val endIndexUnsubscribe = startIndexUnsubscribe + highlightString.length
addStyle(
style = SpanStyle(
color = MaterialTheme.colorScheme.primary,
fontSize = MaterialTheme.typography.bodyMedium.fontSize,
fontWeight = FontWeight.Bold,
),
style = highlightStyle,
start = startIndexUnsubscribe,
end = endIndexUnsubscribe,
)

View file

@ -117,6 +117,11 @@ interface IntentManager {
requestCode: Int,
): PendingIntent
/**
* Open the default email app on device.
*/
fun startDefaultEmailApplication()
/**
* Represents file information.
*/

View file

@ -265,6 +265,13 @@ class IntentManagerImpl(
)
}
override fun startDefaultEmailApplication() {
val intent = Intent(Intent.ACTION_MAIN)
intent.addCategory(Intent.CATEGORY_APP_EMAIL)
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
startActivity(intent)
}
private fun getCameraFileData(): IntentManager.FileData {
val tmpDir = File(context.filesDir, TEMP_CAMERA_IMAGE_DIR)
val file = File(tmpDir, TEMP_CAMERA_IMAGE_NAME)

View file

@ -0,0 +1,84 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="413dp"
android:height="114dp"
android:viewportWidth="413"
android:viewportHeight="114">
<group>
<clip-path
android:pathData="M134.84,0.57h143.82v112.71h-143.82z"/>
<path
android:pathData="M260.14,44.62V59.04M192.86,14.74L201.04,8.64C204.02,6.42 208.12,6.45 211.07,8.73L212.84,10.09M164.61,35.82L156.15,42.14C154.05,43.7 152.82,46.17 152.82,48.79V100.48C152.82,105.07 156.53,108.78 161.11,108.78H251.85C256.43,108.78 260.14,105.07 260.14,100.48V71.05"
android:strokeWidth="2.76577"
android:fillColor="#00000000"
android:strokeColor="#175DDC"
android:strokeLineCap="round"/>
<path
android:pathData="M165.38,54.52V17.83C165.38,16.3 166.61,15.07 168.14,15.07H206.8"
android:strokeWidth="2.76577"
android:fillColor="#00000000"
android:strokeColor="#175DDC"
android:strokeLineCap="round"/>
<path
android:pathData="M175.32,27.21H194.11"
android:strokeWidth="2.76577"
android:fillColor="#00000000"
android:strokeColor="#175DDC"
android:strokeLineCap="round"/>
<path
android:pathData="M175.32,38.39H193.56"
android:strokeWidth="2.76577"
android:fillColor="#00000000"
android:strokeColor="#175DDC"
android:strokeLineCap="round"/>
<path
android:pathData="M175.32,49.57H196.57"
android:strokeWidth="2.76577"
android:fillColor="#00000000"
android:strokeColor="#175DDC"
android:strokeLineCap="round"/>
<path
android:pathData="M175.32,60.75H203.13"
android:strokeWidth="2.76577"
android:fillColor="#00000000"
android:strokeColor="#175DDC"
android:strokeLineCap="round"/>
<path
android:pathData="M257.86,106.5L223.49,74.77C220.93,72.41 217.59,71.1 214.11,71.1H197.24C193.65,71.1 190.2,72.5 187.62,75L155.1,106.5"
android:strokeWidth="2.76577"
android:fillColor="#00000000"
android:strokeColor="#175DDC"/>
<path
android:pathData="M220.36,71.58L231.04,65.46"
android:strokeWidth="2.76577"
android:fillColor="#00000000"
android:strokeColor="#175DDC"
android:strokeLineCap="round"/>
<path
android:pathData="M153.86,48.45L192.59,71.58"
android:strokeWidth="2.77"
android:fillColor="#00000000"
android:strokeColor="#175DDC"
android:strokeLineCap="round"/>
<path
android:pathData="M260.35,35.09C260.35,51.78 246.82,65.3 230.13,65.3C213.45,65.3 199.92,51.78 199.92,35.09C199.92,18.4 213.45,4.88 230.13,4.88C246.82,4.88 260.35,18.4 260.35,35.09Z"
android:strokeLineJoin="round"
android:strokeWidth="2.76577"
android:fillColor="#00000000"
android:strokeColor="#175DDC"
android:strokeLineCap="round"/>
<path
android:pathData="M256.3,35.09C256.3,49.44 244.53,61.07 230.02,61.07M230.02,9.11C215.5,9.11 203.73,20.74 203.73,35.09"
android:strokeLineJoin="round"
android:strokeWidth="1.38289"
android:fillColor="#00000000"
android:strokeColor="#175DDC"
android:strokeLineCap="round"/>
<path
android:pathData="M254.25,53.55L258.87,58.17L276.21,75.51C277.56,76.86 277.56,79.05 276.21,80.4L275.49,81.12C274.14,82.47 271.95,82.47 270.6,81.12L253.26,63.78L248.64,59.16"
android:strokeLineJoin="round"
android:strokeWidth="2.76577"
android:fillColor="#00000000"
android:strokeColor="#175DDC"
android:strokeLineCap="round"/>
</group>
</vector>

View file

@ -0,0 +1,88 @@
package com.x8bit.bitwarden.ui.auth.feature.checkemail
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow
import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest
import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.runs
import io.mockk.verify
import junit.framework.TestCase
import kotlinx.coroutines.flow.MutableStateFlow
import org.junit.Before
import org.junit.Test
class CheckEmailScreenTest : BaseComposeTest() {
private val intentManager = mockk<IntentManager>(relaxed = true) {
every { startDefaultEmailApplication() } just runs
}
private var onNavigateBackCalled = false
private var onNavigateBackToLandingCalled = false
private var onNavigateToEmailAppCalled = false
private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE)
private val mutableEventFlow = bufferedMutableSharedFlow<CheckEmailEvent>()
private val viewModel = mockk<CheckEmailViewModel>(relaxed = true) {
every { stateFlow } returns mutableStateFlow
every { eventFlow } returns mutableEventFlow
}
@Before
fun setUp() {
composeTestRule.setContent {
CheckEmailScreen(
onNavigateBack = { onNavigateBackCalled = true },
onNavigateBackToLanding = { onNavigateBackToLandingCalled = true },
viewModel = viewModel,
intentManager = intentManager,
)
}
}
@Test
fun `close button click should send CloseTap action`() {
composeTestRule.onNodeWithContentDescription("Close").performClick()
verify {
viewModel.trySendAction(CheckEmailAction.CloseClick)
}
}
@Test
fun `open email app button click should send OpenEmailTap action`() {
composeTestRule.onNodeWithText("Open email app").performClick()
verify {
viewModel.trySendAction(CheckEmailAction.OpenEmailClick)
}
}
@Test
fun `login button click should send LoginTap action`() {
mutableEventFlow.tryEmit(CheckEmailEvent.NavigateBackToLanding)
TestCase.assertTrue(onNavigateBackToLandingCalled)
}
@Test
fun `NavigateBack should call onNavigateBack`() {
mutableEventFlow.tryEmit(CheckEmailEvent.NavigateBack)
TestCase.assertTrue(onNavigateBackCalled)
}
@Test
fun `NavigateToEmailApp should call openEmailApp`() {
mutableEventFlow.tryEmit(CheckEmailEvent.NavigateToEmailApp)
verify {
intentManager.startDefaultEmailApplication()
}
}
companion object {
private const val EMAIL = "test@gmail.com"
private val DEFAULT_STATE = CheckEmailState(
email = EMAIL,
)
}
}

View file

@ -0,0 +1,80 @@
package com.x8bit.bitwarden.ui.auth.feature.checkemail
import androidx.lifecycle.SavedStateHandle
import app.cash.turbine.test
import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
class CheckEmailViewModelTest : BaseViewModelTest() {
@Test
fun `initial state should be correct`() = runTest {
val viewModel = createViewModel()
viewModel.stateFlow.test {
assertEquals(DEFAULT_STATE, awaitItem())
}
}
@Test
fun `initial state should pull from handle when present`() = runTest {
val expectedState = DEFAULT_STATE.copy(
email = "another@email.com",
)
val viewModel = createViewModel(expectedState)
viewModel.stateFlow.test {
assertEquals(expectedState, awaitItem())
}
}
@Test
fun `CloseTap should emit NavigateBack`() = runTest {
val viewModel = createViewModel()
viewModel.eventFlow.test {
viewModel.trySendAction(CheckEmailAction.CloseClick)
assertEquals(
CheckEmailEvent.NavigateBack,
awaitItem(),
)
}
}
@Test
fun `LoginTap should emit NavigateBackToLanding`() = runTest {
val viewModel = createViewModel()
viewModel.eventFlow.test {
viewModel.trySendAction(CheckEmailAction.LoginClick)
assertEquals(
CheckEmailEvent.NavigateBackToLanding,
awaitItem(),
)
}
}
@Test
fun `OpenEmailTap should emit NavigateToEmailApp`() = runTest {
val viewModel = createViewModel()
viewModel.eventFlow.test {
viewModel.trySendAction(CheckEmailAction.OpenEmailClick)
assertEquals(
CheckEmailEvent.NavigateToEmailApp,
awaitItem(),
)
}
}
private fun createViewModel(state: CheckEmailState? = null): CheckEmailViewModel =
CheckEmailViewModel(
savedStateHandle = SavedStateHandle().also {
it["email"] = EMAIL
it["state"] = state
},
)
companion object {
private const val EMAIL = "test@gmail.com"
private val DEFAULT_STATE = CheckEmailState(
email = EMAIL,
)
}
}