Animated Task App with Flutter & BLoC
Build a task app with staggered animations and Clean Architecture BLoC.
Introduction
30 Second Summary
Every time you use a polished app with smooth animations, those transitions between states are carefully orchestrated. They make the difference between an app that feels amateur and one that feels production-ready.
In this project, you will build TaskMotion, an animated task management app using Flutter, BLoC state management, and Clean Architecture. You will create staggered entrance animations, animated task completions with checkmarks, and a scalable project structure that separates concerns into proper layers.
What You'll Build
A beautiful animated task management app called TaskMotion, with three types of Flutter animations driven reactively by BLoC state changes within a Clean Architecture project structure.
By the end of this project, you'll have:
- A working Flutter mobile app with three animation types: staggered entrance, explicit checkmark, and implicit color transitions.
- A Clean Architecture folder structure with proper separation between Domain, Data, and Presentation layers.
- A TaskBloc that manages task state with events and states, driving animations reactively through state changes.
- Secret Mission: Add a Hero animation transition to a task detail page with physics-based spring animation.
Are there any prerequisites?
This project assumes basic Dart and Flutter knowledge. You should be comfortable with widgets, StatefulWidget, and StatelessWidget before starting.
Set Up the Flutter Project & Dependencies
Every Flutter app starts with a project scaffold and a clear folder structure. For TaskMotion, you need three external packages to handle state management, dependency injection, and value equality.
In this step, you will create the Flutter project, add the required packages, and build a folder structure that follows Clean Architecture.
In this step, get ready to:
- Create a new Flutter project called task_motion.
- Add BLoC, GetIt, and Equatable packages to the project.
- Set up the Clean Architecture folder structure under lib/.
Create the Flutter project
The flutter create command scaffolds a full app template with a lib/ folder, pubspec.yaml, and platform-specific directories.
- Open your terminal by pressing Cmd+Space (macOS) or the Windows key (Windows), then type Terminal and press Enter.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create the project by running this command:
flutter create task_motion
What does this command do?
The flutter create command generates a complete Flutter app template. It creates the project folder, adds a sample main.dart file, pulls default dependencies, and sets up platform folders for Android, iOS, web, and more.
This command takes a moment to run. You will see output listing every file it creates.
- Move into the new project folder by running:
cd task_motion
- Confirm the project was created by running:
ls
You should see folders like lib, android, ios, and a pubspec.yaml file in the output.
Command not found?
If you see a "flutter: command not found" error, your Flutter SDK is not on your system PATH. Re-run flutter doctor to verify your installation.
help me fix a flutter command not found error
- Switch to your editor (VS Code or Android Studio, already open from earlier).
- Open the task_motion folder from your Desktop using File then Open Folder.
Add project dependencies
TaskMotion needs three packages: flutter_bloc for state management, get_it for dependency injection, and equatable for value equality in your state classes.
- In your editor, open pubspec.yaml from the project root.
- Find the dependencies: section and replace it with the following:
dependencies:
flutter:
sdk: flutter
flutter_bloc: any
get_it: any
equatable: any
What does each package do?
- flutter_bloc provides the BLoC pattern widgets (BlocProvider, BlocBuilder, BlocListener) for reactive state management.
- get_it is a service locator for dependency injection. It lets you register and retrieve instances without passing them through constructors.
- equatable simplifies equality comparisons. BLoC needs value equality on states and events to know when to rebuild the UI.
- Save pubspec.yaml.
- Install the packages by running this command in your terminal:
flutter pub get
You should see output ending with a message like "Got dependencies" confirming all packages resolved successfully.
Seeing a dependency resolution error?
Make sure your pubspec.yaml indentation uses exactly 2 spaces (not tabs) for each level. YAML is whitespace-sensitive, so incorrect spacing causes parse failures.
help me fix a pubspec.yaml dependency error in Flutter
Set up the Clean Architecture folder structure
Clean Architecture separates your code into three layers: Domain (business rules), Data (storage), and Presentation (UI and state).
Each layer lives in its own folder so dependencies flow inward. The Domain layer never depends on the Data or Presentation layers.
- Create the folder structure by running these commands in your terminal:
macOS
mkdir -p lib/features/task/domain/entities
mkdir -p lib/features/task/domain/repositories
mkdir -p lib/features/task/domain/usecases
mkdir -p lib/features/task/data/datasources
mkdir -p lib/features/task/data/repositories
mkdir -p lib/features/task/presentation/bloc
mkdir -p lib/features/task/presentation/pages
mkdir -p lib/features/task/presentation/widgets
touch lib/injection_container.dart
Windows
mkdir lib\features\task\domain\entities
mkdir lib\features\task\domain\repositories
mkdir lib\features\task\domain\usecases
mkdir lib\features\task\data\datasources
mkdir lib\features\task\data\repositories
mkdir lib\features\task\presentation\bloc
mkdir lib\features\task\presentation\pages
mkdir lib\features\task\presentation\widgets
New-Item lib\injection_container.dart -ItemType File
What is this folder structure?
- domain/entities/ holds your core data classes (the Task model).
- domain/repositories/ defines abstract contracts for data access.
- domain/usecases/ contains one class per business action (GetTasks, AddTask, CompleteTask).
- data/datasources/ implements the actual data storage (in-memory for this project).
- data/repositories/ implements the domain contracts using data sources.
- presentation/ holds the BLoC, pages, and widgets that make up the UI.
- injection_container.dart will wire all layers together using GetIt.
- Confirm the folders were created by running:
ls lib/features/task
You should see three folders listed: data, domain, and presentation.
Folders not showing up?
Make sure you ran the commands from inside the task_motion folder. Run pwd to check your current directory ends with task_motion.
help me fix my folder structure in the task_motion project
✔️ Awesome, I've got everything!
Great. Double check you have saved pubspec.yaml and that flutter pub get completed without errors.
ⓧ I'd like to double check the full code
Here is what your pubspec.yaml dependencies section should look like:
name: task_motion
dependencies:
flutter:
sdk: flutter
flutter_bloc: any
get_it: any
equatable: any
Your project skeleton is ready with all dependencies installed and a clean folder structure in place. Next up, you will build the Domain layer with the Task entity and repository contracts.
Build the Domain Layer
Your project structure is in place with all the folders for Clean Architecture. Now it's time to fill in the innermost layer: the domain.
The domain layer holds your app's core business logic with no dependencies on Flutter, databases, or UI. This makes it easy to test and swap implementations later.
In this step, get ready to:
- Create the Task entity with Equatable for value equality.
- Define the TaskRepository abstract class as a contract for data access.
- Build the GetTasks, AddTask, and CompleteTask use cases.
Create the Task entity
An entity is the core data object in your domain. It represents a real concept in your app without knowing how it's stored or displayed.
- In your editor, open the file lib/features/task/domain/entities/task_entity.dart.
- Add the following code to define your Task entity:
import 'package:equatable/equatable.dart';
class TaskEntity extends Equatable {
final String id;
final String title;
final String description;
final bool isCompleted;
final DateTime createdAt;
const TaskEntity({
required this.id,
required this.title,
required this.description,
required this.isCompleted,
required this.createdAt,
});
// Creates a copy with optional field overrides
TaskEntity copyWith({
String? id,
String? title,
String? description,
bool? isCompleted,
DateTime? createdAt,
}) {
return TaskEntity(
id: id ?? this.id,
title: title ?? this.title,
description: description ?? this.description,
isCompleted: isCompleted ?? this.isCompleted,
createdAt: createdAt ?? this.createdAt,
);
}
// Equatable uses these to compare two TaskEntity objects
@override
List<Object?> get props => [id, title, description, isCompleted, createdAt];
}
What does this code do?
TaskEntity extends Equatable, which lets you compare two task objects by their field values instead of their memory references.
The copyWith method creates a new TaskEntity with some fields changed. This is essential for immutable state management. When BLoC updates a task's completion status, it creates a new copy rather than mutating the original.
- Save task_entity.dart.
- Check that your editor shows no red underlines in this file.
Seeing red underlines?
If you see an error on the import 'package:equatable/equatable.dart' line, 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 the file is saved at the correct path: lib/features/task/domain/entities/task_entity.dart.
Help me fix Equatable import errors in my Flutter project.
✔️ Awesome, I've got everything!
Great. Make sure you've saved your file before moving on.
ⓧ I'd like to double check the full code
import 'package:equatable/equatable.dart';
class TaskEntity extends Equatable {
final String id;
final String title;
final String description;
final bool isCompleted;
final DateTime createdAt;
const TaskEntity({
required this.id,
required this.title,
required this.description,
required this.isCompleted,
required this.createdAt,
});
TaskEntity copyWith({
String? id,
String? title,
String? description,
bool? isCompleted,
DateTime? createdAt,
}) {
return TaskEntity(
id: id ?? this.id,
title: title ?? this.title,
description: description ?? this.description,
isCompleted: isCompleted ?? this.isCompleted,
createdAt: createdAt ?? this.createdAt,
);
}
@override
List<Object?> get props => [id, title, description, isCompleted, createdAt];
}
Define the TaskRepository abstract class
A repository contract defines what operations your app can perform on tasks without specifying how those operations work. The domain layer only knows the interface. The data layer provides the actual implementation.
- Open lib/features/task/domain/repositories/task_repository.dart.
- Add the following abstract class that defines the contract:
import '../entities/task_entity.dart';
// Abstract contract — the domain says WHAT, not HOW
abstract class TaskRepository {
Future<List<TaskEntity>> getTasks();
Future<void> addTask(TaskEntity task);
Future<void> completeTask(String id);
Future<void> deleteTask(String id);
}
What does this code do?
This is an abstract class, meaning you cannot instantiate it directly. It defines four methods that any task data source must implement: fetching all tasks, adding a task, marking one complete, and deleting one.
Every method returns a Future because data operations are asynchronous. The domain doesn't care if the data comes from memory, a database, or an API. It only cares that these four operations exist.
- Save task_repository.dart.
- Check that your editor shows no red underlines. The import should resolve to the entity file you just created.
Import not resolving?
Make sure task_entity.dart is saved in the lib/features/task/domain/entities/ folder. The relative import path ../entities/task_entity.dart navigates up one level from repositories/ into domain/, then into entities/.
Help me fix relative import paths in my Flutter domain layer.
✔️ Awesome, I've got everything!
Great. Make sure you've saved your file before moving on.
ⓧ I'd like to double check the full code
import '../entities/task_entity.dart';
abstract class TaskRepository {
Future<List<TaskEntity>> getTasks();
Future<void> addTask(TaskEntity task);
Future<void> completeTask(String id);
Future<void> deleteTask(String id);
}
Create the use cases
Use cases represent individual actions your app can perform. Each use case has a single responsibility: one action, one class. This keeps your business logic modular and easy to test.
- Open lib/features/task/domain/usecases/get_tasks.dart and add the following code:
import '../entities/task_entity.dart';
import '../repositories/task_repository.dart';
// Use case: fetch all tasks from the repository
class GetTasks {
final TaskRepository repository;
GetTasks(this.repository);
Future<List<TaskEntity>> call() async {
return await repository.getTasks();
}
}
What does this code do?
GetTasks takes a TaskRepository through its constructor. It doesn't know which implementation it gets. It just calls the contract's getTasks() method.
The call() method is a Dart convention that lets you invoke the use case like a function. Later, your BLoC will call getTasks() as if the use case itself were a function.
- Save get_tasks.dart.
- Confirm no red underlines appear in the file.
- Open lib/features/task/domain/usecases/add_task.dart and add the following code:
import '../entities/task_entity.dart';
import '../repositories/task_repository.dart';
// Use case: add a new task to the repository
class AddTask {
final TaskRepository repository;
AddTask(this.repository);
Future<void> call(TaskEntity task) async {
return await repository.addTask(task);
}
}
What's different about this use case?
AddTask follows the same pattern as GetTasks, but its call() method accepts a TaskEntity parameter and returns Future<void>. It passes the task down to the repository for storage.
- Save add_task.dart.
- Confirm no red underlines appear in the file.
- Open lib/features/task/domain/usecases/complete_task.dart and add the following code:
import '../repositories/task_repository.dart';
// Use case: mark a specific task as completed
class CompleteTask {
final TaskRepository repository;
CompleteTask(this.repository);
Future<void> call(String id) async {
return await repository.completeTask(id);
}
}
What's different about CompleteTask?
CompleteTask only needs a task id (a String) to do its job. It doesn't need the full entity because the repository already has the task stored. It just flips the completion status for the matching ID.
- Save complete_task.dart.
- Confirm no red underlines appear in the file.
Seeing errors in any use case file?
Check that both task_entity.dart and task_repository.dart are saved. The use cases import both of these files, so they must exist first.
Verify that your folder names match exactly: usecases (no space, no hyphen). A mismatch in any folder name breaks the relative import path.
Help me debug import errors in my Flutter use case files.
✔️ Awesome, I've got everything!
Great. Double check that all three use case files are saved before moving on.
ⓧ I'd like to double check the full code
import '../entities/task_entity.dart';
import '../repositories/task_repository.dart';
class GetTasks {
final TaskRepository repository;
GetTasks(this.repository);
Future<List<TaskEntity>> call() async {
return await repository.getTasks();
}
}
import '../entities/task_entity.dart';
import '../repositories/task_repository.dart';
class AddTask {
final TaskRepository repository;
AddTask(this.repository);
Future<void> call(TaskEntity task) async {
return await repository.addTask(task);
}
}
import '../repositories/task_repository.dart';
class CompleteTask {
final TaskRepository repository;
CompleteTask(this.repository);
Future<void> call(String id) async {
return await repository.completeTask(id);
}
}
Why one class per use case?
You could put all task operations in a single service class. But single-responsibility use cases are easier to test in isolation. They also make your BLoC's dependencies explicit. When you see GetTasks in a constructor, you know exactly what that dependency does.
Your domain layer is complete. You have an entity, a repository contract, and three use cases that define your app's core capabilities. Next up, you'll build the data layer that fulfills the contracts you just defined.
Build the Data Layer
Your domain layer defines what the app needs. It declares a TaskRepository contract and use cases that call it. But nothing actually stores or retrieves tasks yet.
The data layer is where the real work happens. It provides a concrete implementation of the repository contract, backed by an actual data source. In this step, you'll build an in-memory data source and connect it to the domain through a repository implementation.
In this step, get ready to:
- Create a local data source with in-memory task storage.
- Implement the repository that fulfills the domain contract.
Create the local data source
A data source is the class that directly interacts with your storage mechanism. In a production app this might talk to a REST API or a local database. For TaskMotion, you'll use an in-memory list with seed data so you can focus on animations later.
- Create the folders for your data layer by running these commands in your terminal:
mkdir -p lib/features/task/data/datasources
mkdir -p lib/features/task/data/repositories
- Confirm the folders exist by running:
ls lib/features/task/data/
You should see datasources and repositories listed in the output.
- Create a new file at lib/features/task/data/datasources/local_task_datasource.dart and paste the following code:
import '../../domain/entities/task_entity.dart';
class LocalTaskDataSource {
// Seed tasks so the app has data on first launch
final List<TaskEntity> _tasks = [
TaskEntity(
id: '1',
title: 'Learn Flutter Animations',
description: 'Study AnimationController, Tween, and Curves',
isCompleted: false,
createdAt: DateTime.now().subtract(const Duration(days: 2)),
),
TaskEntity(
id: '2',
title: 'Implement Clean Architecture',
description: 'Set up domain, data, and presentation layers',
isCompleted: false,
createdAt: DateTime.now().subtract(const Duration(days: 1)),
),
TaskEntity(
id: '3',
title: 'Master BLoC Pattern',
description: 'Use events and states for reactive UI',
isCompleted: false,
createdAt: DateTime.now(),
),
];
// Simulate network delay to make loading state visible
Future<List<TaskEntity>> getTasks() async {
await Future.delayed(const Duration(milliseconds: 500));
return List.from(_tasks);
}
// Insert new tasks at the beginning of the list
Future<void> addTask(TaskEntity task) async {
_tasks.insert(0, task);
}
// Find task by id and mark it as completed
Future<void> completeTask(String id) async {
final index = _tasks.indexWhere((t) => t.id == id);
if (index != -1) {
_tasks[index] = _tasks[index].copyWith(isCompleted: true);
}
}
// Remove a task entirely from the list
Future<void> deleteTask(String id) async {
_tasks.removeWhere((t) => t.id == id);
}
}
What does this code do?
This class acts as your local storage layer. The _tasks list holds three seed tasks so the app has visible data from the start.
The getTasks() method includes a 500ms delay to simulate a network request. This makes the loading state visible later when you add animations. The other methods manipulate the in-memory list directly.
Why use in-memory storage instead of a database?
Adding a real database (like SQLite or Hive) would introduce complexity that distracts from the animation concepts. The data layer contract is properly abstracted behind TaskRepository, so you could swap in a real database later without touching any other layer.
- Save local_task_datasource.dart.
- Confirm there are no red underlines or analysis errors in your editor. The import for TaskEntity should resolve without issues since you built the domain layer in the previous step.
Seeing an unresolved import error?
Make sure the file path matches exactly: lib/features/task/data/datasources/local_task_datasource.dart. The relative import ../../domain/entities/task_entity.dart depends on this exact folder structure.
help me fix my import path in local_task_datasource.dart
Implement the repository
The repository implementation bridges your domain contract and your data source. It implements the abstract TaskRepository interface and delegates every call to LocalTaskDataSource.
- Create a new file at lib/features/task/data/repositories/task_repository_impl.dart and paste the following code:
import '../../domain/entities/task_entity.dart';
import '../../domain/repositories/task_repository.dart';
import '../datasources/local_task_datasource.dart';
// Concrete implementation that fulfills the domain contract
class TaskRepositoryImpl implements TaskRepository {
final LocalTaskDataSource localDataSource;
TaskRepositoryImpl({required this.localDataSource});
@override
Future<List<TaskEntity>> getTasks() async {
return await localDataSource.getTasks();
}
@override
Future<void> addTask(TaskEntity task) async {
return await localDataSource.addTask(task);
}
@override
Future<void> completeTask(String id) async {
return await localDataSource.completeTask(id);
}
@override
Future<void> deleteTask(String id) async {
return await localDataSource.deleteTask(id);
}
}
What does this code do?
TaskRepositoryImpl uses the implements keyword to guarantee it provides every method defined in the abstract TaskRepository class. Each method simply delegates to the localDataSource.
This pattern keeps your domain layer completely independent of your storage mechanism. The use cases only know about the abstract TaskRepository interface. They never touch LocalTaskDataSource directly.
- Save task_repository_impl.dart.
- Confirm there are no analysis errors. All three imports should resolve cleanly, and the @override annotations should show no warnings.
Seeing a missing override error?
If your editor complains about a missing method, check that your TaskRepositoryImpl has all four methods: getTasks, addTask, completeTask, and deleteTask. They must match the signatures in your abstract task_repository.dart exactly.
help me fix the missing override error in TaskRepositoryImpl
✔️ Awesome, I've got everything!
Great. Double check you've saved both files before moving on.
ⓧ I'd like to double check the full code
Here is local_task_datasource.dart:
import '../../domain/entities/task_entity.dart';
class LocalTaskDataSource {
final List<TaskEntity> _tasks = [
TaskEntity(
id: '1',
title: 'Learn Flutter Animations',
description: 'Study AnimationController, Tween, and Curves',
isCompleted: false,
createdAt: DateTime.now().subtract(const Duration(days: 2)),
),
TaskEntity(
id: '2',
title: 'Implement Clean Architecture',
description: 'Set up domain, data, and presentation layers',
isCompleted: false,
createdAt: DateTime.now().subtract(const Duration(days: 1)),
),
TaskEntity(
id: '3',
title: 'Master BLoC Pattern',
description: 'Use events and states for reactive UI',
isCompleted: false,
createdAt: DateTime.now(),
),
];
Future<List<TaskEntity>> getTasks() async {
await Future.delayed(const Duration(milliseconds: 500));
return List.from(_tasks);
}
Future<void> addTask(TaskEntity task) async {
_tasks.insert(0, task);
}
Future<void> completeTask(String id) async {
final index = _tasks.indexWhere((t) => t.id == id);
if (index != -1) {
_tasks[index] = _tasks[index].copyWith(isCompleted: true);
}
}
Future<void> deleteTask(String id) async {
_tasks.removeWhere((t) => t.id == id);
}
}
And here is task_repository_impl.dart:
import '../../domain/entities/task_entity.dart';
import '../../domain/repositories/task_repository.dart';
import '../datasources/local_task_datasource.dart';
class TaskRepositoryImpl implements TaskRepository {
final LocalTaskDataSource localDataSource;
TaskRepositoryImpl({required this.localDataSource});
@override
Future<List<TaskEntity>> getTasks() async {
return await localDataSource.getTasks();
}
@override
Future<void> addTask(TaskEntity task) async {
return await localDataSource.addTask(task);
}
@override
Future<void> completeTask(String id) async {
return await localDataSource.completeTask(id);
}
@override
Future<void> deleteTask(String id) async {
return await localDataSource.deleteTask(id);
}
}
Your data layer is complete. The domain declares what it needs, and the data layer delivers it. Next up, you'll build the BLoC that ties these layers together and drives the UI with events and states.
Build the TaskBloc
Your domain and data layers are complete. Now you need something to bridge the data to the UI.
The BLoC (Business Logic Component) pattern uses events and states to manage how your app reacts to user actions. Instead of calling methods directly, the UI sends events to the BLoC, and the BLoC responds by emitting new states that the UI rebuilds from.
In this step, get ready to:
- Define event classes that represent user actions (load, add, complete).
- Define state classes that represent what the UI should display.
- Implement the TaskBloc that connects events to states using your use cases.
Define the TaskEvent classes
Events are messages the UI sends to the BLoC. Each event represents one thing the user can do. Your task app needs three: loading all tasks, adding a new task, and completing an existing task.
- Create a new file at lib/features/task/presentation/bloc/task_bloc.dart.
- You will need to create the presentation/bloc/ folders inside lib/features/task/ first.
- Add the imports and event class definitions by pasting this code:
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:equatable/equatable.dart';
import '../../domain/entities/task_entity.dart';
import '../../domain/usecases/get_tasks.dart';
import '../../domain/usecases/add_task.dart';
import '../../domain/usecases/complete_task.dart';
// Base event class - all events extend this
abstract class TaskEvent extends Equatable {
const TaskEvent();
@override
List<Object> get props => [];
}
// Triggers loading all tasks from the repository
class LoadTasks extends TaskEvent {}
// Carries the data needed to create a new task
class AddTaskEvent extends TaskEvent {
final String title;
final String description;
const AddTaskEvent({required this.title, required this.description});
@override
List<Object> get props => [title, description];
}
// Carries the ID of the task to mark as complete
class CompleteTaskEvent extends TaskEvent {
final String taskId;
const CompleteTaskEvent({required this.taskId});
@override
List<Object> get props => [taskId];
}
What does this code do?
Each event class extends TaskEvent which extends Equatable. Equatable lets BLoC compare events by value instead of by reference, preventing duplicate processing.
LoadTasks has no data because loading doesn't need parameters. AddTaskEvent carries a title and description. CompleteTaskEvent carries just the task ID.
- Save task_bloc.dart.
You won't see a change yet. The next chunk adds the state classes that respond to these events.
Define the TaskState classes
States represent what the UI should show at any given moment. A loading spinner, a list of tasks, or an error message each correspond to a different state class.
- In task_bloc.dart, add the state class definitions below the CompleteTaskEvent class:
// Base state class - all states extend this
abstract class TaskState extends Equatable {
const TaskState();
@override
List<Object> get props => [];
}
// Initial state before any action is taken
class TaskInitial extends TaskState {}
// Emitted while tasks are being fetched
class TaskLoading extends TaskState {}
// Holds the current list of tasks for the UI to display
class TaskLoaded extends TaskState {
final List<TaskEntity> tasks;
const TaskLoaded({required this.tasks});
@override
List<Object> get props => [tasks];
}
// Holds an error message when something goes wrong
class TaskError extends TaskState {
final String message;
const TaskError({required this.message});
@override
List<Object> get props => [message];
}
What does this code do?
Four states cover every scenario your UI will encounter. TaskInitial is the starting point. TaskLoading tells the UI to show a spinner.
TaskLoaded carries the actual task list for rendering. TaskError carries a message string so the UI can display what went wrong.
- Save task_bloc.dart.
You won't see a change yet. The next chunk wires events to states inside the TaskBloc class itself.
Implement the TaskBloc class
The Bloc class is where events get processed into states. It receives use cases through its constructor and registers a handler for each event type.
- In task_bloc.dart, add the TaskBloc class below the TaskError class:
// The BLoC that connects events to states via use cases
class TaskBloc extends Bloc<TaskEvent, TaskState> {
final GetTasks getTasks;
final AddTask addTask;
final CompleteTask completeTask;
TaskBloc({
required this.getTasks,
required this.addTask,
required this.completeTask,
}) : super(TaskInitial()) {
// Register a handler for each event type
on<LoadTasks>(_onLoadTasks);
on<AddTaskEvent>(_onAddTask);
on<CompleteTaskEvent>(_onCompleteTask);
}
// Emits loading, then fetches tasks and emits loaded or error
Future<void> _onLoadTasks(LoadTasks event, Emitter<TaskState> emit) async {
emit(TaskLoading());
try {
final tasks = await getTasks();
emit(TaskLoaded(tasks: tasks));
} catch (e) {
emit(TaskError(message: e.toString()));
}
}
// Creates a new task entity and prepends it to the current list
Future<void> _onAddTask(AddTaskEvent event, Emitter<TaskState> emit) async {
final currentState = state;
if (currentState is TaskLoaded) {
final newTask = TaskEntity(
id: DateTime.now().millisecondsSinceEpoch.toString(),
title: event.title,
description: event.description,
isCompleted: false,
createdAt: DateTime.now(),
);
await addTask(newTask);
final updatedTasks = [newTask, ...currentState.tasks];
emit(TaskLoaded(tasks: updatedTasks));
}
}
// Marks a task as completed and emits updated list
Future<void> _onCompleteTask(
CompleteTaskEvent event,
Emitter<TaskState> emit,
) async {
final currentState = state;
if (currentState is TaskLoaded) {
await completeTask(event.taskId);
final updatedTasks = currentState.tasks.map((task) {
if (task.id == event.taskId) {
return task.copyWith(isCompleted: true);
}
return task;
}).toList();
emit(TaskLoaded(tasks: updatedTasks));
}
}
}
What does this code do?
The constructor takes the three use cases from the domain layer. It starts in TaskInitial state and registers an on<Event> handler for each event type.
_onLoadTasks emits TaskLoading first (showing a spinner), then fetches tasks and emits either TaskLoaded or TaskError.
_onAddTask checks that the current state is TaskLoaded before creating and prepending a new task. _onCompleteTask maps over the task list, updating the matching task's isCompleted field.
- Save task_bloc.dart.
- Verify the file compiles by running this in your terminal:
flutter analyze lib/features/task/presentation/bloc/task_bloc.dart
You should see No issues found! in the terminal output.
Seeing analysis errors?
Check that your import paths match the folder structure exactly. The path ../../domain/usecases/get_tasks.dart assumes task_bloc.dart lives inside presentation/bloc/.
Make sure all four files in domain/usecases/ and domain/entities/ exist from the previous steps.
Help me fix flutter analyze errors in my task_bloc.dart file
✔️ Awesome, I've got everything!
Great. Double check you've saved your file.
ⓧ I'd like to double check the full code
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:equatable/equatable.dart';
import '../../domain/entities/task_entity.dart';
import '../../domain/usecases/get_tasks.dart';
import '../../domain/usecases/add_task.dart';
import '../../domain/usecases/complete_task.dart';
abstract class TaskEvent extends Equatable {
const TaskEvent();
@override
List<Object> get props => [];
}
class LoadTasks extends TaskEvent {}
class AddTaskEvent extends TaskEvent {
final String title;
final String description;
const AddTaskEvent({required this.title, required this.description});
@override
List<Object> get props => [title, description];
}
class CompleteTaskEvent extends TaskEvent {
final String taskId;
const CompleteTaskEvent({required this.taskId});
@override
List<Object> get props => [taskId];
}
abstract class TaskState extends Equatable {
const TaskState();
@override
List<Object> get props => [];
}
class TaskInitial extends TaskState {}
class TaskLoading extends TaskState {}
class TaskLoaded extends TaskState {
final List<TaskEntity> tasks;
const TaskLoaded({required this.tasks});
@override
List<Object> get props => [tasks];
}
class TaskError extends TaskState {
final String message;
const TaskError({required this.message});
@override
List<Object> get props => [message];
}
class TaskBloc extends Bloc<TaskEvent, TaskState> {
final GetTasks getTasks;
final AddTask addTask;
final CompleteTask completeTask;
TaskBloc({
required this.getTasks,
required this.addTask,
required this.completeTask,
}) : super(TaskInitial()) {
on<LoadTasks>(_onLoadTasks);
on<AddTaskEvent>(_onAddTask);
on<CompleteTaskEvent>(_onCompleteTask);
}
Future<void> _onLoadTasks(LoadTasks event, Emitter<TaskState> emit) async {
emit(TaskLoading());
try {
final tasks = await getTasks();
emit(TaskLoaded(tasks: tasks));
} catch (e) {
emit(TaskError(message: e.toString()));
}
}
Future<void> _onAddTask(AddTaskEvent event, Emitter<TaskState> emit) async {
final currentState = state;
if (currentState is TaskLoaded) {
final newTask = TaskEntity(
id: DateTime.now().millisecondsSinceEpoch.toString(),
title: event.title,
description: event.description,
isCompleted: false,
createdAt: DateTime.now(),
);
await addTask(newTask);
final updatedTasks = [newTask, ...currentState.tasks];
emit(TaskLoaded(tasks: updatedTasks));
}
}
Future<void> _onCompleteTask(
CompleteTaskEvent event,
Emitter<TaskState> emit,
) async {
final currentState = state;
if (currentState is TaskLoaded) {
await completeTask(event.taskId);
final updatedTasks = currentState.tasks.map((task) {
if (task.id == event.taskId) {
return task.copyWith(isCompleted: true);
}
return task;
}).toList();
emit(TaskLoaded(tasks: updatedTasks));
}
}
}
Your BLoC is ready to process events and emit states. Next up, you'll wire everything together with dependency injection so the app can actually create and use your TaskBloc.
Wire Up Dependency Injection
Your BLoC is built. It knows how to process events and emit states. But right now, nothing connects it to the data layer underneath.
In this step, you will create a central registry that tells your app how to build each object and what it depends on. This is called dependency injection. Instead of each class creating its own dependencies, a single container assembles everything and hands objects their requirements.
In this step, get ready to:
- Create the dependency injection container with GetIt.
- Register data sources, repositories, use cases, and the BLoC.
- Initialize DI in main.dart and provide the BLoC to the widget tree.
Create injection_container.dart using GetIt
GetIt is a service locator. You register all your classes in one place, and GetIt resolves the dependency chain automatically when you request an object.
- Create a new file called injection_container.dart inside the lib/ folder (same level as main.dart).
- Add the following code to register every layer of your architecture:
import 'package:get_it/get_it.dart';
import 'features/task/data/datasources/local_task_datasource.dart';
import 'features/task/data/repositories/task_repository_impl.dart';
import 'features/task/domain/repositories/task_repository.dart';
import 'features/task/domain/usecases/get_tasks.dart';
import 'features/task/domain/usecases/add_task.dart';
import 'features/task/domain/usecases/complete_task.dart';
import 'features/task/presentation/bloc/task_bloc.dart';
// Service locator instance - the single access point for all dependencies
final sl = GetIt.instance;
Future<void> init() async {
// Bloc - factory creates a fresh instance each time it's requested
sl.registerFactory(
() => TaskBloc(
getTasks: sl(),
addTask: sl(),
completeTask: sl(),
),
);
// Use cases - singletons since they hold no mutable state
sl.registerLazySingleton(() => GetTasks(sl()));
sl.registerLazySingleton(() => AddTask(sl()));
sl.registerLazySingleton(() => CompleteTask(sl()));
// Repository - registered against the abstract type for loose coupling
sl.registerLazySingleton<TaskRepository>(
() => TaskRepositoryImpl(localDataSource: sl()),
);
// Data source - singleton to preserve the in-memory task list
sl.registerLazySingleton(() => LocalTaskDataSource());
}
What does this code do?
The sl variable is your service locator. Think of it as a phonebook for objects. When you call sl() inside a registration, GetIt looks up whatever type is needed and provides it automatically.
- registerFactory creates a brand-new instance every time. BLoCs get fresh state per screen.
- registerLazySingleton creates one instance on first access, then reuses it forever. Data sources and use cases are stateless workers that only need one copy.
- Registering TaskRepository as the type but providing TaskRepositoryImpl means the rest of your app depends on the abstract contract, not the concrete class.
Why register bottom-up but declare top-down?
The registrations are ordered from outer layer (Bloc) to inner layer (DataSource) in the file. GetIt resolves dependencies lazily, so the order in the file does not matter. The code reads top-down from the entry point the app will request first.
- Save injection_container.dart.
- Confirm there are no red underlines on the import statements. Every path should resolve to a file you already created.
Seeing unresolved import errors?
- Check that injection_container.dart is directly inside lib/, not inside the features/ folder.
- Verify every import path matches your folder structure exactly. Dart imports are case-sensitive.
- Make sure you saved all files from the previous steps.
Help me fix unresolved imports in my injection_container.dart file.
Initialize DI in main.dart and wrap the app with BlocProvider
The container is ready, but nothing calls it yet. You need to initialize the container before the app starts, then provide the BLoC to the entire widget tree so any screen can access task state.
- Open lib/main.dart and replace its entire contents with the following code:
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'injection_container.dart' as di;
import 'features/task/presentation/bloc/task_bloc.dart';
import 'features/task/presentation/pages/task_list_page.dart';
void main() async {
// Ensure Flutter bindings are ready before async init
WidgetsFlutterBinding.ensureInitialized();
// Initialize the dependency injection container
await di.init();
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return BlocProvider(
// Get a fresh TaskBloc from DI and immediately load tasks
create: (_) => di.sl<TaskBloc>()..add(LoadTasks()),
child: MaterialApp(
title: 'TaskMotion',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo),
useMaterial3: true,
),
home: const TaskListPage(),
),
);
}
}
What does this code do?
- WidgetsFlutterBinding.ensureInitialized() prepares Flutter's engine before any async work runs. Without it, calling await before runApp would crash.
- di.init() calls the init() function you just wrote, registering every dependency.
- BlocProvider makes TaskBloc available to every widget below it in the tree. The ..add(LoadTasks()) cascade immediately dispatches a load event so tasks fetch on app startup.
- Save main.dart.
You will see an error on the task_list_page.dart import because that file does not exist yet. You will create it in the next step.
Seeing other errors beyond the missing TaskListPage?
- If injection_container.dart is underlined, check that the import uses import 'injection_container.dart' as di; with a relative path (no package: prefix).
- If BlocProvider is unresolved, confirm flutter_bloc is listed in your pubspec.yaml dependencies and that you ran flutter pub get.
- If LoadTasks is unresolved, make sure the task_bloc.dart import path is correct.
Help me debug errors in my main.dart dependency injection setup.
✔️ Awesome, I've got everything!
Great. Double check you have saved both injection_container.dart and main.dart.
ⓧ I'd like to double check the full code
Here is the full injection_container.dart:
import 'package:get_it/get_it.dart';
import 'features/task/data/datasources/local_task_datasource.dart';
import 'features/task/data/repositories/task_repository_impl.dart';
import 'features/task/domain/repositories/task_repository.dart';
import 'features/task/domain/usecases/get_tasks.dart';
import 'features/task/domain/usecases/add_task.dart';
import 'features/task/domain/usecases/complete_task.dart';
import 'features/task/presentation/bloc/task_bloc.dart';
final sl = GetIt.instance;
Future<void> init() async {
sl.registerFactory(
() => TaskBloc(
getTasks: sl(),
addTask: sl(),
completeTask: sl(),
),
);
sl.registerLazySingleton(() => GetTasks(sl()));
sl.registerLazySingleton(() => AddTask(sl()));
sl.registerLazySingleton(() => CompleteTask(sl()));
sl.registerLazySingleton<TaskRepository>(
() => TaskRepositoryImpl(localDataSource: sl()),
);
sl.registerLazySingleton(() => LocalTaskDataSource());
}
And here is the full main.dart:
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'injection_container.dart' as di;
import 'features/task/presentation/bloc/task_bloc.dart';
import 'features/task/presentation/pages/task_list_page.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await di.init();
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (_) => di.sl<TaskBloc>()..add(LoadTasks()),
child: MaterialApp(
title: 'TaskMotion',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo),
useMaterial3: true,
),
home: const TaskListPage(),
),
);
}
}
Your architecture is fully wired. Every layer connects through the DI container, and the BLoC is ready to receive events the moment the app launches. Next up, you will build the animated task list that brings this state to life on screen.
Build the Animated Task List
Your architecture layers are wired and your BLoC is ready to manage state. Now it is time to build the part of the app that users actually see and interact with.
In this step, you will create the presentation layer. This includes a main page that reacts to BLoC state changes, a task list with staggered entrance animations, and a dialog for adding new tasks.
In this step, get ready to:
- Create the TaskListPage with a BlocBuilder that responds to task states.
- Build staggered entrance animations so tasks slide and fade in one by one.
- Create an animated dialog for adding new tasks.
Create the task list page with BlocBuilder
The TaskListPage is where BLoC state meets the UI. A BlocBuilder widget listens to the TaskBloc and rebuilds the interface whenever the state changes.
- Create a new file at lib/features/task/presentation/pages/task_list_page.dart and add the following code:
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../bloc/task_bloc.dart';
import '../widgets/animated_task_list.dart';
import '../widgets/add_task_dialog.dart';
class TaskListPage extends StatelessWidget {
const TaskListPage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('TaskMotion'),
centerTitle: true,
),
body: BlocBuilder<TaskBloc, TaskState>(
builder: (context, state) {
// Show loading spinner while tasks are being fetched
if (state is TaskLoading) {
return const Center(child: CircularProgressIndicator());
} else if (state is TaskLoaded) {
// Show empty message if no tasks exist
if (state.tasks.isEmpty) {
return const Center(
child: Text('No tasks yet. Add one!'),
);
}
// Display the animated task list
return AnimatedTaskList(tasks: state.tasks);
} else if (state is TaskError) {
return Center(child: Text('Error: ${state.message}'));
}
return const SizedBox.shrink();
},
),
floatingActionButton: FloatingActionButton(
onPressed: () => _showAddTaskDialog(context),
child: const Icon(Icons.add),
),
);
}
// Opens the add task dialog with the existing BLoC instance
void _showAddTaskDialog(BuildContext context) {
showDialog(
context: context,
builder: (_) => BlocProvider.value(
value: context.read<TaskBloc>(),
child: const AddTaskDialog(),
),
);
}
}
What does this code do?
BlocBuilder<TaskBloc, TaskState> rebuilds its child widget every time the TaskBloc emits a new state. Each state type gets a different UI response.
- TaskLoading shows a spinner while tasks are fetched from the data source.
- TaskLoaded displays either the animated task list or an empty-state message.
- BlocProvider.value passes the existing BLoC instance to the dialog without creating a new one.
- Save task_list_page.dart.
- You will see import errors for animated_task_list.dart and add_task_dialog.dart because those files do not exist yet. You will create them next.
Seeing other errors beyond the missing imports?
- Confirm your import path matches the folder structure exactly. The file should be at lib/features/task/presentation/pages/.
- Check that your task_bloc.dart exports the state classes (TaskLoading, TaskLoaded, TaskError) in the same file as the bloc.
Help me fix import errors in my TaskListPage.
Implement the staggered entrance animation
Staggered animations make each list item appear slightly after the previous one, creating a cascading entrance effect. Instead of all tasks popping in at once, each one slides in from the right with a fade, offset by 100ms.
What is a staggered animation?
A staggered animation uses a single AnimationController but divides its timeline into intervals. Each item gets its own slice of the 0.0 to 1.0 range using the Interval class.
This is more performant than creating one controller per item. The official Flutter documentation recommends this single-controller approach for list animations.
Before creating the list widget, you need a basic task tile widget that it can render. You will enhance this widget with completion animations in the next step.
- Create a new file at lib/features/task/presentation/widgets/animated_task_tile.dart and add this placeholder widget:
import 'package:flutter/material.dart';
import '../../domain/entities/task_entity.dart';
// Placeholder tile widget - will be enhanced with animations in the next step
class AnimatedTaskTile extends StatelessWidget {
final TaskEntity task;
const AnimatedTaskTile({super.key, required this.task});
@override
Widget build(BuildContext context) {
return Container(
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.grey.shade300),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
task.title,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 4),
Text(
task.description,
style: TextStyle(
fontSize: 14,
color: Colors.grey.shade600,
),
),
],
),
);
}
}
- Save animated_task_tile.dart.
Now create the main animation widget that orchestrates the staggered entrance.
- Create a new file at lib/features/task/presentation/widgets/animated_task_list.dart and add the following code:
import 'package:flutter/material.dart';
import '../../domain/entities/task_entity.dart';
import 'animated_task_tile.dart';
class AnimatedTaskList extends StatefulWidget {
final List<TaskEntity> tasks;
const AnimatedTaskList({super.key, required this.tasks});
@override
State<AnimatedTaskList> createState() => _AnimatedTaskListState();
}
class _AnimatedTaskListState extends State<AnimatedTaskList>
with TickerProviderStateMixin {
late AnimationController _staggerController;
final List<Animation<double>> _fadeAnimations = [];
final List<Animation<Offset>> _slideAnimations = [];
@override
void initState() {
super.initState();
_initAnimations();
}
void _initAnimations() {
// Total duration scales with number of tasks
_staggerController = AnimationController(
vsync: this,
duration: Duration(
milliseconds: 300 + (widget.tasks.length * 100),
),
);
_fadeAnimations.clear();
_slideAnimations.clear();
// Create staggered intervals for each task item
for (int i = 0; i < widget.tasks.length; i++) {
final startInterval = (i * 0.1).clamp(0.0, 1.0);
final endInterval = ((i * 0.1) + 0.4).clamp(0.0, 1.0);
// Fade from invisible to fully visible
_fadeAnimations.add(
Tween<double>(begin: 0.0, end: 1.0).animate(
CurvedAnimation(
parent: _staggerController,
curve: Interval(startInterval, endInterval, curve: Curves.easeOut),
),
),
);
// Slide in from the right
_slideAnimations.add(
Tween<Offset>(
begin: const Offset(0.5, 0.0),
end: Offset.zero,
).animate(
CurvedAnimation(
parent: _staggerController,
curve: Interval(startInterval, endInterval, curve: Curves.easeOut),
),
),
);
}
// Start the staggered animation
_staggerController.forward();
}
@override
void didUpdateWidget(AnimatedTaskList oldWidget) {
super.didUpdateWidget(oldWidget);
// Re-initialize animations when the task list changes
if (oldWidget.tasks.length != widget.tasks.length) {
_staggerController.dispose();
_initAnimations();
}
}
@override
void dispose() {
_staggerController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: widget.tasks.length,
itemBuilder: (context, index) {
// Fallback for items added after initial animation
if (index >= _fadeAnimations.length) {
return AnimatedTaskTile(task: widget.tasks[index]);
}
// Wrap each tile in fade + slide transitions
return AnimatedBuilder(
animation: _staggerController,
child: AnimatedTaskTile(task: widget.tasks[index]),
builder: (context, child) {
return FadeTransition(
opacity: _fadeAnimations[index],
child: SlideTransition(
position: _slideAnimations[index],
child: child,
),
);
},
);
},
);
}
}
What does this animation code do?
- _staggerController is a single controller whose total duration grows with the number of tasks (300ms base + 100ms per task).
- Interval(startInterval, endInterval) gives each task its own slice of the 0.0 to 1.0 timeline. Task 0 animates first, task 1 starts slightly later, and so on.
- FadeTransition and SlideTransition are composed together so each task fades in while simultaneously sliding from the right.
- AnimatedBuilder accepts a child parameter that stays constant across frames, avoiding unnecessary rebuilds of the task tile itself.
- Save animated_task_list.dart.
- You won't see a change yet because the add task dialog file is still missing. You will create it next.
Seeing a TickerProviderStateMixin error?
- Make sure the State class uses TickerProviderStateMixin (not SingleTickerProviderStateMixin). You need the multi-ticker version because the widget manages multiple animation intervals.
- Confirm you called _staggerController.dispose() in the dispose() method. Missing this causes memory leaks and ticker errors on hot reload.
Help me fix my staggered animation controller setup.
Create the add task dialog
The dialog for adding tasks uses its own explicit animation. When the dialog opens, it slides up from the bottom using a SlideTransition controlled by an AnimationController.
- Create a new file at lib/features/task/presentation/widgets/add_task_dialog.dart and add the following code:
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../bloc/task_bloc.dart';
class AddTaskDialog extends StatefulWidget {
const AddTaskDialog({super.key});
@override
State<AddTaskDialog> createState() => _AddTaskDialogState();
}
class _AddTaskDialogState extends State<AddTaskDialog>
with SingleTickerProviderStateMixin {
final _titleController = TextEditingController();
final _descController = TextEditingController();
late AnimationController _animController;
late Animation<Offset> _slideAnimation;
@override
void initState() {
super.initState();
// Slide-in animation for the dialog entrance
_animController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 300),
);
_slideAnimation = Tween<Offset>(
begin: const Offset(0, 1),
end: Offset.zero,
).animate(
CurvedAnimation(parent: _animController, curve: Curves.easeOut),
);
_animController.forward();
}
@override
void dispose() {
_titleController.dispose();
_descController.dispose();
_animController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return SlideTransition(
position: _slideAnimation,
child: AlertDialog(
title: const Text('Add New Task'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: _titleController,
decoration: const InputDecoration(
labelText: 'Title',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 12),
TextField(
controller: _descController,
decoration: const InputDecoration(
labelText: 'Description',
border: OutlineInputBorder(),
),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () {
// Only add if title is not empty
if (_titleController.text.isNotEmpty) {
context.read<TaskBloc>().add(
AddTaskEvent(
title: _titleController.text,
description: _descController.text,
),
);
Navigator.pop(context);
}
},
child: const Text('Add'),
),
],
),
);
}
}
What does this code do?
- The dialog uses SingleTickerProviderStateMixin because it only needs one animation controller (the slide-in effect).
- Tween<Offset>(begin: Offset(0, 1), end: Offset.zero) moves the dialog from off-screen at the bottom to its final position.
- When the user taps Add, the widget dispatches an AddTaskEvent to the BLoC. The BLoC creates the task and emits a new TaskLoaded state, which triggers the list to re-animate.
- Save add_task_dialog.dart.
- Run the app on your emulator or device. You should see three task cards slide in from the right one after another with a fade effect.
- Tap the floating action button (the + icon) in the bottom right corner. The add task dialog should slide up from the bottom.
Tasks not animating on screen?
- Check that main.dart dispatches LoadTasks() when creating the BLoC. Without this event, the BLoC never fetches tasks from the data source.
- If you see a blank screen, confirm TickerProviderStateMixin is on the _AnimatedTaskListState class. Without it, the AnimationController cannot generate ticks.
- Try a hot restart (not hot reload) by pressing the restart button in your IDE. Animation controllers sometimes need a full restart after initial creation.
Help me debug why my staggered task list animations are not playing.
✔️ Awesome, I've got everything!
Great. Double check you have saved all three new files before moving on.
ⓧ I'd like to double check the full code
Here is the full code for each file you created in this step.
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../bloc/task_bloc.dart';
import '../widgets/animated_task_list.dart';
import '../widgets/add_task_dialog.dart';
class TaskListPage extends StatelessWidget {
const TaskListPage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('TaskMotion'),
centerTitle: true,
),
body: BlocBuilder<TaskBloc, TaskState>(
builder: (context, state) {
if (state is TaskLoading) {
return const Center(child: CircularProgressIndicator());
} else if (state is TaskLoaded) {
if (state.tasks.isEmpty) {
return const Center(
child: Text('No tasks yet. Add one!'),
);
}
return AnimatedTaskList(tasks: state.tasks);
} else if (state is TaskError) {
return Center(child: Text('Error: ${state.message}'));
}
return const SizedBox.shrink();
},
),
floatingActionButton: FloatingActionButton(
onPressed: () => _showAddTaskDialog(context),
child: const Icon(Icons.add),
),
);
}
void _showAddTaskDialog(BuildContext context) {
showDialog(
context: context,
builder: (_) => BlocProvider.value(
value: context.read<TaskBloc>(),
child: const AddTaskDialog(),
),
);
}
}
import 'package:flutter/material.dart';
import '../../domain/entities/task_entity.dart';
import 'animated_task_tile.dart';
class AnimatedTaskList extends StatefulWidget {
final List<TaskEntity> tasks;
const AnimatedTaskList({super.key, required this.tasks});
@override
State<AnimatedTaskList> createState() => _AnimatedTaskListState();
}
class _AnimatedTaskListState extends State<AnimatedTaskList>
with TickerProviderStateMixin {
late AnimationController _staggerController;
final List<Animation<double>> _fadeAnimations = [];
final List<Animation<Offset>> _slideAnimations = [];
@override
void initState() {
super.initState();
_initAnimations();
}
void _initAnimations() {
_staggerController = AnimationController(
vsync: this,
duration: Duration(
milliseconds: 300 + (widget.tasks.length * 100),
),
);
_fadeAnimations.clear();
_slideAnimations.clear();
for (int i = 0; i < widget.tasks.length; i++) {
final startInterval = (i * 0.1).clamp(0.0, 1.0);
final endInterval = ((i * 0.1) + 0.4).clamp(0.0, 1.0);
_fadeAnimations.add(
Tween<double>(begin: 0.0, end: 1.0).animate(
CurvedAnimation(
parent: _staggerController,
curve: Interval(startInterval, endInterval, curve: Curves.easeOut),
),
),
);
_slideAnimations.add(
Tween<Offset>(
begin: const Offset(0.5, 0.0),
end: Offset.zero,
).animate(
CurvedAnimation(
parent: _staggerController,
curve: Interval(startInterval, endInterval, curve: Curves.easeOut),
),
),
);
}
_staggerController.forward();
}
@override
void didUpdateWidget(AnimatedTaskList oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.tasks.length != widget.tasks.length) {
_staggerController.dispose();
_initAnimations();
}
}
@override
void dispose() {
_staggerController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: widget.tasks.length,
itemBuilder: (context, index) {
if (index >= _fadeAnimations.length) {
return AnimatedTaskTile(task: widget.tasks[index]);
}
return AnimatedBuilder(
animation: _staggerController,
child: AnimatedTaskTile(task: widget.tasks[index]),
builder: (context, child) {
return FadeTransition(
opacity: _fadeAnimations[index],
child: SlideTransition(
position: _slideAnimations[index],
child: child,
),
);
},
);
},
);
}
}
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../bloc/task_bloc.dart';
class AddTaskDialog extends StatefulWidget {
const AddTaskDialog({super.key});
@override
State<AddTaskDialog> createState() => _AddTaskDialogState();
}
class _AddTaskDialogState extends State<AddTaskDialog>
with SingleTickerProviderStateMixin {
final _titleController = TextEditingController();
final _descController = TextEditingController();
late AnimationController _animController;
late Animation<Offset> _slideAnimation;
@override
void initState() {
super.initState();
_animController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 300),
);
_slideAnimation = Tween<Offset>(
begin: const Offset(0, 1),
end: Offset.zero,
).animate(
CurvedAnimation(parent: _animController, curve: Curves.easeOut),
);
_animController.forward();
}
@override
void dispose() {
_titleController.dispose();
_descController.dispose();
_animController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return SlideTransition(
position: _slideAnimation,
child: AlertDialog(
title: const Text('Add New Task'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: _titleController,
decoration: const InputDecoration(
labelText: 'Title',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 12),
TextField(
controller: _descController,
decoration: const InputDecoration(
labelText: 'Description',
border: OutlineInputBorder(),
),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () {
if (_titleController.text.isNotEmpty) {
context.read<TaskBloc>().add(
AddTaskEvent(
title: _titleController.text,
description: _descController.text,
),
);
Navigator.pop(context);
}
},
child: const Text('Add'),
),
],
),
);
}
}
Your tasks now cascade onto the screen with smooth fade and slide animations. Next up, you will make task completion feel satisfying by adding an animated checkmark with color transitions.
Animate Task Completion
Your task list slides in beautifully, but tapping a task does nothing visually satisfying yet. A great task app needs feedback when you complete something.
In this step, you'll build the AnimatedTaskTile widget that combines explicit animations (a bouncy checkmark that scales in) with implicit animations (smooth color and text transitions). When the BLoC emits a new state with a completed task, the tile reacts with multiple coordinated animations.
In this step, get ready to:
- Create the AnimatedTaskTile widget with an AnimationController for the checkmark.
- Use AnimatedContainer for smooth color and padding transitions on completion.
- Trigger animations reactively when BLoC state changes.
Create the AnimatedTaskTile with AnimationController
Each task tile needs its own AnimationController to drive the checkmark scale animation independently. The controller lives in the tile's state so each tile can animate on its own timeline.
- Create a new file at lib/features/task/presentation/widgets/animated_task_tile.dart.
- Add the widget scaffold with the animation controller setup by pasting this code:
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../domain/entities/task_entity.dart';
import '../bloc/task_bloc.dart';
class AnimatedTaskTile extends StatefulWidget {
final TaskEntity task;
const AnimatedTaskTile({super.key, required this.task});
@override
State<AnimatedTaskTile> createState() => _AnimatedTaskTileState();
}
class _AnimatedTaskTileState extends State<AnimatedTaskTile>
with SingleTickerProviderStateMixin {
// Controller drives the checkmark scale animation
late AnimationController _checkController;
late Animation<double> _scaleAnimation;
@override
void initState() {
super.initState();
_checkController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 400),
);
// Scale from 0 to 1 with a bouncy elastic curve
_scaleAnimation = Tween<double>(begin: 0.0, end: 1.0).animate(
CurvedAnimation(
parent: _checkController,
curve: Curves.elasticOut,
),
);
// If the task is already completed on first render, show checkmark immediately
if (widget.task.isCompleted) {
_checkController.value = 1.0;
}
}
@override
void dispose() {
_checkController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
// Build method will be added in the next substep
return const Placeholder();
}
}
What does this code do?
SingleTickerProviderStateMixin gives the state a single Ticker for driving the animation at 60fps. The controller runs for 400 milliseconds.
Curves.elasticOut creates a satisfying overshoot bounce effect when the checkmark appears. The initial value check handles tasks that are already completed when the list first loads.
Build the animated UI with AnimatedContainer
The build method combines two animation types. AnimatedContainer handles implicit transitions (background color, border, shadow) automatically when properties change. AnimatedBuilder uses the explicit controller to scale the checkmark icon with precision.
- In animated_task_tile.dart, replace the placeholder build method with the full animated build method below:
@override
Widget build(BuildContext context) {
return AnimatedContainer(
duration: const Duration(milliseconds: 400),
curve: Curves.easeInOut,
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
// Background transitions from white to green on completion
color: widget.task.isCompleted
? Colors.green.shade50
: Colors.white,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: widget.task.isCompleted
? Colors.green.shade300
: Colors.grey.shade300,
),
boxShadow: [
BoxShadow(
color: widget.task.isCompleted
? Colors.green.withValues(alpha: 0.1)
: Colors.black.withValues(alpha: 0.05),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: Row(
children: [
// Tappable circle that dispatches CompleteTaskEvent
GestureDetector(
onTap: () {
if (!widget.task.isCompleted) {
context.read<TaskBloc>().add(
CompleteTaskEvent(taskId: widget.task.id),
);
}
},
child: AnimatedBuilder(
animation: _scaleAnimation,
builder: (context, child) {
return Container(
width: 32,
height: 32,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: widget.task.isCompleted
? Colors.green
: Colors.transparent,
border: Border.all(
color: widget.task.isCompleted
? Colors.green
: Colors.grey.shade400,
width: 2,
),
),
// Scale the checkmark icon using the explicit animation
child: widget.task.isCompleted
? Transform.scale(
scale: _scaleAnimation.value,
child: const Icon(
Icons.check,
color: Colors.white,
size: 18,
),
)
: null,
);
},
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Text style animates implicitly on completion
AnimatedDefaultTextStyle(
duration: const Duration(milliseconds: 300),
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
decoration: widget.task.isCompleted
? TextDecoration.lineThrough
: TextDecoration.none,
color: widget.task.isCompleted
? Colors.grey
: Colors.black87,
),
child: Text(widget.task.title),
),
const SizedBox(height: 4),
Text(
widget.task.description,
style: TextStyle(
fontSize: 14,
color: Colors.grey.shade600,
),
),
],
),
),
],
),
);
}
What's happening here?
- AnimatedContainer automatically tweens between white and green backgrounds over 400ms when isCompleted changes. You don't need a controller for this.
- GestureDetector dispatches CompleteTaskEvent to the BLoC, which updates state and triggers a rebuild.
- AnimatedBuilder rebuilds only the checkmark container on each animation frame, using Transform.scale to bounce the icon in.
- AnimatedDefaultTextStyle smoothly transitions the title text to grey with a strikethrough decoration.
Why mix implicit and explicit animations?
Implicit animations (AnimatedContainer, AnimatedDefaultTextStyle) are perfect for simple property changes because Flutter handles all the tweening for you. Explicit animations (AnimationController with AnimatedBuilder) give you precise control over timing, curves, and sequencing.
The checkmark needs the elastic overshoot effect that only an explicit controller with Curves.elasticOut can deliver. The background color change is straightforward enough for an implicit approach.
Trigger animations when task state changes
The implicit animations trigger automatically when widget properties change during a rebuild. But the explicit checkmark animation needs a manual _checkController.forward() call. The didUpdateWidget lifecycle method is the bridge between BLoC-driven rebuilds and explicit animation triggers.
- In animated_task_tile.dart, add the didUpdateWidget override between initState and dispose:
@override
void didUpdateWidget(AnimatedTaskTile oldWidget) {
super.didUpdateWidget(oldWidget);
// When isCompleted transitions from false to true, play the checkmark animation
if (!oldWidget.task.isCompleted && widget.task.isCompleted) {
_checkController.forward();
}
}
What does this code do?
When the BLoC emits a new TaskLoaded state with the task marked as completed, Flutter rebuilds the widget tree. didUpdateWidget fires during that rebuild, compares the old and new isCompleted values, and starts the checkmark animation only on a false-to-true transition.
- Save animated_task_tile.dart.
- Hot restart your app in the emulator. Tap the circle next to any task. You should see the circle fill green with a bouncy checkmark, the background transition to light green, and the title text strike through with a fade to grey.
Tapping the circle does nothing?
- Make sure your GestureDetector wraps the AnimatedBuilder and not the other way around. The tap target must be the outermost widget.
- Check that didUpdateWidget is placed between initState and dispose inside the State class. If it's outside the class, it won't trigger.
- Verify you're importing flutter_bloc and task_bloc.dart so that context.read<TaskBloc>() resolves correctly.
Why isn't my task completion animation playing when I tap the circle?
✔️ Awesome, I've got everything!
Great. Double check you've saved your file before moving on.
ⓧ I'd like to double check the full code
Here's the complete animated_task_tile.dart file for comparison:
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../domain/entities/task_entity.dart';
import '../bloc/task_bloc.dart';
class AnimatedTaskTile extends StatefulWidget {
final TaskEntity task;
const AnimatedTaskTile({super.key, required this.task});
@override
State<AnimatedTaskTile> createState() => _AnimatedTaskTileState();
}
class _AnimatedTaskTileState extends State<AnimatedTaskTile>
with SingleTickerProviderStateMixin {
late AnimationController _checkController;
late Animation<double> _scaleAnimation;
@override
void initState() {
super.initState();
_checkController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 400),
);
_scaleAnimation = Tween<double>(begin: 0.0, end: 1.0).animate(
CurvedAnimation(
parent: _checkController,
curve: Curves.elasticOut,
),
);
if (widget.task.isCompleted) {
_checkController.value = 1.0;
}
}
@override
void didUpdateWidget(AnimatedTaskTile oldWidget) {
super.didUpdateWidget(oldWidget);
if (!oldWidget.task.isCompleted && widget.task.isCompleted) {
_checkController.forward();
}
}
@override
void dispose() {
_checkController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedContainer(
duration: const Duration(milliseconds: 400),
curve: Curves.easeInOut,
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: widget.task.isCompleted
? Colors.green.shade50
: Colors.white,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: widget.task.isCompleted
? Colors.green.shade300
: Colors.grey.shade300,
),
boxShadow: [
BoxShadow(
color: widget.task.isCompleted
? Colors.green.withValues(alpha: 0.1)
: Colors.black.withValues(alpha: 0.05),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: Row(
children: [
GestureDetector(
onTap: () {
if (!widget.task.isCompleted) {
context.read<TaskBloc>().add(
CompleteTaskEvent(taskId: widget.task.id),
);
}
},
child: AnimatedBuilder(
animation: _scaleAnimation,
builder: (context, child) {
return Container(
width: 32,
height: 32,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: widget.task.isCompleted
? Colors.green
: Colors.transparent,
border: Border.all(
color: widget.task.isCompleted
? Colors.green
: Colors.grey.shade400,
width: 2,
),
),
child: widget.task.isCompleted
? Transform.scale(
scale: _scaleAnimation.value,
child: const Icon(
Icons.check,
color: Colors.white,
size: 18,
),
)
: null,
);
},
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AnimatedDefaultTextStyle(
duration: const Duration(milliseconds: 300),
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
decoration: widget.task.isCompleted
? TextDecoration.lineThrough
: TextDecoration.none,
color: widget.task.isCompleted
? Colors.grey
: Colors.black87,
),
child: Text(widget.task.title),
),
const SizedBox(height: 4),
Text(
widget.task.description,
style: TextStyle(
fontSize: 14,
color: Colors.grey.shade600,
),
),
],
),
),
],
),
);
}
}
Secret mission
Add Hero Animation to Task Detail Page
Your task app looks great, but tapping a task just triggers a checkmark. What if tapping a task opened a beautiful detail page with a physics-based spring animation? In this mission, you'll add a Hero transition that smoothly morphs the task tile into a full detail view, powered by real-world spring physics.
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_motion Flutter project folder on your Desktop.
Keep everything running
No action needed. Choose this if you want to keep building on TaskMotion or experiment with more animations.
- Your task_motion folder stays on your Desktop, ready for you to open and run anytime.
- You can continue adding features, trying new animation types, or connecting a real database.
Pause - I'll come back to this later
Stop any running emulators or dev servers to free up memory. Your project files stay intact for when you return.
- Close the Android Emulator if it is still running.
- Stop the Flutter dev server by pressing q in the terminal where you ran flutter run.
- Your task_motion folder remains on your Desktop. You can resume by running flutter run again from inside the project folder.
Delete - I don't want to use this again
Remove all project files and start fresh. This permanently deletes your TaskMotion code.
- Close any running emulators and stop the Flutter dev server.
- Delete the project folder by running the command for your operating system below.
macOS
- Open your terminal and run:
rm -rf ~/Desktop/task_motion
Windows
- Open PowerShell and run:
rmdir /s /q %USERPROFILE%\Desktop\task_motion
- Confirm the folder is gone by checking your Desktop. You should no longer see a task_motion folder.
That's a Wrap!
That's a Wrap!
Nice work! You just built TaskMotion, a polished Flutter app with production-quality animations driven by BLoC state management and Clean Architecture.
You've learned how to:
- Built a complete Flutter app with three animation types: staggered entrance animations using AnimationController and Interval, explicit checkmark animations with elastic curves, and implicit color transitions with AnimatedContainer.
- Implemented Clean Architecture with proper layer separation (Domain, Data, Presentation) and wired dependency injection with GetIt across all layers.
- Used the BLoC pattern with events and states to drive animations reactively through state changes, keeping business logic fully separated from the UI.
- Secret Mission: Added Hero transitions with physics-based spring animations to a task detail page.
Ready to quiz yourself?