Onboarding With Go Router in Flutter

Onboarding With Go Router in Flutter

Flutter App Development Tutorial | Part-II

About

Hello and welcome to the 2nd part of the flutter app development series. In the last part, we successfully created a flutter starter app and set up launch Icon and Splash Screens for our 'Astha - Being Hindu' app. So far our app has a custom icon and a splash screen shows up when we launch our app. In this section of the tutorial, we will make an onboarding screen and apply it to our app. For that, we'll install GoRouter, Provider, and Shared_Preferences packages for our app.

To Onboard Or Not

Let's again have a look at the user-screen flow from the image below. App Launch.png

Let's go over the routing logic we'll be using for onboarding.

Has the user/app been onboarded?

-> If yes, then no need to onboard until the lifetime of the application i.e until the app is uninstalled.

-> If no, then go to the onboarding screen, just once and then never in the app's lifetime.

So, how are we going to achieve this?

It is simple, you see go router has redirect option available, where the state property of redirect, can be used to write check statements, to inquire about current routes: where is it now, where is it heading, and such. With this, we can redirect to onboard or not.

That's great but what/how will we check?

That's where shared preferences come in. We can store simple data in local storage using shared preferences. So during app initialization:

  1. We'll fetch an integer/key stored in local storage, that's responsible to keep count of onboard state, via shared preferences.

  2. When the app is launched for the first time, the shared preferences will return null because the integer does not exist yet. For us, it's equivalent to the idea of never having been onboarded.

  3. After that on the router we'll check if the integer is null, if so then go to the onboard screen.

  4. When onboarding is done, here we'll finally set that non-existent integer to a non-null value and save it in the local storage using the Provider package.

  5. Now when we launch an app again for the second time, the router will find that the integer is not a null value anymore, so it'll redirect to our next page instead of the onboard screen.

Install Packages

You can find the code of the project up until now in this repository. So, let's go to our projects pubspec.yaml file and add the following packages.

dependencies:
  flutter:
    sdk: flutter
  flutter_native_splash: ^2.1.1
# Our new pacakges
  go_router: ^3.0.5
  shared_preferences: ^2.0.13
  provider: ^6.0.2

Reformat

Before we start doing things, let's do some changes to our project. First, we'll create some files and folders.

#Your cursor should be inside lib your lib folder
# make some folder
mkdir globals screens  globals/providers globals/settings  globals/settings/router globals/settings/router/utils  screens/onboard  screens/home

# make some files
touch app.dart globals/providers/app_state_provider.dart  globals/settings/router/app_router.dart  globals/settings/router/utils/router_utils.dart screens/onboard/onboard_screen.dart  screens/home/home.dart

The main.dart file is like this now.

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';

void main()  {
  //  concrete binding for applications based on the Widgets framewor
  WidgetsFlutterBinding.ensureInitialized();

  SystemChrome.setSystemUIOverlayStyle(
    SystemUiOverlayStyle.dark.copyWith(statusBarColor: Colors.black38),
  );

  runApp(const Home());
}

class Home extends StatefulWidget {
  const Home({Key? key}) : super(key: key);

  @override
  State<Home> createState() => _HomeState();
}

class _HomeState extends State<Home> {
  @override
  Widget build(BuildContext context) {
    return const MaterialApp(
      home: Scaffold(
        body: Center(child: Text("Home Page")),
      ),
    );
  }
}

Then split the main.dart file and move some of its content to the home.dart file. Let's remove the Home class from the main to home.dart file. Now our files look like this:

main.dart

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:temple/screens/home/home.dart';

void main()  {
  //  concrete binding for applications based on the Widgets framewor
  WidgetsFlutterBinding.ensureInitialized();

  SystemChrome.setSystemUIOverlayStyle(
    SystemUiOverlayStyle.dark.copyWith(statusBarColor: Colors.black38),
  );

  runApp(const Home());
}

home.dart

import 'package:flutter/material.dart';

class Home extends StatefulWidget {
  const Home({Key? key}) : super(key: key);

  @override
  State<Home> createState() => _HomeState();
}

class _HomeState extends State<Home> {
  @override
  Widget build(BuildContext context) {
    return const MaterialApp(
      home: Scaffold(
        body: Center(child: Text("Home Page")),
      ),
    );
  }
}

Router Utils

While routing we'll need to provide several properties like Router Path, Named Route, Page Title, and such. It will be efficient if these values can be outsourced from a module. Hence, we created utils/router_utils.dart file.

router_utils.dart

// Create enum to represent different routes
enum APP_PAGE {
  onboard,
  auth,
  home,
}


extension AppPageExtension on APP_PAGE {
  // create path for routes
  String get routePath {
    switch (this) {
      case APP_PAGE.home:
        return "/";

      case APP_PAGE.onboard:
        return "/onboard";

      case APP_PAGE.auth:
        return "/auth";

      default:
        return "/";
    }
  }

// for named routes
  String get routeName {
    switch (this) {
      case APP_PAGE.home:
        return "HOME";

      case APP_PAGE.onboard:
        return "ONBOARD";

      case APP_PAGE.auth:
        return "AUTH";

      default:
        return "HOME";
    }
  }

// for page titles to use on appbar
  String get routePageTitle {
    switch (this) {
      case APP_PAGE.home:
        return "Astha";

      default:
        return "Astha";
    }
  }
}

Go Router

Finally, we can go to the router file where we'll create routes and redirect logic. So, on app_router.dart file.

Create an AppRouter class.

import 'package:go_router/go_router.dart';
import 'package:temple/screens/home/home.dart';
import 'utils/router_utils.dart';

class AppRouter  {
  get router => _router;

  final _router = GoRouter(
      initialLocation: "/",
      routes: [
        GoRoute(
          path: APP_PAGE.home.routePath,
          name: APP_PAGE.home.routeName,
          builder: (context, state) => const Home(),
        ),
      ],
      redirect: (state) {});
}

The AppRouter is a router class we'll use as a provider. The router we just created now has one router "/" which is the route to our home page. Likewise, the initialLocation property tells the router to go to the homepage immediately after the app starts. But, if some conditions are met then, it can be redirected to somewhere else, which is done through redirect. However, we have yet to implement our router. To do so let's head to the app.dart file.

Use Router instead of Navigator.

MaterialApp.Router creates a MaterialApp that uses the Router instead of a Navigator. Check out the differences here. We'll need to use the declarative way for our go_router.

app.dart

import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:provider/provider.dart';
import 'package:temple/globals/settings/router/app_router.dart';

class MyApp extends StatefulWidget {
  const MyApp({Key? key}) : super(key: key);

  @override
  State<MyApp> createState() => _MyAppState();
}

class _MyAppState extends State<MyApp> {
  @override
  Widget build(BuildContext context) {
    return MultiProvider(
      providers: [
        Provider(create: (context) => AppRouter()),
      ],
      child: Builder(
        builder: ((context) {
          final GoRouter router = Provider.of<AppRouter>(context).router;

          return MaterialApp.router(
              routeInformationParser: router.routeInformationParser,
              routerDelegate: router.routerDelegate);
        }),
      ),
    );
  }
}

MyApp class will be the parent class for our app i.e class used in runApp(). Hence, this is where we'll use a router. Moreover, we are returning MultiProvider, because as the app grows we'll use many other providers.

As mentioned before we need to pass the MyApp class in runApp() method in our main.dart file.

// Insde main() method
void main() {
............
// Only change this line 
  runApp(const MyApp());
//
}

Now save all the files and run the app in your emulator. You'll see a homepage that'll look like this.

Home Page Initial

Provider

We'll be writing our logic about the onboard status on the provider class, and since, it's a global state we'll write it on the app_state_provider.dart file inside the "lib/globals/providers" folder.

app_state_provider.dart

import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';

class AppStateProvider with ChangeNotifier {

// lets define a method to check and manipulate onboard status
  void hasOnboarded() async {
    // Get the SharedPreferences instance
    SharedPreferences prefs = await SharedPreferences.getInstance();
    // set the onBoardCount to 1
    await prefs.setInt('onBoardCount', 1);
    // Notify listener provides converted value to all it listeneres
    notifyListeners();
  }
}

Inside hasOnboarded() function, we set the integer of onBoardCount to one or non-null value, like mentioned previously.

Now, do you know how to implement this provider in our app?. Yes, we'll need to add another provider to the app.dart's MultiProvider.

app.dart

import 'package:temple/globals/providers/app_state_provider.dart';

....
  .....
MultiProvider(
      providers: [
        ChangeNotifierProvider(create: (context) => AppStateProvider()),
        Provider(create: (context) => AppRouter())
 ]

Make sure to declare AppStateProvider before AppRouter, which we'll discuss later. For now, we'll make a very simple onboard screen for testing purposes.

Onboard Screen

onboard_screen.dart

import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:provider/provider.dart';
import 'package:temple/globals/providers/app_state_provider.dart';

class OnBoardScreen extends StatefulWidget {
  const OnBoardScreen({Key? key}) : super(key: key);

  @override
  State<OnBoardScreen> createState() => _OnBoardScreenState();
}

void onSubmitDone(AppStateProvider stateProvider, BuildContext context) {
  // When user pressed skip/done button we'll finally set onboardCount integer
  stateProvider.hasOnboarded();
  // After that onboard state is done we'll go to homepage.
  GoRouter.of(context).go("/");
}

class _OnBoardScreenState extends State<OnBoardScreen> {
  @override
  Widget build(BuildContext context) {
    final appStateProvider = Provider.of<AppStateProvider>(context);
    return Scaffold(
      body: Center(
          child: Column(
        children: [
          const Text("This is Onboard Screen"),
          ElevatedButton(
              onPressed: () => onSubmitDone(appStateProvider, context),
              child: const Text("Done/Skip"))
        ],
      )),
    );
  }
}

In this file, a stateful widget class was created. The main thing to notice here for now is onSubmitDone() function. This function we'll be called when the user either pressed the skip button during onboarding or the done button when onboarding is done. Here, it calls the hasOnboarded method we defined earlier in the provider which sets things in motion. After that, our router will take us to the homepage.

Now we're done!, or Are we? We still haven't introduced redirect instructions to our router. Hence, let's make some changes to our app router.

Go-Router Redirect

app_router.dart

// Packages
import 'package:go_router/go_router.dart';
import 'package:shared_preferences/shared_preferences.dart';
//Custom files
import 'package:temple/screens/home/home.dart';
import 'utils/router_utils.dart';
import 'package:temple/screens/onboard/onboard_screen.dart';
import 'package:temple/globals/providers/app_state_provider.dart';

class AppRouter {
  //=======================change #1 start ===========/
  AppRouter({
    required this.appStateProvider,
    required this.prefs,
  });

  AppStateProvider appStateProvider;
  late SharedPreferences prefs;
  //=======================change #1 end===========/
  get router => _router;

// change final to late final to use prefs inside redirect.
  late final _router = GoRouter(
      refreshListenable:
          appStateProvider, //=======================change #2===========/
      initialLocation: "/",
      routes: [
        GoRoute(
          path: APP_PAGE.home.routePath,
          name: APP_PAGE.home.routeName,
          builder: (context, state) => const Home(),
        ),
        // Add the onboard Screen
        //=======================change #3  start===========/

        GoRoute(
            path: APP_PAGE.onboard.routePath,
            name: APP_PAGE.onboard.routeName,
            builder: (context, state) => const OnBoardScreen()),
        //=======================change #3  end===========/
      ],
      redirect: (state) {
        //=======================change #4  start===========/

        // define the named path of onboard screen
        final String onboardPath =
            state.namedLocation(APP_PAGE.onboard.routeName); //#4.1

        // Checking if current path is onboarding or not
        bool isOnboarding = state.subloc == onboardPath; //#4.2

        // check if sharedPref as onBoardCount key or not
        //if is does then we won't onboard else we will
        bool toOnboard =
            prefs.containsKey('onBoardCount') ? false : true; //#4.3

        //#4.4
        if (toOnboard) {
          // return null if the current location is already OnboardScreen to prevent looping
          return isOnboarding ? null : onboardPath;
        }
        // returning null will tell router to don't mind redirect section
        return null; //#4.5
        //=======================change #4  end===========/
      });
}

Let's go through the changes we made.

  1. We created two class files: appStateRouter and prefs. The SharedPrefences instance prefs is needed to check whether we have already onboarded or not, based on the existence of the onboard count integer. The appStateProvider will provide all the changes that matter to the router.

  2. The router property refreshListenableTo is set to listen to the changes from appStateProvider.

  3. We added the OnBoardScreen route to the routes list.

  4. Here we,

    1. First created a named location for the onboard screen.

    2. Then isOnboarding checks whether the current route(state.subloc) is headed towards the onboard screen or not.

    3. If local storage already has onBoardCount integer then we'll not onboard else we will.

    4. Based on the value of toOnboard we'll return either null or onBoardPath to redirect towards. We checked if the current route is going towards onBoardScreen with isOboarding and if so returned null. We need to do this, if we don't then the router will enter a loop and cause an error.

    5. Lastly, if we don't have to redirect anywhere then return null which tells the router to ignore redirect for now.

(PS: I haven't mentioned changes in import.)

Proxy Provider For Router

Alright, so at this point, you probably have your linter screaming errors with red colors. It's the result of us declaring two fields in AppRouter, yet we aren't providing their values in our app.dart. So, let's fix it.

app.dart

import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:temple/globals/providers/app_state_provider.dart';
import 'package:temple/globals/settings/router/app_router.dart';

class MyApp extends StatefulWidget {
// Declared fields prefs which we will pass to the router class
  //=======================change #1==========/
  SharedPreferences prefs; 
  MyApp({required this.prefs, Key? key}) : super(key: key);
  //=======================change #1 end===========/
  @override
  State<MyApp> createState() => _MyAppState();
}

class _MyAppState extends State<MyApp> {
  @override
  Widget build(BuildContext context) {
    return MultiProvider(
      providers: [
        ChangeNotifierProvider(create: (context) => AppStateProvider()),
        //=======================change #2==========/
        // Remove previous Provider call and create new proxyprovider that depends on AppStateProvider
        ProxyProvider<AppStateProvider, AppRouter>(
            update: (context, appStateProvider, _) => AppRouter(
                appStateProvider: appStateProvider, prefs: widget.prefs))
      ],
      //=======================change #2 end==========/
      child: Builder(
        builder: ((context) {
          final GoRouter router = Provider.of<AppRouter>(context).router;

          return MaterialApp.router(
              routeInformationParser: router.routeInformationParser,
              routerDelegate: router.routerDelegate);
        }),
      ),
    );
  }
}
  1. First, create a field prefs, which needs to pass it as the value on our main.dart file which is where we call MyApp class.
  2. We'll create a Proxy AppRouter provider, that'll depend on the AppStateProvider. The proxy provider is not the only way to pass a value.

main.dart

Now, there is another red warning, because we have yet to pass our prefs field in the main.dart file.

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:temple/app.dart';

//=======================change #1==========/
// make app an async funtion to instantiate shared preferences
void main() async {
  //  concrete binding for applications based on the Widgets framewor
  WidgetsFlutterBinding.ensureInitialized();

  //=======================change #2==========/
// Instantiate shared pref
  SharedPreferences prefs = await SharedPreferences.getInstance();
  SystemChrome.setSystemUIOverlayStyle(
    SystemUiOverlayStyle.dark.copyWith(statusBarColor: Colors.black38),
  );
  //=======================change #3==========/
// Pass prefs as value in MyApp
  runApp(MyApp(prefs: prefs));
}

Here we simply converted the main() method to an async method. We did it to instantiate the shared preferences, which then is passed as value for MyApp class's prefs field. Now, when you run the app, it should work as intended.

OnBoardScreen UI & Animation

Now, that we've made the functionality work. Let's do something about the onboard screen itself. You can download the following images or use your own. I created them at canva for free.

Onboard Screen Image 1

Onboard Screen Image2

onboard_screen.dart

import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:provider/provider.dart';
import 'package:temple/globals/providers/app_state_provider.dart';

class OnBoardScreen extends StatefulWidget {
  const OnBoardScreen({Key? key}) : super(key: key);

  @override
  State<OnBoardScreen> createState() => _OnBoardScreenState();
}

void onSubmitDone(AppStateProvider stateProvider, BuildContext context) {
  // When user pressed skip/done button we'll finally set onboardCount integer
  stateProvider.hasOnboarded();
  // After that onboard state is done we'll go to homepage.
  GoRouter.of(context).go("/");
}

class _OnBoardScreenState extends State<OnBoardScreen> {
// Create a private index to track image index
  int _currentImgIndex = 0; // #1

// Create list with images to use while onboarding
  // #2
  final onBoardScreenImages = [
    "assets/onboard/FindTemples.png",
    "assets/onboard/FindVenues.png",
    "assets/onboard/FindTemples.png",
    "assets/onboard/FindVenues.png",
  ];

// Function to display next image in the list when next button  is clicked
  // #4
  void nextImage() {
    if (_currentImgIndex < onBoardScreenImages.length - 1) {
      setState(() => _currentImgIndex += 1);
    }
  }

// Function to display previous image in the list when previous button  is clicked
  // #3
  void prevImage() {
    if (_currentImgIndex > 0) {
      setState(() => _currentImgIndex -= 1);
    }
  }

  @override
  Widget build(BuildContext context) {
    final appStateProvider = Provider.of<AppStateProvider>(context);
    return Scaffold(
        body: SafeArea(
            child: Container(
                color: const Color.fromARGB(255, 255, 209, 166),
                padding: const EdgeInsets.all(10.0),
                child: Column(
                  children: [
                    // Animated switcher class to animated between images
                    // #4
                    AnimatedSwitcher(
                      switchInCurve: Curves.easeInOut,
                      switchOutCurve: Curves.easeOut,
                      transitionBuilder: ((child, animation) =>
                          ScaleTransition(scale: animation, child: child)),
                      duration: const Duration(milliseconds: 800),
                      child: Image.asset(
                        onBoardScreenImages[_currentImgIndex],
                        height: MediaQuery.of(context).size.height * 0.8,
                        width: double.infinity,
                        // Key is needed since widget type is same i.e Image
                        key: ValueKey<int>(_currentImgIndex),
                      ),
                    ),
                    // Container to that contains set butotns
                    // #5
                    Container(
                        color: Colors.black26,
                        child: Row(
                          mainAxisAlignment: MainAxisAlignment.spaceBetween,
                          children: [
                            IconButton(
                              // Change visibility by currentImgIndex
                              // #6
                              onPressed: prevImage,
                              icon: _currentImgIndex == 0
                                  ? const Icon(null)
                                  : const Icon(Icons.arrow_back),
                            ),
                            IconButton(
                              // Change visibility by currentImgIndex
                              // #7
                              onPressed: _currentImgIndex ==
                                      onBoardScreenImages.length - 1
                                  ? () =>
                                      onSubmitDone(appStateProvider, context)
                                  : nextImage,
                              icon: _currentImgIndex ==
                                      onBoardScreenImages.length - 1
                                  ? const Icon(Icons.done)
                                  : const Icon(Icons.arrow_forward),
                            )
                          ],
                        ))
                  ],
                ))));
  }
}

I know it's a bit too much code. So, let's go through them a chunk at a time.

// Create a private index to track image index
  int _currentImgIndex = 0; // #1

// Create list with images to use while onboarding
  // #2
  final onBoardScreenImages = [
    "assets/onboard/FindTemples.png",
    "assets/onboard/FindVenues.png",
    "assets/onboard/FindTemples.png",
    "assets/onboard/FindVenues.png",
  ];
  1. A private variable _currentIndex is created to keep track of images.
  2. Images in the list onBoardScreenImages, will be shown on the screen based on _currentIndex.
// Function to display next image in the list when next button  is clicked
  // #1
  void nextImage() {
    if (_currentImgIndex < onBoardScreenImages.length - 1) {
      setState(() => _currentImgIndex += 1);
    }
  }

// Function to display previous image in the list when previous button  is clicked
  // #2
  void prevImage() {
    if (_currentImgIndex > 0) {
      setState(() => _currentImgIndex -= 1);
    }
  }

These functions will keep track of currentIndex by managing the local state properly.

// Animated switcher class to animated between images
               AnimatedSwitcher(
                      switchInCurve: Curves.easeInOut,
                      switchOutCurve: Curves.easeOut,
                      transitionBuilder: ((child, animation) =>
                          ScaleTransition(scale: animation, child: child)),
                      duration: const Duration(milliseconds: 800),
                      child: Image.asset(
                        onBoardScreenImages[_currentImgIndex],
                        height: MediaQuery.of(context).size.height * 0.8,
                        width: double.infinity,
                         // Key is needed since widget type is same i.e Image
                        key: ValueKey<int>(_currentImgIndex),
                      ),
                    ),

We're using AnimatedSwitcher to switch between our image widgets while using ScaleTransition. BTW, if you remove the transitionBuilder property you'll get the default FadeTransition.

Container(
                        color: Colors.black26,
                        child: Row(
                          mainAxisAlignment: MainAxisAlignment.spaceBetween,
                          children: [
                            IconButton(
                              // Change visibility by currentImgIndex
                              // #1
                              onPressed: prevImage,
                              icon: _currentImgIndex == 0
                                  ? const Icon(null)
                                  : const Icon(Icons.arrow_back),
                            ),
                            IconButton(
                              // Change visibility by currentImgIndex
                              // #2
                              onPressed: _currentImgIndex ==
                                      onBoardScreenImages.length - 1
                                  ? () =>
                                      onSubmitDone(appStateProvider, context)
                                  : nextImage,
                              icon: _currentImgIndex ==
                                      onBoardScreenImages.length - 1
                                  ? const Icon(Icons.done)
                                  : const Icon(Icons.arrow_forward),
                            )
                          ],
                        ))
                  ],
                ))

This container is where we switch the button appearance based on the index.

  1. The back arrow will disappear in case the image is the first on the list of images.
  2. The front arrow will be replaced by the done icon if the image is the last one on the list of images. If it's the next button, the nextImage() function will be triggered on click. Whereas, the done button will trigger the submitButton().

Homework

Test what you've learned so far, and how far can you go. I won't provide source code for the homework, please escape the tutorial hell by making mistakes and fixing them by yourself.

  1. When you save and restart the app, you'll likely encounter an error related to the image. Fix it by yourself, we have done this in the previous part of this series.

  2. Create Skip Button.

    • Skip Button should be a text button.
    • Position it at the bottom Center but inside the container of other buttons.
    • The text should be red in color.
    • Implement the same logic as submit button.

If you've done this part then share the screenshot in the comment section.

KCl

Summary

In this blog, we created an onboard screen with GoRouter, Shared Preferences, and Provider packages. Here are the things we did in brief:

  1. We created router utils file for efficient work.
  2. We also used MaterialApp.Router to implement declarative routing using the GoRouter package.
  3. Then we used the SharedPreferences package to store onboard information on local storage.
  4. With AppStateProvider class we wrote our onboard logic and redirected our routes inside AppRouter class.
  5. We also created a simple onboard screen with a ScaleTransition animation.

Our hard work looks like this:

Show Support

In the upcoming blog, we'll define the theme of our app. If you want to know more about the series and expectations, check out the series introduction page.

If you've any questions please feel free to comment. Please do like and share the article with your friends. Thank you for your time and please keep on supporting us. Don't forget to subscribe to the newsletter to be notified immediately after the upload.

This is Nibesh from Khadka's Coding Lounge, a freelancing agency that makes websites and mobile applications.

Like and Subscribe