Реализация экранов авторизации и регистрации с помощью Custom View и Firebase
Привет, читатели Хабр!
Каждый из нас сталкивается с авторизацией и регистрацией в приложениях как пользователь и как разработчик. Но перед разработчиком стоит более важная задача, а именно реализовать View таким образом, чтобы данные, которые введет пользователь, были корректно обработаны и переданы на сервер, что если пользователь введет вместо своего email просто набор символов, или напишет пароль из одной цифры? В нормальных приложениях это недопустимо! В этой статье я хочу продемонстрировать демо приложение, где будет представлен способ обработки данных полей с использованием Custom View и авторизацией в firebase.
Структура приложения
Данное демо приложение содержит активити и три фрагмента. Первый фрагмент — экран авторизации, второй фрагмент — экран регистрации и еще один фрагмент на который попадает пользователь, если он успешно прошел валидацию на одном из предыдущих фрагментов. Аутентификация, как я писал ранее, происходит в firebase.
Реализация приложения
Поскольку Custom View будет расширять функционал полей для ввода текста, чтобы не писать один и тот же код несколько раз, можно сделать класс заготовку, в котором будет описана основная логика и от которого будут наследоваться последующие классы, таким классом будет CustomInputLayout.
abstract class CustomInputLayout @JvmOverloads constructor(
context: Context,
attrs: AttributeSet,
defStyleAttr: Int = 0
) : TextInputLayout(context, attrs, defStyleAttr), Validation {
protected abstract val errorMessageId: Int
private val textWatcher = RegistrationTextWatcher { error = "" }
open fun text() = editText?.text.toString()
override fun onAttachedToWindow() {
super.onAttachedToWindow()
editText?.addTextChangedListener(textWatcher)
}
override fun isValid(): Boolean {
val isValid = innerIsValid()
error = if (isValid) "" else context.getString(errorMessageId)
return isValid
}
protected abstract fun innerIsValid(): Boolean
}
interface Validation {
fun isValid(): Boolean
}
В данном коде определен абстрактный класс CustomInputLayout, который является наследником класса TextInputLayout из библиотеки Android Material Design. CustomInputLayout также реализует интерфейс Validation.
Конструктор класса принимает параметры context, attrs и defStyleAttr, где context представляет контекст приложения, attrs содержит атрибуты, указанные в разметке, а defStyleAttr — стиль, который будет применен к макету.
В классе определены следующие члены:
errorMessageId — абстрактное свойство, обозначающее идентификатор строки ресурса сообщения об ошибке.
textWatcher — экземпляр класса RegistrationTextWatcher для отслеживания изменений текста в поле ввода.
text () — возвращает строку текста из поля ввода.
onAttachedToWindow () — переопределенный метод, вызывающий родительскую реализацию и добавляющий textWatcher к полю ввода.
isValid () — переопределенный метод интерфейса Validation, возвращающий true, если введенное значение считается допустимым, и false — в противном случае.
innerIsValid () — абстрактный метод, определяющий валидацию значения в классах-наследниках.
Так же нужно создать класс RegistrationTextWatcher, экземпляр которого был создан в CustomInputLayout.
class RegistrationTextWatcher(private val onTextChanged: () -> Unit) : TextWatcher {
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {
}
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
onTextChanged.invoke()
}
override fun afterTextChanged(s: Editable?) {
}
}
Класс RegistrationTextWatcher принимает в конструкторе функцию onTextChanged, которая будет вызываться при изменении текста. Это высокоуровневая функция, которая не принимает аргументов и ничего не возвращает (Unit).
Далее класс переопределяет три метода интерфейса TextWatcher:
1. beforeTextChanged — этот метод вызывается перед изменением текста. В данном коде метод не содержит какой-либо реализации и оставлен пустым.
2. onTextChanged — этот метод вызывается во время изменения текста. В данной реализации метода вызывается функция onTextChanged.invoke (), что приводит к выполнению функции onTextChanged, переданной в конструкторе класса. Таким образом, при изменении текста будет вызываться переданная функция.
3. afterTextChanged — этот метод вызывается после изменения текста.
Таким образом, класс RegistrationTextWatcher позволяет отслеживать изменения текста в поле и вызывать заданную функцию onTextChanged, когда происходят эти изменения.
После того как был создан класс, от которого будут наследоваться классы для обработки полей Email и Password, можно приступать к их реализации.
class MailInput @JvmOverloads constructor(
context: Context,
attrs: AttributeSet,
defStyleAttr: Int = 0
) : CustomInputLayout(context, attrs, defStyleAttr) {
override val errorMessageId = R.string.login_error
override fun innerIsValid(): Boolean {
return Patterns.EMAIL_ADDRESS.matcher(text()).matches()
}
}
В данном коде определен класс MailInput, который наследуется от созданного ранее класса CustomInputLayout. Этот класс будет обрабатывать поле для электронной почты.
Конструктор класса MailInput принимает следующие параметры:
— context: Context — контекст приложения.
— attrs: AttributeSet — набор атрибутов, определенных в XML-разметке для этого пользовательского элемента ввода.
— defStyleAttr: Int — атрибут стиля по умолчанию.
@JvmOverloads — это аннотация, используемая для генерации перегруженных конструкторов с параметрами по умолчанию, которые могут быть использованы из Java-кода.
Класс MailInput переопределяет два метода:
innerIsValid () — этот метод проверяет, является ли введенный текст в поле электронной почты валидным.
В данной реализации используется паттерн который доступен в Java Patterns.EMAIL_ADDRESS.matcher (text ()).matches (), чтобы проверить, соответствует ли введенный текст стандартному формату электронной почты. Если текст соответствует формату, то метод возвращает true, в противном случае — false.
errorMessageId — устанавливает идентификатор R.string.login_error. Это идентификатор используется для отображения сообщения об ошибке, которое будет показано пользователю, если введенный текст не является валидным адресом электронной почты.
class PasswordInput @JvmOverloads constructor(
context: Context,
attrs: AttributeSet,
defStyleAttr: Int = 0
) : CustomInputLayout(context, attrs, defStyleAttr) {
override val errorMessageId: Int = R.string.password_error
override fun innerIsValid(): Boolean {
return text().matches(Regex(PASSWORD_PATTERN))
}
companion object {
private const val PASSWORD_PATTERN =
"^(?=.*?[A-Z])(?=.*?[a-z])(?=.*?[0-9])(?=.*?[#?!@\$%^&*-]).{8,}\$"
}
}
Данный класс мало отличается от предыдущего, за исключением позиций
errorMessageId — в котором переопределяется и устанавливается идентификатор строки ресурса R.string.password_error.
А также PASSWORD_PATTERN, поскольку он не доступен как EMAIL_ADDRESS, был использован сторонний паттерн.
Данное регулярное выражение будет соответствовать строкам, которые:
— Содержат хотя бы одну заглавную букву [A-Z].
— Содержат хотя бы одну строчную букву [a-z].
— Содержат хотя бы одну цифру [0–9].
— Содержат хотя бы один специальный символ [#?!@\$%^&*-].
— Имеют длину не менее 8 символов .{8,}.
В фрагменте с регистрацией, в отличии от фрагмента с авторизацией, кроме поля с email будут два поля password где будет проходить сравнение паролей.
class PasswordLayout @JvmOverloads constructor(
context: Context,
attrs: AttributeSet,
defStyleAttr: Int = 0
) : LinearLayout(context, attrs, defStyleAttr), Validation {
private val binding = PasswordLayoutBinding.inflate(LayoutInflater.from(context), this)
init {
orientation = VERTICAL
listOf(binding.passwordEditText, binding.passwordRepeatEditText).forEach {
it.addTextChangedListener(RegistrationTextWatcher {
binding.errorText.text = ""
})
}
}
private val errorMessageId: Int = R.string.password_error_same
override fun isValid(): Boolean {
with(binding) {
val isPasswordsEquals = passwordLayout.text() == passwordRepeatLayout.text()
errorText.text = if (isPasswordsEquals) "" else context.getString(errorMessageId)
val isPasswordsValid = listOf(passwordLayout, passwordRepeatLayout).map { it.isValid() }
return isPasswordsValid.all { it } && isPasswordsEquals
}
}
fun text(): String {
return binding.passwordLayout.text()
}
}
Класс PasswordLayout определяет переменную binding, которая инфлейтит макет PasswordLayoutBinding с использованием LayoutInflater и привязывается к текущему экземпляру LinearLayout.
Затем в блоке init устанавливается вертикальная ориентация для макета, и для каждого из полей ввода пароля (passwordEditText и passwordRepeatEditText) добавляется TextChangedListener, реализованный как экземпляр RegistrationTextWatcher. Этот слушатель отслеживает изменения в полях ввода и обновляет текст ошибки (errorText) в зависимости от того, совпадают ли значения паролей.
Переменная errorMessageId хранит R.string.password_error_same, которая используется для отображения текста ошибки, если значения полей ввода пароля не совпадают.
Метод isValid () реализует интерфейс Validation и выполняет проверки валидности пароля и сравнивает значения полей ввода пароля. В данной реализации метод сравнивает значения passwordLayout и passwordRepeatLayout в макете и устанавливает текст ошибки errorText в зависимости от результата сравнения. Затем метод проводит валидацию каждого из полей ввода пароля и возвращает значение true, если все поля валидны и значения паролей совпадают, и false в противном случае.
Метод text () возвращает текст из поля ввода пароля passwordLayout в макете, позволяя получить введенный пароль во внешних частях кода.
Теперь можно наверстать разметку для всех экранов.
Разметка MainActivity.
Разметка AuthorizationFragment.
Разметка PasswordLayout.
Разметка RegistrationFragment.
Как можно заметить, вместо обычного TextInputLayout используются созданные Custom View. Так же, в разметке, стоит обратить внимание на такие строки как app: endIconMode=«password_toggle» которые позволяют скрывать и показывать что вводит пользователь в полях password, а так же строки app: endIconMode=«clear_text» которые позволяют стереть то, что было ранее написано в поле email.
Навигация между фрагментами будет осуществляться с помощью Navigation Components. Так же, в приложении, для внедрения зависимостей будет использован Hilt.
Для этого понадобятся:
класс App (нужно указать его в манифесте)
@HiltAndroidApp
class App : Application()
интерфейс AppComponent
@Component
interface AppComponent {
}
класс AuthModule — в этом модуле подключается firebase.
@InstallIn(SingletonComponent::class)
@Module
class AuthModule {
@Provides
@Singleton
fun provideFirebaseAuth(): FirebaseAuth {
return Firebase.auth
}
}
абстрактный класс Module, с абстрактным методом bindAuthRepository (), в котором осуществляется привязка реализации AuthRepositoryImpl с интерфейсом AuthRepository (их реализация будет написана ниже).
@InstallIn(SingletonComponent::class)
@Module
abstract class Module {
@Binds
abstract fun bindAuthRepository(authRepositoryImpl: AuthRepositoryImpl): AuthRepository
}
Что касается самого firebase, опущу описание его подключения, в самом firebase все очень подробно описано, добавлю лишь то что для работы с аутентификацией понадобится зависимость implementation «com.google.firebase: firebase-auth-ktx», а также в самом firebase нужно указать, что аутентификация, будет проходить с помощью email и password.
Отмечаем что аутентификация проходит с помощью email и password.
Далее, нужен abstract class User.
abstract class User {
abstract val email: String
abstract val id: String
class Base(override val email: String, override val id: String) : User()
object Empty : User() {
override val email = "Empty"
override val id = "Empty_id"
}
}
Данный код позволяет создавать объекты User с разными реализациями и значениями email и id, включая «базовые» пользователи Base и «пустого» пользователя Empty.
Теперь понадобится интерфейс AuthRepositiry, в нем описаны методы для авторизации и регистрации.
interface AuthRepository {
suspend fun signInWithEmailAndPassword(email: String, password: String): AuthResult
suspend fun signUpWithEmailAndPassword(email: String, password: String): AuthResult
}
Также нужен класс AuthResult, в нем будут описаны различные результаты обработки ответов от firebase.
sealed class AuthResult {
class Success(val user: User) : AuthResult()
class Error(val e: Exception) : AuthResult()
object Loading : AuthResult()
}
После этого, нужно создать класс AuthRepositiryImpl, которому будет имплементирован интерфейс AuthRepositiry, где будут реализованы его методы.
class AuthRepositoryImpl @Inject constructor(private val auth: FirebaseAuth) : AuthRepository {
override suspend fun signInWithEmailAndPassword(email: String, password: String): AuthResult {
return try {
val user = auth.signInWithEmailAndPassword(email, password).await().user!!
AuthResult.Success(User.Base(user.email ?: " ", user.uid))
} catch (e: Exception) {
AuthResult.Error(e)
}
}
override suspend fun signUpWithEmailAndPassword(email: String, password: String): AuthResult {
return try {
val user = auth.createUserWithEmailAndPassword(email, password).await().user!!
AuthResult.Success(User.Base(user.email ?: " ", user.uid))
} catch (e: Exception) {
AuthResult.Error(e)
}
}
}
В конструкторе класса AuthRepositoryImpl используется внедрение зависимостей с помощью аннотации @Inject и параметра auth типа FirebaseAuth. Это позволяет получить экземпляр FirebaseAuth из Hilt и использовать его внутри класса.
signInWithEmailAndPassword — функция, которая выполняет аутентификацию пользователя с использованием электронной почты и пароля. Она вызывает метод signInWithEmailAndPassword из FirebaseAuth для выполнения фактической операции аутентификации. Если операция завершилась успешно, создается объект User.Base с электронной почтой и идентификатором пользователя, и возвращается AuthResult.Success. Если произошла ошибка, возвращается AuthResult.Error
signUpWithEmailAndPassword — функция устроена аналогично.
Для того чтобы не писать один и тот же код по несколько раз, можно создать класс BaseFragment и класс BaseViewModel, в которых будет описана общая функциональность.
abstract class BaseFragment : Fragment() {
protected abstract val bindingInflater: (LayoutInflater, ViewGroup?) -> B
private var _binding: B? = null
protected val binding get() = _binding!!
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View {
_binding = bindingInflater.invoke(inflater, container)
return binding.root
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
}
BaseFragment — абстрактный класс, параметризованный типом B, который наследуется от класса Fragment.
bindingInflater — абстрактное свойство, которое должно быть переопределено в подклассах BaseFragment. Оно представляет лямбда-выражение, принимающее LayoutInflater и ViewGroup?, и возвращающее B (тип, унаследованный от ViewBinding).
_binding — приватное свойство, представляющее привязку к представлению (binding) фрагмента. Оно инициализируется значением null при создании фрагмента.
binding — защищенное свойство, которое обеспечивает доступ к экземпляру привязки к представлению (binding).
onCreateView — переопределенный метод из класса Fragment, который вызывается при создании представления фрагмента.
onDestroyView — переопределенный метод из класса Fragment, который вызывается при уничтожении фрагмента. В этом методе привязка к представлению (_binding) устанавливается в null для освобождения ресурсов.
abstract class BaseViewModel : ViewModel() {
abstract val sendRequest: suspend (String, String) -> AuthResult
private val _authState = MutableLiveData()
val authState: LiveData get() = _authState
fun sendCredentials(email: String, password: String) {
viewModelScope.launch(Dispatchers.IO) {
_authState.postValue(AuthResult.Loading)
val result = sendRequest.invoke(email, password)
_authState.postValue(result)
}
}
}
BaseViewModel — абстрактный класс, наследующийся от ViewModel. Он предоставляет базовую функциональность для управления состоянием аутентификации (или других операций) в архитектуре MVVM.
sendRequest — абстрактная функция, которая должна быть переопределена в подклассах BaseViewModel. В данном случае, эта функция принимает две строки (String) — адрес электронной почты и пароль, и должна возвращать объект типа AuthResult. Это позволяет использовать подклассам BaseViewModel собственную логику для отправки запросов на сервер аутентификации.
_authState — приватное свойство типа MutableLiveData
, которое представляет внутреннее состояние аутентификации. Оно инициализируется экземпляром MutableLiveData, использующим тип AuthResult. authState — открытое свойство типа LiveData
, которое предоставляет доступ к текущему состоянию аутентификации через authState.value. Отслеживание этого свойства позволяет обновлять пользовательский интерфейс в соответствии с изменениями состояния аутентификации. sendCredentials — функция, которая вызывается для отправки данных на сервер (firebase). Она запускается в viewModelScope с исполнителем Dispatchers.IO, чтобы выполняться в фоновом потоке. Внутри функции, сначала устанавливается состояние AuthResult.Loading, затем вызывается функция sendRequest с передачей адреса электронной почты и пароля, и результат устанавливается в _authState.
Теперь можно создать фрагменты и вью модели для всех View.
@HiltViewModel
class AuthorizationViewModel @Inject constructor(private val authRepository: AuthRepository) :
BaseViewModel() {
override val sendRequest: suspend (String, String) -> AuthResult =
{ email, password -> authRepository.signInWithEmailAndPassword(email, password) }
}
AuthorizationViewModel — класс, представляющий ViewModel. Он наследуется от BaseViewModel.
@HiltViewModel — аннотация, которая обозначает, что этот класс является ViewModel, и его зависимости должны быть внедрены с помощью Hilt.
authRepository — зависимость типа AuthRepository, которая внедряется в конструктор AuthorizationViewModel с использованием аннотации @Inject, через механизм внедрения зависимостей Hilt.
sendRequest — переопределенное свойство из BaseViewModel, которое представляет функцию (String, String) → AuthResult. В данном случае, оно устанавливается как лямбда-выражение, где email и password передаются в authRepository.signInWithEmailAndPassword для выполнения операции аутентификации. authRepository.signInWithEmailAndPassword возвращает объект AuthResult.
@HiltViewModel
class RegistrationViewModel @Inject constructor(private val authRepository: AuthRepository) :
BaseViewModel() {
override val sendRequest: suspend (String, String) -> AuthResult =
{ email, password -> authRepository.signUpWithEmailAndPassword(email, password) }
}
RegistrationViewModelустроена аналогично с AuthorizationViewModel, за исключением того что в sendRequest email и password передаются в signUpWithEmailAndPassword.
@AndroidEntryPoint
class AuthorizationFragment : BaseFragment() {
override val bindingInflater: (LayoutInflater, ViewGroup?) -> FragmentAuthorizationBinding =
{ inflater, container ->
FragmentAuthorizationBinding.inflate(inflater, container, false)
}
private val viewModel: AuthorizationViewModel by viewModels()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val inputList = listOf(
binding.authMail,
binding.authPassword
)
viewModel.authState.observe(viewLifecycleOwner) {
when (it) {
AuthResult.Loading -> binding.progressBar.visibility = View.VISIBLE
is AuthResult.Error -> {
binding.progressBar.visibility = View.GONE
Toast.makeText(requireContext(), it.e.message.toString(), Toast.LENGTH_LONG)
.show()
}
is AuthResult.Success -> {
findNavController().navigate(R.id.action_authorizationFragment_to_homeFragment)
}
}
}
binding.signIn.setOnClickListener {
val allValidation = inputList.map { it.isValid() }
if (allValidation.all { it }) {
viewModel.sendCredentials(
email = binding.authMail.text(),
password = binding.authPassword.text()
)
}
}
binding.navigateToSignUp.setOnClickListener {
findNavController().navigate(R.id.action_authorizationFragment_to_registrationFragment)
}
}
}
AuthorizationFragment — класс фрагмента, наследуется от BaseFragment
— @AndroidEntryPoint — аннотация, которая обозначает, что этот класс является Android-компонентом, который должен быть внедрен с помощью Hilt.
— bindingInflater — переопределенное свойство из BaseFragment, которое представляет лямбда-выражение для создания привязки к представлению (binding). В данном случае, оно использует FragmentAuthorizationBinding.inflate для создания привязки FragmentAuthorizationBinding на основе разметки.
— viewModel — экземпляр AuthorizationViewModel.
— val inputList = listOf (binding.authMail, binding.authPassword) — создается список inputList, который содержит ссылки на представления для ввода адреса электронной почты и пароля.
— viewModel.authState.observe (viewLifecycleOwner) { authResult → … } — устанавливается наблюдатель (observe) на свойство authState из viewModel. Когда состояние аутентификации меняется, код внутри лямбда-выражения будет выполняться. Внутри лямбда-выражения определена логика для обработки каждого возможного значения authResult:
— Если значение authResult является AuthResult.Loading, то прогресс-бар (binding.progressBar) делается видимым.
— Если значение authResult является AuthResult.Error, то прогресс-бар скрывается, и отображается уведомление (Toast) с сообщением об ошибке из объекта authResult.e.
— Если значение authResult является AuthResult.Success, то происходит переход к другому фрагменту с помощью findNavController ().navigate (R.id.action_authorizationFragment_to_homeFragment).
— binding.signIn.setOnClickListener { … } — устанавливается слушатель для кнопки signIn. Когда кнопка нажимается, выполняется код внутри лямбда-выражения. Внутри лямбда-выражения происходит валидация полей ввода (inputList), и если все поля ввода прошли валидацию (allValidation.all { it }), вызывается метод viewModel.sendCredentials, который отправляет учетные данные (email и password) на сервер (firebase) для аутентификации.
— binding.navigateToSignUp.setOnClickListener { … } — устанавливается слушатель для кнопки navigateToSignUp. Когда кнопка нажимается, выполняется код внутри лямбда-выражения. Внутри лямбда-выражения происходит переход к другому фрагменту с помощью findNavController ().navigate (R.id.action_authorizationFragment_to_registrationFragment).
@AndroidEntryPoint
class RegistrationFragment : BaseFragment() {
override val bindingInflater: (LayoutInflater, ViewGroup?) -> FragmentRegistrationBinding =
{ inflater, container ->
FragmentRegistrationBinding.inflate(inflater, container, false)
}
private val viewModel: RegistrationViewModel by viewModels()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val inputList = listOf(
binding.signUpEmail,
binding.signUpPasswordLayout
)
viewModel.authState.observe(viewLifecycleOwner) {
when (it) {
AuthResult.Loading -> binding.progressBarRegistration.visibility = View.VISIBLE
is AuthResult.Error -> {
binding.progressBarRegistration.visibility = View.GONE
Toast.makeText(requireContext(), it.e.message.toString(), Toast.LENGTH_LONG)
.show()
}
is AuthResult.Success -> {
findNavController().navigate(R.id.action_registrationFragment_to_homeFragment)
}
}
}
binding.startSignUp.setOnClickListener {
val allValidation = inputList.map { it.isValid() }
if (allValidation.all { it }) {
viewModel.sendCredentials(
email = binding.signUpEmail.text(),
password = binding.signUpPasswordLayout.text()
)
}
}
}
}
RegistrationFragment устроен аналогично, за исключением некоторых деталей в onViewCreated
Теперь можно запустить приложение и посмотреть, что получилось.
Ввод некорректных данных
Загрузка при вводе данных
Если данные не заполнены, регистрация не происходит
Если пароли не совпадают, появляется надпись и регистрация не происходит
Итог
Данное приложение получилось расширяемым, его легко можно интегрировать в свой проект, можно заменить firebase на свой сервер, добавить новые поля для ввода данных и написать свою логику их обработки, на основании уже написанного кода.
Если вы хотите запустить приложение на своем устройстве, то вам понадобится свой google-services.json. Более подробно ознакомиться с кодом можно тут.