diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/auth/AuthNavigation.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/auth/AuthNavigation.kt index 700e39e7b3..7290519c03 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/auth/AuthNavigation.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/auth/AuthNavigation.kt @@ -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() }, diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/checkemail/CheckEmailNavigation.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/checkemail/CheckEmailNavigation.kt new file mode 100644 index 0000000000..ee8a47ec30 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/checkemail/CheckEmailNavigation.kt @@ -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(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, + ) + } +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/checkemail/CheckEmailScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/checkemail/CheckEmailScreen.kt new file mode 100644 index 0000000000..ff68a25686 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/checkemail/CheckEmailScreen.kt @@ -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()) + } + } +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/checkemail/CheckEmailViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/checkemail/CheckEmailViewModel.kt new file mode 100644 index 0000000000..de7aa21176 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/checkemail/CheckEmailViewModel.kt @@ -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( + 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() +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/base/util/Text.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/base/util/Text.kt index d8f98ed14a..7d6089c8f3 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/base/util/Text.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/base/util/Text.kt @@ -133,6 +133,11 @@ fun @receiver:StringRes Int.asText(vararg args: Any): Text = ResArgsText(this, a fun createAnnotatedString( mainString: String, highlights: List, + 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, ) diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/manager/intent/IntentManager.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/manager/intent/IntentManager.kt index 821f462029..e296b33742 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/manager/intent/IntentManager.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/manager/intent/IntentManager.kt @@ -117,6 +117,11 @@ interface IntentManager { requestCode: Int, ): PendingIntent + /** + * Open the default email app on device. + */ + fun startDefaultEmailApplication() + /** * Represents file information. */ diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/manager/intent/IntentManagerImpl.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/manager/intent/IntentManagerImpl.kt index 42482bf05e..f64bf972a9 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/manager/intent/IntentManagerImpl.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/manager/intent/IntentManagerImpl.kt @@ -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) diff --git a/app/src/main/res/drawable/email_check.xml b/app/src/main/res/drawable/email_check.xml new file mode 100644 index 0000000000..99552cc433 --- /dev/null +++ b/app/src/main/res/drawable/email_check.xml @@ -0,0 +1,84 @@ + + + + + + + + + + + + + + + + + diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/checkemail/CheckEmailScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/checkemail/CheckEmailScreenTest.kt new file mode 100644 index 0000000000..e3453286b9 --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/checkemail/CheckEmailScreenTest.kt @@ -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(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() + private val viewModel = mockk(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, + ) + } +} diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/checkemail/CheckEmailViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/checkemail/CheckEmailViewModelTest.kt new file mode 100644 index 0000000000..6707cde343 --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/checkemail/CheckEmailViewModelTest.kt @@ -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, + ) + } +}