Storing Complex Data
Learning Objectives
- You know of one approach for storing complex data in a Flutter application.
- You know of one possible approach for structuring a Flutter application into files.
In the previous chapters, we have worked with simple data types like strings and numbers. In this chapter, we outline an example of an application that works with more a complex data type, defined using a class. The example application is a task list application, where the user can add tasks to a list, mark tasks as completed, and delete tasks.
Task class and service
To get started, let’s define the Task
class that represents a task in the task list. The Task
class has two properties: name
that is a string representing the name of the task, and completed
that is a boolean indicating whether the task is completed or not. The Task
class also has a toJson
method that converts the task to a JSON object, and a fromJson
factory method that creates a Task
object from a JSON object.
class Task {
final String name;
bool completed;
Task(this.name, this.completed);
Map toJson() => {
'name': name,
'completed': completed,
};
factory Task.fromJson(Map json) {
return Task(json['name'], json['completed']);
}
}
A TaskController
class is used to manage the tasks in the task list. The TaskController
class has a tasks
property that is a list of tasks, and a storage
property that is a Hive box. The TaskService
class has methods for adding a task, changing the completed status of a task, and deleting a task. The TaskService
class also reads the tasks from storage when it is created.
When working with more complex data types and storing data in a list, we want to use the RxList
class from the get
package. The RxList
class is an observable list that can be used to store and observe changes to the list. This, however, also means that the type of data stored in the list is not directly inferrable, and we need to transform the read contents into the correct type when reading from storage.
class TaskController {
final storage = Hive.box("storage");
RxList tasks;
TaskController() : tasks = [].obs {
if (storage.get('tasks') == null) {
storage.put('tasks', []);
}
tasks.value = storage
.get('tasks')
.map(
(task) => Task.fromJson(task),
)
.toList();
}
void _save() {
storage.put(
'tasks',
tasks.map((task) => task.toJson()).toList(),
);
}
void add(Task task) {
tasks.add(task);
_save();
}
void changeCompleted(Task task) {
task.completed = !task.completed;
tasks.refresh();
_save();
}
void delete(Task task) {
tasks.remove(task);
tasks.refresh();
_save();
}
get size => tasks.length;
}
Moreover, when working with lists and when modifying the list, we need to call the refresh
method on the list to notify the observers that the list has changed. Without the refresh
call, the updates to the list would not be reflected in the UI. Finally, as shown above, we also need to write the updated list to storage after modifying it.
First version of a task list
To display the task list, we create a TaskListWidget
widget that uses an Obx
widget to observe changes to the task list. The first version of the task list displays a list of tasks as Text
widgets, and does not provide the possibility to interact with the tasks. We’ll later update the version once we have added the functionality to add tasks.
class TaskListWidget extends StatelessWidget {
final taskController = Get.find<TaskController>();
@override
Widget build(BuildContext context) {
return Obx(
() => taskController.size == 0
? Text('No tasks')
: Column(
children: taskController.tasks
.map(
(task) => Text(task.name),
)
.toList(),
),
);
}
}
With the Task
class, the TaskController
class, and the TaskListWidget
widget in place, we can now create the main application widget MyApp
that displays the task list. The MyApp
widget is a StatelessWidget
that returns a GetMaterialApp
widget with a Scaffold
that contains a Column
with the TaskListWidget
.
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return GetMaterialApp(
home: Scaffold(
body: Column(
children: [
TaskListWidget(),
],
),
),
);
}
}
Starting the application would, as usual, be done through a main
function. In this case, the function would initialize Hive and the storage for Hive, add the TaskController
for Get so that it can be injected to locations where it is needed, and finally run the MyApp
.
Future<void> main() async {
await GetStorage.init();
Get.lazyPut<TaskService>(() => TaskService());
runApp(MyApp());
}
At this point, the application would already work. As a whole, it would look as follows.
The application already starts, but so far there are no tasks in the list. Let’s next add the possibility to add tasks.
Adding tasks
To add tasks, we create a TaskInputWidget
widget that uses the flutter_form_builder
library to create a form for adding tasks. Adding tasks is done with a text field for the task name and a checkbox for marking the task as completed.
As learned in the earlier chapters in this part, we use a static form key for retrieving the data from the form, and have a method for submitting the form. In addition, we use the FormBuilderValidators.required()
validator to ensure that the task name is not empty.
class TaskInputWidget extends StatelessWidget {
final taskController = Get.find<TaskController>();
final static _formKey = GlobalKey<FormBuilderState>();
_submit() {
if (_formKey.currentState!.saveAndValidate()) {
Task task = Task(
_formKey.currentState!.value['task'],
_formKey.currentState!.value['completed'] ?? false,
);
taskController.add(task);
_formKey.currentState?.reset();
}
}
@override
Widget build(BuildContext context) {
return FormBuilder(
key: _formKey,
child: Column(
children: [
FormBuilderTextField(
name: 'task',
decoration: InputDecoration(
hintText: 'Task name',
border: OutlineInputBorder(),
),
autovalidateMode: AutovalidateMode.always,
validator: FormBuilderValidators.required(),
),
FormBuilderCheckbox(
name: 'completed',
title: Text('Completed'),
),
ElevatedButton(
onPressed: _submit,
child: Text("Add task"),
),
],
),
);
}
}
Now, we can modify the MyApp
widget to include the TaskInputWidget
in addition to the TaskListWidget
.
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return GetMaterialApp(
home: Scaffold(
body: Column(
children: [
TaskInputWidget(),
TaskListWidget(),
],
),
),
);
}
}
At this point, the application as a whole looks as follows, and the application allows adding tasks that are then displayed in a list.
Interactive task list
So far, the application does not allow interacting with the tasks in the list. Let’s add the functionality to mark tasks as completed and to delete tasks. For this, Flutter’s ListTile widget is handy, as it can be used to create a list item with leading and trailing widgets that can be used to interact with the item.
An updated version of the TaskListWidget is shown below. The updated version displays the tasks as ListTile
widgets, where each task is displayed with a Checkbox
to mark the task as completed and an IconButton
to delete the task.
class TaskListWidget extends StatelessWidget {
final taskController = Get.find<TaskController>();
@override
Widget build(BuildContext context) {
return Obx(
() => taskController.size == 0
? Text('No tasks')
: Column(
children: taskController.tasks
.map(
(task) => ListTile(
title: Text(task.name),
leading: Checkbox(
value: task.completed,
onChanged: (value) {
taskController.changeCompleted(task);
},
),
trailing: IconButton(
icon: Icon(Icons.delete),
onPressed: () {
taskController.delete(task);
},
),
),
)
.toList(),
),
);
}
}
With the updated version of the TaskListWidget
, the application now allows marking tasks as completed and deleting tasks. As a whole, the application would look as follows.
Dividing an application into files
As the application grows, it is a good idea to divide the application into separate files for better organization. In the example application, we can divide the application into the following files:
main.dart
for the main application widgetMyApp
and themain
function.task.dart
for theTask
class.task_controller.dart
for theTaskController
class.task_input_widget.dart
for theTaskInputWidget
widget.task_list_widget.dart
for theTaskListWidget
widget.
We could also divide the application into folders, such as models
for the Task
class, controllers
for the TaskController
class, and widgets
for the TaskInputWidget
and TaskListWidget
widgets. One possible structure for the application is shown below.
lib/
main.dart
controllers/
task_controller.dart
models/
task.dart
widgets/
task_input_widget.dart
task_list_widget.dart