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.

Create a free account to save this page, take notes and track your progress. Sign in

The brief

Build todo_core, a pure Dart package (dart create -t package todo_core):

  1. An immutable Todo model with copyWith, ==/hashCode and JSON toMap/fromMap.
  2. A TodoRepository interface with an in-memory implementation; async methods (Future) that sometimes fail, to simulate a network.
  3. A TodoState sealed class (Loading, Loaded(todos, filter), Failed(message)) and a controller that exposes a Stream<TodoState>.
  4. Filters (all, active, done) and a summary line ("3 left, 2 done").
  5. Tests with package:test for the model, filters and the controller's state sequence.
  6. Bonus: plug it into a Flutter UI with a StreamBuilder.

A head start

Model, filters and a state stream
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.

Was this page helpful?