Designing Screens & Widgets In Flutter

Designing Screens & Widgets In Flutter

Flutter App Development Series - XI

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, 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 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 a 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. Home Screen

Card Button

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?

  1. Since, the icon itself will vary, we'll take the icon as a field.
  2. 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.
  3. At the end of the day these cards are links to another page, so we'll need routes as well.
  4. We'll make the edges of the card round with RoundedRectangleBorder.
  5. 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.
  6. 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 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:

  1. The card can cause an overflow error so it's important to have fixed width.
  2. Content especially the quote if dynamic can cause overflow error, so adjustable height can be provided with constraints.
  3. The card has been divided into the 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,
                        ),
                      ],
                    ),
                  )
                ])),
              );
  1. Our home page will be SingleChildScrollView with a column as its children.
  2. The column will have two children, the first one is the quote card and the second will be GridView.Count.
  3. 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 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 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.

  1. 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.
  2. 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.

Temple Screen

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 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.

  1. This FutureBuilder is using the getNearyByTemples() method from TemplesProvider class we created in the previous session.
  2. Here, we're checking if the QuerySnapshot(a result of 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.
  3. If QuerySnapshot has data, we'll map it into a ListBuilder consisting of TempleItem Widgets.
  4. 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 building 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 the snapshot is an 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 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 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.

  1. We added new files and folders in a structured manner.
  2. We created a Dynamic Card Button and Daily Quotes Display widget, that's making our homepage beautiful.
  3. We added the first subpage of our app, the temples list screen.
  4. Temples Screen has a list of Card Widgets to display information on temples.
  5. Temples Screen has made good use FutureBuilder.

Hire Us