Mostly complete the user registration flow

This commit is contained in:
Z. Charles Dziura 2024-11-23 11:12:58 -05:00
parent 83d477f6bb
commit 836e24247e
11 changed files with 263 additions and 112 deletions

View file

@ -23,6 +23,7 @@ import androidx.navigation.compose.navigation
import androidx.navigation.compose.rememberNavController
import androidx.navigation.toRoute
import dagger.hilt.android.AndroidEntryPoint
import ing.bikeshedengineer.debtpirate.app.screen.auth.presentation.confirm.ConfirmationData
import ing.bikeshedengineer.debtpirate.app.screen.auth.presentation.confirm.ConfirmationScreen
import ing.bikeshedengineer.debtpirate.app.screen.auth.presentation.login.LoginScreen
import ing.bikeshedengineer.debtpirate.app.screen.auth.presentation.register.RegistrationScreen
@ -88,16 +89,20 @@ class MainActivity : ComponentActivity() {
}
) {
navigation<Destination.AuthGraph>(
startDestination = Destination.LoginScreen
startDestination = Destination.AuthLogin
) {
composable<Destination.LoginScreen> { LoginScreen() }
composable<Destination.RegistrationScreen> { RegistrationScreen() }
composable<Destination.RegistrationConfirmationCheckEmailScreen> {
val (emailAddress, _) = it.toRoute<Destination.RegistrationConfirmationCheckEmailScreen>()
composable<Destination.AuthLogin> { LoginScreen() }
composable<Destination.AuthRegistration> { RegistrationScreen() }
composable<Destination.AuthRegistrationConfirmation> {
val (emailAddress) = it.toRoute<Destination.AuthRegistrationConfirmation>()
ConfirmationScreen(
emailAddress
ConfirmationData.RegistrationConfirmationData(emailAddress)
)
}
composable<Destination.AuthUserConfirmation> {
val (userId, verificationToken) = it.toRoute<Destination.AuthUserConfirmation>()
ConfirmationScreen(ConfirmationData.UserConfirmationData(userId, verificationToken))
}
}
}
}
@ -127,11 +132,11 @@ class MainActivity : ComponentActivity() {
}
}
private fun handleNewUserVerificationIntent(userId: Int, sessionToken: String) {
Log.d("DebtPirate::MainActivity", "User ID: $userId, Session Token: $sessionToken")
private fun handleNewUserVerificationIntent(userId: Int, verificationToken: String) {
Log.d("DebtPirate::MainActivity", "User ID: $userId, Session Token: $verificationToken")
lifecycleScope.launch {
navigator.navigate(Destination.RegistrationConfirmationCheckEmailScreen(comingFromEmail = true))
navigator.navigate(Destination.AuthUserConfirmation(userId, verificationToken))
}
}
}

View file

@ -7,6 +7,7 @@ import dagger.hilt.android.components.ViewModelComponent
import dagger.hilt.android.scopes.ViewModelScoped
import ing.bikeshedengineer.debtpirate.app.screen.auth.usecase.SubmitAccountRegistrationRequestUseCase
import ing.bikeshedengineer.debtpirate.app.screen.auth.usecase.SubmitLoginCredentialsUseCase
import ing.bikeshedengineer.debtpirate.app.screen.auth.usecase.SubmitNewUserVerificationRequestUseCase
import ing.bikeshedengineer.debtpirate.app.screen.auth.usecase.ValidateLoginCredentialsUseCase
import ing.bikeshedengineer.debtpirate.app.screen.auth.usecase.ValidateNewAccountRegistrationUseCase
import ing.bikeshedengineer.debtpirate.data.repository.AuthRepository
@ -32,7 +33,11 @@ object AuthScreensDiModule {
@Provides
@ViewModelScoped
fun provideSubmitAccountRegistrationUseCase(userRepository: UserRepository): SubmitAccountRegistrationRequestUseCase {
return SubmitAccountRegistrationRequestUseCase(userRepository)
}
fun provideSubmitAccountRegistrationUseCase(userRepository: UserRepository): SubmitAccountRegistrationRequestUseCase =
SubmitAccountRegistrationRequestUseCase(userRepository)
@Provides
@ViewModelScoped
fun provideSubmitNewUserVerificationUseCase(userRepository: UserRepository): SubmitNewUserVerificationRequestUseCase =
SubmitNewUserVerificationRequestUseCase(userRepository)
}

View file

@ -1,89 +0,0 @@
package ing.bikeshedengineer.debtpirate.app.screen.auth.presentation.confirm
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.MarkEmailRead
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
@Composable()
fun ConfirmationScreen(emailAddress: String? = null, redirectedFromEmailConfirmation: Boolean = false) {
Scaffold(
modifier = Modifier.fillMaxSize()
) { padding ->
Column(modifier = Modifier.padding(padding).padding(PaddingValues(4.dp))) {
Box(
modifier = Modifier
.fillMaxWidth()
.weight(1f)
) {
Icon(
Icons.Outlined.MarkEmailRead,
null,
modifier = Modifier
.align(Alignment.Center)
.size(96.dp)
)
}
Column(
modifier = Modifier
.fillMaxWidth()
.weight(3f)
) {
Text(
"Confirmation Sent!",
style = MaterialTheme.typography.displaySmall,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
if (emailAddress != null) {
Text(
text = buildAnnotatedString {
append("We sent your confirmation email to ")
withStyle(
style = SpanStyle(
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Bold
)
) {
append(emailAddress)
}
},
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.padding(PaddingValues(top = 16.dp))
)
Text(
"Follow the instructions listed in that email to finish registering your new account.",
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.padding(PaddingValues(top = 8.dp))
)
}
}
}
}
}

View file

@ -0,0 +1,167 @@
package ing.bikeshedengineer.debtpirate.app.screen.auth.presentation.confirm
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.CheckCircle
import androidx.compose.material.icons.outlined.MarkEmailRead
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.State
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
sealed class ConfirmationData {
data class RegistrationConfirmationData(val emailAddress: String) : ConfirmationData()
data class UserConfirmationData(val userId: Int, val verificationToken: String) : ConfirmationData()
}
@Composable()
fun ConfirmationScreen(
data: ConfirmationData,
viewModel: ConfirmationScreenViewModel = hiltViewModel<ConfirmationScreenViewModel>()
) {
Scaffold(
modifier = Modifier.fillMaxSize()
) { padding ->
when (data) {
is ConfirmationData.RegistrationConfirmationData -> {
val (emailAddress) = data
RegistrationConfirmationScreen(emailAddress, modifier = Modifier.padding(padding))
}
is ConfirmationData.UserConfirmationData -> {
val (userId, verificationToken) = data
LaunchedEffect(userId, verificationToken) {
viewModel.submitNewUserVerificationRequest(userId, verificationToken)
}
val isLoading = viewModel.isLoading.collectAsState()
UserVerifiedScreen(isLoading, modifier = Modifier.padding(padding))
}
}
}
}
@Composable
private fun RegistrationConfirmationScreen(emailAddress: String, modifier: Modifier = Modifier) {
Column(modifier = modifier.padding(PaddingValues(4.dp))) {
Box(
modifier = Modifier
.fillMaxWidth()
.weight(1f)
) {
Icon(
Icons.Outlined.MarkEmailRead,
null,
modifier = Modifier
.align(Alignment.Center)
.size(96.dp)
)
}
Column(
modifier = Modifier
.fillMaxWidth()
.weight(3f)
) {
Text(
"Confirmation Sent!",
style = MaterialTheme.typography.displaySmall,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
Text(
text = buildAnnotatedString {
append("We sent your confirmation email to ")
withStyle(
style = SpanStyle(
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Bold
)
) {
append(emailAddress)
}
},
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.padding(PaddingValues(top = 16.dp))
)
Text(
"Follow the instructions listed in that email to finish registering your new account.",
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.padding(PaddingValues(top = 8.dp))
)
}
}
}
@Composable
private fun UserVerifiedScreen(isLoading: State<Boolean>, modifier: Modifier = Modifier) {
Column(modifier = modifier.padding(PaddingValues(4.dp))) {
Box(
modifier = Modifier
.fillMaxWidth()
.weight(1f)
) {
if (isLoading.value) {
CircularProgressIndicator(
color = MaterialTheme.colorScheme.secondary,
trackColor = MaterialTheme.colorScheme.surfaceVariant,
modifier = Modifier
.align(Alignment.Center)
.size(96.dp)
)
} else {
Icon(
Icons.Outlined.CheckCircle,
null,
modifier = Modifier
.align(Alignment.Center)
.size(96.dp)
)
}
}
Column(
modifier = Modifier
.fillMaxWidth()
.weight(3f)
) {
if (!isLoading.value) {
Text(
"You're all set!",
style = MaterialTheme.typography.displaySmall,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
}
}
}
}

View file

@ -0,0 +1,36 @@
package ing.bikeshedengineer.debtpirate.app.screen.auth.presentation.confirm
import android.util.Log
import androidx.datastore.core.DataStore
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import ing.bikeshedengineer.debtpirate.PrefsDataStore
import ing.bikeshedengineer.debtpirate.app.screen.auth.usecase.SubmitNewUserVerificationRequestUseCase
import ing.bikeshedengineer.debtpirate.navigation.Navigator
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class ConfirmationScreenViewModel @Inject constructor(
private val navigator: Navigator,
private val prefsStore: DataStore<PrefsDataStore>,
private val verifyNewUser: SubmitNewUserVerificationRequestUseCase
) : ViewModel() {
private var _isLoading = MutableStateFlow(true)
val isLoading = _isLoading.asStateFlow()
fun submitNewUserVerificationRequest(userId: Int, verificationToken: String) {
viewModelScope.launch {
try {
val response = verifyNewUser(userId, verificationToken)
Log.d("ConfirmationScreenViewModel", "$response")
_isLoading.value = false
} catch (err: Throwable) {
// TODO...
}
}
}
}

View file

@ -114,7 +114,7 @@ class LoginScreenViewModel @Inject constructor(
fun onRegisterButtonClick() {
viewModelScope.launch {
navigator.navigate(destination = Destination.RegistrationScreen)
navigator.navigate(destination = Destination.AuthRegistration)
}
}
}

View file

@ -30,8 +30,8 @@ class RegistrationScreenViewModel @Inject constructor(
fun navigateToConfirmationScreen(emailAddress: String) {
viewModelScope.launch {
navigator.navigate(Destination.RegistrationConfirmationCheckEmailScreen(emailAddress)) {
popUpTo(Destination.LoginScreen) {
navigator.navigate(Destination.AuthRegistrationConfirmation(emailAddress)) {
popUpTo(Destination.AuthLogin) {
inclusive = true
}
}

View file

@ -19,7 +19,7 @@ class SubmitAccountRegistrationRequestUseCase(private val userRepository: UserRe
return response
} catch (err: Throwable) {
// TODO...
Log.e("RegistrationScreen", "$err")
Log.e("SubmitAccountRegistrationRequestUseCase", "$err")
throw err
}
}

View file

@ -0,0 +1,22 @@
package ing.bikeshedengineer.debtpirate.app.screen.auth.usecase
import android.util.Log
import ing.bikeshedengineer.debtpirate.data.remote.model.user.UserVerificationGetRequest
import ing.bikeshedengineer.debtpirate.data.remote.model.user.UserVerificationGetResponse
import ing.bikeshedengineer.debtpirate.data.repository.UserRepository
class SubmitNewUserVerificationRequestUseCase(private val userRepository: UserRepository) {
suspend operator fun invoke(userId: Int, verificationToken: String): UserVerificationGetResponse {
val request = UserVerificationGetRequest(userId, verificationToken)
try {
val response = userRepository.verifyUserGetRequest(request)
Log.d("SubmitAccountRegistrationRequestUseCase", "User verification successful! $response")
return response
} catch (err: Throwable) {
// TODO...
Log.e("SubmitNewUserVerificationRequestUseCase", "$err")
throw err
}
}
}

View file

@ -6,10 +6,10 @@ import ing.bikeshedengineer.debtpirate.data.remote.model.user.UserCreatePostResp
import ing.bikeshedengineer.debtpirate.data.remote.model.user.UserVerificationGetResponse
import retrofit2.Response
import retrofit2.http.Body
import retrofit2.http.Field
import retrofit2.http.GET
import retrofit2.http.POST
import retrofit2.http.Path
import retrofit2.http.Query
interface UserEndpoint {
@POST("user")
@ -18,6 +18,6 @@ interface UserEndpoint {
@GET("user/{userId}/verify")
suspend fun userVerificationGetRequest(
@Path("userId") userId: Int,
@Field("t") verificationToken: String
@Query("t") verificationToken: String
): Response<ApiResponse<UserVerificationGetResponse>>
}

View file

@ -9,14 +9,19 @@ sealed interface Destination {
data object AuthGraph : Destination
@Serializable
data object LoginScreen : Destination
data object AuthLogin : Destination
@Serializable
data object RegistrationScreen : Destination
data object AuthRegistration : Destination
@Serializable
data class RegistrationConfirmationCheckEmailScreen(
val emailAddress: String? = null,
val comingFromEmail: Boolean = false
data class AuthRegistrationConfirmation(
val emailAddress: String
) : Destination
@Serializable
data class AuthUserConfirmation(
val userId: Int,
val verificationToken: String
) : Destination
}