Flutter Authentication Flow with Go Router and Provider

Flutter Authentication Flow with Go Router and Provider

Splash, Onboarding and much more

ยท

13 min read

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.

omg.webp

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.

flutter_01.PNG

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

hooray.webp

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?

lay-down.webp

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 the GoRouter object, Since the AppService 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 the initialLocation 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 locations routers and errorBuilder. one inside the routers, we will use for manually navigate to the error page when there is a critical error in our application. the errorBuilder 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. when errorBuilder is triggered, always exception will be stored in the state.error object, when we navigate to the error page manually we will carry the error string inside the state.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

complicated.webp

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" ๐Ÿ˜‚

facepalm-seriously.gif

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,

tired-sleepy.gif

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,

2021-12-22 23-37-14.gif

You will clearly see everything is working as expected.

five.webp

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.

Github Repository

Happy coding.

byeeee.webp

ย