Hey! This post is part of a series of building an expense tracker app. In today's guide we're going to implement firebase authentication with input verification in jetpack compose. Also, this post features the usage of CompositionLocalProvider
, which enables a clear and reusable way of showing a snackbar instead of injecting a separate callback into composables which is very junky. In one of the next guides I'll show you how to implement Google sign in, UI, and Unit testing of authentication, so stay tuned. If you have any questions or suggestions feel free to leave them in comments
Here's how the end result might look like
And the source code is here
Firebase setup
Head on to firebase and click on add project
After filling in the required info start configuring the project for Android by clicking on the corresponding icon
After following instructions head on to Authentication tab and add Email/Password provider
Hilt setup
Hilt is a popular dependency injection tool which simplifies inserting dependencies (classes, objects, mocks, etc.) into other dependencies.
You can pick versions of plugins that suit you here
Insert the following code into your root build.gradle
file
plugins {
id("com.google.dagger.hilt.android") version "2.50" apply false
// compatible with the 1.9.22 version of Kotlin
id("com.google.devtools.ksp") version "1.9.22-1.0.16" apply false
}
After syncing gradle, apply the below dependencies in your app/build.gradle
file
plugins {
id("com.google.dagger.hilt.android")
id("com.google.devtools.ksp")
}
dependencies {
implementation("com.google.dagger:hilt-android:2.50")
// required if using navigation together with hilt
implementation("androidx.hilt:hilt-navigation-compose:1.2.0")
ksp("com.google.dagger:hilt-compiler:2.50")
ksp("com.google.dagger:hilt-android-compiler:2.50")
}
Then annotate your MainActivity
class with @AndroidEntryPoint
@AndroidEntryPoint
class MainActivity : ComponentActivity()
And last, create a ${YourAppName}Application.kt
file and insert the following code
@HiltAndroidApp
// name it however you want
class MyApplication: Application()
To learn more about Hilt, visit this page:
Repository and View Model
First let's create a AuthUseCases.kt
file and put 3 input validators
class UsernameValidator {
operator fun invoke(username: String): UsernameValidationResult {
return if (username.isBlank()) UsernameValidationResult.IS_EMPTY
else UsernameValidationResult.CORRECT
}
}
class EmailValidator {
operator fun invoke(email: String): EmailValidationResult {
return if (Patterns.EMAIL_ADDRESS.matcher(email).matches()) {
EmailValidationResult.CORRECT
}
else EmailValidationResult.INCORRECT_FORMAT
}
}
class PasswordValidator {
operator fun invoke(password: String): PasswordValidationResult {
return if (password.length < 8) PasswordValidationResult.NOT_LONG_ENOUGH
else if (password.count(Char::isUpperCase) == 0) PasswordValidationResult.NOT_ENOUGH_UPPERCASE
else if (!password.contains("[0-9]".toRegex())) PasswordValidationResult.NOT_ENOUGH_DIGITS
else PasswordValidationResult.CORRECT
}
}
enum class UsernameValidationResult {
IS_EMPTY,
CORRECT
}
enum class EmailValidationResult {
INCORRECT_FORMAT,
CORRECT
}
enum class PasswordValidationResult {
NOT_LONG_ENOUGH,
NOT_ENOUGH_DIGITS,
NOT_ENOUGH_UPPERCASE,
CORRECT
}
operator fun invoke()
is a special type of function in Kotlin that allows us to insert parameters into a class instance. Here's an example
val passwordValidator = PasswordValidator()
passwordValidator(password)
For email validation we're using an email address checker of android.util
package that returns true if a given email passes the check
In password validator, password.contains("[0-9]".toRegex())
returns true if a given input contains at least 1 number, otherwise, we will inform a user of this requirement
Next, we'll create a AuthRepository.kt
file that will consist of a class responsible for authentication, which we'll later inject into our view model
class AuthRepository(
private val auth: FirebaseAuth,
private val firestore: FirebaseFirestore) {
suspend fun signUp(authState: AuthState) {
auth.createUserWithEmailAndPassword(authState.email!!, authState.password!!).await()
auth.currentUser?.updateProfile(UserProfileChangeRequest.Builder()
.setDisplayName(authState.username!!).build())?.await()
}
suspend fun signIn(authState: AuthState) {
auth.signInWithEmailAndPassword(authState.email!!, authState.password!!).await()
}
}
The code above contains a function that creates a user with email, password, and a username, which is set with the help of UserProfileChangeRequest
.
Create a StringValue.kt
file and define the StringValue
class. It will enable us to match input validation results to strings in an easy and clean way
sealed class StringValue {
data class DynamicString(val value: String) : StringValue()
data object Empty : StringValue()
class StringResource(
@StringRes val resId: Int
) : StringValue()
fun asString(context: Context): String {
return when (this) {
is Empty -> ""
is DynamicString -> value
is StringResource -> context.getString(resId))
}
}
}
Create a AuthModule.kt
file and define AuthModule
, that will tell hilt which instance of AuthRepository
and CoroutineScopeProvider
to provide (e.g mocked or real)
@Module
@InstallIn(SingletonComponent::class)
object AuthModule {
@Provides
@Singleton
fun provideAuthRepository(): AuthRepository =
AuthRepository(Firebase.auth, Firebase.firestore)
@Provides
@Singleton
fun provideCoroutineScopeProvider(): CoroutineScopeProvider =
CoroutineScopeProvider()
}
Singleton components are created only once per lifecycle of the application. Since there's absolutely no need to provide a different instance of AuthRepository
and CoroutineScopeProvider
each time they're requested, using @Singleton
is a perfect choice.
Create a Result.kt
file containing the Result
class.
sealed class CustomResult(val error: StringValue = StringValue.Empty) {
data object Idle: CustomResult()
data object InProgress: CustomResult()
data object Empty: CustomResult()
data object Success: CustomResult()
class DynamicError(error: String): CustomResult(StringValue.DynamicString(error))
class ResourceError(@StringRes res: Int): CustomResult(StringValue.StringResource(res))
}
This class will be responsible for providing smooth user experience as well as other important features (like disabling auth fields and buttons when the result of a sign in operation is InProgress
)
After that, create a AuthViewModel.kt
file and start defining auth view model
@HiltViewModel
class AuthViewModel @Inject constructor(
private val authRepository: AuthRepository
): ViewModel() {
private val usernameValidator = UsernameValidator()
private val emailValidator = EmailValidator()
private val passwordValidator = PasswordValidator()
private val _uiState = MutableStateFlow(UiState())
val uiState = _uiState.asStateFlow()
fun onUsername(username: String) {
val result = when (usernameValidator(username)) {
UsernameValidationResult.IS_EMPTY -> StringValue.StringResource(R.string.username_not_long_enough)
else -> StringValue.Empty
}
_uiState.update { it.copy(validationState = it.validationState.copy(usernameValidationError = result),
authState = it.authState.copy(username = username)) }
}
fun onEmail(email: String) {
val result = when (emailValidator(email)) {
EmailValidationResult.INCORRECT_FORMAT -> StringValue.StringResource(R.string.invalid_email_format)
else -> StringValue.Empty
}
_uiState.update { it.copy(validationState = it.validationState.copy(emailValidationError = result),
authState = it.authState.copy(email = email)) }
}
fun onPassword(password: String) {
val result = when (passwordValidator(password)) {
PasswordValidationResult.NOT_LONG_ENOUGH -> StringValue.StringResource(R.string.password_not_long_enough)
PasswordValidationResult.NOT_ENOUGH_UPPERCASE -> StringValue.StringResource(R.string.password_not_enough_uppercase)
PasswordValidationResult.NOT_ENOUGH_DIGITS -> StringValue.StringResource(R.string.password_not_enough_digits)
else -> StringValue.Empty
}
_uiState.update { it.copy(validationState = it.validationState.copy(passwordValidationError = result),
authState = it.authState.copy(password = password)) }
}
data class UiState(
val authType: AuthType = AuthType.SIGN_IN,
val authState: AuthState = AuthState(),
val validationState: ValidationState = ValidationState(),
val authResult: CustomResult = CustomResult.Idle)
data class ValidationState(
val usernameValidationError: StringValue = StringValue.Empty,
val emailValidationError: StringValue = StringValue.Empty,
val passwordValidationError: StringValue = StringValue.Empty,
)
}
data class AuthState(
val username: String? = null,
val email: String? = null,
val password: String? = null,
)
enum class AuthType {
SIGN_IN,
SIGN_UP
}
enum class FieldType {
USERNAME,
EMAIL,
PASSWORD
}
In this code AuthViewModel
is annotated with @HiltViewModel
, and its constructor is annotated with @Inject
, which makes hilt inject the dependencies we defined in AuthModule
.
onUsername
, onEmail
, and onPassword
functions are responsible for updating auth fields and the appropriate verification results. These will be called every time a user types something in.
Depending on the value of authType
variable we'll decide what kind of authentication is currently chosen (sign in or sign up), and make the UI and authentication pipeline react accordingly.
Now lets add a couple of functions to AuthViewModel
, which are directly responsible for authenticating a user
fun changeAuthType() {
_uiState.update { it.copy(authType = AuthType.entries[it.authType.ordinal xor 1]) }
}
fun onCustomAuth() {
val authType = _uiState.value.authType
updateAuthResult(CustomResult.InProgress)
viewModelScope.launch {
try {
if (authType == AuthType.SIGN_UP) authRepository.signUp(_uiState.value.authState)
else authRepository.signIn(_uiState.value.authState)
updateAuthResult(CustomResult.Success)
} catch (e: Exception) {
updateAuthResult(CustomResult.DynamicError(e.toStringIfMessageIsNull()))
}
}
}
changeAuthType
changes authentication type using XOR logical operator. For example, when a user is viewing a sign in composable, authType
ordinal has a value of 0. Upon calling the function, 0 xor 1 will be equal to 1, selecting the auth type at index 1, which is sign up. And vise versa, when a user is viewing a sign up composable and calls changeAuthType
function, the result of 1 xor 1 will be 0.
onCustomAuth
function starts off by telling a user that the authentication is running, then launches a coroutine scope with a try
and catch
block. If auth is successful, the function updates the result to Success
.
UI code
(Optional)
Let's create a fancy title for our app with gradient animation
@Composable
fun Title() {
val gradientColors = listOf(MaterialTheme.colorScheme.onBackground,
MaterialTheme.colorScheme.primary,
MaterialTheme.colorScheme.onPrimary.copy(0.5f))
var offsetX by remember { mutableStateOf(0f) }
LaunchedEffect(Unit) {
animate(
initialValue = 0f,
targetValue = 1000f,
animationSpec = tween(3000)) {value, _ ->
offsetX = value
}
}
val brush = Brush.linearGradient(
colors = gradientColors,
start = Offset(offsetX, 0f),
end = Offset(offsetX + 200f, 100f)
)
Text(stringResource(id = R.string.app_name),
style = MaterialTheme.typography.titleLarge.copy(brush = brush))
}
Let's define an auth field
@Composable
fun CustomInputField(
enabled: Boolean,
fieldType: FieldType,
value: String?,
error: String,
onValueChange: (String) -> Unit) {
val shape = RoundedCornerShape(dimensionResource(id = R.dimen.auth_corner))
val fieldTypeString = stringResource(id = when(fieldType) {
FieldType.USERNAME -> R.string.username
FieldType.EMAIL -> R.string.email
FieldType.PASSWORD -> R.string.password
})
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.fillMaxWidth()
) {
OutlinedTextField(
value = value ?: "",
isError = error.isNotEmpty(),
onValueChange = onValueChange,
enabled = enabled,
shape = shape,
keyboardOptions = KeyboardOptions(imeAction =
if (fieldType == FieldType.USERNAME || fieldType == FieldType.EMAIL) ImeAction.Next else ImeAction.Done),
placeholder = {
if (value.isNullOrBlank())
Text(fieldTypeString)
},
singleLine = true,
modifier = Modifier.fillMaxWidth().testTag(fieldTypeString)
)
if (error.isNotEmpty()) {
Text(error,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.testTag(error))
}
}
}
Here, we use fieldType
to decide the Ime action and show a placeholder. Also, we use the result of input validators to either show or not show an error text composable
Let's define an auth button
@Composable
fun CustomAuthButton(
authType: AuthType,
enabled: Boolean,
onClick: () -> Unit) {
val label = stringResource(
id = when (authType) {
AuthType.SIGN_IN -> R.string.sign_in
AuthType.SIGN_UP -> R.string.sign_up
}
)
ElevatedButton(onClick = onClick,
shape = RoundedCornerShape(dimensionResource(id = R.dimen.button_corner)),
colors = ButtonDefaults.buttonColors(),
enabled = enabled,
modifier = Modifier.fillMaxWidth()
) {
Text(label, style = MaterialTheme.typography.displaySmall,
modifier = Modifier.padding(10.dp))
}
}
Here authType
comes in handy, as we can show an appropriate label for a button.
After that follows a "go to text" which would enable a user to transition between desired auth states
@Composable
fun GoToText(authType: AuthType,
enabled: Boolean,
onClick: () -> Unit) {
val label = stringResource(
id = if (authType == AuthType.SIGN_IN) R.string.go_to_signup else R.string.go_to_signin
)
TextButton(onClick = onClick,
enabled = enabled) {
Text(label, style = MaterialTheme.typography.labelSmall.copy(
textDecoration = TextDecoration.Underline
))
}
}
Here we show "Go to sign up" text if a user is in sign in state. And vise versa.
Now we combine all the composables above into a single card
@Composable
fun AuthFieldsColumn(
uiState: AuthViewModel.UiState,
authEnabled: Boolean,
onUsername: (String) -> Unit,
onEmail: (String) -> Unit,
onPassword: (String) -> Unit,
onChangeAuthType: () -> Unit,
onAuth: () -> Unit
) {
val shape = RoundedCornerShape(dimensionResource(id = R.dimen.auth_corner))
val validationState = uiState.validationState
val authState = uiState.authState
val authResult = uiState.authResult
val context = LocalContext.current
val usernameValidationError = validationState.usernameValidationError.asString(context)
val emailValidationError = validationState.emailValidationError.asString(context)
val passwordValidationError = validationState.passwordValidationError.asString(context)
val authButtonEnabled = (usernameValidationError.isEmpty() && authState.username != null || uiState.authType != AuthType.SIGN_UP) &&
(emailValidationError.isEmpty() && authState.email != null) &&
(passwordValidationError.isEmpty() && authState.password != null) && authEnabled
ElevatedCard(
modifier = Modifier
.width(350.dp)
.shadow(
dimensionResource(id = R.dimen.shadow_elevation),
shape = shape,
spotColor = MaterialTheme.colorScheme.primary
)
.clip(shape)
.background(MaterialTheme.colorScheme.onBackground.copy(0.1f))
.border(1.dp, MaterialTheme.colorScheme.primary, shape)
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(20.dp),
modifier = Modifier
.fillMaxWidth()
.padding(10.dp)
) {
AnimatedVisibility(uiState.authType == AuthType.SIGN_UP) {
CustomInputField(
fieldType = FieldType.USERNAME,
enabled = authEnabled,
value = authState.username,
error = usernameValidationError,
onValueChange = onUsername)
}
CustomInputField(
fieldType = FieldType.EMAIL,
enabled = authEnabled,
value = authState.email,
error = emailValidationError,
onValueChange = onEmail)
CustomInputField(
fieldType = FieldType.PASSWORD,
enabled = authEnabled,
value = authState.password,
error = passwordValidationError,
onValueChange = onPassword)
CustomAuthButton(
authType = uiState.authType,
enabled = authButtonEnabled,
onClick = onAuth)
if (authResult is CustomResult.InProgress) {
LinearProgressIndicator()
}
Column(horizontalAlignment = Alignment.CenterHorizontally) {
if (uiState.authType == AuthType.SIGN_IN) {
Text(stringResource(id = R.string.dont_have_an_account),
style = MaterialTheme.typography.labelSmall)
}
GoToText(authType = uiState.authType,
enabled = authEnabled,
onClick = onChangeAuthType)
}
}
}
}
In the code above authButtonEnabled
will be true in 2 cases: either if a current auth option is sign in and email with password pass the input check, or if a current option is sign up and username together with email and password pass the check. Also, username text field will be shown only on sign up.
After that I've defined another composable that accepts ui state and callback methods. This is useful for previewing composables.
@Composable
fun AuthContentColumn(
uiState: AuthViewModel.UiState,
onUsername: (String) -> Unit,
onEmail: (String) -> Unit,
onPassword: (String) -> Unit,
onChangeAuthType: () -> Unit,
onCustomAuth: () -> Unit,
onSignGoogleSignIn: suspend () -> IntentSender?,
onSignInWithIntent: (ActivityResult) -> Unit) {
val authResult = uiState.authResult
val authEnabled = authResult !is CustomResult.InProgress && authResult !is CustomResult.Success
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(40.dp),
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(10.dp)
) {
Title()
AuthFieldsColumn(
uiState = uiState,
authEnabled = authEnabled,
onUsername = onUsername,
onEmail = onEmail,
onPassword = onPassword,
onChangeAuthType = onChangeAuthType,
onAuth = onCustomAuth)
}
}
Next create a top auth screen composable
@Composable
fun AuthScreen(
onSignIn: () -> Unit,
viewModel: AuthViewModel = hiltViewModel()) {
val uiState by viewModel.uiState.collectAsState()
val focusManger = LocalFocusManager.current
val snackbarController = LocalSnackbarController.current
LaunchedEffect(uiState.authResult) {
if (uiState.authResult is CustomResult.Success) {
focusManger.clearFocus(true)
onSignIn()
}
}
LaunchedEffect(uiState.authResult) {
snackbarController.showErrorSnackbar(uiState.authResult)
}
AuthContentColumn(
uiState = uiState,
onUsername = viewModel::onUsername,
onEmail = viewModel::onEmail,
onPassword = viewModel::onPassword,
onChangeAuthType = viewModel::changeAuthType,
onCustomAuth = viewModel::onCustomAuth,
onSignGoogleSignIn = viewModel::onGoogleSignIn,
onSignInWithIntent = viewModel::onSignInWithIntent,
)
}
On successful authentication, the composable clears keyboard focus and calls onSignIn
callback, which is usually responsible for navigating to another screen. On unsuccessful authentication, an error snackbar is shown. Here's the code for it:
In MainActivity.kt
create a LocalSnackbarController
variable
val LocalSnackbarController = compositionLocalOf<SnackbarController> {
error("No snackbar host state provided")
}
Then inside of setContent
function wrap the app's content with CompositionLocalProvider
and define a dismissable snackbar. To learn more about CompositionLocalProvider
, visit this page
setContent {
val snackbarHostState = remember {
SnackbarHostState()
}
val swipeToDismissBoxState = rememberSwipeToDismissBoxState(confirmValueChange = {value ->
if (value != SwipeToDismissBoxValue.Settled) {
snackbarHostState.currentSnackbarData?.dismiss()
true
} else false
})
val snackbarController by remember(snackbarHostState) {
mutableStateOf(SnackbarController(snackbarHostState, lifecycleScope, applicationContext))
}
LaunchedEffect(swipeToDismissBoxState.currentValue) {
if (swipeToDismissBoxState.currentValue != SwipeToDismissBoxValue.Settled) {
swipeToDismissBoxState.reset()
}
}
MoneyMonocleTheme(darkTheme = isThemeDark.value) {
CompositionLocalProvider(LocalSnackbarController provides snackbarController) {
Surface(
Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
) {
Box(
Modifier
.fillMaxSize()
.imePadding()
) {
MoneyMonocleNavHost(
navController = navController
)
CustomErrorSnackbar(snackbarHostState = snackbarHostState,
swipeToDismissBoxState = swipeToDismissBoxState)
}
}
}
}
}
After that in a separate file create SnackbarController
with CustomErrorSnackbar
class SnackbarController(
private val snackbarHostState: SnackbarHostState,
private val coroutineScope: CoroutineScope,
private val context: Context,
) {
fun showErrorSnackbar(result: CustomResult) {
if (result is CustomResult.DynamicError || result is CustomResult.ResourceError) {
coroutineScope.launch {
snackbarHostState.currentSnackbarData?.dismiss()
snackbarHostState.showSnackbar(result.error.asString(context))
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CustomErrorSnackbar(snackbarHostState: SnackbarHostState,
swipeToDismissBoxState: SwipeToDismissBoxState) {
SwipeToDismissBox(state = swipeToDismissBoxState, backgroundContent = {}) {
SnackbarHost(hostState = snackbarHostState,
snackbar = {data ->
Snackbar(
containerColor = MaterialTheme.colorScheme.errorContainer,
modifier = Modifier
.padding(20.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Text(data.visuals.message,
color = MaterialTheme.colorScheme.onErrorContainer,
modifier = Modifier.testTag(stringResource(id = R.string.error_snackbar)))
}
}
})
}
}
And that's all there is to it! Good luck on your Android Development journey.
Top comments (0)