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

View file

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

View file

@ -1,6 +1,7 @@
package ing.bikeshedengineer.debtpirate.app.screen.auth.presentation.login package ing.bikeshedengineer.debtpirate.app.screen.auth.presentation.login
import android.annotation.SuppressLint import android.annotation.SuppressLint
import androidx.activity.ComponentActivity
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@ -28,10 +29,10 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.State import androidx.compose.runtime.State
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
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.graphics.Color import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization 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.text.style.TextAlign
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 ing.bikeshedengineer.debtpirate.R import ing.bikeshedengineer.debtpirate.domain.repository.AccountManager
@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter") @SuppressLint("UnusedMaterial3ScaffoldPaddingParameter")
@Composable @Composable
fun LoginScreen( fun LoginScreen(
viewModel: LoginScreenViewModel = hiltViewModel<LoginScreenViewModel>() viewModel: LoginScreenViewModel = hiltViewModel<LoginScreenViewModel>()
) { ) {
val context = LocalContext.current as ComponentActivity
val accountManager = remember {
AccountManager(context)
}
Scaffold( Scaffold(
modifier = Modifier.fillMaxSize() modifier = Modifier.fillMaxSize()
) { ) {
@ -67,13 +73,32 @@ fun LoginScreen(
LoginComponent( LoginComponent(
emailAddress = emailAddress, emailAddress = emailAddress,
onUpdateEmailAddress = viewModel::updateEmailAddress, onUpdateEmailAddress = {
viewModel.updateState(
LoginScreenStateAction.UpdateEmailAddress(
it
)
)
},
password = password, password = password,
onUpdatePassword = viewModel::updatePassword, onUpdatePassword = {
viewModel.updateState(
LoginScreenStateAction.UpdatePassword(
it
)
)
},
submitLoginRequest = viewModel::submitLoginRequest 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) 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("") private val _password = MutableStateFlow("")
val password = _password.asStateFlow() val password = _password.asStateFlow()
fun updateEmailAddress(emailAddress: String) { fun updateState(action: LoginScreenStateAction) {
_emailAddress.value = emailAddress when (action) {
is LoginScreenStateAction.UpdateEmailAddress -> {
_emailAddress.value = action.emailAddress
} }
fun updatePassword(password: String) { is LoginScreenStateAction.UpdatePassword -> {
_password.value = password _password.value = action.password
}
}
} }
fun submitLoginRequest() { fun submitLoginRequest() {

View file

@ -1,6 +1,8 @@
package ing.bikeshedengineer.debtpirate.app.screen.auth.presentation.register package ing.bikeshedengineer.debtpirate.app.screen.auth.presentation.register
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.util.Log
import androidx.activity.ComponentActivity
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
@ -26,12 +28,15 @@ import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.State import androidx.compose.runtime.State
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.nestedscroll.nestedScroll 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.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight 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.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel 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) @OptIn(ExperimentalMaterial3Api::class)
@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter") @SuppressLint("UnusedMaterial3ScaffoldPaddingParameter")
@Composable() @Composable()
fun RegistrationScreen(viewModel: RegistrationScreenViewModel = hiltViewModel<RegistrationScreenViewModel>()) { fun RegistrationScreen(
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState()) 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( Scaffold(
topBar = { RegistrationTopAppBar(onNavigateUp = viewModel::navigateUp) }, topBar = { RegistrationTopAppBar(onNavigateUp = viewModel::navigateUp) },
modifier = Modifier modifier = Modifier
@ -64,40 +96,8 @@ fun RegistrationScreen(viewModel: RegistrationScreenViewModel = hiltViewModel<Re
.padding(innerPadding) .padding(innerPadding)
.padding(16.dp) .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( RegistrationComponent(
emailAddress = emailAddress, viewModel,
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
) )
} }
} }
@ -134,25 +134,25 @@ private fun RegistrationTopAppBar(onNavigateUp: () -> Unit, modifier: Modifier =
@Composable @Composable
private fun RegistrationComponent( private fun RegistrationComponent(
emailAddress: State<String>, viewModel: RegistrationScreenViewModel,
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,
modifier: Modifier = Modifier 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())) { Column(modifier = modifier.verticalScroll(rememberScrollState())) {
OutlinedTextField( OutlinedTextField(
value = emailAddress.value, value = emailAddress.value,
@ -167,7 +167,7 @@ private fun RegistrationComponent(
keyboardType = KeyboardType.Email, keyboardType = KeyboardType.Email,
imeAction = ImeAction.Next imeAction = ImeAction.Next
), ),
onValueChange = updateEmailAddress, onValueChange = { viewModel.onAction(RegistrationScreenAction.UpdateEmailAddress(it)) },
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) )
@ -184,7 +184,7 @@ private fun RegistrationComponent(
keyboardType = KeyboardType.Text, keyboardType = KeyboardType.Text,
imeAction = ImeAction.Next imeAction = ImeAction.Next
), ),
onValueChange = updateName, onValueChange = { viewModel.onAction(RegistrationScreenAction.UpdateName(it)) },
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(PaddingValues(top = 4.dp)) .padding(PaddingValues(top = 4.dp))
@ -209,7 +209,7 @@ private fun RegistrationComponent(
keyboardType = KeyboardType.Password, keyboardType = KeyboardType.Password,
imeAction = ImeAction.Next imeAction = ImeAction.Next
), ),
onValueChange = updatePassword, onValueChange = { viewModel.onAction(RegistrationScreenAction.UpdatePassword(it)) },
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(PaddingValues(top = 4.dp)) .padding(PaddingValues(top = 4.dp))
@ -234,7 +234,7 @@ private fun RegistrationComponent(
keyboardType = KeyboardType.Password, keyboardType = KeyboardType.Password,
imeAction = ImeAction.Done imeAction = ImeAction.Done
), ),
onValueChange = updateConfirmPassword, onValueChange = { viewModel.onAction(RegistrationScreenAction.UpdateConfirmPassword(it)) },
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(PaddingValues(top = 4.dp)) .padding(PaddingValues(top = 4.dp))
@ -268,7 +268,7 @@ private fun RegistrationComponent(
name, name,
password, password,
confirmPassword, 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.NewAccountRegistrationValidationResult
import ing.bikeshedengineer.debtpirate.app.screen.auth.usecase.SubmitAccountRegistrationRequestUseCase import ing.bikeshedengineer.debtpirate.app.screen.auth.usecase.SubmitAccountRegistrationRequestUseCase
import ing.bikeshedengineer.debtpirate.app.screen.auth.usecase.ValidateNewAccountRegistrationUseCase import ing.bikeshedengineer.debtpirate.app.screen.auth.usecase.ValidateNewAccountRegistrationUseCase
import ing.bikeshedengineer.debtpirate.navigation.Destination
import ing.bikeshedengineer.debtpirate.navigation.Navigator import ing.bikeshedengineer.debtpirate.navigation.Navigator
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -18,7 +19,7 @@ import javax.inject.Inject
class RegistrationScreenViewModel @Inject constructor( class RegistrationScreenViewModel @Inject constructor(
private val navigator: Navigator, private val navigator: Navigator,
private val validateNewAccount: ValidateNewAccountRegistrationUseCase, private val validateNewAccount: ValidateNewAccountRegistrationUseCase,
private val submitAccountRegistrationRequest: SubmitAccountRegistrationRequestUseCase private val submitAccountRegistrationRequest: SubmitAccountRegistrationRequestUseCase,
) : ViewModel() { ) : ViewModel() {
fun navigateUp() { fun navigateUp() {
viewModelScope.launch { viewModelScope.launch {
@ -54,20 +55,40 @@ class RegistrationScreenViewModel @Inject constructor(
val confirmPasswordError = _confirmPasswordError.asStateFlow() val confirmPasswordError = _confirmPasswordError.asStateFlow()
val isInvalidConfirmPassword = _confirmPasswordError.asStateFlow().map { it.isNotEmpty() } val isInvalidConfirmPassword = _confirmPasswordError.asStateFlow().map { it.isNotEmpty() }
fun updateEmailAddress(emailAddress: String) { private val _onRegistrationComplete = MutableSharedFlow<Pair<String, String>>()
_emailAddress.value = emailAddress val onRegistrationComplete = _onRegistrationComplete.asSharedFlow()
fun onAction(action: RegistrationScreenAction) {
when (action) {
is RegistrationScreenAction.UpdateEmailAddress -> {
_emailAddress.value = action.emailAddress
} }
fun updateName(name: String) { is RegistrationScreenAction.UpdateName -> {
_name.value = name _name.value = action.name
} }
fun updatePassword(password: String) { is RegistrationScreenAction.UpdatePassword -> {
_password.value = password _password.value = action.password
} }
fun updateConfirmPassword(confirmPassword: String) { is RegistrationScreenAction.UpdateConfirmPassword -> {
_confirmPassword.value = confirmPassword _confirmPassword.value = action.confirmPassword
}
is RegistrationScreenAction.ResetFields -> {
resetFields()
}
is RegistrationScreenAction.RegisterNewUser -> {
registerNewAccount(
action.emailAddress,
action.name,
action.password,
action.confirmPassword
)
}
}
} }
fun registerNewAccount( fun registerNewAccount(
@ -81,10 +102,12 @@ class RegistrationScreenViewModel @Inject constructor(
if (fieldsAreValid) { if (fieldsAreValid) {
viewModelScope.launch { viewModelScope.launch {
try { try {
// TODO: Store the registration result data in the store
val result = val result =
submitAccountRegistrationRequest(emailAddress, name, confirmPassword) submitAccountRegistrationRequest(emailAddress, name, confirmPassword)
navigator.navigate(Destination.LoginScreen) _onRegistrationComplete.emit(Pair(emailAddress, confirmPassword))
resetFields() resetFields()
} catch (err: Throwable) { } catch (err: Throwable) {
// TODO... // 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 @Composable
fun DebtPirateTheme( fun DebtPirateTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
dynamicColor: Boolean = true,
content: @Composable() () -> Unit content: @Composable() () -> Unit
) { ) {
val colorScheme = darkScheme val colorScheme = darkScheme

View file

@ -25,12 +25,15 @@ okhttp = "4.10.0"
retrofit = "2.9.0" retrofit = "2.9.0"
hiltNavigationCompose = "1.2.0" hiltNavigationCompose = "1.2.0"
fonts = "1.7.5" fonts = "1.7.5"
credentialManager = "1.5.0-beta01"
[libraries] [libraries]
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } 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-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-datastore = { group = "androidx.datastore", name = "datastore", version.ref = "datastore" }
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }