Kotlin
October 23, 2023

Реализация экранов авторизации и регистрации с помощью 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 - стиль, который будет применен к макету.

В классе определены следующие члены:

  1. errorMessageId - абстрактное свойство, обозначающее идентификатор строки ресурса сообщения об ошибке.
  2. textWatcher - экземпляр класса RegistrationTextWatcher для отслеживания изменений текста в поле ввода.
  3. text() - возвращает строку текста из поля ввода.
  4. onAttachedToWindow() - переопределенный метод, вызывающий родительскую реализацию и добавляющий textWatcher к полю ввода.
  5. isValid() - переопределенный метод интерфейса Validation, возвращающий true, если введенное значение считается допустимым, и false - в противном случае.
  6. 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 переопределяет два метода:

  1. innerIsValid() - этот метод проверяет, является ли введенный текст в поле электронной почты валидным.
  2. В данной реализации используется паттерн который доступен в Java Patterns.EMAIL_ADDRESS.matcher(text()).matches(), чтобы проверить, соответствует ли введенный текст стандартному формату электронной почты. Если текст соответствует формату, то метод возвращает true, в противном случае - false.
  3. 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,}\quot;
    }
}

Данный класс мало отличается от предыдущего, за исключением позиций

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.

<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".core.ui.MainActivity">

    <androidx.fragment.app.FragmentContainerView
        android:id="@+id/nav_host_fragment"
        android:name="androidx.navigation.fragment.NavHostFragment"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        app:defaultNavHost="true"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        app:navGraph="@navigation/nav_graph" />

</androidx.constraintlayout.widget.ConstraintLayout>

Разметка AuthorizationFragment.

<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".features.ui.authorization.AuthorizationFragment">

    <ru.anb.passwordapp.features.ui.input.PasswordInput
        android:id="@+id/auth_password"
        style="@style/Widget.Material3.TextInputLayout.OutlinedBox"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_marginHorizontal="16dp"
        android:layout_marginTop="32dp"
        android:hint="@string/password"
        app:endIconDrawable="@drawable/eye"
        app:endIconMode="password_toggle"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/auth_mail">

        <com.google.android.material.textfield.TextInputEditText
            android:id="@+id/password_edittext"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:inputType="textPassword" />

    </ru.anb.passwordapp.features.ui.input.PasswordInput>

    <ru.anb.passwordapp.features.ui.input.MailInput
        android:id="@+id/auth_mail"
        style="@style/Widget.Material3.TextInputLayout.OutlinedBox"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_marginHorizontal="16dp"
        android:layout_marginTop="64dp"
        android:hint="@string/email"
        app:endIconDrawable="@drawable/clear"
        app:endIconMode="clear_text"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent">

        <com.google.android.material.textfield.TextInputEditText
            android:id="@+id/mail_edittext"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:inputType="text" />

    </ru.anb.passwordapp.features.ui.input.MailInput>

    <Button
        android:id="@+id/sign_in"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginTop="32dp"
        android:text="@string/sign_in"
        app:layout_constraintEnd_toStartOf="@+id/navigate_to_sign_up"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/auth_password" />

    <Button
        android:id="@+id/navigate_to_sign_up"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginTop="32dp"
        android:text="@string/sign_up"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toEndOf="@+id/sign_in"
        app:layout_constraintTop_toBottomOf="@+id/auth_password" />

    <FrameLayout
        android:id="@+id/progress_bar"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:background="@color/transparent"
        android:visibility="gone"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent">

        <ProgressBar
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_gravity="center" />
    </FrameLayout>

</androidx.constraintlayout.widget.ConstraintLayout>

Разметка PasswordLayout.

<merge xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    tools:parentTag="android.widget.LinearLayout">

    <ru.anb.passwordapp.features.ui.input.PasswordInput
        android:id="@+id/password_layout"
        style="@style/Widget.Material3.TextInputLayout.OutlinedBox"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_marginHorizontal="16dp"
        android:layout_marginVertical="16dp"
        android:hint="@string/password"
        app:endIconDrawable="@drawable/eye"
        app:endIconMode="password_toggle">

        <com.google.android.material.textfield.TextInputEditText
            android:id="@+id/password_edit_text"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:inputType="textPassword" />

    </ru.anb.passwordapp.features.ui.input.PasswordInput>

    <ru.anb.passwordapp.features.ui.input.PasswordInput
        android:id="@+id/password_repeat_layout"
        style="@style/Widget.Material3.TextInputLayout.OutlinedBox"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_marginHorizontal="16dp"
        android:hint="@string/check_password"
        app:endIconDrawable="@drawable/eye"
        app:endIconMode="password_toggle">

        <com.google.android.material.textfield.TextInputEditText
            android:id="@+id/password_repeat_edit_text"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:inputType="textPassword" />

    </ru.anb.passwordapp.features.ui.input.PasswordInput>

    <TextView
        android:id="@+id/error_text"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:gravity="center"
        android:textColor="@color/red" />

</merge>

Разметка RegistrationFragment.

<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent">


    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:gravity="center_horizontal"
        android:orientation="vertical"
        tools:context=".features.auth.ui.RegisterFragment">

        <ru.anb.passwordapp.features.ui.input.MailInput
            android:id="@+id/sign_up_email"
            style="@style/Widget.Material3.TextInputLayout.OutlinedBox"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:layout_marginHorizontal="16dp"
            android:layout_marginTop="64dp"
            android:hint="@string/email"
            android:inputType="text"
            app:endIconDrawable="@drawable/clear"
            app:endIconMode="clear_text">

            <com.google.android.material.textfield.TextInputEditText
                android:id="@+id/register_login"
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:inputType="text" />

        </ru.anb.passwordapp.features.ui.input.MailInput>

        <ru.anb.passwordapp.features.ui.input.PasswordLayout
            android:id="@+id/sign_up_password_layout"
            android:layout_width="match_parent"
            android:layout_height="match_parent" />


        <Button
            android:id="@+id/start_sign_up"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginTop="16dp"
            android:text="@string/sign_up" />

    </LinearLayout>

    <FrameLayout
        android:id="@+id/progress_bar_registration"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:background="@color/transparent"
        android:visibility="gone">

        <ProgressBar
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_gravity="center" />
    </FrameLayout>
</FrameLayout>

Как можно заметить, вместо обычного TextInputLayout используются созданные Custom View. Так же, в разметке, стоит обратить внимание на такие строки как app:endIconMode="password_toggle" которые позволяют скрывать и показывать что вводит пользователь в полях password, а так же строки app:endIconMode="clear_text" которые позволяют стереть то, что было ранее написано в поле email.

Навигация между фрагментами будет осуществляться с помощью Navigation Components. Так же, в приложении, для внедрения зависимостей будет использован Hilt.

Для этого понадобятся:

  1. класс App (нужно указать его в манифесте)
@HiltAndroidApp
class App : Application()
  1. интерфейс AppComponent
@Component
interface AppComponent {
}
  1. класс AuthModule - в этом модуле подключается firebase.
@InstallIn(SingletonComponent::class)
@Module
class AuthModule {

    @Provides
    @Singleton
    fun provideFirebaseAuth(): FirebaseAuth {
        return Firebase.auth
    }
}
  1. абстрактный класс 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 и использовать его внутри класса.

  1. signInWithEmailAndPassword - функция, которая выполняет аутентификацию пользователя с использованием электронной почты и пароля. Она вызывает метод signInWithEmailAndPassword из FirebaseAuth для выполнения фактической операции аутентификации. Если операция завершилась успешно, создается объект User.Base с электронной почтой и идентификатором пользователя, и возвращается AuthResult.Success. Если произошла ошибка, возвращается AuthResult.Error
  2. signUpWithEmailAndPassword – функция устроена аналогично.

Для того чтобы не писать один и тот же код по несколько раз, можно создать класс BaseFragment и класс BaseViewModel , в которых будет описана общая функциональность.

abstract class BaseFragment<B : ViewBinding> : 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.

  1. bindingInflater - абстрактное свойство, которое должно быть переопределено в подклассах BaseFragment. Оно представляет лямбда-выражение, принимающее LayoutInflater и ViewGroup?, и возвращающее B (тип, унаследованный от ViewBinding).
  2. _binding - приватное свойство, представляющее привязку к представлению (binding) фрагмента. Оно инициализируется значением null при создании фрагмента.
  3. binding - защищенное свойство, которое обеспечивает доступ к экземпляру привязки к представлению (binding).
  4. onCreateView - переопределенный метод из класса Fragment, который вызывается при создании представления фрагмента.
  5. onDestroyView - переопределенный метод из класса Fragment, который вызывается при уничтожении фрагмента. В этом методе привязка к представлению (_binding) устанавливается в null для освобождения ресурсов.
abstract class BaseViewModel : ViewModel() {

    abstract val sendRequest: suspend (String, String) -> AuthResult

    private val _authState = MutableLiveData<AuthResult>()

    val authState: LiveData<AuthResult> 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.

  1. sendRequest - абстрактная функция, которая должна быть переопределена в подклассах BaseViewModel. В данном случае, эта функция принимает две строки (String) - адрес электронной почты и пароль, и должна возвращать объект типа AuthResult. Это позволяет использовать подклассам BaseViewModel собственную логику для отправки запросов на сервер аутентификации.
  2. _authState - приватное свойство типа MutableLiveData<AuthResult>, которое представляет внутреннее состояние аутентификации. Оно инициализируется экземпляром MutableLiveData, использующим тип AuthResult.
  3. authState - открытое свойство типа LiveData<AuthResult>, которое предоставляет доступ к текущему состоянию аутентификации через authState.value. Отслеживание этого свойства позволяет обновлять пользовательский интерфейс в соответствии с изменениями состояния аутентификации.
  4. 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.

  1. @HiltViewModel - аннотация, которая обозначает, что этот класс является ViewModel, и его зависимости должны быть внедрены с помощью Hilt.
  2. authRepository - зависимость типа AuthRepository, которая внедряется в конструктор AuthorizationViewModel с использованием аннотации @Inject, через механизм внедрения зависимостей Hilt.
  3. 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<FragmentAuthorizationBinding>() {
    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<FragmentAuthorizationBinding>.
  • @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<FragmentRegistrationBinding>() {
    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. Более подробно ознакомиться с кодом можно тут.

Источник