Dart Fundamentals: The Language Behind Flutter · Classes, Modern Dart 3 Features and Async
Capstone: A Todo App Core in Dart
Build the logic layer of a todo app you could put behind a Flutter UI: immutable models, a repository, filtering with sealed classes and patterns, async loading with errors, and tests. Then review it against the rubric.
The brief
Build todo_core, a pure Dart package (dart create -t package todo_core):
- An immutable
Todomodel withcopyWith,==/hashCodeand JSONtoMap/fromMap. - A
TodoRepositoryinterface with an in-memory implementation; async methods (Future) that sometimes fail, to simulate a network. - A
TodoStatesealed class (Loading,Loaded(todos, filter),Failed(message)) and a controller that exposes aStream<TodoState>. - Filters (
all,active,done) and a summary line ("3 left, 2 done"). - Tests with
package:testfor the model, filters and the controller's state sequence. - Bonus: plug it into a Flutter UI with a
StreamBuilder.
A head start
import 'dart:async';
class Todo {
final int id;
final String title;
final bool done;
const Todo(this.id, this.title, {this.done = false});
Todo toggle() => Todo(id, title, done: !done);
@override
bool operator ==(Object other) => other is Todo && other.id == id && other.title == title && other.done == done;
@override
int get hashCode => Object.hash(id, title, done);
}
enum Filter { all, active, done }
sealed class TodoState {}
class Loading extends TodoState {}
class Loaded extends TodoState {
final List<Todo> todos;
final Filter filter;
Loaded(this.todos, this.filter);
List<Todo> get visible => switch (filter) {
Filter.all => todos,
Filter.active => todos.where((t) => !t.done).toList(),
Filter.done => todos.where((t) => t.done).toList(),
};
String get summary => '${todos.where((t) => !t.done).length} left, ${todos.where((t) => t.done).length} done';
}
class Failed extends TodoState {
final String message;
Failed(this.message);
}
class TodoController {
final _states = StreamController<TodoState>();
Stream<TodoState> get states => _states.stream;
var _todos = <Todo>[];
var _filter = Filter.all;
Future<void> load(Future<List<Todo>> Function() fetch) async {
_states.add(Loading());
try {
_todos = await fetch();
_emit();
} catch (e) {
_states.add(Failed('could not load todos'));
}
}
void toggle(int id) {
_todos = [for (final t in _todos) t.id == id ? t.toggle() : t];
_emit();
}
void show(Filter f) {
_filter = f;
_emit();
}
void _emit() => _states.add(Loaded(List.unmodifiable(_todos), _filter));
Future<void> close() => _states.close();
}
String render(TodoState s) => switch (s) {
Loading() => 'loading…',
Failed(:final message) => 'error: $message',
Loaded loaded => '${loaded.filter.name}: ${loaded.visible.map((t) => '${t.done ? '✓' : '·'} ${t.title}').join(', ')} (${loaded.summary})',
};
Future<void> main() async {
final c = TodoController();
final done = c.states.map(render).forEach(print);
await c.load(() async => const [Todo(1, 'Buy milk'), Todo(2, 'Write tests'), Todo(3, 'Ship it')]);
c.toggle(2);
c.show(Filter.active);
c.show(Filter.done);
await c.load(() async => throw Exception('offline'));
await c.close();
await done;
print(const Todo(1, 'a') == const Todo(1, 'a').toggle().toggle());
}
Output
loading… all: · Buy milk, · Write tests, · Ship it (3 left, 0 done) all: · Buy milk, ✓ Write tests, · Ship it (2 left, 1 done) active: · Buy milk, · Ship it (2 left, 1 done) done: ✓ Write tests (2 left, 1 done) loading… error: could not load todos true
Run this on your computer — see “Get Started” for setup.
Review your work
Open each question for the model answer and rubric.
Deliverables
Model and state design: what does a strong solution look like?
Model answer
Immutable Todo with final fields, copyWith/toggle, value equality and JSON mapping; a sealed TodoState so UI code must handle Loading, Loaded and Failed; derived values (visible list, summary) computed from state rather than stored separately; unmodifiable lists exposed to the UI.
Rubric
Rubric: no mutation of shared lists; exhaustive switches; equality tested. Weak: mutable models changed in place, booleans like isLoading/isError instead of a sealed state.
Async and testing: what does a strong solution look like?
Model answer
Repository methods return Futures with timeouts and typed exceptions; the controller catches failures and emits Failed instead of crashing; tests use package:test with expectLater(controller.states, emitsInOrder([...])) to check the state sequence, plus unit tests for filters and JSON round-trips.
Rubric
Rubric: errors become states; stream closed on dispose; deterministic tests. Bonus: a Flutter StreamBuilder UI and an isolate for parsing a large JSON import.
Open in the app to mark lessons complete, take notes and track your progress.