Flutter Authentication Flow with Go Router and Provider
Splash, Onboarding and much more
Goal of this article
The goal of this article is to get a comprehensive understanding of the flutter declarative routing and the scenarios like login state, app initialization on startup, splash, onboarding, etc...
Problems that I'm trying to solve
assume we have a flutter application that we have to handle those situations.
- We need to show the onboarding page when the user opens the app for the first time.
- We need to execute some initialisation functions every time the app opens.
- The application should be capable of kicking out the user anywhere in the application when the login state changes (log out).
- And also app should be capable of handling the deep links regardless of login state.
I know, It looks like a lot. but stay with me I'm going to cover it all in this article
Starting the project
First of all, let's create the application. assuming that you have already installed flutter (I use the currently available latest version v2.8.0) Create a folder and inside it run this command,
flutter create --template app --overwrite.
I'm sure you are using vs-code or android studio for this so you don't have to create an app manually. if you do just ignore the above command and create the app from the IDE.
throughout the project, I'm going to run the application on the web. since all plugging, I'm using here is platform-independent you can use any platform you like it's totally up to you.
Okay, Let's add plugging to the pubspec.yaml
under dependencies
- provider: ^6.0.1
we will be using the provider package to do all the state management in our application.
- go_router: ^2.5.5
The purpose of the go_router package is to use declarative routes to reduce complexity, regardless of the platform you're targeting (mobile, web, desktop), handle deep and dynamic linking from Android, iOS and the web, along with several other navigation-related scenarios, while still (hopefully) providing an easy-to-use developer experience.
- shared_preferences: ^2.0.11
will be used to store some persistent data that we have kept in our application.
That's it, that's all the package we are going to use, run flutter pub get
to get the packages.
flutter pub get
Let's start the coding
Open the main.dart
file and remove everything and create the main function like this.
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
final SharedPreferences sharedPreferences = await SharedPreferences.getInstance();
runApp(MyApp(sharedPreferences: sharedPreferences));
}
then create the MyApp
class by extending the StatefulWidget
because we need initState
inside this widget.
class MyApp extends StatefulWidget {
final SharedPreferences sharedPreferences;
const MyApp({
Key? key,
required this.sharedPreferences,
}) : super(key: key);
@override
_MyAppState createState() => _MyAppState();
}
class _MyAppState extends State<MyApp> {
@override
Widget build(BuildContext context) {
return Container(
);
}
}
Okay, then let's create some files for the classes, take a close look and complete the appropriate files and folders for the next step.
Route Utils
Let's create enum
class inside the route_utils.dart
file that contain all the pages that our application has.
enum APP_PAGE {
splash,
login,
home,
error,
onBoarding
}
Then we will add extension
to the APP_PAGE
for purpose convinent.
extension AppPageExtension on APP_PAGE {
String get toPath {
switch (this) {
case APP_PAGE.home:
return "/";
case APP_PAGE.login:
return "/login";
case APP_PAGE.splash:
return "/splash";
case APP_PAGE.error:
return "/error";
case APP_PAGE.onBoarding:
return "/start";
default:
return "/";
}
}
String get toName {
switch (this) {
case APP_PAGE.home:
return "HOME";
case APP_PAGE.login:
return "LOGIN";
case APP_PAGE.splash:
return "SPLASH";
case APP_PAGE.error:
return "ERROR";
case APP_PAGE.onBoarding:
return "START";
default:
return "HOME";
}
}
String get toTitle {
switch (this) {
case APP_PAGE.home:
return "My App";
case APP_PAGE.login:
return "My App Log In";
case APP_PAGE.splash:
return "My App Splash";
case APP_PAGE.error:
return "My App Error";
case APP_PAGE.onBoarding:
return "Welcome to My App";
default:
return "My App";
}
}
}
Next, we will create StatelessWidget
inside every view file except splash_page.dart
file, inside that create a StatefulWidget
,
then, let's create the AppService
class,
// ignore: non_constant_identifier_names
String LOGIN_KEY = "5FD6G46SDF4GD64F1VG9SD68";
// ignore: non_constant_identifier_names
String ONBOARD_KEY = "GD2G82CG9G82VDFGVD22DVG";
class AppService with ChangeNotifier {
late final SharedPreferences sharedPreferences;
final StreamController<bool> _loginStateChange = StreamController<bool>.broadcast();
bool _loginState = false;
bool _initialized = false;
bool _onboarding = false;
AppService(this.sharedPreferences);
bool get loginState => _loginState;
bool get initialized => _initialized;
bool get onboarding => _onboarding;
Stream<bool> get loginStateChange => _loginStateChange.stream;
set loginState(bool state) {
sharedPreferences.setBool(LOGIN_KEY, state);
_loginState = state;
_loginStateChange.add(state);
notifyListeners();
}
set initialized(bool value) {
_initialized = value;
notifyListeners();
}
set onboarding(bool value) {
sharedPreferences.setBool(ONBOARD_KEY, value);
_onboarding = value;
notifyListeners();
}
Future<void> onAppStart() async {
_onboarding = sharedPreferences.getBool(ONBOARD_KEY) ?? false;
_loginState = sharedPreferences.getBool(LOGIN_KEY) ?? false;
// This is just to demonstrate the splash screen is working.
// In real-life applications, it is not recommended to interrupt the user experience by doing such things.
await Future.delayed(const Duration(seconds: 2));
_initialized = true;
notifyListeners();
}
}
This is our AppService
class, we create it with ChangeNotifier
so we can listen for the state changes using Provider
.
SharedPreferences
instance that we created earlier will use in this class. and there are several values to keep track of the different states of the application.
While the initialized
value only lives through the app life circle, loginState
and onboarding
values will be stored in local storage, because those values need to be kept even if the app is entirely closed. the onAppStart
method is responsible for reading those values from local storage and finishing the app initialization. we will be showing the Splash
screen until the onAppStart
future is completed.
Ok, now we can define the routers, I'm going to do it by using Go Router if you're not familiar with its basic concepts first of all you should take a look at its documentation.
Let's start routing
we need to create the AppRouter
class which contains all the routing information in our application.
class AppRouter {
late final AppService appService;
GoRouter get router => _goRouter;
AppRouter(this.appService);
late final GoRouter _goRouter = GoRouter(
refreshListenable: appService,
initialLocation: APP_PAGE.home.toPath,
routes: <GoRoute>[
GoRoute(
path: APP_PAGE.home.toPath,
name: APP_PAGE.home.toName,
builder: (context, state) => const HomePage(),
),
GoRoute(
path: APP_PAGE.splash.toPath,
name: APP_PAGE.splash.toName,
builder: (context, state) => const SplashPage(),
),
GoRoute(
path: APP_PAGE.login.toPath,
name: APP_PAGE.login.toName,
builder: (context, state) => const LogInPage(),
),
GoRoute(
path: APP_PAGE.onBoarding.toPath,
name: APP_PAGE.onBoarding.toName,
builder: (context, state) => const OnBoardingPage(),
),
GoRoute(
path: APP_PAGE.error.toPath,
name: APP_PAGE.error.toName,
builder: (context, state) => ErrorPage(error: state.extra.toString()),
),
],
errorBuilder: (context, state) => ErrorPage(error: state.error.toString()),
redirect: (state) {
final loginLocation = state.namedLocation(APP_PAGE.login.toPath);
final homeLocation = state.namedLocation(APP_PAGE.home.toPath);
final splashLocation = state.namedLocation(APP_PAGE.splash.toPath);
final onboardLocation = state.namedLocation(APP_PAGE.onBoarding.toPath);
final isLogedIn = appService.loginState;
final isInitialized = appService.initialized;
final isOnboarded = appService.onboarding;
final isGoingToLogin = state.subloc == loginLocation;
final isGoingToInit = state.subloc == splashLocation;
final isGoingToOnboard = state.subloc == onboardLocation;
// If not Initialized and not going to Initialized redirect to Splash
if (!isInitialized && !isGoingToInit) {
return splashLocation;
// If not onboard and not going to onboard redirect to OnBoarding
} else if (isInitialized && !isOnboarded && !isGoingToOnboard) {
return onboardLocation;
// If not logedin and not going to login redirect to Login
} else if (isInitialized && isOnboarded && !isLogedIn && !isGoingToLogin) {
return loginLocation;
// If all the scenarios are cleared but still going to any of that screen redirect to Home
} else if ((isLogedIn && isGoingToLogin) || (isInitialized && isGoingToInit) || (isOnboarded && isGoingToOnboard)) {
return homeLocation;
} else {
// Else Don't do anything
return null;
}
},
);
Whoa, whoa what the fudge is going on here?
It looks like too much information to take but don't worry I'm going to break down it for you.
late final AppService appService;
GoRouter get router => _goRouter;
Here we define the
AppService
object and theGoRouter
object, Since theAppService
is listenable we can listen for the state changes and rebuild the router delegate according to the app states.
refreshListenable: appService,
initialLocation: APP_PAGE.home.toPath,
refreshListenable
is that we define a listenable class to go router to react to changes. and theinitialLocation
is the default location that the app will redirect when there is no other path when the app starts (like deep links and dynamic links).
GoRoute(
path: APP_PAGE.home.toPath,
name: APP_PAGE.home.toName,
builder: (context, state) => const HomePage(),
),
Here are all the predefined locations in our application. you can see that earlier we create
APP_PAGE
enum class and an extension for it. it is super convenient to define the path, and name when we have such a class that's why we created it.If you take a closer look you will see we use
ErrorPage
on two different locationsrouters
anderrorBuilder
. one inside therouters
, we will use for manually navigate to the error page when there is a critical error in our application. theerrorBuilder
will use by Go Router to throw exceptions when there is something like an undefined navigation path.one thing that I forgot to mention, there is a String argument on the error page called
error
which we will use to show an error to the user. whenerrorBuilder
is triggered, always exception will be stored in thestate.error
object, when we navigate to the error page manually we will carry the error string inside thestate.extra
object. more information about it here.
Redirects
This method is the most important part of our application. it will handle all the core routing logic of the application, let's break down the block by block for better understanding.
final loginLocation = state.namedLocation(APP_PAGE.login.toName);
final homeLocation = state.namedLocation(APP_PAGE.home.toName);
final splashLocation = state.namedLocation(APP_PAGE.splash.toName);
final onboardLocation = state.namedLocation(APP_PAGE.onBoarding.toName);
Here we get all the locations from the router so we know where to redirect in different scenarios.
final isLogedIn = appService.loginState;
final isInitialized = appService.initialized;
final isOnboarded = appService.onboarding;
Then we get all the current states in our application from
appService
so we know what to do next when we redirect.
final isGoingToLogin = state.subloc == loginLocation;
final isGoingToInit = state.subloc == splashLocation;
final isGoingToOnboard = state.subloc == onboardLocation;
Finally we get where we actually go so we know when to redirect or not.
See it's not that complicated
Ok, ok, the next part is quite complicated, let's break it down,
We know that there are two cases where the redirect method called
- When we navigate to another location.
- When
appService
object changes.because the router is always listing for its changes when it happens router will be rebuilt.
assume the app is starting for the first time, the sub-location of the router will be initialLocation
which is home /
, so this is the first case that I mentioned above the redirect method will be called, after collecting all the information we are in the if
statement. so it will check app is initialized
or not, obviously it is false. because we are stating the app and also we are not going to splash screen. so the statement will be valid we will return Splash Screen Location /splash
. when we do that unintentionally also triggered the first case because the navigator location has changed. so the redirect method will be called again but this time isInitialized
is still false but isGoingToInit
is true because we are going to splash the screen. so along with the if statement all other conditions will be false. we just let go of the navigator to do its thing without changing anything. so at this point,
there is a three-state value of this application, and any of that is not changed yet.
app is just going to the splash screen
and already redirect method is called 3 times
those are the main three-point I need you to understand here. if you didn't please repeat the above paragraph again until you do.
before we start the splash screen we have to do one thing,
Adding Provider and Router to the App
class _MyAppState extends State<MyApp> {
late AppService appService;
@override
void initState() {
appService = AppService(widget.sharedPreferences);
super.initState();
}
@override
Widget build(BuildContext context) {
return MultiProvider(
providers: [
ChangeNotifierProvider<AppService>(create: (_) => appService),
Provider<AppRouter>(create: (_) => AppRouter(appService)),
],
child: Builder(
builder: (context) {
final GoRouter goRouter = Provider.of<AppRouter>(context, listen: false).router;
return MaterialApp.router(
title: "Router App",
routeInformationParser: goRouter.routeInformationParser,
routerDelegate: goRouter.routerDelegate,
);
},
),
);
}
}
You can see in the above code we have created appService
object and initialized it inside initState
function with sharedPreferences
instance. After that we have to wrap MaterialApp
with MultiProvider
and then add appService
as ChangeNotifierProvider
also we will declare AppRouter
with appService
as a provider too. finally we can add routeInformationParser
and routerDelegate
to the MaterialApp.router
by getting it from context
. We have not created AuthService
yet, it will be added later.
Okay, back to the splash screen
Splash Screen
There is one thing we have to do in the splash screen which is we need to call the onAppStart
function.
class SplashPage extends StatefulWidget {
const SplashPage({ Key? key }) : super(key: key);
@override
_SplashPageState createState() => _SplashPageState();
}
class _SplashPageState extends State<SplashPage> {
late AppService _appService;
@override
void initState() {
_appService = Provider.of<AppService>(context, listen: false);
onStartUp();
super.initState();
}
void onStartUp() async {
await _appService.onAppStart();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(APP_PAGE.splash.toTitle),
),
body: const Center(
child: CircularProgressIndicator(),
),
);
}
}
once we are on the splash screen onAppStart
function will be triggered, so it will read _onboarding
and _loginState
values from the locale storage and will change _initialized
to true
then will call notifyListeners
as soon as that happens, the router will be rebuilt and will recall the redirect
function.
oh god, that's too much "re" ๐
Okay, back to the redirect function
At this point isInitialized
is true and other values will be false so obviously, it will redirect to the onboardLocation
and inside the OnBoardingPage
user will press the Done
button and we will change _onboarding
to true, it pretty much the same thing I think you can now understand the redirect
function logics. Let's move on to the AuthService
Authentication Service
The reason why we are creating a separate AuthService
class while tricking the login state inside the AppService
, it will be so clean and understandable to implement authentication logic in a separate class and just expose the login state by an event stream. if you have experience with FirebaseAuth you will easily understand what I'm doing here,
class AuthService {
final StreamController<bool> _onAuthStateChange = StreamController.broadcast();
Stream<bool> get onAuthStateChange => _onAuthStateChange.stream;
Future<bool> login() async {
// This is just to demonstrate the login process time.
// In real-life applications, it is not recommended to interrupt the user experience by doing such things.
await Future.delayed(const Duration(seconds: 1));
_onAuthStateChange.add(true);
return true;
}
void logOut() {
_onAuthStateChange.add(false);
}
}
ok, now it's time to update the main.dart
with AuthService
we will declare it in the initState
and add a listener to change the loginState
.
class _MyAppState extends State<MyApp> {
late AppService appService;
late AuthService authService;
late StreamSubscription<bool> authSubscription;
@override
void initState() {
appService = AppService(widget.sharedPreferences);
authService = AuthService();
authSubscription = authService.onAuthStateChange.listen(onAuthStateChange);
super.initState();
}
void onAuthStateChange(bool login) {
appService.loginState = login;
}
@override
void dispose() {
authSubscription.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
return MultiProvider(
providers: [
ChangeNotifierProvider<AppService>(create: (_) => appService),
Provider<AppRouter>(create: (_) => AppRouter(appService)),
Provider<AuthService>(create: (_) => authService),
],
child: Builder(
builder: (context) {
final GoRouter goRouter = Provider.of<AppRouter>(context, listen: false).router;
return MaterialApp.router(
title: "Router App",
routeInformationParser: goRouter.routeInformationParser,
routerDelegate: goRouter.routerDelegate,
);
},
),
);
}
}
Finally, we have finished almost all the coding now,
we just need to add some buttons and run the application. I'll just add the home page code here the full source code will be available on GitHub.
class HomePage extends StatelessWidget {
const HomePage({ Key? key }) : super(key: key);
@override
Widget build(BuildContext context) {
final authService = Provider.of<AuthService>(context);
return Scaffold(
appBar: AppBar(
title: Text(APP_PAGE.home.toTitle),
),
body: Center(
child: Column(
children: [
TextButton(
onPressed: () {
authService.logOut();
},
child: const Text(
"Log out"
),
),
TextButton(
onPressed: () {
GoRouter.of(context).goNamed(APP_PAGE.error.toName, extra: "Erro from Home");
},
child: const Text(
"Show Error"
),
),
],
),
),
);
}
}
Okay, let's run the application,
You will clearly see everything is working as expected.
Okay, I'm going to wrap up the article here. since it is already too long, Handling Deep Link will be discussed in a separate article later. The link will be updated.
I know this is not the most advanced way to do it. But I think it is good, at what it is capable of. Feel free to share your thoughts in the comment section. Don't hesitate to ask questions I will help you. Don't forget to like, and share the article with your Flutter friends.
Happy coding.