In this part of the workshop (pt.2) we will tackle the landing page (see image below). If you are here, make sure you have completed pt.1 of this series.
We will tackle several aspects during this codelab for this page in particular:
First things first, we want to establish a good foundation for the landing page (which we're calling DonutShopMain), since this is where many of our feature widgets will reside (and even communicate with one another in a decoupled fashion).
This is what we'll set up initially as the base:
Let's continue expanding on our landing page DonutShopMain, where in the previous codelab we created it as a dummy page widget. We'll flesh it out more now.
Let's start from the top of the Scaffold, where we'll create the application bar. See the schematics below:
Now, let's start coding it!
In the DonutShopMain widget's Scaffold widget, set its appBar to a new instance of AppBar:
Proceed to set the rest of the options as follows:
Running the app at this point should get you the following output:
We'll be adding a Drawer widget (like a sliding side menu triggered by a hamburger menu) by first creating a simple widget that encapsulates this functionality. You can expand further on this side menu - we'll just add a couple of images on a background, just to show you how to implement it and then append it to the Scaffold.
Create a new custom widget that extends StatelessWidget called DonutSideMenu. Override its build method and return an empty Container for now:
Let's dig into its structure. To the existing Container, set its color property to a predefined one (Utils.mainDark), and add 40px of padding all around it:
As the child of this Container, add a Column with two Image widgets:
Make sure both images are left aligned and spread apart vertically by setting the Column properties of crossAxisAlignment and mainAxisAlignment to *start** and spaceBetween respectively:
To the top image, wrap it inside a Container so we can add a top margin of 40px to give it more breathing room:
I think we're pretty much done with it. Now let's go ahead and use it in our Scaffold.
Set the drawer property of the Scaffold to a Drawer widget, and set the child of the drawer widget our newly created DonutSideMenu widget, as such:
Running it on Dartpad, we should see the following on the preview panel:
We see the hamburger menu show once we set the Drawer, and tapping on it expands our newly created widget. Sweet! Let's proceed.
We want to distribute the Scaffold's body appropriately so it can accommodate the child pages that will be displayed in the landing page child stack as well as the bottom navigation bar that will trigger the swapping / navigating among those pages.
We'll distribute the body into the main content, for which we'll use an Expanded widget so it can take up most of the real estate of the body, followed by a placeholder Container widget (that will later be replaced by the bottom navigation bar).
Replace the contents of the Scaffold's body (currently a Center widget wrapping a Text widget) by a Column, holding an Expanded and a placeholder Container widget, as follows:
We should be all set to proceed to the next step, which is creating the bottom navigation bar for the landing page.
This widget will reflect which bar item is selected, trigger the child navigation stack so as to display the corresponding associated child page, as well as keep count of how many items have been added to the shopping cart (a nice-to-have we will implement when we get to the shopping cart functionality).
Let's proceed!
As before, we'll create a custom widget for this bottom bar to achieve a unique experience. Create a class called DonutBottomBar that extends StatelessWidget. Override its build method and return a Container with 30px padding all inside.
As the child of the Container, add a Row widget, with its crossAxisAlignment set to end and mainAxisAlignment set to spaceBetween so its children are aligned at the bottom, and spread apart:
Now, as the children of the Row widget, set three IconButton widgets, with the icon property set to your desired icons, with color Utils.mainColor initially, and the onPressed event set to empty (since its mandatory):
This is a good spot for us to test what we've done so far.
Go back to the DonutShopMain page widget and replace the placeholder Container widget below the Expanded widget:
Running it on DartPad, it should look like this once plugged in:
Now, let's add the selection functionality.
Now in order to persist the selection of the currently selected item in the bottom bar, we can tackle it multiple ways:
Let's proceed.
In your project's pubspec.yaml add a dependency to the provider package. Add the latest version to it:
Perform a flutter pub get or save the pubspec.yaml to trigger an update to its dependencies.
Whenever you're using any of the Provider classes (Provider, ChangeNotifierProvider, ChangeNotifier, Consumer, etc.) then import the provider package:
import 'package:provider/provider.dart';
Create a class called DonutBottomBarSelectionService that extends ChangeNotifier. The ChangeNotifier class provides change notification capabilities for those interested widgets in knowing when changes occur in any of their properties, which is accomplished by calling its notifyListeners method.
We are interested in storing what the current bottom bar item selection is at any given time, so we'll store that in a variable called tabSelection, type string. We'll have a default value of "main" which means there will be a bottom bar item named "main" that will always be selected by default.
We will provide a handy setter method called setTabSelection that takes the new selection, and right after storing the new value, we will invoke notifyListeners, which notifies any listening widgets (i.e. Consumer widget) to rebuild based on the new changes.
See the class implementation below:
class DonutBottomBarSelectionService extends ChangeNotifier {
String? tabSelection = 'main';
void setTabSelection(String selection) {
tabSelection = selection;
notifyListeners();
}
}
In order to give this provider service a global scope so it "provides" itself or trickles down to interested widgets in the hierarchy, you have to lift the state as high as you can; in our case we'll inject it at the very top - at the MaterialApp level. This sets app-level scope, as opposed to scoping it to the widget in which it is used.
Since we will be using more than one provider service in this app, let's use a MultiProvider widget, otherwise for a single-provider app or a locally-scoped service, a Provider or ChangeNotifierProvider widget would do.
Wrap the MaterialApp inside a MultiProvider widget, and set its providers property to an empty array to start with (and make sure to import the provider package):
Let's add our first provider service, the DonutBottomBarSelectionService wrapped inside a ChangeNotifierProvider object:
Now that the service has been provided at the root, since the DonutBottomBar widget is a widget located down the hierarchy, we know for sure this service will trickle down to it. In order to consume it - listen to changes occurring when the tab selection changes (i.e. the setTabSelection method gets called), we will use a convenient widget called Consumer. The Consumer widget rebuilds itself when the notifyListeners method of the service it is listening to gets called.
We'll put the Consumer widget inside the DonutBottomBar widget, more precisely around the Row widget that holds our bar items (IconButtons), that way when the selection changes (when the user taps on the corresponding IconButton
onPressed event), we appropriately set the selected one, which triggers the notifyListeners, which in turns forces itself to rebuild; then upon the widget rebuilding, the new selected tab value gets evaluated, and we change the color of the selected one accordingly.
Notice how we make the Consumer widget a direct child of the main Container widget in the bottom bar, which in turn returns the Row widget from its builder callback method. This callback provides a context, an instance of the service being listened to (bottomBarSelectionService) and an optional child.
Now, let's make each one of the IconButton widgets trigger the Consumer to rebuild, by using the provided service instance (bottomBarSelectionService) and its provided method setTabSelection and passing the corresponding value of the selected tab item:
Each button will call up to the service, set the value, and thus invoking the notifyListeners method, which will cause the Consumer widget to rebuild. Now how do we evaluate the selected value upon each rebuild, and in turn change the color of the Icon widget in the IconButton widget accordingly?
Simply read the tabSelection property on each turn of the build method, and change the color of the Icon whether the item in question matches the selection, as such (apply this to all Icon widgets for each IconButton widget accordingly):
Running on DartPad to test the selection, notice how when each IconButton is clicked, the color of the child Icon changes to dark when it is the one being selected, while the rest change to a lighter color, efficiently proving that the state is being lifted and maintained outside of the widget, regardess of it rebuilding on every click.
Nicely done, and great introduction to State Management using Provider in Flutter!
Let's keep rolling!
We will now implement a way in which, without leaving the DonutShopMain landing page widget, we can swap out content, by using the Navigator widget, which sets up a child navigation stack private to this page, but triggered by an external component (the DonutBottomBar widget).
When a user clicks on a bottom bar navigation item, it will push a new child page onto the nested navigation stack in our landing page. The Navigator widget in the DonutShopMain page will switch between three sub-pages (main list, favorites list, shopping list). Let's see how it's done!
The main application contains its own Navigation Stack - that's how it accomplish navigating between the pages it's composed of (in our case, the SplashPage widget and the DonutShopMain pages are pushed onto the main app navigation stack - even the Drawer gets pushed to the main navigation stack ;) - go figure!).
Pages get pushed (in a stack fashion) one on top of the other, the topmost page being the one the user sees at a given time. Pages get popped off the top of the stack, revealing the one underneath until there's no more pages to pop, and so on.
However, pages can implement their own navigation stack - allowing child pages to be displayed within them with the help of the Navigator widget, which works similar to the main app navigation stack, allowing pages to be pushed onto it, etc. More on nested navigation here.
We will have to do some refactoring in order to manage both navigation stacks (main nav stack, and landing page nav stack) independently, but efficiently.
Each navigation stack will be referenced by a unique global key, so that we can uniquely reference them and be able to push and pop widgets on and off of it efficiently.
In the Utils class we created earlier, start by defining two GlobalKey instances, each of which will be assigned to each navigation stack:
Let's do some refactoring to the main navigation strategy, and instead of pushing instances of Route that get pushed onto the navigation stack, we will instead use named routes.
Go to the MaterialApp widget. Remove the home property which points to the SplashPage widget. Add the following properties:
Your code should look like this afterwards:
Let's take this for a spin! In the SplashPageState class, we are currently pushing the DonutShopMain page by creating a Route instance which wraps the corresponding widget, then pushing it onto the navigation stack.
Replace this by using the named route approach; use Utils.mainAppNav.currentState!.pushReplacementNamed(‘/main'), which pushes the widget page associated with the route key named "main", hence beign a "named" route. Your navigation code should look like this:
Running this code should not display anything different from what it was before - the SplashPage gets displayed, then after 2 seconds, it navigates to the DonutShopMain page, this time being done via a named route, but yielding the same results.
And with that in place, we are ready to set up the Navigator widget and our nested navigation strategy!
Let's go to the DonutShopMain widget page; inside the Expanded widget, replace the child Container widget by a Navigator widget.
Inside the Navigator widget, set its key property (Utils.mainListNav) and its initialRoute ("/") - just like the main navigator (yes, even a child navigator has an initialRoute so it knows which page to land on initially by default). We'll set up its routes as well.
In order to set up the routes managed by this Navigator widget, you must hook up to its onGenerateRoute callback, which gets triggered when a user pushes a named route onto this child navigation stack, as follows:
When a user calls Utils.mainListNav.currentState.pushNamed() and pass the named route belonging to this child navigation stack, the onGenerateRoute gets invoked. Its settings parameter type RouteSettings has a property called named which you should use to match the provided named route to the widget to be pushed at the top of this child navigation stack.
At the end of the onGenerateRoute method, return a PageRouteBuilder, provided a pageBuilder callback, which returns the widget to be displayed onto the stack, as well as a transitionDuration, in case you want to provide a duration - in our case, we set a Duration to zero so no transition occurs.
Below the whole code that should go inside the onGenerateRoute (we'll implement each route's corresponding page individually later on):
Time to kick the tires on this nested navigation!
We said earlier that the DonutBottomBar widget, upon the user tapping on one of the items, it will push the corresponding named route. Let's leverage the existing functionality of the DonutBottomBar widget that calls the setTabSelection method on the DonutBottomBarSelectionService and use the same tabSelection as the name of the named route to be pushed onto the child navigation stack, as follows:
With this, we trigger a change in the child navigation stack in the DonutShopMain page (via the Utils.mainListNav.currentState!.pushReplacementNamed using the same tab selection) while at the same time preserving the tab selection and triggering a rebuild on the Consumer widget. One single line of code change, while no changes on the bottom bar widget. See it in action!
Notice how the pages get swapped as the user taps on the corresponding bottom bar item.
With the nested navigation in place, we are free to flesh out each of the child pages in the main landing page: the DonutMainPage, DonutFavoritesPage and the DonutShoppingCartPage.
Let's proceed!
Now we can concentrate on each of the individual child pages that get displayed within the Navigator widget inside our DonutShopMain landing page. This time the focus is on the DonutMainPage.
This page will be comprised of 3 widgets:
We'll get some things out of the way first by setting up the layout of the DonutMainPage child page widget.
Start by creating a custom widget class called DonutMainPage that extends StatelessWidget. Override its build method as customary and return a Column with empty children array as default. As shown above, our child widgets will be laid out vertically:
Then, go to the Navigator widget in the DonutShopMain page, and in the onGenerateRoute callback, find the switch case that matches the /main named route; replace the placeholder Center widget by our newly created DonutMainPage custom child page widget, as such:
With that hooked up, we are ready to expand and define this child page further. Click Next to proceed.
Let's start with the DonutPager. The following image is a schematic view of what we'll be tackling:
Proceed to create a custom widget called DonutPager, this time we'll make it straight into a StatefulWidget since there will be some internal state to be maintained, also we will be using the PageView widget, which requires a PageController that needs to clean up its resources afterwards.
In order to feed the images that make up our DonutPager widget, we pull them from a remote location. I want to package them nicely in a single object, for which I'll create a small PODO (Plain Ol' Dart Model) class called DonutPage, which will have two properties:
With that out of the way, let's focus our attention back to our DonutPager. Let's create a collection of DonutPage objects inside our DonutPagerState widget to mock the retrieval of several images to display on our page view.
Create two more properties:
Override the initState method and initialize the controller instance, setting its initialState property to zero:
Now let's focus our attention inside the DonutPager's build method, and let's build its structure.
Replace the placeholder Container widget by a SizedBox widget, with a fixed height of 350px. We want to constrain the height of this widget to a fixed dimension. Add a Column widget as its child with an empty children array by default:
Inside of this Column widget is where we want to place the PageView widget and later on our custom PageViewIndicator widget; we want the PageView to occupy most of the Column's real estate, so let's start by an Expanded widget, which will serve as the PageView's wrapper / parent, then add the PageView widget as its child:
Let's set some properties on the PageView widget:
Time to load the children on this PageView widget! We'll use the PageView's children property to feed a list of DonutPage objects (which we've conveniently created and set up as a List of DonutPage objects a few steps back).
We'll conveniently use the List.generate factory method with this list to generate widgets on the fly based on how many DonutPage objects we have, and feed the data into custom-created widgets containing the image information. To the List.generate method, pass the amount of pages available (*pages.lenght) and a callback which we'll use to tap into each item in the iteration, and in turn build each page in the PageView, as follows:
Now let's focus our attention on the List.generate's callback method. This method receives an index, which represents the current item in the iteration, with which we'll pull the corresponding DonutPage object from the list of pages to build the page, as such:
Let's start adding some properties to this Container widget:
We'll use the Container widget as a canvas to paint the promo images as a background image, as well as add roundness to the Container and some shadow effects to give it some depth. To accomplish all this this we'll use the decoration property of Container, which we'll facilitate all these features. We'll set the following properties of the decoration, type BoxDecoration:
After setting the decoration properties, our Container code should look like this afterwards:
Let's test what we've built so far.
Back on our DonutMainPage, add our newly created DonutPager as the first child of the Column widget in here, as follows:
Running what we have so far on DartPad, and our preview panel should show the following:
Nice, our DonutPager widget is in place. The last touch is to add the little image logo right on top of the Container's background, which is as simple as adding it as a child of that same Container. Go back to the Container being generated as each PageView page, then pull the logo image out of the currentPage model's logoImgUrl property, and set it to an Image.network widget, with a 120px width, as shown below:
Taking it for a spin once again, and we should see the logo image showing on top of the background image. Play with swiping left and right, and testing the snapping functionality. Sweet!
Let's keep moving!
Let's add a nice touch to this DonutPager - a way for users to see how many pages they will be viewing, and a way to let them know how far along they are on the list by using some circular indicators. We'll encapsulate this functionality in a very convenient widget called PageViewIndicator.
Check out the schematics of this widget below:
Let's start by creating a new custom widget class called PageViewIndicator that extends StatelessWidget. Override its build method and return a Row widget with an empty children array by default:
Next, we'll create 3 convenient properties:
Pass the values via this widget's constructor, and your code should look as follows:
Let's focus now on the build method of this widget. We want our circular indicators to be centered in the row, so let's set its mainAxisAlignment property to center.
To populate each circular indicator based on the number of pages available, let's populate the Row's children property through the List.generate factory method, passing the numberOfPages as the length of items to iterate on, and the callback that will spit out each indicator in turn, as follows:
Now let's give more definition to the Container that will represent each page indicator.
Let's start adding some properties to give this Container the circular shape and color we want. Start by adding the following:
Your code should look as follows afterwards:
Let's start plugging this widget in place.
Back on our DonutPagerState widget, inside the build method, locate the Expanded widget that is wrapping our PageView widget. Right underneath it, add our newly created PageViewIndicator widget. Pass the required values, as illustrated below:
Running this on DartPad, and you should see the page indicator circles showing up! Hooray!!!
Wait - no so fast. They are showing alright, but when you slide left and right, the indicators don't show what the current page - is as if this control is static. We want it so that when we swipe on the PageView widget, the PageViewIndicator also reflects the index of which page is currently showing.
For this, you need to tap into the PageView widget's onPageChanged event. This event gets triggered every time a user swipes on the specified direction, thus triggering a change on the page. This gets assigned a callback, which supplies a parameter of type int that represents the current page being shown.
What we'll do is tap into this callback, capture the index of the current page being displayed, and trigger a widget rebuild so the PageViewIndicator rebuilds as well, reflecting the correct current page being displayed.
In the PageView widget, hook up a callback to the onPageChanged event, and inside this callback, trigger a rebuild using the setState method available on this State class. Inside of this method, then set the currentPage property using the page property supplied by the callback, as follows:
Now with these latest changes, after swiping left and right, you should see the indicator changing! Now we are talking!
Cool that this widget shows the currently displayed page from the PageView. What if I want to use those same indicators to take me to the corresponding page I want to see? So simple - let me show you.
Back in your PageViewIndicator widget, wrap the Container that represents the indicator inside a GestureDetector, since we want to make this Container tappable.
We will leverage the GestureDetector's onTap event, and inside it, use the PageController's animateToPage method, to which you'll pass the index of the page indicator (which will be the same as the page index), the duration of the page sliding effect, and the curve of the animation.
At the end your code should look like below:
We can also add some animation to the color changing effect in the PageViewIndicator individual indicators. A nice-to-have, that kind of smoothens out the animation. Let's do it real quick.
Back to that same Container that represents each indicator widget, replace the Container by an AnimatedContainer widget. The AnimatedContainer is one of those explicit animation widgets available in the library that does all the heavy lifting for you when it comes to animate any of the properties available on a Container. In our case, we want to animate the color property, which is the only thing that changes between widget rebuilds.
Replace Container by AnimatedContainer, and supply two additional properties to get it going:
After applying the AnimatedContainer, your code should look like this:
Run it once again and you'll notice the color animation, which looks much smoother and slicker! Kudos to you!
And that about wraps up the DonutPager which included the PageViewIndicator. Click next to proceed to the next section.
The DonutFilterBar is a widget that handles the filtering of the list of donuts by their types (classic, sprinkled and stuffed). I made it custom so as to continue introducing State Management concepts as well as bringing more interactivity to your apps by introducing both explicit and implicit animations.
The following schematic view shows how we'll be tackling it:
Each filter bar item will perform the filtering of the list of donuts below, as well as change the color of the currently selected one, and perform an animation by showing a sliding bar that runs across the filter bar and lands on the currently selected bar item.
Let's proceed!
First things first - let's create the custom widget that will hold the filter bar, so create a class called DonutFilterBar and make it extend StatelessWidget:
Let's give a pause on building the structure of this widget to give way to building the mocked data and services required to hydrate this widget.
Let's create another useful PODO (Plain Ol' Dart Object) that will hold the data for each of the filter bar items. Let's call it DonutFilterBarItem. We are only interested in knowing the label to display, and the id to uniquely identify the currently selected filter bar item.
We need to create a provider service to hold all the logic of persisting the currently selected filter, the list of filters available, and later on we'll use it to perform the actual filter and hold the data of our available products.
Let's start by creating a provider service class called DonutService. This class will also extends ChangeNotifier since this will perform operations that may trigger widget rebuilds:
In our newly created service, create a mocked data collection representing the three categories of donuts, against which our users should be able to filter their donuts on. Make it a List of type DonutFilterBarItem and populate it accordingly:
Proceed to create a property called selectedDonutType, type string; in the service's constructor, set an initial value to be the first filter bar item id (*classic) as a default value. This property will hold the value of the selected filter bar item at any given point and we have a starting point for what to render the default selection with:
In this same service, let's create a convenient method to call when the user taps on each filter bar item to perform the actual filtering. We will only hold the selected filter bar item for now - later we'll perform the actual filtering. Subsequently, make a call to notifyListeners (provided by inheriting from ChangeNotifier) to trigger a widget rebuild in the event that the selectedDonutType has changed:
With our data mocked up and our provider service ready to be consumed, let's proceed and wrap up the DonutFilterBar widget structure.
In the DonutFilterBar widget, replace the placeholder Container widget by a more specialized Padding widget - we only need to apply padding here, so there's no need for a full-blown Container. Apply padding all inside of 20px:
Since we'll be listening to changes on the currently selected filter bar item, which is being held in the DonutService provider service, let's go right ahead and create a Consumer widget. Recall that a Consumer widget will trigger its builder method when it calls notifyListeners internally - in our case, upon users tapping on the available filtering options. Make the Consumer widget a direct child of the Padding widget:
We must return something out of the Consumer's builder method, therefore if we go by the schematic graphic above, we must start by creating a Column widget:
This Column will hold the Row of filters, some spacing and the sliding bar.
Let's start from the top and add a Row widget, in which its items will be spaced out, hence setting its mainAxisAlignment to spaceAround:
This Row widget will hold the filter bar items, based on the filterBarItems collection of DonutFilterBarItem objects we mocked up earlier in the provided DonutService. Populate the children property of the Row by using the convenient factory method List.generate, feeding into it the filterBarItems collection and a callback that will generate each filter bar item accordingly:
Inside the List.generate callback, extract the corresponding DonutFilterBarItem out of the provided service donutService and its filterBarItems property, using the supplied index value through the callback, and holding it in a property called item:
Using each DonutFilterBarItem item, construct a Container widget that wraps a Text widget inside, with the correct item.label value as well as the corresponding color style, based on whether the currently selected filter bar item (stored in donutService.selectedDonutType) matches the item.id, as follows:
Let's take what we've build so far for a spin.
Plug in this widget into its final destination, by adding it right below the DonutPager widget inside the DonutMainPage child page widget:
Running this on DartPad to see what is looking like, should yield the following result:
At least you can see the currently selected default item (Classic) but there's no interactivity and no way to change the current selection. In the next step, we'll add more interactivity to it.
Let's make them clickable now. Back to the DonutFilterBar, inside the List.generate callback of the Row widget, wrap each Container widget inside a GestureDetector widget to leverage its tapping capabilities:
Inside the GestureDetector's onTap event, invoke the donutService.filteredDonutsByType method, passing the corresponding item's id upon the user tapping on the filter bar item. This will persist the currently selected filter bar item, as well as trigger the parent Consumer widget, kicking off a rebuild, which eventually updates the selected filter bar item:
Running it again, and tapping on the different filter bar items available, should yield the following result:
Let's add yet more interactivity by adding the sliding bar below this Row widget.
The sliding bar effect under the Row widget is nothing fancy - is nothing more than a Container widget that animates its alignment between 3 available positions (left, center and right) over a short period of time in a smooth fashion. Let me show you how I did it.
Back in our DonutFilterBar widget, first add some spacing between the Row of filter bar item widgets and our upcoming sliding bar. Make it a SizedBox with a 10px height:
Right under this spacing widget, add a Stack widget.
We'll use a Stack which will occupy the whole width of the screen and will function as some sort of "rail" on which our Container widget will slide from left to right, aided by an AnimatedAlign widget, which will change its value from 3 possible options based on the selected filter bar item: left (Classic), center (Sprinkled) and right (Stuffed). Let's create a method that will handle this logic.
Create a local method to this widget called alignmentBasedOnTap that takes as a parameter the id of the selected filter bar item, and will perform the logic of returning the corresponding alignment based on the provided filter bar item id:
Back on the Stack, add as its only child inside the children collection, an AnimatedAlign widget, with the following properties:
Your code should look like this so far:
Now the only thing missing is the Container widget that will represent the bar that will simulate a sliding effect. Add as a child of the AnimatedAlign widget a Container widget with the following specs:
The whole AnimatedAlign widget, including its Container widget should look like this:
Running it one more time on DartPad in order to test the sliding effect, should show you this on the preview panel:
And that takes care of the DonutFilterBar widget in its entirety. You should feel proud of what you've accomplished and all we've learned in terms of widget composition, animations and state management!
Let's move on to the next and last widget of the DonutMainPage - the DonutList widget!
Let's check out the schematic view of the DonutList widget we'll be building in this section:
The DonutList will consist of an AnimatedList widget, which is an animated version of the ListView, a scrollable container of child widgets. Each of the child widgets will be a custom DonutCard that will display the information of our donut products.
At the end of this codelab, we will hook it up to the DonutFilterBar we created earlier so we can filter the list of donuts by the selected filter bar item (between Classic, Sprinkled and Stuff).
Let's proceed.
Just like we've done in the previous widgets, we'll use some mocked data to hydrate the list of widgets, in our case we need a collection of donut products, each of which may contain the image of the product, name, price and description. Notice where I'm headed? Yes, we will need a PODO object that will encapsulate this data.
Let's create a model class called DonutModel, which will contain the following properties:
Your model class should look like this:
I've prepared some static mocked data collection representing a list of donut products for use within this app. Feel free to copy / paste the code below anywhere inside the Utils class we created earlier:
/* place inside the Utils class */
static List<DonutModel> donuts = [
DonutModel(
imgUrl: 'https://romanejaquez.github.io/flutter-codelab4/assets/donutclassic/donut_classic1.png',
name: 'Strawberry Sprinkled Glazed',
description: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Fusce blandit, tellus condimentum cursus gravida, lorem augue venenatis elit, sit amet bibendum quam neque id sapien.',
price: 1.99,
type: 'classic'
),
DonutModel(
imgUrl: 'https://romanejaquez.github.io/flutter-codelab4/assets/donutclassic/donut_classic2.png',
name: 'Chocolate Glazed Doughnut',
description: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Fusce blandit, tellus condimentum cursus gravida, lorem augue venenatis elit, sit amet bibendum quam neque id sapien.',
price: 2.99,
type: 'classic',
),
DonutModel(
imgUrl: 'https://romanejaquez.github.io/flutter-codelab4/assets/donutclassic/donut_classic3.png',
name: 'Chocolate Dipped Doughnut',
description: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Fusce blandit, tellus condimentum cursus gravida, lorem augue venenatis elit, sit amet bibendum quam neque id sapien.',
price: 2.99,
type: 'classic'
),
DonutModel(
imgUrl: 'https://romanejaquez.github.io/flutter-codelab4/assets/donutclassic/donut_classic4.png',
name: 'Cinamon Glazed Glazed',
description: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Fusce blandit, tellus condimentum cursus gravida, lorem augue venenatis elit, sit amet bibendum quam neque id sapien.',
price: 2.99,
type: 'classic'
),
DonutModel(
imgUrl: 'https://romanejaquez.github.io/flutter-codelab4/assets/donutclassic/donut_classic5.png',
name: 'Sugar Glazed Doughnut',
description: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Fusce blandit, tellus condimentum cursus gravida, lorem augue venenatis elit, sit amet bibendum quam neque id sapien.',
price: 1.99,
type: 'classic'
),
DonutModel(
imgUrl: 'https://romanejaquez.github.io/flutter-codelab4/assets/donutsprinkled/donut_sprinkled1.png',
name: 'Halloween Chocolate Glazed',
description: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Fusce blandit, tellus condimentum cursus gravida, lorem augue venenatis elit, sit amet bibendum quam neque id sapien.',
price: 2.99,
type: 'sprinkled'
),
DonutModel(
imgUrl: 'https://romanejaquez.github.io/flutter-codelab4/assets/donutsprinkled/donut_sprinkled2.png',
name: 'Party Sprinkled Cream',
description: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Fusce blandit, tellus condimentum cursus gravida, lorem augue venenatis elit, sit amet bibendum quam neque id sapien.',
price: 1.99,
type: 'sprinkled'
),
DonutModel(
imgUrl: 'https://romanejaquez.github.io/flutter-codelab4/assets/donutsprinkled/donut_sprinkled3.png',
name: 'Chocolate Glazed Sprinkled',
description: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Fusce blandit, tellus condimentum cursus gravida, lorem augue venenatis elit, sit amet bibendum quam neque id sapien.',
price: 1.99,
type: 'sprinkled'
),
DonutModel(
imgUrl: 'https://romanejaquez.github.io/flutter-codelab4/assets/donutsprinkled/donut_sprinkled4.png',
name: 'Strawbery Glazed Sprinkled',
description: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Fusce blandit, tellus condimentum cursus gravida, lorem augue venenatis elit, sit amet bibendum quam neque id sapien.',
price: 2.99,
type: 'sprinkled'
),
DonutModel(
imgUrl: 'https://romanejaquez.github.io/flutter-codelab4/assets/donutsprinkled/donut_sprinkled5.png',
name: 'Reese\'s Sprinkled',
description: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Fusce blandit, tellus condimentum cursus gravida, lorem augue venenatis elit, sit amet bibendum quam neque id sapien.',
price: 3.99,
type: 'sprinkled'
),
DonutModel(
imgUrl: 'https://romanejaquez.github.io/flutter-codelab4/assets/donutstuffed/donut_stuffed1.png',
name: 'Brownie Cream Doughnut',
description: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Fusce blandit, tellus condimentum cursus gravida, lorem augue venenatis elit, sit amet bibendum quam neque id sapien.',
price: 1.99,
type: 'stuffed'
),
DonutModel(
imgUrl: 'https://romanejaquez.github.io/flutter-codelab4/assets/donutstuffed/donut_stuffed2.png',
name: 'Jelly Stuffed Doughnut',
description: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Fusce blandit, tellus condimentum cursus gravida, lorem augue venenatis elit, sit amet bibendum quam neque id sapien.',
price: 2.99,
type: 'stuffed'
),
DonutModel(
imgUrl: 'https://romanejaquez.github.io/flutter-codelab4/assets/donutstuffed/donut_stuffed3.png',
name: 'Caramel Stuffed Doughnut',
description: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Fusce blandit, tellus condimentum cursus gravida, lorem augue venenatis elit, sit amet bibendum quam neque id sapien.',
price: 2.59,
type: 'stuffed'
),
DonutModel(
imgUrl: 'https://romanejaquez.github.io/flutter-codelab4/assets/donutstuffed/donut_stuffed4.png',
name: 'Maple Stuffed Doughnut',
description: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Fusce blandit, tellus condimentum cursus gravida, lorem augue venenatis elit, sit amet bibendum quam neque id sapien.',
price: 1.99,
type: 'stuffed'
),
DonutModel(
imgUrl: 'https://romanejaquez.github.io/flutter-codelab4/assets/donutstuffed/donut_stuffed5.png',
name: 'Glazed Jelly Stuffed Doughnut',
description: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Fusce blandit, tellus condimentum cursus gravida, lorem augue venenatis elit, sit amet bibendum quam neque id sapien.',
price: 1.59,
type: 'stuffed'
)
];
With that out of the way, let's create our custom DonutList widget.
Start by creating a custom widget class called DonutList, which inherits from StatefulWidget (we will make it StatefulWidget from the get-go since later we will introduce implicit animations which require tacking on to the State). Right out of the gate, make the corresponding State class (which you can call _DonutListState) return a ListView from its overriden build method (let's start with a ListView to get things going, later down the lab we'll turn it into an AnimatedList).
This should be your code:
Let's refactor the default constructor of the DonutList
Stateful widget to take a list of DonutModel objects, therefore create the corresponding class member to hold its value, as follows:
Let's continue. In the _DonutListState class, where the build method resides, replace the existing ListView by a ListView.builder; this is because the ListView.builder factory method resembles more the AnimatedList constructor for when we build the animated version of the list. Add the following properties to the ListView.builder:
Your code should look like this:
Let's add something to the itemBuilder callback so it stops barking (see the red squiggly line!). Let's come back to this method, and go ahead and build out the structure of each of the donut cards.
Inside of the ListView widget (and eventually the AnimatedList widget), we would like to display each donut as a separate widget, in a card-looking fashion that we'll custom create. Check out this quick schematic view of our card.
Notice the overlapping nature of some of its parts, to give it a unique effect. Users will eventually be able to tap on this card, which will reveal more details about this product by performing a navigation action. More on this on the next lab. Let's proceed with our custom card.
Let's start by creating another custom widget class called DonutCard that extends StatelessWidget.
While overriding its build method, return a Stack widget - this will be the foundation widget of our structure:
Add an empty children array by default, and make sure that when we lay them out, they are center aligned, using the Alignment.center alignment option:
Since a Stack lays out his children in a layered fashion (bottom-up), let's lay down the first children, which is a Container - the one holding the name and price info.
Add a Container inside the Stack as its first child, with the following specs:
Your Container should look like this afterwards:
As the child of this Container, add a Column widget, with an empty children array to start with. We want our items to be aligned at the bottom of the column, left-aligned - therefore set the mainAxisAlignment to end, and crossAxisAlignment to start:
We want to display information related to a donut product, so where will we pull this info? Let's make some changes to this class before proceeding any further.
Add a new property to this DonutCard widget called donutInfo, type DonutModel, and feed it via the constructor. We'll use this property to feed data into this widget and render the text labels and image, as such:
Now let's get back to the structure we were building earlier - the Column widget that will hold the text content, so let's focus on that column for a bit.
Add the first item in the Column: a Text widget that will display the name of the donut (pulling the data out of the injected donutInfo model via the constructor), with the following specs for its TextStyle:
Your Text widget should look like this:
Add some spacing underneath the Text widget by adding a SizedBox with 10px of height - give it some breathing room:
Now let's add the little badge to display the price information in. For that, we'll use a Container widget, with the following specs:
Your Container should look like this:
We need to display the price information inside this little badge Container widget by pulling the value from donutInfo.price, so assign a Text widget as the sole child of this Container, with the following specs:
Your Text widget inside the Container should look like this:
We've coded a lot - let's see how things are looking so far. We'll get back to finalizing the structure, but let's test what we've built up to this point.
Back up to the _DonutListState widget, inside the itemBuilder callback of the ListView.builder, let's use the DonutCard here. First, pull a DonutModel object out of the donuts list that we'll be injected via the constructor, using the index supplied through the callback. Hold the value in a variable called currentDonut:
Now, use this currentDonut to churn out DonutCard widgets out of this callback. Return a DonutCard widget, and pass the currentDonut into the constructor, as such:
With that hooked up, let's go back to the DonutMainPage and start putting the DonutList in place to test how's coming out. We'll even feed it the mocked donuts list straight up (Utils.donuts) just to see it in action. Add the DonutList widget right under the DonutFilterBar, feeding it the Utils.donuts assigning it to the donuts argument.
Since we're adding it to the main Column layout of the DonutMainPage widget, and we want it to take up most of the real estate in the column, wrap it inside an Expanded widget, as such:
Running what we have so far on DartPad, you should be able to see the following:
Now, let's finish up the DonutCard by adding the missing piece - the donut image. Recall that we added a Stack widget in order to achieve that staggered / overlapped effect between the image and the card content below. Let's do it!
Back to the DonutCard widget, locate the Stack widget. Right under the existing Container widget that holds the text information, we'll add an Image widget, using the Image.network option, but to achieve the staggered effect, we'll wrap the Image inside an Align widget with a top center alignment. Recall that the Container widget has a top margin of 80px in order to force the white space we'll be devoting to the image. Once the Align widget anchors itself to the top center of the Stack, it will have that overlapping effect.
Add the Image widget using the network option, with 150px in dimension, and a fit of BoxFit.contain to make sure it preserves its aspect ratio. Feed the image path by pulling it out of the donutInfo.imgUrl. Wrap the image inside an Align widget, with a topCenter alignment, as follows:
Re-run the app in DartPad and it should look pretty much done up to this point:
And if you scroll through all items available, notice we are showing them all - there's no filtering. Now we should be able to hook up the DonutFilterBar filtering functionality with the DonutList, so when the user taps on the desired filter bar item, the DonutList widget and its list of donuts filters itself to show only the selected item.
Let's work on that!
In order to have the DonutFilterBar communicate with the DonutList in a decoupled way, we'll leverage the DonutService we created earlier. When the filtering is triggered from the DonutFilterBar, the DonutList listens to that change and rebuilds itself accordingly, feeding into it the filtered donut list - and the glue between them is the DonutService.
Let's go to the DonutService and start by adding a new property - a List of type DonutModel called filteredDonuts that we'll use to hold the filtered list of donut products at any given time. Initialize it with an empty array for now:
In the existing method called filteredDonutsByType, right after we assigned the selectedDonutType with the currently selected filter bar item from the DonutFilterBar widget, perform a filter against the existing mocked data in the Utils.donuts, and retrieved a filtered list of the donuts that match their type against the selectedDonutType; for this, use the existing type property from the DonutModel, then assign the resulting list to the filteredDonuts collection:
Notice that we still keep the notifyListeners call at the end of this method; this will ensure that when the DonutList rebuilds itself when listens to a change in this service. All we're missing to do is make the DonutList widget listen for changes occurring on the DonutService, and what better way to do it than make it a "consumer" of the Donutservice.
Last thing to do is take care of the initial load, since initially the user hasn't selected anything!. For this, we will execute the filteredDonutsByType in the DonutService's constructor, using the default value assigned to selectedDonutType from the first item in the filterBarItems collection, and assigning it to the filteredDonuts collection, also as a default initial filtered collection, as follows:
Back in the DonutMainPage, locate the DonutList widget, wrapped inside an Expanded widget. All we need to do is wrap this widget into a Consumer widget that listens to the DonutService, and feeds the updated filteredDonuts into the DonutList upon rebuilding, accurately showing the shorten list of donuts, matching what the user selected, as such:
Initially you should see the filtered donut list only showing the "classic" donuts - matching the default selection on the DonutFilterBar. Now tap on any of the other filter bar items; the selection gets stored in the DonutService, which does the filtered based on this selection; stores the new filtered values internally and calls notifyListeners so any interested listeners (in our case, DonutList) rebuild themselves and show the relevant information.
Run it again and tap around, you should see it working as expected - performing the filtering! Eureka!!!
If you want to add yet more interactivity and slickness to your app while giving it a nice touch and make it stand from the rest (and on the cheap!), animations is the way to go.
You saw what we did on the DonutFilterBar as well as the DonutPager, where even the slightest detail made a noticeable difference. We'll do the same with the DonutList items by changing the way they get shown on the list and add some sort of animated transition as they come in.
As you saw in the image provided, the list items get displayed with a fading and sliding animated transition as they enter, so you can imagine that these two animations should be happening in parallel - as it slides in, it starts to fade in. That's exactly what we will do: add a SlidingTransition and FadeTransition widget!
Both the SlidingTransition and FadeTransition widgets are part of the explicit animations package that comes out of the box with Flutter. At a fundamental level, explicit animations provide you with controls for telling Flutter how to quickly rebuild a widget tree to create the illusion of motion.
We'll refactor the existing ListView.builder and replace it by an AnimatedList widget. The **AnimatedList widget consists of a scrolling container that animates items as they are inserted or removed. We will leverage the animated capabilites of the AnimatedList by inserting the items in a staggered fashion in order to achieve that animation where they gradually appear into view.
First, let's add a GlobalKey reference in the _DonutListState class - all AnimatedList widgets require a key in order to tap into its inserting capabilities so we can uniquely reference them outside of the build method of the enclosing widget.
Let's also create a collection of DonutModel references that will serve as the holding collection where we'll be inserting the items in a staggered manner from the original donuts collection:
Next, go to the build method and do the following replacements:
Your new AnimatedList code should look like this:
Let's now implement the logic of how to add the items to the insertedItems list in a staggered fashion. We'll achieve it by introducing a small delay between inserts (about 125 milliseconds). We'll use Futures for this.
Override the initState method in the _DonutListState class, so we can do some needed initializations:
Inside of the initState method, create a loop that runs for the length of provided donut items in the widget.donuts collection, and for each iteration, create a Future object with a 125 ms. delay, inserted in the holding collection insertedItems as well as insert the corresponding index directly to the AnimatedList (via the GlobalKey reference, i.e. key.currentState!.insertItem). This is done so that the AnimatedList knows how to reference how many items are in its internal collection in regards to the actual collection of list items to be animated:
To recap: what will happen is that:
If you try to run the code as-is now, you'll see them pop in, one after the other, with a 125 ms. time difference from each other, since there is no animation.
Let's now implement the animation and leverage all we've done so far. In order to achieve the desired effect, we will have to wrap our DonutCard inside a FadeTransition inside a SlideTransition. Let's see how it's done.
Back in the build method of the DonutList widget, inside the AnimatedList
itemBuilder callback, wrap the DonutCard being returned inside a FadeTransition widget:
Let's dissect this code for a sec:
Running the code as-is, shows us just the fading transition - the items show one at a time, at least in a smooth fading fashion.
Let's wrap it up by adding the final touch, which is adding the sliding animation. In order to make the animations run in parallel, just wrap one inside the other. In this case, wrap the current FadeTransition into a SlideTransition widget, as follows:
Dissecting the code as well, it is pretty similar to the FadeTransition widget, with the only difference that the Tween object takes an Offset (an x and y coordinate in a 2D plane) object instead of a single value (1 or 0, for opacity).
What we're stating by providing a Tween with a begin (0.2, 0.0) and an end of (0.0, 0.0) is that we want a sliding transition, where the x-axis moves from 0.2 (20% of its original position) back to 0 (its original position), while the y-axis remains constant during the transition.
Running this code once again on DartPad and you should see the full-blown animation, both sliding and fading, as the items enter the screen (behind the scene getting inserted in real-time ;)), giving it a nice animated transition.
And with that we've concluded the coding of our landing page. In the next codelab we will tackle the DonutShopDetails widget page, so keep pushing along!
Thank you for making this far - hope you've enjoyed these codelabs so far!
And with that, we wrap up codelab #2 of this series, where we accomplished the following:
In codelab #3 of this series, we'll be tackling the details page of this application, so continue your journey!
In case you fell behind on this codelab, below is the whole code for this codelab in a way you can copy / paste directly into DartPad:
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
void main() {
runApp(
MultiProvider(
providers: [
ChangeNotifierProvider(
create: (_) => DonutBottomBarSelectionService(),
),
ChangeNotifierProvider(
create: (_) => DonutService(),
)
],
child: MaterialApp(
debugShowCheckedModeBanner: false,
initialRoute: '/',
navigatorKey: Utils.mainAppNav,
routes: {
'/': (context) => SplashPage(),
'/main': (context) => DonutShopMain()
}
)
)
);
}
class SplashPage extends StatefulWidget {
@override
SplashPageState createState() => SplashPageState();
}
class SplashPageState extends State<SplashPage>
with SingleTickerProviderStateMixin {
AnimationController? donutController;
Animation<double>? rotationAnimation;
@override
void initState() {
super.initState();
donutController = AnimationController(
duration: const Duration(seconds: 5),
vsync: this)..repeat();
rotationAnimation = Tween<double>(begin: 0, end: 1)
.animate(CurvedAnimation(parent: donutController!, curve: Curves.linear));
}
@override
void dispose() {
donutController!.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
Future.delayed(const Duration(seconds: 2), () {
Utils.mainAppNav.currentState!.pushReplacementNamed('/main');
});
return Scaffold(
backgroundColor: Utils.mainColor,
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
RotationTransition(
turns: rotationAnimation!,
child: Image.network(Utils.donutLogoWhiteNoText, width: 100, height: 100),
),
Image.network(Utils.donutLogoWhiteText, width: 150, height: 150)
],
),
)
);
}
}
class DonutShopMain extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
drawer: Drawer(
child: DonutSideMenu()
),
appBar: AppBar(
iconTheme: const IconThemeData(color: Utils.mainDark),
backgroundColor: Colors.transparent,
elevation: 0,
centerTitle: true,
title: Image.network(Utils.donutLogoRedText, width: 120)
),
body: Column(
children: [
Expanded(
child: Navigator(
key: Utils.mainListNav,
initialRoute: '/main',
onGenerateRoute: (RouteSettings settings) {
Widget page;
switch(settings.name) {
case '/main':
page = DonutMainPage();
break;
case '/favorites':
page = Center(child: Text('favorites'));
break;
case '/shoppingcart':
page = Center(child: Text('shopping cart'));
break;
default:
page = Center(child: Text('main'));
break;
}
return PageRouteBuilder(pageBuilder: (_, __, ___) => page,
transitionDuration: const Duration(seconds: 0)
);
}
)
),
DonutBottomBar()
]
)
);
}
}
class DonutMainPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Column(
children: [
DonutPager(),
DonutFilterBar(),
Expanded(
child: Consumer<DonutService>(
builder: (context, donutService, child) {
return DonutList(donuts: donutService.filteredDonuts);
},
)
)
]
);
}
}
class DonutPager extends StatefulWidget {
@override
State<DonutPager> createState() => _DonutPagerState();
}
class _DonutPagerState extends State<DonutPager> {
List<DonutPage> pages = [
DonutPage(imgUrl: Utils.donutPromo1, logoImgUrl: Utils.donutLogoWhiteText),
DonutPage(imgUrl: Utils.donutPromo2, logoImgUrl: Utils.donutLogoWhiteText),
DonutPage(imgUrl: Utils.donutPromo3, logoImgUrl: Utils.donutLogoRedText),
];
int currentPage = 0;
PageController? controller;
@override
void initState() {
controller = PageController(initialPage: 0);
super.initState();
}
@override
void dispose() {
controller!.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return SizedBox(
height: 350,
child: Column(
children: [
Expanded(
child: PageView(
scrollDirection: Axis.horizontal,
pageSnapping: true,
controller: controller,
onPageChanged: (int page) {
setState(() {
currentPage = page;
});
},
children: List.generate(pages.length, (index) {
DonutPage currentPage = pages[index];
return Container(
alignment: Alignment.bottomLeft,
margin: EdgeInsets.all(20),
padding: EdgeInsets.all(30),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(30),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.2),
blurRadius: 10,
offset: Offset(0.0, 5.0)
)
],
image: DecorationImage(
image: NetworkImage(currentPage.imgUrl!),
fit: BoxFit.cover
)
),
child: Image.network(currentPage.logoImgUrl!, width: 120)
);
})
)
),
PageViewIndicator(
controller: controller,
numberOfPages: pages.length,
currentPage: currentPage,
)
],
)
);
}
}
class PageViewIndicator extends StatelessWidget {
PageController? controller;
int? numberOfPages;
int? currentPage;
PageViewIndicator({ this.controller, this.numberOfPages, this.currentPage });
@override
Widget build(BuildContext context) {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(numberOfPages!, (index) {
return GestureDetector(
onTap: () {
controller!.animateToPage(
index,
duration: const Duration(milliseconds: 500),
curve: Curves.easeInOut);
},
child: AnimatedContainer(
duration: const Duration(milliseconds: 250),
curve: Curves.easeInOut,
width: 15,
height: 15,
margin: EdgeInsets.all(10),
decoration: BoxDecoration(
color: currentPage == index ?
Utils.mainColor : Colors.grey.withOpacity(0.2),
borderRadius: BorderRadius.circular(10)
)
)
);
})
);
}
}
class DonutSideMenu extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Container(
color: Utils.mainDark,
padding: EdgeInsets.all(40),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Container(
margin: EdgeInsets.only(top: 40),
child: Image.network(Utils.donutLogoWhiteNoText,
width: 100
)
),
Image.network(Utils.donutLogoWhiteText,
width: 150
)
],
)
);
}
}
class DonutBottomBar extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Container(
padding: EdgeInsets.all(30),
child: Consumer<DonutBottomBarSelectionService>(
builder: (context, bottomBarSelectionService, child) {
return Row(
crossAxisAlignment: CrossAxisAlignment.end,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
IconButton(
icon: Icon(
Icons.trip_origin,
color: bottomBarSelectionService.tabSelection == 'main' ?
Utils.mainDark : Utils.mainColor
),
onPressed: () {
bottomBarSelectionService.setTabSelection('main');
}
),
IconButton(
icon: Icon(Icons.favorite,
color: bottomBarSelectionService.tabSelection == 'favorites' ?
Utils.mainDark : Utils.mainColor
),
onPressed: () {
bottomBarSelectionService.setTabSelection('favorites');
}
),
IconButton(
icon: Icon(Icons.shopping_cart,
color: bottomBarSelectionService.tabSelection == 'shoppingcart' ?
Utils.mainDark : Utils.mainColor
),
onPressed: () {
bottomBarSelectionService.setTabSelection('shoppingcart');
}
)
]
);
})
);
}
}
class DonutFilterBar extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Padding(
padding: EdgeInsets.all(20),
child: Consumer<DonutService>(
builder: (context, donutService, child) {
return Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: List.generate(
donutService.filterBarItems.length, (index) {
DonutFilterBarItem item = donutService.filterBarItems[index];
return GestureDetector(
onTap: () {
donutService.filteredDonutsByType(item.id!);
},
child: Container(
child: Text('${item.label!}',
style: TextStyle(
color: donutService.selectedDonutType == item.id ?
Utils.mainColor : Colors.black, fontWeight: FontWeight.bold)
)
)
);
}
)
),
SizedBox(height: 10),
Stack(
children: [
AnimatedAlign(
duration: const Duration(milliseconds: 250),
curve: Curves.easeInOut,
alignment: alignmentBasedOnTap(donutService.selectedDonutType),
child: Container(
width: MediaQuery.of(context).size.width / 3 - 20,
height: 5,
decoration: BoxDecoration(
color: Utils.mainColor,
borderRadius: BorderRadius.circular(20)
)
)
)
],
)
]
);
}
)
);
}
Alignment alignmentBasedOnTap(filterBarId) {
switch(filterBarId) {
case 'classic':
return Alignment.centerLeft;
case 'sprinkled':
return Alignment.center;
case 'stuffed':
return Alignment.centerRight;
default:
return Alignment.centerLeft;
}
}
}
class DonutList extends StatefulWidget {
List<DonutModel>? donuts;
DonutList({ this.donuts });
@override
State<DonutList> createState() => _DonutListState();
}
class _DonutListState extends State<DonutList> {
final GlobalKey<AnimatedListState> _key = GlobalKey();
List<DonutModel> insertedItems = [];
@override
void initState() {
super.initState();
var future = Future(() {});
for (var i = 0; i < widget.donuts!.length; i++) {
future = future.then((_) {
return Future.delayed(const Duration(milliseconds: 125), () {
insertedItems.add(widget.donuts![i]);
_key.currentState!.insertItem(i);
});
});
}
}
@override
Widget build(BuildContext context) {
return AnimatedList(
key: _key,
scrollDirection: Axis.horizontal,
initialItemCount: insertedItems.length,
itemBuilder: (context, index, animation) {
DonutModel currentDonut = widget.donuts![index];
return SlideTransition(
position: Tween(
begin: const Offset(0.2, 0.0),
end: const Offset(0.0, 0.0),
).animate(CurvedAnimation(parent: animation, curve: Curves.easeInOut)),
child: FadeTransition(
opacity: Tween(begin: 0.0, end: 1.0)
.animate(CurvedAnimation(
parent: animation, curve: Curves.easeInOut)
),
child: DonutCard(donutInfo: currentDonut)
)
);
}
);
}
}
class DonutCard extends StatelessWidget {
DonutModel? donutInfo;
DonutCard({ this.donutInfo });
@override
Widget build(BuildContext context) {
return Stack(
alignment: Alignment.center,
children: [
Container(
width: 150,
padding: EdgeInsets.all(15),
alignment: Alignment.bottomLeft,
margin: EdgeInsets.only(left: 10, top: 80, right: 10, bottom: 20),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 10,
offset: Offset(0.0, 4.0)
)
]
),
child: Column(
mainAxisAlignment: MainAxisAlignment.end,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('${donutInfo!.name}',
style: TextStyle(
color: Utils.mainDark,
fontWeight: FontWeight.bold,
fontSize: 15
)
),
SizedBox(height: 10),
Container(
decoration: BoxDecoration(
color: Utils.mainColor,
borderRadius: BorderRadius.circular(20),
),
padding: EdgeInsets.only(
left: 10, right: 10, top: 5, bottom: 5
),
child: Text('\$${donutInfo!.price!.toStringAsFixed(2)}',
style: TextStyle(
fontSize: 12,
color: Colors.white,
fontWeight: FontWeight.bold
)
)
)
]
),
),
Align(
alignment: Alignment.topCenter,
child: Image.network(
donutInfo!.imgUrl!,
width: 150, height: 150,
fit: BoxFit.contain
),
)
]
);
}
}
class DonutBottomBarSelectionService extends ChangeNotifier {
String? tabSelection = 'main';
void setTabSelection(String selection) {
Utils.mainListNav.currentState!.pushReplacementNamed('/' + selection);
tabSelection = selection;
notifyListeners();
}
}
class DonutService extends ChangeNotifier {
List<DonutFilterBarItem> filterBarItems = [
DonutFilterBarItem(id: 'classic', label: 'Classic'),
DonutFilterBarItem(id: 'sprinkled', label: 'Sprinkled'),
DonutFilterBarItem(id: 'stuffed', label: 'Stuffed'),
];
String? selectedDonutType;
List<DonutModel> filteredDonuts = [];
DonutService() {
selectedDonutType = filterBarItems.first.id;
filteredDonutsByType(selectedDonutType!);
}
void filteredDonutsByType(String type) {
selectedDonutType = type;
filteredDonuts = Utils.donuts.where(
(d) => d.type == selectedDonutType).toList();
notifyListeners();
}
}
class DonutFilterBarItem {
String? id;
String? label;
DonutFilterBarItem({ this.id, this.label });
}
class DonutPage {
String? imgUrl;
String? logoImgUrl;
DonutPage({ this.imgUrl, this.logoImgUrl });
}
class DonutModel {
String? imgUrl;
String? name;
String? description;
double? price;
String? type;
DonutModel({
this.imgUrl,
this.name,
this.description,
this.price,
this.type
});
}
class Utils {
static GlobalKey<NavigatorState> mainListNav = GlobalKey();
static GlobalKey<NavigatorState> mainAppNav = GlobalKey();
static const Color mainColor = Color(0xFFFF0F7E);
static const Color mainDark = Color(0xFF980346);
static const String donutLogoWhiteNoText = 'https://romanejaquez.github.io/flutter-codelab4/assets/donut_shop_logowhite_notext.png';
static const String donutLogoWhiteText = 'https://romanejaquez.github.io/flutter-codelab4/assets/donut_shop_text_reversed.png';
static const String donutLogoRedText = 'https://romanejaquez.github.io/flutter-codelab4/assets/donut_shop_text.png';
static const String donutTitleFavorites = 'https://romanejaquez.github.io/flutter-codelab4/assets/donut_favorites_title.png';
static const String donutTitleMyDonuts = 'https://romanejaquez.github.io/flutter-codelab4/assets/donut_mydonuts_title.png';
static const String donutPromo1 = 'https://romanejaquez.github.io/flutter-codelab4/assets/donut_promo1.png';
static const String donutPromo2 = 'https://romanejaquez.github.io/flutter-codelab4/assets/donut_promo2.png';
static const String donutPromo3 = 'https://romanejaquez.github.io/flutter-codelab4/assets/donut_promo3.png';
static List<DonutModel> donuts = [
DonutModel(
imgUrl: 'https://romanejaquez.github.io/flutter-codelab4/assets/donutclassic/donut_classic1.png',
name: 'Strawberry Sprinkled Glazed',
description: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Fusce blandit, tellus condimentum cursus gravida, lorem augue venenatis elit, sit amet bibendum quam neque id sapien.',
price: 1.99,
type: 'classic'
),
DonutModel(
imgUrl: 'https://romanejaquez.github.io/flutter-codelab4/assets/donutclassic/donut_classic2.png',
name: 'Chocolate Glazed Doughnut',
description: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Fusce blandit, tellus condimentum cursus gravida, lorem augue venenatis elit, sit amet bibendum quam neque id sapien.',
price: 2.99,
type: 'classic',
),
DonutModel(
imgUrl: 'https://romanejaquez.github.io/flutter-codelab4/assets/donutclassic/donut_classic3.png',
name: 'Chocolate Dipped Doughnut',
description: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Fusce blandit, tellus condimentum cursus gravida, lorem augue venenatis elit, sit amet bibendum quam neque id sapien.',
price: 2.99,
type: 'classic'
),
DonutModel(
imgUrl: 'https://romanejaquez.github.io/flutter-codelab4/assets/donutclassic/donut_classic4.png',
name: 'Cinamon Glazed Glazed',
description: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Fusce blandit, tellus condimentum cursus gravida, lorem augue venenatis elit, sit amet bibendum quam neque id sapien.',
price: 2.99,
type: 'classic'
),
DonutModel(
imgUrl: 'https://romanejaquez.github.io/flutter-codelab4/assets/donutclassic/donut_classic5.png',
name: 'Sugar Glazed Doughnut',
description: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Fusce blandit, tellus condimentum cursus gravida, lorem augue venenatis elit, sit amet bibendum quam neque id sapien.',
price: 1.99,
type: 'classic'
),
DonutModel(
imgUrl: 'https://romanejaquez.github.io/flutter-codelab4/assets/donutsprinkled/donut_sprinkled1.png',
name: 'Halloween Chocolate Glazed',
description: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Fusce blandit, tellus condimentum cursus gravida, lorem augue venenatis elit, sit amet bibendum quam neque id sapien.',
price: 2.99,
type: 'sprinkled'
),
DonutModel(
imgUrl: 'https://romanejaquez.github.io/flutter-codelab4/assets/donutsprinkled/donut_sprinkled2.png',
name: 'Party Sprinkled Cream',
description: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Fusce blandit, tellus condimentum cursus gravida, lorem augue venenatis elit, sit amet bibendum quam neque id sapien.',
price: 1.99,
type: 'sprinkled'
),
DonutModel(
imgUrl: 'https://romanejaquez.github.io/flutter-codelab4/assets/donutsprinkled/donut_sprinkled3.png',
name: 'Chocolate Glazed Sprinkled',
description: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Fusce blandit, tellus condimentum cursus gravida, lorem augue venenatis elit, sit amet bibendum quam neque id sapien.',
price: 1.99,
type: 'sprinkled'
),
DonutModel(
imgUrl: 'https://romanejaquez.github.io/flutter-codelab4/assets/donutsprinkled/donut_sprinkled4.png',
name: 'Strawbery Glazed Sprinkled',
description: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Fusce blandit, tellus condimentum cursus gravida, lorem augue venenatis elit, sit amet bibendum quam neque id sapien.',
price: 2.99,
type: 'sprinkled'
),
DonutModel(
imgUrl: 'https://romanejaquez.github.io/flutter-codelab4/assets/donutsprinkled/donut_sprinkled5.png',
name: 'Reese\'s Sprinkled',
description: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Fusce blandit, tellus condimentum cursus gravida, lorem augue venenatis elit, sit amet bibendum quam neque id sapien.',
price: 3.99,
type: 'sprinkled'
),
DonutModel(
imgUrl: 'https://romanejaquez.github.io/flutter-codelab4/assets/donutstuffed/donut_stuffed1.png',
name: 'Brownie Cream Doughnut',
description: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Fusce blandit, tellus condimentum cursus gravida, lorem augue venenatis elit, sit amet bibendum quam neque id sapien.',
price: 1.99,
type: 'stuffed'
),
DonutModel(
imgUrl: 'https://romanejaquez.github.io/flutter-codelab4/assets/donutstuffed/donut_stuffed2.png',
name: 'Jelly Stuffed Doughnut',
description: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Fusce blandit, tellus condimentum cursus gravida, lorem augue venenatis elit, sit amet bibendum quam neque id sapien.',
price: 2.99,
type: 'stuffed'
),
DonutModel(
imgUrl: 'https://romanejaquez.github.io/flutter-codelab4/assets/donutstuffed/donut_stuffed3.png',
name: 'Caramel Stuffed Doughnut',
description: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Fusce blandit, tellus condimentum cursus gravida, lorem augue venenatis elit, sit amet bibendum quam neque id sapien.',
price: 2.59,
type: 'stuffed'
),
DonutModel(
imgUrl: 'https://romanejaquez.github.io/flutter-codelab4/assets/donutstuffed/donut_stuffed4.png',
name: 'Maple Stuffed Doughnut',
description: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Fusce blandit, tellus condimentum cursus gravida, lorem augue venenatis elit, sit amet bibendum quam neque id sapien.',
price: 1.99,
type: 'stuffed'
),
DonutModel(
imgUrl: 'https://romanejaquez.github.io/flutter-codelab4/assets/donutstuffed/donut_stuffed5.png',
name: 'Glazed Jelly Stuffed Doughnut',
description: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Fusce blandit, tellus condimentum cursus gravida, lorem augue venenatis elit, sit amet bibendum quam neque id sapien.',
price: 1.59,
type: 'stuffed'
)
];
}