Flutter Login
This content is not available in your language yet.
In the following tutorial, we’re going to build a Login Flow in Flutter using the Bloc library.
Key Topics
Section titled “Key Topics”- BlocProvider, Flutter widget which provides a bloc to its children.
- Adding events with context.read.
- Prevent unnecessary rebuilds with Equatable.
- RepositoryProvider, a Flutter widget which provides a repository to its children.
- BlocListener, a Flutter widget which invokes the listener code in response to state changes in the bloc.
- Updating the UI based on a part of a bloc state with context.select.
Project Setup
Section titled “Project Setup”We’ll start off by creating a brand new Flutter project
flutter create flutter_login
Next, we can install all of our dependencies
flutter pub get
Authentication Repository
Section titled “Authentication Repository”The first thing we’re going to do is create an authentication_repository
package which will be responsible for managing the authentication domain.
We’ll start by creating a packages/authentication_repository
directory at the
root of the project which will contain all internal packages.
At a high level, the directory structure should look like this:
├── android├── ios├── lib├── packages│ └── authentication_repository└── test
Next, we can create a pubspec.yaml
for the authentication_repository
package:
name: authentication_repositorydescription: Dart package which manages the authentication domain.publish_to: none
environment: sdk: ">=3.6.0 <4.0.0"
Next up, we need to implement the AuthenticationRepository
class itself which
will be in
packages/authentication_repository/lib/src/authentication_repository.dart
.
import 'dart:async';
enum AuthenticationStatus { unknown, authenticated, unauthenticated }
class AuthenticationRepository { final _controller = StreamController<AuthenticationStatus>();
Stream<AuthenticationStatus> get status async* { await Future<void>.delayed(const Duration(seconds: 1)); yield AuthenticationStatus.unauthenticated; yield* _controller.stream; }
Future<void> logIn({ required String username, required String password, }) async { await Future.delayed( const Duration(milliseconds: 300), () => _controller.add(AuthenticationStatus.authenticated), ); }
void logOut() { _controller.add(AuthenticationStatus.unauthenticated); }
void dispose() => _controller.close();}
The AuthenticationRepository
exposes a Stream
of AuthenticationStatus
updates which will be used to notify the application when a user signs in or
out.
In addition, there are logIn
and logOut
methods which are stubbed for
simplicity but can easily be extended to authenticate with FirebaseAuth
for
example or some other authentication provider.
Lastly, we need to create
packages/authentication_repository/lib/authentication_repository.dart
which
will contain the public exports:
export 'src/authentication_repository.dart';
That’s it for the AuthenticationRepository
, next we’ll work on the
UserRepository
.
User Repository
Section titled “User Repository”Just like with the AuthenticationRepository
, we will create a
user_repository
package inside the packages
directory.
├── android├── ios├── lib├── packages│ ├── authentication_repository│ └── user_repository└── test
Next, we’ll create the pubspec.yaml
for the user_repository
:
name: user_repositorydescription: Dart package which manages the user domain.publish_to: none
environment: sdk: ">=3.6.0 <4.0.0"
dependencies: equatable: ^2.0.0 uuid: ^3.0.0
The user_repository
will be responsible for the user domain and will expose
APIs to interact with the current user.
The first thing we will define is the user model in
packages/user_repository/lib/src/models/user.dart
:
import 'package:equatable/equatable.dart';
class User extends Equatable { const User(this.id);
final String id;
@override List<Object> get props => [id];
static const empty = User('-');}
For simplicity, a user just has an id
property but in practice we might have
additional properties like firstName
, lastName
, avatarUrl
, etc…
Next, we can create a models.dart
in packages/user_repository/lib/src/models
which will export all models so that we can use a single import state to import
multiple models.
export 'user.dart';
Now that the models have been defined, we can implement the UserRepository
class in packages/user_repository/lib/src/user_repository.dart
.
import 'dart:async';
import 'package:user_repository/src/models/models.dart';import 'package:uuid/uuid.dart';
class UserRepository { User? _user;
Future<User?> getUser() async { if (_user != null) return _user; return Future.delayed( const Duration(milliseconds: 300), () => _user = User(const Uuid().v4()), ); }}
For this simple example, the UserRepository
exposes a single method getUser
which will retrieve the current user. We are stubbing this but in practice this
is where we would query the current user from the backend.
Almost done with the user_repository
package — the only thing left to do is
to create the user_repository.dart
file in packages/user_repository/lib
which defines the public exports:
export 'src/models/models.dart';export 'src/user_repository.dart';
Now that we have the authentication_repository
and user_repository
packages
complete, we can focus on the Flutter application.
Installing Dependencies
Section titled “Installing Dependencies”Let’s start by updating the generated pubspec.yaml
at the root of our project:
name: flutter_logindescription: A new Flutter project.version: 1.0.0+1publish_to: none
environment: sdk: ">=3.6.0 <4.0.0"
dependencies: authentication_repository: path: packages/authentication_repository bloc: ^9.0.0 equatable: ^2.0.0 flutter: sdk: flutter flutter_bloc: ^9.1.0 formz: ^0.8.0 user_repository: path: packages/user_repository
dev_dependencies: bloc_lint: ^0.2.0-dev.0 bloc_test: ^10.0.0 flutter_test: sdk: flutter mocktail: ^1.0.0
flutter: uses-material-design: true
We can install the dependencies by running:
flutter pub get
Authentication Bloc
Section titled “Authentication Bloc”The AuthenticationBloc
will be responsible for reacting to changes in the
authentication state (exposed by the AuthenticationRepository
) and will emit
states we can react to in the presentation layer.
The implementation for the AuthenticationBloc
is inside of
lib/authentication
because we treat authentication as a feature in our
application layer.
├── lib│ ├── app.dart│ ├── authentication│ │ ├── authentication.dart│ │ └── bloc│ │ ├── authentication_bloc.dart│ │ ├── authentication_event.dart│ │ └── authentication_state.dart│ ├── main.dart
authentication_event.dart
Section titled “authentication_event.dart”AuthenticationEvent
instances will be the input to the AuthenticationBloc
and will be processed and used to emit new AuthenticationState
instances.
In this application, the AuthenticationBloc
will be reacting to two different
events:
AuthenticationSubscriptionRequested
: initial event that notifies the bloc to subscribe to theAuthenticationStatus
streamAuthenticationLogoutPressed
: notifies the bloc of a user logout action
part of 'authentication_bloc.dart';
sealed class AuthenticationEvent { const AuthenticationEvent();}
final class AuthenticationSubscriptionRequested extends AuthenticationEvent {}
final class AuthenticationLogoutPressed extends AuthenticationEvent {}
Next, let’s take a look at the AuthenticationState
.
authentication_state.dart
Section titled “authentication_state.dart”AuthenticationState
instances will be the output of the AuthenticationBloc
and will be consumed by the presentation layer.
The AuthenticationState
class has three named constructors:
-
AuthenticationState.unknown()
: the default state which indicates that the bloc does not yet know whether the current user is authenticated or not. -
AuthenticationState.authenticated()
: the state which indicates that the user is current authenticated. -
AuthenticationState.unauthenticated()
: the state which indicates that the user is current not authenticated.
part of 'authentication_bloc.dart';
class AuthenticationState extends Equatable { const AuthenticationState._({ this.status = AuthenticationStatus.unknown, this.user = User.empty, });
const AuthenticationState.unknown() : this._();
const AuthenticationState.authenticated(User user) : this._(status: AuthenticationStatus.authenticated, user: user);
const AuthenticationState.unauthenticated() : this._(status: AuthenticationStatus.unauthenticated);
final AuthenticationStatus status; final User user;
@override List<Object> get props => [status, user];}
Now that we have seen the AuthenticationEvent
and AuthenticationState
implementations let’s take a look at AuthenticationBloc
.
authentication_bloc.dart
Section titled “authentication_bloc.dart”The AuthenticationBloc
manages the authentication state of the application
which is used to determine things like whether or not to start the user at a
login page or a home page.
import 'dart:async';
import 'package:authentication_repository/authentication_repository.dart';import 'package:bloc/bloc.dart';import 'package:equatable/equatable.dart';import 'package:user_repository/user_repository.dart';
part 'authentication_event.dart';part 'authentication_state.dart';
class AuthenticationBloc extends Bloc<AuthenticationEvent, AuthenticationState> { AuthenticationBloc({ required AuthenticationRepository authenticationRepository, required UserRepository userRepository, }) : _authenticationRepository = authenticationRepository, _userRepository = userRepository, super(const AuthenticationState.unknown()) { on<AuthenticationSubscriptionRequested>(_onSubscriptionRequested); on<AuthenticationLogoutPressed>(_onLogoutPressed); }
final AuthenticationRepository _authenticationRepository; final UserRepository _userRepository;
Future<void> _onSubscriptionRequested( AuthenticationSubscriptionRequested event, Emitter<AuthenticationState> emit, ) { return emit.onEach( _authenticationRepository.status, onData: (status) async { switch (status) { case AuthenticationStatus.unauthenticated: return emit(const AuthenticationState.unauthenticated()); case AuthenticationStatus.authenticated: final user = await _tryGetUser(); return emit( user != null ? AuthenticationState.authenticated(user) : const AuthenticationState.unauthenticated(), ); case AuthenticationStatus.unknown: return emit(const AuthenticationState.unknown()); } }, onError: addError, ); }
void _onLogoutPressed( AuthenticationLogoutPressed event, Emitter<AuthenticationState> emit, ) { _authenticationRepository.logOut(); }
Future<User?> _tryGetUser() async { try { final user = await _userRepository.getUser(); return user; } catch (_) { return null; } }}
The AuthenticationBloc
has a dependency on both the AuthenticationRepository
and UserRepository
and defines the initial state as
AuthenticationState.unknown()
.
In the constructor body, AuthenticationEvent
subclasses are mapped to their
corresponding event handlers.
In the _onSubscriptionRequested
event handler, the AuthenticationBloc
uses
emit.onEach
to subscribe to the status
stream of the
AuthenticationRepository
and emit a state in response to each
AuthenticationStatus
.
emit.onEach
creates a stream subscription internally and takes care of
canceling it when either AuthenticationBloc
or the status
stream is closed.
If the status
stream emits an error, addError
forwards the error and
stackTrace to any BlocObserver
listening.
When the status
stream emits AuthenticationStatus.unknown
or
unauthenticated
, the corresponding AuthenticationState
is emitted.
When AuthenticationStatus.authenticated
is emitted, the AuthentictionBloc
queries the user via the UserRepository
.
main.dart
Section titled “main.dart”Next, we can replace the default main.dart
with:
import 'package:flutter/widgets.dart';import 'package:flutter_login/app.dart';
void main() => runApp(const App());
app.dart
will contain the root App
widget for the entire application.
import 'package:authentication_repository/authentication_repository.dart';import 'package:flutter/material.dart';import 'package:flutter_bloc/flutter_bloc.dart';import 'package:flutter_login/authentication/authentication.dart';import 'package:flutter_login/home/home.dart';import 'package:flutter_login/login/login.dart';import 'package:flutter_login/splash/splash.dart';import 'package:user_repository/user_repository.dart';
class App extends StatelessWidget { const App({super.key});
@override Widget build(BuildContext context) { return MultiRepositoryProvider( providers: [ RepositoryProvider( create: (_) => AuthenticationRepository(), dispose: (repository) => repository.dispose(), ), RepositoryProvider(create: (_) => UserRepository()), ], child: BlocProvider( lazy: false, create: (context) => AuthenticationBloc( authenticationRepository: context.read<AuthenticationRepository>(), userRepository: context.read<UserRepository>(), )..add(AuthenticationSubscriptionRequested()), child: const AppView(), ), ); }}
class AppView extends StatefulWidget { const AppView({super.key});
@override State<AppView> createState() => _AppViewState();}
class _AppViewState extends State<AppView> { final _navigatorKey = GlobalKey<NavigatorState>();
NavigatorState get _navigator => _navigatorKey.currentState!;
@override Widget build(BuildContext context) { return MaterialApp( navigatorKey: _navigatorKey, builder: (context, child) { return BlocListener<AuthenticationBloc, AuthenticationState>( listener: (context, state) { switch (state.status) { case AuthenticationStatus.authenticated: _navigator.pushAndRemoveUntil<void>( HomePage.route(), (route) => false, ); case AuthenticationStatus.unauthenticated: _navigator.pushAndRemoveUntil<void>( LoginPage.route(), (route) => false, ); case AuthenticationStatus.unknown: break; } }, child: child, ); }, onGenerateRoute: (_) => SplashPage.route(), ); }}
By default, BlocProvider
is lazy and does not call create
until the first
time the Bloc is accessed. Since AuthenticationBloc
should always subscribe to
the AuthenticationStatus
stream immediately (via the
AuthenticationSubscriptionRequested
event), we can explicitly opt out of this
behavior by setting lazy: false
.
AppView
is a StatefulWidget
because it maintains a GlobalKey
which is used
to access the NavigatorState
. By default, AppView
will render the
SplashPage
(which we will see later) and it uses BlocListener
to navigate to
different pages based on changes in the AuthenticationState
.
Splash
Section titled “Splash”The splash feature will just contain a simple view which will be rendered right when the app is launched while the app determines whether the user is authenticated.
lib└── splash ├── splash.dart └── view └── splash_page.dart
import 'package:flutter/material.dart';
class SplashPage extends StatelessWidget { const SplashPage({super.key});
static Route<void> route() { return MaterialPageRoute<void>(builder: (_) => const SplashPage()); }
@override Widget build(BuildContext context) { return const Scaffold( body: Center(child: CircularProgressIndicator()), ); }}
The login feature contains a LoginPage
, LoginForm
and LoginBloc
and allows
users to enter a username and password to log into the application.
├── lib│ ├── login│ │ ├── bloc│ │ │ ├── login_bloc.dart│ │ │ ├── login_event.dart│ │ │ └── login_state.dart│ │ ├── login.dart│ │ ├── models│ │ │ ├── models.dart│ │ │ ├── password.dart│ │ │ └── username.dart│ │ └── view│ │ ├── login_form.dart│ │ ├── login_page.dart│ │ └── view.dart
Login Models
Section titled “Login Models”We are using package:formz
to create
reusable and standard models for the username
and password
.
Username
Section titled “Username”import 'package:formz/formz.dart';
enum UsernameValidationError { empty }
class Username extends FormzInput<String, UsernameValidationError> { const Username.pure() : super.pure(''); const Username.dirty([super.value = '']) : super.dirty();
@override UsernameValidationError? validator(String value) { if (value.isEmpty) return UsernameValidationError.empty; return null; }}
For simplicity, we are just validating the username to ensure that it is not empty but in practice you can enforce special character usage, length, etc…
Password
Section titled “Password”import 'package:formz/formz.dart';
enum PasswordValidationError { empty }
class Password extends FormzInput<String, PasswordValidationError> { const Password.pure() : super.pure(''); const Password.dirty([super.value = '']) : super.dirty();
@override PasswordValidationError? validator(String value) { if (value.isEmpty) return PasswordValidationError.empty; return null; }}
Again, we are just performing a simple check to ensure the password is not empty.
Models Barrel
Section titled “Models Barrel”Just like before, there is a models.dart
barrel to make it easy to import the
Username
and Password
models with a single import.
export 'password.dart';export 'username.dart';
Login Bloc
Section titled “Login Bloc”The LoginBloc
manages the state of the LoginForm
and takes care validating
the username and password input as well as the state of the form.
login_event.dart
Section titled “login_event.dart”In this application there are three different LoginEvent
types:
LoginUsernameChanged
: notifies the bloc that the username has been modified.LoginPasswordChanged
: notifies the bloc that the password has been modified.LoginSubmitted
: notifies the bloc that the form has been submitted.
part of 'login_bloc.dart';
sealed class LoginEvent extends Equatable { const LoginEvent();
@override List<Object> get props => [];}
final class LoginUsernameChanged extends LoginEvent { const LoginUsernameChanged(this.username);
final String username;
@override List<Object> get props => [username];}
final class LoginPasswordChanged extends LoginEvent { const LoginPasswordChanged(this.password);
final String password;
@override List<Object> get props => [password];}
final class LoginSubmitted extends LoginEvent { const LoginSubmitted();}
login_state.dart
Section titled “login_state.dart”The LoginState
will contain the status of the form as well as the username and
password input states.
part of 'login_bloc.dart';
final class LoginState extends Equatable { const LoginState({ this.status = FormzSubmissionStatus.initial, this.username = const Username.pure(), this.password = const Password.pure(), this.isValid = false, });
final FormzSubmissionStatus status; final Username username; final Password password; final bool isValid;
LoginState copyWith({ FormzSubmissionStatus? status, Username? username, Password? password, bool? isValid, }) { return LoginState( status: status ?? this.status, username: username ?? this.username, password: password ?? this.password, isValid: isValid ?? this.isValid, ); }
@override List<Object> get props => [status, username, password];}
login_bloc.dart
Section titled “login_bloc.dart”The LoginBloc
is responsible for reacting to user interactions in the
LoginForm
and handling the validation and submission of the form.
import 'package:authentication_repository/authentication_repository.dart';import 'package:bloc/bloc.dart';import 'package:equatable/equatable.dart';import 'package:flutter_login/login/login.dart';import 'package:formz/formz.dart';
part 'login_event.dart';part 'login_state.dart';
class LoginBloc extends Bloc<LoginEvent, LoginState> { LoginBloc({ required AuthenticationRepository authenticationRepository, }) : _authenticationRepository = authenticationRepository, super(const LoginState()) { on<LoginUsernameChanged>(_onUsernameChanged); on<LoginPasswordChanged>(_onPasswordChanged); on<LoginSubmitted>(_onSubmitted); }
final AuthenticationRepository _authenticationRepository;
void _onUsernameChanged( LoginUsernameChanged event, Emitter<LoginState> emit, ) { final username = Username.dirty(event.username); emit( state.copyWith( username: username, isValid: Formz.validate([state.password, username]), ), ); }
void _onPasswordChanged( LoginPasswordChanged event, Emitter<LoginState> emit, ) { final password = Password.dirty(event.password); emit( state.copyWith( password: password, isValid: Formz.validate([password, state.username]), ), ); }
Future<void> _onSubmitted( LoginSubmitted event, Emitter<LoginState> emit, ) async { if (state.isValid) { emit(state.copyWith(status: FormzSubmissionStatus.inProgress)); try { await _authenticationRepository.logIn( username: state.username.value, password: state.password.value, ); emit(state.copyWith(status: FormzSubmissionStatus.success)); } catch (_) { emit(state.copyWith(status: FormzSubmissionStatus.failure)); } } }}
The LoginBloc
has a dependency on the AuthenticationRepository
because when
the form is submitted, it invokes logIn
. The initial state of the bloc is
pure
meaning neither the inputs nor the form has been touched or interacted
with.
Whenever either the username
or password
change, the bloc will create a
dirty variant of the Username
/Password
model and update the form status via
the Formz.validate
API.
When the LoginSubmitted
event is added, if the current status of the form is
valid, the bloc makes a call to logIn
and updates the status based on the
outcome of the request.
Next let’s take a look at the LoginPage
and LoginForm
.
Login Page
Section titled “Login Page”The LoginPage
is responsible for exposing the Route
as well as creating and
providing the LoginBloc
to the LoginForm
.
import 'package:authentication_repository/authentication_repository.dart';import 'package:flutter/material.dart';import 'package:flutter_bloc/flutter_bloc.dart';import 'package:flutter_login/login/login.dart';
class LoginPage extends StatelessWidget { const LoginPage({super.key});
static Route<void> route() { return MaterialPageRoute<void>(builder: (_) => const LoginPage()); }
@override Widget build(BuildContext context) { return Scaffold( body: Padding( padding: const EdgeInsets.all(12), child: BlocProvider( create: (context) => LoginBloc( authenticationRepository: context.read<AuthenticationRepository>(), ), child: const LoginForm(), ), ), ); }}
Login Form
Section titled “Login Form”The LoginForm
handles notifying the LoginBloc
of user events and also
responds to state changes using BlocBuilder
and BlocListener
.
import 'package:flutter/material.dart';import 'package:flutter_bloc/flutter_bloc.dart';import 'package:flutter_login/login/login.dart';import 'package:formz/formz.dart';
class LoginForm extends StatelessWidget { const LoginForm({super.key});
@override Widget build(BuildContext context) { return BlocListener<LoginBloc, LoginState>( listener: (context, state) { if (state.status.isFailure) { ScaffoldMessenger.of(context) ..hideCurrentSnackBar() ..showSnackBar( const SnackBar(content: Text('Authentication Failure')), ); } }, child: Align( alignment: const Alignment(0, -1 / 3), child: Column( mainAxisSize: MainAxisSize.min, children: [ _UsernameInput(), const Padding(padding: EdgeInsets.all(12)), _PasswordInput(), const Padding(padding: EdgeInsets.all(12)), _LoginButton(), ], ), ), ); }}
class _UsernameInput extends StatelessWidget { @override Widget build(BuildContext context) { final displayError = context.select( (LoginBloc bloc) => bloc.state.username.displayError, );
return TextField( key: const Key('loginForm_usernameInput_textField'), onChanged: (username) { context.read<LoginBloc>().add(LoginUsernameChanged(username)); }, decoration: InputDecoration( labelText: 'username', errorText: displayError != null ? 'invalid username' : null, ), ); }}
class _PasswordInput extends StatelessWidget { @override Widget build(BuildContext context) { final displayError = context.select( (LoginBloc bloc) => bloc.state.password.displayError, );
return TextField( key: const Key('loginForm_passwordInput_textField'), onChanged: (password) { context.read<LoginBloc>().add(LoginPasswordChanged(password)); }, obscureText: true, decoration: InputDecoration( labelText: 'password', errorText: displayError != null ? 'invalid password' : null, ), ); }}
class _LoginButton extends StatelessWidget { @override Widget build(BuildContext context) { final isInProgressOrSuccess = context.select( (LoginBloc bloc) => bloc.state.status.isInProgressOrSuccess, );
if (isInProgressOrSuccess) return const CircularProgressIndicator();
final isValid = context.select((LoginBloc bloc) => bloc.state.isValid);
return ElevatedButton( key: const Key('loginForm_continue_raisedButton'), onPressed: isValid ? () => context.read<LoginBloc>().add(const LoginSubmitted()) : null, child: const Text('Login'), ); }}
BlocListener
is used to show a SnackBar
if the login submission fails. In
addition, context.select
is used to efficiently access specific parts of the
LoginState
for each widget, preventing unnecessary rebuilds. The onChanged
callback is used to notify the LoginBloc
of changes to the username/password.
The _LoginButton
widget is only enabled if the status of the form is valid and
a CircularProgressIndicator
is shown in its place while the form is being
submitted.
Upon a successful logIn
request, the state of the AuthenticationBloc
will
change to authenticated
and the user will be navigated to the HomePage
where
we display the user’s id
as well as a button to log out.
├── lib│ ├── home│ │ ├── home.dart│ │ └── view│ │ └── home_page.dart
Home Page
Section titled “Home Page”The HomePage
can access the current user id via
context.select((AuthenticationBloc bloc) => bloc.state.user.id)
and displays
it via a Text
widget. In addition, when the logout button is tapped, an
AuthenticationLogoutPressed
event is added to the AuthenticationBloc
.
import 'package:flutter/material.dart';import 'package:flutter_bloc/flutter_bloc.dart';import 'package:flutter_login/authentication/authentication.dart';
class HomePage extends StatelessWidget { const HomePage({super.key});
static Route<void> route() { return MaterialPageRoute<void>(builder: (_) => const HomePage()); }
@override Widget build(BuildContext context) { return const Scaffold( body: Center( child: Column( mainAxisSize: MainAxisSize.min, children: [_UserId(), _LogoutButton()], ), ), ); }}
class _LogoutButton extends StatelessWidget { const _LogoutButton();
@override Widget build(BuildContext context) { return ElevatedButton( child: const Text('Logout'), onPressed: () { context.read<AuthenticationBloc>().add(AuthenticationLogoutPressed()); }, ); }}
class _UserId extends StatelessWidget { const _UserId();
@override Widget build(BuildContext context) { final userId = context.select( (AuthenticationBloc bloc) => bloc.state.user.id, );
return Text('UserID: $userId'); }}
At this point we have a pretty solid login implementation and we have decoupled our presentation layer from the business logic layer by using Bloc.
The full source for this example (including unit and widget tests) can be found here.