컨텐츠로 건너뛰기

Flutter Login

이 내용은 아직 번역본이 없습니다.

intermediate

In the following tutorial, we’re going to build a Login Flow in Flutter using the Bloc library.

demo

Key Topics

Project Setup

We’ll start off by creating a brand new Flutter project

Terminal window
flutter create flutter_login

Next, we can install all of our dependencies

Terminal window
flutter packages get

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:

packages/authentication_repository/pubspec.yaml
name: authentication_repository
description: Dart package which manages the authentication domain.
publish_to: none
environment:
sdk: ">=3.0.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.

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:

packages/authentication_repository/lib/authentication_repository.dart
export 'src/authentication_repository.dart';

That’s it for the AuthenticationRepository, next we’ll work on the UserRepository.

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:

packages/user_repository/pubspec.yaml
name: user_repository
description: Dart package which manages the user domain.
publish_to: none
environment:
sdk: ">=3.0.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:

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.

packages/user_repository/lib/src/models/models.dart
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.

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:

packages/user_repository/lib/user_repository.dart
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

Let’s start by updating the generated pubspec.yaml at the root of our project:

pubspec.yaml
name: flutter_login
description: A new Flutter project.
version: 1.0.0+1
publish_to: none
environment:
sdk: ">=3.0.0 <4.0.0"
dependencies:
authentication_repository:
path: packages/authentication_repository
bloc: ^8.1.0
equatable: ^2.0.3
flutter:
sdk: flutter
flutter_bloc: ^8.1.1
formz: ^0.6.0
user_repository:
path: packages/user_repository
dev_dependencies:
bloc_test: ^9.0.0
flutter_test:
sdk: flutter
mocktail: ^1.0.0
flutter:
uses-material-design: true

We can install the dependencies by running:

Terminal window
flutter packages get

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

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 the AuthenticationStatus stream
  • AuthenticationLogoutPressed: notifies the bloc of a user logout action
lib/authentication/bloc/authentication_event.dart
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

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.

lib/authentication/bloc/authentication_state.dart
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

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.

lib/authentication/bloc/authentication_bloc.dart
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

Next, we can replace the default main.dart with:

lib/main.dart
import 'package:flutter/widgets.dart';
import 'package:flutter_login/app.dart';
void main() => runApp(const App());

App

app.dart will contain the root App widget for the entire application.

lib/app.dart
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 StatefulWidget {
const App({super.key});
@override
State<App> createState() => _AppState();
}
class _AppState extends State<App> {
late final AuthenticationRepository _authenticationRepository;
late final UserRepository _userRepository;
@override
void initState() {
super.initState();
_authenticationRepository = AuthenticationRepository();
_userRepository = UserRepository();
}
@override
void dispose() {
_authenticationRepository.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return RepositoryProvider.value(
value: _authenticationRepository,
child: BlocProvider(
lazy: false,
create: (_) => AuthenticationBloc(
authenticationRepository: _authenticationRepository,
userRepository: _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

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

Login

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

We are using package:formz to create reusable and standard models for the username and password.

Username

lib/login/models/username.dart
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

lib/login/models/password.dart
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

Just like before, there is a models.dart barrel to make it easy to import the Username and Password models with a single import.

lib/login/models/models.dart
export 'password.dart';
export 'username.dart';

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

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.
lib/login/bloc/login_event.dart
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

The LoginState will contain the status of the form as well as the username and password input states.

lib/login/bloc/login_state.dart
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

The LoginBloc is responsible for reacting to user interactions in the LoginForm and handling the validation and submission of the form.

lib/login/bloc/login_bloc.dart
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

The LoginPage is responsible for exposing the Route as well as creating and providing the LoginBloc to the LoginForm.

lib/login/view/login_page.dart
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

The LoginForm handles notifying the LoginBloc of user events and also responds to state changes using BlocBuilder and BlocListener.

lib/login/view/login_form.dart
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, BlocBuilder widgets are used to wrap each of the TextField widgets and make use of the buildWhen property in order to optimize for 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.

Home

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

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 AuthenticationLogoutRequested event is added to the AuthenticationBloc.

lib/home/view/home_page.dart
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.