Solidart example
Solidart is a simple state management library for Flutter and inspired by SolidJS.
Dependency
This is the flutter_solidart
version used in this example:
dependencies: flutter_solidart: ^2.0.0-dev.2
This example leverages the power of the ProviderScope
widget using providers.
File structure
Let’s look at the file structure:
Directorycontrollers
- todos.dart
Directorydomain
- todo.dart
Directorypages
- todos.dart
Directorywidgets
- todo_item.dart
- todos_body.dart
- todos_list.dart
- toolbar.dart
- main.dart
Models
Let’s start with the models:
import 'package:flutter/foundation.dart';import 'package:uuid/uuid.dart';
@immutableclass Todo { const Todo({ required this.id, required this.task, required this.completed, });
factory Todo.create(String task) { final uuid = const Uuid().v4(); return Todo(id: uuid, task: task, completed: false); }
final String id; final String task; final bool completed;
static List<Todo> get sample { return [ Todo.create('Learn solidart'), Todo.create('Wash the car'), Todo.create('Go shopping'), ]; }
Todo copyWith({bool? completed}) { return Todo( id: id, task: task, completed: completed ?? this.completed, ); }
@override String toString() => 'Todo(id: $id, task: $task, completed: $completed)';}
enum TodosFilter { all, incomplete, completed;}
TodosController
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.
import 'package:disco/disco.dart';import 'package:flutter/foundation.dart';import 'package:flutter_solidart/flutter_solidart.dart' hide Provider;import 'package:solidart_example/domain/todo.dart';
/// The todos controller providerfinal todosControllerProvider = Provider<TodosController>( (context) => TodosController(initialTodos: Todo.sample), dispose: (controller) => controller.dispose(),);
/// 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.@immutableclass 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.
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
.
TodosPage
The TodosPage
uses a ProviderScope
widget to provide a TodosController
to descendants.
import 'package:disco/disco.dart';import 'package:flutter/material.dart';import 'package:solidart_example/controllers/todos.dart';import 'package:solidart_example/widgets/todos_body.dart';
class TodosPage extends StatelessWidget { const TodosPage({super.key});
@override Widget build(BuildContext context) { // Using ProviderScope here to provide the [TodosController] to descendants. return ProviderScope( providers: [todosControllerProvider], child: Scaffold( appBar: AppBar( title: const Text('Todos'), ), body: const Padding( padding: EdgeInsets.all(8), child: TodosBody(), ), ), ); }}
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.
import 'package:disco/disco.dart';import 'package:flutter/material.dart';import 'package:flutter_solidart/flutter_solidart.dart' hide Provider;import 'package:solidart_example/controllers/todos.dart';import 'package:solidart_example/domain/todo.dart';import 'package:solidart_example/widgets/todos_list.dart';import 'package:solidart_example/widgets/toolbar.dart';
final todosFilterProvider = Provider((context) => Signal(TodosFilter.all));
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 retrieve Provider in both // the `initState` and `build` methods. final todosController = todosControllerProvider.of(context);
return ProviderScope( providers: [ // make the active filter signal visible only to descendants. // scoped here because this is where it starts to be necessary. todosFilterProvider, ], 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: todosController.toggle, ), ), ], ), ); }}
The important parts here are two.
- We’re retrieving the
todosController
with the syntaxtodosControllerProvider.of(context)
. This is how we access providers from descendants. You can safely run this method in theinitState
, in thebuild
method or even inside a callback likeonPressed
. - We’re creating a new
ProviderScope
widget. Yes, you can (you must) create manyProviderScope
widgets inside your app, this the ideal usage.
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:
// skip if the task is emptyif (task.isEmpty) return;// create the new todofinal newTodo = Todo.create(task);// add it to the todosList using our todosControllertodosController.add(newTodo);// clear the text field in order to be able to enter a new todotextController.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.
import 'package:flutter/material.dart';import 'package:flutter_solidart/flutter_solidart.dart';import 'package:solidart_example/controllers/todos.dart';import 'package:solidart_example/domain/todo.dart';import 'package:solidart_example/widgets/todos_body.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 = todosControllerProvider.of(context);
/// 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, 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, child) { return Tab(text: '${filter.name} (${todosCount.value})'); }, ); }, ).toList(), onTap: (index) { // update the current active filter todosFilterProvider.of(context).value = 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 SignalBuilder
widget that subscribes to any signal and rebuilds every time the values change.
import 'package:flutter/material.dart';import 'package:flutter_solidart/flutter_solidart.dart';import 'package:solidart_example/controllers/todos.dart';import 'package:solidart_example/domain/todo.dart';import 'package:solidart_example/widgets/todo_item.dart';import 'package:solidart_example/widgets/todos_body.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 = todosControllerProvider.of(context);
// 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) { return SignalBuilder( builder: (context, child) { // rebuilds every time the activeFilter value changes final activeFilter = todosFilterProvider.of(context).value; // 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.
Unit 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);});
Widget tests
I’m going to use an helper function in all the tests to easily mock the TodosController
, this is the KEYPOINT of how to mock providers, 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: ProviderScopeOverride( overrides: [ todosControllerProvider.overrideWithValue(todosController), ], 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.toString(), task: 'mock$i', completed: false), ); // Build our App and trigger a frame. await tester.pumpWidget( wrapWithMockedTodosController( todosController: TodosController(initialTodos: initialTodos), child: const MyApp(), ), );
// 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 App and trigger a frame. await tester.pumpWidget( wrapWithMockedTodosController( todosController: TodosController(), child: const MyApp(), ), );
// 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.toString(), task: 'mock$i', completed: false), ); // Build our App and trigger a frame. await tester.pumpWidget( wrapWithMockedTodosController( todosController: TodosController(initialTodos: initialTodos), child: const MyApp(), ), );
// 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 App and trigger a frame. await tester.pumpWidget( wrapWithMockedTodosController( todosController: todosController, child: const MyApp(), ), );
// 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);});