Logosolidart

Todos example

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.

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

  @override
  Widget build(BuildContext context) {
    // Using SolidProvider here to provide the [TodosController] to descendants.
    return Solid(
      providers: [
        SolidProvider<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.

@immutable
class TodosController {
  TodosController({
    List<Todo> initialTodos = const [],
  }) : _todos = createSignal(initialTodos);

  // Keep the editable todos signal private
  // only the TodoController can mutate the value.
  final Signal<List<Todo>> _todos;

  // Expose the list of todos as a ReadSignal so the
  // user cannot mutate directly the object.
  ReadSignal<List<Todo>> get todos => _todos.toReadSignal();

  void add(Todo todo) {
    _todos.update((value) => [...value, todo]);
  }

  void remove(String id) {
    _todos.update(
      (value) => value.where((todo) => todo.id != id).toList(),
    );
  }

  void toggle(String id) {
    _todos.update(
      (value) => [
        for (final todo in value)
          if (todo.id != id) todo else todo.copyWith(completed: !todo.completed)
      ],
    );
  }

  void dispose() {
    _todos.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 private by choice. Instead the user has to use the todos getter.

The todos getter is a ReadSignal. This means that the user won't be able to mutate its value directly.

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:

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 and the TodoList.

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.
        SolidSignal<Signal<TodosFilter>>(
            create: () => createSignal(TodosFilter.all)),
        // Registering two new signals, the list of completed and incomplete todos.
        // These are derived [ReadSignal]s.
        // Provided through a [Solid] because their value is used by the [TodoList]
        // and [Toolbar].
        // This is preferable over passing the signals as parameters down to descendants,
        // expecially when the usage is very deep in the tree.
        SolidSignal<ReadSignal<List<Todo>>>(
          create: () => createComputed(
            () => todosController
                .todos()
                .where((element) => element.completed)
                .toList(),
          ),
          id: SignalId.completedTodos,
        ),
        SolidSignal<ReadSignal<List<Todo>>>(
          create: () => createComputed(
            () => todosController
                .todos()
                .where((element) => !element.completed)
                .toList(),
          ),
          id: SignalId.incompleteTodos,
        ),
      ],
      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.

We're also creating two derived signals, the list of completedTodos and incompleteTodos using the select method of the todos list signal.

In the TextFormField when the field is submitted we add the new todo simply using:

// 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.

class Toolbar extends StatefulWidget {
  const Toolbar({super.key});

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

class _ToolbarState extends State<Toolbar> {
  /// All the derived signals
  late final ReadSignal<int> allTodosCount;
  late final ReadSignal<int> incompleteTodosCount;
  late final ReadSignal<int> completedTodosCount;

  @override
  void initState() {
    super.initState();
    // retrieve the todos from TodosController.
    final todos = context.get<TodosController>().todos;

    // create derived signals based on the list of todos

    allTodosCount = createComputed(() => todos().length);

    // retrieve the list of completed count and select just the length.
    final completedTodos =
        context.get<ReadSignal<List<Todo>>>(SignalId.completedTodos);
    completedTodosCount = createComputed(() => completedTodos().length);
    // retrieve the list of incomplete count and select just the length.
    final incompleteTodos =
        context.get<ReadSignal<List<Todo>>>(SignalId.incompleteTodos);
    incompleteTodosCount = createComputed(() => incompleteTodos().length);
  }

  @override
  void dispose() {
    allTodosCount.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(
              signal: todosCount,
              builder: (context, todosCount, _) {
                return Tab(text: '${filter.name} ($todosCount)');
              },
            );
          },
        ).toList(),
        onTap: (index) {
          // update the current active filter
          context.update<TodosFilter>(
            (_) => TodosFilter.values[index],
          );
        },
      ),
    );
  }
}

To get the total number of todos we've used the select method of signals. It subscribes to the signal and updates 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.

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

  final ValueChanged<String>? onTodoToggle;

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

class _TodoListState extends State<TodoList> {
  late final ReadSignal<List<Todo>> todos;
  late final ReadSignal<List<Todo>> completedTodos;
  late final ReadSignal<List<Todo>> incompleteTodos;

  @override
  void initState() {
    super.initState();

    // retrieve the whole todos list and the filtered ones
    // this can be done here in the initState, or in the build method.
    // You're not going to experience any performance issues writing the
    // lines below in the build method. Used the initState here just
    // to show you that it's possible.
    // NOTE: If you need to `select` a value from a signal, do it always in the `initState`.
    //       The `select` method creates a new signal every time it runs, and you don't want this to happen.
    todos = context.get<TodosController>().todos;
    completedTodos =
        context.get<ReadSignal<List<Todo>>>(SignalId.completedTodos);
    incompleteTodos =
        context.get<ReadSignal<List<Todo>>>(SignalId.incompleteTodos);
  }

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

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

    return SignalBuilder(
      // react to the correct list of todos list
      signal: mapFilterToTodosList(activeFilter),
      builder: (_, todos, __) {
        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

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.

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.

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.

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:

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

  @override
  Widget build(BuildContext context) {
    // Using SolidProvider here to provide the [TodosController] to descendants.
    return Solid(
      providers: [
        SolidProvider<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:

// 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: [
        SolidProvider<TodosController>(
          create: () => todosController,
          dispose: (controller) => controller.dispose(),
        ),
      ],
      child: child,
    ),
  );
}

Check that the TodosController emits the initialTodos as a value

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.

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.

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.

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);
});