To-Do List App with Flutter & Firebase
Alongside the usual ‘Hello World’ to get started with a programming language, the to-do list app is probably one of the most common projects newbies start with to familiarise themselves with a new tech stack. It’s right up there with the clock app and calculator app. And with good reason. There’s a lot to learn from implementing these mini projects.
Before we get to the code, what are some of the things we hope to achieve with this app? We can consider the below features. We need to be able to:
- Add a new task though an action button. (Create)
- View the list of all added tasks. (Read)
- Update a task to include or remove some info. (Update)
- Delete a task once it’s done. (Delete)
Just from listing the CRUD operations above, we can see that we’ll need a form of data storage — a database. Whether it’s a relational DB e.g. SQLite or non-relational e.g. Firebase or MongoDB. For this project, we’ll use Firebase.
At the end of this tutorial, we should have a working to-list app such as the one shown below:
Let’s start!
Create a new Flutter project and edit the main.dart file to remove the default starter code.
flutter create — org com.flutter todo_list
I proceeded to implement my UI and added a BottomAppBar. I shared how to implement this in this post. I also added an action icon on the appBar.
appBar: AppBar(
centerTitle: true,
title: const Text("To-Do List"),
actions: [
IconButton(
onPressed: () {},
icon: const Icon(CupertinoIcons.calendar),
),
],
),
So this is what our UI is looking like now …
The Floating Action Button (FAB) will be responsible for displaying a customised alert dialog box when clicked. This dialog box will contain our form whose input and submission will create the tasks.
class _AddTaskAlertDialogState extends State<AddTaskAlertDialog> {
@override
Widget build(BuildContext context) {
var width = MediaQuery.of(context).size.width;
var height = MediaQuery.of(context).size.height;
return AlertDialog(
scrollable: true,
title: const Text(
'New Task',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 16, color: Colors.brown),
),
content: SizedBox(
height: height * 0.35,
width: width,
child: Form(
child: Column(
children: <Widget>[
TextFormField(
style: const TextStyle(fontSize: 14),
decoration: InputDecoration(
contentPadding: const EdgeInsets.symmetric(
horizontal: 20,
vertical: 20,
),
hintText: 'Task',
hintStyle: const TextStyle(fontSize: 14),
icon: const Icon(CupertinoIcons.square_list, color: Colors.brown),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(15),
),
),
),
const SizedBox(height: 15),
TextFormField(
keyboardType: TextInputType.multiline,
maxLines: null,
style: const TextStyle(fontSize: 14),
decoration: InputDecoration(
contentPadding: const EdgeInsets.symmetric(
horizontal: 20,
vertical: 20,
),
hintText: 'Description',
hintStyle: const TextStyle(fontSize: 14),
icon: const Icon(CupertinoIcons.bubble_left_bubble_right, color: Colors.brown),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(15),
),
),
),
const SizedBox(height: 15),
Row(
children: const <Widget>[
Icon(CupertinoIcons.tag, color: Colors.brown),
SizedBox(width: 15.0),
TaskTags(),
],
),
],
),
),
),
actions: [
ElevatedButton(
onPressed: () {},
style: ElevatedButton.styleFrom(
primary: Colors.grey,
),
child: const Text('Cancel'),
),
ElevatedButton(
onPressed: () {},
child: const Text('Save'),
),
],
);
}
}
DB Connection
In my previous article, I detailed how to connect a Flutter application to Firebase. (So, go through that as we’re picking up from where that ended)
Once you have linked the two successfully, go to the Firestore Database on the left panel of your app dashboard.
Click on create database, select start in test mode and click next.
For the location for the server, I went with the default. Then click on enable. After the provisioning of the server is complete, click start collection to create a new collection for your data.
Create ( C )
With the UI ready and the database connection successful, let’s proceed to capture the information and store it in our collection. Ensure you have added the cloud_firestore dependency in your pubspec.yaml file and run flutter pub get to fetch it.
cloud_firestore: ^3.4.4
Add text editing controllers and use them in their required text fields. This will allow us to get the inputs from the text fields. Pass the text from the controllers to their respective variables within the button.
final TextEditingController taskNameController = TextEditingController();
final TextEditingController taskDescController = TextEditingController();...ElevatedButton(
onPressed: () {
final taskName = taskNameController.text;
final taskDesc = taskDescController.text;
final taskTag = selectedValue;
_addTasks(taskName: taskName, taskDesc: taskDesc, taskTag: taskTag);
},
child: const Text('Save'),
),
Finally, create a method that will be invoked when the save button is clicked. We’ll be capturing the task name, task description and task tag as per the alert dialog triggered when we want to add a new task.
DocumentReference docRef = await FirebaseFirestore.instance.collection('tasks').add(
{
'taskName': taskName,
'taskDesc': taskDesc,
'taskTag': taskTag,
},
);
Once this is successfully set up, we can test! Push test data and view the Firebase collection for the real-time update. (The create method generates a doc id which can be used in the future methods e.g. to update the tasks as discussed in the third method.)
Read ( R )
Now that we have been able to post/create data in our DB, we need to find a way to read it and display it in our interface.
We start by getting an instance of Firestore and then use it to make reference to the collection we created.
final fireStore = FirebaseFirestore.instance;
Since we mostly expect to have more than one task at a time, the ideal way to display them would be in a ListView widget. So that’s what we’ll be returning in the build method. To read data from a collection, we can either read it once using FutureBuilder or have it updated in real time using StreamBuilder. We’ll go with the later — because it’s better to have this list updated dynamically as more tasks are added in the collection.
StreamBuilder(
stream: db.collection('tasks').snapshots(), // fetch the collection name i.e. tasks
),
The QuerySnapshot contains the results of a query. It can contain zero or more DocumentSnapshot objects. To access and get the list of all the documents included in the snapshot, call the docs property.
Widget build(BuildContext context) {
return Container(
margin: const EdgeInsets.all(10.0),
child: StreamBuilder<QuerySnapshot>(
stream: fireStore.collection('tasks').snapshots(),
builder: (context, snapshot){
return ListView(
children: snapshot.data!.docs.map((DocumentSnapshot document) {
Map<String, dynamic> data = document.data()! as Map<String, dynamic>;
return Container(
height: 100,
margin: const EdgeInsets.only(bottom: 15.0),
child: ListTile (
title: Text(data['taskName']),
subtitle: Text(data['taskDesc']),
isThreeLine: true,
),
);
}).toList(),
);
},
),
);
}
The above code will work perfectly to fetch the data and display it in a ListView. I added a few beautifying elements such as adding color to represent the different task tags
Color taskColor = AppColors.blueShadeColor;
var taskTag = data['taskTag'];
if (taskTag == 'Work') {
taskColor = AppColors.salmonColor;
} else if (taskTag == 'School') {
taskColor = AppColors.greenShadeColor;
}
I also added a BoxDecoration for the ListView to have it present more like a card.
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(15.0),
color: Colors.white,
boxShadow: const [
BoxShadow(
color: AppColors.shadowColor,
blurRadius: 5.0,
offset: Offset(0, 5), // shadow direction: bottom right
),
],
),
Final ListView with three items in our tasks collection.
Update ( U )
Before we implement this third method, it’s important to update the create method to include the id in the tasks. This is also done using the update method. We include this update method while creating the task because it helps to uniquely identify each task as we create it in the db. The task id is automatically created with the add method, so what the below implementation does, is to simply update the created id within the task details.
String taskId = docRef.id;
await FirebaseFirestore.instance.collection('tasks').doc(taskId).update(
{'id': taskId},
);
Now, onto the third step which is to update our tasks. At the moment, it is possible to do this from the Firestore dashboard. However, users don’t have access to this dashboard. They’ll therefore need to be able to edit (update) the tasks from within the app.
I struggled a bit (psyche, a lot) with this part. This is because unlike the create method, I needed to have the data present in the form for the update method — this is typically how the update/edit should work in an app e.g. to change a typo, you’d need the previous entered text to be persistent in the text field allowing you to just edit the required word. After numerous trials and errors, this is how I managed to achieve that.
I implemented the update function in a different class from where the tasks are displayed. That is; the alert dialog triggered when the edit value in the PopupMenuItem is selected, is created from a different class. Therefore, I needed to pass the values between the two classes.
The snippet below shows what happens when the edit value is selected: The task id, task name, task description and task tag are passed to the UpdateTaskAlertDialog class. Thus, the alert dialog will pop up with the string values already populated
PopupMenuItem( //pop up menu item has two values: edit and delete
value: 'edit', //when value is edit, proceed to change the string values
child: const Text(
'Edit',
style: TextStyle(fontSize: 13.0),
),
onTap: () {
String taskId = (data['id']);
String taskName = (data['taskName']);
String taskDesc = (data['taskDesc']);
String taskTag = (data['taskTag']);
Future.delayed(
const Duration(seconds: 0),
() => showDialog( //opens an alert dialog box with the strings already populated
context: context,
builder: (context) => UpdateTaskAlertDialog(taskId: taskId, taskName: taskName, taskDesc: taskDesc, taskTag: taskTag,),
),
);
},
),
Within the UpdateTaskAlertDialog class, we receive the passed strings from the previous class which populate the selected fields.
class UpdateTaskAlertDialog extends StatefulWidget {
final String taskId, taskName, taskDesc, taskTag;
const UpdateTaskAlertDialog(
{Key? Key, required this.taskId, required this.taskName, required this.taskDesc, required this.taskTag})
: super(key: Key);
@override
State<UpdateTaskAlertDialog> createState() => _UpdateTaskAlertDialogState();
}
Because we want the text fields to already have the values from the database, assign the text controllers the values passed from the task class to the update class. As for the dropdown list capturing the task category, have the value set as the taskTag.
final TextEditingController taskNameController = TextEditingController();
final TextEditingController taskDescController = TextEditingController();...taskNameController.text = widget.taskName;
taskDescController.text = widget.taskDesc;...value: widget.taskTag
Then, within the update button, collect the new values for the three entries. Those values are then passed to the firebase update method.
ElevatedButton(
onPressed: () {
final taskName = taskNameController.text;
final taskDesc = taskDescController.text;
var taskTag = '';
selectedValue == '' ? taskTag = widget.taskTag : taskTag = selectedValue;
_updateTasks(taskName, taskDesc, taskTag);
Navigator.of(context, rootNavigator: true).pop();
},
child: const Text('Update'),
),...Future _updateTasks(String taskName, String taskDesc, String taskTag) async {
var collection = FirebaseFirestore.instance.collection('tasks'); // fetch the collection name i.e. tasks
collection
.doc(widget.taskId) // ensure the right task is updated by referencing the task id in the method
.update({'taskName': taskName, 'taskDesc': taskDesc, 'taskTag': taskTag}) // the update method will replace the values in the db, with these new values from the update alert dialog box
.then( // implement error handling
(_) => Fluttertoast.showToast(
msg: "Task updated successfully",
toastLength: Toast.LENGTH_LONG,
gravity: ToastGravity.SNACKBAR,
backgroundColor: Colors.black54,
textColor: Colors.white,
fontSize: 14.0),
)
.catchError(
(error) => Fluttertoast.showToast(
msg: "Failed: $error",
toastLength: Toast.LENGTH_SHORT,
gravity: ToastGravity.SNACKBAR,
backgroundColor: Colors.black54,
textColor: Colors.white,
fontSize: 14.0),
);
}
.then and .catchError are common patterns used to handle errors when dealing with futures. Sort of like a try-catch block. In the snippet above, we’ll be creating a toast message when we have successfully updated the task or when there is an error preventing the update. For more information on futures and error handling, see the link below.
And with that, the update method finally works as expected!
Delete ( D )
The delete method was quite easy to implement. Similar to the update function, we will trigger an alert dialog from the PopupMenuItem for the user to confirm deletion.
PopupMenuItem(
value: 'delete',
child: const Text(
'Delete',
style: TextStyle(fontSize: 13.0),
),
onTap: (){
String taskId = (data['id']);
String taskName = (data['taskName']);
Future.delayed(
const Duration(seconds: 0),
() => showDialog(
context: context,
builder: (context) => DeleteTaskDialog(taskId: taskId, taskName:taskName),
),
);
},
),
Nothing happens when the user cancels the action. However, when the user confirms deletion, the following method is called to remove the document from the firebase collection.
Future _deleteTasks() async {
var collection = FirebaseFirestore.instance.collection('tasks'); // fetch the collection name i.e. tasks
collection
.doc(widget.taskId) // ensure the right task is deleted by passing the task id to the method
.delete() // delete method removes the task entry in the collection
.then( // implement error handling
(_) => Fluttertoast.showToast(
msg: "Task deleted successfully",
toastLength: Toast.LENGTH_LONG,
gravity: ToastGravity.SNACKBAR,
backgroundColor: Colors.black54,
textColor: Colors.white,
fontSize: 14.0),
)
.catchError(
(error) => Fluttertoast.showToast(
msg: "Failed: $error",
toastLength: Toast.LENGTH_SHORT,
gravity: ToastGravity.SNACKBAR,
backgroundColor: Colors.black54,
textColor: Colors.white,
fontSize: 14.0),
);
}
And there you have it!
That’s it for this tutorial! We have successfully implemented all the CRUD methods using Firebase + Flutter in this simple todo application. The full code is currently available on my GitHub repo here.
I will be making updates to the app in future, so stay tuned for that.
“Each day I will accomplish one thing on my to do list.”
― Lailah Gifty Akita
Of course, it goes without saying … thank you for reading ❤