Hello and Welcome to the 11th piece of the Flutter App Development Tutorial Series. This is Nibesh from Khadka's Coding Lounge. We traveled a long way to be here. Before this, we have already made a splash Screen, defined a theme, made global widgets, made an authentication screen, and used google places API to fetch different locations on firebase projects.
In this blog, we will create new card buttons, that'll be displayed in the grid view. Each button UI will take the user to a sub-page like Events, Temples, etc. There will also be another widget, let's call it a quote card, at the top of the home screen. It'll have beautiful quotes from Hinduism displayed there. We'll also make a Temples Screen, which will display a list of temples we fetched in the last section. Each temple will be a Temple Item Widget which will be a card with information on a temple from the list.
You can find the source code for the progress so far on this link.
Let's go to our favorite VS Code with the project opened and make a file where we'll create a dynamic Card. This card will be used as a button on our home page. We won't be using both the card buttons and quote card outside of the home screen, they will be a local widget and the same goes for the temples card widget. Hence, we need new files and folders for both home and temple screens.
# make folder first
mkdir lib/screens/home/widgets
#make file for home
touch lib/screens/home/widgets/card_button_widget.dart lib/screens/home/widgets/quote_card_widget.dart
# make files for temples
touch lib/screens/temples/widgets/temple_item_widget.dart lib/screens/temples/screens/temples_screen.dart
By the end, our home screen will look like this.
card_button_widget
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 file.
import 'package:flutter/material.dart';
class DailyQuotes extends StatelessWidget {
// width for our card
// #1
final double width;
const DailyQuotes(this.width, {Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Container(
constraints:
// Adjust the height by content
// #2
const BoxConstraints(maxHeight: 180, minHeight: 160),
width: width,
alignment: Alignment.center,
padding: const EdgeInsets.all(2),
child: Card(
elevation: 4,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(10.0)),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// #3
Expanded(
flex: 2,
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
// Adjust padding
// #2
padding: const EdgeInsets.only(
top: 10, left: 4, bottom: 10, right: 4),
child: Text(
"Bhagavad Gita",
style: Theme.of(context).textTheme.headline2,
),
),
Padding(
padding: const EdgeInsets.only(top: 6, left: 4, right: 4),
child: Text(
"Calmness, gentleness, silence, self-restraint, and purity: these are the disciplines of the mind.",
style: Theme.of(context).textTheme.bodyText2,
overflow: TextOverflow.clip,
softWrap: true,
),
),
],
),
),
Expanded(
child: ClipRRect(
borderRadius: const BorderRadius.only(
topRight: Radius.circular(10.0),
bottomRight: Radius.circular(10.0)),
child: Image.asset(
"assets/images/image_3.jpg",
fit: BoxFit.cover,
),
),
)
],
)),
);
}
}
Let's go over minor details:
The card can cause an overflow error so it's important to have fixed width.
Content especially the quote if dynamic can cause overflow error, so adjustable height can be provided with constraints.
The card has been divided into Text and Image sections with Row, while text occupies 2/3 space available with Expanded and flex.
You can use the image of your choice, but make sure to add the path on the pubspec file.
Now, that our widgets are ready let's add them to the home screen. Currently, our home screen's code is:
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
// Custom
import 'package:temple/globals/providers/permissions/app_permission_provider.dart';
import 'package:temple/globals/widgets/app_bar/app_bar.dart';
import 'package:temple/globals/settings/router/utils/router_utils.dart';
import 'package:temple/globals/widgets/bottom_nav_bar/bottom_nav_bar.dart';
import 'package:temple/globals/widgets/user_drawer/user_drawer.dart';
class Home extends StatefulWidget {
const Home({Key? key}) : super(key: key);
@override
State<Home> createState() => _HomeState();
}
class _HomeState extends State<Home> {
// create a global key for scafoldstate
final GlobalKey<ScaffoldState> _scaffoldKey = GlobalKey();
@override
Widget build(BuildContext context) {
return Scaffold(
// Provide key to scaffold
key: _scaffoldKey,
// Changed to custom appbar
appBar: CustomAppBar(
title: APP_PAGE.home.routePageTitle,
// pass the scaffold key to custom app bar
// #3
scaffoldKey: _scaffoldKey,
),
// Pass our drawer to drawer property
// if you want to slide right to left use
endDrawer: const UserDrawer(),
bottomNavigationBar: const CustomBottomNavBar(
navItemIndex: 0,
),
primary: true,
body: SafeArea(
child: FutureBuilder(
// Call getLocation function as future
// its very very important to set listen to false
future: Provider.of<AppPermissionProvider>(context, listen: false)
.getLocation(),
// don't need context in builder for now
builder: ((_, snapshot) {
// if snapshot connectinState is none or waiting
if (snapshot.connectionState == ConnectionState.waiting ||
snapshot.connectionState == ConnectionState.none) {
return const Center(child: CircularProgressIndicator());
} else {
// if snapshot connectinState is active
if (snapshot.connectionState == ConnectionState.active) {
return const Center(
child: Text("Loading..."),
);
}
// if snapshot connection state is done
//==========================//
// Replace this section
return const Center(
child: Directionality(
textDirection: TextDirection.ltr,
child: Text("This Is home")),
);
//==========================//
}
})),
),
);
}
}
First, we'll need to calculate the available width for the widgets. MediaQurery class can be used to do so. So, add the following code right after the BuildContext method and before we return Scaffold.
// Device width
final deviceWidth = MediaQuery.of(context).size.width;
// Available width
final availableWidth = deviceWidth -
MediaQuery.of(context).padding.right -
MediaQuery.of(context).padding.left;
Can you calculate the available height of the device?
Now, to add our widgets to the home screen, we'll replace the section that handles the Snapshot.done with the code below.
return SafeArea(
// Whole view will be scrollable
// #1
child: SingleChildScrollView(
// Column
child: Column(children: [
// FIrst child would be quote card
// #2
DailyQuotes(availableWidth),
// Second child will be GriDview.count with padding of 4
// #2
Padding(
padding: const EdgeInsets.all(4),
child: GridView.count(
// scrollable
physics: const ScrollPhysics(),
shrinkWrap: true,
// two grids
crossAxisCount: 2,
// Space between two Horizontal axis
mainAxisSpacing: 10,
// Space between two vertical axis
crossAxisSpacing: 10,
children: [
// GridView Will have children
// #3
CardButton(
Icons.temple_hindu_sharp,
"Temples Near You",
availableWidth,
APP_PAGE.temples.routeName, // Route for temples
),
CardButton(
Icons.event,
"Coming Events",
availableWidth,
APP_PAGE.home.routeName, // Route for homescreen we are not making these for MVP
),
CardButton(
Icons.location_pin,
"Find Venues",
availableWidth,
APP_PAGE.home.routeName,
),
CardButton(
Icons.music_note,
"Morning Prayers",
availableWidth,
APP_PAGE.home.routeName,
),
CardButton(
Icons.attach_money_sharp,
"Donate",
availableWidth,
APP_PAGE.home.routeName,
),
],
),
)
])),
);
You'll get an error mentioning no temples route name, that's because we haven't yet created a temples route. To do so let's head over to the router_utils file in the "libs/settings/router/utils" folder.
// add temples in the list of enum options
enum APP_PAGE { onboard, auth, home, search, shop, favorite, temples }
extension AppPageExtension on APP_PAGE {
// add temple path for routes
switch (this) {
...
// Don't put "/" infront of path
case APP_PAGE.temples:
return "home/temples";
...
}
}
// for named routes
String get routeName {
switch (this) {
...
case APP_PAGE.temples:
return "TEMPLES";
...
}
}
// for page titles
String get routePageTitle {
switch (this) {
...
case APP_PAGE.temples:
return "Temples Near You";
...
}
}
}
Temple will be a sub-page of the home page, hence the route path will be "home/temples" with no "/" at the front.
Now we need to add the respective routes to AppRouter, but we'll do that later, first, we'll create the temple screen with the list of temple widgets.
We have already made the temple_item_widget file, let's create a card widget that'll display information on the temple we fetch from google's place API.
temple_item_widget
import 'package:flutter/material.dart';
class TempleItemWidget extends StatefulWidget {
// Fields that'll shape the Widget
final String title;
final String imageUrl;
final String address;
final double width;
final String itemId;
const TempleItemWidget(
{required this.title,
required this.imageUrl,
required this.address,
required this.width,
required this.itemId,
// required this.establishedDate,
Key? key})
: super(key: key);
@override
State<TempleItemWidget> createState() => _TempleItemWidgetState();
}
class _TempleItemWidgetState extends State<TempleItemWidget> {
@override
Widget build(BuildContext context) {
return SizedBox(
// Card will have height of 260
height: 260,
width: widget.width,
child: Card(
key: ValueKey<String>(widget.itemId),
elevation: 4,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
margin: const EdgeInsets.all(10),
child: Column(
// Column will have two children stack and a row
// #1
children: [
// Stack will have two children image and title text
Stack(
children: [
ClipRRect(
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(10),
topRight: Radius.circular(10),
),
child: Image.network(
widget.imageUrl,
fit: BoxFit.cover,
width: widget.width,
height: 190,
),
),
Positioned(
bottom: 1,
child: Container(
color: Colors.black54,
width: widget.width,
height: 30,
child: Text(
widget.title,
style: Theme.of(context)
.textTheme
.headline3!
.copyWith(color: Colors.white),
// softWrap: true,
overflow: TextOverflow.fade,
textAlign: TextAlign.center,
),
),
),
],
),
Row(
// Rows will have two icons as children
// #2
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
Expanded(
child: IconButton(
onPressed: () {
print("Donate Button Pressed");
},
icon: Icon(
Icons.attach_money,
color: Colors.amber,
),
),
),
Expanded(
child: IconButton(
onPressed: () {
print("Toggle Fav Button Pressed");
},
icon: const Icon(
Icons.favorite,
color: Colors.red,
)),
)
]),
],
),
),
);
}
}
There are two things different from the custom widgets we have already made previously from this card. This widget will have a column with two children, Stack, and a Row.
In the next part, we will add toggle Favorite functionality but Donation will remain hardcoded for this tutorial.
Can you suggest to me a better icon for donation?
By the end, our temple screen page will look like this.
We spent quite some time in the previous chapter fetching nearby temples from google's Place API. Now, it's time to see our results in fruition. The futureBuilder method will be best suited for this scenario. As for the future property of the class, we'll provide the getNearbyPlaes() method we created in TempleStateProvider class.
It's a long code, so let's go over it a small chunk at a time.
import 'package:flutter/material.dart';
import 'package:google_maps_flutter/google_maps_flutter.dart';
import 'package:provider/provider.dart';
import 'package:temple/globals/providers/permissions/app_permission_provider.dart';
import 'package:temple/globals/settings/router/utils/router_utils.dart';
import 'package:temple/globals/widgets/app_bar/app_bar.dart';
import 'package:temple/globals/widgets/user_drawer/user_drawer.dart';
import 'package:temple/screens/temples/providers/temple_provider.dart';
import 'package:temple/screens/temples/widgets/temple_item_widget.dart';
class TempleListScreen extends StatefulWidget {
const TempleListScreen({Key? key}) : super(key: key);
@override
State<TempleListScreen> createState() => _TempleListScreenState();
}
class _TempleListScreenState extends State<TempleListScreen> {
final GlobalKey<ScaffoldState> _scaffoldKey = GlobalKey();
// location is required as input for getNearbyTemples method
LatLng? _userLocation;
@override
void didChangeDependencies() {
// after the build load get the user location from AppPermissionProvider
_userLocation = Provider.of<AppPermissionProvider>(context, listen: false)
.locationCenter;
super.didChangeDependencies();
}
This part is where we import modules and declare a StatefulWidget Class. The important part to notice here is didChangeDependencies(), where we are getting the user location from AppPermissionProvider class. Why? because we'll need the user's location to get temples near the user.
...
@override
Widget build(BuildContext context) {
// Device width
final deviceWidth = MediaQuery.of(context).size.width;
// Subtract paddings to calculate available dimensions
final availableWidth = deviceWidth -
MediaQuery.of(context).padding.right -
MediaQuery.of(context).padding.left;
return Scaffold(
key: _scaffoldKey,
drawer: const UserDrawer(),
appBar: CustomAppBar(
scaffoldKey: _scaffoldKey,
title: APP_PAGE.temples.routePageTitle,
// Its a subpage so we'll use backarrow and now bottom nav bar
isSubPage: true,
),
primary: true,
body: SafeArea(
child: ....
Like before we'll now return a scaffold with an app bar, available width, and so on. Two things are different from any other screens here. First is that this is a sub-page, so our dynamic app bar will be consisting of a back-arrow. The second is that since this page is a sub-page there won't be Bottom Nav Bar as well.
Continuing from before:
...
FutureBuilder(
// pass the getNearyByTemples as future
// #1
future: Provider.of<TempleProvider>(context, listen: false)
.getNearyByTemples(_userLocation as LatLng),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting ||
snapshot.connectionState == ConnectionState.none) {
return const Center(child: CircularProgressIndicator());
} else {
if (snapshot.connectionState == ConnectionState.active) {
return const Center(child: Text("Loading..."));
} else {
// After the snapshot connectionState is done
// if theres an error go back home
// # 2
if (snapshot.hasError) {
Navigator.of(context).pop();
}
// check if snapshot has data return on temple widget list
if (snapshot.hasData) {
// # 3
final templeList = snapshot.data as List;
return SizedBox(
width: availableWidth,
child: Column(
children: [
Expanded(
child: ListView.builder(
itemBuilder: (context, i) => TempleItemWidget(
address: templeList[i].address,
imageUrl: templeList[i].imageUrl,
title: templeList[i].name,
width: availableWidth,
itemId: templeList[i].placesId,
),
itemCount: templeList.length,
))
],
),
);
} else {
// check if snapshot is empty return text widget
// # 3
return const Center(
child: Text("There are no temples around you."));
}
}
}
},
)
FutureBuilder to the rescue.
Here's the whole file, if you're confused with my chunking skill.
import 'package:flutter/material.dart';
import 'package:google_maps_flutter/google_maps_flutter.dart';
import 'package:provider/provider.dart';
import 'package:temple/globals/providers/permissions/app_permission_provider.dart';
import 'package:temple/globals/settings/router/utils/router_utils.dart';
import 'package:temple/globals/widgets/app_bar/app_bar.dart';
import 'package:temple/globals/widgets/user_drawer/user_drawer.dart';
import 'package:temple/screens/temples/providers/temple_provider.dart';
import 'package:temple/screens/temples/widgets/temple_item_widget.dart';
class TempleListScreen extends StatefulWidget {
const TempleListScreen({Key? key}) : super(key: key);
@override
State<TempleListScreen> createState() => _TempleListScreenState();
}
class _TempleListScreenState extends State<TempleListScreen> {
final GlobalKey<ScaffoldState> _scaffoldKey = GlobalKey();
// location is required as input for getNearbyTemples method
LatLng? _userLocation;
@override
void didChangeDependencies() {
// after the build load get the user location from AppPermissionProvider
_userLocation = Provider.of<AppPermissionProvider>(context, listen: false)
.locationCenter;
super.didChangeDependencies();
}
@override
Widget build(BuildContext context) {
// Device width
final deviceWidth = MediaQuery.of(context).size.width;
// Subtract paddings to calculate available dimensions
final availableWidth = deviceWidth -
MediaQuery.of(context).padding.right -
MediaQuery.of(context).padding.left;
return Scaffold(
key: _scaffoldKey,
drawer: const UserDrawer(),
appBar: CustomAppBar(
scaffoldKey: _scaffoldKey,
title: APP_PAGE.temples.routePageTitle,
// Its a subpage so we'll use backarrow and now bottom nav bar
isSubPage: true,
),
primary: true,
body: SafeArea(
child: FutureBuilder(
// pass the getNearyByTemples as future
future: Provider.of<TempleProvider>(context, listen: false)
.getNearyByTemples(_userLocation as LatLng),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting ||
snapshot.connectionState == ConnectionState.none) {
return const Center(child: CircularProgressIndicator());
} else {
if (snapshot.connectionState == ConnectionState.active) {
return const Center(child: Text("Loading..."));
} else {
// After the snapshot connectionState is done
// if theres an error go back home
if (snapshot.hasError) {
Navigator.of(context).pop();
}
// check if snapshot has data return on temple widget list
if (snapshot.hasData) {
final templeList = snapshot.data as List;
return SizedBox(
width: availableWidth,
child: Column(
children: [
Expanded(
child: ListView.builder(
itemBuilder: (context, i) => TempleItemWidget(
address: templeList[i].address,
imageUrl: templeList[i].imageUrl,
title: templeList[i].name,
width: availableWidth,
itemId: templeList[i].placesId,
),
itemCount: templeList.length,
))
],
),
);
} else {
// check if snapshot is empty return text widget
return const Center(
child: Text("There are no temples around you."));
}
}
}
},
),
),
);
}
}
All that's left now is to add Temples Screen to the app's router list. Let's do it quickly on
app_router
...
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 image paths list in the same class. If you cannot make it work somehow, then remove all the codes related to Firebase Storage and just provide a hardcoded URL as an image reference.
Let's summarize what we did in this section.
We added new files and folders in a structured manner.
We created a Dynamic Card Button and Daily Quotes Display widget, that's making our homepage beautiful.
We added the first subpage of our app, the temples list screen.
Temples Screen has a list of Card Widgets to display information on temples.
Temples Screen has made good use FutureBuilder.
Also published here.