---
title: Todos example
description: Todos example using flutter_solidart
---

# Todos example

See the code [here](https://github.com/nank1ro/solidart/tree/main/examples/todos).

This example leverages the power of the `Solid` widgets using `providers`.

### TodosPage

In this example we've a `TodosPage` that is the starting point of our __todos__ feature.
The `TodosPage` uses a `Solid` widget to provide a `TodosController` to descendants.
```dart
class TodosPage extends StatelessWidget {
  const TodosPage({super.key});

  @override
  Widget build(BuildContext context) {
    // Using Provider here to provide the [TodosController] to descendants.
    return Solid(
      providers: [
        Provider<TodosController>(
          create: () => TodosController(initialTodos: Todo.sample),
          dispose: (controller) => controller.dispose(),
        ),
      ],
      child: const TodosPageView(),
    );
  }
}
```

### TodosController

Let's jump into the `TodosController` and let's check what is responsible for.

The `TodosController` is where the business logic lives.
It keep the whole todos list state and allows us to __add__, __remove__ or __toggle__ a _Todo_.
The `TodosController` can have an initialValue, that is the initial list of todos.
```dart
/// Contains the state of the [todos] list and allows to
/// - `add`: Add a todo in the list of [todos]
/// - `remove`: Removes a todo with the given id from the list of [todos]
/// - `toggle`: Toggles a todo with the given id
/// The list of todos exposed is a [ReadSignal] so the user cannot mutate
/// the signal without using this controller.
@immutable
class TodosController {
  TodosController({
    List<Todo> initialTodos = const [],
  }) : todos = ListSignal(initialTodos);

  // The list of todos
  final ListSignal<Todo> todos;

  /// The list of completed todos
  late final completedTodos = Computed(
    () => todos.where((todo) => todo.completed).toList(),
  );

  /// The list of incomplete todos
  late final incompleteTodos = Computed(
    () => todos.where((todo) => !todo.completed).toList(),
  );

  /// Add a todo
  void add(Todo todo) {
    todos.add(todo);
  }

  /// Remove a todo with the given [id]
  void remove(String id) {
    todos.removeWhere((todo) => todo.id == id);
  }

  /// Toggle a todo with the given [id]
  void toggle(String id) {
    final todoIndex = todos.indexWhere((element) => element.id == id);
    final todo = todos[todoIndex];
    todos[todoIndex] = todo.copyWith(completed: !todo.completed);
  }

  void dispose() {
    todos.dispose();
    completedTodos.dispose();
    incompleteTodos.dispose();
  }
}
```

As you can see the `TodosController` can get an `initialTodos` list. This is going to be its initial state.

When the constructor is runned, the `todos` signal is populated with the provided `initialTodos`.
The thing to note here is that `todos` is a `ListSignal`.
A `ListSignal` automatically notifies its listeners when items change.

<Info>See also `SetSignal` and `MapSignal`</Info>

The controller exposes `completedTodos` and `incompleteTodos` derived signals.
They will automatically react to the `_todos` signal and provide a read only signal.

The `add` method uses the `update` function of a `Signal` to append the new `todo` to the current list of todos.

In a similar way the `remove` and `toggle` methods update the signal value.
In the `remove` method we __remove__ from the list the todo with the `id` provided.
In the `toggle` method we loop through each todo and toggle the completed state of the todo with the given `id`.

### TodosPageView

Let's jump back to the UI and let's see the `TodosPageView` widget:
```dart
class TodosPageView extends StatelessWidget {
  const TodosPageView({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Todos'),
      ),
      body: const Padding(
        padding: EdgeInsets.all(8.0),
        child: TodosBody(),
      ),
    );
  }
}
```

This widget is the child of the `TodosPage` and is the base structure of our page.
You may wonder why I haven't placed this code in the `TodosPage`.
This is necessary (_only_) for testing, so I can easily mock the `TodosController` and just test the view.

### TodosBody

Let's continue to the `TodosBody`.
The `TodosBody` is the body of our feature, it has a text input on top where you can write a new todo, a [Toolbar](#toolbar) and the [TodoList](#todolist).
```dart
class TodosBody extends StatefulWidget {
  const TodosBody({super.key});

  @override
  State<TodosBody> createState() => _TodosBodyState();
}

class _TodosBodyState extends State<TodosBody> {
  final textController = TextEditingController();

  @override
  void dispose() {
    textController.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    // retrieve the [TodosController], you're safe to `get` a Signal or Provider
    // in both the `initState` and `build` methods.
    final todosController = context.get<TodosController>();

    return Solid(
      providers: [
        // make the active filter signal visible only to descendants.
        // created here because this is where it starts to be necessary.
        Provider<Signal<TodosFilter>>(
            create: () => Signal(TodosFilter.all)),
      ],
      child: Column(
        children: [
          TextFormField(
            controller: textController,
            decoration: const InputDecoration(
              hintText: 'Write new todo',
            ),
            validator: (v) {
              if (v == null || v.isEmpty) {
                return 'Cannot be empty';
              }
              return null;
            },
            onFieldSubmitted: (task) {
              if (task.isEmpty) return;
              final newTodo = Todo.create(task);
              todosController.add(newTodo);
              textController.clear();
            },
          ),
          const SizedBox(height: 16),
          const Toolbar(),
          const SizedBox(height: 16),
          Expanded(
            child: TodoList(
              onTodoToggle: (id) {
                todosController.toggle(id);
              },
            ),
          ),
        ],
      ),
    );
  }
}
```

The important parts here are two.
1. We're retrieving the `todosController` with the syntax `context.get<TodosController>()`.
   This is how we access providers from descendants. You can safely run this method in the `initState`, in the `build` method or even inside a callback like `onPressed`.
2. We're creating a new `Solid` widget. Yes, you can create many Solid widgets inside your app, this the ideal usage. Don't use a single `Solid` widget but many. Place the `providers` only where needed.

Here we're creating a signal with an initial value of `TodosFilter.all`. This signals keeps the state of the current selected tab.

In the `TextFormField` when the field is submitted we add the new todo simply using:
```dart
// skip if the task is empty
if (task.isEmpty) return;
// create the new todo
final newTodo = Todo.create(task);
// add it to the todosList using our todosController
todosController.add(newTodo);
// clear the text field in order to be able to enter a new todo
textController.clear();
```

### Toolbar

The toolbar shows 3 tabs (or filters).
- All the todos
- The incomplete todos list
- The completed todos list

Each tab contain the number of todos present in the current tab.
```dart
class Toolbar extends StatefulWidget {
  const Toolbar({super.key});

  @override
  State<Toolbar> createState() => _ToolbarState();
}

class _ToolbarState extends State<Toolbar> {
  // retrieve the [TodosController]
  late final todosController = context.get<TodosController>();

  /// All the derived signals, they will react only when the `length` property changes
  late final allTodosCount =
      Computed(() => todosController.todos().length);
  late final incompleteTodosCount =
      Computed(() => todosController.incompleteTodos().length);
  late final completedTodosCount =
      Computed(() => todosController.completedTodos().length);

  @override
  void dispose() {
    allTodosCount.dispose();
    incompleteTodosCount.dispose();
    completedTodosCount.dispose();
    super.dispose();
  }

  /// Maps the given [filter] to the correct list of todos
  ReadSignal<int> mapFilterToTodosList(TodosFilter filter) {
    switch (filter) {
      case TodosFilter.all:
        return allTodosCount;
      case TodosFilter.incomplete:
        return incompleteTodosCount;
      case TodosFilter.completed:
        return completedTodosCount;
    }
  }

  @override
  Widget build(BuildContext context) {
    return DefaultTabController(
      length: TodosFilter.values.length,
      initialIndex: 0,
      child: TabBar(
        labelColor: Colors.black,
        tabs: TodosFilter.values.map(
          (filter) {
            final todosCount = mapFilterToTodosList(filter);
            // Each tab bar is using its specific todos count signal
            return SignalBuilder(
              builder: (context, _) {
                return Tab(text: '${filter.name} (${todosCount.value})');
              },
            );
          },
        ).toList(),
        onTap: (index) {
          // update the current active filter
          context.update<TodosFilter>((_) => TodosFilter.values[index]);
        },
      ),
    );
  }
}
```

To get the total number of todos we've created new `Computed` signals.
They subscribes to the signals used in the function and update only when the selected value changes.

Then we have used a `SignalBuilder` to rebuild every time the count signal changes.

Finally, when a tab is tapped, we `update` the `activeTodoFilter` signal to set the new active tab.

### TodosList

The `TodosList` renders all the todos based on the current `activeFilter`.
In order to react to the active filter it uses the `observe` extension on `BuildContext` that subscribes to the signal provided and rebuilds every time the value changes.

```dart
class TodoList extends StatefulWidget {
  const TodoList({
    super.key,
    this.onTodoToggle,
  });

  final ValueChanged<String>? onTodoToggle;

  @override
  State<TodoList> createState() => _TodoListState();
}

class _TodoListState extends State<TodoList> {
  // retrieve the [TodosController]
  late final todosController = context.get<TodosController>();

  // Given a [filter] return the correct list of todos
  ReadSignal<List<Todo>> mapFilterToTodosList(TodosFilter filter) {
    switch (filter) {
      case TodosFilter.all:
        return todosController.todos;
      case TodosFilter.incomplete:
        return todosController.incompleteTodos;
      case TodosFilter.completed:
        return todosController.completedTodos;
    }
  }

  @override
  Widget build(BuildContext context) {
    // rebuilds this BuildContext every time the `activeFilter` value changes
    final activeFilter = context.observe<Signal<TodosFilter>>().value;

    return SignalBuilder(
      builder: (_, __) {
        // react to the correct list of todos list
        final todos = mapFilterToTodosList(activeFilter).value;
        return ListView.builder(
          itemCount: todos.length,
          itemBuilder: (BuildContext context, int index) {
            final todo = todos[index];
            return TodoItem(
              todo: todo,
              onStatusChanged: (_) {
                widget.onTodoToggle?.call(todo.id);
              },
            );
          },
        );
      },
    );
  }
}
```

## Testing

Here we're going to separate the widgets tests from the business logic tests.
We're going to write unit tests just for the `TodosController` and then we're going to write widgets tests for the whole feature.

### TodosController tests

Check that the `TodosController` emits the `initialTodos` as a value

```dart
test(' When providing initialTodos, `todos` emits the correct state', () {
  // create controller with an initial value
  const initialTodos = [
    Todo(id: '1', task: 'mock1', completed: false),
    Todo(id: '2', task: 'mock2', completed: false),
  ];
  final controller = TodosController(initialTodos: initialTodos);

  // cleanup resources
  addTearDown(controller.dispose);

  // verify that the list of todos has 2 items
  expect(controller.todos.value, hasLength(2));
});
```

---

Test that we are able to add a new `Todo`.

```dart
test('Add a todo', () {
  // create controller
  final controller = TodosController();
  // cleanup resources
  addTearDown(controller.dispose);

  // verify that the list of todos is empty
  expect(controller.todos.value, isEmpty);

  // add a todo with id '1'
  controller.add(const Todo(id: '1', task: 'mock1', completed: false));

  // verify that the list of todos increased
  expect(controller.todos.value, hasLength(1));
});
```

---

Test that we are able to remove an existing `Todo` by its `id`.

```dart
test('Remove a todo', () {
  // create controller with an initial value
  const initialTodos = [
    Todo(id: '1', task: 'mock1', completed: false),
    Todo(id: '2', task: 'mock2', completed: false),
  ];
  final controller = TodosController(initialTodos: initialTodos);

  // cleanup resources
  addTearDown(controller.dispose);

  // verify that the list of todos starts with 2 items
  expect(controller.todos.value, hasLength(2));

  // remove the todo with id '1'
  controller.remove('1');

  // verify that the list of todos decreased
  expect(controller.todos.value, hasLength(1));

  // verify that the remained todo has id '2'
  expect(controller.todos.value.first.id, '2');
});
```

---

Test that we are able to toggle a `Todo` in order to mark it as completed.

```dart
test('Toggle a todo', () {
  // create controller with an initial value
  const initialTodos = [
    Todo(id: '1', task: 'mock1', completed: false),
  ];
  final controller = TodosController(initialTodos: initialTodos);

  // cleanup resources
  addTearDown(controller.dispose);

  // verify that the first todo is not completed
  expect(controller.todos.value.first.completed, false);

  // complete the first todo
  controller.toggle('1');

  // verify that the first todo is completed
  expect(controller.todos.value.first.completed, true);
});
```

### Widgets tests

To easily test the Widgets I really suggest you to split the `Solid` widget that _provides_ `providers` to descendants from descendants.
Like how I did with the `TodosPage` and `TodosPageView`:

```dart
class TodosPage extends StatelessWidget {
  const TodosPage({super.key});

  @override
  Widget build(BuildContext context) {
    // Using Provider here to provide the [TodosController] to descendants.
    return Solid(
      providers: [
        Provider<TodosController>(
          create: () => TodosController(initialTodos: Todo.sample),
          dispose: (controller) => controller.dispose(),
        ),
      ],
      child: const TodosPageView(),
    );
  }
}

class TodosPageView extends StatelessWidget {
  const TodosPageView({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Todos'),
      ),
      body: const Padding(
        padding: EdgeInsets.all(8.0),
        child: TodosBody(),
      ),
    );
  }
}
```

As you can see I'm separating the creation of the Solid widget from the descendants.
This is necessary for testing, so I can easily mock the `TodosController` and just test the view.
So let's start writing the widgets tests.

I'm going to use an helper function in all the tests to easily mock the `TodosController`, here it is the source code:
```dart
// Utility function to easily wrap a [child] into a mocked todos controller.
Widget wrapWithMockedTodosController({
  required Widget child,
  required TodosController todosController,
}) {
  return MaterialApp(
    home: Solid(
      providers: [
        Provider<TodosController>(
          create: () => todosController,
          dispose: (controller) => controller.dispose(),
        ),
      ],
      child: child,
    ),
  );
}
```

---

Check that the `TodosController` emits the `initialTodos` as a value

```dart
testWidgets('Todos with initial value', (WidgetTester tester) async {
  // create controller with an initial value
  final initialTodos = List.generate(
    3,
    (i) => Todo(id: "$i", task: 'mock$i', completed: false),
  );
  // Build our TodosPageView and trigger a frame.
  await tester.pumpWidget(
    wrapWithMockedTodosController(
      todosController: TodosController(initialTodos: initialTodos),
      child: const TodosPageView(),
    ),
  );

  // verify that there are 3 todos rendered initially
  expect(tester.widgetList(find.byType(TodoItem)).length, 3);

  // Verify that the todos list contains 'mock0'
  expect(find.text('mock0'), findsOneWidget);

  // Verify that the todos list contains 'mock1'
  expect(find.text('mock1'), findsOneWidget);

  // Verify that the todos list contains 'mock2'
  expect(find.text('mock2'), findsOneWidget);
});
```

---

Test that we are able to add a new `Todo`.

```dart
testWidgets('Add a todo', (WidgetTester tester) async {
  // Build our TodosPageView and trigger a frame.
  await tester.pumpWidget(
    wrapWithMockedTodosController(
      todosController: TodosController(),
      child: const TodosPageView(),
    ),
  );

  // verify that there are 0 todos rendered initially
  expect(tester.widgetList(find.byType(TodoItem)).length, 0);

  // write and add a new todo
  await tester.enterText(find.byType(TextFormField), 'test todo');
  await tester.testTextInput.receiveAction(TextInputAction.done);
  await tester.pump();

  // verify that there is 1 todos now
  expect(tester.widgetList(find.byType(TodoItem)).length, 1);
  // Verify that the todos list contains 'test todo'
  expect(find.text('test todo'), findsOneWidget);
});
```

---

Test that we are able to remove an existing `Todo` by its `id`.

```dart
testWidgets('Remove a todo', (WidgetTester tester) async {
  // create controller with an initial value
  final initialTodos = List.generate(
    3,
    (i) => Todo(id: "$i", task: 'mock$i', completed: false),
  );
  // Build our TodosPageView and trigger a frame.
  await tester.pumpWidget(
    wrapWithMockedTodosController(
      todosController: TodosController(initialTodos: initialTodos),
      child: const TodosPageView(),
    ),
  );

  // verify that there are 3 todos rendered initially
  expect(tester.widgetList(find.byType(TodoItem)).length, 3);

  final firstTodoItem = find.byType(TodoItem).first;
  // simulate the drag from right to left
  await tester.fling(
    firstTodoItem,
    const Offset(-300, 0),
    1000,
  );
  await tester.pumpAndSettle();

  // verify that there are 2 todos rendered now
  expect(tester.widgetList(find.byType(TodoItem)).length, 2);
  // Verify that the todos list doesn't contain 'mock0'
  expect(find.text('mock0'), findsNothing);
});
```

---

Test that we are able to toggle a `Todo` in order to mark it as completed.

```dart
testWidgets('Toggle a todo', (WidgetTester tester) async {
  // create controller with an initial value
  final initialTodos = List.generate(
    2,
    (i) => Todo(id: "$i", task: 'mock$i', completed: false),
  );
  final todosController = TodosController(initialTodos: initialTodos);
  // Build our TodosPageView and trigger a frame.
  await tester.pumpWidget(
    wrapWithMockedTodosController(
      todosController: todosController,
      child: const TodosPageView(),
    ),
  );

  // verify that the completed tabs starts with 0 todos
  expect(find.text('completed (0)'), findsOneWidget);

  // toggle the first todo
  await tester.tap(find.byType(CheckboxListTile).first);
  await tester.pump();

  // verify that the completed tab shows 1 todo now
  expect(find.text('completed (1)'), findsOneWidget);

  // tap in the completed tab
  await tester.tap(find.text('completed (1)'));
  await tester.pump();

  // Verify that the completed todos list contains 'mock0'
  expect(find.text('mock0'), findsOneWidget);
  // Verify that the completed todos list not contains 'mock1'
  expect(find.text('mock1'), findsNothing);
});
```
