Building an authentication flow in Flutter using the BLoC pattern

11 minute read

In the previous post we introduced the BLoC pattern as one of the state management solutions in Flutter. In this post we are going to put that theory into practice by building a simple authentication flow that utilises the pattern. This is going to be a simple Flutter app that has three screens – a splash screen, a login screen and a home screen.

Update: 12/10/2020: Updated to flutter_bloc: ^6.0.6

App Overview

Before we get into the code let us get a brief overview of how the app is going to behave. This is how the app is going to behave:

  • When a user opens then app a splash screen is displayed while the authentication service is checking if user is authenticated.
  • If the user is not authenticated they are redirected to the login screen otherwise they will go to the home screen.
  • If login is successful the user is redirected to the home screen otherwise an error message is displayed.

The Code

I am assuming you have Flutter installed on your machine and you know how to create a flutter app. If you do not know then you need to visit the Flutter website to get started. We are going to use the flutter_bloc library – a library that helps implement the BLoC pattern. Create a new application and add the following dependencies to your pubsec.yaml:

# pubsec.yaml
# ...
dependencies:
  bloc: ^6.0.3
  flutter_bloc: ^6.0.6
  equatable: ^1.0.2
# ...

For brevity not all of the code will be included in this post. Things like models and services can be found on GitHub. Our focus is going to be on the BLoC and user interface. Let’s get started.

BLoCs

We are going to have two BLoCs with their associated events and states – AuthenticationBloc and LoginBloc. Create a new directory inside your lib directory and name it blocs. Let us start with the AuthenticationBloc, its events and states.

Authentication

Inside the blocs directory create another directory and name it authentication then create a new file authentication_event.dart and add the following code therein:

// lib/blocs/authentication/authentication_event.dart

import 'package:meta/meta.dart';
import 'package:equatable/equatable.dart';

import '../../models/models.dart';

abstract class AuthenticationEvent extends Equatable {
  const AuthenticationEvent();

  @override
  List<Object> get props => [];
}

// Fired just after the app is launched
class AppLoaded extends AuthenticationEvent {}

// Fired when a user has successfully logged in
class UserLoggedIn extends AuthenticationEvent {
  final User user;

  UserLoggedIn({@required this.user});

  @override
  List<Object> get props => [user];
}

// Fired when the user has logged out
class UserLoggedOut extends AuthenticationEvent {}

These are the events whose stream the authentication BLoC will listen to. While you are still in the same directory create another file, authentication_state.dart and add the following code:

// lib/blocs/authentication/authentication_state.dart

import 'package:meta/meta.dart';
import 'package:equatable/equatable.dart';
import '../../models/models.dart';

abstract class AuthenticationState extends Equatable {
  const AuthenticationState();

  @override
  List<Object> get props => [];
}

class AuthenticationInitial extends AuthenticationState {}

class AuthenticationLoading extends AuthenticationState {}

class AuthenticationNotAuthenticated extends AuthenticationState {}

class AuthenticationAuthenticated extends AuthenticationState {
  final User user;

  AuthenticationAuthenticated({@required this.user});

  @override
  List<Object> get props => [user];
}

class AuthenticationFailure extends AuthenticationState {
  final String message;

  AuthenticationFailure({@required this.message});

  @override
  List<Object> get props => [message];
}

The states are self-explanatory so we are not going to discuss them further. Let us now turn our attention to the BLoC. Create a new file and call it authentication_bloc.dart. Add the following code:

// lib/blocs/authentication/authentication_bloc.dart

import 'package:bloc/bloc.dart';

import 'authentication_event.dart';
import 'authentication_state.dart';
import '../../services/services.dart';

class AuthenticationBloc extends Bloc<AuthenticationEvent, AuthenticationState> {
  final AuthenticationService _authenticationService;

  AuthenticationBloc(AuthenticationService authenticationService)
      : assert(authenticationService != null),
        _authenticationService = authenticationService,
        super(AuthenticationInitial());

  @override
  Stream<AuthenticationState> mapEventToState(AuthenticationEvent event) async* {
    if (event is AppLoaded) {
      yield* _mapAppLoadedToState(event);
    }

    if (event is UserLoggedIn) {
      yield* _mapUserLoggedInToState(event);
    }

    if (event is UserLoggedOut) {
      yield* _mapUserLoggedOutToState(event);
    }
  }

  Stream<AuthenticationState> _mapAppLoadedToState(AppLoaded event) async* {
    yield AuthenticationLoading();
    try {
      await Future.delayed(Duration(milliseconds: 500)); // a simulated delay
      final currentUser = await _authenticationService.getCurrentUser();

      if (currentUser != null) {
        yield AuthenticationAuthenticated(user: currentUser);
      } else {
        yield AuthenticationNotAuthenticated();
      }
    } catch (e) {
      yield AuthenticationFailure(message: e.message ?? 'An unknown error occurred');
    }
  }

  Stream<AuthenticationState> _mapUserLoggedInToState(UserLoggedIn event) async* {
    yield AuthenticationAuthenticated(user: event.user);
  }

  Stream<AuthenticationState> _mapUserLoggedOutToState(UserLoggedOut event) async* {
    await _authenticationService.signOut();
    yield AuthenticationNotAuthenticated();
  }
}


We saw in the previous post that a BLoC is responsible for converting a stream of incoming events into a stream of outgoing states. This is done inside the mapEventToState method which takes in an AuthenticationEvent and returns a stream of AuthenticationState. This method is called every time an AuthenticationEvent is added to the events stream. Note that the method does not return a new stream each time an event is fired but it yields the state into the existing stream. For more information on streams in dart check out this page.

Inside the mapEventToState method we check what event type was added to the stream and we map it to a method that does business logic for that specific event. For instance, if AppLoaded event is received we call _mapAppLoadedToState which in turn calls the AuthenticationService to check if there is a currently logged in user. If there is, it will yield AuthenticationAuthenticated state otherwise it will yield AuthenticationNotAuthenticated.

Login

Now let us write code for the login BLoC. Like with the authentication BLoC above we will create a new directory inside lib/blocs called login and first create the login_event.dart file to which we add the following code:

// lib/blocs/login/login_event.dart

import 'package:meta/meta.dart';
import 'package:equatable/equatable.dart';

abstract class LoginEvent extends Equatable {
  @override
  List<Object> get props => [];
}

class LoginInWithEmailButtonPressed extends LoginEvent {
  final String email;
  final String password;

  LoginInWithEmailButtonPressed({@required this.email, @required this.password});

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

As you can see, we only have one login event which will be fired when a user presses the login button in the UI. Let us now add the login states inside the login_state.dart file:

// lib/blocs/login/login_state.dart

import 'package:meta/meta.dart';
import 'package:equatable/equatable.dart';


abstract class LoginState extends Equatable{
  @override
  List<Object> get props => [];
}

class LoginInitial extends LoginState {}

class LoginLoading extends LoginState {}

class LoginSuccess extends LoginState {}

class LoginFailure extends LoginState {
  final String error;

  LoginFailure({@required this.error});

  @override
  List<Object> get props => [error];
}

Finally, let us go and write the login BLoC which will convert a stream of incoming login events into a stream of outgoing login states.

// lib/blocs/login/login_bloc.dart

import 'package:bloc/bloc.dart';
import 'login_event.dart';
import 'login_state.dart';
import '../authentication/authentication.dart';
import '../../exceptions/exceptions.dart';
import '../../services/services.dart';

class LoginBloc extends Bloc<LoginEvent, LoginState> {
  final AuthenticationBloc _authenticationBloc;
  final AuthenticationService _authenticationService;

  LoginBloc(AuthenticationBloc authenticationBloc, AuthenticationService authenticationService)
      : assert(authenticationBloc != null),
        assert(authenticationService != null),
        _authenticationBloc = authenticationBloc,
        _authenticationService = authenticationService,
        super(LoginInitial());

  @override
  Stream<LoginState> mapEventToState(LoginEvent event) async* {
    if (event is LoginInWithEmailButtonPressed) {
      yield* _mapLoginWithEmailToState(event);
    }
  }

  Stream<LoginState> _mapLoginWithEmailToState(LoginInWithEmailButtonPressed event) async* {
    yield LoginLoading();
    try {
      final user = await _authenticationService.signInWithEmailAndPassword(event.email, event.password);
      if (user != null) {
        _authenticationBloc.add(UserLoggedIn(user: user));
        yield LoginSuccess();
        yield LoginInitial();
      } else {
        yield LoginFailure(error: 'Something very weird just happened');
      }
    } on AuthenticationException catch (e) {
      yield LoginFailure(error: e.message);
    } catch (err) {
      yield LoginFailure(error: err.message ?? 'An unknown error occured');
    }
  }
}

As you can see, the LoginBloc depends on AuthenticationBloc. This is because after a user has successfully logged in we want to notify the AuthenticationBloc that a new event (UserLoggedIn) has occured so the authentication bloc can push out the AuthenticationAuthenticated state to all listening widgets. This is not the only way to do it but I decided to do it this way.

User Interface

Now that we have finished all the BLoCs needed for the app let us turn our attention to the UI. We are going to create a directory called pages where all the pages for our app will reside.

Login Page

Inside the pages directory create a new file called login_page.dart and add the following code:

// lib/pages/login_page.dart

import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../blocs/blocs.dart';
import '../services/services.dart';

class LoginPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('Login'),
      ),
      body: SafeArea(
        minimum: const EdgeInsets.all(16),
        child: BlocBuilder<AuthenticationBloc, AuthenticationState>(
          builder: (context, state){
            final authBloc = BlocProvider.of<AuthenticationBloc>(context);
            if (state is AuthenticationNotAuthenticated){
              return _AuthForm(); // show authentication form
            }
            if (state is AuthenticationFailure) {
              // show error message
              return Center(
                  child: Column(
                    mainAxisAlignment: MainAxisAlignment.center,
                    crossAxisAlignment: CrossAxisAlignment.center,
                    children: <Widget>[
                      Text(state.message),
                      FlatButton(
                        textColor: Theme.of(context).primaryColor,
                        child: Text('Retry'),
                        onPressed: () {
                          authBloc.add(AppLoaded());
                        },
                      )
                    ],
                  ));
            }
            // show splash screen
            return Center(
              child: CircularProgressIndicator(
                strokeWidth: 2,
              ),
            );
          },
        )
      ),
    );
  }
}

class _AuthForm extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final authService = RepositoryProvider.of<AuthenticationService>(context);
    final authBloc = BlocProvider.of<AuthenticationBloc>(context);

    return Container(
      alignment: Alignment.center,
      child: BlocProvider<LoginBloc>(
        create: (context) => LoginBloc(authBloc, authService),
        child: _SignInForm(),
      ),
    );
  }
}

class _SignInForm extends StatefulWidget {
  @override
  __SignInFormState createState() => __SignInFormState();
}

class __SignInFormState extends State<_SignInForm> {

  final GlobalKey<FormState> _key = GlobalKey<FormState>();
  final _passwordController = TextEditingController();
  final _emailController = TextEditingController();
  bool _autoValidate = false;

  @override
  Widget build(BuildContext context) {
    final _loginBloc = BlocProvider.of<LoginBloc>(context);

    _onLoginButtonPressed () {
      if (_key.currentState.validate()) {
        _loginBloc.add(LoginInWithEmailButtonPressed(
            email: _emailController.text,
            password: _passwordController.text
        ));
      }else {
        setState(() {
          _autoValidate = true;
        });
      }
    }
    return BlocListener<LoginBloc, LoginState>(
      listener: (context, state){
        if (state is LoginFailure){
          _showError(state.error);
        }
      },
      child: BlocBuilder<LoginBloc, LoginState>(
        builder: (context, state){
          if (state is LoginLoading){
            return Center(
              child: CircularProgressIndicator(),
            );
          }
          return Form(
            key: _key,
            autovalidate: _autoValidate,
            child: SingleChildScrollView(
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.stretch,
                children: <Widget>[
                  TextFormField(
                    decoration: InputDecoration(
                      labelText: 'Email address',
                      filled: true,
                      isDense: true,
                    ),
                    controller: _emailController,
                    keyboardType: TextInputType.emailAddress,
                    autocorrect: false,
                    validator: (value){
                      if (value == null){
                        return 'Email is required.';
                      }
                      return null;
                    },
                  ),
                  SizedBox(
                    height: 12,
                  ),
                  TextFormField(
                    decoration: InputDecoration(
                      labelText: 'Password',
                      filled: true,
                      isDense: true,
                    ),
                    obscureText: true,
                    controller: _passwordController,
                    validator: (value) {
                      if (value == null){
                        return 'Password is required.';
                      }
                      return null;
                    },
                  ),
                  const SizedBox(
                    height: 16,
                  ),
                  RaisedButton(
                    color: Theme.of(context).primaryColor,
                    textColor: Colors.white,
                    padding: const EdgeInsets.all(16),
                    shape: new RoundedRectangleBorder(borderRadius: new BorderRadius.circular(8.0)),
                    child: Text('LOG IN'),
                    onPressed: state is LoginLoading ? () {} : _onLoginButtonPressed,
                  )
                ],
              ),
            ),
          );
        },
      ),
    );
  }

  void _showError(String error) {
    Scaffold.of(context).showSnackBar(
      SnackBar(
        content: Text(error),
        backgroundColor: Theme.of(context).errorColor,
      )
    );
  }

}

Let us talk a little bit about the classes that are provided by the flutter_bloc library.

1. BlocProvider

This is a widget that is responsible for injecting a BLoC instance to its children. Inside the _AuthForm widget we provide the LoginBloc to the _SignInForm widget and all its children. This means all the child widgets of _SignInForm will also have access to the bloc as it is propagated down the widget tree. To access the BLoC you use the BlocProvider.of<T>(context) method. For instance, if you want to access the AuthenticationBloc you call BlocProvider.of<AuthenticationBloc>(context).

2. BlocBuilder

This widget is responsible for rebuilding the UI when the state changes. For instance inside the LoginPage widget we have a BlocBuilder that listens to the AuthenticationState stream. Whenever the state changes the BlocBuilder will build a new widget: the authentication form, error message or the splash screen depending on the state.

3. BlocListener

This widget also listens to changes in state but unlike the BlocBuilder it does not rebuild the UI. You can choose whatever you want to do when the state changes – navigate to another screen, show a message, call a service etc. In our example inside the __SignInFormState widget the BlocListener listens to changes to LoginState. When a LoginFailure is received a toast notification will be displayed notifying the user of the error.

For more indepth information on these widgets check out this website.

Home Page

Let us now create a home_page.dart file inside the pages directory and add the following code:

// lib/pages/home_page.dart

import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_bloc_authentication/blocs/authentication/authentication.dart';
import '../models/models.dart';

class HomePage extends StatelessWidget {
  final User user;

  const HomePage({Key key, this.user}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    final authBloc = BlocProvider.of<AuthenticationBloc>(context);
    return Scaffold(
      appBar: AppBar(
        title: Text('Home Page'),
      ),
      body: SafeArea(
        minimum: const EdgeInsets.all(16),
        child: Center(
          child: Column(
            children: <Widget>[
              Text(
                'Welcome, ${user.name}',
                style: TextStyle(
                  fontSize: 24
                ),
              ),
              const SizedBox(
                height: 12,
              ),
              FlatButton(
                textColor: Theme.of(context).primaryColor,
                child: Text('Logout'),
                onPressed: (){
                  // Add UserLoggedOut to authentication event stream.
                  authBloc.add(UserLoggedOut());
                },
              )
            ],
          ),
        ),
      ),
    );
  }
}

This is a simple page that displays the name of the logged in user and has a logout button.

Putting everything together

Let us now go to main.dart and add the following code:

// lib/main.dart

import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'blocs/blocs.dart';
import 'services/services.dart';
import 'pages/pages.dart';

void main() => runApp(
        // Injects the Authentication service
        RepositoryProvider<AuthenticationService>(
          create: (context) {
            return FakeAuthenticationService();
          },
          // Injects the Authentication BLoC
          child: BlocProvider<AuthenticationBloc>(
            create: (context) {
              final authService = RepositoryProvider.of<AuthenticationService>(context);
              return AuthenticationBloc(authService)..add(AppLoaded());
            },
            child: MyApp(),
          ),
    ));

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Authentication Demo',
      theme: ThemeData(
        primarySwatch: Colors.teal,
      ),
      // BlocBuilder will listen to changes in AuthenticationState
      // and build an appropriate widget based on the state.
      home: BlocBuilder<AuthenticationBloc, AuthenticationState>(
        builder: (context, state) {
          if (state is AuthenticationAuthenticated) {
            // show home page
            return HomePage(
              user: state.user,
            );
          }
          // otherwise show login page
          return LoginPage();
        },
      ),
    );
  }
}

As you may have noticed there is another widget in the flutter_bloc library that we have used – RepositoryProvider. This widget is responsible for injecting repositories (any class that is not a BLoC) to other widgets down the widget tree. You can also see that it is the first widget in our app followed by the AuthenticationBloc provider. This is because we want our entire app to have access to the AuthenticationBloc which in turn needs to have access to the AuthenticationService. The AuthenticationService is also a dependency of the LoginBloc. Repositories are accessed using RepositoryProvider.of<T>(context).

We are done! You may run your app it should behave like the GIF below. Like I said earlier not all the code is included here so you will have to check out the complete project on GitHub.

Flutter app
App showing authentication flow built using the BLoC pattern

Once again, thank you so much for taking your time to read.

Further Reading

You may also want to check out this article where I show how to build an authentication flow using the GetX library.

Comments