Authentication using Flutter bloc and Dependency Injection.
In this article, we’ll be attempting to build an authentication module that can be the base for most of the project. We’ll be using the package flutter_bloc for the state management with the application of dependency injection. Please go through my article on dependency injection if you’re not familiar with implementing dependency injection in a flutter application. Without any further ado, let's get started with our application.
Run the command to create a brand new project:
flutter create auth_module_flutter
Let's remove all the boilerplate code from the main.dart
file and replace it with the following code.
The code displays 3 different sections as shown in the image. The section in red is always visible. In the real-world scenario, these can be something like some app banner images or some article listing that does not require users' login information. The second section in green requires the users to log in to be visible. These could be the blocks showing the user details. Finally, the section in blue is only visible as long as the user is not logged in. These can be the buttons to register or sign in which are no longer required as soon as the user is logged in. The red section is always visible but it may contain some sections that may require the user information ie. need of login. Also, there is a drawer with two items - the first item “Login” should change to “Logout” as soon as the user logs in, and the second item “User profile ” should only be visible after the login. We’ll implement all of these and try to explain different scenarios as best as I could.
Let's set up the dependency injection using injector and get_it and we’ll be mocking all the API calls. Please consider checking out the article for setting up DI or you can look up google for other articles related to this.
Add the following dependencies in pubspec.yaml file.
dependencies:
...
injectable: ^1.0.7
get_it: ^5.0.6
json_annotation: ^3.1.1
flutter_bloc: ^7.0.0
shared_preferences: ^2.0.5
equatable: ^1.2.6dev_dependencies:
...
injectable_generator: ^1.0.6
build_runner: ^1.10.6
json_serializable: ^3.5.0
The mock user API could be something like the below or you can choose your own implementation.
Here the files injectable.config.dart and user_auth.g.dart are auto-generated by the command :
flutter packages pub run build_runner build — delete-conflicting-outputs
The structure of the project looks as shown in the figure to the left. UserAuth class is used to store the user's tokens which we’ll be storing in the app for later use. Using the token we’ll fetch the user information later in this article. The username ‘flutter’ and password ‘password’ is considered the valid-user. Also, there is a to-do section that the user can utilize to make their own API calls and use that instead of the mock call.
We’ll proceed next to build the UI for the login. For this article, we’ll be using popups to display the login forms but you can use a separate page if you prefer so.
But before we actually start building the UI, let's first define some constants and commonly used form widgets as follows:
//you may define these in colors.dart file
const PrimaryBlueColor = Color(0xFF40A8F4);
const FaintPinkColor = Color(0xffEA7F8B);
const DarkBlue = Color(0xff2D3B51);
const Grey = Color(0xFFa19f9d);
const LightGrey = Color(0xdddedddc);//you may define these in dimensions.dart file
const double FONT_NORMAL = 16;
const double FONT_SMALL = 14;
const double FONT_XSMALL = 13;
const double FONT_XXSMALL = 12;
const double GAP_SMALL = 8;
Also, let's create separate classes for TextField and Button so that the code repetition is as less as possible. So let's create a custom_button.dart
file
import 'package:auth_module_flutter/constants/colors.dart';
import 'package:auth_module_flutter/constants/dimensions.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
class CustomButton extends StatelessWidget {
const CustomButton({
Key key,
this.color: FaintPinkColor,
this.child,
this.textColor: Colors.white,
this.onPressed,
this.disabled: false,
this.label: '',
this.fontSize: FONT_SMALL,
this.padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
}) : super(key: key);
final VoidCallback onPressed;
final Color color;
final Color textColor;
final EdgeInsets padding;
final String label;
final bool disabled;
final double fontSize;
final Widget child;
@override
Widget build(BuildContext context) {
return ElevatedButton(
style: ElevatedButton.styleFrom(
primary: color,
padding: padding,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(30.0),
)),
onPressed: disabled ? null : onPressed,
child: child ??
Text(
label,
style: TextStyle(color: textColor, fontSize: fontSize),
),
);
}
}
and also create custom_text_field.dart.
import 'package:auth_module_flutter/constants/colors.dart';
import 'package:auth_module_flutter/constants/dimensions.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
class CustomTextField extends StatelessWidget {
const CustomTextField(
{Key key,
this.focusNode,
this.onTap,
this.label,
this.onSubmitted,
this.onChanged,
this.controller,
this.enabled: true,
this.placeholder,
this.keyboardType,
this.suffixIcon,
this.obscureText: false,
this.readOnly: false,
this.max: 999999,
this.filled: false,
this.prefixIcon,
this.errorMaxLines: 1,
this.maxLines: 1,
this.focusedColor: PrimaryBlueColor,
this.errorText: '',
this.valid: true,
this.inputFormatters})
: super(key: key);
final FocusNode focusNode;
final TextEditingController controller;
final String placeholder;
final String errorText;
final String label;
final bool filled;
final int errorMaxLines;
final int maxLines;
final IconButton prefixIcon;
final void Function(String) onSubmitted;
final void Function(String) onChanged;
final bool enabled;
final bool valid;
final bool readOnly;
final bool obscureText;
final TextInputType keyboardType;
final IconButton suffixIcon;
final VoidCallback onTap;
final Color focusedColor;
final int max;
final List<TextInputFormatter> inputFormatters;
@override
Widget build(BuildContext context) {
return TextField(
focusNode: focusNode,
controller: controller,
textInputAction: TextInputAction.go,
onSubmitted: onSubmitted,
inputFormatters: inputFormatters,
maxLines: maxLines,
onChanged: onChanged,
decoration: InputDecoration(
errorText: valid ? null : errorText,
errorMaxLines: errorMaxLines,
suffixIcon: Padding(
padding: const EdgeInsets.only(right: GAP_SMALL),
child: suffixIcon,
),
prefixIcon: prefixIcon,
filled: filled,
errorBorder: OutlineInputBorder(
borderSide: BorderSide(color: Colors.red),
borderRadius: BorderRadius.all(
const Radius.circular(30.0),
)),
focusedErrorBorder: OutlineInputBorder(
borderSide: BorderSide(color: Colors.red),
borderRadius: BorderRadius.all(
const Radius.circular(30.0),
)),
enabledBorder: OutlineInputBorder(
borderSide: BorderSide(color: LightGrey),
borderRadius: BorderRadius.all(
const Radius.circular(30.0),
)),
focusedBorder: OutlineInputBorder(
borderSide: BorderSide(color: focusedColor),
borderRadius: BorderRadius.all(
const Radius.circular(30.0),
)),
disabledBorder: OutlineInputBorder(
borderSide: BorderSide(color: LightGrey),
borderRadius: BorderRadius.all(
const Radius.circular(30.0),
)),
counter: SizedBox.shrink(),
hintText: placeholder,
// labelText: this.label,
isDense: true,
hintStyle: TextStyle(color: LightGrey, fontSize: FONT_NORMAL),
contentPadding:
const EdgeInsets.symmetric(vertical: 14, horizontal: 20)),
obscureText: obscureText,
keyboardType: keyboardType,
enabled: enabled,
maxLength: max,
readOnly: readOnly,
autocorrect: false,
onTap: onTap,
);
}
}
Now create a diaglog_utils file and insert the following code:
import 'package:auth_module_flutter/screens/auth/auth.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
abstract class DialogUtils {
static Future _showDialogBox(BuildContext context,
{dismissible: false, Widget child}) {
return showDialog(
context: context,
barrierDismissible: dismissible,
barrierColor: Color(0xff000000).withOpacity(.3),
builder: (BuildContext dialogContext) {
return Dialog(
shape:
RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: child);
},
);
}
static Future showAuthDialog(BuildContext context, {dismissible: false}) {
return _showDialogBox(context,
dismissible: dismissible, child: LoginForm());
}
}
The LoginForm class contents are:
import 'package:auth_module_flutter/screens/form/custom_button.dart';
import 'package:auth_module_flutter/screens/form/custom_text_field.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
class LoginForm extends StatefulWidget {
@override
_LoginFormState createState() => _LoginFormState();
}
class _LoginFormState extends State<LoginForm> {
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 20, horizontal: 10),
child: Column(
children: [
Row(
children: [
Expanded(
child: Text(
"Login",
textAlign: TextAlign.center,
)),
IconButton(
icon: Icon(Icons.close),
onPressed: () => Navigator.of(context).pop())
],
),
SizedBox(
height: 10,
),
CustomTextField(
placeholder: 'Username',
),
SizedBox(
height: 10,
),
CustomTextField(
placeholder: 'Password',
),
CustomButton(
label: "Sign In",
),
],
mainAxisSize: MainAxisSize.min,
),
);
}
}
Now add a trigger in the app drawer so that the login popup gets displayed. For that we just have to call the function DialogUtils.showAuthDialog(context);
This is just the UI portion of the app. We will work next on the validation of the fields and the submission of the form by making the API call. We then have to authenticate the logged-in user and show or hide the contents accordingly.
The first step is to get the username and password that the user will enter. This could be done by using bloc or by just using stateful widgets. I prefer to do it by using the stateful widget because it's more simple and this is the reason why I chose to make the LoginForm class stateful. Before we proceed further let's analyze the possible result and how to handle them properly. If the username and password are wrong we must inform the users about the failure. We will do this by using a toast with the failure message. Also, we'll be disabling the button when the API call is still in progress and the success case is handled by showing detailed information about the user being logged. Finally, we also will be saving the logged-in user information so that the users don't have to log in time and again unless they log out of the system. For this, we’ll be using the shared_preferences library.
import 'dart:convert';
import 'package:auth_module_flutter/blocs/token/token_cubit.dart';
import 'package:auth_module_flutter/data/models/user.dart';
import 'package:auth_module_flutter/data/models/user_auth.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:shared_preferences/shared_preferences.dart';
abstract class PrefUtils {
static final Future<SharedPreferences> prefs =
SharedPreferences.getInstance();
static const String USER_IDENTIFIER = "auth_module_flutter_";
static final Map<AUTH_STORE, dynamic> authStore = <AUTH_STORE, dynamic>{
AUTH_STORE.isLoggedIn: false
};
static bool isUserLoggedIn() {
return authStore[AUTH_STORE.isLoggedIn];
}
static UserAuth getUserAuthData() {
return authStore[AUTH_STORE.auth];
}
static User getStoredUser() {
return authStore[AUTH_STORE.user];
}
static Future<void> loadUserAuthData() async {
final userString = (await prefs).get(USER_IDENTIFIER);
if (userString != null) {
final userJson = json.decode(userString);
updateUserSetting(
userAuthData: UserAuth.fromJson(userJson), isLoggedIn: true);
} else {
return null;
}
}
static Future<bool> storeUserAuthData(UserAuth userAuthData) async {
updateUserSetting(userAuthData: userAuthData, isLoggedIn: true);
final userAuth = userAuthData.toJson();
return (await prefs).setString(USER_IDENTIFIER, jsonEncode(userAuth));
}
static Future<bool> clearUserToken() async {
updateUserSetting(clear: true);
return (await prefs).remove(USER_IDENTIFIER);
}
static void updateUserSetting(
{UserAuth userAuthData,
bool isLoggedIn,
dynamic user,
bool clear: false}) async {
if (userAuthData != null || clear) {
authStore[AUTH_STORE.auth] = userAuthData;
}
if (user != null || clear) {
authStore[AUTH_STORE.user] = user;
}
if (isLoggedIn != null || clear) {
authStore[AUTH_STORE.isLoggedIn] = isLoggedIn ?? false;
}
}
static void loggedIn(UserAuth userAuthData, TokenCubit cubit) async {
await storeUserAuthData(userAuthData);
cubit.update(isLoggedIn: true, userAuth: userAuthData);
cubit.loadUserData();
}
static void loggedOut(BuildContext context) async {
await clearUserToken();
context.read<TokenCubit>().reset();
}
}
enum AUTH_STORE { auth, isLoggedIn, user }
Also, we’ll be needing a way to make an API call when the user presses the login button. We’ll be using auth cubit for that.
import 'package:auth_module_flutter/blocs/token/token_cubit.dart';
import 'package:auth_module_flutter/data/models/user_auth.dart';
import 'package:auth_module_flutter/data/repositories/user_repository.dart';
import 'package:auth_module_flutter/utils/pref_utils.dart';
import 'package:equatable/equatable.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:injectable/injectable.dart';
part 'auth_state.dart';
@injectable
class AuthCubit extends Cubit<AuthState> {
final UserRepository _userRepository;
final TokenCubit _tokenCubit;
AuthCubit(
{UserRepository userRepository, @factoryParam TokenCubit tokenCubit})
: this._userRepository = userRepository,
this._tokenCubit = tokenCubit,
super(AuthState.initial());
void login(username, password) async {
try {
emit(state.update(loading: true));
UserAuth _auth = await _userRepository.handleLogin(username, password);
PrefUtils.loggedIn(_auth, _tokenCubit);
emit(state.update(loading: false, success: true));
} catch (e) {
emit(state.update(loading: false, error: e.toString()));
}
}
}part of 'auth_cubit.dart';
@immutable
class AuthState extends Equatable {
final UserAuth authUser;
final String error;
final bool loading;
final bool success;
bool get hasError => error.isNotEmpty;
const AuthState({this.authUser, this.loading, this.error, this.success});
@override
List<Object> get props => [authUser, error, loading, success];
factory AuthState.initial() {
return AuthState(loading: false, error: "", success: false);
}
AuthState update({String error, UserAuth authUser, bool loading, bool success}) {
return AuthState(
error: error ?? "",
loading: loading ?? this.loading,
success: success ?? this.success,
authUser: authUser ?? this.authUser,
);
}
}
Finally, to automatically notify all the views about the logged-in state without reloading we’ll be using a separate cubit to store the login state.
import 'package:auth_module_flutter/data/models/user_auth.dart';
import 'package:auth_module_flutter/data/repositories/user_repository.dart';
import 'package:auth_module_flutter/utils/pref_utils.dart';
import 'package:equatable/equatable.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:injectable/injectable.dart';
part 'token_state.dart';
@injectable
class TokenCubit extends Cubit<TokenState> {
final UserRepository _userRepository;
TokenCubit({UserRepository userRepository})
: _userRepository = userRepository,
super(TokenState.initial()) {
if (PrefUtils.isUserLoggedIn()) {
emit(state.update(isLoggedIn: true));
loadUserData();
} else {
emit(state.update(loading: false));
}
}
void reset() {
emit(TokenState.initial(loading: false));
}
void update(
{bool loading,
bool error,
bool isLoggedIn,
userAuth,
user,
bool forceTrigger}) async {
emit(state.update(
loading: loading,
error: error,
isLoggedIn: isLoggedIn,
userAuth: userAuth,
user: user));
}
void loadUserData() async {
try {
if (PrefUtils.isUserLoggedIn()) {
emit(state.update(loading: true));
final user = await _userRepository.handleGetUser();
emit(state.update(loading: false, isLoggedIn: true, user: user));
}
} catch (e) {
emit(state.update(loading: false, error: true));
}
}
}part of 'token_cubit.dart';
@immutable
class TokenState extends Equatable {
final bool loading;
final bool error;
final bool isLoggedIn;
final UserAuth userAuth;
final dynamic user;
bool get isUserSet => user != null;
TokenState(
{this.error, this.loading, this.isLoggedIn, this.userAuth, this.user});
factory TokenState.initial({loading}) {
return TokenState(
loading: loading ?? true, error: false, isLoggedIn: false);
}
TokenState update(
{bool loading, bool error, bool isLoggedIn, userAuth, user}) {
return TokenState(
loading: loading ?? this.loading,
isLoggedIn: isLoggedIn ?? this.isLoggedIn,
userAuth: userAuth ?? this.userAuth,
user: user ?? this.user,
error: error ?? false);
}
@override
String toString() =>
'TokenState { error: $error, loading: $loading, isLoggedIn: $isLoggedIn }';
@override
List<Object> get props => [loading, error, isLoggedIn, userAuth, user];
}
We are now ready to implement the API call.
import 'package:auth_module_flutter/blocs/token/token_cubit.dart';
import 'package:auth_module_flutter/screens/auth/bloc/auth_cubit.dart';
import 'package:auth_module_flutter/screens/form/custom_button.dart';
import 'package:auth_module_flutter/screens/form/custom_text_field.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:get_it/get_it.dart';
class LoginForm extends StatefulWidget {
@override
_LoginFormState createState() => _LoginFormState();
}
class _LoginFormState extends State<LoginForm> {
TextEditingController _usernameController;
TextEditingController _passwordController;
String _username;
String _password;
@override
void initState() {
_usernameController = TextEditingController()
..addListener(_usernameListener);
_passwordController = TextEditingController()
..addListener(_passwordListener);
super.initState();
}
_usernameListener() {
final _updatedText = _usernameController.text.trim();
if (_updatedText != _username) {
setState(() {
_username = _updatedText;
});
}
}
_passwordListener() {
final _updatedText = _passwordController.text.trim();
if (_updatedText != _password) {
setState(() {
_password = _updatedText;
});
}
}
@override
void dispose() {
_usernameController?.dispose();
_passwordController?.dispose();
super.dispose();
}
_authListeners(context, AuthState state) {
if (state.hasError) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text(state.error),
backgroundColor: Colors.red,
));
} else if (state.success) {
Navigator.of(context).pop();
}
}
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (context) =>
GetIt.instance.get<AuthCubit>(param1: context.read<TokenCubit>()),
child: BlocListener<AuthCubit, AuthState>(
listener: _authListeners,
listenWhen: (c, state) => state.hasError || state.success,
child: BlocBuilder<AuthCubit, AuthState>(builder: (context, state) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 20, horizontal: 10),
child: Column(
children: [
Row(
children: [
Expanded(
child: Text(
"Login",
textAlign: TextAlign.center,
)),
IconButton(
icon: Icon(Icons.close),
onPressed: () => Navigator.of(context).pop())
],
),
SizedBox(
height: 10,
),
CustomTextField(
placeholder: 'Username',
controller: _usernameController,
enabled: !state.loading,
),
SizedBox(
height: 10,
),
CustomTextField(
placeholder: 'Password',
controller: _passwordController,
obscureText: true,
enabled: !state.loading,
),
CustomButton(
label: "Sign In",
disabled: !_validate() || state.loading,
onPressed: () => _submit(context),
),
],
mainAxisSize: MainAxisSize.min,
),
);
}),
),
);
}
_submit(BuildContext context) {
context.read<AuthCubit>().login(_username, _password);
}
_validateUsername() {
return _username != null && _username.isNotEmpty;
}
_validatePassword() {
return _password != null && _password.isNotEmpty;
}
_validate() {
return _validateUsername() && _validatePassword();
}
}
Notice we have provided the AuthCubit in the login form itself as it will be destroyed as soon as the form is removed from the widget tree ie when we close the login popup. Here all the variables such as isLoggedIn and the user are set as soon as the login is successful. Also, we have to provide the TokenCubit as the parent to MaterialApp class to instantiate it as a global variable. Make the changes to the main file as:
import 'package:auth_module_flutter/blocs/token/token_cubit.dart';
import 'package:auth_module_flutter/config/injectable.dart';
import 'package:auth_module_flutter/utils/dialog_utils.dart';
import 'package:auth_module_flutter/utils/pref_utils.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:get_it/get_it.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
configureDependencies();
await PrefUtils.loadUserAuthData();
runApp(MyApp());
}
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (context) => GetIt.instance.get<TokenCubit>(),
child: MaterialApp(
title: 'Auth Demo',
debugShowCheckedModeBanner: false,
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: MyHomePage(title: 'Flutter Auth Demo'),
),
);
}
}
class MyHomePage extends StatelessWidget {
final String _title;
MyHomePage({Key key, String title})
: _title = title,
super(key: key);
@override
Widget build(BuildContext context) {
return BlocBuilder<TokenCubit, TokenState>(builder: (context, state) {
return Scaffold(
appBar: AppBar(
title: Text(_title),
),
drawer: _appDrawer(context, state),
body: ListView(
children: <Widget>[
_block(title: 'This should be always visible'),
if (state.isLoggedIn)
_block(
title: 'This should be visible when the user is logged in.',
color: Colors.green,
)
else
_block(
title: 'This should be visible when the user is logged out.',
color: Colors.blue,
),
],
),
);
});
}
Widget _appDrawer(BuildContext context, TokenState state) {
return Drawer(
child: Column(
children: [
ListTile(
title: Text(state.isLoggedIn ? "Logout" : "Login"),
onTap: () => _login(context, logged: state.isLoggedIn),
),
Divider(
height: 0,
),
if (state.isLoggedIn)
ListTile(
title: Text("User Profile"),
)
],
),
);
}
_login(BuildContext context, {logged}) {
Navigator.of(context).pop();
if (logged) {
PrefUtils.loggedOut(context);
} else {
DialogUtils.showAuthDialog(context);
}
}
Widget _block({String title, Color color: Colors.red}) {
return Container(
height: 200,
alignment: Alignment.center,
color: color,
child: Text(
title,
),
);
}
}
At this point, all our task is completed, and the corresponding widgets are shown according to the user's login state. I may have forgotten to include some steps, for that you can always check out the GitHub repository.
#flutter #di #flutter_bloc #authentication #flutter_authentication