Authentication in Flutter with Supabase and BLoC

Authentication in Flutter with Supabase and BLoC

In this article, we will introduce how to implement authentication in Flutter using Supabase and BLoC.

Introduction

What is Supabase?

Supabase is an open-source alternative to Firebase and represents Backend as a Service platform that allows developers to easily create and manage backend solutions in the cloud. It provides core services such as database, authentication, and storage.

What is BLoC?

BLoC is one of the most popular state management libraries in Flutter that helps developers separate presentation and business logic easily. Each BLoC has three core building blocks:

  • Bloc

  • State

  • Event

To keep it simple, each interaction on UI emits an event that is processed by the bloc. Bloc knows how to process certain events and which state to give back to the UI. Depending on the received state, our UI is updated.

Architecture

In this example, we will organize our project using a “layer-first” approach where each feature is placed in the three layers:

  • data — The data layer communicates with external services such as API, storage, and database. In this example, the data layer communicates with the Supabase.

  • domain — The domain layer transforms and manipulates data from the data layer.

  • presentation — The presentation layer represents the user interaction part of our application. In this example, the presentation layer contains blocs and presentation widgets.

Start Supabase project

Let’s start with Supabase.

Open the supabase.com and click start your project. After successful registration, sign in to your account and create a project by specifying the project name, database password, and region. In this example, we will use a basic free plan project.

In a few minutes, our project is set up and our database and API endpoints are ready.

In this article, we will cover sign-in and sign-up with the password. Open the Authentication section and ensure that the Email provider is enabled with the activated confirm email option.

Providers setting in Authentication Section in Supabase

Additionally, we will need to set up deep-linking to bring the user back to the application when they open the link from the sign-up email. In this example we will use io.supabase.flutterexample://signup-callback, so we will need to update the Site URL and add a new Redirect URL.

URL Configuration settings in Supabase

Setup Flutter project

The first step is to create three main layers of our application that will be represented with folders. Additionally, we will create a core folder where the common files will be created like utils, routers, modules, etc. Our final folder structure will be:

  • core

  • data

  • domain

  • presentation

After the folder structure is organized, we will need to install our dependencies:

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:

In this example, we will implement dependency injection using injectable and get_it packages. We will add dependency injection configuration in dependency_injection.dart file.

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();

In the next step, we will initialize the Supabase in main() method in main.dart :

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

   configureDependencies()
}

YOUR_PROJECT_URL and PUBLIC_ANON_KEY can be found in the API settings tab inside the Project Settings section on Supabase.

To cover authentication with Supabase we need to use GoTrueClient provided by SupabaseClient. We will create app_module.dart in the core folder and we will register a module that will make our third-party dependencies injectable through the project.

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

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

To enable Supabase deep-linking in our application we will need to update ios/Runner/Info.plist and android/app/src/main/AndroidManifest.xml files.

Edit the ios/Runner/Info.plist and add CFBundleURLTypes:

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

Edit the android/app/src/main/AndroidManifest.xml and add an intent-filter:

<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>

Authentication Repository

We will create an IAuthenticationRepository that represents our abstraction over implementation details of how we authenticate in the application. By using the abstraction we can change our implementation details later without affecting our application. In the domain layer, we will create 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();
}

In the data layer, we will create authentication_repository.dart that contains our internal implementation details about authentication using the Supabase. AuthenticationRepository represents an implementation of IAuthenticationRepository and it uses Supabase’s mechanisms for authentication by injecting 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

In the presentation layer, we will create an auth folder with AuthBloc.

AuthBloc is responsible for managing the global authentication state in our application. It receives AuthEvents and transforms them into certain AuthStates.

AuthBloc injects IAutenticationRepository and it contains business logic for the initial authentication check, subscribing to authentication state changes, and logging out from the application.

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();
  }
}

To be responsible for observing authentication state changes, logging out process, and initial authentication state check, the AuthBloc will receive three types of 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 {}

Depending on certain events, the AuthBloc will emit two types of states — one for successful authentication and one when the user is not authenticated.

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 {}

To achieve that we listen to authentication state changes in our application globally, we will create a BlocProvider for the AuthBloc in the main() method in 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(),
  ));
}

After the AuthBloc is instantiated, the event for checking the initial authentication state will be added and the subscription to the authentication changes in the application will be opened.

Since BlocProvider is created in the main.dart, we ensure that the subscription to the authentication changes will be closed when the application is closed.

FlutterExampleApp represents our main application widget and it will have BlocConsumer that will update our UI depending on emitted states in the following way:

  • When initial authentication is checked, the Splash screen will be displayed

  • If the user is authenticated, it will be redirected to the Home screen

  • If the user is not authenticated, it will be redirected to the Login page

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(),
      ));
}

In our HomePage we will add:

  • BlocListener will navigate to the LoginPage if the authentication state is changed to unauthenticated.

  • _LogoutButton to perform logging out from the application on the click. Since our AuthBloc is provided globally, we can read it from the context and add the AuthLogoutButtonPressed event.

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'),
      );
}

Login

In our presentation layer, we will create a login folder that will contain the login bloc and login page.

LoginBloc is responsible for the implementation of the login flow in the application.

LoginBloc injects IAuthenticationRepository and contains the business logic for handling changes in the Email and the Password textfields and for handling press on the Login button.

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,
      ));
}

The opposite of the Auth state that is represented with subclasses, the LoginState is represented with a single class.

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;
}

At the top of the LoginState class, we have the FormSubmissionStatus enumeration, which defines the different states of form submission.

The LoginState class has three properties:

  • final EmailAddress email: Represents the value object and represents the email address entered in the login form.

  • final Password password: Represents the value object and represents the password entered in the login form.

  • final FormSubmissionStatus formSubmissionStatus: Represents the current form submission status.

The EmailAddress value object has a create factory constructor for creating a valid or invalid instance of the EmailAddress. The static constant empty of type EmailAddress represents an empty or uninitialized EmailAddress instance, used when initializing the initial state of a 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);
}

The Password value object has a create factory constructor for creating a valid or invalid instance of the Password. The static constant empty of type Password represents an empty or uninitialized Password instance, used when initializing the initial state of a 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);
}

The copyWith method allows the creation of a new instance of LoginState with updated values for specific properties. It returns a new LoginState object with the desired changes, leaving the other properties unchanged. This method is called in the LoginBloc inside _onEmailAddressChanged and _onPasswordChanged that will update the LoginState depending on the inserted value in the textfields.

The LoginPage represents the screen with email and password fields, a login button, and a register button. It is the Scaffold widget that is wrapped with BlocProvider that creates LoginBloc. The LoginPage widget contains the _LoginForm widget.

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()
          ],
        ),
      );
}

The _LoginForm widget listens to changes in the state of the LoginBloc to perform specific actions based on the login form’s submission status. It contains listenWhen callback that determines whether the listener should react to the state change. In this example, it listens when the current state of form submission is either success or failure. In the case of FormSubmissionStatus.success, the user will be redirected to the HomePage. In the case of the FormSubmissionStatus.failure, the error message will be displayed.

_EmailInputField is a widget representing an input field for entering an email address. It uses the BlocBuilder widget to rebuild the UI based on changes in the emailAddress field in the LoginState. It uses a buildWhen callback that will rebuild the widget only when the current email in the LoginState is different from the previous email. This could be significant in improving the application performance since we will avoid unnecessary rebuilds of the widget.

The value of the current email in the LoginState will be changed on user input by calling the onChanged callback method in the TextField widget that will dispatch the LoginEmailAddressChanged event to the LoginBloc and will pass the new value of the email address. On every change of the email address, a new EmailAddress instance will be created.

If EmailAddress is created in the invalid state and contains an error, the error decoration will be applied to the 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 is a widget representing an input field for entering a password. It will be rebuilt the widget only when the current password in the LoginState is different from the previous password. On every user’s input, the LoginPasswordChanged event will be dispatched and a new Password instance will be created in the LoginState.

If the Password is created in an invalid state and contains an error, the error decoration will be applied to the TextField.

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 is an ElevatedButton widget that has an onPressed callback method and it will dispatch the LoginButtonPressed event to the LoginBloc button click.

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'),
        ),
      );
}

Registration

The sign-up process in the application is covered in RegistrationBloc. After the sign-up form is completed, the user will receive an email with an activation link. After clicking on the activation link, the user will be redirected to the application and authenticated.

The code base is the same as it is for the sign-in process.

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;
}

RegistrationState

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;
}

RegistrationForm

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'),
        ),
      );
}

Conclusion

Screenshots of the application

In this article, we covered how to implement simple authentication in Flutter using Supabase and BLoC. We organized architecture in a layer-first way and implemented basic concepts of dependency injection in Flutter.

Besides the sign-in and sign-up with a password, Supabase offers various providers for handling authentication such as Google, Apple, Magic Link, and many others. You can find more information about that here.