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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
Here we're back again to visuals and aesthetics. We'll make two pages in this part, the homepage and the temple page.
Here, we'll be using Streams and StreamBuilders to reflect real-time changes to the Firestore in our app.
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.
These are two designs we found on Figma, we took motivation from:
Here are a few screenshots of the prototype.
Home Screen
Temple List Screen
SignIn Screen
Registration Screen
The following diagram roughly illustrates the user screen flow in our app.
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 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.
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:
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.
So, this was a brief introduction to the blog on Flutter App Development. We hope you're excited as we are.
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 .
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.
Splash Screen - Om Splash
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.
If you're gonna use the version I'm using then just paste it inside pubspec.yaml
# 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.
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
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
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.
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.
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.
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 a 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:
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
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")),
),
);
}
}
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";
}
}
}
Finally, we can go to the router file where we'll create routes and redirect logic. So, on app_router.dart file.
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 a redirect. However, we have yet to implement our router. To do so let's head to the app.dart file.
MaterialApp.The 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.
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.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.
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 are 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,
(PS: I haven't mentioned changes in import.)
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);
}),
),
);
}
}
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.
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",
];
// 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.
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.
If you've done this part then share the screenshot in the comment section.
In this chapter, we created an onboard screen with GoRouter, Shared Preferences, and Provider packages. Here are the things we did in brief:
Progress until now looks like this:
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.
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
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.
As mentioned before we'll be using ProximaNova font. Create a fonts folder inside the 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),
);
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),
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),
),
);
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
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.
Do me a favor I've forgotten to add the text theme in the code, can you add it by yourself?
In this chapter we:
If you want to learn more, visit Google Shrine App Tutorial, MDC-103.
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.
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.
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 the default value we're using is 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.
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.
With this the navigation bar is ready. It's time to test it out on the homepage.
home.dart
The scaffold class has a property bottomNavigationBar where we'll pass the custom navigation bar.
appBar:....
bottomNavigationBar: const CustomBottomNavBar(
navItemIndex: 0,
),
body:...
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, don't use the Drawer class, instead, we'll pass an 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:
Let's go to the home page to create a scaffold key for the home page.
class _HomeState extends State<Home> {
// create a global key for scafoldstate
// #1
final GlobalKey<ScaffoldState> _scaffoldKey = GlobalKey();
........
return Scaffold(
// Provide key to scaffold
// #2
key: _scaffoldKey,
.....
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.
// #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: () {},
),
],
)
])),
),
);
}
}
Now, we need to make changes to the custom app bar.
// 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.
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.
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:
With this comes an end to the 4th chapter, which was dedicated to creating global widgets that'll be used throughout the application. Here,
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.
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
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 the 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
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.
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 an AuthValidators class.
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.
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;
}
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:
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;
}
}
Here's something for you to practice.
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.
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
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()),
],
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.
The codes for the auth_form_widget.dart file is long. So, let's go over it a few pieces at a time first.
// 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;
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 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();
}
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,
));
}
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: [],)
),
);
}
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
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
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
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
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,
),
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),
),
),
],
),
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"),
)
],
)
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.
Let's summarize what we did so far.
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.
The project source code until the last part can be found in this folder.
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.
We'll rely on the 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.
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.
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:
Now we're done with flutter fire installation and configurations, and next, we need to initialize it.
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));
}
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
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.
To connect to the emulator we once again need to make some changes to the main.dart file.
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';
// Outside of any class or methods, before main()
const bool _useEmulator = true;
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.
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();
}
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);
}
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).
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.
In this very short chapter, we connected our temple app to the Firebase project. Let's retrace our steps, we:
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.
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 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 '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';
Let's create AuthStateProvider Class and instantiate FirebaseAuth.
class AuthStateProvider with ChangeNotifier {
FirebaseAuth authInstance = FirebaseAuth.instance;
}
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:
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.
Sign-Out is a very basic and simple method.
void logOut() async {
await authInstance.signOut();
notifyListeners();
}
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();
}
}
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,
))
],
Let's now go to auth_form_widget.dart file in lib/screen/auth/widgets/ . Here we'll have to write a function that we'll get triggered on the register/sign 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.
Inside BuildContext let's first call the AuthStateProvider.
@override
Widget build(BuildContext context) {
// Instantiate AuthStateProvider
final AuthStateProvider authStateProvider = Provider.of<AuthStateProvider>(context);
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),
),
),
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"),
)
],
)
],
),
),
);
}
}
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 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.
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.
We did quite a few tasks in this chapter. Let's retrace our steps:
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 here.
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();
}
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.
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.
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'),
),
],
);
}
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.
Few changes were made in AuthStateProvider and AuthFormWidget classes.
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.
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.
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
For the Location package, to be able to ask for the user's permission we need to add some settings.
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">
For ios, in "ios/Runner/Info.plist", add the following settings at 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 -->
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.
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.
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.
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
...
In the 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.
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.
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;
}
}
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?
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:
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:
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()),
...
],
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 the 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:
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")),
);
}
})),
),
);
}
}
This chapter was dedicated to permission handling and location access. Tasks accomplished here are as follows:
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.
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
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
Install the HTTP package.
flutter pub add http
There's no need for extra setup with the HTTP package.
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.
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;
}
}
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;
}
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.
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 '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');
With 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.
// 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 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:
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 }).
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.
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.
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;
});
Before we leave let's summarize what we did so far.
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.
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
By the end, our home screen will look like this.
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?
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:
Reminder: You can use the image of your choice, but make sure to add the path on the pubspec file.
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,
),
],
),
)
])),
);
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.
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.
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.
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?
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.
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.
...
@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.
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.
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."));
}
}
}
},
),
),
);
}
}
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.
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.
Let's summarize what we did in this section.
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.
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.
// 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:
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.
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);
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:
And with that latest real-time changes will be reflected in the app.
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,
)),
);
}
})
]),
],
),
),
);
}
}
In this exciting short chapter we:
This will be the last chapter of this blog. Let's go over the things we did in this very long blog.
You can do a few things to improve this app(in any order).
Just use your imagination. If you happen to improve the app just I hope you'll share it with everyone.
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.
You don't have to dwell more here because you'll learn dart as you make the flutter app.
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.
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.
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, mobile applications, and google workspace addons at an affordable price. Hire us
Also Published Here