This long blog is all in one version of the series on Flutter App Development Tutorial. You can always visit individual blogs which are chapters in this blog.
Chapter One: Introduction
Welcome to the blog for the flutter app development. This is Nibesh Khadka from Khadka's Coding Lounge. This blog is going to be a tutorial on app development with Flutter. Yes, there's everything that's mentioned on the Poster.
About App
In this series, we'll be making an app Astha - Being Hindu. This app is our vision of a platform for the Hindu community. It is supposed to be a one-stop destination for everything related to Hinduism, for instance, Finding temples, Finding priests, Venues for marriage and baptisms, Shops selling items like incense sticks, flowers, garlands, etc.
Disclaimer: This tech blog has no intention to divide any religious groups or beliefs. This is just an effort to teach an emerging tech in a context that might not have been realized before.
Chapters Introductions
This blog has been divided into 12 chapters (14 including this one and the conclusion). We've tried to divide the chapters such that each will represent a single goal. Their order is based on the user-screen flow. This blog has also been divided into a series, each chapter as an individual blog which can be found here. This very long blog here is mashed and edited to participate in the writing competition on HashNode.
Launch Icon and Splash Screen for Flutter App
After creating an initial project, this chapter will walk through the steps to set up the app with a launch icon and also create and set a splash screen for our app with packages already available at Pub.Dev.
Onboarding With Go Router in Flutter
This section will see our app getting a few images and a very mundane animation, to onboard users. We'll make use of Go Router and Shared Preferences to make sure onboarding only occurs only once when the app is launched for the first time.
Define Theme In Flutter
After making the onboarding screen, we'll shift our focus to defining a global theme for our application. We'll use a beautiful font, and a set of colors for our app's widgets like the app bar, button, and so on.
How to Create Custom Widgets: App Bar, Drawer, and Bottom Navigation Bar in Flutter
After, defining the theme, we'll then make some global widgets. We'll make a custom app bar that'll have dynamic content. Instead of using the drawer menu, we'll also create a bottom navigation bar. Now, as per the drawer, we'll use it for settings about user accounts links.
Authentication in Flutter | User Interface Design
With the app bar and menus in place, we have to move to the next section. According to the user screen flow, the first screen the app will go to is authentication(unless onboarding). So, we'll take our time to create an authentication screen, add some animation, create a dynamic input form, and many more.
The next three chapters, including this one, have the same main goal i.e Authenticate. But then the chapter got very lengthy. So, we had to divide them into mini-goals, which are UI, Set-Up, and Authentication.
Flutter Firebase Setup | Cloud & Emulator
To add users or to do anything else involving data, we'll need a very important thing: the Database. We will use Firebase for all the backend including the database. In this short chapter, we'll set up Firebase for our project.
Flutter Firebase Authentication | Email and Password
After firebase is set up we'll now write our code to authenticate the users. We'll use email and password to authenticate. Not only that we'll also use Firebase Cloud Functions to be secure and efficient.
Improve User Experience with Snack Bars, Alert Dialogs, and Progress Indicators
It's always good practice to provide users feedback on their actions. There are many ways to do so. Some of them are Snack Bars, Alert Dialogs, Progress Indicators, etc. We'll implement it there in our Authentication Screen.
Permission Handler and Location
It's mandatory to ask for permission to access local files and apps. In our app as well we'll need to access some features. We'll access the user's location and save it on the Firebase Firestore.
Google Maps Places API With Flutter
In this section, we'll use google's Place API to acquire a list of temples nearby the user's locations. The location will be saved on Firebase Firestore using Firebase Cloud Functions.
Flutter Page Design
Here we're back again to visuals and aesthetics. We'll make two pages in this part, the homepage and the temple page.
Realtime Changes With Flutter and Firebase
Here, we'll be using Streams and StreamBuilders to reflect real-time changes to the Firestore in our app.
Flutter App Development | Conclusion
It's a very short chapter where we'll conclude this blog. Here, we will share some awesome courses, books, and blogs out there. Also, a small DIY task has been prepared for all the readers to practice what we've learned so far in the series.
UI/UX
These are two designs we found on Figma, we took motivation from:
- https://www.figma.com/community/file/895207405259865401
- https://www.figma.com/community/file/1032754160766949478
Screenshots
Here are a few screenshots of the prototype.
User ScreenFlow
The following diagram roughly illustrates the user screen flow in our app.
Pre-Requisites
Though beginner-friendly it won't be an absolute zero-level blog. Don't expect an explanation of every nook and crannies. We will not explain what a widget does, but how does that widget fit in our situation at a particular moment. We are using Linux OS, so codes are tested on android, though flutter will adjust it for ios as well. Go through the following checklist before we start.
- Figma SDK installed.
- Android Emulator installed
- Google Clouds Account with billing enabled, required for Firebase and Google Maps API.
- Have a basic understanding of Flutter widgets & Dart.
- We'll write firebase cloud functions in JavaScript, so it'll help if you know JS basics.
- Few commands in Linux OS.
- VS code installed.
Project Structure
Let's take a brief moment for the folder structures on the project. At first, we were following a simple and one layer structure of:
- main.dart
- Providers
- Screens
- Models
But as the project got bigger it got messier with this structure. And Hence, we decided to use the same structure but upgrade to a more layered structure, where each screen will have its respective folders as above, while a few global widgets and settings will be on a global folder.
Conclusion
So, this was a brief introduction to the blog on Flutter App Development. We hope you're excited as we are.
Chapter Two: Launch Icon and Splash Screen for Flutter App
Getting Started
Here's a user screen flow image of the first things a user goes through on app launch.
We'll start in the order of user-screen flow. Hence, in this section, we'll set the launch icon as well as a splash screen for our application. Now let's get started by creating our application. On your terminal:
# I am using the Desktop Directory
cd Desktop
# Create a Flutter project
flutter create astha
# Go to the folder and open the folder on VS Code.
cd astha
code .
Assets
All the project images will be stored in the assets folder in the root directory and further into their relevant sub-directories. So, let's create a folder for images to store.
# In your project root for instance /home/<user>/Desktop/astha
mkdir assets assets/splash
You can use the image of your choice or download the following images to use. I made them on canva.
App Launch Icon - Om and Lotus Splash Image
I resized these images at imageresizer to achieve different sizes as mentioned in the native splash package.
Make sure to download them inside assets/splash. After that to use these images, we'll need to add them to the pubspec file so. In pubsec.yaml file you'll find the assets section commented just uncomment it or replace it with the following:
# To add assets to your application, add an assets section, like this:
# The outer **assets** not folder name
# but a variable that tells flutter SDK where to look for assets into
assets:
# Splash Screens
- assets/splash/om_splash.png
- assets/splash/om_lotus_splash.png
- assets/splash/om_lotus_splash_1152x1152.png
Remember any image sources you'll use from local storage needs to be registered in pubspec.yaml file as above.
Packages
- For the launch icon we'll use flutter_launcher_icons.
- For our splash screen we'll use flutter_native_splash.
If you're gonna use the version I'm using then just paste it inside pubspec.yaml
Installation
# On dependencies section
dependencies:
flutter:
sdk: flutter
flutter_native_splash: ^2.1.1
#On dev_dependencies section
dev_dependencies:
flutter_test:
sdk: flutter
flutter_launcher_icons: ^0.9.2
*Do mind the indentation and also make sure to visit each package's page and follow the readme instructions for the setup if anything's changed. *
Note: Please remember that the settings will only work up to Android 11 as intended. From Android 12+, the splash screen will only show up on launch from emulator icon tap but not on app run from VS Code(for some reason it hasn't worked in mine/is being overridden by launcher icon). Another thing to remember is that the splash screen will be clipped as a round image in the center. I tried to change the window background but failed nonetheless.
Set Up
Now that we've added the packages we're gonna need to provide a few additional settings. So, again in pubspec.yaml file:
#Just add these towards the end
# Launch icon settings
flutter_icons:
android: true
ios: true
image_path: "assets/splash/om_splash.png"
adaptive_icon_background: "#FFD1A6"
adaptive_icon_foreground: "assets/splash/om_splash.png"
# Splash Screen Settings
flutter_native_splash:
#general
color: "#ffffff"
image: assets/splash/om_lotus_splash.png
android_12:
image: assets/splash/om_lotus_splash_1152x1152.png
# icon_background_color: "#FFD1A6"
Now save the file and go to the VS Code terminal and run these commands.
# For splash screen
flutter pub run flutter_native_splash:create
# For launch icon
flutter pub run flutter_launcher_icons:main
Possible Error
While running the second command I encountered an error, it turns out to be an SDK version's incompatibility issue. Hence, on android>app>build.gradle, find and change Compiled, Minimum, and Target SDK versions.
# Only change these values don't delete anything else.
android {
.......
compileSdkVersion 31
...
defaultConfig {
applicationId "com.example.astha"
minSdkVersion 21
targetSdkVersion 30
....
}
After this save the file and in your terminal run the following command again.
# For launch icon
flutter pub run flutter_launcher_icons:main
Home Page
In upcoming chapters, we'll create onboard and lock our app for only registered users. But for now, to test our launcher icon and splash screen let's create a simple home screen.
On main.dart file:
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
void main() async {
// concrete binding for applications based on the Widgets framewor
WidgetsFlutterBinding.ensureInitialized();
SystemChrome.setSystemUIOverlayStyle(
SystemUiOverlayStyle.dark.copyWith(statusBarColor: Colors.black38),
);
// Firebase initalize
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")),
),
);
}
}
Check out this short clip on what we did so far.
Summary
Alright, with this the first part of the Flutter App Development series is completed.
- We implemented Launch Icon in android with the flutter_launcher_icons package.
- We also implemented a splash screen for our app with the flutter_native_splash package.
- We are also working on some expected breakage for Android 12.
Chapter Three: Onboarding With Go Router in Flutter
About
In this chapter 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")),
),
);
}
}
Let's move the Home class to the 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 a 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, as 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 fields: 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, we need 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 chapter, 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.
Progress until now looks like this:
Chapter Four: Define A Flutter Theme
Intro
In this short chapter, we will define a global theme for our applications. We'll mainly work on two aspects colors and fonts. Check out the style guide below.
Please find the source code for the progress so far from here. Now, since this application is for Hindus, I tried to apply a few holy colors like Saffron as the primary color, red as an accent/secondary color, and Green as the background color of the app. The text colors are the result of experimenting with color contrast. For the font, I am using Proxima Nova. You can download your fonts from here.
Styling Your Flutter App
Alright, now that we've seen what our app's roughly going to look like. Let's create a theme folder and a file *app_theme.dart * inside the globals folder.
# on the root of the project
mkdir lib/globals/theme
# Create file
touch lib/globals/theme/app_theme.dart
Defining Themes With ColorScheme
Now inside the app_theme file let's define the colors that our app is going to use.
app_theme.dart
import 'package:flutter/material.dart';
// Instantiate new theme data
final ThemeData asthaTutorialTheme = _asthaTutorialTheme();
//Define Base theme for app
ThemeData _asthaTutorialTheme() {
// We'll just overwrite whatever's already there using ThemeData.light()
final ThemeData base = ThemeData.light();
// Make changes to light() theme
return base.copyWith(
colorScheme: base.colorScheme.copyWith(
primary: const Color.fromARGB(255, 255, 153, 51),
onPrimary: Colors.white,
secondary: const Color.fromARGB(255, 223, 27, 12),
onSecondary: Colors.white,
background: const Color.fromARGB(255, 228, 243, 228),
onBackground: Colors.black,
),
);
}
Flutter ColorScheme can be used to define the colors of many components. Copying the Light theme leaves us less work to do. However, if you've tried the dark theme you're gonna need to experiment a little bit, cause some colors might get overwritten. The primary color is for navigation/app bars while the secondary is the accent color. We must define styles for buttons separately with the respective ButtonTheme class.
Defining Text Theme
As mentioned before we'll be using ProximaNova font. Create fonts folder inside assets folder and download the font if you'll be using the same one. Now, as we've done previously we need to tell flutter to look for the font by adding a path on the pubspec file.
The fonts section should be commented on in the pubspec file, add the following instructions.
fonts:
- family: Proxima Nova Rg Regular
fonts:
- asset: assets/fonts/ProximaNovaRegular.ttf
Let's now head back to our theme and begin writing instructions for what our texts are gonna look like. We'll create a separate function _asthaTutorialTextTheme to keep our main function lean.
// Outside of _asthaTutorialTheme function create another function
TextTheme _asthaTutorialTextTheme(TextTheme base) => base.copyWith(
// This'll be our appbars title
headline1: base.headline1!.copyWith(
fontFamily: "Proxima Nova Rg Regular",
fontSize: 30,
fontWeight: FontWeight.w500,
color: Colors.white),
// for widgets heading/title
headline2: base.headline2!.copyWith(
fontFamily: "Proxima Nova Rg Regular",
fontSize: 26,
fontWeight: FontWeight.w400,
color: Colors.black,
),
// for sub-widgets heading/title
headline3: base.headline3!.copyWith(
fontFamily: "Proxima Nova Rg Regular",
fontSize: 24,
fontWeight: FontWeight.w400,
color: Colors.black,
),
// for widgets contents/paragraph
bodyText1: base.bodyText1!.copyWith(
fontFamily: "Proxima Nova Rg Regular",
fontSize: 20,
fontWeight: FontWeight.w300,
color: Colors.black),
// for sub-widgets contents/paragraph
bodyText2: base.bodyText2!.copyWith(
fontFamily: "Proxima Nova Rg Regular",
fontSize: 18,
fontWeight: FontWeight.w300,
color: Colors.black),
);
In flutter, TextTheme is a material design class for text . I've tried to provide font size and font weight to maintain a hierarchy and be less bland.
After defining the function, we'll need to pass it to our main function:* _asthaTutorialTheme*.
// Inside the base.copyWith method
....
return base.copyWith(
colorScheme: base.colorScheme.copyWith(
// Leave it as it is
.... ),
// Add text theme
textTheme: _asthaTutorialTextTheme(base.textTheme),
);
Elevated Button Theme in Flutter
ElevatedButtonThemeData is a button style that overrides the default appearances of ElevatedButtons. Like previously, we'll create a separate function to define the button style.
ElevatedButtonThemeData _elevatedButtonTheme(ElevatedButtonThemeData base) =>
ElevatedButtonThemeData(
style: ButtonStyle(
backgroundColor: MaterialStateProperty.all<Color>(
const Color.fromARGB(255, 223, 27, 12),
),
foregroundColor: MaterialStateProperty.all<Color>(Colors.white),
),
);
Material StateProperty contains the state of a widget's material. Button like elevated, text or outline consists of many material state properties such as background color, that's why we're defining color property as such above.
With that out of the way, let's pass this function to the elevatedButtonTheme property inside a copy of the base theme.
// below text theme add this
// Define styles for elevated button
elevatedButtonTheme: _elevatedButtonTheme(base.elevatedButtonTheme),
Styling Input Widgets in Flutter
We're going to be using input forms for authentication later on in the series. We'll need to add a few styles for that as well.
InputDecorationTheme _inputDecorationTheme(InputDecorationTheme base) =>
const InputDecorationTheme(
// Label color for the input widget
labelStyle: TextStyle(color: Colors.black),
// Define border of input form while focused on
focusedBorder: OutlineInputBorder(
borderSide: BorderSide(
width: 1.0,
color: Colors.black,
style: BorderStyle.solid,
),
),
);
We've made input such that when focused on it'll have a solid border with a width of 1px. Similarly, colors for both text and border will be black.
Can you add the _inputDecorationTheme function to our main function? I'll leave it to you then.
Now, putting it all together:
app_theme.dart
import 'package:flutter/material.dart';
// Kinda like a getter to import theme from other files
final ThemeData asthaTutorialTheme = _asthaTutorialTheme();
//Define Base theme for app
ThemeData _asthaTutorialTheme() {
final ThemeData base = ThemeData.light();
return base.copyWith(
colorScheme: base.colorScheme.copyWith(
primary: const Color.fromARGB(255, 255, 153, 51),
onPrimary: Colors.white,
secondary: const Color.fromARGB(255, 223, 27, 12),
onSecondary: Colors.white,
error: Colors.red,
background: const Color.fromARGB(255, 228, 243, 228),
onBackground: Colors.black,
),
textTheme: _asthaTutorialTextTheme(base.textTheme),
// below text theme add this
// Define styles for elevated button
elevatedButtonTheme: _elevatedButtonTheme(base.elevatedButtonTheme),
// Set Themes for Input Your homework
// Define theme for text input
);
}
// Outside of _asthaTutorialTheme function create another function
TextTheme _asthaTutorialTextTheme(TextTheme base) => base.copyWith(
// This'll be our appbars title
headline1: base.headline1!.copyWith(
fontFamily: "Proxima Nova Rg Regular",
fontSize: 30,
fontWeight: FontWeight.w500,
color: Colors.white),
// for widgets heading/title
headline2: base.headline2!.copyWith(
fontFamily: "Proxima Nova Rg Regular",
fontSize: 26,
fontWeight: FontWeight.w400,
color: Colors.black,
),
// for sub-widgets heading/title
headline3: base.headline3!.copyWith(
fontFamily: "Proxima Nova Rg Regular",
fontSize: 24,
fontWeight: FontWeight.w400,
color: Colors.black,
),
// for widgets contents/paragraph
bodyText1: base.bodyText1!.copyWith(
fontFamily: "Proxima Nova Rg Regular",
fontSize: 20,
fontWeight: FontWeight.w300,
color: Colors.black),
// for sub-widgets contents/paragraph
bodyText2: base.bodyText2!.copyWith(
fontFamily: "Proxima Nova Rg Regular",
fontSize: 18,
fontWeight: FontWeight.w300,
color: Colors.black),
);
InputDecorationTheme _inputDecorationTheme(InputDecorationTheme base) =>
const InputDecorationTheme(
// Label color for the input widget
labelStyle: TextStyle(color: Colors.black),
// Define border of input form while focused on
focusedBorder: OutlineInputBorder(
borderSide: BorderSide(
width: 1.0,
color: Colors.black,
style: BorderStyle.solid,
),
),
);
ElevatedButtonThemeData _elevatedButtonTheme(ElevatedButtonThemeData base) =>
ElevatedButtonThemeData(
style: ButtonStyle(
backgroundColor: MaterialStateProperty.all<Color>(
const Color.fromARGB(255, 223, 27, 12),
),
foregroundColor: MaterialStateProperty.all<Color>(Colors.white),
),
);
Adding Theme to GoRouter
We're using MaterialApp.router class for declarative routing. It provides a property theme to define a global theme for its children. So, in our app.dart file where we call upon this class, let's add the theme we just defined.
app.dart
// import theme at top
import 'package:temple/globals/theme/app_theme.dart';
//In MaterialApp.router
return MaterialApp.router(
routeInformationParser: router.routeInformationParser,
theme: asthaTutorialTheme, // add our theme here.
routerDelegate: router.routerDelegate);
A kind reminder, your package name can be different while importing
Test Theme
I've changed the Home screen a little bit, to test our theme. Please feel free to experiment on your own.
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 Scaffold(
appBar: AppBar(
leading: Icon(Icons.person),
title: Text(
"This is appbar",
style: Theme.of(context).textTheme.headline1,
),
),
body: SafeArea(
child: Container(
padding: const EdgeInsets.all(20),
color: Theme.of(context).colorScheme.background,
child:
Column(mainAxisAlignment: MainAxisAlignment.start, children: [
Card(
child: Container(
width: 300,
height: 200,
padding: const EdgeInsets.all(4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
"Hi",
style: Theme.of(context).textTheme.headline2,
textAlign: TextAlign.left,
),
Text(
"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Eu id lectus in gravida mauris, nascetur. Cras ut commodo consequat leo, aliquet a ipsum nulla.",
style: Theme.of(context).textTheme.bodyText1,
)
]),
),
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
TextButton(
child: const Text("Text Button"),
onPressed: () {},
),
ElevatedButton(
child: Text(
"Hi",
style: Theme.of(context).textTheme.bodyText1!.copyWith(
color: Colors.white,
),
),
onPressed: () {},
),
],
)
])
),
),
);
}
}
The code yielded the following screen.
Homework
Do me a favor I've forgotten to add the text theme in the code, can you add it by yourself?
Summary
In this chapter we:
- We defined a set of colors for our app.
- We also added font and then defined styles for texts.
- We gave a border to the input form that our app will use.
- Then we also tested the theme with a mock page.
If you want to learn more, visit Google Shrine App Tutorial, MDC-103.
Chapter Five: How to Create Custom Widgets: App Bar, Drawer, and Bottom Navigation Bar in Flutter
Intro
In this section, we'll work on three widgets that'll be part of every screen in the app. Three global widgets we're building are App Bar, Bottom Navigation Bar, and Drawer. All of these three widgets a readily available in Flutter SDK. So usually, you don't have to make custom ones.
As the app grows, the dynamic content for the app bar also increases, hence it's better to write it once and use it everywhere with slight modification. As far as navigation goes, we are not going to use a drawer instead, we'll be using the bottom navigation bar. But we'll be using a drawer to manage navigation tasks related to the User Account, for instance, logout, user profile settings, order history, etc.
You'll find the source code up until now from this repo.
Creating Custom App Bar
We'll first create an app bar. First, let's create a folder and file for our app bar inside the globals folder.
# Cursor on root folder
# Create widgets and app_bar folder
mkdir lib/globals/widgets lib/globals/widgets/app_bar
# Create app_bar.dart
touch lib/globals/widgets/app_bar/app_bar.dart
Before we work on the app bar let's consider some features our app bar will have and how can we make it more flexible.
Is the current screen a main screen or sub-screen? If it's a sub-screen we'll have to display the back arrow while hiding it on the main screen.
Let's say we have search functionality, which is triggered by clicking the search icon. In doing so we'll have to go to a sub-page like mentioned previously. Moreover, we'll have to use navigation features as well.
We'll also need a person icon somewhere in the app bar, which we'll trigger the custom drawer we'll create later.
Maybe we'll want to add icons like shopping cart, bell icon, and more depending on the page, we are on.
app_bar.dart
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:temple/globals/settings/router/utils/router_utils.dart';
class CustomAppBar extends StatefulWidget with PreferredSizeWidget {
// Preffered size required for PreferredSizeWidget extension
final Size prefSize;
// App bar title depending on the screen
final String title;
// A bool to check whether its a subpage or not.
final bool isSubPage;
// An example of search icon press.
final bool hasSearchFunction;
CustomAppBar(
{required this.title,
this.isSubPage = false,
this.hasSearchFunction = false,
this.prefSize = const Size.fromHeight(56.0),
Key? key})
: super(key: key);
@override
Size get preferredSize => const Size.fromHeight(56.0);
@override
State<CustomAppBar> createState() => _CustomAppBarState();
}
class _CustomAppBarState extends State<CustomAppBar> {
@override
Widget build(BuildContext context) {
return AppBar(
title: Text(widget.title),
automaticallyImplyLeading: false,
leading: widget.isSubPage
? IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => GoRouter.of(context).pop(),
)
: null,
actions: [
widget.hasSearchFunction
? IconButton(
onPressed: () =>
GoRouter.of(context).goNamed(APP_PAGE.search.routeName),
icon: const Icon(Icons.search))
: const Icon(null),
IconButton(
onPressed: () {
print("Don't poke me!!");
},
icon: const Icon(Icons.person))
],
);
}
}
In Flutter, PreferredSizeWidget is a class interface that can be used to provide default size to a widget that otherwise is unconstrained. The getter function preferredSize is something that the PrefferedSized class requires you to provide and default value we're using 56px. As for the field prefSize, we'll provide the same value for height to the app bar and infinite width as with getter.
Other fields we've declared are all dynamic and need to provide value when called on their relevant pages. The field isSubPage helps to determine if the icons like Back Arrow and Search will appear on a screen or not. Likewise, the person icon will eventually slide the Drawer in and out.
The automaticallyImplyLeading property helps to determine what should be at the front: the title or the back arrow.
Now, let's go to the homepage and replace the app bar there with the custom app bar.
home.dart
// Changed to custom appbar
appBar: CustomAppBar(
title: APP_PAGE.home.routePageTitle,
),
// =====//
Except for the title, all other fields have default values. The title of the page can be derived from RouterUtils we made earlier on in the onboard section. This is what the app bar looks like for now.
We'll need to make some changes when we create the user drawer but for now, let's make the bottom navigation bar.
Create Bottom Navigation Bar
Nowadays, there's a trend to make the bottom nav bar the main navigation bar with tabs on each page as the sub-nav bars, like in the google play store app. After some consideration, we've decided that our main navigation will have links to three screens: Home, Favorites, and Shop. Previously we created the router_utils file to take care of the route necessities like route path, named route path, and page title. Before we proceed through the bottom navigation bar, let's make some changes in the router_utils file first.
enum APP_PAGE {
onboard,
auth,
home,
search,
shop,
favorite,
}
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";
case APP_PAGE.search:
return "/serach";
case APP_PAGE.favorite:
return "/favorite";
case APP_PAGE.shop:
return "/shop";
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";
case APP_PAGE.search:
return "Search";
case APP_PAGE.favorite:
return "Favorite";
case APP_PAGE.shop:
return "Shop";
default:
return "HOME";
}
}
// for page titles
String get routePageTitle {
switch (this) {
case APP_PAGE.home:
return "Astha";
case APP_PAGE.auth:
return "Register/SignIn";
case APP_PAGE.shop:
return "Shops";
case APP_PAGE.search:
return "Search";
case APP_PAGE.favorite:
return "Your Favorites";
default:
return "Astha";
}
}
}
Finally, Let's create relevant files and folders in globals.
# Cursor on root folder
# Create bottom_nav_bar folder
mkdir lib/globals/widgets/bottom_nav_bar
# Create bottom_nav_bar.dart
touch lib/globals/widgets/bottom_nav_bar/bottom_nav_bar.dart
bottom_nav_bar.dart
Flutter provides a Bottom Navigation Bar widget which is what we'll use to create our bottom navigation bar.
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:temple/globals/settings/router/utils/router_utils.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:temple/globals/settings/router/utils/router_utils.dart';
class CustomBottomNavBar extends StatefulWidget {
// create index to select from the list of route paths
final int navItemIndex; //#1
const CustomBottomNavBar({required this.navItemIndex, Key? key})
: super(key: key);
@override
_CustomBottomNavBarState createState() => _CustomBottomNavBarState();
}
class _CustomBottomNavBarState extends State<CustomBottomNavBar> {
// Make a list of routes that you'll want to go to
// #2
static final List<String> _widgetOptions = [
APP_PAGE.home.routeName,
APP_PAGE.favorite.routeName,
APP_PAGE.shop.routeName,
];
// Function that handles navigation based of index received
// #3
void _onItemTapped(int index) {
GoRouter.of(context).goNamed(_widgetOptions[index]);
}
@override
Widget build(BuildContext context) {
return BottomNavigationBar(
// List of icons that represent screen.
// # 4
items: const [
BottomNavigationBarItem(
icon: Icon(Icons.home),
label: 'Home',
),
BottomNavigationBarItem(
icon: Icon(Icons.favorite),
label: 'Favorites',
),
BottomNavigationBarItem(
icon: Icon(Icons.shop),
label: 'Shop',
),
],
// Backgroud color
// ==========================================//
// #5
backgroundColor: Theme.of(context).colorScheme.primary,
currentIndex: widget.navItemIndex, // current selected index
selectedItemColor:
Theme.of(context).colorScheme.onPrimary, // selected item color
selectedIconTheme: IconThemeData(
size: 30, // Make selected icon bigger than the rest
color: Theme.of(context)
.colorScheme
.onPrimary, // selected icon will be white
),
unselectedIconTheme: const IconThemeData(
size: 24, // Size of non-selected icons
color: Colors.black,
),
selectedLabelStyle: const TextStyle(
fontSize: 20, // When selected make text bigger
fontWeight: FontWeight.w400, // and bolder but not so thick
),
unselectedLabelStyle: const TextStyle(
fontSize: 16,
color: Colors.black,
),
onTap: _onItemTapped,
);
// ==========================================//
}
}
Many things are happening here.
We created a navItemIndex field, its value will be different for each screen CustomBottomNavBar is called. The value ranges from 0 to 2 since we're only using three screens. Remember in programming, index and position are different.
List of Named Routes for Go_Router to navigate to. The value of navItemIndex we get from the three screens i.e Home, Favorite, and Shop, and the order of the paths in this list should match, or else it'll navigate to the wrong screen.
The _onItemTapped function is responsible to provide the correct route based on the index. Have you noticed that we're not passing any index to the function when it's called down below to the onTap property? That's because we don't have to, the onTap property is built that way.
Icons that'll get displayed on the screens. Here, the Home Icon is the first icon. So, we should call the bottom nav bar at HomeScreen with navItemIndex of 0 and so on.
Here, we style our Navigation Bar, and change the size and color of selected items.
With this the navigation bar is ready. It's time to test it out on the homepage.
home.dart
Scaffold class has a property bottomNavigationBar where we'll pass the custom navigation bar.
appBar:....
bottomNavigationBar: const CustomBottomNavBar(
navItemIndex: 0,
),
body:...
Create Custom Drawer
It's now time to create a User drawer, that'll only handle the navigation of user-related settings, for instance, logout, order history, profile, etc. It'll slide in once we click the person icon in the app bar. Let's proceed to create files and folders first.
# Cursor on root folder
# Create user_drawer folder
mkdir lib/globals/widgets/user_drawer
# Create user_drawer.dart file
touch lib/globals/widgets/user_drawer/user_drawer.dart
Let's go over the design, just to be clear what do we mean by User Drawer. The scaffold has a drawer property, which is configured to slide a panel, usually a Drawer class when triggered. This panel is popularly used as a menu that slides in when a hamburger icon is clicked. However, we already have the bottom nav menu. Moreover, the drawer menu also covers the whole device's height and most of the width which we don't want. So, won't use the Drawer class, instead, we'll pass a alert dialog to the drawer/endDrawer property of Scaffold. The alert dialog will be centered and can have desired dimensions as well.
user_drawer.dart
import 'package:flutter/material.dart';
class UserDrawer extends StatefulWidget {
const UserDrawer({Key? key}) : super(key: key);
@override
_UserDrawerState createState() => _UserDrawerState();
}
class _UserDrawerState extends State<UserDrawer> {
@override
Widget build(BuildContext context) {
return AlertDialog(
backgroundColor: Theme.of(context).colorScheme.primary,
actionsPadding: EdgeInsets.zero,
scrollable: true,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(15),
),
title: Text(
"Astha",
style: Theme.of(context).textTheme.headline2,
),
// A line between the title section and the list of links
content: const Divider(
thickness: 1.0,
color: Colors.black,
),
actions: [
// Past two links as list tiles
ListTile(
leading: Icon(
Icons.person_outline_rounded,
color: Theme.of(context).colorScheme.secondary,
),
title: const Text('User Profile'),
onTap: () {
print("User Profile Button Pressed");
}),
ListTile(
leading: Icon(
Icons.logout,
color: Theme.of(context).colorScheme.secondary,
),
title: const Text('Logout'),
onTap: () {
print("Log Out Button Pressed");
}),
],
);
}
}
We're using list tiles that'll act as individual links.
Our drawer is ready, but for it to work properly it is not enough to be added on the home page as value to the endDrawer property of the scaffold. We have to understand and implement the following:
Scaffold is responsible for opening & closing a drawer. Each page has its scaffold. So, we need to differentiate them with a unique key representing Scaffold State for each scaffold.
The same scaffold key needs to be passed down the Custom App Bar, where we trigger the alert dialog by pressing the person icon.
Global Scaffold Key
Let's go to the home page to create a scaffold key for the home page.
Create Global Key
class _HomeState extends State<Home> {
// create a global key for scafoldstate
// #1
final GlobalKey<ScaffoldState> _scaffoldKey = GlobalKey();
........
Provide Scaffold key to the key properties
return Scaffold(
// Provide key to scaffold
// #2
key: _scaffoldKey,
.....
Pass the key to Custom App Bar
appBar: CustomAppBar(
title: APP_PAGE.home.routePageTitle,
// pass the scaffold key to custom app bar
// #3
scaffoldKey: _scaffoldKey,
),
You'll get an error because there's no scaffoldKey field in Custom App Bar, ignore it, for now, we'll fix it in a moment.
Pass the Drawer to the drawer property
// #4
// Pass our drawer to drawer property
// if you want to slide left to right use
// drawer: UserDrawer(),
// if you want to slide right to left use
endDrawer: const UserDrawer(),
Note: Remember to repeat this process for each main screen passed onto the bottom navigation bar.
The whole home page now looks like this:
import 'package:flutter/material.dart';
import 'package:temple/globals/widgets/app_bar/app_bar.dart';
import 'package:temple/globals/settings/router/utils/router_utils.dart';
import 'package:temple/globals/widgets/bottom_nav_bar/bottom_nav_bar.dart';
import 'package:temple/globals/widgets/user_drawer/user_drawer.dart';
class Home extends StatefulWidget {
const Home({Key? key}) : super(key: key);
@override
State<Home> createState() => _HomeState();
}
class _HomeState extends State<Home> {
// create a global key for scafoldstate
// #1
final GlobalKey<ScaffoldState> _scaffoldKey = GlobalKey();
@override
Widget build(BuildContext context) {
return Scaffold(
// Provide key to scaffold
// #2
key: _scaffoldKey,
// Changed to custom appbar
appBar: CustomAppBar(
title: APP_PAGE.home.routePageTitle,
// pass the scaffold key to custom app bar
// #3
scaffoldKey: _scaffoldKey,
),
// #4
// Pass our drawer to drawer property
// if you want to slide lef to right use
// drawer: UserDrawer(),
// if you want to slide right to left use
endDrawer: const UserDrawer(),
bottomNavigationBar: const CustomBottomNavBar(
navItemIndex: 0,
),
primary: true,
body: SafeArea(
child: Container(
padding: const EdgeInsets.all(20),
color: Theme.of(context).colorScheme.background,
child:
Column(mainAxisAlignment: MainAxisAlignment.start, children: [
Card(
child: Container(
width: 300,
height: 200,
padding: const EdgeInsets.all(4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
"Hi",
style: Theme.of(context).textTheme.headline2,
textAlign: TextAlign.left,
),
Text(
"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Eu id lectus in gravida mauris, nascetur. Cras ut commodo consequat leo, aliquet a ipsum nulla.",
style: Theme.of(context).textTheme.bodyText1,
)
]),
),
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
TextButton(
child: const Text("Text Button"),
onPressed: () {},
),
ElevatedButton(
child: Text(
"Hi",
style: Theme.of(context).textTheme.bodyText1!.copyWith(
color: Colors.white,
),
),
onPressed: () {},
),
],
)
])),
),
);
}
}
Open Drawer Using ScaffoldKey
Now, we need to make changes to the custom app bar.
Create and pass the Global Scaffold Key
// Declare new global key field of type ScaffoldState
// #1
final GlobalKey<ScaffoldState> scaffoldKey;
const CustomAppBar(
{required this.title,
required this.scaffoldKey, //#2 pass the new scaffold key to constructor
this.isSubPage = false,
this.hasSearchFunction = false,
this.prefSize = const Size.fromHeight(56.0),
Key? key})
: super(key: key);
This should fix the error we're facing.
Pop Alert Dialog On Tap
IconButton(
icon: const Icon(Icons.person),
// #3
// Slide right to left
onPressed: () => widget.scaffoldKey.currentState!.openEndDrawer(),
// slide lef to right
// onPressed: () => widget.scaffoldKey.currentState!.openDrawer(),
),
Now, the alert dialog will work as a custom user drawer. Have a look at this short clip about its workings.
Homework
Here are a few tasks for you to practice:
- Create A simple favorite and shop screen.
- Link your bottom navigation bar to these screens.
- Only on the shop page display a shopping cart in the app bar.
- Create a search page:
- It should be a sub-page of the home page. You can use a search icon, that'll navigate to the search page, on tap.
- It should only display the back arrow icon as a leading icon.
- Pressing the back arrow should take you back to the home page.
- Sub-pages don't have a bottom navigation bar, so don't display it there.
Summary
With this comes an end to the 4th chapter, which was dedicated to creating global widgets that'll be used throughout the application. Here,
- We created a custom app bar, which will display icons based on the conditions we've provided.
- We also made a menu that'll stick at the bottom of our application instead of sliding in and out.
- We also created a secondary menu that'll be responsible for navigating to the user's settings like profile, order history, logging the user out, etc.
Chapter Six: Authentication in Flutter | User Interface Design
Introduction
This chapter is one of the most important parts of this blog as you can tell from the chapter's title because now, according to our user-flow screen given below, we have to authenticate the user.
Authentication is a basic yet very important aspect of any application regardless of platform. Serverless/Headless apps are on trend these days. Among them, Google's Firebase is one of the popular ones, especially for mobile applications. In this chapter of the series, we'll create an authentication UI. We'll create a dynamic input form widget to be more efficient. We'll also write some validations for the input, and animate sign in <-> registration form transitions.
You can find code up until from here.
Create Authentication Form In Flutter
We're going to make a simple form. For registration, we'll have four inputs: email, username, password, and confirm password inputs, while the sign-in form only uses two inputs: email and password.
Let's create relevant files and folders in our project.
# cursor on root folder
# create folders
mkdir lib/screens/auth lib/screens/auth/widgets lib/screens/auth/providers lib/screens/auth/utils
# Create files
touch lib/screens/auth/auth_screen.dart lib/screens/auth/providers/auth_provider.dart lib/screens/auth/widgets/text_from_widget.dart lib/screens/auth/widgets/auth_form_widget.dart lib/screens/auth/utils/auth_validators.dart lib/screens/auth/utils/auth_utils.dart
Create Dynamic Text Form Widget
Before we create our dynamic text form field widget, let's go over the details of how dynamic it's going to be.
We'll make our dynamic text form widget in text_from_widget.dart file.
import 'package:flutter/material.dart';
class DynamicInputWidget extends StatelessWidget {
const DynamicInputWidget(
{required this.controller,
required this.obscureText,
required this.focusNode,
required this.toggleObscureText,
required this.validator,
required this.prefIcon,
required this.labelText,
required this.textInputAction,
required this.isNonPasswordField,
Key? key})
: super(key: key);
// bool to check if the text field is for password or not
final bool isNonPasswordField;
// Controller for the text field
final TextEditingController controller;
// Functio to toggle Text obscuractio on password text field
final VoidCallback? toggleObscureText;
// to obscure text or not bool
final bool obscureText;
// FocusNode for input
final FocusNode focusNode;
// Validator function
final String? Function(String?)? validator;
// Prefix icon for input form
final Icon prefIcon;
// label for input form
final String labelText;
// The keyword action to display
final TextInputAction textInputAction;
@override
Widget build(BuildContext context) {
return TextFormField(
controller: controller,
decoration: InputDecoration(
// Input with border outlined
border: const OutlineInputBorder(
// Make border edge circular
borderRadius: BorderRadius.all(Radius.circular(10.0)),
),
label: Text(labelText),
prefixIcon: prefIcon,
suffixIcon: IconButton(
onPressed: toggleObscureText,
// If is non-password filed like emal the suffix icon will be null
icon: isNonPasswordField
? const Icon(null)
: obscureText
? const Icon(Icons.visibility)
: const Icon(Icons.visibility_off),
),
),
focusNode: focusNode,
textInputAction: textInputAction,
obscureText: obscureText,
validator: validator,
// onSaved: passwordVlidator,
);
}
}
Let's go over a few important fields of our dynamic input widget class.
// bool to check if the text field is for password or not
final bool isNonPasswordField; //# 1
// Function to toggle Text obscuraction on password text field
final VoidCallback? toggleObscureText; //# 2
// to obscure text or not bool
final bool obscureText;
// Validator function
final String? Function(String?)? validator; //# 3
- We are checking if the input type is a password or not. We're going to need that to determine whether to display a suffix icon that toggles the visibility of the password.
- Boolean obscure text will change the password's visibility from asterisks to strings and vice-versa.
- Validator is a function of a property validator in TextFormField. It returns null when valid and a custom string when invalid.
Input Validation in Flutter
Now, that our dynamic input is ready. Let's write some validators before we move on to create our form. We created a separate file auth_validators.dart for this sole purpose. We'll write three functions, which will separately validate Email, Password, and Confirm Passwords for user input.
Create Email Validator in Flutter
class AuthValidators {
// Create error messages to send.
// #1
static const String emailErrMsg = "Invalid Email Address, Please provide a valid email.";
static const String passwordErrMsg = "Password must have at least 6 characters.";
static const String confirmPasswordErrMsg = "Two passwords don't match.";
// A simple email validator that checks presence and position of @
// #2
String? emailValidator(String? val) {
final String email = val as String;
// If length of email is <=3 then its invlaid
// #3
if (email.length <= 3) return emailErrMsg;
// Check if it has @
// # 4
final hasAtSymbol = email.contains('@');
// find position of @
// # 5
final indexOfAt = email.indexOf('@');
// Check numbers of @
// # 6
final numbersOfAt = "@".allMatches(email).length;
// Valid if has @
// # 7
if (!hasAtSymbol) return emailErrMsg;
// and if number of @ is only 1
// # 8
if (numbersOfAt != 1) return emailErrMsg;
//and if '@' is not the first or last character
// # 9
if (indexOfAt == 0 || indexOfAt == email.length - 1) return emailErrMsg;
// Else its valid
return null;
}
}
Inside the auth_validators.dart, we create a AuthValidators class.
- Then we created three error messages that'll be sent as output to the relevant error.
- Email Validator Function that takes input value from its input form.
- Email is invalid if its length <=3.
- (4&7) Checks if there's a @ symbol and returns the error message on absence.
- (5&9) Checks the position of @. If the position is in the beginning or at the end, that means the email is invalid.
- (6&8) If the number of @ is more or less than one, then the input is an invalid email.
That was a very simple validator, where we checked four things: if the input for email is empty, the length of the email input is less than 4, and the position and number @ in the input.
Reminder: Do not change the order of return statements. It will cause an error.
Create Password Validator in Flutter
We'll continue with the second validator for the password. We'll only validate the password if it's not empty and its length is greater than 5. So, once again inside AuthValidator class, we'll create a new function passwordValidator.
// Password validator
String? passwordVlidator(String? val) {
final String password = val as String;
if (password.isEmpty || password.length <= 5) return passwordErrMsg;
return null;
}
Create Confirm Password Validator in Flutter
During registration, there will be two password input fields, the second one is to confirm the given password. Our confirmPassword validator will take two inputs: the original password and the second password.
// Confirm password
String? confirmPasswordValidator(String? val, firstPasswordInpTxt) {
final String firstPassword = firstPasswordInpTxt;
final String secondPassword = val as String;
// If either of the password field is empty
// Or if thier length do not match then we don't need to compare their content
// #1
if (firstPassword.isEmpty ||
secondPassword.isEmpty ||
firstPassword.length != secondPassword.length) {
return confirmPasswordErrMsg;
}
// If two passwords do not match then send error message
// #2
if (firstPassword != secondPassword) return confirmPasswordErrMsg;
return null;
}
For password confirmation, we checked:
- If either of the two passwords is empty or if their lengths don't match each other. If so, then return the error message.
- The second condition compares the content of two passwords, if they don't match then returns the error message.
Like this our three validators are ready for action.
Altogether AuthValidators Class looks like this.
class AuthValidators {
// Create error messages to send.
static const String emailErrMsg =
"Invalid Email Address, Please provide a valid email.";
static const String passwordErrMsg =
"Password must have at least 6 characters.";
static const String confirmPasswordErrMsg = "Two passwords don't match.";
// A simple email validator that checks presence and position of @
String? emailValidator(String? val) {
final String email = val as String;
// If length of email is <=3 then its invlaid
if (email.length <= 3) return emailErrMsg;
// Check if it has @
final hasAtSymbol = email.contains('@');
// find position of @
final indexOfAt = email.indexOf('@');
// Check numbers of @
final numbersOfAt = "@".allMatches(email).length;
// Valid if has @
if (!hasAtSymbol) return emailErrMsg;
// and if number of @ is only 1
if (numbersOfAt != 1) return emailErrMsg;
//and if '@' is not first or last character
if (indexOfAt == 0 || indexOfAt == email.length - 1) return emailErrMsg;
// Else its valid
return null;
}
// Password validator
String? passwordVlidator(String? val) {
final String password = val as String;
if (password.isEmpty || password.length <= 5) return passwordErrMsg;
return null;
}
// Confirm password
String? confirmPasswordValidator(String? val, firstPasswordInpTxt) {
final String firstPassword = firstPasswordInpTxt;
final String secondPassword = val as String;
// If either of the password field is empty
// Or if thier length do not match then we don't need to compare their content
if (firstPassword.isEmpty ||
secondPassword.isEmpty ||
firstPassword.length != secondPassword.length) {
return confirmPasswordErrMsg;
}
// If two passwords do not match then send error message
if (firstPassword != secondPassword) return confirmPasswordErrMsg;
return null;
}
}
Homework on Validators
Here's something for you to practice.
- xyz..abc@mail.com is an invalid email address because symbols(_, ., -) should always be followed by letters or numbers. Can you write a validator for this error?.
- On the password validator, check that it should contain both numbers and letters to be valid.
- Add a username validator, check that it should not be the same as an email address. By the way, the username is the optional input.
How to create Register & Sign in Form in Flutter?
Auth Form Widget: Temporary
Now, that we've made our dynamic input as well as validator it's time to put them together to create an authentication form visuals. We'll create our form widget in the auth_form_widget.dart file which will be displayed in auth_screen.dart file.
auth_form_widget.dart
import 'package:flutter/material.dart';
class AuthFormWidget extends StatefulWidget {
const AuthFormWidget({Key? key}) : super(key: key);
@override
State<AuthFormWidget> createState() => _AuthFormWidgetState();
}
class _AuthFormWidgetState extends State<AuthFormWidget> {
// Define Form key
final _formKey = GlobalKey<FormState>();
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(8),
child: Form(
key: _formKey,
child: TextFormField(),
),
);
}
}
In flutter Form class can act as a container to display TextFormFields(if they are more than one). It also requires a FormState which can be obtained via the global key of type FormState as we did just now. Before this form widget bulks up let's connect it to the auth_screen and display it on the app. We'll change it later on after connecting auth screen to the router.
Authentication Screen
auth_screen.dart
import 'package:flutter/material.dart';
import 'package:temple/globals/settings/router/utils/router_utils.dart';
import 'package:temple/screens/auth/widgets/auth_form_widget.dart';
class AuthScreen extends StatelessWidget {
const AuthScreen({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text(APP_PAGE.auth.routePageTitle)),
body:
// Safe area prevents safe gards widgets to go beyond device edges
SafeArea(
//===========//
// to dismiss keyword on tap outside use listener
child: Listener(
onPointerDown: (PointerDownEvent event) =>
FocusManager.instance.primaryFocus?.unfocus(),
//===========//
child: SingleChildScrollView(
child: SizedBox(
width: double.infinity,
child: Column(children: [
// Display a welcome user image
Padding(
padding: const EdgeInsets.all(8.0),
child: Image.asset(
'assets/AuthScreen/WelcomeScreenImage_landscape_2.png',
fit: BoxFit.fill,
),
),
const AuthFormWidget()
]),
),
),
),
),
);
}
}
Here is auth_screen.dart, this is it, we'll not make any changes in the future.
Since the app is only accessible to registered users, the authentication screen will be the first screen our user will be prompted to after onboarding. So, we don't need to display user_drawer or the bottom navbar.
Dismissal of the active keyboard, when tapped outside, is an important characteristic to have. Listener class responds to gesture events like a mouse click, tap, etc. Together with FocusManager we can track the focus node tree and unfocus the active keyboard. You might be wondering why am I using it on auth_screen as a whole instead of auth_form_widget. That's because gesture detector events should cover the whole visible area which in this case is SingleChildScrollView.
Next, is the image section, if you're using the source code from the GitHub repo image should already be in the AuthScreen folder inside assets. Let's add the path in the pubspec file.
assets:
# Splash Screens
- assets/splash/om_splash.png
- assets/splash/om_lotus_splash.png
- assets/splash/om_lotus_splash_1152x1152.png
# Onboard Screens
- assets/onboard/FindTemples.png
- assets/onboard/FindVenues.png
// New line
# Auth Screens
- assets/AuthScreen/WelcomeScreenImage_landscape_2.png
It's time to add auth_screen to app_router.dart
Connect Auth Screen to Router
app_router.dart
routes: [
GoRoute(
path: APP_PAGE.home.routePath,
name: APP_PAGE.home.routeName,
builder: (context, state) => const Home(),
),
// Add the onboard Screen
GoRoute(
path: APP_PAGE.onboard.routePath,
name: APP_PAGE.onboard.routeName,
builder: (context, state) => const OnBoardScreen()),
// New line from here
// Add Auth Screen on Go Router
GoRoute(
path: APP_PAGE.auth.routePath,
name: APP_PAGE.auth.routeName,
builder: (context, state) => const AuthScreen()),
],
Temporary Navigation To AuthScreen
We're yet to write a backend/business logic in this blog. But we do have to test UI. So, let's create a temporary link in our user_drawer.dart file that'll take us to auth_screen.
user_drawer.dart
...............
actions: [
ListTile(
leading: Icon(
Icons.person_outline_rounded,
color: Theme.of(context).colorScheme.secondary,
),
title: const Text('User Profile'),
onTap: () {
print("User Profile Button Pressed");
}),
// ============================//
// A temporarry link to auth screen
ListTile(
leading: Icon(
Icons.login,
color: Theme.of(context).colorScheme.secondary,
),
title: const Text('Register/Login'),
onTap: () => GoRouter.of(context).goNamed(APP_PAGE.auth.routeName)),
// ============================//
ListTile(
leading: Icon(
Icons.logout,
color: Theme.of(context).colorScheme.secondary,
),
title: const Text('Logout'),
onTap: () {
print("Log Out Button Pressed");
}),
],
.....
Save it all and run the app, navigate to auth_screen from the user drawer(press the person icon for the drawer), and you'll see auth screen. Now that we can see the authentication screen, let's create a full-fledged auth form.
A Complete Auth Form Widget
The codes for auth_form_widget.dart file is really long. So, let's go over it a few pieces at a time first.
Instantiate AuthValidators inside _AuthFormWidgetState class.
// Instantiate validator
final AuthValidators authValidator = AuthValidators();
Create controllers and focus nodes.
// controllers
late TextEditingController emailController;
late TextEditingController usernameController;
late TextEditingController passwordController;
late TextEditingController confirmPasswordController;
// create focus nodes
late FocusNode emailFocusNode;
late FocusNode usernameFocusNode;
late FocusNode passwordFocusNode;
late FocusNode confirmPasswordFocusNode;
Create obscure text bool
// to obscure text default value is false
bool obscureText = true;
Auth Mode
Instead of creating two separate screens for registering and signing in, we'll just toggle between the few inputs displayed, on and off. For that, let's create a boolean to control authMode.
// This will require toggling between register and signin mode
bool registerAuthMode = false;
Instantiate and Dispose
Instantiate all the text editing controllers and focus nodes on initState function. Similarly, these all also need to be disposed of once done so let's do that as well with the dispose method.
@override
void initState() {
super.initState();
emailController = TextEditingController();
usernameController = TextEditingController();
passwordController = TextEditingController();
confirmPasswordController = TextEditingController();
emailFocusNode = FocusNode();
usernameFocusNode = FocusNode();
passwordFocusNode = FocusNode();
confirmPasswordFocusNode = FocusNode();
}
@override
void dispose() {
super.dispose();
emailController.dispose();
usernameController.dispose();
passwordController.dispose();
confirmPasswordController.dispose();
emailFocusNode.dispose();
usernameFocusNode.dispose();
passwordFocusNode.dispose();
confirmPasswordFocusNode.dispose();
}
Password Visibility Handler
Create a function that'll toggle the password's visibility on the relevant icon tap.
void toggleObscureText() {
setState(() {
obscureText = !obscureText;
});
}
Snackbar Widget
Let's create a snack bar to pop info on various circumstances.
// Create a scaffold messanger
SnackBar msgPopUp(msg) {
return SnackBar(
content: Text(
msg,
textAlign: TextAlign.center,
));
}
Prepare for List of Widgets In Column
Let's change the child of Form we're returning to a column.
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(8),
child: Form(
key: _formKey,
child: Column(children: [],)
),
);
}
Input Widgets
It's time to create our email, username, password, and confirm password input forms. Inside column's children for from widget, we'll add inputs one by one.
Email Input
// Email
DynamicInputWidget(
controller: emailController,
obscureText: false,
focusNode: emailFocusNode,
toggleObscureText: null,
validator: authValidator.emailValidator,
prefIcon: const Icon(Icons.mail),
labelText: "Enter Email Address",
textInputAction: TextInputAction.next,
isNonPasswordField: true,
),
const SizedBox(
height: 20,
),
Make sure to import DynamicInput Widget
Username Input
// Username
DynamicInputWidget(
controller: usernameController,
obscureText: false,
focusNode: usernameFocusNode,
toggleObscureText: null,
validator: null,
prefIcon: const Icon(Icons.person),
labelText: "Enter Username(Optional)",
textInputAction: TextInputAction.next,
isNonPasswordField: true,
),
const SizedBox(
height: 20,
),
Password Input
// password
DynamicInputWidget(
controller: passwordController,
labelText: "Enter Password",
obscureText: obscureText,
focusNode: passwordFocusNode,
toggleObscureText: toggleObscureText,
validator: authValidator.passwordVlidator,
prefIcon: const Icon(Icons.password),
textInputAction: registerAuthMode
? TextInputAction.next
: TextInputAction.done,
isNonPasswordField: false,
),
const SizedBox(
height: 20,
),
Confirm Password Input
// confirm password
DynamicInputWidget(
controller: confirmPasswordController,
focusNode: confirmPasswordFocusNode,
isNonPasswordField: false,
labelText: "Confirm Password",
obscureText: obscureText,
prefIcon: const Icon(Icons.password),
textInputAction: TextInputAction.done,
toggleObscureText: toggleObscureText,
validator: (val) => authValidator.confirmPasswordValidator(
val, passwordController.text),
),
const SizedBox(
height: 20,
),
Regsiter/SignIn Button
Later on, we'll also need to create and toggle functions to register and sign in once we set up the firebase back-end. For now, we'll just toggle the register/sign-in texts of the elevated button.
// Toggle register/singin button text. Later on we'll also need to toggle register or signin function
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () {},
child: const Text('Cancel'),
),
const SizedBox(
width: 20,
),
ElevatedButton(
onPressed: () {},
child: Text(registerAuthMode ? 'Register' : 'Sign In'),
style: ButtonStyle(
elevation: MaterialStateProperty.all(8.0),
),
),
],
),
Toggle Auth Mode
Manage authentication mode with setState().
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(registerAuthMode
? "Already Have an account?"
: "Don't have an account yet?"),
TextButton(
onPressed: () =>
setState(() => registerAuthMode = !registerAuthMode),
child: Text(registerAuthMode ? "Sign In" : "Regsiter"),
)
],
)
Register/Sigin Animated Transition
If you save the file and run it. You probably notice the toggle doesn't work. That's because we haven't implemented animated transition yet. Two input widgets: username and *confirm password * widgets need to be hidden during the sign-in process but visible on registration. So, we'll use the toggle visibility base on the value of the variable registerAuthMode we created earlier. We'll use animation for a smooth transition during the toggle.
AnimatedContainer class can be used for animation, while AnimatedOpacity class can be used for fade in/out widgets. With the combination of these two, we'll toggle input with animated opacity while the animated container will squeeze/fill the space occupied by input smoothly.
So, let's animate the username, sized-box widget following it, and confirm-password input widget.
// Username
AnimatedContainer(
duration: const Duration(milliseconds: 500),
height: registerAuthMode ? 65 : 0,
child: AnimatedOpacity(
duration: const Duration(milliseconds: 500),
opacity: registerAuthMode ? 1 : 0,
child: DynamicInputWidget(
controller: usernameController,
obscureText: false,
focusNode: usernameFocusNode,
toggleObscureText: null,
validator: null,
prefIcon: const Icon(Icons.person),
labelText: "Enter Username(Optional)",
textInputAction: TextInputAction.next,
isNonPasswordField: true,
),
),
),
// We'll also need to fade in/out sizedbox
AnimatedOpacity(
duration: const Duration(milliseconds: 500),
opacity: registerAuthMode ? 1 : 0,
child: const SizedBox(
height: 20,
),
),
// Confirm password
AnimatedContainer(
duration: const Duration(milliseconds: 500),
height: registerAuthMode ? 65 : 0,
child: AnimatedOpacity(
duration: const Duration(milliseconds: 500),
opacity: registerAuthMode ? 1 : 0,
child: DynamicInputWidget(
controller: confirmPasswordController,
focusNode: confirmPasswordFocusNode,
isNonPasswordField: false,
labelText: "Confirm Password",
obscureText: obscureText,
prefIcon: const Icon(Icons.password),
textInputAction: TextInputAction.done,
toggleObscureText: toggleObscureText,
validator: (val) => authValidator.confirmPasswordValidator(
val, passwordController.text),
),
),
),
After this, you'll be able to see two inputs on the authentication screen, cause sign-in mode is our default mode. Now, our Form Widget UI is ready. Don't be confused, you can find all auth_form_widget.dart as a whole down below.
import 'package:flutter/material.dart';
import 'package:temple/screens/auth/utils/auth_validators.dart';
import 'package:temple/screens/auth/widgets/text_from_widget.dart';
class AuthFormWidget extends StatefulWidget {
const AuthFormWidget({Key? key}) : super(key: key);
@override
State<AuthFormWidget> createState() => _AuthFormWidgetState();
}
class _AuthFormWidgetState extends State<AuthFormWidget> {
// Define Form key
final _formKey = GlobalKey<FormState>();
// Instantiate validator
final AuthValidators authValidator = AuthValidators();
// controllers
late TextEditingController emailController;
late TextEditingController usernameController;
late TextEditingController passwordController;
late TextEditingController confirmPasswordController;
// create focus nodes
late FocusNode emailFocusNode;
late FocusNode usernameFocusNode;
late FocusNode passwordFocusNode;
late FocusNode confirmPasswordFocusNode;
// to obscure text default value is false
bool obscureText = true;
// This will require to toggle between register and sigin in mode
bool registerAuthMode = false;
// Instantiate all the *text editing controllers* and focus nodes on *initState* function
@override
void initState() {
super.initState();
emailController = TextEditingController();
usernameController = TextEditingController();
passwordController = TextEditingController();
confirmPasswordController = TextEditingController();
emailFocusNode = FocusNode();
usernameFocusNode = FocusNode();
passwordFocusNode = FocusNode();
confirmPasswordFocusNode = FocusNode();
}
// These all need to be disposed of once done so let's do that as well.
@override
void dispose() {
super.dispose();
emailController.dispose();
usernameController.dispose();
passwordController.dispose();
confirmPasswordController.dispose();
emailFocusNode.dispose();
usernameFocusNode.dispose();
passwordFocusNode.dispose();
confirmPasswordFocusNode.dispose();
}
// Create a function that'll toggle the password's visibility on the relevant icon tap.
void toggleObscureText() {
setState(() {
obscureText = !obscureText;
});
}
// Let's create a snack bar to pop info on various circumstances.
// Create a scaffold messanger
SnackBar msgPopUp(msg) {
return SnackBar(
content: Text(
msg,
textAlign: TextAlign.center,
));
}
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(8),
child: Form(
key: _formKey,
child: Column(
children: [
// Email
DynamicInputWidget(
controller: emailController,
obscureText: false,
focusNode: emailFocusNode,
toggleObscureText: null,
validator: authValidator.emailValidator,
prefIcon: const Icon(Icons.mail),
labelText: "Enter Email Address",
textInputAction: TextInputAction.next,
isNonPasswordField: true,
),
const SizedBox(
height: 20,
),
// Username
AnimatedContainer(
duration: const Duration(milliseconds: 500),
height: registerAuthMode ? 65 : 0,
child: AnimatedOpacity(
duration: const Duration(milliseconds: 500),
opacity: registerAuthMode ? 1 : 0,
child: DynamicInputWidget(
controller: usernameController,
obscureText: false,
focusNode: usernameFocusNode,
toggleObscureText: null,
validator: null,
prefIcon: const Icon(Icons.person),
labelText: "Enter Username(Optional)",
textInputAction: TextInputAction.next,
isNonPasswordField: true,
),
),
),
AnimatedOpacity(
duration: const Duration(milliseconds: 500),
opacity: registerAuthMode ? 1 : 0,
child: const SizedBox(
height: 20,
),
),
DynamicInputWidget(
controller: passwordController,
labelText: "Enter Password",
obscureText: obscureText,
focusNode: passwordFocusNode,
toggleObscureText: toggleObscureText,
validator: authValidator.passwordVlidator,
prefIcon: const Icon(Icons.password),
textInputAction: registerAuthMode
? TextInputAction.next
: TextInputAction.done,
isNonPasswordField: false,
),
const SizedBox(
height: 20,
),
AnimatedContainer(
duration: const Duration(milliseconds: 500),
height: registerAuthMode ? 65 : 0,
child: AnimatedOpacity(
duration: const Duration(milliseconds: 500),
opacity: registerAuthMode ? 1 : 0,
child: DynamicInputWidget(
controller: confirmPasswordController,
focusNode: confirmPasswordFocusNode,
isNonPasswordField: false,
labelText: "Confirm Password",
obscureText: obscureText,
prefIcon: const Icon(Icons.password),
textInputAction: TextInputAction.done,
toggleObscureText: toggleObscureText,
validator: (val) => authValidator.confirmPasswordValidator(
val, passwordController.text),
),
),
),
const SizedBox(
height: 20,
),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () {},
child: const Text('Cancel'),
),
const SizedBox(
width: 20,
),
ElevatedButton(
onPressed: () {},
child: Text(registerAuthMode ? 'Register' : 'Sign In'),
style: ButtonStyle(
elevation: MaterialStateProperty.all(8.0),
),
),
],
),
const SizedBox(
height: 20,
),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(registerAuthMode
? "Already Have an account?"
: "Don't have an account yet?"),
TextButton(
onPressed: () =>
setState(() => registerAuthMode = !registerAuthMode),
child: Text(registerAuthMode ? "Sign In" : "Regsiter"),
)
],
)
],
),
),
);
}
}
Our validator's still not going to work, because we still haven't handled what to do on form submission. But let's check out the result of our hard work.
Summary
Let's summarize what we did so far.
- Created Dynamic Input Widget.
- Wrote our validators for the inputs we'll be taking in.
- Create both sign-in and registration forms on one screen.
- Animated the transition between Registration and Sign In authentication.
Chapter Seven: Flutter Firebase Setup | Cloud & Emulator
Intro
We'll now Create Firebase Project; Connect the Firebase cloud project with a Flutter project from the Command Line Terminal; Install the Firebase and FlutterFire CLI; Set up Firebase locally with the local emulator suite.
Setting Up
The project source code until the last part can be found in this folder.
Create A Firebase Project
Please, create a Firebase project on the console. If you have never created a Firebase project then follow the instructions from this code lab by google.
Install Firebase CLI
We'll rely on FlutterFire package for our development. Configuring the FlutterFire package can be done with FlutterFire CLI. But FlutterFire CLI relies on the Firebase CLI.
Follow the instruction here to install the CLI and then login into the Firebase account. No, need to initialize Firebase, for now, we will do it later on after FlutterFire CLI configurations.
Install Dependencies
After the firebase CLI installation, before we link our project to the firebase project, we'll install some dependencies. On your terminal:
# Root of the flutter project
# Firebase core
flutter pub add firebase_core
# Firebase Auth
flutter pub add firebase_auth
# Firebase Firestore
flutter pub add cloud_firestore
# Firebase Cloud Functions
flutter pub add cloud_functions
# Firebase Storage
flutter pub add firebase_storage
We've installed Firebase Core, Firebase Auth, Firebase Cloud Functions, and Firebase Storage packages for flutter.
Install and Configure FlutterFire CLI
Let's install and configure the CLI on the terminal
# Install Firebase CLI
dart pub global activate flutterfire_cli
# On the root of your project
# configur cli
flutterfire configure
During the configuration process:
- You'll be asked to choose the right project from the list(if there are multiple).
- You'll also be asked to choose apps for the project. Chose only android and ios. You can select/deselect with the space bar.
- You'll be asked to provide the bundle id for IOS. I just gave the same one as android: com.example.flutterfirebase_practice.
Now we're done with flutter fire installation and configurations, and next, we need to initialize it.
Firebase Initialization
On the main.dart file of the project let's initialize firebase.
// Import
import 'package:firebase_core/firebase_core.dart';
import 'firebase_options.dart';
// Inside main() method
// Initialize Firebase
await Firebase.initializeApp(
options: DefaultFirebaseOptions.currentPlatform,
);
Now our main.dart file should look like this.
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:temple/app.dart';
import 'package:firebase_core/firebase_core.dart';
import 'firebase_options.dart';
void main() async {
// concrete binding for applications based on the Widgets framewor
WidgetsFlutterBinding.ensureInitialized();
// Instantiate shared pref
SharedPreferences prefs = await SharedPreferences.getInstance();
SystemChrome.setSystemUIOverlayStyle(
SystemUiOverlayStyle.dark.copyWith(statusBarColor: Colors.black38),
);
// Initialize Firebase
await Firebase.initializeApp(
options: DefaultFirebaseOptions.currentPlatform,
);
// Pass prefs as value in MyApp
runApp(MyApp(prefs: prefs));
}
Set-Up Firebase Backend: Cloud and Emulator
We'll be writing a lot of code as backend, separate from our flutter side to keep it secure and clean. So, let's set up firebase separately for both cloud and emulators.
*Reminder: Before we start configuration, make sure to create a Firestore database(on test mode) from the firebase console of the project you've created. Otherwise, you'll get an error during the process telling you there's no database created. *
# On the root of your project
firebase init
- You'll be asked to choose from different Firebase products. Choose four: Firestore, Firebase Storage, Cloud Functions, and Emulators.
- Next, you have to choose the Firebase project or create one. Since we've already created one, chose the right project from the existing ones.
- Just press enter, on all the file options provided there.
- Select JavaScript as the language to use for firebase functions.
- Select 'y' for both linter and installations.
- During the process, you'll be prompted to Emulators settings. Here, please select four products: Authentication, Firestore, Storage, and Functions Emulators.
- Just press enter to select the default ports.
- Enter "Y" to enable emulator UI.
- Press enter for the rest.
Now, we've set up all the necessities. Wait there's still more, we still haven't linked the flutter project to Firebase Emulator Suite.
Connect Firebase Emulator Suite to Flutter Project.
To connect to the emulator we once again need to make some changes to the **main.dart **file.
Import required dependencies if you haven't already
import 'dart:io' show Platform; // Its required for emulator
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:cloud_functions/cloud_functions.dart';
import 'package:firebase_auth/firebase_auth.dart';
import 'package:firebase_storage/firebase_storage.dart';
Create Global Boolean
// Outside of any class or methods, before main()
const bool _useEmulator = true;
Connection Handler
Create a function that'll connect to the emulator with the required settings.
// Outside of main, preferably at the end of the file
// Settings for firebase emulator connection
Future _connectToEmulator() async {
// Provide url to the emulator, localhost might not work on android emulator.
final host = Platform.isAndroid ? '10.0.2.2' : 'localhost'; //#1
// Provide port for all the local emulator prodcuts
// #2
const authPort = 9099;
const firestorePort = 8080;
const functionsPort = 5001;
const storagePort = 9199;
// Just to make sure we're running locally
print("I am running on emulator");
// Instruct all the relevant firebase products to use the firebase emulator
// # 3
await FirebaseAuth.instance.useAuthEmulator(host, authPort);
FirebaseFirestore.instance.useFirestoreEmulator(host, firestorePort);
FirebaseFunctions.instance.useFunctionsEmulator(host, functionsPort);
FirebaseStorage.instance.useStorageEmulator(host, storagePort);
}
Let's go through the vitals.
- When running on an android emulator it's not localhost. So, it can give you errors so, check the platform and provide the right URL.
- Each firebase product will run on its port. So, provide the right port. You can find the port for all products in the firebase.json file.
- Instruct all the products to use Firebase Emulator if it's running.
Initalize Emulator On Main
Now, we need to call this function on the** main()** method right after you initialize firebase.
// Set app to run on firebase emulator
if (_useEmulator) {
await _connectToEmulator();
}
Full Code
We're all set to use the emulator now. The final form of our main.dart is:
import 'dart:io' show Platform;
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:cloud_functions/cloud_functions.dart';
import 'package:firebase_auth/firebase_auth.dart';
import 'package:firebase_storage/firebase_storage.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:firebase_core/firebase_core.dart';
import 'firebase_options.dart';
// Custom modules
import 'package:temple/app.dart';
const bool _useEmulator = true;
void main() async {
// concrete binding for applications based on the Widgets framewor
WidgetsFlutterBinding.ensureInitialized();
// Instantiate shared pref
SharedPreferences prefs = await SharedPreferences.getInstance();
SystemChrome.setSystemUIOverlayStyle(
SystemUiOverlayStyle.dark.copyWith(statusBarColor: Colors.black38),
);
// Initialize Firebase
await Firebase.initializeApp(
options: DefaultFirebaseOptions.currentPlatform,
);
// Set app to run on firebase emulator
if (_useEmulator) {
await _connectToEmulator();
}
// Pass prefs as value in MyApp
runApp(MyApp(prefs: prefs));
}
// Settings for firebase emulator connection
Future _connectToEmulator() async {
// Provide url to the emulator, localhost might not work on android emulator.
final host = Platform.isAndroid ? '10.0.2.2' : 'localhost';
// Provide port for all the local emulator prodcuts
const authPort = 9099;
const firestorePort = 8080;
const functionsPort = 5001;
const storagePort = 9199;
// Just to make sure we're running locally
print("I am running on emulator");
// Instruct all the relevant firebase products to use firebase emulator
await FirebaseAuth.instance.useAuthEmulator(host, authPort);
FirebaseFirestore.instance.useFirestoreEmulator(host, firestorePort);
FirebaseFunctions.instance.useFunctionsEmulator(host, functionsPort);
FirebaseStorage.instance.useStorageEmulator(host, storagePort);
}
Start Emulator
To run the firebase emulator type the following command.
// On terminal
firebase emulators:start
When you run your android emulator you should see the "I am running on emulator" message(emulator should be running).
Error: Could not start Firestore Emulator, port taken.
At some point when we accidentally forget to close the port, we'll get an error that the port is already taken. To fix that we have to kill the port.
# Provide the port that has been given in an error message like 8080
npx kill-port 8080
Now, that the active port has been terminated, you can start the emulator again, which will look like the image below.
Summary
In this very short chapter, we connected our temple app to the Firebase project. Let's retrace our steps, we:
- Created Firebase Project & Firestore DB.
- Installed Firebase CLI & FlutterFire CLI.
- We installed Firestore, Functions, Storage, and Authentication packages.
- We then connected these Firebase products to the cloud Firebase project on Firebase Init.
- During the initialization, we also installed Firebase Emulator and did all the configuration.
- We then wrote a simple and easy function that handles Local Emulator Connection for us.
- Now know how to solve the port unavailable error issue.
Chapter Eight: Flutter Firebase Authentication | Email and Password
Intro
In the last two chapters, we created a Login/Register UI and Set-Up connection of our Flutter project to the Firebase project. By the end of the chapter, we'll be able to authenticate users in our app. Before that, you can find the progress so far in this folder of the repo.
Authentication Using Email and Password
Redirect Router On Authentication Status
Our user-screen flow is such that after onboarding we check if the user is authenticated or not. If not go to the authentication screen else go to the homepage. So, we have to tell the router to redirect on authentication status changes.
Let's do so on our app_router.dart file. We'll have to make changes inside the redirect method of Go Router.
...
redirect: (state) {
....
// define the named path of auth screen
// #1
final String authPath = state.namedLocation(APP_PAGE.auth.routeName);
// Checking if current path is auth or not
// # 2
bool isAuthenticating = state.subloc == authPath;
// Check if user is loggedin or not based on userLog Status
// #3
bool isLoggedIn =
FirebaseAuth.instance.currentUser != null ? true : false;
print("isLoggedIn is: $isLoggedIn");
if (toOnboard) {
// return null if the current location is already OnboardScreen to prevent looping
return isOnboarding ? null : onboardPath;
}
// only authenticate if a user is not logged in
// #4
else if (!isLoggedIn) {
return isAuthenticating ? null : authPath; // #5
}
// returning null will tell the router to don't mind redirecting the section
return null;
});
So, what we did was:
- We defined a named path for the authentication screen.
- Similarly, a boolean to check if the app is en route to auth screen already.
- FirebaseAuth.instance.currentUser returns the status of the current user: null if absent(logged out mostly), true if logged in.
- If the user is absent then redirect from whatever route currently you're now to the authentication route.
- Unless the current route is already an authentication route then return null. You see if you don't check the current route, then the router may enter an infinite loop.
Firebase User Authentication In With Email and Password
We are using Local Emulator. But in case you're using Firebase Cloud, then first you will have to go to the firebase console of your project, then enable Email/Password SignIn from the Authentication.
Now, on the auth_providers.dart file from screens/auth/providers we'll add authentication functions.
Import the following files
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:firebase_auth/firebase_auth.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:temple/globals/settings/router/utils/router_utils.dart';
Create Class and Instantiate
Let's create AuthStateProvider Class and instantiate FirebaseAuth.
class AuthStateProvider with ChangeNotifier {
FirebaseAuth authInstance = FirebaseAuth.instance;
}
Registration with Email and Password
Write a Registration method.
// Our Function will take email,password, username and buildcontext
// #1
void register(String email, String password, String username,
BuildContext context) async {
try {
// Get back usercredential future from createUserWithEmailAndPassword method
// # 2
UserCredential userCred = await authInstance
.createUserWithEmailAndPassword(email: email, password: password);
// Save username name
await userCred.user!.updateDisplayName(username);
// After that access "users" Firestore in firestore and save username, email and userLocation
// # 3
await FirebaseFirestore.instance
.collection('users')
.doc(userCred.user!.uid)
.set(
{
'username': username,
'email': email,
'userLocation': null,
},
);
// if everything goes well user will be registered and logged in
// now go to the homepage
// #4
GoRouter.of(context).goNamed(APP_PAGE.home.routeName);
} on FirebaseAuthException catch (e) {
// In case of error
// if email already exists
// # 5
if (e.code == "email-already-in-use") {
print("The account with this email already exists.");
}
if (e.code == 'weak-password') {
// If password is too weak
// #6
print("Password is too weak.");
}
} catch (e) {
// For anything else
// #6
print("Something went wrong please try again.");
}
// notify the listeneres
notifyListeners();
}
Let's go over the details:
- Our function will take in the email address, password, username, and BuildContext. We'll need the build context for routing.
- We use the createUserWithEmailAndPassword method made available by FlutterFire.
- After registration of the user, we'll also write a new document in the 'users' collection on Firestore . Ignore the field userLocation for now. When we will deal with that in upcoming parts.
If the operation is successful, then go to the home page. Firebase automatically logs in new users so we don't have to do that ourselves. Now, the currentUser is not null anymore, the router will redirect the user to the homepage.
(5 & 6) In case of errors send the user an appropriate message. Right now, we're just printing the message. Later, we'll implement a snack bar and alert boxes to display messages on the application screen.
Sign In With Email and Password
Now that, we've made our registration function, let's make the sign-in function as well.
// Our Function will take email, password and build context
void login(String email, String password, BuildContext context) async {
try {
// try signing in
# 1
UserCredential userCred = await authInstance.signInWithEmailAndPassword(
email: email, password: password);
// if succesfull leave auth screen and go to homepage
GoRouter.of(context).goNamed(APP_PAGE.home.routeName);
} on FirebaseAuthException catch (e) {
// On error
// If user is not found
if (e.code == 'user-not-found') {
print("No user found for that email.");
}
// If password is wrong
if (e.code == 'wrong-password') {
print("Wrong password.");
}
} catch (e) {
print("Something went wrong please try again");
}
// notify the listeners.
notifyListeners();
}
We're using the sign-in method from FlutterFire. Everything else is the same as in the registration method.
The Sign-Out Method
Sign-Out is a very basic and simple method.
void logOut() async {
await authInstance.signOut();
notifyListeners();
}
Putting All Pieces Together
Our AuthStateProvider Class looks like this now.
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:firebase_auth/firebase_auth.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:temple/globals/settings/router/utils/router_utils.dart';
class AuthStateProvider with ChangeNotifier {
FirebaseAuth authInstance = FirebaseAuth.instance;
// Our Function will take email,password, username and buildcontext
void register(String email, String password, String username,
BuildContext context) async {
try {
// Get back usercredential future from createUserWithEmailAndPassword method
UserCredential userCred = await authInstance
.createUserWithEmailAndPassword(email: email, password: password);
// Save username name
await userCred.user!.updateDisplayName(username);
// After that access "users" Firestore in firestore and save username, email and userLocation
await FirebaseFirestore.instance
.collection('users')
.doc(userCred.user!.uid)
.set(
{
'username': username,
'email': email,
'userLocation': null,
},
);
// if everything goes well user will be registered and logged in
// now go to the homepage
GoRouter.of(context).goNamed(APP_PAGE.home.routeName);
} on FirebaseAuthException catch (e) {
// In case of error
// if email already exists
if (e.code == "email-already-in-use") {
print("The account with this email already exists.");
}
if (e.code == 'weak-password') {
// If password is too weak
print("Password is too weak.");
}
} catch (e) {
// For anything else
print("Something went wrong please try again.");
}
// notify listeneres
notifyListeners();
}
// Our Function will take email, and password and build context
void login(String email, String password, BuildContext context) async {
try {
// try signing in
UserCredential userCred = await authInstance.signInWithEmailAndPassword(
email: email, password: password);
// if succesfull leave auth screen and go to homepage
GoRouter.of(context).goNamed(APP_PAGE.home.routeName);
} on FirebaseAuthException catch (e) {
// On error
// If user is not found
if (e.code == 'user-not-found') {
print("No user found for that email.");
}
// If password is wrong
if (e.code == 'wrong-password') {
print("Wrong password.");
}
} catch (e) {
print("Something went wrong please try again");
}
// notify the listeners.
notifyListeners();
}
void logOut() async {
await authInstance.signOut();
notifyListeners();
}
}
Add Provider To Widget Tree
Our first beta version of authentication functions is ready to be tested. So, let's first enlist our provider in the widget tree with MultipleProviders.
app.dart
providers: [
ChangeNotifierProvider(create: (context) => AppStateProvider()),
// Add authStateProvider
ChangeNotifierProvider(create: (context) => AuthStateProvider()),
// Remove previous Provider call and create new proxyprovider that depends on AppStateProvider
ProxyProvider<AppStateProvider, AppRouter>(
update: (context, appStateProvider, _) => AppRouter(
appStateProvider: appStateProvider,
prefs: widget.prefs,
))
],
Handle Form Submission On Click
Let's now go to auth_form_widget.dart file in** lib/screen/auth/widgets/ . Here we'll have to write function that we'll get triggered on register/sigin button click. We'll call that function **_submitForm(). Add this function right after the msgPopUp() method.
// Submit form will take AuthStateProvider, and BuildContext
// #1
void _submitForm(
AuthStateProvider authStateProvider, BuildContext context) async {
// Check if the form and its input are valid
// #2
final isValid = _formKey.currentState!.validate();
// Trim the inputs to remove extra spaces around them
// #3
String username = usernameController.text.trim();
String email = emailController.text.trim();
String password = passwordController.text.trim();
// if the form is valid
// #4
if (isValid) {
// Save current state if form is valid
_formKey.currentState!.save();
// Try Sign In Or Register baed on if its register Auth Mode or not
// #5
if (registerAuthMode) {
authStateProvider.register(email, password, username, context);
}
} else {
authStateProvider.login(email, password, context);
}
}
Let's go over the details.
- Our function will take AuthStateProvider & Build Context as arguments.
- We can check if the form is valid with formKey.currentState!.validate().
- Trim the input to remove the extra spaces, if they exist.
- If the form/input is valid, then let's move on to the authentication.
- Depending on the authMode we'll either register or sign in the user.
Instantiate Provider
Inside BuildContext let's first call the AuthStateProvider.
@override
Widget build(BuildContext context) {
// Instantiate AuthStateProvider
final AuthStateProvider authStateProvider = Provider.of<AuthStateProvider>(context);
Assign Submission Handler
Let's go way down where our only ElevatedButton is and assign the _submitForm method.
ElevatedButton(
onPressed: () {
// call _submitForm on tap
_submitForm(authStateProvider, context);
},
child: Text(registerAuthMode ? 'Register' : 'Sign In'),
style: ButtonStyle(
elevation: MaterialStateProperty.all(8.0),
),
),
AuthForm Widget
Our auth_form_widget.dart looks like this now.
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:temple/screens/auth/providers/auth_provider.dart';
import 'package:temple/screens/auth/utils/auth_validators.dart';
import 'package:temple/screens/auth/widgets/text_from_widget.dart';
class AuthFormWidget extends StatefulWidget {
const AuthFormWidget({Key? key}) : super(key: key);
@override
State<AuthFormWidget> createState() => _AuthFormWidgetState();
}
class _AuthFormWidgetState extends State<AuthFormWidget> {
// Define Form key
final _formKey = GlobalKey<FormState>();
// Instantiate validator
final AuthValidators authValidator = AuthValidators();
// controllers
late TextEditingController emailController;
late TextEditingController usernameController;
late TextEditingController passwordController;
late TextEditingController confirmPasswordController;
// create focus nodes
late FocusNode emailFocusNode;
late FocusNode usernameFocusNode;
late FocusNode passwordFocusNode;
late FocusNode confirmPasswordFocusNode;
// to obscure text default value is false
bool obscureText = true;
// This will require to toggle between register and sigin in mode
bool registerAuthMode = false;
// Instantiate all the *text editing controllers* and focus nodes on *initState* function
@override
void initState() {
super.initState();
emailController = TextEditingController();
usernameController = TextEditingController();
passwordController = TextEditingController();
confirmPasswordController = TextEditingController();
emailFocusNode = FocusNode();
usernameFocusNode = FocusNode();
passwordFocusNode = FocusNode();
confirmPasswordFocusNode = FocusNode();
}
// These all need to be disposed of once done so let's do that as well.
@override
void dispose() {
super.dispose();
emailController.dispose();
usernameController.dispose();
passwordController.dispose();
confirmPasswordController.dispose();
emailFocusNode.dispose();
usernameFocusNode.dispose();
passwordFocusNode.dispose();
confirmPasswordFocusNode.dispose();
}
// Create a function that'll toggle the password's visibility on the relevant icon tap.
void toggleObscureText() {
setState(() {
obscureText = !obscureText;
});
}
// Let's create a snack bar to pop info on various circumstances.
// Create a scaffold messanger
SnackBar msgPopUp(msg) {
return SnackBar(
content: Text(
msg,
textAlign: TextAlign.center,
));
}
// Submit form will take AuthStateProvider, and BuildContext
void _submitForm(
AuthStateProvider authStateProvider, BuildContext context) async {
// Check if the form and its input are valid
final isValid = _formKey.currentState!.validate();
// Trim the inputs to remove extra spaces around them
String username = usernameController.text.trim();
String email = emailController.text.trim();
String password = passwordController.text.trim();
// if the form is valid
if (isValid) {
// Save current state if form is valid
_formKey.currentState!.save();
// Try Sigin Or Register baed on if its register Auth Mode or not
if (registerAuthMode) {
authStateProvider.register(email, password, username, context);
}
} else {
authStateProvider.login(email, password, context);
}
}
@override
Widget build(BuildContext context) {
final AuthStateProvider authStateProvider =
Provider.of<AuthStateProvider>(context);
return Padding(
padding: const EdgeInsets.all(8),
child: Form(
key: _formKey,
child: Column(
children: [
// Email
DynamicInputWidget(
controller: emailController,
obscureText: false,
focusNode: emailFocusNode,
toggleObscureText: null,
validator: authValidator.emailValidator,
prefIcon: const Icon(Icons.mail),
labelText: "Enter Email Address",
textInputAction: TextInputAction.next,
isNonPasswordField: true,
),
const SizedBox(
height: 20,
),
// Username
AnimatedContainer(
duration: const Duration(milliseconds: 500),
height: registerAuthMode ? 65 : 0,
child: AnimatedOpacity(
duration: const Duration(milliseconds: 500),
opacity: registerAuthMode ? 1 : 0,
child: DynamicInputWidget(
controller: usernameController,
obscureText: false,
focusNode: usernameFocusNode,
toggleObscureText: null,
validator: null,
prefIcon: const Icon(Icons.person),
labelText: "Enter Username(Optional)",
textInputAction: TextInputAction.next,
isNonPasswordField: true,
),
),
),
AnimatedOpacity(
duration: const Duration(milliseconds: 500),
opacity: registerAuthMode ? 1 : 0,
child: const SizedBox(
height: 20,
),
),
DynamicInputWidget(
controller: passwordController,
labelText: "Enter Password",
obscureText: obscureText,
focusNode: passwordFocusNode,
toggleObscureText: toggleObscureText,
validator: authValidator.passwordVlidator,
prefIcon: const Icon(Icons.password),
textInputAction: registerAuthMode
? TextInputAction.next
: TextInputAction.done,
isNonPasswordField: false,
),
const SizedBox(
height: 20,
),
AnimatedContainer(
duration: const Duration(milliseconds: 500),
height: registerAuthMode ? 65 : 0,
child: AnimatedOpacity(
duration: const Duration(milliseconds: 500),
opacity: registerAuthMode ? 1 : 0,
child: DynamicInputWidget(
controller: confirmPasswordController,
focusNode: confirmPasswordFocusNode,
isNonPasswordField: false,
labelText: "Confirm Password",
obscureText: obscureText,
prefIcon: const Icon(Icons.password),
textInputAction: TextInputAction.done,
toggleObscureText: toggleObscureText,
validator: (val) => authValidator.confirmPasswordValidator(
val, passwordController.text),
),
),
),
const SizedBox(
height: 20,
),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () {},
child: const Text('Cancel'),
),
const SizedBox(
width: 20,
),
ElevatedButton(
onPressed: () {
// call _submitForm on tap
_submitForm(authStateProvider, context);
},
child: Text(registerAuthMode ? 'Register' : 'Sign In'),
style: ButtonStyle(
elevation: MaterialStateProperty.all(8.0),
),
),
],
),
const SizedBox(
height: 20,
),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(registerAuthMode
? "Already Have an account?"
: "Don't have an account yet?"),
TextButton(
onPressed: () =>
setState(() => registerAuthMode = !registerAuthMode),
child: Text(registerAuthMode ? "Sign In" : "Regsiter"),
)
],
)
],
),
),
);
}
}
Calling Log Out
We haven't added the logout method. Let's do that inside the user_drawer.dart file in lib/globals/widgets/user_drawer/. Also while we're here, let's remove that temporary authentication route.
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:provider/provider.dart';
import 'package:temple/globals/settings/router/utils/router_utils.dart';
import 'package:temple/screens/auth/providers/auth_provider.dart';
class UserDrawer extends StatefulWidget {
const UserDrawer({Key? key}) : super(key: key);
@override
_UserDrawerState createState() => _UserDrawerState();
}
class _UserDrawerState extends State<UserDrawer> {
@override
Widget build(BuildContext context) {
return AlertDialog(
backgroundColor: Theme.of(context).colorScheme.primary,
actionsPadding: EdgeInsets.zero,
scrollable: true,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(15),
),
title: Text(
"Astha",
style: Theme.of(context).textTheme.headline2,
),
content: const Divider(
thickness: 1.0,
color: Colors.black,
),
actions: [
// Past two links as list tiles
ListTile(
leading: Icon(
Icons.person_outline_rounded,
color: Theme.of(context).colorScheme.secondary,
),
title: const Text('User Profile'),
onTap: () {
print("User Profile Button Pressed");
}),
ListTile(
leading: Icon(
Icons.logout,
color: Theme.of(context).colorScheme.secondary,
),
title: const Text('Logout'),
onTap: () {
Provider.of<AuthStateProvider>(context, listen: false).logOut();
GoRouter.of(context).goNamed(APP_PAGE.auth.routeName);
}),
],
);
}
}
Now, the user can log out.
Firebase Functions: Cloud Firestore Triggers
Firebase provides background triggers, which get called automatically when an event it's attached to occurs. There are four triggers: onCreate, onUpdate, onDelete, and onWrite. We'll use the onCreate trigger when a new user registers to add a time-stamp field createdAt that records the time of registration. We'll write our function on the index.js file inside the functions folder.
index.js
// Import modules
// #1
const functions = require("firebase-functions"),
admin = require('firebase-admin');
// always initialize admin
// #2
admin.initializeApp();
// create a const to represent firestore
// #3
const db = admin.firestore();
// Create a new background trigger function
// #4
exports.addTimeStampToUser = functions.runWith({
timeoutSeconds: 240, // Give timeout // #5
memory: "512MB" // memory allotment // #5
}).firestore.document('users/{userId}').onCreate(async (_, context) => {
// Get current timestamp from server
// #6
let curTimeStamp = admin.firestore.Timestamp.now();
// Print current timestamp on server
// # 7
functions.logger.log(`curTimeStamp ${curTimeStamp.seconds}`);
try {
// add the new value to new users document
// #8
await db.collection('users').doc(context.params.userId).set({ 'registeredAt': curTimeStamp, 'favTempleList': [], 'favShopsList': [], 'favEvents': [] }, { merge: true });
// if its done print in logger
// #7
functions.logger.log(`The current timestamp added to users collection: ${curTimeStamp}`);
// always return something to end the function execution
return { 'status': 200 };
} catch (e) {
// Print error incase of errors
// #7
functions.logger.log(`Something went wrong could not add timestamp to users collectoin ${curTimeStamp}`);
// return status 400 for error
return { 'status': 400 };
}
});
I hope readers are familiar with JavaScript and Node.js. Let's go over the important details on index.js.
- We import firebase functions and firebase-admin.
- Always remember to initialize admin.
- Create a constant to represent the Firebase Firestore.
- We create our first function, addTimeStampToUser which will get triggered whenever a new document is created inside the "users" collection.
- We can provide restrictions to a function with runWtih() method. It's important sometimes to just terminate function to free memory and to save ourselves from terrifying bills. But you have to experiment with it.
- We can get a time-stamp from the server. Well, it's just a placeholder, not a time. Please read this nice article to find out more about it.
- We print the logs to see the progress.
- We can save new fields in a document with db.collection('collection_name').doc(documentId).set(value). Few things to point out here. Since the user is being created, there's no context.auth.uid, but we can get the new id to be, from context's paramsId. Find out more on EventContext. Another thing, when you're updating/adding a new value, you have to provide merge options to make sure the new value doesn't overwrite the previous ones(email&username in our case).
You saw 'favTempleList', 'favShopsList', and 'favEvents' fields being added to the user document. Don't worry about it for now. These arrays will be filled in later on in the tutorial. On your emulator, you'll see these fields and logs when we register a new user.
If you want to deploy functions on the cloud, first you'll have to upgrade plans to the paid plan. When you're done upgrading use the following command to deploy functions.
firebase deploy --only functions
If you're having trouble deploying where you'll get an error like **npm --prefix "$RESOURCE_DIR" run lint, it's an error related to eslint, nothing mentioned here worked for me either, so, I just uninstalled eslint and deployed functions on cloud.
Watch this playlist from Google to understand more about Firebase Functions.
Summary
We did quite a few tasks in this chapter. Let's retrace our steps:
- We set up the AppRouter to redirect to the authentication screen on the app launch if the user is not logged in.
- We created Registration, Login, and LogOut functions using the FlutterFire package.
- We also wrote a simple background trigger for Firebase Functions which will auto-save the time when a user registers on our app.
Chapter Nine: Improve User Experience in Flutter With Snack Bars, Progress Indicators, and Alert Dialogs
Intro
Snack Bars, Alert Dialogs, ProgressIndicators, and similar items are very essential tools to enhance the user experience on an application regardless of platform.
In the last two chapters, we created UI and wrote the backend for registration and sign-in methods to use with the Firebase project. In this chapter, we'll use features from flutters to give visual feedback to users.
You can find the source code from here.
Show Progress Indicator During Registration/Sign In
Head over to auth_state_provider.dart class and make some changes.
Create an enum outside of class, to toggle the Application Process State State.
// Outside of any class or function
// Make an enum to togggle progrss indicator
enum ProcessingState {
done,
waiting,
}
Inside of Provider class let's create a field of type ProcessingState and a function that switches these values/states.
ProcessingState _processingState = ProcessingState.done;
// getter
ProcessingState get processingState => _processingState;
void setPrcState(ProcessingState prcsState) {
_processingState = prcsState;
notifyListeners();
}
We'll display the CircularProgressIndicator whenever the application is busy and remove it when done. For instance, after pressing the register/sign-in button we can display the progress indicator in place of the button and then remove it when firebase sends a response.
So, let's first start by adding the function inside the register function. We'll make changes inside auth_form_widget.dart files after this.
Update Processing State In Register Function
// Our Function will take email,password, username and buildcontext
void register(String email, String password, String username,
BuildContext context) async {
// Start loading progress indicator once submit button is hit
// #1
setPrcState(ProcessingState.waiting);
try {
// Get back usercredential future from createUserWithEmailAndPassword method
UserCredential userCred = await authInstance
.createUserWithEmailAndPassword(email: email, password: password);
// Save username name
await userCred.user!.updateDisplayName(username);
// After that access "users" Firestore in firestore and save username, email and userLocation
await FirebaseFirestore.instance
.collection('users')
.doc(userCred.user!.uid)
.set(
{
'username': username,
'email': email,
'userLocation': null,
},
);
// if everything goes well user will be registered and logged in
// now go to the homepage
GoRouter.of(context).goNamed(APP_PAGE.home.routeName);
} on FirebaseAuthException catch (e) {
// In case of error
// if email already exists
if (e.code == "email-already-in-use") {
print("The account with this email already exists.");
}
if (e.code == 'weak-password') {
// If password is too weak
print("Password is too weak.");
}
// #2
setPrcState(ProcessingState.done);
} catch (e) {
// For anything else
print("Something went wrong please try again.");
// #3
setPrcState(ProcessingState.done);
}
// notify listeneres
notifyListeners();
}
- In the registration function, we'll start to toggle the ProcessState to waiting(which's done by default) right before the Try block.
- (#2&3) We'll only switch it back to done once the servers send an error response, at the end of the Catch block. We'll not switch it back at the end of the try block because, in case of success, we'll move on to the next page as well as display a SnackBar which we'll implement after this.
Please make similar changes to the Sign In and the Log Out functions by yourself.
Now, we'll need to tell the application, when the processing state is in waiting, display a progress indicator, and then remove the indicator once the processing state is done. To do so let's head over to auth_form_widget.dart. Right after we instantiate an AuthStateProvider, create a new variable of the type ProcessState whose value is equal to that of AuthStateProvider's process state.
Widget build(BuildContext context) {
final AuthStateProvider authStateProvider =
Provider.of<AuthStateProvider>(context);
// make new ProcessState var
ProcessingState prcState = authStateProvider.processingState;
After that, down where we have our elevated button, let's make it a conditionally rendering widget.
if (prcState == ProcessingState.waiting) const CircularProgressIndicator(),
if (prcState == ProcessingState.done)
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () {},
child: const Text('Cancel'),
),
const SizedBox(
width: 20,
),
ElevatedButton(
onPressed: () {
// call _submitForm on tap
_submitForm(authStateProvider, context);
},
child: Text(registerAuthMode ? 'Register' : 'Sign In'),
style: ButtonStyle(
elevation: MaterialStateProperty.all(8.0),
),
),
],
),
With this, the progress indicator will activate and deactivate at the right time.
Display Information on Snackbar
As mentioned earlier, we'll be using Snackbar to display information like "Authentication Successful", "Welcome Back", etc. To do so let's again create a simple function on AuthStateProvider class.
// Right after setPrcState function
// create function to handle popups
SnackBar msgPopUp(msg) {
return SnackBar(
content: Text(
msg,
textAlign: TextAlign.center,
));
}
This function will take a custom message and return a SnackBar to display on the screen. Snackbar works in conjunction with ScaffoldMessenger class. So, we'll pass this msgPopUp method in ScaffoldMessenger.of(context) at the end of the try block operations, and just before we call GoRouter.
Inside Registration Function
ScaffoldMessenger.of(context)
.showSnackBar(msgPopUp("The account has been registered."));
// Before GoRouter
GoRouter.of(context).goNamed(APP_PAGE.home.routeName);
Inside Login Function
ScaffoldMessenger.of(context).showSnackBar(msgPopUp("Welcome Back"));
With this when the authentication operation succeeds user will see a snack bar at the bottom of the application.
Alert Error on Authentication With Alert Dialog
The snack bar is useful because it subtly displays short messages. However, because of its subtlety, it is not very appropriate to be used in case of errors. In such cases, it's better to alert users using a dialog pop-up such as the AlertDialog widget. So, right after the msgPopUp function, let's create another function.
// #1
AlertDialog errorDialog(BuildContext context, String errMsg) {
return AlertDialog(
title: Text("Error",
style: TextStyle(
//text color will be red
// #2
color: Theme.of(context).colorScheme.error,
)),
content: Text(errMsg,
style: TextStyle(
//text color will be red
// #3
color: Theme.of(context).colorScheme.error,
)),
actions: [
TextButton(
// On button click remove the dialog box
// #2
onPressed: () => Navigator.of(context).pop(),
child: const Text('OK'),
),
],
);
}
- Our method will take two arguments: an error message and a build context.
- Our alert box will have texts with the color red.
- On click, the dialog box will be removed from the screen. The build context is required here to call Navigator.pop method.
On both registration and sign-in methods, we are only printing the error messages. Now, let's replace those print statements with errorDialog() and pass the same messages into the function.
Inside Register Function's Catch Blocks
on FirebaseAuthException catch (e) {
// In case of error
// if email already exists
if (e.code == "email-already-in-use") {
showDialog(
context: context,
builder: (context) => errorDialog(
context, "The account with this email already exists."));
}
if (e.code == 'weak-password') {
// If password is too weak
showDialog(
context: context,
builder: (context) =>
errorDialog(context, "Password is too weak."));
}
setPrcState(ProcessingState.done);
} catch (e) {
// For anything else
showDialog(
context: context,
builder: (context) =>
errorDialog(context, "Something went wrong please try again."));
setPrcState(ProcessingState.done);
}
This is what the box will look like.
Please, add these alerts to your sign-in method by yourself.
Summary
Few changes were made in AuthStateProvider and AuthFormWidget classes.
- We created an Enum to address process states.
- Progress indicators were implemented based on Processing Status.
- A simple snack bar function was built to display information for users when authentication succeeds.
- An error handler was created to display error messages on an alert dialog box.
Chapter Ten: Permission Handler and Location Access In Flutter
Intro
As a part of the user-screen flow, we are now at the stage where we need to access the user location. So, we'll ask for the user's location as soon as the user authenticates and reaches the homepage. We'll also Firebase Cloud Functions to save the user's location on the 'users/userId' document on Firebase Firestore. Find the source code to start this section from here.
Packages
In previous endeavors, we've already installed and set up Firebase packages. For now, we'll need three more packages: Location, Google Maps Flutter and Permission Handler. Follow the instruction on the packages home page or add just use the version I am using below.
The location package itself is enough to get both permission and location. However, permission_handler can get permission for other tasks like camera, local storage, and so on. Hence, we'll use both, one to get permission and another for location. For now, we'll only use the google maps package to use Latitude and Longitude data types.
Installation
On the command Terminal:
# Install location
flutter pub add location
# Install Permission Handler
flutter pub add permission_handler
# Install Google Maps Flutter
flutter pub add google_maps_flutter
Setting Up Packages
Location Package
For the Location package, to be able to ask for the user's permission we need to add some settings.
Android
For android at "android/app/src/main/AndroidManifest.xml" before the application tag.
<!--
Internet permissions do not affect the `permission_handler` plugin but are required if your app needs access to
the internet.
-->
<uses-permission android:name="android.permission.INTERNET" />
<!-- Permissions options for the `location` group -->
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
<!-- Before application tag-->
<application android:label="astha" android:name="${applicationName}" android:icon="@mipmap/launcher_icon">
IOS
For ios, in "ios/Runner/Info.plist", add the following settings in the end of dict tag.
<!-- Permissions list starts here -->
<!-- Permission while running on backgroud -->
<key>UIBackgroundModes</key>
<string>location</string>
<!-- Permission options for the `location` group -->
<key>NSLocationWhenInUseUsageDescription</key>
<string>Need location when in use</string>
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
<string>Always and when in use!</string>
<key>NSLocationUsageDescription</key>
<string>Older devices need location.</string>
<key>NSLocationAlwaysUsageDescription</key>
<string>Can I have location always?</string>
<!-- Permission options for the `appTrackingTransparency` -->
<key>NSUserTrackingUsageDescription</key>
<string>appTrackingTransparency</string>
<!-- Permissions lists ends here -->
Permission Handler
Android
For android on "android/gradle.properties" add these settings if it's already not there.
android.useAndroidX=true
android.enableJetifier=true
On "android/app/build.gradle" change compiled SDK version to 31 if you haven't already.
android {
compileSdkVersion 31
...
}
As for the permission API, we've already added them in the AndroidManifest.XML file.
IOS
We've already added permissions on info.plist already. Unfortunately, I am using VS Code and could not find the POD file on the ios directory.
Google Maps Flutter
To use google maps you'll need an API key for it. Get it from Google Maps Platform. Follow the instructions from the package's readme on how to create an API key. Create two credentials each for android and ios. After that, we'll have to add it to both android and ios apps.
Android
Go to the AndroidManifest.xml file again.
<manifest ...
<application ...
<meta-data android:name="com.google.android.geo.API_KEY"
android:value="YOUR KEY HERE"/>
<activity ...
In the " android/app/build.gradle" file change the minimum SDK version to 21 if you haven't already.
...
defaultConfig {
...
minSdkVersion 21
...
IOS
In ios/Runner/AppDelegate.swift file add the api key for ios.
// import gmap
import GoogleMaps
@UIApplicationMain
@objc class AppDelegate: FlutterAppDelegate {
...
-> Bool {
// Add api key don't remove anything else
GMSServices.provideAPIKey("API KEY Here")
...
}
DO NOT SHARE YOUR API KEY, ADD ANDROID MANIFEST AND APPDELEGATE FILE TO GITIGNORE BEFORE PUSHING
Reminder: Check out the read me in packages pages if anything doesn't work.
Acess Location of User
Let's go over the series of events that'll occur in the tiny moment user goes from the authentication screen to the home screen.
- User will be asked for permission to grant location access.
- If permission is positive, the user's location will be accessed and given to an HTTPS callable cloud function.
- Callable will then get the current user's id. With that ID callable will read the correct document from the "users" collections.
- It'll check if the location field is either empty or not.
- If it's empty it'll write a new document merge to add location.
- If it's not empty, then the function will just not write anything and return.
Since as the app grows the number of app permissions needed can also keep on increasing and permission is also a global factor, let's create a provider class that'll handle permissions in "globals/providers" folders.
On your terminal
# Make folder
mkdir lib/globals/providers/permissions
// make file
touch lib/globals/providers/permissions/app_permission_provider.dart
App's Permission status is of four types: which is either granted, denied, restricted, or permanently denied. Let's first make an enum to switch these values in our app.
app_permission_provider.dart
enum AppPermissions {
granted,
denied,
restricted,
permanentlyDenied,
}
Let's create a provider class right below the enum. As mentioned earlier, we'll use permission_handler to get permission and the location package to get the location.
import 'package:cloud_functions/cloud_functions.dart';
import 'package:firebase_auth/firebase_auth.dart';
import 'package:flutter/foundation.dart';
import 'package:google_maps_flutter/google_maps_flutter.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:location/location.dart'
as location_package; // to avoid confusion with google_maps_flutter package
class AppPermissionProvider with ChangeNotifier {
// Start with default permission status i.e denied
// #1
PermissionStatus _locationStatus = PermissionStatus.denied;
// Getter
// #2
get locationStatus => _locationStatus;
// # 3
Future<PermissionStatus> getLocationStatus() async {
// Request for permission
// #4
final status = await Permission.location.request();
// change the location status
// #5
_locationStatus = status;
print(_locationStatus);
// notify listeners
notifyListeners();
return status;
}
}
- We start with default permission which is denied.
- A getter of Location status.
- A method that returns a future of the type Permission Status. We'll need it later on.
- A method from Permission Handler(request) that asks for the user's permission.
- Assign new status and then notify listeners.
Now, let's move to the next step of the mission, which is actually to fetch the location and save it on Firestore. We're going to add some new variables and instances that'll help us achieve it. Add the following code before getLocationStatus method.
// Instantiate FIrebase functions
// #1
FirebaseFunctions functions = FirebaseFunctions.instance;
// Create a LatLng type that'll be user location
// # 2
LatLng? _locationCenter;
// Initiate location from location package
// # 3
final location_package.Location _location = location_package.Location();
// # 4
location_package.LocationData? _locationData;
// Getter
// # 5
get location => _location;
get locationStatus => _locationStatus;
get locationCenter => _locationCenter as LatLng;
Let's explain codes, shall we?
- Firebase functions instance is needed because after this we'll create a https callable function to handle the location submission.
- Location of the user that'll be returned by HTTPS callable function.
- Instantiate location package.
- Location to be given by Location package.
- The getters for fetching private values from this class.
Firebase Function: HTTPS Callable
Our getLocation method for AppPermissionProvider, which we'll create later, will call for HTTPS callable inside of it. So, let's head over to the index.js to create the onCall method from the firebase function.
index.js
// Create a function named addUserLocation
exports.addUserLocation = functions.runWith({
timeoutSeconds: 60, // #1
memory: "256MB" //#1
}).https.onCall(async (data, context) => {
try {
// Fetch correct user document with user id.
// #2
let snapshot = await db.collection('users').doc((context.auth.uid)).get();
// functions.logger.log(snapshot['_fieldsProto']['userLocation']["valueType"] === "nullValue");
// Get Location Value Type
// #3
let locationValueType = snapshot['_fieldsProto']['userLocation']["valueType"];
// Check if field value for location is null
// # 4
if (locationValueType == 'nullValue') {
// # 5
await db.collection('users').doc((context.auth.uid)).set({ 'userLocation': data.userLocation }, { merge: true });
functions.logger.log(`User location added ${data.userLocation}`);
}
else {
// # 6
functions.logger.log(`User location not changed`);
}
}
catch (e) {
// # 7
functions.logger.log(e);
throw new functions.https.HttpsError('internal', e);
}
// #7
return data.userLocation;
});
In the addUserLocation callable function above we are:
- Provide memory allocation for functions with runWith() method.
- Get the user document based on the user id provided by EventContext.
- Get the value type of the Location field. If you remember we saved the filed userLocation as null during the registration process in our auth_state_provider.dart file. This long snapshot['_fieldsProto']['userLocation']["valueType"] is something I got from experimenting and printing values. That's why is best to use emulators.
- If the locationValueType is null then means that the user location has never been saved before. Hence, we'll proceed to write a new document.
- Update the user document with the userLocation from the data property of the onCall method. It is the same location that'll be passed from the provider class to this function. Yes, the same one was fetched by the location package.
- If the locationValueType is not null then, we won't write a new document.
- Return the user location. It's important to end the callables with a return, if not function might end up running longer resulting in memory consumption that can cause extra bills from Firebase among other things.
Using HTTPS Callable with Location Package in Flutter
With our callable ready, let's now create a Future method that'll be used by the app. In app_permission_provider.dart file after the getLocationStatus method create getLocation method.
Future<void> getLocation() async {
// Call Location status function here
// #1
final status = await getLocationStatus();
// if permission is granted or limited call function
// #2
if (status == PermissionStatus.granted ||
status == PermissionStatus.limited) {
try {
// assign location data that's returned by Location package
// #3
_locationData = await _location.getLocation();
// Check for null values
// # 4
final lat = _locationData != null
? _locationData!.latitude as double
: "Not available";
final lon = _locationData != null
? _locationData!.longitude as double
: "Not available";
// Instantiate a callable function
// # 5
HttpsCallable addUserLocation =
functions.httpsCallable('addUserLocation');
// finally call the callable function with user location
// #6
final response = await addUserLocation.call(
<String, dynamic>{
'userLocation': {
'lat': lat,
'lon': lon,
}
},
);
// get the response from callable function
// # 7
_locationCenter = LatLng(response.data['lat'], response.data['lon']);
} catch (e) {
// incase of error location witll be null
// #8
_locationCenter = null;
rethrow;
}
}
// Notify listeners
notifyListeners();
}
}
What we did here was:
- Ask for the user's permission to access the location.
- If the permission is Granted/Limited i.e always allow/allow while using the app, then we'll try and access the location.
- User Location packages getLocation method to access location data. It'll return a LatLng object type.
- Check if the data returned is null or not, if so then handle it appropriately.
- (5&6)Inside the try block, we instantiate HTTPS callable function as described by the FlutterFire package. Our callable function takes the parameter "userLocation" as a dictionary with lat and lon as keys. After this function is called in the background, it then returns a LatLng object, which can be accessed from the data object of response.
- In case of error the user location is determined null.
Now, that the user location is updated the corresponding widgets listening to the method will be notified. But for widgets to access the Provider, we'll need to add the provider in the list of MultiProvider in our app.dart file.
...
providers: [
...
ChangeNotifierProvider(create: (context) => AppPermissionProvider()),
...
],
FutureBuilder To The Rescue
Our operation to get the location of the user is an asynchronous one that returns a Future. Future can take time to return the result, hence normal widget won't work. FutureBuilder class from flutter is meant for this task.
We'll call the getLocation method from the Home widget in home.dart file as the future property of FutureBuilder class. While waiting for the location to be saved we can just display a progress indicator.
// Import the provider Package
import 'package:temple/globals/providers/permissions/app_permission_provider.dart';
// Inside Scaffold body
...
body: SafeArea(
child: FutureBuilder(
// Call getLocation function as future
// its very very important to set listen to false
// #1
future: Provider.of<AppPermissionProvider>(context, listen: false)
.getLocation(),
// don't need context in builder for now
builder: ((_, snapshot) {
// if snapshot connectinState is none or waiting
// # 2
if (snapshot.connectionState == ConnectionState.waiting ||
snapshot.connectionState == ConnectionState.none) {
return const Center(child: CircularProgressIndicator());
} else {
// if snapshot connectinState is active
// # 3
if (snapshot.connectionState == ConnectionState.active) {
return const Center(
child: Text("Loading..."),
);
}
// if snapshot connectinState is done
// #4
return const Center(
child: Directionality(
textDirection: TextDirection.ltr,
child: Text("This Is home")),
);
}
})),
),
...
In the home Widget after importing AppPermissionProvider class we returned FutureBuilder as the child of the Safe Area widget. In there we:
User getLocation of AppPermissionProvider as future. It's very important to remember to set listen to false. Otherwise, the build will keep on reloading and functions will get executed again and again.
We return CircularProgressIndicator while waiting for the result to be finished in the background. For now, it seems that there's no point in this because we're not using the location of the user in our app. So, why the progress indicator? It's for later, where we'll again use this moment to fetch another data from firebase which will also be asynchronous.
When the future is active, we display text that says loading.
After the future is done we load or simple home page.
Final Code
app_permission_provider.dart
import 'package:cloud_functions/cloud_functions.dart';
import 'package:flutter/foundation.dart';
import 'package:google_maps_flutter/google_maps_flutter.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:location/location.dart'
as location_package; // to avoid confusion with google_maps_flutter package
enum AppPermissions {
granted,
denied,
restricted,
permanentlyDenied,
}
class AppPermissionProvider with ChangeNotifier {
// Start with default permission status i.e denied
PermissionStatus _locationStatus = PermissionStatus.denied;
// Instantiate FIrebase functions
FirebaseFunctions functions = FirebaseFunctions.instance;
// Create a LatLng type that'll be user location
LatLng? _locationCenter;
// Initiate location from location package
final location_package.Location _location = location_package.Location();
location_package.LocationData? _locationData;
// Getter
get location => _location;
get locationStatus => _locationStatus;
get locationCenter => _locationCenter as LatLng;
Future<PermissionStatus> getLocationStatus() async {
// Request for permission
final status = await Permission.location.request();
// change the location status
_locationStatus = status;
// notiy listeners
notifyListeners();
print(_locationStatus);
return status;
}
Future<void> getLocation() async {
// Call Location status function here
final status = await getLocationStatus();
print("I am insdie get location");
// if permission is granted or limited call function
if (status == PermissionStatus.granted ||
status == PermissionStatus.limited) {
try {
// assign location data that's returned by Location package
_locationData = await _location.getLocation();
// Check for null values
final lat = _locationData != null
? _locationData!.latitude as double
: "Not available";
final lon = _locationData != null
? _locationData!.longitude as double
: "Not available";
// Instantiate a callable function
HttpsCallable addUserLocation =
functions.httpsCallable('addUserLocation');
// finally call the callable function with user location
final response = await addUserLocation.call(
<String, dynamic>{
'userLocation': {
'lat': lat,
'lon': lon,
}
},
);
// get the response from callable function
_locationCenter = LatLng(response.data['lat'], response.data['lon']);
} catch (e) {
// incase of error location witll be null
_locationCenter = null;
rethrow;
}
}
// Notify listeners
notifyListeners();
}
}
index.js
// Import modiules
const functions = require("firebase-functions"),
admin = require('firebase-admin');
// always initialize admin
admin.initializeApp();
// create a const to represent firestore
const db = admin.firestore();
// Create a new background trigger function
exports.addTimeStampToUser = functions.runWith({
timeoutSeconds: 240, // Give timeout
memory: "512MB" // memory allotment
}).firestore.document('users/{userId}').onCreate(async (_, context) => {
// Get current timestamp from server
let curTimeStamp = admin.firestore.Timestamp.now();
// Print current timestamp on server
functions.logger.log(`curTimeStamp ${curTimeStamp.seconds}`);
try {
// add the new value to new users document i
await db.collection('users').doc(context.params.userId).set({ 'registeredAt': curTimeStamp, 'favTempleList': [], 'favShopsList': [], 'favEvents': [] }, { merge: true });
// if its done print in logger
functions.logger.log(`The current timestamp added to users collection: ${curTimeStamp.seconds}`);
// always return something to end the function execution
return { 'status': 200 };
} catch (e) {
// Print error incase of errors
functions.logger.log(`Something went wrong could not add timestamp to users collectoin ${curTimeStamp.seconds}`);
// return status 400 for error
return { 'status': 400 };
}
});
// Create a function named addUserLocation
exports.addUserLocation = functions.runWith({
timeoutSeconds: 60,
memory: "256MB"
}).https.onCall(async (data, context) => {
try {
// Fetch correct user document with user id.
let snapshot = await db.collection('users').doc((context.auth.uid)).get();
// Check if field value for location is null
// functions.logger.log(snapshot['_fieldsProto']['userLocation']["valueType"] === "nullValue");
let locationValueType = snapshot['_fieldsProto']['userLocation']["valueType"];
if (locationValueType == 'nullValue') {
await db.collection('users').doc((context.auth.uid)).set({ 'userLocation': data.userLocation }, { merge: true });
functions.logger.log(`User location added ${data.userLocation}`);
return data.userLocation;
}
else {
functions.logger.log(`User location not changed`);
}
}
catch (e) {
functions.logger.log(e);
throw new functions.https.HttpsError('internal', e);
}
return data.userLocation;
});
home.dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
// Custom
import 'package:temple/globals/providers/permissions/app_permission_provider.dart';
import 'package:temple/globals/widgets/app_bar/app_bar.dart';
import 'package:temple/globals/settings/router/utils/router_utils.dart';
import 'package:temple/globals/widgets/bottom_nav_bar/bottom_nav_bar.dart';
import 'package:temple/globals/widgets/user_drawer/user_drawer.dart';
class Home extends StatefulWidget {
const Home({Key? key}) : super(key: key);
@override
State<Home> createState() => _HomeState();
}
class _HomeState extends State<Home> {
// create a global key for scafoldstate
final GlobalKey<ScaffoldState> _scaffoldKey = GlobalKey();
@override
Widget build(BuildContext context) {
return Scaffold(
// Provide key to scaffold
key: _scaffoldKey,
// Changed to custom appbar
appBar: CustomAppBar(
title: APP_PAGE.home.routePageTitle,
// pass the scaffold key to custom app bar
// #3
scaffoldKey: _scaffoldKey,
),
// Pass our drawer to drawer property
// if you want to slide right to left use
endDrawer: const UserDrawer(),
bottomNavigationBar: const CustomBottomNavBar(
navItemIndex: 0,
),
primary: true,
body: SafeArea(
child: FutureBuilder(
// Call getLocation function as future
// its very very important to set listen to false
future: Provider.of<AppPermissionProvider>(context, listen: false)
.getLocation(),
// don't need context in builder for now
builder: ((_, snapshot) {
// if snapshot connectinState is none or waiting
if (snapshot.connectionState == ConnectionState.waiting ||
snapshot.connectionState == ConnectionState.none) {
return const Center(child: CircularProgressIndicator());
} else {
// if snapshot connectinState is active
if (snapshot.connectionState == ConnectionState.active) {
return const Center(
child: Text("Loading..."),
);
}
// if snapshot connectinState is done
return const Center(
child: Directionality(
textDirection: TextDirection.ltr,
child: Text("This Is home")),
);
}
})),
),
);
}
}
Summary
This chapter was dedicated to permission handling and location access. Tasks accomplished here are as follows:
- Installed three packages: Location, Permission Handler, and Google Maps Flutter.
- Spend a short time updating the settings required to use these packages.
- Created a provider class that'll ask for the user's permission to access the location.
- Same class also has a method that will access location and call the HTTPS callable function.
- Created HTTPS function which will update the user's location on Firebase Firestore.
- Implemented provider class with the help of Future Builder in our app.
Chapter Eleven: Google Maps Places API With Flutter
Intro
As a part of the user-screen flow, we already have access to the user location. In this 10th chapter, we'll use the user's location to fetch the nearest twenty temples using google's Places API. We'll fetch places with the HTTP package, then we'll write another HTTPS Callable with Firebase Functions to store those temples in Firestore. Since we're using an API key, we'll use the flutter_dotenv package to keep it secret. Find the source code to start this section from here.
Packages
DotEnv
So, first lets install flutter_dotenv package.
flutter pub add flutter_dotenv
Create a .env file at the root of your project.
touch .env
Add the .env file in .gitignore file.
#DOT ENV
*.env
Initialize .env file in main() method of our main.dart file.
import 'package:flutter_dotenv/flutter_dotenv.dart';
void main() async {
....
// Initialize dot env
await dotenv.load(fileName: ".env");
// Pass prefs as value in MyApp
runApp(MyApp(prefs: prefs));
}
We also need to add the .env file to the assets section of the pubspec.yaml file.
#find assets section
assets:
# Splash Screens
- assets/splash/om_splash.png
- assets/splash/om_lotus_splash.png
- assets/splash/om_lotus_splash_1152x1152.png
# Onboard Screens
- assets/onboard/FindTemples.png
- assets/onboard/FindVenues.png
# Auth Screens
- assets/AuthScreen/WelcomeScreenImage_landscape_2.png
// add this here
# Dotenv flie
- .env
Google Maps Places API
To use the google places API we'll need the places API key. For that please go to google cloud console, set up a billing account, and create a new API key for Goog Places API. Then add that API key in the .env file.
.env
#Without quotes
GMAP_PLACES_API_KEY = Your_API_KEY
HTTP
Install the HTTP package.
flutter pub add http
There's no need for extra setup with the HTTP package.
Folder Structures
Like Home and Auth Folders, the temples directory will have all the files associated with temples. So, let's create various files and folders we'll use to fetch & display temples.
# Make folders
mkdir lib/screens/temples lib/screens/temples/providers lib/screens/temples/screens lib/screens/temples/widgets lib/screens/temples/models lib/screens/temples/utils
# Make files
touch lib/screens/temples/providers/temples_provider.dart lib/screens/temples/screens/temples_screen.dart lib/screens/temples/widgets/temples_item_widget.dart lib/screens/temples/models/temple.dart lib/screens/temples/utils/temple_utils.dart
Like the chapters before it, we'll keep our all apps logic inside the provider file. On top of that, we'll also need a utils file to store a few functions that we'll use on the provider class. So, first, let's create two simple functions of temple_utils.dart.
Utilities
temple_utils.dart
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:google_maps_flutter/google_maps_flutter.dart';
class TemplesUtils {
// Base url for google maps nearbysearch
// #1
static const String _baseUrlNearBySearch =
"https://maps.googleapis.com/maps/api/place/nearbysearch/json?";
// get api key
// #2
final String _placesApi = dotenv.env['GMAP_PLACES_API_KEY'] as String;
// Create a method that'll parse complete url and return it using http package
// #3
Uri searchUrl(LatLng userLocation) {
// Create variables that'll pass maps API parmas as string
// # 4
//===================//
final api = "&key=$_placesApi";
final location =
"location=${userLocation.latitude},${userLocation.longitude}";
const type = "&type=hindu_temple";
// Closest first
// #5
const rankBy = "&rankby=distance";
//=====================//
// Parse URL to get a new uri object
// #6
final url =
Uri.parse(_baseUrlNearBySearch + location + rankBy + type + api);
return URL;
}
}
- Google Maps NearbySearch takes several parameters, among them we'll use location(required parameter), rankby and type .
- Import API key from .env file.
- Search Url is a function that'll take user location then, combine the base URL acceptable parameter by search API and return parsed URI.
- The API key is a must, on every URL to get a result back from API.
- The rankby="distance" sorts the search results based on distance. When we're using rankby the type parameter is required.
- The final URL to be used by the HTTP package to search for temples.
*Note: If you're from a place that doesn't have temples(some or not at all) you probably won't see any results. So, use something else for the establishment type. *
Another method will be a simple mapper, its sole purpose is to map the incoming list into a list of TempleModels(which we'll create next) and return it as such. This will make our code later much cleaner.
List<TempleModel> mapper(List results) {
final newList = results
.map(
(temple) => TempleModel(
name: temple['name'],
address: temple['address'],
latLng: LatLng(
temple['latLng']['lat'],
temple['latLng']['lon'],
),
imageUrl: temple['imageRef'],
placesId: temple['place_id'],
),
)
.toList();
return newList;
}
Temple Model
The Temple model class will define a framework for the information to be stored about the temple. On the temple.dart file inside models let's create a temple model.
import 'package:google_maps_flutter/google_maps_flutter.dart';
class TempleModel {
// name of temple
final String name;
// the address
final String address;
// geo location
final LatLng latLng;
// ImageUrls
final String imageUrl;
// id given to each item by places api
final String placesId;
const TempleModel(
{required this.name,
required this.address,
required this.latLng,
required this.imageUrl,
required this.placesId});
}
Each temple that'll be saved in Firestore will have a name, address, geographical coordinates, imageUrl, and an ID given by google's place API.
Google Place API with HTTP and Provider
Now, it's time to write a provider class that'll take care of fetching the nearby temples list. This will be a long file with many things to explain. So, we'll go piece by piece codes from top to bottom.
Import Modules and Create a provider class.
import 'dart:convert';
import 'dart:math';
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:cloud_functions/cloud_functions.dart';
import 'package:firebase_auth/firebase_auth.dart';
import 'package:firebase_storage/firebase_storage.dart' as firbase_storage;
import 'package:flutter/foundation.dart';
import 'package:google_maps_flutter/google_maps_flutter.dart';
import 'package:http/http.dart' as http;
//custom modules
import 'package:temple/screens/temples/models/temple.dart';
import 'package:temple/screens/temples/utils/temple_utils.dart';
class TempleProvider with ChangeNotifier {}
Inside the class, instantiate Firebase Products we will be using.
// Instantiate FIbrebase products
final FirebaseAuth auth = FirebaseAuth.instance;
final FirebaseFunctions functions = FirebaseFunctions.instance;
final FirebaseFirestore firestore = FirebaseFirestore.instance;
// Estabish sotrage instance for bucket of our choice
// once e mulator runs you can find the bucket name at storage tab
final firbase_storage.FirebaseStorage storage =
firbase_storage.FirebaseStorage.instanceFor(
bucket: 'astha-being-hindu-tutorial.appspot.com');
With the Firebase Storage we will not use a default bucket to store images whose URL will be fetched to save on Firestore. So, what's up with this logic? You see google places API doesn't provide images, it's provided by details api. We'll not be using it. But instead, I have some random(4 in numbers) Hindu images that I downloaded from Unsplash, which I'll store in storage and fetch a random URL among images and assign it to the temple model. You don't have to do it and provide the hardcoded image URL for imageRef, but it's to practice reading storage.
Other Fields and Getters
// Instantiate Temple Utils
final TemplesUtils templesUtils = TemplesUtils();
// Create the fake list of temples
List<TempleModel>? _temples = [];
// User location from db
LatLng? _userLocation;
// Getters
List<TempleModel> get temples => [..._temples as List];
LatLng get userLocation => _userLocation as LatLng;
// List of Images
static const List<String> imagePaths = [
'image_1.jpg',
'image_2.jpg',
'image_3.jpg',
'image_4.jpg',
];
The imagePaths is a list of literally the name of images that I've uploaded in a folder named "TempleImages" inside our bucket we referenced earlier in Emulator's Storage.
Future To Return Places API Result
// Future method to get temples
Future<List<TempleModel>?> getNearyByTemples(LatLng userLocation) async {
// Get urls from the temple utils
// #1
Uri url = templesUtils.searchUrl(userLocation);
try {
// Set up references for firebase products.
// Callable getNearbyTemples
// #2
HttpsCallable getNearbyTemples =
functions.httpsCallable('getNearbyTemples');
// Collection reference for temples
// # 3
CollectionReference templeDocRef = firestore.collection('temples');
// Get one doc from temples collection
// #4
QuerySnapshot querySnapshot = await templeDocRef.limit(1).get();
// A reference to a folder in storage that has images.
// #5
firbase_storage.Reference storageRef = storage.ref('TempleImages');
// We'll only get nearby temples if the temple's collection empty
// #6
if (querySnapshot.docs.isEmpty) {
print("Temple collection is empty");
// get the result from api search
// #7
final res = await http.get(url);
// decode to json result
// #8
final decodedRes = await jsonDecode(res.body) as Map;
// get result as list
// #9
final results = await decodedRes['results'] as List;
// Get random image url from available ones to put as images
// Since we have 4 images we'll get 0-3 values from Random()
// #10
final imgUrl = await storageRef
.child(imagePaths[Random().nextInt(4)])
.getDownloadURL();
// Call the function
// #11
final templesListCall = await getNearbyTemples.call(<String, dynamic>{
'templeList': [...results],
'imageRef': imgUrl,
});
// map the templesList returned by https callable
// we'll use utils mapper here
// #12
final newTempleLists = templesUtils.mapper(templesListCall.data['temples']);
// update the new temples list
// #13
_temples = [...newTempleLists];
} else {
// If the temples collection already has temples then we won't write
// but just fetch temples collection
// #14
print("Temple collection is not empty");
try {
// get all temples documents
final tempSnapShot = await templeDocRef.get();
// fetch the values as list.
final tempList = tempSnapShot.docs[0]['temples'] as List;
// map the results into a list
final templesList = templesUtils.mapper(tempList);
// update temples
_temples = [...templesList];
} catch (e) {
// incase of error temples list in empty
// # 15
_temples = [];
}
}
} catch (e) {
// incase of error temples list in empty
_temples = [];
}
// notify all the listeners
notifyListeners();
// #16
return _temples;
}
Alright, now the main method that'll do everything we've worked on so far in this blog "getNearyByTemples" has been created. Let's go by numbers:
User the temple utils we created earlier to get the URL ready to be used by the HTTP package.
Reference to the HTTP callable getNearyByTemples, which we'll create after this provider session. It's responsible to save the list of all the temples we fetch during this search.
Reference to temple collection.
Temple reference be used to read a single document from the collection.
References to a folder named "TempleImages" inside the bucket of storage.
We're checking if the temple doc we fetched earlier is empty. The logic is that we don't want to call for Place Api, Firestores, and Functions every time user uses our app. We'll only fetch temples and save them on FireStore if the temple's collection is empty or doesn't exist.
HTTP get() method can be used to fetch the results. You can use software like the postman or just a chrome browser to see the results of the get request.
Json Decode Function parses strings to json data types.
Places API provides the response of a list of temples as a list with results as its key. We'll extract that as the List type.
Firebase Storage provides a means to download URL from the reference. We're randomly downloading a URL and assigning it to imageRef property needed in our HTTPS callable.
We call our HTTPS callable now, provide with temples list and image's Url. It'll save the list in Firesotre's Temples collection and return that list.
The returned list will be now used to update our List of Temple Models using the mapper method of Temple Utils.
This same list will be used by our app to display a beautiful list of temple cards on the temple screen.
The else block only executes if the temple's collection already has a list of temples. In that case unlike in if block we do not fetch the temples list from API, we just read all the documents that are saved in the temple's collection. After this process is the same as above.
In case of errors, the temple list will be empty.
It is very important to return this new list. We will need this List as QurerySnapshot data fetched by FutureBuilder to display it on our app.
Write Google Places API Search Results On FireStore With Firebase Functions
Inside the index.js file we'll now create another HTTPS callable function "getNearbyTemples". This method will create an array with the list of temple objects and then save it to the temples collection.
exports.getNearbyTemples = functions.https.onCall(async (data, _) => {
try {
// Notify function's been called
functions.logger.log("Add nearby temples function was called");
// Create array of temple objects.
let temples = data.templeList.map((temple) => {
return {
'place_id': temple['place_id'],
'address': temple['vicinity'] ? temple['vicinity'] : 'Not Available',
'name': temple['name'] ? temple['name'] : 'Not Available',
'latLng': {
'lat': temple.hasOwnProperty('geometry') ? temple['geometry']['location']['lat'] : 'Not Available', 'lon': temple.hasOwnProperty('geometry') ? temple['geometry']['location']['lng'] : 'Not Available',
'dateAdded': admin.firestore.Timestamp.now()
},
'imageRef': data.imageRef
}
}
);
// save the temples array to temples collection as one document named temples
await db.collection('temples').add({ temples: temples });
} catch (e) {
// if error return errormsg
return { 'Error Msg': e };
}
// If everything's fine return the temple array.
return temples;
});
I have not allocated memory for this operation. It was very tricky and time-consuming. If you want you can experiment. A firebase document can store up to 1MB in size. So, our list at least for this app will never grow beyond 20. So, inside the temple's collection, we are not saving 20 documents but one document with 20 items as a field "temples", db.collection('temples').add({ temples: temples }).
Handle Updates With Firestore Triggers
Let's say the user changed location or a new temple has been added to the google database. It should be reflected in the Firestore Temples collection. But we should handle updates carefully and only write new documents if there are any changes to the old ones. For our temples collection, we can just match the places_id, and only take action accordingly. Firebase provides onUpdate() trigger to handle this type of work. Now, let's write some code on the index.js.
// When the temple List Updates
exports.updateNearbyTemples = functions.runWith({
timeoutSeconds: 120,
memory: "256MB"
}).firestore.document('temples/{id}').onUpdate(async (change, context) => {
// If theres both new and old value
if (change.before.exists && change.after.exists) {
// temples list both new and old
let newTemplesList = change.after.data()['temples'];
let oldTemplesList = change.before.data()['temples'];
// Places Id list from both new and old list
let oldTemplesIdList = oldTemplesList.map(temple => temple['place_id']);
let newTemplesIdList = newTemplesList.map(temple => temple['place_id']);
// Lets find out if theres new temples id by filtering with old one
let filteredList = newTemplesIdList.filter(x => !oldTemplesIdList.includes(x));
// if the length are not same of fileted list has
//length of 0 then nothing new is there so just return
if (oldTemplesIdList.length != newTemplesIdList.length || filteredList.length == 0) {
functions.logger.log("Nothing is changed so onUpdate returned");
return;
}
// If somethings changed then
try {
functions.logger.log("On Update was called ");
// Make new list of temples
let temples = newTemplesList.map((temple) => {
return {
'place_id': temple['place_id'],
'address': temple['vicinity'] ? temple['vicinity'] : 'Not Available',
'name': temple['name'] ? temple['name'] : 'Not Available',
'latLng': {
'lat': temple.hasOwnProperty('geometry') ? temple['geometry']['location']['lat'] : 'Not Available', 'lon': temple.hasOwnProperty('geometry') ? temple['geometry']['location']['lng'] : 'Not Available',
'dateAdded': admin.firestore.Timestamp.now()
}
}
}
);
// use the current context id to update temples, no need to merge
await db.collection('temples').doc(context.params.id).set({ 'palces_id_list': newTemplesIdList, temples: temples });
}
catch (e) { throw e; }
return { 'status': 200 };
}
// return nothing
return null;
});
Changes.before.data gives values already in the Firestore and changes.after.data gives value newly gotten from the API. With this update, the function will not run every time the user loads the temples screen. It will save us lots of money on production mode.
Making Provider Class available for Widgets
Now, our classes are ready for work. So, let's make them available by updating the MultiProviders list in the **app.dart **file.
MultiProvider(
providers: [
...
ChangeNotifierProvider(create: (context) => TempleProvider()),
...
],
...
Now, the GetNearbyTemples method is accessible for all the descendants of MultiProviders. So, where exactly are we going to call this method? Well in the next chapter, We'll make our home page a little bit better looking. On that homepage, there will be a link to Temple List Screen. The method will be executed when the link is clicked. For now, let's end this chapter before we derail from the main theme for this section.
Final Code
temple_provider.dart
import 'dart:convert';
import 'dart:math';
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:cloud_functions/cloud_functions.dart';
import 'package:firebase_auth/firebase_auth.dart';
import 'package:firebase_storage/firebase_storage.dart' as firbase_storage;
import 'package:flutter/foundation.dart';
import 'package:google_maps_flutter/google_maps_flutter.dart';
import 'package:http/http.dart' as http;
//custom modules
import 'package:temple/screens/temples/models/temple.dart';
import 'package:temple/screens/temples/utils/temple_utils.dart';
class TempleProvider with ChangeNotifier {
// Instantiate FIbrebase products
final FirebaseAuth auth = FirebaseAuth.instance;
final FirebaseFunctions functions = FirebaseFunctions.instance;
final FirebaseFirestore firestore = FirebaseFirestore.instance;
// Estabish sotrage instance for bucket of our choice
// once e mulator runs you can find the bucket name at storage tab
final firbase_storage.FirebaseStorage storage =
firbase_storage.FirebaseStorage.instanceFor(
bucket: 'astha-being-hindu-tutorial.appspot.com');
// Instantiate Temple Utils
final TemplesUtils templesUtils = TemplesUtils();
// Create the fake list of temples
List<TempleModel>? _temples = [];
// User location from db
LatLng? _userLocation;
// Getters
List<TempleModel> get temples => [..._temples as List];
LatLng get userLocation => _userLocation as LatLng;
// List of Images
static const List<String> imagePaths = [
'image_1.jpg',
'image_2.jpg',
'image_3.jpg',
'image_4.jpg',
];
// Future method to get temples
Future<void> getNearyByTemples(LatLng userLocation) async {
// Get urls from the temple utils
Uri url = templesUtils.searchUrl(userLocation);
try {
// Set up references for firebase products.
// Callable getNearbyTemples
HttpsCallable getNearbyTemples =
functions.httpsCallable('getNearbyTemples');
// COllection reference for temples
CollectionReference templeDocRef = firestore.collection('temples');
// Get one doc from temples collection
QuerySnapshot querySnapshot = await templeDocRef.limit(1).get();
// A reference to a folder in storage that has images.
firbase_storage.Reference storageRef = storage.ref('TempleImages');
// We'll only get nearby temples if the temples collection empty
if (querySnapshot.docs.isEmpty) {
print("Temple collection is empty");
// get the result from api search
final res = await http.get(url);
// decode the json result
final decodedRes = await jsonDecode(res.body) as Map;
// get result as list
final results = await decodedRes['results'] as List;
// Get random image url from available ones to put as images
// Since we have 4 images we'll get 0-3 values from Random()
final imgUrl = await storageRef
.child(imagePaths[Random().nextInt(4)])
.getDownloadURL();
// Call the function
final templesListCall = await getNearbyTemples.call(<String, dynamic>{
'templeList': [...results],
'imageRef': imgUrl,
});
// map the templesList restured by https callable
final newTempleLists = templesListCall.data['temples']
.map(
(temple) => TempleModel(
name: temple['name'],
address: temple['address'],
latLng: LatLng(
temple['latLng']['lat'],
temple['latLng']['lon'],
),
imageUrl: temple['imageRef'],
placesId: temple['place_id'],
),
)
.toList();
// update the new temples list
_temples = [...newTempleLists];
} else {
// If the temples collection already has temples then we won't write
// but just fetch temples collection
print("Temple collection is not empty");
try {
// get all temples documents
final tempSnapShot = await templeDocRef.get();
// fetch the values as list.
final tempList = tempSnapShot.docs[0]['temples'] as List;
// map the results into a list
final templesList = tempList
.map(
(temple) => TempleModel(
name: temple['name'],
address: temple['address'],
latLng: LatLng(
temple['latLng']['lat'],
temple['latLng']['lon'],
),
imageUrl: temple['imageRef'],
placesId: temple['place_id'],
),
)
.toList();
// update temples
_temples = [...templesList];
} catch (e) {
// incase of error temples list in empty
_temples = [];
}
}
} catch (e) {
// incase of error temples list in empty
_temples = [];
}
// notify all the listeners
notifyListeners();
}
}
index.js
// Import modiules
const functions = require("firebase-functions"),
admin = require('firebase-admin');
// always initialize admin
admin.initializeApp();
// create a const to represent firestore
const db = admin.firestore();
// Create a new background trigger function
exports.addTimeStampToUser = functions.runWith({
timeoutSeconds: 240, // Give timeout
memory: "512MB" // memory allotment
}).firestore.document('users/{userId}').onCreate(async (_, context) => {
// Get current timestamp from server
let curTimeStamp = admin.firestore.Timestamp.now();
// Print current timestamp on server
functions.logger.log(`curTimeStamp ${curTimeStamp.seconds}`);
try {
// add the new value to new users document i
await db.collection('users').doc(context.params.userId).set({ 'registeredAt': curTimeStamp, 'favTempleList': [], 'favShopsList': [], 'favEvents': [] }, { merge: true });
// if its done print in logger
functions.logger.log(`The current timestamp added to users collection: ${curTimeStamp.seconds}`);
// always return something to end the function execution
return { 'status': 200 };
} catch (e) {
// Print error incase of errors
functions.logger.log(`Something went wrong could not add timestamp to users collectoin ${curTimeStamp.seconds}`);
// return status 400 for error
return { 'status': 400 };
}
});
// Create a function named addUserLocation
exports.addUserLocation = functions.runWith({
timeoutSeconds: 60,
memory: "256MB"
}).https.onCall(async (data, context) => {
try {
// Fetch correct user document with user id.
let snapshot = await db.collection('users').doc((context.auth.uid)).get();
// Check if field value for location is null
// functions.logger.log(snapshot['_fieldsProto']['userLocation']["valueType"] === "nullValue");
let locationValueType = snapshot['_fieldsProto']['userLocation']["valueType"];
if (locationValueType == 'nullValue') {
await db.collection('users').doc((context.auth.uid)).set({ 'userLocation': data.userLocation }, { merge: true });
functions.logger.log(`User location added ${data.userLocation}`);
return data.userLocation;
}
else {
functions.logger.log(`User location not changed`);
}
}
catch (e) {
functions.logger.log(e);
throw new functions.https.HttpsError('internal', e);
}
return data.userLocation;
});
exports.getNearbyTemples = functions.https.onCall(async (data, _) => {
try {
// Notify function's been called
functions.logger.log("Add nearby temples function was called");
// Create array of temple objects.
let temples = data.templeList.map((temple) => {
return {
'place_id': temple['place_id'],
'address': temple['vicinity'] ? temple['vicinity'] : 'Not Available',
'name': temple['name'] ? temple['name'] : 'Not Available',
'latLng': {
'lat': temple.hasOwnProperty('geometry') ? temple['geometry']['location']['lat'] : 'Not Available', 'lon': temple.hasOwnProperty('geometry') ? temple['geometry']['location']['lng'] : 'Not Available',
'dateAdded': admin.firestore.Timestamp.now()
},
'imageRef': data.imageRef
}
}
);
// save the temples array to temples collection as one document named temples
await db.collection('temples').add({ temples: temples });
} catch (e) {
// if error return errormsg
return { 'Error Msg': e };
}
// If everything's fine return the temple array.
return temples;
});
// When the temple List Updates
exports.updateNearbyTemples = functions.runWith({
timeoutSeconds: 120,
memory: "256MB"
}).firestore.document('temples/{id}').onUpdate(async (change, context) => {
// If theres both new and old value
if (change.before.exists && change.after.exists) {
// temples list both new and old
let newTemplesList = change.after.data()['temples'];
let oldTemplesList = change.before.data()['temples'];
// Places Id list from both new and old list
let oldTemplesIdList = oldTemplesList.map(temple => temple['place_id']);
let newTemplesIdList = newTemplesList.map(temple => temple['place_id']);
// Lets find out if theres new temples id by filtering with old one
let filteredList = newTemplesIdList.filter(x => !oldTemplesIdList.includes(x));
// if the length are not same of fileted list has
//length of 0 then nothing new is there so just return
if (oldTemplesIdList.length != newTemplesIdList.length || filteredList.length == 0) {
functions.logger.log("Nothing is changed so onUpdate returned");
return;
}
// If somethings changed then
try {
functions.logger.log("On Update was called ");
// Make new list of temples
let temples = newTemplesList.map((temple) => {
return {
'place_id': temple['place_id'],
'address': temple['vicinity'] ? temple['vicinity'] : 'Not Available',
'name': temple['name'] ? temple['name'] : 'Not Available',
'latLng': {
'lat': temple.hasOwnProperty('geometry') ? temple['geometry']['location']['lat'] : 'Not Available', 'lon': temple.hasOwnProperty('geometry') ? temple['geometry']['location']['lng'] : 'Not Available',
'dateAdded': admin.firestore.Timestamp.now()
}
}
}
);
// use the current context id to update temples, no need to merge
await db.collection('temples').doc(context.params.id).set({ 'palces_id_list': newTemplesIdList, temples: temples });
}
catch (e) { throw e; }
return { 'status': 200 };
}
// return nothing
return null;
});
Summary
Before we leave let's summarize what we did so far.
- We created google maps places API.
- By installing the Flutter_DotEnv package, we secured the API from being public.
- HTTP package was also added which played a vital role in fetching temple lists from API.
- We created a utility file though with just one util. Later on, if you want, you can write a distance calculator method from user to temple to represent in google Maps.
- We then wrote a method in our provider class, that fetched a search list, and passes it to firebase cloud functions.
- The firebase function saves the temple lists to Firestore if the collection is empty.
- We then wrote an update trigger, that'll only run when the value is changed.
Chapter Twelve: Flutter Page Design
About
In this chapter, we will create new card buttons, that'll be displayed in the grid view. Each button UI will take the user to a sub-page like Events, Temples, etc. There will also be another widget, let's call it a quote card, at the top of the home screen. It'll have beautiful quotes from Hinduism displayed there. We'll also make a Temples Screen, it will display a list of temples we fetched in the last section. Each temple will be a Temple Item Widget which will be a card with information on a temple from the list. You can find the source code for the progress so far on this link here.
Structures
Let's go to our favorite VS Code with the project opened and make a file where we'll create a dynamic Card that will be used as a button on our home page. We won't be using both the card buttons and quote card outside of the home screen, they will be a local widget and the same goes for the temples card widget. Hence, we need new files and folders for both home and temple screens.
# make folder first
mkdir lib/screens/home/widgets
#make file for home
touch lib/screens/home/widgets/card_button_widget.dart lib/screens/home/widgets/quote_card_widget.dart
# make files for temples
touch lib/screens/temples/widgets/temple_item_widget.dart lib/screens/temples/screens/temples_screen.dart
Home Screen
By the end, our home screen will look like this.
Card Button
card_button_widget.dart
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
class CardButton extends StatelessWidget {
// Define Fields
// Icon to be used
// #1
final IconData icon;
// Tittle of Button
final String title;
// width of the card
// #2
final double width;
// Route to go to
// #3
final String routeName;
const CardButton(this.icon, this.title, this.width, this.routeName,
{Key? key})
: super(key: key);
@override
Widget build(BuildContext context) {
return Card(
elevation: 4,
// Make the border round
// #4
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(10.0)),
),
child:
// We'll make the whole card tappable with inkwell
// #5
InkWell(
// ON tap go to the respective widget
onTap: () => GoRouter.of(context).goNamed(routeName),
child: SizedBox(
width: width,
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceAround,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const SizedBox(
height: 40,
),
Expanded(
flex: 2,
child:
// Icon border should be round and partially transparent
// #6
CircleAvatar(
backgroundColor: Theme.of(context)
.colorScheme
.background
.withOpacity(0.5),
radius: 41,
child:
// Icon
Icon(
icon,
size: 35,
// Use secondary color
color: Theme.of(context).colorScheme.secondary,
),
),
),
Expanded(
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Text(
title,
style: Theme.of(context).textTheme.bodyText1,
),
),
)
]),
),
),
);
}
}
Let's explain a few things, shall we?
- Since, the icon itself will vary, we'll take the icon as a field.
- The width value is for the SizedBox that'll act to contain the card and prevent overflow. Not only that SizedBox also allows the use of the Expanded widget.
- At the end of the day these cards are links to another page, so we'll need routes as well.
- We'll make the edges of the card round with RoundedRectangleBorder.
- When InkWell is tapped, we'll go to the respective sub-page. Inkwell gives off a ripple effect when tapped which is good for the user experience.
- Icon will be inside the Circular Avatar.
Quote Card
Now, let's make a quote card. Typically quotes will be refreshed daily by admin, but we'll use a hardcoded one. Let's head over to the quote_card_widget.dart file.
import 'package:flutter/material.dart';
class DailyQuotes extends StatelessWidget {
// width for our card
// #1
final double width;
const DailyQuotes(this.width, {Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Container(
constraints:
// Adjust the height by content
// #2
const BoxConstraints(maxHeight: 180, minHeight: 160),
width: width,
alignment: Alignment.center,
padding: const EdgeInsets.all(2),
child: Card(
elevation: 4,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(10.0)),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// #3
Expanded(
flex: 2,
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
// Adjust padding
// #2
padding: const EdgeInsets.only(
top: 10, left: 4, bottom: 10, right: 4),
child: Text(
"Bhagavad Gita",
style: Theme.of(context).textTheme.headline2,
),
),
Padding(
padding: const EdgeInsets.only(top: 6, left: 4, right: 4),
child: Text(
"Calmness, gentleness, silence, self-restraint, and purity: these are the disciplines of the mind.",
style: Theme.of(context).textTheme.bodyText2,
overflow: TextOverflow.clip,
softWrap: true,
),
),
],
),
),
Expanded(
child: ClipRRect(
borderRadius: const BorderRadius.only(
topRight: Radius.circular(10.0),
bottomRight: Radius.circular(10.0)),
child: Image.asset(
"assets/images/image_3.jpg",
fit: BoxFit.cover,
),
),
)
],
)),
);
}
}
Let's go over minor details:
- The card can cause an overflow error so it's important to have fixed width.
- Content especially the quote if dynamic can cause overflow error, so adjustable height can be provided with constraints.
- The card has been divided into Text and Image section with Row, while text occupies 2/3 space available with Expanded and flex.
Reminder: You can use the image of your choice, but make sure to add the path on the pubspec file.
Putting All the Pieces Together
Now, that our widgets are ready let's add them to the home screen. Currently, our home screen's code is:
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
// Custom
import 'package:temple/globals/providers/permissions/app_permission_provider.dart';
import 'package:temple/globals/widgets/app_bar/app_bar.dart';
import 'package:temple/globals/settings/router/utils/router_utils.dart';
import 'package:temple/globals/widgets/bottom_nav_bar/bottom_nav_bar.dart';
import 'package:temple/globals/widgets/user_drawer/user_drawer.dart';
class Home extends StatefulWidget {
const Home({Key? key}) : super(key: key);
@override
State<Home> createState() => _HomeState();
}
class _HomeState extends State<Home> {
// create a global key for scafoldstate
final GlobalKey<ScaffoldState> _scaffoldKey = GlobalKey();
@override
Widget build(BuildContext context) {
return Scaffold(
// Provide key to scaffold
key: _scaffoldKey,
// Changed to custom appbar
appBar: CustomAppBar(
title: APP_PAGE.home.routePageTitle,
// pass the scaffold key to custom app bar
// #3
scaffoldKey: _scaffoldKey,
),
// Pass our drawer to drawer property
// if you want to slide right to left use
endDrawer: const UserDrawer(),
bottomNavigationBar: const CustomBottomNavBar(
navItemIndex: 0,
),
primary: true,
body: SafeArea(
child: FutureBuilder(
// Call getLocation function as future
// its very very important to set listen to false
future: Provider.of<AppPermissionProvider>(context, listen: false)
.getLocation(),
// don't need context in builder for now
builder: ((_, snapshot) {
// if snapshot connectinState is none or waiting
if (snapshot.connectionState == ConnectionState.waiting ||
snapshot.connectionState == ConnectionState.none) {
return const Center(child: CircularProgressIndicator());
} else {
// if snapshot connectinState is active
if (snapshot.connectionState == ConnectionState.active) {
return const Center(
child: Text("Loading..."),
);
}
// if snapshot connection state is done
//==========================//
// Replace this section
return const Center(
child: Directionality(
textDirection: TextDirection.ltr,
child: Text("This Is home")),
);
//==========================//
}
})),
),
);
}
}
First, we'll need to caculate the available width for the widgets. MediaQurery class can be used to do so. So, add the following code right after the BuildContext method and before we return Scaffold.
// Device width
final deviceWidth = MediaQuery.of(context).size.width;
// Available width
final availableWidth = deviceWidth -
MediaQuery.of(context).padding.right -
MediaQuery.of(context).padding.left;
Can you calculate the available height of the device?
Now, to add our widgets to the home screen, we'll replace the section that handles the "*Snapshot.done *" with the code below.
return SafeArea(
// Whole view will be scrollable
// #1
child: SingleChildScrollView(
// Column
child: Column(children: [
// FIrst child would be quote card
// #2
DailyQuotes(availableWidth),
// Second child will be GriDview.count with padding of 4
// #2
Padding(
padding: const EdgeInsets.all(4),
child: GridView.count(
// scrollable
physics: const ScrollPhysics(),
shrinkWrap: true,
// two grids
crossAxisCount: 2,
// Space between two Horizontal axis
mainAxisSpacing: 10,
// Space between two vertical axis
crossAxisSpacing: 10,
children: [
// GridView Will have children
// #3
CardButton(
Icons.temple_hindu_sharp,
"Temples Near You",
availableWidth,
APP_PAGE.temples.routeName, // Route for temples
),
CardButton(
Icons.event,
"Coming Events",
availableWidth,
APP_PAGE.home.routeName, // Route for homescreen we are not making these for MVP
),
CardButton(
Icons.location_pin,
"Find Venues",
availableWidth,
APP_PAGE.home.routeName,
),
CardButton(
Icons.music_note,
"Morning Prayers",
availableWidth,
APP_PAGE.home.routeName,
),
CardButton(
Icons.attach_money_sharp,
"Donate",
availableWidth,
APP_PAGE.home.routeName,
),
],
),
)
])),
);
- Our home page will be SingleChildScrollView with a column as its children.
- The column will have two children, the first one is the quote card and the second will be GridView.Count.
- The GridView does have all the links that will be present in the production-ready app. But for now, we'll only use the first card that takes us to the temple screen.
Create New Route
You'll get an error mentioning no temples route name, that's because we haven't yet created a temples route. To do so let's head over to router_utils.dart file in the "libs/settings/router/utils" folder.
// add temples in the list of enum options
enum APP_PAGE { onboard, auth, home, search, shop, favorite, temples }
extension AppPageExtension on APP_PAGE {
// add temple path for routes
switch (this) {
...
// Don't put "/" infront of path
case APP_PAGE.temples:
return "home/temples";
...
}
}
// for named routes
String get routeName {
switch (this) {
...
case APP_PAGE.temples:
return "TEMPLES";
...
}
}
// for page titles
String get routePageTitle {
switch (this) {
...
case APP_PAGE.temples:
return "Temples Near You";
...
}
}
}
Temple will be a sub-page of the home page, hence the route path will be "home/temples" with no "/" at the front.
Temples Screen
Now we need to add the respective routes to AppRouter, but we'll do that later, first, we'll create the temple screen with the list of temple widgets.
Temple Item Widget
We have already made the temple_item_widget.dart file, let's create a card widget that'll display information on the temple we fetch from google's place API.
temple_item_widget.dart
import 'package:flutter/material.dart';
class TempleItemWidget extends StatefulWidget {
// Fields that'll shape the Widget
final String title;
final String imageUrl;
final String address;
final double width;
final String itemId;
const TempleItemWidget(
{required this.title,
required this.imageUrl,
required this.address,
required this.width,
required this.itemId,
// required this.establishedDate,
Key? key})
: super(key: key);
@override
State<TempleItemWidget> createState() => _TempleItemWidgetState();
}
class _TempleItemWidgetState extends State<TempleItemWidget> {
@override
Widget build(BuildContext context) {
return SizedBox(
// Card will have height of 260
height: 260,
width: widget.width,
child: Card(
key: ValueKey<String>(widget.itemId),
elevation: 4,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
margin: const EdgeInsets.all(10),
child: Column(
// Column will have two children stack and a row
// #1
children: [
// Stack will have two children image and title text
Stack(
children: [
ClipRRect(
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(10),
topRight: Radius.circular(10),
),
child: Image.network(
widget.imageUrl,
fit: BoxFit.cover,
width: widget.width,
height: 190,
),
),
Positioned(
bottom: 1,
child: Container(
color: Colors.black54,
width: widget.width,
height: 30,
child: Text(
widget.title,
style: Theme.of(context)
.textTheme
.headline3!
.copyWith(color: Colors.white),
// softWrap: true,
overflow: TextOverflow.fade,
textAlign: TextAlign.center,
),
),
),
],
),
Row(
// Rows will have two icons as children
// #2
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
Expanded(
child: IconButton(
onPressed: () {
print("Donate Button Pressed");
},
icon: Icon(
Icons.attach_money,
color: Colors.amber,
),
),
),
Expanded(
child: IconButton(
onPressed: () {
print("Toggle Fav Button Pressed");
},
icon: const Icon(
Icons.favorite,
color: Colors.red,
)),
)
]),
],
),
),
);
}
}
There are two things different from the custom widgets we have already made previously from this card. This widget will have a column with two children, Stack and a Row.
- Stack will use the image as its background. The title will be positioned at the bottom of the stack. The text widget is inside the container with a transparent black background. It's done to make it more visible.
- The Row will be consisting of two icon buttons, Favorite and Donation.
In the next part, we will add toggle Favorite functionality but Donation will remain hardcoded for this tutorial.
Can you suggest to me a better icon for donation?
Temples List Screen
By the end, our temple screen page will look like this.
We spent quite some time in the previous chapter fetching nearby temples from google's Place API. Now, it's time to see our results in fruition. The futureBuilder method will be best suited for this scenario. As for the future property of the class, we'll provide the getNearbyPlaes() method we created in TempleStateProvider class.
It's a long code, so let's go over it a small chunk at a time.
Imports and Class
import 'package:flutter/material.dart';
import 'package:google_maps_flutter/google_maps_flutter.dart';
import 'package:provider/provider.dart';
import 'package:temple/globals/providers/permissions/app_permission_provider.dart';
import 'package:temple/globals/settings/router/utils/router_utils.dart';
import 'package:temple/globals/widgets/app_bar/app_bar.dart';
import 'package:temple/globals/widgets/user_drawer/user_drawer.dart';
import 'package:temple/screens/temples/providers/temple_provider.dart';
import 'package:temple/screens/temples/widgets/temple_item_widget.dart';
class TempleListScreen extends StatefulWidget {
const TempleListScreen({Key? key}) : super(key: key);
@override
State<TempleListScreen> createState() => _TempleListScreenState();
}
class _TempleListScreenState extends State<TempleListScreen> {
final GlobalKey<ScaffoldState> _scaffoldKey = GlobalKey();
// location is required as input for getNearbyTemples method
LatLng? _userLocation;
@override
void didChangeDependencies() {
// after the build load get the user location from AppPermissionProvider
_userLocation = Provider.of<AppPermissionProvider>(context, listen: false)
.locationCenter;
super.didChangeDependencies();
}
This part is where we import modules and declare a StatefulWidget Class. The important part to notice here is didChangeDependencies(), where we are getting the user location from AppPermissionProvider class. Why? because we'll need the user's location to get temples near to the user.
Device Width and Back Arrow
...
@override
Widget build(BuildContext context) {
// Device width
final deviceWidth = MediaQuery.of(context).size.width;
// Subtract paddings to calculate available dimensions
final availableWidth = deviceWidth -
MediaQuery.of(context).padding.right -
MediaQuery.of(context).padding.left;
return Scaffold(
key: _scaffoldKey,
drawer: const UserDrawer(),
appBar: CustomAppBar(
scaffoldKey: _scaffoldKey,
title: APP_PAGE.temples.routePageTitle,
// Its a subpage so we'll use backarrow and now bottom nav bar
isSubPage: true,
),
primary: true,
body: SafeArea(
child: ....
Like before we'll now return a scaffold with an app bar, available width, and so on. Two things are different from any other screens here. First is that this is a sub-page, so our dynamic app bar will be consisting of a back-arrow. The second is that since this page is a sub-page there won't be Bottom Nav Bar as well.
FutureBuilder With API's Results
Continuing from before:
...
FutureBuilder(
// pass the getNearyByTemples as future
// #1
future: Provider.of<TempleProvider>(context, listen: false)
.getNearyByTemples(_userLocation as LatLng),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting ||
snapshot.connectionState == ConnectionState.none) {
return const Center(child: CircularProgressIndicator());
} else {
if (snapshot.connectionState == ConnectionState.active) {
return const Center(child: Text("Loading..."));
} else {
// After the snapshot connectionState is done
// if theres an error go back home
// # 2
if (snapshot.hasError) {
Navigator.of(context).pop();
}
// check if snapshot has data return on temple widget list
if (snapshot.hasData) {
// # 3
final templeList = snapshot.data as List;
return SizedBox(
width: availableWidth,
child: Column(
children: [
Expanded(
child: ListView.builder(
itemBuilder: (context, i) => TempleItemWidget(
address: templeList[i].address,
imageUrl: templeList[i].imageUrl,
title: templeList[i].name,
width: availableWidth,
itemId: templeList[i].placesId,
),
itemCount: templeList.length,
))
],
),
);
} else {
// check if snapshot is empty return text widget
// # 3
return const Center(
child: Text("There are no temples around you."));
}
}
}
},
)
FutureBuilder to the rescue.
- This FutureBuilder is using the getNearyByTemples() method from TemplesProvider class we created in the previous session.
- Here, we're checking if the QuerySnapshot(a result from an async operation) encountered an error. If so, we'll pop this route and go back to the homepage. If you want you can use an alert box or SnackBar to let users know. A little something to practice for yourself.
- If QuerySnapshot has data, we'll map it into a ListBuilder consisting of TempleItem Widgets.
- If QuerySnapshot is empty then just return a text letting the user know of the situation.
Here's the whole file, if you're confused with my chunking skill.
import 'package:flutter/material.dart';
import 'package:google_maps_flutter/google_maps_flutter.dart';
import 'package:provider/provider.dart';
import 'package:temple/globals/providers/permissions/app_permission_provider.dart';
import 'package:temple/globals/settings/router/utils/router_utils.dart';
import 'package:temple/globals/widgets/app_bar/app_bar.dart';
import 'package:temple/globals/widgets/user_drawer/user_drawer.dart';
import 'package:temple/screens/temples/providers/temple_provider.dart';
import 'package:temple/screens/temples/widgets/temple_item_widget.dart';
class TempleListScreen extends StatefulWidget {
const TempleListScreen({Key? key}) : super(key: key);
@override
State<TempleListScreen> createState() => _TempleListScreenState();
}
class _TempleListScreenState extends State<TempleListScreen> {
final GlobalKey<ScaffoldState> _scaffoldKey = GlobalKey();
// location is required as input for getNearbyTemples method
LatLng? _userLocation;
@override
void didChangeDependencies() {
// after the build load get the user location from AppPermissionProvider
_userLocation = Provider.of<AppPermissionProvider>(context, listen: false)
.locationCenter;
super.didChangeDependencies();
}
@override
Widget build(BuildContext context) {
// Device width
final deviceWidth = MediaQuery.of(context).size.width;
// Subtract paddings to calculate available dimensions
final availableWidth = deviceWidth -
MediaQuery.of(context).padding.right -
MediaQuery.of(context).padding.left;
return Scaffold(
key: _scaffoldKey,
drawer: const UserDrawer(),
appBar: CustomAppBar(
scaffoldKey: _scaffoldKey,
title: APP_PAGE.temples.routePageTitle,
// Its a subpage so we'll use backarrow and now bottom nav bar
isSubPage: true,
),
primary: true,
body: SafeArea(
child: FutureBuilder(
// pass the getNearyByTemples as future
future: Provider.of<TempleProvider>(context, listen: false)
.getNearyByTemples(_userLocation as LatLng),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting ||
snapshot.connectionState == ConnectionState.none) {
return const Center(child: CircularProgressIndicator());
} else {
if (snapshot.connectionState == ConnectionState.active) {
return const Center(child: Text("Loading..."));
} else {
// After the snapshot connectionState is done
// if theres an error go back home
if (snapshot.hasError) {
Navigator.of(context).pop();
}
// check if snapshot has data return on temple widget list
if (snapshot.hasData) {
final templeList = snapshot.data as List;
return SizedBox(
width: availableWidth,
child: Column(
children: [
Expanded(
child: ListView.builder(
itemBuilder: (context, i) => TempleItemWidget(
address: templeList[i].address,
imageUrl: templeList[i].imageUrl,
title: templeList[i].name,
width: availableWidth,
itemId: templeList[i].placesId,
),
itemCount: templeList.length,
))
],
),
);
} else {
// check if snapshot is empty return text widget
return const Center(
child: Text("There are no temples around you."));
}
}
}
},
),
),
);
}
}
Add Temple's Screen to AppRouter
All that's left now is to add Temples Screen to the app's router's list. Let's do it quickly on app_router.dart.
...
routes: [
// Add Home page route
GoRoute(
path: APP_PAGE.home.routePath,
name: APP_PAGE.home.routeName,
builder: (context, state) => const Home(),
routes: [
GoRoute(
path: APP_PAGE.temples.routePath,
name: APP_PAGE.temples.routeName,
builder: (context, state) => const TempleListScreen(),
)
]),
...
The temple screen is a sub-page, so add it as a sub-route of the homepage.
Error: Related to Storage Ref
If you encountered an error related to storage ref, that's because in our TemplesProvider class we're referencing a folder named "TempleImages" which has images we are reading. Create that folder in your storage, then upload the images. They should have the same name as in our imagePaths list in the same class. If you cannot make it work somehow, then remove all the codes related to Firebase Storage and just provide a hardcoded URL as an image reference.
Summary
Let's summarize what we did in this section.
- We added new files and folders in a structured manner.
- We created a Dynamic Card Button and Daily Quotes Display widget, that's making our homepage beautiful.
- We added the first subpage of our app, the temples list screen.
- Temples Screen has a list of Card Widgets to display information on temples.
- Temples Screen has made good use FutureBuilder.
Chapter Thirteen: RealTime Changes With Flutter and Firebase
Intro
We'll now toggle the favorite icon for temples. We'll use Firestore to store all the favorites list for each user's document in the "users" collection. The Firebase Function will help us fetch the immediate list and update it. While Stream will provide the changes in real-time for users to see. You can find the source code so far from here.
Firebase Cloud Functions To Update Firestore In RealTime
Now, we'll create a cloud function in our index.js file. This function will take an "id" as input. This id is place_id provided by Google Maps Places API.
- It'll search and fetch the current user's document. We've already created an array favTempleList while registering with the onCreate trigger.
- The function then will look through the list and toggle the value.
// Add temple to my fav list:
exports.addToFavList = functions.runWith({
timeoutSeconds: 120,
memory: "128MB"
}).https.onCall(async (data, context) => {
const templeId = data.templeId;
try {
// Get user doc
let userDocRef = await db.collection('users').doc(context.auth.uid).get();
// extract favTempleLis from the doc
// #1
let favTempleList = userDocRef._fieldsProto.favTempleList;
// if fav list is empty
// #2
//============================//
if (favTempleList.arrayValue.values.length === 0) {
// Put the id in the list
const templeList = [templeId];
functions.logger.log("Fav list is empty");
// Update the favTemple list
await db.collection('users').doc(context.auth.uid).set({ favTempleList: templeList }, { merge: true });
//============#2 ends here=====================//
} else {
functions.logger.log("Fav Temple List is not empty");
// Make list of available ids
// firebase providers arrays values as such fileName.arrayValue.values array
// consisting dictionary with stringValue as key and its value is the item stored
// #3
functions.logger.log(favTempleList.arrayValue.values[0]);
let tempArrayValList = favTempleList.arrayValue.values.map(item => item.stringValue);
// if not empty Check if the temple id already exists
// #4
let hasId = tempArrayValList.includes(templeId);
// if so remove the id if no just add the list
// #5
//============================//
if (hasId === true) {
// Usr filter to remove value if exists
let newTemplesList = tempArrayValList.filter(id => id !== templeId);
await db.collection('users').doc(context.auth.uid).set({ favTempleList: newTemplesList }, { merge: true });
//==============#5 ends here===========//
}
// If the id doesnot already exists
// #6
//============================//
else {
// first create a fresh copy
let idList = [...tempArrayValList];
// add the new id to the fresh list
idList.push(templeId);
// update the fresh list to the firesotre
await db.collection('users').doc(context.auth.uid).set({ favTempleList: idList }, { merge: true });
//==============#6 ends here===========//
}
}
} catch (e) { functions.logger.log(e); }
// Return the Strig done.
//#7
return "Done";
});
A couple of lines of codes here are on other functions and triggers we've already done. So, let's only go over a few important ones:
We are extracting the firebase field named "favTempleList" from the user documentation. That field is an array.
We're checking if that array is empty. If so, we can just add the new place_id we got from the function call to the array and then update the doc. One thing to notice here is that I am using the merge option for an empty array. That's because this set() method doesn't just set/update a field in a doc, it sets/updates the whole document itself. So, if the **{merge: true} **option is not provided it'll overwrite every field there, in our case username, email, location, and so on.
Now, we enter the section where the temple list is not empty. Then first we need to extract the list of values(id's) that are already present there.
Check if the new ID, we got from the method call, is already present in the list we got from Firestore.
If the id is already there then we need to remove it. We'll do so by combining filter() and includes() methods. After filtering the current place_id, we'll update the Firestore user doc with the fresh list of ids.
If the place_id is not there then we need to add it. We'll push the current place_id into the list. And then update the doc like before.
Remember we should always terminate Firebase functions in the end. We don't need any data from this so we'll just terminate it by returning a string.
Provider
Now, we'll need to add a method in our Provider class that'll call the HTTPS callable function we just created. In our TempleProvider class let's add another method addToFavList.
void addToFavList(String templeId) async {
// Instantiate callable from index.js
HttpsCallable addToFav = functions.httpsCallable('addToFavList');
try {
// Run the callable with the passing the current temples ID
await addToFav.call(<String, String>{
'templeId': templeId,
});
} catch (e) {
rethrow;
}
}
We're not updating or returning anything here. That's because we can get data from snapshots from a stream, as you'll see later. BTW, you could add this method to AuthStateProvider because it deals with user collection.
Use Stream & StreamBuilder To Output Changes In RealTime
We can simply use the streams for this. So, we'll connect with Firesotre with streams and update the screen with StreamBuilder. So, where exactly are we using this StreamBuilder?
Good question, you see getting real-time updates, means reloading(re-reading) the same collections on every change. It is obviously memory expensive. But it can cost expensive as well since Firebase charges for several reads. So, we don't want to load the list of temples, again and again, to toggle a single favorite icon. So, instead, let's just wrap only our favorite icon with stream builder.
On temple_item_widget.dart make these changes.
Create a function that'll call the addToFavList method from the provider class.
// function to call addToFavList from provider class
// It'll take id and providerclass as input
void toggleFavList(String placeId, TempleProvider templeProvider) {
templeProvider.addToFavList(placeId);
}
Inside the build method of class before the return statement.
// Fetch the user doc as a stream
//#1
Stream<DocumentSnapshot> qSnapShot = FirebaseFirestore.instance
.collection('users')
.doc(FirebaseAuth.instance.currentUser!.uid)
.snapshots();
// Instantiate provider method to pass as argument for tooggle FavList
//#2
TempleProvider templeProvider =
Provider.of<TempleProvider>(context, listen: false);
- As mentioned before we'll get the Stream from Firestore, a document using the current user's ID.
- Instantiate provider method to pass as an argument for toggleFavList function. But make sure to turn off the listener.
Replace FavIcon Section With StreamBuilder
StreamBuilder(
// Use latest update provided by stream
// #1
stream: qSnapShot,
builder: (context, snapshot) {
if (snapshot.connectionState ==
ConnectionState.waiting ||
snapshot.connectionState == ConnectionState.none) {
return const CircularProgressIndicator();
} else {
// Get documentsnaphot which is given from the stream
// #2
final docData = snapshot.data as DocumentSnapshot;
// Fetch favTempleList array from user doc
// # 3
final favList = docData['favTempleList'] as List;
// Check if the curent widget id is among the favTempLlist
// #4
final isFav = favList.contains(widget.itemId);
return Expanded(
child: IconButton(
// Call toggleFavlist method on tap
// #5
onPressed: () => toggleFavList(
widget.itemId, templeProvider),
icon: Icon(
Icons.favorite,
// Show color by value of isFav
// #6
color: isFav ? Colors.red : Colors.grey,
)),
);
}
})
Here, in the StreamBuilder Class we:
- Used the stream we got from Firestore 'users' collection as a stream. That way the latest change is reflected asap.
- When the ConnectionState is done, first we get the latest data from the snapshot, which will be the current user's doc.
- Get the latest updated favTempleList array as a List from the document.
- Check if the current ID exists in the favItemList, which is the itemId field passed down from the temples screen.
- On press of the heart icon button call the toggleFavList method of the class.
- Change the color of the heart icon based on the presence or absence of itemId on favTempleList.
And with that latest real-time changes will be reflected in the app.
Final Code
The temple_item_widget.dart file looks like this after the changes.
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:firebase_auth/firebase_auth.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:temple/screens/temples/providers/temple_provider.dart';
class TempleItemWidget extends StatefulWidget {
// Fields that'll shape the Widget
final String title;
final String imageUrl;
final String address;
final double width;
final String itemId;
const TempleItemWidget(
{required this.title,
required this.imageUrl,
required this.address,
required this.width,
required this.itemId,
// required this.establishedDate,
Key? key})
: super(key: key);
@override
State<TempleItemWidget> createState() => _TempleItemWidgetState();
}
class _TempleItemWidgetState extends State<TempleItemWidget> {
// function to call addToFavList from provider class
// It'll take id and providerclass as input
void toggleFavList(String placeId, TempleProvider templeProvider) {
templeProvider.addToFavList(placeId);
}
@override
Widget build(BuildContext context) {
// Fetch the user doc as stream
Stream<DocumentSnapshot> qSnapShot = FirebaseFirestore.instance
.collection('users')
.doc(FirebaseAuth.instance.currentUser!.uid)
.snapshots();
// Instantiate provider method to pass as argument for tooggle FavList
TempleProvider templeProvider =
Provider.of<TempleProvider>(context, listen: false);
return SizedBox(
// Card will have height of 260
height: 260,
width: widget.width,
child: Card(
key: ValueKey<String>(widget.itemId),
elevation: 4,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
margin: const EdgeInsets.all(10),
child: Column(
// Column will have two children stack and a row
children: [
// Stack will have two children image and title text
Stack(
children: [
ClipRRect(
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(10),
topRight: Radius.circular(10),
),
child: Image.network(
widget.imageUrl,
fit: BoxFit.cover,
width: widget.width,
height: 190,
),
),
Positioned(
bottom: 1,
child: Container(
color: Colors.black54,
width: widget.width,
height: 30,
child: Text(
widget.title,
style: Theme.of(context)
.textTheme
.headline2!
.copyWith(color: Colors.white),
// softWrap: true,
overflow: TextOverflow.fade,
textAlign: TextAlign.center,
),
),
),
],
),
Row(
// Rows will have two icons as children
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
Expanded(
child: IconButton(
onPressed: () {
print("Donate Button Pressed");
},
icon: const Icon(
Icons.attach_money,
color: Colors.amber,
),
),
),
StreamBuilder(
// User the ealier stream
stream: qSnapShot,
builder: (context, snapshot) {
if (snapshot.connectionState ==
ConnectionState.waiting ||
snapshot.connectionState == ConnectionState.none) {
return const CircularProgressIndicator();
} else {
// Get documentsnaphot which is given from the stream
final docData = snapshot.data as DocumentSnapshot;
// Fetch favTempleList array from user doc
final favList = docData['favTempleList'] as List;
// Check if the curent widget id is among the favTempLlist
final isFav = favList.contains(widget.itemId);
return Expanded(
child: IconButton(
// Call toggleFavlist method on tap
onPressed: () => toggleFavList(
widget.itemId, templeProvider),
icon: Icon(
Icons.favorite,
// Show color by value of isFav
color: isFav ? Colors.red : Colors.grey,
)),
);
}
})
]),
],
),
),
);
}
}
Summary
In this exciting short chapter we:
- Created a Firebase HTTP Callable function to update a document in Firestore.
- We also wrote a simple instruction in the provider class to handle the callable.
- Lastly using stream and stream builder we displayed real-time changes on our screen.
Chapter Fourteen: Flutter App Development | Conclusion
Intro
This will be the last chapter of this blog. Let's go over the things we did in this very long blog.
- App Launch Icon was created.
- Implemented a Splash screen.
- Designed Global Theme for our app.
- Created dynamic widgets like App Bar, Bottom Navigation Bar, and Drawer.
- Set Up Firebase local as well as in the cloud.
- Design Authentication Screen with little animation.
- User Firebase Authentication for our app.
- Used Permission Handler and Location Packages to access user location.
- We also fetched nearby temples using Google Place API and user location.
- Created some custom buttons for our application.
- We then used Streams and StreamBuilder to toggle favorite temples in real-time.
Where to Go From Here?
You can do a few things to improve this app(in any order).
- Update the Favorite Screen such that it'll display a list of your favorite temples and shops(more on that on #2).
- You can find a hardcoded version of the shop page in this repo. Just like in the temple, create a Firestore collection and fetch data from there. Don't change the design, it's to give you an impression that you're working with a designer.
- Add a save/favorite icon, in the shop items, it should be automatically updated in the favorites screen just like the favorite temples list.
- We haven't much worked on firebase security rules. You can add these on your own for practice. Moreover, you can also add function triggers to validate and filter unwanted user behavior.
- During registration we are not verifying email. Add verification steps to the authentication.
- Make a subpage like a temple list, from any of the button cards. For instance, add a donation portal. You can even add a popular payment gateway like Stripe to practice.
Just use your imagination. If you happen to improve the app just I hope you'll share it with everyone.
Referrals
This was a fantastic journey, but I didn't do it all by myself. There are several blogs, courses, and books that have helped me understand the flutter programming language. So, I would like to share some of my recommendations if you want to learn further.
Dart
- Dart Programming for Flutter: Beginners Tutorial, a free video tutorial on YouTube for absolute beginners.
You don't have to dwell more here because you'll learn dart as you make the flutter app.
Flutter
- Flutter & Dart - The Complete Guide [2022 Edition] by Academind. If I have to recommend only one course for flutter then it'll be this one. This is paid course on Udemy that cost around 13 USD, but it's worth it.
- Flutter & Dart crash course, another course by Academind, but its free and available on youtube. So, do check it out.
- Here's the blog which helped me to understand authentication flow, clean coding, and much more.
- If you prefer to read books then try Flutter Apprentice by raywenderlich Tutorial Team. A complete and insightful book for flutter enthusiasts.
Firebase
- Cloud Functions for Firebase, a very helpful tutorial on firebase functions on YouTube.
- If you understand Hindi, then check out this tutorial series on YouTube by Mukesh Joshi.
- Get to know Firebase for Flutter from the Firebase team, although it's not very simple for beginners.
- Get to know Cloud Firestore, a tutorial series to understand the fundamentals of Firebase Firestore.
Others
The world of development on any platform with any programming language is vast. Many of us recommend/learn a few packages which we feel are necessary sometimes to grow more other times to get a job.
I would recommend you to learn another state-management package since the provider is not a perfect one. There are only two packages I recommend either Riverpod or Bloc. There are other packages too which are good but I would learn animation and designs instead of learning state-management one after another.
Make simple games like those offline games in chrome and play store. Make a simple Mario game. This is all to learn animation in the Flutter.
Dive deeper into the go router package. Learn more about mobile app architectures.
If you're not a designer visit Figma. You can find many designs under CC 4.0 license.
Try reading books like Lean Start-Up and implement the principle in your application and business.
Summary
Whatever you do, don't ever just watch/read tutorials and not practice. If you don't try by yourself you'll never find and understand minor complications and stupid errors that are edited out in tutorials. Write blogs like this to enhance your skills because teaching is one of the best ways to learn.
Show Support
Thank you for reading this blog. More than anything we hope that you'll be able to create at least a simple application after this flutter app development series. Please do like and share the blog. Give us some feedback on how can we improve the content.
We are Khadka's Coding Lounge. We create highly valuable websites and mobile applications at an affordable price. Hire us
Top comments (2)
Thanks you
That's an absolute crazy tutorial you've written here - love the depth and effort you put in. I was wondering have you ever implemented authentication for Flutter without Firebase?