Store user credentials after registration

This commit is contained in:
Z. Charles Dziura 2024-11-14 14:32:51 -05:00
parent da1cebe02d
commit a343f2e2a0
12 changed files with 209 additions and 87 deletions

View file

@ -62,7 +62,7 @@ android {
)
}
debug {
applicationIdSuffix = ".d"
applicationIdSuffix = ".dev"
buildConfigField("String", "API_BASE_URL", "\"http://10.0.2.2:42069\"")
}
}
@ -110,6 +110,8 @@ dependencies {
kapt(libs.hilt.kapt)
implementation(libs.hilt.compose)
implementation(libs.google.fonts)
implementation(libs.androidx.credentials.core)
implementation(libs.androidx.credentials.compat)
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)

View file

@ -2,6 +2,7 @@ package ing.bikeshedengineer.debtpirate.app.host
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.SystemBarStyle
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.animation.AnimatedContentTransitionScope

View file

@ -1,6 +1,7 @@
package ing.bikeshedengineer.debtpirate.app.screen.auth.presentation.login
import android.annotation.SuppressLint
import androidx.activity.ComponentActivity
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@ -28,10 +29,10 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization
@ -40,13 +41,18 @@ import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import ing.bikeshedengineer.debtpirate.R
import ing.bikeshedengineer.debtpirate.domain.repository.AccountManager
@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter")
@Composable
fun LoginScreen(
viewModel: LoginScreenViewModel = hiltViewModel<LoginScreenViewModel>()
) {
val context = LocalContext.current as ComponentActivity
val accountManager = remember {
AccountManager(context)
}
Scaffold(
modifier = Modifier.fillMaxSize()
) {
@ -67,13 +73,32 @@ fun LoginScreen(
LoginComponent(
emailAddress = emailAddress,
onUpdateEmailAddress = viewModel::updateEmailAddress,
onUpdateEmailAddress = {
viewModel.updateState(
LoginScreenStateAction.UpdateEmailAddress(
it
)
)
},
password = password,
onUpdatePassword = viewModel::updatePassword,
onUpdatePassword = {
viewModel.updateState(
LoginScreenStateAction.UpdatePassword(
it
)
)
},
submitLoginRequest = viewModel::submitLoginRequest
)
Separator(modifier = Modifier.padding(PaddingValues(top = 24.dp, bottom = 24.dp)))
Separator(
modifier = Modifier.padding(
PaddingValues(
top = 24.dp,
bottom = 24.dp
)
)
)
RegisterButton(viewModel = viewModel)
}

View file

@ -0,0 +1,6 @@
package ing.bikeshedengineer.debtpirate.app.screen.auth.presentation.login
sealed interface LoginScreenStateAction {
data class UpdateEmailAddress(val emailAddress: String) : LoginScreenStateAction
data class UpdatePassword(val password: String) : LoginScreenStateAction
}

View file

@ -49,12 +49,16 @@ class LoginScreenViewModel @Inject constructor(
private val _password = MutableStateFlow("")
val password = _password.asStateFlow()
fun updateEmailAddress(emailAddress: String) {
_emailAddress.value = emailAddress
}
fun updateState(action: LoginScreenStateAction) {
when (action) {
is LoginScreenStateAction.UpdateEmailAddress -> {
_emailAddress.value = action.emailAddress
}
fun updatePassword(password: String) {
_password.value = password
is LoginScreenStateAction.UpdatePassword -> {
_password.value = action.password
}
}
}
fun submitLoginRequest() {

View file

@ -1,6 +1,8 @@
package ing.bikeshedengineer.debtpirate.app.screen.auth.presentation.register
import android.annotation.SuppressLint
import android.util.Log
import androidx.activity.ComponentActivity
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
@ -26,12 +28,15 @@ import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.State
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
@ -45,14 +50,41 @@ import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import ing.bikeshedengineer.debtpirate.R
import ing.bikeshedengineer.debtpirate.domain.model.AccountManagerResult
import ing.bikeshedengineer.debtpirate.domain.repository.AccountManager
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter")
@Composable()
fun RegistrationScreen(viewModel: RegistrationScreenViewModel = hiltViewModel<RegistrationScreenViewModel>()) {
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
fun RegistrationScreen(
viewModel: RegistrationScreenViewModel = hiltViewModel<RegistrationScreenViewModel>()
) {
val context = LocalContext.current as ComponentActivity
val accountManager = remember { AccountManager(context) }
val coroutineScope = rememberCoroutineScope()
LaunchedEffect(true) {
coroutineScope.launch {
viewModel.onRegistrationComplete.collect { credentials ->
val (emailAddress, password) = credentials
val result = accountManager.storeCredentials(emailAddress, password)
when (result) {
is AccountManagerResult.Unavailable -> {
viewModel.navigateUp()
}
else -> {
}
}
Log.d("RegistrationScreen", "$result")
}
}
}
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
Scaffold(
topBar = { RegistrationTopAppBar(onNavigateUp = viewModel::navigateUp) },
modifier = Modifier
@ -64,40 +96,8 @@ fun RegistrationScreen(viewModel: RegistrationScreenViewModel = hiltViewModel<Re
.padding(innerPadding)
.padding(16.dp)
) {
val emailAddress = viewModel.emailAddress.collectAsState()
val emailAddressError = viewModel.emailAddressError.collectAsState()
val isInvalidEmailAddress = viewModel.isInvalidEmailAddress.collectAsState(false)
val name = viewModel.name.collectAsState()
val nameError = viewModel.nameError.collectAsState()
val isInvalidName = viewModel.isInvalidName.collectAsState(false)
val password = viewModel.password.collectAsState()
val passwordError = viewModel.passwordError.collectAsState()
val isInvalidPassword = viewModel.isInvalidPassword.collectAsState(false)
val confirmPassword = viewModel.confirmPassword.collectAsState()
val confirmPasswordError = viewModel.confirmPasswordError.collectAsState()
val isInvalidConfirmPassword = viewModel.isInvalidConfirmPassword.collectAsState(false)
RegistrationComponent(
emailAddress = emailAddress,
emailAddressError = emailAddressError,
isInvalidEmailAddress = isInvalidEmailAddress,
updateEmailAddress = viewModel::updateEmailAddress,
name = name,
nameError = nameError,
isInvalidName = isInvalidName,
updateName = viewModel::updateName,
password = password,
passwordError = passwordError,
isInvalidPassword = isInvalidPassword,
updatePassword = viewModel::updatePassword,
confirmPassword = confirmPassword,
confirmPasswordError = confirmPasswordError,
isInvalidConfirmPassword = isInvalidConfirmPassword,
updateConfirmPassword = viewModel::updateConfirmPassword,
registerNewAccount = viewModel::registerNewAccount
viewModel,
)
}
}
@ -134,25 +134,25 @@ private fun RegistrationTopAppBar(onNavigateUp: () -> Unit, modifier: Modifier =
@Composable
private fun RegistrationComponent(
emailAddress: State<String>,
emailAddressError: State<String>,
isInvalidEmailAddress: State<Boolean>,
updateEmailAddress: (String) -> Unit,
name: State<String>,
nameError: State<String>,
isInvalidName: State<Boolean>,
updateName: (String) -> Unit,
password: State<String>,
passwordError: State<String>,
isInvalidPassword: State<Boolean>,
updatePassword: (String) -> Unit,
confirmPassword: State<String>,
confirmPasswordError: State<String>,
isInvalidConfirmPassword: State<Boolean>,
updateConfirmPassword: (String) -> Unit,
registerNewAccount: (String, String, String, String) -> Unit,
viewModel: RegistrationScreenViewModel,
modifier: Modifier = Modifier
) {
val emailAddress = viewModel.emailAddress.collectAsState()
val emailAddressError = viewModel.emailAddressError.collectAsState()
val isInvalidEmailAddress = viewModel.isInvalidEmailAddress.collectAsState(false)
val name = viewModel.name.collectAsState()
val nameError = viewModel.nameError.collectAsState()
val isInvalidName = viewModel.isInvalidName.collectAsState(false)
val password = viewModel.password.collectAsState()
val passwordError = viewModel.passwordError.collectAsState()
val isInvalidPassword = viewModel.isInvalidPassword.collectAsState(false)
val confirmPassword = viewModel.confirmPassword.collectAsState()
val confirmPasswordError = viewModel.confirmPasswordError.collectAsState()
val isInvalidConfirmPassword = viewModel.isInvalidConfirmPassword.collectAsState(false)
Column(modifier = modifier.verticalScroll(rememberScrollState())) {
OutlinedTextField(
value = emailAddress.value,
@ -167,7 +167,7 @@ private fun RegistrationComponent(
keyboardType = KeyboardType.Email,
imeAction = ImeAction.Next
),
onValueChange = updateEmailAddress,
onValueChange = { viewModel.onAction(RegistrationScreenAction.UpdateEmailAddress(it)) },
modifier = Modifier.fillMaxWidth()
)
@ -184,7 +184,7 @@ private fun RegistrationComponent(
keyboardType = KeyboardType.Text,
imeAction = ImeAction.Next
),
onValueChange = updateName,
onValueChange = { viewModel.onAction(RegistrationScreenAction.UpdateName(it)) },
modifier = Modifier
.fillMaxWidth()
.padding(PaddingValues(top = 4.dp))
@ -209,7 +209,7 @@ private fun RegistrationComponent(
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Next
),
onValueChange = updatePassword,
onValueChange = { viewModel.onAction(RegistrationScreenAction.UpdatePassword(it)) },
modifier = Modifier
.fillMaxWidth()
.padding(PaddingValues(top = 4.dp))
@ -234,7 +234,7 @@ private fun RegistrationComponent(
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Done
),
onValueChange = updateConfirmPassword,
onValueChange = { viewModel.onAction(RegistrationScreenAction.UpdateConfirmPassword(it)) },
modifier = Modifier
.fillMaxWidth()
.padding(PaddingValues(top = 4.dp))
@ -268,7 +268,7 @@ private fun RegistrationComponent(
name,
password,
confirmPassword,
registerNewAccount
registerNewAccount = viewModel::registerNewAccount
)
}
}

View file

@ -0,0 +1,10 @@
package ing.bikeshedengineer.debtpirate.app.screen.auth.presentation.register
sealed interface RegistrationScreenAction {
data class UpdateEmailAddress(val emailAddress: String) : RegistrationScreenAction
data class UpdateName(val name: String) : RegistrationScreenAction
data class UpdatePassword(val password: String) : RegistrationScreenAction
data class UpdateConfirmPassword(val confirmPassword: String) : RegistrationScreenAction
data object ResetFields : RegistrationScreenAction
data class RegisterNewUser(val emailAddress: String, val name: String, val password: String, val confirmPassword: String) : RegistrationScreenAction
}

View file

@ -6,9 +6,10 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import ing.bikeshedengineer.debtpirate.app.screen.auth.usecase.NewAccountRegistrationValidationResult
import ing.bikeshedengineer.debtpirate.app.screen.auth.usecase.SubmitAccountRegistrationRequestUseCase
import ing.bikeshedengineer.debtpirate.app.screen.auth.usecase.ValidateNewAccountRegistrationUseCase
import ing.bikeshedengineer.debtpirate.navigation.Destination
import ing.bikeshedengineer.debtpirate.navigation.Navigator
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
@ -18,7 +19,7 @@ import javax.inject.Inject
class RegistrationScreenViewModel @Inject constructor(
private val navigator: Navigator,
private val validateNewAccount: ValidateNewAccountRegistrationUseCase,
private val submitAccountRegistrationRequest: SubmitAccountRegistrationRequestUseCase
private val submitAccountRegistrationRequest: SubmitAccountRegistrationRequestUseCase,
) : ViewModel() {
fun navigateUp() {
viewModelScope.launch {
@ -54,20 +55,40 @@ class RegistrationScreenViewModel @Inject constructor(
val confirmPasswordError = _confirmPasswordError.asStateFlow()
val isInvalidConfirmPassword = _confirmPasswordError.asStateFlow().map { it.isNotEmpty() }
fun updateEmailAddress(emailAddress: String) {
_emailAddress.value = emailAddress
}
private val _onRegistrationComplete = MutableSharedFlow<Pair<String, String>>()
val onRegistrationComplete = _onRegistrationComplete.asSharedFlow()
fun updateName(name: String) {
_name.value = name
}
fun onAction(action: RegistrationScreenAction) {
when (action) {
is RegistrationScreenAction.UpdateEmailAddress -> {
_emailAddress.value = action.emailAddress
}
fun updatePassword(password: String) {
_password.value = password
}
is RegistrationScreenAction.UpdateName -> {
_name.value = action.name
}
fun updateConfirmPassword(confirmPassword: String) {
_confirmPassword.value = confirmPassword
is RegistrationScreenAction.UpdatePassword -> {
_password.value = action.password
}
is RegistrationScreenAction.UpdateConfirmPassword -> {
_confirmPassword.value = action.confirmPassword
}
is RegistrationScreenAction.ResetFields -> {
resetFields()
}
is RegistrationScreenAction.RegisterNewUser -> {
registerNewAccount(
action.emailAddress,
action.name,
action.password,
action.confirmPassword
)
}
}
}
fun registerNewAccount(
@ -81,10 +102,12 @@ class RegistrationScreenViewModel @Inject constructor(
if (fieldsAreValid) {
viewModelScope.launch {
try {
// TODO: Store the registration result data in the store
val result =
submitAccountRegistrationRequest(emailAddress, name, confirmPassword)
navigator.navigate(Destination.LoginScreen)
_onRegistrationComplete.emit(Pair(emailAddress, confirmPassword))
resetFields()
} catch (err: Throwable) {
// TODO...

View file

@ -0,0 +1,8 @@
package ing.bikeshedengineer.debtpirate.domain.model
sealed interface AccountManagerResult {
data class Success(val username: String) : AccountManagerResult
data object Unavailable : AccountManagerResult
data object Canceled : AccountManagerResult
data object Failure : AccountManagerResult
}

View file

@ -0,0 +1,42 @@
package ing.bikeshedengineer.debtpirate.domain.repository
import android.app.Activity
import android.util.Log
import androidx.credentials.CreatePasswordRequest
import androidx.credentials.CredentialManager
import androidx.credentials.exceptions.CreateCredentialCancellationException
import androidx.credentials.exceptions.CreateCredentialException
import androidx.credentials.exceptions.CreateCredentialNoCreateOptionException
import ing.bikeshedengineer.debtpirate.domain.model.AccountManagerResult
class AccountManager(private val context: Activity) {
private val credentialManager = CredentialManager.create(context)
suspend fun storeCredentials(
emailAddress: String,
password: String
): AccountManagerResult {
return try {
credentialManager.createCredential(
context, request = CreatePasswordRequest(
id = emailAddress,
password
)
)
AccountManagerResult.Success(emailAddress)
} catch (err: CreateCredentialNoCreateOptionException) {
Log.w(
"DebtPirate::AccountManager",
"Cannot store credentials; a Google account isn't associated with this device"
)
AccountManagerResult.Unavailable
} catch (err: CreateCredentialCancellationException) {
err.printStackTrace()
AccountManagerResult.Canceled
} catch (err: CreateCredentialException) {
err.printStackTrace()
AccountManagerResult.Failure
}
}
}

View file

@ -44,8 +44,6 @@ private val darkScheme = darkColorScheme(
@Composable
fun DebtPirateTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
dynamicColor: Boolean = true,
content: @Composable() () -> Unit
) {
val colorScheme = darkScheme

View file

@ -25,12 +25,15 @@ okhttp = "4.10.0"
retrofit = "2.9.0"
hiltNavigationCompose = "1.2.0"
fonts = "1.7.5"
credentialManager = "1.5.0-beta01"
[libraries]
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
androidx-credentials-core = { group = "androidx.credentials", name = "credentials", version.ref = "credentialManager" }
androidx-credentials-compat = { group = "androidx.credentials", name = "credentials-play-services-auth", version.ref = "credentialManager" }
androidx-datastore = { group = "androidx.datastore", name = "datastore", version.ref = "datastore" }
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }