Flutter Login
このコンテンツはまだ日本語訳がありません。
In the following tutorial, we’re going to build a Login Flow in Flutter using the Bloc library.
- 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.
We’ll start off by creating a brand new Flutter project
flutter create flutter_login
Next, we can install all of our dependencies
flutter packages get
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
.
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.
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.0.0 formz: ^0.8.0 user_repository: path: packages/user_repository
dev_dependencies: 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 packages get
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
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
.
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
.
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
.
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 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
.
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
We are using package:formz
to create reusable and standard models for the username
and password
.
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…
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.
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';
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.
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();}
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];}
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
.
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(), ), ), ); }}
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
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
.
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.