В этой статье мы расскажем, как реализовать аутентификацию во Flutter с помощью Supabase и BLoC.

Введение

Что такое Supabase?

Supabase — это альтернатива Firebase с открытым исходным кодом, представляющая платформу Backend as a Service, которая позволяет разработчикам легко создавать серверные решения и управлять ими в облаке. Он предоставляет основные службы, такие как база данных, аутентификация и хранилище.

Что такое BLoC?

BLoC — одна из самых популярных библиотек управления состоянием во Flutter, которая помогает разработчикам легко отделить представление и бизнес-логику. Каждый BLoC состоит из трех основных строительных блоков:

  • Блок
  • Состояние
  • Событие

Для простоты каждое взаимодействие с пользовательским интерфейсом создает событие, которое обрабатывается блоком. Bloc знает, как обрабатывать определенные события и какое состояние возвращать пользовательскому интерфейсу. В зависимости от полученного состояния наш пользовательский интерфейс обновляется.

Архитектура

В этом примере мы организуем наш проект, используя подход «сначала слой», когда каждая функция размещается в трех слоях:

  • данные — уровень данных взаимодействует с внешними службами, такими как API, хранилище и база данных. В этом примере уровень данных взаимодействует с Supabase.
  • домен — уровень домена преобразует данные из уровня данных и управляет ими.
  • презентация —  Уровень презентации представляет собой часть взаимодействия с пользователем в нашем приложении. В этом примере уровень представления содержит блоки и виджеты представления.

Запустить проект Supabase

Начнем с Supabase.

Откройте supabase.com и нажмите Начать проект. После успешной регистрации войдите в свою учетную запись и создайте проект, указав имя проекта, пароль базы данных и регион. В этом примере мы будем использовать базовый проект бесплатного плана.

Через несколько минут наш проект настроен, а наша база данных и конечные точки API готовы.

В этой статье мы рассмотрим вход и регистрацию с помощью пароля. Откройте раздел «Аутентификация» и убедитесь, что поставщик электронной почты включен с активированной опцией подтверждения электронной почты.

Кроме того, нам нужно будет настроить глубокую ссылку, чтобы возвращать пользователя в приложение, когда он открывает ссылку из электронного письма для регистрации. В этом примере мы будем использовать io.supabase.flutterexample://signup-callback, поэтому нам потребуется обновить URL-адрес сайта и добавить новый URL-адрес перенаправления. .

Настройка проекта Flutter

Первый шаг — создать три основных слоя нашего приложения, которые будут представлены папками. Кроме того, мы создадим основную папку, в которой будут создаваться общие файлы, такие как утилиты, маршрутизаторы, модули и т. д. Наша окончательная структура папок будет следующей:

  • основной
  • данные
  • домен
  • презентация

После того, как структура папок организована, нам нужно будет установить наши зависимости:

dependencies:
  flutter:
    sdk: flutter
  
  supabase_flutter: ^1.10.6
  injectable: ^2.1.2
  get_it: ^7.6.0
  flutter_bloc: ^8.1.3
  equatable: ^2.0.5

....

dev_dependencies:
  injectable_generator:  
  build_runner:

В этом примере мы реализуем внедрение зависимостей с помощью пакетов injectable и get_it. Мы добавим конфигурацию внедрения зависимостей в файл dependency_injection.dart.

import 'package:flutter_examples/dependency_injection.config.dart';
import 'package:get_it/get_it.dart';
import 'package:injectable/injectable.dart';

final getIt = GetIt.instance;

@InjectableInit()
void configureDependencies() => getIt.init();

На следующем шаге мы инициализируем Supabase в методе main() в main.dart:

void main() async {
  await Supabase.initialize(
      url: 'YOUR_PROJECT_URL',
      anonKey: 'PUBLIC-ANON-KEY',
    );

   configureDependencies()
}

YOUR_PROJECT_URL и PUBLIC_ANON_KEY можно найти на вкладке настроек API в разделе настроек проекта в Supabase.

Чтобы покрыть аутентификацию с помощью Supabase, нам нужно использовать GoTrueClient, предоставленный SupabaseClient. Мы создадим app_module.dart в основной папке и зарегистрируем модуль, который сделает наши сторонние зависимости доступными для внедрения в проект.

import 'package:injectable/injectable.dart';
import 'package:supabase_flutter/supabase_flutter.dart';

@module
abstract class AppModule {
  GoTrueClient get supabaseAuth => Supabase.instance.client.auth;
}

Чтобы включить глубокие ссылки Supabase в нашем приложении, нам потребуется обновить файлы ios/Runner/Info.plist и android/app/src/main/AndroidManifest.xml.

Отредактируйте ios/Runner/Info.plist и добавьте CFBundleURLTypes:

<key>CFBundleURLTypes</key>
<array>
	<dict>
		<key>CFBundleTypeRole</key>
		<string>Editor</string>
		<key>CFBundleURLSchemes</key>
		<array>
			<string>io.supabase.flutterexample</string>
		</array>
	</dict>
</array>

Отредактируйте файл android/app/src/main/AndroidManifest.xml и добавьте фильтр намерений:

<intent-filter>
  <action android:name="android.intent.action.VIEW" />
  <category android:name="android.intent.category.DEFAULT" />
  <category android:name="android.intent.category.BROWSABLE" />
  <data
    android:scheme="io.supabase.flutter-example"
    android:host="signup-callback" />
</intent-filter>

Репозиторий аутентификации

Мы создадим IAuthenticationRepository, который представляет нашу абстракцию над деталями реализации того, как мы аутентифицируемся в приложении. Используя абстракцию, мы можем позже изменить детали реализации, не затрагивая наше приложение. На уровне домена мы создадим i_authentication_repository_dart.

import 'package:supabase_flutter/supabase_flutter.dart';

abstract class IAuthenticationRepository {
  Future<void> signInWithEmailAndPassword({
    required String email,
    required String password,
  });
  Future<void> signUpWithEmailAndPassword({
    required String email,
    required String password,
  });
  Future<void> signOut();
  Stream<User?> getCurrentUser();
  User? getSignedInUser();
}

На уровне данных мы создадим файл authentication_repository.dart, содержащий детали нашей внутренней реализации аутентификации с использованием Supabase. AuthenticationRepository представляет собой реализацию IAuthenticationRepository и использует механизмы Supabase для аутентификации путем внедрения GoTrueClient.

import 'package:flutter_examples/domain/repositores/authentication/i_authentication_repository.dart';
import 'package:injectable/injectable.dart';
import 'package:supabase_flutter/supabase_flutter.dart';

@Injectable(as: IAuthenticationRepository)
class AuthenticationRepository implements IAuthenticationRepository {
  final GoTrueClient _supabaseAuth;
  static const String _redirectUrl =
      'io.supabase.flutterexample://signup-callback';

  AuthenticationRepository(this._supabaseAuth);

  @override
  Future<void> signInWithEmailAndPassword({
    required String email,
    required String password,
  }) async =>
      await _supabaseAuth.signInWithPassword(password: password, email: email);

  @override
  Future<void> signUpWithEmailAndPassword({
    required String email,
    required String password,
  }) async =>
      await _supabaseAuth.signUp(
          password: password, email: email, emailRedirectTo: _redirectUrl);

  @override
  Future<void> signOut() async => await _supabaseAuth.signOut();

  @override
  Stream<User?> getCurrentUser() =>
      _supabaseAuth.onAuthStateChange.map((event) => event.session?.user);

  @override
  User? getSignedInUser() => _supabaseAuth.currentUser;
}

Блок авторизации

На уровне представления мы создадим папку авторизации с AuthBloc.

AuthBloc отвечает за управление глобальным состоянием аутентификации в нашем приложении. Он получает события AuthEvents и преобразует их в определенные состояния AuthStates.

AuthBloc внедряет IAutenticationRepository и содержит бизнес-логику для начальной проверки аутентификации, подписки на изменения состояния аутентификации и выхода из приложения.

import 'dart:async';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_examples/domain/repositores/authentication/i_authentication_repository.dart';
import 'package:injectable/injectable.dart';
import 'package:supabase_flutter/supabase_flutter.dart';

part 'auth_event.dart';
part 'auth_state.dart';

@injectable
class AuthBloc extends Bloc<AuthEvent, AuthState> {
  final IAuthenticationRepository _authenticationRepository;
  StreamSubscription<User?>? _userSubscription;

  AuthBloc(this._authenticationRepository) : super(AuthInitial()) {
    on<AuthInitialCheckRequested>(_onInitialAuthChecked);
    on<AuthLogoutButtonPressed>(_onLogoutButtonPressed);
    on<AuthOnCurrentUserChanged>(_onCurrentUserChanged);

    _startUserSubscription();
  }

  Future<void> _onInitialAuthChecked(
      AuthInitialCheckRequested event, Emitter<AuthState> emit) async {
    User? signedInUser = _authenticationRepository.getSignedInUser();
    signedInUser != null
        ? emit(AuthUserAuthenticated(signedInUser))
        : emit(AuthUserUnauthenticated());
  }

  Future<void> _onLogoutButtonPressed(
      AuthLogoutButtonPressed event, Emitter<AuthState> emit) async {
    await _authenticationRepository.signOut();
  }

  Future<void> _onCurrentUserChanged(
          AuthOnCurrentUserChanged event, Emitter<AuthState> emit) async =>
      event.user != null
          ? emit(AuthUserAuthenticated(event.user!))
          : emit(AuthUserUnauthenticated());
  
  void _startUserSubscription() => _userSubscription = _authenticationRepository
      .getCurrentUser()
      .listen((user) => add(AuthOnCurrentUserChanged(user)));

  @override
  Future<void> close() {
    _userSubscription?.cancel();
    return super.close();
  }
}

Чтобы быть ответственным за наблюдение за изменениями состояния аутентификации, процесс выхода из системы и начальную проверку состояния аутентификации, AuthBloc будет получать три типа AuthEvents.

part of 'auth_bloc.dart';

abstract class AuthEvent {}

class AuthInitialCheckRequested extends AuthEvent {}

class AuthOnCurrentUserChanged extends AuthEvent {
  final User? user;

  AuthOnCurrentUserChanged(this.user);
}

class AuthLogoutButtonPressed extends AuthEvent {}

В зависимости от определенных событий AuthBloc будет генерировать два типа состояний — одно для успешной аутентификации и одно, когда пользователь не аутентифицирован.

part of 'auth_bloc.dart';

abstract class AuthState {}

class AuthInitial extends AuthState {}

class AuthUserAuthenticated extends AuthState {
  final User user;

  AuthUserAuthenticated(this.user);
}

class AuthUserUnauthenticated extends AuthState {}

Чтобы добиться того, чтобы мы прослушивали изменения состояния аутентификации в нашем приложении глобально, мы создадим BlocProvider для AuthBloc в методе main() в main.dart:

void main() async {
  await Supabase.initialize(
    url: 'YOUR_PROJECT_URL',
    anonKey: 'PUBLIC-ANON-KEY',
  );
  
  configureDependencies();
  
  runApp(BlocProvider(
    create: (_) => getIt<AuthBloc>()..add(AuthInitialCheckRequested()),
    child: const FlutterExampleApp(),
  ));
}

После создания экземпляра AuthBloc будет добавлено событие проверки исходного состояния аутентификации и открыта подписка на изменения аутентификации в приложении.

Поскольку BlocProvider создается в main.dart, мы гарантируем, что подписка на изменения аутентификации будет закрыта при закрытии приложения.

FlutterExampleApp представляет виджет нашего основного приложения, и у него будет BlocConsumer, который будет обновлять наш пользовательский интерфейс в зависимости от выдаваемых состояний следующим образом:

  • После проверки начальной аутентификации отобразится экран-заставка.
  • Если пользователь аутентифицирован, он будет перенаправлен на главный экран.
  • Если пользователь не аутентифицирован, он будет перенаправлен на страницу входа.
class FlutterExampleApp extends StatelessWidget {
  const FlutterExampleApp({super.key});
  
  @override
  Widget build(BuildContext context) => MaterialApp(
      title: 'Flutter example',
      home: BlocConsumer<AuthBloc, AuthState>(
        listener: (context, state) {
          if (state is AuthUserUnauthenticated) {
            navigateAndReplace(context, const LoginPage());
          }
          if (state is AuthUserAuthenticated) {
            navigateAndReplace(context, const HomePage());
          }
        },
        builder: (context, state) => const SplashScreen(),
      ));
}

На нашей Главной странице мы добавим:

  • BlocListener перейдет на страницу LoginPage, если состояние аутентификации изменится на неаутентифицированное.
  • _LogoutButton для выхода из приложения по щелчку. Поскольку наш AuthBloc предоставляется глобально, мы можем прочитать его из контекста и добавить событие AuthLogoutButtonPressed.
class HomePage extends StatelessWidget {
  const HomePage({super.key});

  @override
  Widget build(BuildContext context) => Scaffold(
      appBar: AppBar(title: const Text('Home')),
      body: Center(
        child: BlocListener<AuthBloc, AuthState>(
          listener: (context, state) {
            if (state is AuthUserUnauthenticated) {
              navigateAndReplace(context, const LoginPage());
            }
          },
          child: const _LogoutButton(),
        ),
      ));
}

class _LogoutButton extends StatelessWidget {
  const _LogoutButton();

  @override
  Widget build(BuildContext context) => ElevatedButton(
        onPressed: () =>
            context.read<AuthBloc>().add(AuthLogoutButtonPressed()),
        child: const Text('Logout'),
      );
}

Войти

На нашем уровне представления мы создадим папку входа, которая будет содержать блок входа и страницу входа.

LoginBloc отвечает за реализацию процесса входа в приложение.

LoginBloc вводит IAuthenticationRepository и содержит бизнес-логику для обработки изменений в текстовых полях Email и Password, а также для обработки нажатия кнопки входа.

import 'dart:async';
import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:injectable/injectable.dart';

import '../../../domain/repositores/authentication/i_authentication_repository.dart';
import '../../../domain/repositores/entities/email_address.dart';
import '../../../domain/repositores/entities/password.dart';

part 'login_event.dart';
part 'login_state.dart';

@Injectable()
class LoginBloc extends Bloc<LoginEvent, LoginState> {
  final IAuthenticationRepository _authenticationRepository;
  
  LoginBloc(this._authenticationRepository) : super(const LoginState()) {
    on<LoginEmailAddressChanged>(_onEmailAddressChanged);
    on<LoginPasswordChanged>(_onPasswordChanged);
    on<LoginButtonPressed>(_onLoginButtonPressed);
  }
  
  Future<void> _onLoginButtonPressed(
    LoginButtonPressed event,
    Emitter<LoginState> emit,
  ) async {
    if (!state.isValid) return;

    emit(state.copyWith(formSubmissionStatus: FormSubmissionStatus.submitting));
    
    try {
      await _authenticationRepository.signInWithEmailAndPassword(
        email: state.email.value,
        password: state.password.value,
      );
      emit(state.copyWith(formSubmissionStatus: FormSubmissionStatus.success));
    } catch (_) {
      emit(state.copyWith(formSubmissionStatus: FormSubmissionStatus.failure));
    }
  }

  Future<void> _onEmailAddressChanged(
    LoginEmailAddressChanged event,
    Emitter<LoginState> emit,
  ) async =>
      emit(state.copyWith(
        email: EmailAddress.create(event.value),
        formSubmissionStatus: FormSubmissionStatus.initial,
      ));

  Future<void> _onPasswordChanged(
    LoginPasswordChanged event,
    Emitter<LoginState> emit,
  ) async =>
      emit(state.copyWith(
        password: Password.create(event.value),
        formSubmissionStatus: FormSubmissionStatus.initial,
      ));
}

В противоположность состоянию Auth, которое представлено подклассами, LoginState представлено одним классом.

part of 'login_bloc.dart';

enum FormSubmissionStatus {
  initial,
  submitting,
  success,
  failure,
}

class LoginState extends Equatable {
  final EmailAddress email;
  final Password password;
  final FormSubmissionStatus formSubmissionStatus;

  const LoginState({
    this.email = EmailAddress.empty,
    this.password = Password.empty,
    this.formSubmissionStatus = FormSubmissionStatus.initial,
  });

  LoginState copyWith({
    EmailAddress? email,
    Password? password,
    FormSubmissionStatus? formSubmissionStatus,
  }) =>
      LoginState(
        email: email ?? this.email,
        password: password ?? this.password,
        formSubmissionStatus: formSubmissionStatus ?? this.formSubmissionStatus,
      );

  @override
  List<Object?> get props => [
        email,
        password,
        formSubmissionStatus,
      ];

  bool isSubmitting() =>
        formSubmissionStatus == FormSubmissionStatus.submitting;
 
   bool isSubmissionSuccessOrFailure() =>
        formSubmissionStatus == FormSubmissionStatus.success ||
        formSubmissionStatus == FormSubmissionStatus.failure;
  
  bool get isValid => !email.hasError && !password.hasError;
}

В верхней части класса LoginState находится перечисление FormSubmissionStatus, определяющее различные состояния отправки формы.

Класс LoginState имеет три свойства:

  • final EmailAddress email: представляет объект значения и представляет адрес электронной почты, указанный в форме входа.
  • final Password password: представляет объект значения и пароль, введенный в форме входа.
  • final FormSubmissionStatus formSubmissionStatus: представляет текущий статус отправки формы.

Объект значения EmailAddress имеет конструктор фабрики create для создания допустимого или недопустимого экземпляра EmailAddress. Статическая константа empty типа EmailAddress представляет пустой или неинициализированный экземпляр EmailAddress, используемый при инициализации начального состояния LoginState.

import 'package:equatable/equatable.dart';

class EmailAddress extends Equatable {
  final String value;
  final String errorMessage;
  final bool hasError;

  const EmailAddress({
    required this.value,
    required this.errorMessage,
    required this.hasError,
  });

  factory EmailAddress.create(String value) {
    if (value.isEmpty ||
        !RegExp(r"^[a-zA-Z0-9.a-zA-Z0-9.!#$%&'*+-/=?^_`{|}~]+@[a-zA-Z0-9]+\\.[a-zA-Z]+")
            .hasMatch(value)) {
      return EmailAddress(
          value: value,
          errorMessage: 'Please insert valid email address',
          hasError: true);
    }
    return EmailAddress(value: value, errorMessage: '', hasError: false);
  }

  @override
  List<Object?> get props => [value, errorMessage, hasError];
  
  static const empty =
      EmailAddress(value: '', errorMessage: '', hasError: false);
}

Объект значения Password имеет конструктор create factory для создания допустимого или недопустимого экземпляра Password. Статическая константа empty типа Password представляет собой пустой или неинициализированный экземпляр Password, используемый при инициализации начального состояния LoginState.

import 'package:equatable/equatable.dart';

class Password extends Equatable {
  final String value;
  final String errorMessage;
  final bool hasError;

  const Password({
    required this.value,
    required this.errorMessage,
    required this.hasError,
  });

  factory Password.create(String value) {
    if (value.isEmpty || value.length < 6) {
      return Password(
          value: value,
          errorMessage: 'Password must be at least 6 characters length.',
          hasError: true);
    }
    return Password(value: value, errorMessage: '', hasError: false);
  }

  @override
  List<Object?> get props => [value, errorMessage, hasError];

  static const empty = Password(value: '', errorMessage: '', hasError: false);
}

Метод copyWith позволяет создать новый экземпляр LoginState с обновленными значениями для определенных свойств. Он возвращает новый объект LoginState с нужными изменениями, оставляя другие свойства без изменений. Этот метод вызывается в LoginBloc внутри _onEmailAddressChanged и _onPasswordChanged, который будет обновлять LoginState в зависимости от вставленного значения в текстовые поля.

Страница LoginPage представляет собой экран с полями электронной почты и пароля, кнопкой входа и кнопкой регистрации. Это виджет Scaffold, обернутый BlocProvider, который создает LoginBloc. Виджет LoginPage содержит виджет _LoginForm.

class _LoginForm extends StatelessWidget {
  const _LoginForm();

  @override
  Widget build(BuildContext context) => BlocListener<LoginBloc, LoginState>(
        listenWhen: (previous, current) =>
            current.isSubmissionSuccessOrFailure(),
        listener: (context, state) {
          if (state.formSubmissionStatus == FormSubmissionStatus.success) {
            navigateAndReplace(context, const HomePage());
          }
          if (state.formSubmissionStatus == FormSubmissionStatus.failure) {
            showErrorScaffold(
                context, 'Login failed. Please check your credentials.');
          }
        },
        child: Column(
          children: const [
            _EmailInputField(),
            SizedBox(height: 8.0),
            _PasswordInputField(),
            SizedBox(height: 8.0),
            _LoginButton()
          ],
        ),
      );
}

Виджет _LoginForm отслеживает изменения в состоянии LoginBloc для выполнения определенных действий в зависимости от статуса отправки формы входа. Он содержит обратный вызов listenWhen, который определяет, должен ли слушатель реагировать на изменение состояния. В этом примере он прослушивает, когда текущее состояние отправки формы является успешным или неудачным. В случае FormSubmissionStatus.success пользователь будет перенаправлен на домашнюю страницу. В случае FormSubmissionStatus.failure будет отображаться сообщение об ошибке.

_EmailInputField – это виджет, представляющий собой поле ввода для ввода адреса электронной почты. Он использует виджет BlocBuilder для перестройки пользовательского интерфейса на основе изменений в поле emailAddress в LoginState. Он использует обратный вызов buildWhen, который перестраивает виджет только тогда, когда текущее электронное письмо в LoginState отличается от предыдущего электронного письма. Это может значительно повысить производительность приложения, поскольку мы избежим ненужных перестроек виджета.

Значение текущего адреса электронной почты в LoginState будет изменено при вводе пользователем путем вызова метода обратного вызова onChanged в виджете TextField, который отправит событие LoginEmailAddressChanged в LoginBloc и передаст новое значение адреса электронной почты. При каждом изменении адреса электронной почты будет создаваться новый экземпляр EmailAddress.

Если EmailAddress создан в недопустимом состоянии и содержит ошибку, оформление ошибки будет применено к TextField.

class _EmailInputField extends StatelessWidget {
  const _EmailInputField();

@override
  Widget build(BuildContext context) => BlocBuilder<LoginBloc, LoginState>(
      buildWhen: (previous, current) => current.email != previous.email,
      builder: (context, state) => TextField(
            onChanged: (email) => context
                .read<LoginBloc>()
                .add(LoginEmailAddressChanged(value: email)),
            keyboardType: TextInputType.emailAddress,
            decoration: InputDecoration(
              labelText: 'Email address',
              errorText: state.email.hasError ? 
                         state.email.errorMessage : null,
            ),
          ));
}

_PasswordInputField – это виджет, представляющий собой поле ввода для ввода пароля. Виджет будет перестроен только тогда, когда текущий пароль в LoginState отличается от предыдущего пароля. При вводе каждого пользователя будет отправлено событие LoginPasswordChanged, и в LoginState будет создан новый экземпляр Password.

Если пароль создан в недопустимом состоянии и содержит ошибку, оформление ошибки будет применено к текстовому полю.

class _PasswordInputField extends StatelessWidget {
  const _PasswordInputField();

  @override
  Widget build(BuildContext context) => BlocBuilder<LoginBloc, LoginState>(
        buildWhen: (previous, current) => current.password != previous.password,
        builder: (context, state) => TextFormField(
          onChanged: (password) => context
              .read<LoginBloc>()
              .add(LoginPasswordChanged(value: password)),
          obscureText: true,
          decoration: InputDecoration(
            labelText: 'Password',
            errorText:
                state.password.hasError ? state.password.errorMessage : null,
          ),
        ),
      );
}

_LoginButton — это виджет ElevatedButton, который имеет метод обратного вызова onPressed и отправляет событие LoginButtonPressed на нажатие кнопки LoginBloc.

class _LoginButton extends StatelessWidget {
  const _LoginButton();

  @override
  Widget build(BuildContext context) => BlocBuilder<LoginBloc, LoginState>(
        builder: (context, state) => ElevatedButton(
          onPressed: () => state.isSubmitting() || !state.isValid
              ? null
              : context.read<LoginBloc>().add(LoginButtonPressed()),
          child: Text(state.isSubmitting() ? 'Submitting' : 'Login'),
        ),
      );
}

Постановка на учет

Процесс регистрации в приложении описан в RegistrationBloc. После заполнения формы регистрации пользователь получит электронное письмо со ссылкой для активации. После перехода по ссылке активации пользователь будет перенаправлен в приложение и аутентифицирован.

Кодовая база такая же, как и для процесса входа.

РегистрацияБлок

import 'dart:async';

import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_examples/domain/repositores/authentication/i_authentication_repository.dart';
import 'package:injectable/injectable.dart';

import '../../../domain/repositores/entities/email_address.dart';
import '../../../domain/repositores/entities/password.dart';

part 'registration_event.dart';
part 'registration_state.dart';

@Injectable()
class RegistrationBloc extends Bloc<RegistrationEvent, RegistrationState> {
  final IAuthenticationRepository _authenticationRepository;

  RegistrationBloc(this._authenticationRepository)
      : super(const RegistrationState()) {
    on<RegistrationRegisterButtonPressed>(_onRegistrationRegisterButtonPressed);
    on<RegistrationEmailAddressChanged>(_onEmailAddressChanged);
    on<RegistrationPasswordChanged>(_onPasswordChanged);
    on<RegistrationConfirmPasswordChanged>(_onConfirmPasswordChanged);
  }

  Future<void> _onRegistrationRegisterButtonPressed(
    RegistrationSignupButtonPressed event,
    Emitter<RegistrationState> emit,
  ) async {
    if (!state.isValid) return;

    emit(state.copyWith(formSubmissionStatus: FormSubmissionStatus.submitting));

    if (!_isConfirmPasswordMatchedWithPassword(
      state.password.value,
      state.confirmPassword.value,
    )) {
      emit(state.copyWith(
          formSubmissionStatus:
              FormSubmissionStatus.confirmPasswordNotMatchWithPassword));

      return;
    }

    try {
      await _authenticationRepository.signUpWithEmailAndPassword(
        email: state.email.value,
        password: state.password.value,
      );

      emit(state.copyWith(formSubmissionStatus: FormSubmissionStatus.success));
    } catch (_) {
      emit(state.copyWith(formSubmissionStatus: FormSubmissionStatus.failure));
    }
  }

  Future<void> _onEmailAddressChanged(
    RegistrationEmailAddressChanged event,
    Emitter<RegistrationState> emit,
  ) async =>
      emit(state.copyWith(
        email: EmailAddress.create(event.value),
        formSubmissionStatus: FormSubmissionStatus.initial,
      ));

  Future<void> _onPasswordChanged(
    RegistrationPasswordChanged event,
    Emitter<RegistrationState> emit,
  ) async =>
      emit(state.copyWith(
        password: Password.create(event.value),
        formSubmissionStatus: FormSubmissionStatus.initial,
      ));

  Future<void> _onConfirmPasswordChanged(
    RegistrationConfirmPasswordChanged event,
    Emitter<RegistrationState> emit,
  ) async =>
      emit(state.copyWith(
        confirmPassword: Password.create(event.value),
        formSubmissionStatus: FormSubmissionStatus.initial,
      ));

  bool _isConfirmPasswordMatchedWithPassword(
    String password,
    String confirmPassword,
  ) =>
      password == confirmPassword;
}

Статус регистрации

part of 'registration_bloc.dart';

enum FormSubmissionStatus {
  initial,
  submitting,
  success,
  failure,
  confirmPasswordNotMatchWithPassword
}

class RegistrationState extends Equatable {
  final EmailAddress email;
  final Password password;
  final Password confirmPassword;
  final FormSubmissionStatus formSubmissionStatus;

  const RegistrationState({
    this.email = EmailAddress.empty,
    this.password = Password.empty,
    this.confirmPassword = Password.empty,
    this.formSubmissionStatus = FormSubmissionStatus.initial,
  });

  RegistrationState copyWith({
    EmailAddress? email,
    Password? password,
    Password? confirmPassword,
    FormSubmissionStatus? formSubmissionStatus,
  }) =>
      RegistrationState(
        email: email ?? this.email,
        password: password ?? this.password,
        confirmPassword: confirmPassword ?? this.confirmPassword,
        formSubmissionStatus: formSubmissionStatus ?? this.formSubmissionStatus,
      );

  @override
  List<Object?> get props => [
        email,
        password,
        confirmPassword,
        formSubmissionStatus,
      ];

  bool isSubmitting() =>
      formSubmissionStatus == FormSubmissionStatus.submitting;

  bool isSubmissionSuccessOrFailure() =>
      formSubmissionStatus == FormSubmissionStatus.success ||
      formSubmissionStatus == FormSubmissionStatus.failure ||
      formSubmissionStatus ==
          FormSubmissionStatus.confirmPasswordNotMatchWithPassword;

  bool get isValid =>
      !email.hasError && !password.hasError && !confirmPassword.hasError;
}

Форма регистрации

class RegistrationForm extends StatelessWidget {
  const RegistrationForm({super.key});

  @override
  Widget build(BuildContext context) =>
      BlocListener<RegistrationBloc, RegistrationState>(
        listenWhen: (previous, current) =>
            current.isSubmissionSuccessOrFailure(),
        listener: (context, state) {
          if (state.formSubmissionStatus == FormSubmissionStatus.success) {
            showSuccessScaffold(
                context, 'Registration success. Please check your e-mail.');
            navigateAndReplace(context, const LoginPage());
          }

          if (state.formSubmissionStatus == FormSubmissionStatus.failure) {
            showErrorScaffold(context, 'Registration failed.');
          }

          if (state.formSubmissionStatus ==
              FormSubmissionStatus.confirmPasswordNotMatchWithPassword) {
            showErrorScaffold(
                context, 'Confirm password does not match password.');
          }
        },
        child: Column(
          children: const [
            _EmailInputField(),
            SizedBox(height: 8.0),
            _PasswordInputField(),
            SizedBox(height: 8.0),
            _ConfirmPasswordInputField(),
            SizedBox(height: 8.0),
            _RegisterButton(),
          ],
        ),
      );
}

class _EmailInputField extends StatelessWidget {
  const _EmailInputField();

  @override
  Widget build(BuildContext context) =>
      BlocBuilder<RegistrationBloc, RegistrationState>(
          buildWhen: (previous, current) => current.email != previous.email,
          builder: (context, state) => TextField(
                onChanged: (email) => context
                    .read<RegistrationBloc>()
                    .add(RegistrationEmailAddressChanged(value: email)),
                keyboardType: TextInputType.emailAddress,
                decoration: InputDecoration(
                  labelText: 'Email address',
                  errorText:
                      state.email.hasError ? state.email.errorMessage : null,
                ),
              ));
}

class _PasswordInputField extends StatelessWidget {
  const _PasswordInputField();

  @override
  Widget build(BuildContext context) =>
      BlocBuilder<RegistrationBloc, RegistrationState>(
        buildWhen: (previous, current) => current.password != previous.password,
        builder: (context, state) => TextFormField(
          onChanged: (password) => context
              .read<RegistrationBloc>()
              .add(RegistrationPasswordChanged(value: password)),
          obscureText: true,
          decoration: InputDecoration(
            labelText: 'Password',
            errorText:
                state.password.hasError ? state.password.errorMessage : null,
          ),
        ),
      );
}

class _ConfirmPasswordInputField extends StatelessWidget {
  const _ConfirmPasswordInputField();

  @override
  Widget build(BuildContext context) =>
      BlocBuilder<RegistrationBloc, RegistrationState>(
        buildWhen: (previous, current) =>
            current.confirmPassword != previous.confirmPassword,
        builder: (context, state) => TextFormField(
          onChanged: (confirmPassword) => context
              .read<RegistrationBloc>()
              .add(RegistrationConfirmPasswordChanged(value: confirmPassword)),
          obscureText: true,
          decoration: InputDecoration(
            labelText: 'Confirm Password',
            errorText: state.confirmPassword.hasError
                ? state.confirmPassword.errorMessage
                : null,
          ),
        ),
      );
}

class _RegisterButton extends StatelessWidget {
  const _RegisterButton();

  @override
  Widget build(BuildContext context) =>
      BlocBuilder<RegistrationBloc, RegistrationState>(
        builder: (context, state) => ElevatedButton(
          onPressed: () => state.isSubmitting() || !state.isValid
              ? null
              : context
                  .read<RegistrationBloc>()
                  .add(RegistrationRegisterButtonPressed()),
          child: Text(state.isSubmitting() ? 'Submitting' : 'Register'),
        ),
      );
}

Заключение

В этой статье мы рассмотрели, как реализовать простую аутентификацию во Flutter с помощью Supabase и BLoC. Мы организовали архитектуру послойно и реализовали базовые концепции внедрения зависимостей во Flutter.

Помимо входа и регистрации с помощью пароля, Supabase предлагает различные провайдеры для обработки аутентификации, такие как Google, Apple, Magic Link и многие другие. Подробнее об этом можно узнать здесь.