Building a Todo App with Flutter BLoC: A Complete Guide

Flutter’s popularity has soared for cross-platform development, with developers seeking efficient state management solutions. The BLoC (Business Logic Component) pattern stands out as one of the most robust approaches for managing application state. In this article, we’ll build a complete Todo application using the BLoC pattern in Flutter.

Understanding the BLoC Pattern

The BLoC pattern was introduced by Google engineers at the 2018 DartConf. It separates business logic from the UI, making code more maintainable, testable, and reusable across platforms.

Key principles of the BLoC pattern:

  • Separation of concerns: UI components should only handle rendering, while business logic lives in BLoC classes
  • Dependency on abstractions: Components should depend on abstractions, not concrete implementations
  • Unidirectional data flow: Data flows in one direction, creating a predictable state management system
  • Asynchronous operations: BLoC handles asynchronous operations elegantly

Getting Started with flutter_bloc

The flutter_bloc package offers a streamlined implementation of the BLoC pattern. Let’s set up our Todo project to demonstrate its use.

Project Setup

First, add the necessary dependencies to your pubspec.yaml:

dependencies:
  flutter:
    sdk: flutter
  flutter_bloc: ^8.1.3
  equatable: ^2.0.5
  http: ^1.1.0

Run flutter pub get to install these packages.

Todo App Architecture

Our Todo app will follow a clean architecture with:

  1. Data Layer: Models and Repository
  2. Domain Layer: BLoC, Events, and States
  3. Presentation Layer: Screens and Widgets

Let’s build our Todo app step by step:

Step 1: Define the Todo Model

Create lib/models/todo_model.dart:

import 'package:equatable/equatable.dart';

class Todo extends Equatable {
  final int id;
  final String title;
  final bool completed;

  const Todo({
    required this.id,
    required this.title,
    required this.completed,
  });

  factory Todo.fromJson(Map<String, dynamic> json) {
    return Todo(
      id: json['id'] as int,
      title: json['title'] as String,
      completed: json['completed'] as bool,
    );
  }

  Map<String, dynamic> toJson() {
    return {
      'id': id,
      'title': title,
      'completed': completed,
    };
  }

  Todo copyWith({
    int? id,
    String? title,
    bool? completed,
  }) {
    return Todo(
      id: id ?? this.id,
      title: title ?? this.title,
      completed: completed ?? this.completed,
    );
  }

  @override
  List<Object> get props => [id, title, completed];
}

Step 2: Create the Todo Repository

Create lib/repositories/todo_repository.dart:

import 'dart:convert';
import 'package:http/http.dart' as http;
import '../models/todo_model.dart';

class TodoRepository {
  final http.Client httpClient;
  final String baseUrl = 'https://jsonplaceholder.typicode.com';

  TodoRepository({http.Client? httpClient})
      : httpClient = httpClient ?? http.Client();

  Future<List<Todo>> fetchTodos() async {
    final response = await httpClient.get(Uri.parse('$baseUrl/todos'));

    if (response.statusCode == 200) {
      final List<dynamic> todosJson = json.decode(response.body);
      return todosJson.map((json) => Todo.fromJson(json)).take(20).toList();
    } else {
      throw Exception('Failed to fetch todos');
    }
  }

  Future<Todo> createTodo(String title) async {
    final response = await httpClient.post(
      Uri.parse('$baseUrl/todos'),
      headers: {'Content-Type': 'application/json'},
      body: json.encode({
        'title': title,
        'completed': false,
      }),
    );

    if (response.statusCode == 201) {
      return Todo.fromJson(json.decode(response.body));
    } else {
      throw Exception('Failed to create todo');
    }
  }

  Future<void> deleteTodo(int id) async {
    final response = await httpClient.delete(
      Uri.parse('$baseUrl/todos/$id'),
    );

    if (response.statusCode != 200) {
      throw Exception('Failed to delete todo');
    }
  }

  Future<void> updateTodo(Todo todo) async {
    final response = await httpClient.put(
      Uri.parse('$baseUrl/todos/${todo.id}'),
      headers: {'Content-Type': 'application/json'},
      body: json.encode(todo.toJson()),
    );

    if (response.statusCode != 200) {
      throw Exception('Failed to update todo');
    }
  }
}

Step 3: Define BLoC Events

Create lib/bloc/todo_event.dart:

import 'package:equatable/equatable.dart';
import '../models/todo_model.dart';

abstract class TodoEvent extends Equatable {
  const TodoEvent();

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

class LoadTodos extends TodoEvent {}

class AddTodo extends TodoEvent {
  final String title;

  const AddTodo(this.title);

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

class UpdateTodo extends TodoEvent {
  final Todo todo;

  const UpdateTodo(this.todo);

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

class DeleteTodo extends TodoEvent {
  final int id;

  const DeleteTodo(this.id);

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

class ToggleTodo extends TodoEvent {
  final int id;

  const ToggleTodo(this.id);

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

Step 4: Define BLoC States

Create lib/bloc/todo_state.dart:

import 'package:equatable/equatable.dart';
import '../models/todo_model.dart';

enum TodoStatus { initial, loading, success, failure }

class TodoState extends Equatable {
  final TodoStatus status;
  final List<Todo> todos;
  final String errorMessage;

  const TodoState({
    this.status = TodoStatus.initial,
    this.todos = const [],
    this.errorMessage = '',
  });

  TodoState copyWith({
    TodoStatus? status,
    List<Todo>? todos,
    String? errorMessage,
  }) {
    return TodoState(
      status: status ?? this.status,
      todos: todos ?? this.todos,
      errorMessage: errorMessage ?? this.errorMessage,
    );
  }

  @override
  List<Object> get props => [status, todos, errorMessage];
}

Step 5: Implement the Todo BLoC

Create lib/bloc/todo_bloc.dart:

import 'package:flutter_bloc/flutter_bloc.dart';
import '../models/todo_model.dart';
import '../repositories/todo_repository.dart';
import 'todo_event.dart';
import 'todo_state.dart';

class TodoBloc extends Bloc<TodoEvent, TodoState> {
  final TodoRepository repository;

  TodoBloc({required this.repository}) : super(const TodoState()) {
    on<LoadTodos>(_onLoadTodos);
    on<AddTodo>(_onAddTodo);
    on<UpdateTodo>(_onUpdateTodo);
    on<DeleteTodo>(_onDeleteTodo);
    on<ToggleTodo>(_onToggleTodo);
  }

  Future<void> _onLoadTodos(
    LoadTodos event,
    Emitter<TodoState> emit,
  ) async {
    emit(state.copyWith(status: TodoStatus.loading));

    try {
      final todos = await repository.fetchTodos();
      emit(state.copyWith(
        status: TodoStatus.success,
        todos: todos,
      ));
    } catch (e) {
      emit(state.copyWith(
        status: TodoStatus.failure,
        errorMessage: e.toString(),
      ));
    }
  }

  Future<void> _onAddTodo(
    AddTodo event,
    Emitter<TodoState> emit,
  ) async {
    try {
      final todo = await repository.createTodo(event.title);

      final updatedTodos = List<Todo>.from(state.todos)..add(todo);

      emit(state.copyWith(
        todos: updatedTodos,
      ));
    } catch (e) {
      emit(state.copyWith(
        status: TodoStatus.failure,
        errorMessage: e.toString(),
      ));
    }
  }

  Future<void> _onUpdateTodo(
    UpdateTodo event,
    Emitter<TodoState> emit,
  ) async {
    try {
      await repository.updateTodo(event.todo);

      final updatedTodos = state.todos.map((todo) {
        return todo.id == event.todo.id ? event.todo : todo;
      }).toList();

      emit(state.copyWith(todos: updatedTodos));
    } catch (e) {
      emit(state.copyWith(
        status: TodoStatus.failure,
        errorMessage: e.toString(),
      ));
    }
  }

  Future<void> _onDeleteTodo(
    DeleteTodo event,
    Emitter<TodoState> emit,
  ) async {
    try {
      await repository.deleteTodo(event.id);

      final updatedTodos = state.todos.where((todo) => todo.id != event.id).toList();

      emit(state.copyWith(todos: updatedTodos));
    } catch (e) {
      emit(state.copyWith(
        status: TodoStatus.failure,
        errorMessage: e.toString(),
      ));
    }
  }

  Future<void> _onToggleTodo(
    ToggleTodo event,
    Emitter<TodoState> emit,
  ) async {
    try {
      final todo = state.todos.firstWhere((todo) => todo.id == event.id);
      final updatedTodo = todo.copyWith(completed: !todo.completed);

      await repository.updateTodo(updatedTodo);

      final updatedTodos = state.todos.map((t) {
        return t.id == event.id ? updatedTodo : t;
      }).toList();

      emit(state.copyWith(todos: updatedTodos));
    } catch (e) {
      emit(state.copyWith(
        status: TodoStatus.failure,
        errorMessage: e.toString(),
      ));
    }
  }
}

Step 6: Create the UI Components

Create the Todo List Screen

Create lib/screens/todo_list_screen.dart:

import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../bloc/todo_bloc.dart';
import '../bloc/todo_event.dart';
import '../bloc/todo_state.dart';
import '../models/todo_model.dart';

class TodoListScreen extends StatelessWidget {
  const TodoListScreen({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Todo List'),
      ),
      body: BlocConsumer<TodoBloc, TodoState>(
        listener: (context, state) {
          if (state.status == TodoStatus.failure) {
            ScaffoldMessenger.of(context).showSnackBar(
              SnackBar(content: Text(state.errorMessage)),
            );
          }
        },
        builder: (context, state) {
          if (state.status == TodoStatus.loading) {
            return const Center(child: CircularProgressIndicator());
          }

          if (state.todos.isEmpty) {
            return const Center(child: Text('No todos found'));
          }

          return ListView.builder(
            itemCount: state.todos.length,
            itemBuilder: (context, index) {
              final todo = state.todos[index];
              return TodoListItem(todo: todo);
            },
          );
        },
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () => _showAddTodoDialog(context),
        child: const Icon(Icons.add),
      ),
    );
  }

  void _showAddTodoDialog(BuildContext context) {
    final TextEditingController controller = TextEditingController();

    showDialog(
      context: context,
      builder: (context) {
        return AlertDialog(
          title: const Text('Add Todo'),
          content: TextField(
            controller: controller,
            decoration: const InputDecoration(
              labelText: 'Todo Title',
            ),
            autofocus: true,
          ),
          actions: [
            TextButton(
              onPressed: () => Navigator.pop(context),
              child: const Text('Cancel'),
            ),
            TextButton(
              onPressed: () {
                if (controller.text.isNotEmpty) {
                  context.read<TodoBloc>().add(AddTodo(controller.text));
                  Navigator.pop(context);
                }
              },
              child: const Text('Add'),
            ),
          ],
        );
      },
    );
  }
}

class TodoListItem extends StatelessWidget {
  final Todo todo;

  const TodoListItem({
    Key? key,
    required this.todo,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Dismissible(
      key: Key('todoItem_${todo.id}'),
      background: Container(
        color: Colors.red,
        alignment: Alignment.centerRight,
        padding: const EdgeInsets.only(right: 20.0),
        child: const Icon(
          Icons.delete,
          color: Colors.white,
        ),
      ),
      direction: DismissDirection.endToStart,
      onDismissed: (direction) {
        context.read<TodoBloc>().add(DeleteTodo(todo.id));
        ScaffoldMessenger.of(context).showSnackBar(
          SnackBar(content: Text('Todo "${todo.title}" deleted')),
        );
      },
      child: ListTile(
        leading: Checkbox(
          value: todo.completed,
          onChanged: (_) {
            context.read<TodoBloc>().add(ToggleTodo(todo.id));
          },
        ),
        title: Text(
          todo.title,
          style: TextStyle(
            decoration: todo.completed ? TextDecoration.lineThrough : null,
          ),
        ),
        onTap: () {
          context.read<TodoBloc>().add(ToggleTodo(todo.id));
        },
      ),
    );
  }
}

Step 7: Set Up the Main App

Create lib/main.dart:

import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'bloc/todo_bloc.dart';
import 'bloc/todo_event.dart';
import 'repositories/todo_repository.dart';
import 'screens/todo_list_screen.dart';

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

class MyApp extends StatelessWidget {
  const MyApp({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Todo BLoC',
      theme: ThemeData(
        primarySwatch: Colors.blue,
        useMaterial3: true,
      ),
      home: RepositoryProvider(
        create: (context) => TodoRepository(),
        child: BlocProvider(
          create: (context) => TodoBloc(
            repository: context.read<TodoRepository>(),
          )..add(LoadTodos()),
          child: const TodoListScreen(),
        ),
      ),
    );
  }
}

Step 8: Add BLoC Observer for Debugging (Optional)

Create lib/bloc/simple_bloc_observer.dart:

import 'package:flutter_bloc/flutter_bloc.dart';

class SimpleBlocObserver extends BlocObserver {
  @override
  void onEvent(Bloc bloc, Object? event) {
    super.onEvent(bloc, event);
    print('${bloc.runtimeType} $event');
  }

  @override
  void onTransition(Bloc bloc, Transition transition) {
    super.onTransition(bloc, transition);
    print('${bloc.runtimeType} $transition');
  }

  @override
  void onError(BlocBase bloc, Object error, StackTrace stackTrace) {
    print('${bloc.runtimeType} $error $stackTrace');
    super.onError(bloc, error, stackTrace);
  }
}

Update main.dart to use the observer:

import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'bloc/todo_bloc.dart';
import 'bloc/todo_event.dart';
import 'bloc/simple_bloc_observer.dart';
import 'repositories/todo_repository.dart';
import 'screens/todo_list_screen.dart';

void main() {
  Bloc.observer = SimpleBlocObserver();
  runApp(const MyApp());
}

// Rest of the code remains the same

Key Benefits of Using BLoC in the Todo App

  1. Separation of Concerns: The UI is completely separated from business logic
  2. Testability: Each component can be tested in isolation
  3. Maintainability: Code is organized and easy to maintain
  4. Reusability: BLoCs can be reused across different UI components
  5. Predictable State Management: Unidirectional data flow ensures predictable state changes

Best Practices for Flutter BLoC Architecture

  1. Keep BLoCs focused: Each BLoC should handle a specific feature or domain
  2. Use Equatable for performance: Implement Equatable for efficient state comparison
  3. Organize code by feature: Structure code by feature rather than by type
  4. Error handling: Always handle errors gracefully in the BLoC
  5. Smart repositories, dumb BLoCs: Business logic should be in the BLoC, data access in repositories
  6. Test your BLoCs: Write tests for your BLoCs to ensure they work as expected

Conclusion

In this tutorial, we built a complete Todo application using the BLoC pattern in Flutter. The BLoC pattern provides a robust solution for state management in Flutter applications, making your code more maintainable, testable, and scalable.

By separating the presentation layer from the business logic, we’ve created a clean architecture that can be easily extended with new features. The flutter_bloc package makes implementing the BLoC pattern straightforward, providing a set of widgets and utilities that simplify the process.

As your application grows in complexity, the BLoC pattern will continue to provide structure and organization, making it easier to add new features and maintain existing code.

Previous Article

Implementing Hive Database in Flutter Applications

Next Article

Appwrite vs. Supabase: Which Backend Should Flutter Developers Choose?

Write a Comment

Leave a Comment

Your email address will not be published. Required fields are marked *

Subscribe to our Newsletter

Subscribe to our email newsletter to get the latest posts delivered right to your email.
Pure inspiration, zero spam ✨