visit
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.
Home Screen
Temple List Screen
SignIn Screen
Registration Screen
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 .
# 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 .
Splash Screen - Om Splash
App Launch Icon - Om and Lotus Splash Image
I resized these images at to achieve different sizes as mentioned in the .
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.
# 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.
#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
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.
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 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: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
#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")),
),
);
}
}
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 . 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.
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 , 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.
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.
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 to switch between our image widgets while using . 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.
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 . 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 .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 can be used to define the colors of many components. Copying the theme leaves us less work to do. However, if you've tried the 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 class.
As mentioned before we'll be using 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, 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 _elevatedButtonTheme(ElevatedButtonThemeData base) =>
ElevatedButtonThemeData(
style: ButtonStyle(
backgroundColor: MaterialStateProperty.all<Color>(
const Color.fromARGB(255, 223, 27, 12),
),
foregroundColor: MaterialStateProperty.all<Color>(Colors.white),
),
);
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),
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 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.
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 .
# 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, 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 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.
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 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.
home.dart
The scaffold class has a property bottomNavigationBar where we'll pass the custom navigation bar.
appBar:....
bottomNavigationBar: const CustomBottomNavBar(
navItemIndex: 0,
),
body:...
# 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 property, which is configured to slide a panel, usually a 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 to the drawer/ 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:
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: () {},
),
],
)
])),
),
);
}
}
// 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.
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 .
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
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.
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:
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;
}
}
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, class can act as a container to display TextFormFields(if they are more than one). It also requires a 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()
]),
),
),
),
),
);
}
}
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;
// This will require toggling between register and signin mode
bool registerAuthMode = false;
Instantiate all the text editing controllers and focus nodes on function. Similarly, these all also need to be disposed of once done so let's do that as well with the 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();
}
void toggleObscureText() {
setState(() {
obscureText = !obscureText;
});
}
// 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,
),
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,
),
// 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),
),
),
],
),
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.
class can be used for animation, while 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.
# Root of the flutter project
# Firebase core
flutter pub add firebase_core
# Firebase Auth
flutter pub add firebase_auth
# Firebase Firestore
flutter pub add cloud_firestore
# Firebase Cloud Functions
flutter pub add cloud_functions
# Firebase Storage
flutter pub add firebase_storage
We've installed Firebase Core, Firebase Auth, Firebase Cloud Functions, and Firebase Storage packages for flutter.
# Install Firebase CLI
dart pub global activate flutterfire_cli
# On the root of your project
# configur cli
flutterfire configure
During the configuration process:
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));
}
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
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;
// 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();
}
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);
}
// 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).
# 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.
Let's do so on our app_router.dart file. We'll have to make changes inside the 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:
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';
class AuthStateProvider with ChangeNotifier {
FirebaseAuth authInstance = FirebaseAuth.instance;
}
// Our Function will take email,password, username and buildcontext
// #1
void register(String email, String password, String username,
BuildContext context) async {
try {
// Get back usercredential future from createUserWithEmailAndPassword method
// # 2
UserCredential userCred = await authInstance
.createUserWithEmailAndPassword(email: email, password: password);
// Save username name
await userCred.user!.updateDisplayName(username);
// After that access "users" Firestore in firestore and save username, email and userLocation
// # 3
await FirebaseFirestore.instance
.collection('users')
.doc(userCred.user!.uid)
.set(
{
'username': username,
'email': email,
'userLocation': null,
},
);
// if everything goes well user will be registered and logged in
// now go to the homepage
// #4
GoRouter.of(context).goNamed(APP_PAGE.home.routeName);
} on FirebaseAuthException catch (e) {
// In case of error
// if email already exists
// # 5
if (e.code == "email-already-in-use") {
print("The account with this email already exists.");
}
if (e.code == 'weak-password') {
// If password is too weak
// #6
print("Password is too weak.");
}
} catch (e) {
// For anything else
// #6
print("Something went wrong please try again.");
}
// notify the listeneres
notifyListeners();
}
Let's go over the details:
// Our Function will take 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 method from FlutterFire. Everything else is the same as in the registration method.
void logOut() async {
await authInstance.signOut();
notifyListeners();
}
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();
}
}
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.
@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),
),
),
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 , 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 functions on the cloud, first you'll have to upgrade plans to the 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 worked for me either, so, I just uninstalled eslint and deployed functions on cloud.
Watch this from Google to understand more about Firebase Functions.
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;
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.
// 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 class. So, we'll pass this msgPopUp method in 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.
// #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'),
),
],
);
}
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.
# 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 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/" 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.
<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.
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?
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:
...
providers: [
...
ChangeNotifierProvider(create: (context) => AppPermissionProvider()),
...
],
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")),
);
}
})),
),
...
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")),
);
}
})),
),
);
}
}
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
.env
#Without quotes
GMAP_PLACES_API_KEY = Your_API_KEY
flutter pub add http
There's no need for extra setup with the HTTP package.
# 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 =
"//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
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;
}
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.
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 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 API. We'll not be using it. But instead, I have some random(4 in numbers) Hindu images that I downloaded from , 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 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;
});
# 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.
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. 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.
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, and a Row.
Can you suggest to me a better icon for donation?
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 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 Class. The important part to notice here is (), 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.
...
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.
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.
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 .
Now, we'll create a cloud function in our index.js file. This function will take an "id" as input. This id is 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";
});
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.
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:
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,
)),
);
}
})
]),
],
),
),
);
}
}