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