About
Hi, 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.
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:
We'll fetch an integer/key stored in local storage, that's responsible to keep count of onboard state, via shared preferences.
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.
After that on the router we'll check if the integer is null, if so then go to the onboard screen.
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.
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.
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.
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.
The router property refreshListenableTo is set to listen to the changes from appStateProvider.
We added the OnBoardScreen route to the routes list.
-
Here we,
- First created a named location for the onboard screen.
- Then isOnboarding checks whether the current route(state.subloc) is headed towards the onboard screen or not.
- If local storage already has onBoardCount integer then we'll not onboard else we will.
- 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.
- 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);
}),
),
);
}
}
- 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.
- 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.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",
];
- A private variable _currentIndex is created to keep track of images.
- 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.
- The back arrow will disappear in case the image is the first on the list of images.
- 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.
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.
-
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.
Summary
In this blog, we created an onboard screen with GoRouter, Shared Preferences, and Provider packages. Here are the things we did in brief:
- We created router utils file for efficient work.
- We also used MaterialApp.Router to implement declarative routing using the GoRouter package.
- Then we used the SharedPreferences package to store onboard information on local storage.
- With AppStateProvider class we wrote our onboard logic and redirected our routes inside AppRouter class.
- 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, bookmark and share the article with your friends. Thank you for your time and please keep on supporting us. Don't forget to follow to be notified immediately after the upload.
This is Nibesh from Khadka's Coding Lounge, a freelancing agency that makes websites and mobile applications.
Top comments (3)
Such a thorough series on Flutter! Awesome of you to share
Thanks for your words of encouragement. ππ
I was able to do this, thank you for this series
imgur.com/a/jZMb6Gs