Flutter Firebase Login
Ce contenu n’est pas encore disponible dans votre langue.
In the following tutorial, we’re going to build a Firebase Login Flow in Flutter using the Bloc library.
- BlocProvider, a Flutter widget which provides a bloc to its children.
- Using Cubit instead of Bloc. What’s the difference?
- 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.
- Adding events with context.read.
We’ll start off by creating a brand new Flutter project.
flutter create flutter_firebase_login
Just like in the login tutorial, we’re going to create internal packages to better layer our application architecture and maintain clear boundaries and to maximize both reusability as well as improve testability.
In this case, the firebase_auth and google_sign_in packages are going to be our data layer so we’re only going to be creating an AuthenticationRepository
to compose data from the two API clients.
The AuthenticationRepository
will be responsible for abstracting the internal implementation details of how we authenticate and fetch user information. In this case, it will be integrating with Firebase but we can always change the internal implementation later on and our application will be unaffected.
We’ll start by creating packages/authentication_repository
and a pubspec.yaml
at the root of the project.
name: authentication_repositorydescription: Dart package which manages the authentication domain.version: 1.0.0publish_to: none
environment: sdk: ">=3.6.0 <4.0.0"
dependencies: cache: path: ../cache equatable: ^2.0.0 firebase_auth: ^5.0.0 firebase_core: ^3.0.0 flutter: sdk: flutter google_sign_in: ^6.1.0 meta: ^1.8.0
dev_dependencies: firebase_auth_platform_interface: ^7.0.5 firebase_core_platform_interface: ^5.0.0 flutter_test: sdk: flutter mocktail: ^1.0.0 plugin_platform_interface: ^2.1.7
Next, we can install the dependencies by running:
flutter packages get
in the authentication_repository
directory.
Just like most packages, the authentication_repository
will define it’s API surface via packages/authentication_repository/lib/authentication_repository.dart
export 'src/authentication_repository.dart';export 'src/models/models.dart';
Next, let’s take a look at the models.
The User
model will describe a user in the context of the authentication domain. For the purposes of this example, a user will consist of an email
, id
, name
, and photo
.
The AuthenticationRepository
is responsible for abstracting the underlying implementation of how a user is authenticated, as well as how a user is fetched.
import 'dart:async';
import 'package:authentication_repository/authentication_repository.dart';import 'package:cache/cache.dart';import 'package:firebase_auth/firebase_auth.dart' as firebase_auth;import 'package:flutter/foundation.dart' show kIsWeb;import 'package:google_sign_in/google_sign_in.dart';import 'package:meta/meta.dart';
/// {@template sign_up_with_email_and_password_failure}/// Thrown during the sign up process if a failure occurs./// {@endtemplate}class SignUpWithEmailAndPasswordFailure implements Exception { /// {@macro sign_up_with_email_and_password_failure} const SignUpWithEmailAndPasswordFailure([ this.message = 'An unknown exception occurred.', ]);
/// Create an authentication message /// from a firebase authentication exception code. /// https://pub.dev/documentation/firebase_auth/latest/firebase_auth/FirebaseAuth/createUserWithEmailAndPassword.html factory SignUpWithEmailAndPasswordFailure.fromCode(String code) { switch (code) { case 'invalid-email': return const SignUpWithEmailAndPasswordFailure( 'Email is not valid or badly formatted.', ); case 'user-disabled': return const SignUpWithEmailAndPasswordFailure( 'This user has been disabled. Please contact support for help.', ); case 'email-already-in-use': return const SignUpWithEmailAndPasswordFailure( 'An account already exists for that email.', ); case 'operation-not-allowed': return const SignUpWithEmailAndPasswordFailure( 'Operation is not allowed. Please contact support.', ); case 'weak-password': return const SignUpWithEmailAndPasswordFailure( 'Please enter a stronger password.', ); default: return const SignUpWithEmailAndPasswordFailure(); } }
/// The associated error message. final String message;}
/// {@template log_in_with_email_and_password_failure}/// Thrown during the login process if a failure occurs./// https://pub.dev/documentation/firebase_auth/latest/firebase_auth/FirebaseAuth/signInWithEmailAndPassword.html/// {@endtemplate}class LogInWithEmailAndPasswordFailure implements Exception { /// {@macro log_in_with_email_and_password_failure} const LogInWithEmailAndPasswordFailure([ this.message = 'An unknown exception occurred.', ]);
/// Create an authentication message /// from a firebase authentication exception code. factory LogInWithEmailAndPasswordFailure.fromCode(String code) { switch (code) { case 'invalid-email': return const LogInWithEmailAndPasswordFailure( 'Email is not valid or badly formatted.', ); case 'user-disabled': return const LogInWithEmailAndPasswordFailure( 'This user has been disabled. Please contact support for help.', ); case 'user-not-found': return const LogInWithEmailAndPasswordFailure( 'Email is not found, please create an account.', ); case 'wrong-password': return const LogInWithEmailAndPasswordFailure( 'Incorrect password, please try again.', ); default: return const LogInWithEmailAndPasswordFailure(); } }
/// The associated error message. final String message;}
/// {@template log_in_with_google_failure}/// Thrown during the sign in with google process if a failure occurs./// https://pub.dev/documentation/firebase_auth/latest/firebase_auth/FirebaseAuth/signInWithCredential.html/// {@endtemplate}class LogInWithGoogleFailure implements Exception { /// {@macro log_in_with_google_failure} const LogInWithGoogleFailure([ this.message = 'An unknown exception occurred.', ]);
/// Create an authentication message /// from a firebase authentication exception code. factory LogInWithGoogleFailure.fromCode(String code) { switch (code) { case 'account-exists-with-different-credential': return const LogInWithGoogleFailure( 'Account exists with different credentials.', ); case 'invalid-credential': return const LogInWithGoogleFailure( 'The credential received is malformed or has expired.', ); case 'operation-not-allowed': return const LogInWithGoogleFailure( 'Operation is not allowed. Please contact support.', ); case 'user-disabled': return const LogInWithGoogleFailure( 'This user has been disabled. Please contact support for help.', ); case 'user-not-found': return const LogInWithGoogleFailure( 'Email is not found, please create an account.', ); case 'wrong-password': return const LogInWithGoogleFailure( 'Incorrect password, please try again.', ); case 'invalid-verification-code': return const LogInWithGoogleFailure( 'The credential verification code received is invalid.', ); case 'invalid-verification-id': return const LogInWithGoogleFailure( 'The credential verification ID received is invalid.', ); default: return const LogInWithGoogleFailure(); } }
/// The associated error message. final String message;}
/// Thrown during the logout process if a failure occurs.class LogOutFailure implements Exception {}
/// {@template authentication_repository}/// Repository which manages user authentication./// {@endtemplate}class AuthenticationRepository { /// {@macro authentication_repository} AuthenticationRepository({ CacheClient? cache, firebase_auth.FirebaseAuth? firebaseAuth, GoogleSignIn? googleSignIn, }) : _cache = cache ?? CacheClient(), _firebaseAuth = firebaseAuth ?? firebase_auth.FirebaseAuth.instance, _googleSignIn = googleSignIn ?? GoogleSignIn.standard();
final CacheClient _cache; final firebase_auth.FirebaseAuth _firebaseAuth; final GoogleSignIn _googleSignIn;
/// Whether or not the current environment is web /// Should only be overridden for testing purposes. Otherwise, /// defaults to [kIsWeb] @visibleForTesting bool isWeb = kIsWeb;
/// User cache key. /// Should only be used for testing purposes. @visibleForTesting static const userCacheKey = '__user_cache_key__';
/// Stream of [User] which will emit the current user when /// the authentication state changes. /// /// Emits [User.empty] if the user is not authenticated. Stream<User> get user { return _firebaseAuth.authStateChanges().map((firebaseUser) { final user = firebaseUser == null ? User.empty : firebaseUser.toUser; _cache.write(key: userCacheKey, value: user); return user; }); }
/// Returns the current cached user. /// Defaults to [User.empty] if there is no cached user. User get currentUser { return _cache.read<User>(key: userCacheKey) ?? User.empty; }
/// Creates a new user with the provided [email] and [password]. /// /// Throws a [SignUpWithEmailAndPasswordFailure] if an exception occurs. Future<void> signUp({required String email, required String password}) async { try { await _firebaseAuth.createUserWithEmailAndPassword( email: email, password: password, ); } on firebase_auth.FirebaseAuthException catch (e) { throw SignUpWithEmailAndPasswordFailure.fromCode(e.code); } catch (_) { throw const SignUpWithEmailAndPasswordFailure(); } }
/// Starts the Sign In with Google Flow. /// /// Throws a [LogInWithGoogleFailure] if an exception occurs. Future<void> logInWithGoogle() async { try { late final firebase_auth.AuthCredential credential; if (isWeb) { final googleProvider = firebase_auth.GoogleAuthProvider(); final userCredential = await _firebaseAuth.signInWithPopup( googleProvider, ); credential = userCredential.credential!; } else { final googleUser = await _googleSignIn.signIn(); final googleAuth = await googleUser!.authentication; credential = firebase_auth.GoogleAuthProvider.credential( accessToken: googleAuth.accessToken, idToken: googleAuth.idToken, ); }
await _firebaseAuth.signInWithCredential(credential); } on firebase_auth.FirebaseAuthException catch (e) { throw LogInWithGoogleFailure.fromCode(e.code); } catch (_) { throw const LogInWithGoogleFailure(); } }
/// Signs in with the provided [email] and [password]. /// /// Throws a [LogInWithEmailAndPasswordFailure] if an exception occurs. Future<void> logInWithEmailAndPassword({ required String email, required String password, }) async { try { await _firebaseAuth.signInWithEmailAndPassword( email: email, password: password, ); } on firebase_auth.FirebaseAuthException catch (e) { throw LogInWithEmailAndPasswordFailure.fromCode(e.code); } catch (_) { throw const LogInWithEmailAndPasswordFailure(); } }
/// Signs out the current user which will emit /// [User.empty] from the [user] Stream. /// /// Throws a [LogOutFailure] if an exception occurs. Future<void> logOut() async { try { await Future.wait([ _firebaseAuth.signOut(), _googleSignIn.signOut(), ]); } catch (_) { throw LogOutFailure(); } }}
extension on firebase_auth.User { /// Maps a [firebase_auth.User] into a [User]. User get toUser { return User(id: uid, email: email, name: displayName, photo: photoURL); }}
The AuthenticationRepository
exposes a Stream<User>
which we can subscribe to in order to be notified of when a User
changes. In addition, it exposes methods to signUp
, logInWithGoogle
, logInWithEmailAndPassword
, and logOut
.
That’s it for the AuthenticationRepository
. Next, let’s take a look at how to integrate it into the Flutter project we created.
We need to follow the firebase_auth usage instructions in order to hook up our application to Firebase and enable google_sign_in.
We can replace the generated pubspec.yaml
at the root of the project with the following:
name: flutter_firebase_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 firebase_core: ^3.0.0 flow_builder: ^0.1.0 flutter: sdk: flutter flutter_bloc: ^9.0.0 font_awesome_flutter: ^10.1.0 form_inputs: path: packages/form_inputs formz: ^0.8.0 google_fonts: ^6.0.0 meta: ^1.7.0
dev_dependencies: bloc_test: ^10.0.0 flutter_test: sdk: flutter mocktail: ^1.0.0
flutter: uses-material-design: true assets: - assets/
Notice that we are specifying an assets directory for all of our applications local assets. Create an assets
directory in the root of your project and add the bloc logo asset (which we’ll use later).
Then install all of the dependencies:
flutter packages get
The main.dart
file can be replaced with the following:
import 'package:authentication_repository/authentication_repository.dart';import 'package:bloc/bloc.dart';import 'package:firebase_core/firebase_core.dart';import 'package:flutter/widgets.dart';import 'package:flutter_firebase_login/app/app.dart';
Future<void> main() async { WidgetsFlutterBinding.ensureInitialized(); Bloc.observer = const AppBlocObserver();
await Firebase.initializeApp();
final authenticationRepository = AuthenticationRepository(); await authenticationRepository.user.first;
runApp(App(authenticationRepository: authenticationRepository));}
It’s simply setting up some global configuration for the application and calling runApp
with an instance of App
.
Just like in the login tutorial, our app.dart
will provide an instance of the AuthenticationRepository
to the application via RepositoryProvider
and also creates and provides an instance of AuthenticationBloc
. Then AppView
consumes the AuthenticationBloc
and handles updating the current route based on the AuthenticationState
.
import 'package:authentication_repository/authentication_repository.dart';import 'package:flow_builder/flow_builder.dart';import 'package:flutter/material.dart';import 'package:flutter_bloc/flutter_bloc.dart';import 'package:flutter_firebase_login/app/app.dart';import 'package:flutter_firebase_login/theme.dart';
class App extends StatelessWidget { const App({ required AuthenticationRepository authenticationRepository, super.key, }) : _authenticationRepository = authenticationRepository;
final AuthenticationRepository _authenticationRepository;
@override Widget build(BuildContext context) { return RepositoryProvider.value( value: _authenticationRepository, child: BlocProvider( lazy: false, create: (_) => AppBloc( authenticationRepository: _authenticationRepository, )..add(const AppUserSubscriptionRequested()), child: const AppView(), ), ); }}
class AppView extends StatelessWidget { const AppView({super.key});
@override Widget build(BuildContext context) { return MaterialApp( theme: theme, home: FlowBuilder<AppStatus>( state: context.select((AppBloc bloc) => bloc.state.status), onGeneratePages: onGenerateAppViewPages, ), ); }}
The AppBloc
is responsible for managing the global state of the application. It has a dependency on the AuthenticationRepository
and subscribes to the user
Stream in order to emit new states in response to changes in the current user.
The AppState
consists of an AppStatus
and a User
. The default constructor accepts an optional User
and redirects to the private constructor with the appropriate authentication status.
part of 'app_bloc.dart';
enum AppStatus { authenticated, unauthenticated }
final class AppState extends Equatable { const AppState({User user = User.empty}) : this._( status: user == User.empty ? AppStatus.unauthenticated : AppStatus.authenticated, user: user, );
const AppState._({required this.status, this.user = User.empty});
final AppStatus status; final User user;
@override List<Object> get props => [status, user];}
The AppEvent
has two subclasses:
AppUserSubscriptionRequested
which notifies the bloc to subscribe to the user stream.AppLogoutPressed
which notifies the bloc of a user logout action.
part of 'app_bloc.dart';
sealed class AppEvent { const AppEvent();}
final class AppUserSubscriptionRequested extends AppEvent { const AppUserSubscriptionRequested();}
final class AppLogoutPressed extends AppEvent { const AppLogoutPressed();}
In the constructor body, AppEvent
subclasses are mapped to their corresponding event handlers.
In the _onUserSubscriptionRequested
event handler, the AppBloc
uses emit.onEach
to subscribe to the user stream of the AuthenticationRepository
and emit a state in response to each User
.
emit.onEach
creates a stream subscription internally and takes care of canceling it when either AppBloc
or the user stream is closed.
If the user stream emits an error, addError
forwards the error and stack trace to any BlocObserver
listening.
import 'dart:async';
import 'package:authentication_repository/authentication_repository.dart';import 'package:bloc/bloc.dart';import 'package:equatable/equatable.dart';
part 'app_event.dart';part 'app_state.dart';
class AppBloc extends Bloc<AppEvent, AppState> { AppBloc({required AuthenticationRepository authenticationRepository}) : _authenticationRepository = authenticationRepository, super(AppState(user: authenticationRepository.currentUser)) { on<AppUserSubscriptionRequested>(_onUserSubscriptionRequested); on<AppLogoutPressed>(_onLogoutPressed); }
final AuthenticationRepository _authenticationRepository;
Future<void> _onUserSubscriptionRequested( AppUserSubscriptionRequested event, Emitter<AppState> emit, ) { return emit.onEach( _authenticationRepository.user, onData: (user) => emit(AppState(user: user)), onError: addError, ); }
void _onLogoutPressed( AppLogoutPressed event, Emitter<AppState> emit, ) { _authenticationRepository.logOut(); }}
An Email
and Password
input model are useful for encapsulating the validation logic and will be used in both the LoginForm
and SignUpForm
(later in the tutorial).
Both input models are made using the formz package and allow us to work with a validated object rather than a primitive type like a String
.
import 'package:formz/formz.dart';
/// Validation errors for the [Email] [FormzInput].enum EmailValidationError { /// Generic invalid error. invalid}
/// {@template email}/// Form input for an email input./// {@endtemplate}class Email extends FormzInput<String, EmailValidationError> { /// {@macro email} const Email.pure() : super.pure('');
/// {@macro email} const Email.dirty([super.value = '']) : super.dirty();
static final RegExp _emailRegExp = RegExp( r'^[a-zA-Z0-9.!#$%&’*+/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*$', );
@override EmailValidationError? validator(String? value) { return _emailRegExp.hasMatch(value ?? '') ? null : EmailValidationError.invalid; }}
import 'package:formz/formz.dart';
/// Validation errors for the [Password] [FormzInput].enum PasswordValidationError { /// Generic invalid error. invalid}
/// {@template password}/// Form input for an password input./// {@endtemplate}class Password extends FormzInput<String, PasswordValidationError> { /// {@macro password} const Password.pure() : super.pure('');
/// {@macro password} const Password.dirty([super.value = '']) : super.dirty();
static final _passwordRegExp = RegExp(r'^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d]{8,}$');
@override PasswordValidationError? validator(String? value) { return _passwordRegExp.hasMatch(value ?? '') ? null : PasswordValidationError.invalid; }}
The LoginPage
is responsible for creating and providing an instance of LoginCubit
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_firebase_login/login/login.dart';
class LoginPage extends StatelessWidget { const LoginPage({super.key});
static Page<void> page() => const MaterialPage<void>(child: LoginPage());
@override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text('Login')), body: Padding( padding: const EdgeInsets.all(8), child: BlocProvider( create: (_) => LoginCubit(context.read<AuthenticationRepository>()), child: const LoginForm(), ), ), ); }}
The LoginCubit
is responsible for managing the LoginState
of the form. It exposes APIs to logInWithCredentials
, logInWithGoogle
, as well as gets notified when the email/password are updated.
The LoginState
consists of an Email
, Password
, and FormzStatus
. The Email
and Password
models extend FormzInput
from the formz package.
part of 'login_cubit.dart';
final class LoginState extends Equatable { const LoginState({ this.email = const Email.pure(), this.password = const Password.pure(), this.status = FormzSubmissionStatus.initial, this.isValid = false, this.errorMessage, });
final Email email; final Password password; final FormzSubmissionStatus status; final bool isValid; final String? errorMessage;
@override List<Object?> get props => [email, password, status, isValid, errorMessage];
LoginState copyWith({ Email? email, Password? password, FormzSubmissionStatus? status, bool? isValid, String? errorMessage, }) { return LoginState( email: email ?? this.email, password: password ?? this.password, status: status ?? this.status, isValid: isValid ?? this.isValid, errorMessage: errorMessage ?? this.errorMessage, ); }}
The LoginCubit
has a dependency on the AuthenticationRepository
in order to sign the user in either via credentials or via google sign in.
import 'package:authentication_repository/authentication_repository.dart';import 'package:bloc/bloc.dart';import 'package:equatable/equatable.dart';import 'package:form_inputs/form_inputs.dart';import 'package:formz/formz.dart';
part 'login_state.dart';
class LoginCubit extends Cubit<LoginState> { LoginCubit(this._authenticationRepository) : super(const LoginState());
final AuthenticationRepository _authenticationRepository;
void emailChanged(String value) { final email = Email.dirty(value); emit( state.copyWith( email: email, isValid: Formz.validate([email, state.password]), ), ); }
void passwordChanged(String value) { final password = Password.dirty(value); emit( state.copyWith( password: password, isValid: Formz.validate([state.email, password]), ), ); }
Future<void> logInWithCredentials() async { if (!state.isValid) return; emit(state.copyWith(status: FormzSubmissionStatus.inProgress)); try { await _authenticationRepository.logInWithEmailAndPassword( email: state.email.value, password: state.password.value, ); emit(state.copyWith(status: FormzSubmissionStatus.success)); } on LogInWithEmailAndPasswordFailure catch (e) { emit( state.copyWith( errorMessage: e.message, status: FormzSubmissionStatus.failure, ), ); } catch (_) { emit(state.copyWith(status: FormzSubmissionStatus.failure)); } }
Future<void> logInWithGoogle() async { emit(state.copyWith(status: FormzSubmissionStatus.inProgress)); try { await _authenticationRepository.logInWithGoogle(); emit(state.copyWith(status: FormzSubmissionStatus.success)); } on LogInWithGoogleFailure catch (e) { emit( state.copyWith( errorMessage: e.message, status: FormzSubmissionStatus.failure, ), ); } catch (_) { emit(state.copyWith(status: FormzSubmissionStatus.failure)); } }}
The LoginForm
is responsible for rendering the form in response to the LoginState
and invokes methods on the LoginCubit
in response to user interactions.
import 'package:flutter/material.dart';import 'package:flutter_bloc/flutter_bloc.dart';import 'package:flutter_firebase_login/login/login.dart';import 'package:flutter_firebase_login/sign_up/sign_up.dart';import 'package:font_awesome_flutter/font_awesome_flutter.dart';import 'package:formz/formz.dart';
class LoginForm extends StatelessWidget { const LoginForm({super.key});
@override Widget build(BuildContext context) { return BlocListener<LoginCubit, LoginState>( listener: (context, state) { if (state.status.isFailure) { ScaffoldMessenger.of(context) ..hideCurrentSnackBar() ..showSnackBar( SnackBar( content: Text(state.errorMessage ?? 'Authentication Failure'), ), ); } }, child: Align( alignment: const Alignment(0, -1 / 3), child: SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, children: [ Image.asset( 'assets/bloc_logo_small.png', height: 120, ), const SizedBox(height: 16), _EmailInput(), const SizedBox(height: 8), _PasswordInput(), const SizedBox(height: 8), _LoginButton(), const SizedBox(height: 8), _GoogleLoginButton(), const SizedBox(height: 4), _SignUpButton(), ], ), ), ), ); }}
class _EmailInput extends StatelessWidget { @override Widget build(BuildContext context) { final displayError = context.select( (LoginCubit cubit) => cubit.state.email.displayError, );
return TextField( key: const Key('loginForm_emailInput_textField'), onChanged: (email) => context.read<LoginCubit>().emailChanged(email), keyboardType: TextInputType.emailAddress, decoration: InputDecoration( labelText: 'email', helperText: '', errorText: displayError != null ? 'invalid email' : null, ), ); }}
class _PasswordInput extends StatelessWidget { @override Widget build(BuildContext context) { final displayError = context.select( (LoginCubit cubit) => cubit.state.password.displayError, );
return TextField( key: const Key('loginForm_passwordInput_textField'), onChanged: (password) => context.read<LoginCubit>().passwordChanged(password), obscureText: true, decoration: InputDecoration( labelText: 'password', helperText: '', errorText: displayError != null ? 'invalid password' : null, ), ); }}
class _LoginButton extends StatelessWidget { @override Widget build(BuildContext context) { final isInProgress = context.select( (LoginCubit cubit) => cubit.state.status.isInProgress, );
if (isInProgress) return const CircularProgressIndicator();
final isValid = context.select( (LoginCubit cubit) => cubit.state.isValid, );
return ElevatedButton( key: const Key('loginForm_continue_raisedButton'), style: ElevatedButton.styleFrom( shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(30), ), backgroundColor: const Color(0xFFFFD600), ), onPressed: isValid ? () => context.read<LoginCubit>().logInWithCredentials() : null, child: const Text('LOGIN'), ); }}
class _GoogleLoginButton extends StatelessWidget { @override Widget build(BuildContext context) { final theme = Theme.of(context); return ElevatedButton.icon( key: const Key('loginForm_googleLogin_raisedButton'), label: const Text( 'SIGN IN WITH GOOGLE', style: TextStyle(color: Colors.white), ), style: ElevatedButton.styleFrom( shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(30), ), backgroundColor: theme.colorScheme.secondary, ), icon: const Icon(FontAwesomeIcons.google, color: Colors.white), onPressed: () => context.read<LoginCubit>().logInWithGoogle(), ); }}
class _SignUpButton extends StatelessWidget { @override Widget build(BuildContext context) { final theme = Theme.of(context); return TextButton( key: const Key('loginForm_createAccount_flatButton'), onPressed: () => Navigator.of(context).push<void>(SignUpPage.route()), child: Text( 'CREATE ACCOUNT', style: TextStyle(color: theme.primaryColor), ), ); }}
The LoginForm
also renders a “Create Account” button which navigates to the SignUpPage
where a user can create a brand new account.
The SignUp
structure mirrors the Login
structure and consists of a SignUpPage
, SignUpView
, and SignUpCubit
.
The SignUpPage
is just responsible for creating and providing an instance of the SignUpCubit
to the SignUpForm
(exactly like in LoginPage
).
import 'package:authentication_repository/authentication_repository.dart';import 'package:flutter/material.dart';import 'package:flutter_bloc/flutter_bloc.dart';import 'package:flutter_firebase_login/sign_up/sign_up.dart';
class SignUpPage extends StatelessWidget { const SignUpPage({super.key});
static Route<void> route() { return MaterialPageRoute<void>(builder: (_) => const SignUpPage()); }
@override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text('Sign Up')), body: Padding( padding: const EdgeInsets.all(8), child: BlocProvider<SignUpCubit>( create: (_) => SignUpCubit(context.read<AuthenticationRepository>()), child: const SignUpForm(), ), ), ); }}
The SignUpCubit
manages the state of the SignUpForm
and communicates with the AuthenticationRepository
in order to create new user accounts.
The SignUpState
reuses the same Email
and Password
form input models because the validation logic is the same.
part of 'sign_up_cubit.dart';
final class SignUpState extends Equatable { const SignUpState({ this.email = const Email.pure(), this.password = const Password.pure(), this.confirmedPassword = const ConfirmedPassword.pure(), this.status = FormzSubmissionStatus.initial, this.isValid = false, this.errorMessage, });
final Email email; final Password password; final ConfirmedPassword confirmedPassword; final FormzSubmissionStatus status; final bool isValid; final String? errorMessage;
@override List<Object?> get props => [ email, password, confirmedPassword, status, isValid, errorMessage, ];
SignUpState copyWith({ Email? email, Password? password, ConfirmedPassword? confirmedPassword, FormzSubmissionStatus? status, bool? isValid, String? errorMessage, }) { return SignUpState( email: email ?? this.email, password: password ?? this.password, confirmedPassword: confirmedPassword ?? this.confirmedPassword, status: status ?? this.status, isValid: isValid ?? this.isValid, errorMessage: errorMessage ?? this.errorMessage, ); }}
The SignUpCubit
is extremely similar to the LoginCubit
with the main exception being it exposes an API to submit the form as opposed to login.
import 'package:authentication_repository/authentication_repository.dart';import 'package:bloc/bloc.dart';import 'package:equatable/equatable.dart';import 'package:form_inputs/form_inputs.dart';import 'package:formz/formz.dart';
part 'sign_up_state.dart';
class SignUpCubit extends Cubit<SignUpState> { SignUpCubit(this._authenticationRepository) : super(const SignUpState());
final AuthenticationRepository _authenticationRepository;
void emailChanged(String value) { final email = Email.dirty(value); emit( state.copyWith( email: email, isValid: Formz.validate([ email, state.password, state.confirmedPassword, ]), ), ); }
void passwordChanged(String value) { final password = Password.dirty(value); final confirmedPassword = ConfirmedPassword.dirty( password: password.value, value: state.confirmedPassword.value, ); emit( state.copyWith( password: password, confirmedPassword: confirmedPassword, isValid: Formz.validate([ state.email, password, confirmedPassword, ]), ), ); }
void confirmedPasswordChanged(String value) { final confirmedPassword = ConfirmedPassword.dirty( password: state.password.value, value: value, ); emit( state.copyWith( confirmedPassword: confirmedPassword, isValid: Formz.validate([ state.email, state.password, confirmedPassword, ]), ), ); }
Future<void> signUpFormSubmitted() async { if (!state.isValid) return; emit(state.copyWith(status: FormzSubmissionStatus.inProgress)); try { await _authenticationRepository.signUp( email: state.email.value, password: state.password.value, ); emit(state.copyWith(status: FormzSubmissionStatus.success)); } on SignUpWithEmailAndPasswordFailure catch (e) { emit( state.copyWith( errorMessage: e.message, status: FormzSubmissionStatus.failure, ), ); } catch (_) { emit(state.copyWith(status: FormzSubmissionStatus.failure)); } }}
The SignUpForm
is responsible for rendering the form in response to the SignUpState
and invokes methods on the SignUpCubit
in response to user interactions.
import 'package:flutter/material.dart';import 'package:flutter_bloc/flutter_bloc.dart';import 'package:flutter_firebase_login/sign_up/sign_up.dart';import 'package:formz/formz.dart';
class SignUpForm extends StatelessWidget { const SignUpForm({super.key});
@override Widget build(BuildContext context) { return BlocListener<SignUpCubit, SignUpState>( listener: (context, state) { if (state.status.isSuccess) { Navigator.of(context).pop(); } else if (state.status.isFailure) { ScaffoldMessenger.of(context) ..hideCurrentSnackBar() ..showSnackBar( SnackBar(content: Text(state.errorMessage ?? 'Sign Up Failure')), ); } }, child: Align( alignment: const Alignment(0, -1 / 3), child: Column( mainAxisSize: MainAxisSize.min, children: [ _EmailInput(), const SizedBox(height: 8), _PasswordInput(), const SizedBox(height: 8), _ConfirmPasswordInput(), const SizedBox(height: 8), _SignUpButton(), ], ), ), ); }}
class _EmailInput extends StatelessWidget { @override Widget build(BuildContext context) { final displayError = context.select( (SignUpCubit cubit) => cubit.state.email.displayError, );
return TextField( key: const Key('signUpForm_emailInput_textField'), onChanged: (email) => context.read<SignUpCubit>().emailChanged(email), keyboardType: TextInputType.emailAddress, decoration: InputDecoration( labelText: 'email', helperText: '', errorText: displayError != null ? 'invalid email' : null, ), ); }}
class _PasswordInput extends StatelessWidget { @override Widget build(BuildContext context) { final displayError = context.select( (SignUpCubit cubit) => cubit.state.password.displayError, );
return TextField( key: const Key('signUpForm_passwordInput_textField'), onChanged: (password) => context.read<SignUpCubit>().passwordChanged(password), obscureText: true, decoration: InputDecoration( labelText: 'password', helperText: '', errorText: displayError != null ? 'invalid password' : null, ), ); }}
class _ConfirmPasswordInput extends StatelessWidget { @override Widget build(BuildContext context) { final displayError = context.select( (SignUpCubit cubit) => cubit.state.confirmedPassword.displayError, );
return TextField( key: const Key('signUpForm_confirmedPasswordInput_textField'), onChanged: (confirmPassword) => context.read<SignUpCubit>().confirmedPasswordChanged(confirmPassword), obscureText: true, decoration: InputDecoration( labelText: 'confirm password', helperText: '', errorText: displayError != null ? 'passwords do not match' : null, ), ); }}
class _SignUpButton extends StatelessWidget { @override Widget build(BuildContext context) { final isInProgress = context.select( (SignUpCubit cubit) => cubit.state.status.isInProgress, );
if (isInProgress) return const CircularProgressIndicator();
final isValid = context.select( (SignUpCubit cubit) => cubit.state.isValid, );
return ElevatedButton( key: const Key('signUpForm_continue_raisedButton'), style: ElevatedButton.styleFrom( shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(30), ), backgroundColor: Colors.orangeAccent, ), onPressed: isValid ? () => context.read<SignUpCubit>().signUpFormSubmitted() : null, child: const Text('SIGN UP'), ); }}
After a user either successfully logs in or signs up, the user
stream will be updated which will trigger a state change in the AuthenticationBloc
and will result in the AppView
pushing the HomePage
route onto the navigation stack.
From the HomePage
, the user can view their profile information and log out by tapping the exit icon in the AppBar
.
import 'package:flutter/material.dart';import 'package:flutter_bloc/flutter_bloc.dart';import 'package:flutter_firebase_login/app/app.dart';import 'package:flutter_firebase_login/home/home.dart';
class HomePage extends StatelessWidget { const HomePage({super.key});
static Page<void> page() => const MaterialPage<void>(child: HomePage());
@override Widget build(BuildContext context) { final textTheme = Theme.of(context).textTheme; final user = context.select((AppBloc bloc) => bloc.state.user); return Scaffold( appBar: AppBar( title: const Text('Home'), actions: <Widget>[ IconButton( key: const Key('homePage_logout_iconButton'), icon: const Icon(Icons.exit_to_app), onPressed: () { context.read<AppBloc>().add(const AppLogoutPressed()); }, ), ], ), body: Align( alignment: const Alignment(0, -1 / 3), child: Column( mainAxisSize: MainAxisSize.min, children: <Widget>[ Avatar(photo: user.photo), const SizedBox(height: 4), Text(user.email ?? '', style: textTheme.titleLarge), const SizedBox(height: 4), Text(user.name ?? '', style: textTheme.headlineSmall), ], ), ), ); }}
At this point we have a pretty solid login implementation using Firebase and we have decoupled our presentation layer from the business logic layer by using the Bloc Library.
The full source for this example can be found here.