I Am Rick (Episode 10 — Finale): Rick’s To-Do List

How Rick manages state in Flutter using the provider package.

Alexandros Baramilis
Flutter Community
23 min readJun 4, 2020

--

Intro

If you haven’t been following the series so far, you can check out the previous episodes here:

or have a look at the Github repo for the series.

If you’re having trouble installing Flutter on macOS Catalina, check out Setting up Flutter on macOS Catalina.

As to why I’m writing this series:

The best way to learn something is to put it into practice and then write about it, so I’m making this series based on the notes that I take as I go through App Brewery’s Flutter Bootcamp. I usually go above and beyond the course to make something cool and learn more stuff. Publishing my notes and code also forces me to maintain a high standard and allows me to easily go back for a quick review.

As for the Rick Grimes theme, it all started from a typo when I was making the first app in the series (I Am Rich). I accidentally typed ‘I Am Rick’ when I was setting up the project in Android Studio. The rest is history.

Rick’s To-Do List

Flashback:

When Rick was still in Alexandria, managing the return of human civilisation, there were a ton of jobs to do.

What better way to use his coding skills than to make a cool to-do list Flutter app to manage it all!

Today I finished the last module in App Brewery’s Flutter bootcamp.

Which means that Rick won’t be returning to you anytime soon. 😭

Except maybe for the big screen when they release the new movies.

That is, after the virus is controlled and life returns back to normal, which might take a long time.

In the meantime, we’ll keep coding!

Today, we’ll make this app:

You can add tasks, check/uncheck tasks and delete tasks (by dragging them left or right).

Along the way, we’ll explore the following topics:

  • SafeArea with different background colours at the top and bottom of the app.
  • How to use ListView.builder() to only render the visible list elements and save resources.
  • Passing state up and down the widget tree the vanilla way, with setState() and callbacks.
  • Properly formatting callbacks to avoid mistakes in your code or app’s behaviour.
  • How to show a BottomSheet with rounded corners and transparent background.
  • Managing state with the provider package, simplifying our code and allowing us to scale the app more efficiently.

And I promise it won’t be as long as the last episode!

First, we’re going to build the app using only vanilla code, passing the state up and down the widget tree using setState() and callbacks. At the end, we’ll refactor our code using the provider package.

In the code files that were modified during the refactoring, I kept the original code commented out. You can toggle between the pre/post-refactoring code by commenting out the // Before Provider or // After Provider sections respectively.

For convenience, I also made two folders on Github, lib (vanilla) and lib (provider), with the appropriate code commented out.

Grab the code and let’s get started!

The Dashboard and the top safe area

We’ll start with the easiest component, what I named the Dashboard.

First, let’s enter at main.dart.

Nothing unusual here, we start our MaterialApp, set the ThemeData and TasksScreen as our starting screen.

Inside TasksScreen, which is a stateful widget, we create the tasks variable, which is a List of Task objects. This variable will hold our state, i.e. the data that we need to power the app.

We initialise it with some sample data, coming from the static method sampleTasks() from Task.

Task is our model class. It takes a required positional parameter that will hold the task’s title and a named boolean parameter that will hold the checked state of the task, set to false if it’s not provided, as all tasks are not completed at the point of creation.

Now, inside TasksScreen, we create a Scaffold and for its body property, we create a Column, with a Dashboard and TaskList children (we’ll come back to TaskList later).

The Dashboard custom widget takes a numberOfTasks parameter that we set to the length of the tasks List, i.e. the number of tasks that we have.

That’s a way of passing state down the widget tree.

Inside the build method of Dashboard, we return a SafeArea with its bottom property set to false. This is because we only need the top padding provided by SafeArea in the Dashboard widget. We’ll take care of SafeArea’s bottom padding inside the TaskList widget later.

Why not use a SafeArea that contains both widgets? Because we need a different background colour behind each area reserved by SafeArea.

The top area of SafeArea
The bottom area of SafeArea

For SafeArea’s child, we create a Column with the rest of our widgets.

Nothing that we haven’t seen before here, our Text widgets, a CircleAvatar widget to show the Icons.list icon and some SizedBoxes for spacing.

The TaskList, TaskListItem and ListView.builder()

The second component in the main Column of TasksScreen, is TaskList.

Here, we use callbacks to pass state up the widget tree.

When a task is tapped inside TaskList, we use the onTaskTapped callback to send the index of the list item that was tapped up into TasksScreen, so we can change its isCompleted property to the opposite boolean value. This is how we toggle the “checked” status of the task.

When a task is dragged inside TaskList, we use the onTaskDragged callback to send the index of the list item that was dragged up into TasksScreen, so we can remove it from the tasks list. This is how we delete tasks.

This is how we declare the properties inside TaskList:

Then, inside the build method, we return an Expanded widget, so that the list takes up all the remaining screen space left by the Dashboard.

Inside the Expanded widget, we have a BottomPaddedCard widget, which I’ll go through in the next section, and inside the BottomPaddedCard widget, we have a ListView element, that we initialise by using its builder() constructor.

This builder constructor is only called for list items that are visible, so it saves a lot of resources if you have a big list with many elements that are not visible.

You need to specify the itemCount property, so the constructor knows how many elements it needs to deal with. This is easily set to tasks.length .

Then you need to specify an itemBuilder, which is what the builder will build for each item in the list.

Here we return a TaskListItem, setting its title and its isChecked properties, as well as the onItemTapped and onItemDragged callbacks, so we can pass the state up from two layers deep in the widget tree.

Inside TaskListItem’s build method, we return a GestureRecogniser that will give us access to the onTap and onHorizontalDragEnd callbacks, so we can detect taps and also when the user drags any task to the left or right, to check/uncheck or delete items respectively.

Inside the GestureRecognizer we have a Container and a Row. The Row has the Icon that gets toggled depending on the isChecked value, as well as a Text widget that is wrapped inside a Padding and inside a Flexible widget. The Flexible widget allows the text to wrap into another line if it’s too long. We also set the text style accordingly, so that the text is crossed when the task is checked.

Properly formatting callbacks

I want to make a note about callbacks here to avoid a certain mistake that I did and took me a while to figure out what was happening.

In the GestureRecognizer above, we set the onTap property to our onItemTapped callback.

We do this like: onTap: onItemTapped, without parentheses, which is fine, because we are just passing the whole function by name.

If however, we include parentheses here, like: onTap: onItemTapped() , this will run the onItemTapped function with every rebuild, which is not what we want!

It’s like having a child property that we set to a method to build a certain widget, like for example: child: buildMyChildWidget(), . This method runs every time there is a rebuild.

What we want here is just to set the onTap function to our onItemTapped function, so that the code inside onItemTapped only runs when there is a tap. For this, we need to write it without parentheses.

Under the onTap, we also have the onHorizontalDrag property. Here, we set it to: onHorizontalDrag: (details) => onItemDragged() .

We can’t just set: onHorizontalDrag: onItemDragged, here, because the onHorizontalDrag callback requires this details parameter, which is of type DragEndDetails and gives us details about the drag. I’m not making any use of it here, but still it’s required, so we need to include it like this.

onHorizontalDrag: (details) => onItemDragged() is just shorthand for:

onHorizontalDrag: (details) {
onItemDragged();
},

Here, because onItemDragged(); is inside the function body, we have to use parentheses. We’re basically setting the onHorizontalDrag property to a function that will take the drag details as a parameter and then execute the onItemDragged callback.

When it might get more confusing, is when there is no required parameter from the property that we are trying to set, but we want to pass a parameter with our callback.

For an example, let’s go one level up to TaskList.

Inside the itemBuilder, we’re returning a TaskListItem, and setting its onItemTapped and onItemDragged properties.

Here, you might by tempted by the way we wrote onTap: onItemTapped, earlier, and write:

onItemTapped: onTaskTapped(index),
onItemDragged: onTaskDragged(index),

You wouldn’t get any compiler errors, but if you try to run the app it would not work.

This is because here, you’re calling the onTaskTapped and onTaskDragged callbacks, every time there is a rebuild!

In this case you will get an exception, but in other cases it could be worse because you might not even get an exception but have some weird behaviour in your app that doesn’t make any sense.

So it’s worth checking that all your callbacks are correctly formatted.

Here, the right way to do this is like:

onItemTapped: () => onTaskTapped(index),
onItemDragged: () => onTaskDragged(index),

Which is shorthand for:

onItemTapped: () {
onTaskTapped(index);
},
onItemDragged: () {
onTaskDragged(index);
},

Here, we set the onItemTapped and onItemDragged properties, to functions that take no parameters, and inside the function body execute the onTaskTapped(index); and onTaskDragged(index); callbacks, passing the relevant index. So they will only get called when there is a tap or a drag.

The BottomPaddedCard widget and the bottom safe area

Now let’s get back to this BottomPaddedCard widget.

This is a widget that I made to create a card-like container with rounded corners. It’s called BottomPaddedCard because it also includes the bottom padding of the SafeArea that we said we’re going to deal with here.

It takes a child property that can be any descendant of Widget, as well as a padding property for some optional extra padding if needed.

Inside the build method we return a Container. For its padding property, if the padding property of BottomPaddedCard was set and therefore not null, we make a copy of it by using the copyWith() method, adding some additional padding to the bottom. If the padding property wasn’t set, it will be null, and we just give some padding to the bottom.

This MediaQuery.of(context).padding.bottom gives us the amount of padding that a SafeArea widget would have.

Since we can’t set the background colour of SafeArea directly, our only option for customising the SafeArea’s background colour is to set the backgroundColour property of the Scaffold, or, like in this app, set the scaffoldBackgroundColor of the ThemeData widget assigned to MaterialApp’s theme property.

But since we only have one Scaffold, we can only have one Scaffold background colour, which we already used as the background of the SafeArea above the Dashboard.

So a good solution to keep the padding of the SafeArea but with a custom colour, is to just use the padding of the safe area through this MediaQuery and keep the background colour of the Container.

The background colour of the Container is specified inside the BoxDecoration widget that we set to its decoration property.

The color property of the Container is actually just a convenience property, so we don’t have to set decoration, BoxDecoration, etc if we only need to set the background colour. Therefore you can only set one or the other.

So since we need to set decoration in order to modify the border radius, we need to set the color property inside BoxDecoration.

We also set the borderRadius property to only have topLeft and topRight circular radius.

So now we get this nice card-like design:

We’ll use BottomPaddedCard again when we’re building the AddTaskScreen.

EDIT: I actually decided that I don’t need the bottom SafeArea in this app, since I like to have the list items go all the way to the bottom, and after some testing I saw that they don’t interfere much with the home button. However, I’m leaving this solution here as it is quite handy if you indeed need to have a safe area both at the top and bottom of the app, but with different background colours. If you don’t need the bottom safe area, just set the Container’s padding property to the BottomPaddedCard’s padding property.

Showing a BottomSheet with showModalBottomSheet and displaying the AddTaskScreen

We’re trying to build this nice bottom sheet here that sits right above the keyboard, looks like another card and darkens the background.

We can dismiss it by tapping on the Add button, or if we don’t want to add a task, by tapping on the darkened background or dragging the sheet down.

As we saw before, inside the build method of TasksScreen, we’re returning a Scaffold.

We set the floatingActionButton property of the Scaffold to a FloatingActionButton. This is a button that will sit above everything else that we have on screen.

For the onPressed property of the button, we use the showModalBottomSheet function. According to the documentation for BottomSheet, “A modal bottom sheet is an alternative to a menu or a dialog and prevents the user from interacting with the rest of the app. Modal bottom sheets can be created and displayed with the showModalBottomSheet function.”

The other option is to create a persistent bottom sheet: “A persistent bottom sheet shows information that supplements the primary content of the app. A persistent bottom sheet remains visible even when the user interacts with other parts of the app. Persistent bottom sheets can be created and displayed with the ScaffoldState.showBottomSheet function or by specifying the Scaffold.bottomSheet constructor parameter.”

Here we just want a temporary bottom sheet to add a new task and then dismiss it. So make sure you type showModalBottomSheet and not showBottomSheet, or you’ll be trying to figure out why it doesn’t work like it’s supposed to. (I did that mistake too…)

showModalBottomSheet takes a context and a builder property to build a customised bottom sheet.

Inside AddTaskScreen, we’re going to use the BottomPaddedCard again to make a card widget with rounded corners. But the BottomSheet has its own background, so the space next to the rounded corners will look like this:

Here, Angela (the App Brewery instructor), uses a bit of a hacky trick with a colour picker, to get the colour of the shaded list under the bottom sheet.

It works, but I think it’s more robust if we just set the backgroundColor property of showModalBottomSheet to Colors.transparent. This will work even if we change the colour of the list, or the Flutter team decides to change the colour of the shade.

Now it’s as if the whole bottom sheet had rounded corners:

The next thing we want to accomplish, is for the BottomSheet to sit right above the keyboard.

For that, we need to set the isScrollControlled property of showModalBottomSheet to true and then wrap our AddTaskScreen widget, inside a SingleChildScrollView widget.

The last thing we need to do is to set the bottom padding to match the height of the keyboard.

If we look inside AddTaskScreen’s build method, we see that we return a BottomPaddedCard widget.

Here, we make use of the optional padding property that we created before. If you kept the code inside BottomPaddedCard that adds the bottom padding of the safe area, this code below will add the extra padding in addition to that bottom padding.

To get the padding required to fit the keyboard, we set the bottom padding to MediaQuery.of(context).viewInsets.bottom

Now the BottomSheet will pop up together with the keyboard.

Here’s the code for the main Column widget, that we pass as a child to the BottomPaddedCard.

  • We have the Text with the ‘Add Task’ title.
  • A TextField set to capitalise sentences. The autofocus property is set to true, which will make the TextField go into edit mode when the bottom sheet comes up, flashing the cursor and popping up the keyboard. The minLines and maxLines are set to 1 and 3 so the TextField can show up to 3 lines of text at the same time. The onChanged callback will set the text variable to the value in the textfield whenever the value changes.

Here we can see the text variable which lives inside the _AddTaskScreenState class, and the onTaskAdded callback that lives inside AddTaskScreen and is set during initialisation from TasksScreen.

  • Finally, we have a FlatButton, where we set the onPressed callback to:
onPressed: () {
widget.onTaskAdded(text);
Navigator.pop(context);
},

By using the widget keyword, we tap into AddTaskScreen’s properties from inside _AddTaskScreenState.

Inside this callback, we execute the onTaskAdded(text); function that was passed from TasksScreen, passing it the text value of the textfield and then we pop the bottom sheet.

If you remember back in TasksScreen, we set the onTaskAdded callback to this:

So whenever the user taps on the ‘Add’ button, we add a new Task to the tasks list and refresh the UI with setState().

So now we have all the functions working.

We can add new tasks, check/uncheck tasks and delete tasks.

Refactoring the app with the provider package

In this section, we’ll refactor our code so we can manage the state of our app with the provider package.

The app functionality will be exactly the same, but behind the scenes our code will be more readable and scalable.

Before we begin, a few words about why we’re doing this.

For this to-do list app it might be not very obvious, but imagine trying to manage the code behind a super-complex app, like the Facebook app for example, with hundreds of screens, widgets, passing data up and down the widget tree from layer to layer, and cluttering all the intermediate layers with properties and callbacks that they don’t even care about.

Your code will become very hairy, unreadable, unmaintainable and unupgradable (does this word even exist?) 🙀

As apps have scaled in size and complexity, savvy developers have come up with various design patterns, or architectures to manage how an app is built, so our poor human brains are able to grasp how the code works, without performing billions of calculations per second!

And then the design pattern wars started. Which is the best design pattern? Is there a design pattern to rule them all?

Apparently not, as a winner hasn’t emerged yet to make all the others go extinct.

It really depends on the kind of framework you use, your app’s requirements as well as its size and complexity, its maturity and its style.

But as the Flutter team says: “if you are new to Flutter and you don’t have a strong reason to choose another approach, you should probably start with the provider package.

The provider package is easy to understand and it doesn’t use much code. It also uses concepts that are applicable in every other approach.

That said, if you have a strong background in state management from other reactive frameworks, you can find packages and tutorials listed on the options page.”

A big problem that provider solves, is prop drilling, or property drilling.

Imagine you need to pass some data from the top level of the widget tree to the bottom level. Without provider, you have to pass it down through every intermediate level, creating, initialising and updating a variable at each level, as well as rebuilding all the widgets, etc, for a variable that those levels don’t even need or care about. This leads to unnecessary complexity and wasted resources.

With provider, you move the state to the top level, but you can have widgets that subscribe to that state from anywhere in the widget tree. So the data is passed directly from the top level to the widget that requires it, skipping all the levels where it’s not needed, leaving your code clearer and avoiding rebuilding all those intermediate widgets unnecessarily.

Let’s see it in practice.

You can grab the refactored code here if you want to have it open in another tab. You can even open the original code next to it so you can compare the changes.

First, get the provider package from pub.dev, install it as usual and import it at the top of the files we’re gonna be using it in.

Note that since the Flutter bootcamp course was released, provider has been upgraded to v4.0.0, but there is a handy migration guide in the package page.

In main.dart, we wrap our MaterialApp inside a ChangeNotifierProvider widget. This is a provider object that notifies all its subscribers about changes in state so they can rebuild themselves.

We put it up here as it’s the highest level in our app, so it will allow any widget in the app to be able to subscribe to the state.

We can use the create property to pass a function that takes the current context and returns the data that we want to subscribe to.

Here, the data that we return as the state, is a TaskData object.

It has a private variable called _tasks that will hold our list of Task objects. I will explain in a bit why we made it private.

I also moved the static method sampleTasks from Task to TaskData in order to initialise the _tasks list with the sample tasks.

Since _tasks is private, we have a getter method called getTasks() that returns the _tasks list, as well as a getTask(int index) method that return a Task at a specific index. We also have a numberOfTasks() method that just returns the number of tasks in the list.

These three methods just help us to get the state.

We also have three methods that modify the state.

  • addTask(String text) adds a Task to the list with the given text.
  • toggleCheck(int index) toggle the isCompleted property of the Task at the given index.
  • deleteTask(int index) deletes the Task from the list at the given index.

After each modification of the state, we call the function notifyListeners().

This function will notify all subscribers to the state that the state has changed, so they can rebuild with the new state. So it’s important to remember to call this method, otherwise you won’t see any changes in your UI.

If you have a keen eye, you might have noticed that this TaskData class extends ChangeNotifier.

ChangeNotifier is a Flutter class that can be extended or mixed in that provides a change notification API using VoidCallback for notifications. What this means is that by inheriting from this class, we are able to listen for changes in the TaskData object.

So it’s also important to remember that you need a custom class that extends ChangeNotifier in order to be able to subscribe to it.

Then we move on to the TasksScreen, which is our home screen. You can see that the code has already simplified a lot and is much more readable.

First, we have changed this widget from a StatefulWidget to a StatelessWidget, since it doesn’t need to hold the state anymore.

Second, we have removed everything that had to do with drilling state down into further widgets, or lifting state up with callbacks and using setState() to trigger a rebuild.

The TasksScreen doesn’t care about these things anymore. 😅

Let’s go into the Dashboard to see what’s happening now.

First, we have removed the numberOfTasks property, as well as the constructor.

The only other thing that’s changed, is how the Text widget gets the data about the number of tasks.

Instead of passing it down from widget to widget, we just subscribe to the state object and we get notified whenever there is a change.

To subscribe to the state we call Provider.of<TaskData>(context) and then we call on it the .numberOfTasks() method to get the number of tasks in the list.

Whenever the notifyListeners() function is called because some data has changed, this widget will also rebuild with the new data.

Easy.

Let’s go to our other main component, the TaskList.

Here, we have also removed all the properties, callbacks and the constructor.

But there’s a small difference in how we subscribe to the state here.

Instead of typing every time Provider.of<TaskData>(context).getTask(index).name , Provider.of<TaskData>(context).getTask(index).isCompleted , Provider.of<TaskData>(context).toggleCheck(index) , Provider.of<TaskData>(context).deleteTask(index) , which is very wordy, we wrap everything in a Consumer widget.

The Consumer widget takes a builder property, which is a function that takes as parameters the current context, the data (or any name that you want to give to the data that is returned by Provider.of<TaskData>(context) ) and a child parameter. Inside, we return what we had before in the builder method.

The difference now is that instead of typing Provider.of<TaskData>(context) every time, we can just type data .

Here, we have left the TaskListItem as is, and are providing it with the properties and callback method that it requires.

Finally, we have the AddTaskScreen.

Here, we have removed the onTaskAdded callback and the constructor and instead, inside the onPressed method of the FlatButton, we call Provider.of<TaskData>(context, listen: false).addTask(text); .

So whenever the user taps on the ‘Add’ button, we call the addTask(text) method, which adds a new Task to the list and also calls notifyListeners(), so all subscribers can update their widgets.

You might have noticed here that I also set the listen property of the provider of method to false.

The listen property is an optional property that can be set to false (default is true) when we don’t want our widget to rebuild with every update of the provider. If it’s set to false, it will just get the initial value, but not subsequent updates.

Here however, we have to set it to false, otherwise we get this exception:

According to the note in the error: “It is unsupported because may pointlessly rebuild the widget associated to the event handler, when the widget tree doesn’t care about the value.”

In this case, indeed, we don’t want the FlatButton to rebuild based on new data, we just want to call this method whenever the user taps the button.

A final note that I said I would come back to is: Why did we make the _tasks variable private?

If you don’t remember how to make a variable private, we just put an underscore _ in front of it.

Let’s say in the onPressed callback above you do this: Provider.of<TaskData>(context, listen: false).tasks.add(Task(text)); , instead of Provider.of<TaskData>(context, listen: false).addTask(text); .

Would this work?

It would update the _tasks list, but it wouldn’t update the UI, so it wouldn’t show the new task until there was another rebuild triggered somehow.

This is because we haven’t called notifyListeners(). And we can’t just call it from anywhere. It has to be called from inside the class that extends ChangeNotifier. This is why we need to call a method, so we can call notifyListeners() right after the code that modifies the state, so the UI can be rebuilt with the new data.

So we made _tasks private to prevent our future selves from accessing the _tasks variable and making this mistake.

Now, if you want to be more thorough, you might ask, can’t we still do: Provider.of<TaskData>(context, listen: false).getTasks().add(Task(text)); ?

You can and it will be the same mistake, although more unlikely. But if you want to further fool-proof your future self, you can use this widget from the dart:collection library, called UnmodifiableListView, which is, as the name implies, an unmodifiable view of another list. Which means you can look, but you can’t touch!

So you can remove the getTasks() and getTask(int index) methods and replace them with this getter:

Now you can tap into the _tasks list, but only for reading.

You also have to modify the code in TaskList like this:

But now, if you try to do Provider.of<TaskData>(context, listen: false).tasks.add(Task(text)); you will get an error. Not at compile-time, but at runtime, but if you’re adequately testing your app you should be able to catch it.

And that’s it!

As you can see, it doesn’t take much code to do state management with provider and it greatly simplifies our code.

It’s also much natural to think about in my opinion, instead of having to wrestle with passing your state up and down the tree using callbacks, setState(), etc.

That brings us to the end of the Flutter bootcamp and the end of the I Am Rick series.

Sad times.

If you read it, I hope you enjoyed it as much as I enjoyed writing it!

Last but not least, a big thank you to Angela Yu, App Brewery’s instructor, for making such an amazing and detailed course and always keeping it fun, and a big thank you to the Flutter team of course for making such an awesome framework. I feel much more confident now in using Flutter in the wild!

https://www.twitter.com/FlutterComm

--

--