Building a TodoList in Flutter
A short example of how easy it is to build a CRUD App using the Flutter framework.
About one year ago, I noticed some mobile app framework which I immediately was interested to learn: The Flutter SDK. I was fascinated how easy and straight forward it is to build apps which this framework. So I decided to have a deeper look how things work there. As at this time there was not much material to learn flutter (besides the massive documentation from google). This is why I wanted to share some of my knowledge (which is of course still basic) with you.
First of all, you will need to install the flutter sdk. Please refer to the official docs at https://flutter.dev/docs/get-started/install for doing this. After you set up all the things, you are ready to scaffold your first app!
Flutter comes with a powerful CLI, which lets you create an example app very easy: simply fire the following command in the directory where you want your app to live in
flutter create todoapp
That’s it. After the command finishes, you will have your first app which is ready to run. The scaffolding creates some example app with an easy use case of state management. First of all, we have to delete all the example code to have a plain app with a simple AppBar. We do this by adjusting the main.dart file (which contains the main method, the entry point for our app). For now you can also delete the unit test file in /test. Your main.dart file will then look like this:
import 'package:flutter/material.dart';
void main() {
runApp(TodoListApp());
}
class TodoListApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Todo list',
home: Scaffold(
appBar: AppBar(title: Text("Todo list"),),
),
);
}
}
You can now run your app (please also refer to the official docs here, because it depends on if you use emulators or real devices, as well as on your IDE / editor and operating system). I use Android Studio in combination with the ios simulator on MacOS. The app will now look like this:
Now it’s time to build some stuff. First of all, we will create a model class for our todos. I will put this file to /lib/model. The content of the file is
class Todo {
int id;
String title;
String description;
bool isDone;
}
Afterwards, we will start implementing our state management. There are quite different approaches for doing this. Flutter brings his own publish-subscribe state management (which we will use in this tutorial), but there are also things like redux etc, which all have their pros and cons. In this guide we will use the standard flutter way.
The first step is to create a state model class, which we will put in /lib/state. The filename is todo_model.dart and it gets the following content:
import 'dart:collection';
import 'package:flutter/material.dart';
import 'package:todo_app/model/todo.dart';
class TodoModel extends ChangeNotifier {
final List<Todo> _todos = [];
UnmodifiableListView<Todo> get todos => UnmodifiableListView(_todos);
void add(Todo todo) {
_todos.add(todo);
notifyListeners();
}
void toggleDone(int id) {
var index = _todos.indexWhere((element) => element.id == id);
_todos[index].isDone = !_todos[index].isDone;
notifyListeners();
} void remove(int id) {
_todos.removeWhere((element) => element.id == id);
notifyListeners();
}
}
What are we doing here? First of all, we extend the ChangeNotifier class, which is a Flutter class that allows us to access the app state and the notifier methods. We have a private list of todos in there, this is our actual data on which we work. For the usage outside of the class, we only provide an immutable view of them and some accessor method (encapsulation principle). There is no magic inside, just some List logic and the notifier calls. The notifier calls trigger the app state update and all widgets that listen to this state are then rebuilt.
For the time being, it makes sense to add some dummy data, so that we can see some entries in the list view we soon will create. First, we have to add a constructor to our model class:
class Todo {
int id;
String title;
String description;
bool isDone = false;
Todo({this.id, this.title, this.description});
}
Then we can go to our state model and initially insert some data into our list:
final List<Todo> _todos = [
Todo(id: 1, title: "First Todo", description: "My first todo"),
Todo(id: 2, title: "Second todo", description: "My second todo")
];
In order to consume this state model, we have to install the provider package into our app. For this, we open the pubspec.yaml file and make the dependency section look like this:
dependencies:
flutter:
sdk: flutter
provider: 4.3.1
After running the pub get command of flutter, we can use this package. For this we wrap our whole App into a ChangeNotifierProvider (main.dart), which is connected to our Model:
void main() {
runApp(
ChangeNotifierProvider(
create: (context) => TodoModel(),
child: TodoListApp(),
),
);
}
We can now consume this model anywhere we want. And so we do, switch do your main.dart file and change it the following way: add a TodoList widget, which is connected to our AppState:
class TodoList extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Consumer<TodoModel>(
builder: (context, todoList, child) => ListView.builder(
padding: const EdgeInsets.all(8),
itemCount: todoList.todos.length,
itemBuilder: (BuildContext context, int index) {
return Container(
height: 50,
child: Center(child: Text(todoList.todos[index].title)),
);
}));
}
}
What does it do? It connects to our AppState via the Consumer widget, and then renders a ListView with the obtained data.
Now we only need to integrate this list into our Scaffold, so the TodoListApp Widget will look like this:
class TodoListApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Todo list',
home: Scaffold(
appBar: AppBar(
title: Text("Todo list"),
),
body: TodoList(),
),
);
}
}
And boom, our app looks like this now:
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:todo_app/state/todo_model.dart';
class TodoScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text("Todo list"),
),
body: TodoList(),
);
}
}
class TodoList extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Consumer<TodoModel>(
builder: (context, todoModel, child) =>
ListView.builder(
padding: const EdgeInsets.all(8),
itemCount: todoModel.todos.length,
itemBuilder: (BuildContext context, int index) {
return Container(
height: 50,
child: Center(child: Text(todoModel.todos[index].title)),
);
}));
}
}
The main.dart will then only contain the registration of the state provider as well as the route definitions. It will look like this:
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:todo_app/screens/todo_screen.dart';
import 'package:todo_app/state/todo_model.dart';
void main() {
runApp(
ChangeNotifierProvider(
create: (context) => TodoModel(),
child: TodoListApp(),
),
);
}
class TodoListApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Todo list',
initialRoute: '/',
routes: {
'/': (context) => TodoScreen(),
},
);
}
}
Now it’s time to create the todo_form.dart widget, which will be used for creating and editing todos (we will implement create first and edit afterwards). Create the file in the screens folder and add the following code to it:
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
class TodoForm extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text("Create todo"),
),
body: Center(
child: Text("This will be the add form"),
),
);
}
}
This is only some dummy content to be able to register the route. Then go to the main.dart file and add the following route to the router config:
'/entry': (context) => TodoForm(),
We can now easily add a material floating action button in the TodoList scaffold, which will lead us to the page. Adjust the code of the TodoScreen scaffold to contain a floating action button:
class TodoScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text("Todo list"),
),
body: TodoList(),
floatingActionButton: FloatingActionButton(
child: Icon(Icons.add),
onPressed: () => {Navigator.pushNamed(context, "/entry")},
),
);
}
}
If you now click this button, you are navigated to the form screen (which also contains a back button in the AppBar automatically, because we use the Flutter router).
The next step is to apply some changes to our model. As we don’t want to set the id of our todos manually, we will auto increment the id based on the last one. This can be easily done by adding the following line at the very first line of our add method:
todo.id = todos.last.id + 1;
We are now ready for building our form for entering todos. Our TodoForm is the first stateful widget we write in this tutorial and will be our most complex widget. Go to the todo_form.dart file and add a TodoForm widget which extends the StatefulWidget class, as well as a TodoFormState class which extends the State class. The TodoForm code will be very simple for the time being:
class TodoForm extends StatefulWidget {
@override
TodoFormState createState() {
return TodoFormState();
}
}
In our state we will define a form key now. As we use the Form class of Flutter, we need to use this key as a global identifier of the form. Add the following as first line into the TodoFormState class:
final _formKey = GlobalKey<FormState>();
In the build method we can now return an empty form with our defined key:
@override
Widget build(BuildContext context) {
return Form(key: _formKey, child: null);
}
The whole file should now look like this:
import 'package:flutter/material.dart';
class TodoEntryScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text("Create todo"),
),
body: TodoForm());
}
}
class TodoForm extends StatefulWidget {
@override
TodoFormState createState() {
return TodoFormState();
}
}
class TodoFormState extends State<TodoForm> {
final _formKey = GlobalKey<FormState>();
@override
Widget build(BuildContext context) {
return Form(key: _formKey, child: null);
}
}
While it absolutely amazing that we have a form now, we should teach it to do some stuff. Flutter offers a class called TextEditingController. This is a helper which will store the value and all meta info about the according field. It will help us to access and store the values which we enter into the form. As we only have title and description, we will add two controllers:
final titleController = TextEditingController();
final descriptionController = TextEditingController();
Those controllers are registered in the state and will hold the value, to free up all resources on unload, we also need to dispose it when the state itself is disposed. This is very easy, by overriding the lifecycle method dispose().
@override
void dispose() {
titleController.dispose();
descriptionController.dispose();
super.dispose();
}
We are now ready to add our textfields to the form. In order to render them nicely, we will wrap them into a column. Add the following code behind the child property of the form, where currently null is explicitly set:
Column(children: <Widget>[
TextFormField(
controller: titleController,
),
TextFormField(
controller: descriptionController,
),
RaisedButton(
child: Text("Save"),
onPressed: () => {},
)
])
We see the two fields there, which are bound to our controllers, as well as a save button. If you start the app now, the todo form will look like this (I entered the hello world manually):
The only thing missing now is the save logic. First, we create a method which will add a new todo:
void createTodo(addTodo) {
var todo = new Todo(
title: titleController.text,
description: descriptionController.text
);
addTodo(todo);
Navigator.pop(context);
}
Note how we access the value of the controllers for getting the title and description. Another interesting thing is that we pass in our add method, as we cannot directly use a consumer in this method, but only in our render method (functional programmers will smile now). The last line will return us to the TodoList screen, by removing the last navigation move from the navigation stack. The “go back” is also reflected in the transition of the screens.
The child of our form will now contain a consumer for adding a todo:
@override
Widget build(BuildContext context) {
return Form(
key: _formKey,
child: Consumer<TodoModel>(
builder: (context, todoModel, child) => Column(children: <Widget>[
TextFormField(
controller: titleController,
),
TextFormField(
controller: descriptionController,
),
RaisedButton(
child: Text("Save"),
onPressed: () => {createTodo(todoModel.add)},
)
])));
}
We use the add method of our TodoModel as parameter for the createTodo method.
If you start the app now, you are able to create a todo and it will directly appear in the TodoList.
The next step is to add a checkbox to tick todos as done. In fact this is really easy. Instead of a Container we will render a Row now, and set the mainAxisAlignment to spaceBetween. Beneath the Text widget, we will also have a Checkbox widget now which will display the current done state, as well as change it on tick:
class TodoList extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Consumer<TodoModel>(
builder: (context, todoModel, child) => ListView.builder(
padding: const EdgeInsets.all(8),
itemCount: todoModel.todos.length,
itemBuilder: (BuildContext context, int index) {
return Container(
height: 50,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(todoModel.todos[index].title),
Checkbox(
value: todoModel.todos[index].isDone,
onChanged: (bool newValue) =>
{todoModel.toggleDone(todoModel.todos[index].id)},
)
]),
);
}));
}
}
Now we can easily set todos to done and vice versa.
Of course we also want to edit todos or see their description. We can reuse the todo form for doing so. In order to let the TodoForm know which todo it should load, we have to pass the route our todoId as parameter. In our todo_form file, we add a class which defines the parameter structure our widget will extract:
class ScreenArguments {
final int todoId;
ScreenArguments(this.todoId);
}
We can now simply pass this thing to the route. Let’s go to our TodoScreen and make some changes there. We will move the label to the center, the checkbox to the left side and an edit button to the right side. The edit button will then navigate us to the form screen and pass the todoId as argument. The itemBuilder of our list will now look like this:
return Container(
height: 50,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Checkbox(
value: todoModel.todos[index].isDone,
onChanged: (bool newValue) =>
{todoModel.toggleDone(todoModel.todos[index].id)},
),
Text(todoModel.todos[index].title),
IconButton(
icon: Icon(Icons.edit),
onPressed: () => {
Navigator.pushNamed(context, "/entry",
arguments:
ScreenArguments(todoModel.todos[index].id))
},
)
]),
);
As you can see we added an IconButton, which on press will navigate to the form page with a ScreenArguments object.
The todoList will now look like this:
As our TodoModel is not ready yet to read a todo by id, we have to implement this method:
Todo read(int id){
return _todos.firstWhere((element) => element.id == id);
}
Our form screen is nearly ready to display the current values of the todo. For accessing the screen arguments and setting the value of the textfields, we need to add the following code to the TodoFormState class, and call it in the first step of the build method:
void loadTodoForEdit(BuildContext context){
final ScreenArguments arguments = ModalRoute.of(context).settings.arguments;
if(arguments != null && arguments.todoId != null){
isEditForm = true;
var todo = new TodoModel().read(arguments.todoId);
titleController.text = todo.title;
descriptionController.text = todo.description;
}
}
We also add a boolean field isEditForm to the class, which will be needed to determine the save action and display the delete button). The method needs the context as argument because we need to extract the route param from it. After extracting the argument (in case it is a valid integer), we set isEditForm to true and load the todo and set its values into the textfields. When we navigate to the edit page now via the edit button, our textfields are prefilled with the data of the todo.
The next step is to implement the update and delete methods in our TodoModel class:
void update(int id, String newTitle, String newDescription) {
var todo = _todos.firstWhere((todo) => todo.id == id);
todo.title = newTitle;
todo.description = newDescription;
notifyListeners();
}
void delete(int id){
_todos.removeWhere((todo) => todo.id == id);
notifyListeners();
}
In our TodoForm we add the following methods:
void editTodo(Function editTodo) {
editTodo(editableTodo.id, titleController.text, descriptionController.text);
Navigator.pop(context);
}
void deleteTodo(Function deleteTodo) {
deleteTodo(editableTodo.id);
Navigator.pop(context);
}
We can now display the delete button conditionally, as well as changing the text and the listener of the save button. In order to be able to access the todo in the build function, we will create a field on top of our class:
var editableTodo;
Then we adjust the code of our loadTodoForEdit method that it looks like this:
void loadTodoForEdit(BuildContext context) {
final ScreenArguments arguments = ModalRoute.of(context).settings.arguments;
if (arguments.todoId != null) {
isEditForm = true;
editableTodo = new TodoModel().read(arguments.todoId);
titleController.text = editableTodo.title;
descriptionController.text = editableTodo.description;
}
}
Instead of creating a local Todo, we will initialize the class field now (which is totally fine as it is a State class). To finish the logic in our form, we render the delete button conditionally and change the listener and text of the state button conditionally:
RaisedButton(
child: Text(isEditForm ? "Update" : "Save"),
onPressed: () => {
isEditForm
? editTodo(todoModel.update)
: createTodo(todoModel.add)
},
),
isEditForm
? RaisedButton(
child: Text("Delete"),
onPressed: () => deleteTodo(todoModel.delete),
)
: new Container()
Note: we have to return some placeholder element as a column must not contain null elements.
And that was basically it. We have a todolist app now which can do all CRUD functionality!
You can access the full sourcecode at: https://github.com/mrwdlr/todo-flutter