Build a Task Board with Bloc

Build a Flutter Task Board using Bloc sealed classes and event transformers.

Introduction

30 Second Summary

When apps grow beyond a few screens, managing state becomes chaotic. Tapping a button triggers an API call, a search fires while another is still running, and the user navigates away before a response arrives. Without a clear system, bugs multiply.

In this project, you will build a Task Board app using the Bloc pattern with Dart 3 sealed classes, event transformers for concurrency control, and a full unit test suite.

What You'll Build

A Flutter Task Board app that follows VeryGoodVentures architecture conventions with sealed events and states, restartable search, sequential mutations, and isolated Bloc-layer testing.

By the end of this project, you'll have:

  • A feature-first folder structure with a TaskBloc using Dart 3 sealed classes for compile-time exhaustive event and state handling.
  • Concurrency-safe event transformers that cancel stale search requests and process task mutations in order.
  • A complete test suite using bloc_test and mocktail that verifies happy paths, error states, and transformer behavior.
  • Secret Mission: Add a BlocObserver for global state logging and test the isClosed guard pattern that prevents post-disposal crashes.

Are there any prerequisites?

This project assumes intermediate Flutter experience. You should be comfortable with widgets, navigation, and setState.

Familiarity with the repository pattern is also helpful. You do not need prior Bloc experience.

Set Up the Project

To build a production-quality task board, you need the right foundation of packages and folder structure. The Bloc pattern separates business logic from your UI through a strict event-to-state flow.

In this step, you'll scaffold a new Flutter project, install the Bloc ecosystem packages, and organize your code using a feature-first folder structure. This structure groups files by feature (like the task board) rather than by type (like all blocs in one folder).

In this step, get ready to:
  • Create the Flutter project and install Bloc ecosystem dependencies.
  • Set up the feature-first folder structure for the task board feature.
  • Run the app on the iOS Simulator to verify everything works.
Create a new Flutter project and add dependencies

Flutter's CLI tool scaffolds a complete project structure for you. You'll create the project on your Desktop so it's easy to find.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create the Flutter project by running:
flutter create task_board_app

You should see Flutter generating files, ending with a message that your application code is ready.

Seeing an error?

Make sure you have Flutter SDK 3.44 installed and on your PATH. Run flutter doctor to check for any issues with your environment.

Help me troubleshoot my flutter create command.

  • Move into the new project folder and open it in VS Code by running:
cd task_board_app
code .

What if code . doesn't work?

If your terminal says the code command is not found, open VS Code manually. Press Cmd+Space (macOS) or the Windows key (Windows), type VS Code, and press Enter. Then use File > Open Folder to open the task_board_app folder on your Desktop.

Now you need to add the Bloc ecosystem packages to your project. These packages provide the state management, concurrency control, value equality, and testing tools you'll use throughout this project.

  • In the VS Code Explorer sidebar, open pubspec.yaml.
  • Replace the entire contents of the file with this:
name: task_board_app
description: A Flutter task board demonstrating Bloc best practices.
publish_to: 'none'
version: 1.0.0+1

environment:
  sdk: ^3.7.0
  flutter: '>=3.44.0'

dependencies:
  flutter:
    sdk: flutter
  flutter_bloc: ^9.1.1
  bloc_concurrency: ^0.3.0
  equatable: ^2.0.8

dev_dependencies:
  flutter_test:
    sdk: flutter
  bloc_test: ^10.0.0
  mocktail: ^1.0.5

flutter:
  uses-material-design: true

What does this code do?

flutter_bloc provides the BlocProvider and BlocBuilder widgets that connect your Bloc to the UI. bloc_concurrency gives you event transformers like restartable() and sequential() to control how events are processed.

equatable makes it easy to compare objects by value instead of by reference. bloc_test and mocktail are dev dependencies for unit testing your Bloc in isolation.

  • Save pubspec.yaml.
  • Install the packages by running this in your terminal:
flutter pub get

You should see output listing the resolved packages, ending with a confirmation that dependencies are ready.

Seeing dependency resolution errors?

Check that your pubspec.yaml indentation uses exactly 2 spaces (not tabs). YAML is whitespace-sensitive, so even one extra space can cause failures.

Help me fix my pubspec.yaml dependency errors.

✔️ Awesome, I've got everything!

Great. Make sure you've saved pubspec.yaml before continuing.

ⓧ I'd like to double check the full code

name: task_board_app
description: A Flutter task board demonstrating Bloc best practices.
publish_to: 'none'
version: 1.0.0+1

environment:
  sdk: ^3.7.0
  flutter: '>=3.44.0'

dependencies:
  flutter:
    sdk: flutter
  flutter_bloc: ^9.1.1
  bloc_concurrency: ^0.3.0
  equatable: ^2.0.8

dev_dependencies:
  flutter_test:
    sdk: flutter
  bloc_test: ^10.0.0
  mocktail: ^1.0.5

flutter:
  uses-material-design: true
Set up the feature-first folder structure

A feature-first folder structure groups all the files for a single feature together. Your task board feature will have its own bloc/, data/, and view/ subdirectories.

  • Create the folder structure by running:
mkdir -p lib/task_board/bloc lib/task_board/data lib/task_board/view

What does this structure mean?

bloc/ holds your event classes, state classes, and the Bloc itself. data/ holds your models and repository classes. view/ holds the Page and View widgets that display the UI.

  • Confirm the folders exist by running:
ls lib/task_board

You should see bloc, data, and view listed in the output.

Missing folders?

Make sure you ran the mkdir command from inside the task_board_app directory. Run pwd to check your current location. The output should end with task_board_app.

Help me fix my folder structure.

Verify the setup by running the app

The best way to confirm everything is wired up correctly is to run the app. Flutter ships with a default counter app that should launch without errors if your dependencies installed correctly.

  • Open the iOS Simulator by running:
open -a Simulator
  • Once the simulator is visible, run the app by running:
flutter run

Flutter will compile your app and launch it on the simulator. You should see the default Flutter counter app with a floating action button and a counter that increments when you tap it.

App not launching?

If flutter run says no connected devices, make sure the iOS Simulator is fully booted (you see a home screen). Try running flutter devices to see what Flutter detects.

If you see Xcode build errors, run flutter doctor and follow the suggestions listed.

Help me get my Flutter app running on the simulator.

Your project is scaffolded and the Bloc packages are ready to go. Next up, you'll build the data layer with a Task model and repository that your Bloc will depend on.

Build the Data Layer

Your project structure is ready. Now you need something for the Bloc to manage.

Every Bloc-based feature needs a data layer underneath it. The data layer defines what your app's data looks like (models) and how it gets fetched, created, or modified (repositories). Building this layer first means your Bloc can be written entirely against an abstraction, making it easy to swap implementations later.

In this step, get ready to:
  • Define an immutable Task model class with value equality.
  • Create an abstract TaskRepository contract.
  • Implement an in-memory repository with seeded data and simulated delays.
Define the Task model

The Task model represents a single item on your board. Making it immutable (all fields are final) means state changes always produce new objects instead of mutating existing ones. This is essential for Bloc to detect when state has actually changed.

  • In VS Code, open the file lib/task_board/data/task.dart (create it if it doesn't exist) and paste the following code:
import 'package:equatable/equatable.dart';

class Task extends Equatable {
  const Task({
    required this.id,
    required this.title,
    this.description = '',
    this.isCompleted = false,
  });

  final String id;
  final String title;
  final String description;
  final bool isCompleted;

  // Creates a copy with only the specified fields changed
  Task copyWith({
    String? id,
    String? title,
    String? description,
    bool? isCompleted,
  }) {
    return Task(
      id: id ?? this.id,
      title: title ?? this.title,
      description: description ?? this.description,
      isCompleted: isCompleted ?? this.isCompleted,
    );
  }

  // Equatable uses these fields to determine equality
  @override
  List<Object?> get props => [id, title, description, isCompleted];
}

What does this code do?

The Task class extends Equatable so that two Task instances with the same field values are considered equal. Without this, Bloc would compare objects by reference and could miss state changes or re-emit unchanged states.

The copyWith method lets you create a modified copy without mutating the original. This is the standard immutable-update pattern in Dart.

  • Save task.dart.

You won't see a change in the running app yet. The next substep builds the repository that uses this model.

Seeing a red underline on the import?

Make sure you ran flutter pub get in the previous step. The equatable package must be downloaded before Dart can resolve the import.

Also confirm your file is saved at exactly lib/task_board/data/task.dart. A misplaced file won't be found by other parts of the project.

Still stuck? Help me fix this Equatable import error in my task.dart file.

✔️ Awesome, I've got everything!

Great. Make sure you've saved task.dart before moving on.

ⓧ I'd like to double check the full code

import 'package:equatable/equatable.dart';

class Task extends Equatable {
  const Task({
    required this.id,
    required this.title,
    this.description = '',
    this.isCompleted = false,
  });

  final String id;
  final String title;
  final String description;
  final bool isCompleted;

  Task copyWith({
    String? id,
    String? title,
    String? description,
    bool? isCompleted,
  }) {
    return Task(
      id: id ?? this.id,
      title: title ?? this.title,
      description: description ?? this.description,
      isCompleted: isCompleted ?? this.isCompleted,
    );
  }

  @override
  List<Object?> get props => [id, title, description, isCompleted];
}
Create the TaskRepository abstract class

The repository pattern separates your Bloc from the details of how data is stored or fetched. By coding against an abstract class, your Bloc works the same whether the data comes from memory, a REST API, or a local database.

  • Create a new file at lib/task_board/data/task_repository.dart and paste the abstract class definition:
import 'package:task_board_app/task_board/data/task.dart';

// Defines the contract any task data source must fulfill
abstract class TaskRepository {
  Future<List<Task>> getTasks();
  Future<List<Task>> searchTasks(String query);
  Future<void> addTask(Task task);
  Future<void> deleteTask(String id);
  Future<void> toggleTask(String id);
}

What does this code do?

This abstract class defines five methods that any task data source must implement. Every method returns a Future because real data sources (APIs, databases) are asynchronous.

The Bloc will depend on this TaskRepository type, never on a concrete implementation. This is what makes testing possible. You can swap in a mock repository during tests without touching the Bloc code.

  • Save task_repository.dart.

No visible change yet. The abstract class is a contract. The next substep provides the concrete implementation.

Implement the InMemoryTaskRepository

With the contract defined, you need a concrete implementation. An in-memory repository is perfect for development because it gives you instant feedback without needing a backend server.

  • In task_repository.dart, add the following InMemoryTaskRepository class below the abstract class:
class InMemoryTaskRepository implements TaskRepository {
  // Seeded sample tasks for development
  final List<Task> _tasks = [
    const Task(id: '1', title: 'Set up CI/CD pipeline', description: 'Configure GitHub Actions'),
    const Task(id: '2', title: 'Write unit tests', description: 'Cover Bloc layer'),
    const Task(id: '3', title: 'Design system review', description: 'Audit color tokens'),
    const Task(id: '4', title: 'API integration', description: 'Connect to REST endpoints'),
    const Task(id: '5', title: 'Performance profiling', description: 'Run DevTools timeline'),
  ];

  @override
  Future<List<Task>> getTasks() async {
    // Simulate network latency
    await Future.delayed(const Duration(milliseconds: 300));
    return List.of(_tasks);
  }

  @override
  Future<List<Task>> searchTasks(String query) async {
    await Future.delayed(const Duration(milliseconds: 300));
    if (query.isEmpty) return List.of(_tasks);
    return _tasks
        .where((task) => task.title.toLowerCase().contains(query.toLowerCase()))
        .toList();
  }

  @override
  Future<void> addTask(Task task) async {
    await Future.delayed(const Duration(milliseconds: 200));
    _tasks.add(task);
  }

  @override
  Future<void> deleteTask(String id) async {
    await Future.delayed(const Duration(milliseconds: 200));
    _tasks.removeWhere((task) => task.id == id);
  }

  @override
  Future<void> toggleTask(String id) async {
    await Future.delayed(const Duration(milliseconds: 200));
    final index = _tasks.indexWhere((task) => task.id == id);
    if (index != -1) {
      _tasks[index] = _tasks[index].copyWith(isCompleted: !_tasks[index].isCompleted);
    }
  }
}

What does this code do?

The InMemoryTaskRepository stores tasks in a plain Dart list. It starts with five seeded tasks so you have data to display immediately.

Each method includes a Future.delayed call to simulate real network latency. This is important because it forces your Bloc to handle async gaps correctly from the start. Without these delays, concurrency bugs hide until you connect a real API.

Notice that getTasks() returns List.of(_tasks) instead of _tasks directly. This creates a defensive copy so external code cannot accidentally mutate the repository's internal list.

  • Save task_repository.dart.
  • Confirm there are no errors by running this in your terminal:
flutter analyze

You should see No issues found! in the output. This confirms your data layer compiles without any type errors or missing imports.

Seeing analysis issues?

Check that your import statement at the top of task_repository.dart uses the full package path: import 'package:task_board_app/task_board/data/task.dart';. Relative imports can cause issues in Flutter packages.

Also verify that the InMemoryTaskRepository class is in the same file as the abstract TaskRepository class, not in a separate file.

Still stuck? Help me debug flutter analyze errors in my task_repository.dart file.

✔️ Awesome, I've got everything!

Great. Make sure both task.dart and task_repository.dart are saved before moving on.

ⓧ I'd like to double check the full code

import 'package:task_board_app/task_board/data/task.dart';

abstract class TaskRepository {
  Future<List<Task>> getTasks();
  Future<List<Task>> searchTasks(String query);
  Future<void> addTask(Task task);
  Future<void> deleteTask(String id);
  Future<void> toggleTask(String id);
}

class InMemoryTaskRepository implements TaskRepository {
  final List<Task> _tasks = [
    const Task(id: '1', title: 'Set up CI/CD pipeline', description: 'Configure GitHub Actions'),
    const Task(id: '2', title: 'Write unit tests', description: 'Cover Bloc layer'),
    const Task(id: '3', title: 'Design system review', description: 'Audit color tokens'),
    const Task(id: '4', title: 'API integration', description: 'Connect to REST endpoints'),
    const Task(id: '5', title: 'Performance profiling', description: 'Run DevTools timeline'),
  ];

  @override
  Future<List<Task>> getTasks() async {
    await Future.delayed(const Duration(milliseconds: 300));
    return List.of(_tasks);
  }

  @override
  Future<List<Task>> searchTasks(String query) async {
    await Future.delayed(const Duration(milliseconds: 300));
    if (query.isEmpty) return List.of(_tasks);
    return _tasks
        .where((task) => task.title.toLowerCase().contains(query.toLowerCase()))
        .toList();
  }

  @override
  Future<void> addTask(Task task) async {
    await Future.delayed(const Duration(milliseconds: 200));
    _tasks.add(task);
  }

  @override
  Future<void> deleteTask(String id) async {
    await Future.delayed(const Duration(milliseconds: 200));
    _tasks.removeWhere((task) => task.id == id);
  }

  @override
  Future<void> toggleTask(String id) async {
    await Future.delayed(const Duration(milliseconds: 200));
    final index = _tasks.indexWhere((task) => task.id == id);
    if (index != -1) {
      _tasks[index] = _tasks[index].copyWith(isCompleted: !_tasks[index].isCompleted);
    }
  }
}

Why an abstract class instead of coding directly?

In the testing step later, you'll create a MockTaskRepository that implements this same abstract class. Because your Bloc only knows about TaskRepository (not InMemoryTaskRepository), you can inject any implementation without changing a single line of Bloc code.

This is the repository pattern in action. It keeps your business logic decoupled from your data source.

Your data layer is locked in. You have a clean model, a testable contract, and a working implementation with realistic async behavior. Next up, you'll define the sealed events and states that drive your Bloc's logic.

Define Sealed Events and States

Your data layer gives the app something to work with. Now you need a way to describe what happens in the app and how the app responds.

In the Bloc pattern, events represent user intentions ("I want to add a task") and states represent the UI's current condition ("tasks loaded successfully"). Dart 3's sealed classes make this especially powerful because the compiler guarantees you handle every possible case.

In this step, get ready to:
  • Define a sealed event hierarchy following VGV naming conventions.
  • Define a sealed state hierarchy with data-carrying subclasses.
  • Verify exhaustive pattern matching with a switch statement.
Create the sealed event classes

Events are messages your UI sends to the Bloc when something happens. Each event describes a single user intention.

  • Create a new file at lib/task_board/bloc/task_board_event.dart and add the following code:
import 'package:equatable/equatable.dart';
import 'package:task_board_app/task_board/data/task.dart';

// The sealed keyword means only classes in this file can extend TaskBoardEvent
sealed class TaskBoardEvent extends Equatable {
  const TaskBoardEvent();

  @override
  List<Object?> get props => [];
}

// Fired when the UI first loads and needs tasks
final class TaskBoardSubscriptionRequested extends TaskBoardEvent {
  const TaskBoardSubscriptionRequested();
}

// Carries the full Task object to add
final class TaskBoardTaskAdded extends TaskBoardEvent {
  const TaskBoardTaskAdded({required this.task});

  final Task task;

  @override
  List<Object?> get props => [task];
}

// Carries only the ID of the task to delete
final class TaskBoardTaskDeleted extends TaskBoardEvent {
  const TaskBoardTaskDeleted({required this.taskId});

  final String taskId;

  @override
  List<Object?> get props => [taskId];
}

// Carries only the ID of the task to toggle
final class TaskBoardTaskToggled extends TaskBoardEvent {
  const TaskBoardTaskToggled({required this.taskId});

  final String taskId;

  @override
  List<Object?> get props => [taskId];
}

// Carries the search query string
final class TaskBoardSearchChanged extends TaskBoardEvent {
  const TaskBoardSearchChanged({required this.query});

  final String query;

  @override
  List<Object?> get props => [query];
}

What does this code do?

The sealed keyword restricts which classes can extend TaskBoardEvent. Only classes defined in this same file are allowed. This gives the Dart compiler a complete picture of every possible event.

Each subclass uses final class so nothing else can extend them further. The naming follows the VGV convention: BlocSubject + Noun + VerbPastTense (e.g. TaskBoardSubscriptionRequested, TaskBoardTaskDeleted).

Extending Equatable lets the Bloc compare events by value. Two TaskBoardTaskDeleted instances with the same taskId are considered equal.

Why this naming convention?

The VeryGoodVentures naming pattern (BlocSubject + Noun + VerbPastTense) reads like a sentence describing what already happened from the UI's perspective. "The task board's subscription was requested" or "a task board task was deleted."

This keeps event names descriptive and consistent across large codebases where multiple Blocs coexist.

  • Save task_board_event.dart.

Seeing red squiggles on the import?

Make sure the file is saved inside lib/task_board/bloc/. The import path package:task_board_app/task_board/data/task.dart relies on the exact folder structure you created in Step 1.

If the bloc/ folder doesn't exist yet, create it inside lib/task_board/.

Help me fix my import errors in task_board_event.dart

Create the sealed state classes

States describe what the UI should display at any given moment. Each state subclass represents a distinct screen condition.

  • Create a new file at lib/task_board/bloc/task_board_state.dart and add the following code:
import 'package:equatable/equatable.dart';
import 'package:task_board_app/task_board/data/task.dart';

// Sealed state hierarchy - the UI must handle every case
sealed class TaskBoardState extends Equatable {
  const TaskBoardState();

  @override
  List<Object?> get props => [];
}

// The Bloc has not yet been asked to load anything
final class TaskBoardInitial extends TaskBoardState {
  const TaskBoardInitial();
}

// An async operation is in progress
final class TaskBoardLoading extends TaskBoardState {
  const TaskBoardLoading();
}

// Tasks loaded successfully - carries the task list and current search query
final class TaskBoardSuccess extends TaskBoardState {
  const TaskBoardSuccess({
    required this.tasks,
    this.searchQuery = '',
  });

  final List<Task> tasks;
  final String searchQuery;

  @override
  List<Object?> get props => [tasks, searchQuery];
}

// Something went wrong - carries an error message
final class TaskBoardFailure extends TaskBoardState {
  const TaskBoardFailure({required this.message});

  final String message;

  @override
  List<Object?> get props => [message];
}

What does this code do?

  • TaskBoardInitial is the starting state before any events fire. The UI shows nothing.
  • TaskBoardLoading tells the UI to show a progress indicator while data loads.
  • TaskBoardSuccess carries the actual data: a list of tasks and the current search query string.
  • TaskBoardFailure carries an error message so the UI can display what went wrong.
  • Save task_board_state.dart.

Errors about missing Equatable?

Check that your pubspec.yaml includes equatable: ^2.0.8 under dependencies and that you've run flutter pub get since adding it.

Help me resolve my Equatable import issue

Verify exhaustive pattern matching

The whole point of sealed classes is compile-time exhaustiveness. If you forget to handle a state, the Dart analyzer tells you immediately.

  • Open the Dart terminal in VS Code by pressing Ctrl+` (macOS and Windows).
  • Run the analyzer to confirm your sealed classes compile cleanly:
dart analyze
  • You should see no errors reported. If the analyzer passes, your sealed class hierarchy is valid.

Now test what exhaustive matching looks like in practice. You'll see how the compiler forces you to handle every sealed subclass.

  • In any Dart file (you can use task_board_state.dart temporarily at the bottom), try typing this incomplete switch:
// Temporary test - delete after verifying
String describeState(TaskBoardState state) {
  return switch (state) {
    TaskBoardInitial() => 'Not started',
    TaskBoardLoading() => 'Loading...',
    // Try commenting out the lines below - the analyzer will complain
    TaskBoardSuccess(:final tasks) => '${tasks.length} tasks loaded',
    TaskBoardFailure(:final message) => 'Error: $message',
  };
}

What does this prove?

If you comment out any of the cases, the Dart analyzer immediately flags the switch as non-exhaustive. This means you can never accidentally forget to handle a state in your UI code.

The :final tasks syntax is a Dart 3 destructuring pattern. It pulls the tasks field out of TaskBoardSuccess directly inside the switch case.

  • Delete the temporary describeState function after you've confirmed the exhaustive behavior.
  • Save the file.

Analyzer not catching the missing case?

Make sure you're using the sealed keyword on the base class, not abstract. Only sealed enables exhaustiveness checking in switch expressions.

Also confirm your VS Code Dart extension is up to date. Older versions may not support Dart 3 sealed class analysis.

Help me troubleshoot sealed class exhaustiveness

✔️ Awesome, I've got everything!

Great. Make sure both files are saved and that you've deleted the temporary describeState function.

ⓧ I'd like to double check the full code

Here is the complete task_board_event.dart:

import 'package:equatable/equatable.dart';
import 'package:task_board_app/task_board/data/task.dart';

sealed class TaskBoardEvent extends Equatable {
  const TaskBoardEvent();

  @override
  List<Object?> get props => [];
}

final class TaskBoardSubscriptionRequested extends TaskBoardEvent {
  const TaskBoardSubscriptionRequested();
}

final class TaskBoardTaskAdded extends TaskBoardEvent {
  const TaskBoardTaskAdded({required this.task});

  final Task task;

  @override
  List<Object?> get props => [task];
}

final class TaskBoardTaskDeleted extends TaskBoardEvent {
  const TaskBoardTaskDeleted({required this.taskId});

  final String taskId;

  @override
  List<Object?> get props => [taskId];
}

final class TaskBoardTaskToggled extends TaskBoardEvent {
  const TaskBoardTaskToggled({required this.taskId});

  final String taskId;

  @override
  List<Object?> get props => [taskId];
}

final class TaskBoardSearchChanged extends TaskBoardEvent {
  const TaskBoardSearchChanged({required this.query});

  final String query;

  @override
  List<Object?> get props => [query];
}

And here is the complete task_board_state.dart:

import 'package:equatable/equatable.dart';
import 'package:task_board_app/task_board/data/task.dart';

sealed class TaskBoardState extends Equatable {
  const TaskBoardState();

  @override
  List<Object?> get props => [];
}

final class TaskBoardInitial extends TaskBoardState {
  const TaskBoardInitial();
}

final class TaskBoardLoading extends TaskBoardState {
  const TaskBoardLoading();
}

final class TaskBoardSuccess extends TaskBoardState {
  const TaskBoardSuccess({
    required this.tasks,
    this.searchQuery = '',
  });

  final List<Task> tasks;
  final String searchQuery;

  @override
  List<Object?> get props => [tasks, searchQuery];
}

final class TaskBoardFailure extends TaskBoardState {
  const TaskBoardFailure({required this.message});

  final String message;

  @override
  List<Object?> get props => [message];
}

Your events and states are now type-safe contracts between the UI and the Bloc. Next up, you'll build the Bloc itself and wire up event transformers that control how these events are processed concurrently.

Implement the Bloc and Wire Up the UI

You have a full set of sealed events and states, but nothing connects them to your repository yet. The Bloc is the bridge that listens for events, calls repository methods, and emits the appropriate states.

In this step you will create the TaskBoardBloc with event transformers that control concurrency, then wire everything together using the Page/View pattern so the app renders on your simulator.

In this step, get ready to:
  • Create the TaskBoardBloc with restartable and sequential event transformers.
  • Implement the isClosed guard pattern to prevent crashes during async disposal.
  • Wire up the UI with Page/View separation and RepositoryProvider.
Create the TaskBoardBloc with event transformers

Event transformers decide how the Bloc handles multiple events arriving at the same time. restartable() cancels any in-flight handler when a new event of the same type arrives, which is perfect for search-as-you-type. sequential() queues events so they process one at a time, preventing race conditions on mutations like add and delete.

  • Create a new file at lib/task_board/bloc/task_board_bloc.dart and add the following code:
import 'package:bloc/bloc.dart';
import 'package:bloc_concurrency/bloc_concurrency.dart';
import 'package:task_board_app/task_board/bloc/task_board_event.dart';
import 'package:task_board_app/task_board/bloc/task_board_state.dart';
import 'package:task_board_app/task_board/data/task_repository.dart';

class TaskBoardBloc extends Bloc<TaskBoardEvent, TaskBoardState> {
  TaskBoardBloc({required TaskRepository taskRepository})
      : _taskRepository = taskRepository,
        super(const TaskBoardInitial()) {
    // Sequential ensures mutations process one at a time
    on<TaskBoardSubscriptionRequested>(
      _onSubscriptionRequested,
      transformer: sequential(),
    );
    on<TaskBoardTaskAdded>(
      _onTaskAdded,
      transformer: sequential(),
    );
    on<TaskBoardTaskDeleted>(
      _onTaskDeleted,
      transformer: sequential(),
    );
    on<TaskBoardTaskToggled>(
      _onTaskToggled,
      transformer: sequential(),
    );
    // Restartable cancels previous search when new input arrives
    on<TaskBoardSearchChanged>(
      _onSearchChanged,
      transformer: restartable(),
    );
  }

  final TaskRepository _taskRepository;

  Future<void> _onSubscriptionRequested(
    TaskBoardSubscriptionRequested event,
    Emitter<TaskBoardState> emit,
  ) async {
    emit(const TaskBoardLoading());
    try {
      final tasks = await _taskRepository.getTasks();
      if (isClosed) return;
      emit(TaskBoardSuccess(tasks: tasks));
    } catch (e) {
      if (isClosed) return;
      emit(TaskBoardFailure(message: e.toString()));
    }
  }
}

What does this code do?

The constructor receives a TaskRepository and registers five event handlers. Each on<Event> call binds a handler method to a specific event type and assigns a transformer that controls concurrency.

The _onSubscriptionRequested handler emits TaskBoardLoading first, then fetches tasks from the repository. After the await, it checks isClosed before calling emit to avoid a StateError if the Bloc was disposed during the async gap.

  • Save task_board_bloc.dart.
  • Your IDE may show red underlines for the missing handler methods. That is expected because you have not added them yet.
Add the remaining handlers with isClosed guards

Every handler that calls await needs an isClosed check before each subsequent emit. Without this guard, navigating away while an async operation is in flight throws a StateError because the Bloc has already been closed by BlocProvider.

  • In task_board_bloc.dart, add the following four handler methods below the _onSubscriptionRequested method (inside the class, before the closing brace):
  Future<void> _onTaskAdded(
    TaskBoardTaskAdded event,
    Emitter<TaskBoardState> emit,
  ) async {
    try {
      await _taskRepository.addTask(event.task);
      if (isClosed) return;
      final tasks = await _taskRepository.getTasks();
      if (isClosed) return;
      emit(TaskBoardSuccess(tasks: tasks));
    } catch (e) {
      if (isClosed) return;
      emit(TaskBoardFailure(message: e.toString()));
    }
  }

  Future<void> _onTaskDeleted(
    TaskBoardTaskDeleted event,
    Emitter<TaskBoardState> emit,
  ) async {
    try {
      await _taskRepository.deleteTask(event.taskId);
      if (isClosed) return;
      final tasks = await _taskRepository.getTasks();
      if (isClosed) return;
      emit(TaskBoardSuccess(tasks: tasks));
    } catch (e) {
      if (isClosed) return;
      emit(TaskBoardFailure(message: e.toString()));
    }
  }

  Future<void> _onTaskToggled(
    TaskBoardTaskToggled event,
    Emitter<TaskBoardState> emit,
  ) async {
    try {
      await _taskRepository.toggleTask(event.taskId);
      if (isClosed) return;
      final tasks = await _taskRepository.getTasks();
      if (isClosed) return;
      emit(TaskBoardSuccess(tasks: tasks));
    } catch (e) {
      if (isClosed) return;
      emit(TaskBoardFailure(message: e.toString()));
    }
  }

  Future<void> _onSearchChanged(
    TaskBoardSearchChanged event,
    Emitter<TaskBoardState> emit,
  ) async {
    try {
      final tasks = await _taskRepository.searchTasks(event.query);
      if (isClosed) return;
      emit(TaskBoardSuccess(tasks: tasks, searchQuery: event.query));
    } catch (e) {
      if (isClosed) return;
      emit(TaskBoardFailure(message: e.toString()));
    }
  }

Why check isClosed after every await?

When a user navigates away from a screen, BlocProvider automatically calls close() on the Bloc. If an async operation (like a network call) is still in flight, the handler continues executing after the await resumes.

Calling emit on a closed Bloc throws a StateError with the message "Cannot emit new states after calling close". The if (isClosed) return guard is a one-line safety check that silently exits the handler instead of crashing.

  • Save task_board_bloc.dart.
  • Confirm your IDE shows no red underlines in the file.

Seeing red underlines in the Bloc file?

Check that your import paths match exactly: package:bloc/bloc.dart for the Bloc base class and package:bloc_concurrency/bloc_concurrency.dart for the transformers.

Also verify each handler method signature matches the event type exactly (e.g. TaskBoardTaskAdded event not TaskBoardEvent event).

help me fix the red underlines in my task_board_bloc.dart file

✔️ Awesome, I've got everything!

Great. Double check you have saved task_board_bloc.dart with all five handler methods and no analysis errors.

ⓧ I'd like to double check the full code

import 'package:bloc/bloc.dart';
import 'package:bloc_concurrency/bloc_concurrency.dart';
import 'package:task_board_app/task_board/bloc/task_board_event.dart';
import 'package:task_board_app/task_board/bloc/task_board_state.dart';
import 'package:task_board_app/task_board/data/task_repository.dart';

class TaskBoardBloc extends Bloc<TaskBoardEvent, TaskBoardState> {
  TaskBoardBloc({required TaskRepository taskRepository})
      : _taskRepository = taskRepository,
        super(const TaskBoardInitial()) {
    on<TaskBoardSubscriptionRequested>(
      _onSubscriptionRequested,
      transformer: sequential(),
    );
    on<TaskBoardTaskAdded>(
      _onTaskAdded,
      transformer: sequential(),
    );
    on<TaskBoardTaskDeleted>(
      _onTaskDeleted,
      transformer: sequential(),
    );
    on<TaskBoardTaskToggled>(
      _onTaskToggled,
      transformer: sequential(),
    );
    on<TaskBoardSearchChanged>(
      _onSearchChanged,
      transformer: restartable(),
    );
  }

  final TaskRepository _taskRepository;

  Future<void> _onSubscriptionRequested(
    TaskBoardSubscriptionRequested event,
    Emitter<TaskBoardState> emit,
  ) async {
    emit(const TaskBoardLoading());
    try {
      final tasks = await _taskRepository.getTasks();
      if (isClosed) return;
      emit(TaskBoardSuccess(tasks: tasks));
    } catch (e) {
      if (isClosed) return;
      emit(TaskBoardFailure(message: e.toString()));
    }
  }

  Future<void> _onTaskAdded(
    TaskBoardTaskAdded event,
    Emitter<TaskBoardState> emit,
  ) async {
    try {
      await _taskRepository.addTask(event.task);
      if (isClosed) return;
      final tasks = await _taskRepository.getTasks();
      if (isClosed) return;
      emit(TaskBoardSuccess(tasks: tasks));
    } catch (e) {
      if (isClosed) return;
      emit(TaskBoardFailure(message: e.toString()));
    }
  }

  Future<void> _onTaskDeleted(
    TaskBoardTaskDeleted event,
    Emitter<TaskBoardState> emit,
  ) async {
    try {
      await _taskRepository.deleteTask(event.taskId);
      if (isClosed) return;
      final tasks = await _taskRepository.getTasks();
      if (isClosed) return;
      emit(TaskBoardSuccess(tasks: tasks));
    } catch (e) {
      if (isClosed) return;
      emit(TaskBoardFailure(message: e.toString()));
    }
  }

  Future<void> _onTaskToggled(
    TaskBoardTaskToggled event,
    Emitter<TaskBoardState> emit,
  ) async {
    try {
      await _taskRepository.toggleTask(event.taskId);
      if (isClosed) return;
      final tasks = await _taskRepository.getTasks();
      if (isClosed) return;
      emit(TaskBoardSuccess(tasks: tasks));
    } catch (e) {
      if (isClosed) return;
      emit(TaskBoardFailure(message: e.toString()));
    }
  }

  Future<void> _onSearchChanged(
    TaskBoardSearchChanged event,
    Emitter<TaskBoardState> emit,
  ) async {
    try {
      final tasks = await _taskRepository.searchTasks(event.query);
      if (isClosed) return;
      emit(TaskBoardSuccess(tasks: tasks, searchQuery: event.query));
    } catch (e) {
      if (isClosed) return;
      emit(TaskBoardFailure(message: e.toString()));
    }
  }
}
Wire up the Bloc to the UI with the Page/View pattern

The Page/View pattern separates dependency injection from UI rendering. The Page widget provides the Bloc (and reads the repository from the widget tree), while the View widget consumes the Bloc state via BlocBuilder.

  • Create a new file at lib/task_board/view/task_board_page.dart and add the following code:
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:task_board_app/task_board/bloc/task_board_bloc.dart';
import 'package:task_board_app/task_board/bloc/task_board_event.dart';
import 'package:task_board_app/task_board/data/task_repository.dart';
import 'package:task_board_app/task_board/view/task_board_view.dart';

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

  @override
  Widget build(BuildContext context) {
    return BlocProvider(
      // Read the repository from the ancestor RepositoryProvider
      create: (context) => TaskBoardBloc(
        taskRepository: context.read<TaskRepository>(),
      )..add(const TaskBoardSubscriptionRequested()),
      child: const TaskBoardView(),
    );
  }
}

What does this code do?

TaskBoardPage uses BlocProvider to create the TaskBoardBloc and inject it into the widget tree. The cascade operator ..add() immediately dispatches TaskBoardSubscriptionRequested so the Bloc loads data as soon as the page appears.

context.read<TaskRepository>() pulls the repository from the ancestor RepositoryProvider you will add to main.dart shortly.

  • Save task_board_page.dart.
  • Create a new file at lib/task_board/view/task_board_view.dart and add the following code:
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:task_board_app/task_board/bloc/task_board_bloc.dart';
import 'package:task_board_app/task_board/bloc/task_board_event.dart';
import 'package:task_board_app/task_board/bloc/task_board_state.dart';
import 'package:task_board_app/task_board/data/task.dart';

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Task Board'),
      ),
      body: Column(
        children: [
          // Search field dispatches TaskBoardSearchChanged on every keystroke
          Padding(
            padding: const EdgeInsets.all(16.0),
            child: TextField(
              decoration: const InputDecoration(
                hintText: 'Search tasks...',
                prefixIcon: Icon(Icons.search),
                border: OutlineInputBorder(),
              ),
              onChanged: (query) {
                context.read<TaskBoardBloc>().add(
                      TaskBoardSearchChanged(query: query),
                    );
              },
            ),
          ),
          // BlocBuilder with exhaustive switch on sealed states
          Expanded(
            child: BlocBuilder<TaskBoardBloc, TaskBoardState>(
              builder: (context, state) {
                return switch (state) {
                  TaskBoardInitial() => const SizedBox.shrink(),
                  TaskBoardLoading() => const Center(
                      child: CircularProgressIndicator(),
                    ),
                  TaskBoardSuccess(:final tasks) => tasks.isEmpty
                      ? const Center(child: Text('No tasks found'))
                      : ListView.builder(
                          itemCount: tasks.length,
                          itemBuilder: (context, index) {
                            final task = tasks[index];
                            return _TaskTile(task: task);
                          },
                        ),
                  TaskBoardFailure(:final message) => Center(
                      child: Text('Error: $message'),
                    ),
                };
              },
            ),
          ),
        ],
      ),
      // FAB adds a new task
      floatingActionButton: FloatingActionButton(
        onPressed: () {
          final newTask = Task(
            id: DateTime.now().millisecondsSinceEpoch.toString(),
            title: 'New Task ${DateTime.now().second}',
            description: 'Added from FAB',
          );
          context.read<TaskBoardBloc>().add(TaskBoardTaskAdded(task: newTask));
        },
        child: const Icon(Icons.add),
      ),
    );
  }
}

class _TaskTile extends StatelessWidget {
  const _TaskTile({required this.task});

  final Task task;

  @override
  Widget build(BuildContext context) {
    return Dismissible(
      key: Key(task.id),
      // Swipe to delete
      onDismissed: (_) {
        context.read<TaskBoardBloc>().add(TaskBoardTaskDeleted(taskId: task.id));
      },
      background: Container(
        color: Colors.red,
        alignment: Alignment.centerRight,
        padding: const EdgeInsets.only(right: 16),
        child: const Icon(Icons.delete, color: Colors.white),
      ),
      child: ListTile(
        leading: Checkbox(
          value: task.isCompleted,
          onChanged: (_) {
            context.read<TaskBoardBloc>().add(
                  TaskBoardTaskToggled(taskId: task.id),
                );
          },
        ),
        title: Text(
          task.title,
          style: TextStyle(
            decoration: task.isCompleted ? TextDecoration.lineThrough : null,
          ),
        ),
        subtitle: task.description.isNotEmpty ? Text(task.description) : null,
      ),
    );
  }
}

What does this code do?

The BlocBuilder uses a Dart 3 switch expression over your sealed TaskBoardState class. Because the state is sealed, the compiler guarantees every subclass is handled. No default case is needed.

The _TaskTile widget wraps each task in a Dismissible for swipe-to-delete and a Checkbox for toggling completion. Each interaction dispatches the corresponding event to the Bloc.

  • Save task_board_view.dart.
  • Now replace the contents of lib/main.dart with the following code to bootstrap the app with RepositoryProvider:
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:task_board_app/task_board/data/task_repository.dart';
import 'package:task_board_app/task_board/view/task_board_page.dart';

void main() {
  runApp(const TaskBoardApp());
}

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

  @override
  Widget build(BuildContext context) {
    // RepositoryProvider makes InMemoryTaskRepository available to the tree
    return RepositoryProvider<TaskRepository>(
      create: (_) => InMemoryTaskRepository(),
      child: const MaterialApp(
        title: 'Task Board',
        home: TaskBoardPage(),
      ),
    );
  }
}

What does this code do?

RepositoryProvider<TaskRepository> sits at the top of the widget tree and makes the InMemoryTaskRepository instance available to any descendant via context.read<TaskRepository>().

This keeps the Bloc decoupled from the concrete repository implementation. In production you could swap InMemoryTaskRepository for a real API client without touching the Bloc or UI code.

  • Save main.dart.
  • Run the app on your iOS Simulator by running:
flutter run
  • You should see a Task Board screen with five seeded tasks, a search bar at the top, and a floating action button in the bottom right corner.
  • Try typing in the search bar to filter tasks, tapping a checkbox to toggle completion, swiping a task to delete it, and pressing the + button to add a new task.

App won't compile or shows a blank screen?

If you see a compile error about missing imports, verify that task_board_page.dart imports task_board_view.dart and that main.dart imports task_board_page.dart.

If the app compiles but shows a blank screen, confirm that TaskBoardPage dispatches TaskBoardSubscriptionRequested in its create callback using the ..add() cascade.

help me debug why my Task Board app is not displaying tasks

Your Bloc is fully wired and the app is running. Next up, you will write unit tests that verify this Bloc logic in isolation using bloc_test and mocktail.

Write Tests with bloc_test and mocktail

Your Task Board app is fully wired up and running. The architecture looks clean, but how do you know the Bloc logic actually works correctly under every condition?

Unit tests answer that question. In this step, you'll write a test suite that exercises your TaskBoardBloc in complete isolation from the UI. You'll use mocktail to fake the repository and bloc_test to assert the exact sequence of states the Bloc emits.

In this step, get ready to:
  • Create a mock repository using mocktail for isolated testing.
  • Write happy-path tests covering subscription, search, and task creation.
  • Write edge-case tests for error handling and mutation events.
Create a MockTaskRepository and test scaffold

Testing a Bloc means testing it without a real database or network. You replace the real repository with a mock that returns whatever data you tell it to. This lets you control every scenario precisely.

  • Create the test directory structure by running this command in your terminal:
mkdir -p test/task_board/bloc
  • Create a new file called task_board_bloc_test.dart inside test/task_board/bloc/.
  • Add the test scaffold with imports, the mock class, and the test setup:
import 'package:bloc_test/bloc_test.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:task_board_app/task_board/bloc/task_board_bloc.dart';
import 'package:task_board_app/task_board/bloc/task_board_event.dart';
import 'package:task_board_app/task_board/bloc/task_board_state.dart';
import 'package:task_board_app/task_board/data/task.dart';
import 'package:task_board_app/task_board/data/task_repository.dart';

class MockTaskRepository extends Mock implements TaskRepository {}

void main() {
  late MockTaskRepository mockRepository;

  const testTasks = [
    Task(id: '1', title: 'Task One', description: 'Description one'),
    Task(id: '2', title: 'Task Two', description: 'Description two'),
  ];

  setUp(() {
    mockRepository = MockTaskRepository();
  });

  setUpAll(() {
    registerFallbackValue(
      const Task(id: '', title: '', description: ''),
    );
  });

  group('TaskBoardBloc', () {
    // Tests go here
  });
}

What does this code do?

MockTaskRepository extends Mock implements TaskRepository creates a fake repository. mocktail generates the method stubs automatically. No code generation step needed.

The testTasks list is a simple, predictable dataset that every test shares. Using setUp creates a fresh mock before each test so no state leaks between them.

registerFallbackValue tells mocktail what to use when a stubbed method receives any() as a matcher. Without it, mocktail throws a runtime error for non-primitive argument matching.

  • Save task_board_bloc_test.dart.
  • Confirm the file compiles without errors by running:
flutter test test/task_board/bloc/task_board_bloc_test.dart

You should see output showing 0 tests passed (since the group is empty). No errors means the scaffold is correct.

Seeing import errors?

Make sure you ran flutter pub get after adding bloc_test and mocktail to your pubspec.yaml in Step 1. Also verify the import paths match your project's package name exactly.

Help me fix import errors in my bloc test file.

Write happy-path tests for subscription and search

Happy-path tests verify that the Bloc behaves correctly when everything works as expected. The blocTest function from bloc_test gives you a declarative way to build a Bloc, dispatch events, and assert the emitted states in order.

  • Inside the group('TaskBoardBloc', () { ... }) block, replace the // Tests go here comment with this first test:
    blocTest<TaskBoardBloc, TaskBoardState>(
      'emits [loading, success] when subscription is requested',
      setUp: () {
        when(() => mockRepository.getTasks())
            .thenAnswer((_) async => testTasks);
      },
      build: () => TaskBoardBloc(taskRepository: mockRepository),
      act: (bloc) => bloc.add(const TaskBoardSubscriptionRequested()),
      expect: () => [
        const TaskBoardLoading(),
        const TaskBoardSuccess(tasks: testTasks),
      ],
    );

What does this test do?

  • setUp stubs the mock so getTasks() returns your predictable testTasks list.
  • build creates a fresh Bloc instance injected with the mock.
  • act dispatches the event you want to test.
  • expect lists the exact sequence of states the Bloc should emit. Order matters.
  • Add the search test directly below the subscription test (still inside the group):
    blocTest<TaskBoardBloc, TaskBoardState>(
      'emits [success] with filtered tasks when search changes',
      setUp: () {
        when(() => mockRepository.searchTasks('One'))
            .thenAnswer((_) async => [testTasks[0]]);
      },
      build: () => TaskBoardBloc(taskRepository: mockRepository),
      act: (bloc) => bloc.add(const TaskBoardSearchChanged(query: 'One')),
      expect: () => [
        const TaskBoardSuccess(tasks: [testTasks[0]], searchQuery: 'One'),
      ],
    );

Why only one state is expected here

The search handler does not emit a TaskBoardLoading state before returning results. It goes straight to TaskBoardSuccess. This is intentional for search-as-you-type. A loading spinner on every keystroke would feel janky.

The restartable() transformer on this handler means if multiple search events fire rapidly, only the latest one completes. Previous in-flight searches are cancelled.

  • Add the task-added test below the search test:
    blocTest<TaskBoardBloc, TaskBoardState>(
      'emits [success] with updated list when task is added',
      setUp: () {
        when(() => mockRepository.addTask(any())).thenAnswer((_) async {});
        when(() => mockRepository.getTasks())
            .thenAnswer((_) async => testTasks);
      },
      build: () => TaskBoardBloc(taskRepository: mockRepository),
      act: (bloc) => bloc.add(
        const TaskBoardTaskAdded(
          task: Task(id: '3', title: 'New Task'),
        ),
      ),
      expect: () => [
        const TaskBoardSuccess(tasks: testTasks),
      ],
      verify: (_) {
        verify(() => mockRepository.addTask(any())).called(1);
      },
    );

What does the verify callback do?

The verify callback runs after the test completes. It uses mocktail's verify() to confirm that addTask was actually called on the repository exactly once. This catches bugs where the Bloc might emit the right state without actually persisting the change.

The any() matcher works here because of the registerFallbackValue you set up earlier.

  • Save task_board_bloc_test.dart and run the tests:
flutter test test/task_board/bloc/task_board_bloc_test.dart

You should see 3 tests passed with green checkmarks.

Tests failing with unexpected states?

Check that your stub uses thenAnswer (not thenReturn) for async methods. Using thenReturn with a Future causes type errors. Also verify that the expect list matches the exact state types your Bloc emits.

Help me debug failing bloc tests.

Write edge-case tests for errors and mutations

Happy-path tests confirm things work when the stars align. Edge-case tests confirm your Bloc handles failure gracefully and that mutation events (delete, toggle) process correctly through the sequential() transformer.

  • Add the error-handling test below the task-added test:
    blocTest<TaskBoardBloc, TaskBoardState>(
      'emits [loading, failure] when repository throws',
      setUp: () {
        when(() => mockRepository.getTasks()).thenThrow(Exception('Network error'));
      },
      build: () => TaskBoardBloc(taskRepository: mockRepository),
      act: (bloc) => bloc.add(const TaskBoardSubscriptionRequested()),
      expect: () => [
        const TaskBoardLoading(),
        isA<TaskBoardFailure>(),
      ],
    );

Why use isA instead of a concrete state?

The error message format from Exception.toString() can vary across Dart versions. Using isA<TaskBoardFailure>() asserts the state TYPE is correct without coupling the test to the exact string representation. This makes your test resilient to minor runtime differences.

  • Add the delete test below the error test:
    blocTest<TaskBoardBloc, TaskBoardState>(
      'emits [success] when task is deleted',
      setUp: () {
        when(() => mockRepository.deleteTask('1')).thenAnswer((_) async {});
        when(() => mockRepository.getTasks())
            .thenAnswer((_) async => [testTasks[1]]);
      },
      build: () => TaskBoardBloc(taskRepository: mockRepository),
      act: (bloc) => bloc.add(const TaskBoardTaskDeleted(taskId: '1')),
      expect: () => [
        TaskBoardSuccess(tasks: [testTasks[1]]),
      ],
    );

What does this test verify?

After deleting task '1', the Bloc fetches the updated task list. The stub returns only testTasks[1], confirming the deleted task is gone. The sequential() transformer ensures this delete completes fully before any next event starts processing.

  • Add the toggle test below the delete test:
    blocTest<TaskBoardBloc, TaskBoardState>(
      'emits [success] when task is toggled',
      setUp: () {
        when(() => mockRepository.toggleTask('1')).thenAnswer((_) async {});
        when(() => mockRepository.getTasks()).thenAnswer(
          (_) async => [
            testTasks[0].copyWith(isCompleted: true),
            testTasks[1],
          ],
        );
      },
      build: () => TaskBoardBloc(taskRepository: mockRepository),
      act: (bloc) => bloc.add(const TaskBoardTaskToggled(taskId: '1')),
      expect: () => [
        TaskBoardSuccess(
          tasks: [testTasks[0].copyWith(isCompleted: true), testTasks[1]],
        ),
      ],
    );

What does this test verify?

The stub returns a list where task '1' has isCompleted: true. The expected state uses the same copyWith call. Because both Task and TaskBoardSuccess extend Equatable, the comparison works by value, not by reference.

  • Save task_board_bloc_test.dart and run the full test suite:
flutter test test/task_board/bloc/task_board_bloc_test.dart

You should see 6 tests passed. All green.

Toggle test failing with mismatched state?

Make sure your expect uses the same copyWith(isCompleted: true) call as your stub. If the expected state and emitted state have different property values, Equatable comparison fails even though the types match.

Help me debug my toggle test assertion.

✔️ Awesome, I've got everything!

Great. Double check you've saved your file and all 6 tests pass.

ⓧ I'd like to double check the full code

import 'package:bloc_test/bloc_test.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:task_board_app/task_board/bloc/task_board_bloc.dart';
import 'package:task_board_app/task_board/bloc/task_board_event.dart';
import 'package:task_board_app/task_board/bloc/task_board_state.dart';
import 'package:task_board_app/task_board/data/task.dart';
import 'package:task_board_app/task_board/data/task_repository.dart';

class MockTaskRepository extends Mock implements TaskRepository {}

void main() {
  late MockTaskRepository mockRepository;

  const testTasks = [
    Task(id: '1', title: 'Task One', description: 'Description one'),
    Task(id: '2', title: 'Task Two', description: 'Description two'),
  ];

  setUp(() {
    mockRepository = MockTaskRepository();
  });

  setUpAll(() {
    registerFallbackValue(
      const Task(id: '', title: '', description: ''),
    );
  });

  group('TaskBoardBloc', () {
    blocTest<TaskBoardBloc, TaskBoardState>(
      'emits [loading, success] when subscription is requested',
      setUp: () {
        when(() => mockRepository.getTasks())
            .thenAnswer((_) async => testTasks);
      },
      build: () => TaskBoardBloc(taskRepository: mockRepository),
      act: (bloc) => bloc.add(const TaskBoardSubscriptionRequested()),
      expect: () => [
        const TaskBoardLoading(),
        const TaskBoardSuccess(tasks: testTasks),
      ],
    );

    blocTest<TaskBoardBloc, TaskBoardState>(
      'emits [loading, failure] when repository throws',
      setUp: () {
        when(() => mockRepository.getTasks()).thenThrow(Exception('Network error'));
      },
      build: () => TaskBoardBloc(taskRepository: mockRepository),
      act: (bloc) => bloc.add(const TaskBoardSubscriptionRequested()),
      expect: () => [
        const TaskBoardLoading(),
        isA<TaskBoardFailure>(),
      ],
    );

    blocTest<TaskBoardBloc, TaskBoardState>(
      'emits [success] with filtered tasks when search changes',
      setUp: () {
        when(() => mockRepository.searchTasks('One'))
            .thenAnswer((_) async => [testTasks[0]]);
      },
      build: () => TaskBoardBloc(taskRepository: mockRepository),
      act: (bloc) => bloc.add(const TaskBoardSearchChanged(query: 'One')),
      expect: () => [
        const TaskBoardSuccess(tasks: [testTasks[0]], searchQuery: 'One'),
      ],
    );

    blocTest<TaskBoardBloc, TaskBoardState>(
      'emits [success] with updated list when task is added',
      setUp: () {
        when(() => mockRepository.addTask(any())).thenAnswer((_) async {});
        when(() => mockRepository.getTasks())
            .thenAnswer((_) async => testTasks);
      },
      build: () => TaskBoardBloc(taskRepository: mockRepository),
      act: (bloc) => bloc.add(
        const TaskBoardTaskAdded(
          task: Task(id: '3', title: 'New Task'),
        ),
      ),
      expect: () => [
        const TaskBoardSuccess(tasks: testTasks),
      ],
      verify: (_) {
        verify(() => mockRepository.addTask(any())).called(1);
      },
    );

    blocTest<TaskBoardBloc, TaskBoardState>(
      'emits [success] when task is deleted',
      setUp: () {
        when(() => mockRepository.deleteTask('1')).thenAnswer((_) async {});
        when(() => mockRepository.getTasks())
            .thenAnswer((_) async => [testTasks[1]]);
      },
      build: () => TaskBoardBloc(taskRepository: mockRepository),
      act: (bloc) => bloc.add(const TaskBoardTaskDeleted(taskId: '1')),
      expect: () => [
        TaskBoardSuccess(tasks: [testTasks[1]]),
      ],
    );

    blocTest<TaskBoardBloc, TaskBoardState>(
      'emits [success] when task is toggled',
      setUp: () {
        when(() => mockRepository.toggleTask('1')).thenAnswer((_) async {});
        when(() => mockRepository.getTasks()).thenAnswer(
          (_) async => [
            testTasks[0].copyWith(isCompleted: true),
            testTasks[1],
          ],
        );
      },
      build: () => TaskBoardBloc(taskRepository: mockRepository),
      act: (bloc) => bloc.add(const TaskBoardTaskToggled(taskId: '1')),
      expect: () => [
        TaskBoardSuccess(
          tasks: [testTasks[0].copyWith(isCompleted: true), testTasks[1]],
        ),
      ],
    );
  });
}

Secret mission

Add BlocObserver and isClosed Edge-Case Test

Your Bloc is production-ready, but how do you debug state transitions across an entire app? And how do you prove that your isClosed guards actually prevent crashes during widget disposal? This mission adds global observability and a test that simulates the exact race condition that causes StateError in production.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally, so there are no ongoing costs. Decide whether to keep your project files to continue experimenting, or delete them to free up disk space.

Resources you used:

  • The task_board_app project folder on your Desktop.
  • iOS Simulator (or Android emulator) running your app.

Keep everything running

No action needed. Choose this if you want to keep experimenting with the Bloc architecture, adding new features, or extending the test suite.

  • Your project folder stays on your Desktop at ~/Desktop/task_board_app.
  • The iOS Simulator continues running if you left it open.

Pause - I'll come back to this later

Stop the running processes to free up memory, but keep your project files intact for later.

  • In VS Code, press Ctrl+C in the terminal to stop the Flutter dev server.
  • Quit the iOS Simulator by pressing Cmd+Q (macOS).
  • Your project folder remains at ~/Desktop/task_board_app ready to resume.

Delete - I don't want to use this again

Remove all project files from your machine. You can always recreate the project by following the steps again.

  • In VS Code, press Ctrl+C in the terminal to stop the Flutter dev server.
  • Quit the iOS Simulator by pressing Cmd+Q (macOS).
  • Delete the project folder by running this command in your terminal:
rm -rf ~/Desktop/task_board_app

On Windows, run this instead:

Remove-Item -Recurse -Force ~\Desktop\task_board_app
  • Confirm the folder is gone by running:
ls ~/Desktop

Nice Work!

Nice Work!

Nice work! You just built a production-quality Flutter feature using the Bloc pattern with Dart 3 sealed classes, event transformers, and a complete unit test suite.

You've learned how to:

  • Architect a Flutter feature using the Bloc pattern with Dart 3 sealed classes for compile-time exhaustive event and state handling.
  • Implement event transformers (restartable for search, sequential for mutations) to handle concurrency safely without race conditions.
  • Write a full test suite using bloc_test and mocktail demonstrating mocking, state assertions, and edge-case handling with Page/View separation and RepositoryProvider for clean dependency injection.
  • Secret Mission: Added a BlocObserver for global state logging and tested the isClosed guard pattern that prevents post-disposal crashes.

Ready to quiz yourself?